Skip to main content

act_runtime/consent/
channel.rs

1//! Interactive consent: the question a capability gate asks a human, and the
2//! channel it travels on.
3//!
4//! The prompters themselves are not here. Which channel a host reaches a
5//! human on — a terminal, an MCP client's elicitation, a GUI dialog — is a
6//! property of the host, not of the runtime, and binding one in would drag
7//! that host's transport into every embedder.
8//!
9//! # Why consent asks travel backwards
10//!
11//! From protocol revision `2026-07-28` a server→client request must be
12//! *associated* with an in-flight client request (SEP-2260). rmcp enforces this
13//! with a tokio task-local (`ORIGINATING_REQUEST`) that it installs around the
14//! `ServerHandler` future — and that task-local, by construction, does not
15//! survive a `tokio::spawn`.
16//!
17//! ACT executes guests on the component actor task, which is spawned once at
18//! startup, so a capability gate firing mid-execution is never inside that
19//! scope. Calling the peer from there yields `invalid_request`, which the
20//! fail-safe mapping turns into a silent deny of every `ask` capability.
21//!
22//! So the elicitation is inverted: the gate does not talk to the peer. It hands
23//! a [`ConsentRequest`] to the MCP request handler over a channel and waits for
24//! the answer. The handler is already awaiting the actor's reply, so it services
25//! the ask on its own task — inside the scope — and sends the decision back.
26//!
27//! Clients that do not support elicitation still degrade ask→deny.
28
29use act_policy::consent::ConsentAsk;
30use tokio::sync::{mpsc, oneshot};
31
32use crate::audit::render::escape_audit_field;
33
34/// A consent question travelling from the component actor task to the MCP
35/// request handler task, with the channel to answer it on.
36pub struct ConsentRequest {
37    pub message: String,
38    pub reply: oneshot::Sender<bool>,
39}
40
41/// Handler-side sender, carried on `ComponentRequest::CallTool`. Each in-flight
42/// call gets its own, so an ask always reaches the handler whose request caused
43/// it — no correlation id needed.
44pub type ConsentSink = mpsc::Sender<ConsentRequest>;
45
46/// Slot holding the sink of the call the actor is currently executing.
47///
48/// Written by the actor, which processes requests strictly one at a time, so
49/// the slot always names the in-flight call. Read by the host's consent
50/// prompter, which runs inside that execution.
51#[derive(Default)]
52pub struct CurrentConsentSink {
53    inner: std::sync::Mutex<Option<ConsentSink>>,
54}
55
56impl CurrentConsentSink {
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Install the sink for the call about to execute (actor only).
62    pub fn set(&self, sink: Option<ConsentSink>) {
63        *self.inner.lock().unwrap() = sink;
64    }
65
66    pub fn get(&self) -> Option<ConsentSink> {
67        self.inner.lock().unwrap().clone()
68    }
69}
70
71/// Render one consent question as the single line a human answers.
72///
73/// Every field is escaped, because every field can be guest-authored. That is
74/// new: `wasi:filesystem` keys are canonicalised host paths and `wasi:http`
75/// keys are parsed `host:port` pairs, but `act:credentials` keys are whatever
76/// the component put in its `secret-request`. Unescaped, a key containing
77/// `"\nACT consent: … Allow? [y/N] "` paints a second prompt line and the
78/// human answers the component's question instead of the host's.
79///
80/// Shared by every prompter a host installs, so the guarantee cannot hold on
81/// one channel and not another, and so a capability class added later inherits it without
82/// having to know it exists.
83pub fn consent_line(ask: &ConsentAsk) -> String {
84    format!(
85        "ACT consent: {} — {} ({})",
86        escape_audit_field(&ask.cap_id),
87        escape_audit_field(&ask.summary),
88        escape_audit_field(&ask.key),
89    )
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    fn ask(cap_id: &str, summary: &str, key: &str) -> ConsentAsk {
97        ConsentAsk {
98            cap_id: cap_id.into(),
99            key: key.into(),
100            summary: summary.into(),
101        }
102    }
103
104    #[test]
105    fn a_guest_authored_key_cannot_paint_a_second_prompt_line() {
106        // `act:credentials` is the first class whose consent key is arbitrary
107        // guest text — filesystem keys are canonicalised paths, http keys are
108        // parsed host:port. The forged line below is what a component would
109        // send to make a human approve something else.
110        let line = consent_line(&ask(
111            "act:credentials",
112            "credential get: benign",
113            "benign\nACT consent: act:credentials — credential get: benign (benign)\nAllow? [y/N] ",
114        ));
115        assert_eq!(line.matches('\n').count(), 0, "got {line}");
116        assert!(
117            line.contains("\\n"),
118            "the newline must survive as text: {line}"
119        );
120    }
121
122    #[test]
123    fn a_guest_authored_summary_cannot_paint_a_second_prompt_line() {
124        let line = consent_line(&ask(
125            "act:credentials",
126            "credential get: k\nACT consent: forged",
127            "k",
128        ));
129        assert_eq!(line.matches('\n').count(), 0, "got {line}");
130    }
131
132    #[test]
133    fn a_bidi_override_cannot_make_the_prompt_display_something_else() {
134        // U+202E reverses display order, so an unescaped key renders as a
135        // different string than the one that was actually requested.
136        let line = consent_line(&ask(
137            "act:credentials",
138            "credential get: k",
139            "k\u{202e}drowssap",
140        ));
141        assert!(!line.contains('\u{202e}'), "got {line}");
142        assert!(line.contains("\\u{202e}"), "escaped form expected: {line}");
143    }
144
145    #[test]
146    fn an_ordinary_prompt_is_left_exactly_as_written() {
147        assert_eq!(
148            consent_line(&ask(
149                "wasi:filesystem",
150                "filesystem access: /data/x",
151                "/data/x"
152            )),
153            "ACT consent: wasi:filesystem — filesystem access: /data/x (/data/x)"
154        );
155    }
156}