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}