Skill tool's args does bash-style $N positional substitution against SKILL.md prose, corrupting literal dollar amounts
Summary
Calling the Skill tool with a non-empty args string causes the harness to scan the rendered SKILL.md body for bare $<digit> tokens and replace each with the Nth (0-indexed) whitespace-split word of args — bash/slash-command-style positional-parameter substitution applied to skill markdown that was never authored with placeholders in mind. This silently corrupts any literal dollar amount in the skill body whose $N happens to land on a valid word index.
Repro
Skill file contains (among other rows):
| AR balance >$5,000 and 30+ days | ...
| AR balance >$0 and 90+ days | ...
| Rent PSF below $2.50/SF (under-market flag) | ...
Invocation:
Skill({ skill: "exception-detection", args: "Targeted scan: Category 2 tenant exceptions only, with emphasis on verifying the 3 new v1.2 cross-checks against HubSpot Lease Renewal deals..." })
args split on whitespace: index 0 = "Targeted", index 2 = "Category", index 5 = "exceptions".
Content actually returned to the model:
| AR balance >exceptions,000 and 30+ days | ...
| AR balance >Targeted and 90+ days | ...
| Rent PSF below Category.50/SF (under-market flag) | ...
$5,000 → "exceptions,000" (word[5]), $0 → "Targeted" (word[0]), $2.50 → "Category.50" (word[2]) — an exact 0-indexed match.
Re-tested with a controlled args string (args="zero one two three four five six seven") against the same skill file: same three tokens corrupted predictably ($5,000→"five,000", $0→"zero", $2.50→"two.50"). But a separate literal $5K/mo elsewhere in the same file was left untouched — the substitution appears to require the digit run be followed by a non-letter (space/comma/period qualify, a letter does not), i.e. there's a partial guard against clobbering shorthand like $5K/$10M, but it does not protect comma-formatted or decimal dollar amounts, which is extremely common in ordinary prose.
Skills whose body has no $<digit> substring are unaffected — in that case args is instead appended verbatim as a trailing ARGUMENTS: <raw string> line, which is the correct/safe behavior.
Why this matters
Any skill whose Markdown body contains dollar figures (financial thresholds, pricing, cost references — extremely common in real-world skill content) is silently corrupted in-context whenever it's invoked with an args string whose word count happens to land on those $N positions. The corruption is invisible unless someone happens to diff the rendered output against the source file — the model just silently reasons over garbage numbers.
Suggested fix
args should never be pattern-matched/interpolated against the skill body at all — it should always be passed through as inert appended context (exactly what already happens today for skills with no $N matches). If per-skill templating is intentionally wanted in the future, it should require an explicit, collision-proof placeholder syntax (e.g. {{arg1}} / {{ARGUMENTS}}) that skill authors opt into via frontmatter, rather than a blanket regex applied to every skill's prose regardless of whether the author ever intended a placeholder.
Environment
@anthropic-ai/claude-codev2.1.214- Windows 11 (win32-x64 build)
3 Comments
Independent confirmation on a different platform and a newer build: v2.1.228, macOS (darwin-arm64). Reporter was on 2.1.214/Windows, so this is not platform- or build-specific.
Found the hard way, in production: a skill documenting arts-funding program maxima was invoked by the model with a natural-language
argsstring, and every category maximum in the delivered content was silently wrong. The prose around the numbers still read as authoritative, and the source file on disk was clean, so nothing signalled the corruption. These numbers get quoted to clients.Controlled repro
Same skill, invoked twice via the
Skilltool. Source on disk is byte-identical between runs.Run A — no
args: every figure delivered verbatim. Clean.Run B —
args: "ZEBRAONE ZEBRATWO ZEBRATHREE ZEBRAFOUR ZEBRAFIVE ZEBRASIX ZEBRASEVEN ZEBRAEIGHT ZEBRANINE ZEBRATEN ZEBRAELEVEN":| On disk | Delivered to model | Index hit |
|---|---|---|
|
$1,000|ZEBRATWO,000|ARGUMENTS[1]||
$4,000|ZEBRAFIVE,000|ARGUMENTS[4]||
$5,000|ZEBRASIX,000|ARGUMENTS[5]||
$8,000|ZEBRANINE,000|ARGUMENTS[8]||
$9,990|ZEBRATEN,990|ARGUMENTS[9]||
$10,000|ZEBRAELEVEN,000|ARGUMENTS[10]||
$15,000|$15,000(untouched) | index 15 out of range ||
$500|$500(untouched) | index 500 out of range |Reproduced on a second, unrelated skill in the same run:
$2,052→QQQTWO,052,$0.00→QQQZERO.00, while$110and$[OFFER AMOUNT]passed through. Exactly the 0-indexed behaviour already described above.Worth naming explicitly: the digit run is matched greedily, so how much damage a given figure takes depends on the arg count.
$1,000–$9,999corrupt with as few as 2–10 args;$10,000–$99,999need 11+. A one-sentenceargsstring is ~10 words, which is squarely in the range that destroys five-figure amounts while leaving four-figure ones intact — so the same skill corrupts differently on each invocation, and a spot-check on one number can pass while others are wrong.Prevalence, measured
Across a 27-skill plugin used for grant consulting:
$N,NNN-shaped literals that this substitution can hit.$ARGUMENTS,$ARGUMENTS[N],$N, orarguments:/argument-hint:frontmatter.Zero opt-in to the feature, 81% exposure to its failure mode. Every
argsstring ever passed to this library has been pure downside — no placeholder consumed it, so its only effect was the chance of shredding a number. Note also that the model, not the user, chooses whether to passargs, so an author who never writes a placeholder still cannot predict whether their skill gets templated on any given call.\$escaping is documented at https://code.claude.com/docs/en/skills and does work, but it only protects skills whose authors already know about this issue. It cannot protect the installed base, and it makes the safe way to write a dollar sign in prose the non-obvious one.Strongly seconding the suggested fix: interpolate
argsonly into placeholders the author actually wrote, and when a skill declares none, append it inertly (the behaviour that already happens for skills with no$Nmatch) rather than regexing the prose. Failing that, restricting substitution to skills that declarearguments:frontmatter would close the installed-base hole without changing the placeholder syntax.Disclosure: drafted with Claude Code's help; I reproduced the behaviour myself on the version stated, checked the claims, and will answer follow-ups.
Still present on 2.1.270 (Debian 13, Node 24) as of 2026-09-14. Commenting here because this is now the live report: #78759 covered the same defect from the slash-command side, collected confirmations on 2.1.220 through 2.1.233 across Windows, macOS, WSL and Debian, and was closed by the inactivity sweep on 2026-09-13 rather than resolved. Three things from that thread belong here, since none of them is in this one yet.
An Anthropic maintainer reproduced it. bcherny reproduced it on 2.1.233, assessed the substitution as working as designed with the backslash escape as the opt-out, and agreed that leaving fenced and inline code alone is a reasonable and common expectation, worth either exempting fences or surfacing the escape in the authoring docs. So the open question is which fix, not whether.
Fenced blocks and inline code spans are substituted too, which puts embedded shell in scope alongside prose figures. On 2.1.270, a command file holding
awk '!seen[$0]++'in a bash fence and `$1,000in an inline span, invoked as/demo ARGZERO ARGONE, reaches the model asawk '!seen[ARGZERO]++'andARGONE,000;\$0.01arrives as$0.01. The instance behind [my comment there](https://github.com/anthropics/claude-code/issues/78759#issuecomment-5147192606): that awk line sat in a session close-out skill,/close-out thanksturned it intoawk '!seen[thanks]++'`, which keys every line on one constant and emits one record, and the assistant then reported a defect in the user's file that did not exist. The truncated list was the one that decides which git worktrees get torn down.The typed slash-command path cannot be hooked. fredericobt showed on 2.1.232 that a
PreToolUsehook matchingSkillcan deny non-emptyargs, but a user typing/skill some wordsmakes no tool call, so the same substitution runs with nothing to intercept it. A hook covers the model-initiated half only. This report is about theSkilltool path and #78759 was about the typed one; they want one fix, and a per-skill opt-out closes both.What we did meanwhile, in case it is useful to anyone else: a pre-commit check that flags any
$<digit>inside a fenced block in a skill file, escaped or not. Escaped too, because the prose beside an escaped line then has to say "the backslash is load-bearing", and in 29 of 156 recorded runs of one such recipe the reading agent typed the backslash into the shell, got an awk syntax error, and retried without it. Restructuring to a named variable is the only form that costs nothing on either side.Seconding the ask as stated: interpolate only into placeholders the author wrote, or at the least stop at fences and code spans.
To prevent this issue from getting stale and eventually auto-closed, you'll need to collect at least 10 thumbs up, or comment every time before the 14 days stale interval expires. See https://github.com/anthropics/claude-code/issues/87647 for details, and consider upvoting it for visibility.