PLAN § v8.1.3 punkt 5. watch-cron.mjs started the orchestrator with
cwd = pluginRoot, so every watched project was outside cwd, i.e. not the
caller's own working tree, and its .llm-security-ignore / policy.json
were dropped: the user saw findings they had already suppressed. The
orchestrator now runs with cwd = the target dir (its parent for a file);
a relative target.path still resolves against pluginRoot as before.
The scope test's entropy blob is now fixed instead of random: one run
with a random blob reported zero findings for both projects, probably a
blob starting with `/` (skipped as a path, 1 in 64). Red on 002c0ba with
the fixed blob, verified.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PLAN § v8.1.3 punkt 1-3, PM decisions in order 20260923T092223Z:
- punkt 1: a `site-packages` or `vendor` segment below cwd is foreign
like `node_modules`; a config dir's `skills/` is foreign like its
`plugins/`. Safe direction: a false foreign costs an extra finding.
- punkt 2: `~/.claude` and `$CLAUDE_CONFIG_DIR` both count; a leading
`~` in the variable is expanded. Anthropic's docs (claude-directory,
env-vars) do not say whether Claude Code expands it: not verified,
so both readings are foreign.
- punkt 3: realpathSync.native canonicalizes case on a case-insensitive
volume (measured: realpathSync keeps `sub`, .native gives `Sub`), so
NODE_MODULES/x is foreign and a case-mismatched own path is own
(closes § v8.1.2 punkt 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each test fails on fd7de23:
- own-working-tree: venv site-packages, vendor/, config-dir skills/
(punkt 1); ~/.claude/plugins next to $CLAUDE_CONFIG_DIR and a leading
~ in the variable (punkt 2); NODE_MODULES case variant and a
case-mismatched parent segment (punkt 3, closes v8.1.2 punkt 4).
- watch-cron-scope: a watched project's own ignore file is honored
(punkt 5).
- av-surface (b2): no runnable base64-to-shell line with a short
command blob (addendum a; 3 hits today).
- doc-consistency: scanner-reference Knowledge Files matches knowledge/
(addendum b); ci-cd-guide makes no offline claim (addendum c).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Forgejo repository description is now the value proposition
(operator decision 2026-09-23), so README line 3 can carry it instead
of the old, drier description line. repo-standard README-DESC requires
exact equality, so the line is plain text, not bold.
repo-standard 22/22, doc-consistency 45/0, self-scan README 0 findings.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
README goes from 825 to 227 lines. The value proposition comes first
(three concrete outcomes), then install, a first-five-minutes table,
compact hook and command tables, and one section for teams and CI.
New section "Antivirus and EDR alerts": a warning at the top tells
organizations that products such as Microsoft Defender can raise
alerts, what v8.1.0 did about it (and that v8.1.1/v8.1.2 did not touch
it), that nothing has been measured on Windows, what still sits on
disk as readable text, how to report to Microsoft (managed devices via
the Defender portal, single machines via wdsi), and to email
security@fromaitochitta.com so the file can be fixed in a patch.
Corrected claims found by an independent fact-check: the standalone
CLI is not offline (npm audit, pip-audit, DNS, OSV.dev), the secrets
hook has 19 patterns not 30+, /security deep-scan takes no CI flags,
look-alike package names warn rather than block, the Linux bwrap
caveat, and the demo's 85 findings need the LLM half too. Moved to
links: scanner tables, knowledge list, playground, version history.
SECURITY.md: 8.1.x is the active line; antivirus alerts are in scope,
also on knowledge files; the dead README section references fixed.
CLAUDE.md: README section name updated.
repo-standard 22/22, doc-consistency + av-surface 51/0, full suite
2328 / 2322 pass / 0 fail / 6 skipped. Self-scan: README 1 -> 0
findings.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Version sync: package.json, .claude-plugin/plugin.json, README badge +
Recent versions row, CLAUDE.md header + v8.1.2 highlights, CHANGELOG
[8.1.2]. The own-working-tree header and CHANGELOG no longer claim that
node_modules and the plugin dir are the only install locations foreign
code lands in (independent review: Python venv, vendor/ still own) —
listed as Known open instead.
Gates on the staged tree: suite 2328 / 2322 pass / 0 fail / 6 skipped,
hooks 370/0, golden 109/7/4 61/61, av-surface 6/6, claude plugin
validate pass (1 known CLAUDE.md warning).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
checkNISTAlignment credited Govern for a .llm-security/policy.json that
merely existed. Since v8.1.0 policy-loader ignores a target's policy
outside the caller's own working tree, so a foreign target (a clone, an
installed package) earned governance credit for a file no scanner reads.
The credit now also requires isOwnWorkingTree(projectRoot).
Red first: the two foreign cases (node_modules package, target outside
cwd) failed; the own-tree known-positive passed before and after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A target with no .git of its own under cwd shares cwd's git root, so the
v8.1.1 rule alone called it own: an installed package under a repo and a
plugin-cache copy still had their .llm-security-ignore / policy.json read.
Chosen (PM rule, order 20260922T192716Z): a target is additionally foreign
when the path from cwd to it has a node_modules segment, or when it lies
under $CLAUDE_CONFIG_DIR/plugins (default ~/.claude/plugins). Because those
are the two concrete places foreign code lands under a user's working
directory, the failure direction is safe (foreign means more findings), and
a general "no .git of its own" rule would shut out ordinary subdirectories
of the caller's own repo. git archive exports stay indistinguishable from
own subdirectories; documented as a known limit in the module header.
Red first: 6 unit + 6 orchestrator assertions failed before the fix;
known-positives (plain subdir, workspace package dir, rest of config dir)
passed before and after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Version sync: package.json, .claude-plugin/plugin.json, README (badge +
Recent versions), CLAUDE.md (header + highlights), CHANGELOG [8.1.1]
(incl. Known open from the pre-tag review: a target with no .git of its
own under cwd — node_modules, plugin-cache copy, tarball — still counts
as own; not a regression, the v8.1.0 rule honoured it too).
Remaining `8.1.0` strings are history rows and dated code comments.
Gates on the staged tree: suite 2306 / 2300 pass / 0 fail / 6 skip,
golden 109/7/4 61/61 unchanged, av-surface 6/6, plugin validate pass.
Windows/Defender quarantine: not measured (operator, 22.09).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same choice as the README (previous commit): the 383 / 441 figures were
false one commit after they were written (git-forensics' 50-commit window).
Keeps the stable figure, 58 findings with the file, re-measured on the
release commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chosen per the PM order: the README no longer states the suppressed count
or the no-ignore finding count. Both drift by one per commit because
git-forensics looks at the last 50 commits (measured in v8.1.0 S3c), and a
number that is false after the next commit is worse than no number; the
window is correct behaviour, not a bug. Kept: verdict, score and finding
count with ignore on a fresh clone (stable across S3b-S4), plus the
no-ignore verdict. Re-measured on the release commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The frontmatter description still said "GitHub repos"; git-clone.mjs has
accepted HTTPS on any host since v8.1.0 (SSH stays GitHub-only). The body
was already right. First wording ("HTTPS on any host") added a new
permission-mapper finding — `http` is a network-intent keyword — so the
description avoids the word. Measured on a temp copy of commands/scan.md,
old vs new: identical finding set (6), TRG ok/0 and SIG ok/0 on both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isOwnWorkingTree() treated any target at or below cwd as own, so a foreign
clone under cwd (cwd = $HOME, a vendor clone in a project) still had its
.llm-security-ignore, policy.json and custom SIG rules read. The target must
now also share cwd's git root: nearest ancestor with a `.git` entry (dir for
a clone, file for a submodule/worktree), or none for both. No git spawn.
tmpdir stays foreign. Chosen per the PM order: it is exactly the line
between "my repo" and "something I fetched", and the failure direction is
safe (foreign => config ignored => more findings, never fewer).
Red first: tests/lib/own-working-tree.test.mjs 4 fail / 5 pass (the 5 are
known-positives), tests/scanners/nested-clone-scope.test.mjs 5 fail / 4 pass
on the old rule. Green after; reverting the git-root comparison turns 9 red.
Suite 2306 / 2300 pass / 0 fail / 6 skip; hooks 370/0 (implicit root
untouched); golden 109/7/4, 61/61.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The "--fail-on via policy.json" block wrote a policy.json into a tmp root it
never scanned and passed --fail-on on the CLI, so ci.failOn / ci.compact had
no test at all. The rewrite scans a copy of grade-a-project as the process's
own working tree (the only place policy.json is honored since v8.1.0):
WARNING with 0 critical exits 1 by default, and exits 0 only if
`ci.failOn: 'critical'` is read from the policy. Measured: each branch of
main() mutated away turns exactly its own test red (fail 1), real code 4/4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
loadPolicy() read .llm-security/policy.json from whatever root it was
given, and every scanner passes the SCANNED TARGET: scan-orchestrator
(policyRoot = resolve(args.target)), entropy-scanner (thresholds and
suppression patterns), signature-scanner (sig.custom_rules_path and
enabled_families), trigger-scanner (phrase lists) and ast-taint-scanner
(enabled, python_path). A foreign/cloned target could raise its own
entropy thresholds, disable SIG families, supply its own SIG ruleset or
name the interpreter the AST scanner spawns — configuring the scan of
itself. Same defect class as S3b's .llm-security-ignore fix.
Chosen: move isOwnWorkingTree() to scanners/lib/own-working-tree.mjs (one
copy, reused by the orchestrator's ignore-file check) and make
loadPolicy() refuse an EXPLICIT root that is not the caller's own tree —
defaults plus one stderr line, same form as S3b — because one rule in one
function covers every scanner and a future call site cannot forget it.
The IMPLICIT root (CLAUDE_PROJECT_ROOT/cwd, what every hook uses) is the
caller's own project by construction and is read as before.
entropy-scanner's calibration.policy_source no longer reports an ignored
file as its source.
New tests/scanners/policy-scope.test.mjs was red on 0d37f5a (foreign
target: entropy finding silenced, custom SIG rule loaded, findings differ
from the same tree without policy.json, no stderr line) and is green now;
its own-tree scenario (known-positive) is green before and after. The 15
existing policy tests that placed own-tree fixtures under os.tmpdir() now
use tests/helpers/own-tree.mjs (fixture under $HOME, cwd set to it).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A foreign or freshly-cloned scan target could ship its own
.llm-security-ignore with `**` and silently turn a real finding into
ALLOW (0 findings) -- only the undocumented envelope.suppressed count
revealed it. Same defect class the v8.0.0 commons-root fix closed: a
hostile repo should never be able to empty its own detection output.
Valgt X fordi Y: honor the ignore file only when realpath(target)
equals or is nested under realpath(process.cwd()), and never when the
target resolves under os.tmpdir() -- defense-in-depth for the case a
caller's own cwd happens to sit under tmpdir, matching where
git-clone.mjs materializes clones. A foreign target with an ignore
file now gets one stderr line saying it was not honored (loud, not
silent).
Red test first (tests/scanners/ignore-file-scope.test.mjs): a known
HIGH entropy finding (random-bytes base64, built at test time) that
must survive on a foreign/cloned target and stay suppressed on the
caller's own working tree. Verified red on b6edfa3, green after the
fix. Self-scan invariant reverified on three independent fresh clones
today: WARNING 61/100, 58 findings, 382 suppressed -- identical on
patched and unpatched clones, so the own-tree path is unchanged.
Suite 2276/2270 pass/0 fail/6 skip (jetbrains-parser after-hook flake
listed, not counted -- known). Golden unchanged (109/7/4, 61/61).
av-surface 6/6 green, unchanged.
Open, not fixed this session (scope was the ignore file only):
policy-loader.mjs's loadPolicy() also reads .llm-security/policy.json
from the scanned target, independently re-read by entropy-scanner.mjs
and signature-scanner.mjs -- same defect class, not yet measured red.
Logged in PLAN.md S3b for a follow-up order.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pre-bash-destructive.mjs held the last three probe (e) hits: the
pipe-to-shell and T8 rule names and one comment. The names are now
concatenated from fragments and the comment is a description; the
string the hook prints is unchanged, and tests/golden/reference-run.json
pins it. Patterns untouched.
Applied by the operator (git apply): the installed plugin's path guard
blocks Edit/Write on this file and asks the user to make the change.
Measured: hook stderr for a pipe-to-shell and a T8 command is
byte-identical before/after (cmp); av-surface a=0 a2=0 b=0 c=0 d=0 e=0;
suite 2269 / 2263 pass / 0 fail / 6 skip, exit 0; golden unchanged
(109/7/4, reference run 61/61).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The AV section added two suppressed findings to README.md itself; the
remaining 58 (WARNING 61/100) did not move. Re-measured on a fresh clone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.llm-security-ignore: MEM/SIG/UNI rules added only for by-design
findings, one explaining line each — the vendored conformance corpus,
the Cyrillic confusables in injection-patterns.mjs and the homoglyph
test inputs, the changelog's quoted homoglyph, the grade-F posture
fixture, and the miner names in the supply-chain blocklist. Not
suppressed on purpose: MEM on CLAUDE.md (the file a poisoning would
target) and AST/TRG (no by-design findings once the ast-scan fixtures
left the disk). Header prefix list completed (14 scanners) and the
"150" replaced by the measured count.
Self-scan, fresh clone of HEAD (node scanners/scan-orchestrator.mjs):
before: BLOCK 80/100, 77 findings (1C 9H 29M 2L 36I), 362 suppressed
after: WARNING 61/100, 58 findings (0C 5H 15M 2L 36I), 381 suppressed
without the ignore file: BLOCK 95/100, 439 findings (unchanged)
On `.` (working tree incl. local-only files): BLOCK 80/100, 80
findings -> WARNING 64/100, 61 findings.
README:
- Self-scan: the measured numbers replace "0 findings (ALLOW)".
- The demo commands materialize the tree to a temp dir (the form the
demo README uses); no evil-project-health/ path left in README.
- New "Antivirus false positives" section + TOC entry: what the repo
contains, why a classifier can still flag it, restore from
quarantine, Microsoft's submission form, Microsoft's own advice
against exclusions, a sparse-checkout recipe. Every Defender claim
checked against learn.microsoft.com; the recipe run live against
Forgejo (partial clone supported; 285 of 677 files on disk). The
meaning of "!ml" is not claimed — Microsoft documents "!" suffixes
only as internal indicators.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scanners/lib/git-clone.mjs accepted only github.com, so the README's
Forgejo example (and /security scan on any Forgejo, Codeberg or self-hosted
URL) failed validation; scan.md and plugin-audit.md also routed only
https://github.com/ to the clone path.
Chose generic HTTPS over adding one host to an allowlist because there is
no documented security reason for the host check. Read before choosing:
git log --follow (import + E12 only), the file's comments, CHANGELOG v2.4.0
("GitHub repo URL support", feature framing), security-hardening-guide §2
and §7 (the sandbox is the defence against filter/smudge drivers), and
review-2026-06-20, which lists "vsix-fetch (HTTPS host allowlist)" as a
protection but no host check for git-clone. What protects a clone is the
OS sandbox, GIT_SANDBOX_CONFIG and GIT_SANDBOX_ENV, for every host alike.
Measured: with GIT_SANDBOX_ENV, git reads 0 config lines outside a repo;
without it, osxkeychain comes from Xcode's system gitconfig. So no stored
credential is offered to an unknown host.
The shape stays strict: https only, no userinfo, host starts and ends
alphanumeric (no leading -), optional port, exactly owner/repo, no query
or fragment. SSH stays GitHub-only: ssh uses the user's own keys and
~/.ssh/config, which the git environment does not isolate.
Tests (red first): Forgejo and any-host accept 2 failing -> green; guards
for http, userinfo, leading -, extra path, query, ext:: and file:: and
non-GitHub SSH hold. The old "rejects non-GitHub URL" case still passes
(it has no owner/repo) and is renamed to say so. Regex timed linear
(< 1 ms at 200k chars). Live: a sandboxed clone of
https://git.fromaitochitta.com/open/llm-security.git exits 0 at 5208420,
cleaned up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The five Python taint fixtures under tests/fixtures/ast-scan/ move into
tests/helpers/payload-trees.mjs as the `ast-scan` tree, written to a temp
dir by the test like the three S1 trees. av-surface probe (d) gains the
directory: 5 -> 6 trees.
PM decision (S3 order): creds-net.py is os.environ -> requests.post, the
exfiltration shape AV classifiers are trained on; one rule, "no
payload-shaped runnable file on disk", is easier to defend than an
exception. The .py files match no SIG rule and are stored as plain lines.
Deviation from the order: it named three files (sinks, creds-net, scope);
the directory holds five (also reassign.py, sentinel.py). Gating the
directory means all five move.
Measured: (d) red first, d=1 (5 files) of 6 trees, then 0. sha256 of all
five on-disk files taken before git rm; payload-trees.test.mjs asserts
them, mutation-checked (one byte in creds-net.py -> red, restored
byte-identical). ast-taint-scanner.test.mjs 12/12 with the materialized
tree, both before and after the files were removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The seven-category signal lists in agents/skill-scanner-agent.md move to
knowledge/skill-threat-patterns.md § Detection Rules. The agent keeps its
procedure, severity table, verdict logic and output format, plus a pointer,
and now says to report an unreadable knowledge file instead of scanning from
memory (a remembered subset reports clean for rules it never applied).
Chose the existing file over a new one because every command that invokes
the agent (scan, audit, clean, plugin-audit) already passes
<plugin-root>/knowledge/skill-threat-patterns.md explicitly; a new file would
have needed four command edits to reach the agent at all.
Why (v8.1.0 AV surface): a clean SKILL.md was quarantined as
Trojan:Script/Wacatac.H!ml; a quarantine on agents/*.md breaks the installed
plugin, not just a clone. Move measured lossless: the only line-level
differences are the two intended rewrites and headings.
Also in this commit, rewritten as descriptions or with a <shell>
placeholder for the interpreter (no technique removed):
- posture-assessor-agent: hook-coverage item and override phrases
- deep-scan-synthesizer-agent: example decoded message
- commands/red-team.md: scenario table cell
- knowledge/*.md: 15 runnable download-into-shell one-liners, the
"Decodes to" line first; fenced examples keep their exact shape with
<shell>, prose and tables become sentences.
Probe (e): 8 -> 3 (the three left are hook-script lines behind the
path guard). knowledge: 17 -> 2 (attack-scenarios.json, the red-team
simulator's input; left on purpose). claude plugin validate . passes;
agent frontmatter untouched, all six agents parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New probe (e) over agents/**, commands/** and hooks/** — what a Claude Code
session loads at the user's end, where a quarantine breaks the installed
plugin, not just a clone. It fails on a contiguous literal matching the SIG
reverse-shell/webshell/miner rules, a download piped into a shell, or a
base64 decode piped into a shell. Raw text, comments included, per line, so
every hit names file:line.
knowledge/** is measured by the same rule and reported in the diagnostic as
its own number, not gated in S3.
Measured red before any change (v8.1.0 S3, 2026-09-22):
e = 8 hits in 6 of 36 files; knowledge = 17 hits of 22 files.
No SIG rule hit anywhere in the four directories; validated against built
known positives (all three families match).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v8.1.0 S2. security-assessment.md (two blobs, one split, plus the decoded
exfil command) and the base64 pipe-to-shell one-liner in
knowledge/skill-threat-patterns.md now describe the payload rather than
reproduce it. The entropy path-suppression test builds its 84-char blob
from 12-char fragments, so no contiguous decodable blob sits on disk.
av-surface: b 5->0. a=0 a2=0 b=0 c=0 d=0 - the probe is green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v8.1.0 S2. examples/poisoned-claude-md/fixture/ (CLAUDE.md with a base64
pipe-to-shell blob, plus an agent file) is now fixture.archive.json in the
same format as the demo archive; run-memory-poisoning.mjs materializes it
into a temp dir and deletes it on exit. README shows materialize-then-scan.
payload-trees.test.mjs asserts byte identity for both archives.
av-surface: b 6->5, d 1->0. Walkthrough output identical before/after
(6 pass, 0 fail, 18 findings).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v8.1.0 S2. examples/malicious-skill-demo/evil-project-health/ (7 files,
30 Unicode Tag chars, a base64 exfil blob) is now one archive,
evil-project-health.archive.json: rot13 text, every codepoint above U+007E
stored as a number, sha256 of each retired file recorded. materialize.mjs
writes it to a temp dir (CLI prints the path); run-demo.sh materializes
and deletes it itself; the six scanner tests that scanned the tree use it.
payload-trees.test.mjs asserts byte identity (mutation-checked).
av-surface: b 8->6, c 1->0, d 2->1. Demo 13/13 before and after. All
scanners report identical findings except git-forensics: it used to scan
this repository's own history (21 findings, none about the demo) and now
reports skipped in a temp dir, which git.test.mjs already accepts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The S0 rule fired on 'http' anywhere in decoded text and flagged an SVG
data URI (xmlns) in the hyperframes-like skill-scan fixture - markup, not
a command. PM decision for S2: the decoded text must start with a command
word or contain a pipe into a shell or $(. Measured before any blob was
removed: 8 blobs/7 files -> 8 blobs/6 files; SVG out, one split-payload
fragment in security-assessment.md newly caught, all 7 command blobs kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v8.1.0 AV surface, session S1. The three poisoned fixture trees
(signature-scan/poisoned, memory-scan/poisoned-project, trigger-scan/poisoned)
are deleted from disk and materialized into a temp dir by the new
tests/helpers/payload-trees.mjs. SIG-matching strings are assembled from
fragments, the zero-width carrier comes from String.fromCodePoint, and every
file carries the sha256 of the retired on-disk bytes;
tests/helpers/payload-trees.test.mjs asserts the materialized trees are
byte-identical (mutation-checked: one changed byte fails it).
Inline payload literals in signature-scanner, signature-scanner-custom-rules
and e2e/scan-pipeline are fragmented the same way; the literal U+200B in
attack-simulator, auto-cleaner-rce and auto-cleaner-traversal is replaced by
String.fromCodePoint(0x200B).
av-surface: a 3->0, a2 3->0, c 5->1, d 5->2, b 9->8 (webshell-b64 blob gone).
What remains (c=1, d=2, b) is under examples/** or is (b), both S2.
Suite 2261 / 2252 pass / 3 fail (av-surface b, c, d only) / 6 skip.
Golden output identical before/after (109/7/4, 61/61).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(a) keeps .mjs/.js out of its denominator, so contiguous payload literals in
test sources were ungated. a2 walks tests/**/*.{mjs,js} (tests/golden/**
excluded) and matches the webshell/reverse_shell/cryptominer SIG rules on raw
text. Measured red: a2=3 (scan-pipeline e2e, signature-scanner,
signature-scanner-custom-rules) of 116 test sources.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests/av-surface.test.mjs walks git ls-files and fails on four surfaces a
clone or the plugin cache puts on a user's disk: (a) own SIG payloads
(webshell/reverse_shell/cryptominer) in a runnable file type, (b) base64
blobs decoding to a shell command, (c) Tag/zero-width/bidi carriers in text
files outside the conformance corpus, (d) known payload trees.
Red on purpose (order S0): it is the failing test for S1-S3. Measured on
this tree: a=3 b=9 (8 files) c=5 d=5. Chosen definitions are documented in
the file header: .mjs/.js/.cjs/.json and scanners/commons/** are outside (a)
so the untouchable signature tables stay out of a gate that must reach
zero; binaries are outside (c) because decoding PNG/WOFF2 as UTF-8 yields
the codepoints by chance.
PLAN.md is local-only, so .gitignore now names it. No production code, no
fixtures, no signature tables touched; golden baseline unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 6 of the approved v8.0.0 plan. HEAD was 45 commits past v7.8.3 with
every version-bearing file still reading 7.8.3 and no v8 tag anywhere.
Version sync: plugin.json, package.json, README badge, CLAUDE.md header.
CHANGELOG: [Unreleased] becomes [8.0.0] - 2026-09-18. It covered 3 of the
user-visible commits since v7.8.3; the other 18 fix/feat/refactor commits
are now recorded from their commit bodies - the commons extraction (five
tables, scanners/commons/ subtree, knowledge/signatures.json removed),
conformance spec 1.1, the golden-gate coverage relabel, and eight fixes
(script-tag recall, whole-table ReDoS gate and its own 8-of-45 blind spot,
Berry and nested lockfile-v1 misses, per-occurrence strip attribution,
legacy OpenAI keys, compileRules coercion, SIG self-flag, SARIF tags).
Counts verified against the loaded modules: 83 injection patterns, 19
secret shapes, 7 SIG rules.
SECURITY.md supported-versions table said 7.3.x Active; now 8.0.x Active,
7.0.x - 7.8.x best-effort. README gets the 8.0.0 row; CLAUDE.md gets the
v8.0.0 highlights and the test floor moves 2045+ to 2250+.
Tests: 2253 / 2247 pass / 0 fail / 6 skipped, exit 0, measured on HEAD
before the edits. The post-edit full run had 3 red, all timing ceilings,
under load average 60 from unrelated jobs on the machine; each passes
alone (460 ms of 1000, 370 ms of 2000, 2127 ms) and doc-consistency is
45/45 alone. The diff is docs and version strings only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
D4-C, order 20260821T091412Z-1428311869-from-.claude. Measured by .claude
against the org 21.08: this repo has the org's most mature deprecation
PRACTICE and no stated policy. An adopter cannot read a CHANGELOG row and know
it applies next time -- what D4 measures is what can be EXPECTED, not what
happened last time.
Form copied from open/portfolio-optimiser SECURITY.md "Deprecation Notice
Period"; the numbers are this repo's own. Both floors are kept because the
release count alone is toothless here: measured from the README release table,
7.5.0 and 7.6.0 shipped a day apart (2026-05-05 -> 2026-05-06), so "at least
one minor release" without a day floor would permit a next-day removal. 30 days
is comfortably holdable -- the one precedent ran 112 days (v7.3.0 released
2026-05-01 per commit 4637139; v8.0.0 still unreleased at 2026-08-21).
The precedent is worded as staged, not shipped: git describe reports
v7.8.3-43-ge97c232 and the removal sits under CHANGELOG [Unreleased], so
claiming v8.0.0 "removed" them would have been a claim about an untagged
release. Only the four env-vars are cited; CHANGELOG dates riskScoreV1's
@deprecated to v7.0.0 while README says v7.3.0, and that disagreement should
not be load-bearing in a policy.
No promise the project cannot hold: it is stated as a notice period and not an
SLA, with no backporting, and the one exception for security-critical removal
is named explicitly. README's Project scope now points at the section so an
adopter arriving from the deprecation bullet lands on the rule.
Docs only; the Iron Law does not fire. doc-consistency gate 45/45 pass, exit 0.
Full suite unchanged vs HEAD: 2253 tests / 2247 pass / fail 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7TAHJfxBoCm9YwhMWm6y5
D2b, order 20260821T041145Z-8516817669-from-.claude. Measured by .claude
against the org: llms.txt was missing in 23 of 23 org repos (probe verified
both ways in the same call chain: README.md -> 200, a nonexistent path -> 404).
The operator decision of 21.08 creates it in 6 repos -- the catalog plus the 5
artifacts with their own run path -- and deliberately not in the 12 plugin
repos, whose entry surface is already uniform.
The reader is an AI agent already standing in this repository that needs one
deterministic place to find what this artifact is and how it is started. Not
crawlers: measurements show the major agent crawlers skip /llms.txt and read
the HTML directly, so that would not be a reason that holds.
Follows the llmstxt.org convention: H1, one blockquote summary, then H2
sections holding lists of markdown links, with "Optional" naming the links an
agent may skip. Deliberately minimal -- llms.txt is a convention, not a
ratified standard, and a W3C proposal from June 2026 diverges from the
Answer.AI spec, so a format change should stay cheap.
The install block is quoted byte-identical from README.md's first code block
(verified with diff against README.md:67-68), not rewritten. A rewritten
variant would be a second source of truth, not an entry point.
Docs only; the Iron Law does not fire. Full suite unchanged vs HEAD:
2253 tests / 2247 pass / fail 0, exit 1 from the known jetbrains-parser
after()-hook assertion, measured identically on a stashed HEAD tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7TAHJfxBoCm9YwhMWm6y5
buildRules() and toSARIF() wrapped a multi-mapping f.owasp string
(e.g. 'MCP03, MCP06', emitted by mcp-live-inspect.mjs and
ide-extension-scanner.mjs) as a single-element tags array instead of
splitting it, silently dropping the second OWASP mapping in SARIF
output. Added owaspTags() helper; test asserts a multi-entry tags
array for both rule and result properties.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WbQmoLxcAAsJAkFxBeCx6z
Census (AAA+ round 5) measured 4 FAIL + 1 unmeasured across L55, L206, L207,
L402, L403: strings that look like clickable URLs but are template leftovers
(https://repo-url resolves nowhere; https://github.com/org/repo 404s). L55 is
in an [!IMPORTANT] callout selling the plugin's primary CLAUDE.md-poisoning
defense, so its one example command couldn't be run as written.
First example in each command block now uses a real, resolving self-demo URL
(https://git.fromaitochitta.com/open/llm-security), making the README a
working quickstart instead of an illustration. Remaining variants use an
unambiguous <owner>/<repo> placeholder that doesn't read as a real URL.
L14 (SSRN, 403) and L224 (sandbox-exec docs, 503) are untouched — bot-blocked
or transiently unavailable is not evidence of a dead link.
Order: 20260818T114340Z-186999443-from-.claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCinQGjXauCrGBqKoFNs4u
25 H2 sections, no TOC — long README friction fix per AAA+ B-axis order 32.
Anchors validated against actual headings (see toc_check.py known-positive
control); flat list, mirrors config-audit/README.md pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
new RegExp(pattern, 'i') never throws when pattern is a truthy non-string
(e.g. an object) — it ToString-coerces it first. The truthy-only guard
(`!rule.pattern`) let such a rule through as a real, compiled RegExp,
bypassing the try/catch meant to drop malformed rules. Worse than a silent
drop: `new RegExp("[object Object]", "i")` is parsed as a character class
over o/b/j/e/c/t/space, so the "dropped" rule instead becomes a
near-universal false-positive matcher. Same path for the built-in
commons-backed ruleset and the operator's sig.custom_rules_path (both
route through compileRules).
Fix: require typeof rule.pattern === 'string' before compiling. Verified
the golden dump pins no rule that exists only because of this coercion —
it regenerates byte-identically after the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019iWrdLSVgRhgPzTB29rQGD
Scanning this repository with the SIG scanner produced 7 findings, 4 of them
on our own detection data: scanners/commons/CHANGELOG.md and
scanners/commons/signatures/malware-signatures.json. The ruleset that describes
xmrig and webshells is, byte for byte, a document containing those strings, so
the engine matched it as malware. EXCLUDED_PATH_RE already carried
knowledge/, tests/, docs/ and node_modules/ for exactly this reason; the
vendored commons arrived in v8 Phase 5 (bbada84) without being added.
One alternation branch closes it. Tests first: two cases added to
describe('signature-scanner: path exclusions'), both verified red against the
real scan() entry point before the regex changed.
Stated plainly, because it is a real cost and not a technicality: the branch is
`scanners\/commons` behind the existing `(^|\/)` prefix, so it matches that
two-segment path ANYWHERE in a target's relative path, not only at its root. A
webshell planted at vendor/scanners/commons/shell.php in a hostile repository is
therefore invisible to SIG. The second new test asserts that blind spot
deliberately, so it can never be discovered by accident. It is accepted because
anchoring at ^scanners/commons/ would miss the same payload one directory
deeper while re-opening the self-flag whenever the plugin is scanned from a
parent directory. TRG, AST, entropy and supply-chain still read these files;
only SIG identity-matching is blinded.
scanners/lib/supply-chain-data.mjs is NOT excluded. Its finding is a true
positive against real blocklist data.
Measured before: 7 findings. After: 3 (2 on STATE.md, 1 on
supply-chain-data.mjs). signature-scanner.test.mjs 23/23; custom-rules + e2e
54/54; golden-baseline 8/8 with suite-counts.json untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBJVYzwch63Rpk1hii1cNM
Fifth and last consumer swap of v8 Phase 5 step 4. The seven known-bad-identity
signatures stop living in knowledge/signatures.json and are built from the
vendored commons artifact signatures/malware-signatures.json instead.
Measured before the swap over all seven positions -- id, family, severity,
pattern, description, provenance, key order, and recompilation identity under
the engine's unconditional `i` flag: zero divergences over 56 checks, in order.
The commons copy was extracted from this repository's own file at b0de0ca and
had not drifted.
knowledge/signatures.json is REMOVED rather than left in place. Keeping it would
have left two files spelling one table with nothing gating the drift, and its
golden `file:` pin would have gone on passing while pinning bytes no scanner
reads -- a gate reporting success without running. The pin is replaced by a
walked-module anchor over SIGNATURE_RULES, which is strictly stronger: the pin
covered the bytes on disk, the walk covers what `new RegExp` made of them.
Golden diff was exactly that and nothing else: 7 ADDED, 1 REMOVED, 0 CHANGED
(102/7/5 -> 109/7/4), each added source verified equal to the recompiled commons
pattern.
compileRules() moves into the new lib module and is exported, so the built-in
ruleset and the operator's sig.custom_rules_path path keep one implementation
rather than two copies of the defaulting logic.
Coverage by construction, not by memory: the probe table in the scanner test is
asserted against the LOADED ruleset, so a rule commons adds cannot arrive
without an end-to-end probe. Mutation of the vendored JSON fires in three
directions -- under-match (xmrig alternative dropped) reddens two scanner tests
plus golden; over-match (webshell rule widened to a bare `shell`) reddens the
clean-fixture false-positive probe plus golden; reorder reddens the declared-
order test plus golden.
Loud failure is contract: an unresolvable commons writes one line to stderr
rather than silently disabling known-malware detection, and never throws.
Suite 2247 / 2241 pass / 6 skipped / 0 fail. suite-counts.json untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151x4FVg9Mn55C2LvHLpHKo
The 19 fixed credential shapes in pre-edit-secrets.mjs were regex literals;
they now come from signatures/secret-egress.json in the vendored commons via
a new scanners/lib/secret-egress.mjs. Policy-injected custom patterns (entries
20+) are unchanged and still appended by the hook.
Measured before the swap, not assumed: all 19 positions compared for order,
name, regex source and flags, plus recompilation identity, against the literal
table sliced out of the module text. Zero divergences. Commons had reported
the same result; that was their measurement, so this one was run anyway.
STATE's expectation that the golden gate would go red on both table records
and file sha256 was wrong: pre-edit-secrets.mjs is in neither PINNED_FILES nor
WALKED_MODULES, so the table had no golden coverage at all and the swap moved
nothing. Rather than leave the vendored data with only behavioural coverage,
secret-egress.mjs joins WALKED_MODULES — walked, not pinned, since it inlines
no regex of its own. Golden diff was 19 ADDED, 0 CHANGED, 0 REMOVED, each
source byte-identical to the pre-swap literal; re-blessed. suite-counts.json
untouched.
Tests: coverage is derived from the loaded table, so an entry commons adds
cannot arrive without an end-to-end probe. All 19 now block through the real
hook and are asserted by label, which also pins the ordering contract (a
Bearer-wrapped JWT must report as the header). Mutating the vendored JSON
fires in both directions plus reorder: under-match (AKIA quantifier) reddens
3 hook tests + golden; over-match (Anthropic key truncated to its prefix)
reddens the false-positive probe + golden; moving the JWT entry ahead of the
Bearer entry reddens the ordering test.
Suite 2231 tests / 2223 pass / 6 skipped. The two parallel-run failures
(pre-compact size-cap, benchmark) pass alone — the known timing flakes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGMv5ZTUhVzZtCCwRrNZG5
catalog found commit 0b795b1 sitting on `feat/add-ms-ai-architect` in the
marketplace forge -- a monorepo-path docs commit
(`plugins/llm-security/README.md`) that was only partially carried across
the repo split. Their read was that the CVE reference and
`transfer.fsckObjects` were rewritten into main independently while the
Windows half was never brought over.
Verified here before acting: `AppContainer`, `Windows Sandbox` and
`AppArmor` each grep 0 in our README and 0 at the published v7.8.3 tag,
while `CVE-2024-32002` and `transfer.fsckObjects` are present. Partial
transfer confirmed, not a deliberate drop.
Recovers the three missing pieces:
- the per-platform sandbox matrix, including the Ubuntu 24.04+ AppArmor
caveat that explains why bwrap fails there
- the Windows options table (Windows Sandbox / Docker Desktop / WSL2 /
AppContainer) with isolation level and requirements
- the note on why Node's --permission model does not apply: it restricts
fs access within the Node process and does not sandbox child
processes, and git is a separate OS process
The layer table already in main is kept as-is -- it postdates the stranded
commit and is more accurate than the version there. Only the one-line
Windows sentence is replaced.
All 8 external links verified live (HTTP 200) rather than copied forward
on trust.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJxU3xuwfMq8W1mxtiGhLk
Removes this repo's GOVERNANCE.md and repoints its one reference to the
canonical file in repo-standard, both in the same commit so no
LINK-INTERNAL-MISSING window opens between the two.
Verified here rather than taken from the census message: the canonical
URL returns HTTP 200, md5 3df3603325d3d6937fd560c3f67b5a5d, 131 lines --
matching org-ops' measurement exactly -- and a diff against our copy
shows only the generalising "plugin"/"marketplace" -> "repository"/
"organisation" wording, same 131 lines, no content removed.
Our copy was byte-identical to the stated baseline
(md5 736fc9d6af84fbd83c9cc7f860d8c8b7). Grep confirms README.md:5 was
the only reference in this repo, matching the measured site list.
Nothing gates on the file for us: GOVERNANCE.md is in required_files for
the catalog class only, and this repo's class is plugin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJxU3xuwfMq8W1mxtiGhLk
The v8.x-A whole-table gate times every exported pattern against a corpus
of 8 hand-written units and asserts its own coverage -- but the assertion
`covers every exported pattern` guards the pattern LIST, not the input
corpus. A pattern is only measured if some unit happens to carry its
leading literal; otherwise it fails on the first character and reports
green having measured nothing.
Measured on the pre-swap tables: 37 of the 45 prefix-bearing patterns
were never reached, including BOTH quadratic hybrid-xss rows this gate
was believed to cover. `<script ` and `<iframe ` appear in no unit, so
the two rows commons independently measured as quadratic ran their
literal-prefix check and stopped. Same defect class as all of v7.8.2:
reported success without running.
Hand-writing 37 more units does not fix it -- it re-arms the same trap at
the next pattern. Class 3 derives each attack unit from the pattern's OWN
literal prefix, so coverage is a function of the table rather than a list
someone must remember to extend. 64KB rather than the 512KB read cap for
the class-1 reason: a quadratic pattern met at 512KB stalls the run for
minutes instead of failing it.
Proven to fire, both directions, against the vendored file:
- gate written first, pre-swap: RED, naming script-tag 1429ms and
iframe-src 1161ms against a 150ms budget (exit 1)
- post-swap: GREEN, 17.7ms for all 45 probes (exit 0)
- vendored JSON mutated back to [^>]*: golden AND ReDoS gates both exit 1
- vendored JSON corrupted: golden exit 1, conformance 3 fail
- restored: all green
Clean-table margin at 64KB is ~700x: worst legitimate pattern 1.66ms.
Carried with the commons v0.4.3 subtree pull, which is what makes the
gate passable. v0.4.0 was the tag commons announced; v0.4.1-v0.4.3 came
after and touch no data table -- lexicon 0.8.0 and secret-egress
0.3.0/19 are identical across all four -- so v0.4.3 was taken for the
conformance manifest correction (302625e) they sent separately.
Golden re-blessed after a post-by-post diff: exactly 2 changed records,
both [^>]* -> [^><]*, 0 added, 0 removed, reference run 61/61 unchanged.
The file-sha256 layer did NOT move, contrary to the note in STATE: it
pins scanners/lib/injection-patterns.mjs, which has held no literals
since be14867. The vendored lexicon is covered by the regex layer only.
The script-tag tripwire pinned the old form and fired correctly. Updated
to the v0.4.x form and widened to the iframe row, which had no tripwire
while it was quadratic -- which is why nobody had named it.
Full suite 2193 pass / 6 skipped. The one red is the documented
pre-compact size-cap timing flake; passes alone (exit 0), as do
attack-simulator and the gate itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJxU3xuwfMq8W1mxtiGhLk
7ce0ba7 docs(carriers): the third verdict exists, and publishing an alias is what takes it away
302625e fix(conformance): the tag carrier has no output: label, and our blocker claimed it did
fe5e6b2 docs(conventions): the merge button is off for a reason, and the reason now lives in the repo
2d86151 fix(divergence): our own iframe number read 3x low, and the reported cause was not the cause
daa7ba4 release(0.4.0): two values moved by two mechanisms, and the difference is the release
2eee7e1 feat(lexicon): both unbounded rows narrow to [^><]*, and the mechanism is new here
d467324 feat(signatures): the staleness we disclosed is closed by reading the module, not the message
4187715 docs(divergence): our own form has a number now, and it is quadratic
0e765a0 docs(security): the attack surface here is data, so the report route had to say where a wrong entry gets fixed
d96fbbf docs(divergence): the span row had one witness; llm-security measured five more
git-subtree-dir: scanners/commons
git-subtree-split: 7ce0ba706cadd032ec3c16622dcfdb5ce4dc32d6
Closes the gap STATE has been carrying since e1511f9. Section 1.1's runtime
behaviour has been correct since then -- the third verdict is real, the
declared set is one constant with two uses -- but what we PUBLISHED was a
console.log summary: the right facts in a format only its author could
parse, which is most of what section 1.1 exists to prevent. Commons
shipped a shape for it in v0.3.0
(schema/conformance-declaration.schema.json 0.1.0), so the artifact can
now exist.
Every field is counted from the run rather than restated. The cases record
their own verdict as they execute, keeping `failed` (ran and disagreed)
apart from `error` (could not run) on exactly the distinction section 1
turns on. `commons_commit` is read out of the subtree-pull subject in our
own history rather than transcribed into a constant that would drift at
the next pull, and it refuses to publish a coordinate it cannot determine
-- a fabricated commit is worse than no declaration. The artifact is
gitignored: a committed declaration keeps asserting what was true once,
and nothing makes it wrong out loud when it stops being.
Validated once against the vendored schema with a real 2020-12
implementation: VALID, and the validator proven discriminating by six
negative controls it rejected (dropped zero-count, unknown key,
out-of-enum source, non-integer count, missing enumeration, malformed
case id). Continuous validation would mean a Python dependency in a suite
that has none, so what stays is the cheap half that actually drifts -- the
two key sets, asserted exactly.
TWO DEFECTS FOUND BY MUTATING THIS GATE, both in its own first draft:
1. It lived in `after()`. Measured on Node 25.8.2: an assertion that fails
in an after hook prints under "failing tests" and marks the suite red,
but leaves `fail 0` and exit code ZERO. `npm test` and CI would have
read a falsified declaration as green. The gate against "reports
success without running" was itself reporting success without running.
It is now a test, declared last, and the verdict-count assertion is
what guards the ordering that makes "last" meaningful.
2. Nothing tied the PUBLISHED `declared_tables` to the runner's constant.
Substituting a literal list left every other assertion green, because
they all read the constant rather than what was published -- so
`declaration_source: derived-from-runner` could be a lie with no code
change to point at. Now asserted identical.
Seven mutations, all exiting non-zero: dropped zero-count, falsified
not_applicable, unpublished field, hand-maintained tables, lied-about
source, and a hardcoded `passed` combined with a genuine case failure.
Published this run: 90 total, 84 passed, 0 failed, 6 not-applicable,
0 error, at commons 4641a7b (v0.3.0). Full suite 2192 pass / 0 fail /
6 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XDdiKC9ZXmcSUQ2m84s6y
Third consumer swap of v8 Phase 5 step 4, after codepoints and OWASP_MAP,
and the last one with a behavioural gate behind it. The 83 regex literals
leave injection-patterns.mjs; the four arrays are now built in
scanners/lib/injection-lexicon.mjs from the vendored
lexicon/injection-lexicon.json and re-exported unchanged, so every
consumer sees the same published surface.
Behaviour-preserving by measurement, not by intent. The proven recipe ran
in order: a differential over all 83 positions (regex source, flags,
label, aliases.llm_security) found 0 divergences BEFORE anything changed;
the golden dump was then diffed post-for-post rather than read as a 9000-
character assertion, and the ONLY changed record was the sha256 of
injection-patterns.mjs itself -- 83 regex posts, 7 table records and all
counts identical. That single file digest is the diff a swap MUST produce,
so the baseline was re-blessed rather than silenced.
Two deliberate departures from the two earlier swaps:
FAILURE IS LOUD. codepoints and owasp-map fail silently on purpose: an
empty codepoint table weakens normalization, an empty OWASP map mislabels
a report. An empty injection table is different in kind -- scanForInjection
returns found:false for every input, and the UserPromptSubmit scan, the
MCP output scan and the pre-compact scan all go blind while reporting
success. That is precisely the v7.8.2 defect class, which bit this plugin
four times in one release. An unresolvable commons therefore writes one
line to stderr naming the disabled capability. It still does not throw:
hooks run per-tool-call, and a module-load throw breaks the tool call
instead of degrading the scan. The warning is suppressed for an explicit
commonsRoot, so tests and dev checkouts stay quiet and the line keeps
meaning something.
ENTRIES COMPILE DEFENSIVELY. commons is vendored data, not code. An
uncompilable pattern or unknown flag would throw inside new RegExp at
module load -- in a hook. Malformed entries are dropped instead, the same
call owasp-map.mjs makes for a non-array value.
Gates proven by mutating the vendored JSON in BOTH directions, five ways,
all firing: re-adding the script-tag tail commons dropped (golden 1,
lexicon 2, corpus 1), dropping a critical pattern (2/1/3), stripping the
`m` flag off a spoofed-header anchor (2/1), adding a pattern commons never
published (2/2/85), and removing commons outright -- which produced the
stderr line, four empty tables and 5 red rather than a green suite over
zero patterns. Lexicon restored byte-identical after each.
Full suite 2191 pass / 0 fail / 6 skipped (2184 -> 2197).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XDdiKC9ZXmcSUQ2m84s6y
The subtree pull carries exactly one detection-data change: commons
converged `hybrid-xss:script-tag` on our open-tag-only form
(`<script\b[^>]*>`), dropping the `[\s\S]*?<\/script>` tail that was a
recall hole. Measured before the pull, not taken on their word: a
throwaway differential compared all 83 positions of the v0.3.0 lexicon
against the live source tables on source, flags, label and alias --
0 divergences, in order. The four vendored files were then re-hashed
against `git show v0.3.0:<file>` upstream; all four byte-identical.
Everything else in v0.2.0..v0.3.0 is additive: the CHANGELOG, the
divergence doc, spec text, the new §1.1 declaration schema, and one new
conformance case.
That new case is why the corpus tripwire moves. `manifest.count` is now
90 and `count_by_scope['lexicon/injection-lexicon.json']` is 84, because
`hybrid-xss__script-tag--src-no-close` gives the script pattern a SECOND
case. The tripwire fired on its own (actual 90, expected 89) rather than
being adjusted pre-emptively, so it is proven live this session.
`aliasMap.size` deliberately stays 83: the case-to-pattern relation is
now many-to-one, and only the alias map is a bijection. The header
comment says so explicitly, so the next reader does not "fix" the 83
into an 84.
Corpus: 84/84 passed, 6 not-applicable. Golden gate untouched (8/8) --
no table is built from the lexicon yet, which is the next commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XDdiKC9ZXmcSUQ2m84s6y
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