Compare commits

...

7 Commits

Author SHA1 Message Date
Dylan Hurd
fd9d4a9090 gracefully handle failures, inherited profiles 2026-03-17 09:46:28 -07:00
Dylan Hurd
9ab3a31630 ignore legacy permissions 2026-03-16 23:36:02 -07:00
Dylan Hurd
26e9c745dc address comments 2026-03-16 23:36:02 -07:00
Dylan Hurd
9bf61f70aa Fix rebase fallout for macOS permission schema
Co-authored-by: Codex <noreply@openai.com>
2026-03-16 23:36:02 -07:00
Dylan Hurd
d46aa971b1 fix 2026-03-16 23:36:02 -07:00
Dylan Hurd
500c67f749 codex: fix CI failure on PR #14122
Co-authored-by: Codex <noreply@openai.com>
2026-03-16 23:36:02 -07:00
Dylan Hurd
d390b3ae52 feat(core) request_permissions always allow 2026-03-16 23:36:02 -07:00
34 changed files with 1323 additions and 65 deletions

View File

@@ -65,6 +65,13 @@
],
"type": "string"
},
{
"description": "User has approved this permissions request for the current session and wants the granted subset persisted for future sessions.",
"enum": [
"approved_for_always"
],
"type": "string"
},
{
"additionalProperties": false,
"description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.",

View File

@@ -310,16 +310,17 @@
"type": "object"
},
"MacOsAutomationPermission": {
"oneOf": [
"anyOf": [
{
"enum": [
"none",
"all"
],
"type": "string"
"$ref": "#/definitions/MacOsAutomationPermissionModeSchema"
},
{
"items": {
"type": "string"
},
"type": "array"
},
{
"additionalProperties": false,
"properties": {
"bundle_ids": {
"items": {
@@ -336,6 +337,13 @@
}
]
},
"MacOsAutomationPermissionModeSchema": {
"enum": [
"none",
"all"
],
"type": "string"
},
"MacOsContactsPermission": {
"enum": [
"none",

View File

@@ -65,6 +65,13 @@
],
"type": "string"
},
{
"description": "User has approved this permissions request for the current session and wants the granted subset persisted for future sessions.",
"enum": [
"approved_for_always"
],
"type": "string"
},
{
"additionalProperties": false,
"description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.",

View File

@@ -67,7 +67,8 @@
"PermissionGrantScope": {
"enum": [
"turn",
"session"
"session",
"alwaysAllow"
],
"type": "string"
}

View File

@@ -639,16 +639,17 @@
"type": "object"
},
"MacOsAutomationPermission": {
"oneOf": [
"anyOf": [
{
"enum": [
"none",
"all"
],
"type": "string"
"$ref": "#/definitions/MacOsAutomationPermissionModeSchema"
},
{
"items": {
"type": "string"
},
"type": "array"
},
{
"additionalProperties": false,
"properties": {
"bundle_ids": {
"items": {
@@ -665,6 +666,13 @@
}
]
},
"MacOsAutomationPermissionModeSchema": {
"enum": [
"none",
"all"
],
"type": "string"
},
"MacOsContactsPermission": {
"enum": [
"none",

View File

@@ -2408,16 +2408,17 @@
"type": "object"
},
"MacOsAutomationPermission": {
"oneOf": [
"anyOf": [
{
"enum": [
"none",
"all"
],
"type": "string"
"$ref": "#/definitions/MacOsAutomationPermissionModeSchema"
},
{
"items": {
"type": "string"
},
"type": "array"
},
{
"additionalProperties": false,
"properties": {
"bundle_ids": {
"items": {
@@ -2434,6 +2435,13 @@
}
]
},
"MacOsAutomationPermissionModeSchema": {
"enum": [
"none",
"all"
],
"type": "string"
},
"MacOsContactsPermission": {
"enum": [
"none",
@@ -3244,7 +3252,8 @@
"PermissionGrantScope": {
"enum": [
"turn",
"session"
"session",
"alwaysAllow"
],
"type": "string"
},
@@ -3381,6 +3390,13 @@
],
"type": "string"
},
{
"description": "User has approved this permissions request for the current session and wants the granted subset persisted for future sessions.",
"enum": [
"approved_for_always"
],
"type": "string"
},
{
"additionalProperties": false,
"description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.",

View File

@@ -7,4 +7,4 @@ import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
/**
* User's decision in response to an ExecApprovalRequest.
*/
export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "abort";
export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | "approved_for_always" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "abort";

View File

@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PermissionGrantScope = "turn" | "session";
export type PermissionGrantScope = "turn" | "session" | "alwaysAllow";

View File

@@ -987,7 +987,9 @@ impl From<CoreReviewDecision> for CommandExecutionApprovalDecision {
} => Self::AcceptWithExecpolicyAmendment {
execpolicy_amendment: proposed_execpolicy_amendment.into(),
},
CoreReviewDecision::ApprovedForSession => Self::AcceptForSession,
CoreReviewDecision::ApprovedForSession | CoreReviewDecision::ApprovedForAlways => {
Self::AcceptForSession
}
CoreReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => Self::ApplyNetworkPolicyAmendment {
@@ -5604,7 +5606,8 @@ v2_enum_from_core!(
pub enum PermissionGrantScope from CorePermissionGrantScope {
#[default]
Turn,
Session
Session,
AlwaysAllow
}
);

View File

@@ -964,7 +964,7 @@ The built-in `request_permissions` tool sends an `item/permissions/requestApprov
}
```
The client responds with `result.permissions`, which should be the granted subset of the requested permission profile. It may also set `result.scope` to `"session"` to make the grant persist for later turns in the same session; omitted or `"turn"` keeps the existing turn-scoped behavior:
The client responds with `result.permissions`, which should be the granted subset of the requested permission profile. It may also set `result.scope` to `"session"` to make the grant persist for later turns in the same session, or `"alwaysAllow"` to persist the granted subset into the active `config.toml` permissions profile for future sessions too; omitted or `"turn"` keeps the existing turn-scoped behavior:
```json
{

View File

@@ -742,6 +742,83 @@
}
]
},
"MacOsAutomationPermission": {
"anyOf": [
{
"$ref": "#/definitions/MacOsAutomationPermissionModeSchema"
},
{
"items": {
"type": "string"
},
"type": "array"
},
{
"properties": {
"bundle_ids": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"bundle_ids"
],
"type": "object"
}
]
},
"MacOsAutomationPermissionModeSchema": {
"enum": [
"none",
"all"
],
"type": "string"
},
"MacOsContactsPermission": {
"enum": [
"none",
"read_only",
"read_write"
],
"type": "string"
},
"MacOsPermissionsToml": {
"additionalProperties": false,
"properties": {
"accessibility": {
"type": "boolean"
},
"automations": {
"$ref": "#/definitions/MacOsAutomationPermission"
},
"calendar": {
"type": "boolean"
},
"contacts": {
"$ref": "#/definitions/MacOsContactsPermission"
},
"launch_services": {
"type": "boolean"
},
"preferences": {
"$ref": "#/definitions/MacOsPreferencesPermission"
},
"reminders": {
"type": "boolean"
}
},
"type": "object"
},
"MacOsPreferencesPermission": {
"enum": [
"none",
"read_only",
"read_write"
],
"type": "string"
},
"MemoriesToml": {
"additionalProperties": false,
"description": "Memories settings loaded from config.toml.",
@@ -1200,6 +1277,9 @@
"filesystem": {
"$ref": "#/definitions/FilesystemPermissionsToml"
},
"macos": {
"$ref": "#/definitions/MacOsPermissionsToml"
},
"network": {
"$ref": "#/definitions/NetworkToml"
}

View File

@@ -164,6 +164,7 @@ use crate::config::Constrained;
use crate::config::ConstraintResult;
use crate::config::GhostSnapshotConfig;
use crate::config::StartedNetworkProxy;
use crate::config::persist_granted_permission_profile;
use crate::config::resolve_web_search_mode_for_turn;
use crate::config::types::McpServerConfig;
use crate::config::types::ShellEnvironmentPolicy;
@@ -3148,9 +3149,10 @@ impl Session {
pub async fn notify_request_permissions_response(
&self,
call_id: &str,
response: RequestPermissionsResponse,
mut response: RequestPermissionsResponse,
) {
let mut granted_for_session = None;
let mut granted_always_allow = None;
let entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
@@ -3165,6 +3167,10 @@ impl Session {
PermissionGrantScope::Session => {
granted_for_session = Some(response.permissions.clone());
}
PermissionGrantScope::AlwaysAllow => {
granted_for_session = Some(response.permissions.clone());
granted_always_allow = Some(response.permissions.clone());
}
}
}
entry
@@ -3176,6 +3182,21 @@ impl Session {
let mut state = self.state.lock().await;
state.record_granted_permissions(permissions.into());
}
if let Some(permissions) = granted_always_allow {
let config = self.get_config().await;
if let Err(err) = persist_granted_permission_profile(
self.codex_home().await.as_path(),
&config,
&permissions.clone().into(),
)
.await
{
error!("failed to persist granted permissions: {err}");
response.scope = PermissionGrantScope::Session;
} else {
self.reload_user_config_layer().await;
}
}
match entry {
Some(tx_response) => {
tx_response.send(response).ok();

View File

@@ -43,6 +43,7 @@ use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request_with_cancel;
use crate::guardian::routes_approval_to_guardian;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC;
use crate::mcp_tool_call::build_guardian_mcp_tool_review_request;
@@ -687,6 +688,16 @@ async fn maybe_auto_review_mcp_request_user_input(
)
.await;
let selected_label = match decision {
ReviewDecision::ApprovedForAlways => question
.options
.as_ref()
.and_then(|options| {
options
.iter()
.find(|option| option.label == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER)
})
.map(|option| option.label.clone())
.unwrap_or_else(|| MCP_TOOL_APPROVAL_ACCEPT.to_string()),
ReviewDecision::ApprovedForSession => question
.options
.as_ref()

View File

@@ -1,5 +1,6 @@
use super::*;
use crate::CodexAuth;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::config::test_config;
use crate::config_loader::ConfigLayerStack;
@@ -15,8 +16,10 @@ use crate::shell::default_user_shell;
use crate::tools::format_exec_output_str;
use codex_protocol::ThreadId;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -26,6 +29,8 @@ use codex_protocol::protocol::ReadOnlyAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use tokio::sync::oneshot;
use tracing::Span;
use crate::protocol::CompactedItem;
@@ -2607,6 +2612,54 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req
);
}
#[tokio::test]
async fn notify_request_permissions_response_falls_back_to_session_when_persist_fails() {
let (session, _turn_context) = make_session_and_context().await;
let active_turn = ActiveTurn::default();
let (tx, rx) = oneshot::channel();
active_turn
.turn_state
.lock()
.await
.insert_pending_request_permissions("call-1".to_string(), tx);
*session.active_turn.lock().await = Some(active_turn);
let permissions = RequestPermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![
AbsolutePathBuf::try_from(session.codex_home().await.join("allowed"))
.expect("absolute path"),
]),
}),
};
session
.notify_request_permissions_response(
"call-1",
codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: permissions.clone(),
scope: PermissionGrantScope::AlwaysAllow,
},
)
.await;
assert_eq!(session.granted_turn_permissions().await, None);
assert_eq!(
session.granted_session_permissions().await,
Some(permissions.clone().into())
);
assert_eq!(
rx.await.expect("response should be delivered").scope,
PermissionGrantScope::Session
);
assert!(!session.codex_home().await.join(CONFIG_TOML_FILE).exists());
}
#[tokio::test]
async fn submit_with_id_captures_current_span_trace_context() {
let (session, _turn_context) = make_session_and_context().await;

View File

@@ -15,6 +15,13 @@ use crate::config_loader::RequirementSource;
use crate::features::Feature;
use assert_matches::assert_matches;
use codex_config::CONFIG_TOML_FILE;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsAutomationPermission;
use codex_protocol::models::MacOsContactsPermission;
use codex_protocol::models::MacOsPreferencesPermission;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -320,6 +327,7 @@ allowed_domains = ["openai.com"]
allow_unix_sockets: None,
allow_local_binding: None,
}),
macos: None,
},
)]),
}
@@ -351,6 +359,7 @@ fn permissions_profiles_network_populates_runtime_network_proxy_spec() -> std::i
enable_socks5: Some(false),
..Default::default()
}),
macos: None,
},
)]),
}),
@@ -396,6 +405,7 @@ fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() -> st
allowed_domains: Some(vec!["openai.com".to_string()]),
..Default::default()
}),
macos: None,
},
)]),
}),
@@ -441,6 +451,7 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re
]),
}),
network: None,
macos: None,
},
)]),
}),
@@ -527,6 +538,7 @@ fn permissions_profiles_require_default_permissions() -> std::io::Result<()> {
)]),
}),
network: None,
macos: None,
},
)]),
}),
@@ -569,6 +581,7 @@ fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io::Resul
)]),
}),
network: None,
macos: None,
},
)]),
}),
@@ -614,6 +627,7 @@ fn permissions_profiles_reject_nested_entries_for_non_project_roots() -> std::io
)]),
}),
network: None,
macos: None,
},
)]),
}),
@@ -666,6 +680,7 @@ fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> {
)]),
}),
network: None,
macos: None,
})?;
assert_eq!(
@@ -710,6 +725,7 @@ fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std
)]),
}),
network: None,
macos: None,
})?;
assert_eq!(
@@ -736,6 +752,7 @@ fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Resu
let config = load_workspace_permission_profile(PermissionProfileToml {
filesystem: None,
network: None,
macos: None,
})?;
assert_eq!(
@@ -769,6 +786,7 @@ fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result
entries: BTreeMap::new(),
}),
network: None,
macos: None,
})?;
assert_eq!(
@@ -808,6 +826,7 @@ fn permissions_profiles_reject_project_root_parent_traversal() -> std::io::Resul
)]),
}),
network: None,
macos: None,
},
)]),
}),
@@ -852,6 +871,7 @@ fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> {
enabled: Some(true),
..Default::default()
}),
macos: None,
},
)]),
}),
@@ -6055,3 +6075,266 @@ fn test_tui_notification_method() {
toml::from_str(toml).expect("deserialize notification_method=\"bel\"");
assert_eq!(parsed.tui.notification_method, NotificationMethod::Bel);
}
#[test]
fn config_toml_deserializes_permissions_profile_macos_and_special_paths() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.filesystem]
":cwd" = "write"
":slash_tmp" = "write"
[permissions.workspace.macos]
preferences = "read_write"
automations = ["com.apple.Calendar"]
accessibility = true
calendar = true
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for macOS permissions");
let permissions = cfg.permissions.expect("[permissions] should deserialize");
let workspace = permissions
.entries
.get("workspace")
.expect("workspace profile should exist");
assert_eq!(
workspace.filesystem,
Some(FilesystemPermissionsToml {
entries: BTreeMap::from([
(
":cwd".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
),
(
":slash_tmp".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
),
]),
})
);
assert_eq!(
workspace.macos,
Some(MacOsPermissionsToml {
preferences: Some(MacOsPreferencesPermission::ReadWrite),
automations: Some(MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
])),
launch_services: None,
accessibility: Some(true),
calendar: Some(true),
reminders: None,
contacts: None,
})
);
}
#[test]
fn default_permissions_profile_populates_runtime_macos_extensions() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.filesystem]
":minimal" = "read"
[permissions.workspace.macos]
preferences = "read_write"
automations = ["com.apple.Calendar"]
accessibility = true
calendar = true
"#,
)
.expect("config should deserialize"),
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert_eq!(
config.permissions.macos_seatbelt_profile_extensions,
Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
]),
macos_launch_services: false,
macos_accessibility: true,
macos_calendar: true,
macos_reminders: false,
macos_contacts: MacOsContactsPermission::None,
})
);
Ok(())
}
#[tokio::test]
async fn persist_granted_permission_profile_requires_default_permissions() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
toml::from_str(
r#"
sandbox_mode = "workspace-write"
"#,
)
.expect("config should deserialize"),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
let err = persist_granted_permission_profile(
codex_home.path(),
&config,
&PermissionProfile {
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![AbsolutePathBuf::try_from(cwd.path().join("logs"))?]),
}),
network: Some(NetworkPermissions {
enabled: Some(true),
}),
macos: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
]),
macos_launch_services: false,
macos_accessibility: true,
macos_calendar: false,
macos_reminders: false,
macos_contacts: MacOsContactsPermission::None,
}),
},
)
.await
.expect_err("missing default_permissions should not persist grants");
assert_eq!(
err.to_string(),
"cannot persist granted permissions without user-configured `default_permissions`"
);
assert!(!codex_home.path().join(CONFIG_TOML_FILE).exists());
Ok(())
}
#[tokio::test]
async fn persist_granted_permission_profile_does_not_copy_inherited_profiles() -> anyhow::Result<()>
{
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
std::fs::create_dir_all(cwd.path().join(".codex"))?;
let inherited_read = AbsolutePathBuf::try_from(cwd.path().join("repo-only"))?;
std::fs::write(
cwd.path().join(".codex").join(CONFIG_TOML_FILE),
format!(
r#"
sandbox_mode = "workspace-write"
default_permissions = "workspace"
[permissions.workspace.filesystem]
read = ["{}"]
[permissions.workspace.network]
enabled = true
"#,
inherited_read.display()
),
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
sandbox_mode = "workspace-write"
default_permissions = "workspace"
[permissions.workspace]
"#,
)?;
let cwd = AbsolutePathBuf::try_from(cwd.path().to_path_buf())?;
let config_layer_stack = load_config_layers_state(
codex_home.path(),
Some(cwd.clone()),
&[],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
)
.await?;
let config_toml = deserialize_config_toml_with_base(
config_layer_stack.effective_config(),
codex_home.path(),
)?;
let config = Config::load_config_with_layer_stack(
config_toml,
ConfigOverrides {
cwd: Some(cwd.to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
config_layer_stack,
)?;
let granted_write = AbsolutePathBuf::try_from(cwd.as_path().join("logs"))?;
let profile_name = persist_granted_permission_profile(
codex_home.path(),
&config,
&PermissionProfile {
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![granted_write.clone()]),
}),
network: None,
macos: None,
},
)
.await?;
assert_eq!(profile_name, "workspace");
let persisted = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?;
let persisted: ConfigToml = toml::from_str(&persisted)?;
assert_eq!(persisted.default_permissions.as_deref(), Some("workspace"));
assert_eq!(
persisted
.permissions
.as_ref()
.and_then(|permissions| permissions.entries.get("workspace")),
Some(&PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
granted_write.display().to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
)]),
}),
network: None,
macos: None,
})
);
assert!(
!persisted
.permissions
.as_ref()
.and_then(|permissions| permissions.entries.get("workspace"))
.is_some_and(
|profile| profile
.filesystem
.as_ref()
.is_some_and(|filesystem| filesystem
.entries
.contains_key(&inherited_read.display().to_string()))
)
);
Ok(())
}

View File

@@ -1,3 +1,5 @@
use crate::config::PermissionProfileToml;
use crate::config::permissions::merge_permission_profile_toml;
use crate::config::types::McpServerConfig;
use crate::config::types::Notice;
use crate::features::FEATURES;
@@ -56,6 +58,12 @@ pub enum ConfigEdit {
segments: Vec<String>,
value: TomlItem,
},
/// Merge a permissions profile and optionally set it as the default.
SetPermissionProfile {
name: String,
profile: PermissionProfileToml,
set_as_default: bool,
},
/// Remove the value stored at the exact dotted path.
ClearPath { segments: Vec<String> },
}
@@ -376,6 +384,11 @@ impl ConfigDocument {
Ok(self.set_skill_config(path.as_path(), *enabled))
}
ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())),
ConfigEdit::SetPermissionProfile {
name,
profile,
set_as_default,
} => self.set_permission_profile(name, profile, *set_as_default),
ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)),
ConfigEdit::SetProjectTrustLevel { path, level } => {
// Delegate to the existing, tested logic in config.rs to
@@ -464,6 +477,45 @@ impl ConfigDocument {
true
}
fn set_permission_profile(
&mut self,
name: &str,
profile: &PermissionProfileToml,
set_as_default: bool,
) -> anyhow::Result<bool> {
let root = self.doc.as_table_mut();
if !root.contains_key("permissions") {
root.insert(
"permissions",
TomlItem::Table(document_helpers::new_implicit_table()),
);
}
let Some(permissions_item) = root.get_mut("permissions") else {
return Ok(false);
};
let Some(permissions_table) = document_helpers::ensure_table_for_write(permissions_item)
else {
return Ok(false);
};
let merged_profile = if let Some(existing) = permissions_table.get(name) {
let existing = deserialize_permission_profile(existing)?;
merge_permission_profile_toml(Some(&existing), profile)
} else {
profile.clone()
};
permissions_table[name] = serialize_permission_profile(&merged_profile)?;
let mut mutated = true;
if set_as_default {
mutated |= self.write_value(Scope::Global, &["default_permissions"], value(name));
}
Ok(mutated)
}
fn set_skill_config(&mut self, path: &Path, enabled: bool) -> bool {
let normalized_path = normalize_skill_config_path(path);
let mut remove_skills_table = false;
@@ -678,6 +730,18 @@ impl ConfigDocument {
}
}
fn deserialize_permission_profile(item: &TomlItem) -> anyhow::Result<PermissionProfileToml> {
toml::from_str(&item.to_string()).context("failed to deserialize permission profile")
}
fn serialize_permission_profile(profile: &PermissionProfileToml) -> anyhow::Result<TomlItem> {
let serialized = toml::to_string(profile).context("failed to serialize permission profile")?;
let doc = serialized
.parse::<DocumentMut>()
.context("failed to parse serialized permission profile")?;
Ok(TomlItem::Table(doc.as_table().clone()))
}
fn normalize_skill_config_path(path: &Path) -> String {
dunce::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
@@ -933,6 +997,20 @@ impl ConfigEditsBuilder {
self
}
pub fn set_permission_profile(
mut self,
name: &str,
profile: &PermissionProfileToml,
set_as_default: bool,
) -> Self {
self.edits.push(ConfigEdit::SetPermissionProfile {
name: name.to_string(),
profile: profile.clone(),
set_as_default,
});
self
}
pub fn clear_legacy_windows_sandbox_keys(mut self) -> Self {
for key in [
"experimental_windows_sandbox",

View File

@@ -75,6 +75,7 @@ use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemSandboxPolicy;
@@ -94,7 +95,9 @@ use std::path::Path;
use std::path::PathBuf;
use crate::config::permissions::compile_permission_profile;
use crate::config::permissions::merge_permission_profile_toml;
use crate::config::permissions::network_proxy_config_from_profile_network;
use crate::config::permissions::permission_profile_toml_from_runtime_permissions;
use crate::config::profile::ConfigProfile;
use codex_network_proxy::NetworkProxyConfig;
use toml::Value as TomlValue;
@@ -120,6 +123,7 @@ pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
pub use permissions::FilesystemPermissionToml;
pub use permissions::FilesystemPermissionsToml;
pub use permissions::MacOsPermissionsToml;
pub use permissions::NetworkToml;
pub use permissions::PermissionProfileToml;
pub use permissions::PermissionsToml;
@@ -2207,6 +2211,7 @@ impl Config {
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
macos_seatbelt_profile_extensions,
) = if profiles_are_active {
let permissions = cfg.permissions.as_ref().ok_or_else(|| {
std::io::Error::new(
@@ -2223,12 +2228,15 @@ impl Config {
let profile = resolve_permission_profile(permissions, default_permissions)?;
let configured_network_proxy_config =
network_proxy_config_from_profile_network(profile.network.as_ref());
let (mut file_system_sandbox_policy, network_sandbox_policy) =
compile_permission_profile(
permissions,
default_permissions,
&mut startup_warnings,
)?;
let (
mut file_system_sandbox_policy,
network_sandbox_policy,
macos_seatbelt_profile_extensions,
) = compile_permission_profile(
permissions,
default_permissions,
&mut startup_warnings,
)?;
let mut sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
@@ -2244,6 +2252,7 @@ impl Config {
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
macos_seatbelt_profile_extensions,
)
} else {
let configured_network_proxy_config = NetworkProxyConfig::default();
@@ -2269,6 +2278,7 @@ impl Config {
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
None,
)
};
let approval_policy_was_explicit = approval_policy_override.is_some()
@@ -2620,7 +2630,7 @@ impl Config {
shell_environment_policy,
windows_sandbox_mode,
windows_sandbox_private_desktop,
macos_seatbelt_profile_extensions: None,
macos_seatbelt_profile_extensions,
},
approvals_reviewer,
enforce_residency: enforce_residency.value,
@@ -2867,6 +2877,43 @@ impl Config {
}
}
pub(crate) fn user_default_permissions_profile_name(config: &Config) -> Option<String> {
config
.config_layer_stack
.get_user_layer()
.map(|layer| layer.config.clone())
.and_then(|config| config.try_into::<PermissionSelectionToml>().ok())
.and_then(|selection| selection.default_permissions)
}
pub(crate) async fn persist_granted_permission_profile(
codex_home: &Path,
config: &Config,
granted_permissions: &PermissionProfile,
) -> anyhow::Result<String> {
let granted_profile = permission_profile_toml_from_runtime_permissions(granted_permissions);
let Some(profile_name) = user_default_permissions_profile_name(config) else {
anyhow::bail!(
"cannot persist granted permissions without user-configured `default_permissions`"
);
};
let existing = config
.config_layer_stack
.get_user_layer()
.map(|layer| layer.config.clone())
.and_then(|config| config.try_into::<ConfigToml>().ok())
.and_then(|config_toml| config_toml.permissions)
.and_then(|permissions| permissions.entries.get(&profile_name).cloned());
let profile = merge_permission_profile_toml(existing.as_ref(), &granted_profile);
ConfigEditsBuilder::new(codex_home)
.set_permission_profile(&profile_name, &profile, true)
.apply()
.await?;
Ok(profile_name)
}
pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool {
config_layer_stack
.layers_high_to_low()

View File

@@ -7,6 +7,13 @@ use std::path::PathBuf;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsAutomationPermission;
use codex_protocol::models::MacOsContactsPermission;
use codex_protocol::models::MacOsPreferencesPermission;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -35,6 +42,7 @@ impl PermissionsToml {
pub struct PermissionProfileToml {
pub filesystem: Option<FilesystemPermissionsToml>,
pub network: Option<NetworkToml>,
pub macos: Option<MacOsPermissionsToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
@@ -75,6 +83,18 @@ pub struct NetworkToml {
pub allow_local_binding: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MacOsPermissionsToml {
pub preferences: Option<MacOsPreferencesPermission>,
pub automations: Option<MacOsAutomationPermission>,
pub launch_services: Option<bool>,
pub accessibility: Option<bool>,
pub calendar: Option<bool>,
pub reminders: Option<bool>,
pub contacts: Option<MacOsContactsPermission>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum NetworkModeSchema {
@@ -160,7 +180,11 @@ pub(crate) fn compile_permission_profile(
permissions: &PermissionsToml,
profile_name: &str,
startup_warnings: &mut Vec<String>,
) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> {
) -> io::Result<(
FileSystemSandboxPolicy,
NetworkSandboxPolicy,
Option<MacOsSeatbeltProfileExtensions>,
)> {
let profile = resolve_permission_profile(permissions, profile_name)?;
let mut entries = Vec::new();
@@ -183,13 +207,307 @@ pub(crate) fn compile_permission_profile(
}
let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref());
let macos_seatbelt_profile_extensions = profile.macos.as_ref().map(macos_permissions_from_toml);
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries);
let file_system_sandbox_policy = if file_system_sandbox_policy.has_full_disk_write_access() {
FileSystemSandboxPolicy::unrestricted()
} else {
file_system_sandbox_policy
};
Ok((
FileSystemSandboxPolicy::restricted(entries),
file_system_sandbox_policy,
network_sandbox_policy,
macos_seatbelt_profile_extensions,
))
}
pub(crate) fn merge_permission_profile_toml(
base: Option<&PermissionProfileToml>,
permissions: &PermissionProfileToml,
) -> PermissionProfileToml {
let filesystem = match (
base.and_then(|base| base.filesystem.as_ref()),
permissions.filesystem.as_ref(),
) {
(Some(base), Some(permissions)) => {
Some(merge_filesystem_permissions_toml(base, permissions))
}
(Some(base), None) => Some(base.clone()),
(None, Some(permissions)) => Some(permissions.clone()),
(None, None) => None,
};
let network = match (
base.and_then(|base| base.network.as_ref()),
permissions.network.as_ref(),
) {
(Some(base), Some(permissions)) => Some(merge_network_toml(base, permissions)),
(Some(base), None) => Some(base.clone()),
(None, Some(permissions)) => Some(permissions.clone()),
(None, None) => None,
};
let macos = match (
base.and_then(|base| base.macos.as_ref()),
permissions.macos.as_ref(),
) {
(Some(base), Some(permissions)) => Some(merge_macos_permissions_toml(base, permissions)),
(Some(base), None) => Some(base.clone()),
(None, Some(permissions)) => Some(permissions.clone()),
(None, None) => None,
};
PermissionProfileToml {
filesystem,
network,
macos,
}
}
pub(crate) fn permission_profile_toml_from_runtime_permissions(
permissions: &PermissionProfile,
) -> PermissionProfileToml {
let filesystem = permissions
.file_system
.as_ref()
.map(filesystem_permissions_toml_from_runtime)
.filter(|filesystem| !filesystem.is_empty());
let network = permissions
.network
.as_ref()
.map(network_toml_from_runtime)
.filter(network_toml_has_values);
let macos = permissions
.macos
.as_ref()
.map(macos_permissions_toml_from_runtime)
.filter(macos_permissions_toml_has_values);
PermissionProfileToml {
filesystem,
network,
macos,
}
}
fn filesystem_permissions_toml_from_runtime(
permissions: &FileSystemPermissions,
) -> FilesystemPermissionsToml {
let mut entries = BTreeMap::new();
if let Some(read) = permissions.read.as_ref() {
for path in read {
merge_filesystem_permission_entry(
&mut entries,
path.to_string_lossy().as_ref(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
);
}
}
if let Some(write) = permissions.write.as_ref() {
for path in write {
merge_filesystem_permission_entry(
&mut entries,
path.to_string_lossy().as_ref(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
);
}
}
FilesystemPermissionsToml { entries }
}
fn network_toml_from_runtime(permissions: &NetworkPermissions) -> NetworkToml {
NetworkToml {
enabled: permissions.enabled,
..Default::default()
}
}
fn macos_permissions_toml_from_runtime(
permissions: &MacOsSeatbeltProfileExtensions,
) -> MacOsPermissionsToml {
MacOsPermissionsToml {
preferences: Some(permissions.macos_preferences.clone()),
automations: Some(permissions.macos_automation.clone()),
launch_services: Some(permissions.macos_launch_services),
accessibility: Some(permissions.macos_accessibility),
calendar: Some(permissions.macos_calendar),
reminders: Some(permissions.macos_reminders),
contacts: Some(permissions.macos_contacts.clone()),
}
}
fn macos_permissions_from_toml(
permissions: &MacOsPermissionsToml,
) -> MacOsSeatbeltProfileExtensions {
MacOsSeatbeltProfileExtensions {
macos_preferences: permissions.preferences.clone().unwrap_or_default(),
macos_automation: permissions.automations.clone().unwrap_or_default(),
macos_launch_services: permissions.launch_services.unwrap_or(false),
macos_accessibility: permissions.accessibility.unwrap_or(false),
macos_calendar: permissions.calendar.unwrap_or(false),
macos_reminders: permissions.reminders.unwrap_or(false),
macos_contacts: permissions.contacts.clone().unwrap_or_default(),
}
}
fn merge_filesystem_permissions_toml(
base: &FilesystemPermissionsToml,
permissions: &FilesystemPermissionsToml,
) -> FilesystemPermissionsToml {
let mut entries = base.entries.clone();
for (path, permission) in &permissions.entries {
merge_filesystem_permission_entry(&mut entries, path, permission.clone());
}
FilesystemPermissionsToml { entries }
}
fn merge_filesystem_permission_entry(
entries: &mut BTreeMap<String, FilesystemPermissionToml>,
path: &str,
permission: FilesystemPermissionToml,
) {
entries
.entry(path.to_string())
.and_modify(|existing| {
*existing = merge_filesystem_permission_toml(existing.clone(), permission.clone());
})
.or_insert(permission);
}
fn merge_filesystem_permission_toml(
base: FilesystemPermissionToml,
permissions: FilesystemPermissionToml,
) -> FilesystemPermissionToml {
match (base, permissions) {
(FilesystemPermissionToml::Access(base), FilesystemPermissionToml::Access(permissions)) => {
FilesystemPermissionToml::Access(more_permissive_access(base, permissions))
}
(FilesystemPermissionToml::Scoped(base), FilesystemPermissionToml::Scoped(permissions)) => {
FilesystemPermissionToml::Scoped(merge_scoped_permissions(base, permissions))
}
(FilesystemPermissionToml::Access(base), FilesystemPermissionToml::Scoped(permissions)) => {
FilesystemPermissionToml::Scoped(merge_scoped_permissions(
BTreeMap::from([(".".to_string(), base)]),
permissions,
))
}
(FilesystemPermissionToml::Scoped(base), FilesystemPermissionToml::Access(permissions)) => {
FilesystemPermissionToml::Scoped(merge_scoped_permissions(
base,
BTreeMap::from([(".".to_string(), permissions)]),
))
}
}
}
fn merge_scoped_permissions(
mut base: BTreeMap<String, FileSystemAccessMode>,
permissions: BTreeMap<String, FileSystemAccessMode>,
) -> BTreeMap<String, FileSystemAccessMode> {
for (subpath, access) in permissions {
base.entry(subpath)
.and_modify(|existing| *existing = more_permissive_access(*existing, access))
.or_insert(access);
}
base
}
fn more_permissive_access(
base: FileSystemAccessMode,
permissions: FileSystemAccessMode,
) -> FileSystemAccessMode {
match (base, permissions) {
(FileSystemAccessMode::Write, _) | (_, FileSystemAccessMode::Write) => {
FileSystemAccessMode::Write
}
(FileSystemAccessMode::Read, _) | (_, FileSystemAccessMode::Read) => {
FileSystemAccessMode::Read
}
_ => FileSystemAccessMode::None,
}
}
fn merge_network_toml(base: &NetworkToml, permissions: &NetworkToml) -> NetworkToml {
NetworkToml {
enabled: permissions.enabled.or(base.enabled),
proxy_url: permissions
.proxy_url
.clone()
.or_else(|| base.proxy_url.clone()),
enable_socks5: permissions.enable_socks5.or(base.enable_socks5),
socks_url: permissions
.socks_url
.clone()
.or_else(|| base.socks_url.clone()),
enable_socks5_udp: permissions.enable_socks5_udp.or(base.enable_socks5_udp),
allow_upstream_proxy: permissions
.allow_upstream_proxy
.or(base.allow_upstream_proxy),
dangerously_allow_non_loopback_proxy: permissions
.dangerously_allow_non_loopback_proxy
.or(base.dangerously_allow_non_loopback_proxy),
dangerously_allow_all_unix_sockets: permissions
.dangerously_allow_all_unix_sockets
.or(base.dangerously_allow_all_unix_sockets),
mode: permissions.mode.or(base.mode),
allowed_domains: permissions
.allowed_domains
.clone()
.or_else(|| base.allowed_domains.clone()),
denied_domains: permissions
.denied_domains
.clone()
.or_else(|| base.denied_domains.clone()),
allow_unix_sockets: permissions
.allow_unix_sockets
.clone()
.or_else(|| base.allow_unix_sockets.clone()),
allow_local_binding: permissions.allow_local_binding.or(base.allow_local_binding),
}
}
fn merge_macos_permissions_toml(
base: &MacOsPermissionsToml,
permissions: &MacOsPermissionsToml,
) -> MacOsPermissionsToml {
let base_runtime = macos_permissions_from_toml(base);
let permissions_runtime = macos_permissions_from_toml(permissions);
macos_permissions_toml_from_runtime(
&crate::sandboxing::macos_permissions::merge_macos_seatbelt_profile_extensions(
Some(&base_runtime),
Some(&permissions_runtime),
)
.unwrap_or_default(),
)
}
fn network_toml_has_values(network: &NetworkToml) -> bool {
network.enabled.is_some()
|| network.proxy_url.is_some()
|| network.enable_socks5.is_some()
|| network.socks_url.is_some()
|| network.enable_socks5_udp.is_some()
|| network.allow_upstream_proxy.is_some()
|| network.dangerously_allow_non_loopback_proxy.is_some()
|| network.dangerously_allow_all_unix_sockets.is_some()
|| network.mode.is_some()
|| network.allowed_domains.is_some()
|| network.denied_domains.is_some()
|| network.allow_unix_sockets.is_some()
|| network.allow_local_binding.is_some()
}
fn macos_permissions_toml_has_values(permissions: &MacOsPermissionsToml) -> bool {
permissions.preferences.is_some()
|| permissions.automations.is_some()
|| permissions.launch_services.is_some()
|| permissions.accessibility.is_some()
|| permissions.calendar.is_some()
|| permissions.reminders.is_some()
|| permissions.contacts.is_some()
}
fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy {
let Some(network) = network else {
return NetworkSandboxPolicy::Restricted;
@@ -281,11 +599,11 @@ fn parse_special_path(path: &str) -> Option<FileSystemSpecialPath> {
match path {
":root" => Some(FileSystemSpecialPath::Root),
":minimal" => Some(FileSystemSpecialPath::Minimal),
":project_roots" => Some(FileSystemSpecialPath::project_roots(/*subpath*/ None)),
":cwd" => Some(FileSystemSpecialPath::CurrentWorkingDirectory),
":project_roots" => Some(FileSystemSpecialPath::project_roots(None)),
":tmpdir" => Some(FileSystemSpecialPath::Tmpdir),
_ if path.starts_with(':') => {
Some(FileSystemSpecialPath::unknown(path, /*subpath*/ None))
}
":slash_tmp" => Some(FileSystemSpecialPath::SlashTmp),
_ if path.starts_with(':') => Some(FileSystemSpecialPath::unknown(path, None)),
_ => None,
}
}

View File

@@ -428,7 +428,7 @@ pub(crate) const MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION: &str = "Allow for this se
// real "Decline" answer, so this lets guardian denials round-trip distinctly from user cancel.
// This is not a user-facing option.
pub(crate) const MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC: &str = "__codex_mcp_decline__";
const MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER: &str = "Allow and don't ask me again";
pub(crate) const MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER: &str = "Allow and don't ask me again";
const MCP_TOOL_APPROVAL_CANCEL: &str = "Cancel";
const MCP_TOOL_APPROVAL_KIND_KEY: &str = "codex_approval_kind";
const MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call";
@@ -723,7 +723,9 @@ fn mcp_tool_approval_decision_from_guardian(decision: ReviewDecision) -> McpTool
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::NetworkPolicyAmendment { .. } => McpToolApprovalDecision::Accept,
ReviewDecision::ApprovedForSession => McpToolApprovalDecision::AcceptForSession,
ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedForAlways => {
McpToolApprovalDecision::AcceptForSession
}
ReviewDecision::Denied | ReviewDecision::Abort => McpToolApprovalDecision::Decline,
}
}

View File

@@ -395,7 +395,9 @@ impl NetworkApprovalService {
ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
PendingApprovalDecision::AllowOnce
}
ReviewDecision::ApprovedForSession => PendingApprovalDecision::AllowForSession,
ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedForAlways => {
PendingApprovalDecision::AllowForSession
}
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {

View File

@@ -150,7 +150,8 @@ impl ToolOrchestrator {
}
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {}
| ReviewDecision::ApprovedForSession
| ReviewDecision::ApprovedForAlways => {}
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {
@@ -299,7 +300,8 @@ impl ToolOrchestrator {
}
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {}
| ReviewDecision::ApprovedForSession
| ReviewDecision::ApprovedForAlways => {}
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {

View File

@@ -547,6 +547,10 @@ impl CoreShellActionProvider {
EscalationDecision::run()
}
}
ReviewDecision::ApprovedForAlways => EscalationDecision::deny(Some(
"Persistent approvals are not supported for command execution"
.to_string(),
)),
ReviewDecision::ApprovedForSession => {
// Currently, we only add session approvals for
// skill scripts because we are storing only the

View File

@@ -82,8 +82,12 @@ where
let already_approved = {
let store = services.tool_approvals.lock().await;
keys.iter()
.all(|key| matches!(store.get(key), Some(ReviewDecision::ApprovedForSession)))
keys.iter().all(|key| {
matches!(
store.get(key),
Some(ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedForAlways)
)
})
};
if already_approved {
@@ -101,7 +105,10 @@ where
],
);
if matches!(decision, ReviewDecision::ApprovedForSession) {
if matches!(
decision,
ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedForAlways
) {
let mut store = services.tool_approvals.lock().await;
for key in keys {
store.put(key, ReviewDecision::ApprovedForSession);

View File

@@ -371,7 +371,83 @@ permissions:
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_skill_script_reject_policy_with_sandbox_approval_true_still_prompts()
async fn shell_zsh_fork_skill_script_rejects_persistent_exec_approval() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(runtime) = zsh_fork_runtime("zsh-fork skill persistent approval rejection test")?
else {
return Ok(());
};
let server = start_mock_server().await;
let tool_call_id = "zsh-fork-skill-approved-for-always";
let test = build_zsh_fork_test(
&server,
runtime,
AskForApproval::OnRequest,
SandboxPolicy::new_workspace_write_policy(),
|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:
write:
- "./output"
"#,
)
.unwrap();
},
)
.await?;
let arguments = shell_command_arguments(&skill_script_command(&test, "hello-mbolin.sh")?.1)?;
let mocks =
mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command")
.await;
submit_turn_with_policies(
&test,
"use $mbolin-test-skill",
AskForApproval::OnRequest,
SandboxPolicy::new_workspace_write_policy(),
)
.await?;
let approval = wait_for_exec_approval_request(&test)
.await
.expect("expected exec approval request before completion");
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::ApprovedForAlways,
})
.await?;
wait_for_turn_complete(&test).await;
let call_output = mocks
.completion
.single_request()
.function_call_output(tool_call_id);
let output = call_output["output"].as_str().unwrap_or_default();
assert!(
output.contains(
"Execution denied: Persistent approvals are not supported for command execution"
),
"expected persistent-approval rejection marker in function_call_output: {output:?}"
);
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_skill_script_reject_policy_with_sandbox_approval_true_skips_prompt()
-> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -3,6 +3,7 @@ use std::path::Path;
use codex_utils_image::PromptImageMode;
use codex_utils_image::load_for_prompt;
use schemars::SchemaGenerator;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
@@ -26,6 +27,7 @@ use codex_git::GhostCommit;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_image::error::ImageProcessingError;
use schemars::JsonSchema;
use schemars::schema::Schema;
use crate::mcp::CallToolResult;
@@ -133,7 +135,7 @@ pub enum MacOsContactsPermission {
ReadWrite,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", try_from = "MacOsAutomationPermissionDe")]
pub enum MacOsAutomationPermission {
#[default]
@@ -190,6 +192,33 @@ impl TryFrom<MacOsAutomationPermissionDe> for MacOsAutomationPermission {
}
}
impl JsonSchema for MacOsAutomationPermission {
fn schema_name() -> String {
"MacOsAutomationPermission".to_string()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
#[derive(JsonSchema)]
#[allow(dead_code)]
#[serde(rename_all = "snake_case")]
enum MacOsAutomationPermissionModeSchema {
None,
All,
}
#[derive(JsonSchema)]
#[allow(dead_code)]
#[serde(untagged)]
enum MacOsAutomationPermissionSchema {
Mode(MacOsAutomationPermissionModeSchema),
BundleIds(Vec<String>),
BundleIdsObject { bundle_ids: Vec<String> },
}
MacOsAutomationPermissionSchema::json_schema(generator)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default)]
pub struct MacOsSeatbeltProfileExtensions {
@@ -1772,6 +1801,25 @@ mod tests {
);
}
#[test]
fn macos_automation_permission_schema_accepts_bundle_id_arrays() {
let schema = schemars::schema_for!(MacOsAutomationPermission);
let schema = serde_json::to_value(&schema).expect("serialize schema");
let variants = schema["oneOf"]
.as_array()
.or_else(|| schema["anyOf"].as_array())
.or_else(|| schema["definitions"]["MacOsAutomationPermission"]["oneOf"].as_array())
.or_else(|| schema["definitions"]["MacOsAutomationPermission"]["anyOf"].as_array())
.expect("schema should use oneOf");
assert!(
variants
.iter()
.any(|variant| variant["type"] == "array" && variant["items"]["type"] == "string"),
"expected bare array schema variant, got {variants:?}"
);
}
#[test]
fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() {
let contents = vec![serde_json::json!({

View File

@@ -3133,6 +3133,10 @@ pub enum ReviewDecision {
/// remainder of the session.
ApprovedForSession,
/// User has approved this permissions request for the current session and
/// wants the granted subset persisted for future sessions.
ApprovedForAlways,
/// User chose to persist a network policy rule (allow/deny) for future
/// requests to the same host.
NetworkPolicyAmendment {
@@ -3157,6 +3161,7 @@ impl ReviewDecision {
ReviewDecision::Approved => "approved",
ReviewDecision::ApprovedExecpolicyAmendment { .. } => "approved_with_amendment",
ReviewDecision::ApprovedForSession => "approved_for_session",
ReviewDecision::ApprovedForAlways => "approved_for_always",
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {

View File

@@ -12,6 +12,7 @@ pub enum PermissionGrantScope {
#[default]
Turn,
Session,
AlwaysAllow,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]

View File

@@ -284,19 +284,23 @@ impl ApprovalOverlay {
return;
};
let granted_permissions = match decision {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => permissions.clone(),
ReviewDecision::Approved
| ReviewDecision::ApprovedForSession
| ReviewDecision::ApprovedForAlways => permissions.clone(),
ReviewDecision::Denied | ReviewDecision::Abort => Default::default(),
ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::NetworkPolicyAmendment { .. } => Default::default(),
};
let scope = if matches!(decision, ReviewDecision::ApprovedForSession) {
PermissionGrantScope::Session
} else {
PermissionGrantScope::Turn
let scope = match decision {
ReviewDecision::ApprovedForSession => PermissionGrantScope::Session,
ReviewDecision::ApprovedForAlways => PermissionGrantScope::AlwaysAllow,
_ => PermissionGrantScope::Turn,
};
if request.thread_label().is_none() {
let message = if granted_permissions.is_empty() {
"You did not grant additional permissions"
} else if matches!(scope, PermissionGrantScope::AlwaysAllow) {
"You granted additional permissions and saved them for future sessions"
} else if matches!(scope, PermissionGrantScope::Session) {
"You granted additional permissions for this session"
} else {
@@ -709,6 +713,7 @@ fn exec_options(
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
}),
ReviewDecision::ApprovedForAlways => None,
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => {
@@ -869,6 +874,12 @@ fn permissions_options() -> Vec<ApprovalOption> {
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
},
ApprovalOption {
label: "Yes, always allow these permissions".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::ApprovedForAlways),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
},
ApprovalOption {
label: "No, continue without permissions".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Denied),
@@ -1271,6 +1282,37 @@ mod tests {
);
}
#[test]
fn additional_permissions_exec_options_hide_persistent_approval() {
let additional_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![absolute_path("/tmp/readme.txt")]),
write: Some(vec![absolute_path("/tmp/out.txt")]),
}),
..Default::default()
};
let options = exec_options(
&[
ReviewDecision::Approved,
ReviewDecision::ApprovedForSession,
ReviewDecision::ApprovedForAlways,
ReviewDecision::Abort,
],
None,
Some(&additional_permissions),
);
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
assert_eq!(
labels,
vec![
"Yes, proceed".to_string(),
"Yes, and allow these permissions for this session".to_string(),
"No, and tell Codex what to do differently".to_string(),
]
);
}
#[test]
fn permissions_options_use_expected_labels() {
let labels: Vec<String> = permissions_options()
@@ -1282,6 +1324,7 @@ mod tests {
vec![
"Yes, grant these permissions".to_string(),
"Yes, grant these permissions for this session".to_string(),
"Yes, always allow these permissions".to_string(),
"No, continue without permissions".to_string(),
]
);
@@ -1314,6 +1357,33 @@ mod tests {
);
}
#[test]
fn permissions_always_shortcut_submits_always_allow_scope() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view =
ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults());
view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::RequestPermissionsResponse { response, .. },
..
} = ev
{
assert_eq!(response.scope, PermissionGrantScope::AlwaysAllow);
saw_op = true;
break;
}
}
assert!(
saw_op,
"expected permission approval decision to emit an always-allow response"
);
}
#[test]
fn additional_permissions_prompt_shows_permission_rule_line() {
let (tx, _rx) = unbounded_channel::<AppEvent>();

View File

@@ -11,6 +11,7 @@ expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))"
1. Yes, grant these permissions (y)
2. Yes, grant these permissions for this session (a)
3. No, continue without permissions (n)
3. Yes, always allow these permissions (p)
4. No, continue without permissions (n)
Press enter to confirm or esc to cancel

View File

@@ -840,6 +840,19 @@ pub fn new_approval_decision_cell(
],
)
}
ApprovedForAlways => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".green(),
vec![
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet,
" every time and saved it".bold(),
],
)
}
NetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {

View File

@@ -350,7 +350,9 @@ struct McpLegacyRequestKey {
fn file_change_decision(decision: &ReviewDecision) -> Result<FileChangeApprovalDecision, String> {
match decision {
ReviewDecision::Approved => Ok(FileChangeApprovalDecision::Accept),
ReviewDecision::ApprovedForSession => Ok(FileChangeApprovalDecision::AcceptForSession),
ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedForAlways => {
Ok(FileChangeApprovalDecision::AcceptForSession)
}
ReviewDecision::Denied => Ok(FileChangeApprovalDecision::Decline),
ReviewDecision::Abort => Ok(FileChangeApprovalDecision::Cancel),
ReviewDecision::ApprovedExecpolicyAmendment { .. } => {

View File

@@ -279,19 +279,23 @@ impl ApprovalOverlay {
return;
};
let granted_permissions = match decision {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => permissions.clone(),
ReviewDecision::Approved
| ReviewDecision::ApprovedForSession
| ReviewDecision::ApprovedForAlways => permissions.clone(),
ReviewDecision::Denied | ReviewDecision::Abort => Default::default(),
ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::NetworkPolicyAmendment { .. } => Default::default(),
};
let scope = if matches!(decision, ReviewDecision::ApprovedForSession) {
PermissionGrantScope::Session
} else {
PermissionGrantScope::Turn
let scope = match decision {
ReviewDecision::ApprovedForSession => PermissionGrantScope::Session,
ReviewDecision::ApprovedForAlways => PermissionGrantScope::AlwaysAllow,
_ => PermissionGrantScope::Turn,
};
if request.thread_label().is_none() {
let message = if granted_permissions.is_empty() {
"You did not grant additional permissions"
} else if matches!(scope, PermissionGrantScope::AlwaysAllow) {
"You granted additional permissions and saved them for future sessions"
} else if matches!(scope, PermissionGrantScope::Session) {
"You granted additional permissions for this session"
} else {
@@ -695,6 +699,7 @@ fn exec_options(
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
}),
ReviewDecision::ApprovedForAlways => None,
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => {
@@ -855,6 +860,12 @@ fn permissions_options() -> Vec<ApprovalOption> {
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
},
ApprovalOption {
label: "Yes, always allow these permissions".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::ApprovedForAlways),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
},
ApprovalOption {
label: "No, continue without permissions".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Denied),
@@ -1257,6 +1268,37 @@ mod tests {
);
}
#[test]
fn additional_permissions_exec_options_hide_persistent_approval() {
let additional_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![absolute_path("/tmp/readme.txt")]),
write: Some(vec![absolute_path("/tmp/out.txt")]),
}),
..Default::default()
};
let options = exec_options(
&[
ReviewDecision::Approved,
ReviewDecision::ApprovedForSession,
ReviewDecision::ApprovedForAlways,
ReviewDecision::Abort,
],
None,
Some(&additional_permissions),
);
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
assert_eq!(
labels,
vec![
"Yes, proceed".to_string(),
"Yes, and allow these permissions for this session".to_string(),
"No, and tell Codex what to do differently".to_string(),
]
);
}
#[test]
fn permissions_options_use_expected_labels() {
let labels: Vec<String> = permissions_options()
@@ -1268,6 +1310,7 @@ mod tests {
vec![
"Yes, grant these permissions".to_string(),
"Yes, grant these permissions for this session".to_string(),
"Yes, always allow these permissions".to_string(),
"No, continue without permissions".to_string(),
]
);
@@ -1300,6 +1343,33 @@ mod tests {
);
}
#[test]
fn permissions_always_shortcut_submits_always_allow_scope() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view =
ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults());
view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::RequestPermissionsResponse { response, .. },
..
} = ev
{
assert_eq!(response.scope, PermissionGrantScope::AlwaysAllow);
saw_op = true;
break;
}
}
assert!(
saw_op,
"expected permission approval decision to emit an always-allow response"
);
}
#[test]
fn additional_permissions_prompt_shows_permission_rule_line() {
let (tx, _rx) = unbounded_channel::<AppEvent>();

View File

@@ -11,6 +11,7 @@ expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))"
1. Yes, grant these permissions (y)
2. Yes, grant these permissions for this session (a)
3. No, continue without permissions (n)
3. Yes, always allow these permissions (p)
4. No, continue without permissions (n)
Press enter to confirm or esc to cancel

View File

@@ -840,6 +840,19 @@ pub fn new_approval_decision_cell(
],
)
}
ApprovedForAlways => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".green(),
vec![
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet,
" every time and saved it".bold(),
],
)
}
NetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {