kranz_engine/agent_env.rs
1//! Cleared-environment construction for every prompt-injectable child the
2//! engine spawns (ticket `agent-env-clear`, P1 of the 2026-07-28
3//! hostile-workload review).
4//!
5//! Before this module, agent CLI sessions spawned with `.envs(&spec.env)`
6//! overlaid on the FULL ambient environment (backend_claude.rs), and contract
7//! `command` assertions ran with `clear_env = false` (command_exec.rs) — so
8//! ambient server secrets (Slack tokens, GH_TOKEN, cloud credentials,
9//! remote-workspace tokens) reached every prompt-injectable child. Now:
10//!
11//! - **Agent CLI sessions** (claude/codex/droid/kimi/cursor backends) spawn
12//! with `env_clear` + [`sanitized_child_env`]: PATH, a scratch HOME,
13//! locale vars, and nothing else — plus backend-specific auth injected
14//! explicitly ([`agent_session_env`]), never the ambient set.
15//! - **Contract/gate commands** (validation round, final gate, approval-time
16//! contract lint) run with `env_clear` + [`contract_command_env`]: the
17//! sanitized base plus `KRANZ_BASE_SHA`, a cache-only Cargo home, the
18//! non-credential toolchain locations, and at most the operator's
19//! `contractEnvPassthrough` names.
20//!
21//! The ENGINE process itself keeps its ambient environment — the clearing
22//! applies to child processes only. Merge gates keep their own pre-existing
23//! `command_exec::sanitized_gate_env` allowlist (it intentionally retains
24//! ambient `HOME`/`CI`/temp dirs for the operator's toolchain; not a clean
25//! swap for this module's scratch-HOME shape, so both lists stay, each
26//! documented at its site) — with ONE exception: the gate env never carries
27//! the ambient `CARGO_HOME`, which `run_bounded_gate_command` replaces with
28//! a fresh [`cache_only_cargo_home`] exactly like the contract env. Under
29//! `worker.sandbox.enforce != off` the merge gate additionally runs WRAPPED
30//! in the resolved sandbox profile
31//! (`command_exec::run_bounded_gate_command_sandboxed`): the ambient HOME
32//! pass-through stays (git identity needs `~/.gitconfig`), and the profile
33//! makes it read-only — containment by the sandbox, not by env rewrite.
34//!
35//! Secret hygiene: only variable NAMES are ever logged here (the injected
36//! auth key's name, the passthrough names applied/skipped) — never values.
37
38use std::collections::HashMap;
39use std::path::{Path, PathBuf};
40
41/// Locale/terminal variables passed through from ambient when present. None
42/// of them carry credentials; a missing one is simply omitted (CI runners
43/// routinely have no `TERM`). `USER` rides along as account identity, not a
44/// credential: the `claude` CLI's keychain-backed OAuth resolution FAILS
45/// without it ("Not logged in", probed 2026-07-29 — `USER` alone is
46/// sufficient, `LOGNAME` is not consulted), and a username is already
47/// visible in every absolute path the child sees.
48const AMBIENT_LOCALE_VARS: &[&str] = &["TERM", "LANG", "LC_ALL", "TZ", "USER"];
49
50/// Windows process requirements passed through from ambient: without
51/// `SystemRoot`/`ComSpec`/`PATHEXT` `cmd` and process creation break; the
52/// remaining names are machine-descriptive (not credentials) that `cmd`,
53/// PowerShell, and the .NET CLR consult on startup — a child missing them
54/// hangs or misbehaves in opaque ways (Windows CI, 89f05a1). Names are
55/// matched CASE-INSENSITIVELY (`SystemRoot` vs `SYSTEMROOT`) and emitted
56/// under the canonical casing below so the child env block never carries
57/// duplicate-case entries (Windows env lookup is case-insensitive; a block
58/// with both casings is undefined which wins). `USERPROFILE`/`APPDATA`/
59/// `LOCALAPPDATA`/`TEMP`/`TMP` are NOT passed through: like `HOME` they
60/// are redirected to the scratch dir, never the operator's real profile.
61#[cfg(windows)]
62const AMBIENT_WINDOWS_VARS: &[&str] = &[
63 "SystemRoot",
64 "ComSpec",
65 "PATHEXT",
66 "SystemDrive",
67 "windir",
68 "OS",
69 "PROCESSOR_ARCHITECTURE",
70 "PSModulePath",
71];
72
73/// Add the non-secret Windows process bootstrap variables to a cleared child
74/// environment using one canonical spelling per case-insensitive key. Both
75/// agent sessions and engine-run gates need this set: ordinary unsandboxed
76/// commands may limp along without all of it, while AppContainer process
77/// creation fails with `ERROR_ENVVAR_NOT_FOUND` before the child starts.
78#[cfg(windows)]
79pub(crate) fn extend_windows_process_env(env: &mut HashMap<String, String>) {
80 for key in AMBIENT_WINDOWS_VARS {
81 if let Some((_, value)) =
82 std::env::vars_os().find(|(k, _)| k.to_string_lossy().eq_ignore_ascii_case(key))
83 {
84 env.insert((*key).to_string(), value.to_string_lossy().into_owned());
85 }
86 }
87}
88
89/// Redirect the Windows user-profile variables that AppContainer process
90/// creation consumes to an already-authorized scratch root. Windows rewrites
91/// `LOCALAPPDATA`, `TEMP`, and `TMP` again for the AppContainer profile, but
92/// requires the profile tuple to exist in an explicit environment block.
93#[cfg(windows)]
94pub(crate) fn redirect_windows_profile_env(env: &mut HashMap<String, String>, base_home: &Path) {
95 let tmp = base_home.join("tmp");
96 let appdata_roaming = base_home.join("AppData").join("Roaming");
97 let appdata_local = base_home.join("AppData").join("Local");
98 for path in [&tmp, &appdata_roaming, &appdata_local] {
99 let _ = std::fs::create_dir_all(path);
100 }
101 env.insert("USERPROFILE".to_string(), base_home.display().to_string());
102 env.insert("TMPDIR".to_string(), tmp.display().to_string());
103 env.insert("TEMP".to_string(), tmp.display().to_string());
104 env.insert("TMP".to_string(), tmp.display().to_string());
105 env.insert("APPDATA".to_string(), appdata_roaming.display().to_string());
106 env.insert(
107 "LOCALAPPDATA".to_string(),
108 appdata_local.display().to_string(),
109 );
110}
111
112/// Toolchain locations children may inherit. `CARGO_HOME` is the exception:
113/// [`sanitized_child_env`] always replaces it with a per-invocation
114/// cache-only home (see [`cache_only_cargo_home`]), so neither agent sessions
115/// nor engine-run contract code receives the ambient credential/config root.
116/// `RUSTUP_HOME` must remain visible so a standard rustup shim can locate the
117/// installed toolchain.
118///
119/// Resolution rule for each var: the ambient value when set, ELSE the
120/// default under the OPERATOR's real home (`<real home>/.rustup` etc.) when
121/// that dir exists. The fallback matters: standard rustup/cargo installs
122/// export NEITHER var and derive both from HOME — and the child's HOME is
123/// mission scratch, so without the explicit derivation `cargo --version`
124/// fails "no default is configured" (7th-pass review, reproduced on the
125/// review host and this one).
126const CONTRACT_TOOLCHAIN_VARS: &[(&str, &str)] = &[
127 ("CARGO_HOME", ".cargo"),
128 ("RUSTUP_HOME", ".rustup"),
129 ("NPM_CONFIG_CACHE", ".npm"),
130];
131
132/// Above this size seeding one shared cache directory as a per-env COPY —
133/// even an accelerated clonefile/reflink one — costs more wall clock and
134/// disk per generated child env than the cache reuse saves: this builder
135/// runs for EVERY agent session and EVERY contract command, and the copy
136/// cost scales with the cache's entry count even when its bytes would
137/// clone instantly. Two measurements set the ceiling. Local (2026-08-03):
138/// a 1.34 GiB / ~55k-entry APFS registry takes ~7s to clonefile per env —
139/// all syscall time — and a mission builds dozens of these envs. CI
140/// (same day, run 30842947196): a 512 MiB ceiling put every runner's
141/// registry UNDER the copy threshold, so the workspace suite copied
142/// hundreds of MB per env-build until all three OS legs filled their
143/// disks (windows-latest died "No space left"). Above the ceiling the
144/// cache is therefore LINKED instead — the residual trade documented at
145/// [`cache_only_cargo_home`]: a poisoned write can then still reach the
146/// operator's shared cache. That trade stands for real-world registries
147/// (which are never this small) until `engine-gates-sandbox-wrapped`
148/// (pri 1) lands: under the enforced sandbox the link target is outside
149/// the writable roots and read-only in practice, which is the finding's
150/// true fix. The ceiling still protects the small-cache rigs where the
151/// copy is genuinely cheap.
152const CACHE_COPY_MAX_BYTES: u64 = 64 * 1024 * 1024;
153
154/// File names that must NEVER reach a contract Cargo home: credentials and
155/// credential-provider configuration. Only `registry/` and `git/` are ever
156/// seeded, so these names cannot legitimately appear inside them — the copy
157/// skips them EXPLICITLY anyway (loudly), so a planted
158/// `registry/credentials.toml` cannot ride the seed into the child's home.
159const CARGO_CACHE_NEVER_SEED: &[&str] =
160 &["credentials.toml", "credentials", "config.toml", "config"];
161
162/// Build a fresh Cargo home containing only the two cache directories Cargo
163/// uses for registry and git dependencies. Root-level Cargo configuration,
164/// `credentials.toml`, and the legacy `credentials` file are deliberately
165/// never copied or linked. This matters even though contract command text is
166/// operator-approved: `cargo test` executes worker-authored build scripts and
167/// test binaries outside the agent sandbox.
168///
169/// A fresh, unpredictable directory is used for every generated child env so
170/// worker code cannot pre-plant `config.toml` or a credential-provider in a
171/// stable scratch location. Only `registry/` and `git/` are seeded into it,
172/// preserving cache locality without making the operator's Cargo root
173/// reachable.
174///
175/// The seed is a per-env COPY, not a link (12th-pass review, P1): the
176/// operator's real caches were previously SYMLINKED in, so worker-authored
177/// contract code writing through its Cargo cache could poison the shared
178/// cache for later missions and engine builds. Now each cache is seeded
179/// through the same tier order as the validator snapshot's `target/` warm
180/// ([`crate::validator_snapshot`]): APFS clonefile, else Linux reflink —
181/// both copy-on-write, so a write through the seeded cache never reaches the
182/// operator's bytes — else a plain byte copy. But only at or below
183/// [`CACHE_COPY_MAX_BYTES`]: above that ceiling even an accelerated copy
184/// costs more per child env than the reuse saves, so the cache is still
185/// LINKED (with the trade named in a warning): a poisoned write can then
186/// reach the shared cache, but only one the operator let grow past the
187/// ceiling. A failed seed simply leaves that cache absent and lets Cargo
188/// populate the isolated home (unchanged).
189///
190/// Used by BOTH child-env builders here and by
191/// [`crate::command_exec::run_bounded_gate_command`], whose merge-gate env
192/// substitutes this for the ambient `CARGO_HOME` over a self-cleaning temp
193/// scratch.
194pub(crate) fn cache_only_cargo_home(base_home: &Path) -> PathBuf {
195 let destination = base_home.join(format!(
196 ".cargo-cache-only-{}",
197 uuid::Uuid::new_v4().simple()
198 ));
199 if let Err(error) = std::fs::create_dir_all(&destination) {
200 tracing::warn!(
201 path = %destination.display(),
202 error = %error,
203 "could not create cache-only Cargo home; Cargo will surface the failure"
204 );
205 return destination;
206 }
207
208 let Some(source) = toolchain_var_value("CARGO_HOME", ".cargo").map(PathBuf::from) else {
209 return destination;
210 };
211 for name in ["registry", "git"] {
212 let from = source.join(name);
213 let to = destination.join(name);
214 if !from.is_dir() {
215 continue;
216 }
217 seed_cargo_cache(name, &from, &to);
218 }
219 destination
220}
221
222/// Seed one shared cache directory (`registry/` or `git/`) into the isolated
223/// contract home. At or below [`CACHE_COPY_MAX_BYTES`] the seed is a per-env
224/// COPY through the same tier order as the validator snapshot's `target/`
225/// warm — clonefile, else reflink, else plain copy — so a write through the
226/// child's cache can never reach the operator's bytes. Above the ceiling
227/// (measured by [`crate::validator_snapshot::dir_size_exceeds`], which stops
228/// its walk the moment the answer is known) the cache is LINKED, with the
229/// trade named — the pre-12th-pass behavior, kept for exactly the case a
230/// copy is prohibitively expensive. Credential-shaped top-level entries are
231/// excluded from every copy tier explicitly ([`CARGO_CACHE_NEVER_SEED`]). A
232/// failed seed leaves the cache absent and lets Cargo populate the isolated
233/// home.
234fn seed_cargo_cache(name: &str, from: &Path, to: &Path) {
235 if crate::validator_snapshot::dir_size_exceeds(from, CACHE_COPY_MAX_BYTES) {
236 // The documented residual trade: the cache exceeds the copy ceiling,
237 // so even an accelerated copy would cost more per child env than the
238 // reuse saves. Linking keeps the cache available, but a poisoned
239 // write through the child's Cargo cache reaches the operator's
240 // shared cache — accepted only for a cache the operator let grow
241 // past the ceiling.
242 tracing::warn!(
243 cache = name,
244 source = %from.display(),
245 "shared Cargo cache exceeds the copy ceiling; LINKING it into the contract home — \
246 cache writes from worker-authored contract code will reach the shared cache"
247 );
248 } else if copy_cargo_cache_entries(from, to, crate::validator_snapshot::copy_dir_clonefile)
249 || copy_cargo_cache_entries(from, to, crate::validator_snapshot::copy_dir_reflink)
250 || copy_cargo_cache_entries(from, to, copy_entry_plain)
251 {
252 return;
253 } else {
254 tracing::warn!(
255 cache = name,
256 source = %from.display(),
257 "every copy tier failed for the shared Cargo cache; falling back to linking it"
258 );
259 }
260 link_cargo_cache(name, from, to);
261}
262
263/// Copy each top-level entry of `from` into `to` with `copy_entry` (which
264/// handles files and dirs uniformly), skipping [`CARGO_CACHE_NEVER_SEED`]
265/// names explicitly. `false` on the first entry that fails — the partial
266/// copy is swept before returning, mirroring `run_cp`'s discipline in
267/// [`crate::validator_snapshot`], so the caller's next tier starts clean.
268fn copy_cargo_cache_entries(from: &Path, to: &Path, copy_entry: fn(&Path, &Path) -> bool) -> bool {
269 let Ok(entries) = std::fs::read_dir(from) else {
270 return false;
271 };
272 if std::fs::create_dir_all(to).is_err() {
273 return false;
274 }
275 for entry in entries.flatten() {
276 let file_name = entry.file_name();
277 if CARGO_CACHE_NEVER_SEED.contains(&file_name.to_string_lossy().as_ref()) {
278 tracing::warn!(
279 cache = %from.display(),
280 entry = %file_name.to_string_lossy(),
281 "skipping credential-shaped entry while seeding the contract Cargo cache"
282 );
283 continue;
284 }
285 if !copy_entry(&entry.path(), &to.join(&file_name)) {
286 let _ = std::fs::remove_dir_all(to);
287 return false;
288 }
289 }
290 true
291}
292
293/// Plain-copy one cache entry: [`crate::validator_snapshot::copy_dir_plain`]
294/// for directories (Cargo cache top-levels like `registry/cache/`), a plain
295/// `std::fs::copy` for files (`registry/CACHEDIR.TAG`, lockfiles). Symlinks
296/// are followed either way — the copy owns real bytes, never a link into
297/// the operator's cache.
298fn copy_entry_plain(src: &Path, dst: &Path) -> bool {
299 if src.is_dir() {
300 crate::validator_snapshot::copy_dir_plain(src, dst).is_ok()
301 } else {
302 std::fs::copy(src, dst).is_ok()
303 }
304}
305
306/// Link the operator's cache dir into the contract home — the pre-12th-pass
307/// behavior, now ONLY the last resort when the cache is over the copy
308/// ceiling or every copy tier failed. A failed link leaves the cache absent
309/// and lets Cargo populate the isolated home (unchanged).
310fn link_cargo_cache(name: &str, from: &Path, to: &Path) {
311 #[cfg(unix)]
312 if let Err(error) = std::os::unix::fs::symlink(from, to) {
313 tracing::warn!(
314 cache = name,
315 source = %from.display(),
316 error = %error,
317 "could not seed contract Cargo cache; using an empty isolated cache"
318 );
319 }
320 #[cfg(windows)]
321 if let Err(error) = std::os::windows::fs::symlink_dir(from, to) {
322 tracing::warn!(
323 cache = name,
324 source = %from.display(),
325 error = %error,
326 "could not seed contract Cargo cache; using an empty isolated cache"
327 );
328 }
329}
330
331/// The operator's home directory from the OS account record (`getpwuid_r`),
332/// NOT the ambient `HOME` env var (ticket contract-toolchain-home-os-account).
333/// In env_clear'd / sandboxed gate contexts `HOME` is absent or points at a
334/// relocated scratch dir, so deriving CARGO_HOME/RUSTUP_HOME from it silently
335/// degrades (the m-eee81f workers each misread this as an in-scope bug). The
336/// passwd entry is the operator's real home regardless of the process env.
337/// `HOME` is consulted only as a fallback when the account record is
338/// unavailable, and the toolchain env vars themselves remain the explicit
339/// override (handled in [`toolchain_var_value`]).
340#[cfg(unix)]
341pub(crate) fn os_account_home() -> Option<PathBuf> {
342 // getpwuid_r (the reentrant form): the engine is a multi-threaded tokio
343 // process, so the static-buffer getpwuid is not sound here. pw_dir points
344 // into `buf`; copy it to an owned PathBuf before returning.
345 let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
346 let mut buf = vec![0_u8; 4096];
347 let mut entry_ptr = std::ptr::null_mut();
348 let rc = unsafe {
349 libc::getpwuid_r(
350 libc::getuid(),
351 &mut pwd,
352 buf.as_mut_ptr() as *mut libc::c_char,
353 buf.len(),
354 &mut entry_ptr,
355 )
356 };
357 if rc != 0 || entry_ptr.is_null() || pwd.pw_dir.is_null() {
358 return None;
359 }
360 let home = unsafe { std::ffi::CStr::from_ptr(pwd.pw_dir) }
361 .to_string_lossy()
362 .into_owned();
363 (!home.is_empty()).then(|| PathBuf::from(home))
364}
365
366/// The operator's toolchain home: the OS account record on Unix and the
367/// original `USERPROFILE` on Windows, falling back to the ambient `HOME`
368/// only when the platform-native source is unavailable. The generated child
369/// environment redirects both HOME and USERPROFILE later; this lookup happens
370/// first against the engine's operator environment. See [`os_account_home`].
371pub(crate) fn operator_home() -> Option<PathBuf> {
372 #[cfg(unix)]
373 if let Some(home) = os_account_home() {
374 return Some(home);
375 }
376 #[cfg(windows)]
377 if let Some(home) = std::env::var_os("USERPROFILE").filter(|value| !value.is_empty()) {
378 return Some(PathBuf::from(home));
379 }
380 std::env::var_os("HOME").map(PathBuf::from)
381}
382
383/// The value a toolchain var resolves to for a child env: ambient when set,
384/// else `<real home>/<default_subdir>` when that directory exists.
385fn toolchain_var_value(var: &str, default_subdir: &str) -> Option<String> {
386 if let Some(value) = std::env::var_os(var) {
387 return Some(value.to_string_lossy().into_owned());
388 }
389 let real_home = operator_home()?;
390 let candidate = real_home.join(default_subdir);
391 candidate.is_dir().then(|| candidate.display().to_string())
392}
393
394/// Add credential-free toolchain locations to a cleared environment. Cargo's
395/// root is deliberately excluded: every caller substitutes a fresh
396/// cache-only `CARGO_HOME`, while rustup and npm cache locations contain no
397/// authentication configuration and must remain discoverable after HOME /
398/// USERPROFILE is redirected to scratch.
399pub(crate) fn extend_noncredential_toolchain_env(env: &mut HashMap<String, String>) {
400 for (var, default_subdir) in CONTRACT_TOOLCHAIN_VARS {
401 if *var == "CARGO_HOME" {
402 continue;
403 }
404 if let Some(value) = toolchain_var_value(var, default_subdir) {
405 env.insert((*var).to_string(), value);
406 }
407 }
408}
409
410/// Env names [`contract_command_env`] manages itself; a `contractEnvPassthrough`
411/// entry naming one of these is refused (loudly, name only) so the escape
412/// hatch cannot silently saw off the isolation it sits on — e.g. passing
413/// `HOME` through would hand the operator's real home to the contract.
414fn managed_contract_keys() -> &'static [&'static str] {
415 &[
416 "PATH",
417 "HOME",
418 "USERPROFILE",
419 "TMPDIR",
420 "TEMP",
421 "TMP",
422 "APPDATA",
423 "LOCALAPPDATA",
424 "SystemRoot",
425 "SYSTEMROOT",
426 "ComSpec",
427 "COMSPEC",
428 "PATHEXT",
429 "TERM",
430 "LANG",
431 "LC_ALL",
432 "TZ",
433 "USER",
434 "KRANZ_BASE_SHA",
435 "CARGO_HOME",
436 "RUSTUP_HOME",
437 "NPM_CONFIG_CACHE",
438 ]
439}
440
441/// Build a cleared child environment from scratch: EXACTLY `PATH` (from
442/// ambient — binaries must resolve), `HOME = base_home` (the scratch dir the
443/// session/command already gets, never the operator's real home),
444/// `TMPDIR = base_home/tmp`, the ambient locale vars when present, the
445/// non-credential toolchain locations plus the cache-only Cargo home
446/// ([`CONTRACT_TOOLCHAIN_VARS`] / [`cache_only_cargo_home`]; without cache
447/// seeding every agent session re-downloads the registry into scratch, which
448/// filled the disk and killed mission m-533143), and on
449/// Windows the process-required passthroughs (`AMBIENT_WINDOWS_VARS`)
450/// plus `USERPROFILE = base_home`, `TEMP`/`TMP = base_home/tmp`, and
451/// `APPDATA`/`LOCALAPPDATA = base_home/AppData/{Roaming,Local}`. Then
452/// `extra` is applied verbatim, in order —
453/// that is where `KRANZ_BASE_SHA`, proxy wiring, git identity, and
454/// backend-specific auth go. NOTHING else crosses from ambient.
455///
456/// Creates `base_home`, `base_home/tmp` (and on Windows the AppData dirs)
457/// best-effort (a child pointing at a nonexistent HOME/TMPDIR fails in
458/// opaque ways); a creation failure is not fatal to env construction — the
459/// child surfaces it on its own.
460pub fn sanitized_child_env(
461 base_home: &Path,
462 extra: &[(String, String)],
463) -> HashMap<String, String> {
464 let _ = std::fs::create_dir_all(base_home.join("tmp"));
465
466 let mut env = HashMap::new();
467 if let Some(path) = std::env::var_os("PATH") {
468 env.insert("PATH".to_string(), path.to_string_lossy().into_owned());
469 }
470 env.insert("HOME".to_string(), base_home.display().to_string());
471 env.insert(
472 "TMPDIR".to_string(),
473 base_home.join("tmp").display().to_string(),
474 );
475 for key in AMBIENT_LOCALE_VARS {
476 if let Some(value) = std::env::var_os(key) {
477 env.insert((*key).to_string(), value.to_string_lossy().into_owned());
478 }
479 }
480 // Non-credential toolchain locations ride for BOTH sessions and contract
481 // commands. CARGO_HOME is always replaced with an isolated cache-only
482 // root; no prompt-injectable child receives operator Cargo config/tokens.
483 extend_noncredential_toolchain_env(&mut env);
484 env.insert(
485 "CARGO_HOME".to_string(),
486 cache_only_cargo_home(base_home).display().to_string(),
487 );
488 #[cfg(windows)]
489 {
490 // Case-insensitive ambient lookup, canonical-cased emission: Windows
491 // env names are case-insensitive, but this map is not. Duplicate-case
492 // entries make the resulting child block ambiguous.
493 extend_windows_process_env(&mut env);
494 // Profile/temp locations redirect to scratch (like HOME), never the
495 // operator's real profile. `cmd` stages pipe temp files in %TEMP%
496 // and PowerShell/CLR consult APPDATA/LOCALAPPDATA on startup —
497 // leaving them unset hangs children in opaque ways (89f05a1 CI).
498 redirect_windows_profile_env(&mut env, base_home);
499 }
500 for (key, value) in extra {
501 env.insert(key.clone(), value.clone());
502 }
503 env
504}
505
506/// The cleared environment a BINARY PROBE spawns with (2026-09-01
507/// adversarial audit, H5).
508///
509/// Every session spawn is `env_clear`'d from the allowlist above; the
510/// discovery and readiness probes were the one exception, so a
511/// repo-named `claudeBinary` or a PATH-precedence shadow of
512/// `claude`/`codex`/`droid`/`kimi`/`cursor` received the operator's whole
513/// environment — `GH_TOKEN`, `SLACK_*`, `AWS_*`, every API key — on its
514/// first `--version` invocation, before any auth decision.
515///
516/// Deliberately NOT [`sanitized_child_env`]: that builder relocates `HOME`
517/// to a scratch dir and seeds a cache-only Cargo home, which would copy the
518/// registry for a `--version` call AND would make every login probe report
519/// "not logged in" (`claude auth status` and its siblings read the
520/// operator's real config). The probe env is therefore the allowlist
521/// WITHOUT the relocation: `PATH`, the real `HOME`/`USERPROFILE`, the
522/// ambient locale/identity vars ([`AMBIENT_LOCALE_VARS`] — `USER` alone is
523/// what the claude CLI's keychain OAuth resolution needs), the system temp
524/// dir, and on Windows the process bootstrap set
525/// ([`AMBIENT_WINDOWS_VARS`]) without which process creation fails.
526/// `extra` carries the ONE ambient auth var a login probe may need, named
527/// by its caller. Nothing else crosses.
528pub(crate) fn probe_child_env(extra: &[(String, String)]) -> HashMap<String, String> {
529 let mut env = HashMap::new();
530 if let Some(path) = std::env::var_os("PATH") {
531 env.insert("PATH".to_string(), path.to_string_lossy().into_owned());
532 }
533 for key in ["HOME", "USERPROFILE"] {
534 if let Some(value) = std::env::var_os(key) {
535 env.insert(key.to_string(), value.to_string_lossy().into_owned());
536 }
537 }
538 for key in AMBIENT_LOCALE_VARS {
539 if let Some(value) = std::env::var_os(key) {
540 env.insert((*key).to_string(), value.to_string_lossy().into_owned());
541 }
542 }
543 let temp = std::env::temp_dir().display().to_string();
544 for key in ["TMPDIR", "TEMP", "TMP"] {
545 env.insert(key.to_string(), temp.clone());
546 }
547 #[cfg(windows)]
548 extend_windows_process_env(&mut env);
549 for (key, value) in extra {
550 env.insert(key.clone(), value.clone());
551 }
552 env
553}
554
555/// The per-session scratch `HOME` used when a session spec carries no
556/// relocated `HOME` of its own: the `home` dir under the same per-session
557/// scratch root worker relocation uses
558/// ([`crate::backend_claude::scratch_home_root`]), so sandboxed sessions get
559/// a HOME inside their writable TMPDIR allowlist either way.
560pub fn session_scratch_home(session_id: &str) -> PathBuf {
561 crate::backend_claude::scratch_home_root(session_id).join("home")
562}
563
564/// The cleared env for one agent CLI session, uniform across the spawning
565/// backends (claude/codex/droid/kimi/cursor).
566///
567/// - `base_home` is the session's relocated scratch `HOME` when `spec_env`
568/// carries one (worker relocation, the auth probe's candidate env), else a
569/// fresh per-session scratch home.
570/// - Every `spec_env` entry crosses (it is engine-built: `KRANZ_BASE_SHA`,
571/// `CLAUDE_CONFIG_DIR`, git identity, egress-proxy vars).
572/// - `auth_env_name` is the ONE ambient var this backend may need to
573/// authenticate (`ANTHROPIC_API_KEY` for claude, `OPENAI_API_KEY` for
574/// codex, …): injected only when the operator actually has it set, and
575/// recorded name-only. Ambient `GH_TOKEN`/`SLACK_*`/`AWS_*`/`GOOGLE_*`
576/// never cross, regardless.
577pub fn agent_session_env(
578 spec_env: &HashMap<String, String>,
579 session_id: &str,
580 auth_env_name: Option<&str>,
581) -> HashMap<String, String> {
582 let base_home = spec_env
583 .get("HOME")
584 .map(PathBuf::from)
585 .unwrap_or_else(|| session_scratch_home(session_id));
586 session_env_with_home(spec_env, session_id, auth_env_name, &base_home)
587}
588
589/// [`agent_session_env`] with an explicit `base_home` — the claude backend
590/// uses this after seeding a fresh scratch home (OAuth credentials copy) for
591/// a spec that carried no relocated HOME, so the seeded dir is the HOME the
592/// child actually gets.
593pub fn session_env_with_home(
594 spec_env: &HashMap<String, String>,
595 session_id: &str,
596 auth_env_name: Option<&str>,
597 base_home: &Path,
598) -> HashMap<String, String> {
599 let mut extra: Vec<(String, String)> = spec_env
600 .iter()
601 .map(|(k, v)| (k.clone(), v.clone()))
602 .collect();
603 if let Some(name) = auth_env_name {
604 if let Some(value) = std::env::var_os(name).filter(|v| !v.is_empty()) {
605 // Name only in the log; the value is copied, never recorded.
606 tracing::info!(
607 session_id = %session_id,
608 key = name,
609 "backend auth env var injected from ambient into cleared session env"
610 );
611 extra.push((name.to_string(), value.to_string_lossy().into_owned()));
612 }
613 }
614 sanitized_child_env(base_home, &extra)
615}
616
617/// The cleared env for one contract/gate command execution (validation
618/// round, final gate, approval-time lint — design decision 3 of the
619/// ticket): [`sanitized_child_env`] over the per-mission writable
620/// `mission_scratch` home, plus
621///
622/// - `KRANZ_BASE_SHA` via the shared [`crate::runner::contract_env`] idiom,
623/// - a cache-only `CARGO_HOME` plus the non-credential toolchain locations,
624/// - exactly the ambient vars NAMED in `passthrough` (the mission config's
625/// `contractEnvPassthrough` escape hatch — the sanctioned way to give a
626/// contract one credential). Names only are logged, never values; a
627/// passthrough name colliding with a managed key (PATH/HOME/…) is refused
628/// with a warning so the hatch cannot reopen the boundary it sits on.
629pub fn contract_command_env(
630 mission_scratch: &Path,
631 base_sha: Option<&str>,
632 passthrough: &[String],
633) -> HashMap<String, String> {
634 let mut extra: Vec<(String, String)> =
635 crate::runner::contract_env(base_sha).into_iter().collect();
636 for (var, default_subdir) in CONTRACT_TOOLCHAIN_VARS {
637 if *var == "CARGO_HOME" {
638 continue;
639 }
640 if let Some(value) = toolchain_var_value(var, default_subdir) {
641 extra.push(((*var).to_string(), value));
642 }
643 }
644 let managed = managed_contract_keys();
645 for name in passthrough {
646 let name = name.trim();
647 if name.is_empty() {
648 continue;
649 }
650 // Case-INSENSITIVE refusal: Windows env names are case-insensitive,
651 // so a `path`/`Temp` passthrough would otherwise slip the check and
652 // emit a duplicate-case entry — undefined which value the child
653 // sees, silently overriding a scratch redirect. Refusing every
654 // casing everywhere keeps one rule for all platforms.
655 if managed.iter().any(|m| m.eq_ignore_ascii_case(name)) {
656 tracing::warn!(
657 key = name,
658 "contractEnvPassthrough entry refused: name is managed by the contract env itself"
659 );
660 continue;
661 }
662 match std::env::var_os(name) {
663 Some(value) => {
664 extra.push((name.to_string(), value.to_string_lossy().into_owned()));
665 }
666 None => {
667 tracing::warn!(
668 key = name,
669 "contractEnvPassthrough entry named a var that is not set in the ambient env"
670 );
671 }
672 }
673 }
674 sanitized_child_env(mission_scratch, &extra)
675}
676
677// ---------------------------------------------------------------------------
678
679/// Test-only shared lock + env guard for the exfiltration tests across
680/// `agent_env` / `backend_claude` / `command_exec`: they poison ambient
681/// secret vars, and assertions that depend on an ambient VALUE (e.g. an
682/// injected API key) must serialize against each other so a parallel test
683/// cannot restore a var mid-assertion.
684#[cfg(test)]
685pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
686
687/// Global authority resolution is cached once per process. Fixtures that
688/// relocate HOME must initialize it in a fresh process, without changing the
689/// cached store used by other tests in the workspace.
690#[cfg(test)]
691pub(crate) fn isolated_global_home_test(name: &str) -> bool {
692 if std::env::var("KRANZ_ISOLATED_GLOBAL_HOME_TEST").as_deref() == Ok(name) {
693 return false;
694 }
695 let output = std::process::Command::new(std::env::current_exe().unwrap())
696 .args([name, "--exact", "--nocapture"])
697 .env("KRANZ_ISOLATED_GLOBAL_HOME_TEST", name)
698 .env("RUST_TEST_THREADS", "1")
699 .env_remove("KRANZ_HOME")
700 .output()
701 .unwrap();
702 assert!(
703 output.status.success(),
704 "isolated fixture {name}: {}\n{}",
705 String::from_utf8_lossy(&output.stdout),
706 String::from_utf8_lossy(&output.stderr)
707 );
708 assert!(
709 String::from_utf8_lossy(&output.stdout).contains("1 passed;"),
710 "fixture filter matched no test"
711 );
712 true
713}
714
715/// RAII guard: set each `(name, value)` pair on engage, restore the prior
716/// state (set/unset) on drop, all while holding [`ENV_TEST_LOCK`].
717#[cfg(test)]
718pub(crate) struct EnvTestGuard {
719 vars: Vec<(&'static str, Option<std::ffi::OsString>)>,
720 _lock: std::sync::MutexGuard<'static, ()>,
721}
722
723#[cfg(test)]
724impl EnvTestGuard {
725 pub(crate) fn engage(settings: &[(&'static str, &str)]) -> Self {
726 let lock = ENV_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
727 let vars = settings
728 .iter()
729 .map(|(name, value)| {
730 let prev = std::env::var_os(name);
731 std::env::set_var(name, value);
732 (*name, prev)
733 })
734 .collect();
735 EnvTestGuard { vars, _lock: lock }
736 }
737
738 /// Engage with some vars set and others REMOVED (e.g. prove a key is
739 /// absent unless this backend injects it).
740 pub(crate) fn engage_unsetting(
741 settings: &[(&'static str, &str)],
742 unset: &[&'static str],
743 ) -> Self {
744 let lock = ENV_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
745 let mut vars: Vec<(&'static str, Option<std::ffi::OsString>)> = settings
746 .iter()
747 .map(|(name, value)| {
748 let prev = std::env::var_os(name);
749 std::env::set_var(name, value);
750 (*name, prev)
751 })
752 .collect();
753 for name in unset {
754 let prev = std::env::var_os(name);
755 std::env::remove_var(name);
756 vars.push((name, prev));
757 }
758 EnvTestGuard { vars, _lock: lock }
759 }
760}
761
762#[cfg(test)]
763impl Drop for EnvTestGuard {
764 fn drop(&mut self) {
765 for (name, prev) in &self.vars {
766 match prev {
767 Some(value) => std::env::set_var(name, value),
768 None => std::env::remove_var(name),
769 }
770 }
771 }
772}
773
774// ---------------------------------------------------------------------------
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779
780 fn extra(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
781 pairs
782 .iter()
783 .map(|(k, v)| (k.to_string(), v.to_string()))
784 .collect()
785 }
786
787 /// The locked allowlist (design decision 1): poisoned ambient secrets
788 /// never cross; PATH/scratch-HOME/locale/TMPDIR do; extra applies
789 /// verbatim; and the scratch tmp dir is actually created.
790 #[test]
791 fn sanitized_child_env_starts_empty_and_never_inherits_secrets() {
792 let _poison = EnvTestGuard::engage(&[
793 ("GH_TOKEN", "hunter2"),
794 ("SLACK_BOT_TOKEN", "xoxb-poison"),
795 ("AWS_SECRET_ACCESS_KEY", "aws-poison"),
796 ]);
797 let home = tempfile::tempdir().unwrap();
798
799 let env = sanitized_child_env(home.path(), &extra(&[("KRANZ_BASE_SHA", "deadbeef")]));
800
801 for secret in [
802 "GH_TOKEN",
803 "SLACK_BOT_TOKEN",
804 "AWS_SECRET_ACCESS_KEY",
805 "ANTHROPIC_API_KEY",
806 "OPENAI_API_KEY",
807 "SSH_AUTH_SOCK",
808 "GOOGLE_APPLICATION_CREDENTIALS",
809 ] {
810 assert!(!env.contains_key(secret), "child env leaked {secret}");
811 }
812 assert_eq!(
813 env.get("HOME").map(String::as_str),
814 Some(home.path().to_string_lossy().as_ref()),
815 "HOME must be the scratch dir, never the operator's real home"
816 );
817 assert_eq!(
818 env.get("TMPDIR").map(String::as_str),
819 Some(home.path().join("tmp").to_string_lossy().as_ref()),
820 "TMPDIR must be <scratch>/tmp"
821 );
822 assert!(
823 home.path().join("tmp").is_dir(),
824 "the scratch tmp dir must be created for the child"
825 );
826 assert_eq!(
827 env.get("KRANZ_BASE_SHA").map(String::as_str),
828 Some("deadbeef"),
829 "extra must apply verbatim"
830 );
831 if std::env::var_os("PATH").is_some() {
832 assert!(env.contains_key("PATH"), "PATH must cross from ambient");
833 }
834 // Nothing beyond the allowlist + extra crosses.
835 let allowed = [
836 "PATH",
837 "HOME",
838 "TMPDIR",
839 "TERM",
840 "LANG",
841 "LC_ALL",
842 "TZ",
843 "USER",
844 "CARGO_HOME",
845 "RUSTUP_HOME",
846 "NPM_CONFIG_CACHE",
847 "KRANZ_BASE_SHA",
848 ];
849 for key in env.keys() {
850 assert!(
851 allowed.contains(&key.as_str()) || cfg!(windows),
852 "unexpected key in child env: {key}"
853 );
854 }
855 }
856
857 /// Windows shape: temp/profile dirs redirect into scratch (never the
858 /// operator's), machine passthroughs cross case-deduped, and ambient
859 /// APPDATA/LOCALAPPDATA/TEMP/TMP do NOT pass through.
860 #[cfg(windows)]
861 #[test]
862 fn sanitized_child_env_windows_redirects_profile_and_temp_to_scratch() {
863 if isolated_global_home_test(
864 "agent_env::tests::sanitized_child_env_windows_redirects_profile_and_temp_to_scratch",
865 ) {
866 return;
867 }
868 // Relocate ambient paths in a separate process. Otherwise parallel
869 // tests can create temporary directories under this fixture's roots
870 // and lose them when the fixture completes and deletes those roots.
871 let home = tempfile::tempdir().unwrap();
872 let operator = tempfile::tempdir().unwrap();
873 let operator_temp = operator.path().join("operator-temp");
874 let operator_tmp = operator.path().join("operator-tmp");
875 let operator_roaming = operator.path().join("operator-roaming");
876 let operator_local = operator.path().join("operator-local");
877 for dir in [
878 &operator_temp,
879 &operator_tmp,
880 &operator_roaming,
881 &operator_local,
882 ] {
883 std::fs::create_dir_all(dir).unwrap();
884 }
885 let operator_temp = operator_temp.display().to_string();
886 let operator_tmp = operator_tmp.display().to_string();
887 let operator_roaming = operator_roaming.display().to_string();
888 let operator_local = operator_local.display().to_string();
889 let _poison = EnvTestGuard::engage(&[
890 ("TEMP", &operator_temp),
891 ("TMP", &operator_tmp),
892 ("APPDATA", &operator_roaming),
893 ("LOCALAPPDATA", &operator_local),
894 ]);
895 let _relocated_temp =
896 tempfile::tempdir().expect("relocated temporary paths must remain usable");
897
898 let env = sanitized_child_env(home.path(), &extra(&[]));
899
900 let tmp = home.path().join("tmp").display().to_string();
901 assert_eq!(env.get("TEMP").map(String::as_str), Some(tmp.as_str()));
902 assert_eq!(env.get("TMP").map(String::as_str), Some(tmp.as_str()));
903 assert_eq!(
904 env.get("USERPROFILE").map(String::as_str),
905 Some(home.path().to_string_lossy().as_ref())
906 );
907 assert!(
908 env.get("APPDATA")
909 .is_some_and(|v| v.starts_with(&home.path().display().to_string())),
910 "APPDATA must redirect under scratch, not the operator profile"
911 );
912 assert!(
913 env.get("LOCALAPPDATA")
914 .is_some_and(|v| v.starts_with(&home.path().display().to_string())),
915 "LOCALAPPDATA must redirect under scratch"
916 );
917 // Machine passthroughs cross under canonical casing only (the
918 // duplicate-case check below is the strict property).
919 if env.keys().any(|k| k.eq_ignore_ascii_case("systemroot")) {
920 assert!(
921 env.contains_key("SystemRoot"),
922 "SystemRoot must be emitted under canonical casing"
923 );
924 }
925 // No duplicate-case keys in the emitted block.
926 let mut lowered: Vec<String> = env.keys().map(|k| k.to_ascii_lowercase()).collect();
927 lowered.sort();
928 lowered.dedup();
929 assert_eq!(
930 lowered.len(),
931 env.len(),
932 "child env block carries duplicate-case entries: {:?}",
933 env.keys().collect::<Vec<_>>()
934 );
935 }
936
937 /// Backend auth (design decision 2): exactly the one named key the
938 /// backend needs is injected from ambient — a different backend's key
939 /// (and every non-auth secret) stays out.
940 #[test]
941 fn agent_session_env_injects_only_the_backends_own_auth_key() {
942 let _poison = EnvTestGuard::engage_unsetting(
943 &[
944 ("ANTHROPIC_API_KEY", "sk-ant-poison"),
945 ("GH_TOKEN", "hunter2"),
946 ],
947 &["OPENAI_API_KEY"],
948 );
949
950 // Claude-shaped spawn: its own key crosses, nothing else does.
951 let env = agent_session_env(&HashMap::new(), "sess-claude", Some("ANTHROPIC_API_KEY"));
952 assert_eq!(
953 env.get("ANTHROPIC_API_KEY").map(String::as_str),
954 Some("sk-ant-poison"),
955 "the backend's own auth key must be injected when set"
956 );
957 assert!(!env.contains_key("GH_TOKEN"), "GH_TOKEN never crosses");
958 assert_eq!(
959 env.get("HOME").map(String::as_str),
960 Some(
961 session_scratch_home("sess-claude")
962 .to_string_lossy()
963 .as_ref()
964 ),
965 "a HOME-less spec gets the per-session scratch home"
966 );
967
968 // Codex-shaped spawn on the same ambient env: the claude key must
969 // NOT cross — auth is injected only for the backend that needs it.
970 let env = agent_session_env(&HashMap::new(), "sess-codex", Some("OPENAI_API_KEY"));
971 assert!(
972 !env.contains_key("ANTHROPIC_API_KEY"),
973 "another backend's auth key must never be injected"
974 );
975 assert!(!env.contains_key("OPENAI_API_KEY"), "not set in ambient");
976 }
977
978 /// A spec carrying a relocated scratch HOME keeps exactly that HOME —
979 /// the worker-relocation / auth-probe candidate path the auth verdict
980 /// proved out.
981 #[test]
982 fn agent_session_env_honors_the_specs_relocated_home() {
983 let home = tempfile::tempdir().unwrap();
984 let mut spec_env = HashMap::new();
985 spec_env.insert("HOME".to_string(), home.path().display().to_string());
986 spec_env.insert(
987 "CLAUDE_CONFIG_DIR".to_string(),
988 home.path().join(".claude").display().to_string(),
989 );
990
991 let env = agent_session_env(&spec_env, "sess-worker", None);
992
993 assert_eq!(
994 env.get("HOME").map(String::as_str),
995 Some(home.path().to_string_lossy().as_ref())
996 );
997 assert_eq!(
998 env.get("CLAUDE_CONFIG_DIR").map(String::as_str),
999 Some(home.path().join(".claude").to_string_lossy().as_ref()),
1000 "the seeded config dir must survive env clearing (auth probe shape)"
1001 );
1002 }
1003
1004 /// 7th-pass review: a standard rustup install exports NEITHER
1005 /// RUSTUP_HOME nor CARGO_HOME. RUSTUP_HOME must derive from the
1006 /// OPERATOR's real home or the shim fails "no default is configured";
1007 /// CARGO_HOME must instead be isolated under scratch. Proven by actually
1008 /// executing Cargo under the generated env.
1009 #[cfg(unix)]
1010 /// Ticket contract-toolchain-home-os-account: with `HOME` UNSET in the
1011 /// engine's own env (the env_clear'd / sandboxed gate shape), the
1012 /// toolchain derivation must fall to the OS account record, not silently
1013 /// degrade to None. On a normal host the account record equals `$HOME`.
1014 #[cfg(unix)]
1015 #[test]
1016 fn toolchain_home_os_account_resolves_when_home_is_unset() {
1017 let real_home = std::env::var_os("HOME").map(PathBuf::from).unwrap();
1018 let _guard = EnvTestGuard::engage_unsetting(&[], &["HOME", "CARGO_HOME", "RUSTUP_HOME"]);
1019
1020 // The account record is the source now — HOME is gone, yet the
1021 // resolved operator home is still the operator's real home.
1022 let account_home = os_account_home().expect("this host has a passwd entry");
1023 assert_eq!(account_home, real_home, "account record == $HOME here");
1024 assert_eq!(operator_home().as_deref(), Some(real_home.as_path()));
1025
1026 // And the derivation still resolves the operator's real toolchain
1027 // dirs (only asserted when present, so the test is host-independent).
1028 if real_home.join(".rustup").is_dir() {
1029 assert_eq!(
1030 toolchain_var_value("RUSTUP_HOME", ".rustup"),
1031 Some(real_home.join(".rustup").display().to_string())
1032 );
1033 }
1034 }
1035
1036 /// The toolchain env var remains an explicit override: it wins even when
1037 /// the OS account record disagrees.
1038 #[cfg(unix)]
1039 #[test]
1040 fn toolchain_home_os_account_env_var_is_still_an_explicit_override() {
1041 let _guard = EnvTestGuard::engage(&[("RUSTUP_HOME", "/explicit/override")]);
1042 assert_eq!(
1043 toolchain_var_value("RUSTUP_HOME", ".rustup"),
1044 Some("/explicit/override".to_string()),
1045 "an explicit toolchain env var always wins"
1046 );
1047 }
1048
1049 #[test]
1050 fn noncredential_toolchain_extension_never_carries_cargo_home() {
1051 let _guard = EnvTestGuard::engage(&[
1052 ("CARGO_HOME", "/operator/cargo-with-credentials"),
1053 ("RUSTUP_HOME", "/operator/rustup"),
1054 ("NPM_CONFIG_CACHE", "/operator/npm-cache"),
1055 ]);
1056 let mut env = HashMap::new();
1057
1058 extend_noncredential_toolchain_env(&mut env);
1059
1060 assert_eq!(
1061 env.get("RUSTUP_HOME").map(String::as_str),
1062 Some("/operator/rustup")
1063 );
1064 assert_eq!(
1065 env.get("NPM_CONFIG_CACHE").map(String::as_str),
1066 Some("/operator/npm-cache")
1067 );
1068 assert!(!env.contains_key("CARGO_HOME"));
1069 }
1070
1071 /// The agent-session env shape is byte-identical (ticket's "do not weaken
1072 /// env_clear + scratch HOME" invariant): with HOME set normally, the
1073 /// toolchain derivation lands on the same operator home it always did.
1074 #[cfg(unix)]
1075 #[test]
1076 fn toolchain_home_os_account_keeps_session_env_shape_unchanged() {
1077 let _guard = EnvTestGuard::engage_unsetting(&[], &["CARGO_HOME", "RUSTUP_HOME"]);
1078 let real_home = std::env::var_os("HOME").map(PathBuf::from).unwrap();
1079 let scratch = tempfile::tempdir().unwrap();
1080
1081 let env = contract_command_env(scratch.path(), None, &[]);
1082
1083 if real_home.join(".rustup").is_dir() {
1084 assert_eq!(
1085 env.get("RUSTUP_HOME").map(String::as_str),
1086 Some(real_home.join(".rustup").display().to_string().as_str()),
1087 "RUSTUP_HOME still derives from the operator's real home"
1088 );
1089 }
1090 }
1091
1092 #[test]
1093 fn contract_env_derives_toolchain_homes_from_the_real_home_and_cargo_runs() {
1094 let _guard = EnvTestGuard::engage_unsetting(&[], &["RUSTUP_HOME", "CARGO_HOME"]);
1095 let scratch = tempfile::tempdir().unwrap();
1096 let real_home = operator_home().expect("operator home");
1097
1098 let env = contract_command_env(scratch.path(), None, &[]);
1099
1100 // The operator's rustup toolchain remains discoverable, while Cargo's
1101 // config/credential home is a fresh cache-only directory.
1102 let rustup_home = real_home.join(".rustup");
1103 if rustup_home.is_dir() {
1104 assert_eq!(
1105 env.get("RUSTUP_HOME").map(String::as_str),
1106 Some(rustup_home.display().to_string().as_str()),
1107 "RUSTUP_HOME derives from the operator's real home"
1108 );
1109 }
1110 let cargo_home = PathBuf::from(env.get("CARGO_HOME").expect("CARGO_HOME"));
1111 assert!(
1112 cargo_home.starts_with(scratch.path()),
1113 "CARGO_HOME must be isolated under mission scratch: {}",
1114 cargo_home.display()
1115 );
1116 assert_ne!(
1117 cargo_home,
1118 real_home.join(".cargo"),
1119 "the operator's real Cargo home must never reach contract code"
1120 );
1121
1122 // And cargo actually executes under the generated env: not a PATH
1123 // probe, a real run with HOME=scratch and the derived homes.
1124 let mut cmd = std::process::Command::new("cargo");
1125 cmd.arg("--version")
1126 .env_clear()
1127 .envs(&env)
1128 .stdin(std::process::Stdio::null())
1129 .stdout(std::process::Stdio::piped())
1130 .stderr(std::process::Stdio::piped());
1131 let out = cmd.output().expect("spawn cargo --version");
1132 assert!(
1133 out.status.success(),
1134 "cargo --version must succeed under the generated env: {}",
1135 String::from_utf8_lossy(&out.stderr)
1136 );
1137 let version = String::from_utf8_lossy(&out.stdout);
1138 assert!(
1139 version.starts_with("cargo "),
1140 "expected a cargo version string: {version}"
1141 );
1142 }
1143
1144 /// Contract env: base-sha + non-credential toolchain caches + passthrough
1145 /// names cross; ambient secrets do not; a passthrough entry naming a
1146 /// managed key — in ANY letter casing — is refused. CARGO_HOME always
1147 /// points at a fresh cache-only directory under mission scratch.
1148 #[test]
1149 fn contract_command_env_shapes_the_gate_boundary() {
1150 let _guard = EnvTestGuard::engage(&[
1151 ("RUSTUP_HOME", "/poisoned/rustup-home"),
1152 ("CARGO_HOME", "/poisoned/cargo-home"),
1153 ("KRANZ_AGENT_ENV_TEST_CRED", "cred-value"),
1154 ("GH_TOKEN", "hunter2"),
1155 ]);
1156 let scratch = tempfile::tempdir().unwrap();
1157
1158 // No passthrough configured: exactly base + toolchain caches.
1159 let env = contract_command_env(scratch.path(), Some("deadbeef"), &[]);
1160 assert_eq!(
1161 env.get("KRANZ_BASE_SHA").map(String::as_str),
1162 Some("deadbeef")
1163 );
1164 assert_eq!(
1165 env.get("RUSTUP_HOME").map(String::as_str),
1166 Some("/poisoned/rustup-home"),
1167 "toolchain caches cross from ambient"
1168 );
1169 let cargo_home = PathBuf::from(env.get("CARGO_HOME").expect("CARGO_HOME"));
1170 assert!(
1171 cargo_home.starts_with(scratch.path()),
1172 "CARGO_HOME must be cache-only mission scratch: {}",
1173 cargo_home.display()
1174 );
1175 assert_ne!(cargo_home, PathBuf::from("/poisoned/cargo-home"));
1176 for forbidden in ["credentials.toml", "credentials", "config.toml", "config"] {
1177 assert!(
1178 !cargo_home.join(forbidden).exists(),
1179 "cache-only Cargo home copied forbidden root file {forbidden}"
1180 );
1181 }
1182 assert!(!env.contains_key("GH_TOKEN"));
1183 assert!(
1184 !env.contains_key("KRANZ_AGENT_ENV_TEST_CRED"),
1185 "a credential crosses ONLY when named in contractEnvPassthrough"
1186 );
1187 assert_eq!(
1188 env.get("HOME").map(String::as_str),
1189 Some(scratch.path().to_string_lossy().as_ref())
1190 );
1191
1192 // Passthrough configured: the named var crosses; a managed name is
1193 // refused in any letter casing (HOME stays the scratch).
1194 let env = contract_command_env(
1195 scratch.path(),
1196 None,
1197 &[
1198 "KRANZ_AGENT_ENV_TEST_CRED".to_string(),
1199 "home".to_string(),
1200 "KRANZ_AGENT_ENV_TEST_UNSET".to_string(),
1201 ],
1202 );
1203 assert_eq!(
1204 env.get("KRANZ_AGENT_ENV_TEST_CRED").map(String::as_str),
1205 Some("cred-value"),
1206 "the passthrough-named var crosses"
1207 );
1208 assert_eq!(
1209 env.get("HOME").map(String::as_str),
1210 Some(scratch.path().to_string_lossy().as_ref()),
1211 "a passthrough entry naming `home` (any casing) must be refused"
1212 );
1213 assert!(
1214 !env.contains_key("KRANZ_BASE_SHA"),
1215 "no base sha pinned => no KRANZ_BASE_SHA key"
1216 );
1217 }
1218
1219 /// The cache seed admits only registry/git. Root Cargo credentials and
1220 /// credential-provider configuration stay outside the child namespace,
1221 /// while cache contents remain available for offline/egress-restricted
1222 /// contract gates.
1223 #[cfg(unix)]
1224 #[test]
1225 fn contract_cargo_home_contains_caches_but_no_credentials_or_config() {
1226 let source = tempfile::tempdir().unwrap();
1227 std::fs::create_dir_all(source.path().join("registry")).unwrap();
1228 std::fs::create_dir_all(source.path().join("git")).unwrap();
1229 std::fs::write(source.path().join("registry/cache-marker"), "registry").unwrap();
1230 std::fs::write(source.path().join("git/cache-marker"), "git").unwrap();
1231 for name in ["credentials.toml", "credentials", "config.toml", "config"] {
1232 std::fs::write(source.path().join(name), "operator-secret").unwrap();
1233 }
1234 let _guard = EnvTestGuard::engage(&[(
1235 "CARGO_HOME",
1236 source.path().to_str().expect("utf-8 temp path"),
1237 )]);
1238 let scratch = tempfile::tempdir().unwrap();
1239
1240 let env = contract_command_env(scratch.path(), None, &[]);
1241 let cargo_home = PathBuf::from(env.get("CARGO_HOME").expect("CARGO_HOME"));
1242
1243 for cache in ["registry", "git"] {
1244 assert_eq!(
1245 std::fs::read_to_string(cargo_home.join(cache).join("cache-marker")).unwrap(),
1246 cache
1247 );
1248 }
1249 for forbidden in ["credentials.toml", "credentials", "config.toml", "config"] {
1250 assert!(
1251 std::fs::symlink_metadata(cargo_home.join(forbidden)).is_err(),
1252 "cache-only Cargo home exposed {forbidden}"
1253 );
1254 }
1255 }
1256
1257 /// 12th-pass review (P1): below the plain-copy ceiling the seeded caches
1258 /// are per-env COPIES — real files, never symlinks into the operator's
1259 /// Cargo home — so a write through the child's cache (worker-authored
1260 /// contract code) cannot poison the operator's shared cache for later
1261 /// missions and engine builds. Credential-shaped entries are excluded
1262 /// explicitly, even ones PLANTED inside a cache dir.
1263 #[cfg(unix)]
1264 #[test]
1265 fn contract_cache_cow_seeds_real_copies_and_isolates_writes() {
1266 let source = tempfile::tempdir().unwrap();
1267 std::fs::create_dir_all(source.path().join("registry/cache")).unwrap();
1268 std::fs::create_dir_all(source.path().join("git/db")).unwrap();
1269 std::fs::write(source.path().join("registry/cache/crate-a.crate"), "aaaa").unwrap();
1270 std::fs::write(source.path().join("git/db/HEAD"), "ref: refs/heads/main").unwrap();
1271 // Credential-shaped files at the Cargo root AND planted inside the
1272 // cache dir itself — the copy must exclude both shapes explicitly.
1273 for name in ["credentials.toml", "credentials", "config.toml", "config"] {
1274 std::fs::write(source.path().join(name), "operator-secret").unwrap();
1275 std::fs::write(source.path().join("registry").join(name), "planted-secret").unwrap();
1276 }
1277 let _guard = EnvTestGuard::engage(&[(
1278 "CARGO_HOME",
1279 source.path().to_str().expect("utf-8 temp path"),
1280 )]);
1281 let scratch = tempfile::tempdir().unwrap();
1282
1283 let env = contract_command_env(scratch.path(), None, &[]);
1284 let cargo_home = PathBuf::from(env.get("CARGO_HOME").expect("CARGO_HOME"));
1285
1286 // Real copies, never links: the seeded cache dirs and their files
1287 // are owned by the child's home.
1288 for cache in ["registry", "git"] {
1289 let seeded = cargo_home.join(cache);
1290 assert!(
1291 !std::fs::symlink_metadata(&seeded)
1292 .unwrap()
1293 .file_type()
1294 .is_symlink(),
1295 "{cache} must be seeded as a real copy, not a symlink into the operator's cache"
1296 );
1297 }
1298 assert_eq!(
1299 std::fs::read_to_string(cargo_home.join("registry/cache/crate-a.crate")).unwrap(),
1300 "aaaa",
1301 "cache contents survive the seed"
1302 );
1303 assert_eq!(
1304 std::fs::read_to_string(cargo_home.join("git/db/HEAD")).unwrap(),
1305 "ref: refs/heads/main"
1306 );
1307
1308 // A write through the seeded cache — a new file AND an in-place
1309 // overwrite — never reaches the operator's source dirs (copy-on-write
1310 // tiers break the clone on write; the plain tier owns its bytes).
1311 std::fs::write(cargo_home.join("registry/cache/poisoned.crate"), "x").unwrap();
1312 std::fs::write(cargo_home.join("registry/cache/crate-a.crate"), "POISON").unwrap();
1313 assert!(
1314 !source.path().join("registry/cache/poisoned.crate").exists(),
1315 "a new file written through the seeded cache must not reach the operator's cache"
1316 );
1317 assert_eq!(
1318 std::fs::read_to_string(source.path().join("registry/cache/crate-a.crate")).unwrap(),
1319 "aaaa",
1320 "an overwrite through the seeded cache must not reach the operator's cache"
1321 );
1322
1323 // Credential-shaped files never appear — neither the operator's
1324 // root-level ones nor the ones planted inside the cache dir.
1325 for forbidden in ["credentials.toml", "credentials", "config.toml", "config"] {
1326 assert!(
1327 std::fs::symlink_metadata(cargo_home.join(forbidden)).is_err(),
1328 "cache-only Cargo home exposed {forbidden}"
1329 );
1330 assert!(
1331 std::fs::symlink_metadata(cargo_home.join("registry").join(forbidden)).is_err(),
1332 "the copy tier smuggled a planted {forbidden} out of the cache dir"
1333 );
1334 }
1335 }
1336
1337 /// Above the copy ceiling the seed links (the documented residual
1338 /// trade); at or below it the cache is always copied. The boundary is
1339 /// exercised through the early-exit size probe itself, so no giant
1340 /// fixture is needed (mirrors the validator snapshot's
1341 /// `pick_plain_or_fresh` split).
1342 #[test]
1343 fn contract_cache_cow_links_only_above_the_copy_ceiling() {
1344 let dir = tempfile::tempdir().unwrap();
1345 std::fs::write(dir.path().join("a.bin"), vec![0u8; 8]).unwrap();
1346 std::fs::create_dir_all(dir.path().join("nested")).unwrap();
1347 std::fs::write(dir.path().join("nested/b.bin"), vec![0u8; 8]).unwrap();
1348 let probe = crate::validator_snapshot::dir_size_exceeds;
1349 assert!(!probe(dir.path(), 16), "exactly at the limit: copies");
1350 assert!(probe(dir.path(), 15), "one byte over: links");
1351 assert!(probe(dir.path(), 0));
1352 assert!(
1353 !probe(dir.path(), CACHE_COPY_MAX_BYTES),
1354 "a small cache is always copied"
1355 );
1356 // The configured ceiling is the documented per-env-cadence one
1357 // (64 MiB — see the constant's CI/local measurement notes).
1358 assert_eq!(CACHE_COPY_MAX_BYTES, 64 * 1024 * 1024);
1359 }
1360}