Argument substitution corrupts literal $N text (prices, awk fields) in command/skill file content — including fenced code blocks; no opt-out
Summary
Custom slash-command and skill argument substitution rewrites literal dollar-digit text anywhere in the file — including fenced code blocks and tables — with no opt-out. Prices like $0.01, $1.2M, and awk/bash positional fields ($0, $2) inside embedded shell snippets are silently replaced by invocation arguments. With numeric arguments (ticket numbers are a common convention), the corruption produces plausible-looking wrong numbers, and the on-disk file remains correct — so the damage is invisible to diffs and code review and lands directly in the instructions the model follows.
Environment
- Claude Code current builds (observed July 2026; CLI + programmatic Skill-tool invocation paths both affected)
- Windows 11, but the behavior is loader-level and platform-independent
Minimal reproduction
- Create
.claude/commands/demo.md:
```markdown
Perplexity search costs $0.01/call. Revenue example: $1.2M vs $1.4M. Also $200K.
``mermaid`
flowchart LR
A[DB $1.4M] --> B[Report $1.2M]
``
- Invoke with arguments:
/demo ARGZERO ARGONE
- Rendered content received by the model:
$0.01/call→ARGZERO.01/call($0= first argument, 0-indexed)$1.2M→ARGONE.2M,$1.4M→ARGONE.4M— including inside the mermaid fence$200K→ unchanged (maximal-munch:$200parses as argument index 200, unsupplied → stays literal)
Observed identically for SKILL.md files invoked with arguments, and for the programmatic Skill-tool invocation path. Real-world instances we hit: a cost table rendering Canon.01 (argument "Canon" into $0.01), and an embedded awk one-liner corrupted by a ticket-number argument (l=$0 → l=857), which broke the command's embedded script logic.
What the docs say vs. what's undocumented
Documented (code.claude.com/docs/en/skills → "Available string substitutions"): $ARGUMENTS, $ARGUMENTS[N], $N (0-based), declared $name, ${CLAUDE_*}; unsupplied indexed tokens stay literal; unsupplied named tokens become empty; escape via single backslash (\$1.00).
Undocumented / surprising:
- Fence scope: nothing states whether fenced code blocks are exempt. Empirically they are NOT — substitution reaches inside ``` fences, which is where shell snippets and diagram labels live.
- Maximal-munch ambiguity:
$200Kis index-200, not$2+ "00K" — surprising and undocumented;$199is ambiguous between "index 199" and "money" by design. - Semantic churn risk for the escape defense: legacy docs pages still show 1-indexed examples (
$1= first argument); the changelog records unmatched$1/$2having been silently stripped in earlier builds, later fixed to "preserved verbatim". Content authored against one build's semantics silently changes meaning under another. - Substitution locus: unclear whether substitution happens only when populating model context or also in execution-hook evaluation (`
!cmd`); we observed the context-population path. - Recursion: unstated whether a substituted value containing
$1-shaped text is re-processed. - Special tokens:
$?,${VAR},$(cmd),$WORDpass through untouched (good — matches the documented grammar);$@/$*/$#presumed safe but undocumented.
Why this matters
Command/skill files are instructions. Silent rewriting of their literals corrupts the canon the model executes, at the moment of use, with no error and no on-disk trace. Any file that documents costs, shows example figures, or embeds shell with positional parameters is affected the moment someone passes an argument.
Requests (in priority order)
- Per-file substitution control — a frontmatter opt-out (e.g.
interpolation: false) or, better, opt-in binding of declared arguments only. Indiscriminate whole-file substitution is the root cause; escaping every literal is not durable given the semantic churn above. - Fence exemption (or at minimum a documented statement of fence behavior) — code blocks are the least likely place a template variable is intended and the most likely place a literal
$lives. - Docs clarification + engine-side regression tests for: fence scope, maximal-munch, unsupplied-position behavior, escape semantics across versions, recursion, and hook-time vs context-time substitution. Related docs request: #19355.
(Not a duplicate of #34164 — that reports arguments failing to substitute in forked-skill contexts; this reports unwanted substitution of literal content. #36135 is likewise about frontmatter hook variables, not file-content corruption.)
Our downstream mitigation (for reference)
We removed all dollar-digit literals from our loader-interpolated files (rewording prices to USD x.xx, hoisting embedded shell into external scripts) and added a CI guard banning the token class in those file trees — but that only protects one repo. The engine-level fix belongs here.
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
Independent re-discovery of this on macOS, via the programmatic Skill-tool path — confirming it is loader-level and platform-independent, as you say. Adding one angle not covered above, because it changes the severity argument:
The corruption can destroy the instruction that is about the token.
In our case the affected line was a safety rule in an agent skill: prose instructing that token-denominated usage must be reported as
uncostedand never as$0, because a zero-dollar figure would falsely assert a measured zero spend. Invoked with a ticket id as the argument, the delivered text became "...never asFOO-123, which would falsely claim a measured zero-dollar spend."The rule was therefore silently disarmed at exactly the moment an agent was reading it — and the class of prose most likely to contain a literal
$0is precisely the prose warning about$0. Anti-fabrication and cost-reporting guidance is disproportionately exposed, and a corrupted instruction reads as merely nonsensical rather than as tampering, so a model is unlikely to flag it.Two smaller notes:
skills/*/SKILL.md, plus rewording prose to avoid the literal ("a zero-dollar figure"). We chose rewording over the documented backslash escape because a corpus-wide scan is mechanically enforceable against reintroduction, whereas an escape has to be remembered by every future author.Strong +1 on the opt-out — for agent skills, body text being data rather than a template is the safer default.
Disclosure: drafted with Claude Code's help; I reproduced the behaviour myself on the version stated, checked the claims, and will answer follow-ups.
Confirming this on Claude Code 2.1.220 (Debian 13 in a devcontainer, Node 24.18.1), which is later than the builds named above, and adding one consequence class I do not think is covered yet.
A probe skill invoked as
/subst-probe alpha beta:So: fence scope confirmed again, escape confirmed working inside fences as well as prose, unsupplied indices left alone — all as you describe.
The instance that led me here is worth adding for two reasons beyond "it happened to us too".
It produced confident misinformation, not just a broken script. The corrupted line was a deduplication step,
awk '!seen[$0]++', in a skill that inventories the pull requests a session opened. Invoked as/close-out thanks, it becameawk '!seen[thanks]++'— still valid awk, but keying every line on one constant, so it emits only the first record. The assistant read that output, concluded the skill itself contained a bug, and reported that to the user as a defect in their file. The file was correct. Because the corruption exists only in the model's context and never on disk, there was nothing to diff and no way to see it from the file — the user worked out what had happened only because they remembered they had typed an argument.The corrupted value drove a destructive step. That inventory decides which git worktrees get torn down at the end of a session. A list silently truncated to one entry means the rest are left standing — benign in that direction, but the same shape with the substitution landing elsewhere would remove the wrong thing. Any command that computes a list and then acts on it has this exposure.
Both are consequences of the property already in your summary — that the corruption is invisible to diffs and code review — but they show it reaching past "the embedded script misbehaves" into "the agent reports a falsehood about the user's code" and "the agent acts on a truncated list". That seems worth weighing when ranking request 1 (per-file opt-out) against request 2 (fence exemption). For what it is worth, the fence exemption would have covered our case entirely, and the escape — which does work — did not help, because nobody knew it was needed.
Also a small note on the failure's shape, which is why it survived: an unsupplied index is left literal, so a skill containing
$0is correct every time it is invoked without arguments and corrupt only when someone passes one. Ours had been right for weeks.Independent confirmation of this report, from a controlled experiment run without knowledge of this issue — so the agreement is genuinely independent rather than a re-reading of it. We drafted a standalone report before searching the tracker, found this, and are commenting rather than filing a duplicate. #78759 is the better report; everything below is additive.
Different platform and a different reason to believe it's loader-level. This issue notes Windows 11 with platform-independence as a hypothesis. Ours was measured on WSL2 / Ubuntu 24.04.3, kernel 6.6.87.2, Node v24.15.0, Claude Code
2.1.220— four liveSkill-tool invocations across two separate skill trees (a local repo-symlinked tree and an installed plugin tree), with the on-disk file asserted byte-identical bygrep -nFbefore and after each probe. Same behavior. That makes the platform-independence claim measured on two platforms rather than inferred from one.Confirming your specifics:
$0is the first argument (measured at three index points), fences do not protect, markdown tables and plain prose are rewritten, and$2.50/MbecameTWOTOK.50/Mmid-token. We did not independently measure your maximal-munch finding ($200K→ index 200) — that's new to us and worth keeping front and center.---
The severity case is stronger than "plausible-looking wrong numbers"
Your
awkexample (l=$0→l=857) breaks the command, which at least surfaces. We have a measured case that does not surface at all.One of our skills computes a progress fraction with a two-pass
awkprogram whose body uses$0,$1and$2. Run against the skill's own frozen positive control, which must yield23/23, the delivered form yields0 0, exit 0. Downstream the skill then printsno denominator— a message it explicitly sanctions for a different and legitimate reason (a worklist that genuinely has no checkboxes).So, simultaneously:
Mechanism, derived from the program text and consistent with the measured output: pass one gates all counting on a flag set by testing
$0against a section heading. With$0replaced by a bareword, that bareword is an uninitializedawkvariable — the empty string — which never matches, so no counting rule ever fires and the totals print0 0. Pass two exists as a deliberate fallback and reads a pointer using$1and$2; those were substituted too, so the fallback is skipped as well. Every branch of a deliberately redundant computation is disabled by the same rewrite, and the redundancy that was supposed to make it trustworthy is what makes the failure total.Why "unsupplied indexed tokens stay literal" is the severity multiplier, not a footnote
Your report states this as documented behavior, which it is. We'd argue it deserves promoting to a headline, because it is what removes the fail-loud path. We measured it across 8 out-of-range references.
Emptying an out-of-range
$Nwould usually produce anawk/perlsyntax error — loud, immediate, attributable. Leaving the literal in place means the corrupted program frequently stays syntactically valid and exits 0. Every affected site therefore fails open. There is no configuration in which this defect announces itself.The wrong answer is chosen by whatever the user happened to type
Derived, not measured, and it follows from the substituted value being the user's own
argstext. A filter likeawk '$1 > 0 && $1 < 1900':5becomesawk '5 > 0 && 5 < 1900', constant-true — silently passes every record, including the ones the filter existed to reject.Same defect, opposite wrong answer, selected by unrelated user input. This is why we don't think an author-side escaping discipline is a durable mitigation: the author cannot enumerate the failure modes, because they depend on the caller.
A free triage signal (observed correlation, not a traced mechanism)
In 4 of 4 probes, the delivered body ended with a trailing
ARGUMENTS: <text>line iff nothing was substituted. When substitution fired, that trailer was absent. Useful as a detector — a missing trailer means the body was rewritten — but we're labelling it an observed correlation across four probes, not a mechanism we traced. If it holds generally it's a cheap regression assertion.Shared skill trees multiply the blast radius past one repo
User-level skills are visible to every project on the machine. On the box where this was measured,
~/.claude/skillsis a symlink into a single git repository, so one affectedSKILL.mdexposes every project on that machine, and agit pullpropagates it to every machine that clones it. Your closing line — "that only protects one repo" — understates it slightly in the shared-tree case: the unit of exposure is the machine, not the repo.---
On the requests
Strong agreement with your #1 (per-file opt-out, or better, opt-in binding of declared arguments only). Our downstream mitigation converged on exactly yours independently — we removed every dollar-digit literal from the affected files, hoisted embedded shell out to external scripts, and added a CI guard banning the token class. It took three passes, one site could not be fixed in place at all and had to move its logic out of the skill body entirely, and it protects exactly one machine's tree. That's a lot of work to buy something an engine-side fix gives everyone for free.
One addition we'd suggest for #3: whatever the fix, a loud failure for an out-of-range index would be worth more than silence, even before the scoping question is settled. Under the current behavior there is no configuration in which a user learns this happened.
Caveat on our environment: the measurements above are from
2.1.220. We have not re-probed on2.1.221, so treat the version as the one measured rather than as a claim about current builds.Disclosure: this was surfaced and fixed by a Claude Code agent session (Sonnet 5) while patching a production template shared across our internal repo fleet; I reviewed the repro and the claims below before posting.
Confirming on Claude Code 2.1.227, Linux (WSL2, Ubuntu, kernel 6.6.87.2), Node v24.15.0 — still present, well past the 2.1.220/2.1.221 builds already confirmed above. Adding two angles I don't think are covered yet.
A silent-zero failure shape, distinct from truncation/false-pass
Our case is a two-stage classify-then-count
awkscript (categorize an org's task backlog into columns by section heading, then tally each column) — same family as the!seen[$0]++and23/23examples above, but the corruption disables the classifier rather than the counter:With
$0rewritten to a constant (the first CLI arg token),tolower($0)becomestolower("sometoken")— a literal that never matches any section-heading pattern, socolstays""for the entire run and every category count silently goes to zero,exit 0. Unlike the truncate-to-one or pass-everything shapes already reported, this one reads as "the backlog is empty" — a business-metric failure a human skimming a dashboard is less likely to catch than an obviously-wrong-but-nonzero number, precisely because zero is a plausible value for an empty section.Blast radius via template propagation, not just shared skill-trees
The comment above notes shared
~/.claude/skillssymlinks widen exposure to "the machine, not the repo." We hit a different multiplier: this file is a template deliberately copied by two bootstrap automations into every downstream project (9 separate repos in our case, viacp/contents-API during project scaffolding — not a symlink). The vulnerability reproduces itself at every copy site with no shared code path to patch once — fixing it meant editing the template and separately patching every already-onboarded repo. Any org running a "copy this command/skill into new projects" bootstrap pattern has this exposure scale with fleet size, independent of the shared-tree case already described.A mitigation note (awk-specific, not general)
For anyone hitting this in
awkspecifically: rewriting$0/$1as$(0)/$(1)(parenthesized field-number expression) is byte-different from the literal$Ntoken the substitution keys on, but evaluates identically in awk (verified — same output before/after). This does not generalize to bash —$(0)in a shell script is command substitution (runs a command literally named0), not the positional parameter. Mentioning only so nobody in this thread copies it into a bash context and gets a different, more confusing failure.+1 on request #2 (fence exemption) given how naturally shell/awk snippets and their positional/field-reference syntax land inside fences specifically.