Repository navigation
fix(engine): evaluate tool permissions on the same argument decode the tool executes - #2265
Conversation
e8d0f63 to
e2fe875
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Panel review
Reviewed e2fe87517 against 3145b607f across Spec (#2264), Standards, independent Test adequacy, and Domain (security, architecture, library reuse, duplication).
Requesting changes for one concrete blocker: nonShellPattern can still authorize a resource the tool does not execute. Security and architecture independently confirmed this. It is pre-existing, but the rewritten extractor preserves it despite #2264 explicitly requiring policy/execution equality. See the inline example and proposed fix.
Spec
- Blocker: policy selects a generic resource field rather than the tool's actual operand.
- Important: agreement coverage omits Edit, tests Write only for rejection, and uses name-only stubs for network/MCP tools (
engine/agent/policy_args_decode_test.go:155–178,246–265).
Standards
- Important: PresentPlan bypasses the argument precheck and can surface duplicate-key arguments for approval, contrary to the updated documentation. See inline comment.
Test adequacy
- Important:
temp_scope_permission_test.go:200–226claims rejection before policy evaluation but asserts only no approval, no runner call, and an eventual error. TheTimeout_MSfixture passes the generic precheck and reaches ordinary policy evaluation; subsequent Shell parsing rejects it. Add a recording-policy assertion and reconcile the claimed ordering contract. - Important: the network/MCP stubs at
policy_args_decode_test.go:246prove generic rejection, not target agreement. Use offline real-adapter fixtures with recording transports/providers, resource-specific rules, accepted escaped keys, and competing resource fields. - Advisory: ambiguity-specific hook-mutation, pending-approval resume, and mixed read-batch regressions remain unpinned. Add these lifecycle cases with no-policy/no-execution assertions for ambiguous calls and positive controls.
The scanner's differential, fuzz, boundary, and allocation coverage is substantial.
Domain
- Blocker / High: resource-scoped permission bypass, cross-confirmed by security and architecture.
- Important / Medium: tagged-field validation rejects valid exact matches to untagged fields. See inline comment.
- No library-reuse or duplication findings. A proposed MCP-normalization concern was checked and excluded because the evidence did not establish a policy/execution disagreement.
CI checks were successful or skipped. No local tests or linters were run. The request for changes is based on the code defect, not CI.
Counts preserve independent axes: the two blocker findings describe one underlying defect.
PANEL: ship_blockers=2 important=5 advisory=1 reviewer_failures=0
…e tool executes Permission evaluation, approval display and tool execution now read a tool call's arguments through one decode, governance.DecodeToolArgs, which session.ParseArgs and the policy extractors share. governance.CheckToolArgs refuses argument objects that repeat a key, have top-level keys equal under Unicode case folding, or spell a policy-read argument in another case. Engine.permissionDecision applies it before policy evaluation, so such calls are rejected with a tool error before any rule, guardrail, approval, hook or tool sees them. The evaluator and ParseArgs apply the same check. The tests that pinned the old case-insensitive, last-key-wins reading now assert rejection, including the ambiguous cases added by #2263. Closes #2264 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CheckToolArgs runs on every dispatched call and every policy evaluation. Its map-based key scan cost five to nine allocations for ordinary arguments, and DecodeToolArgs repeated it, which pushed the evaluator and engine-turn benchmarks over the allocs/op gate. The scan now keeps keys as subslices of the arguments in stack-held arrays, finds repeated keys and case-fold collisions by pairwise comparison, and decodes a key only when it carries an escape or invalid UTF-8. An object with more than sixteen keys switches to map indexes, so large objects stay linear. DecodeToolArgs checks tagged field spellings on the same keys instead of building fold strings. CheckToolArgs now allocates nothing for ordinary arguments, and DecodeToolArgs allocates exactly what its encoding/json decode does. The verdicts are unchanged: a differential test and fuzz target compare them, and the error messages, with the previous implementation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three cases in args_test.go meant to spell a key with a JSON escape (`command`) had become literal copies of their neighbours, so the fuzz seeds and the classification table covered a plain duplicate twice and never covered an escaped one. They now carry the escape again: - a key repeated once plainly and once escaped is ambiguous; - a key spelled only with an escape is the canonical name, not ambiguous. Refs #2264 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The single-resource extractor picked the first of path, file_path, pattern, url and query regardless of the tool, so a call could carry an allowed decoy in a field the tool ignores: WebFetch evaluated "path" but fetched "url", and Glob evaluated "path" but listed "pattern". Built-in tools now match the argument they act on. Tools whose contract governance does not know (MCP, custom) keep the generic precedence, and a pattern Deny or Ask on any other candidate argument also applies. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…proval PresentPlan is intercepted before permissionDecision, so a plan with a repeated or case-variant key reached the approval surface. surfacePlanAsk, shared by the batched and serial paths, now applies CheckToolArgs first. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tagged-field spelling check refused {"Limit":7} for a struct with an
untagged Limit field and a field tagged "limit", although encoding/json
decodes it exactly. Field collection now follows encoding/json's field
dominance and accepts an exact match to any effective field.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tion A case variant of an option no rule reads, such as Timeout_MS, passes the generic check and is refused by the Shell decode instead. Record policy evaluation so the test states the real ordering. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
e2fe875 to
1a2fa81
Compare
Cover the paths that authorize a call after its first evaluation: a PreToolUse rewrite on the serial path and in a read batch, an approval resumed from a paused session (an ambiguous pending call, and a hook rewrite on resume), and a read batch mixing ambiguous and canonical calls. Ambiguous arguments reach neither the policy nor the tool; the canonical controls are evaluated and run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Summary
Permission evaluation and tool execution now share one decode of a tool call's arguments, and argument objects that do not decode to a single unambiguous value are rejected before policy evaluation.
Targets
main. #2263 has merged, so this branch is rebased ontomainand the PR targetsmaindirectly.Design
governance.DecodeToolArgsis the single decode. Its callers are:session.ParseArgs, which every built-in tool,parseShellArgs, the system-scope approval projection, andtool.LocalFileOperandsalready use;ShellCommandFromArgs, the single-resource pattern, the Copy/Move operands).The function first applies
governance.CheckToolArgs. It then refuses a key that names a tagged field of the destination only in another case. Finally it decodes withencoding/json.governance.CheckToolArgsrefuses three kinds of argument object:command,cmd,temp_scope,path,file_path,pattern,url,query,source,destination,serverandtool.Empty, non-object and invalid JSON payloads pass unchanged, because no reader extracts a value from them.
After this check,
encoding/json's case-insensitive matching and its last-duplicate-wins rule can no longer pick a value. Every reader that accepts the payload sees the same fields: Go structs, exact-key maps, hooks, and MCP servers.Where rejection happens.
Engine.permissionDecisionis the single entry to policy evaluation. It covers fresh calls, read batches, hook-mutated effective calls, and approvals that resume after a pause. It runsCheckToolArgsbeforePolicy.Evaluate. This is the primary gate: nothing downstream sees the call, including the policy, guardrails, posture wrappers, approval surfaces, child auto-approval, the headless reviewer, PreToolUse hooks, and the tool itself.Evaluator.EvaluateWithdenies the same shapes for policy wrappers and direct callers, andLearnableRulerefuses them.session.ParseArgs. This layer protects execution. It also catches lone case variants of any tagged field, such asTimeout_MS, that the generic pre-check does not know.The rejection is a
permission denied: ambiguous tool arguments: …; send each argument once, spelled exactly as in the tool schematool error. No ask is surfaced and nothing executes.Review follow-up
This revision addresses the panel review:
path,file_path,pattern,urlandquerywhatever the tool, so an allowed decoy in an ignored field chose the matched value. Built-in tools now match their own operand:pathfor Read, Write, Edit, Remove, ListDir and Grep,patternfor Glob,urlfor WebFetch, andqueryfor WebSearch. A Grep call withoutpathmatches only tool-wide rules. MCP and custom tools keep the generic precedence, because governance does not know their argument contract, and a pattern Deny or Ask on any other candidate argument also applies.surfacePlanAsk, shared by the batched and serial dispatch paths, runsCheckToolArgsbefore surfacing the plan approval or the headless refusal.encoding/jsondominance, so{"Limit":7}decodes into an untaggedLimitnext to a field taggedlimit. A name thatencoding/jsonhides is still treated as a case variant.Timeout_MStest states the real ordering. A case variant of an option no rule reads passes the generic check and reaches policy as an ordinary Shell call. The Shell decode then refuses it, before any system-scope approval. The test records policy evaluation to pin this.Before | After
Rules: Allow on the decoy value, Deny on the executed value. "Before" is the previous head
e2fe87517.WebFetch {"path":"https://allowed.example/","url":"https://blocked.example/"}allow, fetchesblocked.exampledenyGlob {"path":"src/**","pattern":"secrets/**"}allow, listssecrets/**denyWebSearch {"pattern":"go docs","query":"internal secrets"}allow, searchesinternal secretsdenyPresentPlan {"plan":"shown","plan":"recorded"}in plan modepermission denied: ambiguous tool argumentsDecodeToolArgs({"Limit":7}), untaggedLimitplus a field taggedlimit"Limit" must be spelled "limit"Limit: 7Policy paths and tools audited
cmdalias, plan mode, learned rules (ShellCommandFromArgs)shellSystemScope,systemScopeApprovalArgs, from #2263)parseShellArgs→session.ParseArgs(shared decode)askadjudicator,autoApproveChildAsk)resourcePatterns)path, orpatternfor Glob)copyMovePatterns)url/query, the arguments the tools execute; the tools decode throughsession.ParseArgsmcp__*, raw arguments forwarded)surfacePlanAsk, intercepted beforepermissionDecision)CheckToolArgsbefore surfacing the approvalauthorityTarget)session.ParseArgsauthorityPathNamed)reviewTarget,tool.LocalFileOperands)reviewTarget's WebFetch branch switched tosession.ParseArgsMutatesParent(dispatch serial or parallel classification)session.ParseArgs, the decodeExecuteuses (Parallel already did)internal/appescape policy)governance.DecodeToolArgsmodelhook)ShellCommandFromArgs(shared)session.ParseArgsinternal/adapter/server)Two readers are display-only and stay as they are. The ACP projector shows tool-call locations, and compaction extracts a path for its summary. Neither one decides permission.
Compatibility
mockllm,mockscriptand the conformance suites need no changes. Provider fixtures contain no non-canonical keys.URLorQuery.engine/CHANGELOG.mdhas an Added entry for the newgovernanceidentifiers and a Changed entry for the behavior. The API snapshot is updated.pathnow matches only tool-wide rules, rather than matching its regular expression as if it were a path. A call to a built-in tool that carriesfile_pathinstead of the tool's own argument now derives no pattern; the tool refuses that call anyway. Thepermpolicyaccept-edits fixtures sent Edit and Writefile_pathand now sendpath.Tests
c6d733888, green on this branch:engine/agent/policy_args_decode_test.go. It drives the engine across argument encodings for Shell, the file tools, WebFetch, WebSearch and an MCP-named tool. It asserts that the value policy evaluates equals the value executed, or that ambiguous arguments never reach policy evaluation or execution.engine/governance/args_test.go: classification, exact field spelling, evaluator deny, and refusal to learn.FuzzPolicyExtractionMatchesExecutionDecode. It checks that whenever the execution decode accepts a payload, the policy extraction reads the same command, path and operands. It also checks the key scanner againstencoding/json.session.TestParseArgs: ambiguous objects are refused.engine/governance/resource_operand_test.go: a Deny on the executed operand wins over an allowed decoy for WebFetch, WebSearch, Glob, Read and Grep; a decoy-only Allow does not admit the call; learned rules use the executed operand; Grep withoutpath; deny-dominant matching for MCP tools.TestBuiltinToolDenyMatchesTheExecutedOperand(engine/agent): real Glob overmemfs, real WebSearch with a recording provider, and real WebFetch. The denied and decoy-only calls never reach the tool, and the canonical call runs.TestPresentPlanAmbiguousArgsAreRejectedBeforeApproval: alone, in a read batch, on the serial path, and headless.TestDecodeToolArgsAcceptsExactUntaggedFields: tagged and untagged collisions, a promoted untagged field, and a name hidden by a dominance tie.engine/agent/policy_args_lifecycle_test.go: a PreToolUse rewrite into ambiguous arguments (serial path and read batch), an approval resumed with an ambiguous pending call or a hook rewrite on resume, and a mixed read batch where only the ambiguous call is refused. Ambiguous arguments reach neither the policy nor the tool, and the canonical controls run.fstools/local_operands_test.go,agent/repair2b_test.go,agent/temp_scope_permission_test.go,internal/app/contextual_build_evidence_test.go.Gates
Each failure listed below fails the same way on a clean
c6d733888checkout on this macOS host. All of them are in packages this change does not touch.task lint: 0 issues in every module exceptenvironment/microvm, which has one pre-existing finding (gosec G703,launch_ownership_darwin.go:754).task test:race: theenginemodule passes, including theGOWORK=offstandalone run, and so do the provider, adapters andauthn/oidcmodules. In the root module, every package that this change touches passes:internal/appcontextual and escape policy,modelhook,permclassify,mcp,serverapprovals, andcmd/mecatui/ui. Pre-existing failures elsewhere in the root module:internal/app:TestCanonicalConfiguredDir_AllowsMissingDefaultParentsTestYoloRelativeEscape*(2 tests)TestSubagentRunsGitInWorktreeEndToEndTestBuildSubagentToolRealWiringForksChildShellWhenShellTestReadOnlyMemberRunsGitInWorktreeEndToEndinternal/adapter/server:TestACPUsesHostGovernanceRoot…andTestLoadACPSessionOwnsExactBinding…internal/adapter/forker:TestFork*(4 tests)internal/executionexecutor: 5 testsdeploy/mecatl-execution-kind: 2 testscmd/mecated:TestMecatedCLICompositionValidatesMicroVMDefaultAfterReadinesscmd/mecatui:TestConnectUsesSavedServerCAForLazyTLSRPCinternal/adapter/microvmmanager:TestDefaultOperationsStartScrubsDaemonEnvironmentAndDetachesFromRequesttimed out once under load. It passes with-race -count=3on this branch.task docs:checkpasses (matlatl strict: 0 broken links, anchors or orphans).sdk:docswas skipped becausepnpmis not installed on this host.go run ./cmd/mecademopasses and shows the tool call, the permission ask and approval, and the result.FuzzPolicyExtractionMatchesExecutionDecoderan for about 90 seconds with no failures. On the allocation-free scan below, it ran two more times (3 minutes and 2.5 minutes, about 51 million executions) with no failures.CheckToolArgsruns on every dispatched call and every policy evaluation, so its key scan does not allocate on ordinary arguments.Keys stay subslices of the argument bytes, held in stack arrays. Repeated keys and case-fold collisions are found by pairwise comparison (
bytes.Equal,bytes.EqualFold), and a key is decoded only when it carries an escape or invalid UTF-8. An object with more than 16 keys switches to map indexes, so large objects stay linear.DecodeToolArgschecks tagged field spellings on the same keys, so it allocates exactly what itsencoding/jsondecode does. The policy extractors allocate fewer times than onmain, for example 3 instead of 7 forShellCommandFromArgs.TestCheckToolArgsDoesNotAllocatepins zero allocations for typical Shell and file-tool arguments.TestDecodeToolArgsAllocatesLikeUnmarshalpins the decode againstjson.Unmarshal. Both skip under-race, wheresync.Pooldrops items at random.TestCheckToolArgsMatchesReferenceandFuzzCheckToolArgsMatchesReferencekeep the previous map-based scan as an oracle and compare verdicts, error messages, top-level keys and decoded values. They agree on the seeds, edge cases past the stack and map thresholds, 20,000 generated payloads, and the 848-entry fuzz corpus. The fuzz target ran for 3 minutes (7.5 million executions) with no differences.task bench BENCHCOUNT=10, compared withgo run ./perf/cmd/allocsgate, reports no regression against a cleanc6d733888checkout. It also reports none against thegh-pagesbaseline, with benchmark names normalized to the CI runner's-4suffix. The gated allocs/op against thegh-pagesbaseline:EvaluatorEvaluate/simple-toolEvaluatorEvaluate/plain-bashEvaluatorEvaluate/compound-bashRunReadOnlyTurnRunReadParallelTurnRunMutatingTurnSteadyStateReadOnlyTurnSteadyStateReadParallelTurnSteadyStateMutatingTurnEvery other benchmark is unchanged.
main:task lint: 0 issues except the same pre-existingenvironment/microvmG703 finding.task test:race: theenginemodule passes. In the root module, the failures are the pre-existing ones listed above, pluscmd/mecatuiTestStartupCompatibilityEmbeddedRejected(embedded socket discovery) and a 20-minute timeout indeploy/mecatl-execution-kind. Neither package is touched.TestYoloRelativeEscape*fails the same way on a clean currentorigin/main.task docspasses.go run ./cmd/mecademoshows the tool call, the permission ask and approval, and the result.BenchmarkEvaluatorEvaluate/simple-tooldrops from 4 to 3 allocs/op; the Shell cases are unchanged.task lint(same result as above),go test -raceforengine/governance,engine/session,engine/agentandengine/adapter/fstools, theinternal/appescape-policy and contextual-evidence tests, andgo run ./cmd/mecademo.Closes #2264
🤖 Generated with Claude Code