Skip to main content

origin_platform/
confirmation.rs

1//! Human-confirmation port (G13).
2//!
3//! A door a mutating action must pass before it takes effect. MCP uses it for
4//! `Commit`/`Delete` tools, where the caller is a language model reacting to
5//! content from outside and therefore not a trusted actor.
6//!
7//! The safe default is deny: a headless run without a human, or a product that
8//! never wired a real prompt, stays read-only by construction.
9
10use async_trait::async_trait;
11use origin_domain::Result;
12use std::fmt::Debug;
13
14/// What is being asked.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ConfirmationRequest {
17    /// One-line question, e.g. "Run `set.threshold`?"
18    pub title: String,
19    /// What is being done, and why. Rendered in the dialog body.
20    pub body: String,
21}
22
23impl ConfirmationRequest {
24    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
25        Self {
26            title: title.into(),
27            body: body.into(),
28        }
29    }
30}
31
32/// What the human answered.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ConfirmationDecision {
35    Approved,
36    Denied,
37}
38
39/// Ask a human before proceeding.
40///
41/// Implementations return quickly — this is not a long-running approval queue.
42/// A read-only product, or one that never wires this capability, gets a deny-all
43/// default so that no mutating action can slip through.
44#[async_trait]
45pub trait ConfirmationService: Debug + Send + Sync + 'static {
46    async fn confirm(&self, request: ConfirmationRequest) -> Result<ConfirmationDecision>;
47}
48
49/// Fails closed: every request is denied.
50///
51/// The safe default for headless runs, CLI builds and any product that never wired
52/// a real prompt — a product that cannot ask a human cannot let an external AI
53/// write through the MCP boundary.
54#[derive(Debug, Clone, Copy, Default)]
55pub struct DenyingConfirmationService;
56
57#[async_trait]
58impl ConfirmationService for DenyingConfirmationService {
59    async fn confirm(&self, request: ConfirmationRequest) -> Result<ConfirmationDecision> {
60        tracing::info!(
61            title = %request.title,
62            "confirmation denied — no confirmation service configured"
63        );
64        Ok(ConfirmationDecision::Denied)
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[tokio::test]
73    async fn the_denying_confirmation_service_always_returns_denied() {
74        let service = DenyingConfirmationService;
75
76        let decision = service
77            .confirm(ConfirmationRequest::new("Allow?", "Run?"))
78            .await
79            .unwrap();
80
81        assert_eq!(decision, ConfirmationDecision::Denied);
82    }
83}