diff --git a/CHANGELOG.md b/CHANGELOG.md
index 649d229..874f174 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
## 3.1.0 (Unreleased)
+### Benchmark & Guard Regression Fixes (2026-08-11)
+- Runner seeds the target engagement brief (`benchmark/targets/duck-store/engage.md` — default credentials, register-first, reset window) into `scope/scope.yaml` as `engagement.brief` so client-provided facts reach the agent through the file it already validates; the `/goal` prompt stays strictly task-only. Restores the `weak-admin-creds` chain that the 08-10 prompt strip silently dropped (18/20 → 6/20 regression).
+- `_validate_phase_exit` now blocks REPORTING close in benchmark mode when no command was ever executed in EXPLOITATION or later — the 08-11 run logged all 47 commands as `phase=recon`, skipped PT-102/PT-103, and reported incomplete (14/20 challenges untouched). Evidence can only accumulate by running later-phase commands, so the gate cannot be faked with prose.
+- Coverage-matrix discipline: `not_applicable` cells must cite an `evidence/` file (a bare reason is memory reconstruction, the false-positive factory) and `blocked` cells must name the guard; `rate_limits` self-declared N/A against a real challenge no longer passes VULN_RESEARCH close.
+- Record-as-you-go recency gate (hard block when execution evidence outdates the hypothesis board by >15 min) and guard friction auto-log at block time; SKILL.md §7 Operational Contract mandates same-turn hypothesis/coverage/PTT writes after every `violin_review_batch`.
+
### Automated Benchmark & Evaluation Framework
- Closeout artifacts are now generated, not hand-written: `python scripts/violin_guard.py generate-closeout --eng-dir
--target ` derives `evidence/reporting/findings.yaml` and a complete `reporting/report.md` skeleton (metadata, severity table, per-finding sections) from canonical `evidence/findings/FIND-NNN.md` files. Agent closeout duty reduced to the executive-summary narrative — cuts per-finding recording from 4 surfaces to 2 and eliminates the duplicate rewriting that starved exploitation budget and timed out closeout.
- Benchmark goal/closeout prompts reduced to task-only (target + integrity constraint); methodology moved into the framework — new `skills/pentest/references/exhaustive-coverage.md` (coverage matrix, don't-stop-at-first-finding, route discovery, authz/business-logic boundaries, FIND-NNN canonization) mandated from `skills/pentest/SKILL.md` §3, plus an Operational Contract in §7 and Citation Discipline in `evidence-and-verification-discipline.md` §8.
diff --git a/benchmark/proof.py b/benchmark/proof.py
index 8cc321a..2e582ee 100644
--- a/benchmark/proof.py
+++ b/benchmark/proof.py
@@ -154,13 +154,9 @@ def pattern_hits(patterns: list[str], content: str) -> set[str]:
# Require-term sentinels resolved against the raw (un-lowered) content.
_NEGATIVE_NUMBER_RE = re.compile(r"-\d+")
-_CREDIT_GRANTED_RE = re.compile(
- r"(?:account_)?credit\"\s*:\s*(?:[1-9]\d*|0\.\d*[1-9]\d*)", re.I
-)
+_CREDIT_GRANTED_RE = re.compile(r"(?:account_)?credit\"\s*:\s*(?:[1-9]\d*|0\.\d*[1-9]\d*)", re.I)
_ADMIN_ROLE_GRANTED_RE = re.compile(r'role\s*[":=]+\s*admin', re.I)
-_EXTERNAL_REDIRECT_RE = re.compile(
- r"location\s*:\s*https?://(?!duck-store|localhost|127\.)", re.I
-)
+_EXTERNAL_REDIRECT_RE = re.compile(r"location\s*:\s*https?://(?!duck-store|localhost|127\.)", re.I)
_SENTINEL_RESOLVERS = {
"NEGATIVE_NUMBER": _NEGATIVE_NUMBER_RE,
@@ -250,11 +246,7 @@ def bundle_matches_challenge(bundle: EvidenceBundle, challenge: dict[str, Any])
content = bundle.context.lower()
challenge_id = str(challenge.get("id") or "").lower()
raw_endpoints = challenge.get("endpoint") or ""
- endpoints = (
- raw_endpoints
- if isinstance(raw_endpoints, list)
- else [str(raw_endpoints)]
- )
+ endpoints = raw_endpoints if isinstance(raw_endpoints, list) else [str(raw_endpoints)]
endpoints = [str(ep) for ep in endpoints if str(ep).strip()]
filename_anchored = challenge_id in bundle.relative_path.lower()
patterns = [str(value) for value in challenge.get("patterns", []) if str(value).strip()]
@@ -267,9 +259,7 @@ def bundle_matches_challenge(bundle: EvidenceBundle, challenge: dict[str, Any])
require = [str(value) for value in challenge.get("require", []) if str(value).strip()]
if require and not require_hits(require, content):
return False
- require_any = [
- str(value) for value in challenge.get("require_any", []) if str(value).strip()
- ]
+ require_any = [str(value) for value in challenge.get("require_any", []) if str(value).strip()]
if require_any and not require_any_hits(require_any, content):
return False
specific_hits = {
diff --git a/benchmark/run.py b/benchmark/run.py
index e92d28f..e62907a 100644
--- a/benchmark/run.py
+++ b/benchmark/run.py
@@ -178,6 +178,16 @@ def _scope_for_target(target: str) -> dict:
benchmark = scope.get("benchmark")
if isinstance(benchmark, dict):
benchmark.pop("openapi_spec", None)
+ # Engagement brief (client-provided facts, framework-owned): seed the
+ # target's engage.md so operational facts (default credentials, register
+ # first, reset window) reach the agent via scope.yaml — the file the agent
+ # is already required to validate — instead of the prompt. The /goal prompt
+ # stays task-only.
+ brief_path = fixture_path.with_name("engage.md")
+ if brief_path.is_file():
+ brief = brief_path.read_text(encoding="utf-8").strip()
+ if brief:
+ scope.setdefault("engagement", {})["brief"] = brief
return scope
diff --git a/benchmark/score.py b/benchmark/score.py
index 78ea6e8..edf1db1 100644
--- a/benchmark/score.py
+++ b/benchmark/score.py
@@ -128,9 +128,7 @@ def parse_findings(eng_dir: Path) -> list[dict]:
if "evidence/" in line:
for part in re.findall(r"evidence/[^\s,)`\]]+", line):
evidence_files.add(Path(part).name)
- results.append(
- {"id": fid, "evidence_files": evidence_files, "text": txt}
- )
+ results.append({"id": fid, "evidence_files": evidence_files, "text": txt})
return results
@@ -199,18 +197,12 @@ def _finding_matches_challenge(text: str, challenge: dict) -> bool:
if len(text.strip()) < 100:
return False
raw_endpoints = challenge.get("endpoint") or ""
- endpoints = (
- raw_endpoints
- if isinstance(raw_endpoints, list)
- else [str(raw_endpoints)]
- )
+ endpoints = raw_endpoints if isinstance(raw_endpoints, list) else [str(raw_endpoints)]
endpoints = [str(ep) for ep in endpoints if str(ep).strip()]
require = [str(value) for value in challenge.get("require", []) if str(value).strip()]
if require and not require_hits(require, text):
return False
- require_any = [
- str(value) for value in challenge.get("require_any", []) if str(value).strip()
- ]
+ require_any = [str(value) for value in challenge.get("require_any", []) if str(value).strip()]
if require_any and not require_any_hits(require_any, text):
return False
patterns = [str(value) for value in challenge.get("patterns", []) if str(value).strip()]
diff --git a/plugins/violin_guard/adapters.py b/plugins/violin_guard/adapters.py
index b5338c2..b7c3b55 100644
--- a/plugins/violin_guard/adapters.py
+++ b/plugins/violin_guard/adapters.py
@@ -203,14 +203,29 @@ def build_ffuf(args: dict) -> str:
return " ".join(parts)
-def resolve_ffuf_wordlist(requested: object = "") -> str:
- """Resolve an ffuf wordlist across common Kali, Parrot, and custom installs."""
+def resolve_ffuf_wordlist(requested: object = "", eng_dir: object = "") -> str:
+ """Resolve an ffuf wordlist across common Kali, Parrot, custom installs, and engagement evidence."""
candidates: list[Path] = []
requested_text = os.path.expandvars(str(requested or "").strip())
if requested_text:
candidates.append(Path(requested_text).expanduser())
+ eng_text = os.path.expandvars(
+ str(
+ eng_dir or os.environ.get("ENG_DIR", "") or os.environ.get("VIOLIN_ENG_ROOT", "")
+ ).strip()
+ )
+ if eng_text:
+ eng_path = Path(eng_text).expanduser()
+ candidates.extend(
+ (
+ eng_path / "evidence" / "recon" / "focused_wordlist.txt",
+ eng_path / "evidence" / "recon" / "wordlist.txt",
+ eng_path / "evidence" / "wordlist.txt",
+ )
+ )
+
seclists_root = os.environ.get("SECLISTS", "").strip()
if seclists_root:
candidates.append(
diff --git a/plugins/violin_guard/handlers/adapter_handlers.py b/plugins/violin_guard/handlers/adapter_handlers.py
index d4ceda1..b0437c8 100644
--- a/plugins/violin_guard/handlers/adapter_handlers.py
+++ b/plugins/violin_guard/handlers/adapter_handlers.py
@@ -1,3 +1,4 @@
+import os
import shlex
import sys
from pathlib import Path
@@ -51,7 +52,16 @@ def handle_ffuf(args, **kwargs):
values = dict(args or {})
try:
- values["wordlist"] = resolve_ffuf_wordlist(values.get("wordlist"))
+ orig_eng_dir = os.environ.get("ENG_DIR")
+ if values.get("eng_dir"):
+ os.environ["ENG_DIR"] = str(values["eng_dir"])
+ try:
+ values["wordlist"] = resolve_ffuf_wordlist(values.get("wordlist"))
+ finally:
+ if orig_eng_dir is None:
+ os.environ.pop("ENG_DIR", None)
+ else:
+ os.environ["ENG_DIR"] = orig_eng_dir
token_file = str(values.get("auth_token_file") or "").strip()
if token_file:
engagement = _eng_path(str(values.get("eng_dir") or ""))
diff --git a/plugins/violin_guard/handlers/ptt_handlers.py b/plugins/violin_guard/handlers/ptt_handlers.py
index a71acb3..41c96f5 100644
--- a/plugins/violin_guard/handlers/ptt_handlers.py
+++ b/plugins/violin_guard/handlers/ptt_handlers.py
@@ -73,14 +73,26 @@ def _validate_phase_exit(engagement: Path, task_id: str, status: str) -> None:
entries = matrix.get("coverage") if isinstance(matrix, dict) else None
if not isinstance(entries, dict) or not entries:
raise ValueError("coverage matrix must contain a non-empty coverage mapping")
- unresolved_coverage = [
- name
- for name, entry in entries.items()
- if not isinstance(entry, dict)
- or str(entry.get("status") or "").strip().lower()
- not in {"tested", "not_applicable", "blocked"}
- or not str(entry.get("evidence_or_reason") or "").strip()
- ]
+ unresolved_coverage = []
+ for name, entry in entries.items():
+ if not isinstance(entry, dict):
+ unresolved_coverage.append(name)
+ continue
+ status = str(entry.get("status") or "").strip().lower()
+ reason = str(entry.get("evidence_or_reason") or "").strip()
+ if status not in {"tested", "not_applicable", "blocked"} or not reason:
+ unresolved_coverage.append(name)
+ continue
+ # not_applicable must be backed by an evidence file: a bare
+ # reason ("no rate-limit behavior observed") is a memory
+ # reconstruction, the exact false-positive factory. The
+ # 2026-08-11 run self-declared rate_limits N/A against a real
+ # challenge because it never probed. blocked must name the
+ # guard that prevented testing.
+ if status == "not_applicable" and "evidence/" not in reason:
+ unresolved_coverage.append(f"{name} (not_applicable without evidence file)")
+ elif status == "blocked" and "guard" not in reason.lower():
+ unresolved_coverage.append(f"{name} (blocked without guard reference)")
if unresolved_coverage:
raise ValueError(
"VULN_RESEARCH cannot close with undispositioned coverage: "
@@ -94,6 +106,32 @@ def _validate_phase_exit(engagement: Path, task_id: str, status: str) -> None:
"VULN_RESEARCH cannot close with unresolved hypotheses: " + ", ".join(unresolved)
)
if phase.value == "REPORTING":
+ scope_path = engagement / "scope" / "scope.yaml"
+ scope_data = (
+ yaml.safe_load(scope_path.read_text(encoding="utf-8")) if scope_path.is_file() else {}
+ )
+ # Benchmark mode: REPORTING may not close unless the run actually
+ # reached EXPLOITATION or later. The 2026-08-11 regression logged
+ # every command as phase=recon, declared RECON "coverage finalized",
+ # skipped PT-102/PT-103, and jumped straight to REPORTING — scoring
+ # 6/20 with 14 challenges never touched. The agent cannot fake this:
+ # evidence only accumulates by running commands in a later phase.
+ if isinstance(scope_data, dict) and (scope_data.get("benchmark") or {}).get("mode") is True:
+ history_path = engagement / "state" / "history.md"
+ reached_later_phase = False
+ if history_path.is_file():
+ history_text = history_path.read_text(encoding="utf-8", errors="replace")
+ for token in re.findall(r"phase=([a-z_]+)", history_text):
+ if token in {"exploitation", "post_exploitation", "privesc", "flags"}:
+ reached_later_phase = True
+ break
+ if not reached_later_phase:
+ raise ValueError(
+ "REPORTING cannot close: no commands were executed in EXPLOITATION or a "
+ "later phase (all history is phase=recon). Activate PT-103 and run "
+ "proof-verification commands under phase=exploitation before reporting; "
+ "skipping the exploitation phase produces an incomplete assessment."
+ )
missing: list[str] = []
for item in board:
if item.canonical_status() != "Validated":
diff --git a/plugins/violin_guard/schemas.py b/plugins/violin_guard/schemas.py
index f8930ca..3ddf267 100644
--- a/plugins/violin_guard/schemas.py
+++ b/plugins/violin_guard/schemas.py
@@ -69,7 +69,15 @@ class RecordHypothesisArgsModel(BaseModel):
port: str = ""
id: str = ""
title: str = ""
- status: str = ""
+ status: str = Field(
+ "",
+ description=(
+ "Canonical status: 'Candidate', 'Likely', 'Validated', or 'Rejected'. "
+ "When status='Validated', 'runtime_evidence' is required (path under evidence/). "
+ "When status='Rejected', 'verification_status' ('syntax_confirmed' or 'not_implemented'), "
+ "'test_command', 'test_response', and 'rejection_reason' are required."
+ ),
+ )
confidence: str = Field("", description="0.1-1.0 guesstimate; escalate only with evidence")
timebox: str = Field("", description="e.g. 4 tool batches or 30 min — then re-evaluate")
cheapest_test: str = Field(
@@ -96,7 +104,13 @@ class RecordHypothesisArgsModel(BaseModel):
)
test_command: str = Field("", description="Exact syntax tested, including argument order")
test_response: str = Field("", description="Exact decisive response or error")
- verification_status: str = ""
+ verification_status: str = Field(
+ "",
+ description=(
+ "Required when status='Rejected': must be 'syntax_confirmed' or 'not_implemented'. "
+ "Use 'syntax_uncertain' or 'not_tested' to keep hypothesis active for re-testing."
+ ),
+ )
kill_criteria: str = Field(
"",
description="Evidence that contradicts, or no new info in N batches — then kill & log in Decoy Trail",
diff --git a/plugins/violin_guard/targets.py b/plugins/violin_guard/targets.py
index 744face..ec1e0b0 100644
--- a/plugins/violin_guard/targets.py
+++ b/plugins/violin_guard/targets.py
@@ -57,8 +57,21 @@ _NON_TARGET_DOTTED_TOKENS = frozenset(
"urllib.parse",
"urllib.error",
"http.client",
+ "http.server",
"json.decoder",
"json.encoder",
+ "json.tool",
+ "xml.etree",
+ "unittest.mock",
+ "importlib.util",
+ "asyncio.runner",
+ "wsgiref.simple_server",
+ "jwt.io",
+ "example.com",
+ "example.org",
+ "example.net",
+ "schema.org",
+ "w3.org",
}
)
_LOCAL_HOSTS = {"127.0.0.1", "0.0.0.0", "localhost", "::1"}
@@ -173,13 +186,21 @@ def extract_target_candidates(command: str) -> list[str]:
"""Return ordered, unique network targets found in a shell command."""
candidates: list[str] = []
skip_path_value = False
+ skip_next_token = False
for token in _command_tokens(command):
if skip_path_value:
skip_path_value = False
continue
+ if skip_next_token:
+ skip_next_token = False
+ continue
if token in _PATH_VALUE_FLAGS:
skip_path_value = True
continue
+ if token == "-m" or token.startswith("-m="):
+ if token == "-m":
+ skip_next_token = True
+ continue
if token in _REDIRECTION_OPERATORS or any(
token.startswith(f"{flag}=") for flag in _PATH_VALUE_FLAGS
):
diff --git a/plugins/violin_guard/terminal_policy.py b/plugins/violin_guard/terminal_policy.py
index 6873614..9de9a8c 100644
--- a/plugins/violin_guard/terminal_policy.py
+++ b/plugins/violin_guard/terminal_policy.py
@@ -356,8 +356,8 @@ def block_terminal_command(command: str) -> str | None:
def _message(reason: str) -> str:
return (
"RAW TERMINAL TARGET EXECUTION BLOCKED by Violin: "
- f"{reason}. Use `violin_exec` for one command or `violin_exec_burst` "
- "for a bounded batch so scope, phase, PTT, hypotheses, history, "
+ f"{reason}. Use `violin_exec` or `violin_exec_burst` (or typed tools like `violin_ffuf`, `violin_httpx`) "
+ "for any target command so scope, phase, PTT, hypotheses, history, "
"evidence, and sync gates are enforced. The built-in terminal remains "
"available for host-local preparation, tests, builds, and bookkeeping."
)
diff --git a/tests/guard/guards/test_scope_authorization.py b/tests/guard/guards/test_scope_authorization.py
index 3d054e6..2b54b81 100644
--- a/tests/guard/guards/test_scope_authorization.py
+++ b/tests/guard/guards/test_scope_authorization.py
@@ -70,6 +70,8 @@ def test_dotted_arguments_are_not_treated_as_network_targets_when_they_are_paths
assert "urllib.request" not in extract_target_candidates(
"python3 -c 'import urllib.request; urllib.request.urlopen(\"https://target.example\")'"
)
+ assert extract_target_candidates("python3 -m json.tool input.json") == []
+ assert extract_target_candidates("curl -u user:pass jwt.io") == []
def test_explicit_target_keeps_unknown_bare_hostnames_reviewable(
diff --git a/tests/guard/state/test_workflow_contract.py b/tests/guard/state/test_workflow_contract.py
index a6866e6..19a7094 100644
--- a/tests/guard/state/test_workflow_contract.py
+++ b/tests/guard/state/test_workflow_contract.py
@@ -202,6 +202,95 @@ def test_reporting_exit_requires_canonical_finding(tmp_path: Path) -> None:
_validate_phase_exit(engagement, "PT-050", "[x]")
+def test_reporting_exit_blocks_recon_only_run_in_benchmark_mode(tmp_path: Path) -> None:
+ engagement = tmp_path / "engagement"
+ assert bootstrap.init_engagement(engagement, host="10.10.10.10") == 0
+ scope = engagement / "scope" / "scope.yaml"
+ scope.write_text(
+ scope.read_text(encoding="utf-8") + "\nbenchmark:\n mode: true\n",
+ encoding="utf-8",
+ )
+ history = engagement / "state" / "history.md"
+ history.write_text(
+ "# Command History\n- 2026-08-11T12:00:00Z | phase=recon | exit_code=0 | command=curl x\n"
+ "- 2026-08-11T12:01:00Z | phase=recon | exit_code=0 | command=curl y\n",
+ encoding="utf-8",
+ )
+ with pytest.raises(ValueError, match="no commands were executed in EXPLOITATION"):
+ _validate_phase_exit(engagement, "PT-050", "[x]")
+
+
+def test_reporting_exit_allows_exploitation_history_in_benchmark_mode(tmp_path: Path) -> None:
+ engagement = tmp_path / "engagement"
+ assert bootstrap.init_engagement(engagement, host="10.10.10.10") == 0
+ scope = engagement / "scope" / "scope.yaml"
+ scope.write_text(
+ scope.read_text(encoding="utf-8") + "\nbenchmark:\n mode: true\n",
+ encoding="utf-8",
+ )
+ history = engagement / "state" / "history.md"
+ history.write_text(
+ "# Command History\n- 2026-08-11T12:00:00Z | phase=recon | exit_code=0 | command=curl x\n"
+ "- 2026-08-11T12:05:00Z | phase=exploitation | exit_code=0 | command=curl y\n",
+ encoding="utf-8",
+ )
+ evidence = engagement / "evidence" / "exploitation" / "proof.txt"
+ evidence.parent.mkdir(parents=True, exist_ok=True)
+ evidence.write_text("decisive runtime proof\n", encoding="utf-8")
+ hypotheses.update_hypothesis(
+ engagement / "hypotheses.md",
+ id="001",
+ title="Validated issue",
+ status="Validated",
+ runtime_evidence="evidence/exploitation/proof.txt",
+ )
+ # No exploitation-phase-history error: the run reached EXPLOITATION. It
+ # still blocks on the unlinked Validated hypothesis (no FIND file yet).
+ with pytest.raises(ValueError, match="canonical findings: H-001"):
+ _validate_phase_exit(engagement, "PT-050", "[x]")
+
+
+def test_vuln_research_exit_requires_evidence_for_not_applicable_coverage(
+ tmp_path: Path,
+) -> None:
+ engagement = tmp_path / "engagement"
+ assert bootstrap.init_engagement(engagement, host="10.10.10.10") == 0
+ scope = engagement / "scope" / "scope.yaml"
+ scope.write_text(
+ scope.read_text(encoding="utf-8") + "\nbenchmark:\n mode: true\n",
+ encoding="utf-8",
+ )
+ matrix = engagement / "state" / "coverage-matrix.yaml"
+ matrix.write_text(
+ "coverage:\n"
+ " rate_limits:\n"
+ " status: not_applicable\n"
+ " evidence_or_reason: 'no rate-limit behavior observed on target'\n",
+ encoding="utf-8",
+ )
+ with pytest.raises(ValueError, match="not_applicable without evidence file"):
+ _validate_phase_exit(engagement, "PT-030", "[x]")
+
+
+def test_vuln_research_exit_accepts_evidence_backed_not_applicable(tmp_path: Path) -> None:
+ engagement = tmp_path / "engagement"
+ assert bootstrap.init_engagement(engagement, host="10.10.10.10") == 0
+ scope = engagement / "scope" / "scope.yaml"
+ scope.write_text(
+ scope.read_text(encoding="utf-8") + "\nbenchmark:\n mode: true\n",
+ encoding="utf-8",
+ )
+ matrix = engagement / "state" / "coverage-matrix.yaml"
+ matrix.write_text(
+ "coverage:\n"
+ " rate_limits:\n"
+ " status: not_applicable\n"
+ " evidence_or_reason: 'probed 10x in evidence/recon/rate_probe.txt; no 429'\n",
+ encoding="utf-8",
+ )
+ _validate_phase_exit(engagement, "PT-030", "[x]") # no exception
+
+
def test_validated_hypothesis_rejects_escaping_or_empty_evidence(tmp_path: Path) -> None:
engagement = tmp_path / "engagement"
assert bootstrap.init_engagement(engagement, host="10.10.10.10") == 0
diff --git a/tests/guard/test_benchmark_runner.py b/tests/guard/test_benchmark_runner.py
index 9c00aba..e8617d2 100644
--- a/tests/guard/test_benchmark_runner.py
+++ b/tests/guard/test_benchmark_runner.py
@@ -83,6 +83,19 @@ def test_init_benchmark_engagement(tmp_path: Path) -> None:
assert scope["targets"]["urls"] == [target]
+def test_init_benchmark_engagement_seeds_engage_brief_into_scope(tmp_path: Path) -> None:
+ eng_dir = tmp_path / "eng"
+ init_benchmark_engagement(eng_dir, "http://test-target.local:8080")
+
+ scope = yaml.safe_load((eng_dir / "scope" / "scope.yaml").read_text(encoding="utf-8"))
+ brief = (scope.get("engagement") or {}).get("brief") or ""
+ # The duck-store engage.md brief (default credentials) must reach the agent
+ # through scope.yaml — the file it is already required to validate — never
+ # through the /goal prompt (task-only rule).
+ assert "admin / admin" in brief
+ assert "register" in brief.lower()
+
+
def test_closeout_detection_requires_all_artifacts(tmp_path: Path) -> None:
eng_dir = tmp_path / "eng"
init_benchmark_engagement(eng_dir, "https://target.local")
diff --git a/tests/guard/test_framework_feedback_clarifications.py b/tests/guard/test_framework_feedback_clarifications.py
index 9cf2ccf..19d735e 100644
--- a/tests/guard/test_framework_feedback_clarifications.py
+++ b/tests/guard/test_framework_feedback_clarifications.py
@@ -23,3 +23,36 @@ def test_heartbeat_done_schema_clearance_sequence():
assert "violin_status" in desc
assert "violin_review_batch" in desc
assert "violin_heartbeat_done" in desc
+
+
+def test_record_hypothesis_schema_guidance_hints():
+ props = schemas.RECORD_HYPOTHESIS_SCHEMA["parameters"]["properties"]
+ status_desc = props["status"]["description"]
+ assert "Validated" in status_desc
+ assert "runtime_evidence" in status_desc
+ assert "Rejected" in status_desc
+ verification_desc = props["verification_status"]["description"]
+ assert "syntax_confirmed" in verification_desc
+ assert "not_implemented" in verification_desc
+
+
+def test_terminal_policy_block_message_mentions_typed_tools():
+ from plugins.violin_guard.terminal_policy import block_terminal_command
+
+ msg = block_terminal_command("nslookup 10.10.10.10")
+ assert msg is not None
+ assert "violin_ffuf" in msg
+ assert "violin_httpx" in msg
+
+
+def test_resolve_ffuf_wordlist_finds_evidence_wordlist(tmp_path, monkeypatch):
+ from plugins.violin_guard.adapters import resolve_ffuf_wordlist
+
+ evidence_dir = tmp_path / "evidence" / "recon"
+ evidence_dir.mkdir(parents=True)
+ wordlist = evidence_dir / "focused_wordlist.txt"
+ wordlist.write_text("admin\nlogin\napi\n", encoding="utf-8")
+
+ monkeypatch.setenv("ENG_DIR", str(tmp_path))
+ resolved = resolve_ffuf_wordlist("")
+ assert resolved == str(wordlist)