Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions codex-rs/core-plugins/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/core-plugins/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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 changes: 289 additions & 0 deletions codex-rs/core-plugins/src/script_attribution.rs
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading