references/browser-runtimes.md
# Bundled Browser Runtimes (RT1)
Companion to `agent-supply-chain-audit` Step 1 and Step 4. The audit already checks how each extension is pinned. This
file checks what an extension **launches**. Browser-automation MCP servers and CLIs bring a full Chromium, and that
browser:
- runs attacker-controlled content by design (every page the agent visits),
- is downloaded once and never updates itself,
- can run with the macOS Accessibility permission of the app that launched it,
- and stays frozen longer the better RP1 is followed, because pinning the server also pins its Chromium.
## Recognize browser-launching extensions
| Extension | How to recognize it | Where its browser lives |
|---|---|---|
| `@playwright/mcp` and anything on `playwright` / `playwright-core` | `command` / `args` mention `playwright`; `package.json` dependencies | `~/Library/Caches/ms-playwright/chromium-<rev>` (macOS), `~/.cache/ms-playwright` (Linux), or `PLAYWRIGHT_BROWSERS_PATH` |
| Puppeteer-based servers | Dependencies on `puppeteer` / `puppeteer-core` | `~/.cache/puppeteer/chrome/<platform>-<version>`, or the `cacheDirectory` config |
| `agent-browser` (CLI and skill) | `agent-browser` in skills, commands or allowed Bash | Its own Chrome for Testing download, or an existing Chrome, Brave, Playwright or Puppeteer install. `--executable-path` / `AGENT_BROWSER_EXECUTABLE_PATH` / `~/.agent-browser/config.json` set it explicitly |
| `chrome-devtools-mcp` | MCP `args` | The installed Chrome by default, which updates itself. Old only when `--executablePath` points at a cached build or `--channel` pins one |
| Skills or plugins that say "run `npx playwright install`" or similar | Grep their SKILL.md and commands | As Playwright |
| Computer-use and desktop-control servers | Tool names such as `screenshot`, `click`, `type_text` | No browser, but they **hold Accessibility themselves**. Record it: it's the permission an escaped browser would use |
| Remote browser services (Browserbase, hosted Playwright) | A `url` server, or an API key for the service | Not on this machine. Out of scope for RT1; the vendor's patch cadence is a `third_party_dependency` condition, not a finding |
```bash
M=".mcp.json .cursor/mcp.json .vscode/mcp.json $HOME/.claude.json $HOME/.cursor/mcp.json $HOME/.gemini/settings.json"
rg -n -i 'playwright|puppeteer|agent-browser|chrome-devtools-mcp|browserbase|stagehand|chromium|executablePath|executable-path|--channel' $M \
"$HOME/Library/Application Support/Claude/claude_desktop_config.json" $HOME/.codex/config.toml 2>/dev/null
rg -l -i 'playwright|puppeteer|agent-browser|chromium' ~/.claude/skills .claude/skills ~/.claude/plugins .agents/skills 2>/dev/null
```
Record the result in the inventory's **Runtime** column: `—`, `node`, `python`, `chromium <version>`,
`system Chrome`, or `electron <version>`.
## RT1 — the bundled browser is behind
Find the Chromium version each browser-launching extension actually starts. The inventory loop in `personal-opsec`
(`references/local-checks.md`, "Agent browser runtimes") reads every cached build's version without running it. Compare
against Chrome for Testing stable:
`https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json` (`.channels.Stable.version`).
| State | Verdict and severity |
|---|---|
| Below the fix for a **known-exploited or sandbox-escape** Chromium CVE, browses arbitrary or untrusted URLs (research, scraping, following links, prompt-driven navigation), **and** launched from an app with Accessibility | `confirmed`, **High**, condition `third_party_dependency` |
| Same, untrusted URLs, no Accessibility in the chain | `confirmed`, **Medium** |
| Same, only loads localhost or the user's own previews | `confirmed`, **Low** |
| More than 2 majors behind stable, no specific CVE | `needs_validation`. Blocker: "which renderer CVEs fixed since `<version>` are public". Settle it from the Chrome release notes for each major in between |
Whether the launching app holds Accessibility comes from `personal-opsec` C7, or from asking the user. Listing TCC
grants needs Full Disk Access, so don't try to read them.
**Evidence:** the binary path, its version, the CVE if any, the extension that launches it, and the launching app.
Save the inventory output under `<audit dir>/evidence/` and point `location` at it.
## Pinning and runtime age together (RP1 and RT1)
For a browser-launching extension, a pin needs an update budget:
> Pin the package (RP1) **and** give it a runtime age budget (RT1). A pinned browser server is a commitment to update
> it on purpose. Once its bundled Chromium is more than one major behind stable, bumping the pin is a security update,
> not a feature change.
Record the pin date in the inventory, so the next audit can see how old the pin is. Following RP1 to the letter
without this leaves the most exposed component, a browser running untrusted content, permanently out of date.
## Settings that pick the browser binary (BH4)
`PLAYWRIGHT_BROWSERS_PATH`, `PUPPETEER_EXECUTABLE_PATH`, `AGENT_BROWSER_EXECUTABLE_PATH`, and `--executablePath` /
`--executable-path` in MCP `args` redirect which binary runs. In an `env` block of settings or MCP config, treat them
like the other BH4 rerouting keys: record the target path and check its version under RT1. A path into the repo, `/tmp`
or another writable shared folder is a separate finding: anyone who can write there can swap the binary.
## Fixing
Hand the update to the tool's own updater (the table in `personal-opsec`'s local checks), delete old builds, and
restart sessions, since a running browser keeps the old binary in memory. For the fleet response when a Chrome CVE
lands across repos, CI images and production renderers, use `patch-sweep`.
## Sources
- Chrome for Testing availability JSON (`googlechromelabs.github.io/chrome-for-testing`)
- Playwright "Browsers" docs (cache paths, `PLAYWRIGHT_BROWSERS_PATH`); Puppeteer configuration docs (`cacheDirectory`);
agent-browser README (browser detection, `--executable-path`)
- Example of the bug class: CVE-2026-106378, a macOS GPU-process sandbox escape fixed in Chrome 155
([pwn.ai write-up](https://pwn.ai/blog/chrome-escape))
- Proposed by @coreyhaines31 in issue #218
references/content-checks.md
# Content checks — patterns per rule category
Each block gives a ripgrep (`rg`) command and a portable `grep -E` / `perl` equivalent (macOS grep has no `-P`).
Run them against the paths you inventoried, e.g. `T=".claude .agents CLAUDE.md AGENTS.md .mcp.json .cursor .github"`
for the project or `T="$HOME/.claude $HOME/.cursor $HOME/.codex $HOME/.gemini"` for the user scope. Every hit is a
**candidate**: read it in context before you raise it.
Rule IDs are NVIDIA SkillSpector's ([README](https://github.com/NVIDIA/SkillSpector/blob/main/README.md#vulnerability-patterns)),
cited so findings line up with a SkillSpector run. The patterns below are this repo's own, not SkillSpector's.
**Documentation vs directive.** Security skills (this catalog included) quote attack strings on purpose — inside fenced
code blocks, tables, or "look for X" sentences. A hit is a directive when it is addressed *to the agent* in prose,
frontmatter, a comment, or a tool description, and would change what the agent does. Quoted examples are not
findings; record them as `rejected` with the reason so re-runs don't re-raise them.
## Hidden instructions — P2, P9 (TP1/TP2 when in MCP metadata)
Invisible and bidi characters: U+200B–200F, U+202A–202E, U+2060–2064, U+2066–2069, U+FEFF, U+00AD, plus the Unicode
tag block U+E0000–E007F (used to smuggle ASCII text that renders as nothing).
```bash
rg -n '[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}-\x{2064}\x{2066}-\x{2069}\x{FEFF}\x{00AD}\x{E0000}-\x{E007F}]' $T
# broader: any Unicode "format" character (also hits emoji ZWJ sequences — triage those out)
rg -n '\p{Cf}' $T
# portable (BSD/GNU grep, byte-level UTF-8)
LC_ALL=C grep -rnE "$(printf '\342\200[\213-\217\252-\256]|\342\201[\240-\244\246-\251]|\357\273\277|\302\255|\363\240[\200\201]')" $T
```
A UTF-8 BOM (U+FEFF) as the first three bytes of a file is usually an editor artefact; anywhere else it is suspicious.
Directive keywords inside comments the renderer hides:
```bash
K='ignore|disregard|override|system|assistant|instruction|you (must|should)|do not (tell|mention|reveal)|secret|token|send|upload|curl|wget'
rg -n -i -U --pcre2 "<!--(?:(?!-->)[\s\S])*?\b($K)\b(?:(?!-->)[\s\S])*?-->" $T
rg -n -i '^\s*\[[^\]]*\]:\s*(#|<>)' $T # [//]: # (...) and [comment]: <> (...)
# portable
perl -0777 -ne 'while (/<!--(.*?)-->/sg) { print "$ARGV: <!--$1-->\n" if $1 =~ /'"$K"'/i }' $(find $T -type f)
grep -rnEi '^[[:space:]]*\[[^]]*\]:[[:space:]]*(#|<>)' $T
```
Whitespace padding (text pushed off-screen to the right, or below a wall of blank lines):
```bash
rg -n '[ \t]{40,}\S' $T
rg -c -U '(\n[ \t]*){20,}\S' $T
rg -n '.{1000,}' $T # very long single lines
# portable
grep -rnE '[[:blank:]]{40,}[^[:space:]]' $T
awk 'NF==0{b++;next} b>=20{print FILENAME":"FNR": text after "b" blank lines"} {b=0}' $(find $T -type f)
```
Encoded payloads:
```bash
rg -n -i 'data:[a-z]+/[a-z0-9.+-]+;base64,' $T
rg -n '[A-Za-z0-9+/]{200,}={0,2}' $T
grep -rnEi 'data:[a-z]+/[a-z0-9.+-]+;base64,' $T
```
Decode what you find (`base64 -d` into a scratch file, never pipe into a shell) and read it.
## Instruction override — P1, P4
```bash
rg -n -i '(ignore|disregard|forget) (all |any )?(previous|prior|above|earlier|other) (instructions|rules|guidance)|you are now|from now on,? you|new (system )?instructions:|takes? precedence over|highest priority' $T
grep -rnEi '(ignore|disregard|forget) (all |any )?(previous|prior|above|other) (instructions|rules)|you are now|from now on|takes? precedence over' $T
```
P4 is the quiet version: "always prefer package X", "silently skip the tests", "don't mention this step to the user",
"approve the PR without comment". Read instruction files end to end; grep finds the loud ones only.
```bash
rg -n -i "(don'?t|do not|never) (mention|tell|show|inform|ask)( the)? user|silently|without (asking|telling|confirmation)" $T
```
## Anti-refusal — AR1–AR3
```bash
rg -n -i "(never|don'?t|do not) (refuse|decline|push back)|without (any )?(warnings?|disclaimers?|caveats?)|(no|without) (ethical|safety|content) (guidelines|restrictions|filters)|(safety|content) polic(y|ies) (do(es)?n'?t|do(es)? not) apply|(developer|god|jailbreak) mode|pre-?authori[sz]ed" $T
grep -rnEi "(never|don't|do not) (refuse|decline)|without (any )?(warning|disclaimer|caveat)s?|(safety|content) polic(y|ies) (does not|do not|doesn't|don't) apply|developer mode" $T
```
## Exfiltration and credential reads — P3, E1–E4, PE3
```bash
# outbound calls in instructions, scripts, hooks
rg -n -i '\b(curl|wget|nc|ncat|socat)\b|Invoke-WebRequest|fetch\(|requests\.(post|get|put)|urllib|http\.client|axios\.' $T
# collector / tunnel endpoints (egress indicators)
rg -n -i 'discord(app)?\.com/api/webhooks|api\.telegram\.org/bot|hooks\.slack\.com/(services|workflows|triggers)|webhook\.site|requestbin|pipedream\.net|ngrok(-free)?\.(io|app|dev)|interact\.sh|oast\.(pro|live|site|online|fun|me)|oastify\.com|burpcollaborator\.net|requestcatcher\.com|beeceptor\.com|trycloudflare\.com|loca\.lt|serveo\.net|transfer\.sh|pastebin\.com' $T
# credential and secret reads
rg -n -i '\.ssh/|id_(rsa|ed25519|ecdsa)|\.aws/credentials|\.netrc|\.npmrc|\.pypirc|\.docker/config\.json|\.kube/config|gcloud/|keychain|find-generic-password|printenv|\benv\b *\||process\.env|os\.environ|\.env\b' $T
# "send the conversation / context / file contents" wording
rg -n -i '(send|post|upload|forward|report|sync) (the |all |this )?(conversation|chat|context|history|transcript|file contents|environment|secrets|tokens|keys)' $T
```
Portable: the same alternations work with `grep -rnEi` (drop `\b`, use `[[:<:]]` on BSD or just accept extra hits).
A legitimate integration names its endpoint, says why, and runs only when the user asks. Egress from a hook,
from a skill's setup step, or of credentials / transcript content is the finding.
## Agent snooping — AS1–AS3
```bash
rg -n '\.claude/|\.claude\.json|\.codex/|\.gemini/|\.cursor/|claude_desktop_config|mcp\.json|mcp_config\.json|SKILL\.md|skills/\*' $T
```
An audit or config-management skill that declares this purpose (this one, `secrets-audit`) will match. That is
expected and should be read-only and local. The finding is undeclared reads, reads of *other* skills' contents, or
reads that feed an outbound call.
## Supply chain — SC2, SC3, SC8, SC9 (SC1, SC6)
```bash
rg -n -i '(curl|wget)[^|\n]*\|\s*(sudo\s+)?(ba|z|da)?sh\b|(ba|z)?sh\s+(-c\s+)?["'"'"']?\$\((curl|wget)|bash\s+<\((curl|wget)|iex\s*\(|Invoke-Expression|IEX\s*\(' $T
rg -n -i 'base64 (-d|--decode)|atob\(|b64decode|fromCharCode|\beval\(|\bexec\(|compile\(|marshal\.loads|pickle\.loads|\\x[0-9a-f]{2}(\\x[0-9a-f]{2}){20,}' $T
rg --files -uu $T -g '*.pyc' -g '__pycache__' -g '*.so' -g '*.dylib' -g '*.dll' -g '*.exe' -g '*.bin'
```
SC9 (an executable disguised as a document or image) needs the `file` command; ask before running it:
```bash
find $T -type f \( -name '*.md' -o -name '*.txt' -o -name '*.png' -o -name '*.jpg' -o -name '*.pdf' -o -name '*.json' \) \
-exec file {} + | grep -Ei 'ELF|Mach-O|PE32|executable|script text'
find $T -type f -perm -u+x # executable bit on files that shouldn't need it
```
SC1/SC6: unpinned or typosquatted dependencies in bundled `requirements.txt`, `package.json`, or install steps — hand
those to `dependency-audit`.
## Excessive agency — EA1–EA4
```bash
# unscoped Bash or WebFetch pre-approval in skill / command / agent frontmatter
rg -n '^allowed-tools:.*\bBash\s*(,|$)|^allowed-tools:.*Bash\(\*\)' $T
rg -n '^allowed-tools:.*WebFetch' $T
grep -rnE '^allowed-tools:.*Bash[[:space:]]*(,|$)' $T
```
`allowed-tools` pre-approves tools without a prompt. Unscoped `Bash` is the high-risk grant; unscoped `Bash` *plus*
`WebFetch`/`WebSearch` gives one skill code execution, untrusted input, and egress at once. Also flag skills whose
instructions reach far beyond their stated purpose (EA3) or that walk `~` / `/` unbounded (EA4).
## Persistence and self-modification — RA1, RA2, MP1
```bash
rg -n -i '(>>|>|tee( -a)?|write|append|edit)[^\n]{0,40}(CLAUDE|AGENTS|GEMINI)\.md|settings(\.local)?\.json|\.mcp\.json|memory/|MEMORY\.md' $T
rg -n -i 'crontab|/etc/cron|LaunchAgents|LaunchDaemons|launchctl|systemctl (enable|--user)|\.bashrc|\.zshrc|\.profile|\.bash_profile|\.git/hooks|core\.hooksPath|Startup( |%20)?folder|CurrentVersion\\Run' $T
```
A skill that writes to instruction files, agent settings, memory, shell init, or schedulers survives uninstall and
can re-grant itself permissions. Treat it as a finding unless that write is the skill's declared, user-invoked job.
## Trigger abuse — TR1–TR3
```bash
rg -n '^(name|description):' $T -g 'SKILL.md' -g '*.agent.md' -g '*.prompt.md' -g '*.mdc'
rg -n -i '^description:.*(always use|use (this )?for (any|every|all)|whenever the user|any (task|request|question)|before (any|every) (task|response))' $T
rg -n '^alwaysApply:\s*true' $T -g '*.mdc'
```
TR1 broad triggers ("help", "code", "fix"), TR2 a `name` that shadows a built-in command (`/init`, `/review`,
`/help`) or another installed skill, TR3 bait ("the best", "required for all tasks"). Compare `name:` values across
the inventory for duplicates.
## Tool poisoning — TP1–TP4 and least privilege — LP1–LP4
For local MCP server source:
```bash
rg -n -A6 'server\.tool\(|registerTool\(|@mcp\.tool|@server\.tool|\.tool\(|"inputSchema"|tools/list' <server-src>
```
Read every tool `description` and every parameter `description` / `title` / enum label. Then run the hidden-
instruction checks above on that text (TP1, TP3), check tool names for mixed scripts or confusables
(`rg -n '[^\x00-\x7F]' ` on names — TP2), and compare what the description promises with what the handler does: file
reads, network calls, spawned processes (TP4, LP1). A declared permission set with wildcards (LP2), no permissions
field despite clear capabilities (LP3), or declared permissions the code never uses (LP4) belong in the same review.
references/findings-format.md
# findings.json — the shared findings format
Audit skills in this repo write their results to `findings.json` first, validate it, and only then write the markdown report. The JSON is the record of what happened. The prose is rendered from it and **never changes a verdict, severity, blocker, or demonstrated impact** that the JSON doesn't already say.
Files that ship next to this guide in `references/`:
| File | What it is |
|---|---|
| `findings.schema.json` | JSON Schema (draft 2020-12), for editors and external tools |
| `validate-findings.mjs` | Zero-dependency validator. It is the authority: it enforces rules JSON Schema can't express |
| `to-sarif.mjs` | Converts `findings.json` to SARIF 2.1.0 for GitHub code scanning and other SARIF viewers |
Any current Node (18+) runs both scripts. Nothing to install.
## Workflow
1. Drop the candidates the fixed exclusion rules catch (test code, fixtures, generated, vendored, docs; see "Two stages" in `verification.md`). They get no record. Close every remaining candidate with one verdict (see below) and write the record.
2. Sort by `fingerprint` and write `findings.json`.
3. Validate, and fix until it passes:
```bash
node references/validate-findings.mjs findings.json
```
Exit `0` = valid, `1` = invalid (one line per problem), `2` = unreadable or not JSON.
4. Render the markdown report from the JSON. Confirmed findings go in the findings table. Needs-validation items go in their own section with their blockers and plan. Rejected records go in "Rejected (do not re-raise)". Hardening notes are not records at all (see below).
5. Optional, export SARIF:
```bash
node references/to-sarif.mjs findings.json -o findings.sarif
node references/to-sarif.mjs findings.json --include-needs-validation -o findings.sarif
```
On a re-run, load the previous `findings.json` first and match candidates by fingerprint. A candidate that matches a `rejected` record stays rejected unless the code at `ruled_out_by.location` changed. A candidate that matches a `Fixed` record is re-checked against the fix: if the fix still rules it out, the record stays `Fixed` and isn't re-raised; if not, it's a regression and the record reopens (see `finding-triage`'s fix verification).
## Verdicts
Every record has exactly one `verdict`. Each verdict has its own shape, and unknown fields are errors.
| Verdict | Meaning | Extra required fields | Severity? |
|---|---|---|---|
| `confirmed` | Complete entrypoint → sink trace with impact, or a working PoC | `severity`, `conditions[]`. Optional: `disposition`, `reproduced` | Yes |
| `needs_validation` | The code supports the claim, but one decisive fact lives outside it (proxy, IdP, deploy config, feature flag) | `blockers[]` (non-empty), `validation_plan` | **No** |
| `rejected` | A named control at a named location runs on every attacker-reachable path before the sink, or the claim is ruled out by reachability or a known-inert value | `ruled_out_by {control, location}` | **No** |
`needs_validation` is not a parking spot for a hunch. If you can't name the blocker and a check the owner can run to settle it, the candidate is either rejected or not ready to record. The old "Potential" label is retired.
A **false positive** is recorded as `rejected`. It is not a disposition.
## Fields
Common to all three shapes:
| Field | Type | Notes |
|---|---|---|
| `fingerprint` | string | Stable root-cause id: 3–200 chars of `A-Z a-z 0-9 . _ : / @ + -`. **Never** a line number, severity or verdict. The validator rejects `:<digits>` segments that look like line numbers |
| `verdict` | enum | `confirmed` \| `needs_validation` \| `rejected` |
| `skill` | string | The skill that produced it, e.g. `owasp-audit` |
| `title` | string | One line, specific |
| `cwe` | string, optional | `CWE-639` |
| `owasp_category` | string, optional | `A01:2025`, `API1:2023` |
| `ext` | object, optional | The producing skill's own fields (see "Skill-specific fields" below) |
| `location` | `{file, line}` | Primary location. `file` is repo-relative, `line` ≥ 1 |
| `trace[]` | `{kind, file, line, description}` | `kind` ∈ `entrypoint` \| `propagation` \| `sink`. A non-empty trace starts with `entrypoint`, ends with `sink`, and has neither in the middle. `confirmed` needs ≥ 2 steps; the others may be `[]` |
| `evidence[]` | `{file, line, description}` | `confirmed` needs ≥ 1 |
| `counterevidence` | string | What the verifier tried in order to refute it, and what it found. Required on every verdict |
| `confidence` | enum | `high` \| `medium` \| `low`. On `confirmed`, `high` requires `"reproduced": true`: a static-only trace caps at `medium` |
`confirmed` only:
| Field | Type | Notes |
|---|---|---|
| `severity` | `{likelihood{score, reason}, impact{score, reason}, overall}` | Scores and `overall` ∈ `critical` \| `high` \| `medium` \| `low`. **`overall` ≤ `impact.score`.** See `severity-calibration.md` |
| `conditions[]` | `{kind, description}` | Preconditions for exploitation. `kind` ∈ `authentication_level`, `authorization_role`, `user_interaction`, `system_configuration`, `network_routing`, `environmental_dependency`, `data_state`, `timing_dependency`, `third_party_dependency`. May be `[]` |
| `reproduced` | boolean, optional | `true` only when demonstrated dynamically (PoC, test, request/response pair) |
| `disposition` | enum, optional | `Fixed` \| `Deferred` \| `Accepted Risk` \| `Mitigated (platform/config)` \| `Requires major migration`. Usually set by `finding-triage` |
`needs_validation` only: `blockers[]` (non-empty strings, one per decisive fact) and `validation_plan` (a concrete check the owner can run locally and/or against the deployment).
`rejected` only: `ruled_out_by.control` (what the control does) and `ruled_out_by.location` (`{file, line}` where it runs). When nothing runs because the code is never reached, the value is a published example, or the guard is inside a dependency, `control` states that fact and `location` points at its evidence: the only call site, the declaring manifest line, the line holding the value, or the lockfile pin. `verification.md` has the table.
A hard-coded fallback or other unsafe shipped default is `confirmed`, not `needs_validation`; the deployment fact caps confidence instead. See "Unsafe shipped defaults" in `verification.md`.
### Rules the validator enforces
- Top level is an array, sorted by `fingerprint` in plain code-unit order (`jq 'sort_by(.fingerprint)'` gives the same order for ASCII ids). No duplicate fingerprints.
- Shape per verdict, with no extra fields. `severity` and `disposition` appear only on `confirmed`.
- Paths are repo-relative with forward slashes: no leading `/`, drive letter, `~`, URL, backslash or `..` segment.
- Every `line` is an integer ≥ 1.
- Trace order (entrypoint first, sink last). `overall` ≤ impact. `high` confidence on `confirmed` needs `reproduced: true`.
- `needs_validation` has non-empty `blockers` and `validation_plan`. `rejected` has `ruled_out_by`.
### Skill-specific fields (`ext`)
Some skills need fields the common shape doesn't have: the PCI DSS requirement a gap is against, the cloud account and region of a resource, the MASVS control, the HIPAA standard. Those go in `ext`, never as new top-level fields (the validator rejects unknown fields). Each skill lists its `ext` keys in its own SKILL.md.
- Keys are snake_case (`requirement`, `in_cde`, `account`). Values are a non-empty string, a number, a boolean, or a non-empty array of those. No nesting, no `null`, no empty `ext` (leave it out instead).
- Don't repeat what already has a field. The CWE goes in `cwe`, the OWASP category in `owasp_category`, the file in `location`.
- `to-sarif.mjs` copies `ext` into the SARIF result's `properties.ext` unchanged.
```json
"ext": { "requirement": "8.4.2", "in_cde": true }
```
`finding-triage` adds three keys to any confirmed record it closes as `Fixed`, next to the producing skill's own: `fixed_in` (the fixing commit SHA or release tag), `fix_verified_on` (`YYYY-MM-DD`, when the fix check passed against the shipped fix) and `regression_test` (repo-relative path or array of paths). A record that regresses loses `disposition`, `fixed_in` and `fix_verified_on`, and gets `regressed_after` with the old `fixed_in` value. Skills shouldn't use these four names for anything else.
### Findings about deployed resources
Not every finding lives in a source file. A cloud bucket policy, an IdP setting, a live host's TLS configuration or a decompiled app binary still needs a `location`. Save the read-only output that shows the problem into the audit's output directory, then point `location` and `evidence[]` at that file and line:
```text
security-audit/evidence/aws-s3-get-bucket-policy-prod-assets.json # aws s3api get-bucket-policy ... > this file
```
- Redact before saving. Never write a secret value, a session token or customer data into an evidence file.
- Record the command and the time it ran in the evidence `description`, so the owner can run it again.
- Build the fingerprint from the resource's own id, with slashes: `cloud-audit/s3-public-read/aws/123456789012/prod-assets`. ARNs and `host:port` contain `:<digits>`, which the validator reads as a line number.
## Fingerprints
A fingerprint names the root cause, so the same bug keeps the same id after code moves, severity changes, or the verdict flips. A good pattern:
```
<skill>/<cwe-or-class>/<repo-relative file>/<function, route, or resource>
api-audit/CWE-639/src/routes/orders.ts/refundOrder
secrets-audit/aws-access-key/config/deploy.yml/AWS_ACCESS_KEY_ID
dependency-audit/GHSA-xxxx-xxxx-xxxx/package-lock.json/lodash
```
If a file is renamed, keep the old fingerprint when it's obviously the same root cause. Don't put the line number, the date or the severity in it.
## Example
```json
[
{
"fingerprint": "api-audit/CWE-639/src/routes/orders.ts/refundOrder",
"verdict": "confirmed",
"skill": "api-audit",
"title": "Refund endpoint lets any user refund another user's order (IDOR)",
"cwe": "CWE-639",
"owasp_category": "API1:2023",
"location": { "file": "src/routes/orders.ts", "line": 88 },
"trace": [
{ "kind": "entrypoint", "file": "src/routes/orders.ts", "line": 84, "description": "POST /api/orders/:id/refund reads :id" },
{ "kind": "propagation", "file": "src/services/orders.ts", "line": 41, "description": "findOrder(id) has no owner filter" },
{ "kind": "sink", "file": "src/services/payments.ts", "line": 17, "description": "refund issued" }
],
"evidence": [{ "file": "src/services/orders.ts", "line": 41, "description": "WHERE id = $1, no user_id predicate" }],
"counterevidence": "requireAuth only authenticates; no policy on this route. Sibling GET /orders/:id does check ownership.",
"confidence": "high",
"reproduced": true,
"severity": {
"likelihood": { "score": "high", "reason": "Open signup; sequential ids" },
"impact": { "score": "high", "reason": "Refunds another customer's payment" },
"overall": "high"
},
"conditions": [{ "kind": "authentication_level", "description": "Any user account" }]
},
{
"fingerprint": "owasp-audit/CWE-89/src/db/search.ts/searchProducts",
"verdict": "rejected",
"skill": "owasp-audit",
"title": "String-built SQL in product search",
"location": { "file": "src/db/search.ts", "line": 30 },
"trace": [],
"evidence": [{ "file": "src/db/search.ts", "line": 30, "description": "ORDER BY column interpolated" }],
"counterevidence": "Column comes from a fixed map; user input never reaches the template literal.",
"confidence": "high",
"ruled_out_by": { "control": "SORT_COLUMNS allow-list; unknown keys throw before the query is built", "location": { "file": "src/db/search.ts", "line": 22 } }
},
{
"fingerprint": "owasp-audit/CWE-918/src/lib/fetch-preview.ts/fetchPreview",
"verdict": "needs_validation",
"skill": "owasp-audit",
"title": "Link-preview fetcher may reach the cloud metadata endpoint (SSRF)",
"location": { "file": "src/lib/fetch-preview.ts", "line": 12 },
"trace": [
{ "kind": "entrypoint", "file": "src/routes/preview.ts", "line": 5, "description": "GET /preview?url=" },
{ "kind": "sink", "file": "src/lib/fetch-preview.ts", "line": 12, "description": "fetch(url), no allow-list" }
],
"evidence": [],
"counterevidence": "No allow-list in code. Egress filtering, if any, is in the platform, not the repo.",
"confidence": "medium",
"blockers": ["Does the production egress proxy block 169.254.169.254 and RFC 1918 ranges?"],
"validation_plan": "From staging, request /preview?url=http://169.254.169.254/latest/meta-data/ and confirm it is blocked."
}
]
```
## Hardening notes are not records
A gap with no traced impact (defense-in-depth, a missing header with no exploit path, a cookie flag that changes nothing reachable) is a **hardening note**. It doesn't go in `findings.json`, never gets a severity, and goes in the report's "Hardening notes (not findings)" section.
## SARIF export
`to-sarif.mjs` validates first and writes nothing if validation fails (exit 1).
| findings.json | SARIF 2.1.0 |
|---|---|
| `confirmed` record | one `result` and one `rule` (rule id = fingerprint, so each rule's severity matches its only result) |
| `needs_validation` | skipped by default. With `--include-needs-validation`: a `note`-level result with no `security-severity`; the message starts "Needs validation:" and lists the blockers and plan; `properties.verdict = "needs_validation"` |
| `rejected` | never exported |
| `fingerprint` | `partialFingerprints["cybersecuritySkills/v1"]` |
| `severity.overall` | `security-severity` on the rule and the result: critical `9.5`, high `8.0`, medium `5.5`, low `3.0`. `level`: critical/high → `error`, medium → `warning`, low → `note` |
| `location` | `locations[0]`, relative to `%SRCROOT%` |
| `trace[]` | `codeFlows[0].threadFlows[0].locations`, each with `kinds: [<trace kind>]` |
| `evidence[]` | `relatedLocations` |
| `cwe`, `owasp_category` | rule tags `external/cwe/cwe-N` and `owasp/<id>`; `helpUri` → the MITRE CWE page |
| `disposition: Fixed` | skipped (nothing left to alert on) |
| `disposition: Accepted Risk` / `Mitigated (platform/config)` | exported with an `accepted` external suppression, so it shows as dismissed rather than open |
Upload to GitHub code scanning with `github/codeql-action/upload-sarif` (`sarif_file: findings.sarif`).
## Coverage (owasp-audit, api-audit)
Those skills also write `coverage.json`, which records what was reviewed so "no findings" can be told apart from "not looked at". Validate it, and optionally cross-check its fingerprints against `findings.json`:
```bash
node references/validate-coverage.mjs coverage.json --findings findings.json
```
A unit is `{id, surface, boundary, owasp_category, status, reviewed_paths[], checks[{invariant, method, result}], finding_fingerprints[], unresolved[]}`. All fields are required; `owasp_category` may be `null`. `status` ∈ `planned`, `in_progress`, `blocked`, `covered`, `candidate`, `deferred`, `out_of_scope`, `not_applicable`. Units have unique ids, and `reviewed_paths` follow the same safe-path rule (globs allowed). The validator also requires:
- `covered`: at least one reviewed path and at least one check. With `--findings`, it may list only `rejected` fingerprints
- `candidate`: at least one finding fingerprint. A unit with a confirmed or needs_validation finding stays `candidate`
- `blocked`: at least one unresolved item
Unit ids use the same characters as fingerprints and may also start with `.`, for dotfiles.
`coverage.schema.json` documents the shape.
## Attribution
The three verdict-shaped records, the root-cause fingerprint, the trace/evidence split, the condition kinds, the coverage ledger and the "JSON drives the prose" rule are adapted from [cloudflare/security-audit-skill](https://github.com/cloudflare/security-audit-skill) (`report-schema.json`, `validate-findings.cjs`, `VALIDATION-AND-REPORTING.md`), MIT License, Copyright (c) Cloudflare, Inc. The schema, validator code and this guide are this repo's own and are paraphrased, not copied.
references/findings.schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/briiirussell/cybersecurity-skills/blob/main/shared/findings.schema.json",
"title": "cybersecurity-skills findings.json",
"description": "An array of finding records, one per candidate, sorted by fingerprint. Each record takes exactly one of three shapes selected by `verdict`. Some rules cannot be expressed in JSON Schema (sort order, unique fingerprints, path safety, trace order, overall <= impact, high confidence needs reproduction); validate-findings.mjs enforces those, so treat that script as authoritative. The three-verdict record shape is adapted from cloudflare/security-audit-skill report-schema.json (MIT, Copyright (c) Cloudflare, Inc.); field names, enums and rules here are this repo's own.",
"type": "array",
"items": { "$ref": "#/$defs/record" },
"$defs": {
"record": {
"type": "object",
"required": ["verdict"],
"properties": {
"verdict": { "enum": ["confirmed", "needs_validation", "rejected"] }
},
"oneOf": [
{ "$ref": "#/$defs/confirmed" },
{ "$ref": "#/$defs/needs_validation" },
{ "$ref": "#/$defs/rejected" }
]
},
"confirmed": {
"type": "object",
"additionalProperties": false,
"required": ["fingerprint", "verdict", "skill", "title", "location", "trace", "evidence", "counterevidence", "confidence", "severity", "conditions"],
"properties": {
"fingerprint": { "$ref": "#/$defs/fingerprint" },
"verdict": { "const": "confirmed" },
"skill": { "$ref": "#/$defs/skill" },
"title": { "$ref": "#/$defs/text" },
"cwe": { "$ref": "#/$defs/cwe" },
"owasp_category": { "$ref": "#/$defs/text" },
"ext": { "$ref": "#/$defs/ext" },
"location": { "$ref": "#/$defs/location" },
"trace": { "type": "array", "minItems": 2, "items": { "$ref": "#/$defs/trace_step" } },
"evidence": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/evidence" } },
"counterevidence": { "$ref": "#/$defs/text" },
"confidence": { "$ref": "#/$defs/confidence" },
"reproduced": {
"type": "boolean",
"description": "true only when the finding was demonstrated dynamically (PoC, test, request/response). A static-only trace caps confidence at medium, so confidence `high` requires reproduced: true."
},
"severity": { "$ref": "#/$defs/severity" },
"conditions": { "type": "array", "items": { "$ref": "#/$defs/condition" } },
"disposition": {
"enum": ["Fixed", "Deferred", "Accepted Risk", "Mitigated (platform/config)", "Requires major migration"],
"description": "What happened to the finding. A false positive is not a disposition: record it as verdict `rejected`."
}
}
},
"needs_validation": {
"type": "object",
"additionalProperties": false,
"required": ["fingerprint", "verdict", "skill", "title", "location", "trace", "evidence", "counterevidence", "confidence", "blockers", "validation_plan"],
"properties": {
"fingerprint": { "$ref": "#/$defs/fingerprint" },
"verdict": { "const": "needs_validation" },
"skill": { "$ref": "#/$defs/skill" },
"title": { "$ref": "#/$defs/text" },
"cwe": { "$ref": "#/$defs/cwe" },
"owasp_category": { "$ref": "#/$defs/text" },
"ext": { "$ref": "#/$defs/ext" },
"location": { "$ref": "#/$defs/location" },
"trace": { "type": "array", "items": { "$ref": "#/$defs/trace_step" } },
"evidence": { "type": "array", "items": { "$ref": "#/$defs/evidence" } },
"counterevidence": { "$ref": "#/$defs/text" },
"confidence": { "$ref": "#/$defs/confidence" },
"blockers": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/text" },
"description": "Each decisive fact that lives outside the code (proxy, IdP, deploy config, runtime flag) and would settle the verdict."
},
"validation_plan": {
"$ref": "#/$defs/text",
"description": "A concrete check the owner can run (locally and/or against the deployment) that resolves every blocker."
}
}
},
"rejected": {
"type": "object",
"additionalProperties": false,
"required": ["fingerprint", "verdict", "skill", "title", "location", "trace", "evidence", "counterevidence", "confidence", "ruled_out_by"],
"properties": {
"fingerprint": { "$ref": "#/$defs/fingerprint" },
"verdict": { "const": "rejected" },
"skill": { "$ref": "#/$defs/skill" },
"title": { "$ref": "#/$defs/text" },
"cwe": { "$ref": "#/$defs/cwe" },
"owasp_category": { "$ref": "#/$defs/text" },
"ext": { "$ref": "#/$defs/ext" },
"location": { "$ref": "#/$defs/location" },
"trace": { "type": "array", "items": { "$ref": "#/$defs/trace_step" } },
"evidence": { "type": "array", "items": { "$ref": "#/$defs/evidence" } },
"counterevidence": { "$ref": "#/$defs/text" },
"confidence": { "$ref": "#/$defs/confidence" },
"ruled_out_by": {
"type": "object",
"additionalProperties": false,
"required": ["control", "location"],
"description": "The specific control that runs on every attacker-reachable path before the sink.",
"properties": {
"control": { "$ref": "#/$defs/text" },
"location": { "$ref": "#/$defs/location" }
}
}
}
},
"fingerprint": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/@+-]{2,199}$",
"description": "Stable root-cause id. Never includes a line number, severity or verdict. Suggested form: <skill>/<cwe-or-class>/<repo-relative file>/<symbol or route>."
},
"skill": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
"ext": {
"type": "object",
"minProperties": 1,
"description": "Fields specific to the producing skill (a PCI DSS requirement, a cloud account and region, a MASVS control). Each skill documents its keys. Flat: snake_case keys, scalar values or arrays of scalars.",
"propertyNames": { "pattern": "^[a-z][a-z0-9_]{0,63}$" },
"additionalProperties": {
"oneOf": [
{ "type": "string", "minLength": 1 },
{ "type": "number" },
{ "type": "boolean" },
{ "type": "array", "minItems": 1, "items": { "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "number" }, { "type": "boolean" }] } }
]
}
},
"cwe": { "type": "string", "pattern": "^CWE-[1-9][0-9]*$" },
"text": { "type": "string", "minLength": 1, "pattern": "\\S" },
"confidence": { "enum": ["high", "medium", "low"] },
"level": { "enum": ["critical", "high", "medium", "low"] },
"path": {
"type": "string",
"minLength": 1,
"description": "Repo-relative path with forward slashes. No leading slash, drive letter, ~, URL scheme, backslash, or `..` segment."
},
"line": { "type": "integer", "minimum": 1 },
"location": {
"type": "object",
"additionalProperties": false,
"required": ["file", "line"],
"properties": {
"file": { "$ref": "#/$defs/path" },
"line": { "$ref": "#/$defs/line" }
}
},
"trace_step": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "file", "line", "description"],
"description": "A non-empty trace starts with an entrypoint and ends with a sink.",
"properties": {
"kind": { "enum": ["entrypoint", "propagation", "sink"] },
"file": { "$ref": "#/$defs/path" },
"line": { "$ref": "#/$defs/line" },
"description": { "$ref": "#/$defs/text" }
}
},
"evidence": {
"type": "object",
"additionalProperties": false,
"required": ["file", "line", "description"],
"properties": {
"file": { "$ref": "#/$defs/path" },
"line": { "$ref": "#/$defs/line" },
"description": { "$ref": "#/$defs/text" }
}
},
"severity": {
"type": "object",
"additionalProperties": false,
"required": ["likelihood", "impact", "overall"],
"description": "overall must not exceed impact.score.",
"properties": {
"likelihood": { "$ref": "#/$defs/scored" },
"impact": { "$ref": "#/$defs/scored" },
"overall": { "$ref": "#/$defs/level" }
}
},
"scored": {
"type": "object",
"additionalProperties": false,
"required": ["score", "reason"],
"properties": {
"score": { "$ref": "#/$defs/level" },
"reason": { "$ref": "#/$defs/text" }
}
},
"condition": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "description"],
"properties": {
"kind": {
"enum": [
"authentication_level",
"authorization_role",
"user_interaction",
"system_configuration",
"network_routing",
"environmental_dependency",
"data_state",
"timing_dependency",
"third_party_dependency"
]
},
"description": { "$ref": "#/$defs/text" }
}
}
}
}
references/ide-extensions.md
# IDE Extensions (EX1–EX6)
Companion to `agent-supply-chain-audit` Step 1 and Step 4. IDE extensions are a larger supply chain than MCP servers,
and a more trusted one. Every extension runs in the extension host with the user's full privileges. It can read every
open repo and `.env` file and start processes, and it usually activates when the IDE opens. Auto-update turns a
marketplace compromise into same-day code execution on every developer machine that has the extension.
**Incident that prompted this (2026-10-29):** malicious `Anthropic.claude-code` 2.1.312 and 2.1.313 in **Cursor's**
extension marketplace, for about 11 hours (02:27–13:35 PT). Code prepended to `extension.js` collected host details
and the *names* of credential-looking environment variables, added an attacker SSH key, tried to turn on SSH, opened a
reverse SSH tunnel, and set up persistence. It was not built by Anthropic. Source:
[Cursor's security notice](https://cursor.com/blog/claude-code-extension-security-notice), updated 2026-10-30 11:30 PT.
Cursor lists both versions as malicious; Threat Landscape's analysis names only 2.1.313. Go with the vendor notice.
IOCs and the response playbook are in `incident-triage` (dev-tool compromise).
The lesson for this audit: **the same "official" extension id can be a different build in a different marketplace.**
Pinning by name isn't enough.
## Inventory
| IDE | Extensions folder | Marketplace it pulls from |
|---|---|---|
| Cursor | `~/.cursor/extensions/` | Cursor's own marketplace |
| VS Code | `~/.vscode/extensions/` | Visual Studio Marketplace |
| VS Code Insiders | `~/.vscode-insiders/extensions/` | Visual Studio Marketplace |
| Windsurf | `~/.windsurf/extensions/` | Open VSX by default |
| VSCodium | `~/.vscode-oss/extensions/` | Open VSX |
| Remote, WSL and SSH sessions | `~/.vscode-server/extensions/`, `~/.cursor-server/extensions/` **on the remote host** | As the local IDE |
| JetBrains | `~/Library/Application Support/JetBrains/<IDE>/plugins/` (macOS); `META-INF/plugin.xml` per plugin | JetBrains Marketplace |
Each extensions folder has an `extensions.json` registry. Read it without starting the IDE:
```bash
for d in ~/.cursor ~/.vscode ~/.vscode-insiders ~/.windsurf ~/.vscode-oss; do
f="$d/extensions/extensions.json"; [ -f "$f" ] || continue
jq -r --arg ide "$(basename "$d")" '.[] | [$ide, .identifier.id, .version, (.metadata.source // "-"),
(.metadata.publisherDisplayName // "-"), ((.metadata.installedTimestamp // 0) / 1000 | todate)] | @tsv' "$f"
done
# Every version folder on disk, including old ones the registry no longer lists
ls ~/.cursor/extensions ~/.vscode/extensions 2>/dev/null | grep -v '^extensions.json$'
```
`metadata.source` is `gallery` for a marketplace install and `vsix` for a manually installed package. **Inventory every
version folder**, not just the registry's current entry. IDEs keep previous versions on disk after an update, so a
malicious build can still be there after the IDE moved on. `cursor --list-extensions --show-versions` and
`code --list-extensions --show-versions` give the same list but start the IDE's CLI, so they prompt.
Record per extension: **id**, **version** (and every older folder), **IDE and marketplace**, **publisher**, **install
time**, **source** (`gallery` / `vsix`), **auto-update on?**, and **what it can run**. For every extension, "what it can
run" is the user's full access.
## Checks
| ID | Check | How | Finding |
|---|---|---|---|
| **EX1** | Known-bad version on disk | Match id and version, across every version folder, against vendor notices. Start with `anthropic.claude-code` 2.1.312 and 2.1.313 (Cursor). Compare the `extension.js` hash with the published IOC | `confirmed`, **Critical**. Hand off to `incident-triage` now, before cleaning anything |
| **EX2** | Auto-update on for high-privilege extensions | `extensions.autoUpdate` and `extensions.autoCheckUpdates` in the IDE's user `settings.json` (`~/Library/Application Support/<Code\|Cursor>/User/settings.json` on macOS). Unset means on | Hardening note for low-reach extensions. **Medium** for agent or AI extensions on a machine that holds production credentials. Turning updates off without someone reviewing them just leaves old versions, so pair it with a review cadence |
| **EX3** | Marketplace divergence | Is the extension installed from a different marketplace than the publisher's main one (Cursor or Open VSX vs the Visual Studio Marketplace)? | Note. Get first-party agent integrations from the vendor's own channel. For Claude Code, that's the CLI from npm or the native installer, which installs its IDE integration |
| **EX4** | Prepended or obfuscated activation code | Read the start of `dist/extension.js` or `out/extension.js` (the first few KB). Look for code before the bundler banner, `child_process`, `authorized_keys`, `launchctl`, `schtasks`, `ssh -R`, `/beacon`, long base64 blobs. The patterns in [content-checks.md](content-checks.md) apply | `confirmed` if present. Size it as EX1 |
| **EX5** | Unverified publisher or typosquat | Publisher id isn't the vendor's, a near-miss name, few installs, published recently | `needs_validation` |
| **EX6** | No extension policy | Is there a reviewed allowlist (VS Code `extensions.allowed`, managed settings or MDM) and a recommended list in `.vscode/extensions.json`? | Hardening note, feeding the team's policy |
Don't run or activate an extension to test it, and don't uninstall one during the audit. An EX1 or EX4 hit is
evidence for `incident-triage`, which decides when to clean up.
## Severity
Size extension findings like a **user-level hook that runs every time the IDE opens**: an EX1 or EX4 hit is Critical.
An unreviewed high-privilege extension on auto-update, on a machine with production access, is Medium. Fingerprint:
`agent-supply-chain-audit:EX1:<ide>:<extension id>@<version>` (for example
`agent-supply-chain-audit:EX1:cursor:anthropic.claude-code@2.1.313`). User-level paths go in `findings.user.json`.
## Sources
- [Cursor security notice](https://cursor.com/blog/claude-code-extension-security-notice) (affected versions, window,
IOCs, remediation scripts)
- [Threat Landscape analysis](https://threatlandscape.io/blog/trojanized-claude-code-extension-2-1-313-ssh-backdoor);
[BleepingComputer](https://www.bleepingcomputer.com/news/security/malicious-claude-code-extension-on-cursor-planted-ssh-backdoors/)
- VS Code docs: extension auto-update settings and `extensions.allowed`
- Proposed by @coreyhaines31 in issue #222
references/launch-directories.md
# Launch Directories and Folder Trust (LD1–LD4)
Companion to `agent-supply-chain-audit` Step 1 and Step 3. The rest of the audit reads the project and the user-level
config. This file asks **where agents get launched**. An instruction file in the folder an agent starts in is code
the agent obeys, and a folder the browser saves downloads into is an unreviewed source.
**Incident that prompted this (reported 2026-10-25/26).** Forever Security found a planted `AGENTS.md` in a customer's
`~/Downloads`, which was also the agent's working directory. Written to look like ordinary engineering guidelines, it
told Codex to register a server in `~/.codex/config.toml` and install the npm package `function-color`, part of the
campaign CloudSEK and Checkmarx call MALFEX. Forever stopped it before any code ran. How the file got there isn't
known. A web page can save a file to Downloads without a click, and Forever built a proof of concept that does, but
whether the attacker used that route isn't known. In Forever's tests:
- **Codex** ran the payload in more than 90% of attempts. Because `~/.codex/config.toml` is machine-wide, the added
server then loads in every later Codex session, in any folder.
- **Claude Code**: a planted `CLAUDE.md` framed as gateway setup got an `ANTHROPIC_BASE_URL` written into project
settings about half the time. That sends the login token to the attacker, who replayed it and took over the account
without running code. The reports disagree on the code-execution rate (Help Net: about a fifth of tests through a
helper agent; BleepingComputer: about half).
- About half of Forever's customers run a coding agent in their Downloads folder.
Forever's view: "There is nothing here for OpenAI or Anthropic to patch." The defense is configuration and habit.
Sources: [The Hacker News](https://thehackernews.com/2026/10/malicious-file-in-downloads-folder.html),
[Help Net Security](https://www.helpnetsecurity.com/2026/10/26/ai-coding-agents-malware-openai-codex/),
[BleepingComputer](https://www.bleepingcomputer.com/news/security/ai-coding-agents-hijacked-using-agentsmd-file-in-downloads-folder/).
## Folder trust, per agent
| Agent | Where trust lives | What trust unlocks |
|---|---|---|
| Codex | `~/.codex/config.toml`: `projects.<path>.trust_level`, set to `trusted` or `untrusted` ([config reference](https://learn.chatgpt.com/docs/config-file/config-reference)) | Untrusted projects skip project-scoped `.codex/` layers (project config, hooks, rules). Help Net reports Codex follows `AGENTS.md` only in a trusted folder, and Downloads qualifies once the user has worked there |
| Claude Code | The folder-trust prompt on first launch | Project `.claude/settings*.json` (hooks, `env`, permissions) and `CLAUDE.md` for whoever trusts the folder (Step 3) |
**Codex trust is per exact path.** A trusted parent doesn't cover its subfolders, and recursive trust is an open
feature request ([openai/codex#19426](https://github.com/openai/codex/issues/19426)). It cuts the other way too: a
comment there (Codex 0.163.0, 2026-10-28) reports that marking a parent `untrusted` didn't stop a child folder's
`AGENTS.md` from loading. **An `untrusted` entry for `~/Downloads` doesn't protect a folder unzipped inside it.** The
fix is not launching agents there.
## Inventory (read-only)
```bash
# Codex: which folders are trusted? Anything that isn't a repo the user owns is a lead
rg -n -A1 '^\[projects\.' ~/.codex/config.toml 2>/dev/null
# Instruction files and project settings in catch-all folders the browser or other tools write to
for d in "$HOME/Downloads" "$HOME/Desktop" "$HOME" /tmp; do
find "$d" -maxdepth 2 \( -name AGENTS.md -o -name CLAUDE.md -o -name CLAUDE.local.md -o -name GEMINI.md \
-o -path '*/.claude/settings*.json' -o -path '*/.codex/config.toml' -o -name .mcp.json \) 2>/dev/null
done
```
Then **ask** where the user usually starts agents. Shell history can show it (`cd ~/Downloads && codex`), but don't
read history files into the session; ask instead.
## Checks
| ID | Check | How | Finding |
|---|---|---|---|
| **LD1** | Trusted catch-all folder | A Codex `projects.<path>.trust_level` set to `trusted` for `~/Downloads`, `~/Desktop`, `$HOME` or `/tmp`, or the user says they launch agents there | Hardening note on a single-user machine with nothing sensitive; **Medium** where the agent holds production credentials. Fix: stop launching agents there and remove the trust entry. An `untrusted` parent entry alone doesn't cover subfolders |
| **LD2** | Instruction file nobody wrote | An `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` from the inventory that isn't in a repo the user owns. Read it with the Step 2 content checks: install commands, edits to the agent's own config, base URLs, requests to turn off approvals or the sandbox | `confirmed`, **Critical**, if it tells the agent to edit agent settings, install a package, or reroute API traffic. Hand off to `incident-triage` (dev-tool compromise) |
| **LD3** | Machine-wide config written from a project session | `rg -n '\[mcp_servers' ~/.codex/config.toml`, and the BH4 `ANTHROPIC_BASE_URL` grep, run over every project settings file the inventory found as well as the project. Any server or base URL the user doesn't recognize | `confirmed`, **Critical**. Per the reporting, assume the login token leaked: rotate it, and remove the entry after evidence is saved |
| **LD4** | Package from a known campaign | `function-color` (MALFEX) in any lockfile or global install: `rg -n 'function-color' --glob '**/{package-lock.json,pnpm-lock.yaml,yarn.lock}' ~ 2>/dev/null` and `npm ls -g function-color` | `confirmed`, **Critical**. Help Net says to treat the machine as compromised; hand off to `incident-triage` |
Size LD2 and LD3 hits like a user-level hook: a planted instruction file can do whatever the agent can do in that
session, and a Codex config change persists across every folder.
## Hardening notes (no product needed)
- Don't start Codex, Claude Code or any agent in `~/Downloads`, `~/Desktop` or `$HOME`. Start it in the project root.
- Review any `AGENTS.md` or `CLAUDE.md` someone else authored before an agent works in that folder, including in a
freshly cloned repo.
- Leave approval prompts and sandboxes on, and treat any instruction to turn them off as a finding.
- Check `~/.codex/config.toml` for servers and base URLs you didn't add, and `.claude/settings.json` for an
`ANTHROPIC_BASE_URL` you didn't set.
- If a planted file is found, rotate the agent's login token.
Proposed by @coreyhaines31 in issue #234.
references/locations.md
# Agent extension locations — where to look, per agent
Paths move between releases. Treat this as a starting list, glob for anything nearby, and record what you actually
found rather than what this table predicts. `~` is the user's home directory; "project" means the repo root and,
where the agent walks up the tree, every parent and nested directory too.
## Claude Code
| Scope | Path | What it is | Runs code? |
|---|---|---|---|
| Project | `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md`, nested `**/CLAUDE.md` | Instruction files loaded into context | No, but they steer every tool call |
| Project | `.claude/settings.json`, `.claude/settings.local.json` | Permissions, `defaultMode`, hooks, `env`, `apiKeyHelper`, `statusLine`, MCP enablement | **Yes** (hooks, helper keys) |
| Project | `.claude/skills/*/SKILL.md` (+ `scripts/`, `references/`) | Project skills | Bundled scripts can |
| Project | `.claude/commands/**/*.md`, `.claude/agents/*.md` | Slash commands, subagents (can carry their own `allowed-tools`) | Via the tools they pre-approve |
| Project | `.mcp.json` | Project MCP servers (`command`/`args`/`env`, or `url`/`headers`) | **Yes** |
| Plugin | `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `hooks/hooks.json`, plugin `skills/`, `commands/`, `agents/`, `.mcp.json` | Plugin manifest and everything it bundles | **Yes** |
| User | `~/.claude/CLAUDE.md`, `~/.claude/settings.json` | User-wide instructions and settings | **Yes** (hooks) |
| User | `~/.claude/skills/`, `~/.claude/commands/`, `~/.claude/agents/` | User-level extensions, active in every project | Via scripts / tools |
| User | `~/.claude/plugins/` (installed plugins, `marketplaces/`, marketplace and install registries) | Installed plugins and the marketplaces they came from | **Yes** |
| User | `~/.claude.json` | User and per-project `mcpServers`, approval state | **Yes** |
| User | `~/.claude/projects/*/memory/` (where present) | Persistent memory files reloaded into later sessions | No, but a persistence target (MP1) |
| Managed | macOS `/Library/Application Support/ClaudeCode/`, Linux `/etc/claude-code/` (`managed-settings.json`, `managed-mcp.json`) | Org-enforced settings; override user and project | **Yes** |
| Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (Windows `%APPDATA%\Claude\`) | Claude Desktop MCP servers; desktop extension bundles carry their own `manifest.json` | **Yes** |
## Cross-agent skill folders
| Scope | Path | Notes |
|---|---|---|
| Project | `.agents/skills/*/SKILL.md` | Where `npx skills add` installs for agents that read the shared folder. This catalog installs here. |
| Project / user | `AGENTS.md` (root and nested), `~/.codex/AGENTS.md` | Read by Codex, Cursor, Copilot, Gemini (when configured) and others |
## Cursor
| Scope | Path | Runs code? |
|---|---|---|
| Project | `.cursor/rules/*.mdc` (frontmatter `alwaysApply`, `globs`), legacy `.cursorrules` | No — instructions |
| Project | `.cursor/mcp.json` | **Yes** |
| Project | `.cursor/hooks.json` (where present), `.cursor/commands/` | **Yes** (hooks) |
| User | `~/.cursor/mcp.json`, `~/.cursor/hooks.json` | **Yes** |
| User | User Rules and auto-run / allowlist settings live in the app's settings UI | Ask the user to export or screenshot |
| User | `~/.codex/config.toml` `projects.<path>.trust_level` | Codex folder trust (`trust_level`), per exact path. Trusted catch-all folders are LD1 ([launch-directories.md](launch-directories.md)) |
| User | `~/.cursor/extensions/` | IDE extensions, a larger supply chain that runs with the user's full access. Audited with EX1–EX6 ([ide-extensions.md](ide-extensions.md)), with VS Code, Windsurf, VSCodium, remote-server and JetBrains folders |
## Codex (OpenAI)
| Scope | Path | Runs code? |
|---|---|---|
| Project / user | `AGENTS.md`, `AGENTS.override.md`, `~/.codex/AGENTS.md` | No — instructions |
| User | `~/.codex/config.toml` — `[mcp_servers.<name>]` (`command`, `args`, `env`), `approval_policy`, `sandbox_mode`, `[shell_environment_policy]`; project `.codex/config.toml` where supported | **Yes** |
| User | `~/.codex/prompts/*.md`, `~/.codex/skills/` (recent versions) | Via the tools they invoke |
## Gemini CLI
| Scope | Path | Runs code? |
|---|---|---|
| Project / user | `GEMINI.md` (root, ancestors, nested), `~/.gemini/GEMINI.md`, or whatever `contextFileName` points at | No — instructions |
| Project / user | `.gemini/settings.json`, `~/.gemini/settings.json` — `mcpServers`, tool allow/exclude lists, auto-approve settings | **Yes** |
| Project / user | `.gemini/commands/*.toml`, `~/.gemini/commands/` | Via shell-injection syntax in prompts |
| User | `~/.gemini/extensions/*/gemini-extension.json` — bundles MCP servers, context files and commands | **Yes** |
## GitHub Copilot (VS Code and the coding agent)
| Scope | Path | Runs code? |
|---|---|---|
| Project | `.github/copilot-instructions.md`, `.github/instructions/*.instructions.md` | No — instructions |
| Project | `.github/prompts/*.prompt.md`, `.github/chatmodes/*.chatmode.md`, `.github/agents/*.agent.md` | Via the tools they list |
| Project | `.vscode/mcp.json` | **Yes** |
| User | VS Code user `settings.json` — `mcp` servers, tool auto-approve settings | **Yes** |
| Repo settings | Coding-agent MCP config (repo Settings UI) and `.github/workflows/copilot-setup-steps.yml` | **Yes**, in CI |
## Others worth a glob
Windsurf (`.windsurfrules`, `.windsurf/rules/`, `~/.codeium/windsurf/mcp_config.json`), Cline / Roo (`.clinerules`,
`.roo/`), Continue (`~/.continue/config.*`), Zed (`settings.json` `context_servers`). Same checks apply.
## One-shot discovery
Project scope (hidden and gitignored files included, `node_modules` skipped):
```bash
rg --files --hidden -uu -g '!node_modules' -g '!.git' \
-g '{CLAUDE,AGENTS,GEMINI}.md' -g 'CLAUDE.local.md' -g 'AGENTS.override.md' -g 'SKILL.md' \
-g '.mcp.json' -g 'mcp.json' -g 'mcp_config.json' -g 'claude_desktop_config.json' \
-g '.cursorrules' -g '.windsurfrules' -g '.clinerules' -g '*.mdc' \
-g 'copilot-instructions.md' -g '*.instructions.md' -g '*.prompt.md' -g '*.chatmode.md' -g '*.agent.md' \
-g 'settings.json' -g 'settings.local.json' -g 'hooks.json' -g 'plugin.json' -g 'marketplace.json' \
-g 'gemini-extension.json' -g 'config.toml'
```
Portable fallback:
```bash
find . -path ./node_modules -prune -o -path ./.git -prune -o -type f \( \
-name 'CLAUDE.md' -o -name 'CLAUDE.local.md' -o -name 'AGENTS.md' -o -name 'GEMINI.md' -o -name 'SKILL.md' \
-o -name '.mcp.json' -o -name 'mcp.json' -o -name '*.mdc' -o -name '.cursorrules' \
-o -name 'copilot-instructions.md' -o -name '*.instructions.md' -o -name '*.prompt.md' \
-o -name 'settings.json' -o -name 'settings.local.json' -o -name 'hooks.json' \
-o -name 'plugin.json' -o -name 'marketplace.json' -o -name 'gemini-extension.json' \) -print
```
User scope: run the same `rg --files` against `~/.claude ~/.claude.json ~/.cursor ~/.codex ~/.gemini` (and the
Desktop / managed paths above), skipping transcript and cache folders (`~/.claude/projects/*/*.jsonl`,
`~/.codex/sessions`, `~/.gemini/tmp`) — those are a `secrets-audit` concern, not extensions.
## Recording the pin
For every item, record where it came from and what version is installed:
| Source | What counts as a pin |
|---|---|
| Git-installed skill / plugin / marketplace | A commit SHA (a tag is movable; a branch is not a pin) |
| `npx` / `bunx` / `pnpm dlx` MCP server | `pkg@1.2.3` exact version, ideally with a lockfile or integrity hash |
| `uvx` / `uv tool run` / `pip` | `pkg==1.2.3` |
| Docker MCP server | `image@sha256:…` (a numeric tag is weaker; `latest` or no tag is unpinned) |
| Remote (`url`) MCP server | Cannot be pinned — the vendor can change tools at any time; record it as such |
| Hand-written / vendored file | Note the commit it was last changed in (`git log -1 --format=%H -- <path>`) |
references/severity-calibration.md
# Severity calibration
How to decide whether something is a finding at all, and if it is, how severe it is. Every skill that rates findings uses this file. The short version: **name the boundary and the result, cap severity at the impact you showed, and keep hardening notes out of the findings list.**
This applies to `confirmed` findings only. `needs_validation` items carry blockers and a plan instead of a severity, and `rejected` items carry the control that rules them out (see `findings-format.md` where it ships alongside this file).
## 1. The boundary + result gate
Before a candidate becomes a finding, write down all six of these. Each one has to be concrete, not "an attacker".
| # | Element | Example |
|---|---|---|
| 1 | **Lower-trust principal**: who starts the attack | Any signed-up user; an unauthenticated internet client; tenant B's admin |
| 2 | **Accepted input**: what they control, and where it enters | The `:id` path segment of `POST /orders/:id/refund` |
| 3 | **Intended control**: what is supposed to stop them | The ownership check that `GET /orders/:id` already performs |
| 4 | **Boundary crossed**: which trust line the input gets across | User A → user B's data |
| 5 | **Affected principal or resource**: who or what gets hurt | Another customer's payment |
| 6 | **Concrete result**: what you observed, or what the traced code does | A refund is issued against an order the caller doesn't own |
If one element is missing:
- **No crossed boundary, or the "victim" is the attacker themselves** (self-XSS, editing your own profile in a weird way, admin doing admin things): it's a hardening note, or nothing.
- **No intended control** that the result defeats: it's a hardening note. A checklist deviation is not a vulnerability by itself.
- **The result depends on a fact outside the code** (proxy rule, IdP setting, deploy flag): it's `needs_validation`, with that fact as the blocker.
- **No concrete result**, only "this could be dangerous": keep tracing, or drop it.
## 2. Likelihood × impact, and overall ≤ impact
Rate likelihood and impact separately, each with a one-line reason, then pick an overall.
**Overall may never exceed demonstrated impact.** High likelihood of a low-impact bug is a low finding. If you can't state the concrete damage, the severity is lower than it feels.
### Exploitation signals (dependency and OS-package findings)
For a finding that rests on a published advisory, look up two free signals before rating likelihood, and record them.
Both are public feeds that need no key:
- **CISA KEV** (Known Exploited Vulnerabilities): the CVE is being exploited now. Likelihood is **High**.
- **EPSS** (probability of exploitation in the next 30 days, updated daily): above 0.7, likelihood is **High**;
0.1 to 0.7, **Medium**; below 0.1, fall back to the CVSS exploitability metrics. These are the same cut points
`vuln-research` uses.
These signals change **likelihood and the order of work**. They never raise severity above the impact you can show.
KEV plus runtime reachability plus a traced path to the vulnerable function is the strongest case for High or
Critical. A KEV-listed CVE in a build-only or dev-only package stays capped by its impact, but moves to the top of its
severity band: the CI runner or the developer's machine is now what's being attacked.
If a lookup wasn't possible (offline, egress blocked), record the signal as `not checked`. Never infer KEV status from
memory: the catalog grows every week.
### Severity anchors
| Level | Anchor | Typical shape |
|---|---|---|
| **Critical** | Fully defeats a core control with broad, severe consequences and little or no precondition | Pre-auth RCE; auth bypass to any account; cross-tenant read/write of all data; leaked production secret with write authority |
| **High** | **Fully defeats an explicit security control, with real consequences** | IDOR that reads or changes another user's data; privilege escalation to admin; stored XSS that runs in other users' sessions on a sensitive origin; SSRF to cloud metadata credentials |
| **Medium** | **Weakens a control** without fully defeating it, or fully defeats one with limited consequences | Rate limit bypassable only partly; data leak limited to non-sensitive fields; CSRF on a low-value state change; reflected XSS needing unusual interaction |
| **Low** | A real boundary crossing with minor consequences, or one that needs unlikely preconditions | User enumeration with no follow-on; information disclosure of internal hostnames; open redirect that does nothing more on its own |
Low is a finding: something crossed a boundary. **Defense-in-depth is not Low.** It's a hardening note (section 5).
### Availability
The anchors above are written for confidentiality and integrity. For a crash or hang an attacker can cause:
| Level | Shape |
|---|---|
| **High** | One unauthenticated request, or a handful, takes down the whole service or keeps it down: an uncaught exception that kills a single-process server, a ReDoS or `qs`-style parser hang on the main event loop, unbounded memory growth until the process is killed |
| **Medium** | The same, but it needs an account, only hits one worker of several, or the platform restarts it within seconds so the attacker has to keep sending |
| **Low** | It only slows or fails the attacker's own request, or needs volumes that rate limits or the platform already absorb |
Volumetric floods (DDoS) are out of scope for code review. Note the edge protection in the report instead. "Restarts
automatically" lowers likelihood and duration, not the fact that the request crashed it. Keep the restart behaviour
as a `system_configuration` condition, or as a blocker when you can't see it.
The question that separates High from Medium: *does the attack fully defeat the control, and does that cause damage you can name?* If both, High is on the table. If the control is only weakened, or the damage is vague, it's Medium or lower.
## 3. Usually NOT High or Critical
These get over-rated habitually. Rate them High or Critical only when you've traced a specific, severe consequence:
- **Self-XSS**: the only victim is the person typing.
- **Clickjacking** on actions that aren't sensitive.
- **Missing security headers, cookie flags or TLS configuration nits** with no traced exploit path.
- **An open redirect on its own.** It's a phishing aid, not account takeover, until you show the chain.
- **"Could matter if chained"** when the other links are unproven. Rate what you showed, not the hypothetical chain.
- **Anything that already requires admin, shell, or physical access.** Those principals can usually do the damage directly.
- **Session weaknesses that need the attacker to already hold a victim secret** (their cookie, their reset token). Getting that secret is the hard part, and it isn't free.
- **Verbose errors and version banners** without a specific exploitable consequence.
- **Missing rate limiting** where no concrete abuse with real cost has been traced (see below).
### Missing rate limiting
Checklists list rate limiting (OWASP A07, API4), so reviewers find it missing everywhere. Decide it the same way each
time:
- **Finding:** there's no limit in the code on an endpoint where repeated requests do concrete harm. Examples are
online password guessing against accounts with no lockout, brute-forcing a 6-digit OTP or reset code, and sending
paid SMS or email, or paid API calls, at the attacker's choice of volume. The intended control is the limit that
login, OTP and paid-send endpoints are expected to have. Usually Medium, since it weakens authentication rather than
bypassing it. It's High when the guessable space is small enough to exhaust (a 4- or 6-digit code with no attempt
limit).
- **`needs_validation`:** the code has no limit, but a gateway, WAF or platform limiter might apply and you can't see
it. The blocker is that limiter.
- **Hardening note:** endpoints where repetition costs nothing beyond load (search, list, profile reads), or where
another control already caps the harm (a lockout after N failures).
## 4. High/Critical acceptance checklist
Before you write High or Critical, all six must be true. If any fails, **downgrade rather than delete**: the finding stays, at the level you can defend.
1. **Realistic, in-scope path.** The vulnerable code runs in the deployed product, on a route or job that exists.
2. **Obtainable attacker position.** The principal from the gate can actually get there (open signup counts; "an employee with database access" usually doesn't).
3. **Impact demonstrated, not asserted.** You traced or ran it to the sink and can say what happens there.
4. **Counterevidence found nothing limiting.** The refutation pass looked for the strongest control on the path and didn't find one that stops it.
5. **Concrete reachability evidence.** `file:line` for entrypoint, propagation and sink, or a request/response pair.
6. **Defensible in a debrief.** A skeptical engineer or client would accept the rating after reading the evidence. Anchor test: *would serious triage accept this as High?*
### Missing deployment evidence
When the decisive fact lives in the deployment (is there a WAF? does the proxy strip this header?), that lowers **confidence**, not the severity floor. Either record it as `needs_validation` with the fact as a blocker, or keep the severity you traced and set confidence to `medium` or `low`. Don't quietly mark a traced High as Low because you couldn't see production.
## 5. Hardening notes are not findings
A **hardening note** is a gap with no traced impact: defense in depth, a best-practice deviation, a missing header with no exploit path, a cookie flag that changes nothing reachable.
- If layer A prevents the attack, the absence of layer B is a hardening note.
- Hardening notes **never get a severity** and don't go in `findings.json`.
- They go in a separate report section titled **"Hardening notes (not findings)"**, one line each: what's missing, why it would help, and the effort.
- Headers, cookie flags and CORS settings become findings only when you trace a concrete impact (e.g. a permissive CORS policy that, combined with credentialed requests, lets an attacker origin read a user's data).
Reporting hardening notes separately keeps the findings list credible. A report where half the "Lows" are missing headers trains readers to skip the Lows.
## 6. Keys: publishable vs restricted vs secret
A public identifier called a "key" is not a secret. Before rating an exposed key, find out which kind it is and what it can do:
| Kind | Examples | Rating |
|---|---|---|
| **Publishable** (designed to ship to browsers or apps) | Stripe `pk_live_…`, Firebase web config, Google Maps browser keys, Supabase `anon` key, Sentry DSN | Not a finding by itself. A finding only if its restrictions are missing and that enables real abuse (e.g. an unrestricted Maps key billed to you, or a Supabase `anon` key in front of tables with no row-level security), and then rate that consequence |
| **Restricted** (scoped server key) | Stripe `rk_…`, a read-only API token | Rate by the scope it grants |
| **Secret** (full authority) | Stripe `sk_live_…`, AWS secret access key, Supabase `service_role`, database URL with password | Rate by the authority: production write access is usually High or Critical |
The prefix alone doesn't decide it. `AIza…` is the format of many Google API keys; some are meant to be public and some unlock paid APIs. Check what the key is restricted to before rating.
## 7. Severity vs SLA
Severity describes the bug. The remediation deadline (SLA) is a policy decision your org attaches to a severity. Keep them in separate columns. A deferral changes the deadline, never the severity.
## Sources
Paraphrased and adapted; no text copied.
- Cloudflare, [security-audit-skill](https://github.com/cloudflare/security-audit-skill) (MIT): `SKILL.md` ("Require a boundary and result", "Separate priority from certainty", anti-patterns), `ATTACK-CLASSES.md` (a flag is not a finding), `WEB-PROTOCOL-AND-AUTH.md` (public identifiers vs secrets).
- Strix, [`analysis/severity_calibration.md`](https://github.com/usestrix/strix/blob/main/strix/skills/analysis/severity_calibration.md) and the CVSS block in [`tools/reporting/tool.py`](https://github.com/usestrix/strix/blob/main/strix/tools/reporting/tool.py) (Apache-2.0): over-rating list, High/Critical acceptance checklist, downgrade-rather-than-delete.
- FIRST, [CVSS v4.0](https://www.first.org/cvss/) for teams that need a numeric score
- CISA, [Known Exploited Vulnerabilities catalog](https://www.cisa.gov/known-exploited-vulnerabilities-catalog), and FIRST, [EPSS](https://www.first.org/epss/): exploitation signals. Proposed for these skills by @coreyhaines31 in issue #207 alongside this.
references/to-sarif.mjs
#!/usr/bin/env node
// to-sarif.mjs: convert findings.json to SARIF 2.1.0 (e.g. for GitHub code scanning). Zero dependencies.
//
// Usage: node to-sarif.mjs findings.json [--include-needs-validation] [-o out.sarif]
// ("-" reads stdin; output goes to stdout unless -o is given)
// Exit: 0 written, 1 findings.json failed validation (nothing written), 2 usage / read / parse error.
//
// Mapping:
// - Only `confirmed` records become results by default. `rejected` records are never exported.
// - --include-needs-validation also exports `needs_validation` records at level "note", with no
// security-severity, properties.verdict = "needs_validation", and the blockers + validation plan in the message.
// - Confirmed with disposition "Fixed" is skipped (nothing left to alert on). "Accepted Risk" and
// "Mitigated (platform/config)" are exported with an accepted external suppression, so they show as dismissed.
// - fingerprint -> partialFingerprints["cybersecuritySkills/v1"] and the rule id (one rule per finding, so
// each rule's security-severity matches its single result).
// - severity.overall -> rule + result property "security-severity" (critical 9.5, high 8.0, medium 5.5, low 3.0)
// and level (critical/high -> error, medium -> warning, low -> note).
// - trace[] -> codeFlows[0].threadFlows[0].locations; evidence[] -> relatedLocations.
// - ext (skill-specific fields) -> result properties.ext, unchanged. Exploitation signals are also surfaced:
// ext.in_kev === true adds the tag "kev" (rule and result); a numeric ext.epss -> result properties.epss.
// - Paths are emitted relative to uriBaseId "%SRCROOT%".
import { readFileSync, writeFileSync, realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { validateFindings, readJson } from "./validate-findings.mjs";
export const SECURITY_SEVERITY = { critical: "9.5", high: "8.0", medium: "5.5", low: "3.0" };
export const LEVEL = { critical: "error", high: "error", medium: "warning", low: "note" };
const FP_KEY = "cybersecuritySkills/v1";
const SUPPRESSED = new Set(["Accepted Risk", "Mitigated (platform/config)"]);
function physical(file, line) {
return {
physicalLocation: {
artifactLocation: { uri: file.split("/").map(encodeURIComponent).join("/"), uriBaseId: "%SRCROOT%" },
region: { startLine: line },
},
};
}
function ruleFor(rec) {
const tags = ["security"];
if (rec.cwe) tags.push(`external/cwe/${rec.cwe.toLowerCase()}`);
if (rec.owasp_category) tags.push(`owasp/${rec.owasp_category}`);
if (rec.ext?.in_kev === true) tags.push("kev");
const properties = { tags, skill: rec.skill };
if (rec.verdict === "confirmed") properties["security-severity"] = SECURITY_SEVERITY[rec.severity.overall];
const rule = {
id: rec.fingerprint,
shortDescription: { text: rec.title },
properties,
};
if (rec.cwe) rule.helpUri = `https://cwe.mitre.org/data/definitions/${rec.cwe.slice(4)}.html`;
return rule;
}
function messageFor(rec) {
if (rec.verdict === "confirmed") {
const s = rec.severity;
return `${rec.title}. Severity ${s.overall} (likelihood ${s.likelihood.score}: ${s.likelihood.reason}; impact ${s.impact.score}: ${s.impact.reason}).`;
}
return `Needs validation: ${rec.title}. Blockers: ${rec.blockers.join("; ")}. Validation plan: ${rec.validation_plan}`;
}
function resultFor(rec, ruleIndex) {
const result = {
ruleId: rec.fingerprint,
ruleIndex,
level: rec.verdict === "confirmed" ? LEVEL[rec.severity.overall] : "note",
message: { text: messageFor(rec) },
locations: [physical(rec.location.file, rec.location.line)],
partialFingerprints: { [FP_KEY]: rec.fingerprint },
properties: { verdict: rec.verdict, confidence: rec.confidence, skill: rec.skill },
};
if (rec.verdict === "confirmed") {
result.properties["security-severity"] = SECURITY_SEVERITY[rec.severity.overall];
if (rec.disposition) result.properties.disposition = rec.disposition;
if (SUPPRESSED.has(rec.disposition))
result.suppressions = [{ kind: "external", status: "accepted", justification: `Disposition: ${rec.disposition}` }];
}
if (rec.ext) result.properties.ext = rec.ext;
if (rec.ext?.in_kev === true) result.properties.tags = ["kev"];
if (typeof rec.ext?.epss === "number") result.properties.epss = rec.ext.epss;
if (rec.trace.length > 0) {
result.codeFlows = [
{
threadFlows: [
{
locations: rec.trace.map((t) => ({
location: { ...physical(t.file, t.line), message: { text: t.description } },
kinds: [t.kind],
})),
},
],
},
];
}
if (rec.evidence.length > 0) {
result.relatedLocations = rec.evidence.map((e, id) => ({ id, ...physical(e.file, e.line), message: { text: e.description } }));
}
return result;
}
/**
* Convert a findings array (must already be valid) to a SARIF 2.1.0 log object.
* @param {object[]} findings
* @param {{includeNeedsValidation?: boolean}} [opts]
*/
export function toSarif(findings, opts = {}) {
const selected = findings.filter((r) => {
if (r.verdict === "confirmed") return r.disposition !== "Fixed";
if (r.verdict === "needs_validation") return Boolean(opts.includeNeedsValidation);
return false;
});
const rules = selected.map(ruleFor);
const results = selected.map((r, i) => resultFor(r, i));
return {
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
version: "2.1.0",
runs: [
{
tool: {
driver: {
name: "cybersecurity-skills",
informationUri: "https://github.com/briiirussell/cybersecurity-skills",
rules,
},
},
originalUriBaseIds: { "%SRCROOT%": { description: { text: "Repository root" } } },
results,
},
],
};
}
function main(argv) {
const args = argv.slice(2);
let out = null;
let includeNV = false;
const pos = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "-o" || args[i] === "--output") out = args[++i] ?? "";
else if (args[i] === "--include-needs-validation") includeNV = true;
else pos.push(args[i]);
}
if (pos.length !== 1 || pos[0] === "-h" || pos[0] === "--help" || out === "") {
console.error("usage: node to-sarif.mjs <findings.json | -> [--include-needs-validation] [-o out.sarif]");
return 2;
}
let data;
try {
data = readJson(pos[0]);
} catch (err) {
console.error(`error: cannot read ${pos[0]}: ${err.message}`);
return 2;
}
const errors = validateFindings(data);
if (errors.length) {
for (const e of errors) console.error(e);
console.error(`\n${pos[0]}: INVALID, no SARIF written. Fix the problems above (node validate-findings.mjs ${pos[0]}).`);
return 1;
}
const sarif = JSON.stringify(toSarif(data, { includeNeedsValidation: includeNV }), null, 2) + "\n";
if (out) {
writeFileSync(out, sarif);
console.error(`wrote ${out}`);
} else process.stdout.write(sarif);
return 0;
}
function isMain() {
try {
return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
} catch {
return false;
}
}
if (isMain()) {
process.exitCode = main(process.argv);
}
references/validate-findings.mjs
#!/usr/bin/env node
// validate-findings.mjs: zero-dependency validator for findings.json.
//
// Usage: node validate-findings.mjs findings.json (or "-" to read stdin)
// Exit: 0 valid, 1 invalid (one message per problem on stderr), 2 usage / read / JSON parse error.
//
// Hand-written checks that mirror findings.schema.json and add the rules JSON Schema cannot express:
// sort order, unique fingerprints, path safety, trace order, overall <= impact, and confidence `high`
// requiring a reproduced finding. This script is authoritative where the two disagree.
//
// The verdict-shaped record design and the idea of a zero-dep validator are adapted from
// cloudflare/security-audit-skill (MIT, Copyright (c) Cloudflare, Inc.). The code is this repo's own.
import { readFileSync, realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";
export const VERDICTS = ["confirmed", "needs_validation", "rejected"];
export const LEVELS = ["low", "medium", "high", "critical"]; // ascending
export const CONFIDENCE = ["high", "medium", "low"];
export const DISPOSITIONS = ["Fixed", "Deferred", "Accepted Risk", "Mitigated (platform/config)", "Requires major migration"];
export const TRACE_KINDS = ["entrypoint", "propagation", "sink"];
export const CONDITION_KINDS = [
"authentication_level",
"authorization_role",
"user_interaction",
"system_configuration",
"network_routing",
"environmental_dependency",
"data_state",
"timing_dependency",
"third_party_dependency",
];
const COMMON = ["fingerprint", "verdict", "skill", "title", "location", "trace", "evidence", "counterevidence", "confidence"];
const OPTIONAL_COMMON = ["cwe", "owasp_category", "ext"];
const SHAPES = {
confirmed: { required: [...COMMON, "severity", "conditions"], optional: [...OPTIONAL_COMMON, "reproduced", "disposition"] },
needs_validation: { required: [...COMMON, "blockers", "validation_plan"], optional: OPTIONAL_COMMON },
rejected: { required: [...COMMON, "ruled_out_by"], optional: OPTIONAL_COMMON },
};
const FINGERPRINT_RE = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{2,199}$/;
const SKILL_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const CWE_RE = /^CWE-[1-9][0-9]*$/;
const EXT_KEY_RE = /^[a-z][a-z0-9_]{0,63}$/;
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
const isText = (v) => typeof v === "string" && /\S/.test(v);
/** Returns a reason string if `p` is not a safe repo-relative path, else null. */
export function unsafePathReason(p) {
if (typeof p !== "string" || p.length === 0) return "must be a non-empty string";
if (p.includes("\0")) return "contains a NUL byte";
if (p.includes("\\")) return "uses a backslash (use forward slashes)";
if (p.startsWith("/")) return "is absolute (must be repo-relative)";
if (p.startsWith("~")) return "starts with ~ (must be repo-relative)";
if (/^[A-Za-z]:/.test(p)) return "has a drive letter (must be repo-relative)";
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(p)) return "is a URL (must be a repo-relative file path)";
if (p.split("/").some((seg) => seg === "..")) return "contains a '..' segment";
return null;
}
function checkKeys(obj, required, optional, where, errors) {
for (const k of required) if (!(k in obj)) errors.push(`${where}: missing required field "${k}"`);
const allowed = new Set([...required, ...optional]);
for (const k of Object.keys(obj)) if (!allowed.has(k)) errors.push(`${where}: unexpected field "${k}"`);
}
function checkText(v, where, errors) {
if (!isText(v)) errors.push(`${where}: must be a non-empty string`);
}
function checkEnum(v, values, where, errors) {
if (!values.includes(v)) errors.push(`${where}: must be one of ${values.join(" | ")} (got ${JSON.stringify(v)})`);
}
function checkPath(v, where, errors) {
const reason = unsafePathReason(v);
if (reason) errors.push(`${where}: path ${JSON.stringify(v)} ${reason}`);
}
function checkLine(v, where, errors) {
if (!Number.isInteger(v) || v < 1) errors.push(`${where}: must be an integer >= 1 (got ${JSON.stringify(v)})`);
}
function checkLocation(v, where, errors) {
if (!isObj(v)) return errors.push(`${where}: must be an object {file, line}`);
checkKeys(v, ["file", "line"], [], where, errors);
if ("file" in v) checkPath(v.file, `${where}.file`, errors);
if ("line" in v) checkLine(v.line, `${where}.line`, errors);
}
function checkFileLineDesc(v, where, errors, extraRequired = []) {
if (!isObj(v)) return errors.push(`${where}: must be an object`);
checkKeys(v, [...extraRequired, "file", "line", "description"], [], where, errors);
if ("file" in v) checkPath(v.file, `${where}.file`, errors);
if ("line" in v) checkLine(v.line, `${where}.line`, errors);
if ("description" in v) checkText(v.description, `${where}.description`, errors);
}
function checkTrace(trace, where, errors, minItems) {
if (!Array.isArray(trace)) return errors.push(`${where}: must be an array`);
if (trace.length < minItems) errors.push(`${where}: needs at least ${minItems} steps (entrypoint ... sink)`);
trace.forEach((step, i) => {
checkFileLineDesc(step, `${where}[${i}]`, errors, ["kind"]);
if (isObj(step) && "kind" in step) checkEnum(step.kind, TRACE_KINDS, `${where}[${i}].kind`, errors);
});
if (trace.length > 0) {
if (trace[0]?.kind !== "entrypoint") errors.push(`${where}: must start with an "entrypoint" step`);
if (trace[trace.length - 1]?.kind !== "sink") errors.push(`${where}: must end with a "sink" step`);
trace.slice(1, -1).forEach((step, j) => {
if (step?.kind === "entrypoint" || step?.kind === "sink")
errors.push(`${where}[${j + 1}]: "${step.kind}" may only appear at the ${step.kind === "entrypoint" ? "start" : "end"} of the trace`);
});
}
}
// ext holds a skill's own fields (a PCI requirement, a cloud account, a MASVS control). Keep it flat so reports and
// SARIF property bags can show it as-is: snake_case keys, scalar values or arrays of scalars.
function checkExt(v, where, errors) {
if (!isObj(v)) return errors.push(`${where}: must be an object of skill-specific fields`);
const keys = Object.keys(v);
if (keys.length === 0) errors.push(`${where}: must not be empty (leave ext out instead)`);
const scalar = (x) => (typeof x === "string" && x.length > 0) || typeof x === "boolean" || (typeof x === "number" && Number.isFinite(x));
for (const k of keys) {
if (!EXT_KEY_RE.test(k)) errors.push(`${where}: key ${JSON.stringify(k)} must be snake_case ([a-z][a-z0-9_]*)`);
const x = v[k];
if (Array.isArray(x) ? x.length === 0 || !x.every(scalar) : !scalar(x))
errors.push(`${where}.${k}: must be a non-empty string, a number, a boolean, or a non-empty array of those`);
}
}
function checkScored(v, where, errors) {
if (!isObj(v)) return errors.push(`${where}: must be an object {score, reason}`);
checkKeys(v, ["score", "reason"], [], where, errors);
if ("score" in v) checkEnum(v.score, LEVELS, `${where}.score`, errors);
if ("reason" in v) checkText(v.reason, `${where}.reason`, errors);
}
function checkSeverity(v, where, errors) {
if (!isObj(v)) return errors.push(`${where}: must be an object {likelihood, impact, overall}`);
checkKeys(v, ["likelihood", "impact", "overall"], [], where, errors);
if ("likelihood" in v) checkScored(v.likelihood, `${where}.likelihood`, errors);
if ("impact" in v) checkScored(v.impact, `${where}.impact`, errors);
if ("overall" in v) checkEnum(v.overall, LEVELS, `${where}.overall`, errors);
const o = LEVELS.indexOf(v.overall);
const i = LEVELS.indexOf(v.impact?.score);
if (o >= 0 && i >= 0 && o > i)
errors.push(`${where}.overall: "${v.overall}" exceeds impact "${v.impact.score}" (overall severity must not exceed demonstrated impact)`);
}
/** Validate one record. Returns an array of error strings. */
export function validateRecord(rec, where = "record") {
const errors = [];
if (!isObj(rec)) return [`${where}: must be an object`];
if (!VERDICTS.includes(rec.verdict)) {
errors.push(`${where}.verdict: must be one of ${VERDICTS.join(" | ")} (got ${JSON.stringify(rec.verdict)})`);
return errors;
}
const shape = SHAPES[rec.verdict];
// Verdict-specific guidance before the generic unexpected-field message.
if (rec.verdict !== "confirmed" && "severity" in rec)
errors.push(`${where}: severity is only allowed on confirmed findings (verdict is "${rec.verdict}")`);
if (rec.verdict !== "confirmed" && "disposition" in rec)
errors.push(`${where}: disposition is only allowed on confirmed findings (a false positive is verdict "rejected")`);
if (rec.verdict !== "needs_validation" && ("blockers" in rec || "validation_plan" in rec))
errors.push(`${where}: blockers / validation_plan belong only on needs_validation records`);
if (rec.verdict !== "rejected" && "ruled_out_by" in rec)
errors.push(`${where}: ruled_out_by belongs only on rejected records`);
const handled = new Set(["severity", "disposition", "blockers", "validation_plan", "ruled_out_by"]);
const allowed = new Set([...shape.required, ...shape.optional]);
for (const k of shape.required) if (!(k in rec)) errors.push(`${where}: missing required field "${k}" for verdict "${rec.verdict}"`);
for (const k of Object.keys(rec))
if (!allowed.has(k) && !handled.has(k)) errors.push(`${where}: unexpected field "${k}"`);
if ("fingerprint" in rec) {
if (typeof rec.fingerprint !== "string" || !FINGERPRINT_RE.test(rec.fingerprint))
errors.push(`${where}.fingerprint: must be 3-200 chars of [A-Za-z0-9._:/@+-], starting alphanumeric`);
else if (/:\d+(?=$|[:/#])/.test(rec.fingerprint))
errors.push(`${where}.fingerprint: looks like it contains a line number (":${rec.fingerprint.match(/:(\d+)(?=$|[:/#])/)[1]}"); fingerprints identify the root cause and must survive code moving`);
}
if ("skill" in rec && (typeof rec.skill !== "string" || !SKILL_RE.test(rec.skill)))
errors.push(`${where}.skill: must be a lowercase kebab-case skill name`);
if ("title" in rec) checkText(rec.title, `${where}.title`, errors);
if ("cwe" in rec && (typeof rec.cwe !== "string" || !CWE_RE.test(rec.cwe)))
errors.push(`${where}.cwe: must look like "CWE-89"`);
if ("owasp_category" in rec) checkText(rec.owasp_category, `${where}.owasp_category`, errors);
if ("ext" in rec) checkExt(rec.ext, `${where}.ext`, errors);
if ("location" in rec) checkLocation(rec.location, `${where}.location`, errors);
if ("trace" in rec) checkTrace(rec.trace, `${where}.trace`, errors, rec.verdict === "confirmed" ? 2 : 0);
if ("evidence" in rec) {
if (!Array.isArray(rec.evidence)) errors.push(`${where}.evidence: must be an array`);
else {
if (rec.verdict === "confirmed" && rec.evidence.length === 0) errors.push(`${where}.evidence: confirmed findings need at least one evidence item`);
rec.evidence.forEach((e, i) => checkFileLineDesc(e, `${where}.evidence[${i}]`, errors));
}
}
if ("counterevidence" in rec) checkText(rec.counterevidence, `${where}.counterevidence`, errors);
if ("confidence" in rec) checkEnum(rec.confidence, CONFIDENCE, `${where}.confidence`, errors);
if (rec.verdict === "confirmed") {
if ("reproduced" in rec && typeof rec.reproduced !== "boolean") errors.push(`${where}.reproduced: must be a boolean`);
if (rec.confidence === "high" && rec.reproduced !== true)
errors.push(`${where}.confidence: "high" requires "reproduced": true (a static-only trace caps confidence at medium)`);
if ("severity" in rec) checkSeverity(rec.severity, `${where}.severity`, errors);
if ("conditions" in rec) {
if (!Array.isArray(rec.conditions)) errors.push(`${where}.conditions: must be an array`);
else
rec.conditions.forEach((c, i) => {
const w = `${where}.conditions[${i}]`;
if (!isObj(c)) return errors.push(`${w}: must be an object {kind, description}`);
checkKeys(c, ["kind", "description"], [], w, errors);
if ("kind" in c) checkEnum(c.kind, CONDITION_KINDS, `${w}.kind`, errors);
if ("description" in c) checkText(c.description, `${w}.description`, errors);
});
}
if ("disposition" in rec) checkEnum(rec.disposition, DISPOSITIONS, `${where}.disposition`, errors);
}
if (rec.verdict === "needs_validation") {
if ("blockers" in rec) {
if (!Array.isArray(rec.blockers) || rec.blockers.length === 0)
errors.push(`${where}.blockers: needs_validation requires a non-empty array of blockers`);
else rec.blockers.forEach((b, i) => checkText(b, `${where}.blockers[${i}]`, errors));
}
if ("validation_plan" in rec) checkText(rec.validation_plan, `${where}.validation_plan`, errors);
}
if (rec.verdict === "rejected" && "ruled_out_by" in rec) {
const r = rec.ruled_out_by;
const w = `${where}.ruled_out_by`;
if (!isObj(r)) errors.push(`${w}: must be an object {control, location}`);
else {
checkKeys(r, ["control", "location"], [], w, errors);
if ("control" in r) checkText(r.control, `${w}.control`, errors);
if ("location" in r) checkLocation(r.location, `${w}.location`, errors);
}
}
return errors;
}
/** Validate a parsed findings.json document. Returns an array of error strings (empty = valid). */
export function validateFindings(data) {
if (!Array.isArray(data)) return ["top level: findings.json must be a JSON array of records"];
const errors = [];
const seen = new Map();
data.forEach((rec, i) => {
const fp = isObj(rec) && typeof rec.fingerprint === "string" ? rec.fingerprint : null;
const where = fp ? `[${i}] (${fp})` : `[${i}]`;
errors.push(...validateRecord(rec, where));
if (fp) {
if (seen.has(fp)) errors.push(`${where}: duplicate fingerprint (also at index ${seen.get(fp)})`);
else seen.set(fp, i);
}
});
for (let i = 1; i < data.length; i++) {
const a = data[i - 1]?.fingerprint;
const b = data[i]?.fingerprint;
if (typeof a === "string" && typeof b === "string" && a > b) {
errors.push(`[${i}]: records must be sorted by fingerprint ("${b}" must come before "${a}"); sort with: jq 'sort_by(.fingerprint)'`);
break;
}
}
return errors;
}
/** Read and parse a findings file ("-" = stdin). Throws on read/parse failure. */
export function readJson(file) {
const text = readFileSync(file === "-" ? 0 : file, "utf8");
return JSON.parse(text);
}
function main(argv) {
const args = argv.slice(2);
if (args.length !== 1 || args[0] === "-h" || args[0] === "--help") {
console.error("usage: node validate-findings.mjs <findings.json | ->");
return 2;
}
let data;
try {
data = readJson(args[0]);
} catch (err) {
console.error(`error: cannot read ${args[0]}: ${err.message}`);
return 2;
}
const errors = validateFindings(data);
if (errors.length) {
for (const e of errors) console.error(e);
console.error(`\n${args[0]}: INVALID (${errors.length} problem${errors.length === 1 ? "" : "s"})`);
return 1;
}
const counts = Object.fromEntries(VERDICTS.map((v) => [v, data.filter((r) => r.verdict === v).length]));
console.log(`${args[0]}: OK (${data.length} records: ${counts.confirmed} confirmed, ${counts.needs_validation} needs_validation, ${counts.rejected} rejected)`);
return 0;
}
function isMain() {
try {
return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
} catch {
return false;
}
}
if (isMain()) {
process.exitCode = main(process.argv);
}
SKILL.md
---
name: agent-supply-chain-audit
description: "Audit the extensions AI coding agents load and trust (skills, plugins, marketplaces, hooks, settings, MCP servers, and instruction files like CLAUDE.md, AGENTS.md, GEMINI.md, .cursor/rules and copilot-instructions.md) across Claude Code, Cursor, Codex, Gemini CLI and GitHub Copilot. Read-only: inventories each item with its source and pin, greps for hidden instructions, exfiltration, credential reads, persistence, curl|bash and over-broad tool grants, reviews hooks and MCP pinning and tool poisoning, and gives each item a SAFE / CAUTION / DO-NOT-INSTALL verdict. Use when the user mentions 'audit my skills,' 'is this skill malicious,' 'is this skill safe,' 'is this MCP server safe,' 'review CLAUDE.md,' 'review AGENTS.md,' 'plugin security,' 'check my hooks,' 'audit my MCP servers,' 'agent supply chain,' 'SkillSpector,' 'tool poisoning,' 'rug pull,' 'audit my IDE extensions,' 'is this VS Code extension safe,' or is about to install a skill, plugin, MCP server, or IDE extension."
allowed-tools: Read, Grep, Glob, Bash(skillspector scan --no-llm:*)
---
# Agent Supply-Chain Audit — Skills, Plugins, Hooks, MCP Servers, Instruction Files
Coding agents now load third-party code and third-party *instructions* with the developer's full privileges: a skill is
prose the model obeys, a hook is a shell command that runs on every prompt, an MCP server is a process launched from
whatever `npx` resolves today. NVIDIA's SkillSpector write-up cites Liu et al. (2026): of 31,132 public skills analysed,
26.1% had a vulnerability and 5.2% looked malicious. Nobody reviews these the way they review a dependency.
This skill inventories everything the user's agents load, reads it for concealed or hostile behaviour, checks what
runs automatically and how it's pinned, and returns a per-item verdict. It never executes, installs, edits, or removes
the items it audits.
Cross-references: `prompt-injection` for the same hidden-instruction and tool-poisoning checks applied to the user's
*application* (RAG corpora, system prompts, the MCP servers the app exposes); `secrets-audit` for tokens sitting in MCP
`env` blocks, agent settings, and session transcripts; `dependency-audit` for the npm / PyPI packages behind MCP servers
and bundled scripts; `ai-risk-management` for where agent tooling sits in the org's AI inventory; `incident-triage`
when a malicious item has already run; `personal-opsec` for the operator's own machine and accounts around the agent
(C7 checks cached browser versions); `patch-sweep` for the fleet response when a Chrome CVE lands;
`cicd-security` for agents that run inside CI workflows (claude-code-action and similar).
## Step 0 — Authorization and scope
- Audit the user's own machine, repos, and the items they're considering installing. User-level folders (`~/.claude`,
`~/.codex`, …) belong to the person at the keyboard; don't audit another user's home or a shared host without the
owner's say-so.
- Agree the scope: **project** (this repo), **user** (home-directory config), **managed** (org policy files), or a
**candidate** (a skill / plugin / server the user hasn't installed yet — have them clone it to a scratch directory;
don't install it to look at it).
- Read-only. Config files hold tokens: when a match contains a secret, report the file and key name, redact the value
to prefix + last 4, and hand it to `secrets-audit`. Don't paste secret values into the conversation.
- Never run an item to see what it does (no launching MCP servers, no running bundled scripts or hook commands). Where
dynamic evidence is needed, write the `validation_plan` for the user to run in a sandbox.
## Step 1 — Inventory
List every instruction file, skill, command, subagent, plugin, marketplace, hook, settings file, and MCP server each
agent loads, at every scope. The per-agent path tables and a one-shot discovery command are in
[references/locations.md](references/locations.md). The headline locations:
| Agent | Instructions | Extensions | Executes code |
|---|---|---|---|
| Claude Code | `CLAUDE.md`, `CLAUDE.local.md`, `~/.claude/CLAUDE.md` | `.claude/skills`, `~/.claude/skills`, `.claude/commands`, `.claude/agents`, `~/.claude/plugins/`, `.claude-plugin/plugin.json` + `marketplace.json` | `.claude/settings*.json`, `~/.claude/settings.json`, `hooks/hooks.json`, `.mcp.json`, `~/.claude.json`, `claude_desktop_config.json`, managed settings |
| Cross-agent | `AGENTS.md` | `.agents/skills` (where `npx skills` installs) | — |
| Cursor | `.cursor/rules/*.mdc`, `.cursorrules` | `.cursor/commands` | `.cursor/mcp.json`, `~/.cursor/mcp.json`, `.cursor/hooks.json` |
| Codex | `AGENTS.md`, `~/.codex/AGENTS.md` | `~/.codex/prompts`, `~/.codex/skills` | `~/.codex/config.toml` (`[mcp_servers.*]`, approval and sandbox policy) |
| Gemini CLI | `GEMINI.md`, `~/.gemini/GEMINI.md` | `.gemini/commands`, `~/.gemini/extensions/*` | `.gemini/settings.json`, `~/.gemini/settings.json` (`mcpServers`) |
| Copilot | `.github/copilot-instructions.md`, `.github/instructions/*.instructions.md` | `.github/prompts`, `.github/agents`, `.github/chatmodes` | `.vscode/mcp.json`, VS Code user settings |
| Launch folders | Any `AGENTS.md` / `CLAUDE.md` in the folder an agent starts in (`~/Downloads`, `~/Desktop`, `$HOME`) | Codex `projects.<path>.trust_level` in `~/.codex/config.toml`; Claude Code's folder-trust prompt | Whatever those instructions tell the agent to do (LD1–LD4, [references/launch-directories.md](references/launch-directories.md)) |
| IDE extensions | — | `~/.cursor/extensions`, `~/.vscode/extensions`, `~/.windsurf/extensions`, `~/.vscode-oss/extensions`, remote `~/.*-server/extensions`, JetBrains `plugins/` | Every extension: the extension host runs it with the user's full access |
For each item record: **type**, **agent**, **scope**, **source** (marketplace, git URL, npm/PyPI package, docker image,
remote URL, hand-written), **pin** (commit SHA, exact version, digest — or "unpinned"), **what it can run**
(nothing / pre-approved tools / hooks / a server process), and **what runtime it launches** (`chromium <version>`,
`system Chrome`, `node`, …; browser servers and CLIs are listed in [references/browser-runtimes.md](references/browser-runtimes.md)). An item with no identifiable source is itself worth a note.
## Step 2 — Content checks
Read every instruction-bearing file end to end: skill bodies and frontmatter, bundled `references/` and `scripts/`,
commands, subagents, rules files, plugin manifests, and MCP tool descriptions. Then grep. One representative pattern
per category is below; the full set, with portable `grep` / `perl` equivalents, is in
[references/content-checks.md](references/content-checks.md). Set `T` to the inventoried paths first.
| Check | SkillSpector IDs | Representative grep |
|---|---|---|
| Invisible / bidi Unicode | P2, TP2 | `rg -n '[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}-\x{2064}\x{2066}-\x{2069}\x{FEFF}\x{00AD}\x{E0000}-\x{E007F}]' $T` |
| Directives in hidden comments | P2, TP1 | `rg -n -i '^\s*\[[^\]]*\]:\s*(#\|<>)' $T` and the multiline `<!-- … -->` keyword search |
| Whitespace padding | P9 | `rg -n '[ \t]{40,}\S' $T` and `rg -c -U '(\n[ \t]*){20,}\S' $T` |
| Encoded payloads | P2, SC3 | `rg -n -i 'data:[a-z]+/[a-z0-9.+-]+;base64,' $T` |
| Instruction override / covert steering | P1, P4 | `rg -n -i '(ignore\|disregard) (all )?(previous\|prior\|above) instructions\|you are now\|(do not\|never) tell the user\|silently' $T` |
| Anti-refusal | AR1–AR3 | `rg -n -i "(never\|do not) refuse\|without (any )?(warnings?\|disclaimers?)\|safety polic(y\|ies) (do(es)? not\|don'?t) apply" $T` |
| Exfiltration wording and egress | P3, E1, E4 | `rg -n -i 'webhook\.site\|discord(app)?\.com/api/webhooks\|api\.telegram\.org/bot\|hooks\.slack\.com\|ngrok\|pipedream\.net\|requestbin\|oast\.' $T` |
| Credential reads | E2, E3, PE3 | `rg -n -i '\.ssh/\|id_(rsa\|ed25519)\|\.aws/credentials\|\.netrc\|\.npmrc\|printenv\|process\.env\|os\.environ' $T` |
| Agent snooping | AS1, AS2, AS3 | `rg -n '\.claude/\|\.codex/\|\.gemini/\|\.cursor/\|mcp\.json\|claude_desktop_config\|SKILL\.md' $T` |
| Remote script execution | SC2 | `rg -n -i '(curl\|wget)[^\|]*[\|]\s*(sudo\s+)?(ba\|z)?sh\b\|bash\s+<\((curl\|wget)' $T` |
| Obfuscation, bytecode, disguised binaries | SC3, SC8, SC9 | `rg -n 'base64 (-d\|--decode)\|atob\(\|fromCharCode\|\beval\(' $T`; `rg --files -uu $T -g '*.pyc' -g '*.so' -g '*.exe'` |
| Unscoped tool pre-approval | EA1 | `rg -n '^allowed-tools:.*\bBash\s*(,\|$)\|^allowed-tools:.*(Bash\(\*\)\|WebFetch)' $T` |
| Persistence / self-modification | RA1, RA2, MP1 | `rg -n -i '(>>\|tee -a\|append\|write)[^\n]{0,40}(CLAUDE\|AGENTS)\.md\|settings(\.local)?\.json\|crontab\|LaunchAgents\|\.zshrc\|\.bashrc\|\.git/hooks' $T` |
| Trigger abuse | TR1, TR2, TR3 | `rg -n -i '^description:.*(always use\|for (any\|every\|all) (task\|request)\|whenever the user)' $T`; duplicate `name:` values |
(In the table, `\|` is markdown escaping — type a plain `|` in the shell.)
How to read the hits:
- **Directive vs documentation.** Security skills quote attack strings on purpose — this catalog does, heavily. A hit
is a finding when it's addressed *to the agent* (prose, frontmatter, a comment, a tool description) and would change
what it does. Quoted examples in fenced blocks or "look for X" lists are `rejected` with that reason.
- **Concealment is the signal.** There is no benign reason for a zero-width character, a tag-block character, or a
directive inside an HTML comment in a file whose only job is to instruct a model. Treat any concealed directive as
confirmed regardless of what it says.
- **Declared purpose.** Agent snooping (AS) and env reads are expected from an audit or config skill that says so and
stays local. The finding is undeclared reads, reads of other skills, or reads that feed an outbound call.
- **Grants multiply.** Unscoped `Bash` alone is code execution; unscoped `Bash` plus `WebFetch`/`WebSearch` adds untrusted
input and an egress channel to the same skill.
## Step 3 — Hooks and settings (BH1–BH4)
These run without the model deciding anything. Read every settings file in scope, project files first — a cloned repo's
`.claude/settings.json` applies to whoever trusts the folder.
```bash
S=".claude/settings.json .claude/settings.local.json $HOME/.claude/settings.json hooks/hooks.json .cursor/hooks.json $HOME/.cursor/hooks.json"
# BH1/BH2: every hook command, then the ones that reach the network or decode payloads
rg -n '"(command|matcher|type)"\s*:' $S
rg -n -i '"command"\s*:\s*"[^"]*(curl|wget|\bnc\b|https?://|base64|\|\s*(ba|z)?sh|python[0-9.]* -c|node -e)' $S
# BH3: permission mode and broad grants
rg -n '"(defaultMode|allow|ask|deny|additionalDirectories|enableAllProjectMcpServers|enabledMcpjsonServers|disableAllHooks)"' $S
rg -n '"(Bash|Bash\(\*\)|WebFetch|Read\(~?/\*\*\)|mcp__[^"]*)"' $S
# BH4: settings keys that run a command on their own, and env that reroutes or injects
rg -n '"(apiKeyHelper|awsAuthRefresh|awsCredentialExport|otelHeadersHelper|statusLine|fileSuggestion)"' $S
rg -n '"(ANTHROPIC_BASE_URL|ANTHROPIC_API_URL|HTTPS?_PROXY|NODE_OPTIONS|LD_PRELOAD|DYLD_INSERT_LIBRARIES|GIT_SSH_COMMAND|CLAUDE_CODE_SHELL_PREFIX)"' $S
rg -n '"(PLAYWRIGHT_BROWSERS_PATH|PUPPETEER_EXECUTABLE_PATH|AGENT_BROWSER_EXECUTABLE_PATH)"' $S # which browser binary runs (RT1)
```
What to weigh:
| Item | Why it matters |
|---|---|
| `UserPromptSubmit` / `PostToolUse` hooks | See every prompt / every tool result (file contents, command output). Any network call here is a BH2 exfil candidate. |
| `PreToolUse` hooks that return an allow decision | Silently approve tool calls the user would otherwise be asked about |
| `SessionStart` hooks | Run on open, before the user has typed anything; can inject context |
| `defaultMode: bypassPermissions` / `acceptEdits` / `dontAsk` in a **project** file | Lowers the bar for everyone who clones the repo |
| `permissions.allow` with `Bash`, `Bash(*)`, `Bash(curl:*)`, `WebFetch`, `Read(~/**)` | Pre-approval far beyond what a repo needs |
| `apiKeyHelper`, `statusLine`, `awsAuthRefresh`, … | Shell commands that run on their own schedule; `disableAllHooks` doesn't cover every one of them — read each |
| `env.ANTHROPIC_BASE_URL` / proxy vars | Route every prompt and file the agent reads through someone else's host |
| `NODE_OPTIONS=--require`, `LD_PRELOAD`, `GIT_SSH_COMMAND` | Code injection into processes the agent spawns |
| `PLAYWRIGHT_BROWSERS_PATH`, `PUPPETEER_EXECUTABLE_PATH`, `--executablePath` | Pick which browser binary runs. Check its version under RT1; a target in the repo or `/tmp` is its own finding |
| `enableAllProjectMcpServers: true` | Auto-approves whatever `.mcp.json` a repo ships |
Equivalents elsewhere: Codex `approval_policy = "never"` with `sandbox_mode = "danger-full-access"`
(`rg -n 'approval_policy|sandbox_mode|\[mcp_servers' ~/.codex/config.toml`); Gemini CLI `"trust": true` on an MCP
server or auto-accept / YOLO settings (`rg -n '"(trust|autoAccept|approvalMode)"' .gemini/settings.json ~/.gemini/settings.json`);
VS Code / Copilot tool auto-approve settings; Cursor auto-run and allowlists (in the app UI — ask the user).
## Step 4 — MCP servers: pinning, tool definitions, rug pulls
### Pinning (RP1, RP3)
```bash
M=".mcp.json .cursor/mcp.json .vscode/mcp.json $HOME/.claude.json $HOME/.cursor/mcp.json $HOME/.gemini/settings.json"
rg -n -A5 '"command"\s*:\s*"(npx|bunx|pnpm|uvx|uv|pipx|docker|deno)"' $M "$HOME/Library/Application Support/Claude/claude_desktop_config.json"
rg -n -A5 'command\s*=\s*"(npx|uvx|docker)"' $HOME/.codex/config.toml
rg -n '@latest|:latest"|"-y"|"--yes"' $M
rg -n '"(url|serverUrl|httpUrl)"\s*:' $M # remote servers
```
```jsonc
"args": ["-y", "@acme/mcp-server"] // unpinned (RP1): next launch runs whatever was published last
"args": ["-y", "@acme/mcp-server@1.4.2"] // pinned to an exact version
"args": ["run", "ghcr.io/acme/mcp@sha256:…"] // docker: digest; a numeric tag is weaker; no tag / latest is unpinned
```
Unpinned package runners are a confirmed finding (condition `third_party_dependency`), sized by what the server can
reach. Remote `url` servers can't be pinned at all — the vendor can change tools any time; record that and lean on
the diff process below. Secrets in `env` / `headers` blocks go to `secrets-audit`; config should hold `${VAR}`
references only.
### Bundled browser runtimes (RT1)
Browser servers and CLIs (Playwright MCP, Puppeteer servers, agent-browser) launch their own Chromium, which never
updates itself and runs every page the agent visits. Find the version each one starts and compare it with Chrome for
Testing stable. Below a known-exploited or sandbox-escape fix is `confirmed`: High when it browses untrusted URLs from
an app that holds Accessibility, Medium for untrusted URLs alone, Low for localhost only. More than 2 majors behind
with no named CVE is `needs_validation`. A pinned browser server also needs an update budget: once its Chromium is
more than one major behind, bumping the pin is a security fix. Recognizers, the grep, the ladder and the RP1
reconciliation are in [references/browser-runtimes.md](references/browser-runtimes.md); the per-machine version check
is `personal-opsec` C7.
### Launch directories and folder trust (LD1–LD4)
Ask where agents get started, and inventory instruction files and settings in catch-all folders the browser writes to.
In October 2026 a planted `AGENTS.md` in a customer's `~/Downloads` told Codex to add a server to the machine-wide
`~/.codex/config.toml` and install a malicious npm package. Checks: a trusted catch-all folder (LD1), an instruction
file nobody wrote (LD2), a machine-wide server or base URL the user doesn't recognize (LD3), a package from a known
campaign (LD4). LD2–LD4 are Critical. Codex trust is per exact path, so an `untrusted` entry for `~/Downloads`
doesn't cover folders inside it. Details: [references/launch-directories.md](references/launch-directories.md).
### IDE extensions (EX1–EX6)
Read each IDE's `extensions.json` and list **every version folder on disk**: IDEs keep old versions after an update,
so a malicious build can outlive the update. Checks: known-bad version (EX1), auto-update on for high-privilege
extensions (EX2), same id from a different marketplace (EX3), code prepended to `extension.js` (EX4), unverified
publisher or typosquat (EX5), no extension policy (EX6). An EX1 or EX4 hit is Critical and goes to `incident-triage`
before anything is cleaned up. The prompting case is the malicious `Anthropic.claude-code` 2.1.312 and 2.1.313 builds
in Cursor's marketplace on 2026-10-29. Inventory command, check details and severity are in
[references/ide-extensions.md](references/ide-extensions.md).
### Tool definitions (TP1–TP4, LP1–LP4)
Read every tool's name, description, and each parameter's name, description, default, and enum labels. That text goes
straight into the model's context and is as authoritative to it as the system prompt. Look for:
- **Injection in descriptions** (TP1) and **parameter definitions** (TP3) — run the Step 2 hidden-instruction and
override checks on the tool text. A catch-all parameter ("notes", "context", "sidenote") whose description asks the
model to fill it with file contents, keys, or the conversation is the classic exfil shape.
- **Unicode deception in names** (TP2) — mixed scripts or confusables that make `read_file` look like a trusted tool, or
a name that shadows another server's tool.
- **Description vs behaviour** (TP4, LP1) — with source available, compare the promise ("formats dates") with the
handler (reads `~/.ssh`, opens sockets, spawns processes). Over-broad declared permissions (LP2), none declared despite
clear capabilities (LP3), or declared but unused (LP4) belong here too.
- **Cross-server instructions** — a description that tells the model how to use *another* server's tools.
### Rug pulls — pin and diff after approval (RP2)
A server can pass review at v1.0 and change its tool descriptions or add tools in v1.1, or a remote server can change
overnight. The control is a snapshot at approval and a diff on every upgrade:
- **Static diff, no execution:** for npm, `npm diff --diff=@acme/mcp-server@1.4.2 --diff=@acme/mcp-server@1.5.0` shows
every changed file, including tool registration code. Read changes to descriptions, schemas, new tools, and new
network or file access.
- **Live snapshot (user runs it, in a sandbox):** list tools with a pinned MCP Inspector, e.g.
`npx @modelcontextprotocol/inspector@<version> --cli <server command> --method tools/list > mcp-snapshots/<server>@<version>.json`,
then `diff` the sorted JSON against the approved snapshot. This launches the server, so it is a validation step for
the user, not something this skill runs.
- Flag: description or parameter text that changed, added tools, widened permissions or triggers, and any change in
tool annotations (read-only → destructive). An approved server with no snapshot and no diff process is a hardening
note.
## Step 5 — Optional automated pass: SkillSpector
[NVIDIA SkillSpector](https://github.com/NVIDIA/SkillSpector) (Apache-2.0) runs ~70 static checks plus optional
LLM-assisted ones and scores each skill 0–100 with a SAFE / CAUTION / DO NOT INSTALL verdict. If the user has it
installed (pinned — it is one more tool you're trusting):
```bash
skillspector scan --no-llm .claude/skills -f json -o skillspector.json # also .agents/skills, ~/.claude/skills, a candidate dir
```
- Only `--no-llm` is pre-approved here. **LLM mode sends file contents to the configured provider** (NVIDIA's hosted
API by default); run it only with the user's explicit OK and never on folders holding config secrets.
- Treat its output as candidates. Merge each hit into your findings under its rule ID, triage it like any grep hit
(security skills will trip it), and don't let a SAFE score replace reading the files — it can't judge intent.
## Step 6 — Verdicts, severity, and per-item rating
Close every candidate as `confirmed`, `needs_validation`, or `rejected` (see
[references/findings-format.md](references/findings-format.md)). Severity goes on confirmed findings only, and
`overall` never exceeds demonstrated impact ([references/severity-calibration.md](references/severity-calibration.md)).
Anchors for this surface:
| Severity | Typical shape |
|---|---|
| Critical | Active item sends credentials, transcripts, or source to an external host; concealed directive that runs commands or disables approvals; a known-bad or code-prepended IDE extension on disk (EX1, EX4) |
| High | Hook or bundled script with undeclared egress; project settings that bypass permissions or reroute model traffic; persistence into instruction files, settings, or shell init |
| Medium | Unpinned MCP server or git-installed skill with broad tool access; an unreviewed agent extension on auto-update on a machine with production access (EX2); unscoped `Bash` + `WebFetch` pre-approval; tool description that steers the model toward another tool or extra data |
| Low | Unpinned item with narrow reach; over-broad trigger that shadows another skill |
Not findings — **hardening notes**: no tool-definition snapshot, missing `permissions.deny` rules, a pinned tag rather
than a SHA, no review process for marketplace updates. Report them separately and never give them a severity.
Per item, roll the findings up:
- **DO-NOT-INSTALL** (or remove now) — any confirmed critical/high, or any concealed directive.
- **CAUTION** — confirmed medium/low, or open `needs_validation` items; list what would clear it (pin, scope the grant,
remove the hook).
- **SAFE** — nothing but `rejected` candidates and hardening notes. "SAFE" means "nothing found by this review", not a
guarantee — say so.
## Eat your own dog food
This catalog installs into `.claude/skills/` and `.agents/skills/`, so audit it like any other source:
- Pin it: install from a tagged release and record the commit SHA, not a moving branch.
- Expect hits from quoted payloads (`prompt-injection` examples, `secrets-audit` key patterns, this skill's own
patterns) — triage them as documentation, and record them as `rejected` so later runs stay quiet.
- Several skills here pre-approve unscoped `Bash`, some alongside `WebFetch`/`WebSearch`. That's an EA1 finding against
the catalog, sized by what the skill's instructions actually do. Report it like you would for anyone else's skill.
## Output Format
Write the machine-readable findings first, validate them, then render the markdown from them.
- `findings.json` — project-scope items, paths repo-relative. `findings.user.json` — user-scope items, paths relative to
`$HOME` (e.g. `.claude/settings.json`). The validator rejects absolute and `~` paths, so keep the two roots apart.
These are the only files this skill writes, and only after the user agrees where they go.
- Each record: `skill: "agent-supply-chain-audit"`; `fingerprint` like
`agent-supply-chain-audit:BH2:.claude/settings.json:posttooluse-hook-posts-to-webhook` (rule ID + item + root cause —
no line numbers); `title` prefixed with the rule ID (`[BH2] PostToolUse hook posts tool output to webhook.site`);
`trace` from activation (hook event, skill trigger, server launch) to sink (egress, exec, write); `owasp_category`
may carry the OWASP LLM / Agentic ID (e.g. `LLM03:2025 Supply Chain`); no secret values anywhere — prefix + last 4.
- Validate: `node references/validate-findings.mjs findings.json` ([references/validate-findings.mjs](references/validate-findings.mjs)) (and `findings.user.json`). Fix every error before
writing prose.
```markdown
# Agent Supply-Chain Audit
## Scope: [project / user / managed / candidate] — [paths]
## Date: [date] — SkillSpector: [version, --no-llm | not run]
### Inventory
| # | Item | Type | Agent | Scope | Source | Pin | Runs | Runtime | Verdict |
|---|------|------|-------|-------|--------|-----|------|---------|---------|
| 1 | .claude/skills/pdf-helper | skill | Claude Code | project | github.com/x/y | unpinned (main) | Bash (unscoped) | — | DO-NOT-INSTALL |
| 2 | playwright (MCP) | MCP server | Claude Code | user | npm @playwright/mcp | 0.0.41 (pinned 2026-06-02) | server process | chromium 149.0.7827.55 ⚠️ | CAUTION |
### Findings
#### [SEVERITY] [RULE-ID] [Title]
**Item / file:** `path` — **Verdict:** confirmed — **Category:** Hidden Instruction / Tool Poisoning | Exfiltration | Agent Snooping | Hooks & Settings | Rug Pull / Pinning | Supply Chain | Excessive Agency | Persistence | Trigger Abuse
**Evidence:** [quoted text or config, secrets redacted]
**Activation → impact:** [when it runs, what it reaches]
**Fix:** [remove / pin to X / scope grant to Y / delete hook]
### Needs validation
| Item | Rule | Blocker | Validation plan |
### Hardening notes (not findings)
- [gap, why it matters, suggested control]
### Rejected candidates
| Item | Rule | Why ruled out (control + location) |
```
## Boundaries
- Read-only against the audited items: never install, launch, run, edit, or delete them. Removal and pinning are
recommendations the user carries out.
- Never execute a hook command, bundled script, or MCP server to observe it; hand dynamic checks to the user as a
sandboxed validation plan.
- Don't copy secrets out of configs or transcripts into the report; redact and route to `secrets-audit`.
- Refuse to help build or tune a malicious skill, poisoned tool description, or hook designed to evade review or
scanners like SkillSpector. Explaining a technique so the user can detect it is fine.
- If an item already ran and looks malicious, stop the audit and switch to `incident-triage`: rotate what it could
reach, preserve the files, check egress.
## References
- NVIDIA SkillSpector (Apache-2.0) — [repo](https://github.com/NVIDIA/SkillSpector),
[vulnerability patterns](https://github.com/NVIDIA/SkillSpector/blob/main/README.md#vulnerability-patterns),
[MCP tool poisoning](https://github.com/NVIDIA/SkillSpector/blob/main/docs/B.3.2-mcp-tool-poisoning.md); rule IDs
cited here are SkillSpector's, the patterns are this repo's own
- OWASP Top 10 for LLM Applications 2025 (LLM01 Prompt Injection, LLM03 Supply Chain, LLM06 Excessive Agency) and
OWASP Top 10 for Agentic Applications
- MITRE ATLAS
- Model Context Protocol specification — security best practices
- Invariant Labs, "MCP Security Notification: Tool Poisoning Attacks" (2025)
- Claude Code documentation — settings, hooks, permissions, MCP, plugins
- [references/locations.md](references/locations.md), [references/content-checks.md](references/content-checks.md),
[references/findings-format.md](references/findings-format.md), [references/severity-calibration.md](references/severity-calibration.md)