#!/usr/bin/env python3 """ Hermaguard Rule Compiler — Compiles dismissed findings into suppression rules. Reads the feedback database, identifies patterns that have been repeatedly dismissed as false positives, and writes rules to hermaguard-rules.yaml. Usage: hermaguard-compile-rules --output hermaguard-rules.yaml hermaguard-compile-rules --output .kensei/hermaguard-rules.yaml --min-dismissals 3 Based on TRACE paper: compiling user corrections into deterministic rules reduces violation rates from 100% → 2%. """ import argparse import json import os import sqlite3 import sys VERSION = "1.0.0" DB_PATH = os.environ.get("HERMAGUARD_FEEDBACK_DB", os.path.expanduser("~/.hermes/data/hermaguard-feedback.db")) def compile_rules(min_dismissals: int = 3) -> list: """Query the feedback database for dismissed patterns, return rules list.""" if not os.path.isfile(DB_PATH): return [] conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row # Find patterns: (source_agent, trigger_condition) pairs dismissed 3+ times rows = conn.execute(""" SELECT source_agent, trigger_condition, file_path, COUNT(*) as dismissals, GROUP_CONCAT(DISTINCT finding_id) as finding_ids FROM findings WHERE status = 'dismissed-false-positive' GROUP BY source_agent, trigger_condition HAVING COUNT(*) >= ? ORDER BY dismissals DESC """, (min_dismissals,)).fetchall() conn.close() rules = [] for i, r in enumerate(rows): agent_short = { "edge-case-hunter": "edge_case", "adversarial-reviewer": "adversarial", "blast-radius-integration": "blast_radius", }.get(r["source_agent"], r["source_agent"].replace("-", "_")) rules.append({ "rule_id": f"HG-SUPPRESS-{i + 1:03d}", "pattern": r["trigger_condition"], "agent": agent_short, "file_scope": r["file_path"], "dismissals": r["dismissals"], "reason": f"Dismissed {r['dismissals']}x as false positive", "source_ids": r["finding_ids"].split(",") if r["finding_ids"] else [], }) return rules def main(): parser = argparse.ArgumentParser( description="Hermaguard Rule Compiler — generate suppression rules from dismissed findings" ) parser.add_argument("--output", required=True, help="Output YAML file path") parser.add_argument("--min-dismissals", type=int, default=3, help="Min dismissals for a pattern to become a rule (default: 3)") args = parser.parse_args() rules = compile_rules(args.min_dismissals) output = { "version": VERSION, "generated_at": __import__("datetime").datetime.now().isoformat(), "rules": rules, "stats": { "total_rules": len(rules), "by_agent": {}, }, } for r in rules: agent = r["agent"] output["stats"]["by_agent"][agent] = output["stats"]["by_agent"].get(agent, 0) + 1 os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) # Write YAML manually to avoid requiring PyYAML with open(args.output, "w") as f: f.write(f"# Hermaguard Suppression Rules\n") f.write(f"# Auto-generated by hermaguard-compile-rules v{VERSION}\n") f.write(f"# Generated: {output['generated_at']}\n") f.write(f"# Total rules: {len(rules)}\n") f.write(f"# Source: {DB_PATH}\n\n") f.write(f"# These patterns have been repeatedly dismissed as false positives.\n") f.write(f"# Agents skip findings matching these patterns.\n") f.write(f"# Delete rules to re-enable checking for a pattern.\n\n") for r in rules: reason_line = str(r['reason']).replace("\n", " ") f.write(f"# {reason_line} | files: {r['file_scope']}\n") f.write(f"- rule_id: {r['rule_id']}\n") # json.dumps yields a valid double-quoted scalar (YAML is a JSON # superset here), so quotes/backslashes in the pattern can't break the file. f.write(f" pattern: {json.dumps(r['pattern'])}\n") f.write(f" agent: {r['agent']}\n") f.write(f" dismissals: {r['dismissals']}\n") f.write(f"\n") print(f"Compiled {len(rules)} suppression rules from {DB_PATH}") print(f"Agents: {json.dumps(output['stats']['by_agent'])}") print(f"Output: {args.output}") if __name__ == "__main__": main()