Skip to content

Commit 12350a4

Browse files
authored
feat(eval): add evaluation harness for rule efficacy measurement (#158)
* feat(eval): add evaluation harness module for rule efficacy testing Add core evaluation types and logic: - EvalCase, EvalResult for test case representation - RuleMetrics with precision/recall/F1 calculation - EvalSummary aggregating results across cases - EvalManifest for YAML manifest parsing - evaluate_case() and evaluate_manifest() functions - Output formatters: JSON, CSV, Markdown This enables measuring validation rule effectiveness against labeled test cases, supporting data-driven rule development. Part of #111 * feat(cli): add 'agnix eval' subcommand for evaluation harness Add CLI subcommand to run evaluation manifests: - --format option: markdown (default), json, csv - --filter option: filter cases by rule prefix - --verbose option: show per-case pass/fail details - Exit code 1 on any failing cases Usage: agnix eval tests/eval/eval.yaml agnix eval tests/eval/eval.yaml --format json agnix eval tests/eval/eval.yaml --filter "MCP-" --verbose Part of #111 * test(eval): add initial evaluation dataset with 39 test cases Create evaluation manifest covering all rule families: - AS-*, CC-SK-*: Skill validation rules (13 cases) - MCP-*: MCP tool validation rules (8 cases) - AGM-*: AGENTS.md rules (6 cases) - XP-*: Cross-platform rules (5 cases) - XML-*: XML validation rules (4 cases) - REF-*: Reference/import rules (3 cases) All 39 cases pass with 100% precision/recall. Part of #111 * docs(eval): add evaluation harness documentation Document the evaluation harness feature: - Quick start examples - Manifest format specification - Metrics explanation (TP, FP, FN, precision, recall, F1) - Output format examples (markdown, JSON, CSV) - Usage guide for regression testing and rule development Part of #111 * fix: add path traversal protection and comprehensive tests - Add validate_path_within_base() to prevent path traversal attacks - Add EvalError::PathTraversal variant for security errors - Add tests for error paths (file not found, invalid YAML) - Add tests for filter logic with various scenarios - Add tests for evaluate_manifest_file entry point - Add tests for path traversal detection - Add tests for Display trait and empty results edge case Security: Validates that manifest file paths stay within base directory * fix(security): harden path traversal protection - Reject absolute paths explicitly (only relative paths allowed) - Reject paths containing .. segments before joining (defense-in-depth) - Fail validation when canonicalization fails (no fallback to non-canonical) - Add explicit test for absolute path rejection - Strengthen test assertions to explicitly verify path-traversal detection Security: Addresses edge cases with non-existent malicious paths and platform-specific path behaviors. * fix(security): allow relative paths with parent refs within repo The previous security model was too strict, rejecting any path with .. segments. This made the evaluation harness unusable for manifests in subdirectories that reference sibling directories (e.g., tests/eval/ referencing ../fixtures/). New security model: - Reject absolute paths (prevents /etc/passwd access) - Allow relative paths with .. (enables ../fixtures patterns) - Require file to exist (canonicalization verifies) This is still secure because: 1. Absolute paths are blocked 2. Non-existent files fail validation 3. Symlinks are resolved to real targets Add test for relative paths with parent directory references. * docs: add eval command to README and CHANGELOG - Add agnix eval example to Quick Start section - Add comprehensive changelog entry for evaluation harness feature * fix: address review feedback - path traversal and filter logic Security (Critical): - Add canonical base directory check to prevent path traversal - Return canonical path to prevent TOCTOU vulnerabilities - Move eval.yaml to tests/ to avoid ../fixtures paths Filter logic (High): - Include cases with empty expected to detect FPs on clean files - Use starts_with for filter matching (per CLI help text) Tests: - Update path traversal tests for stricter security - Add test_relative_path_within_base_dir_allowed - Update test_relative_path_escaping_base_dir_blocked Docs: - Update paths from tests/eval/eval.yaml to tests/eval.yaml
1 parent dd61f9d commit 12350a4

7 files changed

Lines changed: 1575 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7171
- Documented SHA pin reference in .github/workflows/README.md for maintainability
7272

7373
### Added
74+
- Evaluation harness with `agnix eval` command for measuring rule efficacy
75+
- Load test cases from YAML manifests with expected rule IDs
76+
- Calculate precision, recall, and F1 scores per rule and overall
77+
- Output formats: markdown (default), JSON, CSV
78+
- Filter by rule prefix (`--filter`)
79+
- Verbose mode for per-case details (`--verbose`)
80+
- 39 test cases covering AS-*, CC-SK-*, MCP-*, AGM-*, XP-*, XML-*, REF-* rules
81+
- Path traversal protection (relative paths only)
82+
- Documentation in knowledge-base/EVALUATION.md
7483
- MCP-008 rule for protocol version validation with configurable `mcp_protocol_version` option
7584
- 5 new parse error rules with normalized IDs (AS-016, CC-HK-012, CC-AG-007, CC-PL-006, MCP-007)
7685
- Auto-fix support for CC-MEM-005 and CC-MEM-007 memory rules

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ agnix --format sarif .
7575

7676
# Generate config file
7777
agnix init
78+
79+
# Evaluate rule efficacy
80+
agnix eval tests/eval.yaml
7881
```
7982

8083
## Output

crates/agnix-cli/src/main.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use agnix_core::{
77
apply_fixes,
88
config::{LintConfig, TargetTool},
99
diagnostics::DiagnosticLevel,
10+
eval::{evaluate_manifest_file, EvalFormat},
1011
validate_project, ValidationResult,
1112
};
1213
use clap::{Parser, Subcommand, ValueEnum};
@@ -95,6 +96,25 @@ struct Cli {
9596
format: OutputFormat,
9697
}
9798

99+
/// Output format for evaluation results
100+
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
101+
pub enum EvalOutputFormat {
102+
#[default]
103+
Markdown,
104+
Json,
105+
Csv,
106+
}
107+
108+
impl From<EvalOutputFormat> for EvalFormat {
109+
fn from(f: EvalOutputFormat) -> Self {
110+
match f {
111+
EvalOutputFormat::Markdown => EvalFormat::Markdown,
112+
EvalOutputFormat::Json => EvalFormat::Json,
113+
EvalOutputFormat::Csv => EvalFormat::Csv,
114+
}
115+
}
116+
}
117+
98118
#[derive(Subcommand)]
99119
enum Commands {
100120
/// Validate agent configs
@@ -110,6 +130,24 @@ enum Commands {
110130
#[arg(default_value = ".agnix.toml")]
111131
output: PathBuf,
112132
},
133+
134+
/// Evaluate rule efficacy against labeled test cases
135+
Eval {
136+
/// Path to evaluation manifest (YAML file)
137+
path: PathBuf,
138+
139+
/// Output format (markdown, json, csv)
140+
#[arg(long, short, value_enum, default_value_t = EvalOutputFormat::Markdown)]
141+
format: EvalOutputFormat,
142+
143+
/// Filter to specific rule prefix (e.g., "AS-", "MCP-")
144+
#[arg(long)]
145+
filter: Option<String>,
146+
147+
/// Show detailed results for each case
148+
#[arg(long, short)]
149+
verbose: bool,
150+
},
113151
}
114152

115153
fn main() {
@@ -118,6 +156,12 @@ fn main() {
118156
let result = match &cli.command {
119157
Some(Commands::Validate { path }) => validate_command(path, &cli),
120158
Some(Commands::Init { output }) => init_command(output),
159+
Some(Commands::Eval {
160+
path,
161+
format,
162+
filter,
163+
verbose,
164+
}) => eval_command(path, *format, filter.as_deref(), *verbose),
121165
None => validate_command(&cli.path, &cli),
122166
};
123167

@@ -373,3 +417,94 @@ fn init_command(output: &PathBuf) -> anyhow::Result<()> {
373417

374418
Ok(())
375419
}
420+
421+
fn eval_command(
422+
path: &Path,
423+
format: EvalOutputFormat,
424+
filter: Option<&str>,
425+
verbose: bool,
426+
) -> anyhow::Result<()> {
427+
let config = LintConfig::default();
428+
429+
println!("{} {}", "Evaluating:".cyan().bold(), path.display());
430+
if let Some(f) = filter {
431+
println!(" {} {}", "filter:".dimmed(), f);
432+
}
433+
println!();
434+
435+
let (results, summary) = evaluate_manifest_file(path, &config, filter)?;
436+
437+
// Show verbose per-case results if requested
438+
if verbose {
439+
println!("{}", "Per-Case Results".cyan().bold());
440+
println!("{}", "=".repeat(60).dimmed());
441+
442+
for result in &results {
443+
let status = if result.passed() {
444+
"PASS".green().bold()
445+
} else {
446+
"FAIL".red().bold()
447+
};
448+
449+
println!("[{}] {}", status, result.case.file.display());
450+
451+
if let Some(desc) = &result.case.description {
452+
println!(" {}", desc.dimmed());
453+
}
454+
455+
if !result.passed() {
456+
if !result.false_positives.is_empty() {
457+
println!(
458+
" {} {:?}",
459+
"unexpected:".yellow(),
460+
result.false_positives
461+
);
462+
}
463+
if !result.false_negatives.is_empty() {
464+
println!(" {} {:?}", "missing:".red(), result.false_negatives);
465+
}
466+
}
467+
println!();
468+
}
469+
470+
println!("{}", "=".repeat(60).dimmed());
471+
println!();
472+
}
473+
474+
// Output summary in requested format
475+
let eval_format: EvalFormat = format.into();
476+
match eval_format {
477+
EvalFormat::Json => {
478+
let json = summary.to_json()?;
479+
println!("{}", json);
480+
}
481+
EvalFormat::Csv => {
482+
let csv = summary.to_csv();
483+
println!("{}", csv);
484+
}
485+
EvalFormat::Markdown => {
486+
let md = summary.to_markdown();
487+
println!("{}", md);
488+
}
489+
}
490+
491+
// Print final status
492+
println!();
493+
if summary.cases_failed == 0 {
494+
println!(
495+
"{} All {} cases passed",
496+
"SUCCESS".green().bold(),
497+
summary.cases_run
498+
);
499+
} else {
500+
println!(
501+
"{} {}/{} cases failed",
502+
"FAILED".red().bold(),
503+
summary.cases_failed,
504+
summary.cases_run
505+
);
506+
process::exit(1);
507+
}
508+
509+
Ok(())
510+
}

0 commit comments

Comments
 (0)