mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
app-server: accept command permission profiles
This commit is contained in:
@@ -810,7 +810,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
|
||||
@@ -823,12 +829,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.
|
||||
|
||||
@@ -2122,7 +2122,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 {
|
||||
@@ -2254,13 +2263,52 @@ 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 (file_system_sandbox_policy, network_sandbox_policy) =
|
||||
permission_profile.to_runtime_permissions();
|
||||
(
|
||||
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);
|
||||
@@ -2277,12 +2325,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();
|
||||
|
||||
@@ -5,6 +5,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked;
|
||||
use app_test_support::to_response;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use codex_app_server_protocol::AdditionalNetworkPermissions;
|
||||
use codex_app_server_protocol::CommandExecOutputDeltaNotification;
|
||||
use codex_app_server_protocol::CommandExecOutputStream;
|
||||
use codex_app_server_protocol::CommandExecParams;
|
||||
@@ -13,9 +14,16 @@ 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::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,91 @@ 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(())
|
||||
}
|
||||
|
||||
#[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 +305,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 +343,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 +381,7 @@ async fn command_exec_rejects_negative_timeout_ms() -> Result<()> {
|
||||
env: None,
|
||||
size: None,
|
||||
sandbox_policy: None,
|
||||
permission_profile: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -320,6 +419,7 @@ async fn command_exec_without_process_id_rejects_streaming() -> Result<()> {
|
||||
env: None,
|
||||
size: None,
|
||||
sandbox_policy: None,
|
||||
permission_profile: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -361,6 +461,7 @@ async fn command_exec_non_streaming_respects_output_cap() -> Result<()> {
|
||||
env: None,
|
||||
size: None,
|
||||
sandbox_policy: None,
|
||||
permission_profile: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -408,6 +509,7 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> {
|
||||
env: None,
|
||||
size: None,
|
||||
sandbox_policy: None,
|
||||
permission_profile: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -471,6 +573,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 +649,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 +723,7 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> {
|
||||
cols: 101,
|
||||
}),
|
||||
sandbox_policy: None,
|
||||
permission_profile: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -888,6 +993,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(AdditionalNetworkPermissions {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user