fix(security): block shell injection via claude-bus message text - #77
Draft
intellegix wants to merge 1 commit into
Draft
fix(security): block shell injection via claude-bus message text#77intellegix wants to merge 1 commit into
intellegix wants to merge 1 commit into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 .beforenodeever 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 — aspwa-idle-auto-checkinput 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 withfs.writeFileSync, the onespawnuses an argv array withshell:false, and the watcher passes bytes through verbatim.The boundary is the caller's own Bash invocation. By the time
nodestarts, the payload has already executed. That rules out any fix insidebus.mjs, and is why the control is aPreToolUsehook — 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— aPreToolUsehook onBash, wired intosettings.json. For anybus.mjs sendit scans the raw--msg/--body/--subjectvalue 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.
--msg 'single-quoted literal'$(…)/${…}/$VARin 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-stdinIt 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/andsession-control/are gitignored (.gitignoreexcludes*and allowlists only a handful of dirs). So these ship live with timestamped.bakbackups instead:bus.mjs— adds--stdin,--json-stdin,--subject-fileso message text never touches a shellinbox-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 instructionsThat untracked-live-code exposure is flagged in §4.1 of the evidence doc. I did not touch
.gitignore— that's Austin's call.Tests
hooks/tests/test_bus_send_guard.pyclaude-bus/test-injection.mjsThe 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
--msgway 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$HOMEwas 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.pyand every other hook use argv arrays with no shell. Zero occurrences ofshell=True,shell:true, or shell-string child-process calls anywhere in the local tooling. Node 22.20.0 with a realclaude.exe(not a.cmdshim), 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:303spawns throughcmd.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