llm-security/docs/plans/trekplan-2026-06-20-skillspector-gap-analysis.md
Kjell Tore Guttormsen 8a772b0534 docs(llm-security): SkillSpector gap analysis + reviewed execution plan
Brief + 8-step trekplan for 3 new dep-free scanners: TRG (trigger-abuse),
SIG (signature engine over the decode pipeline), AST (Python AST taint).
Adversarial review fixed 3 blockers + 7 majors on draft 1 (59/D -> 86/B).

STATE.md prepped as a self-contained cold-start for /trekexecute next session.
Un-gitignore STATE.md (global ~/.claude rule overrides old polyrepo convention);
ignore generated plan annotation HTML. No scanner code yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3s6WnubSSrFjAQTLQdVbG
2026-06-20 09:00:24 +02:00

35 KiB
Raw Blame History

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 dependencypackage.json has no dependencies key today and must keep none. The 1820+ test suite must stay green.

Architecture Diagram

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.mjsfour 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:
    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:
    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:
    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:
    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:
    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:falseskipped) 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:
    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:
    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:
    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 13
  • 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