Decode the call_tool target as dispatch does - #6175
Merged
Merged
Conversation
aponcedeleonch
requested review from
ChrisJBurns,
JAORMX,
amirejaz,
jerm-dro,
jhrozek,
rdimitrov and
tgrunnagle
as code owners
August 3, 2026 09:26
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6175 +/- ##
==========================================
+ Coverage 72.59% 72.62% +0.03%
==========================================
Files 736 736
Lines 76381 76384 +3
==========================================
+ Hits 55447 55477 +30
+ Misses 17008 16967 -41
- Partials 3926 3940 +14 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Authorization read the target name by indexing the request arguments map, while dispatch decodes the same arguments into a CallToolInput. encoding/json matches struct fields case-insensitively, so a request carrying "Tool_Name" resolved and ran under dispatch but was invisible to the map index, and the pass-through branch waved it to the backend with no policy check. A case-variant "parameters" key dropped the inner arguments from the policy context the same way. Resolve the target through the same schema.Translate call both dispatch sites use, so the two cannot match keys differently. Arguments that fail to decode are denied rather than passed through: dispatch decodes them the same way and rejects them too, so no legitimate invocation is lost. Leave decoding as the only way in. Drop the raw argument-key constants and unexport resolveCallToolTarget, which authorization was the sole outside caller of. Both existed to serve the map-index pattern this change removes, and keeping them exported invites it back.
aponcedeleonch
force-pushed
the
fix-authz-call-tool-key-case
branch
from
August 3, 2026 09:48
01a006d to
fba6787
Compare
JAORMX
approved these changes
Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
call_toolrequest names. It closed the nested-tool_namecase, but the top-level name is still read two different ways: authorization indexes the arguments map on"tool_name", while dispatch decodes the map into aCallToolInput.encoding/jsonprefers an exact field match but falls back to a case-insensitive one, so the two do not match the same set of keys.{"Tool_Name": "forbidden_backend", "parameters": {}}therefore resolves and runs under dispatch, while the map index sees nothing,toolNamestays empty, and the pass-through branch serves the request to the backend with no policy check performed. A case-variant"parameters"key drops the inner arguments the same way, so a policy with awhenclause oncontext.arg_*denies a call it should permit.schema.Translate[optimizer.CallToolInput]call both dispatch sites use (serve_optimizer.go,optimizerdec/decorator.go). The two sides now match keys identically because it is the same call, not two implementations that agree today.CallToolArgToolName/CallToolArgParametersand their drift test are gone, andResolveCallToolTargetis unexported toresolveCallToolTargetnow that authorization, its only caller outside the package, no longer needs it. Both existed to serve the map-index pattern this PR removes, and keeping them exported invites it back. The one remaining raw-key read is the nested lookup inside that function, now a named constant guarded against the struct tag byTestCallToolArgToolNameMatchesStructTag.This is pre-existing, not a regression from #6150, and not reachable today:
server.NewrejectsConfig.Authztogether withConfig.OptimizerConfig, and the Serve path never appliesAuthzMiddleware, so the pass-through branch is dead in-tree. It becomes live with the optimizer-admission work deferred inpkg/vmcp/core/admission.go, which is why it is worth closing before then rather than after.Type of change
Test plan
task test)task lint-fix)Three cases added to
TestMiddlewareOptimizerMetaTools, which runs against a real Cedar authorizer. All three fail onmainand pass here:call_toolargumentsmain{"Tool_Name": "forbidden_backend", "parameters": {}}{"tool_name": "args_backend", "PARAMETERS": {"query": "x"}}{"tool_name": "allowed_backend", "parameters": "not an object"}The second case needs a policy that reads the inner arguments, so the suite gains
permit(... resource == Tool::"args_backend") when { context.arg_query == "x" }. Asserting on status alone would not catch dropped arguments.TestCallToolInput_TranslateResolvesTargetrecords what the shared decoder resolves for each shape, including that a case-variant nested key is not hoisted. That one is a map index on both sides, so both name no tool and dispatch fails closed; the property under test is parity, not leniency.Full
pkg/authz/...,pkg/vmcp/optimizer/...,pkg/vmcp/session/optimizerdec/...,pkg/vmcp/server/...andpkg/vmcp/schema/...suites pass with-race.Not covered by e2e: reaching this branch requires Cedar authz plus the optimizer, which
server.Newrefuses to construct.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Changes
pkg/authz/middleware.goschema.Translate; deny undecodable argumentspkg/vmcp/optimizer/optimizer.goresolveCallToolTarget; name the nested lookup keypkg/vmcp/session/optimizerdec/decorator.goDoes this introduce a user-facing change?
No. The affected branch cannot be reached through
thv vmcp servetoday, since Cedar authz and the optimizer are mutually exclusive at construction.Special notes for reviewers
The deny-on-undecodable choice is the one real judgement call. The alternative was to fall back to the old map lookup so that no shape loses an authorization it gets today; the shape in question is
{"tool_name": "x", "parameters": "oops"}, which authorizes onmainand would now be refused. Denying seemed right for an authz middleware that cannot establish a target, and it costs nothing because dispatch rejects that payload regardless. Happy to switch to the fallback if you would rather keep the branch strictly additive.handleUnauthorizedis called with a nil error because the specific decode failure is already logged with the tool name just above; passing it again would emit a second, less useful line.Three exported symbols go away: the two
CallToolArg*constants andResolveCallToolTarget. All three were added by #6150 sopkg/authzcould resolve the target from a raw map, which is exactly what this PR stops doing, and none has another caller in tree.optimizerdecis dead on the Serve path besides. Technically a Go API break for an out-of-tree importer, and a clean revert if you would rather keep the surface stable.Verified the advertised tool schema is untouched: it is built by reflection over
CallToolInput's struct tags, not from those constants, andOptimizerTools()output is byte-identical tomainfor bothfind_toolandcall_tool.Generated with Claude Code