[BUG] Headless: denied skill-frontmatter shell substitution reported as `success` with num_turns:0 — no typed error, only <local-command-stderr>

Status Open
Reported on v2.1.217
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Jul 22, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

In headless/print mode (claude -p, and the Agent SDK), when a slash command's SKILL.md frontmatter contains a shell substitution (` !… `) that isn't pre-authorized, the pre-loop expansion is denied, the prompt is consumed, and the model never runs — but the result envelope reports success:

{ "subtype": "success", "is_error": false, "num_turns": 0, "result": "", "permission_denials": [], "errors": null }

The only signal of the failure is an untyped string inside a synthetic user message:

<local-command-stderr>Error: Shell command permission check failed for pattern "!`sh ./hello.sh`": This command requires approval</local-command-stderr>

Every typed channel the SDK declares for exactly this case is left empty:

  • permission_denials: SDKPermissionDenial[] → []. Its own doc string in sdk.d.ts says the type exists to surface "headless-agent auto-deny … so SDK hosts can render the denial."
  • SDKResultError carries subtype: 'error_during_execution' | … and errors — unused; the envelope is SDKResultSuccess.

So the same event is reported as success in the typed fields and as an error in the untyped side-channel. For any programmatic (headless/SDK) consumer this is a silent no-op: no output, no error, nothing to key on except string-matching a terminal-rendering artifact.

I'm not reporting the denial itself — routing frontmatter substitution through canUseTool would mean installing a skill implies arbitrary shell execution via any permissive callback, so hard-denying it pre-loop is a defensible security choice. The reporting is the bug: this path appears to be written for an interactive terminal (a human to approve, stderr on screen), and both assumptions are absent under -p/SDK.

What Should Happen?

A prompt that was consumed but never ran should not be reported as success. Any of these fixes the programmatic contract:

  • is_error: true with a subtype naming the denial (e.g. error_during_execution), and/or
  • a populated permission_denials[] entry for the blocked substitution (the field already exists for this), and/or
  • the denial surfaced in errors.

At minimum, the typed result and the <local-command-stderr> side-channel should not contradict each other.

Error Messages/Logs

The full stream (--output-format stream-json --verbose) contains only: system (init) → one synthetic user message (the <local-command-stderr> line above) → result (the success envelope above). There is no assistant message — the model never ran.

Steps to Reproduce

mkdir -p repro/.claude/skills/single && cd repro
printf 'echo MARKER_OK\n' > hello.sh
cat > .claude/skills/single/SKILL.md <<'SKILL'
---
name: single
description: probe
---
Injected: !`sh ./hello.sh`
SKILL

claude -p "/single" --setting-sources=project --permission-mode default \
  --output-format stream-json --verbose

Observed: subtype:"success", is_error:false, num_turns:0, permission_denials:[], empty result.
Expected: a result that identifies the permission denial in a typed field.

Confirming it is specifically the denial (both make it expand and run, num_turns ≥ 1, with MARKER_OK substituted into the prompt):

  • pass --allowedTools "Bash(sh:*)", or
  • add a Bash(sh:*) allow rule to .claude/settings.json.

Claude Model

N/A — the model never runs (num_turns: 0).

Is this a regression?

Not sure (no known-good prior version identified).

Last Working Version

_(unknown)_

Claude Code Version

2.1.217

Platform

Claude Code CLI (headless -p) / Agent SDK

Operating System

Linux

Terminal/Shell

bash

Additional Information

  • Reproduced on 2.1.217 (and 2.1.191). SDK type references: @anthropic-ai/claude-agent-sdk/sdk.d.ts — SDKResultSuccess / SDKResultError (num_turns, permission_denials, errors) and the SDKPermissionDenial doc noting headless-agent auto-deny.
  • Possibly related false-success-in-headless case: #74761 — but there the model did run and the loop exited mid-task; here the model never runs (num_turns: 0), so it's a distinct trigger. Docs gap on error payloads: #41265.
  • There is a large cluster of downstream reports of the denial firing on skill frontmatter; the silent-success reporting is what makes those hard to diagnose in headless/SDK setups.

View original on GitHub ↗

3 Comments

The01Geek · 2 months ago

Reproduces under anthropics/claude-code-action (GitHub Actions), and a granted Bash(...) pattern does not help

Confirming this on the GitHub Actions tier, which I don't think is represented in the existing reports. Same denial, same silent envelope.

Environment: anthropics/claude-code-action@v1, ubuntu-latest, headless (non-interactive) invocation. Observed 2026-08-08.

What happens: a skill body containing

!`${CLAUDE_SKILL_DIR}/../../scripts/render-prompt-extension.sh implement`

is refused at load with:

Shell command permission check failed for pattern
"!`/home/runner/work/.../scripts/render-prompt-extension.sh implement`": This command requires approval

No PROMPT-EXTENSION-STATUS: output renders, nothing replaces the placeholder, and the agent receives no signal that anything was refused. The job's conclusion is success.

The data point I want to add: the command is already granted, in two different pattern shapes, and it makes no difference. Our --allowed-tools list contains both a leading-wildcard form and the exact literal path, but and the refusal is unchanged.

Bash(*/render-prompt-extension.sh:*)
Bash(.prflow/vendor/prflow/scripts/render-prompt-extension.sh:*)

The invocation has no quoting, no bash prefix, and no leading VAR=, so the prefix-matching failure modes described in anthropics/claude-plugins-official#1634 and anthropics/claude-code#11645 don't apply here.

That is consistent with the phase explanation in #39048 (closed as a duplicate of #24203): allowed-tools governs the model's tool calls after the skill loads, while ` !… runs *during* loading as a preprocessor, so no grant can reach it. If that's the intended design, it would help enormously to have it stated in the docs — the skills page documents the injection and documents allowed-tools but never connects them, and its ${CLAUDE_SKILL_DIR}` guidance ("lets a skill run a bundled script without a permission prompt") reads as though a scoped grant is exactly what authorizes this.

Why the silent reporting is costly in CI specifically. Interactively you at least see the Error: line. In Actions there is no operator watching, so the run proceeds with the injected content simply missing. In our case the injected file carried the routing rules and the review gate for the whole run: the agent skipped an obligation it never saw, and the run reported complete and green. The failure surfaced only because a downstream review rejected the PR. A denied injection that produced any in-prompt marker — even the [shell command execution disabled by policy] string that the disableSkillShellExecution setting already produces — would have made this self-evident.

Workarounds reported on #16037/#14956 (bare Bash in permissions.allow, or --dangerously-skip-permissions) aren't usable for anyone running a deliberately narrow allowlist.

bcherny collaborator · 1 month ago

Reproduced on the current release (2.1.233, Linux) following your exact steps. Confirmed both halves:

  • With the substitution denied, the stream is exactly system (init) → one synthetic user message whose only content is the <local-command-stderr> permission error → a result envelope reporting subtype: "success", is_error: false, num_turns: 0, permission_denials: [], empty result, and exit code 0. No assistant message — the model never ran.
  • Pre-authorizing the shell command makes the same invocation expand and run (MARKER_OK substituted, multiple turns), confirming the denial is the specific trigger.

Agreed on the framing: the hard-deny itself is a deliberate security posture, but a prompt that was consumed and never ran shouldn't be reported as a success with all of the typed denial/error channels empty — programmatic consumers currently have nothing to key on except string-matching an untyped side-channel message. Marking this as a confirmed bug in the headless/SDK result reporting.

🤖 Generated with Claude Code

marcindulak · 18 days ago

For your information: the label reproduced on this issue does not prevent it from becoming stale and eventually auto-closed. See https://github.com/anthropics/claude-code/issues/87647 for details, and consider upvoting that for visibility.