diff --git a/codex-rs/core/src/mcp/skill_dependencies_tests.rs b/codex-rs/core/src/mcp/skill_dependencies_tests.rs index 68af0df98..49ba4e9f7 100644 --- a/codex-rs/core/src/mcp/skill_dependencies_tests.rs +++ b/codex-rs/core/src/mcp/skill_dependencies_tests.rs @@ -13,6 +13,7 @@ fn skill_with_tools(tools: Vec) -> SkillMetadata { dependencies: Some(SkillDependencies { tools }), policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: PathBuf::from("skill"), scope: SkillScope::User, } diff --git a/codex-rs/core/src/skills/injection_tests.rs b/codex-rs/core/src/skills/injection_tests.rs index 74ff315bb..8d66a0af5 100644 --- a/codex-rs/core/src/skills/injection_tests.rs +++ b/codex-rs/core/src/skills/injection_tests.rs @@ -12,6 +12,7 @@ fn make_skill(name: &str, path: &str) -> SkillMetadata { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: PathBuf::from(path), scope: codex_protocol::protocol::SkillScope::User, } diff --git a/codex-rs/core/src/skills/invocation_utils_tests.rs b/codex-rs/core/src/skills/invocation_utils_tests.rs index bf244ce76..657582b74 100644 --- a/codex-rs/core/src/skills/invocation_utils_tests.rs +++ b/codex-rs/core/src/skills/invocation_utils_tests.rs @@ -19,6 +19,7 @@ fn test_skill_metadata(skill_doc_path: PathBuf) -> SkillMetadata { dependencies: None, policy: None, permission_profile: None, + managed_network_override: 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 b2dd48b3a..c1aa24c92 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -8,11 +8,15 @@ use crate::skills::model::SkillDependencies; use crate::skills::model::SkillError; use crate::skills::model::SkillInterface; use crate::skills::model::SkillLoadOutcome; +use crate::skills::model::SkillManagedNetworkOverride; use crate::skills::model::SkillMetadata; use crate::skills::model::SkillPolicy; use crate::skills::model::SkillToolDependency; use crate::skills::system::system_cache_root_dir; use codex_app_server_protocol::ConfigLayerSource; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBufGuard; @@ -32,8 +36,6 @@ use tracing::error; #[cfg(test)] use crate::config::Config; -#[cfg(test)] -use codex_protocol::models::NetworkPermissions; #[derive(Debug, Deserialize)] struct SkillFrontmatter { @@ -60,7 +62,7 @@ struct SkillMetadataFile { #[serde(default)] policy: Option, #[serde(default)] - permissions: Option, + permissions: Option, } #[derive(Default)] @@ -69,6 +71,27 @@ struct LoadedSkillMetadata { dependencies: Option, policy: Option, permission_profile: Option, + managed_network_override: Option, +} + +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +struct SkillPermissionProfile { + #[serde(default)] + network: Option, + #[serde(default)] + file_system: Option, + #[serde(default)] + macos: Option, +} + +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +struct SkillNetworkPermissions { + #[serde(default)] + enabled: Option, + #[serde(default)] + allowed_domains: Option>, + #[serde(default)] + denied_domains: Option>, } #[derive(Debug, Default, Deserialize)] @@ -527,6 +550,7 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result Result LoadedSkillMetadata { policy, permissions, } = parsed; + let (permission_profile, managed_network_override) = normalize_permissions(permissions); LoadedSkillMetadata { interface: resolve_interface(interface, skill_dir), dependencies: resolve_dependencies(dependencies), policy: resolve_policy(policy), - permission_profile: permissions.filter(|profile| !profile.is_empty()), + permission_profile, + managed_network_override, } } +fn normalize_permissions( + permissions: Option, +) -> ( + Option, + Option, +) { + let Some(permissions) = permissions else { + return (None, None); + }; + let managed_network_override = permissions + .network + .as_ref() + .map(|network| SkillManagedNetworkOverride { + allowed_domains: network.allowed_domains.clone(), + denied_domains: network.denied_domains.clone(), + }) + .filter(SkillManagedNetworkOverride::has_domain_overrides); + let permission_profile = PermissionProfile { + network: permissions.network.and_then(|network| { + let network = NetworkPermissions { + enabled: network.enabled, + }; + (!network.is_empty()).then_some(network) + }), + file_system: permissions + .file_system + .filter(|file_system| !file_system.is_empty()), + macos: permissions.macos, + }; + + ( + (!permission_profile.is_empty()).then_some(permission_profile), + managed_network_override, + ) +} + fn resolve_interface(interface: Option, skill_dir: &Path) -> Option { let interface = interface?; let interface = SkillInterface { diff --git a/codex-rs/core/src/skills/loader_tests.rs b/codex-rs/core/src/skills/loader_tests.rs index feee09a9a..2da1f9cd2 100644 --- a/codex-rs/core/src/skills/loader_tests.rs +++ b/codex-rs/core/src/skills/loader_tests.rs @@ -239,6 +239,7 @@ fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -390,6 +391,7 @@ async fn loads_skill_dependencies_metadata_from_yaml() { }), policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -446,6 +448,7 @@ interface: dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(skill_path.as_path()), scope: SkillScope::User, }] @@ -568,6 +571,7 @@ permissions: macos: None, }) ); + assert_eq!(outcome.skills[0].managed_network_override, None); } #[tokio::test] @@ -595,6 +599,70 @@ permissions: {} assert_eq!(outcome.skills[0].permission_profile, None); } +#[test] +fn normalize_permissions_splits_managed_network_overrides() { + let (permission_profile, managed_network_override) = + normalize_permissions(Some(SkillPermissionProfile { + network: Some(SkillNetworkPermissions { + enabled: Some(true), + allowed_domains: Some(vec!["skill.example.com".to_string()]), + denied_domains: Some(vec!["blocked.skill.example.com".to_string()]), + }), + file_system: None, + macos: None, + })); + + assert_eq!( + permission_profile, + Some(PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: None, + macos: None, + }) + ); + assert_eq!( + managed_network_override, + Some(SkillManagedNetworkOverride { + allowed_domains: Some(vec!["skill.example.com".to_string()]), + denied_domains: Some(vec!["blocked.skill.example.com".to_string()]), + }) + ); +} + +#[test] +fn normalize_permissions_preserves_network_gate_separately_from_overrides() { + let (permission_profile, managed_network_override) = + normalize_permissions(Some(SkillPermissionProfile { + network: Some(SkillNetworkPermissions { + enabled: Some(false), + allowed_domains: Some(vec!["skill.example.com".to_string()]), + denied_domains: None, + }), + file_system: None, + macos: None, + })); + + assert_eq!( + permission_profile, + Some(PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(false), + }), + file_system: None, + macos: None, + }) + ); + assert_eq!( + managed_network_override, + Some(SkillManagedNetworkOverride { + allowed_domains: Some(vec!["skill.example.com".to_string()]), + denied_domains: None, + }) + ); +} + #[test] fn skill_metadata_parses_macos_permissions_yaml() { let parsed = serde_yaml::from_str::( @@ -613,7 +681,9 @@ permissions: assert_eq!( parsed.permissions, - Some(PermissionProfile { + Some(SkillPermissionProfile { + network: None, + file_system: None, macos: Some(MacOsSeatbeltProfileExtensions { macos_preferences: MacOsPreferencesPermission::ReadWrite, macos_automation: MacOsAutomationPermission::BundleIds(vec![ @@ -625,7 +695,6 @@ permissions: macos_reminders: false, macos_contacts: MacOsContactsPermission::None, }), - ..Default::default() }) ); } @@ -643,7 +712,9 @@ permissions: assert_eq!( parsed.permissions, - Some(PermissionProfile { + Some(SkillPermissionProfile { + network: None, + file_system: None, macos: Some(MacOsSeatbeltProfileExtensions { macos_preferences: MacOsPreferencesPermission::ReadOnly, macos_automation: MacOsAutomationPermission::None, @@ -653,7 +724,35 @@ permissions: macos_reminders: true, macos_contacts: MacOsContactsPermission::None, }), - ..Default::default() + }) + ); +} + +#[test] +fn skill_metadata_parses_network_domain_overrides_under_permissions() { + let parsed = serde_yaml::from_str::( + r#" +permissions: + network: + enabled: true + allowed_domains: + - "skill.example.com" + denied_domains: + - "blocked.skill.example.com" +"#, + ) + .expect("parse network skill metadata"); + + assert_eq!( + parsed.permissions, + Some(SkillPermissionProfile { + network: Some(SkillNetworkPermissions { + enabled: Some(true), + allowed_domains: Some(vec!["skill.example.com".to_string()]), + denied_domains: Some(vec!["blocked.skill.example.com".to_string()]), + }), + file_system: None, + macos: None, }) ); } @@ -801,6 +900,7 @@ async fn accepts_icon_paths_under_assets_dir() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -842,6 +942,7 @@ async fn ignores_invalid_brand_color() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -896,6 +997,7 @@ async fn ignores_default_prompt_over_max_length() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -938,6 +1040,7 @@ async fn drops_interface_when_icons_are_invalid() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -983,6 +1086,7 @@ async fn loads_skills_via_symlinked_subdir_for_user_scope() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::User, }] @@ -1043,6 +1147,7 @@ async fn does_not_loop_on_symlink_cycle_for_user_scope() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1080,6 +1185,7 @@ fn loads_skills_via_symlinked_subdir_for_admin_scope() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::Admin, }] @@ -1120,6 +1226,7 @@ async fn loads_skills_via_symlinked_subdir_for_repo_scope() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&linked_skill_path), scope: SkillScope::Repo, }] @@ -1188,6 +1295,7 @@ async fn respects_max_scan_depth_for_user_scope() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&within_depth_path), scope: SkillScope::User, }] @@ -1216,6 +1324,7 @@ async fn loads_valid_skill() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1249,6 +1358,7 @@ async fn falls_back_to_directory_name_when_skill_name_is_missing() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1291,6 +1401,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1323,6 +1434,7 @@ async fn loads_short_description_from_metadata() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1436,6 +1548,7 @@ async fn loads_skills_from_repo_root() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1472,6 +1585,7 @@ async fn loads_skills_from_agents_dir_without_codex_dir() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1526,6 +1640,7 @@ async fn loads_skills_from_all_codex_dirs_under_project_root() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&nested_skill_path), scope: SkillScope::Repo, }, @@ -1537,6 +1652,7 @@ async fn loads_skills_from_all_codex_dirs_under_project_root() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&root_skill_path), scope: SkillScope::Repo, }, @@ -1577,6 +1693,7 @@ async fn loads_skills_from_codex_dir_when_not_git_repo() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1615,6 +1732,7 @@ async fn deduplicates_by_path_preferring_first_root() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1657,6 +1775,7 @@ async fn keeps_duplicate_names_from_repo_and_user() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&repo_skill_path), scope: SkillScope::Repo, }, @@ -1668,6 +1787,7 @@ async fn keeps_duplicate_names_from_repo_and_user() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&user_skill_path), scope: SkillScope::User, }, @@ -1732,6 +1852,7 @@ async fn keeps_duplicate_names_from_nested_codex_dirs() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: first_path, scope: SkillScope::Repo, }, @@ -1743,6 +1864,7 @@ async fn keeps_duplicate_names_from_nested_codex_dirs() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: second_path, scope: SkillScope::Repo, }, @@ -1815,6 +1937,7 @@ async fn loads_skills_when_cwd_is_file_in_repo() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1874,6 +1997,7 @@ async fn loads_skills_from_system_cache_when_present() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: 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 525ea28da..f8a63f0b2 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -5,6 +5,19 @@ use std::sync::Arc; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SkillScope; +use serde::Deserialize; + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +pub struct SkillManagedNetworkOverride { + pub allowed_domains: Option>, + pub denied_domains: Option>, +} + +impl SkillManagedNetworkOverride { + pub fn has_domain_overrides(&self) -> bool { + self.allowed_domains.is_some() || self.denied_domains.is_some() + } +} #[derive(Debug, Clone, PartialEq)] pub struct SkillMetadata { @@ -15,6 +28,7 @@ pub struct SkillMetadata { pub dependencies: Option, pub policy: Option, pub permission_profile: Option, + pub managed_network_override: Option, /// Path to the SKILLS.md file that declares this skill. pub path_to_skills_md: PathBuf, pub scope: SkillScope, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index a8020c0fb..94d419798 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -92,6 +92,7 @@ fn test_skill_metadata(permission_profile: Option) -> SkillMe dependencies: None, policy: None, permission_profile, + managed_network_override: None, path_to_skills_md: PathBuf::from("/tmp/skill/SKILL.md"), scope: SkillScope::User, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index f314c5e47..1e16eccf6 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -5366,6 +5366,7 @@ mod tests { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"), scope: codex_protocol::protocol::SkillScope::Repo, }])); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index cee2fc086..d15b03021 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1675,6 +1675,7 @@ mod tests { dependencies: None, policy: None, permission_profile: None, + managed_network_override: 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 6228efe4e..a2a5e73e6 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -192,6 +192,7 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata { }), policy: None, permission_profile: None, + managed_network_override: 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 e7a0ac144..fec627885 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -998,6 +998,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: repo_skill_path, scope: SkillScope::Repo, }, @@ -1009,6 +1010,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: user_skill_path.clone(), scope: SkillScope::User, },