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
22 changes: 12 additions & 10 deletions host-setup/agent-safety/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Requirements 1 to 7 judge a command's text, read the way a simple lexer reads it

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.
3. **A GitHub write with an explicit target outside the checkout's own owner is denied, unless the maintainer granted it.** Each target the write names is compared against the owner of the checkout's `origin`. A sibling repository under the same owner is allowed with no grant, since the harm this guards is reaching a stranger's repository, not working across one maintainer's own fleet. A different owner is allowed only when named in a grant read from the environment the session was launched with. That is a channel the agent cannot set for itself, so an inline `VAR=x cmd` prefix or an `export` in the same call never satisfies it. A write is a known-mutating `gh` subcommand, a `gh api` call whose method is not GET, or a GraphQL mutation. It names a target by any of these:
3. **A GitHub write with an explicit target outside the checkout's own owner is denied, unless the maintainer granted it.** Each target the write names is compared against the owner of the checkout's `origin`. A sibling repository under the same owner is allowed with no grant, since the harm this guards is reaching a stranger's repository, not working across one maintainer's own fleet. A different owner is allowed only when named in a grant read from the environment the session was launched with. A tool call cannot change that environment for its own session. So an inline `VAR=x cmd` prefix or an `export` in the same call never satisfies it. A write is a known-mutating `gh` subcommand, a `gh api` call whose method is not GET, or a GraphQL mutation. It names a target by any of these:
- an `-R`/`--repo` value in any of gh's spellings, read by the owner in `[HOST/]OWNER/REPO`
- a `repos/<owner>/<repo>` endpoint on a `gh api` write
- a `GH_REPO` value set before a write carrying no repo flag, in its prefix, earlier in the text, or in the session's environment
Expand Down Expand Up @@ -106,21 +106,23 @@ Requirements 1 to 7 judge a command's text, read the way a simple lexer reads it
them. They bound an accident rather than an adversary, since a command can still raise its own
group's limits or start a group outside it, and the incident this answers was a mistake.

10. **A subagent dispatch at the maintainer-only model tier is denied, unless the maintainer
allowed it when the session started.** A skill named the `frontier` tier in a repository whose
instruction set carried no tier table. It was taken as "the frontier model", and the work ran
on the most capable and most expensive model nobody had asked for. Read the tier mapping from
the hub's `spec/model-tiers.json`, deployed beside the hook. Refuse a dispatch naming the
maintainer-only tier's model unless the session was launched with that tier allowed. An
agent can never grant the allowance to itself. A mapping the hook cannot read refuses every
dispatch naming a model, since a guard that cannot tell what to block must not go quiet.
10. **A subagent dispatch at the maintainer-only model tier is denied, unless the maintainer allowed
it when the session started.** A skill named the `frontier` tier in a repository whose
instruction set carried no tier table. It was taken as "the frontier model", and the work ran on
the most capable and most expensive model nobody had asked for. Read the tier mapping from the
hub's `spec/model-tiers.json`, deployed beside the hook. Refuse a dispatch naming the
maintainer-only tier's model unless the session was launched with that tier allowed. An agent
never sets the allowance, for its own session or for one it launches. A mapping the hook cannot
read refuses every dispatch naming a model, since a guard that cannot tell what to block must
not go quiet. A session launched with the allowance blocks no model, so it needs no mapping.

This one guards the maintainer's choice of model and his quota rather than the credentials or
the machine. It sits in the host layer all the same, because a dispatch happens inside the
harness, where no hook a repository carries can see it. It refuses only a dispatch that names
the model. A saved workflow, a child workflow a script starts, a computed model, and an agent
definition pinning one all pass. So do a dispatch naming no model and a fork naming another
model, which run at a model the hook cannot read.
model, which run at a model the hook cannot read. So does a nested session an agent launches
with the allowance set, which is circumvention rather than the mistake this guards.

## Decision Flow

Expand Down
4 changes: 2 additions & 2 deletions host-setup/agent-safety/claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ 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 the plain shapes of four classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a GitHub write whose output is discarded or forced to success, 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**: `gh pr merge --admin`, a commit or push that skips the hooks by `--no-verify` or by a `core.hooksPath` or include override, and a push the branch's **live** rules forbid. 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. Third, a **mutating git operation run directly against a primary checkout**, 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. `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT` grants an exception through the same channel as `GH_WRITE_GUARD_ALLOW`. Fourth, an **unbounded shell wait**, a loop that sleeps with no bound, the harm behind a third incident where seven such loops outlived the subagents that wrote them and the run that dispatched those subagents. The hook reads a command with one simple lexer and denies a shape only where it appears plainly. A shape it does not read is allowed, per the root spec's threat model. It fires even in autonomous / bypass-permissions sessions, which is how the first incident happened. See the root spec's requirements 1 to 7 for the exact rules.
- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies the plain shapes of four classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a GitHub write whose output is discarded or forced to success, 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 a tool call inside that session cannot change. 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**: `gh pr merge --admin`, a commit or push that skips the hooks by `--no-verify` or by a `core.hooksPath` or include override, and a push the branch's **live** rules forbid. 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. Third, a **mutating git operation run directly against a primary checkout**, 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. `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT` grants an exception through the same channel as `GH_WRITE_GUARD_ALLOW`. Fourth, an **unbounded shell wait**, a loop that sleeps with no bound, the harm behind a third incident where seven such loops outlived the subagents that wrote them and the run that dispatched those subagents. The hook reads a command with one simple lexer and denies a shape only where it appears plainly. A shape it does not read is allowed, per the root spec's threat model. It fires even in autonomous / bypass-permissions sessions, which is how the first incident happened. See the root spec's requirements 1 to 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.
- **`hooks/model-tier-guard.py`** and **`hooks/model-tiers.json`**: a PreToolUse hook that denies a subagent dispatch at the maintainer-only model tier, today `fable`. It reads the tier mapping from the `model-tiers.json` beside it, a copy of the hub's `spec/model-tiers.json` the installer makes. It checks an `Agent` call's `model` and a `Workflow` script's `model` options, from the inline `script` and the file at `scriptPath`. A saved workflow named by `name` alone, or a child workflow a script starts, cannot be inspected and is allowed. A dispatch naming no model runs at a model the hook cannot read, and so does a fork naming another model, so both are allowed. The maintainer allows the tier by launching the session with `FLEET_ALLOW_HIGHEST=1`, a channel an agent cannot set for itself. Any other value stays denied, and the deny text never suggests setting it. Where the maintainer asked for the tier, the deny text says to stop and report rather than dispatch lower. Otherwise it names the default tier's model. A mapping that is missing or unreadable denies any call naming a model and says to re-run the installer, so the guard never goes quiet. It registers in its own `Agent|Workflow` matcher group rather than the Bash one.
- **`hooks/model-tier-guard.py`** and **`hooks/model-tiers.json`**: a PreToolUse hook that denies a subagent dispatch at the maintainer-only model tier, today `fable`. It reads the tier mapping from the `model-tiers.json` beside it, a copy of the hub's `spec/model-tiers.json` the installer makes. It checks an `Agent` call's `model` and a `Workflow` script's `model` options, from the inline `script` and the file at `scriptPath`. A saved workflow named by `name` alone, or a child workflow a script starts, cannot be inspected and is allowed. A dispatch naming no model runs at a model the hook cannot read, and so does a fork naming another model, so both are allowed. The maintainer allows the tier by launching the session with `FLEET_ALLOW_HIGHEST=1`, which a tool call inside that session cannot change. Any other value stays denied, and the deny text never suggests setting it. Where the maintainer asked for the tier, the deny text says to stop and report rather than dispatch lower. Otherwise it names the default tier's model. A mapping that is missing or unreadable denies any call naming a model and says to re-run the installer, so the guard never goes quiet. A session launched with the allowance blocks no model, so it needs no mapping. It registers in its own `Agent|Workflow` matcher group rather than the Bash one.
- **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.
- The host-local file is `~/.config/agent-fleet/local.md`, under `XDG_CONFIG_HOME` where that is set to an absolute path. The installer appends it under a `host-local` marker and never edits it. It is one file for every agent, so a Codex or opencode global file rendered later appends the same text.
- The stamp records a digest of the file each install wrote. A file matching it is an earlier render and is replaced silently. Any other file was edited by hand, or predates whole-file ownership, so it is copied to `CLAUDE.md.<UTC time>.bak` first and the install prints that path. `--report` names which of the two it found.
Expand Down
20 changes: 11 additions & 9 deletions host-setup/agent-safety/claude/model-tier-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@
to its AGENTS.md tier table, never to this hook. The hub's spec gate holds the two equal.

The maintainer allows the tier for a session by launching it with FLEET_ALLOW_HIGHEST=1 in the
environment. A hook runs as its own process, so an agent cannot set it for itself, and the deny
text never suggests that it try. The value must be exactly `1`, so a loose truthy spelling stays
denied.

It bounds a good-faith agent's mistake rather than an adversary. A workflow script is read as
text, so a model chosen by a computed expression is not seen. A saved workflow named by `name`
alone cannot be inspected and is allowed. So is a child workflow a script starts by name or path,
since only the top-level script is read. A dispatch naming no model runs at a model this hook cannot
read, and so does a fork naming another model, so both are allowed.
environment. A hook inherits the environment its session was launched with. A tool call inside that
session cannot change it, and the deny text never suggests that it try. The value must be exactly
`1`, so a loose truthy spelling stays denied. A session launched with it blocks no model, so it
needs no mapping either.

It bounds a good-faith agent's mistake rather than an adversary. A nested `claude` an agent launches
with the variable set is not told apart, since that is circumvention rather than a mistake. A
workflow script is read as text, so a model chosen by a computed expression is not seen. A saved
workflow named by `name` alone cannot be inspected and is allowed. So is a child workflow a script
starts by name or path, since only the top-level script is read. A dispatch naming no model runs at
a model this hook cannot read, and so does a fork naming another model, so both are allowed.

A config that is missing, unreadable, or lacks the keys cannot say which model is blocked. A call
naming any model is then denied, so the guard never goes quiet without saying so, and a call naming
Expand Down
Loading