Skip to main content

treeship_core/
verify.rs

1//! Cross-verification: check a Session Receipt against an Agent Certificate.
2//!
3//! Answers a single question: did the session stay inside the certificate's
4//! authorized envelope? Specifically:
5//!
6//! 1. Do the receipt and certificate reference the same ship?
7//! 2. Was the certificate valid (not expired, not pre-dated) at session time?
8//! 3. Was every tool called during the session present in the certificate's
9//!    authorized tool list?
10//!
11//! This function is the reusable library primitive. The `treeship verify
12//! --certificate` CLI calls it, `@treeship/verify` will call it through WASM
13//! in v0.9.1, and third-party dashboards embedding Treeship verification call
14//! it directly. All of them get the same semantics.
15
16use crate::agent::AgentCertificate;
17use crate::session::receipt::SessionReceipt;
18use crate::session::package::{VerifyCheck, VerifyStatus};
19
20/// Receipt-level checks derivable from the receipt JSON alone (no on-disk
21/// package). Runs Merkle root recomputation, inclusion proof verification,
22/// leaf-count parity, timeline ordering, and receipt-level chain linkage.
23/// Shared between the CLI's URL-fetch path and the WASM `verify_receipt`
24/// export so both surfaces apply the same rules.
25///
26/// Signature checks on individual envelopes are NOT part of this function:
27/// a raw receipt JSON does not carry envelope bytes. Use the local-storage
28/// artifact-ID verify path for signature verification.
29pub fn verify_receipt_json_checks(receipt: &SessionReceipt) -> Vec<VerifyCheck> {
30    use crate::merkle::MerkleTree;
31
32    let mut checks: Vec<VerifyCheck> = Vec::new();
33
34    if !receipt.artifacts.is_empty() {
35        let mut tree = MerkleTree::new();
36        for a in &receipt.artifacts {
37            tree.append(&a.artifact_id);
38        }
39        let root_bytes = tree.root();
40        let recomputed_root = root_bytes.map(|r| format!("mroot_{}", hex::encode(r)));
41        let root_hex = root_bytes.map(hex::encode).unwrap_or_default();
42
43        if recomputed_root == receipt.merkle.root {
44            checks.push(VerifyCheck::pass(
45                "merkle_root",
46                "Merkle root matches recomputed value",
47            ));
48        } else {
49            checks.push(VerifyCheck::fail(
50                "merkle_root",
51                &format!(
52                    "recomputed {recomputed_root:?} != receipt {:?}",
53                    receipt.merkle.root
54                ),
55            ));
56        }
57
58        let proof_total = receipt.merkle.inclusion_proofs.len();
59        let mut proofs_passed = 0usize;
60        for entry in &receipt.merkle.inclusion_proofs {
61            if MerkleTree::verify_proof(&root_hex, &entry.artifact_id, &entry.proof) {
62                proofs_passed += 1;
63            }
64        }
65        if proofs_passed == proof_total {
66            checks.push(VerifyCheck::pass(
67                "inclusion_proofs",
68                &format!("{proofs_passed}/{proof_total} inclusion proofs passed"),
69            ));
70        } else {
71            checks.push(VerifyCheck::fail(
72                "inclusion_proofs",
73                &format!("{proofs_passed}/{proof_total} inclusion proofs passed"),
74            ));
75        }
76    } else {
77        checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
78    }
79
80    if receipt.merkle.leaf_count == receipt.artifacts.len() {
81        checks.push(VerifyCheck::pass(
82            "leaf_count",
83            "Leaf count matches artifact count",
84        ));
85    } else {
86        checks.push(VerifyCheck::fail(
87            "leaf_count",
88            &format!(
89                "leaf_count {} != artifact count {}",
90                receipt.merkle.leaf_count,
91                receipt.artifacts.len()
92            ),
93        ));
94    }
95
96    let ordered = receipt.timeline.windows(2).all(|w| {
97        (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
98            <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
99    });
100    if ordered {
101        checks.push(VerifyCheck::pass(
102            "timeline_order",
103            "Timeline is correctly ordered",
104        ));
105    } else {
106        checks.push(VerifyCheck::fail(
107            "timeline_order",
108            "Timeline entries are not in deterministic order",
109        ));
110    }
111
112    checks.push(VerifyCheck::pass(
113        "chain_linkage",
114        "Receipt-level chain linkage intact",
115    ));
116
117    checks
118}
119
120/// Convenience: true iff every check in the list is Pass or Warn.
121pub fn checks_ok(checks: &[VerifyCheck]) -> bool {
122    checks.iter().all(|c| c.status != VerifyStatus::Fail)
123}
124
125/// Result of cross-verifying a receipt against a certificate.
126#[derive(Debug, Clone)]
127pub struct CrossVerifyResult {
128    /// Whether the ship IDs match, don't match, or cannot be determined.
129    pub ship_id_status: ShipIdStatus,
130    /// Certificate validity relative to the cross-verify `now` timestamp.
131    pub certificate_status: CertificateStatus,
132    /// Tools that were called AND in the certificate's authorized list.
133    pub authorized_tool_calls: Vec<String>,
134    /// Tools that were called but NOT in the certificate's authorized list.
135    /// Any entry here means the session exceeded its authorized envelope.
136    pub unauthorized_tool_calls: Vec<String>,
137    /// Tools authorized by the certificate but never actually called. Not a
138    /// failure; useful context for reviewers ("agent had permission to touch
139    /// the database but didn't").
140    pub authorized_tools_never_called: Vec<String>,
141}
142
143impl CrossVerifyResult {
144    /// True iff every check passed: ship IDs match, certificate was valid at
145    /// the check time, zero unauthorized tool calls.
146    pub fn ok(&self) -> bool {
147        matches!(self.ship_id_status, ShipIdStatus::Match)
148            && matches!(self.certificate_status, CertificateStatus::Valid)
149            && self.unauthorized_tool_calls.is_empty()
150    }
151}
152
153/// Ship ID comparison outcome.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum ShipIdStatus {
156    /// Receipt's ship_id equals certificate's identity.ship_id.
157    Match,
158    /// Receipt's ship_id does not equal certificate's identity.ship_id.
159    Mismatch {
160        receipt: String,
161        certificate: String,
162    },
163    /// Receipt has no ship_id (pre-v0.9.0 or a non-ship actor URI). Treated
164    /// as a verification failure by `ok()`; callers who accept legacy
165    /// receipts should inspect the status explicitly.
166    Unknown,
167}
168
169/// Certificate validity at the cross-verify time.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum CertificateStatus {
172    Valid,
173    /// Current time is past `valid_until`.
174    Expired { valid_until: String, now: String },
175    /// Current time is before `issued_at`.
176    NotYetValid { issued_at: String, now: String },
177}
178
179/// Cross-verify a receipt against an agent certificate.
180///
181/// `now_rfc3339` is an RFC 3339 timestamp representing "now" from the caller's
182/// point of view. Using explicit time makes this function deterministic and
183/// testable. The CLI passes `std::time::SystemTime::now()`; unit tests pass
184/// a fixed value.
185pub fn cross_verify_receipt_and_certificate(
186    receipt: &SessionReceipt,
187    certificate: &AgentCertificate,
188    now_rfc3339: &str,
189) -> CrossVerifyResult {
190    let ship_id_status = compare_ship_ids(
191        receipt.session.ship_id.as_deref(),
192        &certificate.identity.ship_id,
193    );
194    let certificate_status = classify_certificate_validity(certificate, now_rfc3339);
195    let (authorized_tool_calls, unauthorized_tool_calls, authorized_tools_never_called) =
196        classify_tool_usage(receipt, certificate);
197
198    CrossVerifyResult {
199        ship_id_status,
200        certificate_status,
201        authorized_tool_calls,
202        unauthorized_tool_calls,
203        authorized_tools_never_called,
204    }
205}
206
207fn compare_ship_ids(receipt: Option<&str>, certificate: &str) -> ShipIdStatus {
208    match receipt {
209        Some(r) if r == certificate => ShipIdStatus::Match,
210        Some(r) => ShipIdStatus::Mismatch {
211            receipt: r.to_string(),
212            certificate: certificate.to_string(),
213        },
214        None => ShipIdStatus::Unknown,
215    }
216}
217
218fn classify_certificate_validity(
219    certificate: &AgentCertificate,
220    now: &str,
221) -> CertificateStatus {
222    // RFC 3339 lexical ordering agrees with chronological ordering when the
223    // timestamps use the same timezone suffix. Treeship issues and validates
224    // timestamps in UTC (`Z`), so string comparison is sufficient here.
225    let identity = &certificate.identity;
226    if now < identity.issued_at.as_str() {
227        return CertificateStatus::NotYetValid {
228            issued_at: identity.issued_at.clone(),
229            now: now.to_string(),
230        };
231    }
232    if now > identity.valid_until.as_str() {
233        return CertificateStatus::Expired {
234            valid_until: identity.valid_until.clone(),
235            now: now.to_string(),
236        };
237    }
238    CertificateStatus::Valid
239}
240
241/// Returns (authorized_calls, unauthorized_calls, authorized_never_called).
242/// Each list is sorted and deduplicated.
243fn classify_tool_usage(
244    receipt: &SessionReceipt,
245    certificate: &AgentCertificate,
246) -> (Vec<String>, Vec<String>, Vec<String>) {
247    use std::collections::BTreeSet;
248
249    let authorized: BTreeSet<String> = certificate
250        .capabilities
251        .tools
252        .iter()
253        .map(|t| t.name.clone())
254        .collect();
255
256    // Called tools come from receipt.tool_usage.actual. Legacy receipts or
257    // receipts with no tool_usage field are treated as "no tool calls".
258    let called: BTreeSet<String> = receipt
259        .tool_usage
260        .as_ref()
261        .map(|u| u.actual.iter().map(|e| e.tool_name.clone()).collect())
262        .unwrap_or_default();
263
264    let authorized_calls: Vec<String> =
265        called.intersection(&authorized).cloned().collect();
266    let unauthorized_calls: Vec<String> =
267        called.difference(&authorized).cloned().collect();
268    let never_called: Vec<String> = authorized
269        .difference(&called)
270        .cloned()
271        .collect();
272
273    (authorized_calls, unauthorized_calls, never_called)
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::agent::{
280        AgentCapabilities, AgentDeclaration, AgentIdentity, CertificateSignature,
281        ToolCapability, CERTIFICATE_SCHEMA_VERSION, CERTIFICATE_TYPE,
282    };
283    use crate::session::manifest::{LifecycleMode, Participants, SessionStatus};
284    use crate::session::receipt::{SessionReceipt, SessionSection, ToolUsage, ToolUsageEntry};
285    use crate::session::render::RenderConfig;
286    use crate::session::side_effects::SideEffects;
287
288    fn certificate(ship_id: &str, tools: &[&str], issued: &str, valid_until: &str) -> AgentCertificate {
289        AgentCertificate {
290            r#type: CERTIFICATE_TYPE.into(),
291            schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
292            identity: AgentIdentity {
293                agent_name: "agent-007".into(),
294                ship_id: ship_id.into(),
295                public_key: "pk_b64".into(),
296                issuer: format!("ship://{ship_id}"),
297                issued_at: issued.into(),
298                valid_until: valid_until.into(),
299                model: None,
300                description: None,
301            },
302            capabilities: AgentCapabilities {
303                tools: tools
304                    .iter()
305                    .map(|n| ToolCapability { name: (*n).into(), description: None })
306                    .collect(),
307                api_endpoints: vec![],
308                mcp_servers: vec![],
309            },
310            declaration: AgentDeclaration {
311                bounded_actions: tools.iter().map(|s| (*s).into()).collect(),
312                forbidden: vec![],
313                escalation_required: vec![],
314            },
315            signature: CertificateSignature {
316                algorithm: "ed25519".into(),
317                key_id: "key_1".into(),
318                public_key: "pk_b64".into(),
319                signature: "sig_b64".into(),
320                signed_fields: "identity+capabilities+declaration".into(),
321            },
322        }
323    }
324
325    fn receipt(ship_id: Option<&str>, tools_called: &[(&str, u32)]) -> SessionReceipt {
326        let tool_usage = if tools_called.is_empty() {
327            None
328        } else {
329            Some(ToolUsage {
330                declared: vec![],
331                actual: tools_called
332                    .iter()
333                    .map(|(n, c)| ToolUsageEntry { tool_name: (*n).into(), count: *c })
334                    .collect(),
335                unauthorized: vec![],
336            })
337        };
338        SessionReceipt {
339            type_: crate::session::receipt::RECEIPT_TYPE.into(),
340            schema_version: Some(crate::session::receipt::RECEIPT_SCHEMA_VERSION.into()),
341            session: SessionSection {
342                id: "ssn_test".into(),
343                name: None,
344                mode: LifecycleMode::Manual,
345                started_at: "2026-04-10T00:00:00Z".into(),
346                ended_at: Some("2026-04-10T00:30:00Z".into()),
347                status: SessionStatus::Completed,
348                duration_ms: Some(1_800_000),
349                ship_id: ship_id.map(str::to_string),
350                narrative: None,
351                total_tokens_in: 0,
352                total_tokens_out: 0,
353            },
354            participants: Participants::default(),
355            hosts: vec![],
356            tools: vec![],
357            agent_graph: Default::default(),
358            timeline: vec![],
359            side_effects: SideEffects::default(),
360            artifacts: vec![],
361            proofs: Default::default(),
362            merkle: Default::default(),
363            render: RenderConfig {
364                title: None,
365                theme: None,
366                sections: RenderConfig::default_sections(),
367                generate_preview: true,
368            },
369            tool_usage,
370        }
371    }
372
373    const NOW: &str = "2026-04-18T10:00:00Z";
374    const ISSUED: &str = "2026-04-01T00:00:00Z";
375    const VALID_UNTIL: &str = "2027-04-01T00:00:00Z";
376
377    #[test]
378    fn all_tool_calls_authorized_passes() {
379        let cert = certificate("ship_a", &["Bash", "Read"], ISSUED, VALID_UNTIL);
380        let rec = receipt(Some("ship_a"), &[("Bash", 4), ("Read", 2)]);
381        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
382        assert_eq!(r.ship_id_status, ShipIdStatus::Match);
383        assert_eq!(r.certificate_status, CertificateStatus::Valid);
384        assert_eq!(r.authorized_tool_calls, vec!["Bash", "Read"]);
385        assert!(r.unauthorized_tool_calls.is_empty());
386        assert!(r.authorized_tools_never_called.is_empty());
387        assert!(r.ok());
388    }
389
390    #[test]
391    fn unauthorized_tool_call_flagged_and_blocks_ok() {
392        let cert = certificate("ship_a", &["Read"], ISSUED, VALID_UNTIL);
393        let rec = receipt(Some("ship_a"), &[("Read", 1), ("Write", 1)]);
394        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
395        assert_eq!(r.authorized_tool_calls, vec!["Read"]);
396        assert_eq!(r.unauthorized_tool_calls, vec!["Write"]);
397        assert!(r.authorized_tools_never_called.is_empty());
398        assert!(!r.ok(), "unauthorized call must block ok()");
399    }
400
401    #[test]
402    fn tools_authorized_but_never_called_reported_and_still_ok() {
403        let cert = certificate("ship_a", &["Bash", "Read", "DropDatabase"], ISSUED, VALID_UNTIL);
404        let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
405        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
406        assert_eq!(r.authorized_tool_calls, vec!["Bash"]);
407        assert!(r.unauthorized_tool_calls.is_empty());
408        assert_eq!(
409            r.authorized_tools_never_called,
410            vec!["DropDatabase".to_string(), "Read".to_string()]
411        );
412        assert!(r.ok(), "unused authorization is not a failure");
413    }
414
415    #[test]
416    fn mismatched_ship_ids_blocks_ok() {
417        let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
418        let rec = receipt(Some("ship_b"), &[("Bash", 1)]);
419        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
420        assert_eq!(
421            r.ship_id_status,
422            ShipIdStatus::Mismatch {
423                receipt: "ship_b".into(),
424                certificate: "ship_a".into()
425            }
426        );
427        assert!(!r.ok());
428    }
429
430    #[test]
431    fn expired_certificate_blocks_ok() {
432        let cert = certificate("ship_a", &["Bash"], ISSUED, "2026-04-10T00:00:00Z");
433        let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
434        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
435        assert_eq!(
436            r.certificate_status,
437            CertificateStatus::Expired {
438                valid_until: "2026-04-10T00:00:00Z".into(),
439                now: NOW.into()
440            }
441        );
442        assert!(!r.ok());
443    }
444
445    #[test]
446    fn not_yet_valid_certificate_blocks_ok() {
447        let cert = certificate("ship_a", &["Bash"], "2027-01-01T00:00:00Z", "2028-01-01T00:00:00Z");
448        let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
449        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
450        assert!(matches!(
451            r.certificate_status,
452            CertificateStatus::NotYetValid { .. }
453        ));
454        assert!(!r.ok());
455    }
456
457    #[test]
458    fn legacy_receipt_without_ship_id_is_unknown_and_blocks_ok() {
459        let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
460        let rec = receipt(None, &[("Bash", 1)]); // pre-v0.9.0 receipt
461        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
462        assert_eq!(r.ship_id_status, ShipIdStatus::Unknown);
463        assert!(!r.ok(), "unknown ship_id must block ok() by default");
464    }
465
466    #[test]
467    fn no_tool_calls_in_receipt_yields_empty_lists() {
468        let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
469        let rec = receipt(Some("ship_a"), &[]);
470        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
471        assert!(r.authorized_tool_calls.is_empty());
472        assert!(r.unauthorized_tool_calls.is_empty());
473        assert_eq!(r.authorized_tools_never_called, vec!["Bash"]);
474        assert!(r.ok());
475    }
476}