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)