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