# SkillSpector-Inspired Scanners: TRG, SIG, AST

> **Plan quality: B** (86/100) — APPROVE_WITH_NOTES (revised after adversarial review)
>
> Generated by trekplan v2.0 on 2026-06-20 — `plan_version: 1.7`

## Context

llm-security is a broad runtime + posture defense for Claude Code. A gap analysis against
NVIDIA/SkillSpector (`docs/plans/skillspector-gap-analysis.md`) confirmed three genuine holes in our
static analysis, each to be closed our own dep-free way:

1. **#3 TRG — trigger/activation-abuse**: no scanner inspects command/agent/skill `name`+`description`
   frontmatter for broad triggers, keyword-baiting, or built-in shadowing. Skills auto-activate on
   description, so this is a real, uncovered, skill-native attack surface.
2. **#1 SIG — signature engine**: we detect *shape* (entropy/obfuscation/taint) but never *known-bad
   identity* (webshells, reverse shells, cryptominers, hacktools). Our differentiator: run signatures
   against the existing `normalizeForScan()` decode pipeline so obfuscated known-malware is caught —
   something raw YARA (which SkillSpector ships) misses.
3. **#2 AST — Python AST taint**: `taint-tracer.mjs` is regex (~70% recall, no scope/cross-file). A
   shipped `python3` AST helper raises recall/precision for Python skill code, with graceful fallback
   to the regex tracer when python3 is absent.

Hard invariant: **no new npm dependency** — `package.json` has no `dependencies` key today and must
keep none. The 1820+ test suite must stay green.

## Architecture Diagram

```mermaid
graph TD
    subgraph "Changes in this plan"
        ORCH["scanners/scan-orchestrator.mjs<br/>(SCANNERS array + imports)"]
        TRG["scanners/trigger-scanner.mjs (NEW)<br/>prefix TRG"]
        SIG["scanners/signature-scanner.mjs (NEW)<br/>prefix SIG"]
        AST["scanners/ast-taint-scanner.mjs (NEW)<br/>prefix AST"]
        PYH["scanners/lib/py-ast-taint.py (NEW)<br/>parse-only helper"]
        SIGDATA["knowledge/signatures.json (NEW)"]
        POL["scanners/lib/policy-loader.mjs<br/>+trg/+sig/+ast sections"]
        SEV["scanners/lib/severity.mjs<br/>4 OWASP maps +TRG/+SIG/+AST"]
        OUT["scanners/lib/output.mjs<br/>finding() / scannerResult() (reused)"]
        NORM["scanners/lib/string-utils.mjs<br/>normalizeForScan() (reused)"]
        FM["scanners/lib/yaml-frontmatter.mjs<br/>parseFrontmatter() (reused)"]

        ORCH --> TRG & SIG & AST
        AST --> PYH
        SIG --> SIGDATA
        SIG --> NORM
        TRG --> NORM & FM
        TRG & SIG & AST --> OUT
        ORCH --> POL
        TRG & SIG & AST --> SEV
    end
```

## Codebase Analysis

- **Tech stack:** Node.js ESM (`.mjs`), zero npm dependencies (`package.json` has no `dependencies` key). Test runner: `node --test` + `node:assert/strict`. Optional `python3` shell-outs already used (`dep-auditor` → pip).
- **Key patterns:** every deterministic scanner exports `async function scan(targetPath, discovery)`, loops `discovery.files` (`FileInfo {absPath, relPath, ext, size}`), emits `finding(...)`, returns `scannerResult(...)`. The orchestrator resets the finding-ID counter per scanner.
- **Relevant files (verified against source):**
  - Orchestrator: **`scanners/scan-orchestrator.mjs`** (NOT repo root). Imports at `:101-111`, `SCANNERS` array at `:113-125`. Sibling scanners imported as `./unicode-scanner.mjs` (so a new scanner in `scanners/` imports as `./trigger-scanner.mjs`).
  - Template scanner: `scanners/memory-poisoning-scanner.mjs:386` (scan signature + `discovery.files` loop + dual-return).
  - Output: `scanners/lib/output.mjs:30` (`finding()`), `:57` (`scannerResult()`), `:12` (`resetCounter()`).
  - Policy: `scanners/lib/policy-loader.mjs:13-68` (`DEFAULT_POLICY`), `:150` (`getPolicyValue(section, key, default, projectRoot)` — 4th arg is the project root, resolved to `<root>/.llm-security/policy.json`).
  - OWASP maps: `scanners/lib/severity.mjs` — **four** frozen maps with **array** values: `OWASP_MAP` (`:132`, LLM), `OWASP_AGENTIC_MAP` (`:151`, ASI), `OWASP_SKILLS_MAP` (`:170`, AST), `OWASP_MCP_MAP` (`:189`, MCP). Every scanner prefix has a key in all four (empty array where N/A, e.g. `WFL: []` in the skills map). `owaspCategorize` spreads `OWASP_MAP[prefix]` (`:227`).
  - Frontmatter: `scanners/lib/yaml-frontmatter.mjs:13` exports `parseFrontmatter(content)` (already used by `permission-mapper.mjs:10`). TRG reuses it directly — no inline parser needed.
  - Knowledge loader model: `scanners/dep-auditor.mjs:20-42` (cached `__dirname`-relative JSON loader, graceful fallback).
  - Decode API: `scanners/lib/string-utils.mjs:512` (`normalizeForScan()→string`), `:486` (`foldHomoglyphs`), `:474` (`rot13`).
  - File discovery: `scanners/lib/file-discovery.mjs:52` (`discoverFiles`), `readTextFile`; `.md`/`.py`/`.php` are in `TEXT_EXTENSIONS` so TRG/SIG/AST fixtures are discoverable.
  - `package.json` `files`: `["bin/","scanners/","LICENSE","README.md","CONTRIBUTING.md","SECURITY.md","CHANGELOG.md"]` — `knowledge/` absent. Moot for runtime (marketplace = git-clone of whole repo; existing scanners already load `knowledge/*.json`), but Step 7 adds `knowledge/` for correctness.
- **Reusable code:** `finding`/`scannerResult`/`resetCounter` (`lib/output.mjs`); `discoverFiles`/`readTextFile` (`lib/file-discovery.mjs`); `normalizeForScan`/`foldHomoglyphs`/`rot13` (`lib/string-utils.mjs`); `parseFrontmatter` (`lib/yaml-frontmatter.mjs`); `getPolicyValue` (`lib/policy-loader.mjs`); the four OWASP maps + `SEVERITY` (`lib/severity.mjs`).
- **Test infra:** tests in `tests/scanners/*.test.mjs`; committed fixtures under `tests/fixtures/<name>-scan/{clean,poisoned}/` (model: `tests/fixtures/memory-scan/`). `resetCounter()` in `beforeEach`. Single-file run: `node --test tests/scanners/<name>.test.mjs`. No lint/coverage gate. Registration-test model asserts `deepEqual` on all four OWASP maps (`tests/scanners/workflow-scanner.test.mjs:196-216`). Binary-absent skip: `which python3` guard + early `return` (`tests/scanners/vsix-sandbox.test.mjs:27-30`) and graceful `status:'skipped'` envelope (`tests/scanners/dep.test.mjs:117-124`).

## Implementation Plan

Each scanner's **core** (new files only — revert-safe) lands before its **wiring** (small edits to
shared `scanners/scan-orchestrator.mjs` / `scanners/lib/policy-loader.mjs` / `scanners/lib/severity.mjs`).
Build order follows the brief: TRG → SIG → AST. Step 8 (release) is operator-gated.

### Step 1: Add TRG trigger-abuse scanner with tests and fixtures

- **Files:** `scanners/trigger-scanner.mjs` (new), `tests/scanners/trigger-scanner.test.mjs` (new), `tests/fixtures/trigger-scan/clean/` (new), `tests/fixtures/trigger-scan/poisoned/` (new)
- **Changes:** Create `scan(targetPath, discovery)` mirroring `memory-poisoning-scanner.mjs:386`. Filter `discovery.files` to `commands/*.md`, `agents/*.md`, `skills/**/SKILL.md`. Parse frontmatter via `parseFrontmatter` from `scanners/lib/yaml-frontmatter.mjs`. Emit three rule classes: **TRG-broad** (single common-word/≤2-char `name`; description claiming universal applicability), **TRG-baiting** (maximally-activating phrases: "anything","everything","always","all files/messages","whenever" — list from policy), **TRG-shadow** (`name` colliding with a configurable built-in command/tool list). Run the description through `normalizeForScan()` before phrase matching so obfuscated baiting (zero-width/homoglyph) still trips. Default severity MEDIUM; broad-name + universal-claim combination → HIGH. Emit via `finding({scanner:'TRG', owasp:'LLM06', ...})`; return `scannerResult('trigger-scanner', ...)`. Fixtures: `poisoned/` = a skill named `run` with "use for anything" + an obfuscated-baiting case; `clean/` = a scoped, specifically-described skill. (new files)
- **Reuses:** `scan(targetPath, discovery)` + `discovery.files` loop (`memory-poisoning-scanner.mjs:386`); `finding`/`scannerResult`/`resetCounter` (`lib/output.mjs`); `readTextFile`/`discoverFiles` (`lib/file-discovery.mjs`); `normalizeForScan` (`lib/string-utils.mjs`); `parseFrontmatter` (`lib/yaml-frontmatter.mjs`).
- **Test first:**
  - File: `tests/scanners/trigger-scanner.test.mjs` (new)
  - Verifies: `clean/` → `status:'ok'`, `findings.length === 0`; `poisoned/` → TRG-shadow + TRG-baiting fire, obfuscated baiting still flagged, all `id` start `DS-TRG-`, `scanner==='TRG'`, required fields present.
  - Pattern: `tests/scanners/memory-poisoning.test.mjs` (`resetCounter()` in `beforeEach`)
- **Verify:** `node --test tests/scanners/trigger-scanner.test.mjs` → expected: `fail 0`
- **On failure:** revert — `git checkout -- scanners/trigger-scanner.mjs tests/scanners/trigger-scanner.test.mjs && git clean -fd tests/fixtures/trigger-scan`
- **Checkpoint:** `git commit -m "feat(llm-security): add TRG trigger-abuse scanner"`
- **Manifest:**
  ```yaml
  manifest:
    expected_paths:
      - scanners/trigger-scanner.mjs
      - tests/scanners/trigger-scanner.test.mjs
    min_file_count: 2
    commit_message_pattern: "^feat\\(llm-security\\): add TRG trigger-abuse scanner$"
    bash_syntax_check: []
    forbidden_paths:
      - scanners/scan-orchestrator.mjs
    must_contain:
      - path: scanners/trigger-scanner.mjs
        pattern: "'TRG'"
      - path: scanners/trigger-scanner.mjs
        pattern: "normalizeForScan"
      - path: scanners/trigger-scanner.mjs
        pattern: "parseFrontmatter"
  ```

### Step 2: Wire TRG into orchestrator, policy, and the four OWASP maps

- **Files:** `scanners/scan-orchestrator.mjs`, `scanners/lib/policy-loader.mjs`, `scanners/lib/severity.mjs`
- **Changes:** Add `import { scan as trgScan } from './trigger-scanner.mjs';` to the import block (`scan-orchestrator.mjs:101-111`) and `{ name: 'trg', fn: trgScan }` to the `SCANNERS` array (`:113-125`). Add a frozen `trg` section to `DEFAULT_POLICY` (`policy-loader.mjs:13-68`): `{ mode:'warn', baiting_phrases:[...], builtin_names:[...], broad_single_words:[...] }`; the scanner reads these via `getPolicyValue('trg', key, default, targetPath)` (targetPath is the scan root where `.llm-security/policy.json` lives). Add **array** entries to all four maps in `severity.mjs`: `OWASP_MAP.TRG=['LLM06']`, `OWASP_AGENTIC_MAP.TRG=[]`, `OWASP_SKILLS_MAP.TRG=['AST04']` (brief's primary framework), `OWASP_MCP_MAP.TRG=[]`. Add registration assertions to the TRG test (registered in `SCANNERS`; `assert.deepEqual(OWASP_SKILLS_MAP.TRG, ['AST04'])`; policy override of `baiting_phrases` changes detection).
- **Reuses:** `SCANNERS` registration (`scan-orchestrator.mjs:113`); `DEFAULT_POLICY`/`getPolicyValue` (`policy-loader.mjs:13,150`); the four OWASP maps (`severity.mjs:132-189`).
- **Test first:**
  - File: `tests/scanners/trigger-scanner.test.mjs` (existing — extend)
  - Verifies: TRG in orchestrator `SCANNERS`; `deepEqual(OWASP_SKILLS_MAP.TRG, ['AST04'])` and `deepEqual(OWASP_MAP.TRG, ['LLM06'])`; policy override changes detection.
  - Pattern: `tests/scanners/workflow-scanner.test.mjs:196-216`
- **Verify:** `npm test` → expected: `fail 0`
- **On failure:** revert — `git checkout -- scanners/scan-orchestrator.mjs scanners/lib/policy-loader.mjs scanners/lib/severity.mjs tests/scanners/trigger-scanner.test.mjs`
- **Checkpoint:** `git commit -m "feat(llm-security): wire TRG scanner into orchestrator and policy"`
- **Manifest:**
  ```yaml
  manifest:
    expected_paths:
      - scanners/scan-orchestrator.mjs
      - scanners/lib/policy-loader.mjs
      - scanners/lib/severity.mjs
    min_file_count: 3
    commit_message_pattern: "^feat\\(llm-security\\): wire TRG scanner into orchestrator and policy$"
    bash_syntax_check: []
    forbidden_paths: []
    must_contain:
      - path: scanners/scan-orchestrator.mjs
        pattern: "trgScan"
      - path: scanners/lib/severity.mjs
        pattern: "TRG"
  ```

### Step 3: Add SIG signature scanner with rule data, tests and fixtures

- **Files:** `scanners/signature-scanner.mjs` (new), `knowledge/signatures.json` (new), `tests/scanners/signature-scanner.test.mjs` (new), `tests/fixtures/signature-scan/clean/` (new), `tests/fixtures/signature-scan/poisoned/` (new)
- **Changes:** Create `knowledge/signatures.json` with a small, high-confidence, family-grouped ruleset (`webshell`,`reverse_shell`,`cryptominer`,`hacktool`) — each rule `{ id, family, severity, pattern, description, provenance }`. Create `scan(targetPath, discovery)` that, for each file in `discovery.files`, builds the decoded variant set (`content`, `normalizeForScan(content)`, `foldHomoglyphs(content)`, `rot13(content)`) and tests every signature regex against all variants — a match on a decoded-only variant is the differentiator vs raw YARA. De-dup per (file, rule). Emit `finding({scanner:'SIG', owasp:'LLM03', ...})`. Load the ruleset via the cached `__dirname`-relative loader copied from `dep-auditor.mjs:20-42`; graceful empty-ruleset fallback. Path-exclude `knowledge/`, `tests/`, `docs/`. Fixtures: `poisoned/` = a PHP webshell sample AND a base64-wrapped variant of it; `clean/` = benign prose mentioning "shell". (new files)
- **Reuses:** scan/loop/return (`memory-poisoning-scanner.mjs:386`); JSON loader (`dep-auditor.mjs:20-42`); `normalizeForScan`/`foldHomoglyphs`/`rot13` (`string-utils.mjs`); `finding`/`scannerResult` (`output.mjs`).
- **Test first:**
  - File: `tests/scanners/signature-scanner.test.mjs` (new)
  - Verifies: `poisoned/` → webshell flagged AND its base64-wrapped variant flagged (decode-pipeline integration); `clean/` → 0 findings; `scanner==='SIG'`, ids start `DS-SIG-`.
  - Pattern: `tests/scanners/memory-poisoning.test.mjs`
- **Verify:** `node --test tests/scanners/signature-scanner.test.mjs` → expected: `fail 0`
- **On failure:** revert — `git checkout -- scanners/signature-scanner.mjs knowledge/signatures.json tests/scanners/signature-scanner.test.mjs && git clean -fd tests/fixtures/signature-scan`
- **Checkpoint:** `git commit -m "feat(llm-security): add SIG signature scanner with decode-pipeline matching"`
- **Manifest:**
  ```yaml
  manifest:
    expected_paths:
      - scanners/signature-scanner.mjs
      - knowledge/signatures.json
      - tests/scanners/signature-scanner.test.mjs
    min_file_count: 3
    commit_message_pattern: "^feat\\(llm-security\\): add SIG signature scanner with decode-pipeline matching$"
    bash_syntax_check: []
    forbidden_paths:
      - scanners/scan-orchestrator.mjs
    must_contain:
      - path: scanners/signature-scanner.mjs
        pattern: "'SIG'"
      - path: scanners/signature-scanner.mjs
        pattern: "normalizeForScan"
  ```

### Step 4: Wire SIG into orchestrator, policy, and the four OWASP maps

- **Files:** `scanners/scan-orchestrator.mjs`, `scanners/lib/policy-loader.mjs`, `scanners/lib/severity.mjs`
- **Changes:** Add `import { scan as sigScan } from './signature-scanner.mjs';` and `{ name: 'sig', fn: sigScan }` to `SCANNERS`. Add a frozen `sig` section to `DEFAULT_POLICY`: `{ enabled_families:['webshell','reverse_shell','cryptominer','hacktool'], custom_rules_path:null }`, read via `getPolicyValue('sig', ...)`. Add array entries to all four maps: `OWASP_MAP.SIG=['LLM03','LLM02']`, `OWASP_AGENTIC_MAP.SIG=['ASI04']`, `OWASP_SKILLS_MAP.SIG=[]`, `OWASP_MCP_MAP.SIG=[]`. Add registration assertions (incl. `deepEqual(OWASP_MAP.SIG, ['LLM03','LLM02'])` and a family-disable policy test) to the SIG test.
- **Reuses:** same wiring sites as Step 2.
- **Test first:**
  - File: `tests/scanners/signature-scanner.test.mjs` (existing — extend)
  - Verifies: SIG in `SCANNERS`; `deepEqual(OWASP_MAP.SIG, ['LLM03','LLM02'])`; disabling a family via policy suppresses its findings.
  - Pattern: `tests/scanners/workflow-scanner.test.mjs:196-216`
- **Verify:** `npm test` → expected: `fail 0`
- **On failure:** revert — `git checkout -- scanners/scan-orchestrator.mjs scanners/lib/policy-loader.mjs scanners/lib/severity.mjs tests/scanners/signature-scanner.test.mjs`
- **Checkpoint:** `git commit -m "feat(llm-security): wire SIG scanner into orchestrator and policy"`
- **Manifest:**
  ```yaml
  manifest:
    expected_paths:
      - scanners/scan-orchestrator.mjs
      - scanners/lib/policy-loader.mjs
      - scanners/lib/severity.mjs
    min_file_count: 3
    commit_message_pattern: "^feat\\(llm-security\\): wire SIG scanner into orchestrator and policy$"
    bash_syntax_check: []
    forbidden_paths: []
    must_contain:
      - path: scanners/scan-orchestrator.mjs
        pattern: "sigScan"
      - path: scanners/lib/severity.mjs
        pattern: "SIG"
  ```

### Step 5: Add AST Python-taint helper and scanner with graceful fallback

- **Files:** `scanners/lib/py-ast-taint.py` (new), `scanners/ast-taint-scanner.mjs` (new), `tests/scanners/ast-taint-scanner.test.mjs` (new), `tests/fixtures/ast-scan/` (new)
- **Changes:** Create `scanners/lib/py-ast-taint.py`: reads a target `.py` path, runs `ast.parse` (PARSE-ONLY — never `exec`/`eval`/`compile`/import the target), walks the tree for variable-level taint (sources: `os.environ`/`os.getenv`/`open`/`requests`/`sys.stdin`/`input`; sinks: `requests.post/put`, `exec`/`eval`/`subprocess`/`os.system`, file writes), and prints to stdout a single JSON object with this **contract**: `{ "status": "ok", "findings": [ { "rule": "TT3", "severity": "critical", "line": <int>, "source": <str>, "sink": <str>, "message": <str> } ] }`; on a parse error it prints `{ "status": "error", "message": <str> }` and exits non-zero. Create `scanners/ast-taint-scanner.mjs`: for each `.py` in `discovery.files`, call `spawnSync('python3', [helperPath, file], { encoding:'utf8', timeout: 5000 })`. Handle every branch: ENOENT / `python3` absent → return `scannerResult('ast-taint-scanner', 'skipped', ...)`; non-zero exit, timeout, or unparseable stdout → record that file as an `info`-level scan note and continue (never throw); valid `{status:'ok'}` → map each helper finding to `finding({scanner:'AST', owasp:'LLM01', ...})`. Fixtures: `creds-net.py` (`k=os.environ["AWS_SECRET"]; requests.post(url,data=k)`), `scope.py` (`input` reused across two functions, only one tainted), and `sentinel.py` (contains `os.system("touch SENTINEL")` to prove parse-only safety — the file must never run).
- **Reuses:** scan/return shape (`memory-poisoning-scanner.mjs:386`); `spawnSync` graceful-skip precedent (`dep-auditor.mjs`/`git-forensics.mjs`); `finding`/`scannerResult` (`output.mjs`).
- **Test first:**
  - File: `tests/scanners/ast-taint-scanner.test.mjs` (new)
  - Verifies: (a) `which python3` guard, early `return` if absent; with python3 → creds→net flagged through the intermediate variable, scoped `input` only flags the tainted use, ids start `DS-AST-`; (b) python3-absent path → `status:'skipped'`, no crash; (c) parse-only safety: after scanning `sentinel.py`, assert no `SENTINEL` file exists; (d) a deliberately malformed `.py` → scan completes without throwing.
  - Pattern: `tests/scanners/vsix-sandbox.test.mjs:27-30` (which-guard) + `tests/scanners/dep.test.mjs:117-124` (skipped envelope)
- **Verify:** `node --test tests/scanners/ast-taint-scanner.test.mjs` → expected: `fail 0`
- **On failure:** revert — `git checkout -- scanners/lib/py-ast-taint.py scanners/ast-taint-scanner.mjs tests/scanners/ast-taint-scanner.test.mjs && git clean -fd tests/fixtures/ast-scan`
- **Checkpoint:** `git commit -m "feat(llm-security): add AST Python-taint scanner with python3 fallback"`
- **Manifest:**
  ```yaml
  manifest:
    expected_paths:
      - scanners/lib/py-ast-taint.py
      - scanners/ast-taint-scanner.mjs
      - tests/scanners/ast-taint-scanner.test.mjs
    min_file_count: 3
    commit_message_pattern: "^feat\\(llm-security\\): add AST Python-taint scanner with python3 fallback$"
    bash_syntax_check: []
    forbidden_paths:
      - scanners/scan-orchestrator.mjs
    must_contain:
      - path: scanners/lib/py-ast-taint.py
        pattern: "ast.parse"
      - path: scanners/ast-taint-scanner.mjs
        pattern: "'AST'"
      - path: scanners/ast-taint-scanner.mjs
        pattern: "timeout"
  ```

### Step 6: Wire AST into orchestrator, policy, and the four OWASP maps

- **Files:** `scanners/scan-orchestrator.mjs`, `scanners/lib/policy-loader.mjs`, `scanners/lib/severity.mjs`
- **Changes:** Add `import { scan as astScan } from './ast-taint-scanner.mjs';` and `{ name: 'ast', fn: astScan }` to `SCANNERS`. Add a frozen `ast` section to `DEFAULT_POLICY`: `{ enabled:true, python_path:'python3', timeout_ms:5000 }`, read via `getPolicyValue('ast', ...)`. Add array entries to all four maps: `OWASP_MAP.AST=['LLM01','LLM02']`, `OWASP_AGENTIC_MAP.AST=[]`, `OWASP_SKILLS_MAP.AST=['AST02']`, `OWASP_MCP_MAP.AST=[]`. Add registration assertions (incl. `deepEqual(OWASP_MAP.AST, ['LLM01','LLM02'])` and `enabled:false` → `skipped`) to the AST test.
- **Reuses:** same wiring sites as Step 2.
- **Test first:**
  - File: `tests/scanners/ast-taint-scanner.test.mjs` (existing — extend)
  - Verifies: AST in `SCANNERS`; `deepEqual(OWASP_MAP.AST, ['LLM01','LLM02'])`; `enabled:false` policy short-circuits to `skipped`.
  - Pattern: `tests/scanners/workflow-scanner.test.mjs:196-216`
- **Verify:** `npm test` → expected: `fail 0`
- **On failure:** revert — `git checkout -- scanners/scan-orchestrator.mjs scanners/lib/policy-loader.mjs scanners/lib/severity.mjs tests/scanners/ast-taint-scanner.test.mjs`
- **Checkpoint:** `git commit -m "feat(llm-security): wire AST scanner into orchestrator and policy"`
- **Manifest:**
  ```yaml
  manifest:
    expected_paths:
      - scanners/scan-orchestrator.mjs
      - scanners/lib/policy-loader.mjs
      - scanners/lib/severity.mjs
    min_file_count: 3
    commit_message_pattern: "^feat\\(llm-security\\): wire AST scanner into orchestrator and policy$"
    bash_syntax_check: []
    forbidden_paths: []
    must_contain:
      - path: scanners/scan-orchestrator.mjs
        pattern: "astScan"
      - path: scanners/lib/severity.mjs
        pattern: "AST"
  ```

### Step 7: Docs, scanner inventory, packaging, and full-suite gate

- **Files:** `CLAUDE.md`, `docs/scanner-reference.md`, `scanners/lib/output.mjs`, `package.json`
- **Changes:** Add TRG/SIG/AST rows + update the deterministic-scanner counts in `CLAUDE.md` and `docs/scanner-reference.md` (grep current numeric counts and bump each to reflect 3 new scanners). Add `TRG`/`SIG`/`AST` to the valid-prefix list in the `finding()` JSDoc (`output.mjs:19`). Fix the stale header comment in `scanners/scan-orchestrator.mjs` ("all 7 scanners" → actual count). Add `"knowledge/"` to the `files` array in `package.json` (correctness; moot for git-clone distribution). Run the full suite as the integration gate. No version bump here — that is Step 8.
- **Reuses:** existing doc structure in `docs/scanner-reference.md`; JSDoc prefix list (`output.mjs:19`).
- **Test first:** n/a (docs/comment/packaging). Verification = full suite + presence greps.
- **Verify:** `npm test` → expected: `fail 0`; then `grep -q '"knowledge/"' package.json && grep -q 'TRG' docs/scanner-reference.md` → expected: both match
- **On failure:** revert — `git checkout -- CLAUDE.md docs/scanner-reference.md scanners/lib/output.mjs scanners/scan-orchestrator.mjs package.json`
- **Checkpoint:** `git commit -m "docs(llm-security): document TRG/SIG/AST scanners and package knowledge/"`
- **Manifest:**
  ```yaml
  manifest:
    expected_paths:
      - CLAUDE.md
      - docs/scanner-reference.md
      - scanners/lib/output.mjs
      - package.json
    min_file_count: 4
    commit_message_pattern: "^docs\\(llm-security\\): document TRG/SIG/AST scanners and package knowledge/$"
    bash_syntax_check: []
    forbidden_paths: []
    must_contain:
      - path: package.json
        pattern: "knowledge/"
      - path: docs/scanner-reference.md
        pattern: "TRG"
  ```

### Step 8: Release v7.8.0 — version sync (OPERATOR-GATED, beyond brief scope)

- **Files:** `.claude-plugin/plugin.json`, `package.json`, `README.md`, `CHANGELOG.md`, `docs/version-history.md`, `CLAUDE.md`
- **Changes:** Bump `7.7.2 → 7.8.0` in `.claude-plugin/plugin.json` and `package.json`; update the README badge (`README.md:9`); add a `[7.8.0]` CHANGELOG entry and a `docs/version-history.md` v7.8.0 section describing the three scanners; bump the `CLAUDE.md` header version. The brief's success criteria do **not** require a release — this step exists only because the global version-sync convention demands that *if* we bump, we bump everywhere. Skip this step if the operator prefers to defer the release.
- **Reuses:** prior version-bump commit as the model (e.g. `3db6b65`, `b626ba0`).
- **Test first:** n/a. Verification = version-consistency grep.
- **Verify:** `npm test` then `grep -rl "7\\.7\\.2" .claude-plugin/plugin.json package.json README.md CHANGELOG.md CLAUDE.md docs/version-history.md` → expected: tests `fail 0`, grep prints nothing (exit 1 = no stale version left)
- **On failure:** revert — `git checkout -- .claude-plugin/plugin.json package.json README.md CHANGELOG.md docs/version-history.md CLAUDE.md`
- **Checkpoint:** `git commit -m "chore(llm-security): v7.8.0 — TRG/SIG/AST scanners"`
- **Manifest:**
  ```yaml
  manifest:
    expected_paths:
      - .claude-plugin/plugin.json
      - package.json
      - README.md
      - CHANGELOG.md
    min_file_count: 4
    commit_message_pattern: "^chore\\(llm-security\\): v7\\.8\\.0"
    bash_syntax_check: []
    forbidden_paths: []
    must_contain:
      - path: .claude-plugin/plugin.json
        pattern: "7.8.0"
      - path: README.md
        pattern: "7.8.0"
  ```

## Alternatives Considered

| Approach | Pros | Cons | Why rejected |
|----------|------|------|--------------|
| Fold AST findings into existing `taint-tracer.mjs` (no new scanner) | Fewer files | Mixes regex + AST provenance; harder to toggle/skip; muddier reporting | Separate `AST` prefix keeps provenance and graceful-skip clean |
| Ship a real JS/TS AST parser for #2 | Higher JS recall | Requires an npm dep or vendoring a parser — breaks the zero-dep invariant | Python-first (dep-free via `python3`); JS AST phased/deferred per brief |
| Use YARA binary as the SIG engine | Mature rulesets | Hard dependency; matches raw bytes only (misses obfuscation) | Pure-Node engine over the decode pipeline is dep-free and catches obfuscated malware; optional `yara` shell-out can be added later |
| Extend `permission-mapper.mjs` for trigger-abuse instead of a new scanner | One fewer file | Overloads a permission scanner with activation-surface concerns; harder to policy-gate independently | Dedicated `TRG` scanner is clearer and independently configurable |

## Test Strategy

- **Framework:** `node --test` + `node:assert/strict`. No lint/coverage gate.
- **Existing patterns:** committed `clean/`+`poisoned/` fixture dirs (model `tests/fixtures/memory-scan/`); `resetCounter()` in `beforeEach`; assert on `result.status`, `findings.length`, `finding.scanner/severity/id/owasp/evidence`; registration tests use `assert.deepEqual` on the four OWASP maps.
- **New tests:** 3 scanner test files (TRG, SIG, AST), each covering true-positive, false-positive (clean), obfuscation/scope edge cases, parse-only safety (AST), and orchestrator+OWASP registration.

### Tests to write

| Type | File | Verifies | Model test |
|------|------|----------|------------|
| Unit | `tests/scanners/trigger-scanner.test.mjs` | broad/baiting/shadow + obfuscated baiting + 4-map registration | `tests/scanners/memory-poisoning.test.mjs` + `workflow-scanner.test.mjs:196` |
| Unit | `tests/scanners/signature-scanner.test.mjs` | webshell + base64 variant + clean + family-disable + registration | `tests/scanners/memory-poisoning.test.mjs` |
| Unit | `tests/scanners/ast-taint-scanner.test.mjs` | creds→net via var, scope, python3-absent skip, parse-only safety, malformed-input resilience | `tests/scanners/vsix-sandbox.test.mjs` + `tests/scanners/dep.test.mjs` |

## Risks and Mitigations

| Priority | Risk | Location | Impact | Mitigation |
|----------|------|----------|--------|------------|
| High | AST helper executes untrusted target code | `scanners/lib/py-ast-taint.py` | RCE on scan | PARSE-ONLY invariant (`ast.parse`, never exec/eval/import); `sentinel.py` fixture + test assert no side-effect file created; 5s `spawnSync` timeout |
| Medium | AST helper hangs or emits malformed JSON | `scanners/ast-taint-scanner.mjs` | Scan stalls/crashes | `timeout: 5000`; non-zero/timeout/unparseable → `info` note, never throw; tested with malformed `.py` |
| Medium | SIG false positives on security tooling / docs | `scanners/signature-scanner.mjs` | Noise, eroded trust | Small high-confidence ruleset; path-exclude `knowledge/`/`tests/`/`docs/`; family toggles in policy |
| Medium | TRG flags legitimate broad utilities | `scanners/trigger-scanner.mjs` | False positives | Default MEDIUM advisory; HIGH only on broad-name + universal-claim combo; policy allowlist |
| Medium | python3 absent in CI/user env | `scanners/ast-taint-scanner.mjs` | AST coverage lost | Graceful `status:'skipped'`; regex `taint-tracer` still runs; both paths tested |
| Low | Wiring edits to shared files conflict if steps reordered | `scanners/scan-orchestrator.mjs` etc. | Merge friction | Sequential execution; each wiring step isolated; `forbidden_paths` fences core steps off shared files |

## Assumptions

| # | Assumption | Why unverifiable | Impact if wrong |
|---|-----------|-----------------|-----------------|
| 1 | `node --test` per-file output reports `fail 0` on success | Standard node:test behavior | Verify string adjusted to the actual output marker |
| 2 | Brief framework codes for the skills map (TRG→AST04, AST→AST02) match `OWASP_SKILLS_MAP`'s code set | Skills-map code list not fully enumerated in exploration | Adjust the specific code in Step 2/6 to a valid entry; localized, no structural impact |

## Verification

End-to-end checks crossing step boundaries (per-step manifests are checked automatically during execution):

- [ ] `npm test` → expected: all tests pass (`fail 0`), no regression in the existing 1820+ suite
- [ ] `node scanners/scan-orchestrator.mjs <a sample project> --format json` → expected: JSON `results` include `trg`, `sig`, `ast` keys
- [ ] `! grep -q '"dependencies"' package.json` → expected: exit 0 (no `dependencies` key was introduced — zero-dep invariant holds)
- [ ] `node --test tests/scanners/ast-taint-scanner.test.mjs` with `python3` present and (simulated) absent → expected: both pass; absent path yields `status:'skipped'`

## Estimated Scope

- **Files to modify:** ~7 (`scanners/scan-orchestrator.mjs`, `scanners/lib/policy-loader.mjs`, `scanners/lib/severity.mjs`, `scanners/lib/output.mjs`, `CLAUDE.md`, `docs/scanner-reference.md`, `package.json`; +6 more in optional Step 8)
- **Files to create:** ~11 (3 scanners, 1 python helper, 1 knowledge JSON, 3 test files, fixture dirs)
- **Complexity:** medium

## Execution Strategy

Three independent scanner tracks, a docs/packaging wrap-up, and an operator-gated release. Core steps
touch only new files (parallel-safe); wiring steps touch shared `scanners/scan-orchestrator.mjs` /
`policy-loader.mjs` / `severity.mjs` and must serialize. Default execution is sequential in brief
order (TRG → SIG → AST → wrap-up → release).

### Session 1: TRG scanner
- **Steps:** 1, 2
- **Wave:** 1
- **Depends on:** none
- **Scope fence:** Touch `scanners/trigger-scanner.mjs`, its test+fixtures, shared wiring files (Step 2). Never touch SIG/AST scanner files.

### Session 2: SIG scanner
- **Steps:** 3, 4
- **Wave:** 1
- **Depends on:** none (core); wiring serializes after Session 1's wiring
- **Scope fence:** Touch `scanners/signature-scanner.mjs`, `knowledge/signatures.json`, its test+fixtures, shared wiring files (Step 4). Never touch TRG/AST scanner files.

### Session 3: AST scanner
- **Steps:** 5, 6
- **Wave:** 1
- **Depends on:** none (core); wiring serializes after Session 2's wiring
- **Scope fence:** Touch `scanners/lib/py-ast-taint.py`, `scanners/ast-taint-scanner.mjs`, its test+fixtures, shared wiring files (Step 6). Never touch TRG/SIG scanner files.

### Session 4: Docs + packaging
- **Steps:** 7
- **Wave:** 2
- **Depends on:** Sessions 1–3
- **Scope fence:** Touch docs + `output.mjs` JSDoc + orchestrator comment + `package.json` files. Never touch scanner logic.

### Session 5: Release (operator-gated)
- **Steps:** 8
- **Wave:** 3
- **Depends on:** Session 4
- **Scope fence:** Version files only.

### Execution Order

- **Wave 1:** Sessions 1, 2, 3 — core steps parallel-safe; wiring steps serialize on shared files
- **Wave 2:** Session 4 (after Wave 1)
- **Wave 3:** Session 5 (operator-gated; optional)

### Grouping rules applied

- Steps sharing files → same session (each scanner's core + wiring)
- Independent scanners → separate sessions (parallelizable cores)
- Wrap-up and release depend on all scanners → later waves

## Plan Quality Score

| Dimension | Weight | Score | Notes |
|-----------|--------|-------|-------|
| Structural integrity | 0.15 | 88 | core-before-wiring TDD ordering; parallel-safe waves; correct file paths |
| Step quality | 0.20 | 85 | concrete reuse citations; correct imports/arg types post-revision |
| Coverage completeness | 0.20 | 88 | all 3 build items; four OWASP maps; packaging; brief success criteria mapped |
| Specification quality | 0.15 | 85 | no placeholders; AST JSON contract + timeout specified |
| Risk & pre-mortem | 0.15 | 85 | parse-only RCE, timeout, malformed-JSON, python3-absent all handled |
| Headless readiness | 0.10 | 85 | On failure + Checkpoint per step; revert paths corrected |
| Manifest quality | 0.05 | 85 | correct expected/forbidden paths; lenient must_contain |
| **Weighted total** | **1.00** | **86** | **Grade: B** |

**Adversarial review:**
- **Plan critic:** 3 blockers + 7 majors + 5 minors found on the first draft (score 59/D); all addressed — see Revisions.
- **Scope guardian:** MIXED — three build items correctly scoped, non-goals respected; OWASP multi-map gap and path/import errors addressed; version bump isolated to operator-gated Step 8.

## Revisions

| # | Finding | Severity | Resolution |
|---|---------|----------|------------|
| 1 | Orchestrator path is `scanners/scan-orchestrator.mjs`, not repo root | blocker | All references, expected/forbidden paths, and revert commands corrected |
| 2 | Wiring imports used `./scanners/…`; siblings import as `./…` | blocker | Imports changed to `./trigger-scanner.mjs` etc. |
| 3 | `OWASP_MAP` values are arrays, asserts must use `deepEqual` | blocker | All entries are arrays; tests use `assert.deepEqual` |
| 4 | Three sibling OWASP maps (AGENTIC/SKILLS/MCP) never updated | major | Steps 2/4/6 wire all four maps; brief's primary frameworks (TRG→AST04, SIG→ASI04) included |
| 5 | `knowledge/` not in `package.json` `files` | major | Step 7 adds `knowledge/` (noted moot for git-clone distribution) |
| 6 | Zero-dep verify checked a non-existent `dependencies` object | major | Verify changed to `! grep -q '"dependencies"' package.json` |
| 7 | `getPolicyValue` 4th arg is `projectRoot`, not `targetPath` | major | Clarified: pass scan-root `targetPath` (where `.llm-security/policy.json` lives) |
| 8 | README/CHANGELOG omitted from version bump | major | Folded into operator-gated Step 8 with a full version-consistency grep |
| 9 | Step 7 manifest orphan path + ambiguous "update count" | major | Step 7 rescoped; expected_paths aligned; counts bumped by grep |
| 10 | AST helper JSON contract / timeout / failure path unspecified | major | Step 5 defines the JSON contract, 5s timeout, and non-zero/malformed handling |
| 11 | `parseFrontmatter` already exported (Assumption #1 pessimistic) | minor | TRG reuses `scanners/lib/yaml-frontmatter.mjs`; assumption removed |
| 12 | finding() JSDoc prefix list, orchestrator header comment drift | minor | Updated in Step 7 |
| 13 | `must_contain` whitespace fragility; verify orchestrator path | minor | Patterns loosened to tokens; verify uses `scanners/scan-orchestrator.mjs` |
| 14 | Version bump is beyond brief scope | minor (scope) | Isolated into operator-gated Step 8 |
