Skip to main content

tsafe_core/
run_evidence.rs

1//! Run evidence — typed-evidence artifact for a single command execution.
2//!
3//! # Provenance
4//!
5//! Lifted from `algol/src/model.rs` @ `6956cfd347cd8ce492231ba5aaa4952227d72689`
6//! (commit `6956cfd`, branch `master`, repo `0ryant/algol`).
7//!
8//! Re-licensed `AGPL-3.0-or-later` per:
9//!
10//! - `ecosystem-catalog/docs/adr/draft-algol-into-tsafe-merge.md`
11//! - `ecosystem-catalog/portfolio-algol-tsafe-migration-2026-05-21.md`
12//! - `ecosystem-catalog/portfolio-algol-tsafe-phase0-audit-2026-05-21.md`
13//! - operator decision 2026-05-21 (sole legal copyright holder per algol/LICENSE
14//!   + git log; co-founder courtesy gate is not a legal blocker)
15//!
16//! # Scope
17//!
18//! `RunEvidence` and the supporting sub-structs (`ContractRef`,
19//! `InjectedSecretEvidence`, `DeniedSensitiveEnvEvidence`,
20//! `EnvironmentEvidence`, `ProcessEvidence`, `MachineEvidence`,
21//! `RiskDelta`, `EnforcementResult`).
22//!
23//! `AttestContract` lives in [`crate::attest_contract`] as a distinct type
24//! per ec Phase 0 audit §1.5.1 — `tsafe-core::contracts::AuthorityContract`
25//! (vault-policy semantics) and the algol-merged attestation contract
26//! (env-injection semantics) have zero field overlap and must not be merged
27//! at the field level.
28//!
29//! # Phase 4 wire-format changes (ec ADR-0003 + schema rename)
30//!
31//! Hash family converges to BLAKE3 (`blake3:<64 hex>`) for all four
32//! fingerprint slots in `RunEvidence`:
33//!
34//! - `contract.hash`
35//! - `environment.secrets_injected[].hash`
36//! - `environment.sensitive_env_denied[].hash`
37//! - `machine.{hostname_hash, username_hash}`
38//!
39//! Schema renames:
40//!
41//! - `algol.run.v1` -> `tsafe.run.v1`
42//! - Field rename `algol_version` -> `tsafe_attest_version`
43//!
44//! Backward-compat deserialization: any of the legacy `algol.run.v1`
45//! schema name, the `sha256:` hash prefix on inputs, or the `algol_version`
46//! field name is still accepted on parse so existing audit-trail
47//! consumers can migrate incrementally. The compat window scope is
48//! documented in CHANGELOG: `v1.2.x` deserializes legacy artifacts;
49//! `v1.3.x` will emit warnings; `v2.0.0` will remove compat. NEW emission
50//! is always `tsafe.run.v1` + BLAKE3.
51//!
52//! # Platform scope
53//!
54//! Linux + macOS first per ec Phase 0 audit §3 (Windows scope option (c)).
55//! Windows callers opt in via the `windows-experimental` Cargo feature
56//! (see crate `Cargo.toml`).
57
58use chrono::{DateTime, Utc};
59use serde::{Deserialize, Serialize};
60
61use crate::sign::SignaturePayload;
62
63/// Schema version for the `RunEvidence` artifact (current canonical).
64///
65/// New emissions tag `schema = "tsafe.run.v1"`. The legacy
66/// [`LEGACY_RUN_SCHEMA`] (`algol.run.v1`) is still accepted by
67/// [`RunEvidence::validation_errors`] during the v1.x compat window.
68pub const RUN_SCHEMA: &str = "tsafe.run.v1";
69
70/// Legacy schema name accepted during the v1.x compat window.
71///
72/// Phase 4 rename: callers reading older `algol.run.v1` artifacts continue
73/// to parse cleanly. New emission is `tsafe.run.v1`. Removal scheduled
74/// for v2.0.0; see `CHANGELOG.md`.
75pub const LEGACY_RUN_SCHEMA: &str = "algol.run.v1";
76
77/// Version string embedded in `RunEvidence.tsafe_attest_version`.
78///
79/// Phase 4 rename: the wire-shape field name on new emissions is
80/// `tsafe_attest_version`. Legacy `algol_version` is accepted on parse via
81/// the `serde(alias)` declaration on the field below.
82pub const RUN_EVIDENCE_VERSION: &str = env!("CARGO_PKG_VERSION");
83
84/// Reference to a written contract artifact.
85///
86/// Carries the on-disk path the contract was loaded from and a BLAKE3
87/// hash of the contract bytes so consumers can detect divergence. Legacy
88/// `sha256:` hashes are accepted on parse during the v1.x compat window;
89/// new emissions are always `blake3:`.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct ContractRef {
92    pub path: String,
93    pub hash: String,
94}
95
96/// Evidence that a single secret was injected into the child process env.
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct InjectedSecretEvidence {
99    pub name: String,
100    pub source: String,
101    pub hash: String,
102    pub redacted_value: String,
103    pub required: bool,
104}
105
106/// Evidence that a sensitive parent-env variable was denied (stripped).
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub struct DeniedSensitiveEnvEvidence {
109    pub name: String,
110    pub hash: String,
111    pub reason: String,
112}
113
114/// Aggregate environment diff between parent and child processes.
115///
116/// `parent_env_count` and `child_env_count` are total entry counts in each
117/// process's env block. `removed_env_count` is the count of entries that
118/// existed in the parent but were stripped from the child — this must
119/// satisfy the invariant `removed_env_count == parent_env_count -
120/// child_env_count` (see [`RunEvidence::validation_errors`]).
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122pub struct EnvironmentEvidence {
123    pub parent_env_count: usize,
124    pub child_env_count: usize,
125    pub removed_env_count: usize,
126    pub safe_baseline_injected: Vec<String>,
127    pub secrets_injected: Vec<InjectedSecretEvidence>,
128    pub sensitive_env_denied: Vec<DeniedSensitiveEnvEvidence>,
129}
130
131/// Evidence of the child process's lifecycle.
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct ProcessEvidence {
134    pub pid: u32,
135    pub exit_code: i32,
136    pub duration_ms: u128,
137    pub cwd: String,
138}
139
140/// Evidence of the host machine the run happened on.
141///
142/// Hostname and username are recorded as BLAKE3 hashes only — the
143/// plaintext values must not appear in the artifact. The os/arch
144/// strings are platform-neutral identifiers (`linux`, `darwin`,
145/// `x86_64`, `aarch64`, etc.).
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147pub struct MachineEvidence {
148    pub hostname_hash: String,
149    pub username_hash: String,
150    pub os: String,
151    pub arch: String,
152}
153
154/// Before/after risk score for the enforcement window.
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156pub struct RiskDelta {
157    pub before_score: u32,
158    pub after_score: u32,
159}
160
161/// Enforcement outcome — did the contract hold, and what (if anything) failed?
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163pub struct EnforcementResult {
164    pub contract_enforced: bool,
165    pub violations: Vec<String>,
166    pub risk_delta: RiskDelta,
167}
168
169/// Typed-evidence artifact for a single attested command execution.
170///
171/// `RunEvidence` is the post-run truth record: every field is observed
172/// after the child process exits. It carries:
173///
174/// - Identity of the run (schema, version, timestamps, repo, command)
175/// - A pointer to the contract the run was enforced against (`contract`)
176/// - The parent-vs-child env diff with per-var BLAKE3 hashes
177///   (`environment`)
178/// - Process lifecycle observations (`process`)
179/// - Host fingerprint (hashed only) (`machine`)
180/// - Enforcement verdict (`result`)
181///
182/// Construction is the caller's responsibility — this module owns the
183/// type definition and validation only.
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub struct RunEvidence {
186    pub schema: String,
187    /// Producing tool version (BLAKE3-converged `tsafe-attest` since v1.2.0).
188    ///
189    /// Legacy `algol_version` field name is accepted on parse via
190    /// `serde(alias)` for the v1.x compat window; new emissions use
191    /// `tsafe_attest_version`.
192    #[serde(alias = "algol_version", rename = "tsafe_attest_version")]
193    pub tsafe_attest_version: String,
194    pub started_at: DateTime<Utc>,
195    pub finished_at: DateTime<Utc>,
196    pub repo_path: String,
197    pub repo_commit: Option<String>,
198    pub command: Vec<String>,
199    pub contract: ContractRef,
200    pub environment: EnvironmentEvidence,
201    pub process: ProcessEvidence,
202    pub machine: MachineEvidence,
203    pub result: EnforcementResult,
204    /// Optional Ed25519 signature over the canonical form of every
205    /// other field on this artifact (Phase 5; see [`crate::sign`]).
206    ///
207    /// `None` on legacy / unsigned emissions; `Some(..)` when the
208    /// producer had a signing key available and the operator did not
209    /// opt out via `--no-sign`. Skipped from the wire form when absent
210    /// so existing readers parse old artifacts unchanged.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub signature: Option<SignaturePayload>,
213}
214
215impl RunEvidence {
216    /// Return all validation errors for this artifact.
217    ///
218    /// Empty vector indicates the artifact is structurally and
219    /// semantically valid. Used by [`Self::ensure_valid`].
220    ///
221    /// During the v1.x compat window, both `tsafe.run.v1` and
222    /// `algol.run.v1` are accepted for the schema field, and any hash
223    /// field accepts either `blake3:` (canonical) or `sha256:` (legacy)
224    /// prefixes.
225    pub fn validation_errors(&self) -> Vec<String> {
226        let mut errors = Vec::new();
227
228        if !is_supported_run_schema(&self.schema) {
229            errors.push(format!("unsupported schema {}", self.schema));
230        }
231        if self.tsafe_attest_version.trim().is_empty() {
232            errors.push("tsafe_attest_version must not be empty".to_string());
233        }
234        if self.repo_path.trim().is_empty() {
235            errors.push("repo_path must not be empty".to_string());
236        }
237        if self.command.is_empty() || self.command.iter().any(|part| part.trim().is_empty()) {
238            errors.push("command must contain non-empty argv entries".to_string());
239        }
240        if self.contract.path.trim().is_empty() {
241            errors.push("contract.path must not be empty".to_string());
242        }
243        if !is_supported_hash(&self.contract.hash) {
244            errors.push(
245                "contract.hash must be a blake3 hash (or sha256 during compat window)".to_string(),
246            );
247        }
248        for item in &self.environment.secrets_injected {
249            if !is_supported_hash(&item.hash) {
250                errors.push(format!(
251                    "secrets_injected {} hash must be a blake3 hash (or sha256 during compat window)",
252                    item.name
253                ));
254            }
255        }
256        for item in &self.environment.sensitive_env_denied {
257            if !is_supported_hash(&item.hash) {
258                errors.push(format!(
259                    "sensitive_env_denied {} hash must be a blake3 hash (or sha256 during compat window)",
260                    item.name
261                ));
262            }
263        }
264        if !is_supported_hash(&self.machine.hostname_hash) {
265            errors.push(
266                "machine.hostname_hash must be a blake3 hash (or sha256 during compat window)"
267                    .to_string(),
268            );
269        }
270        if !is_supported_hash(&self.machine.username_hash) {
271            errors.push(
272                "machine.username_hash must be a blake3 hash (or sha256 during compat window)"
273                    .to_string(),
274            );
275        }
276        if self.environment.child_env_count > self.environment.parent_env_count {
277            errors.push("child_env_count must not exceed parent_env_count".to_string());
278        }
279        let expected_removed = self
280            .environment
281            .parent_env_count
282            .saturating_sub(self.environment.child_env_count);
283        if self.environment.removed_env_count != expected_removed {
284            errors.push(format!(
285                "removed_env_count must equal parent_env_count - child_env_count ({expected_removed})"
286            ));
287        }
288
289        errors
290    }
291
292    /// Convert the validation-error list into a `Result`.
293    pub fn ensure_valid(&self) -> Result<(), String> {
294        let errors = self.validation_errors();
295        if errors.is_empty() {
296            Ok(())
297        } else {
298            Err(errors.join("; "))
299        }
300    }
301}
302
303/// Test whether `schema` is one of the supported `RunEvidence` schema names.
304///
305/// Accepts both the canonical [`RUN_SCHEMA`] (`tsafe.run.v1`) and the
306/// legacy [`LEGACY_RUN_SCHEMA`] (`algol.run.v1`) during the v1.x compat
307/// window.
308pub fn is_supported_run_schema(schema: &str) -> bool {
309    schema == RUN_SCHEMA || schema == LEGACY_RUN_SCHEMA
310}
311
312/// Test whether a string is a valid BLAKE3 hash with the
313/// `blake3:<64-hex-chars>` prefix convention (canonical Phase 4).
314pub fn is_blake3_hash(value: &str) -> bool {
315    let Some(hex) = value.strip_prefix("blake3:") else {
316        return false;
317    };
318    hex.len() == 64 && hex.chars().all(|char| char.is_ascii_hexdigit())
319}
320
321/// Test whether a string is a valid SHA-256 hash with the
322/// `sha256:<64-hex-chars>` prefix convention (legacy / compat).
323pub fn is_sha256_hash(value: &str) -> bool {
324    let Some(hex) = value.strip_prefix("sha256:") else {
325        return false;
326    };
327    hex.len() == 64 && hex.chars().all(|char| char.is_ascii_hexdigit())
328}
329
330/// Test whether a hash string is one of the supported families.
331///
332/// Accepts BLAKE3 (canonical Phase 4) or SHA-256 (legacy) during the
333/// v1.x compat window. New emission code should call [`blake3_hash`]
334/// directly.
335pub fn is_supported_hash(value: &str) -> bool {
336    is_blake3_hash(value) || is_sha256_hash(value)
337}
338
339/// Build a `blake3:<hex>` hash from raw bytes.
340///
341/// This is the canonical content-hash helper per ec ADR-0003.
342pub fn blake3_hash(value: impl AsRef<[u8]>) -> String {
343    let digest = blake3::hash(value.as_ref());
344    format!("blake3:{}", digest.to_hex())
345}
346
347/// Build a `sha256:<hex>` hash from raw bytes.
348///
349/// **Deprecated.** Retained for the v1.x compat window so callers
350/// reading existing `algol.run.v1` artifacts (which carry `sha256:`
351/// hashes) can validate against them. New emissions use
352/// [`blake3_hash`].
353#[deprecated(
354    since = "1.2.0",
355    note = "Use `blake3_hash` per ec ADR-0003. SHA-256 retained only for \
356            compat-window reads of legacy `algol.run.v1` artifacts."
357)]
358pub fn sha256_hash(value: impl AsRef<[u8]>) -> String {
359    use sha2::{Digest, Sha256};
360    let digest = Sha256::digest(value.as_ref());
361    format!("sha256:{}", hex_encode(&digest))
362}
363
364/// Minimal lowercase hex encoder so this module does not pull in a new
365/// `hex` crate dependency just for the hash helper.
366fn hex_encode(bytes: &[u8]) -> String {
367    const HEX: &[u8; 16] = b"0123456789abcdef";
368    let mut out = String::with_capacity(bytes.len() * 2);
369    for byte in bytes {
370        out.push(HEX[(byte >> 4) as usize] as char);
371        out.push(HEX[(byte & 0x0f) as usize] as char);
372    }
373    out
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    fn valid_run() -> RunEvidence {
381        RunEvidence {
382            schema: RUN_SCHEMA.to_string(),
383            tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
384            started_at: Utc::now(),
385            finished_at: Utc::now(),
386            repo_path: ".".to_string(),
387            repo_commit: None,
388            command: vec!["true".to_string()],
389            contract: ContractRef {
390                path: "tsafe.contract.json".to_string(),
391                hash: blake3_hash("contract"),
392            },
393            environment: EnvironmentEvidence {
394                parent_env_count: 3,
395                child_env_count: 1,
396                removed_env_count: 2,
397                safe_baseline_injected: vec!["PATH".to_string()],
398                secrets_injected: vec![InjectedSecretEvidence {
399                    name: "DATABASE_URL".to_string(),
400                    source: "literal://demo/DATABASE_URL".to_string(),
401                    hash: blake3_hash("database"),
402                    redacted_value: "po***se".to_string(),
403                    required: true,
404                }],
405                sensitive_env_denied: vec![DeniedSensitiveEnvEvidence {
406                    name: "AWS_SECRET_ACCESS_KEY".to_string(),
407                    hash: blake3_hash("secret"),
408                    reason: "test".to_string(),
409                }],
410            },
411            process: ProcessEvidence {
412                pid: 1,
413                exit_code: 0,
414                duration_ms: 1,
415                cwd: ".".to_string(),
416            },
417            machine: MachineEvidence {
418                hostname_hash: blake3_hash("host"),
419                username_hash: blake3_hash("user"),
420                os: "linux".to_string(),
421                arch: "x86_64".to_string(),
422            },
423            result: EnforcementResult {
424                contract_enforced: true,
425                violations: Vec::new(),
426                risk_delta: RiskDelta {
427                    before_score: 10,
428                    after_score: 0,
429                },
430            },
431            signature: None,
432        }
433    }
434
435    #[test]
436    fn run_rejects_empty_and_blank_command_entries() {
437        let mut empty = valid_run();
438        empty.command.clear();
439        assert!(empty
440            .validation_errors()
441            .iter()
442            .any(|error| error.contains("command must contain")));
443
444        let mut blank = valid_run();
445        blank.command = vec!["true".to_string(), "".to_string()];
446        assert!(blank
447            .validation_errors()
448            .iter()
449            .any(|error| error.contains("command must contain")));
450    }
451
452    #[test]
453    fn run_rejects_env_count_edges() {
454        let mut child_exceeds_parent = valid_run();
455        child_exceeds_parent.environment.parent_env_count = 2;
456        child_exceeds_parent.environment.child_env_count = 3;
457        child_exceeds_parent.environment.removed_env_count = 0;
458        let errors = child_exceeds_parent.validation_errors().join("; ");
459        assert!(errors.contains("child_env_count must not exceed parent_env_count"));
460
461        let mut equal_counts = valid_run();
462        equal_counts.environment.parent_env_count = 2;
463        equal_counts.environment.child_env_count = 2;
464        equal_counts.environment.removed_env_count = 1;
465        let errors = equal_counts.validation_errors().join("; ");
466        assert!(!errors.contains("child_env_count must not exceed parent_env_count"));
467        assert!(errors.contains("removed_env_count must equal"));
468
469        let mut removed_mismatch = valid_run();
470        removed_mismatch.environment.parent_env_count = 5;
471        removed_mismatch.environment.child_env_count = 3;
472        removed_mismatch.environment.removed_env_count = 1;
473        let errors = removed_mismatch.validation_errors().join("; ");
474        assert!(!errors.contains("child_env_count must not exceed parent_env_count"));
475        assert!(errors.contains("removed_env_count must equal"));
476    }
477
478    #[test]
479    fn run_rejects_short_or_non_hex_hashes() {
480        let mut short_hash = valid_run();
481        short_hash.contract.hash = "blake3:abc123".to_string();
482        assert!(short_hash
483            .validation_errors()
484            .iter()
485            .any(|error| error.contains("contract.hash must be a blake3 hash")));
486
487        let mut non_hex_hash = valid_run();
488        non_hex_hash.machine.hostname_hash = format!("blake3:{}", "g".repeat(64));
489        assert!(non_hex_hash
490            .validation_errors()
491            .iter()
492            .any(|error| error.contains("machine.hostname_hash must be a blake3 hash")));
493    }
494
495    #[test]
496    fn run_accepts_a_well_formed_artifact() {
497        let run = valid_run();
498        assert!(
499            run.ensure_valid().is_ok(),
500            "valid_run() should pass validation: {:?}",
501            run.validation_errors()
502        );
503    }
504
505    #[test]
506    fn blake3_hash_produces_prefixed_64_hex_string() {
507        let hash = blake3_hash("any payload");
508        assert!(is_blake3_hash(&hash), "rejected own output: {hash}");
509        assert_eq!(hash.len(), "blake3:".len() + 64);
510        assert!(hash.starts_with("blake3:"));
511    }
512
513    #[test]
514    fn blake3_hash_is_deterministic_and_distinct() {
515        assert_eq!(blake3_hash("same input"), blake3_hash("same input"));
516        assert_ne!(blake3_hash("input one"), blake3_hash("input two"));
517    }
518
519    #[test]
520    fn is_blake3_hash_rejects_wrong_prefix_and_length() {
521        assert!(!is_blake3_hash(
522            "sha256:0000000000000000000000000000000000000000000000000000000000000000"
523        ));
524        assert!(!is_blake3_hash("blake3:short"));
525        assert!(!is_blake3_hash("blake3:"));
526        assert!(!is_blake3_hash(""));
527        assert!(!is_blake3_hash(&format!("blake3:{}", "z".repeat(64))));
528    }
529
530    #[test]
531    fn compat_legacy_schema_and_sha256_hashes_accepted() {
532        let mut legacy = valid_run();
533        legacy.schema = LEGACY_RUN_SCHEMA.to_string();
534        // SHA-256 hash on a legacy artifact must remain accepted during compat.
535        #[allow(deprecated)]
536        let legacy_hash = sha256_hash("contract");
537        legacy.contract.hash = legacy_hash;
538        // Replace all hashes with sha256 to model an actual legacy artifact.
539        #[allow(deprecated)]
540        {
541            for item in &mut legacy.environment.secrets_injected {
542                item.hash = sha256_hash(&item.name);
543            }
544            for item in &mut legacy.environment.sensitive_env_denied {
545                item.hash = sha256_hash(&item.name);
546            }
547            legacy.machine.hostname_hash = sha256_hash("host");
548            legacy.machine.username_hash = sha256_hash("user");
549        }
550        assert!(
551            legacy.ensure_valid().is_ok(),
552            "legacy artifact must remain valid during compat: {:?}",
553            legacy.validation_errors()
554        );
555    }
556
557    #[test]
558    fn compat_legacy_algol_version_field_name_deserializes() {
559        // Round-trip from a legacy `algol_version`-keyed JSON document.
560        let blob = serde_json::json!({
561            "schema": LEGACY_RUN_SCHEMA,
562            "algol_version": "0.1.0",
563            "started_at": "2026-05-21T00:00:00Z",
564            "finished_at": "2026-05-21T00:00:01Z",
565            "repo_path": ".",
566            "repo_commit": null,
567            "command": ["true"],
568            "contract": {
569                "path": "algol.contract.json",
570                "hash": format!("sha256:{}", "a".repeat(64)),
571            },
572            "environment": {
573                "parent_env_count": 1,
574                "child_env_count": 1,
575                "removed_env_count": 0,
576                "safe_baseline_injected": ["PATH"],
577                "secrets_injected": [],
578                "sensitive_env_denied": [],
579            },
580            "process": {
581                "pid": 1,
582                "exit_code": 0,
583                "duration_ms": 1u64,
584                "cwd": ".",
585            },
586            "machine": {
587                "hostname_hash": format!("sha256:{}", "b".repeat(64)),
588                "username_hash": format!("sha256:{}", "c".repeat(64)),
589                "os": "linux",
590                "arch": "x86_64",
591            },
592            "result": {
593                "contract_enforced": true,
594                "violations": [],
595                "risk_delta": {"before_score": 10, "after_score": 0},
596            },
597        });
598        let parsed: RunEvidence = serde_json::from_value(blob).expect("legacy json parses");
599        assert_eq!(parsed.tsafe_attest_version, "0.1.0");
600        assert!(parsed.ensure_valid().is_ok());
601    }
602}