use super::*;
use serde_json::json;
#[test]
fn interactive_guardrail_default_mode_balances_safe_and_sensitive_calls() {
let guardrail = InteractiveToolGuardrail::default();
assert_eq!(
guardrail.check("read", &json!({"file_path": "src/lib.rs"})),
PermissionDecision::Allow
);
for path in [
"/etc/passwd",
"../outside",
"C:\\Windows\\System32",
r"\\server\share\secret",
] {
assert_eq!(
guardrail.check("read", &json!({"file_path": path})),
PermissionDecision::Deny,
"cross-platform absolute path must be denied: {path}"
);
}
assert_eq!(
guardrail.check("bash", &json!({"command": "pwd"})),
PermissionDecision::Allow
);
assert_eq!(
guardrail.check("git", &json!({"command": "status"})),
PermissionDecision::Allow
);
assert_eq!(
guardrail.check("write", &json!({"file_path": "src/lib.rs"})),
PermissionDecision::Ask
);
assert_eq!(
guardrail.check("bash", &json!({"command": "cargo test"})),
PermissionDecision::Ask
);
assert_eq!(
guardrail.check("bash", &json!({"command": "rm -rf /"})),
PermissionDecision::Deny
);
}
#[test]
fn interactive_guardrail_exposes_four_risk_levels_without_weakening_hitl() {
let cases = [
(
"read",
json!({"file_path": "src/lib.rs"}),
ToolRiskLevel::Routine,
ToolRiskAction::Allow,
PermissionDecision::Allow,
),
(
"write",
json!({"file_path": "src/lib.rs"}),
ToolRiskLevel::Bounded,
ToolRiskAction::RequireConfirmation,
PermissionDecision::Ask,
),
(
"bash",
json!({"command": "cargo test"}),
ToolRiskLevel::High,
ToolRiskAction::ReviewByLlm,
PermissionDecision::Ask,
),
(
"bash",
json!({"command": "rm -rf /"}),
ToolRiskLevel::Critical,
ToolRiskAction::RuleDeny,
PermissionDecision::Deny,
),
];
let default = InteractiveToolGuardrail::default();
let auto = InteractiveToolGuardrail::for_mode("auto");
for (tool, args, level, default_action, legacy_decision) in cases {
let assessment = default.assess(tool, &args);
assert_eq!(assessment.level, level, "unexpected risk for {tool}");
assert!(
!assessment.reasons.is_empty(),
"risk assessments must remain explainable for {tool}"
);
assert_eq!(default.risk_action(tool, &args), default_action);
assert_eq!(default.check(tool, &args), legacy_decision);
}
assert_eq!(
auto.risk_action("write", &json!({"file_path": "src/lib.rs"})),
ToolRiskAction::Allow,
"auto may streamline a bounded workspace mutation"
);
assert_eq!(
auto.check("bash", &json!({"command": "cargo test"})),
PermissionDecision::Ask,
"a high-risk review candidate must retain HITL until a reviewer resolves it"
);
assert_eq!(
auto.check("bash", &json!({"command": "rm -rf /"})),
PermissionDecision::Deny,
"auto must never override a critical rule denial"
);
}
#[test]
fn interactive_guardrail_aggregates_the_highest_batch_risk() {
let guardrail = InteractiveToolGuardrail::for_mode("auto");
for (args, expected_level, expected_action, expected_permission) in [
(
json!({"invocations": [
{"tool": "read", "args": {"file_path": "README.md"}},
{"tool": "git", "args": {"command": "status"}}
]}),
ToolRiskLevel::Routine,
ToolRiskAction::Allow,
PermissionDecision::Allow,
),
(
json!({"invocations": [
{"tool": "read", "args": {"file_path": "README.md"}},
{"tool": "write", "args": {"file_path": "README.md"}}
]}),
ToolRiskLevel::Bounded,
ToolRiskAction::Allow,
PermissionDecision::Allow,
),
(
json!({"invocations": [
{"tool": "write", "args": {"file_path": "README.md"}},
{"tool": "bash", "args": {"command": "cargo test"}}
]}),
ToolRiskLevel::High,
ToolRiskAction::ReviewByLlm,
PermissionDecision::Ask,
),
(
json!({"invocations": [
{"tool": "write", "args": {"file_path": "README.md"}},
{"tool": "bash", "args": {"command": "rm -rf /"}}
]}),
ToolRiskLevel::Critical,
ToolRiskAction::RuleDeny,
PermissionDecision::Deny,
),
] {
let assessment = guardrail.assess("batch", &args);
assert_eq!(assessment.level, expected_level);
assert!(assessment
.reasons
.contains(&ToolRiskReason::CompositeInvocation));
assert_eq!(guardrail.risk_action("batch", &args), expected_action);
assert_eq!(guardrail.check("batch", &args), expected_permission);
}
}
#[test]
fn interactive_guardrail_rejects_silent_shell_escape_regressions() {
let guardrail = InteractiveToolGuardrail::default();
for command in [
"sort -o output.txt input.txt",
"sort -ooutput.txt input.txt",
"sort -o/tmp/a3s-hitl-bypass input.txt",
"sort -T/tmp input.txt",
"sort --temporary-directory=tmp input.txt",
"sort --compress-program=touch input.txt",
"uniq input.txt output.txt",
"cat ../outside-workspace-secret",
"cat *",
"grep -f/etc/passwd README.md",
"git -C .. status",
"git log --output=history.txt",
"find . -type f -fprint output.txt",
"find . -fls output.txt",
"find .\t-delete",
"sed -i.bak s/old/new/ README.md",
"sed w output.txt README.md",
"sed e commands.txt",
"rg --pre=touch pattern .",
"rg --follow pattern .",
"grep -R pattern .",
"du -L .",
"du -aL .",
"ls -L .",
"ls -RL .",
"find . -follow",
"find -L . -type f",
"date -s2026-01-01",
"date --set=2026-01-01",
"git diff --ext-diff",
] {
assert_eq!(
guardrail.check("bash", &json!({"command": command})),
PermissionDecision::Ask,
"unsafe or boundary-crossing shell call was silently allowed: {command}"
);
}
}
#[test]
fn interactive_guardrail_distinguishes_dangerous_commands_from_read_only_arguments() {
let guardrail = InteractiveToolGuardrail::default();
for command in ["rg mkfs README.md", "cat docs/mkfs-guide.md"] {
assert_eq!(
guardrail.check("bash", &json!({"command": command})),
PermissionDecision::Allow,
"a dangerous command name used only as data must not be hard denied: {command}"
);
}
for command in ["mkfs /dev/disk9", "/sbin/mkfs.ext4 /dev/disk9"] {
assert_eq!(
guardrail.check("bash", &json!({"command": command})),
PermissionDecision::Deny,
"actual filesystem formatting must remain a hard denial: {command}"
);
}
}
#[test]
fn interactive_guardrail_modes_keep_the_hard_deny_floor() {
for mode in ["default", "plan", "auto"] {
let guardrail = InteractiveToolGuardrail::for_mode(mode);
assert_eq!(
guardrail.check("bash", &json!({"command": "rm -rf /"})),
PermissionDecision::Deny,
"{mode} must retain catastrophic-operation denial"
);
assert_eq!(
guardrail.check("read", &json!({"file_path": "README.md"})),
PermissionDecision::Allow
);
}
assert_eq!(
InteractiveToolGuardrail::for_mode("default")
.check("write", &json!({"file_path": "README.md"})),
PermissionDecision::Ask
);
assert_eq!(
InteractiveToolGuardrail::for_mode("plan")
.check("write", &json!({"file_path": "README.md"})),
PermissionDecision::Ask,
"plan mode may request an explicit write escalation"
);
let auto = InteractiveToolGuardrail::for_mode("auto");
assert_eq!(
auto.check("write", &json!({"file_path": "README.md"})),
PermissionDecision::Allow
);
assert_eq!(
auto.check("git", &json!({"command": "checkout", "ref": "feature"})),
PermissionDecision::Allow
);
assert_eq!(
auto.check(
"git",
&json!({"command": "checkout", "ref": "feature", "force": true})
),
PermissionDecision::Ask,
"auto mode must not silently approve potentially destructive Git operations"
);
for (tool, args) in [
("bash", json!({"command": "cargo test"})),
("runtime", json!({"tasks": ["external work"]})),
(
"dynamic_workflow",
json!({"source": "async function run() {}"}),
),
] {
assert_eq!(
auto.check(tool, &args),
PermissionDecision::Ask,
"auto mode must retain HITL for unbounded or external operation {tool}"
);
}
assert_eq!(
auto.check(
"batch",
&json!({"invocations": [
{"tool": "read", "args": {"file_path": "README.md"}},
{"tool": "write", "args": {"file_path": "README.md"}}
]})
),
PermissionDecision::Allow
);
assert_eq!(
auto.check(
"batch",
&json!({"invocations": [
{"tool": "read", "args": {"file_path": "README.md"}},
{"tool": "bash", "args": {"command": "cargo test"}}
]})
),
PermissionDecision::Ask
);
for args in [json!({}), json!({"file_path": 7}), json!({"file_path": ""})] {
assert_eq!(
auto.check("write", &args),
PermissionDecision::Ask,
"auto mode must not approve malformed writes: {args}"
);
assert_eq!(
auto.check("edit", &args),
PermissionDecision::Ask,
"auto mode must not approve malformed edits: {args}"
);
}
for args in [
json!({}),
json!({"command": "not-a-real-git-operation"}),
json!({"command": "checkout", "ref": "feature", "force": "yes"}),
json!({"command": "remote", "path": "https://example.com/repo.git"}),
json!({"command": "status", "target": "unexpected"}),
json!({"command": "diff", "target": "--ext-diff"}),
json!({"command": "checkout", "ref": "-f"}),
] {
assert_eq!(
auto.check("git", &args),
PermissionDecision::Ask,
"auto mode must not approve malformed or unknown Git calls: {args}"
);
}
}
#[cfg(unix)]
#[test]
fn interactive_guardrail_denies_paths_through_existing_symlinks() {
use std::os::unix::fs::symlink;
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
symlink(outside.path(), workspace.path().join("escape")).unwrap();
let guardrail = InteractiveToolGuardrail::default().with_workspace(workspace.path());
for (tool, args) in [
("read", json!({"file_path": "escape/secret.txt"})),
("write", json!({"file_path": "escape/new.txt"})),
("ls", json!({"path": "escape"})),
(
"batch",
json!({"invocations": [
{"tool": "read", "args": {"file_path": "README.md"}},
{"tool": "write", "args": {"file_path": "escape/new.txt"}}
]}),
),
] {
assert_eq!(
guardrail.check(tool, &args),
PermissionDecision::Deny,
"{tool} must not traverse a workspace symlink"
);
}
assert_eq!(
guardrail.check("bash", &json!({"command": "cat escape/secret.txt"})),
PermissionDecision::Deny,
"shell calls through a workspace symlink must be blocked"
);
}
#[test]
fn interactive_guardrail_batch_aggregates_risk_and_rejects_malformed_calls() {
let guardrail = InteractiveToolGuardrail::default();
assert_eq!(
guardrail.check(
"batch",
&json!({"invocations": [
{"tool": "read", "args": {"file_path": "README.md"}},
{"tool": "git", "args": {"command": "status"}}
]})
),
PermissionDecision::Allow
);
assert_eq!(
guardrail.check(
"batch",
&json!({"invocations": [
{"tool": "read", "args": {"file_path": "README.md"}},
{"tool": "write", "args": {"file_path": "README.md"}}
]})
),
PermissionDecision::Ask
);
assert_eq!(
guardrail.check(
"batch",
&json!({"invocations": [
{"tool": "write", "args": {"file_path": "README.md"}},
{"tool": "bash", "args": {"command": "rm -rf /"}}
]})
),
PermissionDecision::Deny
);
assert_eq!(
guardrail.check(
"batch",
&json!({"invocations": [
{"tool": "batch", "args": {"invocations": [
{"tool": "bash", "args": {"command": "rm -rf /"}}
]}}
]})
),
PermissionDecision::Deny,
"nested batches must retain hard-deny aggregation"
);
for malformed in [
json!({}),
json!({"invocations": []}),
json!({"invocations": [{"tool": "read"}]}),
json!({"invocations": [{"args": {}}]}),
json!({"invocations": [{"tool": "batch", "args": {}}]}),
] {
assert_eq!(
guardrail.check("batch", &malformed),
PermissionDecision::Ask
);
}
}
#[test]
fn test_rule_parse_simple() {
let rule = PermissionRule::new("Bash");
assert_eq!(rule.tool_name, Some("Bash".to_string()));
assert_eq!(rule.arg_pattern, None);
}
#[test]
fn test_rule_parse_with_pattern() {
let rule = PermissionRule::new("Bash(cargo:*)");
assert_eq!(rule.tool_name, Some("Bash".to_string()));
assert_eq!(rule.arg_pattern, Some("cargo:*".to_string()));
}
#[test]
fn test_rule_parse_wildcard() {
let rule = PermissionRule::new("Grep(*)");
assert_eq!(rule.tool_name, Some("Grep".to_string()));
assert_eq!(rule.arg_pattern, Some("*".to_string()));
}
#[test]
fn test_rule_match_tool_only() {
let rule = PermissionRule::new("Bash");
assert!(rule.matches("Bash", &json!({"command": "ls -la"})));
assert!(rule.matches("bash", &json!({"command": "echo hello"})));
assert!(!rule.matches("Read", &json!({})));
}
#[test]
fn test_rule_match_wildcard() {
let rule = PermissionRule::new("Grep(*)");
assert!(rule.matches("Grep", &json!({"pattern": "foo", "path": "/tmp"})));
assert!(rule.matches("grep", &json!({"pattern": "bar"})));
}
#[test]
fn test_rule_match_prefix_wildcard() {
let rule = PermissionRule::new("Bash(cargo:*)");
assert!(rule.matches("Bash", &json!({"command": "cargo build"})));
assert!(rule.matches("Bash", &json!({"command": "cargo test --lib"})));
assert!(rule.matches("Bash", &json!({"command": "cargo"})));
assert!(!rule.matches("Bash", &json!({"command": "npm install"})));
}
#[test]
fn test_rule_match_npm_commands() {
let rule = PermissionRule::new("Bash(npm run:*)");
assert!(rule.matches("Bash", &json!({"command": "npm run test"})));
assert!(rule.matches("Bash", &json!({"command": "npm run build"})));
assert!(!rule.matches("Bash", &json!({"command": "npm install"})));
}
#[test]
fn test_rule_match_file_path() {
let rule = PermissionRule::new("Read(src/*.rs)");
assert!(rule.matches("Read", &json!({"file_path": "src/main.rs"})));
assert!(rule.matches("Read", &json!({"file_path": "src/lib.rs"})));
assert!(!rule.matches("Read", &json!({"file_path": "src/foo/bar.rs"})));
}
#[test]
fn test_rule_match_recursive_glob() {
let rule = PermissionRule::new("Read(src/**/*.rs)");
assert!(rule.matches("Read", &json!({"file_path": "src/main.rs"})));
assert!(rule.matches("Read", &json!({"file_path": "src/foo/bar.rs"})));
assert!(rule.matches("Read", &json!({"file_path": "src/a/b/c.rs"})));
}
#[test]
fn test_rule_match_mcp_tool() {
let rule = PermissionRule::new("mcp__pencil");
assert!(rule.matches("mcp__pencil", &json!({})));
assert!(rule.matches("mcp__pencil__batch_design", &json!({})));
assert!(rule.matches("mcp__pencil__batch_get", &json!({})));
assert!(!rule.matches("mcp__other", &json!({})));
}
#[test]
fn test_rule_match_mcp_tool_wildcard() {
let rule = PermissionRule::new("mcp__longvt__*");
assert!(rule.matches("mcp__longvt__search", &json!({})));
assert!(rule.matches("mcp__longvt__create_memory", &json!({})));
assert!(rule.matches("mcp__longvt__delete", &json!({})));
assert!(!rule.matches("mcp__pencil__batch_design", &json!({})));
assert!(!rule.matches("mcp__other__tool", &json!({})));
let rule_all = PermissionRule::new("mcp__*");
assert!(rule_all.matches("mcp__longvt__search", &json!({})));
assert!(rule_all.matches("mcp__pencil__draw", &json!({})));
assert!(!rule_all.matches("bash", &json!({})));
}
#[test]
fn test_rule_case_insensitive() {
let rule = PermissionRule::new("BASH(cargo:*)");
assert!(rule.matches("Bash", &json!({"command": "cargo build"})));
assert!(rule.matches("bash", &json!({"command": "cargo test"})));
assert!(rule.matches("BASH", &json!({"command": "cargo check"})));
}
#[test]
fn test_policy_default() {
let policy = PermissionPolicy::default();
assert!(policy.enabled);
assert_eq!(policy.default_decision, PermissionDecision::Ask);
assert!(policy.allow.is_empty());
assert!(policy.deny.is_empty());
assert!(policy.ask.is_empty());
}
#[test]
fn test_policy_explicit_allow_default() {
let policy = PermissionPolicy {
default_decision: PermissionDecision::Allow,
..PermissionPolicy::default()
};
assert_eq!(policy.default_decision, PermissionDecision::Allow);
}
#[test]
fn test_policy_strict() {
let policy = PermissionPolicy::strict();
assert_eq!(policy.default_decision, PermissionDecision::Ask);
}
#[test]
fn test_policy_builder() {
let policy = PermissionPolicy::new()
.allow("Bash(cargo:*)")
.allow("Grep(*)")
.deny("Bash(rm -rf:*)")
.ask("Write(*)");
assert_eq!(policy.allow.len(), 2);
assert_eq!(policy.deny.len(), 1);
assert_eq!(policy.ask.len(), 1);
}
#[test]
fn test_policy_check_allow() {
let policy = PermissionPolicy::new().allow("Bash(cargo:*)");
let decision = policy.check("Bash", &json!({"command": "cargo build"}));
assert_eq!(decision, PermissionDecision::Allow);
}
#[test]
fn test_policy_check_deny() {
let policy = PermissionPolicy::new().deny("Bash(rm -rf:*)");
let decision = policy.check("Bash", &json!({"command": "rm -rf /"}));
assert_eq!(decision, PermissionDecision::Deny);
}
#[test]
fn test_policy_check_ask() {
let policy = PermissionPolicy::new().ask("Write(*)");
let decision = policy.check("Write", &json!({"file_path": "/tmp/test.txt"}));
assert_eq!(decision, PermissionDecision::Ask);
}
#[test]
fn test_policy_check_default() {
let policy = PermissionPolicy::new();
let decision = policy.check("Unknown", &json!({}));
assert_eq!(decision, PermissionDecision::Ask);
}
#[test]
fn test_policy_deny_wins_over_allow() {
let policy = PermissionPolicy::new().allow("Bash(*)").deny("Bash(rm:*)");
let decision = policy.check("Bash", &json!({"command": "rm -rf /tmp"}));
assert_eq!(decision, PermissionDecision::Deny);
let decision = policy.check("Bash", &json!({"command": "ls -la"}));
assert_eq!(decision, PermissionDecision::Allow);
}
#[test]
fn test_policy_allow_wins_over_ask() {
let policy = PermissionPolicy::new()
.allow("Bash(cargo:*)")
.ask("Bash(*)");
let decision = policy.check("Bash", &json!({"command": "cargo build"}));
assert_eq!(decision, PermissionDecision::Allow);
let decision = policy.check("Bash", &json!({"command": "npm install"}));
assert_eq!(decision, PermissionDecision::Ask);
}
#[test]
fn test_policy_disabled() {
let mut policy = PermissionPolicy::new().deny("Bash(rm:*)").ask("Bash(*)");
policy.enabled = false;
let decision = policy.check("Bash", &json!({"command": "rm -rf /"}));
assert_eq!(decision, PermissionDecision::Allow);
}
#[test]
fn deny_by_default_exposes_only_tools_with_declared_rules() {
let mut policy = PermissionPolicy::new().allow("mcp__use_*");
policy.default_decision = PermissionDecision::Deny;
assert!(policy.expose_to_model("mcp__use_browser__browser_open"));
assert!(!policy.expose_to_model("mcp__github__search"));
assert!(!policy.expose_to_model("read"));
assert!(!policy.expose_to_model("bash"));
assert!(!policy.expose_to_model("task"));
}
#[test]
fn argument_scoped_rules_keep_potentially_allowed_tool_visible() {
let mut policy = PermissionPolicy::new()
.allow("Bash(cargo:*)")
.deny("Bash(rm:*)");
policy.default_decision = PermissionDecision::Deny;
assert!(policy.expose_to_model("bash"));
assert_eq!(
policy.check("bash", &json!({"command": "cargo test"})),
PermissionDecision::Allow
);
assert_eq!(
policy.check("bash", &json!({"command": "rm -rf /"})),
PermissionDecision::Deny
);
}
#[test]
fn whole_tool_deny_hides_tool_even_when_default_is_allow() {
let policy = PermissionPolicy::new()
.allow("mcp__use_*")
.deny("mcp__use_office__*");
assert!(policy.expose_to_model("mcp__use_browser__browser_open"));
assert!(!policy.expose_to_model("mcp__use_office__office_write"));
}
#[test]
fn test_policy_is_allowed() {
let policy = PermissionPolicy::new().allow("Bash(cargo:*)");
assert!(policy.is_allowed("Bash", &json!({"command": "cargo build"})));
assert!(!policy.is_allowed("Bash", &json!({"command": "npm install"})));
}
#[test]
fn test_policy_is_denied() {
let policy = PermissionPolicy::new().deny("Bash(rm:*)");
assert!(policy.is_denied("Bash", &json!({"command": "rm -rf /"})));
assert!(!policy.is_denied("Bash", &json!({"command": "ls -la"})));
}
#[test]
fn test_policy_requires_confirmation() {
let mut policy = PermissionPolicy::new().allow("Read(*)").ask("Write(*)");
policy.default_decision = PermissionDecision::Deny;
assert!(policy.requires_confirmation("Write", &json!({"file_path": "/tmp/test"})));
assert!(!policy.requires_confirmation("Read", &json!({"file_path": "/tmp/test"})));
}
#[test]
fn test_policy_matching_rules() {
let policy = PermissionPolicy::new()
.allow("Bash(cargo:*)")
.deny("Bash(cargo fmt:*)")
.ask("Bash(*)");
let matching = policy.get_matching_rules("Bash", &json!({"command": "cargo fmt"}));
assert_eq!(matching.deny.len(), 1);
assert_eq!(matching.allow.len(), 1);
assert_eq!(matching.ask.len(), 1);
}
#[test]
fn test_policy_allow_all() {
let policy = PermissionPolicy::new().allow_all(&["Bash(cargo:*)", "Bash(npm:*)", "Grep(*)"]);
assert_eq!(policy.allow.len(), 3);
assert!(policy.is_allowed("Bash", &json!({"command": "cargo build"})));
assert!(policy.is_allowed("Bash", &json!({"command": "npm run test"})));
assert!(policy.is_allowed("Grep", &json!({"pattern": "foo"})));
}
#[test]
fn test_rule_deserialize_plain_string() {
let rule: PermissionRule = serde_yaml::from_str("read").unwrap();
assert_eq!(rule.rule, "read");
assert!(rule.matches("read", &json!({})));
assert!(!rule.matches("write", &json!({})));
}
#[test]
fn test_rule_deserialize_plain_string_with_pattern() {
let rule: PermissionRule = serde_yaml::from_str("\"Bash(cargo:*)\"").unwrap();
assert_eq!(rule.rule, "Bash(cargo:*)");
assert!(rule.matches("Bash", &json!({"command": "cargo build"})));
}
#[test]
fn test_rule_deserialize_struct_form() {
let rule: PermissionRule = serde_yaml::from_str("rule: read").unwrap();
assert_eq!(rule.rule, "read");
assert!(rule.matches("read", &json!({})));
}
#[test]
fn test_rule_deserialize_in_policy() {
let yaml = r#"
allow:
- read
- "Bash(cargo:*)"
- rule: grep
deny:
- write
"#;
let policy: PermissionPolicy = serde_yaml::from_str(yaml).unwrap();
assert_eq!(policy.allow.len(), 3);
assert_eq!(policy.deny.len(), 1);
assert!(policy.is_allowed("read", &json!({})));
assert!(policy.is_allowed("Bash", &json!({"command": "cargo build"})));
assert!(policy.is_allowed("grep", &json!({})));
assert!(policy.is_denied("write", &json!({})));
}
#[test]
fn test_manager_default() {
let manager = PermissionManager::new();
assert_eq!(
manager.global_policy().default_decision,
PermissionDecision::Ask
);
}
#[test]
fn test_manager_with_global_policy() {
let policy = PermissionPolicy {
default_decision: PermissionDecision::Allow,
..PermissionPolicy::default()
};
let manager = PermissionManager::with_global_policy(policy);
assert_eq!(
manager.global_policy().default_decision,
PermissionDecision::Allow
);
}
#[test]
fn test_manager_session_policy() {
let mut manager = PermissionManager::new();
let session_policy = PermissionPolicy::new().allow("Bash(cargo:*)");
manager.set_session_policy("session-1", session_policy);
let decision = manager.check("session-1", "Bash", &json!({"command": "cargo build"}));
assert_eq!(decision, PermissionDecision::Allow);
let decision = manager.check("session-2", "Bash", &json!({"command": "cargo build"}));
assert_eq!(decision, PermissionDecision::Ask);
}
#[test]
fn test_manager_remove_session_policy() {
let mut manager = PermissionManager::new();
let session_policy = PermissionPolicy {
default_decision: PermissionDecision::Allow,
..PermissionPolicy::default()
};
manager.set_session_policy("session-1", session_policy);
let decision = manager.check("session-1", "Bash", &json!({"command": "anything"}));
assert_eq!(decision, PermissionDecision::Allow);
manager.remove_session_policy("session-1");
let decision = manager.check("session-1", "Bash", &json!({"command": "anything"}));
assert_eq!(decision, PermissionDecision::Ask);
}
#[test]
fn test_manager_global_deny_overrides_session_allow() {
let mut manager =
PermissionManager::with_global_policy(PermissionPolicy::new().deny("Bash(rm:*)"));
let session_policy = PermissionPolicy::new().allow("Bash(*)");
manager.set_session_policy("session-1", session_policy);
let decision = manager.check("session-1", "Bash", &json!({"command": "rm -rf /"}));
assert_eq!(decision, PermissionDecision::Deny);
let decision = manager.check("session-1", "Bash", &json!({"command": "ls -la"}));
assert_eq!(decision, PermissionDecision::Allow);
}
#[test]
fn test_realistic_dev_policy() {
let policy = PermissionPolicy::new()
.allow_all(&[
"Bash(cargo:*)",
"Bash(npm:*)",
"Bash(pnpm:*)",
"Bash(just:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(echo:*)",
"Grep(*)",
"Glob(*)",
"Ls(*)",
])
.deny_all(&["Bash(rm -rf:*)", "Bash(sudo:*)", "Bash(curl | sh:*)"])
.ask_all(&["Write(*)", "Edit(*)"]);
assert!(policy.is_allowed("Bash", &json!({"command": "cargo build"})));
assert!(policy.is_allowed("Bash", &json!({"command": "npm run test"})));
assert!(policy.is_allowed("Grep", &json!({"pattern": "TODO"})));
assert!(policy.is_denied("Bash", &json!({"command": "rm -rf /"})));
assert!(policy.is_denied("Bash", &json!({"command": "sudo apt install"})));
assert!(policy.requires_confirmation("Write", &json!({"file_path": "/tmp/test.rs"})));
assert!(policy.requires_confirmation("Edit", &json!({"file_path": "src/main.rs"})));
}
#[test]
fn test_mcp_tool_permissions() {
let policy = PermissionPolicy::new()
.allow("mcp__pencil")
.deny("mcp__dangerous");
assert!(policy.is_allowed("mcp__pencil__batch_design", &json!({})));
assert!(policy.is_allowed("mcp__pencil__batch_get", &json!({})));
assert!(policy.is_denied("mcp__dangerous__execute", &json!({})));
}
#[test]
fn test_allow_by_default_with_mcp_wildcard_deny() {
let policy = PermissionPolicy {
default_decision: PermissionDecision::Allow,
..PermissionPolicy::default()
}
.deny("mcp__longvt__*");
assert_eq!(
policy.check("mcp__longvt__search", &json!({})),
PermissionDecision::Deny
);
assert_eq!(
policy.check("mcp__longvt__create_memory", &json!({})),
PermissionDecision::Deny
);
assert_eq!(
policy.check("mcp__pencil__draw", &json!({})),
PermissionDecision::Allow
);
assert_eq!(
policy.check("bash", &json!({"command": "ls"})),
PermissionDecision::Allow
);
}
#[test]
fn test_serialization() {
let policy = PermissionPolicy::new()
.allow("Bash(cargo:*)")
.deny("Bash(rm:*)");
let json = serde_json::to_string(&policy).unwrap();
let deserialized: PermissionPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.allow.len(), 1);
assert_eq!(deserialized.deny.len(), 1);
}
#[test]
fn test_matching_rules_is_empty() {
let rules = MatchingRules {
deny: vec![],
allow: vec![],
ask: vec![],
};
assert!(rules.is_empty());
let rules = MatchingRules {
deny: vec!["Bash".to_string()],
allow: vec![],
ask: vec![],
};
assert!(!rules.is_empty());
let rules = MatchingRules {
deny: vec![],
allow: vec!["Read".to_string()],
ask: vec![],
};
assert!(!rules.is_empty());
let rules = MatchingRules {
deny: vec![],
allow: vec![],
ask: vec!["Write".to_string()],
};
assert!(!rules.is_empty());
}
#[test]
fn test_permission_manager_default() {
let pm = PermissionManager::default();
let policy = pm.global_policy();
assert!(policy.allow.is_empty());
assert!(policy.deny.is_empty());
assert!(policy.ask.is_empty());
}
#[test]
fn test_permission_manager_set_global_policy() {
let mut pm = PermissionManager::new();
let policy = PermissionPolicy::new().allow("Bash(*)");
pm.set_global_policy(policy);
assert_eq!(pm.global_policy().allow.len(), 1);
}
#[test]
fn test_permission_manager_session_policy() {
let mut pm = PermissionManager::new();
let policy = PermissionPolicy::new().deny("Bash(rm:*)");
pm.set_session_policy("s1", policy);
let effective = pm.get_effective_policy("s1");
assert_eq!(effective.deny.len(), 1);
let global = pm.get_effective_policy("s2");
assert!(global.deny.is_empty());
}
#[test]
fn test_permission_manager_remove_session_policy() {
let mut pm = PermissionManager::new();
pm.set_session_policy("s1", PermissionPolicy::new().deny("Bash(*)"));
assert_eq!(pm.get_effective_policy("s1").deny.len(), 1);
pm.remove_session_policy("s1");
assert!(pm.get_effective_policy("s1").deny.is_empty());
}
#[test]
fn test_permission_manager_check_deny() {
let mut pm = PermissionManager::new();
pm.set_global_policy(PermissionPolicy::new().deny("Bash(rm:*)"));
let decision = pm.check("s1", "Bash", &json!({"command": "rm -rf /"}));
assert_eq!(decision, PermissionDecision::Deny);
}
#[test]
fn test_permission_manager_check_allow() {
let mut pm = PermissionManager::new();
pm.set_global_policy(PermissionPolicy::new().allow("Bash(cargo:*)"));
let decision = pm.check("s1", "Bash", &json!({"command": "cargo build"}));
assert_eq!(decision, PermissionDecision::Allow);
}
#[test]
fn test_permission_manager_check_session_override() {
let mut pm = PermissionManager::new();
pm.set_global_policy(PermissionPolicy::new().allow("Bash(*)"));
pm.set_session_policy("s1", PermissionPolicy::new().deny("Bash(rm:*)"));
let decision = pm.check("s1", "Bash", &json!({"command": "rm -rf /"}));
assert_eq!(decision, PermissionDecision::Deny);
let decision = pm.check("s2", "Bash", &json!({"command": "rm -rf /"}));
assert_eq!(decision, PermissionDecision::Allow);
}
#[test]
fn test_permission_manager_with_global_policy() {
let policy = PermissionPolicy::new().allow("Read(*)").deny("Write(*)");
let pm = PermissionManager::with_global_policy(policy);
assert_eq!(pm.global_policy().allow.len(), 1);
assert_eq!(pm.global_policy().deny.len(), 1);
}