This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new e75be92 fix(#197): add project root + worktrees to sandbox
allowRead/Write (#198)
e75be92 is described below
commit e75be92af5ce670062fe39d1a897d2af66f55722
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun May 17 20:01:03 2026 +0200
fix(#197): add project root + worktrees to sandbox allowRead/Write (#198)
The harness pre-resolves `sandbox.filesystem.allowRead: ["."]` at
session start and silently drops the literal, so reads under CWD
fall through to denyRead: ["~/"] and fail under the sandbox even
though writes work. Defensive fix: add the project root as an
explicit absolute path to both allowRead and allowWrite in the
adopter's project-local <repo>/.claude/settings.local.json
(gitignored, per-machine). Worktrees inherit access automatically
— each worktree carries its own settings.local.json entry,
populated for pre-existing worktrees at adopt/upgrade time and for
newly-added worktrees via the post-checkout git hook.
Why project-local, and is it safe? User-scope ~/.claude/settings.json
is sandbox-write-protected but pollutes every Claude Code session
on the host with every adopter project's path. Committed
project-scope leaks machine-specific paths into the repo. Project-
local settings.local.json is per-machine, per-project, never
committed, and sandbox-write-protected — verified empirically:
`echo >> .claude/settings.local.json` from a sandboxed Bash returns
"operation not permitted" because Claude Code's runtime adds
.claude/settings.{json,local.json} and .claude/skills/ to the
sandbox's denyWithinAllow set at the bubblewrap/Seatbelt syscall
level (not user-configurable, owned by the harness).
The harness's protection is for Bash subprocesses; the agent's
Edit/Write/MultiEdit tools bypass it. This PR closes that bypass
via per-tool permissions.deny rules in the committed
.claude/settings.json on both settings files.
The same protection blocks the framework's own helper when invoked
from inside a sandboxed agent session. /setup-steward adopt,
upgrade, and worktree-init therefore invoke the helper with
dangerouslyDisableSandbox: true after proposing the bypass to the
operator; sandbox-bypass-warn.sh fires its bold-red banner as a
backstop. The post-checkout hook fired from a user-terminal git
checkout / git worktree add writes without bypass because the user
shell is not sandboxed. All three write paths are auditable.
What landed:
- tools/agent-isolation/sandbox-add-project-root.sh: new helper.
With --all-worktrees, writes each worktree's path into that
worktree's own .claude/settings.local.json. Refuses to write
when the target file is not gitignored (git check-ignore guard).
Creates the file if missing; atomic via jq → tmp → mv;
idempotent.
- setup-isolated-setup-install Step P: installs the script under
~/.claude/scripts/ and runs it once with --all-worktrees.
Documents the sandbox-bypass requirement for agent-session
invocations.
- setup-isolated-setup-verify Check 8: live sandboxed read+write
probe of the project root, plus a static cross-check that the
abs path is in the current worktree's settings.local.json.
- /setup-steward adopt: Step 7 gitignore template gains
/.claude/settings.local.json. Step 10 post-checkout hook
extends to chain into the helper. Step 12 grows pass 3 that
runs the helper with --all-worktrees under sandbox bypass.
- /setup-steward upgrade Step 6c: helper runs with --all-worktrees
under sandbox bypass after the worktree-init loop.
- /setup-steward worktree-init Step 1c: helper runs for the
current worktree under sandbox bypass.
- /setup-steward verify: Check 4 requires
/.claude/settings.local.json in .gitignore. Check 8b: static
cross-check that the current worktree's abs path is in its own
settings.local.json.
- .claude/settings.json permissions.deny: gains
Edit/Write/MultiEdit(.claude/settings.{json,local.json}) to
close the agent-Edit-tool bypass. tools/sandbox-lint/expected.json
baseline updated to match.
- docs/setup/secure-agent-setup.md: new "Project-root coverage in
the sandbox allowlists" section with harness-behaviour
explanation, scope-choice table, empirically-grounded security
rationale, helper surface, four invocation points.
- tools/agent-isolation/README.md: table row for the new helper.
Closes #197.
Generated-by: Claude Code (Opus 4.7)
---
.claude/settings.json | 6 +
.../skills/setup-isolated-setup-install/SKILL.md | 74 ++++++
.../skills/setup-isolated-setup-verify/SKILL.md | 49 +++-
.claude/skills/setup-steward/SKILL.md | 2 +-
.claude/skills/setup-steward/adopt.md | 122 ++++++++-
.claude/skills/setup-steward/upgrade.md | 40 +++
.claude/skills/setup-steward/verify.md | 71 ++++-
.claude/skills/setup-steward/worktree-init.md | 44 ++++
docs/setup/secure-agent-setup.md | 226 ++++++++++++++++
tools/agent-isolation/README.md | 1 +
tools/agent-isolation/sandbox-add-project-root.sh | 291 +++++++++++++++++++++
tools/sandbox-lint/expected.json | 6 +
12 files changed, 919 insertions(+), 13 deletions(-)
diff --git a/.claude/settings.json b/.claude/settings.json
index aa1e45b..9ce1e85 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -54,6 +54,12 @@
"Read(//**/.env)",
"Read(//**/.env.local)",
"Read(//**/.env.*.local)",
+ "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)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(aws *)",
diff --git a/.claude/skills/setup-isolated-setup-install/SKILL.md
b/.claude/skills/setup-isolated-setup-install/SKILL.md
index b5667f4..2d3a836 100644
--- a/.claude/skills/setup-isolated-setup-install/SKILL.md
+++ b/.claude/skills/setup-isolated-setup-install/SKILL.md
@@ -141,6 +141,80 @@ For the verification step at the end, hand off to the
`setup-isolated-setup-verify` skill rather than re-walking the checklist
inline.
+### Step P — Project-root coverage in the sandbox allowlists
+
+Per
+[`docs/setup/secure-agent-setup.md` → *Project-root coverage in the sandbox
allowlists*](../../../docs/setup/secure-agent-setup.md#project-root-coverage-in-the-sandbox-allowlists)
+and [issue #197](https://github.com/apache/airflow-steward/issues/197),
+the harness pre-resolves `sandbox.filesystem.allowRead: ["."]` at
+session start in a way that silently drops the literal `.` from the
+resolved set, so a session in a freshly-cloned adopter repo can
+write to CWD but cannot **read** from it under the sandbox.
+
+The defensive measure is to add the project root as an explicit
+**absolute path** to `sandbox.filesystem.allowRead` and `allowWrite`
+in the adopter's **project-local** settings file
+(`<repo>/.claude/settings.local.json`) — gitignored, per-machine,
+per-project, merged on top of the committed project-scope and
+user-scope by the harness. Worktrees handle themselves: each
+worktree has its own `<worktree>/.claude/settings.local.json`,
+and each gets its own root added.
+
+Two install sub-steps cover this:
+
+1. **Install the helper script.** Copy
+ `tools/agent-isolation/sandbox-add-project-root.sh` into
+ `~/.claude/scripts/sandbox-add-project-root.sh` (or symlink it
+ from `~/.claude-config/scripts/` if the operator uses the
+ private sync repo), mode `0755`. The script file lives
+ user-scope so a single install covers every adopter project on
+ the host; what it **writes** is project-local. The same install
+ mechanism the `sandbox-bypass-warn.sh` and `sandbox-status-line.sh`
+ helpers use (see the *Sandbox-bypass visibility hook* and
+ *Sandbox-state status line* sections of the doc).
+2. **Run the helper once with `--all-worktrees`** in the adopter
+ repo's main checkout. The helper enumerates
+ `git worktree list --porcelain` and, for each worktree, writes
+ that worktree's absolute path into that worktree's own
+ `<worktree>/.claude/settings.local.json` (creating the file if
+ it does not yet exist). Idempotent, atomic, tolerant of missing
+ prereqs (see the script's header comment for the full
+ failure-mode list). On success, surface the diff so the operator
+ sees which entries landed; on no-op (paths already present),
+ surface a one-line "already covered" confirmation.
+
+ **Sandbox-bypass requirement when invoked from inside an agent
+ session.** `.claude/settings.local.json` is in Claude Code's
+ built-in sandbox `denyWithinAllow` set (verified empirically —
+ see
+ [`docs/setup/secure-agent-setup.md` → *Security
rationale*](../../../docs/setup/secure-agent-setup.md#security-rationale--why-project-local-is-safe-to-write-to)),
+ so the helper's Bash write is blocked when invoked through the
+ agent's `Bash` tool. If this skill is being walked from inside
+ a sandboxed session, invoke the helper with
+ `dangerouslyDisableSandbox: true` and the reason
+ *"writing project-local sandbox-allowlist entries (issue #197 fix)"*.
+ The bypass triggers `sandbox-bypass-warn.sh`'s loud-red banner
+ so the operator sees and approves the single write. When the
+ operator runs `setup-isolated-setup-install` directly from a
+ terminal (the typical first-time-install path), no bypass is
+ needed — the script runs outside the agent sandbox.
+
+The `.` entry stays in the committed project-scope `allowRead`
+regardless — the explicit absolute path in
+`settings.local.json` is belt-and-braces, not a replacement. If
+the harness ever stops resolving `.`, the explicit path still
+covers the project; if `.` works correctly, the explicit entry is
+redundant but harmless. The committed project-scope file is
+**never** modified by the helper (machine-specific absolute paths
+have no business in a file shared across contributors).
+
+The helper is also invoked by `/setup-steward adopt`,
+`/setup-steward upgrade`, and `/setup-steward worktree-init` for
+the same reason. The `post-checkout` git hook installed by
+`/setup-steward adopt` chains into the helper too, so new
+worktrees added via `git worktree add` after this install pass
+inherit access automatically — no operator action needed.
+
## After the install lands
Suggest two follow-up routines the user can wire later:
diff --git a/.claude/skills/setup-isolated-setup-verify/SKILL.md
b/.claude/skills/setup-isolated-setup-verify/SKILL.md
index ef9826b..1404d32 100644
--- a/.claude/skills/setup-isolated-setup-verify/SKILL.md
+++ b/.claude/skills/setup-isolated-setup-verify/SKILL.md
@@ -95,7 +95,7 @@ Drift severity:
path, the version string, the command output, the
`sandbox.enabled` value — never just "✓" or "✗" alone.
-## The 7 checks
+## The 8 checks
The canonical list lives in
[docs/setup/secure-agent-setup.md → Verification → Via a Claude Code
prompt](../../../docs/setup/secure-agent-setup.md#via-a-claude-code-prompt-1).
@@ -144,6 +144,47 @@ Walk each in order:
permission-prompt layer
(`Permission to use Bash with command curl … has been denied`).
+8. **Project-root coverage in the sandbox allowlists** (defensive
+ against the harness behaviour in
+ [issue #197](https://github.com/apache/airflow-steward/issues/197):
+ `allowRead: ["."]` does not in practice cover CWD because the
+ read side pre-resolves `.` at session start and drops the
+ literal). Two sub-checks:
+
+ - **Static:** for the current working tree, confirm its
+ absolute path appears in both
+ `<worktree>/.claude/settings.local.json`'s
+ `sandbox.filesystem.allowRead` and
+ `sandbox.filesystem.allowWrite`. For every other linked
+ worktree in `git worktree list --porcelain`, run the same
+ check against *that* worktree's own
+ `.claude/settings.local.json` — each worktree carries its
+ own entry. Surface ✗ on any missing entry; remediation:
+ `~/.claude/scripts/sandbox-add-project-root.sh --all-worktrees`
+ (or re-run `/setup-isolated-setup-install` if the helper is
+ not installed).
+ - **Live probe:** attempt a sandboxed read of `.git/HEAD` and
+ a sandboxed write of a temp file inside the *current*
+ worktree's project root (e.g.
+ `<root>/.steward-verify-probe.tmp`, removed immediately
+ after the write). The write should succeed because
+ `allowWrite` keeps `.` literal at access-time; the read is
+ the one that actually exercises the harness bug this check
+ exists to defend against. ✗ on either failure; remediation
+ as above.
+
+ The check is cheap (read of a known file, write of a single
+ temp file) and the false-negative cost (a session that can't
+ read the project) is high, so it runs every time
+ `setup-isolated-setup-verify` is invoked — no flag needed to
+ opt in.
+
+ Note: this check looks at **project-local**
+ (`<worktree>/.claude/settings.local.json`), not user-scope.
+ The fix lives there deliberately — see
+ [`docs/setup/secure-agent-setup.md` → *Project-root coverage in the sandbox
allowlists*](../../../docs/setup/secure-agent-setup.md#project-root-coverage-in-the-sandbox-allowlists)
+ for why.
+
## After the report
If every check is ✓, say so explicitly and stop — no further
@@ -157,6 +198,12 @@ without invoking it:
- ⚠ on check 5 (pinned-version drift) or any user-scope script
copy that is older than the framework's source-of-truth →
`setup-isolated-setup-update`.
+- ✗ on check 8 (project root missing from the current
+ worktree's `.claude/settings.local.json`, or the live probe
+ fails) → if `~/.claude/scripts/sandbox-add-project-root.sh`
+ is installed, re-run it with `--all-worktrees`; otherwise
+ re-run `setup-isolated-setup-install` to install the helper
+ and add the paths in one pass.
- The user-scope script copies live under `~/.claude-config/`
for users who maintain that sync repo; uncommitted local edits
there → `setup-shared-config-sync`.
diff --git a/.claude/skills/setup-steward/SKILL.md
b/.claude/skills/setup-steward/SKILL.md
index b5b1ac9..a585f14 100644
--- a/.claude/skills/setup-steward/SKILL.md
+++ b/.claude/skills/setup-steward/SKILL.md
@@ -314,7 +314,7 @@ symlinks, and adds new always-on-family entries the upgrade
introduced). The user does not need to remember to `cd` into each
worktree and re-run anything; the main-checkout sub-action
propagates state outward to the worktrees by itself. See
-[`adopt.md` Step
12.2](adopt.md#step-12--post-install-sync--worktree-propagation--sanity-check)
+[`adopt.md` Step
12.2](adopt.md#step-12--post-install-sync--worktree-propagation--sandbox-allowlist--sanity-check)
and
[`upgrade.md` Step
6c](upgrade.md#step-6c--propagate-to-every-worktree-run-worktree-init-unconditionally).
diff --git a/.claude/skills/setup-steward/adopt.md
b/.claude/skills/setup-steward/adopt.md
index f7a3183..05e1067 100644
--- a/.claude/skills/setup-steward/adopt.md
+++ b/.claude/skills/setup-steward/adopt.md
@@ -280,6 +280,7 @@ idempotent — re-add them if they're missing.
```text
/.apache-steward/
/.apache-steward.local.lock
+/.claude/settings.local.json
/.claude/skills/security-*
/.claude/skills/pr-management-*
/.claude/skills/setup-isolated-setup-*
@@ -302,6 +303,14 @@ gitignored on every adopter regardless of the opt-in
family pick. `setup-steward` itself is **not** gitignored —
it is the one committed framework skill.
+`.claude/settings.local.json` is the project-local
+per-machine settings file that
+[Step 12 pass
3](#step-12--post-install-sync--worktree-propagation--sandbox-allowlist--sanity-check)
+populates with the project-root sandbox-allowlist entry (and
+that each worktree carries independently). Most adopters
+already gitignore this file by Claude Code convention; the
+adopt flow checks for the line and adds it if missing.
+
Mirror under `.github/skills/` only if the adopter uses the
double-symlinked convention.
@@ -532,12 +541,46 @@ collected values substituted in (leaving any unanswered
field as
## Step 10 — Worktree-aware post-checkout hook (FRESH only)
-Install
-`<repo-root>/.git/hooks/post-checkout` that re-creates the
-gitignored symlinks if a fresh worktree is checked out. The
-hook is a one-liner that re-invokes
-`/setup-steward verify --auto-fix-symlinks`. Surface the
-hook content to the user before writing.
+Install `<repo-root>/.git/hooks/post-checkout` that re-creates
+the gitignored symlinks if a fresh worktree is checked out
+**and** chains into the sandbox-allowlist helper installed by
+`setup-isolated-setup-install` so the new worktree's working
+directory is added to the worktree's own
+`.claude/settings.local.json`'s `sandbox.filesystem.allowRead` /
+`allowWrite` (defensive against
+[issue #197](https://github.com/apache/airflow-steward/issues/197)
+— see
+[`setup-isolated-setup-install/SKILL.md` → Step
P](../setup-isolated-setup-install/SKILL.md#step-p--project-root-coverage-in-the-sandbox-allowlists)).
+
+The hook is a small shell script, not a one-liner. Surface the
+exact content to the user before writing:
+
+```bash
+#!/usr/bin/env bash
+# apache-steward post-checkout hook (installed by /setup-steward adopt).
+# Two responsibilities, each idempotent:
+# 1. Reconcile gitignored framework-skill symlinks for the
+# current worktree.
+# 2. Add the current worktree's working dir to the worktree's
+# own .claude/settings.local.json's sandbox allowlists
+# (per issue #197) — chains into the helper if installed by
+# /setup-isolated-setup-install, no-op when absent.
+set -u
+/setup-steward verify --auto-fix-symlinks || true
+if [ -x "$HOME/.claude/scripts/sandbox-add-project-root.sh" ]; then
+ "$HOME/.claude/scripts/sandbox-add-project-root.sh" || true
+fi
+exit 0
+```
+
+The `|| true` guards keep the hook from failing the surrounding
+git operation (`git checkout`, `git worktree add`) — the hook is
+best-effort reconciliation, not a gate.
+
+If the operator has not yet run `/setup-isolated-setup-install`,
+the helper-script line is a no-op (the `-x` test fails). When
+they later install the secure setup, no hook re-write is needed:
+the next `post-checkout` fires the helper automatically.
## Step 11 — Project doc updates (FRESH only)
@@ -657,9 +700,9 @@ Surface the rendered diff (`git diff README.md AGENTS.md`)
to the user before writing. The user confirms once for the
whole doc set; do not ask separately per file.
-## Step 12 — Post-install sync + worktree propagation + sanity check
+## Step 12 — Post-install sync + worktree propagation + sandbox-allowlist +
sanity check
-Three passes, in this order:
+Four passes, in this order:
1. **Sync hooks and config from the snapshot.** Walk every
hook or config file the framework ships that an adopter
@@ -716,13 +759,72 @@ Three passes, in this order:
worktree-init` after merging the adoption commit
forward).
-3. **Run the verify checklist.** Invoke
+3. **Add the adopter's project root to each worktree's
+ project-local sandbox allowlists.** Defensive against
+ [issue #197](https://github.com/apache/airflow-steward/issues/197) —
+ `sandbox.filesystem.allowRead: ["."]` does not in practice
+ cover CWD, so reads under a freshly-cloned adopter repo
+ fail under the sandbox until an explicit absolute path is
+ added. Invoke the helper **with sandbox bypass** (the
+ target file is in Claude Code's built-in sandbox
+ `denyWithinAllow` set, so the Bash write is blocked without
+ it — see
+ [`docs/setup/secure-agent-setup.md` → *Security
rationale*](../../../docs/setup/secure-agent-setup.md#security-rationale--why-project-local-is-safe-to-write-to)):
+
+ ```bash
+ ~/.claude/scripts/sandbox-add-project-root.sh --all-worktrees
+ ```
+
+ Set `dangerouslyDisableSandbox: true` on the Bash call with
+ the reason *"writing project-local sandbox-allowlist entries
+ (issue #197 fix)"*. Surface the bypass proposal to the
+ operator **before** invoking — name the helper, name the
+ target file (`.claude/settings.local.json` of each
+ worktree), and confirm. The bypass triggers
+ `sandbox-bypass-warn.sh`'s bold-red banner as a backstop, but
+ the agent must propose first; do not silently approve.
+
+ The helper enumerates `git worktree list --porcelain` and,
+ for each worktree, writes that worktree's own absolute path
+ into that worktree's own
+ `<worktree>/.claude/settings.local.json` (gitignored,
+ per-machine, per-worktree). It does **not** write to
+ user-scope or to the committed project-scope; see
+ [`setup-isolated-setup-install/SKILL.md` → Step
P](../setup-isolated-setup-install/SKILL.md#step-p--project-root-coverage-in-the-sandbox-allowlists)
+ for the scope rationale. Idempotent — already-present paths
+ are skipped.
+
+ Failure modes:
+
+ - **Helper absent** (`~/.claude/scripts/sandbox-add-project-root.sh`
+ does not exist) → surface as ⚠ in the adopt summary with a
+ pointer at `/setup-isolated-setup-install`. Do not block
+ adopt — many adopters set up secure-agent isolation later,
+ and the framework-skill symlinks are usable without it (the
+ adopter just runs Bash outside the sandbox until they wire
+ in the secure setup).
+ - **Helper present, exits non-zero** → surface as ✗ with the
+ helper's stderr output, but continue with pass 4 and report
+ the gap in the summary.
+ - **Helper succeeds, no paths added** (everything already
+ covered) → surface as ✓ "sandbox allowlist already covers
+ this project + N worktrees".
+
+ This pass is the same as
+ [`upgrade.md` Step
6c](upgrade.md#step-6c--propagate-to-every-worktree-run-worktree-init-unconditionally)'s
+ trailing helper-invocation step — both rely on `worktree-init`
+ having run first (pass 2 above) so the worktree list is the
+ one to feed the helper.
+
+4. **Run the verify checklist.** Invoke
[`verify.md`](verify.md)'s checks. Every check should be
✓ before the skill reports success. The hook-content
drift check passes trivially because pass (1) just
refreshed the hook from the snapshot; the worktree
symlink checks pass trivially because pass (2) just
- ran `worktree-init` everywhere.
+ ran `worktree-init` everywhere; the sandbox-allowlist
+ check passes trivially because pass (3) just ran the
+ helper.
## Output to the user
diff --git a/.claude/skills/setup-steward/upgrade.md
b/.claude/skills/setup-steward/upgrade.md
index db22206..82de293 100644
--- a/.claude/skills/setup-steward/upgrade.md
+++ b/.claude/skills/setup-steward/upgrade.md
@@ -374,6 +374,41 @@ failed — the main is already upgraded and the other
worktrees still benefit from the propagation. The summary
makes the skipped worktrees easy to come back to.
+**After the per-worktree loop**, run the
+sandbox-allowlist helper once with `--all-worktrees` to
+ensure each worktree's project root is in that worktree's
+own `.claude/settings.local.json` (defensive against
+[issue #197](https://github.com/apache/airflow-steward/issues/197);
+see
+[`setup-isolated-setup-install/SKILL.md` → Step
P](../setup-isolated-setup-install/SKILL.md#step-p--project-root-coverage-in-the-sandbox-allowlists)):
+
+```bash
+~/.claude/scripts/sandbox-add-project-root.sh --all-worktrees
+```
+
+**Invoke with `dangerouslyDisableSandbox: true`** — the
+target settings files are in Claude Code's built-in sandbox
+`denyWithinAllow` set, so a sandboxed Bash write fails with
+`operation not permitted`. Surface the bypass proposal to
+the operator *before* invoking — name the helper, name the
+target files, and confirm. The reason for the bypass is
+*"writing project-local sandbox-allowlist entries (issue
+#197 fix)"*. The bypass fires `sandbox-bypass-warn.sh`'s
+bold-red banner as a backstop, but the agent must propose
+the bypass first; do not silently approve.
+
+The helper enumerates `git worktree list --porcelain` and
+writes each worktree's path into that worktree's own
+project-local `settings.local.json` (gitignored, never the
+committed project-scope file). Idempotent — already-present
+paths are skipped. If
+`~/.claude/scripts/sandbox-add-project-root.sh` is absent,
+surface as ⚠ in the upgrade summary with a pointer at
+`/setup-isolated-setup-install` and continue (do not block
+upgrade — secure-agent setup is independent of framework
+upgrade). The recap row in Step 8's output goes under a new
+`Sandbox allowlist:` section.
+
## Step 7 — Update `<local-lock>`
Write the new local lock with the values captured in Step
@@ -426,6 +461,11 @@ Worktrees (worktree-init was run on each, idempotently):
⚠ <worktree-path> (skipped — branch missing adopter's setup-steward)
- <none> (when this repo has no linked worktrees)
+Sandbox allowlist (sandbox-add-project-root.sh --all-worktrees):
+ ✓ already covers this project + N worktrees OR
+ + <list of <worktree>/.claude/settings.local.json files updated> OR
+ ⚠ helper not installed — run /setup-isolated-setup-install
+
Overrides:
✓ <list of overrides whose target is unchanged>
⚠ <list of overrides flagged for re-anchoring> (open the
diff --git a/.claude/skills/setup-steward/verify.md
b/.claude/skills/setup-steward/verify.md
index 6025dfc..7b56603 100644
--- a/.claude/skills/setup-steward/verify.md
+++ b/.claude/skills/setup-steward/verify.md
@@ -96,7 +96,7 @@ Compare:
| Ref differs (e.g. project bumped tag, or `git-branch` local is behind
upstream) | ⚠ — sync needed; remediation: `/setup-steward upgrade` |
| `svn-zip` SHA-512 differs from the verification anchor in `<committed-lock>`
| ✗ — security-flagged; the released zip changed content; investigate before
upgrading |
-### 4. `.gitignore` correctly excludes the snapshot + local lock + symlinks
+### 4. `.gitignore` correctly excludes the snapshot + local lock + symlinks +
project-local settings
Check that the entries from
[`adopt.md` Step 7](adopt.md) are present in
@@ -104,6 +104,13 @@ Check that the entries from
- `/.apache-steward/` (snapshot path)
- `/.apache-steward.local.lock` (per-machine state)
+- `/.claude/settings.local.json` (per-machine project-scope
+ settings — written to by
+
[`sandbox-add-project-root.sh`](../../../tools/agent-isolation/sandbox-add-project-root.sh)
+ as the per-worktree sandbox-allowlist defense for
+ [issue #197](https://github.com/apache/airflow-steward/issues/197);
+ must never be committed since the content is machine-specific
+ absolute paths)
Recommended:
@@ -116,6 +123,12 @@ Recommended:
is at risk of being accidentally committed.
- ✗ if `/.apache-steward.local.lock` is not gitignored —
per-machine state would leak into the repo.
+- ✗ if `/.claude/settings.local.json` is not gitignored —
+ per-machine absolute paths would leak into the repo; the
+ sandbox-allowlist helper refuses to write to a non-ignored
+ target as defense in depth, but `verify` surfaces the
+ underlying `.gitignore` gap so the operator fixes the root
+ cause.
- ⚠ if symlink patterns are not gitignored.
### 5. Symlinks point at live framework skills
@@ -224,6 +237,62 @@ Two sub-checks on `<repo-root>/.git/hooks/post-checkout`:
remediation, no operator prompt needed; the sync
pass overwrites silently.
+### 8b. Sandbox-allowlist coverage of the current worktree
+
+Defensive cross-check for
+[issue #197](https://github.com/apache/airflow-steward/issues/197):
+`sandbox.filesystem.allowRead: ["."]` does not in practice cover
+CWD under the harness, so `/setup-steward` (adopt, upgrade,
+worktree-init) chains into
+`~/.claude/scripts/sandbox-add-project-root.sh` to add explicit
+absolute paths to each worktree's own project-local settings.
+This check verifies that chain landed for the *current* worktree.
+
+For the current worktree (resolved via
+`git rev-parse --show-toplevel`):
+
+- ✓ if the absolute path appears in **both**
+ `<worktree>/.claude/settings.local.json`'s
+ `sandbox.filesystem.allowRead` and `sandbox.filesystem.allowWrite`.
+- ✗ if missing from either array, **and** the helper script
+ `~/.claude/scripts/sandbox-add-project-root.sh` is installed
+ — remediation:
+ `~/.claude/scripts/sandbox-add-project-root.sh`
+ (no `--all-worktrees` needed — just this worktree), or
+ re-run `/setup-steward` (adopt/upgrade) which chains into
+ the helper as part of its Step 12 / Step 6c sandbox-allowlist
+ pass.
+- ⚠ if missing from either array **and** the helper script is
+ absent — the operator has not run
+ `/setup-isolated-setup-install` yet. Suggest that skill.
+ Not ✗ because secure-agent isolation is independent of
+ framework adoption, and an adopter who runs without the
+ sandbox enabled has nothing to lose by the missing entry.
+- ⚠ if `<worktree>/.claude/settings.local.json` is absent
+ entirely — same remediation (re-run the helper or
+ `/setup-isolated-setup-install`). The file is auto-created
+ by the helper on first run.
+- ✗ if `<worktree>/.claude/settings.local.json` exists AND
+ is **not** gitignored (cross-check via `git check-ignore`).
+ Per the security rationale in
+ [`docs/setup/secure-agent-setup.md` → *Security rationale — why
project-local is safe to write
to*](../../../docs/setup/secure-agent-setup.md#security-rationale--why-project-local-is-safe-to-write-to),
+ the per-machine settings.local.json must never be committed.
+ Remediation: add `/.claude/settings.local.json` to the
+ adopter's `.gitignore` (also surfaced by check 4 above).
+
+The check scopes to the current worktree only, not the full
+`git worktree list`, because each worktree carries its own
+project-local settings file — `/setup-steward verify` running
+in worktree A has no business asserting on worktree B's file
+(which it cannot even reliably read without crossing into
+another working tree's path).
+
+This check is read-only on the framework state. The defence
+is layered: `/setup-steward` writes during adopt/upgrade,
+`setup-isolated-setup-verify` adds a live read+write probe
+(check 8 there), and this check is the cheap static cross-check
+to surface drift between the two skill families.
+
### 9. Project documentation mentions the framework
Two files to check (per
diff --git a/.claude/skills/setup-steward/worktree-init.md
b/.claude/skills/setup-steward/worktree-init.md
index 6d9a71f..2125abb 100644
--- a/.claude/skills/setup-steward/worktree-init.md
+++ b/.claude/skills/setup-steward/worktree-init.md
@@ -113,6 +113,50 @@ sanity check as Step 1's bottom bullet, just now end-to-end
from agent-harness path through the worktree's symlink
through the snapshot symlink to the framework source.
+## Step 1c — Add the worktree to its own project-local sandbox allowlists
+
+Defensive against
+[issue #197](https://github.com/apache/airflow-steward/issues/197) —
+`sandbox.filesystem.allowRead: ["."]` does not in practice cover
+the worktree's working dir, so reads under this worktree fail
+under the sandbox until an explicit absolute path is added. See
+[`setup-isolated-setup-install/SKILL.md` → Step
P](../setup-isolated-setup-install/SKILL.md#step-p--project-root-coverage-in-the-sandbox-allowlists)
+for the underlying rationale.
+
+If `~/.claude/scripts/sandbox-add-project-root.sh` is installed,
+invoke it from the worktree's working directory (no
+`--all-worktrees` flag — only this one worktree needs adding;
+the helper picks up the current worktree's
+`git rev-parse --show-toplevel` and writes the abs path to
+`<this-worktree>/.claude/settings.local.json`):
+
+```bash
+"$HOME/.claude/scripts/sandbox-add-project-root.sh"
+```
+
+**Invoke with `dangerouslyDisableSandbox: true`** — the target
+`settings.local.json` is in Claude Code's built-in sandbox
+`denyWithinAllow` set, so a sandboxed Bash write fails with
+`operation not permitted`. Surface the bypass proposal to the
+operator *before* invoking; name the helper and the target file
+(`<worktree>/.claude/settings.local.json`); confirm. Reason:
+*"writing project-local sandbox-allowlist entry (issue #197
+fix)"*.
+
+The helper writes to **project-local** scope, not user-scope —
+each worktree carries its own `.claude/settings.local.json`
+entry, and the per-worktree file is gitignored. The helper is
+idempotent (no-op when already added) and exits 0 when
+prereqs are missing (no `jq`, not in a git repo). Surface a
+one-line recap row for the Step 2 summary:
+
+- ✓ already covered, OR
+- + added `<worktree-path>`, OR
+- ⚠ helper not installed — `/setup-isolated-setup-install` to wire it up.
+
+`worktree-init` does **not** fail when the helper is absent;
+secure-agent isolation is independent of framework adoption.
+
## Step 2 — Recap
Print a short summary:
diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index 85066f3..4d5c8a2 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -12,6 +12,11 @@
- [Bumping a pinned version](#bumping-a-pinned-version)
- [Wiring the check script into a weekly
routine](#wiring-the-check-script-into-a-weekly-routine)
- [The framework's own
`.claude/settings.json`](#the-frameworks-own-claudesettingsjson)
+ - [Project-root coverage in the sandbox
allowlists](#project-root-coverage-in-the-sandbox-allowlists)
+ - [Why project-local, not user-scope and not
committed-project](#why-project-local-not-user-scope-and-not-committed-project)
+ - [Security rationale — why project-local is safe to write
to](#security-rationale--why-project-local-is-safe-to-write-to)
+ - [`sandbox-add-project-root.sh`](#sandbox-add-project-rootsh)
+ - [When the helper runs](#when-the-helper-runs)
- [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)
@@ -395,6 +400,227 @@ agent should never *see* it.
`sandbox.filesystem.allowRead` permits
the bash subprocess to read the file; `permissions.deny[Read(...)]`
blocks the agent's Read tool from reading the same path.
+## Project-root coverage in the sandbox allowlists
+
+The `.` entry in `sandbox.filesystem.allowRead` is **intended** to
+mean "the session's current working directory, resolved at
+access-time" — exactly the same semantics `allowWrite: ["."]` has.
+In practice the two sides diverge in the harness: `allowWrite`
+keeps `.` literal (resolved per access), while `allowRead`
+pre-resolves the path list at session start to absolute paths *and
+silently drops the literal `.`*. The consequence is that a session
+in a freshly-cloned adopter repo can **write** to CWD but cannot
+**read** from it under the sandbox — `git rev-parse --git-dir`
+fails with `Operation not permitted`, and `Read`-tool reads of
+files like `.apache-steward.lock` fail too. The full reproducer
+and harness-side analysis is in
+[issue #197](https://github.com/apache/airflow-steward/issues/197).
+
+The framework's defensive fix is to add the project root as an
+**explicit absolute path** to both `sandbox.filesystem.allowRead`
+and `sandbox.filesystem.allowWrite` in the adopter's **project-local**
+settings file — `<repo>/.claude/settings.local.json`. The `.`
+entry stays in the committed project-scope `settings.json` — the
+explicit absolute path in `settings.local.json` is belt-and-braces:
+
+- If the harness ever stops resolving `.` consistently, the
+ explicit absolute path still covers the project.
+- If `.` works correctly, the explicit entry is redundant but
+ harmless.
+
+### Why project-local, not user-scope and not committed-project
+
+Three scopes the harness merges, top to bottom:
+
+| Scope | File | Shared by | Suitable for the fix? |
+|---|---|---|---|
+| User | `~/.claude/settings.json` | every session on the host (every adopter
project, every tool) | **No** — pollutes user-scope with every adopter
project's abs path. |
+| Project (committed) | `<repo>/.claude/settings.json` | every contributor on
the project | **No** — machine-specific abs paths would leak into the repo. |
+| Project (local, gitignored) | `<repo>/.claude/settings.local.json` | this
machine, this checkout only | **Yes** — per-machine, per-project, never
committed. |
+
+Worktrees handle themselves: each worktree has its own working
+tree (and so its own `.claude/` directory and its own
+`.claude/settings.local.json`). The helper writes each worktree's
+absolute path into **that worktree's own** settings.local.json,
+not into a shared file. When a session starts in worktree A, the
+harness reads worktree A's settings.local.json and sees the
+explicit allow for worktree A's root — nothing more.
+
+The committed project-scope `settings.json` is **never** modified
+by the helper; the user-scope `settings.json` and
+`settings.local.json` are likewise never touched.
+
+### Security rationale — why project-local is safe to write to
+
+A reasonable question: *"the helper writes a config file that
+governs the sandbox itself. If the sandbox grants write access to
+the project tree, can a compromised agent rewrite that file and
+broaden the sandbox for the next session?"* The answer is no, but
+only because the protection comes from **Claude Code's built-in
+sandbox denylist**, not from anything the framework can configure.
+Walking the threat model:
+
+**1. Bash writes from inside the sandbox: blocked by the harness.**
+Claude Code's sandbox resolves the user's
+`sandbox.filesystem.allowWrite` against a hardcoded
+`denyWithinAllow` set that always includes
+`<repo>/.claude/settings.json`,
+`<repo>/.claude/settings.local.json`,
+`<repo>/.claude/skills/`, and the user-scope settings files. This
+is enforced at the bubblewrap (Linux) / Seatbelt (macOS) syscall
+level — the write fails with `Operation not permitted` regardless
+of what `allowWrite` says. Verify empirically with a single line:
+
+```bash
+echo "test" >> .claude/settings.local.json
+# zsh: operation not permitted: .claude/settings.local.json
+```
+
+There is no settings.json field that overrides this protection
+(no `denyWrite` user-config exists at the time of writing); the
+harness owns it. So a sandboxed Bash invocation, even one running
+attacker-chosen code, cannot mutate `.claude/settings.local.json`
+to broaden the next session's sandbox.
+
+**2. Edit / Write / MultiEdit agent tools bypass the sandbox.**
+These tools call into the harness directly, not through a Bash
+subprocess, so the sandbox's `denyWithinAllow` does not apply. The
+framework closes the bypass by adding the per-tool denies in the
+committed `.claude/settings.json`:
+
+```jsonc
+"deny": [
+ "Edit(.claude/settings.json)",
+ "Edit(.claude/settings.local.json)",
+ "Write(.claude/settings.json)",
+ "Write(.claude/settings.local.json)",
+ "MultiEdit(.claude/settings.json)",
+ "MultiEdit(.claude/settings.local.json)"
+]
+```
+
+A compromised agent that tries `Edit('.claude/settings.local.json', ...)`
+hits the deny rule and the call fails. The denies are committed at
+project scope, so every contributor inherits them; an adopter who
+follows the framework's settings template gets them automatically.
+
+**3. The framework's own helper also gets blocked from inside the sandbox.**
+The same `denyWithinAllow` that defends against attack also blocks
+[`sandbox-add-project-root.sh`](../../tools/agent-isolation/sandbox-add-project-root.sh)
+when it is invoked through the agent's `Bash` tool from inside a
+sandboxed session. Three legitimate-write paths remain, all
+auditable:
+
+- **User-terminal post-checkout hook.** `git worktree add` /
+ `git checkout` fired from the operator's shell triggers
+ `post-checkout`, which runs the helper in the *shell's* context —
+ outside the agent sandbox. Writes succeed normally.
+- **First-time install.** `setup-isolated-setup-install` is
+ typically run with the operator's awareness; its Step P
+ invocation of the helper happens in a context where the operator
+ is already approving setup actions.
+- **`dangerouslyDisableSandbox: true` from agent sessions.**
+ `/setup-steward adopt`, `upgrade`, and `worktree-init` invoke the
+ helper with explicit sandbox bypass. Every bypass triggers
+
[`sandbox-bypass-warn.sh`](../../tools/agent-isolation/sandbox-bypass-warn.sh)'s
+ bold-red banner naming the command, the reason, and the file
+ being touched; the operator approves per call. No silent writes.
+
+**4. No vector via commits.**
+`<repo>/.claude/settings.local.json` is gitignored — the adopt
+flow adds the line to `.gitignore`, and
+[`/setup-steward verify`](../../.claude/skills/setup-steward/verify.md)
+Check 4 surfaces ✗ if it is missing. The helper itself runs
+`git check-ignore` against the target file before writing and
+*refuses* to write when the file is not ignored (defense in depth
+against a stale `.gitignore`). A malicious contributor cannot ship
+sandbox-allowlist content via a PR.
+
+**5. No vector via the helper's inputs.**
+The helper takes paths exclusively from
+`git rev-parse --show-toplevel` and
+`git worktree list --porcelain` — both walk the operator's own
+local git state. The only paths added are working directories the
+operator has already created themselves with `git clone` /
+`git worktree add`. No command-line path argument; no
+environment-variable injection.
+
+**6. Cross-project isolation, as a bonus.**
+A session in project A reads
+`<A>/.claude/settings.local.json` and gets read+write access only
+to A. A session that `cd`s into project B mid-session keeps A's
+settings (loaded at session start), so it sees A's grants — never
+B's. The same fix at user-scope (`~/.claude/settings.json`) would
+have given every Claude Code session on the host read+write access
+to every adopter project the operator has ever set up; project-local
+scope confines the grant.
+
+**Net:** every write path to the file is either physically blocked
+or requires explicit per-call user approval. The harness's built-in
+sandbox protection is what makes this true — the framework cannot
+configure it, but it can verify and document it.
+
+### `sandbox-add-project-root.sh`
+
+The framework ships
+[`tools/agent-isolation/sandbox-add-project-root.sh`](../../tools/agent-isolation/sandbox-add-project-root.sh)
+to perform this addition idempotently. Installed during
+[`setup-isolated-setup-install`](../../.claude/skills/setup-isolated-setup-install/SKILL.md)
+into `~/.claude/scripts/sandbox-add-project-root.sh` (the
+*script file* lives user-scope so a single install covers every
+adopter project on the host; what it *writes* is project-local).
+The helper:
+
+- Resolves `git rev-parse --show-toplevel` in the current working
+ directory.
+- With `--all-worktrees`, also enumerates
+ `git worktree list --porcelain` and writes a separate entry
+ into **each worktree's** own `.claude/settings.local.json`.
+- Without the flag, writes only the current worktree's path
+ into the current worktree's `.claude/settings.local.json`.
+- Creates `.claude/settings.local.json` from scratch if missing
+ (with only the `sandbox.filesystem` block — nothing else is
+ touched).
+- Updates the file in place, atomically (`jq` → tmp → `mv`).
+- Skips any path already present in either array (idempotent).
+- Tolerant of missing prerequisites (no `jq`, not in a git repo,
+ invalid existing JSON) — warns on stderr and exits 0 so the
+ calling hook is never derailed by a half-installed setup.
+
+### When the helper runs
+
+The helper is invoked from four points in the framework's lifecycle:
+
+1. **At install** — `setup-isolated-setup-install` runs the
+ helper with `--all-worktrees` against the adopter repo the
+ operator is sitting in.
+2. **During adoption** — `/setup-steward adopt` Step 12 runs the
+ helper with `--all-worktrees` so a fresh adopter repo with
+ pre-existing worktrees has every working-tree path covered
+ without an extra round-trip through
+ `setup-isolated-setup-install`.
+3. **During upgrade** — `/setup-steward upgrade` Step 6c, after
+ the per-worktree `worktree-init` chain, runs the helper with
+ `--all-worktrees` so any worktree added since adopt has its
+ path written into its own settings.local.json.
+4. **Per worktree, on creation** — the `post-checkout` git hook
+ installed by `/setup-steward adopt` runs the helper *without*
+ `--all-worktrees`, picking up only the new worktree's path.
+ `git worktree add` fires `post-checkout` in the new working
+ tree, so every worktree added after adoption inherits sandbox
+ access automatically — landing its abs path in its own
+ `.claude/settings.local.json`.
+
+The verification surface:
+
+-
[`setup-isolated-setup-verify`](../../.claude/skills/setup-isolated-setup-verify/SKILL.md)
+ Check 8 — live sandboxed read+write probe of the project root,
+ plus the static cross-check that the abs path is in the current
+ worktree's `.claude/settings.local.json`.
+- [`/setup-steward verify`](../../.claude/skills/setup-steward/verify.md)
+ Check 8b — static cross-check that the current worktree's
+ abs path is in its own `.claude/settings.local.json`.
+
## The clean-env wrapper
Layer 0 — strip credential-shaped env vars from the parent shell
diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md
index 32f900b..2a7a70a 100644
--- a/tools/agent-isolation/README.md
+++ b/tools/agent-isolation/README.md
@@ -31,6 +31,7 @@ versions.
| [`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 `<model> [sandbox]` (green) or `<model> [NO
SANDBOX]` (bold red) based on `sandbox.enabled` in the active settings —
project `settings.local.json` first, then project `settings.json`, then
user-scope, mirroring Claude Code's own precedence. Reflects in-session
`/sandbox` toggles (which persist to project `settings.local.json`).
Recommended user-scope. |
| [`sandbox-status-line-rich.sh`](sandbox-status-line-rich.sh) | Opt-in richer
alternative to `sandbox-status-line.sh`. Same sandbox-state detection, plus
folder name (hash-coloured), git branch + dirty + ahead/behind, per-branch PR
title (cached, gated by `gh`), and a yellow `[sandbox-auto]` tag for the
`autoAllowBashIfSandboxed` setting. Wire one *or* the other into
`statusLine.command`. |
+| [`sandbox-add-project-root.sh`](sandbox-add-project-root.sh) | Adds the
current adopter repo's project root (and, with `--all-worktrees`, every linked
git worktree's working dir) as an explicit absolute path to
`sandbox.filesystem.allowRead` and `allowWrite` in the project-local,
gitignored `<repo>/.claude/settings.local.json` — one entry per worktree, each
in that worktree's own settings file. Defensive against [issue
#197](https://github.com/apache/airflow-steward/issues/197) — `allo [...]
## Usage at a glance
diff --git a/tools/agent-isolation/sandbox-add-project-root.sh
b/tools/agent-isolation/sandbox-add-project-root.sh
new file mode 100755
index 0000000..0a86126
--- /dev/null
+++ b/tools/agent-isolation/sandbox-add-project-root.sh
@@ -0,0 +1,291 @@
+#!/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-add-project-root.sh — add the project root to the
+# project-local Claude Code sandbox allowlists.
+#
+# Defensive fix for the harness behaviour described in
+# https://github.com/apache/airflow-steward/issues/197 :
+# `sandbox.filesystem.allowRead: ["."]` is *not* equivalent to
+# `allowWrite: ["."]` in the harness — the read side pre-resolves
+# `.` at session start to absolute paths and then drops the literal,
+# so reads under CWD fall through to `denyRead: ["~/"]` and fail.
+# The defensive measure is to add the project root as an explicit
+# absolute path to BOTH `allowRead` and `allowWrite` in the
+# project's gitignored `.claude/settings.local.json`. The `.` entry
+# stays in the project's committed `.claude/settings.json` —
+# the explicit absolute path is belt-and-braces.
+#
+# Scope: writes ONLY to project-local `<repo>/.claude/settings.local.json`,
+# never to user-scope (`~/.claude/settings.json`) and never to the
+# committed project-scope (`<repo>/.claude/settings.json`).
+# - User-scope is shared across every project on the host; per-adopter
+# paths there would pollute every session.
+# - Committed project-scope is shared across contributors; machine-
+# specific absolute paths there would leak into the repo.
+# - Project-local (`settings.local.json`) is gitignored by convention,
+# per-machine, per-project, and merged on top of the other two by
+# the harness. The right home for this fix.
+#
+# Sandbox interaction: the target file
+# `<repo>/.claude/settings.local.json` is in Claude Code's built-in
+# sandbox `denyWithinAllow` set — Bash writes to it from inside a
+# sandboxed session fail with `operation not permitted` (verified
+# empirically via `echo >> .claude/settings.local.json`). This is
+# a SECURITY FEATURE, not a bug: a compromised agent cannot mutate
+# the file to broaden its own sandbox. Practical consequence: this
+# script writes successfully only from one of:
+# 1. A user-terminal context (no Claude Code sandbox active) — e.g.
+# the post-checkout git hook fired by `git checkout` /
+# `git worktree add` run in the operator's shell.
+# 2. A `setup-isolated-setup-install` first-run flow that the
+# operator drives outside an agent session.
+# 3. An agent's `Bash` tool call with `dangerouslyDisableSandbox: true`
+# — `/setup-steward adopt`, `upgrade`, `worktree-init` invoke this
+# script that way, proposing the bypass to the operator first so
+# `sandbox-bypass-warn.sh`'s bold-red banner fires as a backstop.
+# If invoked from inside a sandboxed session *without* the bypass, jq's
+# `mv` fails and this script logs the failure to stderr but exits 0
+# — never derails the calling flow.
+#
+# Usage:
+# sandbox-add-project-root.sh # current worktree only
+# sandbox-add-project-root.sh --all-worktrees # main + every linked worktree
+# sandbox-add-project-root.sh --dry-run # print what would change, do
not write
+# sandbox-add-project-root.sh --help
+#
+# Behaviour:
+# - Single mode (no `--all-worktrees`): resolves the current worktree
+# via `git rev-parse --show-toplevel`, then writes/updates
+# `<that-path>/.claude/settings.local.json` so its
+# `sandbox.filesystem.allowRead` and `allowWrite` arrays contain
+# the worktree's absolute path. Used by the `post-checkout` git
+# hook installed by `/setup-steward adopt` — when a new worktree
+# is created, the hook fires in the new working tree and the
+# helper writes that worktree's own settings.local.json.
+# - All-worktrees mode (`--all-worktrees`): enumerates
+# `git worktree list --porcelain` from the current repo and
+# invokes the same write pass once per worktree, each against
+# the worktree's own `.claude/settings.local.json`. Used by
+# `setup-isolated-setup-install`, `/setup-steward adopt`,
+# `/setup-steward upgrade`.
+# - The target file is created from scratch if it does not exist
+# (only the `sandbox.filesystem` block is written; nothing else
+# is touched). Existing files: idempotent, atomic (`jq` → tmp →
+# `mv`), no-op when the path is already present.
+# - Tolerant of missing prerequisites:
+# - Not inside a git repo → warn on stderr, exit 0.
+# - `jq` not on PATH → warn on stderr, exit 0.
+# - Invalid existing JSON → warn on stderr, exit 0.
+# All exit 0 so callers (post-checkout hook, /setup-steward
+# sub-actions) are not derailed by a half-installed setup.
+#
+# Invoked from:
+# - setup-isolated-setup-install (at install, with --all-worktrees).
+# - /setup-steward adopt + upgrade (with --all-worktrees from the main).
+# - /setup-steward worktree-init (without the flag — current worktree only).
+# - The post-checkout git hook installed by /setup-steward adopt
+# (without the flag — only the new worktree's path).
+
+set -euo pipefail
+
+# --- option parsing ---------------------------------------------------------
+
+all_worktrees=0
+dry_run=0
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --all-worktrees) all_worktrees=1 ;;
+ --dry-run) dry_run=1 ;;
+ -h|--help)
+ sed -n '19,103p' "$0" # print the usage + behaviour block above
+ exit 0
+ ;;
+ *)
+ printf 'sandbox-add-project-root.sh: unknown option: %s\n' "$1" >&2
+ exit 2
+ ;;
+ esac
+ shift
+done
+
+# --- non-fatal preflight ----------------------------------------------------
+
+warn() { printf 'sandbox-add-project-root.sh: %s\n' "$*" >&2; }
+
+if ! command -v git >/dev/null 2>&1; then
+ warn "git not on PATH — skipping (no project root to add)."
+ exit 0
+fi
+
+if ! command -v jq >/dev/null 2>&1; then
+ warn "jq not on PATH — skipping. Install jq to enable project-local
sandbox-allowlist updates."
+ exit 0
+fi
+
+if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
+ warn "not inside a git working tree — skipping (no project root to add)."
+ exit 0
+fi
+
+# --- collect (worktree-path, settings-file) pairs ---------------------------
+
+# Pairs are stored as two parallel arrays — bash 3 (macOS default) has no
+# associative arrays we can rely on portably.
+worktree_paths=()
+target_files=()
+
+add_pair() {
+ local wt="$1"
+ # Skip if we've already queued this worktree (de-dup in --all-worktrees
mode).
+ local existing
+ for existing in "${worktree_paths[@]:-}"; do
+ [ "$existing" = "$wt" ] && return 0
+ done
+ worktree_paths+=("$wt")
+ target_files+=("$wt/.claude/settings.local.json")
+}
+
+if [ "$all_worktrees" -eq 1 ]; then
+ while IFS= read -r line; do
+ case "$line" in
+ "worktree "*)
+ add_pair "${line#worktree }"
+ ;;
+ esac
+ done < <(git worktree list --porcelain)
+else
+ add_pair "$(git rev-parse --show-toplevel)"
+fi
+
+# --- update a single project-local settings file ----------------------------
+
+# update_settings <file> <project-root-abs-path>
+#
+# Ensure <project-root-abs-path> appears in `.sandbox.filesystem.allowRead`
+# and `.sandbox.filesystem.allowWrite` of <file>. Atomic write.
+# Creates <file> + parent dir if missing.
+update_settings() {
+ local file="$1"
+ local path="$2"
+
+ # Safety: refuse to touch <file> if it is NOT gitignored in the
+ # repo it lives in. settings.local.json is per-machine; writing
+ # to a tracked path would create a git diff with absolute paths
+ # that should never be committed. The framework's adopt flow
+ # adds `/.claude/settings.local.json` to the adopter's
+ # .gitignore — if we land here without that entry in place, the
+ # adopter setup is incomplete and the user should fix the
+ # .gitignore first.
+ if ( cd "$(dirname "$file")" 2>/dev/null \
+ && git check-ignore -q "$file" 2>/dev/null ); then
+ : # ignored — safe to write
+ elif [ -d "$(dirname "$file")/.." ] \
+ && git -C "$(dirname "$file")" rev-parse --show-toplevel >/dev/null
2>&1; then
+ # Inside a git repo and check-ignore returned non-zero (path
+ # is not ignored). Refuse to write.
+ warn "$file is not gitignored — refusing to write. Add
/.claude/settings.local.json to the adopter's .gitignore and re-run."
+ return 0
+ fi
+ # If the parent dir is not in any git repo, we are running under a
+ # caller that already verified that. Allow the write to proceed.
+
+ local dir
+ dir=$(dirname "$file")
+ if [ ! -d "$dir" ]; then
+ if [ "$dry_run" -eq 1 ]; then
+ printf 'sandbox-add-project-root.sh [dry-run]: would mkdir -p %s\n'
"$dir" >&2
+ else
+ mkdir -p "$dir"
+ fi
+ fi
+
+ local input
+ if [ -f "$file" ]; then
+ if ! jq empty "$file" >/dev/null 2>&1; then
+ warn "$file is not valid JSON — skipping. Fix the file by hand or re-run
/setup-isolated-setup-install."
+ return 0
+ fi
+ input="$file"
+ else
+ # Synthesise an empty JSON object as input. jq will then
+ # build the sandbox.filesystem.{allowRead,allowWrite} keys from
+ # scratch. The output file lands with ONLY the sandbox stanza
+ # — nothing else is touched.
+ input=/dev/null
+ fi
+
+ local jq_prog='
+ .
+ | .sandbox.filesystem.allowRead = (
+ (.sandbox.filesystem.allowRead // [])
+ | if index($p) then . else . + [$p] end
+ )
+ | .sandbox.filesystem.allowWrite = (
+ (.sandbox.filesystem.allowWrite // [])
+ | if index($p) then . else . + [$p] end
+ )
+ '
+
+ local tmp
+ tmp=$(mktemp "${file}.XXXXXX")
+
+ if [ "$input" = "/dev/null" ]; then
+ if ! printf '{}\n' | jq --arg p "$path" "$jq_prog" > "$tmp"; then
+ rm -f "$tmp"
+ warn "jq update of $file failed — leaving file untouched."
+ return 0
+ fi
+ else
+ if ! jq --arg p "$path" "$jq_prog" "$file" > "$tmp"; then
+ rm -f "$tmp"
+ warn "jq update of $file failed — leaving file untouched."
+ return 0
+ fi
+ fi
+
+ if [ -f "$file" ] && cmp -s "$file" "$tmp"; then
+ rm -f "$tmp"
+ return 0 # no change
+ fi
+
+ if [ "$dry_run" -eq 1 ]; then
+ if [ -f "$file" ]; then
+ printf 'sandbox-add-project-root.sh [dry-run]: would update %s with:\n'
"$file" >&2
+ diff -u "$file" "$tmp" >&2 || true
+ else
+ printf 'sandbox-add-project-root.sh [dry-run]: would create %s with:\n'
"$file" >&2
+ cat "$tmp" >&2
+ fi
+ rm -f "$tmp"
+ return 0
+ fi
+
+ mv "$tmp" "$file"
+ printf 'sandbox-add-project-root.sh: updated %s (project root: %s)\n'
"$file" "$path" >&2
+}
+
+# --- apply ------------------------------------------------------------------
+
+i=0
+while [ "$i" -lt "${#worktree_paths[@]}" ]; do
+ update_settings "${target_files[$i]}" "${worktree_paths[$i]}"
+ i=$((i + 1))
+done
+
+exit 0
diff --git a/tools/sandbox-lint/expected.json b/tools/sandbox-lint/expected.json
index aa1e45b..9ce1e85 100644
--- a/tools/sandbox-lint/expected.json
+++ b/tools/sandbox-lint/expected.json
@@ -54,6 +54,12 @@
"Read(//**/.env)",
"Read(//**/.env.local)",
"Read(//**/.env.*.local)",
+ "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)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(aws *)",