mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
Refactor execpolicy fallback evaluation
This commit is contained in:
@@ -40,17 +40,15 @@ prefix_rule(
|
||||
assert_eq!(
|
||||
result,
|
||||
json!({
|
||||
"match": {
|
||||
"decision": "forbidden",
|
||||
"matchedRules": [
|
||||
{
|
||||
"prefixRuleMatch": {
|
||||
"matchedPrefix": ["git", "push"],
|
||||
"decision": "forbidden"
|
||||
}
|
||||
"decision": "forbidden",
|
||||
"matchedRules": [
|
||||
{
|
||||
"prefixRuleMatch": {
|
||||
"matchedPrefix": ["git", "push"],
|
||||
"decision": "forbidden"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use codex_execpolicy::Error as ExecPolicyRuleError;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_execpolicy::PolicyParser;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_execpolicy::blocking_append_allow_prefix_rule;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
@@ -25,6 +26,8 @@ use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
|
||||
const FORBIDDEN_REASON: &str = "execpolicy forbids this command";
|
||||
const PROMPT_CONFLICT_REASON: &str =
|
||||
"execpolicy requires approval for this command, but AskForApproval is set to Never";
|
||||
const PROMPT_REASON: &str = "execpolicy requires approval for this command";
|
||||
const POLICY_DIR_NAME: &str = "policy";
|
||||
const POLICY_EXTENSION: &str = "codexpolicy";
|
||||
@@ -139,45 +142,41 @@ pub(crate) async fn append_allow_prefix_rule_and_update(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn requirement_from_decision(
|
||||
decision: Decision,
|
||||
approval_policy: AskForApproval,
|
||||
) -> ExecApprovalRequirement {
|
||||
match decision {
|
||||
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
|
||||
reason: FORBIDDEN_REASON.to_string(),
|
||||
},
|
||||
Decision::Prompt => {
|
||||
let reason = PROMPT_REASON.to_string();
|
||||
if matches!(approval_policy, AskForApproval::Never) {
|
||||
ExecApprovalRequirement::Forbidden { reason }
|
||||
} else {
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some(reason),
|
||||
allow_prefix: None,
|
||||
/// Return an allow-prefix option when a command needs approval and execpolicy did not drive the decision.
|
||||
fn allow_prefix_if_applicable(evaluation: &Evaluation, features: &Features) -> Option<Vec<String>> {
|
||||
if !features.enabled(Feature::ExecPolicy) || evaluation.decision != Decision::Prompt {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut first_prompt_from_heuristics: Option<Vec<String>> = None;
|
||||
for rule_match in &evaluation.matched_rules {
|
||||
match rule_match {
|
||||
RuleMatch::HeuristicsRuleMatch { command, decision } => {
|
||||
if *decision == Decision::Prompt && first_prompt_from_heuristics.is_none() {
|
||||
first_prompt_from_heuristics = Some(command.clone());
|
||||
}
|
||||
}
|
||||
_ if rule_match.decision() == Decision::Prompt => {
|
||||
return None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Decision::Allow => ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
},
|
||||
}
|
||||
|
||||
first_prompt_from_heuristics
|
||||
}
|
||||
|
||||
/// Return an allow-prefix option when a single plain command needs approval without
|
||||
/// any matching policy rule. We only surface the prefix opt-in when execpolicy did
|
||||
/// not already drive the decision (NoMatch) and when the command is a single
|
||||
/// unrolled command (multi-part scripts shouldn’t be whitelisted via prefix) and
|
||||
/// when execpolicy feature is enabled.
|
||||
fn allow_prefix_if_applicable(
|
||||
commands: &[Vec<String>],
|
||||
features: &Features,
|
||||
) -> Option<Vec<String>> {
|
||||
if features.enabled(Feature::ExecPolicy) && commands.len() == 1 {
|
||||
Some(commands[0].clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
/// Only return PROMPT_REASON when an execpolicy rule drove the prompt decision.
|
||||
fn derive_prompt_reason(evaluation: &Evaluation) -> Option<String> {
|
||||
evaluation.matched_rules.iter().find_map(|rule_match| {
|
||||
if !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })
|
||||
&& rule_match.decision() == Decision::Prompt
|
||||
{
|
||||
Some(PROMPT_REASON.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn create_exec_approval_requirement_for_command(
|
||||
@@ -189,27 +188,39 @@ pub(crate) async fn create_exec_approval_requirement_for_command(
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> ExecApprovalRequirement {
|
||||
let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]);
|
||||
let evaluation = exec_policy.read().await.check_multiple(commands.iter());
|
||||
let heuristics_fallback = |cmd: &[String]| {
|
||||
if requires_initial_appoval(approval_policy, sandbox_policy, cmd, sandbox_permissions) {
|
||||
Decision::Prompt
|
||||
} else {
|
||||
Decision::Allow
|
||||
}
|
||||
};
|
||||
let policy = exec_policy.read().await;
|
||||
let evaluation = policy.check_multiple(commands.iter(), &heuristics_fallback);
|
||||
let has_policy_allow = evaluation.matched_rules.iter().any(|rule_match| {
|
||||
!matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })
|
||||
&& rule_match.decision() == Decision::Allow
|
||||
});
|
||||
|
||||
match evaluation {
|
||||
Evaluation::Match { decision, .. } => requirement_from_decision(decision, approval_policy),
|
||||
Evaluation::NoMatch { .. } => {
|
||||
if requires_initial_appoval(
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
command,
|
||||
sandbox_permissions,
|
||||
) {
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
allow_prefix: allow_prefix_if_applicable(&commands, features),
|
||||
match evaluation.decision {
|
||||
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
|
||||
reason: FORBIDDEN_REASON.to_string(),
|
||||
},
|
||||
Decision::Prompt => {
|
||||
if matches!(approval_policy, AskForApproval::Never) {
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: PROMPT_CONFLICT_REASON.to_string(),
|
||||
}
|
||||
} else {
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: derive_prompt_reason(&evaluation),
|
||||
allow_prefix: allow_prefix_if_applicable(&evaluation, features),
|
||||
}
|
||||
}
|
||||
}
|
||||
Decision::Allow => ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: has_policy_allow,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,10 +293,19 @@ mod tests {
|
||||
.expect("policy result");
|
||||
|
||||
let commands = [vec!["rm".to_string()]];
|
||||
assert!(matches!(
|
||||
policy.read().await.check_multiple(commands.iter()),
|
||||
Evaluation::NoMatch { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
|
||||
command: vec!["rm".to_string()],
|
||||
decision: Decision::Allow
|
||||
}],
|
||||
},
|
||||
policy
|
||||
.read()
|
||||
.await
|
||||
.check_multiple(commands.iter(), &|_| Decision::Allow)
|
||||
);
|
||||
assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists());
|
||||
}
|
||||
|
||||
@@ -316,10 +336,19 @@ mod tests {
|
||||
.await
|
||||
.expect("policy result");
|
||||
let command = [vec!["rm".to_string()]];
|
||||
assert!(matches!(
|
||||
policy.read().await.check_multiple(command.iter()),
|
||||
Evaluation::Match { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
Evaluation {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: vec!["rm".to_string()],
|
||||
decision: Decision::Forbidden
|
||||
}],
|
||||
},
|
||||
policy
|
||||
.read()
|
||||
.await
|
||||
.check_multiple(command.iter(), &|_| Decision::Allow)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -335,10 +364,19 @@ mod tests {
|
||||
.await
|
||||
.expect("policy result");
|
||||
let command = [vec!["ls".to_string()]];
|
||||
assert!(matches!(
|
||||
policy.read().await.check_multiple(command.iter()),
|
||||
Evaluation::NoMatch { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
|
||||
command: vec!["ls".to_string()],
|
||||
decision: Decision::Allow
|
||||
}],
|
||||
},
|
||||
policy
|
||||
.read()
|
||||
.await
|
||||
.check_multiple(command.iter(), &|_| Decision::Allow)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -428,7 +466,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: PROMPT_REASON.to_string()
|
||||
reason: PROMPT_CONFLICT_REASON.to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -457,6 +495,37 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heuristics_apply_when_other_commands_match_policy() {
|
||||
let policy_src = r#"prefix_rule(pattern=["apple"], decision="allow")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = Arc::new(RwLock::new(parser.build()));
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"apple | orange".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
create_exec_approval_requirement_for_command(
|
||||
&policy,
|
||||
&Features::with_defaults(),
|
||||
&command,
|
||||
AskForApproval::UnlessTrusted,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
allow_prefix: Some(vec!["orange".to_string()])
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_allow_prefix_rule_updates_policy_and_file() {
|
||||
let codex_home = tempdir().expect("create temp dir");
|
||||
@@ -467,14 +536,13 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
.await
|
||||
.expect("update policy");
|
||||
|
||||
let evaluation = current_policy.read().await.check(&[
|
||||
"echo".to_string(),
|
||||
"hello".to_string(),
|
||||
"world".to_string(),
|
||||
]);
|
||||
let evaluation = current_policy.read().await.check(
|
||||
&["echo".to_string(), "hello".to_string(), "world".to_string()],
|
||||
&|_| Decision::Allow,
|
||||
);
|
||||
assert!(matches!(
|
||||
evaluation,
|
||||
Evaluation::Match {
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
..
|
||||
}
|
||||
@@ -606,7 +674,38 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
allow_prefix: None,
|
||||
allow_prefix: Some(vec!["cargo".to_string(), "build".to_string()]),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allow_prefix_uses_first_no_match_in_multi_command_scripts() {
|
||||
let policy_src = r#"prefix_rule(pattern=["python"], decision="allow")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = Arc::new(RwLock::new(parser.build()));
|
||||
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"python && echo ok".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
create_exec_approval_requirement_for_command(
|
||||
&policy,
|
||||
&Features::with_defaults(),
|
||||
&command,
|
||||
AskForApproval::UnlessTrusted,
|
||||
&SandboxPolicy::ReadOnly,
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await,
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -241,7 +241,6 @@ impl ShellHandler {
|
||||
SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)),
|
||||
)
|
||||
.await;
|
||||
|
||||
let req = ShellRequest {
|
||||
command: exec_params.command.clone(),
|
||||
cwd: exec_params.cwd.clone(),
|
||||
|
||||
@@ -30,32 +30,24 @@ codex execpolicy check --policy path/to/policy.codexpolicy git status
|
||||
cargo run -p codex-execpolicy -- check --policy path/to/policy.codexpolicy git status
|
||||
```
|
||||
- Example outcomes:
|
||||
- Match: `{"match": { ... "decision": "allow" ... }}`
|
||||
- No match: `{"noMatch": {}}`
|
||||
- Match: `{"matchedRules":[{...}],"decision":"allow"}`
|
||||
- No match: `{"matchedRules":[]}`
|
||||
|
||||
## Response shapes
|
||||
- Match:
|
||||
## Response shape
|
||||
```json
|
||||
{
|
||||
"match": {
|
||||
"decision": "allow|prompt|forbidden",
|
||||
"matchedRules": [
|
||||
{
|
||||
"prefixRuleMatch": {
|
||||
"matchedPrefix": ["<token>", "..."],
|
||||
"decision": "allow|prompt|forbidden"
|
||||
}
|
||||
"matchedRules": [
|
||||
{
|
||||
"prefixRuleMatch": {
|
||||
"matchedPrefix": ["<token>", "..."],
|
||||
"decision": "allow|prompt|forbidden"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"decision": "allow|prompt|forbidden"
|
||||
}
|
||||
```
|
||||
|
||||
- No match:
|
||||
```json
|
||||
{"noMatch": {}}
|
||||
```
|
||||
|
||||
- When no rules match, `matchedRules` is an empty array and `decision` is omitted.
|
||||
- `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`).
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ use std::path::PathBuf;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Evaluation;
|
||||
use crate::Decision;
|
||||
use crate::Policy;
|
||||
use crate::PolicyParser;
|
||||
use crate::RuleMatch;
|
||||
|
||||
/// Arguments for evaluating a command against one or more execpolicy files.
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
@@ -34,20 +36,25 @@ impl ExecPolicyCheckCommand {
|
||||
/// Load the policies for this command, evaluate the command, and render JSON output.
|
||||
pub fn run(&self) -> Result<()> {
|
||||
let policy = load_policies(&self.policies)?;
|
||||
let evaluation = policy.check(&self.command);
|
||||
let matched_rules = policy.matches_for_command(&self.command, None);
|
||||
|
||||
let json = format_evaluation_json(&evaluation, self.pretty)?;
|
||||
let json = format_matches_json(&matched_rules, self.pretty)?;
|
||||
println!("{json}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_evaluation_json(evaluation: &Evaluation, pretty: bool) -> Result<String> {
|
||||
pub fn format_matches_json(matched_rules: &[RuleMatch], pretty: bool) -> Result<String> {
|
||||
let output = ExecPolicyCheckOutput {
|
||||
matched_rules,
|
||||
decision: matched_rules.iter().map(RuleMatch::decision).max(),
|
||||
};
|
||||
|
||||
if pretty {
|
||||
serde_json::to_string_pretty(evaluation).map_err(Into::into)
|
||||
serde_json::to_string_pretty(&output).map_err(Into::into)
|
||||
} else {
|
||||
serde_json::to_string(evaluation).map_err(Into::into)
|
||||
serde_json::to_string(&output).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,3 +72,12 @@ pub fn load_policies(policy_paths: &[PathBuf]) -> Result<Policy> {
|
||||
|
||||
Ok(parser.build())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExecPolicyCheckOutput<'a> {
|
||||
#[serde(rename = "matchedRules")]
|
||||
matched_rules: &'a [RuleMatch],
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
decision: Option<Decision>,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use codex_execpolicy::ExecPolicyCheckCommand;
|
||||
use codex_execpolicy::execpolicycheck::ExecPolicyCheckCommand;
|
||||
|
||||
/// CLI for evaluating exec policies
|
||||
#[derive(Parser)]
|
||||
@@ -13,10 +13,6 @@ enum Cli {
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli {
|
||||
Cli::Check(cmd) => cmd_check(cmd),
|
||||
Cli::Check(cmd) => cmd.run(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_check(cmd: ExecPolicyCheckCommand) -> Result<()> {
|
||||
cmd.run()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
type HeuristicsFallback<'a> = Option<&'a dyn Fn(&[String]) -> Decision>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Policy {
|
||||
rules_by_program: MultiMap<String, RuleRef>,
|
||||
@@ -50,62 +52,84 @@ impl Policy {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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<RuleMatch> =
|
||||
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 {},
|
||||
}
|
||||
pub fn check<F>(&self, cmd: &[String], heuristics_fallback: &F) -> Evaluation
|
||||
where
|
||||
F: Fn(&[String]) -> Decision,
|
||||
{
|
||||
let matched_rules = self.matches_for_command(cmd, Some(heuristics_fallback));
|
||||
Evaluation::from_matches(matched_rules)
|
||||
}
|
||||
|
||||
pub fn check_multiple<Commands>(&self, commands: Commands) -> Evaluation
|
||||
pub fn check_multiple<Commands, F>(
|
||||
&self,
|
||||
commands: Commands,
|
||||
heuristics_fallback: &F,
|
||||
) -> Evaluation
|
||||
where
|
||||
Commands: IntoIterator,
|
||||
Commands::Item: AsRef<[String]>,
|
||||
F: Fn(&[String]) -> Decision,
|
||||
{
|
||||
let matched_rules: Vec<RuleMatch> = commands
|
||||
.into_iter()
|
||||
.flat_map(|command| match self.check(command.as_ref()) {
|
||||
Evaluation::Match { matched_rules, .. } => matched_rules,
|
||||
Evaluation::NoMatch { .. } => Vec::new(),
|
||||
.flat_map(|command| {
|
||||
self.matches_for_command(command.as_ref(), Some(heuristics_fallback))
|
||||
})
|
||||
.collect();
|
||||
|
||||
match matched_rules.iter().map(RuleMatch::decision).max() {
|
||||
Some(decision) => Evaluation::Match {
|
||||
decision,
|
||||
matched_rules,
|
||||
},
|
||||
None => Evaluation::NoMatch {},
|
||||
Evaluation::from_matches(matched_rules)
|
||||
}
|
||||
|
||||
pub fn matches_for_command(
|
||||
&self,
|
||||
cmd: &[String],
|
||||
heuristics_fallback: HeuristicsFallback<'_>,
|
||||
) -> Vec<RuleMatch> {
|
||||
let mut matched_rules: Vec<RuleMatch> = match cmd.first() {
|
||||
Some(first) => self
|
||||
.rules_by_program
|
||||
.get_vec(first)
|
||||
.map(|rules| rules.iter().filter_map(|rule| rule.matches(cmd)).collect())
|
||||
.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
if let (true, Some(heuristics_fallback)) = (matched_rules.is_empty(), heuristics_fallback) {
|
||||
matched_rules.push(RuleMatch::HeuristicsRuleMatch {
|
||||
command: cmd.to_vec(),
|
||||
decision: heuristics_fallback(cmd),
|
||||
});
|
||||
}
|
||||
|
||||
matched_rules
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Evaluation {
|
||||
NoMatch {},
|
||||
Match {
|
||||
decision: Decision,
|
||||
#[serde(rename = "matchedRules")]
|
||||
matched_rules: Vec<RuleMatch>,
|
||||
},
|
||||
pub struct Evaluation {
|
||||
pub decision: Decision,
|
||||
#[serde(rename = "matchedRules")]
|
||||
pub matched_rules: Vec<RuleMatch>,
|
||||
}
|
||||
|
||||
impl Evaluation {
|
||||
pub fn is_match(&self) -> bool {
|
||||
matches!(self, Self::Match { .. })
|
||||
self.matched_rules
|
||||
.iter()
|
||||
.any(|rule_match| !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }))
|
||||
}
|
||||
|
||||
fn from_matches(matched_rules: Vec<RuleMatch>) -> Self {
|
||||
let decision = matched_rules
|
||||
.iter()
|
||||
.map(RuleMatch::decision)
|
||||
.max()
|
||||
.unwrap_or(Decision::Allow);
|
||||
|
||||
Self {
|
||||
decision,
|
||||
matched_rules,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +64,17 @@ pub enum RuleMatch {
|
||||
matched_prefix: Vec<String>,
|
||||
decision: Decision,
|
||||
},
|
||||
HeuristicsRuleMatch {
|
||||
command: Vec<String>,
|
||||
decision: Decision,
|
||||
},
|
||||
}
|
||||
|
||||
impl RuleMatch {
|
||||
pub fn decision(&self) -> Decision {
|
||||
match self {
|
||||
Self::PrefixRuleMatch { decision, .. } => *decision,
|
||||
Self::HeuristicsRuleMatch { decision, .. } => *decision,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ fn tokens(cmd: &[&str]) -> Vec<String> {
|
||||
cmd.iter().map(std::string::ToString::to_string).collect()
|
||||
}
|
||||
|
||||
fn allow_all(_: &[String]) -> Decision {
|
||||
Decision::Allow
|
||||
}
|
||||
|
||||
fn prompt_all(_: &[String]) -> Decision {
|
||||
Decision::Prompt
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
enum RuleSnapshot {
|
||||
Prefix(PrefixRule),
|
||||
@@ -49,9 +57,9 @@ prefix_rule(
|
||||
parser.parse("test.codexpolicy", policy_src)?;
|
||||
let policy = parser.build();
|
||||
let cmd = tokens(&["git", "status"]);
|
||||
let evaluation = policy.check(&cmd);
|
||||
let evaluation = policy.check(&cmd, &allow_all);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "status"]),
|
||||
@@ -80,9 +88,9 @@ fn add_prefix_rule_extends_policy() -> Result<()> {
|
||||
rules
|
||||
);
|
||||
|
||||
let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"]));
|
||||
let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"]), &allow_all);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
Evaluation {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["ls", "-l"]),
|
||||
@@ -146,9 +154,9 @@ prefix_rule(
|
||||
git_rules
|
||||
);
|
||||
|
||||
let status_eval = policy.check(&tokens(&["git", "status"]));
|
||||
let status_eval = policy.check(&tokens(&["git", "status"]), &allow_all);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
Evaluation {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
@@ -158,9 +166,9 @@ prefix_rule(
|
||||
status_eval
|
||||
);
|
||||
|
||||
let commit_eval = policy.check(&tokens(&["git", "commit", "-m", "hi"]));
|
||||
let commit_eval = policy.check(&tokens(&["git", "commit", "-m", "hi"]), &allow_all);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
Evaluation {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
@@ -217,9 +225,9 @@ prefix_rule(
|
||||
sh_rules
|
||||
);
|
||||
|
||||
let bash_eval = policy.check(&tokens(&["bash", "-c", "echo", "hi"]));
|
||||
let bash_eval = policy.check(&tokens(&["bash", "-c", "echo", "hi"]), &allow_all);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["bash", "-c"]),
|
||||
@@ -229,9 +237,9 @@ prefix_rule(
|
||||
bash_eval
|
||||
);
|
||||
|
||||
let sh_eval = policy.check(&tokens(&["sh", "-l", "echo", "hi"]));
|
||||
let sh_eval = policy.check(&tokens(&["sh", "-l", "echo", "hi"]), &allow_all);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["sh", "-l"]),
|
||||
@@ -273,9 +281,9 @@ prefix_rule(
|
||||
rules
|
||||
);
|
||||
|
||||
let npm_i = policy.check(&tokens(&["npm", "i", "--legacy-peer-deps"]));
|
||||
let npm_i = policy.check(&tokens(&["npm", "i", "--legacy-peer-deps"]), &allow_all);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["npm", "i", "--legacy-peer-deps"]),
|
||||
@@ -285,9 +293,12 @@ prefix_rule(
|
||||
npm_i
|
||||
);
|
||||
|
||||
let npm_install = policy.check(&tokens(&["npm", "install", "--no-save", "leftpad"]));
|
||||
let npm_install = policy.check(
|
||||
&tokens(&["npm", "install", "--no-save", "leftpad"]),
|
||||
&allow_all,
|
||||
);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["npm", "install", "--no-save"]),
|
||||
@@ -314,9 +325,9 @@ prefix_rule(
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse("test.codexpolicy", policy_src)?;
|
||||
let policy = parser.build();
|
||||
let match_eval = policy.check(&tokens(&["git", "status"]));
|
||||
let match_eval = policy.check(&tokens(&["git", "status"]), &allow_all);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "status"]),
|
||||
@@ -326,13 +337,20 @@ prefix_rule(
|
||||
match_eval
|
||||
);
|
||||
|
||||
let no_match_eval = policy.check(&tokens(&[
|
||||
"git",
|
||||
"--config",
|
||||
"color.status=always",
|
||||
"status",
|
||||
]));
|
||||
assert_eq!(Evaluation::NoMatch {}, no_match_eval);
|
||||
let no_match_eval = policy.check(
|
||||
&tokens(&["git", "--config", "color.status=always", "status"]),
|
||||
&allow_all,
|
||||
);
|
||||
assert_eq!(
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
|
||||
command: tokens(&["git", "--config", "color.status=always", "status",]),
|
||||
decision: Decision::Allow,
|
||||
}],
|
||||
},
|
||||
no_match_eval
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -352,9 +370,9 @@ prefix_rule(
|
||||
parser.parse("test.codexpolicy", policy_src)?;
|
||||
let policy = parser.build();
|
||||
|
||||
let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"]));
|
||||
let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"]), &allow_all);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
Evaluation {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
@@ -393,9 +411,9 @@ prefix_rule(
|
||||
tokens(&["git", "commit", "-m", "hi"]),
|
||||
];
|
||||
|
||||
let evaluation = policy.check_multiple(&commands);
|
||||
let evaluation = policy.check_multiple(&commands, &allow_all);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
Evaluation {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
@@ -416,3 +434,21 @@ prefix_rule(
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristics_match_is_returned_when_no_policy_matches() {
|
||||
let policy = Policy::empty();
|
||||
let command = tokens(&["python"]);
|
||||
|
||||
let evaluation = policy.check(&command, &prompt_all);
|
||||
assert_eq!(
|
||||
Evaluation {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
|
||||
command,
|
||||
decision: Decision::Prompt,
|
||||
}],
|
||||
},
|
||||
evaluation
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,30 @@ codex execpolicy check --policy ~/.codex/policy/default.codexpolicy git push ori
|
||||
|
||||
Pass multiple `--policy` flags to test how several files combine, and use `--pretty` for formatted JSON output. See the [`codex-rs/execpolicy` README](../codex-rs/execpolicy/README.md) for a more detailed walkthrough of the available syntax.
|
||||
|
||||
Example output when a rule matches:
|
||||
|
||||
```json
|
||||
{
|
||||
"matchedRules": [
|
||||
{
|
||||
"prefixRuleMatch": {
|
||||
"matchedPrefix": ["git", "push"],
|
||||
"decision": "prompt"
|
||||
}
|
||||
}
|
||||
],
|
||||
"decision": "prompt"
|
||||
}
|
||||
```
|
||||
|
||||
When no rules match, `matchedRules` is an empty array and `decision` is omitted.
|
||||
|
||||
```json
|
||||
{
|
||||
"matchedRules": [],
|
||||
}
|
||||
|
||||
## Status
|
||||
|
||||
`execpolicy` commands are still in preview. The API may have breaking changes in the future.
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user