501 lines
18 KiB
Python
501 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Fail-closed command policy for the Hermes handoff acceptance harness.
|
|
|
|
Before spawn it admits only credential-safe, structurally read-only grammar;
|
|
shell is limited to frozen templates with absolute tool paths.
|
|
|
|
Impersonation is structurally impossible: ``--as`` and its siblings are refused
|
|
for every argv, in every mode, from every vantage, and the inner command of a
|
|
``kubectl exec`` is re-checked under the same rule rather than being trusted.
|
|
Only binaries with a pinned release digest are on the allowlist, so nothing can
|
|
be admitted here that the executor would then refuse to attest.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections.abc import Iterator
|
|
from urllib.parse import unquote
|
|
|
|
READ_ONLY = "read-only"
|
|
ARMED = "ephemeral-armed"
|
|
MODES = (READ_ONLY, ARMED)
|
|
|
|
GITEA_CLIENT = "/opt/scm/gitea_api.py"
|
|
EXPECTED_REPO = "titan/atlas-iac"
|
|
EXPECTED_REMOTE = "origin"
|
|
EXPECTED_BASE = "main"
|
|
|
|
# fmt: off
|
|
FORBIDDEN_ARGS = ("--as", "--as-group", "--as-uid", "--client-certificate",
|
|
"--client-key", "--password", "--token", "--username")
|
|
# fmt: on
|
|
IMPERSONATION_ARGS = ("--as", "--as-group", "--as-uid")
|
|
FORBIDDEN_BINARIES = frozenset(
|
|
{"age", "curl", "gpg", "openssl", "sops", "vault", "wget"}
|
|
)
|
|
ALLOWED_BINARIES = frozenset({"kubectl", "git", "hermes", "sh", GITEA_CLIENT})
|
|
TRUSTED_EXECUTABLE_ROOTS = (
|
|
"/bin/",
|
|
"/usr/bin/",
|
|
"/usr/local/bin/",
|
|
"/opt/data/tools/bin/",
|
|
"/opt/hermes/.venv/bin/",
|
|
"/opt/scm/",
|
|
)
|
|
TRUSTED_INNER_PATHS = {
|
|
"/bin/sh": "sh",
|
|
"/usr/bin/git": "git",
|
|
"/usr/bin/sh": "sh",
|
|
"/usr/local/bin/kubectl": "kubectl",
|
|
"/opt/hermes/.venv/bin/hermes": "hermes",
|
|
GITEA_CLIENT: GITEA_CLIENT,
|
|
}
|
|
|
|
VALUE_FLAGS = frozenset(
|
|
{
|
|
"-C",
|
|
"-c",
|
|
"-l",
|
|
"-n",
|
|
"-o",
|
|
"--cluster",
|
|
"--container",
|
|
"--context",
|
|
"--field-selector",
|
|
"--kubeconfig",
|
|
"--namespace",
|
|
"--output",
|
|
"--request-timeout",
|
|
"--selector",
|
|
"--server",
|
|
"--since",
|
|
"--tail",
|
|
"--user",
|
|
}
|
|
)
|
|
|
|
KUBECTL_READ_SUBCOMMANDS = frozenset(
|
|
{
|
|
"api-resources",
|
|
"api-versions",
|
|
"explain",
|
|
"get",
|
|
"top",
|
|
"version",
|
|
}
|
|
)
|
|
KUBECTL_EXEC_SUBCOMMANDS = frozenset({"exec"})
|
|
KUBECTL_SUBCOMMANDS = KUBECTL_READ_SUBCOMMANDS | KUBECTL_EXEC_SUBCOMMANDS | {"auth"}
|
|
SECRET_RESOURCES = frozenset({"secret", "secrets"})
|
|
UNSAFE_PROJECTION_PARTS = (
|
|
".args",
|
|
".command",
|
|
".data",
|
|
".envfrom",
|
|
".secretkeyref",
|
|
".status.token",
|
|
".stringdata",
|
|
".valuefrom",
|
|
"clientconfig",
|
|
"password",
|
|
"privatekey",
|
|
)
|
|
SAFE_ENV_VALUE = 'env[?(@.name=="hermes_auto_router_profile")].value'
|
|
SAFE_JSON_FIELDS = frozenset(["authenticated", "transport", "state", "model", "effort", "latency_ms", "checked_at", "api_provider", "auth_method", "subscription_type"]) # fmt: skip
|
|
|
|
GIT_READ_SUBCOMMANDS = frozenset(
|
|
{
|
|
"cat-file",
|
|
"diff",
|
|
"for-each-ref",
|
|
"log",
|
|
"ls-remote",
|
|
"ls-tree",
|
|
"merge-base",
|
|
"rev-list",
|
|
"rev-parse",
|
|
"show",
|
|
"status",
|
|
}
|
|
)
|
|
GIT_FORCE_FLAGS = frozenset(
|
|
{"-f", "--force", "--force-with-lease", "--mirror", "--all", "--tags"}
|
|
)
|
|
GIT_UNSAFE_FLAGS = (
|
|
"-c",
|
|
"--config-env",
|
|
"--exec-path",
|
|
"--upload-pack",
|
|
"--receive-pack",
|
|
)
|
|
HERMES_READ_COMMANDS = frozenset(
|
|
{("kanban", "list"), ("kanban", "show"), ("sessions", "list"), ("status",)}
|
|
)
|
|
|
|
# These locations contain credential bytes even when a caller claims it will
|
|
# only inspect metadata. They are rejected for every argv, including a frozen
|
|
# shell rendering and the inner command of kubectl exec.
|
|
SECRET_PATH_RE = re.compile(
|
|
r"(?:^|[\s'\"=])(?:/[^\s'\"]*)?(?:"
|
|
r"var/run/secrets|run/secrets|vault/secrets|runtime-access|scm-access|pool-access|"
|
|
r"\.credentials\.json|auth\.json|\.git-credentials|authorized_keys|"
|
|
r"id_[a-z0-9]+|\.netrc|shadow|gshadow|k3s\.yaml"
|
|
r")(?:$|[/\s'\"])",
|
|
re.IGNORECASE,
|
|
)
|
|
_PARAMETER_RE = re.compile(r"[A-Za-z0-9/][A-Za-z0-9._/@:,-]{0,255}\Z")
|
|
_PLACEHOLDER_RE = re.compile(r"\{([a-z_]+)\}")
|
|
_SAFE_API_PATH_RE = re.compile(r"/[A-Za-z0-9][A-Za-z0-9._~:/?&=%+-]{0,511}\Z")
|
|
|
|
SHELL_TEMPLATES: dict[str, str] = {
|
|
"env_names": (
|
|
"/usr/bin/env | /usr/bin/sed -n "
|
|
"'s/^\\([A-Za-z_][A-Za-z0-9_]*\\)=.*/\\1/p' | /usr/bin/sort"
|
|
),
|
|
"json_fields": (
|
|
'/opt/hermes/.venv/bin/python -c "import json,sys;d=json.load(open(sys.argv[1]));'
|
|
'print(json.dumps({{k:d.get(k) for k in sys.argv[2].split(chr(44))}}))" '
|
|
"{path} {fields}"
|
|
),
|
|
"tail_lines": "/usr/bin/tail -n {limit} {path}",
|
|
}
|
|
|
|
|
|
class PolicyError(ValueError):
|
|
"""Raised when a requested command falls outside the safe boundary."""
|
|
|
|
|
|
def template_parameters(template_name: str) -> set[str]:
|
|
"""Return the parameter names a frozen template requires."""
|
|
template = SHELL_TEMPLATES.get(template_name)
|
|
if template is None:
|
|
raise PolicyError(f"unknown shell template: {template_name}")
|
|
return set(_PLACEHOLDER_RE.findall(template))
|
|
|
|
|
|
def render_shell(template_name: str, **parameters: str) -> str:
|
|
"""Return a frozen shell template with validated parameters substituted."""
|
|
required = template_parameters(template_name)
|
|
if set(parameters) != required:
|
|
raise PolicyError(
|
|
f"shell template {template_name} expects parameters {sorted(required)}"
|
|
)
|
|
for key, value in parameters.items():
|
|
if not _PARAMETER_RE.fullmatch(value):
|
|
raise PolicyError(f"unsafe shell parameter for {template_name}.{key}")
|
|
if SECRET_PATH_RE.search(value):
|
|
raise PolicyError("shell templates may not name credential paths")
|
|
if (
|
|
template_name == "json_fields"
|
|
and not set(parameters["fields"].split(",")) <= SAFE_JSON_FIELDS
|
|
):
|
|
raise PolicyError("json_fields requested a credential-bearing or unknown field")
|
|
return SHELL_TEMPLATES[template_name].format(**parameters)
|
|
|
|
|
|
_RENDERED: set[str] = set()
|
|
_APPROVED_PROJECTIONS: set[str] = set()
|
|
_ARMED_REF = ""
|
|
_ARMED_PULLS: set[int] = set()
|
|
|
|
|
|
# fmt: off
|
|
def arm_ephemeral_policy(ref: str) -> None:
|
|
global _ARMED_REF
|
|
if not re.fullmatch(r"ephemeral/hermes-handoff-acceptance/[a-z0-9][a-z0-9-]{7,63}", ref):
|
|
raise PolicyError("armed policy requires one exact ephemeral ref")
|
|
_ARMED_REF = ref
|
|
_ARMED_PULLS.clear()
|
|
|
|
|
|
def register_ephemeral_pull(number: int) -> None:
|
|
if not _ARMED_REF or isinstance(number, bool) or not isinstance(number, int) or number <= 0:
|
|
raise PolicyError("ephemeral pull registration is malformed")
|
|
_ARMED_PULLS.add(number)
|
|
|
|
|
|
def shell(template_name: str, **parameters: str) -> tuple[str, ...]:
|
|
"""Return a validated ``sh -c`` argv built from a frozen template."""
|
|
script = render_shell(template_name, **parameters)
|
|
_RENDERED.add(script)
|
|
return ("sh", "-c", script)
|
|
|
|
|
|
def projection(value: str) -> str:
|
|
normalized = value.lower().replace(" ", "")
|
|
if not normalized.startswith("jsonpath=") or any(part in normalized for part in UNSAFE_PROJECTION_PARTS):
|
|
raise PolicyError("Kubernetes projection is not credential-safe")
|
|
if ".metadata.annotations" in normalized and not any(key in normalized for key in (
|
|
"deployment\\.kubernetes\\.io/revision", "ai\\.bstein\\.dev/config-rev")):
|
|
raise PolicyError("Kubernetes annotations are not on the reviewed allowlist")
|
|
if ".metadata.labels" in normalized and "pod-template-hash" not in normalized:
|
|
raise PolicyError("Kubernetes labels are not on the reviewed allowlist")
|
|
if ".env" in normalized and ".value" in normalized and SAFE_ENV_VALUE not in normalized:
|
|
raise PolicyError("Kubernetes projection may expose environment values")
|
|
_APPROVED_PROJECTIONS.add(normalized)
|
|
return value
|
|
# fmt: on
|
|
|
|
|
|
def positionals(argv: tuple[str, ...], start: int = 1) -> Iterator[str]:
|
|
"""Yield positional arguments, skipping flags and their values."""
|
|
index = start
|
|
while index < len(argv):
|
|
item = argv[index]
|
|
if item == "--":
|
|
return
|
|
if item.startswith("-"):
|
|
index += 2 if item in VALUE_FLAGS else 1
|
|
continue
|
|
yield item
|
|
index += 1
|
|
|
|
|
|
def _binary_name(value: str) -> str:
|
|
if value == GITEA_CLIENT or (
|
|
value.startswith("/opt/scm/") and value.endswith("/gitea_api.py")
|
|
):
|
|
return GITEA_CLIENT
|
|
if "/" not in value:
|
|
return value
|
|
mapped = TRUSTED_INNER_PATHS.get(value)
|
|
if mapped:
|
|
return mapped
|
|
if "/../" in value or value.endswith("/..") or "/./" in value:
|
|
raise PolicyError(f"executable path is not normalized: {value}")
|
|
if not value.startswith(TRUSTED_EXECUTABLE_ROOTS):
|
|
raise PolicyError(f"executable path is not trusted: {value}")
|
|
return value.rsplit("/", 1)[-1]
|
|
|
|
|
|
def _reject_forbidden_arguments(argv: tuple[str, ...]) -> None:
|
|
"""Refuse credential and impersonation arguments up to the ``--`` boundary.
|
|
|
|
Everything after ``--`` is the inner command of a ``kubectl exec``, which
|
|
:func:`_check_kubectl` submits to :func:`check_argv` again, so it is held to
|
|
exactly this rule rather than being exempted from it.
|
|
"""
|
|
boundary = argv.index("--") if "--" in argv else len(argv)
|
|
for argument in argv[:boundary]:
|
|
for forbidden in FORBIDDEN_ARGS:
|
|
if argument == forbidden or argument.startswith(f"{forbidden}="):
|
|
raise PolicyError(f"forbidden argument: {forbidden}")
|
|
if argument == "--raw" or argument.startswith("--raw="):
|
|
raise PolicyError("raw API reads are forbidden")
|
|
for argument in argv:
|
|
if argument == "--dry-run" or argument.startswith("--dry-run="):
|
|
raise PolicyError("dry-run mutation commands are forbidden")
|
|
|
|
|
|
def _reject_secret_paths(argv: tuple[str, ...]) -> None:
|
|
if any(SECRET_PATH_RE.search(argument) for argument in argv):
|
|
raise PolicyError("probes may not name a credential path")
|
|
|
|
|
|
def _resource_name(value: str) -> str:
|
|
return value.lower().split("/", 1)[0].split(".", 1)[0]
|
|
|
|
|
|
def _output_value(argv: tuple[str, ...]) -> str:
|
|
"""Return kubectl's output argument in a single canonical form."""
|
|
for index, item in enumerate(argv[1:], 1):
|
|
if item in {"-o", "--output"} and index + 1 < len(argv):
|
|
return argv[index + 1]
|
|
if item.startswith(("-o=", "--output=")):
|
|
return item.split("=", 1)[1]
|
|
return ""
|
|
|
|
|
|
def _check_projection(output: str) -> None:
|
|
"""Reject full or credential-bearing Kubernetes output projections."""
|
|
lowered = output.lower().replace(" ", "")
|
|
if lowered in {"json", "yaml", "wide"} or lowered.startswith("custom-columns"):
|
|
raise PolicyError(
|
|
"full Kubernetes object output is forbidden; use a safe projection"
|
|
)
|
|
if lowered and not (lowered == "name" or lowered.startswith("jsonpath=")):
|
|
raise PolicyError(
|
|
"Kubernetes output must be name or an approved JSONPath projection"
|
|
)
|
|
if lowered.startswith("jsonpath="):
|
|
if any(part in lowered for part in UNSAFE_PROJECTION_PARTS):
|
|
raise PolicyError("Kubernetes projection may expose runtime credentials")
|
|
if ".env" in lowered and SAFE_ENV_VALUE not in lowered:
|
|
# Environment names are safe; arbitrary `.value` reads are not.
|
|
without_names = lowered.replace("{.name}", "")
|
|
if ".value" in without_names:
|
|
raise PolicyError("Kubernetes projection may expose environment values")
|
|
if lowered not in _APPROVED_PROJECTIONS:
|
|
raise PolicyError(
|
|
"Kubernetes JSONPath projection is not on the reviewed allowlist"
|
|
)
|
|
|
|
|
|
def _check_kubectl(argv: tuple[str, ...], mode: str) -> None:
|
|
words = list(positionals(argv))
|
|
if not words or words[0] not in KUBECTL_SUBCOMMANDS:
|
|
raise PolicyError("kubectl subcommand is outside the read-only boundary")
|
|
subcommand = words[0]
|
|
if subcommand == "auth":
|
|
if len(words) < 2 or words[1] not in {"can-i", "whoami"}:
|
|
raise PolicyError("kubectl auth is limited to can-i and whoami")
|
|
return
|
|
if subcommand == "exec":
|
|
if "--" not in argv:
|
|
raise PolicyError("kubectl exec requires an explicit -- command boundary")
|
|
inner = argv[argv.index("--") + 1 :]
|
|
if not inner:
|
|
raise PolicyError("kubectl exec requires an inner command")
|
|
if inner[0] not in TRUSTED_INNER_PATHS:
|
|
raise PolicyError(
|
|
"kubectl exec inner command must use a fixed trusted path"
|
|
)
|
|
check_argv(inner, mode)
|
|
return
|
|
resources = words[1].split(",") if len(words) > 1 else []
|
|
if any(_resource_name(resource) in SECRET_RESOURCES for resource in resources):
|
|
raise PolicyError("Secret objects may not be read by the harness")
|
|
output = _output_value(argv)
|
|
if subcommand == "get" and not output:
|
|
raise PolicyError("kubectl get requires a reviewed output projection")
|
|
_check_projection(output)
|
|
|
|
|
|
def _check_git(argv: tuple[str, ...], mode: str) -> None:
|
|
unsafe = False
|
|
for item in argv[1:]:
|
|
if item == "-C" or item.startswith(("--git-dir", "--work-tree", "--namespace")):
|
|
unsafe = True
|
|
if item == "-c" or (item.startswith("-c") and len(item) > 2):
|
|
unsafe = True
|
|
if any(
|
|
item == flag or item.startswith(f"{flag}=") for flag in GIT_UNSAFE_FLAGS[1:]
|
|
):
|
|
unsafe = True
|
|
if unsafe:
|
|
raise PolicyError("git configuration and helper overrides are forbidden")
|
|
subcommand = next(positionals(argv), None)
|
|
if subcommand is None:
|
|
raise PolicyError("git requires a subcommand")
|
|
if subcommand in GIT_READ_SUBCOMMANDS:
|
|
return
|
|
if subcommand == "push" and mode == ARMED:
|
|
if GIT_FORCE_FLAGS & set(argv):
|
|
raise PolicyError("git push may not force or fan out across refs")
|
|
words = list(positionals(argv))
|
|
if len(words) != 3 or words[1] != EXPECTED_REMOTE:
|
|
raise PolicyError("armed git push is pinned to origin and one refspec")
|
|
destination = words[2].partition(":")[2]
|
|
if not _ARMED_REF or destination != f"refs/heads/{_ARMED_REF}":
|
|
raise PolicyError("armed git push is not the preflighted ephemeral ref")
|
|
return
|
|
raise PolicyError(f"git {subcommand} is not permitted in {mode} mode")
|
|
|
|
|
|
def _check_hermes(argv: tuple[str, ...]) -> None:
|
|
words = tuple(positionals(argv))
|
|
if not words:
|
|
raise PolicyError("hermes requires a subcommand")
|
|
if words[:2] not in HERMES_READ_COMMANDS and words[:1] not in HERMES_READ_COMMANDS:
|
|
raise PolicyError(
|
|
f"hermes {' '.join(words[:2])} is outside the read-only boundary"
|
|
)
|
|
|
|
|
|
def _reject_dot_segments(path: str) -> None:
|
|
"""Refuse any relative segment, including a percent-encoded one.
|
|
|
|
The repository pin below is a textual prefix test, so a single ``..`` would
|
|
let a pinned path address another repository or the token endpoints once a
|
|
router normalised it. Decode first: ``%2e%2e`` is the same segment.
|
|
"""
|
|
decoded = unquote(unquote(path)).split("?", 1)[0]
|
|
if any(segment in {"", ".", ".."} for segment in decoded.split("/")[1:]):
|
|
raise PolicyError("gitea_api.py paths may not contain relative segments")
|
|
|
|
|
|
def _check_gitea(argv: tuple[str, ...], mode: str) -> None:
|
|
"""Admit the deployed broker client's only read grammar: ``read <path>``.
|
|
|
|
Bare HTTP methods are refused before spawn. The armed mutation windows
|
|
below belong to the deferred armed mode, whose forge mutations the
|
|
deployed broker-routed client cannot execute yet.
|
|
"""
|
|
if len(argv) < 3:
|
|
raise PolicyError("gitea_api.py requires an operation and a path")
|
|
operation, path = argv[1], argv[2]
|
|
if not path.startswith("/api/v1/") or not _SAFE_API_PATH_RE.fullmatch(path):
|
|
raise PolicyError("gitea_api.py requires a bounded API path")
|
|
_reject_dot_segments(path)
|
|
repo_root = f"/api/v1/repos/{EXPECTED_REPO}"
|
|
if (
|
|
path != "/api/v1/user"
|
|
and path != repo_root
|
|
and not path.startswith((f"{repo_root}/", f"{repo_root}?"))
|
|
):
|
|
raise PolicyError("gitea_api.py is pinned to the Titan atlas-iac repository")
|
|
if operation == "read":
|
|
if len(argv) != 3:
|
|
raise PolicyError("gitea_api.py read takes exactly one API path")
|
|
return
|
|
if mode == ARMED:
|
|
fields = [
|
|
argv[index + 1] for index, item in enumerate(argv[:-1]) if item == "--field"
|
|
]
|
|
if operation == "POST" and path == f"{repo_root}/pulls" and _ARMED_REF:
|
|
required = {
|
|
"title=WIP: Hermes handoff acceptance ephemeral probe",
|
|
f"head={_ARMED_REF}",
|
|
f"base={EXPECTED_BASE}",
|
|
"body=Ephemeral acceptance probe. Closed and deleted by the harness.",
|
|
}
|
|
if len(fields) == len(required) and set(fields) == required:
|
|
return
|
|
match = re.fullmatch(rf"{re.escape(repo_root)}/pulls/([1-9][0-9]*)", path)
|
|
if (
|
|
operation == "PATCH"
|
|
and match
|
|
and int(match.group(1)) in _ARMED_PULLS
|
|
and fields == ["state=closed"]
|
|
):
|
|
return
|
|
if (
|
|
operation == "DELETE"
|
|
and _ARMED_REF
|
|
and path == f"{repo_root}/branches/{_ARMED_REF}"
|
|
and len(argv) == 3
|
|
):
|
|
return
|
|
raise PolicyError(f"gitea_api.py {operation} is not permitted in {mode} mode")
|
|
|
|
|
|
def _check_shell(argv: tuple[str, ...]) -> None:
|
|
if len(argv) != 3 or argv[1] != "-c" or argv[2] not in _RENDERED:
|
|
raise PolicyError("sh is only reachable as sh -c <frozen template>")
|
|
|
|
|
|
def check_argv(argv: tuple[str, ...], mode: str = READ_ONLY) -> None:
|
|
"""Raise :class:`PolicyError` unless ``argv`` is safe in ``mode``."""
|
|
if mode not in MODES:
|
|
raise PolicyError(f"unknown mode: {mode}")
|
|
if not argv:
|
|
raise PolicyError("empty command")
|
|
binary = _binary_name(argv[0])
|
|
if binary in FORBIDDEN_BINARIES:
|
|
raise PolicyError(f"forbidden binary: {binary}")
|
|
if binary not in ALLOWED_BINARIES:
|
|
raise PolicyError(f"binary is not on the harness allowlist: {binary}")
|
|
_reject_forbidden_arguments(argv)
|
|
_reject_secret_paths(argv)
|
|
if binary == "sh":
|
|
_check_shell(argv)
|
|
elif binary == "kubectl":
|
|
_check_kubectl(argv, mode)
|
|
elif binary == "git":
|
|
_check_git(argv, mode)
|
|
elif binary == "hermes":
|
|
_check_hermes(argv)
|
|
else:
|
|
_check_gitea(argv, mode)
|