返回 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(None, "test-model".to_string())
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 #[test]
505 fn apply_overrides_removes_original_when_replacement_is_missing() {
506 let tmp = tempdir().expect("tempdir");
507 let ctx = ToolContext::new(tmp.path().to_path_buf());
508 let mut registry = ToolRegistryBuilder::new().with_file_tools().build(ctx);
509
510 assert!(registry.contains("File"));
511
512 let mut overrides = HashMap::new();
513 overrides.insert(
514 "File".to_string(),
515 ToolOverride::Script {
516 path: "missing-wrapper.sh".to_string(),
517 args: None,
518 },
519 );
520
521 registry.apply_overrides(&overrides, tmp.path());
522
523 assert!(!registry.contains("File"));
524 }
525
526 #[test]
527 fn builder_registers_speech_alias_tools() {
528 let tmp = tempdir().expect("tempdir");
529 let ctx = ToolContext::new(tmp.path().to_path_buf());
530 let registry = ToolRegistryBuilder::new()
531 .with_speech_tools(None, None)
532 .build(ctx);
533
534 assert!(registry.contains("speech"));
535 assert!(registry.contains("tts"));
536 // One capability, one catalog entry: the alias stays callable for replay
537 // but is not advertised (#5941).
538 let visible: Vec<String> = registry
539 .to_api_tools()
540 .into_iter()
541 .map(|tool| tool.name)
542 .collect();
543 assert!(visible.iter().any(|name| name == "speech"));
544 assert!(!visible.iter().any(|name| name == "tts"), "{visible:?}");
545 }
546
547 #[test]
548 fn agent_runtime_surface_skips_speech_without_a_client() {
549 use super::AgentToolSurfaceOptions;
550 use crate::worker_profile::ShellPolicy;
551 let tmp = tempdir().expect("tempdir");
552 let ctx = ToolContext::new(tmp.path().to_path_buf());
553 let registry = ToolRegistryBuilder::new()
554 .with_agent_runtime_surface(
555 None,
556 "test-model".to_string(),
557 AgentToolSurfaceOptions::new(ShellPolicy::Full),
558 crate::tools::todo::new_shared_todo_list(),
559 crate::tools::plan::new_shared_plan_state(),
560 )
561 .build(ctx);
562 assert!(!registry.contains("speech"));
563 assert!(!registry.contains("tts"));
564 }
565
566 #[test]
567 fn model_visible_tool_descriptions_name_no_vendor() {
568 use super::AgentToolSurfaceOptions;
569 use crate::worker_profile::ShellPolicy;
570 let tmp = tempdir().expect("tempdir");
571 let ctx = ToolContext::new(tmp.path().to_path_buf());
572 let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full);
573 options.web_search_enabled = true;
574 let registry = ToolRegistryBuilder::new()
575 .with_agent_runtime_surface(
576 None,
577 "test-model".to_string(),
578 options,
579 crate::tools::todo::new_shared_todo_list(),
580 crate::tools::plan::new_shared_plan_state(),
581 )
582 .with_speech_tools(None, None)
583 .build(ctx);
584 let vendors = [
585 "xiaomi",
586 "mimo",
587 "claude",
588 "anthropic",
589 "openai",
590 "gpt-",
591 "deepseek",
592 "gemini",
593 "kimi",
594 "qwen",
595 "grok",
596 "mistral",
597 ];
598 for tool in registry.to_api_tools() {
599 let description = tool.description.to_ascii_lowercase();
600 for vendor in vendors {
601 assert!(
602 !description.contains(vendor),
603 "tool {} names a vendor ({vendor}) in its model-facing description",
604 tool.name
605 );
606 }
607 }
608 }
609
610 #[test]
611 fn test_registry_names() {
612 let tmp = tempdir().expect("tempdir");
613 let ctx = ToolContext::new(tmp.path().to_path_buf());
614 let mut registry = ToolRegistry::new(ctx);
615
616 registry.register(make_test_tool("tool_a"));
617 registry.register(make_test_tool("tool_b"));
618
619 let names = registry.names();
620 assert_eq!(names.len(), 2);
621 assert!(names.contains(&"tool_a"));
622 assert!(names.contains(&"tool_b"));
623 }
624
625 #[test]
626 fn test_registry_to_api_tools() {
627 let tmp = tempdir().expect("tempdir");
628 let ctx = ToolContext::new(tmp.path().to_path_buf());
629 let mut registry = ToolRegistry::new(ctx);
630
631 registry.register(make_test_tool("my_tool"));
632
633 let api_tools = registry.to_api_tools();
634 assert_eq!(api_tools.len(), 1);
635 assert_eq!(api_tools[0].name, "my_tool");
636 assert_eq!(api_tools[0].description, "A test tool");
637 }
638
639 #[test]
640 fn api_tools_with_cache_marks_last_tool_ephemeral() {
641 let tmp = tempdir().expect("tempdir");
642 let ctx = ToolContext::new(tmp.path().to_path_buf());
643 let mut registry = ToolRegistry::new(ctx);
644
645 registry.register(make_test_tool("tool_a"));
646 registry.register(make_test_tool("tool_b"));
647
648 let api_tools = registry.to_api_tools_with_cache(true);
649 assert_eq!(api_tools.len(), 2);
650 assert!(api_tools[0].cache_control.is_none());
651 assert_eq!(
652 api_tools[1]
653 .cache_control
654 .as_ref()
655 .map(|c| c.cache_type.as_str()),
656 Some("ephemeral")
657 );
658 }
659
660 /// Tool whose `description()` advances through a script of pre-built
661 /// strings, one per call. Used to demonstrate that the api-tools cache
662 /// pins the description bytes on first read instead of re-sampling them
663 /// each turn (#263 follow-up; mirrors reference-cc's `getToolSchemaCache`).
664 struct VaryingDescriptionTool {
665 name: String,
666 descriptions: Vec<String>,
667 next: std::sync::atomic::AtomicUsize,
668 }
669
670 impl VaryingDescriptionTool {
671 fn new(name: &str, descriptions: &[&str]) -> Self {
672 Self {
673 name: name.to_string(),
674 descriptions: descriptions.iter().map(|s| (*s).to_string()).collect(),
675 next: std::sync::atomic::AtomicUsize::new(0),
676 }
677 }
678 }
679
680 #[async_trait::async_trait]
681 impl ToolSpec for VaryingDescriptionTool {
682 fn name(&self) -> &str {
683 &self.name
684 }
685
686 fn description(&self) -> &str {
687 let idx = self
688 .next
689 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
690 .min(self.descriptions.len() - 1);
691 &self.descriptions[idx]
692 }
693
694 fn input_schema(&self) -> Value {
695 json!({"type": "object", "properties": {}, "required": []})
696 }
697
698 fn capabilities(&self) -> Vec<ToolCapability> {
699 vec![ToolCapability::ReadOnly]
700 }
701
702 async fn execute(
703 &self,
704 _input: Value,
705 _context: &ToolContext,
706 ) -> Result<ToolResult, ToolError> {
707 Ok(ToolResult::success("ok".to_string()))
708 }
709 }
710
711 #[test]
712 fn to_api_tools_pins_description_bytes_across_calls() {
713 // Regression for the cache-stability follow-up: an MCP adapter that
714 // returns a different `description()` on reconnect (or any other
715 // tool whose description isn't a `&'static str`) would otherwise
716 // rewrite the catalog bytes mid-session and miss the prefix cache.
717 // The registry pins the first call's value until it's mutated.
718 let tmp = tempdir().expect("tempdir");
719 let ctx = ToolContext::new(tmp.path().to_path_buf());
720 let mut registry = ToolRegistry::new(ctx);
721 registry.register(Arc::new(VaryingDescriptionTool::new(
722 "varying",
723 &["first description", "second description"],
724 )));
725
726 let first = registry.to_api_tools();
727 let second = registry.to_api_tools();
728
729 assert_eq!(first.len(), 1);
730 assert_eq!(first[0].description, "first description");
731 assert_eq!(
732 first, second,
733 "api-tools catalog must be byte-identical across reads with no mutation in between"
734 );
735 }
736
737 #[test]
738 fn register_invalidates_api_tools_cache() {
739 // Counter-test: when a real change happens (a new tool registers,
740 // an existing one is removed, or `clear` is called), the cache must
741 // be discarded so the next read reflects the live registry.
742 let tmp = tempdir().expect("tempdir");
743 let ctx = ToolContext::new(tmp.path().to_path_buf());
744 let mut registry = ToolRegistry::new(ctx);
745 registry.register(Arc::new(VaryingDescriptionTool::new(
746 "varying",
747 &["first description", "second description"],
748 )));
749
750 let before = registry.to_api_tools();
751 assert_eq!(before.len(), 1);
752
753 registry.register(make_test_tool("late_arrival"));
754
755 let after = registry.to_api_tools();
756 assert_eq!(after.len(), 2, "cache must rebuild after register");
757 assert!(after.iter().any(|t| t.name == "varying"));
758 assert!(after.iter().any(|t| t.name == "late_arrival"));
759 // The varying tool's description advances on cache rebuild — the
760 // first read above sampled `first description`; this rebuild samples
761 // `second description`. The point is just that the bytes *can*
762 // change after a real mutation, not that they always do.
763 let varying_after = after
764 .iter()
765 .find(|t| t.name == "varying")
766 .expect("varying tool present");
767 assert_eq!(varying_after.description, "second description");
768 }
769
770 #[test]
771 fn remove_tool_invalidates_api_tools_cache() {
772 let tmp = tempdir().expect("tempdir");
773 let ctx = ToolContext::new(tmp.path().to_path_buf());
774 let mut registry = ToolRegistry::new(ctx);
775 registry.register(make_test_tool("alpha"));
776 registry.register(make_test_tool("beta"));
777
778 let before = registry.to_api_tools();
779 assert_eq!(before.len(), 2);
780
781 assert!(registry.remove_tool("alpha"));
782 let after_remove = registry.to_api_tools();
783 assert_eq!(after_remove.len(), 1);
784 assert_eq!(after_remove[0].name, "beta");
785 }
786
787 #[test]
788 fn to_api_tools_emits_alphabetical_order_regardless_of_registration_order() {
789 // Regression for #263: HashMap iteration is non-deterministic across
790 // process launches, which busts DeepSeek's KV prefix cache for every
791 // cross-session resume. `to_api_tools` must emit by name regardless
792 // of registration order so two consecutive calls (and two distinct
793 // launches) produce byte-identical output.
794 let tmp = tempdir().expect("tempdir");
795 let ctx = ToolContext::new(tmp.path().to_path_buf());
796
797 let order_a = {
798 let mut registry = ToolRegistry::new(ctx.clone());
799 registry.register(make_test_tool("zebra"));
800 registry.register(make_test_tool("alpha"));
801 registry.register(make_test_tool("mango"));
802 registry
803 .to_api_tools()
804 .iter()
805 .map(|t| t.name.clone())
806 .collect::<Vec<_>>()
807 };
808
809 let order_b = {
810 let mut registry = ToolRegistry::new(ctx.clone());
811 registry.register(make_test_tool("alpha"));
812 registry.register(make_test_tool("mango"));
813 registry.register(make_test_tool("zebra"));
814 registry
815 .to_api_tools()
816 .iter()
817 .map(|t| t.name.clone())
818 .collect::<Vec<_>>()
819 };
820
821 assert_eq!(order_a, vec!["alpha", "mango", "zebra"]);
822 assert_eq!(order_a, order_b);
823 }
824
825 fn scoped_context(workspace: &std::path::Path) -> ToolContext {
826 ToolContext::new(workspace.to_path_buf())
827 .with_tool_authority(
828 ToolAuthorityEnvelope {
829 schema_version: 1,
830 owner: "fleet-worker-1".to_string(),
831 authority: ToolMutationAuthority::ScopedWrite,
832 network_access: None,
833 shell: crate::tools::spec::ToolShellAuthority::None,
834 verification: crate::tools::spec::ToolVerificationAuthority::None,
835 writable_roots: vec!["src".to_string()],
836 writable_files: Vec::new(),
837 coordination_contracts: Vec::new(),
838 }
839 .normalized()
840 .expect("test authority"),
841 )
842 .expect("test context authority")
843 }
844
845 fn readonly_scout_context(workspace: &std::path::Path, network_access: bool) -> ToolContext {
846 ToolContext::new(workspace.to_path_buf())
847 .with_tool_authority(ToolAuthorityEnvelope {
848 schema_version: 1,
849 owner: "scout-1".to_string(),
850 authority: ToolMutationAuthority::ReadOnly,
851 network_access: Some(network_access),
852 shell: crate::tools::spec::ToolShellAuthority::ReadOnly,
853 verification: crate::tools::spec::ToolVerificationAuthority::None,
854 writable_roots: Vec::new(),
855 writable_files: Vec::new(),
856 coordination_contracts: Vec::new(),
857 })
858 .expect("read-only Scout authority")
859 }
860
861 fn readonly_verifier_context(workspace: &std::path::Path) -> ToolContext {
862 ToolContext::new(workspace.to_path_buf())
863 .with_tool_authority(ToolAuthorityEnvelope {
864 schema_version: 1,
865 owner: "verifier-1".to_string(),
866 authority: ToolMutationAuthority::ReadOnly,
867 network_access: Some(true),
868 shell: crate::tools::spec::ToolShellAuthority::None,
869 verification: crate::tools::spec::ToolVerificationAuthority::Bounded,
870 writable_roots: Vec::new(),
871 writable_files: Vec::new(),
872 coordination_contracts: Vec::new(),
873 })
874 .expect("bounded verifier authority")
875 }
876
877 #[test]
878 fn machine_verifier_catalog_and_dispatch_add_only_bounded_run() {
879 let tmp = tempdir().expect("tempdir");
880 let registry = ToolRegistryBuilder::new()
881 .with_agent_tools_policy(
882 crate::worker_profile::ShellPolicy::None,
883 crate::tools::user_input::UserInputLimits::default(),
884 )
885 .with_web_tools()
886 .with_todo_tool(crate::tools::todo::new_shared_todo_list())
887 .build(readonly_verifier_context(tmp.path()));
888 let tools = registry.to_api_tools();
889 let names = tools
890 .iter()
891 .map(|tool| tool.name.as_str())
892 .collect::<Vec<_>>();
893 assert_eq!(names, {
894 let mut expected = vec![
895 "Run",
896 "Web",
897 "diagnostics",
898 "file_search",
899 "finance",
900 "grep_files",
901 "handle_read",
902 "list_dir",
903 "load_skill",
904 "lsp",
905 "project_map",
906 "read",
907 "read_media",
908 "request_user_input",
909 "retrieve_tool_result",
910 "todo_write",
911 "tui_help",
912 "validate_data",
913 "web.run",
914 ];
915 if crate::tools::image_ocr::ocr_available() {
916 expected.insert(7, "image_ocr");
917 }
918 expected
919 });
920 let run = registry.get("Run").expect("bounded Run registered");
921 assert!(
922 tools
923 .iter()
924 .find(|tool| tool.name == "Run")
925 .unwrap()
926 .input_schema["properties"]
927 .get("commands")
928 .is_none(),
929 "the catalog must not advertise operator-supplied verifier programs"
930 );
931 enforce_tool_authority(
932 "Run",
933 &json!({"action": "tests", "args": "-p codewhale-tui ordinary_scout"}),
934 run.as_ref(),
935 registry.context(),
936 )
937 .expect("pure test selection fits bounded verifier authority");
938 for input in [
939 json!({"action": "tests", "args": "--manifest-path ../other/Cargo.toml"}),
940 json!({"action": "verifiers", "commands": [{"name": "escape", "program": "sh"}]}),
941 ] {
942 let error = enforce_tool_authority("Run", &input, run.as_ref(), registry.context())
943 .expect_err("unbounded verification must remain refused")
944 .to_string();
945 assert!(error.contains("unbounded verification"), "{error}");
946 }
947 assert!(!registry.contains("bash"), "Verifier never gains raw shell");
948 assert!(!registry.contains("Bash"), "Verifier never gains raw shell");
949 }
950
951 #[tokio::test]
952 async fn fleet_authority_allows_scoped_file_writes_and_rejects_outside_paths() {
953 let tmp = tempdir().expect("tempdir");
954 std::fs::create_dir(tmp.path().join("src")).expect("src");
955 std::fs::create_dir(tmp.path().join("docs")).expect("docs");
956 let registry = ToolRegistryBuilder::new()
957 .with_file_tools()
958 .with_patch_tools()
959 .build(scoped_context(tmp.path()));
960
961 registry
962 .execute_full(
963 "File",
964 json!({"action": "write", "path": "src/ok.txt", "content": "ok\n"}),
965 )
966 .await
967 .expect("scoped File write");
968 assert_eq!(
969 std::fs::read_to_string(tmp.path().join("src/ok.txt")).expect("written file"),
970 "ok\n"
971 );
972
973 let error = registry
974 .execute_full(
975 "File",
976 json!({"action": "write", "path": "docs/no.txt", "content": "no\n"}),
977 )
978 .await
979 .expect_err("out-of-scope File write")
980 .to_string();
981 assert!(error.contains("outside its machine-readable"), "{error}");
982 assert!(!tmp.path().join("docs/no.txt").exists());
983 }
984
985 #[tokio::test]
986 async fn fleet_authority_allows_only_classifier_proven_readonly_bash() {
987 let tmp = tempdir().expect("tempdir");
988 std::fs::create_dir(tmp.path().join("src")).expect("src");
989 std::fs::write(
990 tmp.path().join("src/evidence.txt"),
991 "first\nsecond\nthird\n",
992 )
993 .expect("inspection fixture");
994 let registry = ToolRegistryBuilder::new()
995 .with_shell_tools()
996 .build(readonly_scout_context(tmp.path(), true));
997
998 let shell = BashTool::new("Bash");
999 for command in [
1000 "pwd",
1001 "git status --short",
1002 "rg needle src",
1003 "gh issue list --limit 10",
1004 "gh issue view 5287 --json title,state",
1005 "sed -n '2,3p' src/evidence.txt",
1006 ] {
1007 enforce_tool_authority(
1008 "Bash",
1009 &json!({"action": "run", "command": command}),
1010 &shell,
1011 registry.context(),
1012 )
1013 .unwrap_or_else(|error| panic!("{command} should fit read-only Scout authority: {error}"));
1014 }
1015
1016 let result = registry
1017 .execute_full("Bash", json!({"action": "run", "command": "pwd"}))
1018 .await
1019 .expect("bounded read-only Bash survives machine authority");
1020 assert!(result.success, "{}", result.content);
1021
1022 #[cfg(unix)]
1023 for name in ["bash", "Bash"] {
1024 let result = registry
1025 .execute_full(name, json!({"command": "sed -n '2,3p' src/evidence.txt"}))
1026 .await
1027 .expect("numeric sed inspection survives machine authority");
1028 assert!(result.success, "{}", result.content);
1029 assert!(
1030 result.content.contains("second\nthird"),
1031 "{}",
1032 result.content
1033 );
1034 assert_eq!(
1035 std::fs::read_to_string(tmp.path().join("src/evidence.txt")).expect("fixture"),
1036 "first\nsecond\nthird\n"
1037 );
1038 }
1039
1040 for command in [
1041 "touch src/no.txt",
1042 "git checkout -- src/lib.rs",
1043 "git push origin main",
1044 "gh issue close 5287",
1045 "gh issue edit 5287 --title changed",
1046 "gh issue create --title nope --body nope",
1047 "gh issue view 5287 > issue.txt",
1048 "gh issue view 5287 &",
1049 "bash -lc 'git status'",
1050 "sed -i -n '2p' src/evidence.txt",
1051 "sed -n '2p' src/evidence.txt -i",
1052 "sed -n '2p' src/evidence.txt -e 'w src/no.txt'",
1053 "sed -n '2p' src/evidence.txt -f src/evidence.txt",
1054 "sed -n 'w src/no.txt' src/evidence.txt",
1055 "sed -n 'e touch src/no.txt' src/evidence.txt",
1056 "sed -n 's/first/changed/w src/no.txt' src/evidence.txt",
1057 "sed -n '2p' $(touch src/no.txt)",
1058 "sed -n '2p' src/evidence.txt > src/no.txt",
1059 "sed -n '2p' src/evidence.txt && touch src/no.txt",
1060 "sed -n '2p' src/evidence.txt | head -n 1",
1061 "sed -n '2p' src/evidence.txt | gh issue list",
1062 "gh issue list | sed -n '2p'",
1063 "npm view codewhale",
1064 "find src -name '*.rs'",
1065 "find src -delete",
1066 "awk '1' src/evidence.txt",
1067 ] {
1068 let error = registry
1069 .execute_full("Bash", json!({"action": "run", "command": command}))
1070 .await
1071 .expect_err("mutating Bash remains outside machine authority")
1072 .to_string();
1073 assert!(error.contains("arbitrary command execution"), "{error}");
1074 }
1075 assert!(!tmp.path().join("src/no.txt").exists());
1076
1077 let no_shell = scoped_context(tmp.path());
1078 let error = enforce_tool_authority(
1079 "Bash",
1080 &json!({"action": "run", "command": "pwd"}),
1081 &shell,
1082 &no_shell,
1083 )
1084 .expect_err("mutation authority must not imply shell authority")
1085 .to_string();
1086 assert!(error.contains("does not grant read-only shell"), "{error}");
1087 }
1088
1089 #[test]
1090 fn fleet_authority_sed_inspection_preserves_policy_boundaries() {
1091 let tmp = tempdir().expect("tempdir");
1092 let context = readonly_scout_context(tmp.path(), false);
1093 let registry = ToolRegistryBuilder::new().with_shell_tools().build(context);
1094 for name in ["bash", "Bash"] {
1095 let shell = registry.get(name).expect("shell tool");
1096 let input = if name == "bash" {
1097 json!({"command": "sed -n '300,400p' src/lib.rs", "timeout": 10})
1098 } else {
1099 json!({"action": "run", "command": "sed -n '300,400p' src/lib.rs", "timeout_ms": 10_000})
1100 };
1101 enforce_tool_authority(name, &input, shell.as_ref(), registry.context())
1102 .expect("local numeric sed inspection needs no network grant");
1103 assert!(
1104 !shell.is_read_only_for(&input),
1105 "parent classification stays strict"
1106 );
1107 assert!(
1108 !shell.supports_parallel_for(&input),
1109 "parallel policy stays strict"
1110 );
1111 assert_eq!(
1112 shell.approval_requirement_for(&input),
1113 ApprovalRequirement::Required
1114 );
1115
1116 let mut denied = registry.context().clone();
1117 denied.disallowed_tools = vec!["Bash".into()];
1118 assert!(enforce_tool_authority(name, &input, shell.as_ref(), &denied).is_err());
1119 assert!(
1120 enforce_tool_authority(name, &input, shell.as_ref(), &scoped_context(tmp.path()))
1121 .is_err(),
1122 "write authority does not grant shell authority"
1123 );
1124 assert!(
1125 enforce_tool_authority(
1126 name,
1127 &input,
1128 shell.as_ref(),
1129 &readonly_verifier_context(tmp.path())
1130 )
1131 .is_err(),
1132 "shell-less evidence authority stays shell-less"
1133 );
1134 for field in [
1135 json!({"background": true}),
1136 json!({"tty": true}),
1137 json!({"interactive": true}),
1138 json!({"stdin": ""}),
1139 json!({"action": "wait"}),
1140 json!({"action": "interact"}),
1141 json!({"action": "cancel"}),
1142 json!({"action": 3}),
1143 json!({"task_id": "shell_1"}),
1144 json!({"persist": true}),
1145 json!({"sandbox_permissions": "danger-full-access", "justification": "test"}),
1146 ] {
1147 let mut rejected = input.clone();
1148 rejected
1149 .as_object_mut()
1150 .unwrap()
1151 .extend(field.as_object().unwrap().clone());
1152 assert!(
1153 enforce_tool_authority(name, &rejected, shell.as_ref(), registry.context())
1154 .is_err(),
1155 "{name}: {rejected}"
1156 );
1157 }
1158 }
1159 }
1160
1161 #[test]
1162 fn fleet_authority_intersects_readonly_github_bash_with_network_ceiling() {
1163 let tmp = tempdir().expect("tempdir");
1164 let shell = BashTool::new("Bash");
1165 let input = json!({"action": "run", "command": "gh issue view 5287"});
1166 let networked = ToolContext::new(tmp.path().to_path_buf())
1167 .with_tool_authority(ToolAuthorityEnvelope {
1168 schema_version: 1,
1169 owner: "scout".to_string(),
1170 authority: ToolMutationAuthority::ReadOnly,
1171 network_access: Some(true),
1172 shell: crate::tools::spec::ToolShellAuthority::ReadOnly,
1173 verification: crate::tools::spec::ToolVerificationAuthority::None,
1174 writable_roots: Vec::new(),
1175 writable_files: Vec::new(),
1176 coordination_contracts: Vec::new(),
1177 })
1178 .expect("networked scout");
1179 enforce_tool_authority("Bash", &input, &shell, &networked)
1180 .expect("networked scout may inspect GitHub");
1181
1182 let offline = ToolContext::new(tmp.path().to_path_buf())
1183 .with_tool_authority(ToolAuthorityEnvelope {
1184 schema_version: 1,
1185 owner: "offline-scout".to_string(),
1186 authority: ToolMutationAuthority::ReadOnly,
1187 network_access: Some(false),
1188 shell: crate::tools::spec::ToolShellAuthority::ReadOnly,
1189 verification: crate::tools::spec::ToolVerificationAuthority::None,
1190 writable_roots: Vec::new(),
1191 writable_files: Vec::new(),
1192 coordination_contracts: Vec::new(),
1193 })
1194 .expect("offline scout");
1195 let error = enforce_tool_authority("Bash", &input, &shell, &offline)
1196 .expect_err("network denial must win")
1197 .to_string();
1198 assert!(error.contains("does not grant network access"), "{error}");
1199 }
1200
1201 #[tokio::test]
1202 async fn fleet_authority_denies_git_even_when_the_action_is_nominally_read_only() {
1203 let tmp = tempdir().expect("tempdir");
1204 std::fs::create_dir(tmp.path().join("src")).expect("src");
1205 let registry = ToolRegistryBuilder::new()
1206 .with_git_tools()
1207 .with_git_history_tools()
1208 .with_review_tool(None, "fixture-model".to_string())
1209 .build(scoped_context(tmp.path()));
1210
1211 for (name, input) in [
1212 ("Git", json!({"action": "status"})),
1213 ("Git", json!({"action": "diff"})),
1214 ("Git", json!({"action": "show", "revision": "HEAD"})),
1215 ("Git", json!({"action": "blame", "path": "src/lib.rs"})),
1216 ("review", json!({"target": "diff"})),
1217 ] {
1218 let error = registry
1219 .execute_full(name, input)
1220 .await
1221 .expect_err("Git subprocesses remain unprovable under Fleet authority")
1222 .to_string();
1223 assert!(error.contains("Git helpers"), "{name}: {error}");
1224 }
1225 }
1226
1227 #[tokio::test]
1228 async fn fleet_authority_rejects_fim_edit_outside_its_write_scope() {
1229 let tmp = tempdir().expect("tempdir");
1230 std::fs::create_dir(tmp.path().join("src")).expect("src");
1231 std::fs::create_dir(tmp.path().join("docs")).expect("docs");
1232 std::fs::write(tmp.path().join("docs/outside.txt"), "before\nafter\n").expect("fixture");
1233 let registry = ToolRegistryBuilder::new()
1234 .with_fim_tool(None, "fixture-model".to_string())
1235 .build(scoped_context(tmp.path()));
1236
1237 let error = registry
1238 .execute_full(
1239 "fim_edit",
1240 json!({
1241 "path": "docs/outside.txt",
1242 "prefix_anchor": "before\n",
1243 "suffix_anchor": "after\n"
1244 }),
1245 )
1246 .await
1247 .expect_err("FIM mutation must be checked before model execution")
1248 .to_string();
1249 assert!(error.contains("outside its machine-readable"), "{error}");
1250 assert_eq!(
1251 std::fs::read_to_string(tmp.path().join("docs/outside.txt")).unwrap(),
1252 "before\nafter\n"
1253 );
1254 }
1255
1256 struct MixedExecutionTool;
1257
1258 #[async_trait::async_trait]
1259 impl ToolSpec for MixedExecutionTool {
1260 fn name(&self) -> &str {
1261 "mixed_execution"
1262 }
1263
1264 fn description(&self) -> &str {
1265 "inspect or start a child"
1266 }
1267
1268 fn input_schema(&self) -> Value {
1269 json!({"type": "object"})
1270 }
1271
1272 fn capabilities(&self) -> Vec<ToolCapability> {
1273 vec![ToolCapability::ExecutesCode]
1274 }
1275
1276 fn is_read_only_for(&self, input: &Value) -> bool {
1277 input.get("action").and_then(Value::as_str) == Some("inspect")
1278 }
1279
1280 async fn execute(
1281 &self,
1282 _input: Value,
1283 _context: &ToolContext,
1284 ) -> Result<ToolResult, ToolError> {
1285 Ok(ToolResult::success("observed"))
1286 }
1287 }
1288
1289 #[tokio::test]
1290 async fn fleet_authority_allows_read_only_actions_but_denies_mixed_family_starts() {
1291 let tmp = tempdir().expect("tempdir");
1292 std::fs::create_dir(tmp.path().join("src")).expect("src");
1293 let registry = ToolRegistryBuilder::new()
1294 .with_tool(Arc::new(MixedExecutionTool))
1295 .build(scoped_context(tmp.path()));
1296
1297 registry
1298 .execute_full("mixed_execution", json!({"action": "inspect"}))
1299 .await
1300 .expect("read-only status/inspect actions remain usable");
1301 let error = registry
1302 .execute_full("mixed_execution", json!({"action": "start"}))
1303 .await
1304 .expect_err("child/code starts remain denied")
1305 .to_string();
1306 assert!(error.contains("child execution"), "{error}");
1307 }
1308
1309 struct UnscopedMutator;
1310
1311 #[async_trait::async_trait]
1312 impl ToolSpec for UnscopedMutator {
1313 fn name(&self) -> &str {
1314 "unscoped_mutator"
1315 }
1316
1317 fn description(&self) -> &str {
1318 "mutates state without a file target"
1319 }
1320
1321 fn input_schema(&self) -> Value {
1322 json!({"type": "object"})
1323 }
1324
1325 fn capabilities(&self) -> Vec<ToolCapability> {
1326 Vec::new()
1327 }
1328
1329 fn is_read_only_for(&self, _input: &Value) -> bool {
1330 false
1331 }
1332
1333 async fn execute(
1334 &self,
1335 _input: Value,
1336 _context: &ToolContext,
1337 ) -> Result<ToolResult, ToolError> {
1338 Ok(ToolResult::success("mutated"))
1339 }
1340 }
1341
1342 #[tokio::test]
1343 async fn fleet_authority_denies_every_unscoped_mutator_not_only_file_capabilities() {
1344 let tmp = tempdir().expect("tempdir");
1345 std::fs::create_dir(tmp.path().join("src")).expect("src");
1346 let registry = ToolRegistryBuilder::new()
1347 .with_tool(Arc::new(UnscopedMutator))
1348 .build(scoped_context(tmp.path()));
1349
1350 let error = registry
1351 .execute_full("unscoped_mutator", json!({}))
1352 .await
1353 .expect_err("unscoped mutation must fail closed")
1354 .to_string();
1355 assert!(error.contains("mutating tool"), "{error}");
1356 }
1357
1358 #[test]
1359 fn test_builder_basic() {
1360 let tmp = tempdir().expect("tempdir");
1361 let ctx = ToolContext::new(tmp.path().to_path_buf());
1362
1363 let registry = ToolRegistryBuilder::new()
1364 .with_tool(make_test_tool("custom"))
1365 .build(ctx);
1366
1367 assert!(registry.contains("custom"));
1368 }
1369
1370 #[test]
1371 fn test_builder_with_web_tools_no_longer_includes_finance() {
1372 let tmp = tempdir().expect("tempdir");
1373 let ctx = ToolContext::new(tmp.path().to_path_buf());
1374
1375 let registry = ToolRegistryBuilder::new().with_web_tools().build(ctx);
1376
1377 // The model-facing web surface is the canonical action-dispatched tool.
1378 assert!(registry.contains("Web"));
1379 assert!(registry.contains("web.run"));
1380 for retired in ["web_search", "fetch_url", "wait_for_dev_server"] {
1381 assert!(!registry.contains(retired), "{retired} must stay removed");
1382 }
1383 assert!(!registry.contains("finance"));
1384 }
1385
1386 #[test]
1387 fn canonical_runtime_tools_hide_compatibility_aliases() {
1388 let tmp = tempdir().expect("tempdir");
1389 let ctx = ToolContext::new(tmp.path().to_path_buf());
1390 let registry = ToolRegistryBuilder::new()
1391 .with_file_tools()
1392 .with_search_tools()
1393 .with_git_tools()
1394 .with_git_history_tools()
1395 .with_test_runner_tool()
1396 .with_web_tools()
1397 .with_patch_tools()
1398 .build(ctx);
1399
1400 let api_names = registry
1401 .to_api_tools()
1402 .into_iter()
1403 .map(|tool| tool.name)
1404 .collect::<Vec<_>>();
1405 for canonical in [
1406 "read",
1407 "write",
1408 "edit",
1409 "list_dir",
1410 "file_search",
1411 "grep_files",
1412 "Git",
1413 "Run",
1414 "Web",
1415 ] {
1416 assert!(api_names.iter().any(|name| name == canonical));
1417 }
1418 for hidden in ["File", "read_file", "write_file", "edit_file"] {
1419 assert!(registry.contains(hidden), "{hidden} must remain replayable");
1420 assert!(
1421 api_names.iter().all(|name| name != hidden),
1422 "{hidden} must stay out of new model catalogs"
1423 );
1424 }
1425 for retired in [
1426 "git_status",
1427 "git_diff",
1428 "git_log",
1429 "git_show",
1430 "git_blame",
1431 "run_tests",
1432 "run_verifiers",
1433 "web_search",
1434 "fetch_url",
1435 "wait_for_dev_server",
1436 ] {
1437 assert!(!registry.contains(retired), "{retired} must stay removed");
1438 assert!(
1439 api_names.iter().all(|name| name != retired),
1440 "{retired} must not be advertised"
1441 );
1442 }
1443 // apply_patch remains searchable/deferred outside the Pi-small head.
1444 assert!(registry.contains("apply_patch"));
1445 assert!(api_names.iter().any(|name| name == "apply_patch"));
1446 }
1447
1448 #[tokio::test]
1449 async fn canonical_file_actions_share_read_before_edit_state() {
1450 let tmp = tempdir().expect("tempdir");
1451 std::fs::write(tmp.path().join("sample.txt"), "before\n").expect("fixture");
1452 let ctx = ToolContext::new(tmp.path().to_path_buf());
1453 let registry = ToolRegistryBuilder::new().with_file_tools().build(ctx);
1454
1455 registry
1456 .execute_full("File", json!({"action": "read", "path": "sample.txt"}))
1457 .await
1458 .expect("canonical read should execute");
1459 registry
1460 .execute_full(
1461 "File",
1462 json!({
1463 "action": "edit",
1464 "path": "sample.txt",
1465 "search": "before",
1466 "replace": "after"
1467 }),
1468 )
1469 .await
1470 .expect("canonical edit should execute after the read");
1471
1472 assert_eq!(
1473 std::fs::read_to_string(tmp.path().join("sample.txt")).expect("edited file"),
1474 "after\n"
1475 );
1476 }
1477
1478 #[test]
1479 fn read_only_file_surface_does_not_advertise_write_actions() {
1480 let tmp = tempdir().expect("tempdir");
1481 let ctx = ToolContext::new(tmp.path().to_path_buf());
1482 let registry = ToolRegistryBuilder::new()
1483 .with_read_only_file_tools()
1484 .with_search_tools()
1485 .build(ctx);
1486 let names = registry
1487 .to_api_tools()
1488 .into_iter()
1489 .map(|tool| tool.name)
1490 .collect::<Vec<_>>();
1491 assert!(names.iter().any(|name| name == "read"));
1492 for hidden_or_mutating in ["File", "read_file", "write", "edit"] {
1493 assert!(
1494 names.iter().all(|name| name != hidden_or_mutating),
1495 "{hidden_or_mutating} must not be model-visible"
1496 );
1497 }
1498 assert!(registry.contains("File"));
1499 assert!(registry.contains("read_file"));
1500 assert!(!registry.contains("write_file"));
1501 assert!(!registry.contains("edit_file"));
1502 let hidden_file = registry
1503 .get("File")
1504 .expect("hidden File compatibility tool");
1505 let schema = hidden_file.input_schema();
1506 let actions = schema["properties"]["action"]["enum"]
1507 .as_array()
1508 .expect("action enum");
1509
1510 for blocked in ["write", "edit", "patch"] {
1511 assert!(actions.iter().all(|action| action != blocked));
1512 }
1513 }
1514
1515 #[test]
1516 fn test_builder_with_finance_tool() {
1517 let tmp = tempdir().expect("tempdir");
1518 let ctx = ToolContext::new(tmp.path().to_path_buf());
1519
1520 let registry = ToolRegistryBuilder::new().with_finance_tool().build(ctx);
1521
1522 assert!(registry.contains("finance"));
1523 }
1524
1525 #[test]
1526 fn with_verify_tool_registers_and_exposes_verify() {
1527 let tmp = tempdir().expect("tempdir");
1528 let ctx = ToolContext::new(tmp.path().to_path_buf());
1529
1530 let registry = ToolRegistryBuilder::new()
1531 .with_verify_tool(None, "test-model".to_string())
1532 .build(ctx);
1533
1534 assert!(
1535 registry.contains("verify"),
1536 "verify tool should be registered"
1537 );
1538 let api_names = registry
1539 .to_api_tools()
1540 .into_iter()
1541 .map(|tool| tool.name)
1542 .collect::<Vec<_>>();
1543 assert!(
1544 api_names.iter().any(|name| name == "verify"),
1545 "verify tool should be model-visible"
1546 );
1547 }
1548
1549 #[test]
1550 fn agent_runtime_surface_gates_verify_on_option() {
1551 use super::AgentToolSurfaceOptions;
1552 use crate::worker_profile::ShellPolicy;
1553
1554 let build_surface = |verify_enabled: bool| {
1555 let tmp = tempdir().expect("tempdir");
1556 let ctx = ToolContext::new(tmp.path().to_path_buf());
1557 let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full);
1558 options.verify_tool_enabled = verify_enabled;
1559 ToolRegistryBuilder::new()
1560 .with_agent_runtime_surface(
1561 None,
1562 "test-model".to_string(),
1563 options,
1564 crate::tools::todo::new_shared_todo_list(),
1565 crate::tools::plan::new_shared_plan_state(),
1566 )
1567 .build(ctx)
1568 };
1569
1570 assert!(
1571 build_surface(true).contains("verify"),
1572 "verify should register when enabled"
1573 );
1574 assert!(
1575 !build_surface(false).contains("verify"),
1576 "verify should be absent when the opt-out disables it"
1577 );
1578 }
1579
1580 #[test]
1581 fn test_builder_with_agent_tools_policy_includes_finance() {
1582 let tmp = tempdir().expect("tempdir");
1583 let ctx = ToolContext::new(tmp.path().to_path_buf());
1584
1585 let registry = ToolRegistryBuilder::new()
1586 .with_agent_tools_policy(
1587 crate::worker_profile::ShellPolicy::None,
1588 crate::tools::user_input::UserInputLimits::default(),
1589 )
1590 .build(ctx);
1591
1592 assert!(registry.contains("finance"));
1593 }
1594
1595 #[test]
1596 fn agent_tools_with_shell_policy_none_excludes_shell_tools() {
1597 let tmp = tempdir().expect("tempdir");
1598 let ctx = ToolContext::new(tmp.path().to_path_buf());
1599
1600 let registry = ToolRegistryBuilder::new()
1601 .with_agent_tools_policy(
1602 crate::worker_profile::ShellPolicy::None,
1603 crate::tools::user_input::UserInputLimits::default(),
1604 )
1605 .build(ctx);
1606
1607 assert!(!registry.contains("bash"));
1608 assert!(!registry.contains("Bash"));
1609 assert!(
1610 !registry.contains("exec_shell"),
1611 "retired exec_shell must remain absent"
1612 );
1613 assert!(
1614 !registry.contains("task_shell_start"),
1615 "task_shell_start should be excluded when the shell policy is None"
1616 );
1617 assert!(
1618 !registry.contains("task_shell_wait"),
1619 "task_shell_wait should be excluded when the shell policy is None"
1620 );
1621 }
1622
1623 #[test]
1624 fn agent_tools_with_shell_policy_readonly_exposes_only_run_only_bash() {
1625 let tmp = tempdir().expect("tempdir");
1626 let ctx = ToolContext::new(tmp.path().to_path_buf());
1627
1628 let registry = ToolRegistryBuilder::new()
1629 .with_agent_tools_policy(
1630 crate::worker_profile::ShellPolicy::ReadOnly,
1631 crate::tools::user_input::UserInputLimits::default(),
1632 )
1633 .build(ctx);
1634
1635 assert!(registry.contains("bash"));
1636 assert!(registry.contains("Bash"));
1637 assert!(!registry.contains("exec_shell"));
1638 assert!(!registry.contains("task_shell_start"));
1639 assert!(!registry.contains("task_shell_wait"));
1640 assert!(
1641 registry
1642 .names()
1643 .into_iter()
1644 .all(|name| !name.starts_with("terminal/"))
1645 );
1646 let bash = registry
1647 .to_api_tools()
1648 .into_iter()
1649 .find(|tool| tool.name == "bash")
1650 .expect("read-only lowercase bash catalog");
1651 assert_eq!(bash.input_schema["required"], json!(["command"]));
1652 assert_eq!(
1653 bash.input_schema["properties"]
1654 .as_object()
1655 .expect("bash properties")
1656 .keys()
1657 .cloned()
1658 .collect::<std::collections::BTreeSet<_>>(),
1659 [
1660 "command",
1661 "justification",
1662 "read_only",
1663 "sandbox_permissions",
1664 "timeout"
1665 ]
1666 .into_iter()
1667 .map(str::to_string)
1668 .collect()
1669 );
1670 for hidden in ["action", "background", "tty", "stdin", "task_id", "wait"] {
1671 assert!(bash.input_schema["properties"].get(hidden).is_none());
1672 }
1673 assert!(
1674 registry
1675 .to_api_tools()
1676 .iter()
1677 .all(|tool| tool.name != "Bash")
1678 );
1679 }
1680
1681 #[test]
1682 fn machine_readonly_catalog_is_exactly_the_evidence_profile() {
1683 let tmp = tempdir().expect("tempdir");
1684 let registry = ToolRegistryBuilder::new()
1685 .with_agent_tools_policy(
1686 crate::worker_profile::ShellPolicy::ReadOnly,
1687 crate::tools::user_input::UserInputLimits::default(),
1688 )
1689 .with_web_tools()
1690 .with_todo_tool(crate::tools::todo::new_shared_todo_list())
1691 .build(readonly_scout_context(tmp.path(), true));
1692 let tools = registry.to_api_tools();
1693 let names = tools
1694 .iter()
1695 .map(|tool| tool.name.as_str())
1696 .collect::<Vec<_>>();
1697 assert_eq!(names, {
1698 let mut expected = vec![
1699 "Web",
1700 "bash",
1701 "diagnostics",
1702 "file_search",
1703 "finance",
1704 "grep_files",
1705 "handle_read",
1706 "list_dir",
1707 "load_skill",
1708 "lsp",
1709 "project_map",
1710 "read",
1711 "read_media",
1712 "request_user_input",
1713 "retrieve_tool_result",
1714 "todo_write",
1715 "tui_help",
1716 "validate_data",
1717 "web.run",
1718 ];
1719 if crate::tools::image_ocr::ocr_available() {
1720 expected.insert(7, "image_ocr");
1721 }
1722 expected
1723 });
1724 assert!(registry.contains("File"));
1725 assert!(registry.contains("Bash"));
1726 assert!(tools.iter().all(|tool| tool.name != "File"));
1727 assert!(tools.iter().all(|tool| tool.name != "Bash"));
1728 let shell = tools.iter().find(|tool| tool.name == "bash").unwrap();
1729 assert!(shell.description.contains("cwd field"));
1730 assert!(shell.description.contains("git log"));
1731 assert!(shell.description.contains("cannot change its own role"));
1732 let bash = registry.get("bash").unwrap();
1733 for command in [
1734 "git branch -a",
1735 "cd src && git status",
1736 "git rev-parse HEAD",
1737 ] {
1738 let error = enforce_tool_authority(
1739 "bash",
1740 &json!({"command":command}),
1741 bash.as_ref(),
1742 registry.context(),
1743 )
1744 .unwrap_err()
1745 .to_string();
1746 assert!(
1747 error.contains("cwd field") && error.contains("git log"),
1748 "{error}"
1749 );
1750 }
1751 let web = tools.iter().find(|tool| tool.name == "Web").unwrap();
1752 assert_eq!(
1753 web.input_schema["properties"]["action"]["enum"],
1754 json!(["search", "fetch"])
1755 );
1756 let lsp = registry
1757 .get("lsp")
1758 .expect("registered but catalog-hidden lsp");
1759 enforce_tool_authority("lsp", &json!({}), lsp.as_ref(), registry.context())
1760 .expect("machine read-only dispatch uses the same positive profile as the catalog");
1761 let offline = ToolRegistryBuilder::new()
1762 .with_web_tools()
1763 .build(readonly_scout_context(tmp.path(), false));
1764 assert!(
1765 offline
1766 .to_api_tools()
1767 .iter()
1768 .all(|tool| !matches!(tool.name.as_str(), "Web" | "web.run"))
1769 );
1770 }
1771
1772 #[test]
1773 fn agent_tools_with_shell_policy_full_includes_shell_tools() {
1774 let tmp = tempdir().expect("tempdir");
1775 let ctx = ToolContext::new(tmp.path().to_path_buf());
1776
1777 let registry = ToolRegistryBuilder::new()
1778 .with_agent_tools_policy(
1779 crate::worker_profile::ShellPolicy::Full,
1780 crate::tools::user_input::UserInputLimits::default(),
1781 )
1782 .build(ctx);
1783
1784 assert!(registry.contains("bash"));
1785 assert!(registry.contains("Bash"));
1786 assert!(!registry.contains("exec_shell"));
1787 assert!(
1788 registry.contains("task_shell_start"),
1789 "task_shell_start should be included when the shell policy is Full"
1790 );
1791 assert!(
1792 registry.contains("task_shell_wait"),
1793 "task_shell_wait should be included when the shell policy is Full"
1794 );
1795 let api_names = registry
1796 .to_api_tools()
1797 .into_iter()
1798 .map(|tool| tool.name)
1799 .collect::<Vec<_>>();
1800 assert!(api_names.iter().any(|name| name == "bash"));
1801 assert!(api_names.iter().all(|name| name != "Bash"));
1802 }
1803
1804 /// v0.9.3 removes the per-action shell aliases entirely.
1805 #[test]
1806 fn shell_surface_exposes_lowercase_bash_and_hides_legacy_handler() {
1807 let tmp = tempdir().expect("tempdir");
1808 let ctx = ToolContext::new(tmp.path().to_path_buf());
1809 let registry = ToolRegistryBuilder::new().with_shell_tools().build(ctx);
1810
1811 for alias in [
1812 "exec_shell",
1813 "exec_wait",
1814 "exec_interact",
1815 "exec_shell_wait",
1816 "exec_shell_interact",
1817 "exec_shell_cancel",
1818 ] {
1819 assert!(!registry.contains(alias), "{alias} must be removed");
1820 }
1821
1822 let api_names: Vec<String> = registry
1823 .to_api_tools()
1824 .into_iter()
1825 .map(|tool| tool.name)
1826 .collect();
1827
1828 assert!(registry.contains("bash"));
1829 assert!(registry.contains("Bash"));
1830
1831 // Only lowercase bash is model-visible.
1832 assert!(
1833 api_names.iter().any(|n| n == "bash"),
1834 "bash should be model-visible"
1835 );
1836 assert!(api_names.iter().all(|n| n != "Bash"));
1837
1838 // Removed names also cannot leak back into the model catalog.
1839 for alias in [
1840 "exec_shell",
1841 "exec_wait",
1842 "exec_interact",
1843 "exec_shell_wait",
1844 "exec_shell_interact",
1845 "exec_shell_cancel",
1846 ] {
1847 assert!(
1848 api_names.iter().all(|n| n != alias),
1849 "{alias} should be hidden from the model catalog"
1850 );
1851 }
1852 }
1853
1854 /// Each durable-work family exposes one canonical action tool; v0.9.3
1855 /// removes the per-action execution aliases.
1856 #[test]
1857 fn runtime_task_families_expose_only_canonical_tools() {
1858 let tmp = tempdir().expect("tempdir");
1859 let ctx = ToolContext::new(tmp.path().to_path_buf());
1860 let registry = ToolRegistryBuilder::new()
1861 .with_runtime_task_tools()
1862 .build(ctx);
1863
1864 let legacy_aliases = [
1865 "task_create",
1866 "task_list",
1867 "task_read",
1868 "task_cancel",
1869 "task_gate_run",
1870 "pr_attempt_record",
1871 "pr_attempt_list",
1872 "pr_attempt_read",
1873 "pr_attempt_preflight",
1874 "github_issue_context",
1875 "github_pr_context",
1876 "github_comment",
1877 "github_close_issue",
1878 "github_close_pr",
1879 "automation_create",
1880 "automation_list",
1881 "automation_read",
1882 "automation_update",
1883 "automation_pause",
1884 "automation_resume",
1885 "automation_delete",
1886 "automation_run",
1887 ];
1888 for alias in legacy_aliases {
1889 assert!(!registry.contains(alias), "{alias} must be removed");
1890 }
1891
1892 let api_names: Vec<String> = registry
1893 .to_api_tools()
1894 .into_iter()
1895 .map(|tool| tool.name)
1896 .collect();
1897
1898 // Only the canonical tools are model-visible.
1899 for canonical in ["tasks", "github", "automation"] {
1900 assert!(
1901 api_names.iter().any(|n| n == canonical),
1902 "{canonical} should be model-visible"
1903 );
1904 }
1905 // Removed aliases also cannot leak back into the model catalog.
1906 for alias in legacy_aliases {
1907 assert!(
1908 api_names.iter().all(|n| n != alias),
1909 "{alias} should be hidden from the model catalog"
1910 );
1911 }
1912 }
1913
1914 /// The Plan-mode read-only surface registers only the canonical families,
1915 /// restricted to their read actions.
1916 #[test]
1917 fn read_only_task_surface_contains_no_per_action_aliases() {
1918 let tmp = tempdir().expect("tempdir");
1919 let ctx = ToolContext::new(tmp.path().to_path_buf());
1920 let registry = ToolRegistryBuilder::new()
1921 .with_runtime_read_only_task_tools()
1922 .build(ctx);
1923
1924 for name in [
1925 "task_list",
1926 "task_read",
1927 "pr_attempt_list",
1928 "pr_attempt_read",
1929 "github_issue_context",
1930 "github_pr_context",
1931 "automation_list",
1932 "automation_read",
1933 "task_create",
1934 "task_cancel",
1935 "task_gate_run",
1936 "pr_attempt_record",
1937 "pr_attempt_preflight",
1938 "github_comment",
1939 "github_close_issue",
1940 "github_close_pr",
1941 "automation_create",
1942 "automation_update",
1943 "automation_pause",
1944 "automation_resume",
1945 "automation_delete",
1946 "automation_run",
1947 ] {
1948 assert!(!registry.contains(name), "{name} must be removed");
1949 }
1950
1951 let api_names: Vec<String> = registry
1952 .to_api_tools()
1953 .into_iter()
1954 .map(|tool| tool.name)
1955 .collect();
1956 assert_eq!(api_names.len(), 4);
1957 for canonical in ["tasks", "github", "automation", "send_later"] {
1958 assert!(
1959 api_names.iter().any(|n| n == canonical),
1960 "{canonical} should be model-visible on the read-only surface"
1961 );
1962 }
1963 // Every registered tool stays read-only (Plan-mode invariant).
1964 for tool in registry.all() {
1965 let caps = tool.capabilities();
1966 assert!(
1967 !caps.contains(&ToolCapability::WritesFiles)
1968 && !caps.contains(&ToolCapability::ExecutesCode),
1969 "read-only surface must not register write/exec tools: {}",
1970 tool.name()
1971 );
1972 }
1973 }
1974
1975 /// The action-shaped RLM family is registered only for compatibility.
1976 #[test]
1977 fn rlm_family_removes_legacy_aliases() {
1978 let tmp = tempdir().expect("tempdir");
1979 let ctx = ToolContext::new(tmp.path().to_path_buf());
1980 let registry = ToolRegistryBuilder::new()
1981 .with_rlm_tool(None, "deepseek-v4-pro".to_string())
1982 .build(ctx);
1983
1984 for alias in [
1985 "rlm_session_objects",
1986 "rlm_open",
1987 "rlm_eval",
1988 "rlm_configure",
1989 "rlm_close",
1990 ] {
1991 assert!(!registry.contains(alias), "{alias} must stay removed");
1992 }
1993
1994 let api_names: Vec<String> = registry
1995 .to_api_tools()
1996 .into_iter()
1997 .map(|tool| tool.name)
1998 .collect();
1999 assert!(
2000 api_names.iter().all(|n| n != "rlm"),
2001 "the compatibility RLM surface must not be advertised to new model turns"
2002 );
2003 for retired in [
2004 "rlm_session_objects",
2005 "rlm_open",
2006 "rlm_eval",
2007 "rlm_configure",
2008 "rlm_close",
2009 ] {
2010 assert!(
2011 api_names.iter().all(|n| n != retired),
2012 "{retired} must not be advertised"
2013 );
2014 }
2015 }
2016
2017 #[test]
2018 fn a_builder_upgrade_replaces_the_tool_instead_of_registering_it_twice() {
2019 // `with_patch_tools` swaps the default `File` for the patch-capable one;
2020 // that used to reach `register` as a second `File` and warn on every
2021 // registry rebuild (#5934).
2022 let builder = ToolRegistryBuilder::new()
2023 .with_file_tools()
2024 .with_patch_tools();
2025 let file_tools = builder
2026 .tools
2027 .iter()
2028 .filter(|tool| tool.name() == "File")
2029 .count();
2030 assert_eq!(file_tools, 1, "one File tool after the upgrade");
2031 assert!(
2032 builder
2033 .tools
2034 .iter()
2035 .any(|tool| tool.name() == "apply_patch"),
2036 "the upgrade still adds apply_patch"
2037 );
2038 let tmp = tempdir().unwrap();
2039 let warnings = capture_registration_warnings(|| {
2040 let registry = builder.build(ToolContext::new(tmp.path()));
2041 assert!(registry.contains("File"));
2042 assert!(registry.contains("apply_patch"));
2043 });
2044 assert!(warnings.is_empty(), "normal File composition: {warnings}");
2045 }
2046
2047 fn capture_registration_warnings(action: impl FnOnce()) -> String {
2048 use std::io::{Read, Seek, SeekFrom};
2049 let mut output = tempfile::tempfile().unwrap();
2050 let subscriber = tracing_subscriber::fmt()
2051 .without_time()
2052 .with_ansi(false)
2053 .with_max_level(tracing::Level::WARN)
2054 .with_writer(std::sync::Mutex::new(output.try_clone().unwrap()))
2055 .finish();
2056 tracing::subscriber::with_default(subscriber, action);
2057 output.seek(SeekFrom::Start(0)).unwrap();
2058 let mut warnings = String::new();
2059 output.read_to_string(&mut warnings).unwrap();
2060 warnings
2061 }
2062
2063 #[test]
2064 fn registration_collisions_name_both_origins_and_preserve_replacement() {
2065 use crate::safe_label::SafeLabel;
2066 use crate::tools::file_tool::FileTool;
2067 let tmp = tempdir().unwrap();
2068 let mut registry = ToolRegistryBuilder::new()
2069 .with_file_tools()
2070 .build(ToolContext::new(tmp.path()));
2071 let mut previous_origin = std::any::type_name::<FileTool>().to_string();
2072 for source in ["first", "second"] {
2073 // Same basename and registered name, different actual plugin origins.
2074 let directory = tmp.path().join(source);
2075 std::fs::create_dir(&directory).unwrap();
2076 let path = directory.join("tool.sh");
2077 std::fs::write(&path, format!("# name: File\n# description: {source}\n")).unwrap();
2078 let _before = registry.to_api_tools();
2079 let warnings = capture_registration_warnings(|| registry.load_plugins(&directory));
2080 let replacement_origin = format!(
2081 "plugin script tool.sh ({})",
2082 SafeLabel::identifier(&path.to_string_lossy())
2083 );
2084 assert_eq!(warnings.lines().count(), 1, "{warnings}");
2085 assert!(
2086 warnings.contains("Overwriting existing tool: File"),
2087 "{warnings}"
2088 );
2089 assert!(
2090 warnings.contains(&format!("previous_origin={previous_origin:?}")),
2091 "{warnings}"
2092 );
2093 assert!(
2094 warnings.contains(&format!("replacement_origin={replacement_origin:?}")),
2095 "{warnings}"
2096 );
2097 assert!(!warnings.contains(&tmp.path().to_string_lossy().to_string()));
2098 let installed = registry.get("File").unwrap();
2099 assert_eq!(installed.description(), source);
2100 assert!(
2101 installed
2102 .capabilities()
2103 .contains(&ToolCapability::RequiresApproval)
2104 );
2105 assert_eq!(
2106 registry
2107 .to_api_tools()
2108 .iter()
2109 .find(|tool| tool.name == "File")
2110 .unwrap()
2111 .description,
2112 source,
2113 "replacement still invalidates the catalog cache"
2114 );
2115 previous_origin = replacement_origin;
2116 }
2117 }
2118
2119 #[test]
2120 fn registration_adapter_origins_are_bounded_and_exclude_execution_payloads() {
2121 use crate::tools::dynamic::RuntimeDynamicTool;
2122 use crate::tools::plugin::tool_from_override;
2123 use codewhale_protocol::runtime::DynamicToolSpec;
2124 let tmp = tempdir().unwrap();
2125 let hostile = format!(
2126 "\u{1b}[31m\nhttps://private.invalid/token?{}",
2127 "x".repeat(500)
2128 );
2129 let command = "do-not-log-command";
2130 let argument = "do-not-log-argument";
2131 let schema_payload = "do-not-log-schema";
2132 let cases: Vec<(Arc<dyn ToolSpec>, &str)> = vec![
2133 (
2134 Arc::new(RuntimeDynamicTool::new(DynamicToolSpec {
2135 name: hostile.clone(),
2136 namespace: Some(hostile.clone()),
2137 description: command.into(),
2138 input_schema: json!({"description":schema_payload}),
2139 defer_loading: false,
2140 })),
2141 "runtime dynamic namespace sha256:",
2142 ),
2143 (
2144 Arc::new(super::McpToolAdapter {
2145 name: hostile.clone(),
2146 server_name: Some("plugin-4-demo-server_with_underscores".into()),
2147 tool: crate::mcp::McpTool {
2148 name: hostile.clone(),
2149 description: Some(command.into()),
2150 input_schema: json!({"description":schema_payload}),
2151 },
2152 pool: Arc::new(tokio::sync::Mutex::new(crate::mcp::McpPool::new(
2153 crate::mcp::McpConfig::default(),
2154 ))),
2155 }),
2156 "MCP server plugin-4-demo-server_with_underscores, tool sha256:",
2157 ),
2158 (
2159 tool_from_override(
2160 &hostile,
2161 &ToolOverride::Command {
2162 command: command.into(),
2163 args: Some(vec![argument.into()]),
2164 },
2165 tmp.path(),
2166 )
2167 .unwrap(),
2168 "config [tools.overrides.sha256:",
2169 ),
2170 ];
2171 for (replacement, expected_origin) in cases {
2172 let mut registry = ToolRegistry::new(ToolContext::new(tmp.path()));
2173 registry.register(make_test_tool(&hostile));
2174 let warnings = capture_registration_warnings(|| registry.register(replacement.clone()));
2175 assert_eq!(warnings.lines().count(), 1, "{warnings}");
2176 assert!(
2177 warnings.contains("Overwriting existing tool: sha256:"),
2178 "{warnings}"
2179 );
2180 assert!(warnings.contains(expected_origin), "{warnings}");
2181 assert!(warnings.len() < 600, "{warnings}");
2182 for excluded in [
2183 &hostile,
2184 command,
2185 argument,
2186 schema_payload,
2187 "https://private.invalid",
2188 "\u{1b}",
2189 ] {
2190 assert!(
2191 !warnings.contains(excluded),
2192 "unexpected payload: {warnings}"
2193 );
2194 }
2195 assert!(Arc::ptr_eq(&registry.get(&hostile).unwrap(), &replacement));
2196 }
2197 }
2198
2199 /// Regression probe for the fleet-52663788 class of provider 400
2200 /// (`Invalid schema for function 'bash': null is not of type "array"`):
2201 /// a read-only Fleet worker (reviewer) projects its tool schemas before the
2202 /// wire; no projected schema may carry a JSON null, because strict
2203 /// OpenAI-compatible validators reject null where arrays/objects are typed.
2204 #[test]
2205 fn fleet_readonly_reviewer_wire_catalog_carries_no_null_schema_fields() {
2206 use crate::tools::spec::{
2207 ToolMutationAuthority, ToolShellAuthority, ToolVerificationAuthority,
2208 };
2209
2210 fn collect_null_paths(value: &Value, path: String, out: &mut Vec<String>) {
2211 match value {
2212 Value::Null => out.push(path),
2213 Value::Object(map) => {
2214 for (key, child) in map {
2215 collect_null_paths(child, format!("{path}.{key}"), out);
2216 }
2217 }
2218 Value::Array(items) => {
2219 for (index, child) in items.iter().enumerate() {
2220 collect_null_paths(child, format!("{path}[{index}]"), out);
2221 }
2222 }
2223 _ => {}
2224 }
2225 }
2226
2227 let tmp = tempdir().expect("tempdir");
2228 let reviewer_authority = ToolAuthorityEnvelope {
2229 schema_version: 1,
2230 owner: "reviewer".to_string(),
2231 authority: ToolMutationAuthority::ReadOnly,
2232 network_access: Some(false),
2233 shell: ToolShellAuthority::ReadOnly,
2234 verification: ToolVerificationAuthority::None,
2235 writable_roots: Vec::new(),
2236 writable_files: Vec::new(),
2237 coordination_contracts: Vec::new(),
2238 };
2239 let context = ToolContext::new(tmp.path().to_path_buf())
2240 .with_tool_authority(reviewer_authority)
2241 .expect("reviewer authority");
2242
2243 let registry = ToolRegistryBuilder::new()
2244 .with_file_tools()
2245 .with_foreground_shell_tools()
2246 .with_search_tools()
2247 .build(context);
2248 let tools = registry.to_api_tools();
2249 assert!(
2250 tools.iter().any(|tool| tool.name == "bash"),
2251 "reviewer keeps classifier-bounded bash"
2252 );
2253
2254 for tool in &tools {
2255 let mut nulls = Vec::new();
2256 collect_null_paths(&tool.input_schema, "$".to_string(), &mut nulls);
2257 assert!(
2258 nulls.is_empty(),
2259 "tool {} schema carries null at {nulls:?}: {}",
2260 tool.name,
2261 tool.input_schema
2262 );
2263 }
2264 }
2265
2265 lines RUST