use std::borrow::Cow;
use serde_yaml::Value as YamlValue;
use systemprompt_identifiers::PolicyId;
mod operators;
mod rules;
use rules::{Rule, Verdict};
use super::super::config::GovernanceConfig;
use super::super::registry::PolicyRegistration;
use super::super::types::{AccessScope, GovernancePolicy, PolicyContext};
use crate::authz::types::{Decision, MatchedBy, PendingReason};
pub(crate) const ID: &str = "require_approval";
const DEFAULT_HOLD_SECONDS: u64 = 60;
const DEFAULT_EXPIRY_SECONDS: u64 = 900;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ApprovalSettings {
pub hold_seconds: u64,
pub expiry_seconds: u64,
}
impl Default for ApprovalSettings {
fn default() -> Self {
Self {
hold_seconds: DEFAULT_HOLD_SECONDS,
expiry_seconds: DEFAULT_EXPIRY_SECONDS,
}
}
}
impl ApprovalSettings {
#[must_use]
pub fn from_governance_config(config: &GovernanceConfig) -> Self {
config
.policies
.iter()
.find(|p| p.id == ID)
.map_or_else(Self::default, |p| Self::from_params(&p.params))
}
#[must_use]
pub fn from_params(v: &YamlValue) -> Self {
let default = Self::default();
Self {
hold_seconds: positive_u64(v, "hold_seconds").unwrap_or(default.hold_seconds),
expiry_seconds: positive_u64(v, "expiry_seconds").unwrap_or(default.expiry_seconds),
}
}
}
fn positive_u64(v: &YamlValue, key: &str) -> Option<u64> {
v.get(key).and_then(YamlValue::as_u64).filter(|n| *n > 0)
}
#[derive(Debug)]
struct RequireApproval {
rules: Vec<Rule>,
exempt_scopes: Vec<AccessScope>,
}
impl RequireApproval {
fn from_yaml(v: &YamlValue) -> Self {
let exempt_scopes = string_list(v, "exempt_scopes")
.iter()
.filter_map(|s| parse_scope(s))
.collect();
Self {
rules: rules::compile(v),
exempt_scopes,
}
}
}
fn string_list(v: &YamlValue, key: &str) -> Vec<String> {
v.get(key)
.and_then(YamlValue::as_sequence)
.map(|seq| {
seq.iter()
.filter_map(|p| p.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
fn parse_scope(raw: &str) -> Option<AccessScope> {
match raw.trim().to_ascii_lowercase().as_str() {
"admin" => Some(AccessScope::Admin),
"user" => Some(AccessScope::User),
"unknown" => Some(AccessScope::Unknown),
other => {
tracing::warn!(
scope = other,
policy = ID,
"unknown access scope in exempt_scopes — ignoring"
);
None
},
}
}
impl GovernancePolicy for RequireApproval {
fn id(&self) -> PolicyId {
PolicyId::new(ID)
}
fn name(&self) -> &'static str {
"Require Approval"
}
fn description(&self) -> &'static str {
"Hold matching tool calls for an explicit human approval before they run, \
instead of allowing or denying them outright."
}
fn evaluate(&self, ctx: &PolicyContext<'_>) -> Decision {
let allow = |detail| Decision::Allow {
matched_by: MatchedBy::PolicyAllow {
policy_id: PolicyId::new(ID),
detail,
},
};
let Some(tool) = ctx.target.tool() else {
return allow(Cow::Borrowed("Not a tool call"));
};
if self.exempt_scopes.contains(&ctx.access_scope) {
return allow(Cow::Borrowed("Caller scope is exempt from approval"));
}
let mut scalars = None;
for rule in &self.rules {
if !rule.matches_tool(tool.as_str()) {
continue;
}
let scalars = scalars.get_or_insert_with(|| ctx.input.scalars());
if let Verdict::Hold(rule) = rule.evaluate(scalars) {
return Decision::Pending {
reason: PendingReason::ApprovalRequired {
tool: tool.clone(),
rule,
},
};
}
}
allow(Cow::Borrowed("Tool does not require approval"))
}
}
inventory::submit! {
PolicyRegistration {
id: ID,
factory: |v| Box::new(RequireApproval::from_yaml(v)),
}
}