Skip to content

fix(security): block shell injection via claude-bus message text - #77

Draft
intellegix wants to merge 1 commit into
masterfrom
worktree-fix-bus-shell-injection
Draft

fix(security): block shell injection via claude-bus message text#77
intellegix wants to merge 1 commit into
masterfrom
worktree-fix-bus-shell-injection

Conversation

@intellegix

Copy link
Copy Markdown
Owner

What happened

On 2026-08-09 at 10:23 PT the orchestrator sent a bus broadcast that quoted two git commands in backticks — the ordinary markdown way — one in --subject, one in --msg.

The Bash tool here runs Git Bash. A POSIX shell command-substitutes backticks even inside double quotes, so the shell ran git restore . before node ever started. Its cwd was ~/.claude, a git repo, so it reverted every modified tracked file there.

At least three sends across four sessions hit this in one morning. The other two produced only command not found, which is exactly why nobody investigated — as pwa-idle-auto-checkin put it, "the failure mode advertises itself as cosmetic." It survived not because it was hidden but because it looked trivial to everyone who saw it.

Correction to the reported mechanism

The brief located the shell boundary inside bus.mjs. It isn't there. All four claude-bus files contain zero shell execution — send() writes JSON with fs.writeFileSync, the one spawn uses an argv array with shell:false, and the watcher passes bytes through verbatim.

The boundary is the caller's own Bash invocation. By the time node starts, the payload has already executed. That rules out any fix inside bus.mjs, and is why the control is a PreToolUse hook — the only layer that sees the command before the shell does.

The subject was injected too, not just the body. The brief described this as body-only; the orchestrator has since confirmed its delivered subject has a hole exactly where a backticked command sat.

What's in this commit

hooks/bus-send-guard.py — a PreToolUse hook on Bash, wired into settings.json. For any bus.mjs send it scans the raw --msg/--body/--subject value with a POSIX-quoting-aware parser and denies the call if the shell would expand anything in it.

Precision mattered more than aggression: ~21 live sessions send on this bus, so a guard that blocked legitimate traffic would be its own outage.

Allowed Denied
--msg 'single-quoted literal' backtick / $(…) / ${…} / $VAR in a double-quoted or unquoted value
--msg "$(cat <<'EOF' … EOF)" (the fleet's current safe idiom) --msg "$(cat <<EOF … EOF)" — unquoted delimiter still expands
--file / --stdin / --json-stdin

It fails open on an internal error so a bug in the guard can't brick Bash fleet-wide, and fails closed only on a positive detection. The refusal names the construct and prints the working alternative — the orchestrator asked for this specifically, citing a hook that got reflexively routed around the same morning.

Deployed live but not committable

claude-bus/ and session-control/ are gitignored (.gitignore excludes * and allowlists only a handful of dirs). So these ship live with timestamped .bak backups instead:

  • bus.mjs — adds --stdin, --json-stdin, --subject-file so message text never touches a shell
  • inbox-hook.mjs — stopped printing the vulnerable --msg "..." form to every session on every message, and now wraps each body in <bus_message from="…"> tagged as data, not instructions

That untracked-live-code exposure is flagged in §4.1 of the evidence doc. I did not touch .gitignore — that's Austin's call.

Tests

Suite Result
hooks/tests/test_bus_send_guard.py 20/20
claude-bus/test-injection.mjs 16/16

The end-to-end suite runs every send through a real Git Bash shell against an isolated bus (own CLAUDE_BUS_DIR, own watcher on port 8137) so the live bus was never touched. Seven payloads — backtick, $(…), ${…}, $VAR/$$, newline+;, pipe/redirect, and the verbatim incident text — each asserting the body arrives intact and no sentinel file was created.

It ends with a control that sends the same payload the old --msg way and asserts the sentinel is created, so the suite proves it can actually detect execution rather than passing because it's blind.

Verified live after deploy: a real send carrying backticks, $(…), ${…} and $HOME was delivered verbatim, and the guard denies the exact incident command.

Sweep

Everything else named in the brief is already correct — restart-sessions.mjs, respawn-self.mjs, pushover-notify.py, cc-inject-inbox.py and every other hook use argv arrays with no shell. Zero occurrences of shell=True, shell:true, or shell-string child-process calls anywhere in the local tooling. Node 22.20.0 with a real claude.exe (not a .cmd shim), so CVE-2024-27980 doesn't apply.

The bus send path was the only live instance — it's the one place an agent types the payload into a shell command by hand instead of a program passing it through an array.

One latent item flagged, not changed: restart-sessions.mjs:303 spawns through cmd.exe, but the only interpolated value is a numeric PID. It becomes exploitable if an agent-authored name or prompt is ever added there.

Full write-up: claude-bus/SHELL-INJECTION-EVIDENCE-2026-08-09.md

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6Jfzp1GLPTdSKZxTfuitX

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6Jfzp1GLPTdSKZxTfuitX
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant