use std::path::Path;
use std::sync::{Arc, Mutex};
use crate::permission::allowlist;
use crate::permission::engine;
use crate::permission::path;
use crate::permission::pattern::Pattern;
use crate::permission::{PermissionConfig, SecurityMode};
pub type PermCheck = Arc<Mutex<PermissionChecker>>;
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckResult {
Allowed,
Ask,
Denied(String),
}
fn format_decision(tool: &str, input: &str, decision: &engine::types::Decision) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "why: {tool} {input:?}");
let _ = writeln!(out, " → {:?} ({})", decision.effect, decision.reason());
for e in &decision.trace {
if e.applied {
let eff = e
.effect
.map(|x| format!("{x:?}"))
.unwrap_or_else(|| "—".to_string());
let _ = writeln!(out, " · {:<16} {eff:<6} {}", e.policy, e.why);
} else {
let _ = writeln!(out, " · {:<16} (n/a) {}", e.policy, e.why);
}
}
out
}
#[allow(dead_code)]
fn effect_to_result(decision: engine::types::Decision) -> CheckResult {
use engine::types::Effect;
match decision.effect {
Effect::Allow => CheckResult::Allowed,
Effect::Ask => CheckResult::Ask,
Effect::Deny => CheckResult::Denied(decision.reason()),
}
}
pub struct PermissionChecker {
working_dir: String,
working_dir_canonical: String,
session_allowlist: Vec<(String, Pattern)>,
mode: SecurityMode,
prompt_deny_tools: Vec<String>,
approval_fn: Option<crate::permission::approval::ApprovalFn>,
engine: engine::Engine,
}
pub(crate) fn is_path_tool_name(tool: &str) -> bool {
engine::is_path_tool_name(tool)
}
impl PermissionChecker {
pub fn new(
config: &PermissionConfig,
mode: SecurityMode,
working_dir: Option<std::path::PathBuf>,
) -> Self {
let working_dir = working_dir
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
.to_string_lossy()
.to_string();
let working_dir_canonical = canonicalize_for_cache(&working_dir);
let engine = engine::Engine::from_config(config);
PermissionChecker {
working_dir,
working_dir_canonical,
session_allowlist: Vec::new(),
mode,
prompt_deny_tools: Vec::new(),
approval_fn: None,
engine,
}
}
pub fn set_approval_fn(&mut self, f: crate::permission::approval::ApprovalFn) {
self.approval_fn = Some(f);
}
pub fn approval_fn(&self) -> Option<crate::permission::approval::ApprovalFn> {
self.approval_fn.clone()
}
pub fn authorize_scope(
&mut self,
tool: &str,
input: &str,
is_path: bool,
) -> engine::types::Decision {
let req = self.build_request(tool, input, is_path);
let decision = self.engine.authorize(&req);
self.engine.commit(&req, &decision);
decision
}
pub fn authorize_request(
&mut self,
req: &engine::types::AccessRequest,
) -> engine::types::Decision {
let decision = self.engine.authorize(req);
self.engine.commit(req, &decision);
decision
}
pub fn note_allowed_scope(&mut self, tool: &str, input: &str, is_path: bool) {
let req = self.build_request(tool, input, is_path);
self.engine.note_allowed(&req);
}
pub fn note_allowed_request(&mut self, req: &engine::types::AccessRequest) {
self.engine.note_allowed(req);
}
#[cfg_attr(not(feature = "semantic-bash"), allow(dead_code))]
pub fn working_dir(&self) -> &str {
&self.working_dir
}
pub fn explain(&self, tool: &str, input: &str, is_path: bool) -> String {
let req = self.build_request(tool, input, is_path);
let decision = self.engine.authorize(&req);
format_decision(tool, input, &decision)
}
fn build_request(
&self,
tool: &str,
input: &str,
is_path: bool,
) -> engine::types::AccessRequest {
use engine::types::Resource;
let resource = if is_path {
engine::classify_path(input, &self.working_dir)
} else {
match tool {
"bash" | "shell" => Resource::Command {
raw: input.to_string(),
head: input.split_whitespace().next().unwrap_or("").to_string(),
},
"mcp_tool" => {
let mut parts = input.splitn(3, ':');
let _umbrella = parts.next();
let server = parts.next().unwrap_or("").to_string();
let name = parts.next().unwrap_or("").to_string();
Resource::Mcp {
server,
name,
raw: input.to_string(),
}
}
"webfetch" | "websearch" => Resource::Url(input.to_string()),
_ => Resource::Bareword(input.to_string()),
}
};
engine::types::AccessRequest::single(
tool,
engine::tool_operation(tool),
resource,
self.mode,
input,
)
}
pub fn set_prompt_deny_tools(&mut self, denied: Vec<String>) {
self.engine.ctx_mut().prompt_deny = denied.clone();
self.prompt_deny_tools = denied;
}
fn is_prompt_denied(&self, tool: &str) -> bool {
self.prompt_deny_tools
.iter()
.any(|t| t.eq_ignore_ascii_case(tool))
}
pub fn any_prompt_denied(&self, names: &[&str]) -> bool {
names.iter().any(|n| self.is_prompt_denied(n))
}
#[allow(dead_code)] pub fn check(&mut self, tool: &str, input: &str) -> CheckResult {
effect_to_result(self.authorize_scope(tool, input, false))
}
#[allow(dead_code)] pub fn check_path(&mut self, tool: &str, path: &str) -> CheckResult {
if let Err(reason) = path::validate_path(path) {
return CheckResult::Denied(reason);
}
effect_to_result(self.authorize_scope(tool, path, true))
}
#[allow(dead_code)] fn is_session_allowed(&self, tool: &str, input: &str) -> bool {
allowlist::is_allowed(&self.session_allowlist, tool, input)
}
pub fn session_allows_now(&self, tool: &str, input: &str) -> bool {
let op = engine::tool_operation(tool);
let al = &self.engine.ctx().allowlist;
if is_path_tool_name(tool) {
let abs = resolve_absolute(input, &self.working_dir);
al.allows(op, input) || al.allows(op, &abs)
} else {
al.allows(op, input)
}
}
pub fn add_session_allowlist(&mut self, tool: String, pattern_str: &str) {
register_with_canonical_variant(
&mut self.session_allowlist,
&tool,
pattern_str,
&self.working_dir,
);
let aliases: &[&str] = match tool.as_str() {
"write" => &["edit", "apply_patch"],
"edit" => &["write", "apply_patch"],
"apply_patch" => &["write", "edit"],
_ => &[],
};
for alias in aliases {
register_with_canonical_variant(
&mut self.session_allowlist,
alias,
pattern_str,
&self.working_dir,
);
}
let op = engine::tool_operation(&tool);
self.engine.allow_always(op, pattern_str);
if is_path_tool_name(&tool)
&& let Some(canon) = canonicalize_path_pattern(pattern_str, &self.working_dir)
&& canon != pattern_str
{
self.engine.allow_always(op, &canon);
}
}
pub fn load_session_allowlist(&mut self, entries: &[(String, String)]) {
for (tool, pat) in entries {
self.add_session_allowlist(tool.clone(), pat);
}
}
pub fn allowlist_entries(&self) -> Vec<(String, String)> {
allowlist::entries(&self.session_allowlist)
}
pub fn remove_session_allowlist_at(&mut self, idx: usize) -> Option<(String, String)> {
let removed = allowlist::remove_at(&mut self.session_allowlist, idx)?;
let (tool, pattern_str) = &removed;
let op = engine::tool_operation(tool);
let canon = if is_path_tool_name(tool) {
canonicalize_path_pattern(pattern_str, &self.working_dir).filter(|c| c != pattern_str)
} else {
None
};
let al = &mut self.engine.ctx_mut().allowlist;
al.remove(op, pattern_str);
if let Some(c) = canon {
al.remove(op, &c);
}
Some(removed)
}
pub fn clear_session_allowlist(&mut self) {
allowlist::clear(&mut self.session_allowlist);
self.engine.ctx_mut().allowlist.clear();
}
pub fn set_mode(&mut self, mode: SecurityMode) {
self.mode = mode;
}
pub fn deny_rule_count(&self) -> usize {
self.engine.deny_rule_count()
}
pub fn mode(&self) -> SecurityMode {
self.mode
}
pub fn set_working_dir(&mut self, dir: &str) {
self.working_dir = dir.to_string();
self.working_dir_canonical = canonicalize_for_cache(dir);
self.session_allowlist.clear();
self.engine.ctx_mut().repeat.clear();
self.engine.ctx_mut().allowlist.clear();
}
pub fn is_external_path(&self, path_str: &str) -> bool {
let resolved = resolve_absolute(path_str, &self.working_dir);
let p = Path::new(&resolved);
if !p.is_absolute() {
return false;
}
let cwd = Path::new(&self.working_dir);
let fresh_canonical = canonicalize_for_cache(&self.working_dir);
let canonical_cwd_cached = Path::new(&self.working_dir_canonical);
let canonical_cwd_fresh = Path::new(&fresh_canonical);
!p.starts_with(canonical_cwd_fresh)
&& !p.starts_with(canonical_cwd_cached)
&& !p.starts_with(cwd)
}
}
fn canonicalize_for_cache(working_dir: &str) -> String {
path::canonicalize_for_cache(working_dir)
}
pub(crate) fn resolve_absolute(path: &str, working_dir: &str) -> String {
path::resolve_absolute(path, working_dir)
}
fn register_with_canonical_variant(
allowlist: &mut Vec<(String, crate::permission::pattern::Pattern)>,
tool: &str,
pattern_str: &str,
working_dir: &str,
) {
allowlist::add(allowlist, tool, pattern_str);
if !is_path_tool_name(tool) {
return;
}
if let Some(canonical_pat) = canonicalize_path_pattern(pattern_str, working_dir)
&& canonical_pat != pattern_str
{
allowlist::add(allowlist, tool, &canonical_pat);
}
}
fn canonicalize_path_pattern(pattern_str: &str, working_dir: &str) -> Option<String> {
let split_idx = pattern_str
.find(['*', '?', '[', '{'])
.unwrap_or(pattern_str.len());
if split_idx == 0 {
return None;
}
let (head, tail) = pattern_str.split_at(split_idx);
let (head_trimmed, had_trailing_slash) = match head.strip_suffix('/') {
Some(stripped) => (stripped, true),
None => (head, false),
};
if head_trimmed.is_empty() {
return None;
}
if !std::path::Path::new(head_trimmed).is_absolute() {
let resolved = resolve_absolute(head_trimmed, working_dir);
if resolved != head_trimmed {
let mut out = resolved;
if had_trailing_slash {
out.push('/');
}
out.push_str(tail);
return Some(out);
}
}
let canonical_head = std::fs::canonicalize(head_trimmed)
.ok()
.map(|p| p.to_string_lossy().into_owned())
.or_else(|| {
let resolved = resolve_absolute(head_trimmed, working_dir);
if resolved != head_trimmed {
return Some(resolved);
}
project_canonical_from_existing_ancestor(head_trimmed)
})?;
let mut out = canonical_head;
if had_trailing_slash {
out.push('/');
}
out.push_str(tail);
Some(out)
}
fn project_canonical_from_existing_ancestor(path: &str) -> Option<String> {
let p = std::path::Path::new(path);
let mut tail_components: Vec<&std::ffi::OsStr> = Vec::new();
let mut anchor = p;
loop {
match anchor.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
if let Some(name) = anchor.file_name() {
tail_components.push(name);
}
anchor = parent;
if let Ok(canonical) = std::fs::canonicalize(anchor) {
let mut out = canonical;
for name in tail_components.iter().rev() {
out.push(name);
}
return Some(out.to_string_lossy().into_owned());
}
}
_ => return None,
}
}
}
#[cfg(test)]
#[path = "checker_tests.rs"]
mod tests;