Skip to content

Commit 056c8f8

Browse files
authored
fix: prepare ExecPolicy in exec-server for execpolicy2 cutover (#6888)
This PR introduces an extra layer of abstraction to prepare us for the migration to execpolicy2: - introduces a new trait, `EscalationPolicy`, whose `determine_action()` method is responsible for producing the `EscalateAction` - the existing `ExecPolicy` typedef is changed to return an intermediate `ExecPolicyOutcome` instead of `EscalateAction` - the default implementation of `EscalationPolicy`, `McpEscalationPolicy`, composes `ExecPolicy` - the `ExecPolicyOutcome` includes `codex_execpolicy2::Decision`, which has a `Prompt` variant - when `McpEscalationPolicy` gets `Decision::Prompt` back from `ExecPolicy`, it prompts the user via an MCP elicitation and maps the result into an `ElicitationAction` - now that the end user can reply to an elicitation with `Decline` or `Cancel`, we introduce a new variant, `EscalateAction::Deny`, which the client handles by returning exit code `1` without running anything Note the way the elicitation is created is still not quite right, but I will fix that once we have things running end-to-end for real in a follow-up PR.
1 parent c2ec477 commit 056c8f8

File tree

9 files changed

+266
-56
lines changed

9 files changed

+266
-56
lines changed

codex-rs/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/exec-server/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ workspace = true
1212

1313
[dependencies]
1414
anyhow = { workspace = true }
15+
async-trait = { workspace = true }
1516
clap = { workspace = true, features = ["derive"] }
1617
codex-core = { workspace = true }
1718
libc = { workspace = true }
@@ -31,6 +32,7 @@ rmcp = { workspace = true, default-features = false, features = [
3132
] }
3233
serde = { workspace = true, features = ["derive"] }
3334
serde_json = { workspace = true }
35+
shlex = { workspace = true }
3436
socket2 = { workspace = true }
3537
tokio = { workspace = true, features = [
3638
"io-std",

codex-rs/exec-server/src/posix.rs

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,25 +64,17 @@ use tracing_subscriber::{self};
6464

6565
use crate::posix::escalate_protocol::EscalateAction;
6666
use crate::posix::escalate_server::EscalateServer;
67+
use crate::posix::escalation_policy::EscalationPolicy;
68+
use crate::posix::mcp_escalation_policy::ExecPolicyOutcome;
6769

6870
mod escalate_client;
6971
mod escalate_protocol;
7072
mod escalate_server;
73+
mod escalation_policy;
7174
mod mcp;
75+
mod mcp_escalation_policy;
7276
mod socket;
7377

74-
fn dummy_exec_policy(file: &Path, argv: &[String], _workdir: &Path) -> EscalateAction {
75-
// TODO: execpolicy
76-
if file == Path::new("/opt/homebrew/bin/gh")
77-
&& let [_, arg1, arg2, ..] = argv
78-
&& arg1 == "issue"
79-
&& arg2 == "list"
80-
{
81-
return EscalateAction::Escalate;
82-
}
83-
EscalateAction::Run
84-
}
85-
8678
#[derive(Parser)]
8779
#[command(version)]
8880
pub struct Cli {
@@ -135,7 +127,7 @@ pub async fn main() -> anyhow::Result<()> {
135127
}
136128
Some(Commands::ShellExec(args)) => {
137129
let bash_path = mcp::get_bash_path()?;
138-
let escalate_server = EscalateServer::new(bash_path, dummy_exec_policy);
130+
let escalate_server = EscalateServer::new(bash_path, DummyEscalationPolicy {});
139131
let result = escalate_server
140132
.exec(
141133
args.command.clone(),
@@ -162,3 +154,59 @@ pub async fn main() -> anyhow::Result<()> {
162154
}
163155
}
164156
}
157+
158+
// TODO: replace with execpolicy2
159+
160+
struct DummyEscalationPolicy;
161+
162+
#[async_trait::async_trait]
163+
impl EscalationPolicy for DummyEscalationPolicy {
164+
async fn determine_action(
165+
&self,
166+
file: &Path,
167+
argv: &[String],
168+
workdir: &Path,
169+
) -> Result<EscalateAction, rmcp::ErrorData> {
170+
let outcome = dummy_exec_policy(file, argv, workdir);
171+
let action = match outcome {
172+
ExecPolicyOutcome::Allow {
173+
run_with_escalated_permissions,
174+
} => {
175+
if run_with_escalated_permissions {
176+
EscalateAction::Escalate
177+
} else {
178+
EscalateAction::Run
179+
}
180+
}
181+
ExecPolicyOutcome::Forbidden => EscalateAction::Deny {
182+
reason: Some("Execution forbidden by policy".to_string()),
183+
},
184+
ExecPolicyOutcome::Prompt { .. } => EscalateAction::Deny {
185+
reason: Some("Could not prompt user for permission".to_string()),
186+
},
187+
};
188+
Ok(action)
189+
}
190+
}
191+
192+
fn dummy_exec_policy(file: &Path, argv: &[String], _workdir: &Path) -> ExecPolicyOutcome {
193+
if file.ends_with("/rm") {
194+
ExecPolicyOutcome::Forbidden
195+
} else if file.ends_with("/git") {
196+
ExecPolicyOutcome::Prompt {
197+
run_with_escalated_permissions: false,
198+
}
199+
} else if file == Path::new("/opt/homebrew/bin/gh")
200+
&& let [_, arg1, arg2, ..] = argv
201+
&& arg1 == "issue"
202+
&& arg2 == "list"
203+
{
204+
ExecPolicyOutcome::Allow {
205+
run_with_escalated_permissions: true,
206+
}
207+
} else {
208+
ExecPolicyOutcome::Allow {
209+
run_with_escalated_permissions: false,
210+
}
211+
}
212+
}

codex-rs/exec-server/src/posix/escalate_client.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,12 @@ pub(crate) async fn run(file: String, argv: Vec<String>) -> anyhow::Result<i32>
9898

9999
Err(err.into())
100100
}
101+
EscalateAction::Deny { reason } => {
102+
match reason {
103+
Some(reason) => eprintln!("Execution denied: {reason}"),
104+
None => eprintln!("Execution denied"),
105+
}
106+
Ok(1)
107+
}
101108
}
102109
}

codex-rs/exec-server/src/posix/escalate_protocol.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ pub(super) enum EscalateAction {
3434
Run,
3535
/// The command should be escalated to the server for execution.
3636
Escalate,
37+
/// The command should not be executed.
38+
Deny { reason: Option<String> },
3739
}
3840

3941
/// The client sends this to the server to forward its open FDs.

codex-rs/exec-server/src/posix/escalate_server.rs

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use std::collections::HashMap;
22
use std::os::fd::AsRawFd;
3-
use std::path::Path;
43
use std::path::PathBuf;
54
use std::process::Stdio;
5+
use std::sync::Arc;
66
use std::time::Duration;
77

88
use anyhow::Context as _;
@@ -21,25 +21,24 @@ use crate::posix::escalate_protocol::EscalateRequest;
2121
use crate::posix::escalate_protocol::EscalateResponse;
2222
use crate::posix::escalate_protocol::SuperExecMessage;
2323
use crate::posix::escalate_protocol::SuperExecResult;
24+
use crate::posix::escalation_policy::EscalationPolicy;
2425
use crate::posix::socket::AsyncDatagramSocket;
2526
use crate::posix::socket::AsyncSocket;
2627

27-
/// This is the policy which decides how to handle an exec() call.
28-
///
29-
/// `file` is the absolute, canonical path to the executable to run, i.e. the first arg to exec.
30-
/// `argv` is the argv, including the program name (`argv[0]`).
31-
/// `workdir` is the absolute, canonical path to the working directory in which to execute the
32-
/// command.
33-
pub(crate) type ExecPolicy = fn(file: &Path, argv: &[String], workdir: &Path) -> EscalateAction;
34-
3528
pub(crate) struct EscalateServer {
3629
bash_path: PathBuf,
37-
policy: ExecPolicy,
30+
policy: Arc<dyn EscalationPolicy>,
3831
}
3932

4033
impl EscalateServer {
41-
pub fn new(bash_path: PathBuf, policy: ExecPolicy) -> Self {
42-
Self { bash_path, policy }
34+
pub fn new<P>(bash_path: PathBuf, policy: P) -> Self
35+
where
36+
P: EscalationPolicy + Send + Sync + 'static,
37+
{
38+
Self {
39+
bash_path,
40+
policy: Arc::new(policy),
41+
}
4342
}
4443

4544
pub async fn exec(
@@ -53,7 +52,7 @@ impl EscalateServer {
5352
let client_socket = escalate_client.into_inner();
5453
client_socket.set_cloexec(false)?;
5554

56-
let escalate_task = tokio::spawn(escalate_task(escalate_server, self.policy));
55+
let escalate_task = tokio::spawn(escalate_task(escalate_server, self.policy.clone()));
5756
let mut env = env.clone();
5857
env.insert(
5958
ESCALATE_SOCKET_ENV_VAR.to_string(),
@@ -96,14 +95,18 @@ impl EscalateServer {
9695
}
9796
}
9897

99-
async fn escalate_task(socket: AsyncDatagramSocket, policy: ExecPolicy) -> anyhow::Result<()> {
98+
async fn escalate_task(
99+
socket: AsyncDatagramSocket,
100+
policy: Arc<dyn EscalationPolicy>,
101+
) -> anyhow::Result<()> {
100102
loop {
101103
let (_, mut fds) = socket.receive_with_fds().await?;
102104
if fds.len() != 1 {
103105
tracing::error!("expected 1 fd in datagram handshake, got {}", fds.len());
104106
continue;
105107
}
106108
let stream_socket = AsyncSocket::from_fd(fds.remove(0))?;
109+
let policy = policy.clone();
107110
tokio::spawn(async move {
108111
if let Err(err) = handle_escalate_session_with_policy(stream_socket, policy).await {
109112
tracing::error!("escalate session failed: {err:?}");
@@ -122,7 +125,7 @@ pub(crate) struct ExecResult {
122125

123126
async fn handle_escalate_session_with_policy(
124127
socket: AsyncSocket,
125-
policy: ExecPolicy,
128+
policy: Arc<dyn EscalationPolicy>,
126129
) -> anyhow::Result<()> {
127130
let EscalateRequest {
128131
file,
@@ -132,8 +135,12 @@ async fn handle_escalate_session_with_policy(
132135
} = socket.receive::<EscalateRequest>().await?;
133136
let file = PathBuf::from(&file).absolutize()?.into_owned();
134137
let workdir = PathBuf::from(&workdir).absolutize()?.into_owned();
135-
let action = policy(file.as_path(), &argv, &workdir);
138+
let action = policy
139+
.determine_action(file.as_path(), &argv, &workdir)
140+
.await?;
141+
136142
tracing::debug!("decided {action:?} for {file:?} {argv:?} {workdir:?}");
143+
137144
match action {
138145
EscalateAction::Run => {
139146
socket
@@ -195,6 +202,13 @@ async fn handle_escalate_session_with_policy(
195202
})
196203
.await?;
197204
}
205+
EscalateAction::Deny { reason } => {
206+
socket
207+
.send(EscalateResponse {
208+
action: EscalateAction::Deny { reason },
209+
})
210+
.await?;
211+
}
198212
}
199213
Ok(())
200214
}
@@ -204,14 +218,33 @@ mod tests {
204218
use super::*;
205219
use pretty_assertions::assert_eq;
206220
use std::collections::HashMap;
221+
use std::path::Path;
207222
use std::path::PathBuf;
208223

224+
struct DeterministicEscalationPolicy {
225+
action: EscalateAction,
226+
}
227+
228+
#[async_trait::async_trait]
229+
impl EscalationPolicy for DeterministicEscalationPolicy {
230+
async fn determine_action(
231+
&self,
232+
_file: &Path,
233+
_argv: &[String],
234+
_workdir: &Path,
235+
) -> Result<EscalateAction, rmcp::ErrorData> {
236+
Ok(self.action.clone())
237+
}
238+
}
239+
209240
#[tokio::test]
210241
async fn handle_escalate_session_respects_run_in_sandbox_decision() -> anyhow::Result<()> {
211242
let (server, client) = AsyncSocket::pair()?;
212243
let server_task = tokio::spawn(handle_escalate_session_with_policy(
213244
server,
214-
|_file, _argv, _workdir| EscalateAction::Run,
245+
Arc::new(DeterministicEscalationPolicy {
246+
action: EscalateAction::Run,
247+
}),
215248
));
216249

217250
client
@@ -238,7 +271,9 @@ mod tests {
238271
let (server, client) = AsyncSocket::pair()?;
239272
let server_task = tokio::spawn(handle_escalate_session_with_policy(
240273
server,
241-
|_file, _argv, _workdir| EscalateAction::Escalate,
274+
Arc::new(DeterministicEscalationPolicy {
275+
action: EscalateAction::Escalate,
276+
}),
242277
));
243278

244279
client
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
use std::path::Path;
2+
3+
use crate::posix::escalate_protocol::EscalateAction;
4+
5+
/// Decides what action to take in response to an execve request from a client.
6+
#[async_trait::async_trait]
7+
pub(crate) trait EscalationPolicy: Send + Sync {
8+
async fn determine_action(
9+
&self,
10+
file: &Path,
11+
argv: &[String],
12+
workdir: &Path,
13+
) -> Result<EscalateAction, rmcp::ErrorData>;
14+
}

codex-rs/exec-server/src/posix/mcp.rs

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ use rmcp::tool_handler;
1818
use rmcp::tool_router;
1919
use rmcp::transport::stdio;
2020

21-
use crate::posix::escalate_server;
2221
use crate::posix::escalate_server::EscalateServer;
23-
use crate::posix::escalate_server::ExecPolicy;
22+
use crate::posix::escalate_server::{self};
23+
use crate::posix::mcp_escalation_policy::ExecPolicy;
24+
use crate::posix::mcp_escalation_policy::McpEscalationPolicy;
2425

2526
/// Path to our patched bash.
2627
const CODEX_BASH_PATH_ENV_VAR: &str = "CODEX_BASH_PATH";
@@ -81,10 +82,13 @@ impl ExecTool {
8182
#[tool]
8283
async fn shell(
8384
&self,
84-
_context: RequestContext<RoleServer>,
85+
context: RequestContext<RoleServer>,
8586
Parameters(params): Parameters<ExecParams>,
8687
) -> Result<CallToolResult, McpError> {
87-
let escalate_server = EscalateServer::new(self.bash_path.clone(), self.policy);
88+
let escalate_server = EscalateServer::new(
89+
self.bash_path.clone(),
90+
McpEscalationPolicy::new(self.policy, context),
91+
);
8892
let result = escalate_server
8993
.exec(
9094
params.command,
@@ -99,27 +103,6 @@ impl ExecTool {
99103
ExecResult::from(result),
100104
)?]))
101105
}
102-
103-
#[allow(dead_code)]
104-
async fn prompt(
105-
&self,
106-
command: String,
107-
workdir: String,
108-
context: RequestContext<RoleServer>,
109-
) -> Result<CreateElicitationResult, McpError> {
110-
context
111-
.peer
112-
.create_elicitation(CreateElicitationRequestParam {
113-
message: format!("Allow Codex to run `{command:?}` in `{workdir:?}`?"),
114-
#[allow(clippy::expect_used)]
115-
requested_schema: ElicitationSchema::builder()
116-
.property("dummy", PrimitiveSchema::String(StringSchema::new()))
117-
.build()
118-
.expect("failed to build elicitation schema"),
119-
})
120-
.await
121-
.map_err(|e| McpError::internal_error(e.to_string(), None))
122-
}
123106
}
124107

125108
#[tool_handler]

0 commit comments

Comments
 (0)