diff --git a/Cargo.lock b/Cargo.lock index 2987616..58fb079 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,6 +304,7 @@ dependencies = [ "schemars 1.2.1", "serde", "serde_json", + "shlex", "tokio", "tracing", "tracing-subscriber", @@ -319,7 +320,7 @@ dependencies = [ "futures-util", "serde", "serde_json", - "shell-escape", + "shlex", "thiserror", "tokio", "tracing", @@ -1189,12 +1190,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-escape" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" - [[package]] name = "shlex" version = "1.3.0" diff --git a/crates/devcontainer-mcp-core/Cargo.toml b/crates/devcontainer-mcp-core/Cargo.toml index b737647..8c969ad 100644 --- a/crates/devcontainer-mcp-core/Cargo.toml +++ b/crates/devcontainer-mcp-core/Cargo.toml @@ -16,4 +16,4 @@ tracing = { workspace = true } futures-util = "0.3" async-trait = "0.1" base64 = "0.22" -shell-escape = "0.1" +shlex = "1" diff --git a/crates/devcontainer-mcp-core/src/file_ops.rs b/crates/devcontainer-mcp-core/src/file_ops.rs index c79ccee..93e98e4 100644 --- a/crates/devcontainer-mcp-core/src/file_ops.rs +++ b/crates/devcontainer-mcp-core/src/file_ops.rs @@ -4,10 +4,7 @@ //! formatting, and helpers to build shell commands for reading/writing files //! through any backend (DevPod SSH, devcontainer exec, Codespaces SSH). -use std::borrow::Cow; - use base64::{engine::general_purpose::STANDARD, Engine}; -use shell_escape::escape; use crate::error::{Error, Result}; @@ -57,7 +54,9 @@ pub fn apply_edit(content: &str, old_str: &str, new_str: &str) -> Result /// Shell-escape a string for safe embedding in a shell command. fn quote(s: &str) -> String { - escape(Cow::Borrowed(s)).into_owned() + shlex::try_quote(s) + .unwrap_or(std::borrow::Cow::Borrowed(s)) + .into_owned() } /// Build a shell command that reads a file via `cat`. @@ -133,8 +132,11 @@ mod tests { #[test] fn test_quote_path_with_single_quote() { let result = quote("it's"); - // Should not break when used in a shell command - assert!(!result.contains("it's") || result.contains("\\'")); + // Result should be shell-safe: either escaped or wrapped in quotes + assert!( + result != "it's", + "single quote must be escaped or quoted, got: {result}" + ); } #[test] diff --git a/crates/devcontainer-mcp/Cargo.toml b/crates/devcontainer-mcp/Cargo.toml index 48e283b..4770732 100644 --- a/crates/devcontainer-mcp/Cargo.toml +++ b/crates/devcontainer-mcp/Cargo.toml @@ -17,3 +17,4 @@ rmcp = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } schemars = "1" +shlex = "1" diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/build.rs b/crates/devcontainer-mcp/src/tools/devcontainer/build.rs index caea2c3..b89cb60 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/build.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/build.rs @@ -25,12 +25,13 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - let extra: Vec<&str> = params + let extra: Vec = params .extra_args .as_deref() - .map(|a| a.split_whitespace().collect()) + .and_then(shlex::split) .unwrap_or_default(); - match devcontainer::build(¶ms.workspace_folder, &extra).await { + let extra_refs: Vec<&str> = extra.iter().map(|s| s.as_str()).collect(); + match devcontainer::build(¶ms.workspace_folder, &extra_refs).await { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs b/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs index e97a521..763012b 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs @@ -25,12 +25,11 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - let cmd_args: Vec<&str> = params - .args - .as_deref() - .map(|a| a.split_whitespace().collect()) - .unwrap_or_default(); - match devcontainer::exec(¶ms.workspace_folder, ¶ms.command, &cmd_args).await { + let full_cmd = match ¶ms.args { + Some(a) => format!("{} {}", params.command, a), + None => params.command, + }; + match devcontainer::exec(¶ms.workspace_folder, "sh", &["-c", &full_cmd]).await { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/up.rs b/crates/devcontainer-mcp/src/tools/devcontainer/up.rs index 7c9955d..b2b1b43 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/up.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/up.rs @@ -29,12 +29,19 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - let extra: Vec<&str> = params + let extra: Vec = params .extra_args .as_deref() - .map(|a| a.split_whitespace().collect()) + .and_then(shlex::split) .unwrap_or_default(); - match devcontainer::up(¶ms.workspace_folder, params.config.as_deref(), &extra).await { + let extra_refs: Vec<&str> = extra.iter().map(|s| s.as_str()).collect(); + match devcontainer::up( + ¶ms.workspace_folder, + params.config.as_deref(), + &extra_refs, + ) + .await + { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/devpod/build.rs b/crates/devcontainer-mcp/src/tools/devpod/build.rs index 6ce4642..bdf8d83 100644 --- a/crates/devcontainer-mcp/src/tools/devpod/build.rs +++ b/crates/devcontainer-mcp/src/tools/devpod/build.rs @@ -19,8 +19,10 @@ impl DevContainerMcp { description = "Build a DevPod workspace image without starting it." )] async fn devpod_build(&self, Parameters(params): Parameters) -> String { - let parts: Vec<&str> = params.args.split_whitespace().collect(); - match devpod::build(&parts).await { + let parts: Vec = shlex::split(¶ms.args) + .unwrap_or_else(|| params.args.split_whitespace().map(String::from).collect()); + let part_refs: Vec<&str> = parts.iter().map(|s| s.as_str()).collect(); + match devpod::build(&part_refs).await { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/devpod/provider.rs b/crates/devcontainer-mcp/src/tools/devpod/provider.rs index 24ead82..e3ef756 100644 --- a/crates/devcontainer-mcp/src/tools/devpod/provider.rs +++ b/crates/devcontainer-mcp/src/tools/devpod/provider.rs @@ -37,12 +37,13 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - let opt_parts: Vec<&str> = params + let opt_parts: Vec = params .options .as_deref() - .map(|o| o.split_whitespace().collect()) + .and_then(shlex::split) .unwrap_or_default(); - match devpod::provider_add(¶ms.provider, &opt_parts).await { + let opt_refs: Vec<&str> = opt_parts.iter().map(|s| s.as_str()).collect(); + match devpod::provider_add(¶ms.provider, &opt_refs).await { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/devpod/up.rs b/crates/devcontainer-mcp/src/tools/devpod/up.rs index b0366c5..f854c1a 100644 --- a/crates/devcontainer-mcp/src/tools/devpod/up.rs +++ b/crates/devcontainer-mcp/src/tools/devpod/up.rs @@ -19,8 +19,10 @@ impl DevContainerMcp { description = "Create and start a DevPod workspace. Pass the source (git URL, local path, or image) and any flags as space-separated args. Returns full build output for self-healing." )] async fn devpod_up(&self, Parameters(params): Parameters) -> String { - let parts: Vec<&str> = params.args.split_whitespace().collect(); - match devpod::up(&parts).await { + let parts: Vec = shlex::split(¶ms.args) + .unwrap_or_else(|| params.args.split_whitespace().map(String::from).collect()); + let part_refs: Vec<&str> = parts.iter().map(|s| s.as_str()).collect(); + match devpod::up(&part_refs).await { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), }