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