Skip to content

fix(parse-sdk-options): prevent shell-quote from collapsing unquoted Bash(X:*) rules to bare Bash#1350

Open
alexglynn wants to merge 2 commits into
anthropics:mainfrom
alexglynn:alexglynn/fix-shell-quote-bash-rule-collapse
Open

fix(parse-sdk-options): prevent shell-quote from collapsing unquoted Bash(X:*) rules to bare Bash#1350
alexglynn wants to merge 2 commits into
anthropics:mainfrom
alexglynn:alexglynn/fix-shell-quote-bash-rule-collapse

Conversation

@alexglynn
Copy link
Copy Markdown

@alexglynn alexglynn commented May 23, 2026

Bug

parseClaudeArgsToExtraArgs uses shell-quote's parse() to tokenize the claude_args input, then filters to string entries only. But shell-quote is a full shell-command parser: unquoted ( / ) are returned as {op: '('} / {op: ')'} control-operator objects, and any bareword containing * is returned as {op: 'glob', pattern: '…'}. All of these were dropped by the string-only filter.

Result: an unquoted --allowedTools Bash(gh:*),Bash(cat:*) collapses to bare "Bash" — which Claude Code interprets as Bash(*), i.e. unrestricted shell.

Repro

import { parse } from "shell-quote";
parse("--allowedTools View,Bash(gh:*),Bash(cat:*)")
// → ["--allowedTools", "View,Bash", {op:"("}, {op:"glob",pattern:"gh:*"},
//    {op:")"}, ",Bash", {op:"("}, {op:"glob",pattern:"cat:*"}, {op:")"}]

After the existing .filter(typeof arg === "string")["--allowedTools", "View,Bash", ",Bash"] → split/dedup → ["View", "Bash"].

Before / after (parseSdkOptions)

Input (exact shape the action builds in tag mode — its own quoted --allowedTools + user's unquoted YAML claude_args):

--permission-mode acceptEdits --allowedTools "Glob,Grep,Read,Bash(git add:*),Bash(git commit:*)" --model claude-opus-4-7
--allowedTools View,Bash(gh:*),Bash(printf:*),Bash(cat:*)
sdkOptions.allowedTools
before […, "Bash(git add:*)", "Bash(git commit:*)", "View", "GlobTool", "GrepTool", "Bash"]
after […, "Bash(git add:*)", "Bash(git commit:*)", "View", "Bash(gh:*)", "Bash(printf:*)", "Bash(cat:*)"]

Security impact

This is silent permission widening. Any workflow that passes scoped Bash(X:*) / Read(X) / WebFetch(X) rules in claude_args without wrapping the value in quotes gets the tool-level wildcard instead of the scoped rule. Because Bash(*) subsumes every Bash(X:*), the session behaves as intended — the user never observes a denial that would reveal the scoping was lost.

Found via Claude Code session telemetry. The same collapse happens on --disallowedTools (scoped deny → blanket deny), which is a usability bug rather than a widening.

Fix

claude_args is a CLI argument string, not a shell command — we want shell-quote's quote/whitespace handling, not its operator/glob detection. shell-quote has no option to disable operator parsing, and it does not preserve adjacency (so post-hoc reconstruction of {op} runs is lossy). Instead:

  1. Before parse(), replace the seven shell control metachars ( ) | & ; < > with Unicode private-use codepoints (U+E000–U+E006). shell-quote then treats them as ordinary word characters and never splits on them.
  2. After parse(), map glob ops back to their .pattern (the verbatim token text) and restore the placeholders.

Quoted values, escaped values, whitespace tokenization, comment stripping, and --mcp-config JSON handling are all unchanged.

Tests

Six regression tests added to base-action/test/parse-sdk-options.test.ts covering:

  • unquoted comma-joined Bash(X:*) rules
  • unquoted space-separated Bash(X:*) rules
  • unquoted Tool(content) with no glob chars
  • quoted Bash(X:*) (no-regression)
  • the real-world tag-mode-quoted + user-unquoted mixed case
  • --disallowedTools path

All 5 applicable tests fail on main, pass with this change. Full suite: base-action 133 pass / 0 fail, root 706 pass / 0 fail, typecheck clean, prettier clean.

Note

bun install on main currently fails because @anthropic-ai/claude-agent-sdk@0.3.150 (pinned in 787c5a0) is not yet published to npm; I ran the suite locally against 0.3.149. Unrelated to this change.

🤖 Generated with Claude Code

…Bash(X:*) rules to bare Bash

shell-quote's parse() tokenizes unquoted `(`, `)` as control operators
and barewords containing `*` as glob ops, all returned as non-string
objects. parseClaudeArgsToExtraArgs filtered those out, so an unquoted
`--allowedTools View,Bash(gh:*),Bash(cat:*)` collapsed to bare `Bash` —
silently widening scoped permission rules to unrestricted Bash(*).

Escape shell control metachars to Unicode private-use placeholders
before parse() and restore after; extract .pattern from glob ops.
Preserves existing quote/whitespace handling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@alexglynn alexglynn closed this May 24, 2026
@alexglynn alexglynn reopened this May 24, 2026
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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