Skip to main content

mur_common/
hitl.rs

1//! Risk-tiered HITL vocabulary shared across the executor, runtime, and surfaces.
2
3use serde::{Deserialize, Serialize};
4
5/// How risky an action is. `Ord` is severity order: `Read` < … < `Privileged`.
6/// Tier is resolved most-restrictive-wins and is NEVER LLM-asserted.
7#[derive(
8    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
9)]
10#[serde(rename_all = "kebab-case")]
11pub enum RiskTier {
12    Read,
13    Write,
14    NetworkEgress,
15    Spend,
16    Destructive,
17    Privileged,
18}
19
20/// What the gate does for a tier.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum HitlMode {
24    /// Run unattended (read tier): a post-hoc audit event is fine.
25    Auto,
26    /// Pre-execution human approval required.
27    Ask,
28    /// Refuse pre-emptively.
29    Deny,
30}
31
32/// Default gate mode for a tier. Read runs unattended; everything mutating asks.
33/// A channel policy floor (future) may tighten Ask→Deny but never loosen.
34pub fn default_mode(tier: RiskTier) -> HitlMode {
35    match tier {
36        RiskTier::Read => HitlMode::Auto,
37        _ => HitlMode::Ask,
38    }
39}
40
41/// `EventKind::HitlRequest` payload: the durable, pinned approval request.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct HitlRequest {
44    pub hitl_id: String,
45    /// SHA-256 of the canonical action (see `mur-core` `hitl::pin`).
46    pub action_hash: String,
47    pub tier: RiskTier,
48    pub tool_name: String,
49    pub tool_input: serde_json::Value,
50    pub step_or_call_id: String,
51    pub agent_id: String,
52    pub timeout_ms: u64,
53    pub summary: String,
54}
55
56/// `EventKind::HitlResponse` payload: the human's decision, echoing the pin.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct HitlResponse {
59    pub hitl_id: String,
60    pub action_hash: String,
61    pub allow: bool,
62    #[serde(default)]
63    pub reason: String,
64    /// "cli" | "hub" | "ios" | "auto".
65    pub surface: String,
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn tier_orders_by_severity_and_maps_mode() {
74        assert!(RiskTier::Read < RiskTier::Destructive);
75        assert!(RiskTier::Write < RiskTier::Privileged);
76        assert_eq!(default_mode(RiskTier::Read), HitlMode::Auto);
77        assert_eq!(default_mode(RiskTier::Destructive), HitlMode::Ask);
78    }
79
80    #[test]
81    fn hitl_payloads_round_trip() {
82        let req = HitlRequest {
83            hitl_id: "h1".into(),
84            action_hash: "abc".into(),
85            tier: RiskTier::Destructive,
86            tool_name: "bash".into(),
87            tool_input: serde_json::json!({ "cmd": "rm -rf x" }),
88            step_or_call_id: "s0".into(),
89            agent_id: "mur".into(),
90            timeout_ms: 300_000,
91            summary: "delete x".into(),
92        };
93        let s = serde_json::to_string(&req).unwrap();
94        let back: HitlRequest = serde_json::from_str(&s).unwrap();
95        assert_eq!(back.tier, RiskTier::Destructive);
96        assert_eq!(back.action_hash, "abc");
97    }
98}