mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
Add trusted plugin script attribution (#35016)
## What changed - Build a set of active, verified curated and remote plugin roots from loaded plugins. - Resolve direct and safely wrapped script commands to a plugin ID and normalized plugin-relative path. - Leave complex or ambiguous commands, local overrides, missing files, overlapping roots, and symlink escapes unattributed. - Add a shared validator for the safe cross-platform shape of serialized plugin-relative paths. ## Testing - Cover trusted-root selection, supported interpreters and shell wrappers, normalized paths, and fail-closed cases. GitOrigin-RevId: 6e4199a241fd6dfadfec3df0845e7cb615352a49
This commit is contained in:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -2794,6 +2794,7 @@ dependencies = [
|
||||
"codex-otel",
|
||||
"codex-plugin",
|
||||
"codex-protocol",
|
||||
"codex-shell-command",
|
||||
"codex-skills",
|
||||
"codex-tools",
|
||||
"codex-utils-absolute-path",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
289
codex-rs/core-plugins/src/script_attribution.rs
Normal file
289
codex-rs/core-plugins/src/script_attribution.rs
Normal file
@@ -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<TrustedPluginRoot>,
|
||||
}
|
||||
|
||||
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<PluginCommandAttribution> {
|
||||
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<String> {
|
||||
let normalized = relative_path
|
||||
.components()
|
||||
.map(|component| {
|
||||
let Component::Normal(component) = component else {
|
||||
return None;
|
||||
};
|
||||
component.to_str()
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?
|
||||
.join("/");
|
||||
|
||||
is_safe_plugin_relative_path(&normalized).then_some(normalized)
|
||||
}
|
||||
|
||||
fn single_plain_command(command: &[String]) -> Option<Vec<String>> {
|
||||
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<String> {
|
||||
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;
|
||||
324
codex-rs/core-plugins/src/script_attribution_tests.rs
Normal file
324
codex-rs/core-plugins/src/script_attribution_tests.rs
Normal file
@@ -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<LoadedPlugin>) -> 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<String> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,24 @@ impl From<ExecCommandStatus> 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![
|
||||
|
||||
@@ -1747,7 +1747,8 @@ fn cd_target(args: &[String]) -> Option<String> {
|
||||
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("./")
|
||||
|
||||
Reference in New Issue
Block a user