Skip to main content

sqlite_graphrag/commands/
claude_runner.rs

1//! Shared module for spawning Claude Code (`claude -p`) subprocesses.
2//!
3//! Eliminates duplication between `enrich.rs` and `ingest_claude.rs` (G02).
4//! Detects `terminal_reason: "max_turns"` in the JSON output (G03).
5//!
6//! v1.0.83 (ADR-0041): env whitelist now delegates to
7//! `crate::spawn::env_whitelist::apply_env_whitelist` so the canonical list
8//! lives in one place. `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` remain
9//! INTENTIONALLY ABSENT (defence-in-depth alongside the OAuth-only guards).
10
11use crate::errors::AppError;
12use crate::spawn::env_whitelist::apply_env_whitelist;
13use std::path::Path;
14use std::process::{Command, Stdio};
15
16/// Minimum Claude Code version required for structured JSON output.
17const MIN_CLAUDE_VERSION: &str = "2.1.0";
18
19/// Default virtual memory limit for LLM subprocesses (4 GiB).
20#[cfg(target_os = "linux")]
21const DEFAULT_SUBPROCESS_MEMORY_LIMIT_MB: u64 = 4096;
22
23// G28-C (v1.0.69): process lifecycle. The G28 gap asks for
24// `tokio::process::Command::kill_on_drop(true)`. This codebase uses
25// `std::process::Command` (synchronous) so the tokio helper is not
26// available. Equivalent defence-in-depth is provided by:
27//
28// 1. `SIGTERM` via `libc::kill` in the timeout branch of `run_claude`
29//    and `run_codex` (graceful — gives the child a chance to clean up
30//    MCP children and write logs).
31// 2. `child.kill()` (SIGKILL) if SIGTERM was ignored.
32// 3. `reaper::scan_and_kill_orphans()` at startup, which walks `/proc`
33//    and reaps any `claude`/`codex` processes that were orphaned by a
34//    previous crash.
35//
36// SIGKILL on drop is intentionally NOT used because (a) the gaps.md
37// Passo C warning flags it as risky per tokio-rs/tokio#7082, and (b)
38// the SIGTERM-then-SIGKILL pair covers the same threat model with
39// better cleanup behaviour.
40
41/// Spawns a command with a virtual memory limit via `setrlimit(RLIMIT_AS)`.
42///
43/// On Linux, applies the limit in a `pre_exec` hook before the child process
44/// starts.  On non-Linux platforms, falls back to an unlimited spawn.
45/// The limit is read from XDG `spawn.subprocess_memory_limit_mb`
46/// (`config set`; default: 4096 MiB).
47#[cfg(target_os = "linux")]
48pub fn spawn_with_memory_limit(cmd: &mut Command) -> std::io::Result<std::process::Child> {
49    use std::os::unix::process::CommandExt;
50    let max_mb: u64 = crate::runtime_config::resolve_u64(
51        None,
52        "spawn.subprocess_memory_limit_mb",
53        DEFAULT_SUBPROCESS_MEMORY_LIMIT_MB,
54    );
55    let max_bytes = max_mb * 1024 * 1024;
56    // SAFETY: pre_exec closure runs between fork() and exec() in the
57    // single-threaded child process — no other threads exist.
58    // libc::setsid and libc::setrlimit are async-signal-safe per POSIX.1-2008 §2.4.3.
59    // RLIMIT_AS limits virtual address space, not physical RSS.
60    // setsid failure with EPERM is tolerated (process already a session leader).
61    // On setrlimit failure, Err(last_os_error()) prevents exec.
62    unsafe {
63        cmd.pre_exec(move || {
64            let sid = libc::setsid();
65            if sid == -1 {
66                let err = std::io::Error::last_os_error();
67                if err.raw_os_error() != Some(libc::EPERM) {
68                    return Err(err);
69                }
70            }
71            let limit = libc::rlimit {
72                rlim_cur: max_bytes,
73                rlim_max: max_bytes,
74            };
75            if libc::setrlimit(libc::RLIMIT_AS, &limit) != 0 {
76                return Err(std::io::Error::last_os_error());
77            }
78            Ok(())
79        });
80    }
81    tracing::debug!(
82        target: "process",
83        program = ?cmd.get_program(),
84        args = ?cmd.get_args().collect::<Vec<_>>(),
85        "spawning external process"
86    );
87    cmd.spawn()
88}
89
90/// Spawns a command without memory limits (non-Linux fallback).
91/// On Unix (macOS, FreeBSD), applies setsid for process group isolation.
92#[cfg(not(target_os = "linux"))]
93pub fn spawn_with_memory_limit(cmd: &mut Command) -> std::io::Result<std::process::Child> {
94    #[cfg(unix)]
95    {
96        use std::os::unix::process::CommandExt;
97        // SAFETY: setsid() is async-signal-safe per POSIX.1-2008 §2.4.3.
98        // Creates independent session for cascade termination.
99        unsafe {
100            cmd.pre_exec(|| {
101                let sid = libc::setsid();
102                if sid == -1 {
103                    let err = std::io::Error::last_os_error();
104                    if err.raw_os_error() != Some(libc::EPERM) {
105                        return Err(err);
106                    }
107                }
108                Ok(())
109            });
110        }
111    }
112    tracing::debug!(
113        target: "process",
114        program = ?cmd.get_program(),
115        args = ?cmd.get_args().collect::<Vec<_>>(),
116        "spawning external process"
117    );
118    cmd.spawn()
119}
120
121/// Parsed output element from `claude -p --output-format json`.
122#[derive(Debug, serde::Deserialize)]
123pub struct ClaudeOutputElement {
124    /// Item.
125    pub r#type: Option<String>,
126    /// Subtype.
127    pub subtype: Option<String>,
128    /// Is error.
129    #[serde(default)]
130    pub is_error: bool,
131    /// Structured output.
132    pub structured_output: Option<serde_json::Value>,
133    /// Result.
134    pub result: Option<String>,
135    /// Total cost usd.
136    pub total_cost_usd: Option<f64>,
137    /// Error message, if any.
138    pub error: Option<String>,
139    /// Terminal reason.
140    pub terminal_reason: Option<String>,
141    /// API key source.
142    #[serde(rename = "apiKeySource")]
143    pub api_key_source: Option<String>,
144}
145
146/// Result of a successful Claude invocation.
147#[derive(Debug)]
148pub struct ClaudeResult {
149    /// Value.
150    pub value: serde_json::Value,
151    /// Cost usd.
152    pub cost_usd: f64,
153    /// Is oauth.
154    pub is_oauth: bool,
155}
156
157/// Validates that the Claude binary meets the minimum version requirement.
158pub fn validate_claude_version(binary: &Path) -> Result<String, AppError> {
159    let resolved = which::which(binary).map_err(|_| {
160        AppError::Validation(crate::i18n::validation::executable_not_in_path_generic(
161            &binary.display().to_string(),
162        ))
163    })?;
164    let output = Command::new(&resolved)
165        .arg("--version")
166        .stdin(Stdio::null())
167        .stdout(Stdio::piped())
168        .stderr(Stdio::piped())
169        .output()
170        .map_err(AppError::Io)?;
171
172    if !output.status.success() {
173        return Err(AppError::Validation(
174            "failed to run 'claude --version'".to_string(),
175        ));
176    }
177
178    let version_str = String::from_utf8(output.stdout)
179        .map_err(|_| AppError::Validation(crate::i18n::validation::claude_version_not_utf8()))?;
180    let version = version_str.trim().to_string();
181    let numeric = version.split([' ', '(']).next().unwrap_or("").trim();
182
183    fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
184        let parts: Vec<&str> = s.splitn(3, '.').collect();
185        if parts.len() < 2 {
186            return None;
187        }
188        let major = parts[0].parse::<u64>().ok()?;
189        let minor = parts[1].parse::<u64>().ok()?;
190        let patch = parts
191            .get(2)
192            .and_then(|p| p.parse::<u64>().ok())
193            .unwrap_or(0);
194        Some((major, minor, patch))
195    }
196
197    if let (Some(actual), Some(min)) = (parse_semver(numeric), parse_semver(MIN_CLAUDE_VERSION)) {
198        if actual < min {
199            return Err(AppError::Validation(crate::i18n::validation::version_below_minimum(
200                    "Claude Code",
201                    numeric,
202                    MIN_CLAUDE_VERSION,
203                )));
204        }
205    }
206
207    Ok(version)
208}
209
210/// Builds a `Command` for `claude -p` with least-privilege environment.
211///
212/// G28-A (v1.0.68) + OAuth-only hardening (v1.0.69, mandated by gaps.md
213/// lines 41-49): the command ALWAYS uses the OAuth flow. The flag set
214/// is the canonical one documented in gaps.md Fix A:
215///
216/// ```text
217/// claude -p "TAREFA" \
218///   --strict-mcp-config \
219///   --mcp-config '{}' \
220///   --dangerously-skip-permissions \
221///   --settings '{"hooks":{}}' \
222///   --model <X> \
223///   --max-turns <N> \
224///   --output-format json \
225///   --no-session-persistence
226/// ```
227///
228/// The combination cuts the typical 8-10 MCP process tree to zero and
229/// disables user hooks. The reaper sweep at startup (see `reaper::scan_and_kill_orphans`)
230/// is the last line of defence for any process that ignored the flags.
231///
232/// **`--bare` is FORBIDDEN** (gaps.md:49 and operator policy):
233/// `--bare` cuts MCPs but disables OAuth and demands `ANTHROPIC_API_KEY`,
234/// which is PROHIBITED in this project. We also ABORT the spawn if
235/// `ANTHROPIC_API_KEY` is set in the environment, because that is the
236/// gateway to the prohibited API-key path.
237///
238/// GitHub issue [anthropics/claude-code#10787] documents that earlier
239/// Claude Code CLI builds sometimes ignored `--strict-mcp-config` and
240/// fell back to `~/.mcp.json`. We still pass the flags as defence-in-depth
241/// and ALSO honour XDG `llm.claude_empty_config_dir` so users
242/// who need belt-and-suspenders isolation can point Claude at an empty
243/// config directory (no MCP, no hooks, no settings).
244///
245/// [anthropics/claude-code#10787]: https://github.com/anthropics/claude-code/issues/10787
246pub fn build_claude_command(
247    binary: &Path,
248    prompt: &str,
249    json_schema: &str,
250    model: Option<&str>,
251    max_turns: u32,
252) -> Result<Command, crate::errors::AppError> {
253    // OAuth-only guard (gaps.md:47, ADR-0011). If `ANTHROPIC_API_KEY` is
254    // set in the environment we MUST abort — that is the API-key path
255    // which is explicitly PROHIBITED. Use the OAuth flow exclusively.
256    if let Ok(_key) = std::env::var("ANTHROPIC_API_KEY") {
257        // Return a command that will fail loudly at spawn time. We
258        // intentionally do NOT pass `--bare` (PROHIBITED) and we do NOT
259        // allow the API-key path at all. The second marker arg is the
260        // orientative hint surfaced via the diagnostic pipeline (ADR-0041).
261        let mut cmd = crate::spawn::failing_command();
262        cmd.env_clear();
263        cmd.env("PATH", "/nonexistent");
264        cmd.arg("--oauth-only-violation-anthropic-api-key-set");
265        cmd.arg("--oauth-only-resolution-use-anthropic-auth-token");
266        return Ok(cmd);
267    }
268
269    let mut cmd = Command::new(binary);
270
271    // v1.0.83 (ADR-0041): env whitelist delegated to
272    // `crate::spawn::env_whitelist::apply_env_whitelist`. The single source of
273    // truth lives in `src/spawn/env_whitelist.rs`; do NOT reintroduce a
274    // local whitelist here.
275    apply_env_whitelist(&mut cmd, crate::spawn::env_whitelist::is_strict_env_clear());
276    crate::spawn::apply_cwd_isolation(&mut cmd)?;
277
278    // Canonical OAuth-only command line (gaps.md:201-208). Every flag is
279    // mandatory; do NOT pass `--bare` (PROHIBITED, gaps.md:49).
280    //
281    // GAP-META-005 (v1.0.87, ADR-0045): `--mcp-config '{}'` inline JSON is
282    // rejected by Claude Code 2.1.177 — the flag expects a filepath.
283    // Substitute the inline literal for a tempfile path containing
284    // `{"mcpServers":{}}`. The pre-flight check rejects the inline form
285    // when `mcp_config_inline_json: Some("{}")` is passed.
286    let mcp_config_path = crate::spawn::preflight::write_empty_mcp_config_tempfile()?;
287
288    cmd.arg("-p")
289        .arg(prompt)
290        .arg("--strict-mcp-config")
291        .arg("--mcp-config")
292        .arg(mcp_config_path.as_os_str())
293        .arg("--dangerously-skip-permissions")
294        .arg("--settings")
295        .arg(r#"{"hooks":{}}"#)
296        .arg("--output-format")
297        .arg("json")
298        .arg("--json-schema")
299        .arg(json_schema)
300        .arg("--max-turns")
301        .arg(max_turns.to_string())
302        .arg("--no-session-persistence");
303
304    if let Some(m) = model {
305        cmd.arg("--model").arg(m);
306    }
307
308    cmd.stdin(Stdio::null())
309        .stdout(Stdio::piped())
310        .stderr(Stdio::piped());
311
312    // GAP-META-005 (v1.0.87, ADR-0045): pre-flight validation gate runs
313    // AFTER argv is fully built so binary, argv-size, walk-up of
314    // `.mcp.json`, and `CLAUDE_CONFIG_DIR` cleanliness are all checked.
315    // Pre-flight failure is a configuration error — panic with a clear
316    // message rather than spawn a misconfigured subprocess.
317    let argv_refs: Vec<std::ffi::OsString> = cmd.get_args().map(|s| s.to_os_string()).collect();
318    let preflight_args = crate::spawn::preflight::PreFlightArgs {
319        binary_path: binary,
320        argv: &argv_refs,
321        workspace_root: std::path::Path::new("."),
322        mcp_config_inline_json: None,
323        expected_output_bytes: 65_536,
324        spawner_name: "claude_runner",
325    };
326    if let Err(e) = crate::spawn::preflight::preflight_check(&preflight_args) {
327        // v1.0.88 (BUG-6 fix, ADR-0046): propagate the structured
328        // `PreFlightError` via the `From` impl in `errors.rs` so callers
329        // receive `AppError::PreFlightFailed` (exit 16) instead of a
330        // bare `std::process::exit(16)` that discards the variant name,
331        // tracing context, and PT-BR i18n.
332        return Err(crate::errors::AppError::from(e));
333    }
334
335    Ok(cmd)
336}
337
338/// Parses `claude -p --output-format json` output array.
339///
340/// G03: detects `terminal_reason: "max_turns"` and returns a specific error
341/// instead of a generic failure message.
342pub fn parse_claude_output(stdout: &str) -> Result<ClaudeResult, AppError> {
343    parse_claude_output_opts(stdout, false)
344}
345
346/// Like [`parse_claude_output`] but lets the caller decide whether a
347/// `terminal_reason: "max_turns"` result is fatal. `ingest` passes `true`
348/// (a partial extraction with a usable structured value is acceptable); `enrich`
349/// and the default keep `false` (max_turns means hooks are consuming turns — a
350/// misconfiguration to surface rather than silently accept).
351pub fn parse_claude_output_opts(
352    stdout: &str,
353    tolerate_max_turns: bool,
354) -> Result<ClaudeResult, AppError> {
355    let elements: Vec<ClaudeOutputElement> = serde_json::from_str(stdout).map_err(|e| {
356        AppError::Validation(crate::i18n::validation::failed_to_parse_claude_json_array(&e))
357    })?;
358
359    let is_oauth = elements
360        .iter()
361        .find(|e| e.r#type.as_deref() == Some("system") && e.subtype.as_deref() == Some("init"))
362        .and_then(|e| e.api_key_source.as_deref())
363        .map(|s| s == "none")
364        .unwrap_or(false);
365
366    let result_elem = elements
367        .iter()
368        .find(|e| e.r#type.as_deref() == Some("result"))
369        .ok_or_else(|| {
370            AppError::Validation(crate::i18n::validation::claude_output_missing_result())
371        })?;
372
373    // G03: detect max_turns exhaustion before checking is_error
374    if !tolerate_max_turns && result_elem.terminal_reason.as_deref() == Some("max_turns") {
375        tracing::warn!(
376            target: "claude_runner",
377            "claude -p hit max_turns limit — hooks may have consumed turns"
378        );
379        return Err(AppError::Validation(
380            "claude -p hit max_turns: hooks may be consuming turns; increase --max-turns or disable hooks".to_string(),
381        ));
382    }
383
384    if result_elem.is_error {
385        let err_msg = result_elem
386            .error
387            .as_deref()
388            .or(result_elem.result.as_deref())
389            .unwrap_or("unknown error");
390        if err_msg.contains("rate_limit") || err_msg.contains("overloaded") {
391            return Err(AppError::RateLimited {
392                detail: err_msg.to_string(),
393            });
394        }
395        if err_msg.contains("Not logged in") || err_msg.contains("authentication") {
396            tracing::warn!(
397                target: "claude_runner",
398                "Claude Code authentication failed. Re-authenticate interactively with: claude"
399            );
400        }
401        return Err(AppError::Validation(crate::i18n::validation::claude_extraction_failed(err_msg)));
402    }
403
404    let value = if let Some(v) = result_elem.structured_output.clone() {
405        v
406    } else if let Some(text) = &result_elem.result {
407        serde_json::from_str(text).map_err(|e| {
408            AppError::Validation(crate::i18n::validation::failed_to_parse_claude_result_field(
409                &e,
410            ))
411        })?
412    } else {
413        return Err(AppError::Validation(
414            "claude result missing structured_output and result field".into(),
415        ));
416    };
417
418    let cost = result_elem.total_cost_usd.unwrap_or(0.0);
419    Ok(ClaudeResult {
420        value,
421        cost_usd: cost,
422        is_oauth,
423    })
424}
425
426/// Calls `claude -p` with prompt and schema, waits with timeout, and parses output.
427///
428/// G03: parses stdout even on non-zero exit to detect `terminal_reason: "max_turns"`.
429/// G28-C (v1.0.69): the child is killed explicitly on timeout to avoid
430/// leaving a `claude -p` zombie with its MCP children behind.
431pub fn run_claude(
432    binary: &Path,
433    prompt: &str,
434    json_schema: &str,
435    input_text: &str,
436    model: Option<&str>,
437    timeout_secs: u64,
438    max_turns: u32,
439) -> Result<ClaudeResult, AppError> {
440    use wait_timeout::ChildExt;
441
442    let full_prompt = format!("{prompt}\n\n{input_text}");
443    let mut cmd = build_claude_command(binary, &full_prompt, json_schema, model, max_turns)?;
444
445    let mut child = spawn_with_memory_limit(&mut cmd).map_err(|e| {
446        AppError::Io(std::io::Error::new(
447            e.kind(),
448            format!("failed to spawn claude: {e}"),
449        ))
450    })?;
451
452    let start = std::time::Instant::now();
453    let timeout = std::time::Duration::from_secs(timeout_secs);
454    let status = child.wait_timeout(timeout).map_err(AppError::Io)?;
455
456    if status.is_none() {
457        // G28-C: timeout hit — send SIGTERM to the child so the MCP
458        // children it spawned (and their npm/node tree) are also
459        // reaped. SIGTERM gives the child a chance to clean up; the
460        // reaper sweep in main.rs is the last line of defence for
461        // anything that ignored it.
462        #[cfg(unix)]
463        unsafe {
464            libc::kill(child.id() as i32, libc::SIGTERM);
465        }
466        let _ = child.kill();
467        let _ = child.wait();
468    }
469
470    match status {
471        Some(exit_status) => {
472            tracing::debug!(
473                target: "process",
474                exit_code = ?exit_status.code(),
475                elapsed_ms = start.elapsed().as_millis() as u64,
476                "external process completed"
477            );
478
479            let mut stdout_buf = Vec::new();
480            let mut stderr_buf = Vec::new();
481            if let Some(mut out) = child.stdout.take() {
482                std::io::Read::read_to_end(&mut out, &mut stdout_buf).map_err(AppError::Io)?;
483            }
484            if let Some(mut err) = child.stderr.take() {
485                std::io::Read::read_to_end(&mut err, &mut stderr_buf).map_err(AppError::Io)?;
486            }
487
488            let stdout_str = String::from_utf8(stdout_buf)
489                .map_err(|_| AppError::Validation(crate::i18n::validation::claude_p_stdout_not_utf8()))?;
490
491            // G03: parse stdout even on failure to detect terminal_reason
492            if !exit_status.success() {
493                if let Ok(result) = parse_claude_output(&stdout_str) {
494                    return Ok(result);
495                }
496                let stderr_str = String::from_utf8_lossy(&stderr_buf);
497                if stderr_str.contains("auth") || stderr_str.contains("login") {
498                    tracing::warn!(
499                        target: "claude_runner",
500                        "Claude Code authentication may have failed. Re-authenticate with: claude"
501                    );
502                }
503                return Err(AppError::Validation(crate::i18n::validation::process_exited(
504                    "claude -p",
505                    exit_status.code(),
506                    stderr_str.trim(),
507                )));
508            }
509
510            parse_claude_output(&stdout_str)
511        }
512        None => {
513            tracing::warn!(target: "claude_runner", timeout_secs, "claude -p timed out, terminating");
514            terminate_gracefully(&mut child, 3);
515            Err(AppError::Validation(crate::i18n::validation::process_timed_out(
516                "claude -p",
517                timeout_secs,
518            )))
519        }
520    }
521}
522
523/// Terminates a child process gracefully: SIGTERM first, SIGKILL after grace period.
524#[cfg(unix)]
525pub fn terminate_gracefully(child: &mut std::process::Child, grace_secs: u64) {
526    use wait_timeout::ChildExt;
527    unsafe {
528        libc::kill(child.id() as i32, libc::SIGTERM);
529    }
530    match child.wait_timeout(std::time::Duration::from_secs(grace_secs)) {
531        Ok(Some(_)) => {}
532        _ => {
533            tracing::warn!(target: "process", pid = child.id(), "child ignored SIGTERM, sending SIGKILL");
534            let _ = child.kill();
535            let _ = child.wait();
536        }
537    }
538}
539
540/// Non-Unix fallback: kill immediately (Windows TerminateProcess).
541#[cfg(not(unix))]
542pub fn terminate_gracefully(child: &mut std::process::Child, _grace_secs: u64) {
543    let _ = child.kill();
544    let _ = child.wait();
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    #[test]
552    fn parse_output_detects_max_turns() {
553        let stdout = r#"[{"type":"system","subtype":"init","apiKeySource":"none"},{"type":"result","is_error":false,"terminal_reason":"max_turns","structured_output":{"name":"t"}}]"#;
554        let err = parse_claude_output(stdout).unwrap_err();
555        assert!(
556            format!("{err}").contains("max_turns"),
557            "must detect max_turns in output"
558        );
559    }
560
561    #[test]
562    fn parse_output_extracts_structured_value() {
563        let stdout = r#"[{"type":"system","subtype":"init","apiKeySource":"none"},{"type":"result","is_error":false,"structured_output":{"key":"val"},"total_cost_usd":0.01}]"#;
564        let result = parse_claude_output(stdout).unwrap();
565        assert_eq!(result.value["key"], "val");
566        assert!((result.cost_usd - 0.01).abs() < f64::EPSILON);
567        assert!(result.is_oauth);
568    }
569
570    #[test]
571    fn parse_output_detects_rate_limit() {
572        let stdout = r#"[{"type":"result","is_error":true,"error":"rate_limit exceeded"}]"#;
573        let err = parse_claude_output(stdout).unwrap_err();
574        assert!(
575            matches!(err, AppError::RateLimited { .. }),
576            "expected AppError::RateLimited, got: {err}"
577        );
578    }
579
580    /// OAuth-only conformance test (gaps.md:41-49, v1.0.69 mandate).
581    /// Verifies that `build_claude_command` always emits the canonical
582    /// flag set and NEVER emits `--bare` or any API-key path.
583    #[test]
584    #[serial_test::serial(env)]
585    fn build_command_oauth_only_mandatory_flags() {
586        // SAFETY: this is a unit test, no concurrent env mutation
587        unsafe {
588            std::env::remove_var("ANTHROPIC_API_KEY");
589            // GAP-META-005 (v1.0.87): clear CLAUDE_CONFIG_DIR so the new
590            // pre-flight check does not exit 16 on the test host.
591            std::env::remove_var("CLAUDE_CONFIG_DIR");
592        }
593        let cmd = build_claude_command(
594            std::path::Path::new("/usr/bin/false"),
595            "test prompt",
596            "{}",
597            Some("sonnet"),
598            4,
599        )
600        .expect("preflight gate accepts valid args");
601        let args: Vec<&str> = cmd.get_args().filter_map(|a| a.to_str()).collect();
602        // Mandatory OAuth-only flags from gaps.md lines 201-208
603        assert!(args.contains(&"-p"), "must have -p");
604        assert!(
605            args.contains(&"--strict-mcp-config"),
606            "must have --strict-mcp-config (gaps.md:206)"
607        );
608        assert!(
609            args.contains(&"--mcp-config"),
610            "must have --mcp-config (gaps.md:207)"
611        );
612        assert!(
613            args.contains(&"--dangerously-skip-permissions"),
614            "must have --dangerously-skip-permissions (gaps.md:208)"
615        );
616        assert!(
617            args.contains(&"--settings"),
618            "must have --settings (gaps.md:209)"
619        );
620        assert!(
621            args.contains(&"--output-format"),
622            "must have --output-format json (gaps.md:213)"
623        );
624        assert!(args.contains(&"--json-schema"), "must have --json-schema");
625        assert!(
626            args.contains(&"--max-turns"),
627            "must have --max-turns (gaps.md:212)"
628        );
629        assert!(
630            args.contains(&"--no-session-persistence"),
631            "must have --no-session-persistence"
632        );
633        assert!(
634            args.contains(&"--model"),
635            "must have --model when model is Some"
636        );
637        // PROHIBITED flags (gaps.md:49)
638        assert!(
639            !args.contains(&"--bare"),
640            "--bare is PROHIBITED (gaps.md:49)"
641        );
642    }
643
644    /// OAuth-only guard: when `ANTHROPIC_API_KEY` is in the environment,
645    /// `build_claude_command` MUST abort the spawn (return a `false`
646    /// command), NOT silently fall back to the API-key path.
647    #[test]
648    #[serial_test::serial(env)]
649    fn build_command_aborts_when_anthropic_api_key_set() {
650        // SAFETY: unit test
651        unsafe {
652            std::env::set_var("ANTHROPIC_API_KEY", "sk-test-violation");
653            // GAP-META-005 (v1.0.87): clear CLAUDE_CONFIG_DIR so the
654            // pre-flight check (when it does run on the abort path)
655            // does not exit 16 prematurely.
656            std::env::remove_var("CLAUDE_CONFIG_DIR");
657        }
658        let cmd = build_claude_command(
659            std::path::Path::new("/usr/bin/claude"),
660            "test prompt",
661            "{}",
662            Some("sonnet"),
663            4,
664        )
665        .expect("preflight gate accepts valid args");
666        let program = cmd.get_program().to_string_lossy().to_string();
667        let args: Vec<&str> = cmd.get_args().filter_map(|a| a.to_str()).collect();
668        assert_eq!(
669            program, "false",
670            "when ANTHROPIC_API_KEY is set, build_claude_command must abort"
671        );
672        assert!(
673            args.contains(&"--oauth-only-violation-anthropic-api-key-set"),
674            "aborted command must carry violation marker"
675        );
676        unsafe {
677            std::env::remove_var("ANTHROPIC_API_KEY");
678        }
679    }
680}