# Threat coverage matrix

Honest map of what ClawSecCheck checks today, what it does **not** yet check, and where
the gaps are. `UNKNOWN` is never counted as `PASS`; gaps below are areas with no check at
all (so they can't even surface as a finding). Updated 2026-08-06 for v3.61.0.

Current catalog: A1 plus the B-series, C-series, and T-series (behavioral) — 179 catalogued,
of which **175 run in a default audit**; the three T-series behavioral checks plus B191
(OpenClaw's own `audit_events` runtime trail) execute only under `--behavioral`
total; see `docs/CHECKS.md` for the full generated list, plus the
combinational risk engine `RISK-01..RISK-26`, the install-time vetters `--vet` (B13 plus
the content-security ring — `SKILL_CONTENT_RING`, run against an uninstalled skill; AST-,
injection-, and capability-intent-aware) / `--vet-mcp`, the **attestation
layer** (`--ask` / `--attest`, with a guided interrogation protocol so the agent self-builds
the report; `--attest -` reads stdin) that feeds the agent's self-report into B43/B44, and
the **behavioral trajectory audit** (`--behavioral`, E-032/E-039) — T1/T2/T3, proof-by-log
sequence detectors over OpenClaw's trajectory sidecar, complementing every check above
(which all answer "what the agent *could* do") with "what the agent *actually did*".

## Closure invariant

**"Closed" ≠ zero misses** — that would be a false promise; the honest efficacy verdict is
false-negative-dominant. **"Closed" = zero *silent* gaps**: every threat category below
carries exactly one machine-checked tag, one of four kinds: a **CHECK** tag naming one or
more real `CheckMeta` ids (each with a fixture and a test); a bare **ATTEST** tag (closed
only by the `--attest` self-report interrogation, no static config surface); a **JUDGE** tag
naming an AST taint rule (closed only by the `--judge-packet` advisory band — the rule has
no catalog id of its own); or a bare **CEILING** tag (a declared, honest non-coverage, with
the reason in prose). `UNKNOWN` is a valid, honest per-finding outcome; only an *untagged*
category — one that looks covered but isn't tagged at all — is the thing this invariant
forbids.

`tests/test_threat_coverage_ledger.py` enforces this mechanically: every `CheckMeta` id in
`clawseccheck/catalog.py` must appear inside some CHECK tag below, and every row in the two
canonical sections (**Covered** and **Non-static coverage**) must carry exactly one of the
four tags. The test does *not* judge whether the tag is the *right* one — bucket assignment
is an architect/human call, made here; the test only catches an entry that was never
classified at all.

## Efficacy, not just presence

A check firing is not the same as a check mattering. A scanner that only wraps a
reputation-lookup API, or matches a handful of obvious keywords, can present a "safe" or
"not detected" verdict with the same apparent confidence as one that actually reasoned
about the artifact — and a wrong output that *looks* trustworthy is worse than no output
at all. Concretely, here: every check below carries its own fixture and test, not a
single API call; `skillast.py` reasons about AST structure, not keyword presence; the
combinational risk engine (`RISK-01..RISK-26`) exists because the attacks that matter are
often only visible as a *combination* of individually-ordinary capabilities, not any one
signal alone; the release bar is a documented zero-false-positive-FAIL discipline
(`docs/RELEASING.md`) with any exception named and test-pinned, not asserted; and
`UNKNOWN` is a first-class verdict precisely so the engine never presents a confident
"safe" it can't back up. The engine also makes no network calls of its own — there is no
third-party reputation database behind any verdict here.

## Covered

| Threat | Covered by | Notes |
|---|---|---|
| **Lethal Trifecta** (headline correlation) | A1 | Untrusted input × sensitive data × outbound actions active together — the tool's single CRITICAL trifecta check; keep at most 2 of 3 `[CHECK: A1]` |
| Plaintext secrets in config / bootstrap | B1 | Reports key paths, not values `[CHECK: B1]` |
| System-prompt / secret leak in tool output | B9 | `[CHECK: B9]` |
| Audit log & sensitive redaction | B10 | `[CHECK: B10]` |
| Native-audit suppression list transparency | B173 | `security.audit.suppressions` permanently silences specific findings of OpenClaw's OWN `openclaw security audit` (and therefore native.py's fold-in of it too) — disclosed as WARN when non-empty, escalated to FAIL only when a suppressed checkId is grounded as unconditionally critical in the native audit source (B-237) `[CHECK: B173]` |
| Local-first & model hygiene | B12 | `[CHECK: B12]` |
| Gateway exposure & channel auth | B2, B11, B80 | IPv6-aware bind parsing; B80 flags auth without rate limiting on a non-loopback bind `[CHECK: B2, B11, B80]` |
| Least privilege / dangerous tools | B3, B7, B8 | Approval gate via real `tools.exec.mode` `[CHECK: B3, B7, B8]` |
| Standing exec-approvals grant inventory | B172 | `~/.openclaw/exec-approvals.json` persisted per-agent `allowlist[]` entries with `source: "allow-always"` — a durable per-command exec grant from a historical "always allow" click, living entirely outside `openclaw.json`. WARN-only, unscored advisory: OpenClaw takes the stricter of `tools.exec.*` and the exec-approvals policy (confirmed against the dist: `minSecurity`/`maxAsk` merge), so a standing grant cannot loosen the gate B8/B22/B23/B48 already verify — this check exists purely to surface a forgotten persisted grant, not to override those checks' verdicts `[CHECK: B172]` |
| Hooks enable-toggle attack-surface inventory | B179 | `hooks.enabled` and `hooks.internal.enabled`/`.entries`/`.installs`/`.load.extraDirs` were entirely uncovered (only `hooks.mappings` and `hooks.token` had a `dig()` path before this). `hooks.internal.load.extraDirs` gets sharper wording — it names extra directories OpenClaw searches for internal hook MODULES at startup, a code-exec/persistence surface, not just an enable flag. LOW severity, WARN-only, unscored advisory — the real fleet config has no `hooks` key at all, and the native audit itself treats this as info, not WARN/FAIL; hooks.token (B1), `hooks.mappings[].allowUnsafeExternalContent` (B48), and hook-template content (B169) already cover the higher-risk adjacent settings `[CHECK: B179]` |
| Execution sandbox present | B4 | Depth is partial — presence/mode only, not a full container-escape audit `[CHECK: B4]` |
| Bootstrap-file injection surface | B6, B161 | Prompt-injection-prone directives in SOUL/AGENTS/TOOLS (B6); identity-file injection — staleness-framing/safety-disable directive corroborated by a fabricated admin/auth code (B161) `[CHECK: B6, B161]` |
| Trusted-output boundary policy | B21, B169, B170 | Is external content treated as data, not instructions; combined with RISK-01/02/03 this is also the strongest automated leg of the dirty-input→action-gate concern (B27 was never implemented as its own id — no config surface exists for a generic gate — but this row plus the attestation layer below covers the same ground); B169 (B-231) content-scans `hooks.mappings[].messageTemplate`/`textTemplate` — the strings that carry an untrusted inbound webhook payload into an agent turn — through the content-ring directive/install detectors; B170 (B-232) flags the PRESENCE of a directive telling the agent to treat fetched tool/web/MCP output as authoritative operator instructions (the inverse of B67's absence check) — WARN-only, the content ring's highest-FP surface `[CHECK: B21, B169, B170]` |
| Installed-skill malware (ClawHavoc class) | B13, B86, B87, B88, B89, B90, B91, B92, B336, B93, B94, B96, B97, B98, B99, B100, B335, B102, B103, B104, B105, B135, B151, B152, B153, B154, B156, B157, B158, B165, B338, B339, B343, B344, B347, `--vet` | curl\|sh, base64/PS-encoded, split-stage exfil, paste hosts; **AST obfuscation** (`exec(b64decode)`, xor/zlib-layered and local `_decode()`-wrapper indirection incl. chained multi-stage wrappers, `getattr(os,…)()`, `__import__(…).system`) + injection directives in skill prose — ignore-instructions / hide-from-user plus **anti-refusal & system-prompt/tool-definition leak** directives (fence- and example-context dampened); **AST taint** cred-file→network (`CRED_EXFIL_FLOW`) and env-var / agent-config→network body/URL (`ENV_EXFIL_FLOW`, WARN-first — auth headers excluded, see the JUDGE band below for that exclusion's own recovery); **cross-file import-graph taint** (`CROSS_FILE_EXEC`); **bundled shell (`.sh`)** and **JS/TS** passes (decode-then-exec, remote-fetch-then-exec, dynamic dispatch, unsafe deserialization); **split-payload reassembly** across file boundaries (base64 — B90/B102 — and plaintext — B153/B154); **chunked/part-file assembly executed via exec()/eval()** — a helper reads and joins MULTIPLE chunked/part files at runtime (e.g. `_load.part1.txt`, `.part2.txt`) and the assembled result is exec()'d/eval()'d, the split-by-file scanner-evasion loader shape where the payload never exists whole in any single shipped `.py` file (B336, gated on a common stem+extension differing only by a numeric index); **frontmatter/trigger hygiene** (B88 tag-shaped values, B93 homoglyph/confusable trigger impersonation); **install-time supply chain** (B94 extended lifecycle hooks, B96 config-driven trust widening, B97 per-turn event-hook files, B103 install-directive metadata, B104 offboarding hygiene, B105 cross-skill combined effect, B135 accepted-despite-failed-verification, B151/B152 orphaned plugin cache, B156 overt secret-exfil — also scans a bundled script's own docstring/comment TEXT for the same send-verb+secret+destination shape, WARN-only from that source (never escalated to FAIL even against a known-bad host), B157 non-registry dependency source, B343 ML model artifact provenance (huggingface from_pretrained/snapshot_download/hf_hub_download, ollama pull, or a direct .gguf/.safetensors/.onnx URL — WARN on an unpinned bare repo-id/tag, FAIL only for the same unverifiable-provenance shape B103/B157 already FAIL on), B344 offensive-security tooling directive (Mimikatz/Impacket/BloodHound/Rubeus/CrackMapExec instructed via a bound imperative, e.g. "run Mimikatz" — WARN-only, never FAIL), B158 declared-but-absent load source, B165 hex-shaped crypto private-key value near wallet context — C-200, gated against tx/block-hash wording to avoid colliding with routine Ethereum tx-hash prose); covert tunnel / mesh-VPN enrollment primitive (tailscale/tailscaled, cloudflared, ngrok, ssh -R, socat, frpc, bore, a SOCKS5 proxy flag — WARN-only, a brand-new detection surface, B338, E-065); cloud instance-metadata CREDENTIAL fetch — a request URL combining a known metadata host (169.254.169.254 / metadata.google.internal / 100.100.100.200) with a credential-issuing path (AWS/Alibaba role creds, GCP service-account token, Azure managed-identity token) in one contiguous token, gated on defensive/documentation context so an SSRF-hardening tutorial or SIEM detection-signature skill does not self-FAIL (B339, E-065); dead-drop C2 resolver composition — a periodic poll of a remote content/search API (loop + sleep), whose response is decoded (base64/hex/b85/zlib), and the decoded value reaches an exec sink (eval/exec/os.system/subprocess.*) — the TA488/OWAReaper implant's dead-drop resolver shape (Proofpoint/NSA, CVE-2026-42897): each leg alone is common and benign, all three chained is a resolver; deliberately host-agnostic (the polled host is legitimate by design, so unlike B156/B339 this never anchors on a specific host); WARN when the three ingredients co-occur but no confirmed dataflow connects decode to the sink, FAIL only when the decoded value demonstrably reaches the exec sink (B347, F-159); import-path hijack (B86, `sys.path` from a writable/relative location); dormant-capability code (B89); undeclared capabilities (B98, no `tools.allow` manifest); `.pth`/sitecustomize persistence (B99); runtime-computed sitecustomize/usercustomize write or PYTHONSTARTUP shell-rc install — B99's sibling for a target the shipped skill *computes* rather than ships as a named file (B335, T06/SkillTrustBench gap closure); ClickFix paste-into-terminal (B100); **cross-session persistence** (B13 sub-signal, C-204/T1098.004 + T1053): `~/.ssh/authorized_keys` public-key append (argument-bound write detection — a read-only audit of the file never fires), plus cron/systemd gap closure (`crontab -` stdin install, the `["crontab","-"]` argv form, `systemctl --user enable`, and per-user `~/.config/systemd/user/*.service`/`.timer` unit files — the pre-existing check already covered `crontab -e`/`@reboot`/bare `systemctl enable`); **insecure-coding, no clear attack intent** (B13 sub-signal, C-199/SkillTrustBench T09): `SHELL_INJECTION_RISK` — a `subprocess.*(shell=True, …)` or bare `os.system()`/`os.popen()` call whose command is not a provable compile-time literal (WARN-grade on the unsafe SHAPE alone, distinct from the stronger crit `TT5_CMD_INJECTION`, which requires PROVEN external taint), and hardcoded/predictable `/tmp` writes (CWE-377 — `tempfile.mkstemp()`/`NamedTemporaryFile()` never flagged); parse failures surface UNKNOWN, not a silent skip `[CHECK: B13, B86, B87, B88, B89, B90, B91, B92, B336, B93, B94, B96, B97, B98, B99, B100, B335, B102, B103, B104, B105, B135, B151, B152, B153, B154, B156, B157, B158, B165, B338, B339, B343, B344, B347]` |
| Egress surface | B14, B155, B178 | Where the agent can reach out; B155 hardens the outbound-proxy leg specifically (credential leak / TLS-verify / SSRF-guard bypass); B178 (B-241) reads the ONE sibling field B155 never dig()s on the same provider object — `models.providers.<id>.baseUrl` — cleartext `http://` to a public IP or an unrecognized dotted hostname is FAIL (the provider API key + full model stream leak in plaintext); cleartext `http://` to a private/CGNAT-range IP or a bare single-label hostname (e.g. a Docker-Compose sibling service) is WARN only — on-LAN-only exposure, indistinguishable from a benign local Ollama/LM Studio runtime; loopback and OpenClaw's own local-model hostnames (`host.docker.internal`, `0.0.0.0`, …) are never flagged; a custom `https://` baseUrl (self-hosted gateway) is dual-use and never flagged `[CHECK: B14, B155, B178]` |
| MCP server trust | B15, B24, B47, B331, B332, B333, `--vet-mcp` | Unpinned installs, plaintext transport, env/secret passthrough, broad scopes; B333 (F-143/W2.1, grounded against dist openclaw@2026.7.1-2) flags a config-embedded MCP tool declaring a `readOnlyHint`/`destructiveHint`/`openWorldHint`/`idempotentHint` annotation — OpenClaw's registration path stores only `{serverName, safeServerName, toolName, title, description, inputSchema, fallbackDescription}` and never reads these SDK-vendor-typed hints, so any operator policy relying on them is silently unenforced. WARN-only (a host limitation, not server wrongdoing — never worded as the server lying); UNKNOWN when the only annotation evidence available comes from OpenClaw's own retained/compiled form (trajectory records, an `openclaw mcp probe --json` dump), which structurally never carries annotations at all and so proves nothing about what was originally declared; B331 (F-144/W2.2, grounded against dist openclaw@2026.7.1-2, `sanitizeMcpMetadataText`) scans config-embedded MCP tool descriptions for content-security signals (authority-override, secrecy-directive, exfil-parameter, tag-block, encoded-payload) and conditions the verdict on what OpenClaw's own host-side metadata sanitizer actually does: a payload the sanitizer's two literal phrase families genuinely neutralize is never reported as a live threat (WARN, not FAIL — the sanitizer runs on only one of three model-facing runtime paths, so this is not a clean PASS either); everything else — including a near-miss like "Disregard your prior directions", which the sanitizer's phrasing does not cover — is FAIL, worded so the host's mitigation reads as thin, not absent; a description past the sanitizer's own 1200-char truncation boundary with no signal found in a full-text scan is UNKNOWN, never a confident PASS; a trajectory-sourced surface (host_sanitized=True — this is what OpenClaw actually sent the model) reports any surviving signal as FAIL with no path hedge, since it is proof-positive, not a simulation; B332 (F-145/W2.3, mcptrustchecker's MTC-INJ-SHADOW-2 + MTC-UNI-009 analogue) flags a SECOND MCP server registering a tool NAME that exactly matches, is a homoglyph of (Cyrillic/Greek confusable, via `textnorm.confusable_in_ascii_context` — always suspicious, unconditional on genericness), or is an edit-distance-1 near-miss of (via `checks/_content._levenshtein`, only on a long/specific, non-generic name) a tool a DIFFERENT already-configured server exposes — the model routes a tool call by name alone and cannot reliably tell the two servers' same-named tools apart. Names-only by design: it reads only the tool name, never the description, so it works identically on a config-embedded manifest or a names-only `openclaw mcp probe --json` dump. A curated generic-instrument-name allowlist (`search`, `read_file`, `list`, …) keeps two servers legitimately sharing a common verb-shaped tool name from FAILing; the allowlist and a length floor apply to the exact/near-miss legs only, never to the homoglyph leg `[CHECK: B15, B24, B47, B331, B332, B333]` |
| Threat monitoring present | B16, `--monitor` | Detects absence; **Agent Watch** (`--monitor`) gives severity-tagged drift on skills/bootstrap/score **and connections** (new MCP server / channel / gateway-exposed / host-monitor lost) + a local event journal (`--watch-log`) `[CHECK: B16]` |
| Autonomy / heartbeat | B17 | Self-acting agent steerable by untrusted input `[CHECK: B17]` |
| Subagent delegation | B18, B72, B81 | Elevated/exec inheritance w/o approval (real approval gate); B18 also cross-checks config against the OpenClaw state DB's `subagent_runs` table — when config declares no delegation but the disk proves spawns actually ran, that disagreement is disclosed (WARN, never FAIL — retention is short, so this narrows to disclosure, not a durable audit trail); B72 flags `allowAgents` wildcard; B81 flags spawn limits raised beyond recommended defaults `[CHECK: B18, B72, B81]` |
| Data at-rest perms | B19, B82 | Group/world-readable memory/log dirs; B82 covers `diagnostics.cacheTrace.enabled=true` — a bulk per-turn transcript sink (prompt, system prompt, full message payloads) appended to a JSONL file on disk. Payloads are redacted unconditionally by OpenClaw, so this is a content-at-rest footprint finding, not a redaction gap `[CHECK: B19, B82]` |
| Bootstrap/memory write protection | B20 | Identity-file writability `[CHECK: B20]` |
| Self-modification risk | B22, B175, B345 | Writable identity + tools + no approval; B175 covers the Skill Workshop's own autonomy surface — `skills.workshop.autonomous.enabled=true` (agent auto-authors new executable skill proposals from conversation signals) combined with `approvalPolicy="auto"` (every propose/apply/reject/quarantine lifecycle call skips human confirmation) — FAIL on the full auto-author+auto-install combo, WARN on any single gap (incl. `allowSymlinkTargetWrites=true`), PASS on the safe `"pending"` default; B22/B175 are both config-posture — B345 (B-392) is the content-ring counterpart: a skill's own text instructing it to rewrite its own principles/instructions/configuration, scored WARN alone and FAIL only when corroborated within the same document by a literal self-write sink targeting `__file__` (`open(__file__, "a"/"w").write(...)`, `Path(__file__).write_text(/write_bytes(`) — distinct from B60 (prompt self-replication, a different mechanism) and B335 (narrowly scoped to sitecustomize/PYTHONSTARTUP) `[CHECK: B22, B175, B345]` |
| Approval-bypass directives | B23 | "do X without asking" in bootstrap `[CHECK: B23]` |
| Update / pinning hygiene | B25, C4, C6 | Pinned releases; C6 covers a pre-v2026.6.10 hook-composition tool-policy drop `[CHECK: B25, C4, C6]` |
| Untrusted-context exposure | B26, B140 | `channels.<p>.contextVisibility` — untrusted group/quote/history context injected into the model (config side; B21 is the policy side); B140 flags wildcard group ingress with no `allowFrom` restriction `[CHECK: B26, B140]` |
| Known-vulnerable version gate | B33 | `meta.lastTouchedVersion` vs a maintained advisory table (seeded: GHSA-g8p2-7wf7-98mq, fixed 2026.1.29) `[CHECK: B33]` |
| Credential blast-radius | B41 | `auth.profiles.*` + gateway token vs reachability; PII-safe (provider names only) `[CHECK: B41]` |
| **Capability blast-radius (verb-level least privilege)** | B43 | Attested via `--attest`. Classifies the agent's REAL held verbs: `MAILBOX_CONFIG` (auto-forward/filter/delegation), `DESTRUCTIVE` (delete-forever), `COMMERCE` (purchase/checkout/payment — C-339, ESET H1 2026 "Credit Claw" shape), `EGRESS` (send/forward), `REVERSIBLE`. Reversible-only ⇒ PASS; high-blast + ungated ⇒ WARN (never FAIL — B-315: an unscored, self-report-derived verdict must not hard-cap the grade). `ATTESTED` confidence — has its own id/fixture/test the same as a static check, so it's tagged CHECK; the *residual* case with no config or self-report at all is the ATTEST-band dirty-input taint/provenance row below `[CHECK: B43]` |
| **Self-report ⇄ config drift** | B44 | Attested. Config grants a high-blast verb the agent omitted ⇒ WARN (drift / blind-spot / injection-mask). Impossible for a static-only scan `[CHECK: B44]` |
| Declared vs. proven capability drift | B84, T3 | B84 (static): declared `tools.allow` vs the agent's own attested-effective set vs what the trajectory log actually proves was used. T3 (`--behavioral`): a high-blast verb PROVEN in the trajectory log that `tools.allow` never declared — the runtime-observed sibling of B84. **Limit (B-301):** T3 can only assert drift where an enumerable upper bound exists — a non-empty `tools.allow`, or a tool inventory supplied via `--attest`. OpenClaw's schema forbids combining `allow` with `alsoAllow` and recommends `profile` + `alsoAllow`, so on a profile-only config with no attestation T3 reports `UNKNOWN`, not `PASS`. clawseccheck deliberately does not enumerate OpenClaw's built-in profile table (it is version-specific, and T3 does not read the additive per-agent/per-provider grant channels, so enumerating it would report false drift) `[CHECK: B84, T3]` |
| Incident readiness | B85, B346 | Is a tamper-resistant tool-use trail actually present (trajectory sidecar reachable, not rotated away) — the precondition for T1–T3 and B84 to have anything to read. B346 (F-160, TA488/OWAReaper — Proofpoint/NSA, CVE-2026-42897) is the content-ring counterpart: a skill's own text or code instructing erasure of that same trail *after it runs* — shell-history tampering (`history -c`, `unset HISTFILE`, `HISTFILE=/dev/null`, `HISTSIZE=0`) or a truncate/`: >`/`shred`/`rm` aimed at the trajectory sidecar or an agent-log/session path. WARN on a bare directive or a bare sink alone (ordinary log rotation/temp cleanup of a skill's own build/cache output looks identical at the verb level); FAIL only when a directive is corroborated within the same skill by a sink targeting the agent's own audit trail. Distinct from B189 (cron run-log orphans), which is deliberately advisory/never-FAIL because self-erasure is the OpenClaw product default for one-shot cron jobs — this check targets a skill's own content/code, not a cron job's own log `[CHECK: B85, B346]` |
| Effective-tools bypass | B31, B68, B69, B328 | `tools.deny`/`toolsBySender`/per-agent deny vs the `deny write` ⇏ `deny apply_patch`/`exec` footgun; B68 (`apply_patch` workspace-only disabled), B69 (`exec` inline-eval gate missing); B328 (E-060) flags a group/world-writable `tools.exec.safeBinTrustedDirs` entry (or one of the three vendor-hardcoded sticky world-writable temp roots) — the safe-bin fast path runs a matching command with no allowlist match and no approval prompt, so a writable trusted dir lets anyone with write access there plant a binary that inherits that trust; downgrades to WARN when the global `tools.exec.safeBins` is explicitly emptied (the fast path is inert unless a per-agent override re-enables it) `[CHECK: B31, B68, B69, B328]` |
| Sender identity strength | B30 | Mutable display-name allowlists, group history injection `[CHECK: B30]` |
| Control-plane mutation reachability | B32, B71 | cron/config.apply/update.run exposed over gateway; B71 flags `denyCommands` non-exact-entry footguns `[CHECK: B32, B71]` |
| Browser / SSRF exposure | B38, B83, B195, B196, B321, B322, B330 | Metadata-IP, no-sandbox, hostname allowlist; B83 flags excessive redirect-following in the web-fetch tool; B195 (E-060) denylists dangerous `browser.extraArgs` Chrome launch flags — `--disable-web-security`/`--load-extension` FAIL unconditionally, matched in **either** the one-dash or two-dash spelling Chromium honours (`kSwitchPrefixes` is `{"--", "-"}` on POSIX; three or more dashes are **not** a switch and are deliberately ignored), `--proxy-server`/`--proxy-auto-detect`/`--proxy-pac-url` WARN, a non-loopback `--remote-debugging-address` WARNs as a statement of intent only — Chromium **removed** that switch in M113, so on current Chrome it does nothing and the debug port stays on loopback, which is why it is no longer a FAIL — and a loopback-bound `--remote-debugging-address` is deliberately **not** flagged at all: OpenClaw always passes `--remote-debugging-port` and never passes an address, so pinning loopback restates the bind already in force and costs no score; B330 (C-298) covers the Chrome DevTools Protocol control port itself, which OpenClaw opens on **every** managed launch and which has no authentication step — the port is never graded (it is vendor design the operator cannot switch off, so the ordinary loopback-confined case is a genuine PASS that simply states the fact), but the operator's own two levers are: a wildcard `--remote-allow-origins` FAILs, because it switches off the Origin check Chromium added to the DevTools endpoint and so lets any page the agent's browser has open drive that browser over CDP, and an off-host top-level `browser.cdpUrl` or managed-profile `cdpUrl` WARNs (the corroborated FAIL rung for a non-loopback `cdpUrl` stays with B322/B196 rather than being counted a third time); B196 (E-060) flags `browser.evaluateEnabled`, OpenClaw's own arbitrary-JS-evaluate sink on every visited page — graded on effective state, not spelling: because the vendor default is `true`, an absent key and an explicit `true` are the same runtime exposure and are graded identically (only an explicit `false` closes the sink, which is PASS) — the sink alone is WARN, and it escalates to FAIL when a hand-written `browser.profiles.*.driver` of `existing-session`/`extension`, or an attach-only non-loopback `cdpUrl`, points that sink at a browser OpenClaw does not own; B321 (E-060) FAILs a configured `browser.executablePath`/`profiles.*.executablePath` that is group/world-writable (file or containing directory, symlink-chain-resolved) — OpenClaw spawns it with only an `fs.existsSync()` gate, no signature check — and WARNs (unscored) on a non-default `profiles.*.mcpCommand`; B322 (E-060) FAILs (scored, on an otherwise-unscored check) a `driver:"existing-session"` profile's `cdpUrl` resolving to a non-loopback host, since OpenClaw's own SSRF hostname-allowlist requirement never applies to that driver, and WARNs on an absent/loopback/unparseable `cdpUrl` or a set `userDataDir` `[CHECK: B38, B83, B195, B196, B321, B322, B330]` |
| Session visibility / cross-user leak | B39 | `session.dmScope`, `tools.sessions.visibility` `[CHECK: B39]` |
| Backups of identity/memory | C3 | `[CHECK: C3]` |
| Native binary PATH safety | C5 | `[CHECK: C5]` |
| Tool-registry name collision (SkillTrustBench T07) | C5, B104, B24, B30 | Ground-negative (C-196, grounded against the real installed dist, not the recon doc): a skill's SKILL.md frontmatter carries no tool-declaration field at all (the parser recognizes only `name`/`description`/`openclaw.*`/`user-invocable`/`disable-model-invocation` — no `tools:`/`allowed-tools:`); the only tool-REGISTRATION path is plugin/MCP resolution, which seeds its conflict set from the core tool names and drops (never silently overrides) any tool whose normalized name collides with a built-in — MCP tools are additionally namespace-prefixed (`<server>__`), making a bare-name collision with a built-in structurally impossible. The reachable *shadowing* variants remain covered by the checks in this row: PATH-based binary shadowing (C5), skill-install cross-tier shadowing (B104), MCP server impersonation (B24), display-name impersonation (B30). No new check was built — there is no config field to ground one against `[CHECK: C5, B104, B24, B30]` |
| **Host defensive posture** | B50–B54, B101, B150 | Is the agent's *host* watched: network IDS, host audit, file-integrity, EDR/AV, firewall, outbound-egress-filtering posture (B101) — read-only, WARN only for a high-privilege agent, never FAIL. A self-reported `host_monitors` entry (attestation) upgrades a gap to an `ATTESTED` PASS for a monitor the scan can't see; static detection still wins. B150 flags a systemd `Restart=always` OpenClaw-related persistence unit `[CHECK: B50, B51, B52, B53, B54, B101, B150]` |
| Non-loopback gateway hygiene | B56, B70, B73, B340 | B56 (control-UI `allowedOrigins: "*"`), B70 (`trustedProxy.allowLoopback` header-spoof surface on a non-loopback bind), B73 (mDNS full advertisement on a non-loopback bind). B340 (F-156) is the odd one out here: every other row on this page is declared-state only, reasoning purely about `gateway.bind` as a config string; B340 corroborates it against the ACTUAL listening socket, read from `/proc/net/tcp{,6}` (`sockets.py`, no subprocess — matches `hostwatch.py`'s doctrine, and structurally immune to a competitor tool's peer-column regex bug that misread a LISTEN socket's wildcard peer address as its bind). FAIL when the declared bind is loopback but the effective one is not AND at least one non-loopback listener on that port is POSITIVELY confirmed to be the OpenClaw gateway itself; identity confirmation (B-400, 2026-08-01) requires the KERNEL-RESOLVED executable path (`/proc/<pid>/exe`, which argv text cannot spoof) to name a real script interpreter or OpenClaw itself before a structural, path-segment match against the invoked script's path is even consulted — never a bare substring search over the whole command line, which previously credited any decoy merely mentioning "openclaw" in its argv (an SSH tunnel to a similarly-named host, a text editor with the repo path open, a shell script from a directory named `openclaw`) as the gateway. Anything else — a positively different process (Docker's userland proxy sharing a port number), or identity that cannot be resolved at all (permission denied is the common case) — degrades to UNKNOWN, never a kept FAIL, so this never softens or invents a FAIL on an absence of evidence, only fires on positive evidence. WARN when declared is non-loopback but effective is loopback (dangerous config, not currently exposed); PASS when they agree (either genuinely loopback, or a declared exposure that reality confirms — already assessed by B2/B70 in that case); UNKNOWN whenever the runtime signal itself is missing (the socket scan did not run, `/proc` unavailable, nothing is listening on the resolved port yet, or `gateway.port` is present but not a valid port), or whenever the declared bind profile itself is unresolvable from the config alone (`auto`, an absent/empty `gateway.bind` — C-135 bug 2, resolves through the identical container-dependent vendor default path as `auto` — or a `custom` profile with no valid `customBindHost`). An ABSENT `gateway.port` (B-400) no longer reports UNKNOWN outright — it falls back to OpenClaw's own grounded default gateway port (18789, confirmed against the installed dist's `resolveGatewayPort()`) and corroborates against that instead, closing a real coverage gap on the single most common real config shape for that field `[CHECK: B56, B70, B73, B340]` |
| Plugin / MCP install-time trust | B5, B15, B24, B42, B57, B166, B167, B174, B177, B194, B325, B341, B342, B348, C047 | B57 flags `permissionMode=approve-all`; C047 is a manual-review flag for a non-local MCP endpoint; B166 (C-211, grounded against the real OASB registry corpus — 0/2988 benign FP, 1/166 malicious caught) flags a known paste/exfiltration host (webhook.site, ngrok, pastebin, `.onion`, …) named in an MCP server's own `command`/`args` — scored (C-230) with a two-tier verdict: a very narrow FAIL subset (`webhook.site`, `.onion` — no legitimate startup use) and WARN for the dual-use rest (dev tunnels, hosted-MCP endpoints, paste/fetch hosts); B167 (B-231) reuses the same B100/B103 remote-fetch/pipe-to-shell detector (+ the B-118 first-party-installer allowlist) against a plugin's own `plugins.entries.<name>.config.appServer.command` launch command, a surface with no separate approval gate; B174 (B-238) reads the operator-facing install GATE itself — `security.installPolicy.{enabled, exec:{allowInsecurePath, allowSymlinkCommand, trustedDirs, passEnv}}` — distinct from B42 (which only scans a skill's own postinstall hook content): WARN when the gate is not enabled (installs run with no operator review at all), FAIL on an unrestrained `allowInsecurePath` (no `trustedDirs` to constrain it), WARN when `trustedDirs` narrows that same flag to an operator-declared directory, no finding on a bare `allowSymlinkCommand` alone (C-135 re-pass, B-238: grounded against the dist, it only skips the "not a symlink" check — the resolved target still gets the full permission/ownership verification), WARN on secret-shaped `passEnv` forwarding; B194 (E-060 item 1) mirrors that same `allowInsecurePath`/`allowSymlinkCommand`/`trustedDirs`/`passEnv` model onto a DIFFERENT config subtree, `secrets.providers.<name>` entries with `source:"exec"` — a command that runs on every secret RESOLVE (not just install/update), with the resolved credential already in hand once it returns; the schema's second `source:"exec"` shape (`pluginIntegration`, no `command` field) is excluded, since it has none of this escape surface; B177 (B-240) reads OpenClaw's OWN persisted ClawHub trust verdict for an installed plugin — `installed_plugin_index.install_records_json.<pluginId>.clawhubTrustDisposition` in the shared state SQLite DB (`~/.openclaw/state/openclaw.sqlite`, opened read-only via `file:...?mode=ro` + `PRAGMA query_only=1`, never previously read) — FAIL only on the unambiguous `"blocked"` disposition, WARN on `"review-required"`/`"review-recommended"`/pending/stale, UNKNOWN when the state DB or index row is absent/locked/unreadable; B325 (E-060) WARNs (never FAILs, unscored) when a configured `marketplaces.feeds.<name>.url` hostname is not `clawhub.ai` — OpenClaw hostname-gates only an ad-hoc `--feed-url` CLI override, not a named config profile, so a configured feed url demonstrably expands the trusted-fetch hostname set for future skill/plugin installs; B341 and B342 are unscored disclosure advisories over the rest of the plugin block, which no check read before — B341 names any `plugins.entries.<id>.hooks.allowPromptInjection` / `.allowConversationAccess` grant (`PluginEntrySchema`, a per-entry `hooks` object distinct from the root-level `.strict()` `hooks` schema), i.e. per-turn prompt mutation and transcript reads; B342 names the owner of `plugins.slots.memory` / `.contextEngine` (a `.strict()` object with exactly those two fields — `"none"` disables the slot and is never reported) and any id present in BOTH `plugins.allow` and `plugins.deny`, which is silently blocked because deny wins over allow — both WARN-only and never FAIL, since an intentional grant, a named memory plugin and a stale emergency-rollback deny entry are indistinguishable from an abusive one in config alone; B348 covers the OTHER auto-load surface in the same block — `plugins.load.paths` is an independent load surface from `plugins.entries`, so a load-path directory whose `openclaw.plugin.json` declares an id with no matching `plugins.entries.<id>` record keeps loading on every gateway start, and `openclaw plugins uninstall` removes only the entries record, never the load path itself. WARN-only, unscored, and gated on `plugins.allow` being unset (an explicit reachability allowlist is PASS), because the same shape is the ordinary one for a plugin still in local development and deliberately left unregistered `[CHECK: B5, B15, B24, B42, B57, B166, B167, B174, B177, B194, B325, B341, B342, B348, C047]` |
| **Install-time execution in the installed dependency tree (lifecycle hook · `binding.gyp`)** | B349 | B42 above scans a SKILL's own manifest for a `pre`/`postinstall` hook; B349 (F-167) is its sibling for the one directory every content scanner here steps around — the installed npm dependency tree, where a compromised TRANSITIVE package lives. The threat shape is a widely-depended package republished with an install hook pointing at an obfuscated file shipped in the tarball, while the consuming project's own source contains nothing. **Two surfaces, because one was a false negative (B-447):** a declared lifecycle script, AND a GYP command-expansion (`<!(node index.js …)`) in the package's root `binding.gyp`, which node-gyp runs while merely *configuring* and which npm triggers on the file's mere presence. The node-gyp worm's tarballs declare no lifecycle script at all, so a `scripts`-only check is exactly the script-focused monitoring that shape was built to walk past. FAIL requires a CONJUNCTION on either surface: a resolvable in-package `node <file>` target AND that target tripping an already-validated detector (`skillast.analyze_javascript`, or `textnorm.obfuscation_signals` for Unicode trickery). Measured on real trees 2026-08-04 — 380 packages in the OpenClaw tree carry 3 install-lifecycle hooks, all benign, and 0 trip the conjunction — so a rule keyed on the hook alone would have shipped three false-positive FAILs. The `binding.gyp` surface was measured the same way before shipping (163 trees, 28,350 packages): 11 packages ship one, carrying 27 command expansions, of which **0** resolve to an in-package file — every real one is an inline `<!(node -e "require('nan')")` expression or a non-node probe such as `pkg-config`, neither of which has bytes here to read. Because "no false positive observed" is worthless when nothing exercised the path, the resolving branch was measured separately against 6,067 cached package tarballs: 8 ship a root `binding.gyp` and exactly one, `canvas@2.11.2` (node-canvas), genuinely resolves — 4 commands into `util/has_lib.js` and `util/win_jpeg_lookup.js`, producing **zero** signals of any grade. So the branch is exercised by a real, widely-installed, honest package and stays silent on it. Only the package ROOT's `binding.gyp` is read, since that is where npm's automatic `node-gyp configure` looks; a nested one is not auto-invoked and reporting it would manufacture a finding the installer never triggers. The adversarial pass on that widening also fixed a false-positive FAIL that was already live on the lifecycle-hook half: `textnorm.obfuscation_signals` reports "confusable characters folded to ASCII" for *any* Cyrillic or Greek text at all, so an honest installer whose only unusual property was a non-English comment earned a CRITICAL FAIL. A confusable is now FAIL-eligible only when `textnorm.confusable_in_ascii_context` also holds — a lookalike swapped INTO an otherwise-Latin word (`іgnore`, `requіre`), which is the attack — while a whole non-Latin token is disclosed as i18n and never a verdict. That discriminator is the one `textnorm` already shipped to keep B58 off multilingual prose, not a new threshold. A hook target that cannot be READ (one machine-generated line past `_HOOK_MINIFIED_LINE`, a parse failure, oversized) is **UNKNOWN naming the package**, never PASS and never FAIL, following B97's own precedent: bytes we did not read support no verdict, and "I could not read this package's installer" is itself actionable. Root discovery is PATH-based and subprocess-free (`shutil.which`), and accepts a directory only when its manifest's `name` equals the binary name, so a shadowed or renamed binary cannot redirect the scan onto an unrelated tree; nothing found degrades to UNKNOWN. Scoped to the OpenClaw install tree because measurement found every real dependency tree under the npm global root and none under any skill or plugin directory — a check scoped to those would scan nothing and report clean. **Declared limits:** FAIL inherits the shipped JS detector's recall exactly, so a READABLE staged decode-then-exec (decode into a variable, eval it a line later) reaches no verdict — widening that detector is its own change with its own adversarial pass. GYP's own `includes` directive can pull in a `.gypi` carrying further expansions; that is not followed. Offline by construction: no registry is ever consulted about a version `[CHECK: B349]` |
| Plugin tool-result-interception capability disclosure | B187 | OpenClaw exposes a plugin contract, `agentToolResultMiddleware`, whose registered handlers transform ANY tool result at runtime before it reaches the model (append instructions, or rewrite a security tool's FAIL into a PASS) — a runtime interception point strictly more powerful than a single poisoned MCP server, and never previously enumerated. Reads the sibling `installed_plugin_index.plugins_json` column (same state DB, same row as B177's `install_records_json`) for the full per-plugin index record. Gated on `origin != "bundled"` — 67 of 69 plugins on a stock install ship with the dist itself and 47 of the 69 already contribute some contract, so an ungated "declares a contract" would WARN on every clean machine; bundled plugins are OpenClaw's own shipped code, not the third-party surface this exists to cover. DISCLOSURE ONLY, never a FAIL asserting malice: WHICH non-bundled plugin holds the contract, its origin, and its enabled state are statically decidable; WHAT the handler's code actually does with a tool result is not. Two adjacent readings of the same column are deliberately NOT attempted because the persisted schema cannot support them: `contributions.providers` carries no `baseURL` field (B178 already covers the real provider-baseURL surface, in config, not here), and `commandAliases` is normalized to the alias NAME only — the mapping target is stripped before persistence, so a hijacked-alias claim is unprovable from this data. WARN when a non-bundled plugin declares the contract; UNKNOWN when the state DB, index row, or `plugins_json` column is absent/locked/unparseable; PASS otherwise (the overwhelming common case on a stock install) `[CHECK: B187]` |
| ClawHub CLI token store (outside the OpenClaw home) | B182 | The ClawHub CLI keeps a long-lived API token in a PLAINTEXT JSON file at documented, fixed paths outside the OpenClaw home (`$CLAWHUB_CONFIG_PATH`/`$CLAWDHUB_CONFIG_PATH`, else `~/.config/clawhub/config.json` and its darwin / `$XDG_CONFIG_HOME` / `%APPDATA%` / legacy-`clawdhub` variants — grounded against clawhub@0.22.0 `dist/config.js`). C015 is rooted at the OpenClaw home and never reached it, so nothing checked its permissions — yet that token publishes new versions of the user's OWN skills, making any agent or skill able to read files a supply-chain pivot onto every install. FAIL only when a token is actually present AND someone other than the owner can read the file (the CLI writes it 0600 and re-chmods on every write, so a looser mode is a real deviation; the group-readable leg reuses B-189's singleton-group down-rank so a user-private-group box never false-FAILs); WARN when the containing directory is writable by others; PASS with no token stored or an owner-only one; UNKNOWN when no store exists, one can't be parsed, or the platform has no meaningful POSIX mode bits. The token VALUE is never read into a message, logged, or placed in evidence — only its presence and the file's mode `[CHECK: B182]` |
| Post-install skill tampering | B181 | An installed skill's bytes no longer match the SHA-256 digests ClawHub recorded for it AT INSTALL TIME — read from `.clawhub/lock.json` (`skills.<slug>.skillFile` plus the full `verification.artifact.files[]` manifest) and from the per-skill `.clawhub/origin.json`, with the CLI's `.clawdhub/` legacy fallback handled for both. Purely local: those digests already sit on the user's own disk and were never read. Strictly stronger than `--monitor`, which only detects change since ClawSecCheck first looked and therefore bakes any pre-existing tampering into its own baseline. FAIL on a digest mismatch (a skill verified at install and rewritten afterwards — by the user, another agent, or another skill); WARN on a recorded file that is now gone, and on a whole-directory symlink install (the documented working-tree link, where a mismatch is expected and constant); UNKNOWN when nothing is recorded, the skill directory can't be located, or no recorded file could be hashed. Files present on disk but absent from the manifest are deliberately never flagged — the real install carries 61 of them (`__pycache__/*.pyc`, the installer's own `_meta.json` and `.clawhub/origin.json`) `[CHECK: B181]` |
| Audit target vs the config OpenClaw actually loads | B183 | The collector now mirrors OpenClaw's own config resolver, so an environment override (`OPENCLAW_CONFIG_PATH`, `OPENCLAW_HOME`, `OPENCLAW_STATE_DIR`) that points the product at a different file than the one audited is disclosed instead of silently producing a confident verdict over a dormant file. The resolved target is stated in both the text report and `--json`. WARN when the audited path is not the one that would be loaded; PASS when they agree; UNKNOWN when resolution cannot be settled. Never FAIL, and unscored — a mismatch is a transparency problem, not a security state `[CHECK: B183]` |
| Environment overrides the graded config | B192, B324 | Values delivered through the two global dotenv files OpenClaw loads at startup can switch on a setting the config field says is off, so a check reading only the config emits an affirmative PASS over a state the environment has changed. Break-glass toggles left enabled in a dotenv are surfaced, and the affected PASS wording is softened to the claim the evidence supports. Deliberately NARROWS rather than closes: a variable exported into an already-running shell leaves no on-disk trace and is undetectable — a process boundary, not a network one. That residual is a false negative only, never a false positive, and is reported as UNKNOWN rather than an all-clear. B324 (E-060) is the config-key half of the exact same OR condition B192's `OPENCLAW_LOAD_SHELL_ENV` toggle checks the env-var half of (either side, `shouldEnableShellEnvFallback(env)` or `cfg.env?.shellEnv?.enabled === true`, turns it on) — WARN-only when `env.shellEnv.enabled` is `true`, same never-FAIL rationale as B192 since OpenClaw's own docs call the feature legitimate and commonly wanted. B323 (E-060) is a narrower, unrelated `env.*` gap: an explicit, literal `PATH` override in `env.vars`/the `env.<KEY>` catchall — the one dangerous key OpenClaw's own ~254-entry blocklist does not cover — WARN-only (never FAIL, since exploitability depends on the launcher's own environment, which a static config audit cannot observe) `[CHECK: B192, B324, B323]` |
| Redirected ClawHub registry issues the supply-chain verdicts | B184 | Three shipped checks consume a verdict a registry issues — the install decision (B135), the plugin trust disposition (B177), and the install-time file digests bytes are verified against (B181) — and none asked WHICH registry issued it. Repointing the endpoint (`OPENCLAW_CLAWHUB_URL` / `CLAWHUB_URL`, plus the sibling GitHub-codeload ladder) therefore did not silence those checks; it made them report supply-chain health on the redirected host's word, B181's digest comparison matching by construction. Detection is fully local: OpenClaw persists the endpoint per skill as `registry` in `.clawhub/lock.json` and `.clawhub/origin.json`, files B181 already opened and never read the field from, corroborated by the persistent global dotenv files. WARN-only and unscored — a self-hosted or enterprise mirror is legitimate and disclosed, so a FAIL would false-positive on it. CLOSES the DETECTION half only: whether the redirected host serves anything malicious would require resolving and contacting it, which is out of scope by doctrine and is never implied `[CHECK: B184]` |
| Bundled code-load root relocated by the environment | B186 | The skills and hooks the agent loads at startup do not have to come from its install tree: a persistent environment override can repoint that root somewhere else, and every check that reasoned about the installed set was reading a directory the product no longer loads from. Read from the same persistent artifacts as B183/B192 — a systemd unit's `Environment=`/`EnvironmentFile=` and the two global runtime dotenv files — never from `os.environ`, whose contents belong to the auditing shell rather than to the agent service. FAIL when the relocated root is group- or world-writable without the sticky bit (another local account can replace the code the agent executes); WARN on a readable override, because a source-checkout developer relocating their own tree is legitimate and this is disclosure rather than an accusation; UNKNOWN — deliberately never PASS — when no override appears in any persistent artifact, since an ambient-shell export leaves nothing on disk to read `[CHECK: B186]` |
| Gateway credential inlined in a systemd unit | B193 | OpenClaw resolves the gateway's shared secret from `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` as well as from the config, so operators write it straight into the service unit — a location no config-reading check ever opened. FAIL when the credential is inlined AND the unit is readable by other local accounts, which exposes the gateway secret to every one of them; WARN when only its owner can read it, since an embedded token still drifts silently out of step with `gateway.auth.token` and OpenClaw's own service audit asks for a reinstall; PASS when an OpenClaw unit was read and inlines no credential; UNKNOWN when no unit was readable (not Linux, no service installed, or unreadable). The credential value is tested for presence only and never enters a message, evidence entry, or log `[CHECK: B193]` |
| Poisoned tool description already delivered to the model | B185 | Post-hoc forensic only: reads the `context.compiled` records OpenClaw writes to the trajectory sidecar, which carry every tool description — MCP servers included — verbatim as sent to the model, closing a gap where a poisoned live tool description was structurally invisible to every static config-reading check. Can never pre-clear a live server: a server that serves a clean description on the recorded runs can serve a poisoned one next. FAIL on an encoding/exfil anchor (hidden comment, data-URI, base64 shell payload, a fetch piped into an interpreter, an injection directive in a parameter); WARN on a bare instruction-override keyword, an example URL, or a credential-read directive paired with a concealment instruction whose intent (conceal the act, or a protective guardrail) is not statically separable — three rounds of adversarial review found a static classifier cannot reliably tell those apart, so the conjunction is never escalated past WARN; UNKNOWN when no trajectory sidecar carries a `context.compiled` record — never PASS on absent evidence `[CHECK: B185]` |
| State database with device keys and auth tokens readable at rest | B188 | `state/openclaw.sqlite` holds device private keys, auth tokens, VAPID keys and stored credentials in the clear, and no existing permission check ever stat'ed it — B19 covers workspace memory/logs, B11 reads only openclaw.json's mode, B182 only the ClawHub CLI's own token store. Reachability is proven, not assumed: checks the directory chain both below AND above `~/.openclaw` (a 0700 `$HOME` seals everything beneath it, the routine umask-022 outcome that must not FAIL). FAIL when the database (or a -wal/-shm sibling) is reachable and readable by another user across the whole chain; WARN when reachable and writable only (can't read the secret, can swap the database under the agent); UNKNOWN when no state DB is present or the filesystem is non-POSIX; PASS otherwise, with distinct wording when a loose in-tree mode was sealed by a restrictive parent rather than genuinely tight. Conventional at-rest file-permission check only — the database is never opened, so no secret is ever read `[CHECK: B188]` |
| Cron job executed and erased its own definition | B189 | Reads the cron execution trail (`cron_run_logs`) for job ids with no surviving definition — one-shot jobs default to `deleteAfterRun` true and the run log has no foreign key to the job store, so self-erasure is the product default, not a signal. Advisory only and deliberately never FAIL (`scored=False`): a bare "orphan ⇒ FAIL" would false-positive on essentially every real user doing one-shot scheduling. Corrects B168's own overclaim in the same change — a truncated or legacy-store-shadowed job-definition read now degrades this and B168 to UNKNOWN instead of a "verified" PASS over a store that was never fully seen. WARN when a run-logged job id has no surviving definition; PASS when every one does (including when the definition read was truncated but still covered every id in the run log); UNKNOWN when the state DB, table, or job-definition read is unusable, or when truncation coincides with an apparent orphan, making orphan-ness uncomputable `[CHECK: B189]` |
| Debug traffic-capture proxy enabled, redirected, or holding captured traffic | B190 | Deliberately narrowed against double-counting: the cache-trace FILE sink is already discovered and content-scanned by B164, and is not re-read here. What is genuinely uncovered is the `OPENCLAW_DEBUG_PROXY_*` subsystem, enabled solely by environment variable with no config field anywhere in the dist, whose captured rows live in SQLite tables the file-path-only log-discovery model structurally cannot reach. Counts rows only, never content — `headers_json`/`data_text` hold bearer tokens and request bodies, and flagging a developer's own legitimately captured provider traffic as "exfil" would be the false positive this check must not create. WARN when capture is observably on (enablement variable, redirect URL, or existing rows); UNKNOWN otherwise — never PASS, since an ambient shell export or a redirected `OPENCLAW_DEBUG_PROXY_DB_PATH` both leave zero rows countable here without meaning capture is off `[CHECK: B190]` |
| Plugin holding the runtime tool-result-rewrite contract | B187 | A plugin declaring `contributions.contracts.agentToolResultMiddleware` can rewrite every tool result before it reaches the model — a runtime interception point stronger than a single poisoned MCP server. WARN-only capability disclosure, gated on `origin != "bundled"`: 67 of 69 plugins on a stock install are bundled and already carry this contract, so an ungated check would fire on every clean machine. Reads the `plugins_json` column of `installed_plugin_index` via its own independent `SELECT`, deliberately separate from B177's `install_records_json` read, so a schema variant missing one column degrades only that column's checks to UNKNOWN rather than both. Never asserts malice — only that the contract is present, on which non-bundled plugin, and whether it is enabled `[CHECK: B187]` |
| OpenClaw's own runtime audit trail (`audit_events`) | B191 | `audit_events` records every tool the agent actually invoked (kind/status/error_code/session/tool_name), and was completely unread before this check. `--behavioral`-only, `scored=False`, never in a default audit — invoked solely from `behavioral.analyze()`. Ships a coverage/observability signal, a corroboration source for `--behavioral` keyed on run_id/session_key/tool_call_id (its real value: catching the gap when the trajectory source is disabled or has rotated past its 60-file cap while `audit_events` still holds those sessions), and exactly two narrow, near-zero-FP WARN signals — a runtime policy denial (`status=blocked`) and an evasive tool name that failed OpenClaw's own syntax check. Hard GR#5 scope limit, stated in-source: the table stores `tool_name` only — no arguments, no path, no host — so no volumetric or presence-based rule on it can survive contact with a real host; this check does not attempt one. When the row-sample is truncated and nothing was found, the verdict explicitly degrades to `pass_confidence="no_signal"` rather than claiming undiminished confidence over an incomplete sample `[CHECK: B191]` |
| OpenClaw's own runtime audit trail (`audit_events`) — coverage, policy-blocked tools, evasive tool names, and trajectory divergence | B191 (`--behavioral`) | Reads OpenClaw's OWN metadata-only runtime audit trail (`audit_events` in the shared state SQLite DB — previously entirely unread), which stores `tool_name` alone (no argv, no command, no path, no host), so this deliberately builds no volumetric or tool-name-presence rule — that would false-FAIL essentially every benign config (measured on the real box: 344 of 502 rows are plain `bash`). Three things in one finding: (1) a coverage/observability signal — is the table present, readable, how far back does it reach; (2) a `--behavioral` corroboration source keyed on `session_id` that detects DIVERGENCE — `audit_events` still holding a session the trajectory sidecar no longer does, because it was disabled (`OPENCLAW_TRAJECTORY=0`) or rotated that session out past its own 60-file cap while this independently-bounded (30-day / 100,000-row) store did not; (3) exactly two narrow, near-zero-FP signals — `status=='blocked'`/`error_code=='tool_blocked'` (the policy engine denied a tool at RUNTIME, a signal no other check sees) and `tool_name=='unknown'` (OpenClaw's own audit layer could not even record the tool's name). NARROWS the "prove what the agent did" aspiration, does not close it — a tool-name-only record can never show what a tool was told to do or where it reached. Opt-in, `scored=False`, WARN-only, `--behavioral` only — never `audit()`/CHECKS; F-154: MAY still CAP (never raise, never earn a scored point) the A-F grade via a dedicated cap-only channel when `--behavioral`/`--full` actually ran this run — see `scoring.BEHAVIORAL_SIGNAL_CAP` `[CHECK: B191]` |
| Content-security ring — prompt-injection-shaped directives | B58, B59, B60, B61, B62, B63, B64, B65, B66, B67, B74, B91, B95, B159, B160, B163, B334, B337 | Unicode-obfuscated hidden text (B58), markdown-image data-exfil (B59), self-replication (B60), cross-agent config snooping (B61), capability-intent mismatch (B62), silent-instruction (B63), instruction-hierarchy override (B64), sleeper-trigger (B65), persona jailbreak (B66) — also scans a bundled script's own docstring/comment TEXT, not just bootstrap/SKILL.md prose, since that is otherwise invisible to this ring (a role-override payload authored inside a `scripts/*.py` module docstring), per-source trust contracts (B67), forged-provenance content (B74), dynamic-dispatch sink obfuscation (B91), dependency confusion (B95), self-privilege-escalation directive (B159), prose-intent bulk-data exfiltration (B160), social-engineering/credential-phishing prose — urgency + authority-claim + credential-solicitation-or-OOB-action triad (B163), undocumented bundled helper introduced only by an agent-directed run directive that strips the user's approval (before-reply / no-ask / silent / on-keyword) — WARN-only, deliberately not keyed on filename shape. The directive must be addressed to the READING AGENT: third-person narration of another runner ("the cron job runs …", "`ci/build.sh` is invoked by the pipeline") is descriptive prose and out of scope, as are a prohibition ("never run X"), a block that hands the decision back to the user or instructs disclosure of the helper's output, a helper catalogued in the skill's own scripts/usage inventory, an ordering-before-reply directive whose own clause is immediately followed by a Usage/Setup paragraph stating in plain prose what the helper does/produces (the single-mention documentation shape a small skill has instead of a separate inventory section — scoped to that one class and to the directive's own sentence, since documenting the effect says nothing about consent, visibility, or a keyword trigger), and a helper named with no directory component. A defensive heading ("## Known risks") only suppresses the finding when the block also carries a counter-instruction ("do not comply") (B334). A skill's own Markdown frames a shell command as a mandatory, pre-response checkpoint (or tells the agent not to ask permission), and that command reads a hidden dotfile via `$(cat ...)` and POSTs it via curl/wget -- a "licensing check"-style exfil directive distinct from B334's bundled-helper shape (B337) `[CHECK: B58, B59, B60, B61, B62, B63, B64, B65, B66, B67, B74, B91, B95, B159, B160, B163, B334, B337]` |
| Multi-agent / subagent privilege separation | B45, B46, B47, B75, B76, B327 | Per-agent trifecta decomposition (B45), multi-agent trifecta exposure (B46), cross-agent trifecta reassembly (B47), MCP tool-inheritance bypass — attested (B75) and high-blast (B76); B327 (E-060) FAILs `agents.defaults.embeddedAgent.projectSettingsPolicy="trusted"` — a freshly cloned, attacker-authored repo can ship its own `.openclaw/settings.json` and have its `shellPath`/`shellCommandPrefix` merged verbatim into the embedded agent's live bash tool, prepended to every subsequent command; the vendor default (`"sanitize"`, applied identically on absence or a typo) strips exactly those two keys and is PASS, matching `"ignore"` `[CHECK: B45, B46, B47, B75, B76, B327]` |
| Dangerous break-glass overrides | B48 | `[CHECK: B48]` |
| Elevated-tools default bypasses human approval | B326 | `agents.defaults.elevatedDefault="full"` is the only value that skips the human-approval gate outright (bash-tools.js `bypassApprovals`) — `"on"` (the stock runtime default) and `"ask"` are both approval-gated and behaviorally identical, so neither is flagged. FAIL when `"full"` and `tools.elevated.enabled` is not explicitly `false` AND the global `tools.elevated.allowFrom` grants at least one sender; WARN when `"full"` but either dormancy gate applies (`tools.elevated.enabled=false`, or `allowFrom` empty/absent for every provider — a per-agent `allowFrom` can only further restrict, never rescue a failed global check, so only the global leg is checked; round-2 C-135 review closed this as a real gap, not a residual) `[CHECK: B326]` |
| In-chat privileged-command surface (raw shell / config-mutation / MCP-registry-rewrite / plugin-toggle) | B171 | `commands.bash`/`config`/`mcp`/`plugins` are in-chat commands gated only by their own `ownerAllowFrom`/`allowFrom`/`useAccessGroups` — a separate gate from B2's channel policy and B3's tool allowlist, and previously entirely uncovered (B-235). FAIL/CRITICAL (bash/config) or FAIL/HIGH (mcp/plugins) on a wildcard-open gate or an empty gate on a channel already known to be open; WARN on an empty gate elsewhere or `useAccessGroups=false`; UNKNOWN when no channels are configured to assess reachability through `[CHECK: B171]` |
| Filesystem-write tool exposure | B55 | Broad fs-write without scoping `[CHECK: B55]` |
| **Config-write / config-health / session-approval advisories** | B77, B78, B79 | **B77:** reads `~/.openclaw/logs/config-audit.jsonl` for unexpected writers or suspicious-diff flags (advisory, `scored=False`). **B78:** reads `config-health.json` for a non-null `lastObservedSuspiciousSignature` field (advisory). **B79:** samples recent Codex session JSONL files to detect `approval_policy=never` on every sampled turn (advisory) `[CHECK: B77, B78, B79]` |
| **Log corpus threat-hunting (advisory)** | B164 | Content-scans the agent's OWN log/transcript corpus (trajectory sidecars, `logging.file`, `cacheTrace`, session transcripts, config-audit log, memory files, install backups) for signals against the agent (injected instructions surfacing in log content) and against its environment (exfil evidence, dangerous-capability use, compromise IOCs, tamper/anomaly, at-rest secrets). Quiet-by-default (base-rate discipline): WARN only when ≥2 signal classes co-occur in one sink, or a single class with inherent same-line/permission corroboration fires (exfil evidence is secret+exfil-host paired; secrets-at-rest also needs a world-readable sink); isolated single-class hits are suppressed to a quiet report hint, never a WARN. Also correlates across artifacts (C-221): when an installed skill's own text names a high-specificity IOC (a known drop-host or a credential/secret-shaped path) and that same IOC turns up in the agent's log corpus: a **known drop-host** match alone WARNs (genuinely low base-rate), while a **credential/secret-path** match is only a corroborator — it counts as one extra signal class, so it needs a co-occurring class to WARN (a helper skill legitimately naming and reading `~/.aws/credentials` can't sole-trigger a false alarm). Redacted token + declaring skill name + count only — raw log content never emitted. Advisory, `scored=False`, never FAILs. B164 itself never caps the A-F grade (a same-line `exfil_evidence` cap exception was tried under an earlier I-025 ruling and RETRACTED after independent review found no sound host/verb gate exists for this tool's own audience) — every B164 signal class, including `exfil_evidence` (same-line or cross-line), stays fully excluded from the grade; the sole runtime signal that may cap the grade is a trajaudit-style indicator match `[CHECK: B164]` |
| **Agent memory as an untrusted re-consumption surface (advisory)** | B180 | The "logs the agent reads back as untrusted input" leg of E-044 Phase 5 (originally reserved as B162, an external Sentry/Datadog-style observability-MCP framing that hit a real grounding-gate wall — OpenClaw exposes no config field distinguishing an untrusted-ingest log/observability MCP server from any other; B162 stays permanently skipped). Re-scoped onto a surface that IS grounded: the agent's own `<workspace>/memory/**` corpus (the same `memory` LogSink kind B7/B19 already use) is architecturally distinct from every other B164 sink — it is the one OpenClaw is grounded to RE-CONSUME as trusted context in a later session (a `memory_search`-style tool observed in trajectory `data.name`), not a write-only diagnostic trail. Content-scans memory files (same `logscan.py` six-class scanner B164 uses — zero new regex) for an injected directive (`injection_against_agent`, reusing `INJECTION_PATTERNS`). Quiet-by-default like B164: an isolated injection-marker hit with nothing else corroborating in the SAME memory file stays PASS — this is deliberately how a memory note that merely QUOTES an attack (a security note, an audit tool's own output) is kept from firing; WARN requires that hit to co-occur with >=1 other independent signal class in the same file. Complements B7 (structural: is there access control on memory writes) and B164 (broad forensic sweep across every sink kind) rather than duplicating either. Advisory, `scored=False`, never FAILs `[CHECK: B180]` |
| Codex/device-pairing hygiene | B136, B138, B176 | B136 flags Codex CLI project `trust_level="trusted"`; B138 flags a dangling high-scope pending device pairing; B176 (B-243) inventories standing `operator.admin`/`operator.write` authority in the *approved* device store (`devices/paired.json`) — B138 only ever audited the pending *request*, nothing previously read the resulting grant. WARN-only advisory (count + age, never the token value) — matches B138's precedent that >=1 paired operator device is the expected state, never FAIL `[CHECK: B136, B138, B176]` |
| Cron scheduler persistence surface | C048, B168 | Top-level cron entries; B168 (B-231) collects and content-scans the cron job store (`payload.message` / `trigger.script`) for injected directives, flags a `deleteAfterRun`+exec self-erasing job, and returns UNKNOWN on an absent/unreadable store `[CHECK: C048, B168]` |
| Injection-like text in HTML image attributes | C074 | `[CHECK: C074]` |
| Egress inventory / secrets-at-rest scan | C014, C015 | C014 enumerates the outbound-capable surface; C015 scans the OpenClaw home for secrets at rest `[CHECK: C014, C015]` |
| Proxy header trust | C032 | Real-IP fallback trust boundary `[CHECK: C032]` |
| **Behavioral trajectory (proof-by-log)** | T1, T2 (`--behavioral`) | Every check above answers "what the agent *could* do" from config/skill-source; the T-series reads OpenClaw's trajectory sidecar and answers "what it *actually did*" — an observed ingress→sensitive→egress verb sequence (T1), a fail→fail→success series on a sensitive verb (T2). T3 is tagged above alongside B84 (its static sibling). Metadata-only: never reads call/return payloads. WARN-only, never scored — a separate mode, not part of the ORDINARY per-check A-F scoring. F-154: a fired T1/T2/T3/B191 MAY still CAP (never raise, never earn a scored point) the A-F grade via a dedicated cap-only channel, but only when `--behavioral`/`--full` actually ran the analysis this run — see `scoring.BEHAVIORAL_SIGNAL_CAP` `[CHECK: T1, T2]` |

## Non-static coverage (ATTEST / JUDGE / CEILING)

Categories with **no `CheckMeta` id** — closed some other way, or an honest, declared
ceiling. A category with neither a CHECK-tagged row above nor a tagged row here is a
silent gap by definition; `tests/test_threat_coverage_ledger.py` fails the build on one.

- Dirty-input taint/provenance — "sanitized ≠ trusted" for the *agent's own runtime data*
  (distinct from the skill-code `CRED_EXFIL_FLOW`/`ENV_EXFIL_FLOW` AST taint above, which
  traces dataflow in *skill source*, not agent runtime state). No config surface exists to
  read this statically; the `--attest`/`--ask` self-report protocol lets the agent disclose
  its own untrusted→action gating instead, feeding B43/B44 at `ATTESTED` confidence, which
  never overrides a config fact `[ATTEST]`
- File-read → network sink with no independent credential/exfil signal nearby in the skill
  (`TT4_FILE_NET`) — `check_installed_skills` silently drops this "info"-severity AST
  finding when uncorroborated; `--judge-packet` recovers it as `UNKNOWN` for a host-agent
  second look `[JUDGE: TT4_FILE_NET]`
- Externally-controlled value flowing into a network-fetch URL, an SSRF shape
  (`TT_SSRF`) `[JUDGE: TT_SSRF]`
- External input flowing into a subprocess call as a non-program argument, argument (not
  command) injection (`TT5_ARG_INJECTION`) `[JUDGE: TT5_ARG_INJECTION]`
- Direct shell/exec-family sink call with no independent credential/exfil signal nearby
  (`DANGEROUS_SINK`) `[JUDGE: DANGEROUS_SINK]`
- Env-var / agent-config secret placed in an auth-shaped keyword (`headers=`/`auth=`/
  `cert=`) of a network call — deliberately excluded from `ENV_EXFIL_FLOW` above (the normal
  way a skill authenticates to its own API), so it is never even computed by the static
  engine; a dedicated AST walk surfaces it to the judge packet instead
  (`ENV_AUTH_KWARG_EXFIL`, B-190) `[JUDGE: ENV_AUTH_KWARG_EXFIL]`
- **Windows ACL** equivalents of POSIX permission checks — no stdlib / no-subprocess way to
  read NTFS ACLs under this project's laws (§2). `UNKNOWN` is the honest answer; the message
  points to `icacls` `[CEILING]`
- Credential **lifetime / rotation** signal (long-lived static secrets vs short-lived scoped
  ones) — `auth.profiles` ships no documented expiry/TTL/rotation sub-field, and no
  statically-recognizable long-lived-vs-short-lived token shape exists without decoding
  secret material, which this tool never does. Covered today only indirectly via B1
  (at-rest secrets), C015 (home scan), B41 (blast-radius/reachability); re-ground if OpenClaw
  ever exposes a real expiry field `[CEILING]`
- Deep dirty-input content normalization (bidi/zero-width stripping, hidden-text channel
  neutralization beyond what B26/B21/B58 already flag as a *finding*) — OpenClaw exposes no
  sanitizer config field to check; this is a filtering *capability* gap, not a
  detection one, **for the invisible characters the class already covers** — the two
  narrower classes and the Tier-2 code points still outside it are declared separately
  below `[CEILING]`
- **AST10 — Cross-Platform Reuse** (OWASP Agentic Skills Top 10) — ClawSecCheck audits a
  single install; cross-platform reuse hazards need a multi-deployment view that is out of
  scope for this tool `[CEILING]`
- **MCP package-reputation verdicts** (C-211) — a majority (105/166, 63%) of malicious
  `mcp_tool` samples in the OASB registry corpus are labeled solely on the PUBLISHED
  PACKAGE's own reputation/scan history (an external registry scanner's `criticalCount`/
  `scanVerdict`), with a `command`/`args`/`allowedTools` config shape byte-for-byte
  identical to benign samples of the same shape — the actual malicious behavior lives
  inside the referenced npm/PyPI package's own code, which ClawSecCheck never fetches or
  executes (Golden Rule #1: local-only, no network calls). No static config-level signal
  can discriminate these two populations; this is a hard ceiling, not a missed pattern.
  Covered today only indirectly via B95/B157 (dependency-confusion / non-registry-source
  heuristics — a different surface, skill deps rather than MCP server packages); a real
  fix needs a live package-registry reputation lookup, out of scope by design `[CEILING]`
- **Cross-skill capability-union without an untrusted-input leg** (C-219, arXiv 2606.00448
  "When Safe Skills Collide") — two individually-safe installed skills whose capabilities
  union into a dangerous combination (e.g. skill A can read credentials, skill B has
  network egress; neither alone is flagged). The paper's own measurement on 1,520 real
  ClawHub skills: 47,075 individually-safe PAIRS satisfy a forbidden composition pattern,
  but only ~18.2% of those survive human adjudication as genuinely risky — bare
  capability-*presence*-union is ~80% false-positive against human judgment. The
  discriminating factor the paper's own numbers point to is exactly RISK-02's third leg
  (untrusted ingress) — cred-read + network-egress with NO ingress vector is the normal
  shape of an authenticated integration (a skill reading its own API token and calling that
  API), not an attack. Dropping the ingress requirement to catch the paper's broader 2-leg
  pattern would flood real fleets, since credential access co-located with network egress
  is the authenticated-integration norm — which is exactly why B62 treats `cred` as an
  *expected* capability for network/auth-category skills (C-239) rather than a surprise, and
  its cred matcher (`_b62_actual_families`) now fires only on credential-shaped
  `os.getenv`/`os.environ[` reads, not on every env-var access. Covered today via
  B105 (same cred+network legs co-located in ONE skill, not unioned across a pair, plus a
  secrecy-framing corroborator), RISK-19 (audit-themed skill + a separate high-blast skill,
  a different discriminator), and B62 (per-skill capability/intent mismatch). A narrower
  variant — cred+network legs supplied by CODE in two DIFFERENT skills, gated on a shared
  file-path handoff between them (the only static proxy for "these two skills' capabilities
  are plausibly composable," analogous to RISK-11's delegation "wall") — was designed but
  not built: its precision depends entirely on that gate, whose recall against real skills
  is unproven, so it is tracked as a separate gated spike rather than shipped speculatively.
  `--behavioral` is not a better home either: T1 already is the trifecta-by-trajectory-log
  (`check_behavioral_trifecta`), a C-170 finding already had to NARROW its sensitive-verb
  hints because the naive 2-of-3 form false-fired on mundane sequences, and the trajectory
  schema (§8) exposes only verb name/order/timestamp — never `arguments`/`output` — so the
  cross-skill *content-correlated* handoff this signal would need is unobservable from the
  log at all `[CEILING]`
- **SKILL.md front-loads a mandatory-first-step pointing at a bundled helper** (C-220,
  arXiv 2602.14211 "SkillJect") — the effective delivery mechanism is two-channel: the
  payload lives in a bundled `.py`/`.sh`/`.js` helper, and SKILL.md is front-loaded with a
  "mandatory prerequisite" / "run this first" inducement to get the AGENT to execute it
  (0.0% attack success with the payload in prose alone vs 80.7% with this framing).
  Constructed the shape directly and ran it against the content ring: B100 (ClickFix)
  structurally cannot see it — its remote-fetch regex has no concept of a LOCAL file
  reference; B63 (silent-instruction) requires secrecy framing ("don't tell the user"),
  the opposite of SkillJect's loud "you MUST run this" framing; B64 (instruction-hierarchy
  override) requires overriding/disregarding an instruction, not prepending a step — none
  of the three fire. The gap is real. A correlation-gated design (only flag when the
  referenced helper ALSO independently trips skillast's own AST checks) does not rescue
  it: verified directly that it is either redundant (a genuinely malicious helper already
  fires B13 regardless of SKILL.md framing) or blind (a subtle helper that doesn't trip
  the AST also doesn't trip the correlation) — and escalating an existing WARN to FAIL on
  the strength of the framing alone false-fires on a real fixture
  (`fixtures/benign_b13_disclosed_cred_sink/skills/wechat-push`, a legitimate
  disclosed-credential integration that already WARNs at B13 and carries the identical
  "mandatory setup" framing). Position/heading is not a discriminator either — front-loaded
  `## Prerequisites`/`## Setup` sections are the standard documentation convention, present
  in this repo's own real installed skill and multiple `clean_*` fixtures. Covered today via
  B13/skillast (a genuinely malicious helper fires regardless of SKILL.md framing) and B100
  (the narrower remote-fetch-under-a-setup-heading variant) `[CEILING]`
- **Expression evaluation in a config data field** (C-328, transferability check on the
  HuggingFace agent-intrusion postmortem, where an fsspec reference-spec numeric-offset field
  carried a Jinja2 `{{ cycler.__init__.__globals__.__builtins__.exec(...) }}` payload that the
  config-driven loader evaluated) — grepped the installed dist (grounded against dist
  openclaw@2026.7.1-2) for every `{{ }}`/`${}` template consumer and every `eval`/
  `new Function`/`vm.Script` sink reachable from config parsing. Every config-facing template
  surface found is substitution-only, never expression evaluation: `hooks.mappings[].
  messageTemplate`/`textTemplate`/`sessionKey` (`dist/hooks-Bjrm8pWp.js`, `renderTemplate`/
  `resolveTemplateExpr`) resolve only a fixed literal-prefix whitelist (`path`, `now`,
  `headers.`, `query.`, `payload.`) through a dot-path `getByPath` walker that blocks
  `__proto__`/`prototype`/`constructor` — no arithmetic, no method calls, no arbitrary JS;
  `${VAR_NAME}` env-var interpolation (`dist/env-substitution-CATXLg7n.js`, `substituteString`/
  `resolveConfigEnvVars`) is a character-scan literal lookup against `process.env`; the
  Discord/status presence text fields (`dist/provider-DNXfDOia.js`, a second, unrelated
  `renderTemplate`) substitute only one fixed `{reason}` placeholder. The two real `vm.Script`/
  `eval` sinks found in the dist — `runModelCode` (`dist/tool-search-B8B4Spes.js`, the
  `tool_search` agent-tool feature, `src/agents/local-model-lean.ts`) and the Playwright
  browser `evaluate` action (`dist/pw-ai-BHPU7V7C.js`) — both run code the AGENT supplies as an
  explicit tool-call argument at runtime, never a value read from a static config field, so
  they are a different, already-in-scope surface (agent tool-use), not this bug class. Secret
  references (`dist/resolve-configured-secret-input-string-BefspyML.js`) are a typed
  `{source,provider,id}` dispatch, not expression parsing; the Shiki syntax-highlighting
  grammars bundled under names like `jinja`/`handlebars` are dead JSON grammar data for code-
  block rendering, not live engines. The bug class the incident describes — a plain config
  VALUE silently passed through a general-purpose expression evaluator — has no reachable
  instance in OpenClaw today; a check here would be exactly the fabricated-risk Golden Rule #4
  forbids. Re-derive against a newer dist if OpenClaw ever adds a real expression evaluator to
  config parsing `[CEILING]`
- **Hidden channel built from an invisible character outside the detected class** —
  `textnorm.obfuscation_signals` raises its "zero-width / invisible characters found" signal
  for **twenty** code points (U+00AD soft hyphen, U+180E, U+200B–200D, U+2060–2064,
  U+206A–206F, U+FEFF, U+FFF9–FFFB), and `normalize_for_scan` strips that same set plus the
  nine bidi controls — **29** in all. Both halves derive from one source, so the detector
  cannot again report a channel the normalizer is unable to read through. The separate
  Tag-block detector (U+E0000–U+E007F) covers that family independently. What remains is
  two named, deliberate residuals rather than one open-ended gap:
  **(1) Tier-2 code points, still outside the class** — variation selectors U+FE00–FE0F,
  U+2800 BRAILLE PATTERN BLANK, and U+3164 / U+FFA0 HANGUL FILLER. Each is legitimate and
  common in its own context (U+FE0F alone is what gives a base glyph emoji presentation), so
  a bare presence class would false-fire on ordinary emoji, Korean and Braille text across
  every consumer at once. The sound direction is per-character excusal by surrounding script
  context — the shape `_is_zwj_between_emoji` already uses — not a wider class.
  **(2) Two FAIL-capable paths held deliberately narrower than the shared class** — the MCP
  tool-**NAME** homoglyph leg (`_B332_ZERO_WIDTH_RE`, six code points) and the two
  token-level confusable/NFKC signals (`_INVISIBLE_TOKEN_RE`, fifteen). Widening either
  trades a false negative for a false positive on legitimate text — Japanese ruby annotation
  and Mongolian orthography for the first, mixed-script token joining for the second — and a
  false FAIL is decisive whatever the detection gain. Each is recorded in-source next to its
  own class with the measurement that settled it. Reopening either needs a discriminator for
  the benign population, not more code points `[CEILING]`

## Framework mapping (OWASP)

ClawSecCheck audits the **agent**, so it maps the OWASP categories onto the agent surface
(not app code). The machine-readable mapping is `catalog.OWASP_MAP` / `owasp_for(id)` and is
surfaced per finding in `--json` (`"owasp": [...]`); this table is its human view. Only clear
fits are tagged — checks with no clean LLM-Top-10 analog are covered by the agent-specific
OWASP Agentic (ASI) classes below, not stretched into a category they don't fit.

### OWASP Top 10 for LLM Applications (2025)

| Code | Category | ClawSecCheck checks |
|---|---|---|
| LLM01 | Prompt Injection | A1, B2, B6, B21, B23, B26, B30, B48, B56, B58, B59, B60, B61, B64, B67, B74, B88, B140, B170, B180, B185, C074 |
| LLM02 | Sensitive Information Disclosure | B1, B9, B11, B12, B14, B19, B39, B41, B59, B61, B67, B82, B83, B170, B182, B193, C014, C015 |
| LLM03 | Supply Chain | B5, B13, B15, B24, B25, B33, B42, B57, B103, B135, B151, B152, B174, B177, B181, B182, B184, B186, B187, B194, C4, C5, C047 |
| LLM04 | Data and Model Poisoning | B7, B20, B22, B55, B175, B180 |
| LLM05 | Improper Output Handling | B21, B47, B187 |
| LLM06 | Excessive Agency | A1, B3, B4, B8, B17, B18, B22, B23, B31, B32, B41, B43, B44, B45, B46, B47, B48, B55, B57, B62, B63, B65, B66, B68, B69, B71, B72, B76, B79, B81, B84, B105, B136, B138, B150, B175, B176, T1, T3 |
| LLM07 | System Prompt Leakage | B9 |
| LLM08 | Vector and Embedding Weaknesses | — (no agent-config surface; RAG/embedding concern) |
| LLM09 | Misinformation | — (model output / overreliance; out of scope) |
| LLM10 | Unbounded Consumption | B17, B80, B150 |

LLM08/LLM09 are honest non-coverage: they live in the model/RAG layer, not the agent config
ClawSecCheck reads. **Excessive Agency (LLM06)** is where the tool is densest — the whole
multi-agent privilege-separation arc (B45/B46/B47) lands here, exactly the agent-specific
surface a web/code reviewer never sees.

### OWASP Agentic Skills Top 10 (2026)

> **Status:** candidate / active development.
> **Source:** <https://owasp.org/www-project-agentic-skills-top-10> (v1.0 2026).

The machine-readable mapping is `catalog.AST_MAP` / `ast_for(id)` and is surfaced per
finding in `--json` (`"ast": [...]`).

| AST code | Category | ClawSecCheck checks |
|---|---|---|
| AST01 | Malicious Skills | B13, B60, B63, B65, B89, B90, B91, B100, B102, B336, B338, B339, C048 |
| AST02 | Supply Chain Compromise | B5, B13, B15, B24, B25, B42, B57, B82, B86, B92, B94, B95, B99, B100, B103, B135, B151, B152, B174, B177, B181, B182, B184, B186, B187, B193, B194, C5, C047 |
| AST03 | Over-Privileged Skills | B3, B8, B17, B18, B22, B23, B31, B32, B41, B43, B44, B45, B46, B47, B48, B55, B57, B68, B69, B71, B72, B75, B76, B79, B81, B84, B138, B150, B175, B176 |
| AST04 | Insecure Metadata | B6, B44, B62, B84, B88, B93, B96, B98, B185, T3 |
| AST05 | Untrusted External Instructions | B6, B7, B20, B21, B23, B26, B30, B58, B59, B60, B61, B63, B64, B65, B66, B67, B74, B105, B140, B170, B180, B185, C074, T1 |
| AST06 | Weak Isolation | B4, B22, B38, B39, B48, B70, B73, B80, B83, B87, B136, B175, B195, B196, B330, B340, C032 |
| AST07 | Update Drift | B25, B33, C4, C6 |
| AST08 | Poor Scanning | B16 |
| AST09 | No Governance | B10, B16, B50, B51, B52, B53, B54, B77, B78, B85, B97, B101 |
| AST10 | Cross-Platform Reuse | — (documented coverage gap: single-install scope) |

**Coverage notes:**

- **AST10** (Cross-Platform Reuse) has no catalog check — ClawSecCheck audits a single
  install; cross-platform reuse hazards require a multi-deployment view that is out of
  scope for this tool.
- **B7** (Memory poisoning) and **B57** (Plugin auto-approve) placements diverge
  intentionally from a naive surface mapping: B7 maps to AST05 (external instructions
  reaching memory), not AST04; B57 maps to AST02/AST03 (supply-chain + privilege),
  reflecting its dual exposure.
- **AST01/B13's Python AST analysis is bounded by the interpreter running the audit.**
  `ast.parse()` (`skillast.py`) uses the running interpreter's own grammar, with no
  `feature_version` pin. A skill wrapping an obfuscated call in syntax newer than the
  running Python (e.g. `match`/`case`, Python 3.10+) fails to parse on an older audit
  interpreter and degrades to UNKNOWN there, though it is correctly caught as FAIL on
  3.10+. This is a deliberate trade-off, not an oversight: pinning to the package's
  minimum (Python 3.9+, per `pyproject.toml`) would make detection uniformly worse —
  every interpreter, including 3.10+, would then reject that syntax too, since
  `feature_version` can only *restrict* a parser to an older grammar, never grant an
  older interpreter the ability to parse newer syntax it structurally lacks. Run the
  audit on the newest available Python for maximum AST-based detection coverage.

### OWASP Agentic Security Initiative (ASI) — by threat name

The original ASI taxonomy (pre-AST-2026 numbering) mapped by threat *name*; retained for
historical continuity. The numbered AST-2026 table above supersedes this for new integrations.

| ASI threat class | ClawSecCheck checks |
|---|---|
| Goal hijacking / prompt injection | A1, B6, B21, B23, B26; the taint/provenance leg (reserved as B28, never implemented as its own id) is closed via attestation only — see the dirty-input taint/provenance row under Non-static coverage |
| Tool misuse (unsafe delegation / parameter injection) | B3, B18, B31, B45, B46, B47 |
| Identity & privilege abuse (multi-agent delegation chains) | B30, B45, B46, B47 |
| Runtime supply chain (dynamic tool/plugin composition) | B5, B13, B25, B33, B42 |
| Unexpected RCE (sandboxing failures) | B4, B48, C5 |
| Memory & context poisoning | B7, B20, B180; the taint/provenance leg (reserved as B28, never implemented as its own id) is closed via attestation only — see the dirty-input taint/provenance row under Non-static coverage |
| Insecure inter-agent communication | B47, B2, B32 |
| Cascading failures / blast-radius amplification | B41, B43, B45, B46, B47 |
| Human-agent trust / decision-fatigue | B8, B18, B23 |
| Rogue agent misalignment | B17, B22 (partial) |

**Sources (grounded):** OWASP Top 10 for LLM Applications 2025
(<https://genai.owasp.org/llm-top-10/>); OWASP Agentic Skills Top 10 2026
(<https://owasp.org/www-project-agentic-skills-top-10>); OWASP Agentic Security Initiative
(<https://genai.owasp.org/initiatives/agentic-security-initiative/>) and Agentic AI — Threats
and Mitigations (<https://genai.owasp.org/resource/agentic-ai-threats-and-mitigations/>).

## TAM-01..12 weaponization test matrix (standard §15.3)

Named regression, not accidental coverage — `tests/test_tam_matrix.py` pins each row so a
refactor that silently regresses one leg turns red here. This matrix exercises the
`--monitor` drift subsystem (its own internal finding codes, e.g. `F-079`) rather than the
`CheckMeta` catalog above, so it sits outside the `[CHECK]`/`[ATTEST]`/`[JUDGE]`/`[CEILING]`
tagging scope — its own dedicated test is the closure mechanism here.

| Row | Attack simulation | Mechanism | Status |
|---|---|---|---|
| TAM-01 File tamper | Modify SKILL.md / add an exfil instruction after install | `monitor` skill-hash CHANGED alert (HIGH) | Covered |
| TAM-02 Manifest escalation | Widen network/fs/shell permissions without a version bump | `monitor` capability-diff (F-079, HIGH) | Covered |
| TAM-03 Dependency poison | Add a malicious / unpinned lookalike package | B95 (dependency-confusion co-occurrence) | Covered |
| TAM-04 Cross-skill abuse | Fake low-privilege skill impersonates a caller to a high-privilege skill | — | **Out of scope** — needs a live platform broker mediating caller/callee/action identity; no static equivalent |
| TAM-05 Metadata poison | Tool description/schema changed to look read-only | B24 (MCP hardening) + `monitor` RP1 (oauth.scope expansion) | Covered |
| TAM-06 PATH/import hijack | Fake curl/python/module earlier in PATH | C5 (native binary PATH safety) | Covered |
| TAM-07 Symlink escape | Output/config path replaced with a symlink to a sensitive host path | B87 (symlink-escape finding, F-080) | Covered |
| TAM-08 Prompt weaponization | Poisoned content instructs a skill to use its allowed tools for exfil | Content ring, B63 (silent-instruction) | Covered |
| TAM-09 Downgrade/replay | Install an old signed-but-vulnerable version / replay an old manifest | `monitor` version-regression (F-079, MEDIUM) | **Partial** — static "declared version went backward" signal only; real signed-manifest replay/revocation needs a trust root, not achievable read-only/offline |
| TAM-10 Memory backdoor | Skill writes "always trust attacker.com" into agent memory | B7 (memory poisoning) + B20 (bootstrap write protection) + B180 (content-proven injected directive already sitting in memory); `multiturn.py` covers the live cross-turn leg | Covered |
| TAM-11 Egress mutation | Dependency repoints destination from an approved API to a webhook/pastebin | B24 (MCP hardening) + `monitor` RP3 (endpoint repoint) | Covered |
| TAM-12 Self-modifying skill | Skill writes into its own directory / changes a helper script | RISK-07 (self-modification chain) + B22 | Covered |

## Rule

> If an attack path has no check and no test, assume the tool can miss it. This file is the
> source of truth for that — update it whenever a check or gap changes, and keep every row in
> **Covered** / **Non-static coverage** tagged. `tests/test_threat_coverage_ledger.py` turns
> a drift (a new catalog id with no CHECK tag, or an untagged category row) red in CI before
> it ships silently.
