use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum PermissionMode {
#[default]
Default,
AcceptEdits,
Bypass,
Plan,
}
pub enum PermissionResult {
Allow,
Deny(String),
Ask(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum PermissionResponse {
Allow,
Deny,
AlwaysAllow,
}
pub struct PermissionChecker {
mode: PermissionMode,
session_allows: std::collections::HashSet<String>,
}
impl PermissionChecker {
pub fn new(mode: PermissionMode) -> Self {
Self {
mode,
session_allows: std::collections::HashSet::new(),
}
}
pub fn always_allow(&mut self, tool_name: &str) {
self.session_allows.insert(tool_name.to_string());
}
pub fn check(&self, tool_name: &str, input: &serde_json::Value, is_read_only: bool) -> PermissionResult {
if self.session_allows.contains(tool_name) {
return PermissionResult::Allow;
}
match self.mode {
PermissionMode::Bypass => PermissionResult::Allow,
PermissionMode::Plan => {
if is_read_only {
PermissionResult::Allow
} else {
PermissionResult::Deny("Plan mode: write operations are disabled".to_string())
}
}
PermissionMode::AcceptEdits => {
if is_read_only || tool_name == "Write" || tool_name == "Edit" {
PermissionResult::Allow
} else if tool_name == "Bash" {
let cmd = input["command"].as_str().unwrap_or("");
PermissionResult::Ask(format!("Allow bash: {}?", truncate(cmd, 80)))
} else {
PermissionResult::Allow
}
}
PermissionMode::Default => {
if is_read_only {
PermissionResult::Allow
} else {
let summary = match tool_name {
"Bash" => {
let cmd = input["command"].as_str().unwrap_or("");
format!("bash: {}", truncate(cmd, 80))
}
"Write" => {
let path = input["file_path"].as_str().unwrap_or("?");
format!("write: {}", path)
}
"Edit" => {
let path = input["file_path"].as_str().unwrap_or("?");
format!("edit: {}", path)
}
_ => format!("{}", tool_name),
};
PermissionResult::Ask(summary)
}
}
}
}
}
fn truncate(s: &str, max: usize) -> &str {
if s.len() <= max {
s
} else {
&s[..max]
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn bypass_allows_everything() {
let checker = PermissionChecker::new(PermissionMode::Bypass);
let input = json!({"command": "rm -rf /"});
assert!(matches!(checker.check("Bash", &input, false), PermissionResult::Allow));
}
#[test]
fn plan_denies_writes() {
let checker = PermissionChecker::new(PermissionMode::Plan);
let input = json!({"file_path": "/tmp/test"});
assert!(matches!(checker.check("Write", &input, false), PermissionResult::Deny(_)));
}
#[test]
fn plan_allows_reads() {
let checker = PermissionChecker::new(PermissionMode::Plan);
let input = json!({"file_path": "/tmp/test"});
assert!(matches!(checker.check("Read", &input, true), PermissionResult::Allow));
}
#[test]
fn default_allows_read_only() {
let checker = PermissionChecker::new(PermissionMode::Default);
let input = json!({"pattern": "*.rs"});
assert!(matches!(checker.check("Glob", &input, true), PermissionResult::Allow));
}
#[test]
fn default_asks_for_bash() {
let checker = PermissionChecker::new(PermissionMode::Default);
let input = json!({"command": "cargo test"});
assert!(matches!(checker.check("Bash", &input, false), PermissionResult::Ask(_)));
}
#[test]
fn default_asks_for_write() {
let checker = PermissionChecker::new(PermissionMode::Default);
let input = json!({"file_path": "/tmp/test", "content": "hello"});
assert!(matches!(checker.check("Write", &input, false), PermissionResult::Ask(_)));
}
#[test]
fn accept_edits_allows_write_and_edit() {
let checker = PermissionChecker::new(PermissionMode::AcceptEdits);
let input = json!({"file_path": "/tmp/test"});
assert!(matches!(checker.check("Write", &input, false), PermissionResult::Allow));
assert!(matches!(checker.check("Edit", &input, false), PermissionResult::Allow));
}
#[test]
fn accept_edits_asks_for_bash() {
let checker = PermissionChecker::new(PermissionMode::AcceptEdits);
let input = json!({"command": "rm -rf /"});
assert!(matches!(checker.check("Bash", &input, false), PermissionResult::Ask(_)));
}
#[test]
fn always_allow_overrides_mode() {
let mut checker = PermissionChecker::new(PermissionMode::Default);
let input = json!({"command": "cargo test"});
assert!(matches!(checker.check("Bash", &input, false), PermissionResult::Ask(_)));
checker.always_allow("Bash");
assert!(matches!(checker.check("Bash", &input, false), PermissionResult::Allow));
}
#[test]
fn always_allow_is_tool_specific() {
let mut checker = PermissionChecker::new(PermissionMode::Default);
checker.always_allow("Bash");
let input = json!({"file_path": "/tmp/test"});
assert!(matches!(checker.check("Write", &input, false), PermissionResult::Ask(_)));
}
#[test]
fn ask_summary_contains_command() {
let checker = PermissionChecker::new(PermissionMode::Default);
let input = json!({"command": "cargo test"});
if let PermissionResult::Ask(summary) = checker.check("Bash", &input, false) {
assert!(summary.contains("cargo test"));
} else {
panic!("expected Ask");
}
}
#[test]
fn ask_summary_contains_file_path() {
let checker = PermissionChecker::new(PermissionMode::Default);
let input = json!({"file_path": "/home/ducks/important.rs"});
if let PermissionResult::Ask(summary) = checker.check("Edit", &input, false) {
assert!(summary.contains("important.rs"));
} else {
panic!("expected Ask");
}
}
}