diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d9fb5d2804..85ab7d1882 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2794,6 +2794,7 @@ dependencies = [ "codex-otel", "codex-plugin", "codex-protocol", + "codex-shell-command", "codex-skills", "codex-tools", "codex-utils-absolute-path", diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 200174562b..1545193a98 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -30,6 +30,7 @@ codex-otel = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-skills = { workspace = true } +codex-shell-command = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path = { workspace = true } diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index ce02d0c7ba..e7d33ea212 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -17,6 +17,7 @@ mod provider; pub mod remote; pub mod remote_bundle; pub mod remote_legacy; +mod script_attribution; pub mod startup_sync; pub mod store; #[cfg(test)] @@ -72,3 +73,5 @@ pub use provider::ExecutorPluginProviderError; pub use provider::ResolvedExecutorPlugin; pub use remote::RecommendedPlugin; pub use remote::RecommendedPluginsMode; +pub use script_attribution::PluginCommandAttribution; +pub use script_attribution::TrustedPluginRoots; diff --git a/codex-rs/core-plugins/src/script_attribution.rs b/codex-rs/core-plugins/src/script_attribution.rs new file mode 100644 index 0000000000..064a72aa33 --- /dev/null +++ b/codex-rs/core-plugins/src/script_attribution.rs @@ -0,0 +1,289 @@ +use crate::OPENAI_API_CURATED_MARKETPLACE_NAME; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::PluginLoadOutcome; +use crate::loader::curated_plugin_cache_version; +use crate::marketplace::load_marketplace; +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use crate::startup_sync::curated_plugins_api_marketplace_path; +use crate::startup_sync::curated_plugins_repo_path; +use crate::startup_sync::read_curated_plugins_sha; +use crate::store::DEFAULT_PLUGIN_VERSION; +use crate::store::PluginStore; +use codex_plugin::PluginId; +use codex_protocol::items::is_safe_plugin_relative_path; +use codex_shell_command::bash::extract_bash_command; +use codex_shell_command::bash::parse_shell_lc_plain_commands; +use codex_shell_command::parse_command::is_pathish; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashSet; +use std::path::Component; +use std::path::Path; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TrustedPluginRoot { + plugin_id: PluginId, + root: AbsolutePathBuf, +} + +/// Trusted plugin command attribution safe to carry into command analytics. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginCommandAttribution { + pub plugin_id: PluginId, + pub normalized_relative_path: String, +} + +/// Active first-party roots eligible for command attribution. +/// Trusted means OpenAI-shipped synced code or a server-installed global +/// remote plugin cache entry, not a local override. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TrustedPluginRoots { + roots: Vec, +} + +impl TrustedPluginRoots { + pub fn from_plugin_load_outcome(loaded_plugins: &PluginLoadOutcome, codex_home: &Path) -> Self { + let Ok(store) = PluginStore::try_new(codex_home.to_path_buf()) else { + return Self::default(); + }; + let mut seen = HashSet::new(); + let roots = loaded_plugins + .plugins() + .iter() + .filter(|plugin| plugin.is_active()) + .filter_map(|plugin| { + let plugin_id = PluginId::parse(&plugin.config_name).ok()?; + let expected_root = match plugin_id.marketplace_name.as_str() { + REMOTE_GLOBAL_MARKETPLACE_NAME => { + let active_version = store.active_plugin_version(&plugin_id)?; + if active_version == DEFAULT_PLUGIN_VERSION + || store.remote_plugin_id(&plugin_id).ok().flatten().is_none() + { + return None; + } + store.plugin_root(&plugin_id, &active_version) + } + OPENAI_CURATED_MARKETPLACE_NAME | OPENAI_API_CURATED_MARKETPLACE_NAME => { + let curated_sha = read_curated_plugins_sha(codex_home)?; + let expected_root = store + .plugin_root(&plugin_id, &curated_plugin_cache_version(&curated_sha)); + let marketplace_path = match plugin_id.marketplace_name.as_str() { + OPENAI_CURATED_MARKETPLACE_NAME => { + curated_plugins_repo_path(codex_home) + .join(".agents/plugins/marketplace.json") + } + OPENAI_API_CURATED_MARKETPLACE_NAME => { + curated_plugins_api_marketplace_path(codex_home) + } + _ => return None, + }; + let marketplace_path = AbsolutePathBuf::try_from(marketplace_path).ok()?; + let marketplace = load_marketplace(&marketplace_path).ok()?; + if marketplace.name != plugin_id.marketplace_name + || !marketplace + .plugins + .iter() + .any(|plugin| plugin.name == plugin_id.plugin_name) + { + return None; + } + expected_root + } + _ => return None, + }; + if plugin.root != expected_root || !expected_root.as_path().is_dir() { + return None; + } + let root = expected_root.canonicalize().ok()?; + root.as_path() + .is_dir() + .then_some(TrustedPluginRoot { plugin_id, root }) + }) + .filter(|root| seen.insert((root.plugin_id.as_key(), root.root.clone()))) + .collect(); + Self { roots } + } + + /// Resolves one exact command to one trusted plugin script. + /// + /// Complex shell syntax, missing files, symlink escapes, and overlapping + /// matches are all unattributed by design. + pub fn resolve_attribution( + &self, + command: &[String], + cwd: &AbsolutePathBuf, + ) -> Option { + let command = single_plain_command(command)?; + let script = script_argument(command.as_slice())?; + let script = if Path::new(script).is_absolute() { + AbsolutePathBuf::from_absolute_path_checked(script).ok()? + } else { + cwd.join(script) + } + .canonicalize() + .ok()?; + if !script.as_path().is_file() { + return None; + } + + let mut matches = self.roots.iter().filter_map(|root| { + let relative_path = script + .as_path() + .strip_prefix(root.root.as_path()) + .ok() + .filter(|relative_path| !relative_path.as_os_str().is_empty())?; + Some(PluginCommandAttribution { + plugin_id: root.plugin_id.clone(), + normalized_relative_path: normalized_relative_script_path(relative_path)?, + }) + }); + let attribution = matches.next()?; + matches.next().is_none().then_some(attribution) + } +} + +/// Converts a path already proven to be below a trusted plugin root into the +/// only path shape that may leave the resolver: non-empty, relative, and +/// slash-separated with no traversal or platform-specific prefixes. +fn normalized_relative_script_path(relative_path: &Path) -> Option { + let normalized = relative_path + .components() + .map(|component| { + let Component::Normal(component) = component else { + return None; + }; + component.to_str() + }) + .collect::>>()? + .join("/"); + + is_safe_plugin_relative_path(&normalized).then_some(normalized) +} + +fn single_plain_command(command: &[String]) -> Option> { + if let Some(commands) = parse_shell_lc_plain_commands(command) { + let [command] = commands.as_slice() else { + return None; + }; + return single_plain_command(command); + } + if let Some(script) = windows_shell_script(command) { + let wrapper = ["sh".to_string(), "-lc".to_string(), script.to_string()]; + return single_plain_command(&wrapper); + } + if extract_bash_command(command).is_some() { + return None; + } + Some(command.to_vec()) +} + +fn script_argument(command: &[String]) -> Option<&str> { + let [program, args @ ..] = command else { + return None; + }; + if let Some(interpreter) = interpreter_name(program) { + return interpreter_script_argument(&interpreter, args); + } + is_pathish(program).then_some(program) +} + +fn interpreter_name(program: &str) -> Option { + let basename = executable_basename(program)?; + let basename = basename.to_ascii_lowercase(); + let basename = basename.strip_suffix(".exe").unwrap_or(&basename); + matches!( + basename, + "bash" + | "node" + | "nodejs" + | "perl" + | "php" + | "powershell" + | "pwsh" + | "python" + | "python3" + | "ruby" + | "sh" + | "zsh" + ) + .then(|| basename.to_string()) +} + +fn interpreter_script_argument<'a>(interpreter: &str, args: &'a [String]) -> Option<&'a str> { + if matches!(interpreter, "powershell" | "pwsh") { + let [file_flag, script, ..] = args else { + return None; + }; + return (file_flag.eq_ignore_ascii_case("-file") && !script.starts_with('-')) + .then_some(script); + } + + let mut args = args; + loop { + match args { + [separator, script, ..] if separator == "--" && !script.starts_with('-') => { + return Some(script); + } + [flag, remaining @ ..] if safe_interpreter_flag(interpreter, flag) => { + args = remaining; + } + [script, ..] if !script.starts_with('-') => return Some(script), + _ => return None, + } + } +} + +fn safe_interpreter_flag(interpreter: &str, flag: &str) -> bool { + matches!( + (interpreter, flag), + ("python" | "python3", "-u") | ("bash" | "sh" | "zsh", "-e") + ) +} + +fn executable_basename(program: &str) -> Option<&str> { + program + .rsplit(['/', '\\']) + .next() + .filter(|basename| !basename.is_empty()) +} + +fn windows_shell_script(command: &[String]) -> Option<&str> { + let [program, args @ ..] = command else { + return None; + }; + let basename = executable_basename(program)?.to_ascii_lowercase(); + if matches!(basename.as_str(), "cmd" | "cmd.exe") { + let [flag, script] = args else { + return None; + }; + return flag.eq_ignore_ascii_case("/c").then_some(script); + } + if !matches!( + basename.as_str(), + "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" + ) { + return None; + } + + let [flags @ .., command_flag, script] = args else { + return None; + }; + if !matches!( + command_flag.to_ascii_lowercase().as_str(), + "-command" | "-c" + ) { + return None; + } + flags + .iter() + .all(|flag| { + matches!( + flag.to_ascii_lowercase().as_str(), + "-nologo" | "-noprofile" | "-noninteractive" + ) + }) + .then_some(script) +} + +#[cfg(test)] +#[path = "script_attribution_tests.rs"] +mod tests; diff --git a/codex-rs/core-plugins/src/script_attribution_tests.rs b/codex-rs/core-plugins/src/script_attribution_tests.rs new file mode 100644 index 0000000000..192b15ef82 --- /dev/null +++ b/codex-rs/core-plugins/src/script_attribution_tests.rs @@ -0,0 +1,324 @@ +use super::*; +use crate::LoadedPlugin; +use crate::loader::curated_plugin_cache_version; +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use crate::startup_sync::curated_plugins_repo_path; +use crate::store::DEFAULT_PLUGIN_VERSION; +use crate::store::PluginStore; +use crate::test_support::TEST_CURATED_PLUGIN_SHA; +use crate::test_support::write_curated_plugin_sha_with; +use crate::test_support::write_openai_api_curated_marketplace; +use crate::test_support::write_openai_curated_marketplace; +use codex_plugin::PluginLoadOutcome; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::collections::HashSet; +use std::fs; +use tempfile::TempDir; +const ENABLED: bool = true; +const DISABLED: bool = false; +fn path(path: &Path) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path_checked(path).expect("absolute path") +} +fn loaded_plugin(config_name: &str, root: &Path, enabled: bool) -> LoadedPlugin { + LoadedPlugin { + config_name: config_name.to_string(), + manifest_name: None, + plugin_namespace: None, + manifest_description: None, + root: path(root), + enabled, + skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, + mcp_servers: HashMap::new(), + apps: Vec::new(), + hook_sources: Vec::new(), + hook_load_warnings: Vec::new(), + error: None, + } +} +fn synced_plugin_root(codex_home: &Path, marketplace: &str, plugin_name: &str) -> AbsolutePathBuf { + let synced_root = curated_plugins_repo_path(codex_home); + match marketplace { + OPENAI_CURATED_MARKETPLACE_NAME => { + write_openai_curated_marketplace(&synced_root, &[plugin_name]) + } + OPENAI_API_CURATED_MARKETPLACE_NAME => { + write_openai_api_curated_marketplace(&synced_root, &[plugin_name]) + } + _ => panic!("unsupported test marketplace"), + } + let plugin_id = + PluginId::new(plugin_name.to_string(), marketplace.to_string()).expect("plugin id"); + let root = PluginStore::new(codex_home.to_path_buf()).plugin_root( + &plugin_id, + &curated_plugin_cache_version(TEST_CURATED_PLUGIN_SHA), + ); + fs::create_dir_all(root.as_path()).expect("create cached plugin root"); + root +} +fn cached_remote_plugin_root(codex_home: &Path, plugin_name: &str) -> AbsolutePathBuf { + let plugin_id = PluginId::new( + plugin_name.to_string(), + REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), + ) + .expect("plugin id"); + let root = PluginStore::new(codex_home.to_path_buf()).plugin_root(&plugin_id, "1.2.3"); + fs::create_dir_all(root.as_path()).expect("create cached remote plugin root"); + root +} +fn installed_remote_plugin_root(codex_home: &Path, plugin_name: &str) -> AbsolutePathBuf { + let root = cached_remote_plugin_root(codex_home, plugin_name); + let plugin_id = PluginId::new( + plugin_name.to_string(), + REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), + ) + .expect("plugin id"); + PluginStore::new(codex_home.to_path_buf()) + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample") + .expect("write remote plugin id"); + root +} +fn script_fixture() -> (TempDir, AbsolutePathBuf, AbsolutePathBuf) { + let temp = TempDir::new().expect("temp dir"); + write_curated_plugin_sha_with(temp.path(), TEST_CURATED_PLUGIN_SHA); + let root = synced_plugin_root(temp.path(), OPENAI_CURATED_MARKETPLACE_NAME, "sample"); + let script = root.join("scripts/run.py"); + fs::create_dir_all(script.as_path().parent().expect("script parent")).expect("create scripts"); + fs::write(script.as_path(), "#!/usr/bin/env python3\n").expect("write script"); + let script = script.canonicalize().expect("canonical script"); + (temp, root, script) +} +fn roots_for(codex_home: &Path, plugins: Vec) -> TrustedPluginRoots { + TrustedPluginRoots::from_plugin_load_outcome( + &PluginLoadOutcome::from_plugins(plugins), + codex_home, + ) +} +fn assert_untrusted(codex_home: &Path, config_name: &str, root: &Path) { + assert!( + roots_for(codex_home, vec![loaded_plugin(config_name, root, ENABLED)]) + .roots + .is_empty() + ); +} +fn command(parts: &[&str]) -> Vec { + parts.iter().map(ToString::to_string).collect() +} + +#[test] +fn trusted_roots_require_verified_curated_or_remote_cache() { + let temp = TempDir::new().expect("temp dir"); + write_curated_plugin_sha_with(temp.path(), TEST_CURATED_PLUGIN_SHA); + let root = synced_plugin_root(temp.path(), OPENAI_CURATED_MARKETPLACE_NAME, "sample"); + let api_root = synced_plugin_root( + temp.path(), + OPENAI_API_CURATED_MARKETPLACE_NAME, + "api-sample", + ); + let remote_root = installed_remote_plugin_root(temp.path(), "remote-sample"); + let unverified_remote_root = cached_remote_plugin_root(temp.path(), "unverified-remote"); + let _ = installed_remote_plugin_root(temp.path(), "overridden-remote"); + let overridden_remote_plugin_id = + PluginId::parse("overridden-remote@openai-curated-remote").expect("plugin id"); + let remote_local_override = PluginStore::new(temp.path().to_path_buf()) + .plugin_root(&overridden_remote_plugin_id, DEFAULT_PLUGIN_VERSION); + let local_root = temp + .path() + .join("plugins/cache/openai-curated/sample/local"); + let spoofed_root = temp.path().join("spoofed/openai-curated/sample"); + let spoofed_remote_root = temp + .path() + .join("spoofed/openai-curated-remote/remote-sample"); + fs::create_dir_all(&local_root).expect("create local root"); + fs::create_dir_all(&spoofed_root).expect("create spoofed root"); + fs::create_dir_all(&spoofed_remote_root).expect("create spoofed remote root"); + fs::create_dir_all(remote_local_override.as_path()).expect("create remote local override"); + let roots = roots_for( + temp.path(), + vec![ + loaded_plugin("sample@openai-curated", root.as_path(), ENABLED), + loaded_plugin("api-sample@openai-api-curated", api_root.as_path(), ENABLED), + loaded_plugin( + "remote-sample@openai-curated-remote", + remote_root.as_path(), + ENABLED, + ), + loaded_plugin("sample@openai-curated", &local_root, ENABLED), + loaded_plugin("sample@openai-curated", &spoofed_root, ENABLED), + loaded_plugin("disabled@openai-curated", root.as_path(), DISABLED), + ], + ); + assert_eq!( + roots.roots, + vec![ + TrustedPluginRoot { + plugin_id: PluginId::parse("sample@openai-curated").expect("plugin id"), + root: root.canonicalize().expect("canonical root"), + }, + TrustedPluginRoot { + plugin_id: PluginId::parse("api-sample@openai-api-curated").expect("plugin id"), + root: api_root.canonicalize().expect("canonical root"), + }, + TrustedPluginRoot { + plugin_id: PluginId::parse("remote-sample@openai-curated-remote") + .expect("plugin id"), + root: remote_root.canonicalize().expect("canonical root"), + }, + ] + ); + assert_untrusted( + temp.path(), + "unverified-remote@openai-curated-remote", + unverified_remote_root.as_path(), + ); + assert_untrusted( + temp.path(), + "remote-sample@openai-curated-remote", + &spoofed_remote_root, + ); + assert_untrusted( + temp.path(), + "overridden-remote@openai-curated-remote", + remote_local_override.as_path(), + ); + #[cfg(unix)] + { + let alias = temp.path().join("sample-alias"); + std::os::unix::fs::symlink(root.as_path(), &alias).expect("symlink root"); + assert_untrusted(temp.path(), "sample@openai-curated", &alias); + } + let _ = synced_plugin_root(temp.path(), OPENAI_CURATED_MARKETPLACE_NAME, "listed"); + let unlisted_root = PluginStore::new(temp.path().to_path_buf()).plugin_root( + &PluginId::parse("missing@openai-curated").expect("plugin id"), + &curated_plugin_cache_version(TEST_CURATED_PLUGIN_SHA), + ); + fs::create_dir_all(unlisted_root.as_path()).expect("create unlisted root"); + assert_untrusted( + temp.path(), + "missing@openai-curated", + unlisted_root.as_path(), + ); + let no_sha = TempDir::new().expect("temp dir"); + let no_sha_root = synced_plugin_root(no_sha.path(), OPENAI_CURATED_MARKETPLACE_NAME, "sample"); + assert_untrusted( + no_sha.path(), + "sample@openai-curated", + no_sha_root.as_path(), + ); +} + +#[test] +fn resolves_local_attribution_for_safe_interpreters_and_wrappers() { + let (temp, root, script) = script_fixture(); + let roots = roots_for( + temp.path(), + vec![loaded_plugin( + "sample@openai-curated", + root.as_path(), + ENABLED, + )], + ); + let expected = Some(PluginCommandAttribution { + plugin_id: PluginId::parse("sample@openai-curated").expect("plugin id"), + normalized_relative_path: "scripts/run.py".to_string(), + }); + let script = script.to_string_lossy().to_string(); + let unix_wrapper = format!("python -u {script}"); + for command in [ + command(&["scripts/run.py"]), + command(&["/usr/bin/python", "-u", &script]), + command(&["sh", "-e", &script]), + command(&["bash", "-e", &script]), + command(&["zsh", "-e", &script]), + command(&["pwsh", "-File", &script]), + command(&["powershell", "-File", &script]), + command(&["bash", "-lc", &unix_wrapper]), + command(&["pwsh.exe", "-NoProfile", "-Command", "scripts/run.py"]), + command(&["cmd.exe", "/c", "scripts/run.py"]), + ] { + assert_eq!(roots.resolve_attribution(&command, &root), expected); + } +} + +#[test] +fn only_emits_safe_normalized_relative_script_paths() { + assert_eq!( + normalized_relative_script_path(Path::new("scripts/run.py")), + Some("scripts/run.py".to_string()) + ); + assert_eq!( + normalized_relative_script_path(Path::new( + "/home/user/.codex/plugins/cache/openai-curated/sample/scripts/run.py" + )), + None + ); +} + +#[test] +fn rejects_ambiguous_commands_overlaps_and_symlink_escapes() { + let (temp, root, script) = script_fixture(); + let roots = roots_for( + temp.path(), + vec![loaded_plugin( + "sample@openai-curated", + root.as_path(), + ENABLED, + )], + ); + let script = script.to_string_lossy().to_string(); + let complex = format!("python {script} && echo done"); + for command in [ + command(&["bash", "-lc", &complex]), + command(&["node", "--require", "scripts/bootstrap.js", &script]), + command(&["python", "-m", "scripts.run"]), + command(&[ + "pwsh.exe", + "-NoProfile", + "-Command", + "scripts/run.py; echo done", + ]), + command(&["python", "scripts/missing.py"]), + ] { + assert_eq!(roots.resolve_attribution(&command, &root), None); + } + let overlapping = TrustedPluginRoots { + roots: vec![ + TrustedPluginRoot { + plugin_id: PluginId::parse("sample@openai-curated").expect("plugin id"), + root: root.canonicalize().expect("canonical root"), + }, + TrustedPluginRoot { + plugin_id: PluginId::parse("nested@openai-curated").expect("plugin id"), + root: root.join("scripts").canonicalize().expect("nested root"), + }, + ], + }; + assert_eq!( + overlapping.resolve_attribution(&command(&["scripts/run.py"]), &root), + None + ); + #[cfg(unix)] + { + let outside = temp.path().join("outside.py"); + fs::write(&outside, "print('outside')\n").expect("write outside script"); + std::os::unix::fs::symlink(&outside, root.join("scripts/escape.py")).expect("symlink"); + assert_eq!( + roots.resolve_attribution(&command(&["python", "scripts/escape.py"]), &root), + None + ); + + for unsafe_name in [r"scripts\run.py", "C:run.py"] { + let unsafe_script = root.join(unsafe_name); + fs::write(unsafe_script.as_path(), "print('unsafe')\n").expect("write unsafe script"); + assert_eq!( + roots.resolve_attribution( + &command(&["python", &unsafe_script.to_string_lossy()]), + &root, + ), + None + ); + } + } +} diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index bcaf2ee3ab..df9b4d68f4 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -180,6 +180,24 @@ impl From for CommandExecutionStatus { } } +/// Returns whether a path is safe to serialize as a trusted plugin-relative path. +/// +/// This validates the cross-platform wire shape only. The trusted plugin resolver +/// remains responsible for establishing that the path actually came from a plugin root. +pub fn is_safe_plugin_relative_path(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path.contains('\\') + && path.split('/').all(|component| { + !component.is_empty() + && !matches!(component, "." | "..") + && !matches!( + component.as_bytes(), + [drive, b':', ..] if drive.is_ascii_alphabetic() + ) + }) +} + #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct CommandExecutionItem { pub id: String, @@ -704,6 +722,28 @@ mod tests { ); } + #[test] + fn plugin_relative_paths_use_safe_wire_shape() { + assert!(is_safe_plugin_relative_path("scripts/run.py")); + + for path in [ + "", + "/home/user/.codex/plugins/cache/sample/scripts/run.py", + "C:/Users/user/.codex/plugins/cache/sample/scripts/run.py", + "scripts/C:/run.py", + r"\\server\share\sample\scripts\run.py", + r"scripts\run.py", + "scripts//run.py", + "scripts/./run.py", + "scripts/../run.py", + ] { + assert!( + !is_safe_plugin_relative_path(path), + "unsafe plugin-relative path should be rejected: {path:?}" + ); + } + } + #[test] fn hook_prompt_roundtrips_multiple_fragments() { let original = vec![ diff --git a/codex-rs/shell-command/src/parse_command.rs b/codex-rs/shell-command/src/parse_command.rs index 72e1aca67d..7f5e8942d7 100644 --- a/codex-rs/shell-command/src/parse_command.rs +++ b/codex-rs/shell-command/src/parse_command.rs @@ -1747,7 +1747,8 @@ fn cd_target(args: &[String]) -> Option { target } -fn is_pathish(s: &str) -> bool { +/// Returns whether a command token has an explicit path shape. +pub fn is_pathish(s: &str) -> bool { s == "." || s == ".." || s.starts_with("./")