app-server: accept command permission profiles (#18283)

## Why

`command/exec` is another app-server entry point that can run under
caller-provided permissions. It needs to accept `PermissionProfile`
directly so command execution is not left behind on `SandboxPolicy`
while thread APIs move forward.

Command-level profiles also need to preserve the semantics clients
expect from profile-relative paths. `:cwd` and cwd-relative deny globs
should be anchored to the resolved command cwd for a command-specific
profile, while configured deny-read restrictions such as `**/*.env =
none` still need to be enforced because they can come from config or
requirements rather than the command override itself.

## What Changed

This adds `permissionProfile` to `CommandExecParams`, rejects requests
that combine it with `sandboxPolicy`, and converts accepted profiles
into the runtime filesystem/network permissions used for command
execution.

When a command supplies a profile, the app-server resolves that profile
against the command cwd instead of the thread/server cwd. It also
preserves configured deny-read entries and `globScanMaxDepth` on the
effective filesystem policy so one-off command overrides cannot drop
those read protections. The PR also updates app-server docs/schema
fixtures and adds command-exec coverage for accepted, rejected,
cwd-scoped, and deny-read-preserving profile paths.

## Verification

- `cargo test -p codex-app-server
command_exec_permission_profile_cwd_uses_command_cwd`
- `cargo test -p codex-app-server
command_profile_preserves_configured_deny_read_restrictions`
- `cargo test -p codex-app-server
command_exec_accepts_permission_profile`
- `cargo test -p codex-app-server
command_exec_rejects_sandbox_policy_with_permission_profile`
- `just fix -p codex-app-server`

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/18283).
* #18288
* #18287
* #18286
* #18285
* #18284
* __->__ #18283
This commit is contained in:
Michael Bolin
2026-04-22 22:33:16 -07:00
committed by GitHub
parent bbff4ee61a
commit 9d824cf4b4
10 changed files with 666 additions and 17 deletions
+9 -3
View File
@@ -819,7 +819,13 @@ Run a standalone command (argv vector) in the servers 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 servers 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.
@@ -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(
@@ -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,