diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d941238ca80d..8f2324d8144f 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -218,6 +218,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -234,7 +245,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 5e5269bfb24b..59f1afc0d3c2 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6432,6 +6432,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -6448,7 +6459,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index aaf08566270e..07d3a97557d9 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3012,6 +3012,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -3028,7 +3039,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index 986ec4cc1e0b..4def45c04998 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -27,6 +27,217 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "NetworkAccess": { "enum": [ "restricted", @@ -34,6 +245,64 @@ ], "type": "string" }, + "PermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "PermissionProfileFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "ReadOnlyAccess": { "oneOf": [ { @@ -247,6 +516,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -263,7 +543,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts index 097cfdfeccda..659974feafef 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; /** @@ -92,6 +93,14 @@ size?: CommandExecTerminalSize | null, * Optional sandbox policy for this command. * * Uses the same shape as thread/turn execution sandbox configuration and - * defaults to the user's configured policy when omitted. + * defaults to the user's configured policy when omitted. Cannot be + * combined with `permissionProfile`. */ -sandboxPolicy?: SandboxPolicy | null, }; +sandboxPolicy?: SandboxPolicy | null, +/** + * Optional full permissions profile for this command. + * + * Defaults to the user's configured permissions when omitted. Cannot be + * combined with `sandboxPolicy`. + */ +permissionProfile?: PermissionProfile | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/mappers.rs b/codex-rs/app-server-protocol/src/protocol/mappers.rs index 93f12691be56..dae91e650f90 100644 --- a/codex-rs/app-server-protocol/src/protocol/mappers.rs +++ b/codex-rs/app-server-protocol/src/protocol/mappers.rs @@ -18,6 +18,7 @@ impl From for v2::CommandExecParams { env: None, size: None, sandbox_policy: value.sandbox_policy.map(std::convert::Into::into), + permission_profile: None, } } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9f1801c64f31..45d30f317f7c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3126,9 +3126,16 @@ pub struct CommandExecParams { /// Optional sandbox policy for this command. /// /// Uses the same shape as thread/turn execution sandbox configuration and - /// defaults to the user's configured policy when omitted. + /// defaults to the user's configured policy when omitted. Cannot be + /// combined with `permissionProfile`. #[ts(optional = nullable)] pub sandbox_policy: Option, + /// Optional full permissions profile for this command. + /// + /// Defaults to the user's configured permissions when omitted. Cannot be + /// combined with `sandboxPolicy`. + #[ts(optional = nullable)] + pub permission_profile: Option, } /// Final buffered result for `command/exec`. @@ -8270,6 +8277,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, } ); } @@ -8290,6 +8298,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8304,6 +8313,7 @@ mod tests { "env": null, "size": null, "sandboxPolicy": null, + "permissionProfile": null, "outputBytesCap": null, }) ); @@ -8329,6 +8339,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8345,6 +8356,7 @@ mod tests { "env": null, "size": null, "sandboxPolicy": null, + "permissionProfile": null, }) ); @@ -8373,6 +8385,7 @@ mod tests { ])), size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8391,6 +8404,7 @@ mod tests { }, "size": null, "sandboxPolicy": null, + "permissionProfile": null, }) ); @@ -8460,6 +8474,7 @@ mod tests { cols: 120, }), sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8478,6 +8493,7 @@ mod tests { "cols": 120, }, "sandboxPolicy": null, + "permissionProfile": null, }) ); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8a6ce3b728ed..9c0ad202c8e1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -819,7 +819,13 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin "cwd": "/Users/me/project", // optional; defaults to server cwd "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true - "sandboxPolicy": { "type": "workspaceWrite" }, // optional; defaults to user config + "permissionProfile": { // optional; defaults to user config + "fileSystem": { "entries": [ + { "path": { "type": "special", "value": { "kind": "root" } }, "access": "read" }, + { "path": { "type": "special", "value": { "kind": "current_working_directory" } }, "access": "write" } + ] }, + "network": { "enabled": false } + }, "outputBytesCap": 1048576, // optional; per-stream capture cap "disableOutputCap": false, // optional; cannot be combined with outputBytesCap "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout @@ -832,12 +838,12 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin } } ``` -- For clients that are already sandboxed externally, set `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. +- For clients that are already sandboxed externally, set the legacy `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. Notes: - Empty `command` arrays are rejected. -- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`). +- Prefer `permissionProfile` for command permission overrides. The legacy `sandboxPolicy` field accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`), but cannot be combined with `permissionProfile`. - `env` merges into the environment produced by the server's shell environment policy. Matching names are overridden; unspecified variables are left intact. - When omitted, `timeoutMs` falls back to the server default. - When omitted, `outputBytesCap` falls back to the server default of 1 MiB per stream. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 525e75e65018..e1ee0557570e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -310,6 +310,8 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationStartParams; @@ -2073,7 +2075,16 @@ impl CodexMessageProcessor { env: env_overrides, size, sandbox_policy, + permission_profile, } = params; + if sandbox_policy.is_some() && permission_profile.is_some() { + self.send_invalid_request_error( + request_id, + "`permissionProfile` cannot be combined with `sandboxPolicy`".to_string(), + ) + .await; + return; + } if size.is_some() && !tty { let error = JSONRPCErrorError { @@ -2185,7 +2196,11 @@ impl CodexMessageProcessor { } else { ExecCapturePolicy::ShellTool }; - let sandbox_cwd = self.config.cwd.clone(); + let sandbox_cwd = if permission_profile.is_some() { + cwd.clone() + } else { + self.config.cwd.clone() + }; let exec_params = ExecParams { command, cwd: cwd.clone(), @@ -2205,13 +2220,56 @@ impl CodexMessageProcessor { arg0: None, }; - let requested_policy = sandbox_policy.map(|policy| policy.to_core()); let ( effective_policy, effective_file_system_sandbox_policy, effective_network_sandbox_policy, - ) = match requested_policy { - Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) { + ) = if let Some(permission_profile) = permission_profile { + let permission_profile = + codex_protocol::models::PermissionProfile::from(permission_profile); + let sandbox_policy = match permission_profile.to_legacy_sandbox_policy(&sandbox_cwd) { + Ok(sandbox_policy) => sandbox_policy, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid permission profile: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + }; + match self + .config + .permissions + .sandbox_policy + .can_set(&sandbox_policy) + { + Ok(()) => { + let (mut file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + Self::preserve_configured_deny_read_restrictions( + &mut file_system_sandbox_policy, + &self.config.permissions.file_system_sandbox_policy, + ); + ( + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + ) + } + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid permission profile: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + } + } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { + match self.config.permissions.sandbox_policy.can_set(&policy) { Ok(()) => { let file_system_sandbox_policy = codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, &sandbox_cwd); @@ -2228,12 +2286,13 @@ impl CodexMessageProcessor { self.outgoing.send_error(request, error).await; return; } - }, - None => ( + } + } else { + ( self.config.permissions.sandbox_policy.get().clone(), self.config.permissions.file_system_sandbox_policy.clone(), self.config.permissions.network_sandbox_policy, - ), + ) }; let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); @@ -2290,6 +2349,30 @@ impl CodexMessageProcessor { } } + fn preserve_configured_deny_read_restrictions( + file_system_sandbox_policy: &mut FileSystemSandboxPolicy, + configured_file_system_sandbox_policy: &FileSystemSandboxPolicy, + ) { + if file_system_sandbox_policy.glob_scan_max_depth.is_none() { + file_system_sandbox_policy.glob_scan_max_depth = + configured_file_system_sandbox_policy.glob_scan_max_depth; + } + + for deny_entry in configured_file_system_sandbox_policy + .entries + .iter() + .filter(|entry| entry.access == FileSystemAccessMode::None) + { + if !file_system_sandbox_policy + .entries + .iter() + .any(|entry| entry == deny_entry) + { + file_system_sandbox_policy.entries.push(deny_entry.clone()); + } + } + } + async fn command_exec_write( &self, request_id: ConnectionRequestId, @@ -10085,6 +10168,8 @@ mod tests { use codex_model_provider_info::WireApi; use codex_protocol::ThreadId; use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; @@ -10352,6 +10437,36 @@ mod tests { )); } + #[test] + fn command_profile_preserves_configured_deny_read_restrictions() { + let readable_entry = FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: test_path_buf("/tmp/project").abs(), + }, + access: FileSystemAccessMode::Read, + }; + let deny_entry = FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "/tmp/project/**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }; + let mut file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![readable_entry.clone()]); + let mut configured_file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]); + configured_file_system_sandbox_policy.glob_scan_max_depth = Some(2); + + CodexMessageProcessor::preserve_configured_deny_read_restrictions( + &mut file_system_sandbox_policy, + &configured_file_system_sandbox_policy, + ); + + let mut expected = FileSystemSandboxPolicy::restricted(vec![readable_entry, deny_entry]); + expected.glob_scan_max_depth = Some(2); + assert_eq!(file_system_sandbox_policy, expected); + } + #[test] fn config_load_error_marks_cloud_requirements_failures_for_relogin() { let err = std::io::Error::other(CloudRequirementsLoadError::new( diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index d7645f13e393..c24d2e80db5b 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -13,9 +13,17 @@ use codex_app_server_protocol::CommandExecResponse; use codex_app_server_protocol::CommandExecTerminalSize; use codex_app_server_protocol::CommandExecTerminateParams; use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; +use codex_app_server_protocol::FileSystemSpecialPath; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::PermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; +use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::collections::HashMap; use tempfile::TempDir; @@ -57,6 +65,7 @@ async fn command_exec_without_streams_can_be_terminated() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; let terminate_request_id = mcp @@ -109,6 +118,7 @@ async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Resul env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -167,6 +177,7 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un ])), size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -186,6 +197,158 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un Ok(()) } +#[tokio::test] +async fn command_exec_accepts_permission_profile() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'profile'".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(root_read_only_permission_profile()), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "profile".to_string(), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn command_exec_permission_profile_cwd_uses_command_cwd() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let command_dir = codex_home.path().join("command-cwd"); + std::fs::create_dir(&command_dir)?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let mut permission_profile = root_read_only_permission_profile(); + permission_profile + .file_system + .as_mut() + .expect("root read-only helper should include filesystem permissions") + .entries + .push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }); + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf child > child.txt && ! printf parent > ../parent.txt".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: Some("command-cwd".into()), + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(permission_profile), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response.exit_code, 0, + "parent cwd write should fail under command-cwd-scoped profile: {response:?}" + ); + assert_eq!( + std::fs::read_to_string(command_dir.join("child.txt"))?, + "child" + ); + assert!( + !codex_home.path().join("parent.txt").exists(), + "permissionProfile :cwd write should not grant the server cwd when command cwd differs" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: Some(SandboxPolicy::DangerFullAccess), + permission_profile: Some(root_read_only_permission_profile()), + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "`permissionProfile` cannot be combined with `sandboxPolicy`" + ); + + Ok(()) +} + #[tokio::test] async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; @@ -209,6 +372,7 @@ async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -246,6 +410,7 @@ async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Resu env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -283,6 +448,7 @@ async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -320,6 +486,7 @@ async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -361,6 +528,7 @@ async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -408,6 +576,7 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -471,6 +640,7 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -546,6 +716,7 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<( env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -619,6 +790,7 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { cols: 101, }), sandbox_policy: None, + permission_profile: None, }) .await?; @@ -888,6 +1060,23 @@ fn decode_delta_notification( serde_json::from_value(params).context("deserialize command/exec/outputDelta notification") } +fn root_read_only_permission_profile() -> PermissionProfile { + PermissionProfile { + network: Some(PermissionProfileNetworkPermissions { + enabled: Some(false), + }), + file_system: Some(PermissionProfileFileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }], + glob_scan_max_depth: None, + }), + } +} + async fn read_initialize_response( stream: &mut super::connection_handling_websocket::WsClient, request_id: i64,