From 57b3e3258bf2b67328e44a5eb887c52cc4c64bbd Mon Sep 17 00:00:00 2001 From: bellman Date: Thu, 14 May 2026 18:00:15 +0900 Subject: [PATCH] omx(team): auto-checkpoint worker-2 [3] --- rust/crates/runtime/src/approval_tokens.rs | 495 +++++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 rust/crates/runtime/src/approval_tokens.rs diff --git a/rust/crates/runtime/src/approval_tokens.rs b/rust/crates/runtime/src/approval_tokens.rs new file mode 100644 index 00000000..e220e442 --- /dev/null +++ b/rust/crates/runtime/src/approval_tokens.rs @@ -0,0 +1,495 @@ +use std::collections::BTreeMap; + +/// 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, +} + +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 + } +} + +/// 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, +} + +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 + } +} + +/// Current lifecycle state for a policy-exception approval token. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApprovalTokenStatus { + Pending, + Granted, + Consumed, + Expired, + Revoked, +} + +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", + } + } +} + +/// 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, + }, +} + +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", + } + } +} + +/// 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, +} + +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 + } +} + +/// 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, +} + +/// In-memory approval-token ledger with one-time-use and replay protection. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +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_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, + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + ApprovalDelegationHop, ApprovalScope, ApprovalTokenError, ApprovalTokenGrant, + ApprovalTokenLedger, ApprovalTokenStatus, + }; + + #[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") + ); + } +}