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 command that asks this agent whether it is logged in, or `None`
259    /// when it offers no way to ask.
260    ///
261    /// Verified against each CLI: Claude has `auth status`, which answers JSON
262    /// by default, and Codex has `login status`, which answers prose. Copilot
263    /// has neither, so its credentials cannot be confirmed without spending a
264    /// request.
265    #[must_use]
266    pub fn auth_status_argv(self) -> Option<&'static [&'static str]> {
267        match self {
268            Agent::Claude => Some(&["auth", "status", "--json"]),
269            Agent::Codex => Some(&["login", "status"]),
270            Agent::Copilot => None,
271        }
272    }
273
274    /// The environment variables this agent accepts a credential in, most
275    /// preferred first.
276    ///
277    /// Copilot documents its precedence explicitly: `COPILOT_GITHUB_TOKEN`,
278    /// then `GH_TOKEN`, then `GITHUB_TOKEN`.
279    #[must_use]
280    pub fn auth_env_vars(self) -> &'static [&'static str] {
281        match self {
282            Agent::Claude => &["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
283            Agent::Codex => &["CODEX_API_KEY", "OPENAI_API_KEY"],
284            Agent::Copilot => &["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
285        }
286    }
287
288    /// The command that resolves a missing login for this agent.
289    ///
290    /// Verified against each CLI's own help: Codex and Copilot expose a `login`
291    /// subcommand, while Claude authenticates interactively or through a
292    /// long-lived token.
293    #[must_use]
294    pub fn login_hint(self) -> &'static str {
295        match self {
296            Agent::Claude => {
297                "run `claude` and use /login, or `claude setup-token` for a \
298                              long-lived token"
299            }
300            Agent::Codex => "run `codex login`",
301            Agent::Copilot => "run `copilot login`",
302        }
303    }
304
305    /// The release this crate's flag mappings were verified against.
306    ///
307    /// Every mapping in this module was checked by running these exact
308    /// versions, not by reading their documentation. [`crate::Probe`] compares
309    /// an installed CLI against this so drift is a question a host can ask up
310    /// front rather than something a failing run reveals.
311    #[must_use]
312    pub fn verified_version(self) -> crate::Version {
313        let (major, minor, patch) = match self {
314            // `claude --version` -> "2.1.205 (Claude Code)"
315            Agent::Claude => (2, 1, 205),
316            // `codex --version` -> "codex-cli 0.145.0"
317            Agent::Codex => (0, 145, 0),
318            // `copilot --version` -> "GitHub Copilot CLI 1.0.75."
319            Agent::Copilot => (1, 0, 75),
320        };
321        crate::Version {
322            major,
323            minor,
324            patch,
325        }
326    }
327
328    /// The documented install command, surfaced by [`Error::NotInstalled`].
329    #[must_use]
330    pub fn install_hint(self) -> &'static str {
331        match self {
332            Agent::Claude => "npm install -g @anthropic-ai/claude-code",
333            Agent::Codex => "npm install -g @openai/codex",
334            Agent::Copilot => "npm install -g @github/copilot",
335        }
336    }
337
338    /// The environment variables this agent needs to function, used by
339    /// [`EnvPolicy::Minimal`].
340    ///
341    /// Two groups: what any process needs to start, and this agent's own
342    /// credential and config variables. Permission-controlling variables are
343    /// excluded on principle: `COPILOT_ALLOW_ALL` is Copilot's env equivalent
344    /// of `--allow-all-tools`, so inheriting it would let the host's ambient
345    /// environment widen a run's permissions behind [`Permission`]'s back. A name absent from the parent
346    /// environment is skipped, so nothing here is fabricated.
347    ///
348    /// Proxy and custom-CA variables are deliberately **not** here. They are
349    /// environment-specific rather than required, and `HTTP_PROXY` /
350    /// `HTTPS_PROXY` routinely embed credentials (`http://user:pass@proxy`), so
351    /// passing them automatically would leak one through the very policy meant
352    /// to withhold secrets. A host that needs them should offer them as a
353    /// setting and pass them with [`crate::Request::env`]; [`NETWORK_ENV`] names
354    /// them so a settings screen does not have to hardcode the list.
355    ///
356    /// `PATH`, `HOME` and `USER` are the verified floor on macOS: all three CLIs
357    /// answer correctly with exactly those set, and Claude reports "Not logged
358    /// in" without `USER`, since its keychain lookup is keyed on it. The Windows
359    /// names are included on the same reasoning but are **not** verified, as
360    /// this crate has not been run there.
361    #[must_use]
362    pub fn essential_env(self) -> Vec<&'static str> {
363        // Needed by any child process, plus the locale and temp dir the CLIs
364        // use for scratch files.
365        const BASE: &[&str] = &[
366            "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL",
367        ];
368        // Unverified: this crate has not been exercised on Windows.
369        const WINDOWS: &[&str] = &[
370            "USERPROFILE",
371            "APPDATA",
372            "LOCALAPPDATA",
373            "SystemRoot",
374            "SystemDrive",
375            "TEMP",
376            "TMP",
377            "PATHEXT",
378            "ComSpec",
379        ];
380        let agent: &[&str] = match self {
381            Agent::Claude => &[
382                "ANTHROPIC_API_KEY",
383                "ANTHROPIC_AUTH_TOKEN",
384                "ANTHROPIC_BASE_URL",
385                "CLAUDE_CONFIG_DIR",
386            ],
387            Agent::Codex => &[
388                "CODEX_HOME",
389                "CODEX_API_KEY",
390                "OPENAI_API_KEY",
391                "OPENAI_BASE_URL",
392            ],
393            // `COPILOT_GITHUB_TOKEN` takes precedence over the others per
394            // Copilot's own docs, and was missing here: a host using it would
395            // have failed to authenticate under EnvPolicy::Minimal.
396            Agent::Copilot => &[
397                "COPILOT_GITHUB_TOKEN",
398                "GH_TOKEN",
399                "GITHUB_TOKEN",
400                "XDG_CONFIG_HOME",
401            ],
402        };
403        BASE.iter().chain(WINDOWS).chain(agent).copied().collect()
404    }
405
406    /// What this agent supports.
407    #[must_use]
408    pub fn caps(self) -> Caps {
409        match self {
410            // Verified against claude 2.1.205: `--session-id <uuid>` assigns the
411            // id, `--fork-session` branches, `--output-format stream-json`
412            // streams (and demands `--verbose`), `--append-system-prompt` is a
413            // real flag.
414            Agent::Claude => Caps {
415                session: SessionSupport::Minted,
416                fork: true,
417                events: true,
418                native_system: true,
419            },
420            // `codex exec --json` emits `thread_id`; continuation is the
421            // `resume` subcommand and is linear (`codex fork` is TUI-only).
422            Agent::Codex => Caps {
423                session: SessionSupport::Printed,
424                fork: false,
425                events: true,
426                native_system: false,
427            },
428            // Verified against Copilot CLI 1.0.75: `--session-id <uuid>` both
429            // mints a new session and resumes an existing one (one flag, both
430            // directions), and `--output-format json` is a JSONL event stream.
431            // There is no headless fork.
432            Agent::Copilot => Caps {
433                session: SessionSupport::Minted,
434                fork: false,
435                events: true,
436                native_system: false,
437            },
438        }
439    }
440
441    /// The format that can carry this agent's session id, if any. A named
442    /// session upgrades to this when the caller did not pin a format.
443    #[must_use]
444    pub fn session_format(self) -> Option<Format> {
445        match self.caps().session {
446            // Claude reports the id in both structured formats; `Json` is the
447            // cheaper default when the caller did not ask to stream.
448            SessionSupport::Minted | SessionSupport::Printed => Some(match self {
449                Agent::Claude => Format::Json,
450                // `--json` IS Codex's stream and Copilot's `json` is JSONL;
451                // neither has a single-document form.
452                Agent::Codex | Agent::Copilot => Format::Stream,
453            }),
454            SessionSupport::None => None,
455        }
456    }
457
458    /// Whether `format` can carry this agent's session id.
459    ///
460    /// Distinct from [`Agent::session_format`], which names the *preferred* one:
461    /// Claude reports its id under both `Json` and `Stream`, and only plain text
462    /// loses it. A named session needs this, not equality with the preferred
463    /// format, or streaming a named Claude session would be refused for no
464    /// reason.
465    #[must_use]
466    pub fn format_carries_session(self, format: Format) -> bool {
467        self.session_format().is_some() && format != Format::Text
468    }
469
470    /// Reject a plan this agent cannot honour, before anything is spawned.
471    fn check(self, plan: &Plan) -> Result<()> {
472        let caps = self.caps();
473        if matches!(plan.cont, Continue::Fork(_)) && !caps.fork {
474            return Err(Error::Unsupported {
475                agent: self,
476                what: "forking a session headlessly",
477            });
478        }
479        if matches!(plan.cont, Continue::NewWith(_)) && caps.session != SessionSupport::Minted {
480            return Err(Error::Unsupported {
481                agent: self,
482                what: "assigning a session id up front",
483            });
484        }
485        if plan.format == Format::Stream && !caps.events {
486            return Err(Error::Unsupported {
487                agent: self,
488                what: "a structured event stream",
489            });
490        }
491        Ok(())
492    }
493
494    /// Build the command line for `plan`.
495    ///
496    /// The first element is the binary; the rest are its arguments. Returns
497    /// [`Error::Unsupported`] when the plan asks for a capability this agent
498    /// lacks, never a quiet downgrade.
499    ///
500    /// # Errors
501    /// [`Error::Unsupported`] if the plan needs a capability this agent lacks.
502    pub fn argv(self, plan: &Plan) -> Result<Vec<String>> {
503        Ok(self
504            .typed_argv(plan)?
505            .into_iter()
506            .map(|arg| arg.value)
507            .collect())
508    }
509
510    /// The command line with each argument's sensitivity attached.
511    ///
512    /// # Errors
513    /// [`Error::Unsupported`] if the plan needs a capability this agent lacks.
514    pub(crate) fn typed_argv(self, plan: &Plan) -> Result<Vec<Arg>> {
515        self.check(plan)?;
516        Ok(match self {
517            Agent::Claude => argv_claude(plan),
518            Agent::Codex => argv_codex(plan),
519            Agent::Copilot => argv_copilot(plan),
520        })
521    }
522
523    /// The prompt text actually delivered, with the system prompt folded in for
524    /// agents that have no flag for it. Never dropped silently.
525    #[must_use]
526    pub fn effective_prompt(self, plan: &Plan) -> String {
527        match (&plan.system, self.caps().native_system) {
528            (Some(system), false) => format!("{system}\n\n{}", plan.prompt),
529            _ => plan.prompt.clone(),
530        }
531    }
532}
533
534impl fmt::Display for Agent {
535    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
536        f.write_str(self.id())
537    }
538}
539
540/// How sensitive one argument's value is, decided where the argument is built
541/// rather than guessed back afterwards.
542///
543/// Reconstructing this from a finished command line means pattern-matching flag
544/// names and positions, which misses exactly the cases that matter: Codex's
545/// prompt is a bare trailing positional, and anything from `unchecked_args` has
546/// no recognizable shape at all. Recording it at construction cannot miss.
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub(crate) enum Sensitivity {
549    /// A flag name or fixed token. Safe to show.
550    Public,
551    /// User or caller content: prompts and system prompts.
552    Prompt,
553    /// A session handle, which resumes a conversation.
554    SessionId,
555    /// Caller-supplied raw arguments. Unknowable, so assumed sensitive.
556    Unchecked,
557}
558
559/// One argument and how sensitive it is.
560#[derive(Debug, Clone)]
561pub(crate) struct Arg {
562    pub(crate) value: String,
563    pub(crate) sensitivity: Sensitivity,
564}
565
566/// Builds an argv, keeping every flag name literal at its call site so the flag
567/// list for an agent stays greppable and auditable against `--help`, and
568/// recording per-argument sensitivity so the executable and redacted forms come
569/// from one source.
570pub(crate) struct Argv(Vec<Arg>);
571
572impl Argv {
573    /// Start with the binary.
574    fn new(bin: &str) -> Self {
575        Self(vec![Arg {
576            value: bin.to_string(),
577            sensitivity: Sensitivity::Public,
578        }])
579    }
580
581    fn push(&mut self, value: impl Into<String>, sensitivity: Sensitivity) -> &mut Self {
582        self.0.push(Arg {
583            value: value.into(),
584            sensitivity,
585        });
586        self
587    }
588
589    /// A bare flag with no value.
590    fn bare(&mut self, flag: &str) -> &mut Self {
591        self.push(flag, Sensitivity::Public)
592    }
593
594    /// A flag and a value that is safe to show.
595    fn pair(&mut self, flag: &str, value: impl AsRef<str>) -> &mut Self {
596        self.bare(flag).push(value.as_ref(), Sensitivity::Public)
597    }
598
599    /// A flag and a value that must not be logged.
600    fn secret(&mut self, flag: &str, value: impl AsRef<str>, kind: Sensitivity) -> &mut Self {
601        self.bare(flag).push(value.as_ref(), kind)
602    }
603
604    /// A flag and its value, only when the value is present.
605    fn opt(&mut self, flag: &str, value: Option<&String>) -> &mut Self {
606        if let Some(value) = value {
607            self.pair(flag, value);
608        }
609        self
610    }
611
612    /// A positional argument that is safe to show.
613    fn arg(&mut self, value: impl Into<String>) -> &mut Self {
614        self.push(value, Sensitivity::Public)
615    }
616
617    /// A positional argument carrying caller content.
618    fn arg_sensitive(&mut self, value: impl Into<String>, kind: Sensitivity) -> &mut Self {
619        self.push(value, kind)
620    }
621
622    fn done(&mut self) -> Vec<Arg> {
623        std::mem::take(&mut self.0)
624    }
625}
626
627/// Claude Code's permission-mode token for each posture. Choices verified from
628/// `claude --help` (2.1.205): acceptEdits, auto, bypassPermissions, manual,
629/// dontAsk, plan.
630fn claude_mode(p: Permission) -> &'static str {
631    match p {
632        // `dontAsk` auto-denies gated tools and keeps going rather than
633        // blocking on a prompt no one can answer headlessly. The read-only
634        // guarantee comes from `--disallowedTools`, below.
635        Permission::ReadOnly => "dontAsk",
636        Permission::Plan => "plan",
637        Permission::Edit => "acceptEdits",
638        Permission::Auto => "auto",
639        Permission::Bypass => "bypassPermissions",
640    }
641}
642
643/// `claude -p <prompt> --permission-mode M --output-format F [...]`
644fn argv_claude(plan: &Plan) -> Vec<Arg> {
645    let mut a = Argv::new(&plan.bin);
646    a.bare("-p");
647    if plan.stdin_prompt {
648        // With `--input-format text` claude reads the prompt from stdin, so a
649        // large prompt never has to fit on the argv.
650        a.pair("--input-format", "text");
651    } else {
652        a.arg_sensitive(Agent::Claude.effective_prompt(plan), Sensitivity::Prompt);
653    }
654
655    a.pair("--permission-mode", claude_mode(plan.permission));
656    if plan.permission == Permission::ReadOnly {
657        // Remove the mutating built-ins outright. Reads still run via
658        // Read/Grep/Glob. `mcp__*` covers every MCP tool: denying only the
659        // built-in writers would leave an MCP server free to mutate remote
660        // state during a run the caller asked to be read-only.
661        a.bare("--disallowedTools");
662        for tool in ["Bash", "Edit", "Write", "NotebookEdit", "mcp__*"] {
663            a.arg(tool);
664        }
665    }
666
667    a.opt("--model", plan.model.as_ref());
668    if let Some(system) = &plan.system {
669        a.secret("--append-system-prompt", system, Sensitivity::Prompt);
670    }
671
672    match &plan.cont {
673        Continue::New => {}
674        Continue::NewWith(id) => {
675            a.secret("--session-id", id, Sensitivity::SessionId);
676        }
677        Continue::Resume(id) => {
678            a.secret("--resume", id, Sensitivity::SessionId);
679        }
680        Continue::Fork(id) => {
681            // Mints a new id off `id`, leaving the original and its cached
682            // prefix untouched. The new id comes back in the output.
683            a.secret("--resume", id, Sensitivity::SessionId)
684                .bare("--fork-session");
685        }
686    }
687
688    a.pair(
689        "--output-format",
690        match plan.format {
691            Format::Text => "text",
692            Format::Json => "json",
693            Format::Stream => "stream-json",
694        },
695    );
696    if plan.format == Format::Stream {
697        // Claude refuses `-p --output-format stream-json` without it:
698        // "--print with --output-format=stream-json requires --verbose".
699        a.bare("--verbose");
700    }
701    a.done()
702}
703
704/// `codex exec [resume <id>] --skip-git-repo-check [sandbox flags] [--model M]
705/// [--json] <prompt>`
706fn argv_codex(plan: &Plan) -> Vec<Arg> {
707    let mut a = Argv::new(&plan.bin);
708    a.bare("exec");
709    if let Continue::Resume(id) = &plan.cont {
710        // Continuation is a subcommand, not a flag.
711        a.bare("resume")
712            .arg_sensitive(id.clone(), Sensitivity::SessionId);
713    }
714
715    // `codex exec` aborts outside a git repository unless told not to. That
716    // check guards against an agent editing files with no way to undo them, but
717    // this crate is embedded in hosts that legitimately run against scratch
718    // directories, worktrees and review checkouts, and a hard abort there is
719    // useless to them. The real containment is the sandbox below, which is
720    // `read-only` by default, so nothing is unrecoverable regardless.
721    a.bare("--skip-git-repo-check");
722
723    // `codex exec` takes `--sandbox`, but `codex exec resume` does **not**: it
724    // rejects the flag outright and takes the same setting as a `-c` config
725    // override instead. Verified against codex-cli 0.145.0, where passing
726    // `--sandbox` to a resume fails with "unexpected argument '--sandbox'".
727    // Dropping the sandbox on resume would silently run a continued turn under a
728    // different posture than the caller asked for.
729    let resuming = matches!(plan.cont, Continue::Resume(_));
730    let sandbox = match plan.permission {
731        Permission::Bypass => None,
732        Permission::ReadOnly | Permission::Plan => Some("read-only"),
733        Permission::Edit | Permission::Auto => Some("workspace-write"),
734    };
735    match (sandbox, resuming) {
736        (None, _) => a.bare("--dangerously-bypass-approvals-and-sandbox"),
737        (Some(mode), false) => a.pair("--sandbox", mode),
738        // The value is TOML-parsed, falling back to a raw string, so the bare
739        // token is read as the mode name.
740        (Some(mode), true) => a.pair("-c", format!("sandbox_mode={mode}")),
741    };
742
743    a.opt("--model", plan.model.as_ref());
744    // `--json` is Codex's event stream and the only place `thread_id` appears.
745    if plan.format != Format::Text {
746        a.bare("--json");
747    }
748    // Codex has no system flag, so the system text rides the prompt. A literal
749    // `-` makes it read the prompt from stdin instead, keeping a large one off
750    // the argv.
751    // Codex takes the prompt as a bare trailing positional, which is exactly
752    // the shape positional redaction guesswork gets wrong.
753    if plan.stdin_prompt {
754        a.arg("-");
755    } else {
756        a.arg_sensitive(Agent::Codex.effective_prompt(plan), Sensitivity::Prompt);
757    }
758    a.done()
759}
760
761/// `copilot -p <prompt> --allow-all-tools [...] [--session-id <uuid>]`
762///
763/// Flags verified against Copilot CLI 1.0.75. Two of its conventions matter:
764/// `--allow-all-tools` is *required* for non-interactive mode, and the
765/// repeatable tool filters are declared `--allow-tool[=tools...]`, an optional
766/// value, which only binds with `=`, never across a space.
767fn argv_copilot(plan: &Plan) -> Vec<Arg> {
768    // Copilot reads stdin as the prompt only when `-p` is absent: a `-p` value
769    // makes the pipe be ignored. So a piped prompt drops the flag entirely.
770    let mut a = Argv::new(&plan.bin);
771    if !plan.stdin_prompt {
772        a.secret(
773            "-p",
774            Agent::Copilot.effective_prompt(plan),
775            Sensitivity::Prompt,
776        );
777    }
778
779    // Without this, a headless run stops at the first tool confirmation.
780    a.bare("--allow-all-tools").bare("--no-ask-user");
781    match plan.permission {
782        Permission::Bypass | Permission::Auto => a.bare("--allow-all-paths"),
783        // Deny beats allow, so this is allow-all minus the mutating tools.
784        // `--allow-all-paths` is deliberately NOT set: it disables path
785        // verification entirely, which would widen filesystem reach in the one
786        // posture that exists to narrow it.
787        Permission::ReadOnly => a.bare("--deny-tool=shell").bare("--deny-tool=write"),
788        // Edits run; shell stays denied so commands cannot.
789        Permission::Edit => a.bare("--deny-tool=shell"),
790        Permission::Plan => a.pair("--mode", "plan"),
791    };
792
793    a.opt("--model", plan.model.as_ref());
794    // One flag serves both directions: it sets the UUID for a new session and
795    // resumes an existing one by id.
796    match &plan.cont {
797        Continue::NewWith(id) | Continue::Resume(id) => {
798            a.secret("--session-id", id, Sensitivity::SessionId);
799        }
800        // `Fork` is rejected by `Agent::check` before reaching here.
801        Continue::New | Continue::Fork(_) => {}
802    }
803
804    a.pair(
805        "--output-format",
806        if plan.format == Format::Text {
807            "text"
808        } else {
809            // Copilot's `json` is JSONL, so it serves both structured formats.
810            "json"
811        },
812    );
813    a.done()
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819
820    fn plan(bin: &str) -> Plan {
821        Plan {
822            bin: bin.into(),
823            prompt: "hi".into(),
824            system: None,
825            model: None,
826            permission: Permission::ReadOnly,
827            format: Format::Json,
828            cont: Continue::New,
829            stdin_prompt: false,
830        }
831    }
832
833    fn argv(agent: Agent, plan: &Plan) -> Vec<String> {
834        agent.argv(plan).expect("plan is supported")
835    }
836
837    fn pos(a: &[String], needle: &str) -> Option<usize> {
838        a.iter().position(|s| s == needle)
839    }
840
841    #[test]
842    fn claude_builds_print_mode_with_format_and_permission() {
843        let a = argv(Agent::Claude, &plan("claude"));
844        assert_eq!(a[0..3], ["claude", "-p", "hi"]);
845        assert!(pos(&a, "--permission-mode").is_some());
846        assert!(a.contains(&"dontAsk".to_string()));
847        assert_eq!(a[pos(&a, "--output-format").unwrap() + 1], "json");
848    }
849
850    #[test]
851    fn claude_read_only_removes_the_mutating_tools() {
852        let a = argv(Agent::Claude, &plan("claude"));
853        let at = pos(&a, "--disallowedTools").expect("read-only denies tools");
854        assert_eq!(
855            &a[at + 1..at + 5],
856            ["Bash", "Edit", "Write", "NotebookEdit"]
857        );
858    }
859
860    #[test]
861    fn claude_bypass_does_not_deny_tools() {
862        let mut p = plan("claude");
863        p.permission = Permission::Bypass;
864        let a = argv(Agent::Claude, &p);
865        assert!(a.contains(&"bypassPermissions".to_string()));
866        assert!(pos(&a, "--disallowedTools").is_none());
867    }
868
869    #[test]
870    fn claude_stream_format_adds_verbose_but_json_does_not() {
871        let mut p = plan("claude");
872        p.format = Format::Stream;
873        assert!(argv(Agent::Claude, &p).contains(&"--verbose".to_string()));
874        p.format = Format::Json;
875        assert!(!argv(Agent::Claude, &p).contains(&"--verbose".to_string()));
876    }
877
878    #[test]
879    fn claude_mints_an_id_for_a_new_session_and_resumes_an_old_one() {
880        let mut p = plan("claude");
881        p.cont = Continue::NewWith("11111111-2222-3333-4444-555555555555".into());
882        let a = argv(Agent::Claude, &p);
883        assert_eq!(
884            a[pos(&a, "--session-id").unwrap() + 1],
885            "11111111-2222-3333-4444-555555555555"
886        );
887        assert!(pos(&a, "--resume").is_none());
888
889        p.cont = Continue::Resume("sess-1".into());
890        let a = argv(Agent::Claude, &p);
891        assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1");
892        assert!(!a.contains(&"--fork-session".to_string()));
893    }
894
895    #[test]
896    fn claude_fork_resumes_and_branches() {
897        let mut p = plan("claude");
898        p.cont = Continue::Fork("sess-1".into());
899        let a = argv(Agent::Claude, &p);
900        assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1");
901        assert!(a.contains(&"--fork-session".to_string()));
902    }
903
904    #[test]
905    fn claude_keeps_the_system_prompt_on_its_own_flag() {
906        let mut p = plan("claude");
907        p.system = Some("be terse".into());
908        let a = argv(Agent::Claude, &p);
909        assert_eq!(
910            a[pos(&a, "--append-system-prompt").unwrap() + 1],
911            "be terse"
912        );
913        // The prompt itself stays clean.
914        assert!(a.contains(&"hi".to_string()));
915    }
916
917    #[test]
918    fn claude_stdin_prompt_leaves_the_argv() {
919        let mut p = plan("claude");
920        p.stdin_prompt = true;
921        let a = argv(Agent::Claude, &p);
922        assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "text");
923        assert!(!a.contains(&"hi".to_string()), "prompt must not ride argv");
924    }
925
926    #[test]
927    fn codex_resume_is_a_subcommand_and_prompt_is_last() {
928        let mut p = plan("codex");
929        p.cont = Continue::Resume("thread-9".into());
930        let a = argv(Agent::Codex, &p);
931        assert_eq!(a[0..4], ["codex", "exec", "resume", "thread-9"]);
932        assert_eq!(a.last().unwrap(), "hi");
933    }
934
935    /// `Minimal` exists to withhold secrets, so nothing it passes through may
936    /// be a credential carrier. Proxy URLs in particular routinely embed
937    /// `user:pass`, which is why they are offered separately instead.
938    #[test]
939    fn the_minimal_environment_carries_no_proxy_variables() {
940        for agent in Agent::ALL {
941            let essential = agent.essential_env();
942            for name in NETWORK_ENV {
943                assert!(
944                    !essential.contains(name),
945                    "{agent} would pass {name} through EnvPolicy::Minimal"
946                );
947            }
948        }
949    }
950
951    /// The floor verified live on macOS: with exactly these set, all three CLIs
952    /// authenticate and answer. Claude reports "Not logged in" without `USER`.
953    #[test]
954    fn every_agent_asks_for_the_verified_floor() {
955        for agent in Agent::ALL {
956            let essential = agent.essential_env();
957            for name in ["PATH", "HOME", "USER"] {
958                assert!(essential.contains(&name), "{agent} omits {name}");
959            }
960        }
961    }
962
963    /// Each agent's own credentials, and nobody else's.
964    #[test]
965    fn agents_do_not_request_each_others_credentials() {
966        let claude = Agent::Claude.essential_env();
967        assert!(claude.contains(&"ANTHROPIC_API_KEY"));
968        assert!(!claude.contains(&"OPENAI_API_KEY"));
969        assert!(!claude.contains(&"GH_TOKEN"));
970
971        let codex = Agent::Codex.essential_env();
972        assert!(codex.contains(&"OPENAI_API_KEY"));
973        assert!(!codex.contains(&"ANTHROPIC_API_KEY"));
974    }
975
976    /// The model is the caller's choice on every agent. It is forwarded
977    /// verbatim and never defaulted, normalized, or validated here: a host with
978    /// a model picker owns that list, and an unknown name must surface as the
979    /// agent's own error rather than something this crate guessed at.
980    #[test]
981    fn every_agent_forwards_the_callers_model_verbatim() {
982        for agent in Agent::ALL {
983            let mut p = plan(agent.bin());
984            // Deliberately not a real model id: nothing here may interpret it.
985            p.model = Some("some-model-9".into());
986            let a = argv(agent, &p);
987            let at = pos(&a, "--model").unwrap_or_else(|| panic!("{agent} dropped --model: {a:?}"));
988            assert_eq!(a[at + 1], "some-model-9", "{agent} rewrote the model");
989        }
990    }
991
992    /// No model means the agent picks its own, so a host can offer a "default"
993    /// entry without this crate inventing one.
994    #[test]
995    fn no_model_means_no_model_flag() {
996        for agent in Agent::ALL {
997            let p = plan(agent.bin());
998            assert!(p.model.is_none());
999            let a = argv(agent, &p);
1000            assert!(
1001                pos(&a, "--model").is_none(),
1002                "{agent} invented a model: {a:?}"
1003            );
1004        }
1005    }
1006
1007    /// `codex exec` aborts outside a git repository. A host embedding this
1008    /// crate runs against scratch dirs and review checkouts, so the check is
1009    /// waived on every invocation; the sandbox is what actually contains a run.
1010    #[test]
1011    fn codex_always_waives_the_git_repo_check() {
1012        for cont in [Continue::New, Continue::Resume("t-1".into())] {
1013            let mut p = plan("codex");
1014            p.cont = cont.clone();
1015            assert!(
1016                argv(Agent::Codex, &p).contains(&"--skip-git-repo-check".to_string()),
1017                "{cont:?} must still run outside a repo"
1018            );
1019        }
1020    }
1021
1022    /// `codex exec resume` rejects `--sandbox` and takes `-c sandbox_mode=`
1023    /// instead. Getting this wrong makes every second turn fail with an
1024    /// "unexpected argument" error, which only a multi-turn run reveals.
1025    #[test]
1026    fn codex_sets_the_sandbox_by_flag_when_fresh_and_by_config_when_resuming() {
1027        let mut fresh = plan("codex");
1028        fresh.permission = Permission::ReadOnly;
1029        let a = argv(Agent::Codex, &fresh);
1030        assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], "read-only");
1031        assert!(pos(&a, "-c").is_none());
1032
1033        let mut resumed = fresh.clone();
1034        resumed.cont = Continue::Resume("thread-9".into());
1035        let a = argv(Agent::Codex, &resumed);
1036        assert!(
1037            pos(&a, "--sandbox").is_none(),
1038            "resume rejects --sandbox: {a:?}"
1039        );
1040        assert_eq!(a[pos(&a, "-c").unwrap() + 1], "sandbox_mode=read-only");
1041    }
1042
1043    #[test]
1044    fn codex_bypass_uses_the_same_flag_on_both_paths() {
1045        for cont in [Continue::New, Continue::Resume("t".into())] {
1046            let mut p = plan("codex");
1047            p.permission = Permission::Bypass;
1048            p.cont = cont.clone();
1049            let a = argv(Agent::Codex, &p);
1050            assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
1051            assert!(pos(&a, "--sandbox").is_none(), "{cont:?}: {a:?}");
1052        }
1053    }
1054
1055    #[test]
1056    fn codex_without_a_system_flag_prepends_it_to_the_prompt() {
1057        let mut p = plan("codex");
1058        p.system = Some("be terse".into());
1059        let a = argv(Agent::Codex, &p);
1060        assert_eq!(a.last().unwrap(), "be terse\n\nhi");
1061    }
1062
1063    #[test]
1064    fn codex_maps_each_posture_to_a_sandbox() {
1065        for (perm, expect) in [
1066            (Permission::ReadOnly, "read-only"),
1067            (Permission::Plan, "read-only"),
1068            (Permission::Edit, "workspace-write"),
1069            (Permission::Auto, "workspace-write"),
1070        ] {
1071            let mut p = plan("codex");
1072            p.permission = perm;
1073            let a = argv(Agent::Codex, &p);
1074            assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], expect, "{perm:?}");
1075        }
1076        let mut p = plan("codex");
1077        p.permission = Permission::Bypass;
1078        let a = argv(Agent::Codex, &p);
1079        assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
1080        assert!(pos(&a, "--sandbox").is_none());
1081    }
1082
1083    #[test]
1084    fn copilot_drops_dash_p_when_the_prompt_is_piped() {
1085        let mut p = plan("copilot");
1086        p.stdin_prompt = true;
1087        let a = argv(Agent::Copilot, &p);
1088        assert!(
1089            !a.contains(&"-p".to_string()),
1090            "a -p value shadows the pipe"
1091        );
1092        assert!(!a.contains(&"hi".to_string()));
1093    }
1094
1095    /// Copilot declares its tool filters as `--deny-tool[=tools...]`, an
1096    /// optional value, which binds only with `=`. Passed across a space the
1097    /// value is silently read as a positional instead, so the deny is lost.
1098    #[test]
1099    fn copilot_read_only_denies_shell_and_write_with_the_combined_form() {
1100        let a = argv(Agent::Copilot, &plan("copilot"));
1101        assert!(a.contains(&"--deny-tool=shell".to_string()));
1102        assert!(a.contains(&"--deny-tool=write".to_string()));
1103        assert!(
1104            !a.iter().any(|s| s == "--deny-tool"),
1105            "a bare --deny-tool would drop its value: {a:?}"
1106        );
1107    }
1108
1109    /// A headless Copilot run stalls at the first tool confirmation without it.
1110    #[test]
1111    fn copilot_always_allows_tools_and_silences_the_ask_tool() {
1112        for permission in [Permission::ReadOnly, Permission::Plan, Permission::Bypass] {
1113            let mut p = plan("copilot");
1114            p.permission = permission;
1115            let a = argv(Agent::Copilot, &p);
1116            assert!(
1117                a.contains(&"--allow-all-tools".to_string()),
1118                "{permission:?}"
1119            );
1120            assert!(a.contains(&"--no-ask-user".to_string()), "{permission:?}");
1121        }
1122    }
1123
1124    /// Copilot uses one flag in both directions: it sets the id for a new
1125    /// session and resumes an existing one.
1126    #[test]
1127    fn copilot_uses_session_id_for_both_new_and_resumed_sessions() {
1128        for cont in [
1129            Continue::NewWith("11111111-2222-3333-4444-555555555555".into()),
1130            Continue::Resume("11111111-2222-3333-4444-555555555555".into()),
1131        ] {
1132            let mut p = plan("copilot");
1133            p.cont = cont.clone();
1134            let a = argv(Agent::Copilot, &p);
1135            assert_eq!(
1136                a[pos(&a, "--session-id").unwrap() + 1],
1137                "11111111-2222-3333-4444-555555555555",
1138                "{cont:?}"
1139            );
1140        }
1141    }
1142
1143    #[test]
1144    fn unsupported_capabilities_are_refused_not_downgraded() {
1145        // Forking headlessly is Claude-only.
1146        for agent in [Agent::Codex, Agent::Copilot] {
1147            let mut p = plan(agent.bin());
1148            p.cont = Continue::Fork("s".into());
1149            assert!(
1150                matches!(agent.argv(&p), Err(Error::Unsupported { .. })),
1151                "{agent} must refuse a fork rather than resume linearly"
1152            );
1153        }
1154        // Codex's id is printed, not assigned, so it cannot be chosen up front.
1155        let mut p = plan("codex");
1156        p.cont = Continue::NewWith("id".into());
1157        assert!(matches!(
1158            Agent::Codex.argv(&p),
1159            Err(Error::Unsupported { .. })
1160        ));
1161    }
1162
1163    /// All three expose an id, so all three can back a named session, but only
1164    /// through a format that actually carries one.
1165    #[test]
1166    fn every_agent_has_a_format_that_carries_its_session_id() {
1167        assert_eq!(Agent::Claude.session_format(), Some(Format::Json));
1168        assert_eq!(Agent::Codex.session_format(), Some(Format::Stream));
1169        assert_eq!(Agent::Copilot.session_format(), Some(Format::Stream));
1170    }
1171
1172    /// Claude and Copilot let the caller assign the id, so a run that dies
1173    /// mid-turn still leaves a resumable session.
1174    #[test]
1175    fn the_minting_agents_are_claude_and_copilot() {
1176        let minting: Vec<_> = Agent::ALL
1177            .into_iter()
1178            .filter(|a| a.caps().session == SessionSupport::Minted)
1179            .collect();
1180        assert_eq!(minting, [Agent::Claude, Agent::Copilot]);
1181    }
1182}