From 6da0a160dba1be7cff55ce02e097d56a831f14b2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 29 Aug 2026 08:19:16 -0700 Subject: [PATCH 1/6] Move Claude Code Write-Safety Kit Files Under agent-safety/claude/ Pure rename, no code or content changes: gh-write-guard.py, install.py/.sh/.ps1, claude-md-safety.md, claude-md-fleet.md, test_install.py, and the nested markdownlint config all move as a unit into a new claude/ subdirectory, since every one of their internal path lookups is self-relative and keeps resolving unchanged. This is the first step of restructuring host-setup/agent-safety/ into an agent-agnostic spec plus per-agent implementation subdirectories (claude/, codex/, opencode/), mirroring how host-setup/linux/ and host-setup/windows/ are OS-specific subdirs of host-setup/. Refs #781, #1083. --- host-setup/agent-safety/{ => claude}/.markdownlint-cli2.jsonc | 0 host-setup/agent-safety/{ => claude}/README.md | 0 host-setup/agent-safety/{ => claude}/claude-md-fleet.md | 0 host-setup/agent-safety/{ => claude}/claude-md-safety.md | 0 host-setup/agent-safety/{ => claude}/gh-write-guard.py | 0 host-setup/agent-safety/{ => claude}/install.ps1 | 0 host-setup/agent-safety/{ => claude}/install.py | 0 host-setup/agent-safety/{ => claude}/install.sh | 0 host-setup/agent-safety/{ => claude}/test_install.py | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename host-setup/agent-safety/{ => claude}/.markdownlint-cli2.jsonc (100%) rename host-setup/agent-safety/{ => claude}/README.md (100%) rename host-setup/agent-safety/{ => claude}/claude-md-fleet.md (100%) rename host-setup/agent-safety/{ => claude}/claude-md-safety.md (100%) rename host-setup/agent-safety/{ => claude}/gh-write-guard.py (100%) rename host-setup/agent-safety/{ => claude}/install.ps1 (100%) rename host-setup/agent-safety/{ => claude}/install.py (100%) rename host-setup/agent-safety/{ => claude}/install.sh (100%) rename host-setup/agent-safety/{ => claude}/test_install.py (100%) 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/README.md b/host-setup/agent-safety/claude/README.md similarity index 100% rename from host-setup/agent-safety/README.md rename to host-setup/agent-safety/claude/README.md 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 From 35a3a5c572dd3985048412ed02485097ac76e07a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 29 Aug 2026 08:24:06 -0700 Subject: [PATCH 2/6] Add Agent-Agnostic Write-Safety Spec, Codex/opencode Gap Docs New host-setup/agent-safety/README.md states the kit's six requirements as agent-neutral decision rules, with Mermaid flow diagrams of the decision flow and of the hook-vs-prose-vs-loading distinction, a per-agent status table, and an auditing section -- the source of truth an implementation is built from and checked against, rather than another agent's source code. New codex/README.md and opencode/README.md state the gap plainly (no hook yet, tracked at #781), what to keep enabled meanwhile, and point at the spec's requirements plus claude/ as a reference implementation. claude/README.md is trimmed of the architecture rationale now carried by the root spec, and its relative links/install paths are corrected for the new depth (see the prior commit's move). Refs #781, #1083. --- host-setup/agent-safety/README.md | 146 +++++++++++++++++++++ host-setup/agent-safety/claude/README.md | 23 +++- host-setup/agent-safety/codex/README.md | 35 +++++ host-setup/agent-safety/opencode/README.md | 35 +++++ 4 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 host-setup/agent-safety/README.md create mode 100644 host-setup/agent-safety/codex/README.md create mode 100644 host-setup/agent-safety/opencode/README.md diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md new file mode 100644 index 00000000..41bba08c --- /dev/null +++ b/host-setup/agent-safety/README.md @@ -0,0 +1,146 @@ +# 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. A captured variable is + trusted.** Node ids resolve globally, so a fabricated, stale, or hand-typed id can land on a real + object in a different repository. A `-F name="$VAR"` value captured from a live query in the same + session is allowed. A literal id in the same position, prefixed `PR_`, `PRRT_`, `IC_`, or `BOT_`, + is denied. +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. 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). +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**: when the 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. Every other requirement here instead favors precision + over recall, 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) or flagged.** 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. +6. **A mutating git operation run directly against a primary checkout is denied.** "Primary" means: + not a linked worktree. The decidable test is a comparison, not a filesystem-shape guess -- `git + rev-parse --path-format=absolute --git-dir --git-common-dir` (or the equivalent call in whatever + VCS API the agent's runtime exposes) returns equal paths for a primary checkout and unequal paths + for a linked worktree. A `.git`-is-a-directory heuristic is wrong (a submodule's `.git` is a file + yet is still a primary working tree that can lose uncommitted work). Deny `checkout`/`switch`/ + `pull`/`reset`/`rebase`/`merge`/`cherry-pick`/`revert`/`restore`/`stash pop|apply|drop`/`clean + -f|-fd`/`add`/`commit`/`worktree remove -f|--force` there. Allow `worktree add|list|prune`, a plain + `worktree remove` with no force flag, any read, `merge --ff-only`/`pull --ff-only` (git's own + semantics mean neither can discard anything), and a flagless `checkout `/`switch ` (git + itself already refuses that form when it would overwrite a local modification) -- these are the + normal, documented way an agent uses a primary checkout as a fetch source and returns it to a base + branch afterward, and denying them adds no safety while breaking routine, correct work. Resolve the + target directory from an explicit `-C`/`--git-dir` argument, else a single leading `cd &&` + prefix (or the same with a bare command-separator instead of `&&`) on the same command, else the + invocation's own working directory. Fail + open (allow) when no git repository resolves at all, matching this requirement's own + precision-over-recall stance, not requirement 4's fail-closed one -- the harm here needs a + positively-identified primary checkout to fire on. + +## 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 on unresolved rules)"] + isgit -- no --> isprimary{"A mutating git op\ntargeting a primary\ncheckout? (req. 6)"} + isprimary -- yes, not exempt --> deny6["DENY - requirement 6"] + isprimary -- no / exempt op --> 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 +``` + +```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 +``` + +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 (requirement 6 below is the worked example). 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 | All 6 requirements, 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 +[issue-781]: https://github.com/ptr727/ProjectTemplate/issues/781 diff --git a/host-setup/agent-safety/claude/README.md b/host-setup/agent-safety/claude/README.md index a7da831b..3cd64016 100644 --- a/host-setup/agent-safety/claude/README.md +++ b/host-setup/agent-safety/claude/README.md @@ -1,6 +1,12 @@ # 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. +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 @@ -12,18 +18,19 @@ Into `~/.claude/` (or `%USERPROFILE%\.claude\` on Windows): - 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. +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/install.sh +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\install.ps1 +.\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. @@ -98,8 +105,10 @@ Every other key in the file is left as it stands, `permissions.allow` included, - **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. -[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 +[spec]: ../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 [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/codex/README.md b/host-setup/agent-safety/codex/README.md new file mode 100644 index 00000000..8eaa16c4 --- /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 +all six 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 requirements 1-6. 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 +six requirements, 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..9644a126 --- /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 +all six 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 requirements 1-6. 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 +six requirements, 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 From 01d0b9141b498869d0fe887788ffb51fdc510563 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 29 Aug 2026 08:28:58 -0700 Subject: [PATCH 3/6] Update External References for the agent-safety/claude/ Move Fixes every literal path, reference-style link, and prose mention outside host-setup/agent-safety/ that the prior two commits' rename and restructure left stale: install command paths and link targets in the root README, host-setup/windows/README.md, scripts/README.md, TODO.md, and OPERATIONS.md's coverage runbook. docs/host-setup.md 'Agent Write-Safety' is trimmed to a short pointer at the new spec and per-agent READMEs, matching the contract-vs-implementation split this file already uses for host-setup/linux/ and host-setup/windows/. The full grant-mechanism walkthrough it used to carry moved to host-setup/agent-safety/claude/README.md so it exists in exactly one place, and the Codex/opencode one-liners now point at their new gap docs instead of standing alone. host-setup/README.md's own architecture-rationale example is corrected: it described agent-safety/ as a flat concern directory holding three platform scripts, which the move made false; it now explains agent-safety/'s own per-agent subdirectories as the same platform-grouping shape linux/ and windows/ already use, one level down. Refs #781, #1083. --- OPERATIONS.md | 4 +- README.md | 18 ++++-- TODO.md | 2 +- docs/host-setup.md | 73 +++++++----------------- host-setup/README.md | 2 +- host-setup/agent-safety/claude/README.md | 38 ++++++++++-- host-setup/windows/README.md | 4 +- scripts/README.md | 4 +- 8 files changed, 73 insertions(+), 72 deletions(-) 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..76eb0dd8 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 or run directly against a primary checkout. [`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 @@ -342,6 +345,9 @@ 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-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/ 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/claude/README.md b/host-setup/agent-safety/claude/README.md index 3cd64016..b9340b09 100644 --- a/host-setup/agent-safety/claude/README.md +++ b/host-setup/agent-safety/claude/README.md @@ -65,15 +65,41 @@ Live end-to-end (in any repo): attempt a discarded-output write and confirm the 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 +## Granting a Write the Guard Denies -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. +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. -`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 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 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 grant goes in the checkout's `.claude/settings.local.json`, as an `env` block:** -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]. +```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) @@ -109,6 +135,6 @@ Every other key in the file is left as it stands, `permissions.allow` included, [codex]: ../codex/README.md [opencode]: ../opencode/README.md [governance]: ../../../GOVERNANCE.md -[host-setup-grant]: ../../../docs/host-setup.md#granting-a-write-the-guard-denies +[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/windows/README.md b/host-setup/windows/README.md index 2070eeff..bb14f732 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -25,7 +25,7 @@ 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. @@ -148,7 +148,7 @@ 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 [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 From 2f56a5e96089bd62b3247d728e75ebfafb3a3ac9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 29 Aug 2026 08:43:00 -0700 Subject: [PATCH 4/6] Fix local-strict-review Findings: Stale CI Path, Premature Requirement 6 A pre-push adversarial review caught two real defects and two dangling references this PR's own move/rewrite introduced: - .github/actions/validate/action.yml still ran the self-tests at the old flat paths, breaking the PR-gate CI run on this very branch. The earlier repo-wide sweep for stale references only grepped .github/workflows/, not a composite action under .github/actions/. - The new spec claimed all 6 requirements as implemented and covered by the Claude Code hook's self-test, including requirement 6 (deny a mutating git op against a primary checkout), which does not exist yet anywhere -- it is #1073's still-unmerged work. Corrected the spec, both Mermaid diagrams, the per-agent status table, and the codex/opencode gap docs to describe only what the hook actually enforces today; #1073's requirement lands in the spec in the same change that adds it to the hook. - Tightened the requirement 4 fail-closed description to name the branches it actually applies to (protected-default only), and requirement 2's node-id prefix list to read as illustrative rather than exhaustive, both matching the implementation exactly. - Repointed a dead anchor in the root README (the grant-mechanism section it pointed at moved to claude/README.md in the prior commit) and a stale cross-reference in host-setup/windows/README.md to the same effect. Refs #781, #1083. --- .github/actions/validate/action.yml | 4 +- README.md | 6 +-- host-setup/agent-safety/README.md | 59 +++++++++------------- host-setup/agent-safety/codex/README.md | 18 +++---- host-setup/agent-safety/opencode/README.md | 18 +++---- host-setup/windows/README.md | 2 +- 6 files changed, 48 insertions(+), 59 deletions(-) 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/README.md b/README.md index 76eb0dd8..8c498f62 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ 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 or run directly against a primary checkout. [`host-setup/agent-safety/claude/README.md`][agent-safety-claude] has the details. +- **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. @@ -223,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 the Guard Denies"][agent-safety-claude-grant]. ## Rules @@ -346,6 +346,7 @@ 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-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 @@ -370,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/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index 41bba08c..34f46417 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -27,8 +27,9 @@ hook or approval-gate API, not tied to Claude Code's `PreToolUse` JSON shape. 2. **A GraphQL mutation carrying a literal GitHub node id is denied. A captured variable is trusted.** Node ids resolve globally, so a fabricated, stale, or hand-typed id can land on a real object in a different repository. A `-F name="$VAR"` value captured from a live query in the same - session is allowed. A literal id in the same position, prefixed `PR_`, `PRRT_`, `IC_`, or `BOT_`, - is denied. + session is allowed. A literal id in the same position, 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. 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. A sibling repository under the same owner is allowed @@ -41,46 +42,32 @@ hook or approval-gate API, not tied to Claude Code's `PreToolUse` JSON shape. 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**: when the 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. Every other requirement here instead favors precision - over recall, 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. + **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) or flagged.** 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. -6. **A mutating git operation run directly against a primary checkout is denied.** "Primary" means: - not a linked worktree. The decidable test is a comparison, not a filesystem-shape guess -- `git - rev-parse --path-format=absolute --git-dir --git-common-dir` (or the equivalent call in whatever - VCS API the agent's runtime exposes) returns equal paths for a primary checkout and unequal paths - for a linked worktree. A `.git`-is-a-directory heuristic is wrong (a submodule's `.git` is a file - yet is still a primary working tree that can lose uncommitted work). Deny `checkout`/`switch`/ - `pull`/`reset`/`rebase`/`merge`/`cherry-pick`/`revert`/`restore`/`stash pop|apply|drop`/`clean - -f|-fd`/`add`/`commit`/`worktree remove -f|--force` there. Allow `worktree add|list|prune`, a plain - `worktree remove` with no force flag, any read, `merge --ff-only`/`pull --ff-only` (git's own - semantics mean neither can discard anything), and a flagless `checkout `/`switch ` (git - itself already refuses that form when it would overwrite a local modification) -- these are the - normal, documented way an agent uses a primary checkout as a fetch source and returns it to a base - branch afterward, and denying them adds no safety while breaking routine, correct work. Resolve the - target directory from an explicit `-C`/`--git-dir` argument, else a single leading `cd &&` - prefix (or the same with a bare command-separator instead of `&&`) on the same command, else the - invocation's own working directory. Fail - open (allow) when no git repository resolves at all, matching this requirement's own - precision-over-recall stance, not requirement 4's fail-closed one -- the harm here needs a - positively-identified primary checkout to fire on. + +**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 on unresolved rules)"] - isgit -- no --> isprimary{"A mutating git op\ntargeting a primary\ncheckout? (req. 6)"} - isprimary -- yes, not exempt --> deny6["DENY - requirement 6"] - isprimary -- no / exempt op --> isghwrite{"A GitHub-write\ncommand at all?"} + 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"] @@ -113,15 +100,16 @@ The first diagram is this spec's actual decision flow, generalized from `claude/ 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 (requirement 6 below is the worked example). 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. +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 | All 6 requirements, via a `PreToolUse` hook | [`claude/README.md`][claude] | +| 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] | @@ -144,3 +132,4 @@ spec": point the agent at this file's requirements, not at another agent's sourc [opencode]: ./opencode/README.md [governance]: ../../GOVERNANCE.md [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/codex/README.md b/host-setup/agent-safety/codex/README.md index 8eaa16c4..9083e72c 100644 --- a/host-setup/agent-safety/codex/README.md +++ b/host-setup/agent-safety/codex/README.md @@ -15,19 +15,19 @@ unblocked in a Codex session. [`../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 -all six 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 requirements 1-6. If Codex offers no such extension point at all, that finding belongs on -[issue #781][issue-781], not silently worked around. +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 -six requirements, not against `gh-write-guard.py`'s source. +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 diff --git a/host-setup/agent-safety/opencode/README.md b/host-setup/agent-safety/opencode/README.md index 9644a126..4711d43e 100644 --- a/host-setup/agent-safety/opencode/README.md +++ b/host-setup/agent-safety/opencode/README.md @@ -15,19 +15,19 @@ in an opencode session. [`../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 -all six 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 requirements 1-6. If opencode offers no such extension point at all, that finding belongs -on [issue #781][issue-781], not silently worked around. +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 -six requirements, not against `gh-write-guard.py`'s source. +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 diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md index bb14f732..58ad70cc 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -27,7 +27,7 @@ host-setup\windows\setup-wsl.ps1 -Status **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 runs whatever the policy says is `pwsh -ExecutionPolicy Bypass -File .\install-tools.ps1`, the same shape [`agent-safety/claude/README.md`][agent-safety] gives for the write-safety installer. Prefer clearing the mark with `Unblock-File` over bypassing, since the bypass covers every script that run touches. ## Why winget Is the Only Source From 3faa1417bb3d25e8fc4f40c934894e77d3d29046 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 29 Aug 2026 08:56:51 -0700 Subject: [PATCH 5/6] Fix Spec Wording Gaps and a Dangling Link Copilot/CodeRabbit Found Several review threads pointed at real gaps between the new spec's prose and the Claude hook's actual, documented behavior -- all wording fixes, no hook behavior changed: - Requirement 2 overstated a captured $VAR as verified rather than trusted by convention (a static hook cannot see a shell variable's runtime binding). - Requirement 3 omitted its own no-origin-resolves precondition. - Requirement 5 stated an unconditional denial where the hook's own documented, tested behavior allows a granted cross-owner target through (`_check_reply_resolve_helper`), since the helper it defers to already refuses a cross-owner target outright. - claude/README.md's 'it never blocks legitimate work' claim is softened, and its one stale 'Scope and Limits' bullet describing an already-fixed git-push-newline parser defect is removed (the tokenizer has treated a newline as a separator, and passed self-test cases proving it, since before this restructure -- only the doc lagged). - A grammar fix to a moved heading, its now-changed anchor updated at its one reference. - host-setup/windows/README.md:30's link reused the install.ps1 reference key for a sentence about the README, opening the wrong target; given its own key. Refs #781, #1083. --- README.md | 4 +- host-setup/agent-safety/README.md | 47 ++++++++++++++++-------- host-setup/agent-safety/claude/README.md | 5 +-- host-setup/windows/README.md | 3 +- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 8c498f62..20141926 100644 --- a/README.md +++ b/README.md @@ -223,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 [`host-setup/agent-safety/claude/README.md` "Granting a Write the Guard Denies"][agent-safety-claude-grant]. +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 @@ -346,7 +346,7 @@ 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-the-guard-denies +[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 diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index 34f46417..21369b11 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -24,19 +24,28 @@ hook or approval-gate API, not tied to Claude Code's `PreToolUse` JSON shape. `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. A captured variable is - trusted.** Node ids resolve globally, so a fabricated, stale, or hand-typed id can land on a real - object in a different repository. A `-F name="$VAR"` value captured from a live query in the same - session is allowed. A literal id in the same position, 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. +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. 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). + 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, @@ -51,10 +60,18 @@ hook or approval-gate API, not tied to Claude Code's `PreToolUse` JSON shape. 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) or flagged.** 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. + (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 diff --git a/host-setup/agent-safety/claude/README.md b/host-setup/agent-safety/claude/README.md index b9340b09..dd21e595 100644 --- a/host-setup/agent-safety/claude/README.md +++ b/host-setup/agent-safety/claude/README.md @@ -65,7 +65,7 @@ Live end-to-end (in any repo): attempt a discarded-output write and confirm the gh api graphql -f query='mutation{noop}' -F t="PRRT_x" >/dev/null 2>&1 || true # blocked by the hook ``` -## Granting a Write the Guard Denies +## 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. @@ -123,11 +123,10 @@ Every other key in the file is left as it stands, `permissions.allow` included, ## 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. +- **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. -- **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. diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md index 58ad70cc..c022d9ea 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -27,7 +27,7 @@ host-setup\windows\setup-wsl.ps1 -Status **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`, the same shape [`agent-safety/claude/README.md`][agent-safety] 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 runs whatever the policy says 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. Prefer clearing the mark with `Unblock-File` over bypassing, since the bypass covers every script that run touches. ## Why winget Is the Only Source @@ -149,6 +149,7 @@ The scripts are checked by `PSScriptAnalyzer`, which runs in CI as the peer of t [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 From 91406398de64f37de621f5c067395bd23a08ba0e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 29 Aug 2026 09:17:15 -0700 Subject: [PATCH 6/6] Note Execution-Policy Bypass Is Process-Scoped, Not a Guarantee CodeRabbit caught a real overstatement: 'runs whatever the policy says' implies -ExecutionPolicy Bypass always wins, when it only sets the process scope and a machine- or user-level Group Policy still overrides it. Refs #781, #1083. --- host-setup/windows/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md index c022d9ea..a5de6e62 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -27,7 +27,7 @@ host-setup\windows\setup-wsl.ps1 -Status **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`, the same shape [`agent-safety/claude/README.md`][agent-safety-claude-readme] 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