feat: support skill-scoped managed network domain overrides in skill config (#14522)

## Summary

This lets skill loading split `permissions.network` into two distinct
pieces:

- `permissions.network.enabled` still feeds the skill
`PermissionProfile` and remains the coarse gate for whether the skill
can use network access at all.
- `permissions.network.allowed_domains` and
`permissions.network.denied_domains` are lifted into a new
`SkillManagedNetworkOverride` so managed-network sessions can start
per-skill scoped proxies with the right domain overrides.

The change also updates `SkillMetadata` construction sites and adds
loader tests covering YAML parsing plus normalization of the network
gate vs. domain override fields.

## Follow-up
A PR that uses the network_override to spin up a skill-specific proxy if
network_override is not none.
This commit is contained in:
Celia Chen
2026-03-12 21:45:14 -07:00
committed by GitHub
Unverified
parent 7c7e267501
commit 0c60eea4a5
11 changed files with 218 additions and 8 deletions
@@ -13,6 +13,7 @@ fn skill_with_tools(tools: Vec<SkillToolDependency>) -> SkillMetadata {
dependencies: Some(SkillDependencies { tools }),
policy: None,
permission_profile: None,
managed_network_override: None,
path_to_skills_md: PathBuf::from("skill"),
scope: SkillScope::User,
}
@@ -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,
}
@@ -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,
}
+67 -4
View File
@@ -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<Policy>,
#[serde(default)]
permissions: Option<PermissionProfile>,
permissions: Option<SkillPermissionProfile>,
}
#[derive(Default)]
@@ -69,6 +71,27 @@ struct LoadedSkillMetadata {
dependencies: Option<SkillDependencies>,
policy: Option<SkillPolicy>,
permission_profile: Option<PermissionProfile>,
managed_network_override: Option<SkillManagedNetworkOverride>,
}
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
struct SkillPermissionProfile {
#[serde(default)]
network: Option<SkillNetworkPermissions>,
#[serde(default)]
file_system: Option<FileSystemPermissions>,
#[serde(default)]
macos: Option<MacOsSeatbeltProfileExtensions>,
}
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
struct SkillNetworkPermissions {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
allowed_domains: Option<Vec<String>>,
#[serde(default)]
denied_domains: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize)]
@@ -527,6 +550,7 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, Ski
dependencies,
policy,
permission_profile,
managed_network_override,
} = load_skill_metadata(path);
validate_len(&name, MAX_NAME_LEN, "name")?;
@@ -549,6 +573,7 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, Ski
dependencies,
policy,
permission_profile,
managed_network_override,
path_to_skills_md: resolved_path,
scope,
})
@@ -614,14 +639,52 @@ fn load_skill_metadata(skill_path: &Path) -> 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<SkillPermissionProfile>,
) -> (
Option<PermissionProfile>,
Option<SkillManagedNetworkOverride>,
) {
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<Interface>, skill_dir: &Path) -> Option<SkillInterface> {
let interface = interface?;
let interface = SkillInterface {
+128 -4
View File
@@ -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::<SkillMetadataFile>(
@@ -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::<SkillMetadataFile>(
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,
}]
+14
View File
@@ -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<Vec<String>>,
pub denied_domains: Option<Vec<String>>,
}
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<SkillDependencies>,
pub policy: Option<SkillPolicy>,
pub permission_profile: Option<PermissionProfile>,
pub managed_network_override: Option<SkillManagedNetworkOverride>,
/// Path to the SKILLS.md file that declares this skill.
pub path_to_skills_md: PathBuf,
pub scope: SkillScope,
@@ -92,6 +92,7 @@ fn test_skill_metadata(permission_profile: Option<PermissionProfile>) -> SkillMe
dependencies: None,
policy: None,
permission_profile,
managed_network_override: None,
path_to_skills_md: PathBuf::from("/tmp/skill/SKILL.md"),
scope: SkillScope::User,
}
@@ -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,
}]));
+1
View File
@@ -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,
}]),
+1
View File
@@ -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,
}
+2
View File
@@ -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,
},