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