feat(core) request_permissions always allow

This commit is contained in:
Dylan Hurd
2026-03-16 23:36:02 -07:00
parent db7e02c739
commit d390b3ae52
27 changed files with 1223 additions and 34 deletions
@@ -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.",
@@ -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.",
@@ -67,7 +67,8 @@
"PermissionGrantScope": {
"enum": [
"turn",
"session"
"session",
"alwaysAllow"
],
"type": "string"
}
@@ -3244,7 +3244,8 @@
"PermissionGrantScope": {
"enum": [
"turn",
"session"
"session",
"alwaysAllow"
],
"type": "string"
},
@@ -3381,6 +3382,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.",
@@ -7486,6 +7486,137 @@
}
]
},
"Result_of_CallToolResult_or_String": {
"oneOf": [
{
"properties": {
"Ok": {
"$ref": "#/definitions/CallToolResult"
}
},
"required": [
"Ok"
],
"title": "OkResult_of_CallToolResult_or_String",
"type": "object"
},
{
"properties": {
"Err": {
"type": "string"
}
},
"required": [
"Err"
],
"title": "ErrResult_of_CallToolResult_or_String",
"type": "object"
}
]
},
"ReviewCodeLocation": {
"description": "Location of the code related to a review finding.",
"properties": {
"absolute_file_path": {
"type": "string"
},
"line_range": {
"$ref": "#/definitions/ReviewLineRange"
}
},
"required": [
"absolute_file_path",
"line_range"
],
"type": "object"
},
"ReviewDecision": {
"description": "User's decision in response to an ExecApprovalRequest.",
"oneOf": [
{
"description": "User has approved this command and the agent should execute it.",
"enum": [
"approved"
],
"type": "string"
},
{
"additionalProperties": false,
"description": "User has approved this command and wants to apply the proposed execpolicy amendment so future matching commands are permitted.",
"properties": {
"approved_execpolicy_amendment": {
"properties": {
"proposed_execpolicy_amendment": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"proposed_execpolicy_amendment"
],
"type": "object"
}
},
"required": [
"approved_execpolicy_amendment"
],
"title": "ApprovedExecpolicyAmendmentReviewDecision",
"type": "object"
},
{
"description": "User has approved this request and wants future prompts in the same session-scoped approval cache to be automatically approved for the remainder of the session.",
"enum": [
"approved_for_session"
],
"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.",
"properties": {
"network_policy_amendment": {
"properties": {
"network_policy_amendment": {
"$ref": "#/definitions/NetworkPolicyAmendment"
}
},
"required": [
"network_policy_amendment"
],
"type": "object"
}
},
"required": [
"network_policy_amendment"
],
"title": "NetworkPolicyAmendmentReviewDecision",
"type": "object"
},
{
"description": "User has denied this command and the agent should not execute it, but it should continue the session and try something else.",
"enum": [
"denied"
],
"type": "string"
},
{
"description": "User has denied this command and the agent should not do anything until the user's next command.",
"enum": [
"abort"
],
"type": "string"
}
]
},
"ReviewDelivery": {
"enum": [
"inline",
@@ -12243,4 +12374,4 @@
},
"title": "CodexAppServerProtocolV2",
"type": "object"
}
}
@@ -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";
@@ -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";
@@ -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
}
);
+1 -1
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
{
+55
View File
@@ -742,6 +742,58 @@
}
]
},
"MacOsAutomationPermission": {
"oneOf": [
{
"enum": [
"none",
"all"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"bundle_ids": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"bundle_ids"
],
"type": "object"
}
]
},
"MacOsPermissionsToml": {
"additionalProperties": false,
"properties": {
"accessibility": {
"type": "boolean"
},
"automations": {
"$ref": "#/definitions/MacOsAutomationPermission"
},
"calendar": {
"type": "boolean"
},
"preferences": {
"$ref": "#/definitions/MacOsPreferencesPermission"
}
},
"type": "object"
},
"MacOsPreferencesPermission": {
"enum": [
"none",
"read_only",
"read_write"
],
"type": "string"
},
"MemoriesToml": {
"additionalProperties": false,
"description": "Memories settings loaded from config.toml.",
@@ -1200,6 +1252,9 @@
"filesystem": {
"$ref": "#/definitions/FilesystemPermissionsToml"
},
"macos": {
"$ref": "#/definitions/MacOsPermissionsToml"
},
"network": {
"$ref": "#/definitions/NetworkToml"
}
+20
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;
@@ -3151,6 +3152,7 @@ impl Session {
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,20 @@ 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,
)
.await
{
warn!("failed to persist granted permissions: {err}");
} else {
self.reload_user_config_layer().await;
}
}
match entry {
Some(tx_response) => {
tx_response.send(response).ok();
+69
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,14 @@ 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::MacOsAutomationPermission;
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;
@@ -26,6 +33,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 +2616,66 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req
);
}
#[tokio::test]
async fn notify_request_permissions_response_persists_always_allow_permissions() {
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 = PermissionProfile {
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"),
]),
}),
macos: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
]),
macos_accessibility: true,
macos_calendar: false,
}),
};
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)
);
assert_eq!(
rx.await.expect("response should be delivered").scope,
PermissionGrantScope::AlwaysAllow
);
let serialized = std::fs::read_to_string(session.codex_home().await.join(CONFIG_TOML_FILE))
.expect("config.toml should exist");
let config_toml: crate::config::ConfigToml =
toml::from_str(&serialized).expect("config.toml should parse");
assert_eq!(config_toml.default_permissions.as_deref(), Some("default"));
}
#[tokio::test]
async fn submit_with_id_captures_current_span_trace_context() {
let (session, _turn_context) = make_session_and_context().await;
+223
View File
@@ -15,6 +15,12 @@ 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::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 +326,7 @@ allowed_domains = ["openai.com"]
allow_unix_sockets: None,
allow_local_binding: None,
}),
macos: None,
},
)]),
}
@@ -351,6 +358,7 @@ fn permissions_profiles_network_populates_runtime_network_proxy_spec() -> std::i
enable_socks5: Some(false),
..Default::default()
}),
macos: None,
},
)]),
}),
@@ -396,6 +404,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 +450,7 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re
]),
}),
network: None,
macos: None,
},
)]),
}),
@@ -527,6 +537,7 @@ fn permissions_profiles_require_default_permissions() -> std::io::Result<()> {
)]),
}),
network: None,
macos: None,
},
)]),
}),
@@ -569,6 +580,7 @@ fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io::Resul
)]),
}),
network: None,
macos: None,
},
)]),
}),
@@ -614,6 +626,7 @@ fn permissions_profiles_reject_nested_entries_for_non_project_roots() -> std::io
)]),
}),
network: None,
macos: None,
},
)]),
}),
@@ -808,6 +821,7 @@ fn permissions_profiles_reject_project_root_parent_traversal() -> std::io::Resul
)]),
}),
network: None,
macos: None,
},
)]),
}),
@@ -852,6 +866,7 @@ fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> {
enabled: Some(true),
..Default::default()
}),
macos: None,
},
)]),
}),
@@ -6055,3 +6070,211 @@ 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(),
])),
accessibility: Some(true),
calendar: Some(true),
})
);
}
#[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_accessibility: true,
macos_calendar: true,
})
);
Ok(())
}
#[tokio::test]
async fn persist_granted_permission_profile_creates_default_profile_from_legacy_config()
-> 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"
[sandbox_workspace_write]
network_access = true
exclude_tmpdir_env_var = false
exclude_slash_tmp = false
"#,
)
.expect("config should deserialize"),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
let profile_name = 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_accessibility: true,
macos_calendar: false,
}),
},
)
.await?;
assert_eq!(profile_name, "default");
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
let parsed: ConfigToml = toml::from_str(&serialized)?;
assert_eq!(parsed.default_permissions.as_deref(), Some("default"));
let profile = parsed
.permissions
.expect("permissions should be written")
.entries
.get("default")
.cloned()
.expect("default profile should exist");
let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories"))?;
assert_eq!(
profile.filesystem,
Some(FilesystemPermissionsToml {
entries: BTreeMap::from([
(
":root".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
),
(
":cwd".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
),
(
":slash_tmp".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
),
(
":tmpdir".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
),
(
memories_root.to_string_lossy().to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
),
(
AbsolutePathBuf::try_from(cwd.path().join("logs"))?
.to_string_lossy()
.to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
),
]),
})
);
assert_eq!(
profile.network,
Some(NetworkToml {
enabled: Some(true),
..Default::default()
})
);
assert_eq!(
profile.macos,
Some(MacOsPermissionsToml {
preferences: Some(MacOsPreferencesPermission::ReadWrite),
automations: Some(MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
])),
accessibility: Some(true),
calendar: Some(false),
})
);
Ok(())
}
+78
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",
+100 -7
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,10 @@ 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_effective_permissions;
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 +124,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 +2212,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 +2229,11 @@ 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)?;
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 +2249,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 +2275,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 +2627,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 +2874,92 @@ impl Config {
}
}
pub(crate) fn default_permissions_profile_name(config: &Config) -> Option<String> {
config
.config_layer_stack
.effective_config()
.clone()
.try_into::<PermissionSelectionToml>()
.ok()
.and_then(|selection| selection.default_permissions)
}
pub(crate) fn next_default_permissions_profile_name(config: &Config) -> String {
let permissions = config
.config_layer_stack
.effective_config()
.clone()
.try_into::<ConfigToml>()
.ok()
.and_then(|config_toml| config_toml.permissions)
.unwrap_or_default();
let base = "default".to_string();
if !permissions.entries.contains_key(&base) {
return base;
}
let mut suffix = 2;
loop {
let candidate = format!("default-{suffix}");
if !permissions.entries.contains_key(&candidate) {
return candidate;
}
suffix += 1;
}
}
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 default_permissions = default_permissions_profile_name(config);
let (profile_name, profile) = if let Some(profile_name) = default_permissions {
let existing = config
.config_layer_stack
.effective_config()
.clone()
.try_into::<ConfigToml>()
.ok()
.and_then(|config_toml| config_toml.permissions)
.and_then(|permissions| permissions.entries.get(&profile_name).cloned());
(
profile_name,
merge_permission_profile_toml(existing.as_ref(), &granted_profile),
)
} else {
let profile_name = next_default_permissions_profile_name(config);
let default_network_config = NetworkProxyConfig::default();
let network_config = config
.permissions
.network
.as_ref()
.map(NetworkProxySpec::config)
.unwrap_or(&default_network_config);
let baseline_profile = permission_profile_toml_from_effective_permissions(
&config.permissions.file_system_sandbox_policy,
network_config,
config
.permissions
.macos_seatbelt_profile_extensions
.as_ref(),
)?;
(
profile_name,
merge_permission_profile_toml(Some(&baseline_profile), &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()
@@ -76,6 +76,10 @@ impl NetworkProxySpec {
self.config.network.enabled
}
pub(crate) fn config(&self) -> &NetworkProxyConfig {
&self.config
}
pub fn proxy_host_and_port(&self) -> String {
host_and_port_from_network_addr(&self.config.network.proxy_url, /*default_port*/ 3128)
}
+415 -5
View File
@@ -7,6 +7,12 @@ 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::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 +41,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 +82,15 @@ 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 accessibility: Option<bool>,
pub calendar: Option<bool>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum NetworkModeSchema {
@@ -160,7 +176,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 +203,403 @@ 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);
Ok((
FileSystemSandboxPolicy::restricted(entries),
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,
}
}
pub(crate) fn permission_profile_toml_from_effective_permissions(
file_system: &FileSystemSandboxPolicy,
network: &NetworkProxyConfig,
macos: Option<&MacOsSeatbeltProfileExtensions>,
) -> io::Result<PermissionProfileToml> {
let filesystem = filesystem_permissions_toml_from_policy(file_system)?;
let network = network_toml_from_proxy_config(network);
let macos = macos
.map(macos_permissions_toml_from_runtime)
.filter(macos_permissions_toml_has_values);
Ok(PermissionProfileToml {
filesystem: Some(filesystem).filter(|filesystem| !filesystem.is_empty()),
network: Some(network).filter(network_toml_has_values),
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 filesystem_permissions_toml_from_policy(
policy: &FileSystemSandboxPolicy,
) -> io::Result<FilesystemPermissionsToml> {
let mut entries = BTreeMap::new();
for entry in &policy.entries {
let (path, permission) = filesystem_permission_toml_from_entry(entry)?;
merge_filesystem_permission_entry(&mut entries, &path, permission);
}
Ok(FilesystemPermissionsToml { entries })
}
fn filesystem_permission_toml_from_entry(
entry: &FileSystemSandboxEntry,
) -> io::Result<(String, FilesystemPermissionToml)> {
let access = entry.access;
match &entry.path {
FileSystemPath::Path { path } => Ok((
path.to_string_lossy().to_string(),
FilesystemPermissionToml::Access(access),
)),
FileSystemPath::Special { value } => match value {
FileSystemSpecialPath::Root => Ok((
":root".to_string(),
FilesystemPermissionToml::Access(access),
)),
FileSystemSpecialPath::Minimal => Ok((
":minimal".to_string(),
FilesystemPermissionToml::Access(access),
)),
FileSystemSpecialPath::CurrentWorkingDirectory => {
Ok((":cwd".to_string(), FilesystemPermissionToml::Access(access)))
}
FileSystemSpecialPath::ProjectRoots { subpath: None } => Ok((
":project_roots".to_string(),
FilesystemPermissionToml::Access(access),
)),
FileSystemSpecialPath::ProjectRoots {
subpath: Some(subpath),
} => Ok((
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
subpath.to_string_lossy().to_string(),
access,
)])),
)),
FileSystemSpecialPath::Tmpdir => Ok((
":tmpdir".to_string(),
FilesystemPermissionToml::Access(access),
)),
FileSystemSpecialPath::SlashTmp => Ok((
":slash_tmp".to_string(),
FilesystemPermissionToml::Access(access),
)),
},
}
}
fn network_toml_from_runtime(permissions: &NetworkPermissions) -> NetworkToml {
NetworkToml {
enabled: permissions.enabled,
..Default::default()
}
}
fn network_toml_from_proxy_config(config: &NetworkProxyConfig) -> NetworkToml {
let defaults = NetworkProxyConfig::default();
NetworkToml {
enabled: config.network.enabled.then_some(true),
proxy_url: (config.network.proxy_url != defaults.network.proxy_url)
.then(|| config.network.proxy_url.clone()),
enable_socks5: (config.network.enable_socks5 != defaults.network.enable_socks5)
.then_some(config.network.enable_socks5),
socks_url: (config.network.socks_url != defaults.network.socks_url)
.then(|| config.network.socks_url.clone()),
enable_socks5_udp: (config.network.enable_socks5_udp != defaults.network.enable_socks5_udp)
.then_some(config.network.enable_socks5_udp),
allow_upstream_proxy: (config.network.allow_upstream_proxy
!= defaults.network.allow_upstream_proxy)
.then_some(config.network.allow_upstream_proxy),
dangerously_allow_non_loopback_proxy: (config.network.dangerously_allow_non_loopback_proxy
!= defaults.network.dangerously_allow_non_loopback_proxy)
.then_some(config.network.dangerously_allow_non_loopback_proxy),
dangerously_allow_all_unix_sockets: (config.network.dangerously_allow_all_unix_sockets
!= defaults.network.dangerously_allow_all_unix_sockets)
.then_some(config.network.dangerously_allow_all_unix_sockets),
mode: (config.network.mode != defaults.network.mode).then_some(config.network.mode),
allowed_domains: Some(config.network.allowed_domains.clone())
.filter(|allowed_domains| !allowed_domains.is_empty()),
denied_domains: Some(config.network.denied_domains.clone())
.filter(|denied_domains| !denied_domains.is_empty()),
allow_unix_sockets: Some(config.network.allow_unix_sockets.clone())
.filter(|allow_unix_sockets| !allow_unix_sockets.is_empty()),
allow_local_binding: (config.network.allow_local_binding
!= defaults.network.allow_local_binding)
.then_some(config.network.allow_local_binding),
}
}
fn macos_permissions_toml_from_runtime(
permissions: &MacOsSeatbeltProfileExtensions,
) -> MacOsPermissionsToml {
MacOsPermissionsToml {
preferences: Some(permissions.macos_preferences.clone()),
automations: Some(permissions.macos_automation.clone()),
accessibility: Some(permissions.macos_accessibility),
calendar: Some(permissions.macos_calendar),
}
}
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_accessibility: permissions.accessibility.unwrap_or(false),
macos_calendar: permissions.calendar.unwrap_or(false),
}
}
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.accessibility.is_some()
|| permissions.calendar.is_some()
}
fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy {
let Some(network) = network else {
return NetworkSandboxPolicy::Restricted;
@@ -281,11 +691,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,
}
}
+3 -1
View File
@@ -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,
}
}
+3 -1
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 {
+4 -2
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 {
@@ -540,7 +540,8 @@ impl CoreShellActionProvider {
.await?
{
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForAlways => {
if needs_escalation {
EscalationDecision::escalate(escalation_execution.clone())
} else {
+10 -3
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);
+5
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 {
@@ -12,6 +12,7 @@ pub enum PermissionGrantScope {
#[default]
Turn,
Session,
AlwaysAllow,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
@@ -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,14 @@ fn exec_options(
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
}),
ReviewDecision::ApprovedForAlways => {
additional_permissions.as_ref().map(|_| ApprovalOption {
label: "Yes, and always allow these permissions".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::ApprovedForAlways),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
})
}
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => {
@@ -869,6 +881,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),
@@ -1282,6 +1300,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 +1333,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>();
@@ -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
+13
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 {