Skip to main content

car_server_core/assistant/
mod.rs

1//! Parslee Core — the flagship, general-purpose agent that ships in the `car`
2//! binary and works out of the box (`car do`).
3//!
4//! Unlike the coder (coding-specific) or the create-car-agent skill (build your
5//! own), this is a batteries-included assistant: files + a real shell + web +
6//! durable memory, sandbox-first for safety, driven by CAR inference through a
7//! full [`Runtime`] (validator, policy, permission tiers, event log). One core
8//! backs three entry modes — one-shot, REPL, and the conversational
9//! `agent.chat` surface.
10//!
11//! ## Module map
12//! - [`executor`] — [`GeneralExecutor`], the substrate-bound tool executor
13//!   (agent_basics + `calculate` + `shell` + network delegate).
14//! - [`net_tools`] — host-side `http_request` / `web_search` (bypass the
15//!   sandbox's `--network none`).
16//! - [`substrate`] — sandbox-first environment selection ([`bind_default_substrate`]).
17//! - [`identity_tools`] — the gated `set_assistant_name` tool.
18//! - [`policy`] — the assistant inspector chain (reuses the coder's footgun set).
19//! - [`prompt`] — batch vs. conversational system prompts.
20//! - [`agent_loop`] — the propose→validate→execute→observe loop.
21//! - [`do_json`] — the `car.do/1` envelope: progress events plus the terminal
22//!   document, shared by `car do --json` and the MCP run registry.
23//!
24//! [`Runtime`]: car_engine::Runtime
25//! [`GeneralExecutor`]: executor::GeneralExecutor
26//! [`bind_default_substrate`]: substrate::bind_default_substrate
27
28pub mod agent_loop;
29pub mod automation_tools;
30pub mod browser_control;
31pub mod browser_producer;
32pub mod browser_stream;
33pub mod browser_tools;
34pub mod chat;
35pub mod device_tools;
36pub mod do_json;
37pub mod executor;
38pub mod governance;
39pub mod identity_tools;
40pub mod m365_tools;
41pub mod media_tools;
42pub mod memory;
43pub mod net_tools;
44pub mod policy;
45pub mod production_gates;
46pub mod prompt;
47pub mod register;
48pub mod studio_tools;
49pub mod substrate;
50pub mod todo;
51pub mod tool_memory;
52pub mod value_store;
53pub mod vision_tools;
54
55use std::path::PathBuf;
56use std::sync::Arc;
57
58use async_trait::async_trait;
59use car_engine::{Runtime, ToolEntry, ToolExecutor, ToolSchema};
60use car_eventlog::EventLog;
61use car_inference::InferenceEngine;
62use car_policy::permission::PermissionTier;
63use serde_json::Value;
64
65use memory::MemoryTools;
66pub use memory::{MemorySync, NoteKind, SyncedFact};
67
68pub use agent_loop::{
69    run_assistant_goal_loop, run_assistant_loop, run_assistant_loop_cancellable,
70    ungrounded_summary_claims, ApprovalDecision, ApprovalGate, AssistantConfig, AssistantEvent,
71    AssistantModelAttribution, AssistantOutcome, AssistantToolReceipt, GoalLoopResult,
72};
73pub use chat::{AssistantService, ChatGoal};
74pub use device_tools::DeviceProvider;
75pub use executor::GeneralExecutor;
76pub use net_tools::NetTools;
77pub use substrate::{bind_default_substrate, BoundEnvironment, DEFAULT_ASSISTANT_IMAGE};
78
79/// An assembled assistant runtime: the [`Runtime`] to drive, the model-visible
80/// tool list, and the environment metadata for the system prompt.
81/// One line naming the host OS and the shell the `shell` tool actually uses
82/// there, for the local-substrate environment description.
83///
84/// Windows gets the concrete negative list rather than just "cmd.exe". Saying
85/// "this is Windows" is not enough on its own: a model that has seen a million
86/// POSIX transcripts will still reach for `grep`, and under `cmd /C` that is not
87/// a slightly-wrong command, it is `'grep' is not recognized` — which the coder
88/// then reads as its own broken code rather than as a shell mismatch.
89fn host_shell_note() -> String {
90    if cfg!(windows) {
91        "Host platform: Windows. The `shell` tool runs each command through \
92         `cmd /C` — this is cmd.exe, NOT a POSIX shell. `ls`, `grep`, `cat`, \
93         `head`, `tail`, `rm`, `cp`, `mv`, `which`, `touch` and `export` do not \
94         exist, and neither does `$(...)` command substitution or single-quote \
95         quoting. Use `dir`, `findstr`, `type`, `del`, `copy`, `move`, `where`, \
96         `set` and `%VAR%`. Paths use backslashes and drive letters. Prefer the \
97         file tools over shell text-munging wherever they cover the job."
98            .to_string()
99    } else {
100        format!(
101            "Host platform: {}. The `shell` tool runs each command through `sh -c`.",
102            std::env::consts::OS
103        )
104    }
105}
106
107pub struct AssistantRuntime {
108    /// The configured runtime (validator + policy + tiers + event log), whose
109    /// tool executor is the [`GeneralExecutor`].
110    pub runtime: Runtime,
111    /// The model-visible tools (from `GeneralExecutor::all_tool_defs()`).
112    pub tools: Vec<Value>,
113    /// Environment description for the system prompt: the one-line
114    /// substrate sentence, plus — on a local (non-sandboxed) session — an
115    /// appended names-only, depth-bounded workspace snapshot (F7/L1).
116    pub description: String,
117    /// The name this user chose for the assistant, plus the spoken aliases it
118    /// answers to. Loaded once here rather than at each prompt-building call
119    /// site, so every entry mode — one-shot, REPL, MCP, the coder's discussion
120    /// — agrees on who the agent is. Falls back to the shipped default when
121    /// `identity.json` is missing or unreadable; `car identity` is the surface
122    /// that reports a broken record.
123    pub identity: car_identity::AssistantIdentity,
124    /// Whether execution is isolated in a container.
125    pub sandboxed: bool,
126    /// Tools that require human approval before running under the standing tier
127    /// (writes/shell on the local host without `--full-access`); empty when the
128    /// tier auto-allows everything. Feed into `AssistantConfig::gated_tools`.
129    pub gated_tools: Vec<String>,
130    /// Shared assistant memory bank used by the loop's proactive memory pass and
131    /// by the model-visible `remember` / `recall` tools.
132    pub proactive_memory: Arc<MemoryTools>,
133    /// The run's learned tool repairs — which call recovered which kind of tool
134    /// failure, durable across sessions. Separate from `proactive_memory` on
135    /// purpose: that bank holds facts about the USER, this one holds procedural
136    /// trivia about the TOOLS, and mixing them would put `shell::exit_1` in
137    /// front of a question about their dog. See [`tool_memory`].
138    pub tool_memory: Arc<tool_memory::ToolMemory>,
139    /// If the sandbox was requested but unavailable, why we fell back to local.
140    pub fallback_notice: Option<String>,
141    /// The run's browser (Chromium still un-launched until the first browse
142    /// call). Exposed so the daemon can publish it as a `browser.view.*`
143    /// view for the drawer to watch and drive — the drawer has to reach the
144    /// browser an agent ACTUALLY uses, and this is the only handle on it.
145    pub browser: Arc<browser_tools::BrowserTools>,
146    /// The run's task list (Parslee-ai/car#814), shared with the executor that
147    /// `todo_write` mutates. Exposed because rendering it is the loop's job:
148    /// #814 item 2 (a per-turn state block) is the consumer, and without a
149    /// handle here that change could not reach the state it needs to render.
150    pub todos: Arc<tokio::sync::Mutex<todo::TodoList>>,
151}
152
153/// A delegate that tries each inner executor in turn, using the `unknown tool`
154/// convention to fall through — so several tool families (network, memory) share
155/// one `GeneralExecutor` delegate slot.
156struct ChainedDelegate(Vec<Arc<dyn ToolExecutor>>);
157
158#[async_trait]
159impl ToolExecutor for ChainedDelegate {
160    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
161        for ex in &self.0 {
162            match ex.execute(tool, params).await {
163                Err(e) if e.starts_with("unknown tool") => continue,
164                other => return other,
165            }
166        }
167        Err(format!("unknown tool: '{tool}'"))
168    }
169
170    async fn execute_with_action_in_session(
171        &self,
172        tool: &str,
173        params: &Value,
174        action_id: &str,
175        timeout_ms: Option<u64>,
176        session_id: Option<&str>,
177        attempt: u32,
178    ) -> Result<Value, String> {
179        for ex in &self.0 {
180            match ex
181                .execute_with_action_in_session(
182                    tool, params, action_id, timeout_ms, session_id, attempt,
183                )
184                .await
185            {
186                Err(e) if e.starts_with("unknown tool") => continue,
187                other => return other,
188            }
189        }
190        Err(format!("unknown tool: '{tool}'"))
191    }
192}
193
194/// Build a registry [`ToolSchema`] from a model-facing `{name, description,
195/// parameters}` def, so any advertised tool can be registered for validation.
196fn schema_from_def(def: &Value) -> ToolSchema {
197    ToolSchema {
198        name: def["name"].as_str().unwrap_or_default().to_string(),
199        source: car_ir::ToolSourceKind::UserDefined,
200        description: def["description"].as_str().unwrap_or_default().to_string(),
201        parameters: def["parameters"].clone(),
202        returns: None,
203        idempotent: false,
204        cache_ttl_secs: None,
205        rate_limit: None,
206    }
207}
208
209/// The names of advertised tools whose self-declared `"tier"` exceeds the
210/// standing `tier` — these must be approval-gated (neo leak #3). A tool without
211/// a `"tier"` field, or one at/below the standing tier, is not gated here.
212fn tier_gated_tool_names(tools: &[Value], standing: PermissionTier) -> Vec<String> {
213    tools
214        .iter()
215        .filter_map(|def| {
216            let name = def.get("name").and_then(|v| v.as_str())?;
217            let tier = PermissionTier::from_str_opt(def.get("tier").and_then(|v| v.as_str())?)?;
218            (tier > standing).then(|| name.to_string())
219        })
220        .collect()
221}
222
223/// Every tool schema that the flagship assistant can advertise on any
224/// supported host/configuration. This is the deterministic discoverability
225/// catalog; [`build_assistant_runtime`] still filters availability at runtime
226/// (models, credentials, platform, host connection, and requested delegation).
227pub fn model_tool_catalog() -> Vec<Value> {
228    let mut tools = GeneralExecutor::tool_defs();
229    tools.extend(net_tools::net_tool_defs());
230    tools.extend(MemoryTools::tool_defs());
231    tools.extend(media_tools::catalog_tool_defs());
232    tools.extend(studio_tools::studio_tool_defs());
233    tools.extend(m365_tools::m365_tool_defs());
234    tools.extend(vision_tools::catalog_tool_defs());
235    tools.extend(automation_tools::catalog_tool_defs());
236    tools.extend(browser_tools::browser_tool_defs());
237    tools.extend(device_tools::DeviceTools::tool_defs());
238    tools.extend(identity_tools::IdentityTools::tool_defs());
239    tools.push(GeneralExecutor::events_query_def());
240    tools.push(todo::tool_def());
241    tools.push(agent_loop::delegate_tool_def(&tools));
242    tools.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str()));
243    tools.dedup_by(|left, right| left["name"] == right["name"]);
244    tools
245}
246
247/// Default durable-memory path: `memory/assistant.json` under the CAR state
248/// root — `CAR_HOME` when set, otherwise `~/.car` (HOME, or USERPROFILE on
249/// Windows), and a relative `.car` when neither resolves.
250///
251/// Delegates to [`car_memgine::note_store::default_path`] rather than
252/// recomputing the join, so the assistant and the MCP server point at the same
253/// file **by construction**. They previously agreed only because two copies of
254/// the same expression happened to match, and the MCP server did not use its
255/// copy at all (car#972 §1). "The editor and `car do` share one memory" is the
256/// whole point; it should not depend on nobody editing one of two literals.
257pub(crate) fn default_memory_path() -> PathBuf {
258    car_memgine::note_store::default_path()
259}
260
261/// Assemble an [`AssistantRuntime`] from an engine and a bound environment.
262///
263/// Registers the model-visible tools so the validator allows them (agent_basics
264/// builtins + `shell` + `http_request` + `web_search`), binds the
265/// [`GeneralExecutor`] as the executor and the environment's substrate, and
266/// attaches an optional event-log journal.
267///
268/// `trajectories` is the directory for the execution-trace store. The assistant
269/// is where most real tool execution happens, so without it the per-tool
270/// success rates that `verify.monte_carlo` derives would be built almost
271/// entirely from the daemon's `proposal.submit` path and miss the agent that
272/// actually runs. It is an explicit `Option<PathBuf>` — mirroring `eventlog`
273/// above — rather than defaulting to `~/.car/trajectories/`, because a test
274/// that runs a deliberately-broken tool fifty times would otherwise write that
275/// into the user's real history and permanently skew the rates the feature
276/// reads back.
277///
278/// Fails only when `<root>/.car/policies/` holds a malformed rule file — a
279/// security control that would silently not exist is worse than a startup that
280/// refuses; see [`crate::session::apply_project_policies`].
281/// `allow_delegate` advertises the loop-intercepted `delegate` sub-agent tool
282/// (see `agent_loop::delegate_tool_def`). Pass `true` only for a surface whose
283/// operator asked for a delegating run — `car do` one-shot / goal / `--json`.
284pub async fn build_assistant_runtime(
285    engine: Arc<InferenceEngine>,
286    env: BoundEnvironment,
287    eventlog: Option<PathBuf>,
288    device_provider: Option<Arc<dyn DeviceProvider>>,
289    memory_sync: Option<Arc<dyn MemorySync>>,
290    trajectories: Option<PathBuf>,
291    allow_delegate: bool,
292) -> Result<AssistantRuntime, String> {
293    // Mutations auto-allow unless the standing tier is ReadOnly (local host
294    // without --full-access), in which case writes/shell need approval; clamp
295    // file paths to root only off-sandbox.
296    let mut gated_tools: Vec<String> = if matches!(env.tier, PermissionTier::ReadOnly) {
297        ["write_file", "edit_file", "shell"]
298            .iter()
299            .map(|s| s.to_string())
300            .collect()
301    } else {
302        Vec::new()
303    };
304    // Renaming the assistant is gated on EVERY session, including
305    // `--full-access`. Unlike the writes above, the risk here is not what the
306    // session may do — it is where the instruction came from. A rename can
307    // arrive inside a fetched page, a file, or a recalled memory, and an
308    // assistant that quietly starts answering to a name someone else picked is
309    // an identity-spoof surface. One approval tap is the cheaper mistake.
310    gated_tools.push("set_assistant_name".to_string());
311    let clamp = !env.sandboxed;
312    // Names-only, depth-bounded workspace snapshot (F7/L1): orient the model with
313    // the repo layout up front instead of only a one-line environment sentence.
314    // LOCAL substrate only — for a sandboxed or remote session we keep today's
315    // one-liner and never touch the container/VM at prompt-build time (no docker
316    // spin-up here). Best-effort: an unreadable/empty root yields nothing.
317    let mut description = env.description.clone();
318    if !env.sandboxed && env.substrate.is_local() {
319        // Name the host platform and the shell it actually gets.
320        //
321        // Nothing else in the prompt path ever told the model which OS it was on
322        // — `std::env::consts::OS` appeared in no prompt builder — while the
323        // `shell` tool def said "executed via sh -c" on every platform. On Windows
324        // `run_shell_on` dispatches `cmd /C`, so the model was briefed that it held
325        // a POSIX shell it does not hold, and `ls`/`grep`/`cat` come back
326        // "'grep' is not recognized" (car#1260 audit). The repo already guards this
327        // for scripted fixtures — see `coder::test_cmds` — and this is the same
328        // hazard one layer up.
329        //
330        // Local substrate only, deliberately: when a substrate is bound,
331        // `run_shell_on` routes to `substrate.run_command`, and inside a Linux
332        // container `sh -c` is true even on a Windows host. The sandboxed branch
333        // keeps the substrate's own one-line description, which already says so.
334        description.push_str("\n\n");
335        description.push_str(&host_shell_note());
336        let snapshot = substrate::workspace_snapshot(&env.root, 2, 2000);
337        if !snapshot.is_empty() {
338            description.push_str("\n\n");
339            description.push_str(&snapshot);
340        }
341    }
342    let sandboxed = env.sandboxed;
343    let fallback_notice = env.fallback_notice.clone();
344
345    // Delegate tools (host-side): network + durable memory. Both bypass the
346    // substrate/path-clamp — network needs host egress, memory is CAR's graph.
347    let net: Arc<dyn ToolExecutor> = Arc::new(NetTools::new());
348    let mem = Arc::new(MemoryTools::open(default_memory_path()).with_sync(memory_sync));
349    // Learned tool repairs (see `tool_memory`). Opening only READS the store —
350    // every write is driven by the agent loop, and only when a surface opted in
351    // by setting `AssistantConfig::tool_memory`. That split is what lets this be
352    // unconditional here without a test run teaching the user's real assistant
353    // that the way to fix a tool is whatever the fixture did fifty times.
354    let tool_memory = Arc::new(tool_memory::ToolMemory::open(tool_memory::default_path()));
355    // Media generation (image today) — host-side, backed by the inference
356    // engine's local models. Advertises nothing when no image model is
357    // available, so it never offers a tool it can't run. The capability a
358    // text-only agent structurally cannot have.
359    let media = Arc::new(media_tools::MediaTools::new(
360        engine.clone(),
361        env.root.clone(),
362    ));
363    // Parslee Studio media (music today) — host-side, via the Studio service on
364    // CAR's existing Parslee bearer. Advertises nothing without a Parslee
365    // session. Another capability a text-only agent structurally lacks.
366    let studio = Arc::new(studio_tools::StudioMediaTools::new(env.root.clone()));
367    // Parslee M365 — host-side, via the Parslee platform on CAR's existing
368    // Parslee bearer. Delegates email/calendar/CRM/meeting work to the org's
369    // already-agentic M365 employee. Advertises nothing without a Parslee
370    // session. Another capability a text-only agent structurally lacks.
371    let m365 = Arc::new(m365_tools::M365Tools::new());
372    // Vision (image understanding) — host-side, via Apple Vision / Tesseract.
373    // The CONSUMER counterpart to the generators: read text from an image (OCR)
374    // and classify what it depicts. Advertises nothing when no vision backend is
375    // present. A text-only agent can neither make nor read an image.
376    let vision = Arc::new(vision_tools::VisionTools::new(env.root.clone()));
377    // macOS automation ("control the Mac", AppleScript/JXA) — host-side, CANNOT
378    // be sandboxed. Self-declares tier:full_access, so the tier-based gating
379    // below routes it through approval unless the session is --full-access. A
380    // capability no sandboxed or text-only agent has.
381    let automation = Arc::new(automation_tools::AutomationTools::new());
382    // Browser driving + session RECORDING — host-side. Chromium launches
383    // lazily on first use, so a session that never browses pays nothing. The
384    // recorder is what makes this more than automation: it captures the app
385    // BEING USED (an answer streaming in, a table filling) rather than a still
386    // of its final state. Another capability a text-only agent structurally
387    // lacks.
388    let browser = Arc::new(browser_tools::BrowserTools::new(env.root.clone()));
389    let device_tools = device_provider
390        .map(device_tools::DeviceTools::new)
391        .map(Arc::new);
392    // The assistant's own name. Host-side (it writes the state root), and the
393    // one tool gated on every session regardless of tier — see the module docs
394    // for why an identity change is not a tier decision.
395    let identity_tools = Arc::new(identity_tools::IdentityTools::new());
396    let mut delegate_defs = net_tools::net_tool_defs();
397    delegate_defs.extend(MemoryTools::tool_defs());
398    delegate_defs.extend(media.tool_defs());
399    delegate_defs.extend(studio.tool_defs());
400    delegate_defs.extend(m365.tool_defs());
401    delegate_defs.extend(vision.tool_defs());
402    delegate_defs.extend(automation.tool_defs());
403    delegate_defs.extend(browser.tool_defs());
404    if device_tools.is_some() {
405        delegate_defs.extend(device_tools::DeviceTools::tool_defs());
406    }
407    delegate_defs.extend(identity_tools::IdentityTools::tool_defs());
408    let mem_exec: Arc<dyn ToolExecutor> = mem.clone();
409    let media: Arc<dyn ToolExecutor> = media;
410    let studio: Arc<dyn ToolExecutor> = studio;
411    let m365: Arc<dyn ToolExecutor> = m365;
412    let vision: Arc<dyn ToolExecutor> = vision;
413    let automation: Arc<dyn ToolExecutor> = automation;
414    let browser_tools = Arc::clone(&browser);
415    let browser: Arc<dyn ToolExecutor> = browser;
416    let identity_exec: Arc<dyn ToolExecutor> = identity_tools;
417    let mut delegates: Vec<Arc<dyn ToolExecutor>> = vec![
418        net,
419        mem_exec,
420        media,
421        studio,
422        m365,
423        vision,
424        automation,
425        browser,
426        identity_exec,
427    ];
428    if let Some(device_tools) = device_tools {
429        delegates.push(device_tools);
430    }
431    let delegate: Arc<dyn ToolExecutor> = Arc::new(ChainedDelegate(delegates));
432
433    // The event log is created HERE, before the executor, so both it and the
434    // runtime can hold the same handle. Building it inside `with_event_log`
435    // below would leave the executor unable to read what the runtime records,
436    // and `events_query` would have nothing to answer from (#815).
437    let event_log = if let Some(path) = eventlog.as_ref() {
438        if let Some(parent) = path.parent() {
439            let _ = std::fs::create_dir_all(parent);
440        }
441        let log = if path.exists() {
442            let mut loaded = EventLog::load(path).map_err(|e| {
443                format!(
444                    "cannot resume assistant receipt journal {}: {e}",
445                    path.display()
446                )
447            })?;
448            if let Err(index) = loaded.verify_chain() {
449                return Err(format!(
450                    "assistant receipt journal {} failed its hash chain at event {index}",
451                    path.display()
452                ));
453            }
454            loaded.enable_hash_chaining();
455            loaded
456        } else {
457            EventLog::with_journal(path.clone()).with_hash_chaining()
458        };
459        Some(Arc::new(tokio::sync::Mutex::new(log)))
460    } else {
461        None
462    };
463
464    // The task list is shared, not owned: the executor mutates it via
465    // `todo_write` and the loop renders it, so both need the same handle
466    // (Parslee-ai/car#814).
467    let todos = Arc::new(tokio::sync::Mutex::new(todo::TodoList::new()));
468
469    let mut executor = GeneralExecutor::new(env.substrate.clone(), env.root.clone(), clamp)
470        // Scoped opt-in: only the discussion surface sets `clamp_reads`, so the
471        // general assistant's read reach is unchanged.
472        .with_read_clamp(env.clamp_reads)
473        .with_delegate(delegate, delegate_defs)
474        .with_todos(Arc::clone(&todos));
475    if let Some(log) = &event_log {
476        executor = executor.with_event_log(Arc::clone(log));
477    }
478    // Everything the executor can actually dispatch. The model-visible subset
479    // is derived from this AFTER project policy loads below, because a tool the
480    // project denies outright should never be advertised in the first place.
481    let all_defs = executor.all_tool_defs();
482    let executor: Arc<dyn ToolExecutor> = Arc::new(executor);
483
484    // Outbound human messaging, so "text me when the build finishes" is a
485    // governed runtime tool here too (validator → policy → rate limit →
486    // eventlog) rather than something the model improvises through `shell`.
487    //
488    // iMessage only, and deliberately NO host fallback. The daemon sets one
489    // because it genuinely has a host on the other end of the tool-callback
490    // channel; `car do` does not — its executor is the in-process
491    // `GeneralExecutor`, which would answer `messaging.channel_send` with
492    // `unknown tool` and make `HostChannelAdapter` report "this host does not
493    // implement the callback" for every unknown channel. That is a misleading
494    // error: the truth is that this process has no host to implement it. The
495    // registry's own "unknown messaging channel 'x': registered channels are
496    // imessage" is the accurate answer, so we leave the fallback empty.
497    //
498    // Reuses `RealMessageSender` for the same reason the daemon does, and with
499    // the same non-loop argument: it calls the un-gated `messages_send`
500    // directly, so nothing here can re-enter the approval transport.
501    let outbound = Arc::new(car_messaging::outbound::OutboundRegistry::new());
502    outbound.register(Arc::new(
503        car_messaging::outbound::ImessageOutboundAdapter::new(
504            Arc::new(crate::messaging_orchestrator::RealMessageSender),
505            crate::messaging_config::MessagingConfigStore::from_home(),
506        ),
507    ));
508
509    let mut runtime = Runtime::new()
510        .with_inference(engine)
511        .with_executor(executor)
512        .with_substrate(env.substrate.clone())
513        .with_message_sink(outbound);
514    if let Some(log) = event_log {
515        runtime = runtime.with_shared_event_log(log);
516    }
517    if let Some(dir) = trajectories {
518        runtime = runtime.with_trajectory_store(Arc::new(car_memgine::TrajectoryStore::new(&dir)));
519    }
520
521    // OpenClaw-style personal assistants fail dangerously when persistent,
522    // high-privilege context can flow straight into outbound tools. Install
523    // CAR's verified information-flow gate by default: built-in labels mark
524    // network tools as exfiltration sinks, and projects can refine source
525    // confidentiality in `.car/tool-labels.json`.
526    if let Err(e) = runtime
527        .install_information_flow_gate(
528            env.project_car_dir
529                .clone()
530                .unwrap_or_else(|| env.root.join(".car")),
531        )
532        .await
533    {
534        tracing::warn!(
535            error = %e,
536            "assistant could not load project tool labels; falling back to built-in information-flow labels"
537        );
538        runtime
539            .register_admission_gate(Arc::new(
540                car_engine::InformationFlowGate::with_builtin_labels(),
541            ))
542            .await;
543    }
544
545    // The declarative half of the same `.car` directory: `policies/*.toml`.
546    // Project-scoped, matching its information-flow sibling above — the rules
547    // that govern an agent working in this repo are the ones checked into this
548    // repo.
549    //
550    // Note the deliberate asymmetry with that sibling: missing tool labels fall
551    // back to a safe built-in default, so warning and continuing is honest
552    // there. A malformed policy file has no safe default — the rule it was
553    // meant to enforce simply would not exist — so it is fatal.
554    // `apply_project_policies` carries the full reasoning; do NOT downgrade it
555    // to a warning to match the block above.
556    // The DISCOVERED `.car`, not `root.join(".car")`. A `.car/` checked in at a
557    // repository root governs the repository, so a run started in a
558    // subdirectory is governed by it too — which is what CLAUDE.md has always
559    // said and what nothing implemented (car#1288). Falls back to the old form
560    // when there is nothing to discover, so a run outside a repository behaves
561    // exactly as before.
562    let project_car = env
563        .project_car_dir
564        .clone()
565        .unwrap_or_else(|| env.root.join(".car"));
566    crate::session::apply_project_policies(&runtime, &project_car).await?;
567
568    // The model-visible tool list: everything the executor offers, minus what
569    // project policy denies outright.
570    //
571    // Enforcement alone was already correct — a denied call is refused at
572    // dispatch and the refusal is fed back to the model, which then tries
573    // something else. What it was not is *cheap*. Advertising a tool no call
574    // can satisfy spends a schema's worth of context on every request and, when
575    // the model takes the bait, a whole turn on a refusal. Removing it from the
576    // list makes the project's `deny_tool = [...]` mean "this agent does not
577    // have that tool" rather than "this agent will be told off for using it".
578    //
579    // ONLY the `deny_tool` kind, and `PolicyEngine::blanket_denied_tools`
580    // carries the reason: it is the one kind whose totality is decidable from
581    // the kind alone. Others can forbid a tool outright too (an empty
582    // `allow_tool_param`, `max_calls = 0`), but only by inspection, so this
583    // deliberately under-reports. Under-reporting is the safe direction — the
584    // tool is advertised and then refused, which is the old behavior.
585    //
586    // Read from the engine rather than re-reading `.car/policies/` so this
587    // cannot disagree with what actually enforces, and so any rule that reached
588    // this engine by another route is honoured here too. Note what that does
589    // NOT include: the daemon's `~/.car/policies` is loaded into the `Runtime`
590    // that `session::create_session` builds, not this one, so nothing from
591    // there is in scope here.
592    //
593    // `tools` below is a SNAPSHOT. `blanket_denied_tools` is derived rather
594    // than cached so it cannot drift from what `check` enforces, but that is a
595    // property of the method, not of this list — nothing recomputes `tools`
596    // after build. It is safe here only because no caller mutates this engine's
597    // policies afterwards: `build_assistant_runtime` is reached from `car do`,
598    // the MCP assistant, and the coder's discussion surface, none of which
599    // re-register. A surface that hot-reloads policy must rebuild, not patch.
600    // (`car_policy::tool_gate` holds the opposite posture, freshness over
601    // caching, for a path where the rules genuinely do change under it.)
602    let denied = runtime.policies.read().await.blanket_denied_tools();
603    if !denied.is_empty() {
604        // The operator needs "never offered" to be distinguishable from "never
605        // attempted". Before this filter, every blocked attempt wrote a
606        // `PolicyViolation` to the event log, which was incidental proof the
607        // rule had loaded. A well-behaved model now never attempts it, so that
608        // proof disappears and a policy that silently failed to load would look
609        // identical to one working perfectly. Say it once at build time.
610        //
611        // A LOG LINE, not an event-log record — deliberately. No `EventKind`
612        // means "tools withheld at assembly", `PolicyViolation` would be a lie
613        // (nothing was violated), and adding a variant changes a serialized
614        // event shape that crosses all four binding surfaces. So this reaches
615        // an operator watching stderr and does NOT reach `events.query`. If a
616        // supervised agent's operator needs it there, that is the change to
617        // make, and it is a bigger one than this.
618        tracing::info!(
619            withdrawn = ?denied,
620            "project policy denies these tools outright; withdrawn from the model's advertised list \
621             (still registered, so a call naming one is refused by the policy)"
622        );
623    }
624    let mut tools: Vec<Value> = all_defs
625        .iter()
626        .filter(|def| !denied.contains(def.get("name").and_then(Value::as_str).unwrap_or_default()))
627        .cloned()
628        .collect();
629    // The loop-intercepted sub-agent tool, built over the model-visible set so
630    // its `tools` enum names exactly what the parent has — which now excludes
631    // the denied ones, so a child is never granted what the project denies the
632    // parent. Advertised only where the caller opts in (`car do` foreground
633    // runs); the read-only discussion surface, the MCP `run`, and the
634    // supervised `--serve` agent leave it off. When advertised it is registered
635    // with the validator below like any other def but never dispatched to the
636    // executor — `agent_loop` recognizes the name.
637    let delegate_def = allow_delegate.then(|| agent_loop::delegate_tool_def(&tools));
638    if let Some(def) = &delegate_def {
639        // `delegate` is appended rather than drawn from `all_defs`, so the
640        // filter above cannot reach it — gate the push explicitly.
641        //
642        // This is not cosmetic. `delegate` is loop-intercepted: `agent_loop`
643        // dispatches it itself when `delegate_advertised`, so it never reaches
644        // `runtime.execute` and the policy engine never sees it. Advertising it
645        // under a `deny_tool = ["delegate"]` rule therefore gave a project a
646        // deny that was neither hidden NOR enforced — the agent kept spawning
647        // children, silently. Withholding it flips `delegate_advertised` to
648        // false, the call falls through to normal dispatch, and the rule fires
649        // like any other. It stays registered with the validator below, so the
650        // refusal still names the policy.
651        if !denied.contains(agent_loop::DELEGATE_TOOL) {
652            tools.push(def.clone());
653        }
654    }
655    // Tier-based approval gating (neo leak #3): any advertised tool that
656    // self-declares a `"tier"` ABOVE the standing tier (e.g. a full_access
657    // automation tool in a non-full-access session) must route through the
658    // approval gate. Derived from the defs, so a new capability gates itself
659    // without editing this function.
660    gated_tools.extend(tier_gated_tool_names(&tools, env.tier));
661
662    // Register the dispatchable tools so the validator admits them. Execution
663    // is owned by the GeneralExecutor above; these registrations are for
664    // validation + schema listing. agent_basics covers the file/calculate
665    // builtins; everything else advertised (shell, http_request, web_search,
666    // remember, recall) is registered from its advertised def.
667    runtime.register_agent_basics().await;
668    let builtin_names: std::collections::HashSet<String> = car_engine::agent_basic_entries()
669        .into_iter()
670        .map(|e| e.schema.name)
671        .collect();
672    // Deliberately the UNFILTERED set plus the delegate meta-tool, not the
673    // model-visible list. A tool this project denies is hidden from the model
674    // above but stays registered here, so a model that names it anyway — from a
675    // stale transcript, a recalled memory, or a plain guess — is refused by the
676    // policy with "denied by project policy", the true reason, instead of by
677    // the validator with "unregistered tool", which would send it hunting for a
678    // spelling mistake that does not exist.
679    for def in all_defs.iter().chain(delegate_def.iter()) {
680        let name = def["name"].as_str().unwrap_or_default();
681        if name.is_empty() || builtin_names.contains(name) {
682            continue;
683        }
684        runtime
685            .register_tool_entry(ToolEntry::new(schema_from_def(def)).with_side_effects(true))
686            .await;
687    }
688
689    // Statically verify proposals before any action dispatches.
690    //
691    // Be honest about what this buys *here*. The assistant loop submits one
692    // action per proposal (`agent_loop::build_proposal`), and `validate_action`
693    // already checks tool existence and parameters before that action runs —
694    // with a stronger schema validator than car-verify's. So on this runtime the
695    // gate is close to inert: rejecting "the proposal" and rejecting "the one
696    // action" are the same thing.
697    //
698    // It is registered anyway for two reasons: the loop may batch actions in
699    // future, and a gate that is present everywhere proposals execute is easier
700    // to reason about than one that is conditionally absent. The surface where
701    // it actually earns its place is the daemon's `proposal.submit` runtime
702    // (`session::create_session`), which accepts caller-authored multi-action
703    // proposals — there, refusing up front prevents partial execution.
704    //
705    // Registered after tool registration only for readability; the gate holds
706    // the live registry, so tools added later are still checked.
707    runtime
708        .register_admission_gate(Arc::new(car_engine::StaticVerificationGate::new(
709            runtime.tools.clone(),
710        )))
711        .await;
712
713    Ok(AssistantRuntime {
714        runtime,
715        tools,
716        description,
717        identity: car_identity::IdentityStore::from_home().load_or_default(),
718        sandboxed,
719        gated_tools,
720        proactive_memory: mem,
721        tool_memory,
722        fallback_notice,
723        todos,
724        browser: browser_tools,
725    })
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use car_eventlog::EventKind;
732    use car_ir::{Action, ActionProposal, ActionStatus, ActionType};
733    use std::collections::HashMap;
734
735    use serde_json::json;
736
737    struct StaticDeviceProvider(Value);
738
739    #[async_trait]
740    impl DeviceProvider for StaticDeviceProvider {
741        async fn devices(&self) -> Result<Value, String> {
742            Ok(self.0.clone())
743        }
744
745        async fn notify_device(
746            &self,
747            device_id: Option<String>,
748            title: String,
749            body: String,
750        ) -> Result<Value, String> {
751            Ok(json!({
752                "device_id": device_id,
753                "title": title,
754                "body": body
755            }))
756        }
757    }
758
759    fn test_engine(root: &std::path::Path) -> Arc<InferenceEngine> {
760        let mut cfg = car_inference::InferenceConfig::default();
761        cfg.models_dir = root.join("models");
762        Arc::new(InferenceEngine::new(cfg))
763    }
764
765    fn test_env(root: &std::path::Path) -> BoundEnvironment {
766        BoundEnvironment {
767            substrate: Arc::new(car_engine::LocalSubstrate::new()),
768            root: root.to_path_buf(),
769            tier: PermissionTier::ReadOnly,
770            description: "test local host".to_string(),
771            sandboxed: false,
772            project_car_dir: None,
773            mount: None,
774            fallback_notice: None,
775            clamp_reads: false,
776        }
777    }
778
779    fn test_action(tool: &str) -> Action {
780        {
781            let mut a = Action::new(ActionType::ToolCall);
782            a.id = uuid::Uuid::new_v4().simple().to_string()[..12].to_string();
783            a.tool = Some(tool.to_string());
784            a
785        }
786    }
787
788    fn test_proposal(actions: Vec<Action>) -> ActionProposal {
789        ActionProposal {
790            id: "assistant-test-proposal".to_string(),
791            source: "assistant-test".to_string(),
792            actions,
793            timestamp: chrono::Utc::now(),
794            context: HashMap::new(),
795        }
796    }
797
798    #[test]
799    fn tier_gating_derives_from_self_declared_tier() {
800        let tools = vec![
801            json!({"name": "read_file"}), // no tier → never gated
802            json!({"name": "generate_image", "tier": "sandbox_edit"}),
803            json!({"name": "web_search", "tier": "full_access"}),
804            json!({"name": "run_applescript", "tier": "full_access"}),
805        ];
806
807        // A ReadOnly session gates BOTH the sandbox_edit and full_access tools.
808        let g = tier_gated_tool_names(&tools, PermissionTier::ReadOnly);
809        assert!(g.contains(&"generate_image".to_string()));
810        assert!(g.contains(&"web_search".to_string()));
811        assert!(g.contains(&"run_applescript".to_string()));
812        assert!(!g.contains(&"read_file".to_string()));
813
814        // A SandboxEdit session gates only the full_access tool.
815        let g = tier_gated_tool_names(&tools, PermissionTier::SandboxEdit);
816        assert_eq!(
817            g,
818            vec!["web_search".to_string(), "run_applescript".to_string()]
819        );
820
821        // A FullAccess session gates nothing by tier.
822        assert!(tier_gated_tool_names(&tools, PermissionTier::FullAccess).is_empty());
823    }
824
825    /// `delegate` is advertised by the assembled runtime, built over the
826    /// executor's own tool set, and never tier-gated (the CHILD's calls are
827    /// what the gates see).
828    #[tokio::test]
829    async fn assistant_runtime_advertises_delegate_over_its_own_tools() {
830        let dir = tempfile::tempdir().unwrap();
831        let rt = build_assistant_runtime(
832            test_engine(dir.path()),
833            test_env(dir.path()),
834            None,
835            None,
836            None,
837            None,
838            true,
839        )
840        .await
841        .unwrap();
842        let names: Vec<&str> = rt
843            .tools
844            .iter()
845            .filter_map(|d| d.get("name").and_then(Value::as_str))
846            .collect();
847        assert!(names.contains(&agent_loop::DELEGATE_TOOL), "{names:?}");
848        assert_eq!(
849            names
850                .iter()
851                .filter(|n| **n == agent_loop::DELEGATE_TOOL)
852                .count(),
853            1,
854            "advertised once"
855        );
856        let def = rt
857            .tools
858            .iter()
859            .find(|d| d["name"] == agent_loop::DELEGATE_TOOL)
860            .unwrap();
861        let granted: Vec<&str> = def["parameters"]["properties"]["tools"]["items"]["enum"]
862            .as_array()
863            .unwrap()
864            .iter()
865            .filter_map(Value::as_str)
866            .collect();
867        let others: Vec<&str> = names
868            .iter()
869            .copied()
870            .filter(|n| *n != agent_loop::DELEGATE_TOOL)
871            .collect();
872        assert_eq!(granted, others, "the enum names exactly the parent's tools");
873        assert!(
874            !rt.gated_tools
875                .iter()
876                .any(|g| g == agent_loop::DELEGATE_TOOL),
877            "read_only tier: never gated by tier; got {:?}",
878            rt.gated_tools
879        );
880    }
881
882    /// Surfaces that do not opt in never see the def — not in `tools`, so
883    /// not in the prompt and not in the validator either.
884    #[tokio::test]
885    async fn assistant_runtime_hides_delegate_unless_allowed() {
886        let dir = tempfile::tempdir().unwrap();
887        let rt = build_assistant_runtime(
888            test_engine(dir.path()),
889            test_env(dir.path()),
890            None,
891            None,
892            None,
893            None,
894            false,
895        )
896        .await
897        .unwrap();
898        assert!(
899            !rt.tools
900                .iter()
901                .any(|d| d["name"] == agent_loop::DELEGATE_TOOL),
902            "delegate must not be advertised without opt-in"
903        );
904        assert!(!rt
905            .gated_tools
906            .iter()
907            .any(|g| g == agent_loop::DELEGATE_TOOL));
908    }
909
910    #[tokio::test]
911    async fn assistant_runtime_installs_information_flow_gate_by_default() {
912        let dir = tempfile::tempdir().unwrap();
913        let engine = test_engine(dir.path());
914        let env = test_env(dir.path());
915
916        let rt = build_assistant_runtime(engine, env, None, None, None, None, false)
917            .await
918            .unwrap();
919        let gates = rt.runtime.admission_gate_names().await;
920        assert!(
921            gates.contains(&"information_flow".to_string()),
922            "the information-flow gate must be installed by default, got {gates:?}"
923        );
924        assert!(
925            gates.contains(&"static_verification".to_string()),
926            "the static-verification gate must be installed by default, got {gates:?}"
927        );
928    }
929
930    #[tokio::test]
931    async fn assistant_runtime_appends_local_workspace_snapshot() {
932        // F7/L1: a local (non-sandboxed) session gets a names-only workspace
933        // snapshot appended after the one-line environment sentence.
934        let dir = tempfile::tempdir().unwrap();
935        std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
936        std::fs::create_dir_all(dir.path().join("src")).unwrap();
937        std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
938
939        let rt = build_assistant_runtime(
940            test_engine(dir.path()),
941            test_env(dir.path()),
942            None,
943            None,
944            None,
945            None,
946            false,
947        )
948        .await
949        .unwrap();
950
951        assert!(
952            rt.description.contains("test local host"),
953            "keeps the one-line environment sentence"
954        );
955        assert!(
956            rt.description.contains("Workspace contents"),
957            "appends the names-only snapshot on a local session: {}",
958            rt.description
959        );
960        assert!(rt.description.contains("Cargo.toml"));
961        assert!(rt.description.contains("main.rs"));
962    }
963
964    /// The shell fact has to reach the REAL prompt, not just exist as a helper.
965    /// Everything about the fix depends on one condition firing
966    /// (`!sandboxed && substrate.is_local()`), so assert it end-to-end through
967    /// `build_assistant_runtime` rather than unit-testing `host_shell_note` in
968    /// isolation, which would pass even if the note were never appended.
969    #[tokio::test]
970    async fn assistant_runtime_names_the_host_shell_on_a_local_session() {
971        let dir = tempfile::tempdir().unwrap();
972        let rt = build_assistant_runtime(
973            test_engine(dir.path()),
974            test_env(dir.path()),
975            None,
976            None,
977            None,
978            None,
979            false,
980        )
981        .await
982        .unwrap();
983
984        assert!(
985            rt.description.contains("Host platform:"),
986            "a local session must be told which host it is on: {}",
987            rt.description
988        );
989        if cfg!(windows) {
990            assert!(
991                rt.description.contains("cmd /C") && rt.description.contains("findstr"),
992                "Windows must get cmd.exe and its substitutions, not `sh -c`: {}",
993                rt.description
994            );
995            assert!(
996                !rt.description.contains("through `sh -c`"),
997                "Windows must not be told it has a POSIX shell: {}",
998                rt.description
999            );
1000        } else {
1001            assert!(
1002                rt.description.contains("`sh -c`"),
1003                "unix keeps its existing wording: {}",
1004                rt.description
1005            );
1006        }
1007    }
1008
1009    #[tokio::test]
1010    async fn assistant_runtime_omits_snapshot_when_sandboxed() {
1011        // F7/L1: a sandboxed (or remote) session must NOT get the local-fs
1012        // snapshot — computing it would touch the container/VM at prompt-build
1013        // time. Only the one-line environment sentence remains.
1014        let dir = tempfile::tempdir().unwrap();
1015        std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
1016        let env = BoundEnvironment {
1017            substrate: Arc::new(car_engine::LocalSubstrate::new()),
1018            root: dir.path().to_path_buf(),
1019            tier: PermissionTier::SandboxEdit,
1020            description: "an isolated Docker sandbox".to_string(),
1021            sandboxed: true,
1022            project_car_dir: None,
1023            mount: None,
1024            fallback_notice: None,
1025            clamp_reads: false,
1026        };
1027        let rt =
1028            build_assistant_runtime(test_engine(dir.path()), env, None, None, None, None, false)
1029                .await
1030                .unwrap();
1031        assert!(rt.description.contains("isolated Docker sandbox"));
1032        assert!(
1033            !rt.description.contains("Workspace contents"),
1034            "no snapshot for a sandboxed session: {}",
1035            rt.description
1036        );
1037        assert!(!rt.description.contains("Cargo.toml"));
1038    }
1039
1040    #[tokio::test]
1041    async fn assistant_runtime_gates_external_and_persistent_sinks_by_default() {
1042        let dir = tempfile::tempdir().unwrap();
1043        let rt = build_assistant_runtime(
1044            test_engine(dir.path()),
1045            test_env(dir.path()),
1046            None,
1047            None,
1048            None,
1049            None,
1050            false,
1051        )
1052        .await
1053        .unwrap();
1054
1055        assert!(rt.gated_tools.contains(&"http_request".to_string()));
1056        assert!(rt.gated_tools.contains(&"web_search".to_string()));
1057        assert!(rt.gated_tools.contains(&"remember".to_string()));
1058    }
1059
1060    #[tokio::test]
1061    async fn assistant_runtime_can_see_linked_devices_when_provider_supplied() {
1062        let dir = tempfile::tempdir().unwrap();
1063        let provider: Arc<dyn DeviceProvider> = Arc::new(StaticDeviceProvider(json!([
1064            {
1065                "name": "Mia's iPhone",
1066                "platform": "ios",
1067                "status": "online",
1068                "capabilities": ["assistant.chat", "assistant.approvals"]
1069            }
1070        ])));
1071        let rt = build_assistant_runtime(
1072            test_engine(dir.path()),
1073            test_env(dir.path()),
1074            None,
1075            Some(provider),
1076            None,
1077            None,
1078            false,
1079        )
1080        .await
1081        .unwrap();
1082
1083        assert!(rt
1084            .tools
1085            .iter()
1086            .any(|def| def["name"].as_str() == Some("linked_devices")));
1087        assert!(rt
1088            .tools
1089            .iter()
1090            .any(|def| def["name"].as_str() == Some("notify_linked_device")));
1091        let result = rt
1092            .runtime
1093            .execute(&test_proposal(vec![test_action("linked_devices")]))
1094            .await;
1095        assert_eq!(result.results[0].status, ActionStatus::Succeeded);
1096        assert_eq!(
1097            result.results[0].output.as_ref().unwrap()[0]["platform"],
1098            "ios"
1099        );
1100    }
1101
1102    #[tokio::test]
1103    async fn malformed_assistant_tool_labels_still_install_builtin_flow_gate() {
1104        let dir = tempfile::tempdir().unwrap();
1105        std::fs::create_dir_all(dir.path().join(".car")).unwrap();
1106        std::fs::write(dir.path().join(".car/tool-labels.json"), "{not json").unwrap();
1107        let engine = test_engine(dir.path());
1108        let env = test_env(dir.path());
1109
1110        let rt = build_assistant_runtime(engine, env, None, None, None, None, false)
1111            .await
1112            .unwrap();
1113        let gates = rt.runtime.admission_gate_names().await;
1114        assert!(
1115            gates.contains(&"information_flow".to_string()),
1116            "the information-flow gate must be installed by default, got {gates:?}"
1117        );
1118        assert!(
1119            gates.contains(&"static_verification".to_string()),
1120            "the static-verification gate must be installed by default, got {gates:?}"
1121        );
1122    }
1123
1124    /// The sibling of the test above, and deliberately the OPPOSITE verdict:
1125    /// tool labels degrade to a safe default, policy rules have none, so a
1126    /// malformed `policies/*.toml` refuses to start.
1127    #[tokio::test]
1128    async fn a_malformed_project_policy_file_fails_the_assistant_startup() {
1129        let dir = tempfile::tempdir().unwrap();
1130        std::fs::create_dir_all(dir.path().join(".car/policies")).unwrap();
1131        std::fs::write(
1132            dir.path().join(".car/policies/broken.toml"),
1133            "[[deny_tool]\ntool = \"shell\"",
1134        )
1135        .unwrap();
1136
1137        let result = build_assistant_runtime(
1138            test_engine(dir.path()),
1139            test_env(dir.path()),
1140            None,
1141            None,
1142            None,
1143            None,
1144            false,
1145        )
1146        .await;
1147        let err = match result {
1148            Ok(_) => panic!("a malformed policy rule must not be silently dropped"),
1149            Err(e) => e,
1150        };
1151        assert!(err.contains("broken.toml"), "{err}");
1152    }
1153
1154    /// A project's blanket `deny_tool` removes the tool from the model's view.
1155    ///
1156    /// Asserted as a set difference against a run of the SAME fixture with no
1157    /// policy, so the test cannot pass vacuously: if `shell` were simply
1158    /// dropped from the catalog, or if the filter removed more than it should,
1159    /// the comparison fails. Checking only "shell is absent" would survive
1160    /// both.
1161    #[tokio::test]
1162    async fn project_deny_tool_hides_the_tool_from_the_model() {
1163        // Control: same fixture, no policy file.
1164        let open_dir = tempfile::tempdir().unwrap();
1165        let open = build_assistant_runtime(
1166            test_engine(open_dir.path()),
1167            test_env(open_dir.path()),
1168            None,
1169            None,
1170            None,
1171            None,
1172            true,
1173        )
1174        .await
1175        .unwrap();
1176        let advertised = |rt: &AssistantRuntime| -> std::collections::BTreeSet<String> {
1177            rt.tools
1178                .iter()
1179                .filter_map(|d| d.get("name").and_then(Value::as_str))
1180                .map(str::to_string)
1181                .collect()
1182        };
1183        let before = advertised(&open);
1184        assert!(
1185            before.contains("shell"),
1186            "control run must advertise the tool the next one denies; got {before:?}"
1187        );
1188
1189        let dir = tempfile::tempdir().unwrap();
1190        std::fs::create_dir_all(dir.path().join(".car/policies")).unwrap();
1191        std::fs::write(
1192            dir.path().join(".car/policies/deny.toml"),
1193            "deny_tool = [\"shell\"]\n",
1194        )
1195        .unwrap();
1196        let rt = build_assistant_runtime(
1197            test_engine(dir.path()),
1198            test_env(dir.path()),
1199            None,
1200            None,
1201            None,
1202            None,
1203            true,
1204        )
1205        .await
1206        .unwrap();
1207        let after = advertised(&rt);
1208
1209        let removed: Vec<&String> = before.difference(&after).collect();
1210        assert_eq!(
1211            removed,
1212            vec![&"shell".to_string()],
1213            "exactly the denied tool leaves the model's view"
1214        );
1215        assert!(
1216            after.difference(&before).next().is_none(),
1217            "the filter must not ADD anything"
1218        );
1219
1220        // The child cannot be granted what the project denies the parent — the
1221        // delegate enum is built over the filtered list, not the raw catalog.
1222        let delegate = rt
1223            .tools
1224            .iter()
1225            .find(|d| d["name"] == agent_loop::DELEGATE_TOOL)
1226            .expect("delegate advertised");
1227        let granted: Vec<&str> = delegate["parameters"]["properties"]["tools"]["items"]["enum"]
1228            .as_array()
1229            .unwrap()
1230            .iter()
1231            .filter_map(Value::as_str)
1232            .collect();
1233        assert!(
1234            !granted.contains(&"shell"),
1235            "delegate must not grant a denied tool: {granted:?}"
1236        );
1237
1238        // Hidden from the model, still REGISTERED with the validator. A model
1239        // that names it anyway (stale transcript, recalled memory, a guess) is
1240        // then refused by the policy with the true reason rather than by the
1241        // validator with "unregistered tool".
1242        assert!(
1243            rt.runtime.tools.read().await.contains_key("shell"),
1244            "denied tool stays registered so the refusal names the policy"
1245        );
1246    }
1247
1248    /// `deny_tool = ["delegate"]` must work, and it is the case the filter
1249    /// cannot reach on its own: `delegate` is appended after the filter, not
1250    /// drawn from the executor's defs.
1251    ///
1252    /// It is also the case where hiding is the ONLY enforcement. `agent_loop`
1253    /// intercepts the delegate call and dispatches it itself, so it never
1254    /// reaches the policy engine — advertise it and the deny is inert in both
1255    /// halves at once: not hidden, and not enforced either.
1256    #[tokio::test]
1257    async fn project_deny_tool_withholds_the_delegate_meta_tool() {
1258        let dir = tempfile::tempdir().unwrap();
1259        std::fs::create_dir_all(dir.path().join(".car/policies")).unwrap();
1260        std::fs::write(
1261            dir.path().join(".car/policies/deny.toml"),
1262            "deny_tool = [\"delegate\"]\n",
1263        )
1264        .unwrap();
1265        let rt = build_assistant_runtime(
1266            test_engine(dir.path()),
1267            test_env(dir.path()),
1268            None,
1269            None,
1270            None,
1271            None,
1272            // The caller DID opt into delegation; policy overrides the opt-in.
1273            true,
1274        )
1275        .await
1276        .unwrap();
1277
1278        assert!(
1279            !rt.tools
1280                .iter()
1281                .any(|d| d["name"] == agent_loop::DELEGATE_TOOL),
1282            "a denied delegate must not be advertised even when the surface opts in"
1283        );
1284        // Still registered, so the model naming it anyway is refused by the
1285        // policy rather than by the validator.
1286        assert!(
1287            rt.runtime
1288                .tools
1289                .read()
1290                .await
1291                .contains_key(agent_loop::DELEGATE_TOOL),
1292            "the denied delegate stays registered so the refusal names the policy"
1293        );
1294    }
1295
1296    /// A project with no `.car/policies` at all starts normally — the common
1297    /// case must not pay for the strictness above.
1298    #[tokio::test]
1299    async fn a_project_without_policies_starts_normally() {
1300        let dir = tempfile::tempdir().unwrap();
1301        build_assistant_runtime(
1302            test_engine(dir.path()),
1303            test_env(dir.path()),
1304            None,
1305            None,
1306            None,
1307            None,
1308            false,
1309        )
1310        .await
1311        .expect("no policies directory must be a no-op");
1312    }
1313
1314    /// `messaging.send` is executable on the assistant runtime: the sink is
1315    /// attached, so the schema is registered (`with_message_sink` registers
1316    /// both together or neither).
1317    #[tokio::test]
1318    async fn assistant_runtime_has_the_messaging_send_tool() {
1319        let dir = tempfile::tempdir().unwrap();
1320        let rt = build_assistant_runtime(
1321            test_engine(dir.path()),
1322            test_env(dir.path()),
1323            None,
1324            None,
1325            None,
1326            None,
1327            false,
1328        )
1329        .await
1330        .unwrap();
1331
1332        assert!(
1333            rt.runtime.tools.read().await.contains_key("messaging.send"),
1334            "the outbound sink must make messaging.send a real tool"
1335        );
1336    }
1337
1338    #[tokio::test]
1339    async fn assistant_runtime_rejects_confidential_data_to_web_search() {
1340        let dir = tempfile::tempdir().unwrap();
1341        std::fs::create_dir_all(dir.path().join(".car")).unwrap();
1342        std::fs::write(
1343            dir.path().join(".car/tool-labels.json"),
1344            r#"{"labels":{"read_file":{"capability":"fs_read","confidentiality":"secret"}}}"#,
1345        )
1346        .unwrap();
1347        let rt = build_assistant_runtime(
1348            test_engine(dir.path()),
1349            test_env(dir.path()),
1350            None,
1351            None,
1352            None,
1353            None,
1354            false,
1355        )
1356        .await
1357        .unwrap();
1358
1359        let mut read = test_action("read_file");
1360        read.expected_effects = [("file_data".to_string(), json!(true))].into();
1361        let mut search = test_action("web_search");
1362        search.state_dependencies = vec!["file_data".to_string()];
1363
1364        let result = rt.runtime.execute(&test_proposal(vec![read, search])).await;
1365
1366        assert!(result
1367            .results
1368            .iter()
1369            .all(|r| r.status == ActionStatus::Rejected));
1370        let log = rt.runtime.log.lock().await;
1371        assert!(log.events().iter().any(|e| {
1372            e.kind == EventKind::AdmissionGateDecision
1373                && e.data.get("gate").and_then(|v| v.as_str()) == Some("information_flow")
1374                && e.data.get("decision").and_then(|v| v.as_str()) == Some("reject")
1375        }));
1376    }
1377
1378    #[tokio::test]
1379    async fn assistant_runtime_rejects_recalled_memory_to_web_search_by_default() {
1380        let dir = tempfile::tempdir().unwrap();
1381        let rt = build_assistant_runtime(
1382            test_engine(dir.path()),
1383            test_env(dir.path()),
1384            None,
1385            None,
1386            None,
1387            None,
1388            false,
1389        )
1390        .await
1391        .unwrap();
1392
1393        let mut recall = test_action("recall");
1394        recall.expected_effects = [("memory_context".to_string(), json!(true))].into();
1395        let mut search = test_action("web_search");
1396        search.state_dependencies = vec!["memory_context".to_string()];
1397
1398        let result = rt
1399            .runtime
1400            .execute(&test_proposal(vec![recall, search]))
1401            .await;
1402
1403        assert!(result
1404            .results
1405            .iter()
1406            .all(|r| r.status == ActionStatus::Rejected));
1407        let log = rt.runtime.log.lock().await;
1408        assert!(log.events().iter().any(|e| {
1409            e.kind == EventKind::AdmissionGateDecision
1410                && e.data.get("gate").and_then(|v| v.as_str()) == Some("information_flow")
1411                && e.data.get("decision").and_then(|v| v.as_str()) == Some("reject")
1412        }));
1413    }
1414}