use crate::permission::PermissionTier;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalMode {
AlwaysAllow,
RequireApproval,
Deny,
}
impl ApprovalMode {
pub fn as_str(self) -> &'static str {
match self {
ApprovalMode::AlwaysAllow => "always_allow",
ApprovalMode::RequireApproval => "require_approval",
ApprovalMode::Deny => "deny",
}
}
pub fn from_str_opt(s: &str) -> Option<ApprovalMode> {
match s {
"always_allow" | "allow" => Some(ApprovalMode::AlwaysAllow),
"require_approval" | "approval" | "ask" => Some(ApprovalMode::RequireApproval),
"deny" | "block" => Some(ApprovalMode::Deny),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TierPosture {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub read_only: Option<ApprovalMode>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub sandbox_edit: Option<ApprovalMode>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub full_access: Option<ApprovalMode>,
}
impl TierPosture {
pub fn get(&self, tier: PermissionTier) -> Option<ApprovalMode> {
match tier {
PermissionTier::ReadOnly => self.read_only,
PermissionTier::SandboxEdit => self.sandbox_edit,
PermissionTier::FullAccess => self.full_access,
}
}
pub fn set(&mut self, tier: PermissionTier, mode: ApprovalMode) {
match tier {
PermissionTier::ReadOnly => self.read_only = Some(mode),
PermissionTier::SandboxEdit => self.sandbox_edit = Some(mode),
PermissionTier::FullAccess => self.full_access = Some(mode),
}
}
pub fn uniform(mode: ApprovalMode) -> Self {
TierPosture {
read_only: Some(mode),
sandbox_edit: Some(mode),
full_access: Some(mode),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalPreset {
Cautious,
Balanced,
Trusting,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExactToolApproval {
pub mode: ApprovalMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_digest: Option<String>,
}
impl<'de> Deserialize<'de> for ExactToolApproval {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Wire {
Current {
mode: ApprovalMode,
#[serde(default)]
schema_digest: Option<String>,
},
Legacy(ApprovalMode),
}
Ok(match Wire::deserialize(deserializer)? {
Wire::Current {
mode,
schema_digest,
} => Self {
mode,
schema_digest,
},
Wire::Legacy(mode) => Self {
mode,
schema_digest: None,
},
})
}
}
impl ApprovalPreset {
pub fn posture(self) -> TierPosture {
use ApprovalMode::*;
match self {
ApprovalPreset::Cautious => TierPosture {
read_only: Some(RequireApproval),
sandbox_edit: Some(RequireApproval),
full_access: Some(RequireApproval),
},
ApprovalPreset::Balanced => TierPosture {
read_only: Some(AlwaysAllow),
sandbox_edit: Some(RequireApproval),
full_access: Some(RequireApproval),
},
ApprovalPreset::Trusting => TierPosture {
read_only: Some(AlwaysAllow),
sandbox_edit: Some(AlwaysAllow),
full_access: Some(RequireApproval),
},
}
}
pub fn from_str_opt(s: &str) -> Option<ApprovalPreset> {
match s {
"cautious" => Some(ApprovalPreset::Cautious),
"balanced" => Some(ApprovalPreset::Balanced),
"trusting" => Some(ApprovalPreset::Trusting),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentPermissionPolicy {
pub default: TierPosture,
#[serde(default)]
pub agents: BTreeMap<String, TierPosture>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub tool_overrides: BTreeMap<String, BTreeMap<String, ExactToolApproval>>,
}
impl Default for AgentPermissionPolicy {
fn default() -> Self {
AgentPermissionPolicy {
default: ApprovalPreset::Balanced.posture(),
agents: BTreeMap::new(),
tool_overrides: BTreeMap::new(),
}
}
}
impl AgentPermissionPolicy {
pub fn resolve(&self, agent_id: &str, tier: PermissionTier) -> ApprovalMode {
if let Some(posture) = self.agents.get(agent_id) {
if let Some(mode) = posture.get(tier) {
return mode;
}
}
self.default
.get(tier)
.unwrap_or(ApprovalMode::RequireApproval)
}
pub fn effective(&self, agent_id: &str) -> TierPosture {
TierPosture {
read_only: Some(self.resolve(agent_id, PermissionTier::ReadOnly)),
sandbox_edit: Some(self.resolve(agent_id, PermissionTier::SandboxEdit)),
full_access: Some(self.resolve(agent_id, PermissionTier::FullAccess)),
}
}
pub fn has_override(&self, agent_id: &str) -> bool {
self.agents.contains_key(agent_id)
}
pub fn resolve_tool(&self, agent_id: &str, tool: &str) -> Option<&ExactToolApproval> {
self.tool_overrides
.get(agent_id)
.and_then(|tools| tools.get(tool))
}
pub fn set_tool(
&mut self,
agent_id: &str,
tool: &str,
mode: ApprovalMode,
schema_digest: Option<String>,
) {
self.tool_overrides
.entry(agent_id.to_string())
.or_default()
.insert(
tool.to_string(),
ExactToolApproval {
mode,
schema_digest,
},
);
}
pub fn reset_tool(&mut self, agent_id: &str, tool: &str) -> bool {
let Some(tools) = self.tool_overrides.get_mut(agent_id) else {
return false;
};
let removed = tools.remove(tool).is_some();
if tools.is_empty() {
self.tool_overrides.remove(agent_id);
}
removed
}
pub fn set_agent(&mut self, agent_id: &str, tier: PermissionTier, mode: ApprovalMode) {
self.agents
.entry(agent_id.to_string())
.or_default()
.set(tier, mode);
}
pub fn set_agent_uniform(&mut self, agent_id: &str, mode: ApprovalMode) {
self.agents
.insert(agent_id.to_string(), TierPosture::uniform(mode));
}
pub fn set_default(&mut self, tier: PermissionTier, mode: ApprovalMode) {
self.default.set(tier, mode);
}
pub fn set_default_preset(&mut self, preset: ApprovalPreset) {
self.default = preset.posture();
}
pub fn reset_agent(&mut self, agent_id: &str) {
self.agents.remove(agent_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_balanced() {
let p = AgentPermissionPolicy::default();
assert_eq!(
p.resolve("any", PermissionTier::ReadOnly),
ApprovalMode::AlwaysAllow
);
assert_eq!(
p.resolve("any", PermissionTier::SandboxEdit),
ApprovalMode::RequireApproval
);
assert_eq!(
p.resolve("any", PermissionTier::FullAccess),
ApprovalMode::RequireApproval
);
}
#[test]
fn agent_override_wins_then_falls_through() {
let mut p = AgentPermissionPolicy::default();
p.set_agent("risky", PermissionTier::FullAccess, ApprovalMode::Deny);
assert_eq!(
p.resolve("risky", PermissionTier::FullAccess),
ApprovalMode::Deny
);
assert_eq!(
p.resolve("risky", PermissionTier::ReadOnly),
ApprovalMode::AlwaysAllow
);
assert_eq!(
p.resolve("other", PermissionTier::FullAccess),
ApprovalMode::RequireApproval
);
}
#[test]
fn uniform_and_reset() {
let mut p = AgentPermissionPolicy::default();
p.set_agent_uniform("blocked", ApprovalMode::Deny);
assert_eq!(
p.resolve("blocked", PermissionTier::ReadOnly),
ApprovalMode::Deny
);
assert!(p.has_override("blocked"));
p.reset_agent("blocked");
assert!(!p.has_override("blocked"));
assert_eq!(
p.resolve("blocked", PermissionTier::ReadOnly),
ApprovalMode::AlwaysAllow
);
}
#[test]
fn presets_map_as_expected() {
assert_eq!(
ApprovalPreset::Cautious.posture().read_only,
Some(ApprovalMode::RequireApproval)
);
assert_eq!(
ApprovalPreset::Trusting.posture().sandbox_edit,
Some(ApprovalMode::AlwaysAllow)
);
assert_eq!(
ApprovalPreset::Trusting.posture().full_access,
Some(ApprovalMode::RequireApproval)
);
}
#[test]
fn roundtrips_through_json() {
let mut p = AgentPermissionPolicy::default();
p.set_agent("a", PermissionTier::SandboxEdit, ApprovalMode::AlwaysAllow);
let json = serde_json::to_string(&p).unwrap();
let back: AgentPermissionPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(p, back);
}
#[test]
fn legacy_json_without_tool_overrides_remains_compatible() {
let legacy = serde_json::json!({
"default": {
"read_only": "always_allow",
"sandbox_edit": "require_approval",
"full_access": "require_approval"
},
"agents": {}
});
let policy: AgentPermissionPolicy = serde_json::from_value(legacy.clone()).unwrap();
assert_eq!(
policy.resolve_tool("daily-continuity-newsroom", "newsroom.publish"),
None
);
assert_eq!(serde_json::to_value(policy).unwrap(), legacy);
}
#[test]
fn tool_overrides_match_exact_agent_and_exact_tool_only() {
let mut policy = AgentPermissionPolicy::default();
policy.set_tool(
"daily-continuity-newsroom",
"newsroom.publish",
ApprovalMode::AlwaysAllow,
Some("a".repeat(64)),
);
assert_eq!(
policy
.resolve_tool("daily-continuity-newsroom", "newsroom.publish")
.map(|rule| rule.mode),
Some(ApprovalMode::AlwaysAllow),
);
assert_eq!(
policy.resolve_tool("daily-continuity-newsroom", "newsroom.publish.preview"),
None
);
assert_eq!(policy.resolve_tool("other-agent", "newsroom.publish"), None);
assert_eq!(
policy.resolve_tool("daily-continuity-newsroom", "newsroom.*"),
None
);
assert!(policy.reset_tool("daily-continuity-newsroom", "newsroom.publish"));
assert_eq!(
policy.resolve_tool("daily-continuity-newsroom", "newsroom.publish"),
None
);
assert!(!policy.reset_tool("daily-continuity-newsroom", "newsroom.publish"));
}
#[test]
fn legacy_name_only_tool_override_is_readable_but_unbound() {
let value = serde_json::json!({
"default": {
"read_only": "always_allow",
"sandbox_edit": "require_approval",
"full_access": "require_approval"
},
"agents": {},
"tool_overrides": {
"daily-continuity-newsroom": {
"newsroom.publish": "always_allow"
}
}
});
let policy: AgentPermissionPolicy = serde_json::from_value(value).unwrap();
let rule = policy
.resolve_tool("daily-continuity-newsroom", "newsroom.publish")
.unwrap();
assert_eq!(rule.mode, ApprovalMode::AlwaysAllow);
assert_eq!(rule.schema_digest, None);
}
}