use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use regex::Regex;
use shuvarie_config::{
Mode, PathRule as PathRuleConfig, PermissionsConfig, ShellPatternKind,
ShellRule as ShellRuleConfig, Verb,
};
use tokio::sync::{mpsc, oneshot};
const HIDDEN_ROOT_EXEMPT: [&str; 2] = [".agents", shuvarie_config::WORKSPACE_DIR_NAME];
pub(crate) fn workspace_root() -> Result<PathBuf, String> {
std::env::current_dir().map_err(|e| format!("cwd: {e}"))
}
pub(crate) fn expand_home(path: &str) -> String {
expand_home_in(path, dirs::home_dir().as_deref())
}
pub(crate) fn expand_home_in(path: &str, home: Option<&std::path::Path>) -> String {
if (path == "~" || path.starts_with("~/") || path.starts_with("~\\"))
&& let Some(home) = home
{
let rest = path
.strip_prefix("~/")
.or_else(|| path.strip_prefix("~\\"))
.unwrap_or("");
return home.join(rest).to_string_lossy().into_owned();
}
path.to_string()
}
pub(crate) fn resolve_read(path: &str) -> Result<PathBuf, String> {
let root = workspace_root()?;
let joined = root.join(expand_home(path));
joined.canonicalize().map_err(|e| format!("{path}: {e}"))
}
pub(crate) fn resolve_write(path: &str) -> Result<PathBuf, String> {
resolve_write_in(path, dirs::home_dir().as_deref())
}
pub(crate) fn resolve_write_in(
path: &str,
home: Option<&std::path::Path>,
) -> Result<PathBuf, String> {
let root = workspace_root()?;
let joined = root.join(expand_home_in(path, home));
if joined.exists() {
return joined.canonicalize().map_err(|e| format!("{path}: {e}"));
}
let mut existing = joined.clone();
let mut missing: Vec<std::ffi::OsString> = Vec::new();
while !existing.exists() {
let name = existing
.file_name()
.ok_or_else(|| format!("{path}: invalid path"))?
.to_os_string();
missing.push(name);
existing = existing
.parent()
.ok_or_else(|| format!("{path}: invalid path"))?
.to_path_buf();
}
let mut abs = existing
.canonicalize()
.map_err(|e| format!("{path}: {e}"))?;
for name in missing.iter().rev() {
abs.push(name);
}
Ok(abs)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathKind {
Read,
Write,
}
impl PathKind {
fn action(self) -> &'static str {
match self {
Self::Read => "reading",
Self::Write => "writing",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Allow,
Ask { reason: String },
Deny { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionAnswer {
Allow,
AllowSession,
AllowDirSession,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum AskScope {
Path(PathBuf),
Shell(String),
Tool(String),
Dir(PathBuf),
}
#[derive(Clone, Default)]
pub struct SessionGrants(std::sync::Arc<std::sync::Mutex<HashSet<AskScope>>>);
impl SessionGrants {
fn remember(&self, scope: AskScope) {
self.0
.lock()
.unwrap_or_else(|err| err.into_inner())
.insert(scope);
}
fn contains(&self, scope: &AskScope) -> bool {
self.0
.lock()
.unwrap_or_else(|err| err.into_inner())
.contains(scope)
}
fn granted(&self, scope: &AskScope) -> bool {
if self.contains(scope) {
return true;
}
match scope {
AskScope::Path(path) => path
.ancestors()
.any(|dir| self.contains(&AskScope::Dir(dir.to_path_buf()))),
_ => false,
}
}
}
pub struct PermissionRequest {
pub description: String,
pub scope: Option<AskScope>,
pub respond: oneshot::Sender<PermissionAnswer>,
}
#[derive(Clone, Default)]
pub struct DenyCut(std::sync::Arc<AtomicBool>);
impl DenyCut {
pub fn trigger(&self) {
self.0.store(true, Ordering::SeqCst);
}
pub fn is_set(&self) -> bool {
self.0.load(Ordering::SeqCst)
}
pub fn take(&self) -> bool {
self.0.swap(false, Ordering::SeqCst)
}
pub fn reset(&self) {
self.0.store(false, Ordering::SeqCst);
}
}
#[derive(Clone)]
pub struct PermissionGate {
tx: mpsc::Sender<PermissionRequest>,
grants: SessionGrants,
}
impl PermissionGate {
pub fn new(tx: mpsc::Sender<PermissionRequest>) -> Self {
Self {
tx,
grants: SessionGrants::default(),
}
}
pub async fn request(
&self,
description: String,
scope: Option<AskScope>,
) -> Result<bool, String> {
if scope
.as_ref()
.is_some_and(|scope| self.grants.granted(scope))
{
return Ok(true);
}
let (respond, rx) = oneshot::channel();
self.tx
.send(PermissionRequest {
description,
scope: scope.clone(),
respond,
})
.await
.map_err(|_| "permission channel closed".to_string())?;
let answer = rx
.await
.map_err(|_| "permission responder dropped".to_string())?;
let remembered = match (answer, scope) {
(PermissionAnswer::AllowSession, Some(scope)) => Some(scope),
(PermissionAnswer::AllowDirSession, Some(AskScope::Path(path))) => {
path.parent().map(|dir| AskScope::Dir(dir.to_path_buf()))
}
_ => None,
};
if let Some(scope) = remembered {
self.grants.remember(scope);
}
Ok(!matches!(answer, PermissionAnswer::Deny))
}
}
struct CompiledPathRule {
verb: Verb,
raw: String,
path: PathBuf,
exact: bool,
except_hidden: bool,
mode: Mode,
}
struct CompiledShellRule {
verb: Verb,
raw: String,
matcher: ShellMatcher,
}
#[derive(Clone)]
pub(crate) enum ShellMatcher {
Raw(String),
Regex(Regex),
}
impl ShellMatcher {
pub(crate) fn compile(pattern: &str, kind: ShellPatternKind) -> Result<Self, String> {
match kind {
ShellPatternKind::Raw => Ok(Self::Raw(collapse_whitespace(pattern))),
ShellPatternKind::Regex => Regex::new(pattern)
.map(Self::Regex)
.map_err(|e| format!("invalid regex in shell-patterns rule \"{pattern}\": {e}")),
}
}
pub(crate) fn matches(&self, command: &str, collapsed: &str) -> bool {
match self {
Self::Raw(needle) => contains_word(collapsed, needle),
Self::Regex(re) => re.is_match(command),
}
}
}
pub struct Permissions {
default: Verb,
paths_default: Option<Verb>,
shell_default: Option<Verb>,
paths: Vec<CompiledPathRule>,
shell: Vec<CompiledShellRule>,
read_exempt: Vec<PathBuf>,
}
impl std::fmt::Debug for Permissions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Permissions")
.field("default", &self.default)
.field("paths_default", &self.paths_default)
.field("shell_default", &self.shell_default)
.field("path_rules", &self.paths.len())
.field("shell_rules", &self.shell.len())
.finish()
}
}
impl Permissions {
pub fn build(config: &PermissionsConfig, workspace_root: &Path) -> Result<Self, String> {
let paths = config
.paths
.rules
.iter()
.map(|rule| CompiledPathRule::build(rule, workspace_root))
.collect::<Result<Vec<_>, String>>()?;
let shell = config
.shell
.rules
.iter()
.map(CompiledShellRule::build)
.collect::<Result<Vec<_>, String>>()?;
Ok(Self {
default: config.default.unwrap_or(Verb::Ask),
paths_default: config.paths.default,
shell_default: config.shell.default,
paths,
shell,
read_exempt: crate::skills::global_skill_dirs(
dirs::home_dir().as_deref(),
shuvarie_config::config_dir().ok().as_deref(),
),
})
}
pub fn check_path(&self, kind: PathKind, path: &Path) -> Decision {
if kind == PathKind::Read && self.read_exempt.iter().any(|root| path.starts_with(root)) {
return Decision::Allow;
}
for rule in &self.paths {
if rule.mode == Mode::Ro && kind == PathKind::Write {
continue;
}
if let Some(decision) = rule.check(path) {
return decision;
}
}
let (fallback, reason) = match self.paths_default {
Some(verb) => (verb, format!("paths fallback: {}-all", verb.as_str())),
None => (
self.default,
format!("permissions default: {}-all", self.default.as_str()),
),
};
verb_decision(fallback, reason)
}
pub fn check_shell(&self, command: &str) -> Decision {
let collapsed = collapse_whitespace(command);
for rule in &self.shell {
if rule.matches(command, &collapsed) {
return verb_decision(
rule.verb,
format!("shell-patterns: {} \"{}\"", rule.verb.as_str(), rule.raw),
);
}
}
let (fallback, reason) = match self.shell_default {
Some(verb) => (
verb,
format!("shell-patterns fallback: {}-all", verb.as_str()),
),
None => (
self.default,
format!("permissions default: {}-all", self.default.as_str()),
),
};
verb_decision(fallback, reason)
}
pub fn check_tool(&self, _tool: &str) -> Decision {
verb_decision(
self.default,
format!("permissions default: {}-all", self.default.as_str()),
)
}
pub(crate) fn compose_tool(&self, tool: &str, scene_ask: Option<&str>) -> Decision {
compose_scene_ask(scene_ask, self.check_tool(tool))
}
pub(crate) async fn settle_tool(
&self,
gate: &PermissionGate,
cut: &DenyCut,
tool: &str,
detail: &str,
decision: Decision,
) -> Result<(), String> {
match decision {
Decision::Allow => Ok(()),
Decision::Deny { reason } => {
cut.trigger();
Err(format!("permission denied: {reason}"))
}
Decision::Ask { reason } => {
let description = format!("Allow tool `{tool}`?\n{detail}\n{reason}");
match gate
.request(description, Some(AskScope::Tool(tool.to_string())))
.await
{
Ok(true) => Ok(()),
Ok(false) => {
cut.trigger();
Err(format!("permission denied by the user: {reason}"))
}
Err(err) => Err(err),
}
}
}
}
pub async fn authorize_path(
&self,
gate: &PermissionGate,
cut: &DenyCut,
kind: PathKind,
path: &Path,
display: &str,
scene_ask: Option<&str>,
) -> Result<(), String> {
let decision = compose_scene_ask(scene_ask, self.check_path(kind, path));
match decision {
Decision::Allow => Ok(()),
Decision::Deny { reason } => {
cut.trigger();
Err(format!("permission denied: {reason}"))
}
Decision::Ask { reason } => {
let description = format!("Allow {} `{display}`?\n{reason}", kind.action());
let scope = AskScope::Path(path.to_path_buf());
match gate.request(description, Some(scope)).await {
Ok(true) => Ok(()),
Ok(false) => {
cut.trigger();
Err(format!("permission denied by the user: {reason}"))
}
Err(err) => Err(err),
}
}
}
}
pub async fn confirm_scene(
&self,
gate: &PermissionGate,
cut: &DenyCut,
reason: &str,
) -> Result<(), String> {
match gate.request(reason.to_string(), None).await {
Ok(true) => Ok(()),
Ok(false) => {
cut.trigger();
Err(format!("denied by the user: {reason}"))
}
Err(err) => Err(err),
}
}
pub(crate) fn compose_shell(&self, command: &str, scene_ask: Option<&str>) -> Decision {
compose_scene_ask(scene_ask, self.check_shell(command))
}
pub(crate) async fn settle_shell(
&self,
gate: &PermissionGate,
cut: &DenyCut,
command: &str,
decision: Decision,
) -> Result<(), String> {
match decision {
Decision::Allow => Ok(()),
Decision::Deny { reason } => {
cut.trigger();
Err(format!("permission denied: {reason}"))
}
Decision::Ask { reason } => {
let description = format!("Allow running this command?\n{command}\n{reason}");
let scope = AskScope::Shell(collapse_whitespace(command));
match gate.request(description, Some(scope)).await {
Ok(true) => Ok(()),
Ok(false) => {
cut.trigger();
Err(format!("permission denied by the user: {reason}"))
}
Err(err) => Err(err),
}
}
}
}
pub async fn authorize_shell(
&self,
gate: &PermissionGate,
cut: &DenyCut,
command: &str,
scene_ask: Option<&str>,
) -> Result<(), String> {
let decision = self.compose_shell(command, scene_ask);
self.settle_shell(gate, cut, command, decision).await
}
pub async fn authorize_tool(
&self,
gate: &PermissionGate,
cut: &DenyCut,
tool: &str,
detail: &str,
scene_ask: Option<&str>,
) -> Result<(), String> {
let decision = self.compose_tool(tool, scene_ask);
self.settle_tool(gate, cut, tool, detail, decision).await
}
pub fn output_interrupt(&self) -> Option<OutputInterrupt> {
let rules = self
.shell
.iter()
.filter(|rule| rule.verb == Verb::Deny)
.map(|rule| (rule.matcher.clone(), rule.raw.clone()))
.collect::<Vec<_>>();
(!rules.is_empty()).then_some(OutputInterrupt { rules })
}
}
impl CompiledPathRule {
fn build(rule: &PathRuleConfig, root: &Path) -> Result<Self, String> {
let expanded = expand_home(&rule.path);
let candidate = if Path::new(&expanded).is_absolute() {
PathBuf::from(&expanded)
} else {
root.join(&expanded)
};
let normalized = normalize(&candidate);
let path = std::fs::canonicalize(&normalized).unwrap_or(normalized);
Ok(Self {
verb: rule.verb,
raw: rule.path.clone(),
path,
exact: rule.exact,
except_hidden: rule.except_hidden,
mode: rule.mode,
})
}
fn check(&self, path: &Path) -> Option<Decision> {
if !self.matches(path) {
return None;
}
Some(verb_decision(
self.verb,
format!("paths: {} \"{}\"", self.verb.as_str(), self.raw),
))
}
fn matches(&self, path: &Path) -> bool {
let matched = if self.exact {
path == self.path
} else {
path.starts_with(&self.path)
};
if !matched {
return false;
}
if !self.except_hidden {
return true;
}
let rel = path.strip_prefix(&self.path).unwrap_or(path);
rel.components().enumerate().all(|(i, comp)| {
let s = comp.as_os_str().to_string_lossy();
!(s.starts_with('.') && s != "." && s != "..")
|| (i == 0 && HIDDEN_ROOT_EXEMPT.contains(&&*s))
})
}
}
impl CompiledShellRule {
fn build(rule: &ShellRuleConfig) -> Result<Self, String> {
Ok(Self {
verb: rule.verb,
raw: rule.pattern.clone(),
matcher: ShellMatcher::compile(&rule.pattern, rule.kind)?,
})
}
fn matches(&self, command: &str, collapsed: &str) -> bool {
match &self.matcher {
ShellMatcher::Raw(needle) => contains_word(collapsed, needle),
ShellMatcher::Regex(re) => re.is_match(command),
}
}
}
impl ShellMatcher {
fn matches_text(&self, text: &str) -> bool {
match self {
Self::Raw(needle) => text.contains(needle),
Self::Regex(re) => re.is_match(text),
}
}
}
pub struct OutputInterrupt {
rules: Vec<(ShellMatcher, String)>,
}
impl OutputInterrupt {
pub fn check(&self, text: &str) -> Option<String> {
self.rules.iter().find_map(|(matcher, raw)| {
matcher
.matches_text(text)
.then(|| format!("shell-patterns: deny \"{raw}\" (matched command output)"))
})
}
}
fn verb_decision(verb: Verb, reason: String) -> Decision {
match verb {
Verb::Allow => Decision::Allow,
Verb::Ask => Decision::Ask { reason },
Verb::Deny => Decision::Deny { reason },
}
}
fn compose_scene_ask(scene_ask: Option<&str>, decision: Decision) -> Decision {
match (scene_ask, decision) {
(Some(reason), Decision::Allow) => Decision::Ask {
reason: reason.to_string(),
},
(_, decision) => decision,
}
}
#[derive(Clone)]
pub struct Access {
permissions: std::sync::Arc<Permissions>,
gate: PermissionGate,
deny_cut: DenyCut,
decisions: Option<crate::decisions::SharedDecisions>,
scene_ask: Option<String>,
}
impl Access {
pub fn new(
permissions: std::sync::Arc<Permissions>,
gate: PermissionGate,
deny_cut: DenyCut,
) -> Self {
Self {
permissions,
gate,
deny_cut,
decisions: None,
scene_ask: None,
}
}
pub fn with_decisions(mut self, decisions: crate::decisions::SharedDecisions) -> Self {
self.decisions = Some(decisions);
self
}
pub fn for_tool(&self, tool: &str, scene: &crate::scenes::ToolScene) -> Self {
Self {
scene_ask: scene
.forces_ask(tool)
.then(|| format!("scene requires confirmation for `{tool}`")),
..self.clone()
}
}
pub fn scene_ask_reason(&self) -> Option<&str> {
self.scene_ask.as_deref()
}
pub fn decisions(&self) -> Option<&std::sync::Arc<crate::decisions::Decisions>> {
self.decisions.as_ref()
}
pub async fn authorize_path(
&self,
kind: PathKind,
path: &Path,
display: &str,
) -> Result<(), String> {
self.permissions
.authorize_path(
&self.gate,
&self.deny_cut,
kind,
path,
display,
self.scene_ask.as_deref(),
)
.await
}
pub async fn authorize_shell(&self, command: &str) -> Result<(), String> {
let verdict = self
.permissions
.compose_shell(command, self.scene_ask.as_deref());
let verdict = match &self.decisions {
Some(decisions) => decisions.check_shell(command, verdict).await,
None => verdict,
};
self.permissions
.settle_shell(&self.gate, &self.deny_cut, command, verdict)
.await
}
pub async fn authorize_tool(&self, tool: &str, detail: &str) -> Result<(), String> {
let verdict = self
.permissions
.compose_tool(tool, self.scene_ask.as_deref());
let verdict = match &self.decisions {
Some(decisions) => decisions.check_tool(tool, detail, verdict).await,
None => verdict,
};
self.permissions
.settle_tool(&self.gate, &self.deny_cut, tool, detail, verdict)
.await
}
pub async fn confirm_scene(&self, reason: &str) -> Result<(), String> {
self.permissions
.confirm_scene(&self.gate, &self.deny_cut, reason)
.await
}
pub fn output_interrupt(&self) -> Option<OutputInterrupt> {
self.permissions.output_interrupt()
}
pub fn trigger_cut(&self) {
self.deny_cut.trigger();
}
pub fn turn_cut(&self) -> &DenyCut {
&self.deny_cut
}
}
pub(crate) fn collapse_whitespace(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut pending_space = false;
for c in text.chars() {
if c.is_whitespace() {
pending_space = true;
} else {
if pending_space && !out.is_empty() {
out.push(' ');
}
pending_space = false;
out.push(c);
}
}
out
}
fn contains_word(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return false;
}
let mut start = 0;
while let Some(pos) = haystack[start..].find(needle) {
let at = start + pos;
let end = at + needle.len();
let left_ok = at == 0 || !haystack[..at].ends_with(|c: char| c.is_ascii_alphanumeric());
let right_ok = end == haystack.len()
|| !haystack[end..].starts_with(|c: char| c.is_ascii_alphanumeric());
if left_ok && right_ok {
return true;
}
start = end;
}
false
}
fn normalize(path: &Path) -> PathBuf {
let mut parts: Vec<std::ffi::OsString> = Vec::new();
let mut absolute = false;
for component in path.components() {
match component {
Component::Prefix(prefix) => parts.push(prefix.as_os_str().to_os_string()),
Component::RootDir => {
absolute = true;
parts.clear();
}
Component::CurDir => {}
Component::ParentDir => {
let popped = !parts.is_empty()
&& parts.last().is_some_and(|p| p != "..")
&& parts.pop().is_some();
if !popped && !absolute {
parts.push("..".into());
}
}
Component::Normal(name) => parts.push(name.to_os_string()),
}
}
let mut out = PathBuf::new();
if absolute {
out.push("/");
}
for part in parts {
out.push(part);
}
if out.as_os_str().is_empty() {
out.push(".");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use shuvarie_config::{
Mode, PathRule, PermissionsConfig, RuleSet, ShellPatternKind, ShellRule, Verb,
};
fn builtin() -> Permissions {
Permissions::build(&PermissionsConfig::builtin(), Path::new("/ws")).unwrap()
}
fn config(default: Option<Verb>, paths: Vec<PathRule>, shell: Vec<ShellRule>) -> Permissions {
config_scoped(default, None, paths, None, shell)
}
fn config_scoped(
default: Option<Verb>,
paths_default: Option<Verb>,
paths: Vec<PathRule>,
shell_default: Option<Verb>,
shell: Vec<ShellRule>,
) -> Permissions {
Permissions::build(
&PermissionsConfig {
default,
paths: RuleSet {
default: paths_default,
rules: paths,
},
checks: Vec::new(),
tool_check: None,
shell: RuleSet {
default: shell_default,
rules: shell,
},
},
Path::new("/ws"),
)
.unwrap()
}
fn path_rule(verb: Verb, path: &str) -> PathRule {
PathRule {
verb,
path: path.to_string(),
except_hidden: false,
exact: false,
mode: Mode::Rw,
}
}
fn shell_rule(verb: Verb, pattern: &str) -> ShellRule {
ShellRule {
verb,
pattern: pattern.to_string(),
kind: ShellPatternKind::Raw,
}
}
#[test]
fn builtin_paths_allow_workspace_but_ask_hidden_and_outside() {
let perms = builtin();
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/src/main.rs")),
Decision::Allow
);
assert_eq!(
perms.check_path(PathKind::Write, Path::new("/ws/src/main.rs")),
Decision::Allow
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/.env")),
Decision::Ask {
reason: "permissions default: ask-all".into()
}
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/etc/hosts")),
Decision::Ask {
reason: "permissions default: ask-all".into()
}
);
}
#[test]
fn builtin_keeps_root_exemptions_and_global_skill_reads() {
let perms = builtin();
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/.agents/skills/x")),
Decision::Allow
);
assert_eq!(
perms.check_path(
PathKind::Write,
Path::new(&format!(
"/ws/{}/data.db",
shuvarie_config::WORKSPACE_DIR_NAME
))
),
Decision::Allow
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/.agents/.secrets/key")),
Decision::Allow
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/sub/.agents/x")),
Decision::Ask {
reason: "permissions default: ask-all".into()
}
);
let Some(home) = dirs::home_dir() else {
return;
};
assert_eq!(
perms.check_path(PathKind::Read, &home.join(".agents/skills/tokio/SKILL.md")),
Decision::Allow
);
}
#[test]
fn builtin_allows_agent_and_git_dot_paths_without_asking() {
let perms = builtin();
for dir in [".github", ".gitlab", ".gitea", ".forgejo"] {
let visible = format!("/ws/{dir}/workflows/ci.yml");
assert_eq!(
perms.check_path(PathKind::Read, Path::new(&visible)),
Decision::Allow,
"{dir}"
);
let hidden = format!("/ws/{dir}/.hidden");
assert_eq!(
perms.check_path(PathKind::Read, Path::new(&hidden)),
Decision::Allow,
"{dir}"
);
}
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/.gitignore")),
Decision::Allow
);
assert_eq!(
perms.check_path(PathKind::Write, Path::new("/ws/.git/config")),
Decision::Allow
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/.gitlab-ci.yml")),
Decision::Ask {
reason: "permissions default: ask-all".into()
}
);
}
#[test]
fn first_match_wins_with_scope_fallback() {
let perms = config(
Some(Verb::Allow),
vec![
path_rule(Verb::Ask, "/ws/secrets/public"),
path_rule(Verb::Deny, "/ws/secrets"),
],
vec![shell_rule(Verb::Ask, "make")],
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/secrets/key")),
Decision::Deny {
reason: "paths: deny \"/ws/secrets\"".into()
}
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/secrets/public/x")),
Decision::Ask {
reason: "paths: ask \"/ws/secrets/public\"".into()
}
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/other")),
Decision::Allow
);
assert_eq!(
perms.check_shell("make build"),
Decision::Ask {
reason: "shell-patterns: ask \"make\"".into()
}
);
assert_eq!(perms.check_shell("cargo test"), Decision::Allow);
}
#[test]
fn exact_rules_match_only_the_path_itself() {
let perms = config_scoped(
Some(Verb::Deny),
Some(Verb::Deny),
vec![PathRule {
verb: Verb::Allow,
path: "/ws/file".into(),
except_hidden: false,
exact: true,
mode: Mode::Rw,
}],
None,
Vec::new(),
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/file")),
Decision::Allow
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/file/sub")),
Decision::Deny {
reason: "paths fallback: deny-all".into()
}
);
}
#[test]
fn except_hidden_skips_rules_for_hidden_targets() {
let perms = config_scoped(
Some(Verb::Deny),
Some(Verb::Deny),
vec![PathRule {
verb: Verb::Allow,
path: "/ws".into(),
except_hidden: true,
exact: false,
mode: Mode::Rw,
}],
None,
Vec::new(),
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/x")),
Decision::Allow
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/.env")),
Decision::Deny {
reason: "paths fallback: deny-all".into()
}
);
}
#[test]
fn tilde_expands_at_build_time() {
let Some(home) = dirs::home_dir() else {
return;
};
let perms = config_scoped(
Some(Verb::Deny),
Some(Verb::Deny),
vec![path_rule(Verb::Allow, "~/.ssh")],
None,
Vec::new(),
);
assert_eq!(
perms.check_path(PathKind::Read, &home.join(".ssh/id_rsa")),
Decision::Allow
);
assert_eq!(
perms.check_path(PathKind::Read, &home.join(".ssh/hosts/d")),
Decision::Allow
);
assert_eq!(
perms.check_path(PathKind::Read, &home.join(".sshrc")),
Decision::Deny {
reason: "paths fallback: deny-all".into()
}
);
}
#[test]
fn raw_shell_patterns_match_with_word_boundaries() {
let perms = config(
Some(Verb::Allow),
Vec::new(),
vec![
shell_rule(Verb::Ask, "rm"),
shell_rule(Verb::Deny, "git push --force"),
],
);
for command in ["rm -rf /", "echo x;rm y", "sudo rm x"] {
assert_eq!(
perms.check_shell(command),
Decision::Ask {
reason: "shell-patterns: ask \"rm\"".into()
},
"{command}"
);
}
assert_eq!(perms.check_shell("firm -rf /"), Decision::Allow);
assert_eq!(perms.check_shell("echo rmrf"), Decision::Allow);
assert_eq!(
perms.check_shell("git push --force origin main"),
Decision::Deny {
reason: "shell-patterns: deny \"git push --force\"".into()
}
);
assert_eq!(
perms.check_shell("git\tpush\n--force"),
Decision::Deny {
reason: "shell-patterns: deny \"git push --force\"".into()
}
);
assert_eq!(perms.check_shell("git push"), Decision::Allow);
}
#[test]
fn regex_rules_match_as_written() {
let perms = Permissions::build(
&PermissionsConfig {
default: Some(Verb::Allow),
paths: RuleSet::default(),
checks: Vec::new(),
tool_check: None,
shell: RuleSet {
default: None,
rules: vec![ShellRule {
verb: Verb::Deny,
pattern: "rm (-rf|-fr|--force --recursive)".to_string(),
kind: ShellPatternKind::Regex,
}],
},
},
Path::new("/ws"),
)
.unwrap();
for command in ["rm -rf /tmp", "rm --force --recursive /tmp"] {
assert_eq!(
perms.check_shell(command),
Decision::Deny {
reason: "shell-patterns: deny \"rm (-rf|-fr|--force --recursive)\"".into()
},
"{command}"
);
}
assert_eq!(perms.check_shell("rm /tmp"), Decision::Allow);
}
#[test]
fn invalid_regex_fails_build() {
let err = Permissions::build(
&PermissionsConfig {
default: None,
paths: RuleSet::default(),
checks: Vec::new(),
tool_check: None,
shell: RuleSet {
default: None,
rules: vec![ShellRule {
verb: Verb::Deny,
pattern: "rm (".to_string(),
kind: ShellPatternKind::Regex,
}],
},
},
Path::new("/ws"),
)
.unwrap_err();
assert!(err.contains("invalid regex"), "{err}");
}
#[test]
fn path_rules_never_gate_shell_commands() {
let perms = config_scoped(
Some(Verb::Ask),
None,
vec![
PathRule {
verb: Verb::Deny,
path: "~/.ssh".into(),
except_hidden: false,
exact: false,
mode: Mode::Rw,
},
PathRule {
verb: Verb::Allow,
path: "/ws".into(),
except_hidden: false,
exact: false,
mode: Mode::Ro,
},
],
Some(Verb::Deny),
Vec::new(),
);
assert_eq!(
perms.check_shell("cat ~/.ssh/id_rsa"),
Decision::Deny {
reason: "shell-patterns fallback: deny-all".into()
},
"a path deny never bridges into shell text"
);
assert_eq!(
perms.check_shell("cat /ws/file"),
Decision::Deny {
reason: "shell-patterns fallback: deny-all".into()
},
"a ro file allowance never grants a command"
);
}
#[test]
fn mode_ro_rules_match_reads_but_skip_writes() {
let perms = config_scoped(
Some(Verb::Deny),
Some(Verb::Deny),
vec![PathRule {
verb: Verb::Allow,
path: "/ws".into(),
except_hidden: false,
exact: false,
mode: Mode::Ro,
}],
None,
Vec::new(),
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/file")),
Decision::Allow
);
assert_eq!(
perms.check_path(PathKind::Write, Path::new("/ws/file")),
Decision::Deny {
reason: "paths fallback: deny-all".into()
},
"ro rules never decide writes"
);
}
#[test]
fn mode_ro_rules_fall_through_to_later_rules() {
let perms = config_scoped(
Some(Verb::Deny),
None,
vec![
PathRule {
verb: Verb::Allow,
path: "/ws".into(),
except_hidden: false,
exact: false,
mode: Mode::Ro,
},
path_rule(Verb::Ask, "/ws"),
],
None,
Vec::new(),
);
assert_eq!(
perms.check_path(PathKind::Read, Path::new("/ws/file")),
Decision::Allow,
"first matching ro rule wins for reads"
);
assert_eq!(
perms.check_path(PathKind::Write, Path::new("/ws/file")),
Decision::Ask {
reason: "paths: ask \"/ws\"".into()
},
"writes reach the next rule"
);
}
#[test]
fn builtin_shell_allows_all_commands() {
let perms = builtin();
assert_eq!(perms.check_shell("rm -rf target"), Decision::Allow);
assert_eq!(perms.check_shell("sudo apt install"), Decision::Allow);
assert_eq!(perms.check_shell("cargo build"), Decision::Allow);
}
#[tokio::test]
async fn authorize_triggers_the_cut_on_every_denial() {
let (tx, rx) = mpsc::channel::<PermissionRequest>(8);
let gate = PermissionGate::new(tx);
let cut = DenyCut::default();
let perms = std::sync::Arc::new(config_scoped(
Some(Verb::Allow),
Some(Verb::Ask),
vec![],
None,
vec![shell_rule(Verb::Deny, "sudo")],
));
let rx = std::sync::Arc::new(tokio::sync::Mutex::new(rx));
let answer = |allow: PermissionAnswer| {
let rx = rx.clone();
async move {
rx.lock()
.await
.recv()
.await
.unwrap()
.respond
.send(allow)
.ok()
}
};
let cut1 = cut.clone();
let err = perms
.authorize_shell(&gate, &cut1, "sudo apt install", None)
.await
.unwrap_err();
assert!(err.contains("shell-patterns: deny"), "{err}");
assert!(cut1.is_set(), "rule deny triggers the cut");
assert!(cut.take());
let cut2 = cut.clone();
let perms2 = perms.clone();
let gate2 = gate.clone();
let allow = answer(PermissionAnswer::Deny);
let denied = tokio::spawn(async move {
perms2
.authorize_path(
&gate2,
&cut2,
PathKind::Read,
Path::new("/etc/hosts"),
"/etc/hosts",
None,
)
.await
});
allow.await.unwrap();
let err = denied.await.unwrap().unwrap_err();
assert!(err.contains("denied by the user"), "{err}");
assert!(cut.is_set(), "user denial triggers the cut");
assert!(cut.take());
let cut3 = cut.clone();
let perms3 = perms.clone();
let gate3 = gate.clone();
let allow = answer(PermissionAnswer::Allow);
let granted = tokio::spawn(async move {
perms3
.authorize_path(
&gate3,
&cut3,
PathKind::Read,
Path::new("/etc/hosts"),
"/etc/hosts",
None,
)
.await
});
allow.await.unwrap();
assert!(granted.await.unwrap().is_ok());
assert!(!cut.is_set(), "allowing never triggers the cut");
}
#[tokio::test]
async fn session_grant_skips_repeated_path_asks() {
let (tx, mut rx) = mpsc::channel::<PermissionRequest>(8);
let gate = PermissionGate::new(tx);
let cut = DenyCut::default();
let perms = std::sync::Arc::new(config_scoped(
Some(Verb::Allow),
Some(Verb::Ask),
vec![],
None,
vec![],
));
let first = {
let perms = perms.clone();
let gate = gate.clone();
let cut = cut.clone();
tokio::spawn(async move {
perms
.authorize_path(
&gate,
&cut,
PathKind::Read,
Path::new("/etc/hosts"),
"/etc/hosts",
None,
)
.await
})
};
let request = rx.recv().await.unwrap();
assert!(matches!(
request.scope,
Some(AskScope::Path(ref path)) if path == Path::new("/etc/hosts")
));
request.respond.send(PermissionAnswer::AllowSession).ok();
assert!(first.await.unwrap().is_ok());
perms
.authorize_path(
&gate,
&cut,
PathKind::Read,
Path::new("/etc/hosts"),
"/etc/hosts",
None,
)
.await
.unwrap();
perms
.authorize_path(
&gate,
&cut,
PathKind::Write,
Path::new("/etc/hosts"),
"/etc/hosts",
None,
)
.await
.unwrap();
assert!(
rx.try_recv().is_err(),
"granted asks must not surface again"
);
let other = {
let perms = perms.clone();
let gate = gate.clone();
let cut = cut.clone();
tokio::spawn(async move {
perms
.authorize_path(
&gate,
&cut,
PathKind::Read,
Path::new("/etc/passwd"),
"/etc/passwd",
None,
)
.await
})
};
let request = rx.recv().await.unwrap();
assert!(matches!(
request.scope,
Some(AskScope::Path(ref path)) if path == Path::new("/etc/passwd")
));
request.respond.send(PermissionAnswer::Allow).ok();
assert!(other.await.unwrap().is_ok());
}
#[tokio::test]
async fn dir_session_grant_covers_the_folder_tree() {
let (tx, mut rx) = mpsc::channel::<PermissionRequest>(8);
let gate = PermissionGate::new(tx);
let cut = DenyCut::default();
let perms = std::sync::Arc::new(config_scoped(
Some(Verb::Allow),
Some(Verb::Ask),
vec![path_rule(Verb::Deny, "/etc/net/hosts.deny")],
None,
vec![],
));
let first = {
let perms = perms.clone();
let gate = gate.clone();
let cut = cut.clone();
tokio::spawn(async move {
perms
.authorize_path(
&gate,
&cut,
PathKind::Read,
Path::new("/etc/net/hosts"),
"/etc/net/hosts",
None,
)
.await
})
};
let request = rx.recv().await.unwrap();
assert!(matches!(
request.scope,
Some(AskScope::Path(ref path)) if path == Path::new("/etc/net/hosts")
));
request.respond.send(PermissionAnswer::AllowDirSession).ok();
assert!(first.await.unwrap().is_ok());
perms
.authorize_path(
&gate,
&cut,
PathKind::Read,
Path::new("/etc/net/hosts.allow"),
"/etc/net/hosts.allow",
None,
)
.await
.unwrap();
perms
.authorize_path(
&gate,
&cut,
PathKind::Write,
Path::new("/etc/net/conf.d/base"),
"/etc/net/conf.d/base",
None,
)
.await
.unwrap();
perms
.authorize_path(
&gate,
&cut,
PathKind::Read,
Path::new("/etc/net"),
"/etc/net",
None,
)
.await
.unwrap();
assert!(
rx.try_recv().is_err(),
"a folder grant must keep covering the folder"
);
let deny = {
let perms = perms.clone();
let gate = gate.clone();
let cut = cut.clone();
tokio::spawn(async move {
perms
.authorize_path(
&gate,
&cut,
PathKind::Read,
Path::new("/etc/net/hosts.deny"),
"/etc/net/hosts.deny",
None,
)
.await
})
};
let err = deny.await.unwrap().unwrap_err();
assert!(err.contains("paths: deny"), "{err}");
assert!(cut.is_set(), "the denied call still cuts the turn");
assert!(cut.take());
let other = {
let perms = perms.clone();
let gate = gate.clone();
let cut = cut.clone();
tokio::spawn(async move {
perms
.authorize_path(
&gate,
&cut,
PathKind::Read,
Path::new("/etc/passwd"),
"/etc/passwd",
None,
)
.await
})
};
let request = rx.recv().await.unwrap();
assert!(matches!(
request.scope,
Some(AskScope::Path(ref path)) if path == Path::new("/etc/passwd")
));
request.respond.send(PermissionAnswer::Allow).ok();
assert!(other.await.unwrap().is_ok());
}
#[tokio::test]
async fn dir_answer_on_non_path_asks_stays_one_shot() {
let (tx, mut rx) = mpsc::channel::<PermissionRequest>(8);
let gate = PermissionGate::new(tx);
let cut = DenyCut::default();
let perms = std::sync::Arc::new(config_scoped(
Some(Verb::Allow),
None,
vec![],
Some(Verb::Ask),
vec![],
));
let first = {
let perms = perms.clone();
let gate = gate.clone();
let cut = cut.clone();
tokio::spawn(
async move { perms.authorize_shell(&gate, &cut, "cargo test", None).await },
)
};
let request = rx.recv().await.unwrap();
request.respond.send(PermissionAnswer::AllowDirSession).ok();
assert!(first.await.unwrap().is_ok());
let second = {
let perms = perms.clone();
let gate = gate.clone();
let cut = cut.clone();
tokio::spawn(
async move { perms.authorize_shell(&gate, &cut, "cargo test", None).await },
)
};
let request = rx.recv().await.unwrap();
request.respond.send(PermissionAnswer::Allow).ok();
assert!(second.await.unwrap().is_ok());
}
#[tokio::test]
async fn tool_ask_denies_and_session_grants_by_name() {
let (tx, mut rx) = mpsc::channel::<PermissionRequest>(8);
let gate = PermissionGate::new(tx);
let cut = DenyCut::default();
let perms = std::sync::Arc::new(config_scoped(Some(Verb::Ask), None, vec![], None, vec![]));
let denied = {
let perms = perms.clone();
let gate = gate.clone();
let cut = cut.clone();
tokio::spawn(async move {
perms
.authorize_tool(&gate, &cut, "fetch-json", "Command: `curl …`", None)
.await
})
};
let request = rx.recv().await.unwrap();
assert!(request.description.contains("Allow tool `fetch-json`?"));
assert!(request.description.contains("Command: `curl …`"));
assert!(matches!(
request.scope,
Some(AskScope::Tool(ref name)) if name == "fetch-json"
));
request.respond.send(PermissionAnswer::Deny).ok();
let err = denied.await.unwrap().unwrap_err();
assert!(err.contains("permission denied by the user"), "{err}");
assert!(cut.is_set(), "a user rejection must flag the turn cut");
let granted = {
let perms = perms.clone();
let gate = gate.clone();
let cut = cut.clone();
tokio::spawn(async move {
perms
.authorize_tool(&gate, &cut, "fetch-json", "second call", None)
.await
})
};
let request = rx.recv().await.unwrap();
request.respond.send(PermissionAnswer::AllowSession).ok();
granted.await.unwrap().unwrap();
perms
.authorize_tool(&gate, &cut, "fetch-json", "third call", None)
.await
.unwrap();
assert!(
rx.try_recv().is_err(),
"a session grant must cover the tool name"
);
}
#[tokio::test]
async fn shell_session_grant_matches_the_collapsed_command() {
let (tx, mut rx) = mpsc::channel::<PermissionRequest>(8);
let gate = PermissionGate::new(tx);
let cut = DenyCut::default();
let perms = std::sync::Arc::new(config_scoped(
Some(Verb::Allow),
None,
vec![],
Some(Verb::Ask),
vec![],
));
let first = {
let perms = perms.clone();
let gate = gate.clone();
let cut = cut.clone();
tokio::spawn(async move {
perms
.authorize_shell(&gate, &cut, "cargo test", None)
.await
})
};
let request = rx.recv().await.unwrap();
assert_eq!(
request.scope,
Some(AskScope::Shell("cargo test".to_string()))
);
request.respond.send(PermissionAnswer::AllowSession).ok();
assert!(first.await.unwrap().is_ok());
perms
.authorize_shell(&gate, &cut, "cargo test", None)
.await
.unwrap();
assert!(
rx.try_recv().is_err(),
"granted commands must not surface again"
);
}
#[test]
fn output_interrupt_watches_every_deny_rule() {
let perms = Permissions::build(
&PermissionsConfig {
default: Some(Verb::Allow),
paths: RuleSet::default(),
checks: Vec::new(),
tool_check: None,
shell: RuleSet {
default: None,
rules: vec![
ShellRule {
verb: Verb::Deny,
pattern: "sudo".to_string(),
kind: ShellPatternKind::Raw,
},
ShellRule {
verb: Verb::Ask,
pattern: "secret".to_string(),
kind: ShellPatternKind::Raw,
},
ShellRule {
verb: Verb::Deny,
pattern: "rm (-rf|-fr)".to_string(),
kind: ShellPatternKind::Regex,
},
],
},
},
Path::new("/ws"),
)
.unwrap();
let interrupt = perms.output_interrupt().expect("watcher built");
assert_eq!(
interrupt.check("Password: \nsudo: permission denied"),
Some("shell-patterns: deny \"sudo\" (matched command output)".into())
);
assert_eq!(
interrupt.check("error: rm -rf refused"),
Some("shell-patterns: deny \"rm (-rf|-fr)\" (matched command output)".into())
);
assert_eq!(interrupt.check("plain output"), None);
assert!(builtin().output_interrupt().is_none());
}
#[test]
fn normalize_resolves_dot_components() {
assert_eq!(normalize(Path::new("/ws/./a/../b")), PathBuf::from("/ws/b"));
assert_eq!(normalize(Path::new("a/../b")), PathBuf::from("b"));
assert_eq!(normalize(Path::new("a/../../b")), PathBuf::from("../b"));
assert_eq!(normalize(Path::new("../a")), PathBuf::from("../a"));
assert_eq!(normalize(Path::new("/..")), PathBuf::from("/"));
}
#[tokio::test]
async fn a_decision_reason_is_shown_in_the_permission_prompt() {
let (tx, mut rx) = mpsc::channel::<PermissionRequest>(8);
let gate = PermissionGate::new(tx);
let cut = DenyCut::default();
let perms =
std::sync::Arc::new(config_scoped(Some(Verb::Allow), None, vec![], None, vec![]));
let reason =
"decision `safety` reads this command as suspicious (0.97 \u{2265} 0.50)".to_string();
let asking = {
let perms = perms.clone();
let gate = gate.clone();
let cut = cut.clone();
let reason = reason.clone();
tokio::spawn(async move {
perms
.settle_shell(&gate, &cut, "rm -rf /", Decision::Ask { reason })
.await
})
};
let request = rx.recv().await.unwrap();
assert!(
request.description.contains("Allow running this command?"),
"{}",
request.description
);
assert!(
request.description.contains("rm -rf /"),
"{}",
request.description
);
assert!(
request.description.contains(&reason),
"the decision's reason must reach the prompt: {}",
request.description
);
request.respond.send(PermissionAnswer::Allow).ok();
assert!(asking.await.unwrap().is_ok());
}
}