rhodawk-ai-devops-engine / hermes_orchestrator.py
Rhodawk Agent
fix(playbook §10): resolve all 12 diagnostic warnings W-001..W-012\n\n W-001 CRITICAL: Add scripts/generate_stubs.sh + Makefile to generate\n openclaude_grpc/openclaude_pb2*.py locally without a full Docker build.\n W-002 HIGH: Delete dead Rhodawk_AI_Pitch_Deck_2026.pptx (operator request).\n W-003 HIGH: Rename mcp_config.json -> mcp_config.ARCHIVE.json with archive notice.\n W-004 HIGH: formal_verifier.py — RHODAWK_Z3_ENABLED defaults to true; loud warning if skipped.\n W-005 MEDIUM: Surface RHODAWK_AUTO_MERGE state in System Status banner.\n W-006 MEDIUM: training_store.PgConn.executescript() splits multi-stmt SQL.\n W-007 MEDIUM: webhook_server returns 403 + JSON when secret unset.\n W-008 MEDIUM: hermes_orchestrator — new HERMES_PROVIDER routing flag.\n W-009 MEDIUM: New night_hunt_lock.py shared mutex across both night-hunt loops.\n W-010 MEDIUM: repo_harvester uses dynamic 30-day rolling pushed window.\n W-011 MEDIUM: lora_scheduler emits Telegram/Slack notification on export.\n W-012 MEDIUM: Live Operations tab shows System Status feature-gate banner.
6ed9a00
Raw
History Blame Contribute Delete
38.7 kB
"""
Rhodawk AI — Hermes Master Orchestrator
========================================
Hermes is the intelligent agent that coordinates all security research components.
It acts as the "brain" — deciding which tools to deploy, in what order, and how
to synthesize findings into a coherent vulnerability report.
Architecture:
Hermes receives a target (repo + optional focus area) and executes a dynamic
multi-phase research plan using tool calls. It maintains state across phases,
routes findings between components, and escalates confidence incrementally.
Phases:
1. RECON — clone, fingerprint, map attack surface
2. STATIC — taint analysis, symbolic execution planning, CWE pattern match
3. DYNAMIC — fuzzing harness generation + execution
4. EXPLOIT — exploit primitive reasoning on confirmed crashes
5. CONSENSUS — multi-model adversarial verdict on findings
6. DISCLOSURE — package report, hold for human approval
Custom Algorithms:
VES — Vulnerability Entropy Score: how surprising/dangerous a code path is
TVG — Temporal Vulnerability Graph: how bugs propagate across commits
ACTS — Adversarial Consensus Trust Score: Bayesian multi-model confidence
CAD — Commit Anomaly Detection: statistical detection of silent security patches
SSEC — Semantic Similarity Exploit Chain: embedding distance to known exploit patterns
"""
from __future__ import annotations
import hashlib
import json
import os
import threading
import time
from dataclasses import dataclass, field, asdict
from enum import Enum
from typing import Any, Callable, Optional
import requests
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
HERMES_MODEL = os.getenv("HERMES_MODEL", "deepseek/deepseek-r1:free")
HERMES_FAST_MODEL = os.getenv("HERMES_FAST_MODEL", "deepseek/deepseek-v3:free")
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
# W-008 FIX: explicit provider routing flag so the operator can force Hermes
# through the OpenClaude gRPC daemon (which itself fails over DO → OpenRouter
# inside the daemon process). Without this flag, Hermes was bypassing the
# OpenClaude daemon entirely, breaking cost attribution and rate-limit
# budgeting.
#
# Allowed values:
# "auto" — try DO Inference REST then OpenRouter REST (legacy)
# "openclaude_grpc" — route through openclaude_grpc.client (DO daemon :50051)
# "do" — DO Inference REST only
# "openrouter" — OpenRouter REST only
HERMES_PROVIDER = os.getenv("HERMES_PROVIDER", "auto").lower().strip()
# ── DigitalOcean Serverless Inference (PRIMARY provider) ────────────────
# OpenAI-compatible REST API. We POST to /chat/completions just like
# OpenRouter; only the base URL, auth header, and model name differ.
DO_INFERENCE_API_KEY = os.getenv("DO_INFERENCE_API_KEY", "") or os.getenv("DIGITALOCEAN_INFERENCE_KEY", "")
DO_INFERENCE_BASE = os.getenv("DO_INFERENCE_BASE_URL", "https://inference.do-ai.run/v1").rstrip("/")
# Default DO model used when the caller hands us an `openrouter/...` model
# string (which won't exist on DO). Override with HERMES_DO_MODEL.
DO_HERMES_MODEL = os.getenv("HERMES_DO_MODEL", "llama3.3-70b-instruct")
_log_lock = threading.Lock()
# ARCHITECT stability fix: bounded ring buffer instead of an unbounded list
# (10 000 lines × ~120 B ≈ 1.2 MB ceiling). Both tail-trim cost and memory
# leak class are eliminated.
import collections as _collections
_HERMES_LOG_CAP = int(os.getenv("HERMES_LOG_CAP", "10000"))
_hermes_logs: _collections.deque = _collections.deque(maxlen=_HERMES_LOG_CAP)
def hermes_log(msg: str, level: str = "HERMES"):
ts = time.strftime("%H:%M:%S")
icons = {
"HERMES": "🧠", "RECON": "🔭", "STATIC": "🔬", "DYNAMIC": "💥",
"EXPLOIT": "⚔️", "CONSENSUS": "🗳", "DISCLOSURE": "📋",
"TOOL": "🔧", "FIND": "🎯", "WARN": "⚠️", "OK": "✅", "FAIL": "❌",
"VES": "📊", "TVG": "🕸", "ACTS": "🧮", "CAD": "👁", "SSEC": "🔗",
}
line = f"[{ts}] {icons.get(level, '🧠')} [HERMES] {msg}"
print(line)
with _log_lock:
_hermes_logs.append(line)
def get_hermes_logs() -> list[str]:
with _log_lock:
return list(_hermes_logs)
# ── ARCHITECT stability fix: durable HermesSession persistence ────────────
_HERMES_SESSION_DIR = os.getenv("HERMES_SESSION_DIR", "/data/hermes")
def persist_hermes_session(session) -> str | None:
"""Atomically persist a HermesSession dataclass to disk after each phase.
Best-effort — never raises, returns the on-disk path or None."""
try:
import dataclasses
import json as _json
os.makedirs(_HERMES_SESSION_DIR, exist_ok=True)
sid = getattr(session, "session_id", "unknown")
path = os.path.join(_HERMES_SESSION_DIR, f"{sid}.json")
tmp = path + ".tmp"
if dataclasses.is_dataclass(session):
blob = dataclasses.asdict(session)
# phase enum → str
for k, v in list(blob.items()):
if hasattr(v, "value"):
blob[k] = v.value
else:
blob = {"session_id": sid, "raw": str(session)}
with open(tmp, "w", encoding="utf-8") as fh:
_json.dump(blob, fh, default=str, indent=2)
os.replace(tmp, path)
return path
except Exception: # noqa: BLE001
return None
# ──────────────────────────────────────────────────────────────
# DATA STRUCTURES
# ──────────────────────────────────────────────────────────────
class ResearchPhase(str, Enum):
RECON = "RECON"
STATIC = "STATIC"
DYNAMIC = "DYNAMIC"
EXPLOIT = "EXPLOIT"
CONSENSUS = "CONSENSUS"
DISCLOSURE = "DISCLOSURE"
COMPLETE = "COMPLETE"
@dataclass
class VulnerabilityFinding:
finding_id: str
title: str
cwe_id: str
severity: str # CRITICAL | HIGH | MEDIUM | LOW
confidence: float # 0.0 - 1.0
file_path: str
line_number: int
description: str
proof_of_concept: str
exploit_primitive: str # overflow | uaf | race | injection | crypto | logic
ves_score: float # Vulnerability Entropy Score
acts_score: float # Adversarial Consensus Trust Score
phase_found: str
raw_evidence: dict = field(default_factory=dict)
disclosure_status: str = "PENDING_HUMAN_APPROVAL"
timestamp: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ"))
@dataclass
class HermesSession:
session_id: str
target_repo: str
repo_dir: str
phase: ResearchPhase = ResearchPhase.RECON
findings: list[VulnerabilityFinding] = field(default_factory=list)
tool_call_log: list[dict] = field(default_factory=list)
attack_surface: dict = field(default_factory=dict)
tvg_graph: dict = field(default_factory=dict)
started_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ"))
completed_at: Optional[str] = None
# ──────────────────────────────────────────────────────────────
# TOOL REGISTRY — Hermes dispatches these
# ──────────────────────────────────────────────────────────────
class HermesTool:
"""Base class for all Hermes-dispatchable tools."""
name: str = "base_tool"
description: str = ""
def run(self, **kwargs) -> dict:
raise NotImplementedError
class ReconTool(HermesTool):
name = "recon"
description = "Map attack surface of a cloned repository. Returns language, entry points, dangerous sinks, security-critical files."
def run(self, repo_dir: str, **kwargs) -> dict:
hermes_log(f"Recon scan → {repo_dir}", "RECON")
from taint_analyzer import map_attack_surface
return map_attack_surface(repo_dir)
class TaintTool(HermesTool):
name = "taint_analysis"
description = "Run taint/data-flow analysis to trace untrusted input to dangerous sinks."
def run(self, repo_dir: str, focus_files: list[str] = None, **kwargs) -> dict:
hermes_log(f"Taint analysis → {len(focus_files or [])} focus files", "STATIC")
from taint_analyzer import run_taint_analysis
return run_taint_analysis(repo_dir, focus_files=focus_files)
class SymbolicTool(HermesTool):
name = "symbolic_execution"
description = "Run symbolic execution to explore all code paths and find unsatisfied constraints."
def run(self, repo_dir: str, target_function: str = None, **kwargs) -> dict:
hermes_log(f"Symbolic execution → {target_function or 'auto-select'}", "STATIC")
from symbolic_engine import run_symbolic_analysis
return run_symbolic_analysis(repo_dir, target_function=target_function)
class FuzzTool(HermesTool):
name = "fuzz"
description = "Generate a fuzzing harness and execute it against a target function or binary."
def run(self, repo_dir: str, target: str, language: str = "python", duration_s: int = 60, **kwargs) -> dict:
hermes_log(f"Fuzzing → {target} ({duration_s}s)", "DYNAMIC")
from fuzzing_engine import run_fuzzing_campaign
return run_fuzzing_campaign(repo_dir, target, language=language, duration_s=duration_s)
class ExploitTool(HermesTool):
name = "exploit_reasoning"
description = "Reason about exploitability of a crash or vulnerability candidate. Generate PoC."
def run(self, crash_input: str, crash_output: str, file_path: str, vuln_type: str, **kwargs) -> dict:
hermes_log(f"Exploit reasoning → {vuln_type} in {file_path}", "EXPLOIT")
from exploit_primitives import reason_exploitability
return reason_exploitability(crash_input, crash_output, file_path, vuln_type)
class CVETool(HermesTool):
name = "cve_intel"
description = "Query CVE/NVD/CWE knowledge base for similar historical vulnerabilities."
def run(self, description: str, cwe_hint: str = None, **kwargs) -> dict:
hermes_log(f"CVE intel → {cwe_hint or 'auto'}", "STATIC")
from cve_intel import query_cve_intel
return query_cve_intel(description, cwe_hint=cwe_hint)
class CommitWatchTool(HermesTool):
name = "commit_watch"
description = "Analyze recent commits for silent security patches using CAD algorithm."
def run(self, repo_dir: str, lookback_commits: int = 50, **kwargs) -> dict:
hermes_log(f"Commit watch → last {lookback_commits} commits", "CAD")
from commit_watcher import analyze_recent_commits
return analyze_recent_commits(repo_dir, lookback=lookback_commits)
class SSECTool(HermesTool):
name = "ssec_scan"
description = "Semantic Similarity Exploit Chain: find code patterns similar to known exploits."
def run(self, repo_dir: str, focus_files: list[str] = None, **kwargs) -> dict:
hermes_log("SSEC scan — embedding similarity to known exploit patterns", "SSEC")
from cve_intel import run_ssec_scan
return run_ssec_scan(repo_dir, focus_files=focus_files)
class ChainAnalyzerTool(HermesTool):
name = "chain_analysis"
description = (
"Synthesize stored primitive findings into higher-severity exploit chains. "
"Call after at least 2 primitives are recorded. Returns THEORETICAL proposals "
"tagged PENDING_HUMAN_REVIEW — no chain is executed automatically."
)
def run(self, repo_dir: str, repo: str = "", **kwargs) -> dict:
hermes_log(f"Chain analysis → {repo or repo_dir}", "EXPLOIT")
from chain_analyzer import analyze_chains, get_all_primitives
target = repo or repo_dir
primitives = get_all_primitives(repo=target)
if len(primitives) < 2:
return {"chains": [], "note": f"Only {len(primitives)} primitive(s) stored — need ≥2 to chain"}
chains = analyze_chains(repo=target)
return {"chains": chains, "primitive_count": len(primitives)}
_TOOL_REGISTRY: dict[str, HermesTool] = {
t.name: t() for t in [
ReconTool, TaintTool, SymbolicTool, FuzzTool,
ExploitTool, CVETool, CommitWatchTool, SSECTool,
ChainAnalyzerTool,
]
}
def _dispatch_tool(tool_name: str, args: dict, session: HermesSession) -> dict:
tool = _TOOL_REGISTRY.get(tool_name)
if not tool:
return {"error": f"Unknown tool: {tool_name}"}
start = time.time()
try:
result = tool.run(**args)
except Exception as e:
result = {"error": str(e)}
elapsed = round(time.time() - start, 2)
session.tool_call_log.append({
"tool": tool_name, "args": args,
"result_keys": list(result.keys()) if isinstance(result, dict) else "non-dict",
"elapsed_s": elapsed, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
})
return result
# ──────────────────────────────────────────────────────────────
# VES — VULNERABILITY ENTROPY SCORE (Custom Algorithm)
# ──────────────────────────────────────────────────────────────
def compute_ves(
reachability: float, # 0-1: how reachable is this from untrusted input
severity_class: str, # CRITICAL | HIGH | MEDIUM | LOW
novelty: float, # 0-1: how different from known CVEs (higher = more novel/interesting)
exploit_complexity: str, # LOW | MEDIUM | HIGH
auth_required: bool,
) -> float:
"""
VES (Vulnerability Entropy Score) — custom algorithm.
Measures the "information surprise" of a vulnerability weighted by its
danger. High VES = high-value finding (novel + dangerous + reachable).
Formula inspired by Shannon entropy: VES = -log2(P) × W
Where P is the probability an auditor would find this naturally,
and W is a danger weight. Higher VES = better bug bounty target.
"""
import math
severity_weight = {"CRITICAL": 1.0, "HIGH": 0.75, "MEDIUM": 0.5, "LOW": 0.25}.get(severity_class, 0.5)
complexity_factor = {"LOW": 0.9, "MEDIUM": 0.6, "HIGH": 0.3}.get(exploit_complexity, 0.6)
auth_penalty = 0.7 if auth_required else 1.0
# P(naturally found) = (1 - novelty) × (1 - reachability × 0.5)
p_found_naturally = max(0.001, (1.0 - novelty) * (1.0 - reachability * 0.5))
entropy = -math.log2(p_found_naturally)
ves = entropy * severity_weight * complexity_factor * auth_penalty * reachability
return round(min(ves, 10.0), 4)
# ──────────────────────────────────────────────────────────────
# ACTS — ADVERSARIAL CONSENSUS TRUST SCORE (Custom Algorithm)
# ──────────────────────────────────────────────────────────────
def compute_acts(model_verdicts: list[dict]) -> float:
"""
ACTS (Adversarial Consensus Trust Score) — Bayesian multi-model confidence.
Each model contributes a vote weighted by its stated confidence.
Agreement amplifies confidence. Disagreement deflates it.
Returns 0.0–1.0 where >0.7 = high trust finding.
"""
if not model_verdicts:
return 0.0
confirm_weight = 0.0
total_weight = 0.0
agreements = 0
first_verdict = model_verdicts[0].get("verdict", "UNCERTAIN")
for verdict in model_verdicts:
v = verdict.get("verdict", "UNCERTAIN")
c = float(verdict.get("confidence", 0.5))
total_weight += c
if v in ("CONFIRM", "APPROVE", "VULNERABLE"):
confirm_weight += c
if v == first_verdict:
agreements += 1
if total_weight == 0:
return 0.0
raw_score = confirm_weight / total_weight
agreement_factor = agreements / len(model_verdicts)
acts = raw_score * (0.6 + 0.4 * agreement_factor)
return round(acts, 4)
# ──────────────────────────────────────────────────────────────
# LLM CALLS — Hermes reasoning engine
# ──────────────────────────────────────────────────────────────
_HERMES_SYSTEM = """You are Hermes, a world-class autonomous security research agent.
Your goal is to find real, exploitable vulnerabilities in open source projects.
You are methodical, adversarial, and thorough. You think like an attacker.
You have access to these tools:
- recon: Map attack surface (entry points, dangerous sinks, security-critical files)
- taint_analysis: Trace untrusted input to dangerous sinks
- symbolic_execution: Explore all code paths mathematically
- fuzz: Generate and run fuzzing campaigns to find crashes
- exploit_reasoning: Reason about exploitability, generate PoC
- cve_intel: Query historical CVEs for similar patterns
- commit_watch: Find silent security patches in commit history
- ssec_scan: Semantic similarity to known exploit patterns
- chain_analysis: Synthesize stored primitive findings into exploit chains (call after ≥2 findings)
For each target, produce a research plan and execute it step by step.
When you find something, rate its severity honestly. Never hallucinate findings.
A false positive wastes a maintainer's time. Be certain before escalating.
Respond with JSON tool calls in this format:
{
"thought": "your reasoning about what to do next",
"tool": "tool_name",
"args": {"key": "value"},
"phase": "RECON|STATIC|DYNAMIC|EXPLOIT|CONSENSUS|DISCLOSURE"
}
Or to report a final finding:
{
"thought": "summary reasoning",
"finding": {
"title": "...", "cwe_id": "CWE-XXX", "severity": "HIGH",
"confidence": 0.85, "file_path": "...", "line_number": 0,
"description": "...", "proof_of_concept": "...", "exploit_primitive": "..."
}
}
Or to signal completion:
{"done": true, "summary": "..."}
"""
_RATE_LIMIT_BACKOFF_DELAYS = [15, 30, 60] # seconds — exponential backoff for 429s
def _strip_provider_prefix(model: str) -> str:
"""Strip OpenRouter / OpenAI provider prefixes so we can re-target a
model string at a different provider."""
for prefix in ("openrouter/", "openai/"):
if model.startswith(prefix):
return model[len(prefix):]
return model
def _post_chat_completion(
base_url: str, api_key: str, model: str, messages: list[dict],
timeout: int, extra_headers: dict | None = None,
) -> dict:
"""Single OpenAI-compatible POST. Raises on non-2xx; returns parsed JSON
from the assistant message."""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
if extra_headers:
headers.update(extra_headers)
payload = {
"model": model,
"messages": messages,
"temperature": 0.2,
"max_tokens": 2048,
"response_format": {"type": "json_object"},
}
resp = requests.post(
f"{base_url}/chat/completions",
headers=headers, json=payload, timeout=timeout,
)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
return json.loads(content)
def _hermes_llm_call(messages: list[dict], model: str = None, timeout: int = 120) -> dict:
"""
Call the Hermes LLM with DigitalOcean Serverless Inference as the
PRIMARY provider and OpenRouter as the FALLBACK. Each provider gets
exponential backoff on 429 rate-limit responses before failing over.
Order of operations:
1. Try DigitalOcean (DO_INFERENCE_API_KEY) — fastest, paid endpoint.
2. On any non-recoverable failure or exhausted rate-limit retries,
fall back to OpenRouter (OPENROUTER_API_KEY).
3. If neither is configured, return a graceful no-op.
W-008 FIX: respect HERMES_PROVIDER env var. When set to
"openclaude_grpc" all calls are routed through the OpenClaude gRPC
daemon (DigitalOcean primary on :50051, OpenRouter fallback on :50052)
instead of bypassing the daemon with direct REST calls.
"""
requested_model = model or HERMES_MODEL
# W-008 FIX: openclaude_grpc routing path.
if HERMES_PROVIDER == "openclaude_grpc":
try:
from openclaude_grpc.client import OpenClaudeClient
hermes_log("LLM call → openclaude_grpc daemon (:50051)", "HERMES")
prompt_text = "\n\n".join(
f"[{m.get('role', 'user').upper()}] {m.get('content', '')}"
for m in messages
)
client = OpenClaudeClient(host="127.0.0.1", port=50051)
combined, exit_code = client.chat(prompt_text, timeout=timeout)
try:
return json.loads(combined)
except (json.JSONDecodeError, TypeError):
return {"done": exit_code == 0, "summary": combined}
except Exception as exc:
hermes_log(f"openclaude_grpc routing failed: {exc} — falling back to REST",
"WARN")
# Fall through to REST providers below.
providers: list[tuple[str, str, str, str, dict]] = []
do_allowed = HERMES_PROVIDER in ("auto", "do", "openclaude_grpc")
or_allowed = HERMES_PROVIDER in ("auto", "openrouter", "openclaude_grpc")
if do_allowed and DO_INFERENCE_API_KEY:
do_model = (
_strip_provider_prefix(requested_model)
if requested_model.startswith(("openai/",))
else DO_HERMES_MODEL
)
providers.append(
("DigitalOcean", DO_INFERENCE_BASE, DO_INFERENCE_API_KEY, do_model, {})
)
if or_allowed and OPENROUTER_API_KEY:
or_model = _strip_provider_prefix(requested_model) if "/" in requested_model else requested_model
# OpenRouter expects models in `vendor/name` form — only re-add the
# prefix when the caller passed an `openai/...` (DO-shaped) string.
if requested_model.startswith("openai/"):
or_model = HERMES_MODEL # fall back to default OR-shaped model
providers.append(
("OpenRouter", OPENROUTER_BASE, OPENROUTER_API_KEY, or_model,
{"HTTP-Referer": "https://rhodawk.ai",
"X-Title": "Rhodawk Hermes Orchestrator"})
)
if not providers:
return {"done": True,
"summary": "Neither DO_INFERENCE_API_KEY nor OPENROUTER_API_KEY is set"}
last_error: Exception | None = None
for idx, (name, base_url, api_key, prov_model, extra_headers) in enumerate(providers):
hermes_log(f"LLM call → {name} ({prov_model})", "HERMES")
for attempt, backoff in enumerate([0] + _RATE_LIMIT_BACKOFF_DELAYS):
if backoff:
hermes_log(
f"{name} rate limit — waiting {backoff}s before retry "
f"{attempt}/{len(_RATE_LIMIT_BACKOFF_DELAYS)}", "WARN")
time.sleep(backoff)
try:
return _post_chat_completion(
base_url, api_key, prov_model, messages, timeout,
extra_headers=extra_headers,
)
except requests.HTTPError as e:
status = getattr(e.response, "status_code", None)
if status == 429:
last_error = e
hermes_log(f"{name} HTTP 429 (attempt {attempt + 1})", "WARN")
continue
last_error = e
hermes_log(f"{name} HTTP {status}{e}", "WARN")
break # non-rate-limit HTTP error → fail over to next provider
except Exception as e:
if "429" in str(e):
last_error = e
hermes_log(f"{name} rate limit exception: {e}", "WARN")
continue
last_error = e
hermes_log(f"{name} call failed: {e}", "WARN")
break
if idx < len(providers) - 1:
hermes_log(f"{name} exhausted — failing over to "
f"{providers[idx + 1][0]}", "WARN")
hermes_log(f"All providers exhausted. Last error: {last_error}", "FAIL")
return {"done": True,
"summary": f"LLM providers exhausted (DO + OpenRouter): {last_error}"}
# ──────────────────────────────────────────────────────────────
# MAIN HERMES RESEARCH LOOP
# ──────────────────────────────────────────────────────────────
def run_hermes_research(
target_repo: str,
repo_dir: str,
focus_area: str = "",
max_iterations: int = 20,
progress_callback: Optional[Callable[[str], None]] = None,
) -> HermesSession:
"""
Main Hermes research loop. Runs until max_iterations or completion.
Returns a HermesSession with all findings.
"""
session_id = hashlib.sha256(f"{target_repo}{time.time()}".encode()).hexdigest()[:12]
session = HermesSession(
session_id=session_id,
target_repo=target_repo,
repo_dir=repo_dir,
)
def log(msg, level="HERMES"):
hermes_log(msg, level)
if progress_callback:
progress_callback(f"[{level}] {msg}")
log(f"Session {session_id} started → {target_repo}")
log(f"Model: {HERMES_MODEL} | Max iterations: {max_iterations}")
# ── Masterplan §5: semantic skill injection ──────────────────────────
skill_pack = ""
try:
from architect import skill_selector # local import — never fatal
skill_pack = skill_selector.select_for_task(
task_description=focus_area or f"security audit of {target_repo}",
repo_languages=[], # populated by recon phase later
repo_tech_stack=[],
attack_phase="static",
top_k=5,
pin=["vibe-coded-app-hunter", "bb-methodology-claude"],
)
if skill_pack:
log(f"skill_selector loaded {skill_pack.count('<skill ')} skill(s)", "SKILL")
except Exception as exc: # noqa: BLE001
log(f"skill_selector unavailable: {exc}", "WARN")
system_content = (skill_pack + "\n\n" + _HERMES_SYSTEM) if skill_pack else _HERMES_SYSTEM
messages = [
{"role": "system", "content": system_content},
{"role": "user", "content": (
f"TARGET REPOSITORY: {target_repo}\n"
f"LOCAL PATH: {repo_dir}\n"
f"FOCUS AREA: {focus_area or 'Full autonomous scan — prioritize attack surface'}\n\n"
"Begin your security research. Start with reconnaissance to map the attack surface, "
"then systematically probe for vulnerabilities. Remember: only report findings you "
"are confident are real and exploitable."
)},
]
for iteration in range(max_iterations):
log(f"Iteration {iteration + 1}/{max_iterations}", "HERMES")
response = _hermes_llm_call(messages)
if response.get("done"):
log(f"Research complete: {response.get('summary', 'No summary')}", "OK")
session.phase = ResearchPhase.COMPLETE
break
if "finding" in response:
finding_data = response["finding"]
log(f"FINDING: {finding_data.get('title', '?')} [{finding_data.get('severity', '?')}]", "FIND")
# Compute VES
ves = compute_ves(
reachability=finding_data.get("confidence", 0.5),
severity_class=finding_data.get("severity", "MEDIUM"),
novelty=0.6,
exploit_complexity="MEDIUM",
auth_required=False,
)
log(f"VES Score: {ves}", "VES")
finding = VulnerabilityFinding(
finding_id=hashlib.sha256(
f"{finding_data.get('file_path', '')}{finding_data.get('line_number', 0)}{time.time()}".encode()
).hexdigest()[:12],
title=finding_data.get("title", "Unnamed Finding"),
cwe_id=finding_data.get("cwe_id", "CWE-UNKNOWN"),
severity=finding_data.get("severity", "MEDIUM"),
confidence=float(finding_data.get("confidence", 0.5)),
file_path=finding_data.get("file_path", ""),
line_number=int(finding_data.get("line_number", 0)),
description=finding_data.get("description", ""),
proof_of_concept=finding_data.get("proof_of_concept", ""),
exploit_primitive=finding_data.get("exploit_primitive", "unknown"),
ves_score=ves,
acts_score=0.0,
phase_found=session.phase.value,
disclosure_status="PENDING_HUMAN_APPROVAL",
)
session.findings.append(finding)
messages.append({"role": "assistant", "content": json.dumps(response)})
messages.append({"role": "user", "content": (
f"Finding recorded (ID: {finding.finding_id}, VES: {ves}). "
"Continue research — there may be more vulnerabilities. "
"If you believe the surface is exhausted, signal done."
)})
continue
if "tool" not in response:
log("No tool call in response — signaling done", "WARN")
break
tool_name = response.get("tool", "")
tool_args = response.get("args", {})
phase_str = response.get("phase", session.phase.value)
thought = response.get("thought", "")
try:
session.phase = ResearchPhase(phase_str)
except ValueError:
pass
log(f"Phase: {session.phase.value} | Tool: {tool_name}", session.phase.value)
if thought:
log(f"Reasoning: {thought[:200]}", "HERMES")
tool_args["repo_dir"] = repo_dir
tool_result = _dispatch_tool(tool_name, tool_args, session)
if tool_name == "recon" and isinstance(tool_result, dict):
session.attack_surface = tool_result
log(f"Attack surface: {len(tool_result.get('dangerous_sinks', []))} sinks, "
f"{len(tool_result.get('entry_points', []))} entry points", "RECON")
messages.append({"role": "assistant", "content": json.dumps(response)})
messages.append({
"role": "user",
"content": (
f"Tool '{tool_name}' result:\n```json\n"
f"{json.dumps(tool_result, indent=2)[:3000]}\n```\n\n"
"Based on these results, what is your next action?"
),
})
time.sleep(1)
# Run ACTS consensus on all findings
if session.findings:
log(f"Running ACTS consensus on {len(session.findings)} finding(s)...", "CONSENSUS")
session.phase = ResearchPhase.CONSENSUS
_run_acts_consensus(session)
persist_hermes_session(session)
session.phase = ResearchPhase.DISCLOSURE
session.completed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ")
log(f"Session complete — {len(session.findings)} finding(s) pending human approval", "DISCLOSURE")
# ARCHITECT stability fix: persist the final session blob so a process
# restart never loses operator-visible findings.
persist_hermes_session(session)
return session
def _run_acts_consensus(session: HermesSession):
"""
Run multi-model adversarial consensus on each finding to compute ACTS score.
FIX (ACTS Bug): Previously _call_concurrent_consensus returned a single merged
result, so compute_acts() always received a 1-item list with agreement_factor=1.0
— completely bypassing the disagreement penalty. Now we call each consensus model
individually so all 3 raw verdicts are passed to compute_acts(), enabling the full
Bayesian disagreement weighting to work as designed.
"""
import concurrent.futures
from adversarial_reviewer import _call_single_model, ADVERSARY_SYSTEM_PROMPT
CONSENSUS_MODELS = [
"deepseek/deepseek-r1:free",
"meta-llama/llama-3.3-70b-instruct:free",
"google/gemma-3-27b-it:free",
]
for finding in session.findings:
hermes_log(f"ACTS consensus: {finding.finding_id}", "ACTS")
prompt = (
f"VULNERABILITY FINDING FOR CONSENSUS REVIEW\n\n"
f"Title: {finding.title}\n"
f"CWE: {finding.cwe_id}\n"
f"Severity claimed: {finding.severity}\n"
f"File: {finding.file_path}:{finding.line_number}\n"
f"Description: {finding.description}\n"
f"PoC: {finding.proof_of_concept}\n"
f"Exploit primitive: {finding.exploit_primitive}\n\n"
"Is this a real, exploitable vulnerability? Respond as a hostile security reviewer."
)
try:
# Call all 3 models concurrently and collect individual raw verdicts.
# This is required so compute_acts() receives the full disagreement signal
# rather than a pre-merged single verdict (which collapses agreement_factor to 1.0).
individual_results: list[dict] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=len(CONSENSUS_MODELS)) as ex:
futures = {
ex.submit(_call_single_model, model, prompt): model
for model in CONSENSUS_MODELS
}
for future in concurrent.futures.as_completed(futures, timeout=90):
try:
result_dict, _ = future.result()
if result_dict is not None:
individual_results.append(result_dict)
except Exception:
pass
if not individual_results:
raise RuntimeError("All ACTS consensus models failed")
# Build one verdict entry per model response so compute_acts sees N items.
verdicts = [
{
"verdict": r.get("verdict", "UNCERTAIN"),
"confidence": float(r.get("confidence", 0.5)),
}
for r in individual_results
]
finding.acts_score = compute_acts(verdicts)
hermes_log(
f"ACTS score for {finding.finding_id}: {finding.acts_score} "
f"({len(verdicts)} model verdicts: {[v['verdict'] for v in verdicts]})",
"ACTS",
)
except Exception as e:
hermes_log(f"ACTS consensus failed for {finding.finding_id}: {e}", "WARN")
finding.acts_score = finding.confidence
# ──────────────────────────────────────────────────────────────
# TVG — TEMPORAL VULNERABILITY GRAPH (Custom Algorithm)
# ──────────────────────────────────────────────────────────────
def build_tvg(repo_dir: str, findings: list[VulnerabilityFinding]) -> dict:
"""
TVG (Temporal Vulnerability Graph) — tracks how vulnerability patterns
propagate across commits over time.
Builds a directed graph: commit → file → vulnerable_function → severity
Allows answering: "was this bug always here, or introduced recently?"
"""
import subprocess
graph = {"nodes": [], "edges": [], "hotspots": []}
for finding in findings:
if not finding.file_path:
continue
try:
result = subprocess.run(
["git", "log", "--follow", "--oneline", "-20", "--", finding.file_path],
cwd=repo_dir, capture_output=True, text=True, timeout=10,
)
commits = [line.split(" ", 1) for line in result.stdout.strip().splitlines() if line]
for sha, msg in commits[:5]:
node = {"id": sha[:8], "file": finding.file_path, "msg": msg[:80]}
if node not in graph["nodes"]:
graph["nodes"].append(node)
graph["edges"].append({
"from": sha[:8], "to": finding.finding_id,
"label": finding.cwe_id,
})
if len(commits) > 10:
graph["hotspots"].append({
"file": finding.file_path, "commit_count": len(commits),
"finding": finding.finding_id,
})
except Exception:
pass
return graph
def get_session_summary(session: HermesSession) -> dict:
"""Produce a human-readable summary of a research session."""
findings_by_sev: dict[str, list] = {}
for f in session.findings:
findings_by_sev.setdefault(f.severity, []).append(f)
return {
"session_id": session.session_id,
"target": session.target_repo,
"started_at": session.started_at,
"completed_at": session.completed_at,
"phase": session.phase.value,
"total_findings": len(session.findings),
"by_severity": {k: len(v) for k, v in findings_by_sev.items()},
"top_findings": [
{
"id": f.finding_id, "title": f.title, "cwe": f.cwe_id,
"severity": f.severity, "ves": f.ves_score, "acts": f.acts_score,
"file": f.file_path, "status": f.disclosure_status,
}
for f in sorted(session.findings, key=lambda x: x.ves_score, reverse=True)[:5]
],
"tool_calls": len(session.tool_call_log),
"attack_surface_size": len(session.attack_surface.get("dangerous_sinks", [])),
}