Skip to main content

sqlite_graphrag/spawn/
preflight.rs

1//! Pre-flight validation layer for LLM subprocess spawners (v1.0.87, ADR-0045).
2//!
3//! GAP-META-005: closes the architectural gap between `build_argv` and
4//! `cmd.spawn()` in the four real subprocess spawn sites
5//! (`claude_runner.rs:255`, `codex_spawn.rs:273`, `ingest_claude.rs:297`,
6//! `extract/llm_embedding.rs:671`). Before this module, the 4-stage pipeline
7//! was:
8//!
9//! ```text
10//! 1. build_argv(mode, prompt, body)  -> Vec<OsString>
11//! 2. apply_env_whitelist(cmd)         -> void (helper v1.0.83, ADR-0041)
12//! 3. Command::spawn()                 -> io::Result<Child>
13//! 4. child.wait_with_output()         -> io::Result<Output>
14//! ```
15//!
16//! Stage 3 discovered failures AFTER the kernel fork and AFTER Claude Code
17//! started executing, wasting tokens, locking job-singleton, and producing
18//! opaque diagnostics. This module inserts a gate between stages 2 and 3
19//! that catches the 5 bug-symptom classes documented in `gaps.md` BEFORE
20//! the fork:
21//!
22//! - Bug 1 — `ingest --extraction-backend llm` extracts 0 entities silently
23//! - Bug 2 — `--mcp-config '{}'` rejected by Claude Code 2.1.177
24//! - Bug 3 — argv > ARG_MAX post-fork E2BIG
25//! - Bug 4 — output parser truncates at 65536 chars
26//! - Bug 5 — `.mcp.json` walk-up fails Zod validation
27//!
28//! Pattern: sibling of `env_whitelist.rs` (v1.0.83, ADR-0041). Same
29//! design philosophy (helper consumed by all 4 spawn sites, no
30//! caller-local reimplementation, opt-out via env var for emergencies).
31//!
32//! ## Enforced invariant
33//!
34//! `sqlite-graphrag` runs Claude Code and the Codex CLI **mandatorily
35//! headless without MCP**. Pre-flight rejects argv that carries explicit
36//! MCP servers before the fork, closing the path where
37//! `~/.claude/settings.json` or an inherited `.mcp.json` walk-up could
38//! reintroduce plugins against the policy.
39
40use std::ffi::OsString;
41use std::path::{Path, PathBuf};
42use thiserror::Error;
43
44/// Safety margin subtracted from `ARG_MAX` to leave room for env vars
45/// and the binary path itself (those flow through a different syscall).
46const ARG_MAX_SAFETY_MARGIN_BYTES: usize = 4_096;
47
48/// Default fallback when `libc::sysconf(_SC_ARG_MAX)` returns -1 (rare
49/// but documented on hardened kernels). Matches the Windows `CreateProcess`
50/// cap of 32767 chars per command line. Visible on both unix and non-unix
51/// so `arg_max_bytes()` can reference it from either branch.
52const DEFAULT_ARG_MAX_BYTES: usize = 32_768;
53
54/// Default max output bytes that downstream JSON parsers tolerate
55/// without truncation. Matches the previous 64 KiB parser cap that
56/// `serde_json::from_str` silently truncated in v1.0.86.
57const DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES: usize = 65_536;
58
59/// Walk-up depth cap for `.mcp.json` traversal. Prevents pathological
60/// `..` climbs on hosts with deeply nested CWDs.
61const WALKUP_MAX_DEPTH: usize = 16;
62
63/// Skip pre-flight checks entirely. Emergency escape hatch — strongly
64/// discouraged. Operators accept the 5-bug-class risk by setting this.
65pub fn is_skipped() -> bool {
66    crate::config::get_setting("spawn.skip_preflight")
67        .ok()
68        .flatten()
69        .is_some_and(|v| {
70            matches!(
71                v.trim().to_ascii_lowercase().as_str(),
72                "1" | "true" | "yes"
73            )
74        })
75}
76
77/// Arguments for the pre-flight validation gate.
78///
79/// Each caller populates exactly what the gate needs to validate without
80/// relying on global env vars. The gate never mutates the argv in place —
81/// it only reads and reports. Callers act on `PreFlightError` to substitute
82/// alternatives (e.g. swap inline `--mcp-config '{}'` for a tempfile path).
83#[derive(Debug)]
84pub struct PreFlightArgs<'a> {
85    /// Resolved path to the binary that will be spawned.
86    pub binary_path: &'a Path,
87    /// argv after `build_argv` finished. Includes binary path as argv\[0\].
88    pub argv: &'a [OsString],
89    /// CWD-style anchor for walk-up detection of `.mcp.json`.
90    pub workspace_root: &'a Path,
91    /// If the spawner constructs `--mcp-config '{...}'` literally, the
92    /// gate returns `McpConfigInlineJsonRejected` with a suggested
93    /// tempfile path the caller can substitute.
94    pub mcp_config_inline_json: Option<&'a str>,
95    /// Caller's estimate of the maximum output payload size in bytes.
96    /// Triggers `OutputBufferTooSmall` when above the documented parser cap.
97    pub expected_output_bytes: usize,
98    /// Stable label emitted in telemetry. One of `"claude_runner"`,
99    /// `"codex_spawn"`, `"ingest_claude"`, `"ingest_codex"`,
100    /// `"llm_embedding"`.
101    pub spawner_name: &'static str,
102}
103
104/// Structured errors from the pre-flight gate. Each variant carries the
105/// data needed for an operator to diagnose without re-running.
106///
107/// `thiserror` produces the `Display` impl that `AppError::PreFlightFailed`
108/// captures into the `detail` field for i18n.
109#[derive(Debug, Error)]
110pub enum PreFlightError {
111    /// Binary at `path` does not exist on the filesystem.
112    #[error("binary not found: {path}")]
113    BinaryNotFound {
114        /// Filesystem path involved.
115        path: PathBuf,
116    },
117
118    /// Total bytes of argv (binary + args + separators) exceed
119    /// `ARG_MAX - 4096`. Spawn would fail with `E2BIG` post-fork.
120    #[error("argv exceeds ARG_MAX: total_bytes={total_bytes}, arg_max={arg_max}, safety_margin_bytes={ARG_MAX_SAFETY_MARGIN_BYTES}")]
121    ArgvExceedsArgMax {
122        /// Total argv size in bytes.
123        total_bytes: usize,
124        /// Platform ARG_MAX limit in bytes.
125        arg_max: usize,
126    },
127
128    /// `--mcp-config '{...}'` was passed literally as the inline JSON.
129    /// Claude Code 2.1.177+ expects a filepath. Caller should use the
130    /// `suggested_tempfile` (already written with empty `mcpServers` map).
131    #[error("--mcp-config expects filepath, got inline JSON '{0}'; Claude Code 2.1.177 rejects this form; substitute suggested tempfile")]
132    McpConfigInlineJsonRejected(String),
133
134    /// `--mcp-config <PATH>` was passed but the path does not exist.
135    #[error("--mcp-config path missing: {path}")]
136    McpConfigPathMissing {
137        /// Filesystem path involved.
138        path: PathBuf,
139    },
140
141    /// `--mcp-config <PATH>` was passed but the file is not valid JSON.
142    #[error("--mcp-config path invalid JSON at {path}: {error}")]
143    McpConfigPathInvalidJson {
144        /// Filesystem path involved.
145        path: PathBuf,
146        /// Parse or validation error text.
147        error: String,
148    },
149
150    /// `.mcp.json` walk-up found an invalid file at `path`. Override
151    /// `CLAUDE_CONFIG_DIR` to an empty directory to suppress walk-up.
152    #[error(".mcp.json walk-up found invalid file at {path}: {error}; set CLAUDE_CONFIG_DIR to an empty directory or move the workspace to a parent without .mcp.json")]
153    WalkUpMcpJsonInvalid {
154        /// Filesystem path involved.
155        path: PathBuf,
156        /// Parse or validation error text.
157        error: String,
158    },
159
160    /// Caller's expected output exceeds the documented JSON parser cap.
161    /// The downstream parser truncates silently above this size.
162    #[error("output buffer too small: expected={expected} bytes, configured_limit={configured} bytes; chunk the request or increase the buffer cap")]
163    OutputBufferTooSmall {
164        /// Expected buffer size.
165        expected: usize,
166        /// Configured buffer size.
167        configured: usize,
168    },
169
170    /// `CLAUDE_CONFIG_DIR` is set and `settings.json` declares active
171    /// `mcpServers`. Claude Code would load them and defeat
172    /// `--strict-mcp-config --mcp-config <empty>`. Hooks are NOT
173    /// flagged here because the spawners pass
174    /// `--settings '{"hooks":{}}'` which overrides the user-level
175    /// hooks at the CLI invocation boundary; MCP servers are NOT
176    /// overridden by any flag we pass, so they are the only class of
177    /// `settings.json` entry that can leak into the subprocess.
178    #[error("CLAUDE_CONFIG_DIR={path} contains settings.json with active MCP servers ({reason}); unset the env var or remove the offending entries")]
179    ClaudeConfigDirNotEmpty {
180        /// Filesystem path involved.
181        path: PathBuf,
182        /// Reason the check failed.
183        reason: &'static str,
184    },
185}
186
187/// Returns `Ok(())` when all checks pass, or the first failing variant.
188///
189/// Short-circuits on first failure to give operators a single actionable
190/// diagnostic. When XDG `spawn.skip_preflight` is truthy (`config set
191/// spawn.skip_preflight 1`), returns `Ok(())` unconditionally after
192/// logging a warning (emergency escape hatch).
193pub fn preflight_check(args: &PreFlightArgs) -> Result<(), PreFlightError> {
194    if is_skipped() {
195        tracing::warn!(
196            target: "preflight",
197            event = "preflight_skipped",
198            spawner = args.spawner_name,
199            "spawn.skip_preflight is set — pre-flight checks bypassed; the 5-bug-class risk is accepted"
200        );
201        return Ok(());
202    }
203
204    // Order matters: cheap in-memory checks first, I/O-bound checks last
205    // so a binary-missing operator sees the actionable error first.
206    let argv_total = compute_argv_bytes(args.argv);
207
208    check_argv_size(argv_total)?;
209    check_binary_exists(args.binary_path)?;
210    check_output_buffer(args.expected_output_bytes)?;
211    check_mcp_config_inline(args.mcp_config_inline_json)?;
212    check_mcp_config_path(args.argv)?;
213    check_walkup_mcp_json(args.workspace_root)?;
214    check_claude_config_dir()?;
215
216    tracing::info!(
217        target: "preflight",
218        event = "preflight_passed",
219        spawner = args.spawner_name,
220        argv_bytes = argv_total,
221        workspace_root = %args.workspace_root.display(),
222        "pre-flight validation passed"
223    );
224    Ok(())
225}
226
227/// Writes an empty MCP config tempfile with `{"mcpServers":{}}` and
228/// returns the path. Callers should `cmd.arg(path.as_os_str())` to
229/// substitute for the inline `'{}'` literal rejected by Claude Code 2.1.177.
230///
231/// Tempfile lives in the OS temp dir with a `graphrag-mcp-` prefix.
232/// Caller is responsible for keeping the path alive until the spawned
233/// process terminates; `tempfile::NamedTempFile` cleans up on Drop.
234pub fn write_empty_mcp_config_tempfile() -> Result<PathBuf, std::io::Error> {
235    use std::io::Write;
236    let mut tmp = tempfile::Builder::new()
237        .prefix("graphrag-mcp-")
238        .suffix(".json")
239        .tempfile()?;
240    tmp.write_all(br#"{"mcpServers":{}}"#)?;
241    tmp.flush()?;
242    // Persist (do not auto-delete) so the spawned claude can read it
243    // after this function returns. The caller spawns and waits, then
244    // the tempfile is dropped and cleaned.
245    let (_, path) = tmp.keep()?;
246    Ok(path)
247}
248
249// ---------------------------------------------------------------------------
250// Individual guards
251// ---------------------------------------------------------------------------
252
253/// Sums byte sizes of each argv element plus 1 byte for the NUL separator
254/// in the kernel's `execve` argument buffer layout.
255fn compute_argv_bytes(argv: &[OsString]) -> usize {
256    argv.iter().map(|s| s.as_os_str().len() + 1).sum()
257}
258
259fn arg_max_bytes() -> usize {
260    #[cfg(unix)]
261    {
262        // SAFETY: `sysconf(_SC_ARG_MAX)` is async-signal-safe per POSIX.1-2008
263        // §2.4.3. It returns -1 on error (which we treat as "use the safe
264        // fallback"); a positive value is the kernel's ARG_MAX in bytes.
265        let n = unsafe { libc::sysconf(libc::_SC_ARG_MAX) };
266        if n > 0 {
267            n as usize
268        } else {
269            DEFAULT_ARG_MAX_BYTES
270        }
271    }
272    #[cfg(not(unix))]
273    {
274        DEFAULT_ARG_MAX_BYTES
275    }
276}
277
278fn check_argv_size(argv_total: usize) -> Result<(), PreFlightError> {
279    let max = arg_max_bytes();
280    if argv_total + ARG_MAX_SAFETY_MARGIN_BYTES > max {
281        return Err(PreFlightError::ArgvExceedsArgMax {
282            total_bytes: argv_total,
283            arg_max: max,
284        });
285    }
286    Ok(())
287}
288
289fn check_binary_exists(binary_path: &Path) -> Result<(), PreFlightError> {
290    if binary_path.exists() {
291        Ok(())
292    } else {
293        Err(PreFlightError::BinaryNotFound {
294            path: binary_path.to_path_buf(),
295        })
296    }
297}
298
299fn check_output_buffer(expected: usize) -> Result<(), PreFlightError> {
300    if expected > DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES {
301        Err(PreFlightError::OutputBufferTooSmall {
302            expected,
303            configured: DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES,
304        })
305    } else {
306        Ok(())
307    }
308}
309
310fn check_mcp_config_inline(inline: Option<&str>) -> Result<(), PreFlightError> {
311    if let Some(s) = inline {
312        // Any literal JSON starting with `{` and `}` is treated as
313        // inline. Caller must convert to filepath.
314        let trimmed = s.trim();
315        if trimmed.starts_with('{') && trimmed.ends_with('}') {
316            return Err(PreFlightError::McpConfigInlineJsonRejected(s.to_string()));
317        }
318    }
319    Ok(())
320}
321
322fn check_mcp_config_path(argv: &[OsString]) -> Result<(), PreFlightError> {
323    let mut iter = argv.iter();
324    while let Some(arg) = iter.next() {
325        // BUG-5 fix (v1.0.88): accept the `--mcp-config=PATH` form
326        // (single argv slot) alongside the GNU `--mcp-config <PATH>`
327        // form. Without this, callers using clap's `--flag value`
328        // collapsing (or hand-rolled commands) bypass the guard.
329        let path = if arg == "--mcp-config" {
330            match iter.next() {
331                Some(value) => PathBuf::from(value),
332                None => continue,
333            }
334        } else if let Some(stripped) = arg.to_str().and_then(|s| s.strip_prefix("--mcp-config=")) {
335            PathBuf::from(stripped)
336        } else {
337            continue;
338        };
339        validate_mcp_config_path(&path)?;
340    }
341    Ok(())
342}
343
344fn validate_mcp_config_path(path: &Path) -> Result<(), PreFlightError> {
345    if !path.exists() {
346        return Err(PreFlightError::McpConfigPathMissing {
347            path: path.to_path_buf(),
348        });
349    }
350    let contents =
351        std::fs::read_to_string(path).map_err(|e| PreFlightError::McpConfigPathInvalidJson {
352            path: path.to_path_buf(),
353            error: e.to_string(),
354        })?;
355    if let Err(e) = serde_json::from_str::<serde_json::Value>(&contents) {
356        return Err(PreFlightError::McpConfigPathInvalidJson {
357            path: path.to_path_buf(),
358            error: e.to_string(),
359        });
360    }
361    Ok(())
362}
363
364fn check_walkup_mcp_json(workspace_root: &Path) -> Result<(), PreFlightError> {
365    let mut current = workspace_root.to_path_buf();
366    for _ in 0..WALKUP_MAX_DEPTH {
367        let candidate = current.join(".mcp.json");
368        if candidate.exists() {
369            let contents = std::fs::read_to_string(&candidate).map_err(|e| {
370                PreFlightError::WalkUpMcpJsonInvalid {
371                    path: candidate.clone(),
372                    error: e.to_string(),
373                }
374            })?;
375            // BUG-9 fix (v1.0.88): syntactic JSON validity is necessary
376            // but NOT sufficient — a valid `.mcp.json` can still declare
377            // MCP servers under `mcpServers`. Reject when the file is
378            // syntactically valid AND declares a non-empty `mcpServers`
379            // object. Keep the existing syntactic check for legacy
380            // callers that hand-roll untyped JSON.
381            let parsed: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
382                PreFlightError::WalkUpMcpJsonInvalid {
383                    path: candidate.clone(),
384                    error: e.to_string(),
385                }
386            })?;
387            let has_active_mcps = parsed
388                .get("mcpServers")
389                .and_then(|v| v.as_object())
390                .map(|o| !o.is_empty())
391                .unwrap_or(false);
392            if has_active_mcps {
393                return Err(PreFlightError::WalkUpMcpJsonInvalid {
394                    path: candidate,
395                    error: "mcpServers declares active entries; set CLAUDE_CONFIG_DIR to an empty directory or remove the file".to_string(),
396                });
397            }
398            return Ok(());
399        }
400        match current.parent() {
401            Some(p) => current = p.to_path_buf(),
402            None => break,
403        }
404    }
405    Ok(())
406}
407
408fn check_claude_config_dir() -> Result<(), PreFlightError> {
409    let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") else {
410        return Ok(());
411    };
412    let path = PathBuf::from(&dir);
413    if !path.is_dir() {
414        return Ok(());
415    }
416    // BUG-1 fix (v1.0.88): inspect `settings.json` semantically. A
417    // populated directory containing `CLAUDE.md`, custom `commands/`,
418    // or skills is harmless — Claude Code will not auto-load MCP
419    // servers or hooks unless `settings.json` declares them. The
420    // previous implementation rejected any non-empty directory, which
421    // broke every dev install that points `CLAUDE_CONFIG_DIR` at the
422    // real Claude Code configuration home.
423    let settings = path.join("settings.json");
424    if !settings.exists() {
425        // Directory populated with non-MCP files (CLAUDE.md,
426        // commands/, skills/, etc.) — emit a structured warning so
427        // operators can audit, but do NOT abort the spawn.
428        if std::fs::read_dir(&path)
429            .map(|mut i| i.next().is_some())
430            .unwrap_or(false)
431        {
432            tracing::warn!(
433                target: "preflight",
434                path = %path.display(),
435                "CLAUDE_CONFIG_DIR is populated but contains no settings.json; \
436                 MCP servers and hooks will not be auto-loaded"
437            );
438        }
439        return Ok(());
440    }
441    let contents = match std::fs::read_to_string(&settings) {
442        Ok(c) => c,
443        Err(e) => {
444            tracing::warn!(
445                target: "preflight",
446                path = %settings.display(),
447                error = %e,
448                "CLAUDE_CONFIG_DIR/settings.json exists but could not be read; \
449                 skipping semantic validation"
450            );
451            return Ok(());
452        }
453    };
454    let parsed: serde_json::Value = match serde_json::from_str(&contents) {
455        Ok(v) => v,
456        Err(e) => {
457            tracing::warn!(
458                target: "preflight",
459                path = %settings.display(),
460                error = %e,
461                "CLAUDE_CONFIG_DIR/settings.json is not valid JSON; \
462                 skipping semantic validation"
463            );
464            return Ok(());
465        }
466    };
467    // Reject when settings.json declares active MCP servers. Hooks are
468    // tolerated because the spawners pass `--settings '{"hooks":{}}'`
469    // which overrides the user-level hooks at the CLI boundary.
470    let has_mcp_servers = parsed
471        .get("mcpServers")
472        .and_then(|v| v.as_object())
473        .map(|o| !o.is_empty())
474        .unwrap_or(false);
475    if has_mcp_servers {
476        return Err(PreFlightError::ClaudeConfigDirNotEmpty {
477            path,
478            reason: "mcpServers",
479        });
480    }
481    Ok(())
482}
483
484// ---------------------------------------------------------------------------
485// Tests (GAP-META-005 test plan, 15 cases)
486// ---------------------------------------------------------------------------
487#[cfg(test)]
488#[path = "preflight_tests.rs"]
489mod tests;