Skip to content

Commit a9933c7

Browse files
committed
execpolicy: add host_executable() support
1 parent bc0a584 commit a9933c7

File tree

12 files changed

+692
-26
lines changed

12 files changed

+692
-26
lines changed

codex-rs/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/config/src/config_requirements.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,6 +1147,7 @@ mod tests {
11471147
matched_rules: vec![RuleMatch::PrefixRuleMatch {
11481148
matched_prefix: tokens(&["rm"]),
11491149
decision: Decision::Forbidden,
1150+
resolved_program: None,
11501151
justification: None,
11511152
}],
11521153
}

codex-rs/core/src/config_loader/tests.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,6 +1390,7 @@ prefix_rules = [
13901390
matched_rules: vec![RuleMatch::PrefixRuleMatch {
13911391
matched_prefix: tokens(&["rm"]),
13921392
decision: Decision::Forbidden,
1393+
resolved_program: None,
13931394
justification: None,
13941395
}],
13951396
}
@@ -1415,6 +1416,7 @@ prefix_rules = [
14151416
matched_rules: vec![RuleMatch::PrefixRuleMatch {
14161417
matched_prefix: tokens(&["git", "status"]),
14171418
decision: Decision::Prompt,
1419+
resolved_program: None,
14181420
justification: None,
14191421
}],
14201422
}
@@ -1426,6 +1428,7 @@ prefix_rules = [
14261428
matched_rules: vec![RuleMatch::PrefixRuleMatch {
14271429
matched_prefix: tokens(&["hg", "status"]),
14281430
decision: Decision::Prompt,
1431+
resolved_program: None,
14291432
justification: None,
14301433
}],
14311434
}
@@ -1509,6 +1512,7 @@ prefix_rules = []
15091512
matched_rules: vec![RuleMatch::PrefixRuleMatch {
15101513
matched_prefix: vec!["rm".to_string()],
15111514
decision: Decision::Forbidden,
1515+
resolved_program: None,
15121516
justification: None,
15131517
}],
15141518
}
@@ -1547,6 +1551,7 @@ prefix_rules = []
15471551
matched_rules: vec![RuleMatch::PrefixRuleMatch {
15481552
matched_prefix: vec!["rm".to_string()],
15491553
decision: Decision::Forbidden,
1554+
resolved_program: None,
15501555
justification: None,
15511556
}],
15521557
}
@@ -1561,6 +1566,7 @@ prefix_rules = []
15611566
matched_rules: vec![RuleMatch::PrefixRuleMatch {
15621567
matched_prefix: vec!["git".to_string(), "push".to_string()],
15631568
decision: Decision::Prompt,
1569+
resolved_program: None,
15641570
justification: None,
15651571
}],
15661572
}

codex-rs/core/src/exec_policy.rs

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -472,17 +472,7 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
472472
return Ok(policy);
473473
};
474474

475-
let mut combined_rules = policy.rules().clone();
476-
for (program, rules) in requirements_policy.as_ref().rules().iter_all() {
477-
for rule in rules {
478-
combined_rules.insert(program.clone(), rule.clone());
479-
}
480-
}
481-
482-
let mut combined_network_rules = policy.network_rules().to_vec();
483-
combined_network_rules.extend(requirements_policy.as_ref().network_rules().iter().cloned());
484-
485-
Ok(Policy::from_parts(combined_rules, combined_network_rules))
475+
Ok(policy.merge_overlay(requirements_policy.as_ref()))
486476
}
487477

488478
/// If a command is not matched by any execpolicy rule, derive a [`Decision`].
@@ -827,6 +817,7 @@ mod tests {
827817
use pretty_assertions::assert_eq;
828818
use std::fs;
829819
use std::path::Path;
820+
use std::path::PathBuf;
830821
use std::sync::Arc;
831822
use tempfile::tempdir;
832823
use toml::Value as TomlValue;
@@ -846,6 +837,22 @@ mod tests {
846837
.expect("ConfigLayerStack")
847838
}
848839

840+
fn host_absolute_path(segments: &[&str]) -> String {
841+
let mut path = if cfg!(windows) {
842+
PathBuf::from(r"C:\")
843+
} else {
844+
PathBuf::from("/")
845+
};
846+
for segment in segments {
847+
path.push(segment);
848+
}
849+
path.to_string_lossy().into_owned()
850+
}
851+
852+
fn starlark_string(value: &str) -> String {
853+
value.replace('\\', "\\\\").replace('"', "\\\"")
854+
}
855+
849856
#[tokio::test]
850857
async fn returns_empty_policy_when_no_policy_files_exist() {
851858
let temp_dir = tempdir().expect("create temp dir");
@@ -949,6 +956,7 @@ mod tests {
949956
matched_rules: vec![RuleMatch::PrefixRuleMatch {
950957
matched_prefix: vec!["rm".to_string()],
951958
decision: Decision::Forbidden,
959+
resolved_program: None,
952960
justification: None,
953961
}],
954962
},
@@ -991,6 +999,59 @@ mod tests {
991999
Ok(())
9921000
}
9931001

1002+
#[tokio::test]
1003+
async fn preserves_host_executables_when_requirements_overlay_is_present() -> anyhow::Result<()>
1004+
{
1005+
let temp_dir = tempdir()?;
1006+
let policy_dir = temp_dir.path().join(RULES_DIR_NAME);
1007+
fs::create_dir_all(&policy_dir)?;
1008+
let git_path = host_absolute_path(&["usr", "bin", "git"]);
1009+
let git_path_literal = starlark_string(&git_path);
1010+
fs::write(
1011+
policy_dir.join("host.rules"),
1012+
format!(
1013+
r#"
1014+
host_executable(name = "git", paths = ["{git_path_literal}"])
1015+
"#
1016+
),
1017+
)?;
1018+
1019+
let mut requirements_exec_policy = Policy::empty();
1020+
requirements_exec_policy.add_network_rule(
1021+
"blocked.example.com",
1022+
codex_execpolicy::NetworkRuleProtocol::Https,
1023+
Decision::Forbidden,
1024+
None,
1025+
)?;
1026+
1027+
let requirements = ConfigRequirements {
1028+
exec_policy: Some(codex_config::Sourced::new(
1029+
codex_config::RequirementsExecPolicy::new(requirements_exec_policy),
1030+
codex_config::RequirementSource::Unknown,
1031+
)),
1032+
..ConfigRequirements::default()
1033+
};
1034+
let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?;
1035+
let layer = ConfigLayerEntry::new(
1036+
ConfigLayerSource::Project { dot_codex_folder },
1037+
TomlValue::Table(Default::default()),
1038+
);
1039+
let config_stack =
1040+
ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?;
1041+
1042+
let policy = load_exec_policy(&config_stack).await?;
1043+
1044+
assert_eq!(
1045+
policy
1046+
.host_executables()
1047+
.get("git")
1048+
.expect("missing git host executable")
1049+
.as_ref(),
1050+
[AbsolutePathBuf::try_from(git_path)?]
1051+
);
1052+
Ok(())
1053+
}
1054+
9941055
#[tokio::test]
9951056
async fn ignores_policies_outside_policy_dir() {
9961057
let temp_dir = tempdir().expect("create temp dir");
@@ -1106,6 +1167,7 @@ mod tests {
11061167
matched_rules: vec![RuleMatch::PrefixRuleMatch {
11071168
matched_prefix: vec!["rm".to_string()],
11081169
decision: Decision::Forbidden,
1170+
resolved_program: None,
11091171
justification: None,
11101172
}],
11111173
},
@@ -1117,6 +1179,7 @@ mod tests {
11171179
matched_rules: vec![RuleMatch::PrefixRuleMatch {
11181180
matched_prefix: vec!["ls".to_string()],
11191181
decision: Decision::Prompt,
1182+
resolved_program: None,
11201183
justification: None,
11211184
}],
11221185
},
@@ -1983,6 +2046,7 @@ prefix_rule(
19832046
let matched_rules_prompt = vec![RuleMatch::PrefixRuleMatch {
19842047
matched_prefix: vec!["cargo".to_string()],
19852048
decision: Decision::Prompt,
2049+
resolved_program: None,
19862050
justification: None,
19872051
}];
19882052
assert_eq!(
@@ -1996,6 +2060,7 @@ prefix_rule(
19962060
let matched_rules_allow = vec![RuleMatch::PrefixRuleMatch {
19972061
matched_prefix: vec!["cargo".to_string()],
19982062
decision: Decision::Allow,
2063+
resolved_program: None,
19992064
justification: None,
20002065
}];
20012066
assert_eq!(
@@ -2009,6 +2074,7 @@ prefix_rule(
20092074
let matched_rules_forbidden = vec![RuleMatch::PrefixRuleMatch {
20102075
matched_prefix: vec!["cargo".to_string()],
20112076
decision: Decision::Forbidden,
2077+
resolved_program: None,
20122078
justification: None,
20132079
}];
20142080
assert_eq!(

codex-rs/execpolicy/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ workspace = true
1919
[dependencies]
2020
anyhow = { workspace = true }
2121
clap = { workspace = true, features = ["derive"] }
22+
codex-utils-absolute-path = { workspace = true }
2223
multimap = { workspace = true }
2324
serde = { workspace = true, features = ["derive"] }
2425
serde_json = { workspace = true }

codex-rs/execpolicy/README.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
## Overview
44

5-
- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, justification?, match?, not_match?)`.
6-
- This release covers the prefix-rule subset of the execpolicy language; a richer language will follow.
5+
- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, justification?, match?, not_match?)` plus `host_executable(name=..., paths=[...])`.
6+
- This release covers the prefix-rule subset of the execpolicy language plus host executable metadata; a richer language will follow.
77
- Tokens are matched in order; any `pattern` element may be a list to denote alternatives. `decision` defaults to `allow`; valid values: `allow`, `prompt`, `forbidden`.
88
- `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`."``).
99
- `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`).
@@ -24,6 +24,27 @@ prefix_rule(
2424
)
2525
```
2626

27+
- Host executable metadata can optionally constrain which absolute paths may
28+
resolve through basename rules:
29+
30+
```starlark
31+
host_executable(
32+
name = "git",
33+
paths = [
34+
"/Users/example/.openai/bin/git",
35+
"/opt/homebrew/bin/git",
36+
"/usr/bin/git",
37+
],
38+
)
39+
```
40+
41+
- Matching semantics:
42+
- execpolicy always tries exact first-token matches first.
43+
- With host-executable resolution disabled, `/usr/bin/git status` only matches a rule whose first token is `/usr/bin/git`.
44+
- With host-executable resolution enabled, if no exact rule matches, execpolicy may fall back from `/usr/bin/git` to basename rules for `git`.
45+
- If `host_executable(name="git", ...)` exists, basename fallback is only allowed for listed absolute paths.
46+
- If no `host_executable()` entry exists for a basename, basename fallback is allowed.
47+
2748
## CLI
2849

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

56+
- To opt into basename fallback for absolute program paths, pass `--resolve-host-executables`:
57+
58+
```bash
59+
codex execpolicy check \
60+
--rules path/to/policy.rules \
61+
--resolve-host-executables \
62+
/usr/bin/git status
63+
```
64+
3565
- Pass multiple `--rules` flags to merge rules, evaluated in the order provided, and use `--pretty` for formatted JSON.
3666
- You can also run the standalone dev binary directly during development:
3767

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

6394
- When no rules match, `matchedRules` is an empty array and `decision` is omitted.
6495
- `matchedRules` lists every rule whose prefix matched the command; `matchedPrefix` is the exact prefix that matched.
96+
- `resolvedProgram` is omitted unless an absolute executable path matched via basename fallback.
6597
- The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`).
6698

6799
Note: `execpolicy` commands are still in preview. The API may have breaking changes in the future.

codex-rs/execpolicy/src/execpolicycheck.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use clap::Parser;
77
use serde::Serialize;
88

99
use crate::Decision;
10+
use crate::MatchOptions;
1011
use crate::Policy;
1112
use crate::PolicyParser;
1213
use crate::RuleMatch;
@@ -22,6 +23,11 @@ pub struct ExecPolicyCheckCommand {
2223
#[arg(long)]
2324
pub pretty: bool,
2425

26+
/// Resolve absolute program paths against basename rules, gated by any
27+
/// `host_executable()` definitions in the loaded policy files.
28+
#[arg(long)]
29+
pub resolve_host_executables: bool,
30+
2531
/// Command tokens to check against the policy.
2632
#[arg(
2733
value_name = "COMMAND",
@@ -36,7 +42,13 @@ impl ExecPolicyCheckCommand {
3642
/// Load the policies for this command, evaluate the command, and render JSON output.
3743
pub fn run(&self) -> Result<()> {
3844
let policy = load_policies(&self.rules)?;
39-
let matched_rules = policy.matches_for_command(&self.command, None);
45+
let matched_rules = policy.matches_for_command_with_options(
46+
&self.command,
47+
None,
48+
&MatchOptions {
49+
resolve_host_executables: self.resolve_host_executables,
50+
},
51+
);
4052

4153
let json = format_matches_json(&matched_rules, self.pretty)?;
4254
println!("{json}");

codex-rs/execpolicy/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub use error::TextRange;
1818
pub use execpolicycheck::ExecPolicyCheckCommand;
1919
pub use parser::PolicyParser;
2020
pub use policy::Evaluation;
21+
pub use policy::MatchOptions;
2122
pub use policy::Policy;
2223
pub use rule::NetworkRuleProtocol;
2324
pub use rule::Rule;

0 commit comments

Comments
 (0)