mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
feat(core) request_permissions always allow
This commit is contained in:
@@ -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.",
|
||||
|
||||
+132
-1
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -540,7 +540,8 @@ impl CoreShellActionProvider {
|
||||
.await?
|
||||
{
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::ApprovedForAlways => {
|
||||
if needs_escalation {
|
||||
EscalationDecision::escalate(escalation_execution.clone())
|
||||
} else {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
+2
-1
@@ -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