From 35bf73df0860ea3faa10968c2eb13e54b17662b9 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Thu, 30 Apr 2026 11:38:02 +0200 Subject: [PATCH 1/3] docs(secure-agent-setup): sandbox-bypass warning hook + private-repo sync pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new sections in `secure-agent-setup.md`: - "Sandbox-bypass visibility hook" — install + wiring for a PreToolUse hook that prints a bold-red banner whenever the model invokes Bash with `dangerouslyDisableSandbox: true`. Recommended user-scope (`~/.claude/settings.json`) so it fires in every session on the host, not just tracker sessions. Includes a Verify snippet and Trade-offs notes (visibility-not- block; grep-based JSON match for schema robustness). - "Syncing user-scope config across machines" — the dotfile-style pattern for keeping `CLAUDE.md`, hook scripts, and an optional global `claude-iso.sh` in lockstep across hosts via a private git repo. What-to-track table, repo layout, fresh-host setup, a minimal \`sync.sh\`, and a "Why a private repo" rationale. Also adds two recommended/optional steps to "Adopter setup" linking to the new sections. Ships the hook script as \`tools/agent-isolation/sandbox-bypass-warn.sh\` with the same ASF license header as the sibling \`claude-iso.sh\`, and adds it to the directory's README files table. The doctoc-managed TOC at the top of \`secure-agent-setup.md\` is NOT updated by this commit — re-run doctoc before merging. Generated-by: Claude Code (Claude Opus 4.7) --- secure-agent-setup.md | 243 +++++++++++++++++++ tools/agent-isolation/README.md | 1 + tools/agent-isolation/sandbox-bypass-warn.sh | 97 ++++++++ 3 files changed, 341 insertions(+) create mode 100755 tools/agent-isolation/sandbox-bypass-warn.sh diff --git a/secure-agent-setup.md b/secure-agent-setup.md index a90557d5..f74bc029 100644 --- a/secure-agent-setup.md +++ b/secure-agent-setup.md @@ -383,6 +383,238 @@ CLAUDE_ISO_ALLOW="GH_TOKEN" GH_TOKEN="$(op read 'op://Personal/GitHub/token')" c The `CLAUDE_ISO_ALLOW` mechanism is opt-in per invocation — no implicit propagation, no persistent allowlist. +## 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. + +## 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` 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/` — per-project memory and tasks | +| Custom slash commands (`commands/.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 +├── 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:/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 +mkdir -p ~/.claude/scripts +ln -sfn ~/.claude-config/scripts/sandbox-bypass-warn.sh \ + ~/.claude/scripts/sandbox-bypass-warn.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 +``` + +### 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//` 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 @@ -412,6 +644,17 @@ the secure setup into your tracker's working tree: 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). + It applies to every Claude Code session on the host (not only + tracker sessions), so it belongs 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 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). ## Verification diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md index ba6711d1..d0afba73 100644 --- a/tools/agent-isolation/README.md +++ b/tools/agent-isolation/README.md @@ -28,6 +28,7 @@ versions. | [`pinned-versions.toml`](pinned-versions.toml) | Machine-readable manifest of pinned upstream versions for `bubblewrap`, `socat`, and `claude-code`. Each entry carries a `released` date that satisfies the framework's 7-day cooldown convention. | | [`check-tool-updates.sh`](check-tool-updates.sh) | Reads the manifest and reports upstream releases that are newer than the pin AND have themselves aged past the 7-day cooldown. Side-effect-free — no installs, no edits, no PRs. | | [`claude-iso.sh`](claude-iso.sh) | Shell function to launch Claude Code with `env -i` and a tiny passthrough list, stripping every credential-shaped environment variable from the parent shell. The framework's "layer 0" of the secure setup. | +| [`sandbox-bypass-warn.sh`](sandbox-bypass-warn.sh) | Claude Code `PreToolUse` hook (Bash matcher). Prints a bold-red banner to stderr whenever the model invokes the Bash tool with `dangerouslyDisableSandbox: true`. Belt-and-braces visibility for the sandbox-bypass permission prompt. Recommended user-scope (`~/.claude/settings.json`) so it fires across every session on the host. | ## Usage at a glance diff --git a/tools/agent-isolation/sandbox-bypass-warn.sh b/tools/agent-isolation/sandbox-bypass-warn.sh new file mode 100755 index 00000000..1cc54bac --- /dev/null +++ b/tools/agent-isolation/sandbox-bypass-warn.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# sandbox-bypass-warn.sh — Claude Code PreToolUse hook (Bash matcher). +# +# Loudly warns whenever the model invokes the Bash tool with +# `dangerouslyDisableSandbox: true` — i.e. when the model is asking +# to leave Claude Code's filesystem/network sandbox for one call. +# +# Claude Code already prompts the user before honouring a sandbox +# bypass, but the prompt is easy to skim past in long sessions. +# This hook makes every bypass attempt visually impossible to miss: +# a bold-red banner with the command and the model's stated reason. +# +# Recommended placement: user-scope `~/.claude/settings.json`, so +# the warning fires for *every* Claude Code session on the host — +# not only sessions inside a tracker repo whose project-level +# `.claude/settings.json` would otherwise have to wire it itself. +# +# Behaviour: +# - Reads tool input as JSON on stdin. +# - If `"dangerouslyDisableSandbox": true` appears anywhere in the +# payload (matched as a JSON field, robust to schema changes +# across Claude Code versions), prints a bold-red banner to +# stderr with the command and the model's stated reason. +# - Exits 1: a non-zero exit code that is *not* 2 means stderr is +# shown to the user in the terminal and the tool call still +# proceeds through the normal permission prompt. We want +# visibility, not block. +# - Exit 2 would block the call entirely — *not* what we want; the +# user will be asked to authorise the bypass anyway, and a hard +# block here would defeat the model's ability to do work the +# user explicitly asked for. +# - Otherwise exits 0 silently. +# +# Wiring (user-scope, applies to every session on the host): +# +# { +# "hooks": { +# "PreToolUse": [ +# { +# "matcher": "Bash", +# "hooks": [ +# { "type": "command", +# "command": "~/.claude/scripts/sandbox-bypass-warn.sh" } +# ] +# } +# ] +# } +# } +# +# See `secure-agent-setup.md` → "Sandbox-bypass visibility hook" +# for the install steps and the "Syncing user-scope config across +# machines" section for the private-repo pattern that keeps this +# hook in lockstep across hosts. + +set -u + +input=$(cat) + +# Match the JSON field anywhere in the payload. This is intentionally +# more permissive than `jq -r '.tool_input.dangerouslyDisableSandbox'` +# so the hook keeps working if Claude Code ever reshuffles where in +# the payload the flag lives. +if printf '%s' "$input" | grep -qE '"dangerouslyDisableSandbox"[[:space:]]*:[[:space:]]*true'; then + cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true) + desc=$(printf '%s' "$input" | jq -r '.tool_input.description // ""' 2>/dev/null || true) + + esc=$(printf '\033') + red="${esc}[1;31m" + reset="${esc}[0m" + + { + printf '%s\041\041\041 SANDBOX BYPASS REQUESTED \041\041\041%s\n' "$red" "$reset" + [ -n "$desc" ] && printf '%sReason :%s %s\n' "$red" "$reset" "$desc" + [ -n "$cmd" ] && printf '%sCommand:%s %s\n' "$red" "$reset" "$cmd" + printf '%sThis call will run OUTSIDE the sandbox.%s\n' "$red" "$reset" + } >&2 + exit 1 +fi + +exit 0 From 287d2e2cad13ef9355ad733c5676dc9c838f278a Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Thu, 30 Apr 2026 11:43:38 +0200 Subject: [PATCH 2/3] docs(secure-agent-setup): regenerate doctoc TOC for new sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (35bf73d) flagged that the doctoc-managed TOC at the top of `secure-agent-setup.md` was not updated when the two new sections — "Sandbox-bypass visibility hook" and "Syncing user-scope config across machines" — were added. This commit regenerates the TOC to include those sections plus their H3 subsections (maxlevel=3, matching `.pre-commit-config.yaml`). doctoc itself was not run to produce this — it is not installed in the local environment and the sandbox blocks the npm/uv network access needed to fetch it. The anchors were instead derived by hand following the same encoding rules already visible in the existing TOC entries in this file: - spaces → `-` - in-word hyphens preserved (e.g. `user-scope`) - parentheses, asterisks, periods, backticks dropped without a separator - em-dash / slash with surrounding spaces → `--` A future doctoc run (e.g. via the pre-commit hook in CI) should produce a no-op diff against this TOC. Generated-by: Claude Code (Claude Opus 4.7) --- secure-agent-setup.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/secure-agent-setup.md b/secure-agent-setup.md index f74bc029..94fdcd2a 100644 --- a/secure-agent-setup.md +++ b/secure-agent-setup.md @@ -12,6 +12,17 @@ - [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) - [The clean-env wrapper](#the-clean-env-wrapper) + - [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) + - [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) + - [Why a *private* repo](#why-a-private-repo) - [Adopter setup](#adopter-setup) - [Verification](#verification) - [Residual risks](#residual-risks) From 795f69ab814e1b9442a9b5877c62047158e06c68 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Thu, 30 Apr 2026 11:56:11 +0200 Subject: [PATCH 3/3] docs(secure-agent-setup): sandbox-state status line for the terminal footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `statusLine` helper that makes the active sandbox state of the current Claude Code session visible at all times — green `[sandbox]` when `sandbox.enabled: true` is set in the resolved `settings.json`, bold-red `[NO SANDBOX]` otherwise. The indicator sits in the terminal footer that Claude Code already renders, so no extra UI surface is added; it just makes the existing footer informative about the *one* configuration property the rest of this document is about. Why it is needed even with the existing sandbox-bypass-warn hook: the `PreToolUse` hook fires per-call when the model requests a bypass, but a session whose `sandbox.enabled` was simply never set (e.g. an unrelated project's `.claude/settings.json` is missing, the user is in `~`, settings drift after a Claude Code upgrade) emits no signal until something goes wrong. The status line covers that gap by surfacing settings-level state continuously. Three changes: - `tools/agent-isolation/sandbox-status-line.sh` — the helper. Reads the statusLine JSON on stdin, resolves `.sandbox.enabled` from project- then user-scope `settings.json` via `jq`, prints ` [sandbox]` (green) or ` [NO SANDBOX]` (bold red). ASF license header matching the sibling scripts; executable bit set. - `secure-agent-setup.md` — new H2 "Sandbox-state status line" between the bypass-warn hook section and the sync-config section (sibling user-scope observability features). Adopter step 5 now recommends both, the sync-config layout, "what to track" table and fresh-host setup snippet pick up the new script. TOC entry added by hand following the same encoding rule as the previous TOC regen — anchor `#sandbox-state-status-line`. - `tools/agent-isolation/README.md` — Files table picks up the new script. Trade-offs are documented in-line: the indicator is settings-level truth, not session-level truth (CLI flags like `--bypass-permissions` and runtime mode changes that override the file are not visible), and pairing with the per-call bypass-warn hook is the recommended belt-and-braces posture. A future Claude Code statusLine schema that exposes sandbox state directly would let the helper drop the file-read. Generated-by: Claude Code (Claude Opus 4.7) --- secure-agent-setup.md | 109 +++++++++++++++++-- tools/agent-isolation/README.md | 1 + tools/agent-isolation/sandbox-status-line.sh | 87 +++++++++++++++ 3 files changed, 189 insertions(+), 8 deletions(-) create mode 100755 tools/agent-isolation/sandbox-status-line.sh diff --git a/secure-agent-setup.md b/secure-agent-setup.md index 94fdcd2a..0e3e6eb3 100644 --- a/secure-agent-setup.md +++ b/secure-agent-setup.md @@ -17,6 +17,7 @@ - [Install (user-scope)](#install-user-scope) - [Verify](#verify) - [Trade-offs](#trade-offs) + - [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) @@ -501,6 +502,92 @@ entirely) should produce no output and `exit=0`. every Claude Code upgrade — same cadence as the [Verification](#verification) section below. +## 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: + +- ` [sandbox]` in green when the active `settings.json` + sets `"sandbox": { "enabled": true }`, OR +- ` [NO SANDBOX]` in bold red when it does not. + +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. + +**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`): `Sonnet 4.6 [sandbox]` with `[sandbox]` +rendered in green. From a directory whose `.claude/settings.json` +does **not** enable the sandbox (or does not exist) and whose +`~/.claude/settings.json` likewise does not set +`sandbox.enabled: true`, 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) or + in-session permission-mode changes that override the file — + those still display as `[sandbox]` even though the running + session is unprotected. 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 settings.json + 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 — @@ -524,7 +611,7 @@ paths). Track the artifacts you want shared, symlink them into | Track in the synced repo | Keep per-machine | |---|---| | `CLAUDE.md` (personal collaboration prefs) | `~/.claude/.credentials.json` — ⚠ secret, never commit | -| `scripts/sandbox-bypass-warn.sh` and any other hooks | `~/.claude/sessions/`, `~/.claude/history.jsonl` — session state | +| `scripts/sandbox-bypass-warn.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/` — per-project memory and tasks | | Custom slash commands (`commands/.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 | @@ -547,7 +634,8 @@ 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-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 @@ -568,10 +656,12 @@ mkdir -p ~/.claude mv ~/.claude/CLAUDE.md ~/.claude/CLAUDE.md.bak ln -sf ~/.claude-config/CLAUDE.md ~/.claude/CLAUDE.md -# Sandbox-bypass warning hook +# 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 @@ -657,14 +747,17 @@ the secure setup into your tracker's working tree: `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). - It applies to every Claude Code session on the host (not only - tracker sessions), so it belongs in your user-scope + [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 script, your personal `CLAUDE.md`, an optional - global `claude-iso.sh`) in a private dotfile-style repo per + 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). ## Verification diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md index d0afba73..34b7f10e 100644 --- a/tools/agent-isolation/README.md +++ b/tools/agent-isolation/README.md @@ -29,6 +29,7 @@ versions. | [`check-tool-updates.sh`](check-tool-updates.sh) | Reads the manifest and reports upstream releases that are newer than the pin AND have themselves aged past the 7-day cooldown. Side-effect-free — no installs, no edits, no PRs. | | [`claude-iso.sh`](claude-iso.sh) | Shell function to launch Claude Code with `env -i` and a tiny passthrough list, stripping every credential-shaped environment variable from the parent shell. The framework's "layer 0" of the secure setup. | | [`sandbox-bypass-warn.sh`](sandbox-bypass-warn.sh) | Claude Code `PreToolUse` hook (Bash matcher). Prints a bold-red banner to stderr whenever the model invokes the Bash tool with `dangerouslyDisableSandbox: true`. Belt-and-braces visibility for the sandbox-bypass permission prompt. Recommended user-scope (`~/.claude/settings.json`) so it fires across every session on the host. | +| [`sandbox-status-line.sh`](sandbox-status-line.sh) | Claude Code `statusLine` helper. Renders ` [sandbox]` (green) or ` [NO SANDBOX]` (bold red) based on `sandbox.enabled` in the active `settings.json`, so the terminal footer always shows whether the current session is sandboxed. Recommended user-scope. | ## Usage at a glance diff --git a/tools/agent-isolation/sandbox-status-line.sh b/tools/agent-isolation/sandbox-status-line.sh new file mode 100755 index 00000000..e3bc5146 --- /dev/null +++ b/tools/agent-isolation/sandbox-status-line.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# sandbox-status-line.sh — Claude Code statusLine helper. +# +# Renders one line for the terminal footer that always shows whether +# Claude Code's filesystem/network sandbox is active in the current +# session. Designed to make a session that is *not* sandboxed +# obvious at a glance, so it cannot drift unnoticed for hours. +# +# Output: +# - " [sandbox]" — green, when the active settings.json +# sets `"sandbox": { "enabled": true }`. +# - " [NO SANDBOX]" — bold red, otherwise. +# +# Lookup order for `sandbox.enabled` (project-scope wins): +# 1. /.claude/settings.json +# 2. ~/.claude/settings.json +# Anything not found in either file is treated as "not enabled". +# +# Caveat — the script reads settings *files*, not the live runtime +# state. CLI flags (`--bypass-permissions`, `--no-sandbox`) and +# in-session permission-mode changes that override the file are not +# visible here, and will still show as `[sandbox]`. Pair with the +# sibling `sandbox-bypass-warn.sh` PreToolUse hook for a per-call +# signal. +# +# Wiring (user-scope, applies to every session on the host): +# +# { +# "statusLine": { +# "type": "command", +# "command": "~/.claude/scripts/sandbox-status-line.sh" +# } +# } +# +# See `secure-agent-setup.md` → "Sandbox-state status line" for +# install steps and the trade-off discussion. + +set -u + +input=$(cat) + +model=$(printf '%s' "$input" | jq -r '.model.display_name // .model.id // ""' 2>/dev/null || true) +cwd=$(printf '%s' "$input" | jq -r '.workspace.current_dir // ""' 2>/dev/null || true) + +sandbox="" +for f in "${cwd:+$cwd/.claude/settings.json}" "$HOME/.claude/settings.json"; do + [ -n "$f" ] && [ -f "$f" ] || continue + v=$(jq -r '.sandbox.enabled // empty' "$f" 2>/dev/null || true) + if [ -n "$v" ]; then + sandbox="$v" + break + fi +done + +esc=$(printf '\033') +green="${esc}[1;32m" +red="${esc}[1;31m" +reset="${esc}[0m" + +if [ "$sandbox" = "true" ]; then + tag="${green}[sandbox]${reset}" +else + tag="${red}[NO SANDBOX]${reset}" +fi + +if [ -n "$model" ]; then + printf '%s %s\n' "$model" "$tag" +else + printf '%s\n' "$tag" +fi