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