Skip to main content

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}