1use serde::{Deserialize, Serialize};
41
42use super::invitation::{canonical_json_digest, parse_rfc3339_to_unix};
43use super::SubjectRef;
44use crate::attestation::{Signer, SignerError};
45use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
46use ed25519_dalek::{Signature, VerifyingKey};
47
48pub const TYPE_ACTION_V2: &str = "treeship/action/v2";
50
51pub fn payload_type_v2(suffix: &str) -> String {
58 format!("application/vnd.treeship.{}.v2+json", suffix)
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct Revocation {
76 pub path: String,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub revoked_at: Option<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct Mandate {
88 pub grant_id: String,
90
91 pub grantor: String,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub issuer_sig: Option<String>,
101
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub objective_hash: Option<String>,
105
106 #[serde(default)]
109 pub scope: Vec<String>,
110
111 pub audience: String,
114
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub parent_request_id: Option<String>,
118
119 #[serde(default)]
122 pub delegation_depth: u32,
123
124 pub issued_at: String,
126
127 pub expiry: String,
129
130 #[serde(default)]
132 pub max_delegation: u32,
133
134 pub revocation: Revocation,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct Cost {
141 pub unit: String,
142 pub amount: u64,
143}
144
145#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
160pub struct Witness {
161 pub observer: String,
165 pub observation: String,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub observed_at: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub signature: Option<String>,
178}
179
180impl Witness {
181 pub fn is_signed(&self) -> bool {
186 self.signature.is_some()
187 }
188}
189
190#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
195pub struct Effect {
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub input_hash: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub output_hash: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub readback: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub bytes_moved: Option<u64>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub cost: Option<Cost>,
209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
210 pub side_effects: Vec<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub context_snapshot: Option<String>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub effect_confidence: Option<EffectConfidence>,
223 #[serde(default, skip_serializing_if = "Vec::is_empty")]
228 pub witnesses: Vec<Witness>,
229}
230
231#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(rename_all = "snake_case")]
242pub enum EffectConfidence {
243 Verified,
246 Partial,
249 Ambiguous,
251 Unknown,
253 NotVerified,
256}
257
258impl Effect {
259 pub fn has_independent_evidence(&self) -> bool {
270 self.readback.is_some()
271 }
272
273 pub fn signed_witnesses(&self) -> impl Iterator<Item = &Witness> {
277 self.witnesses.iter().filter(|w| w.is_signed())
278 }
279
280 pub fn evidence_ceiling(&self) -> EffectConfidence {
286 if self.has_independent_evidence() {
287 EffectConfidence::Verified
288 } else {
289 EffectConfidence::NotVerified
290 }
291 }
292}
293
294#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
309pub struct RuntimeIdentity {
310 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub provider: Option<String>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub model: Option<String>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub tool_schema_hash: Option<String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub system_prompt_hash: Option<String>,
322}
323
324impl RuntimeIdentity {
325 pub fn is_unbound(&self) -> bool {
330 self.provider.is_none()
331 && self.model.is_none()
332 && self.tool_schema_hash.is_none()
333 && self.system_prompt_hash.is_none()
334 }
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct ActionStatementV2 {
343 #[serde(rename = "type")]
344 pub type_: String,
345
346 pub timestamp: String,
349
350 pub actor: String,
351 pub action: String,
352
353 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub audience: Option<String>,
359
360 #[serde(default, skip_serializing_if = "subject_is_empty")]
361 pub subject: SubjectRef,
362
363 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
364 pub parent_id: Option<String>,
365
366 pub mandate: Mandate,
367
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub effect: Option<Effect>,
370
371 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub runtime: Option<RuntimeIdentity>,
376
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub meta: Option<serde_json::Value>,
379}
380
381fn subject_is_empty(s: &SubjectRef) -> bool {
382 s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
383}
384
385impl ActionStatementV2 {
386 pub fn new(actor: impl Into<String>, action: impl Into<String>, mandate: Mandate) -> Self {
388 Self {
389 type_: TYPE_ACTION_V2.into(),
390 timestamp: super::unix_to_rfc3339(now_unix()),
391 actor: actor.into(),
392 action: action.into(),
393 audience: None,
394 subject: SubjectRef::default(),
395 parent_id: None,
396 mandate,
397 effect: None,
398 runtime: None,
399 meta: None,
400 }
401 }
402}
403
404fn now_unix() -> u64 {
405 use std::time::{SystemTime, UNIX_EPOCH};
406 SystemTime::now()
407 .duration_since(UNIX_EPOCH)
408 .unwrap_or_default()
409 .as_secs()
410}
411
412pub fn action_in_scope(action: &str, scope: &[String]) -> bool {
422 scope.iter().any(|entry| scope_entry_matches(entry, action))
423}
424
425fn scope_entry_matches(entry: &str, action: &str) -> bool {
426 if let Some(prefix) = entry.strip_suffix(".*") {
427 action == prefix || action.starts_with(&format!("{prefix}."))
428 } else {
429 entry == action
430 }
431}
432
433#[derive(Debug, Clone, PartialEq, Eq)]
439pub enum RevocationStatus {
440 NotRevoked,
442 RevokedAt(String),
444 Unknown(String),
447}
448
449pub trait RevocationSource {
455 fn status(&self, grant_id: &str, path: &str) -> RevocationStatus;
456}
457
458pub struct NoRevocationSource;
460
461impl RevocationSource for NoRevocationSource {
462 fn status(&self, _grant_id: &str, path: &str) -> RevocationStatus {
463 RevocationStatus::Unknown(format!("no revocation source configured for path '{path}'"))
464 }
465}
466
467#[derive(Debug, Clone, PartialEq, Eq)]
477pub enum MandateVerdict {
478 Pass,
479 Unverified(Vec<String>),
480 Fail(Vec<String>),
481}
482
483impl MandateVerdict {
484 pub fn is_pass(&self) -> bool {
485 matches!(self, MandateVerdict::Pass)
486 }
487}
488
489pub fn verify_mandate(
500 stmt: &ActionStatementV2,
501 revocation: &dyn RevocationSource,
502) -> MandateVerdict {
503 let mut fail: Vec<String> = Vec::new();
504 let mut unver: Vec<String> = Vec::new();
505
506 if stmt.type_ != TYPE_ACTION_V2 {
507 return MandateVerdict::Fail(vec![format!(
508 "statement type '{}' is not {TYPE_ACTION_V2}",
509 stmt.type_
510 )]);
511 }
512
513 let m = &stmt.mandate;
514
515 let signed_at = match parse_rfc3339_to_unix(&stmt.timestamp) {
518 Some(t) => t,
519 None => {
520 return MandateVerdict::Fail(vec![format!(
521 "timestamp '{}' is not RFC 3339",
522 stmt.timestamp
523 )])
524 }
525 };
526
527 if m.scope.is_empty() {
529 fail.push("mandate.scope is empty: it authorizes no action".into());
530 } else if !action_in_scope(&stmt.action, &m.scope) {
531 fail.push(format!(
532 "action '{}' is not in mandate scope {:?}",
533 stmt.action, m.scope
534 ));
535 }
536
537 if m.audience.trim().is_empty() {
539 fail.push("mandate.audience is empty: the grant is not bound to an audience".into());
540 } else {
541 match &stmt.audience {
542 Some(a) if a == &m.audience => {}
543 Some(a) => fail.push(format!(
544 "action audience '{a}' does not match mandate audience '{}'",
545 m.audience
546 )),
547 None => unver
548 .push("action recorded no audience; cannot confirm it matched the mandate".into()),
549 }
550 }
551
552 match (
554 parse_rfc3339_to_unix(&m.issued_at),
555 parse_rfc3339_to_unix(&m.expiry),
556 ) {
557 (Some(issued), Some(expiry)) => {
558 if expiry <= issued {
559 fail.push(format!(
560 "mandate expiry '{}' is not after issued_at '{}'",
561 m.expiry, m.issued_at
562 ));
563 }
564 if signed_at < issued {
565 fail.push(format!(
566 "signed_at '{}' is before mandate issued_at '{}'",
567 stmt.timestamp, m.issued_at
568 ));
569 }
570 if signed_at >= expiry {
571 fail.push(format!(
572 "signed_at '{}' is at or after mandate expiry '{}'",
573 stmt.timestamp, m.expiry
574 ));
575 }
576 }
577 _ => fail.push(format!(
578 "mandate issued_at '{}' / expiry '{}' are not both RFC 3339",
579 m.issued_at, m.expiry
580 )),
581 }
582
583 match revocation.status(&m.grant_id, &m.revocation.path) {
586 RevocationStatus::NotRevoked => {}
587 RevocationStatus::RevokedAt(ts) => match parse_rfc3339_to_unix(&ts) {
588 Some(revoked_at) => {
589 if signed_at >= revoked_at {
590 fail.push(format!(
591 "grant was revoked at '{ts}'; signed_at '{}' is not before revocation",
592 stmt.timestamp
593 ));
594 }
595 }
596 None => unver.push(format!("revocation timestamp '{ts}' is not RFC 3339")),
597 },
598 RevocationStatus::Unknown(reason) => {
599 unver.push(format!("revocation could not be checked: {reason}"))
600 }
601 }
602
603 if !fail.is_empty() {
604 MandateVerdict::Fail(fail)
605 } else if !unver.is_empty() {
606 MandateVerdict::Unverified(unver)
607 } else {
608 MandateVerdict::Pass
609 }
610}
611
612pub trait WitnessAuthority {
625 fn is_trusted(&self, actor: &str, effect: &Effect, witness: &Witness) -> bool;
626}
627
628pub struct NoWitnessAuthority;
631
632impl WitnessAuthority for NoWitnessAuthority {
633 fn is_trusted(&self, _actor: &str, _effect: &Effect, _witness: &Witness) -> bool {
634 false
635 }
636}
637
638#[derive(Debug, Clone, PartialEq, Eq)]
644pub struct EffectVerdict {
645 pub effective_confidence: EffectConfidence,
651 pub claimed_confidence: Option<EffectConfidence>,
654 pub trusted_witnesses: usize,
656 pub notes: Vec<String>,
658}
659
660impl EffectVerdict {
661 pub fn is_verified(&self) -> bool {
663 self.effective_confidence == EffectConfidence::Verified
664 }
665}
666
667pub fn verify_effect(stmt: &ActionStatementV2, witnesses: &dyn WitnessAuthority) -> EffectVerdict {
682 let effect = match &stmt.effect {
683 Some(e) => e,
684 None => {
685 return EffectVerdict {
686 effective_confidence: EffectConfidence::NotVerified,
687 claimed_confidence: None,
688 trusted_witnesses: 0,
689 notes: vec!["receipt carries no effect block; effect is unverified".into()],
690 }
691 }
692 };
693
694 let mut notes: Vec<String> = Vec::new();
695
696 let trusted_witnesses = effect
697 .witnesses
698 .iter()
699 .filter(|w| witnesses.is_trusted(&stmt.actor, effect, w))
700 .count();
701 let untrusted = effect.witnesses.len() - trusted_witnesses;
702 if untrusted > 0 {
703 notes.push(format!(
704 "{untrusted} of {} bundled witness(es) not independently trusted; they add no evidence",
705 effect.witnesses.len()
706 ));
707 }
708
709 let has_evidence = effect.has_independent_evidence() || trusted_witnesses > 0;
712
713 let claimed = effect.effect_confidence;
714 let effective = match claimed {
715 None => {
716 notes.push("actor recorded no effect_confidence; effect is unverified".into());
717 EffectConfidence::NotVerified
718 }
719 Some(EffectConfidence::Verified) if !has_evidence => {
720 notes.push(
721 "actor claimed Verified but bundled no independent evidence \
722 (no readback, no trusted witness); downgraded to NotVerified"
723 .into(),
724 );
725 EffectConfidence::NotVerified
726 }
727 Some(c) => c,
728 };
729
730 EffectVerdict {
731 effective_confidence: effective,
732 claimed_confidence: claimed,
733 trusted_witnesses,
734 notes,
735 }
736}
737
738#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
748pub struct Grant {
749 pub grant_id: String,
750 pub grantor: String,
752 #[serde(default)]
753 pub scope: Vec<String>,
754 pub audience: String,
755 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub parent_request_id: Option<String>,
757 #[serde(default)]
758 pub delegation_depth: u32,
759 pub issued_at: String,
760 pub expiry: String,
761 #[serde(default)]
762 pub max_delegation: u32,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
764 pub objective_hash: Option<String>,
765}
766
767impl Grant {
768 pub fn canonical_for_signing(&self) -> String {
773 let scope_digest = canonical_json_digest(&self.scope);
774 format!(
775 "v1|grant|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
776 self.grant_id,
777 self.grantor,
778 scope_digest,
779 self.audience,
780 self.parent_request_id.as_deref().unwrap_or(""),
781 self.delegation_depth,
782 self.issued_at,
783 self.expiry,
784 self.max_delegation,
785 self.objective_hash.as_deref().unwrap_or(""),
786 )
787 }
788
789 pub fn sign_canonical(&self, signer: &dyn Signer) -> Result<String, SignerError> {
793 let sig = signer.sign(self.canonical_for_signing().as_bytes())?;
794 Ok(URL_SAFE_NO_PAD.encode(sig))
795 }
796
797 pub fn verify_canonical(&self, signature_b64url: &str) -> bool {
802 let pk_bytes = match URL_SAFE_NO_PAD.decode(self.grantor.as_bytes()) {
803 Ok(b) if b.len() == 32 => b,
804 _ => return false,
805 };
806 let sig_bytes = match URL_SAFE_NO_PAD.decode(signature_b64url.as_bytes()) {
807 Ok(b) if b.len() == 64 => b,
808 _ => return false,
809 };
810 let mut pk = [0u8; 32];
811 pk.copy_from_slice(&pk_bytes);
812 let mut sig = [0u8; 64];
813 sig.copy_from_slice(&sig_bytes);
814 let vk = match VerifyingKey::from_bytes(&pk) {
815 Ok(k) => k,
816 Err(_) => return false,
817 };
818 vk.verify_strict(
819 self.canonical_for_signing().as_bytes(),
820 &Signature::from_bytes(&sig),
821 )
822 .is_ok()
823 }
824}
825
826#[derive(Debug, Clone, PartialEq, Eq)]
828pub enum GrantChainError {
829 Empty,
831 BadTimestamp { index: usize },
833 ScopeWidened { parent: usize },
835 ExpiryWidened { parent: usize },
837 DepthNotIncremented { parent: usize },
839 DepthExceedsMax { parent: usize },
841 AudienceChanged { parent: usize },
843}
844
845pub fn verify_grant_chain(chain: &[Grant]) -> Result<(), GrantChainError> {
855 if chain.is_empty() {
856 return Err(GrantChainError::Empty);
857 }
858
859 for (i, g) in chain.iter().enumerate() {
862 if parse_rfc3339_to_unix(&g.issued_at).is_none()
863 || parse_rfc3339_to_unix(&g.expiry).is_none()
864 {
865 return Err(GrantChainError::BadTimestamp { index: i });
866 }
867 }
868
869 for (i, pair) in chain.windows(2).enumerate() {
870 let parent = &pair[0];
871 let child = &pair[1];
872
873 if !scope_subset(&child.scope, &parent.scope) {
874 return Err(GrantChainError::ScopeWidened { parent: i });
875 }
876
877 let parent_expiry = parse_rfc3339_to_unix(&parent.expiry).unwrap();
879 let child_expiry = parse_rfc3339_to_unix(&child.expiry).unwrap();
880 if child_expiry > parent_expiry {
881 return Err(GrantChainError::ExpiryWidened { parent: i });
882 }
883
884 if child.delegation_depth != parent.delegation_depth + 1 {
885 return Err(GrantChainError::DepthNotIncremented { parent: i });
886 }
887 if child.delegation_depth > parent.max_delegation {
888 return Err(GrantChainError::DepthExceedsMax { parent: i });
889 }
890
891 if child.audience != parent.audience {
892 return Err(GrantChainError::AudienceChanged { parent: i });
893 }
894 }
895
896 Ok(())
897}
898
899fn scope_subset(child: &[String], parent: &[String]) -> bool {
903 child
904 .iter()
905 .all(|c| parent.iter().any(|p| scope_entry_covers(p, c)))
906}
907
908fn scope_entry_covers(parent: &str, child: &str) -> bool {
909 if parent == child {
910 return true;
911 }
912 if let Some(parent_prefix) = parent.strip_suffix(".*") {
913 let child_core = child.strip_suffix(".*").unwrap_or(child);
916 child_core == parent_prefix || child_core.starts_with(&format!("{parent_prefix}."))
917 } else {
918 false
919 }
920}
921
922#[cfg(test)]
923mod tests {
924 use super::*;
925 use crate::attestation::{sign, Ed25519Signer, Verifier as EnvVerifier};
926
927 #[test]
928 fn effect_confidence_ceiling_gates_on_independent_evidence() {
929 let with_evidence = Effect {
931 readback: Some("sha256:observed".into()),
932 effect_confidence: Some(EffectConfidence::Verified),
933 ..Default::default()
934 };
935 assert!(with_evidence.has_independent_evidence());
936 assert_eq!(with_evidence.evidence_ceiling(), EffectConfidence::Verified);
937
938 let claim_only = Effect {
941 output_hash: Some("sha256:out".into()),
942 effect_confidence: Some(EffectConfidence::Verified),
943 ..Default::default()
944 };
945 assert!(!claim_only.has_independent_evidence());
946 assert_eq!(claim_only.evidence_ceiling(), EffectConfidence::NotVerified);
947
948 let honest_downgrade = Effect {
950 effect_confidence: Some(EffectConfidence::Unknown),
951 ..Default::default()
952 };
953 assert_eq!(
954 honest_downgrade.evidence_ceiling(),
955 EffectConfidence::NotVerified
956 );
957 }
958
959 #[test]
960 fn effect_confidence_serializes_snake_case_and_is_omitted_when_absent() {
961 let e = Effect {
962 effect_confidence: Some(EffectConfidence::NotVerified),
963 ..Default::default()
964 };
965 let j = serde_json::to_string(&e).unwrap();
966 assert!(j.contains("\"effect_confidence\":\"not_verified\""), "{j}");
967
968 let empty = Effect::default();
970 assert!(!serde_json::to_string(&empty)
971 .unwrap()
972 .contains("effect_confidence"));
973 }
974
975 struct TrustingWitnessAuthority;
979 impl WitnessAuthority for TrustingWitnessAuthority {
980 fn is_trusted(&self, actor: &str, effect: &Effect, w: &Witness) -> bool {
981 w.is_signed()
982 && w.observer != actor
983 && effect.readback.as_deref() == Some(w.observation.as_str())
984 }
985 }
986
987 #[test]
988 fn verify_effect_downgrades_unbacked_verified_claim() {
989 let mut s = good_stmt();
991 s.actor = "agent://worker".into();
992 s.effect = Some(Effect {
993 output_hash: Some("sha256:out".into()),
994 effect_confidence: Some(EffectConfidence::Verified),
995 ..Default::default()
996 });
997 let v = verify_effect(&s, &NoWitnessAuthority);
998 assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
999 assert_eq!(v.claimed_confidence, Some(EffectConfidence::Verified));
1000 assert!(!v.is_verified());
1001 assert!(
1002 v.notes.iter().any(|n| n.contains("downgraded")),
1003 "{:?}",
1004 v.notes
1005 );
1006 }
1007
1008 #[test]
1009 fn verify_effect_honors_verified_backed_by_readback() {
1010 let mut s = good_stmt();
1011 s.effect = Some(Effect {
1012 readback: Some("sha256:observed".into()),
1013 effect_confidence: Some(EffectConfidence::Verified),
1014 ..Default::default()
1015 });
1016 let v = verify_effect(&s, &NoWitnessAuthority);
1017 assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1018 assert!(v.is_verified());
1019 }
1020
1021 #[test]
1022 fn verify_effect_trusts_a_vouched_witness_over_no_readback() {
1023 let mut s = good_stmt();
1026 s.actor = "agent://worker".into();
1027 s.effect = Some(Effect {
1028 readback: Some("sha256:state".into()),
1029 effect_confidence: Some(EffectConfidence::Verified),
1030 witnesses: vec![Witness {
1031 observer: "agent://auditor".into(),
1032 observation: "sha256:state".into(),
1033 observed_at: Some("2026-07-20T10:00:00Z".into()),
1034 signature: Some("ed25519:sig".into()),
1035 }],
1036 ..Default::default()
1037 });
1038 let v = verify_effect(&s, &TrustingWitnessAuthority);
1039 assert_eq!(v.trusted_witnesses, 1);
1040 assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1041
1042 let mut self_witness = s.clone();
1044 if let Some(e) = self_witness.effect.as_mut() {
1045 e.readback = None; e.witnesses[0].observer = "agent://worker".into();
1047 }
1048 let v2 = verify_effect(&self_witness, &TrustingWitnessAuthority);
1049 assert_eq!(v2.trusted_witnesses, 0);
1050 assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1051 assert!(v2
1052 .notes
1053 .iter()
1054 .any(|n| n.contains("not independently trusted")));
1055 }
1056
1057 #[test]
1058 fn verify_effect_passes_honest_lesser_claims_through_unchanged() {
1059 for c in [
1062 EffectConfidence::Partial,
1063 EffectConfidence::Ambiguous,
1064 EffectConfidence::Unknown,
1065 EffectConfidence::NotVerified,
1066 ] {
1067 let mut s = good_stmt();
1068 s.effect = Some(Effect {
1069 effect_confidence: Some(c),
1070 ..Default::default()
1071 });
1072 let v = verify_effect(&s, &NoWitnessAuthority);
1073 assert_eq!(v.effective_confidence, c, "claim {c:?} should pass through");
1074 }
1075 }
1076
1077 #[test]
1078 fn verify_effect_reports_unverified_when_no_effect_or_no_claim() {
1079 let s = good_stmt();
1081 assert!(s.effect.is_none());
1082 let v = verify_effect(&s, &NoWitnessAuthority);
1083 assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1084 assert_eq!(v.claimed_confidence, None);
1085 assert!(v.notes.iter().any(|n| n.contains("no effect block")));
1086
1087 let mut s2 = good_stmt();
1089 s2.effect = Some(Effect {
1090 output_hash: Some("sha256:out".into()),
1091 ..Default::default()
1092 });
1093 let v2 = verify_effect(&s2, &NoWitnessAuthority);
1094 assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1095 assert!(v2.notes.iter().any(|n| n.contains("no effect_confidence")));
1096 }
1097
1098 #[test]
1099 fn witness_does_not_inflate_evidence_ceiling() {
1100 let signed_witness = Witness {
1105 observer: "agent://auditor".into(),
1106 observation: "sha256:observed".into(),
1107 observed_at: Some("2026-07-20T10:00:00Z".into()),
1108 signature: Some("ed25519:sig".into()),
1109 };
1110 let e = Effect {
1111 witnesses: vec![signed_witness.clone()],
1112 effect_confidence: Some(EffectConfidence::Verified),
1113 ..Default::default()
1114 };
1115 assert!(!e.has_independent_evidence());
1116 assert_eq!(e.evidence_ceiling(), EffectConfidence::NotVerified);
1117 assert!(signed_witness.is_signed());
1120 assert_eq!(e.signed_witnesses().count(), 1);
1121
1122 let unsigned = Effect {
1124 witnesses: vec![Witness {
1125 observer: "agent://auditor".into(),
1126 observation: "sha256:observed".into(),
1127 ..Default::default()
1128 }],
1129 ..Default::default()
1130 };
1131 assert_eq!(unsigned.signed_witnesses().count(), 0);
1132 }
1133
1134 #[test]
1135 fn witnesses_serialize_and_omit_when_empty() {
1136 let empty = Effect::default();
1137 assert!(!serde_json::to_string(&empty).unwrap().contains("witnesses"));
1138
1139 let e = Effect {
1140 witnesses: vec![Witness {
1141 observer: "key_9f2c".into(),
1142 observation: "sha256:obs".into(),
1143 observed_at: None,
1144 signature: Some("ed25519:sig".into()),
1145 }],
1146 ..Default::default()
1147 };
1148 let j = serde_json::to_string(&e).unwrap();
1149 assert!(j.contains("\"witnesses\":[{"), "{j}");
1150 assert!(j.contains("\"observer\":\"key_9f2c\""), "{j}");
1151 assert!(!j.contains("observed_at"), "{j}");
1153 let back: Effect = serde_json::from_str(&j).unwrap();
1154 assert_eq!(back.witnesses.len(), 1);
1155 assert!(back.witnesses[0].is_signed());
1156 }
1157
1158 #[test]
1159 fn runtime_identity_is_unbound_only_when_all_fields_absent() {
1160 assert!(RuntimeIdentity::default().is_unbound());
1161
1162 let with_model = RuntimeIdentity {
1164 model: Some("claude-opus-4-8".into()),
1165 ..Default::default()
1166 };
1167 assert!(!with_model.is_unbound());
1168
1169 let with_prompt = RuntimeIdentity {
1170 system_prompt_hash: Some("sha256:sys".into()),
1171 ..Default::default()
1172 };
1173 assert!(!with_prompt.is_unbound());
1174 }
1175
1176 #[test]
1177 fn runtime_identity_serializes_snake_case_and_omits_absent_fields() {
1178 let rt = RuntimeIdentity {
1179 provider: Some("anthropic".into()),
1180 model: Some("claude-opus-4-8".into()),
1181 tool_schema_hash: Some("sha256:tools".into()),
1182 system_prompt_hash: None,
1183 };
1184 let j = serde_json::to_string(&rt).unwrap();
1185 assert!(j.contains("\"provider\":\"anthropic\""), "{j}");
1186 assert!(j.contains("\"model\":\"claude-opus-4-8\""), "{j}");
1187 assert!(j.contains("\"tool_schema_hash\":\"sha256:tools\""), "{j}");
1188 assert!(!j.contains("system_prompt_hash"), "{j}");
1190
1191 let empty = serde_json::to_string(&RuntimeIdentity::default()).unwrap();
1193 assert_eq!(empty, "{}");
1194 let back: RuntimeIdentity = serde_json::from_str(&empty).unwrap();
1195 assert!(back.is_unbound());
1196 }
1197
1198 #[test]
1199 fn runtime_is_omitted_from_statement_when_absent() {
1200 let s = good_stmt();
1203 assert!(s.runtime.is_none());
1204 let j = serde_json::to_string(&s).unwrap();
1205 assert!(!j.contains("runtime"), "{j}");
1206
1207 let mut with_rt = good_stmt();
1209 with_rt.runtime = Some(RuntimeIdentity {
1210 model: Some("claude-opus-4-8".into()),
1211 ..Default::default()
1212 });
1213 let j2 = serde_json::to_string(&with_rt).unwrap();
1214 assert!(j2.contains("\"runtime\""), "{j2}");
1215 let back: ActionStatementV2 = serde_json::from_str(&j2).unwrap();
1216 assert_eq!(
1217 back.runtime.unwrap().model.as_deref(),
1218 Some("claude-opus-4-8")
1219 );
1220 }
1221
1222 fn base_mandate() -> Mandate {
1223 Mandate {
1224 grant_id: "grant_9c2f".into(),
1225 grantor: "key_parent".into(),
1226 issuer_sig: None,
1227 objective_hash: Some("sha256:abc".into()),
1228 scope: vec!["payments.charge".into()],
1229 audience: "acme-payments-api".into(),
1230 parent_request_id: Some("req_7d3e".into()),
1231 delegation_depth: 2,
1232 issued_at: "2026-07-11T19:50:00Z".into(),
1233 expiry: "2026-07-11T20:50:00Z".into(),
1234 max_delegation: 3,
1235 revocation: Revocation {
1236 path: "hub://acme/revocations".into(),
1237 revoked_at: None,
1238 },
1239 }
1240 }
1241
1242 fn good_stmt() -> ActionStatementV2 {
1245 let mut s = ActionStatementV2::new("ship://ship_f9ba", "payments.charge", base_mandate());
1246 s.timestamp = "2026-07-11T19:53:09Z".into();
1247 s.audience = Some("acme-payments-api".into());
1248 s
1249 }
1250
1251 struct StaticRevocation(RevocationStatus);
1252 impl RevocationSource for StaticRevocation {
1253 fn status(&self, _g: &str, _p: &str) -> RevocationStatus {
1254 self.0.clone()
1255 }
1256 }
1257
1258 #[test]
1261 fn scope_exact_and_glob() {
1262 assert!(action_in_scope(
1263 "payments.charge",
1264 &["payments.charge".into()]
1265 ));
1266 assert!(action_in_scope("payments.charge", &["payments.*".into()]));
1267 assert!(action_in_scope("payments", &["payments.*".into()]));
1268 assert!(!action_in_scope(
1269 "payments.refund",
1270 &["payments.charge".into()]
1271 ));
1272 assert!(!action_in_scope("email.send", &["payments.*".into()]));
1273 assert!(!action_in_scope("anything", &["*".into()]));
1275 assert!(action_in_scope("*", &["*".into()]));
1276 }
1277
1278 #[test]
1279 fn empty_scope_authorizes_nothing() {
1280 let mut s = good_stmt();
1281 s.mandate.scope = vec![];
1282 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
1283 MandateVerdict::Fail(rs) => assert!(rs.iter().any(|r| r.contains("scope is empty"))),
1284 v => panic!("empty scope must fail, got {v:?}"),
1285 }
1286 }
1287
1288 #[test]
1289 fn action_out_of_scope_fails() {
1290 let mut s = good_stmt();
1291 s.action = "payments.refund".into();
1292 assert!(matches!(
1293 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1294 MandateVerdict::Fail(_)
1295 ));
1296 }
1297
1298 #[test]
1301 fn audience_match_passes_layer() {
1302 let s = good_stmt();
1303 assert_eq!(
1304 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1305 MandateVerdict::Pass
1306 );
1307 }
1308
1309 #[test]
1310 fn audience_mismatch_fails() {
1311 let mut s = good_stmt();
1312 s.audience = Some("evil-api".into());
1313 assert!(matches!(
1314 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1315 MandateVerdict::Fail(_)
1316 ));
1317 }
1318
1319 #[test]
1320 fn missing_action_audience_is_unverified_not_pass() {
1321 let mut s = good_stmt();
1322 s.audience = None;
1323 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
1324 MandateVerdict::Unverified(rs) => {
1325 assert!(rs.iter().any(|r| r.contains("recorded no audience")))
1326 }
1327 v => panic!("missing audience must be Unverified, got {v:?}"),
1328 }
1329 }
1330
1331 #[test]
1332 fn empty_mandate_audience_fails() {
1333 let mut s = good_stmt();
1334 s.mandate.audience = "".into();
1335 assert!(matches!(
1336 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1337 MandateVerdict::Fail(_)
1338 ));
1339 }
1340
1341 #[test]
1344 fn signed_before_issued_fails() {
1345 let mut s = good_stmt();
1346 s.timestamp = "2026-07-11T19:49:59Z".into(); assert!(matches!(
1348 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1349 MandateVerdict::Fail(_)
1350 ));
1351 }
1352
1353 #[test]
1354 fn signed_at_expiry_fails() {
1355 let mut s = good_stmt();
1356 s.timestamp = "2026-07-11T20:50:00Z".into(); assert!(matches!(
1358 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1359 MandateVerdict::Fail(_)
1360 ));
1361 }
1362
1363 #[test]
1364 fn signed_within_window_passes() {
1365 let s = good_stmt(); assert_eq!(
1367 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1368 MandateVerdict::Pass
1369 );
1370 }
1371
1372 #[test]
1373 fn malformed_timestamp_fails_closed() {
1374 let mut s = good_stmt();
1375 s.timestamp = "not-a-timestamp".into();
1376 assert!(matches!(
1377 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1378 MandateVerdict::Fail(_)
1379 ));
1380 }
1381
1382 #[test]
1385 fn revoked_after_signing_still_passes() {
1386 let s = good_stmt();
1389 let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T20:00:00Z".into()));
1390 assert_eq!(verify_mandate(&s, &src), MandateVerdict::Pass);
1391 }
1392
1393 #[test]
1394 fn revoked_before_signing_fails() {
1395 let s = good_stmt(); let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T19:52:00Z".into()));
1397 assert!(matches!(verify_mandate(&s, &src), MandateVerdict::Fail(_)));
1398 }
1399
1400 #[test]
1401 fn revocation_unknown_is_unverified() {
1402 let s = good_stmt();
1403 match verify_mandate(&s, &NoRevocationSource) {
1404 MandateVerdict::Unverified(rs) => {
1405 assert!(rs
1406 .iter()
1407 .any(|r| r.contains("revocation could not be checked")))
1408 }
1409 v => panic!("no revocation source must be Unverified, got {v:?}"),
1410 }
1411 }
1412
1413 #[test]
1414 fn fail_takes_precedence_over_unverified() {
1415 let mut s = good_stmt();
1418 s.action = "payments.refund".into();
1419 assert!(matches!(
1420 verify_mandate(&s, &NoRevocationSource),
1421 MandateVerdict::Fail(_)
1422 ));
1423 }
1424
1425 #[test]
1426 fn wrong_type_fails() {
1427 let mut s = good_stmt();
1428 s.type_ = "treeship/action/v1".into();
1429 assert!(matches!(
1430 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1431 MandateVerdict::Fail(_)
1432 ));
1433 }
1434
1435 #[test]
1438 fn mandate_is_bound_into_signature() {
1439 let signer = Ed25519Signer::generate("key_test").unwrap();
1440 let pt = payload_type_v2("action");
1441
1442 let a = good_stmt();
1443 let mut b = good_stmt();
1444 b.mandate.scope = vec!["payments.*".into()]; let ra = sign(&pt, &a, &signer).unwrap();
1447 let rb = sign(&pt, &b, &signer).unwrap();
1448 assert_ne!(
1449 ra.artifact_id, rb.artifact_id,
1450 "changing mandate.scope must change the signed artifact id"
1451 );
1452 }
1453
1454 #[test]
1455 fn v2_sign_verify_roundtrip() {
1456 let signer = Ed25519Signer::generate("key_test").unwrap();
1457 let verifier = EnvVerifier::from_signer(&signer);
1458 let pt = payload_type_v2("action");
1459
1460 let mut s = good_stmt();
1461 s.effect = Some(Effect {
1462 output_hash: Some("sha256:out".into()),
1463 readback: Some("sha256:observed".into()),
1464 bytes_moved: Some(1_048_576),
1465 cost: Some(Cost {
1466 unit: "usd_micros".into(),
1467 amount: 4200,
1468 }),
1469 side_effects: vec!["db:users.update".into()],
1470 ..Default::default()
1471 });
1472
1473 let signed = sign(&pt, &s, &signer).unwrap();
1474 verifier.verify(&signed.envelope).unwrap();
1475
1476 let decoded: ActionStatementV2 = signed.envelope.unmarshal_statement().unwrap();
1477 assert_eq!(decoded.type_, TYPE_ACTION_V2);
1478 assert_eq!(decoded.mandate.grant_id, "grant_9c2f");
1479 assert_eq!(decoded.effect.unwrap().cost.unwrap().amount, 4200);
1480 }
1481
1482 #[test]
1483 fn v2_payload_type_differs_from_v1() {
1484 assert_eq!(
1485 payload_type_v2("action"),
1486 "application/vnd.treeship.action.v2+json"
1487 );
1488 assert_ne!(
1489 payload_type_v2("action"),
1490 super::super::payload_type("action")
1491 );
1492 }
1493
1494 fn grant(
1497 id: &str,
1498 grantor: &str,
1499 scope: &[&str],
1500 depth: u32,
1501 expiry: &str,
1502 max_deleg: u32,
1503 ) -> Grant {
1504 Grant {
1505 grant_id: id.into(),
1506 grantor: grantor.into(),
1507 scope: scope.iter().map(|s| (*s).into()).collect(),
1508 audience: "acme-payments-api".into(),
1509 parent_request_id: None,
1510 delegation_depth: depth,
1511 issued_at: "2026-07-11T19:00:00Z".into(),
1512 expiry: expiry.into(),
1513 max_delegation: max_deleg,
1514 objective_hash: None,
1515 }
1516 }
1517
1518 #[test]
1519 fn grant_sign_verify_roundtrip_and_tamper() {
1520 let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
1521 let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
1522 let mut g = grant(
1523 "grant_root",
1524 &grantor,
1525 &["payments.*"],
1526 0,
1527 "2026-07-11T21:00:00Z",
1528 3,
1529 );
1530
1531 let sig = g.sign_canonical(&signer).unwrap();
1532 assert!(g.verify_canonical(&sig));
1533
1534 g.scope.push("email.*".into());
1536 assert!(!g.verify_canonical(&sig));
1537 }
1538
1539 #[test]
1540 fn grant_verify_rejects_wrong_key() {
1541 let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
1542 let attacker = Ed25519Signer::from_bytes("a", &[3u8; 32]).unwrap();
1543 let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
1544 let g = grant(
1545 "grant_root",
1546 &grantor,
1547 &["payments.*"],
1548 0,
1549 "2026-07-11T21:00:00Z",
1550 3,
1551 );
1552 let sig = g.sign_canonical(&attacker).unwrap();
1553 assert!(!g.verify_canonical(&sig));
1554 }
1555
1556 #[test]
1557 fn valid_attenuating_chain_ok() {
1558 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
1559 let child = grant(
1560 "g1",
1561 "k",
1562 &["payments.charge"],
1563 1,
1564 "2026-07-11T20:30:00Z",
1565 3,
1566 );
1567 assert_eq!(verify_grant_chain(&[root, child]), Ok(()));
1568 }
1569
1570 #[test]
1571 fn scope_widening_rejected() {
1572 let root = grant(
1573 "g0",
1574 "k",
1575 &["payments.charge"],
1576 0,
1577 "2026-07-11T21:00:00Z",
1578 3,
1579 );
1580 let child = grant("g1", "k", &["payments.*"], 1, "2026-07-11T21:00:00Z", 3);
1581 assert_eq!(
1582 verify_grant_chain(&[root, child]),
1583 Err(GrantChainError::ScopeWidened { parent: 0 })
1584 );
1585 }
1586
1587 #[test]
1588 fn expiry_widening_rejected() {
1589 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
1590 let child = grant(
1591 "g1",
1592 "k",
1593 &["payments.charge"],
1594 1,
1595 "2026-07-11T22:00:00Z",
1596 3,
1597 );
1598 assert_eq!(
1599 verify_grant_chain(&[root, child]),
1600 Err(GrantChainError::ExpiryWidened { parent: 0 })
1601 );
1602 }
1603
1604 #[test]
1605 fn depth_not_incremented_rejected() {
1606 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
1607 let child = grant(
1608 "g1",
1609 "k",
1610 &["payments.charge"],
1611 2,
1612 "2026-07-11T21:00:00Z",
1613 3,
1614 );
1615 assert_eq!(
1616 verify_grant_chain(&[root, child]),
1617 Err(GrantChainError::DepthNotIncremented { parent: 0 })
1618 );
1619 }
1620
1621 #[test]
1622 fn depth_exceeds_max_rejected() {
1623 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 0);
1624 let child = grant(
1625 "g1",
1626 "k",
1627 &["payments.charge"],
1628 1,
1629 "2026-07-11T21:00:00Z",
1630 0,
1631 );
1632 assert_eq!(
1633 verify_grant_chain(&[root, child]),
1634 Err(GrantChainError::DepthExceedsMax { parent: 0 })
1635 );
1636 }
1637
1638 #[test]
1639 fn audience_change_rejected() {
1640 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
1641 let mut child = grant(
1642 "g1",
1643 "k",
1644 &["payments.charge"],
1645 1,
1646 "2026-07-11T21:00:00Z",
1647 3,
1648 );
1649 child.audience = "other-api".into();
1650 assert_eq!(
1651 verify_grant_chain(&[root, child]),
1652 Err(GrantChainError::AudienceChanged { parent: 0 })
1653 );
1654 }
1655
1656 #[test]
1657 fn empty_chain_rejected() {
1658 assert_eq!(verify_grant_chain(&[]), Err(GrantChainError::Empty));
1659 }
1660
1661 #[test]
1662 fn bad_timestamp_in_chain_rejected() {
1663 let mut root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
1664 root.expiry = "nope".into();
1665 assert_eq!(
1666 verify_grant_chain(&[root]),
1667 Err(GrantChainError::BadTimestamp { index: 0 })
1668 );
1669 }
1670
1671 #[test]
1672 fn single_grant_chain_ok() {
1673 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
1674 assert_eq!(verify_grant_chain(&[root]), Ok(()));
1675 }
1676}