1use std::collections::{HashMap, HashSet};
69use std::sync::Arc;
70
71use car_engine::admission::{AdmissionGate, GateContext, GateOutcome};
72use car_ir::{Action, ActionProposal};
73use car_server_types::host::EventSubscriber;
74use chrono::{DateTime, Utc};
75use serde::{Deserialize, Serialize};
76use serde_json::Value;
77use tokio::sync::{Mutex, Notify};
78
79pub const DEFAULT_DECISION_TIMEOUT_MS: u64 = 30_000;
81
82pub const MAX_PENDING_INTENTS: usize = 256;
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct IntentAction {
92 pub id: String,
93 pub action_type: String,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub tool: Option<String>,
97 pub reversibility: String,
101 #[serde(default)]
104 pub parameter_keys: Vec<String>,
105 pub parameters_digest: String,
108}
109
110impl IntentAction {
111 fn from_action(action: &Action) -> Self {
112 let mut parameter_keys: Vec<String> = action.parameters.keys().cloned().collect();
113 parameter_keys.sort();
114 Self {
115 id: action.id.clone(),
116 action_type: action_type_label(action),
117 tool: action.tool.clone(),
118 reversibility: reversibility_label(action),
119 parameters_digest: digest_parameters(&action.parameters),
120 parameter_keys,
121 }
122 }
123}
124
125fn action_type_label(action: &Action) -> String {
128 match serde_json::to_value(&action.action_type) {
129 Ok(Value::String(s)) => s,
130 Ok(other) => other.to_string(),
131 Err(_) => "unknown".to_string(),
132 }
133}
134
135fn reversibility_label(action: &Action) -> String {
136 match serde_json::to_value(action.reversibility) {
137 Ok(Value::String(s)) => s,
138 _ => "irreversible".to_string(),
139 }
140}
141
142fn digest_parameters(parameters: &HashMap<String, Value>) -> String {
150 let mut entries: Vec<(&String, String)> = parameters
151 .iter()
152 .map(|(k, v)| (k, serde_json::to_string(v).unwrap_or_default()))
153 .collect();
154 entries.sort_by(|a, b| a.0.cmp(b.0));
155
156 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
159 for (key, value) in entries {
160 for byte in key.as_bytes().iter().chain(b"=").chain(value.as_bytes()) {
161 hash ^= *byte as u64;
162 hash = hash.wrapping_mul(0x1000_0000_01b3);
163 }
164 }
165 format!("{hash:016x}")
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170pub struct SupervisionIntent {
171 pub id: String,
173 pub proposal_id: String,
174 pub source: String,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub session_id: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub scope: Option<String>,
181 pub actions: Vec<IntentAction>,
182 pub reversibility: String,
185 pub created_at: DateTime<Utc>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
194#[serde(tag = "kind", rename_all = "snake_case")]
195#[non_exhaustive]
196pub enum SupervisionDecision {
197 Allow,
200 Deny { reason: String },
202 Escalate { reason: String },
206}
207
208#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
211pub struct SupervisionFilter {
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub tools: Option<Vec<String>>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub sessions: Option<Vec<String>>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub min_reversibility: Option<String>,
223}
224
225impl SupervisionFilter {
226 fn matches(&self, intent: &SupervisionIntent) -> bool {
227 if let Some(sessions) = &self.sessions {
228 match &intent.session_id {
229 Some(id) if sessions.iter().any(|s| s == id) => {}
230 _ => return false,
231 }
232 }
233 if let Some(tools) = &self.tools {
234 let hit = intent
235 .actions
236 .iter()
237 .filter_map(|a| a.tool.as_ref())
238 .any(|t| tools.iter().any(|w| w == t));
239 if !hit {
240 return false;
241 }
242 }
243 if let Some(min) = &self.min_reversibility {
244 if severity(&intent.reversibility) < severity(min) {
245 return false;
246 }
247 }
248 true
249 }
250}
251
252fn severity(label: &str) -> u8 {
257 match label {
258 "reversible" => 0,
259 "compensable" => 1,
260 _ => 2,
261 }
262}
263
264struct Supervisor {
265 filter: SupervisionFilter,
266 channel: Arc<dyn EventSubscriber>,
267}
268
269struct PendingIntent {
270 intent: SupervisionIntent,
271 decision: Option<SupervisionDecision>,
272 notify: Arc<Notify>,
273}
274
275pub struct SupervisionRegistry {
277 supervisors: Mutex<HashMap<String, Supervisor>>,
278 pending: Mutex<HashMap<String, PendingIntent>>,
279 timeout: std::time::Duration,
280}
281
282impl Default for SupervisionRegistry {
283 fn default() -> Self {
284 Self::new(std::time::Duration::from_millis(
285 DEFAULT_DECISION_TIMEOUT_MS,
286 ))
287 }
288}
289
290impl SupervisionRegistry {
291 pub fn new(timeout: std::time::Duration) -> Self {
292 Self {
293 supervisors: Mutex::new(HashMap::new()),
294 pending: Mutex::new(HashMap::new()),
295 timeout,
296 }
297 }
298
299 pub fn timeout(&self) -> std::time::Duration {
300 self.timeout
301 }
302
303 pub async fn subscribe(
305 &self,
306 client_id: &str,
307 filter: SupervisionFilter,
308 channel: Arc<dyn EventSubscriber>,
309 ) {
310 self.supervisors
311 .lock()
312 .await
313 .insert(client_id.to_string(), Supervisor { filter, channel });
314 }
315
316 pub async fn unsubscribe(&self, client_id: &str) -> bool {
323 self.supervisors.lock().await.remove(client_id).is_some()
324 }
325
326 pub async fn is_subscribed(&self, client_id: &str) -> bool {
327 self.supervisors.lock().await.contains_key(client_id)
328 }
329
330 pub async fn subscriber_count(&self) -> usize {
331 self.supervisors.lock().await.len()
332 }
333
334 pub async fn pending(&self) -> Vec<SupervisionIntent> {
337 let mut intents: Vec<SupervisionIntent> = self
338 .pending
339 .lock()
340 .await
341 .values()
342 .map(|p| p.intent.clone())
343 .collect();
344 intents.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
345 intents
346 }
347
348 pub async fn decide(
355 &self,
356 intent_id: &str,
357 decision: SupervisionDecision,
358 ) -> Result<(), String> {
359 let mut pending = self.pending.lock().await;
360 let entry = pending
361 .get_mut(intent_id)
362 .ok_or_else(|| format!("no pending supervision intent '{intent_id}'"))?;
363 if entry.decision.is_some() {
364 return Err(format!("intent '{intent_id}' was already decided"));
365 }
366 entry.decision = Some(decision);
367 entry.notify.notify_waiters();
368 Ok(())
369 }
370
371 async fn publish_and_wait(&self, intent: SupervisionIntent) -> Option<SupervisionDecision> {
376 let targets: Vec<Arc<dyn EventSubscriber>> = {
377 let supervisors = self.supervisors.lock().await;
378 supervisors
379 .values()
380 .filter(|s| s.filter.matches(&intent))
381 .map(|s| s.channel.clone())
382 .collect()
383 };
384 if targets.is_empty() {
385 return Some(SupervisionDecision::Allow);
386 }
387
388 let intent_id = intent.id.clone();
389 let notify = Arc::new(Notify::new());
390 {
391 let mut pending = self.pending.lock().await;
392 if pending.len() >= MAX_PENDING_INTENTS {
393 return None;
394 }
395 pending.insert(
396 intent_id.clone(),
397 PendingIntent {
398 intent: intent.clone(),
399 decision: None,
400 notify: notify.clone(),
401 },
402 );
403 }
404
405 let waiter = notify.notified();
409 tokio::pin!(waiter);
410
411 if let Ok(frame) = serde_json::to_string(&serde_json::json!({
412 "jsonrpc": "2.0",
413 "method": "supervision.intent",
414 "params": intent,
415 })) {
416 for target in targets {
417 target.send_text(frame.clone()).await;
418 }
419 }
420
421 let outcome = tokio::time::timeout(self.timeout, waiter).await;
422
423 let mut pending = self.pending.lock().await;
424 let entry = pending.remove(&intent_id);
425 match (outcome, entry) {
426 (
430 _,
431 Some(PendingIntent {
432 decision: Some(d), ..
433 }),
434 ) => Some(d),
435 _ => None,
436 }
437 }
438}
439
440pub struct SupervisionGate {
442 registry: Arc<SupervisionRegistry>,
443}
444
445impl SupervisionGate {
446 pub fn new(registry: Arc<SupervisionRegistry>) -> Self {
447 Self { registry }
448 }
449
450 fn intent_for(proposal: &ActionProposal, ctx: &GateContext<'_>) -> SupervisionIntent {
451 SupervisionIntent {
452 id: format!("intent-{}", uuid_like()),
453 proposal_id: proposal.id.clone(),
454 source: proposal.source.clone(),
455 session_id: ctx.session_id.map(|s| s.to_string()),
456 scope: ctx.scope.map(|s| format!("{s:?}")),
457 actions: proposal
458 .actions
459 .iter()
460 .map(IntentAction::from_action)
461 .collect(),
462 reversibility: match serde_json::to_value(proposal.rollback_contract()) {
463 Ok(Value::String(s)) => s,
464 _ => "irreversible".to_string(),
465 },
466 created_at: Utc::now(),
467 }
468 }
469}
470
471fn uuid_like() -> String {
472 use std::sync::atomic::{AtomicU64, Ordering};
473 static COUNTER: AtomicU64 = AtomicU64::new(0);
474 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
475 format!("{:x}-{:x}", Utc::now().timestamp_micros(), n)
476}
477
478#[async_trait::async_trait]
479impl AdmissionGate for SupervisionGate {
480 fn name(&self) -> &str {
481 "supervision"
482 }
483
484 async fn check(&self, proposal: &ActionProposal, ctx: &GateContext<'_>) -> GateOutcome {
485 if self.registry.subscriber_count().await == 0 {
489 return GateOutcome::Allow;
490 }
491
492 let intent = Self::intent_for(proposal, ctx);
493 let all_actions: HashSet<String> = proposal.actions.iter().map(|a| a.id.clone()).collect();
494
495 match self.registry.publish_and_wait(intent).await {
496 Some(SupervisionDecision::Allow) => GateOutcome::Allow,
497 Some(SupervisionDecision::Deny { reason }) => GateOutcome::Reject {
498 blocked: all_actions,
499 reason: format!("supervisor denied: {reason}"),
500 },
501 Some(SupervisionDecision::Escalate { reason }) => GateOutcome::NeedsApproval {
502 fingerprint: format!("supervision:{}", proposal.id),
503 actions: all_actions,
504 reason: format!("supervisor escalated: {reason}"),
505 },
506 None => GateOutcome::Reject {
509 blocked: all_actions,
510 reason: format!(
511 "no supervisor decision within {}ms (fail-closed)",
512 self.registry.timeout().as_millis()
513 ),
514 },
515 }
516 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use car_ir::{ActionType, Reversibility};
523 use std::sync::Mutex as StdMutex;
524
525 struct Recorder {
528 frames: Arc<StdMutex<Vec<String>>>,
529 auto: Option<(Arc<SupervisionRegistry>, SupervisionDecision)>,
530 }
531
532 #[async_trait::async_trait]
533 impl EventSubscriber for Recorder {
534 async fn send_text(&self, json: String) {
535 self.frames.lock().unwrap().push(json.clone());
536 if let Some((registry, decision)) = &self.auto {
537 let parsed: Value = serde_json::from_str(&json).unwrap();
538 let id = parsed["params"]["id"].as_str().unwrap().to_string();
539 let _ = registry.decide(&id, decision.clone()).await;
540 }
541 }
542 }
543
544 fn recorder() -> (Arc<Recorder>, Arc<StdMutex<Vec<String>>>) {
545 let frames = Arc::new(StdMutex::new(Vec::new()));
546 (
547 Arc::new(Recorder {
548 frames: frames.clone(),
549 auto: None,
550 }),
551 frames,
552 )
553 }
554
555 fn proposal(tool: &str, reversibility: Reversibility) -> ActionProposal {
556 let mut action = Action::tool_call(tool);
557 action.reversibility = reversibility;
558 action = action.with_param("path", Value::from("/tmp/x"));
559 ActionProposal {
560 id: "prop-1".to_string(),
561 source: "test".to_string(),
562 actions: vec![action],
563 timestamp: Utc::now(),
564 context: HashMap::new(),
565 }
566 }
567
568 async fn check(gate: &SupervisionGate, p: &ActionProposal) -> GateOutcome {
569 let state = HashMap::new();
570 let versions = HashMap::new();
571 let ctx = GateContext {
572 session_id: Some("sess-1"),
573 scope: None,
574 state: &state,
575 versions: &versions,
576 };
577 gate.check(p, &ctx).await
578 }
579
580 #[tokio::test]
581 async fn a_gate_with_no_subscribers_is_inert() {
582 let registry = Arc::new(SupervisionRegistry::default());
583 let gate = SupervisionGate::new(registry.clone());
584 assert!(matches!(
587 check(&gate, &proposal("write_file", Reversibility::Irreversible)).await,
588 GateOutcome::Allow
589 ));
590 assert!(registry.pending().await.is_empty());
591 }
592
593 #[tokio::test]
594 async fn an_allow_decision_admits_the_proposal() {
595 let registry = Arc::new(SupervisionRegistry::default());
596 let sub = Arc::new(Recorder {
597 frames: Arc::new(StdMutex::new(Vec::new())),
598 auto: Some((registry.clone(), SupervisionDecision::Allow)),
599 });
600 registry
601 .subscribe("sup-1", SupervisionFilter::default(), sub)
602 .await;
603 let gate = SupervisionGate::new(registry);
604 assert!(matches!(
605 check(&gate, &proposal("write_file", Reversibility::Reversible)).await,
606 GateOutcome::Allow
607 ));
608 }
609
610 #[tokio::test]
611 async fn a_deny_blocks_every_action_in_the_proposal() {
612 let registry = Arc::new(SupervisionRegistry::default());
613 let sub = Arc::new(Recorder {
614 frames: Arc::new(StdMutex::new(Vec::new())),
615 auto: Some((
616 registry.clone(),
617 SupervisionDecision::Deny {
618 reason: "not on a Friday".to_string(),
619 },
620 )),
621 });
622 registry
623 .subscribe("sup-1", SupervisionFilter::default(), sub)
624 .await;
625 let gate = SupervisionGate::new(registry);
626 match check(&gate, &proposal("deploy", Reversibility::Irreversible)).await {
627 GateOutcome::Reject { blocked, reason } => {
628 assert_eq!(blocked.len(), 1);
629 assert!(reason.contains("not on a Friday"), "{reason}");
630 }
631 other => panic!("expected Reject, got {other:?}"),
632 }
633 }
634
635 #[tokio::test]
636 async fn an_escalation_becomes_a_human_approval() {
637 let registry = Arc::new(SupervisionRegistry::default());
638 let sub = Arc::new(Recorder {
639 frames: Arc::new(StdMutex::new(Vec::new())),
640 auto: Some((
641 registry.clone(),
642 SupervisionDecision::Escalate {
643 reason: "unsure".to_string(),
644 },
645 )),
646 });
647 registry
648 .subscribe("sup-1", SupervisionFilter::default(), sub)
649 .await;
650 let gate = SupervisionGate::new(registry);
651 match check(&gate, &proposal("deploy", Reversibility::Irreversible)).await {
652 GateOutcome::NeedsApproval { fingerprint, .. } => {
653 assert_eq!(fingerprint, "supervision:prop-1");
654 }
655 other => panic!("expected NeedsApproval, got {other:?}"),
656 }
657 }
658
659 #[tokio::test]
660 async fn a_silent_supervisor_fails_closed() {
661 let registry = Arc::new(SupervisionRegistry::new(std::time::Duration::from_millis(
662 60,
663 )));
664 let (sub, frames) = recorder();
665 registry
666 .subscribe("sup-1", SupervisionFilter::default(), sub)
667 .await;
668 let gate = SupervisionGate::new(registry.clone());
669 match check(&gate, &proposal("rm", Reversibility::Irreversible)).await {
670 GateOutcome::Reject { reason, .. } => {
671 assert!(reason.contains("fail-closed"), "{reason}")
672 }
673 other => panic!("expected fail-closed Reject, got {other:?}"),
674 }
675 assert_eq!(
676 frames.lock().unwrap().len(),
677 1,
678 "intent should be published once"
679 );
680 assert!(registry.pending().await.is_empty());
682 }
683
684 #[tokio::test]
685 async fn unsubscribing_does_not_release_a_parked_intent_as_allow() {
686 let registry = Arc::new(SupervisionRegistry::new(std::time::Duration::from_millis(
688 60,
689 )));
690 let (sub, _) = recorder();
691 registry
692 .subscribe("sup-1", SupervisionFilter::default(), sub)
693 .await;
694 let gate = SupervisionGate::new(registry.clone());
695 let reg = registry.clone();
696 tokio::spawn(async move {
697 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
698 reg.unsubscribe("sup-1").await;
699 });
700 assert!(matches!(
701 check(&gate, &proposal("rm", Reversibility::Irreversible)).await,
702 GateOutcome::Reject { .. }
703 ));
704 }
705
706 #[tokio::test]
707 async fn deciding_an_unknown_intent_is_an_error_not_a_silent_noop() {
708 let registry = SupervisionRegistry::default();
709 let err = registry
710 .decide("intent-nope", SupervisionDecision::Allow)
711 .await
712 .unwrap_err();
713 assert!(err.contains("no pending supervision intent"), "{err}");
714 }
715
716 #[tokio::test]
717 async fn an_intent_cannot_be_decided_twice() {
718 let registry = Arc::new(SupervisionRegistry::new(std::time::Duration::from_millis(
719 200,
720 )));
721 let (sub, _) = recorder();
722 registry
723 .subscribe("sup-1", SupervisionFilter::default(), sub)
724 .await;
725 let gate = SupervisionGate::new(registry.clone());
726 let reg = registry.clone();
727 let handle =
728 tokio::spawn(
729 async move { check(&gate, &proposal("rm", Reversibility::Reversible)).await },
730 );
731 let id = loop {
733 if let Some(i) = reg.pending().await.first() {
734 break i.id.clone();
735 }
736 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
737 };
738 reg.decide(&id, SupervisionDecision::Allow).await.unwrap();
739 let second = reg.decide(&id, SupervisionDecision::Allow).await;
740 assert!(
741 second.is_err(),
742 "a decided intent must not accept a second verdict"
743 );
744 assert!(matches!(handle.await.unwrap(), GateOutcome::Allow));
745 }
746
747 #[tokio::test]
748 async fn a_filter_that_does_not_match_leaves_the_proposal_unsupervised() {
749 let registry = Arc::new(SupervisionRegistry::new(std::time::Duration::from_millis(
750 60,
751 )));
752 let (sub, frames) = recorder();
753 registry
754 .subscribe(
755 "sup-1",
756 SupervisionFilter {
757 tools: Some(vec!["deploy".to_string()]),
758 ..Default::default()
759 },
760 sub,
761 )
762 .await;
763 let gate = SupervisionGate::new(registry);
764 assert!(matches!(
767 check(&gate, &proposal("read_file", Reversibility::Reversible)).await,
768 GateOutcome::Allow
769 ));
770 assert!(frames.lock().unwrap().is_empty());
771 }
772
773 #[tokio::test]
774 async fn min_reversibility_matches_this_severity_and_worse() {
775 let registry = Arc::new(SupervisionRegistry::new(std::time::Duration::from_millis(
776 60,
777 )));
778 let (sub, frames) = recorder();
779 registry
780 .subscribe(
781 "sup-1",
782 SupervisionFilter {
783 min_reversibility: Some("compensable".to_string()),
784 ..Default::default()
785 },
786 sub,
787 )
788 .await;
789 let gate = SupervisionGate::new(registry);
790 let _ = check(&gate, &proposal("read", Reversibility::Reversible)).await;
792 assert!(frames.lock().unwrap().is_empty());
793 let _ = check(&gate, &proposal("rm", Reversibility::Irreversible)).await;
795 assert_eq!(frames.lock().unwrap().len(), 1);
796 }
797
798 #[test]
799 fn the_parameter_digest_is_order_independent_and_value_sensitive() {
800 let mut a = HashMap::new();
801 a.insert("x".to_string(), Value::from(1));
802 a.insert("y".to_string(), Value::from("two"));
803 let mut b = HashMap::new();
804 b.insert("y".to_string(), Value::from("two"));
805 b.insert("x".to_string(), Value::from(1));
806 assert_eq!(digest_parameters(&a), digest_parameters(&b));
807
808 let mut c = HashMap::new();
809 c.insert("x".to_string(), Value::from(2));
810 c.insert("y".to_string(), Value::from("two"));
811 assert_ne!(digest_parameters(&a), digest_parameters(&c));
812 }
813
814 #[test]
815 fn an_intent_carries_key_names_but_never_parameter_values() {
816 let mut action = Action::tool_call("run");
817 action = action.with_param("command", Value::from("rm -rf /secret/path"));
818 let trimmed = IntentAction::from_action(&action);
819 let json = serde_json::to_string(&trimmed).unwrap();
820 assert!(json.contains("command"), "key names are useful and cheap");
821 assert!(
822 !json.contains("secret"),
823 "parameter VALUES must not ride along: {json}"
824 );
825 }
826
827 #[test]
828 fn an_unknown_reversibility_label_sorts_as_most_severe() {
829 assert_eq!(severity("something_new"), severity("irreversible"));
832 }
833
834 #[test]
835 fn the_decision_wire_form_is_tagged_and_snake_case() {
836 let json = serde_json::to_string(&SupervisionDecision::Deny {
837 reason: "no".to_string(),
838 })
839 .unwrap();
840 assert_eq!(json, r#"{"kind":"deny","reason":"no"}"#);
841 let parsed: SupervisionDecision = serde_json::from_str(r#"{"kind":"allow"}"#).unwrap();
842 assert_eq!(parsed, SupervisionDecision::Allow);
843 }
844
845 #[test]
846 fn action_type_and_reversibility_labels_come_from_serde_not_a_second_table() {
847 let mut action = Action::new(ActionType::StateWrite);
848 action.reversibility = Reversibility::Compensable;
849 let trimmed = IntentAction::from_action(&action);
850 assert_eq!(trimmed.action_type, "state_write");
851 assert_eq!(trimmed.reversibility, "compensable");
852 }
853}