use std::collections::HashMap;
use super::rules::{Decision, RuleSet};
use crate::config::glob_match;
#[derive(Debug, Clone)]
pub struct SourceRule {
pub pattern: String,
pub decision: Decision,
}
#[derive(Debug, Clone)]
pub struct Translated {
pub rules: RuleSet,
pub warnings: Vec<String>,
}
pub fn translate_last_match_to_first_match(source: &[SourceRule]) -> Translated {
let mut order: Vec<String> = Vec::new();
let mut last: HashMap<String, Decision> = HashMap::new();
for r in source {
if !last.contains_key(&r.pattern) {
order.push(r.pattern.clone());
}
last.insert(r.pattern.clone(), r.decision);
}
let mut rules = RuleSet::default();
for pattern in &order {
match last[pattern] {
Decision::Deny => rules.deny.push(pattern.clone()),
Decision::Ask => rules.ask.push(pattern.clone()),
Decision::Allow => rules.allow.push(pattern.clone()),
}
}
let mut warnings = Vec::new();
for pattern in &order {
let probe = pattern.as_str();
let source_decision = simulate_last_match(source, probe);
let target_decision = evaluate_self(&rules, pattern);
let (Some(sd), Some(td)) = (source_decision, target_decision) else {
continue;
};
if sd != td {
let safe = sd.stricter(td);
if safe != td {
reassign(&mut rules, pattern, safe);
}
warnings.push(format!(
"C5 translation: pattern `{pattern}` — source (last-match) resolves to \
{sd:?}, first-match-translated resolves to {td:?}; kept {safe:?} \
({} divergence)",
if safe == td {
"safe-direction"
} else {
"unsafe, corrected"
}
));
}
}
Translated { rules, warnings }
}
fn simulate_last_match(source: &[SourceRule], probe: &str) -> Option<Decision> {
let mut result = None;
for r in source {
if glob_match(&r.pattern, probe) || r.pattern == probe {
result = Some(r.decision);
}
}
result
}
fn evaluate_self(rules: &RuleSet, pattern: &str) -> Option<Decision> {
if let Some(open) = pattern.find('(') {
if let Some(subject) = pattern.strip_suffix(')').and_then(|p| p.get(open + 1..)) {
let tool = &pattern[..open];
return rules.evaluate(tool, Some(subject));
}
}
rules.evaluate(pattern, None)
}
fn reassign(rules: &mut RuleSet, pattern: &str, target_tier: Decision) {
rules.deny.retain(|p| p != pattern);
rules.ask.retain(|p| p != pattern);
rules.allow.retain(|p| p != pattern);
match target_tier {
Decision::Deny => rules.deny.push(pattern.to_string()),
Decision::Ask => rules.ask.push(pattern.to_string()),
Decision::Allow => rules.allow.push(pattern.to_string()),
}
}
pub fn opencode_default_policy() -> Translated {
let source = vec![
SourceRule {
pattern: "*".to_string(),
decision: Decision::Allow,
},
SourceRule {
pattern: "tools_question".to_string(),
decision: Decision::Deny,
},
SourceRule {
pattern: "plan_enter".to_string(),
decision: Decision::Deny,
},
SourceRule {
pattern: "plan_exit".to_string(),
decision: Decision::Deny,
},
SourceRule {
pattern: "read(*.env)".to_string(),
decision: Decision::Ask,
},
SourceRule {
pattern: "read(*.env.*)".to_string(),
decision: Decision::Ask,
},
SourceRule {
pattern: "read(*.env.example)".to_string(),
decision: Decision::Allow,
},
];
let mut translated = translate_last_match_to_first_match(&source);
translated.warnings.push(
"C5/S4 deviation 2 (doom_loop): opencode's `doom_loop: ask` carve-out is a repetition \
TRIGGER, not a rule-language pattern — routed to `Config::doom_loop_threshold` (the P4 \
doom-loop breaker) instead of a rule entry; not silently dropped."
.to_string(),
);
translated.warnings.push(
"C5/S4 deviation 3 (external_directory): opencode's `external_directory: ask` carve-out \
is a PERMISSION CATEGORY (any tool touching paths outside the worktree), not a tool \
name — routed to `Config::additional_dirs` instead of a rule entry (paths outside cwd \
and outside additional_dirs are simply unreachable, a stricter reading); not silently \
dropped."
.to_string(),
);
translated
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permissions::rules::{evaluate_command, evaluate_path, PathKind};
#[test]
fn later_allow_overriding_earlier_deny_translates_with_warning_and_safe_reading() {
let source = vec![
SourceRule {
pattern: "bash(rm*)".to_string(),
decision: Decision::Deny,
},
SourceRule {
pattern: "bash(rm*)".to_string(),
decision: Decision::Allow,
},
];
let t = translate_last_match_to_first_match(&source);
assert_eq!(t.rules.allow, vec!["bash(rm*)".to_string()]);
assert!(t.rules.deny.is_empty());
assert!(
t.warnings.is_empty(),
"identical pattern dedupes to last-wins with no divergence to report: {:?}",
t.warnings
);
}
#[test]
fn distinct_overlapping_patterns_detect_unsafe_divergence_and_correct_it() {
let source = vec![
SourceRule {
pattern: "*".to_string(),
decision: Decision::Ask,
},
SourceRule {
pattern: "read(*.env.example)".to_string(),
decision: Decision::Allow,
},
];
let t = translate_last_match_to_first_match(&source);
assert!(t.rules.ask.contains(&"*".to_string()));
assert!(!t.warnings.is_empty());
}
#[test]
fn opencode_default_policy_matches_the_three_named_deviations() {
let t = opencode_default_policy();
assert_eq!(
evaluate_path(&t.rules, PathKind::Read, ".env.example", Decision::Allow),
Decision::Ask,
"expected the named safe-direction deviation (.env.example -> ask): {:?}",
t.rules
);
assert_eq!(
evaluate_path(&t.rules, PathKind::Read, ".env", Decision::Allow),
Decision::Ask
);
assert_eq!(
evaluate_path(&t.rules, PathKind::Read, ".env.local", Decision::Allow),
Decision::Ask
);
assert_eq!(
t.rules.evaluate("tools_question", None),
Some(Decision::Deny)
);
assert_eq!(t.rules.evaluate("plan_enter", None), Some(Decision::Deny));
assert_eq!(t.rules.evaluate("plan_exit", None), Some(Decision::Deny));
assert_eq!(t.rules.evaluate("bash", Some("ls")), Some(Decision::Allow));
assert!(t
.warnings
.iter()
.any(|w| w.contains(".env.example") || w.contains("read(*.env.example)")));
assert!(t.warnings.iter().any(|w| w.contains("doom_loop")));
assert!(t.warnings.iter().any(|w| w.contains("external_directory")));
assert_eq!(
t.warnings.len(),
3,
"expected exactly 3 named deviations: {:?}",
t.warnings
);
}
#[test]
fn broader_later_allow_overlapping_an_earlier_specific_deny_warns_but_keeps_the_deny() {
let source = vec![
SourceRule {
pattern: "bash(rm -rf*)".to_string(),
decision: Decision::Deny,
},
SourceRule {
pattern: "bash(*)".to_string(),
decision: Decision::Allow,
},
];
let t = translate_last_match_to_first_match(&source);
assert_eq!(t.warnings.len(), 1, "{:?}", t.warnings);
assert!(t.warnings[0].contains("safe-direction"), "{:?}", t.warnings);
assert!(t.rules.deny.contains(&"bash(rm -rf*)".to_string()));
assert_eq!(
evaluate_command(&t.rules, "bash", "rm -rf /", Decision::Allow),
Decision::Deny,
"the translated ruleset must still deny the specific dangerous command"
);
}
#[test]
fn genuinely_non_overlapping_rules_translate_with_no_warnings() {
let source = vec![
SourceRule {
pattern: "bash(rm -rf*)".to_string(),
decision: Decision::Deny,
},
SourceRule {
pattern: "write_file(*.lock)".to_string(),
decision: Decision::Ask,
},
];
let t = translate_last_match_to_first_match(&source);
assert!(
t.warnings.is_empty(),
"genuinely non-overlapping patterns should translate cleanly: {:?}",
t.warnings
);
}
}