mur_common/hitl/mod.rs
1//! Risk-tiered HITL vocabulary shared across the executor, runtime, and surfaces.
2
3use serde::{Deserialize, Serialize};
4
5pub mod pin;
6
7/// Approvals and denials settle a gate for this long. Content staleness is
8/// already handled by the hash pin (any input change = a different hash); the
9/// TTL bounds TIME staleness, so a weeks-old approval cannot release a gate
10/// nobody remembers granting. Shared by gate A (`mur-core::hitl::gate`) and
11/// gate B (`mur-agent-runtime::hitl::store`) — one number, or the two gates
12/// remember for different lengths and the Hub cannot explain why.
13pub const APPROVAL_TTL_SECS: i64 = 7 * 24 * 60 * 60;
14
15/// Pure TTL predicate — split out so the boundary is testable without
16/// backdating channel events.
17pub fn within_approval_ttl(
18 event_ts: chrono::DateTime<chrono::Utc>,
19 now: chrono::DateTime<chrono::Utc>,
20) -> bool {
21 (now - event_ts).num_seconds() <= APPROVAL_TTL_SECS
22}
23
24/// How risky an action is. `Ord` is severity order: `Read` < … < `Privileged`.
25/// Tier is resolved most-restrictive-wins and is NEVER LLM-asserted.
26#[derive(
27 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
28)]
29#[serde(rename_all = "kebab-case")]
30pub enum RiskTier {
31 Read,
32 Write,
33 NetworkEgress,
34 Spend,
35 Destructive,
36 Privileged,
37}
38
39/// What the gate does for a tier.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum HitlMode {
43 /// Run unattended (read tier): a post-hoc audit event is fine.
44 Auto,
45 /// Pre-execution human approval required.
46 Ask,
47 /// Refuse pre-emptively.
48 Deny,
49}
50
51/// Default gate mode for a tier. Read runs unattended; everything mutating asks.
52/// A channel policy floor (future) may tighten Ask→Deny but never loosen.
53pub fn default_mode(tier: RiskTier) -> HitlMode {
54 match tier {
55 RiskTier::Read => HitlMode::Auto,
56 _ => HitlMode::Ask,
57 }
58}
59
60/// What an Ask-tier gate does when nobody has answered yet.
61///
62/// This is a policy floor, chosen by the run's owner — it may only tighten the
63/// outcome, never approve anything. `Deny` short-circuits before any lookup so
64/// a fleet declared free of risk-tiered work stays that way even if some older
65/// approval for the same action is still on the channel.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum Unanswered {
69 /// Park the request durably and report the step blocked. Nobody waits; an
70 /// approval arriving later releases the gate on a subsequent run. The
71 /// default when no human is watching.
72 Defer,
73 /// Block the caller, polling until the gate timeout. The default when a
74 /// terminal is attached, and the right choice for an unattended run that
75 /// somebody IS watching on another surface.
76 Wait,
77 /// Refuse every Ask-tier action outright, without writing a request. For a
78 /// run that must never reach for a human — the failure is immediate and
79 /// legible instead of a request nobody will answer.
80 Deny,
81}
82
83impl Default for Unanswered {
84 /// The strict end of the three: a policy built without stating a mode must
85 /// never be the one that waits or lets something through.
86 fn default() -> Self {
87 Unanswered::Defer
88 }
89}
90
91/// May a run's owner take standing responsibility for this tier in config —
92/// i.e. pre-approve it once instead of being asked every time?
93///
94/// Capped at `Write` deliberately. A standing grant is real authority handed
95/// to an unattended process, so widening it is a decision to make in code with
96/// its reasoning written down, never something a user acquires by typing one
97/// more word into a YAML file. `Spend`, `Destructive` and `Privileged` are
98/// exactly the actions whose cost a human cannot undo by noticing later, and
99/// `NetworkEgress` is how data leaves — none of them belongs behind a config
100/// line today.
101pub fn tier_may_be_granted(tier: RiskTier) -> bool {
102 matches!(tier, RiskTier::Read | RiskTier::Write)
103}
104
105/// How far the agent carries a turn on its own before handing back.
106///
107/// ORTHOGONAL to `HitlMode`/`RiskTier`. Those answer "may this ACTION run?"
108/// and are enforced per tool call; this answers "is the TURN over?" and is
109/// enforced once, at the loop's termination branch. Neither may overrule the
110/// other: `Continue` never releases a risk gate, and an approved gate never
111/// extends a turn. Issue #001 is what happens when only the prompt layer
112/// carries this — the model reads "已授權工作持續推進" as a suggestion because
113/// nothing in the runtime ever re-entered the loop.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
115#[serde(rename_all = "kebab-case")]
116pub enum Autonomy {
117 /// Mode 1, 持續推進 — a turn that ends with work still open is nudged back
118 /// into the loop instead of returning. Never implicit, not even for
119 /// unattended runs: handing an agent the right to keep going is written
120 /// down in a profile, because nobody is in the room to take it back.
121 Continue,
122 /// Mode 2, 需要再審核 — the agent finishes its own work but must present it
123 /// for review before anything further; the turn ends where it would anyway.
124 Review,
125 /// Mode 3, 用戶審核 — hand back at every natural stop. The strictest of the
126 /// three and the default when nothing is stated.
127 Ask,
128}
129
130impl Default for Autonomy {
131 /// The strict end, matching `Unanswered::default()`: a policy assembled
132 /// without stating a mode must never be the one that keeps going by
133 /// itself.
134 fn default() -> Self {
135 Autonomy::Ask
136 }
137}
138
139/// How many times one turn may be nudged onward. Bounded, and small: the
140/// iteration ceiling and the stuck clock are the real budgets, and a
141/// continuation that could fire endlessly would quietly convert both into a
142/// suggestion. One nudge is enough to fix #001 (the model stopped once, mid
143/// task) without inventing a second, parallel loop.
144pub const MAX_CONTINUATIONS: u32 = 1;
145
146/// Why a turn was NOT continued. Every variant is a thing the settlement card
147/// can print, because "it just stopped" is the bug being fixed.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum ContinueVeto {
150 /// Policy says hand back — `Review` or `Ask`.
151 Policy,
152 /// A1: the turn did not end cleanly (ceiling, loop, deadline, stuck,
153 /// truncation). Those stops already have their own graceful exit and a
154 /// nudge would fight it.
155 UnCleanStop,
156 /// A2: a risk gate blocked, denied or deferred something this turn. The
157 /// human IS the next step; nudging would spin against a closed gate.
158 GateBlocked,
159 /// The nudge budget for this turn is spent.
160 BudgetSpent,
161}
162
163/// The whole continuation decision, as one pure function so the policy is
164/// testable without a model, a gate, or a clock.
165///
166/// `clean_stop` is "the model ended the turn of its own accord". `gate_blocked`
167/// is "at least one action this turn was refused, denied or parked". Both are
168/// facts the loop already holds at the termination branch.
169pub fn should_continue(
170 autonomy: Autonomy,
171 clean_stop: bool,
172 gate_blocked: bool,
173 continuations_used: u32,
174) -> Result<(), ContinueVeto> {
175 if autonomy != Autonomy::Continue {
176 return Err(ContinueVeto::Policy);
177 }
178 if !clean_stop {
179 return Err(ContinueVeto::UnCleanStop);
180 }
181 if gate_blocked {
182 return Err(ContinueVeto::GateBlocked);
183 }
184 if continuations_used >= MAX_CONTINUATIONS {
185 return Err(ContinueVeto::BudgetSpent);
186 }
187 Ok(())
188}
189
190/// `EventKind::HitlRequest` payload: the durable, pinned approval request.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct HitlRequest {
193 pub hitl_id: String,
194 /// SHA-256 of the canonical action (see `mur-core` `hitl::pin`).
195 pub action_hash: String,
196 pub tier: RiskTier,
197 pub tool_name: String,
198 pub tool_input: serde_json::Value,
199 pub step_or_call_id: String,
200 pub agent_id: String,
201 pub timeout_ms: u64,
202 pub summary: String,
203}
204
205/// `EventKind::HitlResponse` payload: the human's decision, echoing the pin.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct HitlResponse {
208 pub hitl_id: String,
209 pub action_hash: String,
210 pub allow: bool,
211 #[serde(default)]
212 pub reason: String,
213 /// "cli" | "hub" | "ios" | "auto".
214 pub surface: String,
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn tier_orders_by_severity_and_maps_mode() {
223 assert!(RiskTier::Read < RiskTier::Destructive);
224 assert!(RiskTier::Write < RiskTier::Privileged);
225 assert_eq!(default_mode(RiskTier::Read), HitlMode::Auto);
226 assert_eq!(default_mode(RiskTier::Destructive), HitlMode::Ask);
227 }
228
229 #[test]
230 fn hitl_payloads_round_trip() {
231 let req = HitlRequest {
232 hitl_id: "h1".into(),
233 action_hash: "abc".into(),
234 tier: RiskTier::Destructive,
235 tool_name: "bash".into(),
236 tool_input: serde_json::json!({ "cmd": "rm -rf x" }),
237 step_or_call_id: "s0".into(),
238 agent_id: "mur".into(),
239 timeout_ms: 300_000,
240 summary: "delete x".into(),
241 };
242 let s = serde_json::to_string(&req).unwrap();
243 let back: HitlRequest = serde_json::from_str(&s).unwrap();
244 assert_eq!(back.tier, RiskTier::Destructive);
245 assert_eq!(back.action_hash, "abc");
246 }
247
248 /// The grantable ceiling. Widening this list is a security decision that
249 /// belongs in a commit message, not a YAML typo — the test exists so the
250 /// reviewer has to read the reasoning right here.
251 #[test]
252 fn tier_grant_ceiling_is_write() {
253 assert!(tier_may_be_granted(RiskTier::Read));
254 assert!(tier_may_be_granted(RiskTier::Write));
255 assert!(!tier_may_be_granted(RiskTier::NetworkEgress));
256 assert!(!tier_may_be_granted(RiskTier::Spend));
257 assert!(!tier_may_be_granted(RiskTier::Destructive));
258 assert!(!tier_may_be_granted(RiskTier::Privileged));
259 }
260
261 /// #001 §6 A0: the safe default. An `Autonomy` nobody stated must be the
262 /// one that hands back, never the one that drives itself.
263 #[test]
264 fn autonomy_defaults_to_the_strictest_mode() {
265 assert_eq!(Autonomy::default(), Autonomy::Ask);
266 }
267
268 /// The happy path this whole feature exists for: unattended work, a clean
269 /// stop, no blocked gate, budget unspent → carry on.
270 #[test]
271 fn continue_mode_resumes_a_clean_unblocked_turn() {
272 assert_eq!(should_continue(Autonomy::Continue, true, false, 0), Ok(()));
273 }
274
275 /// The other two modes are handbacks by construction. This is the test
276 /// that keeps "持續推進" from silently becoming the behaviour of all three.
277 #[test]
278 fn review_and_ask_never_continue() {
279 for mode in [Autonomy::Review, Autonomy::Ask] {
280 assert_eq!(
281 should_continue(mode, true, false, 0),
282 Err(ContinueVeto::Policy),
283 "{mode:?} must hand back"
284 );
285 }
286 }
287
288 /// #001 §6 A1: a turn stopped by a budget (ceiling / loop / deadline /
289 /// stuck) already has a graceful exit. Nudging it would fight that exit.
290 #[test]
291 fn an_unclean_stop_is_never_continued() {
292 assert_eq!(
293 should_continue(Autonomy::Continue, false, false, 0),
294 Err(ContinueVeto::UnCleanStop)
295 );
296 }
297
298 /// #001 §6 A2 — THE SAFETY BOUNDARY. Continuation and the risk gate are
299 /// orthogonal: when a gate blocked, denied or deferred something, the
300 /// human is the next step and no autonomy setting may route around them.
301 /// If this test ever goes green with `Ok(())`, `Autonomy::Continue` has
302 /// become a privilege escalation.
303 #[test]
304 fn continuation_never_routes_around_a_blocked_gate() {
305 assert_eq!(
306 should_continue(Autonomy::Continue, true, true, 0),
307 Err(ContinueVeto::GateBlocked)
308 );
309 }
310
311 /// Bounded, and the bound is enforced here rather than by hoping the loop
312 /// converges.
313 #[test]
314 fn continuation_budget_is_spent_after_max() {
315 assert_eq!(
316 should_continue(Autonomy::Continue, true, false, MAX_CONTINUATIONS),
317 Err(ContinueVeto::BudgetSpent)
318 );
319 assert_eq!(
320 should_continue(Autonomy::Continue, true, false, MAX_CONTINUATIONS + 9),
321 Err(ContinueVeto::BudgetSpent)
322 );
323 }
324
325 /// Policy is checked before anything else, so a `Ask` run reports "policy"
326 /// rather than leaking why it would ALSO have been stopped.
327 #[test]
328 fn policy_veto_precedes_every_other_veto() {
329 assert_eq!(
330 should_continue(Autonomy::Ask, false, true, 99),
331 Err(ContinueVeto::Policy)
332 );
333 }
334
335 #[test]
336 fn autonomy_round_trips_as_kebab_case() {
337 let y = serde_yaml::to_string(&Autonomy::Continue).unwrap();
338 assert!(y.contains("continue"), "got {y}");
339 let back: Autonomy = serde_yaml::from_str("review").unwrap();
340 assert_eq!(back, Autonomy::Review);
341 }
342
343 #[test]
344 fn ttl_boundary_is_inclusive_at_seven_days() {
345 let now = chrono::Utc::now();
346 let exactly = now - chrono::Duration::seconds(APPROVAL_TTL_SECS);
347 let over = now - chrono::Duration::seconds(APPROVAL_TTL_SECS + 1);
348 assert!(within_approval_ttl(exactly, now));
349 assert!(!within_approval_ttl(over, now));
350 }
351}