From 2af8cf6b37d0f49a1909ee4a21a6837d1a88fc08 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Tue, 15 Sep 2026 20:17:17 -0400 Subject: [PATCH] test(daemon): kill 26 surviving mutants in the tool and repo-context guards Covers the pure helpers in session/claude.rs (12 of its 27 missed mutants) and all 15 in intelligence/repo_context.rs, from the gate's exact lists at b697493. Both claude.rs helpers are long `||` chains, where a `||` -> `&&` mutation pairs the terms on either side of the operator. Such a mutation is only observable for an input that trips exactly one of that pair, so each test uses a tool name isolating a single link: "kill_process" and "terminal_session" for the high-risk chain, "apply_patch" / "replace_text" / "insert_line" / "append_to_file" for the medium one, "run_shell" and "terminal" for the is_shell_tool chain. The home-directory guard gets both directions: a path outside $HOME must be blocked and a path inside it must not. That pins all three of its mutations, two of which would lock an agent out of the user's own working tree rather than letting anything escape. build_modified_section had no coverage at all -- every one of its mutants survived -- so it now has real git2 fixtures. A single-commit repo and a two-commit repo together separate `parent_count() > 0` from `< 0`, `== 0` and `>= 0`: the first commit must diff against the empty tree, the second must diff against its parent and list only the file that changed. Every mutant was hand-applied and the suite confirmed to fail: 26 of 27 killed. The one that survives is equivalent and documented in place: replacing the `Ok(files) if !files.is_empty()` guard with a constant `true`. When files is empty, `files.join("\n")` is the empty string, which is exactly what the fallback arm returns, so no input can tell them apart. Two comparisons needed fixtures built to land on an exact value rather than near it. `total > MAX_ROOT_ENTRIES` is separated from `>=` by a directory holding exactly MAX_ROOT_ENTRIES files, which must produce no "... 0 more files" line. `out.len() > MAX_CHARS` is separated from `>=` by a context of exactly MAX_CHARS, which must not be truncated; that fixture computes its filename lengths from the constants and asserts it hit the cap before asserting anything else, so a later change to the header or section layout fails the test rather than quietly making it vacuous. Not covered here, and still open in claude.rs: the async runner internals (run_turn, event_loop) and the `pid != 0` guards in pause/resume/stop. Those need a live child process, and the pid guards carry a real hazard -- mutating `!= 0` to `== 0` makes the code call libc::kill(0, ...), which signals the entire process group. They need an isolated harness, not an in-process test. Inline `mod tests` blocks move to `/tests.rs` with existing cases kept verbatim. No production code changes. --- apps/daemon/src/intelligence/repo_context.rs | 261 +-------- .../src/intelligence/repo_context/tests.rs | 505 ++++++++++++++++++ apps/daemon/src/session/claude.rs | 113 +--- apps/daemon/src/session/claude/tests.rs | 295 ++++++++++ 4 files changed, 804 insertions(+), 370 deletions(-) create mode 100644 apps/daemon/src/intelligence/repo_context/tests.rs create mode 100644 apps/daemon/src/session/claude/tests.rs diff --git a/apps/daemon/src/intelligence/repo_context.rs b/apps/daemon/src/intelligence/repo_context.rs index 065a1d6..1c163d3 100644 --- a/apps/daemon/src/intelligence/repo_context.rs +++ b/apps/daemon/src/intelligence/repo_context.rs @@ -272,263 +272,6 @@ fn build_session_section(repo_path: &Path, recent_messages: &[ContextMessage]) - // ── Tests ───────────────────────────────────────────────────────────────────── +// Tests live in repo_context/tests.rs. #[cfg(test)] -mod tests { - use super::*; - use std::io::Write as _; - use tempfile::TempDir; - - // ── helpers ─────────────────────────────────────────────────────────────── - - fn make_dir() -> TempDir { - tempfile::tempdir().expect("tempdir") - } - - fn create_file(dir: &TempDir, name: &str) { - let path = dir.path().join(name); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).ok(); - } - let mut f = std::fs::File::create(&path).expect("create file"); - writeln!(f, "// {}", name).ok(); - } - - fn msg(content: &str) -> ContextMessage { - ContextMessage { - role: "user".to_string(), - content: content.to_string(), - pinned: false, - } - } - - // ── structure section tests ─────────────────────────────────────────────── - - #[test] - fn test_structure_small_project_no_truncation() { - let dir = make_dir(); - // Create 10 files at root - for i in 0..10 { - create_file(&dir, &format!("file{i}.rs")); - } - create_file(&dir, "Cargo.toml"); - - let result = build_structure_section(dir.path()); - // All 11 files should appear — no truncation message - assert!(!result.contains("more files"), "should not truncate"); - assert!(result.contains("Cargo.toml\n") || result.contains("file0.rs\n")); - } - - #[test] - fn test_structure_large_project_truncated() { - let dir = make_dir(); - // Create 80 files at root — exceeds MAX_ROOT_ENTRIES (50) - for i in 0..80 { - create_file(&dir, &format!("file{i:03}.rs")); - } - - let result = build_structure_section(dir.path()); - assert!( - result.contains("more files"), - "should have truncation note — got:\n{result}" - ); - // Exactly 50 entries + truncation line - let file_lines: Vec<&str> = result.lines().filter(|l| l.starts_with("file")).collect(); - assert_eq!(file_lines.len(), 50, "exactly 50 file entries expected"); - } - - #[test] - fn test_structure_hides_sensitive_files() { - let dir = make_dir(); - create_file(&dir, "main.rs"); - create_file(&dir, ".env"); - create_file(&dir, "private.key"); - create_file(&dir, "cert.pem"); - - let result = build_structure_section(dir.path()); - assert!(result.contains("main.rs"), "main.rs should appear"); - assert!(!result.contains(".env"), ".env must be hidden"); - assert!(!result.contains("private.key"), "*.key must be hidden"); - assert!(!result.contains("cert.pem"), "*.pem must be hidden"); - } - - #[test] - fn test_structure_hides_dot_git() { - let dir = make_dir(); - create_file(&dir, "README.md"); - // Create a .git directory - std::fs::create_dir(dir.path().join(".git")).ok(); - - let result = build_structure_section(dir.path()); - assert!(result.contains("README.md"), "README should appear"); - assert!(!result.contains(".git"), ".git must be hidden"); - } - - // ── session section tests ───────────────────────────────────────────────── - - #[test] - fn test_session_file_ref_included() { - let dir = make_dir(); - create_file(&dir, "src/auth.rs"); - - let messages = vec![ - msg("Let's look at src/auth.rs for the auth logic"), - msg("The handler is in src/auth.rs:42"), - ]; - - let result = build_session_section(dir.path(), &messages); - assert!( - result.contains("src/auth.rs"), - "src/auth.rs should appear in session context — got: {result}" - ); - } - - #[test] - fn test_session_deduplicates_refs() { - let dir = make_dir(); - create_file(&dir, "src/main.rs"); - - let messages = vec![ - msg("check src/main.rs"), - msg("also see src/main.rs"), - msg("and src/main.rs again"), - ]; - - let result = build_session_section(dir.path(), &messages); - let count = result.matches("src/main.rs").count(); - assert_eq!(count, 1, "should deduplicate repeated refs"); - } - - #[test] - fn test_session_no_path_traversal() { - let dir = make_dir(); - let messages = vec![msg("look at ../../etc/passwd for details")]; - let result = build_session_section(dir.path(), &messages); - assert!( - !result.contains("etc/passwd"), - "traversal path must be rejected" - ); - } - - #[test] - fn test_session_sensitive_refs_excluded() { - let dir = make_dir(); - create_file(&dir, "config/.env"); - create_file(&dir, "keys/server.key"); - - let messages = vec![msg("I edited config/.env and keys/server.key today")]; - let result = build_session_section(dir.path(), &messages); - assert!(!result.contains(".env"), ".env refs must be excluded"); - assert!( - !result.contains("server.key"), - "*.key refs must be excluded" - ); - } - - #[test] - fn test_session_only_last_5_messages() { - let dir = make_dir(); - create_file(&dir, "src/old.rs"); - create_file(&dir, "src/new.rs"); - - // 6 messages — only last 5 should be scanned. - // src/old.rs only in message #1 (index 0), src/new.rs in message #6 (index 5). - let messages = vec![ - msg("see src/old.rs"), // index 0 — NOT in last 5 - msg("nothing here"), - msg("nothing here"), - msg("nothing here"), - msg("nothing here"), - msg("check src/new.rs"), // index 5 — in last 5 - ]; - - let result = build_session_section(dir.path(), &messages); - assert!(result.contains("src/new.rs"), "src/new.rs should appear"); - assert!( - !result.contains("src/old.rs"), - "src/old.rs is outside last 5 messages — should not appear" - ); - } - - // ── full build_repo_context tests ───────────────────────────────────────── - - #[test] - fn test_full_output_under_max_chars() { - let dir = make_dir(); - for i in 0..30 { - create_file(&dir, &format!("src/module{i}.rs")); - } - create_file(&dir, "Cargo.toml"); - create_file(&dir, "README.md"); - - let messages = vec![msg("let's look at src/module0.rs")]; - let output = build_repo_context(dir.path(), &messages).unwrap(); - - assert!( - output.len() <= MAX_CHARS, - "output length {} exceeds MAX_CHARS {}", - output.len(), - MAX_CHARS - ); - } - - #[test] - fn test_full_output_has_sections() { - let dir = make_dir(); - create_file(&dir, "main.rs"); - create_file(&dir, "lib.rs"); - - let output = build_repo_context(dir.path(), &[]).unwrap(); - assert!( - output.contains("## Project Structure"), - "missing structure section" - ); - } - - #[test] - fn test_full_session_context_section_present() { - let dir = make_dir(); - create_file(&dir, "src/auth.rs"); - let messages = vec![msg("working on src/auth.rs today")]; - - let output = build_repo_context(dir.path(), &messages).unwrap(); - assert!( - output.contains("## Session Context"), - "missing session section" - ); - assert!(output.contains("src/auth.rs")); - } - - #[test] - fn test_is_sensitive_patterns() { - assert!(is_sensitive(".env")); - assert!(is_sensitive(".env.local")); - assert!(is_sensitive(".envrc")); - assert!(is_sensitive("private.key")); - assert!(is_sensitive("cert.pem")); - assert!(is_sensitive("bundle.p12")); - assert!(is_sensitive("bundle.pfx")); - assert!(is_sensitive("api.secret")); - assert!(is_sensitive("server.crt")); - assert!(!is_sensitive("main.rs")); - assert!(!is_sensitive("Cargo.toml")); - assert!(!is_sensitive("README.md")); - assert!(!is_sensitive("env.rs")); // not a dotfile - } - - #[test] - fn test_directories_have_trailing_slash() { - let dir = make_dir(); - std::fs::create_dir(dir.path().join("src")).ok(); - create_file(&dir, "main.rs"); - - let result = build_structure_section(dir.path()); - assert!( - result.contains("src/"), - "directories should have trailing /" - ); - assert!( - result.contains("main.rs"), - "files should not have trailing /" - ); - } -} +mod tests; diff --git a/apps/daemon/src/intelligence/repo_context/tests.rs b/apps/daemon/src/intelligence/repo_context/tests.rs new file mode 100644 index 0000000..38a39d0 --- /dev/null +++ b/apps/daemon/src/intelligence/repo_context/tests.rs @@ -0,0 +1,505 @@ +//! Tests for the repo-context builder. +//! +//! The first group is written against the surviving-mutant list from the +//! mutation gate. `build_modified_section` had no coverage at all — every one +//! of its mutants survived — so it gets real git fixtures here rather than a +//! mock. + +use super::*; + +/// Initialise a git repo in a fresh temp dir. +fn init_repo() -> (TempDir, git2::Repository) { + let dir = TempDir::new().unwrap(); + let repo = git2::Repository::init(dir.path()).unwrap(); + (dir, repo) +} + +/// Stage everything in the work tree and commit it, chaining onto HEAD when +/// one exists. Returns nothing — the tests assert on the section text. +fn commit_all(repo: &git2::Repository, message: &str) { + let mut index = repo.index().unwrap(); + index + .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None) + .unwrap(); + index.write().unwrap(); + let tree = repo.find_tree(index.write_tree().unwrap()).unwrap(); + let sig = git2::Signature::now("Test", "test@example.com").unwrap(); + + let head = repo.head().ok().and_then(|h| h.peel_to_commit().ok()); + let parents: Vec<&git2::Commit> = head.iter().collect(); + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents) + .unwrap(); +} + +fn write(dir: &TempDir, name: &str, body: &str) { + std::fs::write(dir.path().join(name), body).unwrap(); +} + +// ─── is_sensitive ──────────────────────────────────────────────────────────── + +/// Kills the `||` -> `&&` mutation pairing the `secrets.json` and +/// `credentials.json` exact-name checks. No single name can equal both, so the +/// pairing makes each unreachable and both files start leaking into AI context. +#[test] +fn both_exact_name_secrets_are_matched_independently() { + assert!(is_sensitive("secrets.json")); + assert!(is_sensitive("credentials.json")); + // The negative direction, so the checks cannot be met by matching all names. + assert!(!is_sensitive("settings.json")); +} + +// ─── build_modified_section ────────────────────────────────────────────────── + +/// Kills the whole-body replacements (`String::new()`, `"xyzzy".into()`), the +/// `>= 0` and `== 0` mutations of the `parent_count() > 0` branch, and the +/// `delete !` / `false` mutations of the `!files.is_empty()` match guard. +/// +/// On an initial commit `parent_count()` is 0, so the real code diffs against +/// the empty tree and every file in the commit is listed. Both `>= 0` and +/// `== 0` send it down the parent branch instead, where `parent(0)` fails and +/// the whole section collapses to "". +#[test] +fn an_initial_commit_is_diffed_against_the_empty_tree() { + let (dir, repo) = init_repo(); + write(&dir, "a.txt", "one\n"); + write(&dir, "b.txt", "two\n"); + commit_all(&repo, "initial"); + + let out = build_modified_section(dir.path()); + let mut lines: Vec<&str> = out.lines().collect(); + lines.sort_unstable(); + assert_eq!(lines, vec!["a.txt", "b.txt"]); +} + +/// Kills the `< 0` and `== 0` mutations of `parent_count() > 0`. +/// +/// With a parent present the real code diffs the commit against that parent, +/// so only the file that actually changed is listed. Both mutations make the +/// condition false and fall into the empty-tree branch, which reports every +/// file in the tree as recently modified. +#[test] +fn a_later_commit_is_diffed_against_its_parent_only() { + let (dir, repo) = init_repo(); + write(&dir, "a.txt", "one\n"); + write(&dir, "b.txt", "two\n"); + commit_all(&repo, "initial"); + + write(&dir, "b.txt", "two, revised\n"); + commit_all(&repo, "second"); + + assert_eq!(build_modified_section(dir.path()), "b.txt"); +} + +/// Kills the `delete !` mutation on `!is_sensitive(&name)` inside the diff +/// walk — with the `!` dropped the filter inverts and the section lists +/// *only* the secrets. +#[test] +fn sensitive_files_are_never_listed_as_modified() { + let (dir, repo) = init_repo(); + write(&dir, ".env", "TOKEN=shh\n"); + write(&dir, "app.rs", "fn main() {}\n"); + commit_all(&repo, "initial"); + + assert_eq!(build_modified_section(dir.path()), "app.rs"); +} + +#[test] +fn a_repo_with_no_commits_has_no_modified_section() { + let (dir, _repo) = init_repo(); + write(&dir, "a.txt", "one\n"); + assert_eq!(build_modified_section(dir.path()), ""); +} + +#[test] +fn a_directory_that_is_not_a_repo_has_no_modified_section() { + let dir = TempDir::new().unwrap(); + write(&dir, "a.txt", "one\n"); + assert_eq!(build_modified_section(dir.path()), ""); +} + +// NOTE — equivalent mutant, deliberately not chased: replacing the +// `Ok(files) if !files.is_empty()` guard with a constant `true`. When `files` +// is empty, `files.join("\n")` is the empty string, which is exactly what the +// `_` arm returns; no input can tell the two apart. + +// ─── build_structure_section ───────────────────────────────────────────────── + +/// Kills the `!=` -> `==` mutation in `name.starts_with('.') && name != +/// ".claude"`. The real rule is "skip hidden entries, except .claude"; the +/// mutation inverts it into "skip only .claude", which both hides the one +/// hidden directory that matters and exposes every other dotfile. +#[test] +fn dot_claude_is_the_only_hidden_entry_kept() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".claude")).unwrap(); + std::fs::create_dir(dir.path().join("src")).unwrap(); + write(&dir, ".hidden", "x"); + write(&dir, "Cargo.toml", "x"); + + let out = build_structure_section(dir.path()); + assert!(out.contains(".claude/"), "got: {out:?}"); + assert!(out.contains("src/")); + assert!(out.contains("Cargo.toml")); + assert!(!out.contains(".hidden"), "got: {out:?}"); +} + +/// Kills the `>` -> `>=` mutation of `total > MAX_ROOT_ENTRIES`. +/// +/// At exactly the cap there is nothing left over, so the real code adds no +/// trailing line; `>=` appends a nonsensical "... 0 more files". +#[test] +fn exactly_the_entry_cap_adds_no_overflow_line() { + let dir = TempDir::new().unwrap(); + for i in 0..MAX_ROOT_ENTRIES { + write(&dir, &format!("f{i:03}.txt"), "x"); + } + let out = build_structure_section(dir.path()); + assert_eq!(out.lines().count(), MAX_ROOT_ENTRIES); + assert!(!out.contains("more files"), "got: {out:?}"); +} + +#[test] +fn one_past_the_entry_cap_reports_the_remainder() { + let dir = TempDir::new().unwrap(); + for i in 0..MAX_ROOT_ENTRIES + 3 { + write(&dir, &format!("f{i:03}.txt"), "x"); + } + let out = build_structure_section(dir.path()); + assert!(out.contains("... 3 more files"), "got: {out:?}"); +} + +// ─── build_repo_context ────────────────────────────────────────────────────── + +/// Kills the `delete !` mutation on `!modified.is_empty()` — with the `!` +/// dropped the section is emitted only when there is nothing to put in it, so +/// the header never appears for a repo that actually has commits. +#[test] +fn the_modified_section_appears_when_there_are_modifications() { + let (dir, repo) = init_repo(); + write(&dir, "a.txt", "one\n"); + commit_all(&repo, "initial"); + + let out = build_repo_context(dir.path(), &[]).unwrap(); + assert!(out.contains("## Recently Modified"), "got: {out:?}"); + assert!(out.contains("a.txt")); +} + +#[test] +fn the_modified_section_is_omitted_when_there_are_no_modifications() { + let dir = TempDir::new().unwrap(); + write(&dir, "a.txt", "one\n"); + + let out = build_repo_context(dir.path(), &[]).unwrap(); + assert!(!out.contains("## Recently Modified"), "got: {out:?}"); +} + +/// Kills the `>` -> `==` mutation of `out.len() > MAX_CHARS`. Overshooting the +/// cap by an arbitrary amount must still truncate; `==` only truncates on an +/// exact hit, so oversized context sails straight through to the model. +#[test] +fn context_well_over_the_cap_is_truncated() { + let dir = TempDir::new().unwrap(); + // 50 entries (the structure cap) of 200 chars each — far past MAX_CHARS. + for i in 0..MAX_ROOT_ENTRIES { + write(&dir, &format!("f{i:03}{}", "x".repeat(196)), "x"); + } + let out = build_repo_context(dir.path(), &[]).unwrap(); + assert!(out.len() <= MAX_CHARS, "len was {}", out.len()); + assert!( + out.ends_with("\n... (truncated)"), + "got tail: {:?}", + &out[out.len().saturating_sub(40)..] + ); +} + +/// Kills the `>` -> `>=` mutation of the same comparison — the only input that +/// separates them is a context of *exactly* MAX_CHARS, which must NOT be +/// truncated. +/// +/// The fixture is sized to land on the cap precisely: the output is the +/// 21-byte "## Project Structure\n" header plus one line per file, and the +/// filename lengths below are chosen so those lines total MAX_CHARS - 21. The +/// first assertion checks the fixture actually hit the mark, so if the header +/// or the section layout ever changes this test fails loudly rather than +/// quietly stopping to test anything. +#[test] +fn context_of_exactly_the_cap_is_not_truncated() { + const HEADER: usize = "## Project Structure\n".len(); + let dir = TempDir::new().unwrap(); + + // Each entry contributes name.len() + 1 (the newline). + let budget = MAX_CHARS - HEADER - MAX_ROOT_ENTRIES; + let base = budget / MAX_ROOT_ENTRIES; + let long = budget % MAX_ROOT_ENTRIES; // this many need one extra char + for i in 0..MAX_ROOT_ENTRIES { + let len = if i < long { base + 1 } else { base }; + // "fNNN" prefix keeps the names unique; pad the rest to `len`. + write(&dir, &format!("f{i:03}{}", "x".repeat(len - 4)), "x"); + } + + let out = build_repo_context(dir.path(), &[]).unwrap(); + assert_eq!(out.len(), MAX_CHARS, "fixture did not land on the cap"); + assert!( + !out.contains("(truncated)"), + "exactly at the cap must not truncate" + ); +} + +// ─── pre-existing tests, kept verbatim ────────────────────────────────────── + +use std::io::Write as _; +use tempfile::TempDir; + +// ── helpers ─────────────────────────────────────────────────────────────── + +fn make_dir() -> TempDir { + tempfile::tempdir().expect("tempdir") +} + +fn create_file(dir: &TempDir, name: &str) { + let path = dir.path().join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + let mut f = std::fs::File::create(&path).expect("create file"); + writeln!(f, "// {}", name).ok(); +} + +fn msg(content: &str) -> ContextMessage { + ContextMessage { + role: "user".to_string(), + content: content.to_string(), + pinned: false, + } +} + +// ── structure section tests ─────────────────────────────────────────────── + +#[test] +fn test_structure_small_project_no_truncation() { + let dir = make_dir(); + // Create 10 files at root + for i in 0..10 { + create_file(&dir, &format!("file{i}.rs")); + } + create_file(&dir, "Cargo.toml"); + + let result = build_structure_section(dir.path()); + // All 11 files should appear — no truncation message + assert!(!result.contains("more files"), "should not truncate"); + assert!(result.contains("Cargo.toml\n") || result.contains("file0.rs\n")); +} + +#[test] +fn test_structure_large_project_truncated() { + let dir = make_dir(); + // Create 80 files at root — exceeds MAX_ROOT_ENTRIES (50) + for i in 0..80 { + create_file(&dir, &format!("file{i:03}.rs")); + } + + let result = build_structure_section(dir.path()); + assert!( + result.contains("more files"), + "should have truncation note — got:\n{result}" + ); + // Exactly 50 entries + truncation line + let file_lines: Vec<&str> = result.lines().filter(|l| l.starts_with("file")).collect(); + assert_eq!(file_lines.len(), 50, "exactly 50 file entries expected"); +} + +#[test] +fn test_structure_hides_sensitive_files() { + let dir = make_dir(); + create_file(&dir, "main.rs"); + create_file(&dir, ".env"); + create_file(&dir, "private.key"); + create_file(&dir, "cert.pem"); + + let result = build_structure_section(dir.path()); + assert!(result.contains("main.rs"), "main.rs should appear"); + assert!(!result.contains(".env"), ".env must be hidden"); + assert!(!result.contains("private.key"), "*.key must be hidden"); + assert!(!result.contains("cert.pem"), "*.pem must be hidden"); +} + +#[test] +fn test_structure_hides_dot_git() { + let dir = make_dir(); + create_file(&dir, "README.md"); + // Create a .git directory + std::fs::create_dir(dir.path().join(".git")).ok(); + + let result = build_structure_section(dir.path()); + assert!(result.contains("README.md"), "README should appear"); + assert!(!result.contains(".git"), ".git must be hidden"); +} + +// ── session section tests ───────────────────────────────────────────────── + +#[test] +fn test_session_file_ref_included() { + let dir = make_dir(); + create_file(&dir, "src/auth.rs"); + + let messages = vec![ + msg("Let's look at src/auth.rs for the auth logic"), + msg("The handler is in src/auth.rs:42"), + ]; + + let result = build_session_section(dir.path(), &messages); + assert!( + result.contains("src/auth.rs"), + "src/auth.rs should appear in session context — got: {result}" + ); +} + +#[test] +fn test_session_deduplicates_refs() { + let dir = make_dir(); + create_file(&dir, "src/main.rs"); + + let messages = vec![ + msg("check src/main.rs"), + msg("also see src/main.rs"), + msg("and src/main.rs again"), + ]; + + let result = build_session_section(dir.path(), &messages); + let count = result.matches("src/main.rs").count(); + assert_eq!(count, 1, "should deduplicate repeated refs"); +} + +#[test] +fn test_session_no_path_traversal() { + let dir = make_dir(); + let messages = vec![msg("look at ../../etc/passwd for details")]; + let result = build_session_section(dir.path(), &messages); + assert!( + !result.contains("etc/passwd"), + "traversal path must be rejected" + ); +} + +#[test] +fn test_session_sensitive_refs_excluded() { + let dir = make_dir(); + create_file(&dir, "config/.env"); + create_file(&dir, "keys/server.key"); + + let messages = vec![msg("I edited config/.env and keys/server.key today")]; + let result = build_session_section(dir.path(), &messages); + assert!(!result.contains(".env"), ".env refs must be excluded"); + assert!( + !result.contains("server.key"), + "*.key refs must be excluded" + ); +} + +#[test] +fn test_session_only_last_5_messages() { + let dir = make_dir(); + create_file(&dir, "src/old.rs"); + create_file(&dir, "src/new.rs"); + + // 6 messages — only last 5 should be scanned. + // src/old.rs only in message #1 (index 0), src/new.rs in message #6 (index 5). + let messages = vec![ + msg("see src/old.rs"), // index 0 — NOT in last 5 + msg("nothing here"), + msg("nothing here"), + msg("nothing here"), + msg("nothing here"), + msg("check src/new.rs"), // index 5 — in last 5 + ]; + + let result = build_session_section(dir.path(), &messages); + assert!(result.contains("src/new.rs"), "src/new.rs should appear"); + assert!( + !result.contains("src/old.rs"), + "src/old.rs is outside last 5 messages — should not appear" + ); +} + +// ── full build_repo_context tests ───────────────────────────────────────── + +#[test] +fn test_full_output_under_max_chars() { + let dir = make_dir(); + for i in 0..30 { + create_file(&dir, &format!("src/module{i}.rs")); + } + create_file(&dir, "Cargo.toml"); + create_file(&dir, "README.md"); + + let messages = vec![msg("let's look at src/module0.rs")]; + let output = build_repo_context(dir.path(), &messages).unwrap(); + + assert!( + output.len() <= MAX_CHARS, + "output length {} exceeds MAX_CHARS {}", + output.len(), + MAX_CHARS + ); +} + +#[test] +fn test_full_output_has_sections() { + let dir = make_dir(); + create_file(&dir, "main.rs"); + create_file(&dir, "lib.rs"); + + let output = build_repo_context(dir.path(), &[]).unwrap(); + assert!( + output.contains("## Project Structure"), + "missing structure section" + ); +} + +#[test] +fn test_full_session_context_section_present() { + let dir = make_dir(); + create_file(&dir, "src/auth.rs"); + let messages = vec![msg("working on src/auth.rs today")]; + + let output = build_repo_context(dir.path(), &messages).unwrap(); + assert!( + output.contains("## Session Context"), + "missing session section" + ); + assert!(output.contains("src/auth.rs")); +} + +#[test] +fn test_is_sensitive_patterns() { + assert!(is_sensitive(".env")); + assert!(is_sensitive(".env.local")); + assert!(is_sensitive(".envrc")); + assert!(is_sensitive("private.key")); + assert!(is_sensitive("cert.pem")); + assert!(is_sensitive("bundle.p12")); + assert!(is_sensitive("bundle.pfx")); + assert!(is_sensitive("api.secret")); + assert!(is_sensitive("server.crt")); + assert!(!is_sensitive("main.rs")); + assert!(!is_sensitive("Cargo.toml")); + assert!(!is_sensitive("README.md")); + assert!(!is_sensitive("env.rs")); // not a dotfile +} + +#[test] +fn test_directories_have_trailing_slash() { + let dir = make_dir(); + std::fs::create_dir(dir.path().join("src")).ok(); + create_file(&dir, "main.rs"); + + let result = build_structure_section(dir.path()); + assert!( + result.contains("src/"), + "directories should have trailing /" + ); + assert!( + result.contains("main.rs"), + "files should not have trailing /" + ); +} diff --git a/apps/daemon/src/session/claude.rs b/apps/daemon/src/session/claude.rs index 323da55..95bd09b 100644 --- a/apps/daemon/src/session/claude.rs +++ b/apps/daemon/src/session/claude.rs @@ -739,115 +739,6 @@ pub fn validate_tool_args(tool_name: &str, input: &serde_json::Value) -> ArgVali // ─── Tests ──────────────────────────────────────────────────────────────────── +// Tests live in claude/tests.rs. #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn risk_classifier_high() { - assert_eq!(classify_tool_risk("bash"), "high"); - assert_eq!(classify_tool_risk("execute_command"), "high"); - assert_eq!(classify_tool_risk("delete_file"), "high"); - assert_eq!(classify_tool_risk("computer_use"), "high"); - } - - #[test] - fn risk_classifier_medium() { - assert_eq!(classify_tool_risk("write_file"), "medium"); - assert_eq!(classify_tool_risk("edit_file"), "medium"); - assert_eq!(classify_tool_risk("create_file"), "medium"); - } - - #[test] - fn risk_classifier_low() { - assert_eq!(classify_tool_risk("read_file"), "low"); - assert_eq!(classify_tool_risk("list_directory"), "low"); - assert_eq!(classify_tool_risk("glob_search"), "low"); - } - - /// Confirms the cancel-during-output guard: once the cancelled flag is set, - /// the event_loop must break before processing any further lines. The full - /// scenario (real child process + flag racing with kill) is covered by the - /// e2e WebSocket integration tests in tests/e2e_websocket.rs. - #[test] - fn cancelled_flag_semantics() { - use std::sync::atomic::{AtomicBool, Ordering}; - let cancelled = AtomicBool::new(false); - // Simulate the guard: before the flag is set, loop should proceed. - assert!(!cancelled.load(Ordering::Acquire)); - cancelled.store(true, Ordering::Release); - // After stop() sets the flag, the loop sees it and exits. - assert!(cancelled.load(Ordering::Acquire)); - } - - // ── validate_tool_args ────────────────────────────────────────────────── - - fn val(tool: &str, json: &str) -> ArgValidation { - validate_tool_args(tool, &serde_json::from_str(json).unwrap()) - } - - #[test] - fn safe_command_passes() { - assert_eq!(val("bash", r#"{"command":"ls -la"}"#), ArgValidation::Ok); - assert_eq!( - val("execute", r#"{"command":"cargo build"}"#), - ArgValidation::Ok - ); - } - - #[test] - fn double_ampersand_blocked() { - assert!(matches!( - val("bash", r#"{"command":"make && rm -rf ."}"#), - ArgValidation::Blocked(_) - )); - } - - #[test] - fn pipe_blocked() { - // Pipes alone are actually fine; only && and || trigger injection check. - // (Pipes are legitimate in shell commands.) - // Backtick injection IS blocked. - assert!(matches!( - val("bash", r#"{"command":"echo `whoami`"}"#), - ArgValidation::Blocked(_) - )); - } - - #[test] - fn destructive_rm_rf_blocked() { - assert!(matches!( - val("bash", r#"{"command":"sudo rm -rf / --no-preserve-root"}"#), - ArgValidation::Blocked(_) - )); - assert!(matches!( - val("bash", r#"{"command":"rm -rf /"}"#), - ArgValidation::Blocked(_) - )); - } - - #[test] - fn chmod_777_blocked() { - assert!(matches!( - val("bash", r#"{"command":"chmod 777 /etc/passwd"}"#), - ArgValidation::Blocked(_) - )); - } - - #[test] - fn fork_bomb_blocked() { - assert!(matches!( - val("bash", r#"{"command":":(){:|:&};:"}"#), - ArgValidation::Blocked(_) - )); - } - - #[test] - fn non_shell_tool_passes_freely() { - // A read_file tool with a path field — path is not absolute, should pass. - assert_eq!( - val("read_file", r#"{"path":"src/main.rs"}"#), - ArgValidation::Ok - ); - } -} +mod tests; diff --git a/apps/daemon/src/session/claude/tests.rs b/apps/daemon/src/session/claude/tests.rs new file mode 100644 index 0000000..ec83eb8 --- /dev/null +++ b/apps/daemon/src/session/claude/tests.rs @@ -0,0 +1,295 @@ +//! Tests for the Claude runner's tool-risk and argument-validation helpers. +//! +//! The first group is written against the surviving-mutant list from the +//! mutation gate. Both helpers are long `||` chains, and a `||` -> `&&` +//! mutation at position *i* pairs the terms on either side of it: the chain +//! only notices if some input matches exactly one of that pair. So each test +//! below uses a name that trips exactly one link in the chain. + +use super::*; + +// ─── classify_tool_risk ────────────────────────────────────────────────────── + +/// Kills the `||` -> `&&` mutations guarding the `kill` and `terminal` terms +/// of the high-risk chain. +/// +/// `kill_process` matches `kill` and nothing else, so pairing `kill` with +/// either neighbour (`remove` before it, `terminal` after it) collapses the +/// chain and the tool is downgraded to "low" — a shell-killing tool silently +/// reclassified as read-only. +#[test] +fn process_control_tools_are_high_risk_on_every_keyword() { + assert_eq!(classify_tool_risk("kill_process"), "high"); + assert_eq!(classify_tool_risk("terminal_session"), "high"); +} + +/// Kills the four `||` -> `&&` mutations in the tail of the medium chain +/// (`overwrite`, `replace`, `insert`, `append`). +/// +/// Each name matches exactly one of those terms and none of its neighbours, +/// so every pairing is observable. `apply_patch` covers the `patch`/`overwrite` +/// pair from the `patch` side, which is the only side that can be isolated: +/// any name containing "overwrite" also contains "write" and would be caught +/// by the first term of the chain regardless. +#[test] +fn content_modifying_tools_are_medium_risk_on_every_keyword() { + assert_eq!(classify_tool_risk("apply_patch"), "medium"); + assert_eq!(classify_tool_risk("replace_text"), "medium"); + assert_eq!(classify_tool_risk("insert_line"), "medium"); + assert_eq!(classify_tool_risk("append_to_file"), "medium"); +} + +/// The default arm: a name matching no keyword in either chain is "low". +/// Pins that the two chains do not fall through into each other. +#[test] +fn read_only_tools_fall_through_to_low() { + assert_eq!(classify_tool_risk("glob"), "low"); + assert_eq!(classify_tool_risk("todo_list"), "low"); +} + +/// Classification is case-insensitive — pins the `to_lowercase()` call. +#[test] +fn classification_ignores_case() { + assert_eq!(classify_tool_risk("KillProcess"), "high"); + assert_eq!(classify_tool_risk("AppendToFile"), "medium"); +} + +// ─── validate_tool_args ────────────────────────────────────────────────────── + +fn blocked(v: &ArgValidation) -> bool { + matches!(v, ArgValidation::Blocked(_)) +} + +/// Kills the `||` -> `&&` mutations guarding the `run` and `terminal` terms of +/// the `is_shell_tool` chain. +/// +/// `run_shell` matches `run` alone. Pairing it with `execute` before it or +/// `terminal` after it makes `is_shell_tool` false, and the injection check is +/// skipped entirely — the command is waved through. +#[test] +fn a_run_prefixed_tool_is_recognised_as_a_shell_tool() { + let input = serde_json::json!({ "command": "echo one && rm -rf /tmp/x" }); + assert!(blocked(&validate_tool_args("run_shell", &input))); +} + +/// Kills the `||` -> `&&` mutation guarding the `command` term. +/// +/// `terminal` matches `terminal` alone; pairing it with `command` drops it out +/// of the chain. +#[test] +fn a_terminal_tool_is_recognised_as_a_shell_tool() { + let input = serde_json::json!({ "command": "ls `whoami`" }); + assert!(blocked(&validate_tool_args("terminal", &input))); +} + +/// The `cmd` field is checked as well as `command`, and each injection +/// indicator is pinned separately so no single one can be dropped unnoticed. +#[test] +fn every_injection_indicator_is_caught_in_either_field() { + for cmd in ["a && b", "a || b", "a `b`", "a $(b)"] { + assert!( + blocked(&validate_tool_args( + "bash", + &serde_json::json!({ "command": cmd }) + )), + "command field should block {cmd:?}" + ); + assert!( + blocked(&validate_tool_args( + "bash", + &serde_json::json!({ "cmd": cmd }) + )), + "cmd field should block {cmd:?}" + ); + } +} + +/// Every destructive pattern is pinned individually. +#[test] +fn every_destructive_pattern_is_blocked() { + for cmd in [ + "rm -rf /", + "rm -rf ~", + "rm -rf $HOME", + "rm --no-preserve-root /", + "chmod 777 /etc", + "mkfs.ext4 /dev/sda", + ":(){:|:&};:", + ] { + assert!( + blocked(&validate_tool_args( + "bash", + &serde_json::json!({ "command": cmd }) + )), + "should block {cmd:?}" + ); + } +} + +/// A plain command through a shell tool is allowed — the negative direction, +/// so the checks above cannot be satisfied by blocking everything. +#[test] +fn an_ordinary_shell_command_is_allowed() { + let input = serde_json::json!({ "command": "cargo test --lib" }); + assert!(!blocked(&validate_tool_args("bash", &input))); +} + +/// A non-shell tool is not subjected to the command checks at all. +#[test] +fn a_non_shell_tool_skips_the_command_checks() { + let input = serde_json::json!({ "command": "rm -rf /" }); + assert!(!blocked(&validate_tool_args("read_file", &input))); +} + +/// Kills the `delete !` mutation on `!home.is_empty()`. +/// +/// With the `!` dropped the guard requires HOME to be *unset*, so with a real +/// HOME nothing is ever blocked and any absolute path escapes. +#[test] +fn an_absolute_path_outside_home_is_blocked() { + assert!( + !std::env::var("HOME").unwrap_or_default().is_empty(), + "this test needs HOME set to mean anything" + ); + for key in ["path", "file_path", "filename", "target"] { + let input = serde_json::json!({ key: "/etc/shadow" }); + assert!( + blocked(&validate_tool_args("read_file", &input)), + "{key} should be checked" + ); + } +} + +/// Kills `&&` -> `||` and the `delete !` on `starts_with` in the same guard. +/// +/// Both mutations invert the home check: `||` makes it true for every absolute +/// path, and dropping the `!` blocks exactly the paths that should be allowed. +/// Either way an agent loses access to the user's own working tree. +#[test] +fn an_absolute_path_inside_home_is_allowed() { + let home = std::env::var("HOME").unwrap_or_default(); + assert!( + !home.is_empty(), + "this test needs HOME set to mean anything" + ); + + let input = serde_json::json!({ "path": format!("{home}/notes.txt") }); + assert!(!blocked(&validate_tool_args("read_file", &input))); +} + +/// A relative path is never subjected to the home check. +#[test] +fn a_relative_path_is_allowed() { + let input = serde_json::json!({ "path": "src/main.rs" }); + assert!(!blocked(&validate_tool_args("read_file", &input))); +} + +// ─── pre-existing tests, kept verbatim ────────────────────────────────────── + +#[test] +fn risk_classifier_high() { + assert_eq!(classify_tool_risk("bash"), "high"); + assert_eq!(classify_tool_risk("execute_command"), "high"); + assert_eq!(classify_tool_risk("delete_file"), "high"); + assert_eq!(classify_tool_risk("computer_use"), "high"); +} + +#[test] +fn risk_classifier_medium() { + assert_eq!(classify_tool_risk("write_file"), "medium"); + assert_eq!(classify_tool_risk("edit_file"), "medium"); + assert_eq!(classify_tool_risk("create_file"), "medium"); +} + +#[test] +fn risk_classifier_low() { + assert_eq!(classify_tool_risk("read_file"), "low"); + assert_eq!(classify_tool_risk("list_directory"), "low"); + assert_eq!(classify_tool_risk("glob_search"), "low"); +} + +/// Confirms the cancel-during-output guard: once the cancelled flag is set, +/// the event_loop must break before processing any further lines. The full +/// scenario (real child process + flag racing with kill) is covered by the +/// e2e WebSocket integration tests in tests/e2e_websocket.rs. +#[test] +fn cancelled_flag_semantics() { + use std::sync::atomic::{AtomicBool, Ordering}; + let cancelled = AtomicBool::new(false); + // Simulate the guard: before the flag is set, loop should proceed. + assert!(!cancelled.load(Ordering::Acquire)); + cancelled.store(true, Ordering::Release); + // After stop() sets the flag, the loop sees it and exits. + assert!(cancelled.load(Ordering::Acquire)); +} + +// ── validate_tool_args ────────────────────────────────────────────────── + +fn val(tool: &str, json: &str) -> ArgValidation { + validate_tool_args(tool, &serde_json::from_str(json).unwrap()) +} + +#[test] +fn safe_command_passes() { + assert_eq!(val("bash", r#"{"command":"ls -la"}"#), ArgValidation::Ok); + assert_eq!( + val("execute", r#"{"command":"cargo build"}"#), + ArgValidation::Ok + ); +} + +#[test] +fn double_ampersand_blocked() { + assert!(matches!( + val("bash", r#"{"command":"make && rm -rf ."}"#), + ArgValidation::Blocked(_) + )); +} + +#[test] +fn pipe_blocked() { + // Pipes alone are actually fine; only && and || trigger injection check. + // (Pipes are legitimate in shell commands.) + // Backtick injection IS blocked. + assert!(matches!( + val("bash", r#"{"command":"echo `whoami`"}"#), + ArgValidation::Blocked(_) + )); +} + +#[test] +fn destructive_rm_rf_blocked() { + assert!(matches!( + val("bash", r#"{"command":"sudo rm -rf / --no-preserve-root"}"#), + ArgValidation::Blocked(_) + )); + assert!(matches!( + val("bash", r#"{"command":"rm -rf /"}"#), + ArgValidation::Blocked(_) + )); +} + +#[test] +fn chmod_777_blocked() { + assert!(matches!( + val("bash", r#"{"command":"chmod 777 /etc/passwd"}"#), + ArgValidation::Blocked(_) + )); +} + +#[test] +fn fork_bomb_blocked() { + assert!(matches!( + val("bash", r#"{"command":":(){:|:&};:"}"#), + ArgValidation::Blocked(_) + )); +} + +#[test] +fn non_shell_tool_passes_freely() { + // A read_file tool with a path field — path is not absolute, should pass. + assert_eq!( + val("read_file", r#"{"path":"src/main.rs"}"#), + ArgValidation::Ok + ); +}