diff --git a/src/cli.rs b/src/cli.rs index a35fecb..3d42bd9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,11 +1,16 @@ use std::env; use std::error::Error; +use std::path::PathBuf; use env_logger::Builder as LoggerBuilder; #[derive(Debug)] pub enum CliAction { - Run { log_level: Option }, + Run { + log_level: Option, + workdir: Option, + restrict_to_workdir: bool, + }, Help, Version, } @@ -15,6 +20,8 @@ where I: Iterator, { let mut log_level = None; + let mut workdir = None; + let mut restrict_to_workdir = false; let mut iter = args.peekable(); while let Some(arg) = iter.next() { @@ -29,6 +36,18 @@ where .next() .ok_or_else(|| "--log-level requires a value".to_string())?; log_level = Some(value); + } else if let Some(path) = arg.strip_prefix("--workdir=") { + if path.is_empty() { + return Err("--workdir requires a value".to_string()); + } + workdir = Some(PathBuf::from(path)); + } else if arg == "--workdir" { + let value = iter + .next() + .ok_or_else(|| "--workdir requires a value".to_string())?; + workdir = Some(PathBuf::from(value)); + } else if arg == "--restrict-to-workdir" { + restrict_to_workdir = true; } else { return Err(format!("Unknown argument: {arg}")); } @@ -36,11 +55,15 @@ where } } - Ok(CliAction::Run { log_level }) + Ok(CliAction::Run { + log_level, + workdir, + restrict_to_workdir, + }) } pub fn print_usage() { - println!("Usage: codex-tools-mcp [OPTIONS]\n\nOptions:\n --log-level Override default log level (info)\n -V, --version Print version information\n -h, --help Print this help message"); + println!("Usage: codex-tools-mcp [OPTIONS]\n\nOptions:\n --log-level Override default log level (info)\n --workdir Set process working directory before serving\n --restrict-to-workdir Reject apply_patch paths that escape the working directory\n -V, --version Print version information\n -h, --help Print this help message"); } pub fn init_logging(log_level: Option) -> Result<(), Box> { diff --git a/src/main.rs b/src/main.rs index 5aae337..ca5fb37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod tools; use cli::{init_logging, parse_cli, print_usage, CliAction}; use std::env; use std::error::Error; +use std::io; use std::process; fn main() { @@ -26,9 +27,41 @@ fn try_main() -> Result<(), Box> { println!("{}", cli::version_string()); Ok(()) } - CliAction::Run { log_level } => { + CliAction::Run { + log_level, + workdir, + restrict_to_workdir, + } => { + if let Some(workdir) = workdir { + env::set_current_dir(&workdir).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "Failed to set working directory to {}: {err}", + workdir.display() + ), + ) + })?; + } + + let restrict_root = if restrict_to_workdir { + let cwd = env::current_dir().map_err(|err| { + io::Error::other(format!( + "Failed to resolve current working directory: {err}" + )) + })?; + Some(cwd.canonicalize().map_err(|err| { + io::Error::other(format!( + "Failed to canonicalize working directory {}: {err}", + cwd.display() + )) + })?) + } else { + None + }; + init_logging(log_level)?; - server::run_server()?; + server::run_server(server::ServerConfig { restrict_root })?; Ok(()) } } diff --git a/src/server.rs b/src/server.rs index e2293fb..9f2cc48 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,14 +1,20 @@ use codex_apply_patch::apply_patch as run_apply_patch; use log::{debug, error, info, warn}; use serde_json::{json, Value}; +use std::fs; use std::io::{self, BufRead, Write}; +use std::path::{Component, Path, PathBuf}; use crate::tools::{ apply_patch_tool_schema, update_plan_tool_schema, INVALID_PARAMS, INVALID_REQUEST, JSONRPC_VERSION, MCP_PROTOCOL_VERSION, METHOD_NOT_FOUND, PARSE_ERROR, }; -pub fn run_server() -> io::Result<()> { +pub struct ServerConfig { + pub restrict_root: Option, +} + +pub fn run_server(config: ServerConfig) -> io::Result<()> { let stdin = io::stdin(); for line_result in stdin.lock().lines() { let line = match line_result { @@ -46,7 +52,7 @@ pub fn run_server() -> io::Result<()> { continue; } - if let Err(err) = handle_message(message) { + if let Err(err) = handle_message(message, &config) { error!("internal error while processing message: {err}"); } } @@ -54,7 +60,7 @@ pub fn run_server() -> io::Result<()> { Ok(()) } -fn handle_message(message: Value) -> io::Result<()> { +fn handle_message(message: Value, config: &ServerConfig) -> io::Result<()> { let method = message .get("method") .and_then(Value::as_str) @@ -67,7 +73,7 @@ fn handle_message(message: Value) -> io::Result<()> { match method { "initialize" => handle_initialize(request_id, params), "tools/list" => handle_tools_list(request_id), - "tools/call" => handle_tools_call(request_id, params), + "tools/call" => handle_tools_call(request_id, params, config), "ping" => handle_ping(request_id), _ => send_error( request_id, @@ -122,7 +128,11 @@ fn handle_tools_list(request_id: Option) -> io::Result<()> { send_result(request_id, result) } -fn handle_tools_call(request_id: Option, params: Option) -> io::Result<()> { +fn handle_tools_call( + request_id: Option, + params: Option, + config: &ServerConfig, +) -> io::Result<()> { if request_id.is_none() { return send_error(None, INVALID_REQUEST, "tools/call must include an id"); } @@ -151,7 +161,7 @@ fn handle_tools_call(request_id: Option, params: Option) -> io::Re }); send_result(request_id, result) } - Some("apply_patch") => handle_apply_patch_tool(request_id, ¶ms_obj), + Some("apply_patch") => handle_apply_patch_tool(request_id, ¶ms_obj, config), Some(other) => { warn!("unknown tool requested: {other}"); send_error( @@ -167,6 +177,7 @@ fn handle_tools_call(request_id: Option, params: Option) -> io::Re fn handle_apply_patch_tool( request_id: Option, params_obj: &serde_json::Map, + config: &ServerConfig, ) -> io::Result<()> { let arguments = match params_obj.get("arguments") { Some(Value::Object(arguments)) => arguments, @@ -191,6 +202,13 @@ fn handle_apply_patch_tool( } }; + if let Some(root) = &config.restrict_root { + if let Err(err) = validate_patch_paths_within_root(&patch, root) { + warn!("apply_patch blocked by --restrict-to-workdir: {err}"); + return send_apply_patch_error(request_id, err); + } + } + info!("running apply_patch ({} bytes)", patch.len()); let mut stdout_buf = Vec::new(); @@ -256,6 +274,138 @@ fn handle_apply_patch_tool( } } +fn validate_patch_paths_within_root(patch: &str, root: &Path) -> Result<(), String> { + const FILE_PREFIXES: [(&str, PatchPathKind); 4] = [ + ("*** Add File: ", PatchPathKind::WriteLike), + ("*** Update File: ", PatchPathKind::WriteLike), + ("*** Delete File: ", PatchPathKind::Delete), + ("*** Move to: ", PatchPathKind::WriteLike), + ]; + + for (idx, line) in patch.lines().enumerate() { + for (prefix, kind) in FILE_PREFIXES { + if let Some(path_text) = line.strip_prefix(prefix) { + validate_patch_path(path_text, root, kind) + .map_err(|err| format!("{err} (line {})", idx + 1))?; + } + } + } + + Ok(()) +} + +#[derive(Clone, Copy)] +enum PatchPathKind { + WriteLike, + Delete, +} + +fn validate_patch_path(path_text: &str, root: &Path, kind: PatchPathKind) -> Result<(), String> { + if path_text.is_empty() { + return Err("Patch path cannot be empty".to_string()); + } + + let path = Path::new(path_text); + if path.is_absolute() { + return Err(format!("Absolute patch paths are not allowed: {path_text}")); + } + + let normalized = normalize_relative_path(path) + .ok_or_else(|| format!("Patch path escapes workdir: {path_text}"))?; + if normalized.as_os_str().is_empty() { + return Err(format!("Patch path cannot resolve to workdir root: {path_text}")); + } + + let allow_terminal_symlink_delete = matches!(kind, PatchPathKind::Delete); + ensure_path_stays_within_root(root, &normalized, allow_terminal_symlink_delete) + .map_err(|err| format!("{err}: {path_text}"))?; + + Ok(()) +} + +fn normalize_relative_path(path: &Path) -> Option { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(seg) => normalized.push(seg), + Component::ParentDir => { + if !normalized.pop() { + return None; + } + } + _ => return None, + } + } + Some(normalized) +} + +fn ensure_path_stays_within_root( + root: &Path, + relative: &Path, + allow_terminal_symlink_delete: bool, +) -> Result<(), String> { + let mut cursor = root.to_path_buf(); + let mut components = relative.components().peekable(); + while let Some(component) = components.next() { + let is_last = components.peek().is_none(); + let segment = match component { + Component::Normal(seg) => seg, + _ => return Err("Patch path contains unsupported component".to_string()), + }; + + cursor.push(segment); + + match fs::symlink_metadata(&cursor) { + Ok(metadata) => match fs::canonicalize(&cursor) { + Ok(canonical) => { + if allow_terminal_symlink_delete && is_last && metadata.file_type().is_symlink() + { + // Deleting the symlink entry itself is safe, regardless of where it points. + continue; + } + if !canonical.starts_with(root) { + return Err("Patch path escapes workdir via symlink".to_string()); + } + cursor = canonical; + } + Err(err) + if allow_terminal_symlink_delete + && is_last + && err.kind() == io::ErrorKind::NotFound + && metadata.file_type().is_symlink() => + { + // Allow deleting a broken symlink that is inside workdir. + } + Err(err) => return Err(format!("Failed to resolve patch path: {err}")), + }, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + // Remaining components do not exist yet, so lexical relative joining is enough. + } + Err(err) => return Err(format!("Failed to inspect patch path: {err}")), + } + } + + if !cursor.starts_with(root) { + return Err("Patch path escapes workdir".to_string()); + } + + Ok(()) +} + +fn send_apply_patch_error(request_id: Option, message: impl Into) -> io::Result<()> { + let result = json!({ + "content": [ + { + "type": "text", + "text": format!("apply_patch failed: {}", message.into()), + } + ], + "isError": true, + }); + send_result(request_id, result) +} + fn handle_ping(request_id: Option) -> io::Result<()> { if request_id.is_none() { return send_error(None, INVALID_REQUEST, "ping must include an id"); diff --git a/tests/integration.rs b/tests/integration.rs index 38334d6..f14591c 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -4,6 +4,9 @@ use assert_cmd::prelude::*; use predicates::prelude::*; use tempfile::tempdir; +#[cfg(unix)] +use std::os::unix::fs::symlink; + #[test] fn prints_version() { let mut cmd = Command::cargo_bin("codex-tools-mcp").expect("binary exists"); @@ -54,3 +57,413 @@ fn applies_patch_and_creates_file() { let contents = std::fs::read_to_string(&hello_path).expect("hello.txt created"); assert_eq!(contents.trim(), "hello world!"); } + +#[test] +fn applies_patch_in_explicit_workdir() { + let launch_dir = tempdir().expect("create launch temp dir"); + let work_dir = tempdir().expect("create work temp dir"); + + let mut cmd = Command::cargo_bin("codex-tools-mcp").expect("binary exists"); + cmd.arg("--log-level").arg("error"); + cmd.arg("--workdir").arg(work_dir.path()); + cmd.current_dir(launch_dir.path()); + + let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"test","version":"0"},"capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"apply_patch","arguments":{"input":"*** Begin Patch\n*** Add File: hello.txt\n+hello workdir!\n*** End Patch\n"}}} +"#; + let mut child = cmd + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn server"); + + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + let _ = stdin.write_all(input.as_bytes()); + } + + let output = child.wait_with_output().expect("collect output"); + assert!( + output.status.success(), + "process exited with failure.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let hello_path = work_dir.path().join("hello.txt"); + let contents = std::fs::read_to_string(&hello_path).expect("hello.txt created in --workdir"); + assert_eq!(contents.trim(), "hello workdir!"); + + let wrong_path = launch_dir.path().join("hello.txt"); + assert!( + !wrong_path.exists(), + "hello.txt should not be created in process cwd" + ); +} + +#[test] +fn restrict_to_workdir_still_allows_in_tree_patch_paths() { + let launch_dir = tempdir().expect("create launch temp dir"); + let work_dir = tempdir().expect("create work temp dir"); + + let mut cmd = Command::cargo_bin("codex-tools-mcp").expect("binary exists"); + cmd.arg("--log-level").arg("error"); + cmd.arg("--workdir").arg(work_dir.path()); + cmd.arg("--restrict-to-workdir"); + cmd.current_dir(launch_dir.path()); + + let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"test","version":"0"},"capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"apply_patch","arguments":{"input":"*** Begin Patch\n*** Add File: nested/ok.txt\n+safe write\n*** End Patch\n"}}} +"#; + let mut child = cmd + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn server"); + + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + stdin.write_all(input.as_bytes()).expect("write stdin"); + } + + let output = child.wait_with_output().expect("collect output"); + assert!( + output.status.success(), + "process exited with failure.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!( + !stdout.contains("\"isError\":true"), + "did not expect apply_patch tool error: {stdout}" + ); + + let safe_path = work_dir.path().join("nested").join("ok.txt"); + let contents = std::fs::read_to_string(&safe_path).expect("ok.txt created in --workdir"); + assert_eq!(contents.trim(), "safe write"); +} + +#[test] +fn restrict_to_workdir_blocks_parent_directory_escape() { + let launch_dir = tempdir().expect("create launch temp dir"); + let work_dir = tempdir().expect("create work temp dir"); + + let mut cmd = Command::cargo_bin("codex-tools-mcp").expect("binary exists"); + cmd.arg("--log-level").arg("error"); + cmd.arg("--workdir").arg(work_dir.path()); + cmd.arg("--restrict-to-workdir"); + cmd.current_dir(launch_dir.path()); + + let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"test","version":"0"},"capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"apply_patch","arguments":{"input":"*** Begin Patch\n*** Add File: ../escape.txt\n+should be blocked\n*** End Patch\n"}}} +"#; + let mut child = cmd + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn server"); + + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + stdin.write_all(input.as_bytes()).expect("write stdin"); + } + + let output = child.wait_with_output().expect("collect output"); + assert!( + output.status.success(), + "process exited with failure.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!( + stdout.contains("\"isError\":true"), + "expected apply_patch rejection for parent directory escape: {stdout}" + ); + + let escaped_path = work_dir + .path() + .parent() + .expect("work_dir has parent") + .join("escape.txt"); + assert!( + !escaped_path.exists(), + "escape.txt should not be created outside --workdir" + ); +} + +#[cfg(unix)] +#[test] +fn restrict_to_workdir_blocks_symlink_escape() { + let launch_dir = tempdir().expect("create launch temp dir"); + let work_dir = tempdir().expect("create work temp dir"); + let outside_dir = tempdir().expect("create outside temp dir"); + symlink(outside_dir.path(), work_dir.path().join("link")).expect("create symlink in workdir"); + + let mut cmd = Command::cargo_bin("codex-tools-mcp").expect("binary exists"); + cmd.arg("--log-level").arg("error"); + cmd.arg("--workdir").arg(work_dir.path()); + cmd.arg("--restrict-to-workdir"); + cmd.current_dir(launch_dir.path()); + + let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"test","version":"0"},"capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"apply_patch","arguments":{"input":"*** Begin Patch\n*** Add File: link/escape.txt\n+should be blocked\n*** End Patch\n"}}} +"#; + let mut child = cmd + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn server"); + + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + stdin.write_all(input.as_bytes()).expect("write stdin"); + } + + let output = child.wait_with_output().expect("collect output"); + assert!( + output.status.success(), + "process exited with failure.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!( + stdout.contains("\"isError\":true"), + "expected apply_patch rejection for symlink escape: {stdout}" + ); + + let escaped_path = outside_dir.path().join("escape.txt"); + assert!( + !escaped_path.exists(), + "escape.txt should not be created via symlink outside --workdir" + ); +} + +#[cfg(unix)] +#[test] +fn restrict_to_workdir_allows_delete_of_broken_symlink() { + let launch_dir = tempdir().expect("create launch temp dir"); + let work_dir = tempdir().expect("create work temp dir"); + let broken_link = work_dir.path().join("dead"); + symlink("missing-target", &broken_link).expect("create broken symlink in workdir"); + assert!( + broken_link.exists() || std::fs::symlink_metadata(&broken_link).is_ok(), + "broken symlink should exist as a directory entry" + ); + + let mut cmd = Command::cargo_bin("codex-tools-mcp").expect("binary exists"); + cmd.arg("--log-level").arg("error"); + cmd.arg("--workdir").arg(work_dir.path()); + cmd.arg("--restrict-to-workdir"); + cmd.current_dir(launch_dir.path()); + + let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"test","version":"0"},"capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"apply_patch","arguments":{"input":"*** Begin Patch\n*** Delete File: dead\n*** End Patch\n"}}} +"#; + let mut child = cmd + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn server"); + + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + stdin.write_all(input.as_bytes()).expect("write stdin"); + } + + let output = child.wait_with_output().expect("collect output"); + assert!( + output.status.success(), + "process exited with failure.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!( + !stdout.contains("\"isError\":true"), + "expected delete of broken in-tree symlink to be allowed: {stdout}" + ); + assert!( + std::fs::symlink_metadata(&broken_link).is_err(), + "broken symlink should be deleted" + ); +} + +#[cfg(unix)] +#[test] +fn restrict_to_workdir_blocks_update_through_symlink_escape() { + let launch_dir = tempdir().expect("create launch temp dir"); + let work_dir = tempdir().expect("create work temp dir"); + let outside_dir = tempdir().expect("create outside temp dir"); + symlink(outside_dir.path(), work_dir.path().join("link")).expect("create symlink in workdir"); + let outside_file = outside_dir.path().join("target.txt"); + std::fs::write(&outside_file, "old value\n").expect("seed outside file"); + + let mut cmd = Command::cargo_bin("codex-tools-mcp").expect("binary exists"); + cmd.arg("--log-level").arg("error"); + cmd.arg("--workdir").arg(work_dir.path()); + cmd.arg("--restrict-to-workdir"); + cmd.current_dir(launch_dir.path()); + + let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"test","version":"0"},"capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"apply_patch","arguments":{"input":"*** Begin Patch\n*** Update File: link/target.txt\n@@\n-old value\n+new value\n*** End Patch\n"}}} +"#; + let mut child = cmd + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn server"); + + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + stdin.write_all(input.as_bytes()).expect("write stdin"); + } + + let output = child.wait_with_output().expect("collect output"); + assert!( + output.status.success(), + "process exited with failure.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!( + stdout.contains("\"isError\":true"), + "expected update through symlink escape to be rejected: {stdout}" + ); + + let outside_contents = std::fs::read_to_string(&outside_file).expect("read outside file"); + assert_eq!( + outside_contents, "old value\n", + "outside target should remain unchanged when restricted update is blocked" + ); +} + +#[cfg(unix)] +#[test] +fn restrict_to_workdir_blocks_move_destination_through_symlink_escape() { + let launch_dir = tempdir().expect("create launch temp dir"); + let work_dir = tempdir().expect("create work temp dir"); + let outside_dir = tempdir().expect("create outside temp dir"); + let source = work_dir.path().join("source.txt"); + std::fs::write(&source, "hello\n").expect("seed source file"); + symlink(outside_dir.path(), work_dir.path().join("link")).expect("create symlink in workdir"); + + let mut cmd = Command::cargo_bin("codex-tools-mcp").expect("binary exists"); + cmd.arg("--log-level").arg("error"); + cmd.arg("--workdir").arg(work_dir.path()); + cmd.arg("--restrict-to-workdir"); + cmd.current_dir(launch_dir.path()); + + let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"test","version":"0"},"capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"apply_patch","arguments":{"input":"*** Begin Patch\n*** Update File: source.txt\n*** Move to: link/moved.txt\n@@\n-hello\n+hello moved\n*** End Patch\n"}}} +"#; + let mut child = cmd + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn server"); + + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + stdin.write_all(input.as_bytes()).expect("write stdin"); + } + + let output = child.wait_with_output().expect("collect output"); + assert!( + output.status.success(), + "process exited with failure.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!( + stdout.contains("\"isError\":true"), + "expected move destination through symlink to be rejected: {stdout}" + ); + + let source_contents = std::fs::read_to_string(&source).expect("read source file"); + assert_eq!( + source_contents, "hello\n", + "source file should remain unchanged when restricted move is blocked" + ); + assert!( + !outside_dir.path().join("moved.txt").exists(), + "move destination should not be created outside --workdir" + ); +} + +#[cfg(unix)] +#[test] +fn restrict_to_workdir_allows_delete_of_nonbroken_symlink() { + let launch_dir = tempdir().expect("create launch temp dir"); + let work_dir = tempdir().expect("create work temp dir"); + let outside_dir = tempdir().expect("create outside temp dir"); + let marker = outside_dir.path().join("marker.txt"); + std::fs::write(&marker, "outside").expect("create outside marker"); + + let link = work_dir.path().join("link"); + symlink(outside_dir.path(), &link).expect("create symlink in workdir"); + assert!( + std::fs::symlink_metadata(&link).is_ok(), + "symlink should exist as a directory entry" + ); + + let mut cmd = Command::cargo_bin("codex-tools-mcp").expect("binary exists"); + cmd.arg("--log-level").arg("error"); + cmd.arg("--workdir").arg(work_dir.path()); + cmd.arg("--restrict-to-workdir"); + cmd.current_dir(launch_dir.path()); + + let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"test","version":"0"},"capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"apply_patch","arguments":{"input":"*** Begin Patch\n*** Delete File: link\n*** End Patch\n"}}} +"#; + let mut child = cmd + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn server"); + + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + stdin.write_all(input.as_bytes()).expect("write stdin"); + } + + let output = child.wait_with_output().expect("collect output"); + assert!( + output.status.success(), + "process exited with failure.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!( + !stdout.contains("\"isError\":true"), + "expected delete of in-tree symlink entry to be allowed: {stdout}" + ); + assert!( + std::fs::symlink_metadata(&link).is_err(), + "symlink entry should be deleted" + ); + assert!( + marker.exists(), + "outside target should remain untouched when deleting symlink entry" + ); +}