Files
evandrodevbr c17192c9fe feat: vendorizar as 10 skills do pilar de seguranca em skills/
Copia local (nao fork, nao submodule) de safedeps, sca-audit,
secrets-scan, code-review-security, api-security-review,
web-security-review, cve-triage, patch-prioritization,
dependency-scanning e hermaguard -- commit exato de origem e licenca
de cada uma em skills/NOTICE.md. Elimina o passo de clone externo
pro pilar de seguranca do wf-security-review: git clone deste repo
ja traz tudo funcional.

Corrige tambem o comando de instalacao pro Claude Code nos dois
READMEs e no docs/SKILLS.md: Claude Code exige um diretorio por
skill (skills/<nome>/SKILL.md), nao aceita os .md soltos que o
comando antigo copiava -- confirmado testando a instalacao real.
2026-08-24 00:32:20 -03:00

759 lines
34 KiB
Python

"""
Hermaguard Tools — Test Suite
Tests import and call real functions from the tools.
No placebo assertions. If a tool is deleted, its tests fail.
Usage:
cd /tmp/hermaguard && python3 -m unittest test_tools.py -v
"""
import importlib.util
import json
import os
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent
def _load_module(name, relpath):
"""Import a Python module from a relative path under ROOT."""
spec = importlib.util.spec_from_file_location(name, ROOT / relpath)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load {relpath}")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# ── Pre-Scan tests (call real split_files, run_prescan, parsers) ────────────
class TestPreScan(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mod = _load_module("prescan", "tools/hermaguard-prescan/hermaguard-prescan.py")
def test_split_files_comma_separated(self):
result = self.mod.split_files("a.py, b.py, c.ts")
self.assertEqual(result, ["a.py", "b.py", "c.ts"])
def test_split_files_newline_separated(self):
result = self.mod.split_files("src/main.py\nsrc/utils.py\n")
self.assertEqual(result, ["src/main.py", "src/utils.py"])
def test_split_files_empty(self):
self.assertEqual(self.mod.split_files(""), [])
self.assertEqual(self.mod.split_files(" , "), [])
def test_run_prescan_empty_files(self):
"""Empty file list produces valid output dict with zero findings."""
output = self.mod.run_prescan([])
self.assertEqual(output["stats"]["total_findings"], 0)
self.assertEqual(output["stats"]["files_scanned"], 0)
self.assertIsInstance(output["findings"], list)
def test_run_prescan_nonexistent_tools(self):
"""Files with extensions that have no installed tools → all skipped."""
# Create temp .py file that doesn't exist on disk (tools won't find it)
# run_prescan will try to find bandit/ruff, fail, skip them
with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as f:
f.write(b"x = 1\n")
pyfile = f.name
try:
output = self.mod.run_prescan([pyfile])
# Should have scanned 1 file, but tools likely skipped (not installed)
self.assertEqual(output["stats"]["files_scanned"], 1)
self.assertIn("findings", output)
self.assertIn("stats", output)
self.assertIn("tools_run", output["stats"])
self.assertIn("tools_skipped", output["stats"])
finally:
os.unlink(pyfile)
def test_bandit_parser_real_output(self):
"""Parse real bandit JSON output."""
sample = json.dumps({
"results": [
{
"test_id": "B301",
"issue_severity": "MEDIUM",
"filename": "src/auth.py",
"line_number": 42,
"issue_text": "Use of unsafe yaml.load()",
}
]
})
findings = self.mod._parse_bandit(sample)
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["tool"], "bandit")
self.assertEqual(findings[0]["rule_id"], "B301")
self.assertEqual(findings[0]["severity"], "MEDIUM")
self.assertEqual(findings[0]["file"], "src/auth.py")
self.assertEqual(findings[0]["line"], 42)
def test_bandit_parser_empty(self):
self.assertEqual(self.mod._parse_bandit("not json"), [])
def test_ruff_parser_real_output(self):
"""Parse real ruff JSON-line output."""
sample = json.dumps({
"code": "B007",
"filename": "src/main.py",
"location": {"row": 15},
"message": "Loop control variable not used",
})
findings = self.mod._parse_ruff(sample + "\n")
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["tool"], "ruff")
self.assertEqual(findings[0]["rule_id"], "B007")
def test_semgrep_parser_real_output(self):
sample = json.dumps({
"results": [
{
"check_id": "python.lang.security.audit.dangerous-subprocess-use",
"path": "src/runner.py",
"start": {"line": 10},
"extra": {"severity": "ERROR", "message": "subprocess call with shell=True"},
}
]
})
findings = self.mod._parse_semgrep(sample)
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["severity"], "HIGH")
self.assertEqual(findings[0]["tool"], "semgrep")
def test_eslint_parser_real_output(self):
sample = json.dumps([
{
"filePath": "/app/src/index.ts",
"messages": [
{"ruleId": "no-undef", "severity": 2, "line": 5, "message": "'foo' is not defined."}
]
}
])
findings = self.mod._parse_eslint(sample)
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["severity"], "HIGH")
self.assertEqual(findings[0]["rule_id"], "no-undef")
def test_gosec_parser_real_output(self):
sample = json.dumps({
"Issues": [
{"rule_id": "G104", "severity": "HIGH", "file": "main.go", "line": "20", "details": "Audit use of net/http"}
]
})
findings = self.mod._parse_gosec(sample)
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["tool"], "gosec")
def test_clippy_parser_real_output(self):
sample = json.dumps({
"reason": "compiler-message",
"message": {"code": {"code": "clippy::needless_return"}, "message": "unneeded return statement", "level": "warning"},
"spans": [{"file_name": "src/lib.rs", "line_start": 42}]
})
findings = self.mod._parse_cargo(sample + "\n")
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["tool"], "clippy")
def test_language_tool_mapping(self):
"""Every known extension maps to at least one tool definition."""
for ext in [".py", ".ts", ".tsx", ".js", ".go", ".rs", ".java"]:
tools = self.mod.LANGUAGE_TOOLS.get(ext, [])
self.assertGreater(len(tools), 0, f"Extension {ext} should have tool mappings")
def test_group_by_extension(self):
groups = self.mod.group_by_extension(["src/main.py", "src/utils.py", "app/index.ts"])
self.assertIn(".py", groups)
self.assertIn(".ts", groups)
self.assertEqual(len(groups[".py"]["files"]), 2)
self.assertEqual(len(groups[".ts"]["files"]), 1)
def test_find_tool_returns_none_for_nonexistent(self):
result = self.mod.find_tool("hermaguard-nonexistent-tool-xyz")
self.assertIsNone(result)
def test_find_tool_returns_path_for_real(self):
# python3 is guaranteed to exist
result = self.mod.find_tool("python3")
self.assertIsNotNone(result)
# ── Feedback MCP server tests (import module, test tool functions directly) ──
class TestFeedbackServer(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mod = _load_module("feedback_server", "mcp/hermaguard-feedback/server.py")
def setUp(self):
self.db_fd, self.db_path = tempfile.mkstemp(suffix=".db")
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA foreign_keys=ON")
# Create tables via the module's get_db logic
self.conn.executescript("""
CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, repo TEXT,
diff_hash TEXT, files_changed INTEGER DEFAULT 0,
files_blast_radius INTEGER DEFAULT 0, total_findings INTEGER DEFAULT 0,
agent_1_findings INTEGER DEFAULT 0, agent_2_findings INTEGER DEFAULT 0,
agent_3_findings INTEGER DEFAULT 0, prescan_findings INTEGER DEFAULT 0,
overall_risk TEXT, report_path TEXT
);
CREATE TABLE IF NOT EXISTS findings (
finding_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(run_id),
severity TEXT NOT NULL, source_agent TEXT NOT NULL,
cross_agent_agreement TEXT, file_path TEXT, line_range TEXT,
trigger_condition TEXT, consequence TEXT, recommendation TEXT,
source_prescan INTEGER DEFAULT 0, prescan_tool TEXT, prescan_rule TEXT,
status TEXT DEFAULT 'open', status_updated_at TEXT, status_note TEXT
);
CREATE TABLE IF NOT EXISTS acceptance_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, finding_id TEXT NOT NULL REFERENCES findings(finding_id),
action TEXT NOT NULL, note TEXT, timestamp TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS suppression_rules_cache (
rule_id TEXT PRIMARY KEY, pattern TEXT NOT NULL, reason TEXT,
created_at TEXT, dismissals INTEGER DEFAULT 0
);
""")
self.conn.commit()
def tearDown(self):
try:
self.conn.close()
os.unlink(self.db_path)
except Exception:
pass
def test_tool_list(self):
"""tools/list returns all 6 tools."""
tools = self.mod.tool_list_tools(self.conn)
tool_names = {t["name"] for t in tools}
for expected in ["record_findings", "accept_finding", "query_agent_precision",
"get_suppression_rules", "get_run_history", "get_run"]:
self.assertIn(expected, tool_names)
def test_record_and_query_run(self):
"""Record a run, then query it back."""
report = {
"meta": {"timestamp": "2026-06-15T10:00:00Z", "diff_ref": "main..HEAD",
"scope": {"files_changed": 2, "files_in_blast_radius": 5},
"prescan": {"total_findings": 1}},
"summary": {"total": 2, "critical": 0, "high": 1, "medium": 1, "low": 0},
"findings": [
{"id": "HG-001", "severity": "HIGH", "source_agent": "adversarial-reviewer",
"cross_agent_agreement": ["edge-case-hunter"],
"file": "src/auth.py", "line_range": "42-42",
"trigger_condition": "unsafe yaml.load", "consequence": "RCE",
"recommendation": "Use safe_load", "source_prescan": True,
"prescan_tool": "bandit", "prescan_rule": "B301"},
{"id": "HG-002", "severity": "MEDIUM", "source_agent": "edge-case-hunter",
"file": "src/auth.py", "line_range": "55-55",
"trigger_condition": "missing null check", "consequence": "TypeError",
"recommendation": "Add guard"},
],
"verdict": {"overall_risk": "HIGH"},
}
result = self.mod.tool_record_findings(self.conn, {
"run_id": "test-run-001",
"findings_json": json.dumps(report),
})
self.assertEqual(result["status"], "recorded")
self.assertEqual(result["findings_stored"], 2)
# Query run history
history = self.mod.tool_get_run_history(self.conn, {"limit": 5})
self.assertEqual(len(history), 1)
self.assertEqual(history[0]["run_id"], "test-run-001")
# Query full run
run = self.mod.tool_get_run(self.conn, {"run_id": "test-run-001"})
self.assertEqual(len(run["findings"]), 2)
def test_accept_finding_and_precision(self):
"""Accept findings, then query precision — verify sane numbers."""
# Record a run with 2 findings
report = {
"meta": {"timestamp": "2026-06-15T10:00:00Z", "scope": {"files_changed": 1, "files_in_blast_radius": 0}},
"summary": {"total": 2, "critical": 0, "high": 1, "medium": 0, "low": 1},
"findings": [
{"id": "HG-001", "severity": "HIGH", "source_agent": "adversarial-reviewer",
"file": "src/x.py", "line_range": "10-10", "trigger_condition": "sql injection",
"consequence": "data leak", "recommendation": "parameterize"},
{"id": "HG-002", "severity": "LOW", "source_agent": "edge-case-hunter",
"file": "src/x.py", "line_range": "20-20", "trigger_condition": "null check on validated input",
"consequence": "noise", "recommendation": "skip"},
],
"verdict": {"overall_risk": "HIGH"},
}
self.mod.tool_record_findings(self.conn, {"run_id": "r1", "findings_json": json.dumps(report)})
# Accept HG-001 as fixed, dismiss HG-002 as false positive
self.mod.tool_accept_finding(self.conn, {"finding_id": "HG-001", "action": "fixed", "note": "parameterized"})
self.mod.tool_accept_finding(self.conn, {"finding_id": "HG-002", "action": "dismissed-false-positive", "note": "validated upstream"})
# Query precision for adversarial-reviewer
prec = self.mod.tool_query_agent_precision(self.conn, {"agent_name": "adversarial-reviewer", "window_days": 30})
# HG-001 is fixed → correct. HG-002 is edge-case-hunter, not adversarial-reviewer.
# So adversarial-reviewer: 1 triaged (HG-001), 1 fixed → 100% precision
self.assertEqual(prec["total_findings"], 1)
self.assertEqual(prec["fixed_or_accepted"], 1)
self.assertEqual(prec["overall_precision_pct"], 100.0)
# Query precision for edge-case-hunter
prec2 = self.mod.tool_query_agent_precision(self.conn, {"agent_name": "edge-case-hunter", "window_days": 30})
# HG-002 is dismissed-false-positive → 0 fixed, 1 triaged → 0% precision
self.assertEqual(prec2["total_findings"], 1)
self.assertEqual(prec2["fixed_or_accepted"], 0)
self.assertEqual(prec2["overall_precision_pct"], 0.0)
def test_open_finding_excluded_from_precision(self):
"""An untriaged (open) finding must not count against precision."""
# Record a run with 1 finding, leave it open
report = {
"meta": {"timestamp": "2026-06-15T10:00:00Z", "scope": {"files_changed": 1, "files_in_blast_radius": 0}},
"summary": {"total": 1, "critical": 0, "high": 1, "medium": 0, "low": 0},
"findings": [
{"id": "HG-001", "severity": "HIGH", "source_agent": "adversarial-reviewer",
"file": "src/x.py", "line_range": "10-10", "trigger_condition": "sql injection",
"consequence": "data leak", "recommendation": "parameterize"},
],
"verdict": {"overall_risk": "HIGH"},
}
self.mod.tool_record_findings(self.conn, {"run_id": "r-open", "findings_json": json.dumps(report)})
# Query precision — finding is still open, never triaged
prec = self.mod.tool_query_agent_precision(self.conn, {"agent_name": "adversarial-reviewer", "window_days": 30})
# Open findings are excluded from the denominator → 0 triaged → N/A
self.assertEqual(prec["total_findings"], 0, "Open finding must not count in denominator")
self.assertEqual(prec["fixed_or_accepted"], 0)
self.assertIsNone(prec["overall_precision_pct"], "Untriaged agent should return None, not 0%")
self.assertEqual(prec["open_untriaged"], 1, "Should report how many findings are still open")
def test_suppression_rules_after_repeated_dismissals(self):
"""After 3 dismissals of same pattern, suppression rules appear."""
# Record 3 runs, each with one finding of the same pattern, then dismiss all 3
for i in range(3):
report = {
"meta": {"timestamp": f"2026-06-15T0{i}:00:00Z", "scope": {"files_changed": 1, "files_in_blast_radius": 0}},
"summary": {"total": 1, "critical": 0, "high": 0, "medium": 0, "low": 1},
"findings": [
{"id": f"HG-00{i+1}", "severity": "LOW", "source_agent": "edge-case-hunter",
"file": "src/test.py", "line_range": "1-1",
"trigger_condition": "null-check on user.id",
"consequence": "noise", "recommendation": "skip"},
],
"verdict": {"overall_risk": "LOW"},
}
self.mod.tool_record_findings(self.conn, {"run_id": f"r{i}", "findings_json": json.dumps(report)})
self.mod.tool_accept_finding(self.conn, {"finding_id": f"HG-00{i+1}", "action": "dismissed-false-positive", "note": "validated upstream"})
# Query suppression rules
rules = self.mod.tool_get_suppression_rules(self.conn)
self.assertGreaterEqual(len(rules), 1)
self.assertIn("null-check", rules[0]["pattern"])
# ── Rule Compiler tests (call real compile_rules) ───────────────────────────
class TestRuleCompiler(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mod = _load_module("compiler", "tools/hermaguard-compile-rules/hermaguard-compile-rules.py")
def setUp(self):
self.db_fd, self.db_path = tempfile.mkstemp(suffix=".db")
def tearDown(self):
try:
os.close(self.db_fd)
os.unlink(self.db_path)
except Exception:
pass
def _seed_db(self):
"""Create tables and insert 3 dismissed findings with same pattern."""
conn = sqlite3.connect(self.db_path)
conn.executescript("""
CREATE TABLE IF NOT EXISTS findings (
finding_id TEXT PRIMARY KEY, run_id TEXT, severity TEXT,
source_agent TEXT, file_path TEXT, trigger_condition TEXT,
status TEXT DEFAULT 'open'
);
""")
for i in range(3):
conn.execute(
"INSERT INTO findings VALUES (?, ?, 'LOW', 'edge-case-hunter', 'src/test.py', 'null-check on user.id', 'dismissed-false-positive')",
(f"HG-00{i+1}", f"r{i}"),
)
conn.commit()
conn.close()
def test_no_db_returns_empty(self):
"""When DB doesn't exist, compile_rules returns empty list."""
with mock.patch.dict(os.environ, {"HERMAGUARD_FEEDBACK_DB": "/nonexistent/path.db"}):
rules = self.mod.compile_rules(min_dismissals=3)
self.assertEqual(rules, [])
def test_compile_rules_from_real_db(self):
"""Seed DB with 3 dismissed findings → compile_rules returns 1 rule."""
self._seed_db()
# Patch the module's DB_PATH directly (set at import time, env override won't work)
with mock.patch.object(self.mod, "DB_PATH", self.db_path):
rules = self.mod.compile_rules(min_dismissals=3)
self.assertEqual(len(rules), 1)
self.assertEqual(rules[0]["agent"], "edge_case")
self.assertEqual(rules[0]["dismissals"], 3)
self.assertIn("null-check", rules[0]["pattern"])
def test_below_threshold_no_rules(self):
"""With min_dismissals=5, 3 dismissals → no rules."""
self._seed_db()
with mock.patch.dict(os.environ, {"HERMAGUARD_FEEDBACK_DB": self.db_path}):
rules = self.mod.compile_rules(min_dismissals=5)
self.assertEqual(rules, [])
# ── Benchmark scoring tests (call real score_run logic) ──────────────────────
class TestBenchmark(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mod = _load_module("benchmark", "tools/hermaguard-benchmark/benchmark.py")
def test_score_run_perfect_match(self):
"""One expected finding, one found finding that matches → 100% P/R/F1."""
bug = {
"id": "test-001", "title": "SQL Injection",
"severity": "CRITICAL", "category": "security",
"expected_findings": [
{"file": "src/auth.py", "trigger": "unsafe sql query", "description": "SQLi in login", "severity": "CRITICAL"}
],
}
run_info = {
"run_ok": True,
"findings": [
{"id": "HG-001", "file": "src/auth.py", "trigger_condition": "unsafe sql query in login handler",
"consequence": "SQL injection", "severity": "CRITICAL"}
],
"summary": {"total": 1},
}
score = self.mod.score_run(bug, run_info)
self.assertEqual(score["precision_pct"], 100.0)
self.assertEqual(score["recall_pct"], 100.0)
self.assertEqual(score["f1_score"], 100.0)
self.assertEqual(score["severity_accuracy_pct"], 100.0)
self.assertEqual(score["true_positives"], 1)
self.assertEqual(score["false_positives"], 0)
self.assertEqual(score["false_negatives"], 0)
def test_score_run_no_match(self):
"""Finding in unrelated file → 0% precision, 0% recall."""
bug = {
"id": "test-002", "title": "Auth Bypass",
"severity": "CRITICAL", "category": "security",
"expected_findings": [
{"file": "src/auth.py", "trigger": "missing auth check", "description": "No auth on admin endpoint", "severity": "CRITICAL"}
],
}
run_info = {
"run_ok": True,
"findings": [
{"id": "HG-001", "file": "README.md", "trigger_condition": "typo in docs",
"consequence": "confusion", "severity": "LOW"}
],
"summary": {"total": 1},
}
score = self.mod.score_run(bug, run_info)
self.assertEqual(score["precision_pct"], 0.0)
self.assertEqual(score["recall_pct"], 0.0)
self.assertEqual(score["f1_score"], 0.0)
self.assertEqual(score["true_positives"], 0)
self.assertEqual(score["false_positives"], 1)
self.assertEqual(score["false_negatives"], 1)
def test_score_run_partial_match(self):
"""2 expected, 1 found that matches one → 50% recall, 100% precision."""
bug = {
"id": "test-003", "title": "Two Bugs",
"severity": "HIGH", "category": "logic",
"expected_findings": [
{"file": "src/a.py", "trigger": "off by one", "description": "Loop boundary", "severity": "HIGH"},
{"file": "src/b.py", "trigger": "race condition", "description": "No lock", "severity": "HIGH"},
],
}
run_info = {
"run_ok": True,
"findings": [
{"id": "HG-001", "file": "src/a.py", "trigger_condition": "off by one in loop boundary",
"consequence": "index error", "severity": "HIGH"}
],
"summary": {"total": 1},
}
score = self.mod.score_run(bug, run_info)
self.assertEqual(score["precision_pct"], 100.0)
self.assertEqual(score["recall_pct"], 50.0)
self.assertEqual(score["f1_score"], 66.7)
self.assertEqual(score["true_positives"], 1)
self.assertEqual(score["false_positives"], 0)
self.assertEqual(score["false_negatives"], 1)
def test_score_run_severity_mismatch(self):
"""Match found but severity wrong → precision/recall OK, severity accuracy 0%."""
bug = {
"id": "test-004", "title": "Severity Test",
"severity": "CRITICAL", "category": "security",
"expected_findings": [
{"file": "src/x.py", "trigger": "hardcoded secret", "description": "API key in source", "severity": "CRITICAL"}
],
}
run_info = {
"run_ok": True,
"findings": [
{"id": "HG-001", "file": "src/x.py", "trigger_condition": "hardcoded secret key in source",
"consequence": "credential leak", "severity": "LOW"}
],
"summary": {"total": 1},
}
score = self.mod.score_run(bug, run_info)
self.assertEqual(score["precision_pct"], 100.0) # still a match
self.assertEqual(score["recall_pct"], 100.0)
self.assertEqual(score["severity_accuracy_pct"], 0.0) # severity wrong
def test_score_run_empty(self):
"""No expected findings, no found findings → all zeroes, no crash."""
bug = {"id": "test-005", "title": "Empty", "severity": "LOW", "category": "style",
"expected_findings": []}
run_info = {"run_ok": True, "findings": [], "summary": {"total": 0}}
score = self.mod.score_run(bug, run_info)
self.assertEqual(score["precision_pct"], 0.0)
self.assertEqual(score["recall_pct"], 0.0)
self.assertEqual(score["f1_score"], 0.0)
def test_score_run_failed(self):
"""Run that failed → score reflects error, metrics are zero."""
bug = {"id": "test-006", "title": "Failed Run", "severity": "HIGH", "category": "security",
"expected_findings": [{"file": "x.py", "trigger": "bug", "description": "bug", "severity": "HIGH"}]}
run_info = {"run_ok": False, "error": "timeout", "findings": [], "summary": {}}
score = self.mod.score_run(bug, run_info)
self.assertFalse(score["run_ok"])
self.assertEqual(score["run_error"], "timeout")
self.assertEqual(score["precision_pct"], 0.0)
# ── SKILL.md integrity tests ────────────────────────────────────────────────
class TestSKILLmd(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.skill_path = ROOT / "SKILL.md"
def test_skill_exists(self):
self.assertTrue(self.skill_path.is_file())
def test_version_is_2_0_0(self):
content = self.skill_path.read_text()
self.assertIn("version: 2.0.0", content)
def test_json_schema_present(self):
# The full JSON schema example lives in references/output-schema.md (the
# skill body points to it); SKILL.md keeps a field-level description.
schema_ref = (ROOT / "references/output-schema.md").read_text()
self.assertIn('"id": "HG-001"', schema_ref, "JSON schema example should be in references/output-schema.md")
self.assertIn("source_agent", self.skill_path.read_text(), "SKILL.md should describe the JSON fields")
def test_no_hardcoded_delegate_task(self):
"""SKILL.md must not hardcode Hermes-specific delegate_task syntax."""
content = self.skill_path.read_text()
self.assertNotIn("delegate_task(tasks=[", content,
"SKILL.md should use harness-agnostic language, not Hermes-specific delegate_task")
def test_harness_agnostic_dispatch_language(self):
"""SKILL.md should express dispatch intent generically."""
content = self.skill_path.read_text()
self.assertIn("dispatch", content.lower(),
"SKILL.md should describe dispatch generically (not framework-specific)")
def test_prescan_referenced(self):
content = self.skill_path.read_text()
self.assertIn("pre-scan", content.lower())
def test_feedback_referenced(self):
content = self.skill_path.read_text()
self.assertIn("feedback", content.lower())
def test_runner_template_referenced(self):
"""SKILL.md should mention that any harness can run it, not assume one."""
content = self.skill_path.read_text()
harnesses = ["hermes agent", "claude code", "codex", "any agent framework"]
found = sum(1 for h in harnesses if h in content.lower())
self.assertGreaterEqual(found, 2, "SKILL.md should reference multiple harness options")
# ── AGENTS.md Linter tests (call real score_file) ────────────────────────────
class TestAgentsmdLinter(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mod = _load_module("linter", "tools/agentsmd-linter/agentsmd-lint.py")
def test_score_empty_file(self):
"""Empty file scores low but doesn't crash."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
f.write("")
path = f.name
try:
result = self.mod.score_file(path)
self.assertLess(result["score"], 30)
self.assertIn("recommendations", result)
self.assertGreater(len(result["recommendations"]), 0)
finally:
os.unlink(path)
def test_score_well_structured_file(self):
"""A file with conventions, tests, stack details scores well."""
content = """# Project X
## Conventions
- Always use TypeScript strict mode
- File names: kebab-case
- Imports: absolute paths from src/
## Testing
Run tests with `pnpm test`. Uses vitest. Coverage must stay above 80%.
## Architecture
- src/components/ — React components
- src/lib/ — business logic
- src/pages/ — route handlers
## Stack
- Node 22, TypeScript 5.5, React 19
- pnpm 9, vitest 2
## Don't
- Never import from `../../` — use absolute paths
- Don't modify generated files in src/generated/
- Avoid `any` — use `unknown` and narrow
## Scope
- You may modify src/components/ and src/lib/
- Do NOT touch src/generated/ or config files
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
f.write(content)
path = f.name
try:
result = self.mod.score_file(path)
self.assertGreaterEqual(result["score"], 55, f"Expected >=55, got {result['score']}")
self.assertIn(result["grade"], ["A", "B"])
finally:
os.unlink(path)
def test_score_hermaguard_skill(self):
"""The actual SKILL.md scores A or better."""
result = self.mod.score_file(str(ROOT / "SKILL.md"))
self.assertGreaterEqual(result["score"], 70, f"SKILL.md should score >=70, got {result['score']}")
class TestTakeoverFixes(unittest.TestCase):
"""Tests for the residual-nit fixes applied during takeover. Each calls the
real function/CLI; each fails if the fix is reverted."""
@classmethod
def setUpClass(cls):
cls.prescan = _load_module("prescan_fx", "tools/hermaguard-prescan/hermaguard-prescan.py")
cls.feedback = _load_module("feedback_fx", "mcp/hermaguard-feedback/server.py")
cls.compiler_path = ROOT / "tools/hermaguard-compile-rules/hermaguard-compile-rules.py"
cls.benchmark = _load_module("benchmark_fx", "tools/hermaguard-benchmark/benchmark.py")
def test_find_tool_cross_platform(self):
"""find_tool resolves a real executable and returns None for a missing one
(shutil.which path, not POSIX-only `which`)."""
self.assertIsNotNone(self.prescan.find_tool("python3") or self.prescan.find_tool("python"))
self.assertIsNone(self.prescan.find_tool("definitely-not-a-real-tool-xyz123"))
def test_diff_alias_splits_like_files(self):
"""--diff and --files feed the same splitter; newline form (git diff) works."""
self.assertEqual(self.prescan.split_files("a.py\nb.py\n"), ["a.py", "b.py"])
self.assertEqual(self.prescan.split_files("a.py, b.py"), ["a.py", "b.py"])
def test_suppression_rules_returns_list(self):
"""get_suppression_rules returns a list, the type that made the old
`'error' in result` dispatch guard fragile."""
db_fd, db_path = tempfile.mkstemp(suffix=".db")
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.executescript(
"CREATE TABLE suppression_rules_cache (rule_id TEXT PRIMARY KEY, "
"pattern TEXT NOT NULL, reason TEXT, dismissals INTEGER DEFAULT 0);"
)
result = self.feedback.tool_get_suppression_rules(conn)
self.assertIsInstance(result, list)
conn.close()
finally:
os.close(db_fd)
os.unlink(db_path)
def test_compile_rules_escapes_quotes_in_pattern(self):
"""A trigger_condition containing a double-quote must produce a YAML
pattern value that round-trips, not a broken file."""
db_fd, db_path = tempfile.mkstemp(suffix=".db")
out_fd, out_path = tempfile.mkstemp(suffix=".yaml")
os.close(out_fd)
try:
conn = sqlite3.connect(db_path)
conn.executescript(
"CREATE TABLE findings (finding_id TEXT PRIMARY KEY, run_id TEXT, "
"severity TEXT, source_agent TEXT, file_path TEXT, trigger_condition TEXT, "
"status TEXT DEFAULT 'open');"
)
tricky = 'unsafe call to "eval" on user input'
for i in range(3):
conn.execute(
"INSERT INTO findings VALUES (?,?,?,?,?,?,?)",
(f"HG-00{i}", f"r{i}", "LOW", "edge-case-hunter", "src/x.py", tricky,
"dismissed-false-positive"),
)
conn.commit()
conn.close()
env = dict(os.environ, HERMAGUARD_FEEDBACK_DB=db_path)
subprocess.run(
[sys.executable, str(self.compiler_path), "--output", out_path,
"--min-dismissals", "1"],
env=env, capture_output=True, text=True, timeout=30, check=True,
)
pattern_line = next(
l for l in open(out_path).read().splitlines() if l.strip().startswith("pattern:")
)
value = pattern_line.split("pattern:", 1)[1].strip()
self.assertEqual(json.loads(value), tricky) # round-trips → escaping works
finally:
os.close(db_fd)
os.unlink(db_path)
os.unlink(out_path)
def test_report_path_not_doubled(self):
"""generate_report writes to exactly the path given (the results/results/
path-doubling bug)."""
out_dir = tempfile.mkdtemp()
target = os.path.join(out_dir, "report.html")
self.benchmark.generate_report([], target)
self.assertTrue(os.path.isfile(target))
os.unlink(target)
os.rmdir(out_dir)
if __name__ == "__main__":
unittest.main()