| <!-- 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 (pinned versions)](#required-tools-pinned-versions) |
| - [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) |
| - [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) |
| - [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-1) |
| - [Verify](#verify-1) |
| - [Trade-offs](#trade-offs-1) |
| - [Sandbox-state status line](#sandbox-state-status-line) |
| - [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 `airflow-steward` |
| 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-steward` (the canonical adopter pattern), run |
| `/setup-steward verify` to confirm the snapshot at |
| `.apache-steward/`, the committed `.apache-steward.lock`, and |
| the project-config files are wired correctly. Read-only — |
| surfaces gaps, never auto-fixes. |
| 3. Run /setup-isolated-setup-install — guided first-time install of |
| the secure-agent setup (sandbox, hooks, status line, |
| clean-env wrapper). |
| 4. Run /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 |
| `/setup-steward upgrade` — pulls your local airflow-steward |
| checkout to origin/main with --ff-only, refuses to touch a |
| dirty working tree, surfaces what arrived. Then run |
| /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 /setup-shared-config-sync to push local edits to the remote |
| so other machines pick them up. |
| ``` |
| |
| The skills are at |
| [`.claude/skills/setup-steward/verify.md`](../../.claude/skills/setup-steward/verify.md), |
| [`.claude/skills/setup-isolated-setup-install/`](../../.claude/skills/setup-isolated-setup-install/SKILL.md), |
| [`.claude/skills/setup-isolated-setup-verify/`](../../.claude/skills/setup-isolated-setup-verify/SKILL.md), |
| [`.claude/skills/setup-steward/upgrade.md`](../../.claude/skills/setup-steward/upgrade.md), |
| [`.claude/skills/setup-isolated-setup-update/`](../../.claude/skills/setup-isolated-setup-update/SKILL.md), |
| [`.claude/skills/setup-shared-config-sync/`](../../.claude/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 (pinned versions)" below. |
| sudo apt-get install --no-install-recommends \ |
| bubblewrap=0.11.2-* socat=1.8.1.1-* |
| npm install -g --no-save @anthropic-ai/claude-code@2.1.141 |
| |
| # 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 `claude-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 (pinned versions) |
| |
| Every system-level tool the secure setup depends on is 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. `claude-code` is the canonical override at 1 day — |
| its release cadence is high enough that a longer floor would |
| strand the framework many versions behind upstream, and any |
| regression that affects the secure setup's permission-rule |
| semantics or sandbox flags is caught broadly within hours of |
| release. |
| |
| 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.1 | 2026-03-13 | 7d (default) | TCP relay for the sandbox network allowlist. Linux only. | |
| | `claude-code` | 2.1.141 | 2026-05-13 | 1d (override) | Agent runtime. Pin separately from any system claude install so behavioural changes don't drift the framework's effective security posture without review. | |
| |
| 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.1-* |
| ``` |
| |
| **Fedora / RHEL (dnf)**: |
| |
| ```bash |
| sudo dnf install \ |
| bubblewrap-0.11.2 \ |
| socat-1.8.1.1 |
| ``` |
| |
| **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**: |
| |
| ```bash |
| # npm distribution (the only stable channel today) |
| npm install -g --no-save @anthropic-ai/claude-code@2.1.141 |
| ``` |
| |
| ### Distro-specific shortcut — Linux Mint 22.x / Ubuntu 24.04 Noble |
| |
| The pinned versions above (bubblewrap `0.11.2`, socat `1.8.1.1`) 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.1` 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 |
| |
| When an upstream release has aged past the tool's cooldown (7-day |
| default, 1-day for `claude-code` per its manifest override) 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. |
| |
| ### 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, |
| "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.) |
| "~/.local/bin/", // uv-installed tool entry points |
| "~/.config/apache-steward/", // 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) |
| ] |
| }, |
| "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", "cveprocess.apache.org", "cve.org", "www.cve.org", |
| "oauth2.googleapis.com", "gmail.googleapis.com" |
| ] |
| } |
| }, |
| "permissions": { |
| "allow": [ |
| "Bash(gh api graphql *)" // read-only GraphQL fetches (PR-triage paginated fetch loop, similar bulk reads); MORE SPECIFIC than the `-F`/`-f` ask rules below, so it short-circuits them. Mutations via `gh api graphql -F query='mutation {...}'` slip through this rule and are not prompted — accept this trade-off because the skills in this framework do not route mutations through graphql (REST + explicit `-X`/`--method` is the mutation path). |
| ], |
| "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-steward/**)", // 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 *)" |
| ], |
| "ask": [ |
| "Bash(git push *)", // including --force / --force-with-lease variants |
| "Bash(gh pr create *)", "Bash(gh pr edit *)", "Bash(gh pr merge *)", |
| "Bash(gh issue create *)", "Bash(gh issue edit *)", |
| "Bash(gh issue close *)", "Bash(gh issue comment *)", |
| "Bash(gh release create *)", |
| "Bash(gh api * -X *)", // any non-default-method API call |
| "Bash(gh api * -f *)", "Bash(gh api * -F *)" // any payload-bearing API call — narrowed by the `gh api graphql *` allow above for the GraphQL read path |
| ] |
| } |
| } |
| ``` |
| |
| The deny / allow split for `~/.config/gh/` and |
| `~/.config/apache-steward/` 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. |
| |
| ## 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-steward.lock` fail too. The full reproducer |
| and harness-side analysis is in |
| [issue #197](https://github.com/apache/airflow-steward/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.** |
| `/setup-steward 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 |
| [`/setup-steward verify`](../../.claude/skills/setup-steward/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`](../../.claude/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** — `/setup-steward 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** — `/setup-steward 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 `/setup-steward 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`](../../.claude/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`. |
| - [`/setup-steward verify`](../../.claude/skills/setup-steward/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`](../../.claude/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. |
| |
| | Scope | What it covers | Mechanism | Reversal | |
| |---|---|---|---| |
| | **Per-project** (default) | 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 `/setup-steward adopt` in steward-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** | Every git repo on the operator's host, existing and future. Includes non-steward 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 framework installs **only** the `post-checkout` hook in the |
| shared dir. Pre-commit / commit-msg / pre-push / other hook types |
| need their own files in the shared dir if the operator wants |
| them to fire. This is a deliberate trade-off: a single mechanism |
| for whole-user coverage at the cost of needing to migrate |
| per-repo hooks. |
| |
| 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](../../.claude/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 apache-steward 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. |
| |
| ## The clean-env wrapper |
| |
| Layer 0 — strip credential-shaped env vars from the parent shell |
| before invoking `claude` — is implemented by |
| [`tools/agent-isolation/claude-iso.sh`](../../tools/agent-isolation/claude-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/airflow-steward/tools/agent-isolation/claude-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/airflow-steward/tools/agent-isolation/claude-iso.sh \ |
| ~/.claude/agent-isolation/claude-iso.sh |
| |
| # ~/.bashrc or ~/.zshrc — guarded so it's a no-op until the file exists |
| [ -f "$HOME/.claude/agent-isolation/claude-iso.sh" ] \ |
| && . "$HOME/.claude/agent-isolation/claude-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/airflow-steward/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. |
| |
| ## 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`](../../.claude/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 |
| `/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/airflow-steward/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/airflow-steward/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. |
| |
| ## 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 `claude-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/claude-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/ |
| │ └── claude-iso.sh # symlinked → ~/.claude/agent-isolation/claude-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/claude-iso.sh \ |
| ~/.claude/agent-isolation/claude-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 …/claude-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-steward/.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-steward/tools/agent-isolation/claude-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 `claude-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 airflow-steward 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. **Pinned tools.** Read |
| `<airflow-steward>/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 the pinned version. Print these for me to |
| run; do not invoke sudo or npm yourself. |
| |
| 2. **Project `.claude/settings.json`.** Read |
| `<airflow-steward>/.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 |
| `<airflow-steward>/tools/agent-isolation/claude-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 |
| `<airflow-steward>/tools/agent-isolation/sandbox-bypass-warn.sh` |
| and |
| `<airflow-steward>/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. **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. |
| |
| 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, and the |
| `sandbox.network.allowedDomains` block. |
| 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 tool versions from |
| `tools/agent-isolation/pinned-versions.toml` are installed at |
| the pinned versions: `bubblewrap` (Linux only), `socat` |
| (Linux only), `claude-code`. |
| 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. |
| ``` |
| |
| 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 upstream tools (`bubblewrap`, `socat`, `claude-code`), 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 `airflow-steward` clone, |
| pull the latest: |
| |
| ```bash |
| cd /path/to/airflow-steward |
| 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 upstream tools.** Run the framework's check script, |
| which compares your 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. |
| |
| 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/airflow-steward/tools/agent-isolation/sandbox-bypass-warn.sh |
| diff ~/.claude/scripts/sandbox-status-line.sh \ |
| /path/to/airflow-steward/tools/agent-isolation/sandbox-status-line.sh |
| diff ~/.claude/agent-isolation/claude-iso.sh \ |
| /path/to/airflow-steward/tools/agent-isolation/claude-iso.sh |
| ``` |
| |
| 4. **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 `airflow-steward` 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 `bubblewrap`, `socat`, or |
| `claude-code`, with the upstream changelog link for each. Do |
| not bump the manifest. |
| 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. 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](../../images/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](../../images/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/'](../../images/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. |