Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions host-setup/agent-safety/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ text says, because the harm it covers was never in the text.
4. **A git operation that would only succeed by bypassing an active branch rule is denied**: a
direct push to a branch whose rules require a pull request, a force-push where history is
protected, a delete where deletion is blocked, or an explicit-bypass flag (`--admin` on a merge,
`--no-verify` or a `-c core.hooksPath=` override on a commit/push). Judge branch-rule cases against that branch's *live* rules, so a
`--no-verify` or a `-c core.hooksPath=` override on a commit/push). An `include.path`/`includeIf`
override on a commit or push is denied as one too. The file it pulls in may set
`core.hooksPath` where the hook cannot read it. These flags are read in a command the hook
parses, a wrapper shell's body among them, and not inside a quoted `eval` argument. Judge branch-rule cases against that branch's *live* rules, so a
code-style `develop` denies and a config-style `develop` allows with no per-repo configuration.
Each refspec's operation is judged on its own, so a `+src` or a `:dst` refspec forces or
deletes only its own branch. A force-push and a delete each write the branch too, so a branch
Expand All @@ -68,8 +71,16 @@ text says, because the harm it covers was never in the text.
that case, a push whose target the hook cannot read at all is denied whatever branch it names.
That covers a push in text the hook does not parse and a push carrying a `-c`/`--config-env`
override. It covers a git command under an `include.path`/`includeIf` override too, where git
lists no such subcommand, since the included file may define it as a push alias. It also covers
a bare push or a `HEAD`/`@` refspec whose directory or branch may change before it runs. The
lists no such subcommand, since the included file may define it as a push alias. The same holds
where git does not build the subcommand in. An `--exec-path`, a `GIT_EXEC_PATH`, or a PATH
change can leave such a name to an alias. The override is also unreadable on a git command that
runs other git commands (`rebase`, `submodule`, `bisect`, `filter-branch`, `difftool`,
`mergetool`), whatever its arguments say. It reaches every git command that one runs. An `eval`
argument holding an include key is read as the command it runs. It also covers a bare push or a
`HEAD`/`@` refspec whose directory or branch may change before it runs. Such a push's local
branch is checked under its whole name, such as `release/1.0`. It is also checked with its first
segment dropped. A remote-tracking push target such as `origin/feature/x` is checked with its
remote dropped. The
workaround is an explicit refspec run as its own command. Every other
requirement here favors precision over recall throughout, denying only a positively-identified
dangerous shape, since a hook that fails closed on an unrelated resolution failure blocks
Expand Down Expand Up @@ -464,9 +475,10 @@ opt-in per clone, visible to anyone reading the repo, and bypassable on purpose,
rule whose harm is a quality miss rather than a destruction, which the host layer's bar excludes.
The hub's own `.husky/pre-push` is the worked example there, refusing a branch push that no
recorded local review pass covers, per [`GOVERNANCE.md`][governance] "Verification Discipline".
The two layers meet at requirement 4, which denies `--no-verify` and a `-c core.hooksPath=`
override unconditionally, so a Claude Code session meets a committed hook it
cannot wave through with either while a human keeps the escape hatch. They do not compose into a
The two layers meet at requirement 4, which denies `--no-verify`, a `-c core.hooksPath=`
override, and an include override on a commit or push unconditionally, wherever it reads them. So
a Claude Code session meets a committed hook it
cannot wave through with any of them while a human keeps the escape hatch. They do not compose into a
seal, and saying so would be the more comfortable claim rather
than the true one. A committed hook is bypassable by construction, since it cannot police its own
invocation, and `--no-verify` is the documented route rather than the only one. This requirement
Expand Down
2 changes: 1 addition & 1 deletion host-setup/agent-safety/claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ installation and operational detail. Codex and opencode have no equivalent hook

Into `~/.claude/` (or `%USERPROFILE%\.claude\` on Windows):

- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies four classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a state-changing `gh` call whose output is discarded, a GraphQL mutation passing a **literal** node id instead of a `$variable`, and a `gh` write whose explicit target is under an owner other than the checkout origin's. Sibling repositories under the same owner are allowed, since the harm this guards is reaching a stranger's repository rather than working across one maintainer's own fleet, and a different owner is allowed only when the maintainer names it in `GH_WRITE_GUARD_ALLOW` (an `owner/repo` list, where `owner/*` grants a whole owner). That variable is read from the environment the session was launched with, which is the one channel an agent cannot set for itself: a hook runs as its own process, so an inline `VAR=x cmd` prefix or an `export` inside a Bash call never reaches it. Second, a **git operation that bypasses a repository protection**. The branch-rule cases (a direct push, a force-push, or a branch delete to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged per refspec against the branch's **live** rules, so a code-style `develop` is denied while a config-style `develop` is allowed with no per-repo configuration. A push to a protected-default branch fails closed when its rules cannot be determined, either because the API is unreachable or because the checkout's origin cannot be resolved to query them. A push whose target the hook cannot read, such as one in text it does not parse, one carrying a `-c` config override, or a git command an `include.path`/`includeIf` override may define as a push alias, is denied whatever branch it names. The explicit-bypass flags are denied **unconditionally**, since the flag is itself the bypass and needs no branch query: `gh pr merge --admin` overrides the server-side merge gate, and `git commit`/`git push --no-verify` skips the local git hooks, as does a `git -c core.hooksPath=<dir>` override on either, which points git at another hooks directory. Third, a **mutating git operation run directly against a primary checkout** (`checkout`/`switch`/`pull`/`reset`/`rebase`/`merge`/`cherry-pick`/`revert`/`restore`/`add`/`commit`/`rm`/`mv`/`apply`/`am`/`push`/`stash` (anything but `list`/`show`)/`clean -f`/`worktree remove -f`), the harm behind a second incident where an agent reused the maintainer's own primary checkout instead of a worktree despite having read the prose rule against it. "Primary" is decided by comparing `git rev-parse --git-dir` against `--git-common-dir`, not a `.git`-is-a-directory guess. `worktree add`/`list`/`prune`, an unforced `worktree remove`, `merge --ff-only`/`pull --ff-only`, and a `checkout <ref>`/`switch <ref>` carrying no force flag (bundled or attached forms like `-qf`/`-Bname` recognized, not only an exact token) stay exempt even in a primary checkout, matching the documented base-clone cleanup step the `repo-worktree` skill already carries. A subcommand this rule does not recognize is resolved through a chain of git aliases, inline and persisted, before falling through, so a custom alias cannot hide a denied builtin behind an unrecognized name. A `!`-prefixed shell alias is denied outright rather than interpreted. A directory named with a `..` component is not resolved, so a mutating command naming one is denied. That holds for `-C`, any `cd`, `pushd`, `popd`, or `chdir` word in the command, `--work-tree`, `--git-dir`, and a `GIT_DIR=`/`GIT_WORK_TREE=` prefix. The target directory follows real git's own `-C`/`--work-tree`/`--git-dir` priority, not a last-option-wins scan. A different owner-style grant, `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT`, follows the same channel as `GH_WRITE_GUARD_ALLOW`. Reads and everything else pass through. Fourth, an **unbounded shell wait**, which the root spec's requirement 7 defines in full rather than this bullet redefining it. In outline: a loop that sleeps, carrying no bound the rule recognizes. It is the shape an agent writes when a brief tells it to poll. It is also the harm behind a third incident, where seven such loops outlived the subagents that wrote them and the run that dispatched those subagents. A shell started by a tool call runs in a session of its own, so the agent could not have reaped it even had it tried. A nested loop is judged on its own terms, and a command that forks anything out of a `timeout`'s reach, by backgrounding it or by `setsid`, is bounded by nothing. A heredoc body is data rather than a command line unless a shell is what it is fed to, so a document quoting the forbidden shape is written rather than denied. It fires even in autonomous / bypass-permissions sessions, which is how the first incident happened. See the root spec's requirements 6 and 7 for the exact rules.
- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies four classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a state-changing `gh` call whose output is discarded, a GraphQL mutation passing a **literal** node id instead of a `$variable`, and a `gh` write whose explicit target is under an owner other than the checkout origin's. Sibling repositories under the same owner are allowed, since the harm this guards is reaching a stranger's repository rather than working across one maintainer's own fleet, and a different owner is allowed only when the maintainer names it in `GH_WRITE_GUARD_ALLOW` (an `owner/repo` list, where `owner/*` grants a whole owner). That variable is read from the environment the session was launched with, which is the one channel an agent cannot set for itself: a hook runs as its own process, so an inline `VAR=x cmd` prefix or an `export` inside a Bash call never reaches it. Second, a **git operation that bypasses a repository protection**. The branch-rule cases (a direct push, a force-push, or a branch delete to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged per refspec against the branch's **live** rules, so a code-style `develop` is denied while a config-style `develop` is allowed with no per-repo configuration. A push to a protected-default branch fails closed when its rules cannot be determined, either because the API is unreachable or because the checkout's origin cannot be resolved to query them. A push whose target the hook cannot read, such as one in text it does not parse, one carrying a `-c` config override, a git command an `include.path`/`includeIf` override may define as a push alias, or a git command that runs other git commands under one, is denied whatever branch it names. The explicit-bypass flags are denied **unconditionally**, since the flag is itself the bypass and needs no branch query: `gh pr merge --admin` overrides the server-side merge gate, and `git commit`/`git push --no-verify` skips the local git hooks, as does a `git -c core.hooksPath=<dir>` override on either, which points git at another hooks directory, and an `include.path`/`includeIf` override on either, whose file may do the same. These are read in a command the hook parses, a wrapper shell's body among them, and not inside a quoted `eval` argument. Third, a **mutating git operation run directly against a primary checkout** (`checkout`/`switch`/`pull`/`reset`/`rebase`/`merge`/`cherry-pick`/`revert`/`restore`/`add`/`commit`/`rm`/`mv`/`apply`/`am`/`push`/`stash` (anything but `list`/`show`)/`clean -f`/`worktree remove -f`), the harm behind a second incident where an agent reused the maintainer's own primary checkout instead of a worktree despite having read the prose rule against it. "Primary" is decided by comparing `git rev-parse --git-dir` against `--git-common-dir`, not a `.git`-is-a-directory guess. `worktree add`/`list`/`prune`, an unforced `worktree remove`, `merge --ff-only`/`pull --ff-only`, and a `checkout <ref>`/`switch <ref>` carrying no force flag (bundled or attached forms like `-qf`/`-Bname` recognized, not only an exact token) stay exempt even in a primary checkout, matching the documented base-clone cleanup step the `repo-worktree` skill already carries. A subcommand this rule does not recognize is resolved through a chain of git aliases, inline and persisted, before falling through, so a custom alias cannot hide a denied builtin behind an unrecognized name. A `!`-prefixed shell alias is denied outright rather than interpreted. A directory named with a `..` component is not resolved, so a mutating command naming one is denied. That holds for `-C`, any `cd`, `pushd`, `popd`, or `chdir` word in the command, `--work-tree`, `--git-dir`, and a `GIT_DIR=`/`GIT_WORK_TREE=` prefix. The target directory follows real git's own `-C`/`--work-tree`/`--git-dir` priority, not a last-option-wins scan. A different owner-style grant, `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT`, follows the same channel as `GH_WRITE_GUARD_ALLOW`. Reads and everything else pass through. Fourth, an **unbounded shell wait**, which the root spec's requirement 7 defines in full rather than this bullet redefining it. In outline: a loop that sleeps, carrying no bound the rule recognizes. It is the shape an agent writes when a brief tells it to poll. It is also the harm behind a third incident, where seven such loops outlived the subagents that wrote them and the run that dispatched those subagents. A shell started by a tool call runs in a session of its own, so the agent could not have reaped it even had it tried. A nested loop is judged on its own terms, and a command that forks anything out of a `timeout`'s reach, by backgrounding it or by `setsid`, is bounded by nothing. A heredoc body is data rather than a command line unless a shell is what it is fed to, so a document quoting the forbidden shape is written rather than denied. It fires even in autonomous / bypass-permissions sessions, which is how the first incident happened. See the root spec's requirements 6 and 7 for the exact rules.
- **`hooks/tool-containment.py`**: a shell prefix, registered as `CLAUDE_CODE_SHELL_PREFIX` in the `env` block of `settings.json`, that runs every Bash tool call inside a transient systemd user scope carrying `TasksMax`, `MemoryMax`, and `MemorySwapMax=0`, the last so a runaway at the memory ceiling is killed rather than spilled into the host's swap. A runaway fan-out then fails at the ceiling with `fork: Resource temporarily unavailable` instead of growing until the host has to be reset, the harm behind the probe-shim incident in the root spec's requirement 9. The guard above reads the command text, and that incident's recursion lived in a script file the command only named. The ceilings default to 8192 tasks and 25% of RAM. A `claude` session started from a tool call is nested in the session that ran it. Every scope of such a chain also joins one slice, `claude-tree-<root session>.slice`. That slice carries a total ceiling for the whole tree, 32768 tasks and 50% of RAM. The root's id reaches nested sessions as `AGENT_CONTAINMENT_ROOT`, which every contained command inherits. The tree's ceiling is a runtime drop-in put on when its slice is first created, and the sweep below reverts it. The maintainer raises any ceiling in the same `env` block. An inline `VAR=x` in a command cannot change one, since the prefix reads it before the command runs. The variables are `AGENT_CONTAINMENT_TASKS_MAX` and `AGENT_CONTAINMENT_MEMORY_MAX` for a scope, and `AGENT_CONTAINMENT_TREE_TASKS_MAX` and `AGENT_CONTAINMENT_TREE_MEMORY_MAX` for the tree. A tree ceiling set below a scope's binds first. Each scope is named `claude-tool-<session>-<pid>-<random>`, so the sweep below can stop one session's tool calls and nothing else. Its description records the pid of the agent that ran it. That record is how the sweep tells a scope whose session has ended from one whose session still runs. The installer sets the prefix only where it can contain, per the Linux bullet under "Scope and Limits", and never over a `CLAUDE_CODE_SHELL_PREFIX` value that names anything else.
- **`hooks/stray-process-sweep.py`**: a SessionEnd hook in two phases. First, it stops every `claude-tool-<session>-*` scope this session left loaded and reports what each held, since the scope's name proves which session started it. It then stops every scope in this session's tree whose recorded agent is no longer running, and names each one's session. That is how a nested session's scopes end, since stopping the parent's scopes kills the nested `claude` before its own sweep runs. Each stop can end an agent nested deeper, so it judges again, up to four rounds. The root session then stops its tree's slice and reverts the ceiling put on it, once nothing is left in it. Second, it reports, and never ends, the process trees outside those scopes that outlive the session. The guard above stops a leak from being written. This finds the ones already running: the ones started before the guard was installed, and the shapes the guard deliberately does not reach. It reports every descendant of the agent process running outside the agent's own session. It names each subtree root's command and age, and hands over the `kill` line. This phase kills nothing itself. A long build backgrounded on purpose reads identically to a leaked wait from there. What is still wanted on the machine is the maintainer's call. A SessionEnd hook cannot block, and its JSON output is discarded, so stderr on exit 2 is the one channel that reaches the maintainer. The sweep uses that for a report rather than for a refusal. It registers under a group carrying no matcher, which fires on every exit reason. It also carries an explicit timeout, since the event's own budget is 1.5 seconds and only a declared per-hook timeout raises it. See the root spec's requirement 8.
- **The whole of `CLAUDE.md`, rendered on every run**, loaded into every session on the machine (including ad-hoc work outside any project). It holds a header, two marker-delimited blocks of host-wide content, and the host-local file. Nothing else survives an install. Content outside the blocks was never written or checked, and it restated fleet rules in wording the fleet had since changed.
Expand Down
Loading
Loading