Skip to main content

car_engine/
flow.rs

1//! Information-flow admission gate + tool-label loading (EPIC A / A3+A4).
2//!
3//! `car_verify::check_information_flow` is a verified static check for data
4//! exfiltration and forbidden tool orderings, but it was never called by
5//! the runtime, and it needs per-tool *labels* (capability, confidentiality,
6//! trust, sink) that nothing produced. This module supplies both halves:
7//!
8//! - **A3 — labels**: a built-in default label table for CAR's commodity
9//!   tools, plus a `.car/tool-labels.json` loader so a project can declare
10//!   which tools are sinks, which produce confidential data, and what tool
11//!   orderings are forbidden.
12//! - **A4 — the gate**: [`InformationFlowGate`], an [`crate::admission::AdmissionGate`]
13//!   that runs `check_information_flow` → `gate_flow` on every admitted
14//!   proposal, blocking exfiltration and escalating forbidden orderings to
15//!   approval (which fails closed until A7 wires the approval transport).
16
17use 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/// The deserialized `.car/tool-labels.json` document: per-tool labels plus
29/// the flow policy (what counts as a hazard) and the gate policy (what to
30/// do about each hazard class). Every field defaults, so a partial file is
31/// valid.
32#[derive(Debug, Clone, Default, Deserialize, Serialize)]
33pub struct ToolLabelConfig {
34    /// Tool name → information-flow labels.
35    #[serde(default)]
36    pub labels: HashMap<String, ToolLabels>,
37    /// What confidentiality reaching a sink is a violation, and which tool
38    /// orderings are forbidden.
39    #[serde(default)]
40    pub flow_policy: FlowPolicy,
41    /// How each hazard class is enforced (block / approve / allow).
42    #[serde(default)]
43    pub gate_policy: FlowGatePolicy,
44}
45
46/// The capability that marks a tool as reaching the network.
47pub const NET_SEND: &str = "net_send";
48
49/// Whether a tool's output should be treated as coming from **outside** the
50/// trust boundary (car#723).
51///
52/// Derived from the information-flow labels a tool already has to carry rather
53/// than from a second, parallel list. That is the whole point: a new
54/// network-reaching tool needs a `net_send` label anyway — without one it is
55/// invisible to the exfiltration gate — so deriving provenance from the same
56/// label means there is one place to forget instead of two. A second list would
57/// drift, and the drift would be silent and security-relevant.
58///
59/// Two signals count:
60///
61/// - `capability == "net_send"` — the tool talks to the network, so what it
62///   returns is shaped by something outside this host. That includes the
63///   generator tools, which submit a prompt to a third-party service and return
64///   what it produced; treating those as external is the conservative reading.
65/// - `trust == Untrusted` — an explicit declaration in `.car/tool-labels.json`,
66///   for a project's own tools that read from somewhere untrusted without
67///   themselves being network sinks.
68///
69/// An unlabeled tool is `false`. That is the same blind spot the flow gate
70/// already has, deliberately shared rather than papered over with a heuristic
71/// on tool names — a name-matching guess here would produce confident-looking
72/// marks that are wrong in both directions.
73pub 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
79/// Default information-flow labels for CAR's built-in commodity tools.
80///
81/// Conservative and capability-only: the network-reaching tools are marked
82/// as exfiltration sinks (so any confidential data flowing into them is
83/// caught), and the file/process tools get capability tags so forbidden
84/// orderings can be expressed against them. Nothing is marked confidential
85/// by default — a project declares which of *its* tools/sources produce
86/// sensitive data in `.car/tool-labels.json`.
87pub 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    // Network-reaching tools are exfiltration sinks.
110    m.insert("http_request".to_string(), sink("net_send"));
111    m.insert("web_search".to_string(), sink("net_send"));
112    // Keep the established generic browser classification and label every
113    // concrete `browse_*` / `browser_*` name the browser executor exposes.
114    // Otherwise page content from either surface comes back as trusted Internal
115    // text even though it crossed the network boundary.
116    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    // Host-side Studio generators submit prompts to external services.
134    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    // Host automation can drive apps, clipboard, browsers, and messaging.
139    m.insert("run_applescript".to_string(), sink("host_automation"));
140    // Durable assistant memory persists across sessions and process restarts.
141    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    // Vision tools read user/workspace images. OCR and classification output can
150    // expose private screenshots, scans, photos, or generated artifacts.
151    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    // Filesystem + process tools: capability-tagged, not sinks.
166    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
221/// Load tool labels for a project, merging `.car/tool-labels.json` with the
222/// built-in defaults.
223///
224/// `car_dir` is the project's `.car` directory; this reads
225/// `car_dir/tool-labels.json` if present. Built-in labels are the base;
226/// project labels can add new tools or strengthen built-ins, but cannot weaken
227/// built-in sinks, capabilities, taint sources, trust labels, or enforcement
228/// defaults. A missing file is not an error (returns builtins + default
229/// policies); a malformed file *is* an error.
230pub 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
263/// An [`AdmissionGate`] that enforces information-flow safety: confidential
264/// data must not reach an exfiltration sink, and forbidden tool orderings
265/// are escalated to human approval.
266pub struct InformationFlowGate {
267    labels: HashMap<String, ToolLabels>,
268    flow_policy: FlowPolicy,
269    gate_policy: FlowGatePolicy,
270}
271
272impl InformationFlowGate {
273    /// Build a gate from a resolved [`ToolLabelConfig`].
274    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    /// Build a gate from the built-in defaults only.
283    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                // Stable fingerprint over EVERY hazard (linus review C-8):
323                // fingerprinting only the first hazard let one approval
324                // admit hazards 2..n. Sorted for order independence, then
325                // HASHED — the components carry model-chosen action ids
326                // and state keys, so a delimiter-joined ledger key would
327                // be forgeable by embedding the separator (same fix as
328                // the intent gate; a ledger key is a security identity).
329                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/// Error raised while loading `.car/tool-labels.json`.
357#[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        // read_file then http_request with no declared confidential data:
447        // nothing sensitive flows, so it's safe.
448        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        // Label a custom source as Secret; it flows into http_request (a
458        // sink) via a shared state key.
459        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        // a1 reads a secret into key "data"; a2 (http_request sink) reads it.
474        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        // New tool added.
746        assert_eq!(
747            cfg.labels.get("my_tool").unwrap().confidentiality,
748            car_verify::infoflow::Confidentiality::Secret
749        );
750        // Built-in sinks/capabilities and internal sources cannot be weakened by
751        // project-local policy files.
752        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        // Untouched built-in preserved.
779        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        // run_applescript is a sink (host automation) but not a network source;
835        // recall reads local persistent memory. Neither returns fetched bytes.
836        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        // Documents the known blind spot rather than pretending it away: an
847        // unlabeled tool is invisible here exactly as it is to the flow gate.
848        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        // A project's own tool that reads from somewhere untrusted without
855        // being a network sink.
856        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        // The reason classification derives from the labels: labeling a new
870        // network tool for the flow gate also classifies its output here.
871        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}