diff --git a/codex-rs/core/src/mcp/skill_dependencies.rs b/codex-rs/core/src/mcp/skill_dependencies.rs index 7ea040aed..1969bc632 100644 --- a/codex-rs/core/src/mcp/skill_dependencies.rs +++ b/codex-rs/core/src/mcp/skill_dependencies.rs @@ -433,6 +433,7 @@ mod tests { interface: None, dependencies: Some(SkillDependencies { tools }), policy: None, + permission_profile: None, permissions: None, path_to_skills_md: PathBuf::from("skill"), scope: SkillScope::User, diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index 22767d6dc..e7856897a 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -482,6 +482,7 @@ mod tests { interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: PathBuf::from(path), scope: codex_protocol::protocol::SkillScope::User, diff --git a/codex-rs/core/src/skills/invocation_utils.rs b/codex-rs/core/src/skills/invocation_utils.rs index 65f077f96..5e6a851db 100644 --- a/codex-rs/core/src/skills/invocation_utils.rs +++ b/codex-rs/core/src/skills/invocation_utils.rs @@ -336,6 +336,7 @@ mod tests { interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: skill_doc_path, scope: codex_protocol::protocol::SkillScope::User, diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 873f6cc09..a1aa3c630 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -57,6 +57,15 @@ struct SkillMetadataFile { permissions: Option, } +#[derive(Default)] +struct LoadedSkillMetadata { + interface: Option, + dependencies: Option, + policy: Option, + permission_profile: Option, + permissions: Option, +} + #[derive(Debug, Default, Deserialize)] struct Interface { display_name: Option, @@ -506,7 +515,13 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result Result ( - Option, - Option, - Option, - Option, -) { +fn load_skill_metadata(skill_path: &Path) -> LoadedSkillMetadata { // Fail open: optional metadata should not block loading SKILL.md. let Some(skill_dir) = skill_path.parent() else { - return (None, None, None, None); + return LoadedSkillMetadata::default(); }; let metadata_path = skill_dir .join(SKILLS_METADATA_DIR) .join(SKILLS_METADATA_FILENAME); if !metadata_path.exists() { - return (None, None, None, None); + return LoadedSkillMetadata::default(); } let contents = match fs::read_to_string(&metadata_path) { @@ -560,7 +569,7 @@ fn load_skill_metadata( path = metadata_path.display(), label = SKILLS_METADATA_FILENAME ); - return (None, None, None, None); + return LoadedSkillMetadata::default(); } }; @@ -572,7 +581,7 @@ fn load_skill_metadata( path = metadata_path.display(), label = SKILLS_METADATA_FILENAME ); - return (None, None, None, None); + return LoadedSkillMetadata::default(); } }; @@ -582,13 +591,15 @@ fn load_skill_metadata( policy, permissions, } = parsed; + let permission_profile = permissions.clone().filter(|profile| !profile.is_empty()); - ( - resolve_interface(interface, skill_dir), - resolve_dependencies(dependencies), - resolve_policy(policy), - compile_permission_profile(skill_dir, permissions), - ) + LoadedSkillMetadata { + interface: resolve_interface(interface, skill_dir), + dependencies: resolve_dependencies(dependencies), + policy: resolve_policy(policy), + permission_profile, + permissions: compile_permission_profile(skill_dir, permissions), + } } fn resolve_interface(interface: Option, skill_dir: &Path) -> Option { @@ -829,6 +840,8 @@ mod tests { use crate::config_loader::ConfigRequirementsToml; use codex_config::CONFIG_TOML_FILE; use codex_protocol::config_types::TrustLevel; + use codex_protocol::models::FileSystemPermissions; + use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -1048,6 +1061,7 @@ mod tests { interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, @@ -1196,6 +1210,7 @@ mod tests { ], }), policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, @@ -1252,6 +1267,7 @@ interface: }), dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(skill_path.as_path()), scope: SkillScope::User, @@ -1341,7 +1357,6 @@ permissions: file_system: read: - "./data" - - "./data" write: - "./output" "#, @@ -1356,6 +1371,17 @@ permissions: outcome.errors ); assert_eq!(outcome.skills.len(), 1); + assert_eq!( + outcome.skills[0].permission_profile, + Some(PermissionProfile { + network: Some(true), + file_system: Some(FileSystemPermissions { + read: Some(vec![PathBuf::from("./data")]), + write: Some(vec![PathBuf::from("./output")]), + }), + macos: None, + }) + ); #[cfg(target_os = "macos")] let macos_seatbelt_profile_extensions = Some(crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default()); @@ -1444,6 +1470,7 @@ permissions: {} windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, }); + assert_eq!(outcome.skills[0].permission_profile, None); assert_eq!(outcome.skills[0].permissions, expected); } @@ -1586,6 +1613,7 @@ permissions: }), dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, @@ -1627,6 +1655,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, @@ -1681,6 +1710,7 @@ permissions: }), dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, @@ -1723,6 +1753,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, @@ -1768,6 +1799,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::User, @@ -1829,6 +1861,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, @@ -1866,6 +1899,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::Admin, @@ -1907,6 +1941,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&linked_skill_path), scope: SkillScope::Repo, @@ -1975,6 +2010,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&within_depth_path), scope: SkillScope::User, @@ -2003,6 +2039,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, @@ -2035,6 +2072,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, @@ -2148,6 +2186,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, @@ -2184,6 +2223,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, @@ -2238,6 +2278,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&nested_skill_path), scope: SkillScope::Repo, @@ -2249,6 +2290,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&root_skill_path), scope: SkillScope::Repo, @@ -2289,6 +2331,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, @@ -2327,6 +2370,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, @@ -2369,6 +2413,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&repo_skill_path), scope: SkillScope::Repo, @@ -2380,6 +2425,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&user_skill_path), scope: SkillScope::User, @@ -2445,6 +2491,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: first_path, scope: SkillScope::Repo, @@ -2456,6 +2503,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: second_path, scope: SkillScope::Repo, @@ -2528,6 +2576,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, @@ -2587,6 +2636,7 @@ permissions: interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::System, diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index 536d2f493..ddb1dd263 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::sync::Arc; use crate::config::Permissions; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SkillScope; #[derive(Debug, Clone, PartialEq)] @@ -14,6 +15,7 @@ pub struct SkillMetadata { pub interface: Option, pub dependencies: Option, pub policy: Option, + pub permission_profile: Option, // This is an experimental field. pub permissions: Option, /// Path to the SKILLS.md file that declares this skill. diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 1b8e79f22..6e8bd138f 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -17,6 +17,7 @@ use codex_execpolicy::Decision; use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::RejectConfig; @@ -179,6 +180,7 @@ impl CoreShellActionProvider { argv: &[String], workdir: &AbsolutePathBuf, stopwatch: &Stopwatch, + additional_permissions: Option, ) -> anyhow::Result { let command = join_program_and_argv(program, argv); let workdir = workdir.to_path_buf(); @@ -198,7 +200,7 @@ impl CoreShellActionProvider { None, None, None, - None, + additional_permissions, ) .await }) @@ -238,6 +240,7 @@ impl CoreShellActionProvider { program: &AbsolutePathBuf, argv: &[String], workdir: &AbsolutePathBuf, + additional_permissions: Option, ) -> anyhow::Result { let action = match decision { Decision::Forbidden => EscalateAction::Deny { @@ -253,7 +256,16 @@ impl CoreShellActionProvider { reason: Some("Execution forbidden by policy".to_string()), } } else { - match self.prompt(program, argv, workdir, &self.stopwatch).await? { + match self + .prompt( + program, + argv, + workdir, + &self.stopwatch, + additional_permissions, + ) + .await? + { ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::ApprovedForSession => { @@ -326,7 +338,14 @@ impl EscalationPolicy for CoreShellActionProvider { // skill matches. let needs_escalation = true; return self - .process_decision(Decision::Prompt, needs_escalation, program, argv, workdir) + .process_decision( + Decision::Prompt, + needs_escalation, + program, + argv, + workdir, + skill.permission_profile.clone(), + ) .await; } @@ -366,6 +385,7 @@ impl EscalationPolicy for CoreShellActionProvider { program, argv, workdir, + None, ) .await } diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index e64ca4c19..147ecdd0c 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -2,6 +2,10 @@ use anyhow::Result; use codex_core::features::Feature; +#[cfg(unix)] +use codex_protocol::models::FileSystemPermissions; +#[cfg(unix)] +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -45,6 +49,14 @@ description: {name} skill Ok(script_path) } +#[cfg(unix)] +fn write_skill_metadata(home: &Path, name: &str, contents: &str) -> Result<()> { + let metadata_dir = home.join("skills").join(name).join("agents"); + fs::create_dir_all(&metadata_dir)?; + fs::write(metadata_dir.join("openai.yaml"), contents)?; + Ok(()) +} + fn shell_command_arguments(command: &str) -> Result { Ok(serde_json::to_string(&json!({ "command": command, @@ -441,6 +453,19 @@ async fn shell_zsh_fork_prompts_for_skill_script_execution() -> Result<()> { let mut builder = test_codex() .with_pre_build_hook(|home| { write_skill_with_shell_script(home, "mbolin-test-skill", "hello-mbolin.sh").unwrap(); + write_skill_metadata( + home, + "mbolin-test-skill", + r#" +permissions: + file_system: + read: + - "./data" + write: + - "./output" +"#, + ) + .unwrap(); }) .with_config(move |config| { config.features.enable(Feature::ShellTool); @@ -493,6 +518,16 @@ async fn shell_zsh_fork_prompts_for_skill_script_execution() -> Result<()> { }; assert_eq!(approval.call_id, tool_call_id); assert_eq!(approval.command, vec![script_path_str.clone()]); + assert_eq!( + approval.additional_permissions, + Some(PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![PathBuf::from("./data")]), + write: Some(vec![PathBuf::from("./output")]), + }), + ..Default::default() + }) + ); test.codex .submit(Op::ExecApproval { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 188ab87d6..e4c40dd45 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1495,6 +1495,7 @@ mod tests { interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: PathBuf::from("test-skill"), scope: SkillScope::User, diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 72af6b89b..a30e7954f 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -190,6 +190,7 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata { .collect(), }), policy: None, + permission_profile: None, permissions: None, path_to_skills_md: skill.path.clone(), scope: skill.scope, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 575b860ba..358a9615c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -930,6 +930,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: repo_skill_path, scope: SkillScope::Repo, @@ -941,6 +942,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { interface: None, dependencies: None, policy: None, + permission_profile: None, permissions: None, path_to_skills_md: user_skill_path.clone(), scope: SkillScope::User,