1use std::collections::HashMap;
20use std::sync::{Mutex, RwLock};
21
22use chio_core::capability::{
23 Constraint, GovernedApprovalDecision, GovernedApprovalToken, GovernedAutonomyTier,
24 GovernedTransactionIntent, MonetaryAmount,
25};
26use chio_core::crypto::{sha256_hex, PublicKey};
27use serde::{Deserialize, Serialize};
28
29use crate::runtime::{ToolCallRequest, Verdict};
30use crate::{AgentId, KernelError, ServerId};
31
32pub const MAX_APPROVAL_TTL_SECS: u64 = 3600;
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct ApprovalRequest {
43 pub approval_id: String,
46
47 pub policy_id: String,
49
50 pub subject_id: AgentId,
52
53 pub capability_id: String,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub subject_public_key: Option<PublicKey>,
60
61 pub tool_server: ServerId,
63
64 pub tool_name: String,
66
67 pub action: String,
69
70 pub parameter_hash: String,
75
76 pub expires_at: u64,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub callback_hint: Option<String>,
85
86 pub created_at: u64,
88
89 pub summary: String,
91
92 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub governed_intent: Option<GovernedTransactionIntent>,
97
98 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub trusted_approvers: Vec<PublicKey>,
103
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub triggered_by: Vec<String>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
115#[serde(rename_all = "snake_case")]
116pub enum ApprovalOutcome {
117 Approved,
118 Denied,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ApprovalDecision {
124 pub approval_id: String,
126 pub outcome: ApprovalOutcome,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub reason: Option<String>,
131 pub approver: PublicKey,
134 pub token: GovernedApprovalToken,
136 pub received_at: u64,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct ApprovalToken {
146 pub approval_id: String,
147 pub governed_token: GovernedApprovalToken,
148 pub approver: PublicKey,
149}
150
151impl ApprovalToken {
152 #[must_use]
154 pub fn from_decision(decision: &ApprovalDecision) -> Self {
155 Self {
156 approval_id: decision.approval_id.clone(),
157 governed_token: decision.token.clone(),
158 approver: decision.approver.clone(),
159 }
160 }
161
162 pub fn verify_against(
166 &self,
167 request: &ApprovalRequest,
168 now: u64,
169 ) -> Result<GovernedApprovalDecision, KernelError> {
170 if self.governed_token.request_id != request.approval_id {
172 return Err(KernelError::ApprovalRejected(
173 "approval token bound to a different request".into(),
174 ));
175 }
176 if self.governed_token.governed_intent_hash != request.parameter_hash {
177 return Err(KernelError::ApprovalRejected(
178 "approval token bound to a different parameter set".into(),
179 ));
180 }
181 if self.governed_token.approver != self.approver {
182 return Err(KernelError::ApprovalRejected(
183 "approval token approver mismatch".into(),
184 ));
185 }
186 if request.trusted_approvers.is_empty() {
187 return Err(KernelError::ApprovalRejected(
188 "approval request does not declare any trusted approvers".into(),
189 ));
190 }
191 if !request.trusted_approvers.contains(&self.approver) {
192 return Err(KernelError::ApprovalRejected(
193 "approval token approver is not trusted for this request".into(),
194 ));
195 }
196 match request.subject_public_key.as_ref() {
197 Some(expected_subject) if &self.governed_token.subject != expected_subject => {
198 return Err(KernelError::ApprovalRejected(
199 "approval token subject does not match the request subject".into(),
200 ));
201 }
202 Some(_) => {}
203 None if self.governed_token.subject.to_hex() != request.subject_id => {
204 return Err(KernelError::ApprovalRejected(
205 "approval request is missing a subject binding".into(),
206 ));
207 }
208 None => {}
209 }
210
211 if now >= self.governed_token.expires_at {
213 return Err(KernelError::ApprovalRejected(
214 "approval token has expired".into(),
215 ));
216 }
217 if now < self.governed_token.issued_at {
218 return Err(KernelError::ApprovalRejected(
219 "approval token not yet valid".into(),
220 ));
221 }
222
223 let lifetime = self
226 .governed_token
227 .expires_at
228 .saturating_sub(self.governed_token.issued_at);
229 if lifetime > MAX_APPROVAL_TTL_SECS {
230 return Err(KernelError::ApprovalRejected(format!(
231 "approval token lifetime {lifetime}s exceeds cap {MAX_APPROVAL_TTL_SECS}s"
232 )));
233 }
234
235 let ok = self.governed_token.verify_signature().map_err(|e| {
237 KernelError::ApprovalRejected(format!(
238 "approval token signature verification failed: {e}"
239 ))
240 })?;
241 if !ok {
242 return Err(KernelError::ApprovalRejected(
243 "approval token signature did not verify".into(),
244 ));
245 }
246
247 Ok(self.governed_token.decision)
248 }
249}
250
251#[derive(Debug, thiserror::Error)]
253pub enum ApprovalStoreError {
254 #[error("approval request not found: {0}")]
255 NotFound(String),
256 #[error("approval already resolved: {0}")]
257 AlreadyResolved(String),
258 #[error("approval token already consumed (replay detected): {0}")]
259 Replay(String),
260 #[error("storage backend error: {0}")]
261 Backend(String),
262 #[error("serialization error: {0}")]
263 Serialization(String),
264}
265
266#[derive(Debug, Clone, Default, Serialize, Deserialize)]
268pub struct ApprovalFilter {
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub subject_id: Option<String>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub tool_server: Option<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub tool_name: Option<String>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub not_expired_at: Option<u64>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub limit: Option<usize>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct ResolvedApproval {
286 pub approval_id: String,
287 pub outcome: ApprovalOutcome,
288 pub resolved_at: u64,
289 pub approver_hex: String,
290 pub token_id: String,
291}
292
293pub trait ApprovalStore: Send + Sync {
299 fn store_pending(&self, request: &ApprovalRequest) -> Result<(), ApprovalStoreError>;
303
304 fn get_pending(&self, id: &str) -> Result<Option<ApprovalRequest>, ApprovalStoreError>;
306
307 fn list_pending(
309 &self,
310 filter: &ApprovalFilter,
311 ) -> Result<Vec<ApprovalRequest>, ApprovalStoreError>;
312
313 fn resolve(&self, id: &str, decision: &ApprovalDecision) -> Result<(), ApprovalStoreError>;
319
320 fn count_approved(&self, subject_id: &str, policy_id: &str) -> Result<u64, ApprovalStoreError>;
323
324 fn record_consumed(
332 &self,
333 token_id: &str,
334 parameter_hash: &str,
335 now: u64,
336 ) -> Result<(), ApprovalStoreError>;
337
338 fn is_consumed(&self, token_id: &str, parameter_hash: &str)
340 -> Result<bool, ApprovalStoreError>;
341
342 fn get_resolution(&self, id: &str) -> Result<Option<ResolvedApproval>, ApprovalStoreError>;
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
348pub struct BatchApproval {
349 pub batch_id: String,
350 pub approver_hex: String,
351 pub subject_id: AgentId,
352 pub server_pattern: String,
353 pub tool_pattern: String,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub max_amount_per_call: Option<MonetaryAmount>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub max_total_amount: Option<MonetaryAmount>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub max_calls: Option<u32>,
360 pub not_before: u64,
361 pub not_after: u64,
362 #[serde(default)]
363 pub used_calls: u32,
364 #[serde(default)]
365 pub used_total_units: u64,
366 #[serde(default)]
367 pub revoked: bool,
368}
369
370pub trait BatchApprovalStore: Send + Sync {
372 fn store(&self, batch: &BatchApproval) -> Result<(), ApprovalStoreError>;
373
374 fn find_matching(
375 &self,
376 subject_id: &str,
377 server_id: &str,
378 tool_name: &str,
379 amount: Option<&MonetaryAmount>,
380 now: u64,
381 ) -> Result<Option<BatchApproval>, ApprovalStoreError>;
382
383 fn record_usage(
384 &self,
385 batch_id: &str,
386 amount: Option<&MonetaryAmount>,
387 ) -> Result<(), ApprovalStoreError>;
388
389 fn revoke(&self, batch_id: &str) -> Result<(), ApprovalStoreError>;
390
391 fn get(&self, batch_id: &str) -> Result<Option<BatchApproval>, ApprovalStoreError>;
392}
393
394pub trait ApprovalChannel: Send + Sync {
400 fn name(&self) -> &str;
402
403 fn dispatch(&self, request: &ApprovalRequest) -> Result<ChannelHandle, ChannelError>;
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct ChannelHandle {
414 pub channel: String,
415 pub channel_ref: String,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub action_url: Option<String>,
418}
419
420#[derive(Debug, thiserror::Error)]
422pub enum ChannelError {
423 #[error("channel transport error: {0}")]
424 Transport(String),
425 #[error("channel remote rejected dispatch: {status}: {body}")]
426 Remote { status: u16, body: String },
427 #[error("channel misconfigured: {0}")]
428 Config(String),
429}
430
431#[derive(Debug, Clone)]
437pub enum HitlVerdict {
438 Allow,
440 Deny { reason: String },
442 Pending {
445 request: Box<ApprovalRequest>,
446 verdict: Verdict,
447 },
448 Approved { token: Box<ApprovalToken> },
450}
451
452#[must_use]
457pub fn compute_parameter_hash(
458 tool_server: &str,
459 tool_name: &str,
460 arguments: &serde_json::Value,
461 governed_intent: Option<&GovernedTransactionIntent>,
462) -> String {
463 let envelope = serde_json::json!({
464 "server_id": tool_server,
465 "tool_name": tool_name,
466 "arguments": arguments,
467 "governed_intent": governed_intent,
468 });
469 match chio_core::canonical::canonical_json_bytes(&envelope) {
470 Ok(bytes) => sha256_hex(&bytes),
471 Err(_) => sha256_hex(envelope.to_string().as_bytes()),
477 }
478}
479
480pub struct ApprovalGuard {
484 store: std::sync::Arc<dyn ApprovalStore>,
486 channels: Vec<std::sync::Arc<dyn ApprovalChannel>>,
491 default_ttl_secs: u64,
493}
494
495impl ApprovalGuard {
496 pub fn new(store: std::sync::Arc<dyn ApprovalStore>) -> Self {
497 Self {
498 store,
499 channels: Vec::new(),
500 default_ttl_secs: 3600,
501 }
502 }
503
504 #[must_use]
505 pub fn with_channel(mut self, channel: std::sync::Arc<dyn ApprovalChannel>) -> Self {
506 self.channels.push(channel);
507 self
508 }
509
510 #[must_use]
511 pub fn with_default_ttl(mut self, secs: u64) -> Self {
512 self.default_ttl_secs = secs;
513 self
514 }
515
516 pub fn evaluate(&self, ctx: ApprovalContext<'_>, now: u64) -> Result<HitlVerdict, KernelError> {
519 let mut triggered = Vec::<String>::new();
520 let mut threshold_hit = false;
521 let mut always_hit = false;
522 let mut tier_hit = false;
523
524 for constraint in ctx.constraints {
525 match constraint {
526 Constraint::RequireApprovalAbove { threshold_units } => {
527 let amount = ctx
528 .request
529 .governed_intent
530 .as_ref()
531 .and_then(|intent| intent.max_amount.as_ref());
532 match amount {
533 Some(amt) if amt.units >= *threshold_units => {
534 threshold_hit = true;
535 triggered.push(format!("require_approval_above:{threshold_units}"));
536 }
537 Some(_) => {
538 }
540 None => {
541 return Ok(HitlVerdict::Deny {
545 reason: format!(
546 "RequireApprovalAbove requires a governed intent with max_amount (threshold={threshold_units})"
547 ),
548 });
549 }
550 }
551 }
552 Constraint::MinimumAutonomyTier(GovernedAutonomyTier::Autonomous)
553 if ctx.request.governed_intent.is_some() =>
554 {
555 tier_hit = true;
559 triggered.push("minimum_autonomy_tier:autonomous".to_string());
560 }
561 _ => {}
562 }
563 }
564
565 if ctx.force_approval {
570 always_hit = true;
571 triggered.push("force_approval".to_string());
572 }
573
574 let needs_approval = threshold_hit || always_hit || tier_hit;
575 if !needs_approval {
576 return Ok(HitlVerdict::Allow);
577 }
578 if ctx.trusted_approvers.is_empty() {
579 return Ok(HitlVerdict::Deny {
580 reason: "approval required but no trusted approvers are configured".to_string(),
581 });
582 }
583
584 if let Some(token) = ctx.presented_token {
587 let parameter_hash = compute_parameter_hash(
590 &ctx.request.server_id,
591 &ctx.request.tool_name,
592 &ctx.request.arguments,
593 ctx.request.governed_intent.as_ref(),
594 );
595
596 let stored = self
601 .store
602 .get_pending(&token.approval_id)
603 .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?;
604 let resolved = self
605 .store
606 .get_resolution(&token.approval_id)
607 .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?;
608
609 let approval_request = match stored.or_else(|| {
610 resolved.map(|res| ApprovalRequest {
611 approval_id: res.approval_id,
612 policy_id: ctx.policy_id.to_string(),
613 subject_id: ctx.request.agent_id.clone(),
614 capability_id: ctx.request.capability.id.clone(),
615 subject_public_key: Some(ctx.request.capability.subject.clone()),
616 tool_server: ctx.request.server_id.clone(),
617 tool_name: ctx.request.tool_name.clone(),
618 action: "invoke".to_string(),
619 parameter_hash: parameter_hash.clone(),
620 expires_at: now + self.default_ttl_secs,
621 callback_hint: None,
622 created_at: now,
623 summary: String::new(),
624 governed_intent: ctx.request.governed_intent.clone(),
625 trusted_approvers: ctx.trusted_approvers.to_vec(),
626 triggered_by: triggered.clone(),
627 })
628 }) {
629 Some(record) => record,
630 None => {
631 return Err(KernelError::ApprovalRejected(
632 "approval token does not match any known request".into(),
633 ));
634 }
635 };
636
637 let already_consumed = self
640 .store
641 .is_consumed(&token.governed_token.id, &approval_request.parameter_hash)
642 .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?;
643 if already_consumed {
644 return Err(KernelError::ApprovalRejected(
645 "approval token already consumed (replay)".into(),
646 ));
647 }
648
649 let decision = token.verify_against(&approval_request, now)?;
650 match decision {
651 GovernedApprovalDecision::Approved => Ok(HitlVerdict::Approved {
652 token: Box::new(token.clone()),
653 }),
654 GovernedApprovalDecision::Denied => Ok(HitlVerdict::Deny {
655 reason: "human approver denied the request".into(),
656 }),
657 }
658 } else {
659 let parameter_hash = compute_parameter_hash(
661 &ctx.request.server_id,
662 &ctx.request.tool_name,
663 &ctx.request.arguments,
664 ctx.request.governed_intent.as_ref(),
665 );
666 let expires_at = now.saturating_add(self.default_ttl_secs);
667 let summary = format!(
668 "agent {} requests approval for {}:{}",
669 ctx.request.agent_id, ctx.request.server_id, ctx.request.tool_name
670 );
671 let request = ApprovalRequest {
672 approval_id: ctx
673 .approval_id_override
674 .unwrap_or_else(|| uuid::Uuid::now_v7().to_string()),
675 policy_id: ctx.policy_id.to_string(),
676 subject_id: ctx.request.agent_id.clone(),
677 capability_id: ctx.request.capability.id.clone(),
678 subject_public_key: Some(ctx.request.capability.subject.clone()),
679 tool_server: ctx.request.server_id.clone(),
680 tool_name: ctx.request.tool_name.clone(),
681 action: "invoke".to_string(),
682 parameter_hash,
683 expires_at,
684 callback_hint: None,
685 created_at: now,
686 summary,
687 governed_intent: ctx.request.governed_intent.clone(),
688 trusted_approvers: ctx.trusted_approvers.to_vec(),
689 triggered_by: triggered,
690 };
691 self.store
692 .store_pending(&request)
693 .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?;
694
695 for channel in &self.channels {
699 if let Err(err) = channel.dispatch(&request) {
700 tracing::warn!(
701 approval_id = %request.approval_id,
702 channel = %channel.name(),
703 error = %err,
704 "approval channel dispatch failed; request remains pending"
705 );
706 }
707 }
708
709 Ok(HitlVerdict::Pending {
710 request: Box::new(request),
711 verdict: Verdict::PendingApproval,
712 })
713 }
714 }
715
716 #[must_use]
718 pub fn store(&self) -> std::sync::Arc<dyn ApprovalStore> {
719 self.store.clone()
720 }
721}
722
723pub struct ApprovalContext<'a> {
725 pub request: &'a ToolCallRequest,
726 pub constraints: &'a [Constraint],
727 pub policy_id: &'a str,
728 pub trusted_approvers: &'a [PublicKey],
730 pub presented_token: Option<&'a ApprovalToken>,
732 pub force_approval: bool,
736 pub approval_id_override: Option<String>,
738}
739
740pub fn resume_with_decision(
744 store: &dyn ApprovalStore,
745 decision: &ApprovalDecision,
746 now: u64,
747) -> Result<ApprovalOutcome, KernelError> {
748 let pending = match store
749 .get_pending(&decision.approval_id)
750 .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?
751 {
752 Some(p) => p,
753 None => {
754 if let Some(resolution) = store
755 .get_resolution(&decision.approval_id)
756 .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?
757 {
758 return Err(KernelError::ApprovalRejected(format!(
759 "already resolved: {} ({:?})",
760 resolution.approval_id, resolution.outcome
761 )));
762 }
763 return Err(KernelError::ApprovalRejected(format!(
764 "unknown approval id: {}",
765 decision.approval_id
766 )));
767 }
768 };
769
770 let already = store
773 .is_consumed(&decision.token.id, &pending.parameter_hash)
774 .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?;
775 if already {
776 return Err(KernelError::ApprovalRejected(
777 "approval token already consumed (replay)".into(),
778 ));
779 }
780
781 let approval_token = ApprovalToken {
783 approval_id: pending.approval_id.clone(),
784 governed_token: decision.token.clone(),
785 approver: decision.approver.clone(),
786 };
787 let token_decision = approval_token.verify_against(&pending, now)?;
788
789 let outcome = match (token_decision, &decision.outcome) {
796 (GovernedApprovalDecision::Approved, ApprovalOutcome::Approved) => {
797 ApprovalOutcome::Approved
798 }
799 (GovernedApprovalDecision::Denied, ApprovalOutcome::Denied) => ApprovalOutcome::Denied,
800 _ => {
801 return Err(KernelError::ApprovalRejected(
802 "HTTP outcome disagrees with signed token decision".into(),
803 ));
804 }
805 };
806
807 store
812 .resolve(&decision.approval_id, decision)
813 .map_err(|e| match e {
814 ApprovalStoreError::AlreadyResolved(m) => {
815 KernelError::ApprovalRejected(format!("already resolved: {m}"))
816 }
817 ApprovalStoreError::Replay(m) => {
818 KernelError::ApprovalRejected(format!("replay detected: {m}"))
819 }
820 other => KernelError::Internal(format!("approval store: {other}")),
821 })?;
822
823 Ok(outcome)
824}
825
826#[derive(Default)]
835pub struct InMemoryApprovalStore {
836 pending: RwLock<HashMap<String, ApprovalRequest>>,
837 resolved: RwLock<HashMap<String, ResolvedApproval>>,
838 consumed: Mutex<HashMap<String, u64>>, approved_counts: Mutex<HashMap<String, u64>>, }
841
842impl InMemoryApprovalStore {
843 pub fn new() -> Self {
844 Self::default()
845 }
846
847 fn consumed_key(token_id: &str, parameter_hash: &str) -> String {
848 format!("{token_id}:{parameter_hash}")
849 }
850}
851
852impl ApprovalStore for InMemoryApprovalStore {
853 fn store_pending(&self, request: &ApprovalRequest) -> Result<(), ApprovalStoreError> {
854 let mut guard = self
855 .pending
856 .write()
857 .map_err(|_| ApprovalStoreError::Backend("pending map poisoned".into()))?;
858 match guard.get(&request.approval_id) {
859 Some(existing) if existing == request => return Ok(()),
860 Some(_) => {
861 return Err(ApprovalStoreError::Backend(format!(
862 "approval_id {} already exists with different payload",
863 request.approval_id
864 )))
865 }
866 None => {}
867 }
868 guard.insert(request.approval_id.clone(), request.clone());
869 Ok(())
870 }
871
872 fn get_pending(&self, id: &str) -> Result<Option<ApprovalRequest>, ApprovalStoreError> {
873 let guard = self
874 .pending
875 .read()
876 .map_err(|_| ApprovalStoreError::Backend("pending map poisoned".into()))?;
877 Ok(guard.get(id).cloned())
878 }
879
880 fn list_pending(
881 &self,
882 filter: &ApprovalFilter,
883 ) -> Result<Vec<ApprovalRequest>, ApprovalStoreError> {
884 let guard = self
885 .pending
886 .read()
887 .map_err(|_| ApprovalStoreError::Backend("pending map poisoned".into()))?;
888 let mut out: Vec<_> = guard
889 .values()
890 .filter(|req| {
891 filter
892 .subject_id
893 .as_deref()
894 .is_none_or(|s| req.subject_id == s)
895 && filter
896 .tool_server
897 .as_deref()
898 .is_none_or(|s| req.tool_server == s)
899 && filter
900 .tool_name
901 .as_deref()
902 .is_none_or(|s| req.tool_name == s)
903 && filter.not_expired_at.is_none_or(|t| req.expires_at > t)
904 })
905 .cloned()
906 .collect();
907 out.sort_by_key(|approval| approval.created_at);
908 if let Some(limit) = filter.limit {
909 out.truncate(limit);
910 }
911 Ok(out)
912 }
913
914 fn resolve(&self, id: &str, decision: &ApprovalDecision) -> Result<(), ApprovalStoreError> {
915 let mut pending_guard = self
916 .pending
917 .write()
918 .map_err(|_| ApprovalStoreError::Backend("pending map poisoned".into()))?;
919 let Some(pending) = pending_guard.remove(id) else {
920 return Err(ApprovalStoreError::NotFound(id.to_string()));
921 };
922
923 {
924 let mut consumed = self
925 .consumed
926 .lock()
927 .map_err(|_| ApprovalStoreError::Backend("consumed map poisoned".into()))?;
928 let key = Self::consumed_key(&decision.token.id, &pending.parameter_hash);
929 if consumed.contains_key(&key) {
930 pending_guard.insert(id.to_string(), pending);
933 return Err(ApprovalStoreError::Replay(id.to_string()));
934 }
935 consumed.insert(key, decision.received_at);
936 }
937
938 let mut resolved = self
939 .resolved
940 .write()
941 .map_err(|_| ApprovalStoreError::Backend("resolved map poisoned".into()))?;
942 if resolved.contains_key(id) {
943 return Err(ApprovalStoreError::AlreadyResolved(id.to_string()));
944 }
945 resolved.insert(
946 id.to_string(),
947 ResolvedApproval {
948 approval_id: id.to_string(),
949 outcome: decision.outcome.clone(),
950 resolved_at: decision.received_at,
951 approver_hex: decision.approver.to_hex(),
952 token_id: decision.token.id.clone(),
953 },
954 );
955
956 if decision.outcome == ApprovalOutcome::Approved {
957 let mut counts = self
958 .approved_counts
959 .lock()
960 .map_err(|_| ApprovalStoreError::Backend("counts map poisoned".into()))?;
961 let key = format!("{}:{}", pending.subject_id, pending.policy_id);
962 *counts.entry(key).or_default() += 1;
963 }
964
965 Ok(())
966 }
967
968 fn count_approved(&self, subject_id: &str, policy_id: &str) -> Result<u64, ApprovalStoreError> {
969 let counts = self
970 .approved_counts
971 .lock()
972 .map_err(|_| ApprovalStoreError::Backend("counts map poisoned".into()))?;
973 Ok(counts
974 .get(&format!("{subject_id}:{policy_id}"))
975 .copied()
976 .unwrap_or(0))
977 }
978
979 fn record_consumed(
980 &self,
981 token_id: &str,
982 parameter_hash: &str,
983 now: u64,
984 ) -> Result<(), ApprovalStoreError> {
985 let mut consumed = self
986 .consumed
987 .lock()
988 .map_err(|_| ApprovalStoreError::Backend("consumed map poisoned".into()))?;
989 let key = Self::consumed_key(token_id, parameter_hash);
990 if consumed.contains_key(&key) {
991 return Err(ApprovalStoreError::Replay(format!(
992 "token {token_id} already consumed"
993 )));
994 }
995 consumed.insert(key, now);
996 Ok(())
997 }
998
999 fn is_consumed(
1000 &self,
1001 token_id: &str,
1002 parameter_hash: &str,
1003 ) -> Result<bool, ApprovalStoreError> {
1004 let consumed = self
1005 .consumed
1006 .lock()
1007 .map_err(|_| ApprovalStoreError::Backend("consumed map poisoned".into()))?;
1008 Ok(consumed.contains_key(&Self::consumed_key(token_id, parameter_hash)))
1009 }
1010
1011 fn get_resolution(&self, id: &str) -> Result<Option<ResolvedApproval>, ApprovalStoreError> {
1012 let guard = self
1013 .resolved
1014 .read()
1015 .map_err(|_| ApprovalStoreError::Backend("resolved map poisoned".into()))?;
1016 Ok(guard.get(id).cloned())
1017 }
1018}
1019
1020#[derive(Default)]
1023pub struct InMemoryBatchApprovalStore {
1024 batches: RwLock<HashMap<String, BatchApproval>>,
1025}
1026
1027impl InMemoryBatchApprovalStore {
1028 pub fn new() -> Self {
1029 Self::default()
1030 }
1031}
1032
1033impl BatchApprovalStore for InMemoryBatchApprovalStore {
1034 fn store(&self, batch: &BatchApproval) -> Result<(), ApprovalStoreError> {
1035 let mut guard = self
1036 .batches
1037 .write()
1038 .map_err(|_| ApprovalStoreError::Backend("batch map poisoned".into()))?;
1039 guard.insert(batch.batch_id.clone(), batch.clone());
1040 Ok(())
1041 }
1042
1043 fn find_matching(
1044 &self,
1045 subject_id: &str,
1046 server_id: &str,
1047 tool_name: &str,
1048 amount: Option<&MonetaryAmount>,
1049 now: u64,
1050 ) -> Result<Option<BatchApproval>, ApprovalStoreError> {
1051 let guard = self
1052 .batches
1053 .read()
1054 .map_err(|_| ApprovalStoreError::Backend("batch map poisoned".into()))?;
1055 Ok(guard
1056 .values()
1057 .find(|b| {
1058 !b.revoked
1059 && b.subject_id == subject_id
1060 && pattern_matches(&b.server_pattern, server_id)
1061 && pattern_matches(&b.tool_pattern, tool_name)
1062 && now >= b.not_before
1063 && now < b.not_after
1064 && b.max_calls.is_none_or(|c| b.used_calls < c)
1065 && amount_fits(b, amount)
1066 })
1067 .cloned())
1068 }
1069
1070 fn record_usage(
1071 &self,
1072 batch_id: &str,
1073 amount: Option<&MonetaryAmount>,
1074 ) -> Result<(), ApprovalStoreError> {
1075 let mut guard = self
1076 .batches
1077 .write()
1078 .map_err(|_| ApprovalStoreError::Backend("batch map poisoned".into()))?;
1079 let Some(batch) = guard.get_mut(batch_id) else {
1080 return Err(ApprovalStoreError::NotFound(batch_id.to_string()));
1081 };
1082 batch.used_calls = batch.used_calls.saturating_add(1);
1083 if let Some(amt) = amount {
1084 batch.used_total_units = batch.used_total_units.saturating_add(amt.units);
1085 }
1086 Ok(())
1087 }
1088
1089 fn revoke(&self, batch_id: &str) -> Result<(), ApprovalStoreError> {
1090 let mut guard = self
1091 .batches
1092 .write()
1093 .map_err(|_| ApprovalStoreError::Backend("batch map poisoned".into()))?;
1094 let Some(batch) = guard.get_mut(batch_id) else {
1095 return Err(ApprovalStoreError::NotFound(batch_id.to_string()));
1096 };
1097 batch.revoked = true;
1098 Ok(())
1099 }
1100
1101 fn get(&self, batch_id: &str) -> Result<Option<BatchApproval>, ApprovalStoreError> {
1102 let guard = self
1103 .batches
1104 .read()
1105 .map_err(|_| ApprovalStoreError::Backend("batch map poisoned".into()))?;
1106 Ok(guard.get(batch_id).cloned())
1107 }
1108}
1109
1110fn pattern_matches(pattern: &str, value: &str) -> bool {
1111 if pattern == "*" {
1112 return true;
1113 }
1114 if let Some(prefix) = pattern.strip_suffix('*') {
1115 return value.starts_with(prefix);
1116 }
1117 pattern == value
1118}
1119
1120fn amount_fits(batch: &BatchApproval, amount: Option<&MonetaryAmount>) -> bool {
1121 let Some(amt) = amount else {
1122 return batch.max_amount_per_call.is_none() && batch.max_total_amount.is_none();
1125 };
1126 if let Some(per_call) = &batch.max_amount_per_call {
1127 if amt.currency != per_call.currency || amt.units > per_call.units {
1128 return false;
1129 }
1130 }
1131 if let Some(total) = &batch.max_total_amount {
1132 if amt.currency != total.currency
1133 || batch.used_total_units.saturating_add(amt.units) > total.units
1134 {
1135 return false;
1136 }
1137 }
1138 true
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143 use super::*;
1144 use chio_core::capability::{GovernedApprovalDecision, GovernedApprovalTokenBody};
1145 use chio_core::crypto::Keypair;
1146
1147 fn make_request(approval_id: &str, parameter_hash: &str) -> ApprovalRequest {
1148 let subject = Keypair::generate();
1149 let approver = Keypair::generate();
1150 ApprovalRequest {
1151 approval_id: approval_id.to_string(),
1152 policy_id: "policy-1".into(),
1153 subject_id: "agent-1".into(),
1154 capability_id: "cap-1".into(),
1155 subject_public_key: Some(subject.public_key()),
1156 tool_server: "srv".into(),
1157 tool_name: "invoke".into(),
1158 action: "invoke".into(),
1159 parameter_hash: parameter_hash.to_string(),
1160 expires_at: 1_000_000,
1161 callback_hint: None,
1162 created_at: 0,
1163 summary: String::new(),
1164 governed_intent: None,
1165 trusted_approvers: vec![approver.public_key()],
1166 triggered_by: vec![],
1167 }
1168 }
1169
1170 fn make_token(
1171 approver: &Keypair,
1172 subject: &Keypair,
1173 approval_id: &str,
1174 parameter_hash: &str,
1175 decision: GovernedApprovalDecision,
1176 ) -> GovernedApprovalToken {
1177 let body = GovernedApprovalTokenBody {
1178 id: format!("tok-{approval_id}"),
1179 approver: approver.public_key(),
1180 subject: subject.public_key(),
1181 governed_intent_hash: parameter_hash.to_string(),
1182 request_id: approval_id.to_string(),
1183 issued_at: 10,
1184 expires_at: 100,
1185 decision,
1186 };
1187 GovernedApprovalToken::sign(body, approver).unwrap()
1188 }
1189
1190 #[test]
1191 fn resume_flow_approved() {
1192 let store = InMemoryApprovalStore::new();
1193 let approver = Keypair::generate();
1194 let subject = Keypair::generate();
1195 let mut req = make_request("a-1", "h-1");
1196 req.subject_public_key = Some(subject.public_key());
1197 req.trusted_approvers = vec![approver.public_key()];
1198 store.store_pending(&req).unwrap();
1199
1200 let token = make_token(
1201 &approver,
1202 &subject,
1203 "a-1",
1204 "h-1",
1205 GovernedApprovalDecision::Approved,
1206 );
1207 let decision = ApprovalDecision {
1208 approval_id: "a-1".into(),
1209 outcome: ApprovalOutcome::Approved,
1210 reason: None,
1211 approver: approver.public_key(),
1212 token,
1213 received_at: 50,
1214 };
1215
1216 let outcome = resume_with_decision(&store, &decision, 50).unwrap();
1217 assert_eq!(outcome, ApprovalOutcome::Approved);
1218 assert_eq!(store.count_approved("agent-1", "policy-1").unwrap(), 1);
1219 }
1220
1221 #[test]
1222 fn resume_flow_replay_rejected() {
1223 let store = InMemoryApprovalStore::new();
1224 let approver = Keypair::generate();
1225 let subject = Keypair::generate();
1226 let mut req = make_request("a-2", "h-2");
1227 req.subject_public_key = Some(subject.public_key());
1228 req.trusted_approvers = vec![approver.public_key()];
1229 store.store_pending(&req).unwrap();
1230
1231 let token = make_token(
1232 &approver,
1233 &subject,
1234 "a-2",
1235 "h-2",
1236 GovernedApprovalDecision::Approved,
1237 );
1238 let decision = ApprovalDecision {
1239 approval_id: "a-2".into(),
1240 outcome: ApprovalOutcome::Approved,
1241 reason: None,
1242 approver: approver.public_key(),
1243 token,
1244 received_at: 50,
1245 };
1246
1247 resume_with_decision(&store, &decision, 50).unwrap();
1249 let err = resume_with_decision(&store, &decision, 51).unwrap_err();
1251 match err {
1252 KernelError::ApprovalRejected(_) => {}
1253 other => panic!("expected ApprovalRejected, got {other:?}"),
1254 }
1255 }
1256
1257 #[test]
1258 fn resume_flow_duplicate_resolution_reports_already_resolved() {
1259 let store = InMemoryApprovalStore::new();
1260 let approver = Keypair::generate();
1261 let subject = Keypair::generate();
1262 let mut req = make_request("a-2b", "h-2b");
1263 req.subject_public_key = Some(subject.public_key());
1264 req.trusted_approvers = vec![approver.public_key()];
1265 store.store_pending(&req).unwrap();
1266
1267 let token = make_token(
1268 &approver,
1269 &subject,
1270 "a-2b",
1271 "h-2b",
1272 GovernedApprovalDecision::Approved,
1273 );
1274 let decision = ApprovalDecision {
1275 approval_id: "a-2b".into(),
1276 outcome: ApprovalOutcome::Approved,
1277 reason: None,
1278 approver: approver.public_key(),
1279 token,
1280 received_at: 50,
1281 };
1282
1283 resume_with_decision(&store, &decision, 50).unwrap();
1284 let err = resume_with_decision(&store, &decision, 51).unwrap_err();
1285 match err {
1286 KernelError::ApprovalRejected(reason) => {
1287 assert!(reason.contains("already resolved"), "{reason}");
1288 }
1289 other => panic!("expected ApprovalRejected, got {other:?}"),
1290 }
1291 }
1292
1293 #[test]
1294 fn verify_against_rejects_wrong_request_id() {
1295 let approver = Keypair::generate();
1296 let subject = Keypair::generate();
1297 let token = make_token(
1298 &approver,
1299 &subject,
1300 "a-X",
1301 "h-X",
1302 GovernedApprovalDecision::Approved,
1303 );
1304 let req = make_request("a-other", "h-X");
1305 let approval_token = ApprovalToken {
1306 approval_id: "a-X".into(),
1307 governed_token: token,
1308 approver: approver.public_key(),
1309 };
1310 let err = approval_token.verify_against(&req, 50).unwrap_err();
1311 match err {
1312 KernelError::ApprovalRejected(_) => {}
1313 other => panic!("expected ApprovalRejected, got {other:?}"),
1314 }
1315 }
1316
1317 #[test]
1318 fn verify_against_rejects_untrusted_approver() {
1319 let trusted_approver = Keypair::generate();
1320 let rogue_approver = Keypair::generate();
1321 let subject = Keypair::generate();
1322 let token = make_token(
1323 &rogue_approver,
1324 &subject,
1325 "a-1",
1326 "h-1",
1327 GovernedApprovalDecision::Approved,
1328 );
1329 let req = ApprovalRequest {
1330 approval_id: "a-1".into(),
1331 policy_id: "policy-1".into(),
1332 subject_id: "agent-1".into(),
1333 capability_id: "cap-1".into(),
1334 subject_public_key: Some(subject.public_key()),
1335 tool_server: "srv".into(),
1336 tool_name: "invoke".into(),
1337 action: "invoke".into(),
1338 parameter_hash: "h-1".into(),
1339 expires_at: 1_000_000,
1340 callback_hint: None,
1341 created_at: 0,
1342 summary: String::new(),
1343 governed_intent: None,
1344 trusted_approvers: vec![trusted_approver.public_key()],
1345 triggered_by: vec![],
1346 };
1347 let approval_token = ApprovalToken {
1348 approval_id: "a-1".into(),
1349 governed_token: token,
1350 approver: rogue_approver.public_key(),
1351 };
1352 let err = approval_token.verify_against(&req, 50).unwrap_err();
1353 match err {
1354 KernelError::ApprovalRejected(reason) => {
1355 assert!(reason.contains("not trusted"), "{reason}");
1356 }
1357 other => panic!("expected ApprovalRejected, got {other:?}"),
1358 }
1359 }
1360
1361 #[test]
1362 fn verify_against_rejects_subject_mismatch() {
1363 let approver = Keypair::generate();
1364 let expected_subject = Keypair::generate();
1365 let rogue_subject = Keypair::generate();
1366 let token = make_token(
1367 &approver,
1368 &rogue_subject,
1369 "a-1",
1370 "h-1",
1371 GovernedApprovalDecision::Approved,
1372 );
1373 let req = ApprovalRequest {
1374 approval_id: "a-1".into(),
1375 policy_id: "policy-1".into(),
1376 subject_id: "agent-1".into(),
1377 capability_id: "cap-1".into(),
1378 subject_public_key: Some(expected_subject.public_key()),
1379 tool_server: "srv".into(),
1380 tool_name: "invoke".into(),
1381 action: "invoke".into(),
1382 parameter_hash: "h-1".into(),
1383 expires_at: 1_000_000,
1384 callback_hint: None,
1385 created_at: 0,
1386 summary: String::new(),
1387 governed_intent: None,
1388 trusted_approvers: vec![approver.public_key()],
1389 triggered_by: vec![],
1390 };
1391 let approval_token = ApprovalToken {
1392 approval_id: "a-1".into(),
1393 governed_token: token,
1394 approver: approver.public_key(),
1395 };
1396 let err = approval_token.verify_against(&req, 50).unwrap_err();
1397 match err {
1398 KernelError::ApprovalRejected(reason) => {
1399 assert!(reason.contains("subject"), "{reason}");
1400 }
1401 other => panic!("expected ApprovalRejected, got {other:?}"),
1402 }
1403 }
1404}