Skip to main content

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