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