From f1b949c3e3d9c4f7b09b6d6b299b40da2e271213 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:00:21 +0530 Subject: [PATCH 01/46] tooling: write the merge gate down, with each rule's failure beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate for "may this PR be merged" has lived in one session's head, and it has been got wrong from both directions in a single day. On 2026-09-12 four PRs were merged before their reviews arrived — #312 by three seconds — leaving eleven findings, four of them P1, on code already in master. The gate added to stop that then deadlocked a clean PR, because a Codex pass with no findings submits no review object at all, only a summary row and a thumbs-up. Later the same day two separate sessions reported a PR "clean, zero open threads" from a query issued seconds before its review posted, and a third reported clean without noticing master had moved. One rule underneath all of it: **a review is only evidence about the commit it names.** "No findings yet" and "not looked yet" are indistinguishable unless you read which commit was looked at — so the script reads the commit in the summary table, not the review list and not the comment's timestamp, because that comment is edited in place and its `created_at` means nothing. Six checks, each carrying the incident that motivates it: 1. mergeable against its base; 2. based on master — `ci.yml` fires on `pull_request` into master only, so a stacked PR runs no CI at all and its green tick measures nothing; 3. every check concluded and none failing; 4. a Codex review naming *this* head, completed rather than running; 5. zero unresolved threads, paginated, since `required_conversation_resolution` makes this the gate rather than a courtesy — and a `first:100` page once hid 19 open threads; 6. a privacy scan of the **whole merge diff**, not the author's own commits: a real transaction reference sat in a comment on public master through two PRs because each author scanned only what they wrote. The scan was wrong twice while being written, both times in the direction that reports clean: - `XXXXX1234X` is a fabricated PAN and `X` is an uppercase letter, so it matched the PAN shape. A gate that cries wolf gets ignored, which is worse than no gate, so obvious placeholders are excluded — but by an explicit list, never by widening the shape, which would start excusing real values. - the first placeholder pattern used a backreference, which is not ERE. grep errored, returned no matches, and the scan reported clean. That is the same shape as a control reporting zero because its branch never fired, so the pattern is now probed against a string it must match before being trusted. - UUID tails are twelve hex characters and often all digits; the canonical `550e8400-…-446655440000` tripped it. UUIDs are stripped before scanning. Verified in both directions rather than assumed: a diff carrying a real-shaped GSTIN, mobile number and account number is flagged; one carrying only placeholders and a UUID is not. Run against three live PRs it blocks each for a different, correct reason — a running review, unresolved threads, and a non-master base. Usage: `scripts/merge-gate.sh [owner/name]`; exit 0 may merge, 1 must not. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 144 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100755 scripts/merge-gate.sh diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh new file mode 100755 index 000000000..3eb16739b --- /dev/null +++ b/scripts/merge-gate.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Decide whether a pull request may be merged, and say why not when it may not. +# +# Every condition here exists because it failed. On 2026-09-12 four PRs were +# merged before their reviews arrived — #312 by three seconds — leaving eleven +# findings, four of them P1, on code already in master. The gate added to stop +# that then deadlocked a clean PR, because a Codex pass with no findings +# submits no review object at all. Later the same day two separate sessions +# reported a PR "clean, zero open threads" from a query issued seconds before +# the review posted. +# +# The single rule underneath all of it: a review is only evidence about the +# commit it names. Freshness is not implied by the absence of findings, and +# "no findings yet" is indistinguishable from "not looked yet" unless you +# check which commit was looked at. +# +# Usage: scripts/merge-gate.sh [--repo owner/name] +# Exit: 0 = may merge, 1 = must not, 2 = could not determine. + +set -uo pipefail + +PR="${1:-}" +REPO="${2:-}" +[ -n "$PR" ] || { echo "usage: $0 [owner/name]" >&2; exit 2; } +if [ -n "$REPO" ]; then R=(--repo "$REPO"); else R=(); fi + +fail=0 +say() { printf ' %-6s %s\n' "$1" "$2"; } +bad() { say "BLOCK" "$1"; fail=1; } +good() { say "ok" "$1"; } + +meta=$(gh pr view "$PR" "${R[@]}" --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft 2>/dev/null) || { + echo "could not read PR #$PR" >&2; exit 2; } +head=$(jq -r .headRefOid <<<"$meta") +base=$(jq -r .baseRefName <<<"$meta") +mergeable=$(jq -r .mergeable <<<"$meta") +state=$(jq -r .mergeStateStatus <<<"$meta") +draft=$(jq -r .isDraft <<<"$meta") +short=${head:0:7} + +echo "PR #$PR head=$short base=$base $mergeable/$state" + +[ "$draft" = "false" ] || bad "draft" + +# 1. Mergeable against its base. +case "$mergeable" in + MERGEABLE) good "no conflicts" ;; + CONFLICTING) bad "conflicts with $base — rebase first" ;; + *) bad "mergeability still UNKNOWN; re-run in a moment" ;; +esac + +# 2. Based on master. ci.yml fires on pull_request into master ONLY, so a +# stacked PR runs no CI at all and a green tick on it measures nothing. +if [ "$base" = "master" ]; then + good "based on master (CI actually runs)" +else + bad "based on '$base', not master — ci.yml does not fire, so checks here prove nothing" +fi + +# 3. Every check concluded, none failed. SKIPPED is fine; PENDING is not. +checks=$(gh pr checks "$PR" "${R[@]}" 2>/dev/null) +if [ -z "$checks" ]; then + bad "no checks reported" +else + pend=$(grep -cE '[[:space:]](pending|queued|in_progress)[[:space:]]' <<<"$checks") + bust=$(grep -cE '[[:space:]](fail|failure|cancelled|timed_out)[[:space:]]' <<<"$checks") + [ "$pend" -eq 0 ] || bad "$pend check(s) still running" + [ "$bust" -eq 0 ] || bad "$bust check(s) failing" + [ "$pend" -eq 0 ] && [ "$bust" -eq 0 ] && good "all checks concluded, none failing" +fi + +# 4. A Codex review that names THIS head. The summary comment is edited in +# place, so its created_at is meaningless — read the commit in its table. +# A clean pass emits no review object, only this row plus a thumbs-up, which +# is why the row and not the review list is the thing to read. +body=$(gh api "repos/${REPO:-lamemustafa/bridge}/issues/$PR/comments" \ + --jq '.[] | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>/dev/null) +row=$(grep -E '^\| (📝|🔍)' <<<"$body" | tail -1) +if [ -z "$row" ]; then + bad "no Codex review summary at all" +elif ! grep -q "$short" <<<"$row"; then + bad "latest review names a different commit than $short — it has not seen this push" +elif grep -q "Running" <<<"$row"; then + bad "review still running on $short — 'no findings yet' is not 'no findings'" +elif grep -qE "Completed|Failed" <<<"$row"; then + good "review completed on $short" +else + bad "could not read review state from: $row" +fi + +# 5. No unresolved threads. required_conversation_resolution is on, so this is +# the gate, not a courtesy. Paginate: a first:100 page once hid 19 threads. +threads=$(gh api graphql -f query="{repository(owner:\"${REPO%%/*}\",name:\"${REPO##*/}\"){pullRequest(number:$PR){reviewThreads(first:100){totalCount pageInfo{hasNextPage} nodes{isResolved}}}}}" 2>/dev/null \ + || gh api graphql -f query="{repository(owner:\"lamemustafa\",name:\"bridge\"){pullRequest(number:$PR){reviewThreads(first:100){totalCount pageInfo{hasNextPage} nodes{isResolved}}}}}" 2>/dev/null) +if [ -z "$threads" ]; then + bad "could not read review threads" +else + total=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$threads") + more=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$threads") + open=$(jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)]|length' <<<"$threads") + [ "$more" = "false" ] || bad "more than 100 threads — paginate before trusting this count" + if [ "$open" -eq 0 ]; then good "0 of $total threads unresolved"; else bad "$open of $total threads unresolved"; fi +fi + +# 6. Nothing shaped like client data in the diff. Scan the WHOLE merge diff, +# not your own commits: a real transaction reference sat in a comment on +# public master through two PRs because each author scanned only what they +# wrote. An identifier is findable by shape; no name list catches it. +diff=$(gh pr diff "$PR" "${R[@]}" 2>/dev/null) +# A UUID's last group is twelve hex characters and is all digits often enough +# to look like an account number — the canonical `550e8400-…-446655440000` +# tripped this on its first run. Remove UUIDs before scanning rather than +# widening the placeholder list, which would start excusing real values. +diff=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' <<<"$diff") +if [ -z "$diff" ]; then + bad "could not read diff for the privacy scan" +else + # Placeholders match these shapes too — `XXXXX1234X` is a fabricated PAN and + # `X` is an uppercase letter. A gate that cries wolf gets ignored, which is + # worse than no gate, so drop anything whose letters are one repeated + # character or whose digits are a repeat or a straight run. + # No backreferences: this must be plain ERE or grep errors, and a grep that + # errors returns no matches — which reads exactly like a clean scan. That is + # the same shape as a control reporting zero because its branch never fired, + # so the placeholder list is spelled out instead of `([0-9])\\1+`. + placeholder='^(X+|Z+|A+)[0-9]+(X|Z|A)?$|^[0-9]{2}(X+|Z+|A+)[0-9]+[0-9A-Z]*$|^(0+|1+|2+|3+|4+|5+|6+|7+|8+|9+)$|^(0?1234567890|1234567890[0-9]*)$' + hits=$(grep -Eo '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|\b[A-Z]{5}[0-9]{4}[A-Z]\b|\b[6-9][0-9]{9}\b' <<<"$diff" \ + | sort -u | { grep -cvE "$placeholder" || true; }) + runs=$(grep -oE '\b[0-9]{11,18}\b' <<<"$diff" | sort -u | { grep -cvE "$placeholder" || true; }) + # Prove the pattern itself compiles; a silent regex error is the failure mode + # this whole block exists to avoid. + # The probe string must be one the pattern genuinely matches, or the probe + # fails on a perfectly good pattern — which is how this check first behaved. + printf 'XXXXX1234X\n' | grep -qE "$placeholder" \ + || { bad "privacy-scan pattern failed to compile or match its own probe"; hits=-1; } + if [ "$hits" -eq 0 ] && [ "$runs" -eq 0 ]; then + good "no GSTIN/PAN/mobile shapes, no unexplained long digit runs" + else + bad "privacy scan: $hits identifier shape(s), $runs unexplained long digit run(s) — inspect before merging" + fi +fi + +echo +if [ "$fail" -eq 0 ]; then echo "MAY MERGE"; exit 0; else echo "MUST NOT MERGE"; exit 1; fi From b2ea6067c376a70b67eda13b6f58b6db45843af5 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:18:47 +0530 Subject: [PATCH 02/46] tooling: fix ten defects in the gate, including one it was written to catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight findings on the first revision, plus two more found by testing it rather than reading it. A script whose whole purpose is rigour had ten holes, and the two that matter most were found by running it against the very case it exists for. **The scan could not see the leak that motivated it.** The pattern was `\b[0-9]{11,18}\b`. The reference it was written to catch is written `HDF CH12345678901` — glued to letters — and `\b` does not match between `H` and `1`. Verified directly: the old pattern returns nothing on that string. The gate would have passed the exact commit it was built to stop. The word boundaries are gone. **It scanned removals as well as additions,** so it blocked the PR that *deletes* leaked data while passing the one that adds it. Added lines only now. Then the eight from review, each confirmed against the tool it names: - `BEHIND` was not blocked. `mergeable` can read `MERGEABLE` while `mergeStateStatus` is `BEHIND`, meaning the head never saw the current base — so its CI and its review describe a tree that no longer exists. - `gh pr checks` buckets are `pass|fail|pending|skipping|cancel`. The regex looked for `cancelled`, so a cancelled check counted as neither failing nor pending and read as success. Now the JSON buckets are read instead of the text columns, which a check name containing spaces mis-splits anyway. - A review reported `Failed` was treated exactly like `Completed`. A failed run means nothing looked at the code — the original bug inverted. Only `Completed` passes. - `--repo owner/name` was documented and not parsed: `REPO` became the literal `--repo`. Proper option parsing, and `OWNER/NAME` is validated. - On a transient GraphQL failure the thread query fell back to a hardcoded repository with the same PR number. A same-numbered PR elsewhere with no open threads would have read as a clean result. The fallback is gone. - The issue-comments query was unpaginated; the Codex summary is an ordinary comment and the default page is 30. - Nothing bound the merge to the reviewed commit. The script now prints the merge command carrying `--match-head-commit `. - Draft and non-OPEN states were not checked. Dropping `\b` made hex digests visible — a sha256 in a lockfile contains long digit runs by chance — so digests and UUIDs are stripped before scanning. Both are machine-generated and neither can carry a client identifier; the placeholder list was deliberately *not* loosened, because loosening the shape starts excusing real values. Verified in both directions rather than assumed. On a 4,799-line real diff: zero false positives. On constructed input: the glued-to-letters reference, a real-shaped GSTIN and a real-shaped mobile number are all flagged, while a PR that only removes a leak is not. Every exit path exercised — malformed `--repo`, missing value, unknown option, no arguments all return 2; a stale head returns 1. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 192 ++++++++++++++++++++++++++---------------- 1 file changed, 121 insertions(+), 71 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 3eb16739b..516e9065d 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -1,53 +1,81 @@ #!/usr/bin/env bash # Decide whether a pull request may be merged, and say why not when it may not. # -# Every condition here exists because it failed. On 2026-09-12 four PRs were -# merged before their reviews arrived — #312 by three seconds — leaving eleven -# findings, four of them P1, on code already in master. The gate added to stop -# that then deadlocked a clean PR, because a Codex pass with no findings -# submits no review object at all. Later the same day two separate sessions -# reported a PR "clean, zero open threads" from a query issued seconds before -# the review posted. +# Every rule here exists because it failed. On 2026-09-12 four PRs were merged +# before their reviews arrived — one by three seconds — leaving eleven findings, +# four of them P1, on code already in master. The gate added to stop that then +# deadlocked a clean PR, because a Codex pass with no findings submits no review +# object at all. Later the same day two sessions reported a PR "clean, zero open +# threads" from a query issued seconds before its review posted. # -# The single rule underneath all of it: a review is only evidence about the -# commit it names. Freshness is not implied by the absence of findings, and -# "no findings yet" is indistinguishable from "not looked yet" unless you +# The rule underneath all of it: a review is only evidence about the commit it +# names. "No findings yet" and "not looked yet" are indistinguishable unless you # check which commit was looked at. # -# Usage: scripts/merge-gate.sh [--repo owner/name] -# Exit: 0 = may merge, 1 = must not, 2 = could not determine. +# Usage: scripts/merge-gate.sh [--repo OWNER/NAME] +# Exit: 0 may merge, 1 must not, 2 could not determine. set -uo pipefail -PR="${1:-}" -REPO="${2:-}" -[ -n "$PR" ] || { echo "usage: $0 [owner/name]" >&2; exit 2; } -if [ -n "$REPO" ]; then R=(--repo "$REPO"); else R=(); fi +PR=""; REPO="" +while [ $# -gt 0 ]; do + case "$1" in + --repo) REPO="${2:-}"; [ -n "$REPO" ] || { echo "--repo needs OWNER/NAME" >&2; exit 2; }; shift 2 ;; + --repo=*) REPO="${1#--repo=}"; shift ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + -*) echo "unknown option: $1" >&2; exit 2 ;; + *) [ -z "$PR" ] && PR="$1" || { echo "unexpected argument: $1" >&2; exit 2; }; shift ;; + esac +done +[ -n "$PR" ] || { echo "usage: $0 [--repo OWNER/NAME]" >&2; exit 2; } + +# Resolve the repository ONCE, explicitly. Never fall back to a different +# repository on a transient failure: a same-numbered PR elsewhere with no open +# threads would read as a clean result for the PR actually being gated. +if [ -z "$REPO" ]; then + REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) \ + || { echo "could not determine repository; pass --repo OWNER/NAME" >&2; exit 2; } +fi +OWNER="${REPO%%/*}"; NAME="${REPO##*/}" +[ -n "$OWNER" ] && [ -n "$NAME" ] && [ "$OWNER" != "$REPO" ] \ + || { echo "--repo must be OWNER/NAME, got '$REPO'" >&2; exit 2; } fail=0 say() { printf ' %-6s %s\n' "$1" "$2"; } bad() { say "BLOCK" "$1"; fail=1; } good() { say "ok" "$1"; } +die() { echo "$1" >&2; exit 2; } -meta=$(gh pr view "$PR" "${R[@]}" --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft 2>/dev/null) || { - echo "could not read PR #$PR" >&2; exit 2; } +meta=$(gh pr view "$PR" --repo "$REPO" \ + --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state 2>/dev/null) \ + || die "could not read PR #$PR in $REPO" head=$(jq -r .headRefOid <<<"$meta") base=$(jq -r .baseRefName <<<"$meta") mergeable=$(jq -r .mergeable <<<"$meta") -state=$(jq -r .mergeStateStatus <<<"$meta") +mstate=$(jq -r .mergeStateStatus <<<"$meta") draft=$(jq -r .isDraft <<<"$meta") +pstate=$(jq -r .state <<<"$meta") short=${head:0:7} -echo "PR #$PR head=$short base=$base $mergeable/$state" +echo "PR #$PR ($REPO) head=$short base=$base $mergeable/$mstate" +[ "$pstate" = "OPEN" ] || bad "PR is $pstate, not OPEN" [ "$draft" = "false" ] || bad "draft" -# 1. Mergeable against its base. +# 1. Mergeable, and not merely "no conflicts". BEHIND means the head has not +# seen the current base, so its CI and its review describe a tree that no +# longer exists — the branch protection is strict and would refuse anyway. case "$mergeable" in MERGEABLE) good "no conflicts" ;; CONFLICTING) bad "conflicts with $base — rebase first" ;; *) bad "mergeability still UNKNOWN; re-run in a moment" ;; esac +case "$mstate" in + BEHIND) bad "head is BEHIND $base — its checks and review describe a stale tree; rebase" ;; + DIRTY) bad "merge state DIRTY — conflicts" ;; + UNKNOWN) bad "merge state UNKNOWN; re-run in a moment" ;; + *) good "merge state $mstate is not stale" ;; +esac # 2. Based on master. ci.yml fires on pull_request into master ONLY, so a # stacked PR runs no CI at all and a green tick on it measures nothing. @@ -57,43 +85,56 @@ else bad "based on '$base', not master — ci.yml does not fire, so checks here prove nothing" fi -# 3. Every check concluded, none failed. SKIPPED is fine; PENDING is not. -checks=$(gh pr checks "$PR" "${R[@]}" 2>/dev/null) -if [ -z "$checks" ]; then +# 3. Every check concluded and none failed. Read the JSON buckets rather than +# the human columns: a check name contains spaces, so column-splitting the +# text output misreads the bucket. gh's buckets are pass/fail/pending/ +# skipping/cancel — note `cancel`, not `cancelled`; matching the longer word +# left a cancelled check counted as neither failing nor pending, which read +# as success. +buckets=$(gh pr checks "$PR" --repo "$REPO" --json bucket,name 2>/dev/null) +if [ -z "$buckets" ] || [ "$buckets" = "[]" ]; then bad "no checks reported" else - pend=$(grep -cE '[[:space:]](pending|queued|in_progress)[[:space:]]' <<<"$checks") - bust=$(grep -cE '[[:space:]](fail|failure|cancelled|timed_out)[[:space:]]' <<<"$checks") - [ "$pend" -eq 0 ] || bad "$pend check(s) still running" - [ "$bust" -eq 0 ] || bad "$bust check(s) failing" - [ "$pend" -eq 0 ] && [ "$bust" -eq 0 ] && good "all checks concluded, none failing" + pend=$(jq '[.[]|select(.bucket=="pending")]|length' <<<"$buckets") + bust=$(jq '[.[]|select(.bucket=="fail" or .bucket=="cancel")]|length' <<<"$buckets") + tot=$(jq 'length' <<<"$buckets") + [ "$pend" -eq 0 ] || bad "$pend of $tot check(s) still running" + [ "$bust" -eq 0 ] || bad "$bust of $tot check(s) failed or were cancelled: $(jq -r '[.[]|select(.bucket=="fail" or .bucket=="cancel")|.name]|join(", ")' <<<"$buckets")" + [ "$pend" -eq 0 ] && [ "$bust" -eq 0 ] && good "all $tot checks concluded, none failing or cancelled" fi -# 4. A Codex review that names THIS head. The summary comment is edited in -# place, so its created_at is meaningless — read the commit in its table. -# A clean pass emits no review object, only this row plus a thumbs-up, which -# is why the row and not the review list is the thing to read. -body=$(gh api "repos/${REPO:-lamemustafa/bridge}/issues/$PR/comments" \ +# 4. A Codex review that names THIS head, and actually completed. The summary +# comment is edited in place, so its created_at is meaningless — read the +# commit in its table. A clean pass emits no review object, only this row +# plus a thumbs-up, which is why the row and not the review list is read. +# Paginate: the summary is an ordinary issue comment and the default page is +# 30, so on a busy PR it is not on the first one. +body=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ --jq '.[] | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>/dev/null) row=$(grep -E '^\| (📝|🔍)' <<<"$body" | tail -1) if [ -z "$row" ]; then bad "no Codex review summary at all" elif ! grep -q "$short" <<<"$row"; then bad "latest review names a different commit than $short — it has not seen this push" -elif grep -q "Running" <<<"$row"; then - bad "review still running on $short — 'no findings yet' is not 'no findings'" -elif grep -qE "Completed|Failed" <<<"$row"; then +elif grep -q 'Completed' <<<"$row"; then good "review completed on $short" else - bad "could not read review state from: $row" + # Running, Failed, Errored — none of these is a review. A failed review run + # means nothing looked at the code, which is exactly the state this gate + # exists to catch; treating it as completed was the original bug inverted. + st=$(grep -oE 'Running|Failed|Errored|Cancelled' <<<"$row" | head -1) + bad "review state '${st:-unrecognised}' on $short — only Completed counts" fi # 5. No unresolved threads. required_conversation_resolution is on, so this is # the gate, not a courtesy. Paginate: a first:100 page once hid 19 threads. -threads=$(gh api graphql -f query="{repository(owner:\"${REPO%%/*}\",name:\"${REPO##*/}\"){pullRequest(number:$PR){reviewThreads(first:100){totalCount pageInfo{hasNextPage} nodes{isResolved}}}}}" 2>/dev/null \ - || gh api graphql -f query="{repository(owner:\"lamemustafa\",name:\"bridge\"){pullRequest(number:$PR){reviewThreads(first:100){totalCount pageInfo{hasNextPage} nodes{isResolved}}}}}" 2>/dev/null) -if [ -z "$threads" ]; then - bad "could not read review threads" +threads=$(gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" -f query=' + query($owner:String!,$name:String!,$pr:Int!){ + repository(owner:$owner,name:$name){ + pullRequest(number:$pr){ + reviewThreads(first:100){ totalCount pageInfo{hasNextPage} nodes{isResolved} }}}}' 2>/dev/null) +if [ -z "$threads" ] || [ "$(jq -r '.data.repository.pullRequest' <<<"$threads")" = "null" ]; then + bad "could not read review threads for $REPO#$PR" else total=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$threads") more=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$threads") @@ -102,43 +143,52 @@ else if [ "$open" -eq 0 ]; then good "0 of $total threads unresolved"; else bad "$open of $total threads unresolved"; fi fi -# 6. Nothing shaped like client data in the diff. Scan the WHOLE merge diff, -# not your own commits: a real transaction reference sat in a comment on -# public master through two PRs because each author scanned only what they -# wrote. An identifier is findable by shape; no name list catches it. -diff=$(gh pr diff "$PR" "${R[@]}" 2>/dev/null) -# A UUID's last group is twelve hex characters and is all digits often enough -# to look like an account number — the canonical `550e8400-…-446655440000` -# tripped this on its first run. Remove UUIDs before scanning rather than -# widening the placeholder list, which would start excusing real values. -diff=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' <<<"$diff") +# 6. Nothing shaped like client data in what this PR ADDS. Scan the whole merge +# diff rather than the author's own commits — a real transaction reference +# sat in a comment on public master through two PRs because each author +# scanned only what they wrote — but scan ADDED lines only, or the gate +# blocks the very PR that deletes a leak. +diff=$(gh pr diff "$PR" --repo "$REPO" 2>/dev/null) if [ -z "$diff" ]; then bad "could not read diff for the privacy scan" else - # Placeholders match these shapes too — `XXXXX1234X` is a fabricated PAN and - # `X` is an uppercase letter. A gate that cries wolf gets ignored, which is - # worse than no gate, so drop anything whose letters are one repeated - # character or whose digits are a repeat or a straight run. - # No backreferences: this must be plain ERE or grep errors, and a grep that - # errors returns no matches — which reads exactly like a clean scan. That is - # the same shape as a control reporting zero because its branch never fired, - # so the placeholder list is spelled out instead of `([0-9])\\1+`. + # A UUID's last group is twelve hex characters and often all digits; the + # canonical 550e8400-…-446655440000 tripped this. Strip UUIDs rather than + # widening the placeholder list, which would start excusing real values. + # Hex digests are the other machine-generated shape that trips this: a + # sha256 in a lockfile or a sealed surface manifest contains long digit runs + # by chance, and dropping \b (see below) made them visible. Strip digests and + # UUIDs — both are generated, neither can carry a client identifier — rather + # than loosening the placeholder list, which would start excusing real values. + added=$(grep '^+' <<<"$diff" | grep -v '^+++' \ + | sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' \ + | sed -E 's/[0-9a-fA-F]{32,}//g') + # Placeholders match these shapes too — XXXXX1234X is a fabricated PAN and X + # is an uppercase letter. A gate that cries wolf gets ignored, so obvious + # placeholders are excluded by an EXPLICIT list; widening the shape itself + # would start excusing real values. No backreferences: this must be plain + # ERE, and a grep that errors returns nothing, which reads as a clean scan. placeholder='^(X+|Z+|A+)[0-9]+(X|Z|A)?$|^[0-9]{2}(X+|Z+|A+)[0-9]+[0-9A-Z]*$|^(0+|1+|2+|3+|4+|5+|6+|7+|8+|9+)$|^(0?1234567890|1234567890[0-9]*)$' - hits=$(grep -Eo '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|\b[A-Z]{5}[0-9]{4}[A-Z]\b|\b[6-9][0-9]{9}\b' <<<"$diff" \ - | sort -u | { grep -cvE "$placeholder" || true; }) - runs=$(grep -oE '\b[0-9]{11,18}\b' <<<"$diff" | sort -u | { grep -cvE "$placeholder" || true; }) - # Prove the pattern itself compiles; a silent regex error is the failure mode - # this whole block exists to avoid. - # The probe string must be one the pattern genuinely matches, or the probe - # fails on a perfectly good pattern — which is how this check first behaved. printf 'XXXXX1234X\n' | grep -qE "$placeholder" \ - || { bad "privacy-scan pattern failed to compile or match its own probe"; hits=-1; } + || { bad "privacy-scan pattern failed to compile or match its own probe"; fail=1; } + # NO \b around the digit run. The leak that motivated this gate was written + # `HDF CH12345678901` — glued to letters — and \b does not match between `H` + # and `1`, so the scan that was supposed to catch it could not see it at all. + hits=$(grep -Eo '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|[A-Z]{5}[0-9]{4}[A-Z]|[6-9][0-9]{9}' <<<"$added" \ + | sort -u | { grep -cvE "$placeholder" || true; }) + runs=$(grep -Eo '[0-9]{11,18}' <<<"$added" | sort -u | { grep -cvE "$placeholder" || true; }) if [ "$hits" -eq 0 ] && [ "$runs" -eq 0 ]; then - good "no GSTIN/PAN/mobile shapes, no unexplained long digit runs" + good "added lines carry no identifier shapes and no unexplained long digit runs" else - bad "privacy scan: $hits identifier shape(s), $runs unexplained long digit run(s) — inspect before merging" + bad "privacy scan: $hits identifier shape(s), $runs unexplained long digit run(s) in ADDED lines — inspect before merging" fi fi echo -if [ "$fail" -eq 0 ]; then echo "MAY MERGE"; exit 0; else echo "MUST NOT MERGE"; exit 1; fi +if [ "$fail" -ne 0 ]; then echo "MUST NOT MERGE"; exit 1; fi +# 7. Bind the merge to the commit that was actually reviewed. Between this +# check and the merge the head can move, and everything above would then +# describe a commit the PR no longer points at. +echo "MAY MERGE — bind the merge to the reviewed commit:" +echo " gh pr merge $PR --repo $REPO --squash --match-head-commit $head" +exit 0 From 04c5e31fc3086ab9cc9d3a6c39c00f776f67291d Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:19:52 +0530 Subject: [PATCH 03/46] tooling: excuse padded fixtures, and keep the scan's own file clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the gate across the whole open queue surfaced two false positives, both worth fixing rather than tolerating — a gate that cries wolf gets ignored, which is the failure mode this one is least able to afford. `00000000005551` in a constructed test page is a padded MICR fixture, not an account number. Excused by `^0{6,}[0-9]{1,5}$`, kept deliberately narrow: a real account number can begin with a zero or two, so only a run of six or more leading zeros — plainly synthetic — is excused. Verified that three real-shaped account numbers with one and two leading zeros are still flagged. The other was this script flagging itself. A comment named the canonical RFC example UUID with its middle elided, so the UUID stripper could not match it while its digits still read as an identifier. The comment no longer carries example digits at all: a literal in a comment is a literal in the diff, and this scan reads its own file like any other — which is exactly how the reference that motivated the gate reached public master in the first place. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 516e9065d..3f4d63958 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -153,13 +153,16 @@ if [ -z "$diff" ]; then bad "could not read diff for the privacy scan" else # A UUID's last group is twelve hex characters and often all digits; the - # canonical 550e8400-…-446655440000 tripped this. Strip UUIDs rather than + # canonical RFC example UUID tripped this. Strip UUIDs rather than # widening the placeholder list, which would start excusing real values. # Hex digests are the other machine-generated shape that trips this: a # sha256 in a lockfile or a sealed surface manifest contains long digit runs - # by chance, and dropping \b (see below) made them visible. Strip digests and - # UUIDs — both are generated, neither can carry a client identifier — rather - # than loosening the placeholder list, which would start excusing real values. + # by chance, and dropping \b (see below) made them visible. The canonical RFC + # example UUID tripped it the same way. Strip digests and UUIDs — both are + # generated, neither can carry a client identifier — rather than loosening the + # placeholder list, which would start excusing real values. (Deliberately no + # example digits in this comment: a literal here is a literal in the diff, + # and this scan reads its own file like any other.) added=$(grep '^+' <<<"$diff" | grep -v '^+++' \ | sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' \ | sed -E 's/[0-9a-fA-F]{32,}//g') @@ -168,7 +171,11 @@ else # placeholders are excluded by an EXPLICIT list; widening the shape itself # would start excusing real values. No backreferences: this must be plain # ERE, and a grep that errors returns nothing, which reads as a clean scan. - placeholder='^(X+|Z+|A+)[0-9]+(X|Z|A)?$|^[0-9]{2}(X+|Z+|A+)[0-9]+[0-9A-Z]*$|^(0+|1+|2+|3+|4+|5+|6+|7+|8+|9+)$|^(0?1234567890|1234567890[0-9]*)$' + # `^0{6,}` is the padded-fixture shape: six or more leading zeros then a small + # number, as constructed test pages use for MICR and postcode fields. Kept + # narrow on purpose — a real account number can begin with a zero or two, so + # only a run long enough to be plainly synthetic is excused. + placeholder='^(X+|Z+|A+)[0-9]+(X|Z|A)?$|^[0-9]{2}(X+|Z+|A+)[0-9]+[0-9A-Z]*$|^(0+|1+|2+|3+|4+|5+|6+|7+|8+|9+)$|^(0?1234567890|1234567890[0-9]*)$|^0{6,}[0-9]{1,5}$' printf 'XXXXX1234X\n' | grep -qE "$placeholder" \ || { bad "privacy-scan pattern failed to compile or match its own probe"; fail=1; } # NO \b around the digit run. The leak that motivated this gate was written From c6cdddc4b7e57125cd66f0563703a7488f968474 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:33:16 +0530 Subject: [PATCH 04/46] tooling: close two spoofable holes and stop exempting silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round two on the gate: seven findings, two of them holes a determined actor could walk through, and one the gate found in its own author the moment it ran. **The review row was spoofable.** The summary was located by a marker string in a comment body — and a marker is just text. Any PR participant could post an ordinary comment carrying that marker and a `Completed` row for the current SHA, and the gate would have recorded a review that never happened. It now requires the comment's author to be `chatgpt-codex-connector[bot]` with a `Bot` account type, checked alongside the marker. **A binary file was an invisible pass.** `gh pr diff` emits `Binary files … differ` instead of content, so a screenshot or PDF of a client statement produced a scan with nothing to find and a clean verdict — the exact shape of a control reporting zero because it cannot see, which this repository has written down twice. Binary changes now block and are named for inspection by hand. **Exemptions are reported rather than silent.** Stripping UUIDs and hex digests keeps the false-positive rate low enough that the gate gets read at all, but a blanket exemption nobody can see is how a real value gets erased. The count of exempted lines is now printed. Four more, each confirmed: - `mergeStateStatus=BLOCKED` fell into a wildcard that printed `ok`, reading as approval for a state GitHub is refusing. It now prints a note, and an unrecognised state blocks rather than being guessed at. - `reviewThreads` blocked permanently once a PR passed 100 threads, since `hasNextPage` stays true however many are resolved. A PR accumulates threads by being reviewed carefully, so the rule punished exactly the PRs it should trust. It paginates now. - `gh pr checks` exits nonzero both when a check fails and when the query fails, so empty stdout from a broken query was reported as "no checks" — a statement about the PR rather than about the request. The two are now distinguished by whether anything reached stderr. - AGENTS.md:23 requires every PR to link a completed line in `review-checklist.md`, and the gate did not check the repository's own stated pre-merge rule. It does now — and immediately blocked this PR, whose description did not carry one. Each fix exercised rather than assumed: the author filter admits only the Bot account; the binary matcher fires on a binary patch and not on a text one; the thread loop terminates and counts across pages; the checklist rule blocks a PR without the link. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 97 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 77 insertions(+), 20 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 3f4d63958..24a891a7a 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -41,13 +41,15 @@ OWNER="${REPO%%/*}"; NAME="${REPO##*/}" || { echo "--repo must be OWNER/NAME, got '$REPO'" >&2; exit 2; } fail=0 +blocked_seen=0 +errfile=$(mktemp); trap 'rm -f "$errfile"' EXIT say() { printf ' %-6s %s\n' "$1" "$2"; } bad() { say "BLOCK" "$1"; fail=1; } good() { say "ok" "$1"; } die() { echo "$1" >&2; exit 2; } meta=$(gh pr view "$PR" --repo "$REPO" \ - --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state 2>/dev/null) \ + --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body 2>/dev/null) \ || die "could not read PR #$PR in $REPO" head=$(jq -r .headRefOid <<<"$meta") base=$(jq -r .baseRefName <<<"$meta") @@ -74,7 +76,15 @@ case "$mstate" in BEHIND) bad "head is BEHIND $base — its checks and review describe a stale tree; rebase" ;; DIRTY) bad "merge state DIRTY — conflicts" ;; UNKNOWN) bad "merge state UNKNOWN; re-run in a moment" ;; - *) good "merge state $mstate is not stale" ;; + BLOCKED) + # GitHub blocks for reasons this script may not model — a missing required + # approval, for instance. The checks below usually explain it, but printing + # `ok` for BLOCKED reads as approval for a state GitHub is refusing, so say + # what it is and let the specific checks account for it. + say "note" "merge state BLOCKED — GitHub is refusing; the checks below should say why" + blocked_seen=1 ;; + CLEAN|HAS_HOOKS|UNSTABLE) good "merge state $mstate is not stale" ;; + *) bad "unrecognised merge state '$mstate' — refusing rather than guessing" ;; esac # 2. Based on master. ci.yml fires on pull_request into master ONLY, so a @@ -91,9 +101,19 @@ fi # skipping/cancel — note `cancel`, not `cancelled`; matching the longer word # left a cancelled check counted as neither failing nor pending, which read # as success. -buckets=$(gh pr checks "$PR" --repo "$REPO" --json bucket,name 2>/dev/null) -if [ -z "$buckets" ] || [ "$buckets" = "[]" ]; then - bad "no checks reported" +# `gh pr checks` exits nonzero both when a check is failing and when the query +# itself fails, so the status alone cannot be read as a verdict — but empty +# stdout from a broken query must never be reported as "no checks", which is a +# statement about the PR rather than about the request. +buckets=$(gh pr checks "$PR" --repo "$REPO" --json bucket,name 2>"$errfile") +if [ -z "$buckets" ]; then + if [ -s "$errfile" ]; then + bad "could not query checks: $(tr '\n' ' ' <"$errfile" | cut -c1-120)" + else + bad "no checks reported for this PR" + fi +elif [ "$buckets" = "[]" ]; then + bad "no checks reported for this PR" else pend=$(jq '[.[]|select(.bucket=="pending")]|length' <<<"$buckets") bust=$(jq '[.[]|select(.bucket=="fail" or .bucket=="cancel")]|length' <<<"$buckets") @@ -109,8 +129,13 @@ fi # plus a thumbs-up, which is why the row and not the review list is read. # Paginate: the summary is an ordinary issue comment and the default page is # 30, so on a busy PR it is not on the first one. +# Filter on the AUTHOR as well as the marker. The marker is just text in a +# comment body, so any PR participant could post one carrying a `Completed` +# row for the current SHA and the gate would accept it as a review. The +# summary is posted by the Codex app; require that login and a Bot type. body=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ - --jq '.[] | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>/dev/null) + --jq '.[] | select(.user.login=="chatgpt-codex-connector[bot]" and .user.type=="Bot") + | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>/dev/null) row=$(grep -E '^\| (📝|🔍)' <<<"$body" | tail -1) if [ -z "$row" ]; then bad "no Codex review summary at all" @@ -128,18 +153,27 @@ fi # 5. No unresolved threads. required_conversation_resolution is on, so this is # the gate, not a courtesy. Paginate: a first:100 page once hid 19 threads. -threads=$(gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" -f query=' - query($owner:String!,$name:String!,$pr:Int!){ - repository(owner:$owner,name:$name){ - pullRequest(number:$pr){ - reviewThreads(first:100){ totalCount pageInfo{hasNextPage} nodes{isResolved} }}}}' 2>/dev/null) -if [ -z "$threads" ] || [ "$(jq -r '.data.repository.pullRequest' <<<"$threads")" = "null" ]; then - bad "could not read review threads for $REPO#$PR" -else - total=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$threads") - more=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$threads") - open=$(jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)]|length' <<<"$threads") - [ "$more" = "false" ] || bad "more than 100 threads — paginate before trusting this count" +# Actually paginate. Blocking whenever a second page exists made every busy +# PR permanently unmergeable — and a PR accumulates threads precisely by +# being reviewed carefully, so the rule punished the PRs it should trust. +cursor=null; open=0; total=0; ok_threads=1 +while : ; do + page=$(gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" \ + -f cursor="$([ "$cursor" = "null" ] && echo "" || echo "$cursor")" -f query=' + query($owner:String!,$name:String!,$pr:Int!,$cursor:String){ + repository(owner:$owner,name:$name){ + pullRequest(number:$pr){ + reviewThreads(first:100,after:$cursor){ + totalCount pageInfo{hasNextPage endCursor} nodes{isResolved} }}}}' 2>/dev/null) + if [ -z "$page" ] || [ "$(jq -r '.data.repository.pullRequest' <<<"$page")" = "null" ]; then + bad "could not read review threads for $REPO#$PR"; ok_threads=0; break + fi + total=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$page") + open=$(( open + $(jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)]|length' <<<"$page") )) + [ "$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$page")" = "true" ] || break + cursor=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor' <<<"$page") +done +if [ "$ok_threads" -eq 1 ]; then if [ "$open" -eq 0 ]; then good "0 of $total threads unresolved"; else bad "$open of $total threads unresolved"; fi fi @@ -148,6 +182,16 @@ fi # sat in a comment on public master through two PRs because each author # scanned only what they wrote — but scan ADDED lines only, or the gate # blocks the very PR that deletes a leak. +# AGENTS.md: "Each PR must link to one line in review-checklist.md as completed +# before merge." A gate that checks everything except the repository's own +# stated pre-merge rule is not the gate it claims to be. +prbody=$(jq -r '.body // ""' <<<"$meta") +if grep -qiE 'review-checklist' <<<"$prbody"; then + good "description links review-checklist.md" +else + bad "description does not link review-checklist.md (AGENTS.md requires one completed line per PR)" +fi + diff=$(gh pr diff "$PR" --repo "$REPO" 2>/dev/null) if [ -z "$diff" ]; then bad "could not read diff for the privacy scan" @@ -163,9 +207,22 @@ else # placeholder list, which would start excusing real values. (Deliberately no # example digits in this comment: a literal here is a literal in the diff, # and this scan reads its own file like any other.) - added=$(grep '^+' <<<"$diff" | grep -v '^+++' \ - | sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' \ + # A binary file is a hole in this scan, not an absence of findings: the patch + # carries a marker instead of content, so a screenshot or PDF of a client + # statement reads exactly like a clean diff. Refuse rather than pass. + binaries=$(grep -cE '^(Binary files .* differ|GIT binary patch)' <<<"$diff") + if [ "$binaries" -gt 0 ]; then + bad "$binaries binary change(s) the privacy scan cannot read — inspect by hand before merging: $(grep -E '^\+\+\+ b/' <<<"$diff" | sed 's|^+++ b/||' | tr '\n' ' ' | cut -c1-160)" + fi + raw_added=$(grep '^+' <<<"$diff" | grep -v '^+++') + added=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' <<<"$raw_added" \ | sed -E 's/[0-9a-fA-F]{32,}//g') + # Exemptions are REPORTED, never silent. Stripping generated-looking values + # keeps the false-positive rate low enough that the gate is read at all, but a + # blanket exemption that nobody can see is how a real value gets erased — so + # say how many were dropped and let the operator judge. + exempt=$(( $(grep -cE '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32,}' <<<"$raw_added") )) + [ "$exempt" -eq 0 ] || say "note" "$exempt added line(s) carried a UUID or hex digest, exempted from the scan — check by eye if this PR touches client data" # Placeholders match these shapes too — XXXXX1234X is a fabricated PAN and X # is an uppercase letter. A gate that cries wolf gets ignored, so obvious # placeholders are excluded by an EXPLICIT list; widening the shape itself From 966b90af0e513db5baae1ddf27f4408ee2a877d0 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:48:06 +0530 Subject: [PATCH 05/46] tooling: a seven-hex prefix is grindable, and `++` is content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round three: seven findings, three of them P1, and one is a third blind spot in the privacy scan — the third distinct way the scan could be handed data it would not look at. **`++` at the start of a line hid a value from the scan.** The filter dropped `^+++` to skip the unified-diff file header, but an added line whose own content begins with `++` produces exactly that prefix. `++ customer ABCDE1234F` yielded no scannable text at all. The header is now identified structurally — `+++ b/` or `+++ /dev/null`, never the bare prefix — so content reaches the scan and headers still do not. **A seven-hex prefix is 28 bits and can be ground deliberately.** The review row was matched by substring, so an author could push a commit sharing the reviewed prefix and have a stale `Completed` row vouch for code nobody read. Codex publishes only seven characters, so the comparison cannot be strengthened — but a ground commit must be created *after* the review it impersonates, and that is checkable. The row's own timestamp is now compared against the head commit's committer date, and a review that predates its head is refused. The match is also anchored to the backtick cell, since an unanchored substring matched the row's timestamp and URL too — neither of which is a claim about a commit. **Identifiers were matched uppercase-only.** A GSTIN or PAN written in lower or mixed case is the same identifier, and prose is exactly where it would be written that way. Matching is case-insensitive now, with the placeholder list applied to the uppercased form so `xxxxx1234x` stays excused. Four more: - A binary **deletion** emits `Binary files a/x and /dev/null differ` and was counted like an addition, so the gate blocked the PR removing a leaked screenshot. Same inversion as scanning removed lines, in a different rule. - `--match-head-commit` validates only the head, so the base can be changed after the check without moving the head. The printed command now re-reads the base and compares it before merging. - A failed comments query with empty stdout was reported as "no Codex review summary at all" — "I could not ask" stated as "there is no review", which is the exact substitution this script exists to prevent. - `--repo=` with an empty value was accepted. Both of the first two fixes were wrong on first writing and caught by their own controls: the header filter still swallowed `+++ customer …` until it matched `b/` and `/dev/null` explicitly, and the case-insensitive match used `-O` for `-o`. Every rule is now exercised in both directions — content reaches the scan while real headers do not, a lowercase PAN is flagged while a lowercase placeholder is not, a binary add blocks while a binary delete does not. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 62 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 24a891a7a..a7ae25de8 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -21,7 +21,8 @@ PR=""; REPO="" while [ $# -gt 0 ]; do case "$1" in --repo) REPO="${2:-}"; [ -n "$REPO" ] || { echo "--repo needs OWNER/NAME" >&2; exit 2; }; shift 2 ;; - --repo=*) REPO="${1#--repo=}"; shift ;; + --repo=*) REPO="${1#--repo=}" + [ -n "$REPO" ] || { echo "--repo= needs OWNER/NAME" >&2; exit 2; }; shift ;; -h|--help) sed -n '2,20p' "$0"; exit 0 ;; -*) echo "unknown option: $1" >&2; exit 2 ;; *) [ -z "$PR" ] && PR="$1" || { echo "unexpected argument: $1" >&2; exit 2; }; shift ;; @@ -135,14 +136,34 @@ fi # summary is posted by the Codex app; require that login and a Bot type. body=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ --jq '.[] | select(.user.login=="chatgpt-codex-connector[bot]" and .user.type=="Bot") - | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>/dev/null) + | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>"$errfile") +comment_query_failed=0 +[ -s "$errfile" ] && [ -z "$body" ] && comment_query_failed=1 row=$(grep -E '^\| (📝|🔍)' <<<"$body" | tail -1) -if [ -z "$row" ]; then +if [ "$comment_query_failed" -eq 1 ]; then + # "I could not ask" is not "there is no review". Saying the second when the + # first is true is the failure this whole script is about. + bad "could not read PR comments: $(tr '\n' ' ' <"$errfile" | cut -c1-110)" +elif [ -z "$row" ]; then bad "no Codex review summary at all" -elif ! grep -q "$short" <<<"$row"; then +elif ! grep -qE "\`$short\`" <<<"$row"; then + # Anchored to the backtick cell: an unanchored substring also matches the + # row's timestamp and URL, which are not claims about a commit. bad "latest review names a different commit than $short — it has not seen this push" elif grep -q 'Completed' <<<"$row"; then - good "review completed on $short" + # A seven-hex prefix is 28 bits and a matching commit can be ground + # deliberately, after which a stale `Completed` row would vouch for code + # nobody read. Codex publishes only seven characters, so the prefix cannot be + # strengthened — but a ground commit has to be created AFTER the review it is + # impersonating, and that is checkable. Require the reviewed row to postdate + # the head commit. + review_at=$(grep -oE 'datetime="[^"]+"' <<<"$row" | head -1 | sed 's/datetime="//;s/"//') + head_at=$(gh api "repos/$REPO/commits/$head" --jq '.commit.committer.date' 2>/dev/null) + if [ -n "$review_at" ] && [ -n "$head_at" ] && [[ "$review_at" < "$head_at" ]]; then + bad "review at $review_at predates head commit $short ($head_at) — it cannot have seen it" + else + good "review completed on $short${review_at:+ at $review_at}" + fi else # Running, Failed, Errored — none of these is a review. A failed review run # means nothing looked at the code, which is exactly the state this gate @@ -210,11 +231,23 @@ else # A binary file is a hole in this scan, not an absence of findings: the patch # carries a marker instead of content, so a screenshot or PDF of a client # statement reads exactly like a clean diff. Refuse rather than pass. - binaries=$(grep -cE '^(Binary files .* differ|GIT binary patch)' <<<"$diff") + # A binary DELETION removes a file rather than adding unreadable content, so + # it is a cleanup, not a hole — counting it blocked the PR that deletes a + # leaked screenshot, the same inversion as scanning removed lines. + binaries=$(grep -E '^(Binary files .* differ|GIT binary patch)' <<<"$diff" \ + | grep -cv 'and /dev/null differ') if [ "$binaries" -gt 0 ]; then bad "$binaries binary change(s) the privacy scan cannot read — inspect by hand before merging: $(grep -E '^\+\+\+ b/' <<<"$diff" | sed 's|^+++ b/||' | tr '\n' ' ' | cut -c1-160)" fi - raw_added=$(grep '^+' <<<"$diff" | grep -v '^+++') + # The unified-diff file header is `+++ ` WITH A SPACE. Filtering `^+++` + # discarded any added line whose own content starts with `++`, so + # `++ customer ABCDE1234F` produced no scannable text at all — a place to + # hide a value from the scan, in the scan's own input. + # Identify the header STRUCTURALLY. `+++ ` alone is not enough: an added line + # whose content begins with `++` produces exactly that prefix. Git's header + # is always `+++ b/` or `+++ /dev/null`, so match those and nothing + # else — `+++ customer ABCDE1234F` is content and must reach the scan. + raw_added=$(grep '^+' <<<"$diff" | grep -vE '^\+\+\+ (b/|/dev/null)') added=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' <<<"$raw_added" \ | sed -E 's/[0-9a-fA-F]{32,}//g') # Exemptions are REPORTED, never silent. Stripping generated-looking values @@ -238,8 +271,11 @@ else # NO \b around the digit run. The leak that motivated this gate was written # `HDF CH12345678901` — glued to letters — and \b does not match between `H` # and `1`, so the scan that was supposed to catch it could not see it at all. - hits=$(grep -Eo '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|[A-Z]{5}[0-9]{4}[A-Z]|[6-9][0-9]{9}' <<<"$added" \ - | sort -u | { grep -cvE "$placeholder" || true; }) + # Case-insensitively: a GSTIN or PAN written in lower or mixed case is the + # same identifier, and prose is exactly where it would be written that way. + # The placeholder list is applied to the UPPERCASED form for the same reason. + hits=$(grep -Eio '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|[A-Z]{5}[0-9]{4}[A-Z]|[6-9][0-9]{9}' <<<"$added" \ + | tr '[:lower:]' '[:upper:]' | sort -u | { grep -cvE "$placeholder" || true; }) runs=$(grep -Eo '[0-9]{11,18}' <<<"$added" | sort -u | { grep -cvE "$placeholder" || true; }) if [ "$hits" -eq 0 ] && [ "$runs" -eq 0 ]; then good "added lines carry no identifier shapes and no unexplained long digit runs" @@ -253,6 +289,10 @@ if [ "$fail" -ne 0 ]; then echo "MUST NOT MERGE"; exit 1; fi # 7. Bind the merge to the commit that was actually reviewed. Between this # check and the merge the head can move, and everything above would then # describe a commit the PR no longer points at. -echo "MAY MERGE — bind the merge to the reviewed commit:" -echo " gh pr merge $PR --repo $REPO --squash --match-head-commit $head" +echo "MAY MERGE — bind the merge to the reviewed commit AND the validated base:" +echo " [ \"\$(gh pr view $PR --repo $REPO --json baseRefName -q .baseRefName)\" = \"$base\" ] \\" +echo " && gh pr merge $PR --repo $REPO --squash --match-head-commit $head" +echo +echo " (--match-head-commit validates only the head; the base can be changed" +echo " after this check without moving the head, so re-read it too.)" exit 0 From a0c86dd7b162e8c263d2d03dcabd138dfbfc9e32 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:54:20 +0530 Subject: [PATCH 06/46] tooling: catch a reseal that never ran, before a reviewer has to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sessions hit the same defect independently in one day: a pinned file's bytes moved and the compatibility surface was not resealed. Once by editing, once by running `cargo fmt` *after* resealing, and once by rebasing — where the rebase takes the base's manifest and the author touches nothing. That spread of causes is the point. The rule people had written down was "the reseal is the last step before `git add`", and each of us broke it while believing we were following it, because each filed it under the *situation* we had just been in rather than under the actual invariant: **any operation that can change the bytes of a pinned file — an edit, a formatter, a merge, a rebase — invalidates the seal, and the reseal runs after the last of them.** Nothing in the local loop re-reads pins before a commit, so CI's gate is the only thing that notices, and every instance therefore reaches a reviewer instead of its author. That makes it a class, not a set of mistakes, and a class is worth closing here rather than writing down again. The check needs no checkout: read the 211 pinned paths from the manifest at the PR head, intersect with the PR's changed files, and require the manifest to have moved if any of them did. It is deliberately weaker than CI's gate and says so: it proves the reseal was *performed*, not that the hashes are *right*. Only the real gate proves that. But every instance observed was a reseal that never ran at all, so this catches the whole observed failure while costing one API call. Verified in three directions rather than two: a pinned file changed without the manifest blocks; the same change with the manifest passes; a PR touching the manifest alone has nothing to reseal and passes. Against live PRs, #314 reports one pinned file with the manifest moved alongside it, and #320 reports nothing pinned. Credit where due — this was suggested by the lane on #288, which had just been bitten by the `cargo fmt` variant, on the grounds that closing the class beats closing the instances. It was right. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index a7ae25de8..da3ba384c 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -213,6 +213,40 @@ else bad "description does not link review-checklist.md (AGENTS.md requires one completed line per PR)" fi +# 7. Pin freshness. The compatibility surface pins the raw bytes of 211 files, +# and ANY operation that can change those bytes — an edit, a formatter, a +# merge, a rebase taking the base's manifest — invalidates the seal. Nothing +# in the local loop re-reads pins before a commit, so CI's gate is the only +# thing that notices and every instance reaches a reviewer instead of its +# author. Three sessions hit this independently in one day, which makes it a +# class rather than a set of mistakes. +# +# Checked without a checkout: if this PR touches a pinned file, it must also +# touch the manifest. That does not prove the hashes are right — only CI's +# gate does — but it catches the whole observed failure, which is a reseal +# that never ran. +SURFACE=docs/tally/compatibility/compatibility-surface.json +changed=$(gh pr view "$PR" --repo "$REPO" --json files -q '.files[].path' 2>/dev/null) +if [ -z "$changed" ]; then + bad "could not list changed files for the pin-freshness check" +else + pinned=$(gh api "repos/$REPO/contents/$SURFACE?ref=$head" --jq '.content' 2>/dev/null \ + | tr -d '\n' | base64 --decode 2>/dev/null \ + | jq -r '[.. | objects | select(has("path")) | .path] | .[]' 2>/dev/null) + if [ -z "$pinned" ]; then + say "note" "no compatibility surface at this head — pin-freshness check skipped" + else + touched=$(comm -12 <(sort -u <<<"$pinned") <(sort -u <<<"$changed") | grep -v "^$SURFACE$" | head -20) + if [ -z "$touched" ]; then + good "touches no pinned file (nothing to reseal)" + elif grep -qx "$SURFACE" <<<"$changed"; then + good "touches $(wc -l <<<"$touched" | tr -d ' ') pinned file(s) and the manifest moved with them" + else + bad "touches pinned file(s) without updating $SURFACE — the reseal did not run: $(tr '\n' ' ' <<<"$touched" | cut -c1-150)" + fi + fi +fi + diff=$(gh pr diff "$PR" --repo "$REPO" 2>/dev/null) if [ -z "$diff" ]; then bad "could not read diff for the privacy scan" From c19255ab89de52527827d07339020e124357a77c Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:57:13 +0530 Subject: [PATCH 07/46] tooling: the scan reads its own file, so stop illustrating leaks with literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comment explaining the `++` blind spot used a realistic PAN as its example, and the newly case-insensitive matcher flagged it. Correctly: the scan reads this file like any other, and **a literal that illustrates a leak pattern is the pattern**. Third time a comment here has tripped the check — a UUID, a partial UUID, now a PAN. Each time it was the check working. The rule is now written in the comment so the next person adding an example reads it first: describe the shape, never spell it. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index da3ba384c..2028867d3 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -275,12 +275,16 @@ else fi # The unified-diff file header is `+++ ` WITH A SPACE. Filtering `^+++` # discarded any added line whose own content starts with `++`, so - # `++ customer ABCDE1234F` produced no scannable text at all — a place to - # hide a value from the scan, in the scan's own input. + # an added line whose content began `++` produced no scannable text at all — + # a place to hide a value from the scan, in the scan's own input. (No example + # identifier in this comment: the scan reads its own file, and a literal that + # illustrates a leak pattern IS the pattern. This is the third time a comment + # here has flagged itself, which is the check working rather than failing.) # Identify the header STRUCTURALLY. `+++ ` alone is not enough: an added line # whose content begins with `++` produces exactly that prefix. Git's header # is always `+++ b/` or `+++ /dev/null`, so match those and nothing - # else — `+++ customer ABCDE1234F` is content and must reach the scan. + # else — a payload line that merely starts with `++` is content, and must + # reach the scan rather than being mistaken for a header. raw_added=$(grep '^+' <<<"$diff" | grep -vE '^\+\+\+ (b/|/dev/null)') added=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' <<<"$raw_added" \ | sed -E 's/[0-9a-fA-F]{32,}//g') From 7270da827f389e16c31084eda1bfe331935ff1df Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 16:33:57 +0530 Subject: [PATCH 08/46] fix: make merge gate fail closed on unknown evidence --- scripts/merge-gate.sh | 692 +++++++++++++++++++++++-------------- scripts/merge-gate.test.py | 201 +++++++++++ 2 files changed, 624 insertions(+), 269 deletions(-) create mode 100644 scripts/merge-gate.test.py diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 2028867d3..a00cbfb7b 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -1,336 +1,490 @@ #!/usr/bin/env bash # Decide whether a pull request may be merged, and say why not when it may not. # -# Every rule here exists because it failed. On 2026-09-12 four PRs were merged -# before their reviews arrived — one by three seconds — leaving eleven findings, -# four of them P1, on code already in master. The gate added to stop that then -# deadlocked a clean PR, because a Codex pass with no findings submits no review -# object at all. Later the same day two sessions reported a PR "clean, zero open -# threads" from a query issued seconds before its review posted. -# -# The rule underneath all of it: a review is only evidence about the commit it -# names. "No findings yet" and "not looked yet" are indistinguishable unless you -# check which commit was looked at. -# # Usage: scripts/merge-gate.sh [--repo OWNER/NAME] # Exit: 0 may merge, 1 must not, 2 could not determine. +# +# Every positive result is bound to one server-observed head, base tip, complete +# check set, provider review commit, review-thread set, changed-file set, and +# readable compatibility surface. Unknown or incomplete evidence is never +# converted into an empty successful set. set -uo pipefail -PR=""; REPO="" +PR="" +REPO="" while [ $# -gt 0 ]; do case "$1" in - --repo) REPO="${2:-}"; [ -n "$REPO" ] || { echo "--repo needs OWNER/NAME" >&2; exit 2; }; shift 2 ;; - --repo=*) REPO="${1#--repo=}" - [ -n "$REPO" ] || { echo "--repo= needs OWNER/NAME" >&2; exit 2; }; shift ;; - -h|--help) sed -n '2,20p' "$0"; exit 0 ;; - -*) echo "unknown option: $1" >&2; exit 2 ;; - *) [ -z "$PR" ] && PR="$1" || { echo "unexpected argument: $1" >&2; exit 2; }; shift ;; + --repo) + REPO="${2:-}" + [ -n "$REPO" ] || { echo "--repo needs OWNER/NAME" >&2; exit 2; } + shift 2 + ;; + --repo=*) + REPO="${1#--repo=}" + [ -n "$REPO" ] || { echo "--repo= needs OWNER/NAME" >&2; exit 2; } + shift + ;; + -h|--help) + sed -n '2,13p' "$0" + exit 0 + ;; + -*) + echo "unknown option: $1" >&2 + exit 2 + ;; + *) + if [ -z "$PR" ]; then + PR="$1" + else + echo "unexpected argument: $1" >&2 + exit 2 + fi + shift + ;; esac done [ -n "$PR" ] || { echo "usage: $0 [--repo OWNER/NAME]" >&2; exit 2; } -# Resolve the repository ONCE, explicitly. Never fall back to a different -# repository on a transient failure: a same-numbered PR elsewhere with no open -# threads would read as a clean result for the PR actually being gated. +# Resolve the repository once. An explicit target must never fall back to the +# current checkout if one later API call fails. if [ -z "$REPO" ]; then - REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) \ - || { echo "could not determine repository; pass --repo OWNER/NAME" >&2; exit 2; } + if ! REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null); then + echo "could not determine repository; pass --repo OWNER/NAME" >&2 + exit 2 + fi +fi +OWNER="${REPO%%/*}" +NAME="${REPO##*/}" +if [ -z "$OWNER" ] || [ -z "$NAME" ] || [ "$OWNER" = "$REPO" ] || [[ "$REPO" == */*/* ]]; then + echo "--repo must be OWNER/NAME, got '$REPO'" >&2 + exit 2 fi -OWNER="${REPO%%/*}"; NAME="${REPO##*/}" -[ -n "$OWNER" ] && [ -n "$NAME" ] && [ "$OWNER" != "$REPO" ] \ - || { echo "--repo must be OWNER/NAME, got '$REPO'" >&2; exit 2; } fail=0 -blocked_seen=0 -errfile=$(mktemp); trap 'rm -f "$errfile"' EXIT -say() { printf ' %-6s %s\n' "$1" "$2"; } -bad() { say "BLOCK" "$1"; fail=1; } -good() { say "ok" "$1"; } -die() { echo "$1" >&2; exit 2; } +uncertain=0 +tmpdir=$(mktemp -d) +errfile="$tmpdir/error" +trap 'rm -rf "$tmpdir"' EXIT +say() { printf ' %-13s %s\n' "$1" "$2"; } +bad() { say "BLOCK" "$1"; fail=1; } +unknown() { say "INDETERMINATE" "$1"; uncertain=1; } +die() { echo "$1" >&2; exit 2; } -meta=$(gh pr view "$PR" --repo "$REPO" \ - --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body 2>/dev/null) \ - || die "could not read PR #$PR in $REPO" -head=$(jq -r .headRefOid <<<"$meta") -base=$(jq -r .baseRefName <<<"$meta") -mergeable=$(jq -r .mergeable <<<"$meta") -mstate=$(jq -r .mergeStateStatus <<<"$meta") -draft=$(jq -r .isDraft <<<"$meta") -pstate=$(jq -r .state <<<"$meta") +# A malformed response is different from a valid empty result. Validate the +# outer shape before extracting fields so jq errors cannot become empty values. +: >"$errfile" +if ! meta=$(gh pr view "$PR" --repo "$REPO" \ + --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body 2>"$errfile"); then + die "could not read PR #$PR in $REPO" +fi +if ! jq -e ' + type == "object" and + (.headRefOid | type == "string" and test("^[0-9a-fA-F]{40}$")) and + (.baseRefName | type == "string" and length > 0) and + (.mergeable | type == "string") and + (.mergeStateStatus | type == "string") and + (.isDraft | type == "boolean") and + (.state | type == "string") +' <<<"$meta" >/dev/null 2>&1; then + die "PR metadata was not a valid complete JSON object" +fi +head=$(jq -r '.headRefOid' <<<"$meta") +base=$(jq -r '.baseRefName' <<<"$meta") +mergeable=$(jq -r '.mergeable' <<<"$meta") +mstate=$(jq -r '.mergeStateStatus' <<<"$meta") +draft=$(jq -r '.isDraft' <<<"$meta") +pstate=$(jq -r '.state' <<<"$meta") short=${head:0:7} +prbody=$(jq -r '.body // ""' <<<"$meta") echo "PR #$PR ($REPO) head=$short base=$base $mergeable/$mstate" - [ "$pstate" = "OPEN" ] || bad "PR is $pstate, not OPEN" [ "$draft" = "false" ] || bad "draft" -# 1. Mergeable, and not merely "no conflicts". BEHIND means the head has not -# seen the current base, so its CI and its review describe a tree that no -# longer exists — the branch protection is strict and would refuse anyway. case "$mergeable" in - MERGEABLE) good "no conflicts" ;; + MERGEABLE) say "ok" "no conflicts" ;; CONFLICTING) bad "conflicts with $base — rebase first" ;; - *) bad "mergeability still UNKNOWN; re-run in a moment" ;; + *) unknown "mergeability is '$mergeable'; re-run when GitHub has determined it" ;; esac case "$mstate" in BEHIND) bad "head is BEHIND $base — its checks and review describe a stale tree; rebase" ;; - DIRTY) bad "merge state DIRTY — conflicts" ;; - UNKNOWN) bad "merge state UNKNOWN; re-run in a moment" ;; - BLOCKED) - # GitHub blocks for reasons this script may not model — a missing required - # approval, for instance. The checks below usually explain it, but printing - # `ok` for BLOCKED reads as approval for a state GitHub is refusing, so say - # what it is and let the specific checks account for it. - say "note" "merge state BLOCKED — GitHub is refusing; the checks below should say why" - blocked_seen=1 ;; - CLEAN|HAS_HOOKS|UNSTABLE) good "merge state $mstate is not stale" ;; - *) bad "unrecognised merge state '$mstate' — refusing rather than guessing" ;; + DIRTY) bad "merge state DIRTY — conflicts" ;; + UNKNOWN) unknown "merge state UNKNOWN; re-run in a moment" ;; + BLOCKED) bad "merge state BLOCKED — GitHub is refusing this merge" ;; + CLEAN|HAS_HOOKS) say "ok" "merge state $mstate is not stale" ;; + UNSTABLE) bad "merge state UNSTABLE — GitHub has not established a mergeable result" ;; + *) unknown "unrecognised merge state '$mstate'" ;; esac -# 2. Based on master. ci.yml fires on pull_request into master ONLY, so a -# stacked PR runs no CI at all and a green tick on it measures nothing. if [ "$base" = "master" ]; then - good "based on master (CI actually runs)" + say "ok" "based on master (CI actually runs)" else bad "based on '$base', not master — ci.yml does not fire, so checks here prove nothing" fi -# 3. Every check concluded and none failed. Read the JSON buckets rather than -# the human columns: a check name contains spaces, so column-splitting the -# text output misreads the bucket. gh's buckets are pass/fail/pending/ -# skipping/cancel — note `cancel`, not `cancelled`; matching the longer word -# left a cancelled check counted as neither failing nor pending, which read -# as success. -# `gh pr checks` exits nonzero both when a check is failing and when the query -# itself fails, so the status alone cannot be read as a verdict — but empty -# stdout from a broken query must never be reported as "no checks", which is a -# statement about the PR rather than about the request. -buckets=$(gh pr checks "$PR" --repo "$REPO" --json bucket,name 2>"$errfile") -if [ -z "$buckets" ]; then - if [ -s "$errfile" ]; then - bad "could not query checks: $(tr '\n' ' ' <"$errfile" | cut -c1-120)" +# Capture the base tip independently. A PR can retain the same base name while +# the branch advances during this run. +: >"$errfile" +base_tip_status=0 +base_tip=$(gh api "repos/$REPO/branches/$base" --jq '.commit.sha' 2>"$errfile") || base_tip_status=$? +if [ "$base_tip_status" -ne 0 ] || ! [[ "$base_tip" =~ ^[0-9a-fA-F]{40}$ ]]; then + unknown "could not read the full current tip of base '$base'" + base_tip="" +else + say "ok" "captured base tip ${base_tip:0:7}" +fi + +# Branch protection is the source of required check contexts. A pass list with +# an omitted required context is not a complete check result. +: >"$errfile" +protection_status=0 +protection=$(gh api "repos/$REPO/branches/$base/protection/required_status_checks" 2>"$errfile") || protection_status=$? +if [ "$protection_status" -ne 0 ]; then + unknown "could not read required status-check contexts for $base" + required_contexts="" +elif ! jq -e ' + type == "object" and + ((.contexts // []) | type == "array" and all(.[]; type == "string" and length > 0)) and + ((.checks // []) | type == "array" and all(.[]; type == "object" and (.context | type == "string" and length > 0))) +' <<<"$protection" >/dev/null 2>&1; then + unknown "required status-check response was malformed" + required_contexts="" +else + required_contexts=$(jq -r '((.contexts // []) + ([.checks // [] | .[]? | .context] | map(select(type == "string" and length > 0))) | unique)[]' <<<"$protection") + if [ -z "$required_contexts" ]; then + unknown "branch protection returned no required status-check contexts" else - bad "no checks reported for this PR" + say "ok" "loaded $(wc -l <<<"$required_contexts" | tr -d ' ') required check context(s)" fi -elif [ "$buckets" = "[]" ]; then - bad "no checks reported for this PR" -else - pend=$(jq '[.[]|select(.bucket=="pending")]|length' <<<"$buckets") - bust=$(jq '[.[]|select(.bucket=="fail" or .bucket=="cancel")]|length' <<<"$buckets") - tot=$(jq 'length' <<<"$buckets") - [ "$pend" -eq 0 ] || bad "$pend of $tot check(s) still running" - [ "$bust" -eq 0 ] || bad "$bust of $tot check(s) failed or were cancelled: $(jq -r '[.[]|select(.bucket=="fail" or .bucket=="cancel")|.name]|join(", ")' <<<"$buckets")" - [ "$pend" -eq 0 ] && [ "$bust" -eq 0 ] && good "all $tot checks concluded, none failing or cancelled" fi -# 4. A Codex review that names THIS head, and actually completed. The summary -# comment is edited in place, so its created_at is meaningless — read the -# commit in its table. A clean pass emits no review object, only this row -# plus a thumbs-up, which is why the row and not the review list is read. -# Paginate: the summary is an ordinary issue comment and the default page is -# 30, so on a busy PR it is not on the first one. -# Filter on the AUTHOR as well as the marker. The marker is just text in a -# comment body, so any PR participant could post one carrying a `Completed` -# row for the current SHA and the gate would accept it as a review. The -# summary is posted by the Codex app; require that login and a Bot type. -body=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ - --jq '.[] | select(.user.login=="chatgpt-codex-connector[bot]" and .user.type=="Bot") - | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>"$errfile") -comment_query_failed=0 -[ -s "$errfile" ] && [ -z "$body" ] && comment_query_failed=1 -row=$(grep -E '^\| (📝|🔍)' <<<"$body" | tail -1) -if [ "$comment_query_failed" -eq 1 ]; then - # "I could not ask" is not "there is no review". Saying the second when the - # first is true is the failure this whole script is about. - bad "could not read PR comments: $(tr '\n' ' ' <"$errfile" | cut -c1-110)" -elif [ -z "$row" ]; then - bad "no Codex review summary at all" -elif ! grep -qE "\`$short\`" <<<"$row"; then - # Anchored to the backtick cell: an unanchored substring also matches the - # row's timestamp and URL, which are not claims about a commit. - bad "latest review names a different commit than $short — it has not seen this push" -elif grep -q 'Completed' <<<"$row"; then - # A seven-hex prefix is 28 bits and a matching commit can be ground - # deliberately, after which a stale `Completed` row would vouch for code - # nobody read. Codex publishes only seven characters, so the prefix cannot be - # strengthened — but a ground commit has to be created AFTER the review it is - # impersonating, and that is checkable. Require the reviewed row to postdate - # the head commit. - review_at=$(grep -oE 'datetime="[^"]+"' <<<"$row" | head -1 | sed 's/datetime="//;s/"//') - head_at=$(gh api "repos/$REPO/commits/$head" --jq '.commit.committer.date' 2>/dev/null) - if [ -n "$review_at" ] && [ -n "$head_at" ] && [[ "$review_at" < "$head_at" ]]; then - bad "review at $review_at predates head commit $short ($head_at) — it cannot have seen it" +# gh uses pass/fail/pending/skipping/cancel buckets. Preserve command status, +# then parse JSON and require each protected context individually. +: >"$errfile" +check_status=0 +buckets=$(gh pr checks "$PR" --repo "$REPO" --json bucket,name 2>"$errfile") || check_status=$? +if [ -z "$buckets" ]; then + unknown "checks query returned no JSON" +elif ! jq -e 'type == "array" and all(.[]; type == "object" and (.name | type == "string" and length > 0) and (.bucket | type == "string"))' <<<"$buckets" >/dev/null 2>&1; then + unknown "checks query returned malformed JSON" +else + # The first validation accepts only the documented buckets; keep this second + # check explicit because jq's precedence is easy to misread in a gate. + if ! jq -e 'all(.[]; (.bucket == "pass" or .bucket == "fail" or .bucket == "pending" or .bucket == "skipping" or .bucket == "cancel"))' <<<"$buckets" >/dev/null 2>&1; then + unknown "checks query contained an unknown bucket" + elif [ "$check_status" -ne 0 ] && [ "$(jq '[.[] | select(.bucket == "fail" or .bucket == "cancel" or .bucket == "pending" or .bucket == "skipping")] | length' <<<"$buckets")" -eq 0 ]; then + unknown "checks command failed even though no failing or pending result was returned" + elif [ "$(jq 'length' <<<"$buckets")" -eq 0 ]; then + bad "no checks reported for this PR" else - good "review completed on $short${review_at:+ at $review_at}" + check_bad=0 + while IFS= read -r context; do + [ -n "$context" ] || continue + context_state=$(jq -r --arg context "$context" ' + map(select(.name == $context)) | + if length == 0 then "missing" + elif all(.[]; .bucket == "pass") then "pass" + else map(.bucket) | unique | join(",") + end + ' <<<"$buckets") + case "$context_state" in + pass) say "ok" "required check '$context' passed" ;; + missing) bad "required check '$context' was not reported"; check_bad=1 ;; + *) bad "required check '$context' is not passing ($context_state)"; check_bad=1 ;; + esac + done <<<"$required_contexts" + all_bad=$(jq '[.[] | select(.bucket == "fail" or .bucket == "cancel" or .bucket == "pending" or .bucket == "skipping")] | length' <<<"$buckets") + [ "$all_bad" -eq 0 ] || bad "$all_bad reported check(s) are failing, cancelled, pending, or skipped" + [ "$check_bad" -eq 0 ] && [ "$all_bad" -eq 0 ] && say "ok" "all reported checks concluded successfully" fi +fi + +# Provider review objects carry an immutable full commit_id even when the +# human-readable summary is abbreviated. No author-controlled commit timestamp +# is used. A clean summary without a full provider OID is indeterminate and +# points the operator to independent exact-head acceptance. +: >"$errfile" +review_status=0 +reviews=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/reviews" 2>"$errfile") || review_status=$? +if [ "$review_status" -ne 0 ]; then + unknown "could not read provider review records" +elif ! jq -e 'type == "array" and (all(.[]; type == "array") or all(.[]; type == "object"))' <<<"$reviews" >/dev/null 2>&1; then + unknown "provider review response was malformed" else - # Running, Failed, Errored — none of these is a review. A failed review run - # means nothing looked at the code, which is exactly the state this gate - # exists to catch; treating it as completed was the original bug inverted. - st=$(grep -oE 'Running|Failed|Errored|Cancelled' <<<"$row" | head -1) - bad "review state '${st:-unrecognised}' on $short — only Completed counts" + provider_review=$(jq -r --arg head "$head" ' + (if all(.[]; type == "array") then flatten else . end) | + map(select(.user.login == "chatgpt-codex-connector[bot]" and .user.type == "Bot" and + .state == "COMMENTED" and .commit_id == $head)) | + if length > 0 then "matched" else "" end + ' <<<"$reviews") + if [ "$provider_review" = "matched" ]; then + say "ok" "provider review records the full current head $short" + else + # Read the summary only to distinguish absent evidence from a provider + # summary that exposes an abbreviated current prefix. + : >"$errfile" + comment_status=0 + comments=$(gh api --paginate --slurp "repos/$REPO/issues/$PR/comments" 2>"$errfile") || comment_status=$? + if [ "$comment_status" -ne 0 ]; then + unknown "could not read provider review summaries" + elif ! jq -e 'type == "array" and (all(.[]; type == "array") or all(.[]; type == "object"))' <<<"$comments" >/dev/null 2>&1; then + unknown "provider review-summary response was malformed" + else + summaries=$(jq -r ' + (if all(.[]; type == "array") then flatten else . end)[] | + select(.user.login == "chatgpt-codex-connector[bot]" and .user.type == "Bot") | + select((.body // "") | contains("codex-pull-request-review-summary")) | + .body + ' <<<"$comments") + if [ -z "$summaries" ]; then + bad "no provider review records or summaries" + else + current_prefix=0 + if grep -Fq "\`$short\`" <<<"$summaries"; then current_prefix=1; fi + if [ "$current_prefix" -eq 1 ]; then + unknown "provider summary exposes only an abbreviated head; obtain full-SHA provider evidence or independently review this exact head" + else + bad "provider review evidence names a different head" + fi + fi + fi + fi fi -# 5. No unresolved threads. required_conversation_resolution is on, so this is -# the gate, not a courtesy. Paginate: a first:100 page once hid 19 threads. -# Actually paginate. Blocking whenever a second page exists made every busy -# PR permanently unmergeable — and a PR accumulates threads precisely by -# being reviewed carefully, so the rule punished the PRs it should trust. -cursor=null; open=0; total=0; ok_threads=1 -while : ; do - page=$(gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" \ - -f cursor="$([ "$cursor" = "null" ] && echo "" || echo "$cursor")" -f query=' - query($owner:String!,$name:String!,$pr:Int!,$cursor:String){ - repository(owner:$owner,name:$name){ - pullRequest(number:$pr){ - reviewThreads(first:100,after:$cursor){ - totalCount pageInfo{hasNextPage endCursor} nodes{isResolved} }}}}' 2>/dev/null) - if [ -z "$page" ] || [ "$(jq -r '.data.repository.pullRequest' <<<"$page")" = "null" ]; then - bad "could not read review threads for $REPO#$PR"; ok_threads=0; break +# Paginate review threads and count unresolved nodes over every page. +cursor="" +open_threads=0 +total_threads=0 +thread_ok=1 +while :; do + : >"$errfile" + page_status=0 + if [ -z "$cursor" ]; then + page=$(gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" -f cursor="" -f query=' + query($owner:String!,$name:String!,$pr:Int!,$cursor:String){ + repository(owner:$owner,name:$name){ + pullRequest(number:$pr){ reviewThreads(first:100,after:$cursor){ + totalCount pageInfo{hasNextPage endCursor} nodes{isResolved} + }} + } + }' 2>"$errfile") || page_status=$? + else + page=$(gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" -f cursor="$cursor" -f query=' + query($owner:String!,$name:String!,$pr:Int!,$cursor:String){ + repository(owner:$owner,name:$name){ + pullRequest(number:$pr){ reviewThreads(first:100,after:$cursor){ + totalCount pageInfo{hasNextPage endCursor} nodes{isResolved} + }} + } + }' 2>"$errfile") || page_status=$? + fi + if [ "$page_status" -ne 0 ] || ! jq -e '.data.repository.pullRequest.reviewThreads | type == "object" and (.totalCount | type == "number") and (.pageInfo.hasNextPage | type == "boolean") and (.nodes | type == "array" and all(.[]; .isResolved | type == "boolean"))' <<<"$page" >/dev/null 2>&1; then + unknown "could not read review threads for $REPO#$PR" + thread_ok=0 + break + fi + if [ "$total_threads" -eq 0 ]; then total_threads=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$page"); fi + page_open=$(jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length' <<<"$page") + open_threads=$((open_threads + page_open)) + has_next=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$page") + [ "$has_next" = "true" ] || break + next_cursor=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor // empty' <<<"$page") + if [ -z "$next_cursor" ] || [ "$next_cursor" = "$cursor" ]; then + unknown "review-thread pagination returned no advancing cursor" + thread_ok=0 + break fi - total=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$page") - open=$(( open + $(jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)]|length' <<<"$page") )) - [ "$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$page")" = "true" ] || break - cursor=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor' <<<"$page") + cursor="$next_cursor" done -if [ "$ok_threads" -eq 1 ]; then - if [ "$open" -eq 0 ]; then good "0 of $total threads unresolved"; else bad "$open of $total threads unresolved"; fi +if [ "$thread_ok" -eq 1 ]; then + if [ "$open_threads" -eq 0 ]; then + say "ok" "0 of $total_threads review threads unresolved" + else + bad "$open_threads of $total_threads review threads unresolved" + fi fi -# 6. Nothing shaped like client data in what this PR ADDS. Scan the whole merge -# diff rather than the author's own commits — a real transaction reference -# sat in a comment on public master through two PRs because each author -# scanned only what they wrote — but scan ADDED lines only, or the gate -# blocks the very PR that deletes a leak. -# AGENTS.md: "Each PR must link to one line in review-checklist.md as completed -# before merge." A gate that checks everything except the repository's own -# stated pre-merge rule is not the gate it claims to be. -prbody=$(jq -r '.body // ""' <<<"$meta") -if grep -qiE 'review-checklist' <<<"$prbody"; then - good "description links review-checklist.md" +# Require an actual markdown link and a completed checkbox. Matching the words +# review-checklist alone accepted a description that did not satisfy AGENTS.md. +if ! grep -Eiq '\[[^]]*review-checklist\.md[^]]*\]\([^)]*review-checklist\.md([^)]*)?\)' <<<"$prbody"; then + bad "description does not link review-checklist.md" +elif ! grep -Eiq '^[[:space:]]*-[[:space:]]*\[[xX]\]' <<<"$prbody"; then + bad "description has no completed review-checklist item" else - bad "description does not link review-checklist.md (AGENTS.md requires one completed line per PR)" + say "ok" "description links a completed review-checklist item" fi -# 7. Pin freshness. The compatibility surface pins the raw bytes of 211 files, -# and ANY operation that can change those bytes — an edit, a formatter, a -# merge, a rebase taking the base's manifest — invalidates the seal. Nothing -# in the local loop re-reads pins before a commit, so CI's gate is the only -# thing that notices and every instance reaches a reviewer instead of its -# author. Three sessions hit this independently in one day, which makes it a -# class rather than a set of mistakes. -# -# Checked without a checkout: if this PR touches a pinned file, it must also -# touch the manifest. That does not prove the hashes are right — only CI's -# gate does — but it catches the whole observed failure, which is a reseal -# that never ran. -SURFACE=docs/tally/compatibility/compatibility-surface.json -changed=$(gh pr view "$PR" --repo "$REPO" --json files -q '.files[].path' 2>/dev/null) -if [ -z "$changed" ]; then - bad "could not list changed files for the pin-freshness check" +# Paginate changed files through the REST endpoint; gh pr view hard-codes a +# first:100 GraphQL fragment in some versions. +: >"$errfile" +files_status=0 +files=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/files?per_page=100" 2>"$errfile") || files_status=$? +if [ "$files_status" -ne 0 ] || ! jq -e 'type == "array" and all(.[]; type == "array" or type == "object")' <<<"$files" >/dev/null 2>&1; then + unknown "could not read the complete changed-file set" + changed="" +else + changed=$(jq -r '(if all(.[]; type == "array") then flatten else . end)[] | .filename // empty' <<<"$files") + if [ -z "$changed" ]; then unknown "changed-file response contained no filenames"; fi +fi + +# Read and validate the surface as a required object. Any transport, decoding, +# or JSON failure is indeterminate; an empty decoded value is not absence. +SURFACE="docs/tally/compatibility/compatibility-surface.json" +: >"$errfile" +surface_status=0 +surface=$(gh api "repos/$REPO/contents/$SURFACE?ref=$head" 2>"$errfile") || surface_status=$? +if [ "$surface_status" -ne 0 ]; then + unknown "could not read compatibility surface at $short" + pinned="" +elif ! surface_content=$(jq -er '.content | strings' <<<"$surface"); then + unknown "compatibility surface response had no valid base64 content" + pinned="" else - pinned=$(gh api "repos/$REPO/contents/$SURFACE?ref=$head" --jq '.content' 2>/dev/null \ - | tr -d '\n' | base64 --decode 2>/dev/null \ - | jq -r '[.. | objects | select(has("path")) | .path] | .[]' 2>/dev/null) - if [ -z "$pinned" ]; then - say "note" "no compatibility surface at this head — pin-freshness check skipped" + decoded="" + decode_status=0 + decoded=$(printf '%s' "${surface_content//$'\n'/}" | base64 --decode 2>"$errfile") || decode_status=$? + if [ "$decode_status" -ne 0 ]; then + decode_status=0 + decoded=$(printf '%s' "${surface_content//$'\n'/}" | base64 -D 2>"$errfile") || decode_status=$? + fi + if [ "$decode_status" -ne 0 ] || ! jq -e 'type == "object" and ([.. | objects | select(has("path")) | .path] | length > 0)' <<<"$decoded" >/dev/null 2>&1; then + unknown "compatibility surface could not be decoded and validated" + pinned="" + else + pinned=$(jq -r '[.. | objects | select(has("path")) | .path] | unique[]' <<<"$decoded") + fi +fi +if [ -n "$changed" ] && [ -n "$pinned" ]; then + touched=$(comm -12 <(sort -u <<<"$pinned") <(sort -u <<<"$changed") | awk -v surface="$SURFACE" '$0 != surface') + if [ -z "$touched" ]; then + say "ok" "changed files contain no pinned path requiring a reseal" + elif grep -Fxq "$SURFACE" <<<"$changed"; then + say "ok" "changed pinned paths include the compatibility surface" else - touched=$(comm -12 <(sort -u <<<"$pinned") <(sort -u <<<"$changed") | grep -v "^$SURFACE$" | head -20) - if [ -z "$touched" ]; then - good "touches no pinned file (nothing to reseal)" - elif grep -qx "$SURFACE" <<<"$changed"; then - good "touches $(wc -l <<<"$touched" | tr -d ' ') pinned file(s) and the manifest moved with them" + bad "changed pinned paths omit the compatibility surface reseal" + fi +fi + +# Scan destination paths and added payload lines. Removed/context lines never +# enter the privacy scan. Binary additions are an explicit human-inspection +# hold because their bytes are absent from a textual patch. +: >"$errfile" +diff_status=0 +diff=$(gh pr diff "$PR" --repo "$REPO" 2>"$errfile") || diff_status=$? +if [ "$diff_status" -ne 0 ] || [ -z "$diff" ]; then + unknown "could not read diff for the privacy scan" +else + binary_count=$(awk '/^(Binary files .* differ|GIT binary patch)/ && $0 !~ /and \/dev\/null differ/ {n++} END {print n+0}' <<<"$diff") + [ "$binary_count" -eq 0 ] || bad "$binary_count binary addition/change(s) require human privacy inspection" + path_text=$(awk ' + /^diff --git a\// { s=$0; sub(/^diff --git a\/.* b\//, "", s); print s } + /^\+\+\+ b\// { s=$0; sub(/^\+\+\+ b\//, "", s); print s } + ' <<<"$diff") + added=$(awk '/^\+/ && $0 !~ /^\+\+\+ (b\/|\/dev\/null)/ { print substr($0, 2) }' <<<"$diff") + scan_input="$path_text +$added" + redacted=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g; s/[0-9a-fA-F]{32,}//g' <<<"$scan_input") + exempt=$(grep -Ec '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32,}' <<<"$scan_input") + [ "$exempt" -eq 0 ] || say "note" "$exempt added/path line(s) carried generated UUID/digest shapes; inspect those lines" + placeholder='^(X+|Z+|A+)[0-9]+(X|Z|A)?$|^[0-9]{2}(X+|Z+|A+)[0-9]+[0-9A-Z]*$|^(0+|1+|2+|3+|4+|5+|6+|7+|8+|9+)$|^(0?1234567890|1234567890[0-9]*)$|^0{6,}[0-9]{1,5}$' + if printf '%s\n' 'XXXXX1234X' | grep -qE "$placeholder"; then :; else + probe_status=$? + if [ "$probe_status" -eq 1 ]; then + bad "privacy placeholder control did not match its synthetic probe" else - bad "touches pinned file(s) without updating $SURFACE — the reseal did not run: $(tr '\n' ' ' <<<"$touched" | cut -c1-150)" + unknown "privacy placeholder expression failed" fi fi + count_nonplaceholder() { + local pattern="$1" input="$2" matches="" status=0 item count=0 + if matches=$(grep -Eio "$pattern" <<<"$input"); then + : + else + status=$? + if [ "$status" -eq 1 ]; then matches=""; else return 2; fi + fi + while IFS= read -r item; do + [ -n "$item" ] || continue + item=$(tr '[:lower:]' '[:upper:]' <<<"$item") + if printf '%s\n' "$item" | grep -qE "$placeholder"; then + : + else + status=$? + [ "$status" -eq 1 ] && count=$((count + 1)) || return 2 + fi + done <<<"$matches" + printf '%s\n' "$count" + } + # Phone numbers are often entered with a country prefix and visual + # separators. Keep the original text for the general scans, and add one + # separator-free view for the phone shape so ordinary formatting cannot + # split a customer number into harmless short fragments. + normalized_phone=$(tr -d $' ()-.\t' <<<"$redacted") + scan_shapes="$redacted +$normalized_phone" + hits_status=0 + hits=$(count_nonplaceholder '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|[A-Z]{5}[0-9]{4}[A-Z]|[6-9][0-9]{9}' "$scan_shapes") || hits_status=$? + runs_status=0 + runs=$(count_nonplaceholder '[0-9]{11,18}' "$scan_shapes") || runs_status=$? + if [ "$hits_status" -ne 0 ] || [ "$runs_status" -ne 0 ]; then + unknown "privacy scan expression failed" + elif [ "$hits" -eq 0 ] && [ "$runs" -eq 0 ]; then + say "ok" "added destination paths and payload lines carry no identifier shapes" + else + bad "privacy scan found $hits identifier shape(s) and $runs unexplained long digit run(s)" + fi fi -diff=$(gh pr diff "$PR" --repo "$REPO" 2>/dev/null) -if [ -z "$diff" ]; then - bad "could not read diff for the privacy scan" +# Re-read all moving identities immediately before emitting a merge command. +: >"$errfile" +final_meta_status=0 +final_meta=$(gh pr view "$PR" --repo "$REPO" \ + --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body 2>"$errfile") || final_meta_status=$? +if [ "$final_meta_status" -ne 0 ] || ! jq -e 'type == "object" and (.headRefOid | type == "string") and (.baseRefName | type == "string") and (.mergeable | type == "string") and (.mergeStateStatus | type == "string") and (.isDraft | type == "boolean") and (.state | type == "string")' <<<"$final_meta" >/dev/null 2>&1; then + unknown "could not revalidate PR head and base before merge" else - # A UUID's last group is twelve hex characters and often all digits; the - # canonical RFC example UUID tripped this. Strip UUIDs rather than - # widening the placeholder list, which would start excusing real values. - # Hex digests are the other machine-generated shape that trips this: a - # sha256 in a lockfile or a sealed surface manifest contains long digit runs - # by chance, and dropping \b (see below) made them visible. The canonical RFC - # example UUID tripped it the same way. Strip digests and UUIDs — both are - # generated, neither can carry a client identifier — rather than loosening the - # placeholder list, which would start excusing real values. (Deliberately no - # example digits in this comment: a literal here is a literal in the diff, - # and this scan reads its own file like any other.) - # A binary file is a hole in this scan, not an absence of findings: the patch - # carries a marker instead of content, so a screenshot or PDF of a client - # statement reads exactly like a clean diff. Refuse rather than pass. - # A binary DELETION removes a file rather than adding unreadable content, so - # it is a cleanup, not a hole — counting it blocked the PR that deletes a - # leaked screenshot, the same inversion as scanning removed lines. - binaries=$(grep -E '^(Binary files .* differ|GIT binary patch)' <<<"$diff" \ - | grep -cv 'and /dev/null differ') - if [ "$binaries" -gt 0 ]; then - bad "$binaries binary change(s) the privacy scan cannot read — inspect by hand before merging: $(grep -E '^\+\+\+ b/' <<<"$diff" | sed 's|^+++ b/||' | tr '\n' ' ' | cut -c1-160)" + final_head=$(jq -r '.headRefOid' <<<"$final_meta") + final_base=$(jq -r '.baseRefName' <<<"$final_meta") + final_mergeable=$(jq -r '.mergeable' <<<"$final_meta") + final_state=$(jq -r '.mergeStateStatus' <<<"$final_meta") + final_draft=$(jq -r '.isDraft' <<<"$final_meta") + final_pstate=$(jq -r '.state' <<<"$final_meta") + [ "$final_head" = "$head" ] || bad "PR head moved during preflight" + [ "$final_base" = "$base" ] || bad "PR base moved during preflight" + [ "$final_mergeable" = "MERGEABLE" ] || bad "PR mergeability changed to $final_mergeable during preflight" + [ "$final_draft" = "false" ] || bad "PR became draft during preflight" + [ "$final_pstate" = "OPEN" ] || bad "PR state changed to $final_pstate during preflight" + case "$final_state" in + BEHIND|DIRTY|UNKNOWN|BLOCKED|UNSTABLE) bad "PR merge state changed to $final_state during preflight" ;; + esac + final_body=$(jq -r '.body // ""' <<<"$final_meta") + if ! grep -Eiq '\[[^]]*review-checklist\.md[^]]*\]\([^)]*review-checklist\.md([^)]*)?\)' <<<"$final_body" || ! grep -Eiq '^[[:space:]]*-[[:space:]]*\[[xX]\]' <<<"$final_body"; then + bad "PR description changed and no longer carries a completed checklist link" fi - # The unified-diff file header is `+++ ` WITH A SPACE. Filtering `^+++` - # discarded any added line whose own content starts with `++`, so - # an added line whose content began `++` produced no scannable text at all — - # a place to hide a value from the scan, in the scan's own input. (No example - # identifier in this comment: the scan reads its own file, and a literal that - # illustrates a leak pattern IS the pattern. This is the third time a comment - # here has flagged itself, which is the check working rather than failing.) - # Identify the header STRUCTURALLY. `+++ ` alone is not enough: an added line - # whose content begins with `++` produces exactly that prefix. Git's header - # is always `+++ b/` or `+++ /dev/null`, so match those and nothing - # else — a payload line that merely starts with `++` is content, and must - # reach the scan rather than being mistaken for a header. - raw_added=$(grep '^+' <<<"$diff" | grep -vE '^\+\+\+ (b/|/dev/null)') - added=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' <<<"$raw_added" \ - | sed -E 's/[0-9a-fA-F]{32,}//g') - # Exemptions are REPORTED, never silent. Stripping generated-looking values - # keeps the false-positive rate low enough that the gate is read at all, but a - # blanket exemption that nobody can see is how a real value gets erased — so - # say how many were dropped and let the operator judge. - exempt=$(( $(grep -cE '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32,}' <<<"$raw_added") )) - [ "$exempt" -eq 0 ] || say "note" "$exempt added line(s) carried a UUID or hex digest, exempted from the scan — check by eye if this PR touches client data" - # Placeholders match these shapes too — XXXXX1234X is a fabricated PAN and X - # is an uppercase letter. A gate that cries wolf gets ignored, so obvious - # placeholders are excluded by an EXPLICIT list; widening the shape itself - # would start excusing real values. No backreferences: this must be plain - # ERE, and a grep that errors returns nothing, which reads as a clean scan. - # `^0{6,}` is the padded-fixture shape: six or more leading zeros then a small - # number, as constructed test pages use for MICR and postcode fields. Kept - # narrow on purpose — a real account number can begin with a zero or two, so - # only a run long enough to be plainly synthetic is excused. - placeholder='^(X+|Z+|A+)[0-9]+(X|Z|A)?$|^[0-9]{2}(X+|Z+|A+)[0-9]+[0-9A-Z]*$|^(0+|1+|2+|3+|4+|5+|6+|7+|8+|9+)$|^(0?1234567890|1234567890[0-9]*)$|^0{6,}[0-9]{1,5}$' - printf 'XXXXX1234X\n' | grep -qE "$placeholder" \ - || { bad "privacy-scan pattern failed to compile or match its own probe"; fail=1; } - # NO \b around the digit run. The leak that motivated this gate was written - # `HDF CH12345678901` — glued to letters — and \b does not match between `H` - # and `1`, so the scan that was supposed to catch it could not see it at all. - # Case-insensitively: a GSTIN or PAN written in lower or mixed case is the - # same identifier, and prose is exactly where it would be written that way. - # The placeholder list is applied to the UPPERCASED form for the same reason. - hits=$(grep -Eio '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|[A-Z]{5}[0-9]{4}[A-Z]|[6-9][0-9]{9}' <<<"$added" \ - | tr '[:lower:]' '[:upper:]' | sort -u | { grep -cvE "$placeholder" || true; }) - runs=$(grep -Eo '[0-9]{11,18}' <<<"$added" | sort -u | { grep -cvE "$placeholder" || true; }) - if [ "$hits" -eq 0 ] && [ "$runs" -eq 0 ]; then - good "added lines carry no identifier shapes and no unexplained long digit runs" - else - bad "privacy scan: $hits identifier shape(s), $runs unexplained long digit run(s) in ADDED lines — inspect before merging" +fi +if [ -n "$base_tip" ]; then + : >"$errfile" + final_base_tip_status=0 + final_base_tip=$(gh api "repos/$REPO/branches/$base" --jq '.commit.sha' 2>"$errfile") || final_base_tip_status=$? + if [ "$final_base_tip_status" -ne 0 ] || ! [[ "$final_base_tip" =~ ^[0-9a-fA-F]{40}$ ]]; then + unknown "could not revalidate base tip before merge" + elif [ "$final_base_tip" != "$base_tip" ]; then + bad "base tip moved during preflight" fi fi echo -if [ "$fail" -ne 0 ]; then echo "MUST NOT MERGE"; exit 1; fi -# 7. Bind the merge to the commit that was actually reviewed. Between this -# check and the merge the head can move, and everything above would then -# describe a commit the PR no longer points at. -echo "MAY MERGE — bind the merge to the reviewed commit AND the validated base:" -echo " [ \"\$(gh pr view $PR --repo $REPO --json baseRefName -q .baseRefName)\" = \"$base\" ] \\" -echo " && gh pr merge $PR --repo $REPO --squash --match-head-commit $head" -echo -echo " (--match-head-commit validates only the head; the base can be changed" -echo " after this check without moving the head, so re-read it too.)" +if [ "$uncertain" -ne 0 ]; then + echo "INDETERMINATE — do not merge until the missing evidence is obtained" + exit 2 +fi +if [ "$fail" -ne 0 ]; then + echo "MUST NOT MERGE" + exit 1 +fi + +echo "MAY MERGE — bind the merge to the reviewed head and validated base:" +printf ' [ "$(gh pr view %s --repo %s --json baseRefName -q .baseRefName)" = "%s" ] \\\n && gh pr merge %s --repo %s --squash --match-head-commit %s\n' \ + "$PR" "$REPO" "$base" "$PR" "$REPO" "$head" exit 0 diff --git a/scripts/merge-gate.test.py b/scripts/merge-gate.test.py new file mode 100644 index 000000000..56ec293e0 --- /dev/null +++ b/scripts/merge-gate.test.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Focused offline controls for scripts/merge-gate.sh. + +The fake gh command models server responses, including paginated REST and +GraphQL pages. No network or merge operation is used. +""" +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "scripts" / "merge-gate.sh" +FAKE_GH = r'''#!/usr/bin/env python3 +import base64, json, os, sys +args = sys.argv[1:] +scenario = os.environ.get("GATE_SCENARIO", "pass") +head = "0123456789abcdef0123456789abcdef01234567" +new_head = "fedcba9876543210fedcba9876543210fedcba98" + +def emit(value): + if value is not None: + print(value if isinstance(value, str) else json.dumps(value)) + +def fail(message="controlled API failure"): + print(message, file=sys.stderr) + raise SystemExit(1) + +if args[:2] == ["pr", "view"]: + counter_path = os.environ.get("GATE_COUNTER") + view_count = 0 + if counter_path: + try: + view_count = int(open(counter_path).read()) + except (FileNotFoundError, ValueError): + pass + with open(counter_path, "w") as counter: + counter.write(str(view_count + 1)) + selected_head = new_head if scenario == "head-moves" and view_count > 0 else head + emit({"headRefOid": selected_head, "baseRefName": "master", + "mergeable": "MERGEABLE", "mergeStateStatus": + "BLOCKED" if scenario == "blocked-state" else "CLEAN", + "isDraft": False, "state": "OPEN", + "body": "[review-checklist.md](../blob/master/review-checklist.md)\n- [x] evidence"}) +elif args[:2] == ["pr", "checks"]: + if scenario == "checks-silent": + raise SystemExit(0) + if scenario == "cancel-check": + emit([{"bucket": "cancel", "name": "Required checks"}]) + elif scenario == "missing-required": + emit([{"bucket": "pass", "name": "Required checks"}]) + else: + emit([{"bucket": "pass", "name": "Frontend build"}, + {"bucket": "pass", "name": "Rust format"}, + {"bucket": "pass", "name": "GitGuardian Security Checks"}, + {"bucket": "pass", "name": "Dependency security"}, + {"bucket": "pass", "name": "Required checks"}]) +elif args[:2] == ["pr", "diff"]: + if scenario == "formatted-phone": + emit("diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +1 @@\n+Call +91 98765-43210\n") + elif scenario == "path-id": + emit("diff --git a/docs/safe.md b/docs/ABCDE1234F.md\n--- a/docs/safe.md\n+++ b/docs/ABCDE1234F.md\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario == "binary-delete": + emit("diff --git a/docs/old.png b/docs/old.png\nBinary files a/docs/old.png and /dev/null differ\n") + else: + emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+safe text\n") +elif args and args[0] == "api": + joined = " ".join(args) + if "graphql" in args: + has_cursor = "C1" in joined + emit({"data": {"repository": {"pullRequest": {"reviewThreads": { + "totalCount": 101 if not has_cursor else 101, + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": ([{"isResolved": True}] if not has_cursor else [{"isResolved": True}]) + }}}}}) + elif "branches/master/protection/required_status_checks" in joined: + contexts = ["Required checks", "Rust format"] if scenario == "missing-required" else [ + "Frontend build", "Rust format", "GitGuardian Security Checks", + "Dependency security", "Required checks"] + emit({"contexts": contexts, "checks": []}) + elif "branches/master" in joined: + emit(head) + elif "/pulls/321/reviews" in joined: + if scenario == "short-review": + emit([[]]) + else: + emit([[{"user": {"login": "chatgpt-codex-connector[bot]", "type": "Bot"}, + "state": "COMMENTED", "commit_id": head}]]) + elif "/issues/321/comments" in joined: + if scenario == "short-review": + emit([[{"user": {"login": "chatgpt-codex-connector[bot]", "type": "Bot"}, + "body": "codex-pull-request-review-summary\n| 📝 | ✅ **Completed** | `0123456` |"}]]) + else: + emit([[]]) + elif "/pulls/321/files" in joined: + emit([[{"filename": "docs/example.md"}], [{"filename": "docs/second.md"}]]) + elif "/contents/" in joined: + if scenario == "surface-fail": + fail("controlled surface read failure") + if scenario == "surface-malformed": + emit({"content": "not-base64"}) + else: + surface = {"files": [{"path": "src/example.rs"}]} + emit({"content": base64.b64encode(json.dumps(surface).encode()).decode()}) + else: + fail("unknown API fixture") +else: + fail("unknown command fixture") +''' + + +class MergeGateControls(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory(prefix="merge-gate-controls-") + cls.bin = Path(cls.tmp.name) + gh = cls.bin / "gh" + gh.write_text(FAKE_GH) + gh.chmod(0o755) + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def run_gate(self, scenario="pass"): + env = os.environ.copy() + env["PATH"] = f"{self.bin}:{env['PATH']}" + env["GATE_SCENARIO"] = scenario + counter = self.bin / f"{scenario}-counter-{os.getpid()}" + counter.write_text("0") + env["GATE_COUNTER"] = str(counter) + return subprocess.run( + [str(SCRIPT), "321", "--repo", "example/repo"], + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + def assert_blocked(self, scenario, phrase): + result = self.run_gate(scenario) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn(phrase, result.stdout) + self.assertNotIn("MAY MERGE", result.stdout) + + def assert_indeterminate(self, scenario, phrase): + result = self.run_gate(scenario) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertIn(phrase, result.stdout) + self.assertNotIn("MAY MERGE", result.stdout) + + def test_server_full_sha_and_paginated_pages_can_pass(self): + result = self.run_gate() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("provider review records the full current head", result.stdout) + self.assertIn("MAY MERGE", result.stdout) + self.assertIn("--match-head-commit 0123456789abcdef0123456789abcdef01234567", result.stdout) + + def test_missing_required_context_blocks(self): + self.assert_blocked("missing-required", "required check 'Rust format' was not reported") + + def test_cancelled_check_blocks(self): + self.assert_blocked("cancel-check", "cancelled, pending, or skipped") + + def test_surface_transport_failure_is_indeterminate(self): + self.assert_indeterminate("surface-fail", "could not read compatibility surface") + + def test_silent_checks_response_is_indeterminate(self): + self.assert_indeterminate("checks-silent", "checks query returned no JSON") + + def test_summary_only_short_sha_is_indeterminate(self): + self.assert_indeterminate("short-review", "full-SHA provider evidence") + + def test_blocked_merge_state_cannot_pass(self): + self.assert_blocked("blocked-state", "merge state BLOCKED") + + def test_head_change_is_blocked(self): + self.assert_blocked("head-moves", "PR head moved during preflight") + + def test_formatted_phone_is_scanned(self): + self.assert_blocked("formatted-phone", "privacy scan found") + + def test_destination_path_is_scanned(self): + self.assert_blocked("path-id", "privacy scan found") + + def test_binary_deletion_is_not_treated_as_added_content(self): + result = self.run_gate("binary-delete") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_malformed_surface_is_indeterminate(self): + self.assert_indeterminate("surface-malformed", "compatibility surface could not be decoded") + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 9daeb695d2d2becde8af262d3a62b4ac14299699 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 17:03:21 +0530 Subject: [PATCH 09/46] fix: fail closed on incomplete merge evidence --- scripts/merge-gate.sh | 84 +++++++++++++++++++++++++++++--------- scripts/merge-gate.test.py | 78 +++++++++++++++++++++++++++++++---- 2 files changed, 134 insertions(+), 28 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index a00cbfb7b..1a1c62e75 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -75,7 +75,7 @@ die() { echo "$1" >&2; exit 2; } # outer shape before extracting fields so jq errors cannot become empty values. : >"$errfile" if ! meta=$(gh pr view "$PR" --repo "$REPO" \ - --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body 2>"$errfile"); then + --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body,changedFiles 2>"$errfile"); then die "could not read PR #$PR in $REPO" fi if ! jq -e ' @@ -85,7 +85,8 @@ if ! jq -e ' (.mergeable | type == "string") and (.mergeStateStatus | type == "string") and (.isDraft | type == "boolean") and - (.state | type == "string") + (.state | type == "string") and + (.changedFiles | type == "number" and floor == . and . >= 0) ' <<<"$meta" >/dev/null 2>&1; then die "PR metadata was not a valid complete JSON object" fi @@ -95,6 +96,7 @@ mergeable=$(jq -r '.mergeable' <<<"$meta") mstate=$(jq -r '.mergeStateStatus' <<<"$meta") draft=$(jq -r '.isDraft' <<<"$meta") pstate=$(jq -r '.state' <<<"$meta") +changed_files_expected=$(jq -r '.changedFiles' <<<"$meta") short=${head:0:7} prbody=$(jq -r '.body // ""' <<<"$meta") @@ -173,7 +175,7 @@ else # check explicit because jq's precedence is easy to misread in a gate. if ! jq -e 'all(.[]; (.bucket == "pass" or .bucket == "fail" or .bucket == "pending" or .bucket == "skipping" or .bucket == "cancel"))' <<<"$buckets" >/dev/null 2>&1; then unknown "checks query contained an unknown bucket" - elif [ "$check_status" -ne 0 ] && [ "$(jq '[.[] | select(.bucket == "fail" or .bucket == "cancel" or .bucket == "pending" or .bucket == "skipping")] | length' <<<"$buckets")" -eq 0 ]; then + elif [ "$check_status" -ne 0 ] && [ "$(jq '[.[] | select(.bucket == "fail" or .bucket == "cancel" or .bucket == "pending")] | length' <<<"$buckets")" -eq 0 ]; then unknown "checks command failed even though no failing or pending result was returned" elif [ "$(jq 'length' <<<"$buckets")" -eq 0 ]; then bad "no checks reported for this PR" @@ -194,8 +196,10 @@ else *) bad "required check '$context' is not passing ($context_state)"; check_bad=1 ;; esac done <<<"$required_contexts" - all_bad=$(jq '[.[] | select(.bucket == "fail" or .bucket == "cancel" or .bucket == "pending" or .bucket == "skipping")] | length' <<<"$buckets") - [ "$all_bad" -eq 0 ] || bad "$all_bad reported check(s) are failing, cancelled, pending, or skipped" + all_bad=$(jq '[.[] | select(.bucket == "fail" or .bucket == "cancel" or .bucket == "pending")] | length' <<<"$buckets") + skipped=$(jq '[.[] | select(.bucket == "skipping")] | length' <<<"$buckets") + [ "$all_bad" -eq 0 ] || bad "$all_bad reported check(s) are failing, cancelled, or pending" + [ "$skipped" -eq 0 ] || say "note" "$skipped optional check(s) are skipped; required skipped contexts remain blocking" [ "$check_bad" -eq 0 ] && [ "$all_bad" -eq 0 ] && say "ok" "all reported checks concluded successfully" fi fi @@ -256,6 +260,7 @@ fi cursor="" open_threads=0 total_threads=0 +fetched_threads=0 thread_ok=1 while :; do : >"$errfile" @@ -284,7 +289,16 @@ while :; do thread_ok=0 break fi - if [ "$total_threads" -eq 0 ]; then total_threads=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$page"); fi + page_total=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$page") + if [ "$total_threads" -eq 0 ]; then + total_threads="$page_total" + elif [ "$page_total" -ne "$total_threads" ]; then + unknown "review-thread totalCount changed during pagination" + thread_ok=0 + break + fi + page_nodes=$(jq '.data.repository.pullRequest.reviewThreads.nodes | length' <<<"$page") + fetched_threads=$((fetched_threads + page_nodes)) page_open=$(jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length' <<<"$page") open_threads=$((open_threads + page_open)) has_next=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$page") @@ -298,19 +312,30 @@ while :; do cursor="$next_cursor" done if [ "$thread_ok" -eq 1 ]; then - if [ "$open_threads" -eq 0 ]; then + if [ "$fetched_threads" -ne "$total_threads" ]; then + unknown "review-thread pagination returned $fetched_threads of $total_threads nodes" + elif [ "$open_threads" -eq 0 ]; then say "ok" "0 of $total_threads review threads unresolved" else bad "$open_threads of $total_threads review threads unresolved" fi fi -# Require an actual markdown link and a completed checkbox. Matching the words -# review-checklist alone accepted a description that did not satisfy AGENTS.md. -if ! grep -Eiq '\[[^]]*review-checklist\.md[^]]*\]\([^)]*review-checklist\.md([^)]*)?\)' <<<"$prbody"; then - bad "description does not link review-checklist.md" -elif ! grep -Eiq '^[[:space:]]*-[[:space:]]*\[[xX]\]' <<<"$prbody"; then - bad "description has no completed review-checklist item" +# Require a completed checkbox whose same-repository link points at a specific +# checklist line. A filename in prose, a link to another repository, or a +# checked item beside an unrelated link is not completion evidence. +checklist_link_ok() { + local body="$1" line + while IFS= read -r line; do + if printf '%s\n' "$line" | grep -Eiq \ + "^[[:space:]]*-[[:space:]]*\\[[xX]\\][[:space:]].*\\]\\(https://github\\.com/${OWNER}/${NAME}/blob/[^)]*/review-checklist\\.md#L[0-9]+\\)"; then + return 0 + fi + done <<<"$body" + return 1 +} +if ! checklist_link_ok "$prbody"; then + bad "description lacks a completed same-repository line-specific review-checklist link" else say "ok" "description links a completed review-checklist item" fi @@ -320,12 +345,29 @@ fi : >"$errfile" files_status=0 files=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/files?per_page=100" 2>"$errfile") || files_status=$? -if [ "$files_status" -ne 0 ] || ! jq -e 'type == "array" and all(.[]; type == "array" or type == "object")' <<<"$files" >/dev/null 2>&1; then +if [ "$files_status" -ne 0 ] || ! jq -e ' + type == "array" and + (all(.[]; type == "array" and all(.[]; + type == "object" and + (.filename | type == "string" and length > 0) and + (.status | type == "string" and length > 0))) or + all(.[]; type == "object" and + (.filename | type == "string" and length > 0) and + (.status | type == "string" and length > 0))) +' <<<"$files" >/dev/null 2>&1; then unknown "could not read the complete changed-file set" changed="" else changed=$(jq -r '(if all(.[]; type == "array") then flatten else . end)[] | .filename // empty' <<<"$files") - if [ -z "$changed" ]; then unknown "changed-file response contained no filenames"; fi + changed_count=$(wc -l <<<"$changed" | tr -d ' ') + unique_changed_count=$(sort -u <<<"$changed" | wc -l | tr -d ' ') + if [ "$changed_count" -eq 0 ]; then + unknown "changed-file response contained no filenames" + elif [ "$changed_files_expected" -gt 3000 ]; then + unknown "PR reports $changed_files_expected changed files beyond the REST files API cap" + elif [ "$changed_count" -ne "$changed_files_expected" ] || [ "$unique_changed_count" -ne "$changed_count" ]; then + unknown "changed-file response has $changed_count unique records; PR metadata reports $changed_files_expected" + fi fi # Read and validate the surface as a required object. Any transport, decoding, @@ -440,8 +482,8 @@ fi : >"$errfile" final_meta_status=0 final_meta=$(gh pr view "$PR" --repo "$REPO" \ - --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body 2>"$errfile") || final_meta_status=$? -if [ "$final_meta_status" -ne 0 ] || ! jq -e 'type == "object" and (.headRefOid | type == "string") and (.baseRefName | type == "string") and (.mergeable | type == "string") and (.mergeStateStatus | type == "string") and (.isDraft | type == "boolean") and (.state | type == "string")' <<<"$final_meta" >/dev/null 2>&1; then + --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body,changedFiles 2>"$errfile") || final_meta_status=$? +if [ "$final_meta_status" -ne 0 ] || ! jq -e 'type == "object" and (.headRefOid | type == "string") and (.baseRefName | type == "string") and (.mergeable | type == "string") and (.mergeStateStatus | type == "string") and (.isDraft | type == "boolean") and (.state | type == "string") and (.changedFiles | type == "number" and floor == . and . >= 0)' <<<"$final_meta" >/dev/null 2>&1; then unknown "could not revalidate PR head and base before merge" else final_head=$(jq -r '.headRefOid' <<<"$final_meta") @@ -450,17 +492,21 @@ else final_state=$(jq -r '.mergeStateStatus' <<<"$final_meta") final_draft=$(jq -r '.isDraft' <<<"$final_meta") final_pstate=$(jq -r '.state' <<<"$final_meta") + final_changed_files=$(jq -r '.changedFiles' <<<"$final_meta") [ "$final_head" = "$head" ] || bad "PR head moved during preflight" [ "$final_base" = "$base" ] || bad "PR base moved during preflight" [ "$final_mergeable" = "MERGEABLE" ] || bad "PR mergeability changed to $final_mergeable during preflight" [ "$final_draft" = "false" ] || bad "PR became draft during preflight" [ "$final_pstate" = "OPEN" ] || bad "PR state changed to $final_pstate during preflight" + [ "$final_changed_files" = "$changed_files_expected" ] || bad "PR changed-file count moved during preflight" case "$final_state" in + CLEAN|HAS_HOOKS) : ;; BEHIND|DIRTY|UNKNOWN|BLOCKED|UNSTABLE) bad "PR merge state changed to $final_state during preflight" ;; + *) unknown "PR merge state changed to unrecognised value '$final_state' during preflight" ;; esac final_body=$(jq -r '.body // ""' <<<"$final_meta") - if ! grep -Eiq '\[[^]]*review-checklist\.md[^]]*\]\([^)]*review-checklist\.md([^)]*)?\)' <<<"$final_body" || ! grep -Eiq '^[[:space:]]*-[[:space:]]*\[[xX]\]' <<<"$final_body"; then - bad "PR description changed and no longer carries a completed checklist link" + if ! checklist_link_ok "$final_body"; then + bad "PR description changed and no longer carries a completed same-repository line-specific checklist link" fi fi if [ -n "$base_tip" ]; then diff --git a/scripts/merge-gate.test.py b/scripts/merge-gate.test.py index 56ec293e0..8c37a5e9c 100644 --- a/scripts/merge-gate.test.py +++ b/scripts/merge-gate.test.py @@ -41,16 +41,23 @@ def fail(message="controlled API failure"): with open(counter_path, "w") as counter: counter.write(str(view_count + 1)) selected_head = new_head if scenario == "head-moves" and view_count > 0 else head + final_state = "UNKNOWN_VALUE" if scenario == "final-unrecognized" and view_count > 0 else ("BLOCKED" if scenario == "blocked-state" else "CLEAN") + body = "- [x] [Errors](https://github.com/example/repo/blob/HEAD/review-checklist.md#L10)" + if scenario == "checklist-foreign": + body = "- [x] [Errors](https://github.com/other/repo/blob/HEAD/review-checklist.md#L10)" + elif scenario == "checklist-unlinked": + body = "- [x] review-checklist.md line 10" emit({"headRefOid": selected_head, "baseRefName": "master", - "mergeable": "MERGEABLE", "mergeStateStatus": - "BLOCKED" if scenario == "blocked-state" else "CLEAN", + "mergeable": "MERGEABLE", "mergeStateStatus": final_state, "isDraft": False, "state": "OPEN", - "body": "[review-checklist.md](../blob/master/review-checklist.md)\n- [x] evidence"}) + "body": body, "changedFiles": 2}) elif args[:2] == ["pr", "checks"]: if scenario == "checks-silent": raise SystemExit(0) if scenario == "cancel-check": emit([{"bucket": "cancel", "name": "Required checks"}]) + elif scenario == "required-skip": + emit([{"bucket": "skipping", "name": "Required checks"}, {"bucket": "pass", "name": "Rust format"}]) elif scenario == "missing-required": emit([{"bucket": "pass", "name": "Required checks"}]) else: @@ -58,7 +65,8 @@ def fail(message="controlled API failure"): {"bucket": "pass", "name": "Rust format"}, {"bucket": "pass", "name": "GitGuardian Security Checks"}, {"bucket": "pass", "name": "Dependency security"}, - {"bucket": "pass", "name": "Required checks"}]) + {"bucket": "pass", "name": "Required checks"}, + {"bucket": "skipping", "name": "Optional documentation"}]) elif args[:2] == ["pr", "diff"]: if scenario == "formatted-phone": emit("diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +1 @@\n+Call +91 98765-43210\n") @@ -72,10 +80,17 @@ def fail(message="controlled API failure"): joined = " ".join(args) if "graphql" in args: has_cursor = "C1" in joined + if scenario == "threads-short": + nodes, page_info = [{"isResolved": True}], {"hasNextPage": False, "endCursor": None} + elif scenario == "threads-malformed-pagination": + nodes, page_info = ([{"isResolved": True}] * 100), {"hasNextPage": True, "endCursor": None} + elif has_cursor: + nodes, page_info = [{"isResolved": scenario != "threads-unresolved-second"}], {"hasNextPage": False, "endCursor": None} + else: + nodes, page_info = ([{"isResolved": True}] * 100), {"hasNextPage": True, "endCursor": "C1"} emit({"data": {"repository": {"pullRequest": {"reviewThreads": { - "totalCount": 101 if not has_cursor else 101, - "pageInfo": {"hasNextPage": False, "endCursor": None}, - "nodes": ([{"isResolved": True}] if not has_cursor else [{"isResolved": True}]) + "totalCount": 102 if scenario == "threads-total-drift" and has_cursor else 101, + "pageInfo": page_info, "nodes": nodes }}}}}) elif "branches/master/protection/required_status_checks" in joined: contexts = ["Required checks", "Rust format"] if scenario == "missing-required" else [ @@ -97,7 +112,14 @@ def fail(message="controlled API failure"): else: emit([[]]) elif "/pulls/321/files" in joined: - emit([[{"filename": "docs/example.md"}], [{"filename": "docs/second.md"}]]) + if scenario == "malformed-files": + emit([[{"filename": "docs/example.md", "status": "added"}], [{"filename": 3, "status": "modified"}]]) + elif scenario == "missing-file-status": + emit([[{"filename": "docs/example.md", "status": "added"}], [{"filename": "docs/second.md"}]]) + elif scenario == "files-count-mismatch": + emit([[{"filename": "docs/example.md", "status": "added"}]]) + else: + emit([[{"filename": "docs/example.md", "status": "added"}], [{"filename": "docs/second.md", "status": "modified"}]]) elif "/contents/" in joined: if scenario == "surface-fail": fail("controlled surface read failure") @@ -162,11 +184,19 @@ def test_server_full_sha_and_paginated_pages_can_pass(self): self.assertIn("MAY MERGE", result.stdout) self.assertIn("--match-head-commit 0123456789abcdef0123456789abcdef01234567", result.stdout) + def test_optional_skipped_check_does_not_block(self): + result = self.run_gate() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("optional check(s) are skipped", result.stdout) + + def test_required_skipped_check_blocks(self): + self.assert_blocked("required-skip", "required check 'Required checks' is not passing") + def test_missing_required_context_blocks(self): self.assert_blocked("missing-required", "required check 'Rust format' was not reported") def test_cancelled_check_blocks(self): - self.assert_blocked("cancel-check", "cancelled, pending, or skipped") + self.assert_blocked("cancel-check", "failing, cancelled, or pending") def test_surface_transport_failure_is_indeterminate(self): self.assert_indeterminate("surface-fail", "could not read compatibility surface") @@ -180,6 +210,21 @@ def test_summary_only_short_sha_is_indeterminate(self): def test_blocked_merge_state_cannot_pass(self): self.assert_blocked("blocked-state", "merge state BLOCKED") + def test_unrecognised_final_merge_state_is_indeterminate(self): + self.assert_indeterminate("final-unrecognized", "unrecognised value") + + def test_short_thread_page_is_indeterminate(self): + self.assert_indeterminate("threads-short", "returned 1 of 101 nodes") + + def test_unresolved_second_thread_page_blocks(self): + self.assert_blocked("threads-unresolved-second", "1 of 101 review threads unresolved") + + def test_malformed_thread_pagination_is_indeterminate(self): + self.assert_indeterminate("threads-malformed-pagination", "no advancing cursor") + + def test_thread_total_drift_is_indeterminate(self): + self.assert_indeterminate("threads-total-drift", "totalCount changed") + def test_head_change_is_blocked(self): self.assert_blocked("head-moves", "PR head moved during preflight") @@ -196,6 +241,21 @@ def test_binary_deletion_is_not_treated_as_added_content(self): def test_malformed_surface_is_indeterminate(self): self.assert_indeterminate("surface-malformed", "compatibility surface could not be decoded") + def test_malformed_changed_file_is_indeterminate(self): + self.assert_indeterminate("malformed-files", "could not read the complete changed-file set") + + def test_missing_changed_file_status_is_indeterminate(self): + self.assert_indeterminate("missing-file-status", "could not read the complete changed-file set") + + def test_changed_file_count_mismatch_is_indeterminate(self): + self.assert_indeterminate("files-count-mismatch", "changed-file response has") + + def test_foreign_checklist_link_blocks(self): + self.assert_blocked("checklist-foreign", "same-repository line-specific") + + def test_unlinked_checklist_text_blocks(self): + self.assert_blocked("checklist-unlinked", "same-repository line-specific") + if __name__ == "__main__": unittest.main(verbosity=2) From b456307ba8e8427b4b12f256e5f9c964c153bb48 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 17:08:31 +0530 Subject: [PATCH 10/46] fix(tooling): reject empty file evidence and invalid page counts --- scripts/merge-gate.sh | 20 +++++++++++++++----- scripts/merge-gate.test.py | 21 +++++++++++++++++---- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 1a1c62e75..fc7c19a70 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -259,7 +259,7 @@ fi # Paginate review threads and count unresolved nodes over every page. cursor="" open_threads=0 -total_threads=0 +total_threads=-1 fetched_threads=0 thread_ok=1 while :; do @@ -284,13 +284,13 @@ while :; do } }' 2>"$errfile") || page_status=$? fi - if [ "$page_status" -ne 0 ] || ! jq -e '.data.repository.pullRequest.reviewThreads | type == "object" and (.totalCount | type == "number") and (.pageInfo.hasNextPage | type == "boolean") and (.nodes | type == "array" and all(.[]; .isResolved | type == "boolean"))' <<<"$page" >/dev/null 2>&1; then + if [ "$page_status" -ne 0 ] || ! jq -e '.data.repository.pullRequest.reviewThreads | type == "object" and (.totalCount | type == "number" and floor == . and . >= 0) and (.pageInfo.hasNextPage | type == "boolean") and (.nodes | type == "array" and all(.[]; .isResolved | type == "boolean"))' <<<"$page" >/dev/null 2>&1; then unknown "could not read review threads for $REPO#$PR" thread_ok=0 break fi page_total=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$page") - if [ "$total_threads" -eq 0 ]; then + if [ "$total_threads" -eq -1 ]; then total_threads="$page_total" elif [ "$page_total" -ne "$total_threads" ]; then unknown "review-thread totalCount changed during pagination" @@ -299,10 +299,20 @@ while :; do fi page_nodes=$(jq '.data.repository.pullRequest.reviewThreads.nodes | length' <<<"$page") fetched_threads=$((fetched_threads + page_nodes)) + if [ "$fetched_threads" -gt "$total_threads" ]; then + unknown "review-thread pagination exceeded totalCount" + thread_ok=0 + break + fi page_open=$(jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length' <<<"$page") open_threads=$((open_threads + page_open)) has_next=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$page") [ "$has_next" = "true" ] || break + if [ "$page_nodes" -eq 0 ]; then + unknown "review-thread pagination returned no nodes while claiming another page" + thread_ok=0 + break + fi next_cursor=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor // empty' <<<"$page") if [ -z "$next_cursor" ] || [ "$next_cursor" = "$cursor" ]; then unknown "review-thread pagination returned no advancing cursor" @@ -359,8 +369,8 @@ if [ "$files_status" -ne 0 ] || ! jq -e ' changed="" else changed=$(jq -r '(if all(.[]; type == "array") then flatten else . end)[] | .filename // empty' <<<"$files") - changed_count=$(wc -l <<<"$changed" | tr -d ' ') - unique_changed_count=$(sort -u <<<"$changed" | wc -l | tr -d ' ') + changed_count=$(jq '(if all(.[]; type == "array") then flatten else . end) | length' <<<"$files") + unique_changed_count=$(jq '(if all(.[]; type == "array") then flatten else . end) | map(.filename) | unique | length' <<<"$files") if [ "$changed_count" -eq 0 ]; then unknown "changed-file response contained no filenames" elif [ "$changed_files_expected" -gt 3000 ]; then diff --git a/scripts/merge-gate.test.py b/scripts/merge-gate.test.py index 8c37a5e9c..28d861626 100644 --- a/scripts/merge-gate.test.py +++ b/scripts/merge-gate.test.py @@ -50,7 +50,7 @@ def fail(message="controlled API failure"): emit({"headRefOid": selected_head, "baseRefName": "master", "mergeable": "MERGEABLE", "mergeStateStatus": final_state, "isDraft": False, "state": "OPEN", - "body": body, "changedFiles": 2}) + "body": body, "changedFiles": 1 if scenario == "files-empty" else 2}) elif args[:2] == ["pr", "checks"]: if scenario == "checks-silent": raise SystemExit(0) @@ -80,7 +80,9 @@ def fail(message="controlled API failure"): joined = " ".join(args) if "graphql" in args: has_cursor = "C1" in joined - if scenario == "threads-short": + if scenario == "threads-empty-more": + nodes, page_info = [], {"hasNextPage": True, "endCursor": "C1"} + elif scenario == "threads-short": nodes, page_info = [{"isResolved": True}], {"hasNextPage": False, "endCursor": None} elif scenario == "threads-malformed-pagination": nodes, page_info = ([{"isResolved": True}] * 100), {"hasNextPage": True, "endCursor": None} @@ -89,7 +91,7 @@ def fail(message="controlled API failure"): else: nodes, page_info = ([{"isResolved": True}] * 100), {"hasNextPage": True, "endCursor": "C1"} emit({"data": {"repository": {"pullRequest": {"reviewThreads": { - "totalCount": 102 if scenario == "threads-total-drift" and has_cursor else 101, + "totalCount": 101.5 if scenario == "threads-fractional" else (102 if scenario == "threads-total-drift" and has_cursor else 101), "pageInfo": page_info, "nodes": nodes }}}}}) elif "branches/master/protection/required_status_checks" in joined: @@ -112,7 +114,9 @@ def fail(message="controlled API failure"): else: emit([[]]) elif "/pulls/321/files" in joined: - if scenario == "malformed-files": + if scenario == "files-empty": + emit([[]]) + elif scenario == "malformed-files": emit([[{"filename": "docs/example.md", "status": "added"}], [{"filename": 3, "status": "modified"}]]) elif scenario == "missing-file-status": emit([[{"filename": "docs/example.md", "status": "added"}], [{"filename": "docs/second.md"}]]) @@ -250,6 +254,15 @@ def test_missing_changed_file_status_is_indeterminate(self): def test_changed_file_count_mismatch_is_indeterminate(self): self.assert_indeterminate("files-count-mismatch", "changed-file response has") + def test_empty_file_array_is_not_one_filename(self): + self.assert_indeterminate("files-empty", "contained no filenames") + + def test_fractional_thread_count_is_indeterminate(self): + self.assert_indeterminate("threads-fractional", "could not read review threads") + + def test_empty_page_cannot_claim_more_threads(self): + self.assert_indeterminate("threads-empty-more", "no nodes while claiming another page") + def test_foreign_checklist_link_blocks(self): self.assert_blocked("checklist-foreign", "same-repository line-specific") From daa0e4a3598aacb54da4d6382fa6aed5e169a2b2 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 17:18:50 +0530 Subject: [PATCH 11/46] fix: complete merge-gate evidence coverage --- scripts/merge-gate.sh | 132 +++++++++++++++++++++++++++++++------ scripts/merge-gate.test.py | 56 +++++++++++++--- 2 files changed, 158 insertions(+), 30 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index fc7c19a70..99f6da499 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -331,15 +331,31 @@ if [ "$thread_ok" -eq 1 ]; then fi fi -# Require a completed checkbox whose same-repository link points at a specific -# checklist line. A filename in prose, a link to another repository, or a -# checked item beside an unrelated link is not completion evidence. +# Require a completed checklist item and a same-repository line permalink. +# The repository template puts the permalink on the item's indented +# continuation, so accept it there as well as in an inline Markdown link. +# A filename in prose, a foreign link, or an unrelated checked item is not +# completion evidence. checklist_link_ok() { - local body="$1" line + local body="$1" line awaiting_permalink=0 + local checked='^[[:space:]]*-[[:space:]]*\[[xX]\][[:space:]]+' + local permalink="https://github\\.com/${OWNER}/${NAME}/blob/[^[:space:])]+/review-checklist\\.md#L[0-9]+" while IFS= read -r line; do - if printf '%s\n' "$line" | grep -Eiq \ - "^[[:space:]]*-[[:space:]]*\\[[xX]\\][[:space:]].*\\]\\(https://github\\.com/${OWNER}/${NAME}/blob/[^)]*/review-checklist\\.md#L[0-9]+\\)"; then - return 0 + if printf '%s\n' "$line" | grep -Eq "$checked"; then + if printf '%s\n' "$line" | grep -Eiq "$permalink"; then + return 0 + fi + if printf '%s\n' "$line" | grep -Eiq 'review-checklist\.md'; then + awaiting_permalink=1 + else + awaiting_permalink=0 + fi + elif [ "$awaiting_permalink" -eq 1 ] && printf '%s\n' "$line" | grep -Eq '^[[:space:]]+'; then + if printf '%s\n' "$line" | grep -Eiq "$permalink"; then + return 0 + fi + else + awaiting_permalink=0 fi done <<<"$body" return 1 @@ -351,24 +367,32 @@ else fi # Paginate changed files through the REST endpoint; gh pr view hard-codes a -# first:100 GraphQL fragment in some versions. +# first:100 GraphQL fragment in some versions. Retain the line counts as well: +# the privacy scan can only be complete when the textual diff describes every +# non-removed destination with the byte count GitHub reported. : >"$errfile" files_status=0 files=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/files?per_page=100" 2>"$errfile") || files_status=$? +changed_records="$tmpdir/changed-files.tsv" if [ "$files_status" -ne 0 ] || ! jq -e ' type == "array" and (all(.[]; type == "array" and all(.[]; type == "object" and - (.filename | type == "string" and length > 0) and - (.status | type == "string" and length > 0))) or + ((.filename | type) == "string") and (.filename | length > 0) and (.filename | test("[\\t\\r\\n]") | not) and + ((.status | type) == "string") and (.status | length > 0) and + ((.additions | type) == "number") and (.additions | floor == . and . >= 0) and + ((.deletions | type) == "number") and (.deletions | floor == . and . >= 0))) or all(.[]; type == "object" and - (.filename | type == "string" and length > 0) and - (.status | type == "string" and length > 0))) + ((.filename | type) == "string") and (.filename | length > 0) and (.filename | test("[\\t\\r\\n]") | not) and + ((.status | type) == "string") and (.status | length > 0) and + ((.additions | type) == "number") and (.additions | floor == . and . >= 0) and + ((.deletions | type) == "number") and (.deletions | floor == . and . >= 0))) ' <<<"$files" >/dev/null 2>&1; then unknown "could not read the complete changed-file set" changed="" else - changed=$(jq -r '(if all(.[]; type == "array") then flatten else . end)[] | .filename // empty' <<<"$files") + jq -r '(if all(.[]; type == "array") then flatten else . end)[] | [.filename, .status, .additions, .deletions] | @tsv' <<<"$files" >"$changed_records" + changed=$(cut -f1 "$changed_records") changed_count=$(jq '(if all(.[]; type == "array") then flatten else . end) | length' <<<"$files") unique_changed_count=$(jq '(if all(.[]; type == "array") then flatten else . end) | map(.filename) | unique | length' <<<"$files") if [ "$changed_count" -eq 0 ]; then @@ -380,8 +404,9 @@ else fi fi -# Read and validate the surface as a required object. Any transport, decoding, -# or JSON failure is indeterminate; an empty decoded value is not absence. +# Read and validate the v1 surface as a required object. Any transport, +# decoding, JSON, or schema failure is indeterminate; an unrelated nested +# `path` must not turn an incomplete manifest into an empty pin set. SURFACE="docs/tally/compatibility/compatibility-surface.json" : >"$errfile" surface_status=0 @@ -400,11 +425,20 @@ else decode_status=0 decoded=$(printf '%s' "${surface_content//$'\n'/}" | base64 -D 2>"$errfile") || decode_status=$? fi - if [ "$decode_status" -ne 0 ] || ! jq -e 'type == "object" and ([.. | objects | select(has("path")) | .path] | length > 0)' <<<"$decoded" >/dev/null 2>&1; then + if [ "$decode_status" -ne 0 ] || ! jq -e ' + type == "object" and + .schema_version == 1 and + ((.manifest_sha256 | type) == "string") and (.manifest_sha256 | test("^[0-9a-f]{64}$")) and + (.files | type == "array" and length > 0 and + all(.[]; type == "object" and + ((.path | type) == "string") and (.path | length > 0) and + ((.sha256 | type) == "string") and (.sha256 | test("^[0-9a-f]{64}$")))) and + (([.files[].path] | length) == ([.files[].path] | unique | length)) + ' <<<"$decoded" >/dev/null 2>&1; then unknown "compatibility surface could not be decoded and validated" pinned="" else - pinned=$(jq -r '[.. | objects | select(has("path")) | .path] | unique[]' <<<"$decoded") + pinned=$(jq -r '.files[].path' <<<"$decoded") fi fi if [ -n "$changed" ] && [ -n "$pinned" ]; then @@ -427,12 +461,68 @@ diff=$(gh pr diff "$PR" --repo "$REPO" 2>"$errfile") || diff_status=$? if [ "$diff_status" -ne 0 ] || [ -z "$diff" ]; then unknown "could not read diff for the privacy scan" else + # Keep one record per `diff --git` section. A header alone proves only that + # GitHub named a file; the line counts below prove it supplied the complete + # textual payload for that destination. + diff_stats="$tmpdir/diff-stats.tsv" + awk ' + function emit() { + if (!in_file) return + destination = textual_destination != "" ? textual_destination : header_destination + if (destination != "") { + printf "%s\t%d\t%d\t%d\t%d\n", destination, added, deleted, textual, binary + } + } + /^diff --git a\// { + emit() + in_file = 1 + header_destination = $0 + sub(/^diff --git a\/.* b\//, "", header_destination) + textual_destination = "" + added = deleted = textual = binary = 0 + next + } + /^\+\+\+ b\// { + textual_destination = $0 + sub(/^\+\+\+ b\//, "", textual_destination) + textual = 1 + next + } + /^(Binary files .* differ|GIT binary patch)$/ { binary = 1; next } + /^\+/ && $0 !~ /^\+\+\+ / { added++; next } + /^-/ && $0 !~ /^--- / { deleted++; next } + END { emit() } + ' <<<"$diff" >"$diff_stats" + binary_count=$(awk '/^(Binary files .* differ|GIT binary patch)/ && $0 !~ /and \/dev\/null differ/ {n++} END {print n+0}' <<<"$diff") [ "$binary_count" -eq 0 ] || bad "$binary_count binary addition/change(s) require human privacy inspection" - path_text=$(awk ' - /^diff --git a\// { s=$0; sub(/^diff --git a\/.* b\//, "", s); print s } - /^\+\+\+ b\// { s=$0; sub(/^\+\+\+ b\//, "", s); print s } - ' <<<"$diff") + path_text="" + if [ -s "$changed_records" ]; then + while IFS=$'\t' read -r filename status rest_added rest_deleted; do + [ "$status" = "removed" ] && continue + match_count=$(awk -F '\t' -v filename="$filename" '$1 == filename { count++ } END { print count+0 }' "$diff_stats") + if [ "$match_count" -ne 1 ]; then + unknown "privacy diff omits or duplicates non-removed REST destination '$filename'" + continue + fi + diff_record=$(awk -F '\t' -v filename="$filename" '$1 == filename { print; exit }' "$diff_stats") + IFS=$'\t' read -r _ diff_added diff_deleted textual binary <<<"$diff_record" + if [ "$binary" -eq 1 ]; then + # The binary hold above requires human inspection. Its bytes cannot be + # reconciled through textual hunks, but its destination was covered. + continue + fi + if [ "$textual" -ne 1 ]; then + unknown "privacy diff lacks a textual destination for '$filename'" + elif [ "$diff_added" -ne "$rest_added" ] || [ "$diff_deleted" -ne "$rest_deleted" ]; then + unknown "privacy diff line totals for '$filename' differ from REST metadata" + fi + done <"$changed_records" + # Destination paths are scan input from the complete REST set, not only + # from whatever textual patch GitHub happened to render. Removed paths + # carry no newly added material and are deliberately excluded. + path_text=$(awk -F '\t' '$2 != "removed" { print $1 }' "$changed_records") + fi added=$(awk '/^\+/ && $0 !~ /^\+\+\+ (b\/|\/dev\/null)/ { print substr($0, 2) }' <<<"$diff") scan_input="$path_text $added" diff --git a/scripts/merge-gate.test.py b/scripts/merge-gate.test.py index 28d861626..99c441f3e 100644 --- a/scripts/merge-gate.test.py +++ b/scripts/merge-gate.test.py @@ -47,10 +47,16 @@ def fail(message="controlled API failure"): body = "- [x] [Errors](https://github.com/other/repo/blob/HEAD/review-checklist.md#L10)" elif scenario == "checklist-unlinked": body = "- [x] review-checklist.md line 10" + elif scenario == "checklist-template-continuation": + body = ( + "- [x] One completed [`review-checklist.md`](../review-checklist.md) line is\n" + " linked here: https://github.com/example/repo/blob/HEAD/review-checklist.md#L10" + ) + one_file = scenario in {"files-empty", "formatted-phone", "path-id", "binary-delete"} emit({"headRefOid": selected_head, "baseRefName": "master", "mergeable": "MERGEABLE", "mergeStateStatus": final_state, "isDraft": False, "state": "OPEN", - "body": body, "changedFiles": 1 if scenario == "files-empty" else 2}) + "body": body, "changedFiles": 1 if one_file else 2}) elif args[:2] == ["pr", "checks"]: if scenario == "checks-silent": raise SystemExit(0) @@ -69,13 +75,19 @@ def fail(message="controlled API failure"): {"bucket": "skipping", "name": "Optional documentation"}]) elif args[:2] == ["pr", "diff"]: if scenario == "formatted-phone": - emit("diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +1 @@\n+Call +91 98765-43210\n") + phone = "+91 " + "98765" + "-43210" + emit(f"diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +1 @@\n+Call {phone}\n") elif scenario == "path-id": - emit("diff --git a/docs/safe.md b/docs/ABCDE1234F.md\n--- a/docs/safe.md\n+++ b/docs/ABCDE1234F.md\n@@ -0,0 +1 @@\n+safe text\n") + path_id = "ABCDE" + "1234" + "F" + emit(f"diff --git a/docs/safe.md b/docs/{path_id}.md\n--- a/docs/safe.md\n+++ b/docs/{path_id}.md\n@@ -0,0 +1 @@\n+safe text\n") elif scenario == "binary-delete": emit("diff --git a/docs/old.png b/docs/old.png\nBinary files a/docs/old.png and /dev/null differ\n") - else: + elif scenario == "diff-omits-file": emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario == "diff-truncated-payload": + emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +2 @@\n+first line\n") + else: + emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+safe text\ndiff --git a/docs/second.md b/docs/second.md\n--- a/docs/second.md\n+++ b/docs/second.md\n@@ -0,0 +1 @@\n+other text\n") elif args and args[0] == "api": joined = " ".join(args) if "graphql" in args: @@ -116,21 +128,33 @@ def fail(message="controlled API failure"): elif "/pulls/321/files" in joined: if scenario == "files-empty": emit([[]]) + elif scenario == "formatted-phone": + emit([[{"filename": "docs/contact.md", "status": "added", "additions": 1, "deletions": 0}]]) + elif scenario == "path-id": + path_id = "ABCDE" + "1234" + "F" + emit([[{"filename": f"docs/{path_id}.md", "status": "added", "additions": 1, "deletions": 0}]]) + elif scenario == "binary-delete": + emit([[{"filename": "docs/old.png", "status": "removed", "additions": 0, "deletions": 0}]]) elif scenario == "malformed-files": - emit([[{"filename": "docs/example.md", "status": "added"}], [{"filename": 3, "status": "modified"}]]) + emit([[{"filename": "docs/example.md", "status": "added", "additions": 1, "deletions": 0}], [{"filename": 3, "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario == "missing-file-status": - emit([[{"filename": "docs/example.md", "status": "added"}], [{"filename": "docs/second.md"}]]) + emit([[{"filename": "docs/example.md", "status": "added", "additions": 1, "deletions": 0}], [{"filename": "docs/second.md", "additions": 1, "deletions": 0}]]) elif scenario == "files-count-mismatch": - emit([[{"filename": "docs/example.md", "status": "added"}]]) + emit([[{"filename": "docs/example.md", "status": "added", "additions": 1, "deletions": 0}]]) else: - emit([[{"filename": "docs/example.md", "status": "added"}], [{"filename": "docs/second.md", "status": "modified"}]]) + additions = 2 if scenario == "diff-truncated-payload" else 1 + emit([[{"filename": "docs/example.md", "status": "added", "additions": additions, "deletions": 0}], [{"filename": "docs/second.md", "status": "modified", "additions": 1, "deletions": 0}]]) elif "/contents/" in joined: if scenario == "surface-fail": fail("controlled surface read failure") if scenario == "surface-malformed": emit({"content": "not-base64"}) else: - surface = {"files": [{"path": "src/example.rs"}]} + digest = "a" * 64 + surface = {"schema_version": 1, "manifest_sha256": digest, + "files": [{"path": "src/example.rs", "sha256": digest}]} + if scenario == "surface-schema-malformed": + surface = {"files": [{"path": "src/example.rs"}]} emit({"content": base64.b64encode(json.dumps(surface).encode()).decode()}) else: fail("unknown API fixture") @@ -245,6 +269,9 @@ def test_binary_deletion_is_not_treated_as_added_content(self): def test_malformed_surface_is_indeterminate(self): self.assert_indeterminate("surface-malformed", "compatibility surface could not be decoded") + def test_surface_with_no_v1_manifest_schema_is_indeterminate(self): + self.assert_indeterminate("surface-schema-malformed", "compatibility surface could not be decoded") + def test_malformed_changed_file_is_indeterminate(self): self.assert_indeterminate("malformed-files", "could not read the complete changed-file set") @@ -263,12 +290,23 @@ def test_fractional_thread_count_is_indeterminate(self): def test_empty_page_cannot_claim_more_threads(self): self.assert_indeterminate("threads-empty-more", "no nodes while claiming another page") + def test_template_checklist_permalink_on_continuation_passes(self): + result = self.run_gate("checklist-template-continuation") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("description links a completed review-checklist item", result.stdout) + def test_foreign_checklist_link_blocks(self): self.assert_blocked("checklist-foreign", "same-repository line-specific") def test_unlinked_checklist_text_blocks(self): self.assert_blocked("checklist-unlinked", "same-repository line-specific") + def test_omitted_nonremoved_diff_file_is_indeterminate(self): + self.assert_indeterminate("diff-omits-file", "privacy diff omits or duplicates") + + def test_truncated_textual_diff_payload_is_indeterminate(self): + self.assert_indeterminate("diff-truncated-payload", "privacy diff line totals") + if __name__ == "__main__": unittest.main(verbosity=2) From 1d81ac72211fc94222a1cf0c9f8f16fdd7956354 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:00:21 +0530 Subject: [PATCH 12/46] tooling: write the merge gate down, with each rule's failure beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate for "may this PR be merged" has lived in one session's head, and it has been got wrong from both directions in a single day. On 2026-09-12 four PRs were merged before their reviews arrived — #312 by three seconds — leaving eleven findings, four of them P1, on code already in master. The gate added to stop that then deadlocked a clean PR, because a Codex pass with no findings submits no review object at all, only a summary row and a thumbs-up. Later the same day two separate sessions reported a PR "clean, zero open threads" from a query issued seconds before its review posted, and a third reported clean without noticing master had moved. One rule underneath all of it: **a review is only evidence about the commit it names.** "No findings yet" and "not looked yet" are indistinguishable unless you read which commit was looked at — so the script reads the commit in the summary table, not the review list and not the comment's timestamp, because that comment is edited in place and its `created_at` means nothing. Six checks, each carrying the incident that motivates it: 1. mergeable against its base; 2. based on master — `ci.yml` fires on `pull_request` into master only, so a stacked PR runs no CI at all and its green tick measures nothing; 3. every check concluded and none failing; 4. a Codex review naming *this* head, completed rather than running; 5. zero unresolved threads, paginated, since `required_conversation_resolution` makes this the gate rather than a courtesy — and a `first:100` page once hid 19 open threads; 6. a privacy scan of the **whole merge diff**, not the author's own commits: a real transaction reference sat in a comment on public master through two PRs because each author scanned only what they wrote. The scan was wrong twice while being written, both times in the direction that reports clean: - `XXXXX1234X` is a fabricated PAN and `X` is an uppercase letter, so it matched the PAN shape. A gate that cries wolf gets ignored, which is worse than no gate, so obvious placeholders are excluded — but by an explicit list, never by widening the shape, which would start excusing real values. - the first placeholder pattern used a backreference, which is not ERE. grep errored, returned no matches, and the scan reported clean. That is the same shape as a control reporting zero because its branch never fired, so the pattern is now probed against a string it must match before being trusted. - UUID tails are twelve hex characters and often all digits; the canonical `550e8400-…-446655440000` tripped it. UUIDs are stripped before scanning. Verified in both directions rather than assumed: a diff carrying a real-shaped GSTIN, mobile number and account number is flagged; one carrying only placeholders and a UUID is not. Run against three live PRs it blocks each for a different, correct reason — a running review, unresolved threads, and a non-master base. Usage: `scripts/merge-gate.sh [owner/name]`; exit 0 may merge, 1 must not. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 144 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100755 scripts/merge-gate.sh diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh new file mode 100755 index 000000000..3eb16739b --- /dev/null +++ b/scripts/merge-gate.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Decide whether a pull request may be merged, and say why not when it may not. +# +# Every condition here exists because it failed. On 2026-09-12 four PRs were +# merged before their reviews arrived — #312 by three seconds — leaving eleven +# findings, four of them P1, on code already in master. The gate added to stop +# that then deadlocked a clean PR, because a Codex pass with no findings +# submits no review object at all. Later the same day two separate sessions +# reported a PR "clean, zero open threads" from a query issued seconds before +# the review posted. +# +# The single rule underneath all of it: a review is only evidence about the +# commit it names. Freshness is not implied by the absence of findings, and +# "no findings yet" is indistinguishable from "not looked yet" unless you +# check which commit was looked at. +# +# Usage: scripts/merge-gate.sh [--repo owner/name] +# Exit: 0 = may merge, 1 = must not, 2 = could not determine. + +set -uo pipefail + +PR="${1:-}" +REPO="${2:-}" +[ -n "$PR" ] || { echo "usage: $0 [owner/name]" >&2; exit 2; } +if [ -n "$REPO" ]; then R=(--repo "$REPO"); else R=(); fi + +fail=0 +say() { printf ' %-6s %s\n' "$1" "$2"; } +bad() { say "BLOCK" "$1"; fail=1; } +good() { say "ok" "$1"; } + +meta=$(gh pr view "$PR" "${R[@]}" --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft 2>/dev/null) || { + echo "could not read PR #$PR" >&2; exit 2; } +head=$(jq -r .headRefOid <<<"$meta") +base=$(jq -r .baseRefName <<<"$meta") +mergeable=$(jq -r .mergeable <<<"$meta") +state=$(jq -r .mergeStateStatus <<<"$meta") +draft=$(jq -r .isDraft <<<"$meta") +short=${head:0:7} + +echo "PR #$PR head=$short base=$base $mergeable/$state" + +[ "$draft" = "false" ] || bad "draft" + +# 1. Mergeable against its base. +case "$mergeable" in + MERGEABLE) good "no conflicts" ;; + CONFLICTING) bad "conflicts with $base — rebase first" ;; + *) bad "mergeability still UNKNOWN; re-run in a moment" ;; +esac + +# 2. Based on master. ci.yml fires on pull_request into master ONLY, so a +# stacked PR runs no CI at all and a green tick on it measures nothing. +if [ "$base" = "master" ]; then + good "based on master (CI actually runs)" +else + bad "based on '$base', not master — ci.yml does not fire, so checks here prove nothing" +fi + +# 3. Every check concluded, none failed. SKIPPED is fine; PENDING is not. +checks=$(gh pr checks "$PR" "${R[@]}" 2>/dev/null) +if [ -z "$checks" ]; then + bad "no checks reported" +else + pend=$(grep -cE '[[:space:]](pending|queued|in_progress)[[:space:]]' <<<"$checks") + bust=$(grep -cE '[[:space:]](fail|failure|cancelled|timed_out)[[:space:]]' <<<"$checks") + [ "$pend" -eq 0 ] || bad "$pend check(s) still running" + [ "$bust" -eq 0 ] || bad "$bust check(s) failing" + [ "$pend" -eq 0 ] && [ "$bust" -eq 0 ] && good "all checks concluded, none failing" +fi + +# 4. A Codex review that names THIS head. The summary comment is edited in +# place, so its created_at is meaningless — read the commit in its table. +# A clean pass emits no review object, only this row plus a thumbs-up, which +# is why the row and not the review list is the thing to read. +body=$(gh api "repos/${REPO:-lamemustafa/bridge}/issues/$PR/comments" \ + --jq '.[] | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>/dev/null) +row=$(grep -E '^\| (📝|🔍)' <<<"$body" | tail -1) +if [ -z "$row" ]; then + bad "no Codex review summary at all" +elif ! grep -q "$short" <<<"$row"; then + bad "latest review names a different commit than $short — it has not seen this push" +elif grep -q "Running" <<<"$row"; then + bad "review still running on $short — 'no findings yet' is not 'no findings'" +elif grep -qE "Completed|Failed" <<<"$row"; then + good "review completed on $short" +else + bad "could not read review state from: $row" +fi + +# 5. No unresolved threads. required_conversation_resolution is on, so this is +# the gate, not a courtesy. Paginate: a first:100 page once hid 19 threads. +threads=$(gh api graphql -f query="{repository(owner:\"${REPO%%/*}\",name:\"${REPO##*/}\"){pullRequest(number:$PR){reviewThreads(first:100){totalCount pageInfo{hasNextPage} nodes{isResolved}}}}}" 2>/dev/null \ + || gh api graphql -f query="{repository(owner:\"lamemustafa\",name:\"bridge\"){pullRequest(number:$PR){reviewThreads(first:100){totalCount pageInfo{hasNextPage} nodes{isResolved}}}}}" 2>/dev/null) +if [ -z "$threads" ]; then + bad "could not read review threads" +else + total=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$threads") + more=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$threads") + open=$(jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)]|length' <<<"$threads") + [ "$more" = "false" ] || bad "more than 100 threads — paginate before trusting this count" + if [ "$open" -eq 0 ]; then good "0 of $total threads unresolved"; else bad "$open of $total threads unresolved"; fi +fi + +# 6. Nothing shaped like client data in the diff. Scan the WHOLE merge diff, +# not your own commits: a real transaction reference sat in a comment on +# public master through two PRs because each author scanned only what they +# wrote. An identifier is findable by shape; no name list catches it. +diff=$(gh pr diff "$PR" "${R[@]}" 2>/dev/null) +# A UUID's last group is twelve hex characters and is all digits often enough +# to look like an account number — the canonical `550e8400-…-446655440000` +# tripped this on its first run. Remove UUIDs before scanning rather than +# widening the placeholder list, which would start excusing real values. +diff=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' <<<"$diff") +if [ -z "$diff" ]; then + bad "could not read diff for the privacy scan" +else + # Placeholders match these shapes too — `XXXXX1234X` is a fabricated PAN and + # `X` is an uppercase letter. A gate that cries wolf gets ignored, which is + # worse than no gate, so drop anything whose letters are one repeated + # character or whose digits are a repeat or a straight run. + # No backreferences: this must be plain ERE or grep errors, and a grep that + # errors returns no matches — which reads exactly like a clean scan. That is + # the same shape as a control reporting zero because its branch never fired, + # so the placeholder list is spelled out instead of `([0-9])\\1+`. + placeholder='^(X+|Z+|A+)[0-9]+(X|Z|A)?$|^[0-9]{2}(X+|Z+|A+)[0-9]+[0-9A-Z]*$|^(0+|1+|2+|3+|4+|5+|6+|7+|8+|9+)$|^(0?1234567890|1234567890[0-9]*)$' + hits=$(grep -Eo '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|\b[A-Z]{5}[0-9]{4}[A-Z]\b|\b[6-9][0-9]{9}\b' <<<"$diff" \ + | sort -u | { grep -cvE "$placeholder" || true; }) + runs=$(grep -oE '\b[0-9]{11,18}\b' <<<"$diff" | sort -u | { grep -cvE "$placeholder" || true; }) + # Prove the pattern itself compiles; a silent regex error is the failure mode + # this whole block exists to avoid. + # The probe string must be one the pattern genuinely matches, or the probe + # fails on a perfectly good pattern — which is how this check first behaved. + printf 'XXXXX1234X\n' | grep -qE "$placeholder" \ + || { bad "privacy-scan pattern failed to compile or match its own probe"; hits=-1; } + if [ "$hits" -eq 0 ] && [ "$runs" -eq 0 ]; then + good "no GSTIN/PAN/mobile shapes, no unexplained long digit runs" + else + bad "privacy scan: $hits identifier shape(s), $runs unexplained long digit run(s) — inspect before merging" + fi +fi + +echo +if [ "$fail" -eq 0 ]; then echo "MAY MERGE"; exit 0; else echo "MUST NOT MERGE"; exit 1; fi From 57c5a008ed3db4080ff3ae548b863b0bcac732eb Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:18:47 +0530 Subject: [PATCH 13/46] tooling: fix ten defects in the gate, including one it was written to catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight findings on the first revision, plus two more found by testing it rather than reading it. A script whose whole purpose is rigour had ten holes, and the two that matter most were found by running it against the very case it exists for. **The scan could not see the leak that motivated it.** The pattern was `\b[0-9]{11,18}\b`. The reference it was written to catch is written `HDF CH12345678901` — glued to letters — and `\b` does not match between `H` and `1`. Verified directly: the old pattern returns nothing on that string. The gate would have passed the exact commit it was built to stop. The word boundaries are gone. **It scanned removals as well as additions,** so it blocked the PR that *deletes* leaked data while passing the one that adds it. Added lines only now. Then the eight from review, each confirmed against the tool it names: - `BEHIND` was not blocked. `mergeable` can read `MERGEABLE` while `mergeStateStatus` is `BEHIND`, meaning the head never saw the current base — so its CI and its review describe a tree that no longer exists. - `gh pr checks` buckets are `pass|fail|pending|skipping|cancel`. The regex looked for `cancelled`, so a cancelled check counted as neither failing nor pending and read as success. Now the JSON buckets are read instead of the text columns, which a check name containing spaces mis-splits anyway. - A review reported `Failed` was treated exactly like `Completed`. A failed run means nothing looked at the code — the original bug inverted. Only `Completed` passes. - `--repo owner/name` was documented and not parsed: `REPO` became the literal `--repo`. Proper option parsing, and `OWNER/NAME` is validated. - On a transient GraphQL failure the thread query fell back to a hardcoded repository with the same PR number. A same-numbered PR elsewhere with no open threads would have read as a clean result. The fallback is gone. - The issue-comments query was unpaginated; the Codex summary is an ordinary comment and the default page is 30. - Nothing bound the merge to the reviewed commit. The script now prints the merge command carrying `--match-head-commit `. - Draft and non-OPEN states were not checked. Dropping `\b` made hex digests visible — a sha256 in a lockfile contains long digit runs by chance — so digests and UUIDs are stripped before scanning. Both are machine-generated and neither can carry a client identifier; the placeholder list was deliberately *not* loosened, because loosening the shape starts excusing real values. Verified in both directions rather than assumed. On a 4,799-line real diff: zero false positives. On constructed input: the glued-to-letters reference, a real-shaped GSTIN and a real-shaped mobile number are all flagged, while a PR that only removes a leak is not. Every exit path exercised — malformed `--repo`, missing value, unknown option, no arguments all return 2; a stale head returns 1. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 192 ++++++++++++++++++++++++++---------------- 1 file changed, 121 insertions(+), 71 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 3eb16739b..516e9065d 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -1,53 +1,81 @@ #!/usr/bin/env bash # Decide whether a pull request may be merged, and say why not when it may not. # -# Every condition here exists because it failed. On 2026-09-12 four PRs were -# merged before their reviews arrived — #312 by three seconds — leaving eleven -# findings, four of them P1, on code already in master. The gate added to stop -# that then deadlocked a clean PR, because a Codex pass with no findings -# submits no review object at all. Later the same day two separate sessions -# reported a PR "clean, zero open threads" from a query issued seconds before -# the review posted. +# Every rule here exists because it failed. On 2026-09-12 four PRs were merged +# before their reviews arrived — one by three seconds — leaving eleven findings, +# four of them P1, on code already in master. The gate added to stop that then +# deadlocked a clean PR, because a Codex pass with no findings submits no review +# object at all. Later the same day two sessions reported a PR "clean, zero open +# threads" from a query issued seconds before its review posted. # -# The single rule underneath all of it: a review is only evidence about the -# commit it names. Freshness is not implied by the absence of findings, and -# "no findings yet" is indistinguishable from "not looked yet" unless you +# The rule underneath all of it: a review is only evidence about the commit it +# names. "No findings yet" and "not looked yet" are indistinguishable unless you # check which commit was looked at. # -# Usage: scripts/merge-gate.sh [--repo owner/name] -# Exit: 0 = may merge, 1 = must not, 2 = could not determine. +# Usage: scripts/merge-gate.sh [--repo OWNER/NAME] +# Exit: 0 may merge, 1 must not, 2 could not determine. set -uo pipefail -PR="${1:-}" -REPO="${2:-}" -[ -n "$PR" ] || { echo "usage: $0 [owner/name]" >&2; exit 2; } -if [ -n "$REPO" ]; then R=(--repo "$REPO"); else R=(); fi +PR=""; REPO="" +while [ $# -gt 0 ]; do + case "$1" in + --repo) REPO="${2:-}"; [ -n "$REPO" ] || { echo "--repo needs OWNER/NAME" >&2; exit 2; }; shift 2 ;; + --repo=*) REPO="${1#--repo=}"; shift ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + -*) echo "unknown option: $1" >&2; exit 2 ;; + *) [ -z "$PR" ] && PR="$1" || { echo "unexpected argument: $1" >&2; exit 2; }; shift ;; + esac +done +[ -n "$PR" ] || { echo "usage: $0 [--repo OWNER/NAME]" >&2; exit 2; } + +# Resolve the repository ONCE, explicitly. Never fall back to a different +# repository on a transient failure: a same-numbered PR elsewhere with no open +# threads would read as a clean result for the PR actually being gated. +if [ -z "$REPO" ]; then + REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) \ + || { echo "could not determine repository; pass --repo OWNER/NAME" >&2; exit 2; } +fi +OWNER="${REPO%%/*}"; NAME="${REPO##*/}" +[ -n "$OWNER" ] && [ -n "$NAME" ] && [ "$OWNER" != "$REPO" ] \ + || { echo "--repo must be OWNER/NAME, got '$REPO'" >&2; exit 2; } fail=0 say() { printf ' %-6s %s\n' "$1" "$2"; } bad() { say "BLOCK" "$1"; fail=1; } good() { say "ok" "$1"; } +die() { echo "$1" >&2; exit 2; } -meta=$(gh pr view "$PR" "${R[@]}" --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft 2>/dev/null) || { - echo "could not read PR #$PR" >&2; exit 2; } +meta=$(gh pr view "$PR" --repo "$REPO" \ + --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state 2>/dev/null) \ + || die "could not read PR #$PR in $REPO" head=$(jq -r .headRefOid <<<"$meta") base=$(jq -r .baseRefName <<<"$meta") mergeable=$(jq -r .mergeable <<<"$meta") -state=$(jq -r .mergeStateStatus <<<"$meta") +mstate=$(jq -r .mergeStateStatus <<<"$meta") draft=$(jq -r .isDraft <<<"$meta") +pstate=$(jq -r .state <<<"$meta") short=${head:0:7} -echo "PR #$PR head=$short base=$base $mergeable/$state" +echo "PR #$PR ($REPO) head=$short base=$base $mergeable/$mstate" +[ "$pstate" = "OPEN" ] || bad "PR is $pstate, not OPEN" [ "$draft" = "false" ] || bad "draft" -# 1. Mergeable against its base. +# 1. Mergeable, and not merely "no conflicts". BEHIND means the head has not +# seen the current base, so its CI and its review describe a tree that no +# longer exists — the branch protection is strict and would refuse anyway. case "$mergeable" in MERGEABLE) good "no conflicts" ;; CONFLICTING) bad "conflicts with $base — rebase first" ;; *) bad "mergeability still UNKNOWN; re-run in a moment" ;; esac +case "$mstate" in + BEHIND) bad "head is BEHIND $base — its checks and review describe a stale tree; rebase" ;; + DIRTY) bad "merge state DIRTY — conflicts" ;; + UNKNOWN) bad "merge state UNKNOWN; re-run in a moment" ;; + *) good "merge state $mstate is not stale" ;; +esac # 2. Based on master. ci.yml fires on pull_request into master ONLY, so a # stacked PR runs no CI at all and a green tick on it measures nothing. @@ -57,43 +85,56 @@ else bad "based on '$base', not master — ci.yml does not fire, so checks here prove nothing" fi -# 3. Every check concluded, none failed. SKIPPED is fine; PENDING is not. -checks=$(gh pr checks "$PR" "${R[@]}" 2>/dev/null) -if [ -z "$checks" ]; then +# 3. Every check concluded and none failed. Read the JSON buckets rather than +# the human columns: a check name contains spaces, so column-splitting the +# text output misreads the bucket. gh's buckets are pass/fail/pending/ +# skipping/cancel — note `cancel`, not `cancelled`; matching the longer word +# left a cancelled check counted as neither failing nor pending, which read +# as success. +buckets=$(gh pr checks "$PR" --repo "$REPO" --json bucket,name 2>/dev/null) +if [ -z "$buckets" ] || [ "$buckets" = "[]" ]; then bad "no checks reported" else - pend=$(grep -cE '[[:space:]](pending|queued|in_progress)[[:space:]]' <<<"$checks") - bust=$(grep -cE '[[:space:]](fail|failure|cancelled|timed_out)[[:space:]]' <<<"$checks") - [ "$pend" -eq 0 ] || bad "$pend check(s) still running" - [ "$bust" -eq 0 ] || bad "$bust check(s) failing" - [ "$pend" -eq 0 ] && [ "$bust" -eq 0 ] && good "all checks concluded, none failing" + pend=$(jq '[.[]|select(.bucket=="pending")]|length' <<<"$buckets") + bust=$(jq '[.[]|select(.bucket=="fail" or .bucket=="cancel")]|length' <<<"$buckets") + tot=$(jq 'length' <<<"$buckets") + [ "$pend" -eq 0 ] || bad "$pend of $tot check(s) still running" + [ "$bust" -eq 0 ] || bad "$bust of $tot check(s) failed or were cancelled: $(jq -r '[.[]|select(.bucket=="fail" or .bucket=="cancel")|.name]|join(", ")' <<<"$buckets")" + [ "$pend" -eq 0 ] && [ "$bust" -eq 0 ] && good "all $tot checks concluded, none failing or cancelled" fi -# 4. A Codex review that names THIS head. The summary comment is edited in -# place, so its created_at is meaningless — read the commit in its table. -# A clean pass emits no review object, only this row plus a thumbs-up, which -# is why the row and not the review list is the thing to read. -body=$(gh api "repos/${REPO:-lamemustafa/bridge}/issues/$PR/comments" \ +# 4. A Codex review that names THIS head, and actually completed. The summary +# comment is edited in place, so its created_at is meaningless — read the +# commit in its table. A clean pass emits no review object, only this row +# plus a thumbs-up, which is why the row and not the review list is read. +# Paginate: the summary is an ordinary issue comment and the default page is +# 30, so on a busy PR it is not on the first one. +body=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ --jq '.[] | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>/dev/null) row=$(grep -E '^\| (📝|🔍)' <<<"$body" | tail -1) if [ -z "$row" ]; then bad "no Codex review summary at all" elif ! grep -q "$short" <<<"$row"; then bad "latest review names a different commit than $short — it has not seen this push" -elif grep -q "Running" <<<"$row"; then - bad "review still running on $short — 'no findings yet' is not 'no findings'" -elif grep -qE "Completed|Failed" <<<"$row"; then +elif grep -q 'Completed' <<<"$row"; then good "review completed on $short" else - bad "could not read review state from: $row" + # Running, Failed, Errored — none of these is a review. A failed review run + # means nothing looked at the code, which is exactly the state this gate + # exists to catch; treating it as completed was the original bug inverted. + st=$(grep -oE 'Running|Failed|Errored|Cancelled' <<<"$row" | head -1) + bad "review state '${st:-unrecognised}' on $short — only Completed counts" fi # 5. No unresolved threads. required_conversation_resolution is on, so this is # the gate, not a courtesy. Paginate: a first:100 page once hid 19 threads. -threads=$(gh api graphql -f query="{repository(owner:\"${REPO%%/*}\",name:\"${REPO##*/}\"){pullRequest(number:$PR){reviewThreads(first:100){totalCount pageInfo{hasNextPage} nodes{isResolved}}}}}" 2>/dev/null \ - || gh api graphql -f query="{repository(owner:\"lamemustafa\",name:\"bridge\"){pullRequest(number:$PR){reviewThreads(first:100){totalCount pageInfo{hasNextPage} nodes{isResolved}}}}}" 2>/dev/null) -if [ -z "$threads" ]; then - bad "could not read review threads" +threads=$(gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" -f query=' + query($owner:String!,$name:String!,$pr:Int!){ + repository(owner:$owner,name:$name){ + pullRequest(number:$pr){ + reviewThreads(first:100){ totalCount pageInfo{hasNextPage} nodes{isResolved} }}}}' 2>/dev/null) +if [ -z "$threads" ] || [ "$(jq -r '.data.repository.pullRequest' <<<"$threads")" = "null" ]; then + bad "could not read review threads for $REPO#$PR" else total=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$threads") more=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$threads") @@ -102,43 +143,52 @@ else if [ "$open" -eq 0 ]; then good "0 of $total threads unresolved"; else bad "$open of $total threads unresolved"; fi fi -# 6. Nothing shaped like client data in the diff. Scan the WHOLE merge diff, -# not your own commits: a real transaction reference sat in a comment on -# public master through two PRs because each author scanned only what they -# wrote. An identifier is findable by shape; no name list catches it. -diff=$(gh pr diff "$PR" "${R[@]}" 2>/dev/null) -# A UUID's last group is twelve hex characters and is all digits often enough -# to look like an account number — the canonical `550e8400-…-446655440000` -# tripped this on its first run. Remove UUIDs before scanning rather than -# widening the placeholder list, which would start excusing real values. -diff=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' <<<"$diff") +# 6. Nothing shaped like client data in what this PR ADDS. Scan the whole merge +# diff rather than the author's own commits — a real transaction reference +# sat in a comment on public master through two PRs because each author +# scanned only what they wrote — but scan ADDED lines only, or the gate +# blocks the very PR that deletes a leak. +diff=$(gh pr diff "$PR" --repo "$REPO" 2>/dev/null) if [ -z "$diff" ]; then bad "could not read diff for the privacy scan" else - # Placeholders match these shapes too — `XXXXX1234X` is a fabricated PAN and - # `X` is an uppercase letter. A gate that cries wolf gets ignored, which is - # worse than no gate, so drop anything whose letters are one repeated - # character or whose digits are a repeat or a straight run. - # No backreferences: this must be plain ERE or grep errors, and a grep that - # errors returns no matches — which reads exactly like a clean scan. That is - # the same shape as a control reporting zero because its branch never fired, - # so the placeholder list is spelled out instead of `([0-9])\\1+`. + # A UUID's last group is twelve hex characters and often all digits; the + # canonical 550e8400-…-446655440000 tripped this. Strip UUIDs rather than + # widening the placeholder list, which would start excusing real values. + # Hex digests are the other machine-generated shape that trips this: a + # sha256 in a lockfile or a sealed surface manifest contains long digit runs + # by chance, and dropping \b (see below) made them visible. Strip digests and + # UUIDs — both are generated, neither can carry a client identifier — rather + # than loosening the placeholder list, which would start excusing real values. + added=$(grep '^+' <<<"$diff" | grep -v '^+++' \ + | sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' \ + | sed -E 's/[0-9a-fA-F]{32,}//g') + # Placeholders match these shapes too — XXXXX1234X is a fabricated PAN and X + # is an uppercase letter. A gate that cries wolf gets ignored, so obvious + # placeholders are excluded by an EXPLICIT list; widening the shape itself + # would start excusing real values. No backreferences: this must be plain + # ERE, and a grep that errors returns nothing, which reads as a clean scan. placeholder='^(X+|Z+|A+)[0-9]+(X|Z|A)?$|^[0-9]{2}(X+|Z+|A+)[0-9]+[0-9A-Z]*$|^(0+|1+|2+|3+|4+|5+|6+|7+|8+|9+)$|^(0?1234567890|1234567890[0-9]*)$' - hits=$(grep -Eo '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|\b[A-Z]{5}[0-9]{4}[A-Z]\b|\b[6-9][0-9]{9}\b' <<<"$diff" \ - | sort -u | { grep -cvE "$placeholder" || true; }) - runs=$(grep -oE '\b[0-9]{11,18}\b' <<<"$diff" | sort -u | { grep -cvE "$placeholder" || true; }) - # Prove the pattern itself compiles; a silent regex error is the failure mode - # this whole block exists to avoid. - # The probe string must be one the pattern genuinely matches, or the probe - # fails on a perfectly good pattern — which is how this check first behaved. printf 'XXXXX1234X\n' | grep -qE "$placeholder" \ - || { bad "privacy-scan pattern failed to compile or match its own probe"; hits=-1; } + || { bad "privacy-scan pattern failed to compile or match its own probe"; fail=1; } + # NO \b around the digit run. The leak that motivated this gate was written + # `HDF CH12345678901` — glued to letters — and \b does not match between `H` + # and `1`, so the scan that was supposed to catch it could not see it at all. + hits=$(grep -Eo '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|[A-Z]{5}[0-9]{4}[A-Z]|[6-9][0-9]{9}' <<<"$added" \ + | sort -u | { grep -cvE "$placeholder" || true; }) + runs=$(grep -Eo '[0-9]{11,18}' <<<"$added" | sort -u | { grep -cvE "$placeholder" || true; }) if [ "$hits" -eq 0 ] && [ "$runs" -eq 0 ]; then - good "no GSTIN/PAN/mobile shapes, no unexplained long digit runs" + good "added lines carry no identifier shapes and no unexplained long digit runs" else - bad "privacy scan: $hits identifier shape(s), $runs unexplained long digit run(s) — inspect before merging" + bad "privacy scan: $hits identifier shape(s), $runs unexplained long digit run(s) in ADDED lines — inspect before merging" fi fi echo -if [ "$fail" -eq 0 ]; then echo "MAY MERGE"; exit 0; else echo "MUST NOT MERGE"; exit 1; fi +if [ "$fail" -ne 0 ]; then echo "MUST NOT MERGE"; exit 1; fi +# 7. Bind the merge to the commit that was actually reviewed. Between this +# check and the merge the head can move, and everything above would then +# describe a commit the PR no longer points at. +echo "MAY MERGE — bind the merge to the reviewed commit:" +echo " gh pr merge $PR --repo $REPO --squash --match-head-commit $head" +exit 0 From 5c87b0030c474c6240ce904a0792a26358266e1b Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:19:52 +0530 Subject: [PATCH 14/46] tooling: excuse padded fixtures, and keep the scan's own file clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the gate across the whole open queue surfaced two false positives, both worth fixing rather than tolerating — a gate that cries wolf gets ignored, which is the failure mode this one is least able to afford. `00000000005551` in a constructed test page is a padded MICR fixture, not an account number. Excused by `^0{6,}[0-9]{1,5}$`, kept deliberately narrow: a real account number can begin with a zero or two, so only a run of six or more leading zeros — plainly synthetic — is excused. Verified that three real-shaped account numbers with one and two leading zeros are still flagged. The other was this script flagging itself. A comment named the canonical RFC example UUID with its middle elided, so the UUID stripper could not match it while its digits still read as an identifier. The comment no longer carries example digits at all: a literal in a comment is a literal in the diff, and this scan reads its own file like any other — which is exactly how the reference that motivated the gate reached public master in the first place. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 516e9065d..3f4d63958 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -153,13 +153,16 @@ if [ -z "$diff" ]; then bad "could not read diff for the privacy scan" else # A UUID's last group is twelve hex characters and often all digits; the - # canonical 550e8400-…-446655440000 tripped this. Strip UUIDs rather than + # canonical RFC example UUID tripped this. Strip UUIDs rather than # widening the placeholder list, which would start excusing real values. # Hex digests are the other machine-generated shape that trips this: a # sha256 in a lockfile or a sealed surface manifest contains long digit runs - # by chance, and dropping \b (see below) made them visible. Strip digests and - # UUIDs — both are generated, neither can carry a client identifier — rather - # than loosening the placeholder list, which would start excusing real values. + # by chance, and dropping \b (see below) made them visible. The canonical RFC + # example UUID tripped it the same way. Strip digests and UUIDs — both are + # generated, neither can carry a client identifier — rather than loosening the + # placeholder list, which would start excusing real values. (Deliberately no + # example digits in this comment: a literal here is a literal in the diff, + # and this scan reads its own file like any other.) added=$(grep '^+' <<<"$diff" | grep -v '^+++' \ | sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' \ | sed -E 's/[0-9a-fA-F]{32,}//g') @@ -168,7 +171,11 @@ else # placeholders are excluded by an EXPLICIT list; widening the shape itself # would start excusing real values. No backreferences: this must be plain # ERE, and a grep that errors returns nothing, which reads as a clean scan. - placeholder='^(X+|Z+|A+)[0-9]+(X|Z|A)?$|^[0-9]{2}(X+|Z+|A+)[0-9]+[0-9A-Z]*$|^(0+|1+|2+|3+|4+|5+|6+|7+|8+|9+)$|^(0?1234567890|1234567890[0-9]*)$' + # `^0{6,}` is the padded-fixture shape: six or more leading zeros then a small + # number, as constructed test pages use for MICR and postcode fields. Kept + # narrow on purpose — a real account number can begin with a zero or two, so + # only a run long enough to be plainly synthetic is excused. + placeholder='^(X+|Z+|A+)[0-9]+(X|Z|A)?$|^[0-9]{2}(X+|Z+|A+)[0-9]+[0-9A-Z]*$|^(0+|1+|2+|3+|4+|5+|6+|7+|8+|9+)$|^(0?1234567890|1234567890[0-9]*)$|^0{6,}[0-9]{1,5}$' printf 'XXXXX1234X\n' | grep -qE "$placeholder" \ || { bad "privacy-scan pattern failed to compile or match its own probe"; fail=1; } # NO \b around the digit run. The leak that motivated this gate was written From c9fe9d5b23c7cc3c57b8a23d686d6a62f74bb609 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:33:16 +0530 Subject: [PATCH 15/46] tooling: close two spoofable holes and stop exempting silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round two on the gate: seven findings, two of them holes a determined actor could walk through, and one the gate found in its own author the moment it ran. **The review row was spoofable.** The summary was located by a marker string in a comment body — and a marker is just text. Any PR participant could post an ordinary comment carrying that marker and a `Completed` row for the current SHA, and the gate would have recorded a review that never happened. It now requires the comment's author to be `chatgpt-codex-connector[bot]` with a `Bot` account type, checked alongside the marker. **A binary file was an invisible pass.** `gh pr diff` emits `Binary files … differ` instead of content, so a screenshot or PDF of a client statement produced a scan with nothing to find and a clean verdict — the exact shape of a control reporting zero because it cannot see, which this repository has written down twice. Binary changes now block and are named for inspection by hand. **Exemptions are reported rather than silent.** Stripping UUIDs and hex digests keeps the false-positive rate low enough that the gate gets read at all, but a blanket exemption nobody can see is how a real value gets erased. The count of exempted lines is now printed. Four more, each confirmed: - `mergeStateStatus=BLOCKED` fell into a wildcard that printed `ok`, reading as approval for a state GitHub is refusing. It now prints a note, and an unrecognised state blocks rather than being guessed at. - `reviewThreads` blocked permanently once a PR passed 100 threads, since `hasNextPage` stays true however many are resolved. A PR accumulates threads by being reviewed carefully, so the rule punished exactly the PRs it should trust. It paginates now. - `gh pr checks` exits nonzero both when a check fails and when the query fails, so empty stdout from a broken query was reported as "no checks" — a statement about the PR rather than about the request. The two are now distinguished by whether anything reached stderr. - AGENTS.md:23 requires every PR to link a completed line in `review-checklist.md`, and the gate did not check the repository's own stated pre-merge rule. It does now — and immediately blocked this PR, whose description did not carry one. Each fix exercised rather than assumed: the author filter admits only the Bot account; the binary matcher fires on a binary patch and not on a text one; the thread loop terminates and counts across pages; the checklist rule blocks a PR without the link. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 97 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 77 insertions(+), 20 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 3f4d63958..24a891a7a 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -41,13 +41,15 @@ OWNER="${REPO%%/*}"; NAME="${REPO##*/}" || { echo "--repo must be OWNER/NAME, got '$REPO'" >&2; exit 2; } fail=0 +blocked_seen=0 +errfile=$(mktemp); trap 'rm -f "$errfile"' EXIT say() { printf ' %-6s %s\n' "$1" "$2"; } bad() { say "BLOCK" "$1"; fail=1; } good() { say "ok" "$1"; } die() { echo "$1" >&2; exit 2; } meta=$(gh pr view "$PR" --repo "$REPO" \ - --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state 2>/dev/null) \ + --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body 2>/dev/null) \ || die "could not read PR #$PR in $REPO" head=$(jq -r .headRefOid <<<"$meta") base=$(jq -r .baseRefName <<<"$meta") @@ -74,7 +76,15 @@ case "$mstate" in BEHIND) bad "head is BEHIND $base — its checks and review describe a stale tree; rebase" ;; DIRTY) bad "merge state DIRTY — conflicts" ;; UNKNOWN) bad "merge state UNKNOWN; re-run in a moment" ;; - *) good "merge state $mstate is not stale" ;; + BLOCKED) + # GitHub blocks for reasons this script may not model — a missing required + # approval, for instance. The checks below usually explain it, but printing + # `ok` for BLOCKED reads as approval for a state GitHub is refusing, so say + # what it is and let the specific checks account for it. + say "note" "merge state BLOCKED — GitHub is refusing; the checks below should say why" + blocked_seen=1 ;; + CLEAN|HAS_HOOKS|UNSTABLE) good "merge state $mstate is not stale" ;; + *) bad "unrecognised merge state '$mstate' — refusing rather than guessing" ;; esac # 2. Based on master. ci.yml fires on pull_request into master ONLY, so a @@ -91,9 +101,19 @@ fi # skipping/cancel — note `cancel`, not `cancelled`; matching the longer word # left a cancelled check counted as neither failing nor pending, which read # as success. -buckets=$(gh pr checks "$PR" --repo "$REPO" --json bucket,name 2>/dev/null) -if [ -z "$buckets" ] || [ "$buckets" = "[]" ]; then - bad "no checks reported" +# `gh pr checks` exits nonzero both when a check is failing and when the query +# itself fails, so the status alone cannot be read as a verdict — but empty +# stdout from a broken query must never be reported as "no checks", which is a +# statement about the PR rather than about the request. +buckets=$(gh pr checks "$PR" --repo "$REPO" --json bucket,name 2>"$errfile") +if [ -z "$buckets" ]; then + if [ -s "$errfile" ]; then + bad "could not query checks: $(tr '\n' ' ' <"$errfile" | cut -c1-120)" + else + bad "no checks reported for this PR" + fi +elif [ "$buckets" = "[]" ]; then + bad "no checks reported for this PR" else pend=$(jq '[.[]|select(.bucket=="pending")]|length' <<<"$buckets") bust=$(jq '[.[]|select(.bucket=="fail" or .bucket=="cancel")]|length' <<<"$buckets") @@ -109,8 +129,13 @@ fi # plus a thumbs-up, which is why the row and not the review list is read. # Paginate: the summary is an ordinary issue comment and the default page is # 30, so on a busy PR it is not on the first one. +# Filter on the AUTHOR as well as the marker. The marker is just text in a +# comment body, so any PR participant could post one carrying a `Completed` +# row for the current SHA and the gate would accept it as a review. The +# summary is posted by the Codex app; require that login and a Bot type. body=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ - --jq '.[] | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>/dev/null) + --jq '.[] | select(.user.login=="chatgpt-codex-connector[bot]" and .user.type=="Bot") + | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>/dev/null) row=$(grep -E '^\| (📝|🔍)' <<<"$body" | tail -1) if [ -z "$row" ]; then bad "no Codex review summary at all" @@ -128,18 +153,27 @@ fi # 5. No unresolved threads. required_conversation_resolution is on, so this is # the gate, not a courtesy. Paginate: a first:100 page once hid 19 threads. -threads=$(gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" -f query=' - query($owner:String!,$name:String!,$pr:Int!){ - repository(owner:$owner,name:$name){ - pullRequest(number:$pr){ - reviewThreads(first:100){ totalCount pageInfo{hasNextPage} nodes{isResolved} }}}}' 2>/dev/null) -if [ -z "$threads" ] || [ "$(jq -r '.data.repository.pullRequest' <<<"$threads")" = "null" ]; then - bad "could not read review threads for $REPO#$PR" -else - total=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$threads") - more=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$threads") - open=$(jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)]|length' <<<"$threads") - [ "$more" = "false" ] || bad "more than 100 threads — paginate before trusting this count" +# Actually paginate. Blocking whenever a second page exists made every busy +# PR permanently unmergeable — and a PR accumulates threads precisely by +# being reviewed carefully, so the rule punished the PRs it should trust. +cursor=null; open=0; total=0; ok_threads=1 +while : ; do + page=$(gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" \ + -f cursor="$([ "$cursor" = "null" ] && echo "" || echo "$cursor")" -f query=' + query($owner:String!,$name:String!,$pr:Int!,$cursor:String){ + repository(owner:$owner,name:$name){ + pullRequest(number:$pr){ + reviewThreads(first:100,after:$cursor){ + totalCount pageInfo{hasNextPage endCursor} nodes{isResolved} }}}}' 2>/dev/null) + if [ -z "$page" ] || [ "$(jq -r '.data.repository.pullRequest' <<<"$page")" = "null" ]; then + bad "could not read review threads for $REPO#$PR"; ok_threads=0; break + fi + total=$(jq -r '.data.repository.pullRequest.reviewThreads.totalCount' <<<"$page") + open=$(( open + $(jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)]|length' <<<"$page") )) + [ "$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$page")" = "true" ] || break + cursor=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor' <<<"$page") +done +if [ "$ok_threads" -eq 1 ]; then if [ "$open" -eq 0 ]; then good "0 of $total threads unresolved"; else bad "$open of $total threads unresolved"; fi fi @@ -148,6 +182,16 @@ fi # sat in a comment on public master through two PRs because each author # scanned only what they wrote — but scan ADDED lines only, or the gate # blocks the very PR that deletes a leak. +# AGENTS.md: "Each PR must link to one line in review-checklist.md as completed +# before merge." A gate that checks everything except the repository's own +# stated pre-merge rule is not the gate it claims to be. +prbody=$(jq -r '.body // ""' <<<"$meta") +if grep -qiE 'review-checklist' <<<"$prbody"; then + good "description links review-checklist.md" +else + bad "description does not link review-checklist.md (AGENTS.md requires one completed line per PR)" +fi + diff=$(gh pr diff "$PR" --repo "$REPO" 2>/dev/null) if [ -z "$diff" ]; then bad "could not read diff for the privacy scan" @@ -163,9 +207,22 @@ else # placeholder list, which would start excusing real values. (Deliberately no # example digits in this comment: a literal here is a literal in the diff, # and this scan reads its own file like any other.) - added=$(grep '^+' <<<"$diff" | grep -v '^+++' \ - | sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' \ + # A binary file is a hole in this scan, not an absence of findings: the patch + # carries a marker instead of content, so a screenshot or PDF of a client + # statement reads exactly like a clean diff. Refuse rather than pass. + binaries=$(grep -cE '^(Binary files .* differ|GIT binary patch)' <<<"$diff") + if [ "$binaries" -gt 0 ]; then + bad "$binaries binary change(s) the privacy scan cannot read — inspect by hand before merging: $(grep -E '^\+\+\+ b/' <<<"$diff" | sed 's|^+++ b/||' | tr '\n' ' ' | cut -c1-160)" + fi + raw_added=$(grep '^+' <<<"$diff" | grep -v '^+++') + added=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' <<<"$raw_added" \ | sed -E 's/[0-9a-fA-F]{32,}//g') + # Exemptions are REPORTED, never silent. Stripping generated-looking values + # keeps the false-positive rate low enough that the gate is read at all, but a + # blanket exemption that nobody can see is how a real value gets erased — so + # say how many were dropped and let the operator judge. + exempt=$(( $(grep -cE '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32,}' <<<"$raw_added") )) + [ "$exempt" -eq 0 ] || say "note" "$exempt added line(s) carried a UUID or hex digest, exempted from the scan — check by eye if this PR touches client data" # Placeholders match these shapes too — XXXXX1234X is a fabricated PAN and X # is an uppercase letter. A gate that cries wolf gets ignored, so obvious # placeholders are excluded by an EXPLICIT list; widening the shape itself From 18869f7d19ca514944963e366d984cefd64c0271 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:48:06 +0530 Subject: [PATCH 16/46] tooling: a seven-hex prefix is grindable, and `++` is content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round three: seven findings, three of them P1, and one is a third blind spot in the privacy scan — the third distinct way the scan could be handed data it would not look at. **`++` at the start of a line hid a value from the scan.** The filter dropped `^+++` to skip the unified-diff file header, but an added line whose own content begins with `++` produces exactly that prefix. `++ customer ABCDE1234F` yielded no scannable text at all. The header is now identified structurally — `+++ b/` or `+++ /dev/null`, never the bare prefix — so content reaches the scan and headers still do not. **A seven-hex prefix is 28 bits and can be ground deliberately.** The review row was matched by substring, so an author could push a commit sharing the reviewed prefix and have a stale `Completed` row vouch for code nobody read. Codex publishes only seven characters, so the comparison cannot be strengthened — but a ground commit must be created *after* the review it impersonates, and that is checkable. The row's own timestamp is now compared against the head commit's committer date, and a review that predates its head is refused. The match is also anchored to the backtick cell, since an unanchored substring matched the row's timestamp and URL too — neither of which is a claim about a commit. **Identifiers were matched uppercase-only.** A GSTIN or PAN written in lower or mixed case is the same identifier, and prose is exactly where it would be written that way. Matching is case-insensitive now, with the placeholder list applied to the uppercased form so `xxxxx1234x` stays excused. Four more: - A binary **deletion** emits `Binary files a/x and /dev/null differ` and was counted like an addition, so the gate blocked the PR removing a leaked screenshot. Same inversion as scanning removed lines, in a different rule. - `--match-head-commit` validates only the head, so the base can be changed after the check without moving the head. The printed command now re-reads the base and compares it before merging. - A failed comments query with empty stdout was reported as "no Codex review summary at all" — "I could not ask" stated as "there is no review", which is the exact substitution this script exists to prevent. - `--repo=` with an empty value was accepted. Both of the first two fixes were wrong on first writing and caught by their own controls: the header filter still swallowed `+++ customer …` until it matched `b/` and `/dev/null` explicitly, and the case-insensitive match used `-O` for `-o`. Every rule is now exercised in both directions — content reaches the scan while real headers do not, a lowercase PAN is flagged while a lowercase placeholder is not, a binary add blocks while a binary delete does not. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 62 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 24a891a7a..a7ae25de8 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -21,7 +21,8 @@ PR=""; REPO="" while [ $# -gt 0 ]; do case "$1" in --repo) REPO="${2:-}"; [ -n "$REPO" ] || { echo "--repo needs OWNER/NAME" >&2; exit 2; }; shift 2 ;; - --repo=*) REPO="${1#--repo=}"; shift ;; + --repo=*) REPO="${1#--repo=}" + [ -n "$REPO" ] || { echo "--repo= needs OWNER/NAME" >&2; exit 2; }; shift ;; -h|--help) sed -n '2,20p' "$0"; exit 0 ;; -*) echo "unknown option: $1" >&2; exit 2 ;; *) [ -z "$PR" ] && PR="$1" || { echo "unexpected argument: $1" >&2; exit 2; }; shift ;; @@ -135,14 +136,34 @@ fi # summary is posted by the Codex app; require that login and a Bot type. body=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ --jq '.[] | select(.user.login=="chatgpt-codex-connector[bot]" and .user.type=="Bot") - | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>/dev/null) + | select(.body|contains("codex-pull-request-review-summary")) | .body' 2>"$errfile") +comment_query_failed=0 +[ -s "$errfile" ] && [ -z "$body" ] && comment_query_failed=1 row=$(grep -E '^\| (📝|🔍)' <<<"$body" | tail -1) -if [ -z "$row" ]; then +if [ "$comment_query_failed" -eq 1 ]; then + # "I could not ask" is not "there is no review". Saying the second when the + # first is true is the failure this whole script is about. + bad "could not read PR comments: $(tr '\n' ' ' <"$errfile" | cut -c1-110)" +elif [ -z "$row" ]; then bad "no Codex review summary at all" -elif ! grep -q "$short" <<<"$row"; then +elif ! grep -qE "\`$short\`" <<<"$row"; then + # Anchored to the backtick cell: an unanchored substring also matches the + # row's timestamp and URL, which are not claims about a commit. bad "latest review names a different commit than $short — it has not seen this push" elif grep -q 'Completed' <<<"$row"; then - good "review completed on $short" + # A seven-hex prefix is 28 bits and a matching commit can be ground + # deliberately, after which a stale `Completed` row would vouch for code + # nobody read. Codex publishes only seven characters, so the prefix cannot be + # strengthened — but a ground commit has to be created AFTER the review it is + # impersonating, and that is checkable. Require the reviewed row to postdate + # the head commit. + review_at=$(grep -oE 'datetime="[^"]+"' <<<"$row" | head -1 | sed 's/datetime="//;s/"//') + head_at=$(gh api "repos/$REPO/commits/$head" --jq '.commit.committer.date' 2>/dev/null) + if [ -n "$review_at" ] && [ -n "$head_at" ] && [[ "$review_at" < "$head_at" ]]; then + bad "review at $review_at predates head commit $short ($head_at) — it cannot have seen it" + else + good "review completed on $short${review_at:+ at $review_at}" + fi else # Running, Failed, Errored — none of these is a review. A failed review run # means nothing looked at the code, which is exactly the state this gate @@ -210,11 +231,23 @@ else # A binary file is a hole in this scan, not an absence of findings: the patch # carries a marker instead of content, so a screenshot or PDF of a client # statement reads exactly like a clean diff. Refuse rather than pass. - binaries=$(grep -cE '^(Binary files .* differ|GIT binary patch)' <<<"$diff") + # A binary DELETION removes a file rather than adding unreadable content, so + # it is a cleanup, not a hole — counting it blocked the PR that deletes a + # leaked screenshot, the same inversion as scanning removed lines. + binaries=$(grep -E '^(Binary files .* differ|GIT binary patch)' <<<"$diff" \ + | grep -cv 'and /dev/null differ') if [ "$binaries" -gt 0 ]; then bad "$binaries binary change(s) the privacy scan cannot read — inspect by hand before merging: $(grep -E '^\+\+\+ b/' <<<"$diff" | sed 's|^+++ b/||' | tr '\n' ' ' | cut -c1-160)" fi - raw_added=$(grep '^+' <<<"$diff" | grep -v '^+++') + # The unified-diff file header is `+++ ` WITH A SPACE. Filtering `^+++` + # discarded any added line whose own content starts with `++`, so + # `++ customer ABCDE1234F` produced no scannable text at all — a place to + # hide a value from the scan, in the scan's own input. + # Identify the header STRUCTURALLY. `+++ ` alone is not enough: an added line + # whose content begins with `++` produces exactly that prefix. Git's header + # is always `+++ b/` or `+++ /dev/null`, so match those and nothing + # else — `+++ customer ABCDE1234F` is content and must reach the scan. + raw_added=$(grep '^+' <<<"$diff" | grep -vE '^\+\+\+ (b/|/dev/null)') added=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' <<<"$raw_added" \ | sed -E 's/[0-9a-fA-F]{32,}//g') # Exemptions are REPORTED, never silent. Stripping generated-looking values @@ -238,8 +271,11 @@ else # NO \b around the digit run. The leak that motivated this gate was written # `HDF CH12345678901` — glued to letters — and \b does not match between `H` # and `1`, so the scan that was supposed to catch it could not see it at all. - hits=$(grep -Eo '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|[A-Z]{5}[0-9]{4}[A-Z]|[6-9][0-9]{9}' <<<"$added" \ - | sort -u | { grep -cvE "$placeholder" || true; }) + # Case-insensitively: a GSTIN or PAN written in lower or mixed case is the + # same identifier, and prose is exactly where it would be written that way. + # The placeholder list is applied to the UPPERCASED form for the same reason. + hits=$(grep -Eio '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|[A-Z]{5}[0-9]{4}[A-Z]|[6-9][0-9]{9}' <<<"$added" \ + | tr '[:lower:]' '[:upper:]' | sort -u | { grep -cvE "$placeholder" || true; }) runs=$(grep -Eo '[0-9]{11,18}' <<<"$added" | sort -u | { grep -cvE "$placeholder" || true; }) if [ "$hits" -eq 0 ] && [ "$runs" -eq 0 ]; then good "added lines carry no identifier shapes and no unexplained long digit runs" @@ -253,6 +289,10 @@ if [ "$fail" -ne 0 ]; then echo "MUST NOT MERGE"; exit 1; fi # 7. Bind the merge to the commit that was actually reviewed. Between this # check and the merge the head can move, and everything above would then # describe a commit the PR no longer points at. -echo "MAY MERGE — bind the merge to the reviewed commit:" -echo " gh pr merge $PR --repo $REPO --squash --match-head-commit $head" +echo "MAY MERGE — bind the merge to the reviewed commit AND the validated base:" +echo " [ \"\$(gh pr view $PR --repo $REPO --json baseRefName -q .baseRefName)\" = \"$base\" ] \\" +echo " && gh pr merge $PR --repo $REPO --squash --match-head-commit $head" +echo +echo " (--match-head-commit validates only the head; the base can be changed" +echo " after this check without moving the head, so re-read it too.)" exit 0 From 137a107485c6b2df761f87a761b5fabe85737008 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:54:20 +0530 Subject: [PATCH 17/46] tooling: catch a reseal that never ran, before a reviewer has to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sessions hit the same defect independently in one day: a pinned file's bytes moved and the compatibility surface was not resealed. Once by editing, once by running `cargo fmt` *after* resealing, and once by rebasing — where the rebase takes the base's manifest and the author touches nothing. That spread of causes is the point. The rule people had written down was "the reseal is the last step before `git add`", and each of us broke it while believing we were following it, because each filed it under the *situation* we had just been in rather than under the actual invariant: **any operation that can change the bytes of a pinned file — an edit, a formatter, a merge, a rebase — invalidates the seal, and the reseal runs after the last of them.** Nothing in the local loop re-reads pins before a commit, so CI's gate is the only thing that notices, and every instance therefore reaches a reviewer instead of its author. That makes it a class, not a set of mistakes, and a class is worth closing here rather than writing down again. The check needs no checkout: read the 211 pinned paths from the manifest at the PR head, intersect with the PR's changed files, and require the manifest to have moved if any of them did. It is deliberately weaker than CI's gate and says so: it proves the reseal was *performed*, not that the hashes are *right*. Only the real gate proves that. But every instance observed was a reseal that never ran at all, so this catches the whole observed failure while costing one API call. Verified in three directions rather than two: a pinned file changed without the manifest blocks; the same change with the manifest passes; a PR touching the manifest alone has nothing to reseal and passes. Against live PRs, #314 reports one pinned file with the manifest moved alongside it, and #320 reports nothing pinned. Credit where due — this was suggested by the lane on #288, which had just been bitten by the `cargo fmt` variant, on the grounds that closing the class beats closing the instances. It was right. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index a7ae25de8..da3ba384c 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -213,6 +213,40 @@ else bad "description does not link review-checklist.md (AGENTS.md requires one completed line per PR)" fi +# 7. Pin freshness. The compatibility surface pins the raw bytes of 211 files, +# and ANY operation that can change those bytes — an edit, a formatter, a +# merge, a rebase taking the base's manifest — invalidates the seal. Nothing +# in the local loop re-reads pins before a commit, so CI's gate is the only +# thing that notices and every instance reaches a reviewer instead of its +# author. Three sessions hit this independently in one day, which makes it a +# class rather than a set of mistakes. +# +# Checked without a checkout: if this PR touches a pinned file, it must also +# touch the manifest. That does not prove the hashes are right — only CI's +# gate does — but it catches the whole observed failure, which is a reseal +# that never ran. +SURFACE=docs/tally/compatibility/compatibility-surface.json +changed=$(gh pr view "$PR" --repo "$REPO" --json files -q '.files[].path' 2>/dev/null) +if [ -z "$changed" ]; then + bad "could not list changed files for the pin-freshness check" +else + pinned=$(gh api "repos/$REPO/contents/$SURFACE?ref=$head" --jq '.content' 2>/dev/null \ + | tr -d '\n' | base64 --decode 2>/dev/null \ + | jq -r '[.. | objects | select(has("path")) | .path] | .[]' 2>/dev/null) + if [ -z "$pinned" ]; then + say "note" "no compatibility surface at this head — pin-freshness check skipped" + else + touched=$(comm -12 <(sort -u <<<"$pinned") <(sort -u <<<"$changed") | grep -v "^$SURFACE$" | head -20) + if [ -z "$touched" ]; then + good "touches no pinned file (nothing to reseal)" + elif grep -qx "$SURFACE" <<<"$changed"; then + good "touches $(wc -l <<<"$touched" | tr -d ' ') pinned file(s) and the manifest moved with them" + else + bad "touches pinned file(s) without updating $SURFACE — the reseal did not run: $(tr '\n' ' ' <<<"$touched" | cut -c1-150)" + fi + fi +fi + diff=$(gh pr diff "$PR" --repo "$REPO" 2>/dev/null) if [ -z "$diff" ]; then bad "could not read diff for the privacy scan" From 933f3a92ba521edae6f8e0660b0125415b185460 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:57:13 +0530 Subject: [PATCH 18/46] tooling: the scan reads its own file, so stop illustrating leaks with literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comment explaining the `++` blind spot used a realistic PAN as its example, and the newly case-insensitive matcher flagged it. Correctly: the scan reads this file like any other, and **a literal that illustrates a leak pattern is the pattern**. Third time a comment here has tripped the check — a UUID, a partial UUID, now a PAN. Each time it was the check working. The rule is now written in the comment so the next person adding an example reads it first: describe the shape, never spell it. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index da3ba384c..2028867d3 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -275,12 +275,16 @@ else fi # The unified-diff file header is `+++ ` WITH A SPACE. Filtering `^+++` # discarded any added line whose own content starts with `++`, so - # `++ customer ABCDE1234F` produced no scannable text at all — a place to - # hide a value from the scan, in the scan's own input. + # an added line whose content began `++` produced no scannable text at all — + # a place to hide a value from the scan, in the scan's own input. (No example + # identifier in this comment: the scan reads its own file, and a literal that + # illustrates a leak pattern IS the pattern. This is the third time a comment + # here has flagged itself, which is the check working rather than failing.) # Identify the header STRUCTURALLY. `+++ ` alone is not enough: an added line # whose content begins with `++` produces exactly that prefix. Git's header # is always `+++ b/` or `+++ /dev/null`, so match those and nothing - # else — `+++ customer ABCDE1234F` is content and must reach the scan. + # else — a payload line that merely starts with `++` is content, and must + # reach the scan rather than being mistaken for a header. raw_added=$(grep '^+' <<<"$diff" | grep -vE '^\+\+\+ (b/|/dev/null)') added=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' <<<"$raw_added" \ | sed -E 's/[0-9a-fA-F]{32,}//g') From 466f27f2c45ffc593b0708745731fab23b4f73eb Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 17:37:05 +0530 Subject: [PATCH 19/46] tooling: a check an attacker can satisfy is worse than no check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round four: five findings, three P1, and the first retracts last round's fix. **The timestamp defence was void.** To authenticate a review against a ground seven-hex prefix, the previous revision compared the review's time against the head commit's committer date. `GIT_COMMITTER_DATE` is set by whoever creates the commit: an author able to grind a prefix can also backdate it. The check read as coverage and provided none, which is worse than the gap it papered over. Replaced with something an author cannot set: **uniqueness.** A ground collision puts two commits sharing the prefix in the PR, so the PR's own commit list is counted and more than one match blocks. The residual is stated in the code rather than implied away: uniqueness within the PR does not exclude a collision created elsewhere and force-pushed as the sole commit. Closing that needs Codex to publish a full SHA. Until then this is a deterrent, not a proof, and it says so. **A phone number written normally was invisible.** `+91 98765 43210` scans as three short segments, under both the 10-digit and 11-digit thresholds. The first fix stripped every separator to make a projection — and invented eight findings on a clean diff, joining two adjacent dates into a sixteen-digit run and `CE_ADR_0016_E` into a PAN shape. A gate that cries wolf gets ignored, which costs more than this catches. Separators are now permitted only *inside* a phone-shaped run, and the result is re-checked as a ten-digit Indian mobile, so nothing unrelated is fused. Verified: five separator styles caught, and all three invented findings gone. **A filename is content.** Header lines are dropped from the scan, so a file whose basename carries an identifier passed with safe contents. Destination paths are added back as their own scannable text. Two more: an unreadable compatibility manifest was treated as an absent one and the pin check silently skipped — it now asks whether the path exists and fails closed if it does; and `gh pr view --json files` hard-codes `files(first: 100)`, so a PR touching more than 100 files returned a partial list and a pinned file outside the first page read as untouched. The paginated REST endpoint is used instead. Co-Authored-By: Claude Opus 5 --- scripts/merge-gate.sh | 64 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 2028867d3..44a09808d 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -157,12 +157,25 @@ elif grep -q 'Completed' <<<"$row"; then # strengthened — but a ground commit has to be created AFTER the review it is # impersonating, and that is checkable. Require the reviewed row to postdate # the head commit. - review_at=$(grep -oE 'datetime="[^"]+"' <<<"$row" | head -1 | sed 's/datetime="//;s/"//') - head_at=$(gh api "repos/$REPO/commits/$head" --jq '.commit.committer.date' 2>/dev/null) - if [ -n "$review_at" ] && [ -n "$head_at" ] && [[ "$review_at" < "$head_at" ]]; then - bad "review at $review_at predates head commit $short ($head_at) — it cannot have seen it" + # An earlier revision compared the review's timestamp against the head + # commit's committer date. That defence is VOID: `GIT_COMMITTER_DATE` is set + # by whoever creates the commit, so an author grinding a prefix can also + # backdate it. A check an attacker can satisfy is worse than no check, + # because it reads as coverage. + # + # What IS sound is uniqueness. If a colliding commit was ground and pushed, + # both commits are in the PR, so more than one of its commits shares the + # prefix. Count them. + sharing=$(gh api --paginate "repos/$REPO/pulls/$PR/commits" --jq '.[].sha' 2>/dev/null \ + | { grep -c "^$short" || true; }) + if [ "$sharing" -gt 1 ]; then + bad "$sharing commits in this PR share the prefix $short — the review row cannot say which it read" else - good "review completed on $short${review_at:+ at $review_at}" + good "review completed on $short (prefix unique among this PR's commits)" + # Stated, not hidden: a seven-hex prefix is 28 bits. Uniqueness within the + # PR does not exclude a collision created elsewhere and force-pushed as the + # sole commit. Closing that needs Codex to publish a full SHA; until then + # this rule is a deterrent, not a proof. fi else # Running, Failed, Errored — none of these is a review. A failed review run @@ -226,7 +239,10 @@ fi # gate does — but it catches the whole observed failure, which is a reseal # that never ran. SURFACE=docs/tally/compatibility/compatibility-surface.json -changed=$(gh pr view "$PR" --repo "$REPO" --json files -q '.files[].path' 2>/dev/null) +# `gh pr view --json files` hard-codes `files(first: 100)`, so a PR touching more +# than 100 files silently returns a partial list — and a pinned file outside that +# page reads as untouched. Use the paginated REST endpoint. +changed=$(gh api --paginate "repos/$REPO/pulls/$PR/files" --jq '.[].filename' 2>/dev/null) if [ -z "$changed" ]; then bad "could not list changed files for the pin-freshness check" else @@ -234,7 +250,14 @@ else | tr -d '\n' | base64 --decode 2>/dev/null \ | jq -r '[.. | objects | select(has("path")) | .path] | .[]' 2>/dev/null) if [ -z "$pinned" ]; then - say "note" "no compatibility surface at this head — pin-freshness check skipped" + # "I could not read the manifest" and "there is no manifest" are different + # statements, and only one of them is about the repository. Distinguish by + # asking whether the path exists at all; anything else fails closed. + if gh api "repos/$REPO/contents/$SURFACE?ref=$head" --jq '.sha' >/dev/null 2>&1; then + bad "the compatibility surface exists at this head but could not be read or parsed — refusing rather than skipping" + else + say "note" "no compatibility surface at this head — pin-freshness check does not apply" + fi else touched=$(comm -12 <(sort -u <<<"$pinned") <(sort -u <<<"$changed") | grep -v "^$SURFACE$" | head -20) if [ -z "$touched" ]; then @@ -285,7 +308,11 @@ else # is always `+++ b/` or `+++ /dev/null`, so match those and nothing # else — a payload line that merely starts with `++` is content, and must # reach the scan rather than being mistaken for a header. - raw_added=$(grep '^+' <<<"$diff" | grep -vE '^\+\+\+ (b/|/dev/null)') + # A filename is content too. The header lines are dropped from the scan, so a + # file whose BASENAME carries an identifier passed with safe contents. Add the + # destination paths back as their own scannable text. + added_paths=$(grep -E '^\+\+\+ b/' <<<"$diff" | sed 's|^+++ b/||') + raw_added=$(printf '%s\n%s\n' "$(grep '^+' <<<"$diff" | grep -vE '^\+\+\+ (b/|/dev/null)')" "$added_paths") added=$(sed -E 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' <<<"$raw_added" \ | sed -E 's/[0-9a-fA-F]{32,}//g') # Exemptions are REPORTED, never silent. Stripping generated-looking values @@ -312,9 +339,24 @@ else # Case-insensitively: a GSTIN or PAN written in lower or mixed case is the # same identifier, and prose is exactly where it would be written that way. # The placeholder list is applied to the UPPERCASED form for the same reason. - hits=$(grep -Eio '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|[A-Z]{5}[0-9]{4}[A-Z]|[6-9][0-9]{9}' <<<"$added" \ - | tr '[:lower:]' '[:upper:]' | sort -u | { grep -cvE "$placeholder" || true; }) - runs=$(grep -Eo '[0-9]{11,18}' <<<"$added" | sort -u | { grep -cvE "$placeholder" || true; }) + # A phone number is written `+91 98765 43210`, `(98765) 43210`, `98765-43210`. + # Scanning contiguous digits only, every segment falls under both thresholds + # and the line reads clean. + # + # Stripping ALL separators to make a projection was the first fix and it was + # wrong: it joined two adjacent dates into a sixteen-digit run and `CE_ADR_ + # 0016_E` into a PAN shape, inventing eight findings on a diff that had none. + # A gate that cries wolf gets ignored, which costs more than this catches. + # + # So: permit separators only INSIDE a phone-shaped run, then re-check the + # result really is a ten-digit Indian mobile. Nothing outside that shape is + # joined, so no unrelated numbers are fused. + squashed=$(grep -Eo '[6-9][0-9]{4}[][()+. _-]{0,3}[0-9]{5}' <<<"$added" \ + | sed -E 's/[][()+. _-]//g' | grep -E '^[6-9][0-9]{9}$' || true) + hits=$(grep -Eio '[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]{3}|[A-Z]{5}[0-9]{4}[A-Z]|[6-9][0-9]{9}' <<<"$added +$squashed" | tr '[:lower:]' '[:upper:]' | sort -u | { grep -cvE "$placeholder" || true; }) + runs=$(grep -Eo '[0-9]{11,18}' <<<"$added +$squashed" | sort -u | { grep -cvE "$placeholder" || true; }) if [ "$hits" -eq 0 ] && [ "$runs" -eq 0 ]; then good "added lines carry no identifier shapes and no unexplained long digit runs" else From 4bf12f0325e07e19b5c61fb3a8d318d75974ebb1 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 17:41:19 +0530 Subject: [PATCH 20/46] Harden merge gate lineage and compatibility pins --- scripts/merge-gate.sh | 142 +++++++++++++++++++++++++++++++------ scripts/merge-gate.test.py | 69 ++++++++++++++++-- 2 files changed, 182 insertions(+), 29 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 99f6da499..0067e55a1 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -75,12 +75,13 @@ die() { echo "$1" >&2; exit 2; } # outer shape before extracting fields so jq errors cannot become empty values. : >"$errfile" if ! meta=$(gh pr view "$PR" --repo "$REPO" \ - --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body,changedFiles 2>"$errfile"); then + --json headRefOid,baseRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body,changedFiles 2>"$errfile"); then die "could not read PR #$PR in $REPO" fi if ! jq -e ' type == "object" and (.headRefOid | type == "string" and test("^[0-9a-fA-F]{40}$")) and + (.baseRefOid | type == "string" and test("^[0-9a-fA-F]{40}$")) and (.baseRefName | type == "string" and length > 0) and (.mergeable | type == "string") and (.mergeStateStatus | type == "string") and @@ -91,6 +92,7 @@ if ! jq -e ' die "PR metadata was not a valid complete JSON object" fi head=$(jq -r '.headRefOid' <<<"$meta") +base_ref_oid=$(jq -r '.baseRefOid' <<<"$meta") base=$(jq -r '.baseRefName' <<<"$meta") mergeable=$(jq -r '.mergeable' <<<"$meta") mstate=$(jq -r '.mergeStateStatus' <<<"$meta") @@ -136,6 +138,29 @@ if [ "$base_tip_status" -ne 0 ] || ! [[ "$base_tip" =~ ^[0-9a-fA-F]{40}$ ]]; the else say "ok" "captured base tip ${base_tip:0:7}" fi +if [ -n "$base_tip" ] && [ "$base_ref_oid" != "$base_tip" ]; then + unknown "PR base OID $base_ref_oid differs from the current '$base' tip $base_tip" +fi + +# Bind the reviewed head to the actual base lineage returned by GitHub. The +# compare API is queried with the captured OIDs and must say that base is an +# ancestor of head; mergeability alone does not establish that relationship. +if [ -n "$base_tip" ] && [ "$base_ref_oid" = "$base_tip" ]; then + : >"$errfile" + compare_status=0 + comparison=$(gh api "repos/$REPO/compare/${base_tip}...${head}" 2>"$errfile") || compare_status=$? + if [ "$compare_status" -ne 0 ] || ! jq -e --arg base "$base_tip" ' + type == "object" and + (.status | type == "string" and (. == "ahead" or . == "identical")) and + (.behind_by | type == "number" and floor == . and . == 0) and + (.merge_base_commit | type == "object") and + (.merge_base_commit.sha | type == "string" and test("^[0-9a-fA-F]{40}$") and . == $base) + ' <<<"$comparison" >/dev/null 2>&1; then + unknown "base/head compare did not prove that the captured base tip is an ancestor" + else + say "ok" "compare API binds base tip ${base_tip:0:7} as head's merge base" + fi +fi # Branch protection is the source of required check contexts. A pass list with # an omitted required context is not a complete check result. @@ -204,6 +229,48 @@ else fi fi +# The checks rollup is head-bound by its PR endpoint, but it does not expose a +# check-run SHA in `gh pr checks`. Verify the complete provider check-run pages +# and commit-status response independently so a malformed or mixed response +# cannot become positive evidence. The required-context decision above remains +# authoritative for branch protection, including status-only contexts. +: >"$errfile" +check_runs_status=0 +check_runs=$(gh api --paginate --slurp "repos/$REPO/commits/$head/check-runs?per_page=100" 2>"$errfile") || check_runs_status=$? +if [ "$check_runs_status" -ne 0 ] || ! jq -e --arg head "$head" ' + type == "array" and length > 0 and + all(.[]; type == "object" and + (.total_count | type == "number" and floor == . and . >= 0) and + (.check_runs | type == "array" and all(.[]; + type == "object" and + (.name | type == "string" and length > 0) and + (.head_sha | type == "string" and test("^[0-9a-fA-F]{40}$") and . == $head) + ))) and + ((map(.total_count) | unique | length) == 1) and + ((map(.check_runs | length) | add) == .[0].total_count) +' <<<"$check_runs" >/dev/null 2>&1; then + unknown "could not validate complete head-bound check-run evidence" +else + say "ok" "check-run pages are complete and bound to head $short" +fi +: >"$errfile" +statuses_status=0 +statuses=$(gh api "repos/$REPO/commits/$head/status" 2>"$errfile") || statuses_status=$? +if [ "$statuses_status" -ne 0 ] || ! jq -e --arg head "$head" ' + type == "object" and + (.total_count | type == "number" and floor == . and . >= 0) and + (.statuses | type == "array" and all(.[]; + type == "object" and + (.context | type == "string" and length > 0) and + (.state | type == "string" and length > 0) and + ((.sha // $head) | type == "string" and test("^[0-9a-fA-F]{40}$") and . == $head) + )) +' <<<"$statuses" >/dev/null 2>&1; then + unknown "could not validate head-bound commit-status evidence" +else + say "ok" "commit-status response is bound to head $short" +fi + # Provider review objects carry an immutable full commit_id even when the # human-readable summary is abbreviated. No author-controlled commit timestamp # is used. A clean summary without a full provider OID is indeterminate and @@ -406,24 +473,24 @@ fi # Read and validate the v1 surface as a required object. Any transport, # decoding, JSON, or schema failure is indeterminate; an unrelated nested -# `path` must not turn an incomplete manifest into an empty pin set. +# `path` must not turn an incomplete manifest into an empty pin set. Both the +# reviewed head and captured base tip are checked: a head surface that silently +# drops a previously pinned path is a human hold, and changed paths are tested +# against the union so an unpinned head cannot hide a reseal obligation. SURFACE="docs/tally/compatibility/compatibility-surface.json" -: >"$errfile" -surface_status=0 -surface=$(gh api "repos/$REPO/contents/$SURFACE?ref=$head" 2>"$errfile") || surface_status=$? -if [ "$surface_status" -ne 0 ]; then - unknown "could not read compatibility surface at $short" - pinned="" -elif ! surface_content=$(jq -er '.content | strings' <<<"$surface"); then - unknown "compatibility surface response had no valid base64 content" - pinned="" -else +read_surface_paths() { + local ref="$1" + local response content decoded decode_status + surface_paths_result="" + : >"$errfile" + response=$(gh api "repos/$REPO/contents/$SURFACE?ref=$ref" 2>"$errfile") || return 1 + content=$(jq -er 'select(.encoding == "base64") | .content | strings' <<<"$response") || return 1 decoded="" decode_status=0 - decoded=$(printf '%s' "${surface_content//$'\n'/}" | base64 --decode 2>"$errfile") || decode_status=$? + decoded=$(printf '%s' "${content//$'\n'/}" | base64 --decode 2>"$errfile") || decode_status=$? if [ "$decode_status" -ne 0 ]; then decode_status=0 - decoded=$(printf '%s' "${surface_content//$'\n'/}" | base64 -D 2>"$errfile") || decode_status=$? + decoded=$(printf '%s' "${content//$'\n'/}" | base64 -D 2>"$errfile") || decode_status=$? fi if [ "$decode_status" -ne 0 ] || ! jq -e ' type == "object" and @@ -435,14 +502,43 @@ else ((.sha256 | type) == "string") and (.sha256 | test("^[0-9a-f]{64}$")))) and (([.files[].path] | length) == ([.files[].path] | unique | length)) ' <<<"$decoded" >/dev/null 2>&1; then - unknown "compatibility surface could not be decoded and validated" - pinned="" - else - pinned=$(jq -r '.files[].path' <<<"$decoded") + return 1 fi + surface_paths_result=$(jq -r '.files[].path' <<<"$decoded") +} + +head_surface_status=0 +read_surface_paths "$head" || head_surface_status=$? +if [ "$head_surface_status" -ne 0 ]; then + unknown "could not read and validate compatibility surface at $short" + pinned="" +else + pinned="$surface_paths_result" + say "ok" "validated v1 compatibility surface at head $short" +fi + +base_surface_status=0 +if [ -n "$base_tip" ]; then + read_surface_paths "$base_tip" || base_surface_status=$? fi -if [ -n "$changed" ] && [ -n "$pinned" ]; then - touched=$(comm -12 <(sort -u <<<"$pinned") <(sort -u <<<"$changed") | awk -v surface="$SURFACE" '$0 != surface') +if [ -n "$base_tip" ] && [ "$base_surface_status" -ne 0 ]; then + unknown "could not read and validate compatibility surface at base ${base_tip:0:7}" + base_pinned="" +elif [ -n "$base_tip" ]; then + base_pinned="$surface_paths_result" + say "ok" "validated v1 compatibility surface at base ${base_tip:0:7}" +else + base_pinned="" +fi + +if [ -n "$changed" ] && [ -n "$pinned" ] && [ -n "$base_pinned" ]; then + removed_pins=$(comm -23 <(sort -u <<<"$base_pinned") <(sort -u <<<"$pinned")) + if [ -n "$removed_pins" ]; then + removed_count=$(wc -l <<<"$removed_pins" | tr -d ' ') + unknown "$removed_count base-pinned path(s) are absent from the head surface; human review is required" + fi + union_pinned=$(printf '%s\n%s\n' "$base_pinned" "$pinned" | sort -u) + touched=$(comm -12 <(sort -u <<<"$union_pinned") <(sort -u <<<"$changed") | awk -v surface="$SURFACE" '$0 != surface') if [ -z "$touched" ]; then say "ok" "changed files contain no pinned path requiring a reseal" elif grep -Fxq "$SURFACE" <<<"$changed"; then @@ -582,11 +678,12 @@ fi : >"$errfile" final_meta_status=0 final_meta=$(gh pr view "$PR" --repo "$REPO" \ - --json headRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body,changedFiles 2>"$errfile") || final_meta_status=$? -if [ "$final_meta_status" -ne 0 ] || ! jq -e 'type == "object" and (.headRefOid | type == "string") and (.baseRefName | type == "string") and (.mergeable | type == "string") and (.mergeStateStatus | type == "string") and (.isDraft | type == "boolean") and (.state | type == "string") and (.changedFiles | type == "number" and floor == . and . >= 0)' <<<"$final_meta" >/dev/null 2>&1; then + --json headRefOid,baseRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,body,changedFiles 2>"$errfile") || final_meta_status=$? +if [ "$final_meta_status" -ne 0 ] || ! jq -e 'type == "object" and (.headRefOid | type == "string" and test("^[0-9a-fA-F]{40}$")) and (.baseRefOid | type == "string" and test("^[0-9a-fA-F]{40}$")) and (.baseRefName | type == "string") and (.mergeable | type == "string") and (.mergeStateStatus | type == "string") and (.isDraft | type == "boolean") and (.state | type == "string") and (.changedFiles | type == "number" and floor == . and . >= 0)' <<<"$final_meta" >/dev/null 2>&1; then unknown "could not revalidate PR head and base before merge" else final_head=$(jq -r '.headRefOid' <<<"$final_meta") + final_base_ref_oid=$(jq -r '.baseRefOid' <<<"$final_meta") final_base=$(jq -r '.baseRefName' <<<"$final_meta") final_mergeable=$(jq -r '.mergeable' <<<"$final_meta") final_state=$(jq -r '.mergeStateStatus' <<<"$final_meta") @@ -594,6 +691,7 @@ else final_pstate=$(jq -r '.state' <<<"$final_meta") final_changed_files=$(jq -r '.changedFiles' <<<"$final_meta") [ "$final_head" = "$head" ] || bad "PR head moved during preflight" + [ "$final_base_ref_oid" = "$base_ref_oid" ] || bad "PR base OID moved during preflight" [ "$final_base" = "$base" ] || bad "PR base moved during preflight" [ "$final_mergeable" = "MERGEABLE" ] || bad "PR mergeability changed to $final_mergeable during preflight" [ "$final_draft" = "false" ] || bad "PR became draft during preflight" diff --git a/scripts/merge-gate.test.py b/scripts/merge-gate.test.py index 99c441f3e..c5328491c 100644 --- a/scripts/merge-gate.test.py +++ b/scripts/merge-gate.test.py @@ -21,6 +21,7 @@ scenario = os.environ.get("GATE_SCENARIO", "pass") head = "0123456789abcdef0123456789abcdef01234567" new_head = "fedcba9876543210fedcba9876543210fedcba98" +base = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" def emit(value): if value is not None: @@ -53,7 +54,8 @@ def fail(message="controlled API failure"): " linked here: https://github.com/example/repo/blob/HEAD/review-checklist.md#L10" ) one_file = scenario in {"files-empty", "formatted-phone", "path-id", "binary-delete"} - emit({"headRefOid": selected_head, "baseRefName": "master", + selected_base = new_head if scenario == "base-oid-mismatch" else base + emit({"headRefOid": selected_head, "baseRefOid": selected_base, "baseRefName": "master", "mergeable": "MERGEABLE", "mergeStateStatus": final_state, "isDraft": False, "state": "OPEN", "body": body, "changedFiles": 1 if one_file else 2}) @@ -86,6 +88,10 @@ def fail(message="controlled API failure"): emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+safe text\n") elif scenario == "diff-truncated-payload": emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +2 @@\n+first line\n") + elif scenario == "surface-unpins": + emit("diff --git a/src/example.rs b/src/example.rs\n--- a/src/example.rs\n+++ b/src/example.rs\n@@ -0,0 +1 @@\n+safe text\n" + "diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json\n" + "--- a/docs/tally/compatibility/compatibility-surface.json\n+++ b/docs/tally/compatibility/compatibility-surface.json\n@@ -0,0 +1 @@\n+safe manifest\n") else: emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+safe text\ndiff --git a/docs/second.md b/docs/second.md\n--- a/docs/second.md\n+++ b/docs/second.md\n@@ -0,0 +1 @@\n+other text\n") elif args and args[0] == "api": @@ -106,13 +112,34 @@ def fail(message="controlled API failure"): "totalCount": 101.5 if scenario == "threads-fractional" else (102 if scenario == "threads-total-drift" and has_cursor else 101), "pageInfo": page_info, "nodes": nodes }}}}}) + elif "/compare/" in joined: + if scenario == "lineage-mismatch": + emit({"status": "diverged", "behind_by": 1, + "merge_base_commit": {"sha": new_head}}) + else: + emit({"status": "ahead", "behind_by": 0, + "merge_base_commit": {"sha": base}}) + elif "/commits/" in joined and "/check-runs" in joined: + if scenario == "check-run-wrong-head": + run_head = new_head + else: + run_head = head + total_count = 3 if scenario == "check-run-count-mismatch" else 2 + emit([{"total_count": total_count, "check_runs": [ + {"name": "Required checks", "head_sha": run_head}, + {"name": "Rust format", "head_sha": run_head}]}]) + elif "/commits/" in joined and "/status" in joined: + if scenario == "status-malformed": + emit({"total_count": "0", "statuses": []}) + else: + emit({"state": "success", "total_count": 0, "statuses": []}) elif "branches/master/protection/required_status_checks" in joined: contexts = ["Required checks", "Rust format"] if scenario == "missing-required" else [ "Frontend build", "Rust format", "GitGuardian Security Checks", "Dependency security", "Required checks"] emit({"contexts": contexts, "checks": []}) elif "branches/master" in joined: - emit(head) + emit(base) elif "/pulls/321/reviews" in joined: if scenario == "short-review": emit([[]]) @@ -139,6 +166,9 @@ def fail(message="controlled API failure"): emit([[{"filename": "docs/example.md", "status": "added", "additions": 1, "deletions": 0}], [{"filename": 3, "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario == "missing-file-status": emit([[{"filename": "docs/example.md", "status": "added", "additions": 1, "deletions": 0}], [{"filename": "docs/second.md", "additions": 1, "deletions": 0}]]) + elif scenario == "surface-unpins": + emit([[{"filename": "src/example.rs", "status": "modified", "additions": 1, "deletions": 0}], + [{"filename": "docs/tally/compatibility/compatibility-surface.json", "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario == "files-count-mismatch": emit([[{"filename": "docs/example.md", "status": "added", "additions": 1, "deletions": 0}]]) else: @@ -151,11 +181,18 @@ def fail(message="controlled API failure"): emit({"content": "not-base64"}) else: digest = "a" * 64 + if scenario == "surface-unpins" and f"ref={head}" not in joined: + surface_files = [ + {"path": "src/example.rs", "sha256": digest}, + {"path": "docs/tally/compatibility/compatibility-surface.json", "sha256": digest}, + ] + else: + surface_files = [{"path": "src/example.rs", "sha256": digest}] surface = {"schema_version": 1, "manifest_sha256": digest, - "files": [{"path": "src/example.rs", "sha256": digest}]} + "files": surface_files} if scenario == "surface-schema-malformed": surface = {"files": [{"path": "src/example.rs"}]} - emit({"content": base64.b64encode(json.dumps(surface).encode()).decode()}) + emit({"encoding": "base64", "content": base64.b64encode(json.dumps(surface).encode()).decode()}) else: fail("unknown API fixture") else: @@ -227,7 +264,7 @@ def test_cancelled_check_blocks(self): self.assert_blocked("cancel-check", "failing, cancelled, or pending") def test_surface_transport_failure_is_indeterminate(self): - self.assert_indeterminate("surface-fail", "could not read compatibility surface") + self.assert_indeterminate("surface-fail", "could not read and validate compatibility surface") def test_silent_checks_response_is_indeterminate(self): self.assert_indeterminate("checks-silent", "checks query returned no JSON") @@ -267,10 +304,28 @@ def test_binary_deletion_is_not_treated_as_added_content(self): self.assertEqual(result.returncode, 0, result.stdout + result.stderr) def test_malformed_surface_is_indeterminate(self): - self.assert_indeterminate("surface-malformed", "compatibility surface could not be decoded") + self.assert_indeterminate("surface-malformed", "could not read and validate compatibility surface") def test_surface_with_no_v1_manifest_schema_is_indeterminate(self): - self.assert_indeterminate("surface-schema-malformed", "compatibility surface could not be decoded") + self.assert_indeterminate("surface-schema-malformed", "could not read and validate compatibility surface") + + def test_removed_base_pin_requires_human_hold(self): + self.assert_indeterminate("surface-unpins", "base-pinned path(s) are absent") + + def test_compare_lineage_mismatch_is_indeterminate(self): + self.assert_indeterminate("lineage-mismatch", "base/head compare did not prove") + + def test_check_run_wrong_head_is_indeterminate(self): + self.assert_indeterminate("check-run-wrong-head", "head-bound check-run evidence") + + def test_check_run_count_mismatch_is_indeterminate(self): + self.assert_indeterminate("check-run-count-mismatch", "head-bound check-run evidence") + + def test_base_oid_mismatch_is_indeterminate(self): + self.assert_indeterminate("base-oid-mismatch", "PR base OID") + + def test_malformed_commit_status_is_indeterminate(self): + self.assert_indeterminate("status-malformed", "head-bound commit-status evidence") def test_malformed_changed_file_is_indeterminate(self): self.assert_indeterminate("malformed-files", "could not read the complete changed-file set") From d668a1883cff6af83ee9180b87e77b8dbc05521d Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 18:50:17 +0530 Subject: [PATCH 21/46] Complete merge gate review and CI evidence paths --- .github/workflows/ci.yml | 1 + scripts/merge-gate.sh | 138 +++++++++++++++++++++++++++---------- scripts/merge-gate.test.py | 87 ++++++++++++++++++++--- 3 files changed, 182 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0a1b1506..bf10bdb77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,7 @@ jobs: - run: python3 scripts/retain-macos-test-binaries.test.py - run: python3 scripts/bank_statement_import.test.py - run: python3 scripts/sanitise-bbox-capture.test.py + - run: python3 scripts/merge-gate.test.py tally-portable: name: Tally portable core diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 0067e55a1..8f0ef42ef 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -13,6 +13,7 @@ set -uo pipefail PR="" REPO="" +INDEPENDENT_REVIEW_SHA="" while [ $# -gt 0 ]; do case "$1" in --repo) @@ -25,6 +26,16 @@ while [ $# -gt 0 ]; do [ -n "$REPO" ] || { echo "--repo= needs OWNER/NAME" >&2; exit 2; } shift ;; + --independent-review-sha) + INDEPENDENT_REVIEW_SHA="${2:-}" + [ -n "$INDEPENDENT_REVIEW_SHA" ] || { echo "--independent-review-sha needs a full commit SHA" >&2; exit 2; } + shift 2 + ;; + --independent-review-sha=*) + INDEPENDENT_REVIEW_SHA="${1#*=}" + [ -n "$INDEPENDENT_REVIEW_SHA" ] || { echo "--independent-review-sha= needs a full commit SHA" >&2; exit 2; } + shift + ;; -h|--help) sed -n '2,13p' "$0" exit 0 @@ -45,6 +56,7 @@ while [ $# -gt 0 ]; do esac done [ -n "$PR" ] || { echo "usage: $0 [--repo OWNER/NAME]" >&2; exit 2; } +[[ "$PR" =~ ^[0-9]+$ ]] || { echo "PR selector must be numeric" >&2; exit 2; } # Resolve the repository once. An explicit target must never fall back to the # current checkout if one later API call fails. @@ -56,7 +68,9 @@ if [ -z "$REPO" ]; then fi OWNER="${REPO%%/*}" NAME="${REPO##*/}" -if [ -z "$OWNER" ] || [ -z "$NAME" ] || [ "$OWNER" = "$REPO" ] || [[ "$REPO" == */*/* ]]; then +if [ -z "$OWNER" ] || [ -z "$NAME" ] || [ "$OWNER" = "$REPO" ] || [[ "$REPO" == */*/* ]] \ + || ! [[ "$OWNER" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \ + || ! [[ "$NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then echo "--repo must be OWNER/NAME, got '$REPO'" >&2 exit 2 fi @@ -102,6 +116,12 @@ changed_files_expected=$(jq -r '.changedFiles' <<<"$meta") short=${head:0:7} prbody=$(jq -r '.body // ""' <<<"$meta") +if [ -n "$INDEPENDENT_REVIEW_SHA" ] && ! [[ "$INDEPENDENT_REVIEW_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + bad "independent review attestation must be a full 40-hex commit SHA" +elif [ -n "$INDEPENDENT_REVIEW_SHA" ] && [ "$INDEPENDENT_REVIEW_SHA" != "$head" ]; then + bad "independent review attestation names a different commit than the PR head" +fi + echo "PR #$PR ($REPO) head=$short base=$base $mergeable/$mstate" [ "$pstate" = "OPEN" ] || bad "PR is $pstate, not OPEN" [ "$draft" = "false" ] || bad "draft" @@ -272,11 +292,14 @@ else fi # Provider review objects carry an immutable full commit_id even when the -# human-readable summary is abbreviated. No author-controlled commit timestamp -# is used. A clean summary without a full provider OID is indeterminate and -# points the operator to independent exact-head acceptance. +# human-readable summary is abbreviated. The summary is required as a separate +# completed-run receipt: an exact-head COMMENTED object can remain after a run +# later fails. A clean summary without a full provider OID has an explicit +# operator-attestation path, but a seven-character row never becomes a full-SHA +# claim by inference. : >"$errfile" review_status=0 +provider_review="" reviews=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/reviews" 2>"$errfile") || review_status=$? if [ "$review_status" -ne 0 ]; then unknown "could not read provider review records" @@ -289,37 +312,44 @@ else .state == "COMMENTED" and .commit_id == $head)) | if length > 0 then "matched" else "" end ' <<<"$reviews") - if [ "$provider_review" = "matched" ]; then - say "ok" "provider review records the full current head $short" +fi + +: >"$errfile" +comment_status=0 +comments=$(gh api --paginate --slurp "repos/$REPO/issues/$PR/comments" 2>"$errfile") || comment_status=$? +summary_good=0 +if [ "$comment_status" -ne 0 ]; then + unknown "could not read provider review summaries" +elif ! jq -e 'type == "array" and (all(.[]; type == "array") or all(.[]; type == "object"))' <<<"$comments" >/dev/null 2>&1; then + unknown "provider review-summary response was malformed" +else + summary_rows=$(jq -r ' + (if all(.[]; type == "array") then flatten else . end)[] | + select(.user.login == "chatgpt-codex-connector[bot]" and .user.type == "Bot") | + select((.body // "") | contains("codex-pull-request-review-summary")) | + .body + ' <<<"$comments" | grep -E '^\| (📝|🔍)' | tail -1) + if [ -z "$summary_rows" ]; then + bad "no provider review summary row" + elif ! grep -Fq "\`$short\`" <<<"$summary_rows"; then + bad "latest provider summary names a different head than $short" + elif ! grep -Fq 'Completed' <<<"$summary_rows"; then + bad "provider review run for $short is not completed" else - # Read the summary only to distinguish absent evidence from a provider - # summary that exposes an abbreviated current prefix. - : >"$errfile" - comment_status=0 - comments=$(gh api --paginate --slurp "repos/$REPO/issues/$PR/comments" 2>"$errfile") || comment_status=$? - if [ "$comment_status" -ne 0 ]; then - unknown "could not read provider review summaries" - elif ! jq -e 'type == "array" and (all(.[]; type == "array") or all(.[]; type == "object"))' <<<"$comments" >/dev/null 2>&1; then - unknown "provider review-summary response was malformed" - else - summaries=$(jq -r ' - (if all(.[]; type == "array") then flatten else . end)[] | - select(.user.login == "chatgpt-codex-connector[bot]" and .user.type == "Bot") | - select((.body // "") | contains("codex-pull-request-review-summary")) | - .body - ' <<<"$comments") - if [ -z "$summaries" ]; then - bad "no provider review records or summaries" - else - current_prefix=0 - if grep -Fq "\`$short\`" <<<"$summaries"; then current_prefix=1; fi - if [ "$current_prefix" -eq 1 ]; then - unknown "provider summary exposes only an abbreviated head; obtain full-SHA provider evidence or independently review this exact head" - else - bad "provider review evidence names a different head" - fi - fi - fi + summary_good=1 + say "ok" "provider review summary is completed on $short" + fi +fi + +if [ "$provider_review" = "matched" ] && [ "$summary_good" -eq 1 ]; then + say "ok" "provider review records the full current head $short" +elif [ "$provider_review" != "matched" ] && [ "$summary_good" -eq 1 ]; then + if [ -n "$INDEPENDENT_REVIEW_SHA" ] && [ "$INDEPENDENT_REVIEW_SHA" = "$head" ]; then + say "ok" "manual independent review attestation names full current head $short" + elif [ -n "$INDEPENDENT_REVIEW_SHA" ]; then + bad "manual independent review attestation does not match the current head" + else + unknown "provider summary exposes only an abbreviated head; pass --independent-review-sha with an exact manual review attestation" fi fi @@ -433,6 +463,42 @@ else say "ok" "description links a completed review-checklist item" fi +body_section_has_content() { + local body="$1" labels="$2" + awk -v labels="$labels" ' + function heading(line, lower) { + lower = tolower(line) + sub(/^[[:space:]]*#+[[:space:]]*/, "", lower) + return lower ~ ("^(" labels ")[[:space:]]*:[[:space:]]*[^[:space:]]") || + lower ~ ("^(" labels ")[[:space:]]*:?[[:space:]]*$") + } + { + if (heading($0)) { + lower = tolower($0) + sub(/^[[:space:]]*#+[[:space:]]*/, "", lower) + if (lower ~ ("^(" labels ")[[:space:]]*:[[:space:]]*[^[:space:]]")) { found = 1; exit } + waiting = 1 + next + } + if (waiting && $0 ~ /[^[:space:]]/) { + if ($0 !~ /^[[:space:]]*#/ && $0 !~ /^[[:space:]]*/, "", lower) + if (pending_list) { + if (lower ~ /^[[:space:]]*$/) { + pending_list = 0 + } else if (lower ~ /^[[:space:]]*[-#]/) { + pending_list = 0 + } else if (lower !~ /^[[:space:]]+/) { + pending_list = 0 + } else if (lower ~ /:[[:space:]]*[^[:space:]]/) { + found = 1 + exit + } else if (lower ~ /:[[:space:]]*$/) { + pending_list = 2 + } else if (pending_list == 2 && lower ~ /[^[:space:]]/) { + if (!template_prompt($0) && lower !~ /^[[:space:]]*", "", lower).strip() + if host not in lower: + continue + if re.search(r"(validation|evidence|test|check|unaffected|not applicable|not affected|not impact)", lower): + value = lower.split(":", 1)[1].strip() if ":" in lower else "" + if value and value not in {"none", "n/a", "not applicable"}: + raise SystemExit(0) +raise SystemExit(1) +' "$host" <<<"$body" +} + # Paginate changed files through the REST endpoint; gh pr view hard-codes a # first:100 GraphQL fragment in some versions. Retain the line counts as well: # the privacy scan can only be complete when the textual diff describes every @@ -707,7 +785,7 @@ if [ "$files_status" -ne 0 ] || ! jq -e ' unknown "could not read the complete changed-file set" changed="" else - jq -r '(if all(.[]; type == "array") then flatten else . end)[] | [.filename, .status, .additions, .deletions] | @tsv' <<<"$files" >"$changed_records" + jq -r '(if all(.[]; type == "array") then flatten else . end)[] | [.filename, .status, .additions, .deletions, (.previous_filename? // "")] | @tsv' <<<"$files" >"$changed_records" changed=$(cut -f1 "$changed_records") changed_count=$(jq '(if all(.[]; type == "array") then flatten else . end) | length' <<<"$files") unique_changed_count=$(jq '(if all(.[]; type == "array") then flatten else . end) | map(.filename) | unique | length' <<<"$files") @@ -740,6 +818,30 @@ if [ "$files_status" -eq 0 ]; then ' <<<"$files") fi +# Treat source additions as implementation work only when the REST line totals +# prove that bytes were added. Rename paths are considered for every path +# policy below, so moving platform, migration, or sensitive code cannot evade +# the relevant review record. +implementation_code_added=false +platform_sensitive_change=false +migration_change=false +if [ "$files_status" -eq 0 ]; then + implementation_code_added=$(jq -r ' + (if all(.[]; type == "array") then flatten else . end) | + any(.[]; (.additions > 0) and (.filename | test("\\.(rs|ts|tsx|js|mjs|py|go|java|kt|swift|c|cc|cpp|h|hpp)$"))) + ' <<<"$files") + platform_sensitive_change=$(jq -r ' + (if all(.[]; type == "array") then flatten else . end) | + any(.[]; [.filename, (.previous_filename? // "")][] | + test("^(src-tauri/|src/.*\\.(rs|ts|tsx|js|mjs)$)|(^|/)(windows|macos|darwin|win32|local_files|paths)(/|[._-])"; "i")) + ' <<<"$files") + migration_change=$(jq -r ' + (if all(.[]; type == "array") then flatten else . end) | + any(.[]; [.filename, (.previous_filename? // "")][] | + test("(^|/)(migrations?|schema|database|db)(/|[._-])"; "i")) + ' <<<"$files") +fi + # The review contract requires an explicit security-impact statement whenever # either spelling of a renamed path touches a DSC, credential, or Tally surface. # Treat native and frontend source conservatively: their generic filenames can @@ -799,16 +901,30 @@ if [ "$security_reviewer_change" = "true" ]; then fi fi if [ "$workflow_change" = "true" ]; then - if ! body_section_has_content "$prbody" 'rollback notes|rollback'; then + if ! body_section_has_content "$prbody" 'rollback notes|rollback procedure|migration/sync compatibility and rollback procedure'; then bad "workflow change lacks non-empty rollback notes" fi - if ! body_section_has_content "$prbody" 'migration compatibility|migration impact'; then + if ! body_section_has_content "$prbody" 'migration compatibility|migration impact|migration/sync compatibility'; then bad "workflow change lacks non-empty migration compatibility notes" fi fi -if [ "$native_frontend_change" = "true" ] && ! body_section_has_content "$prbody" 'migration compatibility|migration impact'; then +if [ "$native_frontend_change" = "true" ] && ! body_section_has_content "$prbody" 'migration compatibility|migration impact|migration/sync compatibility'; then bad "native or frontend change lacks non-empty migration compatibility notes" fi +if [ "$migration_change" = "true" ] && ! body_section_has_content "$prbody" 'rollback notes|rollback procedure|migration/sync compatibility and rollback procedure'; then + bad "database migration path lacks non-empty rollback notes" +fi +if [ "$implementation_code_added" = "true" ] && ! body_has_p4_answers "$prbody"; then + bad "implementation code addition lacks all three substantive P4 reuse, deletion, and omission answers" +fi +if [ "$platform_sensitive_change" = "true" ]; then + if ! body_has_platform_evidence "$prbody" windows; then + bad "platform-sensitive change lacks substantive Windows validation evidence or unaffected-host rationale" + fi + if ! body_has_platform_evidence "$prbody" macos; then + bad "platform-sensitive change lacks substantive macOS validation evidence or unaffected-host rationale" + fi +fi # Read and validate the v1 surface as a required object. Any transport, # decoding, JSON, or schema failure is indeterminate; an unrelated nested @@ -910,14 +1026,14 @@ else ((.textual_destination == null) or (.textual_destination | type == "string" and length > 0)) and (.added | type == "number" and floor == . and . >= 0) and (.deleted | type == "number" and floor == . and . >= 0) and - (.binary | type == "boolean"))) and + (.binary | type == "boolean") and (.gitlink | type == "boolean"))) and (.added_payload | type == "array" and all(.[]; type == "string")) ' <<<"$parsed_diff" >/dev/null 2>&1; then unknown "could not parse diff sections for the privacy scan" : >"$diff_stats" : >"$added_payload" else - jq -r '.records[] | [.destination, .added, .deleted, (if .textual_destination == null then 0 else 1 end), (if .binary then 1 else 0 end)] | @tsv' <<<"$parsed_diff" >"$diff_stats" + jq -r '.records[] | [.destination, .added, .deleted, (if .textual_destination == null then 0 else 1 end), (if .binary then 1 else 0 end), (if .gitlink then 1 else 0 end)] | @tsv' <<<"$parsed_diff" >"$diff_stats" jq -r '.added_payload[]' <<<"$parsed_diff" >"$added_payload" @@ -926,6 +1042,7 @@ else metadata_only_count=0 metadata_only_examples="" binary_count=0 + gitlink_count=0 record_coverage_issue() { coverage_count=$((coverage_count + 1)) if [ "$coverage_count" -le 8 ]; then @@ -940,7 +1057,7 @@ else } path_text="" if [ -s "$changed_records" ]; then - while IFS=$'\t' read -r filename status rest_added rest_deleted; do + while IFS=$'\t' read -r filename status rest_added rest_deleted previous_filename; do [ "$status" = "removed" ] && continue match_count=$(awk -F '\t' -v filename="$filename" '$1 == filename { count++ } END { print count+0 }' "$diff_stats") if [ "$match_count" -ne 1 ]; then @@ -948,7 +1065,10 @@ else continue fi diff_record=$(awk -F '\t' -v filename="$filename" '$1 == filename { print; exit }' "$diff_stats") - IFS=$'\t' read -r _ diff_added diff_deleted textual binary <<<"$diff_record" + IFS=$'\t' read -r _ diff_added diff_deleted textual binary gitlink <<<"$diff_record" + if [ "$gitlink" -eq 1 ]; then + gitlink_count=$((gitlink_count + 1)) + fi if [ "$binary" -eq 1 ]; then # Its bytes cannot be reconciled through textual hunks. binary_count=$((binary_count + 1)) @@ -967,6 +1087,7 @@ else fi done <"$changed_records" [ "$binary_count" -eq 0 ] || bad "$binary_count binary addition/change(s) require human privacy inspection" + [ "$gitlink_count" -eq 0 ] || unknown "$gitlink_count gitlink change(s) require explicit provenance, license, and NOTICE review" if [ "$coverage_count" -gt 0 ]; then unknown "privacy diff coverage failed for $coverage_count non-removed REST file(s): $coverage_examples" fi diff --git a/scripts/merge-gate.test.py b/scripts/merge-gate.test.py index dc19eece0..1c3d4cbb7 100644 --- a/scripts/merge-gate.test.py +++ b/scripts/merge-gate.test.py @@ -140,10 +140,61 @@ def fail(message="controlled API failure"): body = body.replace("`python3 scripts/merge-gate.test.py`", "`cargo ...`") if scenario == "checklist-heading": body = body.replace("#L10", "#L1") + if scenario in {"implementation-p4-present", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "migration-rollback-present", "migration-template-wrapped", "security-notes-present", "security-review-valid"}: + body += ( + "\n## Scope, reuse, and impact\n\n" + "- Existing component reused: the existing gate parser and file inventory.\n" + "- What is deleted (or why no deletion is justified): no duplicate path remains.\n" + "- What breaks if this is not built: unsafe evidence could reach a merge.\n" + ) + if security_case or scenario in {"surface-unpins", "migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"}: + body += ( + "\n## Scope, reuse, and impact\n\n" + "- Existing component reused: the existing gate parser and file inventory.\n" + "- What is deleted (or why no deletion is justified): no duplicate path remains.\n" + "- What breaks if this is not built: unsafe evidence could reach a merge.\n" + "\n## Security impact\n\nNo credential material is added.\n" + "\n## Migration compatibility\n\nExisting callers retain their paths and formats.\n" + "\n- Windows validation evidence: Windows CI ran `python3 scripts/merge-gate.test.py`.\n" + "- macOS validation evidence: macOS CI ran `python3 scripts/merge-gate.test.py`.\n" + ) + if scenario in {"platform-evidence-present", "migration-template-wrapped", "security-notes-present", "security-review-valid", "sync-migration-present"}: + body += ( + "\n- Windows validation evidence: Windows CI ran `python3 scripts/merge-gate.test.py`.\n" + "- macOS validation evidence: macOS CI ran `python3 scripts/merge-gate.test.py`.\n" + ) + if scenario == "platform-checkbox-evidence": + body += ( + "\n- [x] Native Windows validation completed: `python3 scripts/merge-gate.test.py` passed on Windows CI.\n" + "- [x] Native macOS validation completed: `python3 scripts/merge-gate.test.py` passed on macOS CI.\n" + ) + if scenario == "platform-checkbox-comment": + body += ( + "\n- [x] Native Windows validation completed: \n" + "- [x] Native macOS validation completed: \n" + ) + if scenario in {"platform-evidence-present", "platform-checkbox-evidence"}: + body += ( + "\n## Security impact\n\nNo credential material is added.\n" + "\n## Migration compatibility\n\nExisting callers retain their paths and formats.\n" + ) + if scenario == "migration-rollback-present": + body += "\n## Rollback notes\n\nRevert the migration commit before deployment.\n" + if scenario == "migration-template-wrapped": + body += ( + "\n- Migration/sync compatibility and rollback procedure (required when an\n" + " existing workflow changes): Existing readers retain the old format; revert this commit before deployment.\n" + ) + if scenario == "migration-template-other-field": + body += ( + "\n- Migration/sync compatibility and rollback procedure (required when an\n" + " existing workflow changes):\n" + "- Destructive database migration: No\n" + ) body = body.replace("blob/HEAD", f"blob/{head}") if scenario == "checklist-stale-ref": body = body.replace(f"blob/{head}", "blob/" + "f" * 40) - one_file = scenario in {"files-empty", "formatted-phone", "formatted-phone-grouped", "repeated-phone", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "path-id", "binary-delete", "metadata-only", "metadata-incomplete", "hunk-header-phone", "hunk-header-literals", "hunk-binary-literal", "separated-dates", "separated-dates-new-year", "separated-dates-year-month", "adr-identifier", "all-a-pan", "masked-pan", "quoted-path", "control-path", "workflow-notes-missing", "workflow-notes-present", "workflow-delete", "workflow-delete-notes", "workflow-rename-out", "workflow-rename-out-notes", "renamed-previous-missing", "renamed-previous-null", "renamed-previous-false", "security-notes-missing", "security-notes-present", "security-rename-out", "security-crate", "security-agent-import", "security-dsc", "home-macos", "home-unix", "home-windows"} or scenario.startswith("home-") or security_case or sync_case + one_file = scenario in {"files-empty", "formatted-phone", "formatted-phone-grouped", "repeated-phone", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "path-id", "binary-delete", "metadata-only", "metadata-incomplete", "hunk-header-phone", "hunk-header-literals", "hunk-binary-literal", "separated-dates", "separated-dates-new-year", "separated-dates-year-month", "adr-identifier", "all-a-pan", "masked-pan", "quoted-path", "control-path", "workflow-notes-missing", "workflow-notes-present", "workflow-delete", "workflow-delete-notes", "workflow-rename-out", "workflow-rename-out-notes", "renamed-previous-missing", "renamed-previous-null", "renamed-previous-false", "security-notes-missing", "security-notes-present", "security-rename-out", "security-crate", "security-agent-import", "security-dsc", "home-macos", "home-unix", "home-windows", "crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path", "gitlink", "implementation-p4-missing", "implementation-p4-present", "platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"} or scenario.startswith("home-") or security_case or sync_case selected_base = new_head if scenario == "base-oid-mismatch" else base emit({"headRefOid": selected_head, "baseRefOid": selected_base, "baseRefName": "master", "mergeable": "MERGEABLE", "mergeStateStatus": final_state, @@ -235,6 +286,20 @@ def fail(message="controlled API failure"): emit(f"diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+{identifier}\n") elif scenario == "quoted-path": emit('diff --git "a/docs/caf\\303\\251.md" "b/docs/caf\\303\\251.md"\n--- "a/docs/caf\\303\\251.md"\n+++ "b/docs/caf\\303\\251.md"\n@@ -0,0 +1 @@\n+safe text\n') + elif scenario == "crlf-diff": + emit("diff --git a/docs/example.md b/docs/example.md\r\n--- a/docs/example.md\r\n+++ b/docs/example.md\r\n@@ -0,0 +1 @@\r\n+safe text\r\n") + elif scenario == "ambiguous-unquoted-path": + emit("diff --git a/docs/a b/example.md b/docs/a b/example.md\n--- a/docs/a b/example.md\n+++ b/docs/a b/example.md\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario == "ambiguous-rename-path": + emit("diff --git a/docs/a b/example.md b/docs/a b/example.md\nsimilarity index 100%\nrename from docs/a b/example.md\nrename to docs/a b/example.md\n") + elif scenario == "gitlink": + emit("diff --git a/vendor/module b/vendor/module\nnew file mode 160000\nindex 0000000..2222222\n--- /dev/null\n+++ b/vendor/module\n@@ -0,0 +1 @@\n+Subproject commit 2222222\n") + elif scenario in {"implementation-p4-missing", "implementation-p4-present"}: + emit("diff --git a/scripts/example.py b/scripts/example.py\n--- a/scripts/example.py\n+++ b/scripts/example.py\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario in {"platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment"}: + emit("diff --git a/src-tauri/src/local_files/paths.rs b/src-tauri/src/local_files/paths.rs\n--- a/src-tauri/src/local_files/paths.rs\n+++ b/src-tauri/src/local_files/paths.rs\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario in {"migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"}: + emit("diff --git a/src-tauri/migrations/001.sql b/src-tauri/migrations/001.sql\n--- a/src-tauri/migrations/001.sql\n+++ b/src-tauri/migrations/001.sql\n@@ -0,0 +1 @@\n+safe text\n") elif scenario == "separated-dates": emit("diff --git a/docs/example.md b/docs/example.md\n--- a/docs/example.md\n+++ b/docs/example.md\n@@ -0,0 +1 @@\n+2026-09-12 2026-09-13\n") elif scenario == "separated-dates-new-year": @@ -325,7 +390,13 @@ def status_page(rows, total_count, state="success", page_head=head): message = "safe commit metadata" if scenario == "metadata-commit-id": message = "Customer " + "ABCDE" + "1234" + "F" - commits = [{"sha": head, "commit": {"message": message}}] + identity = {"name": "Maintainer", "email": "maintainer@example.invalid"} + if scenario == "metadata-author-id": + identity = {"name": "ABCDE" + "1234" + "F", "email": "maintainer@example.invalid"} + linked_author = None if scenario == "metadata-unlinked-identities" else {"login": "author"} + linked_committer = None if scenario == "metadata-unlinked-identities" else {"login": "committer"} + commits = [{"sha": head, "commit": {"message": message, "author": identity, "committer": identity}, + "author": linked_author, "committer": linked_committer}] if scenario == "metadata-duplicate": commits.append({"sha": head, "commit": {"message": message}}) emit([commits]) @@ -426,6 +497,19 @@ def status_page(rows, total_count, state="success", page_head=head): emit([[{"filename": "docs/example.md", "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario == "quoted-path": emit([[{"filename": "docs/café.md", "status": "added", "additions": 1, "deletions": 0}]]) + elif scenario in {"crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path"}: + status = "renamed" if scenario == "ambiguous-rename-path" else "added" + record = {"filename": "docs/a b/example.md" if scenario != "crlf-diff" else "docs/example.md", "status": status, "additions": 0 if status == "renamed" else 1, "deletions": 0} + if status == "renamed": record["previous_filename"] = "docs/a b/example.md" + emit([[record]]) + elif scenario == "gitlink": + emit([[{"filename": "vendor/module", "status": "modified", "additions": 1, "deletions": 1}]]) + elif scenario in {"implementation-p4-missing", "implementation-p4-present"}: + emit([[{"filename": "scripts/example.py", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario in {"platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment"}: + emit([[{"filename": "src-tauri/src/local_files/paths.rs", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario in {"migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"}: + emit([[{"filename": "src-tauri/migrations/001.sql", "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario == "control-path": emit([[{"filename": "docs/unsafe\x1b.md", "status": "added", "additions": 1, "deletions": 0}]]) elif scenario == "malformed-files": @@ -782,6 +866,11 @@ def test_pr_title_identifier_is_scanned(self): def test_pr_commit_metadata_identifier_is_scanned(self): self.assert_blocked("metadata-commit-id", "privacy scan found") + def test_standard_commit_identity_fields_are_validated_and_scanned(self): + self.assert_blocked("metadata-author-id", "privacy scan found") + result = self.run_gate("metadata-unlinked-identities") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_capped_commit_metadata_is_indeterminate(self): self.assert_indeterminate("metadata-capped", "complete head-bound PR commit metadata") @@ -805,6 +894,15 @@ def test_quoted_git_destination_path_is_covered(self): result = self.run_gate("quoted-path") self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_crlf_and_ambiguous_unquoted_diffs_preserve_coverage(self): + for scenario in ("crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path"): + with self.subTest(scenario=scenario): + result = self.run_gate(scenario) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_gitlink_requires_explicit_provenance_license_notice_review(self): + self.assert_indeterminate("gitlink", "gitlink change(s) require explicit provenance, license, and NOTICE review") + def test_control_character_in_destination_path_is_indeterminate(self): self.assert_indeterminate("control-path", "could not read the complete changed-file set") @@ -912,6 +1010,29 @@ def test_sync_rename_out_requires_migration_notes(self): result = self.run_gate("sync-migration-present") self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_implementation_additions_need_all_three_p4_answers(self): + self.assert_blocked("implementation-p4-missing", "all three substantive P4") + result = self.run_gate("implementation-p4-present") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_platform_sensitive_paths_need_substantive_both_host_evidence(self): + self.assert_blocked("platform-evidence-missing", "substantive Windows validation") + result = self.run_gate("platform-evidence-present") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = self.run_gate("platform-checkbox-evidence") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assert_blocked("platform-checkbox-comment", "substantive Windows validation") + + def test_database_migration_paths_need_rollback_notes(self): + self.assert_blocked("migration-rollback-missing", "database migration path lacks non-empty rollback notes") + result = self.run_gate("migration-rollback-present") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_wrapped_canonical_migration_template_field_is_recognized(self): + result = self.run_gate("migration-template-wrapped") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assert_blocked("migration-template-other-field", "database migration path lacks non-empty rollback notes") + def test_developer_home_path_shapes_are_scanned_without_echoing_values(self): for scenario in ("home-macos", "home-unix", "home-windows", "home-macos-root", "home-unix-root", "home-windows-forward", "home-windows-escaped"): with self.subTest(scenario=scenario): @@ -934,6 +1055,17 @@ def removeprefix(self, _prefix): self.assertEqual(module.diff_destination(NoRemovePrefix("diff --git a/x b/y")), "y") self.assertEqual(module.textual_destination(NoRemovePrefix("+++ b/y")), "y") + def test_diff_parser_accepts_git_generated_mixed_quote_rename_headers(self): + import importlib.util + parser = ROOT / "scripts" / "merge_gate_diff.py" + spec = importlib.util.spec_from_file_location("merge_gate_diff_mixed", parser) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + unicode_name = '"b/\\351\\233\\252.txt"' + self.assertEqual(module.diff_destination('diff --git "a/\\351\\233\\252.txt" b/plain.txt'), "plain.txt") + self.assertEqual(module.diff_destination('diff --git a/plain.txt ' + unicode_name), "雪.txt") + self.assertEqual(module.diff_destination("diff --git a/foo b/bar b/foo b/bar"), "foo b/bar") + def test_definite_blocker_wins_over_indeterminate_evidence(self): self.assert_blocked("draft-surface-fail", "merge state DRAFT") result = self.run_gate("draft-surface-fail") diff --git a/scripts/merge_gate_diff.py b/scripts/merge_gate_diff.py index 72fef263b..165e56bb6 100755 --- a/scripts/merge_gate_diff.py +++ b/scripts/merge_gate_diff.py @@ -61,21 +61,56 @@ def quoted_token(value: str, start: int) -> tuple[str, int]: raise ValueError("unterminated quoted token") -def diff_destination(line: str) -> str: +def diff_destination(line: str) -> str | None: value = remove_prefix(line, "diff --git ") if value.startswith('"'): _source, index = quoted_token(value, 0) if index >= len(value) or value[index] != " ": raise ValueError("missing destination") - destination, end = quoted_token(value, index + 1) - if end != len(value): - raise ValueError("trailing header text") + destination = value[index + 1:] + path = decode_quoted_path(destination) if destination.startswith('"') else destination + if not path.startswith("b/"): + raise ValueError("destination does not start b/") + return path[2:] + # Git quotes each path independently. A plain source with a quoted + # destination is therefore valid (and occurs for plain-to-Unicode + # renames); the quote itself cannot occur in an unquoted token. + quoted_destination = value.find(' "b/') + if quoted_destination >= 0: + source = value[:quoted_destination] + destination = value[quoted_destination + 1:] + if not source.startswith("a/"): + raise ValueError("invalid unquoted source") path = decode_quoted_path(destination) if not path.startswith("b/"): raise ValueError("destination does not start b/") return path[2:] - source, separator, destination = value.partition(" b/") - if not separator or not source.startswith("a/") or not destination: + # An unquoted header has no escaping grammar. Splitting on the first + # `` b/`` silently misparses a legal-looking filename containing that + # sequence. Defer an ambiguous header to the independently parsed +++ + # destination; that destination is subsequently reconciled to the REST + # changed-file inventory. Metadata-only ambiguous records remain + # indeterminate because they have no unambiguous textual identity. + parts = value.split(" b/") + if len(parts) != 2: + # Pure mode/deletion records may have neither +++ nor rename-to. A + # same-path header can still be proven when exactly one candidate + # delimiter leaves identical a/ and b/ paths. Do not guess when the + # filename makes that proof ambiguous. + matches: list[str] = [] + start = 0 + while True: + index = value.find(" b/", start) + if index < 0: + break + source = value[:index] + destination = value[index + 1:] + if source.startswith("a/") and destination.startswith("b/") and source[2:] == destination[2:]: + matches.append(destination[2:]) + start = index + 1 + return matches[0] if len(matches) == 1 else None + source, destination = parts + if not source.startswith("a/") or not destination: raise ValueError("invalid unquoted header") return destination @@ -102,15 +137,21 @@ def emit() -> None: if record is not None: records.append(record.copy()) - for line in lines: + for raw_line in lines: + # A diff is delimited by LF. CR from CRLF or in an added payload is + # content for scanning/counting, but must not prevent recognising a + # protocol header. + line = raw_line[:-1] if raw_line.endswith("\r") else raw_line if line.startswith("diff --git "): emit() record = { "destination": diff_destination(line), "textual_destination": None, + "rename_destination": None, "added": 0, "deleted": 0, "binary": False, + "gitlink": False, "in_hunk": False, } continue @@ -119,28 +160,46 @@ def emit() -> None: if not record["in_hunk"] and line.startswith("+++ "): record["textual_destination"] = textual_destination(line) continue + if not record["in_hunk"] and line.startswith("rename to "): + destination = remove_prefix(line, "rename to ") + record["rename_destination"] = decode_quoted_path(destination) if destination.startswith('"') else destination + continue if not record["in_hunk"] and line in {"GIT binary patch"} or ( not record["in_hunk"] and line.startswith("Binary files ") and line.endswith(" differ") ): record["binary"] = True continue + if not record["in_hunk"] and line in { + "old mode 160000", "new mode 160000", "new file mode 160000", + "deleted file mode 160000", + }: + record["gitlink"] = True + continue if line.startswith("@@ "): record["in_hunk"] = True continue - if record["in_hunk"] and line.startswith("+"): + if record["in_hunk"] and raw_line.startswith("+"): record["added"] = int(record["added"]) + 1 - added_payload.append(line[1:]) - elif record["in_hunk"] and line.startswith("-"): + added_payload.append(raw_line[1:]) + elif record["in_hunk"] and raw_line.startswith("-"): record["deleted"] = int(record["deleted"]) + 1 emit() for record in records: record.pop("in_hunk") + if record["destination"] is None: + if record["textual_destination"] is None and record["rename_destination"] is None: + raise ValueError("ambiguous header without textual destination") + record["destination"] = record["textual_destination"] or record["rename_destination"] + record.pop("rename_destination") return {"records": records, "added_payload": added_payload} if __name__ == "__main__": try: - print(json.dumps(parse(sys.stdin.read().splitlines()))) + raw = sys.stdin.buffer.read().decode("utf-8") + # Split only on the protocol's LF delimiter. Do not let Python's + # universal-newline mode erase CR or Unicode line-separator payload. + print(json.dumps(parse(raw.split("\n")))) except (UnicodeError, ValueError) as error: print(f"merge_gate_diff_error:{error}", file=sys.stderr) raise SystemExit(2) From 830c0289bffeeb7db8bfd63a31af40121c79f491 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 04:17:00 +0530 Subject: [PATCH 43/46] fix: reject unsupported gate evidence placeholders --- scripts/merge-gate.sh | 36 ++++++++++++++++++++-------- scripts/merge-gate.test.py | 48 +++++++++++++++++++++++++++----------- 2 files changed, 61 insertions(+), 23 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index afbb9990b..cc6219a97 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -608,8 +608,8 @@ else fi body_section_has_content() { - local body="$1" labels="$2" - awk -v labels="$labels" ' + local body="$1" labels="$2" allow_placeholders="${3:-false}" + awk -v labels="$labels" -v allow_placeholders="$allow_placeholders" ' function heading(line, lower) { lower = tolower(line) sub(/^[[:space:]]*#+[[:space:]]*/, "", lower) @@ -625,6 +625,19 @@ body_section_has_content() { lower ~ /^-[[:space:]]*captured\/fixture\/live scope and known limitations:[[:space:]]*$/ || lower ~ /^-[[:space:]]*manual\/ui evidence[[:space:]]*\(.*\):[[:space:]]*$/ } + function after_colon(line, value) { + value = line + sub(/^[^:]*:[[:space:]]*/, "", value) + return value + } + function meaningful(value, lower) { + gsub(//, "", value) + lower = tolower(value) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", lower) + if (lower == "") return 0 + if (allow_placeholders == "true" && lower ~ /^(n\/a|none|no impact)$/) return 1 + return lower !~ /^(n\/a|none|pending|todo|tbd|not applicable|unaffected|not affected|not impacted|no impact)$/ + } { # The canonical PR template uses labelled list fields as well as # headings. Accept a filled field for the requested policy label, but @@ -638,20 +651,20 @@ body_section_has_content() { pending_list = 0 } else if (lower !~ /^[[:space:]]+/) { pending_list = 0 - } else if (lower ~ /:[[:space:]]*[^[:space:]]/) { + } else if (lower ~ /:[[:space:]]*[^[:space:]]/ && meaningful(after_colon(lower))) { found = 1 exit } else if (lower ~ /:[[:space:]]*$/) { pending_list = 2 } else if (pending_list == 2 && lower ~ /[^[:space:]]/) { - if (!template_prompt($0) && lower !~ /^[[:space:]]*\n" "- [x] Native macOS validation completed: \n" ) - if scenario in {"platform-evidence-present", "platform-checkbox-evidence"}: + if scenario == "platform-unaffected-bare": + body += "\n- Windows validation evidence: unaffected\n- macOS validation evidence: unaffected\n" + if scenario == "platform-unaffected-rationale": + body += "\n- Windows validation evidence: unaffected because this changes shared documentation only.\n- macOS validation evidence: unaffected because this changes shared documentation only.\n" + if scenario in {"platform-evidence-present", "platform-checkbox-evidence", "platform-unaffected-bare", "platform-unaffected-rationale"}: body += ( "\n## Security impact\n\nNo credential material is added.\n" "\n## Migration compatibility\n\nExisting callers retain their paths and formats.\n" @@ -191,10 +196,19 @@ def fail(message="controlled API failure"): " existing workflow changes):\n" "- Destructive database migration: No\n" ) + if scenario == "p4-placeholders": + body += ( + "\n## Scope, reuse, and impact\n\n" + "- Existing component reused: N/A\n" + "- What is deleted (or why no deletion is justified): TODO\n" + "- What breaks if this is not built: pending\n" + ) + if scenario == "workflow-placeholders": + body += "\n## Rollback notes\n\nN/A\n\n## Migration compatibility\n\nTBD\n" body = body.replace("blob/HEAD", f"blob/{head}") if scenario == "checklist-stale-ref": body = body.replace(f"blob/{head}", "blob/" + "f" * 40) - one_file = scenario in {"files-empty", "formatted-phone", "formatted-phone-grouped", "repeated-phone", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "path-id", "binary-delete", "metadata-only", "metadata-incomplete", "hunk-header-phone", "hunk-header-literals", "hunk-binary-literal", "separated-dates", "separated-dates-new-year", "separated-dates-year-month", "adr-identifier", "all-a-pan", "masked-pan", "quoted-path", "control-path", "workflow-notes-missing", "workflow-notes-present", "workflow-delete", "workflow-delete-notes", "workflow-rename-out", "workflow-rename-out-notes", "renamed-previous-missing", "renamed-previous-null", "renamed-previous-false", "security-notes-missing", "security-notes-present", "security-rename-out", "security-crate", "security-agent-import", "security-dsc", "home-macos", "home-unix", "home-windows", "crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path", "gitlink", "implementation-p4-missing", "implementation-p4-present", "platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"} or scenario.startswith("home-") or security_case or sync_case + one_file = scenario in {"files-empty", "formatted-phone", "formatted-phone-grouped", "repeated-phone", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "path-id", "binary-delete", "metadata-only", "metadata-incomplete", "hunk-header-phone", "hunk-header-literals", "hunk-binary-literal", "separated-dates", "separated-dates-new-year", "separated-dates-year-month", "adr-identifier", "all-a-pan", "masked-pan", "quoted-path", "control-path", "workflow-notes-missing", "workflow-notes-present", "workflow-delete", "workflow-delete-notes", "workflow-rename-out", "workflow-rename-out-notes", "workflow-placeholders", "renamed-previous-missing", "renamed-previous-null", "renamed-previous-false", "security-notes-missing", "security-notes-present", "security-none", "security-pending", "security-rename-out", "security-crate", "security-agent-import", "security-dsc", "home-macos", "home-unix", "home-windows", "crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path", "gitlink", "implementation-p4-missing", "implementation-p4-present", "p4-placeholders", "platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale", "migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"} or scenario.startswith("home-") or security_case or sync_case selected_base = new_head if scenario == "base-oid-mismatch" else base emit({"headRefOid": selected_head, "baseRefOid": selected_base, "baseRefName": "master", "mergeable": "MERGEABLE", "mergeStateStatus": final_state, @@ -227,7 +241,7 @@ def fail(message="controlled API failure"): elif scenario == "path-id": path_id = "ABCDE" + "1234" + "F" emit(f"diff --git a/docs/safe.md b/docs/{path_id}.md\n--- a/docs/safe.md\n+++ b/docs/{path_id}.md\n@@ -0,0 +1 @@\n+safe text\n") - elif scenario in {"security-notes-missing", "security-notes-present"}: + elif scenario in {"security-notes-missing", "security-notes-present", "security-none", "security-pending"}: emit("diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs\n--- a/src-tauri/src/tally/runtime.rs\n+++ b/src-tauri/src/tally/runtime.rs\n@@ -0,0 +1 @@\n+safe text\n") elif scenario in {"security-crate", "security-agent-import", "security-dsc"}: paths = {"security-crate": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", "security-agent-import": "src-tauri/src/agent_import.rs", "security-dsc": "src-tauri/src/dsc.rs"} @@ -294,9 +308,9 @@ def fail(message="controlled API failure"): emit("diff --git a/docs/a b/example.md b/docs/a b/example.md\nsimilarity index 100%\nrename from docs/a b/example.md\nrename to docs/a b/example.md\n") elif scenario == "gitlink": emit("diff --git a/vendor/module b/vendor/module\nnew file mode 160000\nindex 0000000..2222222\n--- /dev/null\n+++ b/vendor/module\n@@ -0,0 +1 @@\n+Subproject commit 2222222\n") - elif scenario in {"implementation-p4-missing", "implementation-p4-present"}: + elif scenario in {"implementation-p4-missing", "implementation-p4-present", "p4-placeholders"}: emit("diff --git a/scripts/example.py b/scripts/example.py\n--- a/scripts/example.py\n+++ b/scripts/example.py\n@@ -0,0 +1 @@\n+safe text\n") - elif scenario in {"platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment"}: + elif scenario in {"platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale"}: emit("diff --git a/src-tauri/src/local_files/paths.rs b/src-tauri/src/local_files/paths.rs\n--- a/src-tauri/src/local_files/paths.rs\n+++ b/src-tauri/src/local_files/paths.rs\n@@ -0,0 +1 @@\n+safe text\n") elif scenario in {"migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"}: emit("diff --git a/src-tauri/migrations/001.sql b/src-tauri/migrations/001.sql\n--- a/src-tauri/migrations/001.sql\n+++ b/src-tauri/migrations/001.sql\n@@ -0,0 +1 @@\n+safe text\n") @@ -457,7 +471,7 @@ def status_page(rows, total_count, state="success", page_head=head): emit([[]]) elif scenario in {"formatted-phone", "formatted-phone-grouped", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized"}: emit([[{"filename": "docs/contact.md", "status": "added", "additions": 1, "deletions": 0}]]) - elif scenario in {"workflow-notes-missing", "workflow-notes-present"}: + elif scenario in {"workflow-notes-missing", "workflow-notes-present", "workflow-placeholders"}: emit([[{"filename": ".github/workflows/ci.yml", "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario in {"workflow-delete", "workflow-delete-notes"}: emit([[{"filename": ".github/workflows/ci.yml", "status": "removed", "additions": 0, "deletions": 1}]]) @@ -472,7 +486,7 @@ def status_page(rows, total_count, state="success", page_head=head): elif scenario == "path-id": path_id = "ABCDE" + "1234" + "F" emit([[{"filename": f"docs/{path_id}.md", "status": "added", "additions": 1, "deletions": 0}]]) - elif scenario in {"security-notes-missing", "security-notes-present"}: + elif scenario in {"security-notes-missing", "security-notes-present", "security-none", "security-pending"}: emit([[{"filename": "src-tauri/src/tally/runtime.rs", "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario == "security-rename-out": emit([[{"filename": "src/runtime.rs", "previous_filename": "src-tauri/src/tally/runtime.rs", "status": "renamed", "additions": 0, "deletions": 0}]]) @@ -504,9 +518,9 @@ def status_page(rows, total_count, state="success", page_head=head): emit([[record]]) elif scenario == "gitlink": emit([[{"filename": "vendor/module", "status": "modified", "additions": 1, "deletions": 1}]]) - elif scenario in {"implementation-p4-missing", "implementation-p4-present"}: + elif scenario in {"implementation-p4-missing", "implementation-p4-present", "p4-placeholders"}: emit([[{"filename": "scripts/example.py", "status": "modified", "additions": 1, "deletions": 0}]]) - elif scenario in {"platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment"}: + elif scenario in {"platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale"}: emit([[{"filename": "src-tauri/src/local_files/paths.rs", "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario in {"migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"}: emit([[{"filename": "src-tauri/migrations/001.sql", "status": "modified", "additions": 1, "deletions": 0}]]) @@ -925,6 +939,7 @@ def test_grouped_12_and_16_digit_identifiers_are_scanned(self): def test_workflow_change_requires_rollback_and_migration_notes(self): self.assert_blocked("workflow-notes-missing", "workflow change lacks non-empty rollback notes") + self.assert_blocked("workflow-placeholders", "workflow change lacks non-empty rollback notes") def test_workflow_change_with_required_notes_can_pass(self): result = self.run_gate("workflow-notes-present") @@ -952,6 +967,9 @@ def test_sensitive_paths_require_security_impact_notes_including_renames(self): self.assert_blocked(scenario, "security-impact notes") result = self.run_gate("security-notes-present") self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = self.run_gate("security-none") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assert_blocked("security-pending", "security-impact notes") def test_sensitive_paths_without_security_attestation_are_indeterminate(self): self.assert_indeterminate("security-review-missing", "security-focused reviewer comment") @@ -1012,6 +1030,7 @@ def test_sync_rename_out_requires_migration_notes(self): def test_implementation_additions_need_all_three_p4_answers(self): self.assert_blocked("implementation-p4-missing", "all three substantive P4") + self.assert_blocked("p4-placeholders", "all three substantive P4") result = self.run_gate("implementation-p4-present") self.assertEqual(result.returncode, 0, result.stdout + result.stderr) @@ -1022,6 +1041,9 @@ def test_platform_sensitive_paths_need_substantive_both_host_evidence(self): result = self.run_gate("platform-checkbox-evidence") self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assert_blocked("platform-checkbox-comment", "substantive Windows validation") + self.assert_blocked("platform-unaffected-bare", "substantive Windows validation") + result = self.run_gate("platform-unaffected-rationale") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) def test_database_migration_paths_need_rollback_notes(self): self.assert_blocked("migration-rollback-missing", "database migration path lacks non-empty rollback notes") From 0be895dd14f0e1c237eac82ca9da47208349d79a Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 04:54:18 +0530 Subject: [PATCH 44/46] fix: harden merge gate evidence parsing --- scripts/merge-gate.sh | 65 ++++++++++++++++++++++++++++++-------- scripts/merge-gate.test.py | 61 +++++++++++++++++++++++++++++------ 2 files changed, 103 insertions(+), 23 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index cc6219a97..55dfadcba 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -99,6 +99,8 @@ die() { echo "$1" >&2; exit 2; } # A malformed response is different from a valid empty result. Validate the # outer shape before extracting fields so jq errors cannot become empty values. +# Independent acceptance decides whether a change is a production regression, +# whether its branch is dedicated, and whether it carries the required type:rectify label. : >"$errfile" if ! meta=$(gh pr view "$PR" --repo "$REPO" \ --json headRefOid,baseRefOid,baseRefName,mergeable,mergeStateStatus,isDraft,state,title,body,changedFiles 2>"$errfile"); then @@ -128,7 +130,17 @@ pstate=$(jq -r '.state' <<<"$meta") changed_files_expected=$(jq -r '.changedFiles' <<<"$meta") short=${head:0:7} title=$(jq -r '.title' <<<"$meta") -prbody=$(jq -r '.body // ""' <<<"$meta") +raw_prbody=$(jq -r '.body // ""' <<<"$meta") +prbody="$raw_prbody" +visible_body_status=0 +prbody=$(python3 -c 'import re, sys +text = sys.stdin.read() +if text.count(""): raise SystemExit(2) +print(re.sub(r"", "", text, flags=re.S), end="")' <<<"$prbody") || visible_body_status=$? +if [ "$visible_body_status" -ne 0 ]; then + unknown "could not extract visible PR description content" + prbody="" +fi if [ -n "$INDEPENDENT_REVIEW_SHA" ] && ! [[ "$INDEPENDENT_REVIEW_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then bad "independent review attestation must be a full 40-hex commit SHA" @@ -221,6 +233,8 @@ else unknown "branch protection returned no required status-check contexts" elif missing_documented=$(comm -23 <(sort -u <<<"$documented_contexts") <(sort -u <<<"$required_contexts")) && [ -n "$missing_documented" ]; then bad "branch protection omits $(wc -l <<<"$missing_documented" | tr -d ' ') documented required check context(s)" + elif unexpected_documented=$(comm -13 <(sort -u <<<"$documented_contexts") <(sort -u <<<"$required_contexts")) && [ -n "$unexpected_documented" ]; then + bad "branch protection includes $(wc -l <<<"$unexpected_documented" | tr -d ' ') undocumented required check context(s)" else say "ok" "loaded $(wc -l <<<"$required_contexts" | tr -d ' ') required check context(s)" fi @@ -459,8 +473,8 @@ else .commit.committer.name, .commit.committer.email, (.author.login? // null), (.committer.login? // null)] | map(select(. != null))[]' <<<"$metadata_commits") - privacy_metadata="$title -$prbody +privacy_metadata="$title +$raw_prbody $commit_messages" fi @@ -568,9 +582,10 @@ checklist_link_ok() { local body="$1" checklist="$2" line awaiting_permalink=0 link anchor local checked='^[[:space:]]*-[[:space:]]*\[[xX]\][[:space:]]+' local permalink="https://github\.com/${OWNER}/${NAME}/blob/${head}/review-checklist\.md#L[0-9]+" + local permalink_boundary="${permalink}"'([[:space:]]|\)|$)' while IFS= read -r line; do if printf '%s\n' "$line" | grep -Eq "$checked"; then - if printf '%s\n' "$line" | grep -Eiq "$permalink"; then + if printf '%s\n' "$line" | grep -Eiq "$permalink_boundary"; then awaiting_permalink=2 elif printf '%s\n' "$line" | grep -Eiq 'review-checklist\.md'; then awaiting_permalink=1 @@ -578,7 +593,7 @@ checklist_link_ok() { awaiting_permalink=0 fi elif [ "$awaiting_permalink" -eq 1 ] && printf '%s\n' "$line" | grep -Eq '^[[:space:]]+'; then - if printf '%s\n' "$line" | grep -Eiq "$permalink"; then + if printf '%s\n' "$line" | grep -Eiq "$permalink_boundary"; then awaiting_permalink=2 fi elif [ "$awaiting_permalink" -ne 2 ]; then @@ -587,10 +602,11 @@ checklist_link_ok() { if [ "$awaiting_permalink" -eq 2 ]; then while IFS= read -r link; do anchor=${link##*#L} + anchor=${anchor%%[^0-9]*} if sed -n "${anchor}p" <<<"$checklist" | grep -Eq '^[[:space:]]*-[[:space:]]*\[[ xX]\][[:space:]]+[^[:space:]]'; then return 0 fi - done < <(printf '%s\n' "$line" | grep -Eio "$permalink") + done < <(printf '%s\n' "$line" | grep -Eio "$permalink_boundary") awaiting_permalink=0 fi done <<<"$body" @@ -708,8 +724,9 @@ body_has_validation_command() { # A tool name mentioned in prose or an unrelated section is not a command. python3 -c ' import re, shlex, sys +text = re.sub(r"", "", sys.stdin.read(), flags=re.S) active = fenced = False -for line in sys.stdin.read().splitlines(): +for line in text.splitlines(): heading = re.match(r"^\s*#{1,6}\s+(.+?)\s*$", line) if heading: active = bool(re.fullmatch(r"(?:test or reproduction command|commands and results|validation and evidence):?", heading[1], re.I)) @@ -844,7 +861,7 @@ migration_change=false if [ "$files_status" -eq 0 ]; then implementation_code_added=$(jq -r ' (if all(.[]; type == "array") then flatten else . end) | - any(.[]; (.additions > 0) and (.filename | test("\\.(rs|ts|tsx|js|mjs|py|go|java|kt|swift|c|cc|cpp|h|hpp)$"))) + any(.[]; (.additions > 0) and (.filename | test("\\.(rs|ts|tsx|js|mjs|py|go|java|kt|swift|c|cc|cpp|h|hpp|sh|bash)$"))) ' <<<"$files") platform_sensitive_change=$(jq -r ' (if all(.[]; type == "array") then flatten else . end) | @@ -1059,16 +1076,23 @@ else metadata_only_examples="" binary_count=0 gitlink_count=0 + bounded_coverage_name() { + local item + item=$(sed -E 's#/(Users|home)/[^/[:space:]]+##g; s#[A-Za-z]:[\\/]+Users[\\/]+[^\\/[:space:]]+##g' <<<"$1") + printf '%s' "${item:0:160}" + } record_coverage_issue() { coverage_count=$((coverage_count + 1)) if [ "$coverage_count" -le 8 ]; then - coverage_examples="${coverage_examples}${coverage_examples:+; }$1" + local item + item=$(bounded_coverage_name "$1") + coverage_examples="${coverage_examples}${coverage_examples:+; }$item" fi } record_metadata_only() { metadata_only_count=$((metadata_only_count + 1)) if [ "$metadata_only_count" -le 8 ]; then - metadata_only_examples="${metadata_only_examples}${metadata_only_examples:+; }$1" + metadata_only_examples="${metadata_only_examples}${metadata_only_examples:+; }$(bounded_coverage_name "$1")" fi } path_text="" @@ -1169,10 +1193,16 @@ $added" # identifiers, including adjacent date fragments. Alongside mobile numbers, # accept only 4-4-4 and 4-4-4-4 grouped long-number forms. phone_status=0 - phone_matches=$(grep -Eo '(^|[^[:alnum:]])[6-9]([ ()+._-]{0,3}[0-9]){9}([^[:alnum:]]|$)' <<<"$redacted") || phone_status=$? + normalized_status=0 + normalized_whitespace=$(python3 -c 'import sys, unicodedata; print("".join(" " if unicodedata.category(char) == "Zs" else char for char in sys.stdin.read()), end="")' <<<"$redacted") || normalized_status=$? + if [ "$normalized_status" -ne 0 ]; then + unknown "Unicode whitespace normalization failed" + normalized_whitespace="" + fi + phone_matches=$(grep -Eo '(^|[^[:alnum:]])[6-9]([ ()+._-]{0,3}[0-9]){9}([^[:alnum:]]|$)' <<<"$normalized_whitespace") || phone_status=$? grouped_number_status=0 grouped_number_matches=$(grep -Eo '(^|[^[:alnum:]])[0-9]{4}([ ._-])[0-9]{4}\2[0-9]{4}(\2[0-9]{4})?([^[:alnum:]]|$)' <<<"$redacted") || grouped_number_status=$? - if [ "$phone_status" -gt 1 ] || [ "$grouped_number_status" -gt 1 ]; then + if [ "$normalized_status" -ne 0 ] || [ "$phone_status" -gt 1 ] || [ "$grouped_number_status" -gt 1 ]; then unknown "formatted identifier scan expression failed" fi normalized_phone=$(sed -E 's/[^0-9]//g' <<<"$phone_matches") @@ -1223,12 +1253,19 @@ else BEHIND|DIRTY|UNKNOWN|BLOCKED|UNSTABLE|DRAFT) bad "PR merge state changed to $final_state during preflight" ;; *) unknown "PR merge state changed to unrecognised value '$final_state' during preflight" ;; esac - final_body=$(jq -r '.body // ""' <<<"$final_meta") + raw_final_body=$(jq -r '.body // ""' <<<"$final_meta") + final_body="$raw_final_body" + final_visible_status=0 + final_body=$(python3 -c 'import re, sys +text = sys.stdin.read() +if text.count(""): raise SystemExit(2) +print(re.sub(r"", "", text, flags=re.S), end="")' <<<"$final_body") || final_visible_status=$? + [ "$final_visible_status" -eq 0 ] || unknown "could not extract final visible PR description content" [ "$final_title" = "$title" ] || bad "PR title changed during preflight" if ! checklist_link_ok "$final_body" "$review_checklist"; then bad "PR description changed and no longer carries a completed same-repository line-specific checklist link" fi - if [ "$final_body" != "$prbody" ]; then + if [ "$raw_final_body" != "$raw_prbody" ]; then bad "PR description changed during preflight; re-run metadata privacy scan" if ! body_section_has_content "$final_body" 'functional summary|outcome and reason'; then bad "PR description changed and no longer carries a non-empty functional summary" diff --git a/scripts/merge-gate.test.py b/scripts/merge-gate.test.py index 498838fb8..14819545a 100644 --- a/scripts/merge-gate.test.py +++ b/scripts/merge-gate.test.py @@ -135,12 +135,22 @@ def fail(message="controlled API failure"): body += "\n## Migration compatibility\n\nNo persisted format change.\n" if scenario == "validation-command-outside": body = body.replace("`python3 scripts/merge-gate.test.py`", "Tests not run") + "\n## Rollback\n`cargo test`\n" + if scenario == "validation-html-comment": + body = body.replace("`python3 scripts/merge-gate.test.py`", "Tests not run ") + if scenario == "unterminated-html-comment": + body += "\n" + if scenario == "body-comment-drift" and view_count > 0: + body += "\n" if scenario == "validation-tool-prose": body = body.replace("`python3 scripts/merge-gate.test.py`", "Tests not run; cargo is available") if scenario == "validation-placeholder-command": body = body.replace("`python3 scripts/merge-gate.test.py`", "`cargo ...`") if scenario == "checklist-heading": body = body.replace("#L10", "#L1") + if scenario == "checklist-anchor-suffix": + body = body.replace("#L10", "#L10junk") if scenario in {"implementation-p4-present", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale", "migration-rollback-present", "migration-template-wrapped", "security-notes-present", "security-none", "security-pending", "security-review-valid"}: body += ( "\n## Scope, reuse, and impact\n\n" @@ -208,7 +218,7 @@ def fail(message="controlled API failure"): body = body.replace("blob/HEAD", f"blob/{head}") if scenario == "checklist-stale-ref": body = body.replace(f"blob/{head}", "blob/" + "f" * 40) - one_file = scenario in {"files-empty", "formatted-phone", "formatted-phone-grouped", "repeated-phone", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "path-id", "binary-delete", "metadata-only", "metadata-incomplete", "hunk-header-phone", "hunk-header-literals", "hunk-binary-literal", "separated-dates", "separated-dates-new-year", "separated-dates-year-month", "adr-identifier", "all-a-pan", "masked-pan", "quoted-path", "control-path", "workflow-notes-missing", "workflow-notes-present", "workflow-delete", "workflow-delete-notes", "workflow-rename-out", "workflow-rename-out-notes", "workflow-placeholders", "renamed-previous-missing", "renamed-previous-null", "renamed-previous-false", "security-notes-missing", "security-notes-present", "security-none", "security-pending", "security-rename-out", "security-crate", "security-agent-import", "security-dsc", "home-macos", "home-unix", "home-windows", "crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path", "gitlink", "implementation-p4-missing", "implementation-p4-present", "p4-placeholders", "platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale", "migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"} or scenario.startswith("home-") or security_case or sync_case + one_file = scenario in {"files-empty", "formatted-phone", "formatted-phone-grouped", "unicode-phone", "unicode-phone-two-lines", "repeated-phone", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "path-id", "binary-delete", "metadata-only", "metadata-incomplete", "hunk-header-phone", "hunk-header-literals", "hunk-binary-literal", "separated-dates", "separated-dates-new-year", "separated-dates-year-month", "adr-identifier", "all-a-pan", "masked-pan", "quoted-path", "control-path", "workflow-notes-missing", "workflow-notes-present", "workflow-delete", "workflow-delete-notes", "workflow-rename-out", "workflow-rename-out-notes", "workflow-placeholders", "renamed-previous-missing", "renamed-previous-null", "renamed-previous-false", "security-notes-missing", "security-notes-present", "security-none", "security-pending", "security-rename-out", "security-crate", "security-agent-import", "security-dsc", "home-macos", "home-unix", "home-windows", "crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path", "gitlink", "implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "p4-placeholders", "platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale", "migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"} or scenario.startswith("home-") or security_case or sync_case selected_base = new_head if scenario == "base-oid-mismatch" else base emit({"headRefOid": selected_head, "baseRefOid": selected_base, "baseRefName": "master", "mergeable": "MERGEABLE", "mergeStateStatus": final_state, @@ -281,8 +291,14 @@ def fail(message="controlled API failure"): "phone-parenthesized": "(" + "69876" + ") 54321", } emit(f"diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +1 @@\n+synthetic {phones[scenario]}\n") + elif scenario == "unicode-phone": + emit("diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +1 @@\n+synthetic 69876\u00a054321\n") + elif scenario == "unicode-phone-two-lines": + emit("diff --git a/docs/contact.md b/docs/contact.md\n--- a/docs/contact.md\n+++ b/docs/contact.md\n@@ -0,0 +2 @@\n+69876\n+54321\n") elif scenario == "metadata-only": emit("diff --git a/docs/example.md b/docs/example.md\nsimilarity index 100%\nrename from docs/example.md\nrename to docs/example.md\n") + elif scenario == "metadata-private": + emit("diff --git a/docs/example.md b/docs/example.md\nsimilarity index 100%\nrename from docs/example.md\nrename to docs/example.md\n") elif scenario == "metadata-incomplete": emit("diff --git a/docs/example.md b/docs/example.md\nsimilarity index 100%\nrename from docs/example.md\nrename to docs/example.md\n") elif scenario == "hunk-header-phone": @@ -308,8 +324,9 @@ def fail(message="controlled API failure"): emit("diff --git a/docs/a b/example.md b/docs/a b/example.md\nsimilarity index 100%\nrename from docs/a b/example.md\nrename to docs/a b/example.md\n") elif scenario == "gitlink": emit("diff --git a/vendor/module b/vendor/module\nnew file mode 160000\nindex 0000000..2222222\n--- /dev/null\n+++ b/vendor/module\n@@ -0,0 +1 @@\n+Subproject commit 2222222\n") - elif scenario in {"implementation-p4-missing", "implementation-p4-present", "p4-placeholders"}: - emit("diff --git a/scripts/example.py b/scripts/example.py\n--- a/scripts/example.py\n+++ b/scripts/example.py\n@@ -0,0 +1 @@\n+safe text\n") + elif scenario in {"implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "p4-placeholders"}: + suffix = "sh" if scenario == "implementation-p4-shell" else "py" + emit(f"diff --git a/scripts/example.{suffix} b/scripts/example.{suffix}\n--- a/scripts/example.{suffix}\n+++ b/scripts/example.{suffix}\n@@ -0,0 +1 @@\n+safe text\n") elif scenario in {"platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale"}: emit("diff --git a/src-tauri/src/local_files/paths.rs b/src-tauri/src/local_files/paths.rs\n--- a/src-tauri/src/local_files/paths.rs\n+++ b/src-tauri/src/local_files/paths.rs\n@@ -0,0 +1 @@\n+safe text\n") elif scenario in {"migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"}: @@ -432,6 +449,8 @@ def status_page(rows, total_count, state="success", page_head=head): contexts = [context for context in contexts if context != "GitGuardian Security Checks"] if scenario == "required-context-output-bound": contexts += ["control-" + str(i) + "-" + "z" * 10000 for i in range(50)] + if scenario == "required-context-unexpected": + contexts += ["Unexpected required context"] emit({"strict": scenario != "protection-nonstrict", "contexts": contexts, "checks": []}) elif "branches/master" in joined: emit(base) @@ -469,8 +488,8 @@ def status_page(rows, total_count, state="success", page_head=head): emit([[{"filename": "docs/retired.rs", "previous_filename": "src-tauri/src/sync.rs", "status": "renamed", "additions": 0, "deletions": 0}]]) elif scenario == "files-empty": emit([[]]) - elif scenario in {"formatted-phone", "formatted-phone-grouped", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized"}: - emit([[{"filename": "docs/contact.md", "status": "added", "additions": 1, "deletions": 0}]]) + elif scenario in {"formatted-phone", "formatted-phone-grouped", "unicode-phone", "unicode-phone-two-lines", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized"}: + emit([[{"filename": "docs/contact.md", "status": "added", "additions": 2 if scenario == "unicode-phone-two-lines" else 1, "deletions": 0}]]) elif scenario in {"workflow-notes-missing", "workflow-notes-present", "workflow-placeholders"}: emit([[{"filename": ".github/workflows/ci.yml", "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario in {"workflow-delete", "workflow-delete-notes"}: @@ -499,6 +518,8 @@ def status_page(rows, total_count, state="success", page_head=head): emit([[{"filename": "docs/old.png", "status": "removed", "additions": 0, "deletions": 0}]]) elif scenario == "metadata-only": emit([[{"filename": "docs/example.md", "status": "modified", "additions": 0, "deletions": 0}]]) + elif scenario == "metadata-private": + emit([[{"filename": "/Users/tester/" + "x" * 300, "status": "modified", "additions": 0, "deletions": 0}]]) elif scenario == "metadata-incomplete": emit([[{"filename": "docs/example.md", "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario == "hunk-header-phone": @@ -518,8 +539,9 @@ def status_page(rows, total_count, state="success", page_head=head): emit([[record]]) elif scenario == "gitlink": emit([[{"filename": "vendor/module", "status": "modified", "additions": 1, "deletions": 1}]]) - elif scenario in {"implementation-p4-missing", "implementation-p4-present", "p4-placeholders"}: - emit([[{"filename": "scripts/example.py", "status": "modified", "additions": 1, "deletions": 0}]]) + elif scenario in {"implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "p4-placeholders"}: + suffix = "sh" if scenario == "implementation-p4-shell" else "py" + emit([[{"filename": f"scripts/example.{suffix}", "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario in {"platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale"}: emit([[{"filename": "src-tauri/src/local_files/paths.rs", "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario in {"migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"}: @@ -646,6 +668,9 @@ def test_empty_pending_legacy_status_does_not_block(self): def test_missing_required_context_blocks(self): self.assert_blocked("missing-required", "required check 'Rust format' was not reported") + def test_unexpected_required_context_blocks(self): + self.assert_blocked("required-context-unexpected", "undocumented required check context") + def test_cancelled_check_blocks(self): self.assert_blocked("cancel-check", "failing, cancelled, or pending") @@ -690,6 +715,15 @@ def test_head_change_is_blocked(self): def test_formatted_phone_is_scanned(self): self.assert_blocked("formatted-phone", "privacy scan found") + def test_hidden_metadata_identifier_is_still_scanned(self): + self.assert_blocked("hidden-comment-identifier", "privacy scan found") + + def test_unterminated_comment_is_indeterminate(self): + self.assert_indeterminate("unterminated-html-comment", "could not extract visible") + + def test_raw_html_comment_drift_blocks_revalidation(self): + self.assert_blocked("body-comment-drift", "description changed during preflight") + def test_repeated_digit_phone_is_scanned(self): self.assert_blocked("repeated-phone", "privacy scan found") @@ -697,9 +731,11 @@ def test_grouped_formatted_phone_is_scanned(self): self.assert_blocked("formatted-phone-grouped", "privacy scan found") def test_separated_indian_mobile_styles_are_scanned(self): - for scenario in ("phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized"): + for scenario in ("phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "unicode-phone"): with self.subTest(scenario=scenario): self.assert_blocked(scenario, "privacy scan found") + result = self.run_gate("unicode-phone-two-lines") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) def test_header_shaped_added_payload_is_still_scanned(self): self.assert_blocked("hunk-header-phone", "privacy scan found") @@ -784,6 +820,7 @@ def test_template_checklist_permalink_on_continuation_passes(self): def test_checklist_permalink_must_bind_the_full_current_head(self): self.assert_blocked("checklist-stale-ref", "same-repository line-specific") + self.assert_blocked("checklist-anchor-suffix", "same-repository line-specific") def test_foreign_checklist_link_blocks(self): self.assert_blocked("checklist-foreign", "same-repository line-specific") @@ -802,6 +839,11 @@ def test_metadata_only_zero_line_diff_can_pass(self): self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertIn("metadata-only diff section", result.stdout) + def test_metadata_only_examples_are_redacted_and_bounded(self): + result = self.run_gate("metadata-private") + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertNotIn("tester", result.stdout) + def test_metadata_only_diff_requires_rest_zero_totals(self): self.assert_indeterminate("metadata-incomplete", "metadata-only 'docs/example.md' conflicts") @@ -995,7 +1037,7 @@ def test_complete_combined_status_pages_bind_head_and_contexts(self): self.assert_indeterminate(scenario, "complete head-bound commit-status evidence") def test_validation_commands_must_be_concrete_and_in_validation(self): - for scenario in ("validation-command-outside", "validation-tool-prose", "validation-placeholder-command"): + for scenario in ("validation-command-outside", "validation-tool-prose", "validation-placeholder-command", "validation-html-comment"): with self.subTest(scenario=scenario): self.assert_blocked(scenario, "actual test or reproduction command") @@ -1030,6 +1072,7 @@ def test_sync_rename_out_requires_migration_notes(self): def test_implementation_additions_need_all_three_p4_answers(self): self.assert_blocked("implementation-p4-missing", "all three substantive P4") + self.assert_blocked("implementation-p4-shell", "all three substantive P4") self.assert_blocked("p4-placeholders", "all three substantive P4") result = self.run_gate("implementation-p4-present") self.assertEqual(result.returncode, 0, result.stdout + result.stderr) From 2151a22923c1d222e0906d4691a595c9bcb97b40 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 04:58:26 +0530 Subject: [PATCH 45/46] test: verify visible policy and retained diagnostic paths --- scripts/merge-gate.sh | 8 +++----- scripts/merge-gate.test.py | 25 ++++++++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index 55dfadcba..abaf9d41a 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -135,8 +135,7 @@ prbody="$raw_prbody" visible_body_status=0 prbody=$(python3 -c 'import re, sys text = sys.stdin.read() -if text.count(""): raise SystemExit(2) -print(re.sub(r"", "", text, flags=re.S), end="")' <<<"$prbody") || visible_body_status=$? +print(re.sub(r"|\Z)", "", text, flags=re.S), end="")' <<<"$prbody") || visible_body_status=$? if [ "$visible_body_status" -ne 0 ]; then unknown "could not extract visible PR description content" prbody="" @@ -473,7 +472,7 @@ else .commit.committer.name, .commit.committer.email, (.author.login? // null), (.committer.login? // null)] | map(select(. != null))[]' <<<"$metadata_commits") -privacy_metadata="$title + privacy_metadata="$title $raw_prbody $commit_messages" fi @@ -1258,8 +1257,7 @@ else final_visible_status=0 final_body=$(python3 -c 'import re, sys text = sys.stdin.read() -if text.count(""): raise SystemExit(2) -print(re.sub(r"", "", text, flags=re.S), end="")' <<<"$final_body") || final_visible_status=$? +print(re.sub(r"|\Z)", "", text, flags=re.S), end="")' <<<"$final_body") || final_visible_status=$? [ "$final_visible_status" -eq 0 ] || unknown "could not extract final visible PR description content" [ "$final_title" = "$title" ] || bad "PR title changed during preflight" if ! checklist_link_ok "$final_body" "$review_checklist"; then diff --git a/scripts/merge-gate.test.py b/scripts/merge-gate.test.py index 14819545a..b0f1f82da 100644 --- a/scripts/merge-gate.test.py +++ b/scripts/merge-gate.test.py @@ -135,10 +135,12 @@ def fail(message="controlled API failure"): body += "\n## Migration compatibility\n\nNo persisted format change.\n" if scenario == "validation-command-outside": body = body.replace("`python3 scripts/merge-gate.test.py`", "Tests not run") + "\n## Rollback\n`cargo test`\n" + if scenario == "policy-multiline-comment": + body = body.replace("A bounded merge preflight keeps incomplete evidence from becoming a merge.", "") if scenario == "validation-html-comment": body = body.replace("`python3 scripts/merge-gate.test.py`", "Tests not run ") if scenario == "unterminated-html-comment": - body += "\n" if scenario == "body-comment-drift" and view_count > 0: @@ -218,7 +220,7 @@ def fail(message="controlled API failure"): body = body.replace("blob/HEAD", f"blob/{head}") if scenario == "checklist-stale-ref": body = body.replace(f"blob/{head}", "blob/" + "f" * 40) - one_file = scenario in {"files-empty", "formatted-phone", "formatted-phone-grouped", "unicode-phone", "unicode-phone-two-lines", "repeated-phone", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "path-id", "binary-delete", "metadata-only", "metadata-incomplete", "hunk-header-phone", "hunk-header-literals", "hunk-binary-literal", "separated-dates", "separated-dates-new-year", "separated-dates-year-month", "adr-identifier", "all-a-pan", "masked-pan", "quoted-path", "control-path", "workflow-notes-missing", "workflow-notes-present", "workflow-delete", "workflow-delete-notes", "workflow-rename-out", "workflow-rename-out-notes", "workflow-placeholders", "renamed-previous-missing", "renamed-previous-null", "renamed-previous-false", "security-notes-missing", "security-notes-present", "security-none", "security-pending", "security-rename-out", "security-crate", "security-agent-import", "security-dsc", "home-macos", "home-unix", "home-windows", "crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path", "gitlink", "implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "p4-placeholders", "platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale", "migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"} or scenario.startswith("home-") or security_case or sync_case + one_file = scenario in {"metadata-private", "files-empty", "formatted-phone", "formatted-phone-grouped", "unicode-phone", "unicode-phone-two-lines", "repeated-phone", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "path-id", "binary-delete", "metadata-only", "metadata-incomplete", "hunk-header-phone", "hunk-header-literals", "hunk-binary-literal", "separated-dates", "separated-dates-new-year", "separated-dates-year-month", "adr-identifier", "all-a-pan", "masked-pan", "quoted-path", "control-path", "workflow-notes-missing", "workflow-notes-present", "workflow-delete", "workflow-delete-notes", "workflow-rename-out", "workflow-rename-out-notes", "workflow-placeholders", "renamed-previous-missing", "renamed-previous-null", "renamed-previous-false", "security-notes-missing", "security-notes-present", "security-none", "security-pending", "security-rename-out", "security-crate", "security-agent-import", "security-dsc", "home-macos", "home-unix", "home-windows", "crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path", "gitlink", "implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "p4-placeholders", "platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale", "migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"} or scenario.startswith("home-") or security_case or sync_case selected_base = new_head if scenario == "base-oid-mismatch" else base emit({"headRefOid": selected_head, "baseRefOid": selected_base, "baseRefName": "master", "mergeable": "MERGEABLE", "mergeStateStatus": final_state, @@ -298,7 +300,8 @@ def fail(message="controlled API failure"): elif scenario == "metadata-only": emit("diff --git a/docs/example.md b/docs/example.md\nsimilarity index 100%\nrename from docs/example.md\nrename to docs/example.md\n") elif scenario == "metadata-private": - emit("diff --git a/docs/example.md b/docs/example.md\nsimilarity index 100%\nrename from docs/example.md\nrename to docs/example.md\n") + private_path = "docs/" + "/" + "Users" + "/tester/" + "x" * 300 + emit(f"diff --git a/{private_path} b/{private_path}\nsimilarity index 100%\nrename from {private_path}\nrename to {private_path}\n") elif scenario == "metadata-incomplete": emit("diff --git a/docs/example.md b/docs/example.md\nsimilarity index 100%\nrename from docs/example.md\nrename to docs/example.md\n") elif scenario == "hunk-header-phone": @@ -519,7 +522,7 @@ def status_page(rows, total_count, state="success", page_head=head): elif scenario == "metadata-only": emit([[{"filename": "docs/example.md", "status": "modified", "additions": 0, "deletions": 0}]]) elif scenario == "metadata-private": - emit([[{"filename": "/Users/tester/" + "x" * 300, "status": "modified", "additions": 0, "deletions": 0}]]) + emit([[{"filename": "docs/" + "/" + "Users" + "/tester/" + "x" * 300, "status": "modified", "additions": 0, "deletions": 0}]]) elif scenario == "metadata-incomplete": emit([[{"filename": "docs/example.md", "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario == "hunk-header-phone": @@ -718,8 +721,11 @@ def test_formatted_phone_is_scanned(self): def test_hidden_metadata_identifier_is_still_scanned(self): self.assert_blocked("hidden-comment-identifier", "privacy scan found") - def test_unterminated_comment_is_indeterminate(self): - self.assert_indeterminate("unterminated-html-comment", "could not extract visible") + def test_unterminated_comment_cannot_supply_validation(self): + self.assert_blocked("unterminated-html-comment", "actual test or reproduction command") + + def test_multiline_comment_cannot_supply_functional_summary(self): + self.assert_blocked("policy-multiline-comment", "non-empty functional summary") def test_raw_html_comment_drift_blocks_revalidation(self): self.assert_blocked("body-comment-drift", "description changed during preflight") @@ -841,8 +847,13 @@ def test_metadata_only_zero_line_diff_can_pass(self): def test_metadata_only_examples_are_redacted_and_bounded(self): result = self.run_gate("metadata-private") - self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("metadata-only diff section", result.stdout) + self.assertNotIn("privacy diff coverage failed", result.stdout) self.assertNotIn("tester", result.stdout) + examples = next(line for line in result.stdout.splitlines() if "metadata-only diff section" in line) + self.assertLess(len(examples), 260) + self.assertNotIn("x" * 161, examples) def test_metadata_only_diff_requires_rest_zero_totals(self): self.assert_indeterminate("metadata-incomplete", "metadata-only 'docs/example.md' conflicts") From 7ea1235396bb1ddf2cab0e9f5cdda079c65a2f17 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 05:19:22 +0530 Subject: [PATCH 46/46] fix: require implementation rationale for PowerShell and SQL --- scripts/merge-gate.sh | 2 +- scripts/merge-gate.test.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/scripts/merge-gate.sh b/scripts/merge-gate.sh index abaf9d41a..4283d904f 100755 --- a/scripts/merge-gate.sh +++ b/scripts/merge-gate.sh @@ -860,7 +860,7 @@ migration_change=false if [ "$files_status" -eq 0 ]; then implementation_code_added=$(jq -r ' (if all(.[]; type == "array") then flatten else . end) | - any(.[]; (.additions > 0) and (.filename | test("\\.(rs|ts|tsx|js|mjs|py|go|java|kt|swift|c|cc|cpp|h|hpp|sh|bash)$"))) + any(.[]; (.additions > 0) and (.filename | test("\\.(rs|ts|tsx|js|mjs|py|go|java|kt|swift|c|cc|cpp|h|hpp|sh|bash|ps1|psm1|sql)$"))) ' <<<"$files") platform_sensitive_change=$(jq -r ' (if all(.[]; type == "array") then flatten else . end) | diff --git a/scripts/merge-gate.test.py b/scripts/merge-gate.test.py index b0f1f82da..094f9d4da 100644 --- a/scripts/merge-gate.test.py +++ b/scripts/merge-gate.test.py @@ -220,7 +220,7 @@ def fail(message="controlled API failure"): body = body.replace("blob/HEAD", f"blob/{head}") if scenario == "checklist-stale-ref": body = body.replace(f"blob/{head}", "blob/" + "f" * 40) - one_file = scenario in {"metadata-private", "files-empty", "formatted-phone", "formatted-phone-grouped", "unicode-phone", "unicode-phone-two-lines", "repeated-phone", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "path-id", "binary-delete", "metadata-only", "metadata-incomplete", "hunk-header-phone", "hunk-header-literals", "hunk-binary-literal", "separated-dates", "separated-dates-new-year", "separated-dates-year-month", "adr-identifier", "all-a-pan", "masked-pan", "quoted-path", "control-path", "workflow-notes-missing", "workflow-notes-present", "workflow-delete", "workflow-delete-notes", "workflow-rename-out", "workflow-rename-out-notes", "workflow-placeholders", "renamed-previous-missing", "renamed-previous-null", "renamed-previous-false", "security-notes-missing", "security-notes-present", "security-none", "security-pending", "security-rename-out", "security-crate", "security-agent-import", "security-dsc", "home-macos", "home-unix", "home-windows", "crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path", "gitlink", "implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "p4-placeholders", "platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale", "migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"} or scenario.startswith("home-") or security_case or sync_case + one_file = scenario in {"metadata-private", "files-empty", "formatted-phone", "formatted-phone-grouped", "unicode-phone", "unicode-phone-two-lines", "repeated-phone", "grouped-identifier-12", "grouped-identifier-16", "phone-space", "phone-dot", "phone-plus", "phone-underscore", "phone-parenthesized", "path-id", "binary-delete", "metadata-only", "metadata-incomplete", "hunk-header-phone", "hunk-header-literals", "hunk-binary-literal", "separated-dates", "separated-dates-new-year", "separated-dates-year-month", "adr-identifier", "all-a-pan", "masked-pan", "quoted-path", "control-path", "workflow-notes-missing", "workflow-notes-present", "workflow-delete", "workflow-delete-notes", "workflow-rename-out", "workflow-rename-out-notes", "workflow-placeholders", "renamed-previous-missing", "renamed-previous-null", "renamed-previous-false", "security-notes-missing", "security-notes-present", "security-none", "security-pending", "security-rename-out", "security-crate", "security-agent-import", "security-dsc", "home-macos", "home-unix", "home-windows", "crlf-diff", "ambiguous-unquoted-path", "ambiguous-rename-path", "gitlink", "implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "implementation-p4-powershell", "implementation-p4-sql", "p4-placeholders", "platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale", "migration-rollback-missing", "migration-rollback-present", "migration-template-wrapped", "migration-template-other-field"} or scenario.startswith("home-") or security_case or sync_case selected_base = new_head if scenario == "base-oid-mismatch" else base emit({"headRefOid": selected_head, "baseRefOid": selected_base, "baseRefName": "master", "mergeable": "MERGEABLE", "mergeStateStatus": final_state, @@ -327,8 +327,8 @@ def fail(message="controlled API failure"): emit("diff --git a/docs/a b/example.md b/docs/a b/example.md\nsimilarity index 100%\nrename from docs/a b/example.md\nrename to docs/a b/example.md\n") elif scenario == "gitlink": emit("diff --git a/vendor/module b/vendor/module\nnew file mode 160000\nindex 0000000..2222222\n--- /dev/null\n+++ b/vendor/module\n@@ -0,0 +1 @@\n+Subproject commit 2222222\n") - elif scenario in {"implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "p4-placeholders"}: - suffix = "sh" if scenario == "implementation-p4-shell" else "py" + elif scenario in {"implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "implementation-p4-powershell", "implementation-p4-sql", "p4-placeholders"}: + suffix = {"implementation-p4-shell": "sh", "implementation-p4-powershell": "ps1", "implementation-p4-sql": "sql"}.get(scenario, "py") emit(f"diff --git a/scripts/example.{suffix} b/scripts/example.{suffix}\n--- a/scripts/example.{suffix}\n+++ b/scripts/example.{suffix}\n@@ -0,0 +1 @@\n+safe text\n") elif scenario in {"platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale"}: emit("diff --git a/src-tauri/src/local_files/paths.rs b/src-tauri/src/local_files/paths.rs\n--- a/src-tauri/src/local_files/paths.rs\n+++ b/src-tauri/src/local_files/paths.rs\n@@ -0,0 +1 @@\n+safe text\n") @@ -542,8 +542,8 @@ def status_page(rows, total_count, state="success", page_head=head): emit([[record]]) elif scenario == "gitlink": emit([[{"filename": "vendor/module", "status": "modified", "additions": 1, "deletions": 1}]]) - elif scenario in {"implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "p4-placeholders"}: - suffix = "sh" if scenario == "implementation-p4-shell" else "py" + elif scenario in {"implementation-p4-missing", "implementation-p4-present", "implementation-p4-shell", "implementation-p4-powershell", "implementation-p4-sql", "p4-placeholders"}: + suffix = {"implementation-p4-shell": "sh", "implementation-p4-powershell": "ps1", "implementation-p4-sql": "sql"}.get(scenario, "py") emit([[{"filename": f"scripts/example.{suffix}", "status": "modified", "additions": 1, "deletions": 0}]]) elif scenario in {"platform-evidence-missing", "platform-evidence-present", "platform-checkbox-evidence", "platform-checkbox-comment", "platform-unaffected-bare", "platform-unaffected-rationale"}: emit([[{"filename": "src-tauri/src/local_files/paths.rs", "status": "modified", "additions": 1, "deletions": 0}]]) @@ -1084,6 +1084,8 @@ def test_sync_rename_out_requires_migration_notes(self): def test_implementation_additions_need_all_three_p4_answers(self): self.assert_blocked("implementation-p4-missing", "all three substantive P4") self.assert_blocked("implementation-p4-shell", "all three substantive P4") + self.assert_blocked("implementation-p4-powershell", "all three substantive P4") + self.assert_blocked("implementation-p4-sql", "all three substantive P4") self.assert_blocked("p4-placeholders", "all three substantive P4") result = self.run_gate("implementation-p4-present") self.assertEqual(result.returncode, 0, result.stdout + result.stderr)