返回 CodeWhale
tool_preparation.rs
根目录 / crates / tui / src / core / engine / tool_preparation.rs
1 //! Side-effect-free preparation of concrete tool inputs.
2 //!
3 //! The turn loop remains the authority orchestrator. This module only makes
4 //! the input-specific policy decision inspectable and reusable, including a
5 //! mandatory second preparation after a hook rewrites input.
6
7 use std::collections::BTreeSet;
8 use std::path::PathBuf;
9
10 use serde_json::Value;
11
12 use codewhale_execpolicy::ApprovalMode;
13
14 use crate::mcp::McpPool;
15 use crate::tools::ToolRegistry;
16 use crate::tools::approval_cache::{computer_use_batch_hidden_gate, computer_use_user_gate};
17 use crate::tools::spec::{ApprovalRequirement, PreparedToolCall, ResourceClaim, ToolError};
18
19 use super::dispatch::{
20 mcp_tool_approval_description, mcp_tool_is_parallel_safe, mcp_tool_is_read_only,
21 };
22 use super::tool_catalog::{
23 CODE_EXECUTION_TOOL_NAME, EXECUTE_TOOLS_TOOL_NAME, JS_EXECUTION_TOOL_NAME, is_tool_search_tool,
24 };
25
26 #[derive(Debug, Clone, PartialEq)]
27 pub(super) struct PreparedToolPolicy {
28 pub(super) call: PreparedToolCall,
29 pub(super) auto_approve: bool,
30 }
31
32 /// Prepare a concrete call without mutating external state.
33 pub(super) fn prepare_tool_call(
34 name: &str,
35 input: Value,
36 registry: Option<&ToolRegistry>,
37 session_auto_approve: bool,
38 ) -> Result<PreparedToolPolicy, ToolError> {
39 if McpPool::is_mcp_tool(name) {
40 // CW-11: a reviewed plugin's `readOnlyHint` makes its tool run like
41 // the built-in resource reads. A declared `destructiveHint` only
42 // withholds that relaxation and labels the card: Full Access still
43 // covers it (#3866), because a host that answers approvals from its
44 // own flag (`exec` with a Full Access `approval_policy`) would
45 // otherwise deny a call its posture already allows.
46 let read_only = mcp_tool_is_read_only(name)
47 || crate::mcp::mcp_tool_approval_hint(name)
48 == Some(crate::mcp::McpToolApprovalHint::TrustedReadOnly);
49 // A bounded worker keeps the execution gate's rule (built-in resource
50 // reads only), so preparation never admits a call that
51 // `tool_execution` then refuses.
52 if !mcp_tool_is_read_only(name)
53 && let Some(authority) =
54 registry.and_then(|registry| registry.context().tool_authority.as_ref())
55 {
56 return Err(ToolError::permission_denied(format!(
57 "worker '{}' cannot run mutating MCP tool {name}: it has no bounded file target under the machine-readable authority envelope",
58 authority.owner
59 )));
60 }
61 // K1/K2 stopgap: Computer Use consent and `app_script` need a human
62 // decision. Never auto-approve them, and refuse them outright in a
63 // posture that cannot open the approval card (Full Access,
64 // Auto-Review, Never) — otherwise the model's own tool call would be
65 // the consent.
66 if let Some(inner) = computer_use_batch_hidden_gate(name, &input) {
67 return Err(ToolError::permission_denied(format!(
68 "Computer Use {inner} cannot run inside {name}: consent and scripts need their own approval card. Call it on its own so the user can decide."
69 )));
70 }
71 if computer_use_user_gate(name, &input).is_some() {
72 let posture = registry.map(|registry| {
73 let context = registry.context();
74 (context.auto_approve, context.approval_mode)
75 });
76 let card_available = !session_auto_approve
77 && posture.is_none_or(|(auto_approve, approval_mode)| {
78 !auto_approve && approval_mode == ApprovalMode::Suggest
79 });
80 if !card_available {
81 let label = posture.map_or("Full Access", |(auto_approve, approval_mode)| {
82 if auto_approve {
83 ApprovalMode::Bypass.permission_chip_label()
84 } else {
85 approval_mode.permission_chip_label()
86 }
87 });
88 return Err(ToolError::permission_denied(format!(
89 "Computer Use call {name} needs your own approval: consent and scripts cannot be granted by a model tool call, and the current {label} posture cannot show an approval card. Switch to Ask mode to review it."
90 )));
91 }
92 return Ok(PreparedToolPolicy {
93 call: PreparedToolCall {
94 name: name.to_string(),
95 description: mcp_tool_approval_description(name, &input),
96 input,
97 read_only: false,
98 supports_parallel: false,
99 starts_detached: false,
100 approval: ApprovalRequirement::Required,
101 resources: vec![ResourceClaim::GlobalExclusive],
102 },
103 auto_approve: false,
104 });
105 }
106 return Ok(PreparedToolPolicy {
107 call: PreparedToolCall {
108 name: name.to_string(),
109 description: mcp_tool_approval_description(name, &input),
110 input,
111 read_only,
112 supports_parallel: mcp_tool_is_parallel_safe(name),
113 starts_detached: false,
114 approval: if read_only {
115 ApprovalRequirement::Auto
116 } else {
117 ApprovalRequirement::Suggest
118 },
119 resources: vec![ResourceClaim::GlobalExclusive],
120 },
121 auto_approve: session_auto_approve,
122 });
123 }
124
125 if let Some(registry) = registry
126 && let Some(spec) = registry.get(name)
127 {
128 let mut call = spec.prepare(input, registry.context())?;
129 call.resources = registered_resource_claims(name, &call.input, registry.context())?;
130 return Ok(PreparedToolPolicy {
131 call,
132 auto_approve: registry.context().auto_approve,
133 });
134 }
135
136 if name == CODE_EXECUTION_TOOL_NAME {
137 reject_unbounded_execution_under_authority(name, registry)?;
138 return Ok(conservative_execution_policy(
139 name,
140 input,
141 "Run model-provided Python code with the current execution policy",
142 session_auto_approve,
143 ));
144 }
145
146 if name == EXECUTE_TOOLS_TOOL_NAME {
147 reject_unbounded_execution_under_authority(name, registry)?;
148 let first_line = input
149 .get("code")
150 .and_then(Value::as_str)
151 .and_then(|code| code.lines().map(str::trim).find(|line| !line.is_empty()))
152 .unwrap_or("execute_tools program");
153 let mut policy = conservative_execution_policy(
154 name,
155 input.clone(),
156 &format!("execute_tools: {first_line}"),
157 session_auto_approve,
158 );
159 // #6562: every nested call is planned and approved through the same
160 // gate as a direct call (or, without an engine gate, limited to
161 // read-only auto-approved calls), so approving the program itself
162 // would grant nothing. It stays exclusive and non-read-only.
163 policy.call.approval = ApprovalRequirement::Auto;
164 return Ok(policy);
165 }
166
167 if name == JS_EXECUTION_TOOL_NAME {
168 reject_unbounded_execution_under_authority(name, registry)?;
169 return Ok(conservative_execution_policy(
170 name,
171 input,
172 "Run model-provided JavaScript code with the current execution policy",
173 session_auto_approve,
174 ));
175 }
176
177 if is_tool_search_tool(name) {
178 return Ok(PreparedToolPolicy {
179 call: PreparedToolCall {
180 name: name.to_string(),
181 input,
182 description: "Search tool catalog".to_string(),
183 read_only: true,
184 supports_parallel: false,
185 starts_detached: false,
186 approval: ApprovalRequirement::Auto,
187 resources: Vec::new(),
188 },
189 auto_approve: session_auto_approve,
190 });
191 }
192
193 Err(ToolError::not_available(format!(
194 "tool '{name}' has no preparation path"
195 )))
196 }
197
198 fn reject_unbounded_execution_under_authority(
199 name: &str,
200 registry: Option<&ToolRegistry>,
201 ) -> Result<(), ToolError> {
202 let Some(authority) = registry.and_then(|registry| registry.context().tool_authority.as_ref())
203 else {
204 return Ok(());
205 };
206 Err(ToolError::permission_denied(format!(
207 "worker '{}' cannot run {name}: arbitrary code execution cannot prove a bounded file target under the machine-readable authority envelope",
208 authority.owner
209 )))
210 }
211
212 /// Re-run preparation from the rewritten input rather than patching any
213 /// previously derived field.
214 pub(super) fn reprepare_tool_call_after_hook(
215 name: &str,
216 updated_input: Value,
217 registry: Option<&ToolRegistry>,
218 session_auto_approve: bool,
219 ) -> Result<PreparedToolPolicy, ToolError> {
220 prepare_tool_call(name, updated_input, registry, session_auto_approve)
221 }
222
223 fn conservative_execution_policy(
224 name: &str,
225 input: Value,
226 description: &str,
227 auto_approve: bool,
228 ) -> PreparedToolPolicy {
229 PreparedToolPolicy {
230 call: PreparedToolCall {
231 name: name.to_string(),
232 input,
233 description: description.to_string(),
234 read_only: false,
235 supports_parallel: false,
236 starts_detached: false,
237 approval: ApprovalRequirement::Suggest,
238 resources: vec![ResourceClaim::GlobalExclusive],
239 },
240 auto_approve,
241 }
242 }
243
244 fn registered_resource_claims(
245 name: &str,
246 input: &Value,
247 context: &crate::tools::ToolContext,
248 ) -> Result<Vec<ResourceClaim>, ToolError> {
249 let canonical = crate::tools::canonical_action::canonical_action_alias(name, input);
250 match canonical {
251 "read_file" => path_claim(input, "path", None, context, ResourceClaim::ReadPath),
252 "write_file" | "edit_file" => {
253 path_claim(input, "path", None, context, ResourceClaim::WritePath)
254 }
255 "list_dir" | "grep_files" | "file_search" => {
256 path_claim(input, "path", Some("."), context, ResourceClaim::ReadTree)
257 }
258 "apply_patch" => apply_patch_resource_claims(input, context),
259 "terminal/run" => Ok(terminal_claim(input, "session", Some("term-1"))),
260 "terminal/send" | "terminal/wait" | "terminal/cancel" | "terminal/reset" => {
261 Ok(terminal_claim(input, "session", None))
262 }
263 "exec_shell_wait"
264 | "exec_wait"
265 | "exec_shell_interact"
266 | "exec_interact"
267 | "exec_shell_cancel" => Ok(terminal_claim(input, "task_id", None)),
268 _ => Ok(global_exclusive_claim()),
269 }
270 }
271
272 fn path_claim(
273 input: &Value,
274 key: &str,
275 default: Option<&str>,
276 context: &crate::tools::ToolContext,
277 build: fn(PathBuf) -> ResourceClaim,
278 ) -> Result<Vec<ResourceClaim>, ToolError> {
279 let raw = input
280 .get(key)
281 .and_then(Value::as_str)
282 .map(str::trim)
283 .filter(|path| !path.is_empty())
284 .or(default);
285 let Some(raw) = raw else {
286 return Ok(global_exclusive_claim());
287 };
288 Ok(context
289 .resolve_path(raw)
290 .map_or_else(|_| global_exclusive_claim(), |path| vec![build(path)]))
291 }
292
293 fn apply_patch_resource_claims(
294 input: &Value,
295 context: &crate::tools::ToolContext,
296 ) -> Result<Vec<ResourceClaim>, ToolError> {
297 let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(input) else {
298 return Ok(global_exclusive_claim());
299 };
300 if preflight.touched_files.is_empty() {
301 return Ok(global_exclusive_claim());
302 }
303
304 let mut claims = BTreeSet::new();
305 for path in preflight.touched_files {
306 let Ok(path) = context.resolve_path(&path) else {
307 return Ok(global_exclusive_claim());
308 };
309 claims.insert(ResourceClaim::WritePath(path));
310 }
311 Ok(claims.into_iter().collect())
312 }
313
314 fn terminal_claim(input: &Value, key: &str, default: Option<&str>) -> Vec<ResourceClaim> {
315 input
316 .get(key)
317 .and_then(Value::as_str)
318 .map(str::trim)
319 .filter(|id| !id.is_empty())
320 .or(default)
321 .map_or_else(global_exclusive_claim, |id| {
322 vec![ResourceClaim::Terminal(id.to_string())]
323 })
324 }
325
326 fn global_exclusive_claim() -> Vec<ResourceClaim> {
327 vec![ResourceClaim::GlobalExclusive]
328 }
329
330 #[cfg(test)]
331 mod tests {
332 use std::sync::Arc;
333
334 use async_trait::async_trait;
335 use serde_json::json;
336 use tempfile::tempdir;
337
338 use crate::tools::spec::{ToolCapability, ToolContext, ToolResult, ToolSpec};
339
340 use super::*;
341
342 struct InputDependentTool;
343
344 #[async_trait]
345 impl ToolSpec for InputDependentTool {
346 fn name(&self) -> &str {
347 "input_dependent"
348 }
349
350 fn description(&self) -> &str {
351 "characterization tool"
352 }
353
354 fn input_schema(&self) -> Value {
355 json!({"type": "object"})
356 }
357
358 fn capabilities(&self) -> Vec<ToolCapability> {
359 vec![ToolCapability::WritesFiles]
360 }
361
362 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
363 if input.get("safe").and_then(Value::as_bool) == Some(true) {
364 ApprovalRequirement::Auto
365 } else {
366 ApprovalRequirement::Required
367 }
368 }
369
370 fn is_read_only_for(&self, input: &Value) -> bool {
371 input.get("safe").and_then(Value::as_bool) == Some(true)
372 }
373
374 fn supports_parallel_for(&self, input: &Value) -> bool {
375 self.is_read_only_for(input)
376 }
377
378 fn starts_detached_for(&self, input: &Value) -> bool {
379 input.get("detached").and_then(Value::as_bool) == Some(true)
380 }
381
382 async fn execute(
383 &self,
384 _input: Value,
385 _context: &ToolContext,
386 ) -> Result<ToolResult, ToolError> {
387 unreachable!("preparation must not execute the tool")
388 }
389 }
390
391 fn registry() -> (tempfile::TempDir, ToolRegistry) {
392 let root = tempdir().expect("tempdir");
393 let mut context = ToolContext::new(root.path().to_path_buf());
394 context.auto_approve = true;
395 let mut registry = ToolRegistry::new(context);
396 registry.register(Arc::new(InputDependentTool));
397 (root, registry)
398 }
399
400 #[test]
401 fn prepared_policy_matches_existing_input_specific_decisions() {
402 let (_root, registry) = registry();
403 let spec = registry.get("input_dependent").expect("registered tool");
404
405 for input in [
406 json!({"safe": true, "detached": false}),
407 json!({"safe": false, "detached": true}),
408 ] {
409 let prepared =
410 prepare_tool_call("input_dependent", input.clone(), Some(&registry), false)
411 .expect("prepare");
412
413 assert_eq!(
414 prepared.call.approval,
415 spec.approval_requirement_for(&input)
416 );
417 assert_eq!(prepared.call.read_only, spec.is_read_only_for(&input));
418 assert_eq!(
419 prepared.call.supports_parallel,
420 spec.supports_parallel_for(&input)
421 );
422 assert_eq!(
423 prepared.call.starts_detached,
424 spec.starts_detached_for(&input)
425 );
426 assert!(prepared.auto_approve);
427 }
428 }
429
430 #[test]
431 fn hook_rewrite_discards_every_original_prepared_decision() {
432 let (_root, registry) = registry();
433 let original = prepare_tool_call(
434 "input_dependent",
435 json!({"safe": true, "detached": false}),
436 Some(&registry),
437 false,
438 )
439 .expect("prepare original");
440 let rewritten = reprepare_tool_call_after_hook(
441 "input_dependent",
442 json!({"safe": false, "detached": true}),
443 Some(&registry),
444 false,
445 )
446 .expect("reprepare rewritten input");
447
448 assert_eq!(original.call.approval, ApprovalRequirement::Auto);
449 assert!(original.call.read_only);
450 assert!(original.call.supports_parallel);
451 assert!(!original.call.starts_detached);
452
453 assert_eq!(rewritten.call.approval, ApprovalRequirement::Required);
454 assert!(!rewritten.call.read_only);
455 assert!(!rewritten.call.supports_parallel);
456 assert!(rewritten.call.starts_detached);
457 assert_eq!(
458 rewritten.call.input,
459 json!({"safe": false, "detached": true})
460 );
461 }
462
463 #[test]
464 fn bypass_preparation_preserves_legacy_policy_table() {
465 struct Expected {
466 name: &'static str,
467 approval: ApprovalRequirement,
468 read_only: bool,
469 supports_parallel: bool,
470 global_exclusive: bool,
471 }
472
473 for expected in [
474 Expected {
475 name: "read_mcp_resource",
476 approval: ApprovalRequirement::Auto,
477 read_only: true,
478 supports_parallel: true,
479 global_exclusive: true,
480 },
481 Expected {
482 name: "mcp_filesystem_write",
483 approval: ApprovalRequirement::Suggest,
484 read_only: false,
485 supports_parallel: false,
486 global_exclusive: true,
487 },
488 Expected {
489 name: CODE_EXECUTION_TOOL_NAME,
490 approval: ApprovalRequirement::Suggest,
491 read_only: false,
492 supports_parallel: false,
493 global_exclusive: true,
494 },
495 Expected {
496 name: JS_EXECUTION_TOOL_NAME,
497 approval: ApprovalRequirement::Suggest,
498 read_only: false,
499 supports_parallel: false,
500 global_exclusive: true,
501 },
502 Expected {
503 name: "tool_search",
504 approval: ApprovalRequirement::Auto,
505 read_only: true,
506 supports_parallel: false,
507 global_exclusive: false,
508 },
509 ] {
510 let prepared = prepare_tool_call(expected.name, json!({}), None, false)
511 .unwrap_or_else(|error| panic!("prepare {}: {error}", expected.name));
512 assert_eq!(
513 prepared.call.approval, expected.approval,
514 "{}",
515 expected.name
516 );
517 assert_eq!(
518 prepared.call.read_only, expected.read_only,
519 "{}",
520 expected.name
521 );
522 assert_eq!(
523 prepared.call.supports_parallel, expected.supports_parallel,
524 "{}",
525 expected.name
526 );
527 assert_eq!(
528 prepared.call.resources == vec![ResourceClaim::GlobalExclusive],
529 expected.global_exclusive,
530 "{}",
531 expected.name
532 );
533 assert!(!prepared.call.starts_detached, "{}", expected.name);
534 assert!(!prepared.auto_approve, "{}", expected.name);
535 }
536 }
537
538 #[test]
539 fn mcp_annotation_hints_drive_approval() {
540 use crate::mcp::{McpToolApprovalHint, set_mcp_tool_approval_hint_for_test};
541
542 let read_only = "mcp_plugin-9-cw11test_page_snapshot";
543 set_mcp_tool_approval_hint_for_test(read_only, Some(McpToolApprovalHint::TrustedReadOnly));
544 let prepared = prepare_tool_call(read_only, json!({}), None, false)
545 .expect("prepare trusted read-only MCP tool");
546 assert_eq!(prepared.call.approval, ApprovalRequirement::Auto);
547 assert!(prepared.call.read_only);
548
549 // A bounded worker keeps the execution gate's rule: only the built-in
550 // resource reads, so preparation never admits a call execution refuses.
551 let workspace = tempfile::tempdir().expect("tempdir");
552 let context = crate::tools::ToolContext::new(workspace.path().to_path_buf())
553 .with_tool_authority(crate::tools::spec::ToolAuthorityEnvelope {
554 schema_version: 1,
555 owner: "cw11-worker".to_string(),
556 authority: crate::tools::spec::ToolMutationAuthority::ScopedWrite,
557 network_access: None,
558 shell: crate::tools::spec::ToolShellAuthority::None,
559 verification: crate::tools::spec::ToolVerificationAuthority::None,
560 writable_roots: Vec::new(),
561 writable_files: vec!["src/named.rs".to_string()],
562 coordination_contracts: Vec::new(),
563 })
564 .expect("valid envelope");
565 let registry = crate::tools::ToolRegistry::new(context);
566 let refused = prepare_tool_call(read_only, json!({}), Some(&registry), false)
567 .expect_err("a bounded worker cannot run a plugin-declared read");
568 assert!(refused.to_string().contains("cw11-worker"), "{refused}");
569
570 let destructive = "mcp_cw11test_drop_table";
571 set_mcp_tool_approval_hint_for_test(destructive, Some(McpToolApprovalHint::Destructive));
572 let prepared = prepare_tool_call(destructive, json!({}), None, false)
573 .expect("prepare destructive MCP tool");
574 assert_eq!(prepared.call.approval, ApprovalRequirement::Suggest);
575 assert!(!prepared.call.read_only);
576 assert!(
577 prepared.call.description.contains("destructive"),
578 "{}",
579 prepared.call.description
580 );
581 // Full Access covers it like any other promptable tool (#3866): a
582 // host answering from its own flag must not deny what the posture
583 // allows.
584 let prepared = prepare_tool_call(destructive, json!({}), None, true)
585 .expect("prepare destructive MCP tool under Full Access");
586 assert!(prepared.auto_approve);
587
588 set_mcp_tool_approval_hint_for_test(read_only, None);
589 set_mcp_tool_approval_hint_for_test(destructive, None);
590 }
591
592 #[test]
593 fn mcp_write_preparation_respects_session_auto_approval() {
594 let prepared = prepare_tool_call("mcp_filesystem_write", json!({}), None, true)
595 .expect("prepare MCP write tool with session auto-approval");
596
597 assert_eq!(prepared.call.approval, ApprovalRequirement::Suggest);
598 assert!(!prepared.call.read_only);
599 assert!(!prepared.call.supports_parallel);
600 assert_eq!(
601 prepared.call.resources,
602 vec![ResourceClaim::GlobalExclusive]
603 );
604 assert!(prepared.auto_approve);
605 assert!(!super::super::turn_loop::registered_tool_approval_required(
606 &prepared.call.name,
607 prepared.call.approval,
608 prepared.auto_approve,
609 ));
610 }
611
612 /// K1: the model cannot grant itself Computer Use consent. In a posture
613 /// that cannot show a human card the call is refused at preparation; in
614 /// Ask it always requires approval, is never session auto-approved, and a
615 /// session grant for one app does not cover another.
616 #[test]
617 fn model_issued_computer_use_consent_is_rejected_without_a_human_card() {
618 let consent = "mcp_plugin-12-computer-use-computer_consent";
619 let allow_safari =
620 json!({"action": "allow", "app": "Safari", "bundle_id": "com.apple.Safari"});
621 let foreground = json!({"action": "allow", "scope": "foreground"});
622
623 // Full Access (session bit, or the registry context) and every
624 // no-card posture refuse the call before any approval routing.
625 for (session_auto, context_auto, mode) in [
626 (true, false, ApprovalMode::Suggest),
627 (false, true, ApprovalMode::Suggest),
628 (false, false, ApprovalMode::Bypass),
629 (false, false, ApprovalMode::Auto),
630 (false, false, ApprovalMode::Never),
631 ] {
632 let root = tempdir().expect("tempdir");
633 let mut context = ToolContext::new(root.path().to_path_buf());
634 context.auto_approve = context_auto;
635 context.approval_mode = mode;
636 let registry = ToolRegistry::new(context);
637 for (name, input) in [
638 (consent, allow_safari.clone()),
639 (consent, foreground.clone()),
640 (
641 "mcp_codewhale-cu_consent_revoke",
642 json!({"app": "Terminal"}),
643 ),
644 (
645 "mcp_plugin-12-computer-use-computer_app_script",
646 json!({"script": "do shell script \"id\""}),
647 ),
648 ] {
649 let error = prepare_tool_call(name, input.clone(), Some(&registry), session_auto)
650 .expect_err("model-issued consent must not run without a human");
651 assert!(
652 matches!(error, ToolError::PermissionDenied { .. }),
653 "{name} {mode:?}: {error}"
654 );
655 }
656 }
657 // No registry: the session bit alone decides.
658 assert!(prepare_tool_call(consent, allow_safari.clone(), None, true).is_err());
659
660 // Ask posture: a Required card that names the app, bundle and scope.
661 let root = tempdir().expect("tempdir");
662 let registry = ToolRegistry::new(ToolContext::new(root.path().to_path_buf()));
663 let prepared = prepare_tool_call(consent, allow_safari.clone(), Some(&registry), false)
664 .expect("Ask posture opens a card");
665 assert_eq!(prepared.call.approval, ApprovalRequirement::Required);
666 assert!(!prepared.auto_approve);
667 assert!(!prepared.call.read_only);
668 assert!(super::super::turn_loop::registered_tool_approval_required(
669 &prepared.call.name,
670 prepared.call.approval,
671 prepared.auto_approve,
672 ));
673 let description = &prepared.call.description;
674 assert!(description.contains("Safari"), "{description}");
675 assert!(description.contains("com.apple.Safari"), "{description}");
676 assert!(description.contains("scope: app"), "{description}");
677 let foreground_card = prepare_tool_call(consent, foreground, Some(&registry), false)
678 .expect("foreground card");
679 assert!(
680 foreground_card
681 .call
682 .description
683 .contains("scope: foreground")
684 );
685 // An irreversible-action confirm token is named as such, not as an
686 // "<unnamed app>" consent; a multi-line script says it is truncated.
687 let confirm_card = prepare_tool_call(
688 consent,
689 json!({"action": "allow", "confirm": "tok-1"}),
690 Some(&registry),
691 false,
692 )
693 .expect("confirm card");
694 assert_eq!(confirm_card.call.approval, ApprovalRequirement::Required);
695 assert!(
696 confirm_card
697 .call
698 .description
699 .contains("irreversible action"),
700 "{}",
701 confirm_card.call.description
702 );
703 let script_card = prepare_tool_call(
704 "mcp_plugin-12-computer-use-computer_app_script",
705 json!({"script": "tell application \"Finder\" to activate\ndo shell script \"id\""}),
706 Some(&registry),
707 false,
708 )
709 .expect("script card");
710 assert!(
711 script_card.call.description.contains("first of 2 lines"),
712 "{}",
713 script_card.call.description
714 );
715
716 // After a session grant for Safari, a consent for Terminal still
717 // prompts: the grant key is the exact call, not the MCP kind.
718 let granted =
719 crate::tools::approval_cache::build_approval_grouping_key(consent, &allow_safari);
720 let terminal = crate::tools::approval_cache::build_approval_grouping_key(
721 consent,
722 &json!({"action": "allow", "app": "Terminal", "bundle_id": "com.apple.Terminal"}),
723 );
724 assert_ne!(granted, terminal);
725
726 // K1: run_actions cannot smuggle a consent grant or a script past
727 // the per-call card, in any posture (Ask included).
728 let batch = "mcp_plugin-12-computer-use-computer_run_actions";
729 for (step, session_auto) in [
730 (
731 json!({"tool": "consent_allow", "arguments": {"app": "Terminal"}}),
732 false,
733 ),
734 (
735 json!({"tool": "consent", "arguments": {"action": "allow", "scope": "foreground"}}),
736 false,
737 ),
738 (
739 json!({"tool": "consent_revoke", "arguments": {"app": "Terminal"}}),
740 true,
741 ),
742 (
743 json!({"tool": "app_script", "arguments": {"script": "do shell script \"id\""}}),
744 false,
745 ),
746 ] {
747 let input = json!({"steps": [{"tool": "click", "arguments": {"x": 1, "y": 1}}, step]});
748 let error = prepare_tool_call(batch, input, Some(&registry), session_auto)
749 .expect_err("a batched consent or script must be refused");
750 assert!(
751 matches!(error, ToolError::PermissionDenied { .. }),
752 "{error}"
753 );
754 }
755 let plain_batch = json!({"steps": [
756 {"tool": "click", "arguments": {"x": 1, "y": 1}},
757 {"tool": "consent", "arguments": {"action": "status"}},
758 ]});
759 assert!(prepare_tool_call(batch, plain_batch, Some(&registry), false).is_ok());
760
761 // Reading the ledger is unaffected.
762 let status = prepare_tool_call(consent, json!({"action": "status"}), Some(&registry), true)
763 .expect("status is not gated");
764 assert!(status.auto_approve);
765 }
766
767 #[test]
768 fn hook_rewrite_reprepares_resource_claims_from_final_input() {
769 let root = tempdir().expect("tempdir");
770 let context = ToolContext::new(root.path().to_path_buf());
771 let original_path = context.resolve_path("before.rs").expect("original path");
772 let rewritten_path = context.resolve_path("after.rs").expect("rewritten path");
773 let mut registry = ToolRegistry::new(context);
774 registry.register(Arc::new(crate::tools::file::ReadFileTool));
775
776 let original = prepare_tool_call(
777 "read_file",
778 json!({"path": "before.rs"}),
779 Some(&registry),
780 false,
781 )
782 .expect("prepare original read");
783 let rewritten = reprepare_tool_call_after_hook(
784 "read_file",
785 json!({"path": "after.rs"}),
786 Some(&registry),
787 false,
788 )
789 .expect("reprepare rewritten read");
790
791 assert_eq!(
792 original.call.resources,
793 vec![ResourceClaim::ReadPath(original_path)]
794 );
795 assert_eq!(
796 rewritten.call.resources,
797 vec![ResourceClaim::ReadPath(rewritten_path)]
798 );
799 }
800
801 #[test]
802 fn registered_file_claims_are_canonical_and_input_specific() {
803 let root = tempdir().expect("tempdir");
804 let context = ToolContext::new(root.path().to_path_buf());
805 let exact = context.resolve_path("src/lib.rs").expect("exact path");
806 let tree = context.resolve_path("src").expect("tree path");
807
808 assert_eq!(
809 registered_resource_claims("read_file", &json!({"path": "src/lib.rs"}), &context,)
810 .expect("read claim"),
811 vec![ResourceClaim::ReadPath(exact.clone())]
812 );
813 assert_eq!(
814 registered_resource_claims("edit_file", &json!({"path": "src/lib.rs"}), &context,)
815 .expect("write claim"),
816 vec![ResourceClaim::WritePath(exact)]
817 );
818 assert_eq!(
819 registered_resource_claims("grep_files", &json!({"path": "src"}), &context)
820 .expect("tree claim"),
821 vec![ResourceClaim::ReadTree(tree)]
822 );
823 assert_eq!(
824 registered_resource_claims("read_file", &json!({"path": "../../outside"}), &context,)
825 .expect("path escape must fall back conservatively"),
826 vec![ResourceClaim::GlobalExclusive]
827 );
828 }
829
830 #[cfg(unix)]
831 #[test]
832 fn symlink_aliases_resolve_to_the_same_file_claim() {
833 use std::os::unix::fs::symlink;
834
835 let root = tempdir().expect("tempdir");
836 let real_dir = root.path().join("real");
837 std::fs::create_dir(&real_dir).expect("create real directory");
838 std::fs::write(real_dir.join("lib.rs"), "fn main() {}\n").expect("write real file");
839 symlink("real", root.path().join("alias")).expect("create directory symlink");
840 let context = ToolContext::new(root.path().to_path_buf());
841 let canonical_real = real_dir
842 .join("lib.rs")
843 .canonicalize()
844 .expect("canonical file");
845
846 let read_alias =
847 registered_resource_claims("read_file", &json!({"path": "alias/lib.rs"}), &context)
848 .expect("alias claim");
849 let write_real =
850 registered_resource_claims("write_file", &json!({"path": "real/lib.rs"}), &context)
851 .expect("real claim");
852
853 assert_eq!(read_alias, vec![ResourceClaim::ReadPath(canonical_real)]);
854 assert!(read_alias[0].conflicts_with(&write_real[0]));
855 }
856
857 #[test]
858 fn apply_patch_claims_every_resolved_target_or_falls_back_global() {
859 let root = tempdir().expect("tempdir");
860 let context = ToolContext::new(root.path().to_path_buf());
861 let a = context.resolve_path("a.rs").expect("a path");
862 let b = context.resolve_path("b.rs").expect("b path");
863
864 let claims = registered_resource_claims(
865 "apply_patch",
866 &json!({
867 "replace": [
868 {"path": "b.rs", "content": "b"},
869 {"path": "a.rs", "content": "a"}
870 ]
871 }),
872 &context,
873 )
874 .expect("patch claims");
875 assert_eq!(
876 claims,
877 vec![ResourceClaim::WritePath(a), ResourceClaim::WritePath(b)]
878 );
879
880 assert_eq!(
881 registered_resource_claims(
882 "apply_patch",
883 &json!({"patch": "not a unified diff"}),
884 &context,
885 )
886 .expect("fallback claim"),
887 vec![ResourceClaim::GlobalExclusive]
888 );
889 assert_eq!(
890 registered_resource_claims(
891 "apply_patch",
892 &json!({"replace": [{"path": "../../outside", "content": "nope"}]}),
893 &context,
894 )
895 .expect("escaped target fallback"),
896 vec![ResourceClaim::GlobalExclusive]
897 );
898 }
899
900 #[test]
901 fn terminal_and_unknown_tools_keep_conservative_claims() {
902 let root = tempdir().expect("tempdir");
903 let context = ToolContext::new(root.path().to_path_buf());
904
905 assert_eq!(
906 registered_resource_claims("terminal/run", &json!({}), &context)
907 .expect("default terminal"),
908 vec![ResourceClaim::Terminal("term-1".to_string())]
909 );
910 assert_eq!(
911 registered_resource_claims(
912 "exec_shell_interact",
913 &json!({"task_id": "task-7"}),
914 &context,
915 )
916 .expect("task terminal"),
917 vec![ResourceClaim::Terminal("task-7".to_string())]
918 );
919 assert_eq!(
920 registered_resource_claims("plugin_tool", &json!({}), &context).expect("unknown tool"),
921 vec![ResourceClaim::GlobalExclusive]
922 );
923 }
924 }
925
925 lines RUST