Skip to main content

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}