From b3507beb0fd0337ebcf829be1ff56285cce59b6d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:47:07 -0400 Subject: [PATCH] feat(skill-quality): enforce shell declaration for injection-carrying skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add check 19 to check-skill.sh: a skill using `!` dynamic-context injections (inline !`cmd` or ```! blocks) with no `shell:` frontmatter FAILs when the injected commands carry detectable bash-only syntax (/dev/null, command -v, a pipe into a Unix text tool) and WARNs when the commands only look portable — static analysis cannot prove portability, and an undeclared bash-only injection silently falls through to the PowerShell tool on a host without Git Bash. A `shell:` declaration is trusted as the author's explicit choice. Add check 20: WARN when an injected command carries no `|| ` continuation, per the pinned defensive-fallback convention. Both checks scan the injected command text only, never prose or a plain ```bash example. Inline injections are recognized only at line start or after whitespace (per the injection docs), so a mid-token !` such as an inline `#!` code span is not captured. Eight new self-tests cover the FAIL/WARN tiers, fenced-block extraction, the || (not || echo) fallback match, the over-reach guard, and the prose-anchor guard. Closes #865 --- README.md | 2 +- .../skill-quality/.claude-plugin/plugin.json | 4 +- plugins/skill-quality/CHANGELOG.md | 19 ++ plugins/skill-quality/scripts/check-skill.sh | 106 ++++++++++ .../skill-quality/scripts/check-skill.test.sh | 187 ++++++++++++++++++ plugins/skill-quality/skills/check/SKILL.md | 14 +- 6 files changed, 326 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 09b7a069c..4d4c55448 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ user opts in with `/plugin enable`; an existing install is never flipped by cata - [`claude-config`](plugins/claude-config) — Four audit skills for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), and audit-model-fit (instruction surfaces swept for model-hobbling constraints newer models no longer need). - [`claude-memory`](plugins/claude-memory) — Keeps a repo's Claude Code memory layer healthy and under your control, against criteria derived from official Claude Code documentation. The audit skill checks the instruction/memory layer (CLAUDE.md, CLAUDE.local.md, .claude/rules/, auto-memory) with a deterministic script-backed spine plus judgment-tier checks. The stateless skill inspects, disables, and (confirm-gated) purges Claude-written auto memory across all settings scopes. - [`claude-ops`](plugins/claude-ops) — Claude Code operations toolkit. Seven skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort and a repo-pull + marketplace-refresh launch step), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads. -- [`skill-quality`](plugins/skill-quality) — Skill-authoring QA tooling: a static contract checker that runs eighteen deterministic checks over a Claude Code skill (frontmatter, listing-budget cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence, precompute opportunity) and a bundled evals.json schema for validation. Runs against any repo's skills directory via the convention-resolution ladder — no baked layout. +- [`skill-quality`](plugins/skill-quality) — Skill-authoring QA tooling: a static contract checker that runs twenty deterministic checks over a Claude Code skill (frontmatter, listing-budget cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence, precompute opportunity, injection shell-declaration) and a bundled evals.json schema for validation. Runs against any repo's skills directory via the convention-resolution ladder — no baked layout. ### Autonomy diff --git a/plugins/skill-quality/.claude-plugin/plugin.json b/plugins/skill-quality/.claude-plugin/plugin.json index 12f2847b9..ff282b8ea 100644 --- a/plugins/skill-quality/.claude-plugin/plugin.json +++ b/plugins/skill-quality/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "skill-quality", - "version": "0.7.2", - "description": "Skill-authoring QA tooling: a static contract checker that runs eighteen deterministic checks over a Claude Code skill (frontmatter, listing-budget cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence, precompute opportunity) and a bundled evals.json schema for validation. Runs against any repo's skills directory via the convention-resolution ladder — no baked layout.", + "version": "0.8.0", + "description": "Skill-authoring QA tooling: a static contract checker that runs twenty deterministic checks over a Claude Code skill (frontmatter, listing-budget cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence, precompute opportunity, injection shell-declaration) and a bundled evals.json schema for validation. Runs against any repo's skills directory via the convention-resolution ladder — no baked layout.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" diff --git a/plugins/skill-quality/CHANGELOG.md b/plugins/skill-quality/CHANGELOG.md index 986ed78d3..414ada2c3 100644 --- a/plugins/skill-quality/CHANGELOG.md +++ b/plugins/skill-quality/CHANGELOG.md @@ -3,6 +3,25 @@ All notable changes to the `skill-quality` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.0] + +### Added + +- **Check 19 (dynamic-context injection shell declaration) — FAIL/WARN.** A `` !`command` `` / + ` ```! ` injection defaults to `shell: bash`; on a host without Git Bash it falls through to + the PowerShell tool, so a bash-only pipeline silently breaks (a 2026-07-21 fleet census found + 64 such skills across 26 plugins). When a skill carries injections and declares no `shell:` + frontmatter, the check FAILs on detectable bash-only syntax (`/dev/null`, `command -v`, a pipe + into a Unix text tool with no same-named PowerShell cmdlet) and WARNs on portable-looking + commands (portability is not statically provable). A `shell:` declaration is trusted as the + author's explicit choice — no per-shell syntax validation. The scan is scoped to injected + command text only, never prose or a plain ` ```bash ` example. +- **Check 20 (injection defensive fallback) — WARN.** Injection failure/timeout/stderr semantics + are undocumented, so an unguarded command can inline an error string into the prompt. The check + WARNs on any injected command lacking a `|| ` continuation, per the pinned + precompute convention. It matches the `||` continuation, not the literal `echo` (`|| printf` / + `|| true` are valid fallbacks). + ## [0.7.2] ### Changed diff --git a/plugins/skill-quality/scripts/check-skill.sh b/plugins/skill-quality/scripts/check-skill.sh index 9decd4831..610319ed9 100755 --- a/plugins/skill-quality/scripts/check-skill.sh +++ b/plugins/skill-quality/scripts/check-skill.sh @@ -45,6 +45,10 @@ # 17. Vendor-backed: metadata.synced not older than 180 days (WARN) # 18. Precompute opportunity: a fenced shell block gathers read-only context # the skill could inline at load time via `!` injection (WARN; heuristic) +# 19. shell: declared when `!` dynamic-context injections carry bash-only syntax +# (FAIL bash-only syntax + no shell:; WARN portable-looking but undeclared) +# 20. `!`-injected commands carry a `|| ` (WARN; undocumented injection +# failure semantics — degrade to a known string, not a surprise) # # Notes (static, git-diff-based design): # - Checks 3/8/9 diff the working tree against CHECK_SKILL_BASE_REF (default @@ -549,6 +553,108 @@ if ((PRECOMPUTE_CANDIDATE == 1)); then warn 'precompute opportunity: a fenced shell block runs read-only context-gathering commands the skill could inline at load time via `!`command`` / ```! dynamic-context injection (preprocessed once at load, no per-invocation tool call). If the block runs on every invocation to gather context, convert it; if it is an illustrative example, ignore. Heuristic over fenced shell blocks — see https://code.claude.com/docs/en/skills#inject-dynamic-context' fi +# --- Checks 19-20: dynamic-context injection portability + fallback ---------- +# Both checks scan the INJECTED command text only — never prose or a plain +# ```bash example that merely shows the syntax. Collect it once: every inline +# !`cmd` occurrence, plus the body lines of each ```! fenced block. Lines inside +# a NON-injection fenced block are literal examples, so inline !` there is +# skipped too (a general-fence state gates the inline scan). The inline form is +# recognized only at line start or after whitespace (per the injection docs), so +# a mid-token `!`` such as an inline `#!` code span in prose is not an injection. +INJECTIONS=() +inj_in_fence=0 # inside any fenced code block +inj_fence_len=0 +inj_is_injection=0 # the current fence opened as a ```! injection block +# shellcheck disable=SC2016 # single quotes deliberate: backticks and $ are literal regex, not shell expansion +inj_fence_re='^(```+)([^`]*)$' +while IFS= read -r il || [[ -n "$il" ]]; do + itrim="${il#"${il%%[![:space:]]*}"}" + if [[ "$itrim" =~ $inj_fence_re ]]; then + iticks="${BASH_REMATCH[1]}" + iinfo="${BASH_REMATCH[2]}" + ilen=${#iticks} + iinfo="${iinfo#"${iinfo%%[![:space:]]*}"}" # ltrim the info string + if ((inj_in_fence == 0)); then + inj_in_fence=1 + inj_fence_len=$ilen + [[ "$iinfo" == '!'* ]] && inj_is_injection=1 || inj_is_injection=0 + elif ((ilen >= inj_fence_len)) && [[ -z "$iinfo" ]]; then + inj_in_fence=0 + inj_is_injection=0 + fi + continue + fi + if ((inj_in_fence == 1)); then + if ((inj_is_injection == 1)) && [[ -n "$itrim" && "$itrim" != \#* ]]; then + INJECTIONS+=("$il") + fi + continue # inside a non-injection fence → literal example, skip inline scan + fi + # Outside any fence: collect every inline injection on the line. grep -oE + # emits each anchored `!`cmd`` match on its own line; strip the + # boundary + opening !` and the closing backtick to leave the command. Fixed + # literals do the stripping (never the command text), so a command carrying + # glob metacharacters is captured verbatim. + # shellcheck disable=SC2016 # single quotes deliberate: the backticks are literal delimiters, not shell expansion + while IFS= read -r inj_match; do + inj_match="${inj_match#*'!`'}" # drop boundary + opening !` + inj_match="${inj_match%'`'}" # drop closing backtick + INJECTIONS+=("$inj_match") + done < <(grep -oE '(^|[[:space:]])!`[^`]+`' <<<"$il") +done <"$SKILL_MD" + +if ((${#INJECTIONS[@]} > 0)); then + # --- Check 19: shell declaration for bash-only injection syntax ------------ + # A `!` injection defaults to bash; on a host without Git Bash it falls + # through to the PowerShell tool, so a bash-only pipeline silently breaks. + # Declaring `shell:` is the author taking explicit responsibility for the + # shell (we trust it — no per-shell syntax validation, so `shell: pwsh` with + # bash-only commands is out of scope). With no declaration, bash-only syntax + # is a FAIL; portable-looking commands are an unprovable WARN. + # + # Bash-only token set is deliberately narrow (tight avoids a false FAIL that + # blocks; anything missed degrades to the WARN path, never a false negative): + # `/dev/null` (PowerShell is `$null`), `command -v` (a bash builtin; + # PowerShell is `Get-Command`), and a pipe into a Unix text tool with no + # same-named PowerShell cmdlet. `sort`/`tee` are excluded — PowerShell aliases + # them, so a pipe there is not a clean break. + # shellcheck disable=SC2016 # single quotes deliberate: \| and $ are literal ERE, not shell expansion + bash_only_re='/dev/null|(^|[[:space:]])command[[:space:]]+-v([[:space:]]|$)|\|[[:space:]]*(head|tail|grep|sed|awk|cut|tr|wc|xargs|rev|nl|fold|paste|comm|join|column|uniq)([[:space:]]|$)' + if grep -qE '^shell:[[:space:]]*\S' <<<"$FRONTMATTER"; then + note "shell: declared — dynamic-context injection portability is the author's explicit choice" + else + bash_only_hit="" + for inj in "${INJECTIONS[@]}"; do + hit="$(grep -oE "$bash_only_re" <<<"$inj" | head -1 || true)" + if [[ -n "$hit" ]]; then + bash_only_hit="$hit" + break + fi + done + if [[ -n "$bash_only_hit" ]]; then + # shellcheck disable=SC2016 # single quotes deliberate: the backticked tokens in the message are literal + err "\`!\` dynamic-context injection uses bash-only syntax ('$bash_only_hit') with no \`shell:\` frontmatter — on a host without Git Bash the injection falls through to the PowerShell tool and breaks. Declare \`shell: bash\` (or write portable commands). See https://code.claude.com/docs/en/skills#inject-dynamic-context" + else + # shellcheck disable=SC2016 # single quotes deliberate: the backticked tokens in the message are literal + warn "\`!\` dynamic-context injection present with no \`shell:\` frontmatter — the commands look portable but static analysis can't prove it. Declare \`shell:\` explicitly, or confirm the commands run under the host's default shell" + fi + fi + + # --- Check 20: injected commands carry a defensive fallback ---------------- + # Injection failure/timeout/stderr semantics are undocumented, so an unguarded + # command can inline an error string (or nothing) into the prompt. The pinned + # convention is a `|| ` on every injected command; match the `||` + # continuation, not the literal `echo` (`|| printf`/`|| true` are valid too). + missing_fallback=0 + for inj in "${INJECTIONS[@]}"; do + [[ "$inj" == *'||'* ]] || missing_fallback=$((missing_fallback + 1)) + done + if ((missing_fallback > 0)); then + # shellcheck disable=SC2016 # single quotes deliberate: the backticked tokens in the message are literal + warn "$missing_fallback \`!\`-injected command(s) carry no \`|| \` — injection failure/timeout/stderr semantics are undocumented, so an unguarded command can inline an error string into the prompt. Add a \`|| echo \"\"\` (or shell-appropriate) continuation" + fi +fi + # --- Summary --------------------------------------------------------------- printf '\n' diff --git a/plugins/skill-quality/scripts/check-skill.test.sh b/plugins/skill-quality/scripts/check-skill.test.sh index 55ab9f2b9..d2161b25d 100755 --- a/plugins/skill-quality/scripts/check-skill.test.sh +++ b/plugins/skill-quality/scripts/check-skill.test.sh @@ -953,6 +953,193 @@ else fail "unpaired vendor/ diff should still fail (rc=$rc): $out" fi +# 21. A `!` injection with a `shell:` declaration is silent — the author has +# taken explicit responsibility for the shell (check 19). +make_skill inj-shell-ok '--- +name: inj-shell-ok +description: "Injects context. Use when: '"'"'injecting with a shell decl'"'"'." +shell: bash +--- + +## Context +- Tree: !`git status --short 2>/dev/null || echo "(clean)"` + +## Gotchas + +None known. +' +out="$(run inj-shell-ok 2>&1)" +rc=$? +if [[ $rc -eq 0 ]] && ! grep -q 'falls through to the PowerShell tool' <<<"$out" && ! grep -q 'look portable but static analysis' <<<"$out"; then + pass "injection with a shell: declaration is silent (check 19)" +else + fail "injection + shell: decl should not warn/fail check 19 (rc=$rc): $out" +fi + +# 22. A `!` injection with bash-only syntax (/dev/null) and NO `shell:` fails +# (check 19 — the census regression this gate exists for). +make_skill inj-bashonly-fail '--- +name: inj-bashonly-fail +description: "Injects context. Use when: '"'"'injecting bash-only syntax'"'"'." +--- + +## Context +- Tree: !`git status --short 2>/dev/null || echo "(clean)"` + +## Gotchas + +None known. +' +out="$(run inj-bashonly-fail 2>&1)" +rc=$? +if [[ $rc -eq 1 ]] && grep -q 'falls through to the PowerShell tool' <<<"$out"; then + pass "bash-only injection without shell: fails (check 19)" +else + fail "bash-only injection + no shell: should fail check 19 (rc=$rc): $out" +fi + +# 23. A `!` injection whose commands look portable, with no `shell:`, WARNs +# (portability is not statically provable) but passes (check 19). +make_skill inj-portable-warn '--- +name: inj-portable-warn +description: "Injects context. Use when: '"'"'injecting portable commands'"'"'." +--- + +## Context +- Branch: !`git branch --show-current` + +## Gotchas + +None known. +' +out="$(run inj-portable-warn 2>&1)" +rc=$? +if [[ $rc -eq 0 ]] && grep -q 'look portable but static analysis' <<<"$out"; then + pass "portable-looking injection without shell: warns without failing (check 19)" +else + fail "portable injection + no shell: should warn not fail (rc=$rc): $out" +fi + +# 24. A ```! FENCED injection with bash-only syntax (| head) and no `shell:` +# fails — exercises the fenced-block extraction feeding check 19. +make_skill inj-fenced-fail '--- +name: inj-fenced-fail +description: "Injects a block. Use when: '"'"'injecting a fenced block'"'"'." +--- + +## Snapshot + +```! +git log --oneline | head -5 +``` + +## Gotchas + +None known. +' +out="$(run inj-fenced-fail 2>&1)" +rc=$? +if [[ $rc -eq 1 ]] && grep -q 'falls through to the PowerShell tool' <<<"$out"; then + pass "fenced injection block with bash-only syntax fails (check 19 fence extraction)" +else + fail "bash-only fenced injection should fail check 19 (rc=$rc): $out" +fi + +# 25. The over-reach guard: a bash-only token in a PLAIN ```bash block (not an +# injection) must NOT flag check 19/20 — the scan is scoped to injected text. +make_skill inj-plainblock-safe '--- +name: inj-plainblock-safe +description: "Shows an example. Use when: '"'"'showing a plain shell example'"'"'." +--- + +## Example + +```bash +git status --short 2>/dev/null | head -5 +``` + +## Gotchas + +None known. +' +out="$(run inj-plainblock-safe 2>&1)" +rc=$? +if [[ $rc -eq 0 ]] && ! grep -q 'falls through to the PowerShell tool' <<<"$out" && ! grep -q 'look portable but static analysis' <<<"$out"; then + pass "bash-only tokens in a plain bash code block do not flag check 19 (scoped to injections)" +else + fail "plain bash code block should not trip the injection checks (rc=$rc): $out" +fi + +# 26. An injected command with no `|| ` warns (check 20), isolated +# from check 19 by a `shell:` declaration. +make_skill inj-nofallback-warn '--- +name: inj-nofallback-warn +description: "Injects context. Use when: '"'"'injecting without a fallback'"'"'." +shell: bash +--- + +## Context +- Tree: !`git status --short` + +## Gotchas + +None known. +' +out="$(run inj-nofallback-warn 2>&1)" +rc=$? +if [[ $rc -eq 0 ]] && grep -q 'carry no' <<<"$out"; then + pass "injected command without a || fallback warns (check 20)" +else + fail "missing || fallback should warn (rc=$rc): $out" +fi + +# 27. A `|| printf` fallback satisfies check 20 — the gate matches the `||` +# continuation, not the literal `echo`. +make_skill inj-printf-fallback '--- +name: inj-printf-fallback +description: "Injects context. Use when: '"'"'injecting with a printf fallback'"'"'." +shell: bash +--- + +## Context +- Tree: !`git status --short || printf "(clean)"` + +## Gotchas + +None known. +' +out="$(run inj-printf-fallback 2>&1)" +rc=$? +if [[ $rc -eq 0 ]] && ! grep -q 'carry no' <<<"$out"; then + pass "a || printf fallback satisfies check 20 (matches || not echo)" +else + fail "|| printf should count as a fallback (rc=$rc): $out" +fi + +# 28. Anchor guard: a mid-token `!`` in prose (an inline `#!` code span) is NOT +# an injection, so a no-shell skill carrying one is not scanned (checks 19/20 +# stay silent). The inline form is recognized only at start / after space. +make_skill inj-prose-hashbang '--- +name: inj-prose-hashbang +description: "Mentions a shebang. Use when: '"'"'mentioning a shebang in prose'"'"'." +--- + +## Notes + +For a file whose first line is a shebang (`#!`), confirm the recorded mode. + +## Gotchas + +None known. +' +out="$(run inj-prose-hashbang 2>&1)" +rc=$? +if [[ $rc -eq 0 ]] && ! grep -q 'falls through to the PowerShell tool' <<<"$out" && ! grep -q 'look portable but static analysis' <<<"$out" && ! grep -q 'carry no' <<<"$out"; then + pass "a mid-token !\` in prose (inline #! code span) is not treated as an injection" +else + fail "prose #! code span should not trip the injection checks (rc=$rc): $out" +fi + if [[ $fails -ne 0 ]]; then printf '%d assertion(s) failed\n' "$fails" >&2 exit 1 diff --git a/plugins/skill-quality/skills/check/SKILL.md b/plugins/skill-quality/skills/check/SKILL.md index ebca60b39..9fbb260bb 100644 --- a/plugins/skill-quality/skills/check/SKILL.md +++ b/plugins/skill-quality/skills/check/SKILL.md @@ -1,6 +1,6 @@ --- name: check -description: "Skill-authoring QA for Claude Code skills. Use when: 'check this skill', 'skill quality', 'lint my skill', 'is this SKILL.md valid', 'validate skill frontmatter', 'check skill before publishing', 'validate evals.json', or before shipping a skill or plugin. Actions: `check []` runs an eighteen-check static contract gate (frontmatter, listing-budget cap, trigger-keyword preservation vs HEAD, line caps, broken internal refs, markdownlint, gotchas surface, evals presence, precompute opportunity) and reports PASS/FAIL with warnings; `validate-evals []` checks a skill's evals/evals.json against the bundled schema. Not for: writing new skills, or running model-graded evals." +description: "Skill-authoring QA for Claude Code skills. Use when: 'check this skill', 'skill quality', 'lint my skill', 'is this SKILL.md valid', 'validate skill frontmatter', 'check skill before publishing', 'validate evals.json', or before shipping a skill or plugin. Actions: `check []` runs a twenty-check static contract gate (frontmatter, listing-budget cap, trigger-keyword preservation vs HEAD, line caps, broken internal refs, markdownlint, gotchas surface, evals presence, precompute opportunity, injection shell-declaration) and reports PASS/FAIL with warnings; `validate-evals []` checks a skill's evals/evals.json against the bundled schema. Not for: writing new skills, or running model-graded evals." argument-hint: "[check|validate-evals] [] — omit the action for check; omit the skill name to run over every skill" user-invocable: true disable-model-invocation: false @@ -10,7 +10,7 @@ shell: bash ## Purpose Static, deterministic quality gate for skill authoring. The `check` action runs the bundled -`check-skill.sh` — eighteen checks with no model invocation, so results are reproducible in CI or a +`check-skill.sh` — twenty checks with no model invocation, so results are reproducible in CI or a pre-commit hook. The `validate-evals` action checks a skill's `/evals/evals.json` against the bundled JSON schema. Catches the failure that static analysis catches best: a rewrite silently dropping a `description` trigger phrase, which degrades auto-invocation. @@ -59,7 +59,8 @@ Parse `$ARGUMENTS`: - **PASS / FAIL** from the script's exit code (0 = pass, 1 = one or more `FAIL:` lines). - The `FAIL:` lines verbatim (each is an actionable defect). - `WARN:` lines grouped after failures (advisory — soft line target, missing gotchas surface, - action-router without evals, orphan spokes). + action-router without evals, orphan spokes, an injection with no `shell:` whose commands + only *look* portable, and an injected command carrying no `|| `). 4. For a multi-skill run, end with a one-line rollup: `N passed, M failed`. The `FAIL:` messages are self-describing. Do not re-derive their meaning; surface them and, when the @@ -92,6 +93,13 @@ that line before editing, since it may be an illustrative example path rather th ref before the change (e.g. `HEAD^` or a merge-base) and run on a clean tree; it reroutes checks 3/8/9. - Trigger-drop protection tracks single-quoted `'phrase'` triggers. An unquoted `Use when:` list is not tracked by check 3; check 12 warns so those phrases get quoted and covered. +- Check 19 (injection shell-declaration) FAILs only when a `!` injection carries *detectable* + bash-only syntax (`/dev/null`, `command -v`, a pipe into a Unix text tool) AND no `shell:` is + declared; portable-looking commands downgrade to a WARN, since static analysis cannot prove + portability. A `shell:` declaration is trusted wholesale — the check does not validate that the + injected commands actually match the declared shell (so `shell: pwsh` with bash-only commands is + out of scope). Both checks 19 and 20 scan the injected command text only — a bash-only token in a + plain `` ```bash `` example or in prose never trips them. - Check 18 (precompute opportunity) is an advisory heuristic, never a FAIL. It cannot tell an instruction-to-run shell block from an illustrative example, so a WARN is a candidate to judge, not a defect — like a check-5 ref, hand-verify the block before converting it. It reads only fenced shell