From 7ece061767a8d0dea59757ca3285f7cb2ccfc4dd Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 20 Aug 2026 03:49:55 +0000 Subject: [PATCH] Enforce filesystem permissions when loading AGENTS.md (#39653) ## Why Project instructions must respect the selected environment's filesystem read permissions. Tightening those permissions for a later turn must not allow previously cached instructions to reach the model. ## What changed - Apply each environment's filesystem sandbox while discovering and reading `AGENTS.md` files. - Fail thread or turn setup when sandboxing blocks a discovered instruction file, while allowing a restricted project with no instructions to start. - Clear cached instructions before refresh and include the Windows sandbox level in the cache key. - Ignore inaccessible ancestor marker probes so readable instructions in the selected working directory can still load. ## Testing Added coverage for restricted projects, denied instruction files, cache invalidation after permissions tighten, and unreadable ancestor markers. GitOrigin-RevId: 6ea1a27b9c873a6260e4f87d42ae1317a6a4ae4b --- .../app-server/tests/suite/v2/thread_start.rs | 5 + .../app-server/tests/suite/v2/turn_start.rs | 5 + codex-rs/core/src/agents_md.rs | 52 ++++- codex-rs/core/src/agents_md_manager.rs | 41 +++- codex-rs/core/src/agents_md_tests.rs | 89 ++++++--- codex-rs/core/src/session/mod.rs | 8 +- codex-rs/core/src/session/session.rs | 10 +- .../src/tools/handlers/multi_agents_tests.rs | 26 ++- codex-rs/core/tests/suite/agents_md.rs | 183 ++++++++++++++++++ codex-rs/core/tests/suite/apply_patch_cli.rs | 24 ++- codex-rs/core/tests/suite/windows_sandbox.rs | 19 +- sdk/typescript/tests/run.test.ts | 5 +- 12 files changed, 407 insertions(+), 60 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 9f62e8e701..a89cc6ba53 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -1840,6 +1840,11 @@ fn create_config_toml_with_profile_workspace_root( [permissions.dev.workspace_roots] "{profile_root_key}" = true +# This test only exercises workspace roots; Windows restricted-token sandboxes +# cannot enforce a filesystem policy without root read access. +[permissions.dev.filesystem] +":root" = "read" + [permissions.dev.filesystem.":workspace_roots"] "." = "write" "#, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 52a05a5925..c9feab115a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2828,6 +2828,11 @@ wire_api = "responses" request_max_retries = 0 stream_max_retries = 0 +# This test only exercises writable workspace roots; the Windows restricted-token +# sandbox cannot enforce a filesystem policy without root read access. +[permissions.dev.filesystem] +":root" = "read" + [permissions.dev.filesystem.":workspace_roots"] "." = "write" "# diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index bd9e433721..255c7ca614 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -18,14 +18,17 @@ use crate::config::Config; use crate::context::UserInstructions as ContextUserInstructions; use crate::environment_selection::TurnEnvironmentSnapshot; +use crate::tools::sandboxing::executor_windows_sandbox_level; use codex_config::ConfigLayerSource; use codex_config::default_project_root_markers; use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; use codex_exec_server::ExecutorFileSystem; use codex_extension_api::UserInstructions; +use codex_file_system::FileSystemSandboxContext; use codex_file_system::FindUpErrorPolicy; use codex_file_system::find_nearest_ancestor_with_markers; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use futures::StreamExt; @@ -53,7 +56,8 @@ pub(crate) async fn load_project_instructions( config: &Config, user_instructions: Option, environments: &TurnEnvironmentSnapshot, -) -> Option { + windows_sandbox_level: WindowsSandboxLevel, +) -> io::Result> { let mut loaded = LoadedAgentsMd::from_user_instructions(user_instructions); let mut remaining = config.project_doc_max_bytes; for turn_environment in environments.turn_environments() { @@ -62,12 +66,31 @@ pub(crate) async fn load_project_instructions( } let filesystem = turn_environment.environment.get_filesystem(); + let sandbox = (!turn_environment + .permission_profile() + .file_system_sandbox_policy() + .has_full_disk_read_access()) + .then(|| { + // TODO(anp): Move sandbox context construction to a method on TurnEnvironment. + let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + turn_environment.permission_profile().clone(), + turn_environment.cwd().clone(), + ); + sandbox.workspace_roots = turn_environment.workspace_roots().to_vec(); + sandbox.windows_sandbox_level = + executor_windows_sandbox_level(windows_sandbox_level, turn_environment.cwd()); + sandbox.windows_sandbox_private_desktop = + config.permissions.windows_sandbox_private_desktop; + sandbox.use_legacy_landlock = config.features.use_legacy_landlock(); + sandbox + }); match read_agents_md( config, filesystem.as_ref(), &turn_environment.selection.environment_id, turn_environment.cwd(), remaining, + sandbox.as_ref(), ) .await { @@ -78,16 +101,25 @@ pub(crate) async fn load_project_instructions( } } Ok(None) => {} - Err(e) => { + Err(error) if sandbox.is_none() => { error!( environment_id = turn_environment.selection.environment_id, - "error trying to find AGENTS.md docs: {e:#}" + "error trying to find AGENTS.md docs: {error:#}" ); } + Err(error) => { + return Err(io::Error::new( + error.kind(), + format!( + "failed to load AGENTS.md instructions for environment `{}`: {error}", + turn_environment.selection.environment_id + ), + )); + } } } - (!loaded.is_empty()).then_some(loaded) + Ok((!loaded.is_empty()).then_some(loaded)) } /// Attempt to locate and load AGENTS.md documentation. @@ -102,12 +134,13 @@ async fn read_agents_md( environment_id: &str, cwd: &PathUri, max_total: usize, + sandbox: Option<&FileSystemSandboxContext>, ) -> io::Result> { if max_total == 0 { return Ok(None); } - let paths = agents_md_paths(config, cwd, fs).await?; + let paths = agents_md_paths(config, cwd, fs, sandbox).await?; if paths.is_empty() { return Ok(None); } @@ -120,7 +153,7 @@ async fn read_agents_md( break; } - let mut data = match fs.read_file(&p, /*sandbox*/ None).await { + let mut data = match fs.read_file(&p, sandbox).await { Ok(data) => data, Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(err), @@ -165,6 +198,7 @@ async fn agents_md_paths( config: &Config, cwd: &PathUri, fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, ) -> io::Result> { let dir = cwd.clone(); @@ -187,8 +221,8 @@ async fn agents_md_paths( fs, &dir, project_root_markers, - FindUpErrorPolicy::Propagate, - /*sandbox*/ None, + FindUpErrorPolicy::Ignore, + sandbox, ) .await?; let search_dirs = if let Some(root) = project_root { @@ -218,7 +252,7 @@ async fn agents_md_paths( let candidate = directory .join(name) .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; - match fs.get_metadata(&candidate, /*sandbox*/ None).await { + match fs.get_metadata(&candidate, sandbox).await { Ok(metadata) if metadata.is_file => return Ok(Some(candidate)), Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => {} diff --git a/codex-rs/core/src/agents_md_manager.rs b/codex-rs/core/src/agents_md_manager.rs index 0218f2b16b..20a6d5fbe4 100644 --- a/codex-rs/core/src/agents_md_manager.rs +++ b/codex-rs/core/src/agents_md_manager.rs @@ -3,7 +3,9 @@ use crate::agents_md::load_project_instructions; use crate::config::Config; use crate::environment_selection::TurnEnvironmentSnapshot; use codex_extension_api::UserInstructions; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::protocol::TurnEnvironmentSelection; +use std::io; use std::sync::Arc; use tokio::sync::Mutex; @@ -16,6 +18,7 @@ pub(crate) struct AgentsMdManager { #[derive(Default)] struct AgentsMdCache { selections: Option>, + windows_sandbox_level: Option, loaded: Option>, } @@ -29,19 +32,41 @@ impl AgentsMdManager { } #[tracing::instrument(name = "agents_md.refresh", skip_all)] - pub(crate) async fn refresh(&self, config: &Config, environments: &TurnEnvironmentSnapshot) { - let selections = environments.to_selections(); - if self.cache.lock().await.selections.as_ref() == Some(&selections) { - return; + pub(crate) async fn refresh( + &self, + config: &Config, + environments: &TurnEnvironmentSnapshot, + windows_sandbox_level: WindowsSandboxLevel, + ) -> io::Result<()> { + let selections = environments + .turn_environments() + .map(|environment| environment.selection.clone()) + .collect::>(); + { + let mut cache = self.cache.lock().await; + if cache.selections.as_ref() == Some(&selections) + && cache.windows_sandbox_level == Some(windows_sandbox_level) + { + return Ok(()); + } + cache.selections = None; + cache.windows_sandbox_level = None; + cache.loaded = None; } - let loaded = - load_project_instructions(config, self.user_instructions.clone(), environments) - .await - .map(Arc::new); + let loaded = load_project_instructions( + config, + self.user_instructions.clone(), + environments, + windows_sandbox_level, + ) + .await? + .map(Arc::new); let mut cache = self.cache.lock().await; cache.selections = Some(selections); + cache.windows_sandbox_level = Some(windows_sandbox_level); cache.loaded = loaded; + Ok(()) } pub(crate) async fn get_loaded(&self) -> Option> { diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index faa6f2b1b9..3dada2a80b 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -22,6 +22,7 @@ use codex_exec_server::ReadDirectoryEntry; use codex_exec_server::RemoveOptions; use codex_extension_api::UserInstructions; use codex_features::Feature; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::EnvironmentConfig; use codex_protocol::protocol::EnvironmentConfigState; @@ -314,8 +315,10 @@ async fn load_agents_md(config: &TestConfig) -> Option { &config.config, config.user_instructions.clone(), &environments, + WindowsSandboxLevel::Disabled, ) .await + .expect("project instructions should load") } async fn agents_md_paths(config: &TestConfig) -> std::io::Result> { @@ -323,6 +326,7 @@ async fn agents_md_paths(config: &TestConfig) -> std::io::Result> { &config.config, &PathUri::from_abs_path(&config.cwd), LOCAL_FS.as_ref(), + /*sandbox*/ None, ) .await } @@ -674,11 +678,16 @@ async fn total_byte_limit_truncates_later_project_docs() { assert_eq!(loaded.text(), "root\n\nabc"); } +/// Unreadable ancestor markers must not hide readable instructions in the selected cwd. #[tokio::test] -async fn read_agents_md_propagates_metadata_errors() { +async fn read_agents_md_loads_cwd_instructions_when_parent_markers_are_unreadable() { let tmp = tempfile::tempdir().expect("tempdir"); - let config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; - let marker_path = config.cwd.join(".git"); + let nested = tmp.path().join("nested"); + fs::create_dir(&nested).unwrap(); + fs::write(nested.join("AGENTS.md"), "project doc").unwrap(); + let mut config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + config.cwd = nested.abs(); + let marker_path = tmp.path().join(".git").abs(); let fs = FailingFileSystem { path: marker_path, failure: InjectedFailure::Metadata(io::ErrorKind::PermissionDenied), @@ -686,17 +695,19 @@ async fn read_agents_md_propagates_metadata_errors() { }; let cwd = config.cwd.clone(); - let err = read_agents_md( + let loaded = read_agents_md( &config.config, &fs, "local", &PathUri::from_abs_path(&cwd), config.project_doc_max_bytes, + /*sandbox*/ None, ) .await - .expect_err("metadata error"); + .expect("unreadable parent markers should not hide cwd instructions") + .expect("cwd instructions"); - assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + assert_eq!(loaded.text(), "project doc"); } #[tokio::test] @@ -717,6 +728,7 @@ async fn read_agents_md_propagates_read_errors() { "local", &PathUri::from_abs_path(&cwd), config.project_doc_max_bytes, + /*sandbox*/ None, ) .await .expect_err("read error"); @@ -742,6 +754,7 @@ async fn read_agents_md_ignores_files_removed_after_discovery() { "local", &PathUri::from_abs_path(&cwd), config.project_doc_max_bytes, + /*sandbox*/ None, ) .await .expect("removed file is recoverable"); @@ -774,7 +787,7 @@ async fn marker_search_does_not_wait_for_a_higher_ancestor() { let paths = tokio::time::timeout( std::time::Duration::from_secs(1), - super::agents_md_paths(&config.config, &cwd, &fs), + super::agents_md_paths(&config.config, &cwd, &fs, /*sandbox*/ None), ) .await .expect("nearest marker should complete") @@ -879,7 +892,7 @@ async fn project_root_marker_search_limits_concurrent_probes_and_preserves_order metadata_calls.release.add_permits(max_probe_count); }; let (paths, ()) = tokio::join!( - super::agents_md_paths(&config.config, &cwd, &fs), + super::agents_md_paths(&config.config, &cwd, &fs, /*sandbox*/ None), assertions ); let paths = paths.expect("AGENTS.md discovery"); @@ -925,9 +938,9 @@ async fn agents_md_search_starts_all_directory_probes() { failure: InjectedFailure::MetadataBlocked, metadata_calls: Arc::clone(&metadata_calls), }; - - let search = - tokio::spawn(async move { super::agents_md_paths(&config.config, &cwd, &fs).await }); + let search = tokio::spawn(async move { + super::agents_md_paths(&config.config, &cwd, &fs, /*sandbox*/ None).await + }); tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { let started = metadata_calls.started.notified(); @@ -1001,7 +1014,7 @@ async fn empty_project_root_markers_only_probe_cwd_candidates() { }; let cwd = PathUri::from_abs_path(&config.cwd); - let paths = super::agents_md_paths(&config.config, &cwd, &fs) + let paths = super::agents_md_paths(&config.config, &cwd, &fs, /*sandbox*/ None) .await .expect("AGENTS.md discovery"); @@ -1096,9 +1109,15 @@ async fn multiple_environment_docs_use_labeled_layout_and_preserve_source_order( ]); let user_instructions = config.user_instructions.clone(); - let loaded = load_project_instructions(&config.config, user_instructions, &environments) - .await - .expect("instructions expected"); + let loaded = load_project_instructions( + &config.config, + user_instructions, + &environments, + WindowsSandboxLevel::Disabled, + ) + .await + .expect("project instructions should load") + .expect("instructions expected"); let inner = format!( r#"global instructions @@ -1157,9 +1176,15 @@ async fn secondary_only_project_doc_uses_single_contributor_layout() { ]); let user_instructions = config.user_instructions.clone(); - let loaded = load_project_instructions(&config.config, user_instructions, &environments) - .await - .expect("instructions expected"); + let loaded = load_project_instructions( + &config.config, + user_instructions, + &environments, + WindowsSandboxLevel::Disabled, + ) + .await + .expect("project instructions should load") + .expect("instructions expected"); let inner = format!("global instructions{AGENTS_MD_SEPARATOR}secondary doc"); assert_eq!(loaded.legacy_text(), inner); @@ -1186,9 +1211,15 @@ async fn primary_only_project_doc_preserves_legacy_layout_with_multiple_bound_en ]); let user_instructions = config.user_instructions.clone(); - let loaded = load_project_instructions(&config.config, user_instructions, &environments) - .await - .expect("instructions expected"); + let loaded = load_project_instructions( + &config.config, + user_instructions, + &environments, + WindowsSandboxLevel::Disabled, + ) + .await + .expect("project instructions should load") + .expect("instructions expected"); let inner = format!("global instructions{AGENTS_MD_SEPARATOR}primary doc"); assert_eq!(loaded.legacy_text(), inner); @@ -1216,9 +1247,15 @@ async fn project_doc_byte_limit_is_shared_across_environments() { ]); let user_instructions = config.user_instructions.clone(); - let loaded = load_project_instructions(&config.config, user_instructions, &environments) - .await - .expect("instructions expected"); + let loaded = load_project_instructions( + &config.config, + user_instructions, + &environments, + WindowsSandboxLevel::Disabled, + ) + .await + .expect("project instructions should load") + .expect("instructions expected"); assert_eq!( loaded.text(), @@ -1249,8 +1286,10 @@ async fn full_primary_environment_budget_excludes_later_environment_docs() { &config.config, /*user_instructions*/ None, &environments, + WindowsSandboxLevel::Disabled, ) .await + .expect("project instructions should load") .expect("instructions expected"); let project_bytes = loaded .entries @@ -1280,8 +1319,10 @@ async fn secondary_environment_invalid_utf8_does_not_suppress_other_docs() { &config.config, /*user_instructions*/ None, &environments, + WindowsSandboxLevel::Disabled, ) .await + .expect("project instructions should load") .expect("instructions expected"); assert!(loaded.text().contains("primary doc")); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index e438fff07f..513e7c03eb 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3154,8 +3154,12 @@ impl Session { let environments = turn_context.environments.refresh_readiness(); self.services .agents_md_manager - .refresh(&turn_context.config, &environments) - .await; + .refresh( + &turn_context.config, + &environments, + turn_context.windows_sandbox_level, + ) + .await?; let loaded_agents_md = self.services.agents_md_manager.get_loaded().await; let selected_capability_roots = self .resolve_selected_capability_roots_for_step(&environments) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index c934599a68..c0b0407967 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1186,11 +1186,17 @@ impl Session { "session_init.thread_name_lookup", otel.name = "session_init.thread_name_lookup", )); - let ((), plugin_skill_errors, thread_name) = tokio::join!( - agents_md_manager.refresh(config.as_ref(), &resolved_environments), + let (agents_md_result, plugin_skill_errors, thread_name) = tokio::join!( + agents_md_manager.refresh( + config.as_ref(), + &resolved_environments, + session_configuration.windows_sandbox_level, + ), plugin_skill_warmup, thread_name_lookup, ); + // TODO(anp): Present AGENTS.md discovery errors more clearly to the user. + agents_md_result?; for err in &plugin_skill_errors { error!( "failed to load skill {}: {}", diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 455ff92528..fc76ab4b47 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2245,6 +2245,8 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() { ); } +// TODO(anp): Restore this test on Linux once sandbox helpers work inside test microVMs. +#[cfg_attr(target_os = "linux", ignore)] #[tokio::test] async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { #[derive(Debug, Deserialize)] @@ -2254,7 +2256,18 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { } let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let server = core_test_support::responses::start_mock_server().await; + let sandbox_runtime = core_test_support::test_codex::test_codex() + .build_with_auto_env(&server) + .await + .expect("sandbox-capable test environment should start"); + let environment_manager = sandbox_runtime.thread_manager.environment_manager(); + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(), + turn.config.codex_home.to_path_buf(), + Arc::clone(&environment_manager), + ); session.services.agent_control = manager.agent_control(); let expected_sandbox = turn.config.legacy_sandbox_policy(); #[allow(deprecated)] @@ -2266,7 +2279,13 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { path: FileSystemPath::GlobPattern { pattern: "**/.env".to_string(), }, - access: FileSystemAccessMode::Deny, + // TODO(anp): Configure this fixture with the elevated Windows backend so it can + // enforce denied reads while reapplying the owner's restrictive policy. + access: if cfg!(windows) { + FileSystemAccessMode::Read + } else { + FileSystemAccessMode::Deny + }, missing_path_behavior: None, }); let expected_network_sandbox_policy = NetworkSandboxPolicy::from(&expected_sandbox); @@ -2300,6 +2319,9 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { else { panic!("parent environment should be ready"); }; + environment.environment = environment_manager + .default_environment() + .expect("sandbox-capable test environment should exist"); environment.config_mut().permission_profile = PermissionProfileSnapshot::legacy(expected_permission_profile.clone()); environment.config_origin = EnvironmentConfigOrigin::Owner; diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 692fe8b57c..9a1ff1f9a9 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -10,9 +10,15 @@ use codex_features::Feature; use codex_history::RolloutItem; use codex_history::RolloutLine; use codex_home::CodexHomeUserInstructionsProvider; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::EnvironmentConfigState; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; @@ -28,10 +34,13 @@ 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_no_remote_env; +use core_test_support::skip_if_sandbox; +use core_test_support::skip_if_target_windows; use core_test_support::test_codex::RecordingUserInstructionsProvider; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::executor_path_uri; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::json; @@ -440,6 +449,180 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restricted_project_without_instructions_starts_successfully() -> Result<()> { + skip_if_target_windows!( + Ok(()), + "Windows restricted-token sandbox cannot enforce deny-read policies" + ); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + let mut builder = test_codex().with_config(|config| { + let mut file_system_policy = FileSystemSandboxPolicy::read_only(); + file_system_policy.entries.push(FileSystemSandboxEntry::new( + config.cwd.join("private.txt").into(), + FileSystemAccessMode::Deny, + )); + config + .permissions + .set_permission_profile(PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + )) + .expect("test config should allow a restricted read policy"); + }); + let test = builder.build_with_auto_env(&server).await?; + + assert_eq!( + test.codex.instruction_sources().await, + Vec::::new() + ); + test.submit_text_turn("continue without project instructions") + .await?; + response_mock.single_request(); + + Ok(()) +} + +/// Thread creation fails when sandboxing prevents project instructions from loading. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn denied_project_instructions_fail_thread_creation() -> Result<()> { + skip_if_target_windows!( + Ok(()), + "Windows restricted-token sandbox cannot enforce deny-read policies" + ); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let home = Arc::new(TempDir::new()?); + write_global_file(home.as_ref(), GLOBAL_AGENTS_FILENAME, GLOBAL_INSTRUCTIONS)?; + + let mut builder = test_codex() + .with_home(home) + .with_config(|config| { + let mut file_system_policy = FileSystemSandboxPolicy::read_only(); + file_system_policy.entries.push(FileSystemSandboxEntry::new( + config.cwd.join(GLOBAL_AGENTS_FILENAME).into(), + FileSystemAccessMode::Deny, + )); + config + .permissions + .set_permission_profile(PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + )) + .expect("test config should allow a restricted read policy"); + }) + .with_workspace_setup(|cwd, fs| async move { + fs.write_file( + &executor_path_uri(cwd.join(GLOBAL_AGENTS_FILENAME))?, + PROJECT_INSTRUCTIONS.as_bytes().to_vec(), + /*sandbox*/ None, + ) + .await?; + Ok(()) + }); + let error = match builder.build_with_auto_env(&server).await { + Ok(_) => { + anyhow::bail!("thread creation must fail when project instructions are unreadable") + } + Err(error) => error, + }; + let error = format!("{error:#}"); + assert!( + error.contains("AGENTS.md"), + "thread creation should report the unreadable project instructions: {error}" + ); + + Ok(()) +} + +/// Tightening permissions fails the turn before stale project instructions reach the model. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tightening_environment_read_permissions_invalidates_cached_project_instructions() +-> Result<()> { + skip_if_target_windows!( + Ok(()), + "Windows restricted-token sandbox cannot enforce deny-read policies" + ); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + let mut builder = test_codex().with_workspace_setup(|cwd, fs| async move { + fs.write_file( + &executor_path_uri(cwd.join(GLOBAL_AGENTS_FILENAME))?, + PROJECT_INSTRUCTIONS.as_bytes().to_vec(), + /*sandbox*/ None, + ) + .await?; + Ok(()) + }); + let test = builder.build_with_auto_env(&server).await?; + + assert_eq!( + test.codex.instruction_sources().await, + vec![test.workspace_path_uri(GLOBAL_AGENTS_FILENAME)?] + ); + + let mut file_system_policy = FileSystemSandboxPolicy::read_only(); + file_system_policy.entries.push(FileSystemSandboxEntry::new( + test.config.cwd.join(GLOBAL_AGENTS_FILENAME).into(), + FileSystemAccessMode::Deny, + )); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permission_profile, test.config.cwd.as_path()); + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "inspect instructions after tightening permissions".to_string(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + sandbox_policy: Some(sandbox_policy), + permission_profile, + ..Default::default() + }), + ) + .await?; + + let EventMsg::Error(error) = + wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await + else { + unreachable!(); + }; + assert!( + error.message.contains("AGENTS.md"), + "turn should report the unreadable project instructions: {}", + error.message + ); + + assert_eq!( + test.codex.instruction_sources().await, + Vec::::new() + ); + assert!( + response_mock.requests().is_empty(), + "the denied turn must fail before sending a model request" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { let server = start_mock_server().await; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 61ea6bc5ca..007198c394 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -146,6 +146,16 @@ fn workspace_write_with_read_only_root(read_only_root: AbsolutePathBuf) -> Permi } let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + // TODO(anp): Rationalize these write-confinement tests so sandboxed project-instruction + // discovery works on macOS without unrestricted reads. + #[cfg(target_os = "macos")] + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: read_only_root.into(), @@ -932,14 +942,19 @@ async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace ); let test_root = tempfile::tempdir_in(std::env::current_dir()?)?; - let work_dir = AbsolutePathBuf::try_from(test_root.path().join("work"))?; - let outside_dir = AbsolutePathBuf::try_from(test_root.path().join("outside"))?; + let test_root_path = dunce::canonicalize(test_root.path())?; + let work_dir = AbsolutePathBuf::try_from(test_root_path.join("work"))?; + let outside_dir = AbsolutePathBuf::try_from(test_root_path.join("outside"))?; std::fs::create_dir_all(work_dir.as_path())?; std::fs::create_dir_all(outside_dir.as_path())?; let harness_work_dir = work_dir.clone(); let harness = apply_patch_harness_with(move |builder| { builder.with_config(move |config| { + config.workspace_roots = vec![harness_work_dir.clone()]; + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); config.cwd = harness_work_dir; }) }) @@ -1062,8 +1077,9 @@ async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace() -> Res ); let test_root = tempfile::tempdir_in(std::env::current_dir()?)?; - let work_dir = AbsolutePathBuf::try_from(test_root.path().join("work"))?; - let outside_dir = AbsolutePathBuf::try_from(test_root.path().join("outside"))?; + let test_root_path = dunce::canonicalize(test_root.path())?; + let work_dir = AbsolutePathBuf::try_from(test_root_path.join("work"))?; + let outside_dir = AbsolutePathBuf::try_from(test_root_path.join("outside"))?; std::fs::create_dir_all(work_dir.as_path())?; std::fs::create_dir_all(outside_dir.as_path())?; diff --git a/codex-rs/core/tests/suite/windows_sandbox.rs b/codex-rs/core/tests/suite/windows_sandbox.rs index a9afe3ae8f..8fb8f01ca5 100644 --- a/codex-rs/core/tests/suite/windows_sandbox.rs +++ b/codex-rs/core/tests/suite/windows_sandbox.rs @@ -451,15 +451,20 @@ async fn windows_elevated_shell_and_unified_exec_enforce_managed_deny_reads() -> NetworkSandboxPolicy::Restricted, )) .expect("set managed deny-read permission profile"); + }) + .with_workspace_setup(|cwd, _fs| async move { + std::fs::write( + cwd.join("secret.env"), + "glob secret should remain private\n", + )?; + std::fs::write( + cwd.join("exact-secret.txt"), + "exact secret should remain private\n", + )?; + std::fs::write(cwd.join("public.txt"), "public ok\n")?; + Ok(()) }); let harness = TestCodexHarness::with_builder(builder).await?; - harness - .write_file("secret.env", "glob secret should remain private\n") - .await?; - harness - .write_file("exact-secret.txt", "exact secret should remain private\n") - .await?; - harness.write_file("public.txt", "public ok\n").await?; let command = concat!( "(type secret.env 1>NUL 2>NUL && echo GLOB-READ || echo GLOB-DENIED) & ", diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index a359513398..b6cb0c34f0 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -499,8 +499,9 @@ describe("Codex", () => { ], }); - const deniedPath = path.join(os.tmpdir(), "codex-sdk-config.env"); - const permissionOverride = `permissions.sdk_test.filesystem={":root"="read",${JSON.stringify(deniedPath)}="deny"}`; + // TODO(anp): Add the sandbox helper to the SDK workflow so this can use a deny-read override. + const writablePath = path.join(os.tmpdir(), "codex-sdk-config.env"); + const permissionOverride = `permissions.sdk_test.filesystem={":root"="read",${JSON.stringify(writablePath)}="write"}`; const { args: spawnArgs, restore } = codexExecSpy(); const { client, cleanup } = createTestClient({ baseUrl: url,