Files
hermes-agent/agent/verification_evidence.py
teknium1 5195c13873 fix: verify-on-stop recognises python.exe / py launcher interpreters (review follow-up)
_is_interpreter_token matched Path(token).name against the bare-name regex,
so a Windows venv path `...\Scripts\python.exe` (and the `py` launcher)
recorded no ad-hoc evidence and the nudge loop the PR closes stayed open on
that platform. Strip a case-insensitive .exe/.bat/.cmd suffix, take the
basename across backslashes, and accept `py`. Parametrized invariant test,
red before.
2026-09-20 10:23:04 -07:00

591 lines
23 KiB
Python

"""Coding verification evidence ledger: records what the agent actually proved in
a code workspace. Deliberately passive — it never runs a suite, never blocks
completion, and never upgrades targeted checks into "repo green"."""
from __future__ import annotations
import json
import re
import shlex
import sqlite3
import tempfile
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
from hermes_constants import get_hermes_home
_DB_LOCK = threading.Lock()
_MAX_OUTPUT_SUMMARY_CHARS = 2000
_MAX_EVIDENCE_AGE_DAYS = 30
_MAX_EVENTS_PER_SESSION_ROOT = 100
_MAX_TOTAL_UNREFERENCED_EVENTS = 10_000
_AD_HOC_SCRIPT_NAME_PREFIXES = ("hermes-verify-", "hermes-ad-hoc-")
_VERIFY_SCHEMA_VERSION = 1
_INTERPRETERS = {"python", "python3", "py", "node", "bash", "sh", "ruby", "perl"}
# Windows spells the same interpreters `python.exe` / `py.exe` (a venv's absolute Scripts path).
_WINDOWS_EXE_SUFFIX_RE = re.compile(r"\.(?:exe|bat|cmd)$", re.IGNORECASE)
# The same interpreter is reached as `python3`, `python3.12`, `/usr/bin/python3.12`, or
# `env python3`. Matching only the bare token recorded no evidence for the invocation shapes
# the verify-on-stop nudge itself hands the agent, so a passing run left the workspace
# "unverified" and the nudge returned every turn.
_INTERPRETER_NAME_RE = re.compile(
r"^(?:" + "|".join(sorted(_INTERPRETERS, key=len, reverse=True)) + r")(?:[0-9]+(?:\.[0-9]+)*)?$"
)
_TARGET_EXTENSIONS = (".py", ".js", ".jsx", ".ts", ".tsx", ".rs", ".go", ".java")
_TARGET_PREFIXES = ("test_", "tests", "spec", "__tests__")
# Ordered: first matching keyword group wins; "check" only counts when the
# command is not itself a test command; anything else is a test.
_KIND_KEYWORDS = (
(("lint", "eslint", "ruff"), "lint"),
(("typecheck", "tsc", "mypy", "pyright", "ty"), "typecheck"), (("build",), "build"),
(("fmt", "format"), "format"),
)
_PYTEST_SPELLINGS = (
["python", "-m", "pytest"], ["python3", "-m", "pytest"],
["uv", "run", "pytest"], ["poetry", "run", "pytest"], ["pipenv", "run", "pytest"],
)
_SCHEMA_DDL = (
"""
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS verification_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
session_id TEXT NOT NULL,
cwd TEXT NOT NULL,
root TEXT NOT NULL,
command TEXT NOT NULL,
canonical_command TEXT NOT NULL,
kind TEXT NOT NULL,
scope TEXT NOT NULL,
status TEXT NOT NULL,
exit_code INTEGER NOT NULL,
output_summary TEXT NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS verification_state (
session_id TEXT NOT NULL,
root TEXT NOT NULL,
last_event_id INTEGER,
last_edit_at TEXT,
changed_paths_json TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (session_id, root)
)
""",
"""
CREATE INDEX IF NOT EXISTS idx_verification_events_session_root
ON verification_events(session_id, root, id DESC)
""",
)
@dataclass(frozen=True)
class _ShellSegment:
tokens: list[str]
following_operator: str | None = None
@dataclass(frozen=True)
class VerificationEvidence:
"""A classified command result worth recording."""
command: str
canonical_command: str
kind: str
scope: str
status: str
exit_code: int
cwd: str
root: str
session_id: str
output_summary: str = ""
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _db_path() -> Path:
return get_hermes_home() / "verification_evidence.db"
def _ledger_enabled() -> bool:
"""The ledger exists only to feed verify-on-stop; when that guard is off nothing may
record, read, or even create the database (an unconsumed ledger is pure disk churn)."""
from agent.verification_stop import verify_on_stop_enabled
return verify_on_stop_enabled()
def _connect() -> sqlite3.Connection:
from hermes_cli.sqlite_util import open_db
return open_db(_db_path(), db_label="verification_evidence.db", initialize=_ensure_schema)
def _transaction():
from hermes_cli.sqlite_util import transaction
return transaction(_connect())
def _ensure_schema(conn: sqlite3.Connection) -> None:
for ddl in _SCHEMA_DDL:
conn.execute(ddl)
conn.execute(
"INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)",
(str(_VERIFY_SCHEMA_VERSION),),
)
conn.commit()
def _split_shell_segments(command: str, *, posix: bool = True) -> list[_ShellSegment]:
"""Tokenize top-level shell commands while preserving their control operators.
Returns ``[]`` for anything unparseable (unbalanced quotes, empty segment,
trailing operator other than ``;``) so callers never match a partial parse.
"""
raw_segments: list[tuple[str, str | None]] = []
start = 0
quote: str | None = None
escaped = False
index = 0
while index < len(command):
char = command[index]
if escaped or (char == "\\" and quote != "'"):
escaped = not escaped # consume the escaped char / start an escape
index += 1
continue
if quote or char in "'\"":
quote = None if char == quote else (quote or char) # close / stay / open
index += 1
continue
operator = None
if command.startswith(("&&", "||", "|&"), index):
operator = command[index:index + 2]
elif char == "\n":
operator = ";"
elif char in ";|" or (
char == "&"
and (index == 0 or command[index - 1] not in "<>")
and not command.startswith(("&>", "&>>"), index)
):
operator = char
if operator is None:
index += 1
continue
raw = command[start:index].strip()
if not raw:
return []
raw_segments.append((raw, operator))
index += 1 if char == "\n" else len(operator)
start = index
if quote or escaped:
return []
trailing = command[start:].strip()
if trailing:
raw_segments.append((trailing, None))
elif raw_segments and raw_segments[-1][1] != ";":
return []
try:
segments = [_ShellSegment(shlex.split(raw, posix=posix), operator) for raw, operator in raw_segments]
except ValueError:
return []
return segments if all(s.tokens for s in segments) else []
def _exit_status_is_attributable(segments: list[_ShellSegment], match_index: int, exit_code: int) -> bool:
"""Whether the shell's status proves the matched segment's own status.
Only the last ``;``-separated sequence reports its status; backgrounding,
pipes and ``||`` hide it; an ``&&`` chain proves each member only on success.
"""
if not segments or not 0 <= match_index < len(segments) or any(s.following_operator == "&" for s in segments):
return False
sequence_start = max(
(i + 1 for i, s in enumerate(segments[:-1]) if s.following_operator == ";"), default=0
)
if match_index < sequence_start:
return False
operators = {segment.following_operator for segment in segments[sequence_start:-1]}
if operators & {"|", "|&", "||"}:
return False
return not operators or (int(exit_code) == 0 and operators == {"&&"})
def _canonical_tokens(canonical: str) -> list[str]:
"""Tokenize a canonical command, stripping leading ``./`` from each token."""
try:
return [re.sub(r"^(?:\./)+", "", t.strip()) for t in shlex.split(canonical) if t]
except ValueError:
return []
def _strip_command_prefix(tokens: list[str]) -> list[str]:
"""Remove harmless command prefixes (env, VAR=x, command/time/noglob)."""
i = 1 if tokens and tokens[0] == "env" else 0
while i < len(tokens) and "=" in tokens[i] and not tokens[i].startswith("-"):
i += 1
while i < len(tokens) and tokens[i] in {"command", "time", "noglob"}:
i += 1
return list(tokens[i:])
def _equivalent_needles(needle: list[str]) -> list[list[str]]:
"""Return command spellings equivalent to the detected canonical command."""
candidates = [needle]
if len(needle) >= 3 and needle[1] == "run" and needle[0] in {"npm", "pnpm", "yarn", "bun"}:
candidates.append([needle[0], needle[2]])
if len(needle) == 1 and "/" in needle[0]:
candidates += [["bash", needle[0]], ["sh", needle[0]]]
if needle == ["pytest"]:
candidates += _PYTEST_SPELLINGS
return candidates
def _find_canonical_match(command: str, canonical_commands: list[str], exit_code: int) -> Optional[tuple[str, list[str]]]:
"""Return ``(canonical, trailing_args)`` for the first detected command."""
segments = _split_shell_segments(command)
for canonical in canonical_commands:
needle = _canonical_tokens(canonical)
if not needle:
continue
for index, segment in enumerate(segments):
candidate_tokens = _strip_command_prefix(segment.tokens)
for candidate in _equivalent_needles(needle):
if candidate_tokens[:len(candidate)] == candidate and _exit_status_is_attributable(segments, index, exit_code):
return canonical, candidate_tokens[len(candidate):]
return None
def _kind_for_command(canonical: str) -> str:
lowered = canonical.lower()
kind = next((k for words, k in _KIND_KEYWORDS if any(w in lowered for w in words)), None)
return kind or ("check" if "check" in lowered and "test" not in lowered else "test")
def _looks_like_target(arg: str) -> bool:
return bool(arg) and not arg.startswith("-") and "=" not in arg and (
any(m in arg for m in ("/", "\\", "::")) or arg.endswith(_TARGET_EXTENSIONS) or arg.startswith(_TARGET_PREFIXES)
)
def _is_under(token: str, base: str | Path | None) -> bool:
"""Whether absolute path ``token`` is ``base`` or lies beneath it (resolved)."""
if not base:
return False
try:
path = Path(token).expanduser()
if not path.is_absolute():
return False
resolved, base_path = path.resolve(), Path(base).expanduser().resolve()
return resolved == base_path or base_path in resolved.parents
except Exception:
return False
def _is_temp_script_path(token: str, root: str | Path | None) -> bool:
"""An ad-hoc verify script: prefixed name, under the temp dir, outside the repo."""
try:
name = Path(token).expanduser().name
except Exception:
return False
return name.startswith(_AD_HOC_SCRIPT_NAME_PREFIXES) and _is_under(token, tempfile.gettempdir()) and not _is_under(token, root)
def _is_interpreter_token(token: str) -> bool:
"""Whether a command word names a shell interpreter.
``python3``, ``python3.12`` and ``/usr/bin/python3.12`` are the same interpreter; matching
only the bare token left the ad-hoc branch blind to the invocation shapes the nudge hands
the agent, so a passing run recorded no evidence and the workspace stayed ``unverified``.
"""
# Basename by hand: on POSIX ``Path`` treats a Windows backslash path as one component.
name = token.replace("\\", "/").rsplit("/", 1)[-1]
return bool(_INTERPRETER_NAME_RE.match(_WINDOWS_EXE_SUFFIX_RE.sub("", name)))
def _ad_hoc_script_args(tokens: list[str], root: str | Path | None) -> Optional[list[str]]:
candidate_tokens = _strip_command_prefix(tokens)
if not candidate_tokens:
return None
command = candidate_tokens[0]
if _is_temp_script_path(command, root):
return candidate_tokens[1:]
if Path(command).name == "env" and len(candidate_tokens) > 1 and _is_interpreter_token(candidate_tokens[1]):
# `/usr/bin/env python3 script` names the same interpreter as `python3 script`.
candidate_tokens, command = candidate_tokens[1:], candidate_tokens[1]
if _is_interpreter_token(command):
# Skip interpreter flags; the first positional must be the script.
for idx, token in enumerate(candidate_tokens[1:], start=1):
if _is_temp_script_path(token, root):
return candidate_tokens[idx + 1:]
if token != "--" and not token.startswith("-"):
return None
return None
def _find_ad_hoc_match(command: str, root: str | Path | None, exit_code: int = 0) -> Optional[list[str]]:
# posix=False is retried so Windows backslash script paths survive splitting.
for posix in (True, False):
segments = _split_shell_segments(command, posix=posix)
for index, segment in enumerate(segments):
trailing_args = _ad_hoc_script_args(segment.tokens, root)
if trailing_args is not None and _exit_status_is_attributable(segments, index, exit_code):
return trailing_args
return None
def _summarize_output(output: str) -> str:
text = (output or "").strip()
if len(text) <= _MAX_OUTPUT_SUMMARY_CHARS:
return text
head = _MAX_OUTPUT_SUMMARY_CHARS // 3
return (
f"{text[:head]}\n... [{len(text) - _MAX_OUTPUT_SUMMARY_CHARS} chars omitted] ...\n"
f"{text[head - _MAX_OUTPUT_SUMMARY_CHARS:]}"
)
def _prune_old_events(conn: sqlite3.Connection, *, session_id: str, root: str) -> None:
"""Bound ledger growth without deleting the current state pointer.
Order matters: per-(session, root) cap, expire stale state rows, then expire
old events and cap the total — never dropping an event still referenced
by a ``verification_state.last_event_id``.
"""
cutoff = (datetime.now(timezone.utc) - timedelta(days=_MAX_EVIDENCE_AGE_DAYS)).isoformat()
conn.execute(
"DELETE FROM verification_events WHERE session_id = ? AND root = ? AND id NOT IN ("
" SELECT id FROM verification_events WHERE session_id = ? AND root = ?"
" ORDER BY id DESC LIMIT ?)",
(session_id, root, session_id, root, _MAX_EVENTS_PER_SESSION_ROOT),
)
conn.execute(
"DELETE FROM verification_state"
" WHERE (last_edit_at IS NOT NULL AND last_edit_at < ?)"
" OR (last_edit_at IS NULL AND last_event_id IN ("
" SELECT id FROM verification_events WHERE created_at < ?))",
(cutoff, cutoff),
)
conn.execute(
"DELETE FROM verification_events WHERE created_at < ? AND id NOT IN ("
" SELECT last_event_id FROM verification_state WHERE last_event_id IS NOT NULL)",
(cutoff,),
)
conn.execute(
"DELETE FROM verification_events WHERE id NOT IN ("
" SELECT id FROM verification_events ORDER BY id DESC LIMIT ?)"
" AND id NOT IN ("
" SELECT last_event_id FROM verification_state WHERE last_event_id IS NOT NULL)",
(_MAX_TOTAL_UNREFERENCED_EVENTS,),
)
def _project_facts(cwd: str | Path | None) -> Optional[dict[str, Any]]:
"""Workspace facts for ``cwd``; ``None`` when detection fails or finds nothing."""
try:
from agent.coding_context import project_facts_for
return project_facts_for(cwd)
except Exception:
return None
def _root_for(facts: dict[str, Any] | None, cwd: str | Path | None) -> str:
return str((facts or {}).get("root") or Path(cwd or ".").resolve())
def _load_changed_paths(raw: Any) -> list[Any]:
try:
return json.loads(raw or "[]")
except (TypeError, ValueError):
return []
def classify_verification_command(
command: str, *, cwd: str | Path | None = None, session_id: str | None = None, exit_code: int = 0, output: str = ""
) -> Optional[VerificationEvidence]:
"""Classify a terminal command as verification evidence, if applicable.
Ad-hoc temp scripts only count when the project has no canonical verify
commands at all, so they never shadow a real suite.
"""
if not command or not isinstance(command, str):
return None
facts = _project_facts(cwd)
if not facts:
return None
verify_commands = list(facts.get("verifyCommands") or [])
match = _find_canonical_match(command, verify_commands, int(exit_code))
if match is not None:
canonical, trailing_args = match
kind = _kind_for_command(canonical)
scope = "targeted" if any(map(_looks_like_target, trailing_args)) else "full"
else:
if verify_commands or _find_ad_hoc_match(command, facts.get("root"), int(exit_code)) is None:
return None
canonical, kind, scope = "ad-hoc verification script", "ad_hoc", "targeted"
return VerificationEvidence(
command=command, canonical_command=canonical, kind=kind, scope=scope,
status="passed" if int(exit_code) == 0 else "failed", exit_code=int(exit_code),
cwd=str(Path(cwd or ".").resolve()), root=_root_for(facts, cwd),
session_id=str(session_id or "default"), output_summary=_summarize_output(output),
)
def record_terminal_result(
*, command: str, cwd: str | Path | None, session_id: str | None, exit_code: int, output: str = ""
) -> Optional[dict[str, Any]]:
"""Record a foreground terminal result when it is verification evidence."""
if not _ledger_enabled():
return None
evidence = classify_verification_command(command, cwd=cwd, session_id=session_id, exit_code=exit_code, output=output)
return None if evidence is None else _insert_evidence(evidence)
def record_verify_run(
*, root: str | Path, session_id: str | None = None, ok: bool, command: str = "hermes verify",
scope: str = "full", output: str = "",
) -> Optional[dict[str, Any]]:
"""Record a completed ``hermes verify`` run as verification evidence.
A pass marks the workspace ``passed`` for the verify-on-stop guard like a
canonical test command would. ``root`` is re-resolved through project facts
so it matches what :func:`verification_status` derives later.
"""
if not _ledger_enabled():
return None
resolved = str(Path(root).resolve())
return _insert_evidence(VerificationEvidence(
command=command, canonical_command="hermes verify", kind="verify",
scope=scope if scope in {"full", "targeted"} else "full",
status="passed" if ok else "failed", exit_code=0 if ok else 1, cwd=resolved,
root=str((_project_facts(root) or {}).get("root") or resolved),
session_id=str(session_id or "default"), output_summary=_summarize_output(output),
))
def _insert_evidence(evidence: VerificationEvidence) -> dict[str, Any]:
"""Insert a classified evidence row and repoint the workspace state."""
created_at = _utc_now()
e = evidence
with _DB_LOCK, _transaction() as conn:
cur = conn.execute(
"INSERT INTO verification_events("
" created_at, session_id, cwd, root, command, canonical_command,"
" kind, scope, status, exit_code, output_summary"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(created_at, e.session_id, e.cwd, e.root, e.command, e.canonical_command, e.kind, e.scope,
e.status, e.exit_code, e.output_summary),
)
if cur.lastrowid is None:
raise RuntimeError("verification event insert did not return an id")
event_id = int(cur.lastrowid)
conn.execute(
"INSERT INTO verification_state("
" session_id, root, last_event_id, last_edit_at, changed_paths_json"
") VALUES (?, ?, ?, NULL, '[]')"
" ON CONFLICT(session_id, root) DO UPDATE SET"
" last_event_id = excluded.last_event_id,"
" last_edit_at = NULL,"
" changed_paths_json = '[]'",
(e.session_id, e.root, event_id),
)
_prune_old_events(conn, session_id=e.session_id, root=e.root)
conn.commit()
return {"id": event_id, **e.__dict__, "created_at": created_at}
def mark_workspace_edited(
*, session_id: str | None, cwd: str | Path | None, paths: list[str] | tuple[str, ...] | None = None
) -> Optional[dict[str, Any]]:
"""Mark verification evidence stale after a successful file edit."""
if not _ledger_enabled():
return None
facts = _project_facts(cwd)
if not facts:
return None
sid = str(session_id or "default")
root = _root_for(facts, cwd)
changed_paths = sorted({str(p) for p in (paths or []) if p})
edited_at = _utc_now()
with _DB_LOCK, _transaction() as conn:
row = conn.execute(
"SELECT changed_paths_json FROM verification_state WHERE session_id = ? AND root = ?",
(sid, root),
).fetchone()
# Merge with what was already recorded, bounded to the last 200 paths.
existing = _load_changed_paths(row["changed_paths_json"]) if row is not None else []
merged = sorted(set(existing) | set(changed_paths))[-200:]
conn.execute(
"INSERT INTO verification_state("
" session_id, root, last_event_id, last_edit_at, changed_paths_json"
") VALUES (?, ?, NULL, ?, ?)"
" ON CONFLICT(session_id, root) DO UPDATE SET"
" last_edit_at = excluded.last_edit_at,"
" changed_paths_json = excluded.changed_paths_json",
(sid, root, edited_at, json.dumps(merged)),
)
conn.commit()
return {"session_id": sid, "root": root, "last_edit_at": edited_at, "changed_paths": changed_paths}
def verification_status(*, session_id: str | None, cwd: str | Path | None) -> dict[str, Any]:
"""Return the best known verification state for a session/workspace.
Evidence recorded before the latest edit is reported as ``stale``.
"""
if not _ledger_enabled():
return {"status": "disabled", "evidence": None}
facts = _project_facts(cwd)
if not facts:
return {"status": "not_applicable", "evidence": None}
sid = str(session_id or "default")
root = _root_for(facts, cwd)
with _DB_LOCK, _transaction() as conn:
state = conn.execute(
"SELECT last_event_id, last_edit_at, changed_paths_json"
" FROM verification_state WHERE session_id = ? AND root = ?",
(sid, root),
).fetchone()
if state is None:
return {"status": "unverified", "evidence": None, "root": root, "session_id": sid, "changed_paths": []}
event = None
if state["last_event_id"] is not None:
event = conn.execute("SELECT * FROM verification_events WHERE id = ?", (state["last_event_id"],)).fetchone()
result = {
"evidence": None, "root": root, "session_id": sid, "changed_paths": _load_changed_paths(state["changed_paths_json"])
}
if event is None:
return {"status": "unverified", **result}
evidence = dict(event)
stale = bool(state["last_edit_at"]) and state["last_edit_at"] > evidence["created_at"]
return {"status": "stale" if stale else evidence["status"], **result, "evidence": evidence}