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
Unverified
parent bbff4ee61a
commit 9d824cf4b4
10 changed files with 666 additions and 17 deletions
@@ -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(