use serde::{Deserialize, Serialize};
use std::sync::RwLock;
use crate::config::{PermissionsMode, active_permissions_mode};
use crate::hooks::decision::HookDecision;
use crate::hooks::events::MemoryDelta;
pub(crate) const GOVERNANCE_TRACE_TARGET: &str = "governance";
pub mod agent_action;
pub mod audit;
pub mod deferred_audit;
pub mod rule_cache;
pub mod rules_store;
pub mod wire_check;
pub mod refusal;
pub use refusal::GovernanceRefusal;
pub const OP_MEMORY_ARCHIVE: &str = "memory_archive";
pub mod action_labels {
pub const ARCHIVE_PURGE: &str = "archive_purge";
pub const ARCHIVE_RESTORE: &str = "archive_restore";
pub const KG_INVALIDATE: &str = "kg_invalidate";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Op {
MemoryStore,
MemoryLink,
MemoryDelete,
MemoryArchive,
MemoryConsolidate,
MemoryReplay,
}
impl Op {
#[must_use]
pub fn as_str(self) -> &'static str {
use crate::mcp::registry::tool_names as tn;
match self {
Op::MemoryStore => tn::MEMORY_STORE,
Op::MemoryLink => tn::MEMORY_LINK,
Op::MemoryDelete => tn::MEMORY_DELETE,
Op::MemoryArchive => OP_MEMORY_ARCHIVE,
Op::MemoryConsolidate => tn::MEMORY_CONSOLIDATE,
Op::MemoryReplay => tn::MEMORY_REPLAY,
}
}
#[must_use]
pub fn from_str(s: &str) -> Option<Op> {
use crate::mcp::registry::tool_names as tn;
match s {
tn::MEMORY_STORE => Some(Op::MemoryStore),
tn::MEMORY_LINK => Some(Op::MemoryLink),
tn::MEMORY_DELETE => Some(Op::MemoryDelete),
OP_MEMORY_ARCHIVE => Some(Op::MemoryArchive),
tn::MEMORY_CONSOLIDATE => Some(Op::MemoryConsolidate),
tn::MEMORY_REPLAY => Some(Op::MemoryReplay),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Decision {
Allow,
Deny(String),
Modify(MemoryDelta),
Ask(String),
}
#[derive(Debug, Clone)]
pub struct PermissionContext {
pub op: Op,
pub namespace: String,
pub agent_id: String,
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PermissionRule {
pub namespace_pattern: String,
pub op: String,
#[serde(default = "default_agent_pattern")]
pub agent_pattern: String,
pub decision: RuleDecision,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
fn default_agent_pattern() -> String {
"*".to_string()
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RuleDecision {
Allow,
Deny,
Ask,
}
#[must_use]
pub fn glob_matches(pattern: &str, value: &str) -> bool {
glob_inner(pattern.as_bytes(), value.as_bytes())
}
fn glob_inner(pat: &[u8], val: &[u8]) -> bool {
let (mut p, mut v) = (0usize, 0usize);
let (mut star_p, mut star_v): (Option<usize>, usize) = (None, 0);
let mut star_double = false;
while v < val.len() {
if p < pat.len() {
if pat[p] == b'*' {
let double = p + 1 < pat.len() && pat[p + 1] == b'*';
star_p = Some(p);
star_double = double;
p += if double { 2 } else { 1 };
star_v = v;
continue;
}
if pat[p] == val[v] {
p += 1;
v += 1;
continue;
}
}
if let Some(sp) = star_p {
if !star_double && val[star_v] == b'/' {
return false;
}
star_v += 1;
if !star_double && star_v <= val.len() && {
false
} {
return false;
}
p = sp + if star_double { 2 } else { 1 };
v = star_v;
continue;
}
return false;
}
while p < pat.len() && pat[p] == b'*' {
p += 1;
}
p == pat.len()
}
#[must_use]
pub fn pattern_specificity(pattern: &str) -> usize {
pattern.bytes().take_while(|b| *b != b'*').count()
}
pub struct Permissions;
impl Permissions {
#[must_use]
pub fn evaluate(ctx: &PermissionContext, hook_decisions: &[HookDecision]) -> Decision {
let mut rules = synthetic_rules_as_permission_rules();
rules.extend(active_permission_rules());
Self::evaluate_with(ctx, hook_decisions, &rules, active_permissions_mode())
}
#[must_use]
pub fn evaluate_with(
ctx: &PermissionContext,
hook_decisions: &[HookDecision],
rules: &[PermissionRule],
mode: PermissionsMode,
) -> Decision {
if mode == PermissionsMode::Off {
return Decision::Allow;
}
let pinned_ns = ctx.namespace.clone();
let matched = matched_rules(ctx, rules);
let rule_outcomes: Vec<&PermissionRule> = matched;
for r in &rule_outcomes {
if matches!(r.decision, RuleDecision::Deny) {
return Decision::Deny(r.reason.clone().unwrap_or_else(|| {
format!(
"denied by permission rule (namespace_pattern={}, op={}, agent_pattern={})",
r.namespace_pattern, r.op, r.agent_pattern
)
}));
}
}
for h in hook_decisions {
if let HookDecision::Deny { reason, .. } = h {
return Decision::Deny(reason.clone());
}
}
let mut composed: Option<MemoryDelta> = None;
for h in hook_decisions {
if let HookDecision::Modify(payload) = h {
let next = payload.delta.clone();
composed = Some(merge_delta(composed.take(), next));
}
}
if let Some(delta) = composed {
if let Some(new_ns) = delta.namespace.as_deref()
&& new_ns != pinned_ns
{
let rebound_ctx = PermissionContext {
op: ctx.op,
namespace: new_ns.to_string(),
agent_id: ctx.agent_id.clone(),
payload: ctx.payload.clone(),
};
let rebound = Self::evaluate_with(&rebound_ctx, &[], rules, mode);
match rebound {
Decision::Deny(reason) => {
return Decision::Deny(format!(
"namespace escalation rejected: hook proposed Modify into \
namespace {new_ns:?} (from pinned {pinned_ns:?}) which is denied: \
{reason}"
));
}
Decision::Ask(reason) => {
return Decision::Ask(format!(
"namespace escalation requires approval: hook proposed Modify \
into namespace {new_ns:?} (from pinned {pinned_ns:?}); \
rebound rule prompts: {reason}"
));
}
Decision::Allow | Decision::Modify(_) => {}
}
}
return Decision::Modify(delta);
}
let any_rule_allow = rule_outcomes
.iter()
.any(|r| matches!(r.decision, RuleDecision::Allow));
let any_hook_allow = hook_decisions
.iter()
.any(|h| matches!(h, HookDecision::Allow));
if any_rule_allow || any_hook_allow {
return Decision::Allow;
}
let any_rule_ask = rule_outcomes
.iter()
.find(|r| matches!(r.decision, RuleDecision::Ask));
let any_hook_ask = hook_decisions
.iter()
.find(|h| matches!(h, HookDecision::AskUser { .. }));
let prompt = if let Some(r) = any_rule_ask {
r.reason.clone().unwrap_or_else(|| {
format!(
"permission rule requests approval (namespace_pattern={}, op={})",
r.namespace_pattern, r.op
)
})
} else if let Some(HookDecision::AskUser { prompt, .. }) = any_hook_ask {
prompt.clone()
} else {
return mode_default_for(mode, ctx);
};
match mode {
PermissionsMode::Enforce => Decision::Deny(format!(
"permission ask escalated to deny under enforce mode: {prompt}"
)),
PermissionsMode::Advisory | PermissionsMode::Off => Decision::Ask(prompt),
}
}
}
fn mode_default_for(_mode: PermissionsMode, _ctx: &PermissionContext) -> Decision {
Decision::Allow
}
fn matched_rules<'a>(
ctx: &PermissionContext,
rules: &'a [PermissionRule],
) -> Vec<&'a PermissionRule> {
let mut hits: Vec<&PermissionRule> = rules
.iter()
.filter(|r| {
r.op == ctx.op.as_str()
&& glob_matches(&r.namespace_pattern, &ctx.namespace)
&& glob_matches(&r.agent_pattern, &ctx.agent_id)
})
.collect();
hits.sort_by(|a, b| {
let sa = (
pattern_specificity(&a.namespace_pattern),
usize::from(!a.agent_pattern.contains('*')),
);
let sb = (
pattern_specificity(&b.namespace_pattern),
usize::from(!b.agent_pattern.contains('*')),
);
sb.cmp(&sa)
});
hits
}
fn merge_delta(prior: Option<MemoryDelta>, next: MemoryDelta) -> MemoryDelta {
let mut out = prior.unwrap_or_default();
if next.tier.is_some() {
out.tier = next.tier;
}
if next.namespace.is_some() {
out.namespace = next.namespace;
}
if next.title.is_some() {
out.title = next.title;
}
if next.content.is_some() {
out.content = next.content;
}
if next.tags.is_some() {
out.tags = next.tags;
}
if next.priority.is_some() {
out.priority = next.priority;
}
if next.confidence.is_some() {
out.confidence = next.confidence;
}
if next.source.is_some() {
out.source = next.source;
}
if next.expires_at.is_some() {
out.expires_at = next.expires_at;
}
if next.metadata.is_some() {
out.metadata = next.metadata;
}
out
}
fn op_matches_action_type(op: Op, action_type: &str) -> bool {
match (op, action_type) {
(Op::MemoryStore, "store")
| (Op::MemoryDelete, "delete")
| (Op::MemoryArchive, "archive" | "promote")
| (Op::MemoryConsolidate, crate::audit::OP_CONSOLIDATE)
| (Op::MemoryLink, "link") => true,
_ => false,
}
}
fn synthetic_rules_as_permission_rules() -> Vec<PermissionRule> {
let synth = crate::approvals::list_synthetic_rules();
let mut out: Vec<PermissionRule> = Vec::with_capacity(synth.len());
let ops = [
Op::MemoryStore,
Op::MemoryDelete,
Op::MemoryArchive,
Op::MemoryConsolidate,
Op::MemoryLink,
];
for s in synth {
let decision = match s.decision.as_str() {
"approve" | "allow" => RuleDecision::Allow,
"deny" | "reject" => RuleDecision::Deny,
other => {
tracing::warn!(
"ignoring synthetic permission rule with unknown decision verb: {other:?}"
);
continue;
}
};
let agent_pattern = s.agent_id.clone().unwrap_or_else(|| "*".to_string());
for op in ops {
if !op_matches_action_type(op, &s.action_type) {
continue;
}
out.push(PermissionRule {
namespace_pattern: s.namespace.clone(),
op: op.as_str().to_string(),
agent_pattern: agent_pattern.clone(),
decision,
reason: Some(format!(
"remembered operator decision (recorded_at={})",
s.recorded_at
)),
});
}
}
out
}
#[must_use]
pub fn default_v07_secure_mode() -> PermissionsMode {
PermissionsMode::Enforce
}
#[must_use]
pub fn resolve_v07_default_mode(
configured: Option<PermissionsMode>,
) -> (PermissionsMode, Option<String>) {
if let Some(mode) = configured {
return (mode, None);
}
let warning = "v0.7.0 default changed to enforce; set permissions.mode=advisory in config to \
opt out — see release notes."
.to_string();
(default_v07_secure_mode(), Some(warning))
}
#[must_use]
pub fn startup_banner_line(mode: PermissionsMode) -> String {
format!("permissions: {}", mode.as_str())
}
static ACTIVE_PERMISSION_RULES: RwLock<Vec<PermissionRule>> = RwLock::new(Vec::new());
pub fn set_active_permission_rules(rules: Vec<PermissionRule>) {
if let Ok(mut w) = ACTIVE_PERMISSION_RULES.write() {
*w = rules;
}
}
#[must_use]
pub fn active_permission_rules() -> Vec<PermissionRule> {
ACTIVE_PERMISSION_RULES
.read()
.map(|g| g.clone())
.unwrap_or_default()
}
#[doc(hidden)]
pub fn clear_active_permission_rules_for_test() {
set_active_permission_rules(Vec::new());
}
#[must_use]
pub fn deny_message(action: &str, gate: DenyGate, reason: &str) -> String {
let gate_str = gate.as_str();
let mut out = String::with_capacity(action.len() + 12 + gate_str.len() + 2 + reason.len());
out.push_str(action);
out.push_str(" denied by ");
out.push_str(gate_str);
out.push_str(": ");
out.push_str(reason);
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DenyGate {
PermissionRule,
Governance,
}
impl DenyGate {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
DenyGate::PermissionRule => "permission rule",
DenyGate::Governance => crate::models::field_names::GOVERNANCE,
}
}
}
#[cfg(test)]
mod deny_message_tests {
use super::*;
#[test]
fn governance_shape_byte_identical_to_pre_971_format() {
let got = deny_message("store", DenyGate::Governance, "policy XYZ denies write");
assert_eq!(got, "store denied by governance: policy XYZ denies write");
}
#[test]
fn permission_rule_shape_byte_identical_to_pre_971_format() {
let got = deny_message("delete", DenyGate::PermissionRule, "rule R1 deny");
assert_eq!(got, "delete denied by permission rule: rule R1 deny");
}
#[test]
fn empty_reason_does_not_panic() {
let got = deny_message("archive", DenyGate::Governance, "");
assert_eq!(got, "archive denied by governance: ");
}
#[test]
fn long_action_verb_with_underscores() {
let got = deny_message("kg_invalidate", DenyGate::PermissionRule, "K9");
assert_eq!(got, "kg_invalidate denied by permission rule: K9");
}
#[test]
fn gate_as_str_round_trips() {
assert_eq!(DenyGate::PermissionRule.as_str(), "permission rule");
assert_eq!(DenyGate::Governance.as_str(), "governance");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(op: Op, ns: &str, agent: &str) -> PermissionContext {
PermissionContext {
op,
namespace: ns.to_string(),
agent_id: agent.to_string(),
payload: serde_json::Value::Null,
}
}
fn rule(ns_pat: &str, op: &str, agent_pat: &str, dec: RuleDecision) -> PermissionRule {
PermissionRule {
namespace_pattern: ns_pat.to_string(),
op: op.to_string(),
agent_pattern: agent_pat.to_string(),
decision: dec,
reason: Some(format!("test:{ns_pat}/{op}/{agent_pat}")),
}
}
#[test]
fn glob_exact_match() {
assert!(glob_matches("foo", "foo"));
assert!(!glob_matches("foo", "bar"));
assert!(glob_matches("", ""));
}
#[test]
fn glob_single_star_within_segment() {
assert!(glob_matches("ai:*", "ai:claude"));
assert!(glob_matches("ai:*", "ai:claude-1"));
assert!(!glob_matches("foo/*", "foo/bar/baz"));
}
#[test]
fn glob_double_star_across_segments() {
assert!(glob_matches("foo/**", "foo/bar/baz"));
assert!(glob_matches("**", "anything/at/all"));
}
#[test]
fn rule_deny_short_circuits_pipeline() {
let r = rule("secrets/*", "memory_store", "ai:*", RuleDecision::Deny);
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "secrets/api", "ai:claude"),
&[],
&[r],
PermissionsMode::Enforce,
);
assert!(matches!(d, Decision::Deny(_)));
}
#[test]
fn rule_allow_returns_allow() {
let r = rule("public/*", "memory_store", "*", RuleDecision::Allow);
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "public/blog", "human:alice"),
&[],
&[r],
PermissionsMode::Enforce,
);
assert_eq!(d, Decision::Allow);
}
#[test]
fn off_mode_short_circuits_to_allow() {
let r = rule("**", "memory_store", "*", RuleDecision::Deny);
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "secrets/api", "ai:claude"),
&[],
&[r],
PermissionsMode::Off,
);
assert_eq!(d, Decision::Allow);
}
#[test]
fn no_match_defaults_to_allow() {
let r = rule("secrets/*", "memory_store", "*", RuleDecision::Deny);
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "public/blog", "human:alice"),
&[],
&[r],
PermissionsMode::Enforce,
);
assert_eq!(d, Decision::Allow);
}
#[test]
fn op_as_str_round_trips() {
for op in [
Op::MemoryStore,
Op::MemoryLink,
Op::MemoryDelete,
Op::MemoryArchive,
Op::MemoryConsolidate,
Op::MemoryReplay,
] {
assert_eq!(Op::from_str(op.as_str()), Some(op));
}
}
#[test]
fn specificity_orders_long_prefix_first() {
assert!(pattern_specificity("secrets/api/v1") > pattern_specificity("secrets/*"));
assert!(pattern_specificity("secrets/*") > pattern_specificity("**"));
}
#[test]
fn default_v07_secure_mode_is_enforce() {
assert_eq!(default_v07_secure_mode(), PermissionsMode::Enforce);
}
#[test]
fn resolve_v07_default_mode_unconfigured_yields_enforce_with_warning() {
let (mode, warning) = resolve_v07_default_mode(None);
assert_eq!(mode, PermissionsMode::Enforce);
let w = warning.expect("expected migration warning when mode is unconfigured");
assert!(w.contains("v0.7.0 default changed to enforce"), "got: {w}");
assert!(w.contains("permissions.mode=advisory"), "got: {w}");
}
#[test]
fn resolve_v07_default_mode_configured_passes_through() {
let (mode, warning) = resolve_v07_default_mode(Some(PermissionsMode::Advisory));
assert_eq!(mode, PermissionsMode::Advisory);
assert!(warning.is_none(), "no warning when operator opted in");
let (mode, warning) = resolve_v07_default_mode(Some(PermissionsMode::Off));
assert_eq!(mode, PermissionsMode::Off);
assert!(warning.is_none());
let (mode, warning) = resolve_v07_default_mode(Some(PermissionsMode::Enforce));
assert_eq!(mode, PermissionsMode::Enforce);
assert!(warning.is_none());
}
#[test]
fn startup_banner_line_includes_mode_str() {
assert_eq!(
startup_banner_line(PermissionsMode::Enforce),
"permissions: enforce"
);
assert_eq!(
startup_banner_line(PermissionsMode::Advisory),
"permissions: advisory"
);
assert_eq!(
startup_banner_line(PermissionsMode::Off),
"permissions: off"
);
}
#[test]
fn op_from_str_unknown_returns_none() {
assert!(Op::from_str("not_a_real_op").is_none());
assert!(Op::from_str("").is_none());
}
#[test]
fn glob_matches_empty_pattern_only_matches_empty_value() {
assert!(glob_matches("", ""));
assert!(!glob_matches("", "anything"));
}
#[test]
fn glob_matches_pattern_longer_than_value_fails() {
assert!(!glob_matches("longpattern", "x"));
}
#[test]
fn pattern_specificity_increasing_order() {
let s1 = pattern_specificity("**");
let s2 = pattern_specificity("foo/*");
let s3 = pattern_specificity("foo/bar");
let s4 = pattern_specificity("foo/bar/baz/qux/quux");
assert!(s2 > s1);
assert!(s3 > s2);
assert!(s4 > s3);
}
#[test]
fn hook_deny_propagates_through_pipeline() {
let hook_decisions = vec![HookDecision::Deny {
reason: "hook said no".into(),
code: 403,
}];
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "public/blog", "ai:claude"),
&hook_decisions,
&[],
PermissionsMode::Enforce,
);
match d {
Decision::Deny(reason) => assert!(reason.contains("hook said no")),
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn hook_modify_composes_into_decision() {
let delta = MemoryDelta {
tags: Some(vec!["hooked".into()]),
..Default::default()
};
let hook_decisions = vec![HookDecision::Modify(
crate::hooks::decision::ModifyPayload { delta },
)];
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "public/blog", "ai:claude"),
&hook_decisions,
&[],
PermissionsMode::Enforce,
);
match d {
Decision::Modify(delta) => {
assert_eq!(delta.tags.as_deref(), Some(&["hooked".to_string()][..]));
}
other => panic!("expected Modify, got {other:?}"),
}
}
#[test]
fn hook_modify_namespace_escalation_rejected_when_destination_denied() {
let delta = MemoryDelta {
namespace: Some("secrets/api".into()),
..Default::default()
};
let hook_decisions = vec![HookDecision::Modify(
crate::hooks::decision::ModifyPayload { delta },
)];
let r = rule("secrets/**", "memory_store", "*", RuleDecision::Deny);
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "public/blog", "ai:claude"),
&hook_decisions,
&[r],
PermissionsMode::Enforce,
);
match d {
Decision::Deny(reason) => {
assert!(
reason.contains("namespace escalation rejected"),
"expected escalation rejection: {reason}"
);
}
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn hook_modify_namespace_change_to_allowed_passes() {
let delta = MemoryDelta {
namespace: Some("public/wiki".into()),
..Default::default()
};
let hook_decisions = vec![HookDecision::Modify(
crate::hooks::decision::ModifyPayload { delta },
)];
let r = rule("public/**", "memory_store", "*", RuleDecision::Allow);
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "public/blog", "ai:claude"),
&hook_decisions,
&[r],
PermissionsMode::Enforce,
);
match d {
Decision::Modify(_) => {}
other => panic!("expected Modify, got {other:?}"),
}
}
#[test]
fn rule_allow_short_circuits_ask() {
let allow = rule("public/*", "memory_store", "*", RuleDecision::Allow);
let ask = rule("public/*", "memory_store", "*", RuleDecision::Ask);
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "public/blog", "ai:claude"),
&[],
&[allow, ask],
PermissionsMode::Enforce,
);
assert_eq!(d, Decision::Allow);
}
#[test]
fn ask_under_enforce_mode_escalates_to_deny() {
let r = rule("private/*", "memory_store", "*", RuleDecision::Ask);
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "private/journal", "ai:claude"),
&[],
&[r],
PermissionsMode::Enforce,
);
match d {
Decision::Deny(reason) => assert!(reason.contains("permission ask escalated")),
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn ask_under_advisory_mode_returns_ask() {
let r = rule("private/*", "memory_store", "*", RuleDecision::Ask);
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "private/journal", "ai:claude"),
&[],
&[r],
PermissionsMode::Advisory,
);
match d {
Decision::Ask(_) => {}
other => panic!("expected Ask, got {other:?}"),
}
}
#[test]
fn hook_askuser_under_advisory_mode_surfaces_ask() {
let hook_decisions = vec![HookDecision::AskUser {
prompt: "Please confirm".into(),
options: vec!["yes".into(), "no".into()],
default: Some("no".into()),
}];
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "public/blog", "ai:claude"),
&hook_decisions,
&[],
PermissionsMode::Advisory,
);
match d {
Decision::Ask(prompt) => assert!(prompt.contains("Please confirm")),
other => panic!("expected Ask, got {other:?}"),
}
}
#[test]
fn hook_allow_alone_short_circuits_to_allow() {
let hook_decisions = vec![HookDecision::Allow];
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "public/blog", "ai:claude"),
&hook_decisions,
&[],
PermissionsMode::Enforce,
);
assert_eq!(d, Decision::Allow);
}
#[test]
fn evaluate_public_facade_uses_active_rules() {
clear_active_permission_rules_for_test();
let d = Permissions::evaluate(&ctx(Op::MemoryStore, "anywhere", "ai:claude"), &[]);
assert_eq!(d, Decision::Allow);
}
#[test]
fn set_and_active_permission_rules_round_trip() {
clear_active_permission_rules_for_test();
set_active_permission_rules(vec![rule(
"secrets/*",
"memory_store",
"*",
RuleDecision::Deny,
)]);
let rules = active_permission_rules();
assert_eq!(rules.len(), 1);
clear_active_permission_rules_for_test();
assert!(active_permission_rules().is_empty());
}
#[test]
fn permissions_mode_serde_round_trip() {
let modes = [
PermissionsMode::Off,
PermissionsMode::Advisory,
PermissionsMode::Enforce,
];
for m in modes {
let json = serde_json::to_string(&m).unwrap();
let back: PermissionsMode = serde_json::from_str(&json).unwrap();
assert_eq!(m, back);
}
}
#[test]
fn decision_partial_eq_distinct_variants() {
let allow = Decision::Allow;
let deny = Decision::Deny("x".into());
let ask = Decision::Ask("?".into());
let modify = Decision::Modify(MemoryDelta::default());
assert_ne!(allow, deny);
assert_ne!(allow, ask);
assert_ne!(allow, modify);
assert_ne!(deny, ask);
}
#[test]
fn glob_double_star_matches_zero_segments() {
assert!(glob_matches("foo/**", "foo/anything"));
}
#[test]
fn evaluate_no_rules_no_hooks_returns_allow() {
let d = Permissions::evaluate_with(
&ctx(Op::MemoryStore, "anywhere", "ai:claude"),
&[],
&[],
PermissionsMode::Enforce,
);
assert_eq!(d, Decision::Allow);
}
#[test]
fn decision_partial_eq_same_allow_arms_match() {
let a = Decision::Allow;
let b = Decision::Allow;
assert_eq!(a, b);
}
#[test]
fn decision_partial_eq_same_deny_arms_match_by_reason() {
let same = Decision::Deny("nope".into());
let same_again = Decision::Deny("nope".into());
assert_eq!(same, same_again);
let different_reason = Decision::Deny("other".into());
assert_ne!(same, different_reason);
}
#[test]
fn decision_partial_eq_same_modify_arms_compare_canonical_json() {
let a = Decision::Modify(MemoryDelta::default());
let b = Decision::Modify(MemoryDelta::default());
assert_eq!(a, b);
let mut delta_with_meta = MemoryDelta::default();
delta_with_meta.metadata = Some(serde_json::json!({"k":"v"}));
let c = Decision::Modify(delta_with_meta);
assert_ne!(a, c);
}
#[test]
fn decision_partial_eq_same_ask_arms_match_by_prompt() {
let a = Decision::Ask("really?".into());
let b = Decision::Ask("really?".into());
assert_eq!(a, b);
let c = Decision::Ask("hmm?".into());
assert_ne!(a, c);
}
#[test]
fn permission_rule_default_agent_pattern_is_wildcard() {
let json = r#"{
"namespace_pattern": "*",
"op": "memory_store",
"decision": "allow"
}"#;
let rule: PermissionRule = serde_json::from_str(json).expect("deserialise");
assert_eq!(rule.agent_pattern, "*");
assert_eq!(default_agent_pattern(), "*");
}
}