Compare commits

...

8 Commits

Author SHA1 Message Date
Michael Bolin
c00ff8b8ed sandboxing: preserve denied paths when widening permissions 2026-03-06 16:24:56 -08:00
Michael Bolin
30aae07ce3 linux-sandbox: plumb split sandbox policies through helper 2026-03-06 16:23:59 -08:00
Michael Bolin
e24e36ec77 seatbelt: honor split filesystem sandbox policies 2026-03-06 16:23:59 -08:00
Michael Bolin
fd4299609a safety: honor filesystem policy carveouts in apply_patch 2026-03-06 16:23:58 -08:00
Michael Bolin
8a70b181dc protocol: derive effective file access from filesystem policies 2026-03-06 16:23:58 -08:00
Michael Bolin
a065a40c38 sandboxing: plumb split sandbox policies through runtime 2026-03-06 16:23:58 -08:00
Michael Bolin
f82678b2a4 config: add initial support for the new permission profile config language in config.toml (#13434)
## Why

`SandboxPolicy` currently mixes together three separate concerns:

- parsing layered config from `config.toml`
- representing filesystem sandbox state
- carrying basic network policy alongside filesystem choices

That makes the existing config awkward to extend and blocks the new TOML
proposal where `[permissions]` becomes a table of named permission
profiles selected by `default_permissions`. (The idea is that if
`default_permissions` is not specified, we assume the user is opting
into the "traditional" way to configure the sandbox.)

This PR adds the config-side plumbing for those profiles while still
projecting back to the legacy `SandboxPolicy` shape that the current
macOS and Linux sandbox backends consume.

It also tightens the filesystem profile model so scoped entries only
exist for `:project_roots`, and so nested keys must stay within a
project root instead of using `.` or `..` traversal.

This drops support for the short-lived `[permissions.network]` in
`config.toml` because now that would be interpreted as a profile named
`network` within `[permissions]`.

## What Changed

- added `PermissionsToml`, `PermissionProfileToml`,
`FilesystemPermissionsToml`, and `FilesystemPermissionToml` so config
can parse named profiles under `[permissions.<profile>.filesystem]`
- added top-level `default_permissions` selection, validation for
missing or unknown profiles, and compilation from a named profile into
split `FileSystemSandboxPolicy` and `NetworkSandboxPolicy` values
- taught config loading to choose between the legacy `sandbox_mode` path
and the profile-based path without breaking legacy users
- introduced `codex-protocol::permissions` for the split filesystem and
network sandbox types, and stored those alongside the legacy projected
`sandbox_policy` in runtime `Permissions`
- modeled `FileSystemSpecialPath` so only `ProjectRoots` can carry a
nested `subpath`, matching the intended config syntax instead of
allowing invalid states for other special paths
- restricted scoped filesystem maps to `:project_roots`, with validation
that nested entries are non-empty descendant paths and cannot use `.` or
`..` to escape the project root
- kept existing runtime consumers working by projecting
`FileSystemSandboxPolicy` back into `SandboxPolicy`, with an explicit
error for profiles that request writes outside the workspace root
- loaded proxy settings from top-level `[network]`
- regenerated `core/config.schema.json`

## Verification

- added config coverage for profile deserialization,
`default_permissions` selection, top-level `[network]` loading, network
enablement, rejection of writes outside the workspace root, rejection of
nested entries for non-`:project_roots` special paths, and rejection of
parent-directory traversal in `:project_roots` maps
- added protocol coverage for the legacy bridge rejecting non-workspace
writes

## Docs

- update the Codex config docs on developers.openai.com/codex to
document named `[permissions.<profile>]` entries, `default_permissions`,
scoped `:project_roots` syntax, the descendant-path restriction for
nested `:project_roots` entries, and top-level `[network]` proxy
configuration






---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/13434).
* #13453
* #13452
* #13451
* #13449
* #13448
* #13445
* #13440
* #13439
* __->__ #13434
2026-03-06 15:39:13 -08:00
Josh McKinney
8ba718a611 docs: remove auth login logging plan (#13810)
## Summary

Remove `docs/auth-login-logging-plan.md`.

## Why

The document was a temporary planning artifact. The durable rationale
for the
auth-login diagnostics work now lives in the code comments, tests, PR
context,
and existing implementation notes, so keeping the standalone plan doc
adds
duplicate maintenance surface.

## Testing

- not run (docs-only deletion)

Co-authored-by: Codex <noreply@openai.com>
2026-03-06 23:32:53 +00:00
34 changed files with 3365 additions and 631 deletions

View File

@@ -1534,9 +1534,19 @@ impl CodexMessageProcessor {
};
let requested_policy = params.sandbox_policy.map(|policy| policy.to_core());
let effective_policy = match requested_policy {
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) {
Ok(()) => policy,
Ok(()) => {
let file_system_sandbox_policy =
codex_protocol::permissions::FileSystemSandboxPolicy::from(&policy);
let network_sandbox_policy =
codex_protocol::permissions::NetworkSandboxPolicy::from(&policy);
(policy, file_system_sandbox_policy, network_sandbox_policy)
}
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
@@ -1547,7 +1557,11 @@ impl CodexMessageProcessor {
return;
}
},
None => self.config.permissions.sandbox_policy.get().clone(),
None => (
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();
@@ -1562,6 +1576,8 @@ impl CodexMessageProcessor {
match codex_core::exec::process_exec_tool_call(
exec_params,
&effective_policy,
&effective_file_system_sandbox_policy,
effective_network_sandbox_policy,
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
use_linux_sandbox_bwrap,

View File

@@ -568,6 +568,30 @@
},
"type": "object"
},
"FileSystemAccessMode": {
"enum": [
"none",
"read",
"write"
],
"type": "string"
},
"FilesystemPermissionToml": {
"anyOf": [
{
"$ref": "#/definitions/FileSystemAccessMode"
},
{
"additionalProperties": {
"$ref": "#/definitions/FileSystemAccessMode"
},
"type": "object"
}
]
},
"FilesystemPermissionsToml": {
"type": "object"
},
"ForcedLoginMethod": {
"enum": [
"chatgpt",
@@ -1089,20 +1113,21 @@
},
"type": "object"
},
"PermissionsToml": {
"PermissionProfileToml": {
"additionalProperties": false,
"properties": {
"filesystem": {
"$ref": "#/definitions/FilesystemPermissionsToml"
},
"network": {
"allOf": [
{
"$ref": "#/definitions/NetworkToml"
}
],
"description": "Network proxy settings from `[permissions.network]`. User config can enable the proxy; managed requirements may still constrain values."
"$ref": "#/definitions/NetworkToml"
}
},
"type": "object"
},
"PermissionsToml": {
"type": "object"
},
"Personality": {
"enum": [
"none",
@@ -1664,6 +1689,10 @@
"description": "Compact prompt used for history compaction.",
"type": "string"
},
"default_permissions": {
"description": "Default named permissions profile to apply from the `[permissions]` table.",
"type": "string"
},
"developer_instructions": {
"default": null,
"description": "Developer instructions inserted as a `developer` role message.",
@@ -2067,7 +2096,7 @@
}
],
"default": null,
"description": "Nested permissions settings."
"description": "Named permissions profiles."
},
"personality": {
"allOf": [

View File

@@ -40,6 +40,7 @@ pub(crate) async fn apply_patch(
&action,
turn_context.approval_policy.value(),
turn_context.sandbox_policy.get(),
&turn_context.file_system_sandbox_policy,
&turn_context.cwd,
turn_context.windows_sandbox_level,
) {

View File

@@ -87,6 +87,8 @@ use codex_protocol::models::BaseInstructions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::format_allow_prefixes;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::HasLegacyEvent;
use codex_protocol::protocol::ItemCompletedEvent;
@@ -488,6 +490,8 @@ impl Codex {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -683,6 +687,8 @@ pub(crate) struct TurnContext {
pub(crate) personality: Option<Personality>,
pub(crate) approval_policy: Constrained<AskForApproval>,
pub(crate) sandbox_policy: Constrained<SandboxPolicy>,
pub(crate) file_system_sandbox_policy: FileSystemSandboxPolicy,
pub(crate) network_sandbox_policy: NetworkSandboxPolicy,
pub(crate) network: Option<NetworkProxy>,
pub(crate) windows_sandbox_level: WindowsSandboxLevel,
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
@@ -773,6 +779,8 @@ impl TurnContext {
personality: self.personality,
approval_policy: self.approval_policy.clone(),
sandbox_policy: self.sandbox_policy.clone(),
file_system_sandbox_policy: self.file_system_sandbox_policy.clone(),
network_sandbox_policy: self.network_sandbox_policy,
network: self.network.clone(),
windows_sandbox_level: self.windows_sandbox_level,
shell_environment_policy: self.shell_environment_policy.clone(),
@@ -878,6 +886,8 @@ pub(crate) struct SessionConfiguration {
approval_policy: Constrained<AskForApproval>,
/// How to sandbox commands executed in the system
sandbox_policy: Constrained<SandboxPolicy>,
file_system_sandbox_policy: FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
windows_sandbox_level: WindowsSandboxLevel,
/// Working directory that should be treated as the *root* of the
@@ -944,6 +954,10 @@ impl SessionConfiguration {
}
if let Some(sandbox_policy) = updates.sandbox_policy.clone() {
next_configuration.sandbox_policy.set(sandbox_policy)?;
next_configuration.file_system_sandbox_policy =
FileSystemSandboxPolicy::from(next_configuration.sandbox_policy.get());
next_configuration.network_sandbox_policy =
NetworkSandboxPolicy::from(next_configuration.sandbox_policy.get());
}
if let Some(windows_sandbox_level) = updates.windows_sandbox_level {
next_configuration.windows_sandbox_level = windows_sandbox_level;
@@ -1156,6 +1170,8 @@ impl Session {
personality: session_configuration.personality,
approval_policy: session_configuration.approval_policy.clone(),
sandbox_policy: session_configuration.sandbox_policy.clone(),
file_system_sandbox_policy: session_configuration.file_system_sandbox_policy.clone(),
network_sandbox_policy: session_configuration.network_sandbox_policy,
network,
windows_sandbox_level: session_configuration.windows_sandbox_level,
shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(),
@@ -4983,6 +4999,8 @@ async fn spawn_review_thread(
personality: parent_turn_context.personality,
approval_policy: parent_turn_context.approval_policy.clone(),
sandbox_policy: parent_turn_context.sandbox_policy.clone(),
file_system_sandbox_policy: parent_turn_context.file_system_sandbox_policy.clone(),
network_sandbox_policy: parent_turn_context.network_sandbox_policy,
network: parent_turn_context.network.clone(),
windows_sandbox_level: parent_turn_context.windows_sandbox_level,
shell_environment_policy: parent_turn_context.shell_environment_policy.clone(),

View File

@@ -1416,6 +1416,8 @@ async fn set_rate_limits_retains_previous_credits() {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -1510,6 +1512,8 @@ async fn set_rate_limits_updates_plan_type_when_present() {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -1862,6 +1866,8 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -1919,6 +1925,8 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -2009,6 +2017,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -2414,6 +2424,8 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -3841,11 +3853,15 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
// Now retry the same command WITHOUT escalated permissions; should succeed.
// Force DangerFullAccess to avoid platform sandbox dependencies in tests.
Arc::get_mut(&mut turn_context)
.expect("unique turn context Arc")
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique turn context Arc");
turn_context_mut
.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
turn_context_mut.file_system_sandbox_policy =
FileSystemSandboxPolicy::from(turn_context_mut.sandbox_policy.get());
turn_context_mut.network_sandbox_policy =
NetworkSandboxPolicy::from(turn_context_mut.sandbox_policy.get());
let resp2 = handler
.handle(ToolInvocation {

View File

@@ -13,6 +13,10 @@ use crate::config_loader::RequirementSource;
use crate::features::Feature;
use assert_matches::assert_matches;
use codex_config::CONFIG_TOML_FILE;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use serde::Deserialize;
use tempfile::tempdir;
@@ -197,9 +201,18 @@ fn runtime_config_defaults_model_availability_nux() {
}
#[test]
fn config_toml_deserializes_permissions_network() {
fn config_toml_deserializes_permission_profiles() {
let toml = r#"
[permissions.network]
default_permissions = "workspace"
[permissions.workspace.filesystem]
":minimal" = "read"
[permissions.workspace.filesystem.":project_roots"]
"." = "write"
"docs" = "read"
[permissions.workspace.network]
enabled = true
proxy_url = "http://127.0.0.1:43128"
enable_socks5 = false
@@ -207,55 +220,92 @@ allow_upstream_proxy = false
allowed_domains = ["openai.com"]
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for permissions.network");
toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles");
assert_eq!(cfg.default_permissions.as_deref(), Some("workspace"));
assert_eq!(
cfg.permissions
.and_then(|permissions| permissions.network)
.expect("permissions.network should deserialize"),
NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
socks_url: None,
enable_socks5_udp: None,
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
mode: None,
allowed_domains: Some(vec!["openai.com".to_string()]),
denied_domains: None,
allow_unix_sockets: None,
allow_local_binding: None,
cfg.permissions.expect("[permissions] should deserialize"),
PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([
(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
),
(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
("docs".to_string(), FileSystemAccessMode::Read),
])),
),
]),
}),
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
socks_url: None,
enable_socks5_udp: None,
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
mode: None,
allowed_domains: Some(vec!["openai.com".to_string()]),
denied_domains: None,
allow_unix_sockets: None,
allow_local_binding: None,
}),
},
)]),
}
);
}
#[test]
fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io::Result<()> {
fn permissions_profiles_network_populates_runtime_network_proxy_spec() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
permissions: Some(PermissionsToml {
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
..Default::default()
}),
}),
..Default::default()
};
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
let network = config
.permissions
.network
.as_ref()
.expect("enabled permissions.network should produce a NetworkProxySpec");
.expect("enabled profile network should produce a NetworkProxySpec");
assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128");
assert!(!network.socks_enabled());
@@ -263,24 +313,357 @@ fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io
}
#[test]
fn permissions_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> {
fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
permissions: Some(PermissionsToml {
network: Some(NetworkToml {
allowed_domains: Some(vec!["openai.com".to_string()]),
..Default::default()
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
allowed_domains: Some(vec!["openai.com".to_string()]),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
assert!(config.permissions.network.is_none());
Ok(())
}
#[test]
fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::create_dir_all(cwd.path().join("docs"))?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let cfg = ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([
(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
),
(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
("docs".to_string(), FileSystemAccessMode::Read),
])),
),
]),
}),
network: None,
},
)]),
}),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
assert!(config.permissions.network.is_none());
let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories")).unwrap();
assert_eq!(
config.permissions.file_system_sandbox_policy,
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(None),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some("docs".into())),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: memories_root.clone(),
},
access: FileSystemAccessMode::Write,
},
]),
);
assert_eq!(
config.permissions.sandbox_policy.get(),
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![memories_root],
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![
AbsolutePathBuf::try_from(cwd.path().join("docs")).expect("absolute docs path"),
],
},
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
);
assert_eq!(
config.permissions.network_sandbox_policy,
NetworkSandboxPolicy::Restricted
);
Ok(())
}
#[test]
fn permissions_profiles_require_default_permissions() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("missing default_permissions should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"config defines `[permissions]` profiles but does not set `default_permissions`"
);
Ok(())
}
#[test]
fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let external_write_path = if cfg!(windows) { r"C:\temp" } else { "/tmp" };
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
external_write_path.to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("writes outside the workspace root should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(
err.to_string()
.contains("filesystem writes outside the workspace root"),
"{err}"
);
Ok(())
}
#[test]
fn permissions_profiles_reject_nested_entries_for_non_project_roots() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
"docs".to_string(),
FileSystemAccessMode::Read,
)])),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("nested entries outside :project_roots should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"filesystem path `:minimal` does not support nested entries"
);
Ok(())
}
#[test]
fn permissions_profiles_reject_project_root_parent_traversal() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
"../sibling".to_string(),
FileSystemAccessMode::Read,
)])),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("parent traversal should be rejected for project root subpaths");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"filesystem subpath `../sibling` must be a descendant path without `.` or `..` components"
);
Ok(())
}
#[test]
fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
assert!(
config.permissions.network_sandbox_policy.is_enabled(),
"expected network sandbox policy to be enabled",
);
assert!(
config
.permissions
.sandbox_policy
.get()
.has_full_network_access()
);
Ok(())
}
@@ -2653,6 +3036,10 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -2782,6 +3169,10 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -2909,6 +3300,10 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -3022,6 +3417,10 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),

View File

@@ -27,6 +27,7 @@ use crate::config::types::WindowsSandboxModeToml;
use crate::config::types::WindowsToml;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConstrainedWithSource;
use crate::config_loader::LoaderOverrides;
@@ -72,6 +73,8 @@ use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_rmcp_client::OAuthCredentialsStoreMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
@@ -86,8 +89,10 @@ use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use crate::config::permissions::network_proxy_config_from_permissions;
use crate::config::permissions::compile_permission_profile;
use crate::config::permissions::network_proxy_config_from_profile_network;
use crate::config::profile::ConfigProfile;
use codex_network_proxy::NetworkProxyConfig;
use toml::Value as TomlValue;
use toml_edit::DocumentMut;
@@ -107,8 +112,12 @@ pub use codex_network_proxy::NetworkProxyAuditMetadata;
pub use managed_features::ManagedFeatures;
pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
pub use permissions::FilesystemPermissionToml;
pub use permissions::FilesystemPermissionsToml;
pub use permissions::NetworkToml;
pub use permissions::PermissionProfileToml;
pub use permissions::PermissionsToml;
pub(crate) use permissions::resolve_permission_profile;
pub use service::ConfigService;
pub use service::ConfigServiceError;
@@ -137,11 +146,9 @@ fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
Some(resolved_cwd.join(path))
}
}
#[cfg(test)]
pub(crate) fn test_config() -> Config {
use tempfile::tempdir;
let codex_home = tempdir().expect("create temp dir");
let codex_home = tempfile::tempdir().expect("create temp dir");
Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
@@ -157,6 +164,12 @@ pub struct Permissions {
pub approval_policy: Constrained<AskForApproval>,
/// Effective sandbox policy used for shell/unified exec.
pub sandbox_policy: Constrained<SandboxPolicy>,
/// Effective filesystem sandbox policy, including entries that cannot yet
/// be fully represented by the legacy [`SandboxPolicy`] projection.
pub file_system_sandbox_policy: FileSystemSandboxPolicy,
/// Effective network sandbox policy split out from the legacy
/// [`SandboxPolicy`] projection.
pub network_sandbox_policy: NetworkSandboxPolicy,
/// Effective network configuration applied to all spawned processes.
pub network: Option<NetworkProxySpec>,
/// Whether the model may request a login shell for shell-based tools.
@@ -1045,7 +1058,11 @@ pub struct ConfigToml {
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
/// Nested permissions settings.
/// Default named permissions profile to apply from the `[permissions]`
/// table.
pub default_permissions: Option<String>,
/// Named permissions profiles.
#[serde(default)]
pub permissions: Option<PermissionsToml>,
@@ -1563,6 +1580,78 @@ impl ConfigToml {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermissionConfigSyntax {
Legacy,
Profiles,
}
#[derive(Debug, Deserialize, Default)]
struct PermissionSelectionToml {
default_permissions: Option<String>,
sandbox_mode: Option<SandboxMode>,
}
fn resolve_permission_config_syntax(
config_layer_stack: &ConfigLayerStack,
cfg: &ConfigToml,
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
) -> Option<PermissionConfigSyntax> {
if sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() {
return Some(PermissionConfigSyntax::Legacy);
}
let mut selection = None;
for layer in
config_layer_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false)
{
let Ok(layer_selection) = layer.config.clone().try_into::<PermissionSelectionToml>() else {
continue;
};
if layer_selection.sandbox_mode.is_some() {
selection = Some(PermissionConfigSyntax::Legacy);
}
if layer_selection.default_permissions.is_some() {
selection = Some(PermissionConfigSyntax::Profiles);
}
}
selection.or_else(|| {
if cfg.default_permissions.is_some() {
Some(PermissionConfigSyntax::Profiles)
} else if cfg.sandbox_mode.is_some() {
Some(PermissionConfigSyntax::Legacy)
} else {
None
}
})
}
fn add_additional_file_system_writes(
file_system_sandbox_policy: &mut FileSystemSandboxPolicy,
additional_writable_roots: &[AbsolutePathBuf],
) {
for path in additional_writable_roots {
let exists = file_system_sandbox_policy.entries.iter().any(|entry| {
matches!(
&entry.path,
codex_protocol::permissions::FileSystemPath::Path { path: existing }
if existing == path && entry.access == codex_protocol::permissions::FileSystemAccessMode::Write
)
});
if !exists {
file_system_sandbox_policy.entries.push(
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Path { path: path.clone() },
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
);
}
}
}
/// Optional overrides for user configuration (e.g., from CLI flags).
#[derive(Default, Debug, Clone)]
pub struct ConfigOverrides {
@@ -1750,9 +1839,6 @@ impl Config {
.clone(),
None => ConfigProfile::default(),
};
let configured_network_proxy_config =
network_proxy_config_from_permissions(cfg.permissions.as_ref());
let feature_overrides = FeatureOverrides {
include_apply_patch_tool: include_apply_patch_tool_override,
web_search_request: override_tools_web_search_request,
@@ -1779,42 +1865,123 @@ impl Config {
}
}
});
let additional_writable_roots: Vec<AbsolutePathBuf> = additional_writable_roots
let mut additional_writable_roots: Vec<AbsolutePathBuf> = additional_writable_roots
.into_iter()
.map(|path| AbsolutePathBuf::resolve_path_against_base(path, &resolved_cwd))
.collect::<Result<Vec<_>, _>>()?;
let active_project = cfg
.get_active_project(&resolved_cwd)
.unwrap_or(ProjectConfig { trust_level: None });
let permission_config_syntax = resolve_permission_config_syntax(
&config_layer_stack,
&cfg,
sandbox_mode,
config_profile.sandbox_mode,
);
let has_permission_profiles = cfg
.permissions
.as_ref()
.is_some_and(|profiles| !profiles.is_empty());
if has_permission_profiles
&& !matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Legacy)
)
&& cfg.default_permissions.is_none()
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"config defines `[permissions]` profiles but does not set `default_permissions`",
));
}
let windows_sandbox_level = match windows_sandbox_mode {
Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated,
Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken,
None => WindowsSandboxLevel::from_features(&features),
};
let mut sandbox_policy = cfg.derive_sandbox_policy(
sandbox_mode,
config_profile.sandbox_mode,
windows_sandbox_level,
&resolved_cwd,
Some(&constrained_sandbox_policy),
);
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
let memories_root = memory_root(&codex_home);
std::fs::create_dir_all(&memories_root)?;
let memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?;
if !writable_roots
.iter()
.any(|existing| existing == &memories_root)
{
writable_roots.push(memories_root);
let memories_root = memory_root(&codex_home);
std::fs::create_dir_all(&memories_root)?;
let memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?;
if !additional_writable_roots
.iter()
.any(|existing| existing == &memories_root)
{
additional_writable_roots.push(memories_root);
}
let profiles_are_active = matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Profiles)
) || (permission_config_syntax.is_none()
&& has_permission_profiles);
let (
configured_network_proxy_config,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
) = if profiles_are_active {
let permissions = cfg.permissions.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"default_permissions requires a `[permissions]` table",
)
})?;
let default_permissions = cfg.default_permissions.as_deref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"default_permissions requires a named permissions profile",
)
})?;
let profile = resolve_permission_profile(permissions, default_permissions)?;
let configured_network_proxy_config =
network_proxy_config_from_profile_network(profile.network.as_ref());
let (mut file_system_sandbox_policy, network_sandbox_policy) =
compile_permission_profile(permissions, default_permissions)?;
let mut sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
add_additional_file_system_writes(
&mut file_system_sandbox_policy,
&additional_writable_roots,
);
sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
}
for path in additional_writable_roots {
if !writable_roots.iter().any(|existing| existing == &path) {
writable_roots.push(path);
(
configured_network_proxy_config,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
)
} else {
let configured_network_proxy_config = NetworkProxyConfig::default();
let mut sandbox_policy = cfg.derive_sandbox_policy(
sandbox_mode,
config_profile.sandbox_mode,
windows_sandbox_level,
&resolved_cwd,
Some(&constrained_sandbox_policy),
);
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
for path in &additional_writable_roots {
if !writable_roots.iter().any(|existing| existing == path) {
writable_roots.push(path.clone());
}
}
}
}
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
(
configured_network_proxy_config,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
)
};
let approval_policy_was_explicit = approval_policy_override.is_some()
|| config_profile.approval_policy.is_some()
|| cfg.approval_policy.is_some();
let mut approval_policy = approval_policy_override
.or(config_profile.approval_policy)
.or(cfg.approval_policy)
@@ -1827,7 +1994,9 @@ impl Config {
AskForApproval::default()
}
});
if let Err(err) = constrained_approval_policy.can_set(&approval_policy) {
if !approval_policy_was_explicit
&& let Err(err) = constrained_approval_policy.can_set(&approval_policy)
{
tracing::warn!(
error = %err,
"default approval policy is disallowed by requirements; falling back to required default"
@@ -2072,6 +2241,7 @@ impl Config {
.map(AbsolutePathBuf::to_path_buf)
.or_else(|| resolve_sqlite_home_env(&resolved_cwd))
.unwrap_or_else(|| codex_home.to_path_buf());
let original_sandbox_policy = sandbox_policy.clone();
apply_requirement_constrained_value(
"approval_policy",
@@ -2119,6 +2289,19 @@ impl Config {
} else {
network.enabled().then_some(network)
};
let effective_sandbox_policy = constrained_sandbox_policy.value.get().clone();
let effective_file_system_sandbox_policy =
if effective_sandbox_policy == original_sandbox_policy {
file_system_sandbox_policy
} else {
FileSystemSandboxPolicy::from(&effective_sandbox_policy)
};
let effective_network_sandbox_policy =
if effective_sandbox_policy == original_sandbox_policy {
network_sandbox_policy
} else {
NetworkSandboxPolicy::from(&effective_sandbox_policy)
};
let config = Self {
model,
@@ -2133,6 +2316,8 @@ impl Config {
permissions: Permissions {
approval_policy: constrained_approval_policy.value,
sandbox_policy: constrained_sandbox_policy.value,
file_system_sandbox_policy: effective_file_system_sandbox_policy,
network_sandbox_policy: effective_network_sandbox_policy,
network,
allow_login_shell,
shell_environment_policy,

View File

@@ -1,17 +1,60 @@
use std::collections::BTreeMap;
use std::io;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PermissionsToml {
/// Network proxy settings from `[permissions.network]`.
/// User config can enable the proxy; managed requirements may still constrain values.
#[serde(flatten)]
pub entries: BTreeMap<String, PermissionProfileToml>,
}
impl PermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PermissionProfileToml {
pub filesystem: Option<FilesystemPermissionsToml>,
pub network: Option<NetworkToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
pub struct FilesystemPermissionsToml {
#[serde(flatten)]
pub entries: BTreeMap<String, FilesystemPermissionToml>,
}
impl FilesystemPermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(untagged)]
pub enum FilesystemPermissionToml {
Access(FileSystemAccessMode),
Scoped(BTreeMap<String, FileSystemAccessMode>),
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkToml {
@@ -91,13 +134,173 @@ impl NetworkToml {
}
}
pub(crate) fn network_proxy_config_from_permissions(
permissions: Option<&PermissionsToml>,
pub(crate) fn network_proxy_config_from_profile_network(
network: Option<&NetworkToml>,
) -> NetworkProxyConfig {
permissions
.and_then(|permissions| permissions.network.as_ref())
.map_or_else(
NetworkProxyConfig::default,
NetworkToml::to_network_proxy_config,
)
network.map_or_else(
NetworkProxyConfig::default,
NetworkToml::to_network_proxy_config,
)
}
pub(crate) fn resolve_permission_profile<'a>(
permissions: &'a PermissionsToml,
profile_name: &str,
) -> io::Result<&'a PermissionProfileToml> {
permissions.entries.get(profile_name).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("default_permissions refers to undefined profile `{profile_name}`"),
)
})
}
pub(crate) fn compile_permission_profile(
permissions: &PermissionsToml,
profile_name: &str,
) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> {
let profile = resolve_permission_profile(permissions, profile_name)?;
let filesystem = profile.filesystem.as_ref().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"permissions profile `{profile_name}` must define a `[permissions.{profile_name}.filesystem]` table"
),
)
})?;
if filesystem.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"permissions profile `{profile_name}` must define at least one filesystem entry"
),
));
}
let mut entries = Vec::new();
for (path, permission) in &filesystem.entries {
compile_filesystem_permission(path, permission, &mut entries)?;
}
let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref());
Ok((
FileSystemSandboxPolicy::restricted(entries),
network_sandbox_policy,
))
}
fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy {
let Some(network) = network else {
return NetworkSandboxPolicy::Restricted;
};
match network.enabled {
Some(true) => NetworkSandboxPolicy::Enabled,
_ => NetworkSandboxPolicy::Restricted,
}
}
fn compile_filesystem_permission(
path: &str,
permission: &FilesystemPermissionToml,
entries: &mut Vec<FileSystemSandboxEntry>,
) -> io::Result<()> {
match permission {
FilesystemPermissionToml::Access(access) => entries.push(FileSystemSandboxEntry {
path: compile_filesystem_path(path)?,
access: *access,
}),
FilesystemPermissionToml::Scoped(scoped_entries) => {
for (subpath, access) in scoped_entries {
entries.push(FileSystemSandboxEntry {
path: compile_scoped_filesystem_path(path, subpath)?,
access: *access,
});
}
}
}
Ok(())
}
fn compile_filesystem_path(path: &str) -> io::Result<FileSystemPath> {
if let Some(special) = parse_special_path(path)? {
return Ok(FileSystemPath::Special { value: special });
}
let path = parse_absolute_path(path)?;
Ok(FileSystemPath::Path { path })
}
fn compile_scoped_filesystem_path(path: &str, subpath: &str) -> io::Result<FileSystemPath> {
if subpath == "." {
return compile_filesystem_path(path);
}
if let Some(special) = parse_special_path(path)? {
if !matches!(special, FileSystemSpecialPath::ProjectRoots { .. }) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem path `{path}` does not support nested entries"),
));
}
return Ok(FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(parse_relative_subpath(subpath)?)),
});
}
let subpath = parse_relative_subpath(subpath)?;
let base = parse_absolute_path(path)?;
let path = AbsolutePathBuf::resolve_path_against_base(&subpath, base.as_path())?;
Ok(FileSystemPath::Path { path })
}
fn parse_special_path(path: &str) -> io::Result<Option<FileSystemSpecialPath>> {
let special = match path {
":root" => Some(FileSystemSpecialPath::Root),
":minimal" => Some(FileSystemSpecialPath::Minimal),
":project_roots" => Some(FileSystemSpecialPath::project_roots(None)),
":tmpdir" => Some(FileSystemSpecialPath::Tmpdir),
_ if path.starts_with(':') => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown filesystem special path `{path}`"),
));
}
_ => None,
};
Ok(special)
}
fn parse_absolute_path(path: &str) -> io::Result<AbsolutePathBuf> {
let path_ref = Path::new(path);
if !path_ref.is_absolute() && path != "~" && !path.starts_with("~/") {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem path `{path}` must be absolute, use `~/...`, or start with `:`"),
));
}
AbsolutePathBuf::from_absolute_path(path_ref)
}
fn parse_relative_subpath(subpath: &str) -> io::Result<PathBuf> {
let path = Path::new(subpath);
if !subpath.is_empty()
&& path
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
return Ok(path.to_path_buf());
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"filesystem subpath `{}` must be a descendant path without `.` or `..` components",
path.display()
),
))
}

View File

@@ -19,7 +19,6 @@ use tokio_util::sync::CancellationToken;
use crate::error::CodexErr;
use crate::error::Result;
use crate::error::SandboxErr;
use crate::get_platform_sandbox;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::ExecCommandOutputDeltaEvent;
@@ -33,7 +32,11 @@ use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use crate::text_encoding::bytes_to_string_smart;
use crate::tools::sandboxing::SandboxablePreference;
use codex_network_proxy::NetworkProxy;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_pty::process_group::kill_child_process_group;
pub const DEFAULT_EXEC_COMMAND_TIMEOUT_MS: u64 = 10_000;
@@ -72,6 +75,21 @@ pub struct ExecParams {
pub arg0: Option<String>,
}
fn select_process_exec_tool_sandbox_type(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
enforce_managed_network: bool,
) -> SandboxType {
SandboxManager::new().select_initial(
file_system_sandbox_policy,
network_sandbox_policy,
SandboxablePreference::Auto,
windows_sandbox_level,
enforce_managed_network,
)
}
/// Mechanism to terminate an exec invocation before it finishes naturally.
#[derive(Clone, Debug)]
pub enum ExecExpiration {
@@ -149,9 +167,12 @@ pub struct StdoutStream {
pub tx_event: Sender<Event>,
}
#[allow(clippy::too_many_arguments)]
pub async fn process_exec_tool_call(
params: ExecParams,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_cwd: &Path,
codex_linux_sandbox_exe: &Option<PathBuf>,
use_linux_sandbox_bwrap: bool,
@@ -159,23 +180,12 @@ pub async fn process_exec_tool_call(
) -> Result<ExecToolCallOutput> {
let windows_sandbox_level = params.windows_sandbox_level;
let enforce_managed_network = params.network.is_some();
let sandbox_type = match &sandbox_policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
if enforce_managed_network {
get_platform_sandbox(
windows_sandbox_level
!= codex_protocol::config_types::WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
}
}
_ => get_platform_sandbox(
windows_sandbox_level != codex_protocol::config_types::WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None),
};
let sandbox_type = select_process_exec_tool_sandbox_type(
file_system_sandbox_policy,
network_sandbox_policy,
windows_sandbox_level,
enforce_managed_network,
);
tracing::debug!("Sandbox type: {sandbox_type:?}");
let ExecParams {
@@ -215,6 +225,8 @@ pub async fn process_exec_tool_call(
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
policy: sandbox_policy,
file_system_policy: file_system_sandbox_policy,
network_policy: network_sandbox_policy,
sandbox: sandbox_type,
enforce_managed_network,
network: network.as_ref(),
@@ -247,9 +259,12 @@ pub(crate) async fn execute_exec_request(
windows_sandbox_level,
sandbox_permissions,
sandbox_policy: _sandbox_policy_from_env,
file_system_sandbox_policy,
network_sandbox_policy,
justification,
arg0,
} = exec_request;
let _ = _sandbox_policy_from_env;
let params = ExecParams {
command,
@@ -264,7 +279,16 @@ pub(crate) async fn execute_exec_request(
};
let start = Instant::now();
let raw_output_result = exec(params, sandbox, sandbox_policy, stdout_stream, after_spawn).await;
let raw_output_result = exec(
params,
sandbox,
sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
stdout_stream,
after_spawn,
)
.await;
let duration = start.elapsed();
finalize_exec_result(raw_output_result, sandbox, duration)
}
@@ -693,16 +717,17 @@ async fn exec(
params: ExecParams,
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> Result<RawExecToolCallOutput> {
#[cfg(target_os = "windows")]
if sandbox == SandboxType::WindowsRestrictedToken
&& !matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
{
if should_use_windows_restricted_token_sandbox(
sandbox,
sandbox_policy,
file_system_sandbox_policy,
) {
return exec_windows_sandbox(params, sandbox_policy).await;
}
let ExecParams {
@@ -731,7 +756,7 @@ async fn exec(
args: args.into(),
arg0: arg0_ref,
cwd,
sandbox_policy,
network_sandbox_policy,
// The environment already has attempt-scoped proxy settings from
// apply_to_env_for_attempt above. Passing network here would reapply
// non-attempt proxy vars and drop attempt correlation metadata.
@@ -746,6 +771,20 @@ async fn exec(
consume_truncated_output(child, expiration, stdout_stream).await
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn should_use_windows_restricted_token_sandbox(
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
) -> bool {
sandbox == SandboxType::WindowsRestrictedToken
&& file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted
&& !matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
}
/// Consumes the output of a child process, truncating it so it is suitable for
/// use as the output of a `shell` tool call. Also enforces specified timeout.
async fn consume_truncated_output(
@@ -1098,6 +1137,53 @@ mod tests {
assert_eq!(aggregated.truncated_after_lines, None);
}
#[test]
fn windows_restricted_token_skips_external_sandbox_policies() {
let policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
};
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
should_use_windows_restricted_token_sandbox(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
),
false
);
}
#[test]
fn windows_restricted_token_runs_for_legacy_restricted_policies() {
let policy = SandboxPolicy::new_read_only_policy();
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
should_use_windows_restricted_token_sandbox(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
),
true
);
}
#[test]
fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() {
let expected = crate::get_platform_sandbox(false).unwrap_or(SandboxType::None);
assert_eq!(
select_process_exec_tool_sandbox_type(
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
codex_protocol::config_types::WindowsSandboxLevel::Disabled,
false,
),
expected
);
}
#[cfg(unix)]
#[test]
fn sandbox_detection_flags_sigsys_exit_code() {
@@ -1140,6 +1226,8 @@ mod tests {
params,
SandboxType::None,
&SandboxPolicy::new_read_only_policy(),
&FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()),
NetworkSandboxPolicy::Restricted,
None,
None,
)
@@ -1196,6 +1284,8 @@ mod tests {
let result = process_exec_tool_call(
params,
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess),
NetworkSandboxPolicy::Enabled,
cwd.as_path(),
&None,
false,

View File

@@ -3,6 +3,8 @@ use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use codex_network_proxy::NetworkProxy;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
@@ -13,9 +15,9 @@ use tokio::process::Child;
/// isolation plus seccomp for network restrictions.
///
/// Unlike macOS Seatbelt where we directly embed the policy text, the Linux
/// helper accepts a list of `--sandbox-permission`/`-s` flags mirroring the
/// public CLI. We convert the internal [`SandboxPolicy`] representation into
/// the equivalent CLI options.
/// helper is a separate executable. We pass the legacy [`SandboxPolicy`] plus
/// split filesystem/network policies as JSON so the helper can migrate
/// incrementally without breaking older call sites.
#[allow(clippy::too_many_arguments)]
pub async fn spawn_command_under_linux_sandbox<P>(
codex_linux_sandbox_exe: P,
@@ -31,9 +33,13 @@ pub async fn spawn_command_under_linux_sandbox<P>(
where
P: AsRef<Path>,
{
let args = create_linux_sandbox_command_args(
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(sandbox_policy);
let args = create_linux_sandbox_command_args_for_policies(
command,
sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd,
use_bwrap_sandbox,
allow_network_for_proxy(false),
@@ -44,7 +50,7 @@ where
args,
arg0,
cwd: command_cwd,
sandbox_policy,
network_sandbox_policy,
network,
stdio_policy,
env,
@@ -59,13 +65,55 @@ pub(crate) fn allow_network_for_proxy(enforce_managed_network: bool) -> bool {
enforce_managed_network
}
/// Converts the sandbox policy into the CLI invocation for `codex-linux-sandbox`.
/// Converts the sandbox policies into the CLI invocation for
/// `codex-linux-sandbox`.
///
/// The helper performs the actual sandboxing (bubblewrap + seccomp) after
/// parsing these arguments. See `docs/linux_sandbox.md` for the Linux semantics.
pub(crate) fn create_linux_sandbox_command_args(
#[allow(clippy::too_many_arguments)]
pub(crate) fn create_linux_sandbox_command_args_for_policies(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
use_bwrap_sandbox: bool,
allow_network_for_proxy: bool,
) -> Vec<String> {
#[expect(clippy::expect_used)]
let sandbox_policy_json =
serde_json::to_string(sandbox_policy).expect("Failed to serialize SandboxPolicy to JSON");
#[expect(clippy::expect_used)]
let file_system_policy_json = serde_json::to_string(file_system_sandbox_policy)
.expect("Failed to serialize FileSystemSandboxPolicy to JSON");
#[expect(clippy::expect_used)]
let network_policy_json = serde_json::to_string(&network_sandbox_policy)
.expect("Failed to serialize NetworkSandboxPolicy to JSON");
let mut linux_cmd = create_linux_sandbox_command_args(
command,
sandbox_policy_cwd,
use_bwrap_sandbox,
allow_network_for_proxy,
);
linux_cmd.splice(
2..2,
[
"--sandbox-policy".to_string(),
sandbox_policy_json,
"--file-system-sandbox-policy".to_string(),
file_system_policy_json,
"--network-sandbox-policy".to_string(),
network_policy_json,
],
);
linux_cmd
}
/// Converts the sandbox cwd and execution options into the CLI invocation for
/// `codex-linux-sandbox`.
pub(crate) fn create_linux_sandbox_command_args(
command: Vec<String>,
sandbox_policy_cwd: &Path,
use_bwrap_sandbox: bool,
allow_network_for_proxy: bool,
@@ -76,16 +124,7 @@ pub(crate) fn create_linux_sandbox_command_args(
.expect("cwd must be valid UTF-8")
.to_string();
#[expect(clippy::expect_used)]
let sandbox_policy_json =
serde_json::to_string(sandbox_policy).expect("Failed to serialize SandboxPolicy to JSON");
let mut linux_cmd: Vec<String> = vec![
"--sandbox-policy-cwd".to_string(),
sandbox_policy_cwd,
"--sandbox-policy".to_string(),
sandbox_policy_json,
];
let mut linux_cmd: Vec<String> = vec!["--sandbox-policy-cwd".to_string(), sandbox_policy_cwd];
if use_bwrap_sandbox {
linux_cmd.push("--use-bwrap-sandbox".to_string());
}
@@ -112,16 +151,14 @@ mod tests {
fn bwrap_flags_are_feature_gated() {
let command = vec!["/bin/true".to_string()];
let cwd = Path::new("/tmp");
let policy = SandboxPolicy::new_read_only_policy();
let with_bwrap =
create_linux_sandbox_command_args(command.clone(), &policy, cwd, true, false);
let with_bwrap = create_linux_sandbox_command_args(command.clone(), cwd, true, false);
assert_eq!(
with_bwrap.contains(&"--use-bwrap-sandbox".to_string()),
true
);
let without_bwrap = create_linux_sandbox_command_args(command, &policy, cwd, false, false);
let without_bwrap = create_linux_sandbox_command_args(command, cwd, false, false);
assert_eq!(
without_bwrap.contains(&"--use-bwrap-sandbox".to_string()),
false
@@ -132,15 +169,46 @@ mod tests {
fn proxy_flag_is_included_when_requested() {
let command = vec!["/bin/true".to_string()];
let cwd = Path::new("/tmp");
let policy = SandboxPolicy::new_read_only_policy();
let args = create_linux_sandbox_command_args(command, &policy, cwd, true, true);
let args = create_linux_sandbox_command_args(command, cwd, true, true);
assert_eq!(
args.contains(&"--allow-network-for-proxy".to_string()),
true
);
}
#[test]
fn split_policy_flags_are_included() {
let command = vec!["/bin/true".to_string()];
let cwd = Path::new("/tmp");
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
let args = create_linux_sandbox_command_args_for_policies(
command,
&sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
cwd,
true,
false,
);
assert_eq!(
args.windows(2).any(|window| {
window[0] == "--file-system-sandbox-policy" && !window[1].is_empty()
}),
true
);
assert_eq!(
args.windows(2)
.any(|window| window[0] == "--network-sandbox-policy"
&& window[1] == "\"restricted\""),
true
);
}
#[test]
fn proxy_network_requires_managed_requirements() {
assert_eq!(allow_network_for_proxy(false), false);

View File

@@ -1,6 +1,7 @@
use crate::config::NetworkToml;
use crate::config::PermissionsToml;
use crate::config::find_codex_home;
use crate::config::resolve_permission_profile;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
@@ -118,13 +119,7 @@ fn network_constraints_from_trusted_layers(
}
let parsed = network_tables_from_toml(&layer.config)?;
if let Some(network) = parsed.network {
apply_network_constraints(network, &mut constraints);
}
if let Some(network) = parsed
.permissions
.and_then(|permissions| permissions.network)
{
if let Some(network) = selected_network_from_tables(parsed)? {
apply_network_constraints(network, &mut constraints);
}
}
@@ -165,7 +160,7 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx
#[derive(Debug, Clone, Default, Deserialize)]
struct NetworkTablesToml {
network: Option<NetworkToml>,
default_permissions: Option<String>,
permissions: Option<PermissionsToml>,
}
@@ -176,16 +171,24 @@ fn network_tables_from_toml(value: &toml::Value) -> Result<NetworkTablesToml> {
.context("failed to deserialize network tables from config")
}
fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) {
if let Some(network) = parsed.network {
network.apply_to_network_proxy_config(config);
}
if let Some(network) = parsed
fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result<Option<NetworkToml>> {
let Some(default_permissions) = parsed.default_permissions else {
return Ok(None);
};
let permissions = parsed
.permissions
.and_then(|permissions| permissions.network)
{
.context("default_permissions requires a `[permissions]` table for network settings")?;
let profile = resolve_permission_profile(&permissions, &default_permissions)
.map_err(anyhow::Error::from)?;
Ok(profile.network.clone())
}
fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) -> Result<()> {
if let Some(network) = selected_network_from_tables(parsed)? {
network.apply_to_network_proxy_config(config);
}
Ok(())
}
fn config_from_layers(
@@ -195,7 +198,7 @@ fn config_from_layers(
let mut config = NetworkProxyConfig::default();
for layer in layers.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) {
let parsed = network_tables_from_toml(&layer.config)?;
apply_network_tables(&mut config, parsed);
apply_network_tables(&mut config, parsed)?;
}
apply_exec_policy_network_rules(&mut config, exec_policy);
Ok(config)
@@ -310,17 +313,21 @@ mod tests {
use pretty_assertions::assert_eq;
#[test]
fn higher_precedence_network_table_beats_lower_permissions_network_table() {
let lower_permissions: toml::Value = toml::from_str(
fn higher_precedence_profile_network_beats_lower_profile_network() {
let lower_network: toml::Value = toml::from_str(
r#"
[permissions.network]
default_permissions = "workspace"
[permissions.workspace.network]
allowed_domains = ["lower.example.com"]
"#,
)
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
[network]
default_permissions = "workspace"
[permissions.workspace.network]
allowed_domains = ["higher.example.com"]
"#,
)
@@ -329,12 +336,14 @@ allowed_domains = ["higher.example.com"]
let mut config = NetworkProxyConfig::default();
apply_network_tables(
&mut config,
network_tables_from_toml(&lower_permissions).expect("lower layer should deserialize"),
);
network_tables_from_toml(&lower_network).expect("lower layer should deserialize"),
)
.expect("lower layer should apply");
apply_network_tables(
&mut config,
network_tables_from_toml(&higher_network).expect("higher layer should deserialize"),
);
)
.expect("higher layer should apply");
assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]);
}
@@ -382,15 +391,18 @@ allowed_domains = ["higher.example.com"]
fn apply_network_constraints_includes_allow_all_unix_sockets_flag() {
let config: toml::Value = toml::from_str(
r#"
[network]
default_permissions = "workspace"
[permissions.workspace.network]
dangerously_allow_all_unix_sockets = true
"#,
)
.expect("network table should parse");
let network = network_tables_from_toml(&config)
.expect("network table should deserialize")
.network
.expect("network table should be present");
.expect("permissions profile should parse");
let network = selected_network_from_tables(
network_tables_from_toml(&config).expect("permissions profile should deserialize"),
)
.expect("permissions profile should select a network table")
.expect("network table should be present");
let mut constraints = NetworkProxyConstraints::default();
apply_network_constraints(network, &mut constraints);

View File

@@ -9,6 +9,7 @@ use crate::exec::SandboxType;
use crate::util::resolve_path;
use crate::protocol::AskForApproval;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::SandboxPolicy;
use codex_protocol::config_types::WindowsSandboxLevel;
@@ -28,6 +29,7 @@ pub fn assess_patch_safety(
action: &ApplyPatchAction,
policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
windows_sandbox_level: WindowsSandboxLevel,
) -> SafetyCheck {
@@ -60,7 +62,7 @@ pub fn assess_patch_safety(
// Even though the patch appears to be constrained to writable paths, it is
// possible that paths in the patch are hard links to files outside the
// writable roots, so we should still run `apply_patch` in a sandbox in that case.
if is_write_patch_constrained_to_writable_paths(action, sandbox_policy, cwd)
if is_write_patch_constrained_to_writable_paths(action, file_system_sandbox_policy, cwd)
|| matches!(policy, AskForApproval::OnFailure)
{
if matches!(
@@ -122,20 +124,9 @@ pub fn get_platform_sandbox(windows_sandbox_enabled: bool) -> Option<SandboxType
fn is_write_patch_constrained_to_writable_paths(
action: &ApplyPatchAction,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
) -> bool {
// Earlyexit if there are no declared writable roots.
let writable_roots = match sandbox_policy {
SandboxPolicy::ReadOnly { .. } => {
return false;
}
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
return true;
}
SandboxPolicy::WorkspaceWrite { .. } => sandbox_policy.get_writable_roots_with_cwd(cwd),
};
// Normalize a path by removing `.` and resolving `..` without touching the
// filesystem (works even if the file does not exist).
fn normalize(path: &Path) -> Option<PathBuf> {
@@ -152,6 +143,9 @@ fn is_write_patch_constrained_to_writable_paths(
Some(out)
}
let unreadable_roots = file_system_sandbox_policy.get_unreadable_roots_with_cwd(cwd);
let writable_roots = file_system_sandbox_policy.get_writable_roots_with_cwd(cwd);
// Determine whether `path` is inside **any** writable root. Both `path`
// and roots are converted to absolute, normalized forms before the
// prefix check.
@@ -162,6 +156,17 @@ fn is_write_patch_constrained_to_writable_paths(
None => return false,
};
if unreadable_roots
.iter()
.any(|root| abs.starts_with(root.as_path()))
{
return false;
}
if file_system_sandbox_policy.has_full_disk_write_access() {
return true;
}
writable_roots
.iter()
.any(|writable_root| writable_root.is_path_writable(&abs))
@@ -193,6 +198,10 @@ fn is_write_patch_constrained_to_writable_paths(
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::protocol::FileSystemAccessMode;
use codex_protocol::protocol::FileSystemPath;
use codex_protocol::protocol::FileSystemSandboxEntry;
use codex_protocol::protocol::FileSystemSpecialPath;
use codex_protocol::protocol::RejectConfig;
use codex_utils_absolute_path::AbsolutePathBuf;
use tempfile::TempDir;
@@ -223,13 +232,13 @@ mod tests {
assert!(is_write_patch_constrained_to_writable_paths(
&add_inside,
&policy_workspace_only,
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&cwd,
));
assert!(!is_write_patch_constrained_to_writable_paths(
&add_outside,
&policy_workspace_only,
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&cwd,
));
@@ -244,7 +253,7 @@ mod tests {
};
assert!(is_write_patch_constrained_to_writable_paths(
&add_outside,
&policy_with_parent,
&FileSystemSandboxPolicy::from(&policy_with_parent),
&cwd,
));
}
@@ -264,6 +273,7 @@ mod tests {
&add_inside,
AskForApproval::OnRequest,
&policy,
&FileSystemSandboxPolicy::from(&policy),
&cwd,
WindowsSandboxLevel::Disabled
),
@@ -294,6 +304,7 @@ mod tests {
&add_outside,
AskForApproval::OnRequest,
&policy_workspace_only,
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&cwd,
WindowsSandboxLevel::Disabled,
),
@@ -308,6 +319,7 @@ mod tests {
mcp_elicitations: false,
}),
&policy_workspace_only,
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&cwd,
WindowsSandboxLevel::Disabled,
),
@@ -339,6 +351,7 @@ mod tests {
mcp_elicitations: false,
}),
&policy_workspace_only,
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&cwd,
WindowsSandboxLevel::Disabled,
),
@@ -348,4 +361,47 @@ mod tests {
},
);
}
#[test]
fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let blocked_path = cwd.join("blocked.txt");
let blocked_absolute = AbsolutePathBuf::from_absolute_path(blocked_path.clone()).unwrap();
let action = ApplyPatchAction::new_add_for_test(&blocked_path, "".to_string());
let sandbox_policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
};
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: blocked_absolute,
},
access: FileSystemAccessMode::None,
},
]);
assert!(!is_write_patch_constrained_to_writable_paths(
&action,
&file_system_sandbox_policy,
&cwd,
));
assert_eq!(
assess_patch_safety(
&action,
AskForApproval::OnRequest,
&sandbox_policy,
&file_system_sandbox_policy,
&cwd,
WindowsSandboxLevel::Disabled,
),
SafetyCheck::AskUser,
);
}
}

View File

@@ -14,12 +14,12 @@ use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::execute_exec_request;
use crate::landlock::allow_network_for_proxy;
use crate::landlock::create_linux_sandbox_command_args;
use crate::landlock::create_linux_sandbox_command_args_for_policies;
use crate::protocol::SandboxPolicy;
#[cfg(target_os = "macos")]
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
#[cfg(target_os = "macos")]
use crate::seatbelt::create_seatbelt_command_args_with_extensions;
use crate::seatbelt::create_seatbelt_command_args_for_policies_with_extensions;
#[cfg(target_os = "macos")]
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
@@ -30,6 +30,13 @@ use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
pub use codex_protocol::models::SandboxPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize;
@@ -62,6 +69,8 @@ pub struct ExecRequest {
pub windows_sandbox_level: WindowsSandboxLevel,
pub sandbox_permissions: SandboxPermissions,
pub sandbox_policy: SandboxPolicy,
pub file_system_sandbox_policy: FileSystemSandboxPolicy,
pub network_sandbox_policy: NetworkSandboxPolicy,
pub justification: Option<String>,
pub arg0: Option<String>,
}
@@ -72,6 +81,8 @@ pub struct ExecRequest {
pub(crate) struct SandboxTransformRequest<'a> {
pub spec: CommandSpec,
pub policy: &'a SandboxPolicy,
pub file_system_policy: &'a FileSystemSandboxPolicy,
pub network_policy: NetworkSandboxPolicy,
pub sandbox: SandboxType,
pub enforce_managed_network: bool,
// TODO(viyatb): Evaluate switching this to Option<Arc<NetworkProxy>>
@@ -203,6 +214,39 @@ fn additional_permission_roots(
)
}
fn merge_file_system_policy_with_additional_permissions(
file_system_policy: &FileSystemSandboxPolicy,
extra_reads: Vec<AbsolutePathBuf>,
extra_writes: Vec<AbsolutePathBuf>,
) -> FileSystemSandboxPolicy {
match file_system_policy.kind {
FileSystemSandboxKind::Restricted => {
let mut merged_policy = file_system_policy.clone();
for path in extra_reads {
let entry = FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
};
if !merged_policy.entries.contains(&entry) {
merged_policy.entries.push(entry);
}
}
for path in extra_writes {
let entry = FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Write,
};
if !merged_policy.entries.contains(&entry) {
merged_policy.entries.push(entry);
}
}
merged_policy
}
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
file_system_policy.clone()
}
}
}
fn merge_read_only_access_with_additional_reads(
read_only_access: &ReadOnlyAccess,
extra_reads: Vec<AbsolutePathBuf>,
@@ -246,9 +290,17 @@ fn sandbox_policy_with_additional_permissions(
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions);
match sandbox_policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
sandbox_policy.clone()
}
SandboxPolicy::DangerFullAccess => SandboxPolicy::DangerFullAccess,
SandboxPolicy::ExternalSandbox { network_access } => SandboxPolicy::ExternalSandbox {
network_access: if merge_network_access(
network_access.is_enabled(),
additional_permissions,
) {
NetworkAccess::Enabled
} else {
NetworkAccess::Restricted
},
},
SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access,
@@ -297,6 +349,27 @@ fn sandbox_policy_with_additional_permissions(
}
}
pub(crate) fn should_require_platform_sandbox(
file_system_policy: &FileSystemSandboxPolicy,
network_policy: NetworkSandboxPolicy,
has_managed_network_requirements: bool,
) -> bool {
if has_managed_network_requirements {
return true;
}
if !network_policy.is_enabled() {
return !matches!(
file_system_policy.kind,
FileSystemSandboxKind::ExternalSandbox
);
}
match file_system_policy.kind {
FileSystemSandboxKind::Restricted => !file_system_policy.has_full_disk_write_access(),
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => false,
}
}
#[derive(Default)]
pub struct SandboxManager;
@@ -307,7 +380,8 @@ impl SandboxManager {
pub(crate) fn select_initial(
&self,
policy: &SandboxPolicy,
file_system_policy: &FileSystemSandboxPolicy,
network_policy: NetworkSandboxPolicy,
pref: SandboxablePreference,
windows_sandbox_level: WindowsSandboxLevel,
has_managed_network_requirements: bool,
@@ -322,22 +396,20 @@ impl SandboxManager {
)
.unwrap_or(SandboxType::None)
}
SandboxablePreference::Auto => match policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
if has_managed_network_requirements {
crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
}
SandboxablePreference::Auto => {
if should_require_platform_sandbox(
file_system_policy,
network_policy,
has_managed_network_requirements,
) {
crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
}
_ => crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None),
},
}
}
}
@@ -348,6 +420,8 @@ impl SandboxManager {
let SandboxTransformRequest {
mut spec,
policy,
file_system_policy,
network_policy,
sandbox,
enforce_managed_network,
network,
@@ -360,16 +434,41 @@ impl SandboxManager {
} = request;
#[cfg(not(target_os = "macos"))]
let macos_seatbelt_profile_extensions = None;
let effective_permissions = EffectiveSandboxPermissions::new(
let additional_permissions = spec.additional_permissions.take();
let EffectiveSandboxPermissions {
sandbox_policy: effective_policy,
macos_seatbelt_profile_extensions: _effective_macos_seatbelt_profile_extensions,
} = EffectiveSandboxPermissions::new(
policy,
macos_seatbelt_profile_extensions,
spec.additional_permissions.as_ref(),
additional_permissions.as_ref(),
);
let (effective_file_system_policy, effective_network_policy) =
if let Some(additional_permissions) = additional_permissions {
let (extra_reads, extra_writes) =
additional_permission_roots(&additional_permissions);
let file_system_sandbox_policy =
if extra_reads.is_empty() && extra_writes.is_empty() {
file_system_policy.clone()
} else {
merge_file_system_policy_with_additional_permissions(
file_system_policy,
extra_reads,
extra_writes,
)
};
let network_sandbox_policy =
if merge_network_access(network_policy.is_enabled(), &additional_permissions) {
NetworkSandboxPolicy::Enabled
} else {
NetworkSandboxPolicy::Restricted
};
(file_system_sandbox_policy, network_sandbox_policy)
} else {
(file_system_policy.clone(), network_policy)
};
let mut env = spec.env;
if !effective_permissions
.sandbox_policy
.has_full_network_access()
{
if !effective_network_policy.is_enabled() {
env.insert(
CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(),
"1".to_string(),
@@ -386,15 +485,14 @@ impl SandboxManager {
SandboxType::MacosSeatbelt => {
let mut seatbelt_env = HashMap::new();
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
let mut args = create_seatbelt_command_args_with_extensions(
let mut args = create_seatbelt_command_args_for_policies_with_extensions(
command.clone(),
&effective_permissions.sandbox_policy,
&effective_file_system_policy,
effective_network_policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
effective_permissions
.macos_seatbelt_profile_extensions
.as_ref(),
_effective_macos_seatbelt_profile_extensions.as_ref(),
);
let mut full_command = Vec::with_capacity(1 + args.len());
full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());
@@ -407,9 +505,11 @@ impl SandboxManager {
let exe = codex_linux_sandbox_exe
.ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?;
let allow_proxy_network = allow_network_for_proxy(enforce_managed_network);
let mut args = create_linux_sandbox_command_args(
let mut args = create_linux_sandbox_command_args_for_policies(
command.clone(),
&effective_permissions.sandbox_policy,
&effective_policy,
&effective_file_system_policy,
effective_network_policy,
sandbox_policy_cwd,
use_linux_sandbox_bwrap,
allow_proxy_network,
@@ -444,7 +544,9 @@ impl SandboxManager {
sandbox,
windows_sandbox_level,
sandbox_permissions: spec.sandbox_permissions,
sandbox_policy: effective_permissions.sandbox_policy,
sandbox_policy: effective_policy,
file_system_sandbox_policy: effective_file_system_policy,
network_sandbox_policy: effective_network_policy,
justification: spec.justification,
arg0: arg0_override,
})
@@ -477,9 +579,12 @@ mod tests {
#[cfg(target_os = "macos")]
use super::EffectiveSandboxPermissions;
use super::SandboxManager;
use super::merge_file_system_policy_with_additional_permissions;
use super::normalize_additional_permissions;
use super::sandbox_policy_with_additional_permissions;
use super::should_require_platform_sandbox;
use crate::exec::SandboxType;
use crate::protocol::NetworkAccess;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
use crate::tools::sandboxing::SandboxablePreference;
@@ -493,16 +598,24 @@ mod tests {
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use tempfile::TempDir;
#[test]
fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() {
let manager = SandboxManager::new();
let sandbox = manager.select_initial(
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
false,
@@ -515,7 +628,8 @@ mod tests {
let manager = SandboxManager::new();
let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None);
let sandbox = manager.select_initial(
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
true,
@@ -523,6 +637,98 @@ mod tests {
assert_eq!(sandbox, expected);
}
#[test]
fn restricted_file_system_uses_platform_sandbox_without_managed_network() {
let manager = SandboxManager::new();
let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None);
let sandbox = manager.select_initial(
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}]),
NetworkSandboxPolicy::Enabled,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
false,
);
assert_eq!(sandbox, expected);
}
#[test]
fn full_access_restricted_policy_skips_platform_sandbox_when_network_is_enabled() {
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}]);
assert_eq!(
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false),
false
);
}
#[test]
fn full_access_restricted_policy_still_uses_platform_sandbox_for_restricted_network() {
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}]);
assert_eq!(
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Restricted, false),
true
);
}
#[test]
fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() {
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
let exec_request = manager
.transform(super::SandboxTransformRequest {
spec: super::CommandSpec {
program: "true".to_string(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
expiration: crate::exec::ExecExpiration::DefaultTimeout,
sandbox_permissions: super::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
},
policy: &SandboxPolicy::ExternalSandbox {
network_access: crate::protocol::NetworkAccess::Restricted,
},
file_system_policy: &FileSystemSandboxPolicy::unrestricted(),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: None,
use_linux_sandbox_bwrap: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.expect("transform");
assert_eq!(
exec_request.file_system_sandbox_policy,
FileSystemSandboxPolicy::unrestricted()
);
assert_eq!(
exec_request.network_sandbox_policy,
NetworkSandboxPolicy::Restricted
);
}
#[test]
fn normalize_additional_permissions_preserves_network() {
let temp_dir = TempDir::new().expect("create temp dir");
@@ -624,7 +830,6 @@ mod tests {
}
);
}
#[cfg(target_os = "macos")]
#[test]
fn effective_permissions_merge_macos_extensions_with_additional_permissions() {
@@ -679,4 +884,223 @@ mod tests {
})
);
}
#[test]
fn external_sandbox_additional_permissions_can_enable_network() {
let temp_dir = TempDir::new().expect("create temp dir");
let path = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let policy = sandbox_policy_with_additional_permissions(
&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
&PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![path]),
write: Some(Vec::new()),
}),
..Default::default()
},
);
assert_eq!(
policy,
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
}
);
}
#[test]
fn transform_additional_permissions_enable_network_for_external_sandbox() {
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
let temp_dir = TempDir::new().expect("create temp dir");
let path = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let exec_request = manager
.transform(super::SandboxTransformRequest {
spec: super::CommandSpec {
program: "true".to_string(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
expiration: crate::exec::ExecExpiration::DefaultTimeout,
sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions,
additional_permissions: Some(PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![path]),
write: Some(Vec::new()),
}),
..Default::default()
}),
justification: None,
},
policy: &SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
file_system_policy: &FileSystemSandboxPolicy::unrestricted(),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: None,
use_linux_sandbox_bwrap: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.expect("transform");
assert_eq!(
exec_request.sandbox_policy,
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
}
);
assert_eq!(
exec_request.network_sandbox_policy,
NetworkSandboxPolicy::Enabled
);
}
#[test]
fn transform_additional_permissions_preserves_denied_entries() {
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
let temp_dir = TempDir::new().expect("create temp dir");
let workspace_root = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let allowed_path = workspace_root.join("allowed").expect("allowed path");
let denied_path = workspace_root.join("denied").expect("denied path");
let exec_request = manager
.transform(super::SandboxTransformRequest {
spec: super::CommandSpec {
program: "true".to_string(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
expiration: crate::exec::ExecExpiration::DefaultTimeout,
sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions,
additional_permissions: Some(PermissionProfile {
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![allowed_path.clone()]),
}),
..Default::default()
}),
justification: None,
},
policy: &SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
},
file_system_policy: &FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_path.clone(),
},
access: FileSystemAccessMode::None,
},
]),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: None,
use_linux_sandbox_bwrap: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.expect("transform");
assert_eq!(
exec_request.file_system_sandbox_policy,
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: denied_path },
access: FileSystemAccessMode::None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: allowed_path },
access: FileSystemAccessMode::Write,
},
])
);
assert_eq!(
exec_request.network_sandbox_policy,
NetworkSandboxPolicy::Restricted
);
}
#[test]
fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roots() {
let temp_dir = TempDir::new().expect("create temp dir");
let cwd = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let allowed_path = cwd.join("allowed").expect("allowed path");
let denied_path = cwd.join("denied").expect("denied path");
let merged_policy = merge_file_system_policy_with_additional_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_path.clone(),
},
access: FileSystemAccessMode::None,
},
]),
vec![allowed_path.clone()],
Vec::new(),
);
assert_eq!(
merged_policy.entries.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path { path: denied_path },
access: FileSystemAccessMode::None,
}),
true
);
assert_eq!(
merged_policy.entries.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path { path: allowed_path },
access: FileSystemAccessMode::Read,
}),
true
);
}
}

View File

@@ -22,6 +22,8 @@ use crate::spawn::CODEX_SANDBOX_ENV_VAR;
use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl");
const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl");
@@ -51,7 +53,7 @@ pub async fn spawn_command_under_seatbelt(
args,
arg0,
cwd: command_cwd,
sandbox_policy,
network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy),
network,
stdio_policy,
env,
@@ -259,10 +261,23 @@ fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String {
policy
}
#[cfg_attr(not(test), allow(dead_code))]
fn dynamic_network_policy(
sandbox_policy: &SandboxPolicy,
enforce_managed_network: bool,
proxy: &ProxyPolicyInputs,
) -> String {
dynamic_network_policy_for_network(
NetworkSandboxPolicy::from(sandbox_policy),
enforce_managed_network,
proxy,
)
}
fn dynamic_network_policy_for_network(
network_policy: NetworkSandboxPolicy,
enforce_managed_network: bool,
proxy: &ProxyPolicyInputs,
) -> String {
let should_use_restricted_network_policy =
!proxy.ports.is_empty() || proxy.has_proxy_config || enforce_managed_network;
@@ -287,7 +302,19 @@ fn dynamic_network_policy(
return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}");
}
if sandbox_policy.has_full_network_access() {
if proxy.has_proxy_config {
// Proxy configuration is present but we could not infer any valid loopback endpoints.
// Fail closed to avoid silently widening network access in proxy-enforced sessions.
return String::new();
}
if enforce_managed_network {
// Managed network requirements are active but no usable proxy endpoints
// are available. Fail closed for network access.
return String::new();
}
if network_policy.is_enabled() {
// No proxy env is configured: retain the existing full-network behavior.
format!(
"(allow network-outbound)\n(allow network-inbound)\n{MACOS_SEATBELT_NETWORK_POLICY}"
@@ -304,9 +331,28 @@ pub(crate) fn create_seatbelt_command_args(
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
) -> Vec<String> {
create_seatbelt_command_args_with_extensions(
create_seatbelt_command_args_for_policies(
command,
sandbox_policy,
&FileSystemSandboxPolicy::from(sandbox_policy),
NetworkSandboxPolicy::from(sandbox_policy),
sandbox_policy_cwd,
enforce_managed_network,
network,
)
}
pub(crate) fn create_seatbelt_command_args_for_policies(
command: Vec<String>,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
) -> Vec<String> {
create_seatbelt_command_args_for_policies_with_extensions(
command,
file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
@@ -314,6 +360,64 @@ pub(crate) fn create_seatbelt_command_args(
)
}
fn root_absolute_path() -> AbsolutePathBuf {
match AbsolutePathBuf::from_absolute_path(Path::new("/")) {
Ok(path) => path,
Err(err) => panic!("root path must be absolute: {err}"),
}
}
#[derive(Debug, Clone)]
struct SeatbeltAccessRoot {
root: AbsolutePathBuf,
excluded_subpaths: Vec<AbsolutePathBuf>,
}
fn build_seatbelt_access_policy(
action: &str,
param_prefix: &str,
roots: Vec<SeatbeltAccessRoot>,
) -> (String, Vec<(String, PathBuf)>) {
let mut policy_components = Vec::new();
let mut params = Vec::new();
for (index, access_root) in roots.into_iter().enumerate() {
let root =
normalize_path_for_sandbox(access_root.root.as_path()).unwrap_or(access_root.root);
let root_param = format!("{param_prefix}_{index}");
params.push((root_param.clone(), root.into_path_buf()));
if access_root.excluded_subpaths.is_empty() {
policy_components.push(format!("(subpath (param \"{root_param}\"))"));
continue;
}
let mut require_parts = vec![format!("(subpath (param \"{root_param}\"))")];
for (excluded_index, excluded_subpath) in
access_root.excluded_subpaths.into_iter().enumerate()
{
let excluded_subpath =
normalize_path_for_sandbox(excluded_subpath.as_path()).unwrap_or(excluded_subpath);
let excluded_param = format!("{param_prefix}_{index}_RO_{excluded_index}");
params.push((excluded_param.clone(), excluded_subpath.into_path_buf()));
require_parts.push(format!(
"(require-not (subpath (param \"{excluded_param}\")))"
));
}
policy_components.push(format!("(require-all {} )", require_parts.join(" ")));
}
if policy_components.is_empty() {
(String::new(), Vec::new())
} else {
(
format!("(allow {action}\n{}\n)", policy_components.join(" ")),
params,
)
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn create_seatbelt_command_args_with_extensions(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
@@ -322,101 +426,132 @@ pub(crate) fn create_seatbelt_command_args_with_extensions(
network: Option<&NetworkProxy>,
extensions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Vec<String> {
let (file_write_policy, file_write_dir_params) = {
if sandbox_policy.has_full_disk_write_access() {
// Allegedly, this is more permissive than `(allow file-write*)`.
(
r#"(allow file-write* (regex #"^/"))"#.to_string(),
Vec::new(),
)
} else {
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
create_seatbelt_command_args_for_policies_with_extensions(
command,
&FileSystemSandboxPolicy::from(sandbox_policy),
NetworkSandboxPolicy::from(sandbox_policy),
sandbox_policy_cwd,
enforce_managed_network,
network,
extensions,
)
}
let mut writable_folder_policies: Vec<String> = Vec::new();
let mut file_write_params = Vec::new();
pub(crate) fn create_seatbelt_command_args_for_policies_with_extensions(
command: Vec<String>,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
extensions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Vec<String> {
create_seatbelt_command_args_from_policies_inner(
command,
file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
extensions,
)
}
for (index, wr) in writable_roots.iter().enumerate() {
// Canonicalize to avoid mismatches like /var vs /private/var on macOS.
let canonical_root = wr
.root
.as_path()
.canonicalize()
.unwrap_or_else(|_| wr.root.to_path_buf());
let root_param = format!("WRITABLE_ROOT_{index}");
file_write_params.push((root_param.clone(), canonical_root));
if wr.read_only_subpaths.is_empty() {
writable_folder_policies.push(format!("(subpath (param \"{root_param}\"))"));
} else {
// Add parameters for each read-only subpath and generate
// the `(require-not ...)` clauses.
let mut require_parts: Vec<String> = Vec::new();
require_parts.push(format!("(subpath (param \"{root_param}\"))"));
for (subpath_index, ro) in wr.read_only_subpaths.iter().enumerate() {
let canonical_ro = ro
.as_path()
.canonicalize()
.unwrap_or_else(|_| ro.to_path_buf());
let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}");
require_parts
.push(format!("(require-not (subpath (param \"{ro_param}\")))"));
file_write_params.push((ro_param, canonical_ro));
}
let policy_component = format!("(require-all {} )", require_parts.join(" "));
writable_folder_policies.push(policy_component);
}
}
if writable_folder_policies.is_empty() {
("".to_string(), Vec::new())
fn create_seatbelt_command_args_from_policies_inner(
command: Vec<String>,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
extensions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Vec<String> {
let unreadable_roots =
file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd);
let (file_write_policy, file_write_dir_params) =
if file_system_sandbox_policy.has_full_disk_write_access() {
if unreadable_roots.is_empty() {
// Allegedly, this is more permissive than `(allow file-write*)`.
(
r#"(allow file-write* (regex #"^/"))"#.to_string(),
Vec::new(),
)
} else {
let file_write_policy = format!(
"(allow file-write*\n{}\n)",
writable_folder_policies.join(" ")
);
(file_write_policy, file_write_params)
build_seatbelt_access_policy(
"file-write*",
"WRITABLE_ROOT",
vec![SeatbeltAccessRoot {
root: root_absolute_path(),
excluded_subpaths: unreadable_roots.clone(),
}],
)
}
}
};
let (file_read_policy, file_read_dir_params) = if sandbox_policy.has_full_disk_read_access() {
(
"; allow read-only file operations\n(allow file-read*)".to_string(),
Vec::new(),
)
} else {
let mut readable_roots_policies: Vec<String> = Vec::new();
let mut file_read_params = Vec::new();
for (index, root) in sandbox_policy
.get_readable_roots_with_cwd(sandbox_policy_cwd)
.into_iter()
.enumerate()
{
// Canonicalize to avoid mismatches like /var vs /private/var on macOS.
let canonical_root = root
.as_path()
.canonicalize()
.unwrap_or_else(|_| root.to_path_buf());
let root_param = format!("READABLE_ROOT_{index}");
file_read_params.push((root_param.clone(), canonical_root));
readable_roots_policies.push(format!("(subpath (param \"{root_param}\"))"));
}
if readable_roots_policies.is_empty() {
("".to_string(), Vec::new())
} else {
(
format!(
"; allow read-only file operations\n(allow file-read*\n{}\n)",
readable_roots_policies.join(" ")
),
file_read_params,
build_seatbelt_access_policy(
"file-write*",
"WRITABLE_ROOT",
file_system_sandbox_policy
.get_writable_roots_with_cwd(sandbox_policy_cwd)
.into_iter()
.map(|root| SeatbeltAccessRoot {
root: root.root,
excluded_subpaths: root.read_only_subpaths,
})
.collect(),
)
}
};
};
let (file_read_policy, file_read_dir_params) =
if file_system_sandbox_policy.has_full_disk_read_access() {
if unreadable_roots.is_empty() {
(
"; allow read-only file operations\n(allow file-read*)".to_string(),
Vec::new(),
)
} else {
let (policy, params) = build_seatbelt_access_policy(
"file-read*",
"READABLE_ROOT",
vec![SeatbeltAccessRoot {
root: root_absolute_path(),
excluded_subpaths: unreadable_roots,
}],
);
(
format!("; allow read-only file operations\n{policy}"),
params,
)
}
} else {
let (policy, params) = build_seatbelt_access_policy(
"file-read*",
"READABLE_ROOT",
file_system_sandbox_policy
.get_readable_roots_with_cwd(sandbox_policy_cwd)
.into_iter()
.map(|root| SeatbeltAccessRoot {
excluded_subpaths: unreadable_roots
.iter()
.filter(|path| path.as_path().starts_with(root.as_path()))
.cloned()
.collect(),
root,
})
.collect(),
);
if policy.is_empty() {
(String::new(), params)
} else {
(
format!("; allow read-only file operations\n{policy}"),
params,
)
}
};
let proxy = proxy_policy_inputs(network);
let network_policy = dynamic_network_policy(sandbox_policy, enforce_managed_network, &proxy);
let network_policy =
dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy);
let seatbelt_extensions = extensions.map_or_else(
|| {
// Backward-compatibility default when no extension profile is provided.
@@ -425,7 +560,7 @@ pub(crate) fn create_seatbelt_command_args_with_extensions(
build_seatbelt_extensions,
);
let include_platform_defaults = sandbox_policy.include_platform_defaults();
let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults();
let mut policy_sections = vec![
MACOS_SEATBELT_BASE_POLICY.to_string(),
file_read_policy,
@@ -492,6 +627,7 @@ mod tests {
use super::ProxyPolicyInputs;
use super::UnixDomainSocketPolicy;
use super::create_seatbelt_command_args;
use super::create_seatbelt_command_args_for_policies;
use super::create_seatbelt_command_args_with_extensions;
use super::dynamic_network_policy;
use super::macos_dir_params;
@@ -503,6 +639,11 @@ mod tests {
use crate::seatbelt_permissions::MacOsAutomationPermission;
use crate::seatbelt_permissions::MacOsPreferencesPermission;
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::fs;
@@ -525,6 +666,15 @@ mod tests {
AbsolutePathBuf::from_absolute_path(Path::new(path)).expect("absolute path")
}
fn seatbelt_policy_arg(args: &[String]) -> &str {
let policy_index = args
.iter()
.position(|arg| arg == "-p")
.expect("seatbelt args should include -p");
args.get(policy_index + 1)
.expect("seatbelt args should include policy text")
}
#[test]
fn base_policy_allows_node_cpu_sysctls() {
assert!(
@@ -572,6 +722,93 @@ mod tests {
);
}
#[test]
fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() {
let unreadable = absolute_path("/tmp/codex-unreadable");
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: crate::protocol::FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: unreadable },
access: FileSystemAccessMode::None,
},
]);
let args = create_seatbelt_command_args_for_policies(
vec!["/bin/true".to_string()],
&file_system_policy,
NetworkSandboxPolicy::Restricted,
Path::new("/"),
false,
None,
);
let policy = seatbelt_policy_arg(&args);
assert!(
policy.contains("(require-not (subpath (param \"READABLE_ROOT_0_RO_0\")))"),
"expected read carveout in policy:\n{policy}"
);
assert!(
policy.contains("(require-not (subpath (param \"WRITABLE_ROOT_0_RO_0\")))"),
"expected write carveout in policy:\n{policy}"
);
assert!(
args.iter()
.any(|arg| arg == "-DREADABLE_ROOT_0_RO_0=/tmp/codex-unreadable"),
"expected read carveout parameter in args: {args:#?}"
);
assert!(
args.iter()
.any(|arg| arg == "-DWRITABLE_ROOT_0_RO_0=/tmp/codex-unreadable"),
"expected write carveout parameter in args: {args:#?}"
);
}
#[test]
fn explicit_unreadable_paths_are_excluded_from_readable_roots() {
let root = absolute_path("/tmp/codex-readable");
let unreadable = absolute_path("/tmp/codex-readable/private");
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: root },
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: unreadable },
access: FileSystemAccessMode::None,
},
]);
let args = create_seatbelt_command_args_for_policies(
vec!["/bin/true".to_string()],
&file_system_policy,
NetworkSandboxPolicy::Restricted,
Path::new("/"),
false,
None,
);
let policy = seatbelt_policy_arg(&args);
assert!(
policy.contains("(require-not (subpath (param \"READABLE_ROOT_0_RO_0\")))"),
"expected read carveout in policy:\n{policy}"
);
assert!(
args.iter()
.any(|arg| arg == "-DREADABLE_ROOT_0=/tmp/codex-readable"),
"expected readable root parameter in args: {args:#?}"
);
assert!(
args.iter()
.any(|arg| arg == "-DREADABLE_ROOT_0_RO_0=/tmp/codex-readable/private"),
"expected read carveout parameter in args: {args:#?}"
);
}
#[test]
fn seatbelt_args_include_macos_permission_extensions() {
let cwd = std::env::temp_dir();
@@ -990,7 +1227,7 @@ sys.exit(0 if allowed else 13)
; allow read-only file operations
(allow file-read*)
(allow file-write*
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_RO_1"))) ) (subpath (param "WRITABLE_ROOT_1")) (subpath (param "WRITABLE_ROOT_2"))
(subpath (param "WRITABLE_ROOT_0")) (require-all (subpath (param "WRITABLE_ROOT_1")) (require-not (subpath (param "WRITABLE_ROOT_1_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_1_RO_1"))) ) (subpath (param "WRITABLE_ROOT_2"))
)
; macOS permission profile extensions
@@ -1003,43 +1240,51 @@ sys.exit(0 if allowed else 13)
"#,
);
let mut expected_args = vec![
"-p".to_string(),
expected_policy,
assert_eq!(seatbelt_policy_arg(&args), expected_policy);
let expected_definitions = [
format!(
"-DWRITABLE_ROOT_0={}",
vulnerable_root_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_0_RO_0={}",
dot_git_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_0_RO_1={}",
dot_codex_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_1={}",
empty_root_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_2={}",
cwd.canonicalize()
.expect("canonicalize cwd")
.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_1={}",
vulnerable_root_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_1_RO_0={}",
dot_git_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_1_RO_1={}",
dot_codex_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_2={}",
empty_root_canonical.to_string_lossy()
),
];
for expected_definition in expected_definitions {
assert!(
args.contains(&expected_definition),
"expected definition arg `{expected_definition}` in {args:#?}"
);
}
for (key, value) in macos_dir_params() {
let expected_definition = format!("-D{key}={}", value.to_string_lossy());
assert!(
args.contains(&expected_definition),
"expected definition arg `{expected_definition}` in {args:#?}"
);
}
expected_args.extend(
macos_dir_params()
.into_iter()
.map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())),
);
expected_args.push("--".to_string());
expected_args.extend(shell_command);
assert_eq!(expected_args, args);
let command_index = args
.iter()
.position(|arg| arg == "--")
.expect("seatbelt args should include command separator");
assert_eq!(args[command_index + 1..], shell_command);
// Verify that .codex/config.toml cannot be modified under the generated
// Seatbelt policy.

View File

@@ -6,13 +6,13 @@ use tokio::process::Child;
use tokio::process::Command;
use tracing::trace;
use crate::protocol::SandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
/// Experimental environment variable that will be set to some non-empty value
/// if both of the following are true:
///
/// 1. The process was spawned by Codex as part of a shell tool call.
/// 2. SandboxPolicy.has_full_network_access() was false for the tool call.
/// 2. NetworkSandboxPolicy is restricted for the tool call.
///
/// We may try to have just one environment variable for all sandboxing
/// attributes, so this may change in the future.
@@ -33,15 +33,15 @@ pub enum StdioPolicy {
/// ensuring the args and environment variables used to create the `Command`
/// (and `Child`) honor the configuration.
///
/// For now, we take `SandboxPolicy` as a parameter to spawn_child() because
/// we need to determine whether to set the
/// For now, we take `NetworkSandboxPolicy` as a parameter to spawn_child()
/// because we need to determine whether to set the
/// `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` environment variable.
pub(crate) struct SpawnChildRequest<'a> {
pub program: PathBuf,
pub args: Vec<String>,
pub arg0: Option<&'a str>,
pub cwd: PathBuf,
pub sandbox_policy: &'a SandboxPolicy,
pub network_sandbox_policy: NetworkSandboxPolicy,
pub network: Option<&'a NetworkProxy>,
pub stdio_policy: StdioPolicy,
pub env: HashMap<String, String>,
@@ -53,14 +53,14 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io
args,
arg0,
cwd,
sandbox_policy,
network_sandbox_policy,
network,
stdio_policy,
mut env,
} = request;
trace!(
"spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {sandbox_policy:?} {stdio_policy:?} {env:?}"
"spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {network_sandbox_policy:?} {stdio_policy:?} {env:?}"
);
let mut cmd = Command::new(&program);
@@ -74,7 +74,7 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io
cmd.env_clear();
cmd.envs(env);
if !sandbox_policy.has_full_network_access() {
if !network_sandbox_policy.is_enabled() {
cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1");
}

View File

@@ -36,6 +36,8 @@ use super::SessionTaskContext;
use crate::codex::Session;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
const USER_SHELL_TIMEOUT_MS: u64 = 60 * 60 * 1000; // 1 hour
@@ -167,6 +169,8 @@ pub(crate) async fn execute_user_shell_command(
windows_sandbox_level: turn_context.windows_sandbox_level,
sandbox_permissions: SandboxPermissions::UseDefault,
sandbox_policy: sandbox_policy.clone(),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
justification: None,
arg0: None,
};

View File

@@ -852,7 +852,8 @@ impl JsReplManager {
.network
.is_some();
let sandbox_type = sandbox.select_initial(
&turn.sandbox_policy,
&turn.file_system_sandbox_policy,
turn.network_sandbox_policy,
SandboxablePreference::Auto,
turn.windows_sandbox_level,
has_managed_network_requirements,
@@ -861,6 +862,8 @@ impl JsReplManager {
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
policy: &turn.sandbox_policy,
file_system_policy: &turn.file_system_sandbox_policy,
network_policy: turn.network_sandbox_policy,
sandbox: sandbox_type,
enforce_managed_network: has_managed_network_requirements,
network: None,
@@ -1747,6 +1750,16 @@ mod tests {
use std::path::Path;
use tempfile::tempdir;
fn set_danger_full_access(turn: &mut crate::codex::TurnContext) {
turn.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
turn.file_system_sandbox_policy =
crate::protocol::FileSystemSandboxPolicy::from(turn.sandbox_policy.get());
turn.network_sandbox_policy =
crate::protocol::NetworkSandboxPolicy::from(turn.sandbox_policy.get());
}
#[test]
fn node_version_parses_v_prefix_and_suffix() {
let version = NodeVersion::parse("v25.1.0-nightly.2024").unwrap();
@@ -2464,9 +2477,7 @@ mod tests {
turn.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
turn.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
set_danger_full_access(&mut turn);
let session = Arc::new(session);
let turn = Arc::new(turn);
@@ -2518,9 +2529,7 @@ console.log("cell-complete");
turn.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
turn.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
set_danger_full_access(&mut turn);
let session = Arc::new(session);
let turn = Arc::new(turn);
@@ -2576,9 +2585,7 @@ console.log(out.type);
turn.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
turn.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
set_danger_full_access(&mut turn);
let session = Arc::new(session);
let turn = Arc::new(turn);

View File

@@ -169,7 +169,8 @@ impl ToolOrchestrator {
let initial_sandbox = match tool.sandbox_mode_for_first_attempt(req) {
SandboxOverride::BypassSandboxFirstAttempt => crate::exec::SandboxType::None,
SandboxOverride::NoOverride => self.sandbox.select_initial(
&turn_ctx.sandbox_policy,
&turn_ctx.file_system_sandbox_policy,
turn_ctx.network_sandbox_policy,
tool.sandbox_preference(),
turn_ctx.windows_sandbox_level,
has_managed_network_requirements,
@@ -182,6 +183,8 @@ impl ToolOrchestrator {
let initial_attempt = SandboxAttempt {
sandbox: initial_sandbox,
policy: &turn_ctx.sandbox_policy,
file_system_policy: &turn_ctx.file_system_sandbox_policy,
network_policy: turn_ctx.network_sandbox_policy,
enforce_managed_network: has_managed_network_requirements,
manager: &self.sandbox,
sandbox_cwd: &turn_ctx.cwd,
@@ -296,6 +299,8 @@ impl ToolOrchestrator {
let escalated_attempt = SandboxAttempt {
sandbox: crate::exec::SandboxType::None,
policy: &turn_ctx.sandbox_policy,
file_system_policy: &turn_ctx.file_system_sandbox_policy,
network_policy: turn_ctx.network_sandbox_policy,
enforce_managed_network: has_managed_network_requirements,
manager: &self.sandbox,
sandbox_cwd: &turn_ctx.cwd,

View File

@@ -24,6 +24,8 @@ use codex_execpolicy::RuleMatch;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::NetworkPolicyRuleAction;
use codex_protocol::protocol::RejectConfig;
@@ -98,6 +100,8 @@ pub(super) async fn try_run_zsh_fork(
windows_sandbox_level,
sandbox_permissions,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
justification,
arg0,
} = sandbox_exec_request;
@@ -113,6 +117,8 @@ pub(super) async fn try_run_zsh_fork(
command,
cwd: sandbox_cwd,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
sandbox,
env: sandbox_env,
network: sandbox_network,
@@ -220,6 +226,8 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
command: exec_request.command.clone(),
cwd: exec_request.cwd.clone(),
sandbox_policy: exec_request.sandbox_policy.clone(),
file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(),
network_sandbox_policy: exec_request.network_sandbox_policy,
sandbox: exec_request.sandbox,
env: exec_request.env.clone(),
network: exec_request.network.clone(),
@@ -728,6 +736,8 @@ struct CoreShellCommandExecutor {
command: Vec<String>,
cwd: PathBuf,
sandbox_policy: SandboxPolicy,
file_system_sandbox_policy: FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox: SandboxType,
env: HashMap<String, String>,
network: Option<codex_network_proxy::NetworkProxy>,
@@ -747,6 +757,8 @@ struct PrepareSandboxedExecParams<'a> {
workdir: &'a AbsolutePathBuf,
env: HashMap<String, String>,
sandbox_policy: &'a SandboxPolicy,
file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
additional_permissions: Option<PermissionProfile>,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>,
@@ -782,6 +794,8 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
windows_sandbox_level: self.windows_sandbox_level,
sandbox_permissions: self.sandbox_permissions,
sandbox_policy: self.sandbox_policy.clone(),
file_system_sandbox_policy: self.file_system_sandbox_policy.clone(),
network_sandbox_policy: self.network_sandbox_policy,
justification: self.justification.clone(),
arg0: self.arg0.clone(),
},
@@ -828,6 +842,8 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
workdir,
env,
sandbox_policy: &self.sandbox_policy,
file_system_sandbox_policy: &self.file_system_sandbox_policy,
network_sandbox_policy: self.network_sandbox_policy,
additional_permissions: None,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: self
@@ -845,6 +861,8 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
workdir,
env,
sandbox_policy: &self.sandbox_policy,
file_system_sandbox_policy: &self.file_system_sandbox_policy,
network_sandbox_policy: self.network_sandbox_policy,
additional_permissions: Some(permission_profile),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: self
@@ -854,11 +872,17 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
}
EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => {
// Use a fully specified sandbox policy instead of merging into the turn policy.
let file_system_sandbox_policy =
FileSystemSandboxPolicy::from(&permissions.sandbox_policy);
let network_sandbox_policy =
NetworkSandboxPolicy::from(&permissions.sandbox_policy);
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
command,
workdir,
env,
sandbox_policy: &permissions.sandbox_policy,
file_system_sandbox_policy: &file_system_sandbox_policy,
network_sandbox_policy,
additional_permissions: None,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: permissions
@@ -873,6 +897,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
}
impl CoreShellCommandExecutor {
#[allow(clippy::too_many_arguments)]
fn prepare_sandboxed_exec(
&self,
params: PrepareSandboxedExecParams<'_>,
@@ -882,6 +907,8 @@ impl CoreShellCommandExecutor {
workdir,
env,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
additional_permissions,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions,
@@ -891,7 +918,8 @@ impl CoreShellCommandExecutor {
.ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?;
let sandbox_manager = crate::sandboxing::SandboxManager::new();
let sandbox = sandbox_manager.select_initial(
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
SandboxablePreference::Auto,
self.windows_sandbox_level,
self.network.is_some(),
@@ -913,6 +941,8 @@ impl CoreShellCommandExecutor {
justification: self.justification.clone(),
},
policy: sandbox_policy,
file_system_policy: file_system_sandbox_policy,
network_policy: network_sandbox_policy,
sandbox,
enforce_managed_network: self.network.is_some(),
network: self.network.as_ref(),

View File

@@ -31,6 +31,10 @@ use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsPreferencesPermission;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
#[cfg(target_os = "macos")]
use codex_protocol::permissions::FileSystemSandboxPolicy;
#[cfg(target_os = "macos")]
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::SkillScope;
use codex_shell_escalation::EscalationExecution;
use codex_shell_escalation::EscalationPermissions;
@@ -474,6 +478,10 @@ async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions
network: None,
sandbox: SandboxType::None,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
@@ -524,6 +532,8 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions()
network: None,
sandbox: SandboxType::None,
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess),
network_sandbox_policy: NetworkSandboxPolicy::Enabled,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
@@ -537,6 +547,10 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions()
let permissions = Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: codex_protocol::permissions::FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -584,13 +598,16 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions()
#[tokio::test]
async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_macos_extensions() {
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap();
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let executor = CoreShellCommandExecutor {
command: vec!["echo".to_string(), "ok".to_string()],
cwd: cwd.to_path_buf(),
env: HashMap::new(),
network: None,
sandbox: SandboxType::None,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
sandbox_policy: sandbox_policy.clone(),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,

View File

@@ -17,6 +17,8 @@ use crate::tools::network_approval::NetworkApprovalSpec;
use codex_network_proxy::NetworkProxy;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use futures::Future;
@@ -318,6 +320,8 @@ pub(crate) trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable {
pub(crate) struct SandboxAttempt<'a> {
pub sandbox: crate::exec::SandboxType,
pub policy: &'a crate::protocol::SandboxPolicy,
pub file_system_policy: &'a FileSystemSandboxPolicy,
pub network_policy: NetworkSandboxPolicy,
pub enforce_managed_network: bool,
pub(crate) manager: &'a SandboxManager,
pub(crate) sandbox_cwd: &'a Path,
@@ -336,6 +340,8 @@ impl<'a> SandboxAttempt<'a> {
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
policy: self.policy,
file_system_policy: self.file_system_policy,
network_policy: self.network_policy,
sandbox: self.sandbox,
enforce_managed_network: self.enforce_managed_network,
network,

View File

@@ -205,6 +205,10 @@ mod tests {
turn.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
turn.file_system_sandbox_policy =
codex_protocol::permissions::FileSystemSandboxPolicy::from(turn.sandbox_policy.get());
turn.network_sandbox_policy =
codex_protocol::permissions::NetworkSandboxPolicy::from(turn.sandbox_policy.get());
(Arc::new(session), Arc::new(turn))
}

View File

@@ -2234,7 +2234,12 @@ async fn denying_network_policy_amendment_persists_policy_and_skips_future_netwo
let home = Arc::new(TempDir::new()?);
fs::write(
home.path().join("config.toml"),
r#"[permissions.network]
r#"default_permissions = "workspace"
[permissions.workspace.filesystem]
":minimal" = "read"
[permissions.workspace.network]
enabled = true
mode = "limited"
allow_local_binding = true

View File

@@ -10,6 +10,8 @@ use codex_core::exec::process_exec_tool_call;
use codex_core::sandboxing::SandboxPermissions;
use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use tempfile::TempDir;
@@ -45,7 +47,17 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput
let policy = SandboxPolicy::new_read_only_policy();
process_exec_tool_call(params, &policy, tmp.path(), &None, false, None).await
process_exec_tool_call(
params,
&policy,
&FileSystemSandboxPolicy::from(&policy),
NetworkSandboxPolicy::from(&policy),
tmp.path(),
&None,
false,
None,
)
.await
}
/// Command succeeds with exit code 0 normally

View File

@@ -1,5 +1,6 @@
use anyhow::Context;
use codex_core::features::Feature;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandEndEvent;
@@ -23,6 +24,7 @@ use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use core_test_support::wait_for_event_with_timeout;
use pretty_assertions::assert_eq;
use regex_lite::escape;
use std::path::PathBuf;
use tempfile::TempDir;
@@ -328,6 +330,35 @@ async fn user_shell_command_history_is_persisted_and_shared_with_model() -> anyh
Ok(())
}
#[tokio::test]
async fn user_shell_command_does_not_set_network_sandbox_env_var() -> anyhow::Result<()> {
let server = responses::start_mock_server().await;
let mut builder = core_test_support::test_codex::test_codex().with_config(|config| {
config.permissions.network_sandbox_policy = NetworkSandboxPolicy::Restricted;
});
let test = builder.build(&server).await?;
#[cfg(windows)]
let command = r#"$val = $env:CODEX_SANDBOX_NETWORK_DISABLED; if ([string]::IsNullOrEmpty($val)) { $val = 'not-set' } ; [System.Console]::Write($val)"#.to_string();
#[cfg(not(windows))]
let command =
r#"sh -c "printf '%s' \"${CODEX_SANDBOX_NETWORK_DISABLED:-not-set}\"""#.to_string();
test.codex
.submit(Op::RunUserShellCommand { command })
.await?;
let end_event = wait_for_event_match(&test.codex, |ev| match ev {
EventMsg::ExecCommandEnd(event) => Some(event.clone()),
_ => None,
})
.await;
assert_eq!(end_event.exit_code, 0);
assert_eq!(end_event.stdout.trim(), "not-set");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg(not(target_os = "windows"))] // TODO: unignore on windows
async fn user_shell_command_output_is_truncated_in_history() -> anyhow::Result<()> {

View File

@@ -8,6 +8,7 @@ use std::path::Path;
use codex_core::error::CodexErr;
use codex_core::error::Result;
use codex_core::error::SandboxErr;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -40,13 +41,14 @@ use seccompiler::apply_filter;
/// Filesystem restrictions are intentionally handled by bubblewrap.
pub(crate) fn apply_sandbox_policy_to_current_thread(
sandbox_policy: &SandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
cwd: &Path,
apply_landlock_fs: bool,
allow_network_for_proxy: bool,
proxy_routed_network: bool,
) -> Result<()> {
let network_seccomp_mode = network_seccomp_mode(
sandbox_policy,
network_sandbox_policy,
allow_network_for_proxy,
proxy_routed_network,
);
@@ -91,20 +93,20 @@ enum NetworkSeccompMode {
}
fn should_install_network_seccomp(
sandbox_policy: &SandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
allow_network_for_proxy: bool,
) -> bool {
// Managed-network sessions should remain fail-closed even for policies that
// would normally grant full network access (for example, DangerFullAccess).
!sandbox_policy.has_full_network_access() || allow_network_for_proxy
!network_sandbox_policy.is_enabled() || allow_network_for_proxy
}
fn network_seccomp_mode(
sandbox_policy: &SandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
allow_network_for_proxy: bool,
proxy_routed_network: bool,
) -> Option<NetworkSeccompMode> {
if !should_install_network_seccomp(sandbox_policy, allow_network_for_proxy) {
if !should_install_network_seccomp(network_sandbox_policy, allow_network_for_proxy) {
None
} else if proxy_routed_network {
Some(NetworkSeccompMode::ProxyRouted)

View File

@@ -14,6 +14,9 @@ use crate::proxy_routing::activate_proxy_routes_in_netns;
use crate::proxy_routing::prepare_host_proxy_route_spec;
use crate::vendored_bwrap::exec_vendored_bwrap;
use crate::vendored_bwrap::run_vendored_bwrap_main;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
#[derive(Debug, Parser)]
/// CLI surface for the Linux sandbox helper.
@@ -26,8 +29,18 @@ pub struct LandlockCommand {
#[arg(long = "sandbox-policy-cwd")]
pub sandbox_policy_cwd: PathBuf,
#[arg(long = "sandbox-policy")]
pub sandbox_policy: codex_protocol::protocol::SandboxPolicy,
/// Legacy compatibility policy.
///
/// Newer callers pass split filesystem/network policies as well so the
/// helper can migrate incrementally without breaking older invocations.
#[arg(long = "sandbox-policy", hide = true)]
pub sandbox_policy: Option<SandboxPolicy>,
#[arg(long = "file-system-sandbox-policy", hide = true)]
pub file_system_sandbox_policy: Option<FileSystemSandboxPolicy>,
#[arg(long = "network-sandbox-policy", hide = true)]
pub network_sandbox_policy: Option<NetworkSandboxPolicy>,
/// Opt-in: use the bubblewrap-based Linux sandbox pipeline.
///
@@ -77,6 +90,8 @@ pub fn run_main() -> ! {
let LandlockCommand {
sandbox_policy_cwd,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
use_bwrap_sandbox,
apply_seccomp_then_exec,
allow_network_for_proxy,
@@ -89,6 +104,16 @@ pub fn run_main() -> ! {
panic!("No command specified to execute.");
}
ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_bwrap_sandbox);
let EffectiveSandboxPolicies {
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
} = resolve_sandbox_policies(
sandbox_policy_cwd.as_path(),
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
);
// Inner stage: apply seccomp/no_new_privs after bubblewrap has already
// established the filesystem view.
@@ -104,6 +129,7 @@ pub fn run_main() -> ! {
let proxy_routing_active = allow_network_for_proxy;
if let Err(e) = apply_sandbox_policy_to_current_thread(
&sandbox_policy,
network_sandbox_policy,
&sandbox_policy_cwd,
false,
allow_network_for_proxy,
@@ -114,9 +140,10 @@ pub fn run_main() -> ! {
exec_or_panic(command);
}
if sandbox_policy.has_full_disk_write_access() && !allow_network_for_proxy {
if file_system_sandbox_policy.has_full_disk_write_access() && !allow_network_for_proxy {
if let Err(e) = apply_sandbox_policy_to_current_thread(
&sandbox_policy,
network_sandbox_policy,
&sandbox_policy_cwd,
false,
allow_network_for_proxy,
@@ -142,6 +169,8 @@ pub fn run_main() -> ! {
let inner = build_inner_seccomp_command(
&sandbox_policy_cwd,
&sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
use_bwrap_sandbox,
allow_network_for_proxy,
proxy_route_spec,
@@ -150,6 +179,7 @@ pub fn run_main() -> ! {
run_bwrap_with_proc_fallback(
&sandbox_policy_cwd,
&sandbox_policy,
network_sandbox_policy,
inner,
!no_proc,
allow_network_for_proxy,
@@ -159,6 +189,7 @@ pub fn run_main() -> ! {
// Legacy path: Landlock enforcement only, when bwrap sandboxing is not enabled.
if let Err(e) = apply_sandbox_policy_to_current_thread(
&sandbox_policy,
network_sandbox_policy,
&sandbox_policy_cwd,
true,
allow_network_for_proxy,
@@ -169,6 +200,53 @@ pub fn run_main() -> ! {
exec_or_panic(command);
}
#[derive(Debug, Clone)]
struct EffectiveSandboxPolicies {
sandbox_policy: SandboxPolicy,
file_system_sandbox_policy: FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
}
fn resolve_sandbox_policies(
sandbox_policy_cwd: &Path,
sandbox_policy: Option<SandboxPolicy>,
file_system_sandbox_policy: Option<FileSystemSandboxPolicy>,
network_sandbox_policy: Option<NetworkSandboxPolicy>,
) -> EffectiveSandboxPolicies {
match (
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
) {
(Some(sandbox_policy), Some(file_system_sandbox_policy), Some(network_sandbox_policy)) => {
EffectiveSandboxPolicies {
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
}
}
(Some(sandbox_policy), None, None) => EffectiveSandboxPolicies {
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
sandbox_policy,
},
(None, Some(file_system_sandbox_policy), Some(network_sandbox_policy)) => {
let sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, sandbox_policy_cwd)
.unwrap_or_else(|err| {
panic!("failed to derive legacy sandbox policy from split policies: {err}")
});
EffectiveSandboxPolicies {
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
}
}
(None, None, None) => panic!("missing sandbox policy configuration"),
_ => panic!("file-system and network sandbox policies must be provided together"),
}
}
fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_sandbox: bool) {
if apply_seccomp_then_exec && !use_bwrap_sandbox {
panic!("--apply-seccomp-then-exec requires --use-bwrap-sandbox");
@@ -177,12 +255,13 @@ fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_san
fn run_bwrap_with_proc_fallback(
sandbox_policy_cwd: &Path,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
sandbox_policy: &SandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
inner: Vec<String>,
mount_proc: bool,
allow_network_for_proxy: bool,
) -> ! {
let network_mode = bwrap_network_mode(sandbox_policy, allow_network_for_proxy);
let network_mode = bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy);
let mut mount_proc = mount_proc;
if mount_proc && !preflight_proc_mount_support(sandbox_policy_cwd, sandbox_policy, network_mode)
@@ -200,12 +279,12 @@ fn run_bwrap_with_proc_fallback(
}
fn bwrap_network_mode(
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
allow_network_for_proxy: bool,
) -> BwrapNetworkMode {
if allow_network_for_proxy {
BwrapNetworkMode::ProxyOnly
} else if sandbox_policy.has_full_network_access() {
} else if network_sandbox_policy.is_enabled() {
BwrapNetworkMode::FullAccess
} else {
BwrapNetworkMode::Isolated
@@ -214,7 +293,7 @@ fn bwrap_network_mode(
fn build_bwrap_argv(
inner: Vec<String>,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
sandbox_policy: &SandboxPolicy,
sandbox_policy_cwd: &Path,
options: BwrapOptions,
) -> Vec<String> {
@@ -237,7 +316,7 @@ fn build_bwrap_argv(
fn preflight_proc_mount_support(
sandbox_policy_cwd: &Path,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
sandbox_policy: &SandboxPolicy,
network_mode: BwrapNetworkMode,
) -> bool {
let preflight_argv =
@@ -248,7 +327,7 @@ fn preflight_proc_mount_support(
fn build_preflight_bwrap_argv(
sandbox_policy_cwd: &Path,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
sandbox_policy: &SandboxPolicy,
network_mode: BwrapNetworkMode,
) -> Vec<String> {
let preflight_command = vec![resolve_true_command()];
@@ -361,7 +440,9 @@ fn is_proc_mount_failure(stderr: &str) -> bool {
/// Build the inner command that applies seccomp after bubblewrap.
fn build_inner_seccomp_command(
sandbox_policy_cwd: &Path,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
use_bwrap_sandbox: bool,
allow_network_for_proxy: bool,
proxy_route_spec: Option<String>,
@@ -375,6 +456,14 @@ fn build_inner_seccomp_command(
Ok(json) => json,
Err(err) => panic!("failed to serialize sandbox policy: {err}"),
};
let file_system_policy_json = match serde_json::to_string(file_system_sandbox_policy) {
Ok(json) => json,
Err(err) => panic!("failed to serialize filesystem sandbox policy: {err}"),
};
let network_policy_json = match serde_json::to_string(&network_sandbox_policy) {
Ok(json) => json,
Err(err) => panic!("failed to serialize network sandbox policy: {err}"),
};
let mut inner = vec![
current_exe.to_string_lossy().to_string(),
@@ -382,6 +471,10 @@ fn build_inner_seccomp_command(
sandbox_policy_cwd.to_string_lossy().to_string(),
"--sandbox-policy".to_string(),
policy_json,
"--file-system-sandbox-policy".to_string(),
file_system_policy_json,
"--network-sandbox-policy".to_string(),
network_policy_json,
];
if use_bwrap_sandbox {
inner.push("--use-bwrap-sandbox".to_string());

View File

@@ -1,7 +1,13 @@
#[cfg(test)]
use super::*;
#[cfg(test)]
use codex_protocol::protocol::FileSystemSandboxPolicy;
#[cfg(test)]
use codex_protocol::protocol::NetworkSandboxPolicy;
#[cfg(test)]
use codex_protocol::protocol::SandboxPolicy;
#[cfg(test)]
use pretty_assertions::assert_eq;
#[test]
fn detects_proc_mount_invalid_argument_failure() {
@@ -91,22 +97,25 @@ fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
#[test]
fn proxy_only_mode_takes_precedence_over_full_network_policy() {
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
let mode = bwrap_network_mode(NetworkSandboxPolicy::Enabled, true);
assert_eq!(mode, BwrapNetworkMode::ProxyOnly);
}
#[test]
fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() {
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
let mode = bwrap_network_mode(NetworkSandboxPolicy::Enabled, true);
let argv = build_preflight_bwrap_argv(Path::new("/"), &SandboxPolicy::DangerFullAccess, mode);
assert!(argv.iter().any(|arg| arg == "--"));
}
#[test]
fn managed_proxy_inner_command_includes_route_spec() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let args = build_inner_seccomp_command(
Path::new("/tmp"),
&SandboxPolicy::new_read_only_policy(),
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::Restricted,
true,
true,
Some("{\"routes\":[]}".to_string()),
@@ -118,10 +127,31 @@ fn managed_proxy_inner_command_includes_route_spec() {
}
#[test]
fn non_managed_inner_command_omits_route_spec() {
fn inner_command_includes_split_policy_flags() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let args = build_inner_seccomp_command(
Path::new("/tmp"),
&SandboxPolicy::new_read_only_policy(),
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::Restricted,
true,
false,
None,
vec!["/bin/true".to_string()],
);
assert!(args.iter().any(|arg| arg == "--file-system-sandbox-policy"));
assert!(args.iter().any(|arg| arg == "--network-sandbox-policy"));
}
#[test]
fn non_managed_inner_command_omits_route_spec() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let args = build_inner_seccomp_command(
Path::new("/tmp"),
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::Restricted,
true,
false,
None,
@@ -134,9 +164,12 @@ fn non_managed_inner_command_omits_route_spec() {
#[test]
fn managed_proxy_inner_command_requires_route_spec() {
let result = std::panic::catch_unwind(|| {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
build_inner_seccomp_command(
Path::new("/tmp"),
&SandboxPolicy::new_read_only_policy(),
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::Restricted,
true,
true,
None,
@@ -146,6 +179,59 @@ fn managed_proxy_inner_command_requires_route_spec() {
assert!(result.is_err());
}
#[test]
fn resolve_sandbox_policies_derives_split_policies_from_legacy_policy() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let resolved =
resolve_sandbox_policies(Path::new("/tmp"), Some(sandbox_policy.clone()), None, None);
assert_eq!(resolved.sandbox_policy, sandbox_policy.clone());
assert_eq!(
resolved.file_system_sandbox_policy,
FileSystemSandboxPolicy::from(&sandbox_policy)
);
assert_eq!(
resolved.network_sandbox_policy,
NetworkSandboxPolicy::from(&sandbox_policy)
);
}
#[test]
fn resolve_sandbox_policies_derives_legacy_policy_from_split_policies() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
let resolved = resolve_sandbox_policies(
Path::new("/tmp"),
None,
Some(file_system_sandbox_policy.clone()),
Some(network_sandbox_policy),
);
assert_eq!(resolved.sandbox_policy, sandbox_policy);
assert_eq!(
resolved.file_system_sandbox_policy,
file_system_sandbox_policy
);
assert_eq!(resolved.network_sandbox_policy, network_sandbox_policy);
}
#[test]
fn resolve_sandbox_policies_rejects_partial_split_policies() {
let result = std::panic::catch_unwind(|| {
resolve_sandbox_policies(
Path::new("/tmp"),
Some(SandboxPolicy::new_read_only_policy()),
Some(FileSystemSandboxPolicy::default()),
None,
)
});
assert!(result.is_err());
}
#[test]
fn apply_seccomp_then_exec_without_bwrap_panics() {
let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, false));

View File

@@ -9,6 +9,8 @@ use codex_core::exec::process_exec_tool_call;
use codex_core::exec_env::create_env;
use codex_core::sandboxing::SandboxPermissions;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -102,6 +104,8 @@ async fn run_cmd_result_with_writable_roots(
process_exec_tool_call(
params,
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::from(&sandbox_policy),
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
use_bwrap_sandbox,
@@ -333,6 +337,8 @@ async fn assert_network_blocked(cmd: &[&str]) {
let result = process_exec_tool_call(
params,
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::from(&sandbox_policy),
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
false,

View File

@@ -13,10 +13,12 @@ It enforces an allow/deny policy and a "limited" mode intended for read-only net
`codex-network-proxy` reads from Codex's merged `config.toml` (via `codex-core` config loading).
Example config:
Network settings live under the selected permissions profile. Example config:
```toml
[network]
default_permissions = "workspace"
[permissions.workspace.network]
enabled = true
proxy_url = "http://127.0.0.1:3128"
# SOCKS5 listener (enabled by default).

View File

@@ -12,6 +12,7 @@ pub mod models;
pub mod num_format;
pub mod openai_models;
pub mod parse_command;
pub mod permissions;
pub mod plan_tool;
pub mod protocol;
pub mod request_user_input;

View File

@@ -0,0 +1,695 @@
use std::collections::HashSet;
use std::ffi::OsStr;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use strum_macros::Display;
use tracing::error;
use ts_rs::TS;
use crate::protocol::NetworkAccess;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
use crate::protocol::WritableRoot;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum NetworkSandboxPolicy {
#[default]
Restricted,
Enabled,
}
impl NetworkSandboxPolicy {
pub fn is_enabled(self) -> bool {
matches!(self, NetworkSandboxPolicy::Enabled)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum FileSystemAccessMode {
None,
Read,
Write,
}
impl FileSystemAccessMode {
pub fn can_read(self) -> bool {
!matches!(self, FileSystemAccessMode::None)
}
pub fn can_write(self) -> bool {
matches!(self, FileSystemAccessMode::Write)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[ts(tag = "kind")]
pub enum FileSystemSpecialPath {
Root,
Minimal,
CurrentWorkingDirectory,
ProjectRoots {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
subpath: Option<PathBuf>,
},
Tmpdir,
SlashTmp,
}
impl FileSystemSpecialPath {
pub fn project_roots(subpath: Option<PathBuf>) -> Self {
Self::ProjectRoots { subpath }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
pub struct FileSystemSandboxEntry {
pub path: FileSystemPath,
pub access: FileSystemAccessMode,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum FileSystemSandboxKind {
#[default]
Restricted,
Unrestricted,
ExternalSandbox,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
pub struct FileSystemSandboxPolicy {
pub kind: FileSystemSandboxKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<FileSystemSandboxEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum FileSystemPath {
Path { path: AbsolutePathBuf },
Special { value: FileSystemSpecialPath },
}
impl Default for FileSystemSandboxPolicy {
fn default() -> Self {
Self {
kind: FileSystemSandboxKind::Restricted,
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}],
}
}
}
impl FileSystemSandboxPolicy {
pub fn unrestricted() -> Self {
Self {
kind: FileSystemSandboxKind::Unrestricted,
entries: Vec::new(),
}
}
pub fn external_sandbox() -> Self {
Self {
kind: FileSystemSandboxKind::ExternalSandbox,
entries: Vec::new(),
}
}
pub fn restricted(entries: Vec<FileSystemSandboxEntry>) -> Self {
Self {
kind: FileSystemSandboxKind::Restricted,
entries,
}
}
/// Returns true when filesystem reads are unrestricted.
pub fn has_full_disk_read_access(&self) -> bool {
match self.kind {
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
FileSystemSandboxKind::Restricted => self.entries.iter().any(|entry| {
matches!(
&entry.path,
FileSystemPath::Special { value }
if matches!(value, FileSystemSpecialPath::Root) && entry.access.can_read()
)
}),
}
}
/// Returns true when filesystem writes are unrestricted.
pub fn has_full_disk_write_access(&self) -> bool {
match self.kind {
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
FileSystemSandboxKind::Restricted => self.entries.iter().any(|entry| {
matches!(
&entry.path,
FileSystemPath::Special { value }
if matches!(value, FileSystemSpecialPath::Root)
&& entry.access.can_write()
)
}),
}
}
/// Returns true when platform-default readable roots should be included.
pub fn include_platform_defaults(&self) -> bool {
!self.has_full_disk_read_access()
&& matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self.entries.iter().any(|entry| {
matches!(
&entry.path,
FileSystemPath::Special { value }
if matches!(value, FileSystemSpecialPath::Minimal)
&& entry.access.can_read()
)
})
}
/// Returns the explicit readable roots resolved against the provided cwd.
pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
if self.has_full_disk_read_access() {
return Vec::new();
}
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
dedup_absolute_paths(
self.entries
.iter()
.filter(|entry| entry.access.can_read())
.filter_map(|entry| resolve_file_system_path(&entry.path, cwd_absolute.as_ref()))
.collect(),
)
}
/// Returns the writable roots together with read-only carveouts resolved
/// against the provided cwd.
pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
if self.has_full_disk_write_access() {
return Vec::new();
}
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
let unreadable_roots = self.get_unreadable_roots_with_cwd(cwd);
dedup_absolute_paths(
self.entries
.iter()
.filter(|entry| entry.access.can_write())
.filter_map(|entry| resolve_file_system_path(&entry.path, cwd_absolute.as_ref()))
.collect(),
)
.into_iter()
.map(|root| {
let mut read_only_subpaths = default_read_only_subpaths_for_writable_root(&root);
read_only_subpaths.extend(
unreadable_roots
.iter()
.filter(|path| path.as_path().starts_with(root.as_path()))
.cloned(),
);
WritableRoot {
root,
read_only_subpaths: dedup_absolute_paths(read_only_subpaths),
}
})
.collect()
}
/// Returns explicit unreadable roots resolved against the provided cwd.
pub fn get_unreadable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
return Vec::new();
}
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
dedup_absolute_paths(
self.entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::None)
.filter_map(|entry| resolve_file_system_path(&entry.path, cwd_absolute.as_ref()))
.collect(),
)
}
pub fn to_legacy_sandbox_policy(
&self,
network_policy: NetworkSandboxPolicy,
cwd: &Path,
) -> io::Result<SandboxPolicy> {
Ok(match self.kind {
FileSystemSandboxKind::ExternalSandbox => SandboxPolicy::ExternalSandbox {
network_access: if network_policy.is_enabled() {
NetworkAccess::Enabled
} else {
NetworkAccess::Restricted
},
},
FileSystemSandboxKind::Unrestricted => {
if network_policy.is_enabled() {
SandboxPolicy::DangerFullAccess
} else {
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
}
}
}
FileSystemSandboxKind::Restricted => {
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
let mut include_platform_defaults = false;
let mut has_full_disk_read_access = false;
let mut has_full_disk_write_access = false;
let mut workspace_root_writable = false;
let mut writable_roots = Vec::new();
let mut readable_roots = Vec::new();
let mut tmpdir_writable = false;
let mut slash_tmp_writable = false;
for entry in &self.entries {
match &entry.path {
FileSystemPath::Path { path } => {
if entry.access.can_write() {
if cwd_absolute.as_ref().is_some_and(|cwd| cwd == path) {
workspace_root_writable = true;
} else {
writable_roots.push(path.clone());
}
} else if entry.access.can_read() {
readable_roots.push(path.clone());
}
}
FileSystemPath::Special { value } => match value {
FileSystemSpecialPath::Root => match entry.access {
FileSystemAccessMode::None => {}
FileSystemAccessMode::Read => has_full_disk_read_access = true,
FileSystemAccessMode::Write => {
has_full_disk_read_access = true;
has_full_disk_write_access = true;
}
},
FileSystemSpecialPath::Minimal => {
if entry.access.can_read() {
include_platform_defaults = true;
}
}
FileSystemSpecialPath::CurrentWorkingDirectory => {
if entry.access.can_write() {
workspace_root_writable = true;
} else if entry.access.can_read()
&& let Some(path) = resolve_file_system_special_path(
value,
cwd_absolute.as_ref(),
)
{
readable_roots.push(path);
}
}
FileSystemSpecialPath::ProjectRoots { subpath } => {
if subpath.is_none() && entry.access.can_write() {
workspace_root_writable = true;
} else if let Some(path) =
resolve_file_system_special_path(value, cwd_absolute.as_ref())
{
if entry.access.can_write() {
writable_roots.push(path);
} else if entry.access.can_read() {
readable_roots.push(path);
}
}
}
FileSystemSpecialPath::Tmpdir => {
if entry.access.can_write() {
tmpdir_writable = true;
} else if entry.access.can_read()
&& let Some(path) = resolve_file_system_special_path(
value,
cwd_absolute.as_ref(),
)
{
readable_roots.push(path);
}
}
FileSystemSpecialPath::SlashTmp => {
if entry.access.can_write() {
slash_tmp_writable = true;
} else if entry.access.can_read()
&& let Some(path) = resolve_file_system_special_path(
value,
cwd_absolute.as_ref(),
)
{
readable_roots.push(path);
}
}
},
}
}
if has_full_disk_write_access {
return Ok(if network_policy.is_enabled() {
SandboxPolicy::DangerFullAccess
} else {
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
}
});
}
let read_only_access = if has_full_disk_read_access {
ReadOnlyAccess::FullAccess
} else {
ReadOnlyAccess::Restricted {
include_platform_defaults,
readable_roots: dedup_absolute_paths(readable_roots),
}
};
if workspace_root_writable {
SandboxPolicy::WorkspaceWrite {
writable_roots: dedup_absolute_paths(writable_roots),
read_only_access,
network_access: network_policy.is_enabled(),
exclude_tmpdir_env_var: !tmpdir_writable,
exclude_slash_tmp: !slash_tmp_writable,
}
} else if !writable_roots.is_empty() || tmpdir_writable || slash_tmp_writable {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly",
));
} else {
SandboxPolicy::ReadOnly {
access: read_only_access,
network_access: network_policy.is_enabled(),
}
}
}
})
}
}
impl From<&SandboxPolicy> for NetworkSandboxPolicy {
fn from(value: &SandboxPolicy) -> Self {
if value.has_full_network_access() {
NetworkSandboxPolicy::Enabled
} else {
NetworkSandboxPolicy::Restricted
}
}
}
impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
fn from(value: &SandboxPolicy) -> Self {
match value {
SandboxPolicy::DangerFullAccess => FileSystemSandboxPolicy::unrestricted(),
SandboxPolicy::ExternalSandbox { .. } => FileSystemSandboxPolicy::external_sandbox(),
SandboxPolicy::ReadOnly { access, .. } => {
let mut entries = Vec::new();
match access {
ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}),
ReadOnlyAccess::Restricted {
include_platform_defaults,
readable_roots,
} => {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Read,
});
if *include_platform_defaults {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
});
}
entries.extend(readable_roots.iter().cloned().map(|path| {
FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
}
}));
}
}
FileSystemSandboxPolicy::restricted(entries)
}
SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
..
} => {
let mut entries = Vec::new();
match read_only_access {
ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}),
ReadOnlyAccess::Restricted {
include_platform_defaults,
readable_roots,
} => {
if *include_platform_defaults {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
});
}
entries.extend(readable_roots.iter().cloned().map(|path| {
FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
}
}));
}
}
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Write,
});
if !exclude_slash_tmp {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::SlashTmp,
},
access: FileSystemAccessMode::Write,
});
}
if !exclude_tmpdir_env_var {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
access: FileSystemAccessMode::Write,
});
}
entries.extend(
writable_roots
.iter()
.cloned()
.map(|path| FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Write,
}),
);
FileSystemSandboxPolicy::restricted(entries)
}
}
}
}
fn resolve_file_system_path(
path: &FileSystemPath,
cwd: Option<&AbsolutePathBuf>,
) -> Option<AbsolutePathBuf> {
match path {
FileSystemPath::Path { path } => Some(path.clone()),
FileSystemPath::Special { value } => resolve_file_system_special_path(value, cwd),
}
}
fn resolve_file_system_special_path(
value: &FileSystemSpecialPath,
cwd: Option<&AbsolutePathBuf>,
) -> Option<AbsolutePathBuf> {
match value {
FileSystemSpecialPath::Root | FileSystemSpecialPath::Minimal => None,
FileSystemSpecialPath::CurrentWorkingDirectory => {
let cwd = cwd?;
Some(cwd.clone())
}
FileSystemSpecialPath::ProjectRoots { subpath } => {
let cwd = cwd?;
match subpath.as_ref() {
Some(subpath) => {
AbsolutePathBuf::resolve_path_against_base(subpath, cwd.as_path()).ok()
}
None => Some(cwd.clone()),
}
}
FileSystemSpecialPath::Tmpdir => {
let tmpdir = std::env::var_os("TMPDIR")?;
if tmpdir.is_empty() {
None
} else {
let tmpdir = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()?;
Some(tmpdir)
}
}
FileSystemSpecialPath::SlashTmp => {
#[allow(clippy::expect_used)]
let slash_tmp = AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute");
if !slash_tmp.as_path().is_dir() {
return None;
}
Some(slash_tmp)
}
}
}
fn dedup_absolute_paths(paths: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
let mut deduped = Vec::with_capacity(paths.len());
let mut seen = HashSet::new();
for path in paths {
if seen.insert(path.to_path_buf()) {
deduped.push(path);
}
}
deduped
}
fn default_read_only_subpaths_for_writable_root(
writable_root: &AbsolutePathBuf,
) -> Vec<AbsolutePathBuf> {
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
#[allow(clippy::expect_used)]
let top_level_git = writable_root
.join(".git")
.expect(".git is a valid relative path");
// This applies to typical repos (directory .git), worktrees/submodules
// (file .git with gitdir pointer), and bare repos when the gitdir is the
// writable root itself.
let top_level_git_is_file = top_level_git.as_path().is_file();
let top_level_git_is_dir = top_level_git.as_path().is_dir();
if top_level_git_is_dir || top_level_git_is_file {
if top_level_git_is_file
&& is_git_pointer_file(&top_level_git)
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
{
subpaths.push(gitdir);
}
subpaths.push(top_level_git);
}
// Make .agents/skills and .codex/config.toml and related files read-only
// to the agent, by default.
for subdir in &[".agents", ".codex"] {
#[allow(clippy::expect_used)]
let top_level_codex = writable_root.join(subdir).expect("valid relative path");
if top_level_codex.as_path().is_dir() {
subpaths.push(top_level_codex);
}
}
dedup_absolute_paths(subpaths)
}
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
}
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
let contents = match std::fs::read_to_string(dot_git.as_path()) {
Ok(contents) => contents,
Err(err) => {
error!(
"Failed to read {path} for gitdir pointer: {err}",
path = dot_git.as_path().display()
);
return None;
}
};
let trimmed = contents.trim();
let (_, gitdir_raw) = match trimmed.split_once(':') {
Some(parts) => parts,
None => {
error!(
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
path = dot_git.as_path().display()
);
return None;
}
};
let gitdir_raw = gitdir_raw.trim();
if gitdir_raw.is_empty() {
error!(
"Expected {path} to contain a gitdir pointer, but it was empty.",
path = dot_git.as_path().display()
);
return None;
}
let base = match dot_git.as_path().parent() {
Some(base) => base,
None => {
error!(
"Unable to resolve parent directory for {path}.",
path = dot_git.as_path().display()
);
return None;
}
};
let gitdir_path = match AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base) {
Ok(path) => path,
Err(err) => {
error!(
"Failed to resolve gitdir path {gitdir_raw} from {path}: {err}",
path = dot_git.as_path().display()
);
return None;
}
};
if !gitdir_path.as_path().exists() {
error!(
"Resolved gitdir path {path} does not exist.",
path = gitdir_path.as_path().display()
);
return None;
}
Some(gitdir_path)
}

View File

@@ -61,6 +61,13 @@ pub use crate::approvals::NetworkApprovalContext;
pub use crate::approvals::NetworkApprovalProtocol;
pub use crate::approvals::NetworkPolicyAmendment;
pub use crate::approvals::NetworkPolicyRuleAction;
pub use crate::permissions::FileSystemAccessMode;
pub use crate::permissions::FileSystemPath;
pub use crate::permissions::FileSystemSandboxEntry;
pub use crate::permissions::FileSystemSandboxKind;
pub use crate::permissions::FileSystemSandboxPolicy;
pub use crate::permissions::FileSystemSpecialPath;
pub use crate::permissions::NetworkSandboxPolicy;
pub use crate::request_user_input::RequestUserInputEvent;
/// Open/close tags for special user-input blocks. Used across crates to avoid
@@ -542,7 +549,6 @@ impl NetworkAccess {
matches!(self, NetworkAccess::Enabled)
}
}
fn default_include_platform_defaults() -> bool {
true
}
@@ -721,6 +727,22 @@ impl FromStr for SandboxPolicy {
}
}
impl FromStr for FileSystemSandboxPolicy {
type Err = serde_json::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_str(s)
}
}
impl FromStr for NetworkSandboxPolicy {
type Err = serde_json::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_str(s)
}
}
impl SandboxPolicy {
/// Returns a policy with read-only disk access and no network.
pub fn new_read_only_policy() -> Self {
@@ -883,45 +905,11 @@ impl SandboxPolicy {
// For each root, compute subpaths that should remain read-only.
roots
.into_iter()
.map(|writable_root| {
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
#[allow(clippy::expect_used)]
let top_level_git = writable_root
.join(".git")
.expect(".git is a valid relative path");
// This applies to typical repos (directory .git), worktrees/submodules
// (file .git with gitdir pointer), and bare repos when the gitdir is the
// writable root itself.
let top_level_git_is_file = top_level_git.as_path().is_file();
let top_level_git_is_dir = top_level_git.as_path().is_dir();
if top_level_git_is_dir || top_level_git_is_file {
if top_level_git_is_file
&& is_git_pointer_file(&top_level_git)
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
&& !subpaths
.iter()
.any(|subpath| subpath.as_path() == gitdir.as_path())
{
subpaths.push(gitdir);
}
subpaths.push(top_level_git);
}
// Make .agents/skills and .codex/config.toml and
// related files read-only to the agent, by default.
for subdir in &[".agents", ".codex"] {
#[allow(clippy::expect_used)]
let top_level_codex =
writable_root.join(subdir).expect("valid relative path");
if top_level_codex.as_path().is_dir() {
subpaths.push(top_level_codex);
}
}
WritableRoot {
root: writable_root,
read_only_subpaths: subpaths,
}
.map(|writable_root| WritableRoot {
read_only_subpaths: default_read_only_subpaths_for_writable_root(
&writable_root,
),
root: writable_root,
})
.collect()
}
@@ -929,6 +917,49 @@ impl SandboxPolicy {
}
}
fn default_read_only_subpaths_for_writable_root(
writable_root: &AbsolutePathBuf,
) -> Vec<AbsolutePathBuf> {
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
#[allow(clippy::expect_used)]
let top_level_git = writable_root
.join(".git")
.expect(".git is a valid relative path");
// This applies to typical repos (directory .git), worktrees/submodules
// (file .git with gitdir pointer), and bare repos when the gitdir is the
// writable root itself.
let top_level_git_is_file = top_level_git.as_path().is_file();
let top_level_git_is_dir = top_level_git.as_path().is_dir();
if top_level_git_is_dir || top_level_git_is_file {
if top_level_git_is_file
&& is_git_pointer_file(&top_level_git)
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
{
subpaths.push(gitdir);
}
subpaths.push(top_level_git);
}
// Make .agents/skills and .codex/config.toml and related files read-only
// to the agent, by default.
for subdir in &[".agents", ".codex"] {
#[allow(clippy::expect_used)]
let top_level_codex = writable_root.join(subdir).expect("valid relative path");
if top_level_codex.as_path().is_dir() {
subpaths.push(top_level_codex);
}
}
let mut deduped = Vec::with_capacity(subpaths.len());
let mut seen = HashSet::new();
for path in subpaths {
if seen.insert(path.to_path_buf()) {
deduped.push(path);
}
}
deduped
}
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
}
@@ -3152,10 +3183,17 @@ mod tests {
use crate::items::ImageGenerationItem;
use crate::items::UserMessageItem;
use crate::items::WebSearchItem;
use crate::permissions::FileSystemAccessMode;
use crate::permissions::FileSystemPath;
use crate::permissions::FileSystemSandboxEntry;
use crate::permissions::FileSystemSandboxPolicy;
use crate::permissions::FileSystemSpecialPath;
use crate::permissions::NetworkSandboxPolicy;
use anyhow::Result;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::NamedTempFile;
use tempfile::TempDir;
#[test]
fn external_sandbox_reports_full_access_flags() {
@@ -3236,6 +3274,127 @@ mod tests {
}
}
#[test]
fn restricted_file_system_policy_reports_full_access_from_root_entries() {
let read_only = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}]);
assert!(read_only.has_full_disk_read_access());
assert!(!read_only.has_full_disk_write_access());
assert!(!read_only.include_platform_defaults());
let writable = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}]);
assert!(writable.has_full_disk_read_access());
assert!(writable.has_full_disk_write_access());
}
#[test]
fn restricted_file_system_policy_derives_effective_paths() {
let cwd = TempDir::new().expect("tempdir");
std::fs::create_dir_all(cwd.path().join(".agents")).expect("create .agents");
std::fs::create_dir_all(cwd.path().join(".codex")).expect("create .codex");
let cwd_absolute =
AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute tempdir");
let secret = AbsolutePathBuf::resolve_path_against_base("secret", cwd.path())
.expect("resolve unreadable path");
let agents = AbsolutePathBuf::resolve_path_against_base(".agents", cwd.path())
.expect("resolve .agents");
let codex = AbsolutePathBuf::resolve_path_against_base(".codex", cwd.path())
.expect("resolve .codex");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: secret.clone(),
},
access: FileSystemAccessMode::None,
},
]);
assert!(!policy.has_full_disk_read_access());
assert!(!policy.has_full_disk_write_access());
assert!(policy.include_platform_defaults());
assert_eq!(
policy.get_readable_roots_with_cwd(cwd.path()),
vec![cwd_absolute]
);
assert_eq!(
policy.get_unreadable_roots_with_cwd(cwd.path()),
vec![secret.clone()]
);
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
assert_eq!(writable_roots.len(), 1);
assert_eq!(writable_roots[0].root.as_path(), cwd.path());
assert!(
writable_roots[0]
.read_only_subpaths
.iter()
.any(|path| path.as_path() == secret.as_path())
);
assert!(
writable_roots[0]
.read_only_subpaths
.iter()
.any(|path| path.as_path() == agents.as_path())
);
assert!(
writable_roots[0]
.read_only_subpaths
.iter()
.any(|path| path.as_path() == codex.as_path())
);
}
#[test]
fn file_system_policy_rejects_legacy_bridge_for_non_workspace_writes() {
let cwd = if cfg!(windows) {
Path::new(r"C:\workspace")
} else {
Path::new("/tmp/workspace")
};
let external_write_path = if cfg!(windows) {
AbsolutePathBuf::from_absolute_path(r"C:\temp").expect("absolute windows temp path")
} else {
AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute tmp path")
};
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: external_write_path,
},
access: FileSystemAccessMode::Write,
}]);
let err = policy
.to_legacy_sandbox_policy(NetworkSandboxPolicy::Restricted, cwd)
.expect_err("non-workspace writes should be rejected");
assert!(
err.to_string()
.contains("filesystem writes outside the workspace root"),
"{err}"
);
}
#[test]
fn item_started_event_from_web_search_emits_begin_event() {
let event = ItemStartedEvent {

View File

@@ -1,193 +0,0 @@
# Auth Login Logging
## Problem
Customer-side auth failures are hard to diagnose because the most important browser-login step,
the final `POST https://auth.openai.com/oauth/token` after the localhost callback, historically
does not show up as a first-class application event.
In the failing HARs and Slack thread, browser auth succeeds, workspace selection succeeds, and the
browser reaches `http://localhost:1455/auth/callback`. Support can usually confirm that:
- the user reached the browser sign-in flow
- the browser returned to the localhost callback
- Codex showed a generic sign-in failure
What support cannot reliably determine from Codex-owned logs is why the final token exchange
failed. That leaves the most important diagnostic question unanswered:
- was this a backend non-2xx response
- a transport failure talking to `auth.openai.com`
- a proxy, TLS, DNS, or connectivity issue
- some other local client-side failure after browser auth completed
This documentation explains how the current instrumentation closes that gap without broadening the
normal logging surface in unsafe ways.
## Mental Model
The browser-login flow has three separate outputs, and they do not serve the same audience:
- the browser-facing error page
- the caller-visible returned `io::Error`
- the normal structured application log
Those outputs now intentionally diverge.
The browser-facing page and returned error still preserve the backend detail needed by developers,
sysadmins, and support engineers to understand what happened. The structured log stays narrower:
it emits explicitly reviewed fields, redacted URLs, and redacted transport errors so the normal
log file is useful without becoming a credential sink.
## Non-goals
This does not add auth logging to every runtime request.
- The instrumentation is scoped to the initial browser-login callback flow.
- The refresh-token path in `codex-core` remains a separate concern.
- This does not attempt to classify every transport failure into a specific root cause from string
matching.
## Tradeoffs
This implementation prefers fidelity for caller-visible errors and restraint for structured logs.
- Non-2xx token endpoint responses log parsed safe fields such as status, `error`, and
`error_description` when available.
- Non-JSON token endpoint bodies are preserved in the returned error so CLI/browser flows still
surface the backend detail that operators need.
- The callback-layer structured log does not log `%err` for token endpoint failures, because that
would persist arbitrary backend response text into the normal log file.
- Transport failures keep the underlying `reqwest` error text, but attached URLs are redacted
before they are logged or returned.
- Caller-supplied issuer URLs are sanitized before they are logged, including custom issuers with
embedded credentials or sensitive query params.
The result is not maximally detailed in one place. It is intentionally split so each surface gets
the level of detail it can safely carry.
## Architecture
The browser-login callback flow lives in
[`codex-rs/login/src/server.rs`](../codex-rs/login/src/server.rs).
The key behavior is:
- the callback handler logs whether the callback was received and whether state validation passed
- the token exchange logs start, success, and non-2xx responses as structured events
- transport failures log the redacted `reqwest` error plus `is_timeout`, `is_connect`, and
`is_request`
- the browser-facing `Codex Sign-in Error` page remains intact
- the returned `io::Error` continues to carry useful backend detail for CLI/browser callers
App-server consumers use the same login-server path rather than a separate auth implementation.
- `account/login/start` calls into `run_login_server(...)`
- app-server waits for `server.block_until_done()`
- app-server emits `account/login/completed` with wrapped success/error state
That means the login-crate instrumentation benefits:
- direct CLI / TUI login
- Electron app login
- VS Code extension login
Direct `codex login` also writes a small file-backed log through the CLI crate.
- the file is `codex-login.log` under the configured `log_dir`
- this uses a deliberately small tracing setup local to the CLI login commands
- it does not try to reuse the TUI logging stack wholesale, because the TUI path also installs
feedback, OpenTelemetry, and other interactive-session layers that are not needed for a
one-shot login command
- the duplication is intentional: it keeps the direct CLI behavior easy to reason about while
still giving support a durable artifact from the same `codex_login::server` events
## Observability
The main new signals are emitted from the `login` crate target, for example
`codex_login::server`, so they stay aligned with the code that produces them.
The useful events are:
- callback received
- callback state mismatch
- OAuth callback returned error
- OAuth token exchange started
- OAuth token exchange transport failure
- OAuth token exchange returned non-success status
- OAuth token exchange succeeded
The structured log intentionally uses a narrower payload than the returned error:
- issuer URLs are sanitized before logging
- sensitive URL query keys such as `code`, `state`, `token`, `access_token`, `refresh_token`,
`id_token`, `client_secret`, and `code_verifier` are redacted
- embedded credentials and fragments are stripped from logged URLs
- parsed token-endpoint fields are logged individually when available
- arbitrary non-JSON token endpoint bodies are not logged into the normal application log
This split is the main privacy boundary in the implementation.
## Failure Modes
The current instrumentation is most useful for these cases:
- browser auth succeeds but the final token exchange fails
- custom issuer deployments need confirmation that the callback reached the login server
- operators need to distinguish backend non-2xx responses from transport failures
- transport failures need the underlying `reqwest` signal without leaking sensitive URL parts
It is intentionally weaker for one class of diagnosis:
- it does not try to infer specific transport causes such as proxy, TLS, or DNS from message
string matching, because that kind of over-classification can mislead operators
## Security and Sensitivity Notes
This implementation treats the normal application log as a persistent surface that must be safe to
collect and share.
That means:
- user-supplied issuer URLs are sanitized before logging
- transport errors redact attached URLs instead of dropping them entirely
- known secret-bearing query params are redacted surgically rather than removing all URL context
- non-JSON token endpoint bodies are preserved only for the returned error path, not the
structured-log path
This behavior reflects two review-driven constraints that are already fixed in the code:
- custom issuers no longer leak embedded credentials or sensitive query params in the
`starting oauth token exchange` log line
- non-JSON token endpoint bodies are once again preserved for caller-visible errors, but they no
longer get duplicated into normal structured logs through callback-layer `%err` logging
## Debug Path
For a failed sign-in, read the evidence in this order:
1. Browser/HAR evidence:
confirm the browser reached `http://localhost:1455/auth/callback`.
2. Login-crate structured logs:
check whether the callback was received, whether state validation passed, and whether the token
exchange failed as transport or non-2xx.
3. Caller-visible error:
use the CLI/browser/app error text to recover backend detail that is intentionally not copied
into the normal log file.
4. App-server wrapper:
if the flow runs through app-server, use `account/login/completed` and its wrapped
`Login server error: ...` result as the client-facing envelope around the same login-crate
behavior.
The most important invariant is simple: browser success does not imply login success. The native
client still has to exchange the auth code successfully after the callback arrives.
## Tests
The `codex-login` test suite covers the new redaction and parsing boundaries:
- parsed token endpoint JSON fields still surface correctly
- plain-text token endpoint bodies still remain available to the caller-visible error path
- sensitive query values are redacted selectively
- URL shape is preserved while credentials, fragments, and known secret-bearing params are removed
- issuer sanitization redacts custom issuer credentials and sensitive params before logging