mirror of
https://github.com/openai/codex.git
synced 2026-02-27 19:23:48 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eedb6e3f28 |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -1900,6 +1900,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"codex-utils-absolute-path",
|
||||
"multimap",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
|
||||
@@ -1147,6 +1147,7 @@ mod tests {
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["rm"]),
|
||||
decision: Decision::Forbidden,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -1390,6 +1390,7 @@ prefix_rules = [
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["rm"]),
|
||||
decision: Decision::Forbidden,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
}
|
||||
@@ -1415,6 +1416,7 @@ prefix_rules = [
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "status"]),
|
||||
decision: Decision::Prompt,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
}
|
||||
@@ -1426,6 +1428,7 @@ prefix_rules = [
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["hg", "status"]),
|
||||
decision: Decision::Prompt,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
}
|
||||
@@ -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,
|
||||
}],
|
||||
}
|
||||
@@ -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,
|
||||
}],
|
||||
}
|
||||
@@ -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,
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -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`].
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -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,
|
||||
}],
|
||||
},
|
||||
@@ -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");
|
||||
@@ -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,
|
||||
}],
|
||||
},
|
||||
@@ -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,
|
||||
}],
|
||||
},
|
||||
@@ -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!(
|
||||
@@ -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!(
|
||||
@@ -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!(
|
||||
|
||||
@@ -16,6 +16,8 @@ use crate::tools::sandboxing::SandboxablePreference;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::MatchOptions;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
@@ -493,28 +495,16 @@ impl EscalationPolicy for CoreShellActionProvider {
|
||||
.await;
|
||||
}
|
||||
|
||||
let command = join_program_and_argv(program, argv);
|
||||
let (commands, used_complex_parsing) =
|
||||
if let Some(commands) = parse_shell_lc_plain_commands(&command) {
|
||||
(commands, false)
|
||||
} else if let Some(single_command) = parse_shell_lc_single_command_prefix(&command) {
|
||||
(vec![single_command], true)
|
||||
} else {
|
||||
(vec![command.clone()], false)
|
||||
};
|
||||
|
||||
let fallback = |cmd: &[String]| {
|
||||
crate::exec_policy::render_decision_for_unmatched_command(
|
||||
self.approval_policy,
|
||||
&self.sandbox_policy,
|
||||
cmd,
|
||||
self.sandbox_permissions,
|
||||
used_complex_parsing,
|
||||
)
|
||||
};
|
||||
let evaluation = {
|
||||
let policy = self.policy.read().await;
|
||||
policy.check_multiple(commands.iter(), &fallback)
|
||||
evaluate_intercepted_exec_policy(
|
||||
&policy,
|
||||
program,
|
||||
argv,
|
||||
self.approval_policy,
|
||||
&self.sandbox_policy,
|
||||
self.sandbox_permissions,
|
||||
)
|
||||
};
|
||||
// When true, means the Evaluation was due to *.rules, not the
|
||||
// fallback function.
|
||||
@@ -552,6 +542,43 @@ impl EscalationPolicy for CoreShellActionProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_intercepted_exec_policy(
|
||||
policy: &Policy,
|
||||
program: &AbsolutePathBuf,
|
||||
argv: &[String],
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> Evaluation {
|
||||
let command = join_program_and_argv(program, argv);
|
||||
let (commands, used_complex_parsing) =
|
||||
if let Some(commands) = parse_shell_lc_plain_commands(&command) {
|
||||
(commands, false)
|
||||
} else if let Some(single_command) = parse_shell_lc_single_command_prefix(&command) {
|
||||
(vec![single_command], true)
|
||||
} else {
|
||||
(vec![command], false)
|
||||
};
|
||||
|
||||
let fallback = |cmd: &[String]| {
|
||||
crate::exec_policy::render_decision_for_unmatched_command(
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
cmd,
|
||||
sandbox_permissions,
|
||||
used_complex_parsing,
|
||||
)
|
||||
};
|
||||
|
||||
policy.check_multiple_with_options(
|
||||
commands.iter(),
|
||||
&fallback,
|
||||
&MatchOptions {
|
||||
resolve_host_executables: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
struct CoreShellCommandExecutor {
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::CoreShellActionProvider;
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::CoreShellCommandExecutor;
|
||||
use super::ParsedShellCommand;
|
||||
use super::evaluate_intercepted_exec_policy;
|
||||
use super::extract_shell_script;
|
||||
use super::join_program_and_argv;
|
||||
use super::map_exec_result;
|
||||
@@ -12,14 +13,16 @@ use crate::config::Permissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::exec::SandboxType;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::PolicyParser;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
@@ -36,8 +39,25 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
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('"', "\\\"")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_shell_script_preserves_login_flag() {
|
||||
assert_eq!(
|
||||
@@ -203,6 +223,84 @@ fn shell_request_escalation_execution_is_explicit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intercepted_exec_policy_uses_host_executable_mappings() {
|
||||
let git_path = host_absolute_path(&["usr", "bin", "git"]);
|
||||
let git_path_literal = starlark_string(&git_path);
|
||||
let policy_src = format!(
|
||||
r#"
|
||||
prefix_rule(pattern = ["git", "status"], decision = "prompt")
|
||||
host_executable(name = "git", paths = ["{git_path_literal}"])
|
||||
"#
|
||||
);
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse("test.rules", &policy_src).unwrap();
|
||||
let policy = parser.build();
|
||||
let program = AbsolutePathBuf::try_from(git_path).unwrap();
|
||||
|
||||
let evaluation = evaluate_intercepted_exec_policy(
|
||||
&policy,
|
||||
&program,
|
||||
&["git".to_string(), "status".to_string()],
|
||||
AskForApproval::OnRequest,
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
SandboxPermissions::UseDefault,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
evaluation,
|
||||
Evaluation {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: vec!["git".to_string(), "status".to_string()],
|
||||
decision: Decision::Prompt,
|
||||
resolved_program: Some(program),
|
||||
justification: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
assert!(CoreShellActionProvider::decision_driven_by_policy(
|
||||
&evaluation.matched_rules,
|
||||
evaluation.decision
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intercepted_exec_policy_rejects_disallowed_host_executable_mapping() {
|
||||
let allowed_git = host_absolute_path(&["usr", "bin", "git"]);
|
||||
let other_git = host_absolute_path(&["opt", "homebrew", "bin", "git"]);
|
||||
let allowed_git_literal = starlark_string(&allowed_git);
|
||||
let policy_src = format!(
|
||||
r#"
|
||||
prefix_rule(pattern = ["git", "status"], decision = "prompt")
|
||||
host_executable(name = "git", paths = ["{allowed_git_literal}"])
|
||||
"#
|
||||
);
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse("test.rules", &policy_src).unwrap();
|
||||
let policy = parser.build();
|
||||
let program = AbsolutePathBuf::try_from(other_git.clone()).unwrap();
|
||||
|
||||
let evaluation = evaluate_intercepted_exec_policy(
|
||||
&policy,
|
||||
&program,
|
||||
&["git".to_string(), "status".to_string()],
|
||||
AskForApproval::OnRequest,
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
SandboxPermissions::UseDefault,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
evaluation.matched_rules.as_slice(),
|
||||
[RuleMatch::HeuristicsRuleMatch { command, .. }]
|
||||
if command == &vec![other_git, "status".to_string()]
|
||||
));
|
||||
assert!(!CoreShellActionProvider::decision_driven_by_policy(
|
||||
&evaluation.matched_rules,
|
||||
evaluation.decision
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions() {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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`).
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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": "..."
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
@@ -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}");
|
||||
|
||||
@@ -18,6 +18,7 @@ pub use error::TextRange;
|
||||
pub use execpolicycheck::ExecPolicyCheckCommand;
|
||||
pub use parser::PolicyParser;
|
||||
pub use policy::Evaluation;
|
||||
pub use policy::MatchOptions;
|
||||
pub use policy::Policy;
|
||||
pub use rule::NetworkRuleProtocol;
|
||||
pub use rule::Rule;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use multimap::MultiMap;
|
||||
use shlex;
|
||||
use starlark::any::ProvidesStaticType;
|
||||
@@ -13,6 +14,8 @@ use starlark::values::list::UnpackList;
|
||||
use starlark::values::none::NoneType;
|
||||
use std::cell::RefCell;
|
||||
use std::cell::RefMut;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::decision::Decision;
|
||||
@@ -74,6 +77,7 @@ impl PolicyParser {
|
||||
struct PolicyBuilder {
|
||||
rules_by_program: MultiMap<String, RuleRef>,
|
||||
network_rules: Vec<NetworkRule>,
|
||||
host_executables_by_name: HashMap<String, Arc<[AbsolutePathBuf]>>,
|
||||
}
|
||||
|
||||
impl PolicyBuilder {
|
||||
@@ -81,6 +85,7 @@ impl PolicyBuilder {
|
||||
Self {
|
||||
rules_by_program: MultiMap::new(),
|
||||
network_rules: Vec::new(),
|
||||
host_executables_by_name: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,8 +98,16 @@ impl PolicyBuilder {
|
||||
self.network_rules.push(rule);
|
||||
}
|
||||
|
||||
fn add_host_executable(&mut self, name: String, paths: Vec<AbsolutePathBuf>) {
|
||||
self.host_executables_by_name.insert(name, paths.into());
|
||||
}
|
||||
|
||||
fn build(self) -> crate::policy::Policy {
|
||||
crate::policy::Policy::from_parts(self.rules_by_program, self.network_rules)
|
||||
crate::policy::Policy::from_parts(
|
||||
self.rules_by_program,
|
||||
self.network_rules,
|
||||
self.host_executables_by_name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +163,36 @@ fn parse_examples<'v>(examples: UnpackList<Value<'v>>) -> Result<Vec<Vec<String>
|
||||
examples.items.into_iter().map(parse_example).collect()
|
||||
}
|
||||
|
||||
fn parse_literal_absolute_path(raw: &str) -> Result<AbsolutePathBuf> {
|
||||
if !Path::new(raw).is_absolute() {
|
||||
return Err(Error::InvalidRule(format!(
|
||||
"host_executable paths must be absolute (got {raw})"
|
||||
)));
|
||||
}
|
||||
|
||||
AbsolutePathBuf::try_from(raw.to_string())
|
||||
.map_err(|error| Error::InvalidRule(format!("invalid absolute path `{raw}`: {error}")))
|
||||
}
|
||||
|
||||
fn validate_host_executable_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() {
|
||||
return Err(Error::InvalidRule(
|
||||
"host_executable name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let path = Path::new(name);
|
||||
if path.components().count() != 1
|
||||
|| path.file_name().and_then(|value| value.to_str()) != Some(name)
|
||||
{
|
||||
return Err(Error::InvalidRule(format!(
|
||||
"host_executable name must be a bare executable name (got {name})"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_network_rule_decision(raw: &str) -> Result<Decision> {
|
||||
match raw {
|
||||
"deny" => Ok(Decision::Forbidden),
|
||||
@@ -308,4 +351,35 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
|
||||
});
|
||||
Ok(NoneType)
|
||||
}
|
||||
|
||||
fn host_executable<'v>(
|
||||
name: &'v str,
|
||||
paths: UnpackList<Value<'v>>,
|
||||
eval: &mut Evaluator<'v, '_, '_>,
|
||||
) -> anyhow::Result<NoneType> {
|
||||
validate_host_executable_name(name)?;
|
||||
|
||||
let mut parsed_paths = Vec::new();
|
||||
for value in paths.items {
|
||||
let raw = value.unpack_str().ok_or_else(|| {
|
||||
Error::InvalidRule(format!(
|
||||
"host_executable paths must be strings (got {})",
|
||||
value.get_type()
|
||||
))
|
||||
})?;
|
||||
let path = parse_literal_absolute_path(raw)?;
|
||||
if path.as_path().file_name().and_then(|value| value.to_str()) != Some(name) {
|
||||
return Err(Error::InvalidRule(format!(
|
||||
"host_executable path `{raw}` must have basename `{name}`"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
if !parsed_paths.iter().any(|existing| existing == &path) {
|
||||
parsed_paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
policy_builder(eval).add_host_executable(name.to_string(), parsed_paths);
|
||||
Ok(NoneType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,31 +9,42 @@ use crate::rule::PrefixRule;
|
||||
use crate::rule::RuleMatch;
|
||||
use crate::rule::RuleRef;
|
||||
use crate::rule::normalize_network_rule_host;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use multimap::MultiMap;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
type HeuristicsFallback<'a> = Option<&'a dyn Fn(&[String]) -> Decision>;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct MatchOptions {
|
||||
pub resolve_host_executables: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Policy {
|
||||
rules_by_program: MultiMap<String, RuleRef>,
|
||||
network_rules: Vec<NetworkRule>,
|
||||
host_executables_by_name: HashMap<String, Arc<[AbsolutePathBuf]>>,
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
pub fn new(rules_by_program: MultiMap<String, RuleRef>) -> Self {
|
||||
Self::from_parts(rules_by_program, Vec::new())
|
||||
Self::from_parts(rules_by_program, Vec::new(), HashMap::new())
|
||||
}
|
||||
|
||||
pub fn from_parts(
|
||||
rules_by_program: MultiMap<String, RuleRef>,
|
||||
network_rules: Vec<NetworkRule>,
|
||||
host_executables_by_name: HashMap<String, Arc<[AbsolutePathBuf]>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
rules_by_program,
|
||||
network_rules,
|
||||
host_executables_by_name,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +60,10 @@ impl Policy {
|
||||
&self.network_rules
|
||||
}
|
||||
|
||||
pub fn host_executables(&self) -> &HashMap<String, Arc<[AbsolutePathBuf]>> {
|
||||
&self.host_executables_by_name
|
||||
}
|
||||
|
||||
pub fn get_allowed_prefixes(&self) -> Vec<Vec<String>> {
|
||||
let mut prefixes = Vec::new();
|
||||
|
||||
@@ -119,6 +134,36 @@ impl Policy {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_host_executable_paths(&mut self, name: String, paths: Vec<AbsolutePathBuf>) {
|
||||
self.host_executables_by_name.insert(name, paths.into());
|
||||
}
|
||||
|
||||
pub fn merge_overlay(&self, overlay: &Policy) -> Policy {
|
||||
let mut combined_rules = self.rules_by_program.clone();
|
||||
for (program, rules) in overlay.rules_by_program.iter_all() {
|
||||
for rule in rules {
|
||||
combined_rules.insert(program.clone(), rule.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut combined_network_rules = self.network_rules.clone();
|
||||
combined_network_rules.extend(overlay.network_rules.iter().cloned());
|
||||
|
||||
let mut host_executables_by_name = self.host_executables_by_name.clone();
|
||||
host_executables_by_name.extend(
|
||||
overlay
|
||||
.host_executables_by_name
|
||||
.iter()
|
||||
.map(|(name, paths)| (name.clone(), paths.clone())),
|
||||
);
|
||||
|
||||
Policy::from_parts(
|
||||
combined_rules,
|
||||
combined_network_rules,
|
||||
host_executables_by_name,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compiled_network_domains(&self) -> (Vec<String>, Vec<String>) {
|
||||
let mut allowed = Vec::new();
|
||||
let mut denied = Vec::new();
|
||||
@@ -144,7 +189,25 @@ impl Policy {
|
||||
where
|
||||
F: Fn(&[String]) -> Decision,
|
||||
{
|
||||
let matched_rules = self.matches_for_command(cmd, Some(heuristics_fallback));
|
||||
let matched_rules = self.matches_for_command_with_options(
|
||||
cmd,
|
||||
Some(heuristics_fallback),
|
||||
&MatchOptions::default(),
|
||||
);
|
||||
Evaluation::from_matches(matched_rules)
|
||||
}
|
||||
|
||||
pub fn check_with_options<F>(
|
||||
&self,
|
||||
cmd: &[String],
|
||||
heuristics_fallback: &F,
|
||||
options: &MatchOptions,
|
||||
) -> Evaluation
|
||||
where
|
||||
F: Fn(&[String]) -> Decision,
|
||||
{
|
||||
let matched_rules =
|
||||
self.matches_for_command_with_options(cmd, Some(heuristics_fallback), options);
|
||||
Evaluation::from_matches(matched_rules)
|
||||
}
|
||||
|
||||
@@ -154,6 +217,20 @@ impl Policy {
|
||||
commands: Commands,
|
||||
heuristics_fallback: &F,
|
||||
) -> Evaluation
|
||||
where
|
||||
Commands: IntoIterator,
|
||||
Commands::Item: AsRef<[String]>,
|
||||
F: Fn(&[String]) -> Decision,
|
||||
{
|
||||
self.check_multiple_with_options(commands, heuristics_fallback, &MatchOptions::default())
|
||||
}
|
||||
|
||||
pub fn check_multiple_with_options<Commands, F>(
|
||||
&self,
|
||||
commands: Commands,
|
||||
heuristics_fallback: &F,
|
||||
options: &MatchOptions,
|
||||
) -> Evaluation
|
||||
where
|
||||
Commands: IntoIterator,
|
||||
Commands::Item: AsRef<[String]>,
|
||||
@@ -162,7 +239,11 @@ impl Policy {
|
||||
let matched_rules: Vec<RuleMatch> = commands
|
||||
.into_iter()
|
||||
.flat_map(|command| {
|
||||
self.matches_for_command(command.as_ref(), Some(heuristics_fallback))
|
||||
self.matches_for_command_with_options(
|
||||
command.as_ref(),
|
||||
Some(heuristics_fallback),
|
||||
options,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -181,14 +262,25 @@ impl Policy {
|
||||
cmd: &[String],
|
||||
heuristics_fallback: HeuristicsFallback<'_>,
|
||||
) -> Vec<RuleMatch> {
|
||||
let 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(),
|
||||
};
|
||||
self.matches_for_command_with_options(cmd, heuristics_fallback, &MatchOptions::default())
|
||||
}
|
||||
|
||||
pub fn matches_for_command_with_options(
|
||||
&self,
|
||||
cmd: &[String],
|
||||
heuristics_fallback: HeuristicsFallback<'_>,
|
||||
options: &MatchOptions,
|
||||
) -> Vec<RuleMatch> {
|
||||
let matched_rules = self
|
||||
.match_exact_rules(cmd)
|
||||
.filter(|matched_rules| !matched_rules.is_empty())
|
||||
.or_else(|| {
|
||||
options
|
||||
.resolve_host_executables
|
||||
.then(|| self.match_host_executable_rules(cmd))
|
||||
.filter(|matched_rules| !matched_rules.is_empty())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if matched_rules.is_empty()
|
||||
&& let Some(heuristics_fallback) = heuristics_fallback
|
||||
@@ -201,6 +293,49 @@ impl Policy {
|
||||
matched_rules
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
fn match_host_executable_rules(&self, cmd: &[String]) -> Vec<RuleMatch> {
|
||||
let Some(first) = cmd.first() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !Path::new(first).is_absolute() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let Ok(program) = AbsolutePathBuf::try_from(first.clone()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(basename) = program.as_path().file_name().and_then(|name| name.to_str()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(rules) = self.rules_by_program.get_vec(basename) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if let Some(paths) = self.host_executables_by_name.get(basename)
|
||||
&& !paths.iter().any(|path| path == &program)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let basename_command = std::iter::once(basename.to_string())
|
||||
.chain(cmd.iter().skip(1).cloned())
|
||||
.collect::<Vec<_>>();
|
||||
rules
|
||||
.iter()
|
||||
.filter_map(|rule| rule.matches(&basename_command))
|
||||
.map(|rule_match| rule_match.with_resolved_program(&program))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_domain(entries: &mut Vec<String>, host: &str) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::decision::Decision;
|
||||
use crate::error::Error;
|
||||
use crate::error::Result;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use shlex::try_join;
|
||||
@@ -63,6 +64,8 @@ pub enum RuleMatch {
|
||||
#[serde(rename = "matchedPrefix")]
|
||||
matched_prefix: Vec<String>,
|
||||
decision: Decision,
|
||||
#[serde(rename = "resolvedProgram", skip_serializing_if = "Option::is_none")]
|
||||
resolved_program: Option<AbsolutePathBuf>,
|
||||
/// Optional rationale for why this rule exists.
|
||||
///
|
||||
/// This can be supplied for any decision and may be surfaced in different contexts
|
||||
@@ -83,6 +86,23 @@ impl RuleMatch {
|
||||
Self::HeuristicsRuleMatch { decision, .. } => *decision,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_resolved_program(self, resolved_program: &AbsolutePathBuf) -> Self {
|
||||
match self {
|
||||
Self::PrefixRuleMatch {
|
||||
matched_prefix,
|
||||
decision,
|
||||
justification,
|
||||
..
|
||||
} => Self::PrefixRuleMatch {
|
||||
matched_prefix,
|
||||
decision,
|
||||
resolved_program: Some(resolved_program.clone()),
|
||||
justification,
|
||||
},
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -210,6 +230,7 @@ impl Rule for PrefixRule {
|
||||
.map(|matched_prefix| RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix,
|
||||
decision: self.decision,
|
||||
resolved_program: None,
|
||||
justification: self.justification.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::any::Any;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
@@ -7,6 +8,7 @@ use anyhow::Result;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Error;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::MatchOptions;
|
||||
use codex_execpolicy::NetworkRuleProtocol;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_execpolicy::PolicyParser;
|
||||
@@ -16,6 +18,7 @@ use codex_execpolicy::blocking_append_allow_prefix_rule;
|
||||
use codex_execpolicy::rule::PatternToken;
|
||||
use codex_execpolicy::rule::PrefixPattern;
|
||||
use codex_execpolicy::rule::PrefixRule;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -31,6 +34,27 @@ fn prompt_all(_: &[String]) -> Decision {
|
||||
Decision::Prompt
|
||||
}
|
||||
|
||||
fn absolute_path(path: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(path.to_string())
|
||||
.unwrap_or_else(|error| panic!("expected absolute path `{path}`: {error}"))
|
||||
}
|
||||
|
||||
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('"', "\\\"")
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
enum RuleSnapshot {
|
||||
Prefix(PrefixRule),
|
||||
@@ -125,6 +149,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "status"]),
|
||||
decision: Decision::Allow,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
@@ -156,6 +181,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["rm"]),
|
||||
decision: Decision::Forbidden,
|
||||
resolved_program: None,
|
||||
justification: Some("destructive command".to_string()),
|
||||
}],
|
||||
},
|
||||
@@ -184,6 +210,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["ls"]),
|
||||
decision: Decision::Allow,
|
||||
resolved_program: None,
|
||||
justification: Some("safe and commonly used".to_string()),
|
||||
}],
|
||||
},
|
||||
@@ -236,6 +263,7 @@ fn add_prefix_rule_extends_policy() -> Result<()> {
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["ls", "-l"]),
|
||||
decision: Decision::Prompt,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
@@ -305,6 +333,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
@@ -319,11 +348,13 @@ prefix_rule(
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
},
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "commit"]),
|
||||
decision: Decision::Forbidden,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
},
|
||||
],
|
||||
@@ -381,6 +412,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["bash", "-c"]),
|
||||
decision: Decision::Allow,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
@@ -394,6 +426,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["sh", "-l"]),
|
||||
decision: Decision::Allow,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
@@ -440,6 +473,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["npm", "i", "--legacy-peer-deps"]),
|
||||
decision: Decision::Allow,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
@@ -456,6 +490,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["npm", "install", "--no-save"]),
|
||||
decision: Decision::Allow,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
@@ -486,6 +521,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "status"]),
|
||||
decision: Decision::Allow,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
@@ -533,11 +569,13 @@ prefix_rule(
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
},
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "commit"]),
|
||||
decision: Decision::Forbidden,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
},
|
||||
],
|
||||
@@ -576,16 +614,19 @@ prefix_rule(
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
},
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
},
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "commit"]),
|
||||
decision: Decision::Forbidden,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
},
|
||||
],
|
||||
@@ -612,3 +653,278 @@ fn heuristics_match_is_returned_when_no_policy_matches() {
|
||||
evaluation
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_host_executable_paths() -> Result<()> {
|
||||
let homebrew_git = host_absolute_path(&["opt", "homebrew", "bin", "git"]);
|
||||
let usr_git = host_absolute_path(&["usr", "bin", "git"]);
|
||||
let homebrew_git_literal = starlark_string(&homebrew_git);
|
||||
let usr_git_literal = starlark_string(&usr_git);
|
||||
let policy_src = format!(
|
||||
r#"
|
||||
host_executable(
|
||||
name = "git",
|
||||
paths = [
|
||||
"{homebrew_git_literal}",
|
||||
"{usr_git_literal}",
|
||||
"{usr_git_literal}",
|
||||
],
|
||||
)
|
||||
"#
|
||||
);
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse("test.rules", &policy_src)?;
|
||||
let policy = parser.build();
|
||||
|
||||
assert_eq!(
|
||||
policy
|
||||
.host_executables()
|
||||
.get("git")
|
||||
.expect("missing git host executable")
|
||||
.as_ref(),
|
||||
[absolute_path(&homebrew_git), absolute_path(&usr_git)]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_executable_rejects_non_absolute_path() {
|
||||
let policy_src = r#"
|
||||
host_executable(name = "git", paths = ["git"])
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
let err = parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect_err("expected parse error");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("host_executable paths must be absolute")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_executable_rejects_name_with_path_separator() {
|
||||
let git_path = host_absolute_path(&["usr", "bin", "git"]);
|
||||
let git_path_literal = starlark_string(&git_path);
|
||||
let policy_src =
|
||||
format!(r#"host_executable(name = "{git_path_literal}", paths = ["{git_path_literal}"])"#);
|
||||
let mut parser = PolicyParser::new();
|
||||
let err = parser
|
||||
.parse("test.rules", &policy_src)
|
||||
.expect_err("expected parse error");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("host_executable name must be a bare executable name")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_executable_rejects_path_with_wrong_basename() {
|
||||
let rg_path = host_absolute_path(&["usr", "bin", "rg"]);
|
||||
let rg_path_literal = starlark_string(&rg_path);
|
||||
let policy_src = format!(r#"host_executable(name = "git", paths = ["{rg_path_literal}"])"#);
|
||||
let mut parser = PolicyParser::new();
|
||||
let err = parser
|
||||
.parse("test.rules", &policy_src)
|
||||
.expect_err("expected parse error");
|
||||
assert!(err.to_string().contains("must have basename `git`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_executable_last_definition_wins() -> Result<()> {
|
||||
let usr_git = host_absolute_path(&["usr", "bin", "git"]);
|
||||
let homebrew_git = host_absolute_path(&["opt", "homebrew", "bin", "git"]);
|
||||
let usr_git_literal = starlark_string(&usr_git);
|
||||
let homebrew_git_literal = starlark_string(&homebrew_git);
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse(
|
||||
"shared.rules",
|
||||
&format!(r#"host_executable(name = "git", paths = ["{usr_git_literal}"])"#),
|
||||
)?;
|
||||
parser.parse(
|
||||
"user.rules",
|
||||
&format!(r#"host_executable(name = "git", paths = ["{homebrew_git_literal}"])"#),
|
||||
)?;
|
||||
let policy = parser.build();
|
||||
|
||||
assert_eq!(
|
||||
policy
|
||||
.host_executables()
|
||||
.get("git")
|
||||
.expect("missing git host executable")
|
||||
.as_ref(),
|
||||
[absolute_path(&homebrew_git)]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_executable_resolution_uses_basename_rule_when_allowed() -> Result<()> {
|
||||
let git_path = host_absolute_path(&["usr", "bin", "git"]);
|
||||
let git_path_literal = starlark_string(&git_path);
|
||||
let policy_src = format!(
|
||||
r#"
|
||||
prefix_rule(pattern = ["git", "status"], decision = "prompt")
|
||||
host_executable(name = "git", paths = ["{git_path_literal}"])
|
||||
"#
|
||||
);
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse("test.rules", &policy_src)?;
|
||||
let policy = parser.build();
|
||||
|
||||
let evaluation = policy.check_with_options(
|
||||
&[git_path.clone(), "status".to_string()],
|
||||
&allow_all,
|
||||
&MatchOptions {
|
||||
resolve_host_executables: true,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
evaluation,
|
||||
Evaluation {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "status"]),
|
||||
decision: Decision::Prompt,
|
||||
resolved_program: Some(absolute_path(&git_path)),
|
||||
justification: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_executable_resolution_respects_explicit_empty_allowlist() -> Result<()> {
|
||||
let policy_src = r#"
|
||||
prefix_rule(pattern = ["git"], decision = "prompt")
|
||||
host_executable(name = "git", paths = [])
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse("test.rules", policy_src)?;
|
||||
let policy = parser.build();
|
||||
let git_path = host_absolute_path(&["usr", "bin", "git"]);
|
||||
|
||||
let evaluation = policy.check_with_options(
|
||||
&[git_path.clone(), "status".to_string()],
|
||||
&allow_all,
|
||||
&MatchOptions {
|
||||
resolve_host_executables: true,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
evaluation,
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
|
||||
command: vec![git_path, "status".to_string()],
|
||||
decision: Decision::Allow,
|
||||
}],
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_executable_resolution_ignores_path_not_in_allowlist() -> Result<()> {
|
||||
let allowed_git = host_absolute_path(&["usr", "bin", "git"]);
|
||||
let other_git = host_absolute_path(&["opt", "homebrew", "bin", "git"]);
|
||||
let allowed_git_literal = starlark_string(&allowed_git);
|
||||
let policy_src = format!(
|
||||
r#"
|
||||
prefix_rule(pattern = ["git"], decision = "prompt")
|
||||
host_executable(name = "git", paths = ["{allowed_git_literal}"])
|
||||
"#
|
||||
);
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse("test.rules", &policy_src)?;
|
||||
let policy = parser.build();
|
||||
|
||||
let evaluation = policy.check_with_options(
|
||||
&[other_git.clone(), "status".to_string()],
|
||||
&allow_all,
|
||||
&MatchOptions {
|
||||
resolve_host_executables: true,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
evaluation,
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
|
||||
command: vec![other_git, "status".to_string()],
|
||||
decision: Decision::Allow,
|
||||
}],
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_executable_resolution_falls_back_without_mapping() -> Result<()> {
|
||||
let policy_src = r#"
|
||||
prefix_rule(pattern = ["git"], decision = "prompt")
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse("test.rules", policy_src)?;
|
||||
let policy = parser.build();
|
||||
let git_path = host_absolute_path(&["usr", "bin", "git"]);
|
||||
|
||||
let evaluation = policy.check_with_options(
|
||||
&[git_path.clone(), "status".to_string()],
|
||||
&allow_all,
|
||||
&MatchOptions {
|
||||
resolve_host_executables: true,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
evaluation,
|
||||
Evaluation {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
resolved_program: Some(absolute_path(&git_path)),
|
||||
justification: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_executable_resolution_does_not_override_exact_match() -> Result<()> {
|
||||
let git_path = host_absolute_path(&["usr", "bin", "git"]);
|
||||
let git_path_literal = starlark_string(&git_path);
|
||||
let policy_src = format!(
|
||||
r#"
|
||||
prefix_rule(pattern = ["{git_path_literal}"], decision = "allow")
|
||||
prefix_rule(pattern = ["git"], decision = "prompt")
|
||||
host_executable(name = "git", paths = ["{git_path_literal}"])
|
||||
"#
|
||||
);
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse("test.rules", &policy_src)?;
|
||||
let policy = parser.build();
|
||||
|
||||
let evaluation = policy.check_with_options(
|
||||
&[git_path.clone(), "status".to_string()],
|
||||
&allow_all,
|
||||
&MatchOptions {
|
||||
resolve_host_executables: true,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
evaluation,
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: vec![git_path],
|
||||
decision: Decision::Allow,
|
||||
resolved_program: None,
|
||||
justification: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user