use serde::{Deserialize, Serialize};
use super::approval_resolver::ApprovalResolver;
use super::{PolicyAction, ToolApprovalPolicy};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunInteractivity {
Interactive,
NonInteractive,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalAvailability {
Available,
Unavailable,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceTrust {
Untrusted,
Trusted,
HostMaterialized,
}
impl WorkspaceTrust {
pub fn permits_project_policy(self) -> bool {
matches!(self, Self::Trusted | Self::HostMaterialized)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct RunAuthorityPosture {
pub interactivity: RunInteractivity,
pub approval_availability: ApprovalAvailability,
pub workspace_trust: WorkspaceTrust,
}
impl RunAuthorityPosture {
fn approval_is_unsatisfiable(self, resolver: ApprovalResolver) -> bool {
if resolver.answers_ask_without_a_person() {
return false;
}
self.interactivity == RunInteractivity::NonInteractive
&& self.approval_availability == ApprovalAvailability::Unavailable
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RunApprovalPolicy {
posture: RunAuthorityPosture,
effective: ToolApprovalPolicy,
resolver: ApprovalResolver,
}
impl RunApprovalPolicy {
pub fn construct(
posture: RunAuthorityPosture,
build: impl FnOnce(RunAuthorityPosture) -> ToolApprovalPolicy,
) -> Self {
Self::construct_with_resolver(posture, ApprovalResolver::Host, build)
}
pub fn construct_with_resolver(
posture: RunAuthorityPosture,
resolver: ApprovalResolver,
build: impl FnOnce(RunAuthorityPosture) -> ToolApprovalPolicy,
) -> Self {
let mut effective = build(posture);
if posture.approval_is_unsatisfiable(resolver) {
deny_unsatisfiable_approval(&mut effective);
}
Self {
posture,
effective,
resolver,
}
}
pub fn posture(&self) -> RunAuthorityPosture {
self.posture
}
pub fn resolver(&self) -> ApprovalResolver {
self.resolver
}
pub fn effective(&self) -> &ToolApprovalPolicy {
&self.effective
}
}
fn deny_unsatisfiable_approval(policy: &mut ToolApprovalPolicy) {
for rule in &mut policy.rules {
if rule.action == PolicyAction::Ask {
rule.action = PolicyAction::Deny;
rule.reason = Some(match rule.reason.take() {
Some(reason) => format!("approval unavailable: {reason}"),
None => "approval unavailable: the matched rule requires approval".to_string(),
});
}
}
for pattern in std::mem::take(&mut policy.require_approval) {
if !policy.auto_deny.contains(&pattern) {
policy.auto_deny.push(pattern);
}
}
if policy.repeat_limit.is_some()
&& matches!(policy.repeat_action, None | Some(PolicyAction::Ask))
{
policy.repeat_action = Some(PolicyAction::Deny);
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn posture(workspace_trust: WorkspaceTrust) -> RunAuthorityPosture {
RunAuthorityPosture {
interactivity: RunInteractivity::NonInteractive,
approval_availability: ApprovalAvailability::Unavailable,
workspace_trust,
}
}
#[test]
fn host_materialized_workspace_reaches_policy_construction() {
let materialized =
RunApprovalPolicy::construct(posture(WorkspaceTrust::HostMaterialized), |posture| {
ToolApprovalPolicy {
auto_deny: (!posture.workspace_trust.permits_project_policy())
.then(|| "edit".to_string())
.into_iter()
.collect(),
..ToolApprovalPolicy::default()
}
});
let untrusted =
RunApprovalPolicy::construct(posture(WorkspaceTrust::Untrusted), |posture| {
ToolApprovalPolicy {
auto_deny: (!posture.workspace_trust.permits_project_policy())
.then(|| "edit".to_string())
.into_iter()
.collect(),
..ToolApprovalPolicy::default()
}
});
assert_eq!(
materialized.effective().evaluate("edit", &json!({})),
super::super::ToolApprovalDecision::AutoApproved
);
assert!(matches!(
untrusted.effective().evaluate("edit", &json!({})),
super::super::ToolApprovalDecision::AutoDenied { .. }
));
}
fn ask_rule_policy() -> ToolApprovalPolicy {
serde_json::from_value(json!({
"rules": [{
"id": "rule-ask",
"action": "ask",
"match": {"tool": "rule_tool"},
"reason": "review the rule tool"
}]
}))
.expect("policy parses")
}
#[test]
fn host_resolver_still_collapses_an_unanswerable_ask() {
let policy = RunApprovalPolicy::construct_with_resolver(
posture(WorkspaceTrust::Trusted),
ApprovalResolver::Host,
|_| ask_rule_policy(),
);
assert!(matches!(
policy.effective().evaluate("rule_tool", &json!({})),
super::super::ToolApprovalDecision::AutoDenied { .. }
));
assert_eq!(policy.resolver(), ApprovalResolver::Host);
}
#[test]
fn a_resolver_that_answers_keeps_the_ask_askable() {
for resolver in [ApprovalResolver::AutoReview, ApprovalResolver::AllowAll] {
let policy = RunApprovalPolicy::construct_with_resolver(
posture(WorkspaceTrust::Trusted),
resolver,
|_| ask_rule_policy(),
);
assert_eq!(
policy.effective().evaluate("rule_tool", &json!({})),
super::super::ToolApprovalDecision::RequiresHostApproval,
"{resolver:?} answers asks, so the ask must reach it"
);
assert_eq!(policy.resolver(), resolver);
}
}
#[test]
fn the_default_constructor_is_unchanged() {
let legacy =
RunApprovalPolicy::construct(posture(WorkspaceTrust::Trusted), |_| ask_rule_policy());
assert_eq!(legacy.resolver(), ApprovalResolver::Host);
assert!(matches!(
legacy.effective().evaluate("rule_tool", &json!({})),
super::super::ToolApprovalDecision::AutoDenied { .. }
));
}
#[test]
fn unavailable_noninteractive_policy_has_no_satisfiable_ask_form() {
let policy = RunApprovalPolicy::construct(posture(WorkspaceTrust::Trusted), |_| {
serde_json::from_value(json!({
"rules": [{
"id": "rule-ask",
"action": "ask",
"match": {"tool": "rule_tool"},
"reason": "review the rule tool"
}],
"require_approval": ["legacy_tool"],
"repeat_limit": 1
}))
.expect("policy")
});
for (tool, repeat_count) in [("rule_tool", 0), ("legacy_tool", 0), ("other", 2)] {
let decision =
policy
.effective()
.evaluate_detailed_with_repeat(tool, &json!({}), repeat_count);
assert!(decision.is_deny(), "{tool} remained {:?}", decision.action);
assert!(
!decision.is_ask(),
"{tool} still requires unavailable approval"
);
}
}
#[test]
fn other_postures_preserve_reviewable_asks() {
for posture in [
RunAuthorityPosture {
interactivity: RunInteractivity::Interactive,
approval_availability: ApprovalAvailability::Unavailable,
workspace_trust: WorkspaceTrust::Trusted,
},
RunAuthorityPosture {
interactivity: RunInteractivity::NonInteractive,
approval_availability: ApprovalAvailability::Available,
workspace_trust: WorkspaceTrust::Trusted,
},
] {
let policy = RunApprovalPolicy::construct(posture, |_| ToolApprovalPolicy {
require_approval: vec!["edit".to_string()],
..ToolApprovalPolicy::default()
});
assert!(policy
.effective()
.evaluate_detailed("edit", &json!({}))
.is_ask());
}
}
}