mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Enforce Agent Plugin runtime boundaries (#37027)
## What changed - Track Agent Plugin manifests through plugin, skill, and MCP loading so their capabilities use format-specific behavior without changing legacy plugins. - Discover only direct-child skills, exclude app and hook capabilities, isolate MCP data, and reject MCP configuration files that are non-regular or resolve outside the plugin root. - Bound model-visible skill instructions, plugin instructions, MCP descriptions, schemas, individual tools, and the aggregate Agent Plugin MCP tool set. - Stop MCP and OAuth redirects when Agent Plugins send configured or authorization headers, while retaining existing redirect behavior for legacy MCP servers. ## Testing - Add coverage for capability filtering, skill discovery, isolated MCP data and reserved-path expansion, unsafe MCP configuration files, context limits, and redirect handling. GitOrigin-RevId: c9af66b051269f3226628ca280a58d32c808c38f
This commit is contained in:
1 parent
f21dc46388
commit
56b82e676c
63 files changed
+1830
-106
No files matched your search
@@ -27,6 +27,7 @@ codex-skills = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-path-uri = { workspace = true }
|
||||
codex-utils-plugins = { workspace = true }
|
||||
codex-utils-string = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -16,6 +16,9 @@ use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
|
||||
use crate::MAX_SKILL_PROMPT_BYTES;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SkillInjections {
|
||||
@@ -93,6 +96,18 @@ pub async fn build_skill_injections(
|
||||
let path = PathUri::from_abs_path(&skill.path_to_skills_md);
|
||||
match fs.read_file_text(&path, /*sandbox*/ None).await {
|
||||
Ok(contents) => {
|
||||
let (contents, truncated) =
|
||||
if loaded_skills.is_some_and(|outcome| outcome.is_agent_plugin_skill(skill)) {
|
||||
bounded_skill_prompt_contents(&contents)
|
||||
} else {
|
||||
(contents, false)
|
||||
};
|
||||
if truncated {
|
||||
result.warnings.push(format!(
|
||||
"Skill `{}` exceeded the main prompt context limit and was truncated.",
|
||||
skill.name
|
||||
));
|
||||
}
|
||||
emit_skill_injected_metric(otel, skill, "ok");
|
||||
invocations.push(SkillInvocation {
|
||||
skill_name: skill.name.clone(),
|
||||
@@ -125,6 +140,11 @@ pub async fn build_skill_injections(
|
||||
result
|
||||
}
|
||||
|
||||
fn bounded_skill_prompt_contents(contents: &str) -> (String, bool) {
|
||||
let bounded = take_bytes_at_char_boundary(contents, MAX_SKILL_PROMPT_BYTES);
|
||||
(bounded.to_string(), bounded.len() < contents.len())
|
||||
}
|
||||
|
||||
fn normalize_host_skill_path(path: &str) -> String {
|
||||
normalize_skill_path(path).replace('\\', "/")
|
||||
}
|
||||
|
||||
@@ -21,6 +21,16 @@ fn make_skill(name: &str, path: &str) -> SkillMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_prompt_contents_are_bounded_at_utf8_boundaries() {
|
||||
let contents = format!("{}é", "a".repeat(MAX_SKILL_PROMPT_BYTES - 1));
|
||||
|
||||
let (bounded, truncated) = bounded_skill_prompt_contents(&contents);
|
||||
|
||||
assert_eq!(bounded.len(), MAX_SKILL_PROMPT_BYTES - 1);
|
||||
assert_eq!(truncated, true);
|
||||
}
|
||||
|
||||
fn set<'a>(items: &'a [&'a str]) -> HashSet<&'a str> {
|
||||
items.iter().copied().collect()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ pub mod remote;
|
||||
mod root_loader;
|
||||
mod skill_instructions;
|
||||
|
||||
/// Hard byte limit for one model-visible skill instruction body.
|
||||
///
|
||||
/// Both the legacy explicit-injection path and the skills extension use this
|
||||
/// limit so a skill cannot bypass context bounds by changing how it is loaded.
|
||||
pub const MAX_SKILL_PROMPT_BYTES: usize = 8_000;
|
||||
|
||||
pub(crate) use invocation_utils::build_implicit_skill_path_indexes;
|
||||
pub use invocation_utils::detect_implicit_skill_invocation_for_command;
|
||||
pub use mention_counts::build_skill_name_counts;
|
||||
|
||||
@@ -95,7 +95,7 @@ const SKILLS_FILENAME: &str = "SKILL.md";
|
||||
const SKILLS_METADATA_DIR: &str = "agents";
|
||||
const SKILLS_METADATA_FILENAME: &str = "openai.yaml";
|
||||
const MAX_NAME_LEN: usize = 64;
|
||||
const MAX_QUALIFIED_NAME_LEN: usize = 128;
|
||||
const MAX_QUALIFIED_NAME_LEN: usize = MAX_NAME_LEN * 2 + 1;
|
||||
const MAX_DESCRIPTION_LEN: usize = 1024;
|
||||
const MAX_DEPENDENCY_TYPE_LEN: usize = MAX_NAME_LEN;
|
||||
const MAX_DEPENDENCY_TRANSPORT_LEN: usize = MAX_NAME_LEN;
|
||||
@@ -163,6 +163,7 @@ where
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SkillRootSnapshot {
|
||||
pub(crate) root: AbsolutePathBuf,
|
||||
pub(crate) is_agent_plugin: bool,
|
||||
pub(crate) skills: Vec<SkillMetadata>,
|
||||
pub(crate) errors: Vec<SkillError>,
|
||||
pub(crate) file_system: Arc<dyn ExecutorFileSystem>,
|
||||
@@ -175,6 +176,7 @@ pub(crate) async fn load_skill_root(root: SkillRoot) -> SkillRootSnapshot {
|
||||
load_skills_under_root(&root, &canonical_root, &mut outcome).await;
|
||||
SkillRootSnapshot {
|
||||
root: canonical_root,
|
||||
is_agent_plugin: root.discovery_mode == SkillDiscoveryMode::DirectChildren,
|
||||
skills: outcome.skills,
|
||||
errors: outcome.errors,
|
||||
file_system: root.file_system,
|
||||
|
||||
@@ -1202,7 +1202,7 @@ async fn keeps_inherited_namespace_when_symlink_target_is_scan_root_ancestor() {
|
||||
#[tokio::test]
|
||||
async fn plugin_skill_name_length_limit_allows_max_qualified_name() {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
let plugin_name = "p".repeat(MAX_NAME_LEN - 1);
|
||||
let plugin_name = "p".repeat(MAX_NAME_LEN);
|
||||
let skill_name = "s".repeat(MAX_NAME_LEN);
|
||||
let plugin_root = root.path().join("plugins").join(&plugin_name);
|
||||
let frontmatter = format!("name: {skill_name}\ndescription: search sample data");
|
||||
@@ -1257,7 +1257,7 @@ async fn plugin_skill_name_length_limit_allows_max_qualified_name() {
|
||||
#[tokio::test]
|
||||
async fn plugin_skill_name_length_limit_rejects_overlong_qualified_name() {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
let plugin_name = "p".repeat(MAX_NAME_LEN);
|
||||
let plugin_name = "p".repeat(MAX_NAME_LEN + 1);
|
||||
let skill_name = "s".repeat(MAX_NAME_LEN);
|
||||
let plugin_root = root.path().join("plugins").join(&plugin_name);
|
||||
let frontmatter = format!("name: {skill_name}\ndescription: search sample data");
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct SkillLoadOutcome {
|
||||
pub disabled_paths: HashSet<AbsolutePathBuf>,
|
||||
pub(crate) skill_roots: Vec<AbsolutePathBuf>,
|
||||
pub(crate) skill_root_by_path: Arc<HashMap<AbsolutePathBuf, AbsolutePathBuf>>,
|
||||
pub(crate) agent_plugin_skill_paths: HashSet<AbsolutePathBuf>,
|
||||
pub(crate) file_systems_by_skill_path: SkillFileSystemsByPath,
|
||||
pub(crate) implicit_skills_by_scripts_dir: Arc<HashMap<AbsolutePathBuf, SkillMetadata>>,
|
||||
pub(crate) implicit_skills_by_doc_path: Arc<HashMap<AbsolutePathBuf, SkillMetadata>>,
|
||||
@@ -70,6 +71,11 @@ impl SkillLoadOutcome {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_agent_plugin_skill(&self, skill: &SkillMetadata) -> bool {
|
||||
self.agent_plugin_skill_paths
|
||||
.contains(&skill.path_to_skills_md)
|
||||
}
|
||||
|
||||
/// Returns the discovery root that supplied a loaded skill path.
|
||||
pub fn skill_root_for_path(&self, path: &AbsolutePathBuf) -> Option<&AbsolutePathBuf> {
|
||||
self.skill_root_by_path.get(path)
|
||||
@@ -173,6 +179,9 @@ pub fn filter_skill_load_outcome_for_product(
|
||||
.map(|(path, root)| (path.clone(), root.clone()))
|
||||
.collect(),
|
||||
);
|
||||
outcome
|
||||
.agent_plugin_skill_paths
|
||||
.retain(|path| retained_paths.contains(path));
|
||||
let retained_roots: HashSet<AbsolutePathBuf> =
|
||||
outcome.skill_root_by_path.values().cloned().collect();
|
||||
outcome
|
||||
|
||||
@@ -150,6 +150,7 @@ fn merge_skill_root_snapshots(snapshots: Vec<SkillRootSnapshot>) -> SkillLoadOut
|
||||
for snapshot in snapshots {
|
||||
let SkillRootSnapshot {
|
||||
root,
|
||||
is_agent_plugin,
|
||||
skills,
|
||||
errors,
|
||||
file_system,
|
||||
@@ -158,12 +159,14 @@ fn merge_skill_root_snapshots(snapshots: Vec<SkillRootSnapshot>) -> SkillLoadOut
|
||||
skill_roots.push(root.clone());
|
||||
}
|
||||
for skill in &skills {
|
||||
skill_root_by_path
|
||||
.entry(skill.path_to_skills_md.clone())
|
||||
.or_insert_with(|| root.clone());
|
||||
file_systems_by_skill_path
|
||||
.entry(skill.path_to_skills_md.clone())
|
||||
.or_insert_with(|| Arc::clone(&file_system));
|
||||
let path = skill.path_to_skills_md.clone();
|
||||
if !skill_root_by_path.contains_key(&path) {
|
||||
skill_root_by_path.insert(path.clone(), root.clone());
|
||||
file_systems_by_skill_path.insert(path.clone(), Arc::clone(&file_system));
|
||||
if is_agent_plugin {
|
||||
outcome.agent_plugin_skill_paths.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
outcome.skills.extend(skills);
|
||||
outcome.errors.extend(errors);
|
||||
@@ -182,6 +185,9 @@ fn merge_skill_root_snapshots(snapshots: Vec<SkillRootSnapshot>) -> SkillLoadOut
|
||||
let used_roots = skill_root_by_path.values().cloned().collect::<HashSet<_>>();
|
||||
skill_roots.retain(|root| used_roots.contains(root));
|
||||
file_systems_by_skill_path.retain(|path, _| retained_skill_paths.contains(path));
|
||||
outcome
|
||||
.agent_plugin_skill_paths
|
||||
.retain(|path| retained_skill_paths.contains(path));
|
||||
outcome.skill_roots = skill_roots;
|
||||
outcome.skill_root_by_path = Arc::new(skill_root_by_path);
|
||||
outcome.file_systems_by_skill_path = SkillFileSystemsByPath::new(file_systems_by_skill_path);
|
||||
|
||||
Reference in new issue
Block a user