[BUG] Headless: denied skill-frontmatter shell substitution reported as `success` with num_turns:0 — no typed error, only <local-command-stderr>
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 insdk.d.tssays the type exists to surface "headless-agent auto-deny … so SDK hosts can render the denial."SDKResultErrorcarriessubtype: 'error_during_execution' | …anderrors— unused; the envelope isSDKResultSuccess.
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: truewith asubtypenaming 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 theSDKPermissionDenialdoc 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-
successreporting is what makes those hard to diagnose in headless/SDK setups.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗