Skip to content

Commit a26be71

Browse files
Merge pull request #60 from robercano/fix/bump-plugin-version-for-hooks-fix
harness: hooks.json fix + git-add guard + sandbox git-config docs (v0.1.2)
2 parents a46f3e9 + 8018742 commit a26be71

8 files changed

Lines changed: 277 additions & 19 deletions

File tree

.claude/.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
"name": "Roberto Cano"
55
},
66
"description": "Multi-agent orchestration harness: fan out sub-tasks to isolated worktree implementers, gate them, and route results through reviewers.",
7-
"version": "0.1.1",
7+
"version": "0.1.2",
88
"plugins": [
99
{
1010
"name": "orchestrator",
1111
"source": "./",
1212
"description": "Multi-agent orchestration harness: fan out sub-tasks to isolated worktree implementers, gate them, and route results through reviewers.",
13-
"version": "0.1.1",
13+
"version": "0.1.2",
1414
"author": {
1515
"name": "Roberto Cano"
1616
}

.claude/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "orchestrator",
3-
"version": "0.1.1",
3+
"version": "0.1.2",
44
"description": "Multi-agent orchestration harness: fan out sub-tasks to isolated worktree implementers, gate them, and route results through reviewers.",
55
"author": { "name": "Roberto Cano" }
66
}

.claude/hooks/hooks.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
{
22
"hooks": {
3+
"PreToolUse": [
4+
{
5+
"matcher": "Bash",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guard-git-add.py"
10+
}
11+
]
12+
}
13+
],
314
"PostToolUse": [
415
{
516
"matcher": "Edit|Write",

.claude/scripts/guard-git-add.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
#!/usr/bin/env python3
2+
"""PreToolUse guard: stop blanket `git add`/`git commit -a` when the worktree
3+
contains sandbox `/dev/null` device-node masks.
4+
5+
Under the hardened sandbox, Claude Code bind-mounts /dev/null over sensitive
6+
config paths (.mcp.json, .gitconfig, .claude/{launch.json,routines,...}, editor
7+
dirs). Git cannot index a character-device node, so `git add -A` / `git add .` /
8+
`git commit -a` abort the *whole* commit with:
9+
10+
error: <path>: can only add regular files, symbolic links or git-directories
11+
fatal: adding files failed
12+
13+
Explicit path staging (`git add <path> ...`) skips the masks and works fine, so
14+
this guard blocks only the blanket forms — and only when masks are actually
15+
present (i.e. inside the sandbox). Outside the sandbox it is a no-op, so it never
16+
obstructs consumers who don't run hardened.
17+
18+
Contract: reads the PreToolUse event JSON on stdin. Exit 0 = allow. Exit 2 =
19+
block (stderr is shown to the agent).
20+
"""
21+
import json
22+
import os
23+
import re
24+
import shlex
25+
import stat as _stat
26+
import subprocess
27+
import sys
28+
29+
# Shell separators that terminate one simple command.
30+
_SEP = re.compile(r"&&|\|\||;|\||\n")
31+
32+
33+
def _segments(cmd):
34+
return [s.strip() for s in _SEP.split(cmd) if s.strip()]
35+
36+
37+
def _tokens(segment):
38+
try:
39+
return shlex.split(segment)
40+
except ValueError:
41+
# Unbalanced quotes etc. — fall back to whitespace split.
42+
return segment.split()
43+
44+
45+
def _short_flag_has(tok, letter):
46+
"""True if tok is a single-dash short flag bundle containing `letter`
47+
(e.g. -a, -am). Excludes long options like --all / --amend."""
48+
return (
49+
tok.startswith("-")
50+
and not tok.startswith("--")
51+
and letter in tok[1:]
52+
)
53+
54+
55+
def _is_blanket(cmd):
56+
for seg in _segments(cmd):
57+
toks = _tokens(seg)
58+
if "git" not in toks:
59+
continue
60+
gi = toks.index("git")
61+
rest = toks[gi + 1 :]
62+
if "add" in rest:
63+
args = rest[rest.index("add") + 1 :]
64+
for a in args:
65+
if a in ("-A", "--all", "."):
66+
return True
67+
if _short_flag_has(a, "A"): # bundled, e.g. -Av
68+
return True
69+
if "commit" in rest:
70+
args = rest[rest.index("commit") + 1 :]
71+
for a in args:
72+
if a == "--all":
73+
return True
74+
if _short_flag_has(a, "a"): # -a, -am, -a -m (not --amend)
75+
return True
76+
return False
77+
78+
79+
def _has_device_masks():
80+
"""True if the worktree contains a /dev/null character-device mask.
81+
82+
The sandbox bind-mounts /dev/null over sensitive config paths. Crucially, a
83+
directory entry's readdir `d_type` still reports the *underlying* regular
84+
file, so `find -type c` (and DirEntry.is_*) miss the mask — only an actual
85+
`stat()` follows the bind-mount and reports S_IFCHR. So we os.stat() entries
86+
ourselves. Masks always include repo-root dotfiles (.gitconfig, .mcp.json,
87+
shell rc, editor dirs) and .claude/* items, so a shallow scan of those two
88+
dirs is enough and stays fast on every Bash call.
89+
"""
90+
try:
91+
root = subprocess.run(
92+
["git", "rev-parse", "--show-toplevel"],
93+
capture_output=True, text=True, timeout=5,
94+
).stdout.strip() or "."
95+
except Exception:
96+
root = "."
97+
for d in (root, os.path.join(root, ".claude")):
98+
try:
99+
with os.scandir(d) as it:
100+
for e in it:
101+
try:
102+
if _stat.S_ISCHR(os.stat(e.path).st_mode):
103+
return True
104+
except OSError:
105+
continue
106+
except OSError:
107+
continue
108+
return False
109+
110+
111+
def _sandbox_enabled():
112+
"""True if the Bash sandbox is enabled for this session.
113+
114+
The PreToolUse hook runs *outside* the sandbox, so it cannot see the
115+
/dev/null device-node masks — they exist only inside the per-command bwrap
116+
namespace, and os.stat here reports the masked paths as absent. So instead of
117+
detecting the symptom (masks), detect the cause: an enabled sandbox. When it
118+
is on, a blanket `git add` run as a sandboxed Bash command will hit the masks
119+
and abort, so we block preemptively. When it is off (non-hardened consumers),
120+
this returns False and the guard is a no-op.
121+
"""
122+
home = os.path.expanduser("~")
123+
pdir = os.environ.get("CLAUDE_PROJECT_DIR", ".")
124+
for path in (
125+
os.path.join(pdir, ".claude", "settings.local.json"),
126+
os.path.join(pdir, ".claude", "settings.json"),
127+
os.path.join(home, ".claude", "settings.json"),
128+
):
129+
try:
130+
with open(path) as f:
131+
cfg = json.load(f)
132+
except Exception:
133+
continue
134+
if (cfg.get("sandbox") or {}).get("enabled") is True:
135+
return True
136+
return False
137+
138+
139+
def main():
140+
try:
141+
data = json.load(sys.stdin)
142+
except Exception:
143+
return 0
144+
if data.get("tool_name") != "Bash":
145+
return 0
146+
cmd = (data.get("tool_input") or {}).get("command", "")
147+
if not cmd or "git" not in cmd:
148+
return 0
149+
if not _is_blanket(cmd):
150+
return 0
151+
# Fire when the sandbox is active (its masks will abort the blanket add) or,
152+
# should a future Claude Code run hooks sandboxed, when masks are visible.
153+
if not (_sandbox_enabled() or _has_device_masks()):
154+
return 0
155+
156+
sys.stderr.write(
157+
"Blocked: a blanket `git add -A/./--all` or `git commit -a` will try to "
158+
"index this sandbox's /dev/null device-node masks (the `crw-` entries in "
159+
"`git status`) and abort the whole commit "
160+
"(`can only add regular files, symbolic links or git-directories`).\n"
161+
"Stage the files you actually changed, by name:\n"
162+
' git add <path1> <path2> && git commit -m "..."\n'
163+
"The `crw-` entries are sandbox masks, not your work — ignore them. "
164+
"See docs/HARDENING.md -> Caveats.\n"
165+
)
166+
return 2
167+
168+
169+
if __name__ == "__main__":
170+
sys.exit(main())

.claude/settings.json

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"_README": "Generic harness settings. Hooks call .claude/scripts/gate.sh (commands live in .claude/gates.json) — you usually edit gates.json, not this file. The 'allow' list pre-approves the agent command surface so PARALLEL workers never block on permission prompts (an unapproved command in one subagent stalls the fan-out) and so the harness never has to persist a grant into this tracked file mid-run. Build/lint/test are covered generically via the gate.sh wildcard; if your agents run package-manager/build tools DIRECTLY (not via gate.sh), add those prefixes too — e.g. 'Bash(pnpm -r:*)', 'Bash(forge test:*)', 'Bash(cargo test:*)', 'Bash(go test:*)'. NOTE: the harness OWNS this file at runtime — it may rewrite the working-tree copy with its own session grant list, so the COMMITTED version is the source of truth and hand-edits won't persist mid-session. To commit a clean version despite that rewrite, stage via the git index: `sha=$(git hash-object -w .claude/settings.json) && git update-index --cacheinfo 100644,$sha,.claude/settings.json`. See docs/USAGE.md and the project's notes on concurrent config writes (anthropics/claude-code#29217).",
3-
43
"hooks": {
54
"PostToolUse": [
65
{
@@ -22,18 +21,26 @@
2221
}
2322
]
2423
}
24+
],
25+
"PreToolUse": [
26+
{
27+
"matcher": "Bash",
28+
"hooks": [
29+
{
30+
"type": "command",
31+
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/scripts/guard-git-add.py\""
32+
}
33+
]
34+
}
2535
]
2636
},
27-
2837
"permissions": {
2938
"allow": [
3039
"Read(//**)",
31-
3240
"Bash(bash .claude/scripts/gate.sh:*)",
3341
"Bash(bash .claude/scripts/notify-poll.sh:*)",
3442
"Bash(bash .claude/scripts/pr-feedback.sh:*)",
3543
"Bash(bash .claude/scripts/merge-ready.sh:*)",
36-
3744
"Bash(git status:*)",
3845
"Bash(git diff:*)",
3946
"Bash(git log:*)",
@@ -55,13 +62,11 @@
5562
"Bash(git rev-parse:*)",
5663
"Bash(git hash-object:*)",
5764
"Bash(git update-index:*)",
58-
5965
"Bash(gh issue:*)",
6066
"Bash(gh pr:*)",
6167
"Bash(gh label:*)",
6268
"Bash(gh repo view:*)",
6369
"Bash(gh auth status)",
64-
6570
"Bash(ls:*)",
6671
"Bash(pwd)",
6772
"Bash(mkdir:*)",
@@ -118,17 +123,40 @@
118123
"enabled": true,
119124
"allowUnsandboxedCommands": true,
120125
"filesystem": {
121-
"denyRead": ["/mnt"]
126+
"denyRead": [
127+
"/mnt"
128+
]
122129
},
123130
"credentials": {
124131
"files": [
125-
{ "path": "~/.ssh", "mode": "deny" },
126-
{ "path": "~/.aws", "mode": "deny" },
127-
{ "path": "~/.config/gcloud", "mode": "deny" },
128-
{ "path": "~/.kube", "mode": "deny" },
129-
{ "path": "~/.gnupg", "mode": "deny" },
130-
{ "path": "~/.npmrc", "mode": "deny" },
131-
{ "path": "~/.docker/config.json", "mode": "deny" }
132+
{
133+
"path": "~/.ssh",
134+
"mode": "deny"
135+
},
136+
{
137+
"path": "~/.aws",
138+
"mode": "deny"
139+
},
140+
{
141+
"path": "~/.config/gcloud",
142+
"mode": "deny"
143+
},
144+
{
145+
"path": "~/.kube",
146+
"mode": "deny"
147+
},
148+
{
149+
"path": "~/.gnupg",
150+
"mode": "deny"
151+
},
152+
{
153+
"path": "~/.npmrc",
154+
"mode": "deny"
155+
},
156+
{
157+
"path": "~/.docker/config.json",
158+
"mode": "deny"
159+
}
132160
]
133161
}
134162
}

.claude/skills/setup/templates/CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,9 @@ re-scoped by the orchestrator, never reached across by a worker.
3434
## Don'ts
3535
- Don't put secrets in the repo.
3636
- Don't bypass the gates.
37+
- Don't `git add -A` / `git add .` / `git commit -a`**stage explicit paths by name.** Under the sandbox,
38+
masked config paths (`.mcp.json`, `.gitconfig`, `.claude/{launch.json,routines,…}`, editor dirs) appear as
39+
`/dev/null` character-device nodes; git can't index a device node, so a blanket add aborts the whole commit
40+
(`can only add regular files, symbolic links or git-directories`). Ignore any `crw-` entries in `git status`
41+
— they're sandbox masks, not your changes. See `docs/HARDENING.md` → Caveats.
3742
- <project-specific landmines>

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,9 @@ re-scoped by the orchestrator, never reached across by a worker.
3434
## Don'ts
3535
- Don't put secrets in the repo.
3636
- Don't bypass the gates.
37+
- Don't `git add -A` / `git add .` / `git commit -a`**stage explicit paths by name.** Under the sandbox,
38+
masked config paths (`.mcp.json`, `.gitconfig`, `.claude/{launch.json,routines,…}`, editor dirs) appear as
39+
`/dev/null` character-device nodes; git can't index a device node, so a blanket add aborts the whole commit
40+
(`can only add regular files, symbolic links or git-directories`). Ignore any `crw-` entries in `git status`
41+
— they're sandbox masks, not your changes. See `docs/HARDENING.md` → Caveats.
3742
- <project-specific landmines>

docs/HARDENING.md

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,38 @@ Starting point — adapt the lists to your stack, then drop into `.claude/settin
194194
> (e.g. a git op against a repo outside the sandbox root). Network access (registry, GitHub) is a
195195
> separate axis — see `sandbox.network.allowedDomains` in Step 3.
196196
197+
> **Git config/hook writes are denied by default — and you can't re-enable them in-sandbox. Use a real
198+
> terminal.** The sandbox lets `git commit` update refs and the index but keeps `.git/config` **and**
199+
> `.git/hooks/` writes denied (see the [sandbox docs](https://code.claude.com/docs/en/sandboxing):
200+
> *"Writes to `hooks/` and `config` inside that directory remain denied"*). That's on purpose — git
201+
> config and hooks are an **arbitrary-code-execution surface** (`core.pager`, `core.fsmonitor`,
202+
> `core.hooksPath`, `alias.* = !cmd`, `filter.*.clean/smudge`, a committed `pre-commit` hook…), any of
203+
> which fires the next time git runs. So the mask is *why* `git config --local`, `git remote add`, and
204+
> upstream tracking fail under strict mode while ordinary `git commit`/`diff`/`log` work. It's enforced
205+
> as a **`/dev/null` bind-mount over `.git/config.lock`**: git creates that lockfile with
206+
> `O_CREAT|O_EXCL` before renaming it over `config`, and the device node already occupying the path makes
207+
> the exclusive-create fail — hence `error: could not lock config file .git/config: File exists`. It's
208+
> also the phantom `crw-` `config.lock` you see in `git status` (see Caveats).
209+
>
210+
> **`sandbox.filesystem.allowWrite` does *not* lift this** — verified 2026-07-07: with
211+
> `allowWrite: [".git/config", ".git/config.lock", ".git/worktrees"]` set and Claude Code restarted, the
212+
> `/dev/null` mask on `.git/config.lock` persisted and `git config --local` still failed with `File
213+
> exists`. The mask is applied at the **mount layer** as a built-in git protection; `allowWrite` only
214+
> adjusts the **permission layer**, so it can't dislodge the bind-mount. Don't add these paths to
215+
> `allowWrite` expecting config writes to work — they won't.
216+
>
217+
> The only setting that removes the mask is `excludedCommands: ["git"]`, and you should **not** use it:
218+
> that runs git *and every subprocess it spawns* fully **unsandboxed**, so a poisoned pager/hook/alias
219+
> executes with network, credential-dir, and host-filesystem access — you've handed the ACE surface a way
220+
> out (and `excludedCommands` has a [write/unlink bug, #39078](https://github.com/anthropics/claude-code/issues/39078)
221+
> on top). Keeping git sandboxed is the whole point; the config mask is a feature, not a bug.
222+
>
223+
> **So when you genuinely need a git config/hook write** (`git config`, `git remote add`, setting
224+
> upstreams, installing a hook), run it in a **real terminal outside Claude Code** — the mask exists only
225+
> inside the sandbox, so the same command works normally there. This is the same rule as `git config
226+
> --global` / `~/.gitconfig` edits (see Caveats). Note `git commit`, `git worktree add` (basic), and ref
227+
> updates are *not* affected — those write refs/index/HEAD, which the sandbox allows.
228+
197229
---
198230

199231
## Step 2 — OS-level isolation
@@ -351,8 +383,15 @@ agent operates *inside*, not one it configures.
351383
editor dirs, `.mcp.json`, and Claude's own `.claude/{hooks,skills,routines,launch.json}`). In a
352384
sandboxed view these appear as **character-device files** (`ls -l` shows `crw-rw-rw- … 1, 3`), which
353385
`git status` reports as untracked/modified even though they aren't real project files. This is expected,
354-
not corruption. Two consequences: (1) **never `git add -A` / `git commit -a`** — git can't index a
355-
device node and the commit may abort; stage explicit paths instead (the agent instructions enforce this).
386+
not corruption. (The `.git/config.lock` device is the same thing — a mask, not a stale lock; there's no
387+
lock to remove, and `allowWrite` can't dislodge it. If you need git's config writes to land, run them in
388+
a real terminal — see the git-config note in the strict-mode section above.) Two consequences: (1)
389+
**never `git add -A` / `git commit -a`** — git
390+
can't index a device node and the commit may abort; stage explicit paths instead. This is enforced by
391+
the agent prompts **and** a plugin `PreToolUse` hook (`.claude/scripts/guard-git-add.py`) that blocks
392+
blanket `git add -A/./--all` and `git commit -a`. Note the hook runs *outside* the sandbox, so it can't
393+
see the `/dev/null` masks directly (`os.stat` reports them absent); it keys off `sandbox.enabled` in
394+
settings instead — active only when the sandbox is on, a no-op for non-hardened repos.
356395
(2) The unambiguous personal dotfiles are gitignored so they don't surface; `.mcp.json`/`.gitmodules`/
357396
`.claude/*` are deliberately *not* ignored (they can be real), so rely on explicit staging there.
358397
- **Open PRs gate loop advancement.** A typical loop won't start a new ticket while a PR is open —

0 commit comments

Comments
 (0)