mirror of
https://github.com/openai/codex.git
synced 2026-10-15 16:48:52 +00:00
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:
committed by
copyberry
parent
ab82cddd04
commit
7ece061767
@@ -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"
|
||||
"#
|
||||
|
||||
@@ -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 => {}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
|
||||
@@ -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) & ",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user