You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/09/15 10:17:25 UTC

(magpie) branch main updated: fix(agent-isolation): read the main checkout's settings in a linked worktree (#1229)

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/magpie.git


The following commit(s) were added to refs/heads/main by this push:
     new 32e82cd0 fix(agent-isolation): read the main checkout's settings in a linked worktree (#1229)
32e82cd0 is described below

commit 32e82cd0f40665c3b82064de7703f6b326fdb2dc
Author: Bartosz Sławecki <ba...@ilikepython.com>
AuthorDate: Tue Sep 15 12:17:20 2026 +0200

    fix(agent-isolation): read the main checkout's settings in a linked worktree (#1229)
    
    * fix(agent-isolation): read the main checkout's settings in a linked worktree
    
    The status line claimed a sandbox a worktree session did not have. It
    walked `<cwd>/.claude/` and then user scope, but Claude Code scopes a
    linked git worktree's project to the *main* checkout — that is where
    `/sandbox` persists the toggle and where `.claude/.cc-writes` lands.
    The worktree's own `settings.local.json` holds only the per-worktree
    filesystem allowlist `sandbox-add-project-root.sh` writes, with no
    `enabled` key at all, so the walk fell through to
    `~/.claude/settings.json` and painted a green `[sandbox]` on a session
    the operator had deliberately switched out of the sandbox. That is the
    one failure mode this status line exists to prevent.
    
    Resolve the working-tree root and the main checkout by comparing
    `git rev-parse --git-dir` with `--git-common-dir` — they differ in a
    linked worktree and nowhere else — and insert both between `<cwd>` and
    user scope in the settings walk. The working-tree root also closes a
    latent miss: a `<cwd>` below the tree root has no `<cwd>/.claude/` to
    read, so project scope was skipped there too.
    
    The same resolution replaces the hardcoded `*/.claude/worktrees/*`
    substring match in the folder segment. `<source>/<worktree>` rendering
    now covers every layout instead of the single one it enumerated:
    worktrunk's sibling `<repo>.<branch>/` directories (the repeated repo
    prefix is stripped, so `cpython.gh-156021` reads `cpython/gh-156021`),
    Claude Code's own `.claude/worktrees/<name>`, and a plain
    `git worktree add` anywhere on disk. Because the test is a property of
    git rather than of a naming scheme, a worktree manager does not have
    to be enumerated here to be supported.
    
    Covered by a new test module: settings precedence across main,
    worktree and user scope, subdirectories on both sides, and folder
    rendering per layout.
    
    Generated-by: Claude Code (Opus 5)
    
    * test(agent-isolation): parametrize the status-line cases, drop the classes
    
    Each test was a class method repeating the same three steps — build a
    layout under tmp_path, run the script, assert on one segment of the
    line. Collapse them into a frozen `Case` dataclass, one `_build`, and a
    single `@pytest.mark.parametrize` run, and every case now asserts both
    the sandbox tag and the folder segment rather than whichever one it was
    written for. 13 cases, no classes; a new layout is one entry.
    
    Also state in the script header and the test docstring that this helper
    is Claude-Code-only, unlike the harness-agnostic scripts beside it: it
    is wired through Claude Code's `statusLine`, fed Claude Code's payload,
    and reads Claude Code's settings schema. That is why `.claude/` is the
    only settings directory under test.
    
    Generated-by: Claude Code (Opus 5)
    
    * test(agent-isolation): revert the status-line parametrization
    
    Restore the class-grouped tests from 8aa6fd9. The parametrized form
    collapsed the layouts into one `Case` table, which reads well as a
    matrix but loses the grouping that says *why* a case exists — the
    settings walk and the folder rendering are separate concerns that
    happen to share a fixture, not one table with two columns.
    
    Keeps the Claude-Code-only note added alongside it: the docstring still
    says why `.claude/` is the only settings directory under test.
    
    Generated-by: Claude Code (Opus 5)
    
    * docs(agent-isolation): say that the status line is Claude Code only
    
    `tools/agent-isolation/README.md` tagged the whole directory **Harness:
    agnostic**, which is true of every script in it except this one:
    `sandbox-status-line.sh` is wired through Claude Code's `statusLine`
    setting, is fed Claude Code's statusLine payload on stdin, and reads
    Claude Code's `sandbox.enabled` schema. Nothing said so, so a reader
    comparing harnesses had no way to tell the exception from an omission.
    
    Note it in the directory's harness tag, in its Files row, and in the
    status-line section of the secure-agent-setup document, each pointing
    at `docs/adapters/add-a-harness.md` for a harness that grows one. The
    setup document also names the harness-agnostic half of sandbox
    visibility — the bypass-warning hook, which fires on the tool call
    rather than in the footer.
    
    Generated-by: Claude Code (Opus 5)
    
    * fix(agent-isolation): keep the Harness tag machine-readable
    
    a337b03 wrote `**Harness:** agnostic, except sandbox-status-line.sh —
    Claude Code only`, and the vendor-neutrality-score hook parses that
    field as a comma-separated list of harness names:
    
        error: tools/agent-isolation: unknown harness(es) ['agnostic', 'except']
    
    The field is data, not prose. Put `agnostic` back in it and state the
    one exception in a sentence below, where a reader gets the same
    information and the parser is not asked to read English.
    
    Generated-by: Claude Code (Opus 5)
    
    * fix(agent-isolation): read the main checkout first, not a bare repo's parent
    
    Two corrections to the settings walk this branch introduces.
    
    The walk put <cwd> and the working-tree root ahead of the main checkout,
    so an `enabled` written by hand into a linked worktree's own settings won
    over the main checkout. Claude Code does not read that file — it scopes a
    linked worktree's project to the main checkout — so the more specific file
    is precisely the one that cannot describe the session. A worktree saying
    `true` over a main checkout saying `false` rendered a green [sandbox] on a
    session that had none, which is the failure this status line exists to
    prevent. The main checkout now leads the walk, and the test that asserted
    the old order asserts the new one.
    
    `main_root` was derived as the parent of `--git-common-dir`, which assumes
    the common dir is `<main>/.git`. For `git clone --bare` plus `git worktree
    add` it is `<repo>.git`, so the parent is whatever directory happens to
    contain the bare repo: its `.claude/settings.json` was read as project
    scope, and the folder segment rendered that directory's name rather than
    the repository's. The derivation is now guarded on the `.git` suffix — a
    bare common dir yields no project scope and the walk falls through to user
    scope, while the folder segment still names the repository, taken from the
    bare directory (`proj.git` -> `proj/feature-x`).
    
    Also guards the comparison on a non-empty `--git-dir`, so a `--git-dir`
    that fails to resolve no longer reads as "linked worktree".
    
    Generated-by: Claude Opus 5
    
    ---------
    
    Co-authored-by: Jarek Potiuk <po...@apache.org>
---
 docs/setup/secure-agent-setup.md                   |  73 ++++-
 tools/agent-isolation/README.md                    |   6 +-
 tools/agent-isolation/sandbox-status-line.sh       | 156 +++++++++--
 .../tests/test_sandbox_status_line.py              | 299 +++++++++++++++++++++
 4 files changed, 510 insertions(+), 24 deletions(-)

diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index ed124226..9895859c 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -1459,7 +1459,22 @@ unnoticed for hours.
 
 The framework ships
 [`tools/agent-isolation/sandbox-status-line.sh`](../../tools/agent-isolation/sandbox-status-line.sh)
-to render exactly that, leading with the sandbox tag:
+to render exactly that, leading with the sandbox tag.
+
+**Claude Code only**, unlike the harness-agnostic helpers beside it:
+the script is wired through Claude Code's `statusLine` setting, is fed
+Claude Code's statusLine payload on stdin, and reads Claude Code's
+`sandbox.enabled` schema. No other harness the framework supports has a
+status-line hook of that shape — Codex, Gemini, OpenCode and Kiro carry
+their sandbox posture in their own config and surface it, where they
+surface it at all, through their own UI. A harness that grows one gets
+its own helper; see
+[`docs/adapters/add-a-harness.md`](../adapters/add-a-harness.md). The
+harness-agnostic half of sandbox visibility is the
+[bypass-warning hook](#sandbox-bypass-visibility-hook), which fires on
+the tool call rather than in the footer.
+
+The tag and the segments that follow it:
 
 - `[sandbox]` in green when the active settings set
   `"sandbox": { "enabled": true }`;
@@ -1477,9 +1492,12 @@ another when several are open across worktrees and repos:
 ```
 
 - **the project folder**, colour-coded by a stable hash of its name, so
-  a repo keeps the same colour across sessions — and a Claude Code
+  a repo keeps the same colour across sessions — and a linked git
   worktree renders as `<source>/<worktree>` with each half hashed
-  independently;
+  independently, whatever the layout: worktrunk's sibling
+  `<repo>.<branch>/` directories (the repeated `<repo>.` prefix is
+  stripped, so `cpython.gh-156021` reads `cpython/gh-156021`), Claude
+  Code's own `.claude/worktrees/<name>`, or a plain `git worktree add`;
 - **the git branch**, with a dirty marker and ahead/behind against the
   upstream — read from local refs only, no network;
 - **the PR**, number and title, from one cached `gh pr view` per
@@ -1500,6 +1518,55 @@ slash-command toggle persists to project `settings.local.json`,
 so flipping it mid-session is reflected in the prefix on the
 next render.
 
+**Linked worktrees.** Claude Code scopes the project of a linked
+git worktree to the **main checkout**: that is where `/sandbox`
+writes `enabled` and where `.claude/.cc-writes` lands, while
+`<cwd>` is the worktree's own directory. A worktree's
+`.claude/settings.local.json` normally carries only the
+per-worktree filesystem allowlist that
+[`sandbox-add-project-root.sh`](#sandbox-add-project-rootsh)
+writes — no `enabled` key at all. Walking `<cwd>` alone therefore
+falls straight through to user scope, and a session the operator
+deliberately switched *out* of the sandbox keeps rendering a green
+`[sandbox]` — the exact silent drift this line exists to prevent.
+So the walk leads with the main checkout and keeps the working-tree
+root ahead of user scope:
+
+```text
+<main-checkout>/.claude/settings.local.json   (linked worktree only)
+<main-checkout>/.claude/settings.json         (linked worktree only)
+<cwd>/.claude/settings.local.json
+<cwd>/.claude/settings.json
+<worktree-root>/.claude/settings.local.json
+<worktree-root>/.claude/settings.json
+~/.claude/settings.local.json
+~/.claude/settings.json
+```
+
+The main checkout leads rather than follows because it is the file the
+harness itself reads, so it is the only one that can describe the
+session. An `enabled` written by hand into a worktree's own settings is
+not read by Claude Code at all; preferring it for being "more specific"
+would let the line paint a green `[sandbox]` over a session that has
+none — the one thing it exists to prevent. This helper may say nothing;
+it may not say the wrong thing.
+
+The main checkout is found by comparing `git rev-parse --git-dir`
+with `--git-common-dir` — they differ in a linked worktree and
+nowhere else. That is a property of git, not of a directory
+naming scheme, so it holds for every worktree manager without the
+script having to enumerate them: worktrunk's sibling
+`<repo>.<branch>/` directories, Claude Code's own
+`<source>/.claude/worktrees/<name>`, and a plain `git worktree
+add` anywhere on disk.
+
+One layout has no main checkout to find: `git clone --bare` plus
+`git worktree add`, where the common dir is `<repo>.git` rather than
+`<main>/.git`. Its parent is simply whatever directory happens to
+contain the bare repo, so it is not read as project scope — the walk
+falls through to user scope, which is the honest answer. The folder
+segment still names the repository, taken from the bare directory.
+
 Like the [Sandbox-bypass visibility hook](#sandbox-bypass-visibility-hook),
 this is **complementary**, not authoritative — see Trade-offs
 below.
diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md
index d5f79c91..485766f1 100644
--- a/tools/agent-isolation/README.md
+++ b/tools/agent-isolation/README.md
@@ -23,6 +23,10 @@
 
 **Harness:** agnostic
 
+One exception to that tag:
+[`sandbox-status-line.sh`](sandbox-status-line.sh) is Claude Code only.
+Every other script here is harness-agnostic.
+
 This directory ships the moving pieces the framework's
 [`docs/setup/secure-agent-setup.md`](../../docs/setup/secure-agent-setup.md) document
 references. It is not a Python project (unlike the sibling tools
@@ -82,7 +86,7 @@ per runtime — see [`docs/adapters/add-a-harness.md`](../../docs/adapters/add-a
 | [`agent-iso.sh`](agent-iso.sh) | Shell function to launch Claude Code with `env -i` and a tiny passthrough list, stripping every credential-shaped environment variable from the parent shell. The framework's "layer 0" of the secure setup. |
 | [`sandbox-bypass-warn.sh`](sandbox-bypass-warn.sh) | Claude Code `PreToolUse` hook (Bash matcher). Prints a bold-red banner to stderr whenever the model invokes the Bash tool with `dangerouslyDisableSandbox: true`. Belt-and-braces visibility for the sandbox-bypass permission prompt. Recommended user-scope (`~/.claude/settings.json`) so it fires across every session on the host. |
 | [`sandbox-error-hint.sh`](sandbox-error-hint.sh) | Claude Code `PostToolUse` hook (Bash matcher). Scans the tool's stdout + stderr for the three known sandbox-shaped error signatures (SSH agent / Yubikey unreachable, loopback port-bind blocked, docker / podman socket denied) and prints a `[sandbox-hint]` line pointing at the matching entry in [`docs/setup/sandbox-troubleshooting.md`](../../docs/setup/sandbox-troubleshooting.md). Fail-open: any unexpected JSON shape exits silent. Recomm [...]
-| [`sandbox-status-line.sh`](sandbox-status-line.sh) | Claude Code `statusLine` helper, and the only one Magpie ships. Leads with the sandbox state — green `[sandbox]`, yellow `[sandbox-auto]` when `autoAllowBashIfSandboxed` widens the blast radius, bold-red `[NO SANDBOX]` — read from `sandbox.enabled` in the active settings, project `settings.local.json` first, then project `settings.json`, then user-scope, mirroring Claude Code's own precedence, so in-session `/sandbox` toggles are ref [...]
+| [`sandbox-status-line.sh`](sandbox-status-line.sh) | Claude Code `statusLine` helper, and the only one Magpie ships. **Claude Code only** — the one script in this directory that is not harness-agnostic: it is wired through Claude Code's `statusLine` setting, is fed Claude Code's statusLine payload on stdin, and reads Claude Code's `sandbox.enabled` schema. No other harness the framework supports has a status-line hook of that shape; Codex, Gemini, OpenCode and Kiro carry their sandbox  [...]
 | [`claude-term-bg.sh`](claude-term-bg.sh) | **Opt-in quality-of-life helper (not a security control).** Keeps a calm baseline background and tints it only when Claude genuinely wants you to act (never while working, and never when it merely *finished* a turn), so a window you've tabbed away from can't sit blocked unnoticed. Distinguishes "blocked on a decision" from "finished and idle" — which look identical at the `Stop` event — via three signals across six hooks: `Stop` → `stop` (heur [...]
 | [`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/magpie/issues/197) — `allowRead: [" [...]
 | [`git-global-post-checkout.sh`](git-global-post-checkout.sh) | Universal `post-checkout` git hook installed at `~/.claude/git-hooks/post-checkout` when the operator picks the **simple whole-user** flavour in `setup-isolated-setup-install`. Activated by `git config --global core.hooksPath ~/.claude/git-hooks/` so every `git checkout` / `git clone` / `git worktree add` across the host invokes it. Best-effort + idempotent + `\|\| true`: invokes `sandbox-add-project-root.sh` for any worktr [...]
diff --git a/tools/agent-isolation/sandbox-status-line.sh b/tools/agent-isolation/sandbox-status-line.sh
index 73def096..2f7d3eb7 100755
--- a/tools/agent-isolation/sandbox-status-line.sh
+++ b/tools/agent-isolation/sandbox-status-line.sh
@@ -30,11 +30,24 @@
 #                         | #<PR> <title>
 #                         | <model>
 #
+# Claude-Code-only, unlike the harness-agnostic helpers alongside it:
+# it is wired through Claude Code's `statusLine` setting, is fed Claude
+# Code's statusLine payload on stdin, and reads Claude Code's
+# `sandbox.enabled` settings schema. No other harness the framework
+# supports has a status-line hook of that shape — Codex, Gemini,
+# OpenCode and Kiro carry their sandbox posture in their own config and
+# surface it, where they surface it at all, through their own UI. A
+# harness that grows one gets its own helper; see
+# `docs/adapters/add-a-harness.md`.
+#
 # - Folder name is colour-coded by a stable hash of its basename, so
 #   each repo / worktree keeps the same colour across sessions.
-# - Inside a Claude Code worktree (`<source>/.claude/worktrees/<name>`)
-#   renders `<source>/<worktree>` with each segment hash-coloured
-#   independently — the source colour stays stable across worktrees.
+# - Inside a linked git worktree renders `<source>/<worktree>` with
+#   each segment hash-coloured independently — the source colour stays
+#   stable across worktrees. Covers every layout: worktrunk's sibling
+#   `<repo>.<branch>/` directories (the repeated `<repo>.` prefix is
+#   stripped from the second segment), Claude Code's own
+#   `<source>/.claude/worktrees/<name>`, and plain `git worktree add`.
 # - Git segment is local-only (no network): branch, dirty marker
 #   (`-uno` skips untracked-file scan), ahead/behind vs upstream from
 #   cached refs.
@@ -52,10 +65,14 @@
 # Sandbox tag reads `.sandbox.enabled` from the active settings,
 # walked in Claude Code's standard precedence order (most-specific
 # first):
-#   1. <cwd>/.claude/settings.local.json
-#   2. <cwd>/.claude/settings.json
-#   3. ~/.claude/settings.local.json
-#   4. ~/.claude/settings.json
+#   1. <main-checkout>/.claude/settings.local.json   (linked worktree only)
+#   2. <main-checkout>/.claude/settings.json          (linked worktree only)
+#   3. <cwd>/.claude/settings.local.json
+#   4. <cwd>/.claude/settings.json
+#   5. <worktree-root>/.claude/settings.local.json
+#   6. <worktree-root>/.claude/settings.json
+#   7. ~/.claude/settings.local.json
+#   8. ~/.claude/settings.json
 # First file with `.sandbox.enabled` set (true *or* false) wins. The
 # `/sandbox` slash-command toggle persists to project-scope
 # `settings.local.json`, so reading that file is what makes the prefix
@@ -63,6 +80,32 @@
 # `--bypass-permissions` are still not visible here — pair with
 # `sandbox-bypass-warn.sh` for per-call signal.
 #
+# Steps 1-2 and 5-6 are what make the tag correct inside a linked git
+# worktree. Claude Code scopes the project of a linked worktree to
+# the *main* checkout — that is where `/sandbox` writes the toggle and
+# where `.claude/.cc-writes` lands — while `<cwd>` is the worktree's
+# own directory, whose `.claude/settings.local.json` typically carries
+# only the per-worktree filesystem allowlist and no `enabled` key.
+# Walking `<cwd>` alone there falls straight through to user scope, so
+# a session the operator switched *out* of the sandbox still renders
+# green `[sandbox]` — precisely the silent drift this line exists to
+# prevent.
+#
+# The main checkout is read *first* for the same reason, and it is the
+# whole point: it is the file the harness itself reads, so it is the
+# only one that can describe the session. A worktree-local `enabled`
+# set by hand is not read by Claude Code at all, and preferring it
+# because it is "more specific" would let this line paint a green
+# `[sandbox]` over a session that has none. This helper is allowed to
+# say nothing; it is not allowed to say the wrong thing.
+#
+# Layout-agnostic: worktrunk's `<repo>.<branch>/` siblings,
+# `.claude/worktrees/<name>`, and plain `git worktree add` all resolve
+# through the same `--git-dir` / `--git-common-dir` comparison. A bare
+# common dir (`clone --bare` + worktrees) has no main checkout at all,
+# so there is no project scope to read — its parent directory is not
+# one, and is not treated as one.
+#
 # Wiring (user-scope):
 #
 #   {
@@ -101,16 +144,77 @@ model=${parsed#*$'\t'}
 [ -z "$cwd" ] && cwd="$PWD"
 
 # ---------------------------------------------------------------------------
-# 1. Folder (colour-coded by hash)
-#    Inside a Claude worktree (<source>/.claude/worktrees/<name>) we render
-#    "<source>/<worktree>" with each segment hash-coloured independently —
-#    the source colour stays stable across all worktrees of the same repo.
+# 1. Worktree roots
+#    `wt_root` is the working tree containing <cwd>; `main_root` is the main
+#    checkout, set only when <cwd> sits in a *linked* worktree. Both feed the
+#    folder segment below and the settings walk in section 5.
+#
+#    `--git-dir` != `--git-common-dir` is the exact test for "linked
+#    worktree" and is layout-agnostic — it holds for worktrunk's sibling
+#    `<repo>.<branch>/` directories, for Claude Code's own
+#    `<source>/.claude/worktrees/<name>`, and for a plain `git worktree add`
+#    anywhere else. Matching on the directory name instead would only ever
+#    know the layouts someone thought to enumerate.
+# ---------------------------------------------------------------------------
+wt_root=""
+main_root=""
+source_name=""
+if wt_root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null); then
+    gdir=$(git -C "$cwd" rev-parse --git-dir 2>/dev/null || true)
+    gcommon=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null || true)
+    # Both come back relative to <cwd> when <cwd> *is* the working-tree
+    # root and absolute from a subdirectory below it, so resolve each to
+    # a real path before comparing — a raw string compare reports every
+    # subdirectory of a plain checkout as a linked worktree.
+    [ -n "$gdir" ] && [[ "$gdir" != /* ]] && gdir="$cwd/$gdir"
+    [ -n "$gcommon" ] && [[ "$gcommon" != /* ]] && gcommon="$cwd/$gcommon"
+    gdir=$(cd "$gdir" 2>/dev/null && pwd -P) || gdir=""
+    gcommon=$(cd "$gcommon" 2>/dev/null && pwd -P) || gcommon=""
+    if [ -n "$gdir" ] && [ -n "$gcommon" ] && [ "$gdir" != "$gcommon" ]; then
+        # <main>/.git -> <main>, but only when the common dir *is* a `.git`.
+        # `git clone --bare` + `git worktree add` is a linked worktree with no
+        # main checkout at all: the common dir is `<repo>.git` and its parent
+        # is whatever directory happens to contain it. Reading that parent as
+        # project scope is worse than reading nothing — it is an unrelated
+        # directory whose `.claude/settings.json`, if any, belongs to someone
+        # else's project and can send the tag either way.
+        case "$gcommon" in
+            */.git)
+                main_root=$(cd "$gcommon/.." 2>/dev/null && pwd -P) || main_root=""
+                [ "$main_root" = "$wt_root" ] && main_root=""
+                source_name=${main_root:+$(basename "$main_root")}
+                ;;
+            *)
+                # Bare: no settings scope to read, but the repository still has
+                # a name worth rendering — `<repo>.git` -> `<repo>`.
+                main_root=""
+                source_name=$(basename "$gcommon")
+                source_name=${source_name%.git}
+                ;;
+        esac
+    fi
+else
+    wt_root=""
+fi
+
+# ---------------------------------------------------------------------------
+# 2. Folder (colour-coded by hash)
+#    In a linked worktree we render "<source>/<worktree>" with each segment
+#    hash-coloured independently — the source colour stays stable across all
+#    worktrees of the same repo.
 # ---------------------------------------------------------------------------
 folder=$(basename "$cwd")
 worktree_name=""
-if [[ "$cwd" == *"/.claude/worktrees/"* ]]; then
-    worktree_name="$folder"
-    folder=$(basename "${cwd%%/.claude/worktrees/*}")
+if [ -n "$source_name" ]; then
+    worktree_name=$(basename "$wt_root")
+    folder=$source_name
+    # worktrunk names worktrees "<repo>.<branch>" as siblings of the main
+    # checkout, so the repo name would otherwise appear twice. Strip the
+    # repeated prefix and its separator; layouts that do not repeat it
+    # (".claude/worktrees/<name>") fall through untouched.
+    case "$worktree_name" in
+        "$folder"[._-]*) worktree_name=${worktree_name#"$folder"?} ;;
+    esac
 fi
 
 palette=(33 39 45 51 75 81 87 105 111 117 123 141 147 153 159 165 171 177 183 189 195 201 207 213 219 220 226 214 208 202 196 199 203 209 215 221 227 154 118 82 46 47 48 49 50 84 120 156 190 228)
@@ -127,7 +231,7 @@ if [ -n "$worktree_name" ]; then
 fi
 
 # ---------------------------------------------------------------------------
-# 2. Git status
+# 3. Git status
 # ---------------------------------------------------------------------------
 git_info=""
 branch=""
@@ -166,7 +270,7 @@ if git_dir=$(git -C "$cwd" rev-parse --git-dir 2>/dev/null); then
 fi
 
 # ---------------------------------------------------------------------------
-# 3. PR title (cached per repo+branch)
+# 4. PR title (cached per repo+branch)
 # ---------------------------------------------------------------------------
 pr_info=""
 if [ -n "$branch" ] && [[ "$branch" != *"(detached)" ]] && command -v gh >/dev/null 2>&1; then
@@ -216,21 +320,33 @@ if [ -n "$branch" ] && [[ "$branch" != *"(detached)" ]] && command -v gh >/dev/n
 fi
 
 # ---------------------------------------------------------------------------
-# 4. Sandbox state — green [sandbox] / yellow [sandbox-auto] / bold-red
+# 5. Sandbox state — green [sandbox] / yellow [sandbox-auto] / bold-red
 #    [NO SANDBOX] suffix. Walks settings.local.json + settings.json in
 #    project then user scope. First file with `.sandbox.enabled` set
 #    (true *or* false) wins — `/sandbox` writes to project
 #    `settings.local.json`, so that is the file that flips when the user
-#    toggles in-session. The yellow `[sandbox-auto]` variant lights up
-#    when `.sandbox.autoAllowBashIfSandboxed` is true in the same file —
+#    toggles in-session. In a linked worktree that project scope is the
+#    *main* checkout, not the worktree directory, so `main_root` leads the
+#    walk: without it a worktree session whose sandbox is off reads user
+#    scope instead and renders a green `[sandbox]` it has not earned, and
+#    behind `<cwd>` it would lose to a hand-written worktree file the
+#    harness never reads. `wt_root` covers the other half — a <cwd> below
+#    the working-tree root, where `<cwd>/.claude/` does not exist at all.
+#
+#    The yellow `[sandbox-auto]` variant lights up when
+#    `.sandbox.autoAllowBashIfSandboxed` is true in the same file —
 #    it signals that bash commands inside the sandbox skip the per-call
 #    allow prompt, which is a wider blast radius than vanilla sandbox.
 # ---------------------------------------------------------------------------
 sandbox=""
 sandbox_auto=""
 for f in \
+    "${main_root:+$main_root/.claude/settings.local.json}" \
+    "${main_root:+$main_root/.claude/settings.json}" \
     "${cwd:+$cwd/.claude/settings.local.json}" \
     "${cwd:+$cwd/.claude/settings.json}" \
+    "${wt_root:+$wt_root/.claude/settings.local.json}" \
+    "${wt_root:+$wt_root/.claude/settings.json}" \
     "$HOME/.claude/settings.local.json" \
     "$HOME/.claude/settings.json"; do
     [ -n "$f" ] && [ -f "$f" ] || continue
@@ -273,6 +389,6 @@ model_info=""
 [ -n "$model" ] && model_info=" | $model"
 
 # ---------------------------------------------------------------------------
-# 5. Emit — sandbox tag leads, then folder / git / PR / model.
+# 6. Emit — sandbox tag leads, then folder / git / PR / model.
 # ---------------------------------------------------------------------------
 printf '%s %s%s%s%s' "$sandbox_tag" "$folder_out" "$git_info" "$pr_info" "$model_info"
diff --git a/tools/agent-isolation/tests/test_sandbox_status_line.py b/tools/agent-isolation/tests/test_sandbox_status_line.py
new file mode 100644
index 00000000..e7827d14
--- /dev/null
+++ b/tools/agent-isolation/tests/test_sandbox_status_line.py
@@ -0,0 +1,299 @@
+# 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.
+
+"""Tests for sandbox-status-line.sh.
+
+The helper reads the Claude Code statusLine JSON payload on stdin and
+prints one line: the sandbox tag, the folder, the branch, the PR, the
+model. The cases that matter here are the sandbox tag (it must never
+claim a sandbox the session does not have) and the folder segment
+inside a linked git worktree, where the project settings that decide
+the tag live in the *main* checkout rather than in <cwd>.
+
+Claude-Code-specific by construction, so `.claude/` is the only
+settings directory under test: the script is wired through Claude
+Code's `statusLine` setting, is fed Claude Code's statusLine payload on
+stdin, and reads Claude Code's `sandbox.enabled` schema. No other
+harness the framework supports has a status-line hook of that shape —
+Codex, Gemini, OpenCode and Kiro carry their sandbox posture in their
+own config files and surface it (when they surface it at all) through
+their own UI. A harness that grows one gets its own helper and its own
+tests; see `docs/adapters/add-a-harness.md`.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).parent.parent / "sandbox-status-line.sh"
+
+ANSI = re.compile(r"\x1b\[[0-9;]*m")
+
+
+# ---------------------------------------------------------------------------
+# helpers
+# ---------------------------------------------------------------------------
+
+
+# Ignore the operator's own git config: a global `commit.gpgsign` (or a
+# signing key this process cannot read) would fail every fixture commit.
+GIT_ENV = {
+    **os.environ,
+    "GIT_CONFIG_GLOBAL": os.devnull,
+    "GIT_CONFIG_SYSTEM": os.devnull,
+    "GIT_AUTHOR_NAME": "T",
+    "GIT_AUTHOR_EMAIL": "t@example.invalid",
+    "GIT_COMMITTER_NAME": "T",
+    "GIT_COMMITTER_EMAIL": "t@example.invalid",
+}
+
+
+def _git(cwd: Path, *args: str) -> None:
+    subprocess.run(
+        ["git", "-C", str(cwd), *args],
+        check=True,
+        capture_output=True,
+        text=True,
+        env=GIT_ENV,
+    )
+
+
+def _init_repo(path: Path) -> Path:
+    """Create a git repo at *path* with one commit, ready for worktrees."""
+    path.mkdir(parents=True, exist_ok=True)
+    subprocess.run(
+        ["git", "init", "-q", "-b", "main", str(path)],
+        check=True,
+        capture_output=True,
+        text=True,
+        env=GIT_ENV,
+    )
+    _git(path, "commit", "-q", "--allow-empty", "-m", "init")
+    return path
+
+
+def _settings(path: Path, sandbox: dict) -> None:
+    """Write <path>/.claude/settings.local.json with a sandbox block."""
+    claude = path / ".claude"
+    claude.mkdir(parents=True, exist_ok=True)
+    (claude / "settings.local.json").write_text(json.dumps({"sandbox": sandbox}))
+
+
+def _fake_gh(tmp_path: Path) -> Path:
+    """A `gh` on PATH that always fails, so the PR segment stays silent."""
+    bin_dir = tmp_path / "fakebin"
+    bin_dir.mkdir(parents=True, exist_ok=True)
+    gh = bin_dir / "gh"
+    gh.write_text("#!/bin/sh\nexit 1\n")
+    gh.chmod(0o755)
+    return bin_dir
+
+
+def _run(cwd: Path, home: Path, tmp_path: Path) -> str:
+    env = dict(GIT_ENV)
+    env["HOME"] = str(home)
+    env["XDG_CACHE_HOME"] = str(tmp_path / "cache")
+    env["PATH"] = f"{_fake_gh(tmp_path)}{os.pathsep}{env['PATH']}"
+    payload = json.dumps({"cwd": str(cwd), "model": {"display_name": "Opus 5"}})
+    result = subprocess.run(
+        ["bash", str(SCRIPT)],
+        input=payload,
+        capture_output=True,
+        text=True,
+        env=env,
+    )
+    assert result.returncode == 0, result.stderr
+    return ANSI.sub("", result.stdout)
+
+
+def _folder(out: str) -> str:
+    """The folder segment: everything between the tag and the first ' | '."""
+    return out.split("] ", 1)[1].split(" | ", 1)[0]
+
+
+@pytest.fixture
+def home(tmp_path: Path) -> Path:
+    """A user scope that says the sandbox is ON — the drift-prone default."""
+    h = tmp_path / "home"
+    (h / ".claude").mkdir(parents=True)
+    (h / ".claude" / "settings.json").write_text(
+        json.dumps({"sandbox": {"enabled": True}})
+    )
+    return h
+
+
+# ---------------------------------------------------------------------------
+# sandbox tag — plain (non-worktree) checkout
+# ---------------------------------------------------------------------------
+
+
+class TestSandboxTag:
+    def test_project_scope_true_renders_sandbox(self, tmp_path: Path, home: Path) -> None:
+        repo = _init_repo(tmp_path / "repo")
+        _settings(repo, {"enabled": True})
+        assert _run(repo, home, tmp_path).startswith("[sandbox] ")
+
+    def test_project_scope_false_beats_user_scope_true(self, tmp_path: Path, home: Path) -> None:
+        repo = _init_repo(tmp_path / "repo")
+        _settings(repo, {"enabled": False})
+        assert _run(repo, home, tmp_path).startswith("[NO SANDBOX] ")
+
+    def test_auto_allow_gets_its_own_tag(self, tmp_path: Path, home: Path) -> None:
+        repo = _init_repo(tmp_path / "repo")
+        _settings(repo, {"enabled": True, "autoAllowBashIfSandboxed": True})
+        assert _run(repo, home, tmp_path).startswith("[sandbox-auto] ")
+
+    def test_falls_through_to_user_scope(self, tmp_path: Path, home: Path) -> None:
+        repo = _init_repo(tmp_path / "repo")
+        assert _run(repo, home, tmp_path).startswith("[sandbox] ")
+
+    def test_non_git_directory_still_renders(self, tmp_path: Path, home: Path) -> None:
+        plain = tmp_path / "plain"
+        plain.mkdir()
+        out = _run(plain, home, tmp_path)
+        assert out.startswith("[sandbox] ")
+        assert "plain" in out
+
+
+# ---------------------------------------------------------------------------
+# sandbox tag — linked worktrees
+#
+# Claude Code scopes a linked worktree's project settings to the main
+# checkout: `/sandbox` writes `enabled` there, and the worktree's own
+# .claude/settings.local.json usually holds only the per-worktree
+# filesystem allowlist. Reading <cwd> alone falls through to user scope
+# and claims a sandbox the session does not have.
+# ---------------------------------------------------------------------------
+
+
+class TestLinkedWorktreeSandboxTag:
+    def test_worktrunk_worktree_reads_main_checkout(self, tmp_path: Path, home: Path) -> None:
+        repo = _init_repo(tmp_path / "repo")
+        _settings(repo, {"enabled": False})
+        wt = tmp_path / "repo.feature-x"
+        _git(repo, "worktree", "add", "-q", "-b", "feature-x", str(wt))
+        # What the post-checkout hook writes: filesystem only, no `enabled`.
+        _settings(wt, {"filesystem": {"allowRead": [str(wt)]}})
+        assert _run(wt, home, tmp_path).startswith("[NO SANDBOX] ")
+
+    def test_worktree_subdirectory_reads_main_checkout(self, tmp_path: Path, home: Path) -> None:
+        repo = _init_repo(tmp_path / "repo")
+        _settings(repo, {"enabled": False})
+        wt = tmp_path / "repo.feature-x"
+        _git(repo, "worktree", "add", "-q", "-b", "feature-x", str(wt))
+        sub = wt / "src" / "deep"
+        sub.mkdir(parents=True)
+        assert _run(sub, home, tmp_path).startswith("[NO SANDBOX] ")
+
+    def test_main_checkout_beats_a_hand_written_worktree_setting(
+        self, tmp_path: Path, home: Path
+    ) -> None:
+        """The main checkout is the file the harness reads, so it wins.
+
+        A worktree-local `enabled` is not read by Claude Code at all.
+        Preferring it because it is "more specific" would paint a green
+        [sandbox] over a session that has none -- the one thing this line
+        must never do.
+        """
+        repo = _init_repo(tmp_path / "repo")
+        _settings(repo, {"enabled": False})
+        wt = tmp_path / "repo.feature-x"
+        _git(repo, "worktree", "add", "-q", "-b", "feature-x", str(wt))
+        _settings(wt, {"enabled": True})
+        assert _run(wt, home, tmp_path).startswith("[NO SANDBOX] ")
+
+    def test_bare_repo_worktree_does_not_read_the_bare_dir_s_parent(
+        self, tmp_path: Path, home: Path
+    ) -> None:
+        """`clone --bare` + worktrees has no main checkout to read.
+
+        The common dir is `<repo>.git`, so its parent is whatever directory
+        happens to contain it -- an unrelated project's settings, or none.
+        Falling through to user scope is the honest answer.
+        """
+        container = tmp_path / "container"
+        container.mkdir()
+        # The trap: a `.claude` belonging to the containing directory.
+        _settings(container, {"enabled": False})
+        bare = container / "proj.git"
+        seed = _init_repo(tmp_path / "seed")
+        subprocess.run(
+            ["git", "clone", "-q", "--bare", str(seed), str(bare)],
+            check=True,
+            capture_output=True,
+            text=True,
+            env=GIT_ENV,
+        )
+        wt = container / "feature-x"
+        _git(bare, "worktree", "add", "-q", "-b", "feature-x", str(wt))
+        out = _run(wt, home, tmp_path)
+        # User scope says on, and nothing legitimate overrides it.
+        assert out.startswith("[sandbox] ")
+        # The repo name comes from the bare dir, not from its parent.
+        assert _folder(out) == "proj/feature-x"
+
+    def test_main_checkout_subdirectory_is_not_a_worktree(self, tmp_path: Path, home: Path) -> None:
+        repo = _init_repo(tmp_path / "repo")
+        _settings(repo, {"enabled": False})
+        sub = repo / "src"
+        sub.mkdir()
+        out = _run(sub, home, tmp_path)
+        assert out.startswith("[NO SANDBOX] ")
+        # Not a linked worktree, so no "<source>/<worktree>" split — the
+        # folder segment stays the plain basename it has always been.
+        assert _folder(out) == "src"
+
+
+# ---------------------------------------------------------------------------
+# folder segment
+# ---------------------------------------------------------------------------
+
+
+class TestFolderSegment:
+    def test_worktrunk_layout_strips_the_repeated_repo_name(self, tmp_path: Path, home: Path) -> None:
+        repo = _init_repo(tmp_path / "repo")
+        wt = tmp_path / "repo.feature-x"
+        _git(repo, "worktree", "add", "-q", "-b", "feature-x", str(wt))
+        assert _folder(_run(wt, home, tmp_path)) == "repo/feature-x"
+
+    def test_worktrunk_dash_separator(self, tmp_path: Path, home: Path) -> None:
+        repo = _init_repo(tmp_path / "repo")
+        wt = tmp_path / "repo-feature-x"
+        _git(repo, "worktree", "add", "-q", "-b", "feature-x", str(wt))
+        assert _folder(_run(wt, home, tmp_path)) == "repo/feature-x"
+
+    def test_claude_code_worktree_layout(self, tmp_path: Path, home: Path) -> None:
+        repo = _init_repo(tmp_path / "repo")
+        wt = repo / ".claude" / "worktrees" / "feature-x"
+        _git(repo, "worktree", "add", "-q", "-b", "feature-x", str(wt))
+        assert _folder(_run(wt, home, tmp_path)) == "repo/feature-x"
+
+    def test_unrelated_worktree_name_is_left_alone(self, tmp_path: Path, home: Path) -> None:
+        repo = _init_repo(tmp_path / "repo")
+        wt = tmp_path / "elsewhere"
+        _git(repo, "worktree", "add", "-q", "-b", "feature-x", str(wt))
+        assert _folder(_run(wt, home, tmp_path)) == "repo/elsewhere"
+
+    def test_main_checkout_renders_one_segment(self, tmp_path: Path, home: Path) -> None:
+        repo = _init_repo(tmp_path / "repo")
+        assert _folder(_run(repo, home, tmp_path)) == "repo"