返回 DeepSeek-Reasonix
execute_one.go
根目录 / internal / agent / execute_one.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8
9 "reasonix/internal/checkpoint"
10 "reasonix/internal/event"
11 "reasonix/internal/evidence"
12 "reasonix/internal/instruction"
13 "reasonix/internal/jobs"
14 "reasonix/internal/memory"
15 "reasonix/internal/permission"
16 "reasonix/internal/planmode"
17 "reasonix/internal/provider"
18 "reasonix/internal/sandbox"
19 "reasonix/internal/tool"
20 )
21
22 // toolCallPlan holds the resolved, policy-checked state for one tool call.
23 // Package-private; not shared across goroutines beyond the single executeOne
24 // invocation that owns it.
25 type toolCallPlan struct {
26 call provider.ToolCall
27 tool tool.Tool
28 canonicalName string
29
30 permName string
31 permArgs json.RawMessage
32 execTool tool.Tool
33 execArgs json.RawMessage
34 evidenceName string
35 evidenceArgs json.RawMessage
36 readOnly bool
37
38 resolved tool.ResolvedCall
39 resolvedMeta *tool.ResolvedCall
40
41 mutates bool
42 verification bool
43 planTransition bool
44 planBefore string
45 planAfter string
46 planReplacementAuthorized bool
47 recoveryGen uint64
48
49 runTool tool.Tool
50 runArgs json.RawMessage
51 cctx context.Context
52 releaseParentWrite func()
53 releaseMutationWrite func()
54
55 // mutationPath is set when a Previewer described a concrete workspace path
56 // for AfterMutation fingerprint capture (success or failure).
57 mutationPath string
58 mutationObserved bool
59 mutationAfterDone bool
60 }
61
62 // executeOne runs a single tool call. It is pure with respect to the event sink
63 // — the caller emits ToolDispatch/ToolResult — so it is safe to invoke from
64 // parallel goroutines. Stages: parse → policy → prepare → finish.
65 func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) (out toolOutcome) {
66 plan := &toolCallPlan{call: call}
67 defer func() {
68 if plan.mutationObserved && !plan.mutationAfterDone {
69 a.observeAfterMutation(plan)
70 }
71 if plan.releaseMutationWrite != nil {
72 plan.releaseMutationWrite()
73 }
74 if plan.releaseParentWrite != nil {
75 plan.releaseParentWrite()
76 }
77 if plan.resolvedMeta == nil {
78 return
79 }
80 out.resolved = true
81 out.resolvedName = plan.resolvedMeta.TargetName
82 out.capabilityID = plan.resolvedMeta.CapabilityID
83 out.resolvedReadOnly = plan.resolvedMeta.ReadOnly
84 }()
85
86 if blocked, early := a.parseToolCall(plan); early {
87 return blocked
88 }
89 // tool.before: extensions rule on the parsed call before any policy or
90 // permission check. A valid replacement is re-parsed so every later stage
91 // sees the call that will actually execute.
92 if blocked, early := a.interceptToolBefore(ctx, plan); early {
93 return blocked
94 }
95 if blocked, early := a.resolveToolPolicy(ctx, plan); early {
96 return blocked
97 }
98 if blocked, early := a.prepareToolExecution(ctx, plan); early {
99 return blocked
100 }
101 return a.finishToolExecution(ctx, plan)
102 }
103
104 // parseToolCall resolves the canonical tool, rejects ambiguity/unknown tools,
105 // and applies repeat-success and stale-anchor guards.
106 func (a *Agent) parseToolCall(plan *toolCallPlan) (toolOutcome, bool) {
107 t, canonicalName, ambiguous := a.tools.ResolveCall(plan.call.Name)
108 if len(ambiguous) > 0 {
109 msg := fmt.Sprintf("ambiguous MCP tool reference %q; use one of: %s", plan.call.Name, strings.Join(ambiguous, ", "))
110 return toolOutcome{
111 output: "error: " + msg,
112 errMsg: msg,
113 }, true
114 }
115 if t == nil {
116 if server, ok := completedMCPConnect(a.tools, plan.call.Name); ok {
117 return toolOutcome{
118 output: fmt.Sprintf("MCP server %q is connected; its real tools are now available", server),
119 }, true
120 }
121 return toolOutcome{
122 output: fmt.Sprintf("error: unknown tool %q", plan.call.Name),
123 errMsg: fmt.Sprintf("unknown tool %q", plan.call.Name),
124 }, true
125 }
126 if out, blocked := a.repeatedSuccessBlock(plan.call, t); blocked {
127 return toolOutcome{
128 output: out,
129 blocked: true,
130 errMsg: loopGuardBlockErrMsg,
131 }, true
132 }
133 if out, blocked := a.repeatedFailureBlock(plan.call, t); blocked {
134 return toolOutcome{
135 output: out,
136 blocked: true,
137 errMsg: loopGuardBlockErrMsg,
138 }, true
139 }
140 if out, blocked := a.staleAnchorEditBlock(plan.call); blocked {
141 return toolOutcome{
142 output: out,
143 blocked: true,
144 errMsg: "blocked: fresh read required",
145 }, true
146 }
147 plan.tool = t
148 plan.canonicalName = canonicalName
149 plan.permName = canonicalName
150 plan.permArgs = json.RawMessage(plan.call.Arguments)
151 plan.execTool = t
152 plan.execArgs = json.RawMessage(plan.call.Arguments)
153 plan.evidenceName = canonicalName
154 plan.evidenceArgs = json.RawMessage(plan.call.Arguments)
155 plan.readOnly = t.ReadOnly()
156 if canonicalName == "bash" && permission.BashCommandIsReadOnly(plan.execArgs) {
157 // Bash is schema-level writer-capable, but the host can resolve a
158 // concrete invocation to read-only after parsing its arguments. Carry
159 // that fact through permission, mutation accounting, evidence, and the
160 // refreshed local tool receipt without changing the provider schema.
161 plan.readOnly = true
162 plan.resolvedMeta = &tool.ResolvedCall{TargetName: canonicalName, ReadOnly: true}
163 }
164 return toolOutcome{}, false
165 }
166
167 // resolveToolPolicy applies Plan mode, proxy resolution, delivery gates, Auto
168 // Guard, and permission checks. Permission must complete before any write lease.
169 func (a *Agent) resolveToolPolicy(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
170 if blocked, early := a.applyPlanModeAndProxy(ctx, plan); early {
171 return blocked, true
172 }
173 if blocked, early := a.applyDeliveryPolicyGates(plan); early {
174 return blocked, true
175 }
176 // After proxy resolution, re-apply the batch mutation barrier using the
177 // real target classification. Provider-visible proxies such as
178 // use_capability advertise ReadOnly()==true before resolution and would
179 // otherwise slip past the pre-run skip pass.
180 if blocked, early := a.applyMutationDependencyBarrier(plan); early {
181 return blocked, true
182 }
183 if blocked, early := a.applyRecoveryAndPermission(ctx, plan); early {
184 return blocked, true
185 }
186 return toolOutcome{}, false
187 }
188
189 // applyMutationDependencyBarrier blocks later mutations and verifications in the
190 // same provider batch after an earlier modification failed. Host-proven
191 // read-only diagnosis (resolved ReadOnly with no verification classification)
192 // still runs.
193 func (a *Agent) applyMutationDependencyBarrier(plan *toolCallPlan) (toolOutcome, bool) {
194 if a == nil || plan == nil || !a.mutationDependencyBarrier.Load() {
195 return toolOutcome{}, false
196 }
197 verification := plan.evidenceName == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(plan.evidenceArgs))
198 // Prefer the post-gate mutates flag; fall back to !readOnly so a resolved
199 // writer proxy cannot claim non-mutation by skipping ToolCallMutates.
200 mutates := plan.mutates || !plan.readOnly
201 if !mutates && !verification {
202 return toolOutcome{}, false
203 }
204 msg := "blocked: skipped because an earlier modification in this tool batch failed or was blocked. " +
205 "Fix or re-run the failed change first; verification was not executed."
206 var ex *tool.ShellExecution
207 // Structured shell metadata only for bash cards; other tools keep plain text.
208 if plan.evidenceName == "bash" || plan.call.Name == "bash" {
209 ex = shellPreflightExecution(plan, verification)
210 if ex != nil {
211 ex.FailurePhase = tool.ShellPhaseDependency
212 ex.State = tool.ShellStateNotRun
213 ex.MutationRisk = tool.ShellMutationNotStarted
214 if verification {
215 ex.Verification = tool.ShellVerificationNotRun
216 }
217 }
218 }
219 return toolOutcome{
220 output: msg,
221 blocked: true,
222 errMsg: firstLine(msg),
223 execution: ex,
224 }, true
225 }
226
227 // applyPlanModeAndProxy handles initial Plan mode, proxy resolution / skip path,
228 // resolved-target Plan re-check, and MCP Plan availability.
229 func (a *Agent) applyPlanModeAndProxy(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
230 t := plan.tool
231 call := plan.call
232 if a.planMode.Load() {
233 // Translate the tool's optional plan-mode self-report into the policy's
234 // tri-state. Mirrors the t.(tool.Previewer) assertion precedent below.
235 safety := planmode.PlanSafetyUnknown
236 if c, ok := t.(tool.PlanModeClassifier); ok {
237 if c.PlanModeSafe() {
238 safety = planmode.PlanSafetySafe
239 } else {
240 safety = planmode.PlanSafetyUnsafe
241 }
242 }
243 if decision := a.planModeDecision(plan.canonicalName, t.ReadOnly(), safety, json.RawMessage(call.Arguments)); decision.Blocked {
244 return toolOutcome{
245 output: decision.Message,
246 blocked: true,
247 errMsg: "blocked: tool is unavailable during planning",
248 }, true
249 }
250 }
251 // Resolve proxy tools (use_capability) to the real MCP target before
252 // permission, hooks, and evidence. Provider transcript keeps call.Name.
253 if resolver, ok := t.(tool.CallResolver); ok {
254 rc, rerr := resolver.ResolveCall(ctx, json.RawMessage(call.Arguments))
255 if rerr != nil {
256 return toolOutcome{
257 output: fmt.Sprintf("error: %v", rerr),
258 errMsg: firstLine(rerr.Error()),
259 }, true
260 }
261 plan.resolved = rc
262 plan.resolvedMeta = &plan.resolved
263 if rc.TargetName != "" {
264 plan.permName = rc.TargetName
265 plan.evidenceName = rc.TargetName
266 }
267 if len(rc.Args) > 0 {
268 plan.permArgs = rc.Args
269 plan.evidenceArgs = rc.Args
270 plan.execArgs = rc.Args
271 }
272 if rc.Target != nil {
273 plan.execTool = rc.Target
274 }
275 plan.readOnly = rc.ReadOnly
276 if outcome, blocked := a.readOnlyExecutionBlock(t, &rc); blocked {
277 return outcome, true
278 }
279 if rc.Commit != nil {
280 if err := rc.Commit(); err != nil {
281 return toolOutcome{
282 output: fmt.Sprintf("error: %v", err),
283 errMsg: firstLine(err.Error()),
284 }, true
285 }
286 }
287 if rc.SkipExecute {
288 // Resolution completed without target execution; still record a meta receipt.
289 // A connected mcp-server call completes during resolution by listing
290 // its live tools, so account for that successful call here too.
291 if rc.ProxyAction == "call" && !rc.Unavailable {
292 a.noteCapabilityInvocation(call.Name, json.RawMessage(call.Arguments), nil)
293 }
294 result := rc.Result
295 if a.evidence != nil {
296 // inspect/decline are not mutations; unavailable call targets are not success.
297 success := !rc.Unavailable
298 rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), success, true)
299 a.evidence.Record(rec)
300 }
301 if rc.Unavailable {
302 return toolOutcome{output: result, errMsg: firstLine(rc.UnavailableReason)}, true
303 }
304 body, truncMsg := truncateToolOutput(result)
305 return toolOutcome{output: body, truncated: truncMsg != "", truncMsg: truncMsg}, true
306 }
307 } else if outcome, blocked := a.readOnlyExecutionBlock(t, nil); blocked {
308 return outcome, true
309 }
310
311 // A proxy resolution can point at a target with an explicit planning-phase
312 // opt-out even though the proxy itself has none. Re-check the resolved target
313 // before its ordinary permission and sandbox path.
314 if plan.resolved.TargetName != "" && a.planMode.Load() {
315 safety := planmode.PlanSafetyUnknown
316 if c, ok := plan.execTool.(tool.PlanModeClassifier); ok {
317 if c.PlanModeSafe() {
318 safety = planmode.PlanSafetySafe
319 } else {
320 safety = planmode.PlanSafetyUnsafe
321 }
322 }
323 if decision := a.planModeDecision(plan.permName, plan.resolved.ReadOnly, safety, plan.permArgs); decision.Blocked {
324 return toolOutcome{
325 output: decision.Message,
326 blocked: true,
327 errMsg: "blocked: tool is unavailable during planning",
328 }, true
329 }
330 }
331 plannerTrustedMCP := a.plannerMCPExecution && isMCPExecutionTarget(plan.execTool, plan.permName) && mcpServerAuthorized(plan.execTool) && !mcpDestructiveHint(plan.execTool)
332 if a.planMode.Load() && isMCPExecutionTarget(plan.execTool, plan.permName) && !plannerTrustedMCP && (!plan.readOnly || !mcpServerAuthorized(plan.execTool) || mcpDestructiveHint(plan.execTool)) {
333 reason := "writer/destructive target"
334 if plan.readOnly && !mcpServerAuthorized(plan.execTool) {
335 reason = "reader from an unauthorized server"
336 }
337 return toolOutcome{
338 output: fmt.Sprintf("blocked: MCP %s %q is unavailable during Plan mode; finish or exit Plan mode before requesting this call", reason, plan.permName),
339 blocked: true,
340 errMsg: "blocked: MCP target is unavailable during planning",
341 }, true
342 }
343 return toolOutcome{}, false
344 }
345
346 // applyDeliveryPolicyGates enforces global deterministic shell contracts plus
347 // delivery-profile-only criteria rules, and classifies mutation/verification.
348 func (a *Agent) applyDeliveryPolicyGates(plan *toolCallPlan) (toolOutcome, bool) {
349 // Global deterministic shell contract (ordinary + Delivery). PowerShell 5.1
350 // &&/|| is enforced inside the bash tool itself so descriptor and error text
351 // stay shell-accurate; the agent layers apply command-shape protections.
352 // Delivery keeps its longer recovery copy so existing delivery guidance tests
353 // and model recovery prompts stay stable.
354 //
355 // Ordinary mode blocks only shapes where a later segment can actually hide an
356 // earlier failure. Delivery keeps the broader classifier because a mutation
357 // invalidates the verification receipt even when the exit status is honest.
358 // Without that split, `go build ./... && go test ./...`, `npm install &&
359 // npm test`, and every other short-circuit chain would be rejected for every
360 // user, though bash already reports the failing step's status for them.
361 if plan.evidenceName == "bash" {
362 if evidence.BashToolCallMasksVerificationExit(plan.evidenceArgs) {
363 msg := evidence.ShellContractPreflightMessage("mask_exit")
364 if a.deliveryProfile {
365 msg = "blocked: the trailing echo/printf of $? masks the verifier's exit status, so this command would look successful even when the check failed. Run the verifier or read-only extraction pipeline by itself and let its exit status be the tool result; for example: tail ... | head ... | node --check -"
366 }
367 return toolOutcome{
368 output: msg,
369 blocked: true,
370 errMsg: "blocked: verification exit status masked",
371 execution: shellPreflightExecution(plan, true),
372 }, true
373 }
374 mixed := evidence.BashToolCallMixesMutationAndMaskableVerification
375 if a.deliveryProfile {
376 mixed = evidence.BashToolCallMixesMutationAndVerification
377 }
378 if mixed(plan.evidenceArgs) {
379 msg := evidence.ShellContractPreflightMessage("mixed")
380 if a.deliveryProfile {
381 msg = "blocked: this command mixes a verification check with a segment that may write state. Run the state-changing preparation separately while a todo is in_progress, then run a read-only verification command. For generated input, prefer a host-recognized read-only pipeline into the verifier (for example: tail ... | head ... | node --check -) instead of writing a temporary file."
382 }
383 return toolOutcome{
384 output: msg,
385 blocked: true,
386 errMsg: "blocked: mixed mutation and verification command",
387 execution: shellPreflightExecution(plan, true),
388 }, true
389 }
390 if evidence.BashToolCallUsesNonTerminalInlineInterpreter(plan.evidenceArgs) {
391 msg := evidence.ShellContractPreflightMessage("inline_nonterminal")
392 return toolOutcome{
393 output: msg,
394 blocked: true,
395 errMsg: "blocked: non-terminal inline interpreter command",
396 execution: shellPreflightExecution(plan, false),
397 }, true
398 }
399 }
400 // Delivery-only: any opaque inline interpreter is unauditable as evidence.
401 if a.deliveryProfile && plan.evidenceName == "bash" && evidence.BashToolCallUsesOpaqueInlineInterpreter(plan.evidenceArgs) {
402 return toolOutcome{
403 output: "blocked: delivery mode cannot audit inline interpreter source such as node -e or python -c, so executing it would become an opaque mutation and invalidate prior verification. For inspection, use read_file/grep or another host-proven read-only command. For validation, use a conventional verifier such as node --check, a project test/check/lint command, or a read-only extraction pipeline into the verifier. For an intentional state change, use a file tool or a script file under the current in_progress todo. " + evidence.VerificationCommandSummary(),
404 blocked: true,
405 errMsg: "blocked: opaque inline interpreter command",
406 execution: shellPreflightExecution(plan, false),
407 }, true
408 }
409
410 plan.mutates = evidence.ToolCallMutates(plan.evidenceName, plan.evidenceArgs, plan.readOnly)
411 persistentWorkflowCall := a.deliveryPersistentExpected && !a.deliveryMutationExpected && plan.evidenceName == "remember"
412 if a.deliveryProfile && !persistentWorkflowCall && evidence.ToolCallRequiresDeliveryCriteria(plan.evidenceName, plan.evidenceArgs, plan.readOnly) && !a.deliveryCriteriaEstablished {
413 return toolOutcome{
414 output: "blocked: delivery-first mode requires acceptance criteria before state-changing work. Call todo_write with a concrete, verifiable task list, then retry this tool call.",
415 blocked: true,
416 errMsg: "blocked: delivery acceptance criteria required",
417 }, true
418 }
419 if a.deliveryProfile && !persistentWorkflowCall && plan.mutates && !a.hasActiveCanonicalTodo() {
420 return toolOutcome{
421 output: "blocked: delivery-first mode requires every state change to belong to the current in_progress todo. Preserve the completed todo prefix, append a concrete new item if more work was discovered, mark that item in_progress with todo_write, then retry this mutation.",
422 blocked: true,
423 errMsg: "blocked: active delivery todo required",
424 }, true
425 }
426 return toolOutcome{}, false
427 }
428
429 // applyRecoveryAndPermission runs Auto Guard then ordinary permission. Neither
430 // acquires a write lease; that happens only after permission in prepare.
431 func (a *Agent) applyRecoveryAndPermission(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
432 // Auto Guard: after resolution/mutation classification, before
433 // permission approval and workspace write-lock acquisition, so a waiting
434 // recovery card never holds a write lease. Consult on mutations,
435 // verification, plan transitions, and again for every tool once an Episode
436 // is exhausted so host-proven read-only diagnosis can remain available while
437 // further execution is quarantined. Ask/Yolo still bypass inside the gate.
438 plan.verification = plan.evidenceName == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(plan.evidenceArgs))
439 plan.planTransition, plan.planBefore, plan.planAfter = a.recoveryPlanTransition(plan.evidenceName, plan.evidenceArgs)
440 episodeStopped := false
441 if ctrl := a.recoveryEpisodeControl(); ctrl != nil {
442 plan.recoveryGen = ctrl.Generation()
443 episodeStopped = ctrl.EpisodeStopped(a.recoveryTaskID)
444 }
445 if a.recoveryGate != nil && (plan.mutates || plan.verification || plan.planTransition || episodeStopped) {
446 subject := recoverySubject(plan.evidenceName, plan.evidenceArgs)
447 if plan.planTransition {
448 subject = "Update the active execution plan"
449 }
450 preview := strings.TrimSpace(plan.call.Diff)
451 if preview == "" {
452 preview = subject
453 }
454 if plan.planTransition {
455 preview = plan.planAfter
456 }
457 episodeID := ""
458 if ctrl := a.recoveryEpisodeControl(); ctrl != nil {
459 episodeID = ctrl.EpisodeID()
460 }
461 dec, rerr := a.recoveryGate.BeforeMutation(ctx, RecoveryProposal{
462 AgentID: a.recoveryAgentID,
463 TaskID: a.recoveryTaskID,
464 TaskScopeID: recoveryTaskScopeID(a.deliveryScopeID, a.recoveryRunSeq.Load()),
465 EpisodeID: episodeID,
466 TaskSummary: a.recoveryTaskSummary,
467 Tool: plan.evidenceName,
468 Args: plan.evidenceArgs,
469 Subject: subject,
470 Preview: preview,
471 ReadOnly: plan.readOnly,
472 Mutates: plan.mutates,
473 Verification: plan.verification,
474 PlanTransition: plan.planTransition,
475 PlanBefore: plan.planBefore,
476 PlanAfter: plan.planAfter,
477 })
478 if dec.Generation != 0 {
479 plan.recoveryGen = dec.Generation
480 }
481 if rerr != nil && !dec.Blocked {
482 return toolOutcome{
483 output: fmt.Sprintf("blocked: Auto Guard error: %v", rerr),
484 blocked: true,
485 errMsg: "blocked: Auto Guard error",
486 recoveryGeneration: plan.recoveryGen,
487 }, true
488 }
489 if dec.Blocked || !dec.Allow {
490 msg := strings.TrimSpace(dec.Message)
491 if msg == "" {
492 msg = "blocked: Auto Guard declined this mutation"
493 }
494 if !strings.HasPrefix(msg, "blocked:") {
495 msg = "blocked: " + msg
496 }
497 return toolOutcome{
498 output: msg,
499 blocked: true,
500 // Surface the concrete stopped operation and next step in the
501 // failed tool card instead of exposing only an internal guard name.
502 errMsg: firstLine(msg),
503 recoveryGeneration: plan.recoveryGen,
504 recoveryStopTurn: dec.StopTurn,
505 recoveryStopReason: dec.StopReason,
506 }, true
507 }
508 plan.planReplacementAuthorized = plan.planTransition && dec.AuthorizePlanReplacement
509 }
510 // Trusted MCP fast path: installed tools and authorized lifecycle connects
511 // (mcp_connect__*) skip ordinary Ask/Auto/dontAsk gates. Only explicit deny
512 // and live authorization apply — first connect of an installed server must
513 // not re-prompt under headless or partial-auto policies.
514 if isInstalledMCPTool(plan.execTool) || isMCPLifecycleConnectTarget(plan.execTool) {
515 if !mcpServerAuthorized(plan.execTool) {
516 return toolOutcome{
517 output: "blocked: this project MCP server identity has not been authorized; approve the server from a parent session and retry",
518 blocked: true,
519 errMsg: "blocked: MCP server identity is not authorized",
520 }, true
521 }
522 if denyGate, ok := a.gate.(ExplicitDenyGate); ok && denyGate.ExplicitlyDenies(plan.permName, plan.permArgs) {
523 return toolOutcome{
524 output: "blocked: denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain.",
525 blocked: true,
526 errMsg: "blocked by permission policy",
527 }, true
528 }
529 } else if a.gate != nil {
530 allow, reason, err := a.gate.Check(ctx, plan.permName, plan.permArgs, plan.readOnly)
531 if err != nil {
532 return toolOutcome{
533 output: fmt.Sprintf("blocked: %s (%v)", reason, err),
534 blocked: true,
535 errMsg: fmt.Sprintf("blocked: %v", err),
536 }, true
537 }
538 // permission.decision: the host verdict is computed first; the
539 // extension ruling may override it in either direction (an allow
540 // overriding a host deny is the full-trust contract and is audited).
541 if blocked, early := a.interceptExtensionPermission(ctx, plan, &allow); early {
542 return blocked, true
543 }
544 if !allow {
545 return toolOutcome{
546 output: "blocked: " + reason,
547 blocked: true,
548 errMsg: "blocked by permission policy",
549 }, true
550 }
551 }
552 return toolOutcome{}, false
553 }
554
555 // prepareToolExecution acquires write leases, parent write claims, runs
556 // PreToolUse hooks and preview checkpoints, and injects call context. All of
557 // this happens after permission and before the concrete Execute call.
558 func (a *Agent) prepareToolExecution(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
559 // Acquire after permission is granted but before PreToolUse: hooks are user
560 // shell code and can themselves change the workspace. This keeps readers
561 // concurrent and avoids holding the workspace during an approval prompt while
562 // still covering every write-side action that follows authorization.
563 if a.deliveryProfile && plan.mutates && a.workspaceLease != nil {
564 if err := a.workspaceLease.AcquireWrite(ctx); err != nil {
565 return toolOutcome{
566 output: fmt.Sprintf("blocked: the workspace did not become available for Delivery writing: %v", err),
567 blocked: true,
568 errMsg: "blocked: workspace write lease unavailable",
569 }, true
570 }
571 }
572 // Resolve the concrete execution target before hooks. A proxy may carry a
573 // different target/name/argument set than the provider-visible call.
574 plan.runTool = plan.execTool
575 plan.runArgs = plan.execArgs
576 if plan.resolved.Target != nil {
577 plan.runTool = plan.resolved.Target
578 plan.runArgs = plan.resolved.Args
579 if len(plan.runArgs) == 0 {
580 plan.runArgs = json.RawMessage(`{}`)
581 }
582 }
583 // Hold the parent claim before PreToolUse: hooks are user shell code and may
584 // mutate the same workspace. The reservation remains live through hooks,
585 // checkpointing, and the concrete Execute call, closing both hook-side and
586 // check-before-write TOCTOU windows. Dynamic Economy/MCP tools are covered
587 // here after registry lookup without schema-changing wrappers.
588 // executeOne defers plan.releaseParentWrite so every return path releases.
589 if releaseParentWrite, perr := a.reserveParentWrite(plan.runTool, plan.runArgs, plan.readOnly); perr != nil {
590 return toolOutcome{
591 output: "blocked: " + perr.Error(),
592 blocked: true,
593 errMsg: "blocked: write path claimed by background subagent",
594 }, true
595 } else if releaseParentWrite != nil {
596 plan.releaseParentWrite = releaseParentWrite
597 }
598 // Acquire the checkpoint barrier before preimage capture and any hook. It is
599 // held through post hooks and AfterMutation so rewind cannot interleave with
600 // writer-side user code.
601 if !plan.readOnly && a.mutationObserver != nil && a.mutationObserver.Store() != nil {
602 barrier := a.mutationObserver.Store().Barrier()
603 if err := barrier.EnterWrite(); err != nil {
604 return toolOutcome{output: "blocked: " + err.Error(), blocked: true, errMsg: "blocked: mutation barrier unavailable"}, true
605 }
606 plan.releaseMutationWrite = barrier.ExitWrite
607 }
608 // Checkpoint the file this writer is about to change before PreToolUse.
609 // A hook may mutate and then block the call, so the deferred AfterMutation
610 // still finalizes the fingerprint on every return path. Built-in
611 // Previewers get precise paths (complete coverage). Bash / opaque MCP
612 // writers record explicit coverage gaps instead of guessing targets.
613 if !plan.readOnly {
614 a.observeBeforeMutation(plan)
615 plan.mutationObserved = plan.mutationPath != ""
616 if toolHooksMayMutateWorkspace(a.hooks) && a.mutationObserver != nil {
617 a.mutationObserver.RecordGap(checkpoint.CoverageGap{Reason: checkpoint.GapHookWrite, Tool: plan.evidenceName, Detail: "tool hook may write paths that are not declared by the tool"})
618 }
619 }
620 // Proxy tools fire hooks against the real MCP target name and arguments.
621 if a.hooks != nil {
622 if block, msg := a.hooks.PreToolUse(ctx, plan.permName, plan.permArgs); block {
623 if msg == "" {
624 msg = "blocked by a PreToolUse hook"
625 }
626 return toolOutcome{
627 output: "blocked: " + msg,
628 blocked: true,
629 errMsg: "blocked by PreToolUse hook",
630 }, true
631 }
632 }
633 cctx := withCallContext(ctx, plan.call.ID, a.sink, a.asker, a.planMode.Load())
634 cctx = WithSubagentDepth(cctx, a.subagentDepth)
635 if a.evidence != nil {
636 cctx = evidence.WithLedger(cctx, a.evidence)
637 cctx = evidence.WithSessionMessages(cctx, a.session.Snapshot())
638 if a.deliveryProfile {
639 cctx = evidence.WithDeliveryProfile(cctx)
640 }
641 }
642 if !a.planMode.Load() {
643 cctx = evidence.WithTodoState(cctx, a.CanonicalTodoState())
644 }
645 if plan.planReplacementAuthorized {
646 cctx = tool.WithPlanReplacementAuthorization(cctx)
647 }
648 if len(a.projectChecks) > 0 {
649 cctx = instruction.WithChecks(cctx, a.projectChecks)
650 }
651 if a.jobs != nil {
652 cctx = jobs.WithManager(cctx, a.jobs)
653 }
654 if a.sandboxEscapeApprover != nil {
655 cctx = sandbox.WithEscapeApprover(cctx, a.sandboxEscapeApprover)
656 }
657 if a.configWriteApprover != nil {
658 cctx = tool.WithConfigWriteApprover(cctx, a.configWriteApprover)
659 }
660 if v := a.responseLanguage.Load(); v != nil {
661 if lang, ok := v.(string); ok {
662 cctx = WithResponseLanguagePreference(cctx, lang)
663 }
664 }
665 if v := a.reasoningLanguage.Load(); v != nil {
666 if lang, ok := v.(string); ok {
667 cctx = WithReasoningLanguagePreference(cctx, lang)
668 }
669 }
670 if a.memQueue != nil {
671 cctx = memory.WithQueue(cctx, a.memQueue)
672 }
673 callID := plan.call.ID
674 cctx = tool.WithProgress(cctx, func(chunk string) {
675 a.sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: callID, Output: chunk}})
676 })
677 plan.cctx = cctx
678 return toolOutcome{}, false
679 }
680
681 type toolMutationHookReporter interface {
682 ToolMutationHooksEnabled() bool
683 }
684
685 func toolHooksMayMutateWorkspace(hooks ToolHooks) bool {
686 if hooks == nil {
687 return false
688 }
689 if reporter, ok := hooks.(toolMutationHookReporter); ok {
690 return reporter.ToolMutationHooksEnabled()
691 }
692 // Custom ToolHooks implementations predate the capability report. Preserve
693 // conservative coverage for them because their callbacks may write files.
694 return true
695 }
696
697 // finishToolExecution performs the concrete Execute, records evidence, runs
698 // post hooks and recovery observation, and truncates the model-facing result.
699 func (a *Agent) finishToolExecution(ctx context.Context, plan *toolCallPlan) toolOutcome {
700 cctx := plan.cctx
701 runTool := plan.runTool
702 runArgs := plan.runArgs
703 call := plan.call
704 t := plan.tool
705 readOnly := plan.readOnly
706 permName := plan.permName
707 permArgs := plan.permArgs
708 evidenceName := plan.evidenceName
709 evidenceArgs := plan.evidenceArgs
710 mutates := plan.mutates
711 recoveryGen := plan.recoveryGen
712
713 var result string
714 var images []string
715 var err error
716 // A call that was authorized under reader classification carries that
717 // basis into dispatch: the MCP execution layer re-verifies it linearizably
718 // against server authorization and live safety metadata, and refuses to
719 // promote it into a writer lane if reclassification landed after the gate.
720 if readOnly && isInstalledMCPTool(runTool) && mcpServerAuthorized(runTool) && !mcpDestructiveHint(runTool) {
721 cctx = tool.WithReaderExecutionIntent(cctx)
722 }
723 // Planner-trusted MCP: authorized + non-destructive, even without
724 // readOnlyHint. Final dispatch re-checks live authorization/destructiveHint.
725 if a.plannerMCPExecution && isMCPExecutionTarget(runTool, permName) && mcpServerAuthorized(runTool) && !mcpDestructiveHint(runTool) {
726 cctx = tool.WithNonDestructiveMCPExecutionIntent(cctx)
727 }
728 var execution *tool.ShellExecution
729 if de, ok := runTool.(tool.DetailedExecutor); ok {
730 var detailed tool.DetailedResult
731 detailed, err = de.ExecuteDetailed(cctx, runArgs)
732 result, images, execution = detailed.Output, detailed.Images, detailed.Execution
733 // Annotate verification outcome when the host classified this call as a verifier.
734 if execution != nil && plan.verification {
735 switch {
736 case err != nil:
737 execution.Verification = tool.ShellVerificationFailed
738 default:
739 execution.Verification = tool.ShellVerificationPassed
740 }
741 } else if execution != nil && execution.Verification == "" {
742 execution.Verification = tool.ShellVerificationNotVerification
743 }
744 // Sole opaque inline interpreters are allowed outside Delivery but cannot
745 // prove mutation completeness.
746 if execution != nil && evidence.BashCommandMayBeOpaqueMutation(runArgs) &&
747 execution.MutationRisk == tool.ShellMutationMayHaveCompleted {
748 execution.MutationRisk = tool.ShellMutationUnknown
749 }
750 } else if it, ok := runTool.(tool.ImageTool); ok {
751 result, images, err = it.ExecuteWithImages(cctx, runArgs)
752 } else {
753 result, err = runTool.Execute(cctx, runArgs)
754 }
755 // tool.after: extensions rule on the executed result (success or error)
756 // before evidence, hooks, and recovery observation, so every downstream
757 // consumer sees the final (possibly replaced) outcome.
758 result, err = a.interceptToolAfter(ctx, call, result, err)
759 if a.evidence != nil {
760 // Always record the model-visible call for audit, then the real target
761 // attributes for mutation/read classification when they differ.
762 if call.Name == "complete_step" {
763 rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), err == nil, readOnly)
764 a.evidence.Record(rec)
765 if err == nil {
766 a.advanceCanonicalTodo(rec.Step)
767 }
768 } else if evidenceName != call.Name {
769 // Proxy: meta receipt (non-mutation) + real target receipt.
770 a.evidence.Record(evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), err == nil, true))
771 rec := evidence.ReceiptFromToolCall(evidenceName, evidenceArgs, err == nil, readOnly)
772 rec.OutputBytes = len(strings.TrimSpace(result))
773 a.evidence.Record(rec)
774 } else {
775 rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), err == nil, t.ReadOnly())
776 rec.OutputBytes = len(strings.TrimSpace(result))
777 a.evidence.Record(rec)
778 if err == nil && call.Name == "todo_write" {
779 a.setTodoState(rec.Todos)
780 if len(rec.Todos) > 0 {
781 a.deliveryCriteriaEstablished = true
782 }
783 }
784 }
785 }
786 // Track skill/capability outcomes for Delivery gates.
787 a.noteCapabilityInvocation(call.Name, json.RawMessage(call.Arguments), err)
788 // Success and failure hooks observe the result after the tool ran. Use the
789 // real target name for proxied tools.
790 if a.hooks != nil {
791 if err != nil {
792 a.hooks.PostToolUseFailure(ctx, permName, permArgs, result, err)
793 } else {
794 a.hooks.PostToolUse(ctx, permName, permArgs, result)
795 }
796 }
797 // Always re-read after post hooks — partial writes and hook side effects can
798 // change the previewed path even when the concrete tool returned an error.
799 a.observeAfterMutation(plan)
800 plan.mutationAfterDone = true
801 if a.recoveryGate != nil {
802 a.observeRecoveryResult(ctx, evidenceName, evidenceArgs, readOnly, mutates, result, err, false, false, recoveryGen)
803 }
804 if err != nil {
805 detail := result
806 // Malformed-args failures are a transient model JSON glitch (e.g. options
807 // written as ["a":"b"] → "invalid character ':' after array element"). The
808 // args can't be safely re-parsed, but echoing the tool's schema makes the
809 // retry land valid instead of repeating the same broken shape.
810 if !json.Valid([]byte(call.Arguments)) {
811 detail = strings.TrimRight(detail, "\n") + "\nThe arguments were not valid JSON. Re-emit them exactly per this schema:\n" + string(t.Schema())
812 }
813 a.recordRepeatFailure(call, t, err)
814 body, truncMsg := truncateToolOutput(fmt.Sprintf("error: %v\n%s", err, detail))
815 return toolOutcome{
816 output: body, errMsg: firstLine(err.Error()), truncated: truncMsg != "", truncMsg: truncMsg,
817 execution: execution, recoveryGeneration: recoveryGen,
818 }
819 }
820 if mutates {
821 a.clearRepeatFailuresAfterMutation(evidenceName, evidenceArgs, readOnly)
822 }
823 a.recordRepeatSuccess(call, t)
824 // A foreground `task` sub-agent just finished — its result is the final answer.
825 // (A backgrounded one returns a "Started…" string and stops later in a job, so
826 // it doesn't fire here.) SubagentStop lets a hook react to delegated work.
827 if a.hooks != nil && call.Name == "task" && !isBackgroundTaskCall(call.Arguments) {
828 a.hooks.SubagentStop(ctx, result)
829 }
830 body, truncMsg := truncateToolOutput(result)
831 return toolOutcome{
832 output: body, images: images, truncated: truncMsg != "", truncMsg: truncMsg,
833 execution: execution, recoveryGeneration: recoveryGen,
834 }
835 }
836
837 // shellPreflightExecution builds not_run/preflight metadata for a blocked bash call.
838 func shellPreflightExecution(plan *toolCallPlan, hasVerification bool) *tool.ShellExecution {
839 ex := &tool.ShellExecution{
840 Kind: "shell",
841 State: tool.ShellStateNotRun,
842 FailurePhase: tool.ShellPhasePreflight,
843 MutationRisk: tool.ShellMutationNotStarted,
844 Verification: tool.ShellVerificationNotVerification,
845 }
846 if hasVerification {
847 ex.Verification = tool.ShellVerificationNotRun
848 }
849 if plan != nil {
850 if de, ok := plan.execTool.(tool.DetailedExecutor); ok {
851 if desc := de.ExecutionDescriptor(plan.execArgs); desc != nil {
852 ex.Shell = desc.Shell
853 ex.ShellVersion = desc.ShellVersion
854 ex.Platform = desc.Platform
855 ex.SupportsAndAnd = desc.SupportsAndAnd
856 }
857 }
858 }
859 return ex
860 }
861
862 // observeBeforeMutation captures preimages for Previewable writers and records
863 // explicit coverage gaps for bash / opaque MCP tools. Host-internal only.
864 func (a *Agent) observeBeforeMutation(plan *toolCallPlan) {
865 if a == nil || plan == nil {
866 return
867 }
868 toolName := plan.evidenceName
869 if toolName == "" {
870 toolName = plan.call.Name
871 }
872 obs := a.mutationObserver
873 if obs != nil {
874 if pv, ok := plan.execTool.(tool.Previewer); ok {
875 if change, perr := pv.Preview(plan.execArgs); perr == nil && change.Path != "" {
876 obs.BeforeMutationFromChange(change, toolName)
877 plan.mutationPath = change.Path
878 return
879 }
880 }
881 // Non-previewable writers: record a coverage gap (do not guess paths).
882 switch toolName {
883 case "bash":
884 obs.RecordGap(checkpoint.CoverageGap{Reason: checkpoint.GapBashSideEffect, Tool: toolName, Detail: "bash side effects are not path-tracked"})
885 default:
886 // MCP or other writers without Previewer.
887 if !plan.readOnly {
888 obs.RecordGap(checkpoint.CoverageGap{Reason: checkpoint.GapMCPExternal, Tool: toolName, Detail: "tool cannot describe local write paths"})
889 }
890 }
891 return
892 }
893 // Legacy onPreEdit path.
894 if a.onPreEdit != nil {
895 if pv, ok := plan.execTool.(tool.Previewer); ok {
896 if change, perr := pv.Preview(plan.execArgs); perr == nil {
897 a.onPreEdit(change)
898 plan.mutationPath = change.Path
899 }
900 }
901 }
902 }
903
904 // observeAfterMutation records the after fingerprint when a concrete path was
905 // known before execution, regardless of tool success or failure.
906 func (a *Agent) observeAfterMutation(plan *toolCallPlan) {
907 if a == nil || plan == nil || plan.mutationPath == "" || a.mutationObserver == nil {
908 return
909 }
910 toolName := plan.evidenceName
911 if toolName == "" {
912 toolName = plan.call.Name
913 }
914 a.mutationObserver.AfterMutation(plan.mutationPath, toolName)
915 }
916
916 lines GO