This commit is contained in:
Dylan Hurd
2026-03-16 23:36:02 -07:00
parent 500c67f749
commit d46aa971b1
11 changed files with 299 additions and 166 deletions
@@ -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",
@@ -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",
@@ -7486,137 +7486,6 @@
}
]
},
"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",
@@ -12374,4 +12243,4 @@
},
"title": "CodexAppServerProtocolV2",
"type": "object"
}
}
+15 -7
View File
@@ -743,16 +743,17 @@
]
},
"MacOsAutomationPermission": {
"oneOf": [
"anyOf": [
{
"enum": [
"none",
"all"
],
"type": "string"
"$ref": "#/definitions/MacOsAutomationPermissionModeSchema"
},
{
"items": {
"type": "string"
},
"type": "array"
},
{
"additionalProperties": false,
"properties": {
"bundle_ids": {
"items": {
@@ -768,6 +769,13 @@
}
]
},
"MacOsAutomationPermissionModeSchema": {
"enum": [
"none",
"all"
],
"type": "string"
},
"MacOsPermissionsToml": {
"additionalProperties": false,
"properties": {
+74
View File
@@ -25,6 +25,7 @@ use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use serde::Deserialize;
@@ -679,6 +680,7 @@ fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> {
)]),
}),
network: None,
macos: None,
})?;
assert_eq!(
@@ -723,6 +725,7 @@ fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std
)]),
}),
network: None,
macos: None,
})?;
assert_eq!(
@@ -749,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!(
@@ -782,6 +786,7 @@ fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result
entries: BTreeMap::new(),
}),
network: None,
macos: None,
})?;
assert_eq!(
@@ -6217,6 +6222,7 @@ exclude_slash_tmp = false
assert_eq!(parsed.default_permissions.as_deref(), Some("default"));
let profile = parsed
.permissions
.as_ref()
.expect("permissions should be written")
.entries
.get("default")
@@ -6278,3 +6284,71 @@ exclude_slash_tmp = false
Ok(())
}
#[tokio::test]
async fn persist_granted_permission_profile_preserves_unrestricted_legacy_default()
-> 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 = "danger-full-access"
"#,
)
.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::default(),
)
.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)?;
let profile = parsed
.permissions
.as_ref()
.expect("permissions should be written")
.entries
.get("default")
.cloned()
.expect("default profile should exist");
assert_eq!(
profile.filesystem,
Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":root".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
)]),
})
);
let reloaded = Config::load_from_base_config_with_overrides(
parsed,
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
assert_eq!(
reloaded.permissions.file_system_sandbox_policy.kind,
FileSystemSandboxKind::Unrestricted
);
Ok(())
}
+5 -1
View File
@@ -2233,7 +2233,11 @@ impl Config {
mut file_system_sandbox_policy,
network_sandbox_policy,
macos_seatbelt_profile_extensions,
) = compile_permission_profile(permissions, default_permissions)?;
) = 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 { .. }) {
+28 -1
View File
@@ -204,9 +204,15 @@ 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,
))
@@ -329,6 +335,13 @@ fn filesystem_permissions_toml_from_policy(
) -> io::Result<FilesystemPermissionsToml> {
let mut entries = BTreeMap::new();
if policy.has_full_disk_write_access() {
entries.insert(
":root".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
);
}
for entry in &policy.entries {
let (path, permission) = filesystem_permission_toml_from_entry(entry)?;
merge_filesystem_permission_entry(&mut entries, &path, permission);
@@ -379,6 +392,20 @@ fn filesystem_permission_toml_from_entry(
":slash_tmp".to_string(),
FilesystemPermissionToml::Access(access),
)),
FileSystemSpecialPath::Unknown {
path,
subpath: None,
} => Ok((path.clone(), FilesystemPermissionToml::Access(access))),
FileSystemSpecialPath::Unknown {
path,
subpath: Some(subpath),
} => Ok((
path.clone(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
subpath.to_string_lossy().to_string(),
access,
)])),
)),
},
}
}
@@ -540,14 +540,17 @@ impl CoreShellActionProvider {
.await?
{
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForAlways => {
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
if needs_escalation {
EscalationDecision::escalate(escalation_execution.clone())
} else {
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
+77 -1
View File
@@ -371,7 +371,83 @@ permissions:
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_skill_script_reject_policy_with_sandbox_approval_true_still_prompts()
async fn shell_zsh_fork_skill_script_rejects_persistent_exec_approval() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(runtime) = zsh_fork_runtime("zsh-fork skill persistent approval rejection test")?
else {
return Ok(());
};
let server = start_mock_server().await;
let tool_call_id = "zsh-fork-skill-approved-for-always";
let test = build_zsh_fork_test(
&server,
runtime,
AskForApproval::OnRequest,
SandboxPolicy::new_workspace_write_policy(),
|home| {
write_skill_with_shell_script(home, "mbolin-test-skill", "hello-mbolin.sh").unwrap();
write_skill_metadata(
home,
"mbolin-test-skill",
r#"
permissions:
file_system:
write:
- "./output"
"#,
)
.unwrap();
},
)
.await?;
let arguments = shell_command_arguments(&skill_script_command(&test, "hello-mbolin.sh")?.1)?;
let mocks =
mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command")
.await;
submit_turn_with_policies(
&test,
"use $mbolin-test-skill",
AskForApproval::OnRequest,
SandboxPolicy::new_workspace_write_policy(),
)
.await?;
let approval = wait_for_exec_approval_request(&test)
.await
.expect("expected exec approval request before completion");
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::ApprovedForAlways,
})
.await?;
wait_for_turn_complete(&test).await;
let call_output = mocks
.completion
.single_request()
.function_call_output(tool_call_id);
let output = call_output["output"].as_str().unwrap_or_default();
assert!(
output.contains(
"Execution denied: Persistent approvals are not supported for command execution"
),
"expected persistent-approval rejection marker in function_call_output: {output:?}"
);
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_skill_script_reject_policy_with_sandbox_approval_true_skips_prompt()
-> Result<()> {
skip_if_no_network!(Ok(()));
+49 -1
View File
@@ -3,6 +3,7 @@ use std::path::Path;
use codex_utils_image::PromptImageMode;
use codex_utils_image::load_for_prompt;
use schemars::SchemaGenerator;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
@@ -26,6 +27,7 @@ use codex_git::GhostCommit;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_image::error::ImageProcessingError;
use schemars::JsonSchema;
use schemars::schema::Schema;
use crate::mcp::CallToolResult;
@@ -133,7 +135,7 @@ pub enum MacOsContactsPermission {
ReadWrite,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", try_from = "MacOsAutomationPermissionDe")]
pub enum MacOsAutomationPermission {
#[default]
@@ -190,6 +192,33 @@ impl TryFrom<MacOsAutomationPermissionDe> for MacOsAutomationPermission {
}
}
impl JsonSchema for MacOsAutomationPermission {
fn schema_name() -> String {
"MacOsAutomationPermission".to_string()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
#[derive(JsonSchema)]
#[allow(dead_code)]
#[serde(rename_all = "snake_case")]
enum MacOsAutomationPermissionModeSchema {
None,
All,
}
#[derive(JsonSchema)]
#[allow(dead_code)]
#[serde(untagged)]
enum MacOsAutomationPermissionSchema {
Mode(MacOsAutomationPermissionModeSchema),
BundleIds(Vec<String>),
BundleIdsObject { bundle_ids: Vec<String> },
}
MacOsAutomationPermissionSchema::json_schema(generator)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default)]
pub struct MacOsSeatbeltProfileExtensions {
@@ -1772,6 +1801,25 @@ mod tests {
);
}
#[test]
fn macos_automation_permission_schema_accepts_bundle_id_arrays() {
let schema = schemars::schema_for!(MacOsAutomationPermission);
let schema = serde_json::to_value(&schema).expect("serialize schema");
let variants = schema["oneOf"]
.as_array()
.or_else(|| schema["anyOf"].as_array())
.or_else(|| schema["definitions"]["MacOsAutomationPermission"]["oneOf"].as_array())
.or_else(|| schema["definitions"]["MacOsAutomationPermission"]["anyOf"].as_array())
.expect("schema should use oneOf");
assert!(
variants
.iter()
.any(|variant| variant["type"] == "array" && variant["items"]["type"] == "string"),
"expected bare array schema variant, got {variants:?}"
);
}
#[test]
fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() {
let contents = vec![serde_json::json!({