From efa135cc06fdb242d31b7cc52ffd7384a29dff36 Mon Sep 17 00:00:00 2001 From: maple Date: Thu, 2 Jul 2026 17:42:32 +0800 Subject: [PATCH 1/3] feat(tui): auto-discover .codewhale/rules/ and .claude/rules/ directories as project context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add rules-directory auto-discovery as solution D from #3867. - Scans .codewhale/rules/ (native) and .claude/rules/ (Claude compat) for *.md files - Loads in filename order, wraps each in elements - Separates rules into rules_block field to avoid blocking parent AGENTS.md traversal - Reuses load_context_file() for size checking + symlink safety (MAX_CONTEXT_SIZE 100KB) - Caps at MAX_RULES_FILES=50 per directory to prevent abuse - Adds rules files to project_context_cache_candidate_paths for proper cache invalidation - Updates /context report to surface rules Files: project_context.rs (+~190), context_report.rs (+18), project_context_cache.rs (+28) Tests: 11 new (9 rules + 2 cache), 67 passed, clippy clean --- crates/tui/src/context_report.rs | 18 +- crates/tui/src/project_context.rs | 351 +++++++++++++++++++++++- crates/tui/src/project_context_cache.rs | 38 +++ 3 files changed, 402 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/context_report.rs b/crates/tui/src/context_report.rs index ca408d8a5a..5b34d7a0fa 100644 --- a/crates/tui/src/context_report.rs +++ b/crates/tui/src/context_report.rs @@ -334,9 +334,14 @@ fn base_source_entries(model: &str, workspace: &Path, skills_dir: Option<&Path>) .source_path .as_ref() .map_or_else(|| "project".to_string(), |p| p.display().to_string()); - let block = format!( + let mut block = format!( "\n{content}\n" ); + // Include rules in the report when present + if let Some(rules) = &project_context.rules_block { + block.push('\n'); + block.push_str(rules); + } builder.push(SourceEntry::text( SourceKind::ProjectContext, "Project instructions", @@ -349,6 +354,17 @@ fn base_source_entries(model: &str, workspace: &Path, skills_dir: Option<&Path>) CountingConfidence::High, Some(5), )); + } else if let Some(rules) = &project_context.rules_block { + // Rules exist without main instructions + builder.push(SourceEntry::text( + SourceKind::ProjectContext, + "Project rules", + None::, + ActivationReason::FilePresent, + rules, + CountingConfidence::High, + Some(5), + )); } if project_context.constitution_block.is_none() && project_context.instructions.is_none() { diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 912807d344..616ea9befd 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -39,6 +39,13 @@ const PROJECT_CONTEXT_FILES: &[&str] = &[ ".deepseek/instructions.md", ]; +/// Rules directories auto-discovered at workspace level, in priority order. +/// `.codewhale/rules/` is CodeWhale-native; `.claude/rules/` is Claude compatibility. +/// All `.md` files in these directories are loaded as project rules in filename order. +/// Security model: same trust class as AGENTS.md — workspace-contained content only, +/// no absolute-path escape. Does not require #417 project-config relaxation. +const RULES_DIRS: &[&str] = &[".codewhale/rules", ".claude/rules"]; + /// File name of the deprecated CodeWhale-native instructions file. const DEPRECATED_WHALE_FILENAME: &str = "WHALE.md"; @@ -72,6 +79,10 @@ const GLOBAL_INSTRUCTIONS_LEGACY_PATH: &[&str] = &[".deepseek", "instructions.md /// Maximum size for project context files (to prevent loading huge files) const MAX_CONTEXT_SIZE: usize = 100 * 1024; // 100KB + +/// Maximum number of rule files loaded per rules directory. +/// Prevents a project from silently injecting hundreds of rule files. +const MAX_RULES_FILES: usize = 50; const PACK_README_MAX_CHARS: usize = 4_000; const PACK_MAX_ENTRIES: usize = 220; const PACK_MAX_SOURCE_FILES: usize = 60; @@ -133,6 +144,10 @@ enum ProjectContextError { pub struct ProjectContext { /// The loaded instructions content pub instructions: Option, + /// Auto-discovered rules from `.codewhale/rules/` / `.claude/rules/`. + /// Kept separate from `instructions` so rules alone don't block + /// parent-directory AGENTS.md discovery via `has_instructions()`. + pub rules_block: Option, /// Path to the loaded file (for display) pub source_path: Option, /// Any warnings during loading @@ -155,6 +170,7 @@ impl ProjectContext { pub fn empty(project_root: PathBuf) -> Self { Self { instructions: None, + rules_block: None, source_path: None, warnings: Vec::new(), constitution_block: None, @@ -181,18 +197,36 @@ impl ProjectContext { .as_ref() .map_or_else(|| "project".to_string(), |p| p.display().to_string()); - format!( + let mut block = format!( "\n{content}\n" - ) + ); + // Append rules after instructions, inside the same logical block. + // Rules are kept separate from `instructions` so they don't block + // parent-directory AGENTS.md discovery via `has_instructions()`. + if let Some(rules) = &self.rules_block { + block.push('\n'); + block.push_str(rules); + } + block }); match (self.constitution_block.as_ref(), instructions_block) { (Some(constitution), Some(instructions)) => { Some(format!("{constitution}\n\n{instructions}")) } - (Some(constitution), None) => Some(constitution.clone()), + (Some(constitution), None) => { + // Constitution present but no main instructions — still emit rules if any + if let Some(rules) = &self.rules_block { + Some(format!("{constitution}\n\n{rules}")) + } else { + Some(constitution.clone()) + } + } (None, Some(instructions)) => Some(instructions), - (None, None) => None, + (None, None) => { + // No main instructions, but rules may exist on their own + self.rules_block.clone() + } } } } @@ -841,6 +875,29 @@ pub fn load_project_context(workspace: &Path) -> ProjectContext { ctx.warnings .extend(ignored_project_whale_warnings(workspace)); + // Load rules from auto-discovered directories (.codewhale/rules/, .claude/rules/) + // Each rule file is wrapped in a block and appended after + // the main instructions content. Security model: same as AGENTS.md — + // workspace-contained content only, no absolute-path escape. + let mut rules_content = String::new(); + for rules_dir in RULES_DIRS { + let rules = load_rules_from_dir(workspace, rules_dir); + for (path, content) in rules { + if !rules_content.is_empty() { + rules_content.push('\n'); + } + rules_content.push_str(&format!( + "\n{}\n", + path.display(), + content.trim() + )); + } + } + + if !rules_content.is_empty() { + ctx.rules_block = Some(rules_content); + } + // Check for trust file ctx.is_trusted = check_trust_status(workspace); @@ -995,6 +1052,20 @@ pub(crate) fn project_context_cache_candidate_paths( paths.push(workspace.join(".deepseek").join("trust.json")); paths.extend(crate::config::workspace_trust_config_candidate_paths()); + // Include auto-discovered rules directory files so cache invalidates + // when rules change (not just when AGENTS.md changes). + for rules_dir in RULES_DIRS { + let dir_path = workspace.join(rules_dir); + if let Ok(entries) = std::fs::read_dir(&dir_path) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "md") { + paths.push(path); + } + } + } + } + paths } @@ -1222,6 +1293,70 @@ fn context_candidate_exists(path: &Path) -> bool { }) } +/// Scan a rules directory for `.md` files and load them in filename order. +/// Missing or unreadable directories return an empty vec (no error). +/// Each file is verified through `load_context_file` (size check, symlink safety). +fn load_rules_from_dir(workspace: &Path, rules_dir_name: &str) -> Vec<(PathBuf, String)> { + let rules_dir = workspace.join(rules_dir_name); + let mut entries: Vec<(PathBuf, String)> = Vec::new(); + + let dir_iter = match fs::read_dir(&rules_dir) { + Ok(iter) => iter, + Err(_) => return entries, + }; + + let mut file_paths: Vec = Vec::new(); + for entry in dir_iter.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "md") && context_candidate_exists(&path) { + file_paths.push(path); + } + } + + // Sort by filename for deterministic order + file_paths.sort_by(|a, b| { + a.file_name() + .unwrap_or_default() + .cmp(b.file_name().unwrap_or_default()) + }); + + // Enforce per-directory cap + let total = file_paths.len(); + if total > MAX_RULES_FILES { + tracing::warn!( + target: "project_context", + dir = %rules_dir.display(), + total, + cap = MAX_RULES_FILES, + "Truncating rules directory to cap" + ); + file_paths.truncate(MAX_RULES_FILES); + } + + for path in file_paths { + match load_context_file(&path) { + Ok(content) => { + tracing::info!( + "Loaded project rule from {} ({} bytes)", + path.display(), + content.len() + ); + entries.push((path, content)); + } + Err(error) => { + tracing::warn!( + target: "project_context", + ?error, + ?path, + "Skipping unreadable rules file" + ); + } + } + } + + entries +} + #[cfg(unix)] fn open_context_file(path: &Path) -> Result { use std::os::unix::fs::OpenOptionsExt; @@ -2444,4 +2579,212 @@ mod tests { .contains("Project Context (Auto-generated, ephemeral)") ); } + + // ── Rules directory auto-discovery tests ── + + #[test] + fn rules_from_codewhale_dir_are_loaded_as_project_context() { + let tmp = tempdir().expect("tempdir"); + let rules_dir = tmp.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + fs::write( + rules_dir.join("security.md"), + "# Security\nNo hardcoded secrets.", + ) + .expect("write"); + + let ctx = load_project_context(tmp.path()); + + let rules = ctx.rules_block.as_ref().expect("rules_block should be set"); + assert!( + rules.contains("Security"), + "expected rules content, got: {rules}" + ); + assert!( + rules.contains(" wrapper, got: {rules}" + ); + } + + #[test] + fn rules_are_loaded_in_filename_order() { + let tmp = tempdir().expect("tempdir"); + let rules_dir = tmp.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + fs::write(rules_dir.join("zzz.md"), "last").expect("write"); + fs::write(rules_dir.join("aaa.md"), "first").expect("write"); + fs::write(rules_dir.join("mmm.md"), "middle").expect("write"); + + let ctx = load_project_context(tmp.path()); + let rules = ctx.rules_block.as_ref().unwrap(); + + let pos_aaa = rules.find("first").unwrap(); + let pos_mmm = rules.find("middle").unwrap(); + let pos_zzz = rules.find("last").unwrap(); + assert!(pos_aaa < pos_mmm, "aaa should come before mmm"); + assert!(pos_mmm < pos_zzz, "mmm should come before zzz"); + } + + #[test] + fn rules_from_claude_dir_are_compat_loaded() { + let tmp = tempdir().expect("tempdir"); + let rules_dir = tmp.path().join(".claude/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + fs::write(rules_dir.join("style.md"), "Use tabs").expect("write"); + + let ctx = load_project_context(tmp.path()); + + let rules = ctx.rules_block.as_ref().expect("rules should be loaded"); + assert!( + rules.contains("Use tabs"), + "expected .claude/rules/ compat loading" + ); + } + + #[test] + fn rules_directory_missing_does_not_crash() { + let tmp = tempdir().expect("tempdir"); + // No .codewhale/rules/ or .claude/rules/ directories exist + let ctx = load_project_context(tmp.path()); + // Rules block should be None when no rules directories exist + assert!( + ctx.rules_block.is_none(), + "rules_block should be None when no rules exist" + ); + } + + #[test] + fn rules_coexist_with_agents_md() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "Main project instructions").expect("write"); + let rules_dir = tmp.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + fs::write(rules_dir.join("extra.md"), "Extra rule").expect("write"); + + let ctx = load_project_context(tmp.path()); + let instructions = ctx.instructions.as_ref().unwrap(); + let rules = ctx.rules_block.as_ref().unwrap(); + + assert!( + instructions.contains("Main project instructions"), + "AGENTS.md content missing" + ); + assert!(rules.contains("Extra rule"), "rules content missing"); + // AGENTS.md should come first in system block + let block = ctx.as_system_block().unwrap(); + let pos_agents = block.find("Main project instructions").unwrap(); + let pos_rule = block.find("Extra rule").unwrap(); + assert!(pos_agents < pos_rule, "AGENTS.md should precede rules"); + } + + #[test] + fn non_md_files_in_rules_dir_are_ignored() { + let tmp = tempdir().expect("tempdir"); + let rules_dir = tmp.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + fs::write(rules_dir.join("notes.txt"), "should be ignored").expect("write"); + fs::write(rules_dir.join("valid.md"), "loaded").expect("write"); + + let ctx = load_project_context(tmp.path()); + let rules = ctx.rules_block.as_ref().unwrap(); + + assert!(rules.contains("loaded"), "valid .md should be loaded"); + assert!( + !rules.contains("should be ignored"), + ".txt should be ignored" + ); + } + + #[test] + fn rules_cap_truncates_excess_files() { + let tmp = tempdir().expect("tempdir"); + let rules_dir = tmp.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + + // Create more files than the cap + for i in 0..60 { + fs::write( + rules_dir.join(format!("rule_{i:04}.md")), + format!("content {i}"), + ) + .expect("write"); + } + + let ctx = load_project_context(tmp.path()); + let rules = ctx.rules_block.as_ref().unwrap(); + + // The last file (by sorted name) should NOT be present + assert!( + !rules.contains("content 59"), + "rule_0059 should be above cap" + ); + // The first file should be present + assert!( + rules.contains("content 0"), + "rule_0000 should be within cap" + ); + // Count blocks + let count = rules.matches(" Date: Thu, 2 Jul 2026 19:04:54 +0800 Subject: [PATCH 2/3] fix(tui): refuse symlinked rules directories to prevent workspace escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A symlinked rules directory (e.g. .codewhale/rules -> /outside) would allow real .md files behind it to pass per-file symlink checks and be read from outside the workspace subtree — same escape class as #417. Adds fs::symlink_metadata guard in load_rules_from_dir() and project_context_cache_candidate_paths() to skip symlinked directories. New test rules_rejects_symlinked_directory locks in the guard. Reported-by: @LeoLin990405 --- crates/tui/src/project_context.rs | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 616ea9befd..73531d7b84 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -1056,6 +1056,13 @@ pub(crate) fn project_context_cache_candidate_paths( // when rules change (not just when AGENTS.md changes). for rules_dir in RULES_DIRS { let dir_path = workspace.join(rules_dir); + // Skip symlinked rules directories (same guard as load_rules_from_dir) + if fs::symlink_metadata(&dir_path) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + continue; + } if let Ok(entries) = std::fs::read_dir(&dir_path) { for entry in entries.flatten() { let path = entry.path(); @@ -1300,6 +1307,21 @@ fn load_rules_from_dir(workspace: &Path, rules_dir_name: &str) -> Vec<(PathBuf, let rules_dir = workspace.join(rules_dir_name); let mut entries: Vec<(PathBuf, String)> = Vec::new(); + // Refuse a symlinked rules directory: the real .md files behind it + // would pass per-file is_symlink checks and be read from outside the + // workspace subtree — same escape class as #417. + if fs::symlink_metadata(&rules_dir) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + tracing::warn!( + target: "project_context", + dir = %rules_dir.display(), + "Refusing symlinked rules directory" + ); + return entries; + } + let dir_iter = match fs::read_dir(&rules_dir) { Ok(iter) => iter, Err(_) => return entries, @@ -2758,6 +2780,34 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn rules_rejects_symlinked_directory() { + let workspace = tempdir().expect("workspace tempdir"); + let outside = tempdir().expect("outside tempdir"); + let outside_dir = outside.path().join("real_rules"); + fs::create_dir_all(&outside_dir).expect("mkdir outside dir"); + fs::write(outside_dir.join("secret.md"), "outside content").expect("write outside"); + fs::create_dir_all(workspace.path().join(".codewhale")).expect("mkdir codewhale"); + + // Symlink the directory itself, not individual files + std::os::unix::fs::symlink(&outside_dir, workspace.path().join(".codewhale/rules")) + .expect("symlink rules dir"); + + let ctx = load_project_context(workspace.path()); + + // Symlinked rules directory must be refused at the directory level + assert!( + ctx.rules_block.is_none() + || !ctx + .rules_block + .as_ref() + .unwrap() + .contains("outside content"), + "symlinked rules directory must be refused" + ); + } + #[test] fn rules_from_both_dirs_are_loaded_together() { let tmp = tempdir().expect("tempdir"); From 20926cf895f04980fba1efcfb284a037145f4a99 Mon Sep 17 00:00:00 2001 From: maple Date: Fri, 3 Jul 2026 09:03:19 +0800 Subject: [PATCH 3/3] feat(tui): add total byte budget cap for assembled rules block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 50 files × 100KB could reach ~5MB. Caps cumulative rules_block at MAX_RULES_BLOCK_BYTES=500KB with truncation marker to prevent a large rules directory from dominating the context window. Suggested-by: review on #3892 --- crates/tui/src/project_context.rs | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 73531d7b84..4eaf005fef 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -83,6 +83,13 @@ const MAX_CONTEXT_SIZE: usize = 100 * 1024; // 100KB /// Maximum number of rule files loaded per rules directory. /// Prevents a project from silently injecting hundreds of rule files. const MAX_RULES_FILES: usize = 50; + +/// Maximum total bytes across the assembled rules_block. +/// 50 files × 100 KB per file could reach ~5 MB; this caps the +/// cumulative injected content so a large rules directory can't +/// dominate the context window. Exceeded bytes are truncated with +/// an explicit marker. +const MAX_RULES_BLOCK_BYTES: usize = 500 * 1024; // 500 KB const PACK_README_MAX_CHARS: usize = 4_000; const PACK_MAX_ENTRIES: usize = 220; const PACK_MAX_SOURCE_FILES: usize = 60; @@ -895,6 +902,21 @@ pub fn load_project_context(workspace: &Path) -> ProjectContext { } if !rules_content.is_empty() { + // Cap total rules bytes so a large rules dir can't dominate the context window + if rules_content.len() > MAX_RULES_BLOCK_BYTES { + let mut end = MAX_RULES_BLOCK_BYTES; + while !rules_content.is_char_boundary(end) { + end -= 1; + } + rules_content.truncate(end); + rules_content.push_str("\n\n[…rules block truncated at 500 KB…]"); + tracing::warn!( + target: "project_context", + total_bytes = rules_content.len(), + cap = MAX_RULES_BLOCK_BYTES, + "Truncating rules block to total byte budget" + ); + } ctx.rules_block = Some(rules_content); } @@ -2837,4 +2859,31 @@ mod tests { ".codewhale/rules/ should precede .claude/rules/" ); } + + #[test] + fn rules_block_truncated_at_total_byte_budget() { + let tmp = tempdir().expect("tempdir"); + let rules_dir = tmp.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + + // Create files whose combined content exceeds MAX_RULES_BLOCK_BYTES + let per_file = "X".repeat(20 * 1024); // 20 KB each + for i in 0..30 { + fs::write(rules_dir.join(format!("rule_{i:04}.md")), &per_file).expect("write"); + } + + let ctx = load_project_context(tmp.path()); + let rules = ctx.rules_block.as_ref().unwrap(); + + assert!( + rules.len() <= MAX_RULES_BLOCK_BYTES + 200, // + marker overhead + "rules block should be truncated to budget: {} > {}", + rules.len(), + MAX_RULES_BLOCK_BYTES + ); + assert!( + rules.contains("truncated at 500 KB"), + "truncation marker missing" + ); + } }