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}