From bd19459358f534ed1cae464ec13d56600aeb45f2 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 21 Aug 2026 02:28:13 +0000 Subject: [PATCH] Ignore project instructions for untrusted projects (#39837) ## What changed - Skip project-scoped `AGENTS.md` discovery when the active project is untrusted, while preserving user-level instructions. - Include the active project trust level in the instruction cache key so runtime trust changes reload the applicable instructions. - Preserve updated project trust metadata during runtime configuration refreshes. ## Testing Add coverage for initially untrusted projects and for switching between trusted and untrusted states at runtime. GitOrigin-RevId: 284bf7693943f585013fb5fe4b6aa3309bc50cf3 --- codex-rs/core/src/agents_md.rs | 4 + codex-rs/core/src/agents_md_manager.rs | 6 ++ codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/tests/suite/agents_md.rs | 120 +++++++++++++++++++++++++ 4 files changed, 131 insertions(+) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index c65a688063..1cbbc5bfcf 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -61,6 +61,10 @@ pub(crate) async fn load_project_instructions( windows_sandbox_level: WindowsSandboxLevel, ) -> io::Result> { let mut loaded = LoadedAgentsMd::from_user_instructions(user_instructions); + if config.active_project.is_untrusted() { + return Ok((!loaded.is_empty()).then_some(loaded)); + } + let mut remaining = config.project_doc_max_bytes; for turn_environment in environments.turn_environments() { if remaining == 0 { diff --git a/codex-rs/core/src/agents_md_manager.rs b/codex-rs/core/src/agents_md_manager.rs index 20a6d5fbe4..7117fd8f6d 100644 --- a/codex-rs/core/src/agents_md_manager.rs +++ b/codex-rs/core/src/agents_md_manager.rs @@ -3,6 +3,7 @@ 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::TrustLevel; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::protocol::TurnEnvironmentSelection; use std::io; @@ -18,6 +19,7 @@ pub(crate) struct AgentsMdManager { #[derive(Default)] struct AgentsMdCache { selections: Option>, + active_project_trust_level: Option, windows_sandbox_level: Option, loaded: Option>, } @@ -42,14 +44,17 @@ impl AgentsMdManager { .turn_environments() .map(|environment| environment.selection.clone()) .collect::>(); + let active_project_trust_level = config.active_project.trust_level; { let mut cache = self.cache.lock().await; if cache.selections.as_ref() == Some(&selections) + && cache.active_project_trust_level == active_project_trust_level && cache.windows_sandbox_level == Some(windows_sandbox_level) { return Ok(()); } cache.selections = None; + cache.active_project_trust_level = None; cache.windows_sandbox_level = None; cache.loaded = None; } @@ -64,6 +69,7 @@ impl AgentsMdManager { .map(Arc::new); let mut cache = self.cache.lock().await; cache.selections = Some(selections); + cache.active_project_trust_level = active_project_trust_level; cache.windows_sandbox_level = Some(windows_sandbox_level); cache.loaded = loaded; Ok(()) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index a72d3f441b..14700373a4 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1752,6 +1752,7 @@ impl Session { let previous_config = notify_config_contributors .then(|| self.build_effective_session_config(&state.session_configuration)); let mut config = (*state.session_configuration.original_config_do_not_use).clone(); + config.active_project = next_config.active_project.clone(); config.config_layer_stack = config .config_layer_stack .with_user_layer_from(&next_config.config_layer_stack); diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 0b1183e921..908fd272f3 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -10,6 +10,7 @@ use codex_features::Feature; use codex_history::RolloutItem; use codex_history::RolloutLine; use codex_home::CodexHomeUserInstructionsProvider; +use codex_protocol::config_types::TrustLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -466,6 +467,125 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn untrusted_project_excludes_project_instructions() -> Result<()> { + let server = start_mock_server().await; + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + let home = Arc::new(TempDir::new()?); + let global_agents = + write_global_file(home.as_ref(), GLOBAL_AGENTS_FILENAME, GLOBAL_INSTRUCTIONS)?; + + let mut builder = test_codex() + .with_home(home) + .with_config(|config| { + config.active_project.trust_level = Some(TrustLevel::Untrusted); + }) + .with_workspace_setup(|cwd, fs| async move { + fs.write_file( + &executor_path_uri(cwd.join(GLOBAL_AGENTS_FILENAME))?, + PROJECT_INSTRUCTIONS.as_bytes().to_vec(), + Default::default(), + /*sandbox*/ None, + ) + .await?; + Ok::<(), anyhow::Error>(()) + }); + let test = builder.build_with_auto_env(&server).await?; + + assert_eq!( + test.codex.instruction_sources().await, + vec![PathUri::from_abs_path(&global_agents)] + ); + + test.submit_turn("hello").await?; + let instructions = resp_mock + .single_request() + .message_input_texts("user") + .into_iter() + .find(|text| text.starts_with("# AGENTS.md instructions")) + .expect("global instructions message"); + assert!(instructions.contains(GLOBAL_INSTRUCTIONS)); + assert!(!instructions.contains(PROJECT_INSTRUCTIONS)); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn runtime_trust_reload_refreshes_project_instructions() -> Result<()> { + let server = start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + sse(vec![ev_response_created("resp2"), ev_completed("resp2")]), + sse(vec![ev_response_created("resp3"), ev_completed("resp3")]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let global_agents = + write_global_file(home.as_ref(), GLOBAL_AGENTS_FILENAME, GLOBAL_INSTRUCTIONS)?; + let mut builder = test_codex() + .with_home(home) + .with_config(|config| { + config.active_project.trust_level = Some(TrustLevel::Trusted); + }) + .with_workspace_setup(|cwd, fs| async move { + fs.write_file( + &executor_path_uri(cwd.join(GLOBAL_AGENTS_FILENAME))?, + PROJECT_INSTRUCTIONS.as_bytes().to_vec(), + Default::default(), + /*sandbox*/ None, + ) + .await?; + Ok::<(), anyhow::Error>(()) + }); + let test = builder.build_with_auto_env(&server).await?; + let project_agents = test.workspace_path_uri(GLOBAL_AGENTS_FILENAME)?; + let global_agents = PathUri::from_abs_path(&global_agents); + + test.submit_turn("trusted project").await?; + assert_eq!( + test.codex.instruction_sources().await, + vec![global_agents.clone(), project_agents.clone()] + ); + + let mut untrusted_config = (*test.codex.config().await).clone(); + untrusted_config.active_project.trust_level = Some(TrustLevel::Untrusted); + test.codex.refresh_runtime_config(untrusted_config).await; + test.submit_turn("untrusted project").await?; + assert_eq!( + test.codex.instruction_sources().await, + vec![global_agents.clone()] + ); + + let mut trusted_config = (*test.codex.config().await).clone(); + trusted_config.active_project.trust_level = Some(TrustLevel::Trusted); + test.codex.refresh_runtime_config(trusted_config).await; + test.submit_turn("trusted again").await?; + assert_eq!( + test.codex.instruction_sources().await, + vec![global_agents, project_agents] + ); + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + let latest_instruction_fragments = requests + .iter() + .map(instruction_fragments) + .map(|fragments| fragments.last().cloned().expect("instructions message")) + .collect::>(); + assert!(latest_instruction_fragments[0].contains(PROJECT_INSTRUCTIONS)); + assert!(!latest_instruction_fragments[1].contains(PROJECT_INSTRUCTIONS)); + assert!(latest_instruction_fragments[2].contains(PROJECT_INSTRUCTIONS)); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn restricted_project_without_instructions_starts_successfully() -> Result<()> { skip_if_target_windows!(