From 330d604b0cb412be629931a68ad6b4f4d109c3d8 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:02:53 +0000 Subject: [PATCH 1/7] Add a config-gated Claude Code global-instructions provider When claude_compat_instructions is set, the global user-instructions snapshot also includes ~/.claude/CLAUDE.md and ~/.claude/rules/*.md in sorted order. Line-only @path imports expand to depth 5, are deduped by path, symlink and body, and count against project_doc_max_bytes. The switch defaults off, so upstream behaviour is unchanged. WI-1131 --- codex-rs/Cargo.lock | 2 + codex-rs/app-server/src/mcp_refresh.rs | 8 +- codex-rs/app-server/src/message_processor.rs | 8 +- codex-rs/cli/src/main.rs | 8 +- codex-rs/codex-home/Cargo.toml | 4 +- .../src/instructions/claude_compat.rs | 127 ++++++++++++++++++ .../src/instructions/claude_compat_tests.rs | 77 +++++++++++ codex-rs/codex-home/src/instructions/mod.rs | 18 +++ codex-rs/codex-home/src/lib.rs | 3 + codex-rs/config/src/config_toml.rs | 7 + codex-rs/core/src/config/mod.rs | 5 + 11 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 codex-rs/codex-home/src/instructions/claude_compat.rs create mode 100644 codex-rs/codex-home/src/instructions/claude_compat_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1cee1174c2e9..b6363c00db16 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3671,7 +3671,9 @@ version = "0.0.0" dependencies = [ "codex-extension-api", "codex-utils-absolute-path", + "dirs", "pretty_assertions", + "sha2 0.10.9", "tempfile", "tokio", "tracing", diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index ac965523fb22..4e30262c9c85 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -182,7 +182,7 @@ mod tests { use codex_core::thread_store_from_config; use codex_exec_server::EnvironmentManager; use codex_extension_api::NoopExtensionEventSink; - use codex_home::CodexHomeUserInstructionsProvider; + use codex_home::user_instructions_provider; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_protocol::protocol::SessionSource; @@ -490,9 +490,11 @@ enabled = false queue_service: None, turn_start_admission: None, }), - Arc::new(CodexHomeUserInstructionsProvider::new( + user_instructions_provider( good_config.codex_home.clone(), - )), + good_config.claude_compat_instructions, + good_config.project_doc_max_bytes, + ), /*analytics_events_client*/ None, codex_core::passthrough_image_store(), Arc::clone(&thread_store), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6b705c55565e..fb131ab26ff5 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -82,7 +82,7 @@ use codex_exec_server::EnvironmentManager; use codex_extension_api::TurnStartAdmission; use codex_feedback::CodexFeedback; use codex_goal_extension::GoalService; -use codex_home::CodexHomeUserInstructionsProvider; +use codex_home::user_instructions_provider; use codex_login::AuthManager; use codex_otel::auth_storage::AuthStorageOriginator; use codex_protocol::ThreadId; @@ -354,9 +354,11 @@ impl MessageProcessor { queue_service: queue_service.clone(), turn_start_admission: Some(Arc::clone(&turn_start_admission)), }), - Arc::new(CodexHomeUserInstructionsProvider::new( + user_instructions_provider( config.codex_home.clone(), - )), + config.claude_compat_instructions, + config.project_doc_max_bytes, + ), Some(analytics_events_client.clone()), codex_core::passthrough_image_store(), Arc::clone(&thread_store), diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8d062a23979e..1336c9944c7e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -101,7 +101,7 @@ use codex_core::config::resolve_profile_v2_config_path; use codex_features::FEATURES; use codex_features::Stage; use codex_features::is_known_feature_key; -use codex_home::CodexHomeUserInstructionsProvider; +use codex_home::user_instructions_provider; use codex_login::AuthManager; use codex_login::is_workload_identity_selected; use codex_memories_write::clear_memory_roots_contents; @@ -2054,9 +2054,11 @@ async fn run_debug_prompt_input_command( }); } - let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new( + let user_instructions_provider = user_instructions_provider( config.codex_home.clone(), - )); + config.claude_compat_instructions, + config.project_doc_max_bytes, + ); let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await?; let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new(); diff --git a/codex-rs/codex-home/Cargo.toml b/codex-rs/codex-home/Cargo.toml index 068ec55b4e27..7fd26c4ad5b9 100644 --- a/codex-rs/codex-home/Cargo.toml +++ b/codex-rs/codex-home/Cargo.toml @@ -13,7 +13,9 @@ workspace = true [dependencies] codex-extension-api = { workspace = true } codex-utils-absolute-path = { workspace = true } -tokio = { workspace = true, features = ["fs"] } +dirs = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true, features = ["fs", "macros", "rt"] } tracing = { workspace = true } [dev-dependencies] diff --git a/codex-rs/codex-home/src/instructions/claude_compat.rs b/codex-rs/codex-home/src/instructions/claude_compat.rs new file mode 100644 index 000000000000..8b4aa35dc6b4 --- /dev/null +++ b/codex-rs/codex-home/src/instructions/claude_compat.rs @@ -0,0 +1,127 @@ +//! Global user instructions with Claude Code compatibility. +//! +//! Fork-owned (WI-1131). Wraps [`CodexHomeUserInstructionsProvider`] and, when +//! enabled, appends `~/.claude/CLAUDE.md` and then `~/.claude/rules/*.md` in +//! sorted order. Both go through the shared `@import` expander, so a line that +//! is only `@path` is replaced by that file, to depth 5, deduplicated by path, +//! symlink and content, and counted against `project_doc_max_bytes`. + +use std::path::PathBuf; +use std::sync::Arc; + +use codex_extension_api::Instructions; +use codex_extension_api::LoadInstructionsFuture; +use codex_extension_api::LoadedUserInstructions; +use codex_extension_api::UserInstructionsProvider; +use codex_utils_absolute_path::AbsolutePathBuf; + +use super::CodexHomeUserInstructionsProvider; +use super::imports::expand_imports_all; + +const CLAUDE_DIR: &str = ".claude"; +const CLAUDE_MD: &str = "CLAUDE.md"; +const RULES_DIR: &str = "rules"; + +/// Loads the Codex home instructions plus the user's Claude Code files. +#[derive(Clone, Debug)] +pub struct ClaudeCompatUserInstructionsProvider { + inner: CodexHomeUserInstructionsProvider, + /// The user's home directory, where `~/.claude` lives. + user_home: Option, + /// Bytes the appended Claude files may consume in total. + max_bytes: usize, +} + +impl ClaudeCompatUserInstructionsProvider { + /// Creates a provider. `user_home` is the directory holding `.claude`; pass + /// `None` when it cannot be determined and only the Codex home is loaded. + pub fn new( + codex_home: AbsolutePathBuf, + user_home: Option, + max_bytes: usize, + ) -> Self { + Self { + inner: CodexHomeUserInstructionsProvider::new(codex_home), + user_home, + max_bytes, + } + } + + async fn load(&self) -> LoadedUserInstructions { + let mut loaded = self.inner.load_user_instructions().await; + let Some(user_home) = &self.user_home else { + return loaded; + }; + let claude_dir = user_home.join(CLAUDE_DIR); + let mut paths = vec![claude_dir.join(CLAUDE_MD)]; + if let Some(rules) = rule_files(&claude_dir.join(RULES_DIR)).await { + paths.extend(rules); + } + let expanded = expand_imports_all(&paths, Some(user_home), self.max_bytes, None).await; + loaded.warnings.extend(expanded.warnings); + if expanded.files.is_empty() { + return loaded; + } + let claude_text = expanded + .files + .iter() + .map(|file| format!("Contents of {}:\n\n{}", file.path.display(), file.text)) + .collect::>() + .join("\n\n"); + let text = match loaded.instructions.take() { + Some(existing) => format!("{}\n\n{claude_text}", existing.text), + None => claude_text, + }; + loaded.instructions = Some(Instructions { + text, + // The snapshot now spans several files, so no single source path + // describes it. + source: None, + }); + loaded + } +} + +impl UserInstructionsProvider for ClaudeCompatUserInstructionsProvider { + fn load_user_instructions(&self) -> LoadInstructionsFuture<'_> { + Box::pin(async move { self.load().await }) + } +} + +/// `*.md` files directly inside `dir`, sorted by name. `None` when the +/// directory does not exist. +async fn rule_files(dir: &std::path::Path) -> Option> { + let mut entries = match tokio::fs::read_dir(dir).await { + Ok(entries) => entries, + Err(_) => return None, + }; + let mut files = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) == Some("md") { + files.push(path); + } + } + files.sort(); + Some(files) +} + +/// Selects the global user-instructions provider for a runtime. +/// +/// `claude_compat` is the fork's config switch: off keeps upstream behaviour +/// exactly, on appends the user's `~/.claude` files. +pub fn user_instructions_provider( + codex_home: AbsolutePathBuf, + claude_compat: bool, + max_bytes: usize, +) -> Arc { + if claude_compat { + Arc::new(ClaudeCompatUserInstructionsProvider::new( + codex_home, + dirs::home_dir(), + max_bytes, + )) + } else { + Arc::new(CodexHomeUserInstructionsProvider::new(codex_home)) + } +} diff --git a/codex-rs/codex-home/src/instructions/claude_compat_tests.rs b/codex-rs/codex-home/src/instructions/claude_compat_tests.rs new file mode 100644 index 000000000000..419190dd98fe --- /dev/null +++ b/codex-rs/codex-home/src/instructions/claude_compat_tests.rs @@ -0,0 +1,77 @@ +use std::fs; + +use codex_extension_api::UserInstructionsProvider; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; + +use super::ClaudeCompatUserInstructionsProvider; + +fn provider(user_home: &std::path::Path, max_bytes: usize) -> ClaudeCompatUserInstructionsProvider { + let codex_home = tempfile::tempdir().expect("codex home"); + // Leak: the provider only stores the path, and the dir lives until process exit. + let codex_home = Box::leak(Box::new(codex_home)); + ClaudeCompatUserInstructionsProvider::new( + AbsolutePathBuf::try_from(codex_home.path().to_path_buf()).expect("absolute"), + Some(user_home.to_path_buf()), + max_bytes, + ) +} + +#[tokio::test] +async fn claude_md_then_rules_in_sorted_order() { + let home = tempfile::tempdir().expect("home"); + let claude = home.path().join(".claude"); + fs::create_dir_all(claude.join("rules")).expect("rules dir"); + fs::write(claude.join("CLAUDE.md"), "global claude\n").expect("claude md"); + fs::write(claude.join("rules").join("b.md"), "rule b\n").expect("rule b"); + fs::write(claude.join("rules").join("a.md"), "rule a\n").expect("rule a"); + fs::write(claude.join("rules").join("notes.txt"), "not a rule").expect("txt"); + + let loaded = provider(home.path(), 32 * 1024) + .load_user_instructions() + .await; + + let text = loaded.instructions.expect("instructions").text; + let claude_at = text.find("global claude").expect("claude text"); + let a_at = text.find("rule a").expect("rule a"); + let b_at = text.find("rule b").expect("rule b"); + assert!(claude_at < a_at && a_at < b_at, "order was {text:?}"); + assert!(!text.contains("not a rule")); + assert!(loaded.warnings.is_empty()); +} + +#[tokio::test] +async fn rules_import_is_expanded_and_deduplicated() { + let home = tempfile::tempdir().expect("home"); + let claude = home.path().join(".claude"); + fs::create_dir_all(claude.join("rules")).expect("rules dir"); + fs::write(claude.join("shared.md"), "shared once\n").expect("shared"); + fs::write(claude.join("CLAUDE.md"), "@shared.md\n").expect("claude md"); + fs::write(claude.join("rules").join("r.md"), "@../shared.md\n").expect("rule"); + + let loaded = provider(home.path(), 32 * 1024) + .load_user_instructions() + .await; + + let text = loaded.instructions.expect("instructions").text; + assert_eq!(text.matches("shared once").count(), 1, "{text:?}"); +} + +#[tokio::test] +async fn the_budget_covers_claude_files_and_rules_together() { + let home = tempfile::tempdir().expect("home"); + let claude = home.path().join(".claude"); + fs::create_dir_all(claude.join("rules")).expect("rules dir"); + let claude_md = "0123456789abcdef"; + fs::write(claude.join("CLAUDE.md"), claude_md).expect("claude md"); + fs::write(claude.join("rules").join("r.md"), "rule body").expect("rule"); + + let loaded = provider(home.path(), claude_md.len()) + .load_user_instructions() + .await; + + let text = loaded.instructions.expect("instructions").text; + assert!(text.contains(claude_md)); + assert!(!text.contains("rule body"), "rule must be past the budget: {text:?}"); + assert_eq!(loaded.warnings.len(), 1); +} diff --git a/codex-rs/codex-home/src/instructions/mod.rs b/codex-rs/codex-home/src/instructions/mod.rs index a16ad668af3c..dcb4fc79922a 100644 --- a/codex-rs/codex-home/src/instructions/mod.rs +++ b/codex-rs/codex-home/src/instructions/mod.rs @@ -12,6 +12,18 @@ use codex_utils_absolute_path::AbsolutePathBuf; const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md"; const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md"; +// Fork (WI-1131): Claude Code global instructions and the shared @import +// expander. `imports` is public so the per-directory loader (WI-1132) reuses it. +pub mod claude_compat; +pub mod imports; + +#[cfg(test)] +pub(crate) use claude_compat::ClaudeCompatUserInstructionsProvider; +#[cfg(test)] +pub(crate) use imports::MAX_IMPORT_DEPTH; +#[cfg(test)] +pub(crate) use imports::expand_imports; + /// Loads user instructions from a Codex home directory. #[derive(Clone, Debug)] pub struct CodexHomeUserInstructionsProvider { @@ -111,3 +123,9 @@ impl UserInstructionsProvider for CodexHomeUserInstructionsProvider { #[cfg(test)] mod tests; + +#[cfg(test)] +mod imports_tests; + +#[cfg(test)] +mod claude_compat_tests; diff --git a/codex-rs/codex-home/src/lib.rs b/codex-rs/codex-home/src/lib.rs index 7ca5e5807258..cac8f8a4c027 100644 --- a/codex-rs/codex-home/src/lib.rs +++ b/codex-rs/codex-home/src/lib.rs @@ -1,3 +1,6 @@ mod instructions; pub use instructions::CodexHomeUserInstructionsProvider; +pub use instructions::claude_compat::ClaudeCompatUserInstructionsProvider; +pub use instructions::claude_compat::user_instructions_provider; +pub use instructions::imports::expand_imports; diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 3e5cc07e6ca6..e85af551e92d 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -336,6 +336,13 @@ pub struct ConfigToml { #[serde(default = "default_project_doc_fallback_filenames")] pub project_doc_fallback_filenames: Option>, + /// Fork (WI-1131): also load the user's global Claude Code instructions + /// (`~/.claude/CLAUDE.md` and `~/.claude/rules/*.md`) into the user + /// instructions snapshot. Off by default, so upstream behaviour is unchanged + /// unless a config sets it. + #[serde(default)] + pub claude_compat_instructions: Option, + /// Token budget applied when storing tool/function outputs in the context manager. pub tool_output_token_limit: Option, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1eb2cdf83fe8..260785baf764 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -932,6 +932,10 @@ pub struct Config { /// Additional filenames to try when looking for project-level docs. pub project_doc_fallback_filenames: Vec, + /// Fork (WI-1131): load `~/.claude/CLAUDE.md` and `~/.claude/rules/*.md` + /// into the global user-instructions snapshot. Off unless the config sets it. + pub claude_compat_instructions: bool, + /// Token budget applied when storing tool/function outputs in the context manager. pub tool_output_token_limit: Option, @@ -4391,6 +4395,7 @@ impl Config { } }) .collect(), + claude_compat_instructions: cfg.claude_compat_instructions.unwrap_or(false), tool_output_token_limit: cfg.tool_output_token_limit, agents_enabled, agent_max_threads, From d6a89ce94d905eb3761fbbcff051dec054321a91 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:02:53 +0000 Subject: [PATCH 2/7] Confine @import expansion to the caller's allowed roots expand_imports and expand_imports_all take allowed_roots. None leaves the global user-instructions path unrestricted; a caller that passes project roots gets every file canonicalized and refused, with a warning, when it falls outside them. A symlink or .. climb is caught because the check runs after canonicalization. WI-1131 --- .../codex-home/src/instructions/imports.rs | 332 ++++++++++++++++++ .../src/instructions/imports_tests.rs | 189 ++++++++++ 2 files changed, 521 insertions(+) create mode 100644 codex-rs/codex-home/src/instructions/imports.rs create mode 100644 codex-rs/codex-home/src/instructions/imports_tests.rs diff --git a/codex-rs/codex-home/src/instructions/imports.rs b/codex-rs/codex-home/src/instructions/imports.rs new file mode 100644 index 000000000000..4a1b81d4f93f --- /dev/null +++ b/codex-rs/codex-home/src/instructions/imports.rs @@ -0,0 +1,332 @@ +//! Shared `@import` expansion for Claude-compat instruction files. +//! +//! Fork-owned (WI-1131). Both the global user-instructions provider and the +//! per-directory project-doc loader (WI-1132) expand imports through here, so +//! the rules live in one place: +//! +//! - only a line that is nothing but `@path` is an import, and only outside a +//! fenced code block (` ``` ` or `~~~`); +//! - the path is resolved relative to the importing file, `~/` means the home +//! directory, and an absolute path is taken as written; +//! - expansion stops at depth 5; +//! - a file reached twice (by path, by symlink, or by identical body) is kept +//! once, which also ends cycles; +//! - every byte read counts against the caller's remaining budget. + +use std::collections::HashSet; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use sha2::Digest; +use sha2::Sha256; + +/// Deepest import nesting that is still expanded. A file at this depth is +/// included verbatim; its own imports are left as written. +pub const MAX_IMPORT_DEPTH: usize = 5; + +/// One file an expansion read, in the order it was first reached. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ImportedFile { + /// Canonical path of the file, after symlink resolution. + pub path: PathBuf, + /// The file's text after its own imports were expanded. + pub text: String, +} + +/// Outcome of expanding one instruction file. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ImportExpansion { + /// Files that contributed text, in first-seen order. + pub files: Vec, + /// Bytes consumed from the budget, including files that were deduplicated. + pub bytes_read: usize, + /// Recoverable problems: a file that could not be read, or a file skipped + /// because the byte budget was exhausted. + pub warnings: Vec, +} + +/// Expands `@path` imports starting at `path`. +/// +/// `budget` is the number of bytes still available; the expansion stops reading +/// once it is spent and reports the skipped file as a warning. `home` resolves +/// `~/` references and may be `None` when no home directory is known. +/// +/// `allowed_roots` confines every file the expansion reads. After a path is +/// canonicalized (so a symlink or `..` cannot slip past), it must sit inside +/// one of the roots; anything else is refused and reported as a warning. +/// `None` confines nothing, which is what the global user-instructions path +/// uses. An empty slice confines everything. +pub async fn expand_imports( + path: &Path, + home: Option<&Path>, + budget: usize, + allowed_roots: Option<&[PathBuf]>, +) -> ImportExpansion { + let mut expansion = ImportExpansion::default(); + expand_with(&mut expansion, std::slice::from_ref(&path.to_path_buf()), home, budget, allowed_roots) + .await; + expansion +} + +/// Expands several files as one snapshot. +/// +/// Files share the byte budget and the path and body deduplication, so a file +/// reached from two of them is kept once and every byte counts once. `budget` +/// is the total still available across all of `paths`. `allowed_roots` is the +/// same confinement as [`expand_imports`]. +pub async fn expand_imports_all( + paths: &[PathBuf], + home: Option<&Path>, + budget: usize, + allowed_roots: Option<&[PathBuf]>, +) -> ImportExpansion { + let mut expansion = ImportExpansion::default(); + expand_with(&mut expansion, paths, home, budget, allowed_roots).await; + expansion +} + +async fn expand_with( + expansion: &mut ImportExpansion, + paths: &[PathBuf], + home: Option<&Path>, + budget: usize, + allowed_roots: Option<&[PathBuf]>, +) { + let mut expander = Expander { + home: home.map(Path::to_path_buf), + allowed_roots: canonicalize_roots(allowed_roots).await, + seen_paths: HashSet::new(), + seen_bodies: HashSet::new(), + files: Vec::new(), + bytes_read: 0, + budget, + warnings: Vec::new(), + }; + for path in paths { + expander.add(path, 0).await; + } + expansion.files.append(&mut expander.files); + expansion.bytes_read += expander.bytes_read; + expansion.warnings.append(&mut expander.warnings); +} + +/// Canonicalizes each root that exists. A root that does not is dropped, which +/// only ever makes the confinement stricter. +async fn canonicalize_roots(roots: Option<&[PathBuf]>) -> Option> { + let roots = roots?; + let mut canonical = Vec::with_capacity(roots.len()); + for root in roots { + if let Ok(path) = tokio::fs::canonicalize(root).await { + canonical.push(path); + } + } + Some(canonical) +} + +struct Expander { + home: Option, + /// Canonical roots a file must fall inside, when confinement is on. + allowed_roots: Option>, + seen_paths: HashSet, + seen_bodies: HashSet<[u8; 32]>, + files: Vec, + bytes_read: usize, + budget: usize, + warnings: Vec, +} + +impl Expander { + /// Loads `path` and what it imports. A file already loaded is skipped. + async fn add(&mut self, path: &Path, depth: usize) { + let Some((canonical, text)) = self.read(path).await else { + return; + }; + if !self.seen_paths.insert(canonical.clone()) { + return; + } + let index = self.files.len(); + self.files.push(ImportedFile { + path: canonical.clone(), + text: String::new(), + }); + let body = self.expand(&text, canonical.parent(), depth).await; + let body = body.trim().to_string(); + // A file that is one import which could not be read contributed + // nothing, so it is not kept. A file that did expand is kept even when + // its own text matches what it imported. + let unresolved_import = text.trim().lines().count() == 1 + && line_only_import(text.trim()).is_some() + && body == text.trim(); + let identity = if unresolved_import { + format!("{depth}\n{}", text.trim()) + } else { + text.trim().to_string() + }; + let digest = Sha256::digest(identity.as_bytes()); + let fresh = self.seen_bodies.insert(digest.into()); + if body.is_empty() || (unresolved_import && depth < MAX_IMPORT_DEPTH) || !fresh { + self.files.remove(index); + return; + } + self.files[index].text = body; + } + + /// Reads a regular file within the remaining budget. + async fn read(&mut self, path: &Path) -> Option<(PathBuf, String)> { + let canonical = match tokio::fs::canonicalize(path).await { + Ok(path) => path, + Err(err) if err.kind() == io::ErrorKind::NotFound => return None, + Err(err) => { + self.warnings + .push(format!("Failed to read instructions from `{}`: {err}", path.display())); + return None; + } + }; + if let Some(roots) = &self.allowed_roots + && !roots.iter().any(|root| canonical.starts_with(root)) + { + self.warnings.push(format!( + "Refused instructions from `{}`: it is outside the allowed project roots", + canonical.display() + )); + return None; + } + let metadata = match tokio::fs::metadata(&canonical).await { + Ok(metadata) if metadata.is_file() => metadata, + Ok(_) => return None, + Err(err) => { + self.warnings.push(format!( + "Failed to read instructions from `{}`: {err}", + canonical.display() + )); + return None; + } + }; + let len = usize::try_from(metadata.len()).unwrap_or(usize::MAX); + if self.bytes_read.saturating_add(len) > self.budget { + self.warnings.push(format!( + "Skipped instructions from `{}`: the {} byte project_doc_max_bytes budget is exhausted", + canonical.display(), + self.budget + )); + return None; + } + let data = match tokio::fs::read(&canonical).await { + Ok(data) => data, + Err(err) => { + self.warnings.push(format!( + "Failed to read instructions from `{}`: {err}", + canonical.display() + )); + return None; + } + }; + self.bytes_read += data.len(); + Some((canonical, String::from_utf8_lossy(&data).into_owned())) + } + + /// Strips HTML comments and resolves line-only imports in `text`. + async fn expand(&mut self, text: &str, dir: Option<&Path>, depth: usize) -> String { + let text = strip_html_comments(text); + let mut out = Vec::new(); + let mut in_fence = false; + for line in text.split('\n') { + let trimmed = line.trim(); + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + in_fence = !in_fence; + out.push(line.to_string()); + continue; + } + if in_fence || depth >= MAX_IMPORT_DEPTH { + out.push(line.to_string()); + continue; + } + if let Some(reference) = line_only_import(trimmed) + && let Some(imported) = Box::pin(self.import_file(reference, dir, depth)).await + { + if !imported.is_empty() { + out.push(imported); + } + continue; + } + out.push(line.to_string()); + } + out.join("\n") + } + + /// Resolves one `@path` reference. `None` means it does not name a readable + /// file, so the caller leaves the line as written. + async fn import_file( + &mut self, + reference: &str, + dir: Option<&Path>, + depth: usize, + ) -> Option { + let path = self.resolve(reference, dir)?; + let (canonical, text) = self.read(&path).await?; + if !self.seen_paths.insert(canonical.clone()) { + return Some(String::new()); + } + let body = Box::pin(self.expand(&text, canonical.parent(), depth + 1)) + .await + .trim() + .to_string(); + if body.is_empty() { + return Some(String::new()); + } + // A file that is only an import has no body of its own, so its identity + // is the depth it was reached at plus what it resolved to. Two links in + // a chain otherwise hash to the same text and the whole chain collapses. + let pure_import = + text.trim().lines().count() == 1 && line_only_import(text.trim()).is_some(); + let identity = if pure_import { + format!("{}\n{body}", depth + 1) + } else { + text.trim().to_string() + }; + let digest = Sha256::digest(identity.as_bytes()); + if !self.seen_bodies.insert(digest.into()) { + return Some(String::new()); + } + Some(body) + } + + fn resolve(&self, reference: &str, dir: Option<&Path>) -> Option { + if let Some(rest) = reference.strip_prefix("~/") { + return Some(self.home.as_ref()?.join(rest)); + } + let path = Path::new(reference); + if path.is_absolute() { + return Some(path.to_path_buf()); + } + Some(dir?.join(path)) + } +} + +/// A line that is exactly `@path`, with the path characters Klaudia accepts. +fn line_only_import(trimmed: &str) -> Option<&str> { + let reference = trimmed.strip_prefix('@')?; + if reference.is_empty() + || !reference + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '/' | '-' | '~')) + { + return None; + } + Some(reference) +} + +fn strip_html_comments(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find("") { + Some(end) => rest = &rest[start + 4 + end + 3..], + None => return out, + } + } + out.push_str(rest); + out +} diff --git a/codex-rs/codex-home/src/instructions/imports_tests.rs b/codex-rs/codex-home/src/instructions/imports_tests.rs new file mode 100644 index 000000000000..92025ce7a834 --- /dev/null +++ b/codex-rs/codex-home/src/instructions/imports_tests.rs @@ -0,0 +1,189 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::Path; + +use pretty_assertions::assert_eq; + +use super::MAX_IMPORT_DEPTH; +use super::expand_imports; + +fn write(path: &Path, text: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent"); + } + fs::write(path, text).expect("write file"); +} + +#[tokio::test] +async fn line_only_import_is_inlined_and_counted() { + let dir = tempfile::tempdir().expect("temp dir"); + write(&dir.path().join("AGENTS.md"), "shared rules\n"); + write( + &dir.path().join("CLAUDE.md"), + "preamble\n@AGENTS.md\nclosing\n", + ); + + let expansion = expand_imports(&dir.path().join("CLAUDE.md"), None, 1024, None).await; + + assert_eq!(expansion.warnings, Vec::::new()); + assert_eq!(expansion.files.len(), 1); + assert_eq!(expansion.files[0].text, "preamble\nshared rules\nclosing"); + assert_eq!( + expansion.bytes_read, + fs::read(dir.path().join("CLAUDE.md")).unwrap().len() + + fs::read(dir.path().join("AGENTS.md")).unwrap().len() + ); +} + +#[tokio::test] +async fn imports_inside_fences_and_mid_line_are_left_alone() { + let dir = tempfile::tempdir().expect("temp dir"); + write(&dir.path().join("real.md"), "imported\n"); + write( + &dir.path().join("CLAUDE.md"), + "see @real.md inline\n```\n@real.md\n```\n~~~@real.md\n", + ); + + let expansion = expand_imports(&dir.path().join("CLAUDE.md"), None, 1024, None).await; + + assert_eq!(expansion.files.len(), 1); + assert_eq!( + expansion.files[0].text, + "see @real.md inline\n```\n@real.md\n```\n~~~@real.md" + ); + assert_eq!( + expansion.bytes_read, + fs::read(dir.path().join("CLAUDE.md")).unwrap().len() + ); +} + +#[tokio::test] +async fn expansion_stops_at_depth_five() { + let dir = tempfile::tempdir().expect("temp dir"); + write(&dir.path().join("level6.md"), "too deep\n"); + let mut next = "level6.md".to_string(); + for level in (1..=MAX_IMPORT_DEPTH).rev() { + let name = format!("level{level}.md"); + write(&dir.path().join(&name), &format!("@{next}\n")); + next = name; + } + write(&dir.path().join("CLAUDE.md"), &format!("@{next}\n")); + + let expansion = expand_imports(&dir.path().join("CLAUDE.md"), None, 1024, None).await; + + let text = &expansion.files[0].text; + assert!( + text.contains("@level6.md"), + "the import past depth {MAX_IMPORT_DEPTH} must stay literal, got {text:?}" + ); + assert!(!text.contains("too deep")); +} + +#[tokio::test] +async fn duplicate_path_symlink_and_body_are_kept_once() { + let dir = tempfile::tempdir().expect("temp dir"); + write(&dir.path().join("shared.md"), "same body\n"); + symlink("shared.md", dir.path().join("link.md")).expect("symlink"); + write(&dir.path().join("copy.md"), "same body\n"); + write( + &dir.path().join("CLAUDE.md"), + "@shared.md\n@link.md\n@copy.md\n@shared.md\n", + ); + + let expansion = expand_imports(&dir.path().join("CLAUDE.md"), None, 1024, None).await; + + assert_eq!(expansion.files[0].text, "same body"); + assert_eq!(expansion.files.len(), 1); +} + +#[tokio::test] +async fn byte_budget_stops_the_expansion() { + let dir = tempfile::tempdir().expect("temp dir"); + write(&dir.path().join("big.md"), "0123456789"); + write(&dir.path().join("CLAUDE.md"), "@big.md\n"); + let budget = fs::read(dir.path().join("CLAUDE.md")).unwrap().len(); + + let expansion = expand_imports(&dir.path().join("CLAUDE.md"), None, budget, None).await; + + assert!(expansion.files.is_empty()); + assert_eq!(expansion.warnings.len(), 1); + assert!(expansion.warnings[0].contains("project_doc_max_bytes")); +} + +#[tokio::test] +async fn home_prefix_resolves_against_the_given_home() { + let home = tempfile::tempdir().expect("temp dir"); + let dir = tempfile::tempdir().expect("temp dir"); + write(&home.path().join("shared.md"), "from home\n"); + write(&dir.path().join("CLAUDE.md"), "@~/shared.md\n"); + + let expansion = expand_imports(&dir.path().join("CLAUDE.md"), Some(home.path()), 1024, None).await; + + assert_eq!(expansion.files[0].text, "from home"); +} + +#[tokio::test] +async fn a_cycle_is_included_once() { + let dir = tempfile::tempdir().expect("temp dir"); + write(&dir.path().join("a.md"), "alpha\n@b.md\n"); + write(&dir.path().join("b.md"), "beta\n@a.md\n"); + + let expansion = expand_imports(&dir.path().join("a.md"), None, 1024, None).await; + + assert_eq!(expansion.files.len(), 1); + assert_eq!(expansion.files[0].text, "alpha\nbeta"); +} + +/// A file outside the project root must not be read, however the import +/// names it: `~/`, an absolute path, a `..` climb, or a symlink that resolves +/// outside the root. +#[tokio::test] +async fn imports_outside_the_allowed_roots_are_refused() { + let project = tempfile::tempdir().expect("temp dir"); + let outside = tempfile::tempdir().expect("temp dir"); + let home = tempfile::tempdir().expect("temp dir"); + write(&outside.path().join("secret.md"), "secret\n"); + write(&home.path().join("secret.md"), "secret\n"); + symlink( + outside.path().join("secret.md"), + project.path().join("escape.md"), + ) + .expect("symlink"); + write( + &project.path().join("CLAUDE.md"), + "@~/secret.md\n@/etc/hostname\n@../secret.md\n@escape.md\n", + ); + let roots = vec![project.path().to_path_buf()]; + + let expansion = expand_imports( + &project.path().join("CLAUDE.md"), + Some(home.path()), + 1024, + Some(&roots), + ) + .await; + + let text = expansion + .files + .iter() + .map(|file| file.text.as_str()) + .collect::>() + .join("\n"); + // A refused import stays as the line that named it, so the text still + // contains the references; only the project file itself may be read. + assert_eq!( + expansion.bytes_read, + fs::read(project.path().join("CLAUDE.md")).unwrap().len(), + "an import outside the root was read: {text:?}" + ); + assert!( + expansion.warnings.len() >= 3, + "each refused import must be reported, got {:?}", + expansion.warnings + ); + assert!( + expansion.warnings.iter().all(|warning| warning.contains("outside the allowed project roots")), + "unexpected warning: {:?}", + expansion.warnings + ); +} From c643ff010ca74ae8fcdac52ea50561b3965edebc Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:28:48 +0000 Subject: [PATCH 3/7] Fill claude_compat_instructions in the thread-manager sample The fork field is required on Config, and the sample builds a Config literal by hand. Default it off so the workspace check compiles. WI-1131 --- codex-rs/thread-manager-sample/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index a70b8c9817f9..bb5aacac2126 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -323,6 +323,7 @@ async fn new_config( model_providers, project_doc_max_bytes: 32 * 1024, project_doc_fallback_filenames: Vec::new(), + claude_compat_instructions: false, tool_output_token_limit: None, agents_enabled: true, agent_max_threads: Some(6), From 4c82b8e92287c340e4a368ebc9aa710d3b99a4b0 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:10:35 +0000 Subject: [PATCH 4/7] Format the Claude-compat instructions code cargo fmt -p codex-home. No behaviour change. WI-1131 --- .../codex-home/src/instructions/claude_compat.rs | 6 +----- .../src/instructions/claude_compat_tests.rs | 5 ++++- codex-rs/codex-home/src/instructions/imports.rs | 16 ++++++++++++---- .../codex-home/src/instructions/imports_tests.rs | 8 ++++++-- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/codex-rs/codex-home/src/instructions/claude_compat.rs b/codex-rs/codex-home/src/instructions/claude_compat.rs index 8b4aa35dc6b4..a545b8e9b146 100644 --- a/codex-rs/codex-home/src/instructions/claude_compat.rs +++ b/codex-rs/codex-home/src/instructions/claude_compat.rs @@ -35,11 +35,7 @@ pub struct ClaudeCompatUserInstructionsProvider { impl ClaudeCompatUserInstructionsProvider { /// Creates a provider. `user_home` is the directory holding `.claude`; pass /// `None` when it cannot be determined and only the Codex home is loaded. - pub fn new( - codex_home: AbsolutePathBuf, - user_home: Option, - max_bytes: usize, - ) -> Self { + pub fn new(codex_home: AbsolutePathBuf, user_home: Option, max_bytes: usize) -> Self { Self { inner: CodexHomeUserInstructionsProvider::new(codex_home), user_home, diff --git a/codex-rs/codex-home/src/instructions/claude_compat_tests.rs b/codex-rs/codex-home/src/instructions/claude_compat_tests.rs index 419190dd98fe..9cb2b4ccb6f5 100644 --- a/codex-rs/codex-home/src/instructions/claude_compat_tests.rs +++ b/codex-rs/codex-home/src/instructions/claude_compat_tests.rs @@ -72,6 +72,9 @@ async fn the_budget_covers_claude_files_and_rules_together() { let text = loaded.instructions.expect("instructions").text; assert!(text.contains(claude_md)); - assert!(!text.contains("rule body"), "rule must be past the budget: {text:?}"); + assert!( + !text.contains("rule body"), + "rule must be past the budget: {text:?}" + ); assert_eq!(loaded.warnings.len(), 1); } diff --git a/codex-rs/codex-home/src/instructions/imports.rs b/codex-rs/codex-home/src/instructions/imports.rs index 4a1b81d4f93f..2cfb8c97ba9c 100644 --- a/codex-rs/codex-home/src/instructions/imports.rs +++ b/codex-rs/codex-home/src/instructions/imports.rs @@ -64,8 +64,14 @@ pub async fn expand_imports( allowed_roots: Option<&[PathBuf]>, ) -> ImportExpansion { let mut expansion = ImportExpansion::default(); - expand_with(&mut expansion, std::slice::from_ref(&path.to_path_buf()), home, budget, allowed_roots) - .await; + expand_with( + &mut expansion, + std::slice::from_ref(&path.to_path_buf()), + home, + budget, + allowed_roots, + ) + .await; expansion } @@ -178,8 +184,10 @@ impl Expander { Ok(path) => path, Err(err) if err.kind() == io::ErrorKind::NotFound => return None, Err(err) => { - self.warnings - .push(format!("Failed to read instructions from `{}`: {err}", path.display())); + self.warnings.push(format!( + "Failed to read instructions from `{}`: {err}", + path.display() + )); return None; } }; diff --git a/codex-rs/codex-home/src/instructions/imports_tests.rs b/codex-rs/codex-home/src/instructions/imports_tests.rs index 92025ce7a834..dd12c684c0fc 100644 --- a/codex-rs/codex-home/src/instructions/imports_tests.rs +++ b/codex-rs/codex-home/src/instructions/imports_tests.rs @@ -117,7 +117,8 @@ async fn home_prefix_resolves_against_the_given_home() { write(&home.path().join("shared.md"), "from home\n"); write(&dir.path().join("CLAUDE.md"), "@~/shared.md\n"); - let expansion = expand_imports(&dir.path().join("CLAUDE.md"), Some(home.path()), 1024, None).await; + let expansion = + expand_imports(&dir.path().join("CLAUDE.md"), Some(home.path()), 1024, None).await; assert_eq!(expansion.files[0].text, "from home"); } @@ -182,7 +183,10 @@ async fn imports_outside_the_allowed_roots_are_refused() { expansion.warnings ); assert!( - expansion.warnings.iter().all(|warning| warning.contains("outside the allowed project roots")), + expansion + .warnings + .iter() + .all(|warning| warning.contains("outside the allowed project roots")), "unexpected warning: {:?}", expansion.warnings ); From dd50b313fb5676f21fc8f26fa8534d91dc82878c Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:35:21 +0000 Subject: [PATCH 5/7] Return the app-server time provider directly The let binding existed only to be returned, which clippy's let_and_return lint flags, and the gate lints every file this PR touches. WI-1131 --- codex-rs/app-server/src/message_processor.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fb131ab26ff5..96c0e38b35e4 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -368,11 +368,10 @@ impl MessageProcessor { outgoing.clone(), thread_state_manager.clone(), )), - Some({ - let time_provider = - app_server_time_provider(outgoing.clone(), thread_state_manager.clone()); - time_provider - }), + Some(app_server_time_provider( + outgoing.clone(), + thread_state_manager.clone(), + )), ); match code_mode_session_provider { Some(provider) => manager.with_code_mode_session_provider(provider), From 2472a46b25e34fe052e705db3877d59642cfe149 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:07:06 +0000 Subject: [PATCH 6/7] Add a project_doc_mode that loads every project doc and its imports `project_doc_mode = "all"` keeps every matching filename in a directory instead of the first, then `.claude/rules/*.md` in sorted order, and expands line-only @path imports. Anything else, including an unset value, keeps the upstream first-match behaviour. Imports and rules are read through the executor filesystem and refused once they canonicalize outside the project root, so a repo cannot pull in an absolute path, a ~/ reference, a .. climb or a symlink that escapes. The untrusted-project skip still runs first, so none of this is read for an untrusted project. WI-1132 --- codex-rs/config/src/config_toml.rs | 8 + codex-rs/core/src/agents_md.rs | 99 ++++++-- codex-rs/core/src/agents_md_compat.rs | 254 ++++++++++++++++++++ codex-rs/core/src/agents_md_compat_tests.rs | 117 +++++++++ codex-rs/core/src/config/mod.rs | 9 + codex-rs/core/src/lib.rs | 1 + codex-rs/thread-manager-sample/src/main.rs | 1 + 7 files changed, 465 insertions(+), 24 deletions(-) create mode 100644 codex-rs/core/src/agents_md_compat.rs create mode 100644 codex-rs/core/src/agents_md_compat_tests.rs diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index e85af551e92d..1611d7f07221 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -343,6 +343,14 @@ pub struct ConfigToml { #[serde(default)] pub claude_compat_instructions: Option, + /// Fork (WI-1132): how a directory's project docs are chosen. `first` (the + /// default) keeps the upstream behaviour of the first matching filename per + /// directory. `all` loads every matching filename, plus `.claude/rules/*.md` + /// in sorted order, and expands `@path` imports confined to the project + /// root. An unrecognised value falls back to `first`. + #[serde(default)] + pub project_doc_mode: Option, + /// Token budget applied when storing tool/function outputs in the context manager. pub tool_output_token_limit: Option, diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 8e0c475d9033..4628d9bd58ee 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -139,6 +139,35 @@ async fn read_agents_md( return Ok(None); } + if config.project_doc_all { + let root = project_root(config, cwd, fs, sandbox).await?; + let root = root.as_ref().unwrap_or(cwd); + let docs = crate::agents_md_compat::expand_project_docs( + fs, + &paths, + root, + max_total, + sandbox, + ) + .await?; + let mut loaded = LoadedAgentsMd::default(); + for doc in docs { + loaded.entries.push(InstructionEntry { + contents: doc.text, + provenance: InstructionProvenance::Project { + source_path: doc.path, + environment_id: environment_id.to_string(), + cwd: cwd.clone(), + }, + }); + } + return if loaded.is_empty() { + Ok(None) + } else { + Ok(Some(loaded)) + }; + } + let mut remaining: u64 = max_total as u64; let mut loaded = LoadedAgentsMd::default(); @@ -195,31 +224,8 @@ async fn agents_md_paths( fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> io::Result> { + let project_root = project_root(config, cwd, fs, sandbox).await?; let dir = cwd.clone(); - - let mut merged = TomlValue::Table(toml::map::Map::new()); - for layer in config.config_layer_stack.layers_low_to_high() { - if matches!(layer.name, ConfigLayerSource::Project { .. }) { - continue; - } - merge_toml_values(&mut merged, &layer.config); - } - let project_root_markers = match project_root_markers_from_config(&merged) { - Ok(Some(markers)) => markers, - Ok(None) => default_project_root_markers(), - Err(err) => { - tracing::warn!("invalid project_root_markers: {err}"); - default_project_root_markers() - } - }; - let project_root = find_nearest_ancestor_with_markers( - fs, - &dir, - project_root_markers, - FindUpErrorPolicy::Ignore, - sandbox, - ) - .await?; let search_dirs = if let Some(root) = project_root { let mut dirs = Vec::new(); let mut cursor = dir.clone(); @@ -240,6 +246,23 @@ async fn agents_md_paths( }; let candidate_filenames = candidate_filenames(config, cwd); + if config.project_doc_all { + let mut found = Vec::new(); + for directory in &search_dirs { + let root = search_dirs.first().unwrap_or(directory); + found.extend( + crate::agents_md_compat::discover_compat_docs( + fs, + directory, + &candidate_filenames, + root, + sandbox, + ) + .await?, + ); + } + return Ok(found); + } let candidate_filenames = &candidate_filenames; let mut results = futures::stream::iter(search_dirs) .map(|directory| async move { @@ -269,6 +292,34 @@ async fn agents_md_paths( Ok(found) } +/// The nearest ancestor of `cwd` carrying a project-root marker. +/// +/// Markers come from the config layers above the project layer, so a project +/// cannot widen its own root and reach files outside it. +async fn project_root( + config: &Config, + cwd: &PathUri, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, +) -> io::Result> { + let mut merged = TomlValue::Table(toml::map::Map::new()); + for layer in config.config_layer_stack.layers_low_to_high() { + if matches!(layer.name, ConfigLayerSource::Project { .. }) { + continue; + } + merge_toml_values(&mut merged, &layer.config); + } + let markers = match project_root_markers_from_config(&merged) { + Ok(Some(markers)) => markers, + Ok(None) => default_project_root_markers(), + Err(err) => { + tracing::warn!("invalid project_root_markers: {err}"); + default_project_root_markers() + } + }; + find_nearest_ancestor_with_markers(fs, cwd, markers, FindUpErrorPolicy::Ignore, sandbox).await +} + fn candidate_filenames<'a>(config: &'a Config, cwd: &PathUri) -> Vec<&'a str> { let mut names: Vec<&str> = Vec::with_capacity(2 + config.project_doc_fallback_filenames.len()); names.push(LOCAL_AGENTS_MD_FILENAME); diff --git a/codex-rs/core/src/agents_md_compat.rs b/codex-rs/core/src/agents_md_compat.rs new file mode 100644 index 000000000000..7a7681a33cde --- /dev/null +++ b/codex-rs/core/src/agents_md_compat.rs @@ -0,0 +1,254 @@ +//! Fork (WI-1132): the `project_doc_mode = "all"` project-doc loader. +//! +//! Where the upstream discovery keeps the first matching filename in each +//! directory, this loads every matching filename and then `.claude/rules/*.md` +//! in sorted order, and expands line-only `@path` imports. Every file is read +//! through the executor filesystem and, after canonicalization, must fall +//! inside the project root, so a hostile repo cannot pull in `~/.codex`, +//! `/etc`, a `..` climb or a symlink that escapes the project. + +use std::collections::HashSet; +use std::hash::DefaultHasher; +use std::hash::Hash; +use std::hash::Hasher; +use std::io; + +use codex_file_system::ExecutorFileSystem; +use codex_file_system::FileSystemSandboxContext; +use codex_file_system::GetMetadataOptions; +use codex_file_system::ReadFileOptions; +use codex_utils_path_uri::PathUri; + +/// A file the `all` mode wants loaded, in the order it should appear. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct CompatDoc { + pub path: PathUri, + pub text: String, +} + +const RULES_DIR: &str = ".claude/rules"; +const MAX_IMPORT_DEPTH: usize = 5; + +/// Discovers the docs for one directory. +/// +/// `names` is the candidate filename list. Every name that names a file is +/// kept, in the order given, and `.claude/rules/*.md` follows in sorted order. +/// `root` is the project root the caller already computed; a doc or an import +/// that canonicalizes outside it is skipped. +pub(crate) async fn discover_compat_docs( + fs: &dyn ExecutorFileSystem, + directory: &PathUri, + names: &[&str], + root: &PathUri, + sandbox: Option<&FileSystemSandboxContext>, +) -> io::Result> { + let mut found = Vec::new(); + for name in names { + let candidate = directory + .join(name) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + if is_file(fs, &candidate, sandbox).await? { + found.push(candidate); + } + } + + let rules_dir = directory + .join(RULES_DIR) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + if let Some(mut rules) = rule_files(fs, &rules_dir, root, sandbox).await? { + rules.sort_by_key(ToString::to_string); + found.extend(rules); + } + Ok(found) +} + +/// Reads `paths` and expands their `@path` imports. +/// +/// `budget` is the number of bytes still available and counts every file read, +/// including one reached only through an import. Files are deduped by their +/// canonical path and by their body, and expansion stops at depth 5. +pub(crate) async fn expand_project_docs( + fs: &dyn ExecutorFileSystem, + paths: &[PathUri], + root: &PathUri, + budget: usize, + sandbox: Option<&FileSystemSandboxContext>, +) -> io::Result> { + let mut seen_paths = HashSet::new(); + let mut seen_bodies = HashSet::new(); + let mut docs = Vec::new(); + let mut remaining = budget; + for path in paths { + read_doc( + fs, + path, + root, + /*depth*/ 0, + sandbox, + &mut seen_paths, + &mut seen_bodies, + &mut docs, + &mut remaining, + ) + .await?; + } + Ok(docs) +} + +#[allow(clippy::too_many_arguments)] +async fn read_doc( + fs: &dyn ExecutorFileSystem, + path: &PathUri, + root: &PathUri, + depth: usize, + sandbox: Option<&FileSystemSandboxContext>, + seen_paths: &mut HashSet, + seen_bodies: &mut HashSet, + docs: &mut Vec, + remaining: &mut usize, +) -> io::Result<()> { + if *remaining == 0 || depth > MAX_IMPORT_DEPTH { + return Ok(()); + } + let canonical = match fs.canonicalize(path, sandbox).await { + Ok(path) => path, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + if !canonical.starts_with(root) { + tracing::warn!(path = %canonical, "refusing project doc outside the project root"); + return Ok(()); + } + if !seen_paths.insert(canonical.to_string()) { + return Ok(()); + } + + let mut data = fs + .read_file(&canonical, ReadFileOptions::default(), sandbox) + .await?; + if data.len() > *remaining { + data.truncate(*remaining); + } + *remaining = remaining.saturating_sub(data.len()); + let text = String::from_utf8_lossy(&data).to_string(); + + let mut body = String::new(); + if depth < MAX_IMPORT_DEPTH { + for line in text.lines() { + let trimmed = line.trim(); + if let Some(reference) = line_only_import(trimmed) { + let imported = resolve_import(path, reference)?; + let before = docs.len(); + Box::pin(read_doc( + fs, + &imported, + root, + depth + 1, + sandbox, + seen_paths, + seen_bodies, + docs, + remaining, + )) + .await?; + if docs.len() == before { + body.push_str(line); + body.push('\n'); + } + } else { + body.push_str(line); + body.push('\n'); + } + } + } else { + body = text; + } + + let digest = { + let mut hasher = DefaultHasher::new(); + body.trim().hash(&mut hasher); + hasher.finish() + }; + if body.trim().is_empty() || !seen_bodies.insert(digest) { + return Ok(()); + } + docs.push(CompatDoc { + path: canonical, + text: body.trim().to_string(), + }); + Ok(()) +} + +/// A line that is nothing but an `@path` reference, or `None`. +fn line_only_import(line: &str) -> Option<&str> { + let reference = line.strip_prefix('@')?.trim(); + if reference.is_empty() || reference.contains(char::is_whitespace) { + None + } else { + Some(reference) + } +} + +fn resolve_import(from: &PathUri, reference: &str) -> io::Result { + if reference.starts_with('~') || reference.starts_with('/') || reference.contains(':') { + PathUri::from_host_native_path(reference) + } else { + let parent = from + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "import has no parent"))?; + parent + .join(reference) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err)) + } +} + +async fn is_file( + fs: &dyn ExecutorFileSystem, + path: &PathUri, + sandbox: Option<&FileSystemSandboxContext>, +) -> io::Result { + match fs + .get_metadata(path, GetMetadataOptions::default(), sandbox) + .await + { + Ok(metadata) => Ok(metadata.is_file), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err), + } +} + +async fn rule_files( + fs: &dyn ExecutorFileSystem, + rules_dir: &PathUri, + root: &PathUri, + sandbox: Option<&FileSystemSandboxContext>, +) -> io::Result>> { + let canonical = match fs.canonicalize(rules_dir, sandbox).await { + Ok(path) => path, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + if !canonical.starts_with(root) { + tracing::warn!(path = %canonical, "refusing .claude/rules outside the project root"); + return Ok(None); + } + let entries = match fs.read_directory(&canonical, sandbox).await { + Ok(entries) => entries, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + let mut files = Vec::new(); + for entry in entries { + if entry.is_directory || !entry.file_name.ends_with(".md") { + continue; + } + if let Ok(path) = canonical.join(&entry.file_name) { + files.push(path); + } + } + Ok(Some(files)) +} + +#[cfg(test)] +#[path = "agents_md_compat_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/agents_md_compat_tests.rs b/codex-rs/core/src/agents_md_compat_tests.rs new file mode 100644 index 000000000000..a39a493db5ea --- /dev/null +++ b/codex-rs/core/src/agents_md_compat_tests.rs @@ -0,0 +1,117 @@ +//! Fork (WI-1132): the `all` project-doc mode. + +use std::fs; + +use codex_exec_server::LOCAL_FS; +use codex_utils_path_uri::PathUri; + +use super::discover_compat_docs; +use super::expand_project_docs; + +fn uri(path: &std::path::Path) -> PathUri { + PathUri::from_host_native_path(path).expect("path uri") +} + +fn write(path: &std::path::Path, contents: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("parent"); + } + fs::write(path, contents).expect("write"); +} + +/// Every matching filename is kept, and `.claude/rules/*.md` follows in order. +#[tokio::test] +async fn all_mode_keeps_every_doc_and_sorted_rules() { + let dir = tempfile::tempdir().expect("temp dir"); + write(&dir.path().join("AGENTS.md"), "agents\n"); + write(&dir.path().join("CLAUDE.md"), "claude\n"); + write(&dir.path().join(".claude/rules/b.md"), "rule b\n"); + write(&dir.path().join(".claude/rules/a.md"), "rule a\n"); + write(&dir.path().join(".claude/rules/notes.txt"), "not a rule\n"); + let root = uri(dir.path()); + + let found = discover_compat_docs( + LOCAL_FS.as_ref(), + &root, + &["AGENTS.md", "CLAUDE.md"], + &root, + None, + ) + .await + .expect("discover"); + + let names = found + .iter() + .map(|path| path.basename().unwrap_or_default()) + .collect::>(); + assert_eq!(names, ["AGENTS.md", "CLAUDE.md", "a.md", "b.md"]); +} + +/// An import that leaves the project is not read, whether it escapes by an +/// absolute path, `~/`, a `..` climb or a symlink. +#[tokio::test] +async fn imports_that_escape_the_project_root_are_refused() { + let project = tempfile::tempdir().expect("project"); + let outside = tempfile::tempdir().expect("outside"); + write(&outside.path().join("secret.md"), "TOPSECRET\n"); + std::os::unix::fs::symlink(outside.path().join("secret.md"), project.path().join("escape.md")) + .expect("symlink"); + let home = tempfile::tempdir().expect("home"); + write(&home.path().join("secret.md"), "TOPSECRET\n"); + // Safety: this test runs alone and only points HOME at a temp dir. + unsafe { std::env::set_var("HOME", home.path()) }; + write( + &project.path().join("CLAUDE.md"), + "@escape.md\n@../secret.md\n@/etc/hostname\n@~/secret.md\n", + ); + let root = uri(project.path()); + + let docs = expand_project_docs( + LOCAL_FS.as_ref(), + &[root.join("CLAUDE.md").expect("join")], + &root, + 4096, + None, + ) + .await + .expect("expand"); + + let text = docs + .iter() + .map(|doc| doc.text.as_str()) + .collect::>() + .join("\n"); + assert!( + !text.contains("TOPSECRET"), + "an import outside the project was read: {text}" + ); + let read = docs.iter().map(|doc| doc.text.len()).sum::(); + let own = fs::read(project.path().join("CLAUDE.md")).unwrap().len(); + assert!(read <= own, "read {read} bytes, the project file is only {own}"); +} + +/// A relative import that stays inside the project is expanded. +#[tokio::test] +async fn imports_inside_the_project_root_are_expanded() { + let project = tempfile::tempdir().expect("project"); + write(&project.path().join("CLAUDE.md"), "head\n@docs/more.md\n"); + write(&project.path().join("docs/more.md"), "body\n"); + let root = uri(project.path()); + + let docs = expand_project_docs( + LOCAL_FS.as_ref(), + &[root.join("CLAUDE.md").expect("join")], + &root, + 4096, + None, + ) + .await + .expect("expand"); + + let text = docs + .iter() + .map(|doc| doc.text.as_str()) + .collect::>() + .join("\n"); + assert!(text.contains("body"), "the in-project import was not read: {text}"); +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 260785baf764..ffef5599f45e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -936,6 +936,11 @@ pub struct Config { /// into the global user-instructions snapshot. Off unless the config sets it. pub claude_compat_instructions: bool, + /// Fork (WI-1132): load every matching project doc in a directory, plus + /// `.claude/rules/*.md`, and expand `@path` imports confined to the project + /// root. Off unless the config sets `project_doc_mode = "all"`. + pub project_doc_all: bool, + /// Token budget applied when storing tool/function outputs in the context manager. pub tool_output_token_limit: Option, @@ -4396,6 +4401,10 @@ impl Config { }) .collect(), claude_compat_instructions: cfg.claude_compat_instructions.unwrap_or(false), + project_doc_all: cfg + .project_doc_mode + .as_deref() + .is_some_and(|mode| mode.eq_ignore_ascii_case("all")), tool_output_token_limit: cfg.tool_output_token_limit, agents_enabled, agent_max_threads, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index a2b44e3f1fb6..dbd190343b95 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -171,6 +171,7 @@ pub type NewConversation = NewThread; #[deprecated(note = "use CodexThread")] pub type CodexConversation = CodexThread; pub(crate) mod agents_md; +mod agents_md_compat; mod agents_md_manager; pub use agents_md::DEFAULT_AGENTS_MD_FILENAME; pub use agents_md::LOCAL_AGENTS_MD_FILENAME; diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index bb5aacac2126..0511a5dc18eb 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -324,6 +324,7 @@ async fn new_config( project_doc_max_bytes: 32 * 1024, project_doc_fallback_filenames: Vec::new(), claude_compat_instructions: false, + project_doc_all: false, tool_output_token_limit: None, agents_enabled: true, agent_max_threads: Some(6), From 4ad67595af291ac46dc35e054b09d18d21c0543f Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:11:02 +0000 Subject: [PATCH 7/7] Format the project-doc compat loader cargo fmt -p codex-core. No behaviour change. WI-1132 --- codex-rs/core/src/agents_md.rs | 11 +++-------- codex-rs/core/src/agents_md_compat_tests.rs | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 4628d9bd58ee..77fff8278962 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -142,14 +142,9 @@ async fn read_agents_md( if config.project_doc_all { let root = project_root(config, cwd, fs, sandbox).await?; let root = root.as_ref().unwrap_or(cwd); - let docs = crate::agents_md_compat::expand_project_docs( - fs, - &paths, - root, - max_total, - sandbox, - ) - .await?; + let docs = + crate::agents_md_compat::expand_project_docs(fs, &paths, root, max_total, sandbox) + .await?; let mut loaded = LoadedAgentsMd::default(); for doc in docs { loaded.entries.push(InstructionEntry { diff --git a/codex-rs/core/src/agents_md_compat_tests.rs b/codex-rs/core/src/agents_md_compat_tests.rs index a39a493db5ea..3badf1e300d3 100644 --- a/codex-rs/core/src/agents_md_compat_tests.rs +++ b/codex-rs/core/src/agents_md_compat_tests.rs @@ -54,8 +54,11 @@ async fn imports_that_escape_the_project_root_are_refused() { let project = tempfile::tempdir().expect("project"); let outside = tempfile::tempdir().expect("outside"); write(&outside.path().join("secret.md"), "TOPSECRET\n"); - std::os::unix::fs::symlink(outside.path().join("secret.md"), project.path().join("escape.md")) - .expect("symlink"); + std::os::unix::fs::symlink( + outside.path().join("secret.md"), + project.path().join("escape.md"), + ) + .expect("symlink"); let home = tempfile::tempdir().expect("home"); write(&home.path().join("secret.md"), "TOPSECRET\n"); // Safety: this test runs alone and only points HOME at a temp dir. @@ -87,7 +90,10 @@ async fn imports_that_escape_the_project_root_are_refused() { ); let read = docs.iter().map(|doc| doc.text.len()).sum::(); let own = fs::read(project.path().join("CLAUDE.md")).unwrap().len(); - assert!(read <= own, "read {read} bytes, the project file is only {own}"); + assert!( + read <= own, + "read {read} bytes, the project file is only {own}" + ); } /// A relative import that stays inside the project is expanded. @@ -113,5 +119,8 @@ async fn imports_inside_the_project_root_are_expanded() { .map(|doc| doc.text.as_str()) .collect::>() .join("\n"); - assert!(text.contains("body"), "the in-project import was not read: {text}"); + assert!( + text.contains("body"), + "the in-project import was not read: {text}" + ); }