use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use super::config::{PermissionConfig, PermissionMode, PermissionType, RiskLevel};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PermissionContext {
pub permission_type: PermissionType,
pub resource: String,
pub operation_description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
impl PermissionContext {
pub fn new(
permission_type: PermissionType,
resource: impl Into<String>,
operation_description: impl Into<String>,
) -> Self {
Self {
permission_type,
resource: resource.into(),
operation_description: operation_description.into(),
details: None,
}
}
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
pub fn risk_level(&self) -> RiskLevel {
self.permission_type.risk_level()
}
pub fn format_request_message(&self) -> String {
let risk_label = self.risk_level().label();
format!(
"{} - {}\n\nResource: {}\nOperation: {}",
risk_label,
self.permission_type.description(),
self.resource,
self.operation_description
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionResult {
Granted,
Denied,
RequiresConfirmation(PermissionContext),
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum PermissionError {
#[error("Permission denied: {0}")]
Denied(String),
#[error("Permission check failed: {0}")]
CheckFailed(String),
#[error("Confirmation required for {permission_type:?} on {resource}")]
ConfirmationRequired {
permission_type: PermissionType,
resource: String,
},
}
impl PermissionError {
pub fn confirmation_required(context: PermissionContext) -> Self {
Self::ConfirmationRequired {
permission_type: context.permission_type,
resource: context.resource,
}
}
}
#[async_trait]
pub trait PermissionChecker: Send + Sync {
async fn needs_confirmation(&self, perm_type: PermissionType, resource: &str) -> bool;
async fn is_granted(&self, perm_type: PermissionType, resource: &str) -> bool {
!self.needs_confirmation(perm_type, resource).await
}
async fn request_confirmation(&self, ctx: PermissionContext) -> Result<bool, PermissionError>;
fn grant_session_permission(&self, perm_type: PermissionType, resource: String);
async fn check_or_request(&self, ctx: PermissionContext) -> Result<bool, PermissionError> {
if self.is_granted(ctx.permission_type, &ctx.resource).await {
return Ok(true);
}
self.request_confirmation(ctx).await
}
}
#[derive(Debug)]
pub struct ConfigPermissionChecker {
config: Arc<PermissionConfig>,
}
impl ConfigPermissionChecker {
pub fn new(config: Arc<PermissionConfig>) -> Self {
Self { config }
}
pub fn config(&self) -> &PermissionConfig {
&self.config
}
}
#[async_trait]
impl PermissionChecker for ConfigPermissionChecker {
async fn needs_confirmation(&self, perm_type: PermissionType, resource: &str) -> bool {
self.config.needs_confirmation(perm_type, resource)
}
async fn request_confirmation(&self, _ctx: PermissionContext) -> Result<bool, PermissionError> {
Err(PermissionError::confirmation_required(_ctx))
}
fn grant_session_permission(&self, perm_type: PermissionType, resource: String) {
self.config.grant_session_permission(perm_type, resource);
}
}
#[derive(Debug)]
pub struct LoggingPermissionChecker<T: PermissionChecker> {
inner: T,
}
impl<T: PermissionChecker> LoggingPermissionChecker<T> {
pub fn new(inner: T) -> Self {
Self { inner }
}
}
#[async_trait]
impl<T: PermissionChecker> PermissionChecker for LoggingPermissionChecker<T> {
async fn needs_confirmation(&self, perm_type: PermissionType, resource: &str) -> bool {
let needs = self.inner.needs_confirmation(perm_type, resource).await;
tracing::debug!(
"Permission check: {:?} for '{}' - needs_confirmation: {}",
perm_type,
resource,
needs
);
needs
}
async fn request_confirmation(&self, ctx: PermissionContext) -> Result<bool, PermissionError> {
tracing::info!(
"Requesting user confirmation: {:?} for '{}'",
ctx.permission_type,
ctx.resource
);
let result = self.inner.request_confirmation(ctx).await;
tracing::debug!("User confirmation result: {:?}", result);
result
}
fn grant_session_permission(&self, perm_type: PermissionType, resource: String) {
tracing::info!(
"Granting session permission: {:?} for '{}'",
perm_type,
resource
);
self.inner.grant_session_permission(perm_type, resource);
}
}
#[derive(Debug, Clone)]
pub struct AllowAllPermissionChecker;
#[async_trait]
impl PermissionChecker for AllowAllPermissionChecker {
async fn needs_confirmation(&self, _perm_type: PermissionType, _resource: &str) -> bool {
false
}
async fn request_confirmation(&self, _ctx: PermissionContext) -> Result<bool, PermissionError> {
Ok(true)
}
fn grant_session_permission(&self, _perm_type: PermissionType, _resource: String) {
}
}
#[derive(Debug, Clone)]
pub struct DenyDangerousPermissionChecker;
#[async_trait]
impl PermissionChecker for DenyDangerousPermissionChecker {
async fn needs_confirmation(&self, perm_type: PermissionType, _resource: &str) -> bool {
matches!(perm_type.risk_level(), RiskLevel::High | RiskLevel::Medium)
}
async fn request_confirmation(&self, ctx: PermissionContext) -> Result<bool, PermissionError> {
Err(PermissionError::Denied(format!(
"{} operation denied: {}",
ctx.permission_type.description(),
ctx.resource
)))
}
fn grant_session_permission(&self, _perm_type: PermissionType, _resource: String) {
}
}
const SAFE_EDIT_COMMANDS: &[&str] = &[
"mkdir",
"touch",
"cp",
"mv",
"ls",
"cat",
"echo",
"pwd",
"chmod",
"chown",
"git status",
"git diff",
"git log",
"git add",
"git commit",
"cargo check",
"cargo build",
"cargo test",
"cargo clippy",
"npm run",
"npm test",
"npm install",
];
const COMMAND_WRAPPERS: &[&str] = &["time", "nohup", "timeout", "nice", "env"];
pub fn is_safe_edit_command(command: &str) -> bool {
let trimmed = command.trim();
if trimmed.is_empty() {
return false;
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if tokens.is_empty() {
return false;
}
let mut idx = 0;
while idx < tokens.len() {
let token = tokens[idx];
if !COMMAND_WRAPPERS.contains(&token) {
break;
}
idx += 1;
while idx < tokens.len() {
let next = tokens[idx];
if next.starts_with('-') {
idx += 1;
continue;
}
if COMMAND_WRAPPERS.contains(&next) {
break;
}
if ["timeout", "nice", "env"].contains(&token) {
idx += 1;
}
break;
}
}
if idx >= tokens.len() {
return false;
}
let cmd = tokens[idx..].join(" ");
for &safe_cmd in SAFE_EDIT_COMMANDS {
if cmd == safe_cmd {
return true;
}
if let Some(after) = cmd.strip_prefix(safe_cmd) {
if after.is_empty() || after.starts_with(' ') {
return true;
}
}
}
false
}
pub struct ModeAwarePermissionChecker {
inner: Arc<dyn PermissionChecker>,
config: Arc<PermissionConfig>,
}
impl std::fmt::Debug for ModeAwarePermissionChecker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ModeAwarePermissionChecker")
.field("mode", &self.config.mode())
.finish()
}
}
impl ModeAwarePermissionChecker {
pub fn new(inner: Arc<dyn PermissionChecker>, config: Arc<PermissionConfig>) -> Self {
Self { inner, config }
}
}
#[async_trait]
impl PermissionChecker for ModeAwarePermissionChecker {
async fn needs_confirmation(&self, perm_type: PermissionType, resource: &str) -> bool {
match self.config.mode() {
PermissionMode::BypassPermissions => false,
PermissionMode::Plan => {
perm_type.risk_level() != RiskLevel::Low
}
PermissionMode::AcceptEdits => {
if perm_type == PermissionType::WriteFile {
return false;
}
if perm_type == PermissionType::ExecuteCommand && is_safe_edit_command(resource) {
return false;
}
self.inner.needs_confirmation(perm_type, resource).await
}
PermissionMode::DontAsk => {
!matches!(
self.config.is_whitelist_allowed(perm_type, resource),
Some(true)
)
}
PermissionMode::Default => self.inner.needs_confirmation(perm_type, resource).await,
}
}
async fn request_confirmation(&self, ctx: PermissionContext) -> Result<bool, PermissionError> {
match self.config.mode() {
PermissionMode::BypassPermissions => Ok(true),
PermissionMode::Plan => Err(PermissionError::Denied(format!(
"Plan mode: {} operation blocked for '{}'",
ctx.permission_type.description(),
ctx.resource
))),
PermissionMode::DontAsk => Err(PermissionError::Denied(format!(
"Permission denied (dontAsk mode): {} on '{}'",
ctx.permission_type.description(),
ctx.resource
))),
PermissionMode::AcceptEdits => {
if ctx.permission_type == PermissionType::WriteFile
|| (ctx.permission_type == PermissionType::ExecuteCommand
&& is_safe_edit_command(&ctx.resource))
{
Ok(true)
} else {
self.inner.request_confirmation(ctx).await
}
}
PermissionMode::Default => self.inner.request_confirmation(ctx).await,
}
}
fn grant_session_permission(&self, perm_type: PermissionType, resource: String) {
self.inner.grant_session_permission(perm_type, resource);
}
}
#[async_trait]
pub trait PermissionCheckerExt: PermissionChecker {
async fn check_write_file(&self, path: &str) -> Result<(), PermissionError> {
let ctx = PermissionContext::new(
PermissionType::WriteFile,
path,
format!("Write file: {}", path),
);
if self.check_or_request(ctx).await? {
Ok(())
} else {
Err(PermissionError::Denied(format!(
"Write permission denied for: {}",
path
)))
}
}
async fn check_execute_command(&self, command: &str) -> Result<(), PermissionError> {
let ctx = PermissionContext::new(
PermissionType::ExecuteCommand,
command,
format!("Execute command: {}", command),
);
if self.check_or_request(ctx).await? {
Ok(())
} else {
Err(PermissionError::Denied(format!(
"Command execution denied for: {}",
command
)))
}
}
async fn check_http_request(&self, url: &str) -> Result<(), PermissionError> {
let ctx = PermissionContext::new(
PermissionType::HttpRequest,
url,
format!("HTTP request to: {}", url),
);
if self.check_or_request(ctx).await? {
Ok(())
} else {
Err(PermissionError::Denied(format!(
"HTTP request denied for: {}",
url
)))
}
}
async fn check_delete(&self, path: &str) -> Result<(), PermissionError> {
let ctx = PermissionContext::new(
PermissionType::DeleteOperation,
path,
format!("Delete: {}", path),
);
if self.check_or_request(ctx).await? {
Ok(())
} else {
Err(PermissionError::Denied(format!(
"Delete permission denied for: {}",
path
)))
}
}
async fn check_git_write(&self, operation: &str) -> Result<(), PermissionError> {
let ctx = PermissionContext::new(
PermissionType::GitWrite,
operation,
format!("Git operation: {}", operation),
);
if self.check_or_request(ctx).await? {
Ok(())
} else {
Err(PermissionError::Denied(format!(
"Git write denied for: {}",
operation
)))
}
}
async fn check_terminal_session(&self, command: &str) -> Result<(), PermissionError> {
let ctx = PermissionContext::new(
PermissionType::TerminalSession,
command,
format!("Terminal session: {}", command),
);
if self.check_or_request(ctx).await? {
Ok(())
} else {
Err(PermissionError::Denied(format!(
"Terminal session denied for: {}",
command
)))
}
}
}
#[async_trait]
impl<T: PermissionChecker + ?Sized> PermissionCheckerExt for T {}
#[cfg(test)]
mod tests {
use super::*;
use crate::permission::PermissionRule;
#[tokio::test]
async fn test_allow_all_checker() {
let checker = AllowAllPermissionChecker;
assert!(
!checker
.needs_confirmation(PermissionType::WriteFile, "/tmp/test")
.await
);
assert!(
!checker
.needs_confirmation(PermissionType::ExecuteCommand, "rm -rf /")
.await
);
let ctx = PermissionContext::new(PermissionType::WriteFile, "/tmp/test", "test");
assert!(checker.request_confirmation(ctx).await.unwrap());
}
#[tokio::test]
async fn test_deny_dangerous_checker() {
let checker = DenyDangerousPermissionChecker;
assert!(
checker
.needs_confirmation(PermissionType::WriteFile, "/tmp/test")
.await
);
assert!(
checker
.needs_confirmation(PermissionType::ExecuteCommand, "ls")
.await
);
}
#[tokio::test]
async fn test_config_checker() {
let config = Arc::new(PermissionConfig::new());
let checker = ConfigPermissionChecker::new(config);
assert!(
checker
.needs_confirmation(PermissionType::WriteFile, "/tmp/test")
.await
);
checker.grant_session_permission(PermissionType::WriteFile, "/tmp/*".to_string());
assert!(
!checker
.needs_confirmation(PermissionType::WriteFile, "/tmp/test")
.await
);
}
#[test]
fn test_permission_context() {
let ctx = PermissionContext::new(
PermissionType::WriteFile,
"/tmp/test.txt",
"Write configuration file",
);
assert_eq!(ctx.permission_type, PermissionType::WriteFile);
assert_eq!(ctx.resource, "/tmp/test.txt");
assert!(ctx.operation_description.contains("Write configuration"));
assert_eq!(ctx.risk_level(), RiskLevel::Medium);
let message = ctx.format_request_message();
assert!(message.contains("Medium Risk"));
assert!(message.contains("/tmp/test.txt"));
}
fn mode_aware_setup(mode: PermissionMode) -> ModeAwarePermissionChecker {
let config = Arc::new(PermissionConfig::new());
config.set_mode(mode);
let inner: Arc<dyn PermissionChecker> =
Arc::new(ConfigPermissionChecker::new(config.clone()));
ModeAwarePermissionChecker::new(inner, config)
}
#[tokio::test]
async fn test_mode_default_delegates_to_inner() {
let checker = mode_aware_setup(PermissionMode::Default);
assert!(
checker
.needs_confirmation(PermissionType::WriteFile, "/tmp/test")
.await
);
assert!(
checker
.needs_confirmation(PermissionType::ExecuteCommand, "ls")
.await
);
}
#[tokio::test]
async fn test_mode_bypass_allows_everything() {
let checker = mode_aware_setup(PermissionMode::BypassPermissions);
assert!(
!checker
.needs_confirmation(PermissionType::WriteFile, "/tmp/test")
.await
);
assert!(
!checker
.needs_confirmation(PermissionType::ExecuteCommand, "rm -rf /")
.await
);
assert!(
!checker
.needs_confirmation(PermissionType::DeleteOperation, "/etc/passwd")
.await
);
let ctx = PermissionContext::new(PermissionType::ExecuteCommand, "rm -rf /", "dangerous");
assert!(checker.request_confirmation(ctx).await.is_ok());
}
#[tokio::test]
async fn test_mode_plan_blocks_mutating() {
let checker = mode_aware_setup(PermissionMode::Plan);
assert!(
checker
.needs_confirmation(PermissionType::WriteFile, "/tmp/test")
.await
);
assert!(
checker
.needs_confirmation(PermissionType::ExecuteCommand, "ls")
.await
);
assert!(
checker
.needs_confirmation(PermissionType::DeleteOperation, "/tmp/file")
.await
);
let ctx = PermissionContext::new(PermissionType::WriteFile, "/tmp/test", "write");
let result = checker.request_confirmation(ctx).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Plan mode"));
}
#[tokio::test]
async fn test_mode_accept_edits_auto_approves_writes() {
let checker = mode_aware_setup(PermissionMode::AcceptEdits);
assert!(
!checker
.needs_confirmation(PermissionType::WriteFile, "/tmp/test")
.await
);
assert!(
checker
.needs_confirmation(PermissionType::ExecuteCommand, "rm -rf /")
.await
);
}
#[tokio::test]
async fn test_mode_dont_ask_denies_unless_whitelisted() {
let config = Arc::new(PermissionConfig::new());
config.set_mode(PermissionMode::DontAsk);
config.add_rule(PermissionRule::new(
PermissionType::WriteFile,
"/safe/*",
true,
));
let inner: Arc<dyn PermissionChecker> =
Arc::new(ConfigPermissionChecker::new(config.clone()));
let checker = ModeAwarePermissionChecker::new(inner, config);
assert!(
!checker
.needs_confirmation(PermissionType::WriteFile, "/safe/file.rs")
.await
);
assert!(
checker
.needs_confirmation(PermissionType::WriteFile, "/unsafe/file.rs")
.await
);
let ctx = PermissionContext::new(PermissionType::WriteFile, "/unsafe/file.rs", "write");
let result = checker.request_confirmation(ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("dontAsk"));
}
#[tokio::test]
async fn test_mode_switches_at_runtime() {
let config = Arc::new(PermissionConfig::new());
let inner: Arc<dyn PermissionChecker> =
Arc::new(ConfigPermissionChecker::new(config.clone()));
let checker = ModeAwarePermissionChecker::new(inner, config.clone());
assert!(
checker
.needs_confirmation(PermissionType::WriteFile, "/tmp/test")
.await
);
config.set_mode(PermissionMode::BypassPermissions);
assert!(
!checker
.needs_confirmation(PermissionType::WriteFile, "/tmp/test")
.await
);
config.set_mode(PermissionMode::Plan);
assert!(
checker
.needs_confirmation(PermissionType::WriteFile, "/tmp/test")
.await
);
}
}