Skip to main content

agent_abstraction/
agent.rs

1//! The three agents, what each can do, and how a request becomes an argv.
2//!
3//! Everything here is pure: [`Agent::argv`] builds a command line from a
4//! [`Plan`] without touching the filesystem, the clock, or a process, so every
5//! flag mapping is covered by an ordinary unit test. Spawning lives in
6//! [`crate::run`].
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Error, Result};
13
14/// A coding agent this crate can drive headlessly.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum Agent {
18    /// Anthropic's Claude Code (`claude`).
19    Claude,
20    /// The `OpenAI` Codex CLI (`codex`).
21    Codex,
22    /// GitHub Copilot CLI (`copilot`).
23    Copilot,
24}
25
26/// How an agent's native session id is obtained. This is the axis deciding whether
27/// a caller-owned session name can be bound to it at all.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SessionSupport {
30    /// The caller assigns the id up front (`claude --session-id <uuid>`), so the
31    /// binding is known before the process starts and survives a crashed run.
32    Minted,
33    /// The agent prints an id we read back out of its output (Codex's
34    /// `thread_id`). The binding only exists once the run produced output.
35    Printed,
36    /// No id is exposed headlessly. Named sessions are refused for this agent.
37    None,
38}
39
40/// What an agent supports. Used to reject an impossible request before spawning
41/// rather than silently doing something weaker than asked.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct Caps {
45    /// How a native session id is obtained, if at all.
46    pub session: SessionSupport,
47    /// Whether resuming can branch a new session instead of appending in place.
48    pub fork: bool,
49    /// Whether the agent emits a structured event stream this crate normalizes.
50    pub events: bool,
51    /// Whether the agent takes a real system-prompt flag. When false the system
52    /// text is prepended to the prompt so it still reaches the model.
53    pub native_system: bool,
54}
55
56/// Permission posture for a run, mapped onto each agent's own vocabulary.
57///
58/// # What these do and do not guarantee
59///
60/// These postures constrain each CLI's **built-in** tools: its shell, its file
61/// writes, its sandbox. They do **not** constrain MCP servers, plugins or custom
62/// tools the agent is configured with. An MCP tool that files an issue, writes
63/// to a database or calls a deployment API is a separate tool category in all
64/// three CLIs and can still act during a nominally restricted run.
65///
66/// If a run must not cause remote side effects, the containment has to come from
67/// the agent's own configuration (which MCP servers are enabled at all), not
68/// from this enum. What is selected here is enforced by the CLI, and what the
69/// CLI does not model cannot be enforced from out here.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum Permission {
73    /// No writes to the local filesystem, and no shell where the CLI can gate
74    /// one.
75    ///
76    /// The strongest posture this crate can express, and still not a guarantee
77    /// of "no side effects": see the type-level note about MCP tools. Codex
78    /// enforces it with a read-only sandbox, which blocks writes but still
79    /// permits command execution.
80    #[default]
81    ReadOnly,
82    /// Ask the agent to plan rather than act.
83    ///
84    /// Claude and Copilot have a real plan mode. **Codex has none**, so this
85    /// maps to its read-only sandbox: writes are blocked, but the model is not
86    /// instructed to withhold execution the way a true plan mode would.
87    Plan,
88    /// Allow file edits, while still gating shell commands where the CLI can.
89    Edit,
90    /// Allow the agent's own default automation.
91    Auto,
92    /// Skip every permission check. For sandboxes.
93    Bypass,
94}
95
96/// Environment variables that route an agent's traffic through a corporate
97/// proxy or a custom certificate authority.
98///
99/// None of the three vendors documents proxy support, and none exposes a proxy
100/// flag, so this is a convenience list of names a host may want to forward, not
101/// a claim that forwarding them works. (The names do appear in all three
102/// shipped binaries, but that shows they are referenced, not that provider
103/// traffic honours them.) Verify against your own proxy before relying on it.
104///
105/// Not included in [`EnvPolicy::Minimal`]: they are situational, and the proxy
106/// URLs frequently carry credentials. Offered here so a host can present them
107/// as an explicit setting and forward the ones it wants with
108/// [`crate::Request::env`], rather than every caller rediscovering the names.
109///
110/// Excluding them from `Minimal` does not block them. Under the default
111/// [`EnvPolicy::Inherit`] they flow exactly as they would for the CLI run from a
112/// shell; the only thing `Minimal` changes is that forwarding becomes a
113/// decision rather than an accident.
114///
115/// ```no_run
116/// # use agent_abstraction::{Agent, EnvPolicy, NETWORK_ENV, Request};
117/// let mut request = Request::new(Agent::Claude, "hi").env_policy(EnvPolicy::Minimal);
118/// // Forward only the proxy settings this host actually has.
119/// for name in NETWORK_ENV {
120///     if let Ok(value) = std::env::var(name) {
121///         request = request.env(*name, value);
122///     }
123/// }
124/// ```
125pub const NETWORK_ENV: &[&str] = &[
126    "HTTP_PROXY",
127    "HTTPS_PROXY",
128    "ALL_PROXY",
129    "NO_PROXY",
130    "http_proxy",
131    "https_proxy",
132    "all_proxy",
133    "no_proxy",
134    "SSL_CERT_FILE",
135    "SSL_CERT_DIR",
136    "NODE_EXTRA_CA_CERTS",
137];
138
139/// Which of the host's environment variables reach the agent.
140///
141/// **The default is [`EnvPolicy::Minimal`].** Inheriting the whole environment
142/// is what a CLI gets from a shell, but this crate is embedded in processes that
143/// hold unrelated secrets, and full inheritance hands every one of them to the
144/// agent and to every command the agent runs. That is a decision worth making
145/// deliberately, so it is the opt-in rather than the default.
146#[derive(Debug, Clone, PartialEq, Eq, Default)]
147#[non_exhaustive]
148pub enum EnvPolicy {
149    /// Pass through only what the selected agent needs, per
150    /// [`Agent::essential_env`], plus anything set with [`crate::Request::env`].
151    ///
152    /// The crate owns this list rather than the caller, because "what does this
153    /// CLI need to work" is knowledge about the agent, and an incomplete
154    /// hand-written list produces a run that fails in a way that looks like an
155    /// auth problem. Every agent is verified to authenticate under it by the
156    /// live test suite.
157    #[default]
158    Minimal,
159    /// Pass the whole parent environment through, as a shell would.
160    ///
161    /// Correct when the host process holds nothing the agent should not see, or
162    /// when something environment-specific (a proxy, a custom CA, a vendor
163    /// variable this crate does not know about) has to reach the CLI and
164    /// enumerating it is impractical.
165    Inherit,
166    /// Pass through only these names, plus anything set with
167    /// [`crate::Request::env`]. Names unset in the parent are skipped.
168    Only(Vec<String>),
169}
170
171/// Output shape requested from the agent.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
173#[serde(rename_all = "kebab-case")]
174pub enum Format {
175    /// Plain prose on stdout. Carries no session id and no events.
176    Text,
177    /// One JSON result document.
178    #[default]
179    Json,
180    /// A JSONL event stream, normalized into [`crate::Event`]s.
181    Stream,
182}
183
184/// How a run continues an earlier conversation.
185#[derive(Debug, Clone, PartialEq, Eq, Default)]
186pub enum Continue {
187    /// Start a fresh conversation.
188    #[default]
189    New,
190    /// Start a fresh conversation under an id the caller chose. Only valid for
191    /// [`SessionSupport::Minted`] agents.
192    NewWith(String),
193    /// Append to an existing conversation in place.
194    Resume(String),
195    /// Branch a new conversation off an existing one, leaving it untouched.
196    Fork(String),
197}
198
199/// A fully resolved run request, ready to become an argv. Built by
200/// [`crate::Request::plan`]; consumed by [`Agent::argv`].
201#[derive(Debug, Clone)]
202pub struct Plan {
203    /// The binary to invoke.
204    pub bin: String,
205    /// The user prompt.
206    pub prompt: String,
207    /// System prompt, if any.
208    pub system: Option<String>,
209    /// Model id or alias, if pinned.
210    pub model: Option<String>,
211    /// Permission posture.
212    pub permission: Permission,
213    /// Requested output shape.
214    pub format: Format,
215    /// How this run continues an earlier one.
216    pub cont: Continue,
217    /// True when the prompt is piped on stdin instead of riding the argv.
218    pub stdin_prompt: bool,
219}
220
221/// Prompts at or above this many bytes are piped on stdin rather than placed on
222/// the argv. Well under the ~1 MiB `ARG_MAX` floor on macOS, with room for the
223/// rest of the command line and the inherited environment.
224pub(crate) const STDIN_THRESHOLD: usize = 128 * 1024;
225
226/// The budget for everything on one command line.
227///
228/// `ARG_MAX` is about 1 MiB on macOS and covers the environment as well as the
229/// arguments, so half of it leaves room for a large inherited environment. Over
230/// this the spawn fails with a bare `E2BIG` that names nothing; the crate checks
231/// first so the error can say which input was too big.
232pub(crate) const MAX_COMMAND_LINE: usize = 512 * 1024;
233
234impl Agent {
235    /// Every agent, in a stable order.
236    pub const ALL: [Agent; 3] = [Agent::Claude, Agent::Codex, Agent::Copilot];
237
238    /// The stable identifier used in session records and logs.
239    #[must_use]
240    pub fn id(self) -> &'static str {
241        match self {
242            Agent::Claude => "claude-code",
243            Agent::Codex => "codex",
244            Agent::Copilot => "copilot",
245        }
246    }
247
248    /// The default binary name looked up on `PATH`.
249    #[must_use]
250    pub fn bin(self) -> &'static str {
251        match self {
252            Agent::Claude => "claude",
253            Agent::Codex => "codex",
254            Agent::Copilot => "copilot",
255        }
256    }
257
258    /// The documented install command, surfaced by [`Error::NotInstalled`].
259    #[must_use]
260    pub fn install_hint(self) -> &'static str {
261        match self {
262            Agent::Claude => "npm install -g @anthropic-ai/claude-code",
263            Agent::Codex => "npm install -g @openai/codex",
264            Agent::Copilot => "npm install -g @github/copilot",
265        }
266    }
267
268    /// The environment variables this agent needs to function, used by
269    /// [`EnvPolicy::Minimal`].
270    ///
271    /// Two groups: what any process needs to start, and this agent's own
272    /// credential and config variables. Permission-controlling variables are
273    /// excluded on principle: `COPILOT_ALLOW_ALL` is Copilot's env equivalent
274    /// of `--allow-all-tools`, so inheriting it would let the host's ambient
275    /// environment widen a run's permissions behind [`Permission`]'s back. A name absent from the parent
276    /// environment is skipped, so nothing here is fabricated.
277    ///
278    /// Proxy and custom-CA variables are deliberately **not** here. They are
279    /// environment-specific rather than required, and `HTTP_PROXY` /
280    /// `HTTPS_PROXY` routinely embed credentials (`http://user:pass@proxy`), so
281    /// passing them automatically would leak one through the very policy meant
282    /// to withhold secrets. A host that needs them should offer them as a
283    /// setting and pass them with [`crate::Request::env`]; [`NETWORK_ENV`] names
284    /// them so a settings screen does not have to hardcode the list.
285    ///
286    /// `PATH`, `HOME` and `USER` are the verified floor on macOS: all three CLIs
287    /// answer correctly with exactly those set, and Claude reports "Not logged
288    /// in" without `USER`, since its keychain lookup is keyed on it. The Windows
289    /// names are included on the same reasoning but are **not** verified, as
290    /// this crate has not been run there.
291    #[must_use]
292    pub fn essential_env(self) -> Vec<&'static str> {
293        // Needed by any child process, plus the locale and temp dir the CLIs
294        // use for scratch files.
295        const BASE: &[&str] = &[
296            "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL",
297        ];
298        // Unverified: this crate has not been exercised on Windows.
299        const WINDOWS: &[&str] = &[
300            "USERPROFILE",
301            "APPDATA",
302            "LOCALAPPDATA",
303            "SystemRoot",
304            "SystemDrive",
305            "TEMP",
306            "TMP",
307            "PATHEXT",
308            "ComSpec",
309        ];
310        let agent: &[&str] = match self {
311            Agent::Claude => &[
312                "ANTHROPIC_API_KEY",
313                "ANTHROPIC_AUTH_TOKEN",
314                "ANTHROPIC_BASE_URL",
315                "CLAUDE_CONFIG_DIR",
316            ],
317            Agent::Codex => &[
318                "CODEX_HOME",
319                "CODEX_API_KEY",
320                "OPENAI_API_KEY",
321                "OPENAI_BASE_URL",
322            ],
323            Agent::Copilot => &["GH_TOKEN", "GITHUB_TOKEN", "XDG_CONFIG_HOME"],
324        };
325        BASE.iter().chain(WINDOWS).chain(agent).copied().collect()
326    }
327
328    /// What this agent supports.
329    #[must_use]
330    pub fn caps(self) -> Caps {
331        match self {
332            // Verified against claude 2.1.205: `--session-id <uuid>` assigns the
333            // id, `--fork-session` branches, `--output-format stream-json`
334            // streams (and demands `--verbose`), `--append-system-prompt` is a
335            // real flag.
336            Agent::Claude => Caps {
337                session: SessionSupport::Minted,
338                fork: true,
339                events: true,
340                native_system: true,
341            },
342            // `codex exec --json` emits `thread_id`; continuation is the
343            // `resume` subcommand and is linear (`codex fork` is TUI-only).
344            Agent::Codex => Caps {
345                session: SessionSupport::Printed,
346                fork: false,
347                events: true,
348                native_system: false,
349            },
350            // Verified against Copilot CLI 1.0.75: `--session-id <uuid>` both
351            // mints a new session and resumes an existing one (one flag, both
352            // directions), and `--output-format json` is a JSONL event stream.
353            // There is no headless fork.
354            Agent::Copilot => Caps {
355                session: SessionSupport::Minted,
356                fork: false,
357                events: true,
358                native_system: false,
359            },
360        }
361    }
362
363    /// The format that can carry this agent's session id, if any. A named
364    /// session upgrades to this when the caller did not pin a format.
365    #[must_use]
366    pub fn session_format(self) -> Option<Format> {
367        match self.caps().session {
368            // Claude reports the id in both structured formats; `Json` is the
369            // cheaper default when the caller did not ask to stream.
370            SessionSupport::Minted | SessionSupport::Printed => Some(match self {
371                Agent::Claude => Format::Json,
372                // `--json` IS Codex's stream and Copilot's `json` is JSONL;
373                // neither has a single-document form.
374                Agent::Codex | Agent::Copilot => Format::Stream,
375            }),
376            SessionSupport::None => None,
377        }
378    }
379
380    /// Whether `format` can carry this agent's session id.
381    ///
382    /// Distinct from [`Agent::session_format`], which names the *preferred* one:
383    /// Claude reports its id under both `Json` and `Stream`, and only plain text
384    /// loses it. A named session needs this, not equality with the preferred
385    /// format, or streaming a named Claude session would be refused for no
386    /// reason.
387    #[must_use]
388    pub fn format_carries_session(self, format: Format) -> bool {
389        self.session_format().is_some() && format != Format::Text
390    }
391
392    /// Reject a plan this agent cannot honour, before anything is spawned.
393    fn check(self, plan: &Plan) -> Result<()> {
394        let caps = self.caps();
395        if matches!(plan.cont, Continue::Fork(_)) && !caps.fork {
396            return Err(Error::Unsupported {
397                agent: self,
398                what: "forking a session headlessly",
399            });
400        }
401        if matches!(plan.cont, Continue::NewWith(_)) && caps.session != SessionSupport::Minted {
402            return Err(Error::Unsupported {
403                agent: self,
404                what: "assigning a session id up front",
405            });
406        }
407        if plan.format == Format::Stream && !caps.events {
408            return Err(Error::Unsupported {
409                agent: self,
410                what: "a structured event stream",
411            });
412        }
413        Ok(())
414    }
415
416    /// Build the command line for `plan`.
417    ///
418    /// The first element is the binary; the rest are its arguments. Returns
419    /// [`Error::Unsupported`] when the plan asks for a capability this agent
420    /// lacks, never a quiet downgrade.
421    ///
422    /// # Errors
423    /// [`Error::Unsupported`] if the plan needs a capability this agent lacks.
424    pub fn argv(self, plan: &Plan) -> Result<Vec<String>> {
425        Ok(self
426            .typed_argv(plan)?
427            .into_iter()
428            .map(|arg| arg.value)
429            .collect())
430    }
431
432    /// The command line with each argument's sensitivity attached.
433    ///
434    /// # Errors
435    /// [`Error::Unsupported`] if the plan needs a capability this agent lacks.
436    pub(crate) fn typed_argv(self, plan: &Plan) -> Result<Vec<Arg>> {
437        self.check(plan)?;
438        Ok(match self {
439            Agent::Claude => argv_claude(plan),
440            Agent::Codex => argv_codex(plan),
441            Agent::Copilot => argv_copilot(plan),
442        })
443    }
444
445    /// The prompt text actually delivered, with the system prompt folded in for
446    /// agents that have no flag for it. Never dropped silently.
447    #[must_use]
448    pub fn effective_prompt(self, plan: &Plan) -> String {
449        match (&plan.system, self.caps().native_system) {
450            (Some(system), false) => format!("{system}\n\n{}", plan.prompt),
451            _ => plan.prompt.clone(),
452        }
453    }
454}
455
456impl fmt::Display for Agent {
457    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
458        f.write_str(self.id())
459    }
460}
461
462/// How sensitive one argument's value is, decided where the argument is built
463/// rather than guessed back afterwards.
464///
465/// Reconstructing this from a finished command line means pattern-matching flag
466/// names and positions, which misses exactly the cases that matter: Codex's
467/// prompt is a bare trailing positional, and anything from `unchecked_args` has
468/// no recognizable shape at all. Recording it at construction cannot miss.
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub(crate) enum Sensitivity {
471    /// A flag name or fixed token. Safe to show.
472    Public,
473    /// User or caller content: prompts and system prompts.
474    Prompt,
475    /// A session handle, which resumes a conversation.
476    SessionId,
477    /// Caller-supplied raw arguments. Unknowable, so assumed sensitive.
478    Unchecked,
479}
480
481/// One argument and how sensitive it is.
482#[derive(Debug, Clone)]
483pub(crate) struct Arg {
484    pub(crate) value: String,
485    pub(crate) sensitivity: Sensitivity,
486}
487
488/// Builds an argv, keeping every flag name literal at its call site so the flag
489/// list for an agent stays greppable and auditable against `--help`, and
490/// recording per-argument sensitivity so the executable and redacted forms come
491/// from one source.
492pub(crate) struct Argv(Vec<Arg>);
493
494impl Argv {
495    /// Start with the binary.
496    fn new(bin: &str) -> Self {
497        Self(vec![Arg {
498            value: bin.to_string(),
499            sensitivity: Sensitivity::Public,
500        }])
501    }
502
503    fn push(&mut self, value: impl Into<String>, sensitivity: Sensitivity) -> &mut Self {
504        self.0.push(Arg {
505            value: value.into(),
506            sensitivity,
507        });
508        self
509    }
510
511    /// A bare flag with no value.
512    fn bare(&mut self, flag: &str) -> &mut Self {
513        self.push(flag, Sensitivity::Public)
514    }
515
516    /// A flag and a value that is safe to show.
517    fn pair(&mut self, flag: &str, value: impl AsRef<str>) -> &mut Self {
518        self.bare(flag).push(value.as_ref(), Sensitivity::Public)
519    }
520
521    /// A flag and a value that must not be logged.
522    fn secret(&mut self, flag: &str, value: impl AsRef<str>, kind: Sensitivity) -> &mut Self {
523        self.bare(flag).push(value.as_ref(), kind)
524    }
525
526    /// A flag and its value, only when the value is present.
527    fn opt(&mut self, flag: &str, value: Option<&String>) -> &mut Self {
528        if let Some(value) = value {
529            self.pair(flag, value);
530        }
531        self
532    }
533
534    /// A positional argument that is safe to show.
535    fn arg(&mut self, value: impl Into<String>) -> &mut Self {
536        self.push(value, Sensitivity::Public)
537    }
538
539    /// A positional argument carrying caller content.
540    fn arg_sensitive(&mut self, value: impl Into<String>, kind: Sensitivity) -> &mut Self {
541        self.push(value, kind)
542    }
543
544    fn done(&mut self) -> Vec<Arg> {
545        std::mem::take(&mut self.0)
546    }
547}
548
549/// Claude Code's permission-mode token for each posture. Choices verified from
550/// `claude --help` (2.1.205): acceptEdits, auto, bypassPermissions, manual,
551/// dontAsk, plan.
552fn claude_mode(p: Permission) -> &'static str {
553    match p {
554        // `dontAsk` auto-denies gated tools and keeps going rather than
555        // blocking on a prompt no one can answer headlessly. The read-only
556        // guarantee comes from `--disallowedTools`, below.
557        Permission::ReadOnly => "dontAsk",
558        Permission::Plan => "plan",
559        Permission::Edit => "acceptEdits",
560        Permission::Auto => "auto",
561        Permission::Bypass => "bypassPermissions",
562    }
563}
564
565/// `claude -p <prompt> --permission-mode M --output-format F [...]`
566fn argv_claude(plan: &Plan) -> Vec<Arg> {
567    let mut a = Argv::new(&plan.bin);
568    a.bare("-p");
569    if plan.stdin_prompt {
570        // With `--input-format text` claude reads the prompt from stdin, so a
571        // large prompt never has to fit on the argv.
572        a.pair("--input-format", "text");
573    } else {
574        a.arg_sensitive(Agent::Claude.effective_prompt(plan), Sensitivity::Prompt);
575    }
576
577    a.pair("--permission-mode", claude_mode(plan.permission));
578    if plan.permission == Permission::ReadOnly {
579        // Remove the mutating built-ins outright. Reads still run via
580        // Read/Grep/Glob. `mcp__*` covers every MCP tool: denying only the
581        // built-in writers would leave an MCP server free to mutate remote
582        // state during a run the caller asked to be read-only.
583        a.bare("--disallowedTools");
584        for tool in ["Bash", "Edit", "Write", "NotebookEdit", "mcp__*"] {
585            a.arg(tool);
586        }
587    }
588
589    a.opt("--model", plan.model.as_ref());
590    if let Some(system) = &plan.system {
591        a.secret("--append-system-prompt", system, Sensitivity::Prompt);
592    }
593
594    match &plan.cont {
595        Continue::New => {}
596        Continue::NewWith(id) => {
597            a.secret("--session-id", id, Sensitivity::SessionId);
598        }
599        Continue::Resume(id) => {
600            a.secret("--resume", id, Sensitivity::SessionId);
601        }
602        Continue::Fork(id) => {
603            // Mints a new id off `id`, leaving the original and its cached
604            // prefix untouched. The new id comes back in the output.
605            a.secret("--resume", id, Sensitivity::SessionId)
606                .bare("--fork-session");
607        }
608    }
609
610    a.pair(
611        "--output-format",
612        match plan.format {
613            Format::Text => "text",
614            Format::Json => "json",
615            Format::Stream => "stream-json",
616        },
617    );
618    if plan.format == Format::Stream {
619        // Claude refuses `-p --output-format stream-json` without it:
620        // "--print with --output-format=stream-json requires --verbose".
621        a.bare("--verbose");
622    }
623    a.done()
624}
625
626/// `codex exec [resume <id>] --skip-git-repo-check [sandbox flags] [--model M]
627/// [--json] <prompt>`
628fn argv_codex(plan: &Plan) -> Vec<Arg> {
629    let mut a = Argv::new(&plan.bin);
630    a.bare("exec");
631    if let Continue::Resume(id) = &plan.cont {
632        // Continuation is a subcommand, not a flag.
633        a.bare("resume")
634            .arg_sensitive(id.clone(), Sensitivity::SessionId);
635    }
636
637    // `codex exec` aborts outside a git repository unless told not to. That
638    // check guards against an agent editing files with no way to undo them, but
639    // this crate is embedded in hosts that legitimately run against scratch
640    // directories, worktrees and review checkouts, and a hard abort there is
641    // useless to them. The real containment is the sandbox below, which is
642    // `read-only` by default, so nothing is unrecoverable regardless.
643    a.bare("--skip-git-repo-check");
644
645    // `codex exec` takes `--sandbox`, but `codex exec resume` does **not**: it
646    // rejects the flag outright and takes the same setting as a `-c` config
647    // override instead. Verified against codex-cli 0.145.0, where passing
648    // `--sandbox` to a resume fails with "unexpected argument '--sandbox'".
649    // Dropping the sandbox on resume would silently run a continued turn under a
650    // different posture than the caller asked for.
651    let resuming = matches!(plan.cont, Continue::Resume(_));
652    let sandbox = match plan.permission {
653        Permission::Bypass => None,
654        Permission::ReadOnly | Permission::Plan => Some("read-only"),
655        Permission::Edit | Permission::Auto => Some("workspace-write"),
656    };
657    match (sandbox, resuming) {
658        (None, _) => a.bare("--dangerously-bypass-approvals-and-sandbox"),
659        (Some(mode), false) => a.pair("--sandbox", mode),
660        // The value is TOML-parsed, falling back to a raw string, so the bare
661        // token is read as the mode name.
662        (Some(mode), true) => a.pair("-c", format!("sandbox_mode={mode}")),
663    };
664
665    a.opt("--model", plan.model.as_ref());
666    // `--json` is Codex's event stream and the only place `thread_id` appears.
667    if plan.format != Format::Text {
668        a.bare("--json");
669    }
670    // Codex has no system flag, so the system text rides the prompt. A literal
671    // `-` makes it read the prompt from stdin instead, keeping a large one off
672    // the argv.
673    // Codex takes the prompt as a bare trailing positional, which is exactly
674    // the shape positional redaction guesswork gets wrong.
675    if plan.stdin_prompt {
676        a.arg("-");
677    } else {
678        a.arg_sensitive(Agent::Codex.effective_prompt(plan), Sensitivity::Prompt);
679    }
680    a.done()
681}
682
683/// `copilot -p <prompt> --allow-all-tools [...] [--session-id <uuid>]`
684///
685/// Flags verified against Copilot CLI 1.0.75. Two of its conventions matter:
686/// `--allow-all-tools` is *required* for non-interactive mode, and the
687/// repeatable tool filters are declared `--allow-tool[=tools...]`, an optional
688/// value, which only binds with `=`, never across a space.
689fn argv_copilot(plan: &Plan) -> Vec<Arg> {
690    // Copilot reads stdin as the prompt only when `-p` is absent: a `-p` value
691    // makes the pipe be ignored. So a piped prompt drops the flag entirely.
692    let mut a = Argv::new(&plan.bin);
693    if !plan.stdin_prompt {
694        a.secret(
695            "-p",
696            Agent::Copilot.effective_prompt(plan),
697            Sensitivity::Prompt,
698        );
699    }
700
701    // Without this, a headless run stops at the first tool confirmation.
702    a.bare("--allow-all-tools").bare("--no-ask-user");
703    match plan.permission {
704        Permission::Bypass | Permission::Auto => a.bare("--allow-all-paths"),
705        // Deny beats allow, so this is allow-all minus the mutating tools.
706        // `--allow-all-paths` is deliberately NOT set: it disables path
707        // verification entirely, which would widen filesystem reach in the one
708        // posture that exists to narrow it.
709        Permission::ReadOnly => a.bare("--deny-tool=shell").bare("--deny-tool=write"),
710        // Edits run; shell stays denied so commands cannot.
711        Permission::Edit => a.bare("--deny-tool=shell"),
712        Permission::Plan => a.pair("--mode", "plan"),
713    };
714
715    a.opt("--model", plan.model.as_ref());
716    // One flag serves both directions: it sets the UUID for a new session and
717    // resumes an existing one by id.
718    match &plan.cont {
719        Continue::NewWith(id) | Continue::Resume(id) => {
720            a.secret("--session-id", id, Sensitivity::SessionId);
721        }
722        // `Fork` is rejected by `Agent::check` before reaching here.
723        Continue::New | Continue::Fork(_) => {}
724    }
725
726    a.pair(
727        "--output-format",
728        if plan.format == Format::Text {
729            "text"
730        } else {
731            // Copilot's `json` is JSONL, so it serves both structured formats.
732            "json"
733        },
734    );
735    a.done()
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741
742    fn plan(bin: &str) -> Plan {
743        Plan {
744            bin: bin.into(),
745            prompt: "hi".into(),
746            system: None,
747            model: None,
748            permission: Permission::ReadOnly,
749            format: Format::Json,
750            cont: Continue::New,
751            stdin_prompt: false,
752        }
753    }
754
755    fn argv(agent: Agent, plan: &Plan) -> Vec<String> {
756        agent.argv(plan).expect("plan is supported")
757    }
758
759    fn pos(a: &[String], needle: &str) -> Option<usize> {
760        a.iter().position(|s| s == needle)
761    }
762
763    #[test]
764    fn claude_builds_print_mode_with_format_and_permission() {
765        let a = argv(Agent::Claude, &plan("claude"));
766        assert_eq!(a[0..3], ["claude", "-p", "hi"]);
767        assert!(pos(&a, "--permission-mode").is_some());
768        assert!(a.contains(&"dontAsk".to_string()));
769        assert_eq!(a[pos(&a, "--output-format").unwrap() + 1], "json");
770    }
771
772    #[test]
773    fn claude_read_only_removes_the_mutating_tools() {
774        let a = argv(Agent::Claude, &plan("claude"));
775        let at = pos(&a, "--disallowedTools").expect("read-only denies tools");
776        assert_eq!(
777            &a[at + 1..at + 5],
778            ["Bash", "Edit", "Write", "NotebookEdit"]
779        );
780    }
781
782    #[test]
783    fn claude_bypass_does_not_deny_tools() {
784        let mut p = plan("claude");
785        p.permission = Permission::Bypass;
786        let a = argv(Agent::Claude, &p);
787        assert!(a.contains(&"bypassPermissions".to_string()));
788        assert!(pos(&a, "--disallowedTools").is_none());
789    }
790
791    #[test]
792    fn claude_stream_format_adds_verbose_but_json_does_not() {
793        let mut p = plan("claude");
794        p.format = Format::Stream;
795        assert!(argv(Agent::Claude, &p).contains(&"--verbose".to_string()));
796        p.format = Format::Json;
797        assert!(!argv(Agent::Claude, &p).contains(&"--verbose".to_string()));
798    }
799
800    #[test]
801    fn claude_mints_an_id_for_a_new_session_and_resumes_an_old_one() {
802        let mut p = plan("claude");
803        p.cont = Continue::NewWith("11111111-2222-3333-4444-555555555555".into());
804        let a = argv(Agent::Claude, &p);
805        assert_eq!(
806            a[pos(&a, "--session-id").unwrap() + 1],
807            "11111111-2222-3333-4444-555555555555"
808        );
809        assert!(pos(&a, "--resume").is_none());
810
811        p.cont = Continue::Resume("sess-1".into());
812        let a = argv(Agent::Claude, &p);
813        assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1");
814        assert!(!a.contains(&"--fork-session".to_string()));
815    }
816
817    #[test]
818    fn claude_fork_resumes_and_branches() {
819        let mut p = plan("claude");
820        p.cont = Continue::Fork("sess-1".into());
821        let a = argv(Agent::Claude, &p);
822        assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1");
823        assert!(a.contains(&"--fork-session".to_string()));
824    }
825
826    #[test]
827    fn claude_keeps_the_system_prompt_on_its_own_flag() {
828        let mut p = plan("claude");
829        p.system = Some("be terse".into());
830        let a = argv(Agent::Claude, &p);
831        assert_eq!(
832            a[pos(&a, "--append-system-prompt").unwrap() + 1],
833            "be terse"
834        );
835        // The prompt itself stays clean.
836        assert!(a.contains(&"hi".to_string()));
837    }
838
839    #[test]
840    fn claude_stdin_prompt_leaves_the_argv() {
841        let mut p = plan("claude");
842        p.stdin_prompt = true;
843        let a = argv(Agent::Claude, &p);
844        assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "text");
845        assert!(!a.contains(&"hi".to_string()), "prompt must not ride argv");
846    }
847
848    #[test]
849    fn codex_resume_is_a_subcommand_and_prompt_is_last() {
850        let mut p = plan("codex");
851        p.cont = Continue::Resume("thread-9".into());
852        let a = argv(Agent::Codex, &p);
853        assert_eq!(a[0..4], ["codex", "exec", "resume", "thread-9"]);
854        assert_eq!(a.last().unwrap(), "hi");
855    }
856
857    /// `Minimal` exists to withhold secrets, so nothing it passes through may
858    /// be a credential carrier. Proxy URLs in particular routinely embed
859    /// `user:pass`, which is why they are offered separately instead.
860    #[test]
861    fn the_minimal_environment_carries_no_proxy_variables() {
862        for agent in Agent::ALL {
863            let essential = agent.essential_env();
864            for name in NETWORK_ENV {
865                assert!(
866                    !essential.contains(name),
867                    "{agent} would pass {name} through EnvPolicy::Minimal"
868                );
869            }
870        }
871    }
872
873    /// The floor verified live on macOS: with exactly these set, all three CLIs
874    /// authenticate and answer. Claude reports "Not logged in" without `USER`.
875    #[test]
876    fn every_agent_asks_for_the_verified_floor() {
877        for agent in Agent::ALL {
878            let essential = agent.essential_env();
879            for name in ["PATH", "HOME", "USER"] {
880                assert!(essential.contains(&name), "{agent} omits {name}");
881            }
882        }
883    }
884
885    /// Each agent's own credentials, and nobody else's.
886    #[test]
887    fn agents_do_not_request_each_others_credentials() {
888        let claude = Agent::Claude.essential_env();
889        assert!(claude.contains(&"ANTHROPIC_API_KEY"));
890        assert!(!claude.contains(&"OPENAI_API_KEY"));
891        assert!(!claude.contains(&"GH_TOKEN"));
892
893        let codex = Agent::Codex.essential_env();
894        assert!(codex.contains(&"OPENAI_API_KEY"));
895        assert!(!codex.contains(&"ANTHROPIC_API_KEY"));
896    }
897
898    /// The model is the caller's choice on every agent. It is forwarded
899    /// verbatim and never defaulted, normalized, or validated here: a host with
900    /// a model picker owns that list, and an unknown name must surface as the
901    /// agent's own error rather than something this crate guessed at.
902    #[test]
903    fn every_agent_forwards_the_callers_model_verbatim() {
904        for agent in Agent::ALL {
905            let mut p = plan(agent.bin());
906            // Deliberately not a real model id: nothing here may interpret it.
907            p.model = Some("some-model-9".into());
908            let a = argv(agent, &p);
909            let at = pos(&a, "--model").unwrap_or_else(|| panic!("{agent} dropped --model: {a:?}"));
910            assert_eq!(a[at + 1], "some-model-9", "{agent} rewrote the model");
911        }
912    }
913
914    /// No model means the agent picks its own, so a host can offer a "default"
915    /// entry without this crate inventing one.
916    #[test]
917    fn no_model_means_no_model_flag() {
918        for agent in Agent::ALL {
919            let p = plan(agent.bin());
920            assert!(p.model.is_none());
921            let a = argv(agent, &p);
922            assert!(
923                pos(&a, "--model").is_none(),
924                "{agent} invented a model: {a:?}"
925            );
926        }
927    }
928
929    /// `codex exec` aborts outside a git repository. A host embedding this
930    /// crate runs against scratch dirs and review checkouts, so the check is
931    /// waived on every invocation; the sandbox is what actually contains a run.
932    #[test]
933    fn codex_always_waives_the_git_repo_check() {
934        for cont in [Continue::New, Continue::Resume("t-1".into())] {
935            let mut p = plan("codex");
936            p.cont = cont.clone();
937            assert!(
938                argv(Agent::Codex, &p).contains(&"--skip-git-repo-check".to_string()),
939                "{cont:?} must still run outside a repo"
940            );
941        }
942    }
943
944    /// `codex exec resume` rejects `--sandbox` and takes `-c sandbox_mode=`
945    /// instead. Getting this wrong makes every second turn fail with an
946    /// "unexpected argument" error, which only a multi-turn run reveals.
947    #[test]
948    fn codex_sets_the_sandbox_by_flag_when_fresh_and_by_config_when_resuming() {
949        let mut fresh = plan("codex");
950        fresh.permission = Permission::ReadOnly;
951        let a = argv(Agent::Codex, &fresh);
952        assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], "read-only");
953        assert!(pos(&a, "-c").is_none());
954
955        let mut resumed = fresh.clone();
956        resumed.cont = Continue::Resume("thread-9".into());
957        let a = argv(Agent::Codex, &resumed);
958        assert!(
959            pos(&a, "--sandbox").is_none(),
960            "resume rejects --sandbox: {a:?}"
961        );
962        assert_eq!(a[pos(&a, "-c").unwrap() + 1], "sandbox_mode=read-only");
963    }
964
965    #[test]
966    fn codex_bypass_uses_the_same_flag_on_both_paths() {
967        for cont in [Continue::New, Continue::Resume("t".into())] {
968            let mut p = plan("codex");
969            p.permission = Permission::Bypass;
970            p.cont = cont.clone();
971            let a = argv(Agent::Codex, &p);
972            assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
973            assert!(pos(&a, "--sandbox").is_none(), "{cont:?}: {a:?}");
974        }
975    }
976
977    #[test]
978    fn codex_without_a_system_flag_prepends_it_to_the_prompt() {
979        let mut p = plan("codex");
980        p.system = Some("be terse".into());
981        let a = argv(Agent::Codex, &p);
982        assert_eq!(a.last().unwrap(), "be terse\n\nhi");
983    }
984
985    #[test]
986    fn codex_maps_each_posture_to_a_sandbox() {
987        for (perm, expect) in [
988            (Permission::ReadOnly, "read-only"),
989            (Permission::Plan, "read-only"),
990            (Permission::Edit, "workspace-write"),
991            (Permission::Auto, "workspace-write"),
992        ] {
993            let mut p = plan("codex");
994            p.permission = perm;
995            let a = argv(Agent::Codex, &p);
996            assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], expect, "{perm:?}");
997        }
998        let mut p = plan("codex");
999        p.permission = Permission::Bypass;
1000        let a = argv(Agent::Codex, &p);
1001        assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
1002        assert!(pos(&a, "--sandbox").is_none());
1003    }
1004
1005    #[test]
1006    fn copilot_drops_dash_p_when_the_prompt_is_piped() {
1007        let mut p = plan("copilot");
1008        p.stdin_prompt = true;
1009        let a = argv(Agent::Copilot, &p);
1010        assert!(
1011            !a.contains(&"-p".to_string()),
1012            "a -p value shadows the pipe"
1013        );
1014        assert!(!a.contains(&"hi".to_string()));
1015    }
1016
1017    /// Copilot declares its tool filters as `--deny-tool[=tools...]`, an
1018    /// optional value, which binds only with `=`. Passed across a space the
1019    /// value is silently read as a positional instead, so the deny is lost.
1020    #[test]
1021    fn copilot_read_only_denies_shell_and_write_with_the_combined_form() {
1022        let a = argv(Agent::Copilot, &plan("copilot"));
1023        assert!(a.contains(&"--deny-tool=shell".to_string()));
1024        assert!(a.contains(&"--deny-tool=write".to_string()));
1025        assert!(
1026            !a.iter().any(|s| s == "--deny-tool"),
1027            "a bare --deny-tool would drop its value: {a:?}"
1028        );
1029    }
1030
1031    /// A headless Copilot run stalls at the first tool confirmation without it.
1032    #[test]
1033    fn copilot_always_allows_tools_and_silences_the_ask_tool() {
1034        for permission in [Permission::ReadOnly, Permission::Plan, Permission::Bypass] {
1035            let mut p = plan("copilot");
1036            p.permission = permission;
1037            let a = argv(Agent::Copilot, &p);
1038            assert!(
1039                a.contains(&"--allow-all-tools".to_string()),
1040                "{permission:?}"
1041            );
1042            assert!(a.contains(&"--no-ask-user".to_string()), "{permission:?}");
1043        }
1044    }
1045
1046    /// Copilot uses one flag in both directions: it sets the id for a new
1047    /// session and resumes an existing one.
1048    #[test]
1049    fn copilot_uses_session_id_for_both_new_and_resumed_sessions() {
1050        for cont in [
1051            Continue::NewWith("11111111-2222-3333-4444-555555555555".into()),
1052            Continue::Resume("11111111-2222-3333-4444-555555555555".into()),
1053        ] {
1054            let mut p = plan("copilot");
1055            p.cont = cont.clone();
1056            let a = argv(Agent::Copilot, &p);
1057            assert_eq!(
1058                a[pos(&a, "--session-id").unwrap() + 1],
1059                "11111111-2222-3333-4444-555555555555",
1060                "{cont:?}"
1061            );
1062        }
1063    }
1064
1065    #[test]
1066    fn unsupported_capabilities_are_refused_not_downgraded() {
1067        // Forking headlessly is Claude-only.
1068        for agent in [Agent::Codex, Agent::Copilot] {
1069            let mut p = plan(agent.bin());
1070            p.cont = Continue::Fork("s".into());
1071            assert!(
1072                matches!(agent.argv(&p), Err(Error::Unsupported { .. })),
1073                "{agent} must refuse a fork rather than resume linearly"
1074            );
1075        }
1076        // Codex's id is printed, not assigned, so it cannot be chosen up front.
1077        let mut p = plan("codex");
1078        p.cont = Continue::NewWith("id".into());
1079        assert!(matches!(
1080            Agent::Codex.argv(&p),
1081            Err(Error::Unsupported { .. })
1082        ));
1083    }
1084
1085    /// All three expose an id, so all three can back a named session, but only
1086    /// through a format that actually carries one.
1087    #[test]
1088    fn every_agent_has_a_format_that_carries_its_session_id() {
1089        assert_eq!(Agent::Claude.session_format(), Some(Format::Json));
1090        assert_eq!(Agent::Codex.session_format(), Some(Format::Stream));
1091        assert_eq!(Agent::Copilot.session_format(), Some(Format::Stream));
1092    }
1093
1094    /// Claude and Copilot let the caller assign the id, so a run that dies
1095    /// mid-turn still leaves a resumable session.
1096    #[test]
1097    fn the_minting_agents_are_claude_and_copilot() {
1098        let minting: Vec<_> = Agent::ALL
1099            .into_iter()
1100            .filter(|a| a.caps().session == SessionSupport::Minted)
1101            .collect();
1102        assert_eq!(minting, [Agent::Claude, Agent::Copilot]);
1103    }
1104}