Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/zeph-tools/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ pub struct ToolsConfig {
pub audit: AuditConfig,
#[serde(default)]
pub permissions: Option<PermissionsConfig>,
#[serde(default)]
pub filters: crate::filter::FilterConfig,
}

impl ToolsConfig {
Expand Down Expand Up @@ -93,6 +95,7 @@ impl Default for ToolsConfig {
scrape: ScrapeConfig::default(),
audit: AuditConfig::default(),
permissions: None,
filters: crate::filter::FilterConfig::default(),
}
}
}
Expand Down
133 changes: 133 additions & 0 deletions crates/zeph-tools/src/filter/clippy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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<Regex> =
LazyLock::new(|| Regex::new(r"#\[warn\(([^)]+)\)\]").unwrap());

static LOCATION_RE: LazyLock<Regex> = 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<String, Vec<String>> = BTreeMap::new();
let mut pending_location: Option<String> = 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);
}
}
104 changes: 104 additions & 0 deletions crates/zeph-tools/src/filter/dir_listing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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)"));
}
}
Loading
Loading