chore: refactor exec-server to prepare it for standalone MCP use

This commit is contained in:
Michael Bolin
2025-11-19 14:39:23 -08:00
parent 056c8f8279
commit a429fcef19
8 changed files with 113 additions and 123 deletions

View File

@@ -4,8 +4,16 @@ name = "codex-exec-server"
version = { workspace = true }
[[bin]]
name = "codex-exec-server"
path = "src/main.rs"
name = "codex-execve-wrapper"
path = "src/bin/main_execve_wrapper.rs"
[[bin]]
name = "codex-exec-mcp-server"
path = "src/bin/main_mcp_server.rs"
[lib]
name = "codex_exec_server"
path = "src/lib.rs"
[lints]
workspace = true

View File

@@ -0,0 +1,8 @@
#[cfg(not(unix))]
fn main() {
eprintln!("codex-execve-wrapper is only implemented for UNIX");
std::process::exit(1);
}
#[cfg(unix)]
pub use codex_exec_server::main_execve_wrapper as main;

View File

@@ -0,0 +1,8 @@
#[cfg(not(unix))]
fn main() {
eprintln!("codex-exec-mcp-server is only implemented for UNIX");
std::process::exit(1);
}
#[cfg(unix)]
pub use codex_exec_server::main_mcp_server as main;

View File

@@ -0,0 +1,8 @@
#[cfg(unix)]
mod posix;
#[cfg(unix)]
pub use posix::main_execve_wrapper;
#[cfg(unix)]
pub use posix::main_mcp_server;

View File

@@ -1,11 +0,0 @@
#[cfg(target_os = "windows")]
fn main() {
eprintln!("codex-exec-server is not implemented on Windows targets");
std::process::exit(1);
}
#[cfg(not(target_os = "windows"))]
mod posix;
#[cfg(not(target_os = "windows"))]
pub use posix::main;

View File

@@ -56,15 +56,12 @@
//! o<-----x
//!
use std::path::Path;
use std::path::PathBuf;
use clap::Parser;
use clap::Subcommand;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::{self};
use crate::posix::escalate_protocol::EscalateAction;
use crate::posix::escalate_server::EscalateServer;
use crate::posix::escalation_policy::EscalationPolicy;
use crate::posix::mcp_escalation_policy::ExecPolicyOutcome;
mod escalate_client;
@@ -75,124 +72,84 @@ mod mcp;
mod mcp_escalation_policy;
mod socket;
/// Default value of --execve option relative to the current executable.
/// Note this must match the name of the binary as specified in Cargo.toml.
const CODEX_EXECVE_WRAPPER_EXE_NAME: &str = "codex-execve-wrapper";
#[derive(Parser)]
#[command(version)]
pub struct Cli {
#[command(subcommand)]
subcommand: Option<Commands>,
}
struct McpServerCli {
/// Executable to delegate execve(2) calls to in Bash.
#[arg(long = "execve")]
execve_wrapper: Option<PathBuf>,
#[derive(Subcommand)]
enum Commands {
Escalate(EscalateArgs),
ShellExec(ShellExecArgs),
}
/// Invoked from within the sandbox to (potentially) escalate permissions.
#[derive(Parser, Debug)]
struct EscalateArgs {
file: String,
#[arg(trailing_var_arg = true)]
argv: Vec<String>,
}
impl EscalateArgs {
/// This is the escalate client. It talks to the escalate server to determine whether to exec()
/// the command directly or to proxy to the escalate server.
async fn run(self) -> anyhow::Result<i32> {
let EscalateArgs { file, argv } = self;
escalate_client::run(file, argv).await
}
}
/// Debugging command to emulate an MCP "shell" tool call.
#[derive(Parser, Debug)]
struct ShellExecArgs {
command: String,
/// Path to Bash that has been patched to support execve() wrapping.
#[arg(long = "bash")]
bash_path: Option<PathBuf>,
}
#[tokio::main]
pub async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
pub async fn main_mcp_server() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
match cli.subcommand {
Some(Commands::Escalate(args)) => {
std::process::exit(args.run().await?);
}
Some(Commands::ShellExec(args)) => {
let bash_path = mcp::get_bash_path()?;
let escalate_server = EscalateServer::new(bash_path, DummyEscalationPolicy {});
let result = escalate_server
.exec(
args.command.clone(),
std::env::vars().collect(),
std::env::current_dir()?,
None,
)
.await?;
println!("{result:?}");
std::process::exit(result.exit_code);
}
let cli = McpServerCli::parse();
let execve_wrapper = match cli.execve_wrapper {
Some(path) => path,
None => {
let bash_path = mcp::get_bash_path()?;
tracing::info!("Starting MCP server");
let service = mcp::serve(bash_path, dummy_exec_policy)
.await
.inspect_err(|e| {
tracing::error!("serving error: {:?}", e);
})?;
service.waiting().await?;
Ok(())
let cwd = std::env::current_exe()?;
cwd.parent()
.map(|p| p.join(CODEX_EXECVE_WRAPPER_EXE_NAME))
.ok_or_else(|| {
anyhow::anyhow!("failed to determine execve wrapper path from current exe")
})?
}
}
};
let bash_path = match cli.bash_path {
Some(path) => path,
None => mcp::get_bash_path()?,
};
tracing::info!("Starting MCP server");
let service = mcp::serve(bash_path, execve_wrapper, dummy_exec_policy)
.await
.inspect_err(|e| {
tracing::error!("serving error: {:?}", e);
})?;
service.waiting().await?;
Ok(())
}
#[derive(Parser)]
pub struct ExecveWrapperCli {
file: String,
#[arg(trailing_var_arg = true)]
argv: Vec<String>,
}
#[tokio::main]
pub async fn main_execve_wrapper() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
let ExecveWrapperCli { file, argv } = ExecveWrapperCli::parse();
escalate_client::run(file, argv).await?;
Ok(())
}
// TODO: replace with execpolicy2
struct DummyEscalationPolicy;
#[async_trait::async_trait]
impl EscalationPolicy for DummyEscalationPolicy {
async fn determine_action(
&self,
file: &Path,
argv: &[String],
workdir: &Path,
) -> Result<EscalateAction, rmcp::ErrorData> {
let outcome = dummy_exec_policy(file, argv, workdir);
let action = match outcome {
ExecPolicyOutcome::Allow {
run_with_escalated_permissions,
} => {
if run_with_escalated_permissions {
EscalateAction::Escalate
} else {
EscalateAction::Run
}
}
ExecPolicyOutcome::Forbidden => EscalateAction::Deny {
reason: Some("Execution forbidden by policy".to_string()),
},
ExecPolicyOutcome::Prompt { .. } => EscalateAction::Deny {
reason: Some("Could not prompt user for permission".to_string()),
},
};
Ok(action)
}
}
fn dummy_exec_policy(file: &Path, argv: &[String], _workdir: &Path) -> ExecPolicyOutcome {
if file.ends_with("/rm") {
if file.ends_with("rm") {
ExecPolicyOutcome::Forbidden
} else if file.ends_with("/git") {
} else if file.ends_with("git") {
ExecPolicyOutcome::Prompt {
run_with_escalated_permissions: false,
}

View File

@@ -27,16 +27,18 @@ use crate::posix::socket::AsyncSocket;
pub(crate) struct EscalateServer {
bash_path: PathBuf,
execve_wrapper: PathBuf,
policy: Arc<dyn EscalationPolicy>,
}
impl EscalateServer {
pub fn new<P>(bash_path: PathBuf, policy: P) -> Self
pub fn new<P>(bash_path: PathBuf, execve_wrapper: PathBuf, policy: P) -> Self
where
P: EscalationPolicy + Send + Sync + 'static,
{
Self {
bash_path,
execve_wrapper,
policy: Arc::new(policy),
}
}
@@ -60,8 +62,15 @@ impl EscalateServer {
);
env.insert(
BASH_EXEC_WRAPPER_ENV_VAR.to_string(),
format!("{} escalate", std::env::current_exe()?.to_string_lossy()),
self.execve_wrapper.to_string_lossy().to_string(),
);
// TODO: use the sandbox policy and cwd from the calling client.
// Note that sandbox_cwd is ignored for ReadOnly, but needs to be legit
// for `SandboxPolicy::WorkspaceWrite`.
let sandbox_policy = SandboxPolicy::ReadOnly;
let sandbox_cwd = PathBuf::from("/__NONEXISTENT__");
let result = process_exec_tool_call(
codex_core::exec::ExecParams {
command: vec![
@@ -77,9 +86,8 @@ impl EscalateServer {
arg0: None,
},
get_platform_sandbox().unwrap_or(SandboxType::None),
// TODO: use the sandbox policy and cwd from the calling client
&SandboxPolicy::ReadOnly,
&PathBuf::from("/__NONEXISTENT__"), // This is ignored for ReadOnly
&sandbox_policy,
&sandbox_cwd,
&None,
None,
)

View File

@@ -65,15 +65,17 @@ impl From<escalate_server::ExecResult> for ExecResult {
pub struct ExecTool {
tool_router: ToolRouter<ExecTool>,
bash_path: PathBuf,
execve_wrapper: PathBuf,
policy: ExecPolicy,
}
#[tool_router]
impl ExecTool {
pub fn new(bash_path: PathBuf, policy: ExecPolicy) -> Self {
pub fn new(bash_path: PathBuf, execve_wrapper: PathBuf, policy: ExecPolicy) -> Self {
Self {
tool_router: Self::tool_router(),
bash_path,
execve_wrapper,
policy,
}
}
@@ -87,6 +89,7 @@ impl ExecTool {
) -> Result<CallToolResult, McpError> {
let escalate_server = EscalateServer::new(
self.bash_path.clone(),
self.execve_wrapper.clone(),
McpEscalationPolicy::new(self.policy, context),
);
let result = escalate_server
@@ -130,8 +133,9 @@ impl ServerHandler for ExecTool {
pub(crate) async fn serve(
bash_path: PathBuf,
execve_wrapper: PathBuf,
policy: ExecPolicy,
) -> Result<RunningService<RoleServer, ExecTool>, rmcp::service::ServerInitializeError> {
let tool = ExecTool::new(bash_path, policy);
let tool = ExecTool::new(bash_path, execve_wrapper, policy);
tool.serve(stdio()).await
}