feat: introduce ExternalSandbox policy (#8290)

## Description

Introduced `ExternalSandbox` policy to cover use case when sandbox
defined by outside environment, effectively it translates to
`SandboxMode#DangerFullAccess` for file system (since sandbox configured
on container level) and configurable `network_access` (either Restricted
or Enabled by outside environment).

as example you can configure `ExternalSandbox` policy as part of
`sendUserTurn` v1 app_server API:

```
 {
            "conversationId": <id>,
            "cwd": <cwd>,
            "approvalPolicy": "never",
            "sandboxPolicy": {
                  "type": ""external-sandbox",
                  "network_access": "enabled"/"restricted"
            },
            "model": <model>,
            "effort": <effort>,
            ....
        }
```
This commit is contained in:
Anton Panasenko authored and GitHub committed 2025-12-18 17:02:03 -08:00
1 parent 3d4ced3ff5
commit 3429de21b3
26 files changed
+435 -39

No files matched your search

+3 -1
View File
@@ -1244,6 +1244,8 @@ dependencies = [
"codex-lmstudio", "codex-lmstudio",
"codex-ollama", "codex-ollama",
"codex-protocol", "codex-protocol",
"codex-utils-absolute-path",
"pretty_assertions",
"serde", "serde",
"toml 0.9.5", "toml 0.9.5",
] ]
@@ -1316,7 +1318,6 @@ dependencies = [
"sha2", "sha2",
"shlex", "shlex",
"similar", "similar",
"strum_macros 0.27.2",
"tempfile", "tempfile",
"test-case", "test-case",
"test-log", "test-log",
@@ -1913,6 +1914,7 @@ dependencies = [
"codex-utils-absolute-path", "codex-utils-absolute-path",
"dirs-next", "dirs-next",
"dunce", "dunce",
"pretty_assertions",
"rand 0.8.5", "rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
@@ -18,6 +18,7 @@ use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus;
use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
use codex_protocol::protocol::SessionSource as CoreSessionSource; use codex_protocol::protocol::SessionSource as CoreSessionSource;
@@ -470,6 +471,15 @@ pub enum ApprovalDecision {
Cancel, Cancel,
} }
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum NetworkAccess {
#[default]
Restricted,
Enabled,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")] #[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")] #[ts(tag = "type")]
@@ -479,6 +489,12 @@ pub enum SandboxPolicy {
ReadOnly, ReadOnly,
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")] #[ts(rename_all = "camelCase")]
ExternalSandbox {
#[serde(default)]
network_access: NetworkAccess,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
WorkspaceWrite { WorkspaceWrite {
#[serde(default)] #[serde(default)]
writable_roots: Vec<AbsolutePathBuf>, writable_roots: Vec<AbsolutePathBuf>,
@@ -498,6 +514,14 @@ impl SandboxPolicy {
codex_protocol::protocol::SandboxPolicy::DangerFullAccess codex_protocol::protocol::SandboxPolicy::DangerFullAccess
} }
SandboxPolicy::ReadOnly => codex_protocol::protocol::SandboxPolicy::ReadOnly, SandboxPolicy::ReadOnly => codex_protocol::protocol::SandboxPolicy::ReadOnly,
SandboxPolicy::ExternalSandbox { network_access } => {
codex_protocol::protocol::SandboxPolicy::ExternalSandbox {
network_access: match network_access {
NetworkAccess::Restricted => CoreNetworkAccess::Restricted,
NetworkAccess::Enabled => CoreNetworkAccess::Enabled,
},
}
}
SandboxPolicy::WorkspaceWrite { SandboxPolicy::WorkspaceWrite {
writable_roots, writable_roots,
network_access, network_access,
@@ -520,6 +544,14 @@ impl From<codex_protocol::protocol::SandboxPolicy> for SandboxPolicy {
SandboxPolicy::DangerFullAccess SandboxPolicy::DangerFullAccess
} }
codex_protocol::protocol::SandboxPolicy::ReadOnly => SandboxPolicy::ReadOnly, codex_protocol::protocol::SandboxPolicy::ReadOnly => SandboxPolicy::ReadOnly,
codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => {
SandboxPolicy::ExternalSandbox {
network_access: match network_access {
CoreNetworkAccess::Restricted => NetworkAccess::Restricted,
CoreNetworkAccess::Enabled => NetworkAccess::Enabled,
},
}
}
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { codex_protocol::protocol::SandboxPolicy::WorkspaceWrite {
writable_roots, writable_roots,
network_access, network_access,
@@ -1916,11 +1948,30 @@ mod tests {
use codex_protocol::items::TurnItem; use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem; use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem; use codex_protocol::items::WebSearchItem;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::user_input::UserInput as CoreUserInput; use codex_protocol::user_input::UserInput as CoreUserInput;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use serde_json::json; use serde_json::json;
use std::path::PathBuf; use std::path::PathBuf;
#[test]
fn sandbox_policy_round_trips_external_sandbox_network_access() {
let v2_policy = SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
};
let core_policy = v2_policy.to_core();
assert_eq!(
core_policy,
codex_protocol::protocol::SandboxPolicy::ExternalSandbox {
network_access: CoreNetworkAccess::Enabled,
}
);
let back_to_v2 = SandboxPolicy::from(core_policy);
assert_eq!(back_to_v2, v2_policy);
}
#[test] #[test]
fn core_turn_item_into_thread_item_converts_supported_variants() { fn core_turn_item_into_thread_item_converts_supported_variants() {
let user_item = TurnItem::UserMessage(UserMessageItem { let user_item = TurnItem::UserMessage(UserMessageItem {
+4 -2
View File
@@ -172,7 +172,7 @@ You can optionally specify config overrides on the new turn. If specified, these
"cwd": "/Users/me/project", "cwd": "/Users/me/project",
"approvalPolicy": "unlessTrusted", "approvalPolicy": "unlessTrusted",
"sandboxPolicy": { "sandboxPolicy": {
"mode": "workspaceWrite", "type": "workspaceWrite",
"writableRoots": ["/Users/me/project"], "writableRoots": ["/Users/me/project"],
"networkAccess": true "networkAccess": true
}, },
@@ -285,10 +285,12 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin
{ "id": 32, "result": { "exitCode": 0, "stdout": "...", "stderr": "" } } { "id": 32, "result": { "exitCode": 0, "stdout": "...", "stderr": "" } }
``` ```
- 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`.
Notes: Notes:
- Empty `command` arrays are rejected. - Empty `command` arrays are rejected.
- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags). - `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`).
- When omitted, `timeoutMs` falls back to the server default. - When omitted, `timeoutMs` falls back to the server default.
## Events ## Events
+7
View File
@@ -21,3 +21,10 @@ toml = { workspace = true, optional = true }
cli = ["clap", "serde", "toml"] cli = ["clap", "serde", "toml"]
elapsed = [] elapsed = []
sandbox_summary = [] sandbox_summary = []
[dev-dependencies]
clap = { workspace = true, features = ["derive", "wrap_help"] }
codex-utils-absolute-path = { workspace = true }
pretty_assertions = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
@@ -26,3 +26,22 @@ impl From<SandboxModeCliArg> for SandboxMode {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn maps_cli_args_to_protocol_modes() {
assert_eq!(SandboxMode::ReadOnly, SandboxModeCliArg::ReadOnly.into());
assert_eq!(
SandboxMode::WorkspaceWrite,
SandboxModeCliArg::WorkspaceWrite.into()
);
assert_eq!(
SandboxMode::DangerFullAccess,
SandboxModeCliArg::DangerFullAccess.into()
);
}
}
+50
View File
@@ -1,9 +1,17 @@
use codex_core::protocol::NetworkAccess;
use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SandboxPolicy;
pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
match sandbox_policy { match sandbox_policy {
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
SandboxPolicy::ReadOnly => "read-only".to_string(), SandboxPolicy::ReadOnly => "read-only".to_string(),
SandboxPolicy::ExternalSandbox { network_access } => {
let mut summary = "external-sandbox".to_string();
if matches!(network_access, NetworkAccess::Enabled) {
summary.push_str(" (network access enabled)");
}
summary
}
SandboxPolicy::WorkspaceWrite { SandboxPolicy::WorkspaceWrite {
writable_roots, writable_roots,
network_access, network_access,
@@ -34,3 +42,45 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[test]
fn summarizes_external_sandbox_without_network_access_suffix() {
let summary = summarize_sandbox_policy(&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
});
assert_eq!(summary, "external-sandbox");
}
#[test]
fn summarizes_external_sandbox_with_enabled_network() {
let summary = summarize_sandbox_policy(&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
});
assert_eq!(summary, "external-sandbox (network access enabled)");
}
#[test]
fn workspace_write_summary_still_includes_network_access() {
let root = if cfg!(windows) { "C:\\repo" } else { "/repo" };
let writable_root = AbsolutePathBuf::try_from(root).unwrap();
let summary = summarize_sandbox_policy(&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable_root.clone()],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
});
assert_eq!(
summary,
format!(
"workspace-write [workdir, {}] (network access enabled)",
writable_root.to_string_lossy()
)
);
}
}
-1
View File
@@ -61,7 +61,6 @@ sha1 = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
shlex = { workspace = true } shlex = { workspace = true }
similar = { workspace = true } similar = { workspace = true }
strum_macros = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
test-case = "3.3.1" test-case = "3.3.1"
test-log = { workspace = true } test-log = { workspace = true }
@@ -21,8 +21,11 @@ pub fn requires_initial_appoval(
match policy { match policy {
AskForApproval::Never | AskForApproval::OnFailure => false, AskForApproval::Never | AskForApproval::OnFailure => false,
AskForApproval::OnRequest => { AskForApproval::OnRequest => {
// In DangerFullAccess, only prompt if the command looks dangerous. // In DangerFullAccess or ExternalSandbox, only prompt if the command looks dangerous.
if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { if matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
) {
return command_might_be_dangerous(command); return command_might_be_dangerous(command);
} }
@@ -83,6 +86,7 @@ fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use codex_protocol::protocol::NetworkAccess;
fn vec_str(items: &[&str]) -> Vec<String> { fn vec_str(items: &[&str]) -> Vec<String> {
items.iter().map(std::string::ToString::to_string).collect() items.iter().map(std::string::ToString::to_string).collect()
@@ -150,4 +154,23 @@ mod tests {
fn rm_f_is_dangerous() { fn rm_f_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&["rm", "-f", "/"]))); assert!(command_might_be_dangerous(&vec_str(&["rm", "-f", "/"])));
} }
#[test]
fn external_sandbox_only_prompts_for_dangerous_commands() {
let external_policy = SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
};
assert!(!requires_initial_appoval(
AskForApproval::OnRequest,
&external_policy,
&vec_str(&["ls"]),
SandboxPermissions::UseDefault,
));
assert!(requires_initial_appoval(
AskForApproval::OnRequest,
&external_policy,
&vec_str(&["rm", "-rf", "/"]),
SandboxPermissions::UseDefault,
));
}
} }
+48 -12
View File
@@ -1,10 +1,6 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use strum_macros::Display as DeriveDisplay;
use crate::codex::TurnContext; use crate::codex::TurnContext;
use crate::protocol::AskForApproval; use crate::protocol::AskForApproval;
use crate::protocol::NetworkAccess;
use crate::protocol::SandboxPolicy; use crate::protocol::SandboxPolicy;
use crate::shell::Shell; use crate::shell::Shell;
use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::SandboxMode;
@@ -12,15 +8,11 @@ use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem; use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, DeriveDisplay)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum NetworkAccess {
Restricted,
Enabled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename = "environment_context", rename_all = "snake_case")] #[serde(rename = "environment_context", rename_all = "snake_case")]
pub(crate) struct EnvironmentContext { pub(crate) struct EnvironmentContext {
@@ -45,12 +37,14 @@ impl EnvironmentContext {
sandbox_mode: match sandbox_policy { sandbox_mode: match sandbox_policy {
Some(SandboxPolicy::DangerFullAccess) => Some(SandboxMode::DangerFullAccess), Some(SandboxPolicy::DangerFullAccess) => Some(SandboxMode::DangerFullAccess),
Some(SandboxPolicy::ReadOnly) => Some(SandboxMode::ReadOnly), Some(SandboxPolicy::ReadOnly) => Some(SandboxMode::ReadOnly),
Some(SandboxPolicy::ExternalSandbox { .. }) => Some(SandboxMode::DangerFullAccess),
Some(SandboxPolicy::WorkspaceWrite { .. }) => Some(SandboxMode::WorkspaceWrite), Some(SandboxPolicy::WorkspaceWrite { .. }) => Some(SandboxMode::WorkspaceWrite),
None => None, None => None,
}, },
network_access: match sandbox_policy { network_access: match sandbox_policy {
Some(SandboxPolicy::DangerFullAccess) => Some(NetworkAccess::Enabled), Some(SandboxPolicy::DangerFullAccess) => Some(NetworkAccess::Enabled),
Some(SandboxPolicy::ReadOnly) => Some(NetworkAccess::Restricted), Some(SandboxPolicy::ReadOnly) => Some(NetworkAccess::Restricted),
Some(SandboxPolicy::ExternalSandbox { network_access }) => Some(network_access),
Some(SandboxPolicy::WorkspaceWrite { network_access, .. }) => { Some(SandboxPolicy::WorkspaceWrite { network_access, .. }) => {
if network_access { if network_access {
Some(NetworkAccess::Enabled) Some(NetworkAccess::Enabled)
@@ -272,6 +266,48 @@ mod tests {
assert_eq!(context.serialize_to_xml(), expected); assert_eq!(context.serialize_to_xml(), expected);
} }
#[test]
fn serialize_external_sandbox_environment_context() {
let context = EnvironmentContext::new(
None,
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
}),
fake_shell(),
);
let expected = r#"<environment_context>
<approval_policy>on-request</approval_policy>
<sandbox_mode>danger-full-access</sandbox_mode>
<network_access>enabled</network_access>
<shell>bash</shell>
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
}
#[test]
fn serialize_external_sandbox_with_restricted_network_environment_context() {
let context = EnvironmentContext::new(
None,
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
}),
fake_shell(),
);
let expected = r#"<environment_context>
<approval_policy>on-request</approval_policy>
<sandbox_mode>danger-full-access</sandbox_mode>
<network_access>restricted</network_access>
<shell>bash</shell>
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
}
#[test] #[test]
fn serialize_full_access_environment_context() { fn serialize_full_access_environment_context() {
let context = EnvironmentContext::new( let context = EnvironmentContext::new(
+7 -2
View File
@@ -135,7 +135,9 @@ pub async fn process_exec_tool_call(
stdout_stream: Option<StdoutStream>, stdout_stream: Option<StdoutStream>,
) -> Result<ExecToolCallOutput> { ) -> Result<ExecToolCallOutput> {
let sandbox_type = match &sandbox_policy { let sandbox_type = match &sandbox_policy {
SandboxPolicy::DangerFullAccess => SandboxType::None, SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
SandboxType::None
}
_ => get_platform_sandbox().unwrap_or(SandboxType::None), _ => get_platform_sandbox().unwrap_or(SandboxType::None),
}; };
tracing::debug!("Sandbox type: {sandbox_type:?}"); tracing::debug!("Sandbox type: {sandbox_type:?}");
@@ -523,7 +525,10 @@ async fn exec(
) -> Result<RawExecToolCallOutput> { ) -> Result<RawExecToolCallOutput> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
if sandbox == SandboxType::WindowsRestrictedToken if sandbox == SandboxType::WindowsRestrictedToken
&& !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) && !matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
{ {
return exec_windows_sandbox(params, sandbox_policy).await; return exec_windows_sandbox(params, sandbox_policy).await;
} }
+24 -2
View File
@@ -91,7 +91,10 @@ pub fn assess_patch_safety(
if is_write_patch_constrained_to_writable_paths(action, sandbox_policy, cwd) if is_write_patch_constrained_to_writable_paths(action, sandbox_policy, cwd)
|| policy == AskForApproval::OnFailure || policy == AskForApproval::OnFailure
{ {
if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { if matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
) {
// DangerFullAccess is intended to bypass sandboxing entirely. // DangerFullAccess is intended to bypass sandboxing entirely.
SafetyCheck::AutoApprove { SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None, sandbox_type: SandboxType::None,
@@ -147,7 +150,7 @@ fn is_write_patch_constrained_to_writable_paths(
SandboxPolicy::ReadOnly => { SandboxPolicy::ReadOnly => {
return false; return false;
} }
SandboxPolicy::DangerFullAccess => { SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
return true; return true;
} }
SandboxPolicy::WorkspaceWrite { .. } => sandbox_policy.get_writable_roots_with_cwd(cwd), SandboxPolicy::WorkspaceWrite { .. } => sandbox_policy.get_writable_roots_with_cwd(cwd),
@@ -262,4 +265,23 @@ mod tests {
&cwd, &cwd,
)); ));
} }
#[test]
fn external_sandbox_auto_approves_in_on_request() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let add_inside = ApplyPatchAction::new_add_for_test(&cwd.join("inner.txt"), "".to_string());
let policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Enabled,
};
assert_eq!(
assess_patch_safety(&add_inside, AskForApproval::OnRequest, &policy, &cwd,),
SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None,
user_explicitly_approved: false,
}
);
}
} }
+3 -1
View File
@@ -85,7 +85,9 @@ impl SandboxManager {
crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None) crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None)
} }
SandboxablePreference::Auto => match policy { SandboxablePreference::Auto => match policy {
SandboxPolicy::DangerFullAccess => SandboxType::None, SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
SandboxType::None
}
_ => crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None), _ => crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None),
}, },
} }
+38 -1
View File
@@ -132,7 +132,10 @@ pub(crate) fn default_exec_approval_requirement(
) -> ExecApprovalRequirement { ) -> ExecApprovalRequirement {
let needs_approval = match policy { let needs_approval = match policy {
AskForApproval::Never | AskForApproval::OnFailure => false, AskForApproval::Never | AskForApproval::OnFailure => false,
AskForApproval::OnRequest => !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess), AskForApproval::OnRequest => !matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
),
AskForApproval::UnlessTrusted => true, AskForApproval::UnlessTrusted => true,
}; };
@@ -253,3 +256,37 @@ impl<'a> SandboxAttempt<'a> {
) )
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::protocol::NetworkAccess;
use pretty_assertions::assert_eq;
#[test]
fn external_sandbox_skips_exec_approval_on_request() {
assert_eq!(
default_exec_approval_requirement(
AskForApproval::OnRequest,
&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
),
ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
}
);
}
#[test]
fn restricted_sandbox_requires_exec_approval_on_request() {
assert_eq!(
default_exec_approval_requirement(AskForApproval::OnRequest, &SandboxPolicy::ReadOnly),
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
}
);
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ Request `newConversation` params (subset):
- `profile`: optional named profile - `profile`: optional named profile
- `cwd`: optional working directory - `cwd`: optional working directory
- `approvalPolicy`: `untrusted` | `on-request` | `on-failure` | `never` - `approvalPolicy`: `untrusted` | `on-request` | `on-failure` | `never`
- `sandbox`: `read-only` | `workspace-write` | `danger-full-access` - `sandbox`: `read-only` | `workspace-write` | `external-sandbox` (honors `networkAccess` restricted/enabled) | `danger-full-access`
- `config`: map of additional config overrides - `config`: map of additional config overrides
- `baseInstructions`: optional instruction override - `baseInstructions`: optional instruction override
- `compactPrompt`: optional replacement for the default compaction prompt - `compactPrompt`: optional replacement for the default compaction prompt
+45
View File
@@ -268,6 +268,24 @@ pub enum AskForApproval {
Never, Never,
} }
/// Represents whether outbound network access is available to the agent.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum NetworkAccess {
#[default]
Restricted,
Enabled,
}
impl NetworkAccess {
pub fn is_enabled(self) -> bool {
matches!(self, NetworkAccess::Enabled)
}
}
/// Determines execution restrictions for model shell commands. /// Determines execution restrictions for model shell commands.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)]
#[strum(serialize_all = "kebab-case")] #[strum(serialize_all = "kebab-case")]
@@ -281,6 +299,15 @@ pub enum SandboxPolicy {
#[serde(rename = "read-only")] #[serde(rename = "read-only")]
ReadOnly, ReadOnly,
/// Indicates the process is already in an external sandbox. Allows full
/// disk access while honoring the provided network setting.
#[serde(rename = "external-sandbox")]
ExternalSandbox {
/// Whether the external sandbox permits outbound network traffic.
#[serde(default)]
network_access: NetworkAccess,
},
/// Same as `ReadOnly` but additionally grants write access to the current /// Same as `ReadOnly` but additionally grants write access to the current
/// working directory ("workspace"). /// working directory ("workspace").
#[serde(rename = "workspace-write")] #[serde(rename = "workspace-write")]
@@ -373,6 +400,7 @@ impl SandboxPolicy {
pub fn has_full_disk_write_access(&self) -> bool { pub fn has_full_disk_write_access(&self) -> bool {
match self { match self {
SandboxPolicy::DangerFullAccess => true, SandboxPolicy::DangerFullAccess => true,
SandboxPolicy::ExternalSandbox { .. } => true,
SandboxPolicy::ReadOnly => false, SandboxPolicy::ReadOnly => false,
SandboxPolicy::WorkspaceWrite { .. } => false, SandboxPolicy::WorkspaceWrite { .. } => false,
} }
@@ -381,6 +409,7 @@ impl SandboxPolicy {
pub fn has_full_network_access(&self) -> bool { pub fn has_full_network_access(&self) -> bool {
match self { match self {
SandboxPolicy::DangerFullAccess => true, SandboxPolicy::DangerFullAccess => true,
SandboxPolicy::ExternalSandbox { network_access } => network_access.is_enabled(),
SandboxPolicy::ReadOnly => false, SandboxPolicy::ReadOnly => false,
SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access, SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access,
} }
@@ -392,6 +421,7 @@ impl SandboxPolicy {
pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> { pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
match self { match self {
SandboxPolicy::DangerFullAccess => Vec::new(), SandboxPolicy::DangerFullAccess => Vec::new(),
SandboxPolicy::ExternalSandbox { .. } => Vec::new(),
SandboxPolicy::ReadOnly => Vec::new(), SandboxPolicy::ReadOnly => Vec::new(),
SandboxPolicy::WorkspaceWrite { SandboxPolicy::WorkspaceWrite {
writable_roots, writable_roots,
@@ -1830,6 +1860,21 @@ mod tests {
use serde_json::json; use serde_json::json;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
#[test]
fn external_sandbox_reports_full_access_flags() {
let restricted = SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
};
assert!(restricted.has_full_disk_write_access());
assert!(!restricted.has_full_network_access());
let enabled = SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
};
assert!(enabled.has_full_disk_write_access());
assert!(enabled.has_full_network_access());
}
#[test] #[test]
fn item_started_event_from_web_search_emits_begin_event() { fn item_started_event_from_web_search_emits_begin_event() {
let event = ItemStartedEvent { let event = ItemStartedEvent {
+13 -1
View File
@@ -13,7 +13,9 @@ pub fn add_dir_warning_message(
} }
match sandbox_policy { match sandbox_policy {
SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess => None, SandboxPolicy::WorkspaceWrite { .. }
| SandboxPolicy::DangerFullAccess
| SandboxPolicy::ExternalSandbox { .. } => None,
SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)), SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)),
} }
} }
@@ -32,6 +34,7 @@ fn format_warning(additional_dirs: &[PathBuf]) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::add_dir_warning_message; use super::add_dir_warning_message;
use codex_core::protocol::NetworkAccess;
use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SandboxPolicy;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use std::path::PathBuf; use std::path::PathBuf;
@@ -50,6 +53,15 @@ mod tests {
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
} }
#[test]
fn returns_none_for_external_sandbox() {
let sandbox = SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
};
let dirs = vec![PathBuf::from("/tmp/example")];
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
}
#[test] #[test]
fn warns_for_read_only() { fn warns_for_read_only() {
let sandbox = SandboxPolicy::ReadOnly; let sandbox = SandboxPolicy::ReadOnly;
+8
View File
@@ -8,6 +8,7 @@ use chrono::Local;
use codex_common::create_config_summary_entries; use codex_common::create_config_summary_entries;
use codex_core::config::Config; use codex_core::config::Config;
use codex_core::openai_models::model_family::ModelFamily; use codex_core::openai_models::model_family::ModelFamily;
use codex_core::protocol::NetworkAccess;
use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsage;
use codex_protocol::ConversationId; use codex_protocol::ConversationId;
@@ -122,6 +123,13 @@ impl StatusHistoryCell {
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
SandboxPolicy::ReadOnly => "read-only".to_string(), SandboxPolicy::ReadOnly => "read-only".to_string(),
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(), SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(),
SandboxPolicy::ExternalSandbox { network_access } => {
if matches!(network_access, NetworkAccess::Enabled) {
"external-sandbox (network access enabled)".to_string()
} else {
"external-sandbox".to_string()
}
}
}; };
let agents_summary = compose_agents_summary(config); let agents_summary = compose_agents_summary(config);
let account = compose_account_display(auth_manager, plan_type); let account = compose_account_display(auth_manager, plan_type);
+13 -1
View File
@@ -13,7 +13,9 @@ pub fn add_dir_warning_message(
} }
match sandbox_policy { match sandbox_policy {
SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess => None, SandboxPolicy::WorkspaceWrite { .. }
| SandboxPolicy::DangerFullAccess
| SandboxPolicy::ExternalSandbox { .. } => None,
SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)), SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)),
} }
} }
@@ -32,6 +34,7 @@ fn format_warning(additional_dirs: &[PathBuf]) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::add_dir_warning_message; use super::add_dir_warning_message;
use codex_core::protocol::NetworkAccess;
use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SandboxPolicy;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use std::path::PathBuf; use std::path::PathBuf;
@@ -50,6 +53,15 @@ mod tests {
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
} }
#[test]
fn returns_none_for_external_sandbox() {
let sandbox = SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
};
let dirs = vec![PathBuf::from("/tmp/example")];
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
}
#[test] #[test]
fn warns_for_read_only() { fn warns_for_read_only() {
let sandbox = SandboxPolicy::ReadOnly; let sandbox = SandboxPolicy::ReadOnly;
+8
View File
@@ -8,6 +8,7 @@ use chrono::Local;
use codex_common::create_config_summary_entries; use codex_common::create_config_summary_entries;
use codex_core::config::Config; use codex_core::config::Config;
use codex_core::openai_models::model_family::ModelFamily; use codex_core::openai_models::model_family::ModelFamily;
use codex_core::protocol::NetworkAccess;
use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsage;
use codex_protocol::ConversationId; use codex_protocol::ConversationId;
@@ -122,6 +123,13 @@ impl StatusHistoryCell {
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
SandboxPolicy::ReadOnly => "read-only".to_string(), SandboxPolicy::ReadOnly => "read-only".to_string(),
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(), SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(),
SandboxPolicy::ExternalSandbox { network_access } => {
if matches!(network_access, NetworkAccess::Enabled) {
"external-sandbox (network access enabled)".to_string()
} else {
"external-sandbox".to_string()
}
}
}; };
let agents_summary = compose_agents_summary(config); let agents_summary = compose_agents_summary(config);
let account = compose_account_display(auth_manager, plan_type); let account = compose_account_display(auth_manager, plan_type);
+1
View File
@@ -77,6 +77,7 @@ features = [
version = "0.52" version = "0.52"
[dev-dependencies] [dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = "3" tempfile = "3"
[build-dependencies] [build-dependencies]
+1 -1
View File
@@ -271,7 +271,7 @@ pub fn apply_capability_denies_for_world_writable(
})?, })?,
Vec::new(), Vec::new(),
), ),
SandboxPolicy::DangerFullAccess => { SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
return Ok(()); return Ok(());
} }
}; };
@@ -106,7 +106,9 @@ pub fn main() -> Result<()> {
SandboxPolicy::WorkspaceWrite { .. } => { SandboxPolicy::WorkspaceWrite { .. } => {
create_workspace_write_token_with_cap_from(base, psid_cap) create_workspace_write_token_with_cap_from(base, psid_cap)
} }
SandboxPolicy::DangerFullAccess => unreachable!(), SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
unreachable!()
}
} }
}; };
let (h_token, psid_to_use) = token_res?; let (h_token, psid_to_use) = token_res?;
@@ -239,8 +239,11 @@ mod windows_impl {
require_logon_sandbox_creds(&policy, sandbox_policy_cwd, cwd, &env_map, codex_home)?; require_logon_sandbox_creds(&policy, sandbox_policy_cwd, cwd, &env_map, codex_home)?;
log_note("cli creds ready", logs_base_dir); log_note("cli creds ready", logs_base_dir);
// Build capability SID for ACL grants. // Build capability SID for ACL grants.
if matches!(&policy, SandboxPolicy::DangerFullAccess) { if matches!(
anyhow::bail!("DangerFullAccess is not supported for sandboxing") &policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
) {
anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing")
} }
let caps = load_or_create_cap_sids(codex_home)?; let caps = load_or_create_cap_sids(codex_home)?;
let (psid_to_use, cap_sid_str) = match &policy { let (psid_to_use, cap_sid_str) = match &policy {
@@ -252,7 +255,9 @@ mod windows_impl {
unsafe { convert_string_sid_to_sid(&caps.workspace).unwrap() }, unsafe { convert_string_sid_to_sid(&caps.workspace).unwrap() },
caps.workspace.clone(), caps.workspace.clone(),
), ),
SandboxPolicy::DangerFullAccess => unreachable!("DangerFullAccess handled above"), SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
unreachable!("DangerFullAccess handled above")
}
}; };
let AllowDenyPaths { allow: _, deny: _ } = let AllowDenyPaths { allow: _, deny: _ } =
+8 -3
View File
@@ -194,8 +194,11 @@ mod windows_impl {
log_start(&command, logs_base_dir); log_start(&command, logs_base_dir);
let is_workspace_write = matches!(&policy, SandboxPolicy::WorkspaceWrite { .. }); let is_workspace_write = matches!(&policy, SandboxPolicy::WorkspaceWrite { .. });
if matches!(&policy, SandboxPolicy::DangerFullAccess) { if matches!(
anyhow::bail!("DangerFullAccess is not supported for sandboxing") &policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
) {
anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing")
} }
let caps = load_or_create_cap_sids(codex_home)?; let caps = load_or_create_cap_sids(codex_home)?;
let (h_token, psid_to_use): (HANDLE, *mut c_void) = unsafe { let (h_token, psid_to_use): (HANDLE, *mut c_void) = unsafe {
@@ -208,7 +211,9 @@ mod windows_impl {
let psid = convert_string_sid_to_sid(&caps.workspace).unwrap(); let psid = convert_string_sid_to_sid(&caps.workspace).unwrap();
super::token::create_workspace_write_token_with_cap(psid)? super::token::create_workspace_write_token_with_cap(psid)?
} }
SandboxPolicy::DangerFullAccess => unreachable!("DangerFullAccess handled above"), SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
unreachable!("DangerFullAccess handled above")
}
} }
}; };
+43 -3
View File
@@ -5,13 +5,53 @@ pub fn parse_policy(value: &str) -> Result<SandboxPolicy> {
match value { match value {
"read-only" => Ok(SandboxPolicy::ReadOnly), "read-only" => Ok(SandboxPolicy::ReadOnly),
"workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()), "workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()),
"danger-full-access" => anyhow::bail!("DangerFullAccess is not supported for sandboxing"), "danger-full-access" | "external-sandbox" => anyhow::bail!(
"DangerFullAccess and ExternalSandbox are not supported for sandboxing"
),
other => { other => {
let parsed: SandboxPolicy = serde_json::from_str(other)?; let parsed: SandboxPolicy = serde_json::from_str(other)?;
if matches!(parsed, SandboxPolicy::DangerFullAccess) { if matches!(
anyhow::bail!("DangerFullAccess is not supported for sandboxing"); parsed,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
) {
anyhow::bail!(
"DangerFullAccess and ExternalSandbox are not supported for sandboxing"
);
} }
Ok(parsed) Ok(parsed)
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn rejects_external_sandbox_preset() {
let err = parse_policy("external-sandbox").unwrap_err();
assert!(err
.to_string()
.contains("DangerFullAccess and ExternalSandbox are not supported"));
}
#[test]
fn rejects_external_sandbox_json() {
let payload = serde_json::to_string(
&codex_protocol::protocol::SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Enabled,
},
)
.unwrap();
let err = parse_policy(&payload).unwrap_err();
assert!(err
.to_string()
.contains("DangerFullAccess and ExternalSandbox are not supported"));
}
#[test]
fn parses_read_only_policy() {
assert_eq!(parse_policy("read-only").unwrap(), SandboxPolicy::ReadOnly);
}
}
@@ -52,7 +52,10 @@ pub fn run_setup_refresh(
codex_home: &Path, codex_home: &Path,
) -> Result<()> { ) -> Result<()> {
// Skip in danger-full-access. // Skip in danger-full-access.
if matches!(policy, SandboxPolicy::DangerFullAccess) { if matches!(
policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
) {
return Ok(()); return Ok(());
} }
let (read_roots, write_roots) = build_payload_roots( let (read_roots, write_roots) = build_payload_roots(