Skip to main content

typesec_agent/
conversation.rs

1//! Conversation typestate: "ask before acting" as a compile-time obligation.
2//!
3//! Multi-turn agent protocols routinely require consent before sensitive
4//! actions — and routinely forget to check it on some code path. This module
5//! extends the `SecureAgent<S>` idea to conversations: the *state of the
6//! consent handshake is part of the type*, and consent itself is not a
7//! boolean but a minted [`Capability<CanDelegate, GenericResource>`] over the
8//! resource `conversation/<peer>` — unforgeable, policy-checked, audited,
9//! and expiring like every other capability.
10//!
11//! ```text
12//! Conversation<Proposed> ──request_consent(scopes)──▶ Conversation<AwaitingConsent>
13//!                                                          │ grant_via(engine, subject)
14//!                                                          ▼   (policy check mints the proof)
15//!                                                Conversation<Consented>
16//!                                                          └─ consent() / consented_scopes()
17//! ```
18//!
19//! `Conversation<Proposed>` has no `consent()`; `Conversation<AwaitingConsent>`
20//! has no way to *become* consented except through a policy engine. Skipping
21//! the handshake is a type error, not a code-review finding.
22
23use std::marker::PhantomData;
24
25use typesec_core::policy::{CapabilityError, MintOptions, PolicyEngine, mint_capability_for_id};
26use typesec_core::resource::GenericResource;
27use typesec_core::{CanDelegate, Capability, SubjectId};
28
29/// Sealed state trait for the conversation typestate machine.
30pub trait ConversationState: private::Sealed + Send + Sync + 'static {}
31
32mod private {
33    pub trait Sealed {}
34}
35
36/// Initial state: a peer has been named, nothing has been asked.
37#[derive(Debug)]
38pub struct Proposed;
39
40/// Consent has been requested for specific scopes but not yet granted.
41#[derive(Debug)]
42pub struct AwaitingConsent;
43
44/// Consent is held as a minted capability; scoped actions may proceed.
45#[derive(Debug)]
46pub struct Consented;
47
48impl private::Sealed for Proposed {}
49impl private::Sealed for AwaitingConsent {}
50impl private::Sealed for Consented {}
51impl ConversationState for Proposed {}
52impl ConversationState for AwaitingConsent {}
53impl ConversationState for Consented {}
54
55/// A conversation with `peer`, parameterized by its consent state.
56pub struct Conversation<S: ConversationState> {
57    peer: String,
58    purpose: Option<String>,
59    scopes: Vec<String>,
60    consent: Option<Capability<CanDelegate, GenericResource>>,
61    _state: PhantomData<fn() -> S>,
62}
63
64impl<S: ConversationState> std::fmt::Debug for Conversation<S> {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("Conversation")
67            .field("peer", &self.peer)
68            .field("purpose", &self.purpose)
69            .field("scopes", &self.scopes)
70            .field("state", &std::any::type_name::<S>())
71            .finish_non_exhaustive()
72    }
73}
74
75impl<S: ConversationState> Conversation<S> {
76    /// The peer this conversation addresses.
77    pub fn peer(&self) -> &str {
78        &self.peer
79    }
80
81    /// The declared purpose, if any.
82    pub fn purpose(&self) -> Option<&str> {
83        self.purpose.as_deref()
84    }
85
86    /// The resource id consent is minted against: `conversation/<peer>`.
87    pub fn resource_id(&self) -> String {
88        format!("conversation/{}", self.peer)
89    }
90
91    fn transition<T: ConversationState>(self) -> Conversation<T> {
92        Conversation {
93            peer: self.peer,
94            purpose: self.purpose,
95            scopes: self.scopes,
96            consent: self.consent,
97            _state: PhantomData,
98        }
99    }
100}
101
102impl Conversation<Proposed> {
103    /// Open a conversation proposal with `peer`.
104    pub fn propose(peer: impl Into<String>) -> Self {
105        Self {
106            peer: peer.into(),
107            purpose: None,
108            scopes: Vec::new(),
109            consent: None,
110            _state: PhantomData,
111        }
112    }
113
114    /// Declare the purpose of the conversation.
115    #[must_use]
116    pub fn with_purpose(mut self, purpose: impl Into<String>) -> Self {
117        self.purpose = Some(purpose.into());
118        self
119    }
120
121    /// Ask for consent to the named action scopes. The conversation can no
122    /// longer be treated as consent-free — and is not yet consented.
123    pub fn request_consent<I, T>(mut self, scopes: I) -> Conversation<AwaitingConsent>
124    where
125        I: IntoIterator<Item = T>,
126        T: Into<String>,
127    {
128        self.scopes = scopes.into_iter().map(Into::into).collect();
129        self.transition()
130    }
131}
132
133impl Conversation<AwaitingConsent> {
134    /// The scopes consent was requested for.
135    pub fn requested_scopes(&self) -> &[String] {
136        &self.scopes
137    }
138
139    /// Resolve the consent request through a policy engine.
140    ///
141    /// Consent is granted only if `engine` allows `subject` the `delegate`
142    /// action on `conversation/<peer>` — the decision is audited and the
143    /// resulting capability expires like any other. On deny, the
144    /// conversation is returned unchanged so the caller can renegotiate.
145    // The Err variant intentionally carries the conversation back to the
146    // caller; its size is the conversation itself, not incidental baggage.
147    #[allow(clippy::result_large_err)]
148    pub fn grant_via(
149        self,
150        engine: &dyn PolicyEngine,
151        subject: impl Into<SubjectId>,
152    ) -> Result<Conversation<Consented>, (Self, CapabilityError)> {
153        match mint_capability_for_id::<CanDelegate, GenericResource>(
154            engine,
155            subject,
156            self.resource_id(),
157            &MintOptions::default(),
158        ) {
159            Ok(consent) => {
160                let mut conversation = self.transition::<Consented>();
161                conversation.consent = Some(consent);
162                Ok(conversation)
163            }
164            Err(err) => Err((self, err)),
165        }
166    }
167}
168
169impl Conversation<Consented> {
170    /// The scopes this conversation is consented for.
171    pub fn consented_scopes(&self) -> &[String] {
172        &self.scopes
173    }
174
175    /// `true` if `action` is within the consented scopes.
176    pub fn covers(&self, action: &str) -> bool {
177        self.scopes.iter().any(|scope| scope == action)
178    }
179
180    /// The consent proof: a real capability, checkable and expiring.
181    pub fn consent(&self) -> &Capability<CanDelegate, GenericResource> {
182        self.consent
183            .as_ref()
184            .expect("Consented state always holds the minted capability")
185    }
186}
187
188#[cfg(test)]
189mod tests;