Files
violin/tests/guard/integration/test_plugin_guard.py
T

720 lines
27 KiB
Python

import importlib.util
import json
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[3]
# Make `violin_guard` resolvable
_PLUGIN_ROOT = ROOT / "plugins" / "violin_guard"
_PLUGIN_PARENT = _PLUGIN_ROOT.parent
if str(_PLUGIN_PARENT) not in sys.path:
sys.path.insert(0, str(_PLUGIN_PARENT))
_PLATFORM_SCOPE = """targets:
ip_addresses: ["10.10.10.10"]
in_scope_urls: []
exclusions: {}
authorized_parties: ["test owner"]
authorisation:
confirmed: true
rules_of_engagement:
allowed_actions: [recon, vuln-research, exploitation]
forbidden_actions: []
engagement:
name: e2e-test
date: "2026-07-08"
type: authorised-pentest
client: test
"""
def _load_sub(name, path):
spec = importlib.util.spec_from_file_location("vgpkg." + name, path)
mod = importlib.util.module_from_spec(spec)
mod.__package__ = "vgpkg"
sys.modules["vgpkg." + name] = mod
spec.loader.exec_module(mod)
return mod
# Load the plugin as a real package so `from . import utils` resolves.
_PLUGIN = ROOT / "plugins/violin_guard"
_PKG = importlib.util.spec_from_file_location("vgpkg", _PLUGIN / "__init__.py")
pkg = importlib.util.module_from_spec(_PKG)
pkg.__path__ = [str(_PLUGIN)]
pkg.__package__ = "vgpkg"
sys.modules["vgpkg"] = pkg
TOOLS = _load_sub("tools", _PLUGIN / "service.py")
# Import core modules from the new location
from plugins.violin_guard import bootstrap, command, execution, history, hypotheses, ptt, state
from plugins.violin_guard.targets import extract_target_candidates
def _cp(code, out="", err=""):
"""A fake CompletedProcess-like object returned by monkeypatched subprocess.run."""
return lambda *a, **k: _FakeProc(code, out, err)
class _FakeProc:
"""Stand-in for subprocess.CompletedProcess used by monkeypatched run_guard."""
def __init__(self, returncode, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
def _patch(monkeypatch, proc):
monkeypatch.setattr(subprocess, "run", proc)
@pytest.fixture(autouse=True)
def _fake_target_executor(monkeypatch):
"""Keep guard-state tests independent from installed network tools."""
def fake_execute(command, *, eng_dir, phase, **kwargs):
engagement = Path(eng_dir)
history.append_history(engagement, command, phase, 0, "evidence/executions/test.json")
remaining = state.spend_sync_credit(str(engagement), phase)
# Mirror real execution: tick command counter, mark pending sync, set heartbeat if interval reached
from plugins.violin_guard.phases import normalize_phase, suppresses_heartbeat
count = state.tick_command(str(engagement))
active = ptt.find_active_task(ptt.parse_ptt(engagement / "state" / "ptt.md"))
state.mark_pending_sync(str(engagement), command, phase, active.id if active else "")
phase_enum = normalize_phase(phase)
if count % state.COMMAND_INTERVAL == 0 and not suppresses_heartbeat(phase_enum):
state.set_heartbeat_pending(
str(engagement),
f"Reached {count} executed target commands. Review engagement files for drift.",
)
return {
"execution_id": "00000000-0000-0000-0000-000000000001",
"status": "completed",
"backend": kwargs.get("backend", "local"),
"command": command,
"phase": phase,
"executed": True,
"started_at": "2026-07-11T00:00:00Z",
"completed_at": "2026-07-11T00:00:01Z",
"exit_code": 0,
"timed_out": False,
"cancelled": False,
"stdout_preview": "",
"stderr_preview": "",
"evidence_paths": {},
"sync_required": remaining <= 0,
"sync_credit_remaining": remaining,
}
# Patch the exact module used by the separately loaded vgpkg.tools facade.
monkeypatch.setattr(TOOLS.execution, "execute", fake_execute)
monkeypatch.setattr(execution, "execute", fake_execute)
assert TOOLS.execution.execute is fake_execute
def _init_e2e(tmp_path, skill_file):
"""Build a guard-clean RECON engagement (scope allows vuln-research so the
hypothesis guard is exercised) and write the skill-load marker at its
canonical location.
The skill-load gate requires a session-scoped marker at
``$ENG_DIR/state/.skill-loaded-<session-id>``; passing ``--session-id``
makes the CLI compute that canonical path itself, so we write there. We
also pre-mark PT-010 as in-progress so the PTT phase gate (which BLOCKs
until at least one PT row has moved past ``[ ]``) does not reject the very
first recon command — this mirrors a normal SCOPING->RECON handoff.
"""
eng = tmp_path / "10.10.10.10-2026-07-08"
assert bootstrap.init_engagement(str(eng), host="10.10.10.10") == 0
(eng / "scope" / "scope.yaml").write_text(_PLATFORM_SCOPE, encoding="utf-8")
# Canonical marker path (session-id takes precedence over --skill-loaded-file).
# The CLI builds ``$ENG_DIR/state/.skill-loaded-<session-id>`` from --session-id,
# so the filename suffix is the bare session label, not the full marker name.
canonical = eng / "state" / f".skill-loaded-{skill_file.name.removeprefix('.skill-loaded-')}"
canonical.write_text(
f"skill-loaded: skills/pentest/SKILL.md\nsession: {skill_file.name}\n",
encoding="utf-8",
)
# At least one PTT row must have advanced so the staleness guard passes.
ptt_path = eng / "state" / "ptt.md"
ptt_path.write_text(
ptt_path.read_text(encoding="utf-8").replace("| PT-010 | [ ] |", "| PT-010 | [~] |"),
encoding="utf-8",
)
return eng
def test_meta_loaded():
# Current plugin surface: handle_* command entrypoints registered.
for name in (
"handle_exec",
"handle_check_command",
"handle_review_batch",
"handle_record_ptt",
"handle_record_hypothesis",
):
assert hasattr(TOOLS, name), f"plugin must expose {name}"
for removed in ("handle_sync_done", "handle_review_and_release", "handle_finding"):
assert not hasattr(TOOLS, removed), f"plugin must not expose removed handler {removed}"
def test_recon_does_not_require_hypothesis(tmp_path):
"""Recon should not require a hypothesis yet; it is the discovery phase
that creates the evidence hypotheses later consume."""
skill_file = tmp_path / ".skill-loaded-ts"
eng = _init_e2e(tmp_path, skill_file)
result = command.check_command(
command.CheckCommandArgs(
command="nmap -sV 10.10.10.10",
phase="recon",
eng_dir=str(eng),
scope=str(eng / "scope" / "scope.yaml"),
skill_loaded_file=str(skill_file),
session_id="ts",
)
)
assert not result.errors
assert not any("hypothesis guard:" in warning for warning in result.warnings)
# Vuln-research with NO hypotheses should error
research = command.check_command(
command.CheckCommandArgs(
command="nmap -sV 10.10.10.10",
phase="vuln-research",
eng_dir=str(eng),
scope=str(eng / "scope" / "scope.yaml"),
skill_loaded_file=str(skill_file),
session_id="ts",
)
)
assert any("requires at least one hypothesis" in error.lower() for error in research.errors)
# Add a fresh hypothesis - should pass without warnings
ts = datetime.now(UTC).strftime("%Y-%m-%d %H:%M")
(eng / "hypotheses.md").write_text(
(eng / "hypotheses.md").read_text(encoding="utf-8")
+ (
f"\n### H-001: SMB share exposed\n- **Status:** Candidate\n- **Phase:** VULN_RESEARCH\n"
f"- **Target:** 10.10.10.10\n- **Updated:** {ts} UTC\n"
),
encoding="utf-8",
)
ptt_path = eng / "state" / "ptt.md"
ptt_path.write_text(
ptt_path.read_text(encoding="utf-8")
.replace("| PT-010 | [~] |", "| PT-010 | [x] |")
.replace("| PT-030 | [ ] |", "| PT-030 | [~] |"),
encoding="utf-8",
)
research2 = command.check_command(
command.CheckCommandArgs(
command="nmap -sV 10.10.10.10",
phase="vuln-research",
eng_dir=str(eng),
scope=str(eng / "scope" / "scope.yaml"),
skill_loaded_file=str(skill_file),
session_id="ts",
)
)
assert not research2.errors
assert not any("hypothesis" in warning.lower() for warning in research2.warnings)
# Add a stale hypothesis - should warn
old_ts = "2020-01-01 00:00"
(eng / "hypotheses.md").write_text(
(eng / "hypotheses.md").read_text(encoding="utf-8")
+ (
f"\n### H-002: Old hypothesis\n- **Status:** Candidate\n- **Phase:** VULN_RESEARCH\n"
f"- **Target:** 10.10.10.10\n- **Updated:** {old_ts} UTC\n"
),
encoding="utf-8",
)
research3 = command.check_command(
command.CheckCommandArgs(
command="nmap -sV 10.10.10.10",
phase="vuln-research",
eng_dir=str(eng),
scope=str(eng / "scope" / "scope.yaml"),
skill_loaded_file=str(skill_file),
session_id="ts",
)
)
assert any("hypothesis guard:" in warning for warning in research3.warnings)
def test_target_scanner_ignores_dotted_files_and_handles_dev_tcp_endpoint():
candidates = extract_target_candidates(
"python3 server.py --output 01-nmap-full.txt "
"bash -c 'sock.close(); s.close(); echo test > /dev/tcp/10.10.15.65/4445'"
)
assert "10.10.15.65" in candidates
assert "10.10.15.65/44" not in candidates
assert "server.py" not in candidates
assert "01-nmap-full.txt" not in candidates
assert "sock.close" not in candidates
assert "s.close" not in candidates
def test_hypothesis_id_and_target_are_canonicalized_without_false_collisions(tmp_path):
path = tmp_path / "hypotheses.md"
path.write_text("# Hypothesis Board\n\n### H-H-001: malformed stale entry\n", encoding="utf-8")
record = hypotheses.update_hypothesis(
path,
in_scope_hosts={"10.10.15.65"},
id="H-001",
title="Scoped endpoint test",
status="Candidate",
phase="EXPLOITATION",
target="http://10.10.15.65:4445",
cve_research="web_search scoped endpoint CVE; NVD; not applicable",
exploit_research="web_search scoped endpoint exploit; GitHub; no results",
)
assert record.id == "001"
text = path.read_text(encoding="utf-8")
assert "### H-001: Scoped endpoint test" in text
assert "H-H-001" not in text
result = command.check_hypothesis_freshness(
tmp_path,
command.Phase.EXPLOITATION,
"bash -c 'echo test > /dev/tcp/10.10.15.65/4445' > 01-nmap-full.txt",
)
assert not result.errors, result.errors
def test_hypothesis_refuses_syntax_uncertain_rejection(tmp_path):
path = tmp_path / "hypotheses.md"
with pytest.raises(ValueError, match="must remain active for re-test"):
hypotheses.update_hypothesis(
path,
in_scope_hosts={"10.10.15.65"},
id="001",
title="PJL file download",
status="Rejected",
phase="EXPLOITATION",
target="10.10.15.65",
test_command='@PJL FSDOWNLOAD NAME="x" SIZE=1',
test_response="FILEERROR=1",
verification_status="syntax_uncertain",
rejection_reason="argument order needs source-verified re-test",
)
assert not path.exists(), "invalid rejection must not mutate the board"
def test_hypothesis_preserves_verified_rejection_details(tmp_path):
path = tmp_path / "hypotheses.md"
record = hypotheses.update_hypothesis(
path,
in_scope_hosts={"10.10.15.65"},
id="001",
title="PJL file download",
status="Rejected",
phase="EXPLOITATION",
target="10.10.15.65",
test_command='@PJL FSDOWNLOAD NAME="x" SIZE=1',
test_response="parser branch proves feature disabled",
verification_status="not_implemented",
rejection_reason="source-verified stub",
)
assert record.verification_status == "not_implemented"
text = path.read_text(encoding="utf-8")
assert '- **Test Command:** @PJL FSDOWNLOAD NAME="x" SIZE=1' in text
assert "- **Verification Status:** not_implemented" in text
assert "- **Rejection Reason:** source-verified stub" in text
def test_hypothesis_write_accepts_descriptive_target_context(tmp_path):
record = hypotheses.update_hypothesis(
tmp_path / "hypotheses.md",
in_scope_hosts={"cctv.htb"},
id="001",
title="Camera portal",
status="Candidate",
phase="VULN_RESEARCH",
target="cctv.htb (/zm/index.php, camera portal)",
)
assert record.target == "cctv.htb (/zm/index.php, camera portal)"
def test_exploitation_hypothesis_match_accepts_manual_field_order(tmp_path):
(tmp_path / "hypotheses.md").write_text(
"""### H-001: Queue service validation
- **Target:** 10.129.47.140:1515
- **Port:** 1515
- **Evidence:** evidence/vuln-research/queue.txt
- **CVE Research:** web_search queue service 1515 CVE; NVD; no results
- **Exploit Research:** web_search queue service 1515 exploit; GitHub; no results
- **Status:** Validated
- **Phase:** EXPLOITATION
""",
encoding="utf-8",
)
result = command.check_hypothesis_freshness(
tmp_path, command.Phase.EXPLOITATION, "python3 exploit.py 10.129.47.140 1515"
)
assert not result.errors, result.errors
def test_exploitation_requires_cve_and_exploit_research_attempts(tmp_path):
(tmp_path / "hypotheses.md").write_text(
"""### H-001: Queue service validation
- **Target:** 10.129.47.140:1515
- **Status:** Likely
- **Phase:** VULN_RESEARCH
- **CVE Research:** web_search queue service 1515 CVE; NVD; no results
""",
encoding="utf-8",
)
blocked = command.check_hypothesis_freshness(
tmp_path, command.Phase.EXPLOITATION, "python3 exploit.py 10.129.47.140 1515"
)
assert any("Exploit Research" in error for error in blocked.errors)
(tmp_path / "hypotheses.md").write_text(
(tmp_path / "hypotheses.md").read_text(encoding="utf-8")
+ "- **Exploit Research:** web_search queue service 1515 PoC; GitHub; source unavailable\n",
encoding="utf-8",
)
allowed = command.check_hypothesis_freshness(
tmp_path, command.Phase.EXPLOITATION, "python3 exploit.py 10.129.47.140 1515"
)
assert not allowed.errors, allowed.errors
def test_record_ptt_can_start_pristine_task(tmp_path):
skill_file = tmp_path / ".skill-loaded-ts"
eng = _init_e2e(tmp_path, skill_file)
ptt_path = eng / "state" / "ptt.md"
ptt_path.write_text(
ptt_path.read_text(encoding="utf-8").replace("| PT-010 | [~] |", "| PT-010 | [ ] |"),
encoding="utf-8",
)
result = json.loads(
TOOLS.handle_record_ptt(
{"eng_dir": str(eng), "id": "PT-010", "status": "[~]", "note": "Start recon"}
)
)
assert result["status"] == "ok", result
assert result["task_started"] is True
assert ptt.find_active_task(ptt.parse_ptt(ptt_path)).id == "PT-010"
def test_first_command_requires_an_active_ptt_task(tmp_path):
"""The guard blocks target work until one PTT task is explicitly active."""
skill_file = tmp_path / ".skill-loaded-ts"
eng = _init_e2e(tmp_path, skill_file)
ptt_path = eng / "state" / "ptt.md"
ptt_path.write_text(
ptt_path.read_text(encoding="utf-8").replace("| PT-010 | [~] |", "| PT-010 | [ ] |"),
encoding="utf-8",
)
args = command.CheckCommandArgs(
command="nmap -sV 10.10.10.10",
phase="recon",
eng_dir=str(eng),
scope=str(eng / "scope" / "scope.yaml"),
skill_loaded_file=str(skill_file),
session_id="ts",
)
first = command.check_command(args)
assert any(
"exactly one" in error.lower() or "active task" in error.lower() for error in first.errors
)
def test_multiple_active_ptt_tasks_block_target_execution(tmp_path):
skill_file = tmp_path / ".skill-loaded-ts"
eng = _init_e2e(tmp_path, skill_file)
ptt_path = eng / "state" / "ptt.md"
ptt_path.write_text(
ptt_path.read_text(encoding="utf-8").replace("| PT-011 | [ ] |", "| PT-011 | [~] |"),
encoding="utf-8",
)
result = command.check_command(
command.CheckCommandArgs(
command="nmap -sV 10.10.10.10",
phase="recon",
eng_dir=str(eng),
scope=str(eng / "scope" / "scope.yaml"),
skill_loaded_file=str(skill_file),
session_id="ts",
)
)
assert any(
"exactly one" in error.lower() or "active task" in error.lower() for error in result.errors
)
def test_exec_blocked_without_skill_load(monkeypatch, tmp_path):
"""Skill-load gate: check-command BLOCKs when the SKILL.md marker is absent,
and handle_exec honours that BLOCK (status 'denied')."""
from plugins.violin_guard.command import check_skill_load
# Real gate: missing marker file => BLOCK (error).
gate = check_skill_load(Path(tmp_path / "no-skill-loaded"), "test", mandatory=True)
assert gate.errors, "missing skill-loaded marker must BLOCK"
# handle_exec must translate a BLOCKed check-command into 'denied'.
_patch(monkeypatch, _cp(1, "BLOCK: skill load gate not satisfied\n"))
out = json.loads(
TOOLS.handle_exec(
{
"eng_dir": str(tmp_path),
"scope": "s",
"phase": "recon",
"command": "nmap 1.2.3.4",
}
)
)
assert out["status"] in ("denied", "error")
assert out["status"] in ("denied", "error")
def test_skill_load_gate_identifies_stale_session_marker(tmp_path):
from plugins.violin_guard.command import check_skill_load
state = tmp_path / "state"
state.mkdir()
(state / ".skill-loaded-old-session").write_text("skill-loaded: test\n", encoding="utf-8")
gate = check_skill_load(tmp_path, "current-session", mandatory=True)
assert gate.errors
assert ".skill-loaded-old-session" in gate.errors[0]
assert str(state / ".skill-loaded-current-session") in gate.errors[0]
def test_init_engagement_creates_compliant_artifacts(tmp_path):
"""`init-engagement` auto-creates a bootstrap-complete, guard-clean dir."""
import yaml
eng = tmp_path / "10.129.45.228-2026-07-08"
rc = bootstrap.init_engagement(str(eng))
assert rc == 0, "init-engagement should succeed"
# A default engagement is structurally complete but deliberately remains
# unapproved until the operator confirms authorisation.
scope = yaml.safe_load((eng / "scope" / "scope.yaml").read_text(encoding="utf-8"))
assert scope["targets"]["ip_addresses"] == ["10.129.45.228"]
validation = command.validate_scope(eng / "scope" / "scope.yaml")
assert any("authorisation.confirmed" in error for error in validation.errors)
# bootstrap reports complete (exit 0) or REVIEW-only (pristine PTT is
# legitimate on a brand-new engagement — no task touched yet).
res = bootstrap.check_bootstrap(str(eng), auto_repair=False)
assert int(res) in (0, 2), "bootstrap must be complete (or REVIEW for pristine PTT) after init"
def test_auto_repair_creates_missing_artifacts(tmp_path):
"""`check-bootstrap --auto-repair` self-heals missing required files."""
import yaml
eng = tmp_path / "10.10.10.5-2026-07-08"
eng.mkdir(parents=True, exist_ok=True) # empty dir, no artifacts
# First pass with auto-repair creates every missing artifact.
res = bootstrap.check_bootstrap(str(eng), auto_repair=True)
# After self-heal, bootstrap must be clean (0) or REVIEW-only (2).
assert int(res) in (0, 2), f"auto-repair should self-heal to clean, got {res}"
# Artifacts now exist; a real operator still has to confirm authorisation.
for rel in (
"scope/scope.yaml",
"state/ptt.md",
"hypotheses.md",
"state/history.md",
"exploits",
"evidence/exploitation",
):
assert (eng / rel).exists(), f"auto-repair should create {rel}"
yaml.safe_load((eng / "scope" / "scope.yaml").read_text(encoding="utf-8"))
assert command.validate_scope(eng / "scope" / "scope.yaml").errors
def test_local_tmp_script_path_is_an_informational_reminder():
result = command.check_local_artifact_paths("cat > /tmp/exploit.py <<'PY'\nprint('x')\nPY")
assert result.infos == ["local script path uses /tmp; save it under $ENG_DIR/exploits instead"]
def test_exec_auto_records_history_but_requires_explicit_ptt_review(monkeypatch, tmp_path):
"""History is automatic; PTT freshness cannot be satisfied by execution."""
monkeypatch.setenv("HERMES_YOLO_MODE", "1")
skill_file = tmp_path / ".skill-loaded-ts"
eng = _init_e2e(tmp_path, skill_file)
args = {
"eng_dir": str(eng),
"scope": str(eng / "scope" / "scope.yaml"),
"phase": "recon",
"command": "nmap -sV 10.10.10.10",
"skill_loaded_file": str(skill_file),
"session_id": "ts",
}
ptt_path = eng / "state" / "ptt.md"
ptt_before = ptt_path.read_text(encoding="utf-8")
first = json.loads(TOOLS.handle_exec(args))
assert first["status"] in ("ok", "approved", "review"), first
assert "command=nmap -sV 10.10.10.10" in (eng / "state" / "history.md").read_text(
encoding="utf-8"
)
assert ptt_path.read_text(encoding="utf-8") == ptt_before
window = state.sync_credit_limit("recon")
for i in range(2, window + 1):
command_val = f"nmap -sV 10.10.10.10 -p {i}"
out = json.loads(TOOLS.handle_exec({**args, "command": command_val}))
assert out["status"] in ("ok", "approved", "review"), out
blocked = json.loads(TOOLS.handle_exec({**args, "command": "nmap -sV 10.10.10.10 -p 99"}))
assert blocked["status"] == "sync_required", blocked
assert ptt_path.read_text(encoding="utf-8") == ptt_before
# The guard captures the batch ID from pending state and appends its marker
# to the PTT note; operators need not copy opaque internal IDs.
from plugins.violin_guard import state as _state
pending = _state.get_pending_sync(str(eng))
assert pending, "a batch must be pending before review"
batch_id = pending.get("batch_id")
assert batch_id, "pending batch must carry a batch_id"
history_text = (eng / "state" / "history.md").read_text(encoding="utf-8")
assert history_text.count("exit_code=0 | command=nmap") == window
reviewed = json.loads(
TOOLS.handle_review_batch(
{
"eng_dir": str(eng),
"id": "PT-010",
"status": "[~]",
"note": "batch reviewed",
}
)
)
assert reviewed["status"] == "ok", reviewed
assert f"[reviewed-batch:{batch_id}]" in ptt_path.read_text(encoding="utf-8")
resumed = json.loads(TOOLS.handle_exec({**args, "command": "nmap -sV 10.10.10.10 -p 99"}))
assert resumed["status"] in ("ok", "approved", "review"), resumed
def test_exploitation_gets_bounded_window_then_requires_ptt_review(monkeypatch, tmp_path):
"""Exploit payloads may batch, but cannot self-certify PTT progress."""
monkeypatch.setenv("HERMES_YOLO_MODE", "1")
skill_file = tmp_path / ".skill-loaded-ts"
eng = _init_e2e(tmp_path, skill_file)
ptt_path = eng / "state" / "ptt.md"
ptt_path.write_text(
ptt_path.read_text(encoding="utf-8")
.replace("| PT-010 | [~] |", "| PT-010 | [x] |")
.replace("| PT-042 | [ ] |", "| PT-042 | [~] |"),
encoding="utf-8",
)
# Create a real hypothesis (not in comment) for exploitation phase
recorded = json.loads(
TOOLS.handle_record_hypothesis(
{
"eng_dir": str(eng),
"id": "001",
"title": "scoped payload validation",
"status": "Candidate",
"phase": "EXPLOITATION",
"target": "10.10.10.10",
"service": "http",
"port": "80",
"cve_research": "web_search HTTP endpoint CVE; NVD; not applicable",
"exploit_research": "web_search HTTP endpoint exploit; GitHub; no results",
}
)
)
assert recorded["status"] == "ok", recorded
args = {
"eng_dir": str(eng),
"scope": str(eng / "scope" / "scope.yaml"),
"phase": "exploitation",
"skill_loaded_file": str(skill_file),
"session_id": "ts",
}
ptt_before = ptt_path.read_text(encoding="utf-8")
total = state.sync_credit_limit("exploitation")
for i in range(total):
command_val = f"curl http://10.10.10.10/probe?variant={i}"
out = json.loads(TOOLS.handle_exec({**args, "command": command_val}))
assert out["status"] in ("ok", "approved", "review"), out
blocked = json.loads(
TOOLS.handle_exec({**args, "command": "curl http://10.10.10.10/probe?variant=99"})
)
assert blocked["status"] == "sync_required", blocked
assert ptt_path.read_text(encoding="utf-8") == ptt_before
def test_heartbeat_gate_every_n_commands(monkeypatch, tmp_path):
"""The interval command executes, then the next command waits for review."""
monkeypatch.setenv("HERMES_YOLO_MODE", "1")
skill_file = tmp_path / ".skill-loaded-ts"
eng = _init_e2e(tmp_path, skill_file)
args = {
"eng_dir": str(eng),
"scope": str(eng / "scope" / "scope.yaml"),
"phase": "recon",
"skill_loaded_file": str(skill_file),
"session_id": "ts",
}
for _ in range(state.COMMAND_INTERVAL - 1):
state.tick_command(str(eng))
threshold = json.loads(TOOLS.handle_exec({**args, "command": "nmap -sV 10.10.10.10 -p 20"}))
assert threshold["status"] == "ok", threshold
assert threshold["executed"] is True
assert state.read_counts(str(eng))["commands"] == state.COMMAND_INTERVAL
assert state.has_heartbeat_pending(str(eng))
blocked = json.loads(TOOLS.handle_exec({**args, "command": "nmap -sV 10.10.10.10 -p 21"}))
assert blocked["status"] == "denied", blocked
assert blocked["executed"] is False
assert state.read_counts(str(eng))["commands"] == state.COMMAND_INTERVAL
cleared = json.loads(TOOLS.handle_heartbeat_done({"eng_dir": str(eng)}))
assert cleared["status"] == "ok", cleared
resumed = json.loads(TOOLS.handle_exec({**args, "command": "nmap -sV 10.10.10.10 -p 21"}))
assert resumed["status"] == "ok", resumed
assert resumed["executed"] is True
assert state.read_counts(str(eng))["commands"] == state.COMMAND_INTERVAL + 1
def test_message_ticks_are_diagnostic_and_do_not_trigger_heartbeat(monkeypatch, tmp_path):
"""LLM message volume must not create a stale guard lock."""
skill_file = tmp_path / ".skill-loaded-ts"
eng = _init_e2e(tmp_path, skill_file)
# Build a session object via pre_llm_call (which increments message tick)
from plugins.violin_guard import _pre_llm_call_hook
for _ in range(100):
_pre_llm_call_hook(session_id="ts", eng_dir=str(eng), phase="recon")
assert not state.has_heartbeat_pending(str(eng))
assert state.read_counts(str(eng))["messages"] == 100