1use crate::error::{EngineError, Result};
8use crate::gate::{ArtefactRef, GateKind, GateOutcome, GateReport};
9use crate::git_ops::GitRepo;
10use crate::paths::MissionPaths;
11use crate::types::{Assertion, AssertionCheck, MissionConfig, SandboxEnforce, SandboxProvider};
12use cap_std::fs::Dir;
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeSet;
15use std::io::Write as _;
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::{Arc, Mutex};
19use std::time::{Duration, Instant};
20
21pub mod pair;
22
23const MAX_FILE_BYTES: usize = 64 * 1024;
24const MAX_TOTAL_BYTES: usize = 512 * 1024;
25const MAX_CONTROLS: usize = 8;
26const TOTAL_BUDGET: Duration = Duration::from_secs(300);
27static CONTROL_EXECUTIONS: Mutex<()> = Mutex::new(());
28
29#[derive(Default)]
32pub(crate) struct CancellationGuard(Arc<AtomicBool>);
33
34impl CancellationGuard {
35 pub(crate) fn flag(&self) -> Arc<AtomicBool> {
36 self.0.clone()
37 }
38}
39
40impl Drop for CancellationGuard {
41 fn drop(&mut self) {
42 self.0.store(true, Ordering::Release);
43 }
44}
45
46fn check_budget(deadline: Instant, cancelled: &AtomicBool) -> Result<()> {
47 if cancelled.load(Ordering::Acquire) {
48 return Err(invalid("control evaluation cancelled"));
49 }
50 if Instant::now() >= deadline {
51 return Err(invalid("control evaluation budget exhausted"));
52 }
53 Ok(())
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase", deny_unknown_fields)]
58pub struct ControlFile {
59 pub path: String,
60 pub content: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase", deny_unknown_fields)]
65pub struct ControlSpec {
66 pub checker_files: Vec<ControlFile>,
67 pub valid_files: Vec<ControlFile>,
68 pub defective_files: Vec<ControlFile>,
69 pub expected_failure: String,
70 #[serde(default = "default_timeout")]
71 pub timeout_seconds: u64,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub baseline_pair: Option<pair::PairSpec>,
74}
75
76fn default_timeout() -> u64 {
77 60
78}
79
80fn invalid(message: impl Into<String>) -> EngineError {
81 EngineError::InvalidState(format!("negative control: {}", message.into()))
82}
83
84fn file_paths(files: &[ControlFile]) -> Result<BTreeSet<String>> {
85 if files.is_empty() || files.len() > 16 {
86 return Err(invalid("each file group must contain 1–16 files"));
87 }
88 let mut paths = BTreeSet::new();
89 for file in files {
90 let path = &file.path;
91 if path.is_empty()
92 || path.len() > 240
93 || path.contains(['\\', ':', '\0'])
94 || path.split('/').any(|part| {
95 part.is_empty()
96 || matches!(part, "." | "..")
97 || part.eq_ignore_ascii_case(".git")
98 || part.eq_ignore_ascii_case(".kranz")
99 || part.ends_with(['.', ' '])
100 })
101 || file.content.len() > MAX_FILE_BYTES
102 || !paths.insert(path.to_lowercase())
103 {
104 return Err(invalid(format!(
105 "unsafe, duplicate, or oversized file {path:?}"
106 )));
107 }
108 }
109 Ok(paths)
110}
111
112pub fn validate(assertions: &[Assertion]) -> Result<()> {
113 let selected: Vec<_> = assertions
114 .iter()
115 .filter(|a| a.negative_control.is_some())
116 .collect();
117 if selected.len() > MAX_CONTROLS {
118 return Err(invalid("at most eight assertions may carry controls"));
119 }
120 for assertion in selected {
121 let spec = assertion
122 .negative_control
123 .as_ref()
124 .expect("selected control");
125 if assertion.check != AssertionCheck::Command
126 || assertion
127 .command
128 .as_ref()
129 .is_none_or(|command| command.trim().is_empty())
130 {
131 return Err(invalid("controls require a nonempty command assertion"));
132 }
133 if !(1..=180).contains(&spec.timeout_seconds)
134 || spec.expected_failure.trim().is_empty()
135 || spec.expected_failure.len() > 128
136 {
137 return Err(invalid(
138 "timeout must be 1–180 seconds and expectedFailure must name a defect",
139 ));
140 }
141 let checker = file_paths(&spec.checker_files)?;
142 if let Some(pair) = &spec.baseline_pair {
143 pair.validate()?;
144 }
145 let valid = file_paths(&spec.valid_files)?;
146 let defective = file_paths(&spec.defective_files)?;
147 let exact_paths = |files: &[ControlFile]| {
148 files
149 .iter()
150 .map(|file| file.path.clone())
151 .collect::<BTreeSet<_>>()
152 };
153 if valid != defective
154 || exact_paths(&spec.valid_files) != exact_paths(&spec.defective_files)
155 || !checker.is_disjoint(&valid)
156 {
157 return Err(invalid(
158 "valid/defective paths must match and exclude checking inputs",
159 ));
160 }
161 let paths: Vec<_> = checker.union(&valid).collect();
162 if paths.iter().any(|a| {
163 paths
164 .iter()
165 .any(|b| a != b && b.starts_with(&format!("{a}/")))
166 }) {
167 return Err(invalid("file paths must not contain one another"));
168 }
169 if !spec.valid_files.iter().any(|valid| {
170 spec.defective_files
171 .iter()
172 .any(|defect| defect.path == valid.path && defect.content != valid.content)
173 }) {
174 return Err(invalid(
175 "the defective control must change at least one file",
176 ));
177 }
178 if spec
179 .checker_files
180 .iter()
181 .chain(&spec.valid_files)
182 .chain(&spec.defective_files)
183 .map(|file| file.content.len())
184 .sum::<usize>()
185 > MAX_TOTAL_BYTES
186 {
187 return Err(invalid("control contents exceed 512 KiB"));
188 }
189 }
190 Ok(())
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(rename_all = "kebab-case")]
195pub enum ControlStatus {
196 Verified,
197 NotRejected,
198 Inconclusive,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase", deny_unknown_fields)]
207pub struct CheckReceipt {
208 pub checks_run: u64,
209 pub outcome: CheckOutcome,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub failure_id: Option<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub diagnostic: Option<String>,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "lowercase")]
218pub enum CheckOutcome {
219 Passed,
220 Failed,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(rename_all = "camelCase")]
225pub struct CaseEvidence {
226 pub exit_code: Option<i32>,
227 pub receipt: Option<CheckReceipt>,
228 pub output_tail: String,
229 pub elapsed_ms: u128,
230 pub environment_names: Vec<String>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub binding: Option<pair::CaseBinding>,
233}
234
235#[derive(Debug, Serialize, Deserialize)]
236#[serde(rename_all = "camelCase")]
237pub struct ControlEvidence {
238 pub version: u32,
239 pub assertion_id: String,
240 pub assertion_sha256: String,
241 pub checker_sha256: String,
242 pub control_sha256: String,
243 pub source_revision: String,
244 pub recorded_at: String,
245 pub platform: String,
246 pub containment: String,
247 pub environment_names: Vec<String>,
248 pub status: ControlStatus,
249 pub detail: String,
250 pub valid: Option<CaseEvidence>,
251 pub defective: Option<CaseEvidence>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub baseline_pair: Option<pair::PairEvidence>,
254}
255
256fn identity(value: &impl Serialize) -> String {
257 crate::standards_waiver::sha256_hex(&serde_json::to_vec(value).expect("serializable control"))
258}
259
260fn classify(valid: &CaseEvidence, defective: &CaseEvidence, expected: &str) -> ControlStatus {
261 let passed = |case: &CaseEvidence| {
262 case.exit_code == Some(0)
263 && case.receipt.as_ref().is_some_and(|r| {
264 r.checks_run > 0
265 && r.outcome == CheckOutcome::Passed
266 && r.failure_id.is_none()
267 && r.diagnostic.is_none()
268 })
269 };
270 if !passed(valid) {
271 return ControlStatus::Inconclusive;
272 }
273 if passed(defective) {
274 return ControlStatus::NotRejected;
275 }
276 if defective.exit_code.is_some_and(|code| code != 0)
277 && defective.receipt.as_ref().is_some_and(|r| {
278 r.checks_run > 0
279 && r.outcome == CheckOutcome::Failed
280 && r.failure_id.as_deref() == Some(expected)
281 })
282 {
283 ControlStatus::Verified
284 } else {
285 ControlStatus::Inconclusive
286 }
287}
288
289struct ScratchRoot(PathBuf);
290impl ScratchRoot {
291 fn create() -> Result<Self> {
292 let path = std::env::temp_dir().join(format!("kranz-controls-{}", uuid::Uuid::new_v4()));
293 #[allow(unused_mut)] let mut builder = std::fs::DirBuilder::new();
295 #[cfg(unix)]
296 {
297 use std::os::unix::fs::DirBuilderExt as _;
298 builder.mode(0o700);
299 }
300 builder.create(&path)?;
301 Ok(Self(std::fs::canonicalize(path)?))
302 }
303}
304impl Drop for ScratchRoot {
305 fn drop(&mut self) {
306 let _ = std::fs::remove_dir_all(&self.0);
307 }
308}
309
310fn parent_under(root: &Path, path: &str, create: bool) -> Result<(Dir, String)> {
311 let mut dir = Dir::open_ambient_dir(root, cap_std::ambient_authority())?;
312 let mut walked = root.to_path_buf();
313 let parts: Vec<_> = path.split('/').collect();
314 for part in &parts[..parts.len() - 1] {
315 walked.push(part);
316 dir = crate::paths::open_real_subdir(&dir, part, &walked, create)?;
317 }
318 Ok((dir, parts[parts.len() - 1].to_string()))
319}
320
321fn check_inputs(root: &Path, files: &[ControlFile]) -> Result<()> {
322 for file in files {
323 let (dir, name) = parent_under(root, &file.path, false)?;
324 if crate::paths::read_regular_file_under(&dir, Path::new(&name), MAX_FILE_BYTES as u64)?
325 != file.content
326 {
327 return Err(invalid(format!(
328 "approved checking input {} changed or is unavailable",
329 file.path
330 )));
331 }
332 }
333 Ok(())
334}
335
336fn apply_files(root: &Path, files: &[ControlFile]) -> Result<()> {
337 for file in files {
338 let (dir, name) = parent_under(root, &file.path, true)?;
339 match dir.symlink_metadata(&name) {
340 Ok(metadata) if metadata.is_file() => dir.remove_file(&name)?,
341 Ok(_) => {
342 return Err(invalid(format!(
343 "fixture {} is not a regular file",
344 file.path
345 )))
346 }
347 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
348 Err(error) => return Err(error.into()),
349 }
350 dir.open_with(
352 &name,
353 cap_std::fs::OpenOptions::new().write(true).create_new(true),
354 )?
355 .write_all(file.content.as_bytes())?;
356 }
357 Ok(())
358}
359
360#[allow(clippy::too_many_arguments)]
361fn run_case(
362 repo: &GitRepo,
363 paths: &MissionPaths,
364 revision: &str,
365 assertion: &Assertion,
366 files: &[ControlFile],
367 overlay_checker: bool,
368 config: &MissionConfig,
369 deadline: Instant,
370 cancelled: &AtomicBool,
371) -> Result<CaseEvidence> {
372 check_budget(deadline, cancelled)?;
373 if config.worker.sandbox.provider != SandboxProvider::Process || cfg!(target_os = "windows") {
374 return Err(invalid(
375 "read-only control snapshots currently require native macOS/Linux process containment",
376 ));
377 }
378 let spec = assertion
379 .negative_control
380 .as_ref()
381 .expect("selected control");
382 let root = ScratchRoot::create()?;
383 let snapshot = root.0.join("checkout");
384 let _worktree = crate::orchestrator::ApprovalLintWorktree::create(repo, &snapshot, revision)?;
385 let source = spec
386 .baseline_pair
387 .as_ref()
388 .map(|pair| {
389 let repo = GitRepo::open(&snapshot)?;
390 crate::gate_evaluation::snapshot::SourceSnapshot::capture(
391 &repo,
392 &pair.baseline_revision,
393 )
394 .map(|snapshot| snapshot.identity)
395 .map_err(invalid)
396 })
397 .transpose()?;
398 if overlay_checker {
399 apply_files(&snapshot, &spec.checker_files)?;
400 }
401 check_inputs(&snapshot, &spec.checker_files)?;
402 apply_files(&snapshot, files)?;
403 let scratch = root.0.join("scratch");
404 let profiles = root.0.join("profiles");
405 std::fs::create_dir(&scratch)?;
406 std::fs::create_dir(&profiles)?;
407 let mut policy = config.worker.sandbox.clone();
408 if policy.enforce == SandboxEnforce::Off {
412 policy.enforce = SandboxEnforce::Fs;
413 }
414 policy.extra_write.clear();
415 let sandbox = crate::command_exec::resolve_gate_sandbox(
416 &policy,
417 &scratch,
418 &paths.mission_dir(),
419 &scratch,
420 &profiles,
421 )?
422 .sandbox;
423 if sandbox.enforce() == SandboxEnforce::Off {
424 return Err(invalid("control containment unavailable"));
425 }
426 let env = pair::case_environment(config, &scratch, revision);
427 let env = crate::command_exec::gate_env_for_sandbox(&env, &sandbox);
428 let binding = source.map(|source| pair::CaseBinding {
429 source,
430 environment: pair::environment_identity(config, &scratch, &env),
431 });
432 let mut environment_names: Vec<_> = env.keys().cloned().collect();
433 environment_names.sort();
434 let timeout = deadline
435 .saturating_duration_since(Instant::now())
436 .min(Duration::from_secs(spec.timeout_seconds));
437 if timeout.is_zero() {
438 return Err(invalid("control evaluation budget exhausted"));
439 }
440 check_budget(deadline, cancelled)?;
441 let start = Instant::now();
442 let (exit_code, output) = crate::command_exec::run_control_command_sandboxed_blocking(
443 &snapshot,
444 assertion.command.as_deref().expect("validated command"),
445 timeout,
446 &env,
447 &sandbox,
448 cancelled,
449 );
450 let dir = Dir::open_ambient_dir(&scratch, cap_std::ambient_authority())?;
451 let receipt = crate::paths::read_regular_file_under(
452 &dir,
453 Path::new("result.json"),
454 MAX_FILE_BYTES as u64,
455 )
456 .ok()
457 .and_then(|bytes| serde_json::from_str::<CheckReceipt>(&bytes).ok());
458 Ok(CaseEvidence {
459 exit_code,
460 receipt,
461 output_tail: crate::scrub::scrub_and_truncate(&output, 4096),
462 elapsed_ms: start.elapsed().as_millis(),
463 environment_names,
464 binding,
465 })
466}
467
468fn persist(paths: &MissionPaths, evidence: &ControlEvidence) -> Result<(String, Vec<u8>)> {
469 let mission = paths.open_mission_dir_nofollow(false)?;
470 let runs = crate::paths::open_real_subdir(&mission, "runs", &paths.runs_dir(), true)?;
471 let name = format!("control-{}.json", uuid::Uuid::new_v4());
472 let mut value = serde_json::to_value(evidence)?;
473 crate::scrub::scrub_json_value(&mut value, "control-evidence");
474 let mut file = runs.open_with(
475 &name,
476 cap_std::fs::OpenOptions::new().write(true).create_new(true),
477 )?;
478 let bytes = serde_json::to_vec_pretty(&value)?;
479 file.write_all(&bytes)?;
480 file.sync_all()?;
481 Ok((
482 crate::gate_results::file_artefact_ref(&format!("runs/{name}")),
483 bytes,
484 ))
485}
486
487pub fn evaluate(
491 repo: &GitRepo,
492 paths: &MissionPaths,
493 revision: &str,
494 assertions: &[Assertion],
495 config: &MissionConfig,
496) -> Vec<GateReport> {
497 evaluate_cancellable(
498 repo,
499 paths,
500 revision,
501 assertions,
502 config,
503 &AtomicBool::new(false),
504 )
505}
506
507pub(crate) fn evaluate_cancellable(
508 repo: &GitRepo,
509 paths: &MissionPaths,
510 revision: &str,
511 assertions: &[Assertion],
512 config: &MissionConfig,
513 cancelled: &AtomicBool,
514) -> Vec<GateReport> {
515 let admission = match CONTROL_EXECUTIONS.try_lock() {
516 Ok(permit) => Some(permit),
517 Err(std::sync::TryLockError::Poisoned(error)) => Some(error.into_inner()),
518 Err(std::sync::TryLockError::WouldBlock) => None,
519 };
520 let deadline = Instant::now() + TOTAL_BUDGET;
521 let mut reports = Vec::new();
522 for assertion in assertions {
523 let Some(spec) = assertion.negative_control.as_ref() else {
524 continue;
525 };
526 let mut evidence = ControlEvidence {
527 version: if spec.baseline_pair.is_some() { 2 } else { 1 },
528 assertion_id: assertion.id.clone(),
529 assertion_sha256: identity(assertion),
530 checker_sha256: identity(&spec.checker_files),
531 control_sha256: identity(spec),
532 source_revision: revision.to_string(),
533 recorded_at: chrono::Utc::now().to_rfc3339(),
534 platform: format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH),
535 containment: format!(
536 "{}:{}; read-only checkout; scratch-only writes",
537 config.worker.sandbox.provider.as_str(),
538 if config.worker.sandbox.enforce == SandboxEnforce::Off {
539 "fs"
540 } else {
541 config.worker.sandbox.enforce.as_str()
542 }
543 ),
544 environment_names: Vec::new(),
545 status: ControlStatus::Inconclusive,
546 detail: String::new(),
547 valid: None,
548 defective: None,
549 baseline_pair: spec.baseline_pair.as_ref().map(pair::PairEvidence::pending),
550 };
551 let result = (|| -> Result<()> {
552 if admission.is_none() {
553 return Err(invalid("control evaluator busy; retry to collect evidence"));
554 }
555 check_budget(deadline, cancelled)?;
556 validate(assertions)?;
557 if spec.baseline_pair.is_some()
558 && repo.rev_parse(&format!("{revision}^{{commit}}"))? != revision
559 {
560 return Err(invalid(
561 "paired observations require a full immutable candidate commit",
562 ));
563 }
564 if !repo.is_clean_tracked_strict()? {
565 return Err(invalid("source has tracked changes or hidden index flags"));
566 }
567 check_inputs(repo.root(), &spec.checker_files)?;
568 evidence.valid = Some(run_case(
569 repo,
570 paths,
571 revision,
572 assertion,
573 &spec.valid_files,
574 false,
575 config,
576 deadline,
577 cancelled,
578 )?);
579 evidence.environment_names = evidence.valid.as_ref().unwrap().environment_names.clone();
580 evidence.defective = Some(run_case(
581 repo,
582 paths,
583 revision,
584 assertion,
585 &spec.defective_files,
586 false,
587 config,
588 deadline,
589 cancelled,
590 )?);
591 evidence.status = classify(
592 evidence.valid.as_ref().unwrap(),
593 evidence.defective.as_ref().unwrap(),
594 &spec.expected_failure,
595 );
596 if evidence.status == ControlStatus::Verified && spec.baseline_pair.is_some() {
597 if let Err(error) = pair::evaluate_pair(
598 repo,
599 paths,
600 revision,
601 assertion,
602 config,
603 deadline,
604 cancelled,
605 &mut evidence,
606 ) {
607 evidence.baseline_pair.as_mut().unwrap().detail =
608 format!("INCONCLUSIVE: {error}");
609 }
610 }
611 Ok(())
612 })();
613 evidence.detail = match result {
614 Err(error) => format!("INCONCLUSIVE: {error}"),
615 Ok(()) => match evidence.status {
616 ControlStatus::Verified => "VERIFIED: valid control passed; defective control failed with the expected behavioral finding".into(),
617 ControlStatus::NotRejected => "NOT REJECTED: both controls passed; this check did not detect the selected defect".into(),
618 ControlStatus::Inconclusive => "INCONCLUSIVE: execution did not establish both a valid pass and rejection of the intended defect; inspect case receipts and output".into(),
619 },
620 };
621 if let Some(pair) = &mut evidence.baseline_pair {
622 if evidence.status != ControlStatus::Verified {
623 pair.detail = format!(
624 "INCONCLUSIVE: controls did not establish a usable pair. {}",
625 evidence.detail
626 );
627 }
628 }
629 let retained = persist(paths, &evidence);
630 if spec.baseline_pair.is_some() {
631 reports.push(pair::report(&evidence, &retained));
632 }
633 let artefact = match retained {
634 Ok((reference, _)) => {
635 ArtefactRef::new(reference).with_detail(format!("{} (advisory)", evidence.detail))
636 }
637 Err(error) => {
638 evidence.status = ControlStatus::Inconclusive;
639 ArtefactRef::new("negative-control evidence unavailable").with_detail(format!(
640 "INCONCLUSIVE: could not persist control evidence: {error} (advisory)"
641 ))
642 }
643 };
644 reports.push(GateReport {
645 name: format!("negative-control:{}", assertion.id),
646 kind: GateKind::Deterministic,
647 outcome: if evidence.status == ControlStatus::Verified {
648 GateOutcome::pass(artefact)
649 } else {
650 GateOutcome::fail(artefact)
651 },
652 });
653 }
654 reports
655}
656
657#[cfg(test)]
658#[path = "contract_controls_tests.rs"]
659mod tests;