Skip to main content

act_policy/
consent.rs

1//! Interactive consent: prompt-on-access for `ask`-mode capabilities,
2//! with a per-session decision cache and fail-safe (no channel = deny).
3//!
4//! Portable types: `ConsentAsk`, `ConsentPrompter`, `DenyPrompter`,
5//! `DecisionCache`. The TTY-backed prompter lives in act-cli's
6//! `runtime::consent` module (host-only, uses tokio I/O).
7
8use std::collections::HashMap;
9use std::sync::Mutex;
10
11#[derive(Debug, Clone)]
12pub struct ConsentAsk {
13    pub cap_id: String,
14    /// Cache key within the class (e.g. a path, host:port, or socket addr).
15    pub key: String,
16    pub summary: String,
17}
18
19#[async_trait::async_trait]
20pub trait ConsentPrompter: Send + Sync {
21    async fn decide(&self, ask: &ConsentAsk) -> bool;
22
23    /// Whether this prompter can actually reach a human at all. `true` for
24    /// every interactive prompter (a real TTY, an MCP client offering
25    /// elicitation); `false` only for [`DenyPrompter`], which resolves every
26    /// `ask` to deny with nobody consulted.
27    ///
28    /// Read at the point an `ask` resolves, so the audit trail can tell "a
29    /// human answered no" apart from "there was no one to ask" — §5's
30    /// degrade-to-deny is not the same event as a real refusal, and callers
31    /// must not attribute the latter's `actor`/`reason` to the former. See
32    /// `CapDecisionRecord::answered`.
33    fn has_channel(&self) -> bool {
34        true
35    }
36}
37
38/// No prompt channel (headless / --mcp / non-TTY): every ask denies (fail-safe).
39pub struct DenyPrompter;
40
41#[async_trait::async_trait]
42impl ConsentPrompter for DenyPrompter {
43    async fn decide(&self, _ask: &ConsentAsk) -> bool {
44        false
45    }
46
47    fn has_channel(&self) -> bool {
48        false
49    }
50}
51
52/// Per-session memory of granted/denied (`cap_id`, key) decisions.
53#[derive(Default)]
54pub struct DecisionCache {
55    seen: Mutex<HashMap<(String, String), bool>>,
56}
57
58impl DecisionCache {
59    pub fn new() -> Self {
60        Self {
61            seen: Mutex::new(HashMap::new()),
62        }
63    }
64
65    /// Return the remembered decision for `(cap_id, key)`, or prompt once via
66    /// `prompter`, store, and return it.
67    pub async fn decide_cached(&self, prompter: &dyn ConsentPrompter, ask: ConsentAsk) -> bool {
68        let k = (ask.cap_id.clone(), ask.key.clone());
69        if let Some(v) = self.seen.lock().unwrap().get(&k).copied() {
70            return v;
71        }
72        let v = prompter.decide(&ask).await;
73        self.seen.lock().unwrap().insert(k, v);
74        v
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use std::sync::atomic::{AtomicUsize, Ordering};
82
83    struct CountingPrompter {
84        allow: bool,
85        calls: AtomicUsize,
86    }
87
88    #[async_trait::async_trait]
89    impl ConsentPrompter for CountingPrompter {
90        async fn decide(&self, _ask: &ConsentAsk) -> bool {
91            self.calls.fetch_add(1, Ordering::SeqCst);
92            self.allow
93        }
94    }
95
96    fn ask(key: &str) -> ConsentAsk {
97        ConsentAsk {
98            cap_id: "wasi:filesystem".into(),
99            key: key.into(),
100            summary: "read".into(),
101        }
102    }
103
104    #[tokio::test]
105    async fn cache_remembers_and_prompts_once() {
106        let cache = DecisionCache::new();
107        let p = CountingPrompter {
108            allow: true,
109            calls: AtomicUsize::new(0),
110        };
111        assert!(cache.decide_cached(&p, ask("/a")).await);
112        assert!(cache.decide_cached(&p, ask("/a")).await); // cached, no second prompt
113        assert_eq!(p.calls.load(Ordering::SeqCst), 1);
114        assert!(cache.decide_cached(&p, ask("/b")).await); // different key → prompts
115        assert_eq!(p.calls.load(Ordering::SeqCst), 2);
116    }
117
118    #[tokio::test]
119    async fn deny_prompter_denies() {
120        let cache = DecisionCache::new();
121        assert!(!cache.decide_cached(&DenyPrompter, ask("/x")).await);
122    }
123
124    /// Prompter scripted per cache-key: returns the configured verdict for the
125    /// key and records every prompt (post-cache misses only).
126    struct ScriptedPrompter {
127        decisions: HashMap<String, bool>,
128        prompts: Mutex<Vec<String>>,
129    }
130
131    #[async_trait::async_trait]
132    impl ConsentPrompter for ScriptedPrompter {
133        async fn decide(&self, ask: &ConsentAsk) -> bool {
134            self.prompts.lock().unwrap().push(ask.key.clone());
135            self.decisions.get(&ask.key).copied().unwrap_or(false)
136        }
137    }
138
139    #[tokio::test]
140    #[allow(clippy::await_holding_lock)]
141    async fn ask_allow_remembered_deny_blocked_and_degrade() {
142        // Scripted: "/allow" → allow, "/deny" → deny.
143        let p = ScriptedPrompter {
144            decisions: HashMap::from([("/allow".to_string(), true), ("/deny".to_string(), false)]),
145            prompts: Mutex::new(Vec::new()),
146        };
147        let cache = DecisionCache::new();
148
149        // First access to an allowed key prompts and is allowed.
150        assert!(cache.decide_cached(&p, ask("/allow")).await);
151        // Repeat is served from cache — no second prompt.
152        assert!(cache.decide_cached(&p, ask("/allow")).await);
153        // A denied key is blocked.
154        assert!(!cache.decide_cached(&p, ask("/deny")).await);
155        // Repeat denied key is also cached (no re-prompt).
156        assert!(!cache.decide_cached(&p, ask("/deny")).await);
157
158        // Exactly one prompt per distinct key: ["/allow", "/deny"].
159        let prompts = p.prompts.lock().unwrap();
160        assert_eq!(
161            prompts.as_slice(),
162            &["/allow".to_string(), "/deny".to_string()]
163        );
164
165        // DenyPrompter degrades any ask → deny (fail-safe, no channel).
166        let deny_cache = DecisionCache::new();
167        assert!(!deny_cache.decide_cached(&DenyPrompter, ask("/allow")).await);
168    }
169}
170
171// ── A queue a human drains out of band ──────────────────────────────────────
172
173use std::sync::Arc;
174use std::sync::atomic::{AtomicU64, Ordering};
175use std::time::{Duration, SystemTime, UNIX_EPOCH};
176
177/// A consent question waiting for an answer.
178///
179/// The caller — a tool call that touched an `ask`-mode capability — is blocked
180/// until someone resolves this or it expires.
181#[derive(Debug, Clone, PartialEq)]
182pub struct PendingConsent {
183    pub id: u64,
184    /// What is asking, named the way the host names its subjects: a component
185    /// label in the toolserver, a reference on the command line.
186    pub subject: String,
187    /// The host's own identifier for that subject, carried through untouched.
188    /// The queue never interprets it; it exists so an answer can be attributed
189    /// to something more durable than a display name — a toolset membership,
190    /// say — when the host wants to remember it.
191    pub subject_id: i64,
192    pub cap_id: String,
193    /// The specific thing within the capability — a path, a host, an address.
194    pub key: String,
195    pub summary: String,
196    /// Unix epoch seconds.
197    pub asked_at: i64,
198}
199
200struct Waiting {
201    entry: PendingConsent,
202    answer: tokio::sync::oneshot::Sender<bool>,
203}
204
205/// Consent questions that are waiting for a person.
206///
207/// This is the portable half of an out-of-band consent surface: it holds the
208/// questions and wakes their callers. How a person *reaches* it belongs to the
209/// host — an HTTP endpoint and a window in the toolserver, a second terminal
210/// for a CLI. Nothing here knows about either.
211pub struct ConsentQueue {
212    next_id: AtomicU64,
213    waiting: Mutex<HashMap<u64, Waiting>>,
214    timeout: Duration,
215}
216
217impl ConsentQueue {
218    pub fn new(timeout: Duration) -> Self {
219        Self {
220            next_id: AtomicU64::new(0),
221            waiting: Mutex::new(HashMap::new()),
222            timeout,
223        }
224    }
225
226    /// Questions waiting right now, oldest first.
227    pub fn pending(&self) -> Vec<PendingConsent> {
228        let mut all: Vec<PendingConsent> = self.lock().values().map(|w| w.entry.clone()).collect();
229        all.sort_by_key(|e| e.id);
230        all
231    }
232
233    /// Answer a question, waking whoever asked it.
234    ///
235    /// Returns the question that was answered, so a host that wants to
236    /// remember the decision has what it needs without a second lookup — and
237    /// without a window in which the entry could expire between the two.
238    /// `None` when nothing was waiting: it was answered already, or it expired.
239    pub fn resolve(&self, id: u64, allow: bool) -> Option<PendingConsent> {
240        let waiting = self.lock().remove(&id)?;
241        // The receiver is gone if the caller stopped waiting; the decision is
242        // then moot rather than an error.
243        let _ = waiting.answer.send(allow);
244        Some(waiting.entry)
245    }
246
247    /// Ask, and wait for an answer or for the deadline.
248    ///
249    /// Expiry denies. A tool call cannot hang forever waiting for someone who
250    /// may have walked away, and the safe direction when nobody answered is
251    /// the same as when they said no — with the difference visible to the
252    /// caller, which is why this is separate from `DenyPrompter`.
253    pub async fn ask(&self, subject: &str, subject_id: i64, ask: &ConsentAsk) -> bool {
254        let id = self.next_id.fetch_add(1, Ordering::Relaxed) + 1;
255        let (tx, rx) = tokio::sync::oneshot::channel();
256
257        self.lock().insert(
258            id,
259            Waiting {
260                entry: PendingConsent {
261                    id,
262                    subject: subject.to_string(),
263                    subject_id,
264                    cap_id: ask.cap_id.clone(),
265                    key: ask.key.clone(),
266                    summary: ask.summary.clone(),
267                    asked_at: now_epoch(),
268                },
269                answer: tx,
270            },
271        );
272
273        match tokio::time::timeout(self.timeout, rx).await {
274            Ok(Ok(decision)) => decision,
275            // Timed out, or the sender was dropped with the queue.
276            _ => {
277                self.lock().remove(&id);
278                false
279            }
280        }
281    }
282
283    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<u64, Waiting>> {
284        self.waiting
285            .lock()
286            .unwrap_or_else(std::sync::PoisonError::into_inner)
287    }
288}
289
290fn now_epoch() -> i64 {
291    SystemTime::now()
292        .duration_since(UNIX_EPOCH)
293        .map_or(0, |d| d.as_secs() as i64)
294}
295
296/// A prompter that parks its question in a [`ConsentQueue`].
297///
298/// `subject` is what the host calls whatever is asking; it is the first thing
299/// a person reads when deciding, so "allow `wasi:filesystem` on `/data`" does
300/// not arrive without saying who wants it.
301pub struct QueuePrompter {
302    queue: Arc<ConsentQueue>,
303    subject: String,
304    subject_id: i64,
305}
306
307impl QueuePrompter {
308    pub fn new(queue: Arc<ConsentQueue>, subject: impl Into<String>, subject_id: i64) -> Self {
309        Self {
310            queue,
311            subject: subject.into(),
312            subject_id,
313        }
314    }
315}
316
317#[async_trait::async_trait]
318impl ConsentPrompter for QueuePrompter {
319    async fn decide(&self, ask: &ConsentAsk) -> bool {
320        self.queue.ask(&self.subject, self.subject_id, ask).await
321    }
322
323    /// There is a channel: someone may be looking at the queue. Whether they
324    /// answer in time is a different question, and expiry is reported as a
325    /// refusal rather than as "nobody was there".
326    fn has_channel(&self) -> bool {
327        true
328    }
329}
330
331#[cfg(test)]
332mod queue_tests {
333    use super::*;
334
335    fn ask(key: &str) -> ConsentAsk {
336        ConsentAsk {
337            cap_id: "wasi:filesystem".into(),
338            key: key.into(),
339            summary: format!("read {key}"),
340        }
341    }
342
343    fn queue() -> Arc<ConsentQueue> {
344        Arc::new(ConsentQueue::new(Duration::from_secs(5)))
345    }
346
347    #[tokio::test]
348    async fn a_question_waits_and_names_who_is_asking() {
349        let queue = queue();
350        let asking = tokio::spawn({
351            let queue = queue.clone();
352            async move { queue.ask("clock", 1, &ask("/data")).await }
353        });
354
355        let pending = wait_for_one(&queue).await;
356        assert_eq!(pending.subject, "clock");
357        assert_eq!(pending.cap_id, "wasi:filesystem");
358        assert_eq!(pending.key, "/data");
359        assert!(pending.asked_at > 1_577_836_800);
360
361        assert!(queue.resolve(pending.id, true).is_some());
362        assert!(asking.await.unwrap(), "allowing must wake the caller");
363        assert!(queue.pending().is_empty());
364    }
365
366    #[tokio::test]
367    async fn denying_wakes_the_caller_with_a_refusal() {
368        let queue = queue();
369        let asking = tokio::spawn({
370            let queue = queue.clone();
371            async move { queue.ask("clock", 1, &ask("/etc")).await }
372        });
373
374        let pending = wait_for_one(&queue).await;
375        queue.resolve(pending.id, false);
376
377        assert!(!asking.await.unwrap());
378    }
379
380    #[tokio::test]
381    async fn answering_a_question_nobody_asked_reports_it() {
382        let queue = queue();
383        assert!(queue.resolve(999, true).is_none());
384    }
385
386    #[tokio::test]
387    async fn a_question_cannot_be_answered_twice() {
388        let queue = queue();
389        let asking = tokio::spawn({
390            let queue = queue.clone();
391            async move { queue.ask("clock", 1, &ask("/data")).await }
392        });
393        let pending = wait_for_one(&queue).await;
394
395        assert!(queue.resolve(pending.id, true).is_some());
396        assert!(
397            queue.resolve(pending.id, false).is_none(),
398            "it is no longer waiting"
399        );
400        assert!(asking.await.unwrap());
401    }
402
403    /// Nobody is coming: the call must not hang for the life of the process.
404    #[tokio::test]
405    async fn an_unanswered_question_expires_into_a_refusal() {
406        let queue = Arc::new(ConsentQueue::new(Duration::from_millis(50)));
407
408        let decision = queue.ask("clock", 1, &ask("/data")).await;
409
410        assert!(!decision, "expiry denies");
411        assert!(queue.pending().is_empty(), "and stops waiting");
412    }
413
414    #[tokio::test]
415    async fn two_questions_are_answered_independently() {
416        let queue = queue();
417        let first = tokio::spawn({
418            let queue = queue.clone();
419            async move { queue.ask("clock", 1, &ask("/a")).await }
420        });
421        let second = tokio::spawn({
422            let queue = queue.clone();
423            async move { queue.ask("db", 2, &ask("/b")).await }
424        });
425
426        let mut pending = Vec::new();
427        while pending.len() < 2 {
428            tokio::time::sleep(Duration::from_millis(5)).await;
429            pending = queue.pending();
430        }
431        assert_ne!(pending[0].id, pending[1].id);
432
433        let by_key = |k: &str| pending.iter().find(|p| p.key == k).unwrap().id;
434        queue.resolve(by_key("/a"), true);
435        queue.resolve(by_key("/b"), false);
436
437        assert!(first.await.unwrap());
438        assert!(!second.await.unwrap());
439    }
440
441    #[tokio::test]
442    async fn the_prompter_reports_that_a_human_can_be_reached() {
443        let prompter = QueuePrompter::new(queue(), "clock", 1);
444        assert!(prompter.has_channel());
445    }
446
447    async fn wait_for_one(queue: &ConsentQueue) -> PendingConsent {
448        for _ in 0..200 {
449            if let Some(entry) = queue.pending().into_iter().next() {
450                return entry;
451            }
452            tokio::time::sleep(Duration::from_millis(5)).await;
453        }
454        panic!("the question never reached the queue");
455    }
456}