Compare commits

...

1 Commits

Author SHA1 Message Date
kevin zhao
8832459f7f draft impl of execpolicy in posix 2025-12-01 13:04:56 -05:00
7 changed files with 293 additions and 37 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -1254,6 +1254,7 @@ dependencies = [
"async-trait",
"clap",
"codex-core",
"codex-execpolicy",
"libc",
"path-absolutize",
"pretty_assertions",
@@ -1263,6 +1264,7 @@ dependencies = [
"shlex",
"socket2 0.6.0",
"tempfile",
"thiserror 2.0.17",
"tokio",
"tokio-util",
"tracing",

View File

@@ -16,7 +16,7 @@ mod codex_conversation;
mod compact_remote;
pub use codex_conversation::CodexConversation;
mod codex_delegate;
mod command_safety;
pub mod command_safety;
pub mod config;
pub mod config_loader;
mod context_manager;

View File

@@ -24,6 +24,7 @@ anyhow = { workspace = true }
async-trait = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-core = { workspace = true }
codex-execpolicy = { workspace = true }
libc = { workspace = true }
path-absolutize = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [
@@ -51,6 +52,7 @@ tokio = { workspace = true, features = [
"signal",
] }
tokio-util = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }

View File

@@ -55,19 +55,21 @@
//! | |
//! o<-----x
//!
use std::path::Path;
use std::path::PathBuf;
use clap::Parser;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::{self};
use crate::posix::mcp_escalation_policy::ExecPolicyOutcome;
use crate::posix::exec_policy::ExecPolicyEvaluator;
use crate::posix::exec_policy::load_policy_from_codex_home;
use crate::posix::mcp_escalation_policy::SharedExecPolicy;
mod escalate_client;
mod escalate_protocol;
mod escalate_server;
mod escalation_policy;
mod exec_policy;
mod mcp;
mod mcp_escalation_policy;
mod socket;
@@ -114,7 +116,10 @@ pub async fn main_mcp_server() -> anyhow::Result<()> {
};
tracing::info!("Starting MCP server");
let service = mcp::serve(bash_path, execve_wrapper, dummy_exec_policy)
let policy = load_policy_from_codex_home().await?;
let policy: SharedExecPolicy = std::sync::Arc::new(ExecPolicyEvaluator::new(policy));
let service = mcp::serve(bash_path, execve_wrapper, policy)
.await
.inspect_err(|e| {
tracing::error!("serving error: {:?}", e);
@@ -144,27 +149,3 @@ pub async fn main_execve_wrapper() -> anyhow::Result<()> {
let exit_code = escalate_client::run(file, argv).await?;
std::process::exit(exit_code);
}
// TODO: replace with execpolicy
fn dummy_exec_policy(file: &Path, argv: &[String], _workdir: &Path) -> ExecPolicyOutcome {
if file.ends_with("rm") {
ExecPolicyOutcome::Forbidden
} else if file.ends_with("git") {
ExecPolicyOutcome::Prompt {
run_with_escalated_permissions: false,
}
} else if file == Path::new("/opt/homebrew/bin/gh")
&& let [_, arg1, arg2, ..] = argv
&& arg1 == "issue"
&& arg2 == "list"
{
ExecPolicyOutcome::Allow {
run_with_escalated_permissions: true,
}
} else {
ExecPolicyOutcome::Allow {
run_with_escalated_permissions: false,
}
}
}

View File

@@ -0,0 +1,266 @@
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use codex_core::command_safety::is_dangerous_command::command_might_be_dangerous;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
use codex_execpolicy::Policy;
use codex_execpolicy::PolicyParser;
use thiserror::Error;
use tokio::fs;
use crate::posix::mcp_escalation_policy::ExecPolicy;
use crate::posix::mcp_escalation_policy::ExecPolicyOutcome;
const POLICY_DIR_NAME: &str = "policy";
const POLICY_EXTENSION: &str = "codexpolicy";
#[derive(Debug, Error)]
pub(crate) enum ExecPolicyError {
#[error("failed to resolve CODEX_HOME: {source}")]
CodexHome { source: std::io::Error },
#[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,
},
}
pub(crate) async fn load_policy_from_codex_home() -> Result<Arc<Policy>, ExecPolicyError> {
let codex_home = codex_core::config::find_codex_home()
.map_err(|source| ExecPolicyError::CodexHome { source })?;
let policy_dir = codex_home.join(POLICY_DIR_NAME);
let policy_paths = collect_policy_files(&policy_dir).await?;
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 = Arc::new(parser.build());
tracing::debug!(
"loaded execpolicy from {} files in {}",
policy_paths.len(),
policy_dir.display()
);
Ok(policy)
}
pub(crate) struct ExecPolicyEvaluator {
policy: Arc<Policy>,
}
impl ExecPolicyEvaluator {
pub(crate) fn new(policy: Arc<Policy>) -> Self {
Self { policy }
}
fn command_for(file: &Path, argv: &[String]) -> Vec<String> {
let cmd0 = argv
.first()
.and_then(|s| Path::new(s).file_name())
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
.map(std::string::ToString::to_string)
.or_else(|| {
file.file_name()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
.map(std::string::ToString::to_string)
})
.unwrap_or_else(|| file.display().to_string());
let mut command = Vec::with_capacity(argv.len().max(1));
command.push(cmd0);
if argv.len() > 1 {
command.extend(argv.iter().skip(1).cloned());
}
command
}
}
impl ExecPolicy for ExecPolicyEvaluator {
fn evaluate(&self, file: &Path, argv: &[String], _workdir: &Path) -> ExecPolicyOutcome {
let command = Self::command_for(file, argv);
match self.policy.check_multiple(std::iter::once(&command)) {
Evaluation::Match { decision, .. } => match decision {
Decision::Forbidden => ExecPolicyOutcome::Forbidden,
Decision::Prompt => ExecPolicyOutcome::Prompt {
run_with_escalated_permissions: true,
},
Decision::Allow => ExecPolicyOutcome::Allow {
run_with_escalated_permissions: true,
},
},
Evaluation::NoMatch { .. } => {
if command_might_be_dangerous(&command) {
ExecPolicyOutcome::Prompt {
run_with_escalated_permissions: false,
}
} else {
ExecPolicyOutcome::Allow {
run_with_escalated_permissions: false,
}
}
}
}
}
}
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 tempfile::tempdir;
struct EnvVarGuard {
key: &'static str,
original: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &std::ffi::OsStr) -> Self {
let original = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match &self.original {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
#[test]
fn allow_policy_bypasses_sandbox() {
let mut parser = PolicyParser::new();
parser
.parse(
"test.codexpolicy",
r#"prefix_rule(pattern=["echo"], decision="allow")"#,
)
.expect("parse policy");
let evaluator = ExecPolicyEvaluator::new(Arc::new(parser.build()));
let outcome = evaluator.evaluate(
Path::new("/bin/echo"),
&["echo".to_string()],
Path::new("/"),
);
assert!(matches!(
outcome,
ExecPolicyOutcome::Allow {
run_with_escalated_permissions: true
}
));
}
#[test]
fn no_match_dangerous_command_prompts() {
let evaluator = ExecPolicyEvaluator::new(Arc::new(Policy::empty()));
let outcome = evaluator.evaluate(
Path::new("/bin/rm"),
&["rm".to_string(), "-rf".to_string(), "/".to_string()],
Path::new("/"),
);
assert!(matches!(
outcome,
ExecPolicyOutcome::Prompt {
run_with_escalated_permissions: false
}
));
}
#[tokio::test]
async fn missing_policy_dir_loads_empty() {
let dir = tempdir().expect("tempdir");
let _guard = EnvVarGuard::set("CODEX_HOME", dir.path().as_os_str());
let policy = load_policy_from_codex_home().await.expect("load policy");
assert!(matches!(
policy.check_multiple(std::iter::once(&vec!["rm".to_string()])),
Evaluation::NoMatch { .. }
));
}
}

View File

@@ -27,8 +27,8 @@ use tracing::debug;
use crate::posix::escalate_server::EscalateServer;
use crate::posix::escalate_server::{self};
use crate::posix::mcp_escalation_policy::ExecPolicy;
use crate::posix::mcp_escalation_policy::McpEscalationPolicy;
use crate::posix::mcp_escalation_policy::SharedExecPolicy;
use crate::posix::stopwatch::Stopwatch;
/// Path to our patched bash.
@@ -78,13 +78,13 @@ pub struct ExecTool {
tool_router: ToolRouter<ExecTool>,
bash_path: PathBuf,
execve_wrapper: PathBuf,
policy: ExecPolicy,
policy: SharedExecPolicy,
sandbox_state: Arc<RwLock<Option<SandboxState>>>,
}
#[tool_router]
impl ExecTool {
pub fn new(bash_path: PathBuf, execve_wrapper: PathBuf, policy: ExecPolicy) -> Self {
pub fn new(bash_path: PathBuf, execve_wrapper: PathBuf, policy: SharedExecPolicy) -> Self {
Self {
tool_router: Self::tool_router(),
bash_path,
@@ -121,7 +121,7 @@ impl ExecTool {
let escalate_server = EscalateServer::new(
self.bash_path.clone(),
self.execve_wrapper.clone(),
McpEscalationPolicy::new(self.policy, context, stopwatch.clone()),
McpEscalationPolicy::new(self.policy.clone(), context, stopwatch.clone()),
);
let result = escalate_server
@@ -198,7 +198,7 @@ impl ServerHandler for ExecTool {
pub(crate) async fn serve(
bash_path: PathBuf,
execve_wrapper: PathBuf,
policy: ExecPolicy,
policy: SharedExecPolicy,
) -> Result<RunningService<RoleServer, ExecTool>, rmcp::service::ServerInitializeError> {
let tool = ExecTool::new(bash_path, execve_wrapper, policy);
tool.serve(stdio()).await

View File

@@ -1,4 +1,5 @@
use std::path::Path;
use std::sync::Arc;
use rmcp::ErrorData as McpError;
use rmcp::RoleServer;
@@ -18,7 +19,11 @@ use crate::posix::stopwatch::Stopwatch;
/// `argv` is the argv, including the program name (`argv[0]`).
/// `workdir` is the absolute, canonical path to the working directory in which to execute the
/// command.
pub(crate) type ExecPolicy = fn(file: &Path, argv: &[String], workdir: &Path) -> ExecPolicyOutcome;
pub(crate) trait ExecPolicy: Send + Sync {
fn evaluate(&self, file: &Path, argv: &[String], workdir: &Path) -> ExecPolicyOutcome;
}
pub(crate) type SharedExecPolicy = Arc<dyn ExecPolicy>;
pub(crate) enum ExecPolicyOutcome {
Allow {
@@ -33,14 +38,14 @@ pub(crate) enum ExecPolicyOutcome {
/// ExecPolicy with access to the MCP RequestContext so that it can leverage
/// elicitations.
pub(crate) struct McpEscalationPolicy {
policy: ExecPolicy,
policy: SharedExecPolicy,
context: RequestContext<RoleServer>,
stopwatch: Stopwatch,
}
impl McpEscalationPolicy {
pub(crate) fn new(
policy: ExecPolicy,
policy: SharedExecPolicy,
context: RequestContext<RoleServer>,
stopwatch: Stopwatch,
) -> Self {
@@ -103,7 +108,7 @@ impl EscalationPolicy for McpEscalationPolicy {
argv: &[String],
workdir: &Path,
) -> Result<EscalateAction, rmcp::ErrorData> {
let outcome = (self.policy)(file, argv, workdir);
let outcome = self.policy.evaluate(file, argv, workdir);
let action = match outcome {
ExecPolicyOutcome::Allow {
run_with_escalated_permissions,