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}