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 twice with a read-only checkout
4//! and writable scratch, and must report a nonzero number of behavioral checks.
5
6use 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/// Dropping the awaiting mission operation cancels the active control and
27/// stops subsequent launches. Its runner retains ownership through cleanup.
28#[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/// Written by the approved checking adapter to KRANZ_CONTROL_RESULT. A failed
194/// build, missing test, or shell error without this receipt cannot masquerade
195/// as a test finding the intended defect. This is scoped checker evidence,
196/// not an independent attestation that an arbitrary checker is truthful.
197#[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)] // Unix permissions require the mutable builder.
277        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        // Fresh inode: a tracked symlink or hard link can never redirect writes.
334        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    // The writable root is deliberately scratch, while the command's actual
375    // cwd is the sibling read-only checkout. Container/LPAC layouts have no
376    // verified mount/read contract for this split yet; refuse, never degrade.
377    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
460/// Run selected controls afresh at this immutable revision. Receipt files are
461/// unique per evaluation and referenced by the existing gate.result event.
462/// Failure to run or retain evidence stays visibly inconclusive and advisory.
463pub 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;