differential_engine/llm.rs
1//! LLM backend abstraction (ADR 0016; an engine module since ADR 0018).
2//!
3//! Nothing else in the engine may reach into subprocess machinery: grouping
4//! and the pipeline consume only `LlmBackend`/`CommandBackend` from here.
5//!
6//! The grouping stage needs exactly one capability from a model: one-shot text
7//! completion — prompt in, raw text out. The contract is deliberately that
8//! narrow: no streaming, no chat state, no conversation to manage.
9//!
10//! It stays that narrow now that the model reads for itself (ADR 0022). Tools
11//! run inside the CLI this spawns, so what crosses this seam is still a prompt
12//! and a string. What changed is a flag in the argv below, not the trait.
13//!
14//! There are five agents, one constructor each, and the trait did not move to
15//! make room for them (ADR 0033). What differs between them is the argv, and
16//! the part of the argv that matters is how each one is stopped from writing:
17//!
18//! - **An allowlist** — `claude_cli`, `copilot_cli`. The agent may run the
19//! named tools and nothing else, and the fetch command is one of them.
20//! - **An OS sandbox** — `codex_cli`, `droid_cli`. The agent may run anything
21//! and the kernel refuses the writes, so there is no allowlist to derive and
22//! `fetch` does not appear in the argv.
23//! - **Nothing** — `pi_cli`. Pi ships no sandbox and no per-command allowlist,
24//! and the shell tool it needs to fetch is the one that also lets it write.
25//! That is a decision with a reason, recorded in ADR 0033 and in the
26//! constructor, and `config::Agent::read_only` is how a caller
27//! tells a user about it.
28//!
29//! Adding a sixth means a constructor here, a variant in `config::Agent`, and
30//! an arm in the application layer's `backend_from`. The compiler asks for the
31//! last two; this comment is the only thing that asks for the boundary.
32
33use std::path::{Path, PathBuf};
34use std::sync::Arc;
35use std::sync::atomic::AtomicBool;
36use std::time::Duration;
37
38use crate::subprocess;
39
40#[derive(Debug, thiserror::Error)]
41pub enum LlmError {
42 #[error("failed to spawn {command}: {source}")]
43 Spawn {
44 command: String,
45 #[source]
46 source: std::io::Error,
47 },
48
49 #[error("{command} exited with {code:?}: {stderr}")]
50 Failed {
51 command: String,
52 code: Option<i32>,
53 stderr: String,
54 },
55
56 #[error("{command} produced no output")]
57 Empty { command: String },
58
59 #[error("{command} exceeded the {timeout:?} deadline and was killed")]
60 Timeout { command: String, timeout: Duration },
61
62 #[error("{command} was cancelled and killed")]
63 Cancelled { command: String },
64
65 #[error("io error talking to {command}: {source}")]
66 Io {
67 command: String,
68 #[source]
69 source: std::io::Error,
70 },
71}
72
73/// One-shot completion: prompt in, raw text out.
74pub trait LlmBackend: Send + Sync {
75 /// What to call this agent on screen, for a reviewer waiting on it.
76 ///
77 /// A product name, not a command line: "Claude Code", not `claude -p
78 /// --output-format text --allowed-tools Bash(...),...`. The reviewer is
79 /// waiting to learn *which agent* is thinking, and the argv answers a
80 /// different question at four times the width — it overran the splash line
81 /// the moment the allowlist grew.
82 ///
83 /// The command as it will actually run is still reported where it is the
84 /// answer: `LlmError` carries it, because a spawn failure is debugged with
85 /// the whole argv and nothing less.
86 fn name(&self) -> &str;
87
88 /// Everything about this backend that could change the grouping, and
89 /// nothing that could not. The grouping cache key hashes this (ADR 0009).
90 ///
91 /// Separate from `name` because the two answer different questions. `name`
92 /// is what to show a reviewer, so it is a product name. This is what
93 /// determines the answer, so it is the argv — minus the parts that say
94 /// where this machine keeps things. Hashing a path put the absolute
95 /// location of `dfr` into the key, so a debug build, a release build and
96 /// two checkouts of one commit each re-ran a four-hundred-second call for
97 /// an identical class partition, and the worktree-shared cache
98 /// `plan::grouping_cache_dir` promises was defeated.
99 ///
100 /// Defaults to `name`, which is right for any backend whose identity has no
101 /// environment in it.
102 fn identity(&self) -> &str {
103 self.name()
104 }
105
106 fn complete(&self, prompt: &str) -> Result<String, LlmError>;
107}
108
109/// A subprocess backend: prompt on stdin, completion on stdout.
110pub struct CommandBackend {
111 argv: Vec<String>,
112 timeout: Duration,
113 /// What a reviewer is shown: see [`LlmBackend::name`].
114 name: String,
115 /// The argv as it will actually run, for error text only. A spawn failure
116 /// is debugged with the whole command, and neither `name` nor `identity`
117 /// is that: one is a product name, the other stands a placeholder where
118 /// the executable's path was.
119 command: String,
120 /// See [`LlmBackend::identity`].
121 identity: String,
122 /// Where the child runs.
123 ///
124 /// The prompt hands the model `git diff <base> <head> -- <path>` with paths
125 /// as the document records them, which is relative to the repository root.
126 /// Git resolves a bare pathspec against the **current directory**, not the
127 /// root, so a child inheriting `dfr`'s cwd matches nothing whenever `dfr`
128 /// was run from a subdirectory — and matching nothing is an empty diff and
129 /// exit 0, not an error. The model would then rate a class having seen no
130 /// diff at all, and nothing anywhere would say so.
131 ///
132 /// `None` means inherit, which is right for a child that reads no repository
133 /// (the tests here, and any future backend that takes its whole input on
134 /// stdin).
135 working_dir: Option<PathBuf>,
136 /// Set from another thread to kill an in-flight child (a reviewer
137 /// abandoning the wait). Without this the subprocess would outlive the
138 /// process that asked for it, up to the whole timeout.
139 cancel: Option<Arc<AtomicBool>>,
140}
141
142impl CommandBackend {
143 /// A backend named by its own command line.
144 ///
145 /// The named constructors below are the production path; this is for a
146 /// backend with nothing better to call itself, which in practice means a
147 /// test double.
148 pub fn new(argv: Vec<String>, timeout: Duration) -> Self {
149 assert!(!argv.is_empty(), "CommandBackend needs a program to run");
150 let command = argv.join(" ");
151 CommandBackend {
152 argv,
153 timeout,
154 name: command.clone(),
155 identity: command.clone(),
156 command,
157 working_dir: None,
158 cancel: None,
159 }
160 }
161
162 /// Run the child in `dir`.
163 ///
164 /// The repository root, for any backend whose prompt names repo-relative
165 /// paths — which the default one does. See the field for what goes wrong
166 /// without it, and why it goes wrong silently.
167 pub fn with_working_dir(mut self, dir: &Path) -> Self {
168 self.working_dir = Some(dir.to_path_buf());
169 self
170 }
171
172 /// Kill the child as soon as `flag` is set.
173 pub fn with_cancel(mut self, flag: Arc<AtomicBool>) -> Self {
174 self.cancel = Some(flag);
175 self
176 }
177
178 pub fn with_timeout(mut self, timeout: Duration) -> Self {
179 self.timeout = timeout;
180 self
181 }
182
183 /// The default: headless, text output, and read-only tools (ADR 0022).
184 ///
185 /// ADR 0010 denied tools outright, because the evaluated grouping tool kept
186 /// exiting 1 on `stop_reason: "tool_use"`. Denying them cured it by sending
187 /// no tool definitions at all, so the model could not ask. An allowlist is
188 /// the other cure: it can ask, and the answer is yes.
189 ///
190 /// `fetch` is the executable the prompt tells the model to run — normally
191 /// this process. The allowlist is derived from it, so the two cannot
192 /// disagree about what the model is allowed to invoke.
193 ///
194 /// Nothing here can write. The fetch command reads the document the engine
195 /// just wrote; the rest read the repository. `git log` and `git show` are
196 /// what reach the *reason* a change was made, which no prompt can carry.
197 ///
198 /// **`git diff` is advertised; the rest are not.** The prompt names the
199 /// fetch command and `git diff`, and nothing else.
200 ///
201 /// That is a change of rule, and it is worth saying why. `git diff` is
202 /// advertised because it is now the only way to see what a hunk says: the
203 /// fetch command's `diff` query is gone, having duplicated `class` except
204 /// for the text. A tool the model must use and is not told about is a tool
205 /// it will not use.
206 ///
207 /// It costs an invitation to read the whole repository, and the prompt is
208 /// what pays for that: it says to read what decides a label and then stop.
209 ///
210 /// It no longer costs a route around the generated content this stage folds
211 /// away, though it did when it was written. `generated` is part of the
212 /// shape-class key now (ADR 0004), so no class the model is given contains
213 /// a generated file and there is nothing folded left for it to ask
214 /// `git diff` about by accident. The prompt still says not to go looking.
215 ///
216 /// `Read`, `Grep`, `Glob`, `git log` and `git show` stay unadvertised for
217 /// the original reason: a model that needs the code around a hunk can go
218 /// and read it, but it is not sent looking. If you add a tool here, do not
219 /// add a line about it to the prompt.
220 ///
221 /// The allowlist is this function's business, not the user's, and there is
222 /// no config that replaces it. `[grouping].agent` picks between agents by
223 /// name; it used to take a free argv, which handed a stranger's process the
224 /// prompt and none of the allowlist, fetch command or read path the prompt
225 /// is written for.
226 ///
227 /// `fetch` is where a binary lives, so it is the one part of this argv that
228 /// says nothing about what the model will do. The cache identity stands a
229 /// placeholder in its place: change the allowlist and every cached grouping
230 /// is rightly invalidated, move the binary and none of them are.
231 ///
232 /// **`--permission-mode default` is what makes the allowlist mean anything,
233 /// and it was missing for two releases** (ADR 0033). `--allowed-tools` ADDS
234 /// permissions; it does not cap them. A user whose own settings set
235 /// `defaultMode` to `auto`, `acceptEdits` or `bypassPermissions` was
236 /// handing this call an agent that could write, commit and push, and
237 /// nothing anywhere said so. `default` means ask, and a headless call has
238 /// nobody to ask, so the answer is no.
239 ///
240 /// It was found by `dfr agents --probe`, on the first run, against the
241 /// agent that had shipped as the only option. That is the whole argument
242 /// for the probe existing.
243 pub fn claude_cli(fetch: &str) -> Self {
244 let mut b = Self::new(Self::claude_argv(fetch), Duration::from_secs(1200));
245 b.name = "Claude Code".to_string();
246 b.identity = Self::claude_argv("<fetch>").join(" ");
247 b
248 }
249
250 fn claude_argv(fetch: &str) -> Vec<String> {
251 vec![
252 "claude".to_string(),
253 "-p".to_string(),
254 "--output-format".to_string(),
255 "text".to_string(),
256 "--permission-mode".to_string(),
257 "default".to_string(),
258 "--allowed-tools".to_string(),
259 format!(
260 "Bash({fetch} agent:*),Bash(git diff:*),Read,Grep,Glob,\
261 Bash(git log:*),Bash(git show:*)"
262 ),
263 ]
264 }
265
266 /// Headless `codex exec`, read-only by OS sandbox (ADR 0033).
267 ///
268 /// Codex has no tool allowlist and needs none: `--sandbox read-only` is
269 /// enforced by the kernel — Seatbelt on macOS, bubblewrap on Linux — so the
270 /// model may run any command it likes and the writes are refused beneath
271 /// it. That is a different boundary from Claude Code's and an equally real
272 /// one, which is why `fetch` does not appear in this argv at all. The
273 /// prompt still names the fetch command; nothing has to permit it.
274 ///
275 /// `-c approval_policy="never"` is the headless half. Without it a command
276 /// the sandbox refuses escalates to a human who is not there, and the call
277 /// sits until the deadline kills it. With it the refusal returns to the
278 /// model as a tool failure, which is what we want it to see.
279 ///
280 /// It is a config override rather than the `--ask-for-approval` flag the
281 /// docs name, because **that flag does not exist on `codex exec`** — it is
282 /// on the interactive top-level command only, and `codex exec` rejects it
283 /// outright. Checked against 0.154.0, where passing it is
284 /// `error: unexpected argument`, which is a failure to spawn rather than a
285 /// bad grouping.
286 ///
287 /// `codex exec` already defaults to never asking, so this says out loud
288 /// what is currently true anyway. That is the point: a boundary resting on
289 /// another program's default is one release away from being no boundary,
290 /// and `--ignore-user-config` means nothing on disk can move it back.
291 ///
292 /// `--color never` keeps stdout clean. The response parser takes the text
293 /// between the first `{` and the last `}`, and an escape sequence inside
294 /// that span is a parse error with a sample nobody can read.
295 ///
296 /// The trailing `-` makes stdin the whole prompt. Codex will otherwise
297 /// treat stdin as context for an argv instruction, and there is no argv
298 /// instruction here.
299 ///
300 /// Never pass `--full-auto`, `--yolo` or
301 /// `--dangerously-bypass-approvals-and-sandbox`: each removes the boundary.
302 ///
303 /// `--ignore-user-config` and `--ignore-rules` are the same lesson Claude
304 /// Code taught (ADR 0033): the sandbox a flag asks for is not the sandbox
305 /// that runs if the user's own `config.toml` or execpolicy rules say
306 /// otherwise. An argv that can be widened by a file this crate never reads
307 /// is not a boundary, it is a request.
308 pub fn codex_cli() -> Self {
309 let mut b = Self::new(Self::codex_argv(), Duration::from_secs(1200));
310 b.name = "Codex".to_string();
311 b.identity = Self::codex_argv().join(" ");
312 b
313 }
314
315 fn codex_argv() -> Vec<String> {
316 vec![
317 "codex".to_string(),
318 "exec".to_string(),
319 "--ignore-user-config".to_string(),
320 "--ignore-rules".to_string(),
321 "-c".to_string(),
322 "approval_policy=\"never\"".to_string(),
323 "--sandbox".to_string(),
324 "read-only".to_string(),
325 "--color".to_string(),
326 "never".to_string(),
327 "-".to_string(),
328 ]
329 }
330
331 /// Headless `droid exec`, read-only by default (ADR 0033).
332 ///
333 /// Droid is the one agent whose boundary is what this function does NOT
334 /// pass. Its documented default is read-only file inspection plus git read
335 /// operations, with file edits, package installs and git writes blocked,
336 /// and a blocked action fails rather than asking — so a bare `droid exec`
337 /// neither writes nor stalls.
338 ///
339 /// Never pass `--auto` at any level, and never
340 /// `--skip-permissions-unsafe`. Each is the whole boundary, given away.
341 ///
342 /// `-o text` prints the final message only. `-` makes stdin the prompt.
343 pub fn droid_cli() -> Self {
344 let mut b = Self::new(Self::droid_argv(), Duration::from_secs(1200));
345 b.name = "Droid".to_string();
346 b.identity = Self::droid_argv().join(" ");
347 b
348 }
349
350 fn droid_argv() -> Vec<String> {
351 vec![
352 "droid".to_string(),
353 "exec".to_string(),
354 "-o".to_string(),
355 "text".to_string(),
356 "-".to_string(),
357 ]
358 }
359
360 /// Headless `copilot`, read-only by allowlist and an explicit deny
361 /// (ADR 0033).
362 ///
363 /// The closest of the five to Claude Code: an allowlist derived from
364 /// `fetch`, so the prompt can never name a command the model may not run.
365 ///
366 /// **There is deliberately no `-p`.** Copilot reads the prompt from stdin,
367 /// and its own documentation says piped input is ignored when `-p` is
368 /// given. Passing both would send an empty prompt and waste a call.
369 ///
370 /// `-s` suppresses the session decoration around the reply, for the same
371 /// reason Codex gets `--color never`. `--no-ask-user` stops the agent
372 /// pausing for a human who is not there.
373 ///
374 /// `--deny-tool write` is belt and braces: `write` is already absent from
375 /// the allowlist, and a deny takes precedence over any allow, so the two
376 /// cannot be talked out of agreeing.
377 ///
378 /// Never pass `--allow-all-tools` or `--allow-all-paths`.
379 pub fn copilot_cli(fetch: &str) -> Self {
380 let mut b = Self::new(Self::copilot_argv(fetch), Duration::from_secs(1200));
381 b.name = "GitHub Copilot".to_string();
382 b.identity = Self::copilot_argv("<fetch>").join(" ");
383 b
384 }
385
386 fn copilot_argv(fetch: &str) -> Vec<String> {
387 vec![
388 "copilot".to_string(),
389 "-s".to_string(),
390 "--no-ask-user".to_string(),
391 "--deny-tool".to_string(),
392 "write".to_string(),
393 "--allow-tool".to_string(),
394 format!("read,shell(git:*),shell({fetch}:*)"),
395 ]
396 }
397
398 /// Headless `pi`. **Read-only is NOT enforced here** (ADR 0033).
399 ///
400 /// Every other constructor in this file hands the model a boundary. This
401 /// one cannot, and the reason is Pi's design rather than an oversight in
402 /// this argv.
403 ///
404 /// Pi ships no sandbox, no per-command allowlist and no approval prompts.
405 /// Its `-t` flag toggles whole tools, and `bash` is one tool: the model
406 /// needs it to run the fetch command and `git diff`, and the same tool lets
407 /// it write a file, commit or push. Nothing but the prompt asks it not to.
408 ///
409 /// Dropping `bash` would restore the boundary and take the change with it.
410 /// The model would be back to grouping from class ids alone, which is the
411 /// truncated payload ADR 0022 was written to end — a worse grouping, every
412 /// time, in exchange for a risk the prompt never asks anyone to take.
413 ///
414 /// So the author chose this knowingly, and the duty that comes with it is
415 /// disclosure: `Agent::read_only` answers `NotEnforced` for Pi, and
416 /// every place that offers the name says so.
417 ///
418 /// The rest of the argv is hermetic sealing, and it is not decoration.
419 /// `-nc` drops `AGENTS.md` and `CLAUDE.md`, `-na` drops the repository's
420 /// own `.pi/` config, and `--no-extensions --no-skills` drop the user's.
421 /// Each is a file outside the cache key that could otherwise change a
422 /// grouping, which is the hole ADR 0022 names and cannot close.
423 /// `--no-session` stops Pi writing a session file for a call nobody
424 /// resumes.
425 pub fn pi_cli() -> Self {
426 let mut b = Self::new(Self::pi_argv(), Duration::from_secs(1200));
427 b.name = "Pi".to_string();
428 b.identity = Self::pi_argv().join(" ");
429 b
430 }
431
432 fn pi_argv() -> Vec<String> {
433 vec![
434 "pi".to_string(),
435 "-p".to_string(),
436 "--mode".to_string(),
437 "text".to_string(),
438 "--no-session".to_string(),
439 "-nc".to_string(),
440 "-na".to_string(),
441 "--no-extensions".to_string(),
442 "--no-skills".to_string(),
443 "-t".to_string(),
444 "read,grep,find,ls,bash".to_string(),
445 ]
446 }
447
448 /// The program this backend spawns, for a caller checking `PATH`.
449 ///
450 /// `dfr agents` says whether each agent is installed, and the answer has to
451 /// come from the argv that will actually run rather than from a second list
452 /// of executable names that could disagree with it.
453 pub fn program(&self) -> &str {
454 &self.argv[0]
455 }
456
457 /// The whole command line, for a caller showing what will run.
458 ///
459 /// Not [`LlmBackend::name`], which is a product name, and not
460 /// [`LlmBackend::identity`], which stands a placeholder where the binary
461 /// path is. This is the argv itself, and the two callers that want it are a
462 /// spawn failure and `dfr agents`.
463 pub fn command(&self) -> &str {
464 &self.command
465 }
466}
467
468impl LlmBackend for CommandBackend {
469 fn name(&self) -> &str {
470 &self.name
471 }
472
473 fn identity(&self) -> &str {
474 &self.identity
475 }
476
477 fn complete(&self, prompt: &str) -> Result<String, LlmError> {
478 let command = || self.command.clone();
479 let out = subprocess::run(&subprocess::Run {
480 argv: &self.argv,
481 stdin: Some(prompt.as_bytes()),
482 working_dir: self.working_dir.as_deref(),
483 timeout: self.timeout,
484 cancel: self.cancel.as_ref(),
485 })
486 .map_err(|f| match f {
487 subprocess::Failure::Spawn(source) => LlmError::Spawn {
488 command: command(),
489 source,
490 },
491 subprocess::Failure::Io(source) => LlmError::Io {
492 command: command(),
493 source,
494 },
495 subprocess::Failure::Timeout => LlmError::Timeout {
496 command: command(),
497 timeout: self.timeout,
498 },
499 subprocess::Failure::Cancelled => LlmError::Cancelled { command: command() },
500 })?;
501
502 if !out.status.success() {
503 return Err(LlmError::Failed {
504 command: command(),
505 code: out.status.code(),
506 stderr: subprocess::stderr_excerpt(&out.stderr, 600),
507 });
508 }
509 let text = String::from_utf8_lossy(&out.stdout).into_owned();
510 if text.trim().is_empty() {
511 return Err(LlmError::Empty { command: command() });
512 }
513 Ok(text)
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use std::sync::atomic::Ordering;
520
521 use super::*;
522
523 #[test]
524 fn cat_echoes_the_prompt() {
525 let b = CommandBackend::new(vec!["cat".into()], Duration::from_secs(10));
526 let out = b.complete("hello prompt\n").unwrap();
527 assert_eq!(out, "hello prompt\n");
528 }
529
530 #[test]
531 fn nonzero_exit_is_failed() {
532 let b = CommandBackend::new(vec!["false".into()], Duration::from_secs(10));
533 match b.complete("x") {
534 Err(LlmError::Failed { code, .. }) => assert_eq!(code, Some(1)),
535 other => panic!("expected Failed, got {other:?}"),
536 }
537 }
538
539 #[test]
540 fn empty_output_is_an_error() {
541 let b = CommandBackend::new(vec!["true".into()], Duration::from_secs(10));
542 match b.complete("x") {
543 Err(LlmError::Empty { .. }) => {}
544 other => panic!("expected Empty, got {other:?}"),
545 }
546 }
547
548 #[test]
549 fn cancel_kills_the_child() {
550 // A long sleep with a generous deadline: only the cancel flag can end
551 // this, and it must do so promptly rather than leaving the child to
552 // outlive the caller.
553 let flag = Arc::new(AtomicBool::new(false));
554 let backend =
555 CommandBackend::new(vec!["sleep".into(), "600".into()], Duration::from_secs(600))
556 .with_cancel(Arc::clone(&flag));
557 let started = std::time::Instant::now();
558 std::thread::spawn(move || {
559 std::thread::sleep(Duration::from_millis(100));
560 flag.store(true, Ordering::Relaxed);
561 });
562 let err = backend.complete("hello").unwrap_err();
563 assert!(
564 matches!(err, LlmError::Cancelled { .. }),
565 "expected cancellation, got {err:?}"
566 );
567 assert!(
568 started.elapsed() < Duration::from_secs(5),
569 "child was not killed promptly"
570 );
571 }
572
573 #[test]
574 fn deadline_kills_the_child() {
575 let b = CommandBackend::new(
576 vec!["sleep".into(), "30".into()],
577 Duration::from_millis(200),
578 );
579 let started = std::time::Instant::now();
580 match b.complete("x") {
581 Err(LlmError::Timeout { .. }) => {}
582 other => panic!("expected Timeout, got {other:?}"),
583 }
584 assert!(
585 started.elapsed() < Duration::from_secs(5),
586 "child was not killed promptly"
587 );
588 }
589
590 #[test]
591 fn large_prompt_does_not_deadlock() {
592 // A prompt bigger than the pipe buffer, against a child that echoes
593 // while reading: the writer thread prevents the classic deadlock.
594 let b = CommandBackend::new(vec!["cat".into()], Duration::from_secs(30));
595 let big = "line of prompt text\n".repeat(60_000); // ~1.2 MB
596 let out = b.complete(&big).unwrap();
597 assert_eq!(out.len(), big.len());
598 }
599
600 #[test]
601 fn where_the_binary_lives_is_not_part_of_the_cache_identity() {
602 // The grouping cache key hashes `identity`. If it hashed the argv the
603 // absolute path would be in the key, and a debug build, a release build
604 // and a second checkout of the same commit would each re-run a
605 // four-hundred-second call over an identical class partition.
606 let a = CommandBackend::claude_cli("/Users/someone/.cargo/bin/dfr");
607 let b = CommandBackend::claude_cli("/srv/ci/target/release/dfr");
608 assert_eq!(a.identity(), b.identity());
609 assert!(!a.identity().contains(".cargo"), "{}", a.identity());
610
611 // A backend with nothing better to call itself is its own identity, and
612 // two different agents must never share a cache entry.
613 let one = CommandBackend::new(vec!["agent-one".into()], Duration::from_secs(1));
614 let two = CommandBackend::new(vec!["agent-two".into()], Duration::from_secs(1));
615 assert_eq!(one.identity(), one.name());
616 assert_ne!(one.identity(), two.identity());
617 }
618
619 #[test]
620 fn the_child_runs_where_it_was_told_to() {
621 // The prompt hands the model repo-root-relative paths for `git diff`.
622 // Git resolves a bare pathspec against the CURRENT DIRECTORY, so a child
623 // inheriting this process's cwd matches nothing whenever `dfr` ran from
624 // a subdirectory — and matching nothing is an empty diff and exit 0, not
625 // an error. The model would rate a class having seen no diff, and
626 // nothing would say so. Hence a test on the cwd itself.
627 let dir = tempfile::TempDir::new().unwrap();
628 // The temp dir may be a symlink (/var -> /private/var on macOS), so
629 // compare what the child reports against the canonical form.
630 let want = dir.path().canonicalize().unwrap();
631 let b = CommandBackend::new(vec!["pwd".into()], Duration::from_secs(10))
632 .with_working_dir(dir.path());
633 let got = b.complete("x").unwrap();
634 assert_eq!(
635 std::path::Path::new(got.trim()).canonicalize().unwrap(),
636 want,
637 "the child must run in the directory it was given"
638 );
639
640 // Without it, the child inherits — which is right for a backend that
641 // reads no repository, and wrong for one whose prompt names paths.
642 let inherit = CommandBackend::new(vec!["pwd".into()], Duration::from_secs(10));
643 assert_ne!(
644 std::path::Path::new(inherit.complete("x").unwrap().trim())
645 .canonicalize()
646 .unwrap(),
647 want
648 );
649 }
650
651 #[test]
652 fn the_reviewer_sees_a_product_name_and_an_error_sees_the_command() {
653 // The splash prints `name` on one line. The argv is four times the
654 // width and answers a different question, so it lives where it is the
655 // answer: a spawn failure.
656 let b = CommandBackend::claude_cli("/opt/bin/dfr");
657 assert_eq!(b.name(), "Claude Code");
658
659 let missing = CommandBackend::new(
660 vec!["definitely-not-a-real-program".into()],
661 Duration::from_secs(1),
662 );
663 match missing.complete("x") {
664 Err(LlmError::Spawn { command, .. }) => {
665 assert_eq!(command, "definitely-not-a-real-program");
666 }
667 other => panic!("expected Spawn, got {other:?}"),
668 }
669 }
670
671 #[test]
672 fn changing_the_allowlist_does_change_the_cache_identity() {
673 // The other half of the rule: the allowlist shapes what the model can
674 // see, so it must stay in the key even though the path does not.
675 let b = CommandBackend::claude_cli("/opt/bin/dfr");
676 assert!(b.identity().contains("Read,Grep,Glob"), "{}", b.identity());
677 assert!(!b.identity().contains("/opt/bin"), "{}", b.identity());
678 }
679
680 #[test]
681 fn claude_cli_default_allows_reading_and_nothing_else() {
682 let b = CommandBackend::claude_cli("/opt/bin/dfr");
683 let argv = &b.command;
684 assert!(
685 argv.contains("Bash(/opt/bin/dfr agent:*)"),
686 "the allowlist names the same executable the prompt does"
687 );
688 assert!(
689 argv.contains("Bash(git diff:*)"),
690 "the prompt tells the model to run git diff, so it must be permitted"
691 );
692 // The whole list, exactly. The argv is built with a line continuation,
693 // and a stray space inside one would produce an allowlist that parses
694 // as something else. This is the security boundary, and a broken fetch
695 // costs minutes of a model working around it, so it fails here loudly
696 // rather than there silently.
697 assert!(
698 argv.ends_with(
699 "--allowed-tools Bash(/opt/bin/dfr agent:*),Bash(git diff:*),Read,Grep,Glob,Bash(git log:*),Bash(git show:*)"
700 ),
701 "{argv}"
702 );
703 // Without this the allowlist is advisory: `--allowed-tools` adds
704 // permissions and does not cap them, so a user whose settings set
705 // `defaultMode` to `auto` got an agent that could write. `dfr agents
706 // --probe` caught it; this line is what stops it coming back.
707 assert!(
708 argv.contains("--permission-mode default"),
709 "the allowlist only binds under the default permission mode: {argv}"
710 );
711 // The allowlist is the security boundary, so the test states what must
712 // stay OUT of it, not merely what is in it.
713 for forbidden in [
714 "Write",
715 "Edit",
716 "Bash(git commit",
717 "Bash(git push",
718 "WebFetch",
719 ] {
720 assert!(!argv.contains(forbidden), "{forbidden} must not be allowed");
721 }
722 }
723
724 /// Every backend this crate builds, for the tests that must hold across all
725 /// of them. A new agent belongs here, and two of the tests below fail until
726 /// it is.
727 fn every_backend() -> Vec<(&'static str, CommandBackend)> {
728 vec![
729 ("claude-code", CommandBackend::claude_cli("/opt/bin/dfr")),
730 ("codex", CommandBackend::codex_cli()),
731 ("droid", CommandBackend::droid_cli()),
732 ("copilot", CommandBackend::copilot_cli("/opt/bin/dfr")),
733 ("pi", CommandBackend::pi_cli()),
734 ]
735 }
736
737 #[test]
738 fn codex_runs_sandboxed_and_never_stops_to_ask() {
739 let b = CommandBackend::codex_cli();
740 assert_eq!(b.name(), "Codex");
741 assert_eq!(b.program(), "codex");
742 // The whole argv, exactly. Codex has no allowlist to get wrong, so the
743 // boundary IS these two flag pairs and nothing else says so.
744 assert_eq!(
745 b.command(),
746 "codex exec --ignore-user-config --ignore-rules -c approval_policy=\"never\" \
747 --sandbox read-only --color never -"
748 );
749 // A sandbox the user's own config can widen is not a sandbox. Same
750 // lesson as `--permission-mode default` on Claude Code (ADR 0033).
751 assert!(
752 b.command().contains("--ignore-user-config"),
753 "{}",
754 b.command()
755 );
756 // `-` is what makes stdin the whole prompt rather than context for an
757 // argv instruction that does not exist here. Without it Codex waits for
758 // an instruction and the call is wasted.
759 assert!(b.command().ends_with(" -"), "{}", b.command());
760 }
761
762 #[test]
763 fn droid_is_read_only_because_of_what_it_does_not_pass() {
764 let b = CommandBackend::droid_cli();
765 assert_eq!(b.name(), "Droid");
766 assert_eq!(b.program(), "droid");
767 assert_eq!(b.command(), "droid exec -o text -");
768 // Droid's default is read-only, so its boundary is an absence. A test
769 // on presence would pass while the boundary was being given away, which
770 // is why this one is written the other way round.
771 assert!(!b.command().contains("--auto"), "{}", b.command());
772 }
773
774 #[test]
775 fn copilot_allows_reading_and_the_fetch_command_and_nothing_else() {
776 let b = CommandBackend::copilot_cli("/opt/bin/dfr");
777 assert_eq!(b.name(), "GitHub Copilot");
778 assert_eq!(b.program(), "copilot");
779 assert!(
780 b.command().contains("shell(/opt/bin/dfr:*)"),
781 "the allowlist names the same executable the prompt does: {}",
782 b.command()
783 );
784 assert!(
785 b.command().contains("shell(git:*)"),
786 "the prompt tells the model to run git diff, so it must be permitted"
787 );
788 // The whole list, exactly, for the reason the Claude one is pinned: a
789 // stray character inside it produces an allowlist that parses as
790 // something else, and it fails here loudly rather than there silently.
791 assert!(
792 b.command().ends_with(
793 "--deny-tool write --allow-tool read,shell(git:*),shell(/opt/bin/dfr:*)"
794 ),
795 "{}",
796 b.command()
797 );
798 // Copilot ignores piped input when `-p` is given, and the prompt only
799 // ever arrives on stdin. A `-p` here would send an empty prompt.
800 assert!(
801 !b.command().contains(" -p"),
802 "the prompt comes from stdin: {}",
803 b.command()
804 );
805 assert!(b.command().contains("--no-ask-user"), "{}", b.command());
806 }
807
808 #[test]
809 fn pi_is_the_one_agent_that_can_write_and_says_so() {
810 // This test states an exception, not a requirement. Pi ships no sandbox
811 // and no per-command allowlist, so the shell tool it needs to run the
812 // fetch command is the same tool that lets it write (ADR 0033).
813 //
814 // `bash` being present is therefore the decision, and pinning it here is
815 // what stops a later reader "fixing" it and silently taking the fetch
816 // command away — which does not fail, it just groups worse.
817 let b = CommandBackend::pi_cli();
818 assert_eq!(b.name(), "Pi");
819 assert_eq!(b.program(), "pi");
820 assert!(
821 b.command().contains("-t read,grep,find,ls,bash"),
822 "pi needs bash to fetch; removing it removes the change, not the risk: {}",
823 b.command()
824 );
825 assert!(
826 !b.command().contains("edit") && !b.command().contains("write"),
827 "the write tools stay off even though bash makes that a courtesy: {}",
828 b.command()
829 );
830 // The hermetic flags are not decoration. Each one is a file outside the
831 // cache key that could otherwise change a grouping.
832 for flag in [
833 "-nc",
834 "-na",
835 "--no-extensions",
836 "--no-skills",
837 "--no-session",
838 ] {
839 assert!(
840 b.command().contains(flag),
841 "{flag} missing: {}",
842 b.command()
843 );
844 }
845 }
846
847 #[test]
848 fn no_agent_is_given_a_flag_that_removes_its_boundary() {
849 // One list, every agent. These are the flags each CLI offers for
850 // turning its own protection off, and none of them may ever appear in
851 // an argv this crate writes. A new agent is covered the moment it joins
852 // `every_backend`.
853 const FORBIDDEN: [&str; 8] = [
854 "--yolo",
855 "--full-auto",
856 "--dangerously-bypass-approvals-and-sandbox",
857 "--dangerously-allow-all",
858 "--skip-permissions-unsafe",
859 "--allow-all-tools",
860 "--allow-all-paths",
861 "--auto",
862 ];
863 for (key, b) in every_backend() {
864 for flag in FORBIDDEN {
865 assert!(
866 !b.command().contains(flag),
867 "{key} must never be given {flag}: {}",
868 b.command()
869 );
870 }
871 }
872 }
873
874 #[test]
875 fn no_agent_takes_its_prompt_or_its_working_directory_in_the_argv() {
876 // Two rules that hold across all five.
877 //
878 // The prompt goes on stdin, always: `complete` writes it there and
879 // passes no argument. An agent given an argv prompt would read an empty
880 // one.
881 //
882 // The working directory comes from `with_working_dir`, never from a
883 // flag. A path in the argv lands in the cache identity, and then a
884 // debug build, a release build and a second checkout each re-run a
885 // four-hundred-second call over an identical class partition.
886 for (key, b) in every_backend() {
887 for flag in ["--cwd", "--workspace", "--dir", "-C "] {
888 assert!(
889 !b.command().contains(flag),
890 "{key} must take its directory from with_working_dir, not {flag}"
891 );
892 }
893 assert!(
894 !b.identity().contains("/opt/bin"),
895 "{key} put a binary path in its cache identity: {}",
896 b.identity()
897 );
898 }
899 }
900
901 #[test]
902 fn no_two_agents_share_a_cache_identity() {
903 // Two agents sharing an identity share a cache entry, so one would
904 // serve the other's grouping under a name it never ran.
905 let all = every_backend();
906 for (i, (key_a, a)) in all.iter().enumerate() {
907 for (key_b, b) in all.iter().skip(i + 1) {
908 assert_ne!(
909 a.identity(),
910 b.identity(),
911 "{key_a} and {key_b} share a cache identity"
912 );
913 assert_ne!(a.name(), b.name(), "{key_a} and {key_b} share a name");
914 }
915 }
916 }
917}