use async_trait::async_trait;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionDecision {
AllowOnce,
AllowAlways,
RejectOnce,
RejectAlways,
}
impl PermissionDecision {
pub fn is_allowed(self) -> bool {
matches!(self, Self::AllowOnce | Self::AllowAlways)
}
}
#[async_trait]
pub trait PermissionGate: Send + Sync {
async fn check(
&self,
tool_call_id: &str,
tool_name: &str,
arguments: &str,
) -> PermissionDecision;
}
pub fn approval_key(tool_name: &str, arguments: &str) -> String {
if tool_name != "execute_command" {
return tool_name.to_string();
}
let command_prefix = serde_json::from_str::<serde_json::Value>(arguments)
.ok()
.and_then(|v| v.get("command")?.as_str().map(str::to_string))
.and_then(|cmd| cmd.split_whitespace().next().map(str::to_string));
match command_prefix {
Some(prefix) => format!("{tool_name}:{prefix}"),
None => tool_name.to_string(),
}
}
pub struct AllowAll;
#[async_trait]
impl PermissionGate for AllowAll {
async fn check(
&self,
_tool_call_id: &str,
_tool_name: &str,
_arguments: &str,
) -> PermissionDecision {
PermissionDecision::AllowOnce
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn allow_all_always_allows() {
let decision = AllowAll.check("call_1", "execute_command", "{}").await;
assert!(decision.is_allowed());
}
#[test]
fn non_shell_tools_are_keyed_by_bare_tool_name_regardless_of_arguments() {
assert_eq!(
approval_key("read_file", r#"{"path": "a.txt"}"#),
"read_file"
);
assert_eq!(
approval_key("read_file", r#"{"path": "b.txt"}"#),
"read_file"
);
}
#[test]
fn shell_commands_are_scoped_by_their_first_word() {
assert_eq!(
approval_key("execute_command", r#"{"command": "git status"}"#),
"execute_command:git"
);
assert_eq!(
approval_key("execute_command", r#"{"command": "git commit -m x"}"#),
"execute_command:git"
);
}
#[test]
fn different_shell_commands_get_different_keys() {
let git = approval_key("execute_command", r#"{"command": "git status"}"#);
let rm = approval_key("execute_command", r#"{"command": "rm -rf /"}"#);
assert_ne!(git, rm);
}
#[test]
fn unparseable_shell_arguments_fall_back_to_the_bare_tool_name() {
assert_eq!(
approval_key("execute_command", "not json"),
"execute_command"
);
assert_eq!(
approval_key("execute_command", r#"{"no_command_field": true}"#),
"execute_command"
);
}
}