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 `SQLITE_GRAPHRAG_SUBPROCESS_MEMORY_LIMIT_MB`
46/// (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 = std::env::var("SQLITE_GRAPHRAG_SUBPROCESS_MEMORY_LIMIT_MB")
51        .ok()
52        .and_then(|v| v.parse().ok())
53        .unwrap_or(DEFAULT_SUBPROCESS_MEMORY_LIMIT_MB);
54    let max_bytes = max_mb * 1024 * 1024;
55    // SAFETY: pre_exec closure runs between fork() and exec() in the
56    // single-threaded child process — no other threads exist.
57    // libc::setsid and libc::setrlimit are async-signal-safe per POSIX.1-2008 §2.4.3.
58    // RLIMIT_AS limits virtual address space, not physical RSS.
59    // setsid failure with EPERM is tolerated (process already a session leader).
60    // On setrlimit failure, Err(last_os_error()) prevents exec.
61    unsafe {
62        cmd.pre_exec(move || {
63            let sid = libc::setsid();
64            if sid == -1 {
65                let err = std::io::Error::last_os_error();
66                if err.raw_os_error() != Some(libc::EPERM) {
67                    return Err(err);
68                }
69            }
70            let limit = libc::rlimit {
71                rlim_cur: max_bytes,
72                rlim_max: max_bytes,
73            };
74            if libc::setrlimit(libc::RLIMIT_AS, &limit) != 0 {
75                return Err(std::io::Error::last_os_error());
76            }
77            Ok(())
78        });
79    }
80    tracing::debug!(
81        target: "process",
82        program = ?cmd.get_program(),
83        args = ?cmd.get_args().collect::<Vec<_>>(),
84        "spawning external process"
85    );
86    cmd.spawn()
87}
88
89/// Spawns a command without memory limits (non-Linux fallback).
90/// On Unix (macOS, FreeBSD), applies setsid for process group isolation.
91#[cfg(not(target_os = "linux"))]
92pub fn spawn_with_memory_limit(cmd: &mut Command) -> std::io::Result<std::process::Child> {
93    #[cfg(unix)]
94    {
95        use std::os::unix::process::CommandExt;
96        // SAFETY: setsid() is async-signal-safe per POSIX.1-2008 §2.4.3.
97        // Creates independent session for cascade termination.
98        unsafe {
99            cmd.pre_exec(|| {
100                let sid = libc::setsid();
101                if sid == -1 {
102                    let err = std::io::Error::last_os_error();
103                    if err.raw_os_error() != Some(libc::EPERM) {
104                        return Err(err);
105                    }
106                }
107                Ok(())
108            });
109        }
110    }
111    tracing::debug!(
112        target: "process",
113        program = ?cmd.get_program(),
114        args = ?cmd.get_args().collect::<Vec<_>>(),
115        "spawning external process"
116    );
117    cmd.spawn()
118}
119
120/// Parsed output element from `claude -p --output-format json`.
121#[derive(Debug, serde::Deserialize)]
122pub struct ClaudeOutputElement {
123    pub r#type: Option<String>,
124    pub subtype: Option<String>,
125    #[serde(default)]
126    pub is_error: bool,
127    pub structured_output: Option<serde_json::Value>,
128    pub result: Option<String>,
129    pub total_cost_usd: Option<f64>,
130    pub error: Option<String>,
131    pub terminal_reason: Option<String>,
132    #[serde(rename = "apiKeySource")]
133    pub api_key_source: Option<String>,
134}
135
136/// Result of a successful Claude invocation.
137#[derive(Debug)]
138pub struct ClaudeResult {
139    pub value: serde_json::Value,
140    pub cost_usd: f64,
141    pub is_oauth: bool,
142}
143
144/// Validates that the Claude binary meets the minimum version requirement.
145pub fn validate_claude_version(binary: &Path) -> Result<String, AppError> {
146    let resolved = which::which(binary).map_err(|_| {
147        AppError::Validation(format!(
148            "executable '{}' not found in PATH; ensure it is installed and accessible",
149            binary.display()
150        ))
151    })?;
152    let output = Command::new(&resolved)
153        .arg("--version")
154        .stdin(Stdio::null())
155        .stdout(Stdio::piped())
156        .stderr(Stdio::piped())
157        .output()
158        .map_err(AppError::Io)?;
159
160    if !output.status.success() {
161        return Err(AppError::Validation(
162            "failed to run 'claude --version'".to_string(),
163        ));
164    }
165
166    let version_str = String::from_utf8(output.stdout)
167        .map_err(|_| AppError::Validation("claude --version output is not UTF-8".to_string()))?;
168    let version = version_str.trim().to_string();
169    let numeric = version.split([' ', '(']).next().unwrap_or("").trim();
170
171    fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
172        let parts: Vec<&str> = s.splitn(3, '.').collect();
173        if parts.len() < 2 {
174            return None;
175        }
176        let major = parts[0].parse::<u64>().ok()?;
177        let minor = parts[1].parse::<u64>().ok()?;
178        let patch = parts
179            .get(2)
180            .and_then(|p| p.parse::<u64>().ok())
181            .unwrap_or(0);
182        Some((major, minor, patch))
183    }
184
185    if let (Some(actual), Some(min)) = (parse_semver(numeric), parse_semver(MIN_CLAUDE_VERSION)) {
186        if actual < min {
187            return Err(AppError::Validation(format!(
188                "Claude Code version {numeric} is below minimum required {MIN_CLAUDE_VERSION}"
189            )));
190        }
191    }
192
193    Ok(version)
194}
195
196/// Builds a `Command` for `claude -p` with least-privilege environment.
197///
198/// G28-A (v1.0.68) + OAuth-only hardening (v1.0.69, mandated by gaps.md
199/// lines 41-49): the command ALWAYS uses the OAuth flow. The flag set
200/// is the canonical one documented in gaps.md Fix A:
201///
202/// ```text
203/// claude -p "TAREFA" \
204///   --strict-mcp-config \
205///   --mcp-config '{}' \
206///   --dangerously-skip-permissions \
207///   --settings '{"hooks":{}}' \
208///   --model <X> \
209///   --max-turns <N> \
210///   --output-format json \
211///   --no-session-persistence
212/// ```
213///
214/// The combination cuts the typical 8-10 MCP process tree to zero and
215/// disables user hooks. The reaper sweep at startup (see `reaper::scan_and_kill_orphans`)
216/// is the last line of defence for any process that ignored the flags.
217///
218/// **`--bare` is FORBIDDEN** (gaps.md:49 and operator policy):
219/// `--bare` cuts MCPs but disables OAuth and demands `ANTHROPIC_API_KEY`,
220/// which is PROHIBITED in this project. We also ABORT the spawn if
221/// `ANTHROPIC_API_KEY` is set in the environment, because that is the
222/// gateway to the prohibited API-key path.
223///
224/// GitHub issue [anthropics/claude-code#10787] documents that earlier
225/// Claude Code CLI builds sometimes ignored `--strict-mcp-config` and
226/// fell back to `~/.mcp.json`. We still pass the flags as defence-in-depth
227/// and ALSO honour `SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR` so users
228/// who need belt-and-suspenders isolation can point Claude at an empty
229/// config directory (no MCP, no hooks, no settings).
230///
231/// [anthropics/claude-code#10787]: https://github.com/anthropics/claude-code/issues/10787
232pub fn build_claude_command(
233    binary: &Path,
234    prompt: &str,
235    json_schema: &str,
236    model: Option<&str>,
237    max_turns: u32,
238) -> Command {
239    // OAuth-only guard (gaps.md:47, ADR-0011). If `ANTHROPIC_API_KEY` is
240    // set in the environment we MUST abort — that is the API-key path
241    // which is explicitly PROHIBITED. Use the OAuth flow exclusively.
242    if let Ok(_key) = std::env::var("ANTHROPIC_API_KEY") {
243        // Return a command that will fail loudly at spawn time. We
244        // intentionally do NOT pass `--bare` (PROHIBITED) and we do NOT
245        // allow the API-key path at all. The second marker arg is the
246        // orientative hint surfaced via the diagnostic pipeline (ADR-0041).
247        let mut cmd = Command::new("false");
248        cmd.env_clear();
249        cmd.env("PATH", "/nonexistent");
250        cmd.arg("--oauth-only-violation-anthropic-api-key-set");
251        cmd.arg("--oauth-only-resolution-use-anthropic-auth-token");
252        return cmd;
253    }
254
255    let mut cmd = Command::new(binary);
256
257    // v1.0.83 (ADR-0041): env whitelist delegated to
258    // `crate::spawn::env_whitelist::apply_env_whitelist`. The single source of
259    // truth lives in `src/spawn/env_whitelist.rs`; do NOT reintroduce a
260    // local whitelist here.
261    apply_env_whitelist(&mut cmd, crate::spawn::env_whitelist::is_strict_env_clear());
262
263    // G28-A: if the user has pointed us at an empty config dir, force Claude
264    // Code to use it (which suppresses user-scoped MCP servers and hooks).
265    if let Ok(empty_dir) = std::env::var("SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR") {
266        if std::path::Path::new(&empty_dir).is_dir() {
267            cmd.env("CLAUDE_CONFIG_DIR", &empty_dir);
268            tracing::debug!(
269                target: "claude_runner",
270                "isolating claude subprocess to CLAUDE_CONFIG_DIR={}",
271                empty_dir
272            );
273        } else {
274            tracing::warn!(
275                target: "claude_runner",
276                path = %empty_dir,
277                "SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR is set but path is not a directory; \
278                 ignoring.  MCP isolation will NOT be applied."
279            );
280        }
281    }
282
283    // Canonical OAuth-only command line (gaps.md:201-208). Every flag is
284    // mandatory; do NOT pass `--bare` (PROHIBITED, gaps.md:49).
285    cmd.arg("-p")
286        .arg(prompt)
287        .arg("--strict-mcp-config")
288        .arg("--mcp-config")
289        .arg("{}")
290        .arg("--dangerously-skip-permissions")
291        .arg("--settings")
292        .arg(r#"{"hooks":{}}"#)
293        .arg("--output-format")
294        .arg("json")
295        .arg("--json-schema")
296        .arg(json_schema)
297        .arg("--max-turns")
298        .arg(max_turns.to_string())
299        .arg("--no-session-persistence");
300
301    if let Some(m) = model {
302        cmd.arg("--model").arg(m);
303    }
304
305    cmd.stdin(Stdio::null())
306        .stdout(Stdio::piped())
307        .stderr(Stdio::piped());
308
309    cmd
310}
311
312/// Parses `claude -p --output-format json` output array.
313///
314/// G03: detects `terminal_reason: "max_turns"` and returns a specific error
315/// instead of a generic failure message.
316pub fn parse_claude_output(stdout: &str) -> Result<ClaudeResult, AppError> {
317    let elements: Vec<ClaudeOutputElement> = serde_json::from_str(stdout).map_err(|e| {
318        AppError::Validation(format!("failed to parse claude output as JSON array: {e}"))
319    })?;
320
321    let is_oauth = elements
322        .iter()
323        .find(|e| e.r#type.as_deref() == Some("system") && e.subtype.as_deref() == Some("init"))
324        .and_then(|e| e.api_key_source.as_deref())
325        .map(|s| s == "none")
326        .unwrap_or(false);
327
328    let result_elem = elements
329        .iter()
330        .find(|e| e.r#type.as_deref() == Some("result"))
331        .ok_or_else(|| {
332            AppError::Validation("claude output missing 'result' element".to_string())
333        })?;
334
335    // G03: detect max_turns exhaustion before checking is_error
336    if result_elem.terminal_reason.as_deref() == Some("max_turns") {
337        tracing::warn!(
338            target: "claude_runner",
339            "claude -p hit max_turns limit — hooks may have consumed turns"
340        );
341        return Err(AppError::Validation(
342            "claude -p hit max_turns: hooks may be consuming turns; increase --max-turns or disable hooks".to_string(),
343        ));
344    }
345
346    if result_elem.is_error {
347        let err_msg = result_elem
348            .error
349            .as_deref()
350            .or(result_elem.result.as_deref())
351            .unwrap_or("unknown error");
352        if err_msg.contains("rate_limit") || err_msg.contains("overloaded") {
353            return Err(AppError::RateLimited {
354                detail: err_msg.to_string(),
355            });
356        }
357        if err_msg.contains("Not logged in") || err_msg.contains("authentication") {
358            tracing::warn!(
359                target: "claude_runner",
360                "Claude Code authentication failed. Re-authenticate interactively with: claude"
361            );
362        }
363        return Err(AppError::Validation(format!(
364            "claude extraction failed: {err_msg}"
365        )));
366    }
367
368    let value = if let Some(v) = result_elem.structured_output.clone() {
369        v
370    } else if let Some(text) = &result_elem.result {
371        serde_json::from_str(text).map_err(|e| {
372            AppError::Validation(format!("failed to parse claude result field as JSON: {e}"))
373        })?
374    } else {
375        return Err(AppError::Validation(
376            "claude result missing structured_output and result field".into(),
377        ));
378    };
379
380    let cost = result_elem.total_cost_usd.unwrap_or(0.0);
381    Ok(ClaudeResult {
382        value,
383        cost_usd: cost,
384        is_oauth,
385    })
386}
387
388/// Calls `claude -p` with prompt and schema, waits with timeout, and parses output.
389///
390/// G03: parses stdout even on non-zero exit to detect `terminal_reason: "max_turns"`.
391/// G28-C (v1.0.69): the child is killed explicitly on timeout to avoid
392/// leaving a `claude -p` zombie with its MCP children behind.
393pub fn run_claude(
394    binary: &Path,
395    prompt: &str,
396    json_schema: &str,
397    input_text: &str,
398    model: Option<&str>,
399    timeout_secs: u64,
400    max_turns: u32,
401) -> Result<ClaudeResult, AppError> {
402    use wait_timeout::ChildExt;
403
404    let full_prompt = format!("{prompt}\n\n{input_text}");
405    let mut cmd = build_claude_command(binary, &full_prompt, json_schema, model, max_turns);
406
407    let mut child = spawn_with_memory_limit(&mut cmd).map_err(|e| {
408        AppError::Io(std::io::Error::new(
409            e.kind(),
410            format!("failed to spawn claude: {e}"),
411        ))
412    })?;
413
414    let start = std::time::Instant::now();
415    let timeout = std::time::Duration::from_secs(timeout_secs);
416    let status = child.wait_timeout(timeout).map_err(AppError::Io)?;
417
418    if status.is_none() {
419        // G28-C: timeout hit — send SIGTERM to the child so the MCP
420        // children it spawned (and their npm/node tree) are also
421        // reaped. SIGTERM gives the child a chance to clean up; the
422        // reaper sweep in main.rs is the last line of defence for
423        // anything that ignored it.
424        #[cfg(unix)]
425        unsafe {
426            libc::kill(child.id() as i32, libc::SIGTERM);
427        }
428        let _ = child.kill();
429        let _ = child.wait();
430    }
431
432    match status {
433        Some(exit_status) => {
434            tracing::debug!(
435                target: "process",
436                exit_code = ?exit_status.code(),
437                elapsed_ms = start.elapsed().as_millis() as u64,
438                "external process completed"
439            );
440
441            let mut stdout_buf = Vec::new();
442            let mut stderr_buf = Vec::new();
443            if let Some(mut out) = child.stdout.take() {
444                std::io::Read::read_to_end(&mut out, &mut stdout_buf).map_err(AppError::Io)?;
445            }
446            if let Some(mut err) = child.stderr.take() {
447                std::io::Read::read_to_end(&mut err, &mut stderr_buf).map_err(AppError::Io)?;
448            }
449
450            let stdout_str = String::from_utf8(stdout_buf)
451                .map_err(|_| AppError::Validation("claude -p stdout is not valid UTF-8".into()))?;
452
453            // G03: parse stdout even on failure to detect terminal_reason
454            if !exit_status.success() {
455                if let Ok(result) = parse_claude_output(&stdout_str) {
456                    return Ok(result);
457                }
458                let stderr_str = String::from_utf8_lossy(&stderr_buf);
459                if stderr_str.contains("auth") || stderr_str.contains("login") {
460                    tracing::warn!(
461                        target: "claude_runner",
462                        "Claude Code authentication may have failed. Re-authenticate with: claude"
463                    );
464                }
465                return Err(AppError::Validation(format!(
466                    "claude -p exited with code {:?}: {}",
467                    exit_status.code(),
468                    stderr_str.trim()
469                )));
470            }
471
472            parse_claude_output(&stdout_str)
473        }
474        None => {
475            tracing::warn!(target: "claude_runner", timeout_secs, "claude -p timed out, terminating");
476            terminate_gracefully(&mut child, 3);
477            Err(AppError::Validation(format!(
478                "claude -p timed out after {timeout_secs} seconds"
479            )))
480        }
481    }
482}
483
484/// Terminates a child process gracefully: SIGTERM first, SIGKILL after grace period.
485#[cfg(unix)]
486pub fn terminate_gracefully(child: &mut std::process::Child, grace_secs: u64) {
487    use wait_timeout::ChildExt;
488    unsafe {
489        libc::kill(child.id() as i32, libc::SIGTERM);
490    }
491    match child.wait_timeout(std::time::Duration::from_secs(grace_secs)) {
492        Ok(Some(_)) => {}
493        _ => {
494            tracing::warn!(target: "process", pid = child.id(), "child ignored SIGTERM, sending SIGKILL");
495            let _ = child.kill();
496            let _ = child.wait();
497        }
498    }
499}
500
501/// Non-Unix fallback: kill immediately (Windows TerminateProcess).
502#[cfg(not(unix))]
503pub fn terminate_gracefully(child: &mut std::process::Child, _grace_secs: u64) {
504    let _ = child.kill();
505    let _ = child.wait();
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn parse_output_detects_max_turns() {
514        let stdout = r#"[{"type":"system","subtype":"init","apiKeySource":"none"},{"type":"result","is_error":false,"terminal_reason":"max_turns","structured_output":{"name":"t"}}]"#;
515        let err = parse_claude_output(stdout).unwrap_err();
516        assert!(
517            format!("{err}").contains("max_turns"),
518            "must detect max_turns in output"
519        );
520    }
521
522    #[test]
523    fn parse_output_extracts_structured_value() {
524        let stdout = r#"[{"type":"system","subtype":"init","apiKeySource":"none"},{"type":"result","is_error":false,"structured_output":{"key":"val"},"total_cost_usd":0.01}]"#;
525        let result = parse_claude_output(stdout).unwrap();
526        assert_eq!(result.value["key"], "val");
527        assert!((result.cost_usd - 0.01).abs() < f64::EPSILON);
528        assert!(result.is_oauth);
529    }
530
531    #[test]
532    fn parse_output_detects_rate_limit() {
533        let stdout = r#"[{"type":"result","is_error":true,"error":"rate_limit exceeded"}]"#;
534        let err = parse_claude_output(stdout).unwrap_err();
535        assert!(
536            matches!(err, AppError::RateLimited { .. }),
537            "expected AppError::RateLimited, got: {err}"
538        );
539    }
540
541    /// OAuth-only conformance test (gaps.md:41-49, v1.0.69 mandate).
542    /// Verifies that `build_claude_command` always emits the canonical
543    /// flag set and NEVER emits `--bare` or any API-key path.
544    #[test]
545    #[serial_test::serial(env)]
546    fn build_command_oauth_only_mandatory_flags() {
547        // SAFETY: this is a unit test, no concurrent env mutation
548        unsafe {
549            std::env::remove_var("ANTHROPIC_API_KEY");
550        }
551        let cmd = build_claude_command(
552            std::path::Path::new("/usr/bin/false"),
553            "test prompt",
554            "{}",
555            Some("sonnet"),
556            4,
557        );
558        let args: Vec<&str> = cmd.get_args().filter_map(|a| a.to_str()).collect();
559        // Mandatory OAuth-only flags from gaps.md lines 201-208
560        assert!(args.contains(&"-p"), "must have -p");
561        assert!(
562            args.contains(&"--strict-mcp-config"),
563            "must have --strict-mcp-config (gaps.md:206)"
564        );
565        assert!(
566            args.contains(&"--mcp-config"),
567            "must have --mcp-config (gaps.md:207)"
568        );
569        assert!(
570            args.contains(&"--dangerously-skip-permissions"),
571            "must have --dangerously-skip-permissions (gaps.md:208)"
572        );
573        assert!(
574            args.contains(&"--settings"),
575            "must have --settings (gaps.md:209)"
576        );
577        assert!(
578            args.contains(&"--output-format"),
579            "must have --output-format json (gaps.md:213)"
580        );
581        assert!(args.contains(&"--json-schema"), "must have --json-schema");
582        assert!(
583            args.contains(&"--max-turns"),
584            "must have --max-turns (gaps.md:212)"
585        );
586        assert!(
587            args.contains(&"--no-session-persistence"),
588            "must have --no-session-persistence"
589        );
590        assert!(
591            args.contains(&"--model"),
592            "must have --model when model is Some"
593        );
594        // PROHIBITED flags (gaps.md:49)
595        assert!(
596            !args.contains(&"--bare"),
597            "--bare is PROHIBITED (gaps.md:49)"
598        );
599    }
600
601    /// OAuth-only guard: when `ANTHROPIC_API_KEY` is in the environment,
602    /// `build_claude_command` MUST abort the spawn (return a `false`
603    /// command), NOT silently fall back to the API-key path.
604    #[test]
605    #[serial_test::serial(env)]
606    fn build_command_aborts_when_anthropic_api_key_set() {
607        // SAFETY: unit test
608        unsafe {
609            std::env::set_var("ANTHROPIC_API_KEY", "sk-test-violation");
610        }
611        let cmd = build_claude_command(
612            std::path::Path::new("/usr/bin/claude"),
613            "test prompt",
614            "{}",
615            Some("sonnet"),
616            4,
617        );
618        let program = cmd.get_program().to_string_lossy().to_string();
619        let args: Vec<&str> = cmd.get_args().filter_map(|a| a.to_str()).collect();
620        assert_eq!(
621            program, "false",
622            "when ANTHROPIC_API_KEY is set, build_claude_command must abort"
623        );
624        assert!(
625            args.contains(&"--oauth-only-violation-anthropic-api-key-set"),
626            "aborted command must carry violation marker"
627        );
628        unsafe {
629            std::env::remove_var("ANTHROPIC_API_KEY");
630        }
631    }
632}