返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tools / registry / tests.rs
1 use std::collections::HashMap;
2 use std::sync::Arc;
3
4 use serde_json::{Value, json};
5 use tempfile::tempdir;
6
7 use crate::config::ToolOverride;
8 use crate::tools::ToolRegistryBuilder;
9 use crate::tools::shell::BashTool;
10 use crate::tools::spec::{
11 ApprovalRequirement, ToolAuthorityEnvelope, ToolCapability, ToolContext, ToolError,
12 ToolMutationAuthority, ToolResult, ToolSpec, required_str,
13 };
14
15 use super::{
16 MCP_IMAGE_TEXT_PLACEHOLDER, ToolRegistry, enforce_tool_authority,
17 mcp_result_to_bounded_rich_tool_result, mcp_tool_adapter_for_test,
18 };
19
20 #[tokio::test]
21 async fn shell_denial_reaches_registry_and_direct_delegation_sinks() {
22 use crate::tools::run_tool::RunTool;
23 use crate::tools::tasks::{TaskShellStartTool, TasksTool};
24 use crate::tools::terminal_session::{TerminalResetTool, TerminalRunTool, TerminalSendTool};
25 use crate::tools::test_runner::RunTestsTool;
26 use crate::tools::verifier::RunVerifiersTool;
27 let tmp = tempdir().unwrap();
28 let mut context = ToolContext::new(tmp.path());
29 context.auto_approve = true;
30 context.disallowed_tools = vec!["Bash".into()];
31 let command = "printf forbidden > denial-canary.txt";
32 let cases: Vec<(Arc<dyn ToolSpec>, Value)> = vec![
33 (Arc::new(BashTool::new("Bash")), json!({"command":command})),
34 (
35 Arc::new(BashTool::alias("exec_interact", "interact")),
36 json!({"task_id":"missing", "stdin":command, "action":"wait"}),
37 ),
38 (Arc::new(TaskShellStartTool), json!({"command":command})),
39 (
40 Arc::new(TasksTool::new("tasks")),
41 json!({"action":"gate_run", "gate":"custom", "command":command}),
42 ),
43 (
44 Arc::new(TasksTool::alias("task_gate_run", "gate_run")),
45 json!({"action":"list", "gate":"custom", "command":command}),
46 ),
47 (Arc::new(TerminalRunTool), json!({"command":command})),
48 (
49 Arc::new(TerminalSendTool),
50 json!({"session":"missing", "text":command}),
51 ),
52 (Arc::new(TerminalResetTool), json!({"session":"missing"})),
53 (
54 Arc::new(RunTool::new("Run")),
55 json!({"action":"verifiers", "commands":[{"program":"sh", "args":["-c", command]}]}),
56 ),
57 (
58 Arc::new(RunTestsTool),
59 json!({"args":"--config build.rustc=malicious"}),
60 ),
61 (
62 Arc::new(RunVerifiersTool),
63 json!({"commands":[{"program":"sh", "args":["-c",command]}]}),
64 ),
65 ];
66 for (tool, input) in cases {
67 let mut registry = ToolRegistry::new(context.clone());
68 registry.register(tool.clone());
69 for result in [
70 registry.execute_full(tool.name(), input.clone()).await,
71 tool.execute(input, &context).await,
72 ] {
73 let error = result.expect_err(tool.name());
74 assert!(
75 error.to_string().contains("disallowed-tools"),
76 "{}: {error}",
77 tool.name()
78 );
79 assert!(!tmp.path().join("denial-canary.txt").exists());
80 }
81 }
82 }
83
84 #[test]
85 fn shell_denial_keeps_the_existing_bounded_child_read_only_exception() {
86 use crate::core::engine::tool_catalog::enforce_tool_denial;
87 use crate::worker_profile::ShellPolicy;
88 let tmp = tempdir().unwrap();
89 let mut context = ToolContext::new(tmp.path()).with_shell_policy(ShellPolicy::ReadOnly);
90 context.disallowed_tools = vec!["Bash".into()];
91 assert!(enforce_tool_denial(&context, "bash", &json!({"command":"pwd"})).is_err());
92 context = context.with_owner_agent("fixture-child", "fixture");
93 assert!(enforce_tool_denial(&context, "bash", &json!({"command":"pwd"})).is_ok());
94 for (name, input) in [
95 ("Bash", json!({"command":"pwd"})),
96 ("bash", json!({"command":"printf bad > denied"})),
97 ("bash", json!({"command":"pwd", "background":true})),
98 ("task_shell_start", json!({"command":"pwd"})),
99 (
100 "terminal/send",
101 json!({"session":"existing", "text":"pwd\n"}),
102 ),
103 ] {
104 assert!(
105 enforce_tool_denial(&context, name, &input).is_err(),
106 "{name}: {input}"
107 );
108 }
109 }
110
111 #[test]
112 fn mcp_iserror_result_maps_to_tool_error_preserving_text() {
113 // #5123-class: MCP servers report tool failure via isError on an
114 // otherwise successful response; the model must see a failure, not a
115 // success carrying an error message body.
116 let error_payload = json!({
117 "content": [
118 {"type": "text", "text": "delete failed: permission denied"}
119 ],
120 "isError": true
121 });
122 let result = mcp_result_to_bounded_rich_tool_result(error_payload).result;
123 assert!(!result.success, "isError must not be reported as success");
124 assert_eq!(result.content, "delete failed: permission denied");
125
126 let ok_payload = json!({
127 "content": [{"type": "text", "text": "wrote 3 rows"}]
128 });
129 let result = mcp_result_to_bounded_rich_tool_result(ok_payload).result;
130 assert!(result.success);
131 assert!(result.content.contains("wrote 3 rows"));
132
133 // isError without text content falls back to the serialized payload.
134 let bare_error = json!({"isError": true, "content": []});
135 let result = mcp_result_to_bounded_rich_tool_result(bare_error).result;
136 assert!(!result.success);
137 assert!(result.content.contains("isError"));
138 }
139
140 #[test]
141 fn mcp_image_result_uses_typed_block_without_base64_in_text() {
142 let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
143 let payload = json!({
144 "content": [
145 {"type": "text", "text": "screenshot captured"},
146 {"type": "image", "data": image_data, "mimeType": "image/png"}
147 ],
148 "structuredContent": {"page": "https://example.com"},
149 "isError": false
150 });
151
152 let rich = mcp_result_to_bounded_rich_tool_result(payload);
153
154 assert!(rich.result.success);
155 let sanitized: Value = serde_json::from_str(&rich.result.content).expect("sanitized MCP JSON");
156 assert_eq!(sanitized["content"][0]["text"], "screenshot captured");
157 assert_eq!(sanitized["content"][1]["data"], MCP_IMAGE_TEXT_PLACEHOLDER);
158 assert_eq!(
159 sanitized["structuredContent"],
160 json!({"page": "https://example.com"})
161 );
162 assert_eq!(sanitized["isError"], false);
163 assert!(!rich.result.content.contains(image_data));
164 assert_eq!(
165 rich.content_blocks,
166 vec![codewhale_tools::ToolResultContentBlock::Image {
167 mime_type: "image/png".to_string(),
168 data: image_data.to_string(),
169 }]
170 );
171 }
172
173 #[test]
174 fn mcp_invalid_image_is_removed_with_a_visible_receipt() {
175 let payload = json!({
176 "content": [
177 {"type": "image", "data": "not base64", "mimeType": "image/png"}
178 ]
179 });
180
181 let rich = mcp_result_to_bounded_rich_tool_result(payload);
182
183 assert!(rich.content_blocks.is_empty());
184 assert!(rich.result.content.contains("MCP image payload removed"));
185 assert!(
186 rich.result
187 .content
188 .contains("1 tool-result image block(s) omitted")
189 );
190 assert!(!rich.result.content.contains("not base64"));
191 }
192
193 #[test]
194 fn mcp_malformed_images_are_removed_with_a_visible_receipt() {
195 let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
196 let payload = json!({
197 "content": [
198 {"type": "image", "data": image_data},
199 {"type": "image", "data": {"nested": image_data}, "mimeType": "image/png"}
200 ]
201 });
202
203 let rich = mcp_result_to_bounded_rich_tool_result(payload);
204
205 assert!(rich.content_blocks.is_empty());
206 assert!(rich.result.content.contains("MCP image payload removed"));
207 assert!(
208 rich.result
209 .content
210 .contains("2 tool-result image block(s) omitted")
211 );
212 assert!(!rich.result.content.contains(image_data));
213 }
214
215 #[test]
216 fn mcp_image_limits_keep_one_valid_block_and_report_the_rest() {
217 let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
218 let oversized = "A".repeat(crate::image_attach::MAX_IMAGE_BYTES.div_ceil(3) * 4 + 4);
219 let payload = json!({
220 "content": [
221 {"type": "image", "data": oversized, "mimeType": "image/png"},
222 {"type": "image", "data": image_data, "mimeType": "image/png"},
223 {"type": "image", "data": image_data, "mimeType": "image/png"}
224 ]
225 });
226
227 let rich = mcp_result_to_bounded_rich_tool_result(payload);
228
229 assert_eq!(
230 rich.content_blocks,
231 vec![codewhale_tools::ToolResultContentBlock::Image {
232 mime_type: "image/png".to_string(),
233 data: image_data.to_string(),
234 }]
235 );
236 assert!(
237 rich.result
238 .content
239 .contains("2 tool-result image block(s) omitted")
240 );
241 assert!(!rich.result.content.contains(&oversized));
242 }
243
244 #[test]
245 fn mcp_error_text_and_typed_image_are_both_preserved() {
246 let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
247 let payload = json!({
248 "content": [
249 {"type": "text", "text": "capture failed after partial screenshot"},
250 {"type": "image", "data": image_data, "mimeType": "image/png"}
251 ],
252 "structuredContent": {"retryable": true},
253 "isError": true
254 });
255
256 let rich = mcp_result_to_bounded_rich_tool_result(payload);
257
258 assert!(!rich.result.success);
259 assert_eq!(
260 rich.result.content,
261 "capture failed after partial screenshot"
262 );
263 assert_eq!(
264 rich.content_blocks,
265 vec![codewhale_tools::ToolResultContentBlock::Image {
266 mime_type: "image/png".to_string(),
267 data: image_data.to_string(),
268 }]
269 );
270 }
271
272 /// A simple test tool for unit testing
273 struct TestTool {
274 name: String,
275 description: String,
276 }
277
278 #[async_trait::async_trait]
279 impl ToolSpec for TestTool {
280 fn name(&self) -> &str {
281 &self.name
282 }
283
284 fn description(&self) -> &str {
285 &self.description
286 }
287
288 fn input_schema(&self) -> Value {
289 json!({
290 "type": "object",
291 "properties": {
292 "message": { "type": "string" }
293 },
294 "required": ["message"]
295 })
296 }
297
298 fn capabilities(&self) -> Vec<ToolCapability> {
299 vec![ToolCapability::ReadOnly]
300 }
301
302 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
303 let message = required_str(&input, "message")?;
304 Ok(ToolResult::success(format!("Echo: {message}")))
305 }
306 }
307
308 fn make_test_tool(name: &str) -> Arc<TestTool> {
309 Arc::new(TestTool {
310 name: name.to_string(),
311 description: "A test tool".to_string(),
312 })
313 }
314
315 #[test]
316 fn mcp_read_helpers_remain_auto_and_eagerly_loaded() {
317 for name in [
318 "list_mcp_resources",
319 "list_mcp_resource_templates",
320 "mcp_read_resource",
321 "read_mcp_resource",
322 "mcp_get_prompt",
323 ] {
324 let adapter = mcp_tool_adapter_for_test(name);
325 assert_eq!(
326 adapter.approval_requirement(),
327 ApprovalRequirement::Auto,
328 "{name} should remain an automatic read helper"
329 );
330 assert!(adapter.is_read_only(), "{name} should remain read-only");
331 assert!(!adapter.defer_loading(), "{name} should remain loaded");
332 }
333 }
334
335 #[test]
336 fn mcp_actions_require_approval_with_exact_helper_matching() {
337 for name in [
338 "mcp_github_create_pull_request",
339 "mcp_github_list_mcp_resources_export",
340 "read_mcp_resource_and_delete",
341 ] {
342 let adapter = mcp_tool_adapter_for_test(name);
343 assert_eq!(
344 adapter.approval_requirement(),
345 ApprovalRequirement::Required,
346 "{name} must not inherit read-helper approval"
347 );
348 assert!(
349 adapter
350 .capabilities()
351 .contains(&ToolCapability::RequiresApproval),
352 "{name} should advertise approval gating"
353 );
354 assert!(adapter.defer_loading(), "{name} should remain deferred");
355 }
356 }
357
358 #[test]
359 fn test_registry_register_and_get() {
360 let tmp = tempdir().expect("tempdir");
361 let ctx = ToolContext::new(tmp.path().to_path_buf());
362 let mut registry = ToolRegistry::new(ctx);
363
364 let tool = make_test_tool("test_tool");
365 registry.register(tool);
366
367 assert!(registry.contains("test_tool"));
368 assert!(!registry.contains("nonexistent"));
369 assert_eq!(registry.all().len(), 1);
370 }
371
372 #[test]
373 fn resolve_exact_match_is_ascii_case_insensitive() {
374 let tmp = tempdir().expect("tempdir");
375 let ctx = ToolContext::new(tmp.path().to_path_buf());
376 let mut registry = ToolRegistry::new(ctx);
377
378 registry.register(make_test_tool("read_file"));
379
380 assert_eq!(registry.resolve("READ_FILE"), Some("read_file"));
381 }
382
383 #[test]
384 fn resolve_never_executes_a_fuzzy_prefix_guess() {
385 // #5123-class: a hallucinated name that merely shares a prefix with a
386 // real tool must NOT resolve — executing a prefix guess dispatched an
387 // arbitrary sibling tool ("agents" -> "agents/interrupt"). Exact and
388 // lossless normalizations still resolve; guesses return None so the
389 // caller can surface "unknown tool, did you mean: …".
390 let tmp = tempdir().expect("tempdir");
391 let ctx = ToolContext::new(tmp.path().to_path_buf());
392 let mut registry = ToolRegistry::new(ctx);
393
394 registry.register(make_test_tool("agents/interrupt"));
395 registry.register(make_test_tool("read_file"));
396
397 // Prefix guesses in both directions are rejected.
398 assert_eq!(registry.resolve("agents"), None);
399 assert_eq!(registry.resolve("agents/int"), None);
400 assert_eq!(registry.resolve("read"), None);
401 assert_eq!(registry.resolve("read_file_extra"), None);
402
403 // Lossless normalizations still resolve.
404 let mut hyphen_registry = ToolRegistry::new(ToolContext::new(tmp.path().to_path_buf()));
405 hyphen_registry.register(make_test_tool("read_file"));
406 assert_eq!(hyphen_registry.resolve("read-file"), Some("read_file"));
407 assert_eq!(hyphen_registry.resolve("ReadFile"), Some("read_file"));
408 assert_eq!(hyphen_registry.resolve("read_file_tool"), Some("read_file"));
409 }
410
411 #[test]
412 fn work_update_is_the_only_registered_progress_surface() {
413 let tmp = tempdir().expect("tempdir");
414 let ctx = ToolContext::new(tmp.path().to_path_buf());
415 let registry = ToolRegistryBuilder::new()
416 .with_todo_tool(crate::tools::todo::new_shared_todo_list())
417 .build(ctx);
418
419 // Canonical is todo_write; work_update/TodoWrite/todo are hidden compat aliases.
420 assert!(registry.contains("todo_write"));
421 for alias in ["work_update", "TodoWrite", "todo"] {
422 assert!(
423 registry.contains(alias),
424 "{alias} compat alias must be registered"
425 );
426 // Hidden aliases are distinct entries (same handler, model_visible=false).
427 assert_eq!(
428 registry.resolve(alias),
429 Some(alias),
430 "{alias} must be directly resolvable as hidden alias"
431 );
432 let tool = registry.get(alias).expect("alias tool");
433 assert!(
434 !tool.model_visible(),
435 "{alias} hidden alias must not be model-visible"
436 );
437 }
438 // Only todo_write is model-visible.
439 let api_names = registry
440 .to_api_tools()
441 .into_iter()
442 .map(|tool| tool.name)
443 .collect::<Vec<_>>();
444
445 assert!(
446 api_names.iter().any(|name| name == "todo_write"),
447 "todo_write should be the sole model-visible progress surface"
448 );
449 assert_eq!(
450 api_names.iter().filter(|n| *n == "todo_write").count(),
451 1,
452 "canonical todo_write must appear exactly once in model catalog"
453 );
454 for hidden in [
455 "work_update",
456 "TodoWrite",
457 "todo",
458 "checklist_write",
459 "checklist_update",
460 "checklist_add",
461 "checklist_list",
462 "todo_add",
463 "todo_update",
464 "todo_list",
465 ] {
466 assert!(
467 api_names.iter().all(|name| name != hidden),
468 "{hidden} must not appear in the model catalog"
469 );
470 }
471 // But hidden aliases still execute via registry dispatch.
472 assert!(registry.contains("checklist_write"));
473 assert!(registry.contains("checklist_update"));
474 }
475
476 #[test]
477 fn rlm_is_the_only_registered_session_surface() {
478 let tmp = tempdir().expect("tempdir");
479 let ctx = ToolContext::new(tmp.path().to_path_buf());
480 let registry = ToolRegistryBuilder::new()
481 .with_rlm_tool()
482 .with_harness_tool()
483 .build(ctx);
484
485 assert!(registry.contains("rlm"));
486 assert!(
487 registry.contains("harness"),
488 "the durable continual harness must accompany the persistent RLM surface"
489 );
490 for retired in [
491 "rlm_session_objects",
492 "rlm_open",
493 "rlm_eval",
494 "rlm_configure",
495 "rlm_close",
496 ] {
497 assert!(
498 !registry.contains(retired),
499 "{retired} must no longer be callable"
500 );
501 }
502 }
503
504 /// The names an engine treats as built in: whatever is registered before the
505 /// plugin directory loads (see `configure_plugin_tools`).
506 fn builtin_names(registry: &ToolRegistry) -> std::collections::HashSet<String> {
507 registry.names().into_iter().map(str::to_string).collect()
508 }
509
510 #[test]
511 fn apply_overrides_removes_original_when_replacement_is_missing() {
512 let tmp = tempdir().expect("tempdir");
513 let ctx = ToolContext::new(tmp.path().to_path_buf());
514 let mut registry = ToolRegistryBuilder::new().with_file_tools().build(ctx);
515 let builtins = builtin_names(&registry);
516 std::fs::write(
517 tmp.path().join("reader.sh"),
518 "# name: custom-reader\n# description: drop-in reader\n",
519 )
520 .unwrap();
521 registry.load_plugins(tmp.path());
522 assert!(registry.contains("custom-reader"));
523
524 let mut overrides = HashMap::new();
525 overrides.insert(
526 "custom-reader".to_string(),
527 ToolOverride::Script {
528 path: "missing-wrapper.sh".to_string(),
529 args: None,
530 },
531 );
532
533 registry.apply_overrides(&overrides, tmp.path(), &builtins);
534
535 assert!(!registry.contains("custom-reader"));
536 assert!(registry.contains("File"));
537 }
538
539 /// D4 (CURRENT_DECISIONS §26): a `[tools.overrides]` script or command cannot
540 /// replace a built-in. The entry is refused loudly, naming the config key and
541 /// the built-in, and the built-in stays active; `disabled` and overrides under
542 /// a new name keep working.
543 #[test]
544 fn script_and_command_overrides_cannot_replace_builtins() {
545 let tmp = tempdir().unwrap();
546 let mut registry = ToolRegistryBuilder::new()
547 .with_file_tools()
548 .with_patch_tools()
549 .build(ToolContext::new(tmp.path()));
550 let builtins = builtin_names(&registry);
551 let file = registry.get("File").unwrap();
552 let patch = registry.get("apply_patch").unwrap();
553 std::fs::write(
554 tmp.path().join("wrapper.sh"),
555 "# name: wrapper\n# description: audit wrapper\n",
556 )
557 .unwrap();
558 let script = || ToolOverride::Script {
559 path: "wrapper.sh".to_string(),
560 args: None,
561 };
562 let command = || ToolOverride::Command {
563 command: "my-patcher".to_string(),
564 args: None,
565 };
566 let overrides = HashMap::from([
567 ("File".to_string(), script()),
568 ("apply_patch".to_string(), command()),
569 ("audited_file".to_string(), script()),
570 ("my_patcher".to_string(), command()),
571 ]);
572
573 let mut refused = Vec::new();
574 let errors = capture_registration_warnings(|| {
575 refused = registry.apply_overrides(&overrides, tmp.path(), &builtins);
576 });
577
578 assert!(Arc::ptr_eq(&registry.get("File").unwrap(), &file));
579 assert!(Arc::ptr_eq(&registry.get("apply_patch").unwrap(), &patch));
580 // The refusals are returned for the engine's status line and logged.
581 refused.sort();
582 assert_eq!(refused, ["File", "apply_patch"]);
583 for name in ["File", "apply_patch"] {
584 assert!(
585 errors.contains(&super::override_refusal_notice(name)),
586 "{errors}"
587 );
588 }
589 assert_eq!(
590 registry.get("audited_file").unwrap().description(),
591 "audit wrapper"
592 );
593 assert!(registry.contains("my_patcher"));
594
595 let disable = HashMap::from([("File".to_string(), ToolOverride::Disabled)]);
596 registry.apply_overrides(&disable, tmp.path(), &builtins);
597 assert!(!registry.contains("File"));
598 }
599
600 /// D4 (CURRENT_DECISIONS §26): a script cannot approve itself. `approval: auto`
601 /// gets the default a script without the line gets, and the loader says so.
602 #[test]
603 fn script_tool_auto_approval_is_ignored_and_reported() {
604 let tmp = tempdir().unwrap();
605 let mut registry = ToolRegistryBuilder::new()
606 .with_file_tools()
607 .build(ToolContext::new(tmp.path()));
608 let builtins = builtin_names(&registry);
609 let plugin_dir = tmp.path().join("tools");
610 std::fs::create_dir(&plugin_dir).unwrap();
611 std::fs::write(
612 plugin_dir.join("greet.sh"),
613 "# name: greet\n# description: Say hello\n# approval: auto\n",
614 )
615 .unwrap();
616 std::fs::write(
617 tmp.path().join("audit.sh"),
618 "# name: ignored\n# description: audit\n# approval: auto\n",
619 )
620 .unwrap();
621 let overrides = HashMap::from([(
622 "audited".to_string(),
623 ToolOverride::Script {
624 path: tmp.path().join("audit.sh").to_string_lossy().into_owned(),
625 args: None,
626 },
627 )]);
628
629 let warnings = capture_registration_warnings(|| {
630 registry.load_plugins(&plugin_dir);
631 registry.apply_overrides(&overrides, &plugin_dir, &builtins);
632 });
633
634 for name in ["greet", "audited"] {
635 assert_eq!(
636 registry.get(name).unwrap().approval_requirement(),
637 ApprovalRequirement::Suggest,
638 "{name}"
639 );
640 assert!(
641 warnings.contains(&format!(
642 "Script tool '{name}': `approval: auto` is no longer supported for script tools"
643 )),
644 "{warnings}"
645 );
646 }
647 }
648
649 #[test]
650 fn builder_registers_speech_alias_tools() {
651 let tmp = tempdir().expect("tempdir");
652 let ctx = ToolContext::new(tmp.path().to_path_buf());
653 let registry = ToolRegistryBuilder::new()
654 .with_speech_tools(None, None)
655 .build(ctx);
656
657 assert!(registry.contains("speech"));
658 assert!(registry.contains("tts"));
659 // One capability, one catalog entry: the alias stays callable for replay
660 // but is not advertised (#5941).
661 let visible: Vec<String> = registry
662 .to_api_tools()
663 .into_iter()
664 .map(|tool| tool.name)
665 .collect();
666 assert!(visible.iter().any(|name| name == "speech"));
667 assert!(!visible.iter().any(|name| name == "tts"), "{visible:?}");
668 }
669
670 #[test]
671 fn agent_runtime_surface_skips_speech_without_a_client() {
672 use super::AgentToolSurfaceOptions;
673 use crate::worker_profile::ShellPolicy;
674 let tmp = tempdir().expect("tempdir");
675 let ctx = ToolContext::new(tmp.path().to_path_buf());
676 let registry = ToolRegistryBuilder::new()
677 .with_agent_runtime_surface(
678 None,
679 "test-model".to_string(),
680 AgentToolSurfaceOptions::new(ShellPolicy::Full),
681 crate::tools::todo::new_shared_todo_list(),
682 crate::tools::plan::new_shared_plan_state(),
683 )
684 .build(ctx);
685 assert!(!registry.contains("speech"));
686 assert!(!registry.contains("tts"));
687 }
688
689 #[test]
690 fn model_visible_tool_descriptions_name_no_vendor() {
691 use super::AgentToolSurfaceOptions;
692 use crate::worker_profile::ShellPolicy;
693 let tmp = tempdir().expect("tempdir");
694 let ctx = ToolContext::new(tmp.path().to_path_buf());
695 let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full);
696 options.web_search_enabled = true;
697 let registry = ToolRegistryBuilder::new()
698 .with_agent_runtime_surface(
699 None,
700 "test-model".to_string(),
701 options,
702 crate::tools::todo::new_shared_todo_list(),
703 crate::tools::plan::new_shared_plan_state(),
704 )
705 .with_speech_tools(None, None)
706 .build(ctx);
707 let vendors = [
708 "xiaomi",
709 "mimo",
710 "claude",
711 "anthropic",
712 "openai",
713 "gpt-",
714 "deepseek",
715 "gemini",
716 "kimi",
717 "qwen",
718 "grok",
719 "mistral",
720 ];
721 for tool in registry.to_api_tools() {
722 let description = tool.description.to_ascii_lowercase();
723 for vendor in vendors {
724 assert!(
725 !description.contains(vendor),
726 "tool {} names a vendor ({vendor}) in its model-facing description",
727 tool.name
728 );
729 }
730 }
731 }
732
733 #[test]
734 fn test_registry_names() {
735 let tmp = tempdir().expect("tempdir");
736 let ctx = ToolContext::new(tmp.path().to_path_buf());
737 let mut registry = ToolRegistry::new(ctx);
738
739 registry.register(make_test_tool("tool_a"));
740 registry.register(make_test_tool("tool_b"));
741
742 let names = registry.names();
743 assert_eq!(names.len(), 2);
744 assert!(names.contains(&"tool_a"));
745 assert!(names.contains(&"tool_b"));
746 }
747
748 #[test]
749 fn test_registry_to_api_tools() {
750 let tmp = tempdir().expect("tempdir");
751 let ctx = ToolContext::new(tmp.path().to_path_buf());
752 let mut registry = ToolRegistry::new(ctx);
753
754 registry.register(make_test_tool("my_tool"));
755
756 let api_tools = registry.to_api_tools();
757 assert_eq!(api_tools.len(), 1);
758 assert_eq!(api_tools[0].name, "my_tool");
759 assert_eq!(api_tools[0].description, "A test tool");
760 }
761
762 #[test]
763 fn api_tools_with_cache_marks_last_tool_ephemeral() {
764 let tmp = tempdir().expect("tempdir");
765 let ctx = ToolContext::new(tmp.path().to_path_buf());
766 let mut registry = ToolRegistry::new(ctx);
767
768 registry.register(make_test_tool("tool_a"));
769 registry.register(make_test_tool("tool_b"));
770
771 let api_tools = registry.to_api_tools_with_cache(true);
772 assert_eq!(api_tools.len(), 2);
773 assert!(api_tools[0].cache_control.is_none());
774 assert_eq!(
775 api_tools[1]
776 .cache_control
777 .as_ref()
778 .map(|c| c.cache_type.as_str()),
779 Some("ephemeral")
780 );
781 }
782
783 /// Tool whose `description()` advances through a script of pre-built
784 /// strings, one per call. Used to demonstrate that the api-tools cache
785 /// pins the description bytes on first read instead of re-sampling them
786 /// each turn (#263 follow-up; mirrors reference-cc's `getToolSchemaCache`).
787 struct VaryingDescriptionTool {
788 name: String,
789 descriptions: Vec<String>,
790 next: std::sync::atomic::AtomicUsize,
791 }
792
793 impl VaryingDescriptionTool {
794 fn new(name: &str, descriptions: &[&str]) -> Self {
795 Self {
796 name: name.to_string(),
797 descriptions: descriptions.iter().map(|s| (*s).to_string()).collect(),
798 next: std::sync::atomic::AtomicUsize::new(0),
799 }
800 }
801 }
802
803 #[async_trait::async_trait]
804 impl ToolSpec for VaryingDescriptionTool {
805 fn name(&self) -> &str {
806 &self.name
807 }
808
809 fn description(&self) -> &str {
810 let idx = self
811 .next
812 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
813 .min(self.descriptions.len() - 1);
814 &self.descriptions[idx]
815 }
816
817 fn input_schema(&self) -> Value {
818 json!({"type": "object", "properties": {}, "required": []})
819 }
820
821 fn capabilities(&self) -> Vec<ToolCapability> {
822 vec![ToolCapability::ReadOnly]
823 }
824
825 async fn execute(
826 &self,
827 _input: Value,
828 _context: &ToolContext,
829 ) -> Result<ToolResult, ToolError> {
830 Ok(ToolResult::success("ok".to_string()))
831 }
832 }
833
834 #[test]
835 fn to_api_tools_pins_description_bytes_across_calls() {
836 // Regression for the cache-stability follow-up: an MCP adapter that
837 // returns a different `description()` on reconnect (or any other
838 // tool whose description isn't a `&'static str`) would otherwise
839 // rewrite the catalog bytes mid-session and miss the prefix cache.
840 // The registry pins the first call's value until it's mutated.
841 let tmp = tempdir().expect("tempdir");
842 let ctx = ToolContext::new(tmp.path().to_path_buf());
843 let mut registry = ToolRegistry::new(ctx);
844 registry.register(Arc::new(VaryingDescriptionTool::new(
845 "varying",
846 &["first description", "second description"],
847 )));
848
849 let first = registry.to_api_tools();
850 let second = registry.to_api_tools();
851
852 assert_eq!(first.len(), 1);
853 assert_eq!(first[0].description, "first description");
854 assert_eq!(
855 first, second,
856 "api-tools catalog must be byte-identical across reads with no mutation in between"
857 );
858 }
859
860 #[test]
861 fn register_invalidates_api_tools_cache() {
862 // Counter-test: when a real change happens (a new tool registers,
863 // an existing one is removed, or `clear` is called), the cache must
864 // be discarded so the next read reflects the live registry.
865 let tmp = tempdir().expect("tempdir");
866 let ctx = ToolContext::new(tmp.path().to_path_buf());
867 let mut registry = ToolRegistry::new(ctx);
868 registry.register(Arc::new(VaryingDescriptionTool::new(
869 "varying",
870 &["first description", "second description"],
871 )));
872
873 let before = registry.to_api_tools();
874 assert_eq!(before.len(), 1);
875
876 registry.register(make_test_tool("late_arrival"));
877
878 let after = registry.to_api_tools();
879 assert_eq!(after.len(), 2, "cache must rebuild after register");
880 assert!(after.iter().any(|t| t.name == "varying"));
881 assert!(after.iter().any(|t| t.name == "late_arrival"));
882 // The varying tool's description advances on cache rebuild — the
883 // first read above sampled `first description`; this rebuild samples
884 // `second description`. The point is just that the bytes *can*
885 // change after a real mutation, not that they always do.
886 let varying_after = after
887 .iter()
888 .find(|t| t.name == "varying")
889 .expect("varying tool present");
890 assert_eq!(varying_after.description, "second description");
891 }
892
893 #[test]
894 fn remove_tool_invalidates_api_tools_cache() {
895 let tmp = tempdir().expect("tempdir");
896 let ctx = ToolContext::new(tmp.path().to_path_buf());
897 let mut registry = ToolRegistry::new(ctx);
898 registry.register(make_test_tool("alpha"));
899 registry.register(make_test_tool("beta"));
900
901 let before = registry.to_api_tools();
902 assert_eq!(before.len(), 2);
903
904 assert!(registry.remove_tool("alpha"));
905 let after_remove = registry.to_api_tools();
906 assert_eq!(after_remove.len(), 1);
907 assert_eq!(after_remove[0].name, "beta");
908 }
909
910 #[test]
911 fn to_api_tools_emits_alphabetical_order_regardless_of_registration_order() {
912 // Regression for #263: HashMap iteration is non-deterministic across
913 // process launches, which busts DeepSeek's KV prefix cache for every
914 // cross-session resume. `to_api_tools` must emit by name regardless
915 // of registration order so two consecutive calls (and two distinct
916 // launches) produce byte-identical output.
917 let tmp = tempdir().expect("tempdir");
918 let ctx = ToolContext::new(tmp.path().to_path_buf());
919
920 let order_a = {
921 let mut registry = ToolRegistry::new(ctx.clone());
922 registry.register(make_test_tool("zebra"));
923 registry.register(make_test_tool("alpha"));
924 registry.register(make_test_tool("mango"));
925 registry
926 .to_api_tools()
927 .iter()
928 .map(|t| t.name.clone())
929 .collect::<Vec<_>>()
930 };
931
932 let order_b = {
933 let mut registry = ToolRegistry::new(ctx.clone());
934 registry.register(make_test_tool("alpha"));
935 registry.register(make_test_tool("mango"));
936 registry.register(make_test_tool("zebra"));
937 registry
938 .to_api_tools()
939 .iter()
940 .map(|t| t.name.clone())
941 .collect::<Vec<_>>()
942 };
943
944 assert_eq!(order_a, vec!["alpha", "mango", "zebra"]);
945 assert_eq!(order_a, order_b);
946 }
947
948 fn scoped_context(workspace: &std::path::Path) -> ToolContext {
949 ToolContext::new(workspace.to_path_buf())
950 .with_tool_authority(
951 ToolAuthorityEnvelope {
952 schema_version: 1,
953 owner: "fleet-worker-1".to_string(),
954 authority: ToolMutationAuthority::ScopedWrite,
955 network_access: None,
956 shell: crate::tools::spec::ToolShellAuthority::None,
957 verification: crate::tools::spec::ToolVerificationAuthority::None,
958 writable_roots: vec!["src".to_string()],
959 writable_files: Vec::new(),
960 coordination_contracts: Vec::new(),
961 }
962 .normalized()
963 .expect("test authority"),
964 )
965 .expect("test context authority")
966 }
967
968 fn readonly_scout_context(workspace: &std::path::Path, network_access: bool) -> ToolContext {
969 ToolContext::new(workspace.to_path_buf())
970 .with_tool_authority(ToolAuthorityEnvelope {
971 schema_version: 1,
972 owner: "scout-1".to_string(),
973 authority: ToolMutationAuthority::ReadOnly,
974 network_access: Some(network_access),
975 shell: crate::tools::spec::ToolShellAuthority::ReadOnly,
976 verification: crate::tools::spec::ToolVerificationAuthority::None,
977 writable_roots: Vec::new(),
978 writable_files: Vec::new(),
979 coordination_contracts: Vec::new(),
980 })
981 .expect("read-only Scout authority")
982 }
983
984 fn readonly_verifier_context(workspace: &std::path::Path) -> ToolContext {
985 ToolContext::new(workspace.to_path_buf())
986 .with_tool_authority(ToolAuthorityEnvelope {
987 schema_version: 1,
988 owner: "verifier-1".to_string(),
989 authority: ToolMutationAuthority::ReadOnly,
990 network_access: Some(true),
991 shell: crate::tools::spec::ToolShellAuthority::None,
992 verification: crate::tools::spec::ToolVerificationAuthority::Bounded,
993 writable_roots: Vec::new(),
994 writable_files: Vec::new(),
995 coordination_contracts: Vec::new(),
996 })
997 .expect("bounded verifier authority")
998 }
999
1000 #[test]
1001 fn machine_verifier_catalog_and_dispatch_add_only_bounded_run() {
1002 let tmp = tempdir().expect("tempdir");
1003 let registry = ToolRegistryBuilder::new()
1004 .with_agent_tools_policy(
1005 crate::worker_profile::ShellPolicy::None,
1006 crate::tools::user_input::UserInputLimits::default(),
1007 )
1008 .with_web_tools()
1009 .with_todo_tool(crate::tools::todo::new_shared_todo_list())
1010 .build(readonly_verifier_context(tmp.path()));
1011 let tools = registry.to_api_tools();
1012 let names = tools
1013 .iter()
1014 .map(|tool| tool.name.as_str())
1015 .collect::<Vec<_>>();
1016 assert_eq!(names, {
1017 let mut expected = vec![
1018 "Run",
1019 "Web",
1020 "diagnostics",
1021 "file_search",
1022 "finance",
1023 "grep_files",
1024 "handle_read",
1025 "list_dir",
1026 "load_skill",
1027 "lsp",
1028 "project_map",
1029 "read",
1030 "read_media",
1031 "request_user_input",
1032 "retrieve_tool_result",
1033 "todo_write",
1034 "tui_help",
1035 "validate_data",
1036 "web.run",
1037 ];
1038 if crate::tools::image_ocr::ocr_available() {
1039 expected.insert(7, "image_ocr");
1040 }
1041 expected
1042 });
1043 let run = registry.get("Run").expect("bounded Run registered");
1044 assert!(
1045 tools
1046 .iter()
1047 .find(|tool| tool.name == "Run")
1048 .unwrap()
1049 .input_schema["properties"]
1050 .get("commands")
1051 .is_none(),
1052 "the catalog must not advertise operator-supplied verifier programs"
1053 );
1054 enforce_tool_authority(
1055 "Run",
1056 &json!({"action": "tests", "args": "-p codewhale-tui ordinary_scout"}),
1057 run.as_ref(),
1058 registry.context(),
1059 )
1060 .expect("pure test selection fits bounded verifier authority");
1061 for input in [
1062 json!({"action": "tests", "args": "--manifest-path ../other/Cargo.toml"}),
1063 json!({"action": "verifiers", "commands": [{"name": "escape", "program": "sh"}]}),
1064 ] {
1065 let error = enforce_tool_authority("Run", &input, run.as_ref(), registry.context())
1066 .expect_err("unbounded verification must remain refused")
1067 .to_string();
1068 assert!(error.contains("unbounded verification"), "{error}");
1069 }
1070 assert!(!registry.contains("bash"), "Verifier never gains raw shell");
1071 assert!(!registry.contains("Bash"), "Verifier never gains raw shell");
1072 }
1073
1074 #[tokio::test]
1075 async fn fleet_authority_allows_scoped_file_writes_and_rejects_outside_paths() {
1076 let tmp = tempdir().expect("tempdir");
1077 std::fs::create_dir(tmp.path().join("src")).expect("src");
1078 std::fs::create_dir(tmp.path().join("docs")).expect("docs");
1079 let registry = ToolRegistryBuilder::new()
1080 .with_file_tools()
1081 .with_patch_tools()
1082 .build(scoped_context(tmp.path()));
1083
1084 registry
1085 .execute_full(
1086 "File",
1087 json!({"action": "write", "path": "src/ok.txt", "content": "ok\n"}),
1088 )
1089 .await
1090 .expect("scoped File write");
1091 assert_eq!(
1092 std::fs::read_to_string(tmp.path().join("src/ok.txt")).expect("written file"),
1093 "ok\n"
1094 );
1095
1096 let error = registry
1097 .execute_full(
1098 "File",
1099 json!({"action": "write", "path": "docs/no.txt", "content": "no\n"}),
1100 )
1101 .await
1102 .expect_err("out-of-scope File write")
1103 .to_string();
1104 assert!(error.contains("outside its machine-readable"), "{error}");
1105 assert!(!tmp.path().join("docs/no.txt").exists());
1106 }
1107
1108 #[tokio::test]
1109 async fn fleet_authority_allows_only_classifier_proven_readonly_bash() {
1110 let tmp = tempdir().expect("tempdir");
1111 std::fs::create_dir(tmp.path().join("src")).expect("src");
1112 std::fs::write(
1113 tmp.path().join("src/evidence.txt"),
1114 "first\nsecond\nthird\n",
1115 )
1116 .expect("inspection fixture");
1117 let registry = ToolRegistryBuilder::new()
1118 .with_shell_tools()
1119 .build(readonly_scout_context(tmp.path(), true));
1120
1121 let shell = BashTool::new("Bash");
1122 for command in [
1123 "pwd",
1124 "git status --short",
1125 "rg needle src",
1126 "gh issue list --limit 10",
1127 "gh issue view 5287 --json title,state",
1128 "sed -n '2,3p' src/evidence.txt",
1129 // #6015: durable workers accept the same grammar as in-session
1130 // agents — pipelines, chains, find, git -C and a leading cd.
1131 "rg -n foo src | head -5",
1132 "find . -name '*.rs'",
1133 "git -C . log --oneline -3",
1134 "cd src && git diff",
1135 "git diff HEAD && echo '=== FILES ===' && ls -la",
1136 "rg -n foo src 2>/dev/null",
1137 "sed -n '2p' src/evidence.txt | head -n 1",
1138 "sed -n '2p' src/evidence.txt | gh issue list",
1139 "gh issue list | sed -n '2p'",
1140 "find src -name '*.rs'",
1141 ] {
1142 enforce_tool_authority(
1143 "Bash",
1144 &json!({"action": "run", "command": command}),
1145 &shell,
1146 registry.context(),
1147 )
1148 .unwrap_or_else(|error| panic!("{command} should fit read-only Scout authority: {error}"));
1149 }
1150
1151 let result = registry
1152 .execute_full("Bash", json!({"action": "run", "command": "pwd"}))
1153 .await
1154 .expect("bounded read-only Bash survives machine authority");
1155 assert!(result.success, "{}", result.content);
1156
1157 #[cfg(unix)]
1158 for name in ["bash", "Bash"] {
1159 let result = registry
1160 .execute_full(name, json!({"command": "sed -n '2,3p' src/evidence.txt"}))
1161 .await
1162 .expect("numeric sed inspection survives machine authority");
1163 assert!(result.success, "{}", result.content);
1164 assert!(
1165 result.content.contains("second\nthird"),
1166 "{}",
1167 result.content
1168 );
1169 assert_eq!(
1170 std::fs::read_to_string(tmp.path().join("src/evidence.txt")).expect("fixture"),
1171 "first\nsecond\nthird\n"
1172 );
1173 }
1174
1175 for command in [
1176 "touch src/no.txt",
1177 "git checkout -- src/lib.rs",
1178 "git push origin main",
1179 "gh issue close 5287",
1180 "gh issue edit 5287 --title changed",
1181 "gh issue create --title nope --body nope",
1182 "gh issue view 5287 > issue.txt",
1183 "gh issue view 5287 &",
1184 "bash -lc 'git status'",
1185 "sed -i -n '2p' src/evidence.txt",
1186 "sed -n '2p' src/evidence.txt -i",
1187 "sed -n '2p' src/evidence.txt -e 'w src/no.txt'",
1188 "sed -n '2p' src/evidence.txt -f src/evidence.txt",
1189 "sed -n 'w src/no.txt' src/evidence.txt",
1190 "sed -n 'e touch src/no.txt' src/evidence.txt",
1191 "sed -n 's/first/changed/w src/no.txt' src/evidence.txt",
1192 "sed -n '2p' $(touch src/no.txt)",
1193 "sed -n '2p' src/evidence.txt > src/no.txt",
1194 "sed -n '2p' src/evidence.txt && touch src/no.txt",
1195 "find src -delete",
1196 "awk '1' src/evidence.txt",
1197 "git commit -m x",
1198 "sort -o src/no.txt src/evidence.txt",
1199 "cd /etc; cat passwd",
1200 ] {
1201 let error = registry
1202 .execute_full("Bash", json!({"action": "run", "command": command}))
1203 .await
1204 .expect_err("mutating Bash remains outside machine authority")
1205 .to_string();
1206 // The refusal names the rule, from the same classifier every
1207 // read-only gate uses.
1208 assert!(error.contains("[shell.readonly.command]"), "{error}");
1209 assert!(error.contains("File tool"), "{error}");
1210 }
1211 assert!(!tmp.path().join("src/no.txt").exists());
1212
1213 let no_shell = scoped_context(tmp.path());
1214 let error = enforce_tool_authority(
1215 "Bash",
1216 &json!({"action": "run", "command": "pwd"}),
1217 &shell,
1218 &no_shell,
1219 )
1220 .expect_err("mutation authority must not imply shell authority")
1221 .to_string();
1222 assert!(error.contains("does not grant read-only shell"), "{error}");
1223 }
1224
1225 #[test]
1226 fn fleet_authority_sed_inspection_preserves_policy_boundaries() {
1227 let tmp = tempdir().expect("tempdir");
1228 let context = readonly_scout_context(tmp.path(), false);
1229 let registry = ToolRegistryBuilder::new().with_shell_tools().build(context);
1230 for name in ["bash", "Bash"] {
1231 let shell = registry.get(name).expect("shell tool");
1232 let input = if name == "bash" {
1233 json!({"command": "sed -n '300,400p' src/lib.rs", "timeout": 10})
1234 } else {
1235 json!({"action": "run", "command": "sed -n '300,400p' src/lib.rs", "timeout_ms": 10_000})
1236 };
1237 enforce_tool_authority(name, &input, shell.as_ref(), registry.context())
1238 .expect("local numeric sed inspection needs no network grant");
1239 assert!(
1240 !shell.is_read_only_for(&input),
1241 "parent classification stays strict"
1242 );
1243 assert!(
1244 !shell.supports_parallel_for(&input),
1245 "parallel policy stays strict"
1246 );
1247 assert_eq!(
1248 shell.approval_requirement_for(&input),
1249 ApprovalRequirement::Required
1250 );
1251
1252 let mut denied = registry.context().clone();
1253 denied.disallowed_tools = vec!["Bash".into()];
1254 assert!(enforce_tool_authority(name, &input, shell.as_ref(), &denied).is_err());
1255 assert!(
1256 enforce_tool_authority(name, &input, shell.as_ref(), &scoped_context(tmp.path()))
1257 .is_err(),
1258 "write authority does not grant shell authority"
1259 );
1260 assert!(
1261 enforce_tool_authority(
1262 name,
1263 &input,
1264 shell.as_ref(),
1265 &readonly_verifier_context(tmp.path())
1266 )
1267 .is_err(),
1268 "shell-less evidence authority stays shell-less"
1269 );
1270 for field in [
1271 json!({"background": true}),
1272 json!({"tty": true}),
1273 json!({"interactive": true}),
1274 json!({"stdin": ""}),
1275 json!({"action": "wait"}),
1276 json!({"action": "interact"}),
1277 json!({"action": "cancel"}),
1278 json!({"action": 3}),
1279 json!({"task_id": "shell_1"}),
1280 json!({"persist": true}),
1281 json!({"sandbox_permissions": "danger-full-access", "justification": "test"}),
1282 ] {
1283 let mut rejected = input.clone();
1284 rejected
1285 .as_object_mut()
1286 .unwrap()
1287 .extend(field.as_object().unwrap().clone());
1288 assert!(
1289 enforce_tool_authority(name, &rejected, shell.as_ref(), registry.context())
1290 .is_err(),
1291 "{name}: {rejected}"
1292 );
1293 }
1294 }
1295 }
1296
1297 #[test]
1298 fn fleet_authority_intersects_readonly_github_bash_with_network_ceiling() {
1299 let tmp = tempdir().expect("tempdir");
1300 let shell = BashTool::new("Bash");
1301 let input = json!({"action": "run", "command": "gh issue view 5287"});
1302 let networked = ToolContext::new(tmp.path().to_path_buf())
1303 .with_tool_authority(ToolAuthorityEnvelope {
1304 schema_version: 1,
1305 owner: "scout".to_string(),
1306 authority: ToolMutationAuthority::ReadOnly,
1307 network_access: Some(true),
1308 shell: crate::tools::spec::ToolShellAuthority::ReadOnly,
1309 verification: crate::tools::spec::ToolVerificationAuthority::None,
1310 writable_roots: Vec::new(),
1311 writable_files: Vec::new(),
1312 coordination_contracts: Vec::new(),
1313 })
1314 .expect("networked scout");
1315 enforce_tool_authority("Bash", &input, &shell, &networked)
1316 .expect("networked scout may inspect GitHub");
1317
1318 let offline = ToolContext::new(tmp.path().to_path_buf())
1319 .with_tool_authority(ToolAuthorityEnvelope {
1320 schema_version: 1,
1321 owner: "offline-scout".to_string(),
1322 authority: ToolMutationAuthority::ReadOnly,
1323 network_access: Some(false),
1324 shell: crate::tools::spec::ToolShellAuthority::ReadOnly,
1325 verification: crate::tools::spec::ToolVerificationAuthority::None,
1326 writable_roots: Vec::new(),
1327 writable_files: Vec::new(),
1328 coordination_contracts: Vec::new(),
1329 })
1330 .expect("offline scout");
1331 let error = enforce_tool_authority("Bash", &input, &shell, &offline)
1332 .expect_err("network denial must win")
1333 .to_string();
1334 assert!(error.contains("does not grant network access"), "{error}");
1335
1336 // #6015: an admitted network read cannot hide inside a pipeline or chain.
1337 for command in [
1338 "gh pr view 1 | head",
1339 "ls && gh issue list",
1340 "cd . && gh pr view 1",
1341 ] {
1342 let input = json!({"action": "run", "command": command});
1343 enforce_tool_authority("Bash", &input, &shell, &networked)
1344 .unwrap_or_else(|error| panic!("{command}: {error}"));
1345 let error = enforce_tool_authority("Bash", &input, &shell, &offline)
1346 .expect_err("network denial must win inside compositions")
1347 .to_string();
1348 assert!(
1349 error.contains("does not grant network access"),
1350 "{command}: {error}"
1351 );
1352 }
1353 // Network access alone cannot authorize npm's configured destinations.
1354 for command in [
1355 "npm view x",
1356 "npm view @scope/pkg --json",
1357 "cd sub && npm view x",
1358 ] {
1359 let input = json!({"action": "run", "command": command});
1360 for context in [&networked, &offline] {
1361 let error = enforce_tool_authority("Bash", &input, &shell, context)
1362 .expect_err("npm metadata reads require ordinary shell authority")
1363 .to_string();
1364 assert!(error.contains("configuration"), "{command}: {error}");
1365 }
1366 }
1367 }
1368
1369 #[tokio::test]
1370 async fn fleet_authority_denies_git_even_when_the_action_is_nominally_read_only() {
1371 let tmp = tempdir().expect("tempdir");
1372 std::fs::create_dir(tmp.path().join("src")).expect("src");
1373 let registry = ToolRegistryBuilder::new()
1374 .with_git_tools()
1375 .with_git_history_tools()
1376 .with_review_tool(None, "fixture-model".to_string())
1377 .build(scoped_context(tmp.path()));
1378
1379 for (name, input) in [
1380 ("Git", json!({"action": "status"})),
1381 ("Git", json!({"action": "diff"})),
1382 ("Git", json!({"action": "show", "revision": "HEAD"})),
1383 ("Git", json!({"action": "blame", "path": "src/lib.rs"})),
1384 ("review", json!({"target": "diff"})),
1385 ] {
1386 let error = registry
1387 .execute_full(name, input)
1388 .await
1389 .expect_err("Git subprocesses remain unprovable under Fleet authority")
1390 .to_string();
1391 assert!(error.contains("Git helpers"), "{name}: {error}");
1392 }
1393 }
1394
1395 #[tokio::test]
1396 async fn fleet_authority_rejects_fim_edit_outside_its_write_scope() {
1397 let tmp = tempdir().expect("tempdir");
1398 std::fs::create_dir(tmp.path().join("src")).expect("src");
1399 std::fs::create_dir(tmp.path().join("docs")).expect("docs");
1400 std::fs::write(tmp.path().join("docs/outside.txt"), "before\nafter\n").expect("fixture");
1401 let registry = ToolRegistryBuilder::new()
1402 .with_fim_tool(None, "fixture-model".to_string())
1403 .build(scoped_context(tmp.path()));
1404
1405 let error = registry
1406 .execute_full(
1407 "fim_edit",
1408 json!({
1409 "path": "docs/outside.txt",
1410 "prefix_anchor": "before\n",
1411 "suffix_anchor": "after\n"
1412 }),
1413 )
1414 .await
1415 .expect_err("FIM mutation must be checked before model execution")
1416 .to_string();
1417 assert!(error.contains("outside its machine-readable"), "{error}");
1418 assert_eq!(
1419 std::fs::read_to_string(tmp.path().join("docs/outside.txt")).unwrap(),
1420 "before\nafter\n"
1421 );
1422 }
1423
1424 struct MixedExecutionTool;
1425
1426 #[async_trait::async_trait]
1427 impl ToolSpec for MixedExecutionTool {
1428 fn name(&self) -> &str {
1429 "mixed_execution"
1430 }
1431
1432 fn description(&self) -> &str {
1433 "inspect or start a child"
1434 }
1435
1436 fn input_schema(&self) -> Value {
1437 json!({"type": "object"})
1438 }
1439
1440 fn capabilities(&self) -> Vec<ToolCapability> {
1441 vec![ToolCapability::ExecutesCode]
1442 }
1443
1444 fn is_read_only_for(&self, input: &Value) -> bool {
1445 input.get("action").and_then(Value::as_str) == Some("inspect")
1446 }
1447
1448 async fn execute(
1449 &self,
1450 _input: Value,
1451 _context: &ToolContext,
1452 ) -> Result<ToolResult, ToolError> {
1453 Ok(ToolResult::success("observed"))
1454 }
1455 }
1456
1457 #[tokio::test]
1458 async fn fleet_authority_allows_read_only_actions_but_denies_mixed_family_starts() {
1459 let tmp = tempdir().expect("tempdir");
1460 std::fs::create_dir(tmp.path().join("src")).expect("src");
1461 let registry = ToolRegistryBuilder::new()
1462 .with_tool(Arc::new(MixedExecutionTool))
1463 .build(scoped_context(tmp.path()));
1464
1465 registry
1466 .execute_full("mixed_execution", json!({"action": "inspect"}))
1467 .await
1468 .expect("read-only status/inspect actions remain usable");
1469 let error = registry
1470 .execute_full("mixed_execution", json!({"action": "start"}))
1471 .await
1472 .expect_err("child/code starts remain denied")
1473 .to_string();
1474 assert!(error.contains("child execution"), "{error}");
1475 }
1476
1477 struct UnscopedMutator;
1478
1479 #[async_trait::async_trait]
1480 impl ToolSpec for UnscopedMutator {
1481 fn name(&self) -> &str {
1482 "unscoped_mutator"
1483 }
1484
1485 fn description(&self) -> &str {
1486 "mutates state without a file target"
1487 }
1488
1489 fn input_schema(&self) -> Value {
1490 json!({"type": "object"})
1491 }
1492
1493 fn capabilities(&self) -> Vec<ToolCapability> {
1494 Vec::new()
1495 }
1496
1497 fn is_read_only_for(&self, _input: &Value) -> bool {
1498 false
1499 }
1500
1501 async fn execute(
1502 &self,
1503 _input: Value,
1504 _context: &ToolContext,
1505 ) -> Result<ToolResult, ToolError> {
1506 Ok(ToolResult::success("mutated"))
1507 }
1508 }
1509
1510 #[tokio::test]
1511 async fn fleet_authority_denies_every_unscoped_mutator_not_only_file_capabilities() {
1512 let tmp = tempdir().expect("tempdir");
1513 std::fs::create_dir(tmp.path().join("src")).expect("src");
1514 let registry = ToolRegistryBuilder::new()
1515 .with_tool(Arc::new(UnscopedMutator))
1516 .build(scoped_context(tmp.path()));
1517
1518 let error = registry
1519 .execute_full("unscoped_mutator", json!({}))
1520 .await
1521 .expect_err("unscoped mutation must fail closed")
1522 .to_string();
1523 assert!(error.contains("mutating tool"), "{error}");
1524 }
1525
1526 #[test]
1527 fn test_builder_basic() {
1528 let tmp = tempdir().expect("tempdir");
1529 let ctx = ToolContext::new(tmp.path().to_path_buf());
1530
1531 let registry = ToolRegistryBuilder::new()
1532 .with_tool(make_test_tool("custom"))
1533 .build(ctx);
1534
1535 assert!(registry.contains("custom"));
1536 }
1537
1538 #[test]
1539 fn test_builder_with_web_tools_no_longer_includes_finance() {
1540 let tmp = tempdir().expect("tempdir");
1541 let ctx = ToolContext::new(tmp.path().to_path_buf());
1542
1543 let registry = ToolRegistryBuilder::new().with_web_tools().build(ctx);
1544
1545 // The model-facing web surface is the canonical action-dispatched tool.
1546 assert!(registry.contains("Web"));
1547 assert!(registry.contains("web.run"));
1548 for retired in ["web_search", "fetch_url", "wait_for_dev_server"] {
1549 assert!(!registry.contains(retired), "{retired} must stay removed");
1550 }
1551 assert!(!registry.contains("finance"));
1552 }
1553
1554 #[test]
1555 fn canonical_runtime_tools_hide_compatibility_aliases() {
1556 let tmp = tempdir().expect("tempdir");
1557 let ctx = ToolContext::new(tmp.path().to_path_buf());
1558 let registry = ToolRegistryBuilder::new()
1559 .with_file_tools()
1560 .with_search_tools()
1561 .with_git_tools()
1562 .with_git_history_tools()
1563 .with_test_runner_tool()
1564 .with_web_tools()
1565 .with_patch_tools()
1566 .build(ctx);
1567
1568 let api_names = registry
1569 .to_api_tools()
1570 .into_iter()
1571 .map(|tool| tool.name)
1572 .collect::<Vec<_>>();
1573 for canonical in [
1574 "read",
1575 "write",
1576 "edit",
1577 "list_dir",
1578 "file_search",
1579 "grep_files",
1580 "Git",
1581 "Run",
1582 "Web",
1583 ] {
1584 assert!(api_names.iter().any(|name| name == canonical));
1585 }
1586 for hidden in ["File", "read_file", "write_file", "edit_file"] {
1587 assert!(registry.contains(hidden), "{hidden} must remain replayable");
1588 assert!(
1589 api_names.iter().all(|name| name != hidden),
1590 "{hidden} must stay out of new model catalogs"
1591 );
1592 }
1593 for retired in [
1594 "git_status",
1595 "git_diff",
1596 "git_log",
1597 "git_show",
1598 "git_blame",
1599 "run_tests",
1600 "run_verifiers",
1601 "web_search",
1602 "fetch_url",
1603 "wait_for_dev_server",
1604 ] {
1605 assert!(!registry.contains(retired), "{retired} must stay removed");
1606 assert!(
1607 api_names.iter().all(|name| name != retired),
1608 "{retired} must not be advertised"
1609 );
1610 }
1611 // apply_patch remains searchable/deferred outside the Pi-small head.
1612 assert!(registry.contains("apply_patch"));
1613 assert!(api_names.iter().any(|name| name == "apply_patch"));
1614 }
1615
1616 #[tokio::test]
1617 async fn canonical_file_actions_share_read_before_edit_state() {
1618 let tmp = tempdir().expect("tempdir");
1619 std::fs::write(tmp.path().join("sample.txt"), "before\n").expect("fixture");
1620 let ctx = ToolContext::new(tmp.path().to_path_buf());
1621 let registry = ToolRegistryBuilder::new().with_file_tools().build(ctx);
1622
1623 registry
1624 .execute_full("File", json!({"action": "read", "path": "sample.txt"}))
1625 .await
1626 .expect("canonical read should execute");
1627 registry
1628 .execute_full(
1629 "File",
1630 json!({
1631 "action": "edit",
1632 "path": "sample.txt",
1633 "search": "before",
1634 "replace": "after"
1635 }),
1636 )
1637 .await
1638 .expect("canonical edit should execute after the read");
1639
1640 assert_eq!(
1641 std::fs::read_to_string(tmp.path().join("sample.txt")).expect("edited file"),
1642 "after\n"
1643 );
1644 }
1645
1646 #[test]
1647 fn read_only_file_surface_does_not_advertise_write_actions() {
1648 let tmp = tempdir().expect("tempdir");
1649 let ctx = ToolContext::new(tmp.path().to_path_buf());
1650 let registry = ToolRegistryBuilder::new()
1651 .with_read_only_file_tools()
1652 .with_search_tools()
1653 .build(ctx);
1654 let names = registry
1655 .to_api_tools()
1656 .into_iter()
1657 .map(|tool| tool.name)
1658 .collect::<Vec<_>>();
1659 assert!(names.iter().any(|name| name == "read"));
1660 for hidden_or_mutating in ["File", "read_file", "write", "edit"] {
1661 assert!(
1662 names.iter().all(|name| name != hidden_or_mutating),
1663 "{hidden_or_mutating} must not be model-visible"
1664 );
1665 }
1666 assert!(registry.contains("File"));
1667 assert!(registry.contains("read_file"));
1668 assert!(!registry.contains("write_file"));
1669 assert!(!registry.contains("edit_file"));
1670 let hidden_file = registry
1671 .get("File")
1672 .expect("hidden File compatibility tool");
1673 let schema = hidden_file.input_schema();
1674 let actions = schema["properties"]["action"]["enum"]
1675 .as_array()
1676 .expect("action enum");
1677
1678 for blocked in ["write", "edit", "patch"] {
1679 assert!(actions.iter().all(|action| action != blocked));
1680 }
1681 }
1682
1683 #[test]
1684 fn test_builder_with_finance_tool() {
1685 let tmp = tempdir().expect("tempdir");
1686 let ctx = ToolContext::new(tmp.path().to_path_buf());
1687
1688 let registry = ToolRegistryBuilder::new().with_finance_tool().build(ctx);
1689
1690 assert!(registry.contains("finance"));
1691 }
1692
1693 #[test]
1694 fn with_verify_tool_registers_and_exposes_verify() {
1695 let tmp = tempdir().expect("tempdir");
1696 let ctx = ToolContext::new(tmp.path().to_path_buf());
1697
1698 let registry = ToolRegistryBuilder::new()
1699 .with_verify_tool(None, "test-model".to_string())
1700 .build(ctx);
1701
1702 assert!(
1703 registry.contains("verify"),
1704 "verify tool should be registered"
1705 );
1706 let api_names = registry
1707 .to_api_tools()
1708 .into_iter()
1709 .map(|tool| tool.name)
1710 .collect::<Vec<_>>();
1711 assert!(
1712 api_names.iter().any(|name| name == "verify"),
1713 "verify tool should be model-visible"
1714 );
1715 }
1716
1717 #[test]
1718 fn agent_runtime_surface_gates_verify_on_option() {
1719 use super::AgentToolSurfaceOptions;
1720 use crate::worker_profile::ShellPolicy;
1721
1722 let build_surface = |verify_enabled: bool| {
1723 let tmp = tempdir().expect("tempdir");
1724 let ctx = ToolContext::new(tmp.path().to_path_buf());
1725 let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full);
1726 options.verify_tool_enabled = verify_enabled;
1727 ToolRegistryBuilder::new()
1728 .with_agent_runtime_surface(
1729 None,
1730 "test-model".to_string(),
1731 options,
1732 crate::tools::todo::new_shared_todo_list(),
1733 crate::tools::plan::new_shared_plan_state(),
1734 )
1735 .build(ctx)
1736 };
1737
1738 assert!(
1739 build_surface(true).contains("verify"),
1740 "verify should register when enabled"
1741 );
1742 assert!(
1743 !build_surface(false).contains("verify"),
1744 "verify should be absent when the opt-out disables it"
1745 );
1746 }
1747
1748 #[test]
1749 fn agent_runtime_surface_gates_request_plugin_install_on_option() {
1750 use super::AgentToolSurfaceOptions;
1751 use crate::worker_profile::ShellPolicy;
1752
1753 // Policy rule 11: hosts without the TUI (exec, runtime API) and sessions
1754 // with contextual tips off get no plugin-offer tool in any mode.
1755 let build_surface = |enabled: bool| {
1756 let tmp = tempdir().expect("tempdir");
1757 let ctx = ToolContext::new(tmp.path().to_path_buf());
1758 let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full);
1759 options.request_plugin_install_enabled = enabled;
1760 ToolRegistryBuilder::new()
1761 .with_agent_runtime_surface(
1762 None,
1763 "test-model".to_string(),
1764 options,
1765 crate::tools::todo::new_shared_todo_list(),
1766 crate::tools::plan::new_shared_plan_state(),
1767 )
1768 .build(ctx)
1769 };
1770
1771 assert!(build_surface(true).contains("request_plugin_install"));
1772 assert!(!build_surface(false).contains("request_plugin_install"));
1773 }
1774
1775 #[test]
1776 fn test_builder_with_agent_tools_policy_includes_finance() {
1777 let tmp = tempdir().expect("tempdir");
1778 let ctx = ToolContext::new(tmp.path().to_path_buf());
1779
1780 let registry = ToolRegistryBuilder::new()
1781 .with_agent_tools_policy(
1782 crate::worker_profile::ShellPolicy::None,
1783 crate::tools::user_input::UserInputLimits::default(),
1784 )
1785 .build(ctx);
1786
1787 assert!(registry.contains("finance"));
1788 }
1789
1790 #[test]
1791 fn agent_tools_with_shell_policy_none_excludes_shell_tools() {
1792 let tmp = tempdir().expect("tempdir");
1793 let ctx = ToolContext::new(tmp.path().to_path_buf());
1794
1795 let registry = ToolRegistryBuilder::new()
1796 .with_agent_tools_policy(
1797 crate::worker_profile::ShellPolicy::None,
1798 crate::tools::user_input::UserInputLimits::default(),
1799 )
1800 .build(ctx);
1801
1802 assert!(!registry.contains("bash"));
1803 assert!(!registry.contains("Bash"));
1804 assert!(
1805 !registry.contains("exec_shell"),
1806 "retired exec_shell must remain absent"
1807 );
1808 assert!(
1809 !registry.contains("task_shell_start"),
1810 "task_shell_start should be excluded when the shell policy is None"
1811 );
1812 assert!(
1813 !registry.contains("task_shell_wait"),
1814 "task_shell_wait should be excluded when the shell policy is None"
1815 );
1816 }
1817
1818 #[test]
1819 fn agent_tools_with_shell_policy_readonly_exposes_only_run_only_bash() {
1820 let tmp = tempdir().expect("tempdir");
1821 let ctx = ToolContext::new(tmp.path().to_path_buf());
1822
1823 let registry = ToolRegistryBuilder::new()
1824 .with_agent_tools_policy(
1825 crate::worker_profile::ShellPolicy::ReadOnly,
1826 crate::tools::user_input::UserInputLimits::default(),
1827 )
1828 .build(ctx);
1829
1830 assert!(registry.contains("bash"));
1831 assert!(registry.contains("Bash"));
1832 assert!(!registry.contains("exec_shell"));
1833 assert!(!registry.contains("task_shell_start"));
1834 assert!(!registry.contains("task_shell_wait"));
1835 assert!(
1836 registry
1837 .names()
1838 .into_iter()
1839 .all(|name| !name.starts_with("terminal/"))
1840 );
1841 let bash = registry
1842 .to_api_tools()
1843 .into_iter()
1844 .find(|tool| tool.name == "bash")
1845 .expect("read-only lowercase bash catalog");
1846 assert_eq!(bash.input_schema["required"], json!(["command"]));
1847 assert_eq!(
1848 bash.input_schema["properties"]
1849 .as_object()
1850 .expect("bash properties")
1851 .keys()
1852 .cloned()
1853 .collect::<std::collections::BTreeSet<_>>(),
1854 [
1855 "command",
1856 "justification",
1857 "read_only",
1858 "sandbox_permissions",
1859 "timeout"
1860 ]
1861 .into_iter()
1862 .map(str::to_string)
1863 .collect()
1864 );
1865 for hidden in ["action", "background", "tty", "stdin", "task_id", "wait"] {
1866 assert!(bash.input_schema["properties"].get(hidden).is_none());
1867 }
1868 assert!(
1869 registry
1870 .to_api_tools()
1871 .iter()
1872 .all(|tool| tool.name != "Bash")
1873 );
1874 }
1875
1876 #[test]
1877 fn machine_readonly_catalog_is_exactly_the_evidence_profile() {
1878 let tmp = tempdir().expect("tempdir");
1879 let registry = ToolRegistryBuilder::new()
1880 .with_agent_tools_policy(
1881 crate::worker_profile::ShellPolicy::ReadOnly,
1882 crate::tools::user_input::UserInputLimits::default(),
1883 )
1884 .with_web_tools()
1885 .with_todo_tool(crate::tools::todo::new_shared_todo_list())
1886 .build(readonly_scout_context(tmp.path(), true));
1887 let tools = registry.to_api_tools();
1888 let names = tools
1889 .iter()
1890 .map(|tool| tool.name.as_str())
1891 .collect::<Vec<_>>();
1892 assert_eq!(names, {
1893 let mut expected = vec![
1894 "Web",
1895 "bash",
1896 "diagnostics",
1897 "file_search",
1898 "finance",
1899 "grep_files",
1900 "handle_read",
1901 "list_dir",
1902 "load_skill",
1903 "lsp",
1904 "project_map",
1905 "read",
1906 "read_media",
1907 "request_user_input",
1908 "retrieve_tool_result",
1909 "todo_write",
1910 "tui_help",
1911 "validate_data",
1912 "web.run",
1913 ];
1914 if crate::tools::image_ocr::ocr_available() {
1915 expected.insert(7, "image_ocr");
1916 }
1917 expected
1918 });
1919 assert!(registry.contains("File"));
1920 assert!(registry.contains("Bash"));
1921 assert!(tools.iter().all(|tool| tool.name != "File"));
1922 assert!(tools.iter().all(|tool| tool.name != "Bash"));
1923 let shell = tools.iter().find(|tool| tool.name == "bash").unwrap();
1924 assert!(shell.description.contains("`cd <dir> &&`"));
1925 assert!(shell.description.contains("git log"));
1926 assert!(shell.description.contains("cannot change its own role"));
1927 let bash = registry.get("bash").unwrap();
1928 enforce_tool_authority(
1929 "bash",
1930 &json!({"command": "cd src && git status"}),
1931 bash.as_ref(),
1932 registry.context(),
1933 )
1934 .expect("a leading cd moves into the working directory (#6015)");
1935 for command in ["git branch -a", "git rev-parse HEAD"] {
1936 let error = enforce_tool_authority(
1937 "bash",
1938 &json!({"command":command}),
1939 bash.as_ref(),
1940 registry.context(),
1941 )
1942 .unwrap_err()
1943 .to_string();
1944 assert!(
1945 error.contains("subcommand:") && error.contains("git log"),
1946 "{error}"
1947 );
1948 }
1949 let web = tools.iter().find(|tool| tool.name == "Web").unwrap();
1950 assert_eq!(
1951 web.input_schema["properties"]["action"]["enum"],
1952 json!(["search", "fetch"])
1953 );
1954 let lsp = registry
1955 .get("lsp")
1956 .expect("registered but catalog-hidden lsp");
1957 enforce_tool_authority("lsp", &json!({}), lsp.as_ref(), registry.context())
1958 .expect("machine read-only dispatch uses the same positive profile as the catalog");
1959 let offline = ToolRegistryBuilder::new()
1960 .with_web_tools()
1961 .build(readonly_scout_context(tmp.path(), false));
1962 assert!(
1963 offline
1964 .to_api_tools()
1965 .iter()
1966 .all(|tool| !matches!(tool.name.as_str(), "Web" | "web.run"))
1967 );
1968 }
1969
1970 #[test]
1971 fn agent_tools_with_shell_policy_full_includes_shell_tools() {
1972 let tmp = tempdir().expect("tempdir");
1973 let ctx = ToolContext::new(tmp.path().to_path_buf());
1974
1975 let registry = ToolRegistryBuilder::new()
1976 .with_agent_tools_policy(
1977 crate::worker_profile::ShellPolicy::Full,
1978 crate::tools::user_input::UserInputLimits::default(),
1979 )
1980 .build(ctx);
1981
1982 assert!(registry.contains("bash"));
1983 assert!(registry.contains("Bash"));
1984 assert!(!registry.contains("exec_shell"));
1985 assert!(
1986 registry.contains("task_shell_start"),
1987 "task_shell_start should be included when the shell policy is Full"
1988 );
1989 assert!(
1990 registry.contains("task_shell_wait"),
1991 "task_shell_wait should be included when the shell policy is Full"
1992 );
1993 let api_names = registry
1994 .to_api_tools()
1995 .into_iter()
1996 .map(|tool| tool.name)
1997 .collect::<Vec<_>>();
1998 assert!(api_names.iter().any(|name| name == "bash"));
1999 assert!(api_names.iter().all(|name| name != "Bash"));
2000 }
2001
2002 /// v0.9.3 removes the per-action shell aliases entirely.
2003 #[test]
2004 fn shell_surface_exposes_lowercase_bash_and_hides_legacy_handler() {
2005 let tmp = tempdir().expect("tempdir");
2006 let ctx = ToolContext::new(tmp.path().to_path_buf());
2007 let registry = ToolRegistryBuilder::new().with_shell_tools().build(ctx);
2008
2009 for alias in [
2010 "exec_shell",
2011 "exec_wait",
2012 "exec_interact",
2013 "exec_shell_wait",
2014 "exec_shell_interact",
2015 "exec_shell_cancel",
2016 ] {
2017 assert!(!registry.contains(alias), "{alias} must be removed");
2018 }
2019
2020 let api_names: Vec<String> = registry
2021 .to_api_tools()
2022 .into_iter()
2023 .map(|tool| tool.name)
2024 .collect();
2025
2026 assert!(registry.contains("bash"));
2027 assert!(registry.contains("Bash"));
2028
2029 // Only lowercase bash is model-visible.
2030 assert!(
2031 api_names.iter().any(|n| n == "bash"),
2032 "bash should be model-visible"
2033 );
2034 assert!(api_names.iter().all(|n| n != "Bash"));
2035
2036 // Removed names also cannot leak back into the model catalog.
2037 for alias in [
2038 "exec_shell",
2039 "exec_wait",
2040 "exec_interact",
2041 "exec_shell_wait",
2042 "exec_shell_interact",
2043 "exec_shell_cancel",
2044 ] {
2045 assert!(
2046 api_names.iter().all(|n| n != alias),
2047 "{alias} should be hidden from the model catalog"
2048 );
2049 }
2050 }
2051
2052 /// Each durable-work family exposes one canonical action tool; v0.9.3
2053 /// removes the per-action execution aliases.
2054 #[test]
2055 fn runtime_task_families_expose_only_canonical_tools() {
2056 let tmp = tempdir().expect("tempdir");
2057 let ctx = ToolContext::new(tmp.path().to_path_buf());
2058 let registry = ToolRegistryBuilder::new()
2059 .with_runtime_task_tools()
2060 .build(ctx);
2061
2062 let legacy_aliases = [
2063 "task_create",
2064 "task_list",
2065 "task_read",
2066 "task_cancel",
2067 "task_gate_run",
2068 "pr_attempt_record",
2069 "pr_attempt_list",
2070 "pr_attempt_read",
2071 "pr_attempt_preflight",
2072 "github_issue_context",
2073 "github_pr_context",
2074 "github_comment",
2075 "github_close_issue",
2076 "github_close_pr",
2077 "automation_create",
2078 "automation_list",
2079 "automation_read",
2080 "automation_update",
2081 "automation_pause",
2082 "automation_resume",
2083 "automation_delete",
2084 "automation_run",
2085 ];
2086 for alias in legacy_aliases {
2087 assert!(!registry.contains(alias), "{alias} must be removed");
2088 }
2089
2090 let api_names: Vec<String> = registry
2091 .to_api_tools()
2092 .into_iter()
2093 .map(|tool| tool.name)
2094 .collect();
2095
2096 // Only the canonical tools are model-visible.
2097 for canonical in ["tasks", "github", "automation"] {
2098 assert!(
2099 api_names.iter().any(|n| n == canonical),
2100 "{canonical} should be model-visible"
2101 );
2102 }
2103 // Removed aliases also cannot leak back into the model catalog.
2104 for alias in legacy_aliases {
2105 assert!(
2106 api_names.iter().all(|n| n != alias),
2107 "{alias} should be hidden from the model catalog"
2108 );
2109 }
2110 }
2111
2112 /// The Plan-mode read-only surface registers only the canonical families,
2113 /// restricted to their read actions.
2114 #[test]
2115 fn read_only_task_surface_contains_no_per_action_aliases() {
2116 let tmp = tempdir().expect("tempdir");
2117 let ctx = ToolContext::new(tmp.path().to_path_buf());
2118 let registry = ToolRegistryBuilder::new()
2119 .with_runtime_read_only_task_tools()
2120 .build(ctx);
2121
2122 for name in [
2123 "task_list",
2124 "task_read",
2125 "pr_attempt_list",
2126 "pr_attempt_read",
2127 "github_issue_context",
2128 "github_pr_context",
2129 "automation_list",
2130 "automation_read",
2131 "task_create",
2132 "task_cancel",
2133 "task_gate_run",
2134 "pr_attempt_record",
2135 "pr_attempt_preflight",
2136 "github_comment",
2137 "github_close_issue",
2138 "github_close_pr",
2139 "automation_create",
2140 "automation_update",
2141 "automation_pause",
2142 "automation_resume",
2143 "automation_delete",
2144 "automation_run",
2145 ] {
2146 assert!(!registry.contains(name), "{name} must be removed");
2147 }
2148
2149 let api_names: Vec<String> = registry
2150 .to_api_tools()
2151 .into_iter()
2152 .map(|tool| tool.name)
2153 .collect();
2154 assert_eq!(api_names.len(), 4);
2155 for canonical in ["tasks", "github", "automation", "send_later"] {
2156 assert!(
2157 api_names.iter().any(|n| n == canonical),
2158 "{canonical} should be model-visible on the read-only surface"
2159 );
2160 }
2161 // Every registered tool stays read-only (Plan-mode invariant).
2162 for tool in registry.all() {
2163 let caps = tool.capabilities();
2164 assert!(
2165 !caps.contains(&ToolCapability::WritesFiles)
2166 && !caps.contains(&ToolCapability::ExecutesCode),
2167 "read-only surface must not register write/exec tools: {}",
2168 tool.name()
2169 );
2170 }
2171 }
2172
2173 /// The action-shaped RLM family is registered only for compatibility.
2174 #[test]
2175 fn rlm_family_removes_legacy_aliases() {
2176 let tmp = tempdir().expect("tempdir");
2177 let ctx = ToolContext::new(tmp.path().to_path_buf());
2178 let registry = ToolRegistryBuilder::new().with_rlm_tool().build(ctx);
2179
2180 for alias in [
2181 "rlm_session_objects",
2182 "rlm_open",
2183 "rlm_eval",
2184 "rlm_configure",
2185 "rlm_close",
2186 ] {
2187 assert!(!registry.contains(alias), "{alias} must stay removed");
2188 }
2189
2190 let api_names: Vec<String> = registry
2191 .to_api_tools()
2192 .into_iter()
2193 .map(|tool| tool.name)
2194 .collect();
2195 assert!(
2196 api_names.iter().all(|n| n != "rlm"),
2197 "the compatibility RLM surface must not be advertised to new model turns"
2198 );
2199 for retired in [
2200 "rlm_session_objects",
2201 "rlm_open",
2202 "rlm_eval",
2203 "rlm_configure",
2204 "rlm_close",
2205 ] {
2206 assert!(
2207 api_names.iter().all(|n| n != retired),
2208 "{retired} must not be advertised"
2209 );
2210 }
2211 }
2212
2213 #[test]
2214 fn a_builder_upgrade_replaces_the_tool_instead_of_registering_it_twice() {
2215 // `with_patch_tools` swaps the default `File` for the patch-capable one;
2216 // that used to reach `register` as a second `File` and warn on every
2217 // registry rebuild (#5934).
2218 let builder = ToolRegistryBuilder::new()
2219 .with_file_tools()
2220 .with_patch_tools();
2221 let file_tools = builder
2222 .tools
2223 .iter()
2224 .filter(|tool| tool.name() == "File")
2225 .count();
2226 assert_eq!(file_tools, 1, "one File tool after the upgrade");
2227 assert!(
2228 builder
2229 .tools
2230 .iter()
2231 .any(|tool| tool.name() == "apply_patch"),
2232 "the upgrade still adds apply_patch"
2233 );
2234 let tmp = tempdir().unwrap();
2235 let warnings = capture_registration_warnings(|| {
2236 let registry = builder.build(ToolContext::new(tmp.path()));
2237 assert!(registry.contains("File"));
2238 assert!(registry.contains("apply_patch"));
2239 });
2240 assert!(warnings.is_empty(), "normal File composition: {warnings}");
2241 }
2242
2243 fn capture_registration_warnings(action: impl FnOnce()) -> String {
2244 use std::io::{Read, Seek, SeekFrom};
2245 let mut output = tempfile::tempfile().unwrap();
2246 let subscriber = tracing_subscriber::fmt()
2247 .without_time()
2248 .with_ansi(false)
2249 .with_max_level(tracing::Level::WARN)
2250 .with_writer(std::sync::Mutex::new(output.try_clone().unwrap()))
2251 .finish();
2252 tracing::subscriber::with_default(subscriber, action);
2253 output.seek(SeekFrom::Start(0)).unwrap();
2254 let mut warnings = String::new();
2255 output.read_to_string(&mut warnings).unwrap();
2256 warnings
2257 }
2258
2259 #[test]
2260 fn runtime_surface_hardening_plugin_collisions_preserve_registered_tools() {
2261 let tmp = tempdir().unwrap();
2262 let mut registry = ToolRegistryBuilder::new()
2263 .with_file_tools()
2264 .build(ToolContext::new(tmp.path()));
2265 let original = registry.get("File").unwrap();
2266 let original_catalog = registry.to_api_tools();
2267 std::fs::write(
2268 tmp.path().join("tool.sh"),
2269 "# name: File\n# description: custom file tool\n",
2270 )
2271 .unwrap();
2272 let errors = capture_registration_warnings(|| registry.load_plugins(tmp.path()));
2273 assert!(
2274 errors.contains("Cannot load plugin tool 'File': name is already registered"),
2275 "{errors}"
2276 );
2277 assert!(errors.contains(&format!(
2278 "previous_origin={:?}",
2279 original.registration_origin()
2280 )));
2281 let plugin_origin = format!(
2282 "plugin script tool.sh ({})",
2283 crate::safe_label::SafeLabel::identifier(&tmp.path().join("tool.sh").to_string_lossy())
2284 );
2285 assert!(errors.contains(&format!("plugin_origin={plugin_origin:?}")));
2286 assert!(!errors.contains(tmp.path().to_string_lossy().as_ref()));
2287 assert!(Arc::ptr_eq(&registry.get("File").unwrap(), &original));
2288 assert_eq!(
2289 serde_json::to_value(registry.to_api_tools()).unwrap(),
2290 serde_json::to_value(original_catalog).unwrap()
2291 );
2292
2293 std::fs::write(
2294 tmp.path().join("other.sh"),
2295 "# name: custom-reader\n# description: custom reader\n",
2296 )
2297 .unwrap();
2298 registry.load_plugins(tmp.path());
2299 assert!(registry.contains("custom-reader"));
2300
2301 // An explicit override still wins over a drop-in script of the same name;
2302 // it never replaces a built-in (see
2303 // `script_and_command_overrides_cannot_replace_builtins`).
2304 let builtins = std::collections::HashSet::from(["File".to_string()]);
2305 let overrides = std::collections::HashMap::from([(
2306 "custom-reader".to_string(),
2307 crate::config::ToolOverride::Command {
2308 command: "my-reader".to_string(),
2309 args: None,
2310 },
2311 )]);
2312 registry.apply_overrides(&overrides, tmp.path(), &builtins);
2313 assert!(
2314 registry
2315 .get("custom-reader")
2316 .unwrap()
2317 .description()
2318 .contains("my-reader")
2319 );
2320 assert!(Arc::ptr_eq(&registry.get("File").unwrap(), &original));
2321 }
2322
2323 #[test]
2324 fn registration_adapter_origins_are_bounded_and_exclude_execution_payloads() {
2325 use crate::tools::dynamic::RuntimeDynamicTool;
2326 use crate::tools::plugin::tool_from_override;
2327 use codewhale_protocol::runtime::DynamicToolSpec;
2328 let tmp = tempdir().unwrap();
2329 let hostile = format!(
2330 "\u{1b}[31m\nhttps://private.invalid/token?{}",
2331 "x".repeat(500)
2332 );
2333 let command = "do-not-log-command";
2334 let argument = "do-not-log-argument";
2335 let schema_payload = "do-not-log-schema";
2336 let cases: Vec<(Arc<dyn ToolSpec>, &str)> = vec![
2337 (
2338 Arc::new(RuntimeDynamicTool::new(DynamicToolSpec {
2339 name: hostile.clone(),
2340 namespace: Some(hostile.clone()),
2341 description: command.into(),
2342 input_schema: json!({"description":schema_payload}),
2343 defer_loading: false,
2344 })),
2345 "runtime dynamic namespace sha256:",
2346 ),
2347 (
2348 Arc::new(super::McpToolAdapter {
2349 name: hostile.clone(),
2350 server_name: Some("plugin-4-demo-server_with_underscores".into()),
2351 tool: crate::mcp::McpTool {
2352 name: hostile.clone(),
2353 description: Some(command.into()),
2354 input_schema: json!({"description":schema_payload}),
2355 annotations: None,
2356 },
2357 pool: Arc::new(tokio::sync::Mutex::new(crate::mcp::McpPool::new(
2358 crate::mcp::McpConfig::default(),
2359 ))),
2360 }),
2361 "MCP server plugin-4-demo-server_with_underscores, tool sha256:",
2362 ),
2363 (
2364 tool_from_override(
2365 &hostile,
2366 &ToolOverride::Command {
2367 command: command.into(),
2368 args: Some(vec![argument.into()]),
2369 },
2370 tmp.path(),
2371 )
2372 .unwrap(),
2373 "config [tools.overrides.sha256:",
2374 ),
2375 ];
2376 for (replacement, expected_origin) in cases {
2377 let mut registry = ToolRegistry::new(ToolContext::new(tmp.path()));
2378 registry.register(make_test_tool(&hostile));
2379 let warnings = capture_registration_warnings(|| registry.register(replacement.clone()));
2380 assert_eq!(warnings.lines().count(), 1, "{warnings}");
2381 assert!(
2382 warnings.contains("Overwriting existing tool: sha256:"),
2383 "{warnings}"
2384 );
2385 assert!(warnings.contains(expected_origin), "{warnings}");
2386 assert!(warnings.len() < 600, "{warnings}");
2387 for excluded in [
2388 &hostile,
2389 command,
2390 argument,
2391 schema_payload,
2392 "https://private.invalid",
2393 "\u{1b}",
2394 ] {
2395 assert!(
2396 !warnings.contains(excluded),
2397 "unexpected payload: {warnings}"
2398 );
2399 }
2400 assert!(Arc::ptr_eq(&registry.get(&hostile).unwrap(), &replacement));
2401 }
2402 }
2403
2404 /// Regression probe for the fleet-52663788 class of provider 400
2405 /// (`Invalid schema for function 'bash': null is not of type "array"`):
2406 /// a read-only Fleet worker (reviewer) projects its tool schemas before the
2407 /// wire; no projected schema may carry a JSON null, because strict
2408 /// OpenAI-compatible validators reject null where arrays/objects are typed.
2409 #[test]
2410 fn fleet_readonly_reviewer_wire_catalog_carries_no_null_schema_fields() {
2411 use crate::tools::spec::{
2412 ToolMutationAuthority, ToolShellAuthority, ToolVerificationAuthority,
2413 };
2414
2415 fn collect_null_paths(value: &Value, path: String, out: &mut Vec<String>) {
2416 match value {
2417 Value::Null => out.push(path),
2418 Value::Object(map) => {
2419 for (key, child) in map {
2420 collect_null_paths(child, format!("{path}.{key}"), out);
2421 }
2422 }
2423 Value::Array(items) => {
2424 for (index, child) in items.iter().enumerate() {
2425 collect_null_paths(child, format!("{path}[{index}]"), out);
2426 }
2427 }
2428 _ => {}
2429 }
2430 }
2431
2432 let tmp = tempdir().expect("tempdir");
2433 let reviewer_authority = ToolAuthorityEnvelope {
2434 schema_version: 1,
2435 owner: "reviewer".to_string(),
2436 authority: ToolMutationAuthority::ReadOnly,
2437 network_access: Some(false),
2438 shell: ToolShellAuthority::ReadOnly,
2439 verification: ToolVerificationAuthority::None,
2440 writable_roots: Vec::new(),
2441 writable_files: Vec::new(),
2442 coordination_contracts: Vec::new(),
2443 };
2444 let context = ToolContext::new(tmp.path().to_path_buf())
2445 .with_tool_authority(reviewer_authority)
2446 .expect("reviewer authority");
2447
2448 let registry = ToolRegistryBuilder::new()
2449 .with_file_tools()
2450 .with_foreground_shell_tools()
2451 .with_search_tools()
2452 .build(context);
2453 let tools = registry.to_api_tools();
2454 assert!(
2455 tools.iter().any(|tool| tool.name == "bash"),
2456 "reviewer keeps classifier-bounded bash"
2457 );
2458
2459 for tool in &tools {
2460 let mut nulls = Vec::new();
2461 collect_null_paths(&tool.input_schema, "$".to_string(), &mut nulls);
2462 assert!(
2463 nulls.is_empty(),
2464 "tool {} schema carries null at {nulls:?}: {}",
2465 tool.name,
2466 tool.input_schema
2467 );
2468 }
2469 }
2470
2471 #[test]
2472 fn read_media_is_not_offered_to_a_text_only_route() {
2473 use codewhale_config::route::CapabilityState;
2474 let tmp = tempdir().unwrap();
2475 for (state, offered) in [
2476 (CapabilityState::Unsupported, false),
2477 (CapabilityState::Unknown, true),
2478 (CapabilityState::Supported, true),
2479 ] {
2480 let mut context = ToolContext::new(tmp.path());
2481 context.route_capabilities.image_input = state;
2482 let registry = ToolRegistryBuilder::new()
2483 .with_read_media_tool()
2484 .build(context);
2485 assert_eq!(registry.get("read_media").is_some(), offered, "{state:?}");
2486 }
2487 }
2488
2489 /// #6559 D04-10: a client-supplied dynamic tool cannot take over a builtin
2490 /// handler (and its approval policy), and a second dynamic tool with the same
2491 /// model-facing name from another namespace cannot replace the first.
2492 #[test]
2493 fn dynamic_tools_never_replace_registered_tools() {
2494 use codewhale_protocol::runtime::DynamicToolSpec;
2495 let tmp = tempdir().unwrap();
2496 let spec = |namespace: &str, name: &str, description: &str| DynamicToolSpec {
2497 namespace: Some(namespace.to_string()),
2498 name: name.to_string(),
2499 description: description.to_string(),
2500 input_schema: json!({"type": "object"}),
2501 defer_loading: false,
2502 };
2503 let registry = ToolRegistryBuilder::new()
2504 .with_file_tools()
2505 .with_dynamic_tools(&[
2506 spec("client", "read", "client read"),
2507 spec("first", "lookup", "first lookup"),
2508 spec("second", "lookup", "second lookup"),
2509 ])
2510 .build(ToolContext::new(tmp.path()));
2511
2512 let read = registry.get("read").expect("builtin read");
2513 assert_ne!(read.description(), "client read");
2514 assert!(
2515 !read.registration_origin().contains("runtime dynamic"),
2516 "{}",
2517 read.registration_origin()
2518 );
2519 assert_eq!(
2520 registry
2521 .get("lookup")
2522 .expect("first dynamic tool")
2523 .description(),
2524 "first lookup"
2525 );
2526 }
2527
2527 lines RUST