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
This commit is contained in:
Adam Perry @ OpenAI
2026-08-20 07:48:46 +00:00
committed by copyberry
parent ab82cddd04
commit 7ece061767
12 changed files with 407 additions and 60 deletions
@@ -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"
"#,
@@ -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"
"#
+43 -9
View File
@@ -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<UserInstructions>,
environments: &TurnEnvironmentSnapshot,
) -> Option<LoadedAgentsMd> {
windows_sandbox_level: WindowsSandboxLevel,
) -> io::Result<Option<LoadedAgentsMd>> {
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<Option<LoadedAgentsMd>> {
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<Vec<PathUri>> {
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 => {}
+33 -8
View File
@@ -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<Vec<TurnEnvironmentSelection>>,
windows_sandbox_level: Option<WindowsSandboxLevel>,
loaded: Option<Arc<LoadedAgentsMd>>,
}
@@ -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::<Vec<_>>();
{
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<Arc<LoadedAgentsMd>> {
+65 -24
View File
@@ -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<LoadedAgentsMd> {
&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<Vec<PathUri>> {
@@ -323,6 +326,7 @@ async fn agents_md_paths(config: &TestConfig) -> std::io::Result<Vec<PathUri>> {
&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"));
+6 -2
View File
@@ -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)
+8 -2
View File
@@ -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 {}: {}",
@@ -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;
+183
View File
@@ -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::<PathUri>::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::<PathUri>::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;
+20 -4
View File
@@ -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())?;
+12 -7
View File
@@ -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) & ",
+3 -2
View File
@@ -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,