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 *)",

Reply via email to