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/10/25 16:43:10 UTC

(magpie) branch main updated: feat(agent-isolation): hook-manager contract, with prek as its first adapter (#1819)

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 d774c4155 feat(agent-isolation): hook-manager contract, with prek as its first adapter (#1819)
d774c4155 is described below

commit d774c4155c92d7e5217d9a9075722fc0557bc07a
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Sun Oct 25 17:43:03 2026 +0100

    feat(agent-isolation): hook-manager contract, with prek as its first adapter (#1819)
    
    * feat(agent-isolation): hook-manager contract, with prek as its first adapter
    
    The secure setup's prek integration (the install shim, the sandbox and
    host-only homes, the sandbox-lint checks) becomes one instance of a
    contract for git-hook managers: tools/hook-manager/README.md states the
    install-target, home and detection rules, and registry.json records how
    each manager satisfies them.
    
    - hook-manager-shim.sh replaces prek-shim.sh; it takes the tool from the
      name it is installed under (~/.claude/bin/<tool>) and applies the
      adapter's install strategy (git-dir-flag for prek).
    - The whole-user dispatcher gives each manager the repository uses its
      host-only home, and none to a repository that uses none.
    - sandbox-lint requires env.<HOME> and the default-home deny only for a
      project that uses the manager: a root config file, a tracked nested
      config (prek workspaces), or an installed hook that runs it.
    - tools/dev/sync-hook-managers.py validates the registry and writes it
      into the shim, the dispatcher and sandbox-lint (prek hook).
    - contract:hook-manager joins the label taxonomy, the validator and the
      vendor-neutrality policy.
    
    The contract lists the strategies the other managers need (pre-commit,
    lefthook, husky, overcommit, CaptainHook, and those that need none),
    from a source-level check of each, for follow-up adapters.
    
    Generated-by: Claude Opus 5
    
    * fix(agent-isolation): keep per-user hook-manager protections unconditional
    
    Detection now decides only what is configured for a repository: the
    sandbox home a project's settings must carry, the shim, verify 19d.
    The default-home deny and the dispatcher's host home stay on for every
    registered manager. Both homes are per-user: a project without prek
    dropping the deny lets its sandboxed commands write the environments
    another project's terminal commits run, and a hook that reaches prek
    through a script would otherwise inherit the sandbox home in a
    bypassed command.
    
    Generated-by: Claude Opus 5
---
 .github/labeler.yml                                |   6 +
 .pre-commit-config.yaml                            |  12 +
 docs/labels-and-capabilities.md                    |   2 +
 docs/setup/secure-agent-setup.md                   |  15 +-
 docs/vendor-neutrality.md                          |   3 +-
 .../skills/isolated-setup-install/SKILL.md         |  10 +-
 .../step-p-sandbox-allowlists.md                   |  27 ++-
 .../skills/isolated-setup-verify/SKILL.md          |   6 +-
 .../setup/setup_preflight/isolated_fingerprint.py  |   2 +-
 tools/agent-isolation/README.md                    |   4 +-
 tools/agent-isolation/git-hook-dispatcher.sh       |  71 +++---
 tools/agent-isolation/hook-manager-shim.sh         | 131 +++++++++++
 tools/agent-isolation/prek-shim.sh                 | 105 ---------
 .../tests/test_git_hook_dispatcher.py              |  43 +++-
 .../tests/test_hook_manager_shim.py                | 136 +++++++++++
 tools/dev/README.md                                |   3 +-
 tools/dev/sync-hook-managers.py                    | 249 +++++++++++++++++++++
 tools/dev/tests/test_sync_hook_managers.py         | 170 ++++++++++++++
 tools/hook-manager/README.md                       | 161 +++++++++++++
 tools/hook-manager/registry.json                   |  30 +++
 tools/sandbox-lint/src/sandbox_lint/__init__.py    | 108 +++++++--
 tools/sandbox-lint/tests/test_validator.py         |  63 ++++++
 .../src/skill_and_tool_validator/__init__.py       |   1 +
 tools/spec-loop/specs/agent-isolation-sandbox.md   |  12 +
 .../src/vendor_neutrality_score/__init__.py        |   4 +
 25 files changed, 1189 insertions(+), 185 deletions(-)

diff --git a/.github/labeler.yml b/.github/labeler.yml
index 039856d01..b25e52622 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -465,6 +465,12 @@ contract:cve-authority:
               - 'tools/cve-tool/**'
               - 'tools/cve-tool-vulnogram/**'
 
+contract:hook-manager:
+  - any:
+      - changed-files:
+          - any-glob-to-any-file:
+              - 'tools/hook-manager/**'
+
 contract:mail-archive:
   - any:
       - changed-files:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 53c248a95..85ecd8e7d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -603,6 +603,18 @@ repos:
         entry: tools/dev/check-workspace-members.py
         files: ^((tools/[^/]+(/[^/]+)?|ai-tutors|skills)/pyproject\.toml|pyproject\.toml|.*/tests?/.*\.py|.*/test_[^/]+\.py|.*_test\.py)$
         pass_filenames: false
+  # The hook-manager registry (tools/hook-manager/registry.json) is written
+  # into the shim, the whole-user dispatcher and sandbox-lint as generated
+  # blocks, since none of them can read it where they run; the settings that
+  # give each sandbox home are checked against it, not written.
+  - repo: local
+    hooks:
+      - id: sync-hook-managers
+        name: sync-hook-managers (hook-manager registry -> shim, dispatcher, sandbox-lint)
+        language: system
+        entry: tools/dev/sync-hook-managers.py
+        files: ^(tools/hook-manager/registry\.json|tools/dev/sync-hook-managers\.py|tools/agent-isolation/(hook-manager-shim|git-hook-dispatcher)\.sh|tools/sandbox-lint/src/sandbox_lint/__init__\.py|\.claude/settings\.json|tools/sandbox-lint/expected\.json)$
+        pass_filenames: false
   # Tool-capability PR labels. `.github/labeler.yml` is generated from every
   # tool README's `**Capability:**` line; regenerate it whenever a README or
   # the generator changes, so a new tool is labelled with no hand-edit.
diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md
index 48f23e483..7c0f99ad6 100644
--- a/docs/labels-and-capabilities.md
+++ b/docs/labels-and-capabilities.md
@@ -146,6 +146,7 @@ framework substrate:
 | `contract:people` | contract | Forge / tracker accounts: profile lookup, repository collaborators, team membership (one confirmed write). Distinct from `contract:project-metadata`, which reads an organization's governance rosters. |
 | `contract:security-tracker` | contract | Where the security skills keep embargoed reports: body fields, status rollup, attachments, a confidentiality guard, and a publish step for working-copy backends (GitHub, local files, private Jira). |
 | `contract:body-field` | contract | How the `### <Name>` fields of a tracker issue body are found, read and rewritten: field boundaries, closed code blocks, the refusals, and a language-neutral conformance suite every implementation passes. |
+| `contract:hook-manager` | contract | Git-hook managers (prek, pre-commit, lefthook, husky, …) under the secure agent setup: where a manager's install puts its hook, and how its home is split between sandboxed and host runs. |
 | `substrate:analytics` | substrate | Read-only metrics / dashboards / renderers. |
 | `substrate:sandbox` | substrate | Agent isolation, egress control, settings audit. |
 | `substrate:action-guard` | substrate | Deterministic pre-tool-use command guards. |
@@ -388,6 +389,7 @@ or a contract-free mix of substrates (e.g. `tools/spec-inventory` is
 | [`tools/skill-reconciler-diff`](../tools/skill-reconciler-diff/) | `substrate:framework-dev` | Deterministic structural diff between two skill trees — parses frontmatter, section headings, step inventory, placeholders, support files, and safety-baseline clauses into a JSON diff object for the `skill-reconciler` skill |
 | [`tools/vcs`](../tools/vcs/) | `contract:source-control` | Backend-dispatching implementation of the source-control (VCS) capability ([`tools/github/source-control.md`](../tools/github/source-control.md)); complete Git, Mercurial (Hg), Fossil, and Subversion (SVN) backends |
 | [`tools/sourcehut`](../tools/sourcehut/) | `contract:tracker` + `contract:source-control` + `contract:mail-archive` | SourceHut (sr.ht) forge bridge: todo.sr.ht, lists.sr.ht, builds.sr.ht, and git/hg repository reads |
+| [`tools/hook-manager`](../tools/hook-manager/) | `contract:hook-manager` | Contract for git-hook managers under the secure agent setup: the install-target and home rules, and the registry of adapters (prek today) that the hook-manager shim, the whole-user dispatcher and `sandbox-lint` are generated from |
 | [`tools/tracker`](../tools/tracker/) | `contract:tracker` | Interface spec for the tracker contract's read-only contributor-activity queries (issues filed, triaged and commented on; issues created; first maintainer reply). Pure interface spec; GitHub and Jira implement it, and the rest of the contract is described by `tools/github/tool.md` |
 | [`tools/body-field-contract`](../tools/body-field-contract/) | `contract:body-field` | The body-field contract: how every reader and writer finds a tracker body's `### <Name>` fields, with a language-neutral conformance suite run against every implementation. Pure interface spec; implementations: `tools/github-body-field/` (reference), `tools/vetted-ops/`, `tools/local-tracker/`, `tools/jira/` |
 | [`tools/security-tracker`](../tools/security-tracker/) | `contract:security-tracker` | Adapter contract for the security tracker the `security-*` skills read and write, selected by `tracker.platform`. Pure interface spec; backends: `tools/github/` (default), `tools/local-tracker/`, `tools/jira/` |
diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index dc65aca1d..4e30c6b13 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -1050,7 +1050,7 @@ uv tool upgrade --all --reinstall
 
 The old `~/.cache/uv/` stays as the sandbox's cache; the host never reads it again.
 
-**prek has the same problem.** The git hooks of a commit you make in a terminal run prek outside the sandbox, from hook environments under its home: `~/.cache/prek/` by default, which the `~/.cache/` grant makes sandbox-writable. Nothing excluded from the sandbox runs prek, so the settings `env` (which only Claude Code's own commands see, never your terminal) does the split: `"PREK_HOME": "~/.cache/prek-sandbox"` gives sandboxed prek a home of its own, and `Edit(~/.cache/prek/**)` keeps  [...]
+**prek has the same problem.** The git hooks of a commit you make in a terminal run prek outside the sandbox, from hook environments under its home: `~/.cache/prek/` by default, which the `~/.cache/` grant makes sandbox-writable. Nothing excluded from the sandbox runs prek, so the settings `env` (which only Claude Code's own commands see, never your terminal) does the split: `"PREK_HOME": "~/.cache/prek-sandbox"` gives sandboxed prek a home of its own, and `Edit(~/.cache/prek/**)` keeps  [...]
 
 The `env` also reaches a command Claude Code runs *outside* the sandbox (an approved bypass, often a signed commit). The whole-user [git-hook dispatcher](#whole-user-with-the-per-repo-dispatcher) covers that: a hook it runs with no `SANDBOX_RUNTIME` in the environment gets `PREK_HOME=~/.local/share/prek-host` and a host-only `XDG_CACHE_HOME` (for the pip, go, npm and uv caches the hooks use), whatever the env said. It only runs when git reaches it: a repository-level `core.hooksPath` (so [...]
 
@@ -1415,24 +1415,29 @@ original argv + inherited stdin, so a failing local `pre-commit` /
 `pre-push` still aborts the git operation. A repo with **no** local
 hook is a clean no-op.
 
-**The `prek` PATH shim.** prek honours `core.hooksPath`, so a bare
+**The hook-manager PATH shim.** prek honours `core.hooksPath`, so a bare
 `prek install` under whole-user scope would write its shim into the
 *shared* dir, not the repo. The framework ships a transparent shim
-([`tools/agent-isolation/prek-shim.sh`](../../tools/agent-isolation/prek-shim.sh),
+([`tools/agent-isolation/hook-manager-shim.sh`](../../tools/agent-isolation/hook-manager-shim.sh),
 installed as `~/.claude/bin/prek` with `~/.claude/bin` prepended to
 PATH) that rewrites **only** `prek install` — injecting
 `--git-dir "$(git rev-parse --git-common-dir)"` so the shim lands
 in the repo-local `.git/hooks/` where the dispatcher finds it.
 Every other `prek` command passes straight through, and the shim is
 a no-op on hosts with no global `core.hooksPath`.
+The shim, the host-only homes and the sandbox-lint checks all follow the
+[hook-manager contract](../../tools/hook-manager/README.md):
+one script serves every adapter in its registry, keyed by the name it is
+installed under, and other hook managers (pre-commit, lefthook, husky, …)
+join by adding an adapter there.
 
 Validated behaviour under this flavour (`core.hooksPath` global +
-dispatchers + prek shim):
+dispatchers + hook-manager shim):
 
 | Repo's local hook | Result |
 |---|---|
 | prek shim in `.git/hooks/` (installed via the shim) | dispatcher → prek runs that repo's `.pre-commit-config.yaml` |
-| hand-written / husky / lefthook `.git/hooks/<name>` | dispatcher → that hook runs (heterogeneous tooling coexists) |
+| hand-written `.git/hooks/<name>`, or one a manager without an adapter put there | dispatcher → that hook runs (heterogeneous tooling coexists) |
 | none | dispatcher no-op; git operation proceeds cleanly |
 | local hook exits non-zero | exit code propagates; git operation aborts |
 | `post-checkout` | framework sandbox sync **and** repo-local `post-checkout` both run |
diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md
index 60290e08b..ca97fc546 100644
--- a/docs/vendor-neutrality.md
+++ b/docs/vendor-neutrality.md
@@ -581,7 +581,7 @@ generated block below.
 
 <!-- BEGIN vendor-neutrality-score — generated by `uv run --project tools/vendor-neutrality-score vendor-neutrality-score --markdown`; do not edit by hand -->
 
-**Overall vendor-neutrality score: 16/17 capability contracts (94%).** Generated by [`tools/vendor-neutrality-score`](../tools/vendor-neutrality-score/); re-run it to refresh this section.
+**Overall vendor-neutrality score: 16/18 capability contracts (89%).** Generated by [`tools/vendor-neutrality-score`](../tools/vendor-neutrality-score/); re-run it to refresh this section.
 
 | Capability contract | Neutral? | Class | Backends today | Basis |
 |---|---|---|---|---|
@@ -602,6 +602,7 @@ generated block below.
 | `contract:people` | ✅ | vendor-backed | Atlassian, GitHub | 2 backend vendors: Atlassian, GitHub |
 | `contract:security-tracker` | ✅ | vendor-backed | Atlassian, agnostic | 2 backend vendors: Atlassian, agnostic; partial foundation, not counted: asf-security-dash |
 | `contract:body-field` | ✅ | agnostic | Atlassian, GitHub, agnostic | vendor-neutral by construction — one spec serves every backend |
+| `contract:hook-manager` | ❌ | vendor-backed | — | no backend implemented yet |
 
 **Per-skill assessment: 83/83 skills carry no vendor lock-in.** A skill is *capability-pure* when it names no backend at all, *portable* when every backend it names has an alternative (its contract is green), and *vendor-coupled* only when it reaches for a backend that is the sole implementation of a capability.
 
diff --git a/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md
index c1b8da99a..0757836d4 100644
--- a/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md
+++ b/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md
@@ -15,9 +15,9 @@ when_to_use: >-
   If it is already installed, use `setup-isolated-setup-verify` to check
   it or `setup-isolated-setup-update` to refresh it.
 capability: capability:platform
-surface_hash: sha256:6358702510d21c26
+surface_hash: sha256:d33e1ff373860d4b
 license: Apache-2.0
-measured_tokens: 8036
+measured_tokens: 8116
 ---
 
 <!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -491,7 +491,11 @@ can write:
   framework's `.claude/settings.json`, so Step 2's copy brings them). Sandboxed
   uv then keeps its default, writable cache.
 
-- **Sandboxed prek gets its own home.** The settings carry
+- **Sandboxed prek gets its own home.** The `env` entry is needed only where a
+  repository uses prek (a `prek.toml`, `.pre-commit-config.yaml` or
+  `.pre-commit-config.yml`, or a hook that runs it:
+  [detection](../../../../tools/hook-manager/README.md#detection)); the deny
+  is always kept, since the default home is per-user. The settings carry
   `"env": {"PREK_HOME": "~/.cache/prek-sandbox"}` and `permissions.deny`
   carries `Edit(~/.cache/prek/**)` (both in the framework's
   `.claude/settings.json`): a terminal's git hooks run prek outside the
diff --git a/plugins/magpie-setup/skills/isolated-setup-install/step-p-sandbox-allowlists.md b/plugins/magpie-setup/skills/isolated-setup-install/step-p-sandbox-allowlists.md
index e4a88a065..12edb7909 100644
--- a/plugins/magpie-setup/skills/isolated-setup-install/step-p-sandbox-allowlists.md
+++ b/plugins/magpie-setup/skills/isolated-setup-install/step-p-sandbox-allowlists.md
@@ -74,7 +74,7 @@ disclosure first, then Step P.0b (pick the whole-user *flavour* —
 simple vs dispatcher), 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`). If they chose the dispatcher flavour,
-also run Step P.3b-whole-user (install the dispatcher + prek shim).
+also run Step P.3b-whole-user (install the dispatcher + hook-manager shim).
 
 #### Step P.0a — Loud disclosure before setting whole-user scope
 
@@ -137,7 +137,7 @@ footprint and confirms they run no per-repo hooks.
   P.3b-whole-user additionally installs `git-hook-dispatcher.sh`
   (symlinked to each hook name, including `post-checkout` — which
   in this flavour supersedes the standalone `git-global-post-checkout.sh`)
-  and the `prek` PATH shim.
+  and the hook-manager PATH shim.
 
 ### Step P.1 — Install the helper script
 
@@ -292,7 +292,7 @@ framework's universal post-checkout, which keeps each
 Claude-Code-aware project's `.claude/settings.local.json` in
 sync without any further operator action.
 
-### Step P.3b-whole-user — Install the per-repo dispatcher + prek shim (dispatcher flavour only)
+### Step P.3b-whole-user — Install the per-repo dispatcher + hook-manager shim (dispatcher flavour only)
 
 Skip unless the operator picked the **dispatcher** flavour in
 Step P.0b. This is what keeps the operator's per-repo hooks (prek,
@@ -326,18 +326,27 @@ for the full rationale + the validated behaviour matrix.
    with the original argv + inherited stdin, so a failing local
    `pre-commit` / `pre-push` still aborts the git operation.
 
-2. **Install the `prek` PATH shim** so `prek install` writes its
+2. **Install the hook-manager PATH shim** so `prek install` writes its
    shim into the repo-local `.git/hooks/` (where the dispatcher
    chains) instead of the shared dir. Copy
-   [`tools/agent-isolation/prek-shim.sh`](../../../../tools/agent-isolation/prek-shim.sh)
-   into `~/.claude/bin/prek` (or symlink from
-   `~/.claude-config/bin/prek`), mode `0755`. The shim rewrites
-   only `prek install` (injecting
+   [`tools/agent-isolation/hook-manager-shim.sh`](../../../../tools/agent-isolation/hook-manager-shim.sh)
+   into `~/.claude/bin/<tool>` (or symlink from
+   `~/.claude-config/bin/<tool>`), mode `0755`, once for each adapter in the
+   [hook-manager registry](../../../../tools/hook-manager/registry.json)
+   that a repository the operator works in uses — `~/.claude/bin/prek` today.
+   A repository uses a manager when one of its config files is at the root
+   (or tracked below it, for prek's nested projects) or one of its installed
+   hooks runs it ([Detection](../../../../tools/hook-manager/README.md#detection));
+   for prek that is `prek.toml`, `.pre-commit-config.yaml` or
+   `.pre-commit-config.yml`. Install no shim for a manager no repository uses. The shim
+   takes the tool from the name it is installed under. For prek it
+   rewrites only `prek install` (injecting
    `--git-dir "$(git rev-parse --git-common-dir)"` unless the caller
    already passed `--git-dir`, asked for `--help`, or is outside a
    git work tree); **every other `prek` invocation passes through
    unchanged**. It is a no-op on hosts with no global
-   `core.hooksPath`.
+   `core.hooksPath`. A host that installed the older `prek-shim.sh`
+   as `~/.claude/bin/prek` keeps working; replacing it is optional.
 
 3. **Surface the PATH line for the operator to add** to their
    `~/.bashrc` / `~/.zshrc` (per the golden rules — never edit the
diff --git a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md
index a3768180f..5c7f3b856 100644
--- a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md
+++ b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md
@@ -18,7 +18,7 @@ when_to_use: >-
 capability: capability:platform
 surface_hash: sha256:ce33cb9437d44992
 license: Apache-2.0
-measured_tokens: 7317
+measured_tokens: 7351
 ---
 
 <!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -249,7 +249,9 @@ Walk each in order:
       `Read(~/.config/uv/uv.toml)` and `Edit(~/.config/uv/**)`, and a
       sandboxed `uv cache dir` prints uv's default cache, not 19b's. Printing
       19b's path is ✗: the sandbox reads the config and writes the host cache.
-    - **19d — sandboxed prek has its own home.** The merged settings carry
+    - **19d — sandboxed prek has its own home.** When the project does not
+      use prek ([detection](../../../../tools/hook-manager/README.md#detection)),
+      only the deny below is checked. The merged settings carry
       `env.PREK_HOME` outside `~/.cache/prek` (`~/.cache/prek-sandbox` in the
       reference) and `permissions.deny` carries `Edit(~/.cache/prek/**)`; a
       sandboxed write probe into `~/.cache/prek` fails. Either missing is ✗: a
diff --git a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py
index 7d1e70b77..5c52022ab 100644
--- a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py
+++ b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py
@@ -21,4 +21,4 @@ The isolated-setup fingerprint of the framework this package shipped with,
 for installs that do not carry the framework source (see `isolated.py`).
 """
 
-FRAMEWORK_FINGERPRINT = "sha256:8e11389827351820"
+FRAMEWORK_FINGERPRINT = "sha256:a19871219995dc10"
diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md
index 4cce89429..89ed72b87 100644
--- a/tools/agent-isolation/README.md
+++ b/tools/agent-isolation/README.md
@@ -94,8 +94,8 @@ per runtime — see [`docs/adapters/add-a-harness.md`](../../docs/adapters/add-a
 | [`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 [...]
-| [`git-hook-dispatcher.sh`](git-hook-dispatcher.sh) | Universal, basename-keyed git-hook **dispatcher** for the **whole-user dispatcher** flavour. Symlinked to every hook name under `~/.claude/git-hooks/`. For each git operation it runs the framework's own logic for that hook type (the `post-checkout` sandbox-allowlist sync) **and then chains through to the repo-local `.git/hooks/<name>`** (resolved via `git rev-parse --git-common-dir`, worktree-safe; `exec`-ed with original argv + inhe [...]
-| [`prek-shim.sh`](prek-shim.sh) | Transparent `prek` front installed as `~/.claude/bin/prek` (with `~/.claude/bin` prepended to PATH) for the whole-user dispatcher flavour. Rewrites **only** `prek install` — injecting `--git-dir "$(git rev-parse --git-common-dir)"` unless the caller already passed `--git-dir`, asked for `--help`, or is outside a git work tree — so prek's shim lands in the repo-local `.git/hooks/` where the dispatcher chains, instead of the shared `core.hooksPath` dir. E [...]
+| [`git-hook-dispatcher.sh`](git-hook-dispatcher.sh) | Universal, basename-keyed git-hook **dispatcher** for the **whole-user dispatcher** flavour. Symlinked to every hook name under `~/.claude/git-hooks/`. For each git operation it runs the framework's own logic for that hook type (the `post-checkout` sandbox-allowlist sync) **and then chains through to the repo-local `.git/hooks/<name>`** (resolved via `git rev-parse --git-common-dir`, worktree-safe; `exec`-ed with original argv + inhe [...]
+| [`hook-manager-shim.sh`](hook-manager-shim.sh) | Transparent front for a git-hook manager, installed as `~/.claude/bin/<tool>` (with `~/.claude/bin` prepended to PATH) for the whole-user dispatcher flavour, one install per adapter in the [hook-manager registry](../hook-manager/registry.json) (`prek` today). The tool is the name it is installed under. Rewrites **only** the adapter's install subcommand so the hook lands in the repo-local `.git/hooks/` where the dispatcher chains, instead [...]
 
 ## Usage at a glance
 
diff --git a/tools/agent-isolation/git-hook-dispatcher.sh b/tools/agent-isolation/git-hook-dispatcher.sh
index eca9972e2..11fa36a61 100755
--- a/tools/agent-isolation/git-hook-dispatcher.sh
+++ b/tools/agent-isolation/git-hook-dispatcher.sh
@@ -48,7 +48,7 @@
 # can keep using prek, husky, lefthook, or a hand-written hook in
 # its own `.git/hooks/` — installed there via, e.g.,
 # `prek install --git-dir "$(git rev-parse --git-common-dir)"`
-# (the bundled `prek-shim.sh` injects that flag automatically) —
+# (the bundled `hook-manager-shim.sh` injects that flag automatically) —
 # and the dispatcher will invoke it, while the framework's global
 # post-checkout sandbox-allowlist sync still runs everywhere.
 #
@@ -94,35 +94,6 @@ run_framework() {
   esac
 }
 
-# --- prek's home outside the sandbox --------------------------------------
-# prek runs a hook from environments under its home, ~/.cache/prek by default,
-# which the sandbox's ~/.cache grant makes writable. A hook run outside the
-# sandbox (a commit from a terminal, or one Claude Code ran with the sandbox
-# bypassed) must not run what a sandboxed command wrote there, so outside the
-# sandbox (no SANDBOX_RUNTIME) prek gets a home of its own under
-# ~/.local/share, which no sandbox grant covers, and so does XDG_CACHE_HOME.
-# Sandboxed hooks keep the defaults. An operator's own value is kept unless it
-# sits under ~/.cache.
-# The selection need not be trusted: sandboxed code that unsets
-# SANDBOX_RUNTIME only makes its own prek fail to write the host home.
-if [ -z "${SANDBOX_RUNTIME:-}" ]; then
-  case "${PREK_HOME:-}" in
-    ""|"~/.cache"|"~/.cache/"*|"$HOME/.cache"|"$HOME/.cache/"*)
-      export PREK_HOME="$HOME/.local/share/prek-host"
-      ;;
-  esac
-  # The tools a hook calls keep caches of their own (pip, go, npm and uv
-  # without a configured cache-dir follow XDG_CACHE_HOME, ~/.cache by default
-  # on Linux), and installing from a cache the sandbox wrote is the same
-  # escape. Same rule: a host-only cache root unless the operator chose one
-  # outside ~/.cache.
-  case "${XDG_CACHE_HOME:-}" in
-    ""|"~/.cache"|"~/.cache/"*|"$HOME/.cache"|"$HOME/.cache/"*)
-      export XDG_CACHE_HOME="$HOME/.local/share/prek-host/cache"
-      ;;
-  esac
-fi
-
 # --- resolve the repo-local hook (worktree-safe) ---------------------------
 common="$(git rev-parse --git-common-dir 2>/dev/null || echo .git)"
 case "$common" in
@@ -141,6 +112,46 @@ if [ -x "$local_hook" ] && [ "$lh_canon" != "$self_canon" ]; then
   has_local=1
 fi
 
+# --- hook managers' homes outside the sandbox -----------------------------
+# A hook manager (prek, ...: tools/hook-manager/registry.json) runs a hook from
+# environments under its home, ~/.cache/<tool> by default, which the sandbox's
+# ~/.cache grant makes writable. A hook run outside the sandbox (a commit from
+# a terminal, or one Claude Code ran with the sandbox bypassed) must not run
+# what a sandboxed command wrote there, so outside the sandbox (no
+# SANDBOX_RUNTIME) every registered manager gets a home of its own under
+# ~/.local/share, which no sandbox grant covers, and so does XDG_CACHE_HOME.
+# Every manager, not only those the repository is detected to use: a hook can
+# reach a manager the detection does not see (through a script), and the
+# settings' sandbox home would then reach it; an unused variable is harmless.
+# Sandboxed hooks keep the defaults. An operator's own value is kept unless it
+# sits under ~/.cache.
+# The selection need not be trusted: sandboxed code that unsets
+# SANDBOX_RUNTIME only makes its own hook manager fail to write the host home.
+# Entries: <home var>=<host dir under ~/.local/share>
+# BEGIN generated by tools/dev/sync-hook-managers.py; do not edit
+hook_manager_homes="PREK_HOME=prek-host"
+# END generated by tools/dev/sync-hook-managers.py
+if [ -z "${SANDBOX_RUNTIME:-}" ]; then
+  for _entry in $hook_manager_homes; do
+    _var="${_entry%%=*}"
+    case "${!_var:-}" in
+      ""|"~/.cache"|"~/.cache/"*|"$HOME/.cache"|"$HOME/.cache/"*)
+        export "$_var=$HOME/.local/share/${_entry#*=}"
+        ;;
+    esac
+  done
+  # The tools any hook calls keep caches of their own (pip, go, npm and uv
+  # without a configured cache-dir follow XDG_CACHE_HOME, ~/.cache by default
+  # on Linux), and installing from a cache the sandbox wrote is the same
+  # escape. Same rule: a host-only cache root
+  # unless the operator chose one outside ~/.cache.
+  case "${XDG_CACHE_HOME:-}" in
+    ""|"~/.cache"|"~/.cache/"*|"$HOME/.cache"|"$HOME/.cache/"*)
+      export XDG_CACHE_HOME="$HOME/.local/share/prek-host/cache"
+      ;;
+  esac
+fi
+
 # --- non-post-checkout: exec local for a perfect passthrough ---------------
 if [ "$hook" != "post-checkout" ]; then
   if [ "$has_local" = 1 ]; then
diff --git a/tools/agent-isolation/hook-manager-shim.sh b/tools/agent-isolation/hook-manager-shim.sh
new file mode 100755
index 000000000..ed4a83a41
--- /dev/null
+++ b/tools/agent-isolation/hook-manager-shim.sh
@@ -0,0 +1,131 @@
+#!/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.
+#
+# hook-manager-shim.sh — a transparent front for a git-hook manager
+# (prek today; see tools/hook-manager/registry.json), installed as
+# `~/.claude/bin/<tool>` with `~/.claude/bin` prepended to PATH so it
+# shadows the real binary. The tool is the name it is installed
+# under (`basename "$0"`), so one script serves every adapter.
+#
+# WHY THIS EXISTS
+# ---------------
+# Under **whole-user (global) scope**, the framework sets
+#   git config --global core.hooksPath ~/.claude/git-hooks/
+# which makes git resolve hooks from ONE shared directory for every
+# repo. A hook manager then either refuses to install (prek since
+# 0.5: "core.hooksPath is configured outside this repository") or
+# writes its hook into that shared dir instead of the repo's own
+# `.git/hooks/` — collapsing every repo onto a single shared hook and
+# colliding with the framework's dispatchers.
+#
+# With the framework's per-repo **dispatcher** hooks installed in
+# the shared dir (git-hook-dispatcher.sh), the correct arrangement
+# is the opposite: the manager's hook belongs in each repo's *local*
+# `.git/hooks/`, where the dispatcher chains into it. This is the
+# contract's *install target* rule (tools/hook-manager/README.md).
+#
+# WHAT THIS SHIM DOES
+# -------------------
+#   * `<tool> <install subcommand> [...]` -> rewritten per the
+#     adapter's install strategy so the hook lands in
+#     "$(git rev-parse --git-common-dir)/hooks" (absolute, worktree-
+#     safe: `--git-common-dir` resolves linked worktrees to the main
+#     checkout's hooks dir) — UNLESS the caller already chose a
+#     target, asked for help, or we are not inside a git work tree.
+#       git-dir-flag: inject `<flag> <common dir>` (prek: --git-dir)
+#   * every other invocation (`prek run`, `prek autoupdate`,
+#     `--version`, bare `prek`, ...) is passed through to the real
+#     binary **unchanged**.
+#
+# The shim is a no-op on hosts that never set a global
+# core.hooksPath — installing into `.git/hooks/` is exactly where a
+# bare install would have put it anyway, so the rewrite is harmless
+# when there is no global hooks dir.
+
+set -euo pipefail
+
+tool="$(basename "$0")"
+
+# --- adapters: generated from tools/hook-manager/registry.json -------------
+# BEGIN generated by tools/dev/sync-hook-managers.py; do not edit
+adapter() {
+  case "$1" in
+    prek) install_subcommand=install; install_strategy=git-dir-flag; install_flag=--git-dir ;;
+    *) return 1 ;;
+  esac
+}
+# END generated by tools/dev/sync-hook-managers.py
+
+if ! adapter "$tool"; then
+  echo "hook-manager-shim: no adapter for '$tool' in tools/hook-manager/registry.json" >&2
+  echo "hook-manager-shim: (install this script under an adapter's name, e.g. ~/.claude/bin/prek)" >&2
+  exit 127
+fi
+
+# --- locate the REAL binary: the next `$tool` on PATH that is not us -------
+self_canon="$(readlink -f "$0" 2>/dev/null || echo "$0")"
+real_tool=""
+IFS=':' read -ra _path_dirs <<<"${PATH:-}"
+for _d in "${_path_dirs[@]}"; do
+  [ -n "$_d" ] || continue
+  _cand="$_d/$tool"
+  [ -x "$_cand" ] || continue
+  _cand_canon="$(readlink -f "$_cand" 2>/dev/null || echo "$_cand")"
+  [ "$_cand_canon" = "$self_canon" ] && continue   # skip ourselves
+  real_tool="$_cand"
+  break
+done
+
+if [ -z "$real_tool" ]; then
+  echo "hook-manager-shim: could not locate the real '$tool' binary on PATH" >&2
+  echo "hook-manager-shim: (this wrapper must sit ahead of $tool, not replace it)" >&2
+  exit 127
+fi
+
+# --- only rewrite the install subcommand; everything else is transparent ---
+if [ "${1:-}" != "$install_subcommand" ]; then
+  exec "$real_tool" "$@"
+fi
+
+shift  # drop the install subcommand
+
+# Respect a target the caller chose, and never interfere with help output.
+inject=1
+for _a in "$@"; do
+  case "$_a" in
+    -h|--help) inject=0; break ;;
+    "$install_flag"|"$install_flag"=*) inject=0; break ;;
+  esac
+done
+
+if [ "$inject" = 1 ]; then
+  if gitdir="$(git rev-parse --git-common-dir 2>/dev/null)"; then
+    case "$gitdir" in
+      /*) : ;;                                   # already absolute
+      *)  gitdir="$(cd "$gitdir" 2>/dev/null && pwd -P || echo "$gitdir")" ;;
+    esac
+    case "$install_strategy" in
+      git-dir-flag)
+        exec "$real_tool" "$install_subcommand" "$install_flag" "$gitdir" "$@"
+        ;;
+    esac
+  fi
+  # not in a git work tree: let the real tool produce its own error
+fi
+
+exec "$real_tool" "$install_subcommand" "$@"
diff --git a/tools/agent-isolation/prek-shim.sh b/tools/agent-isolation/prek-shim.sh
deleted file mode 100755
index 8daa51656..000000000
--- a/tools/agent-isolation/prek-shim.sh
+++ /dev/null
@@ -1,105 +0,0 @@
-#!/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.
-#
-# prek-shim.sh — a transparent front for `prek`, installed as
-# `~/.claude/bin/prek` with `~/.claude/bin` prepended to PATH so it
-# shadows the real `prek` binary.
-#
-# WHY THIS EXISTS
-# ---------------
-# Under **whole-user (global) scope**, the framework sets
-#   git config --global core.hooksPath ~/.claude/git-hooks/
-# which makes git resolve hooks from ONE shared directory for every
-# repo. prek honours `core.hooksPath`, so a bare `prek install`
-# would write its shim into that shared dir instead of the repo's
-# own `.git/hooks/` — collapsing every repo onto a single shared
-# shim and colliding with the framework's dispatchers.
-#
-# With the framework's per-repo **dispatcher** hooks installed in
-# the shared dir (git-hook-dispatcher.sh), the correct arrangement
-# is the opposite: prek's shim belongs in each repo's *local*
-# `.git/hooks/`, where the dispatcher chains into it. prek supports
-# that via `prek install --git-dir <gitdir>`.
-#
-# WHAT THIS SHIM DOES
-# -------------------
-#   * `prek install [...]`  -> injects
-#         --git-dir "$(git rev-parse --git-common-dir)"
-#     (absolute) UNLESS the caller already passed --git-dir, or
-#     asked for help, or we are not inside a git work tree. This
-#     forces the shim into the repo-local `.git/hooks/`, worktree-
-#     safe (`--git-common-dir` resolves linked worktrees to the
-#     shared hooks dir of the main checkout).
-#   * every other invocation (`prek run`, `prek install-hooks`,
-#     `prek autoupdate`, `--version`, bare `prek`, ...) is passed
-#     through to the real prek **unchanged**.
-#
-# The shim is a no-op on hosts that never set a global
-# core.hooksPath — installing prek into `.git/hooks/` is exactly
-# where a bare `prek install` would have put it anyway, so the
-# rewrite is harmless when there is no global hooks dir.
-
-set -euo pipefail
-
-# --- locate the REAL prek: the next `prek` on PATH that is not us -----------
-self_canon="$(readlink -f "$0" 2>/dev/null || echo "$0")"
-real_prek=""
-IFS=':' read -ra _path_dirs <<<"${PATH:-}"
-for _d in "${_path_dirs[@]}"; do
-  [ -n "$_d" ] || continue
-  _cand="$_d/prek"
-  [ -x "$_cand" ] || continue
-  _cand_canon="$(readlink -f "$_cand" 2>/dev/null || echo "$_cand")"
-  [ "$_cand_canon" = "$self_canon" ] && continue   # skip ourselves
-  real_prek="$_cand"
-  break
-done
-
-if [ -z "$real_prek" ]; then
-  echo "prek-shim: could not locate the real 'prek' binary on PATH" >&2
-  echo "prek-shim: (this wrapper must sit ahead of prek, not replace it)" >&2
-  exit 127
-fi
-
-# --- only rewrite the `install` subcommand; everything else is transparent --
-if [ "${1:-}" != "install" ]; then
-  exec "$real_prek" "$@"
-fi
-
-shift  # drop the literal `install`
-
-# Respect an explicit --git-dir, and never interfere with help output.
-inject=1
-for _a in "$@"; do
-  case "$_a" in
-    --git-dir|--git-dir=*|-h|--help) inject=0; break ;;
-  esac
-done
-
-if [ "$inject" = 1 ]; then
-  if gitdir="$(git rev-parse --git-common-dir 2>/dev/null)"; then
-    case "$gitdir" in
-      /*) : ;;                                   # already absolute
-      *)  gitdir="$(cd "$gitdir" 2>/dev/null && pwd -P || echo "$gitdir")" ;;
-    esac
-    exec "$real_prek" install --git-dir "$gitdir" "$@"
-  fi
-  # not in a git work tree: let real prek produce its own error
-fi
-
-exec "$real_prek" install "$@"
diff --git a/tools/agent-isolation/tests/test_git_hook_dispatcher.py b/tools/agent-isolation/tests/test_git_hook_dispatcher.py
index 2da9ac22e..b5acee0dc 100644
--- a/tools/agent-isolation/tests/test_git_hook_dispatcher.py
+++ b/tools/agent-isolation/tests/test_git_hook_dispatcher.py
@@ -28,10 +28,18 @@ import pytest
 DISPATCHER = Path(__file__).parent.parent / "git-hook-dispatcher.sh"
 
 
-def _repo_with_local_hook(tmp_path: Path) -> tuple[Path, Path, Path]:
-    """A repository whose own pre-commit records the PREK_HOME it was given."""
+def _repo_with_local_hook(
+    tmp_path: Path, config: str | None = ".pre-commit-config.yaml"
+) -> tuple[Path, Path, Path]:
+    """A repository whose own pre-commit records the PREK_HOME it was given.
+
+    `config` is the hook-manager config at its root, or None for a repository
+    that has none.
+    """
     repo = tmp_path / "repo"
     subprocess.run(["git", "init", "-q", str(repo)], check=True)
+    if config is not None:
+        (repo / config).write_text("repos: []\n")
     seen = tmp_path / "seen"
     local = repo / ".git" / "hooks" / "pre-commit"
     local.write_text(
@@ -46,7 +54,11 @@ def _repo_with_local_hook(tmp_path: Path) -> tuple[Path, Path, Path]:
 
 
 def _run(repo: Path, hook: Path, env_extra: dict[str, str]) -> None:
-    env = {k: v for k, v in os.environ.items() if k not in ("PREK_HOME", "SANDBOX_RUNTIME", "XDG_CACHE_HOME")}
+    env = {
+        k: v
+        for k, v in os.environ.items()
+        if k not in ("PREK_HOME", "SANDBOX_RUNTIME", "XDG_CACHE_HOME")
+    }
     env.update(env_extra)
     subprocess.run(["bash", str(hook)], cwd=repo, env=env, check=True)
 
@@ -54,8 +66,12 @@ def _run(repo: Path, hook: Path, env_extra: dict[str, str]) -> None:
 HOST_HOME = "{home}/.local/share/prek-host"
 
 
-@pytest.mark.parametrize("given", [None, "~/.cache/prek", "{home}/.cache/prek", "{home}/.cache/prek-sandbox"])
-def test_outside_the_sandbox_prek_gets_the_host_home(tmp_path: Path, given: str | None) -> None:
+@pytest.mark.parametrize(
+    "given", [None, "~/.cache/prek", "{home}/.cache/prek", "{home}/.cache/prek-sandbox"]
+)
+def test_outside_the_sandbox_prek_gets_the_host_home(
+    tmp_path: Path, given: str | None
+) -> None:
     """A terminal or bypassed commit must not run environments the sandbox could write."""
     repo, hook, seen = _repo_with_local_hook(tmp_path)
     home = os.environ["HOME"]
@@ -64,7 +80,9 @@ def test_outside_the_sandbox_prek_gets_the_host_home(tmp_path: Path, given: str
 
 
 @pytest.mark.parametrize("given", [None, "~/.cache/prek"])
-def test_inside_the_sandbox_prek_keeps_its_home(tmp_path: Path, given: str | None) -> None:
+def test_inside_the_sandbox_prek_keeps_its_home(
+    tmp_path: Path, given: str | None
+) -> None:
     repo, hook, seen = _repo_with_local_hook(tmp_path)
     env = {"SANDBOX_RUNTIME": "1"} | ({} if given is None else {"PREK_HOME": given})
     _run(repo, hook, env)
@@ -85,7 +103,9 @@ def test_a_repository_without_its_own_hook_is_a_no_op(tmp_path: Path) -> None:
 
 
 @pytest.mark.parametrize("given", [None, "{home}/.cache"])
-def test_outside_the_sandbox_tool_caches_move_too(tmp_path: Path, given: str | None) -> None:
+def test_outside_the_sandbox_tool_caches_move_too(
+    tmp_path: Path, given: str | None
+) -> None:
     """pip, go, npm and uv caches under ~/.cache would be sandbox-written too."""
     repo, hook, seen = _repo_with_local_hook(tmp_path)
     home = os.environ["HOME"]
@@ -98,3 +118,12 @@ def test_inside_the_sandbox_tool_caches_stay(tmp_path: Path) -> None:
     repo, hook, seen = _repo_with_local_hook(tmp_path)
     _run(repo, hook, {"SANDBOX_RUNTIME": "1"})
     assert Path(f"{seen}.xdg").read_text() == "<unset>"
+
+
+def test_a_repository_without_a_detected_manager_still_gets_the_host_home(
+    tmp_path: Path,
+) -> None:
+    """Fail closed: a hook can reach prek through a script detection never sees."""
+    repo, hook, seen = _repo_with_local_hook(tmp_path, config=None)
+    _run(repo, hook, {"PREK_HOME": "~/.cache/prek-sandbox"})
+    assert seen.read_text() == HOST_HOME.format(home=os.environ["HOME"])
diff --git a/tools/agent-isolation/tests/test_hook_manager_shim.py b/tools/agent-isolation/tests/test_hook_manager_shim.py
new file mode 100644
index 000000000..43ea94d5a
--- /dev/null
+++ b/tools/agent-isolation/tests/test_hook_manager_shim.py
@@ -0,0 +1,136 @@
+# 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.
+
+"""The hook-manager shim puts a manager's hook in the repository's own hooks dir."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SHIM = Path(__file__).parent.parent / "hook-manager-shim.sh"
+
+
+def _setup(tmp_path: Path, name: str = "prek") -> tuple[dict[str, str], Path]:
+    """A fake real `name` that records its argv, behind the shim installed as `name`."""
+    real = tmp_path / "real"
+    real.mkdir()
+    argv = tmp_path / "argv"
+    (real / name).write_text(f'#!/bin/sh\nprintf "%s\\n" "$@" > {argv}\n')
+    (real / name).chmod(0o755)
+    shims = tmp_path / "shims"
+    shims.mkdir()
+    (shims / name).symlink_to(SHIM)
+    env = dict(os.environ, PATH=f"{shims}:{real}:/usr/bin:/bin")
+    return env, argv
+
+
+def _run(
+    env: dict[str, str], cwd: Path, *args: str, name: str = "prek"
+) -> subprocess.CompletedProcess[str]:
+    return subprocess.run(
+        [name, *args], cwd=cwd, env=env, capture_output=True, text=True, check=False
+    )
+
+
+def _repo(tmp_path: Path) -> Path:
+    repo = tmp_path / "repo"
+    subprocess.run(["git", "init", "-q", str(repo)], check=True)
+    return repo
+
+
+def test_install_targets_the_common_git_dir(tmp_path: Path) -> None:
+    env, argv = _setup(tmp_path)
+    repo = _repo(tmp_path)
+    assert _run(env, repo, "install", "-t", "pre-push").returncode == 0
+    git_dir = str((repo / ".git").resolve())
+    assert argv.read_text().splitlines() == [
+        "install",
+        "--git-dir",
+        git_dir,
+        "-t",
+        "pre-push",
+    ]
+
+
+def test_a_linked_worktree_targets_the_main_checkout(tmp_path: Path) -> None:
+    env, argv = _setup(tmp_path)
+    repo = _repo(tmp_path)
+    subprocess.run(
+        ["git", "-C", str(repo), "commit", "-q", "--allow-empty", "-m", "x"],
+        check=True,
+        env=dict(
+            os.environ,
+            GIT_AUTHOR_NAME="t",
+            GIT_AUTHOR_EMAIL="t@t",
+            GIT_COMMITTER_NAME="t",
+            GIT_COMMITTER_EMAIL="t@t",
+        ),
+    )
+    worktree = tmp_path / "wt"
+    subprocess.run(
+        ["git", "-C", str(repo), "worktree", "add", "-q", str(worktree)], check=True
+    )
+    assert _run(env, worktree, "install").returncode == 0
+    assert argv.read_text().splitlines() == [
+        "install",
+        "--git-dir",
+        str((repo / ".git").resolve()),
+    ]
+
+
+@pytest.mark.parametrize(
+    "args",
+    [
+        ["install", "--git-dir", "/x"],
+        ["install", "--git-dir=/x"],
+        ["install", "--help"],
+        ["run", "--all-files"],
+        ["--version"],
+    ],
+)
+def test_other_invocations_pass_through(tmp_path: Path, args: list[str]) -> None:
+    env, argv = _setup(tmp_path)
+    assert _run(env, _repo(tmp_path), *args).returncode == 0
+    assert argv.read_text().splitlines() == args
+
+
+def test_install_outside_a_work_tree_passes_through(tmp_path: Path) -> None:
+    env, argv = _setup(tmp_path)
+    outside = tmp_path / "outside"
+    outside.mkdir()
+    env["GIT_CEILING_DIRECTORIES"] = str(tmp_path)
+    assert _run(env, outside, "install").returncode == 0
+    assert argv.read_text().splitlines() == ["install"]
+
+
+def test_a_name_without_an_adapter_is_refused(tmp_path: Path) -> None:
+    env, _ = _setup(tmp_path, name="not-a-hook-manager")
+    result = _run(env, tmp_path, "install", name="not-a-hook-manager")
+    assert result.returncode == 127
+    assert "no adapter" in result.stderr
+
+
+def test_a_missing_real_binary_is_reported(tmp_path: Path) -> None:
+    env, _ = _setup(tmp_path)
+    (tmp_path / "real" / "prek").unlink()
+    result = _run(env, tmp_path, "run")
+    assert result.returncode == 127
+    assert "could not locate the real 'prek'" in result.stderr
diff --git a/tools/dev/README.md b/tools/dev/README.md
index 2705ccfd2..a426c6daf 100644
--- a/tools/dev/README.md
+++ b/tools/dev/README.md
@@ -68,6 +68,7 @@ installable for other members to depend on it.
 | [`gh-signed-commit.py`](gh-signed-commit.py) | Commits the working tree through GitHub's `createCommitOnBranch` mutation instead of `git commit` + `git push`, so the commit is signed by GitHub and shows as **Verified** with no key material in CI. Collects changed and deleted paths from `git status --porcelain -z`, decomposes renames (the mutation has no rename concept), and pins `expectedHeadOid` so a concurrent push fails the call rather than being overwritten. Used by [`bump-dev-vers [...]
 | [`check-placeholders.sh`](check-placeholders.sh) | Fails the build on hardcoded project references in skill and tool docs, which must use `<PROJECT>` / `<project>` / `<tracker>` / `<upstream>` instead. Carries both casings and matches spaced variants. |
 | [`check-workspace-members.py`](check-workspace-members.py) | Catches a new `tools/<name>/pyproject.toml` that was never added to `[tool.uv.workspace] members` — an omission that silently drops the tool from both the pre-commit hooks and the CI pytest matrix. Also verifies each member's tests actually run: both surfaces key off `[tool.pytest.ini_options]`, so a project can carry a full `tests/` directory and be executed by nothing. Reports tests-without-config, config-without-tests, and [...]
+| [`sync-hook-managers.py`](sync-hook-managers.py) | Validates [`tools/hook-manager/registry.json`](../hook-manager/registry.json) against the [hook-manager contract](../hook-manager/README.md) and writes it, as generated blocks, into the shim and the whole-user dispatcher in `tools/agent-isolation/` and into `sandbox-lint`, which run where the registry is not available. Also checks, without writing, that `.claude/settings.json` and `tools/sandbox-lint/expected.json` carry the sandbox ho [...]
 | [`generate-labeler-config.py`](generate-labeler-config.py) | Generates `.github/labeler.yml` — the path → label map the [`labeler.yml`](../../.github/workflows/labeler.yml) workflow uses to pre-apply labels to a PR and its linked issues — from the `**Capability:**` line of every `tools/<name>/README.md` (`contract:*` / `substrate:*`), the `family:` / `capability:` frontmatter of every skill (its directory and its eval suite), and the paths of the non-skill families (`family:tools`, `fa [...]
 | [`run-workspace-check.sh`](run-workspace-check.sh) | Runs one static-check or test command across every workspace member, auto-discovering which members a given check applies to. The four `workspace-*` hooks call it, so adding a tool needs no edit to the pre-commit config. |
 | [`ci-pytest-batches.py`](ci-pytest-batches.py) | Picks the members the `tests` workflow runs pytest for, with the same applicability rule as `run-workspace-check.sh` plus a pull request's path filter, and groups them into jobs. Most of a pytest job is setup (checkout, uv, a workspace sync: 27–75 s against 2–18 s of tests), so up to six members get a job each, named after the member, and more are split into at most eight jobs balanced by lines of test code. Each job also gets its member [...]
@@ -82,7 +83,7 @@ a human has to remember to update while thinking about something else.
 
 ## Prerequisites
 
-- **Runtime:** Bash + coreutils; `check-workspace-members.py`, `check-family-plugins.py`, `check-doc-sync.py`, `check-quickstart-recording.py`, `check-shared-blocks.py`, `skill-surface-hash.py`, `check-duplication.py`, `generate-labeler-config.py`, and `add-license-headers.py` run under `python3` (standard library only). `check-quickstart-recording.py` parses the recording with `xml.etree`, so the check needs no image library; every SVG it validates is generated by `render-screenshot.sh` [...]
+- **Runtime:** Bash + coreutils; `check-workspace-members.py`, `check-family-plugins.py`, `check-doc-sync.py`, `check-quickstart-recording.py`, `check-shared-blocks.py`, `skill-surface-hash.py`, `check-duplication.py`, `generate-labeler-config.py`, `sync-hook-managers.py`, and `add-license-headers.py` run under `python3` (standard library only). `check-quickstart-recording.py` parses the recording with `xml.etree`, so the check needs no image library; every SVG it validates is generated  [...]
 - **CLIs:** `uv` (the workspace checks run `uv run`), `git`, and `prek` (or `pre-commit`) — these scripts wire up the framework's hooks.
 - **Credentials / auth:** None.
 - **Network:** Local checks; `uv` may resolve workspace dependencies from PyPI (`pypi.org`, `files.pythonhosted.org`) on first sync.
diff --git a/tools/dev/sync-hook-managers.py b/tools/dev/sync-hook-managers.py
new file mode 100755
index 000000000..7e09ee3af
--- /dev/null
+++ b/tools/dev/sync-hook-managers.py
@@ -0,0 +1,249 @@
+#!/usr/bin/env python3
+# 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.
+"""Validate the hook-manager registry and write it into its implementations.
+
+`tools/hook-manager/registry.json` is the source of truth for the git-hook
+managers the secure agent setup integrates (the contract is
+`tools/hook-manager/README.md`). The scripts that implement the contract run
+where the registry is not available (a copy under `~/.claude/bin`, a hook
+under `~/.claude/git-hooks`, a git hook that must start fast), so each carries
+a generated block between `BEGIN generated by tools/dev/sync-hook-managers.py`
+and `END ...` markers:
+
+- `tools/agent-isolation/hook-manager-shim.sh`: the install rewrite per tool.
+- `tools/agent-isolation/git-hook-dispatcher.sh`: the host-only home per tool.
+- `tools/sandbox-lint/src/sandbox_lint/__init__.py`: the homes the lint checks.
+
+It also checks, without writing, that the committed settings and the
+sandbox-lint baseline deny every manager's default home and give the sandbox
+home of every manager this repository uses.
+
+Run as a prek hook. Rewrites the blocks in place and exits 1 if one changed
+(re-stage and commit again); `--check` reports drift without writing.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+from typing import Any
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+REGISTRY = Path("tools/hook-manager/registry.json")
+SHIM = Path("tools/agent-isolation/hook-manager-shim.sh")
+DISPATCHER = Path("tools/agent-isolation/git-hook-dispatcher.sh")
+SANDBOX_LINT = Path("tools/sandbox-lint/src/sandbox_lint/__init__.py")
+SETTINGS = (Path(".claude/settings.json"), Path("tools/sandbox-lint/expected.json"))
+
+#: Install strategies the shim implements. A new one lands with its shim branch.
+STRATEGIES = {"git-dir-flag"}
+
+_NAME = re.compile(r"^[a-z][a-z0-9-]*$")
+_ENV = re.compile(r"^[A-Z][A-Z0-9_]*$")
+_FLAG = re.compile(r"^--[a-z][a-z0-9-]*$")
+_DIR = re.compile(r"^[a-z][a-z0-9.-]*$")
+_FILE = re.compile(r"^[A-Za-z0-9._-]+$")
+_MARKER = re.compile(r"^[A-Za-z0-9._-]+$")
+_BLOCK = re.compile(
+    r"(?P<begin>^[ \t]*# BEGIN generated by tools/dev/sync-hook-managers\.py; do not edit\n)"
+    r".*?"
+    r"(?P<end>^[ \t]*# END generated by tools/dev/sync-hook-managers\.py$)",
+    re.MULTILINE | re.DOTALL,
+)
+
+
+def validate(registry: dict[str, Any]) -> list[str]:
+    """Every rule of the contract's registry section, as error strings."""
+    errors: list[str] = []
+    adapters = registry.get("adapters")
+    if not isinstance(adapters, dict) or not adapters:
+        return ["adapters: missing or empty"]
+    for name, adapter in adapters.items():
+        where = f"adapters.{name}"
+        if not _NAME.match(name):
+            errors.append(f"{where}: the name must be the binary's name ([a-z][a-z0-9-]*)")
+        if not isinstance(adapter, dict):
+            errors.append(f"{where}: must be an object")
+            continue
+        for key in ("language", "upstream"):
+            if not isinstance(adapter.get(key), str) or not adapter[key]:
+                errors.append(f"{where}.{key}: missing")
+        detect = adapter.get("detect")
+        if not isinstance(detect, dict):
+            errors.append(f"{where}.detect: missing")
+        else:
+            files = detect.get("config_files")
+            if (
+                not isinstance(files, list)
+                or not files
+                or not all(isinstance(f, str) and _FILE.match(f) for f in files)
+            ):
+                errors.append(f"{where}.detect.config_files: a non-empty list of file names")
+            if not isinstance(detect.get("nested"), bool):
+                errors.append(f"{where}.detect.nested: whether a config below the root counts")
+            if not isinstance(detect.get("hook_marker"), str) or not _MARKER.match(detect["hook_marker"]):
+                errors.append(f"{where}.detect.hook_marker: the text an installed hook that runs it contains")
+        install = adapter.get("install")
+        if not isinstance(install, dict):
+            errors.append(f"{where}.install: missing")
+        else:
+            if install.get("strategy") not in STRATEGIES:
+                errors.append(
+                    f"{where}.install.strategy: {install.get('strategy')!r} is not one the shim implements "
+                    f"({', '.join(sorted(STRATEGIES))})"
+                )
+            if not isinstance(install.get("subcommand"), str) or not _NAME.match(install["subcommand"]):
+                errors.append(f"{where}.install.subcommand: missing")
+            if install.get("strategy") == "git-dir-flag" and not (
+                isinstance(install.get("flag"), str) and _FLAG.match(install["flag"])
+            ):
+                errors.append(
+                    f"{where}.install.flag: git-dir-flag needs the long option that takes the git dir"
+                )
+        home = adapter.get("home")
+        if home is None:
+            continue  # a manager that keeps nothing outside the repository
+        if not isinstance(home, dict):
+            errors.append(f"{where}.home: must be an object")
+            continue
+        if not isinstance(home.get("env"), str) or not _ENV.match(home["env"]):
+            errors.append(f"{where}.home.env: the environment variable that relocates the home")
+        if home.get("expands_tilde") is not True:
+            # The committed settings can only spell a per-user path with `~`.
+            errors.append(
+                f"{where}.home.expands_tilde: a tool that does not expand ~ needs a per-user sandbox home"
+            )
+        default, sandbox, host = home.get("default"), home.get("sandbox"), home.get("host")
+        for key, value in (("default", default), ("sandbox", sandbox)):
+            if not isinstance(value, str) or not value.startswith("~/.cache/"):
+                errors.append(f"{where}.home.{key}: must sit under ~/.cache/")
+        if (
+            isinstance(default, str)
+            and isinstance(sandbox, str)
+            and (sandbox == default or sandbox.startswith(default + "/") or default.startswith(sandbox + "/"))
+        ):
+            errors.append(f"{where}.home.sandbox: must not overlap the default home {default}")
+        if not isinstance(host, str) or not host.startswith("~/.local/share/") or not _DIR.match(host[15:]):
+            errors.append(
+                f"{where}.home.host: a single directory under ~/.local/share/, which no sandbox grant covers"
+            )
+    return errors
+
+
+def render(registry: dict[str, Any]) -> dict[Path, str]:
+    """The body of each generated block."""
+    adapters = registry["adapters"]
+    cases = []
+    for name, adapter in sorted(adapters.items()):
+        install = adapter["install"]
+        fields = [f"install_subcommand={install['subcommand']}", f"install_strategy={install['strategy']}"]
+        if "flag" in install:
+            fields.append(f"install_flag={install['flag']}")
+        cases.append(f"    {name}) {'; '.join(fields)} ;;\n")
+    shim = 'adapter() {\n  case "$1" in\n' + "".join(cases) + "    *) return 1 ;;\n  esac\n}\n"
+    homes = [(n, a) for n, a in sorted(adapters.items()) if a.get("home")]
+    dispatcher = (
+        'hook_manager_homes="'
+        + " ".join(f"{a['home']['env']}={a['home']['host'][15:]}" for _, a in homes)
+        + '"\n'
+    )
+    lint = "HOOK_MANAGER_HOMES = (\n"
+    for name, a in homes:
+        home, detect = a["home"], a["detect"]
+        files = ", ".join(json.dumps(f) for f in detect["config_files"])
+        files = f"({files},)" if len(detect["config_files"]) == 1 else f"({files})"
+        fields = [
+            json.dumps(name),
+            json.dumps(home["env"]),
+            json.dumps(home["default"]),
+            json.dumps(home["sandbox"]),
+        ]
+        fields += [files, str(detect["nested"]), json.dumps(detect["hook_marker"])]
+        lint += "    (\n" + "".join(f"        {f},\n" for f in fields) + "    ),\n"
+    lint += ")\n"
+    return {SHIM: shim, DISPATCHER: dispatcher, SANDBOX_LINT: lint}
+
+
+def check_settings(registry: dict[str, Any], root: Path) -> list[str]:
+    """The committed settings give each sandbox home and deny each default home.
+
+    The deny for every manager (the default home is per-user); the sandbox
+    home only for the managers this repository uses (a config file at its
+    root), as sandbox-lint requires it only there.
+    """
+    errors = []
+    for rel in SETTINGS:
+        settings = json.loads((root / rel).read_text(encoding="utf-8"))
+        env = settings.get("env") or {}
+        deny = (settings.get("permissions") or {}).get("deny") or []
+        for name, adapter in sorted(registry["adapters"].items()):
+            home = adapter.get("home")
+            if not home:
+                continue
+            used = any((root / f).is_file() for f in adapter["detect"]["config_files"])
+            if used and env.get(home["env"]) != home["sandbox"]:
+                errors.append(f"{rel}: env.{home['env']} must be {home['sandbox']!r} ({name})")
+            if f"Edit({home['default']}/**)" not in deny:
+                errors.append(f"{rel}: permissions.deny must carry Edit({home['default']}/**) ({name})")
+    return errors
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+    parser.add_argument("--check", action="store_true", help="report drift without writing")
+    parser.add_argument("--root", type=Path, default=REPO_ROOT, help=argparse.SUPPRESS)
+    args = parser.parse_args(argv)
+    root: Path = args.root
+
+    registry = json.loads((root / REGISTRY).read_text(encoding="utf-8"))
+    errors = validate(registry)
+    if errors:
+        for error in errors:
+            print(f"{REGISTRY}: {error}", file=sys.stderr)
+        return 1
+
+    status = 0
+    for rel, body in render(registry).items():
+        path = root / rel
+        text = path.read_text(encoding="utf-8")
+        blocks = list(_BLOCK.finditer(text))
+        if len(blocks) != 1:
+            print(f"{rel}: expected exactly one generated block, found {len(blocks)}", file=sys.stderr)
+            status = 1
+            continue
+        block = blocks[0]
+        new = text[: block.end("begin")] + body + text[block.start("end") :]
+        if new != text:
+            status = 1
+            if args.check:
+                print(f"{rel}: generated block is out of date; run {Path(__file__).name}", file=sys.stderr)
+            else:
+                path.write_text(new, encoding="utf-8")
+                print(f"{rel}: regenerated from {REGISTRY}", file=sys.stderr)
+
+    for error in check_settings(registry, root):
+        print(error, file=sys.stderr)
+        status = 1
+    return status
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/dev/tests/test_sync_hook_managers.py b/tools/dev/tests/test_sync_hook_managers.py
new file mode 100644
index 000000000..ef9cd965d
--- /dev/null
+++ b/tools/dev/tests/test_sync_hook_managers.py
@@ -0,0 +1,170 @@
+# 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 ``sync-hook-managers.py``.
+
+The committed tree must be in sync, a registry that breaks a contract rule is
+refused, and a stale block is rewritten (or reported under ``--check``).
+
+The script's filename is hyphenated, which is not an importable module name, so
+it is loaded through ``importlib.util``.
+"""
+
+from __future__ import annotations
+
+import copy
+import importlib.util
+import json
+import shutil
+from pathlib import Path
+from types import ModuleType
+from typing import Any
+
+import pytest
+
+_SCRIPT = Path(__file__).resolve().parents[1] / "sync-hook-managers.py"
+
+
+def _load() -> ModuleType:
+    spec = importlib.util.spec_from_file_location("sync_hook_managers", _SCRIPT)
+    assert spec and spec.loader
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+mod = _load()
+
+
+@pytest.fixture
+def registry() -> dict[str, Any]:
+    return json.loads((mod.REPO_ROOT / mod.REGISTRY).read_text(encoding="utf-8"))
+
+
+@pytest.fixture
+def tree(tmp_path: Path) -> Path:
+    """A copy of every file the script reads or writes."""
+    for rel in (mod.REGISTRY, mod.SHIM, mod.DISPATCHER, mod.SANDBOX_LINT, *mod.SETTINGS):
+        (tmp_path / rel).parent.mkdir(parents=True, exist_ok=True)
+        shutil.copy(mod.REPO_ROOT / rel, tmp_path / rel)
+    (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")  # this repository uses prek
+    return tmp_path
+
+
+def test_the_committed_tree_is_in_sync() -> None:
+    assert mod.main(["--check"]) == 0
+
+
+def test_the_committed_registry_follows_the_contract(registry: dict[str, Any]) -> None:
+    assert mod.validate(registry) == []
+
+
+@pytest.mark.parametrize(
+    ("path", "value", "message"),
+    [
+        (("install", "strategy"), "hooks-path-env", "install.strategy"),
+        (("detect", "config_files"), [], "detect.config_files"),
+        (("detect", "config_files"), ["sub/prek.toml"], "detect.config_files"),
+        (("detect", "nested"), "yes", "detect.nested"),
+        (("detect", "hook_marker"), "", "detect.hook_marker"),
+        (("install", "flag"), "git-dir", "install.flag"),
+        (("home", "sandbox"), "~/.cache/prek", "home.sandbox"),
+        (("home", "sandbox"), "~/.cache/prek/sandbox", "home.sandbox"),
+        (("home", "sandbox"), "/tmp/prek", "home.sandbox"),
+        (("home", "host"), "~/.cache/prek-host", "home.host"),
+        (("home", "host"), "~/.local/share/a/b", "home.host"),
+        (("home", "expands_tilde"), False, "home.expands_tilde"),
+        (("home", "env"), "prek_home", "home.env"),
+    ],
+)
+def test_a_registry_that_breaks_the_contract_is_refused(
+    registry: dict[str, Any], path: tuple[str, str], value: object, message: str
+) -> None:
+    broken = copy.deepcopy(registry)
+    broken["adapters"]["prek"][path[0]][path[1]] = value
+    assert any(message in e for e in mod.validate(broken)), mod.validate(broken)
+
+
+def test_a_manager_without_a_home_is_allowed(registry: dict[str, Any]) -> None:
+    del registry["adapters"]["prek"]["home"]
+    assert mod.validate(registry) == []
+    assert mod.render(registry)[mod.DISPATCHER] == 'hook_manager_homes=""\n'
+
+
+def _add_demo(tree: Path, registry: dict[str, Any]) -> None:
+    extra = copy.deepcopy(registry["adapters"]["prek"])
+    extra["detect"] = {"config_files": ["demo.toml"], "nested": False, "hook_marker": "demo"}
+    extra["home"].update(env="DEMO_HOME", default="~/.cache/demo", sandbox="~/.cache/demo-sandbox")
+    extra["home"]["host"] = "~/.local/share/demo-host"
+    registry["adapters"]["demo"] = extra
+    (tree / mod.REGISTRY).write_text(json.dumps(registry), encoding="utf-8")
+
+
+def test_a_new_adapter_reaches_every_block(tree: Path, registry: dict[str, Any]) -> None:
+    _add_demo(tree, registry)
+    assert mod.main(["--check", "--root", str(tree)]) == 1
+    assert mod.main(["--root", str(tree)]) == 1  # rewrote the blocks; the settings lack the demo deny
+    assert "    demo) install_subcommand=install;" in (tree / mod.SHIM).read_text()
+    dispatcher = (tree / mod.DISPATCHER).read_text()
+    assert 'hook_manager_homes="DEMO_HOME=demo-host PREK_HOME=prek-host"' in dispatcher
+    lint = (tree / mod.SANDBOX_LINT).read_text()
+    assert (
+        '        "DEMO_HOME",\n        "~/.cache/demo",\n        "~/.cache/demo-sandbox",\n        ("demo.toml",),'
+        in lint
+    )
+
+
+def _set(tree: Path, env: dict[str, str], deny: list[str]) -> None:
+    for rel in mod.SETTINGS:
+        settings = json.loads((tree / rel).read_text())
+        settings["env"].update(env)
+        settings["permissions"]["deny"].extend(deny)
+        (tree / rel).write_text(json.dumps(settings), encoding="utf-8")
+
+
+def test_every_adapter_needs_its_deny_and_a_used_one_its_home(tree: Path, registry: dict[str, Any]) -> None:
+    """The default-home deny is per-user; the sandbox home only where the repository uses it."""
+    _add_demo(tree, registry)
+    mod.main(["--root", str(tree)])
+    _set(tree, {}, ["Edit(~/.cache/demo/**)"])
+    assert mod.main(["--check", "--root", str(tree)]) == 0  # not used here: the deny is enough
+    (tree / "demo.toml").write_text("")
+    assert mod.main(["--check", "--root", str(tree)]) == 1
+    _set(tree, {"DEMO_HOME": "~/.cache/demo-sandbox"}, [])
+    assert mod.main(["--check", "--root", str(tree)]) == 0
+
+
+def test_a_repository_without_the_manager_needs_no_settings(tree: Path) -> None:
+    (tree / ".pre-commit-config.yaml").unlink()
+    rel = mod.SETTINGS[0]
+    settings = json.loads((tree / rel).read_text())
+    del settings["env"]["PREK_HOME"]
+    (tree / rel).write_text(json.dumps(settings), encoding="utf-8")
+    assert mod.main(["--check", "--root", str(tree)]) == 0
+
+
+def test_settings_without_the_sandbox_home_fail(tree: Path) -> None:
+    rel = mod.SETTINGS[0]
+    settings = json.loads((tree / rel).read_text())
+    del settings["env"]["PREK_HOME"]
+    (tree / rel).write_text(json.dumps(settings), encoding="utf-8")
+    assert mod.main(["--check", "--root", str(tree)]) == 1
+
+
+def test_a_missing_block_is_reported(tree: Path) -> None:
+    shim = tree / mod.SHIM
+    shim.write_text(shim.read_text().replace("# BEGIN generated", "# BEGIN removed"))
+    assert mod.main(["--root", str(tree)]) == 1
diff --git a/tools/hook-manager/README.md b/tools/hook-manager/README.md
new file mode 100644
index 000000000..88fe9c032
--- /dev/null
+++ b/tools/hook-manager/README.md
@@ -0,0 +1,161 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/licenses/LICENSE-2.0 -->
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+
+- [tools/hook-manager/](#toolshook-manager)
+  - [Prerequisites](#prerequisites)
+  - [Why a contract](#why-a-contract)
+  - [The rules](#the-rules)
+    - [Install target](#install-target)
+    - [Home](#home)
+    - [Detection](#detection)
+  - [Registry](#registry)
+  - [Adapters](#adapters)
+  - [Adding an adapter](#adding-an-adapter)
+  - [Consumers](#consumers)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# tools/hook-manager/
+
+**Capability:** contract:hook-manager
+
+**Kind:** interface
+
+**Vendor:** agnostic
+
+This file defines the contract for **git-hook managers** — prek, pre-commit, lefthook, husky and their kin — under the [secure agent setup](../../docs/setup/secure-agent-setup.md).
+A hook manager installs a script into a repository's hooks directory and, when git runs that hook, runs the checks the repository configured, often from environments it built and keeps outside the repository.
+Both halves meet the sandbox: where the install writes, and where the environments live.
+The contract states the two rules every manager must satisfy, and [`registry.json`](registry.json) records, per manager, how it satisfies them.
+
+The secure setup does not *run* hook managers; it keeps them working, and keeps them from becoming a way out of the sandbox.
+It configures a manager for a repository only where the repository uses it (see [Detection](#detection)): a repository that uses none gets no shim and no sandbox-home requirement. The protections of the per-user default and host homes apply everywhere.
+
+## Prerequisites
+
+- **Runtime:** None of its own — this file is a contract *specification* plus a JSON registry.
+  The implementations are shell scripts (`tools/agent-isolation/`) and Python (`tools/sandbox-lint/`).
+- **CLIs:** `git`; the hook manager itself is the operator's.
+- **Credentials / auth:** None.
+- **Network:** None.
+
+## Why a contract
+
+Under the whole-user flavour of the secure setup, `git config --global core.hooksPath ~/.claude/git-hooks/` makes git read hooks from one shared directory, where the [dispatcher](../agent-isolation/git-hook-dispatcher.sh) runs the framework's own hook logic and then chains to the repository's own `$(git rev-parse --git-common-dir)/hooks/<name>`.
+Separately, the sandbox makes `~/.cache/` writable, and most hook managers keep their environments there.
+Each manager meets those two facts differently — one writes into whatever `core.hooksPath` names, one refuses to install while it is set, one sets its own — so each needs its own fix, and the fixes must not drift apart.
+The contract names the rules; the registry is the one place a manager's answers are written down; the implementations are generated from it.
+
+## The rules
+
+### Install target
+
+A manager's install must put its hook in the repository's own `$(git rev-parse --git-common-dir)/hooks/`, never in the shared `core.hooksPath` directory, and must not leave a repository-level `core.hooksPath` behind.
+
+- The shared directory belongs to the dispatcher. A manager that writes there replaces the dispatcher for every repository on the host.
+- A repository-level `core.hooksPath` overrides the global one, so git never reaches the dispatcher, and with it the host-only home below.
+  magpie-setup's session-start hook removes one that points at the repository's own `.git/hooks` and reports any other value.
+- `--git-common-dir`, not `--git-dir`: a linked worktree shares its main checkout's hooks.
+
+The fix is an **install strategy**, applied by [`hook-manager-shim.sh`](../agent-isolation/hook-manager-shim.sh), installed as `~/.claude/bin/<tool>` ahead of the real binary on `PATH`.
+The shim takes the tool from the name it is installed under, rewrites only the install subcommand, and passes every other invocation through unchanged.
+It leaves the install alone when the caller already chose a target, asked for help, or is not in a git work tree.
+
+| Strategy | What the shim does | Status |
+|---|---|---|
+| `git-dir-flag` | Inserts `<flag> <common dir>` after the install subcommand (prek: `--git-dir`; prek refuses a bare install while a global `core.hooksPath` is set). | implemented |
+| `hide-global-config` | Runs the install with `GIT_CONFIG_GLOBAL=/dev/null`, for a manager that writes to the common dir but refuses while a global `core.hooksPath` is set (pre-commit, lefthook). | planned |
+| `hooks-path-env` | Runs the install with `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=core.hooksPath`, `GIT_CONFIG_VALUE_0=<common dir>/hooks`, for a manager that writes into whatever `core.hooksPath` names (overcommit, CaptainHook). The value must be that absolute path: git treats an empty `core.hooksPath` as set, and hooks then resolve against the filesystem root. | planned |
+| `none` | The manager already writes to the repository's own hooks directory whatever the global value (simple-git-hooks, cargo-husky, the JGit-based Maven plugins). | planned |
+
+A manager whose install the shim cannot reach (one run from `node_modules/.bin` by an npm script, from `vendor/bin` by a composer plugin, or from a `build.rs`, all ahead of `~/.claude/bin` or off `PATH` entirely) needs a strategy outside the shim, and a manager that writes a repository-level `core.hooksPath` itself (husky on every `npm install`) needs the dispatcher to chain to that directory instead; the adapter issues name the candidates.
+
+### Home
+
+A hook that runs outside the sandbox must never run an environment a sandboxed command could have written.
+
+A manager that keeps environments in a home outside the repository gets three directories:
+
+| Directory | Who uses it | How |
+|---|---|---|
+| `default` (under `~/.cache/`) | The operator's terminal under per-project scope. | Sandboxed commands may not edit it: `permissions.deny` carries `Edit(<default>/**)`. |
+| `sandbox` (under `~/.cache/`, disjoint from `default`) | Every command Claude Code runs. | The committed settings' `env.<var>`, which only Claude Code's commands see. |
+| `host` (one directory under `~/.local/share/`) | Every hook the whole-user dispatcher runs outside the sandbox (no `SANDBOX_RUNTIME`). | The dispatcher exports `<var>=~/.local/share/<host>` for every registered manager unless the operator chose a value outside `~/.cache`. |
+
+`~/.local/share/` is outside every sandbox write grant, so nothing sandboxed can plant code in the host home.
+The committed settings can name a per-user directory only with `~`, so the contract requires a manager whose home variable is relative to `~` to expand it (`expands_tilde: true`); one that does not needs the sandbox home written as an absolute path into the per-user `settings.local.json`, which no adapter does yet.
+
+### Detection
+
+A repository **uses** a hook manager when any of these holds:
+
+1. One of the manager's config files sits at the repository root.
+2. For a manager with nested project configs (prek's workspace mode), one of its config files is tracked anywhere below the root (`git ls-files ':(glob)**/<name>'`). An untracked nested file does not count.
+3. One of the repository's installed hooks (`$(git rev-parse --git-common-dir)/hooks/*`, samples aside) contains the manager's hook marker. This covers an install with a custom config path (`prek install --config ci/hooks.yaml` leaves no standard file name behind) and one made from a nested project.
+
+Detection decides what the setup configures *for a repository*; it never decides a per-user protection, since a manager's default and host homes are shared by every repository on the host, and a hook can reach a manager detection does not see (through a script):
+
+| Integration | Scope | Rule |
+|---|---|---|
+| Sandbox home (`env.<var>`) | per repository | `sandbox-lint` (`uses_hook_manager`) requires it only for a project that uses the manager; with no project to look at, every manager counts. A project without it is still safe: its sandboxed runs of the manager fail on the denied default home. The generator checks the framework's own settings the same way, from the root config files. |
+| Shim | per host, for the repositories the operator works in | `setup-isolated-setup-install` installs `~/.claude/bin/<tool>` only when such a repository uses the manager. |
+| Verify check 19d | per repository | **n/a** for a project that does not use prek. |
+| Default-home deny (`Edit(<default>/**)`) | per user | Always, for every registered manager: another repository's terminal commits run from that home. |
+| Host home (dispatcher) | per user | Always, outside the sandbox, for every registered manager: an unused variable is harmless, a missed detection would let a bypassed command inherit the sandbox home. |
+
+How prek configures itself, from its source (v0.5.7): it reads one project config per directory, `prek.toml`, then `.pre-commit-config.yaml`, then `.pre-commit-config.yml` (`crates/prek-consts/src/lib.rs`); searches upward from the current directory to the git root for the workspace root, and below it for nested project configs, honouring `.gitignore` and `.prekignore` (`crates/prek/src/workspace.rs`); takes `--config <path>` instead, which disables discovery and is baked into the instal [...]
+The two YAML names are shared with pre-commit, so a repository with one uses both managers as far as detection goes; configuring a home the repository's hooks do not end up using is harmless.
+
+## Registry
+
+[`registry.json`](registry.json) holds one adapter per manager under `adapters.<binary name>`:
+
+| Field | Meaning |
+|---|---|
+| `language` | The ecosystem the manager comes from (for the reader, not the implementation). |
+| `upstream` | The manager's source repository. |
+| `detect.config_files` | The config file names that say a repository uses it (bare names, no directories). |
+| `detect.nested` | Whether a tracked config below the root counts (rule 2 of [Detection](#detection)). |
+| `detect.hook_marker` | Text every hook the manager installs contains (rule 3). |
+| `install.subcommand` | The subcommand the shim rewrites. |
+| `install.strategy` | One of the install strategies above. |
+| `install.flag` | For `git-dir-flag`: the long option that takes the git directory. |
+| `home` | Omitted for a manager that keeps nothing outside the repository. |
+| `home.env` | The environment variable that relocates the home. |
+| `home.default`, `home.sandbox`, `home.host` | The three directories above. |
+| `home.expands_tilde` | Whether the manager expands a leading `~` in `home.env`; must be `true` today. |
+
+[`tools/dev/sync-hook-managers.py`](../dev/sync-hook-managers.py) validates the registry against the rules and writes it into the implementations, which run where the registry is not available, as generated blocks:
+
+- [`tools/agent-isolation/hook-manager-shim.sh`](../agent-isolation/hook-manager-shim.sh) — the install rewrite per tool;
+- [`tools/agent-isolation/git-hook-dispatcher.sh`](../agent-isolation/git-hook-dispatcher.sh) — the host home per tool;
+- [`tools/sandbox-lint/`](../sandbox-lint/) — `HOOK_MANAGER_HOMES`, which fails any settings file whose deny list lacks `Edit(<default>/**)`, and a project's settings file whose `env.<var>` is missing or overlaps the default home when the project uses the manager.
+
+It also checks, without writing, that [`.claude/settings.json`](../../.claude/settings.json) and [`tools/sandbox-lint/expected.json`](../sandbox-lint/expected.json) deny every manager's default home and carry the sandbox home of each manager this repository uses.
+It runs as the `sync-hook-managers` prek hook.
+
+## Adapters
+
+| Adapter | Language | Install strategy | Home | Status |
+|---|---|---|---|---|
+| `prek` | Rust | `git-dir-flag` (`--git-dir`); detected by `prek.toml`, `.pre-commit-config.yaml` or `.pre-commit-config.yml` (nested too) or a hook mentioning `prek` | `PREK_HOME`: `~/.cache/prek` → sandbox `~/.cache/prek-sandbox`, host `~/.local/share/prek-host` | shipping |
+
+## Adding an adapter
+
+1. Read the manager's source for the answers the registry needs — where its install writes and whether it honours, refuses or sets `core.hooksPath`; whether a `PATH` shim can reach the install; the home variable, its default, and whether it expands `~`; whether a hook run executes code from the home.
+2. Add the adapter to `registry.json`. If it needs an install strategy the shim does not implement, add the strategy to the shim and to `STRATEGIES` in the generator in the same change.
+3. Give the detection rules: the config file names, whether nested configs count, and a marker every hook it installs contains.
+4. Deny the default home in `.claude/settings.json` and `tools/sandbox-lint/expected.json`, and, if this repository uses the manager, give the sandbox home in their `env`.
+5. Run `tools/dev/sync-hook-managers.py`, then the agent-isolation, sandbox-lint and dev tests, and add a shim test for the new strategy.
+6. Add a row above, and update the troubleshooting and verify pages if the manager brings a failure mode of its own.
+
+## Consumers
+
+- [`setup-isolated-setup-install`](../../plugins/magpie-setup/skills/isolated-setup-install/step-p-sandbox-allowlists.md) Step P.3b-whole-user installs the shim once per adapter the operator uses.
+- [`setup-isolated-setup-verify`](../../plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md) check 19 and [`setup-isolated-setup-update`](../../plugins/magpie-setup/skills/isolated-setup-update/SKILL.md) check the homes through `sandbox-lint`.
diff --git a/tools/hook-manager/registry.json b/tools/hook-manager/registry.json
new file mode 100644
index 000000000..445bcb4da
--- /dev/null
+++ b/tools/hook-manager/registry.json
@@ -0,0 +1,30 @@
+{
+  "$comment": "Hook-manager adapters for the secure agent setup. The contract is tools/hook-manager/README.md; tools/dev/sync-hook-managers.py validates this file and writes it into the scripts that implement the contract.",
+  "adapters": {
+    "prek": {
+      "language": "Rust",
+      "upstream": "https://github.com/j178/prek",
+      "detect": {
+        "config_files": [
+          "prek.toml",
+          ".pre-commit-config.yaml",
+          ".pre-commit-config.yml"
+        ],
+        "nested": true,
+        "hook_marker": "prek"
+      },
+      "install": {
+        "subcommand": "install",
+        "strategy": "git-dir-flag",
+        "flag": "--git-dir"
+      },
+      "home": {
+        "env": "PREK_HOME",
+        "default": "~/.cache/prek",
+        "sandbox": "~/.cache/prek-sandbox",
+        "host": "~/.local/share/prek-host",
+        "expands_tilde": true
+      }
+    }
+  }
+}
diff --git a/tools/sandbox-lint/src/sandbox_lint/__init__.py b/tools/sandbox-lint/src/sandbox_lint/__init__.py
index 910ab8605..75487aa64 100644
--- a/tools/sandbox-lint/src/sandbox_lint/__init__.py
+++ b/tools/sandbox-lint/src/sandbox_lint/__init__.py
@@ -37,6 +37,7 @@ import fnmatch
 import json
 import os
 import re
+import subprocess
 import sys
 import tomllib
 from pathlib import Path
@@ -185,9 +186,6 @@ REQUIRED_PERMISSIONS_DENY = {
     # keeps its own writable default cache; never editable by the agent.
     "Read(~/.config/uv/uv.toml)",
     "Edit(~/.config/uv/**)",
-    # prek's default home, which the hooks of commits made in a terminal run
-    # outside the sandbox: sandboxed prek gets its own (env.PREK_HOME below).
-    "Edit(~/.cache/prek/**)",
     "Read(~/.azure/**)",
     "Read(//**/.env)",
     "Read(//**/.env.local)",
@@ -200,6 +198,29 @@ REQUIRED_PERMISSIONS_DENY = {
     "Bash(kubectl *)",
 }
 
+# Hook managers (tools/hook-manager/registry.json): (tool, home env var,
+# default home, sandbox home, config files, nested configs count, hook marker).
+# The default home is where the hooks of commits made in a terminal run a
+# manager's environments outside the sandbox. It is per-user, shared by every
+# project, so sandboxed commands may never edit it, whatever the project uses.
+# A project that uses the manager also gives sandboxed runs a home of their own
+# through env.<var>; one that does not needs no env.<var>, since its sandboxed
+# runs of the manager then fail on the denied default home.
+# BEGIN generated by tools/dev/sync-hook-managers.py; do not edit
+HOOK_MANAGER_HOMES = (
+    (
+        "prek",
+        "PREK_HOME",
+        "~/.cache/prek",
+        "~/.cache/prek-sandbox",
+        ("prek.toml", ".pre-commit-config.yaml", ".pre-commit-config.yml"),
+        True,
+        "prek",
+    ),
+)
+# END generated by tools/dev/sync-hook-managers.py
+REQUIRED_PERMISSIONS_DENY |= {f"Edit({h[2]}/**)" for h in HOOK_MANAGER_HOMES}
+
 # denyRead must contain ~/ as a base deny. Without it, allowRead would
 # operate over the full filesystem instead of being an inclusion list
 # carved out of a denied home directory.
@@ -252,6 +273,50 @@ _PACKAGE_MANAGER_READS = {
 _IMPLICIT_WRITABLE = ("/tmp", "/private/tmp", "/var/folders", "/private/var/folders")
 
 
+def uses_hook_manager(
+    project_root: Path | None, config_files: tuple[str, ...], nested: bool, marker: str
+) -> bool:
+    """Whether the project uses a hook manager (tools/hook-manager/README.md#detection).
+
+    It does when one of the manager's config files sits at its root, is
+    tracked anywhere below it (for a manager with nested workspace configs),
+    or one of its installed git hooks runs the manager (an install with a
+    custom config path). With no project to look at, every manager counts:
+    the settings may serve a project that uses it.
+    """
+    if project_root is None:
+        return True
+    if any((project_root / name).is_file() for name in config_files):
+        return True
+    git = ["git", "-C", str(project_root)]
+    if nested:
+        tracked = _git_output([*git, "ls-files", "-z", "--", *(f":(glob)**/{name}" for name in config_files)])
+        if tracked:
+            return True
+    common = _git_output([*git, "rev-parse", "--path-format=absolute", "--git-common-dir"]).strip()
+    hooks = Path(common) / "hooks" if common else project_root / ".git" / "hooks"
+    try:
+        candidates = [h for h in hooks.iterdir() if h.is_file() and not h.name.endswith(".sample")]
+    except OSError:
+        return False
+    for hook in candidates:
+        try:
+            if marker in hook.read_text(encoding="utf-8", errors="replace"):
+                return True
+        except OSError:
+            continue
+    return False
+
+
+def _git_output(cmd: list[str]) -> str:
+    """A git command's stdout, or "" when git is missing or the command fails."""
+    try:
+        result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=30)
+    except (OSError, subprocess.SubprocessError):
+        return ""
+    return result.stdout if result.returncode == 0 else ""
+
+
 def _expand(path: str, project_root: Path | None) -> str | None:
     """``path`` as an absolute, normalised string, or None when it is relative
     and there is no project root to anchor it to."""
@@ -628,23 +693,28 @@ def check_invariants(settings: dict[str, Any], project_root: Path | None = None)
                     "(sandbox-add-project-root.sh removes the write it once added)"
                 )
 
-    # Sandboxed prek must not share the home a terminal's git hooks run from:
-    # env.PREK_HOME (which only Claude Code's own commands see) points it at a
-    # directory of its own, outside ~/.cache/prek.
+    # A sandboxed hook manager must not share the home a terminal's git hooks
+    # run from: env.<HOME> (which only Claude Code's own commands see) points
+    # it at a directory of its own, outside the default home. Only for the
+    # managers the project uses; the default-home deny is required for every
+    # manager (REQUIRED_PERMISSIONS_DENY).
     env = settings.get("env")
-    prek_home = env.get("PREK_HOME") if isinstance(env, dict) else None
-    default_prek = os.path.normpath(os.path.expanduser("~/.cache/prek"))
-    expanded_prek = _expand(prek_home, project_root) if isinstance(prek_home, str) and prek_home else None
-    if expanded_prek is None:
-        errors.append(
-            "env.PREK_HOME: missing; set it to ~/.cache/prek-sandbox so sandboxed prek does not "
-            "write the hook environments a terminal's commits run outside the sandbox"
-        )
-    elif _is_writable(expanded_prek, (default_prek,)) or _is_writable(default_prek, (expanded_prek,)):
-        errors.append(
-            f"env.PREK_HOME: {prek_home!r} overlaps ~/.cache/prek, the home a terminal's git hooks "
-            "run from outside the sandbox; use ~/.cache/prek-sandbox"
-        )
+    for tool, var, default, sandbox_home, files, nested, marker in HOOK_MANAGER_HOMES:
+        if not uses_hook_manager(project_root, files, nested, marker):
+            continue
+        given = env.get(var) if isinstance(env, dict) else None
+        default_home = os.path.normpath(os.path.expanduser(default))
+        expanded_home = _expand(given, project_root) if isinstance(given, str) and given else None
+        if expanded_home is None:
+            errors.append(
+                f"env.{var}: missing; set it to {sandbox_home} so sandboxed {tool} does not "
+                "write the hook environments a terminal's commits run outside the sandbox"
+            )
+        elif _is_writable(expanded_home, (default_home,)) or _is_writable(default_home, (expanded_home,)):
+            errors.append(
+                f"env.{var}: {given!r} overlaps {default}, the home a terminal's git hooks "
+                f"run from outside the sandbox; use {sandbox_home}"
+            )
 
     # Same matching as a Bash allow rule (see `permissions.allow` below): a `*`
     # before the end also matches options spliced in at that position, and an
diff --git a/tools/sandbox-lint/tests/test_validator.py b/tools/sandbox-lint/tests/test_validator.py
index c7e82deb6..01615fe24 100644
--- a/tools/sandbox-lint/tests/test_validator.py
+++ b/tools/sandbox-lint/tests/test_validator.py
@@ -20,6 +20,7 @@ from __future__ import annotations
 import copy
 import fnmatch
 import json
+import subprocess
 from pathlib import Path
 from typing import Any
 
@@ -30,6 +31,7 @@ from sandbox_lint import (
     deep_diff,
     main,
     merge_layers,
+    uses_hook_manager,
 )
 
 REPO_ROOT = Path(__file__).resolve().parents[3]
@@ -910,3 +912,64 @@ def test_the_baseline_gives_sandboxed_prek_its_own_home(baseline: dict[str, Any]
     assert baseline["env"]["PREK_HOME"] == "~/.cache/prek-sandbox"
     assert "Edit(~/.cache/prek/**)" in baseline["permissions"]["deny"]
     assert check_invariants(baseline) == []
+
+
+def _git_repo(path: Path) -> Path:
+    subprocess.run(["git", "init", "-q", str(path)], check=True)
+    return path
+
+
+PREK_FILES = ("prek.toml", ".pre-commit-config.yaml", ".pre-commit-config.yml")
+
+
+def _uses_prek(root: Path) -> bool:
+    return uses_hook_manager(root, PREK_FILES, True, "prek")
+
+
+def test_a_project_without_prek_does_not_use_it(tmp_path: Path) -> None:
+    assert not _uses_prek(_git_repo(tmp_path / "r"))
+
+
+@pytest.mark.parametrize("name", PREK_FILES)
+def test_a_root_config_means_the_project_uses_prek(tmp_path: Path, name: str) -> None:
+    repo = _git_repo(tmp_path / "r")
+    (repo / name).write_text("")
+    assert _uses_prek(repo)
+
+
+def test_a_tracked_nested_config_means_the_project_uses_prek(tmp_path: Path) -> None:
+    """prek's workspace mode: a project config below the root."""
+    repo = _git_repo(tmp_path / "r")
+    (repo / "frontend").mkdir()
+    (repo / "frontend" / "prek.toml").write_text("")
+    assert not _uses_prek(repo)  # untracked: not part of the project yet
+    subprocess.run(["git", "-C", str(repo), "add", "frontend/prek.toml"], check=True)
+    assert _uses_prek(repo)
+
+
+def test_an_installed_hook_that_runs_prek_means_the_project_uses_it(tmp_path: Path) -> None:
+    """An install with a custom --config leaves no standard config name behind."""
+    repo = _git_repo(tmp_path / "r")
+    hook = repo / ".git" / "hooks" / "pre-push"
+    hook.write_text('#!/bin/sh\nexec prek hook-impl --config="ci/hooks.yaml" "$@"\n')
+    assert _uses_prek(repo)
+
+
+def test_settings_need_no_prek_home_for_a_project_without_prek(
+    baseline: dict[str, Any], tmp_path: Path
+) -> None:
+    repo = _git_repo(tmp_path / "r")
+    settings = copy.deepcopy(baseline)
+    settings.pop("env", None)
+    assert not any("PREK_HOME" in e for e in check_invariants(settings, project_root=repo))
+    (repo / ".pre-commit-config.yaml").write_text("")
+    errors = check_invariants(settings, project_root=repo)
+    assert any(e.startswith("env.PREK_HOME: missing") for e in errors), errors
+
+
+def test_the_default_prek_home_is_denied_even_without_prek(baseline: dict[str, Any], tmp_path: Path) -> None:
+    """The default home is per-user: another project's terminal commits run from it."""
+    settings = copy.deepcopy(baseline)
+    settings["permissions"]["deny"].remove("Edit(~/.cache/prek/**)")
+    errors = check_invariants(settings, project_root=_git_repo(tmp_path / "r"))
+    assert "permissions.deny: must contain 'Edit(~/.cache/prek/**)'" in errors, errors
diff --git a/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py b/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py
index 7ff4677b7..a7a7609a5 100644
--- a/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py
+++ b/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py
@@ -400,6 +400,7 @@ TOOL_CAPABILITIES = {
     "contract:people",
     "contract:security-tracker",
     "contract:body-field",
+    "contract:hook-manager",
     "substrate:analytics",
     "substrate:sandbox",
     "substrate:action-guard",
diff --git a/tools/spec-loop/specs/agent-isolation-sandbox.md b/tools/spec-loop/specs/agent-isolation-sandbox.md
index b1e3244a8..da719476e 100644
--- a/tools/spec-loop/specs/agent-isolation-sandbox.md
+++ b/tools/spec-loop/specs/agent-isolation-sandbox.md
@@ -249,6 +249,18 @@ existing sandbox grants can widen the baseline. See `docs/adapters/gemini.md`.
   the update skill's drift check and verify check 8 recognise the
   dispatcher flavour's symlinks as its installed shape, not as drift
   or as inert per-repo hooks (#1322, #1358).
+- `tools/hook-manager/` — the contract for git-hook managers (prek
+  today) under the secure setup, `contract:hook-manager`: a manager's
+  install puts its hook in the repository's own
+  `$(git rev-parse --git-common-dir)/hooks`, where the dispatcher
+  chains, and its home is split — `env.<HOME>` gives sandboxed runs a
+  home of their own, `Edit(<default home>/**)` is denied, and the
+  dispatcher gives hooks run outside the sandbox a host-only home under
+  `~/.local/share`. `registry.json` holds the adapters;
+  `tools/dev/sync-hook-managers.py` writes it into
+  `tools/agent-isolation/hook-manager-shim.sh` (installed as
+  `~/.claude/bin/<tool>`, keyed by that name), the dispatcher and
+  `sandbox-lint`, and checks the committed settings against it.
 - `tools/egress-gateway/` — local HTTP(S) forward proxy for egress
   control. Framework tools point `HTTPS_PROXY`/`HTTP_PROXY` at it; the
   gateway rejects any connection to a host not on its allowlist before a
diff --git a/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py b/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py
index 4047df9f9..e1238e9b8 100644
--- a/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py
+++ b/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py
@@ -117,6 +117,10 @@ CONTRACT_POLICY: dict[str, tuple[str, str]] = {
         "Security tracker for embargoed reports (GitHub / local files / private Jira)",
     ),
     "contract:body-field": (AGNOSTIC, "Tracker body `### <Name>` field parsing and rewriting"),
+    "contract:hook-manager": (
+        VENDOR_BACKED,
+        "Git-hook managers under the secure setup: install target and sandbox / host homes (prek, …)",
+    ),
 }
 
 # Which capability *contract* a skill actually invokes, keyed by