Skip to main content

leash_harness/
capability.rs

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}