返回 CodeWhale
ticket.rs
根目录 / crates / tui / src / extension_host / ticket.rs
1 //! Capability tickets: what lets the host ask the core for one specific thing.
2 //!
3 //! A ticket is an opaque id the core mints and keeps a row for. The host can
4 //! only hand it back; what the ticket is good for lives in the row, on the
5 //! Rust side, and [`TicketTable::redeem`] checks every field of it under one
6 //! lock. Today there is one kind, [`TicketKind::Invocation`]: minted for one
7 //! `tool/call` that runs under the turn loop's permission gate, and redeemed
8 //! by `core/call` (`super::core_call`). Other kinds (a process launch, a
9 //! fetch, an MCP grant) join the enum with their first redeemer.
10 //!
11 //! A ticket narrows what a frame may do; it does not isolate plugins that
12 //! share the host process (design section 4.4): a plugin can read the ticket
13 //! of a call made to it, and so can others in the same process. What stops
14 //! that being useful is the row: the owner (its generation and token), the
15 //! tier and the host generation are all checked, so a ticket presented by
16 //! another owner, another tier, a restarted host or for another method is
17 //! refused, and it dies with its invocation, its owner and its host.
18 //!
19 //! Tickets are never persisted, logged or put in a diagnostic: [`Ticket`]'s
20 //! `Debug` is redacted, the table has none, and no refusal quotes one.
21 //!
22 //! A burst of invalid presentations from one host is a protocol violation
23 //! ([`TicketTable::redeem`] reports it; the channel ends the host): a host
24 //! that guesses ids or replays old ones is not honestly confused.
25 //!
26 //! Known limit: ids are 244 random bits from the OS generator (two UUIDv4
27 //! bodies), found by hash lookup rather than compared in constant time; a
28 //! host that can time that lookup already holds the ticket it is probing for
29 //! or none worth having.
30
31 use std::collections::{HashMap, VecDeque};
32 use std::sync::Mutex;
33 use std::time::{Duration, Instant};
34
35 use serde_json::Value;
36
37 use super::protocol::OwnerRef;
38 use super::tier::HostTier;
39
40 /// What a ticket is for. A kind says how it is used up, and nothing else; the
41 /// row says the rest.
42 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
43 pub(crate) enum TicketKind {
44 /// One `tool/call` running under the turn loop's gate: redeemable by that
45 /// call's `core/call` requests, up to the row's `uses`.
46 Invocation,
47 McpLaunch,
48 McpOperation,
49 Execution,
50 }
51
52 impl TicketKind {
53 /// A single-use ticket is removed when used; a budgeted one stays (at zero
54 /// uses it refuses as exhausted) until it is revoked.
55 fn single_use(self) -> bool {
56 match self {
57 Self::Invocation => false,
58 Self::McpLaunch | Self::McpOperation | Self::Execution => true,
59 }
60 }
61 }
62
63 /// An opaque ticket id. Not `Display`, and `Debug` is redacted.
64 #[derive(Clone, PartialEq, Eq, Hash)]
65 pub(crate) struct Ticket(String);
66
67 impl Ticket {
68 /// The id, to send to the host (and nowhere else).
69 #[must_use]
70 pub(crate) fn expose(&self) -> &str {
71 &self.0
72 }
73 }
74
75 impl std::fmt::Debug for Ticket {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.write_str("Ticket(..)")
78 }
79 }
80
81 /// What a new ticket is good for.
82 pub(crate) struct Grant {
83 pub kind: TicketKind,
84 pub tier: HostTier,
85 /// The host process generation that may present it.
86 pub host_generation: u64,
87 pub owner: OwnerRef,
88 /// The protocol method that may redeem it.
89 pub method: &'static str,
90 /// What it names: for an invocation, the `tool/call` id. Compared as
91 /// parsed JSON when a redeemer states one.
92 pub target: Value,
93 pub ttl: Duration,
94 pub uses: u32,
95 }
96
97 struct Row {
98 kind: TicketKind,
99 tier: HostTier,
100 host_generation: u64,
101 owner: OwnerRef,
102 method: &'static str,
103 target: Value,
104 expires: Instant,
105 uses_left: u32,
106 }
107
108 /// What the redeemer says it is.
109 pub(crate) struct Presented<'a> {
110 pub ticket: &'a str,
111 pub kind: TicketKind,
112 pub tier: HostTier,
113 pub host_generation: u64,
114 pub owner: &'a OwnerRef,
115 pub method: &'a str,
116 /// The target the request names, if it names one.
117 pub target: Option<&'a Value>,
118 }
119
120 /// Why a presentation was refused. Never carries a ticket.
121 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
122 pub(crate) enum Refusal {
123 Unknown,
124 Expired,
125 /// A valid ticket whose uses are spent: a limit, not a forgery.
126 Exhausted,
127 WrongKind,
128 WrongTier,
129 WrongGeneration,
130 WrongOwner,
131 WrongMethod,
132 WrongTarget,
133 }
134
135 impl Refusal {
136 /// The words the host is told. Generic on purpose: which field mismatched
137 /// is not an oracle for the host.
138 #[must_use]
139 pub(crate) fn describe(self) -> &'static str {
140 match self {
141 Self::Exhausted => "this invocation's core/call limit is used up",
142 Self::Expired => "this invocation's ticket expired",
143 _ => "the ticket is not valid for this request",
144 }
145 }
146 }
147
148 /// A refusal, and whether it makes the host's frames a protocol violation.
149 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
150 pub(crate) struct Refused {
151 pub reason: Refusal,
152 /// This refusal tipped the host into a burst of invalid presentations
153 /// ([`INVALID_BURST`] within [`INVALID_WINDOW`]).
154 pub violation: bool,
155 }
156
157 /// This many invalid presentations from one host process within
158 /// [`INVALID_WINDOW`] are a protocol violation.
159 pub(crate) const INVALID_BURST: usize = 8;
160 pub(crate) const INVALID_WINDOW: Duration = Duration::from_secs(60);
161
162 #[derive(Default)]
163 struct State {
164 rows: HashMap<String, Row>,
165 /// Recent invalid presentations, by host process `(tier, generation)`.
166 invalid: HashMap<(HostTier, u64), VecDeque<Instant>>,
167 }
168
169 /// Every live ticket, behind one mutex.
170 #[derive(Default)]
171 pub(crate) struct TicketTable {
172 state: Mutex<State>,
173 }
174
175 fn mint_id() -> String {
176 // Two v4 UUIDs: 244 random bits from the OS generator.
177 format!(
178 "cwt.{}{}",
179 uuid::Uuid::new_v4().simple(),
180 uuid::Uuid::new_v4().simple()
181 )
182 }
183
184 impl TicketTable {
185 /// Mint a ticket for `grant`.
186 pub(crate) fn mint(&self, grant: Grant) -> Ticket {
187 let id = mint_id();
188 self.state.lock().expect("ticket lock").rows.insert(
189 id.clone(),
190 Row {
191 kind: grant.kind,
192 tier: grant.tier,
193 host_generation: grant.host_generation,
194 owner: grant.owner,
195 method: grant.method,
196 target: grant.target,
197 expires: Instant::now() + grant.ttl,
198 uses_left: grant.uses,
199 },
200 );
201 Ticket(id)
202 }
203
204 /// Check `presented` against its row, every field, under one lock, and
205 /// use one of its uses. Returns the row's target. A refusal that is not a
206 /// mere limit counts toward the host's burst.
207 pub(crate) fn redeem(&self, presented: &Presented<'_>) -> Result<Value, Refused> {
208 self.redeem_at(presented, Instant::now())
209 }
210
211 fn redeem_at(&self, presented: &Presented<'_>, now: Instant) -> Result<Value, Refused> {
212 let mut state = self.state.lock().expect("ticket lock");
213 let verdict = match state.rows.get_mut(presented.ticket) {
214 None => Err(Refusal::Unknown),
215 Some(row) => Self::check(row, presented, now).map(|target| {
216 row.uses_left -= 1;
217 (target, row.uses_left == 0 && row.kind.single_use())
218 }),
219 };
220 match verdict {
221 Ok((target, spent)) => {
222 if spent {
223 state.rows.remove(presented.ticket);
224 }
225 Ok(target)
226 }
227 Err(reason) => {
228 let violation =
229 reason != Refusal::Exhausted && Self::note_invalid(&mut state, presented, now);
230 Err(Refused { reason, violation })
231 }
232 }
233 }
234
235 fn check(row: &Row, presented: &Presented<'_>, now: Instant) -> Result<Value, Refusal> {
236 if row.kind != presented.kind {
237 return Err(Refusal::WrongKind);
238 }
239 if row.tier != presented.tier {
240 return Err(Refusal::WrongTier);
241 }
242 if row.host_generation != presented.host_generation {
243 return Err(Refusal::WrongGeneration);
244 }
245 if row.owner != *presented.owner {
246 return Err(Refusal::WrongOwner);
247 }
248 if row.method != presented.method {
249 return Err(Refusal::WrongMethod);
250 }
251 if presented.target.is_some_and(|target| *target != row.target) {
252 return Err(Refusal::WrongTarget);
253 }
254 if now >= row.expires {
255 return Err(Refusal::Expired);
256 }
257 if row.uses_left == 0 {
258 return Err(Refusal::Exhausted);
259 }
260 Ok(row.target.clone())
261 }
262
263 /// Record one invalid presentation by `presented`'s host; whether that
264 /// made a burst.
265 fn note_invalid(state: &mut State, presented: &Presented<'_>, now: Instant) -> bool {
266 let recent = state
267 .invalid
268 .entry((presented.tier, presented.host_generation))
269 .or_default();
270 while recent
271 .front()
272 .is_some_and(|at| now.saturating_duration_since(*at) >= INVALID_WINDOW)
273 {
274 recent.pop_front();
275 }
276 recent.push_back(now);
277 recent.len() >= INVALID_BURST
278 }
279
280 /// Revoke one ticket (its invocation ended). Idempotent.
281 pub(crate) fn revoke(&self, ticket: &Ticket) {
282 self.state
283 .lock()
284 .expect("ticket lock")
285 .rows
286 .remove(ticket.expose());
287 }
288
289 /// Revoke every ticket of `plugin_id` (its owner was revoked).
290 pub(crate) fn revoke_owner(&self, plugin_id: &str) {
291 self.state
292 .lock()
293 .expect("ticket lock")
294 .rows
295 .retain(|_, row| row.owner.plugin_id != plugin_id);
296 }
297
298 /// Revoke every ticket of one host process and forget its invalid-burst
299 /// record (the host exited).
300 pub(crate) fn revoke_host(&self, tier: HostTier, host_generation: u64) {
301 let mut state = self.state.lock().expect("ticket lock");
302 state
303 .rows
304 .retain(|_, row| !(row.tier == tier && row.host_generation == host_generation));
305 state.invalid.remove(&(tier, host_generation));
306 }
307
308 /// How many tickets are live.
309 #[cfg(test)]
310 pub(crate) fn live(&self) -> usize {
311 self.state.lock().expect("ticket lock").rows.len()
312 }
313 }
314
315 #[cfg(test)]
316 mod tests {
317 use serde_json::json;
318
319 use super::*;
320
321 fn owner(id: &str, token: &str) -> OwnerRef {
322 OwnerRef {
323 plugin_id: id.to_string(),
324 generation: 1,
325 owner_token: token.to_string(),
326 }
327 }
328
329 fn grant(uses: u32) -> Grant {
330 Grant {
331 kind: TicketKind::Invocation,
332 tier: HostTier::Plugin,
333 host_generation: 3,
334 owner: owner("a", "token-a"),
335 method: "core/call",
336 target: json!({"call_id": "c1", "n": 1}),
337 ttl: Duration::from_secs(60),
338 uses,
339 }
340 }
341
342 fn presented<'a>(ticket: &'a Ticket, owner: &'a OwnerRef) -> Presented<'a> {
343 Presented {
344 ticket: ticket.expose(),
345 kind: TicketKind::Invocation,
346 tier: HostTier::Plugin,
347 host_generation: 3,
348 owner,
349 method: "core/call",
350 target: None,
351 }
352 }
353
354 fn refusal_of(table: &TicketTable, presented: &Presented<'_>) -> Refusal {
355 table.redeem(presented).unwrap_err().reason
356 }
357
358 #[test]
359 fn a_ticket_redeems_only_for_exactly_what_it_was_minted_for() {
360 let table = TicketTable::default();
361 let ticket = table.mint(grant(5));
362 let a = owner("a", "token-a");
363 assert_eq!(
364 table.redeem(&presented(&ticket, &a)).unwrap(),
365 json!({"call_id": "c1", "n": 1})
366 );
367
368 // Every field is checked: owner (plugin, token, generation), tier,
369 // host generation, method; and a stated target, as parsed JSON.
370 let mut other_token = a.clone();
371 other_token.owner_token = "token-x".to_string();
372 let mut other_generation = a.clone();
373 other_generation.generation = 2;
374 let b = owner("b", "token-a");
375 for (owner, expected) in [
376 (&other_token, Refusal::WrongOwner),
377 (&other_generation, Refusal::WrongOwner),
378 (&b, Refusal::WrongOwner),
379 ] {
380 assert_eq!(refusal_of(&table, &presented(&ticket, owner)), expected);
381 }
382 let mut wrong_tier = presented(&ticket, &a);
383 wrong_tier.tier = HostTier::Builtin;
384 assert_eq!(refusal_of(&table, &wrong_tier), Refusal::WrongTier);
385 let mut wrong_generation = presented(&ticket, &a);
386 wrong_generation.host_generation = 4;
387 assert_eq!(
388 refusal_of(&table, &wrong_generation),
389 Refusal::WrongGeneration
390 );
391 let mut wrong_method = presented(&ticket, &a);
392 wrong_method.method = "registry/register";
393 assert_eq!(refusal_of(&table, &wrong_method), Refusal::WrongMethod);
394 let other_target = json!({"call_id": "c2", "n": 1});
395 let mut wrong_target = presented(&ticket, &a);
396 wrong_target.target = Some(&other_target);
397 assert_eq!(refusal_of(&table, &wrong_target), Refusal::WrongTarget);
398 // Parsed JSON, not bytes: key order does not matter.
399 let same_target: Value = serde_json::from_str(r#"{"n": 1, "call_id": "c1"}"#).unwrap();
400 let mut right_target = presented(&ticket, &a);
401 right_target.target = Some(&same_target);
402 assert!(table.redeem(&right_target).is_ok());
403 // Unknown.
404 let unknown = Ticket("cwt.nope".to_string());
405 assert_eq!(
406 refusal_of(&table, &presented(&unknown, &a)),
407 Refusal::Unknown
408 );
409 }
410
411 #[test]
412 fn a_ticket_expires_and_runs_out_of_uses() {
413 let table = TicketTable::default();
414 let ticket = table.mint(grant(2));
415 let a = owner("a", "token-a");
416 let now = Instant::now();
417 assert!(table.redeem_at(&presented(&ticket, &a), now).is_ok());
418 assert!(table.redeem_at(&presented(&ticket, &a), now).is_ok());
419 // Used up: a limit, which is not counted as a forgery.
420 let refused = table.redeem_at(&presented(&ticket, &a), now).unwrap_err();
421 assert_eq!(
422 refused,
423 Refused {
424 reason: Refusal::Exhausted,
425 violation: false
426 }
427 );
428 // Expired: checked against the clock.
429 let fresh = table.mint(grant(5));
430 let later = now + Duration::from_secs(61);
431 let refused = table.redeem_at(&presented(&fresh, &a), later).unwrap_err();
432 assert_eq!(refused.reason, Refusal::Expired);
433 assert!(table.redeem_at(&presented(&fresh, &a), now).is_ok());
434 }
435
436 #[test]
437 fn a_burst_of_invalid_presentations_from_one_host_is_a_violation_and_only_that_host() {
438 let table = TicketTable::default();
439 let a = owner("a", "token-a");
440 let guess = Ticket("cwt.guess".to_string());
441 let now = Instant::now();
442 for count in 1..=INVALID_BURST {
443 let refused = table.redeem_at(&presented(&guess, &a), now).unwrap_err();
444 assert_eq!(
445 refused.violation,
446 count >= INVALID_BURST,
447 "presentation {count}"
448 );
449 }
450 // Another host process is counted on its own.
451 let mut elsewhere = presented(&guess, &a);
452 elsewhere.host_generation = 4;
453 assert!(!table.redeem_at(&elsewhere, now).unwrap_err().violation);
454 // The window: old presentations stop counting.
455 let table = TicketTable::default();
456 for _ in 0..INVALID_BURST - 1 {
457 let _ = table.redeem_at(&presented(&guess, &a), now);
458 }
459 let later = now + INVALID_WINDOW;
460 assert!(
461 !table
462 .redeem_at(&presented(&guess, &a), later)
463 .unwrap_err()
464 .violation
465 );
466 // Exhausting a valid ticket never counts toward a burst.
467 let table = TicketTable::default();
468 let ticket = table.mint(grant(1));
469 assert!(table.redeem_at(&presented(&ticket, &a), now).is_ok());
470 for _ in 0..INVALID_BURST * 2 {
471 let refused = table.redeem_at(&presented(&ticket, &a), now).unwrap_err();
472 assert!(!refused.violation);
473 }
474 }
475
476 #[test]
477 fn tickets_are_revoked_with_their_invocation_owner_and_host_and_never_printed() {
478 let table = TicketTable::default();
479 let a = owner("a", "token-a");
480 let by_call = table.mint(grant(5));
481 let by_owner = table.mint(grant(5));
482 let mut other_owner_grant = grant(5);
483 other_owner_grant.owner = owner("b", "token-b");
484 let other_owner = table.mint(other_owner_grant);
485 let mut other_host_grant = grant(5);
486 other_host_grant.host_generation = 9;
487 other_host_grant.owner = owner("c", "token-c");
488 let other_host = table.mint(other_host_grant);
489 assert_eq!(table.live(), 4);
490
491 table.revoke(&by_call);
492 table.revoke(&by_call);
493 assert_eq!(
494 refusal_of(&table, &presented(&by_call, &a)),
495 Refusal::Unknown
496 );
497 table.revoke_owner("a");
498 assert_eq!(
499 refusal_of(&table, &presented(&by_owner, &a)),
500 Refusal::Unknown
501 );
502 assert_eq!(table.live(), 2, "only a's tickets went");
503 table.revoke_host(HostTier::Plugin, 9);
504 assert_eq!(table.live(), 1);
505 let _ = (other_owner, other_host);
506
507 // A ticket's Debug never shows it.
508 let shown = format!("{:?}", Ticket("cwt.secret-id".to_string()));
509 assert!(!shown.contains("secret"), "{shown}");
510 // Two tickets are never the same, and are long random ids.
511 let one = table.mint(grant(1));
512 let two = table.mint(grant(1));
513 assert_ne!(one.expose(), two.expose());
514 assert!(one.expose().len() >= 64);
515 }
516 }
517
517 lines RUST