1use crate::admission::{AdmissionGate, GateContext, GateOutcome};
18use car_ir::ActionProposal;
19use car_verify::infoflow::{
20 check_information_flow, gate_flow, Confidentiality, FlowAction, FlowGatePolicy, FlowPolicy,
21 ToolLabels, TrustLevel,
22};
23use serde::{Deserialize, Serialize};
24use std::collections::{HashMap, HashSet};
25use std::fmt;
26use std::path::Path;
27
28#[derive(Debug, Clone, Default, Deserialize, Serialize)]
33pub struct ToolLabelConfig {
34 #[serde(default)]
36 pub labels: HashMap<String, ToolLabels>,
37 #[serde(default)]
40 pub flow_policy: FlowPolicy,
41 #[serde(default)]
43 pub gate_policy: FlowGatePolicy,
44}
45
46pub const NET_SEND: &str = "net_send";
48
49pub fn tool_output_is_external(tool: &str, labels: &HashMap<String, ToolLabels>) -> bool {
74 labels.get(tool).is_some_and(|l| {
75 l.capability.as_deref() == Some(NET_SEND) || l.trust == TrustLevel::Untrusted
76 })
77}
78
79pub fn builtin_tool_labels() -> HashMap<String, ToolLabels> {
88 fn sink(capability: &str) -> ToolLabels {
89 ToolLabels {
90 capability: Some(capability.to_string()),
91 sink: true,
92 ..Default::default()
93 }
94 }
95 fn cap(capability: &str) -> ToolLabels {
96 ToolLabels {
97 capability: Some(capability.to_string()),
98 ..Default::default()
99 }
100 }
101 fn source(capability: &str, confidentiality: Confidentiality) -> ToolLabels {
102 ToolLabels {
103 capability: Some(capability.to_string()),
104 confidentiality,
105 ..Default::default()
106 }
107 }
108 let mut m = HashMap::new();
109 m.insert("http_request".to_string(), sink("net_send"));
111 m.insert("web_search".to_string(), sink("net_send"));
112 m.insert("browser".to_string(), sink("net_send"));
117 for tool in [
118 "browse_navigate",
119 "browse_click",
120 "browse_type",
121 "browse_scroll",
122 "browse_keypress",
123 "browse_wait",
124 "browse_observe",
125 "browser_await_answer",
126 "browser_await_signin",
127 "browser_record_start",
128 "browser_record_stop",
129 ] {
130 m.insert(tool.to_string(), sink("net_send"));
131 }
132 m.insert("search".to_string(), sink("net_send"));
133 m.insert("generate_music".to_string(), sink("net_send"));
135 m.insert("generate_jingle".to_string(), sink("net_send"));
136 m.insert("generate_studio_image".to_string(), sink("net_send"));
137 m.insert("generate_song".to_string(), sink("net_send"));
138 m.insert("run_applescript".to_string(), sink("host_automation"));
140 m.insert("remember".to_string(), sink("persistent_memory"));
142 m.insert(
143 "recall".to_string(),
144 source(
145 "persistent_memory_read",
146 car_verify::infoflow::Confidentiality::Internal,
147 ),
148 );
149 m.insert(
152 "read_image_text".to_string(),
153 source(
154 "vision_read",
155 car_verify::infoflow::Confidentiality::Internal,
156 ),
157 );
158 m.insert(
159 "classify_image".to_string(),
160 source(
161 "vision_read",
162 car_verify::infoflow::Confidentiality::Internal,
163 ),
164 );
165 m.insert("read_file".to_string(), cap("fs_read"));
167 m.insert("write_file".to_string(), cap("fs_write"));
168 m.insert("edit_file".to_string(), cap("fs_write"));
169 m.insert("list_dir".to_string(), cap("fs_read"));
170 m.insert("find_files".to_string(), cap("fs_read"));
171 m.insert("grep_files".to_string(), cap("fs_read"));
172 m.insert("shell".to_string(), cap("process_exec"));
173 m
174}
175
176fn merge_builtin_label(builtin: ToolLabels, project: ToolLabels) -> ToolLabels {
177 let capability = builtin.capability.clone().or(project.capability);
178 let confidentiality = std::cmp::max(builtin.confidentiality, project.confidentiality);
179 let trust = if matches!(builtin.trust, TrustLevel::Untrusted)
180 || matches!(project.trust, TrustLevel::Untrusted)
181 {
182 TrustLevel::Untrusted
183 } else {
184 TrustLevel::Trusted
185 };
186
187 ToolLabels {
188 capability,
189 confidentiality,
190 trust,
191 sink: builtin.sink || project.sink,
192 declassifier: builtin.declassifier,
193 }
194}
195
196fn merge_flow_policy(builtin: FlowPolicy, project: FlowPolicy) -> FlowPolicy {
197 let mut forbidden_sequences = builtin.forbidden_sequences;
198 forbidden_sequences.extend(project.forbidden_sequences);
199 forbidden_sequences.sort();
200 forbidden_sequences.dedup();
201
202 FlowPolicy {
203 min_confidential: std::cmp::min(builtin.min_confidential, project.min_confidential),
204 forbidden_sequences,
205 }
206}
207
208fn merge_gate_policy(builtin: FlowGatePolicy, project: FlowGatePolicy) -> FlowGatePolicy {
209 FlowGatePolicy {
210 on_sensitive_to_sink: std::cmp::max(
211 builtin.on_sensitive_to_sink,
212 project.on_sensitive_to_sink,
213 ),
214 on_forbidden_sequence: std::cmp::max(
215 builtin.on_forbidden_sequence,
216 project.on_forbidden_sequence,
217 ),
218 }
219}
220
221pub fn load_tool_labels(car_dir: impl AsRef<Path>) -> Result<ToolLabelConfig, FlowLoadError> {
231 let mut config = ToolLabelConfig {
232 labels: builtin_tool_labels(),
233 flow_policy: FlowPolicy::default(),
234 gate_policy: FlowGatePolicy::default(),
235 };
236 let path = car_dir.as_ref().join("tool-labels.json");
237 if !path.exists() {
238 return Ok(config);
239 }
240 let src = std::fs::read_to_string(&path).map_err(|e| FlowLoadError {
241 message: format!("reading {}: {e}", path.display()),
242 })?;
243 let file: ToolLabelConfig = serde_json::from_str(&src).map_err(|e| FlowLoadError {
244 message: format!("parsing {}: {e}", path.display()),
245 })?;
246 for (name, project_label) in file.labels {
247 match config.labels.remove(&name) {
248 Some(builtin_label) => {
249 config
250 .labels
251 .insert(name, merge_builtin_label(builtin_label, project_label));
252 }
253 None => {
254 config.labels.insert(name, project_label);
255 }
256 }
257 }
258 config.flow_policy = merge_flow_policy(config.flow_policy, file.flow_policy);
259 config.gate_policy = merge_gate_policy(config.gate_policy, file.gate_policy);
260 Ok(config)
261}
262
263pub struct InformationFlowGate {
267 labels: HashMap<String, ToolLabels>,
268 flow_policy: FlowPolicy,
269 gate_policy: FlowGatePolicy,
270}
271
272impl InformationFlowGate {
273 pub fn new(config: ToolLabelConfig) -> Self {
275 Self {
276 labels: config.labels,
277 flow_policy: config.flow_policy,
278 gate_policy: config.gate_policy,
279 }
280 }
281
282 pub fn with_builtin_labels() -> Self {
284 Self::new(ToolLabelConfig {
285 labels: builtin_tool_labels(),
286 ..Default::default()
287 })
288 }
289}
290
291#[async_trait::async_trait]
292impl AdmissionGate for InformationFlowGate {
293 fn name(&self) -> &str {
294 "information_flow"
295 }
296
297 async fn check(&self, proposal: &ActionProposal, _ctx: &GateContext<'_>) -> GateOutcome {
298 let report = check_information_flow(proposal, &self.labels, &self.flow_policy);
299 if report.safe {
300 return GateOutcome::Allow;
301 }
302 let decision = gate_flow(&report, &self.gate_policy);
303 match decision.action {
304 FlowAction::Allow => GateOutcome::Allow,
305 FlowAction::Block => {
306 let blocked: HashSet<String> = decision
307 .blocked
308 .iter()
309 .flat_map(|v| v.actions.iter().cloned())
310 .collect();
311 GateOutcome::Reject {
312 blocked,
313 reason: decision.reason,
314 }
315 }
316 FlowAction::RequireApproval => {
317 let actions: HashSet<String> = decision
318 .needs_approval
319 .iter()
320 .flat_map(|v| v.actions.iter().cloned())
321 .collect();
322 let mut fps: Vec<String> = decision
330 .needs_approval
331 .iter()
332 .map(car_policy::flow_fingerprint)
333 .collect();
334 fps.sort();
335 fps.dedup();
336 let fingerprint = if fps.is_empty() {
337 "flow:unknown".to_string()
338 } else {
339 use sha2::Digest;
340 let canonical = serde_json::to_string(&fps).unwrap_or_default();
341 format!(
342 "flow:sha256:{:x}",
343 sha2::Sha256::digest(canonical.as_bytes())
344 )
345 };
346 GateOutcome::NeedsApproval {
347 actions,
348 fingerprint,
349 reason: decision.reason,
350 }
351 }
352 }
353 }
354}
355
356#[derive(Debug, Clone)]
358pub struct FlowLoadError {
359 pub message: String,
360}
361
362impl fmt::Display for FlowLoadError {
363 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364 write!(f, "tool-labels load error: {}", self.message)
365 }
366}
367
368impl std::error::Error for FlowLoadError {}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use car_ir::{Action, ActionType};
374
375 fn tool_action(id: &str, tool: &str) -> Action {
376 {
377 let mut a = Action::new(ActionType::ToolCall);
378 a.id = id.to_string();
379 a.tool = Some(tool.to_string());
380 a.max_retries = 0;
381 a
382 }
383 }
384
385 fn proposal(actions: Vec<Action>) -> ActionProposal {
386 ActionProposal {
387 id: "p".to_string(),
388 source: "test".to_string(),
389 actions,
390 timestamp: chrono::Utc::now(),
391 context: HashMap::new(),
392 }
393 }
394
395 #[test]
396 fn builtins_mark_network_tools_as_sinks() {
397 let labels = builtin_tool_labels();
398 assert!(labels.get("http_request").unwrap().sink);
399 assert!(labels.get("web_search").unwrap().sink);
400 assert!(labels.get("generate_music").unwrap().sink);
401 assert!(labels.get("generate_jingle").unwrap().sink);
402 assert!(labels.get("generate_studio_image").unwrap().sink);
403 assert!(labels.get("generate_song").unwrap().sink);
404 assert!(labels.get("run_applescript").unwrap().sink);
405 assert!(labels.get("remember").unwrap().sink);
406 assert!(labels.get("browser").unwrap().sink);
407 assert!(!labels.get("read_file").unwrap().sink);
408 }
409
410 #[test]
411 fn builtins_mark_recall_as_internal_source() {
412 let labels = builtin_tool_labels();
413 let recall = labels.get("recall").unwrap();
414 assert_eq!(
415 recall.confidentiality,
416 car_verify::infoflow::Confidentiality::Internal
417 );
418 assert!(!recall.sink);
419 }
420
421 #[test]
422 fn builtins_mark_vision_tools_as_internal_sources() {
423 let labels = builtin_tool_labels();
424 for name in ["read_image_text", "classify_image"] {
425 let label = labels.get(name).unwrap();
426 assert_eq!(
427 label.confidentiality,
428 car_verify::infoflow::Confidentiality::Internal,
429 "{name} should carry internal taint"
430 );
431 assert!(!label.sink, "{name} is a source, not a sink");
432 }
433 }
434
435 #[tokio::test]
436 async fn clean_proposal_is_allowed() {
437 let gate = InformationFlowGate::with_builtin_labels();
438 let ctx_state = HashMap::new();
439 let ctx_versions = HashMap::new();
440 let ctx = GateContext {
441 session_id: None,
442 scope: None,
443 state: &ctx_state,
444 versions: &ctx_versions,
445 };
446 let p = proposal(vec![
449 tool_action("a1", "read_file"),
450 tool_action("a2", "http_request"),
451 ]);
452 assert!(matches!(gate.check(&p, &ctx).await, GateOutcome::Allow));
453 }
454
455 #[tokio::test]
456 async fn confidential_to_sink_is_blocked() {
457 let mut labels = builtin_tool_labels();
460 labels.insert(
461 "read_secret".to_string(),
462 ToolLabels {
463 capability: Some("fs_read".to_string()),
464 confidentiality: car_verify::infoflow::Confidentiality::Secret,
465 ..Default::default()
466 },
467 );
468 let gate = InformationFlowGate::new(ToolLabelConfig {
469 labels,
470 ..Default::default()
471 });
472
473 let mut a1 = tool_action("a1", "read_secret");
475 a1.expected_effects = [("data".to_string(), serde_json::Value::from(1))].into();
476 let mut a2 = tool_action("a2", "http_request");
477 a2.state_dependencies = vec!["data".to_string()];
478
479 let ctx_state = HashMap::new();
480 let ctx_versions = HashMap::new();
481 let ctx = GateContext {
482 session_id: None,
483 scope: None,
484 state: &ctx_state,
485 versions: &ctx_versions,
486 };
487 let outcome = gate.check(&proposal(vec![a1, a2]), &ctx).await;
488 assert!(
489 matches!(outcome, GateOutcome::Reject { .. }),
490 "secret reaching a sink must be blocked, got {outcome:?}"
491 );
492 }
493
494 #[tokio::test]
495 async fn confidential_to_web_search_is_blocked() {
496 let mut labels = builtin_tool_labels();
497 labels.insert(
498 "read_secret".to_string(),
499 ToolLabels {
500 capability: Some("fs_read".to_string()),
501 confidentiality: car_verify::infoflow::Confidentiality::Secret,
502 ..Default::default()
503 },
504 );
505 let gate = InformationFlowGate::new(ToolLabelConfig {
506 labels,
507 ..Default::default()
508 });
509
510 let mut a1 = tool_action("a1", "read_secret");
511 a1.expected_effects = [("query".to_string(), serde_json::Value::from(1))].into();
512 let mut a2 = tool_action("a2", "web_search");
513 a2.state_dependencies = vec!["query".to_string()];
514
515 let ctx_state = HashMap::new();
516 let ctx_versions = HashMap::new();
517 let ctx = GateContext {
518 session_id: None,
519 scope: None,
520 state: &ctx_state,
521 versions: &ctx_versions,
522 };
523 let outcome = gate.check(&proposal(vec![a1, a2]), &ctx).await;
524 assert!(
525 matches!(outcome, GateOutcome::Reject { .. }),
526 "secret reaching web_search must be blocked, got {outcome:?}"
527 );
528 }
529
530 #[tokio::test]
531 async fn recalled_memory_to_web_search_is_blocked_by_default() {
532 let gate = InformationFlowGate::with_builtin_labels();
533 let mut a1 = tool_action("a1", "recall");
534 a1.expected_effects = [("memory_context".to_string(), serde_json::Value::from(1))].into();
535 let mut a2 = tool_action("a2", "web_search");
536 a2.state_dependencies = vec!["memory_context".to_string()];
537
538 let ctx_state = HashMap::new();
539 let ctx_versions = HashMap::new();
540 let ctx = GateContext {
541 session_id: None,
542 scope: None,
543 state: &ctx_state,
544 versions: &ctx_versions,
545 };
546 let outcome = gate.check(&proposal(vec![a1, a2]), &ctx).await;
547 assert!(
548 matches!(outcome, GateOutcome::Reject { .. }),
549 "recalled durable memory reaching web_search must be blocked, got {outcome:?}"
550 );
551 }
552
553 #[tokio::test]
554 async fn image_text_to_web_search_is_blocked_by_default() {
555 let gate = InformationFlowGate::with_builtin_labels();
556 let mut a1 = tool_action("a1", "read_image_text");
557 a1.expected_effects = [("ocr_text".to_string(), serde_json::Value::from(1))].into();
558 let mut a2 = tool_action("a2", "web_search");
559 a2.state_dependencies = vec!["ocr_text".to_string()];
560
561 let ctx_state = HashMap::new();
562 let ctx_versions = HashMap::new();
563 let ctx = GateContext {
564 session_id: None,
565 scope: None,
566 state: &ctx_state,
567 versions: &ctx_versions,
568 };
569 let outcome = gate.check(&proposal(vec![a1, a2]), &ctx).await;
570 assert!(
571 matches!(outcome, GateOutcome::Reject { .. }),
572 "OCR output reaching web_search must be blocked, got {outcome:?}"
573 );
574 }
575
576 #[tokio::test]
577 async fn image_classification_to_persistent_memory_is_blocked_by_default() {
578 let gate = InformationFlowGate::with_builtin_labels();
579 let mut a1 = tool_action("a1", "classify_image");
580 a1.expected_effects = [("image_labels".to_string(), serde_json::Value::from(1))].into();
581 let mut a2 = tool_action("a2", "remember");
582 a2.state_dependencies = vec!["image_labels".to_string()];
583
584 let ctx_state = HashMap::new();
585 let ctx_versions = HashMap::new();
586 let ctx = GateContext {
587 session_id: None,
588 scope: None,
589 state: &ctx_state,
590 versions: &ctx_versions,
591 };
592 let outcome = gate.check(&proposal(vec![a1, a2]), &ctx).await;
593 assert!(
594 matches!(outcome, GateOutcome::Reject { .. }),
595 "image classification output reaching persistent memory must be blocked, got {outcome:?}"
596 );
597 }
598
599 #[tokio::test]
600 async fn confidential_to_studio_generator_is_blocked() {
601 let mut labels = builtin_tool_labels();
602 labels.insert(
603 "read_secret".to_string(),
604 ToolLabels {
605 capability: Some("fs_read".to_string()),
606 confidentiality: car_verify::infoflow::Confidentiality::Secret,
607 ..Default::default()
608 },
609 );
610 let gate = InformationFlowGate::new(ToolLabelConfig {
611 labels,
612 ..Default::default()
613 });
614
615 let mut a1 = tool_action("a1", "read_secret");
616 a1.expected_effects = [("prompt".to_string(), serde_json::Value::from(1))].into();
617 let mut a2 = tool_action("a2", "generate_studio_image");
618 a2.state_dependencies = vec!["prompt".to_string()];
619
620 let ctx_state = HashMap::new();
621 let ctx_versions = HashMap::new();
622 let ctx = GateContext {
623 session_id: None,
624 scope: None,
625 state: &ctx_state,
626 versions: &ctx_versions,
627 };
628 let outcome = gate.check(&proposal(vec![a1, a2]), &ctx).await;
629 assert!(
630 matches!(outcome, GateOutcome::Reject { .. }),
631 "secret reaching a Studio generator must be blocked, got {outcome:?}"
632 );
633 }
634
635 #[tokio::test]
636 async fn confidential_to_host_automation_is_blocked() {
637 let mut labels = builtin_tool_labels();
638 labels.insert(
639 "read_secret".to_string(),
640 ToolLabels {
641 capability: Some("fs_read".to_string()),
642 confidentiality: car_verify::infoflow::Confidentiality::Secret,
643 ..Default::default()
644 },
645 );
646 let gate = InformationFlowGate::new(ToolLabelConfig {
647 labels,
648 ..Default::default()
649 });
650
651 let mut a1 = tool_action("a1", "read_secret");
652 a1.expected_effects = [("script".to_string(), serde_json::Value::from(1))].into();
653 let mut a2 = tool_action("a2", "run_applescript");
654 a2.state_dependencies = vec!["script".to_string()];
655
656 let ctx_state = HashMap::new();
657 let ctx_versions = HashMap::new();
658 let ctx = GateContext {
659 session_id: None,
660 scope: None,
661 state: &ctx_state,
662 versions: &ctx_versions,
663 };
664 let outcome = gate.check(&proposal(vec![a1, a2]), &ctx).await;
665 assert!(
666 matches!(outcome, GateOutcome::Reject { .. }),
667 "secret reaching host automation must be blocked, got {outcome:?}"
668 );
669 }
670
671 #[tokio::test]
672 async fn confidential_to_persistent_memory_is_blocked() {
673 let mut labels = builtin_tool_labels();
674 labels.insert(
675 "read_secret".to_string(),
676 ToolLabels {
677 capability: Some("fs_read".to_string()),
678 confidentiality: car_verify::infoflow::Confidentiality::Secret,
679 ..Default::default()
680 },
681 );
682 let gate = InformationFlowGate::new(ToolLabelConfig {
683 labels,
684 ..Default::default()
685 });
686
687 let mut a1 = tool_action("a1", "read_secret");
688 a1.expected_effects = [("fact".to_string(), serde_json::Value::from(1))].into();
689 let mut a2 = tool_action("a2", "remember");
690 a2.state_dependencies = vec!["fact".to_string()];
691
692 let ctx_state = HashMap::new();
693 let ctx_versions = HashMap::new();
694 let ctx = GateContext {
695 session_id: None,
696 scope: None,
697 state: &ctx_state,
698 versions: &ctx_versions,
699 };
700 let outcome = gate.check(&proposal(vec![a1, a2]), &ctx).await;
701 assert!(
702 matches!(outcome, GateOutcome::Reject { .. }),
703 "secret reaching persistent memory must be blocked, got {outcome:?}"
704 );
705 }
706
707 #[test]
708 fn load_missing_file_returns_builtins() {
709 let cfg = load_tool_labels("/nonexistent/.car").unwrap();
710 assert!(cfg.labels.contains_key("http_request"));
711 assert!(cfg.labels.contains_key("web_search"));
712 assert!(cfg.labels.contains_key("generate_studio_image"));
713 assert!(cfg.labels.contains_key("run_applescript"));
714 assert!(cfg.labels.contains_key("remember"));
715 assert!(cfg.labels.contains_key("recall"));
716 assert!(cfg.labels.contains_key("read_image_text"));
717 assert!(cfg.labels.contains_key("classify_image"));
718 }
719
720 #[test]
721 fn load_merges_file_without_weakening_builtins() {
722 let dir = std::env::temp_dir().join(format!("car_flow_{}", std::process::id()));
723 std::fs::create_dir_all(&dir).unwrap();
724 std::fs::write(
725 dir.join("tool-labels.json"),
726 r#"{
727 "labels": {
728 "my_tool": {"confidentiality": "secret"},
729 "http_request": {"capability": "not_net", "sink": false},
730 "recall": {"confidentiality": "public"},
731 "remember": {"sink": false, "declassifier": true}
732 },
733 "flow_policy": {
734 "min_confidential": "secret",
735 "forbidden_sequences": [["fs_read", "net_send"]]
736 },
737 "gate_policy": {
738 "on_sensitive_to_sink": "allow",
739 "on_forbidden_sequence": "block"
740 }
741 }"#,
742 )
743 .unwrap();
744 let cfg = load_tool_labels(&dir).unwrap();
745 assert_eq!(
747 cfg.labels.get("my_tool").unwrap().confidentiality,
748 car_verify::infoflow::Confidentiality::Secret
749 );
750 let http = cfg.labels.get("http_request").unwrap();
753 assert!(http.sink);
754 assert_eq!(http.capability.as_deref(), Some("net_send"));
755 assert_eq!(
756 cfg.labels.get("recall").unwrap().confidentiality,
757 car_verify::infoflow::Confidentiality::Internal
758 );
759 let remember = cfg.labels.get("remember").unwrap();
760 assert!(remember.sink);
761 assert!(!remember.declassifier);
762 assert_eq!(
763 cfg.flow_policy.min_confidential,
764 car_verify::infoflow::Confidentiality::Internal
765 );
766 assert_eq!(
767 cfg.flow_policy.forbidden_sequences,
768 vec![("fs_read".to_string(), "net_send".to_string())]
769 );
770 assert_eq!(
771 cfg.gate_policy.on_sensitive_to_sink,
772 car_verify::infoflow::FlowAction::Block
773 );
774 assert_eq!(
775 cfg.gate_policy.on_forbidden_sequence,
776 car_verify::infoflow::FlowAction::Block
777 );
778 assert!(cfg.labels.contains_key("read_file"));
780 std::fs::remove_dir_all(&dir).ok();
781 }
782
783 #[test]
784 fn load_malformed_file_is_error() {
785 let dir = std::env::temp_dir().join(format!("car_flow_bad_{}", std::process::id()));
786 std::fs::create_dir_all(&dir).unwrap();
787 std::fs::write(dir.join("tool-labels.json"), "{not json").unwrap();
788 assert!(load_tool_labels(&dir).is_err());
789 std::fs::remove_dir_all(&dir).ok();
790 }
791}
792
793#[cfg(test)]
794mod provenance_classification_tests {
795 use super::*;
796
797 #[test]
798 fn network_reaching_builtins_are_external() {
799 let labels = builtin_tool_labels();
800 for tool in ["web_search", "http_request", "browser", "search"] {
801 assert!(
802 tool_output_is_external(tool, &labels),
803 "{tool} reaches the network — its output is outside the trust boundary"
804 );
805 }
806 }
807
808 #[test]
809 fn coder_browser_builtins_are_external() {
810 let labels = builtin_tool_labels();
811 for tool in [
812 "browse_navigate",
813 "browse_click",
814 "browse_type",
815 "browse_scroll",
816 "browse_keypress",
817 "browse_wait",
818 "browse_observe",
819 "browser_await_answer",
820 "browser_await_signin",
821 "browser_record_start",
822 "browser_record_stop",
823 ] {
824 assert!(
825 tool_output_is_external(tool, &labels),
826 "{tool} reaches a network-backed browser — its output is outside the trust boundary"
827 );
828 }
829 }
830
831 #[test]
832 fn local_tools_are_not_external() {
833 let labels = builtin_tool_labels();
834 for tool in ["run_applescript", "recall", "read_image_text"] {
837 assert!(
838 !tool_output_is_external(tool, &labels),
839 "{tool} does not fetch from outside the trust boundary"
840 );
841 }
842 }
843
844 #[test]
845 fn unlabeled_tool_is_not_external() {
846 let labels = builtin_tool_labels();
849 assert!(!tool_output_is_external("some_new_tool", &labels));
850 }
851
852 #[test]
853 fn explicit_untrusted_label_is_honoured() {
854 let mut labels = builtin_tool_labels();
857 labels.insert(
858 "scrape_intranet".to_string(),
859 ToolLabels {
860 trust: TrustLevel::Untrusted,
861 ..Default::default()
862 },
863 );
864 assert!(tool_output_is_external("scrape_intranet", &labels));
865 }
866
867 #[test]
868 fn a_new_net_send_tool_is_classified_without_touching_this_function() {
869 let mut labels = builtin_tool_labels();
872 labels.insert(
873 "fetch_rss".to_string(),
874 ToolLabels {
875 capability: Some(NET_SEND.to_string()),
876 sink: true,
877 ..Default::default()
878 },
879 );
880 assert!(tool_output_is_external("fetch_rss", &labels));
881 }
882}