mirror of
https://github.com/openai/codex.git
synced 2026-09-17 12:23:33 +00:00
This introduces a new feature to Codex when it operates as an MCP
_client_ where if an MCP _server_ replies that it has an entry named
`"codex/sandbox-state"` in its _server capabilities_, then Codex will
send it an MCP notification with the following structure:
```json
{
"method": "codex/sandbox-state/update",
"params": {
"sandboxPolicy": {
"type": "workspace-write",
"network-access": false,
"exclude-tmpdir-env-var": false
"exclude-slash-tmp": false
},
"codexLinuxSandboxExe": null,
"sandboxCwd": "/Users/mbolin/code/codex2"
}
}
```
or with whatever values are appropriate for the initial `sandboxPolicy`.
**NOTE:** Codex _should_ continue to send the MCP server notifications
of the same format if these things change over the lifetime of the
thread, but that isn't wired up yet.
The result is that `shell-tool-mcp` can consume these values so that
when it calls `codex_core::exec::process_exec_tool_call()` in
`codex-rs/exec-server/src/posix/escalate_server.rs`, it is now sure to
call it with the correct values (whereas previously we relied on
hardcoded values).
While I would argue this is a supported use case within the MCP
protocol, the `rmcp` crate that we are using today does not support
custom notifications. As such, I had to patch it and I submitted it for
review, so hopefully it will be accepted in some form:
https://github.com/modelcontextprotocol/rust-sdk/pull/556
To test out this change from end-to-end:
- I ran `cargo build` in `~/code/codex2/codex-rs/exec-server`
- I built the fork of Bash in `~/code/bash/bash`
- I added the following to my `~/.codex/config.toml`:
```toml
# Use with `codex --disable shell_tool`.
[mcp_servers.execshell]
args = ["--bash", "/Users/mbolin/code/bash/bash"]
command = "/Users/mbolin/code/codex2/codex-rs/target/debug/codex-exec-mcp-server"
```
- From `~/code/codex2/codex-rs`, I ran `just codex --disable shell_tool`
- When the TUI started up, I verified that the sandbox mode is
`workspace-write`
- I ran `/mcp` to verify that the shell tool from the MCP is there:
<img width="1387" height="1400" alt="image"
src="https://github.com/user-attachments/assets/1a8addcc-5005-4e16-b59f-95cfd06fd4ab"
/>
- Then I asked it:
> what is the output of `gh issue list`
because this should be auto-approved with our existing dummy policy:
af63e6eccc/codex-rs/exec-server/src/posix.rs (L157-L164)
And it worked:
<img width="1387" height="1400" alt="image"
src="https://github.com/user-attachments/assets/7568d2f7-80da-4d68-86d0-c265a6f5e6c1"
/>
321 lines
9.9 KiB
Rust
321 lines
9.9 KiB
Rust
use std::collections::HashMap;
|
|
use std::os::fd::AsRawFd;
|
|
use std::path::PathBuf;
|
|
use std::process::Stdio;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Context as _;
|
|
use path_absolutize::Absolutize as _;
|
|
|
|
use codex_core::SandboxState;
|
|
use codex_core::exec::process_exec_tool_call;
|
|
use tokio::process::Command;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use crate::posix::escalate_protocol::BASH_EXEC_WRAPPER_ENV_VAR;
|
|
use crate::posix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR;
|
|
use crate::posix::escalate_protocol::EscalateAction;
|
|
use crate::posix::escalate_protocol::EscalateRequest;
|
|
use crate::posix::escalate_protocol::EscalateResponse;
|
|
use crate::posix::escalate_protocol::SuperExecMessage;
|
|
use crate::posix::escalate_protocol::SuperExecResult;
|
|
use crate::posix::escalation_policy::EscalationPolicy;
|
|
use crate::posix::mcp::ExecParams;
|
|
use crate::posix::socket::AsyncDatagramSocket;
|
|
use crate::posix::socket::AsyncSocket;
|
|
use codex_core::exec::ExecExpiration;
|
|
|
|
pub(crate) struct EscalateServer {
|
|
bash_path: PathBuf,
|
|
execve_wrapper: PathBuf,
|
|
policy: Arc<dyn EscalationPolicy>,
|
|
}
|
|
|
|
impl EscalateServer {
|
|
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),
|
|
}
|
|
}
|
|
|
|
pub async fn exec(
|
|
&self,
|
|
params: ExecParams,
|
|
cancel_rx: CancellationToken,
|
|
sandbox_state: &SandboxState,
|
|
) -> anyhow::Result<ExecResult> {
|
|
let (escalate_server, escalate_client) = AsyncDatagramSocket::pair()?;
|
|
let client_socket = escalate_client.into_inner();
|
|
client_socket.set_cloexec(false)?;
|
|
|
|
let escalate_task = tokio::spawn(escalate_task(escalate_server, self.policy.clone()));
|
|
let mut env = std::env::vars().collect::<HashMap<String, String>>();
|
|
env.insert(
|
|
ESCALATE_SOCKET_ENV_VAR.to_string(),
|
|
client_socket.as_raw_fd().to_string(),
|
|
);
|
|
env.insert(
|
|
BASH_EXEC_WRAPPER_ENV_VAR.to_string(),
|
|
self.execve_wrapper.to_string_lossy().to_string(),
|
|
);
|
|
|
|
let ExecParams {
|
|
command,
|
|
workdir,
|
|
timeout_ms: _,
|
|
login,
|
|
} = params;
|
|
let result = process_exec_tool_call(
|
|
codex_core::exec::ExecParams {
|
|
command: vec![
|
|
self.bash_path.to_string_lossy().to_string(),
|
|
if login == Some(false) {
|
|
"-c".to_string()
|
|
} else {
|
|
"-lc".to_string()
|
|
},
|
|
command,
|
|
],
|
|
cwd: PathBuf::from(&workdir),
|
|
expiration: ExecExpiration::Cancellation(cancel_rx),
|
|
env,
|
|
with_escalated_permissions: None,
|
|
justification: None,
|
|
arg0: None,
|
|
},
|
|
&sandbox_state.sandbox_policy,
|
|
&sandbox_state.sandbox_cwd,
|
|
&sandbox_state.codex_linux_sandbox_exe,
|
|
None,
|
|
)
|
|
.await?;
|
|
escalate_task.abort();
|
|
let result = ExecResult {
|
|
exit_code: result.exit_code,
|
|
output: result.aggregated_output.text,
|
|
duration: result.duration,
|
|
timed_out: result.timed_out,
|
|
};
|
|
Ok(result)
|
|
}
|
|
}
|
|
|
|
async fn escalate_task(
|
|
socket: AsyncDatagramSocket,
|
|
policy: Arc<dyn EscalationPolicy>,
|
|
) -> anyhow::Result<()> {
|
|
loop {
|
|
let (_, mut fds) = socket.receive_with_fds().await?;
|
|
if fds.len() != 1 {
|
|
tracing::error!("expected 1 fd in datagram handshake, got {}", fds.len());
|
|
continue;
|
|
}
|
|
let stream_socket = AsyncSocket::from_fd(fds.remove(0))?;
|
|
let policy = policy.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(err) = handle_escalate_session_with_policy(stream_socket, policy).await {
|
|
tracing::error!("escalate session failed: {err:?}");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct ExecResult {
|
|
pub(crate) exit_code: i32,
|
|
pub(crate) output: String,
|
|
pub(crate) duration: Duration,
|
|
pub(crate) timed_out: bool,
|
|
}
|
|
|
|
async fn handle_escalate_session_with_policy(
|
|
socket: AsyncSocket,
|
|
policy: Arc<dyn EscalationPolicy>,
|
|
) -> anyhow::Result<()> {
|
|
let EscalateRequest {
|
|
file,
|
|
argv,
|
|
workdir,
|
|
env,
|
|
} = socket.receive::<EscalateRequest>().await?;
|
|
let file = PathBuf::from(&file).absolutize()?.into_owned();
|
|
let workdir = PathBuf::from(&workdir).absolutize()?.into_owned();
|
|
let action = policy
|
|
.determine_action(file.as_path(), &argv, &workdir)
|
|
.await?;
|
|
|
|
tracing::debug!("decided {action:?} for {file:?} {argv:?} {workdir:?}");
|
|
|
|
match action {
|
|
EscalateAction::Run => {
|
|
socket
|
|
.send(EscalateResponse {
|
|
action: EscalateAction::Run,
|
|
})
|
|
.await?;
|
|
}
|
|
EscalateAction::Escalate => {
|
|
socket
|
|
.send(EscalateResponse {
|
|
action: EscalateAction::Escalate,
|
|
})
|
|
.await?;
|
|
let (msg, fds) = socket
|
|
.receive_with_fds::<SuperExecMessage>()
|
|
.await
|
|
.context("failed to receive SuperExecMessage")?;
|
|
if fds.len() != msg.fds.len() {
|
|
return Err(anyhow::anyhow!(
|
|
"mismatched number of fds in SuperExecMessage: {} in the message, {} from the control message",
|
|
msg.fds.len(),
|
|
fds.len()
|
|
));
|
|
}
|
|
|
|
if msg
|
|
.fds
|
|
.iter()
|
|
.any(|src_fd| fds.iter().any(|dst_fd| dst_fd.as_raw_fd() == *src_fd))
|
|
{
|
|
return Err(anyhow::anyhow!(
|
|
"overlapping fds not yet supported in SuperExecMessage"
|
|
));
|
|
}
|
|
|
|
let mut command = Command::new(file);
|
|
command
|
|
.args(&argv[1..])
|
|
.arg0(argv[0].clone())
|
|
.envs(&env)
|
|
.current_dir(&workdir)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
unsafe {
|
|
command.pre_exec(move || {
|
|
for (dst_fd, src_fd) in msg.fds.iter().zip(&fds) {
|
|
libc::dup2(src_fd.as_raw_fd(), *dst_fd);
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
let mut child = command.spawn()?;
|
|
let exit_status = child.wait().await?;
|
|
socket
|
|
.send(SuperExecResult {
|
|
exit_code: exit_status.code().unwrap_or(127),
|
|
})
|
|
.await?;
|
|
}
|
|
EscalateAction::Deny { reason } => {
|
|
socket
|
|
.send(EscalateResponse {
|
|
action: EscalateAction::Deny { reason },
|
|
})
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
|
|
struct DeterministicEscalationPolicy {
|
|
action: EscalateAction,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl EscalationPolicy for DeterministicEscalationPolicy {
|
|
async fn determine_action(
|
|
&self,
|
|
_file: &Path,
|
|
_argv: &[String],
|
|
_workdir: &Path,
|
|
) -> Result<EscalateAction, rmcp::ErrorData> {
|
|
Ok(self.action.clone())
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn handle_escalate_session_respects_run_in_sandbox_decision() -> anyhow::Result<()> {
|
|
let (server, client) = AsyncSocket::pair()?;
|
|
let server_task = tokio::spawn(handle_escalate_session_with_policy(
|
|
server,
|
|
Arc::new(DeterministicEscalationPolicy {
|
|
action: EscalateAction::Run,
|
|
}),
|
|
));
|
|
|
|
client
|
|
.send(EscalateRequest {
|
|
file: PathBuf::from("/bin/echo"),
|
|
argv: vec!["echo".to_string()],
|
|
workdir: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
})
|
|
.await?;
|
|
|
|
let response = client.receive::<EscalateResponse>().await?;
|
|
assert_eq!(
|
|
EscalateResponse {
|
|
action: EscalateAction::Run,
|
|
},
|
|
response
|
|
);
|
|
server_task.await?
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn handle_escalate_session_executes_escalated_command() -> anyhow::Result<()> {
|
|
let (server, client) = AsyncSocket::pair()?;
|
|
let server_task = tokio::spawn(handle_escalate_session_with_policy(
|
|
server,
|
|
Arc::new(DeterministicEscalationPolicy {
|
|
action: EscalateAction::Escalate,
|
|
}),
|
|
));
|
|
|
|
client
|
|
.send(EscalateRequest {
|
|
file: PathBuf::from("/bin/sh"),
|
|
argv: vec![
|
|
"sh".to_string(),
|
|
"-c".to_string(),
|
|
r#"if [ "$KEY" = VALUE ]; then exit 42; else exit 1; fi"#.to_string(),
|
|
],
|
|
workdir: std::env::current_dir()?,
|
|
env: HashMap::from([("KEY".to_string(), "VALUE".to_string())]),
|
|
})
|
|
.await?;
|
|
|
|
let response = client.receive::<EscalateResponse>().await?;
|
|
assert_eq!(
|
|
EscalateResponse {
|
|
action: EscalateAction::Escalate,
|
|
},
|
|
response
|
|
);
|
|
|
|
client
|
|
.send_with_fds(SuperExecMessage { fds: Vec::new() }, &[])
|
|
.await?;
|
|
|
|
let result = client.receive::<SuperExecResult>().await?;
|
|
assert_eq!(42, result.exit_code);
|
|
|
|
server_task.await?
|
|
}
|
|
}
|