use serde::{Deserialize, Serialize};
pub mod approval_token;
pub mod bash_class;
pub mod builtin_tier;
pub mod dest;
pub mod floor_waiver;
pub mod label;
mod opaque_shell;
pub mod pin;
pub mod signed_time;
pub mod tool_tier;
pub use signed_time::{HITL_CLOCK_SKEW_SECS, TimeFault, check_answer_time, check_fresh};
pub const APPROVAL_TTL_SECS: i64 = 7 * 24 * 60 * 60;
pub const REMEMBER_DECISIONS_PARAM: &str = "remember_decisions";
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum RiskTier {
Read,
Write,
NetworkEgress,
Spend,
Destructive,
Privileged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HitlMode {
Auto,
Ask,
Deny,
}
pub fn default_mode(tier: RiskTier) -> HitlMode {
match tier {
RiskTier::Read => HitlMode::Auto,
_ => HitlMode::Ask,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Unanswered {
Defer,
Wait,
Deny,
}
impl Default for Unanswered {
fn default() -> Self {
Unanswered::Defer
}
}
pub fn tier_may_be_granted(tier: RiskTier) -> bool {
matches!(tier, RiskTier::Read | RiskTier::Write)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Autonomy {
Continue,
Review,
Ask,
}
impl Default for Autonomy {
fn default() -> Self {
Autonomy::Ask
}
}
pub const MAX_CONTINUATIONS: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContinueVeto {
Policy,
UnCleanStop,
GateBlocked,
BudgetSpent,
}
pub fn should_continue(
autonomy: Autonomy,
clean_stop: bool,
gate_blocked: bool,
continuations_used: u32,
) -> Result<(), ContinueVeto> {
if autonomy != Autonomy::Continue {
return Err(ContinueVeto::Policy);
}
if !clean_stop {
return Err(ContinueVeto::UnCleanStop);
}
if gate_blocked {
return Err(ContinueVeto::GateBlocked);
}
if continuations_used >= MAX_CONTINUATIONS {
return Err(ContinueVeto::BudgetSpent);
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HitlRequest {
pub hitl_id: String,
pub action_hash: String,
pub tier: RiskTier,
pub tool_name: String,
pub tool_input: serde_json::Value,
pub step_or_call_id: String,
pub agent_id: String,
pub timeout_ms: u64,
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issued_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HitlResponse {
pub hitl_id: String,
pub action_hash: String,
pub allow: bool,
#[serde(default)]
pub reason: String,
pub surface: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issued_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
pub const HITL_LIST_METHOD: &str = "hitl/list";
pub const HITL_FORGET_METHOD: &str = "hitl/forget";
pub const REVOCATION_PAYLOAD_KEY: &str = "hitl_revocation";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "scope", content = "action_hash")]
pub enum RevocationScope {
One(String),
All,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HitlRevocation {
#[serde(flatten)]
pub scope: RevocationScope,
pub issued_at: chrono::DateTime<chrono::Utc>,
}
impl HitlRevocation {
pub fn to_payload(&self) -> serde_json::Value {
serde_json::json!({ REVOCATION_PAYLOAD_KEY: self })
}
pub fn from_payload(payload: &serde_json::Value) -> Option<Self> {
serde_json::from_value(payload.get(REVOCATION_PAYLOAD_KEY)?.clone()).ok()
}
pub fn covers(&self, action_hash: &str) -> bool {
match &self.scope {
RevocationScope::All => true,
RevocationScope::One(h) => h == action_hash,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tier_orders_by_severity_and_maps_mode() {
assert!(RiskTier::Read < RiskTier::Destructive);
assert!(RiskTier::Write < RiskTier::Privileged);
assert_eq!(default_mode(RiskTier::Read), HitlMode::Auto);
assert_eq!(default_mode(RiskTier::Destructive), HitlMode::Ask);
}
#[test]
fn hitl_payloads_round_trip() {
let req = HitlRequest {
hitl_id: "h1".into(),
action_hash: "abc".into(),
tier: RiskTier::Destructive,
tool_name: "bash".into(),
tool_input: serde_json::json!({ "cmd": "rm -rf x" }),
step_or_call_id: "s0".into(),
agent_id: "mur".into(),
timeout_ms: 300_000,
summary: "delete x".into(),
issued_at: Some(chrono::Utc::now()),
};
let s = serde_json::to_string(&req).unwrap();
let back: HitlRequest = serde_json::from_str(&s).unwrap();
assert_eq!(back.tier, RiskTier::Destructive);
assert_eq!(back.action_hash, "abc");
}
#[test]
fn tier_grant_ceiling_is_write() {
assert!(tier_may_be_granted(RiskTier::Read));
assert!(tier_may_be_granted(RiskTier::Write));
assert!(!tier_may_be_granted(RiskTier::NetworkEgress));
assert!(!tier_may_be_granted(RiskTier::Spend));
assert!(!tier_may_be_granted(RiskTier::Destructive));
assert!(!tier_may_be_granted(RiskTier::Privileged));
}
#[test]
fn autonomy_defaults_to_the_strictest_mode() {
assert_eq!(Autonomy::default(), Autonomy::Ask);
}
#[test]
fn continue_mode_resumes_a_clean_unblocked_turn() {
assert_eq!(should_continue(Autonomy::Continue, true, false, 0), Ok(()));
}
#[test]
fn review_and_ask_never_continue() {
for mode in [Autonomy::Review, Autonomy::Ask] {
assert_eq!(
should_continue(mode, true, false, 0),
Err(ContinueVeto::Policy),
"{mode:?} must hand back"
);
}
}
#[test]
fn an_unclean_stop_is_never_continued() {
assert_eq!(
should_continue(Autonomy::Continue, false, false, 0),
Err(ContinueVeto::UnCleanStop)
);
}
#[test]
fn continuation_never_routes_around_a_blocked_gate() {
assert_eq!(
should_continue(Autonomy::Continue, true, true, 0),
Err(ContinueVeto::GateBlocked)
);
}
#[test]
fn continuation_budget_is_spent_after_max() {
assert_eq!(
should_continue(Autonomy::Continue, true, false, MAX_CONTINUATIONS),
Err(ContinueVeto::BudgetSpent)
);
assert_eq!(
should_continue(Autonomy::Continue, true, false, MAX_CONTINUATIONS + 9),
Err(ContinueVeto::BudgetSpent)
);
}
#[test]
fn policy_veto_precedes_every_other_veto() {
assert_eq!(
should_continue(Autonomy::Ask, false, true, 99),
Err(ContinueVeto::Policy)
);
}
#[test]
fn autonomy_round_trips_as_kebab_case() {
let y = serde_yaml::to_string(&Autonomy::Continue).unwrap();
assert!(y.contains("continue"), "got {y}");
let back: Autonomy = serde_yaml::from_str("review").unwrap();
assert_eq!(back, Autonomy::Review);
}
#[test]
fn revocation_payload_round_trips_and_ignores_other_notes() {
let at = chrono::Utc::now();
for scope in [RevocationScope::All, RevocationScope::One("abc".into())] {
let r = HitlRevocation {
scope: scope.clone(),
issued_at: at,
};
let back = HitlRevocation::from_payload(&r.to_payload()).expect("round trip");
assert_eq!(back.scope, scope);
assert_eq!(back.issued_at, at);
}
let one = HitlRevocation {
scope: RevocationScope::One("abc".into()),
issued_at: at,
};
assert!(one.covers("abc") && !one.covers("abd"));
assert!(HitlRevocation::from_payload(&serde_json::json!({"review": {}})).is_none());
assert!(HitlRevocation::from_payload(&serde_json::json!({"hitl_revocation": 1})).is_none());
}
#[test]
fn a_response_without_label_still_parses() {
let v = serde_json::json!({"hitl_id": "h", "action_hash": "a", "allow": true, "surface": "hub"});
let r: HitlResponse = serde_json::from_value(v).unwrap();
assert!(r.label.is_none());
assert!(!serde_json::to_string(&r).unwrap().contains("label"));
}
}