Add opt-in Unicode canonical matching to exec policy rules (#59535)

## What changed

- Support `canonical_matching=True` in `prefix_rule` and `canonical_matching = true` in managed TOML prefix rules. Apply Unicode canonical caseless matching to every prefix token, including alternatives, while keeping exact matching as the default.
- Match case and canonically equivalent Unicode spellings, such as `bash` and `bAſh`, without resolving executables on the filesystem.
- Preserve managed restrictions when checking absolute-path rule amendments, and enforce applicable `host_executable` path allowlists during basename fallback.

## Testing

Add coverage for case folding, Unicode normalization, token alternatives, exact-match defaults, and host executable allowlists. Extend managed-policy and shell-execution tests to cover canonical matching.

GitOrigin-RevId: c2f79069dc38d55c77028c457b22beb468d5c571
This commit is contained in:
Dylan Hurd
2026-11-05 00:55:46 +00:00
committed by copyberry
parent f3074e35c0
commit de6e72addd
11 changed files with 234 additions and 41 deletions
+2
View File
@@ -18,6 +18,7 @@ path = "src/main.rs"
workspace = true
[dependencies]
caseless = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-utils-absolute-path = { workspace = true }
@@ -29,6 +30,7 @@ starlark = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "io-util", "macros", "rt"] }
unicode-normalization = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
+4 -2
View File
@@ -40,8 +40,8 @@ host_executable(
- execpolicy always tries exact first-token matches first.
- With host-executable resolution disabled, `/usr/bin/git status` only matches a rule whose first token is `/usr/bin/git`.
- With host-executable resolution enabled, if no exact rule matches, execpolicy may fall back from `/usr/bin/git` to basename rules for `git`.
- If `host_executable(name="git", ...)` exists, basename fallback is only allowed for listed absolute paths.
- If no `host_executable()` entry exists for a basename, basename fallback is allowed.
- A `host_executable()` declaration restricts basename fallback to its listed absolute paths. Declarations apply to the command's basename and the matching rule's program name; if both have declarations, the path must appear in both lists.
- Without an applicable declaration, basename fallback is allowed.
## CLI
@@ -95,3 +95,5 @@ cargo run -p codex-execpolicy -- check --rules path/to/policy.rules git status
- The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`).
Note: `execpolicy` commands are still in preview. The API may have breaking changes in the future.
Set `canonical_matching=True` on a `prefix_rule` to use Unicode canonical caseless matching for every token in the prefix, including alternatives. For example, `["bash", "run"]` matches `bAſh RUN`, and a rule containing `café` also matches `CAFE` followed by a combining acute accent. This compares the supplied tokens without resolving the executable on the filesystem. Prefix rules match case exactly by default.
+2
View File
@@ -352,6 +352,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
r#match: Option<UnpackList<Value<'v>>>,
not_match: Option<UnpackList<Value<'v>>>,
justification: Option<&'v str>,
canonical_matching: Option<bool>,
eval: &mut Evaluator<'v, '_, '_>,
) -> anyhow::Result<NoneType> {
let decision = match decision {
@@ -393,6 +394,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
.map(|head| {
Arc::new(PrefixRule {
pattern: PrefixPattern {
canonical_matching: canonical_matching.unwrap_or(false),
first: Arc::from(head.as_str()),
rest: rest.clone(),
},
+114 -22
View File
@@ -1,6 +1,7 @@
use crate::decision::Decision;
use crate::error::Error;
use crate::error::Result;
use crate::executable_name::executable_lookup_key;
use crate::executable_name::executable_path_lookup_key;
use crate::rule::NetworkRule;
use crate::rule::NetworkRuleProtocol;
@@ -9,6 +10,7 @@ use crate::rule::PrefixPattern;
use crate::rule::PrefixRule;
use crate::rule::RuleMatch;
use crate::rule::RuleRef;
use crate::rule::canonical_key;
use crate::rule::normalize_network_rule_host;
use codex_utils_absolute_path::AbsolutePathBuf;
use multimap::MultiMap;
@@ -26,6 +28,7 @@ pub struct MatchOptions {
#[derive(Clone, Debug)]
pub struct Policy {
insensitive_rules: MultiMap<String, RuleRef>,
rules_by_program: MultiMap<String, RuleRef>,
network_rules: Vec<NetworkRule>,
host_executables_by_name: HashMap<String, Arc<[AbsolutePathBuf]>>,
@@ -78,7 +81,21 @@ impl Policy {
network_rules: Vec<NetworkRule>,
host_executables_by_name: HashMap<String, Arc<[AbsolutePathBuf]>>,
) -> Self {
let insensitive_rules = rules_by_program
.iter_all()
.flat_map(|(program, rules)| {
rules
.iter()
.filter(|rule| {
rule.as_any()
.downcast_ref::<PrefixRule>()
.is_some_and(|rule| rule.pattern.canonical_matching)
})
.map(|rule| (canonical_key(program).collect::<String>(), rule.clone()))
})
.collect();
Self {
insensitive_rules,
rules_by_program,
network_rules,
host_executables_by_name,
@@ -132,6 +149,7 @@ impl Policy {
let rule: RuleRef = Arc::new(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from(first_token.as_str()),
rest: rest
.iter()
@@ -195,6 +213,31 @@ impl Policy {
let Ok(path) = AbsolutePathBuf::try_from(program.as_str()) else {
continue;
};
// A differently cased path can shadow a managed basename at that spelling.
if rules.iter().any(|rule| {
rule.as_any()
.downcast_ref::<PrefixRule>()
.is_some_and(|rule| rule.pattern.canonical_matching)
}) && let Some(name) = executable_path_lookup_key(path.as_path())
&& managed.rules().iter_all().any(|(program, fallbacks)| {
canonical_key(program).eq(canonical_key(&name))
&& rules
.iter()
.filter(|rule| {
rule.as_any()
.downcast_ref::<PrefixRule>()
.is_some_and(|prefix| prefix.pattern.canonical_matching)
&& !managed.contains_prefix_rule(rule.program(), rule)
})
.any(|rule| {
fallbacks
.iter()
.any(|fallback| can_weaken_fallback(rule, fallback, rules))
})
})
{
return false;
}
if let Some((_, fallback_rules)) = managed.host_executable_rules(&path)
&& rules.iter().any(|rule| {
!managed.contains_prefix_rule(program, rule)
@@ -383,13 +426,32 @@ impl Policy {
fn match_exact_rules(&self, cmd: &[String]) -> Option<Vec<RuleMatch>> {
let first = cmd.first()?;
Some(
self.rules_by_program
.get_vec(first)
.map(|rules| rules.iter().filter_map(|rule| rule.matches(cmd)).collect())
.unwrap_or_default(),
self.candidates(first)
.into_iter()
.filter_map(|rule| rule.matches(cmd))
.collect(),
)
}
fn candidates(&self, program: &str) -> Vec<&RuleRef> {
let mut candidates: Vec<_> = self
.rules_by_program
.get_vec(program)
.into_iter()
.flatten()
.collect();
if !self.insensitive_rules.is_empty() {
candidates.extend(
self.insensitive_rules
.get_vec(&canonical_key(program).collect::<String>())
.into_iter()
.flatten()
.filter(|rule| rule.program() != program),
);
}
candidates
}
fn match_host_executable_rules(&self, cmd: &[String]) -> Vec<RuleMatch> {
let Some(first) = cmd.first() else {
return Vec::new();
@@ -411,14 +473,22 @@ impl Policy {
.collect()
}
fn host_executable_rules(&self, path: &AbsolutePathBuf) -> Option<(String, &[RuleRef])> {
fn host_executable_rules(&self, path: &AbsolutePathBuf) -> Option<(String, Vec<&RuleRef>)> {
let basename = executable_path_lookup_key(path.as_path())?;
if let Some(paths) = self.host_executables_by_name.get(&basename)
&& !paths.contains(path)
{
return None;
}
let rules = self.rules_by_program.get_vec(&basename)?;
let mut rules = self.candidates(&basename);
// Canonical rules can use a different name, whose declaration also restricts the path.
rules.retain(|rule| {
rule.program() == basename.as_str()
|| self
.host_executables_by_name
.get(&executable_lookup_key(rule.program()))
.is_none_or(|paths| paths.contains(path))
});
Some((basename, rules))
}
}
@@ -433,31 +503,53 @@ fn can_weaken_fallback(exact: &RuleRef, fallback: &RuleRef, path_rules: &[RuleRe
if exact.decision >= fallback.decision {
return false;
}
let (mut shorter, mut longer) = (&exact.pattern.rest, &fallback.pattern.rest);
if shorter.len() > longer.len() {
std::mem::swap(&mut shorter, &mut longer);
}
// The overlapping commands use arguments allowed by both prefixes where both apply.
let shared_arguments_at = |i: usize| {
longer[i].alternatives().iter().filter(move |arg| {
shorter
.get(i)
.is_none_or(|token| token.alternatives().contains(arg))
let fallback_decision = fallback.decision;
let exact = &exact.pattern;
let fallback = &fallback.pattern;
let length = exact.rest.len().max(fallback.rest.len());
// Where both rules constrain an argument, use the case-sensitive spellings
// if available. A missing token leaves the other rule's alternatives unrestricted.
let overlap_at = |i: usize| {
let (source, filter) = if i >= exact.rest.len()
|| (i < fallback.rest.len() && exact.canonical_matching && !fallback.canonical_matching)
{
(fallback, exact)
} else {
(exact, fallback)
};
source.rest[i].alternatives().iter().filter_map(move |arg| {
let matches = filter.rest.get(i).is_none_or(|token| {
token
.alternatives()
.iter()
.any(|alt| filter.tokens_equal(alt, arg))
});
matches.then_some((arg, source.canonical_matching))
})
};
if (0..shorter.len()).any(|i| shared_arguments_at(i).next().is_none()) {
if (0..length).any(|i| overlap_at(i).next().is_none()) {
return false;
}
// Exact matches take the strongest decision. A shorter or equal exact rule
// can therefore preserve the fallback for every command in the overlap.
// A protecting exact rule must cover every spelling in the overlap.
!path_rules.iter().any(|rule| {
let Some(rule) = rule.as_any().downcast_ref::<PrefixRule>() else {
return false;
};
rule.decision >= fallback.decision
&& rule.pattern.rest.len() <= longer.len()
rule.decision >= fallback_decision
&& (!exact.canonical_matching || rule.pattern.canonical_matching)
&& rule.pattern.rest.len() <= length
&& rule.pattern.rest.iter().enumerate().all(|(i, token)| {
shared_arguments_at(i).all(|arg| token.alternatives().contains(arg))
overlap_at(i).all(|(arg, insensitive)| {
// For a folded overlap, an exact spelling alone proves coverage
// only for ASCII tokens without letters.
let needs_fold = insensitive
&& (!arg.is_ascii() || arg.bytes().any(|b| b.is_ascii_alphabetic()));
(!needs_fold || rule.pattern.canonical_matching)
&& token
.alternatives()
.iter()
.any(|alt| rule.pattern.tokens_equal(alt, arg))
})
})
})
}
+22 -9
View File
@@ -3,6 +3,7 @@ use crate::error::Error;
use crate::error::Result;
use crate::policy::MatchOptions;
use crate::policy::Policy;
use caseless::Caseless;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
@@ -10,6 +11,12 @@ use shlex::try_join;
use std::any::Any;
use std::fmt::Debug;
use std::sync::Arc;
use unicode_normalization::UnicodeNormalization;
/// Unicode canonical caseless comparison key: NFD, full case folding, then NFD.
pub(crate) fn canonical_key(token: &str) -> impl Iterator<Item = char> + '_ {
token.nfd().default_case_fold().nfd()
}
/// Matches a single command token, either a fixed string or one of several allowed alternatives.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -19,13 +26,6 @@ pub enum PatternToken {
}
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),
@@ -38,19 +38,32 @@ impl PatternToken {
/// First token is fixed since we key by the first token in policy.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PrefixPattern {
pub canonical_matching: bool,
pub first: Arc<str>,
pub rest: Arc<[PatternToken]>,
}
impl PrefixPattern {
pub(crate) fn tokens_equal(&self, a: &str, b: &str) -> bool {
if self.canonical_matching {
canonical_key(a).eq(canonical_key(b))
} else {
a == b
}
}
pub fn matches_prefix(&self, cmd: &[String]) -> Option<Vec<String>> {
let pattern_length = self.rest.len() + 1;
if cmd.len() < pattern_length || cmd[0] != self.first.as_ref() {
if cmd.len() < pattern_length || !self.tokens_equal(&cmd[0], &self.first) {
return None;
}
for (pattern_token, cmd_token) in self.rest.iter().zip(&cmd[1..pattern_length]) {
if !pattern_token.matches(cmd_token) {
if !pattern_token
.alternatives()
.iter()
.any(|alt| self.tokens_equal(alt, cmd_token))
{
return None;
}
}
+74 -1
View File
@@ -256,6 +256,7 @@ fn add_prefix_rule_extends_policy() -> Result<()> {
assert_eq!(
vec![RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from("ls"),
rest: vec![PatternToken::Single(String::from("-l"))].into(),
},
@@ -317,6 +318,7 @@ prefix_rule(
vec![
RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from("git"),
rest: Vec::<PatternToken>::new().into(),
},
@@ -325,6 +327,7 @@ prefix_rule(
}),
RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from("git"),
rest: vec![PatternToken::Single("commit".to_string())].into(),
},
@@ -394,6 +397,7 @@ prefix_rule(
assert_eq!(
vec![RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from("bash"),
rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(),
},
@@ -405,6 +409,7 @@ prefix_rule(
assert_eq!(
vec![RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from("sh"),
rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(),
},
@@ -459,6 +464,7 @@ prefix_rule(
assert_eq!(
vec![RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from("npm"),
rest: vec![
PatternToken::Alts(vec!["i".to_string(), "install".to_string()]),
@@ -810,7 +816,7 @@ fn inherited_absolute_amendments_preserve_managed_basename_restrictions() -> Res
let mut parser = PolicyParser::new();
parser.parse(
"managed.rules",
r#"prefix_rule(pattern = ["git", ["push", "send-pack"]], decision = "forbidden")"#,
r#"prefix_rule(pattern = ["git", ["push", "send-pack"]], canonical_matching=True, decision = "forbidden")"#,
)?;
let managed = parser.build();
let requirements = RequirementsExecPolicy::new(managed.clone());
@@ -819,6 +825,7 @@ fn inherited_absolute_amendments_preserve_managed_basename_restrictions() -> Res
(&["status"][..], Decision::Allow, true),
(&[], Decision::Allow, false),
(&["send-pack", "origin"], Decision::Allow, false),
(&["PUſH"], Decision::Allow, false),
(&["push"], Decision::Forbidden, true),
] {
let mut snapshot = managed.clone();
@@ -830,6 +837,20 @@ fn inherited_absolute_amendments_preserve_managed_basename_restrictions() -> Res
accepted
);
}
let path = starlark_string(&git);
for (insensitive, accepted) in [("True", true), ("False", false)] {
let mut parser = PolicyParser::new();
parser.parse("path.rules", &format!(
r#"prefix_rule(pattern=["{path}", ["push", "send-pack"]], canonical_matching={insensitive}, decision="forbidden")"#
))?;
let managed = managed.merge_overlay(&parser.build());
let mut snapshot = managed.clone();
snapshot.add_prefix_rule(std::slice::from_ref(&git), Decision::Allow)?;
assert_eq!(
snapshot.preserves_managed_requirements(&RequirementsExecPolicy::new(managed)),
accepted
);
}
Ok(())
}
@@ -877,6 +898,31 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"])
Ok(())
}
#[test]
fn canonical_host_executable_resolution_uses_only_relevant_declarations() -> Result<()> {
let bash_path = starlark_string(&host_absolute_path(&[
"usr",
"bin",
&host_executable_name("bash"),
]));
// The long s keeps the executable lookup keys distinct on Windows too.
let long_s_bash_path = starlark_string(&host_absolute_path(&[
"usr",
"bin",
&host_executable_name("baſh"),
]));
let policy_src = format!(
r#"
prefix_rule(pattern = ["bash"], canonical_matching = True, decision = "forbidden",
match = [["{bash_path}"]], not_match = [["{long_s_bash_path}"]])
host_executable(name = "bash", paths = ["{bash_path}"])
host_executable(name = "baſh", paths = ["{long_s_bash_path}"])
"#
);
PolicyParser::new().parse("test.rules", &policy_src)?;
Ok(())
}
#[test]
fn host_executable_resolution_respects_explicit_empty_allowlist() -> Result<()> {
let policy_src = r#"
@@ -1011,3 +1057,30 @@ host_executable(name = "git", paths = ["{git_path_literal}"])
);
Ok(())
}
#[test]
fn canonical_matching_prefix_matches_each_token() -> Result<()> {
let mut parser = PolicyParser::new();
parser.parse(
"case.rules",
r#"
prefix_rule(pattern=[["Tool", "bash"], "Run", ["ONE", "two", "set"]], canonical_matching=True,
match=["TOOL rUn Two extra", "bAſh RUN one", "bash run ſet"],
not_match=["TOOL run three", "tool runner two"])
prefix_rule(pattern=["café", ["résumé", "run"]], canonical_matching=True,
match=["CAFÉ RÉSUMÉ"], not_match=["cafe resume", "café résumé"])
# Full case folding can expand one character into two, within a single token.
prefix_rule(pattern=["straße"], canonical_matching=True,
match=["STRASSE"], not_match=["STRAS SE"])
# U+0345 folds to a spacing iota. Reorder the acute accent before folding
# so it stays attached to the alpha.
prefix_rule(pattern=["α\u0345\u0301"], canonical_matching=True,
match=["άι"], not_match=["αί"])
prefix_rule(pattern=["café"], not_match=["café"])
prefix_rule(pattern=["set-content"], canonical_matching=True, not_match=["set-cøntent"])
prefix_rule(pattern=["bash"], not_match=["baſh"])
prefix_rule(pattern=["Tool", "Run"], not_match=["tool Run", "Tool run"])
"#,
)?;
Ok(())
}