Skip to content

fix(mcp): enforce audit fail-closed before running a tool#174

Merged
xunholy merged 1 commit into
mainfrom
fix/mcp-audit-fail-closed
Jun 30, 2026
Merged

fix(mcp): enforce audit fail-closed before running a tool#174
xunholy merged 1 commit into
mainfrom
fix/mcp-audit-fail-closed

Conversation

@xunholy

@xunholy xunholy commented Jun 30, 2026

Copy link
Copy Markdown
Owner

The MCP dispatch path gated on consentDecision (env opt-in) but never called audit.RequireOpen — the rail the REPL and --web both apply. Audit is wired best-effort; if audit.Open fails (locked WAL, unwritable ~/.promptzero, unset $HOME) the server runs with s.audit==nil and RecordCtx silently no-ops. So with ALLOW_HIGH/ALLOW_CRITICAL set AND audit failed to open, a High/Critical MCP tool ran UNAUDITED — a fail-open vs the documented 'every destructive action is audited' invariant, diverging from REPL/web. Adds audit.RequireOpen(s.audit, effectiveLevel) before consentDecision; risk≥High refused when no audit log, Low/Medium unaffected. Harness now wires audit by default; new fail-closed test proven to fail pre-fix, -race clean. task ci green; eval 17/17. Found via the internal audit sweep.

The MCP dispatch path gated tools on consentDecision (the env-driven
PROMPTZERO_MCP_ALLOW_HIGH / ALLOW_CRITICAL opt-in) but never called
audit.RequireOpen, the rail the REPL and --web dispatch both apply. Audit
logging is wired best-effort in wireMCPSidecars: if audit.Open fails (a locked
SQLite WAL from another promptzero process, an unwritable ~/.promptzero, an
unset $HOME), the server continues with s.audit == nil and the RecordCtx calls
silently no-op (they're nil-guarded).

Result: with the operator opted in via env AND the audit log failed to open, a
High/Critical MCP tool (an NFC / sub-GHz transmit, a run_payload deploy) ran
with NO audit record — a fail-open relative to the documented invariant that
every destructive action is audited, and a silent divergence from the REPL/web
surfaces where the identical scenario is refused.

Adds audit.RequireOpen(s.audit, effectiveLevel) before consentDecision, so MCP
fails closed identically to the agent path: risk≥High is refused when no audit
log is wired; Low/Medium tools are unaffected. The run_payload Critical
escalation is already applied to effectiveLevel before this gate, so a
path-resolved Critical can't slip through as High.

Found via the internal audit sweep (bootstrap + web-route surfaces, otherwise
clean).

Tests: newTestHarness now wires an audit log by default (production does, and
the new rail requires it for High/Critical) — tests exercising the nil-audit
refusal call s.SetAuditLog(nil). New TestServer_CallTool_AuditFailClosedWhenNoAuditLog
asserts a High tool with ALLOW_HIGH=1 but no audit log is refused with the
"audit log not initialized" message and the handler never runs; -race clean,
proven to fail pre-fix. The five consent matrix tests still pass.

Verified: task ci green (lint 0 / vet / build / test:full -race 0 fail /
govulncheck clean); task eval 17/17.
@xunholy xunholy merged commit b67b509 into main Jun 30, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant