From d3aafb93f3e2036cf3f21ad7087b5624e5096672 Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Wed, 12 Nov 2025 12:49:47 -0500 Subject: [PATCH] feat: tokenizing whole strings in match and not_match --- codex-rs/execpolicy2/README.md | 6 +-- codex-rs/execpolicy2/src/parser.rs | 74 +++++++++++++++++++---------- codex-rs/execpolicy2/tests/basic.rs | 7 ++- 3 files changed, 56 insertions(+), 31 deletions(-) diff --git a/codex-rs/execpolicy2/README.md b/codex-rs/execpolicy2/README.md index 9f54a174be..22436c0e7c 100644 --- a/codex-rs/execpolicy2/README.md +++ b/codex-rs/execpolicy2/README.md @@ -4,7 +4,7 @@ - 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). +- `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 @@ -13,8 +13,8 @@ prefix_rule( pattern = ["cmd", ["alt1", "alt2"]], # ordered tokens; list entries denote alternatives decision = "prompt", # allow | prompt | forbidden; defaults to allow - match = [["cmd", "alt1"]], # examples that must match this rule - not_match = [["cmd", "oops"]], # examples that must not match this rule + match = [["cmd", "alt1"], "cmd alt2"], # examples that must match this rule + not_match = [["cmd", "oops"], "cmd alt3"], # examples that must not match this rule ) ``` diff --git a/codex-rs/execpolicy2/src/parser.rs b/codex-rs/execpolicy2/src/parser.rs index 27caffb3f2..b1e0c146ff 100644 --- a/codex-rs/execpolicy2/src/parser.rs +++ b/codex-rs/execpolicy2/src/parser.rs @@ -1,5 +1,6 @@ use multimap::MultiMap; use parking_lot::Mutex; +use shlex; use starlark::any::ProvidesStaticType; use starlark::environment::GlobalsBuilder; use starlark::environment::Module; @@ -123,34 +124,55 @@ fn parse_pattern_token<'v>(value: Value<'v>) -> Result { } fn parse_examples<'v>(examples: UnpackList>) -> Result>> { - examples - .items - .into_iter() - .map(|example| { - let list = ListRef::from_value(example).ok_or_else(|| { - Error::InvalidExample("example must be a list of strings".to_string()) - })?; - let tokens: Vec = list - .content() - .iter() - .map(|value| { - value - .unpack_str() - .ok_or_else(|| { - Error::InvalidExample("example tokens must be strings".to_string()) - }) - .map(str::to_string) - }) - .collect::>()?; + examples.items.into_iter().map(parse_example).collect() +} - match tokens.as_slice() { - [] => Err(Error::InvalidExample( - "example cannot be an empty list".to_string(), - )), - _ => Ok(tokens), - } +fn parse_example<'v>(value: Value<'v>) -> Result> { + if let Some(raw) = value.unpack_str() { + return parse_string_example(raw); + } + + if let Some(list) = ListRef::from_value(value) { + return parse_list_example(&list); + } + + 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()) + })?; + + match tokens.as_slice() { + [] => Err(Error::InvalidExample( + "example cannot be an empty string".to_string(), + )), + _ => 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("example tokens must be strings".to_string())) + .map(str::to_string) }) - .collect() + .collect::>()?; + + match tokens.as_slice() { + [] => Err(Error::InvalidExample( + "example cannot be an empty list".to_string(), + )), + _ => Ok(tokens), + } } fn policy_builder<'v, 'a>(eval: &Evaluator<'v, 'a, '_>) -> &'a PolicyBuilder { diff --git a/codex-rs/execpolicy2/tests/basic.rs b/codex-rs/execpolicy2/tests/basic.rs index 18daa63340..27e5ee5651 100644 --- a/codex-rs/execpolicy2/tests/basic.rs +++ b/codex-rs/execpolicy2/tests/basic.rs @@ -112,8 +112,11 @@ fn match_and_not_match_examples_are_enforced() { let policy_src = r#" prefix_rule( pattern = ["git", "status"], - match = [["git", "status"]], - not_match = [["git", "--config", "color.status=always", "status"]], + match = [["git", "status"], "git status"], + not_match = [ + ["git", "--config", "color.status=always", "status"], + "git --config color.status=always status", + ], ) "#; let parser = PolicyParser::new("test.codexpolicy", policy_src);