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