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
+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;