Skip to content

ci: add make ci-remote to run the pre-push gate's make ci elsewhere - #766

Closed
EricAndrechek wants to merge 5 commits into
build/trimpathfrom
ci/remote-make-ci
Closed

EricAndrechek wants to merge 5 commits into
build/trimpathfrom
ci/remote-make-ci

Conversation

@EricAndrechek

Copy link
Copy Markdown
Member

Summary

A code push needs tmp/ci-passed-tree-<tree>, and only a make ci in the same checkout wrote it. So every push paid for a full local run (race detector, coverage, a Docker daemon carrying every suite's containers), and the gate only ever exercised the contributor's own OS and architecture.

make ci-remote HOST=<host> runs that make ci on another machine and keeps the gate's meaning.

  • Transports. HOST is any ssh destination (a ~/.ssh/config alias works), or docker:<container> for a local container or devcontainer. Over ssh the commands run in a login shell; in a container, in its own environment.
  • What travels. The worktree must be clean. HEAD is streamed as a git bundle on the transport's stdin, right after the host-side script, so nothing is pushed. The host checks out that commit in a fresh directory under REMOTE_DIR, runs make tools && make ci, streams the output back with its uname -sm first, and removes the directory unless KEEP=1. Stopping the command, by Ctrl-C or a signal to the script alone, stops the run on the host: the host's stdin stays open only while the script runs, and a watcher stops the run's process group when it closes.
  • When the marker is written. Only if the host exits 0, the tree it reports having checked out is HEAD^{tree}, make ci left its checkout clean, and the local worktree is still clean and on that tree at the end. The host's report lines carry a per-run nonce, so make ci's own output can't forge them. The verify marker is written too, as make ci does.
  • Provenance. tmp/ci-passed-tree-<tree>.provenance records the host, OS/arch, start and end times, and the run's own local copy of the log (tmp/ci-remote-<tree>-<run>.log, so concurrent runs of one tree don't overwrite each other). The pre-push hook prints make ci passed on <host> (<os>/<arch>) from it. Marker semantics and local make ci are unchanged.
  • Docs. A make ci-remote section in the development guide (usage, host requirements, how the gate treats it), the AGENTS.md pre-push rules, the hook table, and CONTRIBUTING.md's pre-push sentence.

Test plan

  • scripts/ci/remote-ci.test.sh, a new make verify leaf (test-remote-ci): 31 cases in a scratch repository, with stand-in ssh/docker commands that run the host side for real, in its own process group as sshd would, against a stub make, uname and git. It covers a passing run (markers, provenance fields, the log, host cleanup), KEEP/REMOTE_DIR, a failing make ci, two tree mismatches (HEAD moved during the run; the host checked out another commit), a worktree dirtied during the run, make ci dirtying the host's checkout, stopping the local process group or only the script mid-run (either way the host's make ci stops and its directory goes), a report line forged in make ci's output, dirty-worktree and bad-HOST refusals with status.showUntrackedFiles=no set, the docker exec command line, two runs of one tree keeping separate logs, and the pre-push hook with and without provenance.
  • Each check in the script and the hook was deleted in turn; every deletion fails at least one case, except the empty-report check, which was then folded into the tree comparison.
  • make lint-sh lint-md lint-prose test-classify-paths pass; pre-commit make verify passed on the commit.
  • Against a real Linux x86_64 host over ssh: the bundle checked out at the right tree, the report lines came first, and phase 1 of make ci passed there, remote-ci test included. Phase 2 needed host-side Docker networking setup: make ci ran inside a container on the host's network, so testcontainers dialed the bridge gateway and timed out (TESTCONTAINERS_HOST_OVERRIDE=localhost is the documented fix). No marker was written for that run, and none when the local side was killed. A full green make ci on a remote host has not been run yet.
  • ssh -T and docker exec -i both return within a second of the remote command exiting while local stdin is still open, which the interrupt watcher relies on.

Related Issues

Closes #764. Part of #740. Stacked on #765 (base build/trimpath); contains the whole stack below it and today's main.

🤖 Generated with Claude Code

EricAndrechek and others added 5 commits October 7, 2026 16:13
A code push needs tmp/ci-passed-tree-<tree>, and only a make ci in the
same checkout wrote it, so every push paid for a full local run and the
gate only ever exercised the contributor's own OS and architecture.

make ci-remote HOST=<ssh destination>|docker:<container> streams HEAD to
the host as a git bundle on the transport's stdin, checks it out in a
fresh directory, runs make tools && make ci there and streams the output
back. The same markers are written locally only if the host exits 0, the
tree it reports having checked out is HEAD^{tree}, make ci left that
checkout clean, and the local worktree is clean before and after the run
and still on that tree. Report lines carry a per-run nonce so make's own
output can't forge them. A provenance file beside the marker records
where the run happened, and the pre-push hook prints it.

scripts/ci/remote-ci.test.sh (a make verify leaf) runs the script through
stand-in ssh and docker commands in a scratch repository.

Closes #764. Part of #740.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stop the host run when the local side goes away: the host keeps its
stdin open for the whole run and a watcher stops the run's process group
when it closes. The bundle is length-prefixed so stdin can stay open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Interrupting make ci-remote now stops the run on the host, so say so in
the development guide and the CHANGELOG entry. A host that runs make ci
inside a container on its own network needs testcontainers pointed at
the published ports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A signal to the script alone left its keepalive loop, the transport and
tee running, so the host's stdin never closed and make ci went on there
with its run directory. The loop now also checks that the script is
alive.

The host's dirty check now passes --untracked-files=normal, as the local
one does, so a host with status.showUntrackedFiles=no can't hide files a
run left behind. Each run writes its own log, named with its nonce, so
two runs of one tree don't overwrite each other's. The bundle is read
through an open descriptor, so no temporary file outlives a killed run.

Tests: signaling only the script, a report line forged in make's output,
two runs of one tree, and every case under status.showUntrackedFiles=no.
run() stops a hung run after 60s. The docs list curl, jq and Chromium's
system libraries among the host's needs, and CONTRIBUTING.md counts a
remote make ci.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: aed91281-1c4e-443d-97df-118523d8657a
📥 Commits

Reviewing files that changed from the base of the PR and between 8b04490 and ebf0c5e.

📒 Files selected for processing (10)
  • .githooks/pre-push
  • AGENTS.md
  • CHANGELOG.md
  • CONTRIBUTING.md
  • Makefile
  • docs/src/content/docs/claude-code.md
  • docs/src/content/docs/development.md
  • scripts/ci-marker.sh
  • scripts/ci/remote-ci.sh
  • scripts/ci/remote-ci.test.sh

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📜 Recent review details
🧰 Additional context used
📚 Code guidelines (5)
docs/src/content/docs/claude-code.md — auto-discovered
CONTRIBUTING.md — auto-discovered
.github/copilot-instructions.md — auto-discovered
AGENTS.md — auto-discovered
docs/src/content/docs/development.md — auto-discovered
📓 Path-based instructions (6)
Source excerpt: To add a command: drop a `.md` file in `.claude/commands/`.

📄 CodeRabbit inference engine (docs/src/content/docs/claude-code.md)

Files:

  • docs/src/content/docs/claude-code.md
Source excerpt: Use [Conventional Commits](https://www.conventionalcommits.org/):

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Files:

  • CONTRIBUTING.md
See [AGENTS.md](../AGENTS.md) for project conventions, architecture notes, and AI agent instructions.

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • AGENTS.md
Source excerpt: **Never hard-wrap prose.

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • docs/src/content/docs/claude-code.md
  • CONTRIBUTING.md
  • AGENTS.md
  • docs/src/content/docs/development.md
  • CHANGELOG.md
Source excerpt: **Docs prose**: never hard-wrap Markdown — one paragraph is one line.

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Files:

  • docs/src/content/docs/claude-code.md
  • CONTRIBUTING.md
  • AGENTS.md
  • docs/src/content/docs/development.md
  • CHANGELOG.md
Source excerpt: Run `make fix` to apply them.

📄 CodeRabbit inference engine (docs/src/content/docs/development.md)

Files:

  • docs/src/content/docs/claude-code.md
  • CONTRIBUTING.md
  • AGENTS.md
  • docs/src/content/docs/development.md
  • CHANGELOG.md
🪛 ast-grep (0.45.3)
scripts/ci/remote-ci.test.sh

[warning] 129-129: Writing to or reading from a hardcoded, predictable path under /tmp is vulnerable to symlink and TOCTOU attacks: a local attacker can pre-create the file (or a symlink pointing elsewhere) and hijack or corrupt the contents. Generate a unique, unpredictable temporary file with mktemp instead, e.g. tmpfile="$(mktemp)" (or mktemp -d for directories) and reference "$tmpfile".
Context: "$repo/tmp/ci-passed-tree-$1"
Note: [CWE-377] Insecure Temporary File.

(predictable-tmp-file-bash)

🪛 LanguageTool
docs/src/content/docs/development.md

[locale-violation] ~353-~353: In American English, ‘afterward’ is the preferred variant. ‘Afterwards’ is more commonly used in British English and other dialects.
Context: ...ke cithere, and removes the directory afterwards unlessKEEP=1. The host's uname -sm`...

(AFTERWARDS_US)


[style] ~353-~353: This sentence is over 40 words long. Consider splitting it up, as shorter sentences make the text easier to read.
Context: ...e directory afterwards unless KEEP=1. The host's uname -sm is the first line of the output, and each run keeps a copy of its log in tmp/ci-remote-<tree>-<run>.log, so two runs of one tree, say on two architectures at once, don't overwrite each other. If make ci-remote is stopped, by Ctrl...

(TOO_LONG_SENTENCE)


[style] ~357-~357: This word has been used in one of the immediately preceding sentences. Using a synonym could make your text more interesting to read, unless the repetition is intentional.
Context: ...aning doesn't change: a code push still needs tmp/ci-passed-tree-<tree>, which says...

(EN_REPEATEDWORDS_NEED)


[style] ~357-~357: This sentence is over 40 words long. Consider splitting it up, as shorter sentences make the text easier to read.
Context: ...make ci passed for exactly that tree. make ci-remote writes it (and the verify marker) only if the host exits 0, the tree the host reports having checked out equals your HEAD^{tree}, make ci left the host's checkout clean, and your worktree is still clean and on that tree when the run ends; otherwise it writes nothing. Beside the marker, `tmp/ci-passed-tree-...

(TOO_LONG_SENTENCE)

CHANGELOG.md

[style] ~51-~51: This sentence is over 40 words long. Consider splitting it up, as shorter sentences make the text easier to read.
Context: ...the run's process group when it closes. It writes the markers a local make ci does only if the host exits 0, the tree the host reports having checked out is HEAD^{tree}, make ci left the host's checkout clean, and the local worktree is clean before and after the run and still on that tree; the host's report lines carry a per-run nonce, so make ci's own output can't forge them. A provenance file beside the marker rec...

(TOO_LONG_SENTENCE)

🔇 Additional comments (10)
Makefile (1)

501-509: LGTM!

Also applies to: 600-605, 619-619, 1016-1025

scripts/ci/remote-ci.sh (1)

136-138: Check the transport exit status before you trust the tree report.

status[1] holds the exit status of the transport (ssh or docker exec). It is not the exit status of make ci. The remote script runs set -eu, so a failing make ci also makes wait "$run" fail. That failure propagates to the transport, so the gate works. One gap remains. The remote script does not require the watcher to be killed before it exits. The finish trap kills the watcher, and the run exits with the wait status. I traced the path and found no false pass. The error text "make ci failed" can also appear for an ssh connection failure, exit 255. That message can mislead the user, but it is not a defect.

LGTM!

scripts/ci/remote-ci.test.sh (1)

1-339: LGTM!

docs/src/content/docs/development.md (1)

343-358: LGTM!

Also applies to: 552-552, 568-568

CHANGELOG.md (1)

51-51: LGTM!

.githooks/pre-push (1)

7-8: LGTM!

Also applies to: 42-49

AGENTS.md (1)

9-9: LGTM!

Also applies to: 172-173, 291-291

CONTRIBUTING.md (1)

40-40: LGTM!

docs/src/content/docs/claude-code.md (1)

40-40: LGTM!

scripts/ci-marker.sh (1)

6-8: LGTM!


📝 Summary

Summary by CodeRabbit

  • New Features
    • Run the full CI checks for your current commit on a configured SSH host or running Docker container with make ci-remote.
    • Successful remote runs record validation markers and provenance locally. Markers are written only when the tested commit matches and both local and remote checks pass.
    • Choose whether to keep the remote working directory with KEEP=1.
  • Documentation
    • Added guidance for remote CI setup, usage, and pre-push validation.

Walkthrough

Adds make ci-remote to run CI for HEAD on an SSH host or Docker container. The script checks the tested tree and local worktree before writing CI markers and provenance. The pre-push hook accepts those markers and reports provenance when available.

Changes

Remote CI

Layer / File(s) Summary
Remote execution
Makefile, scripts/ci/remote-ci.sh, scripts/ci/remote-ci.test.sh, docs/src/content/docs/development.md
Adds the ci-remote target and SSH or Docker execution for a bundle of local HEAD. The script creates a remote checkout, runs make tools and make ci, and removes the checkout unless KEEP=1. The test suite covers successful SSH and Docker runs, retention, and distinct run logs.
Validation and marker creation
scripts/ci/remote-ci.sh, scripts/ci/remote-ci.test.sh, Makefile, docs/src/content/docs/development.md, CHANGELOG.md
The script writes CI and verification markers with provenance only when the remote run succeeds for the matching tree and the local and remote worktrees meet the stated clean-tree conditions. Tests cover failures, tree changes, dirty worktrees, interruptions, and invalid host values. The test target is included in verify-parallel.
Pre-push marker integration
.githooks/pre-push, AGENTS.md, CONTRIBUTING.md, docs/src/content/docs/claude-code.md, scripts/ci-marker.sh, scripts/ci/remote-ci.test.sh
The pre-push hook accepts a remote CI marker and reports host, OS, and architecture when provenance exists. Instructions and marker comments identify make ci-remote as an option. Tests cover valid markers, missing provenance, and absent markers.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Developer
  participant Makefile
  participant RemoteCIScript
  participant SSHorDocker
  participant RemoteMake
  Developer->>Makefile: Run ci-remote with HOST
  Makefile->>RemoteCIScript: Pass HOST, KEEP, and REMOTE_DIR
  RemoteCIScript->>SSHorDocker: Stream bundle and remote runner
  SSHorDocker->>RemoteMake: Run make tools and make ci
Loading

Suggested reviewers: taitelee

Merge Risk: ⚪ Minimal · up to ebf0c

This adds an opt-in make ci-remote command and lets the pre-push hook accept its markers. Markers are written only after a verified successful run on the exact tree, so the gate keeps its meaning. No merge-blocking issues were found.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 22 functions across 3 files. (7 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #764 requires remote make ci support without changing marker meaning. scripts/ci/remote-ci.sh requires a clean worktree, bundles HEAD, checks out that commit on the host, runs `make tools …
Out of Scope Changes check ✅ Passed The changes stay within Issue #764. The Makefile target, pre-push hook changes, marker provenance, documentation, changelog entry, and make verify test are directly connected to remote CI usage and …
Title check ✅ Passed The title clearly and concisely identifies the main change: adding make ci-remote to run the pre-push make ci gate on another machine.
Description check ✅ Passed The description directly explains the new remote CI workflow, marker and provenance behavior, documentation updates, and test coverage.
Full details: Docstring Coverage

Explanation

Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 22 functions across 3 files. (7 skipped: 7 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
✨ Simplify code
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added documentation Improvements or additions to documentation area/docs Documentation, site/, README area/infra CI, build, deploy, Docker, release labels Oct 7, 2026
@EricAndrechek

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@EricAndrechek

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@EricAndrechek

Copy link
Copy Markdown
Member Author

Superseded by #798, which rebuilds this change directly on main (after #785) and also carries the hook fixes for #678, #787 and #790, which touch the same pre-push hook and verify leaves.


— Posted by Claude Code on behalf of @EricAndrechek

EricAndrechek added a commit that referenced this pull request Oct 9, 2026
This PR has two parts:

1. **`make ci-remote`** runs the pre-push gate's `make ci` on another
machine.
2. **The repo's hooks now fail closed.** These are git's `.githooks/`
and the Claude Code hooks in `.claude/`. Each one failed the wrong way
when a file it runs was missing, or when an action came in a form the
hook didn't recognise (#678, #787, #790).

They share the pre-push hook, the verify leaves and the hook docs, so
they ship together and their overlaps are resolved once.

### Part 1: `make ci-remote`

A code push needs `tmp/ci-passed-tree-<tree>`, and only a `make ci` in
the same checkout wrote it. So every push paid for a full local run
(race detector, coverage, a Docker daemon carrying every suite's
containers), and the gate only ever exercised the contributor's own OS
and architecture.

`make ci-remote HOST=<host>` runs that `make ci` on another machine and
keeps the gate's meaning.

- **Transports.** `HOST` is any ssh destination (a `~/.ssh/config` alias
works), or `docker:<container>` for a local container or devcontainer.
Over ssh the commands run in a login shell; in a container, in its own
environment.
- **What travels.** The worktree must be clean. `HEAD` is streamed as a
`git bundle` on the transport's stdin, right after the host-side script,
so nothing is pushed. The host checks out that commit in a fresh
directory under `REMOTE_DIR`, runs `make tools && make ci`, streams the
output back with its `uname -sm` first, and removes the directory unless
`KEEP=1`. Stopping the command, by Ctrl-C or a signal to the script
alone, stops the run on the host: the host's stdin stays open only while
the script runs, and a watcher stops the run's process group when it
closes.
- **When the marker is written.** Only if all of these hold: the host
exits 0; the tree it reports having checked out is `HEAD^{tree}`; `make
ci` left its checkout clean; and the local worktree is still clean and
on that tree at the end. The host's report lines carry a per-run nonce,
so `make ci`'s own output can't forge them. The verify marker is written
too, as `make ci` does.
- **Provenance.** `tmp/ci-passed-tree-<tree>.provenance` records the
host, OS/arch, start and end times, and the run's own local copy of the
log (`tmp/ci-remote-<tree>-<run>.log`, so concurrent runs of one tree
don't overwrite each other). The pre-push hook prints `make ci passed on
<host> (<os>/<arch>)` from it. Marker semantics and local `make ci` are
unchanged.
- **Docs.** A `make ci-remote` section in the development guide (usage,
host requirements, how the gate treats it), the AGENTS.md pre-push
rules, the hook table, and CONTRIBUTING.md's pre-push sentence.

### Part 2: hooks that fail closed

- **#678:** git's `pre-commit` failed loudly in a worktree with no code,
so nothing could be committed there.
- **#787:** the Claude Code hooks failed silently when their scripts
were gone, so the push gate stopped gating and nothing said so.
- **#790:** the agent gate checked only the `gh pr` spelling of each
action it guards, so the alias and API forms went through. Its `gh`
checks also blocked commands that only *mentioned* a guarded action in a
heredoc or a comment.

Each now fails closed and says why.

#### #678: git hooks in a worktree with no Makefile

With an absolute `core.hooksPath`, git runs one checkout's `.githooks/`
in every worktree, including one on an orphan branch that keeps a tool's
state and no code. There, `pre-commit` couldn't find
`scripts/ci-marker.sh`, hid that behind `2>/dev/null`, and ran `make
verify` with no Makefile (`No rule to make target 'verify'`).

- **`pre-commit`:** no `Makefile` in the worktree means nothing to
verify, so it exits 0 with a note. A missing `scripts/ci-marker.sh`, or
one that fails with anything but "no marker" (exit 1), is now reported,
and then `make verify` runs, which is the fail-closed path.
- **`pre-push`** had the same failure, reported as a misleading "can't
resolve tree". It now decides per pushed commit, not per worktree. A
pushed commit with no `Makefile` holds no code to validate, so it goes
through. One with a `Makefile`, pushed from a worktree that lacks
`scripts/ci-marker.sh`, is blocked with a message naming the script.
Deciding by worktree would have failed open: `git push origin
<code-branch>` from a worktree with no code would have skipped the
check.
- **The `make ci-remote` test needed one fix for this.** Its scratch
repository had no `Makefile`. So once the pre-push hook passed a commit
without one, the test's two pre-push cases ("blocks a code push with no
marker" and "accepts the marker and names the host") stopped exercising
the marker check and failed. A code repository has a Makefile, so the
scratch one now does too, in its own commit.

**On `core.hooksPath`.** This PR doesn't change it. `make tools` sets
the relative `.githooks`, and git resolves a relative `core.hooksPath`
in each worktree (checked with git 2.56). So each worktree runs its own
branch's hooks, and a worktree whose branch has no `.githooks/` runs no
hooks at all. That fails open, the class of bug #787 is about. #678 was
found with an absolute path, which runs one checkout's hooks everywhere.
Switching to a relative path would have "fixed" #678 by turning a loud
failure into a silent one. So the fix is in the hooks, which now behave
correctly under either setting.

#### #787: Claude Code hooks whose scripts are missing

Each hook in `.claude/settings.json` was a bare
`$CLAUDE_PROJECT_DIR/.claude/hooks/<script>.sh`. `CLAUDE_PROJECT_DIR` is
fixed when the session starts. Once that checkout is removed, or a
script loses its executable bit, the shell exits 127 or 126. Claude Code
[treats that as a non-blocking
error](https://code.claude.com/docs/en/hooks#other-exit-codes): "When
the script path doesn't exist or isn't executable, the shell exits with
a code like 127 … For most hook events, the action proceeds. … a
mistyped path in `settings.json` leaves the gate silently disabled."

The scripts may be the thing that's missing, so the fix has to live in
the commands. Each is now a one-line POSIX `sh` guard; `sh -c` is how
Claude Code [runs a shell-form
hook](https://code.claude.com/docs/en/hooks#exec-form-and-shell-form) on
macOS and Linux.

When the script is there and executable, the guard `exec`s it, with the
same process, stdin, argv and environment as before. The only visible
change is that the path is now quoted, so a project directory with a
space in it works.

When the script isn't there, the guard acts by event:

| Event | Script missing or not executable → | Why this code |
| ----- | ---------------------------------- | ------------- |
| PreToolUse (Bash) | **exit 2** for any command containing the words
`git` and `push`; `gh` and `pr` with one of `create`, `new`, `edit`,
`ready`, `review` or `merge`; or `gh` and `api` with `pulls` or
`graphql`. That last rule blocks every call to the pull-request or
GraphQL API, reads included. Also **exit 2 for every command** when `jq`
is missing or the payload can't be parsed. Every other command: exit 0.
| "Exit 2 means a blocking error … `PreToolUse` blocks the tool call",
and exit 2 is "the only exit code that blocks through the code alone"
([Exit code 2](https://code.claude.com/docs/en/hooks#exit-code-2)). |
| SubagentStop | **exit 1**, with a one-line stderr message | For
SubagentStop, exit 2 "Prevents the subagent from stopping" ([per-event
table](https://code.claude.com/docs/en/hooks#exit-code-2-behavior-per-event)),
which risks a loop. A non-blocking error shows "a `<hook name> hook
error` notice followed by the first line of stderr" ([Other exit
codes](https://code.claude.com/docs/en/hooks#other-exit-codes)), so the
message is one line. No marker gets written, so a push is still blocked,
by the push gate or by the PreToolUse fallback above. |
| PostToolUse (both formatters) | **exit 2**, with a stderr message |
For PostToolUse, exit 2 "Shows stderr to Claude; the tool already ran"
([per-event
table](https://code.claude.com/docs/en/hooks#exit-code-2-behavior-per-event)).
The docs recommend exit 2 for exactly this case: "To surface a warning
to Claude from a `PostToolUse` … hook, exit 2 instead" ([Exit code
0](https://code.claude.com/docs/en/hooks#exit-code-0)). Stderr from exit
0 "goes to the debug log only … Claude never sees it", and exit 1 only
reaches the transcript notice. |

Each message names the missing script and says how to recover: restart
Claude Code from a live checkout.

Design notes:

- **Why not block every Bash call?** A session whose launch checkout has
gone can still be working in other worktrees, and it needs Bash to
notice the problem and recover. Blocking only what the gate guards keeps
the gate's guarantee. The exception is a missing `jq`: then the command
can't be read, so every command is blocked, which is what
`agent-bash-gate.sh` already does without `jq`.
- **The fallback's detection is crude on purpose.** It matches whole
words anywhere in the command text, ignoring quoting, so it over-blocks:
`git stash push`, `git push` mentioned in a commit message, and a
read-only `gh api …/pulls/N/comments` are all blocked. It never runs
while the real gate can run, and like the gate it guards against
accidents, not deliberate workarounds. Only `.tool_input.command`
decides; the Bash tool's `description` field is ignored.
- **`jq` isn't guaranteed.** It's a documented prerequisite (`make
verify` and the hooks need it), not a given, so its absence fails
closed.
- **Every fallback reads its payload to the end** before exiting, so
Claude Code isn't left half-way through writing a large `Write` payload
to a hook that never reads it.
- **A script that exists but can't start** (a broken shebang, say) still
fails open. The guard checks that the script exists and is executable;
catching a failed `exec` would mean giving up the `exec`.

#### #790: the agent gate missed the alias and API forms of what it
guards

`agent-bash-gate.sh` matched only the `gh pr` spellings, so each guarded
action went through in another form:

- `gh pr new`, an alias of `gh pr create`, skipped the `--draft` check
and the title lint.
- `gh api …/pulls/N/reviews -f event=APPROVE` approved a PR.
- `gh api …/pulls -f head=… -f title=…` opened a non-draft PR.
- `gh api …/requested_reviewers -f 'reviewers[]=…'` requested a
reviewer. The check wanted `-X POST` or a literal `reviewers=`, though a
field already makes `gh api` POST.
- `gh api -X PUT …/pulls/N/merge` merged.
- GraphQL `markPullRequestReadyForReview` marked a draft ready.

Each of these now gets the same answer as its `gh pr` form:

- **`gh pr new`** is checked like `gh pr create`.
- **REST.** A `gh api` call counts as a write when it names a method
other than GET or, naming none, sends fields.
  - A write to `…/requested_reviewers` (`DELETE` included) is blocked.
  - A write to `…/pulls/N/merge` is blocked.
- A write that opens a PR (`…/pulls`) needs `draft=true`. Its `title=`
goes through the PR-title lint, as does a retitle (`PATCH …/pulls/N`).
  - A review with `event=APPROVE` or `event=REQUEST_CHANGES` is blocked.
- **GraphQL.** These are blocked:
  - `markPullRequestReadyForReview`;
  - `mergePullRequest` and `enablePullRequestAutoMerge`;
  - `requestReviews`;
- an `addPullRequestReview` or `submitPullRequestReview` that approves
or requests changes;
  - a `createPullRequest` without `draft: true`.
- **`gh pr merge` stays with the `deny` rule** in
`.claude/settings.json` and out of the gate. A person merges with it
from Claude Code's shell mode (`! gh pr merge …`), and the docs don't
say whether PreToolUse hooks see shell-mode commands. The [shell-mode
section](https://code.claude.com/docs/en/interactive-mode#shell-mode-with-prefix)
says only that they run "without going through Claude" and don't
"require Claude to interpret or approve the command"; the [hooks
reference](https://code.claude.com/docs/en/hooks) doesn't mention them.
Blocking it in the gate could break that workflow. The API and GraphQL
merge forms don't overlap with it, so the gate blocks them.

**The checks now read what the line runs, not what it mentions.** The
`gh` checks used to read the line with quoted strings removed and
everything else kept. That made them wrong both ways:

- They blocked a command that only mentioned a guarded action in a
heredoc body or a comment, such as a `python3 - <<'EOF'` script with `#
gh pr ready` in it.
- They missed a guarded action inside a `$(…)` or handed to `bash -c`.

They now read the commands the line runs, through the gate's own
tokenizer, the same view the push check already uses:

- A mention in a quoted string, a heredoc body or a comment doesn't
count.
- Code a shell runs is queued, tokenized and judged the same way. That
covers a `$(…)`, `eval`'s words, a heredoc a shell reads directly or
through a pipe, and the code a shell is handed with `-c`. Options before
`-c` are stepped over, including the value of `-o`/`+o`: `bash -euo
pipefail -c '…'` and `bash --noprofile --norc -c '…'` both count. A
short-option cluster containing `c` counts as `-c`; a long option such
as `--norc` doesn't.
- The code inside a `$(…)` is read as written, quotes and heredoc bodies
included. So `url=$(gh pr create --draft --title "feat(x): …")` is
judged on its real title, and a heredoc a shell reads inside a `$(…)`
counts too. (The push check still doesn't follow that last form.)
- A GraphQL `query=` computed by a command (`-f query="$(cat <<'EOF' …
EOF)"`) has its guarded mutation names matched against the code that
computes it, heredoc bodies included.
- The gate follows 64 pieces of nested code. Past that, a piece left
that mentions a `gh pr` or `gh api` command is refused, unjudged.
- A line the tokenizer can't parse is refused when it mentions a `gh pr`
or `gh api` command, because what it runs can't be told.

**Each `gh` command is judged on its own words.**

- A `gh api` call is judged by its endpoint (its first argument that
isn't a flag) and by its fields. `draft=`, `title=` and `event=` are
read only from `-f`, `-F`, `--field` or `--raw-field` values, and
GraphQL mutation names only from the `query=` field. So a review-thread
reply or comment whose body names an endpoint, a field or a mutation
doesn't count, and neither does another call on the same line.
- gh's own `-R`/`--repo` before the command is stepped over (`gh -R o/r
pr ready 12`, `--repo=o/r`, `-Ro/r`), for `pr` and `api` both.
- A boolean flag set with `=` is read as gh reads it: `--draft=true`
counts as `--draft`, and `--approve=false` doesn't approve.
- Titles are read from the tokens, so an unquoted `--title` is linted
too.

The gate still reads the command line only, so fields sent through
`--input` or `-F field=@file` go unseen.

**It runs under macOS's bash 3.2.** On a Mac with no newer bash on
`PATH`, `#!/usr/bin/env bash` finds `/bin/bash` 3.2. An earlier revision
of this branch stopped there with "unbound variable" whenever a gh line
had no substitution, because it expanded an empty array under `set -u`.
The gate then exited 1, which Claude Code doesn't treat as a block. The
scan now walks its arrays by index. The three hook tests run a second
time with `/bin/bash` first on `PATH` when it is a different bash, so a
construct 3.2 rejects fails a test.

**Timing.** The tokenizer used to step through a double-quoted string
one character at a time; it now takes runs of plain text in one step.
Measured on an earlier revision of this change (a 100 KB inline `--body
"…"`: 11 s with the old character loop, under 1 s after); not
re-measured after the per-call rewrite.

## Test plan

**On this branch at its head, 9b8c30c (measured):**

- [x] `make verify` passes with all 23 leaves.
- [x] `make build-docs` passes.
- [x] shellcheck v0.11.0 is clean on all 48 tracked `*.sh` and
`.githooks/*` files.
- [x] The pre-commit `make verify` passed on each commit that went
through it.
- [ ] `make ci`: not run.

**Part 1, `make ci-remote`.** Carried over from the stacked version of
this PR (#766) unless marked as re-run here.

- [x] `scripts/ci/remote-ci.test.sh`, a new `make verify` leaf
(`test-remote-ci`): **31/31 on this branch (re-run here)**. It runs in a
scratch repository, which now has a Makefile, with stand-in
`ssh`/`docker` commands that run the host side for real, in its own
process group as sshd would, against a stub `make`, `uname` and `git`.
It covers:
  - a passing run: markers, provenance fields, the log, host cleanup;
  - `KEEP` and `REMOTE_DIR`;
  - a failing `make ci`;
- two tree mismatches: HEAD moved during the run, and the host checked
out another commit;
- a worktree dirtied during the run, and `make ci` dirtying the host's
checkout;
- stopping the local process group, or only the script, mid-run; either
way the host's `make ci` stops and its directory goes;
  - a report line forged in `make ci`'s output;
- dirty-worktree and bad-`HOST` refusals with
`status.showUntrackedFiles=no` set;
  - the `docker exec` command line;
  - two runs of one tree keeping separate logs;
  - the pre-push hook with and without provenance.
- [ ] Each check in the script and the hook was deleted in turn, and
every deletion fails at least one case. The exception was the
empty-report check, which was then folded into the tree comparison.
(Carried over.)
- [ ] Against a real Linux x86_64 host over ssh (carried over):
- What worked: the bundle checked out at the right tree, the report
lines came first, and phase 1 of `make ci` passed there, `remote-ci
test` included.
- What didn't: phase 2 needed host-side Docker networking setup. `make
ci` ran inside a container on the host's network, so testcontainers
dialed the bridge gateway and timed out;
`TESTCONTAINERS_HOST_OVERRIDE=localhost` is the documented fix.
- No marker was written for that run, and none when the local side was
killed.
  - A full green `make ci` on a remote host has not been run yet.
- [ ] `ssh -T` and `docker exec -i` both return within a second of the
remote command exiting while local stdin is still open, which the
interrupt watcher relies on. (Carried over.)

**Part 2, hooks that fail closed (measured):**

- [x] `make test-githooks` (`scripts/githooks.test.sh`, new verify
leaf): 11/11 pass, under bash 5.3 and again with `/bin/bash` 3.2 first
on `PATH`.
- It builds a scratch repository whose `core.hooksPath` names this
checkout's `.githooks/` by absolute path, then commits and pushes from a
code worktree and from an orphan worktree with no Makefile.
- The test clears git's repository variables, uses `git -C` throughout,
and refuses to write unless each scratch repository and worktree is
where it asked.
- Against the old hooks, **6 of 11 fail**, including `make: *** No rule
to make target 'verify'` for the commit with no Makefile.
- [x] `make test-hook-commands` (`.claude/hooks/hook-commands.test.sh`,
new verify leaf): 171/171 pass under `sh`, `dash` and `bash`, and
171/171 again with `/bin/bash` 3.2 first on `PATH`.
- It reads every hook command from the real `settings.json` and runs it
under each shell.
- It covers an empty `CLAUDE_PROJECT_DIR`, a non-executable copy of
every script, the variable unset, and no `jq`.
- It runs each command against stub scripts that record their stdin byte
for byte, their argv and their exit code, including with a path
containing a space.
  - It also runs this checkout's real scripts.
- A catch-all case fails any hook command, present or future, that exits
0, 126 or 127 when its script is missing or not executable. Dropping `[
-x "$h" ] &&` from the SubagentStop guard fails 6 cases.
- Against the old `settings.json`, **141 of 171 fail**. The 30 that pass
are exactly the stub-script and real-checkout cases, which shows the
commands behave as before when the scripts exist.
- [x] `make test-review-gate`: 230/230 pass under bash 5.3, and 230/230
with `/bin/bash` 3.2.57 first on `PATH`.
  - The 93 new cases cover every alias, REST and GraphQL form above.
  - **Code inside `$(…)`, read as written:**
- Blocked: a GraphQL ready mutation captured by `$(…)`; a bad title is
still linted there; a `gh pr ready` in a heredoc read by `bash` inside
`$(…)`.
- Allowed: a draft PR's URL captured with a two-word title and with a
scoped title; a comment body with an apostrophe; a `grep -c 'gh pr
ready'`.
- **Look-alikes that must go through:** GET on a PR, its reviews, its
requested reviewers and its merge status; `-X GET` with fields; a
COMMENT review; a GraphQL query; a GraphQL draft create; an issue
comment that mentions approving and merging.
- **Mentions, allowed:** a Python heredoc's comment, a `cat` heredoc, a
shell comment, a quoted string, a PR-body heredoc.
- **A guarded command a shell runs, blocked:** in a `$(…)`, `bash -c`,
`bash -lc`, `sh -c` or `eval`, in a heredoc read by `bash` or piped into
`sh`, and behind `timeout`.
- **`=`-flags in both directions:** `--draft=true` allowed,
`--draft=false` blocked, `--approve=true` and `--request-changes=true`
blocked, `--approve=false --comment` allowed.
- **Per-call judgment:** a review-thread reply quoting a merge endpoint,
a comment naming `requested_reviewers` or `event=APPROVE`, and a GraphQL
comment naming a mutation are allowed. A reviewer read followed by a
comment is allowed; a comment followed by a reviewer write is blocked. A
non-draft create whose body says `draft=true` is blocked.
- **`-R`/`--repo` forms:** blocked for ready, review `--approve`, create
without `--draft`, edit `--add-reviewer` and an API merge. `gh -R o/r pr
view` is allowed.
- **Nesting cap:** a `gh pr ready` in the 63rd `$(…)` is judged and
blocked, one past the 64 pieces the gate follows is refused, and a gh
read beside 70 substitutions with no gh in them is allowed.
- **Heredoc GraphQL query:** ready-for-review is blocked; a read is
allowed.
- **Shell options before `-c`:** `-e -c`, `-euo pipefail -c`, `-o
pipefail -c`, `+o posix -c` and `--noprofile --norc -c` are all
followed, by the gh check and the push check alike. `bash --norc`
running a script and a read under `bash -o pipefail -c` are allowed.
  - **Against main's gate:** 49 of the 93 fail, under both bashes.
  - **Against earlier revisions of this branch:**
- The revision before the last round failed 6 of the 7 newest cases, the
`$(…)` ones above, under both bashes. Only "a bad title is still linted"
passed there.
- The round before that failed 12 of its 21 newest cases (cap, `-R`,
heredoc query, shell options), under both bashes.
- The one before that, under `/bin/bash` 3.2, gave 135 ok / 67 FAIL,
every failure "TK_SUBS[@]: unbound variable".
- [x] `make test-hook-commands` also caught the bash 3.2 bug: "the real
gate blocks gh pr ready" exited 1 instead of 2, in all three shells.
- [x] The inline guards pass shellcheck as `sh` and `dash`. `make
lint-sh` now also checks the extensionless `.githooks/` files.
- [x] Timing: measured on an earlier revision of this change (a 100 KB
double-quoted `--body`: 11 s with the old character loop, under 1 s
after); not re-measured after the per-call rewrite.

## Related Issues

Closes #764
Closes #678
Closes #787
Closes #790
Supersedes #766 (rebuilt on `main` after #785).
Part of #740

Based on `main`. The two PRs it was stacked on, #793 (`-trimpath`) and
#794 (the per-PR build cache), have merged. Gate forms review found but
left out of this PR, as less common than the ones it covers: #797.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation, site/, README area/infra CI, build, deploy, Docker, release documentation Improvements or additions to documentation

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

1 participant