Skip to content

Commit e6b51aa

Browse files
committed
feat: exec policy integration in shell mcp
1 parent 37c3602 commit e6b51aa

File tree

7 files changed

+116
-36
lines changed

7 files changed

+116
-36
lines changed

codex-rs/Cargo.lock

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

codex-rs/core/src/exec_policy.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ pub enum ExecPolicyUpdateError {
7373
FeatureDisabled,
7474
}
7575

76-
pub(crate) async fn exec_policy_for(
76+
pub async fn exec_policy_for(
7777
features: &Features,
7878
codex_home: &Path,
7979
) -> Result<Arc<RwLock<Policy>>, ExecPolicyError> {

codex-rs/core/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ mod user_shell_command;
9797
pub mod util;
9898

9999
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
100+
pub use command_safety::is_dangerous_command;
100101
pub use command_safety::is_safe_command;
102+
pub use exec_policy::ExecPolicyError;
103+
pub use exec_policy::exec_policy_for;
101104
pub use safety::get_platform_sandbox;
102105
pub use safety::set_windows_sandbox_enabled;
103106
// Re-export the protocol types from the standalone `codex-protocol` crate so existing

codex-rs/exec-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ anyhow = { workspace = true }
2424
async-trait = { workspace = true }
2525
clap = { workspace = true, features = ["derive"] }
2626
codex-core = { workspace = true }
27+
codex-execpolicy = { workspace = true }
2728
libc = { workspace = true }
2829
path-absolutize = { workspace = true }
2930
rmcp = { workspace = true, default-features = false, features = [

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

Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,20 @@
5757
//!
5858
use std::path::Path;
5959
use std::path::PathBuf;
60+
use std::sync::Arc;
6061

62+
use anyhow::Context as _;
6163
use clap::Parser;
64+
use codex_core::ExecPolicyError;
65+
use codex_core::bash::parse_shell_lc_plain_commands;
66+
use codex_core::config::find_codex_home;
67+
use codex_core::exec_policy_for;
68+
use codex_core::features::Features;
69+
use codex_core::is_dangerous_command::command_might_be_dangerous;
70+
use codex_execpolicy::Decision;
71+
use codex_execpolicy::Policy;
72+
use codex_execpolicy::RuleMatch;
73+
use tracing::debug;
6274
use tracing_subscriber::EnvFilter;
6375
use tracing_subscriber::{self};
6476

@@ -76,6 +88,7 @@ mod stopwatch;
7688
/// Default value of --execve option relative to the current executable.
7789
/// Note this must match the name of the binary as specified in Cargo.toml.
7890
const CODEX_EXECVE_WRAPPER_EXE_NAME: &str = "codex-execve-wrapper";
91+
const POLICY_DIR_NAME: &str = "policy";
7992

8093
#[derive(Parser)]
8194
#[clap(version)]
@@ -87,6 +100,10 @@ struct McpServerCli {
87100
/// Path to Bash that has been patched to support execve() wrapping.
88101
#[arg(long = "bash")]
89102
bash_path: Option<PathBuf>,
103+
104+
/// Strip program paths before applying execpolicy (e.g., /usr/bin/echo -> echo).
105+
#[arg(long, default_value_t = true)]
106+
strip_program_paths: bool,
90107
}
91108

92109
#[tokio::main]
@@ -113,13 +130,19 @@ pub async fn main_mcp_server() -> anyhow::Result<()> {
113130
Some(path) => path,
114131
None => mcp::get_bash_path()?,
115132
};
133+
let policy = load_exec_policy().await?;
116134

117135
tracing::info!("Starting MCP server");
118-
let service = mcp::serve(bash_path, execve_wrapper, dummy_exec_policy)
119-
.await
120-
.inspect_err(|e| {
121-
tracing::error!("serving error: {:?}", e);
122-
})?;
136+
let service = mcp::serve(
137+
bash_path,
138+
execve_wrapper,
139+
policy.clone(),
140+
cli.strip_program_paths,
141+
)
142+
.await
143+
.inspect_err(|e| {
144+
tracing::error!("serving error: {:?}", e);
145+
})?;
123146

124147
service.waiting().await?;
125148
Ok(())
@@ -146,26 +169,64 @@ pub async fn main_execve_wrapper() -> anyhow::Result<()> {
146169
std::process::exit(exit_code);
147170
}
148171

149-
// TODO: replace with execpolicy
150-
151-
fn dummy_exec_policy(file: &Path, argv: &[String], _workdir: &Path) -> ExecPolicyOutcome {
152-
if file.ends_with("rm") {
153-
ExecPolicyOutcome::Forbidden
154-
} else if file.ends_with("git") {
155-
ExecPolicyOutcome::Prompt {
156-
run_with_escalated_permissions: false,
157-
}
158-
} else if file == Path::new("/opt/homebrew/bin/gh")
159-
&& let [_, arg1, arg2, ..] = argv
160-
&& arg1 == "issue"
161-
&& arg2 == "list"
162-
{
163-
ExecPolicyOutcome::Allow {
164-
run_with_escalated_permissions: true,
172+
/// Decide how to handle an exec() call for a specific command.
173+
///
174+
/// `file` is the absolute, canonical path to the executable to run, i.e. the first arg to exec.
175+
/// `argv` is the argv, including the program name (`argv[0]`).
176+
pub(crate) fn evaluate_exec_policy(
177+
policy: &Policy,
178+
file: &Path,
179+
argv: &[String],
180+
strip_program_paths: bool,
181+
) -> ExecPolicyOutcome {
182+
let command: Vec<String> = std::iter::once(format_program_name(file, strip_program_paths))
183+
// Use the normalized program name instead of argv[0].
184+
.chain(argv.iter().skip(1).cloned())
185+
.collect();
186+
let commands = parse_shell_lc_plain_commands(&command).unwrap_or_else(|| vec![command]);
187+
let evaluation = policy.check_multiple(commands.iter(), &|cmd| {
188+
if command_might_be_dangerous(cmd) {
189+
Decision::Prompt
190+
} else {
191+
Decision::Allow
165192
}
193+
});
194+
195+
// decisions driven by policy should run outside sandbox
196+
let decision_driven_by_policy = evaluation.matched_rules.iter().any(|rule_match| {
197+
!matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })
198+
&& rule_match.decision() == evaluation.decision
199+
});
200+
201+
match evaluation.decision {
202+
Decision::Forbidden => ExecPolicyOutcome::Forbidden,
203+
Decision::Prompt => ExecPolicyOutcome::Prompt {
204+
run_with_escalated_permissions: decision_driven_by_policy,
205+
},
206+
Decision::Allow => ExecPolicyOutcome::Allow {
207+
run_with_escalated_permissions: decision_driven_by_policy,
208+
},
209+
}
210+
}
211+
212+
fn format_program_name(path: &Path, strip_program_paths: bool) -> String {
213+
if strip_program_paths {
214+
path.file_name()
215+
.and_then(|name| name.to_str())
216+
.map(str::to_string)
217+
.unwrap_or_else(|| path.display().to_string())
166218
} else {
167-
ExecPolicyOutcome::Allow {
168-
run_with_escalated_permissions: false,
169-
}
219+
path.display().to_string()
170220
}
171221
}
222+
223+
async fn load_exec_policy() -> anyhow::Result<Arc<Policy>> {
224+
let codex_home = find_codex_home().context("failed to resolve codex_home for execpolicy")?;
225+
let policy_dir = codex_home.join(POLICY_DIR_NAME);
226+
let policy = exec_policy_for(&Features::with_defaults(), &codex_home)
227+
.await
228+
.map_err(|err: ExecPolicyError| anyhow::anyhow!(err))?;
229+
let policy = policy.read().await.clone();
230+
debug!("loaded execpolicy from {}", policy_dir.display());
231+
Ok(Arc::new(policy))
232+
}

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,17 +79,24 @@ pub struct ExecTool {
7979
bash_path: PathBuf,
8080
execve_wrapper: PathBuf,
8181
policy: ExecPolicy,
82+
strip_program_paths: bool,
8283
sandbox_state: Arc<RwLock<Option<SandboxState>>>,
8384
}
8485

8586
#[tool_router]
8687
impl ExecTool {
87-
pub fn new(bash_path: PathBuf, execve_wrapper: PathBuf, policy: ExecPolicy) -> Self {
88+
pub fn new(
89+
bash_path: PathBuf,
90+
execve_wrapper: PathBuf,
91+
policy: ExecPolicy,
92+
strip_program_paths: bool,
93+
) -> Self {
8894
Self {
8995
tool_router: Self::tool_router(),
9096
bash_path,
9197
execve_wrapper,
9298
policy,
99+
strip_program_paths,
93100
sandbox_state: Arc::new(RwLock::new(None)),
94101
}
95102
}
@@ -121,7 +128,12 @@ impl ExecTool {
121128
let escalate_server = EscalateServer::new(
122129
self.bash_path.clone(),
123130
self.execve_wrapper.clone(),
124-
McpEscalationPolicy::new(self.policy, context, stopwatch.clone()),
131+
McpEscalationPolicy::new(
132+
self.policy.clone(),
133+
context,
134+
stopwatch.clone(),
135+
self.strip_program_paths,
136+
),
125137
);
126138

127139
let result = escalate_server
@@ -199,8 +211,9 @@ pub(crate) async fn serve(
199211
bash_path: PathBuf,
200212
execve_wrapper: PathBuf,
201213
policy: ExecPolicy,
214+
strip_program_paths: bool,
202215
) -> Result<RunningService<RoleServer, ExecTool>, rmcp::service::ServerInitializeError> {
203-
let tool = ExecTool::new(bash_path, execve_wrapper, policy);
216+
let tool = ExecTool::new(bash_path, execve_wrapper, policy, strip_program_paths);
204217
tool.serve(stdio()).await
205218
}
206219

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::path::Path;
2+
use std::sync::Arc;
23

4+
use codex_execpolicy::Policy;
35
use rmcp::ErrorData as McpError;
46
use rmcp::RoleServer;
57
use rmcp::model::CreateElicitationRequestParam;
@@ -12,13 +14,8 @@ use crate::posix::escalate_protocol::EscalateAction;
1214
use crate::posix::escalation_policy::EscalationPolicy;
1315
use crate::posix::stopwatch::Stopwatch;
1416

15-
/// This is the policy which decides how to handle an exec() call.
16-
///
17-
/// `file` is the absolute, canonical path to the executable to run, i.e. the first arg to exec.
18-
/// `argv` is the argv, including the program name (`argv[0]`).
19-
/// `workdir` is the absolute, canonical path to the working directory in which to execute the
20-
/// command.
21-
pub(crate) type ExecPolicy = fn(file: &Path, argv: &[String], workdir: &Path) -> ExecPolicyOutcome;
17+
/// In-memory execpolicy rules that drive how to handle an exec() call.
18+
pub(crate) type ExecPolicy = Arc<Policy>;
2219

2320
pub(crate) enum ExecPolicyOutcome {
2421
Allow {
@@ -36,18 +33,21 @@ pub(crate) struct McpEscalationPolicy {
3633
policy: ExecPolicy,
3734
context: RequestContext<RoleServer>,
3835
stopwatch: Stopwatch,
36+
strip_program_paths: bool,
3937
}
4038

4139
impl McpEscalationPolicy {
4240
pub(crate) fn new(
4341
policy: ExecPolicy,
4442
context: RequestContext<RoleServer>,
4543
stopwatch: Stopwatch,
44+
strip_program_paths: bool,
4645
) -> Self {
4746
Self {
4847
policy,
4948
context,
5049
stopwatch,
50+
strip_program_paths,
5151
}
5252
}
5353

@@ -103,7 +103,8 @@ impl EscalationPolicy for McpEscalationPolicy {
103103
argv: &[String],
104104
workdir: &Path,
105105
) -> Result<EscalateAction, rmcp::ErrorData> {
106-
let outcome = (self.policy)(file, argv, workdir);
106+
let outcome =
107+
crate::posix::evaluate_exec_policy(&self.policy, file, argv, self.strip_program_paths);
107108
let action = match outcome {
108109
ExecPolicyOutcome::Allow {
109110
run_with_escalated_permissions,

0 commit comments

Comments
 (0)