fix(mcp): a denied tool call is audited as a refusal, not a success (#2807) - #2855
Merged
Merged
Conversation
…2807) Every access gate on the MCP tool surface RETURNS its denial as JSON (the envelope agents parse), and the audit wrapper labelled a call by throw/no-throw. So a refused chat_with_agent / chat_with_<slug> / fan_out / run_agent_loop call left an mcp_operation row reading `success: true`, and an operator reading the audit log could not tell a permitted call from a refused one. Reproduced live before the fix: the refused call's row was {"tool": "chat_with_agent", "duration_ms": 32, "success": true}. The deny sites now serialise through one helper, access.ts::accessDenied, which stamps `context.outcome = {kind: "denied", reason}` on the per-call tool context (the seam #905 already uses for requestId); withAudit reads it after execute and writes `success: false`, `denied: true`, `error: <reason>`. A thrown backend 403 is marked denied too. The caller's JSON is byte-identical. All 29 deny branches across 10 tool modules go through the helper, and a decision-based guard fails `npm test` for a `!allowed` branch or an `error: "Access denied"` envelope that bypasses it. createServer injects the audit URL and secret (configureAudit), so a test can observe the row. Tests: audit-denial.test.ts (captured audit POST, byte-identical envelopes, root-context-only stamp, stamp-then-throw, thrown 403, the guard); access-wiring.test.ts records /api/internal/audit over the real transport and runs deny-then-allow on one session; the J10 strict xfail test_the_operator_can_see_that_a_call_was_refused is flipped. Fixes #2807 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts: # docs/memory/learnings.md
Contributor
Author
|
First run: every check green except |
…ied-calls # Conflicts: # docs/memory/learnings.md # src/mcp-server/src/server.ts # tests/registry.json
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
success: true, becausewithAuditlabels a call by throw/no-throw and every gate on the tool surface returns its denial as JSON (the envelope agents parse). Reproduced live: the refusedchat_with_agentrow read{"tool":"chat_with_agent","duration_ms":32,"success":true}, so an operator could not tell a permitted call from a refused one.access.ts::accessDenied→context.outcome = {kind: "denied", reason}, the seam feat(mcp): expose git status/sync/log/pull as direct MCP tools (bypass LLM) #905 already uses forrequestId) andwithAuditreads it afterexecute:success: false,denied: true,error: <reason>. A thrown backend 403 is markeddeniedtoo. The caller's JSON is byte-identical.audit-denial.test.tsfails a!allowedbranch or anerror: "Access denied"envelope that bypasses it.createServerinjects the audit URL + secret (configureAudit) so the row is observable in-process and over the real transport.Changes
src/mcp-server/src/access.ts—accessDenied(context, envelope, auditReason?)+DenyCallContext; the ent#628withAgentAccessdenial uses it.src/mcp-server/src/audit.ts—ToolCallContext.outcome;configureAudit;withAudithandsexecutea defined context and reads the stamp on both exits;details.denied.src/mcp-server/src/types.ts—ToolOutcome.server.ts—internalApiSecretoption →configureAudit.src/mcp-server/src/tools/{chat,agents,a2a,a2a_call,executions,git,loops,operator_queue,reports,schedules}.ts— every deny site through the helper (envelopes unchanged, key order preserved); loop-id andget_reportrefusals pass the internal reason for the admin-only row.src/mcp-server/src/audit-denial.test.ts(new: captured audit POST, byte-identical envelopes, root-context-only stamp, stamp-then-throw, thrown 403, the guard),access-wiring.test.ts(+2 real-transport cases: the refusedrun_agent_looprow, deny→allow on one session),tests/journeys/test_j10_agent_calls_agent_journey.py(thestrict=Truexfail removed).audit-trail.md,agent-to-agent-collaboration.md,run-agent-loop.md,mcp-git-tools.md,architecture/mcp-server.md,requirements/security.md,learnings.md(+1 entry),tests/registry.json,docs/security-reports/cso-diff-2026-09-16-2807-denied-call-audit.*.Test Plan
cd src/mcp-server && npm run build(typecheck) +npm test: 423 tests, 0 failures locally (node --import tsx --test), re-run green after mergingdev.{"tool": "chat_with_agent", "duration_ms": 33, "success": false, "error": "Permission denied: …", "denied": true};pytest tests/journeys/test_j10_agent_calls_agent_journey.py -k "refused or without_permission"→ 5 passed.mcp-server-test(build + boot smoke + unit) andjourney-smoke(J10 runs credential-free on every PR; the flippedtest_the_operator_can_see_that_a_call_was_refusedis the operator-side proof).Journey Impact: extends: J10
Mutation: reverting the wrapper's read of the stamp (
const outcome = ctx.outcome→undefined, plus the catch-pathdenied) turns 10 of 17 tests red acrossaudit-denial.test.ts+access-wiring.test.ts(every labelled-row case, both transports); un-stamping one deny site (chat.tsrunAgentChatback to a bareJSON.stringify) turns 5 red including both guard rules; files restored byte-identical from scratch copies, control 17/17.Out of scope, registered in the trinity-dev debt inbox (2026-09-16): a tool that catches a backend error and RETURNS
{error}still auditssuccess: true(theoutcomeshape adds a kind for it, not a second field); id-addressed tools (loops,get_report, operator-queue item/respond) leavetarget_idempty; refusals are visible per row but/api/audit-logcannot filter them./review: 0 critical./cso --diff: 0 findings introduced (tenth consecutive clean diff audit; report indocs/security-reports/).Fixes #2807
🤖 Generated with Claude Code