use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::event::Decision;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionStore {
pub tools: HashMap<String, PersistedDecision>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PersistedDecision {
AskUser,
AllowOnce,
AllowSession,
AllowAlways,
Deny,
}
impl PersistedDecision {
pub fn to_decision(&self) -> Decision {
match self {
PersistedDecision::AskUser => Decision::AskUser,
PersistedDecision::AllowOnce => Decision::AllowOnce,
PersistedDecision::AllowSession => Decision::AllowSession,
PersistedDecision::AllowAlways => Decision::AllowAlways,
PersistedDecision::Deny => Decision::Deny,
}
}
pub fn from_decision(d: &Decision) -> Option<Self> {
match d {
Decision::AskUser => Some(PersistedDecision::AskUser),
Decision::AllowOnce => Some(PersistedDecision::AllowOnce),
Decision::AllowSession => Some(PersistedDecision::AllowSession),
Decision::AllowAlways => Some(PersistedDecision::AllowAlways),
Decision::Deny => Some(PersistedDecision::Deny),
Decision::Allow => None,
}
}
pub fn is_allowed(&self) -> bool {
matches!(
self,
PersistedDecision::AllowOnce
| PersistedDecision::AllowSession
| PersistedDecision::AllowAlways
)
}
pub fn is_durable(&self) -> bool {
matches!(
self,
PersistedDecision::AllowAlways | PersistedDecision::Deny
)
}
}
impl PermissionStore {
pub fn load(config_dir: &PathBuf) -> Self {
let path = permissions_path(config_dir);
if path.exists() {
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
} else {
PermissionStore::default()
}
}
pub fn save(&self, config_dir: &PathBuf) -> anyhow::Result<()> {
let path = permissions_path(config_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
std::fs::write(&path, json)?;
Ok(())
}
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
self.tools
.get(tool_name)
.map(|d| d.is_allowed())
.unwrap_or(false)
}
pub fn is_tool_denied(&self, tool_name: &str) -> bool {
self.tools
.get(tool_name)
.map(|d| matches!(d, PersistedDecision::Deny))
.unwrap_or(false)
}
pub fn get(&self, tool_name: &str) -> Option<&PersistedDecision> {
self.tools.get(tool_name)
}
pub fn set_and_save(
&mut self,
tool_name: &str,
decision: &Decision,
config_dir: &PathBuf,
) -> anyhow::Result<()> {
if let Some(pd) = PersistedDecision::from_decision(decision) {
if pd.is_durable() {
self.tools.insert(tool_name.to_string(), pd);
self.save(config_dir)?;
}
}
Ok(())
}
pub fn expire_session_scoped(&mut self) {
self.tools.retain(|_, d| d.is_durable());
}
pub fn to_api_map(&self) -> HashMap<String, String> {
self.tools
.iter()
.map(|(k, v)| {
let s = match v {
PersistedDecision::AskUser => "ask_user",
PersistedDecision::AllowOnce => "allow_once",
PersistedDecision::AllowSession => "allow_session",
PersistedDecision::AllowAlways => "allow_always",
PersistedDecision::Deny => "deny",
};
(k.clone(), s.to_string())
})
.collect()
}
}
impl Default for PermissionStore {
fn default() -> Self {
PermissionStore {
tools: HashMap::new(),
}
}
}
fn permissions_path(config_dir: &PathBuf) -> PathBuf {
config_dir.join("permissions.json")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_store_is_empty() {
let store = PermissionStore::default();
assert!(store.tools.is_empty());
}
#[test]
fn test_is_tool_allowed() {
let mut store = PermissionStore::default();
store
.tools
.insert("web_search".into(), PersistedDecision::AllowAlways);
assert!(store.is_tool_allowed("web_search"));
assert!(!store.is_tool_allowed("code_exec"));
}
#[test]
fn test_expire_session_scoped() {
let mut store = PermissionStore::default();
store
.tools
.insert("web_search".into(), PersistedDecision::AllowAlways);
store
.tools
.insert("fs_read".into(), PersistedDecision::AllowSession);
store.expire_session_scoped();
assert!(store.tools.contains_key("web_search"));
assert!(!store.tools.contains_key("fs_read"));
}
#[test]
fn test_persist_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let mut store = PermissionStore::default();
store
.tools
.insert("web_search".into(), PersistedDecision::AllowAlways);
store.save(&dir).unwrap();
let loaded = PermissionStore::load(&dir);
assert!(loaded.is_tool_allowed("web_search"));
}
}