diff --git a/.claude/.claude-plugin/marketplace.json b/.claude/.claude-plugin/marketplace.json index f62c53a..7d3c132 100644 --- a/.claude/.claude-plugin/marketplace.json +++ b/.claude/.claude-plugin/marketplace.json @@ -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" } diff --git a/.claude/.claude-plugin/plugin.json b/.claude/.claude-plugin/plugin.json index 3f06301..6a4fa89 100644 --- a/.claude/.claude-plugin/plugin.json +++ b/.claude/.claude-plugin/plugin.json @@ -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" } } diff --git a/.claude/hooks/hooks.json b/.claude/hooks/hooks.json index e5c3906..40a0609 100644 --- a/.claude/hooks/hooks.json +++ b/.claude/hooks/hooks.json @@ -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", diff --git a/.claude/scripts/guard-git-add.py b/.claude/scripts/guard-git-add.py new file mode 100755 index 0000000..05e804a --- /dev/null +++ b/.claude/scripts/guard-git-add.py @@ -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: : can only add regular files, symbolic links or git-directories + fatal: adding files failed + +Explicit path staging (`git add ...`) 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 && 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()) diff --git a/.claude/settings.json b/.claude/settings.json index 94c3380..f266ae6 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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": [ { @@ -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:*)", @@ -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:*)", @@ -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" + } ] } } diff --git a/.claude/skills/setup/templates/CLAUDE.md b/.claude/skills/setup/templates/CLAUDE.md index 348fb4f..bdf225c 100644 --- a/.claude/skills/setup/templates/CLAUDE.md +++ b/.claude/skills/setup/templates/CLAUDE.md @@ -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. - diff --git a/CLAUDE.md b/CLAUDE.md index 348fb4f..bdf225c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - diff --git a/docs/HARDENING.md b/docs/HARDENING.md index f15fa65..8d9908d 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -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 @@ -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 —