Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .claude/.claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
"name": "Roberto Cano"
},
"description": "Multi-agent orchestration harness: fan out sub-tasks to isolated worktree implementers, gate them, and route results through reviewers.",
"version": "0.1.1",
"version": "0.1.2",
"plugins": [
{
"name": "orchestrator",
"source": "./",
"description": "Multi-agent orchestration harness: fan out sub-tasks to isolated worktree implementers, gate them, and route results through reviewers.",
"version": "0.1.1",
"version": "0.1.2",
"author": {
"name": "Roberto Cano"
}
Expand Down
2 changes: 1 addition & 1 deletion .claude/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "orchestrator",
"version": "0.1.1",
"version": "0.1.2",
"description": "Multi-agent orchestration harness: fan out sub-tasks to isolated worktree implementers, gate them, and route results through reviewers.",
"author": { "name": "Roberto Cano" }
}
11 changes: 11 additions & 0 deletions .claude/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/guard-git-add.py"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
Expand Down
170 changes: 170 additions & 0 deletions .claude/scripts/guard-git-add.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""PreToolUse guard: stop blanket `git add`/`git commit -a` when the worktree
contains sandbox `/dev/null` device-node masks.

Under the hardened sandbox, Claude Code bind-mounts /dev/null over sensitive
config paths (.mcp.json, .gitconfig, .claude/{launch.json,routines,...}, editor
dirs). Git cannot index a character-device node, so `git add -A` / `git add .` /
`git commit -a` abort the *whole* commit with:

error: <path>: can only add regular files, symbolic links or git-directories
fatal: adding files failed

Explicit path staging (`git add <path> ...`) skips the masks and works fine, so
this guard blocks only the blanket forms — and only when masks are actually
present (i.e. inside the sandbox). Outside the sandbox it is a no-op, so it never
obstructs consumers who don't run hardened.

Contract: reads the PreToolUse event JSON on stdin. Exit 0 = allow. Exit 2 =
block (stderr is shown to the agent).
"""
import json
import os
import re
import shlex
import stat as _stat
import subprocess
import sys

# Shell separators that terminate one simple command.
_SEP = re.compile(r"&&|\|\||;|\||\n")


def _segments(cmd):
return [s.strip() for s in _SEP.split(cmd) if s.strip()]


def _tokens(segment):
try:
return shlex.split(segment)
except ValueError:
# Unbalanced quotes etc. — fall back to whitespace split.
return segment.split()


def _short_flag_has(tok, letter):
"""True if tok is a single-dash short flag bundle containing `letter`
(e.g. -a, -am). Excludes long options like --all / --amend."""
return (
tok.startswith("-")
and not tok.startswith("--")
and letter in tok[1:]
)


def _is_blanket(cmd):
for seg in _segments(cmd):
toks = _tokens(seg)
if "git" not in toks:
continue
gi = toks.index("git")
rest = toks[gi + 1 :]
if "add" in rest:
args = rest[rest.index("add") + 1 :]
for a in args:
if a in ("-A", "--all", "."):
return True
if _short_flag_has(a, "A"): # bundled, e.g. -Av
return True
if "commit" in rest:
args = rest[rest.index("commit") + 1 :]
for a in args:
if a == "--all":
return True
if _short_flag_has(a, "a"): # -a, -am, -a -m (not --amend)
return True
return False


def _has_device_masks():
"""True if the worktree contains a /dev/null character-device mask.

The sandbox bind-mounts /dev/null over sensitive config paths. Crucially, a
directory entry's readdir `d_type` still reports the *underlying* regular
file, so `find -type c` (and DirEntry.is_*) miss the mask — only an actual
`stat()` follows the bind-mount and reports S_IFCHR. So we os.stat() entries
ourselves. Masks always include repo-root dotfiles (.gitconfig, .mcp.json,
shell rc, editor dirs) and .claude/* items, so a shallow scan of those two
dirs is enough and stays fast on every Bash call.
"""
try:
root = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, timeout=5,
).stdout.strip() or "."
except Exception:
root = "."
for d in (root, os.path.join(root, ".claude")):
try:
with os.scandir(d) as it:
for e in it:
try:
if _stat.S_ISCHR(os.stat(e.path).st_mode):
return True
except OSError:
continue
except OSError:
continue
return False


def _sandbox_enabled():
"""True if the Bash sandbox is enabled for this session.

The PreToolUse hook runs *outside* the sandbox, so it cannot see the
/dev/null device-node masks — they exist only inside the per-command bwrap
namespace, and os.stat here reports the masked paths as absent. So instead of
detecting the symptom (masks), detect the cause: an enabled sandbox. When it
is on, a blanket `git add` run as a sandboxed Bash command will hit the masks
and abort, so we block preemptively. When it is off (non-hardened consumers),
this returns False and the guard is a no-op.
"""
home = os.path.expanduser("~")
pdir = os.environ.get("CLAUDE_PROJECT_DIR", ".")
for path in (
os.path.join(pdir, ".claude", "settings.local.json"),
os.path.join(pdir, ".claude", "settings.json"),
os.path.join(home, ".claude", "settings.json"),
):
try:
with open(path) as f:
cfg = json.load(f)
except Exception:
continue
if (cfg.get("sandbox") or {}).get("enabled") is True:
return True
return False


def main():
try:
data = json.load(sys.stdin)
except Exception:
return 0
if data.get("tool_name") != "Bash":
return 0
cmd = (data.get("tool_input") or {}).get("command", "")
if not cmd or "git" not in cmd:
return 0
if not _is_blanket(cmd):
return 0
# Fire when the sandbox is active (its masks will abort the blanket add) or,
# should a future Claude Code run hooks sandboxed, when masks are visible.
if not (_sandbox_enabled() or _has_device_masks()):
return 0

sys.stderr.write(
"Blocked: a blanket `git add -A/./--all` or `git commit -a` will try to "
"index this sandbox's /dev/null device-node masks (the `crw-` entries in "
"`git status`) and abort the whole commit "
"(`can only add regular files, symbolic links or git-directories`).\n"
"Stage the files you actually changed, by name:\n"
' git add <path1> <path2> && git commit -m "..."\n'
"The `crw-` entries are sandbox masks, not your work — ignore them. "
"See docs/HARDENING.md -> Caveats.\n"
)
return 2


if __name__ == "__main__":
sys.exit(main())
56 changes: 42 additions & 14 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"_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).",

"hooks": {
"PostToolUse": [
{
Expand All @@ -22,18 +21,26 @@
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/scripts/guard-git-add.py\""
}
]
}
]
},

"permissions": {
"allow": [
"Read(//**)",

"Bash(bash .claude/scripts/gate.sh:*)",
"Bash(bash .claude/scripts/notify-poll.sh:*)",
"Bash(bash .claude/scripts/pr-feedback.sh:*)",
"Bash(bash .claude/scripts/merge-ready.sh:*)",

"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
Expand All @@ -55,13 +62,11 @@
"Bash(git rev-parse:*)",
"Bash(git hash-object:*)",
"Bash(git update-index:*)",

"Bash(gh issue:*)",
"Bash(gh pr:*)",
"Bash(gh label:*)",
"Bash(gh repo view:*)",
"Bash(gh auth status)",

"Bash(ls:*)",
"Bash(pwd)",
"Bash(mkdir:*)",
Expand Down Expand Up @@ -118,17 +123,40 @@
"enabled": true,
"allowUnsandboxedCommands": true,
"filesystem": {
"denyRead": ["/mnt"]
"denyRead": [
"/mnt"
]
},
"credentials": {
"files": [
{ "path": "~/.ssh", "mode": "deny" },
{ "path": "~/.aws", "mode": "deny" },
{ "path": "~/.config/gcloud", "mode": "deny" },
{ "path": "~/.kube", "mode": "deny" },
{ "path": "~/.gnupg", "mode": "deny" },
{ "path": "~/.npmrc", "mode": "deny" },
{ "path": "~/.docker/config.json", "mode": "deny" }
{
"path": "~/.ssh",
"mode": "deny"
},
{
"path": "~/.aws",
"mode": "deny"
},
{
"path": "~/.config/gcloud",
"mode": "deny"
},
{
"path": "~/.kube",
"mode": "deny"
},
{
"path": "~/.gnupg",
"mode": "deny"
},
{
"path": "~/.npmrc",
"mode": "deny"
},
{
"path": "~/.docker/config.json",
"mode": "deny"
}
]
}
}
Expand Down
5 changes: 5 additions & 0 deletions .claude/skills/setup/templates/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,9 @@ re-scoped by the orchestrator, never reached across by a worker.
## Don'ts
- Don't put secrets in the repo.
- Don't bypass the gates.
- Don't `git add -A` / `git add .` / `git commit -a` — **stage explicit paths by name.** Under the sandbox,
masked config paths (`.mcp.json`, `.gitconfig`, `.claude/{launch.json,routines,…}`, editor dirs) appear as
`/dev/null` character-device nodes; git can't index a device node, so a blanket add aborts the whole commit
(`can only add regular files, symbolic links or git-directories`). Ignore any `crw-` entries in `git status`
— they're sandbox masks, not your changes. See `docs/HARDENING.md` → Caveats.
- <project-specific landmines>
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,9 @@ re-scoped by the orchestrator, never reached across by a worker.
## Don'ts
- Don't put secrets in the repo.
- Don't bypass the gates.
- Don't `git add -A` / `git add .` / `git commit -a` — **stage explicit paths by name.** Under the sandbox,
masked config paths (`.mcp.json`, `.gitconfig`, `.claude/{launch.json,routines,…}`, editor dirs) appear as
`/dev/null` character-device nodes; git can't index a device node, so a blanket add aborts the whole commit
(`can only add regular files, symbolic links or git-directories`). Ignore any `crw-` entries in `git status`
— they're sandbox masks, not your changes. See `docs/HARDENING.md` → Caveats.
- <project-specific landmines>
43 changes: 41 additions & 2 deletions docs/HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,38 @@ Starting point — adapt the lists to your stack, then drop into `.claude/settin
> (e.g. a git op against a repo outside the sandbox root). Network access (registry, GitHub) is a
> separate axis — see `sandbox.network.allowedDomains` in Step 3.

> **Git config/hook writes are denied by default — and you can't re-enable them in-sandbox. Use a real
> terminal.** The sandbox lets `git commit` update refs and the index but keeps `.git/config` **and**
> `.git/hooks/` writes denied (see the [sandbox docs](https://code.claude.com/docs/en/sandboxing):
> *"Writes to `hooks/` and `config` inside that directory remain denied"*). That's on purpose — git
> config and hooks are an **arbitrary-code-execution surface** (`core.pager`, `core.fsmonitor`,
> `core.hooksPath`, `alias.* = !cmd`, `filter.*.clean/smudge`, a committed `pre-commit` hook…), any of
> which fires the next time git runs. So the mask is *why* `git config --local`, `git remote add`, and
> upstream tracking fail under strict mode while ordinary `git commit`/`diff`/`log` work. It's enforced
> as a **`/dev/null` bind-mount over `.git/config.lock`**: git creates that lockfile with
> `O_CREAT|O_EXCL` before renaming it over `config`, and the device node already occupying the path makes
> the exclusive-create fail — hence `error: could not lock config file .git/config: File exists`. It's
> also the phantom `crw-` `config.lock` you see in `git status` (see Caveats).
>
> **`sandbox.filesystem.allowWrite` does *not* lift this** — verified 2026-07-07: with
> `allowWrite: [".git/config", ".git/config.lock", ".git/worktrees"]` set and Claude Code restarted, the
> `/dev/null` mask on `.git/config.lock` persisted and `git config --local` still failed with `File
> exists`. The mask is applied at the **mount layer** as a built-in git protection; `allowWrite` only
> adjusts the **permission layer**, so it can't dislodge the bind-mount. Don't add these paths to
> `allowWrite` expecting config writes to work — they won't.
>
> The only setting that removes the mask is `excludedCommands: ["git"]`, and you should **not** use it:
> that runs git *and every subprocess it spawns* fully **unsandboxed**, so a poisoned pager/hook/alias
> executes with network, credential-dir, and host-filesystem access — you've handed the ACE surface a way
> out (and `excludedCommands` has a [write/unlink bug, #39078](https://github.com/anthropics/claude-code/issues/39078)
> on top). Keeping git sandboxed is the whole point; the config mask is a feature, not a bug.
>
> **So when you genuinely need a git config/hook write** (`git config`, `git remote add`, setting
> upstreams, installing a hook), run it in a **real terminal outside Claude Code** — the mask exists only
> inside the sandbox, so the same command works normally there. This is the same rule as `git config
> --global` / `~/.gitconfig` edits (see Caveats). Note `git commit`, `git worktree add` (basic), and ref
> updates are *not* affected — those write refs/index/HEAD, which the sandbox allows.

---

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