mirror of
https://github.com/openai/codex.git
synced 2026-09-07 15:40:00 +00:00
## Why An `apply_patch` path can be replaced with a symlink after verification, allowing an unsandboxed patch operation to reach a different file than the one that was approved. ## What changed - Add `follow_symlinks` options to executor filesystem reads, writes, metadata lookups, directory creation, and removal, including the corresponding `followSymlinks` protocol fields. - Implement no-follow filesystem operations on Unix and Windows that reject links in any path component and restrict file access to regular files. - Run `apply_patch` with symlink traversal disabled when an otherwise-required sandbox is bypassed, while retaining the existing follow-symlink default for standalone callers. ## Testing - Cover leaf and ancestor symlinks across patch add, update, delete, and move operations, including a path swap after verification. - Exercise local and remote no-follow filesystem behavior, concurrent directory creation, special-file rejection, and Windows reparse points. GitOrigin-RevId: 43fd479084891493ce13564fbd894b98f329c6dd
373 lines
15 KiB
Rust
373 lines
15 KiB
Rust
//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`.
|
|
|
|
use codex_exec_server::ExecutorFileSystem;
|
|
use codex_exec_server::GetMetadataOptions;
|
|
use codex_exec_server::ReadFileOptions;
|
|
use codex_exec_server_protocol::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
|
|
use codex_utils_path_uri::PathUri;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
|
|
pub const AGENT_PLUGIN_MANIFEST_RELATIVE_PATH: &str = "plugin.json";
|
|
/// Published Agent Plugins v1 manifest schema:
|
|
/// https://github.com/agentplugins/agent-plugins-spec/blob/main/schemas/1.0.0/plugin.schema.json
|
|
pub const AGENT_PLUGIN_SCHEMA_URI: &str =
|
|
"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
|
pub const SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS: &[&str] = &[AGENT_PLUGIN_SCHEMA_URI];
|
|
pub const AGENT_PLUGIN_SCHEMA_PREFIX: &str = "https://agent-plugins.org/schemas/";
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum AgentPluginSchemaStatus {
|
|
Supported,
|
|
Unsupported,
|
|
Unrelated,
|
|
}
|
|
|
|
pub fn agent_plugin_schema_status(contents: &str) -> AgentPluginSchemaStatus {
|
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(contents) else {
|
|
return AgentPluginSchemaStatus::Unrelated;
|
|
};
|
|
let Some(schema) = value.get("$schema").and_then(serde_json::Value::as_str) else {
|
|
return AgentPluginSchemaStatus::Unrelated;
|
|
};
|
|
if SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS.contains(&schema) {
|
|
AgentPluginSchemaStatus::Supported
|
|
} else if schema.starts_with(AGENT_PLUGIN_SCHEMA_PREFIX) {
|
|
AgentPluginSchemaStatus::Unsupported
|
|
} else {
|
|
AgentPluginSchemaStatus::Unrelated
|
|
}
|
|
}
|
|
|
|
pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option<PathBuf> {
|
|
let agent_manifest_path = plugin_root.join(AGENT_PLUGIN_MANIFEST_RELATIVE_PATH);
|
|
match std::fs::symlink_metadata(&agent_manifest_path) {
|
|
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.file_type().is_file() => {
|
|
return None;
|
|
}
|
|
Ok(_) => {
|
|
if std::fs::read_to_string(&agent_manifest_path)
|
|
.ok()
|
|
.is_some_and(|contents| {
|
|
agent_plugin_schema_status(&contents) != AgentPluginSchemaStatus::Unrelated
|
|
})
|
|
{
|
|
return Some(agent_manifest_path);
|
|
}
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(_) => return None,
|
|
}
|
|
|
|
for relative_path in DISCOVERABLE_PLUGIN_MANIFEST_PATHS {
|
|
let manifest_path = plugin_root.join(relative_path);
|
|
let manifest_parent = manifest_path.parent()?;
|
|
match std::fs::symlink_metadata(manifest_parent) {
|
|
Ok(metadata) if !metadata.file_type().is_dir() => return None,
|
|
Ok(_) => {}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
|
|
Err(_) => return None,
|
|
}
|
|
match std::fs::symlink_metadata(&manifest_path) {
|
|
Ok(metadata) if metadata.file_type().is_file() => return Some(manifest_path),
|
|
Ok(_) => return None,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(_) => return None,
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawPluginManifestName {
|
|
#[serde(default)]
|
|
name: String,
|
|
}
|
|
|
|
/// Returns the plugin manifest `name` defined directly below `plugin_root`.
|
|
pub async fn plugin_namespace_for_root_uri(
|
|
fs: &dyn ExecutorFileSystem,
|
|
plugin_root: &PathUri,
|
|
) -> Option<String> {
|
|
let mut manifest_path = None;
|
|
for relative_path in DISCOVERABLE_PLUGIN_MANIFEST_PATHS {
|
|
let candidate = plugin_root.join(relative_path).ok()?;
|
|
match fs
|
|
.get_metadata(
|
|
&candidate,
|
|
GetMetadataOptions::default(),
|
|
/*sandbox*/ None,
|
|
)
|
|
.await
|
|
{
|
|
Ok(metadata) if metadata.is_file => {
|
|
manifest_path = Some(candidate);
|
|
break;
|
|
}
|
|
Ok(_) | Err(_) => {}
|
|
}
|
|
}
|
|
let contents = fs
|
|
.read_file_text(
|
|
&manifest_path?,
|
|
ReadFileOptions::default(),
|
|
/*sandbox*/ None,
|
|
)
|
|
.await
|
|
.ok()?;
|
|
let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?;
|
|
Some(
|
|
plugin_root
|
|
.basename()
|
|
.filter(|_| raw_name.trim().is_empty())
|
|
.unwrap_or(raw_name),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::AGENT_PLUGIN_MANIFEST_RELATIVE_PATH;
|
|
use super::AGENT_PLUGIN_SCHEMA_URI;
|
|
use super::find_plugin_manifest_path;
|
|
use super::plugin_namespace_for_root_uri;
|
|
use codex_exec_server::LOCAL_FS;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use codex_utils_path_uri::PathUri;
|
|
use std::fs;
|
|
use tempfile::tempdir;
|
|
|
|
const ALTERNATE_PLUGIN_CLA_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
|
const ALTERNATE_PLUGIN_CUR_MANIFEST_RELATIVE_PATH: &str = ".cursor-plugin/plugin.json";
|
|
|
|
#[tokio::test]
|
|
async fn uses_manifest_name() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let plugin_root = tmp.path().join("plugins/sample");
|
|
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
|
|
|
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest");
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
)
|
|
.expect("write manifest");
|
|
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
|
|
|
|
assert_eq!(
|
|
plugin_namespace_for_root_uri(
|
|
LOCAL_FS.as_ref(),
|
|
&PathUri::from_abs_path(&plugin_root.abs()),
|
|
)
|
|
.await,
|
|
Some("sample".to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uses_name_from_alternate_discoverable_manifest_path() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let plugin_root = tmp.path().join("plugins/sample");
|
|
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
|
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_CLA_MANIFEST_RELATIVE_PATH);
|
|
|
|
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
|
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
|
|
.expect("mkdir manifest");
|
|
fs::write(&manifest_path, r#"{"name":"sample"}"#).expect("write manifest");
|
|
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
|
|
|
|
assert_eq!(
|
|
plugin_namespace_for_root_uri(
|
|
LOCAL_FS.as_ref(),
|
|
&PathUri::from_abs_path(&plugin_root.abs()),
|
|
)
|
|
.await,
|
|
Some("sample".to_string())
|
|
);
|
|
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uses_name_from_cur_plugin_manifest_path() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let plugin_root = tmp.path().join("plugins/sample");
|
|
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
|
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_CUR_MANIFEST_RELATIVE_PATH);
|
|
|
|
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
|
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
|
|
.expect("mkdir manifest");
|
|
fs::write(&manifest_path, r#"{"name":"sample"}"#).expect("write manifest");
|
|
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
|
|
|
|
assert_eq!(
|
|
plugin_namespace_for_root_uri(
|
|
LOCAL_FS.as_ref(),
|
|
&PathUri::from_abs_path(&plugin_root.abs()),
|
|
)
|
|
.await,
|
|
Some("sample".to_string())
|
|
);
|
|
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
|
|
}
|
|
|
|
#[test]
|
|
fn recognizes_schema_declared_root_plugin_manifest() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let plugin_root = tmp.path().join("plugins/portable");
|
|
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
|
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
|
let manifest_path = plugin_root.join(AGENT_PLUGIN_MANIFEST_RELATIVE_PATH);
|
|
fs::write(
|
|
&manifest_path,
|
|
format!(r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"portable"}}"#),
|
|
)
|
|
.expect("write manifest");
|
|
|
|
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_unrelated_root_plugin_manifest_before_legacy_fallback() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let plugin_root = tmp.path().join("plugins/sample");
|
|
let legacy_path = plugin_root.join(".codex-plugin/plugin.json");
|
|
fs::create_dir_all(legacy_path.parent().expect("parent")).expect("mkdir");
|
|
fs::write(plugin_root.join("plugin.json"), r#"{"name":"npm-package"}"#)
|
|
.expect("write unrelated root");
|
|
fs::write(&legacy_path, r#"{"name":"sample"}"#).expect("write legacy");
|
|
|
|
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(legacy_path));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_nonregular_root_plugin_manifest() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let plugin_root = tmp.path().join("plugins/sample");
|
|
let legacy_path = plugin_root.join(".codex-plugin/plugin.json");
|
|
fs::create_dir_all(plugin_root.join("plugin.json")).expect("root manifest directory");
|
|
fs::create_dir_all(legacy_path.parent().expect("parent")).expect("legacy parent");
|
|
fs::write(&legacy_path, r#"{"name":"sample"}"#).expect("legacy manifest");
|
|
|
|
assert_eq!(find_plugin_manifest_path(&plugin_root), None);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn rejects_symlinked_root_plugin_manifest() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let plugin_root = tmp.path().join("plugins/sample");
|
|
let manifest_target = tmp.path().join("manifest.json");
|
|
let legacy_path = plugin_root.join(".codex-plugin/plugin.json");
|
|
fs::create_dir_all(&plugin_root).expect("plugin root");
|
|
fs::write(
|
|
&manifest_target,
|
|
format!(r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"sample"}}"#),
|
|
)
|
|
.expect("manifest target");
|
|
std::os::unix::fs::symlink(&manifest_target, plugin_root.join("plugin.json"))
|
|
.expect("root manifest symlink");
|
|
fs::create_dir_all(legacy_path.parent().expect("parent")).expect("legacy parent");
|
|
fs::write(&legacy_path, r#"{"name":"sample"}"#).expect("legacy manifest");
|
|
|
|
assert_eq!(find_plugin_manifest_path(&plugin_root), None);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_nonregular_legacy_plugin_manifest_before_lower_precedence_manifest() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let plugin_root = tmp.path().join("plugins/sample");
|
|
let codex_path = plugin_root.join(".codex-plugin/plugin.json");
|
|
let claude_path = plugin_root.join(ALTERNATE_PLUGIN_CLA_MANIFEST_RELATIVE_PATH);
|
|
fs::create_dir_all(&codex_path).expect("nonregular Codex manifest");
|
|
fs::create_dir_all(claude_path.parent().expect("Claude manifest parent"))
|
|
.expect("Claude manifest parent");
|
|
fs::write(&claude_path, r#"{"name":"sample"}"#).expect("Claude manifest");
|
|
|
|
assert_eq!(find_plugin_manifest_path(&plugin_root), None);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn rejects_symlinked_legacy_plugin_manifest_before_lower_precedence_manifest() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let plugin_root = tmp.path().join("plugins/sample");
|
|
let codex_path = plugin_root.join(".codex-plugin/plugin.json");
|
|
let claude_path = plugin_root.join(ALTERNATE_PLUGIN_CLA_MANIFEST_RELATIVE_PATH);
|
|
fs::create_dir_all(codex_path.parent().expect("Codex manifest parent"))
|
|
.expect("Codex manifest parent");
|
|
fs::create_dir_all(claude_path.parent().expect("Claude manifest parent"))
|
|
.expect("Claude manifest parent");
|
|
fs::write(plugin_root.join("benign.json"), r#"{"name":"sample"}"#)
|
|
.expect("benign manifest");
|
|
fs::write(&claude_path, r#"{"name":"sample"}"#).expect("Claude manifest");
|
|
std::os::unix::fs::symlink("../benign.json", &codex_path).expect("Codex manifest symlink");
|
|
|
|
assert_eq!(find_plugin_manifest_path(&plugin_root), None);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn rejects_symlinked_legacy_plugin_manifest_directory_before_lower_precedence_manifest() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let plugin_root = tmp.path().join("plugins/sample");
|
|
let manifest_directory = tmp.path().join("manifest-directory");
|
|
let claude_path = plugin_root.join(ALTERNATE_PLUGIN_CLA_MANIFEST_RELATIVE_PATH);
|
|
fs::create_dir_all(&manifest_directory).expect("manifest target directory");
|
|
fs::create_dir_all(claude_path.parent().expect("Claude manifest parent"))
|
|
.expect("Claude manifest parent");
|
|
fs::write(
|
|
manifest_directory.join("plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
)
|
|
.expect("benign manifest");
|
|
fs::write(&claude_path, r#"{"name":"sample"}"#).expect("Claude manifest");
|
|
std::os::unix::fs::symlink(&manifest_directory, plugin_root.join(".codex-plugin"))
|
|
.expect("Codex manifest directory symlink");
|
|
|
|
assert_eq!(find_plugin_manifest_path(&plugin_root), None);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn rejects_symlinked_claude_manifest_before_cursor_manifest() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let plugin_root = tmp.path().join("plugins/sample");
|
|
let claude_path = plugin_root.join(ALTERNATE_PLUGIN_CLA_MANIFEST_RELATIVE_PATH);
|
|
let cursor_path = plugin_root.join(ALTERNATE_PLUGIN_CUR_MANIFEST_RELATIVE_PATH);
|
|
fs::create_dir_all(claude_path.parent().expect("Claude manifest parent"))
|
|
.expect("Claude manifest parent");
|
|
fs::create_dir_all(cursor_path.parent().expect("Cursor manifest parent"))
|
|
.expect("Cursor manifest parent");
|
|
let manifest_target = plugin_root.join("benign.json");
|
|
fs::write(&manifest_target, r#"{"name":"sample"}"#).expect("benign manifest");
|
|
fs::write(&cursor_path, r#"{"name":"sample"}"#).expect("Cursor manifest");
|
|
std::os::unix::fs::symlink(&manifest_target, &claude_path)
|
|
.expect("Claude manifest symlink");
|
|
|
|
assert_eq!(find_plugin_manifest_path(&plugin_root), None);
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_codex_claude_cursor_legacy_precedence() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let plugin_root = tmp.path().join("plugins/sample");
|
|
let codex_path = plugin_root.join(".codex-plugin/plugin.json");
|
|
let claude_path = plugin_root.join(".claude-plugin/plugin.json");
|
|
let cursor_path = plugin_root.join(".cursor-plugin/plugin.json");
|
|
for path in [&codex_path, &claude_path, &cursor_path] {
|
|
fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
|
|
fs::write(path, r#"{"name":"sample"}"#).expect("write manifest");
|
|
}
|
|
|
|
assert_eq!(
|
|
find_plugin_manifest_path(&plugin_root),
|
|
Some(codex_path.clone())
|
|
);
|
|
fs::remove_file(codex_path).expect("remove Codex manifest");
|
|
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(claude_path));
|
|
}
|
|
}
|