use serde::{Deserialize, Serialize};
pub mod pin;
pub const APPROVAL_TTL_SECS: i64 = 7 * 24 * 60 * 60;
pub fn within_approval_ttl(
event_ts: chrono::DateTime<chrono::Utc>,
now: chrono::DateTime<chrono::Utc>,
) -> bool {
(now - event_ts).num_seconds() <= APPROVAL_TTL_SECS
}
#[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,
}
#[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,
}
#[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(),
};
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 ttl_boundary_is_inclusive_at_seven_days() {
let now = chrono::Utc::now();
let exactly = now - chrono::Duration::seconds(APPROVAL_TTL_SECS);
let over = now - chrono::Duration::seconds(APPROVAL_TTL_SECS + 1);
assert!(within_approval_ttl(exactly, now));
assert!(!within_approval_ttl(over, now));
}
}