use crate::permission::bash_command_splitting::{all_commands_from_script, unwrap_wrappers}; use crate::permission::shell_access::combine_decisions; use crate::permission::types::{ AccessKind, Decision, PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter, }; use kigi_tools::implementations::grok_build::web_fetch::domain::normalize_domain; #[derive(Clone, Copy)] enum MatchContext { /// `*` respects `/` as a segment boundary; `**` crosses it. Path, /// `*` matches any character including `/`. Freeform, } struct CompiledRule<'a> { rule: &'a PermissionRule, matcher: Option<&'a glob::Pattern>, } /// Permission policy with pre-compiled glob patterns. pub struct CompiledPolicy { config: PermissionConfig, matchers: Vec>, /// True if any Read/Edit/Any deny/ask rule exists, so the shell file-access /// gate (`shell_access.rs`) should run. Read by `evaluate_shell_file_access`. pub(crate) has_file_restrictions: bool, /// True if any Bash/Any deny/ask rule exists, so the per-segment Bash command /// gate should run. Read by `evaluate_bash_command_policy`. has_bash_command_restrictions: bool, } impl CompiledPolicy { pub fn new(config: PermissionConfig) -> Self { let matchers = config .rules .iter() .map(|rule| { rule.pattern .as_deref() .filter(|p| *p != "*") .and_then(|p| glob::Pattern::new(p).ok()) }) .collect(); let has_file_restrictions = config.rules.iter().any(|rule| { matches!(rule.action, RuleAction::Deny | RuleAction::Ask) && matches!( rule.tool, ToolFilter::Read | ToolFilter::Edit | ToolFilter::Any ) }); let has_bash_command_restrictions = config.rules.iter().any(|rule| { matches!(rule.action, RuleAction::Deny | RuleAction::Ask) && matches!(rule.tool, ToolFilter::Bash | ToolFilter::Any) }); Self { config, matchers, has_file_restrictions, has_bash_command_restrictions, } } /// Evaluate managed Bash/Any deny/ask command rules against every chained /// segment (wrappers like `timeout`/`env` peeled, `bash -c` scripts recursed /// into), not just the leading command. Escalation only: returns /// `Reject`/`Ask`, never `Allow`. A script that can't be decomposed fails /// closed to `Ask` rather than falling through. pub fn evaluate_bash_command_policy(&self, cmd: &str) -> Option { if !self.has_bash_command_restrictions { return None; } self.evaluate_bash_command_segments(cmd, 0) } fn evaluate_bash_command_segments(&self, cmd: &str, depth: usize) -> Option { // Far deeper than legitimate `bash -c` nesting; fail closed rather than // let an over-nested script run unevaluated. if depth >= 8 { return Some(Decision::Ask); } let Some(segments) = all_commands_from_script(cmd) else { return Some(Decision::Ask); }; let escalate = |segment: &str| match self.evaluate(&AccessKind::Bash(segment.to_owned())) { Some(Decision::Allow) | None => None, other => other, }; let mut decision = None; for parsed in &segments { let raw_words = parsed.words(); let unwrapped = unwrap_wrappers(raw_words); // Rules may target the wrapper or the wrapped program, so both forms // are checked — but only once when nothing was peeled. let forms = std::iter::once(raw_words) .chain((unwrapped.len() != raw_words.len()).then_some(unwrapped)); for words in forms { decision = combine_decisions(decision, escalate(&words.join(" "))); if let Some(inner) = shell_dash_c_script(words) { decision = combine_decisions( decision, self.evaluate_bash_command_segments(inner, depth + 1), ); } } } decision } /// Evaluate using deny > ask > allow precedence (order-independent). pub fn evaluate(&self, access: &AccessKind) -> Option { let mut matched_ask = false; let mut matched_allow = false; for (rule, matcher) in self.config.rules.iter().zip(&self.matchers) { if !tool_filter_matches(access, &rule.tool) { continue; } let cr = CompiledRule { rule, matcher: matcher.as_ref(), }; if !pattern_matches(access, &cr) { continue; } match rule.action { RuleAction::Deny => { let tool_label = match &rule.tool { ToolFilter::Any => "any tool", ToolFilter::Bash => "bash", ToolFilter::Edit => "edit", ToolFilter::Read => "read", ToolFilter::Grep => "grep", ToolFilter::Mcp => "mcp", ToolFilter::WebFetch => "web_fetch", ToolFilter::WebSearch => "web_search", }; let reason = match &rule.pattern { Some(pattern) => format!( "Denied by permission policy: deny rule on {tool_label} matching \"{pattern}\"" ), None => format!("Denied by permission policy: deny rule on {tool_label}"), }; return Some(Decision::Reject(reason)); } RuleAction::Ask => matched_ask = true, RuleAction::Allow => matched_allow = true, } } if matched_ask { return Some(Decision::Ask); } if matched_allow { return Some(Decision::Allow); } None } } impl From for CompiledPolicy { fn from(config: PermissionConfig) -> Self { Self::new(config) } } /// The inner script string of a `bash -c "