diff --git a/.github/actions/validate/action.yml b/.github/actions/validate/action.yml index caf2ed79..f7792dad 100644 --- a/.github/actions/validate/action.yml +++ b/.github/actions/validate/action.yml @@ -33,8 +33,8 @@ runs: uvx coverage@latest run --source=scripts,spec,host-setup -m unittest discover -s scripts/tests uvx coverage@latest run --source=scripts,spec,host-setup --append spec/audit.py --selftest uvx coverage@latest run --source=scripts,spec,host-setup --append spec/workflow_reuse.py --selftest - uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/gh-write-guard.py --selftest - uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/test_install.py + uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/claude/gh-write-guard.py --selftest + uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/claude/test_install.py uvx coverage@latest report # Read-only: fails if a generated skill distribution differs from .agents/skills/. diff --git a/OPERATIONS.md b/OPERATIONS.md index 28e81e80..15dce873 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -23,8 +23,8 @@ uvx ruff@latest format --check . uvx mypy@latest uvx coverage@latest run --source=scripts,spec,host-setup -m unittest discover -s scripts/tests uvx coverage@latest run --source=scripts,spec,host-setup --append spec/audit.py --selftest -uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/gh-write-guard.py --selftest -uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/test_install.py +uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/claude/gh-write-guard.py --selftest +uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/claude/test_install.py uvx coverage@latest report python3 scripts/build_dist.py --check python3 scripts/repo_gate.py diff --git a/README.md b/README.md index ab0b8676..20141926 100644 --- a/README.md +++ b/README.md @@ -163,21 +163,24 @@ Four things are deployed from here, and they land in different places. The host The guardrails are **host state rather than repository content**, because they have to cover ad-hoc sessions in no project at all. Each provider's implementation stays separate: -- **Claude Code:** the installable safety kit denies a mis-targeted GitHub write under your identity. It also denies a git operation that would bypass a branch rule. -- **Codex:** no equivalent host hook ships yet. The carried repository rules and Codex's own sandbox and execution policies remain active. [Issue #781][issue-781] tracks the missing hook. -- **opencode:** no equivalent host hook ships yet. The carried repository rules and opencode's own permission model remain active. [Issue #781][issue-781] tracks the missing hook. +- **Claude Code:** the installable safety kit denies a mis-targeted GitHub write under your identity. It also denies a git operation that would bypass a branch rule. [`host-setup/agent-safety/claude/README.md`][agent-safety-claude] has the details. +- **Codex:** no equivalent host hook ships yet. The carried repository rules and Codex's own sandbox and execution policies remain active. [Issue #781][issue-781] tracks the missing hook, and [`host-setup/agent-safety/codex/README.md`][agent-safety-codex] states the gap. +- **opencode:** no equivalent host hook ships yet. The carried repository rules and opencode's own permission model remain active. [Issue #781][issue-781] tracks the missing hook, and [`host-setup/agent-safety/opencode/README.md`][agent-safety-opencode] states the gap. + +The requirements every agent's kit is built and audited against, agent-agnostic, are in +[`host-setup/agent-safety/README.md`][agent-safety]. #### Claude Code ```shell -host-setup/agent-safety/install.sh # Linux, WSL, macOS +host-setup/agent-safety/claude/install.sh # Linux, WSL, macOS ``` ```powershell -.\host-setup\agent-safety\install.ps1 # Windows, and the .\ prefix is required +.\host-setup\agent-safety\claude\install.ps1 # Windows, and the .\ prefix is required ``` -Restart Claude Code sessions on the machine afterward so the hook and the `CLAUDE.md` blocks load. The installer is idempotent, so re-running it is also how a machine picks up an upstream change to the guard. What it installs, how to verify it, and what it deliberately does not catch are in [`host-setup/agent-safety/README.md`][agent-safety]. The surrounding host prerequisites are in [`docs/host-setup.md`][host-setup]. +Restart Claude Code sessions on the machine afterward so the hook and the `CLAUDE.md` blocks load. The installer is idempotent, so re-running it is also how a machine picks up an upstream change to the guard. What it installs, how to verify it, and what it deliberately does not catch are in [`host-setup/agent-safety/claude/README.md`][agent-safety-claude]. The surrounding host prerequisites are in [`docs/host-setup.md`][host-setup]. ### Install the Fleet Skills @@ -220,7 +223,7 @@ A rule that cannot be diverged from is a rule people work around silently, which The guard denies a `gh` write whose explicit target sits under an owner other than the checkout's `origin` owner, which is the shape that once put a stray comment on a stranger's repository. Sibling repositories under the same owner are allowed, so the denial appears only on a write that leaves the owner, and the common case that raises it is a fork, where `origin` is yours and `upstream` is the project you forked from. -The only way past it is a grant the maintainer makes **outside the session**, in `GH_WRITE_GUARD_ALLOW`. It is deliberately not something an agent can do for itself once blocked, so an inline `GH_WRITE_GUARD_ALLOW=owner/repo gh ...` prefix and an `export` inside a shell call both leave the write denied. The worked example, the file the grant goes in, and how to confirm one took effect are in [`docs/host-setup.md` "Granting a Write the Guard Denies"][host-setup-granting-a-write-the-guard-denies]. +The only way past it is a grant the maintainer makes **outside the session**, in `GH_WRITE_GUARD_ALLOW`. It is deliberately not something an agent can do for itself once blocked, so an inline `GH_WRITE_GUARD_ALLOW=owner/repo gh ...` prefix and an `export` inside a shell call both leave the write denied. The worked example, the file the grant goes in, and how to confirm one took effect are in [`host-setup/agent-safety/claude/README.md` "Granting a Write That the Guard Denies"][agent-safety-claude-grant]. ## Rules @@ -342,6 +345,10 @@ Licensed under the [MIT License][license]\ [agent-safety]: ./host-setup/agent-safety/README.md +[agent-safety-claude]: ./host-setup/agent-safety/claude/README.md +[agent-safety-claude-grant]: ./host-setup/agent-safety/claude/README.md#granting-a-write-that-the-guard-denies +[agent-safety-codex]: ./host-setup/agent-safety/codex/README.md +[agent-safety-opencode]: ./host-setup/agent-safety/opencode/README.md [agents]: ./AGENTS.md [audit]: ./AUDIT.md [catalog]: ./catalog/ @@ -364,7 +371,6 @@ Licensed under the [MIT License][license]\ [history]: ./HISTORY.md [host-setup]: ./docs/host-setup.md [host-setup-dir]: ./host-setup/ -[host-setup-granting-a-write-the-guard-denies]: ./docs/host-setup.md#granting-a-write-the-guard-denies [issue-781]: https://github.com/ptr727/ProjectTemplate/issues/781 [license]: ./LICENSE [matrix]: ./reports/conformance-matrix.md diff --git a/TODO.md b/TODO.md index ee31d2cf..703502d5 100644 --- a/TODO.md +++ b/TODO.md @@ -578,4 +578,4 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w [validate-task]: ./.github/workflows/validate-task.yml [workflow]: ./WORKFLOW.md [workflow-reuse-report]: ./reports/workflow-reuse.md -[write-guard]: ./host-setup/agent-safety/gh-write-guard.py +[write-guard]: ./host-setup/agent-safety/claude/gh-write-guard.py diff --git a/docs/host-setup.md b/docs/host-setup.md index d7807ff7..d56fc37c 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -185,65 +185,32 @@ Choose the SSH key generated above when prompted. Host-level write safety is required where an agent runs with the maintainer's `gh` credentials. Each provider's implementation stays in its own subsection. -### Claude Code Write Safety - -The Claude Code safety kit is the first agent-specific control to deploy on a new system. Install it from this repo. The installer is idempotent and safe to re-run: - -```shell -host-setup/agent-safety/install.sh # Linux, WSL, macOS -``` - -```powershell -.\host-setup\agent-safety\install.ps1 # Windows, and the .\ prefix is required -``` - -Both wrap one `install.py`, so every platform runs the same tested path. Restart Claude Code sessions on the machine afterward so the hook and the `CLAUDE.md` block load. Details, verification, and scope limits are in [`host-setup/agent-safety/README.md`][agent-safety]. - -This is a **host** control, not a repo one. The carried `GOVERNANCE.md` rules reach fleet repos only, while the hook and the `CLAUDE.md` block cover every session on the machine, including ad-hoc work in no project at all, which is where the incident behind the kit happened. - -#### Granting a Write the Guard Denies - -The guard denies a `gh` write whose explicit target sits under an owner other than the checkout's `origin` owner, and the denial names `GH_WRITE_GUARD_ALLOW` as the way past it. That grant is the maintainer's to make, and making it is a deliberate act taken outside the session rather than something an agent does for itself once blocked. - -**The case that raises it is usually a fork.** `origin` is your own fork under your own owner, and `upstream` is the project it was forked from under someone else's. Everything aimed at the fork is in scope and never denies, and only the half that leaves the owner stops: filing an issue on the upstream, opening a pull request against it, or commenting on one there. The grant therefore names the upstream alone, and the fork needs no grant at all. That asymmetry is what a reader hits first, since half the session's writes succeed and the other half do not. - -**The grant goes in the checkout's `.claude/settings.local.json`, as an `env` block:** +The requirements every agent's kit is built and audited against, agent-agnostic, are in +[`host-setup/agent-safety/README.md`][agent-safety], the spec. Each agent's own implementation +detail lives one level down, following the same contract-vs-implementation split this file uses +for [`host-setup/`][host-setup-dir]'s own per-platform subdirectories: this file states the +requirement, the per-agent `README.md` owns the how-to. -```json -{ - "env": { - "GH_WRITE_GUARD_ALLOW": "upstream-owner/upstream-repo second-owner/other-repo third-owner/*" - } -} -``` - -**The value is one string holding every grant, never a JSON array**, since the hook reads an environment variable and an environment variable is a string. The three tokens above are three separate grants: two naming one repository each, and `third-owner/*` granting every repository under that owner. - -Tokens are separated by **any run of whitespace or commas**, so `a/b c/d`, `a/b,c/d`, and `a/b, c/d` all parse to the same two grants and the choice is cosmetic. A token carrying no `/` is ignored, so a malformed entry grants nothing rather than granting everything, and it also fails silently, which is why the confirmation step below is worth running. Grant the narrowest thing that unblocks the work, since a repository grant does not extend to that owner's other repositories and that containment is the property worth keeping. - -**The grant is per checkout, not per host.** `.claude/settings.local.json` lives in the working tree and is git-ignored, so it applies to sessions started in that checkout and does not follow the agent into another repository's sessions. That is the intended scope: a grant made to file one upstream issue from one fork does not quietly become a standing permission everywhere. - -**Restart the session afterward.** The hook reads the value from the environment the session was launched with, which is what makes the channel one an agent cannot use on itself, and it is equally why a grant added to a live session does nothing until that session restarts. - -**Two forms look right and leave the write denied.** An inline `GH_WRITE_GUARD_ALLOW=owner/repo gh ...` prefix sets the environment of the `gh` process, and an `export` inside a shell call sets the environment of that shell. The hook runs as its own process and sees neither, so the write stays denied with nothing to explain the difference. [`gh-write-guard.py`][write-guard] asserts the inline-prefix case in its own self-test, so this is settled behavior rather than a quirk to work around. - -**Confirm the grant loaded before relying on it**, since inferring it from a write that no longer denies means learning the answer by making the write. In a restarted session in that checkout, read the variable the hook reads: - -```shell -printenv GH_WRITE_GUARD_ALLOW -``` - -Run it bare, with no `VAR=value` prefix of its own, which would report a value the hook never sees. An empty result means the grant did not load, and the fix is the file location or the restart rather than the token. Feeding the hook a synthetic payload is not a usable probe from inside a session, because the payload text carries the very write shape the guard matches and the guard denies the probe command itself. +### Claude Code Write Safety -Withdraw a grant by deleting the `env` entry and restarting. Nothing expires it, so a grant left in place stays live for every later session in that checkout, which is the reason to remove it once the work that needed it is done. +The Claude Code safety kit is the first agent-specific control to deploy on a new system, and the +only one implemented today. Install, verify, scope limits, and the cross-owner write grant +mechanism are all in [`host-setup/agent-safety/claude/README.md`][agent-safety-claude]. This is a +**host** control, not a repo one: the carried `GOVERNANCE.md` rules reach fleet repos only, while +the hook and the `CLAUDE.md` block cover every session on the machine, including ad-hoc work in no +project at all, which is where the incident behind the kit happened. ### Codex Write Safety -No equivalent host write hook ships yet for Codex. Keep Codex's sandbox and execution policies enabled. The carried repository rules remain the behavioral layer in a fleet checkout. [Issue #781][issue-781] tracks the missing hook. +No equivalent host write hook ships yet for Codex. Keep Codex's sandbox and execution policies +enabled meanwhile. [`host-setup/agent-safety/codex/README.md`][agent-safety-codex] states the gap +and what implementing against the spec would look like. [Issue #781][issue-781] tracks it. ### opencode Write Safety -No equivalent host write hook ships yet for opencode. Keep opencode's own permission model enabled. The carried repository rules remain the behavioral layer in a fleet checkout. [Issue #781][issue-781] tracks the missing hook. +No equivalent host write hook ships yet for opencode. Keep opencode's own permission model enabled +meanwhile. [`host-setup/agent-safety/opencode/README.md`][agent-safety-opencode] states the gap and +what implementing against the spec would look like. [Issue #781][issue-781] tracks it. ## Agent Worktree Access @@ -362,6 +329,9 @@ A host that fails any row is not ready for the procedure that row names, and the [agent-safety]: ../host-setup/agent-safety/README.md +[agent-safety-claude]: ../host-setup/agent-safety/claude/README.md +[agent-safety-codex]: ../host-setup/agent-safety/codex/README.md +[agent-safety-opencode]: ../host-setup/agent-safety/opencode/README.md [audit]: ../AUDIT.md [bootstrap]: ../host-setup/bootstrap.sh [bootstrap-ps1]: ../host-setup/bootstrap.ps1 @@ -379,7 +349,6 @@ A host that fails any row is not ready for the procedure that row names, and the [spec-dir]: ../spec/ [ssh-signing]: ./ssh-signing.md [standup]: ../STANDUP.md -[write-guard]: ../host-setup/agent-safety/gh-write-guard.py diff --git a/host-setup/README.md b/host-setup/README.md index 674474cf..2d318aa2 100644 --- a/host-setup/README.md +++ b/host-setup/README.md @@ -100,7 +100,7 @@ Reaching `spec/audit.py` and `scripts/carry.py` from outside a hub checkout mean ## Three Rules This Directory Follows -**Group by whichever axis has one member.** `agent-safety/` is one concern across three platforms, so it is a concern directory holding `install.sh`, `install.ps1` and `install.py`. `linux/` is three concerns on one platform, so it is a platform directory. Windows host tooling therefore sits at `windows/` rather than beside the Linux scripts, because the `winget` equivalent of `install-tools.sh` is a different program rather than a translation of one. It carries one registry record per tool where the Linux script carries four functions, since every Windows source is `winget` and the per-tool variation those functions exist for does not arise. `windows/` also carries a fourth script with no Linux peer, because WSL is a Windows-side concern. The loader and the menu are the same shape as `agent-safety/`, not as `linux/`/`windows/`: one concern, two platforms, so `bootstrap.ps1` sits beside `bootstrap.sh`, and `menu.ps1` beside `menu.sh`, at the top level rather than inside `windows/`. +**Group by whichever axis has one member.** `linux/` is three concerns on one platform, so it is a platform directory. Windows host tooling therefore sits at `windows/` rather than beside the Linux scripts, because the `winget` equivalent of `install-tools.sh` is a different program rather than a translation of one. It carries one registry record per tool where the Linux script carries four functions, since every Windows source is `winget` and the per-tool variation those functions exist for does not arise. `windows/` also carries a fourth script with no Linux peer, because WSL is a Windows-side concern. The loader and the menu are one concern, two platforms, so `bootstrap.ps1` sits beside `bootstrap.sh`, and `menu.ps1` beside `menu.sh`, at the top level rather than inside `windows/`. `agent-safety/` follows the same platform-grouping shape one level down: it holds one concern (write-safety guards) implemented per *agent* rather than per OS, so `claude/`, `codex/`, and `opencode/` sit beside each other the way `linux/` and `windows/` do, each holding that one agent's own files (a Claude Code implementation carries its own `install.sh`/`install.ps1` pair inside `claude/`, the same platform variation `linux/`/`windows/` carry at the top level, just nested one directory deeper because the outer axis here is the agent, not the OS). Its root `README.md` is the agent-agnostic spec those per-agent directories each implement, not a concern directory of its own. **Nothing here needs Python to stand a host up, and neither loader needs an interpreter to fetch what it drives.** [`docs/host-setup.md`][host-setup] carries that as part of the contract, with the reasoning. `bootstrap.sh` needs only `curl` and `tar`. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, `pwsh`, itself through `winget`. The one exception is the `install-skills` pair, which drives the Python installer at `scripts/skills_install.py` and runs last in a stand-up for exactly that reason: `install-tools` has provided the interpreter by then, and run alone on a host without one it stops and names the tools step as its prerequisite. The menu needs `git` to fetch the hub, and it checks for a working Python the same lazy way: only the tasks that call a Python tool ask for it, and every host task still works without one. Neither loader runs a gate as a closing step: [`scripts/host_gate.py`][host-gate] measures a host against the floors and is not called from here, and nothing here is called from it. A host set up by hand years ago is an ordinary host, so the gate reports what it is missing and running this tooling is a remedy a person chooses. The two are joined at code time instead, by [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserting that every tool the spec requires is one this tooling can provide. diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index a7da831b..21369b11 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -1,105 +1,152 @@ -# Claude Code Write-Safety Kit - -These per-machine, user-account-scoped Claude Code guards cover two action classes. The first is a mis-targeted GitHub **write** under the maintainer's identity. The second is a **git operation that bypasses a branch rule or required check**. That class includes a forbidden push, force-push, or delete. It also includes an override flag such as `--admin` or `--no-verify`. Deploy the kit first on any system where Claude Code uses the maintainer's `gh` credentials. Linux, WSL, macOS, and Windows are supported. This kit does not configure Codex or opencode. Their host-specific controls live in [`docs/host-setup.md` "Agent Write-Safety"][host-setup-write-safety], and [issue #781][issue-781] tracks their missing hooks. - -## What It Installs - -Into `~/.claude/` (or `%USERPROFILE%\.claude\` on Windows): - -- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies two 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 to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged 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. 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. Reads and everything else pass through. It fires even in autonomous / bypass-permissions sessions, which is how the incident happened. -- **Two marker-delimited blocks of host-wide content in `CLAUDE.md`**, loaded into every session on the machine (including ad-hoc work outside any project), installed and updated independently so one can change without rewriting the other. - - The `agent-safety` block carries restrictions alone, so nothing in it can widen a permission. `Repository Boundaries and Write Safety` mirrors the committed `GOVERNANCE.md` section of the same name, carrying as behavioral guidance its write rules and its rules that a task works in its own checkout and isolates into its own worktree before its first file edit. `Data in Agent-Authored Text` mirrors `GOVERNANCE.md` "Representative Data in Agent-Authored Text", so an agent illustrates with constructed data rather than data observed on the machine. `Authorization Scope and Memory Hygiene` bounds how a granted permission is recorded and read. Each mirrors a rule that otherwise reaches fleet repos only. - - The `fleet-bootstrap` block carries `Fleet Governance Entry Point`, which names the template repository and routes by the state a repository is actually in. It is separate precisely because it enables rather than restricts, and the safety block's own text says nothing in it widens a permission, so merging the two would contradict that. It is host-wide rather than per repository because the repositories that most need it are the ones carrying no instruction set to point the way, and it mirrors the byte-locked `AGENTS.md` "Fleet Bootstrap" section that a conformant repository carries. -- **The permission rules this kit owns, merged into `settings.json`** beside the hook registration. Each is declared as a prefix and a rule, and a re-run drops every rule the prefix owns before adding the current one, so a rule whose spelling changes updates in place rather than accumulating beside the version it replaced. Ownership requires a rule-syntax delimiter after the prefix, since the prefix ends at the script name and a bare prefix test would also claim a longer path such as `pr_review.py-custom`, so a rule written by hand for a different script is never touched. These widen rather than restrict, which is why they are their own component for the same reason the `fleet-bootstrap` block is separate from the `agent-safety` one. Today the list holds one rule, for `scripts/pr_review.py`, the review loop's reply and resolve. Driving that loop by hand needs a raw GraphQL mutation carrying a node id, which is the shape that reached a stranger's repository, where the script queries the id itself and takes no argument an id fits in. What the rule decides is which command runs without a prompt, and it matches the command text rather than the directory the command runs in, so it reaches a `scripts/pr_review.py` in any checkout that carries one. An absolute path would not narrow that, since the hub is reached as a checkout of the caller's own and its location differs per task, so pinning one path would name a checkout the next task does not use. What bounds it is the rule that an agent reaches the hub as a checkout of its own, fetched immediately before it is read, rather than a copy it happens to find on disk, which the `fleet-bootstrap` block beside this carries and [`GOVERNANCE.md`][governance] "Hub-Hosted Tooling" states in full. - -The hook is the mechanical backstop. The CLAUDE.md rules and the carried GOVERNANCE.md rules are the behavioral layer. Prose alone is not enough, since the incident happened under prose rules, so both ship. The GitHub write rules have a hook behind them, as do the git operations that bypass a repository protection, while which checkout a command belongs in, the data a line of text quotes, and how an authorization is recorded are prose only, since no hook can see any of the three. - -## Install (Idempotent, Safe to Re-Run to Update) - -```sh -# Linux / WSL / macOS -host-setup/agent-safety/install.sh -``` - -```powershell -# Windows - the .\ prefix is required, PowerShell does not run a script from a relative path without it -.\host-setup\agent-safety\install.ps1 +# Agent Write-Safety Spec + +What any coding agent must be stopped from doing when it runs on this host with the maintainer's +`gh` credentials, stated once, independent of which agent implements it. This file is the source +of truth: an implementation is built from the requirements below, and an implementation is audited +by checking its decisions against them, not by reading its source as the implicit spec. + +## Why This Exists + +A mis-targeted GitHub write acts publicly under the maintainer's identity: a fabricated node id +once posted a stray comment, as the maintainer, to a stranger's repository. A mutating git command +run directly in a primary checkout destroys another task's uncommitted work without ever reaching +GitHub. Both incidents happened under prose rules the agent had already read. Neither was fixed by +writing the rule more clearly. [`GOVERNANCE.md`][governance] "Durable Knowledge and Self-Improvement" +states the general criteria for when a rule like this earns a mechanical hook instead of staying +prose. The requirements below are the write-safety instance of that criteria, applied. + +## Requirements + +Each requirement is stated as a decision rule, precise enough to implement against any agent's own +hook or approval-gate API, not tied to Claude Code's `PreToolUse` JSON shape. + +1. **A GitHub write with its output discarded or forced to success is denied.** A state-changing + `gh`/API call piped to `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, or `|| echo` + hides the one signal that tells a client-reported failure apart from a server-side success. Deny + the write, then allow it once run so its real result is read. +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, such as one prefixed `PR_`, `PRRT_`, `IC_`, or `BOT_` (an uppercase-letter prefix + followed by an underscore and a long body, or the legacy `MD`-prefixed base64 form), is denied. A + `-F name="$VAR"` value in the same position is allowed instead of being pattern-matched, **not** + because the hook has verified where `$VAR`'s value came from -- a static, pre-execution hook + cannot see a shell variable's runtime binding, only the command text -- but because this rule's + job is to catch the literal-id mistake specifically, and a captured-variable convention is what + the fleet's own prose rule (`GOVERNANCE.md` "Repository Boundaries and Write Safety") requires + agent behavior to uphold. Enforcing that the value genuinely came from a live query is + behavioral, not something this decidable-from-text-alone rule can check. +3. **A GitHub write with an explicit target outside the checkout's own owner is denied, unless the + maintainer granted it.** Compare the write's explicit `-R`/`--repo`/`repos//` target + against the checkout's own `origin` owner, when an `origin` resolves at all. 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 -- + never a channel the agent itself can set (an inline `VAR=x cmd` prefix or an `export` inside the + same call must not satisfy this). **When no `origin` resolves at all** (a non-git directory, or a + checkout whose remote can't be read), this requirement has nothing to compare the target against + and does not fire -- requirements 1 and 2 still apply regardless, and this is the same + precision-over-recall stance every requirement but 4 takes. +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` on a commit/push). 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. + **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, 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 this requirement instead, since there is nothing yet on record to bypass. 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 + legitimate work far more often than it catches a real bypass. +5. **A hand-rolled reply or resolve on a review thread, bypassing the one-call helper, is denied + (where a helper exists) unless 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. Where the agent's fleet ships a single documented helper for + this (this repo's `scripts/pr_review.py reply --resolve`), a raw mutation reaching the same + endpoint is denied in favor of it. 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, and this is allowed + through the same grant channel requirement 3 reads rather than a separate one. A REST reply's own + URL can be checked against the grant. A `resolveReviewThread` mutation's thread id is opaque, so + any active grant is the only signal available there, a coarser check than a REST reply gets and a + residual gap this requirement accepts rather than blocking every grant-holding session's replies + on an unrelated target. + +**Not yet implemented anywhere, tracked at [issue #1073][issue-1073]:** a mutating git operation run +directly against a primary (non-worktree) checkout should be denied the same way. This spec is +updated with that requirement's exact decision rule in the same change that adds it to the Claude +Code hook, so a reader here always sees what is actually enforced, not what is merely planned. + +## Decision Flow + +```mermaid +flowchart TD + cmd["Tool call: a shell/git/gh command"] --> isgit{"A git operation\nthat bypasses a\nbranch rule\nor a bypass flag?"} + isgit -- yes --> deny4["DENY - requirement 4\n(fails closed for a\nprotected-default branch\nwith undeterminable rules)"] + isgit -- no --> isghwrite{"A GitHub-write\ncommand at all?"} + isghwrite -- no --> allow["ALLOW"] + isghwrite -- yes --> suppressed{"Output discarded or\nforced to success?"} + suppressed -- yes --> deny1["DENY - requirement 1"] + suppressed -- no --> literalid{"Mutation carries a\nliteral node id?"} + literalid -- yes --> deny2["DENY - requirement 2"] + literalid -- no --> crossowner{"Explicit target outside\nthe checkout's owner,\nnot granted?"} + crossowner -- yes --> deny3["DENY - requirement 3"] + crossowner -- no --> handrolled{"Hand-rolled reply/resolve,\nhelper available?"} + handrolled -- yes --> deny5["DENY - requirement 5"] + handrolled -- no --> allow ``` -Both are thin wrappers around `install.py`, so every OS runs one tested code path. The installer self-tests the hook before registering it, merges the settings.json hook entry and the permission rules without clobbering other keys, and updates each CLAUDE.md block in place by its own markers rather than duplicating it, so the two blocks move independently. The settings file is read once and written once, so the hook and the permission rules land together or not at all. - -**Restart Claude Code sessions on the machine afterward** so the new hook and CLAUDE.md load. - -## Refreshing After an Upstream Change - -The deployed copy on each machine is a snapshot, so when the guard changes upstream (a new rule or a fix) every machine keeps running the old hook until it is refreshed. The installer **is** the refresh: pull the latest template and re-run `install.sh` (or `install.ps1`) on each machine. It re-copies the hook, re-runs the self-test, and re-registers in place, so a re-run is safe and updates the deployed copy. [#365][issue-365] tracks the per-machine rollout and its re-runs. - -## Verify (POSIX Shell) - -```sh -python3 ~/.claude/hooks/gh-write-guard.py --selftest # decision matrix: all cases pass -grep -c 'agent-safety v' ~/.claude/CLAUDE.md # expect 2 (start + end marker) -grep -c 'fleet-bootstrap v' ~/.claude/CLAUDE.md # expect 2 (start + end marker) -grep -cF 'Bash(python3 scripts/pr_review.py:*)' ~/.claude/settings.json # expect 1 (never duplicated) +```mermaid +flowchart LR + subgraph layer1["Loading layer"] + L1["Does the governing rule\neven reach the session's\ncontext? (e.g. CLAUDE.md\nimporting AGENTS.md)"] + end + subgraph layer2["Prose layer"] + L2["GOVERNANCE.md / AGENTS.md /\nSkill trigger descriptions --\nread and followed by judgment"] + end + subgraph layer3["Hook layer"] + L3["This spec's requirements --\nmechanically decided from the\ntool call alone, no judgment"] + end + L1 -->|"fixed by a structural\nload/import fix, not a hook"| L2 + L2 -->|"promoted here only when\nmechanically decidable +\ndestructive, per GOVERNANCE.md\n'Durable Knowledge'"| L3 ``` -On Windows PowerShell: - -```powershell -py -3 "$env:USERPROFILE\.claude\hooks\gh-write-guard.py" --selftest # all cases pass -(Select-String 'agent-safety v' "$env:USERPROFILE\.claude\CLAUDE.md").Count # expect 2 -(Select-String 'fleet-bootstrap v' "$env:USERPROFILE\.claude\CLAUDE.md").Count # expect 2 -(Select-String -SimpleMatch 'Bash(python3 scripts/pr_review.py:*)' "$env:USERPROFILE\.claude\settings.json").Count # expect 1 -``` - -Live end-to-end (in any repo): attempt a discarded-output write and confirm the Bash tool is blocked: - -```sh -gh api graphql -f query='mutation{noop}' -F t="PRRT_x" >/dev/null 2>&1 || true # blocked by the hook -``` - -## Granting a Cross-Owner Write - -The cross-origin rule is the one denial a maintainer has to act on, because it is the only one with a grant behind it. The others name a shape to stop using, while this one names a target that may be entirely legitimate. - -`GH_WRITE_GUARD_ALLOW` is one string holding every grant, since it is an environment variable, and it is split into `owner/repo` tokens on any run of whitespace or commas, so `a/b c/d` and `a/b, c/d` are the same two grants. `owner/*` grants a whole owner. A token carrying no `/` is ignored, so a malformed grant grants nothing, and a repository grant does not extend to that owner's other repositories. - -The hook reads it from the environment the session was launched with, which is the one channel an agent cannot use on itself: the hook runs as its own process, so an inline `VAR=x cmd` prefix and an `export` inside a Bash call both leave the write denied. Granting is therefore a deliberate act taken outside the session, and a blocked agent asks rather than unblocks itself. - -The channel that works is an `env` block in the checkout's `.claude/settings.local.json`, which scopes the grant to sessions started in that checkout, followed by a session restart. The worked example, the fork case that raises this most often, and how to confirm a grant loaded without making the write are in [`docs/host-setup.md` "Granting a Write the Guard Denies"][host-setup-grant]. - -## Manual settings.json Shape (for Reference) - -The installer writes this. It is here so you can inspect or hand-place it: - -```json -{ - "hooks": { - "PreToolUse": [ - { "matcher": "Bash", "hooks": [ { "type": "command", "command": "\"python3\" \"/.claude/hooks/gh-write-guard.py\"" } ] } - ] - }, - "permissions": { - "allow": [ "Bash(python3 scripts/pr_review.py:*)" ] - } -} -``` - -Every other key in the file is left as it stands, `permissions.allow` included, apart from the rules whose prefix this kit owns. - -## Scope and Limits - -- **Per-machine.** `~/.claude/` does not travel, so run the installer on each box. This is the rollout that [#365][issue-365] tracks. -- **Precision over recall for the write footguns.** The hook denies the specific dangerous write shapes with high confidence rather than gating every write, so it never blocks legitimate work. A shape it does not catch still falls under the behavioral rules. -- **The branch-bypass rule fails closed.** Unlike the write-footgun rules, a push to `main`/`master`/`develop` is denied even when its rules cannot be determined (the API is unreachable, or the checkout's origin cannot be resolved to query them), because the harm there is a silent success under the maintainer's admin bypass. The rule reads each branch's live rules, so it adapts to every repo (a code-style `develop` denies, a config-style `develop` allows) with no per-repo configuration, and hands the exact command to the maintainer to run when a bypass is genuinely intended. -- **Opaque targets are unseen.** The hook cannot see the repository behind a GraphQL node id, which is exactly why rule 2 blocks a *literal* id at all, since a captured `$variable` is trusted. Likewise, the cross-origin check only runs when an `origin` can be resolved and the write names an explicit `-R`/`repos//` target. A write from a non-git directory, or one whose target is only a node id, is evaluated by rules 1 and 2 alone. -- **A write inside a script file is unseen, so never batch writes into a script.** The hook reads the command the agent runs, which for `bash deploy.sh` is that one string, so a `git push` or a `gh` mutation inside the script reaches the server without the hook ever parsing it. This is the widest hole in the kit and it is one an agent opens by accident, since collecting fifteen repetitive pushes into a loop looks like tidiness rather than like disabling a guard. Issue each write as its own command. A script that only reads, computes, or prepares local commits is fine, because the boundary is the write and not the script. -- **The hook's own parser over-blocks a `git push` followed by a newline.** Git and GitHub are not involved in this one: the hook splits the command string to find each `git push` and its arguments, and that split ends an argument list at `&&` but not at a newline, so it reads every token on a later line of the same command as a refspec of that push. Measured against the installed hook, `git push -u origin revendor/x` resolves to that one branch, while the same push followed by a newline and a `gh pr create` naming `develop` as its base resolves to five, meaning `revendor/x`, `gh`, `pr`, `create`, and `develop`. The hook then denies the push as a direct push to a protected branch that the push never named. The direction is safe, since it blocks rather than admits, but the denial names a bypass the agent never attempted, and a guard that cries wolf is one an agent starts working around. Until the parser is fixed, issue the push as its own command, which is the rule directly above in any case. Tracked in `TODO.md`. -- **Not a credential control.** A fine-grained PAT limited to owned repositories is a separate, stronger structural guard (a hard `403` on any non-owned repo) and is left to per-machine credential setup, out of this kit. +The first diagram is this spec's actual decision flow, generalized from `claude/gh-write-guard.py`'s +`classify()`. The second is why a failure lands in one layer and not another. A rule that never +reached the session at all is a loading bug, fixed the way PR #1081 fixed `local-strict-review`'s +missed trigger, by wiring `CLAUDE.md` to import `AGENTS.md`. A rule that reached the session and +was still not followed, where the trigger is mechanically decidable and the harm is destructive, +is promoted to a hook ([issue #1073][issue-1073]'s primary-checkout guard, above, is the worked +example once it lands). A rule whose violation can only be judged, not mechanically decided (was a +review finding actually evidence-backed?), stays prose and a chained Skill trigger, since a hook +there could only nag, never decide. + +## Per-Agent Status + +| Agent | Status | Implementation | +| --- | --- | --- | +| Claude Code | Requirements 1-5, via a `PreToolUse` hook | [`claude/README.md`][claude] | +| Codex | No hook yet -- tracked at [issue #781][issue-781] | [`codex/README.md`][codex] | +| opencode | No hook yet -- tracked at [issue #781][issue-781] | [`opencode/README.md`][opencode] | + +GitHub Copilot carries no subdirectory here: it reviews through GitHub's own hosted infrastructure +rather than running local shell commands under the maintainer's credentials, so it has no analogous +local write-safety hazard for this kit to cover. + +## Auditing an Implementation Against This Spec + +Run the implementation's own self-test (`claude/gh-write-guard.py --selftest` for Claude Code) and +compare every case against the requirements list above, one by one, rather than reading the +implementation's source as though it were the spec. A case the self-test doesn't cover is a gap in +the audit, not evidence the requirement is satisfied. This is the concrete shape of "ask Claude to +audit the Claude hooks against the spec" or "ask Codex to implement Codex's own hooks against the +spec": point the agent at this file's requirements, not at another agent's source code. +[claude]: ./claude/README.md +[codex]: ./codex/README.md +[opencode]: ./opencode/README.md [governance]: ../../GOVERNANCE.md -[host-setup-grant]: ../../docs/host-setup.md#granting-a-write-the-guard-denies -[host-setup-write-safety]: ../../docs/host-setup.md#agent-write-safety -[issue-365]: https://github.com/ptr727/ProjectTemplate/issues/365 [issue-781]: https://github.com/ptr727/ProjectTemplate/issues/781 +[issue-1073]: https://github.com/ptr727/ProjectTemplate/issues/1073 diff --git a/host-setup/agent-safety/.markdownlint-cli2.jsonc b/host-setup/agent-safety/claude/.markdownlint-cli2.jsonc similarity index 100% rename from host-setup/agent-safety/.markdownlint-cli2.jsonc rename to host-setup/agent-safety/claude/.markdownlint-cli2.jsonc diff --git a/host-setup/agent-safety/claude/README.md b/host-setup/agent-safety/claude/README.md new file mode 100644 index 00000000..dd21e595 --- /dev/null +++ b/host-setup/agent-safety/claude/README.md @@ -0,0 +1,139 @@ +# Claude Code Write-Safety Kit + +This is the Claude Code implementation of the write-safety spec at [`../README.md`][spec] -- +per-machine, user-account-scoped guards, deployed first on any system where Claude Code uses the +maintainer's `gh` credentials. Linux, WSL, macOS, and Windows are supported. See the spec for the +requirements this kit satisfies and why each exists. What follows here is Claude-Code-specific +installation and operational detail. Codex and opencode have no equivalent hook yet -- see +[`../codex/README.md`][codex] and [`../opencode/README.md`][opencode] for their status, tracked at +[issue #781][issue-781]. + +## What It Installs + +Into `~/.claude/` (or `%USERPROFILE%\.claude\` on Windows): + +- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies two 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 to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged 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. 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. Reads and everything else pass through. It fires even in autonomous / bypass-permissions sessions, which is how the incident happened. +- **Two marker-delimited blocks of host-wide content in `CLAUDE.md`**, loaded into every session on the machine (including ad-hoc work outside any project), installed and updated independently so one can change without rewriting the other. + - The `agent-safety` block carries restrictions alone, so nothing in it can widen a permission. `Repository Boundaries and Write Safety` mirrors the committed `GOVERNANCE.md` section of the same name, carrying as behavioral guidance its write rules and its rules that a task works in its own checkout and isolates into its own worktree before its first file edit. `Data in Agent-Authored Text` mirrors `GOVERNANCE.md` "Representative Data in Agent-Authored Text", so an agent illustrates with constructed data rather than data observed on the machine. `Authorization Scope and Memory Hygiene` bounds how a granted permission is recorded and read. Each mirrors a rule that otherwise reaches fleet repos only. + - The `fleet-bootstrap` block carries `Fleet Governance Entry Point`, which names the template repository and routes by the state a repository is actually in. It is separate precisely because it enables rather than restricts, and the safety block's own text says nothing in it widens a permission, so merging the two would contradict that. It is host-wide rather than per repository because the repositories that most need it are the ones carrying no instruction set to point the way, and it mirrors the byte-locked `AGENTS.md` "Fleet Bootstrap" section that a conformant repository carries. +- **The permission rules this kit owns, merged into `settings.json`** beside the hook registration. Each is declared as a prefix and a rule, and a re-run drops every rule the prefix owns before adding the current one, so a rule whose spelling changes updates in place rather than accumulating beside the version it replaced. Ownership requires a rule-syntax delimiter after the prefix, since the prefix ends at the script name and a bare prefix test would also claim a longer path such as `pr_review.py-custom`, so a rule written by hand for a different script is never touched. These widen rather than restrict, which is why they are their own component for the same reason the `fleet-bootstrap` block is separate from the `agent-safety` one. Today the list holds one rule, for `scripts/pr_review.py`, the review loop's reply and resolve. Driving that loop by hand needs a raw GraphQL mutation carrying a node id, which is the shape that reached a stranger's repository, where the script queries the id itself and takes no argument an id fits in. What the rule decides is which command runs without a prompt, and it matches the command text rather than the directory the command runs in, so it reaches a `scripts/pr_review.py` in any checkout that carries one. An absolute path would not narrow that, since the hub is reached as a checkout of the caller's own and its location differs per task, so pinning one path would name a checkout the next task does not use. What bounds it is the rule that an agent reaches the hub as a checkout of its own, fetched immediately before it is read, rather than a copy it happens to find on disk, which the `fleet-bootstrap` block beside this carries and [`GOVERNANCE.md`][governance] "Hub-Hosted Tooling" states in full. + +See [`../README.md`][spec] "Requirements" for which of these each rule implements, and "Auditing an +Implementation Against This Spec" for how to check this hook still satisfies them after a change. + +## Install (Idempotent, Safe to Re-Run to Update) + +```sh +# Linux / WSL / macOS +host-setup/agent-safety/claude/install.sh +``` + +```powershell +# Windows - the .\ prefix is required, PowerShell does not run a script from a relative path without it +.\host-setup\agent-safety\claude\install.ps1 +``` + +Both are thin wrappers around `install.py`, so every OS runs one tested code path. The installer self-tests the hook before registering it, merges the settings.json hook entry and the permission rules without clobbering other keys, and updates each CLAUDE.md block in place by its own markers rather than duplicating it, so the two blocks move independently. The settings file is read once and written once, so the hook and the permission rules land together or not at all. + +**Restart Claude Code sessions on the machine afterward** so the new hook and CLAUDE.md load. + +## Refreshing After an Upstream Change + +The deployed copy on each machine is a snapshot, so when the guard changes upstream (a new rule or a fix) every machine keeps running the old hook until it is refreshed. The installer **is** the refresh: pull the latest template and re-run `install.sh` (or `install.ps1`) on each machine. It re-copies the hook, re-runs the self-test, and re-registers in place, so a re-run is safe and updates the deployed copy. [#365][issue-365] tracks the per-machine rollout and its re-runs. + +## Verify (POSIX Shell) + +```sh +python3 ~/.claude/hooks/gh-write-guard.py --selftest # decision matrix: all cases pass +grep -c 'agent-safety v' ~/.claude/CLAUDE.md # expect 2 (start + end marker) +grep -c 'fleet-bootstrap v' ~/.claude/CLAUDE.md # expect 2 (start + end marker) +grep -cF 'Bash(python3 scripts/pr_review.py:*)' ~/.claude/settings.json # expect 1 (never duplicated) +``` + +On Windows PowerShell: + +```powershell +py -3 "$env:USERPROFILE\.claude\hooks\gh-write-guard.py" --selftest # all cases pass +(Select-String 'agent-safety v' "$env:USERPROFILE\.claude\CLAUDE.md").Count # expect 2 +(Select-String 'fleet-bootstrap v' "$env:USERPROFILE\.claude\CLAUDE.md").Count # expect 2 +(Select-String -SimpleMatch 'Bash(python3 scripts/pr_review.py:*)' "$env:USERPROFILE\.claude\settings.json").Count # expect 1 +``` + +Live end-to-end (in any repo): attempt a discarded-output write and confirm the Bash tool is blocked: + +```sh +gh api graphql -f query='mutation{noop}' -F t="PRRT_x" >/dev/null 2>&1 || true # blocked by the hook +``` + +## Granting a Write That the Guard Denies + +The guard denies a `gh` write whose explicit target sits under an owner other than the checkout's `origin` owner, and the denial names `GH_WRITE_GUARD_ALLOW` as the way past it. That grant is the maintainer's to make, and making it is a deliberate act taken outside the session rather than something an agent does for itself once blocked. This is the one denial a maintainer has to act on, because it is the only one with a grant behind it -- the others name a shape to stop using, while this one names a target that may be entirely legitimate. + +**The case that raises it is usually a fork.** `origin` is your own fork under your own owner, and `upstream` is the project it was forked from under someone else's. Everything aimed at the fork is in scope and never denies, and only the half that leaves the owner stops: filing an issue on the upstream, opening a pull request against it, or commenting on one there. The grant therefore names the upstream alone, and the fork needs no grant at all. That asymmetry is what a reader hits first, since half the session's writes succeed and the other half do not. + +**The grant goes in the checkout's `.claude/settings.local.json`, as an `env` block:** + +```json +{ + "env": { + "GH_WRITE_GUARD_ALLOW": "upstream-owner/upstream-repo second-owner/other-repo third-owner/*" + } +} +``` + +**The value is one string holding every grant, never a JSON array**, since the hook reads an environment variable and an environment variable is a string. The three tokens above are three separate grants: two naming one repository each, and `third-owner/*` granting every repository under that owner. + +Tokens are separated by **any run of whitespace or commas**, so `a/b c/d`, `a/b,c/d`, and `a/b, c/d` all parse to the same two grants and the choice is cosmetic. A token carrying no `/` is ignored, so a malformed entry grants nothing rather than granting everything, and it also fails silently, which is why the confirmation step below is worth running. Grant the narrowest thing that unblocks the work, since a repository grant does not extend to that owner's other repositories and that containment is the property worth keeping. + +**The grant is per checkout, not per host.** `.claude/settings.local.json` lives in the working tree and is git-ignored, so it applies to sessions started in that checkout and does not follow the agent into another repository's sessions. That is the intended scope: a grant made to file one upstream issue from one fork does not quietly become a standing permission everywhere. + +**Restart the session afterward.** The hook reads the value from the environment the session was launched with, which is what makes the channel one an agent cannot use on itself, and it is equally why a grant added to a live session does nothing until that session restarts. + +**Two forms look right and leave the write denied.** An inline `GH_WRITE_GUARD_ALLOW=owner/repo gh ...` prefix sets the environment of the `gh` process, and an `export` inside a shell call sets the environment of that shell. The hook runs as its own process and sees neither, so the write stays denied with nothing to explain the difference. [`gh-write-guard.py`][write-guard] asserts the inline-prefix case in its own self-test, so this is settled behavior rather than a quirk to work around. + +**Confirm the grant loaded before relying on it**, since inferring it from a write that no longer denies means learning the answer by making the write. In a restarted session in that checkout, read the variable the hook reads: + +```shell +printenv GH_WRITE_GUARD_ALLOW +``` + +Run it bare, with no `VAR=value` prefix of its own, which would report a value the hook never sees. An empty result means the grant did not load, and the fix is the file location or the restart rather than the token. Feeding the hook a synthetic payload is not a usable probe from inside a session, because the payload text carries the very write shape the guard matches and the guard denies the probe command itself. + +Withdraw a grant by deleting the `env` entry and restarting. Nothing expires it, so a grant left in place stays live for every later session in that checkout, which is the reason to remove it once the work that needed it is done. + +## Manual settings.json Shape (for Reference) + +The installer writes this. It is here so you can inspect or hand-place it: + +```json +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [ { "type": "command", "command": "\"python3\" \"/.claude/hooks/gh-write-guard.py\"" } ] } + ] + }, + "permissions": { + "allow": [ "Bash(python3 scripts/pr_review.py:*)" ] + } +} +``` + +Every other key in the file is left as it stands, `permissions.allow` included, apart from the rules whose prefix this kit owns. + +## Scope and Limits + +- **Per-machine.** `~/.claude/` does not travel, so run the installer on each box. This is the rollout that [#365][issue-365] tracks. +- **Precision over recall for the write footguns.** The hook denies the specific dangerous write shapes with high confidence rather than gating every write, so it is written to avoid blocking legitimate work -- a known false-positive shape is a parser defect to fix, not an accepted cost. A shape it does not catch still falls under the behavioral rules. +- **The branch-bypass rule fails closed.** Unlike the write-footgun rules, a push to `main`/`master`/`develop` is denied even when its rules cannot be determined (the API is unreachable, or the checkout's origin cannot be resolved to query them), because the harm there is a silent success under the maintainer's admin bypass. The rule reads each branch's live rules, so it adapts to every repo (a code-style `develop` denies, a config-style `develop` allows) with no per-repo configuration, and hands the exact command to the maintainer to run when a bypass is genuinely intended. +- **Opaque targets are unseen.** The hook cannot see the repository behind a GraphQL node id, which is exactly why rule 2 blocks a *literal* id at all, since a captured `$variable` is trusted. Likewise, the cross-origin check only runs when an `origin` can be resolved and the write names an explicit `-R`/`repos//` target. A write from a non-git directory, or one whose target is only a node id, is evaluated by rules 1 and 2 alone. +- **A write inside a script file is unseen, so never batch writes into a script.** The hook reads the command the agent runs, which for `bash deploy.sh` is that one string, so a `git push` or a `gh` mutation inside the script reaches the server without the hook ever parsing it. This is the widest hole in the kit and it is one an agent opens by accident, since collecting fifteen repetitive pushes into a loop looks like tidiness rather than like disabling a guard. Issue each write as its own command. A script that only reads, computes, or prepares local commits is fine, because the boundary is the write and not the script. +- **Not a credential control.** A fine-grained PAT limited to owned repositories is a separate, stronger structural guard (a hard `403` on any non-owned repo) and is left to per-machine credential setup, out of this kit. + + +[spec]: ../README.md +[codex]: ../codex/README.md +[opencode]: ../opencode/README.md +[governance]: ../../../GOVERNANCE.md +[write-guard]: ./gh-write-guard.py +[issue-365]: https://github.com/ptr727/ProjectTemplate/issues/365 +[issue-781]: https://github.com/ptr727/ProjectTemplate/issues/781 diff --git a/host-setup/agent-safety/claude-md-fleet.md b/host-setup/agent-safety/claude/claude-md-fleet.md similarity index 100% rename from host-setup/agent-safety/claude-md-fleet.md rename to host-setup/agent-safety/claude/claude-md-fleet.md diff --git a/host-setup/agent-safety/claude-md-safety.md b/host-setup/agent-safety/claude/claude-md-safety.md similarity index 100% rename from host-setup/agent-safety/claude-md-safety.md rename to host-setup/agent-safety/claude/claude-md-safety.md diff --git a/host-setup/agent-safety/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py similarity index 100% rename from host-setup/agent-safety/gh-write-guard.py rename to host-setup/agent-safety/claude/gh-write-guard.py diff --git a/host-setup/agent-safety/install.ps1 b/host-setup/agent-safety/claude/install.ps1 similarity index 100% rename from host-setup/agent-safety/install.ps1 rename to host-setup/agent-safety/claude/install.ps1 diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/claude/install.py similarity index 100% rename from host-setup/agent-safety/install.py rename to host-setup/agent-safety/claude/install.py diff --git a/host-setup/agent-safety/install.sh b/host-setup/agent-safety/claude/install.sh similarity index 100% rename from host-setup/agent-safety/install.sh rename to host-setup/agent-safety/claude/install.sh diff --git a/host-setup/agent-safety/test_install.py b/host-setup/agent-safety/claude/test_install.py similarity index 100% rename from host-setup/agent-safety/test_install.py rename to host-setup/agent-safety/claude/test_install.py diff --git a/host-setup/agent-safety/codex/README.md b/host-setup/agent-safety/codex/README.md new file mode 100644 index 00000000..9083e72c --- /dev/null +++ b/host-setup/agent-safety/codex/README.md @@ -0,0 +1,35 @@ +# Codex Write-Safety Gap + +No hook implements [`../README.md`][spec]'s requirements for Codex yet. Tracked at +[issue #781][issue-781]. + +## What To Keep Enabled Meanwhile + +Keep Codex's own sandbox and execution policies enabled. The carried repository rules +(`GOVERNANCE.md` "Repository Boundaries and Write Safety" and the rest of the fleet's prose) +remain the only behavioral layer in a fleet checkout until a hook exists -- there is no mechanical +backstop for Codex today, which means a mistake that the Claude Code hook would deny goes through +unblocked in a Codex session. + +## Implementing Against the Spec + +[`../README.md`][spec] states each requirement as an agent-agnostic decision rule, not tied to any +one hook API. [`../claude/gh-write-guard.py`][claude-hook] is a worked reference implementation of +the spec's currently-implemented requirements against Claude Code's `PreToolUse` hook, including +its tokenizer, its self-test matrix (`--selftest`), and its documented fail-open/fail-closed +choices per requirement -- useful as a model for argv parsing and edge cases, not as something to +port line for line, since Codex's own approval/sandbox extension points differ from Claude Code's +hook shape. Whatever mechanism Codex offers for intercepting or gating a command before it runs is +the place to implement the spec's requirements. If Codex offers no such extension point at all, +that finding belongs on [issue #781][issue-781], not silently worked around. + +## Auditing + +Once a Codex-side implementation exists, audit it the way [`../README.md`][spec] "Auditing an +Implementation Against This Spec" describes: run its own self-test and check every case against +the spec's requirements list, not against `gh-write-guard.py`'s source. + + +[spec]: ../README.md +[claude-hook]: ../claude/gh-write-guard.py +[issue-781]: https://github.com/ptr727/ProjectTemplate/issues/781 diff --git a/host-setup/agent-safety/opencode/README.md b/host-setup/agent-safety/opencode/README.md new file mode 100644 index 00000000..4711d43e --- /dev/null +++ b/host-setup/agent-safety/opencode/README.md @@ -0,0 +1,35 @@ +# opencode Write-Safety Gap + +No hook implements [`../README.md`][spec]'s requirements for opencode yet. Tracked at +[issue #781][issue-781]. + +## What To Keep Enabled Meanwhile + +Keep opencode's own permission model enabled. The carried repository rules (`GOVERNANCE.md` +"Repository Boundaries and Write Safety" and the rest of the fleet's prose) remain the only +behavioral layer in a fleet checkout until a hook exists -- there is no mechanical backstop for +opencode today, which means a mistake that the Claude Code hook would deny goes through unblocked +in an opencode session. + +## Implementing Against the Spec + +[`../README.md`][spec] states each requirement as an agent-agnostic decision rule, not tied to any +one hook API. [`../claude/gh-write-guard.py`][claude-hook] is a worked reference implementation of +the spec's currently-implemented requirements against Claude Code's `PreToolUse` hook, including +its tokenizer, its self-test matrix (`--selftest`), and its documented fail-open/fail-closed +choices per requirement -- useful as a model for argv parsing and edge cases, not as something to +port line for line, since opencode's own permission-model extension points differ from Claude +Code's hook shape. Whatever mechanism opencode offers for intercepting or gating a command before +it runs is the place to implement the spec's requirements. If opencode offers no such extension +point at all, that finding belongs on [issue #781][issue-781], not silently worked around. + +## Auditing + +Once an opencode-side implementation exists, audit it the way [`../README.md`][spec] "Auditing an +Implementation Against This Spec" describes: run its own self-test and check every case against +the spec's requirements list, not against `gh-write-guard.py`'s source. + + +[spec]: ../README.md +[claude-hook]: ../claude/gh-write-guard.py +[issue-781]: https://github.com/ptr727/ProjectTemplate/issues/781 diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md index 2070eeff..a5de6e62 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -25,9 +25,9 @@ host-setup\windows\setup-wsl.ps1 -Status **winget**, which arrives with App Installer from the Microsoft Store. -**Script execution.** A `git clone` carries no mark of the web, so these run under the default `RemoteSigned` policy. A browser-downloaded zip does carry one, and is blocked until `Unblock-File` clears the mark. The `.\` prefix is required when running a script from the current directory, exactly as it is for [`agent-safety/install.ps1`][agent-safety]. +**Script execution.** A `git clone` carries no mark of the web, so these run under the default `RemoteSigned` policy. A browser-downloaded zip does carry one, and is blocked until `Unblock-File` clears the mark. The `.\` prefix is required when running a script from the current directory, exactly as it is for [`agent-safety/claude/install.ps1`][agent-safety]. -`pwsh -File .\install-tools.ps1` answers the `.\` rule and **not** the policy, which still applies to it: on a marked file under `RemoteSigned` it fails with a `SecurityError` naming the file as unsigned. The form that runs whatever the policy says is `pwsh -ExecutionPolicy Bypass -File .\install-tools.ps1`, which is what [`docs/host-setup.md`][host-setup] already gives for the write-safety installer. Prefer clearing the mark with `Unblock-File` over bypassing, since the bypass covers every script that run touches. +`pwsh -File .\install-tools.ps1` answers the `.\` rule and **not** the policy, which still applies to it: on a marked file under `RemoteSigned` it fails with a `SecurityError` naming the file as unsigned. The form that gets past the mark is `pwsh -ExecutionPolicy Bypass -File .\install-tools.ps1`, the same shape [`agent-safety/claude/README.md`][agent-safety-claude-readme] gives for the write-safety installer -- `-ExecutionPolicy Bypass` sets only the process scope, which a machine- or user-level policy set by Group Policy still overrides. Prefer clearing the mark with `Unblock-File` over bypassing, since the bypass covers every script that run touches and does not universally guarantee the run. ## Why winget Is the Only Source @@ -148,7 +148,8 @@ The scripts are checked by `PSScriptAnalyzer`, which runs in CI as the peer of t -[agent-safety]: ../agent-safety/install.ps1 +[agent-safety]: ../agent-safety/claude/install.ps1 +[agent-safety-claude-readme]: ../agent-safety/claude/README.md [bootstrap]: ../bootstrap.sh [bootstrap-ps1]: ../bootstrap.ps1 [governance]: ../../GOVERNANCE.md diff --git a/scripts/README.md b/scripts/README.md index f416a0a8..3941be23 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -236,13 +236,13 @@ The tool validates the registry identity, target origin, feature-branch worktree ## `skills_install.py` -Installs the fleet's Skills for the current machine, cross-platform and idempotent, mirroring [`host-setup/agent-safety/install.py`][agent-safety-install]'s shape: `skills_install.sh` and `skills_install.ps1` are thin wrappers that locate a Python 3 interpreter and hand off, so every OS runs one tested code path. Two independent things happen on a run, since the three tools this fleet targets discover skills differently: `.agents/skills/` is materialized (not symlinked) to `$HOME/.agents/skills/`, so Codex and opencode's global scan covers every repo on the machine rather than only the one that happens to be open, and this repo's marketplace is registered with the `claude` CLI (`claude plugin marketplace add`, `claude plugin install`) so Claude Code loads the same content the other two read directly. The marketplace/plugin registration goes through the `claude` CLI's own commands rather than writing its internal `known_marketplaces.json` by hand, because that file's shape is the CLI's state, not a documented contract, and a hand-written copy risks drifting from what the CLI expects on its next release. +Installs the fleet's Skills for the current machine, cross-platform and idempotent, mirroring [`host-setup/agent-safety/claude/install.py`][agent-safety-install]'s shape: `skills_install.sh` and `skills_install.ps1` are thin wrappers that locate a Python 3 interpreter and hand off, so every OS runs one tested code path. Two independent things happen on a run, since the three tools this fleet targets discover skills differently: `.agents/skills/` is materialized (not symlinked) to `$HOME/.agents/skills/`, so Codex and opencode's global scan covers every repo on the machine rather than only the one that happens to be open, and this repo's marketplace is registered with the `claude` CLI (`claude plugin marketplace add`, `claude plugin install`) so Claude Code loads the same content the other two read directly. The marketplace/plugin registration goes through the `claude` CLI's own commands rather than writing its internal `known_marketplaces.json` by hand, because that file's shape is the CLI's state, not a documented contract, and a hand-written copy risks drifting from what the CLI expects on its next release. `--report` reads the stamp a prior run wrote (`$HOME/.agents/skills-install-stamp.json`, naming the hub commit installed) against the current checkout and says whether the machine is current, without installing anything. A repository whose `AGENTS.md` keeps needing a rule restated is usually this: the machine was never installed, or was installed from an older commit. -[agent-safety-install]: ../host-setup/agent-safety/install.py +[agent-safety-install]: ../host-setup/agent-safety/claude/install.py [agents-skills]: ../.agents/skills/README.md [audit]: ../spec/audit.py [copilot-instructions]: ../.github/copilot-instructions.md