| <!-- SPDX-License-Identifier: Apache-2.0 |
| https://www.apache.org/licenses/LICENSE-2.0 --> |
| |
| <!-- START doctoc generated TOC please keep comment here to allow auto update --> |
| <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> |
| **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* |
| |
| - [Secure agent setup](#secure-agent-setup) |
| - [Quick start](#quick-start) |
| - [Agent-guided (recommended)](#agent-guided-recommended) |
| - [Manual (if you do not want the agent-guided path)](#manual-if-you-do-not-want-the-agent-guided-path) |
| - [Required tools](#required-tools) |
| - [Install commands](#install-commands) |
| - [Distro-specific shortcut — Linux Mint 22.x / Ubuntu 24.04 Noble](#distro-specific-shortcut--linux-mint-22x--ubuntu-2404-noble) |
| - [Bumping a pinned version](#bumping-a-pinned-version) |
| - [Raising the claude-code floor](#raising-the-claude-code-floor) |
| - [Wiring the check script into a weekly routine](#wiring-the-check-script-into-a-weekly-routine) |
| - [The framework's own `.claude/settings.json`](#the-frameworks-own-claudesettingsjson) |
| - [Project-root coverage in the sandbox allowlists](#project-root-coverage-in-the-sandbox-allowlists) |
| - [Why project-local, not user-scope and not committed-project](#why-project-local-not-user-scope-and-not-committed-project) |
| - [Security rationale — why project-local is safe to write to](#security-rationale--why-project-local-is-safe-to-write-to) |
| - [`sandbox-add-project-root.sh`](#sandbox-add-project-rootsh) |
| - [When the helper runs](#when-the-helper-runs) |
| - [Per-project vs whole-user scope](#per-project-vs-whole-user-scope) |
| - [The clean-env wrapper](#the-clean-env-wrapper) |
| - [Automatic sandbox allow-paths](#automatic-sandbox-allow-paths) |
| - [Sandbox-bypass visibility hook](#sandbox-bypass-visibility-hook) |
| - [Why install it user-scope, not project-scope](#why-install-it-user-scope-not-project-scope) |
| - [Install (user-scope)](#install-user-scope) |
| - [Verify](#verify) |
| - [Trade-offs](#trade-offs) |
| - [Agent-guard deterministic guard hook](#agent-guard-deterministic-guard-hook) |
| - [Extensible — any skill can contribute a guard](#extensible--any-skill-can-contribute-a-guard) |
| - [Install (user-scope)](#install-user-scope-1) |
| - [Verify](#verify-1) |
| - [Sandbox-error hint hook](#sandbox-error-hint-hook) |
| - [Why install it](#why-install-it) |
| - [Why install it user-scope, not project-scope](#why-install-it-user-scope-not-project-scope-1) |
| - [Install (user-scope)](#install-user-scope-2) |
| - [Verify](#verify-2) |
| - [Trade-offs](#trade-offs-1) |
| - [Sandbox-state status line](#sandbox-state-status-line) |
| - [Waiting-for-input terminal tint](#waiting-for-input-terminal-tint) |
| - [Syncing user-scope config across machines](#syncing-user-scope-config-across-machines) |
| - [What to track, what not to track](#what-to-track-what-not-to-track) |
| - [Layout](#layout) |
| - [Setting up a fresh host](#setting-up-a-fresh-host) |
| - [A minimal `sync.sh`](#a-minimal-syncsh) |
| - [Extending `sync.sh`: share project memory across machines](#extending-syncsh-share-project-memory-across-machines) |
| - [Extending `sync.sh`: expose tracked scripts on `$PATH`](#extending-syncsh-expose-tracked-scripts-on-path) |
| - [Why a *private* repo](#why-a-private-repo) |
| - [Adopter setup](#adopter-setup) |
| - [Direct manual install](#direct-manual-install) |
| - [Via a Claude Code prompt](#via-a-claude-code-prompt) |
| - [Verification](#verification) |
| - [Direct Bash verification](#direct-bash-verification) |
| - [Via a Claude Code prompt](#via-a-claude-code-prompt-1) |
| - [Keeping the setup updated](#keeping-the-setup-updated) |
| - [Direct steps](#direct-steps) |
| - [Via a Claude Code prompt](#via-a-claude-code-prompt-2) |
| - [What a session looks like](#what-a-session-looks-like) |
| - [See also](#see-also) |
| |
| <!-- END doctoc generated TOC please keep comment here to allow auto update --> |
| |
| <!-- SPDX-License-Identifier: Apache-2.0 |
| https://www.apache.org/licenses/LICENSE-2.0 --> |
| |
| # Secure agent setup |
| |
| **Audience: adopters.** This document walks through every install |
| step for the secure agent setup — pinned tool versions, the |
| framework's `.claude/settings.json`, the `claude-iso` clean-env |
| wrapper, the sandbox-bypass-warn hook, the sandbox-state status |
| line, multi-host syncing, the agent-guided install / verify / |
| keep-updated prompts, and the five session screenshots that show |
| what a working setup looks like in action. Read this end-to-end |
| and you will have the secure setup running. |
| |
| **Why** this setup is shaped the way it is — the threat model it |
| addresses, how the three layers fit together, what bubblewrap / |
| Seatbelt actually do at the OS layer, where the residual blind |
| spots are — lives in the companion document |
| [`secure-agent-internals.md`](secure-agent-internals.md). It is |
| optional reading for adopters; required reading for anyone |
| modifying the setup or debugging an unexpected denial. |
| |
| The framework's tracker repo and `<security-list>` thread content are |
| **pre-disclosure CVE material**. A default agent session with |
| unfettered access to `~/`, all environment variables, and a |
| permissive network egress can — by accident or via a prompt-injection |
| attack hidden in an inbound report — exfiltrate cloud credentials, |
| SSH keys, GitHub tokens, the Gmail OAuth refresh token, and similar |
| host-level secrets. This setup does not eliminate that risk; it |
| reduces it to the project tree. |
| |
| ## Quick start |
| |
| If you just want the secure setup running, follow this short |
| path. The rest of the document below expands every bullet here |
| with the *why* and the trade-offs; you can return to it whenever |
| you want the full picture. For the rationale and mechanism behind |
| each layer, see |
| [`secure-agent-internals.md`](secure-agent-internals.md). |
| |
| ### Agent-guided (recommended) |
| |
| If you have Claude Code installed and a clone of `magpie` |
| on the host, the framework ships six skills that walk every |
| step interactively. Each surfaces sudo / shell-rc / settings-file |
| changes for explicit approval before applying — nothing |
| privilege-elevating runs without you saying so. |
| |
| ```text |
| 1. Open Claude Code in your tracker repo (or any directory). |
| 2. If you consume the framework as a gitignored snapshot managed |
| by `setup` (the canonical adopter pattern), run |
| `/magpie-setup verify` to confirm the snapshot at |
| `.apache-magpie/`, the committed `.apache-magpie.lock`, and |
| the project-config files are wired correctly. Read-only — |
| surfaces gaps, never auto-fixes. |
| 3. Run /magpie-setup-isolated-setup-install — guided first-time install of |
| the secure-agent setup (sandbox, hooks, status line, |
| clean-env wrapper). |
| 4. Run /magpie-setup-isolated-setup-verify — confirms ✓/✗/⚠ for every piece |
| of the secure-agent setup. |
| 5. When you want to be on the framework's latest, run |
| `/magpie-setup upgrade` — pulls your local magpie |
| checkout to origin/main with --ff-only, refuses to touch a |
| dirty working tree, surfaces what arrived. Then run |
| /magpie-setup-isolated-setup-update to surface user-side drift the |
| upgrade introduced (new permissions.deny entries, |
| user-scope script copies older than the framework, pinned |
| tool bumps that warrant a host install). |
| 6. Optional: if you maintain a private dotfile-style sync repo |
| per |
| [Syncing user-scope config across machines](#syncing-user-scope-config-across-machines), |
| run /magpie-setup-shared-config-sync to push local edits to the remote |
| so other machines pick them up. |
| ``` |
| |
| The skills are at |
| [`.claude/skills/magpie-setup/verify.md`](../../skills/setup/verify.md), |
| [`.claude/skills/setup-isolated-setup-install/`](../../skills/setup-isolated-setup-install/SKILL.md), |
| [`.claude/skills/setup-isolated-setup-verify/`](../../skills/setup-isolated-setup-verify/SKILL.md), |
| [`.claude/skills/magpie-setup/upgrade.md`](../../skills/setup/upgrade.md), |
| [`.claude/skills/setup-isolated-setup-update/`](../../skills/setup-isolated-setup-update/SKILL.md), |
| [`.claude/skills/setup-shared-config-sync/`](../../skills/setup-shared-config-sync/SKILL.md). |
| Each skill references back into the canonical sections of this |
| document rather than duplicating them, so anything the skill walks |
| you through has a longer-form section here you can read for |
| context. |
| |
| ### Manual (if you do not want the agent-guided path) |
| |
| The same flow, condensed to commands you run yourself: |
| |
| ```bash |
| # 1. Pinned system tools (Linux only — macOS uses built-in |
| # Seatbelt). Exact distro commands and version pins are in |
| # `tools/agent-isolation/pinned-versions.toml`; canonical |
| # section: "Required tools" below. claude-code is unpinned — |
| # always install the latest for the newest security fixes. |
| sudo apt-get install --no-install-recommends \ |
| bubblewrap=0.11.2-* socat=1.8.1.3-* |
| npm install -g --no-save @anthropic-ai/claude-code@latest |
| |
| # 2. Project-scope `.claude/settings.json`. Copy the framework's |
| # sandbox / permissions.deny / permissions.ask / allowedDomains |
| # blocks into your tracker repo's `.claude/settings.json`. |
| # Section: "The framework's own .claude/settings.json" below. |
| |
| # 3. The clean-env wrapper. Source `agent-iso.sh` from your rc |
| # file, optionally alias `claude=claude-iso`. Section: "The |
| # clean-env wrapper" below. |
| |
| # 4. User-scope hooks. Copy `sandbox-bypass-warn.sh`, |
| # `sandbox-error-hint.sh`, and `sandbox-status-line.sh` into |
| # `~/.claude/scripts/`, wire them into `~/.claude/settings.json` |
| # under `PreToolUse`, `PostToolUse`, and `statusLine`. |
| # Sections: "Sandbox-bypass visibility hook", |
| # "Sandbox-error hint hook", and "Sandbox-state status line" |
| # below. |
| |
| # 5. Verify the install actually denies what it claims to — |
| # section "Verification" below has both a three-line Bash |
| # check and the agent-guided form. |
| ``` |
| |
| Both paths converge on the same end state: a sandboxed Claude Code |
| session that cannot read `~/.aws/`, cannot exfiltrate via `curl`, |
| runs Bash subprocesses inside bubblewrap (Linux) or Seatbelt |
| (macOS), and visibly flags `sandbox` / `NO SANDBOX` / bypass |
| attempts in the terminal so an unprotected session cannot drift |
| unnoticed. |
| |
| The rest of this document is the long-form reference behind each |
| of those steps. If you used the agent-guided path, you can read |
| sections on demand when a skill points you at one for more |
| detail. |
| |
| ## Required tools |
| |
| The **sandbox primitives** (`bubblewrap`, `socat`) are pinned with a |
| **per-tool cooldown** before the framework adopts a new upstream |
| release — same convention as the `[tool.uv] exclude-newer = "7 days"` |
| setting in [`pyproject.toml`](../../pyproject.toml) and the weekly Dependabot |
| updates in [`.github/dependabot.yml`](../../.github/dependabot.yml). |
| Default cooldown is 7 days; individual tools can override via |
| `cooldown_days = N` in the manifest when their release stream |
| warrants it. These are low-level sandbox building blocks where |
| reproducibility and settle-time matter more than chasing the newest |
| build. |
| |
| The **agent runtime** (`claude-code`) is deliberately **not** pinned. |
| The secure setup installs `@anthropic-ai/claude-code@latest` because |
| each release carries the newest permission-rule, sandbox, and |
| prompt-injection fixes — pinning the runtime to an older build would |
| *increase* the framework's security lag, not reduce it. Always run |
| the latest. |
| |
| The current pins live in machine-readable form in |
| [`tools/agent-isolation/pinned-versions.toml`](../../tools/agent-isolation/pinned-versions.toml): |
| |
| | Tool | Pinned version | Released | Cooldown | Purpose | |
| |---|---|---|---|---| |
| | `bubblewrap` | 0.11.2 | 2026-04-23 | 7d (default) | Linux user-namespace sandbox (filesystem layer). Required on Linux; macOS uses Seatbelt instead. | |
| | `socat` | 1.8.1.3 | 2026-06-26 | 7d (default) | TCP relay for the sandbox network allowlist. Linux only. | |
| | `claude-code` | *(unpinned — `@latest`)* | — | none | Agent runtime. Installed at the latest release so it always carries the newest permission-rule / sandbox / prompt-injection fixes; not in the pin manifest. | |
| |
| The pin date floor (`pinned_at` in the manifest) is the day the |
| manifest was last touched; it is the framework's promise that every |
| version above had at least its tool's cooldown to settle before |
| being adopted. |
| |
| ### Install commands |
| |
| The exact commands are also in `pinned-versions.toml` under each |
| tool's `install.<distro>` field; below is the one-line view per |
| distro. Choose whichever applies to your host. |
| |
| **Debian / Ubuntu (apt)**: |
| |
| ```bash |
| sudo apt-get update |
| sudo apt-get install --no-install-recommends \ |
| bubblewrap=0.11.2-* \ |
| socat=1.8.1.3-* |
| ``` |
| |
| > **Debian stable (bookworm) caveat.** The pinned `bubblewrap 0.11.2` |
| > is not available on Debian bookworm — bookworm ships an older |
| > `bubblewrap`, and an adopter reported the `0.11.x` line not working |
| > there. `0.11.x` ships in **Debian trixie**, so the supported path is |
| > to run the secure setup on **trixie** (or newer). If you must stay on |
| > bookworm, install the older distro `bubblewrap` and accept the same |
| > LTS trade-off documented in the Ubuntu Noble shortcut below — the |
| > sandbox flags don't depend on a specific bubblewrap version (the |
| > `denyRead`/`allowRead` API has been stable since `0.6.x`). |
| |
| **Fedora / RHEL (dnf)**: |
| |
| ```bash |
| sudo dnf install \ |
| bubblewrap-0.11.2 \ |
| socat-1.8.1.3 |
| ``` |
| |
| **macOS**: bubblewrap is not needed (Seatbelt is built in); socat is |
| optional. If you want socat, `brew install socat` (current Homebrew |
| version, no pin enforced — Homebrew rolls forward, so the |
| "7-day cooldown" promise is best-effort here). |
| |
| **Claude Code** (unpinned — always the latest for the newest security fixes): |
| |
| ```bash |
| # npm distribution (the only stable channel today) |
| npm install -g --no-save @anthropic-ai/claude-code@latest |
| ``` |
| |
| ### Distro-specific shortcut — Linux Mint 22.x / Ubuntu 24.04 Noble |
| |
| The pinned versions above (bubblewrap `0.11.2`, socat `1.8.1.3`) are |
| the *upstream* releases that have aged past the framework's 7-day |
| cooldown. **They are not in Ubuntu Noble's main repos** — Noble |
| ships `bubblewrap 0.9.0` (`0.9.0-1ubuntu0.1`) and |
| `socat 1.8.0.0` (`1.8.0.0-4build3`). |
| |
| Both Noble-shipped versions pre-date the framework's pins by months |
| and are well past the 7-day cooldown, so they're a legitimate |
| adopter choice on Mint 22.x / Ubuntu 24.04. The trade-off is the |
| usual LTS one: older feature set, but no source build required, |
| and security backports flow through Ubuntu's standard update |
| channel. |
| |
| If you accept the trade-off, install via apt: |
| |
| ```bash |
| sudo apt-get update |
| sudo apt-get install --no-install-recommends \ |
| bubblewrap=0.9.0-1ubuntu0.1 \ |
| socat=1.8.0.0-4build3 |
| ``` |
| |
| The framework's `.claude/settings.json` works unchanged — the |
| sandbox flags don't depend on a specific bubblewrap version (the |
| `denyRead`/`allowRead` API has been stable since `0.6.x`). |
| |
| The framework's `tools/agent-isolation/check-tool-updates.sh` will |
| still report upstream `0.11.2` / `1.8.1.3` as the pinned versions — |
| that's the manifest's view of what's *upstream-current*, not what |
| your distro shipped. If you want to silence the drift, override the |
| manifest locally with a `pinned-versions.local.toml` (gitignored) |
| declaring the Noble versions; the script's manifest-precedence |
| follows the same `*.local` convention as Claude Code's |
| `settings.local.json`. |
| |
| > **Why this is documented as a separate "shortcut" rather than |
| > the canonical path.** The framework's default pin tracks the |
| > upstream release stream, not any specific distro. Adopters on |
| > distros that ship recent versions (Arch, Fedora rolling, NixOS |
| > on `nixos-unstable`) can install the upstream-pinned versions |
| > directly from their package manager. Adopters on LTS distros |
| > like Mint / Ubuntu Noble use this shortcut. The two paths |
| > converge — once Noble's next LTS adopts a newer bubblewrap, this |
| > section retires. |
| |
| ### Bumping a pinned version |
| |
| This applies to the **pinned sandbox primitives** (`bubblewrap`, |
| `socat`) only — `claude-code` is unpinned and tracks `@latest`, so |
| there is nothing to bump for it (to move its `min_version` floor, see |
| [Raising the claude-code floor](#raising-the-claude-code-floor) below). |
| When an upstream primitive release has aged past the tool's 7-day |
| cooldown and you want to adopt it: |
| |
| 1. Run `tools/agent-isolation/check-tool-updates.sh`. It compares the |
| pinned versions to upstream and prints an "upgrade candidate" line |
| for any tool whose latest aged-past-cooldown release is newer than |
| the pin. |
| 2. Read the upstream release-notes / CHANGELOG for the tool. Don't |
| bump on a "performance improvements" entry — wait for a feature |
| you actually want or a security fix. |
| 3. Edit `tools/agent-isolation/pinned-versions.toml`: update the |
| tool's `version` and `released` fields, then update the top-level |
| `pinned_at` field to today's date. |
| 4. Update the install commands in this document if the distro |
| package version string has shifted. |
| 5. Open the bump as its own PR with a one-paragraph rationale. |
| |
| The check script is idempotent and side-effect-free — it never edits |
| the manifest, never installs anything, never opens a PR. |
| |
| ### Raising the claude-code floor |
| |
| `claude-code` carries a `min_version` **floor** instead of a pin. It |
| is not a version to install (installs are always `@latest`) — it is |
| the oldest release whose permission-rule / sandbox / prompt-injection |
| semantics the secure setup relies on. `setup-isolated-setup-verify` |
| check 5 **hard-fails** when the setup is driven from Claude Code and |
| the running claude-code is below the floor: the run stops rather than |
| certifying a setup whose guarantees may not hold on an older runtime. |
| |
| Raise the floor only when the framework starts to *depend* on |
| behaviour introduced by a newer release (a new permission-rule field, |
| a sandbox flag, a prompt-injection mitigation the skills assume): |
| |
| 1. Edit `min_version` (and `released`) in the `[tools.claude-code]` |
| table of `tools/agent-isolation/pinned-versions.toml`. |
| 2. Note in the PR which framework behaviour now requires the higher |
| floor, so adopters understand why their below-floor runtime is |
| being rejected. |
| |
| Because the floor is a hard failure, raising it is a breaking change |
| for adopters still on an older runtime — they must upgrade to |
| `@latest` (which they should be doing anyway) before the setup will |
| verify. |
| |
| ### Wiring the check script into a weekly routine |
| |
| The framework's `/schedule` slash-command lets you wire the check |
| script into a recurring agent without leaving Claude Code: |
| |
| ```text |
| /schedule weekly run tools/agent-isolation/check-tool-updates.sh |
| and surface upgrade candidates |
| ``` |
| |
| The scheduled agent runs in the same secure setup the rest of the |
| framework uses, so it has no special access to install the upgrade |
| itself — the surfaced candidates are a *proposal*, and the framework |
| maintainer's deliberate confirmation (per step 5 above) is what |
| actually lands the bump. |
| |
| ## The framework's own `.claude/settings.json` |
| |
| The framework dogfoods the secure config in |
| [`.claude/settings.json`](../../.claude/settings.json). The full block is |
| below, annotated. |
| |
| ```jsonc |
| { |
| "sandbox": { |
| "enabled": true, |
| // `excludedCommands` runs the listed commands OUTSIDE the sandbox. |
| // `gh` authenticates via the OS keyring (and `~/.config/gh`), which the |
| // sandbox blocks — so a sandboxed `gh` fails with a keyring / "not |
| // logged in" error. Excluding it lets `gh` use the real host auth. Its |
| // write / destructive subcommands are still gated by the |
| // `permissions.ask` rules below, and `gh auth token` / `gh auth refresh` |
| // stay in `permissions.deny` so the token can never be dumped. This is |
| // what makes the "`gh` is sandbox-bypassed" note under `credentials` |
| // below actually hold. |
| "excludedCommands": ["gh *"], |
| // The `lychee` link-check hook runs in OFFLINE mode (`offline = |
| // true` in `.lychee.toml`): it validates only local cross-file and |
| // anchor references and never fetches remote URLs, so it makes no |
| // network calls and needs no in-sandbox TLS at runtime. This |
| // sidesteps a macOS-26 issue where the sandbox's CONNECT proxy is |
| // incompatible with SecureTransport (the `native-tls` stack the |
| // cargo/brew lychee links): online link checks fail every external |
| // URL with `OSStatus -26276` even though the certs are valid and |
| // `enableWeakerNetworkIsolation` is set. Building lychee still |
| // needs the rust toolchain (see the `~/.rustup`/`~/.cargo` + |
| // `*.crates.io`/`static.rust-lang.org` entries below); only its |
| // *runtime* network use is eliminated. |
| "filesystem": { |
| "denyRead": ["~/"], // default-deny the entire home dir for Bash subprocesses |
| "allowRead": [ |
| ".", // the project tree (cwd) |
| "~/.gitconfig", // git's user.name / user.email |
| "~/.config/git/", // git's per-host config |
| "~/.config/gh/", // gh CLI auth (token in hosts.yml) |
| "~/.cache/", // dev tool caches (uv HTTP cache, prek logs, ruff/mypy caches) |
| "~/.local/share/uv/", // uv's tool venvs (prek, etc.) |
| "~/.rustup/", // rustup toolchains (the `lychee` rust hook builds against them) |
| "~/.cargo/", // cargo registry + the lychee binary the rust hook installs |
| "~/.local/bin/", // uv-installed tool entry points |
| "~/.config/apache-magpie/", // Gmail OAuth refresh token (oauth-draft tool) |
| "~/.gnupg/", // gpg keys (commit signing) |
| "/run/user/*/gnupg/" // gpg-agent socket dir (ssh-via-gpg-agent commit signing) |
| ], |
| "allowWrite": [ |
| "~/.cache/", // uv lock files, prek log + state, ruff/mypy caches |
| "~/.local/share/uv/", // uv's tool venvs (prek installs new hook envs here) |
| "~/.rustup/", // rustup writes settings.toml + downloaded toolchains (first run of the `lychee` rust hook) |
| "~/.cargo/" // cargo registry cache + the compiled lychee binary |
| ] |
| }, |
| "network": { |
| "allowedDomains": [ // every host the framework legitimately reaches |
| "github.com", "api.github.com", "raw.githubusercontent.com", |
| "objects.githubusercontent.com", "codeload.github.com", "uploads.github.com", |
| "pypi.org", "files.pythonhosted.org", |
| "lists.apache.org", "dist.apache.org", "downloads.apache.org", "archive.apache.org", |
| "cveprocess.apache.org", "cve.org", "www.cve.org", "cveawg.mitre.org", |
| "oauth2.googleapis.com", "gmail.googleapis.com", |
| // `*.crates.io` + `static.rust-lang.org` let the `lychee` rust |
| // hook bootstrap a rustup toolchain and `cargo install` lychee |
| // on first run (rustup downloads the toolchain from |
| // static.rust-lang.org; crate deps come from crates.io). These |
| // are the ONLY hosts lychee needs: it runs offline (see |
| // `.lychee.toml`), so it never fetches the external URLs the |
| // docs link to — the wildcard link-target hosts that used to |
| // live here (`*.apache.org`, `*.nist.gov`, `lychee.cli.rs`, …) |
| // were removed when the hook went offline. |
| "*.crates.io", "static.rust-lang.org" |
| ], |
| // Lets native-TLS CLI tools (lychee — and, per the schema, gh / |
| // gcloud / terraform) verify TLS through the sandbox's |
| // TLS-terminating proxy; without it lychee fails every external |
| // link with `failed to verify TLS certificate`. Documented |
| // trade-off: "reduces security — opens a potential |
| // data-exfiltration vector through the trustd service." No-op |
| // outside the sandbox (e.g. CI). macOS-only. |
| "enableWeakerNetworkIsolation": true |
| }, |
| // `sandbox.credentials` (claude-code 2.1.187+) is the only layer |
| // that protects secret ENVIRONMENT VARIABLES — `denyRead` and the |
| // `permissions.deny[Read(...)]` rules are filesystem-only and do |
| // NOT stop a sandboxed command from reading `$ANTHROPIC_API_KEY` |
| // or dumping `env`. `mode: "deny"` unsets the var for sandboxed |
| // commands only; the unsandboxed agent process keeps its own auth, |
| // and sandbox-bypassed commands (e.g. `gh`, which reads |
| // ~/.config/gh) are unaffected. Credential FILES are already |
| // covered by `denyRead: ["~/"]`, so only `envVars` is listed here. |
| // (`mode: "mask"` + `injectHosts` is the alternative: keep the var |
| // usable only for connections to named hosts — not needed here.) |
| "credentials": { |
| "envVars": [ |
| { "name": "ANTHROPIC_API_KEY", "mode": "deny" }, |
| { "name": "ANTHROPIC_AUTH_TOKEN", "mode": "deny" }, |
| { "name": "CLAUDE_CODE_OAUTH_TOKEN", "mode": "deny" }, |
| { "name": "GH_TOKEN", "mode": "deny" }, |
| { "name": "GITHUB_TOKEN", "mode": "deny" }, |
| { "name": "AWS_ACCESS_KEY_ID", "mode": "deny" }, |
| { "name": "AWS_SECRET_ACCESS_KEY", "mode": "deny" }, |
| { "name": "AWS_SESSION_TOKEN", "mode": "deny" }, |
| { "name": "NPM_TOKEN", "mode": "deny" }, |
| { "name": "TWINE_PASSWORD", "mode": "deny" } |
| ] |
| } |
| }, |
| "permissions": { |
| "allow": [ |
| "Bash(gh api graphql *)", // read-only GraphQL fetches (PR-triage paginated loop). MORE SPECIFIC than the `gh *` ask below, so it — and the read-only rules that follow — run WITHOUT a prompt. GraphQL mutations slip through; accepted, since the skills route mutations through REST, not graphql. |
| // Two read-only REST `gh api` GETs the skills need for read-only |
| // analysis (security-team / reviewer roster lookup; release-tag ↔ |
| // fix-commit ancestry when verifying a fix shipped). Without these, |
| // read-only assessor subagents prompt mid-run for the roster/ancestry |
| // reads. Kept mutation-safe: the `collaborators --*` guard matches the |
| // list GET (`… /collaborators --paginate --jq …`) but NOT the add-member |
| // mutation (`… /collaborators/<user> -X PUT`, which has no ` --` prefix); |
| // `compare/…` is a GET-only endpoint with no mutating counterpart. |
| "Bash(gh api repos/*/*/collaborators --*)", "Bash(gh api repos/*/*/compare/*)", |
| // Read-only gh, allow-listed so they don't trip the `gh *` ask below. |
| // Anything NOT listed here — every write/destructive gh, and any other |
| // REST `gh api` (GET included) — falls through to `gh *` and prompts. |
| "Bash(gh pr view *)", "Bash(gh pr list *)", "Bash(gh pr diff *)", "Bash(gh pr checks *)", |
| "Bash(gh issue view *)", "Bash(gh issue list *)", |
| "Bash(gh repo view *)", "Bash(gh repo list *)", |
| "Bash(gh run view *)", "Bash(gh run list *)", "Bash(gh run watch *)", |
| "Bash(gh workflow view *)", "Bash(gh workflow list *)", |
| "Bash(gh release view *)", "Bash(gh release list *)", |
| "Bash(gh label list *)", "Bash(gh cache list *)", |
| "Bash(gh search *)", "Bash(gh browse *)", "Bash(gh auth status*)" |
| ], |
| "deny": [ |
| "Read(~/.aws/**)", "Read(~/.ssh/**)", "Read(~/.netrc)", |
| "Read(~/.docker/**)", "Read(~/.kube/**)", |
| "Read(~/.config/gh/**)", // bash can read it (sandbox.allowRead); the AGENT can't |
| "Read(~/.config/apache-magpie/**)", // same — Bash via oauth-draft tool, not the agent directly |
| "Read(~/.config/gcloud/**)", "Read(~/.azure/**)", |
| "Read(//**/.env)", "Read(//**/.env.local)", "Read(//**/.env.*.local)", |
| "Bash(curl *)", "Bash(wget *)", // network egress via Bash bypasses the sandbox proxy |
| "Bash(aws *)", "Bash(gcloud *)", "Bash(az *)", "Bash(kubectl *)", |
| "Bash(docker login *)", "Bash(npm publish *)", |
| "Bash(pip install --upgrade *)", "Bash(uv self update *)", |
| "Bash(gh auth token*)", "Bash(gh auth refresh*)" // gh runs unsandboxed (excludedCommands), so deny the two subcommands that would print/rotate the token |
| ], |
| "ask": [ |
| "Bash(git push *)", // including --force / --force-with-lease variants |
| "Bash(gh *)" // safe-by-default: EVERY gh command prompts unless it matches a more-specific read-only allow rule above. Guarantees every destructive / unknown gh subcommand (gh pr close, gh run delete, gh label delete, gh repo archive, gh variable set, gh project item-delete, …) is confirmed. `gh auth token`/`refresh` are denied above (deny > ask). |
| ] |
| } |
| } |
| ``` |
| |
| The deny / allow split for `~/.config/gh/` and |
| `~/.config/apache-magpie/` is deliberate: bash subprocesses (the `gh` |
| CLI, `oauth-draft-create`) need to *use* the credential, but the |
| agent should never *see* it. `sandbox.filesystem.allowRead` permits |
| the bash subprocess to read the file; `permissions.deny[Read(...)]` |
| blocks the agent's Read tool from reading the same path. |
| |
| **OpenCode parity.** OpenCode has no per-command sandbox exclusion — its |
| isolation is the OS-level sandbox of the [clean-env wrapper](#the-clean-env-wrapper), |
| which already runs `gh` with the host keyring, so there is no `excludedCommands` |
| equivalent to add. The "always confirm" half carries over via OpenCode's own |
| policy: `sandbox-lint --opencode` requires `permission.bash` to default to |
| `ask`/`deny` (never a blanket `allow`), so `gh` write subcommands prompt there |
| by default. No `opencode.json` change is needed to match the Claude config. |
| |
| ## Project-root coverage in the sandbox allowlists |
| |
| The `.` entry in `sandbox.filesystem.allowRead` is **intended** to |
| mean "the session's current working directory, resolved at |
| access-time" — exactly the same semantics `allowWrite: ["."]` has. |
| In practice the two sides diverge in the harness: `allowWrite` |
| keeps `.` literal (resolved per access), while `allowRead` |
| pre-resolves the path list at session start to absolute paths *and |
| silently drops the literal `.`*. The consequence is that a session |
| in a freshly-cloned adopter repo can **write** to CWD but cannot |
| **read** from it under the sandbox — `git rev-parse --git-dir` |
| fails with `Operation not permitted`, and `Read`-tool reads of |
| files like `.apache-magpie.lock` fail too. The full reproducer |
| and harness-side analysis is in |
| [issue #197](https://github.com/apache/magpie/issues/197). |
| |
| The framework's defensive fix is to add the project root as an |
| **explicit absolute path** to both `sandbox.filesystem.allowRead` |
| and `sandbox.filesystem.allowWrite` in the adopter's **project-local** |
| settings file — `<repo>/.claude/settings.local.json`. The `.` |
| entry stays in the committed project-scope `settings.json` — the |
| explicit absolute path in `settings.local.json` is belt-and-braces: |
| |
| - If the harness ever stops resolving `.` consistently, the |
| explicit absolute path still covers the project. |
| - If `.` works correctly, the explicit entry is redundant but |
| harmless. |
| |
| ### Why project-local, not user-scope and not committed-project |
| |
| Three scopes the harness merges, top to bottom: |
| |
| | Scope | File | Shared by | Suitable for the fix? | |
| |---|---|---|---| |
| | User | `~/.claude/settings.json` | every session on the host (every adopter project, every tool) | **No** — pollutes user-scope with every adopter project's abs path. | |
| | Project (committed) | `<repo>/.claude/settings.json` | every contributor on the project | **No** — machine-specific abs paths would leak into the repo. | |
| | Project (local, gitignored) | `<repo>/.claude/settings.local.json` | this machine, this checkout only | **Yes** — per-machine, per-project, never committed. | |
| |
| Worktrees handle themselves: each worktree has its own working |
| tree (and so its own `.claude/` directory and its own |
| `.claude/settings.local.json`). The helper writes each worktree's |
| absolute path into **that worktree's own** settings.local.json, |
| not into a shared file. When a session starts in worktree A, the |
| harness reads worktree A's settings.local.json and sees the |
| explicit allow for worktree A's root — nothing more. |
| |
| The committed project-scope `settings.json` is **never** modified |
| by the helper; the user-scope `settings.json` and |
| `settings.local.json` are likewise never touched. |
| |
| ### Security rationale — why project-local is safe to write to |
| |
| A reasonable question: *"the helper writes a config file that |
| governs the sandbox itself. If the sandbox grants write access to |
| the project tree, can a compromised agent rewrite that file and |
| broaden the sandbox for the next session?"* The answer is no, but |
| only because the protection comes from **Claude Code's built-in |
| sandbox denylist**, not from anything the framework can configure. |
| Walking the threat model: |
| |
| **1. Bash writes from inside the sandbox: blocked by the harness.** |
| Claude Code's sandbox resolves the user's |
| `sandbox.filesystem.allowWrite` against a hardcoded |
| `denyWithinAllow` set that always includes |
| `<repo>/.claude/settings.json`, |
| `<repo>/.claude/settings.local.json`, |
| `<repo>/.claude/skills/`, and the user-scope settings files. This |
| is enforced at the bubblewrap (Linux) / Seatbelt (macOS) syscall |
| level — the write fails with `Operation not permitted` regardless |
| of what `allowWrite` says. Verify empirically with a single line: |
| |
| ```bash |
| echo "test" >> .claude/settings.local.json |
| # zsh: operation not permitted: .claude/settings.local.json |
| ``` |
| |
| There is no settings.json field that overrides this protection |
| (no `denyWrite` user-config exists at the time of writing); the |
| harness owns it. So a sandboxed Bash invocation, even one running |
| attacker-chosen code, cannot mutate `.claude/settings.local.json` |
| to broaden the next session's sandbox. |
| |
| **2. Edit / Write / MultiEdit agent tools bypass the sandbox.** |
| These tools call into the harness directly, not through a Bash |
| subprocess, so the sandbox's `denyWithinAllow` does not apply. The |
| framework closes the bypass by adding the per-tool denies in the |
| committed `.claude/settings.json`: |
| |
| ```jsonc |
| "deny": [ |
| "Edit(.claude/settings.json)", |
| "Edit(.claude/settings.local.json)", |
| "Write(.claude/settings.json)", |
| "Write(.claude/settings.local.json)", |
| "MultiEdit(.claude/settings.json)", |
| "MultiEdit(.claude/settings.local.json)" |
| ] |
| ``` |
| |
| A compromised agent that tries `Edit('.claude/settings.local.json', ...)` |
| hits the deny rule and the call fails. The denies are committed at |
| project scope, so every contributor inherits them; an adopter who |
| follows the framework's settings template gets them automatically. |
| |
| **3. The framework's own helper also gets blocked from inside the sandbox.** |
| The same `denyWithinAllow` that defends against attack also blocks |
| [`sandbox-add-project-root.sh`](../../tools/agent-isolation/sandbox-add-project-root.sh) |
| when it is invoked through the agent's `Bash` tool from inside a |
| sandboxed session. Three legitimate-write paths remain, all |
| auditable: |
| |
| - **User-terminal post-checkout hook.** `git worktree add` / |
| `git checkout` fired from the operator's shell triggers |
| `post-checkout`, which runs the helper in the *shell's* context — |
| outside the agent sandbox. Writes succeed normally. |
| - **First-time install.** `setup-isolated-setup-install` is |
| typically run with the operator's awareness; its Step P |
| invocation of the helper happens in a context where the operator |
| is already approving setup actions. |
| - **`dangerouslyDisableSandbox: true` from agent sessions.** |
| `/magpie-setup adopt`, `upgrade`, and `worktree-init` invoke the |
| helper with explicit sandbox bypass. Every bypass triggers |
| [`sandbox-bypass-warn.sh`](../../tools/agent-isolation/sandbox-bypass-warn.sh)'s |
| bold-red banner naming the command, the reason, and the file |
| being touched; the operator approves per call. No silent writes. |
| |
| **4. No vector via commits.** |
| `<repo>/.claude/settings.local.json` is gitignored — the adopt |
| flow adds the line to `.gitignore`, and |
| [`/magpie-setup verify`](../../skills/setup/verify.md) |
| Check 4 surfaces ✗ if it is missing. The helper itself runs |
| `git check-ignore` against the target file before writing and |
| *refuses* to write when the file is not ignored (defense in depth |
| against a stale `.gitignore`). A malicious contributor cannot ship |
| sandbox-allowlist content via a PR. |
| |
| **5. No vector via the helper's inputs.** |
| The helper takes paths exclusively from |
| `git rev-parse --show-toplevel` and |
| `git worktree list --porcelain` — both walk the operator's own |
| local git state. The only paths added are working directories the |
| operator has already created themselves with `git clone` / |
| `git worktree add`. No command-line path argument; no |
| environment-variable injection. |
| |
| **6. Cross-project isolation, as a bonus.** |
| A session in project A reads |
| `<A>/.claude/settings.local.json` and gets read+write access only |
| to A. A session that `cd`s into project B mid-session keeps A's |
| settings (loaded at session start), so it sees A's grants — never |
| B's. The same fix at user-scope (`~/.claude/settings.json`) would |
| have given every Claude Code session on the host read+write access |
| to every adopter project the operator has ever set up; project-local |
| scope confines the grant. |
| |
| **Net:** every write path to the file is either physically blocked |
| or requires explicit per-call user approval. The harness's built-in |
| sandbox protection is what makes this true — the framework cannot |
| configure it, but it can verify and document it. |
| |
| ### `sandbox-add-project-root.sh` |
| |
| The framework ships |
| [`tools/agent-isolation/sandbox-add-project-root.sh`](../../tools/agent-isolation/sandbox-add-project-root.sh) |
| to perform this addition idempotently. Installed during |
| [`setup-isolated-setup-install`](../../skills/setup-isolated-setup-install/SKILL.md) |
| into `~/.claude/scripts/sandbox-add-project-root.sh` (the |
| *script file* lives user-scope so a single install covers every |
| adopter project on the host; what it *writes* is project-local). |
| The helper: |
| |
| - Resolves `git rev-parse --show-toplevel` in the current working |
| directory. |
| - With `--all-worktrees`, also enumerates |
| `git worktree list --porcelain` and writes a separate entry |
| into **each worktree's** own `.claude/settings.local.json`. |
| - Without the flag, writes only the current worktree's path |
| into the current worktree's `.claude/settings.local.json`. |
| - Creates `.claude/settings.local.json` from scratch if missing |
| (with only the `sandbox.filesystem` block — nothing else is |
| touched). |
| - Updates the file in place, atomically (`jq` → tmp → `mv`). |
| - Skips any path already present in either array (idempotent). |
| - Tolerant of missing prerequisites (no `jq`, not in a git repo, |
| invalid existing JSON) — warns on stderr and exits 0 so the |
| calling hook is never derailed by a half-installed setup. |
| |
| ### When the helper runs |
| |
| The helper is invoked from four points in the framework's lifecycle: |
| |
| 1. **At install** — `setup-isolated-setup-install` runs the |
| helper with `--all-worktrees` against the adopter repo the |
| operator is sitting in. |
| 2. **During adoption** — `/magpie-setup adopt` Step 12 runs the |
| helper with `--all-worktrees` so a fresh adopter repo with |
| pre-existing worktrees has every working-tree path covered |
| without an extra round-trip through |
| `setup-isolated-setup-install`. |
| 3. **During upgrade** — `/magpie-setup upgrade` Step 6c, after |
| the per-worktree `worktree-init` chain, runs the helper with |
| `--all-worktrees` so any worktree added since adopt has its |
| path written into its own settings.local.json. |
| 4. **Per worktree, on creation** — the `post-checkout` git hook |
| installed by `/magpie-setup adopt` runs the helper *without* |
| `--all-worktrees`, picking up only the new worktree's path. |
| `git worktree add` fires `post-checkout` in the new working |
| tree, so every worktree added after adoption inherits sandbox |
| access automatically — landing its abs path in its own |
| `.claude/settings.local.json`. |
| |
| The verification surface: |
| |
| - [`setup-isolated-setup-verify`](../../skills/setup-isolated-setup-verify/SKILL.md) |
| Check 8 — live sandboxed read+write probe of the project root, |
| plus the static cross-check that the abs path is in the current |
| worktree's `.claude/settings.local.json`. |
| - [`/magpie-setup verify`](../../skills/setup/verify.md) |
| Check 8b — static cross-check that the current worktree's |
| abs path is in its own `.claude/settings.local.json`. |
| |
| ### Per-project vs whole-user scope |
| |
| [`setup-isolated-setup-install`](../../skills/setup-isolated-setup-install/SKILL.md) |
| offers two scopes for the project-root sandbox-allowlist setup. |
| The operator picks one during install; both are reversible. |
| |
| **Recommended default depends on host state.** The install skill |
| first checks whether whole-user (global) scope is already active |
| (`git config --global --get core.hooksPath` pointing at the shared |
| hook dir). When it is **not yet set up on the host**, the skill |
| proposes **whole-user (global) as the default** — it is the |
| recommended baseline that covers every current and future repo in |
| one pass. When global scope is **already active**, the skill |
| defaults to per-project for the specific repo being installed (the |
| global hook already covers the host). The operator can always pick |
| the other scope. |
| |
| | Scope | What it covers | Mechanism | Reversal | |
| |---|---|---|---| |
| | **Per-project** | The single adopter repo the operator is sitting in when running the install skill. Each subsequent adopter project needs the install skill re-run there. | The helper runs once with `--all-worktrees` against the current repo; nothing global is touched. The per-repo `post-checkout` hook (installed by `/magpie-setup adopt` in Magpie-adopted repos) chains into the helper on future `git checkout` operations within that repo. | None needed — per-project scope is inert outside the configured repos. | |
| | **Whole-user (global)** (recommended default when not yet set up) | Every git repo on the operator's host, existing and future. Includes non-Magpie Claude-Code-aware projects (any project with a `.claude/` directory). | Walks the operator's existing checkouts under prompted root dirs and writes each one's `settings.local.json`; sets `git config --global core.hooksPath ~/.claude/git-hooks/` and installs the universal [`git-global-post-checkout.sh`](../../tools/agent-isolation/git-global-post-checkout.sh) there. | `git config --global --unset core.hooksPath` restores per-repo hook lookup. The populated `settings.local.json` files stay (they are harmless if the operator no longer wants them, and gitignored so they cause no commit noise). | |
| |
| #### Important trade-off — `core.hooksPath` shadows per-repo hooks |
| |
| When `core.hooksPath` is set globally, git looks up hooks **only** |
| in that directory for every repo on the host. Every per-repo |
| `<repo>/.git/hooks/*` becomes inert across the host. If the |
| operator has hooks they care about (pre-commit formatters, |
| commit-msg linters, pre-push gates, project-specific |
| post-checkout actions), those will no longer fire after whole-user |
| scope is set, unless the operator migrates them into |
| `~/.claude/git-hooks/`. |
| |
| The *simple* whole-user flavour installs **only** the |
| `post-checkout` hook in the shared dir, so pre-commit / commit-msg |
| / pre-push / other hook types would need their own files there to |
| fire. The **dispatcher flavour** removes that cost entirely — it |
| chains the shared hooks back to each repo's own `.git/hooks/`, so |
| per-repo hooks keep working with no migration. See |
| [Whole-user with the per-repo dispatcher](#whole-user-with-the-per-repo-dispatcher). |
| Pick simple only if you run no per-repo hooks and want the minimal |
| footprint. |
| |
| The install skill surfaces this trade-off loudly before setting |
| `core.hooksPath` and requires explicit operator acknowledgement. |
| See |
| [`setup-isolated-setup-install` Step P.0a](../../skills/setup-isolated-setup-install/SKILL.md#step-p0a--loud-disclosure-before-setting-whole-user-scope). |
| |
| #### When to pick which scope |
| |
| - **Pick per-project** when: |
| - You adopt one or two projects on this host and prefer not to |
| touch global git config. |
| - You have per-repo hooks (pre-commit, commit-msg, etc.) you |
| rely on and do not want shadowed. |
| - You are evaluating Magpie and have not yet decided |
| whether to commit to the framework. |
| |
| - **Pick whole-user** when: |
| - You adopt many Claude-Code-aware projects and do not want to |
| re-run the install skill in each. |
| - You add worktrees frequently and want each one's |
| `settings.local.json` auto-populated without per-worktree |
| action. |
| - You do not rely on per-repo hooks (or are prepared to migrate |
| them into the shared dir). |
| - You sync `~/.claude/` across machines via the private dotfile |
| repo (the global config + hook propagates with the sync). |
| |
| Switching scopes later is non-destructive: the install skill is |
| idempotent. Re-running it with a different scope is the supported |
| upgrade path. The walking pass under whole-user scope is also a |
| one-time bulk operation — once existing checkouts are populated, |
| the global `post-checkout` keeps everything aligned going forward. |
| |
| #### Whole-user with the per-repo dispatcher |
| |
| The `core.hooksPath`-shadowing trade-off above has a clean |
| resolution: instead of a plain `post-checkout` in the shared dir, |
| install the **dispatcher** |
| ([`tools/agent-isolation/git-hook-dispatcher.sh`](../../tools/agent-isolation/git-hook-dispatcher.sh)) |
| under *every* hook name. Each dispatcher runs the framework's own |
| logic for that hook type (the `post-checkout` sandbox-allowlist |
| sync) **and then chains through to the repo-local |
| `.git/hooks/<name>`** — so your per-repo hooks keep firing even |
| though `core.hooksPath` is global. This is the **recommended |
| whole-user flavour** for anyone who uses prek, pre-commit, husky, |
| lefthook, or any per-repo hook. |
| |
| The dispatcher is basename-keyed (one script, symlinked to each |
| hook name), resolves the local hook via `git rev-parse |
| --git-common-dir` (worktree-safe), and `exec`s it with the |
| original argv + inherited stdin, so a failing local `pre-commit` / |
| `pre-push` still aborts the git operation. A repo with **no** local |
| hook is a clean no-op. |
| |
| **The `prek` PATH shim.** prek honours `core.hooksPath`, so a bare |
| `prek install` under whole-user scope would write its shim into the |
| *shared* dir, not the repo. The framework ships a transparent shim |
| ([`tools/agent-isolation/prek-shim.sh`](../../tools/agent-isolation/prek-shim.sh), |
| installed as `~/.claude/bin/prek` with `~/.claude/bin` prepended to |
| PATH) that rewrites **only** `prek install` — injecting |
| `--git-dir "$(git rev-parse --git-common-dir)"` so the shim lands |
| in the repo-local `.git/hooks/` where the dispatcher finds it. |
| Every other `prek` command passes straight through, and the shim is |
| a no-op on hosts with no global `core.hooksPath`. |
| |
| Validated behaviour under this flavour (`core.hooksPath` global + |
| dispatchers + prek shim): |
| |
| | Repo's local hook | Result | |
| |---|---| |
| | prek shim in `.git/hooks/` (installed via the shim) | dispatcher → prek runs that repo's `.pre-commit-config.yaml` | |
| | hand-written / husky / lefthook `.git/hooks/<name>` | dispatcher → that hook runs (heterogeneous tooling coexists) | |
| | none | dispatcher no-op; git operation proceeds cleanly | |
| | local hook exits non-zero | exit code propagates; git operation aborts | |
| | `post-checkout` | framework sandbox sync **and** repo-local `post-checkout` both run | |
| |
| Reversal is the same as simple whole-user (`git config --global |
| --unset core.hooksPath`), plus removing `~/.claude/bin` from PATH to |
| restore the stock `prek`. |
| |
| ## The clean-env wrapper |
| |
| Layer 0 — strip credential-shaped env vars from the parent shell |
| before invoking `claude` — is implemented by |
| [`tools/agent-isolation/agent-iso.sh`](../../tools/agent-isolation/agent-iso.sh). |
| |
| There are two valid ways to make `claude-iso` available on your |
| shell. Pick whichever matches how you use Claude Code; the wrapper |
| behaviour is identical either way. |
| |
| **Per-repo install** — source the script directly from the |
| framework checkout. Simplest, always tracks the wrapper version in |
| the repo (so a `git pull` of the framework updates the wrapper), |
| but only works on hosts where the framework path resolves. |
| |
| ```bash |
| # ~/.bashrc or ~/.zshrc |
| source /path/to/magpie/tools/agent-isolation/agent-iso.sh |
| ``` |
| |
| **Global (user-scope) install** — copy the script into |
| `~/.claude/agent-isolation/` and source from there. Survives |
| branch / worktree / repo-path changes, travels with the rest of |
| `~/.claude/` when you sync dotfiles between machines, and works |
| regardless of whether the framework repo happens to be checked |
| out on a given host. |
| |
| ```bash |
| # one-time install (re-run to pick up an upstream wrapper change) |
| mkdir -p ~/.claude/agent-isolation |
| cp /path/to/magpie/tools/agent-isolation/agent-iso.sh \ |
| ~/.claude/agent-isolation/agent-iso.sh |
| |
| # ~/.bashrc or ~/.zshrc — guarded so it's a no-op until the file exists |
| [ -f "$HOME/.claude/agent-isolation/agent-iso.sh" ] \ |
| && . "$HOME/.claude/agent-isolation/agent-iso.sh" |
| ``` |
| |
| Trade-off: the global install decouples the wrapper from the |
| repo's pinned copy. If a future framework release changes the |
| wrapper (new passthrough vars, security fix), you need to |
| re-`cp` it into `~/.claude/agent-isolation/` by hand. Diff the |
| two paths periodically — or schedule it via `/schedule` — to |
| surface drift. |
| |
| Then use `claude-iso` instead of `claude` whenever you start a |
| session in the tracker repo: |
| |
| ```bash |
| cd ~/code/<tracker> |
| claude-iso |
| ``` |
| |
| The wrapper hard-allows only a tiny passthrough list (`HOME`, `PATH`, |
| `SHELL`, `TERM`, `LANG`, `XDG_*`, `DISPLAY`, `SSH_AUTH_SOCK`, |
| `USER`, `LOGNAME`, `PWD`); everything else from the parent shell is |
| dropped via `env -i`. |
| |
| **Optional — make the isolated wrapper your default `claude`.** Once |
| the wrapper is sourced, you can alias `claude` to it so every plain |
| `claude` invocation goes through the clean-env path: |
| |
| ```bash |
| # in your ~/.bashrc or ~/.zshrc, *after* the source line above |
| alias claude='claude-iso' |
| ``` |
| |
| The wrapper resolves the underlying binary via shell-aware path lookup |
| (`type -P` in bash, `whence -p` in zsh) rather than `command -v`, so |
| the alias does not loop back into itself. Each launch prints a dim |
| one-line banner on stderr (`[claude-iso] running in isolated env (…)`) |
| so it is obvious which mode the agent is starting in. To bypass the |
| alias for a single invocation, use `command claude …` or `\claude …`. |
| |
| The trade-off is the same one as any "shadow the binary with a safer |
| wrapper" pattern: a session you forgot to start in a tracker checkout |
| also runs with a stripped env, which surprises tools that rely on a |
| parent-shell credential. If that bites, drop the alias and call |
| `claude-iso` explicitly when you actually want the isolation. |
| |
| To inject one credential explicitly for one session: |
| |
| ```bash |
| # git push session — bring in the gh token for one run |
| CLAUDE_ISO_ALLOW="GH_TOKEN" GH_TOKEN="$(gh auth token)" claude-iso |
| |
| # 1Password integration: |
| CLAUDE_ISO_ALLOW="GH_TOKEN" GH_TOKEN="$(op read 'op://Personal/GitHub/token')" claude-iso |
| ``` |
| |
| The `CLAUDE_ISO_ALLOW` mechanism is opt-in per invocation — no |
| implicit propagation, no persistent allowlist. |
| |
| ### Automatic sandbox allow-paths |
| |
| Beyond the env-stripping role, `claude-iso` also injects up to two |
| absolute paths into the session's `sandbox.filesystem.allowRead` |
| via a one-shot `claude --settings <json>` flag prepended to the |
| argv. The injection merges with the loaded settings stack at |
| startup, *before* sandbox initialisation, so the paths take |
| effect for that session immediately — no on-disk |
| `settings.local.json` edit, no per-checkout bootstrap, nothing |
| to clean up afterwards. A stderr banner reports what was added. |
| |
| **Current-repo auto-allow (always on).** Whenever `claude-iso` is |
| launched from inside a git working tree, the working-tree root |
| (resolved via `git rev-parse --show-toplevel`) is added to |
| `allowRead`. This closes the visibility gap described in |
| [Project-root coverage in the sandbox allowlists](#project-root-coverage-in-the-sandbox-allowlists) |
| for the wrapper-launch path: when launched through `claude-iso`, |
| you do not also need the project root hand-listed in |
| `<repo>/.claude/settings.local.json` for the agent to be able to |
| read the source tree. (The settings.local.json fix remains the |
| right answer for plain `claude` launches — the harness can't |
| see the wrapper's argv.) Outside a git repo, this is a silent |
| no-op. |
| |
| **Worktree mode (`claude-iso -w` / `claude-iso --worktree`).** |
| Additive on top of the current-repo auto-allow. When `-w` is on |
| the argv and `$PWD` is a worktree, the *main* repo (resolved via |
| `git rev-parse --git-common-dir`) is also added — that path is |
| otherwise unreachable from a worktree session, because the |
| sandbox's relative `.` rule covers only the worktree itself. |
| Run inside the main repo, `-w` is effectively a no-op: the |
| working-tree root and the main repo resolve to the same path |
| and dedupe into a single `allowRead` entry. Both paths ride |
| into the session via a single `--settings` injection. |
| |
| ## Sandbox-bypass visibility hook |
| |
| The Bash tool accepts a `dangerouslyDisableSandbox: true` flag that |
| lets the model run a single command outside the sandbox — necessary |
| for the (rare) cases where a legitimate task needs to read or write |
| a path that the sandbox denies. Claude Code prompts the user before |
| honouring the bypass, but in a long session the prompt is easy to |
| skim past, especially when several appear in quick succession. |
| |
| The framework ships a `PreToolUse` hook in |
| [`tools/agent-isolation/sandbox-bypass-warn.sh`](../../tools/agent-isolation/sandbox-bypass-warn.sh) |
| that makes every bypass attempt visually impossible to miss: a bold |
| red banner with the command and the model's stated reason printed |
| to stderr, before the permission prompt appears. |
| |
| The hook is **complementary** to the rest of the secure setup, not a |
| replacement: it does not prevent a bypass, it just makes the bypass |
| visible. The user still has to approve the call at the permission |
| prompt — the banner gives them a fair chance to read what they are |
| about to approve. |
| |
| ### Why install it user-scope, not project-scope |
| |
| Unlike the framework's |
| [`.claude/settings.json`](../../.claude/settings.json) (which is |
| repo-scoped — only sessions started inside the tracker repo see |
| it), this hook is most useful in |
| **`~/.claude/settings.json`** — the user-scope config that applies |
| to *every* Claude Code session on the host, tracker or otherwise. |
| A sandbox-bypass attempt is just as worth noticing in an unrelated |
| project as in the tracker. |
| |
| Per-project-scope installation is also valid (drop the same hook |
| entry into a tracker's `.claude/settings.json`) — the trade-off is |
| narrower coverage in exchange for one fewer file to manage at the |
| user level. |
| |
| ### Install (user-scope) |
| |
| ```bash |
| # Copy the hook script into ~/.claude/scripts/ (or symlink it from |
| # the framework checkout — see "Syncing user-scope config across |
| # machines" below for the multi-host pattern). |
| mkdir -p ~/.claude/scripts |
| cp /path/to/magpie/tools/agent-isolation/sandbox-bypass-warn.sh \ |
| ~/.claude/scripts/sandbox-bypass-warn.sh |
| chmod +x ~/.claude/scripts/sandbox-bypass-warn.sh |
| ``` |
| |
| Then wire the hook into `~/.claude/settings.json` under the |
| `PreToolUse` block, matched on the `Bash` tool. If a `Bash` matcher |
| already exists (e.g. for an unrelated hook), append to its `hooks` |
| array rather than creating a second matcher block: |
| |
| ```jsonc |
| { |
| "hooks": { |
| "PreToolUse": [ |
| { |
| "matcher": "Bash", |
| "hooks": [ |
| { |
| "type": "command", |
| "command": "~/.claude/scripts/sandbox-bypass-warn.sh" |
| } |
| ] |
| } |
| ] |
| } |
| } |
| ``` |
| |
| ### Verify |
| |
| The hook is exit-code-driven — exit 1 with stderr output means |
| "show stderr to the user, tool proceeds". To test without a real |
| bypass: |
| |
| ```bash |
| echo '{"tool_name":"Bash","tool_input":{"command":"ls ~/.aws","description":"check aws creds","dangerouslyDisableSandbox":true}}' \ |
| | ~/.claude/scripts/sandbox-bypass-warn.sh; echo "exit=$?" |
| ``` |
| |
| Expected: a four-line red banner on stderr, then `exit=1`. A second |
| call with `dangerouslyDisableSandbox` set to `false` (or absent |
| entirely) should produce no output and `exit=0`. |
| |
| ### Trade-offs |
| |
| - **No block, only visibility.** The hook deliberately exits 1, not |
| 2 — exit 2 would block the call outright, and that defeats the |
| model's ability to do legitimate work the user has just asked for |
| (e.g. installing packages outside the project tree). If a stricter |
| posture is wanted, change the script's `exit 1` to `exit 2`; the |
| consequence is that *every* sandbox-bypass attempt then has to be |
| unblocked by editing the hook out, which in practice trains the |
| user to skip the safety entirely. Visibility-with-prompt is the |
| better steady state. |
| - **Schema robustness.** The hook greps the JSON payload for |
| `"dangerouslyDisableSandbox": true` rather than reading a fixed |
| JSON path via `jq`, so it keeps working if Claude Code reshuffles |
| where in the payload the flag lives. Cost: a future Claude Code |
| release that renames the flag will silently stop firing the hook |
| until the regex is updated. Re-run the verification snippet after |
| every Claude Code upgrade — same cadence as the |
| [Verification](#verification) section below. |
| |
| ## Agent-guard deterministic guard hook |
| |
| A `PreToolUse` hook that, unlike the bypass-visibility hook above, |
| **blocks** (not just annotates) a small set of `gh`/`git` commands |
| that would violate a hard framework rule — protections that must not |
| depend on the model remembering a `SKILL.md` instruction. The engine |
| lives in [`tools/agent-guard`](../../tools/agent-guard/README.md) and |
| ships two **bundled** (universal `git` hygiene) guards: |
| |
| - **commit-trailer** — never let a `git commit` carry a |
| `Co-Authored-By:` trailer (use `Generated-by:`). |
| - **empty-rebase** — never force-push a branch with no commits over |
| its base (an empty push to a PR head auto-closes it and revokes |
| write). |
| |
| The domain-specific guards are **owned by the skills that need them** |
| and discovered the same way (below) — `skills/pr-management-triage/guards/` |
| ships **mention** (never `@`-ping a non-author in an author-directed |
| `gh pr comment`/`gh issue comment`; never `@`-mention anyone in a |
| `gh pr edit --body` fold) and **mark-ready** (never add `ready for |
| maintainer review` while CI awaits approval); `skills/security-issue-fix/guards/` |
| ships **security-language** (no CVE / security-fix wording in a public |
| `gh pr create|edit` title/body). |
| |
| Each guard is overridable per command by a visible inline env |
| assignment (`MAGPIE_ALLOW_MENTIONS=1 gh pr comment …`, etc.) or |
| disabled wholesale with `MAGPIE_GUARD_OFF=1` — the deny message |
| names the override. The dispatcher is stdlib-only and invoked as |
| `python3 …/agent-guard.py`, fast-pathing everything that is not a |
| `gh` / `git` command. |
| |
| ### Extensible — any skill can contribute a guard |
| |
| The hook is **wired once**. Additional guards are discovered at |
| runtime from the `guards.d` directory next to the script (plus any |
| dir in `$MAGPIE_GUARD_DIRS`). A skill contributes a guard by |
| shipping one import-free `*.py` file that defines `guard(ctx)` (and |
| an optional `TRIGGERS` list) — **no `settings.json` change**. The |
| setup skills sync `guards.d` from the snapshot, so a new bundled or |
| skill-contributed guard activates on the next `/magpie-setup` / |
| `setup-isolated-setup-update`. See the |
| [tool README](../../tools/agent-guard/README.md) for the contract and |
| `guards.d/no_verify_commit.py` for the template. |
| |
| ### Install (user-scope) |
| |
| ```bash |
| mkdir -p ~/.claude/scripts/guards.d |
| cp /path/to/magpie/tools/agent-guard/src/agent_guard/__init__.py \ |
| ~/.claude/scripts/agent-guard.py |
| # Bundled (universal) guards… |
| cp /path/to/magpie/tools/agent-guard/src/agent_guard/guards.d/*.py \ |
| ~/.claude/scripts/guards.d/ |
| # …plus every skill-owned guard (mention, mark-ready, security-language, …) |
| cp /path/to/magpie/skills/*/guards/*.py ~/.claude/scripts/guards.d/ 2>/dev/null || true |
| chmod +x ~/.claude/scripts/agent-guard.py |
| ``` |
| |
| Then wire it into `~/.claude/settings.json` (project-scope |
| `.claude/settings.json` works too) under `PreToolUse`, matched on |
| `Bash` — append to an existing `Bash` matcher's `hooks` array if one |
| is already present (e.g. the bypass-visibility hook): |
| |
| ```jsonc |
| { |
| "hooks": { |
| "PreToolUse": [ |
| { |
| "matcher": "Bash", |
| "hooks": [ |
| { |
| "type": "command", |
| "command": "python3 \"$HOME/.claude/scripts/agent-guard.py\"", |
| "timeout": 30 |
| } |
| ] |
| } |
| ] |
| } |
| } |
| ``` |
| |
| ### Verify |
| |
| ```bash |
| echo '{"tool_name":"Bash","tool_input":{"command":"gh pr edit 5 --body \"@alice hi\""}}' \ |
| | python3 ~/.claude/scripts/agent-guard.py |
| ``` |
| |
| Expected: a JSON object with `permissionDecision: "deny"` and a |
| reason mentioning the `mention` guard. A plain command |
| (`{"tool_input":{"command":"ls"}}`) produces no output and `exit=0`. |
| |
| ## Sandbox-error hint hook |
| |
| Companion to the *Sandbox-bypass visibility hook* above — a |
| `PostToolUse` hook that fires **after** every Bash tool call and |
| scans the result for the known sandbox-shaped error signatures |
| catalogued in |
| [`sandbox-troubleshooting.md`](sandbox-troubleshooting.md). |
| On a match, prints a `[sandbox-hint] …` line to stderr pointing |
| at the matching catalog entry. The tool's actual outcome is |
| unchanged — the hook is purely an annotation layer that surfaces |
| the catalog reference at the moment of failure, so the agent (or |
| the user) does not have to remember the catalog exists. |
| |
| ### Why install it |
| |
| The catalog (PR #291) and the diagnostic skill |
| [`setup-isolated-setup-doctor`](../../skills/setup-isolated-setup-doctor/SKILL.md) |
| (PR #292) cover the same ground but require explicit |
| recall — *"my SSH push failed; let me check the catalog"* or |
| *"let me run the doctor"*. The hint hook closes the loop by |
| making the catalog reference appear next to the error |
| automatically. Three classes of failure are recognised today: |
| |
| | Error signature | Catalog anchor | |
| |---|---| |
| | `Could not open a connection to your authentication agent` / `agent refused operation` / `ssh-add: error fetching identities` / `Permission denied (publickey)` | [SSH agent / Yubikey unreachable](sandbox-troubleshooting.md#ssh-agent--yubikey-appears-unreachable-from-inside-the-sandbox) | |
| | `Cannot connect to the Docker daemon` / `open /var/run/docker.sock: operation not permitted` / `Cannot connect to Podman` / podman `connect: permission denied` | [Docker / Podman socket denied](sandbox-troubleshooting.md#docker--podman-command-fails-with-a-socket-error) | |
| | `127.0.0.1 … Permission denied` / `Operation not permitted … bind` / `Errno 49 … assign requested address` / `Connection refused … 127.0.0.1` | [Localhost port-bind blocked](sandbox-troubleshooting.md#test-cannot-bind-to-a-localhost-port) | |
| |
| The hint also tells the user to run |
| `/magpie-setup-isolated-setup-doctor` for a structured probe of all |
| three failure modes, so a single mid-flow failure can lead to a |
| broader sandbox health-check. |
| |
| ### Why install it user-scope, not project-scope |
| |
| Same reasoning as the bypass-warn hook: the failure signatures |
| the hook detects are not framework-specific — they show up in any |
| sandboxed Bash session against any project. Putting the hook in |
| `~/.claude/settings.json` makes the hint fire across every |
| project on the host, including adopters that have not (yet) |
| adopted the framework. Project-scope wiring would leave |
| unrelated sessions silent. |
| |
| ### Install (user-scope) |
| |
| ```bash |
| mkdir -p ~/.claude/scripts |
| cp /path/to/magpie/tools/agent-isolation/sandbox-error-hint.sh \ |
| ~/.claude/scripts/sandbox-error-hint.sh |
| chmod +x ~/.claude/scripts/sandbox-error-hint.sh |
| ``` |
| |
| Then wire under `PostToolUse` with a `Bash` matcher. If a |
| `PostToolUse` `Bash` matcher already exists for another hook, |
| append to its `hooks` array rather than creating a second |
| matcher block: |
| |
| ```jsonc |
| { |
| "hooks": { |
| "PostToolUse": [ |
| { |
| "matcher": "Bash", |
| "hooks": [ |
| { |
| "type": "command", |
| "command": "~/.claude/scripts/sandbox-error-hint.sh" |
| } |
| ] |
| } |
| ] |
| } |
| } |
| ``` |
| |
| ### Verify |
| |
| The hook is exit-code-driven — exit 1 with stderr output means |
| "surface stderr to the user as a tool-result hint". To test |
| without a real failure: |
| |
| ```bash |
| echo '{"tool_name":"Bash","tool_response":{"stdout":"","stderr":"Could not open a connection to your authentication agent."}}' \ |
| | ~/.claude/scripts/sandbox-error-hint.sh; echo "exit=$?" |
| ``` |
| |
| Expected: a yellow `[sandbox-hint] SSH agent / Yubikey appears |
| unreachable …` line on stderr, then `exit=1`. A second call with |
| benign tool output (e.g. `"stdout":"hello world","stderr":""`) |
| should produce no output and `exit=0`. |
| |
| ### Trade-offs |
| |
| - **Pattern-matched, not semantic.** The hook recognises literal |
| error strings; it does not know *why* a tool call failed. A |
| failure mode dressed up in a userland framework's generic error |
| ("test failed", "build error") slips past silently. The |
| doctor skill is the catch-all when the hint does not fire and |
| the user suspects a sandbox issue. |
| - **Pattern set must stay in lock-step with the catalog.** When a |
| new entry lands in [`sandbox-troubleshooting.md`](sandbox-troubleshooting.md), |
| add a matching `match … hint=…` branch to the script. The |
| catalog is the source of truth; the hook is the discoverability |
| layer. |
| - **Fail-open by design.** Any unexpected JSON shape, missing |
| `tool_response`, missing `jq`, or other parse failure exits 0 |
| silently. A broken hint must never break a legitimate tool |
| call. Cost: a future Claude Code hook-schema change can silently |
| stop the hook from firing; re-run the verification snippet |
| above after every Claude Code upgrade. |
| - **Non-blocking.** The hook exits 1, not 2 — the tool call |
| result is unchanged. The hint is informational; the user |
| decides whether to apply the catalog's remediation. |
| |
| ## Sandbox-state status line |
| |
| The Claude Code terminal footer (`statusLine`) is the |
| always-visible bottom-of-window line that renders the model name, |
| context usage, and any custom information you wire in. It is the |
| right place to surface whether the sandbox is currently active for |
| this session — a session that is inadvertently running with |
| `sandbox.enabled` unset (or globally bypassed) cannot then drift |
| unnoticed for hours. |
| |
| The framework ships |
| [`tools/agent-isolation/sandbox-status-line.sh`](../../tools/agent-isolation/sandbox-status-line.sh) |
| to render exactly that: |
| |
| - `<model> [sandbox]` in green when the active settings set |
| `"sandbox": { "enabled": true }`, OR |
| - `<model> [NO SANDBOX]` in bold red when they do not. |
| |
| The script walks the same precedence Claude Code itself uses for |
| `sandbox.enabled` — project `settings.local.json` first, then |
| project `settings.json`, then `~/.claude/settings.local.json`, |
| then `~/.claude/settings.json` — and stops at the first file |
| that sets the key (to `true` *or* `false`). The `/sandbox` |
| slash-command toggle persists to project `settings.local.json`, |
| so flipping it mid-session is reflected in the prefix on the |
| next render. |
| |
| Like the [Sandbox-bypass visibility hook](#sandbox-bypass-visibility-hook), |
| this is **complementary**, not authoritative — see Trade-offs |
| below. |
| |
| **Why user-scope.** Same reasoning as the bypass-warn hook: a |
| session that runs without the sandbox is just as worth flagging |
| in an unrelated project as in a tracker. Install in |
| `~/.claude/settings.json` so the indicator shows in every session |
| on the host, not only sessions inside a tracker repo whose |
| project-level `.claude/settings.json` would otherwise have to wire |
| it itself. |
| |
| **Install (user-scope).** |
| |
| ```bash |
| mkdir -p ~/.claude/scripts |
| cp /path/to/magpie/tools/agent-isolation/sandbox-status-line.sh \ |
| ~/.claude/scripts/sandbox-status-line.sh |
| chmod +x ~/.claude/scripts/sandbox-status-line.sh |
| ``` |
| |
| Wire it into `~/.claude/settings.json` under the `statusLine` key: |
| |
| ```jsonc |
| { |
| "statusLine": { |
| "type": "command", |
| "command": "~/.claude/scripts/sandbox-status-line.sh" |
| } |
| } |
| ``` |
| |
| If you already maintain a richer custom statusLine, the helper is |
| intentionally one-line — call it as one segment of your own |
| renderer rather than replacing it. |
| |
| For adopters who want a richer variant out of the box, the framework |
| also ships |
| [`tools/agent-isolation/sandbox-status-line-rich.sh`](../../tools/agent-isolation/sandbox-status-line-rich.sh). |
| Same sandbox-state detection, plus folder name (hash-coloured for a |
| stable per-repo identity), git branch + dirty marker + ahead/behind, |
| per-branch PR title (cached for 5 min, silent when `gh` is missing or |
| unauthenticated), and a yellow `[sandbox-auto]` tag for the |
| `autoAllowBashIfSandboxed` setting. Install steps are identical — |
| copy the `-rich` file in place of the minimal one and point |
| `statusLine.command` at it. The minimal variant remains the |
| documented default; the rich one is opt-in. |
| |
| **Verify.** |
| |
| ```bash |
| echo '{"model":{"display_name":"Sonnet 4.6"},"workspace":{"current_dir":"'"$PWD"'"}}' \ |
| | ~/.claude/scripts/sandbox-status-line.sh |
| ``` |
| |
| Expected output, *inside* this repo (its |
| [`.claude/settings.json`](../../.claude/settings.json) sets |
| `sandbox.enabled: true`, and assuming `.claude/settings.local.json` |
| either does not exist or does not override the key): |
| `Sonnet 4.6 [sandbox]` with `[sandbox]` rendered in green. From a |
| directory whose project and user settings files do **not** enable |
| the sandbox (or do not exist), the output is `[NO SANDBOX]` in |
| bold red. |
| |
| **Trade-offs.** |
| |
| - **Settings-level truth, not session-level truth.** The script |
| reads `sandbox.enabled` from the file system. It cannot see CLI |
| flags (`--bypass-permissions`, equivalent runtime overrides) — |
| those still display as `[sandbox]` even though the running |
| session is unprotected. The `/sandbox` slash-command toggle |
| *is* reflected, because it persists to project |
| `settings.local.json`, which the script reads. Pair the |
| indicator with the |
| [Sandbox-bypass visibility hook](#sandbox-bypass-visibility-hook) |
| so per-call bypass attempts also surface in real time. |
| - **Schema robustness.** The Claude Code statusLine input JSON |
| does not currently expose sandbox state — we read the settings |
| files ourselves. If a future Claude Code release adds a sandbox |
| field to the statusLine input, the script can be simplified to |
| read that field directly. Until then the file-read approach is |
| the only option, with the trade-off above. |
| |
| ## Waiting-for-input terminal tint |
| |
| > **Quality-of-life helper, not a security control.** Unlike the |
| > rest of this document, this piece protects nothing — it just |
| > makes the "Claude is blocked on me" state impossible to miss. It |
| > rides the same user-scope-hook install machinery as the helpers |
| > above, which is why it lives here, but it is entirely optional |
| > and off by default. |
| |
| When you run several agents across tabs, it is easy to leave one |
| sitting at a permission prompt or a finished turn while you work |
| elsewhere. The framework ships |
| [`tools/agent-isolation/claude-term-bg.sh`](../../tools/agent-isolation/claude-term-bg.sh) |
| to make a **calm baseline the normal state and tint the background |
| only when Claude genuinely wants you to act** — never while it is |
| working, and never when it merely *finished* a turn and is idle |
| until you start the next thing. Those last two look identical at the |
| `Stop` event, so the model leans on three "Claude is asking you for |
| something" signals (two exact, one heuristic), wired across six |
| hooks: |
| |
| | Moment | Hook → action | Background | |
| |---|---|---| |
| | Turn ended on a genuine question/request | `Stop` → `stop` | tinted (muted indigo `#2a1a3a`) | |
| | Turn ended on a completion ("Done.") | `Stop` → `stop` | calm | |
| | Structured question posed | `PreToolUse` (matcher `AskUserQuestion`) → `wait` | tinted | |
| | Blocked on a permission prompt | `Notification` → `notify` | tinted | |
| | Actively working / you just acted | `PostToolUse` (matcher `*`) → `reset` | calm | |
| | Plain 60-second idle ping | `Notification` → `notify` (no-op) | unchanged | |
| | Fresh session, or you submit a reply | `SessionStart` / `UserPromptSubmit` → `reset` | calm | |
| |
| Four details make the model behave: |
| |
| - **`Stop` → `stop`** is the only non-exact signal. It reads the |
| last assistant text message from the session transcript (the path |
| arrives on stdin in the `Stop` payload) and tints only when that |
| message ends as a question (`…?`) or with a strong trailing |
| request ("want me to", "would you like", "should I", "OK to", |
| "your call", …); a statement-shaped completion stays calm. Needs |
| `python3`/`python` on `PATH`; if absent, `stop` defaults to calm |
| and only the two exact signals tint. |
| - **`PostToolUse` → `reset`** (not `PreToolUse`) clears the "you |
| just acted" tint. `PreToolUse` fires *before* the permission |
| prompt is shown, so it cannot clear a tint the prompt itself |
| sets; `PostToolUse` fires *after* the tool completes — the moment |
| your approval lets work resume — so it is what returns the screen |
| to calm. `PreToolUse` is reserved for the `AskUserQuestion` → |
| `wait` tint. |
| - **`SessionStart` → `reset`** clears any tint a *previous* session |
| left behind (OSC background changes persist in the terminal |
| across processes, so a session closed mid-wait would otherwise |
| hand its tint to the next one — making a "fresh" session look |
| like it is waiting on you). |
| - **`Notification` → `notify`** is selective: the same hook fires |
| both for permission prompts *and* the plain 60-second idle ping, |
| so the script reads the notification payload on stdin and tints |
| only for a permission/attention prompt. The idle ping is a |
| deliberate **no-op** (not a reset) — otherwise a turn that ended |
| on a genuine question, tinted by `stop`, would silently go calm |
| after a minute. |
| |
| **Two mechanics make this work** (both are easy to get wrong): |
| |
| 1. **Hooks have no controlling terminal.** Claude Code spawns hook |
| commands detached from the tty, so `/dev/tty` does not resolve |
| to your window — a naive `printf '\033]11;…' > /dev/tty` writes |
| nowhere. The script walks up the process tree from `$PPID` to |
| find the Claude process's pty (e.g. `/dev/ttys003`) and writes |
| the escape straight to that device. |
| 2. **Set and reset are not symmetric.** iTerm2 honours OSC 11 (set |
| background) but does **not** reliably honour OSC 111 |
| (reset-to-default) through Claude's fullscreen TUI, so a naive |
| reset leaves the tint stuck on. The script resets |
| belt-and-braces: it emits both OSC 111 *and* iTerm2's |
| proprietary `SetColors=bg=default`. For a guaranteed reset on |
| any terminal, set `CLAUDE_RESET_BG` to your normal background |
| colour and the script re-applies it via OSC 11 (the path that |
| is known to work since the tint itself does). |
| |
| **Why user-scope.** Same reasoning as the helpers above: you want |
| the signal in every session on the host, not only tracker |
| sessions. Install in `~/.claude/settings.json`. |
| |
| **Install (user-scope).** |
| |
| ```bash |
| mkdir -p ~/.claude/scripts |
| cp /path/to/magpie/tools/agent-isolation/claude-term-bg.sh \ |
| ~/.claude/scripts/claude-term-bg.sh |
| chmod +x ~/.claude/scripts/claude-term-bg.sh |
| ``` |
| |
| Wire it into `~/.claude/settings.json` under six hook events. If |
| you already have hooks on any of these events, add the command as |
| an extra entry rather than replacing the existing array. The |
| `PreToolUse` entry uses the `AskUserQuestion` matcher (not `*`), so |
| it tints only on a structured question; `PostToolUse` carries the |
| general `reset`. The `CLAUDE_RESET_BG=#000000` prefix makes the |
| calm state a deterministic black (recommended — it sidesteps the |
| OSC-111 reset gap described above); drop it to fall back to |
| profile-default reset. |
| |
| ```jsonc |
| { |
| "hooks": { |
| "Stop": [ |
| { "hooks": [ { "type": "command", "command": "CLAUDE_RESET_BG=#000000 ~/.claude/scripts/claude-term-bg.sh stop" } ] } |
| ], |
| "PreToolUse": [ |
| { "matcher": "AskUserQuestion", "hooks": [ { "type": "command", "command": "~/.claude/scripts/claude-term-bg.sh wait" } ] } |
| ], |
| "PostToolUse": [ |
| { "matcher": "*", "hooks": [ { "type": "command", "command": "CLAUDE_RESET_BG=#000000 ~/.claude/scripts/claude-term-bg.sh reset" } ] } |
| ], |
| "UserPromptSubmit": [ |
| { "hooks": [ { "type": "command", "command": "CLAUDE_RESET_BG=#000000 ~/.claude/scripts/claude-term-bg.sh reset" } ] } |
| ], |
| "SessionStart": [ |
| { "hooks": [ { "type": "command", "command": "CLAUDE_RESET_BG=#000000 ~/.claude/scripts/claude-term-bg.sh reset" } ] } |
| ], |
| "Notification": [ |
| { "hooks": [ { "type": "command", "command": "CLAUDE_RESET_BG=#000000 ~/.claude/scripts/claude-term-bg.sh notify" } ] } |
| ] |
| } |
| } |
| ``` |
| |
| Override the colours via the environment: `CLAUDE_WAIT_BG` |
| (default `#2a1a3a`) and `CLAUDE_RESET_BG` (default unset → reset to |
| the profile default; set to a colour like `#000000` for a |
| deterministic calm background). |
| |
| **Verify.** The hooks fire on real events, so the quickest check |
| is live: start a session, let a turn finish, and confirm the |
| background tints; then send a reply and confirm it resets. To |
| sanity-check the script in isolation, run it against your own |
| terminal device: |
| |
| ```bash |
| ~/.claude/scripts/claude-term-bg.sh wait # background tints |
| ~/.claude/scripts/claude-term-bg.sh reset # background restored |
| ``` |
| |
| (Run directly from an interactive shell, the script finds the |
| shell's pty via `$PPID` and writes there.) |
| |
| **Trade-offs.** |
| |
| - **iTerm2-tested, fail-soft elsewhere.** The set path uses OSC 11, |
| which most modern terminal emulators support; terminals that |
| ignore it simply show no change. The reset path is hardened for |
| iTerm2's OSC-111 gap specifically. On a terminal where reset |
| misbehaves, set `CLAUDE_RESET_BG` for a deterministic restore. |
| - **Cosmetic only.** It conveys no sandbox or permission state — |
| pair it with the [Sandbox-state status line](#sandbox-state-status-line) |
| and [Sandbox-bypass visibility hook](#sandbox-bypass-visibility-hook) |
| for the security-relevant signals. |
| |
| ## Syncing user-scope config across machines |
| |
| The user-scope pieces of the secure setup — |
| `~/.claude/scripts/sandbox-bypass-warn.sh`, an optional global copy |
| of `agent-iso.sh` (per the |
| [Global (user-scope) install](#the-clean-env-wrapper) trade-off), |
| your personal `~/.claude/CLAUDE.md`, plus any other custom hooks — |
| only protect a host once they are installed there. Working on more |
| than one machine means keeping all of them in lockstep, by hand, |
| forever. That is exactly the workflow a small dotfile-style sync |
| repo solves. |
| |
| The recommended pattern is a **private** git repository (private, |
| not public, because `~/.claude/CLAUDE.md` typically carries personal |
| collaboration preferences and the scripts may reference internal |
| paths). Track the artifacts you want shared, symlink them into |
| `~/.claude/`, and run a small sync script that pulls/commits/pushes. |
| |
| ### What to track, what not to track |
| |
| | Track in the synced repo | Keep per-machine | |
| |---|---| |
| | `CLAUDE.md` (personal collaboration prefs) | `~/.claude/.credentials.json` — ⚠ secret, never commit | |
| | `scripts/sandbox-bypass-warn.sh`, `scripts/sandbox-error-hint.sh`, `scripts/sandbox-status-line.sh`, and any other hooks | `~/.claude/sessions/`, `~/.claude/history.jsonl` — session state | |
| | `agent-isolation/agent-iso.sh` (if you globally installed it per the wrapper section) | `~/.claude/projects/<key>/` — per-project session state and tasks (the `memory/` subdir is optionally sharable, see [Extending `sync.sh`: share project memory across machines](#extending-syncsh-share-project-memory-across-machines)) | |
| | Custom slash commands (`commands/<name>.md`) | `~/.claude/settings.json` — typically differs per host (plugins, statusLine paths, voice) | |
| | MCP servers you've audited and want everywhere (`.mcp.json` shape, by hand) | `~/.claude/settings.local.json` — by design machine-specific | |
| |
| The settings.json line is worth highlighting: it is tempting to |
| sync it, and it does work, but in practice the machines drift |
| (different plugin sets, different terminal capabilities) and the |
| last-writer-wins behaviour of a naive sync script overwrites the |
| divergent settings every push. Keep it per-machine and document |
| the **wiring** instead — i.e. ship the `scripts/` directory in the |
| synced repo, then on each new host edit `~/.claude/settings.json` |
| once to point at the synced scripts. The "Install" snippets above |
| already follow this pattern. |
| |
| ### Layout |
| |
| A minimal repo layout: |
| |
| ```text |
| ~/.claude-config/ # the synced repo's checkout |
| ├── CLAUDE.md # symlinked → ~/.claude/CLAUDE.md |
| ├── scripts/ |
| │ ├── sandbox-bypass-warn.sh # symlinked → ~/.claude/scripts/sandbox-bypass-warn.sh |
| │ └── sandbox-status-line.sh # symlinked → ~/.claude/scripts/sandbox-status-line.sh |
| ├── agent-isolation/ |
| │ └── agent-iso.sh # symlinked → ~/.claude/agent-isolation/agent-iso.sh |
| ├── README.md # what's in the repo, install steps per machine |
| └── sync.sh # the pull/commit/push helper |
| ``` |
| |
| Each tracked artifact lives in the repo; the path under `~/.claude/` |
| is a symlink pointing at the repo. Editing either side updates both. |
| |
| ### Setting up a fresh host |
| |
| ```sh |
| git clone git@github.com:<you>/claude-config.git ~/.claude-config |
| |
| # CLAUDE.md |
| mkdir -p ~/.claude |
| [ -f ~/.claude/CLAUDE.md ] && [ ! -L ~/.claude/CLAUDE.md ] && \ |
| mv ~/.claude/CLAUDE.md ~/.claude/CLAUDE.md.bak |
| ln -sf ~/.claude-config/CLAUDE.md ~/.claude/CLAUDE.md |
| |
| # Sandbox-bypass warning hook + sandbox-state status line |
| mkdir -p ~/.claude/scripts |
| ln -sfn ~/.claude-config/scripts/sandbox-bypass-warn.sh \ |
| ~/.claude/scripts/sandbox-bypass-warn.sh |
| ln -sfn ~/.claude-config/scripts/sandbox-status-line.sh \ |
| ~/.claude/scripts/sandbox-status-line.sh |
| |
| # (Optional) global claude-iso wrapper — see the wrapper section |
| mkdir -p ~/.claude/agent-isolation |
| ln -sfn ~/.claude-config/agent-isolation/agent-iso.sh \ |
| ~/.claude/agent-isolation/agent-iso.sh |
| ``` |
| |
| Then wire the per-machine bits one time, per the install snippets |
| in the relevant sections (the hook entry in |
| `~/.claude/settings.json`, the `source …/agent-iso.sh` line in |
| `~/.bashrc` / `~/.zshrc`, etc.). |
| |
| ### A minimal `sync.sh` |
| |
| The script is intentionally tiny — pull, commit anything dirty, |
| push. Run it manually, on a cron, on a systemd timer, or wherever |
| fits your workflow: |
| |
| ```bash |
| #!/usr/bin/env bash |
| # Pull-commit-push the personal claude-config repo. Safe to run on |
| # a timer: flock prevents concurrent runs, --rebase --autostash |
| # carries any local edits through cleanly. |
| set -u |
| REPO="$HOME/.claude-config" |
| LOCK="$REPO/.sync.lock" |
| exec 9>"$LOCK"; flock -n 9 || exit 0 |
| cd "$REPO" || exit 1 |
| git pull --rebase --autostash |
| git add -A |
| git diff --cached --quiet || \ |
| git commit -m "auto-sync from $(hostname) at $(date -Iseconds)" |
| git log @{u}.. --oneline | grep -q . && git push |
| ``` |
| |
| ### Extending `sync.sh`: share project memory across machines |
| |
| Claude Code persists durable per-project memory under |
| `~/.claude/projects/<key>/memory/`, where `<key>` is the project's |
| absolute working directory with `/` and `.` replaced by `-`. The same |
| project takes a different key on each host |
| (`-home-you-code-foo` on Linux vs `-Users-you-code-foo` on macOS), so |
| a naive copy-the-tree-into-the-repo sync either misses the cross-host |
| mapping or stomps over it. |
| |
| The pattern that works: store memories in the repo under a |
| `$HOME`-relative subdir, and have `sync.sh` re-establish a per-host |
| symlink after every pull. The function below is idempotent — it |
| ingests any non-symlink memory dir found on the host that is not yet |
| in the repo, then re-points the runtime symlinks at the repo paths. |
| New project on a new host? Open it once; the next sync pass picks up |
| the memory dir, ingests it, and the symlink appears on every other |
| host on their next pull. |
| |
| ```bash |
| MEM_REPO="$HOME/.claude-config/memory" |
| PROJECTS="$HOME/.claude/projects" |
| |
| # Encode an absolute path the way Claude Code keys project dirs: every |
| # / and . becomes -. So /home/you/.claude-config -> -home-you--claude-config. |
| encode_path() { |
| local p="$1" |
| p="${p//\//-}" |
| p="${p//./-}" |
| printf '%s' "$p" |
| } |
| |
| ensure_memory_links() { |
| mkdir -p "$MEM_REPO" |
| local home_key |
| home_key="$(encode_path "$HOME")" |
| |
| # Step 1 — ingest any non-symlink memory dir not yet in the repo. |
| for project_dir in "$PROJECTS"/*/; do |
| runtime_mem="${project_dir}memory" |
| [[ -d "$runtime_mem" && ! -L "$runtime_mem" ]] || continue |
| [[ -n "$(ls -A "$runtime_mem" 2>/dev/null)" ]] || continue |
| |
| key="$(basename "${project_dir%/}")" |
| if [[ "$key" == "$home_key" ]]; then |
| norm="_root_" |
| elif [[ "$key" == "$home_key-"* ]]; then |
| norm="${key#$home_key-}" |
| else |
| # Project lives outside $HOME — preserve full key under ABS-. |
| norm="ABS$key" |
| fi |
| |
| repo_mem="$MEM_REPO/$norm" |
| [[ -e "$repo_mem" ]] && continue |
| mv "$runtime_mem" "$repo_mem" |
| done |
| |
| # Step 2 — re-establish per-host symlinks for every tracked memory dir. |
| for repo_mem in "$MEM_REPO"/*/; do |
| [[ -d "$repo_mem" ]] || continue |
| norm="$(basename "${repo_mem%/}")" |
| if [[ "$norm" == "_root_" ]]; then |
| key="$home_key" |
| elif [[ "$norm" == ABS-* ]]; then |
| key="${norm#ABS}" |
| else |
| key="$home_key-$norm" |
| fi |
| target="$PROJECTS/$key/memory" |
| mkdir -p "$(dirname "$target")" |
| if [[ -L "$target" ]]; then |
| [[ "$(readlink "$target")" == "${repo_mem%/}" ]] && continue |
| rm "$target" |
| elif [[ -d "$target" ]]; then |
| continue # real dir not yet ingested — leave alone |
| fi |
| ln -s "${repo_mem%/}" "$target" |
| done |
| } |
| ``` |
| |
| Call `ensure_memory_links` from `sync.sh` *after* `git pull` (untracked |
| files are not autostashed, so ingesting before pull risks colliding with |
| a remote add of the same path). |
| |
| ### Extending `sync.sh`: expose tracked scripts on `$PATH` |
| |
| A second helper, dropped into the same `sync.sh`, symlinks every |
| tracked executable into `~/.local/bin/` so the scripts are invocable |
| by name from any shell. Platform-suffixed binaries (`foo-linux`, |
| `foo-macos`) link as the bare `foo` on the matching host only — so the |
| same repo can carry both builds and each host picks up the right one. |
| |
| ```bash |
| LOCAL_BIN="$HOME/.local/bin" |
| REPO="$HOME/.claude-config" |
| |
| ensure_bin_links() { |
| mkdir -p "$LOCAL_BIN" |
| local platform="" |
| case "$(uname -s)" in |
| Linux) platform=linux ;; |
| Darwin) platform=macos ;; |
| esac |
| |
| link_one() { |
| local src="$1" name="$2" dst="$LOCAL_BIN/$2" |
| if [[ -L "$dst" ]]; then |
| [[ "$(readlink "$dst")" == "$src" ]] && return |
| rm "$dst" |
| elif [[ -e "$dst" ]]; then |
| return # something non-symlink is in the way — leave alone |
| fi |
| ln -s "$src" "$dst" |
| } |
| |
| for f in "$REPO"/bin/* "$REPO"/scripts/*.sh; do |
| [[ -f "$f" && -x "$f" ]] || continue |
| name="$(basename "$f")" |
| case "$name" in |
| *-linux) [[ "$platform" == "linux" ]] && link_one "$f" "${name%-linux}" ;; |
| *-macos) [[ "$platform" == "macos" ]] && link_one "$f" "${name%-macos}" ;; |
| *) link_one "$f" "$name" ;; |
| esac |
| done |
| } |
| ``` |
| |
| With this in place, no one-shot symlink step is needed when wiring a |
| fresh host for scripts in `bin/` or `scripts/` — the next sync pass |
| takes care of it. The hooks referenced by absolute path from |
| `settings.json` (e.g. `~/.claude/scripts/sandbox-bypass-warn.sh`) still |
| need their one-time symlink as in |
| [Setting up a fresh host](#setting-up-a-fresh-host) — these run from |
| the harness, not the user shell. |
| |
| ### Why a *private* repo |
| |
| Three reasons make this non-negotiable: |
| |
| 1. **`CLAUDE.md` carries personal preferences.** Tone overrides |
| for specific people, opinions about review style, names of |
| internal projects — content you do not want indexed by GitHub |
| search. |
| 2. **Hooks may embed internal paths.** A custom statusline script |
| that pokes at `~/work/<employer>/` is not something to publish. |
| 3. **Audit surface for prompt-injection.** If the synced repo is |
| public and writable by anyone with a PR, an attacker can land |
| a malicious script that every host pulling the repo will then |
| execute on the next sync. A private repo with branch protection |
| (or a single-author push policy) closes that vector. |
| |
| Public dotfile repos are fine for shell aliases and editor configs; |
| they are the wrong shape for agent-runtime files. |
| |
| ## Adopter setup |
| |
| If you are adopting the framework into your own tracker repo, copy |
| the secure setup into your tracker's working tree. Two paths — |
| the manual recipe is below, the agent-guided form is in the |
| sub-section that follows. |
| |
| ### Direct manual install |
| |
| 1. Install the pinned tools per [Install commands](#install-commands) |
| above. |
| 2. Copy |
| [`.claude/settings.json`](../../.claude/settings.json) from the framework |
| snapshot at `<your-tracker>/.apache-magpie/.claude/settings.json` |
| into `<your-tracker>/.claude/settings.json`. Adjust: |
| - The `sandbox.network.allowedDomains` list — drop the framework |
| domains you don't actually use, add any project-specific hosts. |
| - The `sandbox.filesystem.allowRead` list — same: drop the |
| dotfiles your project doesn't need, add any project-specific |
| paths the host requires. If you use Claude Code's `--worktree` |
| agent isolation, sibling agent worktrees live next to the active |
| one (e.g. `~/code/<project>/.claude/worktrees/agent-*/`), and |
| `git` operations on a worktree follow its `.git` file up to the |
| main repo's `.git/` directory. Both require read access to the |
| parent path that contains all worktrees and the main repo — |
| adopters who keep their checkout at, say, `~/code/<project>/` |
| should add that directory to `allowRead`. |
| - The `permissions.ask` list — add any project-specific |
| write-side commands you want to confirm explicitly (e.g. a |
| custom release-publishing CLI). |
| 3. Make `claude-iso` available on your shell — either per-repo |
| (sourcing the script from the framework snapshot) or globally |
| (copying the script to `~/.claude/agent-isolation/` and |
| sourcing from there). Both options are documented in |
| [The clean-env wrapper](#the-clean-env-wrapper). When the |
| framework is consumed via the standard snapshot path, the |
| per-repo source path is |
| `<your-tracker>/.apache-magpie/tools/agent-isolation/agent-iso.sh`. |
| 4. Decide whether to gitignore `.claude/settings.local.json` in your |
| tracker repo — Claude Code does this by default; verify with |
| `git check-ignore .claude/settings.local.json`. |
| 5. **Recommended (user-scope, not repo-scope):** install the |
| sandbox-bypass warning hook per |
| [Sandbox-bypass visibility hook](#sandbox-bypass-visibility-hook) |
| *and* the sandbox-state status line per |
| [Sandbox-state status line](#sandbox-state-status-line). Both |
| apply to every Claude Code session on the host (not only |
| tracker sessions), so they belong in your user-scope |
| `~/.claude/settings.json` — not in the tracker's |
| `.claude/settings.json`. |
| 6. **Optional (multi-machine workflow):** keep the user-scope |
| pieces (the hook scripts, the status-line script, your personal |
| `CLAUDE.md`, an optional global `agent-iso.sh`) in a private |
| dotfile-style repo per |
| [Syncing user-scope config across machines](#syncing-user-scope-config-across-machines). |
| |
| ### Via a Claude Code prompt |
| |
| Paste the following into Claude Code at the start of a fresh |
| session in your tracker repo. Claude walks every install step, |
| surfacing each command for you to approve or run yourself — |
| nothing privilege-elevating, nothing that touches your shell rc |
| or overwrites an existing settings file is applied without your |
| explicit OK: |
| |
| ```text |
| Set up the secure-agent setup for me from scratch in this tracker |
| repo. Walk me through every step before doing it; do not auto-run |
| anything that needs sudo, would overwrite an existing file, or |
| would write to my shell rc — print the command and ask me to run |
| it / approve it. |
| |
| Before starting, confirm: |
| |
| - The OS (Linux distro / macOS). |
| - The path to my magpie framework checkout (you'll need |
| to read its `.claude/settings.json`, |
| `tools/agent-isolation/*`, and |
| `tools/agent-isolation/pinned-versions.toml`). |
| - Whether this is a fresh install (no prior secure setup) or a |
| re-install on top of a partial state — for a re-install, |
| surface any existing user-scope `~/.claude/settings.json` hooks |
| and statusLine before merging. |
| |
| Then walk through: |
| |
| 1. **Required tools.** Read |
| `<magpie>/tools/agent-isolation/pinned-versions.toml` |
| and surface the install command for `bubblewrap` and `socat` |
| at the pinned versions for my distro (skip both on macOS — |
| Seatbelt is built-in). Then surface the npm command for |
| `claude-code` at **`@latest`** (it is unpinned — always the |
| newest for security). Also read the `[tools.claude-code]` |
| `min_version` floor and, since you are running under Claude |
| Code, check my running `claude --version` against it — if I am |
| **below** the floor, hard-fail and tell me to upgrade before |
| continuing. Print the install commands for me to run; do not |
| invoke sudo or npm yourself. |
| |
| 2. **Project `.claude/settings.json`.** Read |
| `<magpie>/.claude/settings.json` and copy its |
| `sandbox`, `permissions.deny`, and `permissions.ask` blocks |
| into this repo's `.claude/settings.json`. If a project |
| settings.json already exists, surface a diff of the merged |
| result first and ask me to approve before writing. |
| |
| 3. **Clean-env wrapper.** Surface the line to add to my |
| `~/.bashrc` or `~/.zshrc` to source |
| `<magpie>/tools/agent-isolation/agent-iso.sh`. Ask |
| whether I want it as the default `claude` (alias) or |
| on-demand only. Print the line; do not edit my shell rc |
| yourself. |
| |
| 4. **User-scope hook scripts.** `mkdir -p ~/.claude/scripts`, |
| then copy |
| `<magpie>/tools/agent-isolation/sandbox-bypass-warn.sh` |
| and |
| `<magpie>/tools/agent-isolation/sandbox-status-line.sh` |
| into `~/.claude/scripts/` and `chmod +x` them. |
| |
| 5. **User-scope `~/.claude/settings.json` wiring.** Read the |
| file if it exists. Add the `PreToolUse` `Bash` matcher wired |
| to `sandbox-bypass-warn.sh` and the `statusLine` command set |
| to `sandbox-status-line.sh`. If either key exists already |
| (e.g. I have other PreToolUse hooks for unrelated work), |
| surface the merge diff and ask me to approve before writing. |
| |
| 6. **(Optional) Waiting-for-input terminal tint.** Ask me whether |
| I want the terminal background to tint while Claude is waiting |
| on me (a pure quality-of-life signal, no security effect). |
| **Default no.** Only if I say yes: copy |
| `<magpie>/tools/agent-isolation/claude-term-bg.sh` |
| into `~/.claude/scripts/` and `chmod +x` it, then add six |
| hooks to `~/.claude/settings.json`, merging into any existing |
| arrays on those events — `Stop` → `claude-term-bg.sh stop` |
| (heuristic tint on a question/request, calm on a completion); |
| `PreToolUse` (matcher `AskUserQuestion`) → `claude-term-bg.sh |
| wait`; `UserPromptSubmit`, `SessionStart`, and `PostToolUse` |
| (matcher `*`) → `claude-term-bg.sh reset`; and `Notification` → |
| `claude-term-bg.sh notify`. Ask whether I want the calm state |
| to be a deterministic black (prefix the reset/notify commands |
| with `CLAUDE_RESET_BG=#000000`) or the terminal's profile |
| default. See |
| [Waiting-for-input terminal tint](#waiting-for-input-terminal-tint). |
| |
| 7. **Verify.** After everything is in place, walk through the |
| Verification checks from the next section of this document |
| ("Verification — Via a Claude Code prompt") and report |
| ✓ done / ✗ missing / ⚠ partial for each piece. |
| |
| 8. **Offer shared-config sync.** Once the install lands, propose |
| running `setup-shared-config-sync` to commit + push the |
| user-scope config this install just wired up to my private |
| `~/.claude-config` dotfile repo, so my other machines pick it |
| up. If I don't have `~/.claude-config` yet, note that the sync |
| skill will bootstrap it. Offer it — don't auto-run it. |
| |
| If any step fails, stop and report the failure — do not work |
| around it silently. |
| ``` |
| |
| When the prompt finishes, the [Verification](#verification) |
| section is the natural next step (Claude can run the verification |
| prompt in the same session — it has all the context already), and |
| [Keeping the setup updated](#keeping-the-setup-updated) is the |
| section to revisit after every Claude Code upgrade. |
| |
| ## Verification |
| |
| After installing and configuring, verify the setup actually denies |
| what it claims to. Two paths — pick whichever is easier; the |
| Claude-prompt path is more thorough, the direct-Bash path is |
| faster. |
| |
| ### Direct Bash verification |
| |
| Inside a `claude-iso` session, run these from the agent's Bash |
| tool. Each should fail or be denied: |
| |
| ```bash |
| cat ~/.aws/credentials # → permission denied (sandbox) |
| echo $AWS_ACCESS_KEY_ID # → empty (env stripped by claude-iso) |
| curl https://example.com # → blocked by permissions.deny |
| ``` |
| |
| Each command should produce a denial — not a leaked credential. |
| |
| ### Via a Claude Code prompt |
| |
| Paste the following into Claude Code at the start of a fresh |
| session in the tracker repo. Claude walks every install step and |
| reports what is wired vs missing, without trying to fix anything |
| on its own: |
| |
| ```text |
| Verify my secure-agent-setup install is complete. Check each item |
| below and report ✓ done / ✗ missing / ⚠ partial, with the evidence |
| (file path, line, command output). Do not attempt to fix anything |
| — surface the gaps and stop: |
| |
| 1. Project `.claude/settings.json` exists and has |
| `sandbox.enabled: true`, the `permissions.deny` block, the |
| `permissions.ask` block, the |
| `sandbox.network.allowedDomains` block, and the |
| `sandbox.filesystem` allowlist (`allowRead`/`allowWrite`). |
| 2. User-scope `~/.claude/settings.json` has the `PreToolUse` |
| `Bash` matcher wired to a `sandbox-bypass-warn.sh` command |
| and the `statusLine` command set to `sandbox-status-line.sh`. |
| 3. Both hook scripts exist and are executable |
| (`~/.claude/scripts/sandbox-bypass-warn.sh`, |
| `~/.claude/scripts/sandbox-status-line.sh`). |
| 4. The `claude-iso` shell function is sourced in `~/.bashrc` or |
| `~/.zshrc`. Note whether `alias claude='claude-iso'` is set. |
| 5. The pinned sandbox primitives from |
| `tools/agent-isolation/pinned-versions.toml` are installed at |
| the pinned versions: `bubblewrap` (Linux only), `socat` |
| (Linux only). For the agent runtime `claude-code` (unpinned, |
| `@latest`), instead compare my running `claude --version` |
| against the `[tools.claude-code]` `min_version` floor — |
| **hard-fail** if below it (running under Claude Code), else ✓. |
| 6. The status-line prefix in this session shows `[sandbox]` (not |
| `[NO SANDBOX]`). |
| 7. Run `cat ~/.aws/credentials`, `echo $AWS_ACCESS_KEY_ID`, and |
| `curl https://example.com` and confirm each is denied. |
| 8. If a `ponymail` and/or `apache-projects` MCP server is |
| registered in `~/.claude/settings.json` or |
| `.claude/settings.json`, resolve its `apache/comdev` checkout |
| from the `args` path and confirm it is on `main` |
| (`git -C <root> rev-parse --abbrev-ref HEAD`) and not behind |
| the last-fetched `origin/main` |
| (`git -C <root> rev-list --count HEAD..origin/main`). These |
| MCP servers track `main` by design — see |
| `tools/ponymail/tool.md` → "Keeping the checkout current". |
| Report only; do not fetch or pull. |
| ``` |
| |
| Re-run either form after every Claude Code upgrade — the sandbox |
| semantics occasionally evolve and the framework maintainer wants |
| to know the day a denial silently turns into an allow. |
| |
| ## Keeping the setup updated |
| |
| The secure setup has three independent moving parts that drift on |
| different schedules: the framework checkout (`.claude/settings.json`, |
| the wrapper / hook / status-line scripts under |
| `tools/agent-isolation/`, the pinned-versions manifest), the |
| pinned sandbox primitives (`bubblewrap`, `socat`) plus the unpinned |
| agent runtime (`claude-code`, tracked at `@latest`), and |
| any user-scope copies of helper scripts you installed under |
| `~/.claude/scripts/` or `~/.claude/agent-isolation/`. Keeping them |
| synchronised is a periodic operation, not a one-time install. |
| |
| ### Direct steps |
| |
| 1. **Framework checkout.** From your `magpie` clone, |
| pull the latest: |
| |
| ```bash |
| cd /path/to/magpie |
| git pull --ff-only |
| ``` |
| |
| That carries forward updates to `.claude/settings.json` (new |
| `denyRead` paths, `allowedDomains` entries, `ask`-list |
| additions), the wrapper / hook / status-line scripts under |
| `tools/agent-isolation/`, and the pinned-versions manifest. |
| |
| 2. **Pinned sandbox primitives.** Run the framework's check script, |
| which compares your `bubblewrap` / `socat` pins to upstream |
| releases that have aged past the 7-day cooldown: |
| |
| ```bash |
| tools/agent-isolation/check-tool-updates.sh |
| ``` |
| |
| For any candidate worth adopting, follow |
| [Bumping a pinned version](#bumping-a-pinned-version) — the |
| check script is side-effect-free and never edits the manifest |
| itself. It does **not** report `claude-code`: the runtime is |
| unpinned. Keep it current with |
| `npm install -g --no-save @anthropic-ai/claude-code@latest`, and |
| note the verify step hard-fails if your running claude-code is |
| below the manifest's `min_version` floor. |
| |
| 3. **User-scope script copies.** If you installed any helpers |
| user-scope (per |
| [Syncing user-scope config across machines](#syncing-user-scope-config-across-machines)), |
| diff each installed copy against the framework's |
| source-of-truth and re-`cp` if it has drifted: |
| |
| ```bash |
| diff ~/.claude/scripts/sandbox-bypass-warn.sh \ |
| /path/to/magpie/tools/agent-isolation/sandbox-bypass-warn.sh |
| diff ~/.claude/scripts/sandbox-status-line.sh \ |
| /path/to/magpie/tools/agent-isolation/sandbox-status-line.sh |
| diff ~/.claude/agent-isolation/agent-iso.sh \ |
| /path/to/magpie/tools/agent-isolation/agent-iso.sh |
| ``` |
| |
| 4. **comdev MCP checkouts.** If you registered the `ponymail` |
| and/or `apache-projects` MCP servers, refresh their local |
| `apache/comdev` checkout — these track `main`, not a pinned |
| tag (comdev ships them as in-repo source with no releases): |
| |
| ```bash |
| git -C /path/to/comdev fetch origin main |
| git -C /path/to/comdev rev-list --count HEAD..origin/main # behind? |
| git -C /path/to/comdev pull --ff-only # if behind |
| ( cd /path/to/comdev/mcp/ponymail-mcp && npm install ) |
| ( cd /path/to/comdev/mcp/apache-projects-mcp && npm install ) |
| ``` |
| |
| See [`tools/ponymail/tool.md` → Keeping the checkout current](../../tools/ponymail/tool.md#keeping-the-checkout-current). |
| |
| 5. **Re-verify.** Re-run [Verification](#verification) above |
| (either form) to confirm the denials still fire after the |
| update. |
| |
| ### Via a Claude Code prompt |
| |
| Paste the following into Claude Code at the start of a fresh |
| session in the tracker repo. Claude reports drift and upgrade |
| candidates, without modifying anything — you decide what to |
| apply: |
| |
| ```text |
| Update my secure-agent-setup install to the framework's latest. |
| Surface the diffs and the upgrade candidates; do not modify |
| anything — I will decide what to apply: |
| |
| 1. `cd` into my `magpie` clone and `git pull --ff-only`. |
| Report what changed under `tools/agent-isolation/`, |
| `.claude/settings.json`, and `secure-agent-setup.md`. |
| 2. Run `tools/agent-isolation/check-tool-updates.sh` and surface |
| any upgrade candidates for the pinned primitives `bubblewrap` |
| and `socat`, with the upstream changelog link for each. Do not |
| bump the manifest. Separately, check my running `claude |
| --version` against the `[tools.claude-code]` `min_version` |
| floor — flag a hard problem if I am below it — and recommend |
| `npm install -g --no-save @anthropic-ai/claude-code@latest` if a |
| newer runtime exists (claude-code is unpinned, so the check |
| script does not report it). |
| 3. Diff every user-scope copy under `~/.claude/scripts/` and (if |
| present) `~/.claude/agent-isolation/` against the framework |
| checkout. Report any drift, file by file. |
| 4. For any `ponymail` / `apache-projects` MCP server registered in |
| my settings, resolve its `apache/comdev` checkout from the |
| `args` path, `git -C <root> fetch origin main`, and report the |
| behind-count. When behind, print (do not run) |
| `git -C <root> pull --ff-only` + `npm install` in the affected |
| `mcp/<server>/` dir, plus the |
| `github.com/apache/comdev/compare/<sha>...main` link. These |
| servers track `main` by design — no manifest bump, no cooldown. |
| 5. Re-run `cat ~/.aws/credentials`, `echo $AWS_ACCESS_KEY_ID`, |
| `curl https://example.com` and confirm each is still denied. |
| Note any newly-allowed call as a regression to investigate. |
| ``` |
| |
| A good cadence for this prompt is once per Claude Code upgrade |
| or once a month, whichever comes first — and immediately after |
| adopting a pinned-version bump elsewhere in your fleet (so the |
| machines do not silently drift apart). Wire it into a recurring |
| agent via the framework's `/schedule` slash-command if you want |
| it to run unattended; the surfaced drift and upgrade candidates |
| land as a report you skim, not as auto-applied changes. |
| |
| ## What a session looks like |
| |
| The four screenshots below cover the visible states an adopter |
| actually meets. Each is reproducible from this repo with the |
| setup steps written into the screenshot's caption. |
| |
| **1. Sandboxed session — the steady state.** |
| |
| ![Sandboxed session: status-line prefix `[sandbox]` rendered green](../../assets/session-sandboxed.png) |
| |
| The terminal footer renders `<model> [sandbox]` in green when |
| the active settings (project `settings.local.json` → |
| project `settings.json` → user-scope) set |
| `sandbox.enabled: true`. Bash subprocesses run inside |
| bubblewrap (Linux) or Seatbelt (macOS) and only see paths |
| listed in `sandbox.filesystem.allowRead`. |
| |
| **2. Unsandboxed session — the failure mode this setup exists |
| to make obvious.** |
| |
| ![Unsandboxed session: status-line prefix `[NO SANDBOX]` rendered bold red](../../assets/session-no-sandbox.png) |
| |
| `[NO SANDBOX]` in bold red means the active settings do not |
| enable the sandbox. The agent's Bash subprocesses run with full |
| access to the host filesystem. The |
| [Sandbox-state status line](#sandbox-state-status-line) |
| exists specifically so a session in this state cannot drift |
| unnoticed for hours. |
| |
| **3. Sandbox-bypass attempt — the per-call signal.** |
| |
|  |
| |
| When the model invokes the Bash tool with |
| `dangerouslyDisableSandbox: true`, the |
| [Sandbox-bypass visibility hook](#sandbox-bypass-visibility-hook) |
| prints a bold red banner to stderr **before** the Claude Code |
| permission prompt renders. Approving the prompt at that point is |
| a deliberate act, not a skim-past click. |
| |
| The hook fires on bypass *attempts*, not on sandbox denials — a |
| Bash call that simply hits the sandbox and fails (screenshot 4 |
| below) will not trigger the banner, because the model never |
| requested bypass. To reproduce this state in a fresh session, ask |
| the model explicitly: *"use the Bash tool with |
| `dangerouslyDisableSandbox: true` to run `ls ~/.aws/`"*. The |
| explicit flag-name makes the next call a deterministic bypass |
| request — the banner renders, the prompt appears, and you can |
| deny at the prompt (the visual is what matters). |
| |
| **4. Sandbox actually denying a read — proof it is real.** |
| |
|  |
| |
| In a sandboxed session **without** bypass, a Bash call that |
| tries to touch a path outside `allowRead` is intercepted by |
| Claude Code's tool runtime *before* the bubblewrap (Linux) / |
| Seatbelt (macOS) subprocess actually fires. The runtime |
| surfaces the rule that was violated by name (here, |
| `read ~/Downloads (outside allowed read paths)`) and offers to |
| retry with the sandbox disabled — which would, in turn, route |
| through the bypass-warn hook from screenshot 3. The call never |
| reaches the OS-level enforcement layer; the runtime catches it |
| at the tool boundary, which is the cleaner failure mode. |
| |
| **5. bubblewrap / Seatbelt in action — the OS layer the runtime |
| falls back to.** |
| |
| ![Sandboxed Bash call running `python3 -c 'os.listdir(os.path.expanduser("~/.aws/"))'`; the inner syscall fails with PermissionError: [Errno 1] Operation not permitted: '/Users/jarekpotiuk/.aws/'](../../assets/sandbox-os-level-block.png) |
| |
| When the eventual filesystem access is **opaque to lexical |
| analysis** — here, a path constructed inside a `python3 -c` |
| one-liner via `os.path.expanduser`, which the runtime cannot |
| parse without actually executing it — the runtime hands the |
| Bash subprocess off to bubblewrap (Linux) / Seatbelt (macOS). |
| The OS sandbox then catches the violation at the syscall |
| boundary. The visible result is the underlying OS error: on |
| macOS Seatbelt, `[Errno 1] Operation not permitted` (above); |
| on Linux bubblewrap, `[Errno 2] No such file or directory`, |
| because the path is not even mounted into the subprocess's |
| namespace. |
| |
| Claude Code's runtime *also* recognises the denied path |
| post-hoc from the traceback and refuses to retry with bypass — |
| visible as the "I am **not** going to propose bypassing the |
| sandbox for this" narration below the python error. The two |
| layers are stacked deliberately: the runtime is the cheap, |
| predictable check (screenshot 4); bubblewrap/Seatbelt is the |
| unbypassable backstop for everything the runtime cannot |
| lexically pre-parse (this screenshot). Either layer alone has |
| gaps; together they are the actual sandbox. |
| |
| ## See also |
| |
| - [`secure-agent-internals.md`](secure-agent-internals.md) — the |
| design and mechanism behind the install steps in this document: |
| threat model, the three-layer defence, what `sandbox.enabled` |
| actually directs the Bash tool to do, how bubblewrap (Linux) |
| and Seatbelt (macOS) enforce the policy at the OS layer, the |
| SNI / DoH blind spot, the feedback-mechanism layering, and the |
| residual risks the setup does not eliminate. |
| - [`sandbox-troubleshooting.md`](sandbox-troubleshooting.md) — |
| catalog of known sandbox-shaped failure modes (SSH agent / |
| Yubikey unreachable, test port-bind blocked, docker / podman |
| socket denied) with symptom → root cause → settings.json fix |
| for each. Grep here first when a normal-looking operation fails |
| inside the sandbox. |
| - [`AGENTS.md`](../../AGENTS.md) — placeholder convention used in skill |
| files (`<tracker>`, `<upstream>`, `<security-list>`, …). |
| - [`README.md`](../../README.md) — framework overview and how the |
| secure setup fits the broader skill workflow. |