use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::hooks::{HookDecision, Hooks, PermCtx, ToolCtx};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Action {
Allow,
Deny,
Ask,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Rule {
pub tool: String,
pub action: Action,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeRule {
pub pattern: String,
pub action: Action,
}
#[derive(Debug, Default)]
pub struct RuntimeRuleStore {
rules: std::sync::Mutex<Vec<RuntimeRule>>,
}
impl RuntimeRuleStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(&self, rule: RuntimeRule) {
self.rules
.lock()
.expect("RuntimeRuleStore mutex poisoned")
.push(rule);
}
#[must_use]
pub fn snapshot(&self) -> Vec<RuntimeRule> {
self.rules
.lock()
.expect("RuntimeRuleStore mutex poisoned")
.clone()
}
pub fn remove(&self, index: usize) -> Option<RuntimeRule> {
let mut rules = self.rules.lock().expect("RuntimeRuleStore mutex poisoned");
if index < rules.len() {
Some(rules.remove(index))
} else {
None
}
}
#[must_use]
pub fn evaluate(&self, ctx: &ToolCtx<'_>) -> Option<Action> {
let rules = self.rules.lock().expect("RuntimeRuleStore mutex poisoned");
for r in rules.iter().rev() {
let synth = Rule {
tool: r.pattern.clone(),
action: r.action,
comment: None,
reason: None,
expires_at: None,
};
if rule_matches(&synth, ctx) {
return Some(r.action);
}
}
None
}
}
#[derive(Debug, Deserialize)]
struct RulesFile {
#[serde(default, rename = "rule")]
rules: Vec<Rule>,
}
#[deprecated(
since = "0.0.0",
note = "use `caliban_common::glob_match::matches_glob` instead"
)]
pub use caliban_common::glob_match::matches_glob;
#[deprecated(
since = "0.0.0",
note = "use `caliban_common::glob_match::first_arg` instead"
)]
pub use caliban_common::glob_match::first_arg;
fn rule_matches(rule: &Rule, ctx: &ToolCtx<'_>) -> bool {
crate::permissions_matcher::matches(&rule.tool, ctx)
}
#[must_use]
pub fn evaluate_rules<'a>(rules: &'a [Rule], ctx: &ToolCtx<'_>) -> Option<&'a Rule> {
rules.iter().find(|r| rule_matches(r, ctx))
}
#[must_use]
pub fn default_rules() -> Vec<Rule> {
[
("Read", Action::Allow),
("Grep", Action::Allow),
("Glob", Action::Allow),
("WebFetch", Action::Ask),
("Bash", Action::Ask),
("Write", Action::Ask),
("Edit", Action::Ask),
("TodoWrite", Action::Allow),
("EnterPlanMode", Action::Allow),
("ExitPlanMode", Action::Allow),
("*", Action::Ask),
]
.into_iter()
.map(|(t, a)| Rule {
tool: t.into(),
action: a,
comment: None,
reason: None,
expires_at: None,
})
.collect()
}
#[derive(thiserror::Error, Debug)]
pub enum PermissionsLoadError {
#[error("permissions: io error reading {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("permissions: parse error in {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
}
#[deprecated(
since = "0.0.1",
note = "load via caliban-settings; legacy loaders remove in v0.2"
)]
pub fn load_rules_file(path: &Path) -> std::result::Result<Vec<Rule>, PermissionsLoadError> {
let body = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(PermissionsLoadError::Io {
path: path.to_path_buf(),
source: e,
});
}
};
let parsed: RulesFile =
toml::from_str(&body).map_err(|source| PermissionsLoadError::Parse {
path: path.to_path_buf(),
source,
})?;
Ok(parsed.rules)
}
#[deprecated(
since = "0.0.1",
note = "load via caliban-settings; legacy loaders remove in v0.2"
)]
pub fn load_rules(
cli_rules: Vec<Rule>,
workspace_root: &Path,
) -> std::result::Result<Vec<Rule>, PermissionsLoadError> {
let mut all = cli_rules;
let project_file = workspace_root.join(".caliban/permissions.toml");
#[allow(deprecated)]
all.extend(load_rules_file(&project_file)?);
let user_dir = dirs::config_dir().map(|d| d.join("caliban/permissions.toml"));
if let Some(p) = user_dir {
#[allow(deprecated)]
all.extend(load_rules_file(&p)?);
}
all.extend(default_rules());
Ok(all)
}
#[async_trait]
pub trait AskHandler: Send + Sync {
async fn prompt(&self, ctx: &ToolCtx<'_>) -> HookDecision;
}
#[derive(Debug)]
pub struct NonInteractiveAskHandler {
pub auto_allow: bool,
}
#[async_trait]
impl AskHandler for NonInteractiveAskHandler {
async fn prompt(&self, ctx: &ToolCtx<'_>) -> HookDecision {
if self.auto_allow {
HookDecision::Allow
} else {
HookDecision::AskDenied(non_interactive_deny_message(ctx.tool_name))
}
}
}
fn non_interactive_deny_message(tool_name: &str) -> String {
let head = format!("permission denied: '{tool_name}' requires interactive approval (no TTY)");
let hint = if crate::permission_mode::is_file_edit_tool(tool_name) {
"re-run with `--permission-mode acceptEdits` to auto-allow file edits, \
or `--allow '<Tool>(<glob>)'` for a narrower rule"
} else if tool_name == "Bash" {
"re-run with `--allow 'Bash(<glob>)'` to allow specific commands, \
or `--auto-allow` to allow all Ask-rule tools (dangerous)"
} else {
"re-run with `--allow '<Tool>'` to allow this tool, \
or `--auto-allow` to allow all Ask-rule tools (dangerous)"
};
format!("{head}; {hint}")
}
pub struct PermissionsHook {
rules: Vec<Rule>,
runtime: Arc<RuntimeRuleStore>,
ask: Arc<dyn AskHandler>,
inner: Arc<dyn Hooks>,
}
impl std::fmt::Debug for PermissionsHook {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PermissionsHook")
.field("rules", &self.rules.len())
.finish_non_exhaustive()
}
}
impl PermissionsHook {
#[must_use]
pub fn new(rules: Vec<Rule>, ask: Arc<dyn AskHandler>, inner: Arc<dyn Hooks>) -> Self {
Self {
rules,
runtime: Arc::new(RuntimeRuleStore::new()),
ask,
inner,
}
}
#[must_use]
pub fn with_runtime_rules(mut self, runtime: Arc<RuntimeRuleStore>) -> Self {
self.runtime = runtime;
self
}
#[must_use]
pub fn evaluate(&self, ctx: &ToolCtx<'_>) -> Action {
self.evaluate_with_rule(ctx).0
}
#[must_use]
pub fn evaluate_with_rule(
&self,
ctx: &ToolCtx<'_>,
) -> (Action, Option<String>, Option<String>) {
if let Some(action) = self.runtime.evaluate(ctx) {
return (action, None, None);
}
for r in &self.rules {
if rule_matches(r, ctx) {
return (r.action, r.comment.clone(), r.reason.clone());
}
}
(Action::Deny, None, None)
}
}
fn action_str(a: Action) -> &'static str {
match a {
Action::Allow => "allow",
Action::Deny => "deny",
Action::Ask => "ask",
}
}
#[async_trait]
impl Hooks for PermissionsHook {
async fn before_tool(&self, ctx: &ToolCtx<'_>) -> Result<HookDecision> {
let (action, comment, reason) = self.evaluate_with_rule(ctx);
match action {
Action::Allow => self.inner.before_tool(ctx).await,
Action::Deny => {
let perm_ctx = PermCtx {
turn_index: ctx.turn_index,
tool_use_id: ctx.tool_use_id,
tool_name: ctx.tool_name,
input: ctx.input,
rule_action: action_str(Action::Deny),
rule_comment: comment.as_deref(),
};
if let Err(e) = self.inner.permission_denied(&perm_ctx).await {
tracing::warn!(error = %e, "permission_denied hook error (non-fatal)");
}
let deny_msg = reason
.unwrap_or_else(|| format!("permission denied for tool '{}'", ctx.tool_name));
Ok(HookDecision::Deny(deny_msg))
}
Action::Ask => {
let perm_ctx = PermCtx {
turn_index: ctx.turn_index,
tool_use_id: ctx.tool_use_id,
tool_name: ctx.tool_name,
input: ctx.input,
rule_action: action_str(Action::Ask),
rule_comment: comment.as_deref(),
};
if let Err(e) = self.inner.permission_request(&perm_ctx).await {
tracing::warn!(error = %e, "permission_request hook error (non-fatal)");
}
let decision = self.ask.prompt(ctx).await;
if matches!(decision, HookDecision::Deny(_) | HookDecision::AskDenied(_))
&& let Err(e) = self.inner.permission_denied(&perm_ctx).await
{
tracing::warn!(error = %e, "permission_denied hook error (non-fatal)");
}
Ok(decision)
}
}
}
crate::forward_all_hooks_except!(inner; forward:
before_run, after_run, after_run_failure,
before_turn, after_turn, after_turn_failure,
after_tool,
session_start, session_end, user_prompt_submit,
pre_compact, post_compact,
config_change, cwd_changed, file_changed,
permission_request, permission_denied, notification,
subagent_start, subagent_stop,
task_created, task_completed);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::NoopHooks;
fn ctx<'a>(name: &'a str, input: &'a serde_json::Value) -> ToolCtx<'a> {
ToolCtx {
session_id: "test-session",
turn_index: 0,
tool_use_id: "t1",
tool_name: name,
input,
is_read_only: false,
}
}
fn rule(tool: &str, action: Action) -> Rule {
Rule {
tool: tool.into(),
action,
comment: None,
reason: None,
expires_at: None,
}
}
fn hook(rules: Vec<Rule>) -> PermissionsHook {
PermissionsHook::new(
rules,
Arc::new(NonInteractiveAskHandler { auto_allow: false }),
Arc::new(NoopHooks),
)
}
#[test]
fn default_read_allowed_bash_ask() {
let h = hook(default_rules());
let i = serde_json::json!({});
assert_eq!(h.evaluate(&ctx("Read", &i)), Action::Allow);
assert_eq!(h.evaluate(&ctx("Bash", &i)), Action::Ask);
assert_eq!(h.evaluate(&ctx("WebFetch", &i)), Action::Ask);
}
#[test]
fn first_match_wins_within_a_source() {
let mut rules = vec![rule("Bash:git *", Action::Allow)];
rules.extend(default_rules());
let h = hook(rules);
let i = serde_json::json!({"command": "git push"});
assert_eq!(h.evaluate(&ctx("Bash", &i)), Action::Allow);
let i2 = serde_json::json!({"command": "rm -rf /"});
assert_eq!(h.evaluate(&ctx("Bash", &i2)), Action::Ask);
}
#[test]
fn cli_priority_overrides_default() {
let mut rules = vec![rule("Bash", Action::Allow)];
rules.extend(default_rules());
let h = hook(rules);
let i = serde_json::json!({"command": "anything"});
assert_eq!(h.evaluate(&ctx("Bash", &i)), Action::Allow);
}
#[test]
fn runtime_rule_overrides_config_ask_live() {
let store = Arc::new(RuntimeRuleStore::new());
let h = hook(default_rules()).with_runtime_rules(Arc::clone(&store));
let i = serde_json::json!({"command": "ls -F"});
assert_eq!(h.evaluate(&ctx("Bash", &i)), Action::Ask);
store.add(RuntimeRule {
pattern: "Bash:ls *".into(),
action: Action::Allow,
});
assert_eq!(h.evaluate(&ctx("Bash", &i)), Action::Allow);
}
#[test]
fn catchall_star_matches_unknown_tool() {
let h = hook(default_rules());
let i = serde_json::json!({});
assert_eq!(h.evaluate(&ctx("UnknownMcpTool", &i)), Action::Ask);
}
#[test]
fn first_arg_only_matches_when_accessor_known() {
let mut rules = vec![rule("UnknownMcpTool:foo", Action::Allow)];
rules.extend(default_rules());
let h = hook(rules);
let i = serde_json::json!({"command": "foo"});
assert_eq!(h.evaluate(&ctx("UnknownMcpTool", &i)), Action::Ask);
}
#[tokio::test]
async fn deny_action_surfaces_reason_to_model() {
let mut rules = vec![Rule {
tool: "Bash".into(),
action: Action::Deny,
comment: None,
reason: Some("no shell, use Edit".into()),
expires_at: None,
}];
rules.extend(default_rules());
let h = hook(rules);
let i = serde_json::json!({"command": "ls"});
let d = h.before_tool(&ctx("Bash", &i)).await.unwrap();
match d {
HookDecision::Deny(msg) => assert!(
msg.contains("no shell, use Edit"),
"deny message must surface rule.reason — got: {msg}"
),
other => panic!("expected Deny, got {other:?}"),
}
}
#[tokio::test]
async fn deny_action_returns_deny_decision() {
let mut rules = vec![rule("Bash", Action::Deny)];
rules.extend(default_rules());
let h = hook(rules);
let i = serde_json::json!({"command": "x"});
let d = h.before_tool(&ctx("Bash", &i)).await.unwrap();
assert!(matches!(d, HookDecision::Deny(_)));
}
#[tokio::test]
async fn ask_without_auto_allow_denies() {
let h = hook(default_rules());
let i = serde_json::json!({"command": "x"});
let d = h.before_tool(&ctx("Bash", &i)).await.unwrap();
assert!(matches!(d, HookDecision::AskDenied(_)));
}
#[tokio::test]
async fn non_interactive_deny_for_file_edit_suggests_accept_edits() {
let h = hook(default_rules());
let i = serde_json::json!({"file_path": "/tmp/x", "content": "y"});
let d = h.before_tool(&ctx("Write", &i)).await.unwrap();
let HookDecision::AskDenied(msg) = d else {
panic!("expected AskDenied, got {d:?}");
};
assert!(msg.contains("--permission-mode acceptEdits"), "got: {msg}");
assert!(msg.contains("'Write'"), "got: {msg}");
}
#[tokio::test]
async fn non_interactive_deny_for_bash_suggests_targeted_allow_rule() {
let h = hook(default_rules());
let i = serde_json::json!({"command": "ls"});
let d = h.before_tool(&ctx("Bash", &i)).await.unwrap();
let HookDecision::AskDenied(msg) = d else {
panic!("expected AskDenied, got {d:?}");
};
assert!(msg.contains("--allow 'Bash"), "got: {msg}");
assert!(msg.contains("dangerous"), "got: {msg}");
assert!(
!msg.contains("acceptEdits"),
"acceptEdits doesn't cover Bash; got: {msg}"
);
}
#[tokio::test]
async fn non_interactive_deny_for_other_tool_suggests_generic_allow_rule() {
let mut rules = vec![rule("WebFetch", Action::Ask)];
rules.extend(default_rules());
let h = hook(rules);
let i = serde_json::json!({"url": "https://example.com"});
let d = h.before_tool(&ctx("WebFetch", &i)).await.unwrap();
let HookDecision::AskDenied(msg) = d else {
panic!("expected AskDenied, got {d:?}");
};
assert!(msg.contains("--allow '<Tool>'"), "got: {msg}");
assert!(
!msg.contains("acceptEdits"),
"acceptEdits doesn't cover WebFetch; got: {msg}"
);
}
#[tokio::test]
async fn ask_with_auto_allow_allows() {
let h = PermissionsHook::new(
default_rules(),
Arc::new(NonInteractiveAskHandler { auto_allow: true }),
Arc::new(NoopHooks),
);
let i = serde_json::json!({"command": "x"});
let d = h.before_tool(&ctx("Bash", &i)).await.unwrap();
assert!(matches!(d, HookDecision::Allow));
}
#[test]
fn rule_deserializes_reason_and_expires_at() {
let src = r#"
[[rule]]
tool = "Bash"
action = "deny"
reason = "no shell access in CI"
expires_at = "2026-12-31T00:00:00Z"
"#;
let parsed: RulesFile = toml::from_str(src).unwrap();
assert_eq!(parsed.rules.len(), 1);
let r = &parsed.rules[0];
assert_eq!(r.action, Action::Deny);
assert_eq!(r.reason.as_deref(), Some("no shell access in CI"));
assert!(r.expires_at.is_some());
}
#[test]
#[allow(deprecated)]
fn loader_parses_valid_toml() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("permissions.toml");
std::fs::write(
&f,
r#"
[[rule]]
tool = "Bash"
action = "allow"
[[rule]]
tool = "Bash:rm *"
action = "deny"
comment = "no rm"
"#,
)
.unwrap();
let rules = load_rules_file(&f).unwrap();
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].action, Action::Allow);
assert_eq!(rules[1].action, Action::Deny);
assert_eq!(rules[1].comment.as_deref(), Some("no rm"));
}
#[test]
#[allow(deprecated)]
fn loader_missing_file_returns_empty() {
let path = std::path::Path::new("/nonexistent/path/permissions.toml");
let rules = load_rules_file(path).unwrap();
assert!(rules.is_empty());
}
#[test]
#[allow(deprecated)]
fn loader_invalid_action_errors() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("permissions.toml");
std::fs::write(
&f,
r#"
[[rule]]
tool = "Bash"
action = "bogus"
"#,
)
.unwrap();
let err = load_rules_file(&f).unwrap_err();
assert!(matches!(err, PermissionsLoadError::Parse { .. }));
}
}
#[cfg(test)]
mod runtime_rule_tests {
use super::*;
fn ctx<'a>(name: &'a str, input: &'a serde_json::Value) -> ToolCtx<'a> {
ToolCtx {
session_id: "test-session",
turn_index: 0,
tool_use_id: "t",
tool_name: name,
input,
is_read_only: false,
}
}
#[test]
fn empty_store_returns_none() {
let store = RuntimeRuleStore::new();
let input = serde_json::json!({"command": "ls"});
assert!(store.evaluate(&ctx("Bash", &input)).is_none());
}
#[test]
fn always_allow_matches_subsequent_invocation() {
let store = RuntimeRuleStore::new();
store.add(RuntimeRule {
pattern: "Bash:ls *".into(),
action: Action::Allow,
});
let input = serde_json::json!({"command": "ls -al"});
let outcome = store.evaluate(&ctx("Bash", &input));
assert_eq!(outcome, Some(Action::Allow));
}
#[test]
fn snapshot_returns_rules_in_insertion_order() {
let store = RuntimeRuleStore::new();
store.add(RuntimeRule {
pattern: "Bash:ls *".into(),
action: Action::Allow,
});
store.add(RuntimeRule {
pattern: "Edit(/tmp/*)".into(),
action: Action::Deny,
});
let snap = store.snapshot();
assert_eq!(snap.len(), 2);
assert_eq!(snap[0].pattern, "Bash:ls *");
assert_eq!(snap[0].action, Action::Allow);
assert_eq!(snap[1].pattern, "Edit(/tmp/*)");
assert_eq!(snap[1].action, Action::Deny);
}
#[test]
fn snapshot_on_empty_store_returns_empty() {
let store = RuntimeRuleStore::new();
assert!(store.snapshot().is_empty());
}
#[test]
fn remove_drops_rule_at_index_and_shifts_remainder() {
let store = RuntimeRuleStore::new();
store.add(RuntimeRule {
pattern: "a".into(),
action: Action::Allow,
});
store.add(RuntimeRule {
pattern: "b".into(),
action: Action::Allow,
});
store.add(RuntimeRule {
pattern: "c".into(),
action: Action::Allow,
});
let removed = store.remove(1);
assert_eq!(removed.as_ref().map(|r| r.pattern.as_str()), Some("b"));
let snap = store.snapshot();
assert_eq!(snap.len(), 2);
assert_eq!(snap[0].pattern, "a");
assert_eq!(snap[1].pattern, "c");
}
#[test]
fn remove_out_of_bounds_returns_none_and_leaves_store_intact() {
let store = RuntimeRuleStore::new();
store.add(RuntimeRule {
pattern: "x".into(),
action: Action::Allow,
});
assert!(store.remove(5).is_none());
assert!(store.remove(usize::MAX).is_none());
assert_eq!(store.snapshot().len(), 1);
}
#[test]
fn always_reject_overrides_a_later_allow() {
let store = RuntimeRuleStore::new();
store.add(RuntimeRule {
pattern: "Bash:rm *".into(),
action: Action::Allow,
});
store.add(RuntimeRule {
pattern: "Bash:rm *".into(),
action: Action::Deny,
});
let input = serde_json::json!({"command": "rm -rf /tmp"});
let outcome = store.evaluate(&ctx("Bash", &input));
assert_eq!(outcome, Some(Action::Deny));
}
}
#[cfg(test)]
mod evaluate_rules_tests {
use super::*;
fn ctx<'a>(name: &'a str, input: &'a serde_json::Value) -> ToolCtx<'a> {
ToolCtx {
session_id: "test-session",
turn_index: 0,
tool_use_id: "t",
tool_name: name,
input,
is_read_only: false,
}
}
#[test]
fn test_pane_outcome_reflects_matched_rule() {
let rules = vec![Rule {
tool: "Bash:rm *".into(),
action: Action::Deny,
comment: None,
reason: None,
expires_at: None,
}];
let input = serde_json::json!({"command": "rm -rf /"});
let ctx = ctx("Bash", &input);
let r = evaluate_rules(&rules, &ctx).unwrap();
assert_eq!(r.tool, "Bash:rm *");
assert_eq!(r.action, Action::Deny);
}
#[test]
fn evaluate_rules_returns_none_when_no_match() {
let rules = vec![Rule {
tool: "Read".into(),
action: Action::Allow,
comment: None,
reason: None,
expires_at: None,
}];
let input = serde_json::json!({"command": "ls"});
let ctx = ctx("Bash", &input);
assert!(evaluate_rules(&rules, &ctx).is_none());
}
#[test]
fn evaluate_rules_first_match_wins() {
let rules = vec![
Rule {
tool: "Bash".into(),
action: Action::Allow,
comment: None,
reason: None,
expires_at: None,
},
Rule {
tool: "Bash".into(),
action: Action::Deny,
comment: None,
reason: None,
expires_at: None,
},
];
let input = serde_json::json!({"command": "ls"});
let ctx = ctx("Bash", &input);
let r = evaluate_rules(&rules, &ctx).unwrap();
assert_eq!(r.action, Action::Allow, "first rule should win");
}
}