From ae52d86e5a84f7ddadae1ba99f8206e842a08fb9 Mon Sep 17 00:00:00 2001 From: Violin Date: Sun, 19 Jul 2026 00:44:08 +0100 Subject: [PATCH] add automatic Kali backend selection --- plugins/violin_guard/execution.py | 12 ++- plugins/violin_guard/runtime_backend.py | 92 +++++++++++++++++++++++ plugins/violin_guard/schemas.py | 6 +- plugins/violin_guard/service.py | 7 +- tests/guard/state/test_runtime_backend.py | 53 +++++++++++++ 5 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 plugins/violin_guard/runtime_backend.py create mode 100644 tests/guard/state/test_runtime_backend.py diff --git a/plugins/violin_guard/execution.py b/plugins/violin_guard/execution.py index a56d1c2..5f0b480 100644 --- a/plugins/violin_guard/execution.py +++ b/plugins/violin_guard/execution.py @@ -21,6 +21,7 @@ from typing import Any from . import state from .history import append_history from .phases import normalize_phase +from .runtime_backend import resolve_backend __all__ = [ "execute", @@ -111,7 +112,8 @@ def _command_argv( 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}" + docker_root = f"/engagements/{eng_dir.name}" + docker_cwd = docker_root if relative == "." else f"{docker_root}/{relative}" prefix = ["docker", "exec", "-i", "-w", docker_cwd, container] return prefix + list(argv) if argv is not None else prefix + ["sh", "-lc", command] @@ -296,7 +298,7 @@ def execute( *, eng_dir: str, phase: str, - backend: str = "local", + backend: str = "auto", timeout_seconds: Any = DEFAULT_TIMEOUT, cwd: str = "", label: str = "", @@ -309,6 +311,7 @@ def execute( engagement = _resolve_engagement(eng_dir) workdir = _resolve_cwd(engagement, cwd) timeout = _timeout(timeout_seconds) + resolution = resolve_backend(backend, engagement, container=docker_container) execution_id = str(uuid.uuid4()) started_at = _utc_now() stem = f"{started_at[:19].replace(':', '')}-{execution_id[:8]}-{_label(label)}" @@ -327,7 +330,8 @@ def execute( "schema_version": SCHEMA_VERSION, "execution_id": execution_id, "status": "starting", - "backend": backend, + "backend": resolution.resolved, + "runtime": resolution.to_dict(), "command": command, "phase": phase, "cwd": str(workdir), @@ -363,7 +367,7 @@ def execute( popen_kwargs["start_new_session"] = True process_argv = _command_argv( - command, backend, workdir, engagement, docker_container, argv=argv + command, resolution.resolved, workdir, engagement, resolution.container, argv=argv ) proc = subprocess.Popen(process_argv, **popen_kwargs) diff --git a/plugins/violin_guard/runtime_backend.py b/plugins/violin_guard/runtime_backend.py new file mode 100644 index 0000000..fff5c16 --- /dev/null +++ b/plugins/violin_guard/runtime_backend.py @@ -0,0 +1,92 @@ +"""Runtime selection for native Kali/Parrot, Docker Kali, and local fallback.""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +from collections.abc import Callable +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class BackendResolution: + requested: str + resolved: str + platform: str + container: str = "" + mount: str = "" + fallback_reason: str = "" + + def to_dict(self) -> dict[str, str]: + return asdict(self) + + +def _native_kali_or_parrot(os_release: Path = Path("/etc/os-release")) -> bool: + if platform.system().lower() != "linux" or not os_release.is_file(): + return False + text = os_release.read_text(encoding="utf-8", errors="replace").lower() + return "id=kali" in text or "id=parrot" in text or "kali linux" in text or "parrot os" in text + + +def _docker_container_ready( + container: str, engagement: Path, run: Callable = subprocess.run +) -> tuple[bool, str]: + if shutil.which("docker") is None: + return False, "docker executable not found" + result = run( + ["docker", "inspect", "--format", "{{.State.Running}}|{{json .Mounts}}", container], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=10, + check=False, + ) + if result.returncode != 0: + return False, f"Docker container {container!r} is unavailable" + running, _, mounts = result.stdout.strip().partition("|") + expected = f"/engagements/{engagement.name}" + if running.strip().lower() != "true": + return False, f"Docker container {container!r} is not running" + if expected not in mounts: + return False, f"Docker container {container!r} does not mount {expected}" + return True, expected + + +def resolve_backend( + requested: str, + engagement: Path, + *, + container: str = "kali-pentest", + docker_probe: Callable = _docker_container_ready, + native_probe: Callable = _native_kali_or_parrot, +) -> BackendResolution: + """Resolve a backend without installing packages or starting containers.""" + choice = str(requested or "auto").strip().lower() + if choice not in {"auto", "local", "docker"}: + raise ValueError("backend must be auto, local, or docker") + host = platform.system().lower() + if choice == "local": + return BackendResolution(choice, "local", host) + if choice == "docker": + ready, detail = docker_probe(container, engagement) + if not ready: + raise ValueError(f"Docker backend unavailable: {detail}") + return BackendResolution(choice, "docker", host, container, detail) + if native_probe(): + return BackendResolution(choice, "local", host) + ready, detail = docker_probe(container, engagement) + if ready: + return BackendResolution(choice, "docker", host, container, detail) + return BackendResolution(choice, "local", host, fallback_reason=detail) + + +def runtime_readiness(engagement: Path) -> dict[str, object]: + resolution = resolve_backend("auto", engagement) + return { + "native_kali_or_parrot": _native_kali_or_parrot(), + "docker_executable": bool(shutil.which("docker")), + "auto": resolution.to_dict(), + } diff --git a/plugins/violin_guard/schemas.py b/plugins/violin_guard/schemas.py index 4beb55b..f5e46ea 100644 --- a/plugins/violin_guard/schemas.py +++ b/plugins/violin_guard/schemas.py @@ -103,7 +103,7 @@ EXEC_SCHEMA = { "target": {"type": "string", "description": "Explicit primary target host/IP/URL"}, "session_id": {"type": "string"}, "skill_loaded_file": {"type": "string"}, - "backend": {"type": "string", "enum": ["local", "docker"], "default": "local"}, + "backend": {"type": "string", "enum": ["auto", "local", "docker"], "default": "auto"}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 1800}, "cwd": {"type": "string", "description": "Engagement-relative working directory"}, "label": {"type": "string"}, @@ -223,7 +223,7 @@ EXEC_BURST_SCHEMA = { }, "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"}, + "backend": {"type": "string", "enum": ["auto", "local", "docker"], "default": "auto"}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 1800}, "cwd": {"type": "string", "description": "Engagement-relative working directory"}, "continue_on_error": {"type": "boolean", "default": False}, @@ -279,7 +279,7 @@ _ADAPTER_COMMON = { "target": {"type": "string"}, "session_id": {"type": "string"}, "skill_loaded_file": {"type": "string"}, - "backend": {"type": "string", "enum": ["local", "docker"], "default": "local"}, + "backend": {"type": "string", "enum": ["auto", "local", "docker"], "default": "auto"}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 1800}, "cwd": {"type": "string"}, "label": {"type": "string"}, diff --git a/plugins/violin_guard/service.py b/plugins/violin_guard/service.py index ce7a980..77c151b 100644 --- a/plugins/violin_guard/service.py +++ b/plugins/violin_guard/service.py @@ -9,7 +9,7 @@ import shlex from functools import wraps from pathlib import Path -from . import bootstrap, execution, findings, hypotheses, ptt, state +from . import bootstrap, execution, findings, hypotheses, ptt, runtime_backend, state from . import command as cmd_module from .adapters import ( build_ffuf, @@ -446,7 +446,7 @@ def handle_exec(a, **kwargs): command=a["command"], eng_dir=a["eng_dir"], phase=a["phase"], - backend=a.get("backend", "local"), + backend=a.get("backend", "auto"), timeout_seconds=a.get("timeout_seconds", 180), cwd=a.get("cwd", ""), label=a.get("label", ""), @@ -479,7 +479,7 @@ def handle_exec_burst(a, **kwargs): session_id = a.get("session_id", "") skill_loaded_file = a.get("skill_loaded_file", "") label = a.get("label", "") - backend = a.get("backend", "local") + backend = a.get("backend", "auto") timeout_seconds = a.get("timeout_seconds", 180) cwd = a.get("cwd", "") continue_on_error = bool(a.get("continue_on_error", False)) @@ -714,6 +714,7 @@ def handle_status(a, **kwargs): "loaded": skill_loaded, "marker": str(marker) if marker else None, }, + runtime=runtime_backend.runtime_readiness(eng_dir), ) diff --git a/tests/guard/state/test_runtime_backend.py b/tests/guard/state/test_runtime_backend.py new file mode 100644 index 0000000..e26da81 --- /dev/null +++ b/tests/guard/state/test_runtime_backend.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from plugins.violin_guard import execution +from plugins.violin_guard.runtime_backend import resolve_backend + + +def test_auto_uses_native_kali_or_parrot(tmp_path: Path) -> None: + resolution = resolve_backend( + "auto", tmp_path, native_probe=lambda: True, docker_probe=lambda *_: (False, "unused") + ) + assert resolution.resolved == "local" + assert not resolution.fallback_reason + + +def test_auto_uses_valid_docker_container(tmp_path: Path) -> None: + resolution = resolve_backend( + "auto", + tmp_path, + native_probe=lambda: False, + docker_probe=lambda *_: (True, "/engagements/x"), + ) + assert resolution.resolved == "docker" + assert resolution.mount == "/engagements/x" + + +def test_auto_falls_back_locally_with_a_reason(tmp_path: Path) -> None: + resolution = resolve_backend( + "auto", + tmp_path, + native_probe=lambda: False, + docker_probe=lambda *_: (False, "docker missing"), + ) + assert resolution.resolved == "local" + assert resolution.fallback_reason == "docker missing" + + +def test_explicit_docker_fails_when_mount_is_invalid(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="does not mount"): + resolve_backend( + "docker", tmp_path, docker_probe=lambda *_: (False, "does not mount /engagements/x") + ) + + +def test_docker_command_uses_engagement_mount(tmp_path: Path, monkeypatch) -> None: + eng = tmp_path / "assessment-a" + eng.mkdir() + monkeypatch.setattr(execution.shutil, "which", lambda _: "docker") + argv = execution._command_argv("id", "docker", eng, eng, "kali-pentest") + assert argv[:5] == ["docker", "exec", "-i", "-w", "/engagements/assessment-a"]