Skip to main content

chio_kernel/
approval.rs

1//! Human-in-the-loop (HITL) primitives.
2//!
3//! This module houses the approval-request data model, the persistent
4//! approval-store contract, the approval guard that decides when a call
5//! needs human sign-off, and the async resume entry points used by the
6//! HTTP surface after a human responds. The design follows
7//! `docs/protocols/HUMAN-IN-THE-LOOP-PROTOCOL.md`.
8//!
9//! `crate::runtime::Verdict` is `Copy`. This module exposes a richer
10//! [`HitlVerdict`] that carries the pending approval request when one is
11//! needed, keeping `Verdict` itself `Copy`. The public `Verdict` enum carries a
12//! `PendingApproval` marker variant so external callers can pattern-match on
13//! the three-way decision; the payload is returned separately via
14//! [`ApprovalGuard::evaluate`] and
15//! [`ChioKernel::evaluate_tool_call_with_hitl`](crate::ChioKernel).
16
17use 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
34/// Maximum lifetime (in seconds) permitted on a single approval token.
35/// Mirrors the `MAX_APPROVAL_TTL_SECS` documented in the HITL protocol
36/// section 15: the single-use replay registry's TTL is pinned to this
37/// value so no token can outlive its replay entry.
38pub const MAX_APPROVAL_TTL_SECS: u64 = 3600;
39
40/// A request for human approval, produced when the approval guard
41/// returns `Verdict::PendingApproval`. Designed to be serialized into
42/// the approval store and the webhook payload without further wrapping.
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44pub struct ApprovalRequest {
45    /// Unique request identifier. Caller-stable so the approval store
46    /// can be keyed on this value. Callers should supply a UUIDv7.
47    pub approval_id: String,
48
49    /// The policy / grant identifier that triggered the approval.
50    pub policy_id: String,
51
52    /// The calling agent's identifier.
53    pub subject_id: AgentId,
54
55    /// Capability token ID bound to this request.
56    pub capability_id: String,
57
58    /// Public key of the capability subject this approval is bound to.
59    /// A presented approval token must carry the same subject.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub subject_public_key: Option<PublicKey>,
62
63    /// Server hosting the target tool.
64    pub tool_server: ServerId,
65
66    /// Tool being invoked.
67    pub tool_name: String,
68
69    /// Short action verb for human summaries (e.g. `invoke`, `charge`).
70    pub action: String,
71
72    /// SHA-256 hex digest of the canonical JSON of the tool arguments
73    /// / governed intent. Used to bind an approval token to this exact
74    /// parameter set; a mutated argument payload will not satisfy the
75    /// same approval.
76    pub parameter_hash: String,
77
78    /// Unix seconds after which the request auto-denies (or escalates,
79    /// per `timeout_action` in the grant).
80    pub expires_at: u64,
81
82    /// Hint for channels about where the human can respond (e.g. the
83    /// URL of the dashboard or a Slack permalink). `None` means
84    /// "dispatcher will fill this in after sending".
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub callback_hint: Option<String>,
87
88    /// Unix seconds when the request was created.
89    pub created_at: u64,
90
91    /// Short human-readable summary for dashboards.
92    pub summary: String,
93
94    /// Original governed intent, when one is bound. Required for
95    /// threshold-based approvals so the approver sees the financial
96    /// envelope they are signing off on.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub governed_intent: Option<GovernedTransactionIntent>,
99
100    /// Public keys allowed to approve this request. The kernel fails
101    /// closed when the set is empty or when the presented approver is
102    /// not in the set.
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub trusted_approvers: Vec<PublicKey>,
105
106    /// Guards that triggered the approval requirement.
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub triggered_by: Vec<String>,
109}
110
111/// Minimal approval decision recorded after a human responds.
112///
113/// Callers construct this from the HTTP `POST /approvals/{id}/respond`
114/// payload. It is an in-process marker; the cryptographic artifact is
115/// the `GovernedApprovalToken` that rides alongside it.
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(rename_all = "snake_case")]
118pub enum ApprovalOutcome {
119    Approved,
120    Denied,
121}
122
123/// Decision packet delivered by an approver.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ApprovalDecision {
126    /// Approval request this decision answers.
127    pub approval_id: String,
128    /// Outcome (approved / denied).
129    pub outcome: ApprovalOutcome,
130    /// Optional free-form reason supplied by the approver.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub reason: Option<String>,
133    /// Public key of the approver. Used to validate the token signature
134    /// and for non-repudiation in the receipt.
135    pub approver: PublicKey,
136    /// Signed approval token produced by the approver.
137    pub token: GovernedApprovalToken,
138    /// Unix seconds when the kernel received this decision.
139    pub received_at: u64,
140}
141
142/// Lightweight "approval token" representation used inside the kernel.
143/// For HITL v1 this wraps the existing `GovernedApprovalToken` together
144/// with the approval request it satisfies, so consumers do not have to
145/// re-plumb the full governance type through every surface.
146#[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    /// Build an `ApprovalToken` from a decision packet.
155    #[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    /// Verify the token's cryptographic signature and binding against
165    /// the original approval request. Returns `Err(KernelError::ApprovalRejected)`
166    /// when any check fails.
167    pub fn verify_against(
168        &self,
169        request: &ApprovalRequest,
170        now: u64,
171    ) -> Result<GovernedApprovalDecision, KernelError> {
172        // Binding checks: request_id, intent hash, approver identity.
173        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        // Time bounds.
214        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        // Lifetime cap: a token whose lifetime exceeds MAX_APPROVAL_TTL_SECS
226        // cannot be safely tracked in the single-use replay registry.
227        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/// Errors emitted by approval stores.
253#[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/// Filter for `list_pending`.
268#[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    /// Only include requests whose `expires_at` is greater than this.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub not_expired_at: Option<u64>,
279    /// Maximum number of rows to return.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub limit: Option<usize>,
282}
283
284/// Resolved-approval row retained for audit and replay protection.
285#[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
294/// Persistent store for pending and resolved HITL approvals. The trait
295/// is intentionally synchronous because every concrete implementation
296/// in the kernel hot path today (in-memory, SQLite via `rusqlite`) is
297/// synchronous, and the kernel itself does not run on an async
298/// executor.
299pub trait ApprovalStore: Send + Sync {
300    /// Persist a new pending request. Idempotent on `approval_id`: a
301    /// second call with the same id returns without error as long as
302    /// the stored payload matches.
303    fn store_pending(&self, request: &ApprovalRequest) -> Result<(), ApprovalStoreError>;
304
305    /// Fetch a single pending approval by id.
306    fn get_pending(&self, id: &str) -> Result<Option<ApprovalRequest>, ApprovalStoreError>;
307
308    /// List all pending approvals matching the filter.
309    fn list_pending(
310        &self,
311        filter: &ApprovalFilter,
312    ) -> Result<Vec<ApprovalRequest>, ApprovalStoreError>;
313
314    /// Mark a pending approval as resolved. Returns
315    /// `ApprovalStoreError::AlreadyResolved` if the request has already
316    /// been resolved (double-resolve protection) and
317    /// `ApprovalStoreError::Replay` if the bound token has already been
318    /// consumed on a different request.
319    fn resolve(&self, id: &str, decision: &ApprovalDecision) -> Result<(), ApprovalStoreError>;
320
321    /// Count approved calls for a given subject / grant pair. Used by
322    /// `Constraint::RequireApprovalAbove` threshold accounting.
323    fn count_approved(&self, subject_id: &str, policy_id: &str) -> Result<u64, ApprovalStoreError>;
324
325    /// Record that a token (by `token_id` and `parameter_hash`) has
326    /// been consumed. Used to reject replays of the same approval
327    /// token across a restart. Implementations may also call this from
328    /// [`resolve`]; exposing it on the trait lets the kernel do the
329    /// replay check before persisting the resolution, which matters
330    /// when the store is backed by SQLite and wants to run the check
331    /// inside the transaction.
332    fn record_consumed(
333        &self,
334        token_id: &str,
335        parameter_hash: &str,
336        now: u64,
337    ) -> Result<(), ApprovalStoreError>;
338
339    /// Returns `true` if the token has already been consumed.
340    fn is_consumed(&self, token_id: &str, parameter_hash: &str)
341        -> Result<bool, ApprovalStoreError>;
342
343    /// Fetch the resolution record for a previously resolved approval.
344    fn get_resolution(&self, id: &str) -> Result<Option<ResolvedApproval>, ApprovalStoreError>;
345}
346
347/// Batch approvals let a human pre-approve a class of calls.
348#[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
371/// Store for batch approvals. Counterpart to `ApprovalStore`.
372pub 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
395/// Contract a channel must satisfy to dispatch an approval request.
396///
397/// The trait is sync; implementations that need async I/O should use a
398/// dedicated thread or a small runtime. `WebhookChannel` uses the
399/// blocking `ureq` client already in the crate's dependency tree.
400pub trait ApprovalChannel: Send + Sync {
401    /// Short channel name (`"webhook"`, `"slack"`, `"dashboard"`...).
402    fn name(&self) -> &str;
403
404    /// Deliver an approval request to the configured endpoint. The
405    /// channel implementation is responsible for retries; on terminal
406    /// failure the call returns `Err` and the kernel leaves the
407    /// request in the store (fail-closed).
408    fn dispatch(&self, request: &ApprovalRequest) -> Result<ChannelHandle, ChannelError>;
409}
410
411/// Handle returned by `dispatch`. Kernel records this alongside the
412/// request in the store so `cancel` can be called later.
413#[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/// Errors returned by approval channels.
422#[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/// Outcome of running an approval guard against a tool call.
433///
434/// The pending + approved variants box their inner payloads so the
435/// enum stays cheap to pass by value; large variants trip clippy's
436/// `large_enum_variant` lint.
437#[derive(Debug, Clone)]
438pub enum HitlVerdict {
439    /// Guard passes -- no approval required.
440    Allow,
441    /// Guard denies without an approval path (e.g. fail-closed).
442    Deny { reason: String },
443    /// Approval is required. Kernel should persist the request and
444    /// return a 202-style response to the caller.
445    Pending {
446        request: Box<ApprovalRequest>,
447        verdict: Verdict,
448    },
449    /// Approval was supplied with the request and passed verification.
450    Approved { token: Box<ApprovalToken> },
451}
452
453/// Compute the parameter hash that binds an `ApprovalRequest` to a
454/// specific set of arguments. Input is canonicalized via the existing
455/// `sha256_hex(chio_core::canonical::canonical_json_bytes(..))` helper
456/// so independent kernels produce identical hashes.
457#[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        // Canonicalization only fails on unserializable inputs -- the
473        // tool call arguments are already `serde_json::Value` so this
474        // path is unreachable in practice. Fall back to a tagged hash
475        // of the display form rather than panicking: we still return
476        // a stable string so callers do not have to handle the error.
477        Err(_) => sha256_hex(envelope.to_string().as_bytes()),
478    }
479}
480
481/// The built-in HITL guard. Runs before the generic guard pipeline and
482/// decides whether a call passes straight through, requires approval,
483/// or was already approved by an accompanying token.
484pub struct ApprovalGuard {
485    /// Persistent store of pending / resolved approvals.
486    store: std::sync::Arc<dyn ApprovalStore>,
487    /// Channels fired on new pending requests. Dispatch failures are
488    /// logged but do NOT clear the pending record -- the fail-closed
489    /// rule from the protocol table is that a webhook delivery failure
490    /// keeps the request pending and queryable via the API.
491    channels: Vec<std::sync::Arc<dyn ApprovalChannel>>,
492    /// Default timeout for newly created requests.
493    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    /// Evaluate the grant's constraints against the request. Returns
518    /// a `HitlVerdict` describing the next step.
519    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                            // Below threshold -- no approval triggered.
540                        }
541                        None => {
542                            // Fail-closed: constraint present but no
543                            // amount to compare. Deny rather than
544                            // silently skip.
545                            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                    // When paired with the HITL guard, Autonomous tier
557                    // is treated as "requires human approval". Direct
558                    // / Delegated pass through.
559                    tier_hit = true;
560                    triggered.push("minimum_autonomy_tier:autonomous".to_string());
561                }
562                _ => {}
563            }
564        }
565
566        // An attribute flag on the request (`force_approval`) forces a
567        // PendingApproval outcome so host integrations can enter the HITL
568        // flow without teaching every constraint variant.
569        // Test harnesses use this path too.
570        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 the caller attached an approval token, try to satisfy the
586        // request with it before creating a new pending entry.
587        if let Some(token) = ctx.presented_token {
588            // Reconstruct the request envelope matching the original
589            // pending record so we can validate binding.
590            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            // Lookup the pending record. If the token refers to an
598            // approval id, prefer the stored record; otherwise build
599            // a synthetic record to verify against (binding by
600            // parameter hash is the cryptographic gate).
601            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            // Replay check first: before spending cycles on signature
639            // verification, fail-closed on a previously consumed token.
640            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            // No token on the request -- build a new pending entry.
661            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            // Dispatch to channels. Delivery failures are logged but
697            // the pending row stays in place: the API can still serve
698            // it from `/approvals/pending`.
699            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    /// Accessor used by the resume flow in the HTTP layer.
718    #[must_use]
719    pub fn store(&self) -> std::sync::Arc<dyn ApprovalStore> {
720        self.store.clone()
721    }
722}
723
724/// Context passed into [`ApprovalGuard::evaluate`].
725pub struct ApprovalContext<'a> {
726    pub request: &'a ToolCallRequest,
727    pub constraints: &'a [Constraint],
728    pub policy_id: &'a str,
729    /// Public keys trusted to sign the approval token for this request.
730    pub trusted_approvers: &'a [PublicKey],
731    /// Approval token presented by the caller, if any.
732    pub presented_token: Option<&'a ApprovalToken>,
733    /// When `true`, force the guard into the pending path regardless
734    /// of constraints. Used by integration tests and by host adapters
735    /// that decided out-of-band that the call needs approval.
736    pub force_approval: bool,
737    /// Optional deterministic id for the generated approval request.
738    pub approval_id_override: Option<String>,
739}
740
741/// Apply a resolved approval decision: verify the token, mark it
742/// consumed, persist the resolution, and return the final verdict that
743/// the kernel should treat as the outcome.
744pub 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    // Single-use replay check: reject immediately if the token has
772    // already been consumed by a prior resolution.
773    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    // Verify the token cryptographically.
783    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    // Validate that the HTTP envelope's outcome matches the signed-token
791    // decision BEFORE touching the store. Otherwise a mismatched pair
792    // (token says Denied, body says Approved) would already have flipped
793    // the pending request to `resolved=true` and bumped any approval
794    // counters before we bail out, corrupting approval-threshold state
795    // and replay-protection bookkeeping while still returning an error.
796    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    // Record consumption inside the same store call so it survives a
809    // restart: `resolve` is expected to atomically mark the request
810    // resolved AND record the consumed token id. We only reach this
811    // point once the envelope/token consistency check has passed.
812    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// ---------------------------------------------------------------------
828// In-memory reference implementations.
829// ---------------------------------------------------------------------
830
831/// Thread-safe in-memory `ApprovalStore`. Useful for tests and for
832/// ephemeral deployments where operators explicitly accept data loss
833/// on restart (ephemeral: data is lost on restart; use the SQLite-backed
834/// store for production durability).
835#[derive(Default)]
836pub struct InMemoryApprovalStore {
837    pending: RwLock<HashMap<String, ApprovalRequest>>,
838    resolved: RwLock<HashMap<String, ResolvedApproval>>,
839    consumed: Mutex<HashMap<String, u64>>, // key: token_id ":" parameter_hash
840    approved_counts: Mutex<HashMap<String, u64>>, // key: subject_id + ":" + policy_id
841}
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                // Put the pending row back so the caller can retry the
932                // lookup on subsequent requests.
933                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/// In-memory `BatchApprovalStore` used in tests. Production backends
1022/// should persist via `SqliteBatchApprovalStore`.
1023#[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        // Calls without a monetary intent match only batches that don't
1124        // constrain per-call amount.
1125        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        // First resolution succeeds.
1250        resume_with_decision(&store, &decision, 50).unwrap();
1251        // Second resolution must fail (replay).
1252        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}