Skip to main content

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}