From 46bf2929c0ecd76fd47954e708c3a13ddc31b8cc Mon Sep 17 00:00:00 2001 From: Austin Kidwell Date: Mon, 10 Aug 2026 08:55:34 -0700 Subject: [PATCH] fix(security): block shell injection via claude-bus message text A bus message whose subject or body contained backticks was executed by the shell before node ever ran. On 2026-08-09 at 10:23 PT this ran a repo-wide `git restore .` in ~/.claude, reverting every modified tracked file there. At least three sends across four sessions hit it that morning; the other two produced only "command not found", which is why nobody investigated. The shell boundary is NOT inside claude-bus -- bus.mjs, watcher.mjs, inbox-hook.mjs and identity.mjs contain no shell execution at all. It is the caller's own Bash invocation, so by the time node starts the payload has already run and nothing in bus.mjs can prevent it. A PreToolUse hook is the only layer that sees the command before the shell does. hooks/bus-send-guard.py denies any `bus.mjs send` whose --msg/--body/--subject value the shell would expand, using a POSIX-quoting-aware scanner. It allows the idioms ~21 live sessions already use (single-quoted literals, quoted heredoc capture, --file/--stdin), fails open on an internal error so a guard bug cannot brick Bash fleet-wide, and its refusal names the construct and prints the working alternative. Also deployed live but NOT in this commit -- claude-bus/ and session-control/ are gitignored (see the evidence doc, section 4.1): bus.mjs gains --stdin / --json-stdin / --subject-file so message text never touches a shell, and inbox-hook.mjs stops printing the vulnerable `--msg "..."` form to every session on every message. Tests: 20/20 guard cases; 16/16 end-to-end adversarial payloads sent through a real shell against an isolated bus, including a control proving the old route really did execute. Evidence: claude-bus/SHELL-INJECTION-EVIDENCE-2026-08-09.md Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L6Jfzp1GLPTdSKZxTfuitX --- hooks/bus-send-guard.py | 238 +++++++++++++++++++++++++++++ hooks/tests/test_bus_send_guard.py | 105 +++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 hooks/bus-send-guard.py create mode 100644 hooks/tests/test_bus_send_guard.py diff --git a/hooks/bus-send-guard.py b/hooks/bus-send-guard.py new file mode 100644 index 0000000..4ec38e3 --- /dev/null +++ b/hooks/bus-send-guard.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""PreToolUse guard: stop a claude-bus message body from being executed by the shell. + +WHY THIS EXISTS +--------------- +On 2026-08-09 10:23:25 PT the orchestrator ran (abridged): + + node ~/.claude/claude-bus/bus.mjs send --to all \ + --subject "... worktree - `git worktree remove --force` followed the link ..." \ + --msg "... everything was committed and `git restore .` brought it all back ..." + +The Bash tool runs Git Bash (POSIX sh). Inside DOUBLE quotes a POSIX shell still +expands backticks, $(...), ${...} and $VAR. So the shell ran `git restore .` in +~/.claude BEFORE node ever started -- a repo-wide revert of every modified tracked +file -- and delivered the message with the backticked text silently deleted. + +Nothing inside bus.mjs can prevent this: by the time node runs, the shell has +already executed the payload. A PreToolUse hook is the ONLY layer that sees the +command before the shell does. That is what this file is. + +POLICY +------ +For a `bus.mjs send` command, the value of --msg / --body / --subject must be +inert. Allowed: + * a single-quoted literal: --msg 'text' + * a quoted-heredoc capture: --msg "$(cat <<'EOF' ... EOF)" + * a plain file read: --msg "$(cat body.txt)" + * the safe routes: --file body.txt | --stdin | --json-stdin +Denied: any backtick, $(...), ${...} or $VAR that the shell would expand inside a +double-quoted or unquoted value. + +Fails OPEN on an internal error (exit 0, no opinion): a bug in this guard must +never be able to block Bash across the fleet. It fails CLOSED only on a positive +detection. +""" +import json +import os +import re +import sys +from datetime import datetime + +LOG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bus-send-guard.log") + +PAYLOAD_FLAGS = ("--msg", "--body", "--subject") +# Commands whose output is literal text, so "$(cat ...)" is an inert capture. +LITERAL_READERS = ("cat", "printf", "type") + +REMEDY = ( + "Rewrite the send so the text never reaches the shell. Either:\n" + " node ~/.claude/claude-bus/bus.mjs send --to --subject 'plain text' --stdin <<'EOF'\n" + " ...body...\n" + " EOF\n" + "or write the body to a file with a quoted heredoc and pass --file .\n" + "Single quotes around a short subject are also fine. See " + "~/.claude/claude-bus/SHELL-INJECTION-EVIDENCE-2026-08-09.md" +) + + +def log(line: str) -> None: + try: + with open(LOG, "a", encoding="utf-8") as f: + f.write(f"{datetime.now().isoformat(timespec='seconds')} {line}\n") + except OSError: + pass + + +def decide(decision: str, reason: str) -> None: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": decision, + "permissionDecisionReason": reason, + }})) + sys.exit(0) + + +def split_words(cmd: str): + """Split a shell command into words, keeping each word's RAW source text. + + Tracks POSIX quoting so we can later ask "would the shell expand this?". + A $( ) or ` ` region is consumed whole -- whitespace inside it does not split a + word, which is what keeps `--msg "$(cat <<'EOF' ... EOF)"` a single value. + """ + words, buf = [], [] + i, n = 0, len(cmd) + while i < n: + c = cmd[i] + if c in " \t\n": + if buf: + words.append("".join(buf)) + buf = [] + i += 1 + continue + if c == "\\" and i + 1 < n: + buf.append(cmd[i:i + 2]) + i += 2 + continue + if c == "'": + j = cmd.find("'", i + 1) + j = n if j == -1 else j + 1 + buf.append(cmd[i:j]) + i = j + continue + if c == '"': + j, i2 = i + 1, i + while j < n: + if cmd[j] == "\\": + j += 2 + continue + if cmd[j] == '"': + j += 1 + break + j += 1 + buf.append(cmd[i2:j]) + i = j + continue + if c == "$" and i + 1 < n and cmd[i + 1] == "(": + j, depth = i + 2, 1 + while j < n and depth: + if cmd[j] == "(": + depth += 1 + elif cmd[j] == ")": + depth -= 1 + j += 1 + buf.append(cmd[i:j]) + i = j + continue + if c == "`": + j = cmd.find("`", i + 1) + j = n if j == -1 else j + 1 + buf.append(cmd[i:j]) + i = j + continue + buf.append(c) + i += 1 + if buf: + words.append("".join(buf)) + return words + + +def _expansions(text: str): + """Shell expansions the shell WOULD perform in `text` (single-quoted spans skipped).""" + found, i, n = [], 0, len(text) + while i < n: + c = text[i] + if c == "'": # literal span - nothing expands inside + j = text.find("'", i + 1) + i = n if j == -1 else j + 1 + continue + if c == "\\" and i + 1 < n: # escaped -> inert + i += 2 + continue + if c == "`": + found.append("backtick command substitution (`...`)") + i += 1 + continue + if c == "$" and i + 1 < n: + nxt = text[i + 1] + if nxt == "(": + found.append("command substitution $(...)") + elif nxt == "{": + found.append("parameter expansion ${...}") + elif nxt.isalpha() or nxt == "_": + m = re.match(r"\$[A-Za-z_][A-Za-z0-9_]*", text[i:]) + found.append(f"variable expansion {m.group(0) if m else '$VAR'}") + elif nxt in "@*#?!0123456789": + found.append(f"variable expansion ${nxt}") + i += 1 + continue + i += 1 + return found + + +def classify(raw: str): + """Return None if the value is inert, else a human reason it is dangerous.""" + v = raw.strip() + if not v: + return None + + # Fully single-quoted literal -> the shell expands nothing. + if len(v) >= 2 and v[0] == "'" and v[-1] == "'" and "'" not in v[1:-1]: + return None + + # Peel one layer of surrounding double quotes for the checks below. + inner = v[1:-1] if len(v) >= 2 and v[0] == '"' and v[-1] == '"' else v + + # Inert capture: "$(cat <<'EOF' ... EOF)" / "$(cat file)" / "$(printf ...)". + m = re.match(r"^\$\(\s*(\w+)", inner) + if m and inner.endswith(")") and m.group(1) in LITERAL_READERS: + # Only inert if EVERY heredoc delimiter is quoted; < None: + try: + data = json.load(sys.stdin) + except (ValueError, TypeError): + sys.exit(0) + + if data.get("tool_name", "") != "Bash": + sys.exit(0) + + cmd = (data.get("tool_input", {}) or {}).get("command", "") or "" + if "bus.mjs" not in cmd or not re.search(r"(? int: + failures = [] + for label, cmd, expected in CASES: + try: + decision, reason = run(cmd) + except Exception as e: # noqa: BLE001 + failures.append((label, expected, f"EXCEPTION {e!r}")) + continue + ok = (decision == expected) + print(f"{'PASS' if ok else 'FAIL'} [{expected:5}] {label}") + if not ok: + failures.append((label, expected, f"got {decision}: {reason[:120]}")) + + print(f"\n{len(CASES) - len(failures)}/{len(CASES)} passed") + for label, expected, got in failures: + print(f" FAILED {label}: expected {expected}, {got}") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main())