fix(security): require the target tool's tier on call_tool_* dispatch, fail closed on unresolved tiers - #1223
Merged
Merged
Conversation
…, 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.
Deploying mcpproxy-docs with
|
| 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 |
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Contributor
📦 Build ArtifactsWorkflow Run: View Run Available Artifacts
How to DownloadOption 1: GitHub Web UI (easiest)
Option 2: GitHub CLI gh run download 34243455301 --repo smart-mcp-proxy/mcpproxy-go
|
… 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.
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.
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 throughcall_tool_readon every configuration (intent validation does not reject a read variant on a write tool), and a destructive tool the same way wheneverintent_declaration.strict_server_validationis off. Direct-name dispatch and nested code-execution calls already classified the target vialookupToolPermission; the retrieve surface did not.The fix.
handleCallToolVariantnow resolves the target's annotation-derived tier through the samelookupToolPermissionthe other two paths use and refuses the call with the token-permission error (plus ablockedactivity policy decision) when the token lacks that tier — in addition to the existing variant check, and before intent validation.lookupToolPermissionitself changes in two ways:destructive— the top of the cumulative permission ladder — instead of defaulting toread. A discovered tool that publishes no annotations still derives toreadviaDeriveCallWith(the existing annotation-defaulting rule).server:toolquery returns no hits, so the fallback never resolved anything.The found/not-found distinction is exposed through
lookupToolAnnotationsFound(normalizing, for canonical-id callers) andlookupExactToolAnnotations(for callers that already hold a split(server, raw tool)pair);lookupToolAnnotationskeeps its signature.What changed
internal/server/mcp.gohandleCallToolVariant(after the variant check, before intent validation).lookupToolAnnotationsFound(normalizing wrapper) overlookupExactToolAnnotations, which matches the StateView by the exact raw name only (review rounds 5 and 8: the legacyserver:tool-prefixed alternative was first demoted to a fallback, then dropped — the StateView copies the upstream'stool.Nameverbatim, so the alternative was live only when an upstream publishes a self-prefixed colliding name). The pairhandleCallToolVariant/handleCallToolsplit is never re-normalized (round 7).internal/server/mcp_code_execution.golookupToolPermission: StateView-only, undiscovered →destructive, annotation-less discovered →read; BM25 fallback removed; contract documented.tierForAnnotationsshared with the retrieve gate.policyRefusal, the jsruntimeToolAnnotationFuncbridge and the open-world trust read gate the exact pair (round 7).internal/server/mcp_visibility.gonormalizeServerToolstrips a:-prefix only when it equals the server name; a foreignns:segment stays part of the raw tool name. (round 7)isExactToolCallablesplit out ofisToolCallable; the tool-count loops over raw StateView /ListToolsnames and the visibility resolvers use the exact variant after their own single normalization (behaviour-preserving there).internal/server/tool_gate.golookupToolApprovalreads 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:mergeApprovalRecordsranks locksunlocked < pending < changed(approvalLockRank, round 9) so the higher-ranked record is the base whichever key carries it, exact wins ties,Disabledis OR'd, stored records never mutated.evaluateToolGate(normalizing) /evaluateExactToolGateandisToolCallable/isExactToolCallableroute through it.internal/storage/manager.go,internal/storage/bbolt.goGetToolApprovals(server, names...): several keys of one server under oneManagerread lock and one BBoltView; missing keys are absent from the map; a corrupt record fails the whole read.internal/storage/tool_approval_test.goTestToolApprovalRecord_GetToolApprovals_OneSnapshot: keyed result, missing key absent, empty tool-name key readable, corrupt record fails the read, and a one-transaction oracle (bbolt Stats().TxNdelta = 1; the per-key control reads 2).internal/server/mcp_call_tool_target_tier_test.golookupToolPermissionclassification, undiscovered-tool fail-closed, plus the review-round tests listed below.internal/server/mcp_call_tool_trim_test.goNo frontend, docs, config, REST or MCP surface changes (one new internal storage accessor). Tool-definition goldens under
internal/server/testdatapass unregenerated. Final branch head:1c1a8ae0e(10 commits on top oforigin/mainc93f79423).Tests
All tests seed real tools into the live
StateView(the production lookup path) and usecreateTestProxyWithRuntime:TestCallToolRead_ReadOnlyToken_TargetTierEnforced—{read}× {write, destructive} target × strict {on, off}: refused withPermission denied: token does not have …, never reaches dispatch (No client foundabsent), activity policy decision isblockedand attributes the block to the token.TestCallToolRead_TokenHoldsTargetTier_IntentHandlingUnchanged— full token on a destructive target viacall_tool_read: strict on → existingmarked destructivemismatch; strict off → reaches dispatch.TestCallToolVariants_RegisteredRetrieveModeHandlers_EnforceTargetTier— the handlers actually registered on the retrieve-mode server (callToolServer, whichGetMCPServerForModeselects) and the default server refuse a{read,write}token for all three variants on a destructive target. (Thecall_tool_destructivecell 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 thedestructivepermission error; full token still reaches dispatch.Added during cross-model review (same file unless noted):
TestCallToolRead_NamespacedToolName_IsNotClassifiedAsItsSuffix—{read}ona:ns:erase(read-tiereraseand destructivens:eraseon one server) is refused asdestructive, strict on/off, blocked activity record undertool_name: ns:erase.TestToolGate_NamespacedToolName_KeysOnRawName— an approval record forerasedoes not admitns:erase;normalizeServerTooltable.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 (ascheckToolApprovalswrites it) still gatesa:ns:erasefor a full-tier token, anIsAdmin()API-key context (auth.AdminContext()) and the no-auth-context path, each as its own cell.TestToolGate_StaleExactApprovalCannotShadowCollapsedChange— drives the realrt.SetToolEnabledproducer, then marks the collapsed recordchanged; the stale exactapprovedrecord can no longer shadow it.TestToolGate_MergedApprovalRecordsKeepIndependentLocks— exact Disabled + collapsed changed (and mirror / tie) keeplockStatusand answerTOOL_QUARANTINED/tool_description_changedrather than a genericTOOL_BLOCKED; round 9 adds the both-locked cells (exact pending + collapsed changed, and mirror) assertinglockStatus=changed, the review evidence, thetool_description_changedresponse and thechangedactivity reason.TestLookupToolPermission_ExactRawNameOutranksPrefixedAlternative(round 5, tightened in round 8) — a read-only StateView tool literally nameda:ns:erasenever resolves the dispatched pair(a, ns:erase)in either StateView order; with only the self-prefixed sibling present,found=false, the tier isdestructive, the read-only call is refused before dispatch with zero upstream calls, and positive controls dispatcha:ns:eraseunder its own raw name.TestToolGate_TrailingColonRawName_ReadsProducersEmptyKeyRecord(round 5) — a raw toolns:is filed by the producer under the empty collapsed keya:; that pending lock now reaches the gate (TOOL_QUARANTINED, blocked event withtool_name: ns:) and a Disabled record reachesisToolCallable.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 (TxNdelta 1; two per-key reads give 2, so reverting to per-key reads fails).TestCallToolRead_ServerPrefixedRawName_IsNotReNormalized(round 7) — serverapublishes destructivea:ns:eraseand read-onlyns:erase;{read}callingcall_tool_reada:a:ns:eraseis refused asdestructivein all four strict × StateView-order cells with zero upstream calls and activitytool_name: a:ns:erase; positive controls dispatch rawns:eraseanda:ns:erase; the exact-name pending lock answersTOOL_QUARANTINEDon 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):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 upstreamannot= a dependency-free Node MCP server exposing three annotated tools and appending everytools/callit receives toupstream_calls.log:read_thingwrite_thingdelete_thingTokens minted via
POST /api/v1/tokens/:readonly={allowed_servers:["annot"], permissions:["read"]},full={read,write,destructive}. Each call =initialize→notifications/initialized→tools/callonPOST /mcpwithAuthorization: Bearer <agent token>:Before —
origin/main,{read}tokencall_tool_read→delete_thingTool 'annot:delete_thing' is marked destructive by server, use call_tool_destructivecall_tool_read→write_thingUPSTREAM EXECUTED write_thing {"id":"b3"}call_tool_read→delete_thingUPSTREAM EXECUTED delete_thing {"id":"b1s"}call_tool_read→write_thingUPSTREAM EXECUTED write_thing {"id":"b3s"}The activity log recorded these as plain
tool_call/successwith no policy decision.After — branch @
ecf599d59(round-4 build),{read}tokencall_tool_read→delete_thingPermission denied: token does not have 'destructive' permission required for tool 'annot:delete_thing'call_tool_read→write_thingPermission denied: token does not have 'write' permission required for tool 'annot:write_thing'call_tool_write→delete_thingInsufficient permissions: 'call_tool_write' requires 'write' permission(existing variant gate, body identical to baseline)call_tool_read→read_thingUPSTREAM EXECUTED read_thingcall_tool_read→nonexistent_tool(undiscovered)Permission denied: token does not have 'destructive' permission required for tool 'annot:nonexistent_tool'call_tool_read→ns:read_thing(foreignns:prefix, undiscovered)Permission denied: … 'destructive' … 'annot:ns:read_thing'read_thingEvery refusal is recorded as
policy_decision/blockedwith the reason above and the raw dispatched tool name.upstream_calls.logcontains nodelete_thing/write_thingentry from the read token in either strict mode.Positive controls — branch,
{read,write,destructive}tokencall_tool_destructive→delete_thingUPSTREAM EXECUTED delete_thingcall_tool_write→write_thingUPSTREAM EXECUTED write_thingcall_tool_read→delete_thingTool 'annot:delete_thing' is marked destructive by server, use call_tool_destructivecall_tool_read→delete_thingUPSTREAM EXECUTED delete_thingX-API-Keyas Bearer,call_tool_read→delete_thingUPSTREAM EXECUTED delete_thingcall_tool_read→ns:read_thingUPSTREAM EXECUTED ns:read_thingupstream_calls.logfor 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 viacall_tool_readon every config and a destructive-annotated tool whenstrict_server_validationis off. Branch: both refused before dispatch with zero upstream calls and ablockedpolicy-decision record; read → read, every full-token control and the admin path dispatch unchanged; undiscovered tools fail closed asdestructive; 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
annotfixture has none of them, so the five post-ecf599d59commits are covered by the unit tests only. None of them touch theannot-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:
opencodewithgithub-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 atecf599d59); it was re-run and the row below is the real round 5.normalizeServerToolstripped any first:segment, so{read}ona:ns:eraseclassified as read-tiererase5c5aeec3f): strip only when the prefix equals the server name;isToolCallablerouted through the same helper; two tests addeddestructiverather than refusing every scoped callerread); a hard refusal is a cross-surface change (jsruntimeToolAnnotationFunc, nested calls) → follow-uplookupToolAnnotationsFoundread now feeds both the tier gate and intent validation (tierForAnnotations); binding the later approval-gate read to the same generation → follow-upTestCallToolRead_TargetTierGate_UpstreamCallOracle(counting in-process upstream, 0 calls on refusal, exact raw names on dispatch)checkToolApprovals) still file records under the collapsed name, so the now-exact readers missed a pendingns:eraserecord and fell to implicit-approved2eddbcbe9):lookupToolApprovalreads 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<server>:xis ambiguous with the indexing prefixapprovedrecord (synthesised by the enable/disable toggle) shadow a newer collapsedchangedrecordd58092934): read both records, return the more restrictive; test drives the realrt.SetToolEnabledproducerapprovedrecord admitsns:erasewith no exact recordns:erase's own baseline (ApproveToolsonly mutates existing records), so refusing it would make every namespaced tool un-approvable until the producer is made exact-name → follow-upTOOL_BLOCKEDwithlockStatus ""ecf599d59):mergeApprovalRecords— lock-carrying record is the base,DisabledOR'd, stored records never mutated; test covers both directions and the tielookupToolAnnotationsFoundstill accepted the legacyserver:tool-prefixed spelling, so a raw StateView tool literally nameda:ns:erase(read-only) matched the dispatched pair(a, ns:erase)(destructive) order-dependently; tier gate and intent validation then classifiedns:eraseas read while dispatch targetedns:erase;lookupToolPermission(nested) shared it829f3b1e6): 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_ExactRawNameOutranksPrefixedAlternativecovers both StateView orders end-to-enda:ns:lost the pre-diff fail-closed answer:lookupToolApprovalskipped the empty collapsed key, so the pair was implicitly approved for full-tier / admin callers829f3b1e6), and sharper than reported: the producer filesns:under(server, "")and storage accepts the keya:, so a pending lock the producer wrote was never read.lookupToolApprovalnow reads and merges the empty collapsed key;TestToolGate_TrailingColonRawName_ReadsProducersEmptyKeyRecordfailed before / passes after. The remainder (an undiscoveredns:with no record is implicitly approved for full-tier / admin callers) is the general unresolved-identity policy already in the follow-upslookupToolApprovalread the exact and collapsed records in two independent storage reads (eachManager.GetToolApprovaltakes 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 race5334e7fe8): 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. NewManager.GetToolApprovals/BoltDB.GetToolApprovalsread several keys under one read lock and one BBoltView;lookupToolApprovalconsumes that single snapshot and maps absence of both records toErrToolApprovalNotFoundso the implicit-approved branch is unchanged. Tests ininternal/storage/tool_approval_test.goandTestLookupToolApproval_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/normalizeServerTool, butlookupToolAnnotationsFoundandevaluateToolGatere-normalized a pairhandleCallToolVarianthad already split: a raw name starting with its own server's prefix (a:ns:eraseona) was classified, tier-gated, intent-validated and approval-gated as(a, ns:erase)while dispatch targeteda:ns:erase; on main the strict-on / destructive-first cell was refused by intent validation, so one cell regressedfa18266f2): confirmed byTestCallToolRead_ServerPrefixedRawName_IsNotReNormalizedfailing in all four strict × order cells (counting upstream recorded the call;policyRefusaladmitted a pending exact record).lookupToolAnnotationsFound/evaluateToolGate/isToolCallablesplit 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).normalizeServerToolunchanged so index-derived callers keep resolving.describe_tool(visibility-only, identical on main) still normalizes once → follow-upfa18266f2): no new seam needed —bbolt Stats().TxNcounts read transactions andManager.GetDB()is exported; both tests now assert the read costs exactly one transaction (per-key control = 2), stable under-count=10 -racecontext.Background()(nil-auth path), not anIsAdmin()API-key context; the label overstated what was exercisedfa18266f2): those cells now carryauth.WithAuthContext(ctx, auth.AdminContext())and the nil-auth path keeps its own honestly labelled "no auth context" cell in all three tables; test fidelity onlyaholding read-onlya:ns:eraseand nons:erasemadelookupExactToolAnnotations("a","ns:erase")report found/read, bypassing even the fail-closeddestructivedefault;{read}viacall_tool_reada:ns:erasepassed the tier gate and dispatch targeted rawns:erase; the nested bridge inherited ita61ff044c): reproduced (a{read}token reached dispatch; upstream answered "tool 'ns:erase' not found"). Verified the alternative is dead on the normal path (supervisor.go:880andupstream/core/client.go:383copy the upstreamtool.Nameverbatim, so the StateView never holds a self-prefixed name unless the upstream publishes one);lookupExactToolAnnotationsnow 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, tierdestructive, refused with zero upstream calls, positive controls dispatcha:ns:eraseexactly). Failed before on three assertions, passes aftertoolVisibleToSessionre-normalizes the pairresolveDescribeDefinitionalready split, sodescribe_toolida:a:ns:erasetests index presence on rawa:ns:erasebut gates the suffixns:erase;suggestCanonicalToolIDpasses an already-normalized name into the same functionnormalizeServerToolcall atmcp_visibility.go:58is byte-identical toorigin/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-updirectCallabilityEvaluator.getToolApproval(mcp_direct_callability.go:253) andpreflightApprovalReader.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 preflightorigin/mainand 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, notlookupToolApproval; widening the reader change to the direct and preflight surfaces would change behaviour on paths the diff does not touchmergeApprovalRecordstook the exact record as base whenever it was locked, so exactns:erasepending + collapsederasechanged reportedpendingand dropped the changed record's previous/current description and hashes (review evidence and activity reason degraded; execution stayed blocked); no both-locked test cell1c1a8ae0e): reproduced with a new both-locked cell (lockStatus=pending, emptyCurrentDescription/CurrentHash,new_unapproved_toolwithcurrent_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;DisabledOR and copy semantics unchanged; deadapprovalLockedhelper 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 onlyapproved), so this is reader hardening pinned for exact-name producersFollow-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.gomarks the merged reader as the temporary rule until the producers are made exact-name):checkToolApprovals(internal/runtime/tool_quarantine.go:443-460, keyed viaextractToolNameatlifecycle.go:975) files pending/changed/baseline records under everything after the first colon, andlifecycle.go:699-719differential-index maps collapse names the same way, soeraseandns:eraseon 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:erasestays pending until approved under its own name — todaylookupToolApprovalreturns a collapsed-onlyapprovedrecord 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 (evaluateExactToolGatekeeps the implicit-approved default onErrToolApprovalNotFoundattool_gate.go:124-125without consulting the snapshot).erase, a later-discoveredns:erasewith identical description/schema gets no separate pending record (hash-match branch,tool_quarantine.go:~610-647).tierForAnnotations(found=false)(mcp_code_execution.go:1196-1199) maps to the grantabledestructivetier, which a{read,write,destructive}scoped token still passes (R1-2, seen live as A14 driving an undiscoveredns:read_thingthroughcall_tool_read;TestCallToolRead_UndiscoveredTool_RequiresDestructiveTierpins the narrower behaviour on purpose); the same policy admits an undiscoveredns:with no record for full-tier / admin callers (R5-2 remainder).found=truecomes 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.evaluateExactToolGateat dispatch.mcp_direct_callability.go:253feeding the registered direct handler atmcp_routing.go:465, andpreflight_glue.go:404, readstorage.GetToolApprovaldirectly, notlookupToolApproval; both byte-identical toorigin/main, R9-2) andmcp_routing.go:441-445still defaults an unresolved annotation toread. 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.toolVisibleToSession(mcp_visibility.go:58, reached byresolveDescribeDefinitionsodescribe_toola:a:ns:eraseresolves as(a, ns:erase), and bysuggestCanonicalToolID),classifyServerToolStatus(mcp.go:6355→ normalizingevaluateToolGate), and the empty-ServerNamefallbacks inmcp_entry_builder.go:143-156/mcp.go:1855-1863that split on the first colon. Status / count / annotation surfaces only, no execution bypass.{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-writetoken and same-tier config-denied pairs (eraseallowed,ns:eraseconfig-denied — only approval-record variants are covered on the retrieve surface).call_tool/call_toolsscript 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 calllookupToolPermission/policyRefusaldirectly rather than driving the script runtime's refusal envelope.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).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 botheraseandns:erasesurvive real discovery and indexing, and no before/after administrator response-parity comparison.HasPermissionis exact-match, not hierarchical: a token minted as[read, destructive]withoutwritepasses the target gate for a write tool whilecall_tool_writerefuses it. Documented in the code comment; real tokens are minted cumulatively.readviaDeriveCallWith(FR-009 "that rule is documented"); today it lives in code comments only, not in the user-facing docs.Notes
origin/main(c93f79423) before the first commit; no conflicts.internal/server/profile_tool.go. The two profile-related Spec 105 branches (claude/eager-kirch-10f6c2andclaude/xenodochial-lumiere-5abe75) both edit that file and will conflict with each other, not with this PR.