Skip to content

feat(guardrails): add the skill-reference-verify claim guard - #1319

Merged
kyle-sexton merged 8 commits into
mainfrom
feat/1270-guardrails-skill-reference-verify
Jul 25, 2026
Merged

feat(guardrails): add the skill-reference-verify claim guard#1319
kyle-sexton merged 8 commits into
mainfrom
feat/1270-guardrails-skill-reference-verify

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Closes #1270

Supersedes #1284 — same work, rebased onto main after #853 landed, and narrowed from two guards to one on measurement. #1284's branch could not be force-pushed, so this is a fresh branch; its review history is worth reading, since all 13 findings there were real.

Summary

One advisory PostToolUse guard on Write|Edit: skill-reference-verify flags a `/plugin:skill` reference in markdown that does not resolve.

Declared detect-then-judge, not deterministic. Globbing a plugins tree is exact only where the reference is locally owned — in a consuming repo it may name a plugin from another marketplace, or one simply not installed. Per conventions/engineering/enforceability-tiers.md that means advisory plus a human verdict, never an auto-fix.

Gated twice so the oracle only runs where it is meaningful: inert outside a marketplace repo, and within one it adjudicates only a plugin that repo's own manifests own. Resolution goes through manifest name and skill frontmatter name. A renamed skill's directory name is deliberately not an alias — treating it as one would suppress exactly the stale pre-rename references this guard exists to catch. The reference is the leading command token of a code span, so argument-bearing invocations (/plugin:skill --apply) are scanned.

Follows cli-flag-verify exactly: diff-scope only (scan what the call wrote, never re-read from disk), kill switch defaulting true, statusMessage, telemetry with repo-relative path redaction, lib/hook-utils.sh untouched.

Boy Scout: README counts were stale before this change — prose said "nine safety guards" while ten were wired and the table omitted block-convention-violation. Counts now measured against the manifest toggle set, missing row added, plus a new enforceability-tier section so the detect-then-judge guard cannot be read as deterministic.

A guard was built and withdrawn

asserted-path-verify shipped in #1284 and is not in this PR. A full-corpus sweep — all 975 tracked markdown files, each fed as a real payload — measured:

Files firing 231 / 975 = 23.7%
Findings 389
True positives 0

The oracle never misfired; every finding was a scoping problem. 72% were consumer-project config paths (.claude/** and similar) that a doc describes for a consuming repo and that correctly do not exist in a marketplace — its first-segment gate passed only because this repo happens to carry same-named top-level directories. 17% were subtree-relative citations resolvable against a skill or plugin root. Fixing the three dominant causes still left ~4% firing at zero true positives.

A guard that fires on a quarter of writes and is never right trains people to ignore every advisory, including the real ones. Per docs/conventions/hook-precision/README.md's over-fire discipline, it does not ship. The measurement is carried on #1314 for rescoping rather than discarded — the sweep covered one repo and that repo is the pathological case, so the guard may be mis-scoped rather than unsound.

Test plan

skill-reference-verify.test.sh48 cases, covering MUST-fire, MUST-stay-quiet, kill-switch, empty-stdin, missing-prerequisite and telemetry. Stay-quiet coverage includes unowned plugins, manifest-less directories, uppercase non-command tokens, unbackticked prose, non-markdown files, non-Write|Edit tools, files outside CLAUDE_PROJECT_DIR, and CHANGELOGs.

require-jq-notice-isolation.test.sh proves the notice key is unique plugin-wide by glob discovery.

Guard counts reconcile three ways at 11 — README table rows, manifest *_enabled toggles, wired hook scripts. Catalog regenerated via node scripts/generate-catalog.mjs.

Gates: validate-plugins.sh, check-silent-skips.sh, check-changelog-parity.sh --check and --check-bump origin/main, ShellCheck against the repo rcfile — all pass.

Related

Closes #1270, whose scope was amended twice during the build: three guards → two (a version-vs-manifest guard had no buildable trigger, already covered by check-changelog-parity --check-bump), then two → one on the measurement above.

#1314 carries the withdrawn guard's rescope. #1284 is superseded. #853 is the merge whose 0.14.3 bump forced this rebase — the collision #1284's body predicted.

Review status, stated plainly: the contract suite and repo gates are verified, and the noise measurement above is the empirical case for the one guard that ships. A subagent security review was dispatched twice and never reported, so this PR relies on the repo's own security-review, review and cross-vendor checks. A shell hook running on every Write/Edit is a code-execution trust surface — please do not merge on the author's word alone.

kyle-sexton and others added 6 commits July 25, 2026 08:11
Closes #1270

Two advisory PostToolUse guards on Write|Edit, siblings of `cli-flag-verify`:
same defect class — a confident specific that was never checked — with different
oracles.

**asserted-path-verify.** Flags a repo-relative path asserted in markdown, in a
code span or a link target, that does not exist in the working tree.
Deterministic oracle, advisory action: a citation can be deliberately
forward-looking and PostToolUse runs after the write, so it cannot tell.

A first-segment gate is what keeps it quiet: a candidate is adjudicated only when
its leading directory is in the repo, so another project's paths, package paths,
globs, placeholders, and third-party references never fire. Line and range
citation suffixes are stripped, which matters because that is this repo's
dominant citation form.

**skill-reference-verify.** Flags a `/plugin:skill` reference that does not
resolve. Declared **detect-then-judge**, not deterministic: globbing a plugins
tree is exact only where the reference is locally owned, so the finding is a
prompt for a human verdict, never a determination and never an auto-fix. Gated
twice — inert outside a marketplace repo, and within one it adjudicates only a
plugin that repo's own manifests own. Resolution goes through manifest `name` and
skill frontmatter `name`, so a renamed directory still matches.

Both follow the `cli-flag-verify` pattern exactly: diff-scope only, per-guard
kill switch defaulting true, `statusMessage` per handler, telemetry with
repo-relative path redaction, and a co-located test carrying MUST-fire,
MUST-stay-quiet, kill-switch, empty-stdin, missing-prerequisite, and telemetry
cases. `lib/hook-utils.sh` is unchanged. Notice keys are unique plugin-wide, as
`require-jq-notice-isolation.test.sh` now proves across 11 hooks.

**A third guard was scoped and dropped.** A version-versus-manifest guard had no
buildable trigger: its only in-repo shape is already covered by
`check-changelog-parity.sh --check-bump`, and the residual prose surface is
historical, minimum-floor, and planned version claims that a manifest-compare
oracle reads wrong. The enumeration is recorded on #1270 rather than shipped as a
guard that never fires correctly.

**Boy Scout.** README counts were stale before this change: prose said "nine
safety guards" and the table omitted `block-convention-violation` while ten were
wired. Counts are now measured against the manifest toggle set, the missing row
is present, and a new enforceability-tier section states each guard's oracle
class so the detect-then-judge guard cannot be read as deterministic.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI `hygiene` failed with `shellcheck=failure` on #1284. Three findings, all real.

`asserted-path-verify.sh`'s first-segment gate declared `local seg`. `hook-utils.sh`
declares `local -a seg` in its bash parser and ShellCheck resolves sourced files
(`external-sources=true`, `source-path=SCRIPTDIR`), so the string assignment read
as an array-to-string type change — SC2178 plus SC2128 on the expansion. Renamed
to `first_seg`, with the collision recorded at the site so it is not reintroduced.

The same file's candidate-rejection `case` had no default branch, which the repo's
`.shellcheckrc` requires via `enable=add-default-case` (SC2249). Added, stating
that anything unmatched stays a candidate for the gates below.

Both test files tripped SC2016 46 times: their markdown fixtures are single-quoted
because backticks are the code-span delimiters the hooks scan for, and the
placeholder fixtures contain `$`/`{}` on purpose. Nothing there may expand.
Disabled file-wide with the rationale rather than annotating ~45 lines.

`skill-reference-verify.sh` was already clean. Verified after the rename that the
first-segment gate still yields the same findings on the same fixture.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five findings on #1284, all real. Each is pinned by a repro-first case.

**asserted-path-verify — titled and bracketed link destinations were skipped.**
The extractor forbade whitespace inside the parenthesized run, so a valid
`[guide](docs/missing.md "Guide")` was rejected wholesale and its missing target
never checked. It now takes the whole run and keeps the leading destination token,
handling both title-quote styles and the `<dest>` form.

**asserted-path-verify — percent-encoded destinations were tested literally.**
`[g](docs/my%20file.md)` names `docs/my file.md` and reported the encoded string.
Destinations are now decoded before the existence test, but only when every `%`
begins a valid two-hex-digit escape, so a literal `%` in a filename is not
mangled. The traversal guard is re-applied AFTER decoding — `%2e%2e%2f` decodes to
`../` and is only visible at that point.

**asserted-path-verify — link destinations resolved against the wrong base.** A
markdown link resolves relative to the document, not the repo root, so
`docs/guide.md` linking `assets/x.png` means `docs/assets/x.png`. With an
`assets/` at both the root and the document directory, a valid link was reported
missing. Tokens now carry their kind: a link is accepted when either base
resolves, a code-span citation stays repo-root-relative, which is this repo's
convention for citing a file in prose. Both the first-segment gate and the
existence test run per base.

**skill-reference-verify — a bare directory counted as a skill.** A leftover
`skills/<name>/` with no SKILL.md suppressed the advisory for a command that does
not exist. It now requires the SKILL.md.

**skill-reference-verify — a trailing YAML comment defeated name extraction.**
`name: renamed # public command` is valid YAML and a valid rename, but the
end-of-line-anchored parser extracted nothing, so the skill was reported
unresolved. Comments are stripped before matching.

asserted-path-verify 45 -> 56 cases, skill-reference-verify 38 -> 40, both green.
ShellCheck clean against the repo rcfile.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… resolve parent-relative links

Three more review findings on #1284, plus a flaky-harness fix found while
verifying them.

**A renamed skill's directory name was acting as an alias.** A
`skills/legacy-dir/SKILL.md` declaring `name: renamed` made BOTH
`/plugin:renamed` and `/plugin:legacy-dir` resolve. The second is not a command,
and treating it as one suppressed the advisory for exactly the stale pre-rename
references this guard exists to catch. The directory name is now the command
segment only when the file declares no frontmatter name.

**Argument-bearing invocations were never scanned.** The pattern required the
closing backtick immediately after the skill name, so `/plugin:skill --apply` —
the common form in this repo — was skipped entirely. The reference is now the
leading command token of a code span rather than the whole span.

**Parent-relative link destinations were rejected wholesale**, so a legitimate
`[missing](../gone.md)` was never checked. They are now resolved the way a
renderer would, against the document's directory.

Two earlier attempts at that last fix were wrong in the same way, and both would
have passed on Linux while silently doing nothing on Windows. Computing the
document prefix as `${DOC_DIR#$REPO_ROOT}` never matches, because `git rev-parse
--show-toplevel` returns a drive path (`C:/Users/…`) while `dirname "$FILE"`
returns the MSYS form (`/tmp/…`). cygpath does not reconcile them either: the MSYS
`/tmp` mount resolves through an 8.3 short name, so one side reads `KYLESE~1` and
the other `KyleSexton`. The fix is to ask git — `rev-parse --show-prefix` answers
in one universe. Because the result is then repo-relative, ascending past its own
root IS leaving the repo, so lexical_normalize's own failure is the containment
test and no absolute path is involved.

**Harness: pin the stdin read bound.** `hook::buffer_stdin` bounds its fd0 read at
`CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT`, default 2 s, and a timeout makes an
advisory hook exit 0 silently. One invocation costs 10-20 s of wall time on a
loaded Windows/Git Bash box, so the default bound yielded empty output and a FAIL
that read as a detection defect — load-dependent, which is why the same code
passed 40/40 earlier and 44/45 later. Raised to 30 s in both harnesses: these
cases test detection, not the read bound.

asserted-path-verify 56 -> 61 cases, skill-reference-verify 40 -> 45, both green.
ShellCheck clean against the repo rcfile.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A real-world noise measurement across all 975 tracked markdown files found that
56 of this guard's 63 findings (89%) were CHANGELOG rename entries — content the
hook's own advisory calls correct as written.

A CHANGELOG is an append-only historical record: an entry saying a skill was
renamed MUST keep naming the old command, so every rename this repo ever makes
permanently adds an unresolvable reference to one. CHANGELOGs are also touched
every release, which is where the noise is felt most.

Excluding them moves the guard from 3.4% of files firing at 6% precision to 0.51%
firing at 57% precision, keeping all four genuine findings:
`/claude-ops:claude-observability` (the skill is `observability`),
`/skill-quality:skill-quality check` (renamed to `check`, two sites), and
`/planning:architect close-out` (renamed to `plan`).

Deliberately narrow, and basename-scoped rather than substring-matched.
`docs/topics/*/PLAN.md` completion records are arguably the same append-only shape,
but two of the four real findings live there — a broader "historical by contract"
rule would cost half the signal. Recorded at the site so the tension is visible to
whoever revisits it.

skill-reference-verify 45 -> 48 cases, green. ShellCheck clean.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-verify alone

Closes #1270

A full-corpus noise measurement withdrew one of the two guards. Recorded here
because the number, not a judgment call, is the reason.

**asserted-path-verify: withdrawn.** Swept across all 975 tracked markdown files
it fired on 231 of them — 23.7%, roughly one in four writes — producing 389
findings with **zero** true positives. The oracle never misfired; every finding
was a scoping problem. 72% were consumer-project config paths (`.claude/**` and
similar) that a doc describes for a CONSUMING repo and that correctly do not exist
in a marketplace, and its first-segment gate passed only because this repo happens
to carry same-named top-level directories. 17% were subtree-relative citations
resolvable against a skill or plugin root. Fixing the three dominant causes still
left ~4% firing at zero true positives.

A guard that fires on a quarter of writes and is never right trains people to
ignore every advisory, including the real ones its sibling produces. Per
`docs/conventions/hook-precision/README.md`'s over-fire discipline it does not
ship. The measurement is carried on #1314 for rescoping — the sweep covered one
repo and that repo is the pathological case, so the guard may be mis-scoped rather
than unsound.

**skill-reference-verify: ships.** 4 true positives on the same corpus, each
verified individually against the skill tree. After excluding CHANGELOGs as
append-only historical records it fires on 0.51% of files at 57% precision.

Guard counts reconcile three ways at 11: README table rows, manifest `*_enabled`
toggles, and wired hook scripts. Catalog regenerated. `check-changelog-parity
--check-bump`, `check-silent-skips` and ShellCheck all pass.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cda86a36fa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/guardrails/hooks/skill-reference-verify.sh
Review finding on #1319. An Edit may replace an arbitrary substring, so swapping
`setup` for `ghost` inside an existing `/alpha:setup` leaves `/alpha:ghost` on
disk while the hunk is the bare word `ghost` — carrying no command for emit_refs
to find, so a newly-broken reference was silently missed.

Mirrors the sibling `cli-flag-verify`'s reconstruct_partial_edit, which already
solves this exact shape: the edit is applied by PostToolUse time, so pull from
disk only the lines carrying one of the hunk's word tokens, scan those, and keep
only references whose plugin or skill segment appears in the hunk. That token
filter preserves the diff-scope contract — a pre-existing unrelated reference
sharing one of those lines never fires. The anchor is the token rather than the
line because a bare-word hunk carries no positional information.

Reconstruction runs only when the hunk itself yielded no reference; a
full-command hunk already scans correctly and must not be re-scanned from disk.

Three cases pin it: bare-word hunk recovers the containing reference, an
untouched broken neighbour stays unreported, and a full-command hunk takes the
direct path with no disk leakage.

ShellCheck clean against the repo rcfile. The contract suite could not be re-run
to completion locally — a single hook invocation costs 10-20 s on this loaded
Windows/Git Bash box and the suite exceeded the available window — so CI's Linux
run is the authority on the full suite here, and it gates the merge.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 33s —— View job


PR Review: feat(guardrails): add the skill-reference-verify claim guard

  • Gather context (reading PR diff and changed files)
  • Review skill-reference-verify.sh for correctness and security
  • Review test coverage (skill-reference-verify.test.sh)
  • Review plugin manifest and hook registration
  • Review README/docs updates
  • Verify partial-Edit reconstruction fix (Codex finding, commit 639bc4a)
  • Final summary

Overall

The guard is well-built: the two-tier gate (plugins-root → plugin-scope) correctly limits the oracle to contexts where it has authority, the CHANGELOG carve-out is justified and measured, the advisory-only posture is appropriate, and the reconstruct_partial_edit implementation in commit 639bc4a correctly addresses the Codex finding. One documentation gap and one minor performance note below.


Finding 1 — Kill switch table is missing two rows (documentation)

plugins/guardrails/README.md, lines 95–106

The "Per-hook kill switches" table has 9 rows but there are 11 guards. Both skill-reference-verify (skill_reference_verify_enabled) and block-convention-violation (block_convention_gate_enabled) are absent from the table. Both options exist correctly in plugin.json (lines 59–76), so the toggle works — it just isn't documented here.

The PR description says "missing row added" for block-convention-violation, and indeed it was added to the main guard table (line 19 of the README). But neither guard made it into the kill switch table.

Expected additions:

| block-convention-violation | `block_convention_gate_enabled` |
| skill-reference-verify     | `skill_reference_verify_enabled` |

Fix this →


Finding 2 — reconstruct_partial_edit unbounded accumulation before head -40 (minor / performance)

plugins/guardrails/hooks/skill-reference-verify.sh, lines 169–176

for tok in "${toks[@]}"; do
  lines=$(grep -F -- "$tok" "$FILE" 2>/dev/null)
  [[ -n "$lines" ]] && ctx+="$lines"$'\n'
done
SCAN_CONTENT=$(printf '%s' "$ctx" | grep -vE '^[[:space:]]*$' | head -40)

ctx accumulates unboundedly before the head -40 at line 176 applies. For a hunk with many common lowercase tokens (e.g., a prose paragraph where emit_refs found nothing), each token produces one grep -F against the full file, and matches are appended to ctx without bound. Practical impact is low — reconstruction only runs when emit_refs yielded nothing, meaning the hunk lacks backtick command spans and is typically short. The 30-second hook timeout provides a hard ceiling. But a very large hunk on a large file could cause a sluggish hook run.

A simple guard like ((${#toks[@]} > 20)) && return 0 before the loop, or piping ctx through head -40 inside the loop, would eliminate the worst case. Not a blocker.


Correctness — verified correct

Gate chain: CHANGELOG → markdown-only → diff-scope → plugins-root → plugin-scope. Each gate exits 0 silently (advisory hook). Logic is sound and the measured precision figures in the PR body (CHANGELOG exclusion moving from 6% → 57% precision) are consistent with the implementation.

Manifest resolution: PLUGIN_DIR is keyed by manifest name, not directory name. skill_resolves requires a SKILL.md to exist (bare directory does not count) and resolves skill identity through frontmatter name first. A renamed directory's old name is correctly NOT an alias — the guard's primary purpose is to catch exactly those stale references.

Frontmatter extraction: The three-pipe sed chain handles unquoted, single-quoted, double-quoted, and YAML-comment-suffixed name values. Tested with 'commented-name # public command' and '"quoted-name"'.

Partial-Edit reconstruction (commit 639bc4a): The Codex finding is correctly handled. Reconstruction runs only when emit_refs found nothing in the hunk (line 196), ensuring a full-command hunk takes the normal diff-scope path. Token-anchored filtering (line 181) prevents untouched neighbouring references from leaking through. Test cases at lines 249–273 explicitly verify both the positive case and the no-leakage invariant.

emit_refs grammar: The sed -nE 's|^(/[a-z][a-z0-9-]*:[a-z][a-z0-9-]*)([[:space:]].*)?$|\1|p' pattern correctly:

  • anchors to the start of the span (requiring lowercase-first plugin and skill tokens — https://… and 12:30 are inert)
  • strips trailing arguments from invocations like `/alpha:audit --dry-run`
  • ignores uppercase references like `/Alpha:Setup`

Security: No injection vectors. Tokens extracted from SCAN_CONTENT are filtered to [a-z][a-z0-9-]* before being passed to grep -F --. jq handles all JSON output construction. Advisory exit 0 in all paths. The code-execution trust surface is the acknowledged hook boundary, not a defect introduced here.


Test coverage — thorough

48 cases covering MUST-fire (7), MUST-stay-quiet (14), reconstruction (5), kill switch (2), empty stdin (2), missing-prerequisite assertions (3), and telemetry (5). The reconstruction diff-scope invariant is verified by two dedicated cases (lines 259–265 and 267–273). No gaps found.


Hook registration — correct

hooks.json wires the guard under PostToolUse · Write|Edit with a 30-second timeout and statusMessage. Consistent with cli-flag-verify sibling placement and timeout budget.

Plugin manifest — correct

plugin.json version bump to 0.15.0, skill_reference_verify_enabled userConfig boolean added with correct defaults and description. Description prose updated.


Verdict: Ready to merge pending the kill switch table fix (Finding 1). Finding 2 is a non-blocking note.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 20s —— View job


Security Review — PR #1319

  • Read PR diff and changed files
  • Analyze shell hook for injection surfaces
  • Analyze test harness for security issues
  • Analyze manifest/config changes
  • Report findings

No critical or important security vulnerabilities found.

Reviewed: skill-reference-verify.sh, skill-reference-verify.test.sh, hooks.json, and plugin.json. hook-utils.sh is unchanged and out of scope.


What was checked

Injection surfaces in skill-reference-verify.sh

SCAN_CONTENT (the new_string / content field from the tool input) flows exclusively through data pipes — printf '%s' "$SCAN_CONTENT" | grep -oE ... — and is never interpolated into a shell command. Token extraction is doubly restricted: grep -oE '[^]+'seds|^(/[a-z][a-z0-9-]:[a-z][a-z0-9-])...$|\1|p'yields only lowercase-alphanumeric-dash strings. Those tokens are used as Bash associative-array keys and as grep-F` fixed-string patterns — neither path evaluates them as shell code.

File-path handling

FILE is validated by hook::read_file_path (existence check + project-membership check with symlink resolution via realpath/readlink -f) before it reaches the hook body. Its use in reconstruct_partial_edit is grep -F -- "$tok" "$FILE" where the -- prevents option injection even for paths that start with a dash. A JSON-encoded newline in file_path decodes to a real newline, which [[ -f "$file" ]] rejects. Safe.

Plugin manifest name injection

pname=$(jq -r '.name // empty' "$m" 2>/dev/null | tr -d '\r') is used as a Bash associative-array key only (PLUGIN_DIR["$pname"]="$pdir"). Bash does not evaluate array keys. A crafted manifest name cannot escape to shell execution.

Path traversal via skill name

skill is derived from the right side of a [a-z][a-z0-9-]* match, so it can only be a safe lowercase-alphanumeric-dash token. "$pdir/skills/$skill/SKILL.md" cannot contain ...

JSON construction

Telemetry JSON is built entirely through jq -n --arg ... --argjson ...; the advisory context buffer goes through hook::json_escape before emission. No string-interpolated JSON.

Advisory exit semantics

Every code path exits 0. The hook cannot block a tool call — it only produces additionalContext. The kill switch (hook::check_enabled "SKILL_REFERENCE_VERIFY") is the first statement after source.


One suggestion

SUGGESTION — confidence: medium

skill-reference-verify.sh lines 169–176

In reconstruct_partial_edit, ctx accumulates raw grep -F output for every distinct token before head -40 truncates the final result. A SCAN_CONTENT with many distinct short tokens (e.g. a paragraph with common words a, b, re, it) against a large on-disk file could grow the in-memory ctx variable substantially before truncation is applied. This is not exploitable as code execution — it is a resource edge case — but the 30-second hook timeout is the only bound. A per-token head -N on the grep output would contain the worst case. Not a blocker; the real-world exposure is low because the hook fires on markdown writes (typically small documents) and only on Edit (where the hunk is a partial replacement).


Summary

The new hook is well-scoped and follows the same defensive patterns as its sibling cli-flag-verify. No injection, path-traversal, privilege-escalation, or access-control vulnerabilities identified. The one performance edge case noted above is not exploitable and has a natural bound from the hook timeout.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 639bc4a4c9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/guardrails/hooks/skill-reference-verify.sh Outdated
Comment thread plugins/guardrails/hooks/skill-reference-verify.sh Outdated
…onstruction

Two review findings on #1319, both in the reconstruction added one commit
earlier, plus a diff-scope regression the first fix introduced.

**Substring edits were missed.** The filter required the hunk token to equal a
whole plugin or skill segment, but an Edit replaces arbitrary substrings: `up` ->
`host` turns `/alpha:setup` into `/alpha:sethost`, where the token `host` locates
the line but is only part of the segment. Now matches on substring.

**Mixed hunks were missed.** Reconstruction ran only when the direct scan found
nothing, so a hunk carrying both a complete reference and a substring change to
another reported only the complete one. Reconstruction now runs on every Edit;
CHECKED already dedupes a reference reachable both ways, and a case pins that it
is counted once.

**Regression from that second fix, caught by an existing case.** Running
reconstruction unconditionally meant a hunk containing a complete reference
contributed its own plugin name as an anchor token — `alpha` then substring-matched
every reference to that plugin, including untouched ones on neighbouring lines.
Complete references are now stripped before anchor extraction, since the direct
scan already owns them.

That still leaked: stripping the reference out of "Run `/alpha:x` now." leaves the
fragment `un`, which substring-matches `untouched-ghost`. Anchor tokens now require
4+ characters — below that a token carries no locating power, only collisions.

Verified directly on all three shapes as well as through the suite: a full-command
hunk reports only its own reference, a bare-word hunk recovers the containing one,
and a mixed hunk reports both — none leaking the untouched neighbour.

skill-reference-verify 48 -> 59 cases, green. ShellCheck clean.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 51s —— View job


PR Review: feat(guardrails): add the skill-reference-verify claim guard

  • Read current state of skill-reference-verify.sh (HEAD: f0c41f2)
  • Verify Codex finding 1 fix: substring matching in partial-edit reconstruction
  • Verify Codex finding 2 fix: mixed-hunk reconstruction (REFS nonempty + partial edit)
  • Check previous Finding 1: kill switch table completeness (still unresolved — fix below)
  • Review test coverage for new cases
  • Final summary

Verdict on f0c41f2 (the latest commit)

Both Codex findings are correctly addressed. The previous Claude review's blocker (kill switch table) is still open and needs one line per missing guard.


Codex Finding 1 — Substring matching: Fixed

skill-reference-verify.sh:195–203

for seg in "${toks[@]}"; do
  if [[ "$plug" == *"$seg"* || "$skl" == *"$seg"* ]]; then

The old equality guard ("$skl" == "$seg") meant a token host would reject sethost. Now uses glob substring (*"$seg"*). Token host matches segment sethost; token ghost-mixed matches segment ghost-mixed. Correct.

Covered by test case at line 274: 'host' Edit on a file containing `/alpha:sethost` → reports UNRESOLVED_SKILL: /alpha:sethost.


Codex Finding 2 — Mixed-hunk reconstruction: Fixed

skill-reference-verify.sh:211–219

mapfile -t REFS < <(emit_refs)
if [[ "$TOOL" == "Edit" ]]; then
  mapfile -t -O "${#REFS[@]}" REFS < <(reconstruct_partial_edit)
fi

Reconstruction now runs unconditionally on every Edit and appends to REFS. A hunk of 'ghost-mixed and /alpha:ghost-direct' yields both /alpha:ghost-direct (direct) and /alpha:ghost-mixed (reconstructed). The existing CHECKED associative array deduplicates any reference reachable both ways.

The regression introduced by this fix (running reconstruction unconditionally lets the hunk's plugin name become an anchor) is also resolved: complete references are stripped from SCAN_CONTENT before anchor extraction (residue at line 172), and a minimum 4-character token length filters out the resulting fragments (line 179).

Covered by tests at lines 283–298: the mixed-hunk case, and the deduplicated double-path case (1 finding counted once).


Previous Finding — Kill switch table still missing two rows

plugins/guardrails/README.md:95–106

The "Per-hook kill switches" table has 9 rows. block-convention-violation and skill-reference-verify are absent, even though both options exist in plugin.json (lines 59–76) and the main guard table lists all 11. A consumer reading the README to find how to disable one of these two guards won't find the option name.

Expected additions (maintaining the same order as plugin.json):

| block-convention-violation | `block_convention_gate_enabled` |

(after the block-noncanonical-commit row)

| skill-reference-verify     | `skill_reference_verify_enabled` |

(after the cli-flag-verify row)

Fix this →


Everything else — verified correct

The implementation analysis from the prior review stands unchanged. The gate chain, diff-scope contract, manifest resolution, frontmatter extraction, emit_refs grammar, security posture, and advisory-only exit semantics are all sound. The test count is now 59 cases (up from 48), all new cases covering the two Codex shapes and the dedup invariant.


Verdict: One documentation gap remains (kill switch table). The two Codex correctness findings are resolved and verified. Ready to merge once those two table rows are added.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 6s —— View job


Security Review — PR #1319

  • Gather context (PR diff and changed files)
  • Analyze skill-reference-verify.sh for injection surfaces
  • Analyze skill-reference-verify.test.sh for test harness issues
  • Analyze hooks.json and plugin.json for config security
  • Review README and CHANGELOG for sensitive disclosures
  • Report findings

No security vulnerabilities found.

Reviewed: skill-reference-verify.sh, skill-reference-verify.test.sh, hooks.json, plugin.json. hook-utils.sh is unchanged and out of scope.


What was checked

SCAN_CONTENT — the primary untrusted-input path

SCAN_CONTENT is the new_string / content field extracted from the hook's stdin JSON. It flows exclusively through data pipes (printf '%s' "$SCAN_CONTENT" | grep -oE ...), never interpolated into a shell command. In reconstruct_partial_edit, it additionally passes through sed -E via a pipe. At no point is it word-split or evaluated by the shell.

The token-extraction chain in emit_refs imposes a doubly-anchored character filter: grep -oE '[^]+'extracts only backtick spans, thensed -nE 's|^(/[a-z][a-z0-9-]:[a-z][a-z0-9-])...$|\1|p'anchors on start-of-string with lowercase alpha-only first char and[a-z0-9-]body — so the$refvalues that enter the resolution loop are provably[/][a-z][a-z0-9-]:[a-z][a-z0-9-]. No injection, path-traversal, or globbing surface from SCAN_CONTENT`.

reconstruct_partial_edit — disk read path

Reconstruction tokens in $toks are extracted from the hunk residue via grep -oE '[a-z][a-z0-9-]{3,}' — minimum four characters, lowercase alphanumeric plus dash only. These are passed to grep -F -- "$tok" "$FILE", where -- prevents option injection even for hypothetical adversarial paths starting with a dash. The accumulated $ctx from disk is assigned back to SCAN_CONTENT and fed to emit_refs via printf '%s' | grep | sed — same pipeline, same character-class filtering on output. No injection surface.

File path handling — $FILE

$FILE comes from hook::read_file_path, which validates: (1) extracted via jq -r (no eval), (2) [[ -f "$file" ]] must hold, (3) project-membership check with realpath/readlink -f to resolve symlinks before prefix comparison. Used subsequently only as a grep -F -- target (double-quoted, -- separator), a [[ -f ... ]] test, and a directory-of argument. Safe.

Plugin manifest name — $pname

pname=$(jq -r '.name // empty' "$m" 2>/dev/null | tr -d '\r'). Used only as an associative array key (PLUGIN_DIR["$pname"]="$pdir"). Bash does not evaluate associative array keys, so a crafted manifest name containing shell metacharacters cannot escape to command execution. A crafted name never reaches a lookup because $plugin (the lookup key) is constrained to [a-z][a-z0-9-]* by emit_refs — no crafted key would match.

$pdir — plugin directory from glob

pdir="${m%/.claude-plugin/plugin.json}" strips a literal suffix from a glob-expanded path. Used in skill_resolves as "$pdir/skills/$skill/SKILL.md" and "$pdir"/skills/*/SKILL.md. $skill is [a-z][a-z0-9-]* — no .., no metacharacters; no path traversal. The directory glob in skill_resolves is double-quoted on the prefix, and the glob segment */SKILL.md is fixed.

Frontmatter extraction

skill_frontmatter_name reads only the first 40 lines and extracts [A-Za-z0-9_-]+ via a sed pattern. The file path argument is always an internally-derived path from glob expansion, not from SCAN_CONTENT. No injection; no exposure of arbitrary file content.

JSON output

hook::ctx_append accumulates advisory text, then hook::ctx_flush calls hook::emit_additional_context which embeds the entire buffer via jq -n --arg ctx "$context". All UNRESOLVED refs are [a-z][a-z0-9-]*:[a-z][a-z0-9-]* — safe to embed verbatim. Telemetry uses jq -n --arg file "$file_rel" --argjson findings "$findings_json" throughout. No hand-built JSON with interpolated values.

Advisory exit semantics

Every code path exits 0. The hook cannot block a tool call — it only produces additionalContext. No privilege, no blocking, no write side-effect.

Test harness

The test harness creates a synthetic marketplace under $TEST_TMPDIR and removes it on EXIT. Manifest JSON is emitted via jq -n --arg n "$name" (safe). The CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=30 environment variable is a legitimate test configuration. No process substitution or eval over untrusted data.

hooks.json and plugin.json

The new PostToolUse · Write|Edit entry follows the same shape as the existing cli-flag-verify sibling: "${CLAUDE_PLUGIN_ROOT}"/hooks/skill-reference-verify.sh, 30 s timeout, statusMessage. CLAUDE_PLUGIN_ROOT is an installer-set variable; it is not attacker-controlled at hook wiring time. The new skill_reference_verify_enabled boolean userConfig field has "default": true and no execution surface.

HOOK_TELEMETRY_SINK execution

hook::emit_telemetry executes "$sink" from $HOOK_TELEMETRY_SINK in a background subshell. This is consumer-controlled and is existing, documented behavior in hook-utils.sh (unchanged). It is not introduced or widened by this PR.


Summary

The new hook follows the same defensive posture as its cli-flag-verify sibling: all untrusted data flows through pipes rather than shell interpolation, character-class filtering constrains tokens to the safe [a-z][a-z0-9-]* alphabet before any further use, file-path access is gated through the existing hook::read_file_path validator with symlink resolution, and all JSON output is constructed through jq --arg/--argjson. No injection, path-traversal, privilege-escalation, or access-control vulnerabilities identified.

@kyle-sexton
kyle-sexton merged commit 0b0c497 into main Jul 25, 2026
26 checks passed
@kyle-sexton
kyle-sexton deleted the feat/1270-guardrails-skill-reference-verify branch July 25, 2026 16:24

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f0c41f2b09

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/guardrails/hooks/skill-reference-verify.sh
Comment thread plugins/guardrails/hooks/skill-reference-verify.sh
Comment thread plugins/guardrails/hooks/skill-reference-verify.sh
kyle-sexton added a commit that referenced this pull request Jul 25, 2026
…d precision (#1357)

Closes #1352

## Summary

Adds
`docs/adr/0003-verification-guards-earn-default-on-by-measured-precision.md`,
recording the reusable lesson from the #1270 guard program. Docs-only.

Three guards were scoped on sound oracles. One shipped (#1319, 0.51%
firing at 57% precision). Two were withdrawn:

- **version-vs-manifest** � dropped before implementation. Its only
in-repo surface is already covered by `scripts/check-changelog-parity.sh
--check-bump`, and the residual prose surface is historical,
minimum-floor, and planned version claims a manifest compare reads
*wrong*.
- **asserted-path** � built to 61 contract cases, fully reviewed, then
withdrawn on measurement: **23.7% of all 975 tracked markdown files
fired, 389 findings, zero true positives** (#1314 carries the sweep).

The generalizable finding is that **a sound oracle is necessary but not
sufficient**. The path guard's oracle was exact � no candidate resolved
at the repo root, every finding was a scoping failure. A passing
contract suite proves the oracle; only a corpus sweep proves the
scoping.

Four rules recorded: measure against the real corpus before shipping
default-on; report the number in the PR; treat zero true positives as
disqualifying however sound the oracle; distinguish wrong-oracle from
wrong-scope, because that decides deletion versus rescoping.

It also records the cost honestly � measuring immediately after the
guard first worked, rather than after polish and a review round, would
have saved that round. That is the actionable part for whoever builds
the next one.

## Test plan

- `markdownlint-cli2` on the new file � 0 errors
- Every cross-reference verified to resolve: `docs/adr/0002-�md`,
`docs/conventions/hook-precision/README.md`,
`docs/PLUGIN-PHILOSOPHY.md`, `scripts/check-changelog-parity.sh`
- House format matches 0001 and 0002 (`# Title`, Status/Date bullets,
Context, Decision, Consequences)
- Docs-only; no plugin version or CHANGELOG bump applies

## Related

Extends [ADR
0002](docs/adr/0002-default-on-ai-review-advisory-with-earned-promotion.md)
one step earlier in the lifecycle � 0002 governs promoting an advisory
gate to blocking on demonstrated precision, this governs whether it
ships default-on at all, on the same evidentiary basis. Defers to
`docs/conventions/hook-precision/README.md` for post-ship over-fire
discipline rather than restating it.

#1270 (scoping, amended twice), #1319 (shipped guard, merged), #1284
(closed), #1314 (withdrawn guard and its measurement).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 9, 2026
… rescope skill-reference-verify reconstruction (#2100)

No linked issue

## Summary

Discharges six stranded bot review threads against the `guardrails`
plugin, all filed on already-merged
PRs. One is a defeated security guard, the rest are
`skill-reference-verify` correctness and
timeout-budget defects. Four further threads raised on this PR are also
addressed below.

## Fix

**`block-dangerous-git` — the hash-width probe ignored a wrapper's chdir
(thread on #1275).** A
`--force-with-lease` expectation is judged immutable only when it is an
object id of the hash width of
the repository the push will run in. `collect_git_locating_opts` reads
only the slice between the git
word and the subcommand — as it must, since that walk cannot know which
of `env`'s or `sudo`'s options
take a value — so a wrapper's relocation was invisible to it. `env -C
<sha256-repo> git push
--force-with-lease=main:<40-hex>` therefore probed the invoking SHA-1
directory, read the 40-hex word as
an object id, and allowed the push; where git actually runs that word is
an ordinary movable ref name,
which is exactly the hole `--force-with-lease` exists to close.
`hook::git_resolve_index` already records
the relocation in `HOOK_GIT_RESOLVED_WRAPPER_DIRS` — the only parser
that tells a real `env -C <dir>`
from the `-C` in `env -u -C git`, which moves nothing — and the probe
now replays those directories as
leading `-C` words so they compose ahead of git's own under git's rules
rather than being modelled. This
mirrors the migration `848df9e9` (#1785) made in
`block-noncanonical-commit`.

**`skill-reference-verify` — partial-Edit reconstruction (threads on
#1319 and #1466, one span).** The
old shape located the hunk by line and then filtered the whole physical
line by word token. Three
defects, all that filter: an untouched broken reference sharing a line
with the hunk was readmitted by
any word it happened to share; an Edit replacing fewer than four
lowercase characters produced no token
at all, so every short-substring edit went uncovered; and locating spent
two full-file `grep` processes
per hunk line, which a large Edit turned into the hook's 30s timeout.
Reconstruction now keeps only the
inline-code spans whose extent OVERLAPS the located anchor. The
occurrence-uniqueness gate is unchanged.

**`skill-reference-verify` — the cost model behind the timeout fix was
wrong, twice.** Removing the
subprocesses left a per-line RESCAN, so the hunk is now located WHOLE —
one scan for the whole edit,
producing the same span set, since a line anchor's extent is the text
the edit wrote on that line and the
whole hunk's extent is the union of exactly those. Measuring the scan
itself then contradicted the bound
placed on it: one scan is QUADRATIC in file size, not linear, because
bash's `%%` pattern strip walks the
string rather than indexing it. The previous 4 MiB file cap therefore
allowed a single scan of roughly
eighteen minutes — the worst case had been moved off the per-line loop,
not bounded. Both caps are now
set from the measured curve.

**`skill-reference-verify` — manifest-declared skill paths (thread on
#1319).** Resolution hard-coded
`plugins/<plugin>/skills/`. Per the [Plugins
reference](https://code.claude.com/docs/en/plugins-reference) (fetched
2026-08-09), `skills` is a
`string|array` whose paths ADD to the default `skills/` scan, a path may
point straight at a directory
holding `SKILL.md`, and a root `SKILL.md` with no `skills/` and no
`skills` key auto-loads as a
single-skill plugin. All three now resolve. The documented
marketplace-root exception is deliberately not
modelled and is recorded as such at the call site — leaving it out only
ever suppresses an advisory,
never invents one. The advisory's own text carried the same hard-coded
assumption and now lists the
directories the search actually covered.

## Verification

**Security defect, reproduced before and after** against the same
fixture tree (SHA-1 and SHA-256 repos),
hook cwd = the SHA-1 repo unless noted. `origin/main`'s
`block-dangerous-git.sh` vs this branch's:

| case | pre-fix | post-fix | want |
| :-- | :-- | :-- | :-- |
| `env -C <sha256> git push --force-with-lease=main:<40-hex>` |
**ALLOWED** | BLOCKED | BLOCKED |
| `env -C <sha256> git push --force-with-lease=main:<64-hex>` |
**BLOCKED** | ALLOWED | ALLOWED |
| `env -C <sha1> git push …:<64-hex>` (cwd = sha256) | **ALLOWED** |
BLOCKED | BLOCKED |
| `env --chdir=<sha256> git push …:<40-hex>` | **ALLOWED** | BLOCKED |
BLOCKED |
| `sudo -D <sha256> git push …:<40-hex>` | **ALLOWED** | BLOCKED |
BLOCKED |
| `sudo --chdir=<sha256> git push …:<40-hex>` | **ALLOWED** | BLOCKED |
BLOCKED |
| `bash -c 'env -C <sha256> git push …:<40-hex>'` | **ALLOWED** |
BLOCKED | BLOCKED |
| `env -C <parent> git -C repo-sha256 …:<64-hex>` | **BLOCKED** |
ALLOWED | ALLOWED |
| `env -u -C git push …:<40-hex>` (`-C` is `-u`'s operand) | ALLOWED |
ALLOWED | ALLOWED |

The last row is the control that keeps the fix honest: an option that
only looks like a chdir still moves
nothing, so the guard did not simply get stricter. Two rows flip BLOCKED
→ ALLOWED, which a fail-closed
regression could not produce.

**Scan cost, measured rather than assumed.** One `anchor_offsets` scan,
isolated, Windows/Git Bash,
quiescent, best of three:

| file size | 32 KiB | 64 KiB | 96 KiB | 128 KiB | 192 KiB | 256 KiB |
| :-- | :-- | :-- | :-- | :-- | :-- | :-- |
| one scan | 0.07s | 0.24s | 0.53s | 1.07s | 2.18s | 3.94s |

That is ~0.065s × (KiB/32)² — quadratic. Those figures are a FLOOR, not
the cost: they time an anchor
matching near the end, so one strip walks the file and the second is
free, while a no-match strip walks
it twice (2.31s at 200 KiB) and the whole-hunk probe pays a scan before
the fallback runs at all. So the
two bounds are calibrated end to end, not from the table:
`RECONSTRUCT_MAX_CHARS` is 128 KiB, and the
fallback's anchor cap is `RECONSTRUCT_FALLBACK_SCAN_BUDGET / (KiB)²` —
58 anchors at 32 KiB, 14 at 64,
3 at 128. Above the file cap the direct hunk scan is untouched, so a
complete reference is still
reported and only partial-edit recovery stops.

**End-to-end, the shape the defect actually lived in** (a hunk of
distinct span-free lines, so the span
cap never binds and every anchor would rescan). `origin/main` vs this
branch, same fixture:

| hunk | file size | `origin/main` | this branch |
| :-- | :-- | :-- | :-- |
| 1 line | <1 KiB | 10.5s | 0.8s |
| 100 lines | ~4 KiB | 135.8s | — |
| 500 lines | ~19 KiB | 778.8s | — |
| 1000 lines | ~38 KiB | (not run) | 1.0s |

Baseline per-invocation overhead on this host is 0.8–1s quiescent, so
the branch numbers are the scan,
not the harness. An earlier revision of this PR reported far flatter
pre-fix numbers; that benchmark used
hunk lines carrying inline code spans, which trip
`RECONSTRUCT_MAX_SPANS` and stop the loop after 40
anchors — it measured the capped path, not the defect. The table above
is the corrected measurement. A
4000-line row from that revision is dropped rather than restated: at
~156 KiB it now exceeds the file
cap, so it would time the skip path, not reconstruction.

**Why the scale test asserts behavior instead of wall time.** The new
large-file fallback case pins the
cap from both sides — a reference inside the anchor cap is still
reported, one past it is not — rather
than timing it. On this host the same fixture read 21s loaded and a
smaller one 23s, against an isolated
scan of ~1s at that size; a timing assertion that noisy fails on load
and passes on a regression that
happens to run on a quiet box. The scan cost is measured directly
instead, in the constants' docblock.

**Gates run from the worktree root, all green:** `shellcheck -x` on the
four changed shell files;
`markdownlint-cli2` on the changelog; `check-changelog-parity.sh
--check`, `--check-bump origin/main`,
`--check-order`; `check-shell-portability.sh origin/main`;
`sync-hook-utils.sh --check` and
`--check-bump`; `check-cross-plugin-source-drift.sh --check`;
`validate-plugins.sh`;
`check-changed-skills.sh origin/main`. Contract suites:
`block-dangerous-git.test.sh` 341 pass / 0 fail;
`skill-reference-verify.test.sh` 96 pass / 0 fail (see also the CI
`plugin-gate` job, which runs both on
Linux).

**Four threads raised on this PR.** `Xp-3r` (quadratic rescan) and
`XqJ0e` (nothing bounds the anchor
count) are both discharged by the whole-hunk locate plus
`RECONSTRUCT_FALLBACK_SCAN_BUDGET`; the suite
fixture the first was measured against at 35s now runs in 0s. `XqJ1e`
(the advisory hard-coded
`plugins/<x>/skills/`) is fixed and asserted on its full rendering, not
a prefix. `XrcOx` (no test
combines a large file with the fallback path) is the case described
above.

**Not fixed here, flagged instead:** `block-convention-violation.sh`'s
`effective_dir` (`:186-201`) scans
*every* word for `-C`, with no `[git, subcommand)` slice and no wrapper
replay — the pre-`848df9e9` shape,
failing the opposite direction from the one fixed here. It accepts a
`-C` that moves nothing
(`env -u -C git`) and a `-C` after the subcommand (`git commit -C HEAD`,
reuse-message), so
`effective_dir` can name the wrong repository. Different defect class,
needs its own tests; not widened
into this PR.

## Related

- Review thread on #1275 — `block-dangerous-git` wrapper-chdir hash
probe (the security defect)
- Review threads on #1319 and #1466 — `skill-reference-verify`
reconstruction and manifest skill paths
- #1785 (`848df9e9`) — the wrapper-chdir parser in the shared lib this
fix consumes
- #1466 (`527dcd85`) — already landed the hunk-line anchoring; a further
thread on it needed no new fix
- #1432 (`a2d98f8a`) — the sibling `stale-path-verify` fix the
reconstruction docblock cites

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…ction to the lines the Edit wrote (#2153)

## Disposition: fixed, not closed won't-fix

The issue offered three dispositions and flagged the defect as possibly
payload-inherent. It is not,
and that is the whole reason this ships as a fix.

Partial-edit reconstruction separates an occurrence the call wrote from
a coincidental one by
requiring the anchor to occur exactly once. `replace_all` is precisely
where that rule is suspended,
on the reasoning that there every occurrence *is* the edit's own
footprint. It is not: after `ghost`
replaces `setup` everywhere, the `ghost` inside a pre-existing
`ghost-old` matches the anchor too.

The issue's premise — "nothing in the payload distinguishes a `ghost`
this call wrote from the
`ghost` inside a pre-existing `ghost-old`" — **holds for `tool_input`
and fails for
`tool_response`.** The Edit tool's structured output carries
`structuredPatch`, which marks the
lines the call actually wrote with a leading `+`. Under `replace_all`
only, an occurrence is now
kept just when its physical line is one the patch reports as written.
The suspended uniqueness rule
gets an external witness instead of nothing.

Both halves were confirmed against pages **fetched 2026-08-10**, per the
repo's fresh-docs mandate:

- `PostToolUse` input "includes both `tool_input`, the arguments sent to
the tool, and
`tool_response`, the result it returned. The exact schema for both
depends on the tool", and that
  field is "the tool's structured `Output` object" —
  <https://code.claude.com/docs/en/hooks>, "PostToolUse input".
- `Output` for Edit is `FileEditOutput`, whose `structuredPatch` is
  `Array<{oldStart, oldLines, newStart, newLines, lines: string[]}>` —
  <https://code.claude.com/docs/en/agent-sdk/typescript>, "Edit".

## Why line TEXT and not line numbers

Numbers are wrong the moment another PostToolUse hook reformats the file
between the write and this
read — the exact case the reconstruction fallback already exists for.
And mapping a character offset
back to a line number costs a whole-prefix scan per occurrence, which
would reintroduce the
quadratic term 0.21.0 spent a release removing. Text matching is a hash
lookup and survives
renumbering. Its one imprecision is conservative: an untouched line
whose text duplicates an edited
one is kept, so the filter can only ever drop findings the payload
itself calls untouched.

## Deliberately inert outside its one case

- A multi-line `new_string` is **not** filtered: its anchor extent spans
several lines, matches no
single patch line, and filtering would erase every finding rather than
narrow them.
- A payload with no `tool_response`, and every non-`replace_all` Edit,
behaves exactly as before —
  the filter is inert by construction, not by a flag.

## Verification

The table below was produced by running the **shipped test file**
against the **unmodified
`origin/main` hook** in a pristine worktree — not against a separate
throwaway fixture. That
distinction is load-bearing here; see the traps below.

| assertion | pre | post | discriminates? |
|---|---|---|---|
| the WRITTEN reference is still reported | pass | pass | no — must not
regress |
| **the UNTOUCHED reference is not reported** | **FAIL** | **pass** |
**yes** |
| genuine multi-site: both refs survive the filter | pass | pass | no —
proves no findings lost |
| liveness: empty target yields nothing at all | pass | pass | no — see
below |

**Liveness**, using the technique the issue names: the identical payload
is run against a
truncated, empty target file. The hook is silent there, so every finding
in the real fixture
demonstrably came from **reading the file** rather than from the payload
text. A filter that merely
echoed `new_string` back would have spoken in both.

Suite: **106 pass, 0 fail**.

## Two traps hit and fixed while building this

Both are recorded because both produced a green assertion for the wrong
reason, which is the failure
mode this sweep exists to stop.

1. The first draft of the payload builder passed the diff lines as jq
`--args` positionals. Every
line starts with `-`, `+` or a space; jq parsed the leading `-` as an
option and died on
`Unknown option -u`. The payload came back empty, the hook went silent,
and
`assert_absent "the UNTOUCHED reference is not reported"` went **green**
— while testing nothing
at all. It was caught only because the paired `assert_contains` failed
alongside it. Lines now
reach jq on stdin. This is also why the PRE column above is measured
with the shipped test file
rather than the standalone fixture: the two artifacts had already
diverged behaviorally once.
2. The positive needle `UNRESOLVED_SKILL: /alpha:ghost` is a substring
of the `/alpha:ghost-old`
line it exists to exclude, so it was sound only as long as the paired
absence assertion stayed
   next to it. It now carries its own right boundary.

## Not verified

A live PostToolUse payload carrying `structuredPatch` was **not**
directly observed — no hook-event
capture existed on the authoring machine to read. The schema is
documented (above, fetched today)
and corroborated by real Edit records in Claude Code's own transcript
JSONL, which carry
`originalFile` and a `structuredPatch` with `+`/`-`/` `-prefixed lines.
If the field never arrives in
a hook payload, the filter never engages and nothing regresses — but a
reviewer with a hook-event
capture could close this gap in one grep.

## Adversarial verification status

A fresh-context adversarial verifier was spawned for this PR and did
**not** return a verdict before
the authoring session ended — the machine was saturated by concurrent
agents and every spawned
verifier stalled inside a long test sweep. Treat this PR as carrying the
author's own evidence only.

What partially substitutes for it, and why it is not nothing: the PRE
column in the table above was
produced by running the **shipped test file** against the **unmodified
`origin/main` hook** in a
pristine worktree, which is precisely the headline check such a verifier
performs. What is still
unverified by a second party is the "can you break it" attack surface
and the payload-supply
question called out below.

Closes #2129

## Related

- #1319 — where the parent finding was filed
- #2100 — closed the parent finding via span-overlap scoping; this is
the residual it left
- #1938 — the stranded post-merge review-findings sweep
- #2113 — the other guardrails defect from this sweep, shipped
separately
- Another agent is concurrently bumping `guardrails` for #2124, and
#2113's branch bumps to the same
  0.24.0; whichever of the three merges later will need to re-bump.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

guardrails: add a claim-verification guard (plugin-skill reference)

1 participant