Merge branch 'codex/viyatb/trusted-project-config-gating' of github.com:openai/codex into codex/viyatb/pr15936-p3

This commit is contained in:
viyatb-oai
2026-04-16 18:07:35 -07:00
57 changed files with 2063 additions and 464 deletions
+5 -4
View File
@@ -1540,7 +1540,6 @@ dependencies = [
"anyhow",
"clap",
"codex-experimental-api-macros",
"codex-git-utils",
"codex-protocol",
"codex-shell-command",
"codex-utils-absolute-path",
@@ -1866,12 +1865,11 @@ dependencies = [
"codex-app-server-protocol",
"codex-execpolicy",
"codex-features",
"codex-git-utils",
"codex-model-provider-info",
"codex-network-proxy",
"codex-protocol",
"codex-utils-absolute-path",
"dunce",
"codex-utils-path",
"futures",
"multimap",
"pretty_assertions",
@@ -2264,6 +2262,8 @@ name = "codex-git-utils"
version = "0.0.0"
dependencies = [
"assert_matches",
"codex-exec-server",
"codex-protocol",
"codex-utils-absolute-path",
"futures",
"once_cell",
@@ -2334,6 +2334,7 @@ dependencies = [
"codex-protocol",
"codex-sandboxing",
"codex-utils-absolute-path",
"globset",
"landlock",
"libc",
"pkg-config",
@@ -2620,7 +2621,6 @@ dependencies = [
"chrono",
"codex-async-utils",
"codex-execpolicy",
"codex-git-utils",
"codex-network-proxy",
"codex-utils-absolute-path",
"codex-utils-image",
@@ -2760,6 +2760,7 @@ dependencies = [
"dunce",
"libc",
"pretty_assertions",
"regex-lite",
"serde_json",
"tempfile",
"tokio",
-1
View File
@@ -15,7 +15,6 @@ workspace = true
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-experimental-api-macros = { workspace = true }
codex-git-utils = { workspace = true }
codex-protocol = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-absolute-path = { workspace = true }
+1 -1
View File
@@ -4,7 +4,6 @@ mod jsonrpc_lite;
mod protocol;
mod schema_fixtures;
pub use codex_git_utils::GitSha;
pub use experimental_api::*;
pub use export::GenerateTsOptions;
pub use export::generate_internal_json_schema;
@@ -30,6 +29,7 @@ pub use protocol::v1::GetConversationSummaryParams;
pub use protocol::v1::GetConversationSummaryResponse;
pub use protocol::v1::GitDiffToRemoteParams;
pub use protocol::v1::GitDiffToRemoteResponse;
pub use protocol::v1::GitSha;
pub use protocol::v1::InitializeCapabilities;
pub use protocol::v1::InitializeParams;
pub use protocol::v1::InitializeResponse;
@@ -1,7 +1,6 @@
use std::collections::HashMap;
use std::path::PathBuf;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningSummary;
@@ -11,6 +10,7 @@ use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::FileChange;
pub use codex_protocol::protocol::GitSha;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
@@ -2441,9 +2441,9 @@ impl CodexMessageProcessor {
| codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. }
))
{
let trust_target = resolve_root_git_project_for_trust(config.cwd.as_path())
let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd)
.await
.unwrap_or_else(|| config.cwd.to_path_buf());
.unwrap_or_else(|| config.cwd.clone());
let cli_overrides_with_trust;
let cli_overrides_for_reload = if let Err(err) =
codex_core::config::set_project_trust_level(
@@ -6155,6 +6155,7 @@ impl CodexMessageProcessor {
}
};
let config_layer_stack = match load_config_layers_state(
LOCAL_FS.as_ref(),
&self.config.codex_home,
Some(cwd_abs.clone()),
&cli_overrides,
@@ -7,7 +7,11 @@ use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use axum::Router;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server::in_process;
use codex_app_server::in_process::InProcessStartArgs;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::McpResourceContent;
use codex_app_server_protocol::McpResourceReadParams;
@@ -15,7 +19,14 @@ use codex_app_server_protocol::McpResourceReadResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_arg0::Arg0DispatchPaths;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::ConfigBuilder;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
@@ -149,24 +160,57 @@ stream_max_retries = 0
#[tokio::test]
async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_mcp_resource_read_request(McpResourceReadParams {
thread_id: "00000000-0000-4000-8000-000000000000".to_string(),
server: "codex_apps".to_string(),
uri: TEST_RESOURCE_URI.to_string(),
})
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?;
let error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
// This negative-path test does not need the stdio subprocess; keeping it
// in-process avoids child-process teardown timing in nextest leak detection.
let client = in_process::start(InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)),
config_warnings: Vec::new(),
session_source: SessionSource::Cli,
enable_codex_api_key_env: false,
initialize: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: None,
},
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await?;
let response = client
.request(ClientRequest::McpResourceRead {
request_id: RequestId::Integer(1),
params: McpResourceReadParams {
thread_id: "00000000-0000-4000-8000-000000000000".to_string(),
server: "codex_apps".to_string(),
uri: TEST_RESOURCE_URI.to_string(),
},
})
.await;
client.shutdown().await?;
let error = match response? {
Ok(result) => anyhow::bail!("expected thread-not-found error, got response: {result:?}"),
Err(error) => error,
};
assert!(
error.error.message.contains("thread not found"),
error.message.contains("thread not found"),
"expected thread-not-found error, got: {error:?}"
);
@@ -1,6 +1,7 @@
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::PathBufExt;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
@@ -20,6 +21,7 @@ use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::set_project_trust_level;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::config_types::ServiceTier;
@@ -716,10 +718,11 @@ model_reasoning_effort = "high"
assert_eq!(reasoning_effort, Some(ReasoningEffort::High));
let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
let trusted_root = resolve_root_git_project_for_trust(workspace.path())
let workspace_abs = workspace.path().to_path_buf().abs();
let trusted_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &workspace_abs)
.await
.unwrap_or_else(|| workspace.path().to_path_buf());
assert!(config_toml.contains(&persisted_trust_path(&trusted_root)));
.unwrap_or(workspace_abs);
assert!(config_toml.contains(&persisted_trust_path(trusted_root.as_path())));
assert!(config_toml.contains("trust_level = \"trusted\""));
Ok(())
@@ -754,10 +757,11 @@ async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> {
.await??;
let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
let trusted_root = resolve_root_git_project_for_trust(&nested)
let nested_abs = nested.abs();
let trusted_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested_abs)
.await
.expect("git root should resolve");
assert!(config_toml.contains(&persisted_trust_path(&trusted_root)));
assert!(config_toml.contains(&persisted_trust_path(trusted_root.as_path())));
assert!(!config_toml.contains(&persisted_trust_path(&nested)));
Ok(())
+59 -9
View File
@@ -111,8 +111,8 @@ enum Subcommand {
/// Manage external MCP servers for Codex.
Mcp(McpCli),
/// Manage plugin marketplaces for Codex.
Marketplace(MarketplaceCli),
/// Manage Codex plugins.
Plugin(PluginCli),
/// Start Codex as an MCP server (stdio).
McpServer,
@@ -170,6 +170,21 @@ enum Subcommand {
Features(FeaturesCli),
}
#[derive(Debug, Parser)]
struct PluginCli {
#[clap(flatten)]
pub config_overrides: CliConfigOverrides,
#[command(subcommand)]
subcommand: PluginSubcommand,
}
#[derive(Debug, clap::Subcommand)]
enum PluginSubcommand {
/// Manage plugin marketplaces for Codex.
Marketplace(MarketplaceCli),
}
#[derive(Debug, Parser)]
struct CompletionCommand {
/// Shell to generate completions for
@@ -726,17 +741,23 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone());
mcp_cli.run().await?;
}
Some(Subcommand::Marketplace(mut marketplace_cli)) => {
Some(Subcommand::Plugin(plugin_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"marketplace",
"plugin",
)?;
prepend_config_flags(
&mut marketplace_cli.config_overrides,
root_config_overrides.clone(),
);
marketplace_cli.run().await?;
let PluginCli {
mut config_overrides,
subcommand,
} = plugin_cli;
prepend_config_flags(&mut config_overrides, root_config_overrides.clone());
match subcommand {
PluginSubcommand::Marketplace(mut marketplace_cli) => {
prepend_config_flags(&mut marketplace_cli.config_overrides, config_overrides);
marketplace_cli.run().await?;
}
}
}
Some(Subcommand::AppServer(app_server_cli)) => {
let AppServerCommand {
@@ -1690,6 +1711,35 @@ mod tests {
assert!(matches!(cli.subcommand, Some(Subcommand::Responses(_))));
}
#[test]
fn plugin_marketplace_add_parses_under_plugin() {
let cli =
MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "add", "owner/repo"])
.expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn plugin_marketplace_upgrade_parses_under_plugin() {
let cli =
MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "upgrade", "debug"])
.expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn marketplace_no_longer_parses_at_top_level() {
let add_result =
MultitoolCli::try_parse_from(["codex", "marketplace", "add", "owner/repo"]);
assert!(add_result.is_err());
let upgrade_result =
MultitoolCli::try_parse_from(["codex", "marketplace", "upgrade", "debug"]);
assert!(upgrade_result.is_err());
}
fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
let token_usage = TokenUsage {
output_tokens: 2,
+31 -2
View File
@@ -48,7 +48,7 @@ async fn marketplace_add_local_directory_source() -> Result<()> {
codex_command(codex_home.path())?
.current_dir(source_parent)
.args(["marketplace", "add", source_arg.as_str()])
.args(["plugin", "marketplace", "add", source_arg.as_str()])
.assert()
.success();
@@ -78,7 +78,12 @@ async fn marketplace_add_rejects_local_manifest_file_source() -> Result<()> {
let manifest_path = source.path().join(".agents/plugins/marketplace.json");
codex_command(codex_home.path())?
.args(["marketplace", "add", manifest_path.to_str().unwrap()])
.args([
"plugin",
"marketplace",
"add",
manifest_path.to_str().unwrap(),
])
.assert()
.failure()
.stderr(contains(
@@ -87,3 +92,27 @@ async fn marketplace_add_rejects_local_manifest_file_source() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn marketplace_add_rejects_sparse_for_local_directory_source() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_marketplace_source(source.path(), "local ref")?;
codex_command(codex_home.path())?
.args([
"plugin",
"marketplace",
"add",
"--sparse",
".agents",
source.path().to_str().unwrap(),
])
.assert()
.failure()
.stderr(contains(
"--sparse is only supported for git marketplace sources",
));
Ok(())
}
+36
View File
@@ -0,0 +1,36 @@
use anyhow::Result;
use predicates::str::contains;
use std::path::Path;
use tempfile::TempDir;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
#[tokio::test]
async fn marketplace_upgrade_runs_under_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "upgrade"])
.assert()
.success()
.stdout(contains("No configured Git marketplaces to upgrade."));
Ok(())
}
#[tokio::test]
async fn marketplace_upgrade_no_longer_runs_at_top_level() -> Result<()> {
let codex_home = TempDir::new()?;
codex_command(codex_home.path())?
.args(["marketplace", "upgrade"])
.assert()
.failure()
.stderr(contains("unexpected argument 'upgrade' found"));
Ok(())
}
+1 -2
View File
@@ -12,12 +12,11 @@ anyhow = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-execpolicy = { workspace = true }
codex-features = { workspace = true }
codex-git-utils = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-network-proxy = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
dunce = { workspace = true }
codex-utils-path = { workspace = true }
futures = { workspace = true, features = ["alloc", "std"] }
multimap = { workspace = true }
schemars = { workspace = true }
+13 -11
View File
@@ -29,7 +29,6 @@ use crate::types::WindowsToml;
use codex_app_server_protocol::Tools;
use codex_app_server_protocol::UserSavedConfig;
use codex_features::FeaturesToml;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
use codex_model_provider_info::ModelProviderInfo;
@@ -51,6 +50,7 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path::normalize_for_path_comparison;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
@@ -601,7 +601,7 @@ impl ConfigToml {
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
windows_sandbox_level: WindowsSandboxLevel,
resolved_cwd: &Path,
active_project: Option<&ProjectConfig>,
sandbox_policy_constraint: Option<&crate::Constrained<SandboxPolicy>>,
) -> SandboxPolicy {
let sandbox_mode_was_explicit = sandbox_mode_override.is_some()
@@ -616,7 +616,7 @@ impl ConfigToml {
// If no sandbox_mode is set but this directory has a trust decision,
// default to workspace-write except on unsandboxed Windows where we
// default to read-only.
self.get_active_project(resolved_cwd).await.and_then(|p| {
active_project.and_then(|p| {
if p.is_trusted() || p.is_untrusted() {
if cfg!(target_os = "windows")
&& windows_sandbox_level == WindowsSandboxLevel::Disabled
@@ -677,8 +677,13 @@ impl ConfigToml {
}
/// Resolves the cwd to an existing project, or returns None if ConfigToml
/// does not contain a project corresponding to cwd or a git repo for cwd
pub async fn get_active_project(&self, resolved_cwd: &Path) -> Option<ProjectConfig> {
/// does not contain a project corresponding to cwd or the resolved git repo
/// root for cwd.
pub fn get_active_project(
&self,
resolved_cwd: &Path,
repo_root: Option<&Path>,
) -> Option<ProjectConfig> {
let projects = self.projects.as_ref()?;
for normalized_cwd in normalized_project_lookup_keys(resolved_cwd) {
@@ -687,11 +692,8 @@ impl ConfigToml {
}
}
// If cwd lives inside a git repo/worktree, check whether the root git project
// (the primary repository working directory) is trusted. This lets
// worktrees inherit trust from the main project.
if let Some(repo_root) = resolve_root_git_project_for_trust(resolved_cwd).await {
for normalized_repo_root in normalized_project_lookup_keys(&repo_root) {
if let Some(repo_root) = repo_root {
for normalized_repo_root in normalized_project_lookup_keys(repo_root) {
if let Some(project_config_for_root) =
project_config_for_lookup_key(projects, &normalized_repo_root)
{
@@ -731,7 +733,7 @@ impl ConfigToml {
fn normalized_project_lookup_keys(path: &Path) -> Vec<String> {
let normalized_path = normalize_project_lookup_key(path.to_string_lossy().to_string());
let normalized_canonical_path = normalize_project_lookup_key(
dunce::canonicalize(path)
normalize_for_path_comparison(path)
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.to_string(),
+4
View File
@@ -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>,
}
+8
View File
@@ -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": {
+2
View File
@@ -18,6 +18,7 @@ use crate::config_loader::resolve_relative_paths_in_config_toml;
use anyhow::anyhow;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::config_toml::ConfigToml;
use codex_exec_server::LOCAL_FS;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::Path;
@@ -168,6 +169,7 @@ mod reload {
}
let mut next_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
merged_config,
reload_overrides(config, preserve_current_provider),
config.codex_home.clone(),
+2
View File
@@ -22,6 +22,7 @@ use crate::realtime_context::REALTIME_TURN_TOKEN_BUDGET;
use crate::realtime_context::truncate_realtime_text_to_token_budget;
use crate::realtime_conversation::REALTIME_USER_TEXT_PREFIX;
use crate::realtime_conversation::prefix_realtime_v2_text;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -526,6 +527,7 @@ pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec<PathBuf>, for
}
};
let config_layer_stack = match load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd_abs.clone()),
empty_cli_overrides,
+94 -72
View File
@@ -4,6 +4,7 @@ use crate::config_loader::ConfigLayerStackOrdering;
use codex_config::config_toml::AgentRoleToml;
use codex_config::config_toml::AgentsToml;
use codex_config::config_toml::ConfigToml;
use codex_exec_server::ExecutorFileSystem;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use serde::Deserialize;
@@ -14,7 +15,8 @@ use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
pub(crate) fn load_agent_roles(
pub(crate) async fn load_agent_roles(
fs: &dyn ExecutorFileSystem,
cfg: &ConfigToml,
config_layer_stack: &ConfigLayerStack,
startup_warnings: &mut Vec<String>,
@@ -24,7 +26,7 @@ pub(crate) fn load_agent_roles(
/*include_disabled*/ false,
);
if layers.is_empty() {
return load_agent_roles_without_layers(cfg);
return load_agent_roles_without_layers(fs, cfg).await;
}
let mut roles: BTreeMap<String, AgentRoleConfig> = BTreeMap::new();
@@ -40,13 +42,14 @@ pub(crate) fn load_agent_roles(
};
if let Some(agents_toml) = agents_toml {
for (declared_role_name, role_toml) in &agents_toml.roles {
let (role_name, role) = match read_declared_role(declared_role_name, role_toml) {
Ok(role) => role,
Err(err) => {
push_agent_role_warning(startup_warnings, err);
continue;
}
};
let (role_name, role) =
match read_declared_role(fs, declared_role_name, role_toml).await {
Ok(role) => role,
Err(err) => {
push_agent_role_warning(startup_warnings, err);
continue;
}
};
if let Some(config_file) = role.config_file.clone() {
declared_role_files.insert(config_file);
}
@@ -68,10 +71,13 @@ pub(crate) fn load_agent_roles(
if let Some(config_folder) = layer.config_folder() {
for (role_name, role) in discover_agent_roles_in_dir(
config_folder.as_path().join("agents").as_path(),
fs,
&config_folder.join("agents"),
&declared_role_files,
startup_warnings,
)? {
)
.await?
{
if layer_roles.contains_key(&role_name) {
push_agent_role_warning(
startup_warnings,
@@ -113,13 +119,14 @@ fn push_agent_role_warning(startup_warnings: &mut Vec<String>, err: std::io::Err
startup_warnings.push(message);
}
fn load_agent_roles_without_layers(
async fn load_agent_roles_without_layers(
fs: &dyn ExecutorFileSystem,
cfg: &ConfigToml,
) -> std::io::Result<BTreeMap<String, AgentRoleConfig>> {
let mut roles = BTreeMap::new();
if let Some(agents_toml) = cfg.agents.as_ref() {
for (declared_role_name, role_toml) in &agents_toml.roles {
let (role_name, role) = read_declared_role(declared_role_name, role_toml)?;
let (role_name, role) = read_declared_role(fs, declared_role_name, role_toml).await?;
validate_required_agent_role_description(&role_name, role.description.as_deref())?;
if roles.insert(role_name.clone(), role).is_some() {
@@ -134,14 +141,17 @@ fn load_agent_roles_without_layers(
Ok(roles)
}
fn read_declared_role(
async fn read_declared_role(
fs: &dyn ExecutorFileSystem,
declared_role_name: &str,
role_toml: &AgentRoleToml,
) -> std::io::Result<(String, AgentRoleConfig)> {
let mut role = agent_role_config_from_toml(declared_role_name, role_toml)?;
let mut role = agent_role_config_from_toml(fs, declared_role_name, role_toml).await?;
let mut role_name = declared_role_name.to_string();
if let Some(config_file) = role.config_file.as_deref() {
let parsed_file = read_resolved_agent_role_file(config_file, Some(declared_role_name))?;
let config_file = AbsolutePathBuf::from_absolute_path(config_file)?;
let parsed_file =
read_resolved_agent_role_file(fs, &config_file, Some(declared_role_name)).await?;
role_name = parsed_file.role_name;
role.description = parsed_file.description.or(role.description);
role.nickname_candidates = parsed_file.nickname_candidates.or(role.nickname_candidates);
@@ -171,12 +181,17 @@ fn agents_toml_from_layer(layer_toml: &TomlValue) -> std::io::Result<Option<Agen
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
}
fn agent_role_config_from_toml(
async fn agent_role_config_from_toml(
fs: &dyn ExecutorFileSystem,
role_name: &str,
role: &AgentRoleToml,
) -> std::io::Result<AgentRoleConfig> {
let config_file = role.config_file.as_ref().map(AbsolutePathBuf::to_path_buf);
validate_agent_role_config_file(role_name, config_file.as_deref())?;
let config_file = role
.config_file
.as_ref()
.map(AbsolutePathBuf::from_absolute_path)
.transpose()?;
validate_agent_role_config_file(fs, role_name, config_file.as_ref()).await?;
let description = normalize_agent_role_description(
&format!("agents.{role_name}.description"),
role.description.as_deref(),
@@ -188,7 +203,7 @@ fn agent_role_config_from_toml(
Ok(AgentRoleConfig {
description,
config_file,
config_file: config_file.map(AbsolutePathBuf::into_path_buf),
nickname_candidates,
})
}
@@ -293,15 +308,17 @@ pub(crate) fn parse_agent_role_file_contents(
})
}
fn read_resolved_agent_role_file(
path: &Path,
async fn read_resolved_agent_role_file(
fs: &dyn ExecutorFileSystem,
path: &AbsolutePathBuf,
role_name_hint: Option<&str>,
) -> std::io::Result<ResolvedAgentRoleFile> {
let contents = std::fs::read_to_string(path)?;
let contents = fs.read_file_text(path, /*sandbox*/ None).await?;
let config_base_dir = path.parent().unwrap_or_else(|| path.clone());
parse_agent_role_file_contents(
&contents,
path,
path.parent().unwrap_or(path),
path.as_path(),
config_base_dir.as_path(),
role_name_hint,
)
}
@@ -359,31 +376,35 @@ fn validate_agent_role_file_developer_instructions(
}
}
fn validate_agent_role_config_file(
async fn validate_agent_role_config_file(
fs: &dyn ExecutorFileSystem,
role_name: &str,
config_file: Option<&Path>,
config_file: Option<&AbsolutePathBuf>,
) -> std::io::Result<()> {
let Some(config_file) = config_file else {
return Ok(());
};
let metadata = std::fs::metadata(config_file).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"agents.{role_name}.config_file must point to an existing file at {}: {e}",
config_file.display()
),
)
})?;
if metadata.is_file() {
let metadata = fs
.get_metadata(config_file, /*sandbox*/ None)
.await
.map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"agents.{role_name}.config_file must point to an existing file at {}: {e}",
config_file.as_path().display()
),
)
})?;
if metadata.is_file {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"agents.{role_name}.config_file must point to a file: {}",
config_file.display()
config_file.as_path().display()
),
))
}
@@ -441,19 +462,20 @@ fn normalize_agent_role_nickname_candidates(
Ok(Some(normalized_candidates))
}
fn discover_agent_roles_in_dir(
agents_dir: &Path,
async fn discover_agent_roles_in_dir(
fs: &dyn ExecutorFileSystem,
agents_dir: &AbsolutePathBuf,
declared_role_files: &BTreeSet<PathBuf>,
startup_warnings: &mut Vec<String>,
) -> std::io::Result<BTreeMap<String, AgentRoleConfig>> {
let mut roles = BTreeMap::new();
for agent_file in collect_agent_role_files(agents_dir)? {
if declared_role_files.contains(&agent_file) {
for agent_file in collect_agent_role_files(fs, agents_dir).await? {
if declared_role_files.contains(agent_file.as_path()) {
continue;
}
let parsed_file =
match read_resolved_agent_role_file(&agent_file, /*role_name_hint*/ None) {
match read_resolved_agent_role_file(fs, &agent_file, /*role_name_hint*/ None).await {
Ok(parsed_file) => parsed_file,
Err(err) => {
push_agent_role_warning(startup_warnings, err);
@@ -468,7 +490,7 @@ fn discover_agent_roles_in_dir(
std::io::ErrorKind::InvalidInput,
format!(
"duplicate agent role name `{role_name}` discovered in {}",
agents_dir.display()
agents_dir.as_path().display()
),
),
);
@@ -478,7 +500,7 @@ fn discover_agent_roles_in_dir(
role_name,
AgentRoleConfig {
description: parsed_file.description,
config_file: Some(agent_file),
config_file: Some(agent_file.to_path_buf()),
nickname_candidates: parsed_file.nickname_candidates,
},
);
@@ -487,36 +509,36 @@ fn discover_agent_roles_in_dir(
Ok(roles)
}
fn collect_agent_role_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
async fn collect_agent_role_files(
fs: &dyn ExecutorFileSystem,
dir: &AbsolutePathBuf,
) -> std::io::Result<Vec<AbsolutePathBuf>> {
let mut files = Vec::new();
collect_agent_role_files_recursive(dir, &mut files)?;
files.sort();
Ok(files)
}
let mut dirs = vec![dir.clone()];
while let Some(dir) = dirs.pop() {
let entries = match fs.read_directory(&dir, /*sandbox*/ None).await {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => continue,
Err(err) => return Err(err),
};
fn collect_agent_role_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
let read_dir = match std::fs::read_dir(dir) {
Ok(read_dir) => read_dir,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
for entry in read_dir {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_agent_role_files_recursive(&path, files)?;
continue;
}
if file_type.is_file()
&& path
.extension()
.is_some_and(|extension| extension == "toml")
{
files.push(path);
for entry in entries {
let path = dir.join(entry.file_name);
if entry.is_directory {
dirs.push(path);
continue;
}
if entry.is_file
&& path
.as_path()
.extension()
.is_some_and(|extension| extension == "toml")
{
files.push(path);
}
}
}
Ok(())
files.sort();
Ok(files)
}
+49 -11
View File
@@ -45,6 +45,7 @@ use codex_config::types::SkillsConfig;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_config::types::Tui;
use codex_config::types::TuiNotificationSettings;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_features::FeaturesToml;
use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
@@ -429,6 +430,7 @@ allow_upstream_proxy = false
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([
(
":minimal".to_string(),
@@ -483,6 +485,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),
@@ -532,6 +535,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),
@@ -577,6 +581,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(),
@@ -672,6 +677,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([
@@ -694,6 +700,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();
@@ -739,6 +752,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),
@@ -782,6 +796,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),
@@ -825,6 +840,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([(
@@ -884,6 +900,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),
@@ -930,6 +947,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([(
@@ -997,6 +1015,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,
@@ -1031,6 +1050,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([(
@@ -1076,6 +1096,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),
@@ -1175,7 +1196,7 @@ network_access = false # This should be ignored.
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&PathBuf::from("/tmp/test"),
/*active_project*/ None,
/*sandbox_policy_constraint*/ None,
)
.await;
@@ -1196,7 +1217,7 @@ network_access = true # This should be ignored.
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&PathBuf::from("/tmp/test"),
/*active_project*/ None,
/*sandbox_policy_constraint*/ None,
)
.await;
@@ -1213,6 +1234,9 @@ writable_roots = [
]
exclude_tmpdir_env_var = true
exclude_slash_tmp = true
[projects."/tmp/test"]
trust_level = "trusted"
"#,
serde_json::json!(writable_root)
);
@@ -1225,7 +1249,7 @@ exclude_slash_tmp = true
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&PathBuf::from("/tmp/test"),
/*active_project*/ None,
/*sandbox_policy_constraint*/ None,
)
.await;
@@ -1254,9 +1278,6 @@ writable_roots = [
]
exclude_tmpdir_env_var = true
exclude_slash_tmp = true
[projects."/tmp/test"]
trust_level = "trusted"
"#,
serde_json::json!(writable_root)
);
@@ -1269,7 +1290,7 @@ trust_level = "trusted"
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&PathBuf::from("/tmp/test"),
/*active_project*/ None,
/*sandbox_policy_constraint*/ None,
)
.await;
@@ -2066,6 +2087,7 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> {
let cwd = codex_home.path().abs();
let config_layer_stack = load_config_layers_state(
LOCAL_FS.as_ref(),
codex_home.path(),
Some(cwd),
&Vec::new(),
@@ -2199,6 +2221,7 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> {
let cwd = codex_home.path().abs();
let config_layer_stack = load_config_layers_state(
LOCAL_FS.as_ref(),
codex_home.path(),
Some(cwd),
&[("model".to_string(), TomlValue::String("cli".to_string()))],
@@ -3467,6 +3490,7 @@ async fn load_config_uses_requirements_guardian_policy_config() -> std::io::Resu
.map_err(std::io::Error::other)?;
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
@@ -3499,6 +3523,7 @@ async fn load_config_ignores_empty_requirements_guardian_policy_config() -> std:
.map_err(std::io::Error::other)?;
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
@@ -5311,6 +5336,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
.expect("config layer stack");
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
@@ -5451,7 +5477,10 @@ async fn active_project_does_not_match_configured_alias_for_canonical_cwd() -> a
..Default::default()
};
assert_eq!(config.get_active_project(&project_root).await, None);
assert_eq!(
config.get_active_project(&project_root, /*repo_root*/ None),
None
);
Ok(())
}
@@ -5545,13 +5574,16 @@ trust_level = "untrusted"
let cfg = toml::from_str::<ConfigToml>(config_with_untrusted)
.expect("TOML deserialization should succeed");
let active_project = ProjectConfig {
trust_level: Some(TrustLevel::Untrusted),
};
let resolution = cfg
.derive_sandbox_policy(
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&PathBuf::from("/tmp/test"),
Some(&active_project),
/*sandbox_policy_constraint*/ None,
)
.await;
@@ -5587,6 +5619,9 @@ async fn derive_sandbox_policy_falls_back_to_constraint_value_for_implicit_defau
)])),
..Default::default()
};
let active_project = ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
};
let constrained = Constrained::new(SandboxPolicy::DangerFullAccess, |candidate| {
if matches!(candidate, SandboxPolicy::DangerFullAccess) {
Ok(())
@@ -5605,7 +5640,7 @@ async fn derive_sandbox_policy_falls_back_to_constraint_value_for_implicit_defau
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&project_path,
Some(&active_project),
Some(&constrained),
)
.await;
@@ -5629,6 +5664,9 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb
)])),
..Default::default()
};
let active_project = ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
};
let constrained = Constrained::new(SandboxPolicy::new_workspace_write_policy(), |candidate| {
if matches!(candidate, SandboxPolicy::WorkspaceWrite { .. }) {
Ok(())
@@ -5647,7 +5685,7 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
&project_path,
Some(&active_project),
Some(&constrained),
)
.await;
+45 -15
View File
@@ -47,6 +47,8 @@ use codex_config::types::ToolSuggestDiscoverable;
use codex_config::types::TuiNotificationSettings;
use codex_config::types::UriBasedFileOpener;
use codex_config::types::WindowsSandboxModeToml;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
@@ -54,6 +56,7 @@ use codex_features::FeatureToml;
use codex_features::Features;
use codex_features::FeaturesToml;
use codex_features::MultiAgentV2ConfigToml;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::AuthManagerConfig;
use codex_mcp::McpConfig;
use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
@@ -689,6 +692,7 @@ impl ConfigBuilder {
};
harness_overrides.cwd = Some(cwd.to_path_buf());
let config_layer_stack = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&cli_overrides,
@@ -718,6 +722,7 @@ impl ConfigBuilder {
}
};
Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
config_toml,
harness_overrides,
codex_home,
@@ -812,6 +817,7 @@ impl Config {
let codex_home = AbsolutePathBuf::from_absolute_path_checked(codex_home)?;
let config_toml = deserialize_config_toml_with_base(merged, &codex_home)?;
Self::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
config_toml,
ConfigOverrides::default(),
codex_home,
@@ -849,6 +855,7 @@ pub async fn load_config_as_toml_with_cli_overrides(
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<ConfigToml> {
let config_layer_stack = load_config_layers_state(
LOCAL_FS.as_ref(),
codex_home,
cwd.cloned(),
&cli_overrides,
@@ -1019,6 +1026,7 @@ pub async fn load_global_mcp_servers(
// MCP servers defined in in-repo .codex/ folders.
let cwd: Option<AbsolutePathBuf> = None;
let config_layer_stack = load_config_layers_state(
LOCAL_FS.as_ref(),
codex_home,
cwd,
&cli_overrides,
@@ -1420,10 +1428,18 @@ impl Config {
) -> std::io::Result<Self> {
// Note this ignores requirements.toml enforcement for tests.
let config_layer_stack = ConfigLayerStack::default();
Self::load_config_with_layer_stack(cfg, overrides, codex_home, config_layer_stack).await
Self::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
cfg,
overrides,
codex_home,
config_layer_stack,
)
.await
}
pub(crate) async fn load_config_with_layer_stack(
fs: &dyn ExecutorFileSystem,
cfg: ConfigToml,
overrides: ConfigOverrides,
codex_home: AbsolutePathBuf,
@@ -1545,9 +1561,12 @@ impl Config {
.into_iter()
.map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()))
.collect();
let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await;
let active_project = cfg
.get_active_project(resolved_cwd.as_path())
.await
.get_active_project(
resolved_cwd.as_path(),
repo_root.as_ref().map(AbsolutePathBuf::as_path),
)
.unwrap_or(ProjectConfig { trust_level: None });
let permission_config_syntax = resolve_permission_config_syntax(
&config_layer_stack,
@@ -1643,7 +1662,7 @@ impl Config {
sandbox_mode,
config_profile.sandbox_mode,
windows_sandbox_level,
resolved_cwd.as_path(),
Some(&active_project),
Some(&constrained_sandbox_policy),
)
.await;
@@ -1712,7 +1731,8 @@ impl Config {
let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile);
let agent_roles =
agent_roles::load_agent_roles(&cfg, &config_layer_stack, &mut startup_warnings)?;
agent_roles::load_agent_roles(fs, &cfg, &config_layer_stack, &mut startup_warnings)
.await?;
let openai_base_url = cfg
.openai_base_url
@@ -1861,8 +1881,12 @@ impl Config {
.model_instructions_file
.as_ref()
.or(cfg.model_instructions_file.as_ref());
let file_base_instructions =
Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?;
let file_base_instructions = Self::try_read_non_empty_file(
fs,
model_instructions_path,
"model instructions file",
)
.await?;
let base_instructions = base_instructions.or(file_base_instructions);
let developer_instructions = developer_instructions.or(cfg.developer_instructions);
let include_permissions_instructions = config_profile
@@ -1893,9 +1917,11 @@ impl Config {
.as_ref()
.or(cfg.experimental_compact_prompt_file.as_ref());
let file_compact_prompt = Self::try_read_non_empty_file(
fs,
experimental_compact_prompt_path,
"experimental compact prompt file",
)?;
)
.await?;
let compact_prompt = compact_prompt.or(file_compact_prompt);
let js_repl_node_path = js_repl_node_path_override
.or(config_profile.js_repl_node_path.map(Into::into))
@@ -2218,7 +2244,8 @@ impl Config {
/// If `path` is `Some`, attempts to read the file at the given path and
/// returns its contents as a trimmed `String`. If the file is empty, or
/// is `Some` but cannot be read, returns an `Err`.
fn try_read_non_empty_file(
async fn try_read_non_empty_file(
fs: &dyn ExecutorFileSystem,
path: Option<&AbsolutePathBuf>,
context: &str,
) -> std::io::Result<Option<String>> {
@@ -2226,12 +2253,15 @@ impl Config {
return Ok(None);
};
let contents = std::fs::read_to_string(path).map_err(|e| {
std::io::Error::new(
e.kind(),
format!("failed to read {context} {}: {e}", path.display()),
)
})?;
let contents = fs
.read_file_text(path, /*sandbox*/ None)
.await
.map_err(|e| {
std::io::Error::new(
e.kind(),
format!("failed to read {context} {}: {e}", path.display()),
)
})?;
let s = contents.trim().to_string();
if s.is_empty() {
+53 -4
View File
@@ -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(
@@ -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
@@ -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, '*' | '?' | '[' | ']'))
}
@@ -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,
@@ -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(),
@@ -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()?;
@@ -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([(
+2
View File
@@ -29,6 +29,7 @@ use codex_app_server_protocol::OverriddenMetadata;
use codex_app_server_protocol::WriteStatus;
use codex_config::CONFIG_TOML_FILE;
use codex_config::config_toml::ConfigToml;
use codex_exec_server::LOCAL_FS;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value as JsonValue;
use std::borrow::Cow;
@@ -424,6 +425,7 @@ impl ConfigService {
async fn load_thread_agnostic_config(&self) -> std::io::Result<ConfigLayerStack> {
let cwd: Option<AbsolutePathBuf> = None;
load_config_layers_state(
LOCAL_FS.as_ref(),
&self.codex_home,
cwd,
&self.cli_overrides,
+7 -3
View File
@@ -10,7 +10,7 @@ This module is the canonical place to **load and describe Codex configuration la
Exported from `codex_core::config_loader`:
- `load_config_layers_state(codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements) -> ConfigLayerStack`
- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements) -> ConfigLayerStack`
- `ConfigLayerStack`
- `effective_config() -> toml::Value`
- `origins() -> HashMap<String, ConfigLayerMetadata>`
@@ -38,18 +38,22 @@ computing the effective config and origins metadata. This is what
Most callers want the effective config plus metadata:
```rust
use codex_core::config_loader::{load_config_layers_state, LoaderOverrides};
use codex_core::config_loader::{
CloudRequirementsLoader, LoaderOverrides, load_config_layers_state,
};
use codex_exec_server::LOCAL_FS;
use codex_utils_absolute_path::AbsolutePathBuf;
use toml::Value as TomlValue;
let cli_overrides: Vec<(String, TomlValue)> = Vec::new();
let cwd = AbsolutePathBuf::current_dir()?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&cli_overrides,
LoaderOverrides::default(),
None,
CloudRequirementsLoader::default(),
).await?;
let effective = layers.effective_config();
+11 -9
View File
@@ -5,11 +5,11 @@ use super::macos::ManagedAdminConfigLayer;
use super::macos::load_managed_admin_config_layer;
use codex_config::config_error_from_toml;
use codex_config::io_error_from_config_error;
use codex_exec_server::ExecutorFileSystem;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use tokio::fs;
use toml::Value as TomlValue;
#[cfg(unix)]
@@ -36,6 +36,7 @@ pub(super) struct LoadedConfigLayers {
}
pub(super) async fn load_config_layers_internal(
fs: &dyn ExecutorFileSystem,
codex_home: &Path,
overrides: LoaderOverrides,
) -> io::Result<LoadedConfigLayers> {
@@ -57,7 +58,7 @@ pub(super) async fn load_config_layers_internal(
)?;
let managed_config =
read_config_from_path(&managed_config_path, /*log_missing_as_info*/ false)
read_config_from_path(fs, &managed_config_path, /*log_missing_as_info*/ false)
.await?
.map(|managed_config| MangedConfigFromFile {
managed_config,
@@ -89,15 +90,16 @@ fn map_managed_admin_layer(layer: ManagedAdminConfigLayer) -> ManagedConfigFromM
}
pub(super) async fn read_config_from_path(
path: impl AsRef<Path>,
fs: &dyn ExecutorFileSystem,
path: &AbsolutePathBuf,
log_missing_as_info: bool,
) -> io::Result<Option<TomlValue>> {
match fs::read_to_string(path.as_ref()).await {
match fs.read_file_text(path, /*sandbox*/ None).await {
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
Ok(value) => Ok(Some(value)),
Err(err) => {
tracing::error!("Failed to parse {}: {err}", path.as_ref().display());
let config_error = config_error_from_toml(path.as_ref(), &contents, err.clone());
tracing::error!("Failed to parse {}: {err}", path.as_path().display());
let config_error = config_error_from_toml(path.as_path(), &contents, err.clone());
Err(io_error_from_config_error(
io::ErrorKind::InvalidData,
config_error,
@@ -107,14 +109,14 @@ pub(super) async fn read_config_from_path(
},
Err(err) if err.kind() == io::ErrorKind::NotFound => {
if log_missing_as_info {
tracing::info!("{} not found, using defaults", path.as_ref().display());
tracing::info!("{} not found, using defaults", path.as_path().display());
} else {
tracing::debug!("{} not found", path.as_ref().display());
tracing::debug!("{} not found", path.as_path().display());
}
Ok(None)
}
Err(err) => {
tracing::error!("Failed to read {}: {err}", path.as_ref().display());
tracing::error!("Failed to read {}: {err}", path.as_path().display());
Err(err)
}
}
+48 -32
View File
@@ -11,6 +11,7 @@ use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigRequirementsWithSources;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_exec_server::ExecutorFileSystem;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::SandboxMode;
@@ -118,6 +119,7 @@ pub(crate) async fn first_layer_config_error_from_entries(
/// thread-agnostic config loading (e.g., for the app server's `/config`
/// endpoint) should `cwd` be `None`.
pub async fn load_config_layers_state(
fs: &dyn ExecutorFileSystem,
codex_home: &Path,
cwd: Option<AbsolutePathBuf>,
cli_overrides: &[(String, TomlValue)],
@@ -142,11 +144,12 @@ pub async fn load_config_layers_state(
// Honor the system requirements.toml location.
let requirements_toml_file = system_requirements_toml_file()?;
load_requirements_toml(&mut config_requirements_toml, requirements_toml_file).await?;
load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?;
// Make a best-effort to support the legacy `managed_config.toml` as a
// requirements specification.
let loaded_config_layers = layer_io::load_config_layers_internal(codex_home, overrides).await?;
let loaded_config_layers =
layer_io::load_config_layers_internal(fs, codex_home, overrides).await?;
load_requirements_from_legacy_scheme(
&mut config_requirements_toml,
loaded_config_layers.clone(),
@@ -173,7 +176,7 @@ pub async fn load_config_layers_state(
// if it exists.
let system_config_toml_file = system_config_toml_file()?;
let system_layer =
load_config_toml_for_required_layer(&system_config_toml_file, |config_toml| {
load_config_toml_for_required_layer(fs, &system_config_toml_file, |config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::System {
file: system_config_toml_file.clone(),
@@ -188,7 +191,7 @@ pub async fn load_config_layers_state(
// exists, but is malformed, then this error should be propagated to the
// user.
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home);
let user_layer = load_config_toml_for_required_layer(&user_file, |config_toml| {
let user_layer = load_config_toml_for_required_layer(fs, &user_file, |config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
@@ -222,6 +225,7 @@ pub async fn load_config_layers_state(
}
};
let project_trust_context = match project_trust_context(
fs,
&merged_so_far,
&cwd,
&project_root_markers,
@@ -247,6 +251,7 @@ pub async fn load_config_layers_state(
}
};
let project_layers = load_project_layers(
fs,
&cwd,
&project_trust_context.project_root,
&project_trust_context,
@@ -320,22 +325,23 @@ pub async fn load_config_layers_state(
/// - If there is an error reading the file or parsing the TOML, returns an
/// error.
async fn load_config_toml_for_required_layer(
config_toml: impl AsRef<Path>,
fs: &dyn ExecutorFileSystem,
toml_file: &AbsolutePathBuf,
create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry,
) -> io::Result<ConfigLayerEntry> {
let toml_file = config_toml.as_ref();
let toml_value = match tokio::fs::read_to_string(toml_file).await {
let toml_value = match fs.read_file_text(toml_file, /*sandbox*/ None).await {
Ok(contents) => {
let config: TomlValue = toml::from_str(&contents).map_err(|err| {
let config_error = config_error_from_toml(toml_file, &contents, err.clone());
let config_error =
config_error_from_toml(toml_file.as_path(), &contents, err.clone());
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
})?;
let config_parent = toml_file.parent().ok_or_else(|| {
let config_parent = toml_file.as_path().parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Config file {} has no parent directory",
toml_file.display()
toml_file.as_path().display()
),
)
})?;
@@ -347,7 +353,10 @@ async fn load_config_toml_for_required_layer(
} else {
Err(io::Error::new(
e.kind(),
format!("Failed to read config file {}: {e}", toml_file.display()),
format!(
"Failed to read config file {}: {e}",
toml_file.as_path().display()
),
))
}
}
@@ -360,12 +369,14 @@ async fn load_config_toml_for_required_layer(
/// `requirements.toml` location to `config_requirements_toml` by filling in
/// any unset fields.
async fn load_requirements_toml(
fs: &dyn ExecutorFileSystem,
config_requirements_toml: &mut ConfigRequirementsWithSources,
requirements_toml_file: impl AsRef<Path>,
requirements_toml_file: &AbsolutePathBuf,
) -> io::Result<()> {
let requirements_toml_file =
AbsolutePathBuf::from_absolute_path(requirements_toml_file.as_ref())?;
match tokio::fs::read_to_string(&requirements_toml_file).await {
match fs
.read_file_text(requirements_toml_file, /*sandbox*/ None)
.await
{
Ok(contents) => {
let requirements_config: ConfigRequirementsToml =
toml::from_str(&contents).map_err(|e| {
@@ -373,7 +384,7 @@ async fn load_requirements_toml(
io::ErrorKind::InvalidData,
format!(
"Error parsing requirements file {}: {e}",
requirements_toml_file.as_ref().display(),
requirements_toml_file.as_path().display(),
),
)
})?;
@@ -390,7 +401,7 @@ async fn load_requirements_toml(
e.kind(),
format!(
"Failed to read requirements file {}: {e}",
requirements_toml_file.as_ref().display(),
requirements_toml_file.as_path().display(),
),
));
}
@@ -643,6 +654,7 @@ fn project_layer_entry(
}
async fn project_trust_context(
fs: &dyn ExecutorFileSystem,
merged_config: &TomlValue,
cwd: &AbsolutePathBuf,
project_root_markers: &[String],
@@ -657,7 +669,7 @@ async fn project_trust_context(
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?
};
let project_root = find_project_root(cwd, project_root_markers).await?;
let project_root = find_project_root(fs, cwd, project_root_markers).await?;
let projects = project_trust_config.projects.unwrap_or_default();
let project_root_lookup_keys = normalized_project_trust_keys(project_root.as_path());
@@ -665,10 +677,10 @@ async fn project_trust_context(
.first()
.cloned()
.unwrap_or_else(|| normalized_project_trust_key(project_root.as_path()));
let repo_root = resolve_root_git_project_for_trust(cwd.as_path()).await;
let repo_root = resolve_root_git_project_for_trust(fs, cwd).await;
let repo_root_lookup_keys = repo_root
.as_ref()
.map(|root| normalized_project_trust_keys(root));
.map(|root| normalized_project_trust_keys(root.as_path()));
let repo_root_key = repo_root_lookup_keys
.as_ref()
.and_then(|keys| keys.first().cloned());
@@ -802,6 +814,7 @@ fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlV
}
async fn find_project_root(
fs: &dyn ExecutorFileSystem,
cwd: &AbsolutePathBuf,
project_root_markers: &[String],
) -> io::Result<AbsolutePathBuf> {
@@ -809,11 +822,15 @@ async fn find_project_root(
return Ok(cwd.clone());
}
for ancestor in cwd.as_path().ancestors() {
for ancestor in cwd.ancestors() {
for marker in project_root_markers {
let marker_path = ancestor.join(marker);
if tokio::fs::metadata(&marker_path).await.is_ok() {
return AbsolutePathBuf::from_absolute_path(ancestor);
if fs
.get_metadata(&marker_path, /*sandbox*/ None)
.await
.is_ok()
{
return Ok(ancestor);
}
}
}
@@ -826,6 +843,7 @@ async fn find_project_root(
/// starting from folders closest to `project_root` (which is the lowest
/// precedence) to those closest to `cwd` (which is the highest precedence).
async fn load_project_layers(
fs: &dyn ExecutorFileSystem,
cwd: &AbsolutePathBuf,
project_root: &AbsolutePathBuf,
trust_context: &ProjectTrustContext,
@@ -835,13 +853,12 @@ async fn load_project_layers(
let codex_home_normalized =
normalize_path(codex_home_abs.as_path()).unwrap_or_else(|_| codex_home_abs.to_path_buf());
let mut dirs = cwd
.as_path()
.ancestors()
.scan(false, |done, a| {
if *done {
None
} else {
if a == project_root.as_path() {
if &a == project_root {
*done = true;
}
Some(a)
@@ -852,26 +869,25 @@ async fn load_project_layers(
let mut layers = Vec::new();
for dir in dirs {
let dot_codex = dir.join(".codex");
if !tokio::fs::metadata(&dot_codex)
let dot_codex_abs = dir.join(".codex");
if !fs
.get_metadata(&dot_codex_abs, /*sandbox*/ None)
.await
.map(|meta| meta.is_dir())
.map(|metadata| metadata.is_directory)
.unwrap_or(false)
{
continue;
}
let layer_dir = AbsolutePathBuf::from_absolute_path(dir)?;
let decision = trust_context.decision_for_dir(&layer_dir);
let decision = trust_context.decision_for_dir(&dir);
let disabled_reason = trust_context.disabled_reason_for_decision(&decision);
let dot_codex_abs = AbsolutePathBuf::from_absolute_path(&dot_codex)?;
let dot_codex_normalized =
normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf());
if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized {
continue;
}
let config_file = dot_codex_abs.join(CONFIG_TOML_FILE);
match tokio::fs::read_to_string(&config_file).await {
match fs.read_file_text(&config_file, /*sandbox*/ None).await {
Ok(contents) => {
let config: TomlValue = match toml::from_str(&contents) {
Ok(config) => config,
+35 -2
View File
@@ -16,6 +16,7 @@ use crate::config_loader::version_for_toml;
use codex_config::CONFIG_TOML_FILE;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_exec_server::LOCAL_FS;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::AskForApproval;
@@ -92,6 +93,7 @@ async fn returns_config_error_for_invalid_user_config_toml() {
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let err = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
@@ -119,6 +121,7 @@ async fn returns_config_error_for_invalid_managed_config_toml() {
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let err = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
@@ -203,6 +206,7 @@ extra = true
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let state = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
@@ -235,6 +239,7 @@ async fn returns_empty_when_all_layers_missing() {
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
@@ -327,6 +332,7 @@ flag = false
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let state = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
@@ -428,6 +434,7 @@ allowed_sandbox_modes = ["read-only"]
);
let state = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(AbsolutePathBuf::try_from(tmp.path())?),
&[] as &[(String, TomlValue)],
@@ -489,6 +496,7 @@ allowed_approval_policies = ["never"]
);
let state = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(AbsolutePathBuf::try_from(tmp.path())?),
&[] as &[(String, TomlValue)],
@@ -529,8 +537,14 @@ personality = true
)
.await?;
let requirements_file = AbsolutePathBuf::try_from(requirements_file)?;
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?;
load_requirements_toml(
LOCAL_FS.as_ref(),
&mut config_requirements_toml,
&requirements_file,
)
.await?;
assert_eq!(
config_requirements_toml
@@ -620,6 +634,7 @@ allowed_approval_policies = ["on-request"]
),
);
let state = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(AbsolutePathBuf::try_from(tmp.path())?),
&[] as &[(String, TomlValue)],
@@ -691,7 +706,12 @@ allowed_approval_policies = ["on-request"]
guardian_policy_config: None,
},
);
load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?;
load_requirements_toml(
LOCAL_FS.as_ref(),
&mut config_requirements_toml,
&AbsolutePathBuf::try_from(requirements_file)?,
)
.await?;
assert_eq!(
config_requirements_toml
@@ -735,6 +755,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) });
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
@@ -771,6 +792,7 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let err = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
@@ -823,6 +845,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> {
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
@@ -967,6 +990,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
@@ -1006,6 +1030,7 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R
let cwd = AbsolutePathBuf::from_absolute_path(&home_dir)?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
@@ -1062,6 +1087,7 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&project_dot_codex,
Some(cwd),
&[] as &[(String, TomlValue)],
@@ -1132,6 +1158,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
.await?;
let layers_untrusted = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home_untrusted,
Some(cwd.clone()),
&[] as &[(String, TomlValue)],
@@ -1170,6 +1197,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
.await?;
let layers_unknown = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home_unknown,
Some(cwd),
&[] as &[(String, TomlValue)],
@@ -1235,6 +1263,7 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st
.await?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(AbsolutePathBuf::from_absolute_path(&project_root)?),
&[] as &[(String, TomlValue)],
@@ -1387,6 +1416,7 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::
}
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd.clone()),
&[] as &[(String, TomlValue)],
@@ -1454,6 +1484,7 @@ async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown
}
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd.clone()),
&[] as &[(String, TomlValue)],
@@ -1513,6 +1544,7 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io
)];
load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&cli_overrides,
@@ -1555,6 +1587,7 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
+44 -23
View File
@@ -1,3 +1,4 @@
use codex_exec_server::LOCAL_FS;
use codex_git_utils::GitInfo;
use codex_git_utils::GitSha;
use codex_git_utils::collect_git_info;
@@ -5,6 +6,9 @@ use codex_git_utils::get_has_changes;
use codex_git_utils::git_diff_to_remote;
use codex_git_utils::recent_commits;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_utils_path::normalize_for_path_comparison;
use core_test_support::PathBufExt;
use core_test_support::PathExt;
use core_test_support::skip_if_sandbox;
use std::fs;
use std::path::PathBuf;
@@ -430,7 +434,7 @@ async fn test_get_git_working_tree_state_branch_fallback() {
async fn resolve_root_git_project_for_trust_returns_none_outside_repo() {
let tmp = TempDir::new().expect("tempdir");
assert!(
resolve_root_git_project_for_trust(tmp.path())
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &tmp.path().abs())
.await
.is_none()
);
@@ -439,18 +443,17 @@ async fn resolve_root_git_project_for_trust_returns_none_outside_repo() {
#[tokio::test]
async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
let expected = std::fs::canonicalize(&repo_path).unwrap();
let repo_path = create_test_git_repo(&temp_dir).await.abs();
assert_eq!(
resolve_root_git_project_for_trust(&repo_path).await,
Some(expected.clone())
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &repo_path).await,
Some(repo_path.clone())
);
let nested = repo_path.join("sub/dir");
std::fs::create_dir_all(&nested).unwrap();
std::fs::create_dir_all(nested.as_path()).unwrap();
assert_eq!(
resolve_root_git_project_for_trust(&nested).await,
Some(expected)
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await,
Some(repo_path)
);
}
@@ -473,17 +476,27 @@ async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_ro
.output()
.expect("git worktree add");
let expected = std::fs::canonicalize(&repo_path).ok();
let got = resolve_root_git_project_for_trust(&wt_root)
.await
.and_then(|p| std::fs::canonicalize(p).ok());
assert_eq!(got, expected);
let expected = normalize_for_path_comparison(&repo_path).unwrap();
let wt_root = wt_root.abs();
let got = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &wt_root).await;
assert_eq!(
got.as_ref()
.map(normalize_for_path_comparison)
.transpose()
.unwrap(),
Some(expected.clone())
);
let nested = wt_root.join("nested/sub");
std::fs::create_dir_all(&nested).unwrap();
let got_nested = resolve_root_git_project_for_trust(&nested)
.await
.and_then(|p| std::fs::canonicalize(p).ok());
assert_eq!(got_nested, expected);
std::fs::create_dir_all(nested.as_path()).unwrap();
let got_nested = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await;
assert_eq!(
got_nested
.as_ref()
.map(normalize_for_path_comparison)
.transpose()
.unwrap(),
Some(expected)
);
}
#[tokio::test]
@@ -502,13 +515,15 @@ async fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git
)
.unwrap();
let expected = std::fs::canonicalize(&repo_root).unwrap();
let expected = repo_root.abs();
let worktree_root = worktree_root.abs();
assert_eq!(
resolve_root_git_project_for_trust(&worktree_root).await,
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &worktree_root).await,
Some(expected.clone())
);
let nested = worktree_root.join("nested");
assert_eq!(
resolve_root_git_project_for_trust(&worktree_root.join("nested")).await,
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await,
Some(expected)
);
}
@@ -529,9 +544,15 @@ async fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none()
)
.unwrap();
assert!(resolve_root_git_project_for_trust(&proj).await.is_none());
let proj = proj.abs();
assert!(
resolve_root_git_project_for_trust(&proj.join("nested"))
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &proj)
.await
.is_none()
);
let nested = proj.join("nested");
assert!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested)
.await
.is_none()
);
+3
View File
@@ -16,6 +16,7 @@ use crate::config_loader::RequirementSource;
use crate::config_loader::Sourced;
use crate::test_support;
use codex_config::config_toml::ConfigToml;
use codex_exec_server::LOCAL_FS;
use codex_network_proxy::NetworkProxyConfig;
use codex_protocol::ThreadId;
use codex_protocol::approvals::NetworkApprovalProtocol;
@@ -1740,6 +1741,7 @@ async fn guardian_review_session_config_uses_requirements_guardian_policy_config
)
.expect("config layer stack");
let parent_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(workspace.path().to_path_buf()),
@@ -1776,6 +1778,7 @@ async fn guardian_review_session_config_uses_default_guardian_policy_without_req
ConfigLayerStack::new(Vec::new(), Default::default(), Default::default())
.expect("config layer stack");
let parent_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(workspace.path().to_path_buf()),
@@ -16,6 +16,7 @@ use codex_config::CONFIG_TOML_FILE;
use codex_config::permissions_toml::NetworkToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_config::permissions_toml::overlay_network_domain_permissions;
use codex_exec_server::LOCAL_FS;
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkProxyConfig;
@@ -46,6 +47,7 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec<LayerMtime
let cli_overrides = Vec::new();
let overrides = LoaderOverrides::default();
let config_layer_stack = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
/*cwd*/ None,
&cli_overrides,
@@ -77,6 +77,11 @@ where
sparse_paths,
} = request;
let source = parse_marketplace_source(&source, ref_name)?;
if !sparse_paths.is_empty() && !matches!(source, MarketplaceSource::Git { .. }) {
return Err(MarketplaceAddError::InvalidRequest(
"--sparse is only supported for git marketplace sources".to_string(),
));
}
let install_root = marketplace_install_root(codex_home);
fs::create_dir_all(&install_root).map_err(|err| {
@@ -287,6 +292,38 @@ mod tests {
Ok(())
}
#[test]
fn add_marketplace_sync_rejects_sparse_checkout_for_local_directory_source() -> Result<()> {
let codex_home = TempDir::new()?;
let source_root = TempDir::new()?;
write_marketplace_source(source_root.path(), "local copy")?;
let err = add_marketplace_sync_with_cloner(
codex_home.path(),
MarketplaceAddRequest {
source: source_root.path().display().to_string(),
ref_name: None,
sparse_paths: vec![".agents".to_string()],
},
|_url, _ref_name, _sparse_paths, _destination| {
panic!("git cloner should not be called for local marketplace sources")
},
)
.unwrap_err();
assert_eq!(
err.to_string(),
"--sparse is only supported for git marketplace sources"
);
assert!(
!codex_home
.path()
.join(codex_config::CONFIG_TOML_FILE)
.exists()
);
Ok(())
}
#[test]
fn add_marketplace_sync_treats_existing_local_directory_source_as_already_added() -> Result<()>
{
+38 -22
View File
@@ -2,12 +2,14 @@ use crate::codex::Session;
use crate::compact::content_items_to_text;
use crate::event_mapping::is_contextual_user_message_content;
use chrono::Utc;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::models::ResponseItem;
use codex_thread_store::ListThreadsParams;
use codex_thread_store::StoredThread;
use codex_thread_store::ThreadSortKey;
use codex_thread_store::ThreadStore;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::truncate_text;
use dirs::home_dir;
@@ -145,18 +147,26 @@ async fn load_recent_threads(sess: &Session) -> Vec<StoredThread> {
}
}
async fn build_recent_work_section(cwd: &Path, recent_threads: &[StoredThread]) -> Option<String> {
async fn build_recent_work_section(
cwd: &AbsolutePathBuf,
recent_threads: &[StoredThread],
) -> Option<String> {
let mut groups: HashMap<PathBuf, Vec<&StoredThread>> = HashMap::new();
for entry in recent_threads {
let group = resolve_root_git_project_for_trust(&entry.cwd)
.await
.unwrap_or_else(|| entry.cwd.clone());
let group = match AbsolutePathBuf::from_absolute_path(entry.cwd.as_path()) {
Ok(entry_cwd) => resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &entry_cwd)
.await
.map(AbsolutePathBuf::into_path_buf)
.unwrap_or_else(|| entry.cwd.clone()),
Err(_) => entry.cwd.clone(),
};
groups.entry(group).or_default().push(entry);
}
let current_group = resolve_root_git_project_for_trust(cwd)
let current_group = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), cwd)
.await
.unwrap_or_else(|| cwd.to_path_buf());
.map(AbsolutePathBuf::into_path_buf)
.unwrap_or_else(|| cwd.clone().into_path_buf());
let mut groups = groups.into_iter().collect::<Vec<_>>();
groups.sort_by(|(left_group, left_entries), (right_group, right_entries)| {
let left_latest = left_entries
@@ -309,18 +319,19 @@ pub(crate) fn truncate_realtime_text_to_token_budget(text: &str, budget_tokens:
}
async fn build_workspace_section_with_user_root(
cwd: &Path,
cwd: &AbsolutePathBuf,
user_root: Option<PathBuf>,
) -> Option<String> {
let git_root = resolve_root_git_project_for_trust(cwd).await;
let cwd_tree = render_tree(cwd);
let cwd_path = cwd.as_path();
let git_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), cwd).await;
let cwd_tree = render_tree(cwd_path);
let git_root_tree = git_root
.as_ref()
.filter(|git_root| git_root.as_path() != cwd)
.and_then(|git_root| render_tree(git_root));
.filter(|git_root| git_root.as_path() != cwd_path)
.and_then(|git_root| render_tree(git_root.as_path()));
let user_root_tree = user_root
.as_ref()
.filter(|user_root| user_root.as_path() != cwd)
.filter(|user_root| user_root.as_path() != cwd_path)
.filter(|user_root| {
git_root
.as_ref()
@@ -333,8 +344,8 @@ async fn build_workspace_section_with_user_root(
}
let mut lines = vec![
format!("Current working directory: {}", cwd.display()),
format!("Working directory name: {}", file_name_string(cwd)),
format!("Current working directory: {}", cwd_path.display()),
format!("Working directory name: {}", file_name_string(cwd_path)),
];
if let Some(git_root) = &git_root {
@@ -465,14 +476,19 @@ async fn format_thread_group(
entries: Vec<&StoredThread>,
) -> Option<String> {
let latest = entries.first()?;
let group_label = if resolve_root_git_project_for_trust(latest.cwd.as_path())
.await
.is_some()
{
format!("### Git repo: {}", group.display())
} else {
format!("### Directory: {}", group.display())
};
let group_label =
if let Ok(latest_cwd) = AbsolutePathBuf::from_absolute_path(latest.cwd.as_path()) {
if resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &latest_cwd)
.await
.is_some()
{
format!("### Git repo: {}", group.display())
} else {
format!("### Directory: {}", group.display())
}
} else {
format!("### Directory: {}", group.display())
};
let mut lines = vec![
group_label,
format!("Recent sessions: {}", entries.len()),
+10 -7
View File
@@ -19,6 +19,8 @@ use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_thread_store::StoredThread;
use core_test_support::PathBufExt;
use core_test_support::PathExt;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::PathBuf;
@@ -234,7 +236,7 @@ fn fixed_section_budgets_apply_per_section_without_total_blob_truncation() {
async fn workspace_section_requires_meaningful_structure() {
let cwd = TempDir::new().expect("tempdir");
assert_eq!(
build_workspace_section_with_user_root(cwd.path(), /*user_root*/ None).await,
build_workspace_section_with_user_root(&cwd.path().abs(), /*user_root*/ None).await,
None
);
}
@@ -245,9 +247,10 @@ async fn workspace_section_includes_tree_when_entries_exist() {
fs::create_dir(cwd.path().join("docs")).expect("create docs dir");
fs::write(cwd.path().join("README.md"), "hello").expect("write readme");
let section = build_workspace_section_with_user_root(cwd.path(), /*user_root*/ None)
.await
.expect("workspace section");
let section =
build_workspace_section_with_user_root(&cwd.path().abs(), /*user_root*/ None)
.await
.expect("workspace section");
assert!(section.contains("Working directory tree:"));
assert!(section.contains("- docs/"));
assert!(section.contains("- README.md"));
@@ -267,7 +270,7 @@ async fn workspace_section_includes_user_root_tree_when_distinct() {
fs::create_dir_all(user_root.join("code")).expect("create user root child");
fs::write(user_root.join(".zshrc"), "export TEST=1").expect("write home file");
let section = build_workspace_section_with_user_root(cwd.as_path(), Some(user_root))
let section = build_workspace_section_with_user_root(&cwd.abs(), Some(user_root))
.await
.expect("workspace section");
assert!(section.contains("User root tree:"));
@@ -309,9 +312,9 @@ async fn recent_work_section_groups_threads_by_cwd() {
stored_thread(outside.to_string_lossy().as_ref(), "", "Inspect flaky test"),
];
let current_cwd = workspace_a;
let repo = fs::canonicalize(repo).expect("canonicalize repo");
let repo = repo.abs();
let section = build_recent_work_section(current_cwd.as_path(), &recent_threads)
let section = build_recent_work_section(&current_cwd.abs(), &recent_threads)
.await
.expect("recent work section");
assert!(section.contains(&format!("### Git repo: {}", repo.display())));
@@ -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;
@@ -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;
@@ -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,
@@ -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)?;
@@ -26,6 +26,7 @@ use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest;
use crate::tools::runtimes::unified_exec::UnifiedExecRuntime;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::unified_exec::ExecCommandRequest;
use crate::unified_exec::MAX_UNIFIED_EXEC_PROCESSES;
use crate::unified_exec::MAX_YIELD_TIME_MS;
@@ -50,6 +51,8 @@ use crate::unified_exec::process::OutputHandles;
use crate::unified_exec::process::SpawnLifecycleHandle;
use crate::unified_exec::process::UnifiedExecProcess;
use codex_config::types::ShellEnvironmentPolicy;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
use codex_protocol::protocol::ExecCommandSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::approx_token_count;
@@ -778,7 +781,19 @@ impl UnifiedExecProcessManager {
)
.await
.map(|result| (result.output, result.deferred_network_approval))
.map_err(|e| UnifiedExecError::create_process(format!("{e:?}")))
.map_err(|err| match err {
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => {
let output = *output;
let message = if output.aggregated_output.text.is_empty() {
let exit_code = output.exit_code;
format!("Process exited with code {exit_code}")
} else {
output.aggregated_output.text.clone()
};
UnifiedExecError::sandbox_denied(message, output)
}
other => UnifiedExecError::create_process(format!("{other:?}")),
})
}
pub(super) async fn collect_output_until_deadline(
@@ -161,3 +161,45 @@ async fn emits_deprecation_notice_for_web_search_feature_flag_values() -> anyhow
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn emits_deprecation_notice_for_use_legacy_landlock() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
let mut entries = BTreeMap::new();
entries.insert("use_legacy_landlock".to_string(), true);
let mut features = config.features.get().clone();
features.apply_map(&entries);
config
.features
.set(features)
.expect("test config should allow managed feature map updates");
});
let TestCodex { codex, .. } = builder.build(&server).await?;
let notice = wait_for_event_match(&codex, |event| match event {
EventMsg::DeprecationNotice(ev)
if ev.summary.contains("[features].use_legacy_landlock") =>
{
Some(ev.clone())
}
_ => None,
})
.await;
let DeprecationNoticeEvent { summary, details } = notice;
assert_eq!(
summary,
"`[features].use_legacy_landlock` is deprecated and will be removed soon.".to_string(),
);
assert_eq!(
details.as_deref(),
Some("Remove this setting to stop opting into the legacy Linux sandbox behavior."),
);
Ok(())
}
+110 -1
View File
@@ -9,10 +9,15 @@ use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use codex_config::Constrained;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core::sandboxing::SandboxPermissions;
use codex_features::Feature;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use core_test_support::assert_regex_match;
@@ -26,6 +31,7 @@ use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_sandbox;
use core_test_support::stdio_server_bin;
use core_test_support::test_codex::test_codex;
use regex_lite::Regex;
@@ -379,7 +385,7 @@ async fn sandbox_denied_shell_returns_original_output() -> Result<()> {
];
let args = json!({
"command": command,
"timeout_ms": 1_000,
"timeout_ms": 5_000,
});
let responses = vec![
@@ -449,6 +455,109 @@ async fn sandbox_denied_shell_returns_original_output() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_enforces_glob_deny_read_policy() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let read_only_policy = SandboxPolicy::new_read_only_policy();
let read_only_policy_for_config = read_only_policy.clone();
let mut builder = test_codex()
.with_model("gpt-5.1-codex")
.with_config(move |config| {
config.permissions.sandbox_policy = Constrained::allow_any(read_only_policy_for_config);
let mut file_system_sandbox_policy = FileSystemSandboxPolicy::default();
file_system_sandbox_policy
.entries
.push(FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: format!("{}/**/*.env", config.cwd.as_path().display()),
},
access: FileSystemAccessMode::None,
});
config.permissions.file_system_sandbox_policy = file_system_sandbox_policy;
});
let fixture = builder.build(&server).await?;
let fixture_dir = fixture.workspace_path("glob-deny-read");
fs::create_dir_all(&fixture_dir).context("create glob deny-read fixture directory")?;
let denied_path = fixture_dir.join("secret.env");
let allowed_path = fixture_dir.join("notes.txt");
let secret = "shell glob deny-read secret";
let allowed = "shell glob deny-read allowed";
fs::write(&denied_path, format!("{secret}\n")).context("write denied fixture")?;
fs::write(&allowed_path, format!("{allowed}\n")).context("write allowed fixture")?;
let call_id = "shell-glob-deny-read";
let command = vec![
"/bin/sh".to_string(),
"-c".to_string(),
"status=0; cat \"$1\" || status=$?; cat \"$2\"; exit \"$status\"".to_string(),
"sh".to_string(),
denied_path.to_string_lossy().into_owned(),
allowed_path.to_string_lossy().into_owned(),
];
let args = json!({
"command": command,
"timeout_ms": 1_000,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
];
let mock = mount_sse_sequence(&server, responses).await;
fixture
.submit_turn_with_policy("read the fixture files", read_only_policy)
.await?;
let output_text = mock
.function_call_output_text(call_id)
.context("shell output present")?;
let exit_code_line = output_text
.lines()
.next()
.context("exit code line present")?;
let exit_code = exit_code_line
.strip_prefix("Exit code: ")
.context("exit code prefix present")?
.trim()
.parse::<i32>()
.context("exit code is integer")?;
assert_ne!(
exit_code, 0,
"glob deny-read should surface a non-zero exit code"
);
assert!(
output_text.contains(allowed),
"expected allowed file contents in shell output: {output_text}"
);
assert!(
!output_text.contains(secret),
"denied file contents leaked into shell output: {output_text}"
);
let output_lower = output_text.to_lowercase();
let has_denial = output_lower.contains("permission denied")
|| output_lower.contains("operation not permitted")
|| output_lower.contains("read-only file system");
assert!(
has_denial,
"expected sandbox denial details in shell output: {output_text}"
);
Ok(())
}
async fn collect_tools(use_unified_exec: bool) -> Result<Vec<String>> {
let server = start_mock_server().await;
+126
View File
@@ -5,8 +5,13 @@ use std::sync::OnceLock;
use anyhow::Context;
use anyhow::Result;
use codex_config::Constrained;
use codex_exec_server::CreateDirectoryOptions;
use codex_features::Feature;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandSource;
@@ -2341,6 +2346,127 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> {
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let read_only_policy = SandboxPolicy::new_read_only_policy();
let read_only_policy_for_config = read_only_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
config.permissions.sandbox_policy = Constrained::allow_any(read_only_policy_for_config);
let mut file_system_sandbox_policy = FileSystemSandboxPolicy::default();
file_system_sandbox_policy
.entries
.push(FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: format!("{}/**/*.env", config.cwd.as_path().display()),
},
access: FileSystemAccessMode::None,
});
config.permissions.file_system_sandbox_policy = file_system_sandbox_policy;
});
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder.build(&server).await?;
let fixture_dir = cwd.path().join("glob-deny-read");
fs::create_dir_all(&fixture_dir).context("create glob deny-read fixture directory")?;
let denied_path = fixture_dir.join("secret.env");
let allowed_path = fixture_dir.join("notes.txt");
let secret = "unified exec glob deny-read secret";
let allowed = "unified exec glob deny-read allowed";
fs::write(&denied_path, format!("{secret}\n")).context("write denied fixture")?;
fs::write(&allowed_path, format!("{allowed}\n")).context("write allowed fixture")?;
let call_id = "uexec-glob-deny-read";
let cmd = format!(
"read_status=0; cat {denied_path:?} || read_status=$?; cat {allowed_path:?}; exit $read_status"
);
let args = serde_json::json!({
"cmd": cmd,
"yield_time_ms": 5_000,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
];
let request_log = mount_sse_sequence(&server, responses).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "read the fixture files".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
approvals_reviewer: None,
sandbox_policy: read_only_policy,
model: session_model,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let requests = request_log.requests();
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = requests
.into_iter()
.map(|request| request.body_json())
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let output = outputs.get(call_id).expect("missing output");
assert!(
output.exit_code.is_some_and(|code| code != 0),
"glob deny-read should surface a non-zero exit code: {output:?}"
);
assert!(
output.output.contains(allowed),
"expected allowed file contents in unified exec output: {output:?}"
);
assert!(
!output.output.contains(secret),
"denied file contents leaked into unified exec output: {output:?}"
);
let output_lower = output.output.to_lowercase();
let has_denial = output_lower.contains("permission denied")
|| output_lower.contains("operation not permitted")
|| output_lower.contains("read-only file system");
assert!(
has_denial,
"expected sandbox denial details in unified exec output: {output:?}"
);
Ok(())
}
#[cfg(target_os = "macos")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
+20 -1
View File
@@ -371,6 +371,12 @@ impl Features {
"image_detail_original" => {
continue;
}
"use_legacy_landlock" => {
self.record_legacy_usage_force(
"features.use_legacy_landlock",
Feature::UseLegacyLandlock,
);
}
_ => {}
}
match feature_for_key(k) {
@@ -456,6 +462,19 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option<String>
format!("`{label}` is deprecated because web search is enabled by default.");
(summary, Some(web_search_details().to_string()))
}
Feature::UseLegacyLandlock => {
let label = match alias {
"features.use_legacy_landlock" | "use_legacy_landlock" => {
"[features].use_legacy_landlock"
}
_ => alias,
};
let summary = format!("`{label}` is deprecated and will be removed soon.");
let details =
"Remove this setting to stop opting into the legacy Linux sandbox behavior."
.to_string();
(summary, Some(details))
}
_ => {
let label = if alias.contains('.') || alias.starts_with('[') {
alias.to_string()
@@ -728,7 +747,7 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::UseLegacyLandlock,
key: "use_legacy_landlock",
stage: Stage::Stable,
stage: Stage::Deprecated,
default_enabled: false,
},
FeatureSpec {
+24 -2
View File
@@ -42,8 +42,8 @@ fn default_enabled_features_are_stable() {
}
#[test]
fn use_legacy_landlock_is_stable_and_disabled_by_default() {
assert_eq!(Feature::UseLegacyLandlock.stage(), Stage::Stable);
fn use_legacy_landlock_is_deprecated_and_disabled_by_default() {
assert_eq!(Feature::UseLegacyLandlock.stage(), Stage::Deprecated);
assert_eq!(Feature::UseLegacyLandlock.default_enabled(), false);
}
@@ -166,6 +166,28 @@ fn image_generation_is_stable_and_enabled_by_default() {
assert_eq!(Feature::ImageGeneration.default_enabled(), true);
}
#[test]
fn use_legacy_landlock_config_records_deprecation_notice() {
let mut entries = BTreeMap::new();
entries.insert("use_legacy_landlock".to_string(), true);
let mut features = Features::with_defaults();
features.apply_map(&entries);
let usages = features.legacy_feature_usages().collect::<Vec<_>>();
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].alias, "features.use_legacy_landlock");
assert_eq!(usages[0].feature, Feature::UseLegacyLandlock);
assert_eq!(
usages[0].summary,
"`[features].use_legacy_landlock` is deprecated and will be removed soon."
);
assert_eq!(
usages[0].details.as_deref(),
Some("Remove this setting to stop opting into the legacy Linux sandbox behavior.")
);
}
#[test]
fn image_detail_original_is_a_removed_feature_key() {
assert_eq!(
+2
View File
@@ -9,6 +9,8 @@ readme = "README.md"
workspace = true
[dependencies]
codex-exec-server = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
futures = { workspace = true, features = ["alloc"] }
once_cell = { workspace = true }
+32 -14
View File
@@ -4,6 +4,7 @@ use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
use codex_exec_server::ExecutorFileSystem;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::future::join_all;
use schemars::JsonSchema;
@@ -618,30 +619,38 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
/// `[get_git_repo_root]`, but resolves to the root of the main
/// repository. Handles worktrees via filesystem inspection without invoking
/// the `git` executable.
pub async fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
let (repo_root, dot_git) = find_ancestor_git_entry(base)?;
if dot_git.is_dir() {
return Some(canonicalize_or_raw(repo_root));
pub async fn resolve_root_git_project_for_trust(
fs: &dyn ExecutorFileSystem,
cwd: &AbsolutePathBuf,
) -> Option<AbsolutePathBuf> {
let base = match fs.get_metadata(cwd, /*sandbox*/ None).await {
Ok(metadata) if metadata.is_directory => cwd.clone(),
_ => cwd.parent()?,
};
let (repo_root, dot_git) = find_ancestor_git_entry_with_fs(fs, &base).await?;
if fs
.get_metadata(&dot_git, /*sandbox*/ None)
.await
.ok()?
.is_directory
{
return Some(repo_root);
}
let git_dir_s = std::fs::read_to_string(&dot_git).ok()?;
let git_dir_s = fs.read_file_text(&dot_git, /*sandbox*/ None).await.ok()?;
let git_dir_rel = git_dir_s.trim().strip_prefix("gitdir:")?.trim();
if git_dir_rel.is_empty() {
return None;
}
let git_dir_path = canonicalize_or_raw(
AbsolutePathBuf::resolve_path_against_base(git_dir_rel, &repo_root).into_path_buf(),
);
let git_dir_path = AbsolutePathBuf::resolve_path_against_base(git_dir_rel, repo_root.as_path());
let worktrees_dir = git_dir_path.parent()?;
if worktrees_dir.file_name() != Some(OsStr::new("worktrees")) {
if worktrees_dir.as_path().file_name() != Some(OsStr::new("worktrees")) {
return None;
}
let common_dir = worktrees_dir.parent()?;
let main_repo_root = common_dir.parent()?;
Some(canonicalize_or_raw(main_repo_root.to_path_buf()))
common_dir.parent()
}
fn find_ancestor_git_entry(base_dir: &Path) -> Option<(PathBuf, PathBuf)> {
@@ -663,8 +672,17 @@ fn find_ancestor_git_entry(base_dir: &Path) -> Option<(PathBuf, PathBuf)> {
None
}
fn canonicalize_or_raw(path: PathBuf) -> PathBuf {
std::fs::canonicalize(&path).unwrap_or(path)
async fn find_ancestor_git_entry_with_fs(
fs: &dyn ExecutorFileSystem,
base_dir: &AbsolutePathBuf,
) -> Option<(AbsolutePathBuf, AbsolutePathBuf)> {
for dir in base_dir.ancestors() {
let dot_git = dir.join(".git");
if fs.get_metadata(&dot_git, /*sandbox*/ None).await.is_ok() {
return Some((dir, dot_git));
}
}
None
}
/// Returns a list of local git branches.
+2 -72
View File
@@ -1,6 +1,3 @@
use std::fmt;
use std::path::PathBuf;
mod apply;
mod branch;
mod errors;
@@ -16,6 +13,8 @@ pub use apply::extract_paths_from_patch;
pub use apply::parse_git_apply_output;
pub use apply::stage_paths;
pub use branch::merge_base_with_head;
pub use codex_protocol::models::GhostCommit;
pub use codex_protocol::protocol::GitSha;
pub use errors::GitToolingError;
pub use ghost_commits::CreateGhostCommitOptions;
pub use ghost_commits::GhostSnapshotConfig;
@@ -45,72 +44,3 @@ pub use info::local_git_branches;
pub use info::recent_commits;
pub use info::resolve_root_git_project_for_trust;
pub use platform::create_symlink;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
type CommitID = String;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)]
#[serde(transparent)]
#[ts(type = "string")]
pub struct GitSha(pub String);
impl GitSha {
pub fn new(sha: &str) -> Self {
Self(sha.to_string())
}
}
/// Details of a ghost commit created from a repository state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
pub struct GhostCommit {
id: CommitID,
parent: Option<CommitID>,
preexisting_untracked_files: Vec<PathBuf>,
preexisting_untracked_dirs: Vec<PathBuf>,
}
impl GhostCommit {
/// Create a new ghost commit wrapper from a raw commit ID and optional parent.
pub fn new(
id: CommitID,
parent: Option<CommitID>,
preexisting_untracked_files: Vec<PathBuf>,
preexisting_untracked_dirs: Vec<PathBuf>,
) -> Self {
Self {
id,
parent,
preexisting_untracked_files,
preexisting_untracked_dirs,
}
}
/// Commit ID for the snapshot.
pub fn id(&self) -> &str {
&self.id
}
/// Parent commit ID, if the repository had a `HEAD` at creation time.
pub fn parent(&self) -> Option<&str> {
self.parent.as_deref()
}
/// Untracked or ignored files that already existed when the snapshot was captured.
pub fn preexisting_untracked_files(&self) -> &[PathBuf] {
&self.preexisting_untracked_files
}
/// Untracked or ignored directories that already existed when the snapshot was captured.
pub fn preexisting_untracked_dirs(&self) -> &[PathBuf] {
&self.preexisting_untracked_dirs
}
}
impl fmt::Display for GhostCommit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.id)
}
}
+1
View File
@@ -20,6 +20,7 @@ clap = { workspace = true, features = ["derive"] }
codex-protocol = { workspace = true }
codex-sandboxing = { workspace = true }
codex-utils-absolute-path = { workspace = true }
globset = { workspace = true }
landlock = { workspace = true }
libc = { workspace = true }
seccompiler = { workspace = true }
+30 -11
View File
@@ -7,7 +7,7 @@ This crate is responsible for producing:
- the `codex-exec` CLI can check if its arg0 is `codex-linux-sandbox` and, if so, execute as if it were `codex-linux-sandbox`
- this should also be true of the `codex` multitool CLI
On Linux, the bubblewrap pipeline prefers the first `bwrap` found on `PATH`
On Linux, Codex prefers the first `bwrap` found on `PATH`
outside the current working directory whenever it is available. If `bwrap` is
present but too old to support
`--argv0`, the helper keeps using system bubblewrap and switches to a
@@ -23,7 +23,7 @@ commands that would enter the bubblewrap path.
**Current Behavior**
- Legacy `SandboxPolicy` / `sandbox_mode` configs remain supported.
- Bubblewrap is the default filesystem sandbox pipeline.
- Bubblewrap is the default filesystem sandbox.
- If `bwrap` is present on `PATH` outside the current working directory, the
helper uses it.
- If `bwrap` is present but too old to support `--argv0`, the helper uses a
@@ -46,32 +46,51 @@ commands that would enter the bubblewrap path.
- Split-only filesystem policies that do not round-trip through the legacy
`SandboxPolicy` model stay on bubblewrap so nested read-only or denied
carveouts are preserved.
- When the default bubblewrap pipeline is active, the helper applies `PR_SET_NO_NEW_PRIVS` and a
- When bubblewrap is active, the helper applies `PR_SET_NO_NEW_PRIVS` and a
seccomp network filter in-process.
- When the default bubblewrap pipeline is active, the filesystem is read-only by default via `--ro-bind / /`.
- When the default bubblewrap pipeline is active, writable roots are layered with `--bind <root> <root>`.
- When the default bubblewrap pipeline is active, protected subpaths under writable roots (for
- When bubblewrap is active, the filesystem is read-only by default via `--ro-bind / /`.
- When bubblewrap is active, writable roots are layered with `--bind <root> <root>`.
- When bubblewrap is active, protected subpaths under writable roots (for
example `.git`,
resolved `gitdir:`, and `.codex`) are re-applied as read-only via `--ro-bind`.
- When the default bubblewrap pipeline is active, overlapping split-policy
- When bubblewrap is active, overlapping split-policy
entries are applied in path-specificity order so narrower writable children
can reopen broader read-only or denied parents while narrower denied subpaths
still win. For example, `/repo = write`, `/repo/a = none`, `/repo/a/b = write`
keeps `/repo` writable, denies `/repo/a`, and reopens `/repo/a/b` as
writable again.
- When the default bubblewrap pipeline is active, symlink-in-path and non-existent protected paths inside
- When bubblewrap is active, unreadable glob entries are expanded before
launching the sandbox and matching files are masked in bubblewrap:
```text
Prefer: rg --files --hidden --no-ignore --glob <pattern> -- <search-root>
Fallback: internal globset walker when rg is not installed
Failure: any other rg failure aborts sandbox construction
```
Users can cap the scan depth per permissions profile:
```toml
[permissions.workspace.filesystem]
glob_scan_max_depth = 2
[permissions.workspace.filesystem.":project_roots"]
"**/*.env" = "none"
```
- When bubblewrap is active, symlink-in-path and non-existent protected paths inside
writable roots are blocked by mounting `/dev/null` on the symlink or first
missing component.
- When the default bubblewrap pipeline is active, the helper explicitly isolates the user namespace via
- When bubblewrap is active, the helper explicitly isolates the user namespace via
`--unshare-user` and the PID namespace via `--unshare-pid`.
- When the default bubblewrap pipeline is active and network is restricted without proxy routing, the helper also
- When bubblewrap is active and network is restricted without proxy routing, the helper also
isolates the network namespace via `--unshare-net`.
- In managed proxy mode, the helper uses `--unshare-net` plus an internal
TCP->UDS->TCP routing bridge so tool traffic reaches only configured proxy
endpoints.
- In managed proxy mode, after the bridge is live, seccomp blocks new
AF_UNIX/socketpair creation for the user command.
- When the default bubblewrap pipeline is active, it mounts a fresh `/proc` via `--proc /proc` by default, but
- When bubblewrap is active, it mounts a fresh `/proc` via `--proc /proc` by default, but
you can skip this in restrictive container environments with `--no-proc`.
**Notes**
+560 -82
View File
@@ -9,17 +9,27 @@
//! The overall Linux sandbox is composed of:
//! - seccomp + `PR_SET_NO_NEW_PRIVS` applied in-process, and
//! - bubblewrap used to construct the filesystem view before exec.
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs;
use std::fs::File;
use std::io;
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStringExt;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::WritableRoot;
use codex_utils_absolute_path::AbsolutePathBuf;
use globset::GlobBuilder;
use globset::GlobSet;
use globset::GlobSetBuilder;
/// Linux "platform defaults" that keep common system binaries and dynamic
/// libraries readable when `ReadOnlyAccess::Restricted` requests them.
@@ -37,6 +47,8 @@ const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
"/run/current-system/sw",
];
const MAX_UNREADABLE_GLOB_MATCHES: usize = 8192;
/// Options that control how bubblewrap is invoked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct BwrapOptions {
@@ -47,6 +59,11 @@ pub(crate) struct BwrapOptions {
pub mount_proc: bool,
/// How networking should be configured inside the bubblewrap sandbox.
pub network_mode: BwrapNetworkMode,
/// Optional maximum depth for expanding unreadable glob patterns with ripgrep.
///
/// Keep this uncapped by default so existing nested deny-read matches are
/// masked before the sandboxed command starts.
pub glob_scan_max_depth: Option<usize>,
}
impl Default for BwrapOptions {
@@ -54,6 +71,7 @@ impl Default for BwrapOptions {
Self {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
glob_scan_max_depth: None,
}
}
}
@@ -99,7 +117,11 @@ pub(crate) fn create_bwrap_command_args(
command_cwd: &Path,
options: BwrapOptions,
) -> Result<BwrapArgs> {
if file_system_sandbox_policy.has_full_disk_write_access() {
let unreadable_globs =
file_system_sandbox_policy.get_unreadable_globs_with_cwd(sandbox_policy_cwd);
// Full disk write normally skips bwrap, but unreadable glob patterns still
// need concrete bwrap masks for the matches expanded below.
if file_system_sandbox_policy.has_full_disk_write_access() && unreadable_globs.is_empty() {
return if options.network_mode == BwrapNetworkMode::FullAccess {
Ok(BwrapArgs {
args: command,
@@ -157,7 +179,13 @@ fn create_bwrap_flags(
let BwrapArgs {
args: filesystem_args,
preserved_files,
} = create_filesystem_args(file_system_sandbox_policy, sandbox_policy_cwd)?;
} = create_filesystem_args(
file_system_sandbox_policy,
sandbox_policy_cwd,
options
.glob_scan_max_depth
.or(file_system_sandbox_policy.glob_scan_max_depth),
)?;
let normalized_command_cwd = normalize_command_cwd_for_bwrap(command_cwd);
let mut args = Vec::new();
args.push("--new-session".to_string());
@@ -210,16 +238,41 @@ fn create_bwrap_flags(
fn create_filesystem_args(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
glob_scan_max_depth: Option<usize>,
) -> Result<BwrapArgs> {
let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd);
// Bubblewrap requires bind mount targets to exist. Skip missing writable
// roots so mixed-platform configs can keep harmless paths for other
// environments without breaking Linux command startup.
let writable_roots = file_system_sandbox_policy
let mut writable_roots = file_system_sandbox_policy
.get_writable_roots_with_cwd(cwd)
.into_iter()
.filter(|writable_root| writable_root.root.as_path().exists())
.collect::<Vec<_>>();
let unreadable_roots = file_system_sandbox_policy.get_unreadable_roots_with_cwd(cwd);
if writable_roots.is_empty()
&& file_system_sandbox_policy.has_full_disk_write_access()
&& !unreadable_globs.is_empty()
{
writable_roots.push(WritableRoot {
root: AbsolutePathBuf::from_absolute_path("/")?,
read_only_subpaths: Vec::new(),
});
}
let mut unreadable_roots = file_system_sandbox_policy
.get_unreadable_roots_with_cwd(cwd)
.into_iter()
.map(AbsolutePathBuf::into_path_buf)
.collect::<Vec<_>>();
// Bubblewrap can only mask concrete paths. Expand unreadable glob patterns
// to the existing matches we can see before constructing the mount overlay;
// core tool helpers still evaluate the original patterns directly at read time.
unreadable_roots.extend(
expand_unreadable_globs_with_ripgrep(&unreadable_globs, cwd, glob_scan_max_depth)?
.into_iter()
.map(AbsolutePathBuf::into_path_buf),
);
unreadable_roots.sort();
unreadable_roots.dedup();
let mut args = if file_system_sandbox_policy.has_full_disk_read_access() {
// Read-only root, then mount a minimal device tree.
@@ -303,10 +356,7 @@ fn create_filesystem_args(
allowed_write_paths.push(target);
}
}
let unreadable_paths: HashSet<PathBuf> = unreadable_roots
.iter()
.map(|path| path.as_path().to_path_buf())
.collect();
let unreadable_paths: HashSet<PathBuf> = unreadable_roots.iter().cloned().collect();
let mut sorted_writable_roots = writable_roots;
sorted_writable_roots.sort_by_key(|writable_root| path_depth(writable_root.root.as_path()));
// Mask only the unreadable ancestors that sit outside every writable root.
@@ -323,7 +373,7 @@ fn create_filesystem_args(
.iter()
.any(|root| root.starts_with(unreadable_root))
})
.map(|path| path.as_path().to_path_buf())
.cloned()
.collect();
unreadable_ancestors_of_writable_roots.sort_by_key(|path| path_depth(path));
@@ -343,7 +393,7 @@ fn create_filesystem_args(
// target parents before binding the narrower writable descendant.
if let Some(masking_root) = unreadable_roots
.iter()
.map(AbsolutePathBuf::as_path)
.map(PathBuf::as_path)
.filter(|unreadable_root| root.starts_with(unreadable_root))
.max_by_key(|unreadable_root| path_depth(unreadable_root))
{
@@ -366,12 +416,12 @@ fn create_filesystem_args(
}
read_only_subpaths.sort_by_key(|path| path_depth(path));
for subpath in read_only_subpaths {
append_read_only_subpath_args(&mut args, &subpath, &allowed_write_paths);
append_read_only_subpath_args(&mut args, &subpath, &allowed_write_paths)?;
}
let mut nested_unreadable_roots: Vec<PathBuf> = unreadable_roots
.iter()
.filter(|path| path.as_path().starts_with(root))
.map(|path| path.as_path().to_path_buf())
.filter(|path| path.starts_with(root))
.cloned()
.collect();
if let Some(target) = &symlink_target {
nested_unreadable_roots =
@@ -396,7 +446,7 @@ fn create_filesystem_args(
.iter()
.any(|root| unreadable_root.starts_with(root) || root.starts_with(unreadable_root))
})
.map(|path| path.as_path().to_path_buf())
.cloned()
.collect();
rootless_unreadable_roots.sort_by_key(|path| path_depth(path));
for unreadable_root in rootless_unreadable_roots {
@@ -414,6 +464,244 @@ fn create_filesystem_args(
})
}
fn expand_unreadable_globs_with_ripgrep(
patterns: &[String],
cwd: &Path,
max_depth: Option<usize>,
) -> Result<Vec<AbsolutePathBuf>> {
if patterns.is_empty() || max_depth == Some(0) {
return Ok(Vec::new());
}
// Group each pattern by the static path prefix before its first glob
// metacharacter. That keeps scans narrow, avoids searching from `/`, and
// lets one `rg --files` call handle all patterns under the same root.
let mut patterns_by_search_root: BTreeMap<AbsolutePathBuf, Vec<String>> = BTreeMap::new();
for pattern in patterns {
if let Some((search_root, glob)) = split_pattern_for_ripgrep(pattern, cwd)
&& search_root.as_path().is_dir()
{
patterns_by_search_root
.entry(search_root)
.or_default()
.push(glob);
}
}
// Record both the logical match and any canonical symlink target. The bwrap
// overlay needs the resolved target to prevent a readable symlink path from
// bypassing an unreadable glob match.
let mut expanded_paths = BTreeSet::new();
for (search_root, globs) in patterns_by_search_root {
for path in ripgrep_files(search_root.as_path(), &globs, max_depth)? {
if let Some(target) = canonical_target_if_symlinked_path(path.as_path()) {
expanded_paths.insert(AbsolutePathBuf::from_absolute_path_checked(target)?);
}
expanded_paths.insert(path);
if expanded_paths.len() > MAX_UNREADABLE_GLOB_MATCHES {
return Err(CodexErr::Fatal(format!(
"unreadable glob expansion for {} matched more than {MAX_UNREADABLE_GLOB_MATCHES} paths",
search_root.display()
)));
}
}
}
Ok(expanded_paths.into_iter().collect())
}
fn split_pattern_for_ripgrep(pattern: &str, cwd: &Path) -> Option<(AbsolutePathBuf, String)> {
// Resolve relative patterns once, then split at the first glob
// metacharacter. The prefix becomes the search root and the suffix stays as
// the ripgrep glob. Root-level glob scans are intentionally skipped because
// they are too broad for startup-time sandbox construction.
let absolute_pattern = AbsolutePathBuf::resolve_path_against_base(pattern, cwd);
let pattern = absolute_pattern.to_string_lossy();
let first_glob_index = pattern
.char_indices()
.find_map(|(index, ch)| matches!(ch, '*' | '?' | '[' | ']').then_some(index))?;
let static_prefix = &pattern[..first_glob_index];
if static_prefix.is_empty() || static_prefix == "/" {
return None;
}
let search_root_end = if static_prefix.ends_with('/') {
static_prefix.len() - 1
} else {
static_prefix.rfind('/').unwrap_or(0)
};
let search_root = if search_root_end == 0 {
PathBuf::from("/")
} else {
PathBuf::from(&pattern[..search_root_end])
};
let search_root = AbsolutePathBuf::from_absolute_path_checked(search_root).ok()?;
let glob = escape_unclosed_glob_classes(&pattern[search_root_end + 1..]);
(!glob.is_empty()).then_some((search_root, glob))
}
fn escape_unclosed_glob_classes(glob: &str) -> String {
// The filesystem policy accepts an unclosed `[` as a literal. Ripgrep treats
// that as invalid glob syntax, so escape only the unclosed class opener.
let mut escaped = String::with_capacity(glob.len());
let mut chars = glob.chars();
while let Some(ch) = chars.next() {
if ch != '[' {
escaped.push(ch);
continue;
}
let mut class = String::new();
let mut closed = false;
for class_ch in chars.by_ref() {
if class_ch == ']' {
closed = true;
break;
}
class.push(class_ch);
}
if closed {
escaped.push('[');
escaped.push_str(&class);
escaped.push(']');
} else {
escaped.push_str(r"\[");
escaped.push_str(&class);
}
}
escaped
}
fn ripgrep_files(
search_root: &Path,
globs: &[String],
max_depth: Option<usize>,
) -> Result<Vec<AbsolutePathBuf>> {
// Use `rg --files` rather than shell expansion so dotfiles and ignored files
// are still considered. A status 1 with no stderr is ripgrep's "no matches"
// case, not a sandbox construction error.
let mut command = Command::new("rg");
command
.arg("--files")
.arg("--hidden")
.arg("--no-ignore")
.arg("--null");
if let Some(max_depth) = max_depth {
command.arg("--max-depth").arg(max_depth.to_string());
}
for glob in globs {
command.arg("--glob").arg(glob);
}
command.arg("--").arg(search_root);
/*
* Prefer ripgrep for unreadable glob expansion because it is fast and
* already implements the file-walking semantics we want here: include
* dotfiles, ignore ignore files, and do not recurse through symlinked
* directories. If `rg` is not installed in the runtime environment, fall
* back to the internal globset walker so sandbox construction still masks
* matching paths. Other ripgrep failures stay fatal so deny-read does not
* silently weaken.
*/
let output = match command.output() {
Ok(output) => output,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return glob_files(search_root, globs, max_depth);
}
Err(err) => return Err(err.into()),
};
if !output.status.success() {
if output.status.code() == Some(1) && output.stderr.is_empty() {
return Ok(Vec::new());
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(CodexErr::Fatal(format!(
"ripgrep unreadable glob scan failed for {}: {stderr}",
search_root.display()
)));
}
let paths = output
.stdout
.split(|byte| *byte == b'\0')
.filter(|path| !path.is_empty())
.map(|path| {
let path = PathBuf::from(OsString::from_vec(path.to_vec()));
if path.is_absolute() {
path
} else {
search_root.join(path)
}
})
.map(AbsolutePathBuf::from_absolute_path_checked)
.collect::<io::Result<Vec<_>>>()?;
Ok(paths)
}
fn glob_files(
search_root: &Path,
globs: &[String],
max_depth: Option<usize>,
) -> Result<Vec<AbsolutePathBuf>> {
let mut builder = GlobSetBuilder::new();
for glob in globs {
let glob = GlobBuilder::new(glob)
.literal_separator(true)
.allow_unclosed_class(true)
.build()
.map_err(|err| {
CodexErr::Fatal(format!(
"unreadable glob pattern is invalid for {}: {err}",
search_root.display()
))
})?;
builder.add(glob);
}
let glob_set = builder.build().map_err(|err| {
CodexErr::Fatal(format!(
"unreadable glob matcher failed for {}: {err}",
search_root.display()
))
})?;
let mut paths = Vec::new();
collect_glob_files(search_root, search_root, &glob_set, max_depth, &mut paths)?;
Ok(paths)
}
fn collect_glob_files(
search_root: &Path,
dir: &Path,
glob_set: &GlobSet,
remaining_depth: Option<usize>,
paths: &mut Vec<AbsolutePathBuf>,
) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
let relative = path.strip_prefix(search_root).unwrap_or(path.as_path());
if (file_type.is_file() || file_type.is_symlink()) && glob_set.is_match(relative) {
paths.push(AbsolutePathBuf::from_absolute_path_checked(&path)?);
}
if !file_type.is_dir() {
continue;
}
let remaining_depth = match remaining_depth {
Some(0 | 1) => continue,
Some(depth) => Some(depth - 1),
None => None,
};
collect_glob_files(search_root, &path, glob_set, remaining_depth, paths)?;
}
Ok(())
}
fn path_to_string(path: &Path) -> String {
path.to_string_lossy().to_string()
}
@@ -423,6 +711,9 @@ fn path_depth(path: &Path) -> usize {
}
fn canonical_target_if_symlinked_path(path: &Path) -> Option<PathBuf> {
// Return the fully resolved target only when some path component is a
// symlink. Callers use this to bind/mask the real filesystem location while
// leaving ordinary paths in their logical form.
let mut current = PathBuf::new();
for component in path.components() {
use std::path::Component;
@@ -498,17 +789,19 @@ fn append_read_only_subpath_args(
args: &mut Vec<String>,
subpath: &Path,
allowed_write_paths: &[PathBuf],
) {
if let Some(target) = canonical_target_for_symlink_in_path(subpath, allowed_write_paths) {
// bwrap takes `--ro-bind <source> <destination>`. Use the resolved target
// for both operands so a protected symlinked directory is remounted
// read-only in place instead of binding onto the symlink path itself.
let mount_source = path_to_string(&target);
let mount_destination = path_to_string(&target);
args.push("--ro-bind".to_string());
args.push(mount_source);
args.push(mount_destination);
return;
) -> Result<()> {
if let Some(symlink) = first_writable_symlink_component_in_path(subpath, allowed_write_paths) {
/*
* A read-only carveout under a writable symlink cannot be made reliable
* with bwrap path arguments. Binding the symlink's current target would
* only protect a startup-time snapshot; the sandboxed process could
* replace the writable symlink before it reads through the logical path.
*/
return Err(CodexErr::Fatal(format!(
"cannot enforce sandbox read-only path {} because it crosses writable symlink {}",
subpath.display(),
symlink.display()
)));
}
if !subpath.exists() {
@@ -519,7 +812,7 @@ fn append_read_only_subpath_args(
args.push("/dev/null".to_string());
args.push(path_to_string(&first_missing_component));
}
return;
return Ok(());
}
if is_within_allowed_write_paths(subpath, allowed_write_paths) {
@@ -527,6 +820,7 @@ fn append_read_only_subpath_args(
args.push(path_to_string(subpath));
args.push(path_to_string(subpath));
}
Ok(())
}
fn append_unreadable_root_args(
@@ -535,16 +829,21 @@ fn append_unreadable_root_args(
unreadable_root: &Path,
allowed_write_paths: &[PathBuf],
) -> Result<()> {
if let Some(target) = canonical_target_for_symlink_in_path(unreadable_root, allowed_write_paths)
if let Some(symlink) =
first_writable_symlink_component_in_path(unreadable_root, allowed_write_paths)
{
// Apply unreadable handling to the resolved symlink target, not the
// logical symlink path, to avoid file-vs-directory bind mismatches.
return append_existing_unreadable_path_args(
args,
preserved_files,
&target,
allowed_write_paths,
);
/*
* Deny-read masks must fail closed when the protected path crosses a
* symlink that remains writable to the sandboxed process. Resolving and
* masking the symlink's current target is a TOCTTOU snapshot: bwrap would
* protect the old target while the logical path could later point
* somewhere else.
*/
return Err(CodexErr::Fatal(format!(
"cannot enforce sandbox deny-read path {} because it crosses writable symlink {}",
unreadable_root.display(),
symlink.display()
)));
}
if !unreadable_root.exists() {
@@ -621,10 +920,16 @@ fn is_within_allowed_write_paths(path: &Path, allowed_write_paths: &[PathBuf]) -
.any(|root| path.starts_with(root))
}
fn canonical_target_for_symlink_in_path(
fn first_writable_symlink_component_in_path(
target_path: &Path,
allowed_write_paths: &[PathBuf],
) -> Option<PathBuf> {
/*
* Walk the logical path and report the first symlink component that lives
* under a writable root. These symlinks are mutable from inside the sandbox,
* so any mount or mask based on their resolved target would be racing a path
* the sandboxed process can change.
*/
let mut current = PathBuf::new();
for component in target_path.components() {
@@ -651,7 +956,7 @@ fn canonical_target_for_symlink_in_path(
if metadata.file_type().is_symlink()
&& is_within_allowed_write_paths(&current, allowed_write_paths)
{
return fs::canonicalize(&current).ok();
return Some(current);
}
}
@@ -703,6 +1008,26 @@ mod tests {
use pretty_assertions::assert_eq;
use tempfile::TempDir;
const NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH: Option<usize> = None;
#[test]
fn default_unreadable_glob_scan_has_no_depth_cap() {
assert_eq!(BwrapOptions::default().glob_scan_max_depth, None);
}
fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry {
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern { pattern },
access: FileSystemAccessMode::None,
}
}
fn default_policy_with_unreadable_glob(pattern: String) -> FileSystemSandboxPolicy {
let mut policy = FileSystemSandboxPolicy::default();
policy.entries.push(unreadable_glob_entry(pattern));
policy
}
#[test]
fn full_disk_write_full_network_returns_unwrapped_command() {
let command = vec!["/bin/true".to_string()];
@@ -714,6 +1039,7 @@ mod tests {
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
..Default::default()
},
)
.expect("create bwrap args");
@@ -732,6 +1058,7 @@ mod tests {
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::ProxyOnly,
..Default::default()
},
)
.expect("create bwrap args");
@@ -755,6 +1082,42 @@ mod tests {
);
}
#[test]
fn full_disk_write_with_unreadable_glob_still_wraps_and_masks_match() {
if !ripgrep_available() {
return;
}
let temp_dir = TempDir::new().expect("temp dir");
let root_env = temp_dir.path().join(".env");
std::fs::write(&root_env, "secret").expect("write env");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
},
unreadable_glob_entry(format!("{}/**/*.env", temp_dir.path().display())),
]);
let command = vec!["/bin/true".to_string()];
let args = create_bwrap_command_args(
command.clone(),
&policy,
temp_dir.path(),
temp_dir.path(),
BwrapOptions::default(),
)
.expect("create bwrap args");
assert_ne!(
args.args, command,
"full-write policy with unreadable globs must still use bwrap"
);
assert_file_masked(&args.args, &root_env);
}
#[cfg(unix)]
#[test]
fn restricted_policy_chdirs_to_canonical_command_cwd() {
@@ -859,7 +1222,9 @@ mod tests {
},
]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
assert!(args.args.windows(3).any(|window| {
window == ["--bind", real_root_str.as_str(), real_root_str.as_str()]
@@ -902,7 +1267,9 @@ mod tests {
access: FileSystemAccessMode::Write,
}]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
assert!(args.args.windows(3).any(|window| {
window
@@ -924,7 +1291,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn protected_symlinked_directory_subpaths_bind_target_read_only() {
fn protected_symlinked_directory_subpaths_fail_closed() {
let temp_dir = TempDir::new().expect("temp dir");
let root = temp_dir.path().join("root");
let agents_target = root.join("agents-target");
@@ -934,28 +1301,26 @@ mod tests {
let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root");
let agents_link_str = path_to_string(&agents_link);
let agents_target_str = path_to_string(&agents_target);
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path { path: root },
access: FileSystemAccessMode::Write,
}]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
let err =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect_err("protected symlinked subpath should fail closed");
let message = err.to_string();
assert!(args.args.windows(3).any(|window| {
window
== [
"--ro-bind",
agents_target_str.as_str(),
agents_target_str.as_str(),
]
}));
assert!(!args.args.iter().any(|arg| arg == agents_link_str.as_str()));
assert!(
message.contains("cannot enforce sandbox read-only path"),
"{message}"
);
assert!(message.contains(&agents_link_str), "{message}");
}
#[cfg(unix)]
#[test]
fn symlinked_writable_roots_mask_nested_symlink_escape_paths_without_binding_targets() {
fn symlinked_writable_roots_nested_symlink_escape_paths_fail_closed() {
let temp_dir = TempDir::new().expect("temp dir");
let real_root = temp_dir.path().join("real");
let link_root = temp_dir.path().join("link");
@@ -971,7 +1336,6 @@ mod tests {
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
let link_private = link_root.join("linked-private");
let real_linked_private_str = path_to_string(&linked_private);
let outside_str = path_to_string(&outside);
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: link_root },
@@ -983,25 +1347,16 @@ mod tests {
},
]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
let err =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect_err("deny-read path crossing writable symlink should fail closed");
let message = err.to_string();
assert!(args.args.windows(6).any(|window| {
window
== [
"--perms",
"000",
"--tmpfs",
outside_str.as_str(),
"--remount-ro",
outside_str.as_str(),
]
}));
assert!(
!args
.args
.iter()
.any(|arg| arg == real_linked_private_str.as_str())
message.contains("cannot enforce sandbox deny-read path"),
"{message}"
);
assert!(message.contains(&real_linked_private_str), "{message}");
}
#[test]
@@ -1022,8 +1377,12 @@ mod tests {
exclude_slash_tmp: true,
};
let args = create_filesystem_args(&FileSystemSandboxPolicy::from(&policy), temp_dir.path())
.expect("filesystem args");
let args = create_filesystem_args(
&FileSystemSandboxPolicy::from(&policy),
temp_dir.path(),
NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH,
)
.expect("filesystem args");
let existing_root = path_to_string(&existing_root);
let missing_root = path_to_string(&missing_root);
@@ -1052,6 +1411,7 @@ mod tests {
let args = create_filesystem_args(
&FileSystemSandboxPolicy::from(&sandbox_policy),
Path::new("/"),
NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH,
)
.expect("bwrap fs args");
assert_eq!(
@@ -1100,8 +1460,12 @@ mod tests {
network_access: false,
};
let args = create_filesystem_args(&FileSystemSandboxPolicy::from(&policy), temp_dir.path())
.expect("filesystem args");
let args = create_filesystem_args(
&FileSystemSandboxPolicy::from(&policy),
temp_dir.path(),
NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH,
)
.expect("filesystem args");
assert_eq!(args.args[0..4], ["--tmpfs", "/", "--dev", "/dev"]);
@@ -1130,8 +1494,12 @@ mod tests {
// `ReadOnlyAccess::Restricted` always includes `cwd` as a readable
// root. Using `"/"` here would intentionally collapse to broad read
// access, so use a non-root cwd to exercise the restricted path.
let args = create_filesystem_args(&FileSystemSandboxPolicy::from(&policy), temp_dir.path())
.expect("filesystem args");
let args = create_filesystem_args(
&FileSystemSandboxPolicy::from(&policy),
temp_dir.path(),
NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH,
)
.expect("filesystem args");
assert!(
args.args
@@ -1171,7 +1539,9 @@ mod tests {
},
]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
assert!(args.args.windows(3).any(|window| {
window
@@ -1248,7 +1618,9 @@ mod tests {
},
]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let docs_str = path_to_string(docs.as_path());
let docs_public_str = path_to_string(docs_public.as_path());
let docs_ro_index = args
@@ -1300,7 +1672,9 @@ mod tests {
},
]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let blocked_str = path_to_string(blocked.as_path());
let allowed_str = path_to_string(allowed.as_path());
let blocked_none_index = args
@@ -1367,7 +1741,9 @@ mod tests {
},
]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let blocked_str = path_to_string(blocked.as_path());
let allowed_dir_str = path_to_string(allowed_dir.as_path());
let allowed_file_str = path_to_string(allowed_file.as_path());
@@ -1442,7 +1818,9 @@ mod tests {
},
]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let blocked_none_index = args
.args
.windows(4)
@@ -1487,7 +1865,9 @@ mod tests {
},
]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let blocked_str = path_to_string(blocked.as_path());
assert!(
@@ -1529,7 +1909,9 @@ mod tests {
},
]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let blocked_file_str = path_to_string(blocked_file.as_path());
assert_eq!(args.preserved_files.len(), 1);
@@ -1540,4 +1922,100 @@ mod tests {
&& window[4] == blocked_file_str
}));
}
#[test]
fn unreadable_globs_expand_existing_matches_with_configured_depth() {
if !ripgrep_available() {
return;
}
let temp_dir = TempDir::new().expect("temp dir");
let root_env = temp_dir.path().join(".env");
let nested_env = temp_dir.path().join("app").join(".env");
let too_deep_env = temp_dir.path().join("app").join("deep").join(".env");
std::fs::create_dir_all(too_deep_env.parent().expect("parent")).expect("create parent");
std::fs::write(temp_dir.path().join(".gitignore"), ".env\n").expect("write gitignore");
std::fs::write(&root_env, "secret").expect("write root env");
std::fs::write(&nested_env, "secret").expect("write nested env");
std::fs::write(&too_deep_env, "secret").expect("write deep env");
let policy =
default_policy_with_unreadable_glob(format!("{}/**/*.env", temp_dir.path().display()));
let args =
create_filesystem_args(&policy, temp_dir.path(), Some(2)).expect("filesystem args");
assert_file_masked(&args.args, &root_env);
assert_file_masked(&args.args, &nested_env);
assert!(
!args
.args
.iter()
.any(|arg| arg == &path_to_string(&too_deep_env)),
"max depth should keep deeper matches out of bwrap args: {:#?}",
args.args
);
}
#[cfg(unix)]
#[test]
fn unreadable_globs_add_canonical_targets_for_symlink_matches() {
if !ripgrep_available() {
return;
}
let temp_dir = TempDir::new().expect("temp dir");
let real_root = temp_dir.path().join("real");
let link_root = temp_dir.path().join("link");
let real_secret = real_root.join("secret.env");
std::fs::create_dir_all(&real_root).expect("create real root");
std::fs::write(&real_secret, "secret").expect("write real secret");
std::os::unix::fs::symlink(&real_root, &link_root).expect("create symlink");
let policy =
default_policy_with_unreadable_glob(format!("{}/**/*.env", link_root.display()));
let args =
create_filesystem_args(&policy, temp_dir.path(), Some(2)).expect("filesystem args");
assert_file_masked(&args.args, &real_secret);
}
#[test]
fn root_prefix_unreadable_globs_are_too_broad_for_linux_expansion() {
assert_eq!(
split_pattern_for_ripgrep("/**/*.env", Path::new("/tmp")),
None
);
}
#[test]
fn unclosed_character_classes_are_escaped_for_ripgrep() {
let (search_root, glob) =
split_pattern_for_ripgrep("/tmp/[*.env", Path::new("/")).expect("split pattern");
assert_eq!(search_root.as_path(), Path::new("/tmp"));
assert_eq!(glob, r"\[*.env");
}
fn ripgrep_available() -> bool {
Command::new("rg")
.arg("--version")
.output()
.is_ok_and(|output| output.status.success())
}
/// Assert that `path` is masked due to a bwrap arg sequence like:
///
/// `bwrap ... --perms 000 --ro-bind-data FD PATH`
fn assert_file_masked(args: &[String], path: &Path) {
let path = path_to_string(path);
assert!(
args.windows(5).any(|window| {
window[0] == "--perms"
&& window[1] == "000"
&& window[2] == "--ro-bind-data"
&& window[4] == path
}),
"expected file mask for {path}: {args:#?}"
);
}
}
@@ -427,6 +427,7 @@ fn run_bwrap_with_proc_fallback(
let options = BwrapOptions {
mount_proc,
network_mode,
..Default::default()
};
let mut bwrap_args = build_bwrap_argv(
inner,
@@ -547,6 +548,7 @@ fn build_preflight_bwrap_argv(
BwrapOptions {
mount_proc: true,
network_mode,
..Default::default()
},
)
}
@@ -48,6 +48,7 @@ fn inserts_bwrap_argv0_before_command_separator() {
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
..Default::default()
},
)
.args;
@@ -90,6 +91,7 @@ fn rewrites_inner_command_path_when_bwrap_lacks_argv0() {
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
..Default::default()
},
)
.args;
@@ -157,6 +159,7 @@ fn inserts_unshare_net_when_network_isolation_requested() {
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::Isolated,
..Default::default()
},
)
.args;
@@ -174,6 +177,7 @@ fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::ProxyOnly,
..Default::default()
},
)
.args;
-1
View File
@@ -16,7 +16,6 @@ chardetng = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
codex-async-utils = { workspace = true }
codex-execpolicy = { workspace = true }
codex-git-utils = { workspace = true }
codex-network-proxy = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-image = { workspace = true }
+56 -1
View File
@@ -1,5 +1,7 @@
use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
use codex_utils_image::PromptImageMode;
@@ -25,7 +27,6 @@ use crate::protocol::SandboxPolicy;
use crate::protocol::WritableRoot;
use crate::user_input::UserInput;
use codex_execpolicy::Policy;
use codex_git_utils::GhostCommit;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_image::ImageProcessingError;
use schemars::JsonSchema;
@@ -45,6 +46,60 @@ static SANDBOX_MODE_READ_ONLY_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
.unwrap_or_else(|err| panic!("read-only sandbox template must parse: {err}"))
});
type CommitID = String;
/// Details of a ghost commit created from a repository state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
pub struct GhostCommit {
id: CommitID,
parent: Option<CommitID>,
preexisting_untracked_files: Vec<PathBuf>,
preexisting_untracked_dirs: Vec<PathBuf>,
}
impl GhostCommit {
/// Create a new ghost commit wrapper from a raw commit ID and optional parent.
pub fn new(
id: CommitID,
parent: Option<CommitID>,
preexisting_untracked_files: Vec<PathBuf>,
preexisting_untracked_dirs: Vec<PathBuf>,
) -> Self {
Self {
id,
parent,
preexisting_untracked_files,
preexisting_untracked_dirs,
}
}
/// Commit ID for the snapshot.
pub fn id(&self) -> &str {
&self.id
}
/// Parent commit ID, if the repository had a `HEAD` at creation time.
pub fn parent(&self) -> Option<&str> {
self.parent.as_deref()
}
/// Untracked or ignored files that already existed when the snapshot was captured.
pub fn preexisting_untracked_files(&self) -> &[PathBuf] {
&self.preexisting_untracked_files
}
/// Untracked or ignored directories that already existed when the snapshot was captured.
pub fn preexisting_untracked_dirs(&self) -> &[PathBuf] {
&self.preexisting_untracked_dirs
}
}
impl fmt::Display for GhostCommit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.id)
}
}
/// Controls the per-command sandbox override requested by a shell-like tool call.
#[derive(
Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
+8
View File
@@ -138,6 +138,9 @@ pub enum FileSystemSandboxKind {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
pub struct FileSystemSandboxPolicy {
pub kind: FileSystemSandboxKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub glob_scan_max_depth: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<FileSystemSandboxEntry>,
}
@@ -257,6 +260,7 @@ impl Default for FileSystemSandboxPolicy {
fn default() -> Self {
Self {
kind: FileSystemSandboxKind::Restricted,
glob_scan_max_depth: None,
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
@@ -271,6 +275,7 @@ impl FileSystemSandboxPolicy {
pub fn unrestricted() -> Self {
Self {
kind: FileSystemSandboxKind::Unrestricted,
glob_scan_max_depth: None,
entries: Vec::new(),
}
}
@@ -278,6 +283,7 @@ impl FileSystemSandboxPolicy {
pub fn external_sandbox() -> Self {
Self {
kind: FileSystemSandboxKind::ExternalSandbox,
glob_scan_max_depth: None,
entries: Vec::new(),
}
}
@@ -285,6 +291,7 @@ impl FileSystemSandboxPolicy {
pub fn restricted(entries: Vec<FileSystemSandboxEntry>) -> Self {
Self {
kind: FileSystemSandboxKind::Restricted,
glob_scan_max_depth: None,
entries,
}
}
@@ -317,6 +324,7 @@ impl FileSystemSandboxPolicy {
if !matches!(rebuilt.kind, FileSystemSandboxKind::Restricted) {
return rebuilt;
}
rebuilt.glob_scan_max_depth = existing.glob_scan_max_depth;
for deny_entry in existing
.entries
+11 -1
View File
@@ -49,7 +49,6 @@ use crate::request_permissions::RequestPermissionsEvent;
use crate::request_permissions::RequestPermissionsResponse;
use crate::request_user_input::RequestUserInputResponse;
use crate::user_input::UserInput;
use codex_git_utils::GitSha;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -103,6 +102,17 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = "<realtime_conversation>";
pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = "</realtime_conversation>";
pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:";
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)]
#[serde(transparent)]
#[ts(type = "string")]
pub struct GitSha(pub String);
impl GitSha {
pub fn new(sha: &str) -> Self {
Self(sha.to_string())
}
}
/// Submission Queue Entry - requests from user
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Submission {
+1
View File
@@ -18,6 +18,7 @@ codex-utils-absolute-path = { workspace = true }
dunce = { workspace = true }
libc = { workspace = true }
serde_json = { workspace = true }
regex-lite = { workspace = true }
tracing = { workspace = true, features = ["log"] }
url = { workspace = true }
which = { workspace = true }
-3
View File
@@ -34,9 +34,6 @@ impl From<SandboxTransformError> for CodexErr {
SandboxTransformError::MissingLinuxSandboxExecutable => {
CodexErr::LandlockSandboxExecutableNotProvided
}
SandboxTransformError::UnreadableGlobPatternsUnsupported => {
CodexErr::UnsupportedOperation(err.to_string())
}
#[cfg(target_os = "linux")]
SandboxTransformError::Wsl1UnsupportedForBubblewrap => {
CodexErr::UnsupportedOperation(crate::bwrap::WSL1_BWRAP_WARNING.to_string())
-14
View File
@@ -109,7 +109,6 @@ pub struct SandboxTransformRequest<'a> {
#[derive(Debug)]
pub enum SandboxTransformError {
MissingLinuxSandboxExecutable,
UnreadableGlobPatternsUnsupported,
#[cfg(target_os = "linux")]
Wsl1UnsupportedForBubblewrap,
#[cfg(not(target_os = "macos"))]
@@ -122,10 +121,6 @@ impl std::fmt::Display for SandboxTransformError {
Self::MissingLinuxSandboxExecutable => {
write!(f, "missing codex-linux-sandbox executable path")
}
Self::UnreadableGlobPatternsUnsupported => write!(
f,
"platform sandbox backend cannot enforce unreadable glob patterns"
),
#[cfg(target_os = "linux")]
Self::Wsl1UnsupportedForBubblewrap => write!(f, "{WSL1_BWRAP_WARNING}"),
#[cfg(not(target_os = "macos"))]
@@ -201,15 +196,6 @@ impl SandboxManager {
);
let effective_network_policy =
effective_network_sandbox_policy(network_policy, additional_permissions.as_ref());
if matches!(
sandbox,
SandboxType::MacosSeatbelt | SandboxType::LinuxSeccomp
) && !effective_file_system_policy
.get_unreadable_globs_with_cwd(sandbox_policy_cwd)
.is_empty()
{
return Err(SandboxTransformError::UnreadableGlobPatternsUnsupported);
}
let mut argv = Vec::with_capacity(1 + command.args.len());
argv.push(command.program);
argv.extend(command.args.into_iter().map(OsString::from));
+148
View File
@@ -9,6 +9,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::ffi::CStr;
use std::path::Path;
use std::path::PathBuf;
@@ -379,6 +380,150 @@ fn build_seatbelt_access_policy(
}
}
fn build_seatbelt_unreadable_glob_policy(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
) -> String {
// Seatbelt does not understand the filesystem policy's glob syntax directly.
// Convert each unreadable pattern into an anchored regex deny rule and apply
// it to both reads and unlink-style writes so a denied path cannot be probed
// through destructive filesystem operations.
let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd);
if unreadable_globs.is_empty() {
return String::new();
}
let mut policy_components = Vec::new();
for pattern in unreadable_globs {
let mut regexes = BTreeSet::new();
if let Some(regex) = seatbelt_regex_for_unreadable_glob(&pattern) {
regexes.insert(regex);
}
if let Some(pattern) = canonicalize_glob_static_prefix_for_sandbox(&pattern)
&& let Some(regex) = seatbelt_regex_for_unreadable_glob(&pattern)
{
regexes.insert(regex);
}
for regex in regexes {
let regex = regex.replace('"', "\\\"");
policy_components.push(format!(r#"(deny file-read* (regex #"{regex}"))"#));
policy_components.push(format!(r#"(deny file-write-unlink (regex #"{regex}"))"#));
}
}
policy_components.join("\n")
}
fn canonicalize_glob_static_prefix_for_sandbox(pattern: &str) -> Option<String> {
let first_glob_index = pattern
.char_indices()
.find_map(|(index, ch)| matches!(ch, '*' | '?' | '[' | ']').then_some(index));
let Some(first_glob_index) = first_glob_index else {
return normalize_path_for_sandbox(Path::new(pattern))
.map(|path| path.to_string_lossy().to_string());
};
let static_prefix = &pattern[..first_glob_index];
let prefix_end = if static_prefix.ends_with('/') {
static_prefix.len() - 1
} else {
static_prefix.rfind('/').unwrap_or(0)
};
if prefix_end == 0 {
return None;
}
let root = normalize_path_for_sandbox(Path::new(&pattern[..prefix_end]))?;
let root = root.to_string_lossy();
let suffix = &pattern[prefix_end..];
let normalized_pattern = format!("{root}{suffix}");
(normalized_pattern != pattern).then_some(normalized_pattern)
}
fn seatbelt_regex_for_unreadable_glob(pattern: &str) -> Option<String> {
if pattern.is_empty() {
return None;
}
// Translate the supported git-style glob subset into a Seatbelt regex:
// `*` and `?` stay within one path component, `**/` can consume zero or
// more components, and closed character classes remain character classes.
// A pattern with no glob metacharacters is treated as exact path plus subtree.
let mut regex = String::from("^");
let mut chars = pattern.chars().collect::<VecDeque<_>>();
let mut saw_glob = false;
while let Some(ch) = chars.pop_front() {
match ch {
'*' => {
saw_glob = true;
if chars.front() == Some(&'*') {
chars.pop_front();
if chars.front() == Some(&'/') {
chars.pop_front();
regex.push_str("(.*/)?");
} else {
regex.push_str(".*");
}
} else {
regex.push_str("[^/]*");
}
}
'?' => {
saw_glob = true;
regex.push_str("[^/]");
}
'[' => {
saw_glob = true;
let mut class = Vec::new();
let mut closed = false;
while let Some(class_ch) = chars.pop_front() {
if class_ch == ']' {
closed = true;
break;
}
class.push(class_ch);
}
if !closed {
regex.push_str("\\[");
for class_ch in class.into_iter().rev() {
chars.push_front(class_ch);
}
continue;
}
regex.push('[');
let mut class_chars = class.into_iter();
if let Some(first) = class_chars.next() {
match first {
'!' => regex.push('^'),
'^' => regex.push_str("\\^"),
_ => regex.push(first),
}
}
for class_ch in class_chars {
match class_ch {
'\\' => regex.push_str("\\\\"),
_ => regex.push(class_ch),
}
}
regex.push(']');
}
']' => {
saw_glob = true;
regex.push_str("\\]");
}
_ => regex.push_str(&regex_lite::escape(&ch.to_string())),
}
}
if !saw_glob {
regex.push_str("(/.*)?");
}
regex.push('$');
Some(regex)
}
#[cfg_attr(not(test), allow(dead_code))]
fn create_seatbelt_command_args_for_legacy_policy(
command: Vec<String>,
@@ -510,10 +655,13 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -
dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy);
let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults();
let deny_read_policy =
build_seatbelt_unreadable_glob_policy(file_system_sandbox_policy, sandbox_policy_cwd);
let mut policy_sections = vec![
MACOS_SEATBELT_BASE_POLICY.to_string(),
file_read_policy,
file_write_policy,
deny_read_policy,
network_policy,
];
if include_platform_defaults {
+79
View File
@@ -3,11 +3,13 @@ use super::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
use super::MACOS_SEATBELT_BASE_POLICY;
use super::ProxyPolicyInputs;
use super::UnixDomainSocketPolicy;
use super::build_seatbelt_unreadable_glob_policy;
use super::create_seatbelt_command_args;
use super::create_seatbelt_command_args_for_legacy_policy;
use super::dynamic_network_policy;
use super::macos_dir_params;
use super::normalize_path_for_sandbox;
use super::seatbelt_regex_for_unreadable_glob;
use super::unix_socket_dir_params;
use super::unix_socket_policy;
use codex_network_proxy::ConfigReloader;
@@ -257,6 +259,82 @@ fn explicit_unreadable_paths_are_excluded_from_readable_roots() {
);
}
#[test]
fn unreadable_globstar_slash_matches_zero_or_more_directories() {
let regex = seatbelt_regex_for_unreadable_glob("/tmp/repo/**/*.env");
assert_eq!(regex.as_deref(), Some(r"^/tmp/repo/(.*/)?[^/]*\.env$"));
let regex = regex_lite::Regex::new(regex.as_deref().expect("glob should compile"))
.expect("regex should compile");
assert!(regex.is_match("/tmp/repo/.env"));
assert!(regex.is_match("/tmp/repo/app/.env"));
assert!(regex.is_match("/tmp/repo/app/config.env"));
assert!(!regex.is_match("/tmp/repo/app/config.toml"));
}
#[test]
fn unreadable_globs_use_git_style_component_matching() {
let regex = seatbelt_regex_for_unreadable_glob("/tmp/repo/*/file[0-9]?.txt");
assert_eq!(
regex.as_deref(),
Some(r"^/tmp/repo/[^/]*/file[0-9][^/]\.txt$")
);
let regex = regex_lite::Regex::new(regex.as_deref().expect("glob should compile"))
.expect("regex should compile");
assert!(regex.is_match("/tmp/repo/app/file42.txt"));
assert!(!regex.is_match("/tmp/repo/app/nested/file42.txt"));
assert!(!regex.is_match("/tmp/repo/app/file4.txt"));
assert!(!regex.is_match("/tmp/repo/app/fileab.txt"));
}
#[test]
fn unreadable_globs_treat_unclosed_character_classes_as_literals() {
let regex = seatbelt_regex_for_unreadable_glob("/tmp/repo/[*.env");
assert_eq!(regex.as_deref(), Some(r"^/tmp/repo/\[[^/]*\.env$"));
let regex = regex_lite::Regex::new(regex.as_deref().expect("glob should compile"))
.expect("regex should compile");
assert!(regex.is_match("/tmp/repo/[local.env"));
assert!(regex.is_match("/tmp/repo/[.env"));
assert!(!regex.is_match("/tmp/repo/local.env"));
}
#[cfg(unix)]
#[test]
fn unreadable_glob_policy_includes_canonicalized_static_prefix() {
use std::os::unix::fs::symlink;
let temp_dir = TempDir::new().expect("temp dir");
let real_root = temp_dir.path().join("real-root");
let link_root = temp_dir.path().join("link-root");
fs::create_dir(&real_root).expect("create real root");
symlink(&real_root, &link_root).expect("create symlinked root");
let pattern = format!("{}/**/*.env", link_root.display());
let canonical_pattern = format!(
"{}/**/*.env",
real_root
.canonicalize()
.expect("canonicalize real root")
.display()
);
let expected_regex = seatbelt_regex_for_unreadable_glob(&canonical_pattern)
.expect("canonical glob should compile");
let mut policy = FileSystemSandboxPolicy::default();
policy.entries.push(FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern { pattern },
access: FileSystemAccessMode::None,
});
let seatbelt_policy = build_seatbelt_unreadable_glob_policy(&policy, temp_dir.path());
assert!(
seatbelt_policy.contains(&format!(r#"(deny file-read* (regex #"{expected_regex}"))"#)),
"expected canonicalized glob regex in policy:\n{seatbelt_policy}"
);
}
#[test]
fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() {
let cwd = std::env::temp_dir();
@@ -1179,6 +1257,7 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
(allow file-write*
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_1"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry}
)
"#,
);
@@ -4,6 +4,7 @@ use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ServerNotification;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
#[cfg(target_os = "windows")]
use codex_protocol::config_types::WindowsSandboxLevel;
@@ -123,8 +124,9 @@ impl OnboardingScreen {
let show_windows_create_sandbox_hint = false;
let highlighted = TrustDirectorySelection::Trust;
if show_trust_screen {
let trust_target = resolve_root_git_project_for_trust(&cwd)
let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd)
.await
.map(Into::into)
.unwrap_or_else(|| cwd.clone());
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
cwd,