feat(mcp): consent broker + per-call audit + coarse ACL (VS-23) #33

Merged
nrupard merged 3 commits from feat/mcp-consent-broker-acl-audit-VS-23 into main 2026-05-21 19:10:47 +02:00
Owner

What

Implements VS-23: turns the per-agent MCP gateway (VS-22) into the operator-facing consent point for mutating MCP tool calls. New mcp_consent module + gateway wiring; routes registered in build_router.

Read-only v1 traffic is unchanged: it never emits a control frame and falls straight through to the response path.

Acceptance criteria

  • Gateway recognizes consent_request from the agent, renders an operator-facing prompt, returns the decision (Allow / Allow-for-session / Deny / Deny-forever).
  • Server-side ACL per (operator, agent, tool-glob), admin-editable.
  • Per-call audit row for every mutating tool call; queryable by operator id, agent id, and time range.
  • Pre-approval policies skip the prompt without skipping the audit row.
  • Integration test: end-to-end run against a stub agent that requests consent; prompt routed, decision flows back, audit lands.
  • Existing v1 read-only MCP traffic continues to work unchanged.

Design decisions

  • Side-channel = dedicated control frame (per issue discussion). The agent sends {"vervain":"consent_request","call_id","tool","args"}; the "vervain" envelope key is namespaced so it can never collide with a JSON-RPC response. Gateway replies {"vervain":"consent_response","decision":"allow"|"deny"}.
  • Decision channel is a dedicated endpoint POST /agents/:agent_id/mcp/consent/:consent_id rather than threading through the 512KB SPA control-channel handler. The prompt is still pushed to the operator's live session(s) via the user-session registry so the UI can render a modal; the gateway POST blocks on a oneshot until the decision endpoint resolves it (same process, shared AppState). Keyed by a gateway-minted consent_id because the agent's call_id is operator-controlled and could collide across concurrent operators.
  • Fail closed: a consent prompt times out to Deny after 120s so a wedged prompt cannot pin the tunnel.
  • ACL precedence: deny > pre_approve > prompt. A matching deny short-circuits a tools/call before a tunnel is opened. allow is permissive but non-decisive (still prompts).
  • Audit privacy: args_digest is a SHA-256 over canonical args plus a redacted, length-capped (512B) summary; sensitive keys (password/token/secret/...) are masked. Raw args are never persisted.
  • Sticky decisions: AllowForSession caches an in-memory grant (1h TTL); DenyForever persists a deny ACL row.

New routes

  • POST /agents/:agent_id/mcp/consent/:consent_id - operator posts a decision.
  • GET|POST|DELETE /agents/:agent_id/mcp/acl - coarse ACL admin surface (admin only).
  • GET /agents/:agent_id/mcp/audit - per-call audit query (admin only).

Data model (doc store)

  • mcpacl docs: {operator, agent, tool_glob, effect}.
  • mcpaudit docs: {operator, agent, meshid, tool, args_digest, args_summary, decision, latency_ms, time}.

Tests

cargo test -p meshcentral-web (mcp_consent unit + mcp_gateway integration): 9 unit + 10 integration tests pass. cargo clippy --all-targets -- -D warnings and cargo fmt --check clean.

Follow-ups (out of scope here)

  • SPA modal UI wiring for the mcpconsentprompt frame (server contract is in place; frame shape documented).
  • Agent-side consent_request/consent_response emission (companion VS-VA / mcp-agent-tunnel-v2 issue). This PR is exercised end-to-end by a stub agent.
  • Audit retention / GC policy (issue suggests a 90-day default).
  • Persistent Mcp-Session-Id and server-to-client SSE remain deferred.

🤖 Generated with Claude Code

## What Implements VS-23: turns the per-agent MCP gateway (VS-22) into the operator-facing consent point for mutating MCP tool calls. New `mcp_consent` module + gateway wiring; routes registered in `build_router`. Read-only v1 traffic is unchanged: it never emits a control frame and falls straight through to the response path. ## Acceptance criteria - [x] Gateway recognizes `consent_request` from the agent, renders an operator-facing prompt, returns the decision (Allow / Allow-for-session / Deny / Deny-forever). - [x] Server-side ACL per (operator, agent, tool-glob), admin-editable. - [x] Per-call audit row for every mutating tool call; queryable by operator id, agent id, and time range. - [x] Pre-approval policies skip the prompt without skipping the audit row. - [x] Integration test: end-to-end run against a stub agent that requests consent; prompt routed, decision flows back, audit lands. - [x] Existing v1 read-only MCP traffic continues to work unchanged. ## Design decisions - **Side-channel = dedicated control frame** (per issue discussion). The agent sends `{"vervain":"consent_request","call_id","tool","args"}`; the `"vervain"` envelope key is namespaced so it can never collide with a JSON-RPC response. Gateway replies `{"vervain":"consent_response","decision":"allow"|"deny"}`. - **Decision channel** is a dedicated endpoint `POST /agents/:agent_id/mcp/consent/:consent_id` rather than threading through the 512KB SPA control-channel handler. The prompt is still pushed to the operator's live session(s) via the user-session registry so the UI can render a modal; the gateway POST blocks on a oneshot until the decision endpoint resolves it (same process, shared `AppState`). Keyed by a gateway-minted `consent_id` because the agent's `call_id` is operator-controlled and could collide across concurrent operators. - **Fail closed**: a consent prompt times out to Deny after 120s so a wedged prompt cannot pin the tunnel. - **ACL precedence**: `deny` > `pre_approve` > prompt. A matching `deny` short-circuits a `tools/call` before a tunnel is opened. `allow` is permissive but non-decisive (still prompts). - **Audit privacy**: `args_digest` is a SHA-256 over canonical args plus a redacted, length-capped (512B) summary; sensitive keys (password/token/secret/...) are masked. Raw args are never persisted. - **Sticky decisions**: `AllowForSession` caches an in-memory grant (1h TTL); `DenyForever` persists a `deny` ACL row. ## New routes - `POST /agents/:agent_id/mcp/consent/:consent_id` - operator posts a decision. - `GET|POST|DELETE /agents/:agent_id/mcp/acl` - coarse ACL admin surface (admin only). - `GET /agents/:agent_id/mcp/audit` - per-call audit query (admin only). ## Data model (doc store) - `mcpacl` docs: `{operator, agent, tool_glob, effect}`. - `mcpaudit` docs: `{operator, agent, meshid, tool, args_digest, args_summary, decision, latency_ms, time}`. ## Tests `cargo test -p meshcentral-web` (mcp_consent unit + mcp_gateway integration): 9 unit + 10 integration tests pass. `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` clean. ## Follow-ups (out of scope here) - SPA modal UI wiring for the `mcpconsentprompt` frame (server contract is in place; frame shape documented). - Agent-side `consent_request`/`consent_response` emission (companion VS-VA / mcp-agent-tunnel-v2 issue). This PR is exercised end-to-end by a stub agent. - Audit retention / GC policy (issue suggests a 90-day default). - Persistent `Mcp-Session-Id` and server-to-client SSE remain deferred. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(mcp): consent broker + per-call audit + coarse ACL (VS-23)
Some checks failed
Check / fmt + clippy + build + tests (pull_request) Failing after 3s
701e3762f8
Turn the per-agent MCP gateway (VS-22) into the operator-facing consent point for mutating tool calls. Adds a new `mcp_consent` module plus gateway wiring; existing read-only v1 traffic is unchanged because it never emits a control frame and falls straight through to the response path.

Consent broker: the agent emits a dedicated `{"vervain":"consent_request",...}` control frame before running a mutating tool. The `"vervain"` envelope key is namespaced so it cannot collide with a JSON-RPC response. The gateway intercepts the frame, decides (pre-approval / session cache / interactive prompt), pushes a prompt to the operator's live session(s), and relays `{"vervain":"consent_response","decision":"allow"|"deny"}` back to the agent. The interactive decision arrives out-of-band on POST /agents/:agent/mcp/consent/:consent_id, keyed by a gateway-minted consent id (the agent's call id is operator-controlled and may collide across operators). Fails closed to deny on a 120s timeout.

Coarse server-side ACL: per (operator, agent, tool-glob) `mcpacl` docs with effect deny|allow|pre_approve. Precedence deny > pre_approve > prompt. A matching deny short-circuits a tools/call before a tunnel is opened. Admin-only CRUD at /agents/:agent_id/mcp/acl (full site-admin or MeshRights::ADMIN on the agent's mesh).

Per-call audit: every mutating tool call (including coarse-denied and pre-approved) writes an `mcpaudit` doc {operator, agent, tool, args_digest, decision, latency_ms, ts}. args_digest is a SHA-256 plus a redacted, length-capped summary (sensitive keys masked) so raw args / secrets are never persisted. Queryable by operator, agent, and time range at /agents/:agent_id/mcp/audit.

Decision side effects: AllowForSession caches an in-memory grant (1h TTL) to suppress re-prompting; DenyForever persists a deny ACL row so future calls are coarse-filtered.

Tests: end-to-end stub-agent consent round-trip (prompt routed, operator decision flows back, audit lands), pre-approval skips prompt but still audits, coarse-deny blocks before tunnel, ACL admin CRUD round-trip + non-admin rejection, plus mcp_consent unit tests (glob, precedence, redaction, control-frame parse, registry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(mcp): address VS-23 consent broker review findings
Some checks failed
Check / fmt + clippy + build + tests (pull_request) Failing after 2s
8bb476481b
Hardening from code review of the consent broker; no behavior change for the happy path.

- Pending-entry leak: `ConsentRegistry::register` now returns a `PendingGuard` (RAII) that removes the consent id on drop, so a gateway task cancelled mid-prompt (client disconnect) no longer leaks the pending entry. Replaces the manual `cancel` call.
- Per-operator prompt cap: `register` returns `None` once an operator has MAX_PENDING_PER_OPERATOR (16) prompts outstanding; `prompt_operator` fails closed to deny. Stops one operator pinning many agent tunnels with un-answered mutating calls.
- Decision endpoint now cross-checks the agent id in the path against the pending entry's agent (resolve takes `agent`); a decision posted to the wrong agent's URL is 404, not silently resolved.
- session_allow cache is swept of expired grants on insert, not only lazily on read, so tuples that are never re-requested cannot grow it without bound.
- drive_session only treats a parseable JSON frame as the call's final response; stray non-JSON tunnel lines are dropped rather than returned to the client under a application/json header.
- Parse the request body once and reuse the value for coarse-ACL tool extraction (extract_tool_call now takes &Value) instead of re-parsing.
- deny-forever skips the ACL write when an exact-tool deny row already exists, avoiding duplicate mcpacl rows.
- Comments: document is_sensitive_key over-redaction intent and args_digest reliance on serde_json sorted-key default (preserve_order would break digest correlation).

Tests: add registry guard-drop, per-operator cap, and operator+agent resolve checks. All meshcentral-web mcp_consent unit + mcp_gateway integration tests pass; clippy --all-targets -D warnings and fmt --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
refactor(mcp): cap drained noise frames; rename digest var
Some checks failed
Check / fmt + clippy + build + tests (pull_request) Failing after 3s
Create release / Create release from merged PR (pull_request) Has been skipped
906fa0807b
Follow-up nits from review.

- drive_session caps the number of non-JSON "noise" frames it will drop per call at MAX_SKIPPED_FRAMES (1024); a wedged or hostile agent streaming endless garbage lines (each arriving inside RESPONSE_TIMEOUT, so the per-recv timeout never fires) now returns a bad-gateway error instead of keeping the POST alive indefinitely. Real agents emit no noise, so the bound never trips in practice.
- Rename the args_digest local `canonical` to `serialized` to match the corrected doc comment (the hash is over serde_json serialization, not a true canonicalization).

clippy --all-targets -D warnings and fmt --check clean; mcp_consent unit + mcp_gateway integration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nrupard deleted branch feat/mcp-consent-broker-acl-audit-VS-23 2026-05-21 19:10:47 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/vervain-server!33
No description provided.