# Changelog All notable changes to the LLM Security Plugin are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Fixed - **A clone under the working directory is no longer "own working tree".** v8.1.0 honoured `.llm-security-ignore` and `.llm-security/policy.json` (including `sig.custom_rules_path`) for any target at or below the working directory, so a foreign clone there — the working directory at `$HOME`, or a cloned vendor folder inside a project — could still suppress or tune its own scan. The target must now also have the same git root as the working directory: the nearest folder above it holding a `.git` (a directory for a clone, a file for a submodule or worktree), or no git root for either (`scanners/lib/own-working-tree.mjs`). Temp-directory targets stay foreign. **Behaviour change:** scanning a nested clone, submodule or worktree from the parent repository no longer applies that checkout's ignore file or policy; it now gets the defaults and one stderr line per ignored file. Scanning the repository itself or any plain folder inside it is unchanged, and so are hooks, which read the policy from the project root. ## [8.1.0] - 2026-09-22 Antivirus surface. A Windows user should be able to clone the repository and install the plugin without Defender or a similar product quarantining a file, and the plugin should still find what it finds today. No payload now sits on disk as a file that would run it: test fixtures and the malicious-plugin demo are stored encoded or built from fragments, and exist only in a temp directory while a test or the demo runs. Two scope fixes ride along: a scanned repository no longer controls its own suppression or detection thresholds. Detection tables untouched; golden baseline identical. 2286 tests, 2280 pass, 6 skipped, 0 fail. ### Added - **`tests/av-surface.test.mjs`** — a gate that fails the suite if a payload comes back to disk. Five probes over `git ls-files`: (a) files that match the webshell / reverse-shell / cryptominer signatures in a file type that would run them, plus (a2) the same families as literals in test sources; (b) base64 blobs that decode to a shell command; (c) Unicode Tag and zero-width characters outside the conformance corpus; (d) whole malicious fixture trees on disk; (e) runnable payload literals in the files Claude Code loads every session (agents, commands, skills, hooks, knowledge). - **README § Antivirus false positives** — what the repository contains and why, how to restore a quarantined file, where to report a false positive to Microsoft, and a sparse-checkout recipe for users who only want the scanners and hooks. No exclusion is recommended. ### Changed - **Test payloads are built at test time, never stored.** The poisoned fixtures for the signature, memory-poisoning and trigger scanners are written into a temp directory from split strings or rot13; zero-width test lines use `String.fromCodePoint`. `tests/fixtures/signature-scan/poisoned/` is gone. The sha256 of every removed file is asserted against what the builders produce, so the content is provably unchanged. - **The malicious-skill demo is stored encoded.** `examples/malicious-skill-demo/` and `examples/poisoned-claude-md/` ship as encoded archives and are written to a temp directory when run (`materialize.mjs` prints the demo's path). The README and the demo's security assessment describe the base64 payloads instead of showing decodable lines. - **`tests/fixtures/ast-scan/*.py` are materialized at test time** (all five). - **Skill-scanner detection lists moved** from `agents/skill-scanner-agent.md` to `knowledge/skill-threat-patterns.md` § Detection Rules, a file the four scanning commands already hand the agent. A quarantined agent file would break the plugin for the user; a knowledge file would not. Runnable one-liners in `knowledge/*.md` are written as descriptions where that costs no precision. - **Two hook rule names split** so no download-into-shell literal sits on disk in `hooks/scripts/pre-bash-destructive.mjs`. Hook stderr is byte-identical. - **`.llm-security-ignore` now covers the MEM, SIG, AST, TRG and UNI prefixes**, and the README's self-scan numbers are measured, not claimed: a fresh clone scans WARNING 61/100 with 58 findings (the earlier "0 findings (ALLOW)" was false). ### Fixed - **`.llm-security-ignore` is honoured only for the caller's own working tree.** A scanned repository with `**` in its own ignore file turned WARNING (1 finding) into ALLOW (0). The file is now read only when the target is the current working directory or below it, and never when the target is under the system temp directory (where remote clones land). Otherwise it is ignored with one stderr line. - **`.llm-security/policy.json` — including `sig.custom_rules_path`, `ast.python_path` and `ci.failOn` — is honoured only for the caller's own working tree**, by the same rule (`scanners/lib/own-working-tree.mjs`). A foreign target could raise its own entropy thresholds out of reach or add signature rules. Entropy's `calibration.policy_source` now says `defaults` when the file was present but ignored. **Behaviour change:** running a scanner with a target outside the current working directory no longer applies that target's `policy.json`. Hooks, and CI that runs `scan .` from the repository root, are unaffected. - **`scanners/lib/git-clone.mjs` accepts HTTPS repository URLs on any host**, not only GitHub, so the README's Forgejo example works. SSH URLs remain GitHub-only. ### Known open - "Own working tree" means the target is the working directory or below it. A foreign clone that sits under the user's working directory (for example with the working directory at `$HOME`, or a cloned vendor folder inside a project) still has its `policy.json`, ignore file and custom SIG rules read. Clones under the temp directory and targets outside the working directory are covered. - The README's self-scan suppression count drifts by one per commit: `git-forensics` looks at the last 50 commits, and each new commit pushes one older finding out of that window. ## [8.0.0] - 2026-09-18 Major release. The breaking part is small and was announced in v7.3.0: four deprecated `LLM_SECURITY_*` env-vars and the long-dead `riskScoreV1()` are removed. The larger part is structural and behaviour-preserving: the detection tables (injection lexicon, codepoint carriers, OWASP map, secret shapes, malware signatures) are now built from a vendored, versioned data repository under `scanners/commons/` instead of source literals, each swap proven content-identical against a golden baseline. Several recall and correctness fixes ride along. 2253 tests, 2247 pass, 6 skipped, 0 fail. ### Added - **`llms.txt` at the repository root.** A minimal entry point for an AI agent already standing in this repository: H1, one blockquote saying what the artifact does, the install block quoted verbatim from `README.md`, and link lists to the docs. It cites `README.md` rather than restating it, so the two cannot drift into two different install instructions. Deliberately minimal: llms.txt is a convention, not a ratified standard, so a format change stays cheap. - **A stated deprecation notice period** in `SECURITY.md`. The practice already existed — the four `LLM_SECURITY_*` env-vars were announced in v7.3.0 and are removed in v8.0.0, five minor releases later — but nothing said it was the rule, so an adopter could read the changelog and still not know what to expect next time. The promise: announcement in the changelog, at least one minor release **and** no fewer than 30 days before removal, removals only in a major, one stated exception for security-critical removal, and an explicit note that this is a notice period and not an SLA. - **Commons conformance, spec 1.1.** The suite runs the vendored commons conformance corpus through the real entry points and publishes a schema-shaped declaration of the result. The set of commons tables this runtime implements is declared from the same constant the runner uses to accept a scope, so the declaration cannot drift from what actually ran. Cases scoped to a table this runtime does not implement are reported as *not-applicable* — enumerated by name, counted as skipped rather than passed, and kept in the denominator. An empty declaration fails the suite instead of turning every case not-applicable and passing with zero cases run. The declaration artifact is generated per run and gitignored. ### Removed — BREAKING - **The four `LLM_SECURITY_*` configuration env-vars deprecated in v7.3.0.** `.llm-security/policy.json` is now the only source. A removed variable is **inert**: it neither warns nor configures, so a project relying on one silently returns to the default. See the Migration section in `README.md` for the detection commands. | Removed env-var | Policy key | Default | |-----------------|------------|---------| | `LLM_SECURITY_INJECTION_MODE` | `injection.mode` | `block` | | `LLM_SECURITY_TRIFECTA_MODE` | `trifecta.mode` | `warn` | | `LLM_SECURITY_ESCALATION_WINDOW` | `trifecta.escalation_window` | `5` | | `LLM_SECURITY_AUDIT_LOG` | `audit.log_path` | unset | | `LLM_SECURITY_DEPRECATION_QUIET` | *(none)* | dies with the warning it silenced | Env-vars with no policy equivalent are unaffected: `LLM_SECURITY_PRECOMPACT_MODE`, `LLM_SECURITY_PRECOMPACT_MAX_BYTES`, `LLM_SECURITY_UPDATE_CHECK`, `LLM_SECURITY_MCP_CACHE_FILE`, `LLM_SECURITY_IDE_ROOTS`. `getPolicyValueWithEnvWarn` and its one-shot stderr deprecation line are deleted from `scanners/lib/policy-loader.mjs`; the four call sites collapse to `getPolicyValue`. Hook strings that advertised a removed variable as the escape hatch now name the policy key — a blocked user following the old text would have set a variable that does nothing. - **`riskScoreV1()`** in `scanners/lib/severity.mjs`, plus its `SEVERITY_WEIGHTS_V1` table. `@deprecated` since v7.0.0, kept for diff/comparison, with zero callers in code or tests. The v1 weights are recorded here for anyone re-deriving an old score: `critical 25, high 10, medium 4, low 1, info 0`, summed and capped at 100. `riskScore()` (v2) is untouched, so no score, band, or verdict moves. - **Two documented env-vars that were never implemented.** `LLM_SECURITY_SCR_OFFLINE` (`docs/ci-cd-guide.md`) and `LLM_SECURITY_OFFLINE` (`examples/supply-chain-attack/README.md`) were promised as OSV.dev / npm-audit kill-switches; no code has ever read either. The docs now say plainly that there is no kill-switch and that an air-gapped run must block egress at the network layer. The `LLM_SECURITY_AUDIT_*` wildcard phrasing (README, `docs/scanner-reference.md`, `docs/security-hardening-guide.md`) is narrowed to the single real key. ### Changed - **Posture category 12 (Rule of Two) no longer keys off the identifier `TRIFECTA_MODE`.** The check was `/TRIFECTA_MODE/i` over the session-guard source, which measured what a constant was *named* rather than whether enforcement was configurable at all. It now matches `getPolicyValue('trifecta', 'mode', …)`, and still accepts a pre-v8 vendored guard reading `LLM_SECURITY_TRIFECTA_MODE` — third-party projects carry their own hook copy and are equally configurable. Without this, every correctly migrated project would have dropped from PASS to PARTIAL. The PARTIAL finding now recommends the policy key instead of the removed env-var. - **Detection tables are built from vendored commons data, not source literals.** `scanners/commons/` is a pull-only subtree of the `llm-security-commons` data repository, inside the published `scanners/` directory so it ships with the plugin. Five tables moved, each measured position-by-position against the literal it replaced before the swap, with the golden dump diffed record-for-record afterwards: the codepoint carriers (zero-width, bidi, Unicode Tag range, homoglyph map), `OWASP_MAP`, the 83 injection patterns, the 19 fixed credential shapes in `pre-edit-secrets.mjs`, and the 7 SIG malware signatures. Published module surfaces are unchanged — the tables are re-exported from where they always lived. `knowledge/signatures.json` is **removed** rather than kept as a second, ungated copy of the SIG table; operator rules via `sig.custom_rules_path` are unaffected. The commons location is resolved from the plugin's own directory only — never from a scanned target's `policy.json`, which would let a hostile repository substitute an empty detection corpus. Loaded artifacts are deep-frozen. An unresolvable injection lexicon or SIG ruleset writes one line to stderr naming the disabled capability instead of scanning with zero patterns and reporting clean; it never throws, because a module-load throw in a hook would break the tool call rather than degrade the scan. - **Golden gate `coverage` block relabelled to what it measures.** It probes payload strings against patterns in-process, which is static reachability, not what the reference run exercised. `coverage.kind = 'static-reachability'`; `patternsExercised` → `patternsReachable`, `uncoveredPatterns` → `unreachablePatterns`, `tablesExercised` → `corpusContains`. The gate pins the label so it cannot be dropped quietly. ### Fixed - **``, so `