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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/actions/validate/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/.
Expand Down
4 changes: 2 additions & 2 deletions OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment thread
ptr727 marked this conversation as resolved.
uvx coverage@latest report
python3 scripts/build_dist.py --check
python3 scripts/repo_gate.py
Expand Down
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,21 +163,24 @@ Four things are deployed from here, and they land in different places. The host

The guardrails are **host state rather than repository content**, because they have to cover ad-hoc sessions in no project at all. Each provider's implementation stays separate:

- **Claude Code:** the installable safety kit denies a mis-targeted GitHub write under your identity. It also denies a git operation that would bypass a branch rule.
- **Codex:** no equivalent host hook ships yet. The carried repository rules and Codex's own sandbox and execution policies remain active. [Issue #781][issue-781] tracks the missing hook.
- **opencode:** no equivalent host hook ships yet. The carried repository rules and opencode's own permission model remain active. [Issue #781][issue-781] tracks the missing hook.
- **Claude Code:** the installable safety kit denies a mis-targeted GitHub write under your identity. It also denies a git operation that would bypass a branch rule. [`host-setup/agent-safety/claude/README.md`][agent-safety-claude] has the details.
- **Codex:** no equivalent host hook ships yet. The carried repository rules and Codex's own sandbox and execution policies remain active. [Issue #781][issue-781] tracks the missing hook, and [`host-setup/agent-safety/codex/README.md`][agent-safety-codex] states the gap.
- **opencode:** no equivalent host hook ships yet. The carried repository rules and opencode's own permission model remain active. [Issue #781][issue-781] tracks the missing hook, and [`host-setup/agent-safety/opencode/README.md`][agent-safety-opencode] states the gap.

The requirements every agent's kit is built and audited against, agent-agnostic, are in
[`host-setup/agent-safety/README.md`][agent-safety].

#### Claude Code

```shell
host-setup/agent-safety/install.sh # Linux, WSL, macOS
host-setup/agent-safety/claude/install.sh # Linux, WSL, macOS
```

```powershell
.\host-setup\agent-safety\install.ps1 # Windows, and the .\ prefix is required
.\host-setup\agent-safety\claude\install.ps1 # Windows, and the .\ prefix is required
```

Restart Claude Code sessions on the machine afterward so the hook and the `CLAUDE.md` blocks load. The installer is idempotent, so re-running it is also how a machine picks up an upstream change to the guard. What it installs, how to verify it, and what it deliberately does not catch are in [`host-setup/agent-safety/README.md`][agent-safety]. The surrounding host prerequisites are in [`docs/host-setup.md`][host-setup].
Restart Claude Code sessions on the machine afterward so the hook and the `CLAUDE.md` blocks load. The installer is idempotent, so re-running it is also how a machine picks up an upstream change to the guard. What it installs, how to verify it, and what it deliberately does not catch are in [`host-setup/agent-safety/claude/README.md`][agent-safety-claude]. The surrounding host prerequisites are in [`docs/host-setup.md`][host-setup].

### Install the Fleet Skills

Expand Down Expand Up @@ -220,7 +223,7 @@ A rule that cannot be diverged from is a rule people work around silently, which

The guard denies a `gh` write whose explicit target sits under an owner other than the checkout's `origin` owner, which is the shape that once put a stray comment on a stranger's repository. Sibling repositories under the same owner are allowed, so the denial appears only on a write that leaves the owner, and the common case that raises it is a fork, where `origin` is yours and `upstream` is the project you forked from.

The only way past it is a grant the maintainer makes **outside the session**, in `GH_WRITE_GUARD_ALLOW`. It is deliberately not something an agent can do for itself once blocked, so an inline `GH_WRITE_GUARD_ALLOW=owner/repo gh ...` prefix and an `export` inside a shell call both leave the write denied. The worked example, the file the grant goes in, and how to confirm one took effect are in [`docs/host-setup.md` "Granting a Write the Guard Denies"][host-setup-granting-a-write-the-guard-denies].
The only way past it is a grant the maintainer makes **outside the session**, in `GH_WRITE_GUARD_ALLOW`. It is deliberately not something an agent can do for itself once blocked, so an inline `GH_WRITE_GUARD_ALLOW=owner/repo gh ...` prefix and an `export` inside a shell call both leave the write denied. The worked example, the file the grant goes in, and how to confirm one took effect are in [`host-setup/agent-safety/claude/README.md` "Granting a Write That the Guard Denies"][agent-safety-claude-grant].

## Rules

Expand Down Expand Up @@ -342,6 +345,10 @@ Licensed under the [MIT License][license]\
<!-- Repo -->

[agent-safety]: ./host-setup/agent-safety/README.md
[agent-safety-claude]: ./host-setup/agent-safety/claude/README.md
[agent-safety-claude-grant]: ./host-setup/agent-safety/claude/README.md#granting-a-write-that-the-guard-denies
[agent-safety-codex]: ./host-setup/agent-safety/codex/README.md
[agent-safety-opencode]: ./host-setup/agent-safety/opencode/README.md
[agents]: ./AGENTS.md
[audit]: ./AUDIT.md
[catalog]: ./catalog/
Expand All @@ -364,7 +371,6 @@ Licensed under the [MIT License][license]\
[history]: ./HISTORY.md
[host-setup]: ./docs/host-setup.md
[host-setup-dir]: ./host-setup/
[host-setup-granting-a-write-the-guard-denies]: ./docs/host-setup.md#granting-a-write-the-guard-denies
[issue-781]: https://github.com/ptr727/ProjectTemplate/issues/781
[license]: ./LICENSE
[matrix]: ./reports/conformance-matrix.md
Expand Down
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
73 changes: 21 additions & 52 deletions docs/host-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

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

Expand Down Expand Up @@ -362,6 +329,9 @@ A host that fails any row is not ready for the procedure that row names, and the
<!-- Repo -->

[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
Expand All @@ -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

<!-- External -->

Expand Down
2 changes: 1 addition & 1 deletion host-setup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading