From 13c66a23a1df0f273953b625758e008c254b0b97 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 11:40:34 -0700 Subject: [PATCH 1/3] State What the Highest-Tier Allowance Covers and Where the Guard Stops Requirement 10, the Claude kit README, and the guard's docstring each said an unreadable tier mapping refuses every dispatch naming a model, while a session launched with the allowance blocks no model and so needs none. Each now says so. The claim that an agent cannot set the allowance for itself is narrowed to what holds, that a tool call cannot change its own session's environment, and a nested session an agent launches with the allowance set is named as circumvention the guard does not tell apart. Co-Authored-By: Claude Opus 5.5 --- host-setup/agent-safety/README.md | 8 +++++--- host-setup/agent-safety/claude/README.md | 2 +- .../agent-safety/claude/model-tier-guard.py | 20 ++++++++++--------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index 8b1175eb..500317a7 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -112,15 +112,17 @@ Requirements 1 to 7 judge a command's text, read the way a simple lexer reads it 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. + agent never grants 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. 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 diff --git a/host-setup/agent-safety/claude/README.md b/host-setup/agent-safety/claude/README.md index 84b970a8..dabb60b2 100644 --- a/host-setup/agent-safety/claude/README.md +++ b/host-setup/agent-safety/claude/README.md @@ -15,7 +15,7 @@ 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/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-.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---`, 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--*` 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..bak` first and the install prints that path. `--report` names which of the two it found. diff --git a/host-setup/agent-safety/claude/model-tier-guard.py b/host-setup/agent-safety/claude/model-tier-guard.py index 86bbccea..2e3a5357 100755 --- a/host-setup/agent-safety/claude/model-tier-guard.py +++ b/host-setup/agent-safety/claude/model-tier-guard.py @@ -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 From edfc290c33b323aa78f594b70438805669f0c80b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 11:42:44 -0700 Subject: [PATCH 2/3] Narrow the Write-Grant Allowance Claim and State the Tier Allowance as Conduct Requirement 10 states that an agent never sets the tier allowance, for its own session or one it launches, as a conduct rule beside the nested-session bound it names. Requirement 3 and the Claude kit README make the same narrowing for the write-grant variable, that a tool call cannot change its own session's launch environment. Co-Authored-By: Claude Opus 5.5 --- host-setup/agent-safety/README.md | 4 ++-- host-setup/agent-safety/claude/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index 500317a7..15358ddc 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -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//` 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 @@ -112,7 +112,7 @@ Requirements 1 to 7 judge a command's text, read the way a simple lexer reads it 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 grants the allowance to itself. A mapping the hook cannot read refuses every + 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. diff --git a/host-setup/agent-safety/claude/README.md b/host-setup/agent-safety/claude/README.md index dabb60b2..7bab15ad 100644 --- a/host-setup/agent-safety/claude/README.md +++ b/host-setup/agent-safety/claude/README.md @@ -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 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-.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---`, 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--*` 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`, 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. From 625810e070cfb29c9dc92967020b2173d176e660 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 11:43:46 -0700 Subject: [PATCH 3/3] Split the Write-Grant Environment Sentence and Rewrap Requirement 10 Co-Authored-By: Claude Opus 5.5 --- host-setup/agent-safety/README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index 15358ddc..51dcdf6f 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -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. 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: +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//` 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 @@ -106,15 +106,15 @@ 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 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. +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