4641a7b release(0.3.0): a detection pattern changed value — that is new here
1482c0b feat(schema,spec): give the §1.1 MUST a shape, since v0.2.0 shipped it without one
25a2cf9 feat(conformance): the witness case, and the derivation rule that had no room for it
c1b2385 fix(lexicon): converge script-tag on its source — re-extraction, not revision
6f79a6e fix(lexicon,docs): the script-tag row reversed — commons is now the sole diverger
git-subtree-dir: scanners/commons
git-subtree-split: 4641a7b5184047460e3f10038b615a61e7a4ac21
Bare/unquoted legacy OpenAI keys (no label assignment, no Bearer prefix)
slipped past the pre-write secret-detection hook. Added a pattern anchored
on the T3BlbkFJ base64 "OpenAI" watermark (vendor-documented shape),
avoiding the collision-prone bare sk-+48alnum form. Failing tests first,
full suite green (2184/0/6).
The originally planned source for this fix — porting two entries from
commons' secret-egress.json — turned out to be a false premise: that file
is a byte-identical copy of this hook's own table, not a superset. The two
missing names existed only as prose in commons' conformance/manifest.json,
describing a different repo's (the guard's) unpublished Python table.
gcp-service-account-json was measured NOT to be a gap (already covered by
the existing PEM-block pattern); openai-api-key-legacy was the one real
gap, closed here with a locally-authored pattern rather than an invented
"port". Commons notified via coord-send that their secret-egress.json
(count: 18) is now stale.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYJX35KLH3rpS6pi7LHj8u
Commons v0.2.0 (532d70d) grew a MUST: a runtime claiming conformance must
declare the set of commons data files it implements and publish that set with
its result. The corpus went 83 -> 89; the six new cases are scoped to
signatures/active-content.json, which this runtime does not implement. Under
1 they would be six permanent failures; under 1.1 they are not-applicable, a
third verdict distinct from 1's error.
Published shape: 83/83 passed, 6 not-applicable. The six are enumerated by
name, reported as skipped rather than passed, and stay in the denominator.
The declaration is DERIVED from the same constant the runner uses to accept a
scope (DECLARED_TABLES), so it cannot drift from what the suite actually runs
- this is what was promised to commons in reply 20260811T104628Z.
Anti-narrowing was NOT enforced by construction, contrary to the claim in the
reply. Measured: setting DECLARED_TABLES to the empty set turns all 89 cases
not-applicable and leaves the suite GREEN with zero cases run - exactly the
exit 1.1 forbids. "Visible as a code change" describes a reviewer, not a gate.
Closed with a derived floor rather than a second hand-maintained table list
(which would be the parallel declaration we promised not to keep): a commons
table whose aliases name llm_security has registered this runtime as a
consumer per 3.1, and a registered consumer that stops declaring the table is
withdrawing a published claim. The universe of tables comes from the manifest;
membership comes from each table's own aliases. Measured: only the injection
lexicon names us, so the floor is one table and the other three carry no
obligation.
Mutation-proven, all six firing:
declaration -> [] green, 0 cases -> 1 fail (the defect above)
declaration -> wrong table 7 fail
over-declare an unimplemented one 6 fail
count_by_scope 83 -> 82 1 fail
drop a case from manifest.cases 2 fail
strip our alias registration 85 fail
Suite 2173 -> 2181 (+2 gates, +6 not-applicable), 2174 pass, 0 real failures.
The one red under parallel load was pre-compact-scan size-cap, a known timing
flake: 358 ms alone against a 1000 ms cap, 1886 ms under load. Budget untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uj6iB8uUUUFHLgAya1hZt7
532d70d release(0.2.0): the contract gained a MUST, so the tag has to move
946f51d fix(active-content,conformance): cite line numbers per commit — they do not resolve at the pin
bdcb1f1 feat(conformance): ship the six active-content cases; the id space already existed
807c0d4 feat(spec): add not-applicable, so a single-runtime table stops reading as 7 defects
a1578e6 fix(conformance): record the guard's internal-surface position on _LEX_PAYLOADS
4d351d2 fix(mapping): state that three of four OWASP maps have no production consumer
f082a91 fix(lexicon,docs): retract the claim that the guard's port cites severity.mjs
git-subtree-dir: scanners/commons
git-subtree-split: 532d70d5ed2f9b23a8efad760ef490356cf52ada
Second consumer swap of step 4. OWASP_MAP stops being a hardcoded constant in
severity.mjs and is built from the vendored commons artifact
mapping/owasp-map.json by a new scanners/lib/owasp-map.mjs, re-exported from
severity.mjs so the published surface (which the golden gate walks as
severity:OWASP_MAP) is unchanged.
Scope is one of the four maps commons publishes, and the omission is measured,
not incidental. OWASP_MAP has a production consumer: owaspCategorize() reads it
as the per-scanner fallback, and that reaches real report output through
output.mjs's owasp_breakdown. OWASP_AGENTIC_MAP, OWASP_SKILLS_MAP and
OWASP_MCP_MAP have none - every reference tree-wide is a test or a golden
artifact - so they stay source literals, the same call already made for
cyrillic_confusables in the first swap. Porting them would move data no runtime
reads into the load path.
Measured byte-likeness before the swap, all four taxonomies: same 16 prefixes,
same insertion order, same code arrays. Loadable verbatim, unlike the injection
table.
Content preservation proven the same way as the codepoint swap: the golden dump
differs in exactly one record, the sha256 of severity.mjs, which changes by
construction when a table leaves the file. All 83 regex records and all 7 table
records including severity:OWASP_MAP are byte-identical; reference-run.json
unchanged at 61/61. patterns.json re-blessed for the file digest only.
New property, not just preservation: the golden gate now pins the vendored
commons data transitively for this table too. Mutation-proven in both
directions - changing one code value and deleting a whole prefix each turn
three independent gates red (golden table digest, the new owasp-map gate by
name, and the pre-existing severity behaviour tests).
Entries are validated rather than trusted: commons is vendored data, and a
value that is not an array of strings would be spread straight into
owaspCategorize's category list, so a malformed entry is dropped. Graceful-empty
on an unresolvable commons, matching commons-loader's contract - severity.mjs is
on the import path of output.mjs and every orchestrated scanner, so a load throw
would abort a scan rather than degrade it.
Suite 2164 -> 2173, all green.
First consumer swap of step 4. ZERO_WIDTH_CHARS (5), the Unicode Tag range,
BIDI_CHARS (9) and HOMOGLYPH_MAP (28) stop being hardcoded constants in
unicode-scanner.mjs and string-utils.mjs and are built from the vendored
commons artifact codepoints/carriers.json by the new lib/codepoints.mjs.
Started here rather than at injection-patterns, which the plan ordered first:
that table is the one table that cannot be loaded verbatim (the
hybrid-xss:script-tag divergence is directional, and loading the lexicon as-is
would reverse the 90f576f recall fix). The codepoint tables were measured
byte-equal to the source constants BEFORE the swap - same members, same
values, same insertion order on HOMOGLYPH_MAP - so they load verbatim.
Proof the swap is content-preserving: the golden dump differs in exactly one
record, the sha256 of string-utils.mjs, which changes by construction when a
table leaves the file. All 83 regex records and the
table:string-utils:HOMOGLYPH_MAP digest are byte-identical, and
reference-run.json is unchanged at 61/61. patterns.json is re-blessed for the
file digest alone.
The gate is proven red-capable against the SUBJECT, both directions:
- dropping U+00AD from the vendored zero_width table fails the new
codepoints gate by name, twice;
- altering one homoglyph value reddens the golden table digest AND a
behavioural homoglyph test.
That second direction is a property the swap creates rather than preserves:
the golden gate now transitively pins the vendored commons data, where before
it pinned a source literal and a commons mutation was invisible to it.
NOT ported: commons carries cyrillic_confusables (13), and unicode-scanner.mjs
declares a set by that name - but nothing reads it. The homoglyph-mixing
detector tests isCyrillic(cp), the whole U+0400-U+04FF block. Loading it would
move dead data into the load path, so the dead const stays where it is and is
recorded instead. The recorded v8.x-B i/x drift between that set and the
lexicon class is therefore latent, not live. commons' private_use table has no
constant behind it here at all.
Graceful-empty is kept deliberately: codepoints.mjs is on string-utils'
import path and hooks import string-utils in fresh per-tool-call processes, so
a module-load throw would break the tool call rather than degrade the scan.
The loud half is the test, which asserts exact per-table counts through the
real default commons root - the same shape as the lexicon load-assertion.
Drive-by, unavoidable: the deleted JSDoc carried the "~25 entries" claim for a
28-entry table (v8.x-C). It needed a re-bless of the same file digest this
swap already forces, so it closes here at no extra cost.
Suite 2158 -> 2164, all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7XEEFrAJsREqa9N4tpfm8
Vendored commons v0.1.0 carries a cross-runtime conformance corpus. Until now
it was measured by a throwaway script, which makes 83/83 a claim rather than a
gate - and step 4 swaps the very tables it constrains, so the measurement has
to survive into that step or it protects nothing.
Comparison is exact-within-scope per spec section 4: every listed finding must
be raised and no other lexicon finding may be. Findings are named by commons
pattern_id, which scanForInjection() does not carry - it returns our labels.
Section 3.1 permits a runtime registered in the lexicon's aliases object to
compare through it, and we are registered. Measured first, not assumed: the
map is a total bijection, 83 labels to 83 ids, no duplicates, family membership
agreeing throughout. Nothing here restates a pattern's id, severity or label,
so nothing here can drift from the lexicon.
Deliberately NOT done: adding an id field to our 83 table entries. It would
change the source file the golden gate pins by sha256, forcing a re-bless in
the middle of a behaviour-preservation measurement, and duplicate what step 4
does anyway when the table itself starts loading from commons JSON.
Case discovery is driven by manifest.cases and cross-checked against the
directories on disk, because section 1 requires every case to run and a
deleted case dir would otherwise shrink the gate silently. An unimplemented
match or scope throws rather than skips (section 4). Input bytes and sha256 are
both verified before scanning - two fixtures carry characters invisible on
screen.
Proven red-capable in both directions by mutating the subject, not the harness:
neutering one pattern failed exactly override__disregard; widening one to
[aeiou] failed 82 cases on extra findings. Source restored byte-identical after
each.
Suite 2158, 85 new. The one red in the parallel run is the known
pre-compact-scan size-cap flake (366 ms alone, 1060 ms under load).
Vendoring commons v0.1.0 under scanners/commons/ falsified the premise of
`degrades gracefully when commons has not been vendored yet`: it asserted the
default DEFAULT_COMMONS_ROOT did not exist. It was the only red test after the
subtree add (2072/2073).
The graceful-empty contract it guarded is covered twice over by the
missing-artifact and invalid-JSON cases, which drive the same code path through
an explicit commonsRoot. So the replacement asserts the direction that is now
uncovered and matters more: a non-zero record count through the real default
root, no override.
That is the positive load-assertion Phase 5 step 4 requires. Every other gate we
have treats a commons load failure as indistinguishable from a legitimately
empty table, so a total loss of the vendored corpus would leave the suite green.
Proven red-capable by moving scanners/commons aside: the assertion fires by
name, not as an incidental TypeError elsewhere.
Suite 2073/2073.
Two of the three confirmed v8.x-A evasions are closed; the third (attribute
padding) stays open by operator decision and is documented, not silently left.
Recall fix. hybrid-xss required a closing </script>, so `<script>alert(1)` and
`<script src=x.js>` both passed scanForInjection() with found: false, while the
closed form returned high. A src= tag has no body to close in the first place.
The opening tag alone is the signal, and matching it is strictly linear: one
negated-class run whose excluded character is its own terminator, so there is
no backtracking surface that could re-introduce v7.8.3 #24.
ReDoS gate. The #24 test covered six html-obfuscation patterns against the two
shapes that defect was found on. A catastrophically backtracking regex added
anywhere else in the four tables would have failed no test at all - which is
why #24 had to be found by hand. The new gate times every exported pattern and
asserts its own coverage, so it cannot be narrowed silently.
It has two classes because the blowup shapes need opposite inputs, and because
a synchronous RegExp.test() cannot be interrupted: an exponential pattern met
with a 512KB input would HANG the run rather than fail it. Class 1 uses a
28-char ambiguous-run ladder, where exponential costs ~1s and anything sane
costs microseconds; on a hit the 512KB sweeps refuse to run. Class 2 uses the
hook's real read cap to catch the polynomial #24 class. Proven to fire: an
injected exponential regex was named by label and the hang guard held.
Measured clean: worst single pattern 17ms, full large sweep 218ms.
Golden gate went red as expected and was re-blessed - diff is exactly two
lines, the regex source and the source-file digest. The 61-payload reference
run is byte-identical, so no showcase behaviour moved.
Suite 2073/2073 (was 2063; +5 recall tests, +5 gate tests).
Still open, unchanged: all 7 bounded HTML patterns evade on >256 chars of
padding, in two positions - before the attribute and inside the style value.
Wider than STATE recorded, which named only aria-label.
Advisor review on the prior commit (69cad7c) caught a real detection-kill
vulnerability before push: reading `commons.root` from the SCANNED
TARGET's .llm-security/policy.json let a hostile cloned repo redirect
llm-security's own detection corpus to an attacker-supplied (empty)
one, with graceful-empty fallback making the substitution silent — a
substitutive override, unlike sig.custom_rules_path's additive one.
Dropped the policy import entirely; commons location is this plugin's
own concern, resolved only from __dirname or an explicit test/dev
override, never from policy or the scan target.
Also fixed two issues the review surfaced:
- Default vendor path was repo-root `shared/`, which package.json's
`files` allowlist (bin/, scanners/, knowledge/) would never publish —
moved under scanners/commons/, inside the directory that actually
ships. Same defect class as 2fe2915 (green dev checkout, empty
detection tables once installed).
- Cache keyed success/failure together, so the first caller's
`fallback` shape (e.g. []) leaked to a second caller expecting a
different shape ({}) on the same missing artifact. Cache now stores
a load-failed sentinel and returns each caller's own fallback.
Loaded artifacts are also deep-frozen, since the cache hands out one
shared object by reference to every caller.
New/changed tests cover all four: a simulated hostile-target policy
file is ignored, the failure-cache no longer cross-contaminates
fallback shapes, and mutating a loaded artifact throws.
Golden baseline unchanged; full suite 2063/2063.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAYkRaBXT6tmWXTQAi1ZBg
Thin, sync-read JSON artifact loader for the future vendored
llm-security-commons subtree, modeled on signature-scanner.mjs's
loadRules()/loadCustomRules() pair: process-cached, graceful-empty
fallback on any read/parse error, and policy-extensible via a
`commons.root` policy value (mirrors sig.custom_rules_path).
Unit-tested now against a local fixture — Phase 4 (commons repo
creation, gated on the operator creating the Forgejo remote) hasn't
run yet, so the default `shared/` vendor path doesn't exist in this
checkout. That "not vendored yet" case is itself asserted: the loader
must degrade to the caller's fallback, not crash.
Not wired to any consumer yet (that's Phase 5 step 4, table-by-table
behind the golden gate). No CLI/hook/scanner-visible behaviour exists
to document. Golden baseline unchanged; full suite 2063/2063 (2053 +
10 new).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAYkRaBXT6tmWXTQAi1ZBg
The `coverage` block never looked at the 61 hook invocations. It probed every
payload STRING against every injection regex in-process, so `47/83` meant
"if you threw all 61 strings at all 83 patterns, 47 would match" - not "the
reference run exercised 47". The pre-bash-destructive payloads never reach
injection-patterns at all, yet their strings were in the probe set and could
mark a pattern exercised.
That number was asserted as reference-run coverage in three places that
instruct a future session: tests/golden/README.md, the STATE golden-gate
section, and the generator's summary line. In a repo whose v7.8.2 lesson was
"the check reported success without running", a figure that measures one
thing while labelled another is the same defect wearing a different hat.
Relabelled rather than re-measured - the probe still honestly bounds the gate
(an unreachable pattern is one the corpus cannot protect under ANY
attribution), it just has to say what it is:
coverage.kind = 'static-reachability', with the caveat in a `note` field.
patternsExercised -> patternsReachable
uncoveredPatterns -> unreachablePatterns
tablesExercised -> corpusContains (a payload CONTAINS a homoglyph; it
does not say the run folded one)
The gate now pins both the `kind` and the note, so the honest label cannot be
dropped quietly by a later edit.
Also surfaced the gap the old wording hid: the four OWASP maps have NO
behavioural coverage - they are scanner-side and no hook in this corpus
reaches them. They are precisely the tables the dump was widened to cover, so
the table digest is their only protection. Stated in the README next to the
47/83 line, where a reader was previously left to infer the reference run
backed them.
Flakiness check for the new gate (61 sequential spawns, ~12s, the most
process-heavy file in the suite, added to a suite npm test runs concurrently):
three consecutive full runs, 2053/2053, 0 fail. The three known
timing-sensitive files did not destabilise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
Records the reference artifacts Phase 5 swaps will be measured against, and
the gate that reads them. No extraction yet: this is the "before" picture,
and it had to land first or every later comparison would be confounded.
Typed test/ rather than feat/ deliberately - the only production change is
one widened export; the rest is gate, artifacts and generator.
Written failing-first (7 red on missing artifacts), then generated.
Three layers, because the plan's "assert .source/.flags of every regex" is
necessary but not sufficient:
1. regex records - .source/.flags off the COMPILED object. After a swap a
pattern is new RegExp(jsonString, flags), so the plan's named hazard
(JSON backslash-doubling on 83+18 regexes) is visible here and nowhere
else. Source-text comparison cannot see it.
2. table records - key/value digests. Most of what Phase 4 moves is not a
regex at all: HOMOGLYPH_MAP (x3, AS-IS), the typosquat tokens and the
four OWASP maps are char->char and string->string data. A regex-only
dump is blind to a broken homoglyph swap, i.e. to the bulk of the
payload. Operator decision: widen the dump.
3. file records - sha256 of the five moving-set sources. This dissolves
STATE's open question (how to enumerate every regex): it is complete by
construction, covering inline regexes in function bodies that no export
walk reaches, with no JS parser in a zero-dep repo. A lexical count
would have pinned a lie - severity.mjs scores 4 "regexes" that way and
exports none.
Both layers were proven to fire, not assumed to: mutating one HOMOGLYPH_MAP
entry reddens the table layer, and widening an inline regex inside
decodeHexEscapes (unreachable by any export walk) reddens the file layer.
HOMOGLYPH_MAP is now exported from string-utils.mjs. That export is a source
change the plan already flags as a surface hazard ("private tables become
loaded"), so it is pre-paid here rather than confounding the before/after.
Reference run: the 61 showcase payloads through the real hook entry points,
sequentially - array order is semantic and the plan forbids key-sorting, so
concurrency is removed rather than sorted away. 61/61 match expectation,
which also settles the plan's open assumption that payloads.json expectations
match current behaviour. Coverage is recorded, not assumed: 47/83 patterns,
with the other 36 listed by key so the gate never implies coverage it lacks.
Suite counts are per-file, each file run alone - a total is unattributable,
and the three known timing-sensitive files flake only under concurrency.
2045 pass / 0 fail across 91 files, matching the pre-existing count exactly.
The gate's own file is excluded (it reads the artifact the run produces) and
that exclusion is named in the artifact.
Suite: 2053/2053, 0 fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
Closes Phase 3 (B11) of the v8.0.0 plan. Three parts, all with the failing
test written first.
riskScoreV1 removed. scanners/lib/severity.mjs drops riskScoreV1() and its
SEVERITY_WEIGHTS_V1 table - @deprecated since v7.0.0, kept for diff/comparison,
zero callers in code or tests (re-verified, not taken from the plan). The v1
weights are recorded in CHANGELOG so an old score stays re-derivable. riskScore
(v2) is untouched; a test pins that one critical still lands in the 70-95 tier
and that 50 lows score below it, which is exactly the case v1 collapsed to 100.
Posture category 12 no longer keys off an identifier name. The check was
/TRIFECTA_MODE/i over the session-guard source, which measured what a constant
was CALLED rather than whether enforcement was configurable. With the env-var
gone, that regex would have dropped every correctly-migrated project from PASS
to PARTIAL - the gate punishing the migration it exists to encourage. It now
matches getPolicyValue('trifecta', 'mode', ...) and still accepts a pre-v8
vendored guard reading the old env-var, because a third-party project carries
its own hook copy and is equally configurable either way; the evidence line
says which of the two was found. The PARTIAL finding recommended setting an
env-var that v8 ignores; it now names the policy key. The grade-a fixture hook
moves to the policy-era form.
Two never-implemented env-vars deleted from the docs. LLM_SECURITY_SCR_OFFLINE
(ci-cd-guide) and LLM_SECURITY_OFFLINE (supply-chain-attack example) were
documented as OSV.dev / npm-audit kill-switches. No code has ever read either -
verified by grep across scanners, hooks and scripts, which finds them only in
markdown. A promised kill-switch that does nothing is worse than a documented
absence: it is trusted precisely when the run is meant to be air-gapped. The
docs now say there is none and that egress must be blocked at the network
layer. The LLM_SECURITY_AUDIT_* wildcard is narrowed to the one real key.
Docs. Migration section in README + CHANGELOG with the env-var -> policy-key
table, the detection commands (env + shell rc + .envrc + workflows), and the
explicit warning that a removed variable is now INERT rather than an error -
which is the failure mode that loses a project its configuration silently. The
hardening-guide env table splits into surviving vars and a removed-vars
migration table; its "promote to block" runbook named two variables that no
longer exist. Also swept: CLAUDE.md hook table, scanner-reference, ci-cd-guide,
both lethal-trifecta example docs, mitigation-matrix, injection-research.
Test counts in README/CLAUDE.md synced 2034 -> 2045.
Suite 2045 tests, 0 fail (2039 + 4 posture-trifecta + 2 riskScoreV1). The two
known parallel-load flakes did not recur this run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
BREAKING CHANGE: the four LLM_SECURITY_* configuration env-vars deprecated in
v7.3.0 are removed. .llm-security/policy.json is now the only source:
LLM_SECURITY_INJECTION_MODE -> injection.mode
LLM_SECURITY_TRIFECTA_MODE -> trifecta.mode
LLM_SECURITY_ESCALATION_WINDOW -> trifecta.escalation_window
LLM_SECURITY_AUDIT_LOG -> audit.log_path
LLM_SECURITY_DEPRECATION_QUIET -> dies with the mechanism it silenced
Setting a removed var is now inert - it does not warn, and it does not
configure. Env-vars with no policy equivalent (PRECOMPACT_MODE,
PRECOMPACT_MAX_BYTES, UPDATE_CHECK, MCP_CACHE_FILE, IDE_ROOTS) are unaffected.
getPolicyValueWithEnvWarn and its one-shot stderr warning are deleted from
policy-loader.mjs, along with the module-scoped warned-var Set. The four call
sites collapse to getPolicyValue. getPolicyValue's JSDoc claimed "environment
variables ALWAYS take precedence" - it never read env itself, so that line
described the shim, and it is corrected rather than deleted.
User-facing hook strings that advertised a removed var as the escape hatch now
name the policy key instead: the inject-scan block reason, its warn-mode note,
both escalation-window advisories, and the trifecta block message. A blocked
user following the old text would have set a var that does nothing.
Tests. tests/lib/v8-env-removal.test.mjs is the regression gate and was written
failing first (8 of 12 red before the change). It pins the NEGATIVE - setting a
removed var does not alter the outcome - because that is the half that rots
silently: a re-introduced process.env read would leave every migrated positive
test green, since those configure through policy.json and never set the var at
all. One assertion walks hooks/scripts and scanners for `process.env.<removed>`
so the re-introduction is caught structurally, not only behaviourally.
The 44 env-driven test occurrences (18 inject-scan, 13+4 session-guard, 9
audit-trail) migrate to a throwaway .llm-security/policy.json via a new
runHookWithPolicy helper in hook-helper.mjs; audit-trail runs in-process, so it
supplies the same policy through CLAUDE_PROJECT_ROOT. The D3 mechanism tests in
policy-loader.test.mjs are deleted with the mechanism.
Suite 2039 tests, 2037 pass (+12 gate, -7 D3 mechanism). The 2 failures are the
known parallel-load timing flakes (pre-compact-scan size-cap,
pre-install-supply-chain F-3); both green when run isolated.
Remaining in Phase 3: posture-scanner TRIFECTA_MODE heuristic, riskScoreV1
removal, ghost-var cleanup, docs + migration note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
Two org-level findings from the coord inbox, both verified against source
before acting.
README.md:7 - org-ops D12 (docs/decisions.md, 2026-08-01) requires the
plugin-class disclosure line to be self-contained with THREE elements:
generator, process, and the ownership basis (Anthropic Consumer Terms
Section 4). Our line carried the first two. The wording mirrors the one
existing clause in the org (playground-design-system/README.md:21) rather
than inventing a second phrasing. LICENSE verified as MIT before asserting
it. Still one line at the measured head-block position, no link - D12
explicitly rejects both the mechanical sed form (drops ownership) and the
two-paragraph Provenance section (breaks the one-line form).
V3-UPGRADE.md:343 - org-ops census 03 identified this as the org's last
dead `claude-code-llm-security` reference, invisible to the repo-standard
gate because URL_REF requires :// or @host: before open/ and a schemaless
host does not match. Renamed to the post-split name; the file is kept.
CHANGELOG.md:751 is running prose about history with no URL position and
stays as-is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
repo-standard v0.2.0 flagged two README findings via coord. Verified both
against ground truth before acting:
- BADGE-COUNT (WARN, real): 8 badges confirmed by manual count. All eight
numbers were accurate -- commands 20, agents 6, hooks 9, knowledge 23,
scanners 22 (27 top-level .mjs minus the 5 non-scanner modules, per the
counting rule in docs/scanner-reference.md). The finding is about clutter,
not staleness. Operator chose to keep Version, Platform, Scanners, License
and drop the four inventory badges (Commands/Agents/Hooks/Knowledge) --
each of those repeats a table further down the same page and adds a sync
obligation to every version bump.
- README-H1 (WARN, operator's call): no change. The H1 is the human-readable
product title; llm-security is the package id in plugin.json and the
catalog. Renaming the H1 to the slug would be a readability regression.
doc-consistency.test.mjs pinned two of the removed/kept badges. The scanners
assertion still holds; the knowledge_docs badge assertion is dropped and the
test narrowed to the README prose claims, which remain derived from
knowledge/ contents. Full suite 2034/2034.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016h37aUdBLVDT9xrvG9osA9
Ran repo-standard v0.1.1 against this repo (class: plugin, trait: security).
6 ERROR, 4 WARN, 1 SKIP recorded before any edit; now 0 ERROR, 12 checks pass.
BROKEN
- INSTALL-NO-CLI: added `claude plugin install llm-security@ktg-plugin-marketplace`
beside the existing `marketplace add` line. The settings.json `enabledPlugins`
block stays — it is a legitimate second form, just not a CLI command.
- LINK-OUTSIDE-REPO (README:7, reported by catalog): the disclosure link pointed
at `../../README.md#ai-generated-code-disclosure`, relative to the pre-split
monorepo and anchored at a heading that never existed. Replaced with the inline
text the polyrepo migration's step 5 was meant to write.
MISSING
- `## Non-goals`: added as its own heading over the existing out-of-scope table
inside `## Project scope`. The `## Project scope` heading is kept because
SECURITY.md and CONTRIBUTING.md reference it by name.
- `## Changelog`: promoted from the trailing "Full history in CHANGELOG.md" line.
- `## Known limitations` (required by the `security` trait): renamed from
`## What this plugin does NOT cover` — same table, contract heading.
WEAKENING
- README-DESC: opening line now matches the forge description verbatim, so
description == catalog == README holds. This replaced the tagline
"Automated defense and advisory analysis for the agentic AI attack surface."
- HEADING-LEVEL: `### Install` promoted to `## Install`; `## Quick Start` split
into `## Requirements` / `## Install` / `## First scan`.
- BADGE-STATIC-CLAIM: dropped the static `tests-2034` badge. No runner exists on
this forge, so the badge asserted a run nothing performs. Replaced under
`## Self-scan` with the command that runs the suite from a clean clone, and
the plain statement that nothing runs it automatically.
- LINK-NON-REPO x2: `open/claude-code-llm-security` (pre-split name) in
V3-ANNOUNCEMENT.md updated to `open/llm-security`. Same dead name found in
sarif-formatter.mjs TOOL_URI, which ships into SARIF output consumed by other
tools, so it is corrected too.
Left standing, deliberately:
- WARN README-H1 `# LLM Security Plugin for Claude Code` != `# llm-security`.
The gate defers this to the operator, and the description thread it protects
is now carried by the opening line instead.
Suite 2034/2034.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016h37aUdBLVDT9xrvG9osA9
Extends tests/lib/doc-consistency.test.mjs with 15 cases that derive every
inventory count from source instead of trusting prose. Each count has one
stated derivation; a doc surface that disagrees now fails the suite.
Counts corrected (all were wrong before the test existed):
- orchestrated scanners: docs said 10 (README, ci-cd-guide), CLAUDE.md said 12,
the synthesizer agent said 9 — scan-orchestrator registers 14
- total scanners: README badge + 3 prose sites said 23; the counting rule in
docs/scanner-reference.md (14 orchestrated + 8 standalone) yields 22
- knowledge files: README badge + prose said 22; knowledge/ holds 23
- output.mjs finding() prefix JSDoc listed 10 of the 17 prefixes actually
passed to it (missing IDE, MCI, MEM, PST, SCR, TFA, WFL)
- norwegian-context.md said "8 hooks, 10 scanners" -> 9 and 14
- ci-cd-guide "what gets scanned" table listed 10 of 14 rows; adds workflow,
trigger abuse, signature, AST taint
Two plan items changed after verifying against ground truth:
- CLAUDE.md's synthesizer "(12 scanners)" was not a deliberate subset; the
agent file itself claimed 9. Both bumped to 14.
- compliance-mapping.md's "13 posture categories" is substantively correct —
its matrix has exactly 13 data rows, and categories 14-16 are governance
consumers of the file, not rows in it. The planned 13->16 bump would have
made the document false. Wording clarified to "code-level" instead, and the
test now pins row count against the stated claim.
Framework currency (both verified against primary reporting):
- EU AI Act: Digital Omnibus (EP 2026-06-16, Council 2026-06-29) deferred the
high-risk obligations behind Art. 9/15 to 2027-12-02 (Annex III) and
2028-08-02 (Annex I); transparency still applies from 2026-08-02
- OWASP Agentic AI Top 10 labelled as the 2026 edition
Also: CLAUDE.md Distribution section rewritten monorepo -> polyrepo (each
plugin is its own repo; the catalog pins url + ref per plugin), and
current-state test counts synced 2013 -> 2034. Release-note paragraphs keep
their historical numbers.
No scanner, hook, or command behaviour changes. Suite 2034/2034.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wt4YQGoXwRja5K2Zmv8RZE
Three TDD-first fixes surviving the B8 roadmap bucket (v8.0.0-plan.local.md
Phase 1, items 1-3; item 4 JAR hardening scoped out at review):
- supply-chain-recheck.mjs parseYarnLock: ported the hook's per-entry parser
(pre-install-supply-chain.mjs) so Berry's `version: x` format (unquoted) is
recognized alongside Classic's `version "x"` — Berry lockfiles previously
yielded zero deps, silently missing pinned compromised packages.
- supply-chain-recheck.mjs parsePackageLock: lockfileVersion-1 fallback now
recurses nested `dependencies`, mirroring the hook's walk() — a transitive,
non-hoisted compromised copy below the top level was previously invisible.
- content-extractor.mjs stripInjection: attribution moved from a global
`Set<label>` to `Set<label::lineIndex>`. The old check silenced the
unstripped flag for ANY occurrence of a label once ANY occurrence had been
line-redacted, so a second, cross-line-only encoded occurrence of the same
label survived into sanitized output without being flagged.
Full suite 2019/2019 (one known-flaky timing test confirmed green in isolation).
org-ops flagged that STATE.md is tracked in this public repo (open/llm-security),
contradicting the current global convention (public remote => STATE.md gitignored).
The .gitignore comment claiming the opposite was stale. Stops tracking going
forward; existing history is left untouched (already public).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GQCcuMneSfXp6xHUvYbQq
Operator decision 2026-07-18: v8 strategy docs are LOCAL-ONLY. All 4
(FAMILY-MAP, JOBS-TO-BE-DONE, commons-extraction-plan, roadmap) renamed
to docs/*.local.md (gitignored); roadmap.md had been hidden only by the
accidental case-insensitive ROADMAP.md ignore match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
The PostToolUse hook read input.tool_output, but live Claude Code delivers the tool result as tool_response — so the indirect-injection scan on MCP tool output silently never fired outside the test harness (which sent tool_output). The hook now reads tool_response with a tool_output fallback for older harnesses/fixtures. +3 tests covering string and object tool_response and the precedence. Found via a live-session check during the v7.8.3 sweep; not one of the 52 MEDIUM-tier findings. Suite 2016/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
Bumps plugin to 7.8.3 (package.json, plugin.json, README version badge) and syncs the documentation surfaces that had drifted: orchestrated scanner count 9/10/13 to 14 (#8; the WFL/workflow scanner was omitted from the reference list), posture categories 13 to 16 (#44), red-team scenarios 64 to 72 (#45), tests badge/prose 1822 to 2013 (#46). Corrects the pathguard hook-table matcher and header comment to Edit|Write (follows the #9 fix) and removes the two dangling ROADMAP.md references (#47; the roadmap is not a public file). Adds the v7.8.3 CHANGELOG entry, README Recent-versions row, and CLAUDE.md highlights. The '23 scanners' total is a separate module count and is left unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#58 the e2e scan-pipeline suite omitted trg/sig/ast from EXPECTED_SCANNERS and no fixture carried trigger-abuse/known-signature/python-taint content, so the three v7.8.0 scanners' surfacing through the aggregate was never asserted; added a fixture (shadowing command name → TRG, webshell → SIG, tainted os.environ→requests.post → AST, python3-guarded) that asserts each appears in the envelope and the rolled-up counts/owasp_breakdown. #59 the SIG cryptominer (SIG-MINER-001/002) and hacktool (SIG-HACKTOOL-001) families and non-base64 decode variants had no test; added family-attribution tests and a rot13-decode assertion. Test-only, suite 2013/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#20 git-forensics' reflog force-push detector had a redundant bare 'reset' term subsuming 'reset:', so any commit subject containing 'reset' tripped it; removed. #22 diff-engine's per-current moved-fallback greedily consumed a baseline candidate a later byte-exact match needed (mislabeling unchanged as new/moved on duplicate fingerprints); a global exact pass now runs before the moved-fallback. #54 memory-poisoning double-reported a 64+ char hex token as both base64 and hex; the base64 check now skips pure-hex tokens.
#56 SARIF output hardcoded driver.version 6.0.0 because the orchestrator called toSARIF() without a version; it now passes the real plugin version from package.json. #50 the VS Code known-malicious blocklist was empty with no explanation (unlike the JetBrains file's 'empty by design' note); added a matching blocklist_note. Suite 2004/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#9 pathguard registered matcher Write only, so an Edit/MultiEdit to an existing protected file (settings.json, .env, .ssh, the hooks themselves) bypassed it entirely; matcher now Edit|Write (the script already reads only tool_input.file_path). #10 the primary trifecta detector's 20-entry window counted marker lines, so accumulated markers scrolled a real leg out (false negative); the window now counts tool-call entries. #13 pre-edit-secrets caught bare provider keys only inside a quoted label assignment; added anchored patterns for Anthropic sk-ant, OpenAI sk-proj, fine-grained github_pat_, Google AIza, and JWT eyJ (minimum-length guarded against prose false positives).
#12 the remote-pipe-to-shell block required a shell immediately after the first pipe, so xargs/sudo/tee/env interposition evaded it and the comment falsely claimed xargs was caught; broadened to reach a shell through intermediate segments while leaving shell-OR fallbacks unblocked. #11 (doc only): knowledge/owasp-skills-top10.md claimed pre-bash-destructive blocks persistence commands — it does not; corrected to mark persistence detection as unimplemented/future (the detector itself is deferred to v8). Suite 2004/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#21 the bash normalizer decoded only \xHH, leaving ANSI-C octal/\u/\U forms literal so canonical rm/curl never surfaced; now decodes all three. #23 file-discovery keyed on extname so .env.local/.env.example (extname .local/.example) were silently skipped; now matches multi-part suffixes. #42 a legitimate leading UTF-8 BOM was flagged HIGH (and the tool's own auto-cleaner refused to strip it); pos-0 BOM now excepted. #52 collapseLetterSpacing used a literal space, letting multi-space/tab spacing evade; now [ \t]+. #55 redact(_,60,0) did slice(-0) and leaked the whole unredacted URL; showEnd===0 now means no tail.
#30 embedded base64 (const x = "<base64>") never satisfied the whole-string decode, so the SIG identity engine never saw it; added decodeEmbeddedBase64 as an OPT-IN param on normalizeForScan (default off — appending a decoded copy would double-count per-match findings, e.g. content-extractor's injection scan) and enabled it only in signature-scanner, which dedups variants. #36 signature-scanner ignored the documented sig.custom_rules_path policy option; now loads+merges custom rules through the same family filter. Suite 2004/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#33 the frontmatter parser collected a block-scalar body (description: |) but did not skip it, so an indented name:/allowed_tools: inside the body re-matched as a top-level key and overrode the real values TRG-shadow and permission checks depend on; the parser now consumes block-scalar bodies as opaque content. #32 block-scalar headers carrying indentation/chomping indicators (|2, >-, |-2) were not recognized, so their bodies never reached the run: injection sink; now matched via a proper indicator/chomping regex.
#43 the B4 actor auth-bypass detector inspected only braced ${{ }} expressions, missing the canonical bare 'if: github.actor == ...' form (Synacktiv Dependabot-spoof false negative); bare if: expressions now emit a synthetic event the detector reads. Suite 2004/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#24 the HTML-obfuscation injection patterns had overlapping unbounded runs plus a required closing quote, so a non-closing input backtracked O(N^2) (~28.7s at the 512KB cap); quantifiers bounded, pathological input now 4ms. #53 mcp-live-inspect buffered MCP-server stdout via readline with no cap, so a hostile stdio server could exhaust memory / throw an uncaught RangeError; replaced with manual line buffering capped at 4MB that rejects pending RPCs and destroys stdout. #31 vsix-fetch's same-host redirect follower had no depth cap (loop hang); added depth>=5 cap mirroring the sibling fetcher.
#25/#51 mcp-description-cache and skill-registry wrote JSON via bare writeFileSync (non-atomic: concurrent load-modify-save loses updates, a torn read silently yields an empty registry); both now write a temp file then renameSync. Suite 1931/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#38 toxic-flow matched trifecta-leg keywords with bare includes(), so substrings ('url' in 'curl', 'key' in 'monkey', 'auth' in 'author') fabricated CRITICAL trifectas on benign components; now word-boundary matched. #40 TRG-broad fired HIGH on a bare any/all/every anywhere ('fix any lint errors'); the universal-claim regex now requires genuine universal phrasing. #41 TRG-baiting substring-matched ('any file' in 'many files'); now boundary-anchored. #39 the broad-name list missed multi-char generic names (helper/assistant/auto/general/agent/tool); widened coherently so it does not reintroduce #40. #57 the '(recovered from obfuscation)' label compared raw against a lowercased normal form, firing on any uppercase char; now gated on an explicit decode-changed flag.
#26 (same file) getPolicyValue used 'key in sectionObj' with no type guard, so a scalar section override in policy.json (e.g. {"injection":"block"}) threw an uncaught TypeError; now guarded to fall back to the default. Suite 1931/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#18 dep-auditor called 'pip audit' (no such subcommand) so Python CVE detection was a permanent silent no-op; now spawns 'pip-audit' with an argv array. #19 it audited the scanner HOST's env with results mislabelled to the target's requirements.txt; now audits the target via -r and skips cleanly when absent.
#14 the offline npm blocklist was skipped for bare/range/tag installs because it used the (null) parsed-spec version; now re-checks the resolved version before the OSV network path. #15 non-hoisted nested lockfile keys derived the wrong package name (leading-only node_modules/ strip); now strips to the last segment. #48 lockfileVersion-1 nested dependency trees are now walked recursively.
#16/#17 the yarn.lock matcher paired two unassociated whole-file substrings with an unanchored pkg@ (false BLOCK of a legit package) and only matched Yarn Classic quoted versions (Berry known-malware allowed); rewritten as a per-entry parser that associates version to its own entry, anchors the name, and matches both Classic and Berry. Suite 1931/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
py-ast-taint.py Assign handler never removed a name from the tainted set on reassignment, so a source-then-constant/sanitizer rebind (g=os.getenv(); g='safe'; os.system(g), or x=shlex.quote(x)) kept stale taint and fired false AST-CMD-EXEC findings. Assign now clears taint for target names when the RHS is not a source. No cross-expression propagation added — the f-string/concat/alias recall gap (#27) stays deferred to v8.
#28: added fixtures+assertions locking the tainted subprocess/os.system AST-CMD-EXEC sink and the open(...,'w') AST-FILE-WRITE sink. Suite 1931/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
Bumps package.json, .claude-plugin/plugin.json and the README badge to
7.8.2; adds the release entry to CHANGELOG.md, docs/version-history.md,
the README recent-versions table and the CLAUDE.md highlights block.
Also fixes test pollution introduced with the ide-extension regression
suite: its temp roots used an `llmsec-jb-plugin-` prefix, and
jetbrains-parser.test.mjs asserts globally that no `llmsec-jb-*`
directory survives anywhere in tmpdir. The shared prefix made that
assertion fail depending on test order — it passed on the first full run
and failed on the next. Prefix is now `llmsec-nullmanifest-`.
npm test: 1901/1901 green, three consecutive runs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
probe-rm.mjs was self-labelled "Temporary probe — delete after
debugging". It is not a test (no node:test harness, no assertions — it
prints exit codes), it ran against a hardcoded absolute path into the
INSTALLED marketplace copy rather than this repo, and it exercised the
rm-block cases that are now covered properly by
tests/hooks/pre-bash-destructive.test.mjs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
decodeEntities guarded parseInt with Number.isFinite, which bounds
nothing: any numeric character reference above 0x10FFFF produced a
finite integer that String.fromCodePoint rejects with RangeError.
Correction to the review's framing: this does NOT break parsePluginXml's
no-throw contract. The per-field safe() wrapper catches it. The actual
defect is quieter — the affected field is discarded and replaced with
'', with only a warning. A plugin whose <name> carries one out-of-range
reference parses as name: "" while pluginId and every other field
survive, so name-based checks (JetBrains typosquat detection against the
top-plugin list) run against an empty string.
Severity is below the HIGH it was filed as: a document containing a code
point above 0x10FFFF is not well-formed XML, so IntelliJ would reject
such a plugin too — the evasion yields a plugin that does not load. The
fix is still correct and one line of logic.
- isDecodableCodePoint(): integer, >= 0, <= 0x10FFFF.
- Undecodable references are now left literal, matching how lenient
parsers treat unrecognised entities and how this same function already
treats unknown named entities.
Regression test drives parsePluginXml with hex and decimal references
just past and far past the maximum, asserts the no-throw contract still
holds, and pins that valid references, the maximum valid code point, and
named entities still decode.
npm test: 1901/1901 green (1890 + 11 new).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
stripInjection is the remote-scan indirection layer: its `sanitized`
output is what an LLM agent actually reads, verbatim, via
sanitized_content in the evidence package. It scanned two variants of
each file — the raw text and normalizeForScan(text) — but removed
matches with `sanitized.replace(match[0], ...)` against the RAW text
only.
For a match found in the decoded variant, match[0] IS the decoded
string, which by construction does not occur in the raw text. The
replace was therefore a silent no-op: the finding was reported while the
encoded payload was passed to the agent untouched. Every obfuscation the
normalizer exists to defeat — HTML entities, URL encoding, \u escapes,
hex, base64, letter-spacing, Unicode tags — reached the agent intact.
The worst case is the intended one: detection said "critical injection
found" and shipped the injection along with the verdict.
- Pass 1 redacts the whole source LINE whose own normalized form carries
the pattern. Line granularity is deliberate: decoding is not
length-preserving, so decoded match offsets cannot be mapped back onto
the original text.
- Pass 2 keeps the existing literal replacement and finding collection.
- Residual gap, made explicit rather than silent: a payload encoded
across MULTIPLE lines matches whole-text normalization but no single
line, so it cannot be attributed. Those findings now carry
`unstripped: true`. Whole-file redaction was considered and rejected —
normalizeForScan base64-decodes any long blob, so a benign asset could
blank an entire file's evidence.
- stripInjection exported via __testing, and main() is now behind the
standard isMain guard (copied from dashboard-aggregator.mjs) so
importing the module does not execute the CLI. CLI verified unchanged
against the evil-project-health fixture: 7 files, 6 injection
findings, risk_level critical.
This boundary had no direct test coverage before this commit.
npm test: 1890/1890 green (1884 + 6 new).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
The two manifest parsers disagree on how they signal failure:
parseVSCodeExtension returns a bare null, parseIntelliJPlugin returns a
TRUTHY { manifest: null, warnings }. scanOneExtension guarded only the
bare-null form, so every JetBrains failure path — lib/ missing, lib/ not
a directory, lib/ unreadable, no jars in lib/, no jar extractable — fell
through the guard and dereferenced `manifest.hasSignature`.
The resulting TypeError propagated out of scanOneExtension into
mapConcurrent, which awaited fn() with no per-item try/catch, so
Promise.all rejected and the ENTIRE ide-scan aborted. One malformed
plugin directory on disk was enough to take down the scan of every
other installed extension — including, in an audit context, a plugin
that is malformed precisely because it is hostile.
- scanOneExtension: guard is now `!parsed || !parsed.manifest`, and the
parser's own warnings are carried into the result so the reason for
the skip survives instead of being replaced by a generic message.
- unscannableExtension(): the result envelope is extracted so the early
return and the isolation belt below produce the same shape.
- Belt (defense-in-depth): the scanOneExtension call site catches per
extension and yields an unscannable result. mapConcurrent stays
generic — the isolation lives at the call site, not in the helper.
Regression test drives scanOneExtension across all four JetBrains parse
failure paths plus a no-invented-findings assertion.
npm test: 1884/1884 green (1879 + 5 new).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
Rule 4 in isFalsePositive ("test/fixture files intentionally contain
example secrets") matched /(test|spec|fixture|mock|__test__|__spec__)/i
against absPath — the ABSOLUTE path. Any directory name above the scan
root therefore silenced every entropy finding in the entire target: a
repo cloned to a CI workspace, checked out under a parent folder named
`testing`, or scanned from any path with one of those substrings
reported zero secrets and still exited status 'ok'. The failure is
silent — indistinguishable from a clean scan.
- isFalsePositive takes relPath and rule 4 keys off it. relPath was
already computed and passed down to scanFileContent; only the
suppression check was reading the wrong one.
- classifyFileContext keeps using absPath: it reads the basename
extension only, so directory names above the root cannot affect it.
- Rules 1-3 and 5-13 are untouched.
Regression test drives scan() end-to-end against temp roots named
llmsec-test-*, llmsec-spec-*, llmsec-fixture-* and llmsec-mock-*, with a
neutral-root control proving the payload is detectable, plus two
guards that genuine suppression still works (a *.test.mjs file and a
file under a relative tests/ directory).
npm test: 1879/1879 green (1872 + 7 new).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
The BLOCK rule named "Filesystem root destruction" did not block the bare
root target it is named for. The target alternation ended in a shared `\b`,
and a word boundary cannot hold after `/` or `~` at end-of-command.
Measured against the shipped pattern:
bare `/` target NOT blocked `$HOME` target blocked
bare `~` target NOT blocked `/usr` target blocked
`/*` glob target NOT blocked
swapped flags (-fr) NOT blocked
sudo-prefixed NOT blocked
Only targets whose first character is a word character ever satisfied the
assertion, so the rule caught `/etc` but not bare root. These fell through
to WARN (exit 0) — advisory only, command executed.
- Pattern: `\b` moved onto the `$HOME` alternative alone, where it is
meaningful (it ends in a word char, so `$HOMEDIR` is still not
swallowed). Dropped from `/` and `~`. Nothing else changes: `/etc`,
`/home`, `./build` behave exactly as before.
- The old test file encoded this defect as expected behaviour, with a NOTE
claiming the pattern "requires separate flag groups (e.g. -f -r, not -rf
combined)". That diagnosis was wrong — the `/etc` case blocks fine with
merged flags. Comment replaced with the real root cause.
npm test: 1872/1872 green (1865 + 7 new).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
Version bump + release notes for the auto-cleaner command-injection fix
(f083cda). Security patch only; no feature changes. 1865 tests, 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ