hermes-agent/tools/skills_ast_audit.py
teknium1 4254f7dd17 refactor(skills): slim AST diagnostic to single entry point
Trim ~600 LOC off the original contribution while keeping the same
operator-facing surface and detection coverage.

- Collapse three entry points (file / dir / bundle) into one
  ast_scan_path(path) that handles both files and directories.
- Drop AstFinding dataclass + severity field — replaced with plain
  (file, line, pattern_id, description) tuples. Severity ordering was
  display-only for a diagnostic that explicitly disclaims security
  verdicts, so the field added bookkeeping without earning its place.
- Replace Rich-markup formatter with plain text grouped by file.
- Drop the 'inspect --ast-deep' surface — same scanner, same output as
  'audit --deep', single CLI entry is enough. Operators audit after
  install; pre-install inspection signal isn't worth the second surface.
- Trim test file to the cases that earn their place: bypass payload,
  syntax error survival, RecursionError survival, false-positive guard
  (importer lookalike), literal-arg false-positive guard, non-.py
  ignored, directory recursion + cache-dir skipping, missing-path,
  getattr/__dict__ detection, formatter empty + populated.

Net: tools/skills_ast_audit.py 353 -> 133 LOC,
tests/tools/test_skills_ast_audit.py 299 -> 103 LOC, full diff
+704/-12 -> +264/-6. No change to tools/skills_guard.py — Skills Guard
verdicts remain untouched per SECURITY.md §2.4.
2026-05-23 17:47:26 -07:00

133 lines
5.1 KiB
Python

"""
AST-level deep audit for skill Python files — opt-in diagnostic, not a security gate.
Per SECURITY.md §2.4, Skills Guard is in-process heuristics ("useful — not
boundaries"). This module is a separate opt-in diagnostic that flags dynamic
import / dynamic attribute access patterns operators may want to eyeball when
reviewing third-party skill code. Every pattern flagged here has legitimate
uses; findings are hints for human review, not verdicts.
CLI: ``hermes skills audit --deep``
"""
from __future__ import annotations
import ast
from pathlib import Path
from typing import List, Tuple
# (file, line, pattern_id, description)
Finding = Tuple[str, int, str, str]
_IGNORED_DIRS = {"__pycache__", ".venv", "venv", "node_modules"}
def _scan_source(content: str, rel_path: str) -> List[Finding]:
try:
tree = ast.parse(content)
except (SyntaxError, ValueError, RecursionError):
return []
findings: List[Finding] = []
class V(ast.NodeVisitor):
def visit_Call(self, node):
f = node.func
# importlib.import_module(...)
if isinstance(f, ast.Attribute) and f.attr == "import_module":
findings.append((rel_path, node.lineno, "dynamic_import",
"importlib.import_module() — loads arbitrary modules at runtime"))
# __import__(<computed>)
elif isinstance(f, ast.Name) and f.id == "__import__":
if node.args and not isinstance(node.args[0], ast.Constant):
findings.append((rel_path, node.lineno, "dynamic_import_computed",
"__import__ with non-literal module name"))
# getattr(obj, <computed>)
elif isinstance(f, ast.Name) and f.id == "getattr":
if len(node.args) >= 2 and not isinstance(node.args[1], ast.Constant):
findings.append((rel_path, node.lineno, "dynamic_getattr",
"getattr with non-literal attribute name"))
self.generic_visit(node)
def visit_Subscript(self, node):
# obj.__dict__[<computed>]
if (isinstance(node.value, ast.Attribute)
and node.value.attr == "__dict__"
and not isinstance(node.slice, ast.Constant)):
findings.append((rel_path, node.lineno, "dict_access",
"__dict__[<computed>] — dynamic attribute access"))
self.generic_visit(node)
def visit_Import(self, node):
for a in node.names:
if a.name == "importlib" or a.name.startswith("importlib."):
findings.append((rel_path, node.lineno, "importlib_import",
f"import {a.name} — enables dynamic module loading"))
self.generic_visit(node)
def visit_ImportFrom(self, node):
m = node.module or ""
if m == "importlib" or m.startswith("importlib."):
findings.append((rel_path, node.lineno, "importlib_import",
f"from {m} import ... — enables dynamic module loading"))
self.generic_visit(node)
try:
V().visit(tree)
except (RecursionError, ValueError, RuntimeError):
# Hostile/pathological input: return what we collected so far.
pass
return findings
def ast_scan_path(path: Path) -> List[Finding]:
"""Scan a single .py file or recursively scan all .py under a directory.
Returns a list of (file, line, pattern_id, description) tuples. Empty for
non-Python paths, missing paths, or paths with no matching patterns.
"""
if path.is_file():
if path.suffix.lower() != ".py":
return []
try:
content = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return []
return _scan_source(content, path.name)
if not path.is_dir():
return []
out: List[Finding] = []
for py in sorted(path.rglob("*.py")):
if set(py.parent.parts) & _IGNORED_DIRS:
continue
try:
content = py.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
try:
rel = py.relative_to(path).as_posix()
except ValueError:
rel = py.name
out.extend(_scan_source(content, rel))
return out
def format_ast_report(findings: List[Finding], skill_name: str = "") -> str:
"""Plain-text report (Rich-markup-free) grouped by file."""
header = f"AST deep scan: {skill_name}" if skill_name else "AST deep scan"
if not findings:
return f"{header}\n No dynamic import/access patterns detected."
lines = [header, f" {len(findings)} finding(s):"]
current = None
for f, line, pid, desc in sorted(findings):
if f != current:
current = f
lines.append(f" {f}")
lines.append(f" L{line} {pid}{desc}")
lines.append("")
lines.append(" Note: diagnostic hints for human review, not security verdicts.")
return "\n".join(lines)