Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions codex-rs/config/src/config_requirements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,7 @@ mod tests {
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["rm"]),
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
}
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/core/src/config_loader/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,7 @@ prefix_rules = [
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["rm"]),
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
}
Expand All @@ -1415,6 +1416,7 @@ prefix_rules = [
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "status"]),
decision: Decision::Prompt,
resolved_program: None,
justification: None,
}],
}
Expand All @@ -1426,6 +1428,7 @@ prefix_rules = [
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["hg", "status"]),
decision: Decision::Prompt,
resolved_program: None,
justification: None,
}],
}
Expand Down Expand Up @@ -1509,6 +1512,7 @@ prefix_rules = []
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
}
Expand Down Expand Up @@ -1547,6 +1551,7 @@ prefix_rules = []
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
}
Expand All @@ -1561,6 +1566,7 @@ prefix_rules = []
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["git".to_string(), "push".to_string()],
decision: Decision::Prompt,
resolved_program: None,
justification: None,
}],
}
Expand Down
88 changes: 77 additions & 11 deletions codex-rs/core/src/exec_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,17 +472,7 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
return Ok(policy);
};

let mut combined_rules = policy.rules().clone();
for (program, rules) in requirements_policy.as_ref().rules().iter_all() {
for rule in rules {
combined_rules.insert(program.clone(), rule.clone());
}
}

let mut combined_network_rules = policy.network_rules().to_vec();
combined_network_rules.extend(requirements_policy.as_ref().network_rules().iter().cloned());

Ok(Policy::from_parts(combined_rules, combined_network_rules))
Ok(policy.merge_overlay(requirements_policy.as_ref()))
}

/// If a command is not matched by any execpolicy rule, derive a [`Decision`].
Expand Down Expand Up @@ -827,6 +817,7 @@ mod tests {
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::tempdir;
use toml::Value as TomlValue;
Expand All @@ -846,6 +837,22 @@ mod tests {
.expect("ConfigLayerStack")
}

fn host_absolute_path(segments: &[&str]) -> String {
let mut path = if cfg!(windows) {
PathBuf::from(r"C:\")
} else {
PathBuf::from("/")
};
for segment in segments {
path.push(segment);
}
path.to_string_lossy().into_owned()
}

fn starlark_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}

#[tokio::test]
async fn returns_empty_policy_when_no_policy_files_exist() {
let temp_dir = tempdir().expect("create temp dir");
Expand Down Expand Up @@ -949,6 +956,7 @@ mod tests {
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
},
Expand Down Expand Up @@ -991,6 +999,59 @@ mod tests {
Ok(())
}

#[tokio::test]
async fn preserves_host_executables_when_requirements_overlay_is_present() -> anyhow::Result<()>
{
let temp_dir = tempdir()?;
let policy_dir = temp_dir.path().join(RULES_DIR_NAME);
fs::create_dir_all(&policy_dir)?;
let git_path = host_absolute_path(&["usr", "bin", "git"]);
let git_path_literal = starlark_string(&git_path);
fs::write(
policy_dir.join("host.rules"),
format!(
r#"
host_executable(name = "git", paths = ["{git_path_literal}"])
"#
),
)?;

let mut requirements_exec_policy = Policy::empty();
requirements_exec_policy.add_network_rule(
"blocked.example.com",
codex_execpolicy::NetworkRuleProtocol::Https,
Decision::Forbidden,
None,
)?;

let requirements = ConfigRequirements {
exec_policy: Some(codex_config::Sourced::new(
codex_config::RequirementsExecPolicy::new(requirements_exec_policy),
codex_config::RequirementSource::Unknown,
)),
..ConfigRequirements::default()
};
let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?;
let layer = ConfigLayerEntry::new(
ConfigLayerSource::Project { dot_codex_folder },
TomlValue::Table(Default::default()),
);
let config_stack =
ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?;

let policy = load_exec_policy(&config_stack).await?;

assert_eq!(
policy
.host_executables()
.get("git")
.expect("missing git host executable")
.as_ref(),
[AbsolutePathBuf::try_from(git_path)?]
);
Ok(())
}

#[tokio::test]
async fn ignores_policies_outside_policy_dir() {
let temp_dir = tempdir().expect("create temp dir");
Expand Down Expand Up @@ -1106,6 +1167,7 @@ mod tests {
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
},
Expand All @@ -1117,6 +1179,7 @@ mod tests {
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["ls".to_string()],
decision: Decision::Prompt,
resolved_program: None,
justification: None,
}],
},
Expand Down Expand Up @@ -1983,6 +2046,7 @@ prefix_rule(
let matched_rules_prompt = vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["cargo".to_string()],
decision: Decision::Prompt,
resolved_program: None,
justification: None,
}];
assert_eq!(
Expand All @@ -1996,6 +2060,7 @@ prefix_rule(
let matched_rules_allow = vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["cargo".to_string()],
decision: Decision::Allow,
resolved_program: None,
justification: None,
}];
assert_eq!(
Expand All @@ -2009,6 +2074,7 @@ prefix_rule(
let matched_rules_forbidden = vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["cargo".to_string()],
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}];
assert_eq!(
Expand Down
1 change: 1 addition & 0 deletions codex-rs/execpolicy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-utils-absolute-path = { workspace = true }
multimap = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
Expand Down
36 changes: 34 additions & 2 deletions codex-rs/execpolicy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

## Overview

- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, justification?, match?, not_match?)`.
- This release covers the prefix-rule subset of the execpolicy language; a richer language will follow.
- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, justification?, match?, not_match?)` plus `host_executable(name=..., paths=[...])`.
- This release covers the prefix-rule subset of the execpolicy language plus host executable metadata; 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`.
- `justification` is an optional human-readable rationale for why a rule exists. It can be provided for any `decision` and may be surfaced in different contexts (for example, in approval prompts or rejection messages). When `decision = "forbidden"` is used, include a recommended alternative in the `justification`, when appropriate (e.g., ``"Use `jj` instead of `git`."``).
- `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`).
Expand All @@ -24,6 +24,27 @@ prefix_rule(
)
```

- Host executable metadata can optionally constrain which absolute paths may
resolve through basename rules:

```starlark
host_executable(
name = "git",
paths = [
"/Users/example/.openai/bin/git",
"/opt/homebrew/bin/git",
"/usr/bin/git",
],
)
```

- Matching semantics:
- 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.

## CLI

- From the Codex CLI, run `codex execpolicy check` subcommand with one or more policy files (for example `src/default.rules`) to check a command:
Expand All @@ -32,6 +53,15 @@ prefix_rule(
codex execpolicy check --rules path/to/policy.rules git status
```

- To opt into basename fallback for absolute program paths, pass `--resolve-host-executables`:

```bash
codex execpolicy check \
--rules path/to/policy.rules \
--resolve-host-executables \
/usr/bin/git status
```

- Pass multiple `--rules` flags to merge rules, evaluated in the order provided, and use `--pretty` for formatted JSON.
- You can also run the standalone dev binary directly during development:

Expand All @@ -52,6 +82,7 @@ cargo run -p codex-execpolicy -- check --rules path/to/policy.rules git status
"prefixRuleMatch": {
"matchedPrefix": ["<token>", "..."],
"decision": "allow|prompt|forbidden",
"resolvedProgram": "/absolute/path/to/program",
"justification": "..."
}
}
Expand All @@ -62,6 +93,7 @@ cargo run -p codex-execpolicy -- check --rules path/to/policy.rules git status

- 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.
- `resolvedProgram` is omitted unless an absolute executable path matched via basename fallback.
- 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.
33 changes: 32 additions & 1 deletion codex-rs/execpolicy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,47 @@ pub enum Error {
ExampleDidNotMatch {
rules: Vec<String>,
examples: Vec<String>,
location: Option<ErrorLocation>,
},
#[error("expected example to not match rule `{rule}`: {example}")]
ExampleDidMatch { rule: String, example: String },
ExampleDidMatch {
rule: String,
example: String,
location: Option<ErrorLocation>,
},
#[error("starlark error: {0}")]
Starlark(StarlarkError),
}

impl Error {
pub fn with_location(self, location: ErrorLocation) -> Self {
match self {
Error::ExampleDidNotMatch {
rules,
examples,
location: None,
} => Error::ExampleDidNotMatch {
rules,
examples,
location: Some(location),
},
Error::ExampleDidMatch {
rule,
example,
location: None,
} => Error::ExampleDidMatch {
rule,
example,
location: Some(location),
},
other => other,
}
}

pub fn location(&self) -> Option<ErrorLocation> {
match self {
Error::ExampleDidNotMatch { location, .. }
| Error::ExampleDidMatch { location, .. } => location.clone(),
Error::Starlark(err) => err.span().map(|span| {
let resolved = span.resolve_span();
ErrorLocation {
Expand Down
14 changes: 13 additions & 1 deletion codex-rs/execpolicy/src/execpolicycheck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use clap::Parser;
use serde::Serialize;

use crate::Decision;
use crate::MatchOptions;
use crate::Policy;
use crate::PolicyParser;
use crate::RuleMatch;
Expand All @@ -22,6 +23,11 @@ pub struct ExecPolicyCheckCommand {
#[arg(long)]
pub pretty: bool,

/// Resolve absolute program paths against basename rules, gated by any
/// `host_executable()` definitions in the loaded policy files.
#[arg(long)]
pub resolve_host_executables: bool,

/// Command tokens to check against the policy.
#[arg(
value_name = "COMMAND",
Expand All @@ -36,7 +42,13 @@ 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.rules)?;
let matched_rules = policy.matches_for_command(&self.command, None);
let matched_rules = policy.matches_for_command_with_options(
&self.command,
None,
&MatchOptions {
resolve_host_executables: self.resolve_host_executables,
},
);

let json = format_matches_json(&matched_rules, self.pretty)?;
println!("{json}");
Expand Down
Loading
Loading