mirror of
https://github.com/openai/codex.git
synced 2026-09-14 11:57:03 +00:00
268 lines
7.7 KiB
Rust
268 lines
7.7 KiB
Rust
use std::fs;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
|
|
use codex_execpolicy2::Decision;
|
|
use codex_execpolicy2::Evaluation;
|
|
use codex_execpolicy2::Policy;
|
|
use codex_execpolicy2::PolicyParser;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use thiserror::Error;
|
|
|
|
use crate::bash::parse_shell_lc_plain_commands;
|
|
use crate::features::Feature;
|
|
use crate::features::Features;
|
|
use crate::tools::sandboxing::ApprovalRequirement;
|
|
|
|
const FORBIDDEN_REASON: &str = "execpolicy forbids this command";
|
|
const PROMPT_REASON: &str = "execpolicy requires approval for this command";
|
|
|
|
#[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_execpolicy2::Error,
|
|
},
|
|
}
|
|
|
|
pub(crate) fn exec_policy_for(
|
|
features: &Features,
|
|
codex_home: &Path,
|
|
) -> Result<Option<Arc<Policy>>, ExecPolicyError> {
|
|
if !features.enabled(Feature::ExecPolicyV2) {
|
|
return Ok(None);
|
|
}
|
|
|
|
let policy_dir = codex_home.to_path_buf();
|
|
let entries = match fs::read_dir(&policy_dir) {
|
|
Ok(entries) => entries,
|
|
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
Err(source) => {
|
|
return Err(ExecPolicyError::ReadDir {
|
|
dir: policy_dir,
|
|
source,
|
|
});
|
|
}
|
|
};
|
|
|
|
let mut policy_paths: Vec<PathBuf> = Vec::new();
|
|
for entry in entries {
|
|
let entry = entry.map_err(|source| ExecPolicyError::ReadDir {
|
|
dir: policy_dir.clone(),
|
|
source,
|
|
})?;
|
|
let path = entry.path();
|
|
if path
|
|
.extension()
|
|
.and_then(|ext| ext.to_str())
|
|
.is_some_and(|ext| ext == "codexpolicy")
|
|
&& path.is_file()
|
|
{
|
|
policy_paths.push(path);
|
|
}
|
|
}
|
|
|
|
policy_paths.sort();
|
|
|
|
let mut parser = PolicyParser::new();
|
|
for policy_path in &policy_paths {
|
|
let contents =
|
|
fs::read_to_string(policy_path).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 = Arc::new(parser.build());
|
|
tracing::debug!(
|
|
file_count = policy_paths.len(),
|
|
"loaded execpolicy2 from {}",
|
|
policy_dir.display()
|
|
);
|
|
|
|
Ok(Some(policy))
|
|
}
|
|
|
|
pub(crate) fn evaluate_with_policy(
|
|
policy: &Policy,
|
|
command: &[String],
|
|
approval_policy: AskForApproval,
|
|
) -> Option<ApprovalRequirement> {
|
|
let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]);
|
|
let evaluation = policy.check_multiple(commands.iter());
|
|
|
|
match evaluation {
|
|
Evaluation::Match { decision, .. } => match decision {
|
|
Decision::Forbidden => Some(ApprovalRequirement::Forbidden {
|
|
reason: FORBIDDEN_REASON.to_string(),
|
|
}),
|
|
Decision::Prompt => {
|
|
let reason = PROMPT_REASON.to_string();
|
|
if matches!(approval_policy, AskForApproval::Never) {
|
|
Some(ApprovalRequirement::Forbidden { reason })
|
|
} else {
|
|
Some(ApprovalRequirement::NeedsApproval {
|
|
reason: Some(reason),
|
|
})
|
|
}
|
|
}
|
|
Decision::Allow => Some(ApprovalRequirement::Skip),
|
|
},
|
|
Evaluation::NoMatch => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn approval_requirement_for_command(
|
|
policy: Option<&Policy>,
|
|
command: &[String],
|
|
approval_policy: AskForApproval,
|
|
sandbox_policy: &SandboxPolicy,
|
|
with_escalated_permissions: bool,
|
|
) -> ApprovalRequirement {
|
|
if let Some(policy) = policy
|
|
&& let Some(requirement) = evaluate_with_policy(policy, command, approval_policy)
|
|
{
|
|
return requirement;
|
|
}
|
|
|
|
if requires_initial_appoval(
|
|
approval_policy,
|
|
sandbox_policy,
|
|
command,
|
|
with_escalated_permissions,
|
|
) {
|
|
ApprovalRequirement::NeedsApproval { reason: None }
|
|
} else {
|
|
ApprovalRequirement::Skip
|
|
}
|
|
}
|
|
|
|
#[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 tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn returns_none_when_feature_disabled() {
|
|
let features = Features::with_defaults();
|
|
let temp_dir = tempdir().expect("create temp dir");
|
|
|
|
let policy = exec_policy_for(&features, temp_dir.path()).expect("policy result");
|
|
|
|
assert!(policy.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn returns_none_when_policy_dir_is_missing() {
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::ExecPolicyV2);
|
|
let temp_dir = tempdir().expect("create temp dir");
|
|
let missing_dir = temp_dir.path().join("missing");
|
|
|
|
let policy = exec_policy_for(&features, &missing_dir).expect("policy result");
|
|
|
|
assert!(policy.is_none());
|
|
}
|
|
|
|
#[test]
|
|
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 = parser.build();
|
|
|
|
let forbidden_script = vec![
|
|
"bash".to_string(),
|
|
"-lc".to_string(),
|
|
"rm -rf /tmp".to_string(),
|
|
];
|
|
|
|
let requirement =
|
|
evaluate_with_policy(&policy, &forbidden_script, AskForApproval::OnRequest)
|
|
.expect("expected match for forbidden command");
|
|
|
|
assert_eq!(
|
|
requirement,
|
|
ApprovalRequirement::Forbidden {
|
|
reason: FORBIDDEN_REASON.to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn 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 = parser.build();
|
|
let command = vec!["rm".to_string()];
|
|
|
|
let requirement = approval_requirement_for_command(
|
|
Some(&policy),
|
|
&command,
|
|
AskForApproval::OnRequest,
|
|
&SandboxPolicy::DangerFullAccess,
|
|
false,
|
|
);
|
|
|
|
assert_eq!(
|
|
requirement,
|
|
ApprovalRequirement::NeedsApproval {
|
|
reason: Some(PROMPT_REASON.to_string())
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn approval_requirement_falls_back_to_heuristics() {
|
|
let command = vec!["ls".to_string()];
|
|
|
|
let requirement = approval_requirement_for_command(
|
|
None,
|
|
&command,
|
|
AskForApproval::UnlessTrusted,
|
|
&SandboxPolicy::ReadOnly,
|
|
false,
|
|
);
|
|
|
|
assert_eq!(
|
|
requirement,
|
|
ApprovalRequirement::NeedsApproval { reason: None }
|
|
);
|
|
}
|
|
}
|