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