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
+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;
}
}