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 0b84140 feat(setup-isolated): per-project vs whole-user scope choice
(#199)
0b84140 is described below
commit 0b84140827946c0b504bf2ea4945c0b403811184
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun May 17 21:39:01 2026 +0200
feat(setup-isolated): per-project vs whole-user scope choice (#199)
setup-isolated-setup-install now lets the operator choose between
per-project scope (the existing behavior — configure the current
adopter repo only) and whole-user scope (configure every
Claude-Code-aware git repo on the host, existing and future, via
a global core.hooksPath hook).
What landed:
- tools/agent-isolation/git-global-post-checkout.sh: new universal
post-checkout hook installed at ~/.claude/git-hooks/post-checkout
under whole-user scope. Calls setup-steward verify --auto-fix-symlinks
for steward-adopted repos and sandbox-add-project-root.sh for any
project with a .claude/ directory. Best-effort, idempotent, never
fails the git operation.
- setup-isolated-setup-install Step P restructured:
- P.0 asks per-project vs whole-user up front (single-select).
- P.0a (whole-user only) surfaces a loud disclosure of the
core.hooksPath shadowing trade-off and requires explicit
acknowledgement.
- P.1 installs the script (both scopes).
- P.2 runs the helper for the current project (per-project).
- P.2-whole-user walks the operator's existing checkouts under
prompted root dirs (defaults: ~/code, ~/projects, ~/dev,
~/work; depth-limited find with noise exclusions) and writes
settings.local.json in each. Settings-only — no per-repo hook
installs.
- P.3-whole-user installs the global post-checkout +
git config --global core.hooksPath ~/.claude/git-hooks/.
- setup-isolated-setup-verify Check 8: scope-detection sub-check
reads git config --global core.hooksPath; verifies global
post-checkout presence + drift if whole-user scope is detected.
Surfaces an informational note every run when whole-user scope
is in effect.
- setup-isolated-setup-update: drift-check coverage extended to
include ~/.claude/git-hooks/post-checkout when whole-user scope
is detected.
- docs/setup/secure-agent-setup.md: new subsection
"Per-project vs whole-user scope" with the comparison table,
the core.hooksPath shadowing trade-off, when-to-pick-which
guidance, and the reversal command.
- tools/agent-isolation/README.md: row for the new helper.
Generated-by: Claude Code (Opus 4.7)
---
.../skills/setup-isolated-setup-install/SKILL.md | 230 +++++++++++++++++----
.../skills/setup-isolated-setup-update/SKILL.md | 16 +-
.../skills/setup-isolated-setup-verify/SKILL.md | 32 +++
docs/setup/secure-agent-setup.md | 62 ++++++
tools/agent-isolation/README.md | 1 +
tools/agent-isolation/git-global-post-checkout.sh | 93 +++++++++
6 files changed, 393 insertions(+), 41 deletions(-)
diff --git a/.claude/skills/setup-isolated-setup-install/SKILL.md
b/.claude/skills/setup-isolated-setup-install/SKILL.md
index 2d3a836..09c5148 100644
--- a/.claude/skills/setup-isolated-setup-install/SKILL.md
+++ b/.claude/skills/setup-isolated-setup-install/SKILL.md
@@ -160,44 +160,198 @@ 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.
+#### Step P.0 — Ask the user: per-project or whole-user scope?
+
+Before installing anything, ask the operator which scope they
+want — see
+[`docs/setup/secure-agent-setup.md` → *Per-project vs whole-user
scope*](../../../docs/setup/secure-agent-setup.md#per-project-vs-whole-user-scope)
+for the full rationale + trade-offs:
+
+- **Per-project (default).** The skill runs the helper for the
+ current project only. Future projects on this host need the
+ skill re-run in each, OR the operator can pick whole-user later.
+ No global git config changes.
+- **Whole-user.** The skill walks the operator's existing local
+ git checkouts and populates their `settings.local.json` files,
+ then sets `git config --global core.hooksPath` so every future
+ `git checkout` / `git clone` / `git worktree add` on the host
+ picks up the framework's universal post-checkout hook.
+
+**Prefer structured Q&A.** When the agent harness offers a
+structured-question tool (e.g. Claude Code's `AskUserQuestion`),
+use a single-select prompt with `Per-project` as the default and
+`Whole-user (with caveats)` as the alternative. Free-form chat is
+the fallback.
+
+If the user picks **per-project**, skip to *Step P.1 — Install
+the helper script* and *Step P.2 — Run the helper for this project*,
+then move on to the next step in the canonical install list.
+
+If the user picks **whole-user**, follow Step P.0a's loud
+disclosure first, then Step P.1, then Steps P.2-whole-user (walk
+existing checkouts) and P.3-whole-user (install the global hook
++ set `core.hooksPath`).
+
+##### Step P.0a — Loud disclosure before setting whole-user scope
+
+If the operator picked whole-user, surface this disclosure
+**before** any global config write. The operator must
+acknowledge it explicitly; no silent proceed:
+
+> **!!! WHOLE-USER SCOPE — `core.hooksPath` GLOBAL OVERRIDE !!!**
+>
+> Setting `git config --global core.hooksPath` makes git look up
+> hooks in **one shared directory** for every repo on this host.
+> Every `.git/hooks/*` in every existing repo on this machine
+> becomes **inert** — git will no longer fire your per-repo
+> `pre-commit`, `commit-msg`, `pre-push`, or any other hook
+> unless you migrate it into the shared dir.
+>
+> The framework installs **only** the `post-checkout` hook in the
+> shared dir. If you rely on per-repo hooks today (formatters,
+> linters, CI integration), you need to:
+>
+> - Either migrate them into `~/.claude/git-hooks/` so they fire
+> alongside the framework's `post-checkout`, **or**
+> - Pick **per-project** scope instead and re-run this skill in
+> each project you adopt.
+>
+> Whole-user scope is reversible: `git config --global --unset core.hooksPath`
+> restores per-repo hook lookup.
+
+Confirm the operator wants to proceed with whole-user scope after
+reading the disclosure. If they hesitate or pick per-project,
+fall back to the per-project path.
+
+#### Step P.1 — Install the helper script
+
+(Both scopes.)
+
+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).
+#### Step P.2 — Run the helper for this project (per-project scope)
+
+Skip if the operator picked whole-user scope (Step P.2-whole-user
+below covers the equivalent).
+
+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.
+
+#### Step P.2-whole-user — Walk existing checkouts (whole-user scope)
+
+Skip if the operator picked per-project scope.
+
+Walk the operator's existing git checkouts and populate each
+one's `.claude/settings.local.json`. This pass is **settings-only**
+by default — it does NOT install per-repo `post-checkout` hooks
+(the global hook installed in Step P.3-whole-user covers that
+for both existing and future repos via `core.hooksPath`).
+
+1. **Prompt the operator for root directories to scan.**
+ Default suggestions: `~/code/`, `~/projects/`, `~/dev/`,
+ `~/work/`. Show the operator the list, let them edit it.
+ Empty list → skip the walk; the operator can re-run this
+ skill later when they want existing repos covered.
+
+2. **Walk each root dir.** Use a depth-limited `find` with
+ reasonable exclusions:
+
+ ```bash
+ find "<root>" -maxdepth 5 -type d -name .git \
+ -not -path '*/node_modules/*' \
+ -not -path '*/.venv/*' \
+ -not -path '*/__pycache__/*' \
+ -not -path '*/build/*' \
+ -not -path '*/dist/*' \
+ -not -path '*/.cache/*' \
+ -prune
+ ```
+
+ For each `.git/` found, the parent dir is a working tree.
+ De-duplicate by canonical path.
+
+3. **For each working tree found, run the helper with
+ `--all-worktrees`.** Same invocation as the per-project
+ variant — the helper itself handles `git worktree list` so
+ linked worktrees of the same repo get processed. The helper's
+ built-in `git check-ignore` guard skips repos whose
+ `.claude/settings.local.json` is not gitignored (defense in
+ depth — the operator should fix the `.gitignore` first).
+
+4. **Tabulate the result** for the operator: how many checkouts
+ were scanned, how many had `.claude/` (so the helper wrote),
+ how many were skipped (no `.claude/` directory, or
+ `.claude/settings.local.json` not gitignored).
+
+5. **Do not install per-repo `post-checkout` hooks** during this
+ pass. The next sub-step covers future and existing repos
+ uniformly via the global hook.
+
+#### Step P.3-whole-user — Install the global post-checkout hook (whole-user
scope)
+
+Skip if the operator picked per-project scope.
+
+1. **Install the universal `post-checkout` hook.** Copy
+ `tools/agent-isolation/git-global-post-checkout.sh` into
+ `~/.claude/git-hooks/post-checkout` (or symlink it from
+ `~/.claude-config/git-hooks/post-checkout` if the operator
+ uses the private sync repo), mode `0755`. The hook content is
+ in
+
[`tools/agent-isolation/git-global-post-checkout.sh`](../../../tools/agent-isolation/git-global-post-checkout.sh)
—
+ it calls the sandbox-allowlist helper and (for steward-adopted
+ repos) `setup-steward verify --auto-fix-symlinks`.
+
+2. **Set `core.hooksPath` globally** so every git operation across
+ every repo on the host uses the shared hook dir:
+
+ ```bash
+ git config --global core.hooksPath "$HOME/.claude/git-hooks"
+ ```
+
+ Surface the resulting `git config --global --get core.hooksPath`
+ value to the operator to confirm the write.
+
+3. **Reiterate the implication** from Step P.0a's disclosure:
+ per-repo `.git/hooks/*` are now inert across the entire host.
+ The operator must migrate any per-repo hooks they want to keep.
+ `git config --global --unset core.hooksPath` is the reversal.
+
+After this step, future `git clone`, `git worktree add`, and
+`git checkout` operations anywhere on the host invoke the
+framework's universal post-checkout, which keeps each
+Claude-Code-aware project's `.claude/settings.local.json` in
+sync without any further operator action.
The `.` entry stays in the committed project-scope `allowRead`
regardless — the explicit absolute path in
diff --git a/.claude/skills/setup-isolated-setup-update/SKILL.md
b/.claude/skills/setup-isolated-setup-update/SKILL.md
index 8736c26..14d55b0 100644
--- a/.claude/skills/setup-isolated-setup-update/SKILL.md
+++ b/.claude/skills/setup-isolated-setup-update/SKILL.md
@@ -126,9 +126,19 @@ Walk each:
`~/.claude/scripts/sandbox-status-line.sh` or whatever the
user's actual statusLine command resolves to,
`~/.claude/agent-isolation/claude-iso.sh` for the global
- wrapper install), `diff` the user copy against the framework's
- source-of-truth in `tools/agent-isolation/`. Report any drift
- as a unified diff; do not re-`cp`.
+ wrapper install,
+ `~/.claude/scripts/sandbox-add-project-root.sh` for the
+ issue-#197 project-root helper, **and** —
+ *only when whole-user scope is in effect, detected via
+ `git config --global --get core.hooksPath` resolving to
+ `~/.claude/git-hooks`* —
+ `~/.claude/git-hooks/post-checkout` for the universal
+ post-checkout hook), `diff` the user copy against the
+ framework's source-of-truth in `tools/agent-isolation/`.
+ Report any drift as a unified diff; do not re-`cp`. The
+ re-install path for each is
+ [`setup-isolated-setup-install`](../setup-isolated-setup-install/SKILL.md)
+ re-run on the affected Step P sub-step.
4. **Settings.json shape drift.** Diff the user's project
`.claude/settings.json` against the framework's dogfooded
one — the framework occasionally adds new `denyRead` paths
diff --git a/.claude/skills/setup-isolated-setup-verify/SKILL.md
b/.claude/skills/setup-isolated-setup-verify/SKILL.md
index 1404d32..d4382cd 100644
--- a/.claude/skills/setup-isolated-setup-verify/SKILL.md
+++ b/.claude/skills/setup-isolated-setup-verify/SKILL.md
@@ -185,6 +185,38 @@ Walk each in order:
[`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.
+ **Scope detection (per-project vs whole-user).** The install
+ skill offers two scopes. Detect which one is in effect:
+
+ ```bash
+ git config --global --get core.hooksPath
+ ```
+
+ If the output equals `$HOME/.claude/git-hooks` (or its tilde-
+ resolved form), the operator is in **whole-user** scope:
+
+ - ✓ if `~/.claude/git-hooks/post-checkout` exists, is
+ executable, and matches the framework's
+ `tools/agent-isolation/git-global-post-checkout.sh` content.
+ - ⚠ if the hook is missing or non-executable — the `core.hooksPath`
+ pointer is set but the hook content is gone. Remediation:
+ re-run `/setup-isolated-setup-install` Step P.3-whole-user,
+ or `/setup-isolated-setup-update` to refresh the script copy.
+ - ⚠ if the hook content drifted from the framework's source-of-
+ truth — surface the diff, propose `/setup-isolated-setup-update`.
+ - **Loud reminder** (every run, not a ✗): when in whole-user
+ scope, surface a one-line note that per-repo `.git/hooks/*`
+ are inert across the host (per [`docs/setup/secure-agent-setup.md` →
*Per-project vs whole-user
scope*](../../../docs/setup/secure-agent-setup.md#per-project-vs-whole-user-scope)).
+ This is informational, not a failure — the operator chose it
+ deliberately during install. Surface so a future self
+ debugging "why didn't my pre-commit fire" recognises the
+ cause.
+
+ If `core.hooksPath` is unset (or points elsewhere), the
+ operator is in **per-project** scope (the default). No further
+ sub-check needed — the per-project mode is fully covered by
+ the static + live-probe checks above.
+
## After the report
If every check is ✓, say so explicitly and stop — no further
diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index 4d5c8a2..ef60f96 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -17,6 +17,7 @@
- [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)
+ - [Per-project vs whole-user scope](#per-project-vs-whole-user-scope)
- [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)
@@ -621,6 +622,67 @@ The verification surface:
Check 8b — static cross-check that the current worktree's
abs path is in its own `.claude/settings.local.json`.
+### Per-project vs whole-user scope
+
+[`setup-isolated-setup-install`](../../.claude/skills/setup-isolated-setup-install/SKILL.md)
+offers two scopes for the project-root sandbox-allowlist setup.
+The operator picks one during install; both are reversible.
+
+| Scope | What it covers | Mechanism | Reversal |
+|---|---|---|---|
+| **Per-project** (default) | The single adopter repo the operator is sitting
in when running the install skill. Each subsequent adopter project needs the
install skill re-run there. | The helper runs once with `--all-worktrees`
against the current repo; nothing global is touched. The per-repo
`post-checkout` hook (installed by `/setup-steward adopt` in steward-adopted
repos) chains into the helper on future `git checkout` operations within that
repo. | None needed — per-project scope is [...]
+| **Whole-user** | Every git repo on the operator's host, existing and future.
Includes non-steward Claude-Code-aware projects (any project with a `.claude/`
directory). | Walks the operator's existing checkouts under prompted root dirs
and writes each one's `settings.local.json`; sets `git config --global
core.hooksPath ~/.claude/git-hooks/` and installs the universal
[`git-global-post-checkout.sh`](../../tools/agent-isolation/git-global-post-checkout.sh)
there. | `git config --global - [...]
+
+#### Important trade-off — `core.hooksPath` shadows per-repo hooks
+
+When `core.hooksPath` is set globally, git looks up hooks **only**
+in that directory for every repo on the host. Every per-repo
+`<repo>/.git/hooks/*` becomes inert across the host. If the
+operator has hooks they care about (pre-commit formatters,
+commit-msg linters, pre-push gates, project-specific
+post-checkout actions), those will no longer fire after whole-user
+scope is set, unless the operator migrates them into
+`~/.claude/git-hooks/`.
+
+The framework installs **only** the `post-checkout` hook in the
+shared dir. Pre-commit / commit-msg / pre-push / other hook types
+need their own files in the shared dir if the operator wants
+them to fire. This is a deliberate trade-off: a single mechanism
+for whole-user coverage at the cost of needing to migrate
+per-repo hooks.
+
+The install skill surfaces this trade-off loudly before setting
+`core.hooksPath` and requires explicit operator acknowledgement.
+See
+[`setup-isolated-setup-install` Step
P.0a](../../.claude/skills/setup-isolated-setup-install/SKILL.md#step-p0a--loud-disclosure-before-setting-whole-user-scope).
+
+#### When to pick which scope
+
+- **Pick per-project** when:
+ - You adopt one or two projects on this host and prefer not to
+ touch global git config.
+ - You have per-repo hooks (pre-commit, commit-msg, etc.) you
+ rely on and do not want shadowed.
+ - You are evaluating apache-steward and have not yet decided
+ whether to commit to the framework.
+
+- **Pick whole-user** when:
+ - You adopt many Claude-Code-aware projects and do not want to
+ re-run the install skill in each.
+ - You add worktrees frequently and want each one's
+ `settings.local.json` auto-populated without per-worktree
+ action.
+ - You do not rely on per-repo hooks (or are prepared to migrate
+ them into the shared dir).
+ - You sync `~/.claude/` across machines via the private dotfile
+ repo (the global config + hook propagates with the sync).
+
+Switching scopes later is non-destructive: the install skill is
+idempotent. Re-running it with a different scope is the supported
+upgrade path. The walking pass under whole-user scope is also a
+one-time bulk operation — once existing checkouts are populated,
+the global `post-checkout` keeps everything aligned going forward.
+
## The clean-env wrapper
Layer 0 — strip credential-shaped env vars from the parent shell
diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md
index 2a7a70a..fb5c015 100644
--- a/tools/agent-isolation/README.md
+++ b/tools/agent-isolation/README.md
@@ -32,6 +32,7 @@ versions.
| [`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 [...]
+| [`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 **whole-user** scope 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.
Two responsibilities (both best-effort + idempotent + `\|\| true`): (1)
`setup-steward verify --auto-fix- [...]
## Usage at a glance
diff --git a/tools/agent-isolation/git-global-post-checkout.sh
b/tools/agent-isolation/git-global-post-checkout.sh
new file mode 100755
index 0000000..ba2990f
--- /dev/null
+++ b/tools/agent-isolation/git-global-post-checkout.sh
@@ -0,0 +1,93 @@
+#!/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.
+#
+# git-global-post-checkout.sh — the universal post-checkout hook
+# installed under ~/.claude/git-hooks/post-checkout when the
+# operator picks **whole-user** scope in
+# `setup-isolated-setup-install`.
+#
+# Activated by:
+# git config --global core.hooksPath ~/.claude/git-hooks/
+#
+# After that, every `git checkout`, `git clone` (which runs an
+# implicit checkout of the default branch), and `git worktree add`
+# across the operator's host invokes this script — for any repo,
+# not just apache-steward adopters.
+#
+# Two responsibilities, both best-effort + idempotent + `|| true`
+# so the hook never breaks the surrounding git operation:
+#
+# 1. **apache-steward symlink reconciliation.** If the working
+# tree carries `.apache-steward.lock`, this is a steward-
+# adopted repo and its gitignored framework-skill symlinks
+# may need re-creating after a checkout (the symlinks point
+# into `.apache-steward/`, which is itself gitignored). The
+# hook calls `/setup-steward verify --auto-fix-symlinks` if
+# that command is on PATH; if not, it falls through silently
+# (the operator may not be in a Claude Code session, or may
+# not have the framework installed beyond this hook).
+#
+# 2. **Sandbox-allowlist for the current worktree.** If the
+# working tree has a `.claude/` directory (i.e. it is
+# Claude-Code-aware) and the framework's
+# `sandbox-add-project-root.sh` helper is installed at
+# `~/.claude/scripts/`, the hook calls the helper to populate
+# `<worktree>/.claude/settings.local.json` with the
+# worktree's absolute path. Defensive against the harness
+# behaviour documented at
+# https://github.com/apache/airflow-steward/issues/197 .
+#
+# IMPORTANT — `core.hooksPath` shadowing. When `core.hooksPath` is
+# set globally, git looks ONLY in that directory for hooks. Every
+# per-repo `<repo>/.git/hooks/*` becomes inert across every repo
+# on the host. If the operator has hooks they care about in
+# specific repos (per-commit linters, pre-push checks, etc.) those
+# need to be migrated into the global hooks dir or invoked from
+# within these global hooks. The install skill warns the operator
+# about this trade-off loudly before setting `core.hooksPath`.
+#
+# This hook only handles `post-checkout`. Other hook types
+# (pre-commit, commit-msg, pre-push, ...) need their own files in
+# the global hooks dir if the operator wants them to fire after
+# setting `core.hooksPath`. The framework does not ship them; it
+# is up to the operator to migrate them.
+
+set -u
+
+# Resolve the current working tree's root. The hook fires inside a
+# git checkout — `git rev-parse --show-toplevel` should always
+# succeed; the `2>/dev/null` is defensive against odd worktree
+# states.
+worktree=$(git rev-parse --show-toplevel 2>/dev/null || true)
+if [ -z "$worktree" ]; then
+ exit 0
+fi
+
+# 1. apache-steward symlink reconciliation
+if [ -f "$worktree/.apache-steward.lock" ] \
+ && command -v /setup-steward >/dev/null 2>&1; then
+ /setup-steward verify --auto-fix-symlinks 2>/dev/null || true
+fi
+
+# 2. Sandbox-allowlist helper
+if [ -x "$HOME/.claude/scripts/sandbox-add-project-root.sh" ] \
+ && [ -d "$worktree/.claude" ]; then
+ "$HOME/.claude/scripts/sandbox-add-project-root.sh" 2>/dev/null || true
+fi
+
+exit 0