"""Safe CLI executor backend — real read-only `gh` / `docker`, gated writes. WHAT THIS IS ------------ This is the fourth and most operationally sensitive execution backend. It stands in for an operator at a terminal running `gh` (GitHub CLI) and `docker`. In a real SOC that is exactly the kind of power you want on the tightest possible leash, so this backend splits its actions sharply by blast radius: * READS (low-tier) — docker_ps, docker_logs, (REALLY EXECUTE; gh_run_list, gh_pr_list they change nothing) * STATE-CHANGES (L3) — docker_stop, docker_restart, (approval-gated AND gh_issue_create simulated; never run) * DESTRUCTIVE (always denied) — docker_rm, rm_rf, ... (refused outright) WHAT REALLY RUNS, AND WHAT NEVER DOES ------------------------------------- Read-only inspections **actually execute** as subprocesses and return the tool's real output — `docker ps` really lists containers, `gh pr list` really queries GitHub. They are safe to run for real precisely because they only *read*: there is no state to change and nothing to undo. State-changing commands are the opposite. They are approval-gated at L3 (see ``policies/agt_policy.yaml``), and even once an operator approves them this backend **does not shell out** — it returns a simulated/dry-run result. So nothing here can ever stop a real container or open a real issue. (This backend never performs a real *write*.) TWO SAFETY PROPERTIES OF THE REAL EXECUTION PATH ------------------------------------------------ 1. **No shell.** Commands are run as an argument vector (``["gh", "pr", "list", ...]``) with ``shell=False``. There is no shell to interpret metacharacters, so a value can never break out into a second command. This is a deliberate improvement over the naive ``shell=True`` pattern. 2. **Validated arguments.** Any value interpolated into a command (e.g. a container id) is checked against a conservative pattern *before* execution, and a value that looks like a flag or contains shell metacharacters is refused. So even the argv path cannot be used to smuggle in an unexpected flag or token. WHERE IT SITS IN THE TRUST MODEL (same as every backend) -------------------------------------------------------- gate decides ─► dispatcher verifies grant ─► THIS backend executes This backend never decides *whether* an action is allowed — that already happened upstream. By the time :meth:`run` is called, the gate has said ``ALLOW`` and the dispatcher has verified the gate's signed grant. It still keeps its own defense-in-depth guards: an allow-list of handlers, an explicit destructive deny-list, and the argument validation above. """ from __future__ import annotations import re import shutil import subprocess from typing import Any, Callable from control_plane.schema import ProposedAction class CliExecutorError(Exception): """Base error for anything this backend refuses or cannot do.""" class CliUnsupportedActionError(CliExecutorError): """The requested action is not in this backend's allow-list of safe handlers. Defense in depth: anything that isn't one of the allowlisted reads/state-changes below lands here and is refused rather than executed. """ class CliDestructiveActionError(CliExecutorError): """The requested action is a known destructive command and is always denied. Refused *before* any execution and with a distinct error type, so a destructive verb (e.g. ``docker_rm``, ``rm_rf``) can never be quietly treated as a merely-unknown action. No tier or approval unlocks these. """ class CliArgumentError(CliExecutorError): """A command argument was missing or failed validation (refused before execution). Raised for a value that looks like a flag, contains shell metacharacters, or is otherwise outside the safe pattern — caught here so it can never reach the real subprocess. """ class CliToolUnavailableError(CliExecutorError): """A read was requested but its CLI tool (``gh`` / ``docker``) is not installed.""" # Known-destructive verbs this backend refuses outright (destructive # set, plus CLI-specific equivalents). Belt-and-braces: none of these has a handler # either, so they would be refused regardless — naming them here just produces a # clearer, security-relevant error. Illustrative, not exhaustive; the real # guarantee is the allow-list (anything not explicitly handled is denied). _DESTRUCTIVE_ACTIONS: frozenset[str] = frozenset( { "docker_rm", # delete a container "docker_kill", # force-kill (vs. graceful stop) "docker_system_prune", # bulk-delete containers/images/volumes "gh_repo_delete", # delete a repository "rm_rf", # the classic "rm -rf" "delete_logs", # also in the policy's always_denied set "remove_audit_records", "exfiltrate_data", "disable_security_controls", "destructive_wildcard", } ) # Safe shape for any value interpolated into a real command: starts with an # alphanumeric, then alphanumerics and a few benign id characters. This rejects # leading dashes (so a value can't pose as a flag) and every shell metacharacter. _SAFE_ARG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:-]*$") # How long a real read may run before we give up on it. _DEFAULT_TIMEOUT_SECONDS = 15.0 class CliExecutorBackend: """Safe `gh`/`docker` adapter satisfying the dispatcher's ``BackendAdapter`` contract. Construct it once and let the dispatcher call :meth:`run` per authorized action. Read handlers build an argv and execute it for real; state-change handlers return a simulated result and never shell out. """ def __init__(self, timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS) -> None: self._timeout = timeout_seconds # action_name -> argv builder. Membership here is what makes a read # executable, and the names match the policy's cli_executor allow list # (policies/agt_policy.yaml). Each builder returns a full argv (list), so # the command is always run without a shell. self._read_builders: dict[str, Callable[[dict[str, Any]], list[str]]] = { "docker_ps": self._build_docker_ps, "docker_logs": self._build_docker_logs, "gh_run_list": self._build_gh_run_list, "gh_pr_list": self._build_gh_pr_list, } # action_name -> simulated handler. These are the approval-gated # state-changes; they never execute, so they produce a dry-run result only. self._state_change_handlers: dict[str, Callable[[dict[str, Any]], Any]] = { "docker_stop": self._simulate_docker_stop, "docker_restart": self._simulate_docker_restart, "gh_issue_create": self._simulate_gh_issue_create, } @property def command_surface(self) -> frozenset[str]: """Every action name this backend recognises (reads + state-changes). Exposed so the CLI-safety test can assert, from the outside, that no kubernetes action exists anywhere in what this backend can be asked to do. """ return frozenset(self._read_builders) | frozenset(self._state_change_handlers) # -- dispatcher entry point ------------------------------------------------ def run(self, action: ProposedAction) -> Any: """Execute *action* and return its result. Resolution order, with every refusal happening *before* any command runs: 1. a known-destructive action raises :class:`CliDestructiveActionError`; 2. a read action builds an argv and **really executes** it; 3. a state-change action returns a **simulated** result (never shells out); 4. anything else raises :class:`CliUnsupportedActionError`. """ # (1) Destructive verbs are denied first and loudest — no tier unlocks them. if action.action_name in _DESTRUCTIVE_ACTIONS: raise CliDestructiveActionError( f"cli_executor refuses destructive action {action.action_name!r} " f"(always denied; no approval path)" ) # (2) Read-only inspections really run. builder = self._read_builders.get(action.action_name) if builder is not None: argv = builder(action.arguments) # may raise CliArgumentError pre-exec return self._execute(argv) # (3) State-changes are simulated only, even though they reach here only # after approval (the gate gates them; this backend still never writes). handler = self._state_change_handlers.get(action.action_name) if handler is not None: return handler(action.arguments) # (4) Unknown to this backend → refused, never guessed at. raise CliUnsupportedActionError( f"cli_executor backend cannot perform {action.action_name!r} " f"(not in its allow-list of safe gh/docker commands)" ) # -- read argv builders (real, read-only) ---------------------------------- def _build_docker_ps(self, args: dict[str, Any]) -> list[str]: """``docker ps`` — list running containers as JSON lines (read-only).""" return ["docker", "ps", "--format", "{{json .}}"] def _build_docker_logs(self, args: dict[str, Any]) -> list[str]: """``docker logs --tail N `` — read recent logs (read-only).""" container_id = _safe(args, "container_id") return ["docker", "logs", "--tail", "50", container_id] def _build_gh_run_list(self, args: dict[str, Any]) -> list[str]: """``gh run list`` — list recent CI workflow runs (read-only).""" return ["gh", "run", "list", "--limit", "10"] def _build_gh_pr_list(self, args: dict[str, Any]) -> list[str]: """``gh pr list`` — list open pull requests (read-only).""" return ["gh", "pr", "list", "--state", "open", "--limit", "10"] # -- state-change simulations (approval-gated; never executed) ------------- def _simulate_docker_stop(self, args: dict[str, Any]) -> dict[str, Any]: """Simulate ``docker stop `` — report intent only, run nothing. This is the "state-change, approval-gated at L3" case. By the time it runs, an operator has approved it — but this backend still does not shell out, so **nothing is actually stopped**. """ container_id = _safe(args, "container_id") return _simulated("docker stop", container_id, new_status="exited") def _simulate_docker_restart(self, args: dict[str, Any]) -> dict[str, Any]: """Simulate ``docker restart `` — report intent only, run nothing.""" container_id = _safe(args, "container_id") return _simulated("docker restart", container_id, new_status="running") def _simulate_gh_issue_create(self, args: dict[str, Any]) -> dict[str, Any]: """Simulate ``gh issue create`` — return the issue that *would* be opened. No issue is filed anywhere; the number is a fixed placeholder to make clear this is a simulation, not a real GitHub object. """ title = _safe(args, "title", allow_spaces=True) result = _simulated("gh issue create", title) result.update({"issue_number": 9001, "title": title, "state": "open"}) return result # -- the one real-execution helper ----------------------------------------- def _execute(self, argv: list[str]) -> dict[str, Any]: """Run *argv* as a real subprocess (no shell) and return its actual output. A non-zero exit is **not** an exception — it is captured and returned, so the caller (and the agent) sees the command's real result (e.g. a `docker` daemon that is down). Only a missing tool or a timeout is surfaced as an error, because in those cases there is no command result to report. """ if shutil.which(argv[0]) is None: raise CliToolUnavailableError( f"CLI tool {argv[0]!r} is not installed; cannot run {' '.join(argv)!r}" ) try: # shell=False (the default) is the security-critical choice: argv is # passed straight to execve, so there is no shell to interpret any value. proc = subprocess.run( argv, capture_output=True, text=True, timeout=self._timeout, check=False, ) except subprocess.TimeoutExpired: return { "command": " ".join(argv), "argv": argv, "executed": True, "timed_out": True, "returncode": None, } return { "command": " ".join(argv), "argv": argv, "executed": True, # a real subprocess ran "timed_out": False, "returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr, } # Module-level helpers keep the handlers above short and uniform. def _safe(args: dict[str, Any], key: str, *, allow_spaces: bool = False) -> str: """Return ``args[key]`` if present and safe to interpolate, else refuse. The value must be a non-empty string matching the conservative :data:`_SAFE_ARG` pattern (no leading dash, no shell metacharacters). ``allow_spaces`` relaxes the check for free-text fields like an issue title, while still rejecting shell metacharacters. Refusing here means an unsafe value never reaches the subprocess. """ if key not in args or args[key] in (None, ""): raise CliArgumentError(f"missing required argument {key!r}") value = args[key] if not isinstance(value, str): raise CliArgumentError(f"argument {key!r} must be a string, got {type(value).__name__}") if allow_spaces: # Free text: forbid the shell metacharacters and control characters that # could matter if the value were ever logged or re-used, but allow spaces. if any(ch in value for ch in ";|&$`<>\n\r\\\"'") or value.startswith("-"): raise CliArgumentError(f"argument {key!r} contains unsafe characters: {value!r}") return value if not _SAFE_ARG.match(value): raise CliArgumentError( f"argument {key!r} is not a safe identifier: {value!r} " f"(must start alphanumeric; no flags or shell metacharacters)" ) return value def _simulated(command: str, target: str, **extra: Any) -> dict[str, Any]: """Build the standard simulated/dry-run envelope for a state-change. Carries ``simulated``/``executed`` flags so no consumer can mistake a simulated state-change for a real one; nothing here ever ran as a subprocess. """ return { "command": command, "target": target, "executed": False, # state-changes never shell out "simulated": True, "persisted": False, "note": "Approval-gated state-change; simulated only, no real command executed.", **extra, }