act_runtime/consent/mod.rs
1//! Consent: the question a capability gate asks a human, and the channel it
2//! travels on.
3//!
4//! # Two layers of prompt text
5//!
6//! The builders here ([`prompt_line`], and `credentials`' `consent_summary`)
7//! are the *inner* layer: they assemble one readable sentence, attribute the
8//! component's own words to it, and keep guest text from forging a second
9//! question. [`consent_line`] is the *outer* layer, applied by every prompter
10//! just before display, and it escapes the finished line rather than keeping
11//! it readable. Both are needed: the inner one is what a human can actually
12//! read, the outer one is what makes the whole line unforgeable.
13//!
14//! [`sanitize_hint`] and [`HINT_LIMIT`] live here rather than in one gate,
15//! because `act:credentials`' `hint` and `act:consent`'s `summary` are the
16//! same problem — free text the guest wrote, shown to a human about to answer
17//! yes or no — and two copies of that helper are two helpers that drift.
18
19mod channel;
20// Crate-visible: the gate is reached through the linker, and `ConsentGate`
21// cannot be constructed without a wasmtime store, so publishing it would add
22// a type to act-runtime's API that no embedder can use.
23pub(crate) mod gate;
24
25pub use channel::*;
26
27/// Longest guest-authored free text shown on a consent prompt.
28pub(crate) const HINT_LIMIT: usize = 120;
29
30/// Truncate a guest-authored field to at most [`HINT_LIMIT`] characters,
31/// appending `…` when it was cut. Truncation only — no blanking of control
32/// or bidi-override characters, unlike [`sanitize_hint`]. That is
33/// deliberate: `class` and `key` (unlike a free-text `summary`/`hint`) are
34/// escaped whole-line by [`consent_line`] just before display, which is what
35/// makes them forgery-proof; this only guards against a pathologically long
36/// value flooding a terminal prompt or an MCP elicitation the way §5's
37/// "truncate" requirement asks. `sanitize_hint` calls this too, after its own
38/// blanking pass, so there is one truncation rule for every field on a
39/// prompt rather than two that can drift.
40pub(crate) fn truncate_field(s: &str) -> String {
41 match s.char_indices().nth(HINT_LIMIT) {
42 Some((idx, _)) => format!("{}…", &s[..idx]),
43 None => s.to_string(),
44 }
45}
46
47/// Build the one line a human is asked to approve for a semantic class.
48///
49/// Per ACT-CONSENT.md §5 the component reference leads: the whole question is
50/// *which* artifact is asking to drop that database, and a prompt naming only
51/// the class and key would let any component borrow another's reputation. It
52/// is the reference the operator themselves supplied, never a name the guest
53/// chose.
54///
55/// Then the class and the key — the two things policy actually matched on, so
56/// what the human approves is what the grant would have authorized (§8.1) —
57/// and last the component's `summary`, attributed as its own words, stripped
58/// of control and bidi-override characters and truncated.
59///
60/// Deliberately the same shape as `credentials::consent_summary`: a human who
61/// has learned to read one ACT consent prompt has learned to read them all.
62pub fn prompt_line(component: Option<&str>, class: &str, key: &str, summary: &str) -> String {
63 // `key` is guest-authored (it comes straight off the consent request)
64 // and unbounded — nothing stops a component from naming a
65 // megabyte-long key. Escaping (in `consent_line`, applied to the whole
66 // finished line) is what stops it forging a second question; this stops
67 // it flooding the terminal or the MCP elicitation with one real one.
68 let key = truncate_field(key);
69 let base = match component {
70 Some(c) => format!("{c} requests {class}: {key}"),
71 None => format!("{class}: {key}"),
72 };
73 match sanitize_hint(summary) {
74 h if !h.is_empty() => format!("{base} — component says: \"{h}\""),
75 _ => base,
76 }
77}
78
79/// Blank out anything that could forge or disguise prompt text, then truncate.
80///
81/// Uses the audit trail's own `needs_escape` rather than `char::is_control`:
82/// the latter is Unicode category `Cc` only and misses the bidi controls
83/// (U+202A-202E, U+2066-2069) and line separators (U+2028/2029). A
84/// right-to-left override makes a terminal *display* a different string than
85/// the one supplied — which is worth strictly more on a prompt a human is
86/// about to answer than on an audit line read afterwards, so the more
87/// sensitive surface must not carry the weaker predicate.
88pub(crate) fn sanitize_hint(hint: &str) -> String {
89 let cleaned: String = hint
90 .chars()
91 .map(|c| {
92 if crate::audit::render::needs_escape(c) {
93 ' '
94 } else {
95 c
96 }
97 })
98 .collect();
99 truncate_field(cleaned.trim())
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn a_forged_summary_cannot_paint_a_second_prompt_line() {
108 // ACT-CONSENT.md §5: unsanitized guest text can render a second, forged
109 // question and collect approval for something the host never asked.
110 let line = prompt_line(
111 Some("ghcr.io/actpkg/postgres:1.0"),
112 "db:drop",
113 "analytics",
114 "benign\nACT consent: db:drop — drop test_scratch? [y/N] ",
115 );
116 assert!(
117 !line.contains('\n'),
118 "the rendered line must stay one line: {line}"
119 );
120 assert!(
121 line.contains("ghcr.io/actpkg/postgres:1.0"),
122 "the component must be named"
123 );
124 }
125
126 #[test]
127 fn the_prompt_names_the_class_and_the_key_policy_matched_on() {
128 // §8.1: there is exactly one key, and it is simultaneously what a
129 // human is shown, what is recorded, and what policy matches. A prompt
130 // that omitted it would let the operator approve a different subject
131 // than the one authorized.
132 let line = prompt_line(Some("./postgres.wasm"), "db:drop", "analytics", "");
133 assert_eq!(line, "./postgres.wasm requests db:drop: analytics");
134 }
135
136 #[test]
137 fn a_bidi_override_in_a_summary_is_blanked_not_merely_control_stripped() {
138 for sneaky in ['\u{202e}', '\u{2066}', '\u{200f}', '\u{2028}'] {
139 let line = prompt_line(
140 Some("comp"),
141 "db:drop",
142 "analytics",
143 &format!("drop{sneaky}reversed"),
144 );
145 assert!(
146 !line.contains(sneaky),
147 "U+{:04X} survived: {line}",
148 sneaky as u32
149 );
150 }
151 }
152
153 #[test]
154 fn a_long_summary_is_truncated_rather_than_flooding_the_prompt() {
155 let line = prompt_line(Some("comp"), "db:drop", "analytics", &"a".repeat(500));
156 assert!(
157 line.chars().count() < 220,
158 "got {} chars",
159 line.chars().count()
160 );
161 assert!(line.contains('…'));
162 }
163
164 #[test]
165 fn an_empty_summary_leaves_the_prompt_host_authored_end_to_end() {
166 assert_eq!(
167 prompt_line(None, "db:drop", "analytics", " "),
168 "db:drop: analytics"
169 );
170 }
171
172 #[test]
173 fn a_megabyte_long_key_is_truncated_rather_than_flooding_the_prompt() {
174 // M5: `key` is guest-authored and unbounded — nothing at the WIT
175 // level stops a component from naming a huge one. §5 requires
176 // truncation, not just escaping (escaping alone still floods a
177 // terminal or an MCP elicitation with a real, if unforgeable, wall
178 // of text).
179 let huge_key = "x".repeat(1_000_000);
180 let line = prompt_line(Some("comp"), "db:drop", &huge_key, "");
181 assert!(
182 line.chars().count() < 200,
183 "expected the key to be truncated, got {} chars",
184 line.chars().count()
185 );
186 assert!(line.contains('…'), "got {line}");
187 assert!(
188 line.contains("comp requests db:drop:"),
189 "the rest of the line must still render normally, got {line}"
190 );
191 }
192
193 #[test]
194 fn truncate_field_leaves_a_short_value_unchanged() {
195 assert_eq!(truncate_field("analytics"), "analytics");
196 }
197}