# ClawSecCheck — Output Schema (Public API Contract)

This document is the frozen public API contract for the machine-readable outputs
produced by `--json` and `--sarif`. Integrators (CI pipelines, dashboards, SIEM
connectors) may rely on the field names, types, and envelope shapes described here.

**Contract baseline:** v2.0.0 (2026-06-28). Breaking changes vs v1.x:

- `--lang` / `--lang he` CLI flag removed; output is English-only.
- No `lang` field in any JSON or SARIF output.

**Stability rule:** top-level field names and envelope shapes are frozen. New
**optional** top-level fields may be added in any minor release. A top-level field
will not be removed or renamed without a major version bump (see `CHANGELOG.md` and
versioning §6 in `CLAUDE.md`). §17 states exactly what "frozen" covers — read it
before assuming a nested key is part of the contract; notably, the subject keys inside
`inventory` (§18) track the check taxonomy and are **not** frozen.

---

## 1. `--json` — Full Audit Output

### Top-level envelope

| Field | Type | Always present | Description |
|---|---|---|---|
| `score` | `int` | yes | Overall security score, 0–100. |
| `grade` | `str` | yes | Letter grade: `"A"`, `"B"`, `"C"`, `"D"`, or `"F"`. |
| `capped` | `bool` | yes | `true` if the score was capped below `raw_score` (e.g. Lethal Trifecta triggered). |
| `raw_score` | `int` | yes | Score before any cap is applied. Equals `score` when `capped` is `false`. |
| `cap_severity` | `str \| null` | yes | Severity that drove the score cap (`"CRITICAL"`, `"HIGH"`, …), or `null` when no *scored* FAIL capped the score. `null` alongside `capped: true` means a runtime signal (see `runtime_capped`) drove the cap instead, not a scored FAIL. |
| `runtime_capped` | `bool` | yes | `true` when a corroborated *runtime* signal — never a config-static finding — capped the score (I-025). The one eligible signal is a trajaudit-style skill/bootstrap indicator match (`--analyze-trajectory`). It never earns or costs an ordinary scored point — this is a hard cap only, applied after any severity-driven cap above. `B83`, `B84`, `B85`, `B164` and `B180` can never move the grade any other way, and this stays `false` for all of them. (`B164`'s `exfil_evidence` class was briefly cap-eligible on its same-line arm under an earlier ruling; retracted after four independent adversarial reviews found no sound host/verb gate exists for this tool's own audience — `exfil_evidence` is WARN-only, permanently, same-line or cross-line.) F-154: the `--behavioral`-only `T1`/`T2`/`T3` (plus `B191`) can also never set THIS field `true` — but they gained a SEPARATE cap channel of their own, see `behavioral_capped` below. Same "`true` alongside `capped: false`" nuance as `config_blind_capped` applies when nothing else was scorable this run either — see that row. |
| `runtime_cap_reason` | `str \| null` | yes | Stable label for the eligible runtime signal that fired, e.g. `"trajaudit indicator match"`. `null` when `runtime_capped` is `false`. |
| `config_blind_capped` | `bool` | yes | `true` when `openclaw.json` could not actually be read this run — either present but unparseable/unreadable (see `config_parse_error` below, B-306) or wholly ABSENT (`config_found: false`, B-363) — and that alone hard-capped the score at the same ceiling a proven CRITICAL FAIL gets. Without this cap, a config-derived check correctly degrading FAIL/WARN to UNKNOWN (because it could no longer read the config) could otherwise let the grade rise even though the audit saw strictly less evidence, not more — and an absent config is strictly less evidence than an unreadable one, so it must never score better. Composes with `cap_severity`/`runtime_capped` — whichever cap is tightest wins; this one takes reporting priority when it is the binding one. **Can be `true` alongside `capped: false`**: when nothing else was scorable this run either (`score`/`raw_score` both `0`), there is nothing for the cap to numerically reduce, but a blind config is still real signal — B-306's follow-up fix (C-135, 2026-07-21) forces `grade: "F"`/`assessable: true` here instead of silently falling back to the neutral `"N/A"` this combination used to produce. |
| `config_blind_reason` | `str \| null` | yes | Which config-blind state drove `config_blind_capped`: `"unreadable"` (present but unparseable) or `"absent"` (no config found at all), or `null` when `config_blind_capped` is `false` (B-363). |
| `assessable` | `bool` | yes | `false` for the distinct "N/A / nothing scorable" state (empty / all-UNKNOWN / all-advisory config, and neither `config_blind_capped` nor `runtime_capped` fired) — lets a consumer tell a real `F` apart from a not-assessable `"N/A"` config. `true` for every normal audit, **and also** when nothing else was scorable but `config_parse_error`/a corroborated runtime signal fired: B-306 forces a real `grade: "F"` (`score: 0`) in that case rather than falling back to the neutral `"N/A"` — a blind config or corroborated runtime evidence is real, alarming signal, never "nothing known". |
| `trifecta` | `str` | yes | Lethal Trifecta sub-score expressed as `"<n>/3"` (e.g. `"2/3"`). `"?/3"` means check A1 did not run. |
| `findings` | `array[Finding]` | yes | All check results. See §2. |
| `next_actions` | `array[NextAction]` | yes | Prioritised remediation suggestions. See §3. |
| `risk_paths` | `array[RiskPath]` | yes | Combinational attack chains. See §4. May be an empty array. |
| `capability_graph` | `object` | yes | Static capability map of the inspected agent. See §5. |
| `secret_reachability` | `array[SecretClass]` | yes | Per-class secret-exposure analysis. See §6. |
| `intentAttestationRequests` | `array[SAR]` | yes | Structured Attestation Requests for B62 capability-intent mismatches. See §7. |
| `coverage` | `object` | yes | Surface/family coverage map for the Dashboard. See §8. |
| `projection` | `object` | yes | What-if score projections for the Dashboard. See §9. |
| `config_found` | `bool` | yes | `true` when an `openclaw.json` was present at the scanned home (vs a non-OpenClaw setup). |
| `audited_config_path` | `string \| null` | yes | Absolute path of the config file this run actually read — every finding in the payload describes this file and only this file. May be a legacy `clawdbot.json`, which OpenClaw's resolver prefers when it exists. When `config_found` is `false` this still names the canonical path that was looked for. Compare it against check `B183`, which reports whether OpenClaw's own resolver (`OPENCLAW_CONFIG_PATH` / `OPENCLAW_HOME` / `OPENCLAW_STATE_DIR`) selects a different file. `null` only when no context was supplied to the renderer. |
| `config_parse_error` | `bool` | yes | `true` when `openclaw.json` was present but could not be parsed into a config object (syntax error, size-cap truncation, or a non-object top level). A gating consumer should treat `true` as "scan incomplete", not a clean result — the run is UNKNOWN-heavy. A valid empty `{}` config is `false`. |
| `config_symlink_escapes_home` | `bool` | yes | `true` when `openclaw.json` is a symlink whose target leaves its config directory AND that target is a readable regular file owned by the auditing user — a benign dotfiles layout (stow/chezmoi/yadm/bare-git). The collector follows it and audits the real bytes, so this is NOT a blind config: `config_parse_error` stays `false` and the run is never `config_blind_capped` for this reason. Lets a consumer distinguish a safely-relocated config from a genuinely dark one. `false` on every normal (non-symlinked, or in-directory-symlinked) run. |
| `degraded_capped` | `bool` | yes | `true` when a check that could not reach a reliable verdict this run alone hard-capped the score at the same ceiling a proven CRITICAL FAIL gets. Two causes compose here: a check the run_all wrapper had to crash/timeout out of (`Finding.id` prefixed `"ERR:"`, B-313), or a check that ran to completion but honestly reported its own UNKNOWN as engine-side — an input it expected to read that turned out unreadable/corrupt/malformed, or a scan-budget escape internal to its own logic (`Finding.engine_degraded == true`, B-399). Neither counts a check whose UNKNOWN means "there was simply nothing to check" (a genuinely absent config/feature) — that case leaves this field untouched. Same shape as `config_blind_capped` but at check-granularity instead of config-granularity: a degraded check's own would-be verdict is unknowable, so the sound worst-case assumption is "cannot rule out a CRITICAL". Composes with `cap_severity`/`runtime_capped`/`config_blind_capped` — whichever cap is tightest wins; only `true` when THIS cap was the one that actually lowered the score below what the other caps already produced. |
| `degraded_count` | `int` | yes | How many checks could not reach a reliable verdict this run — crashed, timed out, or hit an engine-side-degraded UNKNOWN (see `degraded_capped`) — `0` when none did. Unconditional, independent of whether `degraded_capped` ended up strictly binding. A consumer should treat any nonzero value as "this grade is incomplete", even when a tighter cap already explains the number on screen. |
| `live_injection_capped` | `bool` | yes | F-155: `true` when a submitted VULNERABLE verdict from a live injection-test harness (`--canary`/`--dryrun`/`--redteam`/`--multiturn`) alone hard-capped the score at the same ceiling a proven CRITICAL FAIL gets — see `--full --judged-bundle`'s `liveTest` bucket (§12). Self-attestation guard: the verdict is produced by the agent UNDER TEST, so a RESISTANT verdict or no submission at all leaves this `false` — never an ordinary scored point, never a reason to raise anything. Composes with `cap_severity`/`runtime_capped`/`config_blind_capped`/`degraded_capped` — whichever cap is tightest wins; only `true` when THIS cap was the one that actually lowered the score below what the other caps already produced. |
| `live_injection_cap_reason` | `str \| null` | yes | Stable label naming which harness/scenario(s) drove `live_injection_capped`, e.g. `"redteam:PI-01"` — built only from allow-listed tool names and validated scenario ids, never free text from the submission. `null` when `live_injection_capped` is `false`. |
| `behavioral_capped` | `bool` | yes | F-154: `true` when a fired T1 (behavioral trifecta), T2 (outcome anomaly), T3 (capability drift) or B191 (audit-trail divergence) detector alone hard-capped the score — but ONLY when `--full` ran WITHOUT `--fast` (a plain `clawseccheck` audit, `--full --fast`, and a standalone `--behavioral` run all never set this — `--behavioral` on its own renders its own report and never touches `score` at all; the analysis is deliberately not run automatically for performance/privacy reasons — see `BEHAVIORAL_SIGNAL_CAP`). All four detectors cap at the same MEDIUM-FAIL ceiling — T1's original tighter HIGH ceiling was retracted (B-416): its ingress/sensitive/egress legs are classified by VERB NAME ONLY, no argument/value linkage, so an entirely benign, causally-unrelated tool sequence (e.g. "look something up, then use my own stored credentials, then send a report") satisfies the identical shape a genuine attack chain would, and hard-capping that at the tighter ceiling produced an undiagnosable false positive with no actionable remediation. None of the four ever earns or costs an ordinary scored point (Golden Rule #5) — this is a hard cap only, applied after every other cap above. Composes with `cap_severity`/`runtime_capped`/`config_blind_capped`/`degraded_capped`/`live_injection_capped` — whichever cap is tightest wins; only `true` when THIS cap was the one that actually lowered the score below what the other caps already produced. |
| `behavioral_cap_reason` | `str \| null` | yes | Stable label naming which behavioral detector(s) drove `behavioral_capped`, e.g. `"T1 behavioral trifecta"` (joined with `"; "` when more than one fired) — never free text. `null` when `behavioral_capped` is `false`. |
| `config_parse_reason` | `string \| null` | yes | Short diagnostic for why `config_parse_error` is `true` (the raw loader message), OR a note that a dotfiles-style symlink was safely followed when `config_symlink_escapes_home` is `true`. `null` when the config parsed cleanly with no relocation. Never contains a secret or file-content value. |
| `errors` | `array[str]` | yes | Human-readable collection/parse messages (e.g. the `openclaw.json` parse error). Empty array on a clean run. |
| `inventory` | `object` | yes | Owner-facing "Inventory by subject" regrouping (OpenClaw core/Host machine/Agents/Skills/MCP/Plugins/Channels/Logs & trajectories) of the SAME `findings` above. Purely additive/presentation — never affects `score`/`grade`. See §18. |
| `skill_sweep` | `object` | only with `--full` | Per-skill vet verdict for every installed skill (the second engine, on top of the audit) — the machine-readable form of `--full`'s printed SKILL SWEEP section. Absent (key not present) on a plain `--json` run without `--full`. Visibility only — never affects `score`/`grade`. See §19. |
| `scan_receipt` | `str` | yes | Deterministic content-integrity hash over all findings, formatted `"sha256:<64-hex-chars>"`. Same findings set (any order) always yields the same receipt; a changed finding set changes it. Not a security signature — a drift/tamper-evidence checksum for the scan output itself. |
| `phases` | `array[object]` | only with `--full` | One entry per `--full` pipeline phase (skill sweep, plugin sweep, behavioral, adjudication), in run order. Each entry: `name` (`str`), `status` (`"ran"`/`"skipped"`/`"not_reached"`/`"unavailable"`/`"error"`), `elapsed_s` (`float`, wall-clock — the one non-deterministic value in the payload), `complete` (`bool`, false when the phase could not account for everything it is responsible for), `detail` (`str`, one plain-English sentence), `notScanned` (`array[str]`, every target this phase cannot vouch for). Absent on a plain `--json` run without `--full`. |
| `complete` | `bool` | only with `--full` | `true` only when every `--full` phase ran and accounted for everything it covers — `false` if any phase was skipped, not reached (pipeline budget exhausted), or errored. Does not affect `score`/`grade`; a truncated `--full` run is reported here and in `notScanned`, never by silently reddening the exit code. Absent without `--full`. |
| `notScanned` | `array[str]` | only with `--full` | Every target across all `--full` phases that no phase could vouch for, named individually (the union of each phase's own `notScanned`). Absent without `--full`. |
| `judgePacket` | `array[JudgePacketItem]` | only with `--full` | The same adjudication packet the standalone `--judge-packet` flag produces (see §12) — folded into `--full --json` as the P9 adjudication phase's output instead of requiring a separate invocation. May be an empty array. Absent without `--full`. |
| `vetPackets` | `array[object]` | only with `--full` | One judge packet per `--vet` target passed alongside `--full` (empty array when none were), each shaped `{"target": str, "targetFingerprint": str, "judgePacket": array[JudgePacketItem]}` — same item shape as `judgePacket` above, scoped per target. Absent without `--full`. |
| `attestTemplate` | `object` | only with `--full` | Pre-run attestation template — the same structure produced standalone by the attestation self-report path (see `attest.py`), included here so a `--full` consumer does not need a second invocation to get it. Absent without `--full`. |
| `pluginSweep` | `object` | only with `--full` | Per-plugin vet verdict for every installed plugin (P7), the machine-readable form of `--full`'s printed PLUGIN SWEEP section — same shape as `skill_sweep` above: `no_roots` (`bool`, the installed-plugin index itself could not be read), `no_targets` (`bool`, the index was read but names zero plugins), `complete` (`bool`), `counts` (`object`: `total`/`fails`/`warns`/`safe`/`truncated`/`skipped`), `not_scanned` (`array[str]`). Absent (key not present) when the phase did not run — e.g. `--full --fast`, or a build with no plugin-sweep implementation. Visibility only — never affects `score`/`grade`. |
| `coveragePage` | `object` | only with `--full` | Per-subject (8-subject taxonomy) scanned-vs-total, every gap named rather than merely counted — a different question from `inventory`'s "what did we find". See §20. |
| `secondOpinion` | `array[object]` | only with `--full --judged-bundle <file>` | One row per borderline-band item, annotated with a submitted judge verdict when the bundle supplied one: `finding_id` (`str`), `target` (`str`), `engine_disposition` (`str`), `judge_verdict` (`str` or `null` — unreviewed items still appear), `annotation` (`str`, human-readable). Advisory only — annotates an existing finding, never alters `score`/`grade`/`findings`. Absent unless a judged bundle was supplied. |
| `vetSecondOpinion` | `array[object]` | only with `--full --judged-bundle <file>` carrying a non-empty `vetJudged` array | F-152: the escalate-only counterpart to `secondOpinion` above, for the bundle's SEPARATE `vetJudged` bucket (untrusted content swept by the skill/plugin sweeps) rather than the user's own config. One row per vet-target finding that was actually ESCALATED (rows with no status change are omitted — an empty array means "verdicts were submitted, nothing escalated", not "nothing was submitted"): `finding_id` (`str`), `target` (`str`, the swept target's bare name), `engine_disposition` (`str`, the pre-escalation status), `judge_verdict` (`str`, the POST-escalation status — never a field named `verdict`, to avoid implying it echoes the submitted `SAFE`/`SUSPICIOUS`/`DANGEROUS` value verbatim), `annotation` (`str`, human-readable). Escalate-only and per-target-fingerprint-bound, exactly like the standalone `--vet-judged` path (§15) this reuses: a row's underlying finding can only ever rank higher than the deterministic engine already ranked it, never lower, and a `vetJudged` entry is matched to a target ONLY by that target's own `targetFingerprint` (C-135) — an entry whose fingerprint matches no currently swept target is dropped wholesale, never applied to a different target as a fallback. Never alters `score`/`grade`/the top-level `findings` array — those describe the user's OWN config, which a vet target's own escalated pool never touches. Absent unless the bundle's `vetJudged` array was non-empty. **Includes the three always-offered C-255 pre-install prose-attestation ids** (`ATTEST-PROSE-MISMATCH`/`ATTEST-PROSE-INJECTION`/`ATTEST-PROSE-SOCIAL-ENG`) when a SUSPICIOUS/DANGEROUS verdict creates a brand-new finding for one — `engine_disposition` reads `"UNKNOWN"` for that row (there was no pre-existing finding at all, matching the `engine_disposition: "UNKNOWN"` the judge packet item itself already carried — see §12's `redacted_evidence` note for these ids) and `judge_verdict` reads `"WARN"` (the safety ceiling these three ids are capped at — never `"FAIL"`, since they carry zero independent deterministic signal). The join binding a packet item to its row is by `finding_id`, not position, precisely so this always-offered, no-prior-finding case is never structurally excluded. |

### Skeleton

```json
{
  "score": 74,
  "grade": "C",
  "capped": false,
  "raw_score": 74,
  "cap_severity": null,
  "runtime_capped": false,
  "runtime_cap_reason": null,
  "config_blind_capped": false,
  "config_blind_reason": null,
  "degraded_capped": false,
  "degraded_count": 0,
  "live_injection_capped": false,
  "live_injection_cap_reason": null,
  "behavioral_capped": false,
  "behavioral_cap_reason": null,
  "assessable": true,
  "trifecta": "1/3",
  "findings": [ ... ],
  "next_actions": [ ... ],
  "risk_paths": [ ... ],
  "capability_graph": { "nodes": [], "edges": [] },
  "secret_reachability": [ ... ],
  "intentAttestationRequests": [],
  "coverage": { "surfaces": {}, "families": {}, "gaps": {}, "summary": {} },
  "projection": { "current": {}, "top1": null, "cumulative": {} },
  "config_found": true,
  "audited_config_path": "/home/you/.openclaw/openclaw.json",
  "config_parse_error": false,
  "config_symlink_escapes_home": false,
  "config_parse_reason": null,
  "errors": [],
  "inventory": {
    "openclaw": { "status": "FAIL", "findings": ["B2"], "unassessed": 24 },
    "host": { "status": "WARN", "findings": ["B50"], "unassessed": 0 },
    "agents": { "status": "PASS", "findings": [], "unassessed": 0, "roster": ["(default)"], "attested": false },
    "skills": [ { "name": "pdf", "verdict": "NO KNOWN ISSUE", "status": "PASS", "reasons": [] } ],
    "mcp": [ { "name": "slack", "verdict": "ok", "reasons": [] } ],
    "plugins": { "scanned": false, "rows": [] },
    "channels": { "status": "WARN", "findings": ["B26"], "unassessed": 0, "roster": ["telegram"] },
    "logs": { "status": "PASS", "findings": [], "unassessed": 0 }
  },
  "scan_receipt": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
```

`skill_sweep` is omitted from this skeleton (it appears only under `--full` — see §19).

---

## 2. Finding Object

Shared by `--json` and `--vet` mode.

| Field | Type | Description |
|---|---|---|
| `id` | `str` | Check identifier, e.g. `"B21"`, `"B67"`, `"A1"`. |
| `title` | `str` | Human-readable check title (sanitised; no raw secrets). |
| `severity` | `str` | `"CRITICAL"`, `"HIGH"`, `"MEDIUM"`, or `"LOW"`. |
| `status` | `str` | `"PASS"`, `"FAIL"`, `"WARN"`, or `"UNKNOWN"`. |
| `detail` | `str` | Explanation of the finding (sanitised). |
| `fix` | `str` | Short remediation hint (sanitised). |
| `framework` | `str` | Threat-framework reference, e.g. `"OWASP LLM01"`. |
| `confidence` | `str` | `"HIGH"`, `"MEDIUM"`, `"LOW"`, or `"ATTESTED"`. `"ATTESTED"` sits *below* `"LOW"`: the finding rests on the audited agent's own self-report (`--attest`) or on a host-agent prose verdict (`--vet-judged`), not on a config fact, so it is weaker evidence — the agent could be compromised or prompt-injected. Carried by B43, B44, B45, B47, B75, B76, B84 and by every `ATTEST-PROSE-*` finding (§16). All are `scored: false` except B76, which is scored. Orthogonal to `severity` and `status`: an `"ATTESTED"` finding still carries an ordinary severity and can still be a FAIL. |
| `pass_confidence` | `str \| null` | For PASS findings only: `"verified"` (evidence-based pass), `"no_signal"` (check found nothing but couldn't confirm safety), or `null` (FAIL/WARN/UNKNOWN — not applicable). |
| `scored` | `bool` | `false` for advisory findings excluded from the weighted score (they still appear in the report but don't move the grade); `true` for findings that count toward the score. Lets a JSON consumer reproduce the human report's "N to fix vs M warn" arithmetic, which excludes advisory items. |
| `suppressed` | `bool` | `true` if the finding was suppressed by the user's baseline. |
| `owasp` | `array[str]` | OWASP LLM Top 10 codes that apply, e.g. `["LLM01", "LLM02"]`. May be empty. |
| `ast` | `array[str]` | OWASP Agentic Skills Top 10 (2026) codes that apply, e.g. `["AST03", "AST05"]`. May be empty. Additive metadata only — no scoring or verdict impact. |
| `remediation` | `object` | Paste-ready remediation. Keys: `commands` (`array[str]`) and `config` (`array[object]`). |
| `evidence` | `array[str]` | Supporting evidence strings (sanitised; no raw secrets). May be empty. |
| `surface` | `str` | OpenClaw surface slug this check belongs to (e.g. `"gateway"`, `"tools"`, `"bootstrap"`). `""` for findings not in the CATALOG (e.g. MCP-vet diagnostics). One of the 14 slugs in `catalog.SURFACES` or `""`. |
| `not_applicable` | `bool` | `true` when the check determined its SURFACE does not exist on this host (e.g. no MCP servers configured at all), as opposed to the surface existing but nothing wrong being found — never true unless `status` is also `"UNKNOWN"`. `false` on every other finding. F-138/B1 landed the field (`Finding.not_applicable` plumbing + the `_surface_absent` predicate); F-139/B2 wired the first emitters — B15, B24, B166, and the MCP-VET "no MCP servers to vet" diagnostic — all gated on "the config was actually read completely and still shows no MCP surface in any known form", never merely "we couldn't tell". Always present (unlike `blast_radius`). |
| `blast_radius` | `object` | **Only present when `status` is `"FAIL"` and a config context is available** (always true for the real `clawseccheck --json` CLI path; absent in library calls to `render_json()` made without `ctx`). Estimated attacker gain if this finding is exploited. See below. |

### `blast_radius` object (FAIL findings only)

```json
{
  "open_channels": 1,
  "has_exec": true,
  "has_write": false,
  "secret_paths": 3
}
```

| Field | Type | Description |
|---|---|---|
| `open_channels` | `int` | Count of messaging channels with `dmPolicy` or `groupPolicy` set to `"open"`. |
| `has_exec` | `bool` | `true` if `tools.exec.mode` is configured. |
| `has_write` | `bool` | `true` if `fs_write` or `apply_patch` appears in the tool allowlist. |
| `secret_paths` | `int` | Count of dotted config paths holding a secret-bearing value. |

### `remediation` object

```json
{
  "commands": ["openclaw config set tool.sandboxed true"],
  "config": [
    {"path": "tools.sandboxed", "set": true, "note": "Restrict tool execution"}
  ]
}
```

Each `config` item has `path` (str, the config key), optionally `set` (the target
value), and optionally `note` (str, explanatory text). When `set` is absent the
item describes a manual configuration step.

### Finding skeleton

```json
{
  "id": "B21",
  "title": "Tool-output trust boundary",
  "severity": "HIGH",
  "status": "FAIL",
  "detail": "Retrieved content is injected into the prompt without sanitisation.",
  "fix": "Enable output sandboxing.",
  "framework": "OWASP LLM02",
  "confidence": "HIGH",
  "suppressed": false,
  "owasp": ["LLM02"],
  "ast": [],
  "remediation": {
    "commands": [],
    "config": []
  },
  "evidence": ["tools.output.sanitize = false"],
  "surface": "bootstrap",
  "not_applicable": false,
  "blast_radius": {
    "open_channels": 1,
    "has_exec": true,
    "has_write": false,
    "secret_paths": 3
  }
}
```

---

## 3. NextAction Object

Items in `next_actions` are ordered by ascending `priority` (lower = more urgent).

| Field | Type | Description |
|---|---|---|
| `id` | `str` | Action identifier, e.g. `"NA-B21"`. |
| `title` | `str` | Short action label (sanitised). |
| `command` | `str` | Paste-ready shell command or empty string if not applicable. |
| `why` | `str` | One-sentence rationale. |
| `priority` | `int` | Urgency rank; lower integers are higher priority. |

---

## 4. `risk_paths` — Attack Chain Array

`risk_paths` is always present in the real `clawseccheck --json` CLI output (combinational
attack chains are computed unconditionally per audit; there is no `--risk` gate — that was
true of an older CLI shape and is corrected here). It may be an empty array when no chain
condition matches. Library callers of `render_json()` directly can omit the `risk` keyword
(or pass `risk=None`) to suppress the key entirely — that path is for unit/library use, not
the shipped CLI.

| Field | Type | Description |
|---|---|---|
| `id` | `str` | Risk chain identifier, e.g. `"RISK-03"`. |
| `severity` | `str` | `"CRITICAL"`, `"HIGH"`, `"MEDIUM"`, or `"LOW"`. |
| `title` | `str` | Attack chain name. |
| `chain` | `array[str]` | Ordered list of check IDs that form the chain, e.g. `["B07", "B21", "B33"]`. |
| `why` | `str` | Narrative explanation of the attack path. |
| `fix` | `str` | Recommended mitigation. |

`risk_paths` is absent (not `null`, not `[]`) only when `render_json()` is called as a
library function without a `risk` argument.

---

## 5. `capability_graph` Object

Always present. Describes the static capability topology of the inspected agent
(inputs, main agent, sub-agents, MCP servers). Both arrays are empty when context
is unavailable.

```json
{
  "nodes": [
    {
      "id": "input",
      "label": "input",
      "kind": "ingress",
      "tools": ["web_search"],
      "secrets_visible": false,
      "can_write_memory": false,
      "can_egress": true
    },
    {
      "id": "main",
      "label": "main",
      "kind": "agent",
      "tools": ["fs_read", "web_search"],
      "secrets_visible": true,
      "can_write_memory": false,
      "can_egress": true
    },
    {
      "id": "mcp:brave-search",
      "label": "brave-search",
      "kind": "mcp",
      "tools": ["brave_web_search"],
      "secrets_visible": true,
      "can_write_memory": false,
      "can_egress": true
    }
  ],
  "edges": [
    ["input", "main"],
    ["main", "mcp:brave-search"]
  ]
}
```

### Node fields

| Field | Type | Description |
|---|---|---|
| `id` | `str` | Unique node identifier. `"input"`, `"main"`, `"subagent:<name>"`, or `"mcp:<name>"`. |
| `label` | `str` | Display name. |
| `kind` | `str` | `"ingress"`, `"agent"`, `"subagent"`, or `"mcp"`. |
| `tools` | `array[str]` | Tool names visible to this node. |
| `secrets_visible` | `bool` | `true` if the node can read secret-bearing configuration or env vars. |
| `can_write_memory` | `bool` | `true` if the node has write access to memory / workspace. |
| `can_egress` | `bool` | `true` if the node can make outbound network calls. |

### Edge shape

Each edge is a 2-element JSON array `[from, to]` — not an object — of source/destination
node `id` strings.

| Position | Type | Description |
|---|---|---|
| `[0]` | `str` | Source node `id`. |
| `[1]` | `str` | Destination node `id`. |

---

## 6. `secret_reachability` Array

Always present. Each entry represents one class of potential secret exposure.
The array is not empty even when no secrets are found; it always contains all
defined classes with `reachable: false` and an empty `evidence` array.

| Field | Type | Description |
|---|---|---|
| `class` | `str` | Secret class: `"env"`, `"mcp-passthrough"`, `".env"`, `"keychain"`, `"cookies"`, `"ssh"`, or `"cloud"`. |
| `reachable` | `bool` | `true` if at least one signal in this class was detected. |
| `evidence` | `array[str]` | Supporting signals (sanitised; no raw secret values). |

---

## 7. `intentAttestationRequests` Array (F-020)

Always present. Empty list when no B62 capability-intent mismatches were found.
One entry per skill flagged by check B62.

| Field | Type | Description |
|---|---|---|
| `skill` | `str` | Skill name (redacted if secret-shaped). |
| `declared_purpose` | `str` | Declared purpose extracted from the skill manifest. |
| `capability_set` | `array[str]` | All capability families detected in the skill's code. |
| `mismatches` | `array[MismatchItem]` | Capabilities that are surprising for the declared category. |
| `computed_risk` | `str` | Risk level computed from the mismatch set: `"high"` if any high-surprise capability family is present, `"medium"` otherwise. Lower-case, and only these two values — this is not the severity vocabulary used elsewhere in this document. |
| `question` | `str` | Natural-language attestation question for the host operator, ending in the same answer tail as a §12/§13 judge-packet item (`[SAFE / SUSPICIOUS / DANGEROUS + reason]`) — this array shares that vocabulary rather than a separate yes/no shape (B-334; through v3.56.0 this field ended `[yes/no + reason]`, inconsistent with the rest of the tool). There is no dedicated parser for a standalone `intentAttestationRequests` answer; when this item's `skill`/mismatch also appears in a `--judge-packet` (§12), it is the SAME question text, so a verdict submitted per §13's contract is accepted either way. |

### MismatchItem fields

| Field | Type | Description |
|---|---|---|
| `capability` | `str` | Capability family name, e.g. `"network_egress"`. |
| `declared` | `bool` | Always `false` (the capability was not declared). |
| `evidence` | `str` | Explanation of where the capability was detected (redacted). |

---

## 8. `coverage` Object (F-031)

Always present in `--json` output. Describes check coverage across the 13 OpenClaw
bucket surfaces and the 7 security families they roll up to. Used by the Dashboard
to render the coverage heat-map.

```json
{
  "surfaces": {
    "gateway": { "state": "checked", "counts": {"pass": 2, "warn": 1, "fail": 0, "unknown": 0} },
    "tools":   { "state": "partial", "counts": {"pass": 0, "warn": 0, "fail": 0, "unknown": 3} }
  },
  "families": {
    "exposure":  { "surfaces": ["gateway", "channels", "sessions"],
                   "counts":   {"pass": 2, "warn": 1, "fail": 0, "unknown": 0},
                   "worst":    "warn" },
    "privilege": { "surfaces": ["tools", "agents"],
                   "counts":   {"pass": 0, "warn": 0, "fail": 0, "unknown": 5},
                   "worst":    "unknown" }
  },
  "gaps": {
    "not_checkable": ["outbound egress allowlist"],
    "roadmap": []
  },
  "summary": {
    "checked": 8,
    "partial": 5,
    "not_checkable": 1,
    "roadmap": 0
  }
}
```

### `surfaces` map

Each key is a surface slug (one of the 13 bucket surfaces; `"trifecta"` is excluded — it
is a cross-cutting headline chip, not a coverage bucket). Value fields:

| Field | Type | Description |
|---|---|---|
| `state` | `str` | `"checked"` if ≥1 finding returned PASS/FAIL/WARN; `"partial"` if all findings were UNKNOWN or none ran. |
| `counts` | `object` | `{"pass": N, "warn": N, "fail": N, "unknown": N}` — finding totals for this surface. |

### `families` map

Keys are the 7 security family slugs: `"exposure"`, `"privilege"`, `"supply_chain"`,
`"content_integrity"`, `"secrets"`, `"detection"`, `"automation"`. Value fields:

| Field | Type | Description |
|---|---|---|
| `surfaces` | `array[str]` | Member surface slugs in canonical order. |
| `counts` | `object` | Aggregated `{"pass", "warn", "fail", "unknown"}` across all member surfaces. |
| `worst` | `str` | Worst status across the family: `"fail"`, `"warn"`, `"pass"`, or `"unknown"`. |

### `gaps` object

| Field | Type | Description |
|---|---|---|
| `not_checkable` | `array[str]` | Static list of OpenClaw surfaces with no auditable config control. |
| `roadmap` | `array[str]` | Surfaces not yet covered by ClawSecCheck (extensible; currently empty). |

### `summary` object

| Field | Type | Description |
|---|---|---|
| `checked` | `int` | Surfaces with ≥1 non-UNKNOWN finding. |
| `partial` | `int` | Surfaces where all findings are UNKNOWN. |
| `not_checkable` | `int` | Count of `gaps.not_checkable` entries. |
| `roadmap` | `int` | Count of `gaps.roadmap` entries. |

---

## 9. `projection` Object (F-031)

Always present in `--json` output. Estimates the score impact of fixing FAIL findings.
Used by the Dashboard to render the "fix this one thing" call-to-action.

```json
{
  "current":    {"score": 52, "grade": "D"},
  "top1":       {"finding_id": "B1", "projected_score": 72, "projected_grade": "C", "delta": 20},
  "cumulative": {"projected_score": 81, "projected_grade": "B", "delta": 29}
}
```

| Field | Type | Description |
|---|---|---|
| `current` | `object` | `{"score": int, "grade": str}` — current audit score (mirrors top-level `score`/`grade`). |
| `top1` | `object \| null` | The single highest-leverage fix. `null` when there are no fixable (scored, non-suppressed) FAIL findings. |
| `cumulative` | `object` | Projected score after fixing all CRITICAL + HIGH FAILs simultaneously. `delta` is 0 when none exist. |

### `top1` fields

| Field | Type | Description |
|---|---|---|
| `finding_id` | `str` | Check ID of the recommended fix (e.g. `"B1"`). |
| `projected_score` | `int` | Estimated score if this finding were resolved. |
| `projected_grade` | `str` | Corresponding letter grade. |
| `delta` | `int` | `projected_score − current.score`. |

### `cumulative` fields

| Field | Type | Description |
|---|---|---|
| `projected_score` | `int` | Score after all CRITICAL + HIGH FAILs are fixed. |
| `projected_grade` | `str` | Corresponding letter grade. |
| `delta` | `int` | `projected_score − current.score`. 0 when no CRITICAL/HIGH FAILs exist. |

> **Projection is estimated**, not guaranteed. It assumes each fixing finding flips
> cleanly to PASS; actual hardening may unlock or reveal new findings.

---

## 10. SARIF 2.1.0 Output (`--sarif`)

Schema: `https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json`

Only `FAIL` and `WARN` findings that are not suppressed appear as `results` entries.
`PASS`, `UNKNOWN`, and suppressed findings are omitted from `results` but their
corresponding checks always appear in `rules`.

### Top-level structure

```json
{
  "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
  "version": "2.1.0",
  "runs": [
    {
      "tool": { "driver": { ... } },
      "results": [ ... ]
    }
  ]
}
```

### `runs[0].tool.driver` fields

| Field | Type | Description |
|---|---|---|
| `name` | `str` | Always `"ClawSecCheck"`. |
| `version` | `str` | Tool version string, e.g. `"3.33.0"`. |
| `informationUri` | `str` | Always `"https://github.com/gl0di/clawseccheck"`. |
| `rules` | `array[Rule]` | One entry per check in the CATALOG, in catalog order. |

### Rule object

| Field | Type | Description |
|---|---|---|
| `id` | `str` | Check identifier, e.g. `"B21"`. |
| `name` | `str` | Check title. |
| `shortDescription.text` | `str` | Same as `name`. |
| `defaultConfiguration.level` | `str` | Severity mapping: `CRITICAL`/`HIGH` → `"error"`, `MEDIUM` → `"warning"`, `LOW` → `"note"`. |

### Result object

| Field | Type | Always present | Description |
|---|---|---|---|
| `ruleId` | `str` | yes | Check identifier. |
| `level` | `str` | yes | `"error"` for `FAIL`, `"warning"` for `WARN`. |
| `message.text` | `str` | yes | Finding detail text (sanitised). |
| `properties.confidence` | `str` | yes | `"HIGH"`, `"MEDIUM"`, `"LOW"`, or `"ATTESTED"` — the same four values the `--json` `confidence` field carries (see above). |
| `properties.evidence` | `array[str]` | yes | Supporting evidence (may be empty array). |
| `fixes` | `array[Fix]` | only when remediation exists | Paste-ready remediation steps. |

### Fix object

```json
{"description": {"text": "openclaw config set tool.sandboxed true"}}
```

`fixes` is omitted (not `null`, not `[]`) when the check has no paste-ready remediation.
Each entry carries only `description.text`; ClawSecCheck never emits `artifactChanges`
because ClawSecCheck never rewrites the artifacts it audits.

### `runs[0].properties.analysis_completeness` (when context is available)

Present only when a full context object was passed to the renderer (i.e. when invoked
as a full audit, not in unit/library mode).

| Field | Type | Description |
|---|---|---|
| `total_files_inspected` | `int` | Number of files read during the audit. |
| `excluded_binary_files_count` | `int` | Binary files skipped. |
| `archives_unpacked` | `int` | Archives extracted and inspected. |
| `limit_hits` | `array` | Signals where an inspection limit was reached. |
| `path_traversal_violations` | `array` | Paths rejected by the traversal guard. |
| `file_manifest` | `object` | Map of relative path → file metadata. |
| `simulated_effects` | `array` | Effect-profile entries derived from static analysis of skill Python files. |

### `runs[0].properties.effectProfile` (when non-empty)

Present only when at least one installed skill has a non-empty effect profile (F-018).
Keys are skill names; values are arrays of effect-profile entry objects.

---

## 11. `--vet` Mode Output — Risk Dossier

Produced by `--vet` / `--vet-skill` / `--vet-plugin`, `--vet-mcp`, and `--vet-source`.
**Since v3.8.0** the vet output is a **risk dossier**: the same per-finding results (§2 shape)
plus a five-axis roll-up and an overall grade. No full-audit `next_actions` / `capability_graph`.

### Fields

| Field | Type | Description |
|---|---|---|
| `tool` | `str` | Always `"clawseccheck"`. |
| `version` | `str` | Tool version string. |
| `mode` | `str` | `"vet"` (skill), `"vet-plugin"`, `"vet-mcp"`, or `"vet-source"`. |
| `target` | `str` | Path, name, slug, or URL of the vetted artifact. |
| `target_type` | `str` | `"skill"`, `"plugin"`, `"mcp"`, or `"source"`. |
| `verdict` | `str` | `"NO KNOWN ISSUE"`, `"SUSPICIOUS"`, `"DANGEROUS"`, or `"UNKNOWN"` (derived from the overall status). |
| `grade` | `str` | Overall letter grade `A`–`F`, or `"N/A"` when nothing is assessable. |
| `score` | `int` | 0–100 axis pass-rate behind the grade (0 when not assessable). |
| `axes` | `array[Axis]` | The five risk axes, in fixed order (below). |
| `findings` | `array[Finding]` | All check results. Same Finding shape as §2. |
| `unmapped` | `array[str]` | Finding ids that resolved to no axis (coverage diagnostic; normally empty). |

### Axis object

| Field | Type | Description |
|---|---|---|
| `axis` | `str` | One of `danger`, `build`, `behavior`, `persistence`, `connections`. |
| `status` | `str` | `"PASS"`, `"WARN"`, `"FAIL"`, `"UNKNOWN"`, or `"N/A"`. |
| `reason` | `str` | One-line explanation (untrusted text; sanitized). |
| `fix` | `str` | One-line remediation, or `""`. |
| `finding_ids` | `array[str]` | Ids of the findings bucketed to this axis. |

The five axes answer, together, "how risky is this to install?": **danger** (active malice
/ known-bad — a FAIL here floors the grade to F), **build** (least-privilege, pinning,
authoring hygiene), **behavior** (override / jailbreak / forged provenance / tool-poisoning),
**persistence** (dormant / staged code, install hooks), **connections** (outbound surface,
exfil channels, secret env passthrough).

An axis a target type structurally cannot produce is `"N/A"` (excluded from the grade
denominator) — never a fabricated PASS/FAIL. Examples: an MCP server spec has no on-disk
code, so `persistence` is `"N/A"`; `--vet-source` never fetches the artifact, so every axis
but `danger` is `"N/A"`. An axis with a producer but no measurable input (e.g. a skill with
no executable code) is `"UNKNOWN"`, distinct from PASS. `verdict` maps the overall status:
`FAIL`→DANGEROUS, `WARN`→SUSPICIOUS, `PASS`→NO KNOWN ISSUE, else UNKNOWN.

`grade` derivation: `danger == FAIL` → `F`; otherwise a weighted pass-rate over the
assessable axes (PASS=1, WARN=0.5, FAIL=0; N/A and UNKNOWN excluded), with any WARN capping
below A and any non-danger FAIL capping at C. All-N/A/UNKNOWN → `"N/A"`.

`--vet-plugin` decomposes into its dispatched sub-findings (bundled-skill B13/ring,
embedded-MCP `MCP-VET`), which bucket onto the axes; the `PLUGIN-VET` container id is not
itself an axis. `--vet-source` returns a single `SOURCE-VET` finding on the `danger` axis
(never `PASS` — an identity check cannot prove unseen code safe).

A further synthetic id, `VET-COVERAGE`, can appear in `findings[]` when the content-
security ring's own per-target scan budget runs out before every ring check has run —
part of the target went unassessed, not assessed clean. It is always `"UNKNOWN"` /
`"HIGH"` / `scored: false`, and — like `SOURCE-VET`/`PLUGIN-VET`/`MCP-VET` above —
carries no `CheckMeta` in the CATALOG, so a consumer that resolves finding ids through
the catalog will not find an entry for it. Its `detail` always contains the substring
`"coverage is incomplete"`; that wording is load-bearing — it is what caps the `danger`
axis (and therefore `grade`) below what the checks that did complete would otherwise
earn. Read a `VET-COVERAGE` `UNKNOWN` as "this scan is partial," never as a clean
result — it appears on the `--vet`/`--vet-skill` path, and the same gap can also
surface as a reason string in a full audit's per-skill inventory (§18).

### Skeleton

```json
{
  "tool": "clawseccheck",
  "version": "3.33.0",
  "mode": "vet",
  "target": "/path/to/skill",
  "target_type": "skill",
  "verdict": "DANGEROUS",
  "grade": "F",
  "score": 0,
  "axes": [
    {"axis": "danger", "status": "FAIL", "reason": "...", "fix": "...", "finding_ids": ["B13"]},
    {"axis": "persistence", "status": "PASS", "reason": "...", "fix": "", "finding_ids": []}
  ],
  "findings": [],
  "unmapped": []
}
```

SARIF: the vetting modes additionally carry the dossier roll-up on
`runs[0].properties.vetProfile` and tag each result with `properties.axis` — both additive
(the per-finding `results` stay finding-oriented).

---

## 12. `--judge-packet` Output (F-113)

Produced by the standalone `--judge-packet` flag. A separate JSON artifact — not part
of the `--json` envelope — that assembles the audit's borderline-band results (findings
whose status the engine could not determine, or that are deliberately WARN-only /
dual-use by design) into a machine-readable list of questions for the user's OWN host
agent to review and answer. **Read-only and purely additive**: it never re-runs a check,
never changes any Finding's status/severity/score, and never contacts an LLM or the
network itself.

**`--full --json` cross-reference (F-152):** this exact packet is also embedded as the
top-level `judgePacket` key of a `--full --json` run (§1) — no separate `--judge-packet`
invocation is needed. Its answers travel back the same way, too: `--full
--judged-bundle <file>` accepts a `judged` object of verdicts for THIS own-config
packet (own-config, annotate-only — see §13's `secondOpinion` and its `--full`
counterpart in §1). The bundle's SEPARATE `vetJudged` array answers the per-target
`vetPackets` this same `--full --json` run also carries (§1) — the escalate-only
sibling this packet's own authority rule does not apply to; see §15's own
`--full`/`--judged-bundle` note below for that path.

**`--full --judged-bundle`'s FOURTH bucket, `liveTest` (F-155):** a separate submission
channel from the three above — it carries no relation to the audit's own borderline
band, but reuses the identical bundle file/parsing shape (no second flag, no second
bound) rather than inventing one. Shape:
`{"seed": "<string>|omit", "verdicts": [{"tool": "canary"|"redteam"|"dryrun"|"multiturn",
"id": "<scenario id>", "verdict": "VULNERABLE"|"RESISTANT"}, ...]}`. Self-attestation
guard: only a `"VULNERABLE"` entry can ever move anything (`live_injection_capped` in
§1) — a `"RESISTANT"` entry, an unrecognized tool/id/verdict, or an absent bucket has
ZERO effect, by construction, not by convention (the verdict is produced by the agent
UNDER TEST, so the asymmetry is load-bearing). `seed` gates recordability, not the cap
itself: only a bucket carrying a non-empty `seed` string (the same value passed to the
harness's own `--seed`, making its tokens reproducible) is eligible to be written into
`~/.clawseccheck/history.jsonl`/`--trend`/the `--monitor` baseline — an unseeded
(randomly-tokened) VULNERABLE verdict still caps THIS run's grade, but that run is never
recorded, so a random token cannot manufacture drift across runs. Malformed/forged
entries are dropped per-entry (never a crash), mirroring `judged`/`vetJudged`'s own
defensive parsing.

Sources folded into the packet:

- every unsuppressed `UNKNOWN` finding from the audit;
- unsuppressed `WARN` findings whose id has a documented false-negative-prone history
  (dual-use signals deliberately down-ranked from FAIL so a legitimate skill is never
  hard-failed): `B13`, `B65`, `B66`, `B90`, `B99`, `B100`, `B102`, `B154`, `B156`;
- one item per B62 capability-intent mismatch (see §7);
- taint signals (`TT4_FILE_NET`, `TT_SSRF`, `TT5_ARG_INJECTION`, `DANGEROUS_SINK`) the
  skill AST/taint layer computes but the installed-skill check does not surface on its
  own when no independent credential/exfil signal is present elsewhere in the skill;
- `ENV_AUTH_KWARG_EXFIL`: an environment-variable or agent-config secret placed in an
  auth-shaped keyword (`headers=`/`auth=`/`cert=`) of a network call — the normal way a
  skill authenticates to its own API, so it is deliberately excluded from the engine's
  own `ENV_EXFIL_FLOW` taint rule and never independently reviewed. A separate AST walk
  scoped to exactly that excluded case surfaces it here.

### Envelope fields

| Field | Type | Description |
|---|---|---|
| `tool` | `str` | Always `"clawseccheck"`. |
| `version` | `str` | Tool version string. |
| `judgePacket` | `array[JudgePacketItem]` | The packet items. May be an empty array. |

### JudgePacketItem fields

| Field | Type | Description |
|---|---|---|
| `finding_id` | `str` | Check id (e.g. `"B13"`, `"B62"`) or recovered AST rule name (e.g. `"TT4_FILE_NET"`). |
| `target` | `str` | Skill/file name the item concerns (redacted if secret-shaped), or the `finding_id` when no target could be derived. |
| `redacted_evidence` | `str` | Human-readable evidence summary (fully redacted — no raw secrets or skill source). |
| `engine_disposition` | `str` | The underlying status: `"WARN"` or `"UNKNOWN"` (this artifact never carries `PASS`/`FAIL` items). |
| `question` | `str` | Plain-language attestation question for the host agent, ending in the same answer tail the `verdict_schema` beside it declares (`[SAFE / SUSPICIOUS / DANGEROUS + reason]`). |
| `verdict_schema` | `object` | Fixed answer contract: `{"verdict": ["SAFE", "SUSPICIOUS", "DANGEROUS"], "reason": "free text"}` — exactly the entry shape §13's input contract requires, so a verdicts file written straight from this field is accepted as-is by `--judged` / `--propose-ignore` / `--vet-judged`. (Through v3.56.0 this field wrongly advertised `{"answer": ["yes", "no"], ...}`, which every consumer rejected; `yes`/`no` cannot express the SUSPICIOUS-vs-DANGEROUS distinction the `--vet-judged` escalation ladder depends on, so the packet was corrected to the parser's vocabulary rather than the reverse.) |
| `safe_facts` | `object` | C-284: engine-extracted structured facts, never copied from prose. Carries up to two independent keys, each present only when extracted: `destination_host` (`str`) — the hostname of the first URL found in the finding's raw evidence, reduced to bare `[a-z0-9-]`+`.` (no scheme/userinfo/port/path/query/fragment) and length-capped at 100 chars (C-135, 2026-07-24: the DNS protocol's 253-char ceiling was too permissive — several long hyphenated labels chained by dots can still spell a multi-clause directive within it; 100 stays comfortably above any realistic real-world hostname while shrinking that payload budget); anything that fails that shape check is dropped, never truncated. `config_field_paths` (`array[str]`, C-361) — up to 6 distinct `dig()`-style config field paths (e.g. `"gateway.bind"`) recovered from the finding's evidence, used as a fallback when `redacted_evidence` would otherwise carry no location suffix. A config-derived finding routinely populates only `config_field_paths` with no `destination_host` at all. `{}` when neither could be safely extracted. This exists because `redacted_evidence` deliberately strips content-ring findings down to a location suffix (the matched prose can itself be a jailbreak directive aimed at the judge) — `safe_facts` restores just enough for the judge to check a first-party-endpoint allowlist or the actual config field in question, without reopening that redaction. |
| `corroboration` | `object` | C-285: `{"count": int, "check_ids": [str, ...], "scope": "target"}` — engine-authored, ids-only (no titles/details/evidence/paths). `count`/`check_ids` are the distinct unsuppressed WARN/FAIL check ids sharing this item's own `target` field, across the FULL findings list (not just other packet items); `check_ids` naturally includes this item's own id when its own status is WARN/FAIL, and naturally omits it when the item itself is UNKNOWN (most packet items) — in that case the field reflects purely how much OTHER live signal exists for the same target. `scope: "target"` matches C-252's own measurement unit (`docs/design/severity-separability.md` §5.1: one SkillTrustBench case per subject, not per file) — a lone WARN and a WARN sitting alongside three others on the same target used to be presented identically; C-252 found the co-occurrence count is the strongest signal separating malicious from benign in this engine's own output (monotonic, reaching 100% purity at 4+ distinct checks), stronger than `Finding.confidence`. **Context, not a verdict** — this field never implies a threshold (`count >= N` is not a rule the packet enforces or suggests); `SKILL.md`'s panel guidance says so explicitly. |

### Skeleton

```json
{
  "tool": "clawseccheck",
  "version": "3.37.0",
  "judgePacket": [
    {
      "finding_id": "TT4_FILE_NET",
      "target": "report_uploader",
      "redacted_evidence": "report_uploader: file-read contents flow into requests.post (indirect flow) — data exfiltration risk (uploader.py:8)",
      "engine_disposition": "UNKNOWN",
      "question": "This skill reads a file and the contents appear to flow into a network call, with no independent credential signal nearby (so the engine did not escalate it). Is this an intended upload/sync to a trusted destination? [SAFE / SUSPICIOUS / DANGEROUS + reason]",
      "verdict_schema": {"verdict": ["SAFE", "SUSPICIOUS", "DANGEROUS"], "reason": "free text"},
      "safe_facts": {"destination_host": "reports.example.com"},
      "corroboration": {"count": 2, "check_ids": ["B65", "TT4_FILE_NET"], "scope": "target"}
    }
  ]
}
```

---

## 13. `--judged` Output (F-115)

Consumes a host-agent judge panel's verdicts JSON for a prior `--judge-packet` run (see
§12) and renders the combined report. Takes the verdicts JSON via `--judged PATH`, or
`--judged -` to read it from stdin.

**Hard invariant:** the `score`, `grade`, `capped`, `raw_score`, `cap_severity`,
`assessable`, `trifecta`, and `findings` fields are **byte-identical** to a plain
`--json` run on the same inputs. A judge panel can only annotate an existing finding —
it can never raise, lower, or otherwise touch the deterministic grade. This is
mechanically enforced by `tests/test_adjudication.py`'s adversarial all-`DANGEROUS`
verdict test.

The only addition on top of the standard `--json` payload (§4) is one new key:

| Field | Type | Description |
|---|---|---|
| `secondOpinion` | `array[SecondOpinionItem]` | One row per current `--judge-packet` item — including ones nobody has judged yet. |

### SecondOpinionItem fields

| Field | Type | Description |
|---|---|---|
| `finding_id` | `str` | Same as the originating `judgePacket` item's `finding_id`. |
| `target` | `str` | Same as the originating item's `target`. |
| `engine_disposition` | `str` | The underlying status (`"WARN"` or `"UNKNOWN"`). |
| `judge_verdict` | `str \| null` | `"SAFE"` / `"SUSPICIOUS"` / `"DANGEROUS"` if a verdict was submitted for this item, else `null`. |
| `annotation` | `str` | Plain-language re-rank line, e.g. `"engine: WARN · judges: 3/3 DANGEROUS → treat as high priority"`, or `"not yet reviewed by a judge"`. |

### Input contract (the verdicts JSON `--judged` consumes)

Matched against `judgePacket` items by the `(finding_id, target)` pair. Parsing is
bounded and defensive (untrusted input from a host agent, possibly reflecting
attacker-influenced skill content): a payload over 2 MB, malformed JSON, a
non-object root, a non-array `verdicts` field, a non-object entry, or an entry whose
`finding_id`/`target` isn't a string or whose `verdict` isn't one of `SAFE` /
`SUSPICIOUS` / `DANGEROUS` is each simply dropped (that entry, or the whole parse) —
`--judged` never raises or crashes on bad input; the affected item(s) just render as
not-yet-reviewed.

Dropping is not silent, though. When a **non-empty** payload yields **zero** usable
entries, a `note:` line naming the reason (`0 of N submitted entries were usable`,
`it is not valid JSON`, `it has no top-level "verdicts" array`, a `--vet-judged`
`targetFingerprint` mismatch, …) is written to **stderr** — never stdout, which
carries the JSON artifact. This applies to all three consumers of the verdicts file
(`--judged`, `--propose-ignore`, `--vet-judged`), which share one parser. An
explicitly empty `"verdicts": []`, an empty payload, or an unreadable `--judged PATH`
stays quiet: those genuinely are "no verdicts submitted", and the diagnostic exists
precisely to tell that case apart from "everything you submitted was rejected".

```json
{
  "verdicts": [
    {
      "finding_id": "B13",
      "target": "skillx",
      "verdict": "DANGEROUS",
      "votes": {"SAFE": 0, "SUSPICIOUS": 0, "DANGEROUS": 3}
    }
  ]
}
```

`votes` is optional — when present and its values sum to something greater than
zero, the annotation renders a vote breakdown (`"judges: 3/3 DANGEROUS"`); when
absent, it renders `"judge: DANGEROUS"`.

---

## 14. `--propose-ignore` / `--apply-ignore-proposals` Output (C-253)

`--propose-ignore` consumes the same host-agent judge panel verdicts JSON as
`--judged` (§13, same `(finding_id, target)` matching, same 2 MB bound and defensive
parsing) for a prior `--judge-packet` run (§12), but instead of annotating a finding
it proposes suppressing it: items the panel verdicted `"SAFE"` become PROPOSED
`.clawseccheckignore` entries. **Read-only** — this command never writes to disk.
Takes the verdicts JSON via `--propose-ignore PATH`, or `--propose-ignore -` to read
it from stdin.

**Structural guarantee:** only findings already offered to the judge via
`--judge-packet` (unsuppressed `UNKNOWN`, or `WARN` in the documented
false-negative-prone set) are ever candidates — a `FAIL`-status finding (the only
kind that can cap the score) can never be selected here, regardless of what a
verdicts file claims for it. A finding aggregating more than one target (e.g. a
`WARN` that names several skills in one `Finding`) is also never proposed: a
`SAFE` verdict scoped to one target cannot safely suppress the whole aggregate
without silently hiding the OTHER, unreviewed targets bundled into the same
fingerprint (C-135, 2026-07-22).

`--apply-ignore-proposals` only ever writes an `entry` shaped like a genuine
`fingerprint()` output (`<id>:<8 lowercase hex chars>`) — a bare check id (e.g.
`"B1"`, `"B20"`) in a proposals file that did not genuinely come from
`--propose-ignore` is refused and named on stderr/stdout, never silently applied
(C-135, 2026-07-22): this command's whole premise is "only ever what
`--propose-ignore` already offered," and a bare id would instead suppress that
check file-wide via `.clawseccheckignore`'s separate bare-id form.

### Envelope fields

| Field | Type | Description |
|---|---|---|
| `tool` | `str` | Always `"clawseccheck"`. |
| `version` | `str` | Tool version string. |
| `proposedIgnoreEntries` | `array[IgnoreProposalItem]` | The proposals. May be an empty array. |
| `note` | `str` | Plain-language reminder that nothing was written and how to apply. |

### IgnoreProposalItem fields

| Field | Type | Description |
|---|---|---|
| `entry` | `str` | The exact `.clawseccheckignore` line (`<id>:<fingerprint>`) — the same fingerprint `baseline.py` matches against. |
| `finding_id` | `str` | Check id (e.g. `"B13"`). |
| `target` | `str` | Same as the originating `judgePacket` item's `target`. |
| `votes` | `object \| null` | Vote breakdown if the verdicts file supplied one, else `null`. |

```json
{
  "tool": "clawseccheck",
  "version": "3.55.0",
  "proposedIgnoreEntries": [
    {"entry": "B13:ab12cd34", "finding_id": "B13", "target": "skillx", "votes": {"SAFE": 3}}
  ],
  "note": "PROPOSED ONLY -- nothing was written by this command. ..."
}
```

`--apply-ignore-proposals PATH` reads exactly that JSON and appends each `entry` to
`<home>/.clawseccheckignore`, creating the file if absent. It is **opt-in and
confirmation-gated** the same way `--purge` is: it prints the exact lines and asks
for `[y/N]` unless `--yes` is also given; an unanswerable prompt (no tty / EOF)
aborts loudly (exit 1), never silently proceeds. It never invents an entry beyond
what the proposals file already listed, and already-present entries are skipped
(repeated applies do not grow the file with duplicates).

**This gains no new suppression authority.** A suppressed score-capping
`CRITICAL`/`HIGH` `FAIL`, or a sensitive check id (`B1`, `B2`, `B13`, `B20`), still
surfaces regardless of how the suppressing line got into `.clawseccheckignore` (see
`surfaced_despite_suppression`, §2), and any change to that file — including one made
this way — is still flagged by `--monitor` as drift (`ignore_hash`, §1).

**Residual, stated plainly (not solved away):** if the host agent generating the
verdicts JSON is itself compromised or prompt-injected, it could propose (and, if
also given `--yes` or talked past the confirmation prompt, apply) a suppression for
a real finding. The mitigations above bound the damage — a capping FAIL still
surfaces, the file change is still visible to `--monitor` — but do not eliminate the
risk. This is an accepted, disclosed limitation, not a claim that the risk is gone.

---

## 15. `--vet-judge-packet` / `--vet-judged` Output (C-254)

The same judge-panel idea as `--judge-packet`/`--judged` (§12/§13), scoped to a
SINGLE `--vet`/`--vet-skill`/`--vet-plugin` target instead of the user's full audit.
**The authority rule is the opposite of `--propose-ignore` (§14), deliberately**:
a `--vet` target is untrusted third-party content, so the judge may only
**escalate** a finding's status (raise it), never lower it. Authority here is
scoped by CONTENT PROVENANCE, not by direction — see §14's own note for why that
split, not a single "judge with a direction flag," is the actual security property.

`--vet-judge-packet` takes no argument; use alongside `--vet TARGET` (or
`--vet-skill`/`--vet-plugin PATH`). Read-only. `--vet-judged PATH` (or `-` for
stdin) feeds back the verdicts, same schema and same defensive/bounded parsing as
`--judged` (§13's input contract — 2 MB bound, malformed/wrong-shaped/garbage input
degrades to "no change," never a crash).

**Structural guarantee:** only findings already offered via `--vet-judge-packet`
(unsuppressed `UNKNOWN`, or `WARN` in the documented false-negative-prone set) are
ever candidates. A `SAFE` verdict, an unrecognized verdict, or no verdict at all
changes nothing. A `SUSPICIOUS` verdict raises an `UNKNOWN` finding to `WARN` (a
no-op if it was already `WARN` — nothing to raise). A `DANGEROUS` verdict always
raises the finding to `FAIL`, the ceiling. There is no code path that returns a
lower-ranked status than the finding already had, for any input.

### Envelope fields (`--vet-judge-packet`)

| Field | Type | Description |
|---|---|---|
| `tool` | `str` | Always `"clawseccheck"`. |
| `version` | `str` | Tool version string. |
| `target` | `str` | The vetted target path, as given to `--vet`/`--vet-skill`/`--vet-plugin`. |
| `targetFingerprint` | `str` | Binds this packet to THIS specific vet invocation (see below) — copy it verbatim into the verdicts JSON's own `targetFingerprint` field. |
| `judgePacket` | `array[JudgePacketItem]` | Same item shape as §12. May be an empty array. |

`--vet-judged`'s output is the standard `--vet` JSON (§11) with any escalated
finding's `status` raised and its `detail` prefixed
`"[escalated by host-agent judge: <verdict>] "` so a reader can tell a judge, not
the deterministic engine, raised it — `overall_status`/`overall_grade`/`score` are
then re-derived from that pool the same way a plain `--vet` run always derives
them (no separate rollup logic; `build_profile` is unchanged).

**`targetFingerprint` is mandatory (C-135, 2026-07-22).** An independent
adversarial review confirmed that matching verdicts purely by `(finding_id,
target)` — where `target` is only ever a bare name — let a verdicts file
correctly produced for ONE target silently escalate a DIFFERENT one that
happened to share that bare name (two shipped fixtures with the same
directory name, two bundled skills inside one plugin, or a stale verdicts
file replayed against a later, unrelated run). `--vet-judged` now computes a
fingerprint of the CURRENT run's resolved target path and compares it against
the verdicts JSON's own top-level `targetFingerprint`; a missing or mismatched
fingerprint means the **entire verdicts file is rejected** — degrades to "no
verdicts submitted," never a partial or best-effort match. The fingerprint is
path-based, not content-based: it defends against cross-target
misattribution, not against the target's own content changing between the
packet and the verdicts within one flow, which this feature was never meant
to detect (a static scanner only ever describes one moment-in-time state).

**This gains no new authority over what `--vet` already computes** — it can only
ever raise a finding that was already borderline (`UNKNOWN`/documented-FN-prone
`WARN`) to a status the deterministic engine's OWN severity scale already defines;
it can never invent a new check or a new signal. **Residual, stated plainly:** if
the host agent generating the verdicts is itself compromised, the worst it can do
is submit a verdict that changes nothing (the escalate-only structure gives it no
way to make the target look SAFER) — the downside is bounded to "no escalation
happened," never "a hidden real problem."

**`--full`/`--judged-bundle` cross-reference (F-152).** The same escalate-only cycle
now also runs INSIDE `--full`, for every target its skill/plugin sweeps already vetted
— not just a single standalone `--vet` invocation. `--full --json` carries one
`vetPackets` entry per swept target (§1), each with its own `targetFingerprint`,
exactly like this section's standalone `targetFingerprint` field. Answer them together
in one file via `--full --judged-bundle <file>`'s `vetJudged` array — one entry per
target, each shaped `{"targetFingerprint": str, "verdicts": [...]}` (the SAME
`verdicts` shape §13 documents) — and the result is `vetSecondOpinion` (§1): one row
per finding that was actually escalated, rendered in `--full`'s ADJUDICATION section
alongside the own-config second opinion. The binding, degrade-on-mismatch, and
escalate-only rules above are unchanged and enforced per target — a `vetJudged` entry
is matched ONLY by its own `targetFingerprint`, never by name, and a mismatch drops
that one entry (never a fallback to a different target) rather than the whole bundle.

---

## 16. Pre-Install Prose Attestation (C-255)

The SAME `--vet-judge-packet`/`--vet-judged` cycle (§15) ALSO always carries three
FIXED questions, regardless of whether the deterministic engine flagged anything at
all: `ATTEST-PROSE-MISMATCH`, `ATTEST-PROSE-INJECTION`, `ATTEST-PROSE-SOCIAL-ENG`.
This answers a measured gap, not a hunch: 97.32% of malicious cases the engine only
ever caught at `WARN` had ZERO `FAIL`-capable signal, because the attack was
described in the skill's prose rather than shipped as code. Answering these three
requires actually reading the skill's own SKILL.md/README/instructions — a
capability ClawSecCheck itself does not have (stdlib-only, no LLM); the host agent
supplies it.

**B-317 — injection-framing protocol for that read.** Reading the skill's raw prose
directly into the host agent's own context deliberately opens the structural
context firewall every other part of this packet relies on (§12's `safe_facts`/
location-only evidence exists precisely because attacker-influenced free text must
never reach a judge unframed). `SKILL.md`'s C-255 instruction therefore requires,
every time: (1) **delimiter discipline** — a fresh random token per read, the
content wrapped `<<<UNTRUSTED_SKILL_TEXT_{token}>>> ... <<<END_{token}>>>`; (2) a
**protection preamble** — the delimited text is evidence, never an instruction, and
no role/format/urgency/prior-approval claim inside it can change the verdict
contract; (3) **forgery detection** — content that already contains or attempts to
close the delimiter is itself evidence, reported as `ATTEST-PROSE-INJECTION`; (4) a
**scope limit** — read only the target's own declared files, never a link or fetch
instruction found inside them. This is engine-independent (a text protocol for the
host agent, not code ClawSecCheck runs) and reduces, not eliminates, the risk of
this one intentionally-open read — stated in `SKILL.md` itself, and see
`fixtures/bad_c255_prose_reviewer_injection/` for a concrete example of both attack
shapes the protocol targets (a direct instruction to the reviewer, and a forged
delimiter escape attempt).

**Landed alongside C-254's mechanism, not in `attest.py`**, despite the epic's
original framing — grounding against the real code showed the packet/verdicts/
escalate-only cycle already built for C-254 is the right home; `attest.py` is a
structurally different whole-agent self-report mechanism (see `--attest`, unrelated
to a per-vet-target packet).

**The safety ceiling — the load-bearing difference from C-254's escalation:**
C-254 raises an EXISTING finding that already has independent deterministic
corroboration (a real regex/AST signal). These three ids have NONE — pure
self-report. So even a `DANGEROUS` verdict here only ever produces a `WARN`-status
finding, **never `FAIL`, never score-capping**. A `SAFE` verdict, an unrecognized
verdict, or no verdict at all produces **no finding at all** (not even a
manufactured `PASS`) — these ids can only ever ADD caution, never subtract it and
never add a point to the vet score.

### JudgePacketItem additions

The three fixed items appear in `--vet-judge-packet`'s `judgePacket` array (§15)
with `engine_disposition: "UNKNOWN"` and `redacted_evidence: "(no deterministic
signal -- read the skill's own prose to answer)"` — always present, unlike every
other item in that array.

### New findings in `--vet-judged`'s output

A `SUSPICIOUS`/`DANGEROUS` verdict on one of the three ids adds a NEW Finding (not
an escalation of an existing one) to the standard `--vet` JSON's `findings` array:

| Field | Value |
|---|---|
| `status` | Always `WARN` — never `FAIL`, regardless of verdict. |
| `severity` | `MEDIUM`. |
| `confidence` | `ATTESTED` — the same ceiling `attest.py`'s self-report findings (B43/B44/B45/B84) already use. |
| `scored` | `false` — advisory, matching every other self-report-derived finding. |
| `detail` | Prefixed `"[host-agent pre-install attestation, verdict <verdict>]"`. |

**Residual, stated plainly:** if the host agent's pre-install read is itself
compromised, hallucinating, or talked into a false verdict by the skill's own
prose, the worst it can do is add a `WARN` that was not warranted, or fail to add
one that was — it can never fail an install by itself, and a `SAFE` verdict can
never suppress or soften a REAL deterministic finding (C-254's escalate-only rule
is unaffected by this extension). This is an accepted, disclosed limitation.

---

## 17. Stability Policy

### Frozen (breaking change requires major version bump)

- Top-level field names in all three output modes. **Only the top level.** A key nested
  inside a top-level object is frozen only if it is named below — this list is the whole
  contract, not an illustration of it.
- `Finding` object field names and their enumerated values (`severity`, `status`, `confidence`).
- SARIF `$schema` URI, `version`, and the `runs[0].tool.driver` shape.
- `verdict` enumeration in `--vet` mode.
- `capability_graph` node `kind` enumeration (`ingress`, `agent`, `subagent`, `mcp`).
- `secret_reachability` class enumeration.

> **Extended in v3.8.0 (additive):** the `--vet` envelope gained the risk-dossier fields
> (§11) — `target_type`, `grade`, `score`, `axes`, `unmapped` — as additive top-level fields
> (permitted in a minor release). `verdict` keeps its frozen enumeration but now derives from
> the overall dossier status; the per-finding `findings[]` shape (§2) is unchanged.

### Stable additions (permitted in any minor release without breakage)

- New optional top-level fields in the `--json` envelope.
- New entries in `next_actions` or `secret_reachability`.
- New check IDs in `findings` or SARIF `rules`.
- New fields inside `capability_graph` nodes or edges.
- New fields in `intentAttestationRequests` items.
- New optional fields on the `Finding` object (§2) — existing field names and their
  enumerated values stay frozen (F-138/B1 added `not_applicable` this way).

### Not part of the public contract

- The subject keys inside `inventory` (§18) and `coveragePage` (§20). Both objects are
  presentation regroupings of `findings`, and their keys follow the check taxonomy: v3.60.0
  replaced `inventory.system` with `openclaw` + `host` and added `plugins` + `logs`, in a
  minor release, deliberately. Key off `findings[].id` if you need stability.
- Text content of `title`, `detail`, `fix`, `why`, `question`, and `message.text`
  fields — these may change to improve accuracy without a version bump.
- `runs[0].properties.*` SARIF extension fields — present only when context is
  available and may gain or lose sub-fields in minor releases.
- The default human-readable text output (printed when neither `--json` nor `--sarif`
  is given) — not machine-parseable and not versioned.

---

## 18. `inventory` Object (F-131 — Inventory by Subject, Phase 1; extended to 8 subjects by F-163)

Owner-facing regrouping of the SAME `findings` (§2) above by the entities an owner
actually owns — OpenClaw core, Host machine, Agents, Skills, MCP servers, Plugins,
Channels, Logs & trajectories — instead of the 7 analyst-facing security families the
text report groups by underneath it. Purely additive and presentation-only: it never
changes `score`, `grade`, or any `Finding`; every finding id it lists also appears,
unchanged, in the top-level `findings` array.

Skills, MCP servers, and Plugins get a **per-instance** verdict (one entry per
installed skill / configured MCP server / swept plugin, reusing the same scoring
paths `--vet <skill>` / `--vet-mcp` / the plugin sweep use). OpenClaw core, Host
machine, Agents, Channels, and Logs & trajectories stay **bucket-level** — one
rolled-up status plus the ids of the surface's own FAIL/WARN findings — because no
`Finding` carries a precise per-instance (e.g. "which channel") attribution yet; that
is deferred to a possible Phase 2.

**F-163 change note**: the old 5-subject shape's `"system"` key is gone, replaced by
two subjects — `"openclaw"` (OpenClaw's own configuration: gateway, tools, secrets,
monitoring, hooks, update, sessions) and `"host"` (the host machine: network IDS,
audit logging, file-integrity monitoring, EDR, native binary PATH). `"plugins"` and
`"logs"` are new. A JSON consumer keyed on the old 5-key shape must be updated.

| Field | Type | Description |
|---|---|---|
| `openclaw` | `object` | Bucket: `{"status": str, "findings": array[str], "unassessed": int}`. `status` is the worst status (`FAIL` > `WARN` > `UNKNOWN` > `PASS`) among findings on the OpenClaw-core surfaces (gateway, tools, secrets, monitoring, hooks, update, sessions); `findings` lists the ids of that bucket's own FAIL/WARN findings; `unassessed` counts members whose `status` is `UNKNOWN` **and** `not_applicable` is `false` — a surface positively confirmed absent (`not_applicable: true`) was still assessed, so it is deliberately NOT counted here even though it is also `UNKNOWN`. |
| `host` | `object` | Bucket, same shape as `openclaw`, scoped to the `host` surface (network IDS, audit logging, file-integrity monitoring, EDR, native binary PATH safety, systemd persistence) — answers "is this MACHINE monitored", a distinct question from "is OpenClaw configured well". |
| `agents` | `object` | Bucket, same shape as `openclaw`, plus: `roster` (`array[str]`) — agent names, preferring an attested roster (`--attest`) over the static `agents.list` config, falling back to `["(default)"]`; `attested` (`bool`) — `true` when the roster came from an attestation self-report. |
| `skills` | `array[object]` | One entry per installed skill: `{"name": str, "verdict": str, "status": str, "reasons": array[str]}`. `verdict` reuses the same word set `--vet` uses (`"NO KNOWN ISSUE"`, `"SUSPICIOUS"`, `"DANGEROUS"`, `"UNKNOWN"`); `status` is the underlying `PASS`/`WARN`/`FAIL`/`UNKNOWN`; `reasons` holds up to 3 sanitised detail strings. Empty array when no skills are installed. A skill the per-skill scan budget could not reach reports `status: "UNKNOWN"` with a reason explaining why — never a false `"NO KNOWN ISSUE"`. |
| `mcp` | `array[object]` | One entry per configured MCP server (both `mcp.servers` nesting and legacy `mcpServers`/`mcp_servers`): `{"name": str, "verdict": str, "reasons": array[str]}`. `verdict` is `"ok"` (no supply-chain/trust signal), or `"WARN"`/`"FAIL"`/`"UNKNOWN"`. Empty array when no MCP servers are configured. |
| `plugins` | `object` | `{"scanned": bool, "rows": array[object]}`. `scanned` is `false` when this run never swept plugins (plain `audit()`/`--json` without `--full` — a plugin sweep is `--full`-only) — distinct from `true` with an empty `rows` (a real sweep found zero installed plugins). Each row: `{"name": str, "status": str}` (`PASS`/`WARN`/`FAIL`/`UNKNOWN`/`"SKIPPED"`/`"TRUNCATED"`). |
| `channels` | `object` | Bucket, same shape as `openclaw`, plus: `roster` (`array[str]`) — configured channel provider names (the `defaults` pseudo-provider excluded). |
| `logs` | `object` | Bucket, same shape as `openclaw`, scoped to the `logs` surface (trajectory sidecar / audit-trail / log-content-scan / behavioral checks — B164, B180, B85, T1, T2, T3, B191). |

### Notes

- `inventory` is always present (an empty/all-`PASS` shape when nothing is configured or
  `ctx` is unavailable), matching every other always-present top-level field.
- The exact wording of `reasons[]` entries is **not** part of the frozen contract (same
  rule as `detail`/`fix` text elsewhere in this doc) — only the field names/types are.

---

## 19. `skill_sweep` Object (F-149 — installed-skill sweep under `--full --json`)

`--full` runs a second engine on top of the audit: the audit's own `surface="skills"`
checks and the shared content ring answer "is anything wrong across this fleet",
attributed to the HOME; the sweep answers "which skill, and how bad is THAT one" — one
merged vet verdict per installed skill (the same engine `--vet <path>` uses), which is
the unit an owner actually acts on. The printed `--full` report has always carried this
as its "CLAWSECCHECK SKILL SWEEP" section; `--full --json` did not carry it at all until
this field was added — a `--json` consumer had no way to see per-skill vet verdicts.

Present **only** when `--full` was given (omitted — key absent, not `null` — on a plain
`--json` run). Visibility only, same as the printed section: never folds into the
top-level `score`/`grade`/`findings` above.

| Field | Type | Description |
|---|---|---|
| `checked_dirs` | `array[str]` | Every skill-root directory that exists under this home (`skills/`, `workspace/skills/`, `workspace-home/skills/`, `workspace-work/skills/`, `.agents/skills/` — see `collector.SKILL_DIRS`). Empty when none exist. |
| `no_roots` | `bool` | `true` when no skill-root directory exists at all — nothing to sweep. |
| `no_targets` | `bool` | `true` when at least one root exists but none contains an installed skill (a `SKILL.md`-bearing directory). |
| `truncated` | `bool` | `true` when the whole-sweep wall-clock budget or a per-target scan budget cut the run short — at least one target in `targets` carries `"SKIPPED"` or `"TRUNCATED"`. |
| `complete` | `bool` | `not truncated` — provided so a consumer does not have to negate the field above. |
| `worst` | `str` | Worst verdict among scanned targets: `"PASS"`, `"WARN"`, or `"FAIL"`. Does not account for truncation — check `truncated` separately; an incomplete sweep is never claimed clean by this field alone. |
| `counts` | `object` | `{"total": int, "fails": int, "warns": int, "truncated": int, "skipped": int, "safe": int}`. `total` excludes `"SKIPPED"` rows (attempted-but-unscanned targets are not "checked"); `safe` excludes `fails`/`warns`/`truncated` — an unscanned or partially-scanned target is never folded into `safe`. |
| `targets` | `array[object]` | One entry per skill the sweep accounted for, **including** ones it never fully scanned: `{"name": str, "status": str, "evidence_count": int}`. `status` is one of `"PASS"`, `"WARN"`, `"FAIL"`, `"UNKNOWN"`, `"SKIPPED"` (budget exhausted before this target was reached), `"TRUNCATED"` (this target's own scan was cut short). `name` is sanitized (skill names are attacker-controlled — untrusted, third-party directory names). |
| `not_scanned` | `array[str]` | Names of every target with `status` `"SKIPPED"` or `"TRUNCATED"` — the same list `counts.skipped + counts.truncated` sizes, spelled out. |

### Skeleton

```json
{
  "skill_sweep": {
    "checked_dirs": ["/home/you/.openclaw/skills"],
    "no_roots": false,
    "no_targets": false,
    "truncated": false,
    "complete": true,
    "worst": "PASS",
    "counts": {"total": 2, "fails": 0, "warns": 0, "truncated": 0, "skipped": 0, "safe": 2},
    "targets": [
      {"name": "pdf-tools", "status": "PASS", "evidence_count": 0},
      {"name": "web-search", "status": "PASS", "evidence_count": 0}
    ],
    "not_scanned": []
  }
}
```

### Notes

- `--exit-code` treats a `"FAIL"` target FAIL-only, mirroring the vet-mcp rule: a
  `"WARN"` (SUSPICIOUS) skill never trips it, and neither does truncation alone — an
  incomplete sweep is surfaced through `truncated`/`not_scanned`, not by reddening a
  CI gate that would otherwise be green.
- The sweep runs silently under `--json` (no narrated output mixed into stdout) — the
  JSON document on stdout is the complete, sole output.

---

## 20. `coveragePage` Object (F-165 — "was everything looked at", V1)

Present under `--full --json` only (a plugin/skill sweep pass has to have run to
answer the question at all — omitted, key absent, on a plain `--json` without
`--full`). Answers a different question than `inventory` (§18): that states what each
subject's checks/instances *found*; this states, per subject, how much of that
subject was actually *scanned* — and names every gap rather than only counting it, the
same "no silent caps" rule the rest of this tool follows.

One entry per subject in the 8-subject taxonomy (§18):

| Field | Type | Description |
|---|---|---|
| `total` | `int \| null` | How many things this subject owns (checks for a bucket subject, installed targets for a sweep subject). `null` means this run never swept the subject at all (`skills`/`plugins` on a run without `--full` or under `--fast`) — distinct from `0` (swept, and there was nothing to scan). |
| `scanned` | `int \| null` | How many of `total` reached a conclusive verdict. For `openclaw`/`host`/`agents`/`channels`/`logs` (bucket subjects): checks that returned `PASS`/`FAIL`/`WARN` rather than `UNKNOWN`. For `skills`/`plugins`: installed targets the sweep fully scanned (neither `SKIPPED` nor `TRUNCATED`). `mcp` is always fully scanned (`scanned == total`) — MCP vetting is not sweep-budgeted. `null` mirrors `total`'s `null`. |
| `not_scanned` | `array[str]` | Every gap, named — check ids (bucket subjects) or target names (`skills`/`plugins`), never merely a count. Empty when `scanned == total`. |
| `note` | `str \| null` | Present only for the `null`/`0` cases above, one of four strings: `"not scanned this run (needs --full)"` (a run WITHOUT `--full` at all — `skills`/`plugins` only); `"not scanned this run (--fast drops the sweep phases)"` (`--full --fast --json` — the sweep phases ran, so naming `--full` again would be telling the operator to pass a flag they already passed); `"none installed"` (`skills`/`plugins` swept, nothing found); or `"none configured"` (`mcp` with zero configured servers). `null` for every ordinary scanned-vs-total entry. |

### Skeleton

```json
{
  "coveragePage": {
    "openclaw": {"total": 68, "scanned": 33, "not_scanned": ["B17", "B31", "..."]},
    "host": {"total": 8, "scanned": 6, "not_scanned": ["B101", "B150"]},
    "agents": {"total": 34, "scanned": 24, "not_scanned": ["B18", "B22", "..."]},
    "skills": {"total": 0, "scanned": 0, "not_scanned": [], "note": "none installed"},
    "mcp": {"total": 0, "scanned": 0, "not_scanned": [], "note": "none configured"},
    "plugins": {"total": 0, "scanned": 0, "not_scanned": [], "note": "none installed"},
    "channels": {"total": 3, "scanned": 3, "not_scanned": []},
    "logs": {"total": 7, "scanned": 0, "not_scanned": ["B164", "B180", "..."]}
  }
}
```

### Notes

- **Its gap and `inventory.unassessed` (§18) use different denominators for the same
  subject, by design.** Measured on `fixtures/home_vuln --full --json`:
  `coveragePage.openclaw` shows `total: 68, scanned: 33` (a gap of 35), while
  `inventory.openclaw.unassessed` is `24`. The 11-item difference is exactly the
  `openclaw`-subject findings that are `UNKNOWN` **and** `not_applicable: true` (a
  surface positively confirmed absent, e.g. no MCP servers configured at all): this
  page's `scanned` only counts `PASS`/`FAIL`/`WARN`, so a `not_applicable` `UNKNOWN`
  still counts as a gap here, while `inventory.unassessed` deliberately excludes it
  (that surface WAS assessed — it just resolved to "nothing there"). Not a bug.
- Presentation-only, same as `inventory` — never alters `score`/`grade`/`findings`.
- **V1 scope**: `logs` is CHECK-granularity here (same as the other bucket subjects),
  not the file/byte-level detail ("N of M trajectory files, X of Y MB scanned") a
  future revision may add — that data exists today only as prose inside
  `B164`/trajectory-audit/behavioral findings, not as structured counts.
- **V1 scope**: also rendered as a text section (`--full`, banner `CLAWSECCHECK
  COVERAGE`) built from the same `build_coverage_page` function. `--dashboard --full`,
  `--html`, and `--pdf` do not carry this page yet — they render through a separate
  code path that does not call `pipeline.run_pipeline`.
