Skip to content
6 changes: 3 additions & 3 deletions host-setup/agent-safety/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ A hook implementing these requirements bounds a mistake by an agent acting in go
Each requirement is stated as a decision rule. It is precise enough to implement against any agent's own hook or approval-gate API, and not tied to Claude Code's `PreToolUse` JSON shape. Requirement 8 is stated against the end of a session rather than against a tool call. That is because what it covers has already happened by the time any tool call is judged. Requirement 9 bounds every command whatever its
text says, because the harm it covers was never in the text.

Requirements 1 to 7 judge a command's text, read the way a simple lexer reads it. The text splits into commands on a newline, `;`, `&&`, `||`, `|`, and `&` outside quotes, honoring single and double quotes and backslash escapes. Each command is read as its words, with leading `VAR=value` assignments and redirections set aside. A `$(...)` or backtick substitution stays part of its word and is not read. A heredoc body is data, never commands. An `export VAR=value` or a `VAR=value` earlier in the text is the environment a later command sees. A `cd` or `pushd` earlier in the text moves the directory a later command runs in. Each of those ends at the closing parenthesis of a subshell holding it. A launcher such as `timeout`, `nohup`, `env`, or `xargs` is read past to the command it runs. An `env` is read as `env` applies it. Its `-C` moves the directory the command runs in, and its `-i` and `-u` clear what the command inherits. Its `-S` string is split into the words it runs. The literal string an `sh -c`, `bash -c`, `eval`, or `xargs ... sh -c` runs is read the same way. It is read one level deep and no deeper. A shape this reading does not reach is allowed, except under requirement 4.
Requirements 1 to 7 judge a command's text, read the way a simple lexer reads it. The text splits into commands on a newline, `;`, `&&`, `||`, `|`, and `&` outside quotes, honoring single and double quotes and backslash escapes. Each command is read as its words, with leading `VAR=value` assignments and redirections set aside. A `$(...)` or backtick substitution stays part of its word and is not read. A heredoc body is data, never commands. An `export VAR=value` or a `VAR=value` earlier in the text is the environment a later command sees. A `cd` or `pushd` earlier in the text moves the directory a later command runs in. Each of those ends at the closing parenthesis of a subshell holding it. A launcher such as `timeout`, `nohup`, `env`, or `xargs` is read past to the command it runs. An `env` is read as `env` applies it. Its `-C` moves the directory the command runs in, and its `-i` and `-u` clear what the command inherits. Its `-S` string is split into the words it runs. Each option it does not recognize, such as an abbreviation of `--chdir`, may or may not take the next word. A value it takes may be an `-S` string. Every combination of up to four of those choices is read. The literal string an `sh -c`, `bash -c`, `eval`, or `xargs ... sh -c` runs is read the same way. It is read one level deep and no deeper. A shape this reading does not reach is allowed, except under requirement 4.

1. **A GitHub write with its output discarded or forced to success is denied.** The write is a mutating `gh` call or a `git push`. Its output goes to `/dev/null` (`>/dev/null`, `2>/dev/null`, `&>/dev/null`), or `|| true`, `|| :`, or `|| echo` follows it. Either hides the one signal that tells a client-reported failure apart from a server-side success. Deny it, so the write runs again with its result visible. A tail on a wrapper shell or an `eval` counts for the write inside it. A tail the reading does not attach to the write, such as one on an enclosing group, is allowed.
2. **A GraphQL mutation carrying a literal GitHub node id is denied.** Node ids resolve globally, so a fabricated, stale, or hand-typed id can land on a real object in a different repository. A literal id is an uppercase-letter prefix such as `PR_`, `PRRT_`, `IC_`, or `BOT_` followed by a long body, or the legacy `MD`-prefixed base64 form. It is denied as a `-f`/`-F` field value of a `gh api graphql` mutation, and as a quoted string in the mutation's query text. A `-F name="$VAR"` value is allowed. A static hook cannot see where a variable's value came from, and this rule's job is the literal-id mistake. The fleet's prose rule (`GOVERNANCE.md` "Repository Boundaries and Write Safety") requires that the value came from a live query. A query read from a file, as in `query=@file.graphql`, is not read and is allowed.
Expand All @@ -36,14 +36,14 @@ Requirements 1 to 7 judge a command's text, read the way a simple lexer reads it
- an `--org`, `--owner`, or `-o` value other than `@me`

When no `origin` resolves, no owner exists to compare against, and this requirement does not fire. A target held in a run-time word, as in `--repo "$T"`, is not read and is allowed.
4. **A git operation that would only succeed by bypassing an active branch rule is denied.** The explicit bypasses are denied whatever the branch, since each is the bypass itself. They are `--admin` on `gh pr merge` and `--no-verify` on a commit or push. They include a commit's `-n`, alone or in a short-option cluster such as `-an`. They include a `-c` override of `core.hooksPath` or of an include (`include.path`, `includeIf`) on a commit or push. A push is also judged against the *live* rules of each branch it writes. Those rules deny a direct push to a branch whose rules require a pull request. They deny a force-push where history is protected, and a delete where deletion is blocked. So a code-style `develop` denies and a config-style `develop` allows with no per-repo configuration. A force-push and a delete each write the branch too. So a branch whose rules require a pull request denies them as it denies a direct push. A push writes the branch each refspec names, read as git reads `src:dst`, `+src`, `:dst`, `HEAD`, and a `refs/heads/` destination. A push naming no refspec and no `--tags` writes the branch checked out in the repository git uses, as read below. `--force`, `--delete`, `--all`, and `--mirror` apply as git applies them, and `--all` or `--mirror` is read as writing every branch protected by default. The rules are those of the repository the push's remote writes, read from the remote's push URL or from a URL the push names. A remote's URL is read in the repository git itself uses. That is the one a `--git-dir`, or a `GIT_DIR` set on the push itself, names, or else the one where the push runs. A `--work-tree` does not move it. A `-c` or `--config-env` override of a `remote.` or `url.` key can change that URL in a way the reading does not apply. So can a variable set in the text whose name starts with `GIT_CONFIG`. So does a `--git-dir` or `GIT_DIR` the reading does not resolve. So does a `GIT_DIR` set earlier in the text, since whether git sees it turns on an export or an `unset`. Each leaves the repository undetermined. **This one fails closed, but only for a branch protected by default** (`main`, `master`, `develop`). When that branch's rules cannot be determined at all (network unreachable, origin unresolvable), deny rather than allow. That is because the harm is a silent success under the maintainer's own admin bypass. A push to any other branch whose rules cannot be determined passes, since there is nothing yet on record to bypass. A refspec naming no branch the reading resolves, such as a run-time word or a pattern, is allowed. So is a push naming no refspec whose repository the reading does not determine. So is a push an alias or a script runs.
4. **A git operation that would only succeed by bypassing an active branch rule is denied.** The explicit bypasses are denied whatever the branch, since each is the bypass itself. They are `--admin` on `gh pr merge` and `--no-verify` on a commit or push. They include a commit's `-n`, alone or in a short-option cluster such as `-an`. They include a `-c` override of `core.hooksPath` or of an include (`include.path`, `includeIf`) on a commit or push. A push is also judged against the *live* rules of each branch it writes. Those rules deny a direct push to a branch whose rules require a pull request. They deny a force-push where history is protected, and a delete where deletion is blocked. So a code-style `develop` denies and a config-style `develop` allows with no per-repo configuration. A force-push and a delete each write the branch too. So a branch whose rules require a pull request denies them as it denies a direct push. A push writes the branch each refspec names, read as git reads `src:dst`, `+src`, `:dst`, `HEAD`, and a `refs/heads/` destination. A push naming no refspec and no `--tags` writes the branch checked out in the repository git uses, as read below. `--force`, `--delete`, `--all`, and `--mirror` apply as git applies them, and `--all` or `--mirror` is read as writing every branch protected by default. The rules are those of the repository the push's remote writes, read from the remote's push URL or from a URL the push names. A remote's URL is read in the repository git itself uses. That is the one a `--git-dir`, or a `GIT_DIR` set on the push itself, names, or else the one where the push runs. A `--work-tree` does not move it. A `-c` or `--config-env` override of a `remote.` or `url.` key can change that URL in a way the reading does not apply. So can a variable set in the text whose name starts with `GIT_CONFIG`. So does a `--git-dir` or `GIT_DIR` the reading does not resolve. So does a `GIT_DIR` set earlier in the text, since whether git sees it turns on an export or an `unset`. So does an unrecognized `env` option, since it may move the push. Each leaves the repository undetermined. Behind such an option, an absolute `--git-dir`, git `-C`, or `cd` in a wrapper body still names it. **This one fails closed, but only for a branch protected by default** (`main`, `master`, `develop`). When that branch's rules cannot be determined at all (network unreachable, origin unresolvable), deny rather than allow. That is because the harm is a silent success under the maintainer's own admin bypass. A push to any other branch whose rules cannot be determined passes, since there is nothing yet on record to bypass. A refspec naming no branch the reading resolves, such as a run-time word or a pattern, is allowed. So is a push naming no refspec whose repository the reading does not determine. So is a push an alias or a script runs. A push naming no refspec or `HEAD` behind an unrecognized `env` option is the exception. When nothing else names its branch, it is read as if the option were absent.
5. **A hand-rolled reply or resolve on a review thread, bypassing the one-call helper, is denied (where a helper exists).** **It is not denied when the maintainer's cross-owner grant already covers it.** Splitting a reply and a resolve into two separate hand-run API calls is what let a reply sit unresolved across a push, reading as untriaged. A `resolveReviewThread` mutation or a POST to the review-comment replies endpoint is denied in favor of a single documented helper. This holds where the agent's fleet ships one (this repo's `scripts/pr_review.py reply --resolve`). The one exception is a target the maintainer has already granted this session. The helper itself refuses a cross-owner pull request outright. So the hand-run form is then the documented fallback for that specific repository. That fallback is allowed through the same grant channel requirement 3 reads. A REST reply's own URL is checked against the grant. A `resolveReviewThread` mutation's thread id is opaque, so any active grant is the only signal available there, a residual gap this requirement accepts. A mutation the reading cannot see, such as one in a file or behind `--input`, is allowed.
6. **A mutating git operation run directly against a primary checkout is denied.** "Primary" means not a linked worktree. The decidable test is a comparison, not a filesystem-shape guess. `git rev-parse --path-format=absolute --git-dir --git-common-dir` returns equal paths for a primary checkout and unequal paths for a linked worktree. A `.git`-is-a-directory heuristic is wrong, since a submodule's `.git` is a file yet still a primary working tree that can lose uncommitted work.
- **Denied there:** `reset`, `rebase`, `cherry-pick`, `revert`, `restore`, `add`, `commit`, `rm`, `mv`, `apply`, and `am`, whatever their arguments. Also denied are a `push` other than a delete-only one and a `merge` or `pull` without `--ff-only`. So are a `stash` other than `list` or `show`, a forcing `clean`, and a forced `worktree remove`. A `push` that writes a branch does not touch the working tree. It is denied because no documented fleet workflow pushes a branch from a primary checkout.
- **Allowed there:** any read, `worktree add`, `list`, and `prune`, an unforced `worktree remove`, `merge --ff-only` and `pull --ff-only`. Also allowed is a `clean` carrying `-n` or `--dry-run` before any `--`. A `checkout` or `switch` is allowed with exactly one positional argument, either `-` or one that resolves as a ref (`git rev-parse --verify --quiet <ref>^{commit}`). It must also carry no force flag. A force flag (`-b`, `-B`, `-c`, `-C`, `-f`, `--force`, `--discard-changes`, `--orphan`) is recognized bundled into a short-option cluster such as `-qf`. It is also recognized attached, as in `-Bname`. A `--`, more than one positional argument, or one that is not a ref is a pathspec restore. It is denied, since git's own refusal to overwrite a local change covers only the ref switch. These exemptions are the documented way an agent uses a primary checkout as a fetch source and returns it to a base branch. The incident behind this requirement ran exactly that shape, an unforced `checkout` then an `--ff-only` pull. The concurrent-access hazard those commands still carry stays the prose rule's job (`GOVERNANCE.md` "Repository Boundaries and Write Safety", `repo-worktree`).
- **Also allowed there:** a delete-only `push`. Each refspec it carries deletes a remote ref named other than `main`, `master`, or `develop`. It is spelled `--delete <name>` (or `-d`) or `:<name>`, with a plain, `heads/`, or `refs/heads/` name, so a `refs/tags/` spelling denies. Its remote is a name of letters, digits, `_`, and `-`, never a path or URL. It carries no option but `--delete`, `--dry-run`, `--quiet`, and `--verbose` (or their short forms), and no `-c` override. It moves no local branch and touches no working tree, so the hazard this requirement guards is absent. Requirement 4 still judges each branch's live rules, so a branch whose rules forbid deletion still denies. No skill prescribes this push, since the fleet's `cleanup` helper is the delete path that holds the checks.

A git command targets the session's working directory. Each `cd` or `pushd` earlier in the text moves it. Then the last `-C` of each `env` launching the command moves it, and then each `-C` on the command does, as git composes them. `~` and `$HOME` are expanded. On Windows a Git Bash drive spelling such as `/c/repos/x` reads as `C:/repos/x`, and a rooted path with no drive is not resolved. Fail open (allow) when no git repository resolves there, since the harm needs a positively identified primary checkout. A directory the reading does not resolve, such as a run-time `-C "$D"` value or a `cd -`, is not followed. A command there is allowed. A `--git-dir`, a `--work-tree`, a `GIT_DIR`, and an alias are not read. The grant is `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT`, read from the same session-start environment as requirement 3's. It is a boolean rather than an `owner/repo` list, granted by any value but a falsy one ("0", "false", "no", "off", or empty).
A git command targets the session's working directory. Each `cd` or `pushd` earlier in the text moves it. Then the last `-C` of each `env` launching the command moves it, and then each `-C` on the command does, as git composes them. An `env` option the reading does not recognize leaves it where it was. `~` and `$HOME` are expanded. On Windows a Git Bash drive spelling such as `/c/repos/x` reads as `C:/repos/x`, and a rooted path with no drive is not resolved. Fail open (allow) when no git repository resolves there, since the harm needs a positively identified primary checkout. A directory the reading does not resolve, such as a run-time `-C "$D"` value or a `cd -`, is not followed. A command there is allowed. A `--git-dir`, a `--work-tree`, a `GIT_DIR`, and an alias are not read. The grant is `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT`, read from the same session-start environment as requirement 3's. It is a boolean rather than an `owner/repo` list, granted by any value but a falsy one ("0", "false", "no", "off", or empty).
7. **A shell wait carrying no bound is denied.** `until <condition>; do sleep <n>; done` is what an agent writes when it is told to poll. So is `while ! <condition>; do sleep <n>; done`. Both run until the machine is rebooted when the condition never comes true. The shell is not the agent's to end either. A shell started by a tool call runs in a session of its own. So it outlives the turn, the subagent, and the run that started it. And nothing reaps it. Deny a `while` or `until` loop, or a `for ((;;))`, whose condition or body calls `sleep`, unless it carries one of these bounds. A worker reproduces a quoted shape and not an adjective, so each is named exactly.
- **A `timeout` running the wrapper that holds the loop**, as in `timeout 600 bash -c '<the loop>'`. That placement is the only one that works, since `timeout` takes a command and a loop keyword is not one. A wrapper body that forks work out of the timeout's reach, by a `&` or a `setsid`, is not bounded by it. A `timeout` the command backgrounds as a whole, as in `timeout 900 bash -c '<the loop>' &`, still bounds the loop.
- **An arithmetic guard in the loop's own condition**, a test with `-lt`, `-le`, `-gt`, or `-ge` such as `[ "$i" -lt 120 ]`. An arithmetic comparison such as `(( SECONDS < 600 ))` is also such a guard. A `for` loop over a word list, or an arithmetic `for` whose condition compares, is bounded the same way.
Expand Down
Loading
Loading