* test(#3523): rewrite two undetected source-greps as behavioral tests
Both sites read a real shipped hook and text-searched it, and both were
invisible to local/no-source-grep because the path was bound to a separate
const the rule never resolves back to its literal.
tests/check-update-config-dir.test.cjs carried three such reads, not the
one the issue cites. All three are replaced by a harness that runs the
real hooks/gsd-check-update.js under a fake HOME and observes the config
dirs detectConfigDir resolved, via the env the hook hands its worker.
Coverage now includes the CLAUDE_CONFIG_DIR precedence cases and the
full adjacent-pair search order the deleted static grep only asserted
for one pair.
tests/security-prompt-injection.security.test.cjs asserted the scanner
hook's SOURCE TEXT contained each canonical MARKDOWN_LINK_PATTERNS regex
source. It now drives probes through the real hook and asserts the
emitted ruleId, with a completeness gate so a new canonical pattern
without a probe fails loudly, plus safePredicate parity the text grep
never checked.
No allow-test-rule marker is added. The now-false marker on
check-update-config-dir.test.cjs is removed and its identity-allowlist
entry pruned, which the ratchet requires.
Refs #3464
* feat(#3523): emit typed findings IR from the read-injection scanner
The scanner built a structured findings array internally and discarded the
structure when rendering its advisory sentence, so the only thing a test
could assert on was that prose. CONTRIBUTING's 'Prohibited: Raw Text
Matching on Test Outputs' names that exact situation and prescribes adding
the typed surface rather than matching the text.
findings is now an array of {ruleId, match} records and the advisory is
derived from it through a single renderFinding mapper, so the rendered
text and the IR cannot drift. The array is emitted additively on
hookSpecificOutput for both the advisory and blocking output shapes.
The advisory string itself is unchanged, byte for byte: verified across
six payload shapes (single markdown-link hit, 3+ finding HIGH, invisible
unicode, unicode tag block, injection-pattern-only, mixed) by running the
pristine and modified hooks against identical stdin and comparing. 28
existing assertions across four suites substring-match that string.
The #3523 parity assertions now read the IR, and a new test binds the two
surfaces together by asserting every MD-LINK ruleId in findings appears in
the advisory and that the reported pattern count matches findings.length.
Refs #3464
* docs(#3523): document the read-injection scanner output contract
The scanner had no subsection under Security Hooks, only a one-line table
row. Documents its trigger events, severity thresholds, skip conditions,
rule ids, and the findings IR added alongside the advisory.
Refs #3464
* fix(#3523): bind every finding family to the advisory, freeze rule ids
Two review findings on the typed-IR commit.
The parity test filtered on MD-LINK- and so bound only one of the four
finding families to the rendered advisory; the other three were covered
only by the pattern count, which catches a length mismatch but not wrong
text. It now drives a payload producing all four families at once,
asserts all four are present so it cannot silently degrade, and checks
each one's expected rendering against an expectation table coded
independently of the hook's own mapper.
The three synthetic rule ids were written twice each — once at the push
site, once in renderFinding — so a rename at one site would fall through
the generic render branch with no signal. They are now a frozen RULE_IDS
constant referenced from both. No string value changed; the advisory
remains byte-identical across all six proof payloads.
Refs #3464
* chore: pin changeset pr field to #3548
---------
Co-authored-by: sim <sim@local>