feat: exec policy integration in shell mcp (#7609)

adding execpolicy support into the `posix` mcp

Co-authored-by: Michael Bolin <mbolin@openai.com>
This commit is contained in:
zhao-oaiandMichael Bolin authored and GitHub committed 2025-12-04 21:55:54 -08:00
1 parent 4c9762d15c
commit b1c918d8f7
6 files changed
+171 -41

No files matched your search

+1
View File
@@ -1256,6 +1256,7 @@ dependencies = [
"async-trait", "async-trait",
"clap", "clap",
"codex-core", "codex-core",
"codex-execpolicy",
"libc", "libc",
"path-absolutize", "path-absolutize",
"pretty_assertions", "pretty_assertions",
+3
View File
@@ -97,7 +97,10 @@ mod user_shell_command;
pub mod util; pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1; pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
pub use command_safety::is_dangerous_command;
pub use command_safety::is_safe_command; pub use command_safety::is_safe_command;
pub use exec_policy::ExecPolicyError;
pub use exec_policy::load_exec_policy;
pub use safety::get_platform_sandbox; pub use safety::get_platform_sandbox;
pub use safety::set_windows_sandbox_enabled; pub use safety::set_windows_sandbox_enabled;
// Re-export the protocol types from the standalone `codex-protocol` crate so existing // Re-export the protocol types from the standalone `codex-protocol` crate so existing
+1
View File
@@ -24,6 +24,7 @@ anyhow = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
clap = { workspace = true, features = ["derive"] } clap = { workspace = true, features = ["derive"] }
codex-core = { workspace = true } codex-core = { workspace = true }
codex-execpolicy = { workspace = true }
libc = { workspace = true } libc = { workspace = true }
path-absolutize = { workspace = true } path-absolutize = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [ rmcp = { workspace = true, default-features = false, features = [
+134 -24
View File
@@ -57,8 +57,17 @@
//! //!
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context as _;
use clap::Parser; use clap::Parser;
use codex_core::config::find_codex_home;
use codex_core::is_dangerous_command::command_might_be_dangerous;
use codex_execpolicy::Decision;
use codex_execpolicy::Policy;
use codex_execpolicy::RuleMatch;
use rmcp::ErrorData as McpError;
use tokio::sync::RwLock;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use tracing_subscriber::{self}; use tracing_subscriber::{self};
@@ -87,6 +96,11 @@ struct McpServerCli {
/// Path to Bash that has been patched to support execve() wrapping. /// Path to Bash that has been patched to support execve() wrapping.
#[arg(long = "bash")] #[arg(long = "bash")]
bash_path: Option<PathBuf>, bash_path: Option<PathBuf>,
/// Preserve program paths when applying execpolicy (e.g., keep /usr/bin/echo instead of echo).
/// Note: this does change the actual program being run.
#[arg(long)]
preserve_program_paths: bool,
} }
#[tokio::main] #[tokio::main]
@@ -113,13 +127,19 @@ pub async fn main_mcp_server() -> anyhow::Result<()> {
Some(path) => path, Some(path) => path,
None => mcp::get_bash_path()?, None => mcp::get_bash_path()?,
}; };
let policy = Arc::new(RwLock::new(load_exec_policy().await?));
tracing::info!("Starting MCP server"); tracing::info!("Starting MCP server");
let service = mcp::serve(bash_path, execve_wrapper, dummy_exec_policy) let service = mcp::serve(
.await bash_path,
.inspect_err(|e| { execve_wrapper,
tracing::error!("serving error: {:?}", e); policy,
})?; cli.preserve_program_paths,
)
.await
.inspect_err(|e| {
tracing::error!("serving error: {:?}", e);
})?;
service.waiting().await?; service.waiting().await?;
Ok(()) Ok(())
@@ -146,26 +166,116 @@ pub async fn main_execve_wrapper() -> anyhow::Result<()> {
std::process::exit(exit_code); std::process::exit(exit_code);
} }
// TODO: replace with execpolicy /// Decide how to handle an exec() call for a specific command.
///
/// `file` is the absolute, canonical path to the executable to run, i.e. the first arg to exec.
/// `argv` is the argv, including the program name (`argv[0]`).
pub(crate) fn evaluate_exec_policy(
policy: &Policy,
file: &Path,
argv: &[String],
preserve_program_paths: bool,
) -> Result<ExecPolicyOutcome, McpError> {
let program_name = format_program_name(file, preserve_program_paths).ok_or_else(|| {
McpError::internal_error(
format!("failed to format program name for `{}`", file.display()),
None,
)
})?;
let command: Vec<String> = std::iter::once(program_name)
// Use the normalized program name instead of argv[0].
.chain(argv.iter().skip(1).cloned())
.collect();
let evaluation = policy.check(&command, &|cmd| {
if command_might_be_dangerous(cmd) {
Decision::Prompt
} else {
Decision::Allow
}
});
fn dummy_exec_policy(file: &Path, argv: &[String], _workdir: &Path) -> ExecPolicyOutcome { // decisions driven by policy should run outside sandbox
if file.ends_with("rm") { let decision_driven_by_policy = evaluation.matched_rules.iter().any(|rule_match| {
ExecPolicyOutcome::Forbidden !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })
} else if file.ends_with("git") { && rule_match.decision() == evaluation.decision
ExecPolicyOutcome::Prompt { });
run_with_escalated_permissions: false,
} Ok(match evaluation.decision {
} else if file == Path::new("/opt/homebrew/bin/gh") Decision::Forbidden => ExecPolicyOutcome::Forbidden,
&& let [_, arg1, arg2, ..] = argv Decision::Prompt => ExecPolicyOutcome::Prompt {
&& arg1 == "issue" run_with_escalated_permissions: decision_driven_by_policy,
&& arg2 == "list" },
{ Decision::Allow => ExecPolicyOutcome::Allow {
ExecPolicyOutcome::Allow { run_with_escalated_permissions: decision_driven_by_policy,
run_with_escalated_permissions: true, },
} })
}
fn format_program_name(path: &Path, preserve_program_paths: bool) -> Option<String> {
if preserve_program_paths {
path.to_str().map(str::to_string)
} else { } else {
ExecPolicyOutcome::Allow { path.file_name()?.to_str().map(str::to_string)
run_with_escalated_permissions: false, }
} }
async fn load_exec_policy() -> anyhow::Result<Policy> {
let codex_home = find_codex_home().context("failed to resolve codex_home for execpolicy")?;
codex_core::load_exec_policy(&codex_home)
.await
.map_err(anyhow::Error::from)
}
#[cfg(test)]
mod tests {
use super::*;
use codex_execpolicy::Decision;
use codex_execpolicy::Policy;
use pretty_assertions::assert_eq;
use std::path::Path;
#[test]
fn evaluate_exec_policy_uses_heuristics_for_dangerous_commands() {
let policy = Policy::empty();
let file = Path::new("/bin/rm");
let argv = vec!["rm".to_string(), "-rf".to_string(), "/".to_string()];
let outcome = evaluate_exec_policy(&policy, file, &argv, false).expect("policy evaluation");
assert_eq!(
outcome,
ExecPolicyOutcome::Prompt {
run_with_escalated_permissions: false
}
);
}
#[test]
fn evaluate_exec_policy_respects_preserve_program_paths() {
let mut policy = Policy::empty();
policy
.add_prefix_rule(
&[
"/usr/local/bin/custom-cmd".to_string(),
"--flag".to_string(),
],
Decision::Allow,
)
.expect("policy rule should be added");
let file = Path::new("/usr/local/bin/custom-cmd");
let argv = vec![
"/usr/local/bin/custom-cmd".to_string(),
"--flag".to_string(),
"value".to_string(),
];
let outcome = evaluate_exec_policy(&policy, file, &argv, true).expect("policy evaluation");
assert_eq!(
outcome,
ExecPolicyOutcome::Allow {
run_with_escalated_permissions: true
}
);
} }
} }
+19 -6
View File
@@ -8,6 +8,7 @@ use codex_core::MCP_SANDBOX_STATE_CAPABILITY;
use codex_core::MCP_SANDBOX_STATE_NOTIFICATION; use codex_core::MCP_SANDBOX_STATE_NOTIFICATION;
use codex_core::SandboxState; use codex_core::SandboxState;
use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SandboxPolicy;
use codex_execpolicy::Policy;
use rmcp::ErrorData as McpError; use rmcp::ErrorData as McpError;
use rmcp::RoleServer; use rmcp::RoleServer;
use rmcp::ServerHandler; use rmcp::ServerHandler;
@@ -27,7 +28,6 @@ use tracing::debug;
use crate::posix::escalate_server::EscalateServer; use crate::posix::escalate_server::EscalateServer;
use crate::posix::escalate_server::{self}; 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::McpEscalationPolicy;
use crate::posix::stopwatch::Stopwatch; use crate::posix::stopwatch::Stopwatch;
@@ -78,18 +78,25 @@ pub struct ExecTool {
tool_router: ToolRouter<ExecTool>, tool_router: ToolRouter<ExecTool>,
bash_path: PathBuf, bash_path: PathBuf,
execve_wrapper: PathBuf, execve_wrapper: PathBuf,
policy: ExecPolicy, policy: Arc<RwLock<Policy>>,
preserve_program_paths: bool,
sandbox_state: Arc<RwLock<Option<SandboxState>>>, sandbox_state: Arc<RwLock<Option<SandboxState>>>,
} }
#[tool_router] #[tool_router]
impl ExecTool { impl ExecTool {
pub fn new(bash_path: PathBuf, execve_wrapper: PathBuf, policy: ExecPolicy) -> Self { pub fn new(
bash_path: PathBuf,
execve_wrapper: PathBuf,
policy: Arc<RwLock<Policy>>,
preserve_program_paths: bool,
) -> Self {
Self { Self {
tool_router: Self::tool_router(), tool_router: Self::tool_router(),
bash_path, bash_path,
execve_wrapper, execve_wrapper,
policy, policy,
preserve_program_paths,
sandbox_state: Arc::new(RwLock::new(None)), sandbox_state: Arc::new(RwLock::new(None)),
} }
} }
@@ -121,7 +128,12 @@ impl ExecTool {
let escalate_server = EscalateServer::new( let escalate_server = EscalateServer::new(
self.bash_path.clone(), self.bash_path.clone(),
self.execve_wrapper.clone(), self.execve_wrapper.clone(),
McpEscalationPolicy::new(self.policy, context, stopwatch.clone()), McpEscalationPolicy::new(
self.policy.clone(),
context,
stopwatch.clone(),
self.preserve_program_paths,
),
); );
let result = escalate_server let result = escalate_server
@@ -198,9 +210,10 @@ impl ServerHandler for ExecTool {
pub(crate) async fn serve( pub(crate) async fn serve(
bash_path: PathBuf, bash_path: PathBuf,
execve_wrapper: PathBuf, execve_wrapper: PathBuf,
policy: ExecPolicy, policy: Arc<RwLock<Policy>>,
preserve_program_paths: bool,
) -> Result<RunningService<RoleServer, ExecTool>, rmcp::service::ServerInitializeError> { ) -> Result<RunningService<RoleServer, ExecTool>, rmcp::service::ServerInitializeError> {
let tool = ExecTool::new(bash_path, execve_wrapper, policy); let tool = ExecTool::new(bash_path, execve_wrapper, policy, preserve_program_paths);
tool.serve(stdio()).await tool.serve(stdio()).await
} }
@@ -1,5 +1,6 @@
use std::path::Path; use std::path::Path;
use codex_execpolicy::Policy;
use rmcp::ErrorData as McpError; use rmcp::ErrorData as McpError;
use rmcp::RoleServer; use rmcp::RoleServer;
use rmcp::model::CreateElicitationRequestParam; use rmcp::model::CreateElicitationRequestParam;
@@ -11,15 +12,10 @@ use rmcp::service::RequestContext;
use crate::posix::escalate_protocol::EscalateAction; use crate::posix::escalate_protocol::EscalateAction;
use crate::posix::escalation_policy::EscalationPolicy; use crate::posix::escalation_policy::EscalationPolicy;
use crate::posix::stopwatch::Stopwatch; use crate::posix::stopwatch::Stopwatch;
use std::sync::Arc;
use tokio::sync::RwLock;
/// This is the policy which decides how to handle an exec() call. #[derive(Debug, PartialEq, Eq)]
///
/// `file` is the absolute, canonical path to the executable to run, i.e. the first arg to exec.
/// `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) enum ExecPolicyOutcome { pub(crate) enum ExecPolicyOutcome {
Allow { Allow {
run_with_escalated_permissions: bool, run_with_escalated_permissions: bool,
@@ -33,21 +29,25 @@ pub(crate) enum ExecPolicyOutcome {
/// ExecPolicy with access to the MCP RequestContext so that it can leverage /// ExecPolicy with access to the MCP RequestContext so that it can leverage
/// elicitations. /// elicitations.
pub(crate) struct McpEscalationPolicy { pub(crate) struct McpEscalationPolicy {
policy: ExecPolicy, /// In-memory execpolicy rules that drive how to handle an exec() call.
policy: Arc<RwLock<Policy>>,
context: RequestContext<RoleServer>, context: RequestContext<RoleServer>,
stopwatch: Stopwatch, stopwatch: Stopwatch,
preserve_program_paths: bool,
} }
impl McpEscalationPolicy { impl McpEscalationPolicy {
pub(crate) fn new( pub(crate) fn new(
policy: ExecPolicy, policy: Arc<RwLock<Policy>>,
context: RequestContext<RoleServer>, context: RequestContext<RoleServer>,
stopwatch: Stopwatch, stopwatch: Stopwatch,
preserve_program_paths: bool,
) -> Self { ) -> Self {
Self { Self {
policy, policy,
context, context,
stopwatch, stopwatch,
preserve_program_paths,
} }
} }
@@ -103,7 +103,9 @@ impl EscalationPolicy for McpEscalationPolicy {
argv: &[String], argv: &[String],
workdir: &Path, workdir: &Path,
) -> Result<EscalateAction, rmcp::ErrorData> { ) -> Result<EscalateAction, rmcp::ErrorData> {
let outcome = (self.policy)(file, argv, workdir); let policy = self.policy.read().await;
let outcome =
crate::posix::evaluate_exec_policy(&policy, file, argv, self.preserve_program_paths)?;
let action = match outcome { let action = match outcome {
ExecPolicyOutcome::Allow { ExecPolicyOutcome::Allow {
run_with_escalated_permissions, run_with_escalated_permissions,