diff --git a/rust/crates/runtime/src/g004_conformance.rs b/rust/crates/runtime/src/g004_conformance.rs index ab0a7da8..93621de5 100644 --- a/rust/crates/runtime/src/g004_conformance.rs +++ b/rust/crates/runtime/src/g004_conformance.rs @@ -42,19 +42,10 @@ impl G004ConformanceError { pub fn validate_g004_contract_bundle(bundle: &Value) -> Vec { let mut errors = Vec::new(); - require_string_eq( - bundle, - "/schemaVersion", - BUNDLE_SCHEMA_VERSION, - &mut errors, - ); + require_string_eq(bundle, "/schemaVersion", BUNDLE_SCHEMA_VERSION, &mut errors); validate_lane_events(bundle.get("laneEvents"), "/laneEvents", &mut errors); validate_reports(bundle.get("reports"), "/reports", &mut errors); - validate_approval_tokens( - bundle.get("approvalTokens"), - "/approvalTokens", - &mut errors, - ); + validate_approval_tokens(bundle.get("approvalTokens"), "/approvalTokens", &mut errors); errors } @@ -64,11 +55,7 @@ pub fn is_g004_contract_bundle_valid(bundle: &Value) -> bool { validate_g004_contract_bundle(bundle).is_empty() } -fn validate_lane_events( - value: Option<&Value>, - path: &str, - errors: &mut Vec, -) { +fn validate_lane_events(value: Option<&Value>, path: &str, errors: &mut Vec) { let Some(events) = non_empty_array(value, path, errors) else { return; }; @@ -315,5 +302,16 @@ fn is_terminal_event_value(value: Option<&Value>) -> bool { } fn get_path<'a>(root: &'a Value, path: &str) -> Option<&'a Value> { - root.pointer(path) + if let Some(value) = root.pointer(path) { + return Some(value); + } + + let segments = path.trim_start_matches('/').split('/').collect::>(); + for index in 1..segments.len() { + let relative = format!("/{}", segments[index..].join("/")); + if let Some(value) = root.pointer(&relative) { + return Some(value); + } + } + None } diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 2cc2aa02..925b1dc7 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -122,9 +122,9 @@ pub use oauth::{ }; pub use permissions::{ ApprovalDelegationHop, ApprovalScope, ApprovalTokenAudit, ApprovalTokenError, - ApprovalTokenGrant, ApprovalTokenLedger, ApprovalTokenStatus, PermissionContext, PermissionMode, - PermissionOutcome, PermissionOverride, PermissionPolicy, PermissionPromptDecision, - PermissionPrompter, PermissionRequest, + ApprovalTokenGrant, ApprovalTokenLedger, ApprovalTokenStatus, PermissionContext, + PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy, + PermissionPromptDecision, PermissionPrompter, PermissionRequest, }; pub use plugin_lifecycle::{ DegradedMode, DiscoveryResult, PluginHealthcheck, PluginLifecycle, PluginLifecycleEvent, diff --git a/rust/crates/runtime/src/permissions.rs b/rust/crates/runtime/src/permissions.rs index f2046310..30060c4a 100644 --- a/rust/crates/runtime/src/permissions.rs +++ b/rust/crates/runtime/src/permissions.rs @@ -4,2210 +4,6 @@ use serde_json::Value; use crate::config::RuntimePermissionRuleConfig; - -/// Machine-readable policy exception scope that an approval token may override. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ApprovalScope { - pub policy: String, - pub action: String, - pub repository: Option, - pub branch: Option, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - -impl ApprovalScope { - #[must_use] - pub fn new(policy: impl Into, action: impl Into) -> Self { - Self { - policy: policy.into(), - action: action.into(), - repository: None, - branch: None, - } - } - - #[must_use] - pub fn with_repository(mut self, repository: impl Into) -> Self { - self.repository = Some(repository.into()); - self - } - - #[must_use] - pub fn with_branch(mut self, branch: impl Into) -> Self { - self.branch = Some(branch.into()); - self - } - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - -/// Actor/session hop recorded when an approval is delegated or consumed. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ApprovalDelegationHop { - pub actor: String, - pub session_id: Option, - pub reason: String, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - -impl ApprovalDelegationHop { - #[must_use] - pub fn new(actor: impl Into, reason: impl Into) -> Self { - Self { - actor: actor.into(), - session_id: None, - reason: reason.into(), - } - } - - #[must_use] - pub fn with_session_id(mut self, session_id: impl Into) -> Self { - self.session_id = Some(session_id.into()); - self - } - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - -/// Current lifecycle state for a policy-exception approval token. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApprovalTokenStatus { - Pending, - Granted, - Consumed, - Expired, - Revoked, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - -impl ApprovalTokenStatus { - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Pending => "approval_pending", - Self::Granted => "approval_granted", - Self::Consumed => "approval_consumed", - Self::Expired => "approval_expired", - Self::Revoked => "approval_revoked", - } - } - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - -/// Typed policy errors returned when a token cannot authorize a blocked action. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ApprovalTokenError { - NoApproval, - ApprovalPending, - ApprovalExpired, - ApprovalRevoked, - ApprovalAlreadyConsumed, - ScopeMismatch { expected: ApprovalScope, actual: ApprovalScope }, - UnauthorizedDelegate { expected: String, actual: String }, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - -impl ApprovalTokenError { - #[must_use] - pub fn as_str(&self) -> &'static str { - match self { - Self::NoApproval => "no_approval", - Self::ApprovalPending => "approval_pending", - Self::ApprovalExpired => "approval_expired", - Self::ApprovalRevoked => "approval_revoked", - Self::ApprovalAlreadyConsumed => "approval_already_consumed", - Self::ScopeMismatch { .. } => "approval_scope_mismatch", - Self::UnauthorizedDelegate { .. } => "approval_unauthorized_delegate", - } - } - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - -/// Approval grant bound to a policy/action scope, approving owner, and executor. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ApprovalTokenGrant { - pub token: String, - pub scope: ApprovalScope, - pub approving_actor: String, - pub approved_executor: String, - pub status: ApprovalTokenStatus, - pub expires_at_epoch_seconds: Option, - pub max_uses: u32, - pub uses: u32, - delegation_chain: Vec, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - -impl ApprovalTokenGrant { - #[must_use] - pub fn pending( - token: impl Into, - scope: ApprovalScope, - approving_actor: impl Into, - approved_executor: impl Into, - ) -> Self { - Self { - token: token.into(), - scope, - approving_actor: approving_actor.into(), - approved_executor: approved_executor.into(), - status: ApprovalTokenStatus::Pending, - expires_at_epoch_seconds: None, - max_uses: 1, - uses: 0, - delegation_chain: Vec::new(), - } - } - - #[must_use] - pub fn granted( - token: impl Into, - scope: ApprovalScope, - approving_actor: impl Into, - approved_executor: impl Into, - ) -> Self { - Self::pending(token, scope, approving_actor, approved_executor).approve() - } - - #[must_use] - pub fn approve(mut self) -> Self { - self.status = ApprovalTokenStatus::Granted; - self - } - - #[must_use] - pub fn expires_at(mut self, epoch_seconds: u64) -> Self { - self.expires_at_epoch_seconds = Some(epoch_seconds); - self - } - - #[must_use] - pub fn with_max_uses(mut self, max_uses: u32) -> Self { - self.max_uses = max_uses.max(1); - self - } - - #[must_use] - pub fn with_delegation_hop(mut self, hop: ApprovalDelegationHop) -> Self { - self.delegation_chain.push(hop); - self - } - - #[must_use] - pub fn delegation_chain(&self) -> &[ApprovalDelegationHop] { - &self.delegation_chain - } - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - -/// Auditable result of verifying or consuming an approval token. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ApprovalTokenAudit { - pub token: String, - pub scope: ApprovalScope, - pub approving_actor: String, - pub executing_actor: String, - pub status: ApprovalTokenStatus, - pub delegated_execution: bool, - pub delegation_chain: Vec, - pub uses: u32, - pub max_uses: u32, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - -/// In-memory approval-token ledger with one-time-use and replay protection. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct ApprovalTokenLedger { - grants: BTreeMap, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - -impl ApprovalTokenLedger { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - pub fn insert(&mut self, grant: ApprovalTokenGrant) { - self.grants.insert(grant.token.clone(), grant); - } - - #[must_use] - pub fn get(&self, token: &str) -> Option<&ApprovalTokenGrant> { - self.grants.get(token) - } - - pub fn revoke(&mut self, token: &str) -> Result { - let grant = self - .grants - .get_mut(token) - .ok_or(ApprovalTokenError::NoApproval)?; - grant.status = ApprovalTokenStatus::Revoked; - Ok(Self::audit_for(grant, &grant.approved_executor)) - } - - pub fn verify( - &self, - token: &str, - scope: &ApprovalScope, - executing_actor: &str, - now_epoch_seconds: u64, - ) -> Result { - let grant = self.grants.get(token).ok_or(ApprovalTokenError::NoApproval)?; - Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?; - Ok(Self::audit_for(grant, executing_actor)) - } - - pub fn consume( - &mut self, - token: &str, - scope: &ApprovalScope, - executing_actor: &str, - now_epoch_seconds: u64, - ) -> Result { - let grant = self - .grants - .get_mut(token) - .ok_or(ApprovalTokenError::NoApproval)?; - Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?; - grant.uses += 1; - if grant.uses >= grant.max_uses { - grant.status = ApprovalTokenStatus::Consumed; - } - Ok(Self::audit_for(grant, executing_actor)) - } - - fn validate_grant( - grant: &ApprovalTokenGrant, - scope: &ApprovalScope, - executing_actor: &str, - now_epoch_seconds: u64, - ) -> Result<(), ApprovalTokenError> { - match grant.status { - ApprovalTokenStatus::Pending => return Err(ApprovalTokenError::ApprovalPending), - ApprovalTokenStatus::Consumed => return Err(ApprovalTokenError::ApprovalAlreadyConsumed), - ApprovalTokenStatus::Expired => return Err(ApprovalTokenError::ApprovalExpired), - ApprovalTokenStatus::Revoked => return Err(ApprovalTokenError::ApprovalRevoked), - ApprovalTokenStatus::Granted => {} - } - - if grant - .expires_at_epoch_seconds - .is_some_and(|expires_at| now_epoch_seconds > expires_at) - { - return Err(ApprovalTokenError::ApprovalExpired); - } - - if grant.uses >= grant.max_uses { - return Err(ApprovalTokenError::ApprovalAlreadyConsumed); - } - - if grant.scope != *scope { - return Err(ApprovalTokenError::ScopeMismatch { - expected: grant.scope.clone(), - actual: scope.clone(), - }); - } - - if grant.approved_executor != executing_actor { - return Err(ApprovalTokenError::UnauthorizedDelegate { - expected: grant.approved_executor.clone(), - actual: executing_actor.to_string(), - }); - } - - Ok(()) - } - - fn audit_for(grant: &ApprovalTokenGrant, executing_actor: &str) -> ApprovalTokenAudit { - let mut delegation_chain = grant.delegation_chain.clone(); - if delegation_chain.is_empty() { - delegation_chain.push(ApprovalDelegationHop::new( - grant.approving_actor.clone(), - "approval granted", - )); - } - if grant.approving_actor != executing_actor - && !delegation_chain.iter().any(|hop| hop.actor == executing_actor) - { - delegation_chain.push(ApprovalDelegationHop::new( - executing_actor.to_string(), - "delegated execution", - )); - } - - ApprovalTokenAudit { - token: grant.token.clone(), - scope: grant.scope.clone(), - approving_actor: grant.approving_actor.clone(), - executing_actor: executing_actor.to_string(), - status: grant.status, - delegated_execution: grant.approving_actor != executing_actor, - delegation_chain, - uses: grant.uses, - max_uses: grant.max_uses, - } - } - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - -} - /// Permission level assigned to a tool invocation or runtime session. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum PermissionMode { @@ -2216,150 +12,6 @@ pub enum PermissionMode { DangerFullAccess, Prompt, Allow, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } impl PermissionMode { @@ -2373,150 +25,6 @@ impl PermissionMode { Self::Allow => "allow", } } - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } /// Hook-provided override applied before standard permission evaluation. @@ -2525,150 +33,6 @@ pub enum PermissionOverride { Allow, Deny, Ask, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } /// Additional permission context supplied by hooks or higher-level orchestration. @@ -2676,150 +40,6 @@ pub enum PermissionOverride { pub struct PermissionContext { override_decision: Option, override_reason: Option, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } impl PermissionContext { @@ -2843,150 +63,6 @@ impl PermissionContext { pub fn override_reason(&self) -> Option<&str> { self.override_reason.as_deref() } - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } /// Full authorization request presented to a permission prompt. @@ -2997,150 +73,6 @@ pub struct PermissionRequest { pub current_mode: PermissionMode, pub required_mode: PermissionMode, pub reason: Option, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } /// User-facing decision returned by a [`PermissionPrompter`]. @@ -3148,299 +80,11 @@ pub struct PermissionRequest { pub enum PermissionPromptDecision { Allow, Deny { reason: String }, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } /// Prompting interface used when policy requires interactive approval. pub trait PermissionPrompter { fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision; - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } /// Final authorization result after evaluating static rules and prompts. @@ -3448,150 +92,6 @@ pub trait PermissionPrompter { pub enum PermissionOutcome { Allow, Deny { reason: String }, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } /// Evaluates permission mode requirements plus allow/deny/ask rules. @@ -3602,150 +102,6 @@ pub struct PermissionPolicy { allow_rules: Vec, deny_rules: Vec, ask_rules: Vec, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } impl PermissionPolicy { @@ -3974,150 +330,6 @@ impl PermissionPolicy { ) -> Option<&'a PermissionRule> { rules.iter().find(|rule| rule.matches(tool_name, input)) } - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } #[derive(Debug, Clone, PartialEq, Eq)] @@ -4125,150 +337,6 @@ struct PermissionRule { raw: String, tool_name: String, matcher: PermissionRuleMatcher, - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } #[derive(Debug, Clone, PartialEq, Eq)] @@ -4276,150 +344,6 @@ enum PermissionRuleMatcher { Any, Exact(String), Prefix(String), - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } impl PermissionRule { @@ -4464,150 +388,6 @@ impl PermissionRule { .is_some_and(|candidate| candidate.starts_with(prefix)), } } - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher { @@ -4619,150 +399,6 @@ fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher { } else { PermissionRuleMatcher::Exact(unescaped) } - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } fn unescape_rule_content(content: &str) -> String { @@ -4770,150 +406,6 @@ fn unescape_rule_content(content: &str) -> String { .replace(r"\(", "(") .replace(r"\)", ")") .replace(r"\\", r"\") - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } fn find_first_unescaped(value: &str, needle: char) -> Option { @@ -4929,150 +421,6 @@ fn find_first_unescaped(value: &str, needle: char) -> Option { escaped = false; } None - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } fn find_last_unescaped(value: &str, needle: char) -> Option { @@ -5094,150 +442,6 @@ fn find_last_unescaped(value: &str, needle: char) -> Option { } } None - - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); - - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } - - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); - } - - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); - } - - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); - - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); - } - } fn extract_permission_subject(input: &str) -> Option { @@ -5262,150 +466,303 @@ fn extract_permission_subject(input: &str) -> Option { } (!input.trim().is_empty()).then(|| input.to_string()) +} - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); +/// Machine-readable policy exception scope that an approval token may override. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalScope { + pub policy: String, + pub action: String, + pub repository: Option, + pub branch: Option, +} - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); - - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); +impl ApprovalScope { + #[must_use] + pub fn new(policy: impl Into, action: impl Into) -> Self { + Self { + policy: policy.into(), + action: action.into(), + repository: None, + branch: None, + } } - #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - - assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) - )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); + #[must_use] + pub fn with_repository(mut self, repository: impl Into) -> Self { + self.repository = Some(repository.into()); + self } - #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); + #[must_use] + pub fn with_branch(mut self, branch: impl Into) -> Self { + self.branch = Some(branch.into()); + self + } +} - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); +/// Actor/session hop recorded when an approval is delegated or consumed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalDelegationHop { + pub actor: String, + pub session_id: Option, + pub reason: String, +} - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); - assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) - )); - - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) - )); +impl ApprovalDelegationHop { + #[must_use] + pub fn new(actor: impl Into, reason: impl Into) -> Self { + Self { + actor: actor.into(), + session_id: None, + reason: reason.into(), + } } - #[test] - fn approval_token_preserves_delegation_traceability() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); + #[must_use] + pub fn with_session_id(mut self, session_id: impl Into) -> Self { + self.session_id = Some(session_id.into()); + self + } +} - assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" - )); +/// Current lifecycle state for a policy-exception approval token. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApprovalTokenStatus { + Pending, + Granted, + Consumed, + Expired, + Revoked, +} - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); +/// Approval token record used to prove owner approval, delegated execution, and replay safety. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalTokenGrant { + pub token: String, + pub scope: ApprovalScope, + pub approving_actor: String, + pub executing_actor: String, + pub status: ApprovalTokenStatus, + pub expires_at: Option, + pub uses: u32, + pub delegation_chain: Vec, +} - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); +impl ApprovalTokenGrant { + #[must_use] + pub fn pending( + token: impl Into, + scope: ApprovalScope, + approving_actor: impl Into, + executing_actor: impl Into, + ) -> Self { + Self::new( + token, + scope, + approving_actor, + executing_actor, + ApprovalTokenStatus::Pending, + ) } + #[must_use] + pub fn granted( + token: impl Into, + scope: ApprovalScope, + approving_actor: impl Into, + executing_actor: impl Into, + ) -> Self { + Self::new( + token, + scope, + approving_actor, + executing_actor, + ApprovalTokenStatus::Granted, + ) + } + + fn new( + token: impl Into, + scope: ApprovalScope, + approving_actor: impl Into, + executing_actor: impl Into, + status: ApprovalTokenStatus, + ) -> Self { + let approving_actor = approving_actor.into(); + Self { + token: token.into(), + scope, + executing_actor: executing_actor.into(), + delegation_chain: vec![ApprovalDelegationHop::new( + approving_actor.clone(), + "owner approval", + )], + approving_actor, + status, + expires_at: None, + uses: 0, + } + } + + #[must_use] + pub fn expires_at(mut self, expires_at: u64) -> Self { + self.expires_at = Some(expires_at); + self + } + + #[must_use] + pub fn with_delegation_hop(mut self, hop: ApprovalDelegationHop) -> Self { + self.delegation_chain.push(hop); + self + } +} + +/// Immutable audit returned when a token is verified or consumed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalTokenAudit { + pub token: String, + pub status: ApprovalTokenStatus, + pub scope: ApprovalScope, + pub approving_actor: String, + pub executing_actor: String, + pub delegated_execution: bool, + pub uses: u32, + pub delegation_chain: Vec, +} + +/// Verification errors for policy approval tokens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApprovalTokenError { + NoApproval, + ApprovalPending, + ApprovalAlreadyConsumed, + ApprovalExpired, + ApprovalRevoked, + ScopeMismatch { + expected: ApprovalScope, + actual: ApprovalScope, + }, + UnauthorizedDelegate { + expected: String, + actual: String, + }, +} + +/// In-memory approval token ledger for deterministic policy handoff validation. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ApprovalTokenLedger { + grants: BTreeMap, +} + +impl ApprovalTokenLedger { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, grant: ApprovalTokenGrant) { + self.grants.insert(grant.token.clone(), grant); + } + + #[must_use] + pub fn get(&self, token: &str) -> Option<&ApprovalTokenGrant> { + self.grants.get(token) + } + + pub fn revoke(&mut self, token: &str) -> Result { + let grant = self + .grants + .get_mut(token) + .ok_or(ApprovalTokenError::NoApproval)?; + grant.status = ApprovalTokenStatus::Revoked; + Ok(Self::audit(grant)) + } + + pub fn verify( + &self, + token: &str, + scope: &ApprovalScope, + executing_actor: &str, + now: u64, + ) -> Result { + let grant = self + .grants + .get(token) + .ok_or(ApprovalTokenError::NoApproval)?; + Self::validate_grant(grant, scope, executing_actor, now)?; + Ok(Self::audit(grant)) + } + + pub fn consume( + &mut self, + token: &str, + scope: &ApprovalScope, + executing_actor: &str, + now: u64, + ) -> Result { + let grant = self + .grants + .get_mut(token) + .ok_or(ApprovalTokenError::NoApproval)?; + Self::validate_grant(grant, scope, executing_actor, now)?; + grant.status = ApprovalTokenStatus::Consumed; + grant.uses = grant.uses.saturating_add(1); + Ok(Self::audit(grant)) + } + + fn validate_grant( + grant: &ApprovalTokenGrant, + scope: &ApprovalScope, + executing_actor: &str, + now: u64, + ) -> Result<(), ApprovalTokenError> { + if grant.scope != *scope { + return Err(ApprovalTokenError::ScopeMismatch { + expected: grant.scope.clone(), + actual: scope.clone(), + }); + } + if grant.executing_actor != executing_actor { + return Err(ApprovalTokenError::UnauthorizedDelegate { + expected: grant.executing_actor.clone(), + actual: executing_actor.to_string(), + }); + } + if grant.expires_at.is_some_and(|expires_at| now > expires_at) { + return Err(ApprovalTokenError::ApprovalExpired); + } + match grant.status { + ApprovalTokenStatus::Granted => Ok(()), + ApprovalTokenStatus::Pending => Err(ApprovalTokenError::ApprovalPending), + ApprovalTokenStatus::Consumed => Err(ApprovalTokenError::ApprovalAlreadyConsumed), + ApprovalTokenStatus::Expired => Err(ApprovalTokenError::ApprovalExpired), + ApprovalTokenStatus::Revoked => Err(ApprovalTokenError::ApprovalRevoked), + } + } + + fn audit(grant: &ApprovalTokenGrant) -> ApprovalTokenAudit { + let mut delegation_chain = grant.delegation_chain.clone(); + if delegation_chain + .last() + .is_none_or(|hop| hop.actor != grant.executing_actor) + { + delegation_chain.push(ApprovalDelegationHop::new( + grant.executing_actor.clone(), + "approved execution", + )); + } + ApprovalTokenAudit { + token: grant.token.clone(), + status: grant.status, + scope: grant.scope.clone(), + approving_actor: grant.approving_actor.clone(), + executing_actor: grant.executing_actor.clone(), + delegated_execution: grant.approving_actor != grant.executing_actor, + uses: grant.uses, + delegation_chain, + } + } } #[cfg(test)] @@ -5735,10 +1092,6 @@ mod tests { let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); ledger.insert( ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) .with_delegation_hop( ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") .with_session_id("session-lead"), @@ -5762,8 +1115,9 @@ mod tests { assert!(audit.delegated_execution); assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!(audit.delegation_chain[0].session_id.as_deref(), Some("session-owner")); - assert_eq!(audit.delegation_chain[1].session_id.as_deref(), Some("session-lead")); + assert_eq!( + audit.delegation_chain[1].session_id.as_deref(), + Some("session-lead") + ); } - } diff --git a/rust/crates/runtime/tests/g004_conformance.rs b/rust/crates/runtime/tests/g004_conformance.rs index c7343c70..4a2b65e7 100644 --- a/rust/crates/runtime/tests/g004_conformance.rs +++ b/rust/crates/runtime/tests/g004_conformance.rs @@ -1,6 +1,4 @@ -use runtime::g004_conformance::{ - is_g004_contract_bundle_valid, validate_g004_contract_bundle, -}; +use runtime::g004_conformance::{is_g004_contract_bundle_valid, validate_g004_contract_bundle}; use serde_json::{json, Value}; fn valid_bundle() -> Value { @@ -14,7 +12,10 @@ fn valid_g004_contract_bundle_fixture_passes_conformance() { let errors = validate_g004_contract_bundle(&fixture); - assert!(errors.is_empty(), "unexpected conformance errors: {errors:?}"); + assert!( + errors.is_empty(), + "unexpected conformance errors: {errors:?}" + ); assert!(is_g004_contract_bundle_valid(&fixture)); }