# 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]
## [8.1.3] - 2026-09-23
Patch that closes most of the v8.1.2 known-open list, the `/security watch`
config gap and three findings from the README fact-check. Detection tables,
golden baseline and hooks are unchanged. Windows/Defender quarantine is still
not measured. 2344 tests, 2338 pass, 6 skipped, 0 fail.
### Fixed
- **More install locations are foreign.** A target is now also foreign when
the path from the working directory down to it has a `site-packages` folder
(a Python virtual environment) or a `vendor` folder (composer, bundler, Go)
in it, like `node_modules` since 8.1.2, and when it lies under a Claude Code
config directory's `skills/` folder, like its `plugins/` folder
(`scanners/lib/own-working-tree.mjs`).
- **Both config directories count.** The plugin and skills check covers
`~/.claude` *and* the directory `CLAUDE_CONFIG_DIR` points at, so pointing
the variable at another profile no longer makes `~/.claude/plugins` your own
tree. A leading `~` in the variable is expanded to the home directory; a
relative value is still resolved against the working directory. Anthropic's
documentation does not say whether Claude Code itself expands `~` there (not
verified), so both readings are treated as foreign.
- **Letter case no longer decides.** Paths are compared after
`realpathSync.native`, which returns the case stored on disk. On a
case-insensitive volume (macOS APFS) `NODE_MODULES/pkg` is the
`node_modules` folder it names and is foreign, and a path to your own folder
typed with different case (`/users/…` for `/Users/…`) is your own tree. The
second one was foreign before, the safe direction; it is no longer a special
case.
- **`/security watch` as a cron job honours the watched project's own
configuration.** `scanners/watch-cron.mjs` started the scan with the plugin
directory as working directory, so every watched project was foreign and its
`.llm-security-ignore` and `policy.json` were dropped: you were shown
findings you had already suppressed. The scan now runs from the project's
directory (its parent for a single file). A relative `path` in the watch
config still resolves against the plugin directory.
- **No runnable base64-to-shell line left on disk.** A comment in
`examples/bash-evasion-gallery/run-evasion-gallery.mjs`, the gallery README
and `tests/e2e/attack-chain.test.mjs` each held a complete
"decode base64 into a shell" line whose short blob decodes to a recursive
delete. The blobs are now encoded at runtime; the README shows a
placeholder. New gate probe (b2) in `tests/av-surface.test.mjs` catches the
shape at any blob length (the existing probe starts at 24 characters).
- **`docs/ci-cd-guide.md` and `docs/scanner-reference.md` no longer call the
CLI offline.** The dep scanner runs `npm audit` and `pip-audit`, the network
scanner resolves domains over DNS and the supply-chain scanner queries
OSV.dev, whenever their input is present and with no switch to turn them
off. The guides said "zero network calls", "OSV opt-in" and referred to a
`--online` flag that does not exist.
- `docs/scanner-reference.md` lists all 22 knowledge files (it said 20).
**Behaviour change:** scanning a folder under `site-packages/` or `vendor/`,
or a skill under `~/.claude/skills` (or `$CLAUDE_CONFIG_DIR/skills`), no
longer applies that folder's ignore file or policy, including a folder of
your own that happens to be named `vendor` and skills you wrote yourself.
This is the safe direction: more findings, never fewer.
### Known open
- **A `git archive` export or an unpacked tarball under the working
directory still counts as your own tree** (no marker tells it apart).
- **The working directory inside `node_modules`:** with the working directory
at `project/node_modules` and a sibling package as target, only the path
below the working directory is checked, so the package counts as your own.
Rare.
- **Exotic losses on the own side:** a target in the project's own
`.claude/plugins/…` or `.claude/skills/…` becomes foreign when `HOME` is
empty or `CLAUDE_CONFIG_DIR=.claude` (relative). A plugin placed by hand
directly under `~/.claude/plugins/` is foreign (intended).
- Kept on purpose (safe direction): a git worktree of your repository checked
out inside it, scanned from the main checkout, is foreign.
## [8.1.2] - 2026-09-22
Patch that closes both items v8.1.1 left open. Detection tables, golden
baseline and hooks are unchanged. Windows/Defender quarantine is still not
measured. 2328 tests, 2322 pass, 6 skipped, 0 fail.
### Fixed
- **An installed package and a Claude Code plugin copy are no longer "own
working tree".** A target without a `.git` of its own shares the working
directory's git root, so v8.1.1 still read its `.llm-security-ignore` and
`.llm-security/policy.json` (including `sig.custom_rules_path`). A target is
now also foreign when the path from the working directory down to it has a
`node_modules` folder in it, or when it lies under Claude Code's plugin
directory — `$CLAUDE_CONFIG_DIR/plugins`, or `~/.claude/plugins` when the
variable is unset (`cache/` and `marketplaces/` alike)
(`scanners/lib/own-working-tree.mjs`). These are the two install locations
the rule names; it does not cover every place foreign code can land (see
Known open). A general "has no `.git` of its own" rule was not taken,
because it would also ignore the configuration of ordinary folders inside
your own repository.
**Behaviour change:** scanning a package under `node_modules/`, or a plugin
under the plugin directory, no longer applies that folder's ignore file or
policy; it gets the defaults and one stderr line per ignored file. Only the
path *below* the working directory is checked for `node_modules`, so a
package you have `cd`'d into and scan as `.` is still your own tree. A target
under the plugin directory is foreign even from inside it. Scanning your
repository or a plain folder inside it is unchanged, and so are hooks, which
read the policy from the project root.
- **The posture scanner no longer gives NIST "Govern" credit for a
`policy.json` the scan would not honour.** The credit used to follow the
file's existence; it now also requires the target to be your own working
tree (`scanners/posture-scanner.mjs`).
### Known open
- **A `git archive` export or an unpacked tarball under the working
directory still counts as your own tree.** It has no `.git`, no
`node_modules` folder and no plugin-directory path, so nothing tells it apart
from a folder of your own; its ignore file and policy are read.
- **Other install locations still count as your own tree:** a package in a
Python virtual environment (`.venv/lib/…/site-packages/`), a composer or
bundler `vendor/` folder, and skills copied into `~/.claude/skills` when you
scan from inside a git-tracked `~/.claude`. Only `node_modules` and the
plugin directory are named by the rule.
- The plugin-directory check knows one config directory: the one
`CLAUDE_CONFIG_DIR` points at, or `~/.claude` when it is unset. With the
variable pointing at another profile, a copy under `~/.claude/plugins` counts
as own again.
- Two cases stay foreign on purpose (safe direction, more findings, not
fewer): a git worktree of your repository checked out inside it, scanned
from the main checkout; and a target path typed with different letter case
than the folder on disk (macOS APFS is case-insensitive, but the path check
compares exactly).
## [8.1.1] - 2026-09-22
Patch that closes the v8.1.0 follow-up list. Detection tables, golden baseline
and hooks are unchanged. Windows/Defender quarantine is still not measured:
there is no Windows machine to measure it on, and the release says so rather
than guessing. 2306 tests, 2300 pass, 6 skipped, 0 fail.
### 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. A git
worktree of your own repository, checked out inside it, now counts as
foreign when scanned from the main checkout; scan it from inside the
worktree to apply its configuration.
### Changed
- **The README and `.llm-security-ignore` no longer state the self-scan's
suppressed count or its count without the ignore file.** Both changed by
one with every commit, because `git-forensics` looks at the last 50 commits
— correct behaviour, but it made the figures false one commit after they
were written. The verdict, score and finding count with the ignore file,
measured on a fresh clone of the release commit, stay.
- **`/security scan` description** names what it accepts: remote git
repositories on any host, SSH only for GitHub. It said "GitHub repos".
### Tests
- **`ci.failOn` and `ci.compact` in `policy.json` have a real test.** The old
one wrote a policy into a directory it never scanned and passed
`--fail-on` on the command line, so it passed without reading the policy.
### Known open
- **A target with no `.git` of its own still counts as the caller's own tree
when it sits under the working directory in the same git root (or where
neither has one).** That covers what package managers and plugin installs
produce: a `node_modules/` package, an installed copy in the Claude Code
plugin cache, a tarball or `git archive` export. Such a target's
`.llm-security-ignore` and `policy.json` are still read, as in v8.1.0. Git
clones, submodules, worktrees and temp-directory clones are covered. Without
git there is no general way to tell a fetched folder from your own; a
narrower rule for known install locations is being considered.
- The posture scanner credits a `policy.json` that exists even when the scan
would not honour it.
## [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 `