Skip to content

feat(server): wire the audit stream into every MCP request - #25

Merged
plusky merged 1 commit into
mainfrom
audit-wiring
Jul 28, 2026
Merged

feat(server): wire the audit stream into every MCP request#25
plusky merged 1 commit into
mainfrom
audit-wiring

Conversation

@plusky

@plusky plusky commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Wires the audit module into the MCP request path. With --audit-config / BUGWARDEN_AUDIT_CONFIG set, every tool call produces exactly one audit record — unknown tools, read-only-stripped tools, refusals and protocol errors included — persisted before the response is returned; initialize records a session-start event unconditionally.

What changed

  • #[tool_handler] is replaced by hand-written call_tool, list_tools and get_tool (verified textually equivalent to the rmcp 2.2 macro expansion, dispatching through the pruned instance router — I13). initialize replicates the default handler's peer bookkeeping and version negotiation, then records the session event.
  • An AuditCell in the request extensions lets the existing guard call sites enrich records with verdict (worst-wins merge), deciding rule, suppressed bug ids and redacted fields. Enrichment observes; it never changes what is served.
  • Fail-mode enforcement at the wrapper: a failing sink gates further work pre-dispatch (closed_all: every tool; closed_writes_denials: write tools) so an audit outage cannot accumulate unaudited mutations; post-failure refusals reuse each tool's existing uniform failure text and never vary with the guard's verdict. Defaults derive from the transport: stdio → open, http → closed_all; explicit config wins.
  • Params are recorded through a closed key allowlist (identifier/vocabulary fields only, strings capped at 1024 chars); non-allowlisted keys appear as byte lengths. The API key and free-text bug content remain unrepresentable in the event type (I12).
  • main.rs: audit config loading, transport-derived fail-mode resolution, policy_hash (sha256 of the policy file bytes) stamped into every record, a prominent warning when http runs without auditing, and connect-info wiring so records carry the remote address.
  • New invariant I15 plus an audit section in docs/DESIGN.md; examples/audit.toml wording updated.

Adversarial review

An independent adversarial review ran before this PR: line-by-line pass plus 23 gutting mutations, each expected to be killed by the suite. 20 were killed on the spot; the 3 survivors were all missing tests, not production defects, and are fixed here:

  • list_tools/get_tool were not test-pinned to the pruned instance router (the unpruned Self::tool_router() trap survived) → new listing/lookup tests over the duplex transport against read-only and disabled_tools servers.
  • policy_hash was computed inline in main.rs and unexecuted by tests → extracted to policy_hash_of, unit-bound to the FIPS 180-2 sha256("abc") vector.
  • The suppressed_ids = false counts-only knob was unpinned through the wrapper → new wiremock test asserting empty id array with a positive count.

Also from review: bug_history now notes scrubbed link ids like the other read tools, and the closed_writes_denials docs were tightened to match the code (suppressed, denied, or refused). All formerly surviving mutations were re-applied and confirmed killed in two independent passes. Accepted review notes: guard denials record outcome class ok (the denial lives in guard.verdict, per the schema docs), and quicksearch window drops are not recorded (the accounting lives inside bugwarden-core, which this change does not touch — documented at the site).

Tests

223 → 249, covering: one record per call for every routed tool; persisted-before-response; byte-identical client responses with auditing off / on / failing-open; denied ids in the log and never in the envelope (I14 tie-in); fail-closed scopes with an injected failing sink (pre-dispatch gate proven by absent upstream requests); transport-default binding; refusal-map totality over the full router; four-canary content test (API key, summary, comment body, attachment data never reach the file); initialize event identity; allowlist reduction and truncation.

All gates green: fmt, clippy -D warnings, full test suite, cargo-deny, typos.

Replace the macro-generated tool dispatch with hand-written call_tool,
list_tools and get_tool so that, when auditing is enabled, every tool
call produces exactly one audit record — unknown tools, stripped tools
and refusals included — persisted before the response is returned.
initialize records a session-start event unconditionally.

- An AuditCell in the request extensions lets the guard call sites
  enrich records with verdict, deciding rule, suppressed bug ids and
  redacted fields; enrichment observes and never changes what is
  served (client responses are byte-identical with auditing on, off
  or failing open — tested).
- Fail modes are enforced at the wrapper: a failing sink gates
  further work pre-dispatch, and refusals reuse each tool's existing
  uniform failure text, never varying with the guard's verdict.
  Defaults derive from the transport: stdio fails open, http
  closed_all.
- Request params are recorded through a closed key allowlist;
  free-text values appear as byte lengths only. The API key remains
  unrepresentable (I12).
- --audit-config / BUGWARDEN_AUDIT_CONFIG loads the audit
  configuration; the guard policy file is digested into every
  record's policy_hash; http without auditing warns at startup.
- New invariant I15 in docs/DESIGN.md.
@plusky
plusky merged commit 2673361 into main Jul 28, 2026
10 checks passed
@plusky
plusky deleted the audit-wiring branch July 28, 2026 12:57
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