car_server_core/coder/shell_tool.rs
1//! `WorktreeExecutor` — the coder's host-side tool executor.
2//!
3//! Wraps `car_engine::agent_basics` file tools plus a new host `shell` tool,
4//! with three hard guarantees enforced in code (not just policy):
5//!
6//! 1. **Pinned cwd** — shell commands always run at the worktree root; there
7//! is no cwd parameter. Relative file-tool paths are rooted there too, and
8//! clamped against lexical escape.
9//! 2. **Bounded output** — combined output is capped (tail-kept) so a noisy
10//! build can't flood the conversation or the event stream.
11//! 3. **Bounded time** — wall-clock timeout per command; on expiry the whole
12//! process group is killed (Unix), not just the shell.
13//!
14//! Every call is checked by the coder [`InspectorChain`] first; first Deny
15//! wins and the denial reason is the tool error the model sees.
16
17use std::collections::BTreeSet;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::time::Duration;
21
22use async_trait::async_trait;
23use car_engine::{agent_basics, CommandOutput, LocalSubstrate, Substrate, ToolExecutor};
24use car_policy::InspectorChain;
25use serde_json::{json, Value};
26
27use super::policy::{
28 coder_inspector_chain, coder_inspector_chain_with_project_policies, stays_under,
29};
30
31/// Default and ceiling for per-command wall-clock timeouts.
32pub(crate) const DEFAULT_SHELL_TIMEOUT_SECS: u64 = 120;
33pub(crate) const MAX_SHELL_TIMEOUT_SECS: u64 = 600;
34
35/// Description of the coder `run_shell` tool's `command` parameter. Same reason
36/// as the assistant's: the local leg of [`run_shell_on`] is `cmd /C` on Windows,
37/// and telling the model otherwise makes it author POSIX the shell cannot run.
38#[cfg(windows)]
39const SHELL_COMMAND_PARAM_DESC: &str = concat!(
40 "Command executed via `cmd /C` at the repository root on this Windows host ",
41 "— cmd.exe, not a POSIX shell (no ls/grep/cat/tail/rm, no $(...))."
42);
43#[cfg(not(windows))]
44const SHELL_COMMAND_PARAM_DESC: &str = "Command executed via sh -c at the repository root";
45/// Combined stdout+stderr cap (tail kept).
46pub(crate) const MAX_OUTPUT_BYTES: usize = 64 * 1024;
47
48/// Keep the last `cap` bytes of `s`, on a char boundary, with a marker when
49/// truncated.
50pub(crate) fn tail(s: &str, cap: usize) -> String {
51 if s.len() <= cap {
52 return s.to_string();
53 }
54 let mut start = s.len() - cap;
55 while !s.is_char_boundary(start) {
56 start += 1;
57 }
58 format!("…[truncated]…{}", &s[start..])
59}
60
61/// Fold a `{stdout, stderr, exit_code}` triple into the coder/assistant shell
62/// result shape `{exit_code, output, timed_out}`, with stderr appended after
63/// stdout and the combined text tail-capped.
64fn shell_result(stdout: &str, stderr: &str, exit_code: i32) -> Value {
65 let mut combined = stdout.to_string();
66 if !stderr.is_empty() {
67 if !combined.is_empty() && !combined.ends_with('\n') {
68 combined.push('\n');
69 }
70 combined.push_str(stderr);
71 }
72 json!({
73 "exit_code": exit_code,
74 "output": tail(&combined, MAX_OUTPUT_BYTES),
75 "timed_out": false,
76 })
77}
78
79/// Root relative `path` params at `root` and reject write escapes outside it —
80/// the one shared implementation behind the coder's `WorktreeExecutor` and the
81/// assistant's `GeneralExecutor` (which layers its own `clamp` opt-out on top).
82/// `root_noun` names the boundary in the escape error ("worktree" vs "working
83/// directory"). Reads may roam for context gathering; only `write_file`/
84/// `edit_file` are pinned inside `root`, and relative paths are always
85/// resolved against `root` (never the daemon cwd).
86/// `clamp_reads` additionally pins the READ tools (`read_file`, `list_dir`,
87/// `find_files`, `grep_files`) inside `root`. Off by default because the
88/// general assistant is legitimately allowed to read the wider filesystem; the
89/// `coder.discuss` surface turns it on, since a conversation whose whole
90/// premise is "grounded in THIS repo" has no business reading outside it, and
91/// leaving reads open there is an exfiltration path — a prompt-injected repo
92/// file can ask for `grep_files {"path":"/Users/<user>","pattern":"sk-ant-"}`
93/// and the hits stream to every `coder.discuss.event` subscriber.
94pub(crate) fn clamp_paths_to(
95 root: &std::path::Path,
96 tool: &str,
97 params: &Value,
98 root_noun: &str,
99 clamp_reads: bool,
100) -> Result<Value, String> {
101 let mut params = params.clone();
102 let Some(obj) = params.as_object_mut() else {
103 return Ok(params);
104 };
105 if let Some(Value::String(p)) = obj.get("path") {
106 let pinned = matches!(tool, "write_file" | "edit_file")
107 || (clamp_reads
108 && matches!(tool, "read_file" | "list_dir" | "find_files" | "grep_files"));
109 if !stays_under(root, p) && pinned {
110 return Err(format!("path '{p}' resolves outside the {root_noun}"));
111 }
112 if Path::new(p).is_relative() {
113 let abs = root.join(p);
114 obj.insert("path".into(), json!(abs.to_string_lossy()));
115 }
116 } else if matches!(tool, "list_dir" | "find_files" | "grep_files") {
117 obj.entry("path")
118 .or_insert_with(|| json!(root.to_string_lossy()));
119 }
120 Ok(params)
121}
122
123/// Single-quote a string for POSIX `sh`: wrap in `'…'` and escape embedded
124/// quotes as `'\''`. A PATH can contain spaces and (rarely) quotes.
125fn sh_single_quote(s: &str) -> String {
126 format!("'{}'", s.replace('\'', r"'\''"))
127}
128
129/// Remove every way a child shell could authenticate to a forge.
130///
131/// Three separate mechanisms, because closing one leaves the others open:
132///
133/// 1. **`gh`'s environment tokens.** `GH_TOKEN` / `GITHUB_TOKEN` and their
134/// enterprise spellings are read before any config file.
135/// 2. **`gh`'s config file.** With the env tokens gone, `gh` falls back to
136/// `~/.config/gh/hosts.yml`, so `GH_CONFIG_DIR` is pointed at an empty
137/// directory. The child can write into that directory, which does not help:
138/// a `hosts.yml` still needs a token it no longer has.
139/// 3. **git's credential helper.** This one is easy to miss and would have left
140/// the hole wide open — `git push` over HTTPS does not read `GH_TOKEN` at
141/// all, it asks the configured helper (`osxkeychain` on a Mac), which is
142/// perfectly happy to hand over a stored credential. `GIT_CONFIG_COUNT` and
143/// friends override `credential.helper` to empty for this child only, which
144/// resets the helper list without touching the operator's `~/.gitconfig`.
145///
146/// `GIT_TERMINAL_PROMPT=0` so a de-credentialed push fails immediately instead
147/// of blocking on a prompt no one can answer.
148///
149/// [`super::merge`] is host code: it builds its own `gh`/`git` argv outside this
150/// function and keeps the real credential. That asymmetry is the whole design —
151/// the runtime publishes, the model cannot.
152fn withhold_forge_credentials(cmd: &mut tokio::process::Command) {
153 for var in [
154 "GH_TOKEN",
155 "GITHUB_TOKEN",
156 "GH_ENTERPRISE_TOKEN",
157 "GITHUB_ENTERPRISE_TOKEN",
158 ] {
159 cmd.env_remove(var);
160 }
161 cmd.env("GH_CONFIG_DIR", empty_config_dir());
162 cmd.env("GIT_CONFIG_COUNT", "1")
163 .env("GIT_CONFIG_KEY_0", "credential.helper")
164 .env("GIT_CONFIG_VALUE_0", "")
165 .env("GIT_TERMINAL_PROMPT", "0")
166 .env("GIT_ASKPASS", "")
167 .env("SSH_ASKPASS", "")
168 .env("SSH_ASKPASS_REQUIRE", "never");
169}
170
171/// Keep the runtime's own verification from writing files into the diff it is
172/// about to deliver.
173///
174/// Running a check is not supposed to change the change. Python writes
175/// `__pycache__/*.pyc` beside every module it imports, so a contract check of
176/// `python3 -m pytest` generated bytecode in the worktree — and `stage_and_diff`
177/// stages with `git add -A`, so those files went into the delivered commit.
178///
179/// Found by a live review panel, on the first run where real models saw a real
180/// diff: all three approved the code change and refused the pull request over
181/// the committed bytecode. They were right, and the contract could not have
182/// caught it — the tests passed either way. A repository with a `.gitignore`
183/// hides this; one without it silently receives build artifacts from every
184/// session.
185///
186/// `PYTHONDONTWRITEBYTECODE` is the whole fix for Python: bytecode caching is a
187/// warm-start optimisation and a one-shot check has no warm start to lose.
188fn suppress_incidental_artifacts(cmd: &mut tokio::process::Command) {
189 cmd.env("PYTHONDONTWRITEBYTECODE", "1");
190}
191
192/// A directory that exists and holds no forge configuration.
193///
194/// Under the CAR state root rather than a fresh temp dir per call: this is read
195/// on every shell invocation, and a per-call temp directory would be both waste
196/// and litter. Falls back to the OS temp dir if the state root cannot be
197/// created — an unwritable path would make `gh` fall back to the real config,
198/// which is the one outcome to avoid.
199fn empty_config_dir() -> std::path::PathBuf {
200 let dir = car_home::root_or_relative()
201 .join("run")
202 .join("no-forge-config");
203 if std::fs::create_dir_all(&dir).is_ok() {
204 return dir;
205 }
206 let fallback = std::env::temp_dir().join("car-no-forge-config");
207 let _ = std::fs::create_dir_all(&fallback);
208 fallback
209}
210
211/// Prefix `command` with an `export PATH=<inherited>:$PATH` so the PATH this
212/// process was started with survives a login shell's profile.
213///
214/// macOS `path_helper` reorders PATH (see the call site); re-exporting ours in
215/// front restores the operator's precedence while leaving the profile's own
216/// additions on the tail. Returns `command` unchanged when PATH is unset/empty,
217/// and on Linux this is a harmless no-op reassertion of the same value.
218fn prepend_inherited_path(command: &str) -> String {
219 match std::env::var("PATH") {
220 Ok(p) if !p.trim().is_empty() => {
221 format!("export PATH={}:\"$PATH\"; {}", sh_single_quote(&p), command)
222 }
223 _ => command.to_string(),
224 }
225}
226
227/// Run `command` against `substrate`, inspector-gated, bounded in time and
228/// output — the one shared shell implementation behind both the coder's
229/// `WorktreeExecutor` and the assistant's `GeneralExecutor`.
230///
231/// Returns `{exit_code, output, timed_out}`; a non-zero exit is a value, not an
232/// error, so the model can read it. `cwd` pins the working directory on the
233/// **local** path (ignored by non-local substrates, which carry their own root
234/// — e.g. the Docker sandbox mount or the VM bridge). On the local path a
235/// timeout kills the whole process group (Unix); non-local substrates enforce
236/// their own command timeout via [`Substrate::run_command`].
237///
238/// `max_timeout_secs` is the ceiling `timeout_secs` is clamped to. Every
239/// MODEL-facing caller passes [`MAX_SHELL_TIMEOUT_SECS`], which is what the
240/// advertised tool description promises; the outcome-contract path passes the
241/// operator's own ceiling instead, because a slow test gate is the operator's
242/// decision about their repository, not a licence for the model to run one
243/// command for an hour (car#1065).
244/// Whether a shell child inherits the daemon's forge credentials.
245///
246/// The deny-list in [`super::policy`] is hardening, not a sandbox: it reads the
247/// verb of each segment and then hands that segment to `/bin/sh`, so `sh -c`,
248/// `env`, `timeout`, `$(…)`, a `\gh` escape, or a delegated `car do "…push it"`
249/// all move or bypass the verb. No finite set of patterns enforces an any-route
250/// property against an unrestricted shell (car#1076 documents the residue).
251///
252/// Credential separation does not have that shape. A shell holding no forge
253/// credential cannot publish however the command is spelled, because every
254/// route fails on **authentication** rather than on being recognised.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub(crate) enum ForgeCredentials {
257 /// The child keeps the forge credential. For contract checks, whose command
258 /// the CONTRACT declares rather than the model.
259 ///
260 /// The name is exact: the difference between the two variants is the FORGE
261 /// credential, not the environment. [`Withhold`](Self::Withhold) removes
262 /// four `GH_*`/`GITHUB_*` token variables and neutralizes the gh/git/ssh
263 /// credential helpers; it does not clear anything else, so `$DATABASE_URL`
264 /// and friends reach both paths alike.
265 ///
266 /// Keeping the credential is also not permission to use one: a check runs
267 /// the SAME inspector chain as the model's shell, so `DenyCredentialAccess`
268 /// refuses the command on its text. That is substring hardening rather than
269 /// a boundary — an unmarked spelling still gets through to the inherited
270 /// environment. Both halves are stated beside the contract input in
271 /// `docs/car-code-task.md` (car#1066).
272 Inherit,
273 /// The child gets none. For the model's own shell.
274 Withhold,
275}
276
277#[cfg(unix)]
278struct ProcessGroupGuard {
279 pgid: i32,
280 armed: bool,
281}
282
283#[cfg(unix)]
284impl ProcessGroupGuard {
285 fn new(pgid: u32) -> Self {
286 Self {
287 pgid: pgid as i32,
288 armed: true,
289 }
290 }
291
292 /// Sweep every process the shell left in its private group.
293 ///
294 /// The direct shell is reaped by `wait_with_output`; detached grandchildren
295 /// are not. They keep the shell's group after being reparented, so `killpg`
296 /// remains the one handle that covers both the ordinary return path and a
297 /// partially-unwound command.
298 fn terminate(&mut self) {
299 if self.armed {
300 unsafe {
301 libc::killpg(self.pgid, libc::SIGKILL);
302 }
303 self.armed = false;
304 }
305 }
306}
307
308#[cfg(unix)]
309impl Drop for ProcessGroupGuard {
310 fn drop(&mut self) {
311 // A future can be dropped by Ctrl-C/SIGTERM cancellation or unwinding
312 // before `run_shell_on` reaches its ordinary cleanup. `kill_on_drop`
313 // only owns the direct shell, so the group guard is the descendant
314 // backstop for those paths.
315 self.terminate();
316 }
317}
318
319pub(crate) async fn run_shell_on(
320 substrate: &Arc<dyn Substrate>,
321 cwd: Option<&Path>,
322 inspectors: &InspectorChain,
323 command: &str,
324 timeout_secs: Option<u64>,
325 max_timeout_secs: u64,
326 forge_credentials: ForgeCredentials,
327) -> Result<Value, String> {
328 if let Some(reason) = inspectors.check("shell", &json!({ "command": command })) {
329 return Err(format!("denied by policy: {reason}"));
330 }
331 let secs = timeout_secs
332 .unwrap_or(DEFAULT_SHELL_TIMEOUT_SECS)
333 .clamp(1, max_timeout_secs.max(1));
334
335 // Non-local substrates (Docker sandbox, VM-over-MCP) own their own
336 // isolation and cwd — route straight through their `run_command`, which
337 // enforces the timeout itself.
338 if !substrate.is_local() {
339 let CommandOutput {
340 stdout,
341 stderr,
342 exit_code,
343 } = substrate.run_command(command, Some(secs as f64)).await?;
344 return Ok(shell_result(&stdout, &stderr, exit_code));
345 }
346
347 // Local path: `sh -lc` (Unix) / `cmd /C` (Windows) in a fresh process group
348 // so a timeout can sweep the whole tree, not just the shell.
349 let timeout = Duration::from_secs(secs);
350 let mut cmd = if cfg!(target_os = "windows") {
351 let mut c = tokio::process::Command::new("cmd");
352 c.arg("/C").arg(command);
353 // cmd.exe silently DROPS any env var over ~8191 chars. When that var is
354 // PATH the model's shell loses its entire toolchain — `cargo`/`git`/`npm`
355 // come back "is not recognized" — and the coder misreads the red checks as
356 // its own broken code. See car_engine::win_env; `None` = inherit unchanged.
357 if let Some(path) = car_engine::win_env::cmd_path_override() {
358 c.env("PATH", path);
359 }
360 c
361 } else {
362 let mut c = tokio::process::Command::new("/bin/sh");
363 // `-l` loads the user's profile so the agent inherits their toolchain
364 // (nvm, rbenv, pyenv…). But on macOS `/etc/profile` runs `path_helper`,
365 // which REBUILDS PATH with the system dirs first and merely appends
366 // whatever this process inherited — silently demoting a PATH the operator
367 // set *for the daemon* below `/usr/local/bin`. A stale system binary then
368 // shadows the intended one, and the coder misreads the resulting red
369 // check as its own broken code.
370 //
371 // This is the macOS twin of the Windows bug `car_engine::win_env` fixes
372 // (cmd drops an over-long PATH, emptying the agent's shell). Surfaced by
373 // the coder A/B: a daemon started with a venv first on PATH still had the
374 // venv at position 16 inside the agent's shell, so `pip` resolved to
375 // `/usr/local/bin/pip`, whose `#!/usr/bin/python` shebang no longer
376 // exists on modern macOS. Every `pip` step in a derived contract then
377 // failed forever — sinking sessions whose real work had already passed.
378 //
379 // Re-assert the inherited PATH *after* the profile has loaded, so the
380 // operator's entries win while the profile's additions remain reachable.
381 c.arg("-lc").arg(prepend_inherited_path(command));
382 c
383 };
384 // UNCONDITIONAL, and deliberately not folded into
385 // `withhold_forge_credentials`: that one is gated on the caller's credential
386 // policy, and whether the runtime's own checks litter the worktree has
387 // nothing to do with whether the model may reach a forge token. Hanging it
388 // off that gate left the litter in place on every path that keeps
389 // credentials — which is how the first attempt at this fix silently did
390 // nothing.
391 suppress_incidental_artifacts(&mut cmd);
392 if forge_credentials == ForgeCredentials::Withhold {
393 withhold_forge_credentials(&mut cmd);
394 }
395 cmd.stdin(std::process::Stdio::null())
396 .stdout(std::process::Stdio::piped())
397 .stderr(std::process::Stdio::piped())
398 .kill_on_drop(true);
399 if let Some(dir) = cwd {
400 cmd.current_dir(dir);
401 }
402 #[cfg(unix)]
403 cmd.process_group(0);
404
405 let child = cmd
406 .spawn()
407 .map_err(|e| format!("failed to spawn shell: {e}"))?;
408 #[cfg(unix)]
409 let mut process_group = child.id().map(ProcessGroupGuard::new);
410
411 // Windows has no process groups; assign the shell to a Job Object so a
412 // timeout can atomically kill the whole tree (cmd.exe + everything it
413 // spawns), not just cmd.exe. `kill_on_drop` only reaps the direct child,
414 // orphaning grandchildren of a timed-out build. Best-effort: if the job
415 // can't be created/assigned we fall back to `kill_on_drop`. The
416 // KILL_ON_JOB_CLOSE flag also sweeps any stragglers when the job drops at
417 // the end of this call.
418 #[cfg(windows)]
419 let job = match car_registry::supervisor::JobObject::new() {
420 Ok(j) => {
421 if let Some(pid) = child.id() {
422 let _ = j.assign(pid);
423 }
424 Some(j)
425 }
426 Err(_) => None,
427 };
428
429 let result = match tokio::time::timeout(timeout, child.wait_with_output()).await {
430 Ok(Ok(out)) => Ok(shell_result(
431 &String::from_utf8_lossy(&out.stdout),
432 &String::from_utf8_lossy(&out.stderr),
433 out.status.code().unwrap_or(-1),
434 )),
435 Ok(Err(e)) => Err(format!("shell wait failed: {e}")),
436 Err(_elapsed) => {
437 // Kill the whole process group / job: `sh -c "sleep 999 & wait"`
438 // (Unix) or a `cmd /C` build that spawned children (Windows) must
439 // not outlive the timeout. kill_on_drop has already reaped the
440 // shell itself; this sweeps descendants.
441 #[cfg(windows)]
442 if let Some(job) = &job {
443 let _ = job.terminate(1);
444 }
445 Ok(json!({
446 "exit_code": Value::Null,
447 "output": format!("command timed out after {}s and was killed", timeout.as_secs()),
448 "timed_out": true,
449 }))
450 }
451 };
452
453 // A successful/non-zero shell can daemonize a child after closing the
454 // captured pipes. `wait_with_output` then returns while that process keeps
455 // running (the observed shape was `car-server --no-auth` plus its
456 // supervised `car do --serve`). A shell tool invocation is scoped to this
457 // call, so no outcome grants a background-process lifetime.
458 #[cfg(unix)]
459 if let Some(group) = &mut process_group {
460 group.terminate();
461 }
462
463 result
464}
465
466/// `recall` from the graph memory, and deliberately **not** `remember`.
467///
468/// car#1071's ask is the read: the coder could not recall a fact anyone had
469/// stored about the project, which is the product's headline capability being
470/// unavailable to the flagship coding agent inside it.
471///
472/// The write is a different grant and is withheld on purpose. `remember` is an
473/// information-flow **sink** carrying `persistent_memory`
474/// (`car_engine::builtin_tool_labels`) — it writes durable state that outlives
475/// the session and is recalled by every later one. Compose that with car#1081,
476/// where a coder session may be triaging an issue from a **public** tracker
477/// whose body is attacker-authored, and a write path becomes a persistence
478/// attack: hostile text lands in durable memory once and is read back as
479/// trusted context indefinitely. A prompt injection that ends with the session
480/// is recoverable; one that writes to memory is not.
481///
482/// The coder is not left unable to learn. `super::skill_memory::RepairMemory`
483/// is its own write path — failure signatures and repair skills, scoped to what
484/// a coder round actually establishes, and written by the runtime rather than
485/// by the model.
486fn recall_only_memory_defs() -> Vec<Value> {
487 crate::assistant::memory::MemoryTools::tool_defs()
488 .into_iter()
489 .filter(|d| d["name"] == "recall")
490 .collect()
491}
492
493/// One attached delegate and the tool names it advertises.
494///
495/// The defs are kept beside the executor rather than in a flat list so
496/// dispatch can answer "who owns this name" instead of "does anyone", which is
497/// what a single shared `Vec` could tell you.
498struct Delegate {
499 executor: Arc<dyn ToolExecutor>,
500 defs: Vec<Value>,
501}
502
503impl Delegate {
504 fn tool_names(&self) -> impl Iterator<Item = String> + '_ {
505 self.defs
506 .iter()
507 .filter_map(|d| d["name"].as_str().map(String::from))
508 }
509
510 fn advertises(&self, tool: &str) -> bool {
511 self.defs.iter().any(|d| d["name"] == tool)
512 }
513}
514
515pub struct WorktreeExecutor {
516 worktree: PathBuf,
517 inspectors: InspectorChain,
518 /// Executors for tools this one does not own: the Parslee platform tools,
519 /// graph-memory `recall` (car#1071), the network pair (car#1073), and the
520 /// browser surface when the session explicitly opts in (car#1069). Names a
521 /// delegate advertises route to it, bypassing the worktree path-clamp. They
522 /// still pass the inspector chain, so operator-authored deny rules govern
523 /// delegate calls too.
524 ///
525 /// A **list**, not a single slot. It was `Option<Arc<dyn ToolExecutor>>`
526 /// plus one `Vec<Value>`, so a second `with_delegate` silently replaced the
527 /// first rather than adding to it — which is why three separate issues each
528 /// hit "attach a second delegate" as their blocker.
529 delegates: Vec<Delegate>,
530 /// When set, the per-agent approval policy (`agent_permissions`) is consulted
531 /// before every tool. `Deny` hard-blocks. `RequireApproval` hard-blocks
532 /// `full_access` calls because coder/declarative runs have no interactive
533 /// approval channel; lower tiers keep running so ordinary sandbox edits stay
534 /// usable under the Balanced default.
535 agent_id: Option<String>,
536 /// Full-access delegate tools the operator explicitly approved for this
537 /// session. The browser flag populates this set: it satisfies an ordinary
538 /// `RequireApproval`, but never overrides an Agent Permissions `Deny` and
539 /// never skips the coder inspector chain.
540 session_approved_tools: std::collections::BTreeSet<String>,
541 /// Per-session read ledgers backing the read-before-edit / staleness guard
542 /// on built-in file tools. A shared worktree executor must not let one run
543 /// authorize another run's mutation.
544 read_ledgers: agent_basics::SessionReadLedgers,
545 /// Latches once this executor has changed the worktree. Read by the
546 /// no-change gate, which refuses a nomination from a session that ever
547 /// mutated — see [`super::no_change::MutationLedger`] for why reverting
548 /// does not clear it.
549 mutations: Arc<super::no_change::MutationLedger>,
550 /// Tools the operator's policy files forbid outright, captured when the
551 /// chain was loaded in [`Self::for_coder_session`]. Empty on [`Self::new`],
552 /// which loads no project rules.
553 ///
554 /// The inspector chain already REFUSES these at dispatch. This set exists
555 /// so the coding loop can also stop OFFERING them, which the chain cannot
556 /// do — an inspector sees a call, never the menu.
557 denied_tools: BTreeSet<String>,
558 /// Whether delegate-owned names may actually be **dispatched**.
559 ///
560 /// Attachment is not reachability, and conflating the two is a live hole:
561 /// dispatch keys on `delegate_defs` — what the delegate *offers* — and
562 /// returns before both `clamp_paths` and the inspector chain. A run that
563 /// never advertised a delegate tool could still call one by name, and
564 /// `classify_tool_tier` defaults an unrecognised name to `ReadOnly`, so the
565 /// per-agent gate waves it through. `parslee_generate_document` writes to
566 /// the user's connected drive.
567 ///
568 /// Default **false**: only a run that advertised the delegate surface may
569 /// reach it. Today that is the declarative agent-build path, which calls
570 /// `all_tool_defs()`; the coding loop advertises the static built-ins and
571 /// so reaches nothing here.
572 delegates_reachable: Arc<std::sync::atomic::AtomicBool>,
573 /// Ceiling for outcome-contract check commands only ([`Self::run_check_shell`]).
574 /// Defaults to [`MAX_SHELL_TIMEOUT_SECS`]; an operator raises it with
575 /// `max_check_timeout_secs` in `~/.car/coder.toml` or
576 /// `car code-task --max-check-timeout-secs`. The model's own `shell` calls
577 /// keep the advertised 600s ceiling regardless (car#1065).
578 check_timeout_ceiling: u64,
579}
580
581fn enforce_agent_permission(
582 agent_id: &str,
583 tool: &str,
584 tier: car_policy::PermissionTier,
585 mode: car_policy::ApprovalMode,
586) -> Result<(), String> {
587 match mode {
588 car_policy::ApprovalMode::AlwaysAllow => Ok(()),
589 car_policy::ApprovalMode::Deny => Err(format!(
590 "denied for agent '{agent_id}' by your Agent Permissions settings: \
591 '{tool}' is a {}-tier action this agent may not perform",
592 tier.as_str()
593 )),
594 car_policy::ApprovalMode::RequireApproval
595 if tier == car_policy::PermissionTier::FullAccess =>
596 {
597 Err(format!(
598 "approval required for agent '{agent_id}' by your Agent Permissions \
599 settings: '{tool}' is a {}-tier action, but this runner has no \
600 interactive approval channel",
601 tier.as_str()
602 ))
603 }
604 car_policy::ApprovalMode::RequireApproval => Ok(()),
605 }
606}
607
608impl WorktreeExecutor {
609 /// Executor for `worktree` with the standard coder inspector chain.
610 pub fn new(worktree: impl Into<PathBuf>) -> Self {
611 let worktree: PathBuf = worktree.into();
612 // Canonicalize so lexical clamping isn't fooled by `/var` vs
613 // `/private/var` style aliasing of the worktree root itself.
614 let worktree = worktree.canonicalize().unwrap_or(worktree);
615 let inspectors = coder_inspector_chain(&worktree);
616 Self {
617 worktree,
618 inspectors,
619 delegates: Vec::new(),
620 agent_id: None,
621 session_approved_tools: std::collections::BTreeSet::new(),
622 read_ledgers: agent_basics::SessionReadLedgers::new(),
623 denied_tools: BTreeSet::new(),
624 mutations: Arc::new(super::no_change::MutationLedger::new()),
625 delegates_reachable: Arc::new(std::sync::atomic::AtomicBool::new(false)),
626 check_timeout_ceiling: MAX_SHELL_TIMEOUT_SECS,
627 }
628 }
629
630 /// Raise (or lower) the ceiling for outcome-contract check commands.
631 ///
632 /// Floored at 1s: a `0` reaching here from config would otherwise clamp
633 /// every check to a one-second timeout and turn a whole contract red.
634 pub fn with_check_timeout_ceiling(mut self, secs: u64) -> Self {
635 self.check_timeout_ceiling = secs.max(1);
636 self
637 }
638
639 /// The ceiling [`Self::run_check_shell`] applies. Contract evaluation reads
640 /// it to derive `deadline_clamped` against the ceiling that actually ran.
641 pub fn check_timeout_ceiling(&self) -> u64 {
642 self.check_timeout_ceiling
643 }
644
645 /// Whether this executor has changed the worktree at any point.
646 ///
647 /// One-way: see [`super::no_change::MutationLedger`]. A caller adjudicating
648 /// a no-change nomination reads this, and must not interpret a currently
649 /// clean worktree as equivalent.
650 pub fn has_mutated(&self) -> bool {
651 self.mutations.has_mutated()
652 }
653
654 /// Declare that this run advertises the delegate surface, making those
655 /// names dispatchable.
656 ///
657 /// Call it immediately beside the `all_tool_defs()` that advertises them,
658 /// so the two cannot drift: what a run can call should be what it was
659 /// offered. Deliberately NOT folded into `all_tool_defs()` itself — a getter
660 /// that widens a security boundary as a side effect is worse than one
661 /// explicit line.
662 pub fn advertise_delegates(&self) {
663 self.delegates_reachable
664 .store(true, std::sync::atomic::Ordering::SeqCst);
665 }
666
667 /// Whether delegate names are dispatchable on this executor.
668 pub fn delegates_reachable(&self) -> bool {
669 self.delegates_reachable
670 .load(std::sync::atomic::Ordering::SeqCst)
671 }
672
673 /// Whether a `full_access`-tier tool would actually dispatch here, per the
674 /// per-agent approval policy.
675 ///
676 /// A loop asks this before OFFERING one. A tool the gate will refuse is a
677 /// worse failure than a tool that was never offered: the model spends turns
678 /// on it and reads the refusal as the task being impossible rather than as a
679 /// permission nobody granted. With no `agent_id` there is no per-agent gate
680 /// at all, so the answer is yes. Otherwise it mirrors exactly what
681 /// [`enforce_agent_permission`] lets through at that tier — `AlwaysAllow`
682 /// and nothing else, since `RequireApproval` hard-blocks a runner that has
683 /// no interactive approval channel.
684 pub fn permits_full_access(&self) -> bool {
685 let Some(agent_id) = &self.agent_id else {
686 return true;
687 };
688 matches!(
689 crate::agent_permissions::resolve(agent_id, car_policy::PermissionTier::FullAccess),
690 car_policy::ApprovalMode::AlwaysAllow
691 )
692 }
693
694 /// Executor for a coder session's `worktree`, configured identically for
695 /// every entry point that starts one — the daemon's `coder.*` work loop and
696 /// the headless `car code-task`. Both call this so the two cannot drift
697 /// apart again (Parslee-ai/car#1063).
698 ///
699 /// The Parslee delegate is attached for the **agent-build** project kind:
700 /// [`super::declarative::DeclarativeAgentRunner`] is the only caller of
701 /// [`Self::all_tool_defs`], so those names reach a model only when a
702 /// generated agent's spec allowlists them. A plain coding session runs
703 /// [`super::native_loop::run_native_loop`], which advertises the static
704 /// built-ins ([`Self::tool_defs`]) plus the delegate tools it names one by
705 /// one — so the model-visible tool list is the same whichever entry point
706 /// launched the session.
707 ///
708 /// Browser tools are attached separately by [`Self::with_browser_tools`]
709 /// only after a session's explicit `browser` option is read. Keeping the
710 /// default constructor browser-free makes omission a real absence rather
711 /// than a prompt-only convention.
712 pub fn for_coder_session(worktree: impl Into<PathBuf>) -> Result<Self, String> {
713 let base = Self::new(worktree);
714 let policy = coder_inspector_chain_with_project_policies(&base.worktree).map_err(|e| {
715 format!(
716 "refusing to start coder session with unreadable operator policy rules: {e}. \
717 Fix or remove the file — a deny rule that fails to load is a security \
718 control that would silently not exist"
719 )
720 })?;
721 let denied_tools = policy.denied_tools;
722 Ok(base
723 .with_denied_tools(denied_tools)
724 .with_chain(policy.chain)
725 .with_delegate(
726 Arc::new(crate::parslee_tools::ParsleeToolExecutor),
727 crate::parslee_tools::ParsleeToolExecutor::tool_defs(),
728 )
729 .with_delegate(
730 Arc::new(crate::assistant::memory::MemoryTools::open(
731 crate::assistant::default_memory_path(),
732 )),
733 recall_only_memory_defs(),
734 )
735 // The assistant's network pair — `http_request` and `web_search`
736 // (Parslee-ai/car#1073). Attached rather than withheld, because
737 // withholding buys no containment: the coder's `shell` can already
738 // run `curl` with nothing inspecting it, as `coder::policy`'s own
739 // note on the forge matcher records. What the coder lacked was never
740 // egress, it was GOVERNED egress — these route through the inspector
741 // chain, so an operator's `deny_tool` rule can refuse one by name,
742 // and through the event log, so the call leaves a record that
743 // `sh -c curl` does not.
744 //
745 // Default-closed all the same. Both defs declare `tier:
746 // full_access`, which the Balanced default resolves to
747 // `RequireApproval`, and `enforce_agent_permission` hard-blocks that
748 // tier for a runner with no approval channel. Giving a coding agent
749 // the network stays a decision an operator makes on the Agent
750 // Permissions screen; this attachment does not make it for them.
751 .with_delegate(
752 Arc::new(crate::assistant::net_tools::NetTools::new()),
753 crate::assistant::net_tools::net_tool_defs(),
754 )
755 // The coder agent runs under the stable `car-coder` policy subject, so an
756 // operator can Deny it at a risk tier from the Agent Permissions screen.
757 .with_agent_permissions("car-coder"))
758 }
759
760 /// Attach the assistant's browser integration for an explicitly opted-in
761 /// coder session. Its fresh profile is deleted with the browser; cookies
762 /// and sign-ins do not persist across coder sessions. Chromium still
763 /// launches lazily on the first call. Every
764 /// browser name remains a delegate, so dispatch passes the same per-agent
765 /// permission check and frozen coder inspector chain as file and shell
766 /// tools. The session opt-in satisfies `RequireApproval` for these exact
767 /// names; an explicit `Deny` still wins.
768 pub fn with_browser_tools(mut self) -> Self {
769 let browser = Arc::new(crate::assistant::browser_tools::BrowserTools::isolated(
770 self.worktree.clone(),
771 ));
772 let defs = browser.tool_defs();
773 self.session_approved_tools.extend(
774 defs.iter()
775 .filter_map(|def| def["name"].as_str().map(String::from)),
776 );
777 self.with_delegate(browser, defs)
778 }
779
780 /// Enforce the per-agent approval policy for `agent_id`. Used by declarative
781 /// agents (their `spec.id`) and the coder session, extending per-agent
782 /// guardrails beyond the assistant loop.
783 pub fn with_agent_permissions(mut self, agent_id: impl Into<String>) -> Self {
784 self.agent_id = Some(agent_id.into());
785 self
786 }
787
788 /// Replace the inspector chain (tests; callers wanting extra rules).
789 pub fn with_chain(mut self, chain: InspectorChain) -> Self {
790 self.inspectors = chain;
791 self
792 }
793
794 /// Record the tools operator policy forbids outright — see the field.
795 pub fn with_denied_tools(mut self, denied: BTreeSet<String>) -> Self {
796 self.denied_tools = denied;
797 self
798 }
799
800 /// Tools this session must not offer the model. Narrowing only: a name here
801 /// is refused at dispatch whether or not the caller consults this.
802 pub fn denied_tools(&self) -> &BTreeSet<String> {
803 &self.denied_tools
804 }
805
806 /// Attach a delegate executor that handles the given tool `defs` (by name).
807 /// Used to expose the Parslee platform tools to declarative agents without
808 /// threading them through the worktree's file-tool path logic.
809 ///
810 /// **What a delegate gives up.** Delegate-owned names bypass the worktree
811 /// path-clamp because they execute on another substrate, but still pass the
812 /// coder [`InspectorChain`] so declarative deny rules apply. A delegate
813 /// must still be side-effect-free or independently gated where repository
814 /// path scoping cannot apply. The Parslee surface qualifies
815 /// because it carries its own auth + entitlement gating (`parslee_*` refuses
816 /// unless the account is signed in, has an active org, and holds the
817 /// required entitlement); the per-agent approval check still runs first,
818 /// since it precedes the delegate dispatch in `execute_in_session`. Do not
819 /// attach a delegate that writes to the host on the strength of the caller's
820 /// word.
821 /// Attach a delegate. **Additive** — call it once per delegate.
822 ///
823 /// Name collisions resolve to the delegate attached FIRST, and that is a
824 /// deliberate choice rather than an accident of iteration order: attachment
825 /// order is written at the call site where a reader can see it, whereas
826 /// last-wins would let a delegate added later silently capture a name an
827 /// earlier one owns. [`Self::delegate_name_collisions`] reports any overlap
828 /// so a test can refuse it outright.
829 pub fn with_delegate(mut self, delegate: Arc<dyn ToolExecutor>, defs: Vec<Value>) -> Self {
830 self.delegates.push(Delegate {
831 executor: delegate,
832 defs,
833 });
834 self
835 }
836
837 /// Attached delegate defs whose tool name is `name`.
838 ///
839 /// Lets a loop advertise a specific delegate tool without advertising the
840 /// whole delegate surface — the coding loop wants graph-memory `recall`
841 /// while leaving the Parslee document tools unoffered, and
842 /// `all_tool_defs()` cannot express that.
843 pub fn delegate_defs_named(&self, name: &str) -> Vec<Value> {
844 self.delegates
845 .iter()
846 .flat_map(|d| d.defs.iter())
847 .filter(|d| d["name"] == name)
848 .cloned()
849 .collect()
850 }
851
852 /// Attached delegate definitions whose names begin with `prefix`.
853 pub fn delegate_defs_with_prefix(&self, prefix: &str) -> Vec<Value> {
854 self.delegates
855 .iter()
856 .flat_map(|d| d.defs.iter())
857 .filter(|d| {
858 d["name"]
859 .as_str()
860 .is_some_and(|name| name.starts_with(prefix))
861 })
862 .cloned()
863 .collect()
864 }
865
866 /// Tool names advertised by more than one delegate.
867 ///
868 /// Empty is the only healthy answer. Exposed so a call site that composes
869 /// several delegates can assert it rather than discovering a shadowed tool
870 /// at runtime.
871 pub fn delegate_name_collisions(&self) -> Vec<String> {
872 let mut seen: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
873 for delegate in &self.delegates {
874 for name in delegate.tool_names() {
875 *seen.entry(name).or_default() += 1;
876 }
877 }
878 seen.into_iter()
879 .filter(|(_, n)| *n > 1)
880 .map(|(name, _)| name)
881 .collect()
882 }
883
884 /// The delegate that owns `tool`, if any is attached and advertises it.
885 fn delegate_for(&self, tool: &str) -> Option<&Delegate> {
886 self.delegates.iter().find(|d| d.advertises(tool))
887 }
888
889 /// Every attached delegate's defs, flattened.
890 ///
891 /// Used for tier classification, which needs each tool's declared tier and
892 /// does not care which delegate declared it. Dispatch deliberately does NOT
893 /// go through this — it needs the owner, not the union.
894 fn all_delegate_defs(&self) -> Vec<Value> {
895 self.delegates
896 .iter()
897 .flat_map(|d| d.defs.iter().cloned())
898 .collect()
899 }
900
901 /// All tool defs this executor exposes: the static built-ins plus any
902 /// delegate tools. Agent loops should advertise these (not the static
903 /// [`Self::tool_defs`]) so delegate tools are allowlistable.
904 pub fn all_tool_defs(&self) -> Vec<Value> {
905 let mut defs = Self::tool_defs();
906 for delegate in &self.delegates {
907 defs.extend(delegate.defs.iter().cloned());
908 }
909 defs
910 }
911
912 pub fn worktree(&self) -> &Path {
913 &self.worktree
914 }
915
916 /// Tool definitions to expose to the model: the built-in file tools plus
917 /// the coder's `shell` tool, in the `{name, description, parameters}`
918 /// shape `GenerateRequest.tools` expects.
919 pub fn tool_defs() -> Vec<Value> {
920 let mut defs: Vec<Value> = agent_basics::entries()
921 .iter()
922 .map(|e| {
923 json!({
924 "name": e.schema.name,
925 "description": e.schema.description,
926 "parameters": e.schema.parameters,
927 })
928 })
929 .filter(|d| d["name"] != "calculate") // not useful for coding
930 .collect();
931 defs.push(json!({
932 "name": "shell",
933 "description": "Run a shell command at the repository root (the worktree). \
934 Use for builds, tests, and anything the file tools can't do. \
935 Output is the combined stdout+stderr tail. Publishing and \
936 privilege-escalating commands are denied by policy: `git \
937 push`, `gh`/`glab` writes (`pr create`, `release create`, \
938 non-GET `api`), `npm`/`cargo publish`, `docker push`, \
939 `sudo`, and destructive operations outside the repo. \
940 Reading the forge is allowed (`gh pr view`, `gh run view`, \
941 `gh api` GET) so you can watch CI. Do not try to route \
942 around these — the runtime opens the pull request itself \
943 after it has verified your work.",
944 "parameters": {
945 "type": "object",
946 "properties": {
947 "command": {
948 "type": "string",
949 "description": SHELL_COMMAND_PARAM_DESC
950 },
951 "timeout_secs": {
952 "type": "integer",
953 "description": "Wall-clock limit (default 120, max 600)"
954 }
955 },
956 "required": ["command"]
957 }
958 }));
959 defs
960 }
961
962 /// Root relative path params at the worktree and reject lexical escapes.
963 /// Mirrors the param-name surface of `agent_basics` (everything keys on
964 /// `path`).
965 fn clamp_paths(&self, tool: &str, params: &Value) -> Result<Value, String> {
966 clamp_paths_to(&self.worktree, tool, params, "worktree", false)
967 }
968
969 /// Run `command` via `sh -lc` at the worktree root. Returns
970 /// `{exit_code, output, timed_out}` — non-zero exits are values, not
971 /// errors, so the model (and contract evaluation) can read them.
972 ///
973 /// Thin wrapper over the shared [`run_shell_on`]: the coder always runs on
974 /// the host, pinned to the worktree root.
975 pub async fn run_shell(
976 &self,
977 command: &str,
978 timeout_secs: Option<u64>,
979 ) -> Result<Value, String> {
980 let substrate: Arc<dyn Substrate> = Arc::new(LocalSubstrate::new());
981 run_shell_on(
982 &substrate,
983 Some(&self.worktree),
984 &self.inspectors,
985 command,
986 timeout_secs,
987 MAX_SHELL_TIMEOUT_SECS,
988 // The model's own shell holds no forge credential, so publication
989 // fails on authentication however the command is spelled — the one
990 // version of that property that does not depend on out-lexing
991 // `/bin/sh` (car#1084).
992 ForgeCredentials::Withhold,
993 )
994 .await
995 }
996
997 /// [`Self::run_shell`] for an outcome-contract check: same shell, same
998 /// inspectors, but clamped to [`Self::check_timeout_ceiling`] instead of the
999 /// model-facing [`MAX_SHELL_TIMEOUT_SECS`].
1000 ///
1001 /// Separate entry point rather than a field read inside `run_shell`, so the
1002 /// `shell` tool the model calls cannot reach the raised ceiling: a repo whose
1003 /// test gate legitimately needs twenty minutes should not thereby let the
1004 /// model sit on a hung command for twenty (car#1065).
1005 pub(crate) async fn run_check_shell(
1006 &self,
1007 command: &str,
1008 timeout_secs: Option<u64>,
1009 ) -> Result<Value, String> {
1010 let substrate: Arc<dyn Substrate> = Arc::new(LocalSubstrate::new());
1011 run_shell_on(
1012 &substrate,
1013 Some(&self.worktree),
1014 &self.inspectors,
1015 command,
1016 timeout_secs,
1017 self.check_timeout_ceiling,
1018 // A check runs a command the CONTRACT declares, not one the model
1019 // just wrote, so the forge credential stays. It does not follow
1020 // that the check may name a credential: `self.inspectors` above is
1021 // the full coder chain, `DenyCredentialAccess` included, so the
1022 // command is refused on its text (car#1066). See the
1023 // `ForgeCredentials::Inherit` docs for what that does and does not
1024 // cover.
1025 ForgeCredentials::Inherit,
1026 )
1027 .await
1028 }
1029
1030 async fn execute_in_session(
1031 &self,
1032 tool: &str,
1033 params: &Value,
1034 session_id: Option<&str>,
1035 ) -> Result<Value, String> {
1036 if let Some(agent_id) = &self.agent_id {
1037 // Classify against the delegate's OWN declared tiers, not the bare
1038 // name map. `assistant_tool_tier`'s fallback arm is `ReadOnly`, so
1039 // any name it does not know — every `parslee_*` tool among them —
1040 // was being classified as the most permissive tier and waved
1041 // through the per-agent gate. `classify_tool_tier_with_defs` exists
1042 // for exactly this: honour a tool that declares its own tier.
1043 let tier = crate::agent_permissions::classify_tool_tier_with_defs(
1044 tool,
1045 params,
1046 &self.all_delegate_defs(),
1047 );
1048 let mode = crate::agent_permissions::resolve(agent_id, tier);
1049 // An explicit per-session grant answers RequireApproval for only
1050 // the names it carries. It cannot turn a configured Deny into an
1051 // allow, and the inspector chain below still gets the call.
1052 if !(mode == car_policy::ApprovalMode::RequireApproval
1053 && self.session_approved_tools.contains(tool))
1054 {
1055 enforce_agent_permission(agent_id, tool, tier, mode)?;
1056 }
1057 }
1058
1059 if tool == "shell" {
1060 let command = params
1061 .get("command")
1062 .and_then(Value::as_str)
1063 .ok_or("missing 'command' parameter")?;
1064 let timeout_secs = params.get("timeout_secs").and_then(Value::as_u64);
1065 // A shell call cannot be treated as mutating on its face — the model
1066 // has to grep, build and test to investigate anything, and marking
1067 // every one of those would make a no-change finding unreachable.
1068 // So it is judged by effect: fingerprint either side and record a
1069 // mutation only if the worktree actually moved. If git cannot answer
1070 // on either side, assume it did — an unknown is not a clean bill.
1071 let before = super::no_change::worktree_fingerprint(&self.worktree);
1072 let result = self.run_shell(command, timeout_secs).await;
1073 let after = super::no_change::worktree_fingerprint(&self.worktree);
1074 match (&before, &after) {
1075 (Some(a), Some(b)) if a == b => {}
1076 _ => self.mutations.record_mutation(),
1077 }
1078 return result;
1079 }
1080
1081 // The file-writing built-ins are mutating by definition, and only a
1082 // SUCCESSFUL one counts — a write the policy chain refused changed
1083 // nothing and must not disqualify the session.
1084 let is_mutating_tool = matches!(tool, "write_file" | "edit_file");
1085
1086 // Reachability, not mere attachment — see `delegates_reachable`. An
1087 // unadvertised delegate name falls through to the ordinary path below
1088 // and ends as `unknown tool`, which is what a run that was never
1089 // offered the surface should get.
1090 if self.delegates_reachable() {
1091 if let Some(delegate) = self.delegate_for(tool) {
1092 // Delegate-owned tools bypass the worktree path clamp because
1093 // they execute on a different substrate, but operator policy
1094 // still governs them. Otherwise a deny_tool rule would stop a
1095 // built-in and silently miss the same coder session's delegate.
1096 if let Some(reason) = self.inspectors.check(tool, params) {
1097 return Err(format!("denied by policy: {reason}"));
1098 }
1099 return delegate.executor.execute(tool, params).await;
1100 }
1101 }
1102
1103 let clamped = self.clamp_paths(tool, params)?;
1104 if let Some(reason) = self.inspectors.check(tool, &clamped) {
1105 return Err(format!("denied by policy: {reason}"));
1106 }
1107 let substrate: Arc<dyn Substrate> = Arc::new(LocalSubstrate::new());
1108 let ledger = self.read_ledgers.ledger_for(session_id);
1109 match agent_basics::execute_with_ledger(&substrate, &ledger, tool, &clamped).await {
1110 Some(result) => {
1111 if is_mutating_tool && result.is_ok() {
1112 self.mutations.record_mutation();
1113 }
1114 result
1115 }
1116 None => Err(format!("unknown tool: {tool}")),
1117 }
1118 }
1119}
1120
1121#[async_trait]
1122impl ToolExecutor for WorktreeExecutor {
1123 async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
1124 self.execute_in_session(tool, params, None).await
1125 }
1126
1127 async fn execute_with_action_in_session(
1128 &self,
1129 tool: &str,
1130 params: &Value,
1131 _action_id: &str,
1132 _timeout_ms: Option<u64>,
1133 session_id: Option<&str>,
1134 _attempt: u32,
1135 ) -> Result<Value, String> {
1136 self.execute_in_session(tool, params, session_id).await
1137 }
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142 /// A child that reports `TOK`, `GHT`, `CFG` and `HELPER` — one field per
1143 /// credential route — spelled for the platform's shell. Read a field back
1144 /// with [`probe_field`] rather than by eye: the fields are `|`-separated on
1145 /// Unix and newline-separated on Windows, because `cmd`'s `echo` cannot
1146 /// suppress its newline the way `printf` can.
1147 ///
1148 /// `/bin/sh` does not exist on Windows — the coder shells out through
1149 /// `cmd /C` there, see [`WorktreeExecutor::run_shell`] — and the two tests
1150 /// below were the only ones in this module without the Windows spelling
1151 /// their neighbours already have. They died with "the system cannot find
1152 /// the path specified" and took the whole Windows leg of CI with them,
1153 /// while asserting nothing at all about the strip (car#1096). `cmd` leaves
1154 /// `%VAR%` literal when VAR is unset, so `if defined` stands in for the
1155 /// POSIX default here. The two are not identical — `if defined` is the
1156 /// analogue of `${VAR-…}`, which treats a defined-but-empty variable as
1157 /// present, where `${VAR:-…}` substitutes for it — but every variable
1158 /// these tests set carries a value, so the mapping is exact for them.
1159 fn credential_probe() -> tokio::process::Command {
1160 #[cfg(unix)]
1161 {
1162 let mut cmd = tokio::process::Command::new("/bin/sh");
1163 cmd.arg("-c").arg(
1164 "printf 'TOK=%s|GHT=%s|CFG=%s|HELPER=%s' \
1165 \"${GH_TOKEN:-EMPTY}\" \"${GITHUB_TOKEN:-EMPTY}\" \
1166 \"${GH_CONFIG_DIR:-UNSET}\" \"${GIT_CONFIG_COUNT:-UNSET}\"",
1167 );
1168 cmd
1169 }
1170 #[cfg(windows)]
1171 {
1172 let mut cmd = tokio::process::Command::new("cmd");
1173 cmd.arg("/C").arg(
1174 "(if defined GH_TOKEN (echo TOK=%GH_TOKEN%) else (echo TOK=EMPTY)) & \
1175 (if defined GITHUB_TOKEN (echo GHT=%GITHUB_TOKEN%) else (echo GHT=EMPTY)) & \
1176 (if defined GH_CONFIG_DIR (echo CFG=%GH_CONFIG_DIR%) else (echo CFG=UNSET)) & \
1177 (if defined GIT_CONFIG_COUNT (echo HELPER=%GIT_CONFIG_COUNT%) else (echo HELPER=UNSET))",
1178 );
1179 cmd
1180 }
1181 }
1182
1183 /// One named field out of a [`credential_probe`] record.
1184 ///
1185 /// Asked field-wise, never as a substring of the whole record: a
1186 /// `contains("TOK=x")` is also satisfied by `TOK=xy`, so it cannot tell an
1187 /// exact credential value from one that merely starts with it. Splits on
1188 /// both separators the probe can emit (see its doc comment).
1189 fn probe_field(text: &str, name: &str) -> Option<String> {
1190 let prefix = format!("{name}=");
1191 text.split(['|', '\n', '\r'])
1192 .find_map(|field| field.trim().strip_prefix(&prefix))
1193 .map(str::to_string)
1194 }
1195
1196 /// The mechanism, tested directly rather than through `run_shell`.
1197 ///
1198 /// The inspector chain already refuses a command that *names* a credential
1199 /// variable (`DenyCredentialAccess`), so a shell command cannot be used to
1200 /// observe this. That deny is a pattern defence of exactly the class
1201 /// car#1076 showed cannot be made complete against `/bin/sh`; this one is
1202 /// structural, and the two are complementary — the pattern stops the
1203 /// obvious read, and the strip means there is nothing to read when the
1204 /// pattern is evaded.
1205 ///
1206 /// The credentials are planted on the *builder*, never with
1207 /// `std::env::set_var`. Both spellings reach the child, but a process-wide
1208 /// set is shared with every other test in the binary: `check-windows` runs
1209 /// `cargo test --lib`, which is the threaded harness, so a sibling's
1210 /// `set_var`/`remove_var` landing between this one's set and its spawn
1211 /// would flip either test's answer. Planting on the builder also makes the
1212 /// assertion sharper — `env_remove` now has to beat an explicit value on
1213 /// the same `Command`, not merely an inherited one.
1214 #[tokio::test]
1215 async fn withholding_removes_every_route_to_a_forge_credential() {
1216 let mut cmd = credential_probe();
1217 cmd.env("GH_TOKEN", "ghp_secret_do_not_leak")
1218 .env("GITHUB_TOKEN", "gho_secret_do_not_leak");
1219 withhold_forge_credentials(&mut cmd);
1220 let out = cmd.output().await.expect("child ran");
1221 let text = String::from_utf8_lossy(&out.stdout).to_string();
1222
1223 assert_eq!(
1224 probe_field(&text, "TOK").as_deref(),
1225 Some("EMPTY"),
1226 "GH_TOKEN survived: {text}"
1227 );
1228 assert_eq!(
1229 probe_field(&text, "GHT").as_deref(),
1230 Some("EMPTY"),
1231 "GITHUB_TOKEN survived: {text}"
1232 );
1233 assert!(
1234 !text.contains("ghp_secret_do_not_leak") && !text.contains("gho_secret_do_not_leak"),
1235 "a credential leaked: {text}"
1236 );
1237 // gh must not fall back to the real ~/.config/gh/hosts.yml.
1238 assert!(
1239 !text.contains("CFG=UNSET"),
1240 "GH_CONFIG_DIR not pinned: {text}"
1241 );
1242 assert_eq!(
1243 probe_field(&text, "HELPER").as_deref(),
1244 Some("1"),
1245 "git config override not applied: {text}"
1246 );
1247
1248 // Behavioural, not env-shape: git in this child must resolve NO
1249 // credential helper. That is the route which ignores GH_TOKEN entirely
1250 // and would otherwise have left `git push` over HTTPS working.
1251 let mut git = tokio::process::Command::new("git");
1252 git.arg("config").arg("--get").arg("credential.helper");
1253 withhold_forge_credentials(&mut git);
1254 let helper = git.output().await.expect("git ran");
1255 let resolved = String::from_utf8_lossy(&helper.stdout).trim().to_string();
1256 assert!(
1257 resolved.is_empty(),
1258 "a credential helper survived into the child: {resolved}"
1259 );
1260 }
1261
1262 /// A child that was NOT withheld from still sees the environment — proving
1263 /// the test above is observing the strip and not an already-empty env.
1264 ///
1265 /// Builder-scoped for the same reason as its sibling. `GITHUB_TOKEN` is
1266 /// pinned to a fixture even though nothing reads it back: the probe prints
1267 /// every field it knows, so leaving that one to the ambient environment
1268 /// would put a runner's real token into `text`.
1269 #[tokio::test]
1270 async fn an_untouched_child_still_sees_the_credential() {
1271 let mut cmd = credential_probe();
1272 cmd.env("GH_TOKEN", "ghp_inherit_me")
1273 .env("GITHUB_TOKEN", "gho_not_read_back");
1274 let out = cmd.output().await.expect("child ran");
1275 let text = String::from_utf8_lossy(&out.stdout).to_string();
1276 // The TOK field is compared exactly, so this proves the child saw the
1277 // value we set and not merely something beginning with it.
1278 assert_eq!(
1279 probe_field(&text, "TOK").as_deref(),
1280 Some("ghp_inherit_me"),
1281 "control case failed — the strip test would pass vacuously"
1282 );
1283 }
1284
1285 /// `GH_CONFIG_DIR` must point somewhere that exists and holds no hosts.yml,
1286 /// or `gh` falls straight back to the operator's real config.
1287 #[test]
1288 fn the_empty_config_dir_exists_and_is_empty_of_forge_config() {
1289 let dir = empty_config_dir();
1290 assert!(
1291 dir.is_dir(),
1292 "gh falls back to the real config if this is absent"
1293 );
1294 assert!(!dir.join("hosts.yml").exists());
1295 }
1296
1297 /// macOS `/etc/profile` runs `path_helper`, which rebuilds PATH with the
1298 /// system dirs FIRST and appends the inherited ones — so a toolchain the
1299 /// operator put first for the daemon lands at the tail inside the agent's
1300 /// shell, and a stale system binary shadows it. Surfaced by the coder A/B: a
1301 /// venv-first PATH still lost `pip` to `/usr/local/bin/pip`, whose
1302 /// `#!/usr/bin/python` shebang does not exist on modern macOS, so every
1303 /// `pip` step in a derived contract failed forever and sank sessions whose
1304 /// real work had already gone green. The macOS twin of the Windows
1305 /// over-long-PATH bug `car_engine::win_env` fixes.
1306 #[cfg(unix)]
1307 #[tokio::test]
1308 async fn login_shell_keeps_the_inherited_path_ahead_of_the_profiles() {
1309 let dir = tempfile::tempdir().unwrap();
1310 // A fake tool that must win over anything the profile puts earlier.
1311 let bin = dir.path().join("car-path-probe");
1312 std::fs::write(&bin, "#!/bin/sh\necho WINNER\n").unwrap();
1313 use std::os::unix::fs::PermissionsExt;
1314 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1315
1316 let orig = std::env::var("PATH").unwrap_or_default();
1317 std::env::set_var("PATH", format!("{}:{}", dir.path().display(), orig));
1318 let script =
1319 prepend_inherited_path("command -v car-path-probe >/dev/null && car-path-probe");
1320 std::env::set_var("PATH", &orig);
1321
1322 let out = tokio::process::Command::new("/bin/sh")
1323 .arg("-lc")
1324 .arg(&script)
1325 .output()
1326 .await
1327 .unwrap();
1328 assert_eq!(
1329 String::from_utf8_lossy(&out.stdout).trim(),
1330 "WINNER",
1331 "the daemon's PATH must survive the login shell's profile"
1332 );
1333 }
1334
1335 /// No PATH to re-assert → the command must pass through untouched.
1336 #[test]
1337 fn path_prepend_is_a_no_op_without_a_path() {
1338 let orig = std::env::var("PATH").ok();
1339 std::env::remove_var("PATH");
1340 assert_eq!(prepend_inherited_path("echo hi"), "echo hi");
1341 if let Some(p) = orig {
1342 std::env::set_var("PATH", p);
1343 }
1344 }
1345
1346 /// A PATH with a space or a quote must not break out of the export. The
1347 /// POSIX idiom for a literal `'` inside single quotes is `'\''` — close,
1348 /// escaped quote, reopen. Unix-only: it round-trips through `/bin/sh`,
1349 /// which doesn't exist on Windows (where the coder shells out differently).
1350 #[cfg(unix)]
1351 #[test]
1352 fn path_prepend_quotes_hostile_paths() {
1353 assert_eq!(
1354 sh_single_quote("/a b/bin:/it's/bin"),
1355 r#"'/a b/bin:/it'\''s/bin'"#
1356 );
1357 // And it must actually round-trip through a real shell.
1358 let out = std::process::Command::new("/bin/sh")
1359 .arg("-c")
1360 .arg(format!("printf %s {}", sh_single_quote("/a b/x:/it's/y")))
1361 .output()
1362 .unwrap();
1363 assert_eq!(String::from_utf8_lossy(&out.stdout), "/a b/x:/it's/y");
1364 }
1365 use super::*;
1366
1367 fn executor() -> (tempfile::TempDir, WorktreeExecutor) {
1368 let dir = tempfile::tempdir().unwrap();
1369 let exec = WorktreeExecutor::new(dir.path());
1370 (dir, exec)
1371 }
1372
1373 #[cfg(unix)]
1374 #[tokio::test]
1375 async fn shell_runs_at_worktree_root() {
1376 let (dir, exec) = executor();
1377 let out = exec.run_shell("pwd", Some(10)).await.unwrap();
1378 let cwd = out["output"].as_str().unwrap().trim();
1379 assert_eq!(
1380 PathBuf::from(cwd).canonicalize().unwrap(),
1381 dir.path().canonicalize().unwrap()
1382 );
1383 assert_eq!(out["exit_code"], 0);
1384 }
1385
1386 #[cfg(windows)]
1387 #[tokio::test]
1388 async fn shell_runs_at_worktree_root() {
1389 let (dir, exec) = executor();
1390 // `cmd /C cd` prints the current directory on Windows.
1391 let out = exec.run_shell("cd", Some(10)).await.unwrap();
1392 let cwd = out["output"].as_str().unwrap().trim();
1393 assert_eq!(
1394 PathBuf::from(cwd).canonicalize().unwrap(),
1395 dir.path().canonicalize().unwrap()
1396 );
1397 assert_eq!(out["exit_code"], 0);
1398 }
1399
1400 #[tokio::test]
1401 async fn shell_reports_nonzero_exit_as_value() {
1402 let (_dir, exec) = executor();
1403 let out = exec.run_shell("exit 3", Some(10)).await.unwrap();
1404 assert_eq!(out["exit_code"], 3);
1405 assert_eq!(out["timed_out"], false);
1406 }
1407
1408 #[cfg(unix)]
1409 #[tokio::test]
1410 async fn shell_captures_stderr() {
1411 let (_dir, exec) = executor();
1412 let out = exec
1413 .run_shell("echo to-out; echo to-err 1>&2", Some(10))
1414 .await
1415 .unwrap();
1416 let text = out["output"].as_str().unwrap();
1417 assert!(text.contains("to-out") && text.contains("to-err"));
1418 }
1419
1420 #[cfg(windows)]
1421 #[tokio::test]
1422 async fn shell_captures_stderr() {
1423 let (_dir, exec) = executor();
1424 // `&` is cmd's command separator; `1>&2` redirects stderr.
1425 let out = exec
1426 .run_shell("echo to-out & echo to-err 1>&2", Some(10))
1427 .await
1428 .unwrap();
1429 let text = out["output"].as_str().unwrap();
1430 assert!(text.contains("to-out") && text.contains("to-err"), "{text}");
1431 }
1432
1433 #[cfg(unix)]
1434 #[tokio::test]
1435 async fn shell_timeout_kills_and_reports() {
1436 let (_dir, exec) = executor();
1437 let started = std::time::Instant::now();
1438 let out = exec.run_shell("sleep 30", Some(1)).await.unwrap();
1439 assert!(
1440 started.elapsed() < Duration::from_secs(10),
1441 "did not wait out the sleep"
1442 );
1443 assert_eq!(out["timed_out"], true);
1444 assert!(out["exit_code"].is_null());
1445 }
1446
1447 #[cfg(windows)]
1448 #[tokio::test]
1449 async fn shell_timeout_kills_and_reports() {
1450 let (_dir, exec) = executor();
1451 let started = std::time::Instant::now();
1452 // An infinite `cmd` loop is a deterministic blocker that needs no
1453 // console or stdin (unlike `timeout`/`pause`); the 1s wall-clock limit
1454 // must fire and kill it.
1455 let out = exec
1456 .run_shell("for /L %i in () do @rem", Some(1))
1457 .await
1458 .unwrap();
1459 assert!(
1460 started.elapsed() < Duration::from_secs(10),
1461 "did not enforce the timeout"
1462 );
1463 assert_eq!(out["timed_out"], true);
1464 assert!(out["exit_code"].is_null());
1465 }
1466
1467 #[tokio::test]
1468 async fn shell_denied_by_policy() {
1469 let (_dir, exec) = executor();
1470 let err = exec
1471 .run_shell("git push origin main", Some(5))
1472 .await
1473 .unwrap_err();
1474 assert!(err.contains("denied by policy"), "{err}");
1475 }
1476
1477 #[test]
1478 fn noninteractive_agent_permissions_fail_closed_for_full_access_approval() {
1479 assert!(
1480 enforce_agent_permission(
1481 "writer",
1482 "shell",
1483 car_policy::PermissionTier::SandboxEdit,
1484 car_policy::ApprovalMode::RequireApproval,
1485 )
1486 .is_ok(),
1487 "sandbox edits remain usable under the Balanced default"
1488 );
1489
1490 let err = enforce_agent_permission(
1491 "writer",
1492 "shell",
1493 car_policy::PermissionTier::FullAccess,
1494 car_policy::ApprovalMode::RequireApproval,
1495 )
1496 .unwrap_err();
1497 assert!(err.contains("approval required"), "{err}");
1498 assert!(err.contains("no interactive approval channel"), "{err}");
1499
1500 let err = enforce_agent_permission(
1501 "writer",
1502 "shell",
1503 car_policy::PermissionTier::ReadOnly,
1504 car_policy::ApprovalMode::Deny,
1505 )
1506 .unwrap_err();
1507 assert!(err.contains("denied for agent"), "{err}");
1508 }
1509
1510 #[tokio::test]
1511 async fn relative_file_writes_land_in_worktree() {
1512 let (dir, exec) = executor();
1513 exec.execute(
1514 "write_file",
1515 &json!({"path": "sub/out.txt", "content": "hi"}),
1516 )
1517 .await
1518 .unwrap();
1519 assert_eq!(
1520 std::fs::read_to_string(dir.path().join("sub/out.txt")).unwrap(),
1521 "hi"
1522 );
1523 }
1524
1525 /// (#1a) The read-before-edit guard is LIVE through the coder's
1526 /// WorktreeExecutor: editing a worktree file the session never read is
1527 /// refused. Reverting this call site to the ungated `agent_basics::execute`
1528 /// makes this pass silently — that's the regression this test pins.
1529 #[tokio::test]
1530 async fn edit_requires_prior_read_through_worktree_executor() {
1531 let (dir, exec) = executor();
1532 std::fs::write(dir.path().join("f.txt"), "hello world").unwrap();
1533 let err = exec
1534 .execute(
1535 "edit_file",
1536 &json!({ "path": "f.txt", "old_text": "hello", "new_text": "hi" }),
1537 )
1538 .await
1539 .unwrap_err();
1540 assert!(err.contains("before editing it"), "{err}");
1541 }
1542
1543 #[tokio::test]
1544 async fn escaping_writes_are_rejected_in_code() {
1545 let (_dir, exec) = executor();
1546 let err = exec
1547 .execute(
1548 "write_file",
1549 &json!({"path": "../escape.txt", "content": "x"}),
1550 )
1551 .await
1552 .unwrap_err();
1553 assert!(err.contains("outside the worktree"), "{err}");
1554
1555 let err = exec
1556 .execute(
1557 "write_file",
1558 &json!({"path": "/tmp/abs-escape.txt", "content": "x"}),
1559 )
1560 .await
1561 .unwrap_err();
1562 assert!(err.contains("outside the worktree"), "{err}");
1563 }
1564
1565 #[tokio::test]
1566 async fn list_dir_defaults_to_worktree_not_process_cwd() {
1567 let (dir, exec) = executor();
1568 std::fs::write(dir.path().join("marker.txt"), "x").unwrap();
1569 let out = exec.execute("list_dir", &json!({})).await.unwrap();
1570 assert!(
1571 out.to_string().contains("marker.txt"),
1572 "expected worktree listing, got: {out}"
1573 );
1574 }
1575
1576 #[cfg(unix)]
1577 #[tokio::test]
1578 async fn output_is_tail_capped() {
1579 let (_dir, exec) = executor();
1580 // ~200KB of output → capped to the 64KB tail.
1581 let out = exec
1582 .run_shell("i=0; while [ $i -lt 5000 ]; do echo 'line of output 40 bytes long....'; i=$((i+1)); done", Some(30))
1583 .await
1584 .unwrap();
1585 let text = out["output"].as_str().unwrap();
1586 assert!(text.len() <= MAX_OUTPUT_BYTES + 32, "len={}", text.len());
1587 assert!(text.starts_with("…[truncated]…"));
1588 }
1589
1590 #[cfg(windows)]
1591 #[tokio::test]
1592 async fn output_is_tail_capped() {
1593 let (_dir, exec) = executor();
1594 // ~200KB of output → capped to the 64KB tail.
1595 let out = exec
1596 .run_shell(
1597 "for /L %i in (1,1,5000) do @echo line of output 40 bytes long....",
1598 Some(60),
1599 )
1600 .await
1601 .unwrap();
1602 let text = out["output"].as_str().unwrap();
1603 assert!(text.len() <= MAX_OUTPUT_BYTES + 32, "len={}", text.len());
1604 assert!(text.starts_with("…[truncated]…"));
1605 }
1606
1607 #[tokio::test]
1608 async fn unknown_tool_errors() {
1609 let (_dir, exec) = executor();
1610 assert!(exec.execute("teleport", &json!({})).await.is_err());
1611 }
1612
1613 struct StubDelegate;
1614 #[async_trait]
1615 impl ToolExecutor for StubDelegate {
1616 async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
1617 Ok(json!({ "via": "delegate", "tool": tool, "echo": params.clone() }))
1618 }
1619 }
1620
1621 /// car#1071: a coder session can recall from the graph memory.
1622 ///
1623 /// The product's headline capability was unavailable to the flagship coding
1624 /// agent inside it — the coder could not read a fact anyone had stored about
1625 /// the project.
1626 #[test]
1627 fn a_coder_session_carries_graph_memory_recall() {
1628 let dir = tempfile::tempdir().unwrap();
1629 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
1630 let names: Vec<String> = exec
1631 .all_tool_defs()
1632 .iter()
1633 .filter_map(|d| d["name"].as_str().map(String::from))
1634 .collect();
1635 assert!(
1636 names.iter().any(|n| n == "recall"),
1637 "the coder must be able to recall stored project facts"
1638 );
1639 }
1640
1641 /// And CANNOT write to it. This is the security half of car#1071 and the
1642 /// assertion most worth keeping.
1643 ///
1644 /// `remember` is an information-flow sink carrying `persistent_memory`: it
1645 /// writes durable state that every later session reads. car#1081 says a
1646 /// coder session may be triaging an issue from a public tracker whose body
1647 /// is attacker-authored, so a write path turns a single prompt injection
1648 /// into a persistence attack — hostile text stored once and recalled as
1649 /// trusted context indefinitely.
1650 #[test]
1651 fn a_coder_session_cannot_write_to_graph_memory() {
1652 let dir = tempfile::tempdir().unwrap();
1653 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
1654 let names: Vec<String> = exec
1655 .all_tool_defs()
1656 .iter()
1657 .filter_map(|d| d["name"].as_str().map(String::from))
1658 .collect();
1659 assert!(
1660 !names.iter().any(|n| n == "remember"),
1661 "a coder must not write durable memory later sessions will trust"
1662 );
1663 // The filter is the mechanism, so assert it directly too: if
1664 // MemoryTools grows a second write tool, this catches it.
1665 let attached = recall_only_memory_defs();
1666 assert_eq!(attached.len(), 1);
1667 assert_eq!(attached[0]["name"], "recall");
1668 }
1669
1670 /// Two delegates coexist. This is the whole point of the refactor.
1671 ///
1672 /// `with_delegate` used to assign a single `Option` slot and one shared
1673 /// `Vec<Value>`, so a second call replaced the first — silently, with the
1674 /// first delegate's tools vanishing from `all_tool_defs` and its dispatch
1675 /// falling through to `unknown tool`. Three separate issues (car#1073
1676 /// network, car#1069 browser, car#1071 memory) each hit that as their
1677 /// blocker.
1678 #[tokio::test]
1679 async fn a_second_delegate_does_not_evict_the_first() {
1680 let dir = tempfile::tempdir().unwrap();
1681 let first = vec![json!({
1682 "name": "alpha_tool",
1683 "description": "first",
1684 "parameters": { "type": "object", "properties": {} }
1685 })];
1686 let second = vec![json!({
1687 "name": "beta_tool",
1688 "description": "second",
1689 "parameters": { "type": "object", "properties": {} }
1690 })];
1691 let exec = WorktreeExecutor::new(dir.path())
1692 .with_delegate(Arc::new(StubDelegate), first)
1693 .with_delegate(Arc::new(StubDelegate), second);
1694
1695 let names: Vec<String> = exec
1696 .all_tool_defs()
1697 .iter()
1698 .filter_map(|d| d["name"].as_str().map(String::from))
1699 .collect();
1700 assert!(
1701 names.iter().any(|n| n == "alpha_tool"),
1702 "first delegate evicted"
1703 );
1704 assert!(
1705 names.iter().any(|n| n == "beta_tool"),
1706 "second delegate missing"
1707 );
1708 assert!(names.iter().any(|n| n == "read_file"), "built-ins lost");
1709
1710 exec.advertise_delegates();
1711 for tool in ["alpha_tool", "beta_tool"] {
1712 let out = exec.execute(tool, &json!({ "x": 1 })).await.unwrap();
1713 assert_eq!(out["via"], "delegate", "{tool} did not route to a delegate");
1714 assert_eq!(out["tool"], tool, "{tool} routed to the wrong delegate");
1715 }
1716 }
1717
1718 /// A name advertised by two delegates resolves to the one attached FIRST,
1719 /// and the overlap is reportable rather than silent.
1720 #[tokio::test]
1721 async fn a_name_collision_resolves_to_the_first_delegate_and_is_reportable() {
1722 let dir = tempfile::tempdir().unwrap();
1723 let def = |name: &str| {
1724 vec![json!({
1725 "name": name,
1726 "description": "x",
1727 "parameters": { "type": "object", "properties": {} }
1728 })]
1729 };
1730 let exec = WorktreeExecutor::new(dir.path())
1731 .with_delegate(Arc::new(StubDelegate), def("shared_name"))
1732 .with_delegate(Arc::new(StubDelegate), def("shared_name"));
1733
1734 assert_eq!(
1735 exec.delegate_name_collisions(),
1736 vec!["shared_name".to_string()],
1737 "an overlap a call site could assert on must be visible"
1738 );
1739
1740 // Still dispatches — deterministically, to the first.
1741 exec.advertise_delegates();
1742 let out = exec.execute("shared_name", &json!({})).await.unwrap();
1743 assert_eq!(out["via"], "delegate");
1744 }
1745
1746 /// The healthy case: nothing overlaps.
1747 #[test]
1748 fn a_coder_session_has_no_delegate_name_collisions() {
1749 let dir = tempfile::tempdir().unwrap();
1750 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
1751 assert!(
1752 exec.delegate_name_collisions().is_empty(),
1753 "two attached delegates advertise the same tool name"
1754 );
1755 }
1756
1757 #[tokio::test]
1758 async fn delegate_tool_routes_through_delegate_and_is_advertised() {
1759 let dir = tempfile::tempdir().unwrap();
1760 let defs = vec![json!({
1761 "name": "ext_tool",
1762 "description": "external",
1763 "parameters": { "type": "object", "properties": {} }
1764 })];
1765 let exec = WorktreeExecutor::new(dir.path()).with_delegate(Arc::new(StubDelegate), defs);
1766
1767 // all_tool_defs surfaces the delegate tool alongside the built-ins…
1768 let names: Vec<String> = exec
1769 .all_tool_defs()
1770 .iter()
1771 .filter_map(|d| d["name"].as_str().map(String::from))
1772 .collect();
1773 assert!(names.iter().any(|n| n == "ext_tool"));
1774 assert!(names.iter().any(|n| n == "read_file")); // built-ins still present
1775
1776 // This run advertised the delegate surface, so it may call it. Without
1777 // this the name is not reachable at all — see
1778 // `an_unadvertised_delegate_tool_is_not_reachable`.
1779 exec.advertise_delegates();
1780
1781 // …and execute() routes it to the delegate (no worktree path clamp).
1782 let out = exec.execute("ext_tool", &json!({ "x": 1 })).await.unwrap();
1783 assert_eq!(out["via"], "delegate");
1784 assert_eq!(out["tool"], "ext_tool");
1785 assert_eq!(out["echo"]["x"], 1);
1786
1787 // Tools the delegate doesn't own still fall through to "unknown".
1788 assert!(exec.execute("teleport", &json!({})).await.is_err());
1789 }
1790
1791 #[tokio::test]
1792 async fn project_policy_denies_shell_file_and_advertised_delegate_calls() {
1793 let dir = tempfile::tempdir().unwrap();
1794 let policies = dir.path().join(".car").join("policies");
1795 std::fs::create_dir_all(&policies).unwrap();
1796 std::fs::write(
1797 policies.join("rules.toml"),
1798 "deny_tool = [\"write_file\", \"ext_tool\"]\ndeny_keyword = [\"BLOCKED CHECK\"]\n",
1799 )
1800 .unwrap();
1801
1802 let defs = vec![json!({
1803 "name": "ext_tool",
1804 "description": "external",
1805 "parameters": { "type": "object", "properties": {} }
1806 })];
1807 let mut exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
1808 exec = exec.with_delegate(Arc::new(StubDelegate), defs);
1809
1810 let file_err = exec
1811 .execute(
1812 "write_file",
1813 &json!({"path": "blocked.txt", "content": "x"}),
1814 )
1815 .await
1816 .expect_err("project deny_tool must govern coder file tools");
1817 assert!(file_err.contains("operator policy"), "{file_err}");
1818
1819 let check_err = exec
1820 .run_check_shell("echo BLOCKED CHECK", Some(5))
1821 .await
1822 .expect_err("contract checks use the same governed chain");
1823 assert!(check_err.contains("operator policy"), "{check_err}");
1824
1825 exec.advertise_delegates();
1826 let delegate_err = exec
1827 .execute("ext_tool", &json!({}))
1828 .await
1829 .expect_err("advertised delegates remain governed by operator policy");
1830 assert!(delegate_err.contains("operator policy"), "{delegate_err}");
1831 }
1832
1833 /// The browser flag is approval to offer the surface, not a policy bypass.
1834 /// This call must be denied before BrowserTools can launch Chromium.
1835 #[tokio::test]
1836 async fn opted_in_browser_calls_still_cross_the_coder_policy_chain() {
1837 let dir = tempfile::tempdir().unwrap();
1838 let policies = dir.path().join(".car").join("policies");
1839 std::fs::create_dir_all(&policies).unwrap();
1840 std::fs::write(
1841 policies.join("browser.toml"),
1842 "deny_tool = [\"browse_navigate\"]\n",
1843 )
1844 .unwrap();
1845
1846 let exec = WorktreeExecutor::for_coder_session(dir.path())
1847 .unwrap()
1848 .with_browser_tools();
1849 exec.advertise_delegates();
1850 let err = exec
1851 .execute("browse_navigate", &json!({"url": "https://example.com"}))
1852 .await
1853 .expect_err("project policy must intercept browser delegate calls");
1854 assert!(err.contains("operator policy"), "{err}");
1855 assert!(err.contains("browse_navigate"), "{err}");
1856 }
1857
1858 #[test]
1859 fn malformed_project_policy_refuses_coder_session() {
1860 let dir = tempfile::tempdir().unwrap();
1861 let policies = dir.path().join(".car").join("policies");
1862 std::fs::create_dir_all(&policies).unwrap();
1863 std::fs::write(
1864 policies.join("broken.toml"),
1865 "deny_tool = [this is not TOML\n",
1866 )
1867 .unwrap();
1868
1869 let err = WorktreeExecutor::for_coder_session(dir.path())
1870 .err()
1871 .expect("a session must not start with silently missing denies");
1872 assert!(err.contains("refusing to start coder session"), "{err}");
1873 assert!(err.contains("operator policy"), "{err}");
1874 assert!(err.contains("broken.toml"), "{err}");
1875 }
1876
1877 /// Both coder entry points go through `for_coder_session`, so the delegate
1878 /// and the policy subject cannot drift apart between them
1879 /// (Parslee-ai/car#1063). Dropping either from the shared constructor fails
1880 /// here.
1881 /// Attaching a delegate must not make it callable.
1882 ///
1883 /// Dispatch used to key on `delegate_defs` — what the delegate *offers* —
1884 /// and return before both the path clamp and the inspector chain, so a
1885 /// coding run that advertised only the built-ins could still invoke a
1886 /// `parslee_*` tool by name. `parslee_generate_document` saves to the
1887 /// user's connected drive, and the per-agent gate does not catch it because
1888 /// an unrecognised name classifies as `ReadOnly`.
1889 ///
1890 /// This test is about REACHABILITY, not advertisement — the sibling test
1891 /// covering the advertised list would still pass with the hole open.
1892 #[tokio::test]
1893 async fn an_unadvertised_delegate_tool_is_not_reachable() {
1894 let dir = tempfile::tempdir().unwrap();
1895 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
1896
1897 // The coding loop advertises the static built-ins and never calls
1898 // advertise_delegates().
1899 assert!(
1900 !exec.delegates_reachable(),
1901 "delegates must be closed until a run advertises them"
1902 );
1903
1904 let delegate_name = crate::parslee_tools::ParsleeToolExecutor::tool_defs()
1905 .first()
1906 .and_then(|d| d["name"].as_str().map(String::from))
1907 .expect("the parslee delegate advertises at least one tool");
1908
1909 let err = exec
1910 .execute(&delegate_name, &json!({}))
1911 .await
1912 .expect_err("an unadvertised delegate name must not dispatch");
1913 assert!(
1914 err.contains("unknown tool"),
1915 "expected it to fall through to the ordinary path, got: {err}"
1916 );
1917
1918 // And a run that DOES advertise the surface still reaches it, so the
1919 // agent-build path is unaffected.
1920 exec.advertise_delegates();
1921 assert!(exec.delegates_reachable());
1922 }
1923
1924 #[tokio::test]
1925 async fn for_coder_session_carries_the_parslee_delegate_and_policy_subject() {
1926 let dir = tempfile::tempdir().unwrap();
1927 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
1928
1929 let names: Vec<String> = exec
1930 .all_tool_defs()
1931 .iter()
1932 .filter_map(|d| d["name"].as_str().map(String::from))
1933 .collect();
1934 for parslee in crate::parslee_tools::ParsleeToolExecutor::tool_names() {
1935 assert!(
1936 names.contains(&parslee),
1937 "{parslee} missing from all_tool_defs: {names:?}"
1938 );
1939 }
1940 assert!(names.iter().any(|n| n == "read_file")); // built-ins still present
1941
1942 // The stable `car-coder` policy subject is what the Agent Permissions
1943 // screen denies against.
1944 assert_eq!(exec.agent_id.as_deref(), Some("car-coder"));
1945 }
1946
1947 /// The governed network pair reaches a coder session (car#1073), and stays
1948 /// default-closed while it does.
1949 ///
1950 /// The tier assertion is the load-bearing half. `full_access` is what makes
1951 /// the per-agent gate hard-block these until an operator grants the tier; a
1952 /// re-tier to `read_only` would hand every unattended coder run the network
1953 /// silently, and would still pass a test that only checked attachment.
1954 #[tokio::test]
1955 async fn for_coder_session_carries_the_governed_network_pair_at_full_access() {
1956 let dir = tempfile::tempdir().unwrap();
1957 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
1958
1959 for tool in ["http_request", "web_search"] {
1960 let defs = exec.delegate_defs_named(tool);
1961 assert_eq!(defs.len(), 1, "{tool} must be attached exactly once");
1962 assert_eq!(
1963 defs[0]["tier"], "full_access",
1964 "{tool} must stay full_access — that tier is what keeps the \
1965 per-agent gate closed by default"
1966 );
1967 }
1968 }
1969
1970 /// A third delegate must not shadow a name an earlier one owns. Collisions
1971 /// resolve first-wins, so an overlap would be silent at runtime.
1972 #[tokio::test]
1973 async fn coder_session_delegates_do_not_collide() {
1974 let dir = tempfile::tempdir().unwrap();
1975 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
1976 assert_eq!(
1977 exec.delegate_name_collisions(),
1978 Vec::<String>::new(),
1979 "two delegates advertise the same tool name"
1980 );
1981 }
1982
1983 /// With no per-agent subject there is no gate, so nothing is withheld — the
1984 /// `None` arm of the accessor a loop consults before offering a
1985 /// `full_access` tool.
1986 #[test]
1987 fn an_executor_with_no_agent_subject_permits_full_access() {
1988 let dir = tempfile::tempdir().unwrap();
1989 assert!(WorktreeExecutor::new(dir.path()).permits_full_access());
1990 }
1991
1992 #[cfg(unix)]
1993 struct TestProcessGroup {
1994 pgid: i32,
1995 armed: bool,
1996 }
1997
1998 #[cfg(unix)]
1999 impl TestProcessGroup {
2000 fn from_file(path: &Path) -> Self {
2001 let pgid = std::fs::read_to_string(path)
2002 .unwrap_or_else(|error| {
2003 panic!("read fixture process group {}: {error}", path.display())
2004 })
2005 .trim()
2006 .parse()
2007 .unwrap_or_else(|error| {
2008 panic!("parse fixture process group {}: {error}", path.display())
2009 });
2010 Self { pgid, armed: true }
2011 }
2012
2013 fn exists(&self) -> bool {
2014 let result = unsafe { libc::killpg(self.pgid, 0) };
2015 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
2016 }
2017
2018 async fn assert_reaped(mut self, outcome: &str) {
2019 for _ in 0..100 {
2020 if !self.exists() {
2021 self.armed = false;
2022 return;
2023 }
2024 tokio::time::sleep(Duration::from_millis(10)).await;
2025 }
2026 panic!(
2027 "shell process group {} survived the {outcome} path",
2028 self.pgid
2029 );
2030 }
2031 }
2032
2033 #[cfg(unix)]
2034 impl Drop for TestProcessGroup {
2035 fn drop(&mut self) {
2036 if self.armed {
2037 unsafe {
2038 libc::killpg(self.pgid, libc::SIGKILL);
2039 }
2040 }
2041 }
2042 }
2043
2044 #[cfg(unix)]
2045 async fn wait_for_fixture_file(path: &Path) {
2046 for _ in 0..200 {
2047 if path.is_file() {
2048 return;
2049 }
2050 tokio::time::sleep(Duration::from_millis(10)).await;
2051 }
2052 panic!("fixture did not write {}", path.display());
2053 }
2054
2055 #[cfg(unix)]
2056 fn publish_fixture_process_group(group_file: &Path) -> String {
2057 // The path is the readiness signal. Publish only after printf closes
2058 // the temporary file, so cancellation cannot observe an empty PID.
2059 // Match the executor's canonical worktree on macOS (/var -> /private/var).
2060 let group_file = group_file
2061 .parent()
2062 .unwrap()
2063 .canonicalize()
2064 .unwrap()
2065 .join(group_file.file_name().unwrap());
2066 let temporary = group_file.with_extension("pgid.pending");
2067 format!(
2068 "printf '%s' \"$$\" > {} && mv -f {} {}",
2069 sh_single_quote(&temporary.display().to_string()),
2070 sh_single_quote(&temporary.display().to_string()),
2071 sh_single_quote(&group_file.display().to_string()),
2072 )
2073 }
2074
2075 #[cfg(unix)]
2076 fn long_lived_shell_command(group_file: &Path, finish: &str) -> String {
2077 format!(
2078 "{}; trap '' HUP; sleep 120 >/dev/null 2>&1 & {finish}",
2079 publish_fixture_process_group(group_file)
2080 )
2081 }
2082
2083 /// A shell returning zero or non-zero does not grant its detached children
2084 /// a lifetime beyond the tool call. This reproduces the reported
2085 /// `car-server --no-auth` plus `car do --serve` shape reparented to pid 1
2086 /// after a foreground review completed, without claiming which command
2087 /// originally launched those observed processes.
2088 #[cfg(unix)]
2089 #[tokio::test]
2090 async fn local_shell_lifecycle_reaps_background_descendants_after_completed_outcomes() {
2091 let (dir, exec) = executor();
2092 for (name, finish, expected) in [("success", "exit 0", 0), ("error", "exit 7", 7)] {
2093 let group_file = dir.path().join(format!("{name}.pgid"));
2094 let out = exec
2095 .run_shell(&long_lived_shell_command(&group_file, finish), Some(10))
2096 .await
2097 .unwrap();
2098 assert_eq!(out["exit_code"], expected);
2099 TestProcessGroup::from_file(&group_file)
2100 .assert_reaped(name)
2101 .await;
2102 }
2103 }
2104
2105 /// Dropping an in-flight shell future is the cancellation primitive used by
2106 /// the foreground CLI's SIGINT/SIGTERM path. The guard must sweep the group
2107 /// even though `run_shell_on` never reaches its ordinary return cleanup.
2108 #[cfg(unix)]
2109 #[tokio::test]
2110 async fn local_shell_lifecycle_reaps_process_group_on_cancellation() {
2111 let (dir, exec) = executor();
2112 let group_file = dir.path().join("cancelled.pgid");
2113 let command = format!(
2114 "{}; exec sleep 120",
2115 publish_fixture_process_group(&group_file)
2116 );
2117 let task = tokio::spawn(async move { exec.run_shell(&command, Some(180)).await });
2118 wait_for_fixture_file(&group_file).await;
2119 let group = TestProcessGroup::from_file(&group_file);
2120 task.abort();
2121 assert!(task.await.unwrap_err().is_cancelled());
2122 group.assert_reaped("cancellation").await;
2123 }
2124
2125 /// Rust unwinding drops local futures. Keep that path load-bearing because
2126 /// `kill_on_drop` owns only the direct shell; without the group guard a
2127 /// panic still leaves grandchildren behind.
2128 #[cfg(unix)]
2129 #[tokio::test]
2130 async fn local_shell_lifecycle_reaps_process_group_on_panic() {
2131 let (dir, exec) = executor();
2132 let group_file = dir.path().join("panic.pgid");
2133 let task_group_file = group_file.clone();
2134 let command = format!(
2135 "{}; exec sleep 120",
2136 publish_fixture_process_group(&group_file)
2137 );
2138 let task = tokio::spawn(async move {
2139 let shell = exec.run_shell(&command, Some(180));
2140 tokio::pin!(shell);
2141 tokio::select! {
2142 result = &mut shell => panic!("fixture shell returned before panic: {result:?}"),
2143 _ = wait_for_fixture_file(&task_group_file) => panic!("intentional lifecycle fixture panic"),
2144 }
2145 });
2146 let join = task.await.unwrap_err();
2147 assert!(join.is_panic());
2148 TestProcessGroup::from_file(&group_file)
2149 .assert_reaped("panic")
2150 .await;
2151 }
2152
2153 #[test]
2154 fn tail_respects_char_boundaries() {
2155 let s = "ééééé"; // 2 bytes each
2156 let t = tail(s, 3);
2157 assert!(t.ends_with('é'));
2158 }
2159
2160 /// The two shell entry points read DIFFERENT ceilings, and that separation
2161 /// is the whole shape of the car#1065 fix: `run_check_shell` honors the
2162 /// operator's contract-check ceiling, `run_shell` — the one behind the
2163 /// model's `shell` tool — stays pinned at [`MAX_SHELL_TIMEOUT_SECS`].
2164 ///
2165 /// Read at a ceiling of 1s rather than a raised one so the assertion costs
2166 /// three seconds instead of ten minutes; the direction under test is which
2167 /// ceiling each path reads, and that is the same either way.
2168 #[cfg(unix)]
2169 #[tokio::test]
2170 async fn the_check_ceiling_binds_run_check_shell_and_not_the_model_facing_shell() {
2171 let dir = tempfile::tempdir().unwrap();
2172 let exec = WorktreeExecutor::new(dir.path()).with_check_timeout_ceiling(1);
2173
2174 let checked = exec.run_check_shell("sleep 3", Some(10)).await.unwrap();
2175 assert_eq!(
2176 checked["timed_out"], true,
2177 "a contract check is bound by the executor's check ceiling"
2178 );
2179
2180 let modelled = exec.run_shell("sleep 3", Some(10)).await.unwrap();
2181 assert_eq!(
2182 modelled["timed_out"], false,
2183 "the model's own shell keeps the advertised 600s ceiling — a slow \
2184 test gate is not a licence to hang"
2185 );
2186 }
2187
2188 /// A contract check runs the SAME inspector chain as the model's shell, so
2189 /// `DenyCredentialAccess` refuses it on the command text — and that check
2190 /// is a substring matcher, not a boundary.
2191 ///
2192 /// Both directions are asserted, because `docs/car-code-task.md` now states
2193 /// both beside the contract input and either one alone reads as a promise
2194 /// the code does not keep. Denying only would suggest a check is sealed off
2195 /// from credentials; it is not, since `ForgeCredentials::Inherit` leaves the
2196 /// environment intact and an unmarked spelling carries no marker to match
2197 /// (car#1066).
2198 #[cfg(unix)]
2199 #[tokio::test]
2200 async fn a_contract_check_may_not_name_a_credential_even_though_it_inherits_one() {
2201 let dir = tempfile::tempdir().unwrap();
2202 let exec = WorktreeExecutor::new(dir.path());
2203
2204 for command in [
2205 "curl -H \"Authorization: Bearer $STAGING_API_TOKEN\" https://example.invalid/health",
2206 "sqlcmd -Q \"select 1\" -C \"$DB_CONNECTION_STRING\"",
2207 "cat ~/.aws/credentials",
2208 ] {
2209 let err = exec
2210 .run_check_shell(command, Some(5))
2211 .await
2212 .expect_err("a contract check naming a credential is refused");
2213 assert!(
2214 err.starts_with("denied by policy:"),
2215 "expected a policy refusal for {command:?}, got {err}"
2216 );
2217 }
2218
2219 // …and the matcher is hardening, not a sandbox. None of these carries a
2220 // built-in marker, so all of them reach the shell with the daemon's
2221 // environment still intact. A contract author must not read the
2222 // refusals above as "a check cannot touch a credential".
2223 for command in [
2224 "echo \"Authorization: Bearer $TOKEN\"",
2225 "echo \"$DBURL\"",
2226 "echo ok",
2227 ] {
2228 let out = exec
2229 .run_check_shell(command, Some(5))
2230 .await
2231 .unwrap_or_else(|e| panic!("expected {command:?} to reach the shell, got {e}"));
2232 assert_eq!(out["exit_code"], 0, "{out}");
2233 }
2234 }
2235
2236 /// The default is the constant the tool description advertises, and a `0`
2237 /// from config is floored rather than honored (it would clamp every check
2238 /// to one second).
2239 #[test]
2240 fn the_check_ceiling_defaults_to_the_shell_max_and_floors_zero() {
2241 let dir = tempfile::tempdir().unwrap();
2242 assert_eq!(
2243 WorktreeExecutor::new(dir.path()).check_timeout_ceiling(),
2244 MAX_SHELL_TIMEOUT_SECS
2245 );
2246 assert_eq!(
2247 WorktreeExecutor::new(dir.path())
2248 .with_check_timeout_ceiling(0)
2249 .check_timeout_ceiling(),
2250 1
2251 );
2252 assert_eq!(
2253 WorktreeExecutor::new(dir.path())
2254 .with_check_timeout_ceiling(1800)
2255 .check_timeout_ceiling(),
2256 1800
2257 );
2258 }
2259}