Repository navigation
feat(github-evidence-guard): a PR merge waits for an announced re-read (shadow by default) - #6304
john-the-dev wants to merge 2 commits into
Conversation
…d (shadow by default) The core registers github-evidence-guard.py a second time, before Bash, with --merge-gate. For a merge command (gh pr merge, the REST merge endpoint, or a mergePullRequest / enqueuePullRequest GraphQL call) it reads one GraphQL snapshot and finds re-read announcements since the head commit from reviewers with standing that have not been answered. A hold expires after env.MERGE_HOLD_TTL_HOURS (default 4). A hold said in a chat room is recorded with hooks/_merge_hold.py. env.MERGE_HOLD_MODE=shadow (the default) logs every decision to <workspace>/logs/merge-hold-shadow.jsonl and never denies. enforce denies on HOLD and UNKNOWN unless a logged SUTANDO_MERGE_HOLD_OVERRIDE=<reason> leads the command. Every other Bash command returns on the existing string match. Sutando-Task: 20261107-024 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r paths Brings the changed Python lines to the 95% diff-coverage bar, and keeps the mode test from resolving the live workspace. Sutando-Task: 20261107-024 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sonichi-dot
left a comment
There was a problem hiding this comment.
Reviewed exact head 58b2de635811dd9e85547476a4ea126f218ee6c4.
Recommendation: not ready to merge. Five reproduced correctness gaps below can release or bypass an active hold. Shadow mode mitigates immediate enforcement disruption, but records a false CLEAR or omits these merge attempts entirely, so the trial would not expose all of them. Please fix the cases and retain the planned live-core trial before enabling enforcement.
Validation: policy 45/45, gate 31/31, settings builder 25/25; adjacent evidence/outcome/stale-head suites 25/25, 13/13, 31/31; shell-trap 52 passed, 1 skipped. Python suites ran under the hermetic workspace guard; review-checks.sh passed. Additional synthetic probes exercised the failures with deny/HOLD controls, without performing merges. No live-core restart or 12-hour trial was run. At posting, CI remained in progress and CLA was successful. The CLI preflight could not query GitHub here; current state, reviews and comments were checked through the connector instead.
| def resolves(e: dict) -> bool: | ||
| if e["kind"] == "review": | ||
| return True | ||
| return not announces(e["body"]) and bool(RESOLVE.search(e["body"])) |
There was a problem hiding this comment.
[P2] Keep holds when a follow-up explicitly says the review is unfinished. With the existing policy fixture, Alice's re-reading comment yields HOLD, but a newer I'm not done yet or I haven't approved this yet changes it to CLEAR. RESOLVE.search matches the negated word. Quoted > LGTM also releases the hold because only announcement detection strips quotes. Require an affirmative, unquoted release and add regression cases for these unfinished-status updates.
| name = w.split("=", 1)[0] if w.startswith("--") else w | ||
| if name in API_VALUE_FLAGS: | ||
| val, i = _flag_value(tail, i) |
There was a problem hiding this comment.
[P2] Parse attached short-option values before classifying API requests. gh api repos/o/r/pulls/7/merge -XPUT is valid gh syntax, but -XPUT is not recognized here, leaving method=None; the REST branch consequently returns no targets. With an enforced HOLD stub, -X PUT denies after one check while -XPUT allows with zero checks. Normalize attached values for the supported API flags instead of silently treating a merge as read-only.
| pending.pop(0) | ||
| continue | ||
| out.append(line) | ||
| pending = [(m[1] == "-", m[3]) for m in HEREDOC.finditer(line)] |
There was a problem hiding this comment.
[P2] Recognize heredoc operators outside quoted data only. For echo '<<EOF' followed by a newline and gh pr merge 7 --repo o/r, this regex incorrectly starts a heredoc inside the quoted echo argument and drops the actual merge line. Bash executes the merge, but merge_targets() returns an empty list and enforce mode allows it without checking. Reuse shell quote state when identifying heredoc declarations and test quoted/comment occurrences followed by real commands.
| if not isinstance(command, str) or not re.search(r"\bgh\s+(?:(?:pr|run)\s+(?:view|checks|merge)|api)\b", command): | ||
| return {} | ||
| if "--merge-gate" in argv: | ||
| return merge_gate(data, command, list(argv)) |
There was a problem hiding this comment.
[P2] Do not discard valid merge spellings before the shell scanner runs. The new gate sits behind the old raw regex: "gh" pr merge 7 --repo o/r, or a backslash-newline between pr and merge, returns {} here even though merge_targets() correctly identifies the same merge. Bash confirms the expected argv, and an enforced HOLD probe makes zero checks for either spelling. Make the fast path conservative or normalize shell syntax before returning.
| if depth < 3 and (head == "eval" or (head in SHELLS and "-c" in texts[1:])): | ||
| if head == "eval": | ||
| inner = " ".join(texts[1:]) | ||
| else: | ||
| rest = texts[texts.index("-c") + 1:] | ||
| inner = rest[0] if rest else "" |
There was a problem hiding this comment.
[P2] Recognize combined shell options when unwrapping command strings. bash -lc 'gh pr merge 7 --repo o/r' executes the merge normally, but this condition only recognizes a separate -c token, so the wrapper is discarded as a non-gh command. The enforced HOLD probe allows it with zero checks, unlike bash -c. Parse the shell's combined options (including common -lc/-ec forms) before locating its command argument.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 93%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
Draft until a 12-hour shadow trial reports. This PR ships the gate in
shadowmode, which logs every merge decision and never denies. It stays a draft until a 12h trial on a live core has been summarised (python3 hooks/_merge_hold.py --summary) and the counts and latencies are posted here.What changed, and why
A PR was merged during an announced re-read (#6300 → #6301). The re-read then found two defects, which needed the follow-up.
hooks/github-evidence-guard.pyalready matchesgh pr merge, but only adds advice after the command has run. It never denies.This PR extends that hook instead of adding a new one. The core now registers the same file a second time, before Bash, with
--merge-gate --repo <checkout>. That registration runs only the merge gate. Every existing registration and behaviour of the hook is unchanged.Read first:
hooks/_merge_hold.py: the policy, a helper beside the hook in the_shell_scan.pystyle. It covers the merge-command parser, the single GraphQL read, holds, expiry, standing, mode, the shadow log, the override and the room-hold CLI.hooks/github-evidence-guard.py:merge_gate()and the--merge-gatebranch indecide(), about 20 lines.src/agent/claude/cli/build-core-settings.mjs(arg 9) andsession-launch.sh: the registration.There is no
src/module, because the hook is the only consumer of this policy.Behaviour
Merge commands it reads:
gh pr merge <n|url> [--repo|-R o/r], except--disable-auto.gh api -X PUT repos/o/r/pulls/<n>/merge.gh api graphqlcarryingmergePullRequestorenqueuePullRequest. The body can be inline,-F query=@fileor--input file. ThepullRequestIdcan be a literal or a-f/-Fvariable, and the node id is resolved in the same query.It also reads
bash -c,sh -candevalwrappers. Heredoc bodies are data, so a commit message that mentionsgh pr mergeis not gated.The check. One
gh api graphqlcall per merge, with a 5 s budget. It returns the head commit date, author, review requests, and the last 100 reviews and issue comments.HOLD. A comment or review body posted since the head commit says one of: re-read, re-reading, reading again, will re-review, taking another look, hold merge, do not merge, please wait. It must come from a login with standing.
>) lines, and a re-read named as done ("after re-reading it", "I re-read the diff"). The last case was a false positive found on a live PR while building this.Room holds. A hold said in a chat room is recorded with
python3 hooks/_merge_hold.py OWNER/REPO PR --add-hold --who LOGIN --note … [--url …] [--expires-at …]and cleared with--clear-hold. These live in<workspace>/state/merge-holds.json, with the path resolved throughworkspace_default.UNKNOWN. Any of these: a failed, timed-out, malformed or truncated read (more than 100 comments or reviews since the head commit), an unreadable hold file, a bad TTL, or a merge whose PR it cannot name (no
--repo, a$VARid, an unreadable body). It is never CLEAR.Modes.
env.MERGE_HOLD_MODEis set insutando.config.local.json, throughconfig_get, the repo's convention for non-secret config.shadowis the default. It never denies.enforcedenies on HOLD and UNKNOWN. A HOLD denial names the reviewer, the time, the URL and the expiry.Logging. Both modes append one line per merge target to
<workspace>/logs/merge-hold-shadow.jsonl. Each line hasts,repo,pr,verdict(HOLD / CLEAR / EXPIRED / UNKNOWN),holder,elapsed_ms,kind(pr-merge,rest-merge,graphql-enqueuePullRequest, …),modeandoverride.--summaryprints counts by verdict and p50/p95elapsed_ms.Can a hold block merges forever?
No. There are four ways out, and none of them depends on the announcer coming back.
env.MERGE_HOLD_TTL_HOURS(default 4) from the announcement. After that it passes, with the advisory lineEXPIRED hold by <login> announced <t> (<age>); re-ask them <url>. That line is never a denial. Room holds expire the same way, or at their ownexpires_at.SUTANDO_MERGE_HOLD_OVERRIDE='<reason>' gh pr merge …passes a live HOLD or an UNKNOWN in enforce mode. It works like the repo's existing inline-word overrides, for exampleSUTANDO_ALLOW_STALE_POST=1. The reason must be at least 8 characters. It is appended to<workspace>/state/guards/merge_hold_overrides.jsonl, next to the other guard logs understate/guards/, before the merge is allowed. If that write fails, the merge is denied.An agent that says it will re-read and never does holds the PR for at most the TTL, and the next merge attempt tells the merger to re-ask them.
UNKNOWN is a separate case and still denies in enforce mode: a GitHub outage is rare, and the override covers it.
Cost
Only merge commands pay anything. The values below come from
tests/github-evidence-guard-merge-gate.test.py(Cost), measured on a laptop.ghgh api graphql)A non-merge command returns on the hook's existing
gh (pr|run) (view|checks|merge)|gh apistring match. A merge past the 5 s budget is UNKNOWN.The hook's PostToolUse evidence fetch (
github-decision.py collect) runs after the command and makes several REST reads, so it is not reused before the tool.What user behaviour or bug does this prove?
Live, read-only, against this repo:
Tests
python3 tests/merge-hold.test.py: 45 tests, OK (also under/usr/bin/python33.9.6).sutando.config, with a bad value → UNKNOWN.expires_at.python3 tests/github-evidence-guard-merge-gate.test.py: 31 tests, OK.gh pr merge→ deny, naming the reviewer, URL and expiry. Clear → allow.--merge-gate._merge_hold.gate. Subprocess end to end in both modes, against a hermetic fake checkout. Registration.node --test tests/agent/claude/cli/build-core-settings.test.ts: 25/25.tests/pr-decision-evidence.test.py(25) andtests/pr-outcome-evidence.test.py(13), the existing evidence-guard suites: OK.tests/stale-head-post-guard.test.py,tests/shell-trap-guard.test.pyand the launcher suites that sourcesession-launch.sh: OK.scripts/hermetic-workspace-guard.py run.bash scripts/review-checks.sh --diff: PASS.lint-workspace-resolution.sh --diff: clean.gen-src-map.py --check: up to date.ruff==0.15.22: clean.docs_audit.py: PASS.diff-coveragainstorigin/main): 99%. The one missed line issys.exit(main()).Feature/documentation consistency
hooks/README.mdhas a new section.docs/github-verification.md, the canonical doc for GitHub evidence, has a new paragraph, and itslast_verifiedindocs/catalog.jsonis bumped.Known limits
xargsor another tool is not seen.--add-hold, which is what fix(core-limit-hold): the watchdog reads the launcher's seat, and the card lasts the whole hold #6300 needed.--add-holdwriters can lose an update. Writes are atomic, but there is no lock.Trial plan
Run a live core on this branch's hook in
shadowmode for 12 hours, then post the--summaryoutput here: counts by verdict, and p50/p95. The plan is to switch toenforceonly if HOLD and UNKNOWN look right on real merges.Sutando-Task: 20261107-024
🤖 Generated with Claude Code