mirror of
https://github.com/Strategic-Automation/violin.git
synced 2026-08-14 12:33:37 +02:00
276 lines
9.8 KiB
Python
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"]
|