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 and overcommit) 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, overcommit and lefthook 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/` |
Expand Down
50 changes: 47 additions & 3 deletions docs/setup/sandbox-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,24 @@
- [Root cause](#root-cause-10)
- [Fix](#fix-10)
- [Notes](#notes-9)
- [Reads of `~/.claude/magpie` or `/tmp/claude-<uid>` ask for approval every time](#reads-of-claudemagpie-or-tmpclaude-uid-ask-for-approval-every-time)
- [`lefthook install` refuses with "Custom hooks paths are not supported by default"](#lefthook-install-refuses-with-custom-hooks-paths-are-not-supported-by-default)
- [Symptom](#symptom-11)
- [Root cause](#root-cause-11)
- [Fix](#fix-11)
- [Notes](#notes-10)
- [Every Bash call fails with `bwrap: Can't create file … Read-only file system`](#every-bash-call-fails-with-bwrap-cant-create-file--read-only-file-system)
- [Reads of `~/.claude/magpie` or `/tmp/claude-<uid>` ask for approval every time](#reads-of-claudemagpie-or-tmpclaude-uid-ask-for-approval-every-time)
- [Symptom](#symptom-12)
- [Root cause](#root-cause-12)
- [Fix](#fix-12)
- [`git status` shows files you never created, such as `.mcp.json` or `.bash_profile`](#git-status-shows-files-you-never-created-such-as-mcpjson-or-bash_profile)
- [Notes](#notes-11)
- [Every Bash call fails with `bwrap: Can't create file … Read-only file system`](#every-bash-call-fails-with-bwrap-cant-create-file--read-only-file-system)
- [Symptom](#symptom-13)
- [Root cause](#root-cause-13)
- [Fix](#fix-13)
- [`git status` shows files you never created, such as `.mcp.json` or `.bash_profile`](#git-status-shows-files-you-never-created-such-as-mcpjson-or-bash_profile)
- [Symptom](#symptom-14)
- [Root cause](#root-cause-14)
- [Fix](#fix-14)
- [Adding a new entry](#adding-a-new-entry)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->
Expand Down Expand Up @@ -1236,6 +1241,45 @@ resolved path. Confirm with `git hook run pre-commit` from the agent.

---

## `lefthook install` refuses with "Custom hooks paths are not supported by default"

### Symptom

A repository that uses lefthook has no working hooks, and `lefthook install` fails:

```console
$ lefthook install
core.hooksPath is set globally to '/Users/<you>/.claude/git-hooks'
Custom hooks paths are not supported by default.
...
```

### Root cause

Whole-user scope sets a global `core.hooksPath`, and lefthook refuses to install while a scoped `git config --global` read returns one.
That is the safe failure: the alternative, `--force`, writes lefthook's hook into the shared directory and replaces the framework's dispatcher for every repository on the host.

### Fix

Run `lefthook install` through the framework shim, which hides the global config from that read and leaves the hook in the repository's own `$(git rev-parse --git-common-dir)/hooks/`:

```bash
~/.claude/bin/lefthook install
```

With `~/.claude/bin` ahead of the real binary on PATH, plain `lefthook install` does that already.
The setup installs the shim only for a repository that uses lefthook: a root `lefthook.*`, `.lefthook.*` or `-local` config, or an installed hook mentioning `call_lefthook`.

### Notes

- Do not run `lefthook install --reset-hooks-path` or `--force` to get past the error: the first unsets the whole-user setup, and the second overwrites the dispatcher for every repository.
The agent-guard `lefthook-hooks-path` guard blocks both, in the `--flag` and `--flag=true` spellings, and needs `MAGPIE_ALLOW_LEFTHOOK_HOOKS_PATH=1` to proceed deliberately.
- An npm `postinstall` runs `node_modules/.bin/lefthook`, which the shim does not cover; that install refuses for the same reason, and the repository needs one run through the shim.
- The shim hides the global config only while it holds a `core.hooksPath`, so on a host without one the install keeps the operator's `credential.helper`, `url.*.insteadOf` and `safe.directory`.
- Per-project scope has no global `core.hooksPath`, so the refusal does not happen there.

---

## Reads of `~/.claude/magpie` or `/tmp/claude-<uid>` ask for approval every time

### Symptom
Expand Down
5 changes: 4 additions & 1 deletion docs/setup/secure-agent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -1442,10 +1442,13 @@ 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.
lefthook refuses to install at all while a global `core.hooksPath` is set, so the same shim installed as `~/.claude/bin/lefthook` runs `lefthook install` with `GIT_CONFIG_GLOBAL=/dev/null` whenever the global config holds one, and lefthook then resolves its own target to the common dir.
`lefthook install --force` and `--reset-hooks-path` would still get that override through the shim (they cannot reach the shared dir or unset the setup), and the agent-guard `lefthook-hooks-path` guard blocks an agent from trying.
The shim covers the CLI only: an npm `postinstall` runs `node_modules/.bin/lefthook`, ahead of `~/.claude/bin` on PATH.
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, …)
installed under, and other hook managers (pre-commit, husky, …)
join by adding an adapter there.

Validated behaviour under this flavour (`core.hooksPath` global +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2337,8 +2337,9 @@ def guard_vetted_ops_spelling(seg: Segment, cwd: str | None = None, harness: str

# Only commands in these families are inspected; everything else takes the
# instant fast path. Bundled and contributed guards alike operate on the
# `gh` / `git` outbound/destructive surface.
GUARDED_HEADS = frozenset({"gh", "git"})
# `gh` / `git` outbound/destructive surface and on `lefthook`, whose
# `install` can undo the whole-user hook setup.
GUARDED_HEADS = frozenset({"gh", "git", "lefthook"})

# Colon-separated extra guard directories, searched before the discovered ones.
# Skill-owned guards no longer need it (``framework_root`` finds them in place);
Expand Down Expand Up @@ -2597,12 +2598,32 @@ def run(self, args: list[str]) -> str | None:
return _run(args, cwd=self.cwd)


def guarded_head(argv: list[str]) -> str | None:
"""The guarded command family a segment runs, or None.

The head is usually the bare name, but a manager installed from a package
tree is reached by path (``node_modules/.bin/lefthook``), so the basename
counts too.
"""
if not argv:
return None
head = argv[0]
if head in GUARDED_HEADS:
return head
base = head.replace("\\", "/").rsplit("/", 1)[-1]
if base.lower().endswith(".exe"):
base = base[:-4]
return base if base in GUARDED_HEADS else None


def command_kinds(seg: Segment) -> set[str]:
"""The command-family tags a segment matches, e.g. ``{"git", "git:commit"}``."""
kinds: set[str] = set()
if not seg.argv:
return kinds
head = seg.argv[0]
head = guarded_head(seg.argv)
if head is None:
return kinds
kinds.add(head)
if head == "git":
# A TRIGGERS = ["git:commit"] contributed guard must still fire behind
Expand All @@ -2615,6 +2636,11 @@ def command_kinds(seg: Segment) -> set[str]:
# Same for TRIGGERS = ["gh:pr"] behind `gh -R <repo> pr ...`.
sub = gh_subcommand(seg.argv)
kinds.add(f"{head}:{sub[0] if sub else seg.argv[1]}")
elif head == "lefthook":
# lefthook accepts global flags before the subcommand.
lefthook_sub = next((tok for tok in seg.argv[1:] if not tok.startswith("-")), None)
if lefthook_sub:
kinds.add(f"lefthook:{lefthook_sub}")
return kinds


Expand Down Expand Up @@ -2782,7 +2808,7 @@ def dispatch(command: str, cwd: str | None = None, harness: str | None = None) -
if reason:
return reason
continue
if not seg.argv or seg.argv[0] not in GUARDED_HEADS:
if not seg.argv or guarded_head(seg.argv) is None:
continue # fast path — non-guarded command family
# Bundled guards (trusted, in-process; each self-filters its command).
for builtin in BUILTIN_GUARDS:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 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.

"""setup-isolated-setup-install lefthook hooks-path guard (skill-contributed).

`lefthook install --force` writes lefthook's hook into the directory the global
`core.hooksPath` names, over the framework's dispatcher, and
`--reset-hooks-path` unsets the whole-user setup. Both are destructive when
that path is the framework's own. Discovered by the agent-guard dispatcher;
import-free (uses ``ctx``).
"""

import os

TRIGGERS = ["lefthook:install"]

FRAMEWORK_HOOKS_PATHS = ("~/.claude/git-hooks", "~/.claude-config/git-hooks")


def _install_arguments(argv):
"""The arguments after the ``install`` subcommand, or an empty list."""
for index, token in enumerate(argv[1:], start=1):
if token.startswith("-"):
continue
return argv[index + 1 :] if token == "install" else []
return []


def _destructive_flag(argument):
# lefthook takes the `--flag=value` spelling for its boolean flags, so match
# the name and read the value separately: `--force=true` is destructive,
# `--force=false` is not.
name, _, value = argument.partition("=")
off = value.lower() in ("false", "0", "f", "no", "off")
if name in ("-f", "--force"):
return None if off else "--force"
if name in ("-r", "--reset-hooks-path"):
return None if off else "--reset-hooks-path"
if name.startswith("-") and not name.startswith("--"):
# In a bundle (`-fr=false`) the value belongs to the last switch only;
# every switch before it is set.
letters = name[1:-1] if off else name[1:]
if "f" in letters:
return "--force"
if "r" in letters:
return "--reset-hooks-path"
return None


def _is_framework_path(value):
actual = os.path.normcase(os.path.realpath(os.path.expanduser(value)))
return any(
actual == os.path.normcase(os.path.realpath(os.path.expanduser(name)))
for name in FRAMEWORK_HOOKS_PATHS
)


def guard(ctx):
flag = None
for argument in _install_arguments(ctx.argv):
flag = _destructive_flag(argument)
if flag:
break
if flag is None:
return None
if ctx.override("MAGPIE_ALLOW_LEFTHOOK_HOOKS_PATH"):
return None
hooks_path = ctx.run(["git", "config", "--global", "--get", "core.hooksPath"])
if not hooks_path or not _is_framework_path(hooks_path):
return None
effect = "unset" if flag == "--reset-hooks-path" else "replace"
return (
f"agent-guard[lefthook-hooks-path]: `lefthook install {flag}` would {effect} the "
f"framework's global core.hooksPath ({hooks_path}). Run plain `lefthook install`: "
"the ~/.claude/bin/lefthook shim keeps the hook in this repository's own hooks "
"dir. Override: MAGPIE_ALLOW_LEFTHOOK_HOOKS_PATH=1."
)
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,10 @@ acknowledge it explicitly; no silent proceed:
> runs the framework's own logic (the `post-checkout`
> sandbox-allowlist sync) **and then chains through to that
> repo's own `.git/hooks/<name>`**. Your per-repo hooks keep
> firing, and a repo with no hook is a clean no-op. A bundled
> `prek` PATH shim makes `prek install` write its shim into the
> repo-local `.git/hooks/` (via `--git-dir`) so the dispatcher
> can find it.
> firing, and a repo with no hook is a clean no-op. Bundled
> PATH shims for `prek` and `lefthook` make their `install` write
> into the repo-local `.git/hooks/` (prek via `--git-dir`, lefthook
> with the global config hidden) so the dispatcher can find it.
>
> Either way, whole-user scope is reversible:
> `git config --global --unset core.hooksPath` restores per-repo
Expand Down Expand Up @@ -326,34 +326,39 @@ 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 the manager's install
writes its hook into the repo-local `.git/hooks/` (where the
dispatcher chains) instead of the shared dir. Copy
2. **Install the hook-manager PATH shim** so a 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` and
`~/.claude/bin/overcommit` 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`,
`~/.claude/bin/overcommit` and `~/.claude/bin/lefthook` 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`, 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); 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.
`.pre-commit-config.yml`; for overcommit, `.overcommit.yml`; for lefthook,
its root `lefthook.*`, `.lefthook.*` and `-local` names.
Install no shim for a manager no repository uses.
The shim takes the tool from the name it is installed under and rewrites only
the install action: for prek it injects
`--git-dir "$(git rev-parse --git-common-dir)"` unless the caller already
passed `--git-dir`;
for overcommit it rewrites `overcommit --install` (or `-i`, `-fi`, `--inst`,
anywhere in argv), pointing `core.hooksPath` at `<absolute common dir>/hooks`;
for lefthook it runs the install with `GIT_CONFIG_GLOBAL=/dev/null` while the
global config holds one, which hides the global `core.hooksPath` from
lefthook's own check and lets its target resolve to the common dir.
Each leaves the install alone when the caller asked for `--help` or is outside
a git work tree, and **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.

3. **Surface the PATH line for the operator to add** to their
`~/.bashrc` / `~/.zshrc` (per the golden rules — never edit the
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:db43bec52c1d2c7c"
FRAMEWORK_FINGERPRINT = "sha256:68ceca4bde12d81e"
Loading
Loading