Repository navigation
Description
Problem
The Claude Code guard hooks that keep pull request, merge, watch and fix workflows behind their single entry points are Bash scripts, one per skill, each re-implementing the same command-position regex and jq payload read. The project direction in #588 freezes complex shell and moves its behavior into tested Go, and guard-markdown already shows the target shape: the rule in an internal/ package, payload decoding from internal/hookio, and a thin cmd/ binary.
The guard scripts total roughly 1,300 shell lines across 11 files plus the 242-line tools/test-guards.sh. That does not fit one pull request within the size limit, so this issue is the first slice: the four gh-family guards (pr, merge-pr, watch-pr, fix-pr, about 300 shell lines), which also back agent-pr and agent-merge-pr. These four share one gh pr read-only allowlist, and the pr and merge-pr guards must keep agreeing wherever both are live, which is the strongest reason to port them together. The remaining guards are proposed as follow-up sub-issues (see Scope and non-goals).
Acceptance criteria
- AC1: Given a Bash tool call whose first command-position
gh pr <verb>has the verblist,view,diff,statusorchecks, when theprormerge-prguard judges it, then the call is allowed with no output and exit status 0. - AC2: Given a Bash tool call whose first command-position
gh pr <verb>has any other verb (for examplecreate,edit,ready,merge,close,comment, or a verb that does not exist today), when theprguard judges it, then the call is denied with status 2 and the reason names the pull request publishing entry point. A latergh prmention is not judged once the first one is a read verb, as today. - AC3: Given the same command list, when the
merge-prguard judges it, then every command receives the same allow or deny verdict as under theprguard, and a denial names the squash merge entry point (squash-merge.sh) instead of the publishing one. - AC4: Given a command line, when any of the four guards judges it, then a
gh,git pushorgh runinvocation counts as a call only at a command position: at the start of the text, or after optional whitespace following;,&,|,(or a newline. Quoting and heredocs are not interpreted, as today. Sogh pr mergethat follows other words on a line (for exampleecho "gh pr merge 1") is not a call and is allowed, while the same text at the start of a line inside a quoted string or a heredoc body, or after one of those separators inside a quote, is treated as a call and receives the verdict a real call would. Flag checks (--watch,--fail-fast,--job,--force,-f,--force-with-lease,--log,--log-failed) look at the whole command text, including quoted and heredoc text. - AC5: Given
gh pr checkswith--watchor--fail-fast, orgh run watch, when thewatch-prguard judges it, then the call is denied with a reason pointing at the skill's watcher; and given plaingh pr checks, then it is allowed. Thefix-prguard denies thegh pr checks --watchand--fail-fastforms the same way. - AC6: Given
gh run viewwith--logor--log-failedand no--job, when thefix-prguard judges it, then the call is denied with a reason pointing at the skill's diagnosis; and given the same command with--job, then it is allowed. - AC7: Given
git pushwith--forceor-fand no--force-with-lease, when thefix-prguard judges it, then the call is denied with a reason recommending the lease form; and given--force-with-lease, then it is allowed. - AC8: Given a payload with no command (an absent or empty
tool_input.command), when any of the four guards judges it, then the call is allowed with no output and status 0. Given an undecodable payload, the binary exits 0 with no output (the exception in Behavior preservation). Given a payload whosetool_nameis notBashbut whosetool_input.commandmatches a denial case, the guard denies it exactly as for Bash, as the shells do. - AC9: Given any denial, when the guard answers, then it writes
Blocked by the <skill> skill guard., a blank line and a non-empty reason to standard error, writes nothing to standard output, and exits 2, byte for byte as the shell guards do. - AC10: Non-functional: each verdict is a pure function of the payload text, and the same input gives the same verdict every time. A Go test parses the non-test source files of the rule package with
go/parserand fails when any file imports a package outside an explicit allowlist (fmt,regexp,stringsand the package's own siblings).os,os/exec,net,net/http,io/ioutilandsyscallare therefore absent from the rule path. Payload decoding and output stay incmd/andinternal/hookio, outside that package. - AC11: Given the repository test matrix (Linux, Linux arm, macOS, Windows), when the Go tests run, then every allow and deny case above passes on all four platforms.
Scope and non-goals
In scope: the guards behind guard-gh.sh (pr), guard-merge.sh (merge-pr), guard-watch.sh (watch-pr) and guard-fix.sh (fix-pr), as a new standalone guard binary following guard-markdown, with their rule in a new internal/ package, hook payload support for the Bash command added to internal/hookio, and Go table tests that port the cases the shell scripts encode, including the shared gh pr allowlist.
Non-goal: editing, deleting or rewiring any frozen shell file. The existing guard-*.sh scripts stay untouched and stay what skill frontmatter calls until the follow-up that flips the references.
Non-goal: switching skill frontmatter to the binary, regenerating skill output, and the consumer distribution work (tool pins, release). Per #588 the binaries must be available before any deployment requires them, so the flip belongs after a release carries the binary.
Out of scope: new shell of any kind, and any change to what the guards allow or deny, including the first-match behavior and the missing tool_name check described above. Tightening either belongs in a separate issue with its own decision.
Behavior preservation: This is a behavior-preserving port. The frozen scripts under packages/agents/source/skills/*/claude/scripts/ (guard-gh.sh, guard-merge.sh, guard-watch.sh, guard-fix.sh) define the expected verdict for every input, and every acceptance criterion below states what they do today, including the cases that look like gaps:
- Each guard reads
.tool_input.commandand never checkstool_name, so any payload carrying a matching command is judged, whatever the tool. - The
prandmerge-prguards find the first command-positiongh pr <verb>and judge only that verb.gh pr view 1; gh pr merge 1is allowed today and stays allowed. The port pins this with a test rather than changing it. - A denial writes
Blocked by the <skill> skill guard.and the reason to standard error and exits 2. The Go binary uses the same protocol, so the later frontmatter flip changes only the command name.
One deliberate exception, and it neither loosens nor tightens a verdict: an empty, truncated or otherwise undecodable payload makes the shells fail on the unprotected jq call under set -e, which Claude Code treats as a non-blocking error, so the call runs. The Go binary exits 0 with no output for the same input, following the guard-markdown convention. The call runs in both cases.
Proposed follow-up sub-issues of #588 for the rest of the original list (sizes are estimates):
- Commit guard (
guard-git.sh, 186 lines, also backingagent-commit): whole-tree staging and hand-writtengit commitrefusals tied to the recordedHEAD. Size S. - Rebase and resolve-rebase-conflicts guards (
guard-rebase.sh194,guard-resolve.sh161). Size M. - fix-prose guards (
guard-target.sh130,arm-guard.sh74,release-once.sh73), with thetools/test-guards.shcases moved into Go tests and that script deleted or reduced to a thin wrapper. Size M. - Release guard (
guard-release.sh, 190 lines). It is not in the original list, but it is also a guard script, so it belongs with the set. Size S. - Distribution and rollout: ship the guard binaries through the same path as
agentrepoandguard-markdown, flip every skill's frontmatter to the binary by name, regenerate skill output, and remove the replaced guard scripts. Size M.
Approach
Add one guard binary that takes the target skill as a command-line argument and reads the hook payload from standard input, so agenthooks remains a dispatcher as AGENTS.md describes. Put the shared gh pr allowlist, the command-position matching and each skill's deny text in one internal/ package, with the binary as glue only because test coverage excludes main.go. Extend hookio so a decoded payload exposes the Bash command, and add a stderr-and-exit-2 block helper beside the existing hookio.Deny, which guard-markdown keeps using unchanged. Port the shell scripts' cases into table tests first, then write the rule until they pass. Reuse the existing internal/hookio decoding and fail-open convention from cmd/guard-markdown.
Dependencies
Native blockers: none. #753 (open) gates rollout of the replacement guard binaries (protection of the Go enforcement paths, per #588), not building or merging this PR, so it stays a prose prerequisite and not a native blocked-by link.
Prose prerequisites (each gates rollout only and does not block building or merging this PR):
- Replace complex shell with tested Go, starting with gates and merging #588 (open): the Go port epic and its direction; this issue is one of its sub-issues.
- Move reusable agent tooling into released Go binaries #174 (open): the decisions on command names and where the reusable agent tooling ships. They settle how the binary is distributed and named at rollout, and this PR builds and merges with the working name until then.
- Ship the agent-commit, agent-pr and agent-merge-pr skills in v0.13 #241 (closed): shipped
agent-commit,agent-prandagent-merge-pr, whose guards this port covers.
Size
S. One pull request: an estimated 250 lines of Go code plus 350 lines of tests, under the 800 code line limit, and a handful of prose lines. The full guard set does not fit one pull request, which is why the remainder is split into the five proposed sub-issues above.
Verify plan
- AC1: table test of the read-only verbs under the
prandmerge-prguards, run bymise run test-go; each case asserts an allow verdict, empty standard output, empty standard error and exit status 0. - AC2: table test of mutating and unknown verbs under the
prguard, asserting status 2 and the publishing entry point in the reason, plus a case pinninggh pr view 1; gh pr merge 1as allowed. - AC3: parity test running one shared command list through both guards and asserting identical verdicts, plus an assertion that every
merge-prdenial containssquash-merge.shand noprdenial does. - AC4: table test whose expected verdicts are taken from running the frozen shell guards on the same inputs: a mid-line quoted mention (allowed), a heredoc body line starting with
gh pr merge(denied), a quoted mention after;(denied), separator-prefixed commands and newline-separated ones, and flags appearing only in quoted text. - AC5: table test of the
watch-prguard and of the watching forms under thefix-prguard. - AC6: table test of
gh run viewwith and without--job. - AC7: table test of
git pushwith--force,-fand--force-with-lease. - AC8: table test of command-less, empty, truncated and non-Bash payloads for every guard, asserting the allow or deny verdicts above, empty output for allows and exit status 0 for undecodable input.
- AC9: test asserting, for every denial case, the exact standard error bytes from the shell guard on the same input, empty standard output and exit status 2.
- AC10: the import-allowlist test described in the criterion, plus a repeat-run determinism check over the AC1 to AC7 inputs.
- AC11: the
test (ubuntu-24.04),test (ubuntu-24.04-arm),test (macos-15)andtest (windows-2025)CI jobs.
Risks
- The Go matching could differ from the shell regex on an edge case, either letting a mutating
gh prverb through or blocking a legitimate read. The undo is a revert, since nothing calls the binary until the later rollout. The shell scripts' own decisions, run on the same inputs, supply the expected values, so drift shows before rollout.tools/test-guards.shcovers the fix-prose guards in a later slice and does not validate these four. - Extending
hookio.Payloadcould change decoding forguard-markdown. The existinghookioandguard-markdowntests must pass unchanged, and the change is additive. - Adding the binary before distribution exists leaves it unused for a while. That is intended, per Replace complex shell with tested Go, starting with gates and merging #588, and the undo is leaving it unwired.
Operator authority
None
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Work type
Projects
- StatusShow more project fieldsBacklog
Activity
tbhb-agent commentedon Oct 31, 2026
Refinement: NOT READY — link the golden protection blocker natively
Checked
originidentifiestbhb-dev/devtools.refinement:active, and has no earlier refinement comments; this is round 1.origin/main: the sharedgh prallowlist and command-position matching are inpackages/agents/source/skills/pr/claude/scripts/guard-gh.sh:52-63andpackages/agents/source/skills/merge-pr/claude/scripts/guard-merge.sh:47-56. Watch, log, and force-push rules are inpackages/agents/source/skills/watch-pr/claude/scripts/guard-watch.sh:42-59andpackages/agents/source/skills/fix-pr/claude/scripts/guard-fix.sh:42-75..github/workflows/ci.yml:162-180. Thehookiopackage already supplies payload decoding and structured deny output ininternal/hookio/hookio.go.Gaps
Split
The four
gh-family guards form a coherent slice: they share the allowlist and include the watch/fix rules. The stated estimate fits one PR. No further split is indicated.Definition of done
Planning coverage only; these boxes indicate whether the acceptance criteria and Verify plan cover each item, not whether implementation work is complete.
internal/,hookio, a thin binary, and Go table tests, consistent with Replace complex shell with tested Go, starting with gates and merging #588.Round: 1 of 3
tbhb-agent commentedon Oct 31, 2026
Refinement: NOT READY — align AC4 with the existing guard behavior
Checked
originidentifiestbhb-dev/devtools.refinement:active, and has one earlier “Refinement: NOT READY” comment; this is round 2.origin/mainwith AC1–AC10. Their rules use command-position regular expressions; they do not parse shell quoting or heredocs.Gaps
origin/main, or establish that changing this behavior is intended.Split
The four guards form a coherent slice: the
gh prrules share an allowlist, and watch/fix rules are included. The stated estimate fits one PR. No further split is indicated.Definition of done
Planning coverage only: boxes indicate whether the acceptance criteria and Verify plan cover each item, not whether implementation work is complete.
internal/,hookio, a thin binary, and Go table tests, consistent with Replace complex shell with tested Go, starting with gates and merging #588.Round: 2 of 3
tbhb-agent commentedon Oct 31, 2026
Refinement: NOT READY — resolve behavior preservation and verification gaps
Checked
originidentifiestbhb-dev/devtools. Read the live body, labels and both comments once. The issue is open withrefinement:active; two earlier NOT READY comments make this round 3.packages/agents/source/skills/pr/claude/scripts/guard-gh.sh:54andpackages/agents/source/skills/merge-pr/claude/scripts/guard-merge.sh:49.packages/agents/source/skills/pr/claude/scripts/guard-gh.sh:62andpackages/agents/source/skills/merge-pr/claude/scripts/guard-merge.sh:55. Its verification omits the distinct reason.packages/agents/source/skills/pr/claude/scripts/guard-gh.sh:52,packages/agents/source/skills/merge-pr/claude/scripts/guard-merge.sh:47,packages/agents/source/skills/watch-pr/claude/scripts/guard-watch.sh:40, andpackages/agents/source/skills/fix-pr/claude/scripts/guard-fix.sh:40. The previous quoting blocker is resolved.packages/agents/source/skills/watch-pr/claude/scripts/guard-watch.sh:42andpackages/agents/source/skills/fix-pr/claude/scripts/guard-fix.sh:73.--jobexception inpackages/agents/source/skills/fix-pr/claude/scripts/guard-fix.sh:42.packages/agents/source/skills/fix-pr/claude/scripts/guard-fix.sh:61.internal/hookio/hookio.go:109, but differs from all four shell guards’ stderr/exit-2 protocol.catandjq. Its stated lint verification does not enforce the claimed restriction..github/workflows/ci.yml:175; this is planned coverage, not executed validation.Gaps
Blocker — AC2 and behavior preservation conflict for compound commands. Both PR guards inspect only the first matching
gh prverb and immediately allow a read verb. Consequently,gh pr view 1; gh pr merge 1is allowed today, although AC2 requires denial of the later command-position mutation. Preserve and test first-match behavior, or explicitly authorize this behavior change.Blocker — AC8 conflicts with the unconditional behavior-preserving scope. Every shell guard runs an unprotected
jqassignment underset -e, so malformed JSON fails rather than silently succeeding. None checkstool_name, so a non-Bash payload containing a matching command can be denied. Explicitly identify the intended fail-open/tool-filtering changes as exceptions to preservation. AC9’s structured deny protocol should likewise be identified as an intentional protocol change.Blocker — AC10’s checks do not prove its restrictions. Empty
PATH, an unchanged scratch directory and repeatability cannot establish absence of repository reads or network access. Existing import rules prohibit neither process nor network imports (.golangci.yml:138), and architecture rules enforce only broad layering (.go-arch-lint.yml:34). Specify an actual source/import inspection covering the guard’s execution path; no new configurable enforcement layer is needed.Minor — AC3’s check proves verdict parity but not its distinct denial reason. Add an assertion that merge-guard denials name the squash merge entry point.
Minor — Risk coverage names the wrong existing suite.
tools/test-guards.sh:2identifies it as fix-prose coverage, outside this slice. Remove the claim that porting its cases validates these four guards.Split
The four guards remain a coherent single-PR slice. The estimated 600 code/test lines fit the stated limit. No further split is needed.
Definition of done
Planning coverage only; checked boxes indicate coverage in the ACs and Verify plan, not completed implementation.
Round: 3 of 3
tbhb-agent commentedon Oct 31, 2026
Refinement: READY — the redesigned slice preserves the four guards’ behavior
Checked
originidentifiestbhb-dev/devtools. Read the live body, labels and all three comments once. The issue is open withrefinement:active. This is round 1 of the explicitly authorized restart after three earlier NOT READY rounds.origin/main; no scripts, builds or tests were executed.packages/agents/source/skills/pr/claude/scripts/guard-gh.sh:63andpackages/agents/source/skills/merge-pr/claude/scripts/guard-merge.sh:56.create-pr.sh. Thusgh pr view 1; gh pr merge 1remains allowed:packages/agents/source/skills/pr/claude/scripts/guard-gh.sh:54.squash-merge.sh:packages/agents/source/skills/merge-pr/claude/scripts/guard-merge.sh:49.packages/agents/source/skills/pr/claude/scripts/guard-gh.sh:52,packages/agents/source/skills/merge-pr/claude/scripts/guard-merge.sh:47,packages/agents/source/skills/watch-pr/claude/scripts/guard-watch.sh:40, andpackages/agents/source/skills/fix-pr/claude/scripts/guard-fix.sh:40.packages/agents/source/skills/watch-pr/claude/scripts/guard-watch.sh:42andpackages/agents/source/skills/fix-pr/claude/scripts/guard-fix.sh:73.--job:packages/agents/source/skills/fix-pr/claude/scripts/guard-fix.sh:42.--force-with-lease:packages/agents/source/skills/fix-pr/claude/scripts/guard-fix.sh:61.tool_name; absent or empty commands pass. Undecodable-input success is correctly identified as a deliberate Go protocol exception, consistent withcmd/guard-markdown/run.go:104.packages/agents/source/skills/pr/claude/scripts/guard-gh.sh:45,packages/agents/source/skills/merge-pr/claude/scripts/guard-merge.sh:42,packages/agents/source/skills/watch-pr/claude/scripts/guard-watch.sh:34, andpackages/agents/source/skills/fix-pr/claude/scripts/guard-fix.sh:36..github/workflows/ci.yml:177. Passing the future cases remains planned validation.Gaps
None.
Split
The four guards form a coherent single-PR slice. The estimated 600 code/test lines fit the stated limit; rollout and remaining guards are explicitly deferred. No further split is needed.
Definition of done
Planning coverage only; checked boxes indicate AC and Verify coverage, not completed implementation.
Round: 1 of 3
feat(guard-workflow): add a Go guard for pull request commands