atlas-iac/scripts/ops/hermes_handoff_policy.py
Hermes Agent d208a89d9d fix(hermes): close the zero-evidence fail-open in absence checks
evaluate_names_absent returned PASS when its step exited 0 with no output,
so five mandatory checks - the ones asserting that provider API keys, forge
credentials, a cluster-admin binding, and shared coordinator state are
absent - could report a pass on no evidence and turn a NO_GO into a GO.
Both name rules now resolve their step through one guard in _line_step, so
zero observations are NOT_RUN. Regressions pin all five real catalog specs
plus both reachable silence paths: a POSIX pipeline whose status comes from
its last stage, and a drifted kubectl -o jsonpath. The pool claim projection
emits one <volume>=<claim> line per template volume so a volume without a
PVC still counts as an observation rather than reading as drift.

Also closes the review's reachable hardening and evidence defects:

- pin Gitea paths to atlas/titan-iac on an exact segment boundary and
  reject relative segments, including percent-encoded ones
- forbid impersonation structurally in every mode and vantage; the inner
  command of kubectl exec is re-checked rather than exempted, and
  validate_catalog no longer guards only the operator vantage
- drop flux and helm from the binary allowlist; they had no pinned release
  digest, so no allowlisted binary can now be admitted that the executor
  would refuse to attest
- remove the inert --concurrency and --expect-telegram-sessions flags and
  the dead concurrency bound; Telegram continuity stays mandatory
- read the ephemeral pull index page by page, treat the create response as
  an authoritative source for the pull number, close every number either
  source names, and surface residue_ref plus exact manual_cleanup commands
  when creation is uncertain
- keep executable_path and executable_sha256 on unrecorded bulk-evidence
  steps so withholding bytes never withholds binary attestation
- revert the repo-wide hygiene legacy-exception mechanism; the contract
  change here is purely additive and the four pre-existing over-cap files
  are left to the canonical contract change in PR #14/#15
- correct the runbook ruff format scope so the documented command passes

Split hermes_handoff_arming.py out of hermes_handoff_ephemeral.py to keep
both modules under the 500-line cap. All 16 handoff modules hold at least
95% line and branch coverage; the mutation gate is 13/13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:24:37 +00:00

494 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/coordinator/gitea_api.py"
EXPECTED_REPO = "atlas/titan-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/coordinator/",
)
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",
)
GITEA_READ_METHODS = frozenset({"GET"})
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/coordinator/") 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:
if len(argv) < 3:
raise PolicyError("gitea_api.py requires a method and a path")
method, path = argv[1].upper(), 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 Atlas titan-iac repository")
if method in GITEA_READ_METHODS:
return
if mode == ARMED:
fields = [
argv[index + 1] for index, item in enumerate(argv[:-1]) if item == "--field"
]
if method == "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 (
method == "PATCH"
and match
and int(match.group(1)) in _ARMED_PULLS
and fields == ["state=closed"]
):
return
if (
method == "DELETE"
and _ARMED_REF
and path == f"{repo_root}/branches/{_ARMED_REF}"
and len(argv) == 3
):
return
raise PolicyError(f"gitea_api.py {method} 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)