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/// What an Ask-tier gate does when nobody has answered yet.
42///
43/// This is a policy floor, chosen by the run's owner — it may only tighten the
44/// outcome, never approve anything. `Deny` short-circuits before any lookup so
45/// a fleet declared free of risk-tiered work stays that way even if some older
46/// approval for the same action is still on the channel.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum Unanswered {
50 /// Park the request durably and report the step blocked. Nobody waits; an
51 /// approval arriving later releases the gate on a subsequent run. The
52 /// default when no human is watching.
53 Defer,
54 /// Block the caller, polling until the gate timeout. The default when a
55 /// terminal is attached, and the right choice for an unattended run that
56 /// somebody IS watching on another surface.
57 Wait,
58 /// Refuse every Ask-tier action outright, without writing a request. For a
59 /// run that must never reach for a human — the failure is immediate and
60 /// legible instead of a request nobody will answer.
61 Deny,
62}
63
64impl Default for Unanswered {
65 /// The strict end of the three: a policy built without stating a mode must
66 /// never be the one that waits or lets something through.
67 fn default() -> Self {
68 Unanswered::Defer
69 }
70}
71
72/// May a run's owner take standing responsibility for this tier in config —
73/// i.e. pre-approve it once instead of being asked every time?
74///
75/// Capped at `Write` deliberately. A standing grant is real authority handed
76/// to an unattended process, so widening it is a decision to make in code with
77/// its reasoning written down, never something a user acquires by typing one
78/// more word into a YAML file. `Spend`, `Destructive` and `Privileged` are
79/// exactly the actions whose cost a human cannot undo by noticing later, and
80/// `NetworkEgress` is how data leaves — none of them belongs behind a config
81/// line today.
82pub fn tier_may_be_granted(tier: RiskTier) -> bool {
83 matches!(tier, RiskTier::Read | RiskTier::Write)
84}
85
86/// `EventKind::HitlRequest` payload: the durable, pinned approval request.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct HitlRequest {
89 pub hitl_id: String,
90 /// SHA-256 of the canonical action (see `mur-core` `hitl::pin`).
91 pub action_hash: String,
92 pub tier: RiskTier,
93 pub tool_name: String,
94 pub tool_input: serde_json::Value,
95 pub step_or_call_id: String,
96 pub agent_id: String,
97 pub timeout_ms: u64,
98 pub summary: String,
99}
100
101/// `EventKind::HitlResponse` payload: the human's decision, echoing the pin.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct HitlResponse {
104 pub hitl_id: String,
105 pub action_hash: String,
106 pub allow: bool,
107 #[serde(default)]
108 pub reason: String,
109 /// "cli" | "hub" | "ios" | "auto".
110 pub surface: String,
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn tier_orders_by_severity_and_maps_mode() {
119 assert!(RiskTier::Read < RiskTier::Destructive);
120 assert!(RiskTier::Write < RiskTier::Privileged);
121 assert_eq!(default_mode(RiskTier::Read), HitlMode::Auto);
122 assert_eq!(default_mode(RiskTier::Destructive), HitlMode::Ask);
123 }
124
125 #[test]
126 fn hitl_payloads_round_trip() {
127 let req = HitlRequest {
128 hitl_id: "h1".into(),
129 action_hash: "abc".into(),
130 tier: RiskTier::Destructive,
131 tool_name: "bash".into(),
132 tool_input: serde_json::json!({ "cmd": "rm -rf x" }),
133 step_or_call_id: "s0".into(),
134 agent_id: "mur".into(),
135 timeout_ms: 300_000,
136 summary: "delete x".into(),
137 };
138 let s = serde_json::to_string(&req).unwrap();
139 let back: HitlRequest = serde_json::from_str(&s).unwrap();
140 assert_eq!(back.tier, RiskTier::Destructive);
141 assert_eq!(back.action_hash, "abc");
142 }
143
144 /// The grantable ceiling. Widening this list is a security decision that
145 /// belongs in a commit message, not a YAML typo — the test exists so the
146 /// reviewer has to read the reasoning right here.
147 #[test]
148 fn tier_grant_ceiling_is_write() {
149 assert!(tier_may_be_granted(RiskTier::Read));
150 assert!(tier_may_be_granted(RiskTier::Write));
151 assert!(!tier_may_be_granted(RiskTier::NetworkEgress));
152 assert!(!tier_may_be_granted(RiskTier::Spend));
153 assert!(!tier_may_be_granted(RiskTier::Destructive));
154 assert!(!tier_may_be_granted(RiskTier::Privileged));
155 }
156}