Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions codex-rs/config/src/permissions_toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ pub struct PermissionProfileToml {

#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
pub struct FilesystemPermissionsToml {
/// Optional maximum depth for expanding unreadable glob patterns on
/// platforms that snapshot glob matches before sandbox startup.
#[schemars(range(min = 1))]
pub glob_scan_max_depth: Option<usize>,
#[serde(flatten)]
pub entries: BTreeMap<String, FilesystemPermissionToml>,
}
Expand Down
8 changes: 8 additions & 0 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,14 @@
]
},
"FilesystemPermissionsToml": {
"properties": {
"glob_scan_max_depth": {
"description": "Optional maximum depth for expanding unreadable glob patterns on platforms that snapshot glob matches before sandbox startup.",
"format": "uint",
"minimum": 1.0,
"type": "integer"
}
},
"type": "object"
},
"ForcedLoginMethod": {
Expand Down
20 changes: 20 additions & 0 deletions codex-rs/core/src/config/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ allow_upstream_proxy = false
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([
(
":minimal".to_string(),
Expand Down Expand Up @@ -482,6 +483,7 @@ async fn permissions_profiles_network_populates_runtime_network_proxy_spec() ->
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
Expand Down Expand Up @@ -531,6 +533,7 @@ async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy()
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
Expand Down Expand Up @@ -576,6 +579,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([
(
":minimal".to_string(),
Expand Down Expand Up @@ -671,6 +675,7 @@ async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::i
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: Some(2),
entries: BTreeMap::from([(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
Expand All @@ -693,6 +698,13 @@ async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::i
)
.await?;

assert_eq!(
config
.permissions
.file_system_sandbox_policy
.glob_scan_max_depth,
Some(2)
);
let expected_pattern = AbsolutePathBuf::resolve_path_against_base("**/*.env", cwd.path())
.to_string_lossy()
.into_owned();
Expand Down Expand Up @@ -738,6 +750,7 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
Expand Down Expand Up @@ -781,6 +794,7 @@ async fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io:
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
external_write_path.to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
Expand Down Expand Up @@ -824,6 +838,7 @@ async fn permissions_profiles_reject_nested_entries_for_non_project_roots() -> s
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
Expand Down Expand Up @@ -883,6 +898,7 @@ async fn load_workspace_permission_profile(
async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":future_special_path".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
Expand Down Expand Up @@ -929,6 +945,7 @@ async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries()
-> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":future_special_path".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
Expand Down Expand Up @@ -996,6 +1013,7 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io
async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::new(),
}),
network: None,
Expand Down Expand Up @@ -1030,6 +1048,7 @@ async fn permissions_profiles_reject_project_root_parent_traversal() -> std::io:
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
Expand Down Expand Up @@ -1075,6 +1094,7 @@ async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()>
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
Expand Down
57 changes: 53 additions & 4 deletions codex-rs/core/src/config/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ pub(crate) fn compile_permission_profile(
),
);
}
for pattern in unbounded_unreadable_globstar_paths(filesystem) {
push_warning(
startup_warnings,
format!(
"Filesystem deny-read glob `{pattern}` uses `**`. Non-macOS sandboxing does not support unbounded `**` natively; set `glob_scan_max_depth` in this filesystem profile to cap Linux glob expansion and silence this warning, or enumerate explicit depths such as `*.env`, `*/*.env`, and `*/*/*.env`."
),
);
}
}
for (path, permission) in &filesystem.entries {
entries.extend(compile_filesystem_permission(
Expand All @@ -82,12 +90,17 @@ pub(crate) fn compile_permission_profile(
missing_filesystem_entries_warning(profile_name),
);
}
let glob_scan_max_depth = validate_glob_scan_max_depth(
profile
.filesystem
.as_ref()
.and_then(|filesystem| filesystem.glob_scan_max_depth),
)?;

let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref());
Ok((
FileSystemSandboxPolicy::restricted(entries),
network_sandbox_policy,
))
let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries);
file_system_sandbox_policy.glob_scan_max_depth = glob_scan_max_depth;
Ok((file_system_sandbox_policy, network_sandbox_policy))
}

/// Returns a list of paths that must be readable by shell tools in order
Expand Down Expand Up @@ -333,6 +346,42 @@ fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) ->
patterns
}

fn unbounded_unreadable_globstar_paths(filesystem: &FilesystemPermissionsToml) -> Vec<String> {
if filesystem.glob_scan_max_depth.is_some() {
return Vec::new();
}

let mut patterns = Vec::new();
for (path, permission) in &filesystem.entries {
match permission {
FilesystemPermissionToml::Access(FileSystemAccessMode::None) => {
if path.contains("**") {
patterns.push(path.clone());
}
}
FilesystemPermissionToml::Access(_) => {}
FilesystemPermissionToml::Scoped(scoped_entries) => {
for (subpath, access) in scoped_entries {
if *access == FileSystemAccessMode::None && subpath.contains("**") {
patterns.push(format!("{path}/{subpath}"));
}
}
}
}
}
patterns
}

fn validate_glob_scan_max_depth(max_depth: Option<usize>) -> io::Result<Option<usize>> {
match max_depth {
Some(0) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"glob_scan_max_depth must be at least 1",
)),
_ => Ok(max_depth),
}
}

fn contains_glob_chars(path: &str) -> bool {
path.chars().any(|ch| matches!(ch, '*' | '?' | '[' | ']'))
}
Expand Down
44 changes: 44 additions & 0 deletions codex-rs/core/src/config/permissions_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::new(),
}),
network: None,
Expand Down Expand Up @@ -226,6 +227,7 @@ fn network_toml_overlays_unix_socket_permissions_by_path() {
#[test]
fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths() {
let filesystem = FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([
(
"/tmp/**/*.log".to_string(),
Expand Down Expand Up @@ -256,6 +258,47 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths
);
}

#[test]
fn unreadable_globstar_warning_is_suppressed_when_scan_depth_is_configured() {
let filesystem = FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
("**/*.env".to_string(), FileSystemAccessMode::None),
("*.pem".to_string(), FileSystemAccessMode::None),
])),
)]),
};

assert_eq!(
unbounded_unreadable_globstar_paths(&filesystem),
vec![":project_roots/**/*.env".to_string()]
);

let configured_filesystem = FilesystemPermissionsToml {
glob_scan_max_depth: Some(2),
..filesystem
};
assert_eq!(
unbounded_unreadable_globstar_paths(&configured_filesystem),
Vec::<String>::new()
);
}

#[test]
fn glob_scan_max_depth_must_be_positive() {
let err = validate_glob_scan_max_depth(Some(0))
.expect_err("zero depth would silently skip deny-read glob expansion");

assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(err.to_string(), "glob_scan_max_depth must be at least 1");
assert_eq!(
validate_glob_scan_max_depth(Some(2)).expect("depth should be valid"),
Some(2)
);
}

#[test]
fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> {
let cwd = TempDir::new()?;
Expand All @@ -266,6 +309,7 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()>
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
Expand Down
34 changes: 29 additions & 5 deletions codex-rs/core/src/tools/handlers/unified_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use crate::unified_exec::ExecCommandRequest;
use crate::unified_exec::UnifiedExecContext;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcessManager;
use crate::unified_exec::WriteStdinRequest;
use crate::unified_exec::generate_chunk_id;
use codex_features::Feature;
use codex_otel::SessionTelemetry;
use codex_otel::TOOL_CALL_UNIFIED_EXEC_METRIC;
Expand All @@ -30,6 +32,7 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::TerminalInteractionEvent;
use codex_shell_command::is_safe_command::is_known_safe_command;
use codex_tools::UnifiedExecShellMode;
use codex_utils_output_truncation::approx_token_count;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
Expand Down Expand Up @@ -309,7 +312,8 @@ impl ToolHandler for UnifiedExecHandler {
}

emit_unified_exec_tty_metric(&turn.session_telemetry, tty);
manager
let session_command = command.clone();
match manager
.exec_command(
ExecCommandRequest {
command,
Expand All @@ -330,11 +334,31 @@ impl ToolHandler for UnifiedExecHandler {
&context,
)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
{
Ok(response) => response,
Err(UnifiedExecError::SandboxDenied { output, .. }) => {
let output_text = output.aggregated_output.text;
let original_token_count = approx_token_count(&output_text);
ExecCommandToolOutput {
event_call_id: context.call_id.clone(),
chunk_id: generate_chunk_id(),
wall_time: output.duration,
raw_output: output_text.into_bytes(),
max_output_tokens,
// Sandbox denial is terminal, so there is no live
// process for write_stdin to resume.
process_id: None,
exit_code: Some(output.exit_code),
original_token_count: Some(original_token_count),
session_command: Some(session_command),
}
}
Err(err) => {
return Err(FunctionCallError::RespondToModel(format!(
"exec_command failed for `{command_for_display}`: {err:?}"
))
})?
)));
}
}
}
"write_stdin" => {
let args: WriteStdinArgs = parse_arguments(&arguments)?;
Expand Down
Loading
Loading