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}