kranz_engine/workspace_gate.rs
1//! Workspace bootstrap + readiness gate (design D-C/D-H, ticket
2//! `.kranz/tickets/workspace-bootstrap-preflight.md`) — gate helpers plus
3//! the block/lift policy. The gate's EXECUTION moved under the
4//! [`crate::workspace_provider`] seam (ticket `workspace-provider-seam`):
5//! `LocalWorktreeProvider::readiness` runs the phases below via
6//! [`run_gate_commands`], and the run loop drives provider.provision →
7//! provider.readiness (= this gate) → workers. This module remains the
8//! single owner of the phase shapes, the decision-summary prefixes the
9//! report and workspace endpoint derive outcome lines from, and the
10//! milestone block/lift machinery.
11//!
12//! Behavior (unchanged from the pre-seam gate): when a valid workspace
13//! contract exists, the contract's `bootstrap[]` (ordered, stop at first
14//! failure) and then `readiness[]` (every check runs; all must pass) run in
15//! the mission's execution cwd BEFORE the first worker/validator spawns —
16//! never start spend on a half-ready app. Without a contract the gate is a
17//! no-op and behavior is byte-identical to before (the seam still records
18//! `workspace.provisioned`; see the provider module).
19//!
20//! v1 scope notes (deliberate):
21//! - **Once per `run()` invocation, idempotent-by-contract.** Setup scripts
22//! are assumed re-runnable; a resume after crash re-runs them. Durable
23//! readiness state (skip-when-already-ready) is a later provider-seam
24//! concern, not v1's.
25//! - **Start/pass/fail land on the established `orchestrator.decision`
26//! audit channel** (with per-command results in the detail); failures
27//! block via the existing `milestone.blocked` machinery with owner
28//! `repo-setup`. The provider seam adds the `workspace.*` lifecycle events
29//! alongside (D-E).
30//! - **The contract is read from the live BASE branch** (merge.rs's
31//! `live_base_sha` idiom): base-branch-owned in BOTH isolation modes (a
32//! mission branch cannot weaken the contract that gates its own spend —
33//! checkout mode's working tree IS the mission branch mid-run), and a
34//! committed operator fix on the base branch is picked up on resume.
35//! - **Blocking is this gate's difference from `preflight.rs`.** The
36//! environment preflight is advisory; readiness is a gate (D-C). A block
37//! this gate emitted is lifted automatically once the gate passes again
38//! (its precondition is gone) — blocks from any other cause keep the
39//! normal operator/orchestrator unblock flow.
40
41use crate::command_exec::run_shell_command_with_code_cleared;
42use crate::error::{EngineError, Result};
43use crate::event_log::EventLog;
44use crate::events::{Event, EventKind};
45use crate::orchestrator::{first_incomplete, MissionEngine};
46use crate::types::{BlockContext, MilestoneStatus, MissionStatus};
47use std::collections::HashMap;
48
49/// `orchestrator.decision` summary prefixes the report and the workspace
50/// endpoint derive the bootstrap/readiness outcome lines from (a later
51/// run's outcome supersedes an earlier one, mirroring the preflight line).
52pub const BOOTSTRAP_SUMMARY_PREFIX: &str = "workspace bootstrap:";
53pub const READINESS_SUMMARY_PREFIX: &str = "workspace readiness:";
54
55/// Legacy block ownership marker; only events without BlockContext use this
56/// prefix for recovery. Golden-data skew/reset reasons (workspace_data.rs)
57/// retain the same display prefix.
58pub(crate) const GATE_REASON_PREFIX: &str = "workspace gate:";
59
60/// The `milestone.unblocked` reason [`WorkspaceGate::lift_gate_block`] emits
61/// when a previously failed gate passes — an ENGINE-owned unblock, not an
62/// operator decision. Legacy events use this exact text for classification;
63/// new events carry the workspace-gate BlockContext.
64pub(crate) const GATE_LIFT_REASON: &str = "workspace gate now passing: bootstrap and readiness ok";
65
66/// Outcome of one bootstrap command / readiness check.
67#[derive(Debug, Clone)]
68pub struct CommandOutcome {
69 /// 1-based position within its contract list (for "2/3" reporting).
70 pub(crate) ordinal: usize,
71 pub(crate) total: usize,
72 pub(crate) command: String,
73 /// `None` when the command never produced an exit code (spawn failure,
74 /// the timeout/group-kill path, or signal termination — the output tail
75 /// then says which).
76 pub(crate) code: Option<i32>,
77 pub(crate) output_tail: String,
78}
79
80impl CommandOutcome {
81 pub(crate) fn ok(&self) -> bool {
82 self.code == Some(0)
83 }
84
85 pub(crate) fn exit_phrase(&self) -> String {
86 match self.code {
87 Some(code) => format!("exit code {code}"),
88 None => "no exit code (spawn failure, timeout, or signal)".to_string(),
89 }
90 }
91}
92
93/// One gate phase's static shape (bootstrap or readiness).
94pub(crate) struct GatePhase<'a> {
95 /// "bootstrap command" / "readiness check" — the block-reason kind.
96 pub(crate) kind: &'static str,
97 /// "command" / "check" — singular, for "FAILED at {unit} i/n".
98 pub(crate) unit: &'static str,
99 /// "commands" / "checks" — for "running n {plural}" / "n/n {plural} ok".
100 pub(crate) plural: &'static str,
101 /// Decision-summary prefix the report/endpoint derive outcomes from.
102 pub(crate) prefix: &'static str,
103 pub(crate) commands: &'a [String],
104 /// bootstrap stops at the first failure; readiness runs every check.
105 pub(crate) stop_at_first_failure: bool,
106}
107
108impl MissionEngine {
109 /// Block the first incomplete milestone on a gate failure and return
110 /// `Blocked` (D-C: failures are Blocked, not preflight-warnings).
111 pub(crate) fn block_on_gate_failure(
112 &mut self,
113 kind: &str,
114 failed: &CommandOutcome,
115 ) -> Result<Option<MissionStatus>> {
116 self.block_with_gate_reason(gate_block_reason(kind, failed))
117 }
118
119 /// Block the first incomplete milestone with a pre-built gate-owned
120 /// reason (the `workspace gate:` prefix is what the pass path's
121 /// [`Self::lift_gate_block`] matches). Shared by the bootstrap/readiness
122 /// gate and the golden-data hooks (design D-D: skew and reset failures
123 /// block with their own actionable reason shapes) — and by provider-owned
124 /// failures (`workspace_remote::provider_block_reason`), whose DISTINCT
125 /// `workspace provider:` prefix deliberately keeps them OUT of the
126 /// gate's auto-lift path.
127 ///
128 /// When that milestone was never started (a fresh run blocked pre-loop),
129 /// start it first so the event stream keeps the invariant that
130 /// `milestone.started` precedes any block/unblock cycle — validation
131 /// reads `start_sha`, and a later unblock folds the milestone back to
132 /// Active, skipping the loop's Pending-only start emit.
133 pub(crate) fn block_with_gate_reason(
134 &mut self,
135 reason: String,
136 ) -> Result<Option<MissionStatus>> {
137 let Some(mi) = first_incomplete(&self.state) else {
138 // Nothing left to block (all milestones complete; only the final
139 // gate remained). Still loud and honest: the decisions above are
140 // on the log and the run errors instead of spending in a
141 // half-ready workspace.
142 return Err(EngineError::InvalidState(format!(
143 "{reason} — and no incomplete milestone remains to block; \
144 fix the workspace setup (owner: repo-setup) and re-run"
145 )));
146 };
147 if self.state.mission.milestones[mi].status == MilestoneStatus::Pending {
148 let start_sha = self.active_repo().head_sha()?;
149 let milestone_id = self.state.mission.milestones[mi].id.clone();
150 self.emit(EventKind::MilestoneStarted {
151 milestone_id,
152 start_sha,
153 })?;
154 }
155 let milestone_id = self.state.mission.milestones[mi].id.clone();
156 self.emit(EventKind::MilestoneBlocked {
157 block_context: Some(BlockContext::WORKSPACE_GATE),
158 milestone_id,
159 reason,
160 })?;
161 Ok(Some(MissionStatus::Blocked))
162 }
163
164 /// Lift a gate-owned block on the first incomplete milestone now that
165 /// the gate passes. Reads the (flushed) event log rather than ephemeral
166 /// memory, so it works across process restarts; a block from any other
167 /// cause (validation, grants, …) is left to the normal operator flow.
168 pub(crate) fn lift_gate_block(&mut self) -> Result<()> {
169 if self.state.mission.status != MissionStatus::Blocked {
170 return Ok(());
171 }
172 let Some(mi) = first_incomplete(&self.state) else {
173 return Ok(());
174 };
175 if self.state.mission.milestones[mi].status != MilestoneStatus::Blocked {
176 return Ok(());
177 }
178 let milestone_id = self.state.mission.milestones[mi].id.clone();
179 self.log.flush()?;
180 let events = EventLog::read_events(&self.paths.events_file())?;
181 if latest_block_is_gate_owned(&events, &milestone_id) {
182 self.emit(EventKind::MilestoneUnblocked {
183 block_context: Some(BlockContext::WORKSPACE_GATE),
184 milestone_id,
185 reason: GATE_LIFT_REASON.to_string(),
186 validator_guidance: None,
187 })?;
188 }
189 Ok(())
190 }
191}
192
193/// The scratch `HOME`/`TMPDIR`/`CARGO_HOME` every gate command of ONE
194/// mission run shares, under the mission's own writable `runs/` dir.
195///
196/// This used to be a `GateScratch` built per PHASE with a `Drop` that
197/// `remove_dir_all`'d it (follow-up review, M-1): bootstrap installed a
198/// toolchain into `/tmp/kranz-workspace-gate-<A>`, that directory was deleted
199/// when the phase returned, readiness ran against an empty `<B>`, and the
200/// mission blocked on a readiness failure the operator could not reproduce by
201/// hand. It also paid `cache_only_cargo_home`'s registry copy (bounded at 512
202/// MiB, the cost that filled the disk and killed mission m-533143) once per
203/// phase instead of once per run. One home per mission run fixes both:
204/// bootstrap output survives into readiness and into the data hooks the
205/// engine drives mid-run, and the copy happens once.
206///
207/// `runs/` is deliberate — it is the one part of the mission dir a sandboxed
208/// session may write ([`crate::sandbox`]'s `mission_write_denies` keeps the
209/// audit log, state snapshot and control inbox read-only), so a contained
210/// gate can use it.
211pub(crate) fn mission_gate_home(runtime_dir: &std::path::Path) -> std::path::PathBuf {
212 runtime_dir.join("runs").join("workspace-gate")
213}
214
215/// Create the gate home (owner-only) if it is not there yet. Idempotent: the
216/// first command of the run creates it, the rest reuse it.
217fn ensure_gate_home(root: &std::path::Path) {
218 let _ = std::fs::create_dir_all(root);
219 #[cfg(unix)]
220 {
221 use std::os::unix::fs::PermissionsExt as _;
222 let _ = std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700));
223 }
224}
225
226/// Remove the shared gate home at the END of the whole gate run (provider
227/// teardown — the last point any gate command can fire). Best-effort: a
228/// leftover is mission-scoped and goes with the mission dir.
229pub(crate) fn remove_gate_home(root: &std::path::Path) {
230 if root.as_os_str().is_empty() {
231 return;
232 }
233 let _ = std::fs::remove_dir_all(root);
234}
235
236/// Non-secret OPERATIONAL vars that ALWAYS cross into a gate env (follow-up
237/// review, M-2).
238///
239/// H4's cleared env dropped these along with the credentials, which broke
240/// ordinary bootstraps in ways that read as repo bugs: no `SSH_AUTH_SOCK`
241/// means `git clone git@…` and `git submodule update --init` fail, and no
242/// proxy/CA vars means `npm ci` / `pip install` / `cargo fetch` fail behind a
243/// corporate proxy or a TLS-inspecting CA. None of them is a credential: each
244/// is a LOCATION (a socket path, a proxy URL, a CA bundle path).
245///
246/// What is deliberately NOT here: anything that names a PROGRAM.
247/// `GIT_SSH_COMMAND`, `GIT_CONFIG_*` (beyond the one value this module sets
248/// itself, below), `GIT_EXTERNAL_DIFF`, `LD_PRELOAD` and their kin turn a
249/// later git invocation into arbitrary host execution, which is the same
250/// class of hole the `.git/config` write deny exists to close.
251pub(crate) const GATE_OPERATIONAL_ENV: &[&str] = &[
252 "SSH_AUTH_SOCK",
253 "HTTP_PROXY",
254 "HTTPS_PROXY",
255 "NO_PROXY",
256 "http_proxy",
257 "https_proxy",
258 "no_proxy",
259 "SSL_CERT_FILE",
260 "SSL_CERT_DIR",
261 "GIT_SSL_CAINFO",
262];
263
264/// The operator's real global git config, for `GIT_CONFIG_GLOBAL` (follow-up
265/// review, M-2). The gate's relocated `HOME` hides `~/.gitconfig`, so a
266/// bootstrap that commits fails with "Please tell me who you are" and
267/// `insteadOf` / `credential.helper` rewrites vanish. Naming the operator's
268/// file explicitly restores it for READ without un-relocating HOME (which
269/// would hand the contract the whole home directory back).
270///
271/// Resolution order matches git's own: `$GIT_CONFIG_GLOBAL`, then
272/// `$XDG_CONFIG_HOME/git/config`, then `~/.config/git/config`, then
273/// `~/.gitconfig`. The first that EXISTS wins; nothing is set when none does.
274fn operator_global_gitconfig() -> Option<std::path::PathBuf> {
275 let mut candidates: Vec<std::path::PathBuf> = Vec::new();
276 if let Some(explicit) = std::env::var_os("GIT_CONFIG_GLOBAL").filter(|v| !v.is_empty()) {
277 candidates.push(std::path::PathBuf::from(explicit));
278 }
279 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
280 candidates.push(std::path::PathBuf::from(xdg).join("git").join("config"));
281 }
282 if let Some(home) = crate::agent_env::operator_home() {
283 candidates.push(home.join(".config").join("git").join("config"));
284 candidates.push(home.join(".gitconfig"));
285 }
286 candidates.into_iter().find(|path| path.is_file())
287}
288
289/// The secret names that actually cross into a gate env: the intersection of
290/// what the REPO declares and what the OPERATOR allowed (follow-up review,
291/// H-6).
292///
293/// H4 narrowed the gate env from "every ambient credential" to "every
294/// credential the contract's `secrets[]` names" — which against a CHOOSING
295/// attacker is the same set. `.kranz/workspace.json` is ordinary repo content
296/// that runs at mission start before any agent spawns, and its `secrets[]`
297/// validation is shape-only (`^[A-Z][A-Z0-9_]*$`), so `GH_TOKEN`,
298/// `AWS_SECRET_ACCESS_KEY`, `ANTHROPIC_API_KEY` and `KRANZ_TOKEN` all match.
299/// A hostile clone could therefore name the engine's credentials and exfil
300/// them from an unsandboxed bootstrap command.
301///
302/// The operator-owned channel this duplicates already had the guard: mission
303/// config's `contractEnvPassthrough` is refused from the project layer
304/// (`config.rs`'s `PROJECT_LAYER_REFUSED`) precisely because "it copies named
305/// ambient credentials verbatim into contract-command environments". Two-party
306/// consent restores it: the repo says which names its commands NEED, the
307/// operator says which names may LEAVE the host, and only the intersection
308/// crosses. A declared name the operator did not allow is refused loudly, by
309/// name, with the config key that would admit it.
310///
311/// Matching is case-INSENSITIVE, the same rule
312/// [`crate::agent_env::contract_command_env`] applies to managed keys, so a
313/// Windows casing difference cannot slip a name past the operator's list.
314pub(crate) fn two_party_secrets(declared: &[String], operator_allows: &[String]) -> Vec<String> {
315 let mut allowed = Vec::new();
316 for name in declared {
317 let name = name.trim();
318 if name.is_empty() {
319 continue;
320 }
321 if operator_allows
322 .iter()
323 .any(|allowed| allowed.trim().eq_ignore_ascii_case(name))
324 {
325 allowed.push(name.to_string());
326 } else {
327 tracing::warn!(
328 key = name,
329 config_key = "contractEnvPassthrough",
330 "workspace contract secrets[] entry refused: the repo declared it but the \
331 operator's contractEnvPassthrough does not list it, so it does not cross \
332 into the gate command environment"
333 );
334 }
335 }
336 allowed
337}
338
339/// The COMPLETE environment one workspace bootstrap / readiness / data-hook /
340/// disk-prune command runs with (2026-09-01 adversarial audit, H4; follow-up
341/// review H-6, M-1, M-2, M-3).
342///
343/// These commands used to spawn with `clear_env = false`: the workspace
344/// contract's own doc comment justified the ambient environment by pointing
345/// at the contract's declared `secrets[]` list, but nothing filtered to that
346/// list — `contract.secrets` was referenced only by the remote provider. So
347/// `.kranz/workspace.json`, which runs at mission start before any agent
348/// spawns and is ordinary repo content a merged worker commit can edit,
349/// executed host commands with every ambient credential the engine holds.
350/// That was strictly weaker containment than the validation-contract path in
351/// the same binary.
352///
353/// The env is built the way [`crate::agent_env::contract_command_env`] builds
354/// a contract command's, in this order (later wins):
355///
356/// 1. cleared, with `HOME`/`TMPDIR`/`CARGO_HOME` relocated to the mission's
357/// ONE shared gate home ([`mission_gate_home`], M-1) and `KRANZ_BASE_SHA`
358/// pinned, plus exactly the ambient vars BOTH parties named
359/// ([`two_party_secrets`], H-6);
360/// 2. the fixed operational allowlist ([`GATE_OPERATIONAL_ENV`], M-2) and
361/// `GIT_CONFIG_GLOBAL`. These are set AFTER the secrets deliberately: a
362/// repo-declared secret named `HTTPS_PROXY` or `GIT_CONFIG_GLOBAL` must not
363/// be able to point the gate's git or TLS at somewhere of the repo's
364/// choosing;
365/// 3. the handle env last, so a provider-supplied value (the
366/// container/remote providers' endpoints) still reaches the command.
367///
368/// Known boundary, documented rather than papered over: `~/.ssh` does NOT
369/// cross. The relocated HOME hides it and no key file is copied, so ssh
370/// authentication for gate commands works through the FORWARDED AGENT
371/// (`SSH_AUTH_SOCK`) only — key-file auth without an agent is unsupported
372/// here, because admitting it means either handing the contract the operator's
373/// private keys or letting it name an ssh program.
374///
375/// Remaining gap, named rather than papered over: these commands are still
376/// not SANDBOX-WRAPPED. Every other engine-run command goes through
377/// `command_exec::run_shell_command_sandboxed` with the mission's resolved
378/// `GateSandbox`, but the [`crate::workspace_provider::WorkspaceProvider`]
379/// seam carries neither the mission's sandbox config nor its mission dir,
380/// and both are needed to resolve a target. Closing it means widening
381/// `WorkspaceHandle`, which every provider constructs. The credential half
382/// of H4 — the half the audit confirmed — is closed here.
383pub(crate) fn gate_command_env(
384 policy: &crate::workspace_provider::GateEnvPolicy,
385 handle_env: &HashMap<String, String>,
386 contract: Option<&crate::workspace_contract::WorkspaceContract>,
387) -> HashMap<String, String> {
388 ensure_gate_home(&policy.home);
389 let declared = contract.map(|c| c.secrets.as_slice()).unwrap_or(&[]);
390 let secrets = two_party_secrets(declared, &policy.passthrough);
391 let mut env = crate::agent_env::contract_command_env(
392 &policy.home,
393 handle_env.get("KRANZ_BASE_SHA").map(String::as_str),
394 &secrets,
395 );
396 for name in GATE_OPERATIONAL_ENV {
397 if let Some(value) = std::env::var_os(name).filter(|value| !value.is_empty()) {
398 env.insert((*name).to_string(), value.to_string_lossy().into_owned());
399 }
400 }
401 if let Some(gitconfig) = operator_global_gitconfig() {
402 env.insert(
403 "GIT_CONFIG_GLOBAL".to_string(),
404 gitconfig.display().to_string(),
405 );
406 }
407 for (key, value) in handle_env {
408 env.insert(key.clone(), value.clone());
409 }
410 env
411}
412
413/// Run one phase's command lines in the workspace cwd — bounded,
414/// process-tree-killed, output-tailed (the shared `command_exec` runner
415/// used by validation-contract commands), with the CLEARED gate env
416/// [`gate_command_env`] builds.
417pub(crate) async fn run_gate_commands(
418 cwd: &std::path::Path,
419 phase: &GatePhase<'_>,
420 policy: &crate::workspace_provider::GateEnvPolicy,
421 handle_env: &HashMap<String, String>,
422 contract: Option<&crate::workspace_contract::WorkspaceContract>,
423) -> Vec<CommandOutcome> {
424 let env = gate_command_env(policy, handle_env, contract);
425 let total = phase.commands.len();
426 let mut outcomes = Vec::with_capacity(total);
427 for (i, command) in phase.commands.iter().enumerate() {
428 let (code, output_tail) = run_shell_command_with_code_cleared(cwd, command, &env).await;
429 let outcome = CommandOutcome {
430 ordinal: i + 1,
431 total,
432 command: command.clone(),
433 code,
434 output_tail,
435 };
436 let failed = !outcome.ok();
437 outcomes.push(outcome);
438 if failed && phase.stop_at_first_failure {
439 break;
440 }
441 }
442 outcomes
443}
444
445/// Per-command lines for the decision's `detail` (the audit trail): one
446/// status line per command that ran, plus the failing command's output
447/// tail. Bounded — tails are already capped by the runner.
448pub(crate) fn outcomes_detail(kind: &str, outcomes: &[CommandOutcome]) -> String {
449 use std::fmt::Write as _;
450 let mut detail = String::new();
451 for o in outcomes {
452 let verdict = if o.ok() { "ok" } else { "FAILED" };
453 let _ = writeln!(
454 detail,
455 "{kind} {}/{} `{}` → {verdict} ({})",
456 o.ordinal,
457 o.total,
458 o.command,
459 o.exit_phrase()
460 );
461 }
462 if let Some(failed) = outcomes.iter().find(|o| !o.ok()) {
463 let tail = failed.output_tail.trim();
464 if !tail.is_empty() {
465 let _ = write!(detail, "\noutput tail:\n{tail}");
466 }
467 }
468 detail
469}
470
471/// The `milestone.blocked` reason for a failed command/check: names the
472/// failing command, its ordinal, its exit code, the repo-setup owner, and a
473/// scrubbed, bounded output tail. Credential-scrubbed here AND again at
474/// event-append (defense in depth) — a bootstrap log line must never put a
475/// registry token into events.jsonl.
476pub(crate) fn gate_block_reason(kind: &str, failed: &CommandOutcome) -> String {
477 crate::scrub::scrub(&format!(
478 "{GATE_REASON_PREFIX} {kind} {}/{} failed (owner: repo-setup): `{}` {}: {}",
479 failed.ordinal,
480 failed.total,
481 failed.command,
482 failed.exit_phrase(),
483 failed.output_tail.trim(),
484 ))
485}
486
487/// Whether `milestone_id`'s LATEST block/unblock event is a workspace-gate
488/// block not yet lifted — the pass path unblocks exactly those.
489fn latest_block_is_gate_owned(events: &[Event], milestone_id: &str) -> bool {
490 events.iter().rev().find_map(|event| match &event.kind {
491 EventKind::MilestoneBlocked {
492 milestone_id: id,
493 reason,
494 block_context,
495 } if id == milestone_id => Some(match block_context {
496 Some(context) => context.is_workspace_gate(),
497 None => reason.starts_with(GATE_REASON_PREFIX),
498 }),
499 EventKind::MilestoneUnblocked {
500 milestone_id: id, ..
501 } if id == milestone_id => Some(false),
502 _ => None,
503 }) == Some(true)
504}
505
506// ---------------------------------------------------------------------------
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 fn outcome(
513 ordinal: usize,
514 total: usize,
515 command: &str,
516 code: Option<i32>,
517 tail: &str,
518 ) -> CommandOutcome {
519 CommandOutcome {
520 ordinal,
521 total,
522 command: command.to_string(),
523 code,
524 output_tail: tail.to_string(),
525 }
526 }
527
528 fn ev(seq: u64, kind: EventKind) -> Event {
529 Event {
530 seq,
531 ts: chrono::Utc::now(),
532 mission_id: "m-test".to_string(),
533 kind,
534 }
535 }
536
537 fn blocked(seq: u64, milestone_id: &str, reason: &str) -> Event {
538 ev(
539 seq,
540 EventKind::MilestoneBlocked {
541 block_context: None,
542 milestone_id: milestone_id.to_string(),
543 reason: reason.to_string(),
544 },
545 )
546 }
547
548 fn policy(
549 home: &std::path::Path,
550 passthrough: &[&str],
551 ) -> crate::workspace_provider::GateEnvPolicy {
552 crate::workspace_provider::GateEnvPolicy {
553 home: home.to_path_buf(),
554 passthrough: passthrough.iter().map(|s| s.to_string()).collect(),
555 }
556 }
557
558 fn contract_declaring(secrets: &[&str]) -> crate::workspace_contract::WorkspaceContract {
559 let json = format!(
560 r#"{{"schemaVersion": 1, "readiness": ["true"], "secrets": {}}}"#,
561 serde_json::to_string(secrets).unwrap()
562 );
563 crate::workspace_contract::parse_workspace_contract(json.as_bytes()).expect("contract")
564 }
565
566 /// H-6 (follow-up review): H4 narrowed the gate env from "every ambient
567 /// credential" to "every credential the CONTRACT names" — which against a
568 /// choosing attacker is the same set, because `.kranz/workspace.json` is
569 /// repo content that runs at mission start before any agent spawns and
570 /// its `secrets[]` validation is shape-only. A secret now needs TWO
571 /// parties: the repo declares the need, the operator's
572 /// `contractEnvPassthrough` grants it.
573 #[test]
574 fn gate_env_crosses_a_secret_only_with_both_repo_and_operator_consent() {
575 let home = tempfile::tempdir().expect("tempdir");
576 let _guard = crate::agent_env::EnvTestGuard::engage(&[("GH_TOKEN", "ghp-operator-secret")]);
577 let contract = contract_declaring(&["GH_TOKEN"]);
578 let handle_env = HashMap::new();
579
580 // The repo asks and the operator has granted NOTHING: refused.
581 let env = gate_command_env(&policy(home.path(), &[]), &handle_env, Some(&contract));
582 assert!(
583 !env.contains_key("GH_TOKEN"),
584 "a repo-chosen credential must not cross on the repo's say-so alone: {env:?}"
585 );
586
587 // The operator names it too: it crosses.
588 let env = gate_command_env(
589 &policy(home.path(), &["GH_TOKEN"]),
590 &handle_env,
591 Some(&contract),
592 );
593 assert_eq!(
594 env.get("GH_TOKEN").map(String::as_str),
595 Some("ghp-operator-secret"),
596 "both parties consented, so the named credential crosses"
597 );
598
599 // The operator's grant alone is not enough either — the contract has
600 // to have declared the need, or the gate env stays narrow.
601 let env = gate_command_env(&policy(home.path(), &["GH_TOKEN"]), &handle_env, None);
602 assert!(
603 !env.contains_key("GH_TOKEN"),
604 "an operator grant does not push a credential into a contract that never asked"
605 );
606 }
607
608 /// The intersection is case-insensitive (the rule `contract_command_env`
609 /// already applies to managed keys), so a Windows casing difference
610 /// cannot slip a name past the operator's list in either direction.
611 #[test]
612 fn two_party_secrets_intersects_case_insensitively_and_drops_the_rest() {
613 let declared = ["GH_TOKEN", "AWS_SECRET_ACCESS_KEY", "KRANZ_TOKEN", " "]
614 .map(str::to_string)
615 .to_vec();
616 let allowed = two_party_secrets(&declared, &["gh_token".to_string()]);
617 assert_eq!(allowed, vec!["GH_TOKEN".to_string()]);
618 assert!(two_party_secrets(&declared, &[]).is_empty());
619 }
620
621 /// M-2 (follow-up review): H4's cleared env also dropped the non-secret
622 /// OPERATIONAL vars, which breaks ordinary bootstraps as if they were
623 /// repo bugs — no `SSH_AUTH_SOCK` means `git clone git@…` fails, no
624 /// proxy/CA vars means `npm ci` fails behind a corporate proxy. These are
625 /// locations, not credentials, and cross unconditionally. Anything that
626 /// names a PROGRAM does not.
627 #[test]
628 fn gate_env_always_carries_the_operational_allowlist_but_never_a_program_var() {
629 let home = tempfile::tempdir().expect("tempdir");
630 let _guard = crate::agent_env::EnvTestGuard::engage(&[
631 ("SSH_AUTH_SOCK", "/tmp/ssh-agent.sock"),
632 ("HTTPS_PROXY", "http://proxy.corp.example:3128"),
633 ("NO_PROXY", "localhost"),
634 ("SSL_CERT_FILE", "/etc/ssl/corp-bundle.pem"),
635 ("GIT_SSH_COMMAND", "/tmp/evil-ssh"),
636 ]);
637
638 let env = gate_command_env(&policy(home.path(), &[]), &HashMap::new(), None);
639
640 assert_eq!(
641 env.get("SSH_AUTH_SOCK").map(String::as_str),
642 Some("/tmp/ssh-agent.sock")
643 );
644 assert_eq!(
645 env.get("HTTPS_PROXY").map(String::as_str),
646 Some("http://proxy.corp.example:3128")
647 );
648 assert_eq!(env.get("NO_PROXY").map(String::as_str), Some("localhost"));
649 assert_eq!(
650 env.get("SSL_CERT_FILE").map(String::as_str),
651 Some("/etc/ssl/corp-bundle.pem")
652 );
653 assert!(
654 !env.contains_key("GIT_SSH_COMMAND"),
655 "a var that names a PROGRAM turns a later git call into host execution: {env:?}"
656 );
657 // Unset operational names are simply absent — never an empty value a
658 // tool would read as "no proxy configured differently".
659 assert!(!env.contains_key("GIT_SSL_CAINFO"));
660 }
661
662 /// The gate's relocated HOME hides `~/.gitconfig`, so `git commit` in a
663 /// bootstrap fails "Please tell me who you are" and `insteadOf` /
664 /// `credential.helper` rewrites vanish (M-2). `GIT_CONFIG_GLOBAL` names
665 /// the operator's real file for READ — unconditionally, with no contract
666 /// declaration and no operator passthrough entry involved.
667 #[test]
668 fn gate_env_points_git_at_the_operator_global_config() {
669 let home = tempfile::tempdir().expect("tempdir");
670 let operator = tempfile::tempdir().expect("tempdir");
671 let gitconfig = operator.path().join("gitconfig");
672 std::fs::write(&gitconfig, "[user]\n\tname = Operator\n").expect("write");
673 let _guard = crate::agent_env::EnvTestGuard::engage(&[
674 ("GIT_CONFIG_GLOBAL", gitconfig.to_str().unwrap()),
675 ("HOME", operator.path().to_str().unwrap()),
676 ]);
677
678 let env = gate_command_env(&policy(home.path(), &[]), &HashMap::new(), None);
679 assert_eq!(
680 env.get("GIT_CONFIG_GLOBAL").map(String::as_str),
681 Some(gitconfig.to_str().unwrap()),
682 "git reads the operator's own global config: {env:?}"
683 );
684 assert_ne!(
685 env.get("HOME").map(String::as_str),
686 Some(operator.path().to_str().unwrap()),
687 "naming the config file must not un-relocate HOME"
688 );
689 }
690
691 /// M-1 (follow-up review): the gate home is per MISSION RUN and lives
692 /// under the mission's own writable `runs/` dir, so bootstrap's output
693 /// survives into readiness and into the data hooks the engine drives
694 /// mid-run. It used to be a fresh temp dir per phase with a `Drop` that
695 /// deleted it.
696 #[test]
697 fn the_gate_home_is_one_stable_dir_under_the_mission_runs_dir() {
698 let mission = tempfile::tempdir().expect("tempdir");
699 let home = mission_gate_home(mission.path());
700 assert_eq!(home, mission.path().join("runs").join("workspace-gate"));
701 assert_eq!(
702 home,
703 mission_gate_home(mission.path()),
704 "the same mission resolves to the same home on every phase"
705 );
706
707 // Building an env creates it; a second build reuses what is there.
708 let env = gate_command_env(&policy(&home, &[]), &HashMap::new(), None);
709 assert_eq!(env.get("HOME").map(String::as_str), home.to_str());
710 std::fs::write(home.join("installed-by-bootstrap"), "x").expect("write");
711 let _ = gate_command_env(&policy(&home, &[]), &HashMap::new(), None);
712 assert!(
713 home.join("installed-by-bootstrap").is_file(),
714 "a later phase must find what an earlier phase installed"
715 );
716
717 remove_gate_home(&home);
718 assert!(!home.exists(), "teardown removes the shared home");
719 }
720
721 #[test]
722 fn gate_block_reason_names_command_exit_owner_and_scrubs_the_tail() {
723 let failed = outcome(
724 2,
725 3,
726 "npm ci",
727 Some(42),
728 "registry auth token sk-ant-api03-a1b2c3d4e5f6 failed",
729 );
730 let reason = gate_block_reason("bootstrap command", &failed);
731 assert!(reason.starts_with("workspace gate:"), "{reason}");
732 assert!(reason.contains("bootstrap command 2/3 failed"), "{reason}");
733 assert!(reason.contains("owner: repo-setup"), "{reason}");
734 assert!(reason.contains("`npm ci`"), "{reason}");
735 assert!(reason.contains("exit code 42"), "{reason}");
736 assert!(
737 !reason.contains("sk-ant-api03-a1b2c3d4e5f6"),
738 "the output tail must be scrubbed: {reason}"
739 );
740 assert!(reason.contains("[REDACTED]"), "{reason}");
741 }
742
743 #[test]
744 fn gate_block_reason_without_exit_code_says_so() {
745 let failed = outcome(1, 1, "./setup.sh", None, "timed out after 600s");
746 let reason = gate_block_reason("readiness check", &failed);
747 assert!(reason.contains("readiness check 1/1 failed"), "{reason}");
748 assert!(reason.contains("no exit code"), "{reason}");
749 assert!(reason.contains("timed out after 600s"), "{reason}");
750 }
751
752 #[test]
753 fn outcomes_detail_lists_every_command_that_ran_plus_the_failing_tail() {
754 let outcomes = vec![
755 outcome(1, 3, "cargo fetch", Some(0), ""),
756 outcome(2, 3, "npm ci", Some(1), "npm ERR! 401"),
757 ];
758 let detail = outcomes_detail("bootstrap command", &outcomes);
759 assert!(
760 detail.contains("bootstrap command 1/3 `cargo fetch` → ok (exit code 0)"),
761 "{detail}"
762 );
763 assert!(
764 detail.contains("bootstrap command 2/3 `npm ci` → FAILED (exit code 1)"),
765 "{detail}"
766 );
767 assert!(detail.contains("output tail:\nnpm ERR! 401"), "{detail}");
768 // Stop-at-first-failure: command 3 never ran, so it is not listed.
769 assert!(!detail.contains("3/3"), "{detail}");
770 }
771
772 #[test]
773 fn latest_block_is_gate_owned_only_for_an_unlifted_gate_block() {
774 let gate_reason = "workspace gate: bootstrap command 1/1 failed (owner: repo-setup): `x` exit code 1: boom";
775 let other_reason = "validator command denied: `rm -rf /` — deny-default";
776
777 // Gate block, never lifted ⇒ owned.
778 let events = vec![blocked(1, "ms-1", gate_reason)];
779 assert!(latest_block_is_gate_owned(&events, "ms-1"));
780
781 // Gate block later unblocked ⇒ no longer owned.
782 let events = vec![
783 blocked(1, "ms-1", gate_reason),
784 ev(
785 2,
786 EventKind::MilestoneUnblocked {
787 block_context: None,
788 milestone_id: "ms-1".to_string(),
789 reason: "workspace gate now passing".to_string(),
790 validator_guidance: None,
791 },
792 ),
793 ];
794 assert!(!latest_block_is_gate_owned(&events, "ms-1"));
795
796 // A block from another cause is never gate-owned (stays on the
797 // normal operator/orchestrator unblock flow).
798 let events = vec![blocked(1, "ms-1", other_reason)];
799 assert!(!latest_block_is_gate_owned(&events, "ms-1"));
800
801 // Blocks on OTHER milestones do not count; no block at all either.
802 let events = vec![blocked(1, "ms-2", gate_reason)];
803 assert!(!latest_block_is_gate_owned(&events, "ms-1"));
804 assert!(!latest_block_is_gate_owned(&[], "ms-1"));
805
806 // Latest event wins: gate block → unblock → non-gate block ⇒ not owned.
807 let events = vec![
808 blocked(1, "ms-1", gate_reason),
809 ev(
810 2,
811 EventKind::MilestoneUnblocked {
812 block_context: None,
813 milestone_id: "ms-1".to_string(),
814 reason: "workspace gate now passing".to_string(),
815 validator_guidance: None,
816 },
817 ),
818 blocked(3, "ms-1", other_reason),
819 ];
820 assert!(!latest_block_is_gate_owned(&events, "ms-1"));
821 }
822}
823
824#[cfg(test)]
825#[path = "block_context_tests.rs"]
826mod block_context_tests;