Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/labels-and-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +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/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 and overcommit) 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/` |
Expand Down
19 changes: 12 additions & 7 deletions docs/setup/secure-agent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -1428,16 +1428,20 @@ 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 hook-manager PATH shim.** prek honours `core.hooksPath`, so a bare
`prek install` under whole-user scope would write its shim into the
**The hook-manager PATH shim.** prek and overcommit honour `core.hooksPath`,
so a bare install under whole-user scope would write its hook into the
*shared* dir, not the repo. The framework ships a transparent shim
([`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
installed as `~/.claude/bin/<tool>` with `~/.claude/bin` prepended to
PATH) that rewrites **only** the install subcommand — for prek, injecting
`--git-dir "$(git rev-parse --git-common-dir)"`; for overcommit, setting
`core.hooksPath` to `<absolute common dir>/hooks` — so the hook lands
in the repo-local `.git/hooks/` where the dispatcher finds it.
Every other `prek` command passes straight through, and the shim is
Every other command passes straight through, and the shim is
a no-op on hosts with no global `core.hooksPath`.
When the dispatcher chains into an overcommit hook, it exports the same
`core.hooksPath` override so overcommit's run-time self-check accepts the
hook instead of looking for its hook in the shared dir.
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
Expand All @@ -1450,6 +1454,7 @@ 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` |
| overcommit hook in `.git/hooks/` (installed via the shim) | dispatcher → overcommit runs that repo's `.overcommit.yml`; its self-check sees the repo's hooks path |
| local hook installed by a manager requiring no install fix (`simple-git-hooks`, `cargo-husky`, `git-code-format-maven-plugin`, `git-build-hook`) | dispatcher → local hook runs natively (installs straight to `.git/hooks/` ignoring global `core.hooksPath`) |
| 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 |
Expand Down Expand Up @@ -1479,7 +1484,7 @@ Managers using the `none` install strategy (`tools/hook-manager/README.md`) requ

Reversal is the same as simple whole-user (`git config --global
--unset core.hooksPath`), plus removing `~/.claude/bin` from PATH to
restore the stock `prek`.
restore the stock manager binaries.

## Working directories under the read-outside-working-directories block

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -326,28 +326,32 @@ 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 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
2. **Install the hook-manager PATH shim** so the manager's install
writes its hook into the repo-local `.git/hooks/` (where the
dispatcher chains) instead of the shared dir. Copy
[`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)
with an install strategy other than `none`
that a repository the operator works in uses — `~/.claude/bin/prek` today.
A manager whose install strategy is `none` needs no install fix
and gets no shim.
that a repository the operator works in uses — `~/.claude/bin/prek` and
`~/.claude/bin/overcommit` today. A manager whose install strategy is
`none` needs no install fix and gets no shim.
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
`.pre-commit-config.yml`, and for overcommit it is `.overcommit.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
git work tree); for overcommit it rewrites only
`overcommit --install` (or `-i`, `-fi`, `--inst`, anywhere in argv),
pointing `core.hooksPath` at
`<absolute common dir>/hooks`. **Every other invocation passes
through unchanged.** It is a no-op on hosts with no global
`core.hooksPath`. A host that installed the older `prek-shim.sh`
as `~/.claude/bin/prek` keeps working; replacing it is optional.

Expand All @@ -360,9 +364,9 @@ for the full rationale + the validated behaviour matrix.
```

Confirm the rc path with the operator; print the line for them
to paste. Until it is on PATH ahead of the real `prek`, the
shim is inert and `prek install` reverts to writing into the
shared dir.
to paste. Until it is on PATH ahead of the real manager binary,
the shim is inert and the manager's install reverts to writing
into the shared dir.

4. **Tell the operator how their existing prek repos are picked up.**
Repos that already ran `prek install` before this setup have
Expand All @@ -373,7 +377,10 @@ for the full rationale + the validated behaviour matrix.
locally via the shim. Only repos where the shim already landed
in the shared dir (from a `prek install` run *while*
`core.hooksPath` was set but *before* this dispatcher setup)
need a one-time `prek install` re-run through the shim.
need a one-time `prek install` re-run through the shim. An overcommit
repository installed before the switch already has its hook in
`.git/hooks/<name>`; running `overcommit --install` through the shim
rewrites it there after the switch.

The `.` entry stays in the committed project-scope `allowRead`
regardless — the explicit absolute path in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
for installs that do not carry the framework source (see `isolated.py`).
"""

FRAMEWORK_FINGERPRINT = "sha256:656a6e3f62b13ec0"
FRAMEWORK_FINGERPRINT = "sha256:db43bec52c1d2c7c"
4 changes: 2 additions & 2 deletions tools/agent-isolation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,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` (heuristic — tints only if the final assistant message reads as a question/request; a completion stays calm; needs `python3`/`python`, else defaults calm); `PreToolUse` (matcher `AskUserQuestion`) → `wait` (exact — a structured question was posed); `PostToolUse` (matcher `*`) → `reset` (calm while working, and clears the tint the instant you approve a permission prompt or answer a question); `Notification` → `notify` (tints for permission prompts only — the plain idle ping is a no-op so it can't wipe a pending question's tint); and `UserPromptSubmit` + `SessionStart` → `reset` (you replied / fresh session clears any stale tint). Writes the OSC escape to the Claude pty discovered by walking the process tree (hooks have no controlling tty); the only deterministic reset is an explicit `CLAUDE_RESET_BG` colour via OSC 11 (iTerm2 ignores OSC 111). Colours overridable via `CLAUDE_WAIT_BG` / `CLAUDE_RESET_BG`. Tested on iTerm2 + macOS; fail-soft elsewhere. See [`docs/setup/secure-agent-setup.md` → *Waiting-for-input terminal tint*](../../docs/setup/secure-agent-setup.md#waiting-for-input-terminal-tint). |
| [`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: ["."]` does not in practice cover CWD because the harness pre-resolves the `.` literal away from the read side. The same harness behaviour drops the committed home-directory entries, so it also adds the dev-tool paths `prek` and `uv` need (`~/.gitconfig`, `~/.config/git`, `~/.cache`, `~/.local/share/uv`, `~/.local/bin`; the cache and uv dirs writable) as absolute paths — never the credential paths; `--no-tool-paths` skips them. See [`prek` or `uv` not found inside the sandbox](../../docs/setup/sandbox-troubleshooting.md#prek-or-uv-not-found-or-cannot-write-its-cache-inside-the-sandbox). Never modifies user-scope or committed project-scope. Idempotent, atomic, tolerant of missing prereqs. Invoked from `setup-isolated-setup-install`, `/magpie-setup:setup` (adopt / upgrade / worktree-init), and the `post-checkout` git hook installed by `/magpie-setup:setup adopt`. |
| [`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 worktree with a `.claude/` directory. Trade-off documented in [`docs/setup/secure-agent-setup.md` → *Per-project vs whole-user scope*](../../docs/setup/secure-agent-setup.md#per-project-vs-whole-user-scope): `core.hooksPath` shadows per-repo `.git/hooks/*` across every repo on the host. The **dispatcher** flavour (below) supersedes this file. |
| [`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 + inherited stdin so a failing local hook still aborts the git op). Restores per-repo hooks (prek / pre-commit / husky / hand-written) under global `core.hooksPath`; a repo with no local hook is a clean no-op. Outside the sandbox it also gives every hook manager in the [hook-manager registry](../hook-manager/registry.json) its host-only home (the contract's home rule). See [`docs/setup/secure-agent-setup.md` → *Whole-user with the per-repo dispatcher*](../../docs/setup/secure-agent-setup.md#whole-user-with-the-per-repo-dispatcher). |
| [`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) whose install strategy is not `none` (`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 of the shared `core.hooksPath` dir — for prek, 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 invocation passes through unchanged; no-op on hosts with no global `core.hooksPath`. Implements the install-target rule of the [hook-manager contract](../hook-manager/README.md). |
| [`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 + inherited stdin so a failing local hook still aborts the git op). Restores per-repo hooks (prek / overcommit / pre-commit / husky / hand-written) under global `core.hooksPath`; a repo with no local hook is a clean no-op. Outside the sandbox it also gives every hook manager in the [hook-manager registry](../hook-manager/registry.json) its host-only home (the contract's home rule), and before chaining into an overcommit hook it exports the same `core.hooksPath` override the shim used so overcommit's run-time self-check accepts the hook. See [`docs/setup/secure-agent-setup.md` → *Whole-user with the per-repo dispatcher*](../../docs/setup/secure-agent-setup.md#whole-user-with-the-per-repo-dispatcher). |
| [`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) whose install strategy is not `none` (`prek` and `overcommit` today). The tool is the name it is installed under. Rewrites **only** the adapter's install action so the hook lands in the repo-local `.git/hooks/` where the dispatcher chains, instead of the shared `core.hooksPath` dir — for prek, 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; for overcommit, finding `--install` or its `-i` alias anywhere in argv and setting `core.hooksPath` to `<absolute common dir>/hooks` in the next free `GIT_CONFIG_*` slot. Every other invocation passes through unchanged; no-op on hosts with no global `core.hooksPath`. Implements the install-target rule of the [hook-manager contract](../hook-manager/README.md). |

## Usage at a glance

Expand Down
32 changes: 31 additions & 1 deletion tools/agent-isolation/git-hook-dispatcher.sh
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ if [ -x "$local_hook" ] && [ "$lh_canon" != "$self_canon" ]; then
fi

# --- hook managers' homes outside the sandbox -----------------------------
# A hook manager (prek, ...: tools/hook-manager/registry.json) runs a hook from
# A hook manager (prek, overcommit, ...: 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
Expand All @@ -130,6 +130,7 @@ fi
# 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"
hook_manager_runtime_envs="overcommit:overcommit"
# END generated by tools/dev/sync-hook-managers.py
if [ -z "${SANDBOX_RUNTIME:-}" ]; then
for _entry in $hook_manager_homes; do
Expand All @@ -152,6 +153,35 @@ if [ -z "${SANDBOX_RUNTIME:-}" ]; then
esac
fi

# --- managers that re-read core.hooksPath at run time -----------------------
# overcommit re-checks its install on every hook run and compares the result
# with `git config --get core.hooksPath`. While the dispatcher chains into its
# hook, that value must name the repository's own hooks dir, not the shared
# one, or overcommit's self-update check fails. Entries: <name>:<hook marker>.
# The override is exported, so git commands the hook starts inherit it. A
# command aimed at another repository (a submodule, a temporary clone,
# `git -C <other>`) then sees this repository's hooks path instead of the
# target's dispatcher; only overcommit's self-check needs the value, so this
# remains a documented limitation of the strategy (tools/hook-manager/README.md).
apply_hook_manager_runtime_env() {
for _entry in $hook_manager_runtime_envs; do
_marker="${_entry#*:}"
if grep -q -- "$_marker" "$local_hook" 2>/dev/null; then
_i="${GIT_CONFIG_COUNT:-0}"
case "$_i" in
''|*[!0-9]*) _i=0 ;;
esac
export "GIT_CONFIG_KEY_$_i=core.hooksPath"
export "GIT_CONFIG_VALUE_$_i=$common/hooks"
export GIT_CONFIG_COUNT=$((_i + 1))
fi
done
}

if [ "$has_local" = 1 ]; then
apply_hook_manager_runtime_env
fi

# --- non-post-checkout: exec local for a perfect passthrough ---------------
if [ "$hook" != "post-checkout" ]; then
if [ "$has_local" = 1 ]; then
Expand Down
Loading
Loading