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