From 3fb8be9ccf7a91dc984ccaf69053639b355810c7 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Tue, 17 Feb 2026 14:01:49 +0100 Subject: [PATCH 1/2] feat: smart tool output filtering for token savings (M26) Add command-aware output filter pipeline in ShellExecutor that compresses tool output before context insertion. Filters: test (failures-only), git (compact stats), clippy (group by rule), dir listing (noise removal), log dedup (normalize+count). ANSI stripping runs unconditionally. Closes #427, closes #428, closes #429, closes #430, closes #431, closes #432, closes #433 --- CHANGELOG.md | 9 + config/default.toml | 4 + crates/zeph-tools/src/config.rs | 3 + crates/zeph-tools/src/filter/clippy.rs | 126 +++++++++ crates/zeph-tools/src/filter/dir_listing.rs | 100 ++++++++ crates/zeph-tools/src/filter/git.rs | 269 ++++++++++++++++++++ crates/zeph-tools/src/filter/log_dedup.rs | 145 +++++++++++ crates/zeph-tools/src/filter/mod.rs | 235 +++++++++++++++++ crates/zeph-tools/src/filter/test_output.rs | 196 ++++++++++++++ crates/zeph-tools/src/lib.rs | 2 + crates/zeph-tools/src/shell.rs | 64 ++++- src/main.rs | 16 +- 12 files changed, 1153 insertions(+), 16 deletions(-) create mode 100644 crates/zeph-tools/src/filter/clippy.rs create mode 100644 crates/zeph-tools/src/filter/dir_listing.rs create mode 100644 crates/zeph-tools/src/filter/git.rs create mode 100644 crates/zeph-tools/src/filter/log_dedup.rs create mode 100644 crates/zeph-tools/src/filter/mod.rs create mode 100644 crates/zeph-tools/src/filter/test_output.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b12431bb..e87dc700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- Smart tool output filtering: command-aware filters that compress tool output before context insertion +- `OutputFilter` trait and `OutputFilterRegistry` with first-match-wins dispatch +- `sanitize_output()` ANSI escape and progress bar stripping (runs on all tool output) +- Test output filter: cargo test/nextest failures-only mode (94-99% token savings on green suites) +- Git output filter: compact status/diff/log/push compression (80-99% savings) +- Clippy output filter: group warnings by lint rule (70-90% savings) +- Directory listing filter: hide noise directories (target, node_modules, .git) +- Log deduplication filter: normalize timestamps/UUIDs, count repeated patterns (70-85% savings) +- `[tools.filters]` config section with `enabled` toggle - Skill trust levels: 4-tier model (Trusted, Verified, Quarantined, Blocked) with per-turn enforcement - `TrustGateExecutor` wrapping tool execution with trust-level permission checks - `AnomalyDetector` with sliding-window threshold counters for quarantined skill monitoring diff --git a/config/default.toml b/config/default.toml index d2343106..17c31a71 100644 --- a/config/default.toml +++ b/config/default.toml @@ -252,6 +252,10 @@ timeout = 15 # Maximum response body size in bytes (1MB) max_body_bytes = 1048576 +[tools.filters] +# Enable smart output filtering for tool results +enabled = true + [tools.audit] # Enable audit logging for tool executions enabled = false diff --git a/crates/zeph-tools/src/config.rs b/crates/zeph-tools/src/config.rs index 8adfdddd..ad165d24 100644 --- a/crates/zeph-tools/src/config.rs +++ b/crates/zeph-tools/src/config.rs @@ -40,6 +40,8 @@ pub struct ToolsConfig { pub audit: AuditConfig, #[serde(default)] pub permissions: Option, + #[serde(default)] + pub filters: crate::filter::FilterConfig, } impl ToolsConfig { @@ -93,6 +95,7 @@ impl Default for ToolsConfig { scrape: ScrapeConfig::default(), audit: AuditConfig::default(), permissions: None, + filters: crate::filter::FilterConfig::default(), } } } diff --git a/crates/zeph-tools/src/filter/clippy.rs b/crates/zeph-tools/src/filter/clippy.rs new file mode 100644 index 00000000..2123ecaf --- /dev/null +++ b/crates/zeph-tools/src/filter/clippy.rs @@ -0,0 +1,126 @@ +use std::collections::BTreeMap; +use std::fmt::Write; +use std::sync::LazyLock; + +use regex::Regex; + +use super::{FilterResult, OutputFilter, make_result}; + +pub struct ClippyFilter; + +static LINT_RULE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"#\[warn\(([^)]+)\)\]").unwrap()); + +static LOCATION_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*-->\s*(.+:\d+)").unwrap()); + +impl OutputFilter for ClippyFilter { + fn matches(&self, command: &str) -> bool { + command.contains("cargo clippy") + } + + fn filter(&self, _command: &str, raw_output: &str, exit_code: i32) -> FilterResult { + let has_error = raw_output.contains("error[") || raw_output.contains("error:"); + if has_error && exit_code != 0 { + return make_result(raw_output, raw_output.to_owned()); + } + + let mut warnings: BTreeMap> = BTreeMap::new(); + let mut pending_location: Option = None; + + for line in raw_output.lines() { + if let Some(caps) = LOCATION_RE.captures(line) { + pending_location = Some(caps[1].to_owned()); + } + + if let Some(caps) = LINT_RULE_RE.captures(line) { + let rule = caps[1].to_owned(); + if let Some(loc) = pending_location.take() { + warnings.entry(rule).or_default().push(loc); + } + } + } + + if warnings.is_empty() { + return make_result(raw_output, raw_output.to_owned()); + } + + let total: usize = warnings.values().map(Vec::len).sum(); + let rules = warnings.len(); + let mut output = String::new(); + + for (rule, locations) in &warnings { + let count = locations.len(); + let label = if count == 1 { "warning" } else { "warnings" }; + let _ = writeln!(output, "{rule} ({count} {label}):"); + for loc in locations { + let _ = writeln!(output, " {loc}"); + } + output.push('\n'); + } + let _ = write!(output, "{total} warnings total ({rules} rules)"); + + make_result(raw_output, output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_clippy() { + let f = ClippyFilter; + assert!(f.matches("cargo clippy --workspace")); + assert!(f.matches("cargo clippy -- -D warnings")); + assert!(!f.matches("cargo build")); + assert!(!f.matches("cargo test")); + } + + #[test] + fn filter_groups_warnings() { + let f = ClippyFilter; + let raw = "\ +warning: needless pass by value + --> src/foo.rs:12:5 + | + = help: ... + = note: `#[warn(clippy::needless_pass_by_value)]` on by default + +warning: needless pass by value + --> src/bar.rs:45:10 + | + = help: ... + = note: `#[warn(clippy::needless_pass_by_value)]` on by default + +warning: unused import + --> src/main.rs:5:1 + | + = note: `#[warn(clippy::unused_imports)]` on by default + +warning: `my-crate` (lib) generated 3 warnings +"; + let result = f.filter("cargo clippy", raw, 0); + assert!(result.output.contains("clippy::needless_pass_by_value (2 warnings):")); + assert!(result.output.contains("src/foo.rs:12")); + assert!(result.output.contains("src/bar.rs:45")); + assert!(result.output.contains("clippy::unused_imports (1 warning):")); + assert!(result.output.contains("3 warnings total (2 rules)")); + } + + #[test] + fn filter_error_preserves_full() { + let f = ClippyFilter; + let raw = "error[E0308]: mismatched types\n --> src/main.rs:10:5\nfull details here"; + let result = f.filter("cargo clippy", raw, 1); + assert_eq!(result.output, raw); + } + + #[test] + fn filter_no_warnings_passthrough() { + let f = ClippyFilter; + let raw = "Checking my-crate v0.1.0\n Finished dev [unoptimized] target(s)"; + let result = f.filter("cargo clippy", raw, 0); + assert_eq!(result.output, raw); + } +} diff --git a/crates/zeph-tools/src/filter/dir_listing.rs b/crates/zeph-tools/src/filter/dir_listing.rs new file mode 100644 index 00000000..71f1b3e3 --- /dev/null +++ b/crates/zeph-tools/src/filter/dir_listing.rs @@ -0,0 +1,100 @@ +use std::fmt::Write; + +use super::{FilterResult, OutputFilter, make_result}; + +const NOISE_DIRS: &[&str] = &[ + "node_modules", + "target", + ".git", + "__pycache__", + ".venv", + "venv", + "dist", + "build", + ".next", + ".cache", +]; + +pub struct DirListingFilter; + +impl OutputFilter for DirListingFilter { + fn matches(&self, command: &str) -> bool { + let cmd = command.trim_start(); + cmd == "ls" || cmd.starts_with("ls ") + } + + fn filter(&self, _command: &str, raw_output: &str, _exit_code: i32) -> FilterResult { + let mut kept = Vec::new(); + let mut hidden: Vec<&str> = Vec::new(); + + for line in raw_output.lines() { + let entry = line.split_whitespace().last().unwrap_or(line); + let name = entry.trim_end_matches('/'); + + if NOISE_DIRS.contains(&name) { + hidden.push(name); + } else { + kept.push(line); + } + } + + if hidden.is_empty() { + return make_result(raw_output, raw_output.to_owned()); + } + + let mut output = kept.join("\n"); + let names = hidden.join(", "); + let _ = write!(output, "\n(+ {} hidden: {names})", hidden.len()); + + make_result(raw_output, output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_ls() { + let f = DirListingFilter; + assert!(f.matches("ls")); + assert!(f.matches("ls -la")); + assert!(f.matches("ls /tmp")); + assert!(!f.matches("lsof")); + assert!(!f.matches("cargo build")); + } + + #[test] + fn filter_hides_noise_dirs() { + let f = DirListingFilter; + let raw = "Cargo.toml\nsrc\ntarget\nnode_modules\nREADME.md\n.git"; + let result = f.filter("ls", raw, 0); + assert!(result.output.contains("Cargo.toml")); + assert!(result.output.contains("src")); + assert!(result.output.contains("README.md")); + assert!(!result.output.contains("\ntarget\n")); + assert!(result.output.contains("(+ 3 hidden: target, node_modules, .git)")); + } + + #[test] + fn filter_no_noise_passthrough() { + let f = DirListingFilter; + let raw = "Cargo.toml\nsrc\nREADME.md"; + let result = f.filter("ls", raw, 0); + assert_eq!(result.output, raw); + } + + #[test] + fn filter_ls_la_format() { + let f = DirListingFilter; + let raw = "\ +drwxr-xr-x 5 user staff 160 Jan 1 12:00 src +drwxr-xr-x 20 user staff 640 Jan 1 12:00 node_modules +-rw-r--r-- 1 user staff 200 Jan 1 12:00 Cargo.toml +drwxr-xr-x 8 user staff 256 Jan 1 12:00 target"; + let result = f.filter("ls -la", raw, 0); + assert!(result.output.contains("src")); + assert!(result.output.contains("Cargo.toml")); + assert!(result.output.contains("(+ 2 hidden: node_modules, target)")); + } +} diff --git a/crates/zeph-tools/src/filter/git.rs b/crates/zeph-tools/src/filter/git.rs new file mode 100644 index 00000000..eba9e19f --- /dev/null +++ b/crates/zeph-tools/src/filter/git.rs @@ -0,0 +1,269 @@ +use std::fmt::Write; + +use super::{FilterResult, OutputFilter, make_result}; + +pub struct GitFilter; + +impl OutputFilter for GitFilter { + fn matches(&self, command: &str) -> bool { + command.trim_start().starts_with("git ") + } + + fn filter(&self, command: &str, raw_output: &str, _exit_code: i32) -> FilterResult { + let subcmd = command + .trim_start() + .strip_prefix("git ") + .unwrap_or("") + .split_whitespace() + .next() + .unwrap_or(""); + + match subcmd { + "status" => filter_status(raw_output), + "diff" => filter_diff(raw_output), + "log" => filter_log(raw_output), + "push" => filter_push(raw_output), + _ => make_result(raw_output, raw_output.to_owned()), + } + } +} + +fn filter_status(raw: &str) -> FilterResult { + let mut modified = 0u32; + let mut added = 0u32; + let mut deleted = 0u32; + let mut untracked = 0u32; + + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("M ") || trimmed.starts_with("MM") || trimmed.starts_with(" M") { + modified += 1; + } else if trimmed.starts_with("A ") || trimmed.starts_with("AM") { + added += 1; + } else if trimmed.starts_with("D ") || trimmed.starts_with(" D") { + deleted += 1; + } else if trimmed.starts_with("??") { + untracked += 1; + } else if trimmed.starts_with("modified:") { + modified += 1; + } else if trimmed.starts_with("new file:") { + added += 1; + } else if trimmed.starts_with("deleted:") { + deleted += 1; + } + } + + let total = modified + added + deleted + untracked; + if total == 0 { + return make_result(raw, raw.to_owned()); + } + + let mut output = String::new(); + let _ = write!( + output, + "M {modified} files | A {added} files | D {deleted} files | ?? {untracked} files" + ); + make_result(raw, output) +} + +fn filter_diff(raw: &str) -> FilterResult { + let mut files: Vec<(String, i32, i32)> = Vec::new(); + let mut current_file = String::new(); + let mut additions = 0i32; + let mut deletions = 0i32; + + for line in raw.lines() { + if line.starts_with("diff --git ") { + if !current_file.is_empty() { + files.push((current_file.clone(), additions, deletions)); + } + line.strip_prefix("diff --git a/") + .and_then(|s| s.split(" b/").next()) + .unwrap_or("unknown") + .clone_into(&mut current_file); + additions = 0; + deletions = 0; + } else if line.starts_with('+') && !line.starts_with("+++") { + additions += 1; + } else if line.starts_with('-') && !line.starts_with("---") { + deletions += 1; + } + } + if !current_file.is_empty() { + files.push((current_file, additions, deletions)); + } + + if files.is_empty() { + return make_result(raw, raw.to_owned()); + } + + let total_add: i32 = files.iter().map(|(_, a, _)| a).sum(); + let total_del: i32 = files.iter().map(|(_, _, d)| d).sum(); + let mut output = String::new(); + for (file, add, del) in &files { + let _ = writeln!(output, "{file} | +{add} -{del}"); + } + let _ = write!( + output, + "{} files changed, {} insertions(+), {} deletions(-)", + files.len(), + total_add, + total_del + ); + make_result(raw, output) +} + +fn filter_log(raw: &str) -> FilterResult { + let lines: Vec<&str> = raw.lines().collect(); + if lines.len() <= 20 { + return make_result(raw, raw.to_owned()); + } + + let mut output: String = lines[..20].join("\n"); + let remaining = lines.len() - 20; + let _ = write!(output, "\n... and {remaining} more commits"); + make_result(raw, output) +} + +fn filter_push(raw: &str) -> FilterResult { + let mut output = String::new(); + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.contains("->") || trimmed.starts_with("To ") || trimmed.starts_with("Branch") { + if !output.is_empty() { + output.push('\n'); + } + output.push_str(trimmed); + } + } + if output.is_empty() { + return make_result(raw, raw.to_owned()); + } + make_result(raw, output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_git_commands() { + let f = GitFilter; + assert!(f.matches("git status")); + assert!(f.matches("git diff --stat")); + assert!(f.matches("git log --oneline")); + assert!(f.matches("git push origin main")); + assert!(!f.matches("cargo build")); + assert!(!f.matches("github-cli")); + } + + #[test] + fn filter_status_summarizes() { + let f = GitFilter; + let raw = " M src/main.rs\n M src/lib.rs\n?? new_file.txt\nA added.rs\n"; + let result = f.filter("git status --short", raw, 0); + assert!(result.output.contains("M 2 files")); + assert!(result.output.contains("?? 1 files")); + assert!(result.output.contains("A 1 files")); + } + + #[test] + fn filter_diff_compresses() { + let f = GitFilter; + let raw = "\ +diff --git a/src/main.rs b/src/main.rs +index abc..def 100644 +--- a/src/main.rs ++++ b/src/main.rs ++new line 1 ++new line 2 +-old line 1 +diff --git a/src/lib.rs b/src/lib.rs +index ghi..jkl 100644 +--- a/src/lib.rs ++++ b/src/lib.rs ++added +"; + let result = f.filter("git diff", raw, 0); + assert!(result.output.contains("src/main.rs")); + assert!(result.output.contains("src/lib.rs")); + assert!(result.output.contains("2 files changed")); + assert!(result.output.contains("3 insertions(+)")); + assert!(result.output.contains("1 deletions(-)")); + } + + #[test] + fn filter_log_truncates() { + let f = GitFilter; + let lines: Vec = (0..50) + .map(|i| format!("abc{i:04} feat: commit {i}")) + .collect(); + let raw = lines.join("\n"); + let result = f.filter("git log --oneline", &raw, 0); + assert!(result.output.contains("abc0000")); + assert!(result.output.contains("abc0019")); + assert!(!result.output.contains("abc0020")); + assert!(result.output.contains("and 30 more commits")); + } + + #[test] + fn filter_log_short_passthrough() { + let f = GitFilter; + let raw = "abc1234 feat: something\ndef5678 fix: other"; + let result = f.filter("git log --oneline", raw, 0); + assert_eq!(result.output, raw); + } + + #[test] + fn filter_push_extracts_summary() { + let f = GitFilter; + let raw = "\ +Enumerating objects: 5, done. +Counting objects: 100% (5/5), done. +Delta compression using up to 10 threads +Compressing objects: 100% (3/3), done. +Writing objects: 100% (3/3), 1.20 KiB | 1.20 MiB/s, done. +Total 3 (delta 2), reused 0 (delta 0) +To github.com:user/repo.git + abc1234..def5678 main -> main +"; + let result = f.filter("git push origin main", raw, 0); + assert!(result.output.contains("main -> main")); + assert!(result.output.contains("To github.com")); + assert!(!result.output.contains("Enumerating")); + } + + #[test] + fn filter_status_long_form() { + let f = GitFilter; + let raw = "\ +On branch main +Changes not staged for commit: + modified: src/main.rs + modified: src/lib.rs + deleted: old_file.rs + +Untracked files: + new_file.txt +"; + let result = f.filter("git status", raw, 0); + assert!(result.output.contains("M 2 files")); + assert!(result.output.contains("D 1 files")); + } + + #[test] + fn filter_diff_empty_passthrough() { + let f = GitFilter; + let raw = ""; + let result = f.filter("git diff", raw, 0); + assert_eq!(result.output, raw); + } + + #[test] + fn filter_unknown_subcommand_passthrough() { + let f = GitFilter; + let raw = "some output"; + let result = f.filter("git stash list", raw, 0); + assert_eq!(result.output, raw); + } +} diff --git a/crates/zeph-tools/src/filter/log_dedup.rs b/crates/zeph-tools/src/filter/log_dedup.rs new file mode 100644 index 00000000..a4983df3 --- /dev/null +++ b/crates/zeph-tools/src/filter/log_dedup.rs @@ -0,0 +1,145 @@ +use std::collections::HashMap; +use std::fmt::Write; +use std::sync::LazyLock; + +use regex::Regex; + +use super::{FilterResult, OutputFilter, make_result}; + +const MAX_UNIQUE_PATTERNS: usize = 10_000; + +pub struct LogDedupFilter; + +static TIMESTAMP_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}([.\d]*)?([Z+-][\d:]*)?").unwrap() +}); +static UUID_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}").unwrap() +}); +static IP_RE: LazyLock = + LazyLock::new(|| Regex::new(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}").unwrap()); +static PORT_PID_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?:port|pid|PID)[=: ]+\d+").unwrap()); + +impl OutputFilter for LogDedupFilter { + fn matches(&self, command: &str) -> bool { + let cmd = command.to_lowercase(); + cmd.contains("journalctl") + || cmd.contains("tail -f") + || cmd.contains("docker logs") + || (cmd.contains("cat ") && cmd.contains(".log")) + } + + fn filter(&self, _command: &str, raw_output: &str, _exit_code: i32) -> FilterResult { + let lines: Vec<&str> = raw_output.lines().collect(); + if lines.len() < 3 { + return make_result(raw_output, raw_output.to_owned()); + } + + let mut pattern_counts: HashMap = HashMap::new(); + let mut order: Vec = Vec::new(); + + let mut capped = false; + for line in &lines { + let normalized = normalize(line); + if let Some(entry) = pattern_counts.get_mut(&normalized) { + entry.0 += 1; + } else if pattern_counts.len() < MAX_UNIQUE_PATTERNS { + order.push(normalized.clone()); + pattern_counts.insert(normalized, (1, (*line).to_owned())); + } else { + capped = true; + } + } + + let unique = order.len(); + let total = lines.len(); + + if unique == total && !capped { + return make_result(raw_output, raw_output.to_owned()); + } + + let mut output = String::new(); + for key in &order { + let (count, example) = &pattern_counts[key]; + if *count > 1 { + let _ = writeln!(output, "{example} (x{count})"); + } else { + let _ = writeln!(output, "{example}"); + } + } + let _ = write!(output, "{unique} unique patterns ({total} total lines)"); + if capped { + let _ = write!(output, " (capped at {MAX_UNIQUE_PATTERNS})"); + } + + make_result(raw_output, output) + } +} + +fn normalize(line: &str) -> String { + let s = TIMESTAMP_RE.replace_all(line, ""); + let s = UUID_RE.replace_all(&s, ""); + let s = IP_RE.replace_all(&s, ""); + PORT_PID_RE.replace_all(&s, "").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_log_commands() { + let f = LogDedupFilter; + assert!(f.matches("journalctl -u nginx")); + assert!(f.matches("tail -f /var/log/syslog")); + assert!(f.matches("docker logs -f container")); + assert!(f.matches("cat /var/log/app.log")); + assert!(!f.matches("cat file.txt")); + assert!(!f.matches("cargo build")); + } + + #[test] + fn filter_deduplicates() { + let f = LogDedupFilter; + let raw = "\ +2024-01-15T12:00:01Z INFO request handled path=/api/health +2024-01-15T12:00:02Z INFO request handled path=/api/health +2024-01-15T12:00:03Z INFO request handled path=/api/health +2024-01-15T12:00:04Z WARN connection timeout addr=10.0.0.1 +2024-01-15T12:00:05Z WARN connection timeout addr=10.0.0.2 +2024-01-15T12:00:06Z ERROR database unreachable +"; + let result = f.filter("journalctl -u app", raw, 0); + assert!(result.output.contains("(x3)")); + assert!(result.output.contains("(x2)")); + assert!(result.output.contains("3 unique patterns (6 total lines)")); + assert!(result.savings_pct() > 20.0); + } + + #[test] + fn filter_all_unique_passthrough() { + let f = LogDedupFilter; + let raw = "line one\nline two\nline three"; + let result = f.filter("cat app.log", raw, 0); + assert_eq!(result.output, raw); + } + + #[test] + fn filter_short_passthrough() { + let f = LogDedupFilter; + let raw = "single line"; + let result = f.filter("cat app.log", raw, 0); + assert_eq!(result.output, raw); + } + + #[test] + fn normalize_replaces_patterns() { + let line = "2024-01-15T12:00:00Z req=abc12345-1234-1234-1234-123456789012 addr=192.168.1.1 pid=1234"; + let n = normalize(line); + assert!(n.contains("")); + assert!(n.contains("")); + assert!(n.contains("")); + assert!(n.contains("")); + } +} diff --git a/crates/zeph-tools/src/filter/mod.rs b/crates/zeph-tools/src/filter/mod.rs new file mode 100644 index 00000000..f77425ee --- /dev/null +++ b/crates/zeph-tools/src/filter/mod.rs @@ -0,0 +1,235 @@ +//! Command-aware output filtering pipeline. + +mod clippy; +mod dir_listing; +mod git; +mod log_dedup; +mod test_output; + +use std::sync::LazyLock; + +use regex::Regex; +use serde::Deserialize; + +pub use self::clippy::ClippyFilter; +pub use self::dir_listing::DirListingFilter; +pub use self::git::GitFilter; +pub use self::log_dedup::LogDedupFilter; +pub use self::test_output::TestOutputFilter; + +/// Result of applying a filter to tool output. +pub struct FilterResult { + pub output: String, + pub raw_chars: usize, + pub filtered_chars: usize, +} + +impl FilterResult { + #[must_use] + #[allow(clippy::cast_precision_loss)] + pub fn savings_pct(&self) -> f64 { + if self.raw_chars == 0 { + return 0.0; + } + (1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0 + } +} + +/// Command-aware output filter. +pub trait OutputFilter: Send + Sync { + fn matches(&self, command: &str) -> bool; + fn filter(&self, command: &str, raw_output: &str, exit_code: i32) -> FilterResult; +} + +/// Registry of filters. First match wins; no match = passthrough. +pub struct OutputFilterRegistry { + filters: Vec>, + enabled: bool, +} + +impl std::fmt::Debug for OutputFilterRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OutputFilterRegistry") + .field("enabled", &self.enabled) + .field("filter_count", &self.filters.len()) + .finish() + } +} + +impl OutputFilterRegistry { + #[must_use] + pub fn new(enabled: bool) -> Self { + Self { + filters: Vec::new(), + enabled, + } + } + + pub fn register(&mut self, filter: Box) { + self.filters.push(filter); + } + + #[must_use] + pub fn default_filters() -> Self { + let mut r = Self::new(true); + r.register(Box::new(TestOutputFilter)); + r.register(Box::new(ClippyFilter)); + r.register(Box::new(GitFilter)); + r.register(Box::new(DirListingFilter)); + r.register(Box::new(LogDedupFilter)); + r + } + + #[must_use] + pub fn apply(&self, command: &str, raw_output: &str, exit_code: i32) -> Option { + if !self.enabled { + return None; + } + for f in &self.filters { + if f.matches(command) { + return Some(f.filter(command, raw_output, exit_code)); + } + } + None + } +} + +static ANSI_RE: LazyLock = LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap()); + +/// Strip ANSI escape sequences, carriage-return progress bars, and collapse blank lines. +#[must_use] +pub fn sanitize_output(raw: &str) -> String { + let no_ansi = ANSI_RE.replace_all(raw, ""); + + let mut result = String::with_capacity(no_ansi.len()); + let mut prev_blank = false; + + for line in no_ansi.lines() { + // Strip carriage-return overwrites (progress bars) + let clean = if line.contains('\r') { + line.rsplit('\r').next().unwrap_or("") + } else { + line + }; + + let is_blank = clean.trim().is_empty(); + if is_blank && prev_blank { + continue; + } + prev_blank = is_blank; + + if !result.is_empty() { + result.push('\n'); + } + result.push_str(clean); + } + result +} + +fn default_true() -> bool { + true +} + +/// Configuration for output filters. +#[derive(Debug, Deserialize)] +pub struct FilterConfig { + #[serde(default = "default_true")] + pub enabled: bool, +} + +impl Default for FilterConfig { + fn default() -> Self { + Self { enabled: true } + } +} + +fn make_result(raw: &str, output: String) -> FilterResult { + let filtered_chars = output.len(); + FilterResult { + output, + raw_chars: raw.len(), + filtered_chars, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_strips_ansi() { + let input = "\x1b[32mOK\x1b[0m test passed"; + assert_eq!(sanitize_output(input), "OK test passed"); + } + + #[test] + fn sanitize_strips_cr_progress() { + let input = "Downloading... 50%\rDownloading... 100%"; + assert_eq!(sanitize_output(input), "Downloading... 100%"); + } + + #[test] + fn sanitize_collapses_blank_lines() { + let input = "line1\n\n\n\nline2"; + assert_eq!(sanitize_output(input), "line1\n\nline2"); + } + + #[test] + fn sanitize_preserves_crlf_content() { + let input = "line1\r\nline2\r\n"; + let result = sanitize_output(input); + assert!(result.contains("line1")); + assert!(result.contains("line2")); + } + + #[test] + fn filter_result_savings_pct() { + let r = FilterResult { + output: String::new(), + raw_chars: 1000, + filtered_chars: 200, + }; + assert!((r.savings_pct() - 80.0).abs() < 0.01); + } + + #[test] + fn filter_result_savings_pct_zero_raw() { + let r = FilterResult { + output: String::new(), + raw_chars: 0, + filtered_chars: 0, + }; + assert!((r.savings_pct()).abs() < 0.01); + } + + #[test] + fn registry_disabled_returns_none() { + let r = OutputFilterRegistry::new(false); + assert!(r.apply("cargo test", "output", 0).is_none()); + } + + #[test] + fn registry_no_match_returns_none() { + let r = OutputFilterRegistry::new(true); + assert!(r.apply("some-unknown-cmd", "output", 0).is_none()); + } + + #[test] + fn registry_default_has_filters() { + let r = OutputFilterRegistry::default_filters(); + assert!(r.apply("cargo test", "test result: ok. 5 passed; 0 failed; 0 ignored; 0 filtered out", 0).is_some()); + } + + #[test] + fn filter_config_default_enabled() { + let c = FilterConfig::default(); + assert!(c.enabled); + } + + #[test] + fn filter_config_deserialize() { + let toml_str = "enabled = false"; + let c: FilterConfig = toml::from_str(toml_str).unwrap(); + assert!(!c.enabled); + } +} diff --git a/crates/zeph-tools/src/filter/test_output.rs b/crates/zeph-tools/src/filter/test_output.rs new file mode 100644 index 00000000..c7b7b683 --- /dev/null +++ b/crates/zeph-tools/src/filter/test_output.rs @@ -0,0 +1,196 @@ +use std::fmt::Write; + +use super::{FilterResult, OutputFilter, make_result}; + +pub struct TestOutputFilter; + +impl OutputFilter for TestOutputFilter { + fn matches(&self, command: &str) -> bool { + let cmd = command.to_lowercase(); + // Split into tokens to match "cargo [+toolchain] test" or "cargo nextest" + let tokens: Vec<&str> = cmd.split_whitespace().collect(); + if tokens.first() != Some(&"cargo") { + return false; + } + tokens + .iter() + .skip(1) + .any(|t| *t == "test" || *t == "nextest") + } + + fn filter(&self, _command: &str, raw_output: &str, exit_code: i32) -> FilterResult { + let mut passed = 0u64; + let mut failed = 0u64; + let mut ignored = 0u64; + let mut filtered_out = 0u64; + let mut failure_blocks: Vec = Vec::new(); + let mut in_failure_block = false; + let mut current_block = String::new(); + let mut has_summary = false; + + for line in raw_output.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("FAIL [") || trimmed.starts_with("FAIL [") { + failed += 1; + continue; + } + if trimmed.starts_with("PASS [") || trimmed.starts_with("PASS [") { + passed += 1; + continue; + } + + // Standard cargo test failure block + if trimmed.starts_with("---- ") && trimmed.ends_with(" stdout ----") { + in_failure_block = true; + current_block.clear(); + current_block.push_str(line); + current_block.push('\n'); + continue; + } + + if in_failure_block { + current_block.push_str(line); + current_block.push('\n'); + if trimmed == "failures:" || trimmed.starts_with("---- ") { + failure_blocks.push(current_block.clone()); + in_failure_block = trimmed.starts_with("---- "); + if in_failure_block { + current_block.clear(); + current_block.push_str(line); + current_block.push('\n'); + } + } + continue; + } + + if trimmed == "failures:" && !current_block.is_empty() { + failure_blocks.push(current_block.clone()); + current_block.clear(); + } + + // Parse summary line + if trimmed.starts_with("test result:") { + has_summary = true; + for part in trimmed.split(';') { + let part = part.trim(); + if let Some(n) = extract_count(part, "passed") { + passed += n; + } else if let Some(n) = extract_count(part, "failed") { + failed += n; + } else if let Some(n) = extract_count(part, "ignored") { + ignored += n; + } else if let Some(n) = extract_count(part, "filtered out") { + filtered_out += n; + } + } + } + + if trimmed.contains("tests run:") { + has_summary = true; + } + } + + if in_failure_block && !current_block.is_empty() { + failure_blocks.push(current_block); + } + + if !has_summary && passed == 0 && failed == 0 { + return make_result(raw_output, raw_output.to_owned()); + } + + let mut output = String::new(); + + if exit_code != 0 && !failure_blocks.is_empty() { + output.push_str("FAILURES:\n\n"); + for block in &failure_blocks { + output.push_str(block); + output.push('\n'); + } + } + + let status = if failed > 0 { "FAILED" } else { "ok" }; + let _ = write!( + output, + "test result: {status}. {passed} passed; {failed} failed; \ + {ignored} ignored; {filtered_out} filtered out" + ); + + make_result(raw_output, output) + } +} + +fn extract_count(s: &str, label: &str) -> Option { + let idx = s.find(label)?; + let before = s[..idx].trim(); + let num_str = before.rsplit_once(' ').map_or(before, |(_, n)| n); + let num_str = num_str.trim_end_matches('.'); + let num_str = num_str.rsplit('.').next().unwrap_or(num_str).trim(); + num_str.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_cargo_test() { + let f = TestOutputFilter; + assert!(f.matches("cargo test")); + assert!(f.matches("cargo test --workspace")); + assert!(f.matches("cargo +nightly test")); + assert!(f.matches("cargo nextest run")); + assert!(!f.matches("cargo build")); + assert!(!f.matches("cargo test-helper")); + assert!(!f.matches("cargo install cargo-nextest")); + } + + #[test] + fn filter_success_compresses() { + let f = TestOutputFilter; + let raw = "\ +running 3 tests +test foo::test_a ... ok +test foo::test_b ... ok +test foo::test_c ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 filtered out; finished in 0.01s +"; + let result = f.filter("cargo test", raw, 0); + assert!(result.output.contains("3 passed")); + assert!(result.output.contains("0 failed")); + assert!(!result.output.contains("test_a")); + assert!(result.savings_pct() > 30.0); + } + + #[test] + fn filter_failure_preserves_details() { + let f = TestOutputFilter; + let raw = "\ +running 2 tests +test foo::test_a ... ok +test foo::test_b ... FAILED + +---- foo::test_b stdout ---- +thread 'foo::test_b' panicked at 'assertion failed: false' + +failures: + foo::test_b + +test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 filtered out; finished in 0.01s +"; + let result = f.filter("cargo test", raw, 1); + assert!(result.output.contains("FAILURES:")); + assert!(result.output.contains("foo::test_b")); + assert!(result.output.contains("assertion failed")); + assert!(result.output.contains("1 failed")); + } + + #[test] + fn filter_no_summary_passthrough() { + let f = TestOutputFilter; + let raw = "some random output with no test results"; + let result = f.filter("cargo test", raw, 0); + assert_eq!(result.output, raw); + } +} diff --git a/crates/zeph-tools/src/lib.rs b/crates/zeph-tools/src/lib.rs index 082e5ea2..9d22bfe7 100644 --- a/crates/zeph-tools/src/lib.rs +++ b/crates/zeph-tools/src/lib.rs @@ -6,6 +6,7 @@ pub mod composite; pub mod config; pub mod executor; pub mod file; +pub mod filter; pub mod overflow; pub mod permissions; pub mod registry; @@ -17,6 +18,7 @@ pub use anomaly::{AnomalyDetector, AnomalySeverity}; pub use audit::{AuditEntry, AuditLogger, AuditResult}; pub use composite::CompositeExecutor; pub use config::{AuditConfig, ScrapeConfig, ShellConfig, ToolsConfig}; +pub use filter::{FilterConfig, FilterResult, OutputFilter, OutputFilterRegistry, sanitize_output}; pub use executor::{ MAX_TOOL_OUTPUT_CHARS, ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput, truncate_tool_output, diff --git a/crates/zeph-tools/src/shell.rs b/crates/zeph-tools/src/shell.rs index d5223635..a6209d8e 100644 --- a/crates/zeph-tools/src/shell.rs +++ b/crates/zeph-tools/src/shell.rs @@ -8,6 +8,7 @@ use schemars::JsonSchema; use crate::audit::{AuditEntry, AuditLogger, AuditResult}; use crate::config::ShellConfig; use crate::executor::{ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput}; +use crate::filter::{OutputFilterRegistry, sanitize_output}; use crate::permissions::{PermissionAction, PermissionPolicy}; const DEFAULT_BLOCKED: &[&str] = &[ @@ -35,6 +36,7 @@ pub struct ShellExecutor { audit_logger: Option, tool_event_tx: Option, permission_policy: Option, + output_filter_registry: Option, } impl ShellExecutor { @@ -79,6 +81,7 @@ impl ShellExecutor { audit_logger: None, tool_event_tx: None, permission_policy: None, + output_filter_registry: None, } } @@ -100,6 +103,12 @@ impl ShellExecutor { self } + #[must_use] + pub fn with_output_filters(mut self, registry: OutputFilterRegistry) -> Self { + self.output_filter_registry = Some(registry); + self + } + /// Execute a bash block bypassing the confirmation check (called after user confirms). /// /// # Errors @@ -109,6 +118,7 @@ impl ShellExecutor { self.execute_inner(response, true).await } + #[allow(clippy::too_many_lines)] async fn execute_inner( &self, response: &str, @@ -178,7 +188,8 @@ impl ShellExecutor { } let start = Instant::now(); - let out = execute_bash(block, self.timeout, self.tool_event_tx.as_ref()).await; + let (out, exit_code) = + execute_bash(block, self.timeout, self.tool_event_tx.as_ref()).await; #[allow(clippy::cast_possible_truncation)] let duration_ms = start.elapsed().as_millis() as u64; @@ -202,7 +213,25 @@ impl ShellExecutor { }); } - outputs.push(format!("$ {block}\n{out}")); + let sanitized = sanitize_output(&out); + let filtered = if let Some(ref registry) = self.output_filter_registry { + match registry.apply(block, &sanitized, exit_code) { + Some(fr) => { + tracing::debug!( + command = block, + raw = fr.raw_chars, + filtered = fr.filtered_chars, + savings_pct = fr.savings_pct(), + "output filter applied" + ); + fr.output + } + None => sanitized, + } + } else { + sanitized + }; + outputs.push(format!("$ {block}\n{filtered}")); } Ok(Some(ToolOutput { @@ -338,7 +367,7 @@ fn chrono_now() -> String { format!("{secs}") } -async fn execute_bash(code: &str, timeout: Duration, event_tx: Option<&ToolEventTx>) -> String { +async fn execute_bash(code: &str, timeout: Duration, event_tx: Option<&ToolEventTx>) -> (String, i32) { use std::process::Stdio; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -353,7 +382,7 @@ async fn execute_bash(code: &str, timeout: Duration, event_tx: Option<&ToolEvent let mut child = match child_result { Ok(c) => c, - Err(e) => return format!("[error] {e}"), + Err(e) => return (format!("[error] {e}"), 1), }; let stdout = child.stdout.take().expect("stdout piped"); @@ -402,17 +431,21 @@ async fn execute_bash(code: &str, timeout: Duration, event_tx: Option<&ToolEvent } () = tokio::time::sleep_until(deadline) => { let _ = child.kill().await; - return format!("[error] command timed out after {timeout_secs}s"); + return (format!("[error] command timed out after {timeout_secs}s"), 1); } } } - let _ = child.wait().await; + let status = child.wait().await; + let exit_code = status + .ok() + .and_then(|s| s.code()) + .unwrap_or(1); if combined.is_empty() { - "(no output)".to_string() + ("(no output)".to_string(), exit_code) } else { - combined + (combined, exit_code) } } @@ -476,14 +509,15 @@ mod tests { #[tokio::test] #[cfg(not(target_os = "windows"))] async fn execute_simple_command() { - let result = execute_bash("echo hello", Duration::from_secs(30), None).await; + let (result, code) = execute_bash("echo hello", Duration::from_secs(30), None).await; assert!(result.contains("hello")); + assert_eq!(code, 0); } #[tokio::test] #[cfg(not(target_os = "windows"))] async fn execute_stderr_output() { - let result = execute_bash("echo err >&2", Duration::from_secs(30), None).await; + let (result, _) = execute_bash("echo err >&2", Duration::from_secs(30), None).await; assert!(result.contains("[stderr]")); assert!(result.contains("err")); } @@ -491,7 +525,7 @@ mod tests { #[tokio::test] #[cfg(not(target_os = "windows"))] async fn execute_stdout_and_stderr_combined() { - let result = execute_bash("echo out && echo err >&2", Duration::from_secs(30), None).await; + let (result, _) = execute_bash("echo out && echo err >&2", Duration::from_secs(30), None).await; assert!(result.contains("out")); assert!(result.contains("[stderr]")); assert!(result.contains("err")); @@ -501,8 +535,9 @@ mod tests { #[tokio::test] #[cfg(not(target_os = "windows"))] async fn execute_empty_output() { - let result = execute_bash("true", Duration::from_secs(30), None).await; + let (result, code) = execute_bash("true", Duration::from_secs(30), None).await; assert_eq!(result, "(no output)"); + assert_eq!(code, 0); } #[tokio::test] @@ -1073,14 +1108,15 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn execute_bash_error_handling() { - let result = execute_bash("false", Duration::from_secs(5), None).await; + let (result, code) = execute_bash("false", Duration::from_secs(5), None).await; assert_eq!(result, "(no output)"); + assert_eq!(code, 1); } #[cfg(unix)] #[tokio::test] async fn execute_bash_command_not_found() { - let result = execute_bash("nonexistent-command-xyz", Duration::from_secs(5), None).await; + let (result, _) = execute_bash("nonexistent-command-xyz", Duration::from_secs(5), None).await; assert!(result.contains("[stderr]") || result.contains("[error]")); } diff --git a/src/main.rs b/src/main.rs index 8564184e..23e1b94a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -168,8 +168,14 @@ async fn main() -> anyhow::Result<()> { #[cfg(feature = "mcp")] let (tool_executor, mcp_tools, mcp_manager, shell_executor_for_tui) = { + let filter_registry = if config.tools.filters.enabled { + zeph_tools::OutputFilterRegistry::default_filters() + } else { + zeph_tools::OutputFilterRegistry::new(false) + }; let mut shell_executor = zeph_tools::ShellExecutor::new(&config.tools.shell) - .with_permissions(permission_policy.clone()); + .with_permissions(permission_policy.clone()) + .with_output_filters(filter_registry); if config.tools.audit.enabled && let Ok(logger) = zeph_tools::AuditLogger::from_config(&config.tools.audit).await { @@ -252,8 +258,14 @@ async fn main() -> anyhow::Result<()> { #[cfg(not(any(feature = "mcp", feature = "tui")))] let tool_executor = { + let filter_registry = if config.tools.filters.enabled { + zeph_tools::OutputFilterRegistry::default_filters() + } else { + zeph_tools::OutputFilterRegistry::new(false) + }; let mut shell_executor = zeph_tools::ShellExecutor::new(&config.tools.shell) - .with_permissions(permission_policy.clone()); + .with_permissions(permission_policy.clone()) + .with_output_filters(filter_registry); if config.tools.audit.enabled && let Ok(logger) = zeph_tools::AuditLogger::from_config(&config.tools.audit).await { From c86d5ea137ffb3f6fa66492ed3cd6914adac7900 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Tue, 17 Feb 2026 14:12:45 +0100 Subject: [PATCH 2/2] style: apply rustfmt formatting to filter modules --- crates/zeph-tools/src/filter/clippy.rs | 15 +++++++++++---- crates/zeph-tools/src/filter/dir_listing.rs | 6 +++++- crates/zeph-tools/src/filter/mod.rs | 9 ++++++++- crates/zeph-tools/src/lib.rs | 2 +- crates/zeph-tools/src/shell.rs | 17 ++++++++++------- 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/crates/zeph-tools/src/filter/clippy.rs b/crates/zeph-tools/src/filter/clippy.rs index 2123ecaf..84bab873 100644 --- a/crates/zeph-tools/src/filter/clippy.rs +++ b/crates/zeph-tools/src/filter/clippy.rs @@ -11,8 +11,7 @@ pub struct ClippyFilter; static LINT_RULE_RE: LazyLock = LazyLock::new(|| Regex::new(r"#\[warn\(([^)]+)\)\]").unwrap()); -static LOCATION_RE: LazyLock = - LazyLock::new(|| Regex::new(r"^\s*-->\s*(.+:\d+)").unwrap()); +static LOCATION_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*-->\s*(.+:\d+)").unwrap()); impl OutputFilter for ClippyFilter { fn matches(&self, command: &str) -> bool { @@ -101,10 +100,18 @@ warning: unused import warning: `my-crate` (lib) generated 3 warnings "; let result = f.filter("cargo clippy", raw, 0); - assert!(result.output.contains("clippy::needless_pass_by_value (2 warnings):")); + assert!( + result + .output + .contains("clippy::needless_pass_by_value (2 warnings):") + ); assert!(result.output.contains("src/foo.rs:12")); assert!(result.output.contains("src/bar.rs:45")); - assert!(result.output.contains("clippy::unused_imports (1 warning):")); + assert!( + result + .output + .contains("clippy::unused_imports (1 warning):") + ); assert!(result.output.contains("3 warnings total (2 rules)")); } diff --git a/crates/zeph-tools/src/filter/dir_listing.rs b/crates/zeph-tools/src/filter/dir_listing.rs index 71f1b3e3..fb6ea36f 100644 --- a/crates/zeph-tools/src/filter/dir_listing.rs +++ b/crates/zeph-tools/src/filter/dir_listing.rs @@ -73,7 +73,11 @@ mod tests { assert!(result.output.contains("src")); assert!(result.output.contains("README.md")); assert!(!result.output.contains("\ntarget\n")); - assert!(result.output.contains("(+ 3 hidden: target, node_modules, .git)")); + assert!( + result + .output + .contains("(+ 3 hidden: target, node_modules, .git)") + ); } #[test] diff --git a/crates/zeph-tools/src/filter/mod.rs b/crates/zeph-tools/src/filter/mod.rs index f77425ee..c09c91ea 100644 --- a/crates/zeph-tools/src/filter/mod.rs +++ b/crates/zeph-tools/src/filter/mod.rs @@ -217,7 +217,14 @@ mod tests { #[test] fn registry_default_has_filters() { let r = OutputFilterRegistry::default_filters(); - assert!(r.apply("cargo test", "test result: ok. 5 passed; 0 failed; 0 ignored; 0 filtered out", 0).is_some()); + assert!( + r.apply( + "cargo test", + "test result: ok. 5 passed; 0 failed; 0 ignored; 0 filtered out", + 0 + ) + .is_some() + ); } #[test] diff --git a/crates/zeph-tools/src/lib.rs b/crates/zeph-tools/src/lib.rs index 9d22bfe7..5af8ccfd 100644 --- a/crates/zeph-tools/src/lib.rs +++ b/crates/zeph-tools/src/lib.rs @@ -18,12 +18,12 @@ pub use anomaly::{AnomalyDetector, AnomalySeverity}; pub use audit::{AuditEntry, AuditLogger, AuditResult}; pub use composite::CompositeExecutor; pub use config::{AuditConfig, ScrapeConfig, ShellConfig, ToolsConfig}; -pub use filter::{FilterConfig, FilterResult, OutputFilter, OutputFilterRegistry, sanitize_output}; pub use executor::{ MAX_TOOL_OUTPUT_CHARS, ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput, truncate_tool_output, }; pub use file::FileExecutor; +pub use filter::{FilterConfig, FilterResult, OutputFilter, OutputFilterRegistry, sanitize_output}; pub use overflow::{cleanup_overflow_files, save_overflow}; pub use permissions::{ AutonomyLevel, PermissionAction, PermissionPolicy, PermissionRule, PermissionsConfig, diff --git a/crates/zeph-tools/src/shell.rs b/crates/zeph-tools/src/shell.rs index a6209d8e..f158766c 100644 --- a/crates/zeph-tools/src/shell.rs +++ b/crates/zeph-tools/src/shell.rs @@ -367,7 +367,11 @@ fn chrono_now() -> String { format!("{secs}") } -async fn execute_bash(code: &str, timeout: Duration, event_tx: Option<&ToolEventTx>) -> (String, i32) { +async fn execute_bash( + code: &str, + timeout: Duration, + event_tx: Option<&ToolEventTx>, +) -> (String, i32) { use std::process::Stdio; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -437,10 +441,7 @@ async fn execute_bash(code: &str, timeout: Duration, event_tx: Option<&ToolEvent } let status = child.wait().await; - let exit_code = status - .ok() - .and_then(|s| s.code()) - .unwrap_or(1); + let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(1); if combined.is_empty() { ("(no output)".to_string(), exit_code) @@ -525,7 +526,8 @@ mod tests { #[tokio::test] #[cfg(not(target_os = "windows"))] async fn execute_stdout_and_stderr_combined() { - let (result, _) = execute_bash("echo out && echo err >&2", Duration::from_secs(30), None).await; + let (result, _) = + execute_bash("echo out && echo err >&2", Duration::from_secs(30), None).await; assert!(result.contains("out")); assert!(result.contains("[stderr]")); assert!(result.contains("err")); @@ -1116,7 +1118,8 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn execute_bash_command_not_found() { - let (result, _) = execute_bash("nonexistent-command-xyz", Duration::from_secs(5), None).await; + let (result, _) = + execute_bash("nonexistent-command-xyz", Duration::from_secs(5), None).await; assert!(result.contains("[stderr]") || result.contains("[error]")); }