Files
codex/codex-rs/tui/src/chatwidget/skills.rs
Celia Chen 5b6911cb1b feat(skills): add permission profiles from openai.yaml metadata (#11658)
## Summary

This PR adds support for skill-level permissions in .codex/openai.yaml
and wires that through the skill loading pipeline.

  ## What’s included

1. Added a new permissions section for skills (network, filesystem, and
macOS-related access).
2. Implemented permission parsing/normalization and translation into
runtime permission profiles.
3. Threaded the new permission profile through SkillMetadata and loader
flow.

  ## Follow-up

A follow-up PR will connect these permission profiles to actual sandbox
enforcement and add user approval prompts for executing binaries/scripts
from skill directories.


 ## Example 
`openai.yaml` snippet:
```
  permissions:
    network: true
    fs_read:
      - "./data"
      - "./data"
    fs_write:
      - "./output"
    macos_preferences: "readwrite"
    macos_automation:
      - "com.apple.Notes"
    macos_accessibility: true
    macos_calendar: true
```

compiled skill permission profile metadata (macOS): 
```
SkillPermissionProfile {
      sandbox_policy: SandboxPolicy::WorkspaceWrite {
          writable_roots: vec![
              AbsolutePathBuf::try_from("/ABS/PATH/TO/SKILL/output").unwrap(),
          ],
          read_only_access: ReadOnlyAccess::Restricted {
              include_platform_defaults: true,
              readable_roots: vec![
                  AbsolutePathBuf::try_from("/ABS/PATH/TO/SKILL/data").unwrap(),
              ],
          },
          network_access: true,
          exclude_tmpdir_env_var: false,
          exclude_slash_tmp: false,
      },
      // Truncated for readability; actual generated profile is longer.
      macos_seatbelt_permission_file: r#"
  (allow user-preference-write)
  (allow appleevent-send
      (appleevent-destination "com.apple.Notes"))
  (allow mach-lookup (global-name "com.apple.axserver"))
  (allow mach-lookup (global-name "com.apple.CalendarAgent"))
  ...
  "#.to_string(),
```
2026-02-14 01:43:44 +00:00

452 lines
14 KiB
Rust

use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::SkillsToggleItem;
use crate::bottom_pane::SkillsToggleView;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::skills_helpers::skill_description;
use crate::skills_helpers::skill_display_name;
use codex_chatgpt::connectors::AppInfo;
use codex_core::connectors::connector_mention_slug;
use codex_core::protocol::ListSkillsResponseEvent;
use codex_core::protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_core::protocol::SkillsListEntry;
use codex_core::skills::model::SkillDependencies;
use codex_core::skills::model::SkillInterface;
use codex_core::skills::model::SkillMetadata;
use codex_core::skills::model::SkillToolDependency;
impl ChatWidget {
pub(crate) fn open_skills_list(&mut self) {
self.insert_str("$");
}
pub(crate) fn open_skills_menu(&mut self) {
let items = vec![
SelectionItem {
name: "List skills".to_string(),
description: Some("Tip: press $ to open this list directly.".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenSkillsList);
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Enable/Disable Skills".to_string(),
description: Some("Enable or disable skills.".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenManageSkillsPopup);
})],
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Skills".to_string()),
subtitle: Some("Choose an action".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn open_manage_skills_popup(&mut self) {
if self.skills_all.is_empty() {
self.add_info_message("No skills available.".to_string(), None);
return;
}
let mut initial_state = HashMap::new();
for skill in &self.skills_all {
initial_state.insert(normalize_skill_config_path(&skill.path), skill.enabled);
}
self.skills_initial_state = Some(initial_state);
let items: Vec<SkillsToggleItem> = self
.skills_all
.iter()
.map(|skill| {
let core_skill = protocol_skill_to_core(skill);
let display_name = skill_display_name(&core_skill).to_string();
let description = skill_description(&core_skill).to_string();
let name = core_skill.name.clone();
let path = core_skill.path;
SkillsToggleItem {
name: display_name,
skill_name: name,
description,
enabled: skill.enabled,
path,
}
})
.collect();
let view = SkillsToggleView::new(items, self.app_event_tx.clone());
self.bottom_pane.show_view(Box::new(view));
}
pub(crate) fn update_skill_enabled(&mut self, path: PathBuf, enabled: bool) {
let target = normalize_skill_config_path(&path);
for skill in &mut self.skills_all {
if normalize_skill_config_path(&skill.path) == target {
skill.enabled = enabled;
}
}
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
}
pub(crate) fn handle_manage_skills_closed(&mut self) {
let Some(initial_state) = self.skills_initial_state.take() else {
return;
};
let mut current_state = HashMap::new();
for skill in &self.skills_all {
current_state.insert(normalize_skill_config_path(&skill.path), skill.enabled);
}
let mut enabled_count = 0;
let mut disabled_count = 0;
for (path, was_enabled) in initial_state {
let Some(is_enabled) = current_state.get(&path) else {
continue;
};
if was_enabled != *is_enabled {
if *is_enabled {
enabled_count += 1;
} else {
disabled_count += 1;
}
}
}
if enabled_count == 0 && disabled_count == 0 {
return;
}
self.add_info_message(
format!("{enabled_count} skills enabled, {disabled_count} skills disabled"),
None,
);
}
pub(crate) fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) {
let skills = skills_for_cwd(&self.config.cwd, &response.skills);
self.skills_all = skills;
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
}
}
fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<ProtocolSkillMetadata> {
skills_entries
.iter()
.find(|entry| entry.cwd.as_path() == cwd)
.map(|entry| entry.skills.clone())
.unwrap_or_default()
}
fn enabled_skills_for_mentions(skills: &[ProtocolSkillMetadata]) -> Vec<SkillMetadata> {
skills
.iter()
.filter(|skill| skill.enabled)
.map(protocol_skill_to_core)
.collect()
}
fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata {
SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}),
dependencies: skill
.dependencies
.clone()
.map(|dependencies| SkillDependencies {
tools: dependencies
.tools
.into_iter()
.map(|tool| SkillToolDependency {
r#type: tool.r#type,
value: tool.value,
description: tool.description,
transport: tool.transport,
command: tool.command,
url: tool.url,
})
.collect(),
}),
policy: None,
permissions: None,
path: skill.path.clone(),
scope: skill.scope,
}
}
fn normalize_skill_config_path(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
pub(crate) fn collect_tool_mentions(
text: &str,
mention_paths: &HashMap<String, String>,
) -> ToolMentions {
let mut mentions = extract_tool_mentions_from_text(text);
for (name, path) in mention_paths {
if mentions.names.contains(name) {
mentions.linked_paths.insert(name.clone(), path.clone());
}
}
mentions
}
pub(crate) fn find_skill_mentions_with_tool_mentions(
mentions: &ToolMentions,
skills: &[SkillMetadata],
) -> Vec<SkillMetadata> {
let mention_skill_paths: HashSet<&str> = mentions
.linked_paths
.values()
.filter(|path| is_skill_path(path))
.map(|path| normalize_skill_path(path))
.collect();
let mut seen_names = HashSet::new();
let mut seen_paths = HashSet::new();
let mut matches: Vec<SkillMetadata> = Vec::new();
for skill in skills {
if seen_paths.contains(&skill.path) {
continue;
}
let path_str = skill.path.to_string_lossy();
if mention_skill_paths.contains(path_str.as_ref()) {
seen_paths.insert(skill.path.clone());
seen_names.insert(skill.name.clone());
matches.push(skill.clone());
}
}
for skill in skills {
if seen_paths.contains(&skill.path) {
continue;
}
if mentions.names.contains(&skill.name) && seen_names.insert(skill.name.clone()) {
seen_paths.insert(skill.path.clone());
matches.push(skill.clone());
}
}
matches
}
pub(crate) fn find_app_mentions(
mentions: &ToolMentions,
apps: &[AppInfo],
skill_names_lower: &HashSet<String>,
) -> Vec<AppInfo> {
let mut explicit_names = HashSet::new();
let mut selected_ids = HashSet::new();
for (name, path) in &mentions.linked_paths {
if let Some(connector_id) = app_id_from_path(path) {
explicit_names.insert(name.clone());
selected_ids.insert(connector_id.to_string());
}
}
let mut slug_counts: HashMap<String, usize> = HashMap::new();
for app in apps.iter().filter(|app| app.is_enabled) {
let slug = connector_mention_slug(app);
*slug_counts.entry(slug).or_insert(0) += 1;
}
for app in apps.iter().filter(|app| app.is_enabled) {
let slug = connector_mention_slug(app);
let slug_count = slug_counts.get(&slug).copied().unwrap_or(0);
if mentions.names.contains(&slug)
&& !explicit_names.contains(&slug)
&& slug_count == 1
&& !skill_names_lower.contains(&slug)
{
selected_ids.insert(app.id.clone());
}
}
apps.iter()
.filter(|app| app.is_enabled && selected_ids.contains(&app.id))
.cloned()
.collect()
}
pub(crate) struct ToolMentions {
names: HashSet<String>,
linked_paths: HashMap<String, String>,
}
fn extract_tool_mentions_from_text(text: &str) -> ToolMentions {
let text_bytes = text.as_bytes();
let mut names: HashSet<String> = HashSet::new();
let mut linked_paths: HashMap<String, String> = HashMap::new();
let mut index = 0;
while index < text_bytes.len() {
let byte = text_bytes[index];
if byte == b'['
&& let Some((name, path, end_index)) =
parse_linked_tool_mention(text, text_bytes, index)
{
if !is_common_env_var(name) {
if !is_app_or_mcp_path(path) {
names.insert(name.to_string());
}
linked_paths
.entry(name.to_string())
.or_insert(path.to_string());
}
index = end_index;
continue;
}
if byte != b'$' {
index += 1;
continue;
}
let name_start = index + 1;
let Some(first_name_byte) = text_bytes.get(name_start) else {
index += 1;
continue;
};
if !is_mention_name_char(*first_name_byte) {
index += 1;
continue;
}
let mut name_end = name_start + 1;
while let Some(next_byte) = text_bytes.get(name_end)
&& is_mention_name_char(*next_byte)
{
name_end += 1;
}
let name = &text[name_start..name_end];
if !is_common_env_var(name) {
names.insert(name.to_string());
}
index = name_end;
}
ToolMentions {
names,
linked_paths,
}
}
fn parse_linked_tool_mention<'a>(
text: &'a str,
text_bytes: &[u8],
start: usize,
) -> Option<(&'a str, &'a str, usize)> {
let dollar_index = start + 1;
if text_bytes.get(dollar_index) != Some(&b'$') {
return None;
}
let name_start = dollar_index + 1;
let first_name_byte = text_bytes.get(name_start)?;
if !is_mention_name_char(*first_name_byte) {
return None;
}
let mut name_end = name_start + 1;
while let Some(next_byte) = text_bytes.get(name_end)
&& is_mention_name_char(*next_byte)
{
name_end += 1;
}
if text_bytes.get(name_end) != Some(&b']') {
return None;
}
let mut path_start = name_end + 1;
while let Some(next_byte) = text_bytes.get(path_start)
&& next_byte.is_ascii_whitespace()
{
path_start += 1;
}
if text_bytes.get(path_start) != Some(&b'(') {
return None;
}
let mut path_end = path_start + 1;
while let Some(next_byte) = text_bytes.get(path_end)
&& *next_byte != b')'
{
path_end += 1;
}
if text_bytes.get(path_end) != Some(&b')') {
return None;
}
let path = text[path_start + 1..path_end].trim();
if path.is_empty() {
return None;
}
let name = &text[name_start..name_end];
Some((name, path, path_end + 1))
}
fn is_common_env_var(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
matches!(
upper.as_str(),
"PATH"
| "HOME"
| "USER"
| "SHELL"
| "PWD"
| "TMPDIR"
| "TEMP"
| "TMP"
| "LANG"
| "TERM"
| "XDG_CONFIG_HOME"
)
}
fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
fn is_skill_path(path: &str) -> bool {
!is_app_or_mcp_path(path)
}
fn normalize_skill_path(path: &str) -> &str {
path.strip_prefix("skill://").unwrap_or(path)
}
fn app_id_from_path(path: &str) -> Option<&str> {
path.strip_prefix("app://")
.filter(|value| !value.is_empty())
}
fn is_app_or_mcp_path(path: &str) -> bool {
path.starts_with("app://") || path.starts_with("mcp://")
}