use cersei_tools::permissions::{PermissionDecision, PermissionPolicy, PermissionRequest};
use cersei_tools::PermissionLevel;
use parking_lot::Mutex;
use std::collections::HashSet;
use std::io::{self, Write};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot};
pub type SharedPermissionMode = Arc<AtomicU8>;
pub fn new_shared_mode() -> SharedPermissionMode {
Arc::new(AtomicU8::new(0))
}
pub struct TuiPermissionRequest {
pub tool_name: String,
pub description: String,
pub response_tx: oneshot::Sender<PermissionDecision>,
}
pub fn permission_channel() -> (
mpsc::Sender<TuiPermissionRequest>,
mpsc::Receiver<TuiPermissionRequest>,
) {
mpsc::channel(8)
}
pub struct TuiPermissionPolicy {
session_allowed: Mutex<HashSet<String>>,
always_allowed: Mutex<HashSet<String>>,
mode: SharedPermissionMode,
request_tx: mpsc::Sender<TuiPermissionRequest>,
}
impl TuiPermissionPolicy {
pub fn new(mode: SharedPermissionMode, request_tx: mpsc::Sender<TuiPermissionRequest>) -> Self {
Self {
session_allowed: Mutex::new(HashSet::new()),
always_allowed: Mutex::new(HashSet::new()),
mode,
request_tx,
}
}
}
#[async_trait::async_trait]
impl PermissionPolicy for TuiPermissionPolicy {
async fn check(&self, request: &PermissionRequest) -> PermissionDecision {
let mode = self.mode.load(Ordering::Relaxed);
match mode {
3 => return PermissionDecision::Allow, 4 => {
if request.tool_name == "Bash" || request.tool_name == "PowerShell" {
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("osascript")
.args([
"-e",
&format!(
"display notification \"{}\" with title \"Abstract CLI\"",
request.description.replace('"', "'")
),
])
.spawn();
}
}
return PermissionDecision::Allow;
}
1 => match request.permission_level {
PermissionLevel::None | PermissionLevel::ReadOnly => {
return PermissionDecision::Allow
}
_ => return PermissionDecision::Deny("Plan mode: read-only".into()),
},
2 => {
if request.tool_name == "Bash" || request.tool_name == "PowerShell" {
return PermissionDecision::Deny("Editor mode: shell commands disabled".into());
}
return PermissionDecision::Allow;
}
_ => {}
}
match request.permission_level {
PermissionLevel::None | PermissionLevel::ReadOnly => return PermissionDecision::Allow,
PermissionLevel::Forbidden => {
return PermissionDecision::Deny("Operation is forbidden".into())
}
_ => {}
}
if self.always_allowed.lock().contains(&request.tool_name) {
return PermissionDecision::Allow;
}
if self.session_allowed.lock().contains(&request.tool_name) {
return PermissionDecision::Allow;
}
let (response_tx, response_rx) = oneshot::channel();
let tui_request = TuiPermissionRequest {
tool_name: request.tool_name.clone(),
description: request.description.clone(),
response_tx,
};
if self.request_tx.send(tui_request).await.is_err() {
return PermissionDecision::Deny("TUI channel closed".into());
}
match response_rx.await {
Ok(decision) => {
match &decision {
PermissionDecision::AllowForSession => {
self.session_allowed
.lock()
.insert(request.tool_name.clone());
}
PermissionDecision::Allow => {
self.always_allowed.lock().insert(request.tool_name.clone());
}
_ => {}
}
decision
}
Err(_) => PermissionDecision::Deny("Permission response channel closed".into()),
}
}
}
pub struct CliPermissionPolicy {
session_allowed: Mutex<HashSet<String>>,
always_allowed: Mutex<HashSet<String>>,
mode: SharedPermissionMode,
}
impl CliPermissionPolicy {
pub fn new() -> Self {
Self {
session_allowed: Mutex::new(HashSet::new()),
always_allowed: Mutex::new(HashSet::new()),
mode: new_shared_mode(),
}
}
pub fn with_mode(mode: SharedPermissionMode) -> Self {
Self {
session_allowed: Mutex::new(HashSet::new()),
always_allowed: Mutex::new(HashSet::new()),
mode,
}
}
}
#[async_trait::async_trait]
impl PermissionPolicy for CliPermissionPolicy {
async fn check(&self, request: &PermissionRequest) -> PermissionDecision {
let mode = self.mode.load(Ordering::Relaxed);
match mode {
3 => return PermissionDecision::Allow,
4 => return PermissionDecision::Allow,
1 => match request.permission_level {
PermissionLevel::None | PermissionLevel::ReadOnly => {
return PermissionDecision::Allow
}
_ => return PermissionDecision::Deny("Plan mode: read-only".into()),
},
2 => {
if request.tool_name == "Bash" || request.tool_name == "PowerShell" {
return PermissionDecision::Deny("Editor mode: shell commands disabled".into());
}
return PermissionDecision::Allow;
}
_ => {}
}
match request.permission_level {
PermissionLevel::None | PermissionLevel::ReadOnly => return PermissionDecision::Allow,
PermissionLevel::Forbidden => {
return PermissionDecision::Deny("Operation is forbidden".into())
}
_ => {}
}
if self.always_allowed.lock().contains(&request.tool_name) {
return PermissionDecision::Allow;
}
if self.session_allowed.lock().contains(&request.tool_name) {
return PermissionDecision::Allow;
}
eprint!("\n");
eprint!(
" \x1b[33;1mPermission required: {}\x1b[0m\n",
request.tool_name
);
eprint!(" \x1b[90m{}\x1b[0m\n", request.description);
eprint!(" \x1b[33m[Y]es [N]o [S]ession [A]lways\x1b[0m ");
let _ = io::stderr().flush();
let decision = read_permission_char();
match decision {
'y' | 'Y' | '\n' => PermissionDecision::AllowOnce,
's' | 'S' => {
self.session_allowed
.lock()
.insert(request.tool_name.clone());
PermissionDecision::AllowForSession
}
'a' | 'A' => {
self.always_allowed.lock().insert(request.tool_name.clone());
PermissionDecision::Allow
}
_ => PermissionDecision::Deny("User denied".into()),
}
}
}
fn read_permission_char() -> char {
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use crossterm::terminal;
if terminal::enable_raw_mode().is_ok() {
let result = loop {
if let Ok(Event::Key(KeyEvent { code, .. })) = event::read() {
break match code {
KeyCode::Char(c) => c,
KeyCode::Enter => 'y',
KeyCode::Esc => 'n',
_ => continue,
};
}
};
let _ = terminal::disable_raw_mode();
eprint!("\n");
result
} else {
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
input.trim().chars().next().unwrap_or('n')
}
}