diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c3039453b..cca5ca0b4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1202,6 +1202,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-execpolicy2" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "multimap", + "pretty_assertions", + "serde", + "serde_json", + "shlex", + "starlark", + "thiserror 2.0.17", +] + [[package]] name = "codex-feedback" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 7c905cc72..a8d16dd8b 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -17,6 +17,7 @@ members = [ "core", "exec", "execpolicy", + "execpolicy2", "keyring-store", "file-search", "linux-sandbox", diff --git a/codex-rs/execpolicy2/Cargo.toml b/codex-rs/execpolicy2/Cargo.toml new file mode 100644 index 000000000..c86fb7ff7 --- /dev/null +++ b/codex-rs/execpolicy2/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "codex-execpolicy2" +version = { workspace = true } +edition = "2024" +description = "Codex exec policy v2: prefix-based Starlark rules for command decisions." + +[lib] +name = "codex_execpolicy2" +path = "src/lib.rs" + +[[bin]] +name = "codex-execpolicy2" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +multimap = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +shlex = { workspace = true } +starlark = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/execpolicy2/README.md b/codex-rs/execpolicy2/README.md new file mode 100644 index 000000000..e433f5e2c --- /dev/null +++ b/codex-rs/execpolicy2/README.md @@ -0,0 +1,54 @@ +# codex-execpolicy2 + +## Overview +- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, match?, not_match?)`. +- This release covers only the prefix-rule subset of the planned execpolicy v2 language; a richer language will follow. +- Tokens are matched in order; any `pattern` element may be a list to denote alternatives. `decision` defaults to `allow`; valid values: `allow`, `prompt`, `forbidden`. +- `match` / `not_match` supply example invocations that are validated at load time (think of them as unit tests); examples can be token arrays or strings (strings are tokenized with `shlex`). +- The CLI always prints the JSON serialization of the evaluation result (whether a match or not). + +## Policy shapes +- Prefix rules use Starlark syntax: +```starlark +prefix_rule( + pattern = ["cmd", ["alt1", "alt2"]], # ordered tokens; list entries denote alternatives + decision = "prompt", # allow | prompt | forbidden; defaults to allow + match = [["cmd", "alt1"], "cmd alt2"], # examples that must match this rule + not_match = [["cmd", "oops"], "cmd alt3"], # examples that must not match this rule +) +``` + +## Response shapes +- Match: +```json +{ + "match": { + "decision": "allow|prompt|forbidden", + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["", "..."], + "decision": "allow|prompt|forbidden" + } + } + ] + } +} +``` + +- No match: +```json +"noMatch" +``` + +- `matchedRules` lists every rule whose prefix matched the command; `matchedPrefix` is the exact prefix that matched. +- The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`). + +## CLI +- Provide a policy file (for example `src/default.codexpolicy`) to check a command: +```bash +cargo run -p codex-execpolicy2 -- check --policy path/to/policy.codexpolicy git status +``` +- Example outcomes: + - Match: `{"match": { ... "decision": "allow" ... }}` + - No match: `"noMatch"` diff --git a/codex-rs/execpolicy2/examples/example.codexpolicy b/codex-rs/execpolicy2/examples/example.codexpolicy new file mode 100644 index 000000000..5bb691b6f --- /dev/null +++ b/codex-rs/execpolicy2/examples/example.codexpolicy @@ -0,0 +1,77 @@ + +# Example policy to illustrate syntax; not comprehensive and not recommended for actual use. + +prefix_rule( + pattern = ["git", "reset", "--hard"], + decision = "forbidden", + match = [ + ["git", "reset", "--hard"], + ], + not_match = [ + ["git", "reset", "--keep"], + "git reset --merge", + ], +) + +prefix_rule( + pattern = ["ls"], + match = [ + ["ls"], + ["ls", "-l"], + ["ls", "-a", "."], + ], +) + +prefix_rule( + pattern = ["cat"], + match = [ + ["cat", "file.txt"], + ["cat", "-n", "README.md"], + ], +) + +prefix_rule( + pattern = ["cp"], + decision = "prompt", + match = [ + ["cp", "foo", "bar"], + "cp -r src dest", + ], +) + +prefix_rule( + pattern = ["head"], + match = [ + ["head", "README.md"], + ["head", "-n", "5", "CHANGELOG.md"], + ], + not_match = [ + ["hea", "-n", "1,5p", "CHANGELOG.md"], + ], +) + +prefix_rule( + pattern = ["printenv"], + match = [ + ["printenv"], + ["printenv", "PATH"], + ], + not_match = [ + ["print", "-0"], + ], +) + +prefix_rule( + pattern = ["pwd"], + match = [ + ["pwd"], + ], +) + +prefix_rule( + pattern = ["which"], + match = [ + ["which", "python3"], + ["which", "-a", "python3"], + ], +) diff --git a/codex-rs/execpolicy2/src/decision.rs b/codex-rs/execpolicy2/src/decision.rs new file mode 100644 index 000000000..15dc1f711 --- /dev/null +++ b/codex-rs/execpolicy2/src/decision.rs @@ -0,0 +1,27 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::error::Error; +use crate::error::Result; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Decision { + /// Command may run without further approval. + Allow, + /// Request explicit user approval; rejected outright when running with `approval_policy="never"`. + Prompt, + /// Command is blocked without further consideration. + Forbidden, +} + +impl Decision { + pub fn parse(raw: &str) -> Result { + match raw { + "allow" => Ok(Self::Allow), + "prompt" => Ok(Self::Prompt), + "forbidden" => Ok(Self::Forbidden), + other => Err(Error::InvalidDecision(other.to_string())), + } + } +} diff --git a/codex-rs/execpolicy2/src/error.rs b/codex-rs/execpolicy2/src/error.rs new file mode 100644 index 000000000..2f168a027 --- /dev/null +++ b/codex-rs/execpolicy2/src/error.rs @@ -0,0 +1,26 @@ +use starlark::Error as StarlarkError; +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum Error { + #[error("invalid decision: {0}")] + InvalidDecision(String), + #[error("invalid pattern element: {0}")] + InvalidPattern(String), + #[error("invalid example: {0}")] + InvalidExample(String), + #[error( + "expected every example to match at least one rule. rules: {rules:?}; unmatched examples: \ + {examples:?}" + )] + ExampleDidNotMatch { + rules: Vec, + examples: Vec, + }, + #[error("expected example to not match rule `{rule}`: {example}")] + ExampleDidMatch { rule: String, example: String }, + #[error("starlark error: {0}")] + Starlark(StarlarkError), +} diff --git a/codex-rs/execpolicy2/src/lib.rs b/codex-rs/execpolicy2/src/lib.rs new file mode 100644 index 000000000..1b789fd86 --- /dev/null +++ b/codex-rs/execpolicy2/src/lib.rs @@ -0,0 +1,15 @@ +pub mod decision; +pub mod error; +pub mod parser; +pub mod policy; +pub mod rule; + +pub use decision::Decision; +pub use error::Error; +pub use error::Result; +pub use parser::PolicyParser; +pub use policy::Evaluation; +pub use policy::Policy; +pub use rule::Rule; +pub use rule::RuleMatch; +pub use rule::RuleRef; diff --git a/codex-rs/execpolicy2/src/main.rs b/codex-rs/execpolicy2/src/main.rs new file mode 100644 index 000000000..c0786a320 --- /dev/null +++ b/codex-rs/execpolicy2/src/main.rs @@ -0,0 +1,54 @@ +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use clap::Parser; +use codex_execpolicy2::PolicyParser; + +/// CLI for evaluating exec policies +#[derive(Parser)] +#[command(name = "codex-execpolicy2")] +enum Cli { + /// Evaluate a command against a policy. + Check { + #[arg(short, long, value_name = "PATH")] + policy: PathBuf, + + /// Command tokens to check. + #[arg( + value_name = "COMMAND", + required = true, + trailing_var_arg = true, + allow_hyphen_values = true + )] + command: Vec, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli { + Cli::Check { policy, command } => cmd_check(policy, command), + } +} + +fn cmd_check(policy_path: PathBuf, args: Vec) -> Result<()> { + let policy = load_policy(&policy_path)?; + + let eval = policy.check(&args); + let json = serde_json::to_string_pretty(&eval)?; + println!("{json}"); + Ok(()) +} + +fn load_policy(policy_path: &Path) -> Result { + let policy_file_contents = fs::read_to_string(policy_path) + .with_context(|| format!("failed to read policy at {}", policy_path.display()))?; + let policy_identifier = policy_path.to_string_lossy(); + Ok(PolicyParser::parse( + policy_identifier.as_ref(), + &policy_file_contents, + )?) +} diff --git a/codex-rs/execpolicy2/src/parser.rs b/codex-rs/execpolicy2/src/parser.rs new file mode 100644 index 000000000..b978781af --- /dev/null +++ b/codex-rs/execpolicy2/src/parser.rs @@ -0,0 +1,247 @@ +use multimap::MultiMap; +use shlex; +use starlark::any::ProvidesStaticType; +use starlark::environment::GlobalsBuilder; +use starlark::environment::Module; +use starlark::eval::Evaluator; +use starlark::starlark_module; +use starlark::syntax::AstModule; +use starlark::syntax::Dialect; +use starlark::values::Value; +use starlark::values::list::ListRef; +use starlark::values::list::UnpackList; +use starlark::values::none::NoneType; +use std::cell::RefCell; +use std::cell::RefMut; +use std::sync::Arc; + +use crate::decision::Decision; +use crate::error::Error; +use crate::error::Result; +use crate::rule::PatternToken; +use crate::rule::PrefixPattern; +use crate::rule::PrefixRule; +use crate::rule::RuleRef; +use crate::rule::validate_match_examples; +use crate::rule::validate_not_match_examples; + +// todo: support parsing multiple policies +pub struct PolicyParser; + +impl PolicyParser { + /// Parses a policy, tagging parser errors with `policy_identifier` so failures include the + /// identifier alongside line numbers. + pub fn parse( + policy_identifier: &str, + policy_file_contents: &str, + ) -> Result { + let mut dialect = Dialect::Extended.clone(); + dialect.enable_f_strings = true; + let ast = AstModule::parse( + policy_identifier, + policy_file_contents.to_string(), + &dialect, + ) + .map_err(Error::Starlark)?; + let globals = GlobalsBuilder::standard().with(policy_builtins).build(); + let module = Module::new(); + + let builder = RefCell::new(PolicyBuilder::new()); + { + let mut eval = Evaluator::new(&module); + eval.extra = Some(&builder); + eval.eval_module(ast, &globals).map_err(Error::Starlark)?; + } + Ok(builder.into_inner().build()) + } +} + +#[derive(Debug, ProvidesStaticType)] +struct PolicyBuilder { + rules_by_program: MultiMap, +} + +impl PolicyBuilder { + fn new() -> Self { + Self { + rules_by_program: MultiMap::new(), + } + } + + fn add_rule(&mut self, rule: RuleRef) { + self.rules_by_program + .insert(rule.program().to_string(), rule); + } + + fn build(self) -> crate::policy::Policy { + crate::policy::Policy::new(self.rules_by_program) + } +} + +fn parse_pattern<'v>(pattern: UnpackList>) -> Result> { + let tokens: Vec = pattern + .items + .into_iter() + .map(parse_pattern_token) + .collect::>()?; + if tokens.is_empty() { + Err(Error::InvalidPattern("pattern cannot be empty".to_string())) + } else { + Ok(tokens) + } +} + +fn parse_pattern_token<'v>(value: Value<'v>) -> Result { + if let Some(s) = value.unpack_str() { + Ok(PatternToken::Single(s.to_string())) + } else if let Some(list) = ListRef::from_value(value) { + let tokens: Vec = list + .content() + .iter() + .map(|value| { + value + .unpack_str() + .ok_or_else(|| { + Error::InvalidPattern(format!( + "pattern alternative must be a string (got {})", + value.get_type() + )) + }) + .map(str::to_string) + }) + .collect::>()?; + + match tokens.as_slice() { + [] => Err(Error::InvalidPattern( + "pattern alternatives cannot be empty".to_string(), + )), + [single] => Ok(PatternToken::Single(single.clone())), + _ => Ok(PatternToken::Alts(tokens)), + } + } else { + Err(Error::InvalidPattern(format!( + "pattern element must be a string or list of strings (got {})", + value.get_type() + ))) + } +} + +fn parse_examples<'v>(examples: UnpackList>) -> Result>> { + examples.items.into_iter().map(parse_example).collect() +} + +fn parse_example<'v>(value: Value<'v>) -> Result> { + if let Some(raw) = value.unpack_str() { + parse_string_example(raw) + } else if let Some(list) = ListRef::from_value(value) { + parse_list_example(list) + } else { + Err(Error::InvalidExample(format!( + "example must be a string or list of strings (got {})", + value.get_type() + ))) + } +} + +fn parse_string_example(raw: &str) -> Result> { + let tokens = shlex::split(raw).ok_or_else(|| { + Error::InvalidExample("example string has invalid shell syntax".to_string()) + })?; + + if tokens.is_empty() { + Err(Error::InvalidExample( + "example cannot be an empty string".to_string(), + )) + } else { + Ok(tokens) + } +} + +fn parse_list_example(list: &ListRef) -> Result> { + let tokens: Vec = list + .content() + .iter() + .map(|value| { + value + .unpack_str() + .ok_or_else(|| { + Error::InvalidExample(format!( + "example tokens must be strings (got {})", + value.get_type() + )) + }) + .map(str::to_string) + }) + .collect::>()?; + + if tokens.is_empty() { + Err(Error::InvalidExample( + "example cannot be an empty list".to_string(), + )) + } else { + Ok(tokens) + } +} + +fn policy_builder<'v, 'a>(eval: &Evaluator<'v, 'a, '_>) -> RefMut<'a, PolicyBuilder> { + #[expect(clippy::expect_used)] + eval.extra + .as_ref() + .expect("policy_builder requires Evaluator.extra to be populated") + .downcast_ref::>() + .expect("Evaluator.extra must contain a PolicyBuilder") + .borrow_mut() +} + +#[starlark_module] +fn policy_builtins(builder: &mut GlobalsBuilder) { + fn prefix_rule<'v>( + pattern: UnpackList>, + decision: Option<&'v str>, + r#match: Option>>, + not_match: Option>>, + eval: &mut Evaluator<'v, '_, '_>, + ) -> anyhow::Result { + let decision = match decision { + Some(raw) => Decision::parse(raw)?, + None => Decision::Allow, + }; + + let pattern_tokens = parse_pattern(pattern)?; + + let matches: Vec> = + r#match.map(parse_examples).transpose()?.unwrap_or_default(); + let not_matches: Vec> = not_match + .map(parse_examples) + .transpose()? + .unwrap_or_default(); + + let mut builder = policy_builder(eval); + + let (first_token, remaining_tokens) = pattern_tokens + .split_first() + .ok_or_else(|| Error::InvalidPattern("pattern cannot be empty".to_string()))?; + + let rest: Arc<[PatternToken]> = remaining_tokens.to_vec().into(); + + let rules: Vec = first_token + .alternatives() + .iter() + .map(|head| { + Arc::new(PrefixRule { + pattern: PrefixPattern { + first: Arc::from(head.as_str()), + rest: rest.clone(), + }, + decision, + }) as RuleRef + }) + .collect(); + + validate_not_match_examples(&rules, ¬_matches)?; + validate_match_examples(&rules, &matches)?; + + rules.into_iter().for_each(|rule| builder.add_rule(rule)); + Ok(NoneType) + } +} diff --git a/codex-rs/execpolicy2/src/policy.rs b/codex-rs/execpolicy2/src/policy.rs new file mode 100644 index 000000000..d453404b2 --- /dev/null +++ b/codex-rs/execpolicy2/src/policy.rs @@ -0,0 +1,58 @@ +use crate::decision::Decision; +use crate::rule::RuleMatch; +use crate::rule::RuleRef; +use multimap::MultiMap; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Debug)] +pub struct Policy { + rules_by_program: MultiMap, +} + +impl Policy { + pub fn new(rules_by_program: MultiMap) -> Self { + Self { rules_by_program } + } + + pub fn rules(&self) -> &MultiMap { + &self.rules_by_program + } + + pub fn check(&self, cmd: &[String]) -> Evaluation { + let rules = match cmd.first() { + Some(first) => match self.rules_by_program.get_vec(first) { + Some(rules) => rules, + None => return Evaluation::NoMatch, + }, + None => return Evaluation::NoMatch, + }; + + let matched_rules: Vec = + rules.iter().filter_map(|rule| rule.matches(cmd)).collect(); + match matched_rules.iter().map(RuleMatch::decision).max() { + Some(decision) => Evaluation::Match { + decision, + matched_rules, + }, + None => Evaluation::NoMatch, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Evaluation { + NoMatch, + Match { + decision: Decision, + #[serde(rename = "matchedRules")] + matched_rules: Vec, + }, +} + +impl Evaluation { + pub fn is_match(&self) -> bool { + matches!(self, Self::Match { .. }) + } +} diff --git a/codex-rs/execpolicy2/src/rule.rs b/codex-rs/execpolicy2/src/rule.rs new file mode 100644 index 000000000..20e23fe6a --- /dev/null +++ b/codex-rs/execpolicy2/src/rule.rs @@ -0,0 +1,147 @@ +use crate::decision::Decision; +use crate::error::Error; +use crate::error::Result; +use serde::Deserialize; +use serde::Serialize; +use shlex::try_join; +use std::any::Any; +use std::fmt::Debug; +use std::sync::Arc; + +/// Matches a single command token, either a fixed string or one of several allowed alternatives. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PatternToken { + Single(String), + Alts(Vec), +} + +impl PatternToken { + fn matches(&self, token: &str) -> bool { + match self { + Self::Single(expected) => expected == token, + Self::Alts(alternatives) => alternatives.iter().any(|alt| alt == token), + } + } + + pub fn alternatives(&self) -> &[String] { + match self { + Self::Single(expected) => std::slice::from_ref(expected), + Self::Alts(alternatives) => alternatives, + } + } +} + +/// Prefix matcher for commands with support for alternative match tokens. +/// First token is fixed since we key by the first token in policy. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrefixPattern { + pub first: Arc, + pub rest: Arc<[PatternToken]>, +} + +impl PrefixPattern { + pub fn matches_prefix(&self, cmd: &[String]) -> Option> { + let pattern_length = self.rest.len() + 1; + if cmd.len() < pattern_length || cmd[0] != self.first.as_ref() { + return None; + } + + for (pattern_token, cmd_token) in self.rest.iter().zip(&cmd[1..pattern_length]) { + if !pattern_token.matches(cmd_token) { + return None; + } + } + + Some(cmd[..pattern_length].to_vec()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RuleMatch { + PrefixRuleMatch { + #[serde(rename = "matchedPrefix")] + matched_prefix: Vec, + decision: Decision, + }, +} + +impl RuleMatch { + pub fn decision(&self) -> Decision { + match self { + Self::PrefixRuleMatch { decision, .. } => *decision, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrefixRule { + pub pattern: PrefixPattern, + pub decision: Decision, +} + +pub trait Rule: Any + Debug + Send + Sync { + fn program(&self) -> &str; + + fn matches(&self, cmd: &[String]) -> Option; +} + +pub type RuleRef = Arc; + +impl Rule for PrefixRule { + fn program(&self) -> &str { + self.pattern.first.as_ref() + } + + fn matches(&self, cmd: &[String]) -> Option { + self.pattern + .matches_prefix(cmd) + .map(|matched_prefix| RuleMatch::PrefixRuleMatch { + matched_prefix, + decision: self.decision, + }) + } +} + +/// Count how many rules match each provided example and error if any example is unmatched. +pub(crate) fn validate_match_examples(rules: &[RuleRef], matches: &[Vec]) -> Result<()> { + let mut unmatched_examples = Vec::new(); + + for example in matches { + if rules.iter().any(|rule| rule.matches(example).is_some()) { + continue; + } + + unmatched_examples.push( + try_join(example.iter().map(String::as_str)) + .unwrap_or_else(|_| "unable to render example".to_string()), + ); + } + + if unmatched_examples.is_empty() { + Ok(()) + } else { + Err(Error::ExampleDidNotMatch { + rules: rules.iter().map(|rule| format!("{rule:?}")).collect(), + examples: unmatched_examples, + }) + } +} + +/// Ensure that no rule matches any provided negative example. +pub(crate) fn validate_not_match_examples( + rules: &[RuleRef], + not_matches: &[Vec], +) -> Result<()> { + for example in not_matches { + if let Some(rule) = rules.iter().find(|rule| rule.matches(example).is_some()) { + return Err(Error::ExampleDidMatch { + rule: format!("{rule:?}"), + example: try_join(example.iter().map(String::as_str)) + .unwrap_or_else(|_| "unable to render example".to_string()), + }); + } + } + + Ok(()) +} diff --git a/codex-rs/execpolicy2/tests/basic.rs b/codex-rs/execpolicy2/tests/basic.rs new file mode 100644 index 000000000..702ea6c0a --- /dev/null +++ b/codex-rs/execpolicy2/tests/basic.rs @@ -0,0 +1,256 @@ +use std::any::Any; +use std::sync::Arc; + +use codex_execpolicy2::Decision; +use codex_execpolicy2::Evaluation; +use codex_execpolicy2::PolicyParser; +use codex_execpolicy2::RuleMatch; +use codex_execpolicy2::RuleRef; +use codex_execpolicy2::rule::PatternToken; +use codex_execpolicy2::rule::PrefixPattern; +use codex_execpolicy2::rule::PrefixRule; +use pretty_assertions::assert_eq; + +fn tokens(cmd: &[&str]) -> Vec { + cmd.iter().map(std::string::ToString::to_string).collect() +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum RuleSnapshot { + Prefix(PrefixRule), +} + +fn rule_snapshots(rules: &[RuleRef]) -> Vec { + rules + .iter() + .map(|rule| { + let rule_any = rule.as_ref() as &dyn Any; + if let Some(prefix_rule) = rule_any.downcast_ref::() { + RuleSnapshot::Prefix(prefix_rule.clone()) + } else { + panic!("unexpected rule type in RuleRef: {rule:?}"); + } + }) + .collect() +} + +#[test] +fn basic_match() { + let policy_src = r#" +prefix_rule( + pattern = ["git", "status"], +) + "#; + let policy = PolicyParser::parse("test.codexpolicy", policy_src).expect("parse policy"); + let cmd = tokens(&["git", "status"]); + let evaluation = policy.check(&cmd); + assert_eq!( + Evaluation::Match { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["git", "status"]), + decision: Decision::Allow, + }], + }, + evaluation + ); +} + +#[test] +fn only_first_token_alias_expands_to_multiple_rules() { + let policy_src = r#" +prefix_rule( + pattern = [["bash", "sh"], ["-c", "-l"]], +) + "#; + let policy = PolicyParser::parse("test.codexpolicy", policy_src).expect("parse policy"); + + let bash_rules = rule_snapshots(policy.rules().get_vec("bash").expect("bash rules")); + let sh_rules = rule_snapshots(policy.rules().get_vec("sh").expect("sh rules")); + assert_eq!( + vec![RuleSnapshot::Prefix(PrefixRule { + pattern: PrefixPattern { + first: Arc::from("bash"), + rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(), + }, + decision: Decision::Allow, + })], + bash_rules + ); + assert_eq!( + vec![RuleSnapshot::Prefix(PrefixRule { + pattern: PrefixPattern { + first: Arc::from("sh"), + rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(), + }, + decision: Decision::Allow, + })], + sh_rules + ); + + let bash_eval = policy.check(&tokens(&["bash", "-c", "echo", "hi"])); + assert_eq!( + Evaluation::Match { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["bash", "-c"]), + decision: Decision::Allow, + }], + }, + bash_eval + ); + + let sh_eval = policy.check(&tokens(&["sh", "-l", "echo", "hi"])); + assert_eq!( + Evaluation::Match { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["sh", "-l"]), + decision: Decision::Allow, + }], + }, + sh_eval + ); +} + +#[test] +fn tail_aliases_are_not_cartesian_expanded() { + let policy_src = r#" +prefix_rule( + pattern = ["npm", ["i", "install"], ["--legacy-peer-deps", "--no-save"]], +) + "#; + let policy = PolicyParser::parse("test.codexpolicy", policy_src).expect("parse policy"); + + let rules = rule_snapshots(policy.rules().get_vec("npm").expect("npm rules")); + assert_eq!( + vec![RuleSnapshot::Prefix(PrefixRule { + pattern: PrefixPattern { + first: Arc::from("npm"), + rest: vec![ + PatternToken::Alts(vec!["i".to_string(), "install".to_string()]), + PatternToken::Alts(vec![ + "--legacy-peer-deps".to_string(), + "--no-save".to_string(), + ]), + ] + .into(), + }, + decision: Decision::Allow, + })], + rules + ); + + let npm_i = policy.check(&tokens(&["npm", "i", "--legacy-peer-deps"])); + assert_eq!( + Evaluation::Match { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["npm", "i", "--legacy-peer-deps"]), + decision: Decision::Allow, + }], + }, + npm_i + ); + + let npm_install = policy.check(&tokens(&["npm", "install", "--no-save", "leftpad"])); + assert_eq!( + Evaluation::Match { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["npm", "install", "--no-save"]), + decision: Decision::Allow, + }], + }, + npm_install + ); +} + +#[test] +fn match_and_not_match_examples_are_enforced() { + let policy_src = r#" +prefix_rule( + pattern = ["git", "status"], + match = [["git", "status"], "git status"], + not_match = [ + ["git", "--config", "color.status=always", "status"], + "git --config color.status=always status", + ], +) + "#; + let policy = PolicyParser::parse("test.codexpolicy", policy_src).expect("parse policy"); + let match_eval = policy.check(&tokens(&["git", "status"])); + assert_eq!( + Evaluation::Match { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["git", "status"]), + decision: Decision::Allow, + }], + }, + match_eval + ); + + let no_match_eval = policy.check(&tokens(&[ + "git", + "--config", + "color.status=always", + "status", + ])); + assert_eq!(Evaluation::NoMatch, no_match_eval); +} + +#[test] +fn strictest_decision_wins_across_matches() { + let policy_src = r#" +prefix_rule( + pattern = ["git", "status"], + decision = "allow", +) +prefix_rule( + pattern = ["git"], + decision = "prompt", +) +prefix_rule( + pattern = ["git", "commit"], + decision = "forbidden", +) + "#; + let policy = PolicyParser::parse("test.codexpolicy", policy_src).expect("parse policy"); + + let status = policy.check(&tokens(&["git", "status"])); + assert_eq!( + Evaluation::Match { + decision: Decision::Prompt, + matched_rules: vec![ + RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["git", "status"]), + decision: Decision::Allow, + }, + RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["git"]), + decision: Decision::Prompt, + }, + ], + }, + status + ); + + let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"])); + assert_eq!( + Evaluation::Match { + decision: Decision::Forbidden, + matched_rules: vec![ + RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["git"]), + decision: Decision::Prompt, + }, + RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["git", "commit"]), + decision: Decision::Forbidden, + }, + ], + }, + commit + ); +}