1use anyhow::{anyhow, bail, Result};
2use serde::{Deserialize, Serialize};
3use serde_json::{json, Map, Value};
4
5use crate::{config::PolicyMode, runtime::Harness, types::SpeedMode};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
9#[serde(rename_all = "kebab-case")]
10pub enum SafetyClass {
11 ObserveOnly,
12 SimControl,
13 PhysicalStop,
14 PhysicalMotion,
15 PhysicalHighRisk,
16}
17
18impl SafetyClass {
19 pub fn as_str(self) -> &'static str {
20 match self {
21 Self::ObserveOnly => "observe-only",
22 Self::SimControl => "sim-control",
23 Self::PhysicalStop => "physical-stop",
24 Self::PhysicalMotion => "physical-motion",
25 Self::PhysicalHighRisk => "physical-high-risk",
26 }
27 }
28
29 fn is_physical_action(self) -> bool {
30 matches!(
31 self,
32 Self::PhysicalStop | Self::PhysicalMotion | Self::PhysicalHighRisk
33 )
34 }
35
36 fn is_policy_gated(self) -> bool {
37 matches!(self, Self::PhysicalMotion | Self::PhysicalHighRisk)
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
43#[serde(rename_all = "kebab-case")]
44pub enum InvocationOrigin {
45 Runtime,
46 Cli,
47 Http,
48 Mcp,
49 Agent,
50}
51
52impl InvocationOrigin {
53 pub fn as_str(self) -> &'static str {
54 match self {
55 Self::Runtime => "runtime",
56 Self::Cli => "cli",
57 Self::Http => "http",
58 Self::Mcp => "mcp",
59 Self::Agent => "agent",
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct InvocationContext {
66 pub origin: InvocationOrigin,
67}
68
69impl InvocationContext {
70 pub fn new(origin: InvocationOrigin) -> Self {
71 Self { origin }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum PolicyDecision {
77 Execute,
78 DryRun,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
83pub struct CapabilityDescriptor {
84 pub name: String,
85 pub description: String,
86 pub module: String,
87 pub safety: SafetyClass,
88 pub input_schema: Value,
89 pub output_schema: Value,
90}
91
92#[derive(Clone)]
93pub struct CapabilityRegistry {
94 harness: Harness,
95 descriptors: Vec<CapabilityDescriptor>,
96}
97
98impl CapabilityRegistry {
99 pub fn new(harness: Harness) -> Self {
100 Self {
101 harness,
102 descriptors: default_capability_descriptors(),
103 }
104 }
105
106 pub fn descriptors(&self) -> &[CapabilityDescriptor] {
107 &self.descriptors
108 }
109
110 pub fn names(&self) -> Vec<String> {
111 self.descriptors
112 .iter()
113 .map(|descriptor| descriptor.name.clone())
114 .collect()
115 }
116
117 pub fn invoke_value(&self, name: &str, args: Value) -> Result<Value> {
118 self.invoke_value_with_context(
119 name,
120 args,
121 InvocationContext::new(InvocationOrigin::Runtime),
122 )
123 }
124
125 pub fn invoke_value_with_origin(
126 &self,
127 name: &str,
128 args: Value,
129 origin: InvocationOrigin,
130 ) -> Result<Value> {
131 self.invoke_value_with_context(name, args, InvocationContext::new(origin))
132 }
133
134 pub fn invoke_value_with_context(
135 &self,
136 name: &str,
137 args: Value,
138 context: InvocationContext,
139 ) -> Result<Value> {
140 let name = canonical_name(name);
141 let descriptor = self
142 .descriptors
143 .iter()
144 .find(|descriptor| descriptor.name == name)
145 .ok_or_else(|| anyhow!("unknown capability '{name}'"))?;
146 let safety = descriptor.safety;
147 let mut args = args_object(args)?;
148 let approval = optional_bool_removed(&mut args, "approval")?.unwrap_or(false);
149 let decision = self.policy_decision(name, safety, &args, approval, context)?;
150
151 if decision == PolicyDecision::DryRun {
152 self.log_policy_approved(name, safety, context, true);
153 return Ok(json!({
154 "ok": true,
155 "dry_run": true,
156 "capability": name,
157 "safety": safety.as_str(),
158 "origin": context.origin.as_str(),
159 "policy_mode": self.harness.config().policy_mode.as_str(),
160 }));
161 }
162
163 let result = self.invoke_unchecked(name, args);
164 if safety.is_physical_action() {
165 match &result {
166 Ok(_) => self.log_policy_approved(name, safety, context, false),
167 Err(err) => self.log_policy_denied(name, safety, context, &err.to_string()),
168 }
169 }
170 result
171 }
172
173 fn invoke_unchecked(&self, name: &str, args: Map<String, Value>) -> Result<Value> {
174 match name {
175 "health" => {
176 ensure_fields(&args, &[])?;
177 serde_json::to_value(self.harness.health()).map_err(Into::into)
178 }
179 "capabilities" => {
180 ensure_fields(&args, &[])?;
181 serde_json::to_value(self.harness.capabilities()).map_err(Into::into)
182 }
183 "observe" => {
184 ensure_fields(&args, &[])?;
185 serde_json::to_value(self.harness.telemetry()).map_err(Into::into)
186 }
187 "capture" => {
188 ensure_fields(&args, &[])?;
189 serde_json::to_value(self.harness.capture()).map_err(Into::into)
190 }
191 "authorize" => {
192 ensure_fields(&args, &["token", "ttl_secs", "speed_mode"])?;
193 let token = required_string(&args, "token")?;
194 let ttl_secs = optional_u64(&args, "ttl_secs")?.unwrap_or(120);
195 let speed_mode = optional_speed_mode(&args, "speed_mode")?.unwrap_or_default();
196 self.harness.authorize(token, ttl_secs, speed_mode)?;
197 Ok(json!({ "ok": true, "ttl_secs": ttl_secs, "speed_mode": speed_mode }))
198 }
199 "drive" => {
200 ensure_fields(&args, &["token", "left", "right", "speed_mode"])?;
201 let left = required_f64(&args, "left")?;
202 let right = required_f64(&args, "right")?;
203 let token = optional_string(&args, "token")?;
204 let speed_mode = optional_speed_mode(&args, "speed_mode")?;
205 serde_json::to_value(self.harness.drive(
206 token.as_deref(),
207 left,
208 right,
209 speed_mode,
210 )?)
211 .map_err(Into::into)
212 }
213 "speed_mode" => {
214 ensure_fields(&args, &["token", "speed_mode"])?;
215 let token = optional_string(&args, "token")?;
216 let speed_mode = required_speed_mode(&args, "speed_mode")?;
217 self.harness.set_speed_mode(token.as_deref(), speed_mode)?;
218 Ok(json!({ "ok": true, "speed_mode": speed_mode }))
219 }
220 "stop" => {
221 ensure_fields(&args, &[])?;
222 serde_json::to_value(self.harness.stop()?).map_err(Into::into)
223 }
224 "estop" => {
225 ensure_fields(&args, &[])?;
226 self.harness.estop()?;
227 Ok(json!({ "ok": true, "estop": true }))
228 }
229 "estop_reset" => {
230 ensure_fields(&args, &["token"])?;
231 let token = optional_string(&args, "token")?;
232 self.harness.reset_estop(token.as_deref())?;
233 Ok(json!({ "ok": true, "estop": false }))
234 }
235 other => Err(anyhow!("unknown capability '{other}'")),
236 }
237 }
238
239 fn policy_decision(
240 &self,
241 name: &str,
242 safety: SafetyClass,
243 args: &Map<String, Value>,
244 approval: bool,
245 context: InvocationContext,
246 ) -> Result<PolicyDecision> {
247 if !safety.is_policy_gated() {
248 return Ok(PolicyDecision::Execute);
249 }
250
251 if self.harness.config().profile.is_physical() && !self.harness.physical_actuation_enabled()
252 {
253 self.log_policy_denied(
254 name,
255 safety,
256 context,
257 "physical action requires LEASH_ALLOW_PHYSICAL_ACTUATION=1 or --allow-physical-actuation",
258 );
259 bail!("physical action requires explicit physical actuation gate");
260 }
261
262 if context.origin == InvocationOrigin::Agent && !approval {
263 self.log_policy_denied(
264 name,
265 safety,
266 context,
267 "agent physical action requires approval=true",
268 );
269 bail!("agent physical action requires approval=true");
270 }
271
272 match self.harness.config().policy_mode {
273 PolicyMode::DryRun => Ok(PolicyDecision::DryRun),
274 PolicyMode::RequireToken => {
275 if token_satisfied(args) {
276 Ok(PolicyDecision::Execute)
277 } else {
278 self.log_policy_denied(
279 name,
280 safety,
281 context,
282 "policy require-token requires token for physical action",
283 );
284 bail!("policy require-token requires token for physical action");
285 }
286 }
287 PolicyMode::RequireApproval => {
288 if approval {
289 Ok(PolicyDecision::Execute)
290 } else {
291 self.log_policy_denied(
292 name,
293 safety,
294 context,
295 "policy require-approval requires approval=true for physical action",
296 );
297 bail!("policy require-approval requires approval=true for physical action");
298 }
299 }
300 PolicyMode::Deny => {
301 self.log_policy_denied(name, safety, context, "policy deny blocks physical action");
302 bail!("policy deny blocks physical action");
303 }
304 }
305 }
306
307 fn log_policy_approved(
308 &self,
309 name: &str,
310 safety: SafetyClass,
311 context: InvocationContext,
312 dry_run: bool,
313 ) {
314 if !safety.is_physical_action() {
315 return;
316 }
317 tracing::info!(
318 capability = name,
319 safety = safety.as_str(),
320 origin = context.origin.as_str(),
321 policy_mode = self.harness.config().policy_mode.as_str(),
322 dry_run,
323 "capability policy approved"
324 );
325 }
326
327 fn log_policy_denied(
328 &self,
329 name: &str,
330 safety: SafetyClass,
331 context: InvocationContext,
332 reason: &str,
333 ) {
334 if !safety.is_physical_action() {
335 return;
336 }
337 tracing::warn!(
338 capability = name,
339 safety = safety.as_str(),
340 origin = context.origin.as_str(),
341 policy_mode = self.harness.config().policy_mode.as_str(),
342 reason,
343 "capability policy denied"
344 );
345 }
346}
347
348pub fn default_capability_descriptors() -> Vec<CapabilityDescriptor> {
349 vec![
350 descriptor(
351 "health",
352 "Read harness health and safety state",
353 SafetyClass::ObserveOnly,
354 object_schema(&[]),
355 "Health",
356 ),
357 descriptor(
358 "capabilities",
359 "List endpoints, modules, tools, and capability metadata",
360 SafetyClass::ObserveOnly,
361 object_schema(&[]),
362 "Capabilities",
363 ),
364 descriptor(
365 "observe",
366 "Read the latest telemetry and sensor state",
367 SafetyClass::ObserveOnly,
368 object_schema(&[]),
369 "TelemetryFrame",
370 ),
371 descriptor(
372 "capture",
373 "Capture deterministic frame metadata",
374 SafetyClass::ObserveOnly,
375 object_schema(&[]),
376 "CaptureResult",
377 ),
378 descriptor(
379 "authorize",
380 "Create or refresh a pilot session token",
381 SafetyClass::SimControl,
382 object_schema(&[
383 ("token", "string", true),
384 ("ttl_secs", "integer", false),
385 ("speed_mode", "SpeedMode", false),
386 ]),
387 "PilotTokenResp",
388 ),
389 descriptor(
390 "drive",
391 "Apply left and right drive commands through safety gates",
392 SafetyClass::PhysicalMotion,
393 object_schema(&[
394 ("token", "string", false),
395 ("left", "number", true),
396 ("right", "number", true),
397 ("speed_mode", "SpeedMode", false),
398 ("approval", "boolean", false),
399 ]),
400 "DriveOutcome",
401 ),
402 descriptor(
403 "speed_mode",
404 "Change the active speed cap",
405 SafetyClass::SimControl,
406 object_schema(&[
407 ("token", "string", false),
408 ("speed_mode", "SpeedMode", true),
409 ]),
410 "SpeedModeResp",
411 ),
412 descriptor(
413 "stop",
414 "Send a non-latching zero-speed motor stop",
415 SafetyClass::PhysicalStop,
416 object_schema(&[]),
417 "DriveOutcome",
418 ),
419 descriptor(
420 "estop",
421 "Latch emergency stop until reset",
422 SafetyClass::PhysicalStop,
423 object_schema(&[]),
424 "EstopResp",
425 ),
426 descriptor(
427 "estop_reset",
428 "Reset a latched emergency stop",
429 SafetyClass::PhysicalHighRisk,
430 object_schema(&[("token", "string", false), ("approval", "boolean", false)]),
431 "EstopResp",
432 ),
433 ]
434}
435
436fn descriptor(
437 name: &str,
438 description: &str,
439 safety: SafetyClass,
440 input_schema: Value,
441 output_type: &str,
442) -> CapabilityDescriptor {
443 CapabilityDescriptor {
444 name: name.to_string(),
445 description: description.to_string(),
446 module: "harness-runtime".to_string(),
447 safety,
448 input_schema,
449 output_schema: json!({ "type": output_type }),
450 }
451}
452
453fn object_schema(fields: &[(&str, &str, bool)]) -> Value {
454 let mut properties = Map::new();
455 let mut required = Vec::new();
456 for (name, field_type, is_required) in fields {
457 properties.insert((*name).to_string(), json!({ "type": field_type }));
458 if *is_required {
459 required.push((*name).to_string());
460 }
461 }
462 json!({
463 "type": "object",
464 "additionalProperties": false,
465 "properties": properties,
466 "required": required,
467 })
468}
469
470fn canonical_name(name: &str) -> &str {
471 match name {
472 "motors.stop" | "motors/stop" => "stop",
473 "estop/reset" | "estop.reset" => "estop_reset",
474 other => other,
475 }
476}
477
478fn args_object(args: Value) -> Result<Map<String, Value>> {
479 match args {
480 Value::Null => Ok(Map::new()),
481 Value::Object(map) => Ok(map),
482 _ => bail!("capability args must be a JSON object"),
483 }
484}
485
486fn ensure_fields(args: &Map<String, Value>, allowed: &[&str]) -> Result<()> {
487 for key in args.keys() {
488 if !allowed.contains(&key.as_str()) {
489 bail!("unexpected argument '{key}'");
490 }
491 }
492 Ok(())
493}
494
495fn required_string(args: &Map<String, Value>, key: &str) -> Result<String> {
496 optional_string(args, key)?.ok_or_else(|| anyhow!("{key} is required"))
497}
498
499fn optional_string(args: &Map<String, Value>, key: &str) -> Result<Option<String>> {
500 match args.get(key) {
501 None | Some(Value::Null) => Ok(None),
502 Some(Value::String(value)) => Ok(Some(value.clone())),
503 Some(_) => bail!("{key} must be a string"),
504 }
505}
506
507fn optional_bool_removed(args: &mut Map<String, Value>, key: &str) -> Result<Option<bool>> {
508 match args.remove(key) {
509 None | Some(Value::Null) => Ok(None),
510 Some(Value::Bool(value)) => Ok(Some(value)),
511 Some(_) => bail!("{key} must be a boolean"),
512 }
513}
514
515fn token_satisfied(args: &Map<String, Value>) -> bool {
516 args.get("token")
517 .and_then(Value::as_str)
518 .is_some_and(|token| !token.trim().is_empty())
519}
520
521fn optional_u64(args: &Map<String, Value>, key: &str) -> Result<Option<u64>> {
522 match args.get(key) {
523 None | Some(Value::Null) => Ok(None),
524 Some(Value::Number(value)) => value
525 .as_u64()
526 .map(Some)
527 .ok_or_else(|| anyhow!("{key} must be an unsigned integer")),
528 Some(_) => bail!("{key} must be an unsigned integer"),
529 }
530}
531
532fn required_f64(args: &Map<String, Value>, key: &str) -> Result<f64> {
533 match args.get(key) {
534 Some(Value::Number(value)) => value
535 .as_f64()
536 .ok_or_else(|| anyhow!("{key} must be a finite number")),
537 Some(_) => bail!("{key} must be a number"),
538 None => bail!("{key} is required"),
539 }
540}
541
542fn required_speed_mode(args: &Map<String, Value>, key: &str) -> Result<SpeedMode> {
543 optional_speed_mode(args, key)?.ok_or_else(|| anyhow!("{key} is required"))
544}
545
546fn optional_speed_mode(args: &Map<String, Value>, key: &str) -> Result<Option<SpeedMode>> {
547 match args.get(key) {
548 None | Some(Value::Null) => Ok(None),
549 Some(value) => serde_json::from_value(value.clone())
550 .map(Some)
551 .map_err(|err| anyhow!("{key} must be a valid speed mode: {err}")),
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use crate::{config::PolicyMode, types::TelemetryFrame, HarnessConfig};
559
560 #[tokio::test]
561 async fn descriptors_are_unique() {
562 let registry = CapabilityRegistry::new(Harness::new(HarnessConfig::default()).unwrap());
563 let names = registry.names();
564 let unique: std::collections::HashSet<_> = names.iter().collect();
565 assert_eq!(names.len(), unique.len());
566 }
567
568 #[tokio::test]
569 async fn descriptors_cover_all_safety_classes() {
570 let registry = CapabilityRegistry::new(Harness::new(HarnessConfig::default()).unwrap());
571 let classes = registry
572 .descriptors()
573 .iter()
574 .map(|descriptor| descriptor.safety)
575 .collect::<std::collections::HashSet<_>>();
576
577 for safety in [
578 SafetyClass::ObserveOnly,
579 SafetyClass::SimControl,
580 SafetyClass::PhysicalStop,
581 SafetyClass::PhysicalMotion,
582 SafetyClass::PhysicalHighRisk,
583 ] {
584 assert!(classes.contains(&safety), "missing {safety:?}");
585 }
586 }
587
588 #[tokio::test]
589 async fn rejects_invalid_drive_args_before_command_changes() {
590 let harness = Harness::new(HarnessConfig {
591 policy_mode: PolicyMode::RequireApproval,
592 ..HarnessConfig::default()
593 })
594 .unwrap();
595 let registry = CapabilityRegistry::new(harness.clone());
596 let err = registry
597 .invoke_value("drive", json!({ "left": 0.2, "approval": true }))
598 .unwrap_err()
599 .to_string();
600 assert!(err.contains("right is required"));
601
602 let telemetry = harness.telemetry();
603 assert_eq!(telemetry.left_cmd, 0.0);
604 assert_eq!(telemetry.right_cmd, 0.0);
605 }
606
607 #[tokio::test]
608 async fn observe_returns_telemetry_value() {
609 let registry = CapabilityRegistry::new(Harness::new(HarnessConfig::default()).unwrap());
610 let value = registry.invoke_value("observe", json!({})).unwrap();
611 let telemetry: TelemetryFrame = serde_json::from_value(value).unwrap();
612 assert_eq!(telemetry.robot, "robot");
613 }
614
615 #[tokio::test]
616 async fn require_token_policy_blocks_physical_motion_without_token() {
617 let harness = Harness::new(HarnessConfig {
618 allow_untokened_drive: false,
619 ..HarnessConfig::default()
620 })
621 .unwrap();
622 let registry = CapabilityRegistry::new(harness.clone());
623
624 let err = registry
625 .invoke_value_with_origin(
626 "drive",
627 json!({ "left": 0.2, "right": 0.2 }),
628 InvocationOrigin::Http,
629 )
630 .unwrap_err()
631 .to_string();
632
633 assert!(err.contains("require-token"));
634 assert_eq!(harness.telemetry().left_cmd, 0.0);
635 }
636
637 #[tokio::test]
638 async fn require_approval_policy_blocks_physical_motion_without_approval() {
639 let harness = Harness::new(HarnessConfig {
640 policy_mode: PolicyMode::RequireApproval,
641 ..HarnessConfig::default()
642 })
643 .unwrap();
644 let registry = CapabilityRegistry::new(harness.clone());
645
646 let err = registry
647 .invoke_value_with_origin(
648 "drive",
649 json!({ "left": 0.2, "right": 0.2 }),
650 InvocationOrigin::Cli,
651 )
652 .unwrap_err()
653 .to_string();
654
655 assert!(err.contains("approval=true"));
656 assert_eq!(harness.telemetry().left_cmd, 0.0);
657 }
658
659 #[tokio::test]
660 async fn dry_run_policy_approves_without_command_change() {
661 let harness = Harness::new(HarnessConfig {
662 policy_mode: PolicyMode::DryRun,
663 ..HarnessConfig::default()
664 })
665 .unwrap();
666 let registry = CapabilityRegistry::new(harness.clone());
667
668 let value = registry
669 .invoke_value_with_origin(
670 "drive",
671 json!({ "left": 0.2, "right": 0.2 }),
672 InvocationOrigin::Cli,
673 )
674 .unwrap();
675
676 assert_eq!(value["dry_run"], true);
677 assert_eq!(value["safety"], "physical-motion");
678 assert_eq!(harness.telemetry().left_cmd, 0.0);
679 }
680
681 #[tokio::test]
682 async fn deny_policy_still_allows_stop_and_estop() {
683 let harness = Harness::new(HarnessConfig {
684 policy_mode: PolicyMode::Deny,
685 ..HarnessConfig::default()
686 })
687 .unwrap();
688 let registry = CapabilityRegistry::new(harness);
689
690 let drive_err = registry
691 .invoke_value_with_origin(
692 "drive",
693 json!({ "left": 0.2, "right": 0.2 }),
694 InvocationOrigin::Mcp,
695 )
696 .unwrap_err()
697 .to_string();
698 assert!(drive_err.contains("policy deny"));
699
700 let stop = registry
701 .invoke_value_with_origin("stop", json!({}), InvocationOrigin::Mcp)
702 .unwrap();
703 assert_eq!(stop["ok"], true);
704
705 let estop = registry
706 .invoke_value_with_origin("estop", json!({}), InvocationOrigin::Mcp)
707 .unwrap();
708 assert_eq!(estop["estop"], true);
709 }
710
711 #[tokio::test]
712 async fn agent_physical_motion_requires_approval() {
713 let harness = Harness::new(HarnessConfig {
714 policy_mode: PolicyMode::RequireApproval,
715 ..HarnessConfig::default()
716 })
717 .unwrap();
718 let registry = CapabilityRegistry::new(harness.clone());
719
720 let err = registry
721 .invoke_value_with_origin(
722 "drive",
723 json!({ "left": 0.2, "right": 0.2 }),
724 InvocationOrigin::Agent,
725 )
726 .unwrap_err()
727 .to_string();
728 assert!(err.contains("agent physical action"));
729
730 registry
731 .invoke_value_with_origin(
732 "drive",
733 json!({ "left": 0.2, "right": 0.2, "approval": true }),
734 InvocationOrigin::Agent,
735 )
736 .unwrap();
737 assert!(harness.telemetry().left_cmd > 0.0);
738 }
739}