use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Authorization {
#[default]
PerTool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionKind {
Command,
Read,
Write,
}
impl PermissionKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Command => "command",
Self::Read => "read",
Self::Write => "write",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rule {
pub kind: PermissionKind,
pub allow: bool,
pub args_prefix: Vec<String>,
pub path: String,
}
impl Rule {
pub fn command(allow: bool, args_prefix: Vec<String>) -> Self {
Self {
kind: PermissionKind::Command,
allow,
args_prefix,
path: String::new(),
}
}
pub fn read(path: String) -> Self {
Self {
kind: PermissionKind::Read,
allow: true,
args_prefix: Vec::new(),
path,
}
}
pub fn write(allow: bool, path: String) -> Self {
Self {
kind: PermissionKind::Write,
allow,
args_prefix: Vec::new(),
path,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleScope {
Workspace,
Session,
}
impl RuleScope {
pub fn as_str(self) -> &'static str {
match self {
Self::Workspace => "workspace",
Self::Session => "session",
}
}
}
pub type ScopedRules<'a> = (RuleScope, &'a [Rule]);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Judgment {
AutoApprove(AutoDecision),
AutoDeny(AutoDecision),
Pending,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutoDecision {
pub scope: RuleScope,
pub args_prefix: Vec<String>,
pub allowed: bool,
pub matches: Vec<RuleMatch>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleMatch {
pub scope: RuleScope,
pub kind: PermissionKind,
pub allow: bool,
pub args_prefix: Vec<String>,
pub path: String,
pub adopted: bool,
}
pub fn evaluate(
layers: &[ScopedRules<'_>],
argv: &[String],
_authorization: &Authorization,
) -> Judgment {
let mut matches: Vec<RuleMatch> = Vec::new();
for (scope, rules) in layers {
for rule in rules.iter() {
if rule.kind != PermissionKind::Command {
continue;
}
if rule_matches_argv(rule, argv) {
matches.push(RuleMatch {
scope: *scope,
kind: rule.kind,
allow: rule.allow,
args_prefix: rule.args_prefix.clone(),
path: String::new(),
adopted: false,
});
}
}
}
finish_decision(matches)
}
pub fn evaluate_write(
layers: &[ScopedRules<'_>],
path: &Path,
_authorization: &Authorization,
) -> Judgment {
let mut matches: Vec<RuleMatch> = Vec::new();
for (scope, rules) in layers {
for rule in rules.iter() {
if rule.kind != PermissionKind::Write {
continue;
}
if rule_matches_path(rule, path) {
matches.push(RuleMatch {
scope: *scope,
kind: rule.kind,
allow: rule.allow,
args_prefix: Vec::new(),
path: rule.path.clone(),
adopted: false,
});
}
}
}
finish_decision(matches)
}
fn finish_decision(mut matches: Vec<RuleMatch>) -> Judgment {
let Some(last) = matches.last_mut() else {
return Judgment::Pending;
};
last.adopted = true;
let scope = last.scope;
let args_prefix = last.args_prefix.clone();
let allowed = last.allow;
let decision = AutoDecision {
scope,
args_prefix,
allowed,
matches,
};
if allowed {
Judgment::AutoApprove(decision)
} else {
Judgment::AutoDeny(decision)
}
}
fn rule_matches_argv(rule: &Rule, argv: &[String]) -> bool {
if rule.args_prefix.is_empty() {
return false;
}
if rule.args_prefix.len() > argv.len() {
return false;
}
rule.args_prefix
.iter()
.zip(argv.iter())
.all(|(a, b)| a == b)
}
fn rule_matches_path(rule: &Rule, path: &Path) -> bool {
if rule.path.is_empty() {
return false;
}
let prefix = Path::new(&rule.path);
path.starts_with(prefix)
}
impl Rule {
#[cfg(test)]
fn as_slice(&self) -> &[Rule] {
std::slice::from_ref(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn cmd(allow: bool, prefix: &[&str]) -> Rule {
Rule::command(allow, argv(prefix))
}
#[test]
fn evaluate_allow_rule_matches() {
let r = cmd(true, &["cargo", "test"]);
let layers = [(RuleScope::Workspace, r.as_slice())];
match evaluate(
&layers,
&argv(&["cargo", "test", "--workspace"]),
&Authorization::PerTool,
) {
Judgment::AutoApprove(d) => {
assert!(d.allowed);
assert_eq!(d.args_prefix, argv(&["cargo", "test"]));
assert_eq!(d.scope, RuleScope::Workspace);
assert_eq!(d.matches.len(), 1);
assert!(d.matches[0].adopted);
}
other => panic!("expected AutoApprove, got {other:?}"),
}
}
#[test]
fn evaluate_no_match_pends() {
let r = cmd(true, &["ls"]);
let layers = [(RuleScope::Workspace, r.as_slice())];
assert!(matches!(
evaluate(&layers, &argv(&["cat", "x"]), &Authorization::PerTool),
Judgment::Pending
));
}
#[test]
fn evaluate_deny_rule_alone_denies() {
let r = cmd(false, &["rm", "-rf"]);
let layers = [(RuleScope::Workspace, r.as_slice())];
assert!(matches!(
evaluate(
&layers,
&argv(&["rm", "-rf", "tmp"]),
&Authorization::PerTool
),
Judgment::AutoDeny(_)
));
}
#[test]
fn evaluate_last_match_wins_session_overrides_workspace() {
let ws = cmd(false, &["cargo", "test"]);
let sess = cmd(true, &["cargo", "test"]);
let layers = [
(RuleScope::Workspace, ws.as_slice()),
(RuleScope::Session, sess.as_slice()),
];
match evaluate(&layers, &argv(&["cargo", "test"]), &Authorization::PerTool) {
Judgment::AutoApprove(d) => {
assert_eq!(d.scope, RuleScope::Session);
assert_eq!(d.matches.len(), 2);
assert!(!d.matches[0].adopted);
assert!(d.matches[1].adopted);
}
other => panic!("expected AutoApprove(session), got {other:?}"),
}
}
#[test]
fn evaluate_last_match_wins_workspace_deny_over_session_allow() {
let ws = cmd(false, &["cargo", "test"]);
let sess = cmd(true, &["cargo", "test"]);
let layers = [
(RuleScope::Workspace, ws.as_slice()),
(RuleScope::Session, sess.as_slice()),
];
assert!(matches!(
evaluate(&layers, &argv(&["cargo", "test"]), &Authorization::PerTool),
Judgment::AutoApprove(_)
));
}
#[test]
fn evaluate_rule_prefix_longer_than_argv_does_not_match() {
let r = cmd(true, &["cargo", "test", "--all"]);
let layers = [(RuleScope::Workspace, r.as_slice())];
assert!(matches!(
evaluate(&layers, &argv(&["cargo", "test"]), &Authorization::PerTool),
Judgment::Pending
));
}
#[test]
fn evaluate_element_wise_mismatch_does_not_match() {
let r = cmd(true, &["cargo", "test"]);
let layers = [(RuleScope::Workspace, r.as_slice())];
assert!(matches!(
evaluate(&layers, &argv(&["cargo", "check"]), &Authorization::PerTool),
Judgment::Pending
));
}
#[test]
fn evaluate_empty_argv_prefix_never_matches() {
let r = cmd(true, &[]);
let layers = [(RuleScope::Workspace, r.as_slice())];
assert!(matches!(
evaluate(&layers, &argv(&["ls"]), &Authorization::PerTool),
Judgment::Pending
));
}
#[test]
fn evaluate_bash_dash_c_without_matching_rule_pends() {
let r = cmd(true, &["cargo", "test"]);
let layers = [(RuleScope::Workspace, r.as_slice())];
assert!(matches!(
evaluate(
&layers,
&argv(&["bash", "-c", "ls | head"]),
&Authorization::PerTool
),
Judgment::Pending
));
}
#[test]
fn evaluate_bash_dash_c_with_matching_rule_auto_approves() {
let r = cmd(true, &["bash", "-c"]);
let layers = [(RuleScope::Workspace, r.as_slice())];
match evaluate(
&layers,
&argv(&["bash", "-c", "ls | head"]),
&Authorization::PerTool,
) {
Judgment::AutoApprove(d) => assert_eq!(d.args_prefix, argv(&["bash", "-c"])),
other => panic!("expected AutoApprove for approved bash -c, got {other:?}"),
}
}
#[test]
fn read_rule_matches_recursively_on_segments() {
let r = Rule::read("foo/bar".to_string());
assert!(rule_matches_path(&r, Path::new("foo/bar")));
assert!(rule_matches_path(&r, Path::new("foo/bar/y/z")));
assert!(!rule_matches_path(&r, Path::new("foo/barbaz")));
}
#[test]
fn write_rule_matches_recursively_on_segments() {
let r = Rule::write(true, "src".to_string());
let layers = [(RuleScope::Workspace, r.as_slice())];
assert!(matches!(
evaluate_write(&layers, Path::new("src/lib.rs"), &Authorization::PerTool),
Judgment::AutoApprove(_)
));
assert!(matches!(
evaluate_write(
&layers,
Path::new("src/deep/mod.rs"),
&Authorization::PerTool
),
Judgment::AutoApprove(_)
));
assert!(matches!(
evaluate_write(&layers, Path::new("src2/lib.rs"), &Authorization::PerTool),
Judgment::Pending
));
}
#[test]
fn write_deny_rule_denies() {
let r = Rule::write(false, "src/generated".to_string());
let layers = [(RuleScope::Workspace, r.as_slice())];
assert!(matches!(
evaluate_write(
&layers,
Path::new("src/generated/x.rs"),
&Authorization::PerTool
),
Judgment::AutoDeny(_)
));
}
#[test]
fn write_and_read_rules_do_not_cross_match() {
let read_rule = Rule::read("src".to_string());
let write_rule = Rule::write(true, "src".to_string());
let layers = [
(RuleScope::Workspace, read_rule.as_slice()),
(RuleScope::Session, write_rule.as_slice()),
];
match evaluate_write(&layers, Path::new("src/lib.rs"), &Authorization::PerTool) {
Judgment::AutoApprove(d) => {
assert_eq!(d.scope, RuleScope::Session);
assert_eq!(d.matches.len(), 1);
assert_eq!(d.matches[0].kind, PermissionKind::Write);
}
other => panic!("expected AutoApprove(session write), got {other:?}"),
}
assert!(rule_matches_path(&read_rule, Path::new("src/lib.rs")));
}
#[test]
fn read_and_command_rules_do_not_cross_match() {
let cmd_rule = cmd(true, &["foo"]);
let read_rule = Rule::read("foo".to_string());
let layers = [
(RuleScope::Workspace, cmd_rule.as_slice()),
(RuleScope::Session, read_rule.as_slice()),
];
match evaluate(&layers, &argv(&["foo", "bar"]), &Authorization::PerTool) {
Judgment::AutoApprove(d) => {
assert_eq!(d.scope, RuleScope::Workspace);
assert_eq!(d.matches.len(), 1);
assert_eq!(d.matches[0].kind, PermissionKind::Command);
}
other => panic!("expected AutoApprove(workspace command), got {other:?}"),
}
}
}