diff --git a/.claude/commands/provision.md b/.claude/commands/provision.md index 3515684..a563b72 100644 --- a/.claude/commands/provision.md +++ b/.claude/commands/provision.md @@ -34,6 +34,31 @@ non-`done` phase (re-running a phase's verification before trusting its `done`). interview. Update the file after every phase. Phases the human declines are recorded `skipped`, never silently dropped from the final report. +**One agent user per repo** is the recommended isolation model, so namespace the progress file per +user — `provision-progress-.json` — or a second run will collide with the first. Phases +1, 5, 7 (the nftables table) and 8 are **box-global**: done once, shared by every later agent user. +Only 2, 3, 4 and 6 repeat per repo. Say so during the interview; the second and third runs are short. + +## Field notes (read before phase 1 — these cost hours the first time) + +- **Paste hygiene.** Long lines and heredocs frequently arrive mangled: a fullscreen-renderer terminal + wraps them, and the copied text carries the wrap as a newline plus indentation — which breaks `<<'EOF'` + terminators and produces `IndentationError` from Python one-liners. **Keep every command under ~80 + characters**, prefer repeated short `echo … >> file` lines over heredocs, and for anything longer + write the file yourself into `/tmp/` and have the human `sudo install` it. This is the single + biggest source of friction in a real run. +- **Never accept a sub-agent's diagnosis without evidence.** `/orchestrator:harden` in particular + reports sandbox failures as "the kernel does not allow unprivileged user namespaces" and prescribes + `sysctl kernel.apparmor_restrict_unprivileged_userns=0` or container `--cap-add SYS_ADMIN`. Both are + wrong on a bare-metal Ubuntu box and the first strips a real mitigation machine-wide. Reproduce the + claim yourself (`bwrap --ro-bind / / true`, `cat /proc/self/uid_map`, `strace -f -e trace=execve`) + before changing any security setting. See HARDENING.md **4a** for the actual mechanism. +- **A permission-denied from your own account is not evidence of absence.** The agent's home is `0750`, + so `test -e ~/.ssh/authorized_keys` fails identically whether the file exists or not. Verify + as root. +- **`is-active` is not evidence a unit is healthy** — it reports `active` while a crash-looping unit is + mid-restart. Always read `journalctl --user -u -n 20` and check `NRestarts`. + ## 0. Interview (once — stored in `answers`) Ask, with defaults: @@ -52,7 +77,16 @@ Substitute the answers everywhere below (the docs' `recode-agent` placeholder = equivalent). - On Debian-family kernels, verify unprivileged user namespaces are enabled (`sysctl kernel.unprivileged_userns_clone` where present, else confirm `bwrap --ro-bind / / true` runs). -- Verify: every command above exists on PATH; `bwrap` smoke test passes. +- **Ubuntu ≥24.04: also check `kernel.apparmor_restrict_unprivileged_userns`.** If it is `1`, expect the + app-layer sandbox to fail in Phase 6 even though `bwrap` passes the smoke test — the restriction + strips capabilities from bwrap's *children*, which is where Claude Code builds its seccomp layer. + Read HARDENING.md **4a** and settle the decision (disable the app sandbox vs. grant + `sys_admin` to bwrap children) with the human *before* Phase 5, so managed settings are written once. +- **If `ufw` is active, leave `nftables.service` disabled** — it runs `/etc/nftables.conf`, which + conventionally starts with `flush ruleset` and would wipe ufw's rules at boot. Phase 7 loads its own + table from a dedicated unit instead. +- Verify: every command above exists on PATH; `bwrap` smoke test passes; note whether `/var/log/journal` + exists (journald persistence, a Phase 7 checklist item, is often already satisfied). ## Phase 2 — the agent user (`2-agent-user`, HARDENING step 1) @@ -68,53 +102,102 @@ You cannot mint these; guide the human through each and verify the result. The w verbatim, substituting the target repo(s): - **Fine-grained PAT** for the loop's push identity: *Only select repositories* = the target repo(s); repository permissions exactly Contents/Issues/Pull-requests read-write + Metadata read (Workflows - read-write only if the loop may push `.github/workflows/` changes); expiry set. -- **Bot token** for `bot-gh.sh` (`GH_BOT_TOKEN`): **classic** PAT with the single `repo` scope, minted - as the machine account (classic on purpose — fine-grained PATs cannot reliably target repos owned by - another personal account; one-time bot setup notes at the top of `.claude/scripts/bot-gh.sh`). + read-write only if the loop may push `.github/workflows/` changes; **Actions stays *No access*** — + CI reads go through the bot token); expiry set. It is installed in Phase 4 via + `gh auth login --with-token`, **not** written to `.env`. +- **Bot token** for `bot-gh.sh` (`GH_BOT_TOKEN`): **classic** PAT with the single `repo` scope — and + **not** `workflow`, or the bot can edit CI files through the Contents API regardless of the PAT's + Workflows setting. Minted as the machine account (classic on purpose — fine-grained PATs cannot + reliably target repos owned by another personal account; one-time bot setup notes at the top of + `.claude/scripts/bot-gh.sh`). - **Dedicated Anthropic API key** with a spend cap set in the console (skip if the box will use - subscription auth via `claude` login in Phase 4). + subscription auth via `claude` login in Phase 4). Note for the human: spend caps are set per + **workspace**, not per key, so one workspace per project is what makes the cap per-project. With + subscription auth there is no runaway bill to cap — it rate-limits instead — but each agent user + needs its own `claude` login, so check current concurrent-session terms before assuming it scales + to three boxes. - **Rotate every token that lived on the machine being replaced** — this is part of the migration, not - optional hygiene. + optional hygiene. Include any `.env` already sitting in the human's own clone on the new box. +- Warn the human **not to paste token values into the session** — they would land in the transcript. + They go straight from the browser into the agent's terminal. - Verify (after Phase 4's clone exists): `.env` present in the agent's clone, mode `600`, owned by the - agent user; old tokens confirmed revoked by the human. + agent user, containing **only** `GH_BOT_TOKEN`; bot write access confirmed with + `bot-gh.sh api repos// --jq .permissions` showing `"push": true` (a bare + `bot-gh.sh repo view` succeeds on any public repo and proves nothing); old tokens confirmed revoked. ## Phase 4 — the agent's clone (`4-clone`) Print for the human's terminal (needs a login shell as the agent user — `sudo -iu `): -- Install node (nvm), the `claude` CLI, and `gh` in the agent's own home; authenticate `claude` once. -- `git clone` the target repo; write `.env` (Phase 3) and machine-local state — e.g. - `.claude/state/ntfy-topic` if ntfy was chosen. +- Install node (nvm) and the `claude` CLI in the agent's own home; authenticate `claude` once. + Install **`gh` system-wide** (`sudo apt-get install -y gh`, as the admin) rather than per-home — the + loop's systemd unit needs it on a predictable `PATH`, and later agent users get it for free. +- Install the fine-grained PAT as the push identity: `gh auth login --with-token` then + `gh auth setup-git`. **Do not** put it in `.env` — the scripts source `.env` with `set -a`, so a + `GH_TOKEN`/`GITHUB_TOKEN` there would shadow `bot-gh.sh`'s bot identity and break the + bot-authored-PR property the owner's Approve depends on. +- `git clone` the target repo; write `.env` (Phase 3, `GH_BOT_TOKEN` only) and machine-local state — + e.g. `.claude/state/ntfy-topic` if ntfy was chosen. If the topic is a public ntfy.sh one, mention + that anyone who knows it can both read the alerts and publish fake ones; a random suffix fixes both. +- Re-run the `bwrap` smoke test **as the agent user** — a pass under the admin account does not + transfer, and on Ubuntu ≥24.04 the AppArmor userns policy is per-executable. - Verify from your side where possible (`sudo -u test -f ...` style checks via the human if your session can't): clone exists, `.env` mode `600`, `claude --version` and `gh --version` run as the - agent user. + agent user, `git ls-remote` succeeds, and `bot-gh.sh api user --jq .login` returns the **bot**, not + the owner — the two identities must differ. ## Phase 5 — managed settings (`5-managed`, HARDENING steps 3–4) - Materialize `/etc/claude-code/managed-settings.json` from HARDENING.md **Step 4**, root-owned `644`, with the sandbox **network allowlist here** (not in `settings.local.json`): Anthropic API, GitHub, the - notifier host, package registries the gates need. + notifier host, package registries the gates need. **Read the adapter first** — if `install` is empty + and the gates shell out to node/bash/git only, no package registry belongs in the list at all. +- **Do not include `permissions.disableBypassPermissionsMode`** unless the human has deliberately + chosen `dontAsk`: it disables the bypass mode the loop runs in. See HARDENING.md Step 4's warning. +- If Phase 1 flagged the Ubuntu userns restriction, this is where `{"sandbox": {"enabled": false}}` + gets written instead of the full block — per the decision taken in Phase 1. - Verify: file parses as JSON; `ls -l` shows `root root 644`; a write attempt as the agent user fails. ## Phase 6 — harden profile + arm the loop (`6-arm`) -- In a Claude Code session **as the agent user** inside the clone: run `/orchestrator:harden`, restart, - `/sandbox`, and its backstop checks (`sudo true` blocked under bypass; a write outside the repo fails). +- In a Claude Code session **as the agent user, inside the agent's clone** (not the human's — see the + field notes): run `/orchestrator:harden`, restart, `/sandbox`, and its backstop checks (`sudo true` + blocked under bypass; an edit of `~/.bashrc` blocked). Note the "write outside the repo fails" check + only applies when the app sandbox is on; with it disabled, the deny list and Unix permissions are + the fence, and the check should be recorded as N/A rather than skipped silently. +- Confirm `settings.local.json` actually landed in the agent's clone before arming — it is gitignored + and does not travel with a clone, and `/orchestrator:harden` reports success either way. - Then, in the agent's real terminal: `bash .claude/scripts/arm-loop.sh` (self-hosting this repo: - add `--gates-file self/gates.json`). -- Add the systemd hardening drop-in from the worked example (step 4's `[Service]` block) via - `systemctl --user edit pr-loop-.service`, then `daemon-reload` + restart. -- Verify: both units active as the agent user; `systemd-analyze security pr-loop-.service` reflects - the drop-in (score improves; `NoNewPrivileges=yes` shown). + add `--gates-file self/gates.json`). Check the adapter's `budget.stop_after_days` — if it is large, + pass an explicit short `--stop-after-days` for a first arming so the loop fails safe unattended. +- Units are named `pr-loop-.service` and `claude-rc-.service`, `` = repo directory + name lowercased. +- Add the **user-unit-safe** hardening drop-in (HARDENING.md worked example step 4) by writing + `~/.config/systemd/user/pr-loop-.service.d/hardening.conf`, then `daemon-reload` + restart. + The full system-unit block crash-loops a `--user` unit with `218/CAPABILITIES`. +- To stop the loop, use `bash .claude/scripts/loop-halt.sh --all` — drivers run in transient units + whose lifetime is decoupled from the daemon, so stopping the daemon alone leaves them running. Do + this before Phase 7, or an in-flight driver will be severed by the new egress rules. +- Verify with `journalctl --user -u pr-loop-.service -n 20` showing a clean + `loop-daemon: starting` line and `NRestarts` staying at 0 across a minute — **not** `is-active`, + which reports `active` mid-restart. `systemd-analyze security --user` grades user units against + system-unit expectations, so read its score as indicative only. ## Phase 7 — kernel egress + detection (`7-egress`, optional layers; HARDENING steps 5–6) -If chosen in the interview: +If chosen in the interview — and **after** halting the loop, so an in-flight driver isn't severed: - nftables: materialize the worked example's UID-matched output chain for the agent user, allowed set = exactly the hosts from Phase 5's allowlist; **log the drops** and wire the drop log into the notifier — - blocked egress from this box is the intrusion alarm. -- auditd watches on `.claude/scripts/`, `self/` (if self-hosting), `.env`, and the unit files; - journald `Storage=persistent`; the root-owned origin/main divergence timer if the human wants it. + blocked egress from this box is the intrusion alarm. Run that notifier as **root**, since the chain + matches the agent's UID. Load the table from its own oneshot unit if `ufw` is active (never enable + `nftables.service` alongside ufw — it flushes the ruleset), use `policy accept` plus a trailing + explicit `drop` rather than `policy drop`, and refresh the address set from DNS on a ~5-minute timer + because CDN addresses rotate. Warn the human that the refresh race can produce occasional spurious + drops, and that the coarse alternative (any-destination TCP 443) yields no exfiltration alarm. +- auditd watches on `.claude/scripts/`, `self/` (if self-hosting), `.env`, `settings.local.json`, the + unit files, and the managed-settings file; journald `Storage=persistent` (often already satisfied if + `/var/log/journal` exists); the root-owned origin/main divergence timer if the human wants it. +- Because the human's terminal mangles long pastes, **write these files yourself** into a scratch + directory and hand over `sudo install` one-liners rather than heredocs. - Verify: `curl https://example.com` **as the agent user** is dropped AND the drop is logged/notified; the loop's own hosts still work (`curl -sI https://api.github.com` as the agent user succeeds). diff --git a/.claude/settings.json b/.claude/settings.json index 7db3015..9ae09d6 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -123,9 +123,7 @@ "Read(~/.docker/config.json)", "Read(//mnt/**)", "Edit(//mnt/**)", - "Write(//mnt/**)", - "Edit(//etc/**)", - "Write(//etc/**)" + "Edit(//etc/**)" ] }, "sandbox": { diff --git a/docs/HARDENING.md b/docs/HARDENING.md index 81265b8..d7e8970 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -102,41 +102,48 @@ Starting point — adapt the lists to your stack, then drop into `.claude/settin "Read(~/.docker/config.json)", // host filesystem — WSL only; see Step 2 "Bash(cmd.exe:*)", "Bash(powershell.exe:*)", "Bash(pwsh:*)", "Bash(wsl.exe:*)", - "Bash(/mnt:*)", "Read(//mnt/**)", "Edit(//mnt/**)", "Write(//mnt/**)", - "Edit(//etc/**)", "Write(//etc/**)", + "Bash(/mnt:*)", "Read(//mnt/**)", "Edit(//mnt/**)", + "Edit(//etc/**)", // ── Edit/Write fence (PORTABLE — copy as-is, no project paths) ────────── // The Edit/Write TOOLS are NOT confined by the OS Bash sandbox (that only // confines Bash subprocesses). Their only fence is the deny list + OS file // ownership. Deny the sensitive paths OUTSIDE any project; the project stays // writable by omission (deny beats allow, so you can't "allow-back" — see note). + // + // USE `Edit(path)` ONLY — NOT `Write(path)`. File permission checks match + // `Edit(...)` rules, and an Edit rule covers EVERY file-editing tool + // (Edit, Write, NotebookEdit). A `Write(...)` rule matches nothing; Claude + // Code emits a startup warning per rule and the path is left unfenced if + // that is the only rule you wrote for it. Pairing both was harmless but + // noisy, so the twins were removed here. // shell init & profile (run on next shell = persistence / code-exec) - "Edit(~/.bashrc)", "Write(~/.bashrc)", - "Edit(~/.bash_profile)", "Write(~/.bash_profile)", - "Edit(~/.profile)", "Write(~/.profile)", - "Edit(~/.zshrc)", "Write(~/.zshrc)", - "Edit(~/.zprofile)", "Write(~/.zprofile)", - "Edit(~/.zshenv)", "Write(~/.zshenv)", + "Edit(~/.bashrc)", + "Edit(~/.bash_profile)", + "Edit(~/.profile)", + "Edit(~/.zshrc)", + "Edit(~/.zprofile)", + "Edit(~/.zshenv)", // git config (hooks / aliases = code-exec on next git command) - "Edit(~/.gitconfig)", "Write(~/.gitconfig)", - "Edit(~/.config/git/**)", "Write(~/.config/git/**)", + "Edit(~/.gitconfig)", + "Edit(~/.config/git/**)", // credentials (Edit/Write — Read already denied above) - "Edit(~/.ssh/**)", "Write(~/.ssh/**)", - "Edit(~/.gnupg/**)", "Write(~/.gnupg/**)", - "Edit(~/.aws/**)", "Write(~/.aws/**)", - "Edit(~/.config/gcloud/**)", "Write(~/.config/gcloud/**)", - "Edit(~/.kube/**)", "Write(~/.kube/**)", - "Edit(~/.npmrc)", "Write(~/.npmrc)", - "Edit(~/.docker/config.json)", "Write(~/.docker/config.json)", + "Edit(~/.ssh/**)", + "Edit(~/.gnupg/**)", + "Edit(~/.aws/**)", + "Edit(~/.config/gcloud/**)", + "Edit(~/.kube/**)", + "Edit(~/.npmrc)", + "Edit(~/.docker/config.json)", // login-time / startup persistence - "Edit(~/.config/systemd/**)", "Write(~/.config/systemd/**)", - "Edit(~/.config/autostart/**)", "Write(~/.config/autostart/**)", + "Edit(~/.config/systemd/**)", + "Edit(~/.config/autostart/**)", // Claude Code's OWN guardrails — surgical, NOT all of ~/.claude (memory/state live there) - "Edit(~/.claude/settings.json)", "Write(~/.claude/settings.json)", - "Edit(~/.claude/settings.local.json)", "Write(~/.claude/settings.local.json)", + "Edit(~/.claude/settings.json)", + "Edit(~/.claude/settings.local.json)", // this project's permission files (stop the agent removing its own deny rules) - "Edit(.claude/settings.json)", "Write(.claude/settings.json)", - "Edit(.claude/settings.local.json)", "Write(.claude/settings.local.json)" + "Edit(.claude/settings.json)", + "Edit(.claude/settings.local.json)" ] }, "sandbox": { @@ -341,9 +348,13 @@ sudo -iu recode-agent # how YOU inspect/operate it No `~/.ssh/authorized_keys` for `recode-agent` — it is reachable only via your account + `sudo -u`. **2. Fresh credentials, minted for the box (never copied from your workstation):** -- A **fine-grained GitHub PAT** scoped to only the target repo(s) — into `~recode-agent//.env` - (mode `600`). Mint it at github.com → *Settings → Developer settings → Personal access tokens → - Fine-grained tokens → Generate new token*: +- A **fine-grained GitHub PAT** — the loop's **push identity**. Install it with + `gh auth login --with-token` + `gh auth setup-git` as the agent user; **do not put it in `.env`.** + The scripts source `.env` with `set -a`, so a `GH_TOKEN`/`GITHUB_TOKEN` there would be exported into + *every* `gh` call — including `bot-gh.sh`, which would then run as the owner instead of the bot, and + the owner could no longer formally approve the resulting PRs. `.env` holds `GH_BOT_TOKEN` and + nothing else token-shaped. Mint it at github.com → *Settings → Developer settings → Personal access + tokens → Fine-grained tokens → Generate new token*: - **Resource owner**: the account/org that owns the target repo(s). - **Repository access**: *Only select repositories* → the target repo(s), nothing else. - **Repository permissions** — exactly these, everything else stays *No access*: @@ -354,6 +365,7 @@ No `~/.ssh/authorized_keys` for `recode-agent` — it is reachable only via your | Pull requests | Read and write | open, update, comment on PRs | | Metadata | Read-only | mandatory (auto-selected) | | Workflows | Read and write — **only if** the loop may push changes under `.github/workflows/` | without it such pushes are refused (workflow-scope push restriction); leave at *No access* and keep CI files human-edited otherwise | + | Actions | **No access** | the loop reads CI state (`statusCheckRollup`, failing-check detection) through `bot-gh.sh`, i.e. on the *bot's* classic token — whose `repo` scope already covers the Actions API. Granting Actions here adds reach nothing consumes. | - **No account permissions, nothing administrative.** Set an **expiration** (≤90 days) and put the rotation date somewhere you'll see it. - The **bot token** (`GH_BOT_TOKEN` for `bot-gh.sh`) is a separate credential and deliberately @@ -362,6 +374,16 @@ No `~/.ssh/authorized_keys` for `recode-agent` — it is reachable only via your Developer settings → Tokens (classic) → Generate new token (classic)* with the single `repo` scope, expiry set. Full one-time bot setup (machine account, write-collaborator invite) is in the notes at the top of `.claude/scripts/bot-gh.sh`. + **`repo` only — never add `workflow`.** With `workflow` the bot could edit `.github/workflows/` + through the Contents API, re-opening from the bot's side the boundary you closed by leaving + *Workflows* at *No access* on the fine-grained PAT. Verify the grant with + `bot-gh.sh api repos// --jq .permissions` → expect `"push": true` (a bare + `bot-gh.sh repo view` proves nothing on a public repo). + **One bot, many repos = one shared credential.** `bot-gh.sh` reuses a single machine account across + every repo (GitHub ToS allows one free one), so the same `GH_BOT_TOKEN` ends up in each agent user's + `.env`. Per-repo Unix users isolate the *fine-grained* PATs from each other but **not** the bot + token: compromise of any one agent user yields bot-write on all of them. Org-owned repos avoid this + (fine-grained PATs work reliably there, so each agent can hold its own per-repo bot token). - A **dedicated Anthropic API key** with a spend cap set in the console. - Rotate whatever token previously lived on the old machine as part of the migration. @@ -371,28 +393,70 @@ escalate, `disableBypassPermissionsMode` / `allowManagedDomainsOnly` / `allowMan become structural guarantees rather than conventions. Put the sandbox network allowlist here, not in `settings.local.json`. -**4. systemd unit hardening.** `arm-loop.sh` stamps user units from `.claude/systemd/*.service`; run -it as `recode-agent` and add a drop-in (`systemctl --user edit pr-loop-.service`) — or promote -to system units with `User=recode-agent` for the full directive set: +**4. systemd unit hardening.** `arm-loop.sh` stamps **user** units (`pr-loop-.service` and +`claude-rc-.service`, where `` is the repo directory name lowercased). Add a drop-in as +`recode-agent` at `~/.config/systemd/user/pr-loop-.service.d/hardening.conf`: ```ini [Service] NoNewPrivileges=yes -ProtectSystem=strict -ReadWritePaths=/home/recode-agent -PrivateTmp=yes -ProtectKernelModules=yes -ProtectKernelTunables=yes -ProtectControlGroups=yes -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +UMask=0077 MemoryMax=8G CPUQuota=200% ``` +> ⚠️ **This is the user-unit-safe set. Do not paste the full system-unit block into a `--user` unit.** +> `ProtectKernelModules=`, `ProtectKernelTunables=` and `ProtectControlGroups=` imply +> `CapabilityBoundingSet=` changes, and `PR_CAPBSET_DROP` requires `CAP_SETPCAP` — which an +> unprivileged `systemd --user` manager does not have. The daemon then dies before it starts with +> `status=218/CAPABILITIES` ("Failed to drop capabilities: Operation not permitted") and restart-loops +> indefinitely; `systemctl --user is-active` may still report `active` because it catches the unit +> mid-restart, so **always confirm with `journalctl --user -u pr-loop-.service -n 20`**. +> +> `ProtectSystem=strict`, `ReadWritePaths=` and `PrivateTmp=` need **mount** namespaces, which need +> `CAP_SYS_ADMIN` in a user namespace — unavailable under the Ubuntu ≥24.04 userns restriction (see +> the AppArmor subsection below). Test them one at a time and drop them if the unit fails to start. +> +> For the **full** directive set — including real filesystem confinement — promote the loop to a +> **system** unit with `User=recode-agent`, where systemd has the privileges to enforce it. The +> trade-off: `arm-loop.sh` writes user units, so every re-arm re-creates them and your system unit +> must be maintained alongside. + This constrains even *merged, reviewed* daemon code — the layer nothing else on this page provides. (`MemoryMax`/`CPUQuota` also stop a runaway driver from taking the box down.) +**4a. Ubuntu ≥24.04: the app-layer sandbox and the userns restriction collide.** On a stock +bare-metal Ubuntu 24.04+ install (unlike WSL2, whose Microsoft kernel does not enforce the policy), +`kernel.apparmor_restrict_unprivileged_userns=1` is active and `/etc/apparmor.d/bwrap-userns-restrict` +stacks every process `bwrap` execs into the `unpriv_bwrap` profile, which contains `audit deny +capability`. Claude Code shells out to `/usr/bin/bwrap` and then builds a **second** namespace inside +it for its seccomp layer — which needs `CAP_SYS_ADMIN` in that child. Result: every Bash call fails +with + +``` +apply-seccomp: write /proc/self/setgroups + (nested userns is capability-restricted; caller must provide CAP_SYS_ADMIN): Permission denied +``` + +and, because `failIfUnavailable: true` is fail-closed, **the loop cannot run at all**. Diagnosis +notes, so you don't re-derive them: `bwrap --ro-bind / / true` *passes* (the restriction is not a +kernel-wide userns block); `cat /proc/self/attr/current` returns `unconfined` outside bwrap and +`bwrap//&unpriv_bwrap (enforce)` inside; `strace -f -e trace=execve` shows the `execve("/usr/bin/bwrap", …)`. +Flatpak-style single-layer bwrap use is unaffected — only nested sandboxing collides. + +Three ways out, in the order we'd pick them: + +| Option | What it costs | +|---|---| +| **Disable the app sandbox** (`{"sandbox": {"enabled": false}}` in managed settings) and rely on the dedicated UID, `0750` home, the `Edit()` deny fence, systemd hardening, and step 5's egress allowlist | Loses filesystem confinement *within* the agent's own home. Coherent on a dedicated box, where the OS boundaries are real — which is exactly what WSL never had. | +| **Grant the capability back to bwrap children**: `echo 'allow capability sys_admin,' > /etc/apparmor.d/local/unpriv_bwrap && apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict` | Re-opens the userns→`CAP_SYS_ADMIN` kernel attack surface for anything that can run bwrap — i.e. the agent you are containing. Keep the kernel patched if you take it. | +| **`sysctl kernel.apparmor_restrict_unprivileged_userns=0`** | **Don't.** Removes the mitigation for *every* binary on the box to fix one program's nesting. | + +Note that dropping only `sandbox.network` does **not** help — the capability is required by the core +seccomp layer, not just the domain-filtering proxy. (Tested; it fails identically.) + **5. Kernel-level egress allowlist.** The app-layer sandbox can in principle be escaped; an nftables rule matched on the agent's UID cannot be talked around by an injected model. Allow only what the loop needs — the Anthropic API, GitHub, your notifier — and **log the drops**: @@ -413,6 +477,25 @@ table inet recode_agent { ``` Wire the drop log to your notifier — blocked egress from this box is your intrusion alarm, not noise. +(Run that notifier as **root**: the chain matches the agent's UID, so root's own `curl` to the notifier +is not subject to the allowlist it is reporting on.) + +Three things the sketch above glosses over, learned the hard way: + +- **Coexisting with `ufw`.** If `ufw` is active, do **not** enable `nftables.service` — it runs + `/etc/nftables.conf`, which conventionally starts with `flush ruleset` and would wipe ufw's rules at + boot. Load your table from its own `oneshot` unit (`ExecStart=/usr/sbin/nft -f …`, + `ExecStop=/usr/sbin/nft delete table inet recode_agent`, `After=ufw.service`) so it only ever adds + its own table. +- **`policy accept` + an explicit trailing `drop`,** not `policy drop`. A UID-matched chain with a drop + policy will bite anything you forgot, including your own recovery shell, on a box you may only reach + over SSH. +- **The allowlist rots.** GitHub and Anthropic sit behind CDNs whose addresses rotate, so a static IP + set silently breaks the loop days later. Refresh it from DNS on a timer (5 minutes works) and accept + a small race: the loop can resolve an address the set does not yet contain and get dropped. If you + would rather not run a resolver-refresh loop, the alternative is coarse — permit DNS and TCP 443 to + *any* destination — but understand that this stops odd ports and non-HTTPS exfil only, and gives you + no meaningful exfiltration alarm, which is most of the value. **6. Detection.** Single-purpose boxes make auditing cheap: - `auditd` watches on `.claude/scripts/`, `.claude/self/`, `.env`, and the unit files — any write @@ -422,17 +505,42 @@ Wire the drop log to your notifier — blocked egress from this box is your intr - Persistent journald (`Storage=persistent`) so a post-incident timeline survives a reboot. **7. Migration mechanics** (from a WSL/workstation install): install `bubblewrap` + `socat` (on -Debian-family kernels check unprivileged user namespaces are enabled), fresh clone as -`recode-agent`, write `.env` and any machine-local state (e.g. `.claude/state/ntfy-topic`), apply -Steps 1–4, then re-arm with `arm-loop.sh`. WSL-specific mitigations (interop/`/mnt` severing, the -Windows-side watchdog) retire with the old host. +Debian-family kernels check unprivileged user namespaces are enabled — and read **4a** first, because +on Ubuntu ≥24.04 "enabled" does not mean the app sandbox will work), fresh clone as `recode-agent`, +write `.env` and any machine-local state (e.g. `.claude/state/ntfy-topic`), apply Steps 1–4, then +re-arm with `arm-loop.sh`. WSL-specific mitigations (interop/`/mnt` severing, the Windows-side +watchdog) retire with the old host — as do any `//mnt/**` deny rules, which are dead weight elsewhere. + +Two traps in that sequence: + +- **Run `/orchestrator:harden` *inside the agent's clone*, as the agent user.** It writes + `.claude/settings.local.json`, which is **gitignored** — so it does not travel with a `git clone`, + and running the command in your own checkout silently hardens the wrong copy while reporting + success. Symptom: the agent's Claude Code sessions print only the committed-`settings.json` + warnings and none of the local deny rules. Verify with + `ls -l ~//.claude/settings.local.json` before arming, and confirm + `grep defaultMode` shows `bypassPermissions`. +- **Install a per-repo agent user per repo, not one shared account.** Three loops under one UID means + three repos' `.env` files readable by one compromised process, which makes the per-repo PAT scoping + decorative. Ubuntu's default `HOME_MODE=0750` plus a private user group already prevents one agent + user from traversing another's home — no `chmod` needed. Box-global phases (prerequisites, managed + settings, the nftables table, remote SSH) are done once and shared; only the user, credentials, + clone, and arming repeat. **Checklist deltas** (on top of the Step 3 checklist): -- [ ] Agent user has no sudo/docker membership, no SSH keys, reachable only via your account. +- [ ] Agent user has no sudo/docker membership, no SSH keys, reachable only via your account + (`sudo -l -U ` and `sudo ls ~/.ssh` — check as root; a permission-denied from your + own account proves nothing). - [ ] Managed settings root-owned; agent cannot edit `/etc/claude-code/`. -- [ ] Unit hardening directives active (`systemd-analyze security pr-loop-.service`). -- [ ] Egress allowlist live; a `curl https://example.com` as `recode-agent` is dropped AND logged. -- [ ] PAT is fine-grained + repo-scoped + expiring; API key spend-capped; old tokens rotated. +- [ ] `settings.local.json` exists **in the agent's clone** with `defaultMode: bypassPermissions`. +- [ ] Unit hardening drop-in uses the **user-unit-safe** directive set, and + `journalctl --user -u pr-loop-.service` shows a clean `loop-daemon: starting` line + (not `218/CAPABILITIES`). `is-active` alone is not evidence — it reports `active` mid-restart. +- [ ] Egress allowlist live; a `curl https://example.com` as `recode-agent` is dropped AND logged AND + lands a notification; `curl -sI https://api.github.com` as the same user still succeeds. +- [ ] PAT is fine-grained + repo-scoped + expiring and lives in `gh auth`, not `.env`; bot token is + classic `repo`-only with `"push": true`; API key spend-capped (or subscription auth chosen + deliberately); old tokens rotated. - [ ] auditd + divergence timer alerting into the same channel as the loop's `notify` seam. --- @@ -477,9 +585,6 @@ settings**, a root-owned file the agent can't touch: ```jsonc { - "permissions": { - "disableBypassPermissionsMode": "disable" // pins the bypass decision; local settings can't widen it - }, "sandbox": { "enabled": true, "failIfUnavailable": true, @@ -490,6 +595,13 @@ settings**, a root-owned file the agent can't touch: } ``` +> ⚠️ **Do NOT add `permissions.disableBypassPermissionsMode: "disable"` while the loop runs in +> `bypassPermissions`.** Despite the name reading like "pin the bypass decision", this key *disables* +> bypass mode outright. `/orchestrator:harden` writes `defaultMode: "bypassPermissions"` and +> `arm-loop.sh` reads that value to launch the daemon, so setting it stalls every driver on permission +> prompts no human is there to answer. Use it **only** together with a deliberate switch to `dontAsk` +> (see the note below) and a `permissions.allow` list covering every command the loop runs. + Managed settings win over every other scope, and deny rules from any scope still beat allow rules from a lower one. This is the software-side equivalent of the OS-level isolation in Step 2: a boundary the agent operates *inside*, not one it configures.