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}