scripts
Two scripts that work in any repository, whatever its language.
| Script | What | Needs |
|---|---|---|
guard |
The worker guard: a hook that runs before each of a worker’s commands and refuses what docs/protocol.md § The guard forbids |
POSIX sh, git, jq |
validate-handoff |
Checks handoff/<epic>.json against schemas/handoff.schema.json, and what a schema cannot state |
Python 3, jsonschema |
Both read control-room.json at the repository root, if there is one:
{"integration_branch": "main", "branch_prefix": "claude/"}
Both keys are optional; those are the defaults. The file may hold other keys (the project’s profiles, for example); the scripts ignore them.
guard
What it refuses
| Rule | Examples it denies |
|---|---|
| A push to anything but the current branch | git push origin claude/other, git push origin HEAD:main, git -C ../x push origin main |
| A push to the integration branch, or to a branch outside the prefix | git push origin main even while on main |
| A push with no explicit refspec | git push, git push -u origin |
| Force pushes | --force, -f, --force-with-lease, --force-if-includes, +branch |
| Tags | git push --tags, --follow-tags, refs/tags/..., git tag v1, gh release create |
| Branch deletions | git push origin :branch, --delete, -d, --prune, git branch -d/-D |
| Merging a pull request | gh pr merge, gh api .../pulls/N/merge, MCP merge_pull_request, enable_pr_auto_merge |
| A pull request against another base | MCP create_pull_request or update_pull_request with base other than the integration branch, gh pr create --base other |
| MCP writes to another branch | push_files, create_or_update_file, delete_file with branch other than the current one; delete_branch |
It reads chains (&&, ||, ;, |, subshells, $(...) and backquotes),
sh -c '...', bash -lc '...' and eval, and follows a cd <dir>,
git -C <dir> or git switch -c <branch> / git checkout -b <branch>
earlier in the same command. Everything else passes through, including
commands that only mention a forbidden one (echo "git push origin main").
A denial is one line on stderr, exit status 2:
guard: denied: a push to the integration branch main. Instead: push your own branch by name (git push origin claude/cr-1-x); the coordinator lands it
and the same reason on stdout as a PreToolUse permissionDecision of deny.
Wiring it into Claude Code
Copy scripts/guard into the project (here, scripts/guard) and add this to
the project’s .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|mcp__.*",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/scripts/guard"
}
]
}
]
}
}
What this relies on, checked against the Claude Code hooks reference (https://code.claude.com/docs/en/hooks, read 2026-09-24):
- a PreToolUse hook gets the event as JSON on stdin, with
tool_name,tool_input(for Bash,tool_input.command) andcwd; - MCP tools are named
mcp__<server>__<tool>, andmatcheris a regular expression over the tool name; - exit status 2 blocks the tool call, and the blocking message is the JSON decision’s reason when there is one and stderr otherwise;
$CLAUDE_PROJECT_DIRis the project root.
Not verified by a run in a live session yet: that is CR-2’s pilot.
Where a runner has no pre-command hook
Use the same script as a git pre-push hook. It then sees only pushes (not
gh pr merge or MCP tools), but it sees every push, however it was typed:
mkdir -p .githooks
printf '#!/bin/sh\nexec "$(git rev-parse --show-toplevel)/scripts/guard" --git-pre-push "$@"\n' >.githooks/pre-push
chmod +x .githooks/pre-push
git config core.hooksPath .githooks
In this mode it refuses a push to anything but the current worker branch, a tag, a deletion, and a push that is not a fast-forward of the remote branch.
What it does not do
It is a guard against confusion, not a permission system: the session holds the owner’s credentials, and a worker set on getting around it can. It reads commands the way a careful person would, not the way a shell does, so it does not see through:
- git aliases (
git -c alias.p=push p ..., or a[alias]in the config); - variables and functions (
$GIT push,f() { git push "$@"; }; f); - a command substitution inside double quotes (
echo "$(git push origin main)"); - scripts it runs (
./deploy.shthat pushes); - raw API calls other than the merge endpoint (
curl,gh apitogit/refs).
The pre-push fallback closes the first four for pushes, unless the push says
--no-verify, which skips git’s hooks. A worker the guard
stops records the question in its handoff and ends BLOCKED; it does not
route around it.
validate-handoff
scripts/validate-handoff handoff/proj-12.json
scripts/validate-handoff --config control-room.json --schema schemas/handoff.schema.json handoff/*.json
Beyond the schema it checks:
- the file is named for its
epic; branchstarts with the configured prefix;- a
readyhandoff has alanewhose verdict ispassed, and no child isopen; - a
deferredchild has a non-blanknote, and areparentedchild ato; - every sha (
base,lane.head,children[].commits) is 40 lowercase hex characters.
It prints one line per problem, FILE: $.json.path: message, and exits 1 if
there was any, 0 if every file is valid, and 2 on a usage error or when
jsonschema is missing.
Why Python and jsonschema, not sh and jq. The schema is the contract, so a
real JSON Schema implementation checks it. Rewriting in jq the keywords the
schema happens to use today would be a second, partial implementation that
drifts from the schema without anyone noticing. The cost is Python 3 and one
package (pip install jsonschema, or python3-jsonschema on Debian and
Ubuntu).
Checks
make check runs shellcheck on the guard and the tests, both bats suites,
and the guard’s suite again under bash. .github/workflows/check.yml runs it
on every pull request and on main.