Files
violin/plugins/violin_guard/terminal_policy.py
T

276 lines
9.8 KiB
Python

"""Policy for the built-in Hermes terminal tool.
Violin keeps the built-in ``terminal`` tool available for host-local work, but
raw terminal calls must not become an escape hatch around the typed Violin
execution boundary. This module is intentionally a conservative, pure
classifier: it blocks commands that are clearly target-touching and leaves
ordinary local development/bookkeeping commands available.
The typed ``violin_exec`` and ``violin_exec_burst`` tools remain the authoritative
path for target commands because they carry the engagement, scope, phase, PTT,
hypothesis, history, evidence, and sync arguments needed by the full guard.
"""
from __future__ import annotations
import contextlib
import ipaddress
import re
import shlex
from urllib.parse import urlsplit
from .terminal_rules import (
_COMMAND_SPLIT_RE,
_COMMAND_SUBSTITUTION_RE,
_DOMAIN_RE,
_IPV4_RE,
_KNOWN_SOURCE_HOSTS,
_LOCAL_COMMANDS,
_LOCAL_FILE_SUFFIXES,
_NETWORK_MODULE_RE,
_NETWORK_PATH_RE,
_PACKAGE_OR_SOURCE_COMMANDS,
_SCRIPT_INTERPRETERS,
_SHELL_WRAPPERS,
_SUSPICIOUS_SCRIPT_RE,
_URL_RE,
)
def _command_words(segment: str) -> list[str]:
try:
return shlex.split(segment, posix=True)
except ValueError:
# An incomplete quote is not a reason to let a possibly dangerous
# command through. The fallback is only used for classification.
return re.findall(r"[^\s]+", segment)
def _basename(value: str) -> str:
return re.split(r"[/\\]", value.rsplit("=", 1)[-1])[-1].lower()
def _first_executable(segment: str) -> str:
words = _command_words(segment)
index = 0
while index < len(words):
word = words[index]
lower = word.lower()
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", word):
index += 1
continue
if lower in {"command", "env", "exec", "nice", "sudo", "timeout"}:
index += 1
if lower == "timeout" and index < len(words):
index += 1
continue
return _basename(word)
return ""
def _is_package_or_source_command(command: str) -> bool:
executable = _first_executable(command)
return executable in _PACKAGE_OR_SOURCE_COMMANDS
def _url_hosts(command: str) -> list[str]:
hosts: list[str] = []
for match in _URL_RE.finditer(command):
try:
host = urlsplit(match.group(0)).hostname
except ValueError:
host = None
if host:
hosts.append(host)
return hosts
def _is_known_source_host(host: str) -> bool:
normalized = host.lower().rstrip(".")
return any(
normalized == known or normalized.endswith(f".{known}") for known in _KNOWN_SOURCE_HOSTS
)
def _word_is_target_literal(word: str) -> bool:
"""Inspect a single shell word token for an IP address, remote URL, or domain literal."""
value = word.strip("'\"()[]{}<>,")
authority = value.rsplit("@", 1)[-1]
if authority.count(":") == 1:
authority = authority.split(":", 1)[0]
if _IPV4_RE.fullmatch(authority):
return authority not in {"127.0.0.1", "0.0.0.0"}
with contextlib.suppress(ValueError):
clean_ip = authority.strip("[]")
ip_obj = ipaddress.ip_address(clean_ip)
return not ip_obj.is_loopback and not ip_obj.is_unspecified
if "://" in value:
try:
hostname = urlsplit(value).hostname
return bool(
hostname and hostname.lower() not in {"localhost", "127.0.0.1", "0.0.0.0", "::1"}
)
except ValueError:
return True
# Paths and ordinary local scripts are not host literals
if "/" in value or "\\" in value or value.startswith("."):
return False
if authority.lower() in {"localhost"}:
return False
if any(value.lower().endswith(suffix) for suffix in _LOCAL_FILE_SUFFIXES):
return False
return bool(_DOMAIN_RE.fullmatch(authority))
def _has_target_literal(command: str) -> bool:
"""Inspect shell arguments, not arbitrary source code or file paths."""
for segment in _COMMAND_SPLIT_RE.split(command):
words = _command_words(segment)
executable = _first_executable(segment)
# Shell `-c` strings are commands and must still be inspected. Source
# passed to language runtimes is skipped to avoid classifying an IP
# literal inside ordinary local code as a network action.
skip_code = (
executable in _SCRIPT_INTERPRETERS
and executable not in _SHELL_WRAPPERS
and "-c" in words
)
c_index = words.index("-c") if skip_code else -1
for index, word in enumerate(words):
if skip_code and index > c_index:
continue
if _word_is_target_literal(word):
return True
return False
def _is_violin_init_command(segment: str) -> bool:
"""Return whether ``segment`` invokes Violin's host-local bootstrap command."""
if _first_executable(segment) not in {"python", "python3"}:
return False
words = _command_words(segment)
for index, word in enumerate(words):
script = word.replace("\\", "/").removeprefix("./")
if (
(script == "scripts/violin_guard.py" or script.endswith("/scripts/violin_guard.py"))
and index + 1 < len(words)
and words[index + 1] == "init-engagement"
):
return True
return False
def _dynamic_init_host(segment: str) -> bool:
"""Reject host indirection while allowing variables in local path arguments."""
words = _command_words(segment)
for index, word in enumerate(words):
if word == "--host" and index + 1 < len(words):
return "$" in words[index + 1] or "`" in words[index + 1]
if word.startswith("--host="):
host = word.partition("=")[2]
return "$" in host or "`" in host
return False
def _is_local_compilation_or_test(segment: str) -> bool:
"""Return True if the command is a local syntax compile check or test framework invocation."""
words = _command_words(segment)
lower_words = [w.lower() for w in words]
if "-m" in lower_words:
idx = lower_words.index("-m")
if idx + 1 < len(lower_words) and lower_words[idx + 1] in {
"py_compile",
"pytest",
"unittest",
"doctest",
}:
return True
return "py_compile" in segment or "pytest" in lower_words or "unittest" in lower_words
def _block_terminal_segment(segment: str) -> str | None:
if _NETWORK_PATH_RE.search(segment):
return _message("network socket path detected in the raw terminal command")
executable = _first_executable(segment)
if executable in _SCRIPT_INTERPRETERS and _NETWORK_MODULE_RE.search(segment):
return _message("network-capable script primitive detected in the raw terminal command")
# Package/source retrieval is allowed for local setup (for example git
# clone or pip install). URLs and host literals in all other commands are
# treated as target interaction and must use the typed guard.
is_source_command = _is_package_or_source_command(segment)
url_hosts = _url_hosts(segment)
if not is_source_command and url_hosts:
return _message("URL detected in a non-package raw terminal command")
# Public package/source URLs are host-local setup, not assessment traffic.
# Keep numeric authorities conservative: a clone/install from an IP may be
# an engagement target and must go through the typed guard.
if (
is_source_command
and url_hosts
and all(_is_known_source_host(host) for host in url_hosts)
and not _IPV4_RE.search(segment)
):
return None
# ``init-engagement`` writes local workspace files and creates no network
# traffic, so its scope host may be provided directly. Keep the exception
# narrow: other guard subcommands still use the normal classifier, and
# target values hidden behind shell expansion remain blocked.
if _is_violin_init_command(segment):
if _COMMAND_SUBSTITUTION_RE.search(segment) or _dynamic_init_host(segment):
return _message(
"dynamic init-engagement host detected; pass --host directly without "
"shell or file indirection"
)
return None
if executable not in _LOCAL_COMMANDS and _has_target_literal(segment):
return _message("target host literal detected in the raw terminal command")
if (
executable in _SCRIPT_INTERPRETERS
and _SUSPICIOUS_SCRIPT_RE.search(segment)
and not _is_local_compilation_or_test(segment)
):
return _message("assessment script detected in the raw terminal command")
return None
def block_terminal_command(command: str) -> str | None:
"""Return a block message for clearly target-touching raw terminal calls.
``None`` means the command is host-local enough to remain available through
the built-in terminal. This is not a replacement for scope validation;
it is the escape-hatch prevention layer that forces target work through the
typed Violin tools.
"""
if not isinstance(command, str) or not command.strip():
return None
for segment in _COMMAND_SPLIT_RE.split(command):
if message := _block_terminal_segment(segment):
return message
return None
def _message(reason: str) -> str:
return (
"RAW TERMINAL TARGET EXECUTION BLOCKED by Violin: "
f"{reason}. Use `violin_exec` for one command or `violin_exec_burst` "
"for a bounded batch so scope, phase, PTT, hypotheses, history, "
"evidence, and sync gates are enforced. The built-in terminal remains "
"available for host-local preparation, tests, builds, and bookkeeping."
)
__all__ = ["block_terminal_command"]