# Audit Triage (v1.48.0)

**Resolution (2026-06-25): all 19 rows resolved.** P1–P3 + two of three P4 fixed across PRs #2038–#2042 (per-row Status below). The lone remaining P4 (the `*Args`/enumeration architecture call) is filed as #2043; the carried-forward #1512 (WSL UNC dead branch) stays open. Released in v1.49.0.

Audit date: 2026-06-24. Surface under audit: the re-introduced **MCP bridge** (Phase 1/2a/2b, PRs #2019/#2029/#2033/#2034) riding the Phase-0 cores, plus the RT-RELAY injection-scan family (#2024) and the parser walk-depth/stack rails (#2031).

**Summary.** 32 verified findings consolidate to **19 triaged rows** after dedup (the 1 MiB relay-cap defect was reported 4×; the `cqs_index` slot silent-no-op 4×; the hand-mirrored MCP command enumeration 3×; the `success_result` payload-copy 3×; the parser depth/stack asymmetry 2×; the `cqs_wait_fresh`/`cqs_status` phantom-tool description 2×). The cycle's two genuine **P1**s are the agent-facing docs-lies — the README MCP tool list names 7 nonexistent tools (incl. the 2 *deliberately-withheld* `cqs_context`/`cqs_explain`) while omitting 7 real ones, and the `cqs_index` tool description steers the agent to poll with two phantom tools — both cheap one-line/registry fixes on the most action-relevant surface. The headline **P2**s are two genuine defects on the new boundary: the MCP relay's hardcoded 1 MiB read cap sits 16× under the daemon's own 16 MiB output cap, ignores `CQS_DAEMON_MAX_RESPONSE_BYTES`, and *silently truncates* large-but-valid `gather`/`search` results into a misleading "malformed response" error (MCP strictly narrower than the CLI it fronts); and the daemon/MCP **notes-write path's deferred reindex is inotify-only — never reconcile — so on the documented WSL `/mnt/c` primary deployment a successfully-written note can be silently never indexed**, the index diverging from the file indefinitely after the daemon reports success. Both are high-severity but medium-difficulty. A cluster of P2/P3 RT-RELAY gaps (explain `--tokens` similar-chunk bodies, `read --focus` type-dep bodies, the `leading-directive` char-0 detector blind spot) relay attacker-controllable content with a clean `injection_flags`, breaking the SECURITY.md per-chunk honest-relay contract. The structural root behind the schema/wire and four-hand-mirrored-enumeration drift is the same parallel-`*Args`-layers-with-no-exhaustiveness-guard class re-filed as **#2043** (the prior #1459 attribution was wrong — #1459 is the unrelated API-ergonomics issue) — carried forward, still real, and now manifesting on the MCP surface. P3 collects easy observability/precheck/clamp gaps; P4 holds the unbounded-stdin-read DoS rail (high severity but bounded by `panic`-context and a known fix pattern), the MCP-on-Windows half-broken integration, and the systemic-duplication architecture call.

## Summary by Priority

| Priority | Count | Definition |
|----------|-------|------------|
| P1 | 2 | Easy + high impact (agent-facing docs-lies on the live MCP surface) — fix first |
| P2 | 6 | Medium + high impact (or docs-lying / security-contract) — fix in batch |
| P3 | 8 | Easy + low/med impact — fix if time |
| P4 | 3 | Hard / low impact / already-tracked — issue or inline |

## P1 — easy + high impact (or docs-lying)

| Priority | Category | Title | Location | Difficulty | Suggested fix | Status |
|----------|----------|-------|----------|------------|---------------|--------|
| P1 | documentation | README MCP tool list is fabricated — names 7 nonexistent tools (incl. the 2 explicitly-*withheld* `cqs_context`/`cqs_explain`) and omits 7 real ones; an agent calls phantoms and gets METHOD_NOT_FOUND, never discovers `cqs_similar`/`cqs_ci`/`cqs_plan` | README.md:360 vs src/cli/mcp/tools.rs:149-313, mod.rs:110-112 (withheld) | easy | Replace the list with the real registry order from `tool_table()` read names; add a doc-sync guard test asserting `README list == tool_table() read names`, mirroring the existing name-parity test in `mcp/mod.rs` | ✅ #2042 |
| P1 | documentation/api-design | `cqs_index` tool description tells the agent to poll completion with `cqs_wait_fresh` / `cqs_status` — neither is a registered MCP tool; a fire-and-forget mutator steers the agent to a METHOD_NOT_FOUND dead-end at the exact moment it must wait for the rebuild (merged: documentation + api-design dups) | src/cli/mcp/tools.rs:386-390 | easy | Drop the phantom references; reword to a mechanism that exists (out-of-band `cqs status`/`cqs wait-fresh`, or re-run a read tool and check `_meta.stale_origins`), or expose a real `cqs_status` read tool and reference its actual name. Add a guard test asserting every `cqs_*` name in any tool description exists in `tool_table()` | ✅ #2042 |

## P2 — medium + high impact (or docs-lying / security-contract)

| Priority | Category | Title | Location | Difficulty | Suggested fix | Status |
|----------|----------|-------|----------|------------|---------------|--------|
| P2 | scaling-limits/robustness | MCP relay response read-cap is a hardcoded 1 MiB — 16× below the daemon's own 16 MiB output cap, ignores `CQS_DAEMON_MAX_RESPONSE_BYTES`, and *silently truncates* large valid `gather`/`search`/`scout` results into a misleading "daemon returned a malformed response" error (MCP strictly narrower than the CLI it fronts) (merged: robustness + error-handling + scaling-limits dups, all `daemon_translate.rs:892`) | src/daemon_translate.rs:889-902 × src/cli/limits.rs:209-214 × src/cli/mcp/tools.rs:591-596 | medium | Size the reader from `max_daemon_response_bytes()` (the env-overridable resolver `dispatch.rs:736` uses) instead of `1024*1024`; capture `bytes_read` and when `== cap` return a distinct `DaemonRpcError` ("response exceeded N MiB — set CQS_DAEMON_MAX_RESPONSE_BYTES / narrow the query") instead of an opaque parse failure; fix the `:889-891` comment (request-line and response caps are independent) | ✅ #2041 |
| P2 | data-safety | Daemon/MCP notes-write deferred reindex relies on inotify only — never reconcile — so on the documented WSL `/mnt/c` primary deployment a successfully-written note can be silently never indexed; the index diverges from the file indefinitely after the daemon reports success (CLI path is safe — reindexes inline) | src/cli/commands/io/notes.rs:345-368 ; src/cli/batch/handlers/misc.rs:209-244 ; src/cli/watch/events.rs:80 ; src/cli/watch/mod.rs:1808-1811 ; src/cli/watch/reconcile.rs (zero notes refs) | medium | Drive notes reindex independent of inotify: flip a shared pending-notes signal that the watch loop drains each tick, OR extend `run_daemon_reconcile` to compare the notes-table content digest (`store/notes.rs:320`) against `docs/notes.toml`, OR have the on-demand reconcile branch (`mod.rs:1422`) also set `state.pending_notes` so `cqs_index` is a reliable escape hatch. Add an integration test: write notes via `dispatch_notes_add` with inotify suppressed, drive a reconcile tick, assert the note is queryable | ✅ #2041 (SharedNotesSignal, inotify-independent) |
| P2 | data-safety | MCP `cqs_index` silently ignores a non-active `slot`, returning `{status:queued}` while reindexing a *different* slot — an undetectable correctness lie across the interop boundary to an autonomous agent (merged: 2× data-safety + error-handling + code-quality dups) | src/cli/batch/handlers/misc.rs:267-292 × src/cli/commands/index/index_args.rs:39-46 × src/cli/batch/view.rs:1458 (request_reconcile takes no slot) × src/cli/mcp/tools.rs:382-401 | easy | In `dispatch_index`, when `args.slot` is `Some` and differs from the served slot, either return a slim `{error:{...}}` envelope naming the served slot (→ `isError:true`) OR echo `served_slot`/`slot_ignored:true` so the caller can detect the substitution. Plumb the served-slot name from `ctx` (no accessor today). Add a test that a mismatched-slot request does not return a bare queued success | ✅ #2041 |
| P2 | api-design | Worktree-overlay params (`overlay`/`no_overlay`/`overlay_root`) are accepted on the wire (top-level, `overlay_from_args`) but absent from the advertised inputSchema — contradicting the module-doc "schema==wire cannot drift" invariant; inconsistent across siblings (`cqs_search` lists `overlay` only, `cqs_callers`/`impact`/`dead` list none) — same wire capability, three advertised surfaces | src/cli/mcp/tools.rs:14-16 (false claim) vs src/cli/batch/json_args.rs:116-138,155,194,221,270 | medium | Define a shared `OverlayArgs` (Deserialize + JsonSchema) and `#[serde(flatten)]` it into every overlay-capable Phase-0 core (search/callers/callees/impact/dead/scout/gather/ci/review), reading from the core instead of `overlay_from_args` (collapses the two read paths); OR inject the three properties into each tool's inputSchema after `schema_for!` and correct the `:14-16` doc. Add a test: every key `overlay_from_args` reads is a declared property of the tool's inputSchema. (Same root class as #1459) | ✅ #2042 |
| P2 | security | RT-RELAY gap: `cqs explain --tokens` relays similar-chunk body content verbatim with no injection scan and no per-entry `injection_flags`; an indirect-injection payload in a sibling function body reaches the agent unflagged, violating the SECURITY.md per-chunk honest-relay contract | src/cli/commands/graph/explain.rs:266-308 (SimilarEntry), :183-190 (struct) | easy | Add `injection_flags: Vec<String>` to `SimilarEntry`, populate via `detect_all_injection_patterns` over each relayed similar chunk's content (skip-when-empty), mirroring `search`/`read`/`context --full` | ✅ #2039 |
| P2 | test-coverage-adversarial / security | RT-RELAY `leading-directive` detector fires only at char-0 (`head_trim.starts_with`) while `code-fence`/`embedded-url` use `contains` — a doc-borne payload behind a benign first line is silently unflagged; the relay test never drives the `leading-directive` family and no mid-text directive is tested anywhere | src/llm/validation.rs:158-184 (detector) + tests/cli_doc_injection_relay_test.rs:35-36 (fixture) | easy | Change `leading-directive` to scan anywhere in the body (or at any line start), matching `contains`; add an RT-RELAY relay case whose POISONED_DOC places a directive after a benign first line and asserts a `leading-directive` flag; add a `detect_all_injection_patterns` unit test for a mid-text directive | ✅ #2039 (line-start anchoring, not anywhere — FP fix) |

## P3 — easy + low/med impact

| Priority | Category | Title | Location | Difficulty | Suggested fix | Status |
|----------|----------|-------|----------|------------|---------------|--------|
| P3 | security | RT-RELAY gap: `cqs read --focus` relays type-dependency chunk bodies verbatim but scans only the focus chunk for injection — a payload in a depended-on struct/enum/trait reaches the agent with a clean `injection_flags` | src/cli/commands/io/read.rs:310-346 | easy | Run `detect_all_injection_patterns` over the union of focus + every appended type-dep chunk's content; match the `context --full` `scan_chunk_injection_flags` pattern | ✅ #2039 |
| P3 | security | `cqs trace` relays function signatures verbatim with no injection scan or `injection_flags` field — the lone signature-relaying command that omits the scan `context --compact` performs on the identical surface | src/cli/commands/graph/trace.rs:61-69 (TraceHop), :140-161 | easy | Add `injection_flags` to `TraceHop`, populate via `detect_all_injection_patterns(&signature)` (skip-when-empty), mirroring `context --compact` (context.rs:185) | ✅ #2039 |
| P3 | algorithm-correctness | `explain`/`context` full-mode injection scan reads un-relayed `content`, over-flagging a response that never carried it — diverges from the documented "scan only relayed surfaces" contract the compact path implements correctly | src/cli/commands/graph/explain.rs:302-308 ; src/cli/commands/io/context.rs:378-415 | easy | Make scan text track what is actually emitted: include content only when `include_target_content` (explain) / the chunk is in `content_set` (context full); update the two doc comments | ✅ #2039 |
| P3 | code-quality | `cqs_test_map`/`cqs_trace` advertise a settable `max_nodes` in their inputSchema, but the daemon JSON-args converters drop it and re-resolve the env default — the documented OOM-guard knob is a silent no-op over the wire; parity tests never set it so the guard is structurally blind | src/cli/batch/json_args.rs:490-522 ; cores test_map.rs:42-43 / trace.rs:43-44 ; handlers graph.rs:366,383,433 | easy | Either thread `c.max_nodes` through `dispatch_test_map`/`dispatch_trace` (env value as serde default fallback), OR add `#[schemars(skip)]` to both cores' `max_nodes` to drop it from the schema; add a json_args test that sets `max_nodes` and asserts it reaches the core | ✅ #2038 (`#[schemars(skip)]` on both cores + schema-omits test) |
| P3 | scaling-limits / algorithm-correctness | `parser_max_walk_depth()` is unclamped while its paired `parser_stack_size()` is clamped — raising `CQS_PARSER_MAX_WALK_DEPTH` re-opens the stack-overflow SIGSEGV the rail prevents (`panic=abort` → uncatchable); enforcement is one-directional despite the "load-bearing-by-design invariant" doc (merged: scaling-limits + algorithm-correctness dups) | src/limits.rs:87-88 (unclamped) vs :115-122 (clamped) | easy | Make the coupling bidirectional: derive `parser_stack_size`'s floor from the resolved depth, OR clamp `parser_max_walk_depth` to what the stack accommodates (switch to `parse_env_usize_clamped` with a documented MAX), emitting a warn when clamped; add a clamp test mirroring `parser_stack_size_below_floor_clamps_to_min` | ✅ #2040 (800 ceiling, clamp-down+warn) |
| P3 | algorithm-correctness | MCP precheck `validate_arguments` accepts `max_depth` values (e.g. 999) the daemon then range-rejects (1..=50), contradicting its "accepts exactly what the daemon will" comment — needless round-trip + a less-specific error | src/cli/mcp/tools.rs:517-558 vs src/cli/batch/json_args.rs:490-512 | easy | Route the precheck through the same range validation (share a `validate_max_depth` helper / call the from_core converters), OR move the 1..=50 bound onto the core type as a deserialize-time validator so one deserialize is authoritative on both sides; soften the comment until boundaries match | ✅ #2042 |
| P3 | performance | MCP `success_result` embeds every tool payload twice (JSON-string text mirror + cloned `structuredContent`) and the bridge serializes the whole thing again — a 3-pass / double-on-the-wire copy of up-to-1 MiB results on the per-call hot path (merged: performance + 2× resource-management dups) | src/cli/mcp/tools.rs:656-665, 672-677 | easy | Size-gate the text mirror (full under ~4 KiB, summary above) and build it from the already-inserted `structuredContent` value; change the signature to take `payload: Value` and move it into `structuredContent` rather than cloning. Apply to both the success and empty-with-candidates branches | ✅ #2042 |
| P3 | observability | MCP bridge logs nothing per `tools/call` — the bare `mcp_tools_call` span never records the tool name and emits no completion event, so the agent-facing subprocess stderr has no per-request trace (daemon side logs status+latency+command by contrast) | src/cli/mcp/tools.rs:458 + src/cli/mcp/bridge.rs:99-119 | easy | Declare `tool = tracing::field::Empty` on the span, `span.record("tool", name)` after resolving it, emit one completion event per call (info on success; the existing warn carrying the tool name on handler-error); optional `debug!(method)` in `route` | ✅ #2042 |

## P4 — hard / low impact / already-tracked (issue, or inline-fix trivial)

| Priority | Category | Title | Location | Difficulty | Suggested fix | Status |
|----------|----------|-------|----------|------------|---------------|--------|
| P4 | resource-management | MCP bridge reads each stdin line unbounded before the 1 MiB cap (post-hoc `line.len() > MAX` check) — a no-newline multi-GB line OOMs the long-lived bridge before the check fires; the sibling daemon socket does the bounded `.take(cap+1)` read correctly (high severity, but a known fix pattern + new instance of the RM-V1.46.1-3/#1995 class) | src/cli/mcp/bridge.rs:42-65 | easy | Replace `stdin.lock().lines()` with a manual `read_line`/`read_until` over a reader wrapped in `.take(MAX_LINE_LENGTH+1)`, keeping the post-read `len > MAX` check as a backstop; add a regression test feeding >1 MiB no-newline input asserting bounded peak alloc / clean PARSE_ERROR. **Routes to issue or a fast fix-PR despite P4-by-impact-today** | ✅ #2042 (bounded read + test) |
| P4 | platform-behavior | `cqs mcp` is NOT unix-gated despite an in-code claim that it is — on the Windows release target it advertises the full tool set but every `tools/call` hits the non-unix stub and returns INTERNAL_ERROR; the false "unix-gated at the CLI layer" comment is itself a docs-lie | src/cli/mcp/tools.rs:614-622 vs src/cli/definitions.rs:510-511, dispatch_shims.rs:233-244, cqs-macros/src/lib.rs:340-345 | easy | Either add `#[cfg(unix)]` to the `Mcp` variant (macro forwards it → clean "unknown command" on Windows), OR make `serve_stdio` fail fast on non-unix and return an empty/marked `tools/list`; delete/correct the `:614-615` comment either way | ✅ #2042 (serve_stdio fail-fast) |
| P4 | extensibility / code-quality | MCP command surface is four hand-mirrored enumerations (`build_batch_cmd` dispatch, `read_tools`/`mutation_tools` schema table, `validate_arguments` precheck, `expected_read_commands` guard with hardcoded 19/23) — no derived source of truth; the name-only parity guard pins neither the per-command core *type* across the three type-maps nor `validate_arguments` completeness against the table (merged: 2× code-quality + extensibility + validate_arguments-completeness-unguarded). Same parallel-`*Args`-layers root as the carried-forward #1459 | src/cli/mcp/mod.rs:99-125,162,202 vs json_args.rs:151-347 vs tools.rs:146-403,520-558 | medium | Drive schema/validate/dispatch from one `(name, schema_fn, check_fn, build_fn)` table (const slice or macro); add a guard asserting (a) the type used by `validate_arguments` matches what `build_batch_cmd` deserializes per command, and (b) every `tool_table()` command has a matching `validate_arguments` arm; drop the hardcoded 19/23 for `expected.len()`. **Architecture call → issue; attach to #1459** | #2043 (filed — see note) |

### Folded into P3 rows (logged, no separate row)

The following low-severity observability/test-gap findings are real but ride existing rows or are sub-1-test riders — listed for completeness, not separately tiered:

- `daemon_json_args_request` `SocketMissing` arm has no `tracing::warn!` (lone N-1 sweep straggler; the bridge's most common failure logged nowhere) — `src/daemon_translate.rs:851-856`. **Rider on the P3 MCP-observability row** (one-line warn).
- Notes-mutation daemon handlers log no info on the successful write (span exists, outcome invisible; no parity with `dispatch_index`) — `src/cli/batch/handlers/misc.rs:209-244`. **Rider on the P3 MCP-observability row** (body-free `info!` carrying `core.status`/`core.sentiment`, not the note text).
- `tools/list` regenerates all ~19–23 inputSchemas via `schema_for!` per call instead of caching a static set — `src/cli/mcp/tools.rs:429-444`. **Rider on the P3 `success_result` perf row** (LazyLock the two snapshots; pure waste, per-session bound).
- MCP `tools/call` deserializes/re-walks the arguments object three times across the bridge+daemon round-trip (defensive precheck clone + daemon `parse_core` clone + `overlay_from_args` re-walk) — `tools.rs:522`, `json_args.rs:105,116`. **Rider on the P3 `success_result` perf row** (borrowing deserializer / share one parse).
- SECURITY.md attributes RT-RELAY scanning to #2033/#2034 instead of the real #2024 (transcription error vs CHANGELOG.md:27) — `SECURITY.md:84`. **Trivial inline fix** (change the citation; lands in any docs PR).
- MCP bridge 1 MiB oversized-line cap has no test (`bridge.rs:54-65`); `classify_output` non-slim-envelope fall-through (reports daemon output as `isError:false`, untested — masks a daemon failure on a non-standard shape) (`tools.rs:644-647`); JSON-RPC `ping` keepalive arm untested (`bridge.rs:112`). **Three one-test riders** — fold into the P4 unbounded-stdin-read PR's test backfill (same `tests/cli_mcp_bridge_test.rs` file); the `classify_output` masking case is the most load-bearing (force the safer `isError:true` choice for an unrecognized envelope carrying an `error` key).

## Carried forward (prior triage, still open)

| ID | Finding | Location | Status |
|----|---------|----------|--------|
| API-V1.46.1-1 → **#2043** | Two parallel `*Args` layers (clap wire vs Phase-0 core) with no exhaustiveness guard — now also the MCP four-hand-mirrored-enumeration (P4) + overlay-schema-drift rows. **#2038 partially mitigated** (derived `JSON_ARGS_CAPABLE_COMMANDS` single-source + name exhaustiveness test); the per-command *type*-map unification across schema/validate/build remains. **Re-filed as #2043** — the prior `#1459` attribution was wrong (#1459 is the unrelated API-ergonomics issue: project/ref/index + trait shape) | src/cli/commands/graph/impact.rs vs src/cli/args.rs (+ MCP `tools.rs`/`json_args.rs`) | open (#2043) |
| PB-V1.46.1-2 / #1512 | `is_wsl_drvfs_path` `cfg!(windows)` UNC branch is effectively dead — forward-slash literals vs backslash Windows paths; coarse-fs falls to ZERO on a native-Windows WSL-UNC share | src/config.rs:136-140 | open (#1512) |
