Skip to content

fix(security): require the target tool's tier on call_tool_* dispatch, fail closed on unresolved tiers - #1223

Merged
Dumbris merged 10 commits into
mainfrom
claude/clever-swartz-00b7d6
Sep 8, 2026
Merged

fix(security): require the target tool's tier on call_tool_* dispatch, fail closed on unresolved tiers#1223
Dumbris merged 10 commits into
mainfrom
claude/clever-swartz-00b7d6

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 8, 2026

Copy link
Copy Markdown
Member

Part of Spec 105 (agent-token scope hardening) — FR-009 target-tier execution. Spec 105 is the acceptance contract on branch 105-agent-scope-hardening (not yet merged); the invariant was first stated as Spec 104 FR-016f, which the code comments cite.

Summary

The leak. The retrieve surface (call_tool_read / call_tool_write / call_tool_destructive) authorized an agent token only against the tier of the variant the caller selected, never against the tier of the target tool. Since the variant is the caller's choice, a {read} token could drive a write tool through call_tool_read on every configuration (intent validation does not reject a read variant on a write tool), and a destructive tool the same way whenever intent_declaration.strict_server_validation is off. Direct-name dispatch and nested code-execution calls already classified the target via lookupToolPermission; the retrieve surface did not.

The fix. handleCallToolVariant now resolves the target's annotation-derived tier through the same lookupToolPermission the other two paths use and refuses the call with the token-permission error (plus a blocked activity policy decision) when the token lacks that tier — in addition to the existing variant check, and before intent validation.

lookupToolPermission itself changes in two ways:

  • Fail closed. A tool the StateView has not discovered (unknown server, not yet discovered, no runtime) now classifies as destructive — the top of the cumulative permission ladder — instead of defaulting to read. A discovered tool that publishes no annotations still derives to read via DeriveCallWith (the existing annotation-defaulting rule).
  • Dead BM25 fallback removed. The index stores no annotations and a server:tool query returns no hits, so the fallback never resolved anything.

The found/not-found distinction is exposed through lookupToolAnnotationsFound (normalizing, for canonical-id callers) and lookupExactToolAnnotations (for callers that already hold a split (server, raw tool) pair); lookupToolAnnotations keeps its signature.

What changed

File Change
internal/server/mcp.go Target-tier gate in handleCallToolVariant (after the variant check, before intent validation). lookupToolAnnotationsFound (normalizing wrapper) over lookupExactToolAnnotations, which matches the StateView by the exact raw name only (review rounds 5 and 8: the legacy server:tool-prefixed alternative was first demoted to a fallback, then dropped — the StateView copies the upstream's tool.Name verbatim, so the alternative was live only when an upstream publishes a self-prefixed colliding name). The pair handleCallToolVariant / handleCallTool split is never re-normalized (round 7).
internal/server/mcp_code_execution.go lookupToolPermission: StateView-only, undiscovered → destructive, annotation-less discovered → read; BM25 fallback removed; contract documented. tierForAnnotations shared with the retrieve gate. policyRefusal, the jsruntime ToolAnnotationFunc bridge and the open-world trust read gate the exact pair (round 7).
internal/server/mcp_visibility.go (round 1) normalizeServerTool strips a :-prefix only when it equals the server name; a foreign ns: segment stays part of the raw tool name. (round 7) isExactToolCallable split out of isToolCallable; the tool-count loops over raw StateView / ListTools names and the visibility resolvers use the exact variant after their own single normalization (behaviour-preserving there).
internal/server/tool_gate.go (rounds 2–6, 9) lookupToolApproval reads the exact raw-name record and the record discovery still files under the collapsed name — including the empty collapsed key a trailing-colon raw name produces (round 5) — in one storage snapshot (round 6), and merges them: mergeApprovalRecords ranks locks unlocked < pending < changed (approvalLockRank, round 9) so the higher-ranked record is the base whichever key carries it, exact wins ties, Disabled is OR'd, stored records never mutated. evaluateToolGate (normalizing) / evaluateExactToolGate and isToolCallable / isExactToolCallable route through it.
internal/storage/manager.go, internal/storage/bbolt.go (round 6) GetToolApprovals(server, names...): several keys of one server under one Manager read lock and one BBolt View; missing keys are absent from the map; a corrupt record fails the whole read.
internal/storage/tool_approval_test.go (rounds 6–7) TestToolApprovalRecord_GetToolApprovals_OneSnapshot: keyed result, missing key absent, empty tool-name key readable, corrupt record fails the read, and a one-transaction oracle (bbolt Stats().TxN delta = 1; the per-key control reads 2).
internal/server/mcp_call_tool_target_tier_test.go New: target-tier gate, intent handling unchanged, registered-handler coverage, lookupToolPermission classification, undiscovered-tool fail-closed, plus the review-round tests listed below.
internal/server/mcp_call_tool_trim_test.go Existing interior-padding scope test uses a full-permission fixture (its no-runtime proxy cannot resolve a tier, so the target gate would now refuse before the scope gate under test).

No frontend, docs, config, REST or MCP surface changes (one new internal storage accessor). Tool-definition goldens under internal/server/testdata pass unregenerated. Final branch head: 1c1a8ae0e (10 commits on top of origin/main c93f79423).

Tests

All tests seed real tools into the live StateView (the production lookup path) and use createTestProxyWithRuntime:

  • TestCallToolRead_ReadOnlyToken_TargetTierEnforced{read} × {write, destructive} target × strict {on, off}: refused with Permission denied: token does not have …, never reaches dispatch (No client found absent), activity policy decision is blocked and attributes the block to the token.
  • TestCallToolRead_TokenHoldsTargetTier_IntentHandlingUnchanged — full token on a destructive target via call_tool_read: strict on → existing marked destructive mismatch; strict off → reaches dispatch.
  • TestCallToolVariants_RegisteredRetrieveModeHandlers_EnforceTargetTier — the handlers actually registered on the retrieve-mode server (callToolServer, which GetMCPServerForMode selects) and the default server refuse a {read,write} token for all three variants on a destructive target. (The call_tool_destructive cell is refused by the pre-existing variant gate and passes with the target-tier check removed — documented in the test comment; the tier gate is bitten by the other cells and the tests below.)
  • TestLookupToolPermission_UnresolvedMetadataIsDestructive — annotation-less discovered tool → read; destructive-annotated → destructive; undiscovered tool / unknown server → destructive.
  • TestCallToolRead_UndiscoveredTool_RequiresDestructiveTier — approved-but-undiscovered tool: {read} refused with the destructive permission error; full token still reaches dispatch.

Added during cross-model review (same file unless noted):

  • TestCallToolRead_NamespacedToolName_IsNotClassifiedAsItsSuffix{read} on a:ns:erase (read-tier erase and destructive ns:erase on one server) is refused as destructive, strict on/off, blocked activity record under tool_name: ns:erase.
  • TestToolGate_NamespacedToolName_KeysOnRawName — an approval record for erase does not admit ns:erase; normalizeServerTool table.
  • TestCallToolRead_TargetTierGate_UpstreamCallOracle — real in-process counting upstream: permission-disallowed cells produce 0 upstream invocations; positive controls dispatch under the exact raw names ['erase','ns:erase'].
  • TestToolGate_LegacyCollapsedApprovalRecord_StillGates — a pending/Disabled record filed under the collapsed name (as checkToolApprovals writes it) still gates a:ns:erase for a full-tier token, an IsAdmin() API-key context (auth.AdminContext()) and the no-auth-context path, each as its own cell.
  • TestToolGate_StaleExactApprovalCannotShadowCollapsedChange — drives the real rt.SetToolEnabled producer, then marks the collapsed record changed; the stale exact approved record can no longer shadow it.
  • TestToolGate_MergedApprovalRecordsKeepIndependentLocks — exact Disabled + collapsed changed (and mirror / tie) keep lockStatus and answer TOOL_QUARANTINED/tool_description_changed rather than a generic TOOL_BLOCKED; round 9 adds the both-locked cells (exact pending + collapsed changed, and mirror) asserting lockStatus=changed, the review evidence, the tool_description_changed response and the changed activity reason.
  • TestLookupToolPermission_ExactRawNameOutranksPrefixedAlternative (round 5, tightened in round 8) — a read-only StateView tool literally named a:ns:erase never resolves the dispatched pair (a, ns:erase) in either StateView order; with only the self-prefixed sibling present, found=false, the tier is destructive, the read-only call is refused before dispatch with zero upstream calls, and positive controls dispatch a:ns:erase under its own raw name.
  • TestToolGate_TrailingColonRawName_ReadsProducersEmptyKeyRecord (round 5) — a raw tool ns: is filed by the producer under the empty collapsed key a:; that pending lock now reaches the gate (TOOL_QUARANTINED, blocked event with tool_name: ns:) and a Disabled record reaches isToolCallable.
  • TestLookupToolApproval_ReadsBothKeysFromOneSnapshot (rounds 6–7) — none / exact-only / collapsed-only / both-merged / no-colon outcome table, the merge never writes back, and both keys are read in exactly one storage transaction (TxN delta 1; two per-key reads give 2, so reverting to per-key reads fails).
  • TestCallToolRead_ServerPrefixedRawName_IsNotReNormalized (round 7) — server a publishes destructive a:ns:erase and read-only ns:erase; {read} calling call_tool_read a:a:ns:erase is refused as destructive in all four strict × StateView-order cells with zero upstream calls and activity tool_name: a:ns:erase; positive controls dispatch raw ns:erase and a:ns:erase; the exact-name pending lock answers TOOL_QUARANTINED on the retrieve surface and in the sandbox gate.
  • internal/storage/tool_approval_test.go: TestToolApprovalRecord_GetToolApprovals_OneSnapshot (round 6, one-transaction oracle in round 7).

Gates run locally on every review round and on the final head 1c1a8ae0e (all green):

gofmt -l <touched files>                                              # clean
go build -o /dev/null ./cmd/mcpproxy                                  # ok
go build -tags server -o /dev/null ./cmd/mcpproxy                     # ok
go test -race -count=1 -skip 'E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint' ./internal/server/...   # ok (234s)
go test -race -count=1 ./internal/storage/...                         # ok
/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml ./internal/server/... ./internal/storage/...   # 0 issues

Verification

Live before/after on an isolated instance (127.0.0.1:18211, --data-dir + --config, personal edition, enable_web_ui:false, quarantine_enabled:false). One stdio upstream annot = a dependency-free Node MCP server exposing three annotated tools and appending every tools/call it receives to upstream_calls.log:

tool readOnlyHint destructiveHint derived tier
read_thing true false read
write_thing false false write
delete_thing false true destructive

Tokens minted via POST /api/v1/tokens/: readonly = {allowed_servers:["annot"], permissions:["read"]}, full = {read,write,destructive}. Each call = initializenotifications/initializedtools/call on POST /mcp with Authorization: Bearer <agent token>:

curl -s -X POST http://127.0.0.1:18211/mcp -H "Authorization: Bearer $TOK" -H "Mcp-Session-Id: $SID" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"<VARIANT>","arguments":{"name":"annot:<TOOL>","args":{"id":"x"}}}}'

Before — origin/main, {read} token

# strict variant → tool response upstream executed?
B1 on call_tool_readdelete_thing Tool 'annot:delete_thing' is marked destructive by server, use call_tool_destructive no (intent validation, not permissions)
B3 on call_tool_readwrite_thing UPSTREAM EXECUTED write_thing {"id":"b3"} YES — leak
B1s off call_tool_readdelete_thing UPSTREAM EXECUTED delete_thing {"id":"b1s"} YES — leak
B3s off call_tool_readwrite_thing UPSTREAM EXECUTED write_thing {"id":"b3s"} YES — leak
2026-09-08T11:10:46.299Z tools/call {"name":"delete_thing","arguments":{"id":"b1s"}}
2026-09-08T11:10:46.667Z tools/call {"name":"write_thing","arguments":{"id":"b3s"}}

The activity log recorded these as plain tool_call/success with no policy decision.

After — branch @ ecf599d59 (round-4 build), {read} token

# strict variant → tool response upstream executed?
A1 / A1s on / off call_tool_readdelete_thing Permission denied: token does not have 'destructive' permission required for tool 'annot:delete_thing' no
A3 / A3s on / off call_tool_readwrite_thing Permission denied: token does not have 'write' permission required for tool 'annot:write_thing' no
A2 / A2s on / off call_tool_writedelete_thing Insufficient permissions: 'call_tool_write' requires 'write' permission (existing variant gate, body identical to baseline) no
A4 / A4s on / off call_tool_readread_thing UPSTREAM EXECUTED read_thing yes (correct)
A8 on call_tool_readnonexistent_tool (undiscovered) Permission denied: token does not have 'destructive' permission required for tool 'annot:nonexistent_tool' no (fail closed)
A13 / A13s on / off call_tool_readns:read_thing (foreign ns: prefix, undiscovered) Permission denied: … 'destructive' … 'annot:ns:read_thing' no — classified by its raw name, not collapsed to the read-tier read_thing

Every refusal is recorded as policy_decision / blocked with the reason above and the raw dispatched tool name. upstream_calls.log contains no delete_thing / write_thing entry from the read token in either strict mode.

Positive controls — branch, {read,write,destructive} token

# strict variant → tool response upstream executed?
A5 on call_tool_destructivedelete_thing UPSTREAM EXECUTED delete_thing yes
A6 on call_tool_writewrite_thing UPSTREAM EXECUTED write_thing yes
A7 on call_tool_readdelete_thing Tool 'annot:delete_thing' is marked destructive by server, use call_tool_destructive no (intent validation still runs after the tier gate)
A7s off call_tool_readdelete_thing UPSTREAM EXECUTED delete_thing yes (warning-only mode preserved; token holds the tier)
A9s off admin X-API-Key as Bearer, call_tool_readdelete_thing UPSTREAM EXECUTED delete_thing yes (administrator exception)
A14 on call_tool_readns:read_thing UPSTREAM EXECUTED ns:read_thing yes — dispatched under the raw name (see follow-ups)

upstream_calls.log for the strict-on run held exactly the four admitted calls (A4, A5, A6, A14) and nothing else.

Verdict. Baseline: a {read} token executes a write-annotated tool via call_tool_read on every config and a destructive-annotated tool when strict_server_validation is off. Branch: both refused before dispatch with zero upstream calls and a blocked policy-decision record; read → read, every full-token control and the admin path dispatch unchanged; undiscovered tools fail closed as destructive; a foreign-prefixed raw name is classified by itself, not by its suffix.

Not exercised live: the exact-vs-collapsed approval-record merge (rounds 2–6, 9), the prefixed / self-prefixed raw-name collisions (rounds 5, 7, 8) and the trailing-colon empty key (round 5) need an upstream that publishes such names and/or a stored approval record; the annot fixture has none of them, so the five post-ecf599d59 commits are covered by the unit tests only. None of them touch the annot-shaped paths above (bare names, no records), and the CI unit job re-runs the full suite on the final head.

Cross-model review

Reviewer: opencode with github-copilot/gpt-6-astra, 10 rounds against the full diff + Spec 105 (rounds 1–9 FINDINGS, round 10 CLEAN — the per-PR cap is 10 and it was not exceeded). Each finding was reproduced before it was fixed; each fix round re-ran the CI unit invocation (go test -race -skip 'E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint' ./internal/server/..., plus ./internal/storage/... from round 6), golangci-lint v2 (0 issues) and both edition builds before pushing. An earlier round-5 attempt was cut off by the harness before it wrote a verdict (the worktree was removed by another session mid-task and recreated at ecf599d59); it was re-run and the row below is the real round 5.

Round Verdict Finding Disposition
1 FINDINGS R1-1 (P1) normalizeServerTool stripped any first : segment, so {read} on a:ns:erase classified as read-tier erase Fixed (5c5aeec3f): strip only when the prefix equals the server name; isToolCallable routed through the same helper; two tests added
1 R1-2 (P2) unresolved metadata maps to grantable destructive rather than refusing every scoped caller Declined — deliberate, documented, strictly tighter than main (was read); a hard refusal is a cross-surface change (jsruntime ToolAnnotationFunc, nested calls) → follow-up
1 R1-3 (P2) tier gate, intent validation and approval gate take independent StateView/storage reads Partially fixed: one lookupToolAnnotationsFound read now feeds both the tier gate and intent validation (tierForAnnotations); binding the later approval-gate read to the same generation → follow-up
1 R1-4 (P3) no upstream-call / dispatched-name oracle Fixed: TestCallToolRead_TargetTierGate_UpstreamCallOracle (counting in-process upstream, 0 calls on refusal, exact raw names on dispatch)
2 FINDINGS R2-1 (P1) regression from R1-1: producers (checkToolApprovals) still file records under the collapsed name, so the now-exact readers missed a pending ns:erase record and fell to implicit-approved Fixed (2eddbcbe9): lookupToolApproval reads the exact record and falls back to the collapsed key; both readers use it; test seeds the record as the producer writes it and was shown to fail on the branch / pass on main's reader
2 R2-2 (P3) raw tool literally named <server>:x is ambiguous with the indexing prefix Declined — pre-existing on main, verbatim; needs an exact-identity carrier from discovery → follow-up (the dispatch half was later closed by R7-1; the discovery half remains)
3 FINDINGS R3-1 (P1) exact-first precedence let a stale exact approved record (synthesised by the enable/disable toggle) shadow a newer collapsed changed record Fixed (d58092934): read both records, return the more restrictive; test drives the real rt.SetToolEnabled producer
4 FINDINGS R4-1 (P2) collapsed approved record admits ns:erase with no exact record Declined — not a regression; today the collapsed record is ns:erase's own baseline (ApproveTools only mutates existing records), so refusing it would make every namespaced tool un-approvable until the producer is made exact-name → follow-up
4 R4-2 (P2) returning one record lost the other's lock: exact Disabled + collapsed changed answered generic TOOL_BLOCKED with lockStatus "" Fixed (ecf599d59): mergeApprovalRecords — lock-carrying record is the base, Disabled OR'd, stored records never mutated; test covers both directions and the tie
4 R4-3 (P3) test-vacuity audit: gate is bitten by 5 named tests; compatibility tests pass with or without the guard by design Informational, no action required
5 FINDINGS R5-1 (P2, reviewer P1) lookupToolAnnotationsFound still accepted the legacy server:tool-prefixed spelling, so a raw StateView tool literally named a:ns:erase (read-only) matched the dispatched pair (a, ns:erase) (destructive) order-dependently; tier gate and intent validation then classified ns:erase as read while dispatch targeted ns:erase; lookupToolPermission (nested) shared it Fixed (829f3b1e6): confirmed by a new test failing in the "prefixed listed first" order (a {read} token reached dispatch); the exact raw name now always wins and the prefixed spelling became a fallback only when no exact tool exists (dropped entirely in round 8); TestLookupToolPermission_ExactRawNameOutranksPrefixedAlternative covers both StateView orders end-to-end
5 R5-2 (P3) trailing-colon raw name a:ns: lost the pre-diff fail-closed answer: lookupToolApproval skipped the empty collapsed key, so the pair was implicitly approved for full-tier / admin callers Fixed (829f3b1e6), and sharper than reported: the producer files ns: under (server, "") and storage accepts the key a:, so a pending lock the producer wrote was never read. lookupToolApproval now reads and merges the empty collapsed key; TestToolGate_TrailingColonRawName_ReadsProducersEmptyKeyRecord failed before / passes after. The remainder (an undiscovered ns: with no record is implicitly approved for full-tier / admin callers) is the general unresolved-identity policy already in the follow-ups
6 FINDINGS R6-1 (P3, reviewer P2) lookupToolApproval read the exact and collapsed records in two independent storage reads (each Manager.GetToolApproval takes its own RLock), so a merged view could combine two states that never coexisted (exact read approved+enabled, then operator disables exact and approves collapsed before the second read → merge yields approved+enabled); introduced by the diff, not a Go data race Fixed (5334e7fe8): confirmed by a throwaway 3 s stress probe (4 readers, writer cycling blocked-only states) that observed 1 torn read in 585 on the pre-fix code and 0 in 1136–1200 reads × 3 runs after. New Manager.GetToolApprovals / BoltDB.GetToolApprovals read several keys under one read lock and one BBolt View; lookupToolApproval consumes that single snapshot and maps absence of both records to ErrToolApprovalNotFound so the implicit-approved branch is unchanged. Tests in internal/storage/tool_approval_test.go and TestLookupToolApproval_ReadsBothKeysFromOneSnapshot. Honest note: the interleaving cannot be reproduced deterministically without a test hook (writer-preferring mutex, ~10 ms fsync commits); the behavioural fails-before evidence is the stress probe, kept uncommitted under .review-tmp/
7 FINDINGS R7-1 (P2, reviewer P1) the R5-1 fix held only after normalizeServerTool, but lookupToolAnnotationsFound and evaluateToolGate re-normalized a pair handleCallToolVariant had already split: a raw name starting with its own server's prefix (a:ns:erase on a) was classified, tier-gated, intent-validated and approval-gated as (a, ns:erase) while dispatch targeted a:ns:erase; on main the strict-on / destructive-first cell was refused by intent validation, so one cell regressed Fixed (fa18266f2): confirmed by TestCallToolRead_ServerPrefixedRawName_IsNotReNormalized failing in all four strict × order cells (counting upstream recorded the call; policyRefusal admitted a pending exact record). lookupToolAnnotationsFound / evaluateToolGate / isToolCallable split into a normalizing wrapper for canonical-id callers plus an exact variant (lookupExactToolAnnotations, evaluateExactToolGate, isExactToolCallable) that never re-normalizes; every split-pair caller switched to it (retrieve tier read + gate, direct gate, policyRefusal, jsruntime bridge, open-world trust read, tool-count loops, visibility resolvers after their own single normalization). normalizeServerTool unchanged so index-derived callers keep resolving. describe_tool (visibility-only, identical on main) still normalizes once → follow-up
7 R7-2 (P3) the single-snapshot tests never interleaved a write, so two independent per-key reads (with the merge kept) still passed — vacuous as a regression guard for R6-1 Fixed (fa18266f2): no new seam needed — bbolt Stats().TxN counts read transactions and Manager.GetDB() is exported; both tests now assert the read costs exactly one transaction (per-key control = 2), stable under -count=10 -race
7 R7-3 (P3) the "api-key admin" cells were context.Background() (nil-auth path), not an IsAdmin() API-key context; the label overstated what was exercised Fixed (fa18266f2): those cells now carry auth.WithAuthContext(ctx, auth.AdminContext()) and the nil-auth path keeps its own honestly labelled "no auth context" cell in all three tables; test fidelity only
8 FINDINGS R8-1 (P2, reviewer P1) the prefixed-spelling fallback kept by R5-1 still authorized a different raw tool when the dispatched name was absent: server a holding read-only a:ns:erase and no ns:erase made lookupExactToolAnnotations("a","ns:erase") report found/read, bypassing even the fail-closed destructive default; {read} via call_tool_read a:ns:erase passed the tier gate and dispatch targeted raw ns:erase; the nested bridge inherited it Fixed (a61ff044c): reproduced (a {read} token reached dispatch; upstream answered "tool 'ns:erase' not found"). Verified the alternative is dead on the normal path (supervisor.go:880 and upstream/core/client.go:383 copy the upstream tool.Name verbatim, so the StateView never holds a self-prefixed name unless the upstream publishes one); lookupExactToolAnnotations now matches only the exact raw name and the subtest pinning the fallback was replaced by "undiscovered raw name is not resolved through a self-prefixed sibling" (found=false, tier destructive, refused with zero upstream calls, positive controls dispatch a:ns:erase exactly). Failed before on three assertions, passes after
9 FINDINGS R9-1 (P3, reviewer P2) toolVisibleToSession re-normalizes the pair resolveDescribeDefinition already split, so describe_tool id a:a:ns:erase tests index presence on raw a:ns:erase but gates the suffix ns:erase; suggestCanonicalToolID passes an already-normalized name into the same function Declined — the normalizeServerTool call at mcp_visibility.go:58 is byte-identical to origin/main (git show), where it normalized more aggressively, so the diff narrows the mismatch; describe-only discovery surface, the corrected dispatch gates refuse the tool, no execution bypass; already the "discovery identity for self-prefixed raw names" follow-up
9 R9-2 (P3, reviewer P2) directCallabilityEvaluator.getToolApproval (mcp_direct_callability.go:253) and preflightApprovalReader.ToolApproval (preflight_glue.go:404) still read only the exact key, so exact-approved + collapsed-changed is refused by retrieve/nested but admitted by direct dispatch and reported ready by preflight Declined — pre-existing: both call sites are byte-identical to origin/main and neither file is in the diff; already a declared follow-up ("Direct-name dispatch and preflight keep exact-only approval lookup"); the in-code "same primitive as every other dispatch path" comment (mcp.go:2896) refers to the Spec 098 policy gates, not lookupToolApproval; widening the reader change to the direct and preflight surfaces would change behaviour on paths the diff does not touch
9 R9-3 (P3, reviewer P2) mergeApprovalRecords took the exact record as base whenever it was locked, so exact ns:erase pending + collapsed erase changed reported pending and dropped the changed record's previous/current description and hashes (review evidence and activity reason degraded; execution stayed blocked); no both-locked test cell Fixed (1c1a8ae0e): reproduced with a new both-locked cell (lockStatus=pending, empty CurrentDescription/CurrentHash, new_unapproved_tool with current_description:""). approvalLockRank (unlocked < pending < changed) picks the higher-ranked record as the base whichever key carries it; exact still wins no-lock / same-lock ties; Disabled OR and copy semantics unchanged; dead approvalLocked helper removed. Two both-locked cells added (first failed before, both pass after). Precondition is unreachable with today's producers (discovery collapses every lock to the suffix key; toggle/approve/block write only approved), so this is reader hardening pinned for exact-name producers
10 CLEAN No findings. The reviewer restated the FR-009 spec gaps below (all previously declined or carried as follow-ups; no new evidence)

Follow-ups / Spec 105 gaps

Reviewer spec gaps carried forward (not implemented here by design — the PR ships the retrieve-surface tier gate, the fail-closed default and the conservative reader rule for approval records; tool_gate.go marks the merged reader as the temporary rule until the producers are made exact-name):

  • Producer-side exact-name identity (FR-009 end-to-end, SC-005). checkToolApprovals (internal/runtime/tool_quarantine.go:443-460, keyed via extractToolName at lifecycle.go:975) files pending/changed/baseline records under everything after the first colon, and lifecycle.go:699-719 differential-index maps collapse names the same way, so erase and ns:erase on one server still merge into one identity through approval-record creation, baseline capture, index updates and direct publication. Fix the producers, migrate legacy collapsed records conservatively (a legacy record approves only the raw name it stores; ns:erase stays pending until approved under its own name — today lookupToolApproval returns a collapsed-only approved record for the namespaced target when no exact record exists, tool_gate.go:220-221, R4-1), then make "no record under either key for a tool the discovery snapshot contains" non-callable (evaluateExactToolGate keeps the implicit-approved default on ErrToolApprovalNotFound at tool_gate.go:124-125 without consulting the snapshot).
  • Legacy approval inheritance at the producer is fail-open: with an approved baseline for raw erase, a later-discovered ns:erase with identical description/schema gets no separate pending record (hash-match branch, tool_quarantine.go:~610-647).
  • Unresolved / stale identity MUST refuse scoped callers (FR-009 / US2.5 / SC-002): tierForAnnotations(found=false) (mcp_code_execution.go:1196-1199) maps to the grantable destructive tier, which a {read,write,destructive} scoped token still passes (R1-2, seen live as A14 driving an undiscovered ns:read_thing through call_tool_read; TestCallToolRead_UndiscoveredTool_RequiresDestructiveTier pins the narrower behaviour on purpose); the same policy admits an undiscovered ns: with no record for full-tier / admin callers (R5-2 remainder). found=true comes from name presence alone — nothing proves the StateView entry is fresh, so existing-but-stale metadata is not distinguished from current. Needs a shared not-found / stale sentinel across retrieve, direct and nested dispatch.
  • Bind the approval gate to the same StateView generation as the tier/intent read (R1-3 remainder); no stale-resolution detection between the tier check and evaluateExactToolGate at dispatch.
  • Direct-name dispatch and preflight keep exact-only approval lookup (mcp_direct_callability.go:253 feeding the registered direct handler at mcp_routing.go:465, and preflight_glue.go:404, read storage.GetToolApproval directly, not lookupToolApproval; both byte-identical to origin/main, R9-2) and mcp_routing.go:441-445 still defaults an unresolved annotation to read. Consequence: exact-approved + collapsed-changed/pending/Disabled is refused by retrieve and nested dispatch but admitted by direct-name dispatch and reported ready by preflight. FR-009 requires every dispatch path to evaluate the same pair.
  • Discovery-surface identity for self-prefixed raw names (R2-2 / R9-1, pre-existing). Dispatch now gates the exact split pair (R7-1), but the discovery surfaces still re-normalize an already-split pair: toolVisibleToSession (mcp_visibility.go:58, reached by resolveDescribeDefinition so describe_tool a:a:ns:erase resolves as (a, ns:erase), and by suggestCanonicalToolID), classifyServerToolStatus (mcp.go:6355 → normalizing evaluateToolGate), and the empty-ServerName fallbacks in mcp_entry_builder.go:143-156 / mcp.go:1855-1863 that split on the first colon. Status / count / annotation surfaces only, no execution bypass.
  • FR-009 acceptance table (a): the 54-cell {read},{read,write},{read,write,destructive} × read/write/destructive target × three variants × strict on/off matrix as one table-driven fixture with per-cell classification (the branch samples it); write-target refusals with strict on, strict intent-mismatch cells and unresolved-metadata cells still lack the zero-upstream-call oracle; missing cells: a {read,destructive}-without-write token and same-tier config-denied pairs (erase allowed, ns:erase config-denied — only approval-record variants are covered on the retrieve surface).
  • FR-009 acceptance table (b): direct-name dispatch and nested call_tool / call_tools script matrix with zero-upstream-call oracles, including the US2.6 envelope (outer script succeeds while the nested refusal shows insufficient permission) and same-tier config-denied / unapproved paired-name calls on both paths. The nested-path tests on this branch call lookupToolPermission / policyRefusal directly rather than driving the script runtime's refusal envelope.
  • FR-009 real-discovery fixture: manual-trust server with a baseline, then add read-tier ns:erase; assert it is pending under its own name and that direct, retrieve and nested dispatch all refuse with zero upstream calls (current tests seed discovery-shaped approval records by hand; only the toggle producer is real, and the counting-upstream oracle seeds StateView/approvals manually rather than running discovery).
  • SC-005 administrator fixtures: the admin cells now drive an explicit IsAdmin() API-key context (R7-3) but exercise refusal / lock parity only; no unit-level positive administrator dispatch control through the new gate (live A9s only), no fixture proving both erase and ns:erase survive real discovery and indexing, and no before/after administrator response-parity comparison.
  • HasPermission is exact-match, not hierarchical: a token minted as [read, destructive] without write passes the target gate for a write tool while call_tool_write refuses it. Documented in the code comment; real tokens are minted cumulatively.
  • Document the annotation-defaulting rule for users: an annotation-less discovered tool derives to read via DeriveCallWith (FR-009 "that rule is documented"); today it lives in code comments only, not in the user-facing docs.

Notes

  • Branch was fast-forwarded onto origin/main (c93f79423) before the first commit; no conflicts.
  • This branch does not touch internal/server/profile_tool.go. The two profile-related Spec 105 branches (claude/eager-kirch-10f6c2 and claude/xenodochial-lumiere-5abe75) both edit that file and will conflict with each other, not with this PR.

…, fail closed on unresolved tiers

The retrieve surface (call_tool_read / call_tool_write /
call_tool_destructive) authorized an agent token only against the tier of
the VARIANT the caller selected, never against the tier of the TARGET
tool. Because the variant is the caller's choice, a read-only token could
drive a write tool through call_tool_read on every configuration (intent
validation does not reject a read variant on a write tool), and a
destructive tool the same way whenever strict server validation is off.
Direct-name dispatch and nested code-execution calls already classified
the target through lookupToolPermission; the retrieve surface did not.

handleCallToolVariant now resolves the target's annotation-derived tier
through the same lookupToolPermission the other two paths use and refuses
the call with the token-permission error (and a "blocked" activity policy
decision) when the token lacks that tier, in addition to the existing
variant check and before intent validation.

lookupToolPermission itself changes in two ways. A tool the StateView has
not discovered (unknown server, not yet discovered, no runtime) now
classifies as destructive, the top of the cumulative permission ladder,
instead of defaulting to read: an unresolvable identity must not be
authorized as read for a scoped caller. A discovered tool that publishes
no annotations still derives to read via DeriveCallWith. The BM25 index
fallback is removed: the index stores no annotations and a "server:tool"
query returns no hits, so it never resolved anything. The found/not-found
distinction is exposed through the new lookupToolAnnotationsFound; the
existing lookupToolAnnotations keeps its signature.

Tests (internal/server/mcp_call_tool_target_tier_test.go) seed real
tools into the live StateView and cover: a {read} token refused on write
and destructive targets through call_tool_read with strict validation on
and off, with the activity record attributing the block to the token;
a token holding the target tier keeping today's intent handling (strict
on -> mismatch, strict off -> reaches dispatch); the handlers actually
registered on the retrieve-mode and default servers enforcing the gate
for all three variants; lookupToolPermission returning read for an
annotation-less discovered tool and destructive for an undiscovered one;
and a {read} token refused on an undiscovered tool while a full token
still reaches dispatch. The existing interior-padding scope test now uses
a full-permission fixture because its no-runtime proxy cannot resolve a
tier.

This is Spec 105 FR-009 (target-tier execution), the acceptance contract
on branch 105-agent-scope-hardening, not yet merged; the invariant was
first stated as Spec 104 FR-016f, which the code comments reference.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 8, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1c1a8ae
Status: ✅  Deploy successful!
Preview URL: https://fa6879c8.mcpproxy-docs.pages.dev
Branch Preview URL: https://claude-clever-swartz-00b7d6.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Sep 8, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 98.07692% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/server/mcp.go 92.59% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: claude/clever-swartz-00b7d6

Available Artifacts

  • archive-darwin-amd64 (29 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (16 MB)
  • archive-windows-amd64 (29 MB)
  • archive-windows-arm64 (26 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (23 MB)
  • installer-dmg-darwin-arm64 (21 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 34243455301 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

… name, share one StateView read across the tier and intent gates

Round-1 cross-model review of the target-tier gate (Spec 105 FR-009).

R1-1 (P1, fixed): normalizeServerTool stripped the first ":"-segment of a
raw tool name even when it was not the server's own indexing prefix, so on
server "a" holding read-tier "erase" and destructive "ns:erase", a {read}
token calling "a:ns:erase" via call_tool_read was classified — and
approval/config-gated — as "erase" while dispatch kept the full raw name.
The prefix is now stripped only when the server name is unknown or the
prefix IS the server name; isToolCallable's inline copy of the old rule is
routed through the same helper so every gate keys on the same pair.

R1-3 (P2, partial): handleCallToolVariant now takes one
lookupToolAnnotationsFound read and derives both the target tier
(tierForAnnotations, shared with lookupToolPermission) and the annotations
intent validation checks, so those two gates cannot disagree about which
registration they evaluated. Binding the approval gate to the same
snapshot remains a follow-up.

R1-4 (P3): new counting in-process upstream oracle — permission-disallowed
cells make zero upstream calls; positive controls reach the upstream
exactly once under the exact raw name ("ns:erase" is dispatched
uncollapsed). Paired-name and raw-name approval-gate regression tests added.
…d tool name

Round-1 made normalizeServerTool keep a foreign "ns:" segment as part of the
raw tool name so tier classification, config denial and approval all key on
the exact identity that is dispatched. Discovery, however, still files every
approval record under the COLLAPSED name (runtime.checkToolApprovals keys by
extractToolName(tool.Name), everything after the first colon), so a pending
or user-disabled "ns:erase" had its record under "erase" and the exact-name
readers (evaluateToolGate, isToolCallable) found nothing and fell to the
implicit-approved default — reachable by full-tier agent tokens and by API-key
administrators, who correctly skip the tier gate.

Add lookupToolApproval: the exact raw name is authoritative; when it has no
record and carries a ":" segment, read the record under the producer's
collapsed key. Both readers go through it. Making the producer exact-name
(and the lifecycle diff maps / baseline capture) stays a Spec 105 FR-009
follow-up.

Regression test seeds the record exactly as checkToolApprovals writes it and
asserts the pending lock and the Disabled flag still gate the raw name for a
full-tier token and for an administrator, with zero dispatch, and that an
exact-name record outranks the collapsed one.
…pproval records

lookupToolApproval let an exact-name record outrank the collapsed one. The
two approval producers never see each other's record: discovery
(checkToolApprovals) keys pending/changed/baseline under the collapsed
name ("erase" for raw "ns:erase"), while the user toggle
(setToolEnabledNoEmit, reached from the tools REST endpoint and the Web UI
with the raw StateView name) synthesizes an approved record under the
exact name. So disable + re-enable of a namespaced tool left a stale exact
approval that shadowed the "changed" mark the rug-pull detector later
wrote under the collapsed key, and the retrieve surface and nested
execution dispatched the rug-pulled tool for any caller holding its tier,
administrators included. Before the exact-name reader the collapsed
record was always the one read.

The reader now reads both records and returns the more restrictive one
(user-disabled > pending/changed lock > approved; exact wins ties), so
neither producer can fail the other open until both are made exact-name.

Tests: a regression that drives the real SetToolEnabled producer through
the sequence and asserts the changed lock refuses a full-tier token and an
API-key admin with a blocked policy decision; the exact-precedence case is
replaced by the four precedence pairings.
…f picking one

lookupToolApproval returned whichever of the two records ranked more
restrictive, which dropped the other record's independent fact: a tool
the user disabled under its exact name and the rug-pull detector marked
changed under the collapsed name lost its changed lock, so evaluateToolGate
computed an empty lockStatus and every dispatch path answered with the
generic TOOL_BLOCKED instead of the TOOL_QUARANTINED review response the
toolGate.lockStatus contract preserves for user-disabled tools. The mirror
case (exact pending, collapsed disabled) lost the pending lock the same way.

The reader now merges the two: the record carrying the pending/changed
lock is the base (its status and review evidence are surfaced), the
Disabled flag is OR'd across both, and the result is a copy. Callability
is unchanged (the user block still outranks the lock in ClassifyTool);
only the refusal shape and gate.lockStatus regain the lost information.

New TestToolGate_MergedApprovalRecordsKeepIndependentLocks covers both
directions plus the both-disabled tie, asserting lockStatus, the merged
Disabled flag, the class, and the dispatch response for an admin and a
full-tier token.
…ling, and read the empty collapsed approval key

Round-1 review of the target-tier gate (Spec 105 FR-009) found two shapes
the exact-identity readers still got wrong.

lookupToolAnnotationsFound accepted the raw name and the legacy
"server:tool"-prefixed spelling in one pass, so with foreign prefixes now
preserved a raw tool literally named "a:ns:erase" (read-only) could
satisfy the prefixed spelling for the dispatched pair (a, "ns:erase")
(destructive) — and which one classified the call depended on StateView
order. The exact raw name now always wins; the prefixed spelling is a
fallback only when no exact tool exists. lookupToolPermission (nested
code_execution) shares the lookup and is fixed with it.

lookupToolApproval skipped the collapsed key when it was empty, but the
runtime producer files a raw name that ends in a colon ("ns:") under
(server, "") — storage accepts the key — so a pending lock written for
such a tool was never read and the pair was implicitly approved where
the pre-Spec-105 collapse had refused it. The empty collapsed key is now
read and merged like any other.

Tests cover both StateView orders, the prefixed-only fallback, and the
pending / disabled empty-key records on the gate, dispatch, and search
paths.
…rage snapshot

lookupToolApproval fetched the exact-name and collapsed-name records with
two independent Manager.GetToolApproval calls, each under its own read lock
and its own BBolt read transaction. Two operator writes landing between
them (disable the exact record, approve the collapsed one) let the merge
observe exact={approved,enabled} from before the first write and
collapsed={approved} from after the second, yielding a callable view of a
tool that was locked or disabled at every instant. origin/main did one
read, so the window was introduced by the merge change on this branch.
A 3s stress probe (4 readers, writer cycling only blocked states) hit it
once in 585 reads; 0 in 3x1200 after this change.

Add Manager/BoltDB.GetToolApprovals, which reads several keys of one
server under a single read lock and a single read transaction, and make
lookupToolApproval consume that snapshot. Absence of both records still
surfaces as storage.ErrToolApprovalNotFound, so evaluateToolGate and
isToolCallable keep their existing implicit-approved branch. A corrupt
record fails the whole read instead of handing back a partial map.
…f normalizing it a second time

handleCallToolVariant splits the canonical id once, then fed the pair to
lookupToolAnnotationsFound and evaluateToolGate, which ran
normalizeServerTool again. When a raw tool name itself begins with the
server's own prefix ("a:ns:erase" on server "a"), that second pass strips
the segment, so the destructive "a:ns:erase" was tier-classified,
intent-validated and approval/config-gated as the read-only suffix tool
"ns:erase" while dispatch still targeted "a:ns:erase" - a read-only token
reached the upstream in every strict x StateView-order cell.

Split the three gate readers into a normalizing wrapper for canonical-id
callers (lookupToolAnnotationsFound, evaluateToolGate, isToolCallable) and
an exact variant for callers that already hold a split pair
(lookupExactToolAnnotations, evaluateExactToolGate, isExactToolCallable):
handleCallToolVariant, handleCallTool, the sandbox's policyRefusal and
lookupToolPermission bridge, the open-world trust read, the tool-count
loops over raw StateView/ListTools names, and the visibility resolvers
after their own single normalization.

Tests: a regression fixture with the server-prefixed raw name across strict
on/off x StateView order, a zero-upstream-call oracle with positive
controls, and an exact-name pending lock covering the retrieve surface and
the sandbox gate. The single-snapshot approval read is now pinned by a
bbolt read-transaction count at both the reader and the storage layer,
and the "api-key admin" fixtures drive a real IsAdmin() context with the
nil-auth path as its own cell.
…w name only

lookupExactToolAnnotations still accepted the legacy "server:tool" spelling
as an alternative when the exact raw name was absent. The StateView holds
the upstream's published tool name verbatim, so that alternative never
matched a real entry on the live path; what it matched was a raw tool
literally named "a:ns:erase", which resolved the UNDISCOVERED pair
(a, "ns:erase") to that tool's annotations with found=true. A {read}
token calling call_tool_read name="a:ns:erase" then passed the target-tier
gate on the sibling's read-only hint and dispatch went to the raw
"ns:erase" the proxy holds no metadata for, bypassing the fail-closed
destructive fallback. The nested code_execution bridge (lookupToolPermission)
read the same lookup.

Match only the exact raw name. The pinned subtest that asserted the
prefixed fallback is replaced by one that seeds only the self-prefixed
sibling and proves the pair classifies as not found (destructive), the
read-only call is refused before dispatch with zero upstream calls, and
the prefixed raw name itself stays reachable under its exact identity.
…en both keys are locked

mergeApprovalRecords took the exact record as the base whenever it was
locked, so an exact "pending" record next to a collapsed "changed" one
merged to Status=pending and dropped the changed record's previous /
current description and hashes: retrieve dispatch answered
new_unapproved_tool with an empty current_description and the activity
decision said "pending" instead of "changed". Execution stayed blocked;
only the review evidence and the activity reason degraded.

The merge now ranks the locks (unlocked < pending < changed) and takes
the higher-ranked record as the base whichever key carries it; the exact
record still wins when neither is locked or both carry the same lock.
The Disabled OR and the copy semantics are unchanged.

Unreachable with the current producers (discovery files every lock under
the collapsed key; the toggle, ApproveTools and BlockTools write only
"approved"), so this pins the reader for the day the producers become
exact-name. TestToolGate_MergedApprovalRecordsKeepIndependentLocks gains
both-locked cells in both key orders, asserting lockStatus, the review
evidence, the tool_description_changed dispatch response and the
activity reason.
@Dumbris
Dumbris merged commit b94e2b6 into main Sep 8, 2026
40 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.

2 participants