mirror of
https://github.com/openai/codex.git
synced 2026-05-10 22:32:36 +00:00
Compare commits
7 Commits
codex/viya
...
dh--reques
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd9d4a9090 | ||
|
|
9ab3a31630 | ||
|
|
26e9c745dc | ||
|
|
9bf61f70aa | ||
|
|
d46aa971b1 | ||
|
|
500c67f749 | ||
|
|
d390b3ae52 |
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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,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>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 { .. } => {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user