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