mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
831 lines
27 KiB
Rust
831 lines
27 KiB
Rust
use std::collections::HashSet;
|
|
use std::io::ErrorKind;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
|
|
use codex_execpolicy::AmendError;
|
|
use codex_execpolicy::Decision;
|
|
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::approvals::ExecPolicyAmendment;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use futures::future::try_join_all;
|
|
use thiserror::Error;
|
|
use tokio::fs;
|
|
use tokio::sync::RwLock;
|
|
use tokio::task::spawn_blocking;
|
|
|
|
use crate::bash::parse_shell_lc_plain_commands;
|
|
use crate::features::Feature;
|
|
use crate::features::Features;
|
|
use crate::git_info::resolve_root_git_project_for_trust;
|
|
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";
|
|
const DEFAULT_POLICY_FILE: &str = "default.codexpolicy";
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum ExecPolicyError {
|
|
#[error("failed to read execpolicy files from {dir}: {source}")]
|
|
ReadDir {
|
|
dir: PathBuf,
|
|
source: std::io::Error,
|
|
},
|
|
|
|
#[error("failed to read execpolicy file {path}: {source}")]
|
|
ReadFile {
|
|
path: PathBuf,
|
|
source: std::io::Error,
|
|
},
|
|
|
|
#[error("failed to parse execpolicy file {path}: {source}")]
|
|
ParsePolicy {
|
|
path: String,
|
|
source: codex_execpolicy::Error,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum ExecPolicyUpdateError {
|
|
#[error("failed to update execpolicy file {path}: {source}")]
|
|
AppendRule { path: PathBuf, source: AmendError },
|
|
|
|
#[error("failed to join blocking execpolicy update task: {source}")]
|
|
JoinBlockingTask { source: tokio::task::JoinError },
|
|
|
|
#[error("failed to update in-memory execpolicy: {source}")]
|
|
AddRule {
|
|
#[from]
|
|
source: ExecPolicyRuleError,
|
|
},
|
|
|
|
#[error("cannot append execpolicy rule because execpolicy feature is disabled")]
|
|
FeatureDisabled,
|
|
}
|
|
|
|
pub(crate) async fn load_exec_policy_for_features(
|
|
features: &Features,
|
|
codex_home: &Path,
|
|
cwd: &Path,
|
|
) -> Result<Policy, ExecPolicyError> {
|
|
if !features.enabled(Feature::ExecPolicy) {
|
|
Ok(Policy::empty())
|
|
} else {
|
|
load_exec_policy(codex_home, Some(cwd)).await
|
|
}
|
|
}
|
|
|
|
pub async fn load_exec_policy(
|
|
codex_home: &Path,
|
|
cwd: Option<&Path>,
|
|
) -> Result<Policy, ExecPolicyError> {
|
|
let cwd = cwd
|
|
.map(PathBuf::from)
|
|
.or_else(|| std::env::current_dir().ok());
|
|
|
|
let policy_dirs = execpolicy_directories(codex_home, cwd.as_deref());
|
|
let policy_paths = try_join_all(
|
|
policy_dirs
|
|
.iter()
|
|
.map(|policy_dir| collect_policy_files(policy_dir.as_path())),
|
|
)
|
|
.await?
|
|
.into_iter()
|
|
.flatten()
|
|
.collect::<Vec<_>>();
|
|
|
|
let mut parser = PolicyParser::new();
|
|
for policy_path in &policy_paths {
|
|
let contents =
|
|
fs::read_to_string(policy_path)
|
|
.await
|
|
.map_err(|source| ExecPolicyError::ReadFile {
|
|
path: policy_path.clone(),
|
|
source,
|
|
})?;
|
|
let identifier = policy_path.to_string_lossy().to_string();
|
|
parser
|
|
.parse(&identifier, &contents)
|
|
.map_err(|source| ExecPolicyError::ParsePolicy {
|
|
path: identifier,
|
|
source,
|
|
})?;
|
|
}
|
|
|
|
let policy = parser.build();
|
|
tracing::debug!(
|
|
policy_dirs = ?policy_dirs,
|
|
"loaded execpolicy from {} files across {} policy directories",
|
|
policy_paths.len(),
|
|
policy_dirs.len()
|
|
);
|
|
|
|
Ok(policy)
|
|
}
|
|
|
|
pub(crate) fn default_policy_path(codex_home: &Path) -> PathBuf {
|
|
codex_home.join(POLICY_DIR_NAME).join(DEFAULT_POLICY_FILE)
|
|
}
|
|
|
|
pub(crate) async fn append_execpolicy_amendment_and_update(
|
|
codex_home: &Path,
|
|
current_policy: &Arc<RwLock<Policy>>,
|
|
prefix: &[String],
|
|
) -> Result<(), ExecPolicyUpdateError> {
|
|
let policy_path = default_policy_path(codex_home);
|
|
let prefix = prefix.to_vec();
|
|
spawn_blocking({
|
|
let policy_path = policy_path.clone();
|
|
let prefix = prefix.clone();
|
|
move || blocking_append_allow_prefix_rule(&policy_path, &prefix)
|
|
})
|
|
.await
|
|
.map_err(|source| ExecPolicyUpdateError::JoinBlockingTask { source })?
|
|
.map_err(|source| ExecPolicyUpdateError::AppendRule {
|
|
path: policy_path,
|
|
source,
|
|
})?;
|
|
|
|
current_policy
|
|
.write()
|
|
.await
|
|
.add_prefix_rule(&prefix, Decision::Allow)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns a proposed execpolicy amendment only when heuristics caused
|
|
/// the prompt decision, so we can offer to apply that amendment for future runs.
|
|
///
|
|
/// The amendment uses the first command heuristics marked as `Prompt`. If any explicit
|
|
/// execpolicy rule also prompts, we return `None` because applying the amendment would not
|
|
/// skip that policy requirement.
|
|
///
|
|
/// Examples:
|
|
/// - execpolicy: empty. Command: `["python"]`. Heuristics prompt -> `Some(vec!["python"])`.
|
|
/// - execpolicy: empty. Command: `["bash", "-c", "cd /some/folder && prog1 --option1 arg1 && prog2 --option2 arg2"]`.
|
|
/// Parsed commands include `cd /some/folder`, `prog1 --option1 arg1`, and `prog2 --option2 arg2`. If heuristics allow `cd` but prompt
|
|
/// on `prog1`, we return `Some(vec!["prog1", "--option1", "arg1"])`.
|
|
/// - execpolicy: contains a `prompt for prefix ["prog2"]` rule. For the same command as above,
|
|
/// we return `None` because an execpolicy prompt still applies even if we amend execpolicy to allow ["prog1", "--option1", "arg1"].
|
|
fn proposed_execpolicy_amendment(evaluation: &Evaluation) -> Option<ExecPolicyAmendment> {
|
|
if 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;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
first_prompt_from_heuristics.map(ExecPolicyAmendment::from)
|
|
}
|
|
|
|
/// 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(
|
|
exec_policy: &Arc<RwLock<Policy>>,
|
|
features: &Features,
|
|
command: &[String],
|
|
approval_policy: AskForApproval,
|
|
sandbox_policy: &SandboxPolicy,
|
|
sandbox_permissions: SandboxPermissions,
|
|
) -> ExecApprovalRequirement {
|
|
let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]);
|
|
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.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::NeedsApproval {
|
|
reason: derive_prompt_reason(&evaluation),
|
|
proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) {
|
|
proposed_execpolicy_amendment(&evaluation)
|
|
} else {
|
|
None
|
|
},
|
|
}
|
|
}
|
|
}
|
|
Decision::Allow => ExecApprovalRequirement::Skip {
|
|
bypass_sandbox: has_policy_allow,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn execpolicy_directories(codex_home: &Path, cwd: Option<&Path>) -> Vec<PathBuf> {
|
|
let mut policy_dirs = HashSet::new();
|
|
|
|
// ~/.codex/policy
|
|
policy_dirs.insert(codex_home.join(POLICY_DIR_NAME));
|
|
|
|
// <repo_root>/.codex/policy
|
|
if let Some(repo_root) = cwd.and_then(resolve_root_git_project_for_trust) {
|
|
policy_dirs.insert(repo_root.join(".codex").join(POLICY_DIR_NAME));
|
|
}
|
|
|
|
// /etc/codex/policy
|
|
policy_dirs.insert(PathBuf::from("/etc/codex").join(POLICY_DIR_NAME));
|
|
|
|
policy_dirs.into_iter().collect()
|
|
}
|
|
|
|
async fn collect_policy_files(dir: &Path) -> Result<Vec<PathBuf>, ExecPolicyError> {
|
|
let mut read_dir = match fs::read_dir(dir).await {
|
|
Ok(read_dir) => read_dir,
|
|
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
|
|
Err(source) => {
|
|
return Err(ExecPolicyError::ReadDir {
|
|
dir: dir.to_path_buf(),
|
|
source,
|
|
});
|
|
}
|
|
};
|
|
|
|
let mut policy_paths = Vec::new();
|
|
while let Some(entry) =
|
|
read_dir
|
|
.next_entry()
|
|
.await
|
|
.map_err(|source| ExecPolicyError::ReadDir {
|
|
dir: dir.to_path_buf(),
|
|
source,
|
|
})?
|
|
{
|
|
let path = entry.path();
|
|
let file_type = entry
|
|
.file_type()
|
|
.await
|
|
.map_err(|source| ExecPolicyError::ReadDir {
|
|
dir: dir.to_path_buf(),
|
|
source,
|
|
})?;
|
|
|
|
if path
|
|
.extension()
|
|
.and_then(|ext| ext.to_str())
|
|
.is_some_and(|ext| ext == POLICY_EXTENSION)
|
|
&& file_type.is_file()
|
|
{
|
|
policy_paths.push(path);
|
|
}
|
|
}
|
|
|
|
policy_paths.sort();
|
|
|
|
Ok(policy_paths)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::features::Feature;
|
|
use crate::features::Features;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use pretty_assertions::assert_eq;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::sync::Arc;
|
|
use tempfile::tempdir;
|
|
|
|
#[tokio::test]
|
|
async fn returns_empty_policy_when_feature_disabled() {
|
|
let mut features = Features::with_defaults();
|
|
features.disable(Feature::ExecPolicy);
|
|
let temp_dir = tempdir().expect("create temp dir");
|
|
|
|
let policy = load_exec_policy_for_features(&features, temp_dir.path(), temp_dir.path())
|
|
.await
|
|
.expect("policy result");
|
|
|
|
let commands = [vec!["rm".to_string()]];
|
|
assert_eq!(
|
|
Evaluation {
|
|
decision: Decision::Allow,
|
|
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
|
|
command: vec!["rm".to_string()],
|
|
decision: Decision::Allow
|
|
}],
|
|
},
|
|
policy.check_multiple(commands.iter(), &|_| Decision::Allow)
|
|
);
|
|
assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn collect_policy_files_returns_empty_when_dir_missing() {
|
|
let temp_dir = tempdir().expect("create temp dir");
|
|
|
|
let policy_dir = temp_dir.path().join(POLICY_DIR_NAME);
|
|
let files = collect_policy_files(&policy_dir)
|
|
.await
|
|
.expect("collect policy files");
|
|
|
|
assert!(files.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn execpolicy_directories_include_system_policy_dir() {
|
|
let codex_home = PathBuf::from("/tmp/codex-home");
|
|
|
|
let dirs = execpolicy_directories(&codex_home, None);
|
|
|
|
assert!(dirs.contains(&codex_home.join(POLICY_DIR_NAME)));
|
|
assert!(dirs.contains(&PathBuf::from("/etc/codex").join(POLICY_DIR_NAME)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn loads_policies_from_policy_subdirectory() {
|
|
let temp_dir = tempdir().expect("create temp dir");
|
|
let policy_dir = temp_dir.path().join(POLICY_DIR_NAME);
|
|
fs::create_dir_all(&policy_dir).expect("create policy dir");
|
|
fs::write(
|
|
policy_dir.join("deny.codexpolicy"),
|
|
r#"prefix_rule(pattern=["rm"], decision="forbidden")"#,
|
|
)
|
|
.expect("write policy file");
|
|
|
|
let policy = load_exec_policy(temp_dir.path(), Some(temp_dir.path()))
|
|
.await
|
|
.expect("policy result");
|
|
let command = [vec!["rm".to_string()]];
|
|
assert_eq!(
|
|
Evaluation {
|
|
decision: Decision::Forbidden,
|
|
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
|
matched_prefix: vec!["rm".to_string()],
|
|
decision: Decision::Forbidden
|
|
}],
|
|
},
|
|
policy.check_multiple(command.iter(), &|_| Decision::Allow)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ignores_policies_outside_policy_dir() {
|
|
let temp_dir = tempdir().expect("create temp dir");
|
|
fs::write(
|
|
temp_dir.path().join("root.codexpolicy"),
|
|
r#"prefix_rule(pattern=["ls"], decision="prompt")"#,
|
|
)
|
|
.expect("write policy file");
|
|
|
|
let policy = load_exec_policy(temp_dir.path(), Some(temp_dir.path()))
|
|
.await
|
|
.expect("policy result");
|
|
let command = [vec!["ls".to_string()]];
|
|
assert_eq!(
|
|
Evaluation {
|
|
decision: Decision::Allow,
|
|
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
|
|
command: vec!["ls".to_string()],
|
|
decision: Decision::Allow
|
|
}],
|
|
},
|
|
policy.check_multiple(command.iter(), &|_| Decision::Allow)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn loads_policies_from_git_repo_codex_dir() {
|
|
let temp_dir = tempdir().expect("create temp dir");
|
|
let codex_home = temp_dir.path().join("home");
|
|
let repo_root = temp_dir.path().join("repo");
|
|
fs::create_dir_all(&repo_root).expect("create repo dir");
|
|
let git_init_status = Command::new("git")
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
.arg("init")
|
|
.current_dir(&repo_root)
|
|
.status()
|
|
.expect("initialize git repo");
|
|
assert!(
|
|
git_init_status.success(),
|
|
"git init failed: {git_init_status:?}"
|
|
);
|
|
|
|
let nested_cwd = repo_root.join("nested");
|
|
fs::create_dir_all(&nested_cwd).expect("create nested cwd");
|
|
|
|
let repo_policy_dir = repo_root.join(".codex").join(POLICY_DIR_NAME);
|
|
fs::create_dir_all(&repo_policy_dir).expect("create repo policy dir");
|
|
fs::write(
|
|
repo_policy_dir.join("deny.codexpolicy"),
|
|
r#"prefix_rule(pattern=["git-policy"], decision="forbidden")"#,
|
|
)
|
|
.expect("write repo policy file");
|
|
|
|
let policy = load_exec_policy(&codex_home, Some(nested_cwd.as_path()))
|
|
.await
|
|
.expect("policy result");
|
|
let command = [vec!["git-policy".to_string()]];
|
|
let evaluation = policy.check_multiple(command.iter(), &|_| Decision::Allow);
|
|
|
|
assert_eq!(evaluation.decision, Decision::Forbidden);
|
|
assert!(
|
|
evaluation.matched_rules.iter().any(|rule_match| matches!(
|
|
rule_match,
|
|
RuleMatch::PrefixRuleMatch {
|
|
matched_prefix,
|
|
decision: Decision::Forbidden
|
|
} if matched_prefix == &vec!["git-policy".to_string()]
|
|
)),
|
|
"expected git repo execpolicy rule to match: {evaluation:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluates_bash_lc_inner_commands() {
|
|
let policy_src = r#"
|
|
prefix_rule(pattern=["rm"], decision="forbidden")
|
|
"#;
|
|
let mut parser = PolicyParser::new();
|
|
parser
|
|
.parse("test.codexpolicy", policy_src)
|
|
.expect("parse policy");
|
|
let policy = Arc::new(RwLock::new(parser.build()));
|
|
|
|
let forbidden_script = vec![
|
|
"bash".to_string(),
|
|
"-lc".to_string(),
|
|
"rm -rf /tmp".to_string(),
|
|
];
|
|
|
|
let requirement = create_exec_approval_requirement_for_command(
|
|
&policy,
|
|
&Features::with_defaults(),
|
|
&forbidden_script,
|
|
AskForApproval::OnRequest,
|
|
&SandboxPolicy::DangerFullAccess,
|
|
SandboxPermissions::UseDefault,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
requirement,
|
|
ExecApprovalRequirement::Forbidden {
|
|
reason: FORBIDDEN_REASON.to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_approval_requirement_prefers_execpolicy_match() {
|
|
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
|
|
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!["rm".to_string()];
|
|
|
|
let requirement = create_exec_approval_requirement_for_command(
|
|
&policy,
|
|
&Features::with_defaults(),
|
|
&command,
|
|
AskForApproval::OnRequest,
|
|
&SandboxPolicy::DangerFullAccess,
|
|
SandboxPermissions::UseDefault,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
requirement,
|
|
ExecApprovalRequirement::NeedsApproval {
|
|
reason: Some(PROMPT_REASON.to_string()),
|
|
proposed_execpolicy_amendment: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_approval_requirement_respects_approval_policy() {
|
|
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
|
|
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!["rm".to_string()];
|
|
|
|
let requirement = create_exec_approval_requirement_for_command(
|
|
&policy,
|
|
&Features::with_defaults(),
|
|
&command,
|
|
AskForApproval::Never,
|
|
&SandboxPolicy::DangerFullAccess,
|
|
SandboxPermissions::UseDefault,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
requirement,
|
|
ExecApprovalRequirement::Forbidden {
|
|
reason: PROMPT_CONFLICT_REASON.to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_approval_requirement_falls_back_to_heuristics() {
|
|
let command = vec!["cargo".to_string(), "build".to_string()];
|
|
|
|
let empty_policy = Arc::new(RwLock::new(Policy::empty()));
|
|
let requirement = create_exec_approval_requirement_for_command(
|
|
&empty_policy,
|
|
&Features::with_defaults(),
|
|
&command,
|
|
AskForApproval::UnlessTrusted,
|
|
&SandboxPolicy::ReadOnly,
|
|
SandboxPermissions::UseDefault,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
requirement,
|
|
ExecApprovalRequirement::NeedsApproval {
|
|
reason: None,
|
|
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command))
|
|
}
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
|
"orange".to_string()
|
|
]))
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn append_execpolicy_amendment_updates_policy_and_file() {
|
|
let codex_home = tempdir().expect("create temp dir");
|
|
let current_policy = Arc::new(RwLock::new(Policy::empty()));
|
|
let prefix = vec!["echo".to_string(), "hello".to_string()];
|
|
|
|
append_execpolicy_amendment_and_update(codex_home.path(), ¤t_policy, &prefix)
|
|
.await
|
|
.expect("update policy");
|
|
|
|
let evaluation = current_policy.read().await.check(
|
|
&["echo".to_string(), "hello".to_string(), "world".to_string()],
|
|
&|_| Decision::Allow,
|
|
);
|
|
assert!(matches!(
|
|
evaluation,
|
|
Evaluation {
|
|
decision: Decision::Allow,
|
|
..
|
|
}
|
|
));
|
|
|
|
let contents = fs::read_to_string(default_policy_path(codex_home.path()))
|
|
.expect("policy file should have been created");
|
|
assert_eq!(
|
|
contents,
|
|
r#"prefix_rule(pattern=["echo", "hello"], decision="allow")
|
|
"#
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn append_execpolicy_amendment_rejects_empty_prefix() {
|
|
let codex_home = tempdir().expect("create temp dir");
|
|
let current_policy = Arc::new(RwLock::new(Policy::empty()));
|
|
|
|
let result =
|
|
append_execpolicy_amendment_and_update(codex_home.path(), ¤t_policy, &[]).await;
|
|
|
|
assert!(matches!(
|
|
result,
|
|
Err(ExecPolicyUpdateError::AppendRule {
|
|
source: AmendError::EmptyPrefix,
|
|
..
|
|
})
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proposed_execpolicy_amendment_is_present_for_single_command_without_policy_match() {
|
|
let command = vec!["cargo".to_string(), "build".to_string()];
|
|
|
|
let empty_policy = Arc::new(RwLock::new(Policy::empty()));
|
|
let requirement = create_exec_approval_requirement_for_command(
|
|
&empty_policy,
|
|
&Features::with_defaults(),
|
|
&command,
|
|
AskForApproval::UnlessTrusted,
|
|
&SandboxPolicy::ReadOnly,
|
|
SandboxPermissions::UseDefault,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
requirement,
|
|
ExecApprovalRequirement::NeedsApproval {
|
|
reason: None,
|
|
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command))
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proposed_execpolicy_amendment_is_disabled_when_execpolicy_feature_disabled() {
|
|
let command = vec!["cargo".to_string(), "build".to_string()];
|
|
|
|
let mut features = Features::with_defaults();
|
|
features.disable(Feature::ExecPolicy);
|
|
|
|
let requirement = create_exec_approval_requirement_for_command(
|
|
&Arc::new(RwLock::new(Policy::empty())),
|
|
&features,
|
|
&command,
|
|
AskForApproval::UnlessTrusted,
|
|
&SandboxPolicy::ReadOnly,
|
|
SandboxPermissions::UseDefault,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
requirement,
|
|
ExecApprovalRequirement::NeedsApproval {
|
|
reason: None,
|
|
proposed_execpolicy_amendment: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() {
|
|
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
|
|
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!["rm".to_string()];
|
|
|
|
let requirement = create_exec_approval_requirement_for_command(
|
|
&policy,
|
|
&Features::with_defaults(),
|
|
&command,
|
|
AskForApproval::OnRequest,
|
|
&SandboxPolicy::DangerFullAccess,
|
|
SandboxPermissions::UseDefault,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
requirement,
|
|
ExecApprovalRequirement::NeedsApproval {
|
|
reason: Some(PROMPT_REASON.to_string()),
|
|
proposed_execpolicy_amendment: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() {
|
|
let command = vec![
|
|
"bash".to_string(),
|
|
"-lc".to_string(),
|
|
"cargo build && echo ok".to_string(),
|
|
];
|
|
let requirement = create_exec_approval_requirement_for_command(
|
|
&Arc::new(RwLock::new(Policy::empty())),
|
|
&Features::with_defaults(),
|
|
&command,
|
|
AskForApproval::UnlessTrusted,
|
|
&SandboxPolicy::ReadOnly,
|
|
SandboxPermissions::UseDefault,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
requirement,
|
|
ExecApprovalRequirement::NeedsApproval {
|
|
reason: None,
|
|
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
|
"cargo".to_string(),
|
|
"build".to_string()
|
|
])),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scripts() {
|
|
let policy_src = r#"prefix_rule(pattern=["cat"], 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(),
|
|
"cat && apple".to_string(),
|
|
];
|
|
|
|
assert_eq!(
|
|
create_exec_approval_requirement_for_command(
|
|
&policy,
|
|
&Features::with_defaults(),
|
|
&command,
|
|
AskForApproval::UnlessTrusted,
|
|
&SandboxPolicy::ReadOnly,
|
|
SandboxPermissions::UseDefault,
|
|
)
|
|
.await,
|
|
ExecApprovalRequirement::NeedsApproval {
|
|
reason: None,
|
|
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
|
"apple".to_string()
|
|
])),
|
|
}
|
|
);
|
|
}
|
|
}
|