Table of Contents generated with DocToc
- Secure agent setup
- Quick start
- Required tools (pinned versions)
- The framework’s own
.claude/settings.json - Project-root coverage in the sandbox allowlists
- The clean-env wrapper
- Sandbox-bypass visibility hook
- Sandbox-error hint hook
- Sandbox-state status line
- Syncing user-scope config across machines
- Adopter setup
- Verification
- Keeping the setup updated
- What a session looks like
- See also
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. 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.
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.
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-isolated-setup-install/,
.claude/skills/setup-isolated-setup-verify/,
.claude/skills/setup-steward/upgrade.md,
.claude/skills/setup-isolated-setup-update/,
.claude/skills/setup-shared-config-sync/.
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:
# 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 and the weekly Dependabot
updates in .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:
| 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):
sudo apt-get update
sudo apt-get install --no-install-recommends \
bubblewrap=0.11.2-* \
socat=1.8.1.1-*
Fedora / RHEL (dnf):
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:
# 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:
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:
- 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. - 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.
- Edit
tools/agent-isolation/pinned-versions.toml: update the tool’sversionandreleasedfields, then update the top-levelpinned_atfield to today’s date. - Update the install commands in this document if the distro package version string has shifted.
- 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:
/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. The full block is
below, annotated.
{
"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.
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:
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:
"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
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 checkoutfired from the operator’s shell triggerspost-checkout, which runs the helper in the shell’s context — outside the agent sandbox. Writes succeed normally. - First-time install.
setup-isolated-setup-installis 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: truefrom agent sessions./setup-steward adopt,upgrade, andworktree-initinvoke the helper with explicit sandbox bypass. Every bypass triggerssandbox-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
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 cds 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
to perform this addition idempotently. Installed during
setup-isolated-setup-install
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-toplevelin the current working directory. - With
--all-worktrees, also enumeratesgit worktree list --porcelainand 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.jsonfrom scratch if missing (with only thesandbox.filesystemblock — 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:
- At install —
setup-isolated-setup-installruns the helper with--all-worktreesagainst the adopter repo the operator is sitting in. - During adoption —
/setup-steward adoptStep 12 runs the helper with--all-worktreesso a fresh adopter repo with pre-existing worktrees has every working-tree path covered without an extra round-trip throughsetup-isolated-setup-install. - During upgrade —
/setup-steward upgradeStep 6c, after the per-worktreeworktree-initchain, runs the helper with--all-worktreesso any worktree added since adopt has its path written into its own settings.local.json. - Per worktree, on creation — the
post-checkoutgit hook installed by/setup-steward adoptruns the helper without--all-worktrees, picking up only the new worktree’s path.git worktree addfirespost-checkoutin 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-verifyCheck 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 verifyCheck 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
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 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.
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.jsonauto-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.
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.
# ~/.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.
# 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:
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:
# 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:
# 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
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
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 (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)
# 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:
{
"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:
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 1toexit 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": truerather than reading a fixed JSON path viajq, 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 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.
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
(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 |
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 |
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 |
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)
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:
{
"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:
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, add a matchingmatch … 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, missingjq, 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
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, 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).
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:
{
"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.
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.
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 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.enabledfrom 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/sandboxslash-command toggle is reflected, because it persists to projectsettings.local.json, which the script reads. Pair the indicator with the 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 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) |
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:
~/.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
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:
#!/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.
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.
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 — these run from
the harness, not the user shell.
Why a private repo
Three reasons make this non-negotiable:
CLAUDE.mdcarries personal preferences. Tone overrides for specific people, opinions about review style, names of internal projects — content you do not want indexed by GitHub search.- Hooks may embed internal paths. A custom statusline script
that pokes at
~/work/<employer>/is not something to publish. - 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
- Install the pinned tools per Install commands above.
- Copy
.claude/settings.jsonfrom the framework snapshot at<your-tracker>/.apache-steward/.claude/settings.jsoninto<your-tracker>/.claude/settings.json. Adjust:- The
sandbox.network.allowedDomainslist — drop the framework domains you don’t actually use, add any project-specific hosts. - The
sandbox.filesystem.allowReadlist — same: drop the dotfiles your project doesn’t need, add any project-specific paths the host requires. If you use Claude Code’s--worktreeagent isolation, sibling agent worktrees live next to the active one (e.g.~/code/<project>/.claude/worktrees/agent-*/), andgitoperations on a worktree follow its.gitfile 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 toallowRead. - The
permissions.asklist — add any project-specific write-side commands you want to confirm explicitly (e.g. a custom release-publishing CLI).
- The
- Make
claude-isoavailable 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. 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. - Decide whether to gitignore
.claude/settings.local.jsonin your tracker repo — Claude Code does this by default; verify withgit check-ignore .claude/settings.local.json. - Recommended (user-scope, not repo-scope): install the
sandbox-bypass warning hook per
Sandbox-bypass visibility hook
and the sandbox-state status line per
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. - Optional (multi-machine workflow): keep the user-scope
pieces (the hook scripts, the status-line script, your personal
CLAUDE.md, an optional globalclaude-iso.sh) in a private dotfile-style repo per 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:
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 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 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:
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:
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
-
Framework checkout. From your
airflow-stewardclone, pull the latest:cd /path/to/airflow-steward git pull --ff-onlyThat carries forward updates to
.claude/settings.json(newdenyReadpaths,allowedDomainsentries,ask-list additions), the wrapper / hook / status-line scripts undertools/agent-isolation/, and the pinned-versions manifest. -
Pinned upstream tools. Run the framework’s check script, which compares your pins to upstream releases that have aged past the 7-day cooldown:
tools/agent-isolation/check-tool-updates.shFor any candidate worth adopting, follow Bumping a pinned version — the check script is side-effect-free and never edits the manifest itself.
-
User-scope script copies. If you installed any helpers user-scope (per Syncing user-scope config across machines), diff each installed copy against the framework’s source-of-truth and re-
cpif it has drifted: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 -
Re-verify. Re-run 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:
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](/docs-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](/docs-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
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
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/'](/docs-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— the design and mechanism behind the install steps in this document: threat model, the three-layer defence, whatsandbox.enabledactually 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— 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— placeholder convention used in skill files (<tracker>,<upstream>,<security-list>, …).README.md— framework overview and how the secure setup fits the broader skill workflow.