Skip to main content

kranz_engine/
contract_controls.rs

1//! Opt-in evidence that an approved command distinguishes a valid implementation
2//! from a particular defect. Controls are advisory; an execution failure is not
3//! evidence of rejection. The same command runs on valid/defective controls and
4//! optional actual revision pairs, with read-only checkouts and writable scratch.
5//! Every conclusive case must report a nonzero number of declared checks.
6
7use 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/// Dropping the awaiting mission operation cancels the active control and
30/// stops subsequent launches. Its runner retains ownership through cleanup.
31#[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/// Written by the approved checking adapter to KRANZ_CONTROL_RESULT. A failed
202/// build, missing test, or shell error without this receipt cannot masquerade
203/// as a test finding the intended defect. This is scoped checker evidence,
204/// not an independent attestation that an arbitrary checker is truthful.
205#[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)] // Unix permissions require the mutable builder.
294        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        // Fresh inode: a tracked symlink or hard link can never redirect writes.
351        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    // The writable root is deliberately scratch, while the command's actual
409    // cwd is the sibling read-only checkout. Container/LPAC layouts have no
410    // verified mount/read contract for this split yet; refuse, never degrade.
411    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
487/// Run selected controls afresh at this immutable revision. Receipt files are
488/// unique per evaluation and referenced by the existing gate.result event.
489/// Failure to run or retain evidence stays visibly inconclusive and advisory.
490pub 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;