use std::sync::Arc;
use regex::RegexSet;
use crate::types::*;
#[derive(Debug, Clone)]
pub enum PermissionDecision {
Allow,
Deny { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionCategory {
Tool,
ControlPlaneWrite,
}
struct PredicateRule {
label: &'static str,
check: fn(&str) -> bool,
}
#[derive(Clone)]
pub struct PermissionPolicy {
bash_tool_names: Vec<String>,
predicate_rules: Arc<Vec<PredicateRule>>,
danger_set: Arc<RegexSet>,
danger_labels: Arc<Vec<&'static str>>,
}
impl PermissionPolicy {
pub fn default_for_coding_agent() -> Self {
Self::new(vec!["bash".into()], default_danger_patterns())
}
pub fn new(
bash_tool_names: Vec<String>,
danger_patterns: Vec<(&'static str, &'static str)>,
) -> Self {
let labels: Vec<&'static str> = danger_patterns.iter().map(|(l, _)| *l).collect();
let regexes: Vec<&'static str> = danger_patterns.iter().map(|(_, r)| *r).collect();
let set = RegexSet::new(®exes).expect("danger patterns must compile");
Self {
bash_tool_names,
predicate_rules: Arc::new(default_predicate_rules()),
danger_set: Arc::new(set),
danger_labels: Arc::new(labels),
}
}
pub fn evaluate(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
self.evaluate_with_category(PermissionCategory::Tool, tool_name, args)
}
pub fn evaluate_with_category(
&self,
category: PermissionCategory,
tool_name: &str,
args: &serde_json::Value,
) -> PermissionDecision {
if category == PermissionCategory::ControlPlaneWrite {
return PermissionDecision::Allow;
}
if !self.bash_tool_names.iter().any(|n| n == tool_name) {
return PermissionDecision::Allow;
}
let cmd = extract_shell_command(args);
let Some(cmd) = cmd else {
return PermissionDecision::Allow;
};
for rule in self.predicate_rules.iter() {
if (rule.check)(&cmd) {
return PermissionDecision::Deny {
reason: format!("denied by permission policy: {}", rule.label),
};
}
}
let matches: Vec<usize> = self.danger_set.matches(&cmd).into_iter().collect();
if matches.is_empty() {
return PermissionDecision::Allow;
}
let label = self
.danger_labels
.get(matches[0])
.copied()
.unwrap_or("dangerous shell command");
PermissionDecision::Deny {
reason: format!("denied by permission policy: {label}"),
}
}
pub fn as_before_tool_call(self) -> BeforeToolCallHook {
let policy = Arc::new(self);
Arc::new(move |ctx: BeforeToolCallContext, _cancel| {
let policy = policy.clone();
Box::pin(async move {
match policy.evaluate(&ctx.tool_call.name, &ctx.args) {
PermissionDecision::Allow => BeforeToolCallResult::default(),
PermissionDecision::Deny { reason } => BeforeToolCallResult {
block: true,
reason: Some(reason),
prompt: None,
},
}
})
})
}
}
impl Default for PermissionPolicy {
fn default() -> Self {
Self::default_for_coding_agent()
}
}
fn extract_shell_command(args: &serde_json::Value) -> Option<String> {
for key in ["command", "cmd", "bash", "script"] {
if let Some(v) = args.get(key).and_then(|v| v.as_str()) {
if !v.trim().is_empty() {
return Some(v.to_string());
}
}
}
if let Some(s) = args.as_str() {
if !s.trim().is_empty() {
return Some(s.to_string());
}
}
None
}
fn default_danger_patterns() -> Vec<(&'static str, &'static str)> {
vec![
("sudo invocation", r"\bsudo\b"),
(
"curl/wget piped into shell",
r"\b(curl|wget)\b[^|]*\|\s*(bash|sh|zsh|fish)\b",
),
(
"dd writing to a block device",
r"\bdd\b[^\n]*\bof=/dev/(disk|sd[a-z]|nvme|hd[a-z])",
),
("mkfs / format command", r"\bmkfs(\.|\s)"),
("chmod 777 on absolute path", r"\bchmod\b\s+777\s+/"),
(
"shutdown / reboot / halt",
r"\b(shutdown|reboot|halt|poweroff)\b",
),
(
"git push --force on main/master",
r"\bgit\s+push\s+(--force|-f)\b[^\n]*\b(main|master)\b",
),
("piping into eval", r"\|\s*eval\b"),
(":(){:|:&};: forkbomb", r":\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:"),
]
}
fn default_predicate_rules() -> Vec<PredicateRule> {
vec![
PredicateRule {
label: "rm recursive+force on absolute path",
check: rm_recursive_force_on_absolute_target,
},
PredicateRule {
label: "rm recursive+force on $HOME or ~",
check: rm_recursive_force_on_home_target,
},
]
}
fn rm_recursive_force_on_absolute_target(cmd: &str) -> bool {
rm_dangerous_with(cmd, |operand| operand == "/" || operand.starts_with('/'))
}
fn rm_recursive_force_on_home_target(cmd: &str) -> bool {
rm_dangerous_with(cmd, |operand| {
operand == "~"
|| operand.starts_with("~/")
|| operand == "$HOME"
|| operand.starts_with("$HOME/")
})
}
fn rm_dangerous_with(cmd: &str, target_matches: fn(&str) -> bool) -> bool {
for clause in split_shell_clauses(cmd) {
let tokens: Vec<&str> = clause.split_whitespace().collect();
let Some(first) = tokens.first() else {
continue;
};
let prog = first.rsplit('/').next().unwrap_or(first);
if prog != "rm" {
continue;
}
let mut has_recursive = false;
let mut has_force = false;
let mut operands: Vec<String> = Vec::new();
for tok in tokens.iter().skip(1) {
if let Some(long) = tok.strip_prefix("--") {
match long {
"recursive" => has_recursive = true,
"force" => has_force = true,
"" => continue, _ => {}
}
} else if let Some(short) = tok.strip_prefix('-') {
if short.is_empty() {
operands.push(normalize_operand(tok));
} else {
if short.contains('r') || short.contains('R') {
has_recursive = true;
}
if short.contains('f') {
has_force = true;
}
}
} else {
operands.push(normalize_operand(tok));
}
}
if !(has_recursive && has_force) {
continue;
}
if operands.iter().any(|op| target_matches(op.as_str())) {
return true;
}
}
false
}
fn normalize_operand(raw: &str) -> String {
let unquoted = strip_one_layer_of_quotes(raw);
rewrite_brace_home(&unquoted)
}
fn strip_one_layer_of_quotes(raw: &str) -> String {
if raw.len() >= 2 {
let bytes = raw.as_bytes();
let first = bytes[0];
let last = bytes[raw.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return raw[1..raw.len() - 1].to_string();
}
}
raw.to_string()
}
fn rewrite_brace_home(raw: &str) -> String {
if let Some(rest) = raw.strip_prefix("${HOME}") {
return format!("$HOME{rest}");
}
raw.to_string()
}
fn split_shell_clauses(cmd: &str) -> Vec<&str> {
let mut out = Vec::new();
let bytes = cmd.as_bytes();
let mut start = 0usize;
let mut i = 0usize;
while i < bytes.len() {
let b = bytes[i];
if b == b';' {
out.push(cmd[start..i].trim());
start = i + 1;
i += 1;
} else if i + 1 < bytes.len()
&& ((b == b'&' && bytes[i + 1] == b'&') || (b == b'|' && bytes[i + 1] == b'|'))
{
out.push(cmd[start..i].trim());
start = i + 2;
i += 2;
} else if b == b'|' {
out.push(cmd[start..i].trim());
start = i + 1;
i += 1;
} else {
i += 1;
}
}
if start <= bytes.len() {
out.push(cmd[start..].trim());
}
out.into_iter().filter(|s| !s.is_empty()).collect()
}
#[cfg(test)]
mod permission_external_tests {
tests_bridge_macro::tests_bridge!("agent/permission");
}
#[cfg(test)]
mod permission_linecov_tests {
tests_bridge_macro::tests_bridge!("agent/permission/linecov");
}