atlas-iac/scripts/ops/hermes_handoff_policy.py
Hermes Agent 8f00545828 hermes: add a fail-closed full-handoff acceptance harness
Decides whether the Hermes platform handoff is fit to release, and refuses
to round an absence of evidence up to a pass.

The harness is read-only by default and classifies 71 checks PASS / FAIL /
NOT_RUN / NOT_APPLICABLE. Any mandatory FAIL or NOT_RUN is NO_GO, and so is a
harness-level problem: an unreachable vantage, a catalog entry whose evidence
no longer exists, an expired deadline, or an evaluator that raised.

Evidence comes from two vantages that cannot cover for each other: an external
read-only operator kubeconfig, and the Hermes agent probing itself from inside
its own pod. Before any check runs, the harness asks each vantage who it is and
stops if they are the same principal, because dual-vantage evidence from one
identity is a restatement rather than a corroboration. `--as` is rejected for
every operator-side command and reachable only as the inner command of a
`kubectl exec`, so impersonation can never stand in for a real self-probe. A
deny check needs a live refused request, not only an authorization review.

Two safety properties are structural rather than conventional, enforced where
an argv becomes a subprocess: the default mode mutates nothing (mutating verbs
require a server dry run; there is deliberately no live TokenRequest probe,
because a successful one would mint a real credential), and no probe can pull a
credential value into a report (no vault/sops/curl, secrets readable only with
-o name, environment probes list names, shell only through frozen reviewed
templates). Captures are bounded before they are screened, and the rendered
report is re-screened before it is written.

Mutation lives behind a separate arming flag with an exact confirmation phrase,
a caller-supplied unique ref, a preflight that refuses a protected push target
before any network call, and a cleanup whose verification is itself mandatory.
A default run reports those four checks NOT_RUN.

The catalog is declarative so a reviewer reads what is asserted rather than how
it is plumbed, and so structural properties can be proven over every entry
before a run. Catalog drift surfaces as NOT_RUN, which stops the release.

docs/hermes_full_handoff_acceptance.md carries the merge order for PRs #14-#18
on top of the merged #13 baseline, the image build and Flux rollout, the
rollback point for each step, the go/no-go checklist, and the limits that are
asserted rather than exercised.

Validation: 295 handoff tests pass with 100% line coverage on all 15 new
modules; the full unit suite is 647 passed with two failures that reproduce
unchanged on origin/main; Ruff, py_compile, kustomize render, and a diff
credential screen are clean; a live read-only run against Atlas returns NO_GO
for the pre-merge cluster with no unscreened fields in the report.
2026-08-17 10:14:17 +00:00

378 lines
15 KiB
Python

#!/usr/bin/env python3
"""Fail-closed command policy for the Hermes handoff acceptance harness.
Two properties have to hold structurally, not by convention:
* the default run mutates nothing, and
* no probe can pull a credential value into the report.
Both are enforced here, where an argv becomes a subprocess, so a mistake in the
check catalog surfaces as a policy error when the catalog is built rather than
as a mutation or a leak when it runs. `--as` is rejected on the operator side:
impersonation is not evidence about what Hermes can do, and a harness able to
impersonate would be tempted to substitute it for a real self-probe. It stays
available to the inner command of a `kubectl exec`, where an identity is
probing its own refusal to impersonate rather than borrowing authority.
Shell is reachable only through frozen templates. Free-form `sh -c` cannot be
screened reliably, but a few probes genuinely need field extraction (report that
a password field is locked without ever reading its hash) or an explicit helper
path, so those scripts are named, reviewed here, and parameter-validated instead
of assembled by callers.
"""
from __future__ import annotations
import re
from collections.abc import Iterator
READ_ONLY = "read-only"
ARMED = "ephemeral-armed"
MODES = (READ_ONLY, ARMED)
# Rejected everywhere. Impersonation and inline credentials have no honest use
# in an acceptance probe.
FORBIDDEN_ARGS = (
"--as",
"--as-group",
"--as-uid",
"--token",
"--password",
"--username",
"--client-key",
"--client-certificate",
)
# Reachable only from inside a pod, where an identity is probing its own
# refusal to impersonate rather than borrowing someone else's authority.
IMPERSONATION_ARGS = ("--as", "--as-group", "--as-uid")
FORBIDDEN_BINARIES = frozenset({"vault", "sops", "age", "gpg", "openssl", "curl", "wget"})
GITEA_CLIENT = "/opt/coordinator/gitea_api.py"
ALLOWED_BINARIES = frozenset({"kubectl", "flux", "helm", "git", "hermes", "sh", GITEA_CLIENT})
# Flags that consume the following token, so it is never a subcommand.
VALUE_FLAGS = frozenset(
{
"-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",
"auth",
"cluster-info",
"config",
"describe",
"events",
"explain",
"get",
"logs",
"top",
"version",
}
)
# Reachable only through a dry run: the API server authorises the request and
# discards it. A rejection is the evidence, and nothing is persisted either way.
KUBECTL_DRY_RUN_SUBCOMMANDS = frozenset(
{"annotate", "apply", "create", "delete", "label", "patch", "replace", "scale", "set"}
)
KUBECTL_EXEC_SUBCOMMANDS = frozenset({"exec"})
KUBECTL_SUBCOMMANDS = (
KUBECTL_READ_SUBCOMMANDS | KUBECTL_DRY_RUN_SUBCOMMANDS | KUBECTL_EXEC_SUBCOMMANDS
)
DRY_RUN_FLAGS = ("--dry-run=server", "--dry-run=client")
# Resources whose objects carry credential material. Reading them is permitted
# only in name-only output form, so a regression that grants access still cannot
# deliver a secret value into a report.
NAME_ONLY_RESOURCES = frozenset({"secret", "secrets"})
FLUX_READ_SUBCOMMANDS = frozenset(
{"check", "diff", "events", "export", "get", "stats", "trace", "tree", "version"}
)
HELM_READ_SUBCOMMANDS = frozenset({"get", "history", "list", "status", "version"})
GIT_READ_SUBCOMMANDS = frozenset(
{
"cat-file",
"config",
"diff",
"fetch",
"for-each-ref",
"log",
"ls-remote",
"ls-tree",
"merge-base",
"remote",
"rev-list",
"rev-parse",
"show",
"status",
}
)
GIT_ARMED_SUBCOMMANDS = frozenset({"push"})
GIT_FORCE_FLAGS = frozenset({"-f", "--force", "--force-with-lease", "--mirror", "--all", "--tags"})
GITEA_READ_METHODS = frozenset({"GET"})
GITEA_ARMED_METHODS = frozenset({"POST", "PATCH", "DELETE"})
# The Hermes CLI is the only honest source for durable session and Kanban
# activity, so a narrow read-only slice of it is on the allowlist.
HERMES_READ_COMMANDS = frozenset({("kanban", "list"), ("kanban", "show"), ("sessions", "list"), ("status",)})
# Paths that hold credential material. A probe may prove one exists or is
# unreadable; it may never read it outside a frozen template.
SECRET_PATH_RE = re.compile(
r"(?:^|/)(?:vault/secrets|var/run/secrets|runtime-access|\.credentials\.json"
r"|auth\.json|authorized_keys|id_[a-z0-9]+|\.netrc|shadow|gshadow)(?:$|/)"
)
_PARAMETER_RE = re.compile(r"[A-Za-z0-9/][A-Za-z0-9._/@:,-]{0,255}\Z")
_PLACEHOLDER_RE = re.compile(r"\{([a-z_]+)\}")
# Frozen shell templates. Each exists because a plain argv cannot express the
# safety property its probe needs.
SHELL_TEMPLATES: dict[str, str] = {
# Report an account's lock state without reading its password hash.
"account_lock_state": (
'awk -F: \'$1=="{account}"{{print ($2=="!"||$2=="*"||$2=="!!")?"locked":"unlocked"}}\' {path}'
),
# Report the account record's non-secret identity fields.
"account_identity": 'awk -F: \'$1=="{account}"{{print $3":"$4":"$6":"$7}}\' {path}',
# Report supplementary group names the account joins, never their members.
"account_groups": "awk -F: '$4~/(^|,){account}(,|$)/{{print $1}}' {path}",
# Prove a path's presence and mode without reading its bytes.
"path_mode": 'if [ -e {path} ]; then stat -c "%a %U:%G" {path}; else echo absent; fi',
# Prove whether a path is readable by this identity, without printing it.
"path_readable": 'if [ -r {path} ]; then echo readable; else echo unreadable; fi',
# List environment variable names only. Values never leave the container.
"env_names": "env | sed -n 's/^\\([A-Za-z_][A-Za-z0-9_]*\\)=.*/\\1/p' | sort",
# Report a JSON document's top-level key names without any of its values.
"json_keys": (
'python3 -c "import json,sys;print(chr(10).join(sorted(json.load(open(sys.argv[1])))))" {path}'
),
# Report allow-listed non-secret status fields from a provider health file.
"json_fields": (
'python3 -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}'
),
# Count non-empty lines, for authorized-key inventories.
"line_count": "awk 'NF{{n++}} END{{print n+0}}' {path}",
# Tail an append-only evidence log. The harness parses it; the container is
# not assumed to carry an interpreter.
"tail_lines": "tail -n {limit} {path}",
# Prove the worker can still read the repository. `kubectl exec` does not
# inherit the coordinator process environment, so the askpass helper is
# named explicitly rather than assumed; the helper reads runtime state and
# the credential never reaches this command line.
"git_ls_remote": "GIT_ASKPASS={askpass} git ls-remote {url} refs/heads/main",
}
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)
template = SHELL_TEMPLATES[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}")
return template.format(**parameters)
_RENDERED: set[str] = set()
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 positionals(argv: tuple[str, ...], start: int = 1) -> Iterator[str]:
"""Yield positional arguments, skipping flags and the values they consume."""
index = start
while index < len(argv):
item = argv[index]
if item == "--":
return
if item.startswith("-"):
if item in VALUE_FLAGS:
index += 2
continue
index += 1
continue
yield item
index += 1
def _reject_forbidden_arguments(argv: tuple[str, ...], allow_impersonation: bool = False) -> None:
permitted = IMPERSONATION_ARGS if allow_impersonation else ()
boundary = argv.index("--") if "--" in argv else len(argv)
for argument in argv[:boundary]:
for forbidden in FORBIDDEN_ARGS:
if forbidden in permitted:
continue
if argument == forbidden or argument.startswith(f"{forbidden}="):
raise PolicyError(f"forbidden argument: {forbidden}")
def _reject_secret_paths(argv: tuple[str, ...]) -> None:
boundary = argv.index("--") if "--" in argv else len(argv)
for argument in argv[:boundary]:
if SECRET_PATH_RE.search(argument):
raise PolicyError("probes may not name a credential path outside a frozen template")
def _assert_name_only_output(argv: tuple[str, ...]) -> None:
if "--output=name" in argv or "-oname" in argv:
return
for index, item in enumerate(argv[:-1]):
if item in {"-o", "--output"} and argv[index + 1] == "name":
return
raise PolicyError("credential-bearing resources may only be read with -o name")
def _check_kubectl(argv: tuple[str, ...], mode: str, allow_impersonation: bool) -> None:
words = list(positionals(argv))
subcommand = next((word for word in words if word in KUBECTL_SUBCOMMANDS), None)
if subcommand is None:
raise PolicyError("kubectl subcommand is outside the read-only boundary")
if subcommand != words[0]:
raise PolicyError("kubectl subcommand must precede its resource arguments")
resource = words[1].split(".")[0] if len(words) > 1 else ""
if subcommand in {"get", "describe"} and resource in NAME_ONLY_RESOURCES:
_assert_name_only_output(argv)
if subcommand in KUBECTL_READ_SUBCOMMANDS:
return
if subcommand in KUBECTL_EXEC_SUBCOMMANDS:
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")
check_argv(inner, mode, allow_impersonation=True)
return
if any(flag in argv for flag in DRY_RUN_FLAGS):
return
raise PolicyError(f"kubectl {subcommand} requires a dry run")
def _first_positional(binary: str, argv: tuple[str, ...]) -> str:
word = next(positionals(argv), None)
if word is None:
raise PolicyError(f"{binary} requires a subcommand")
return word
def _check_read_only_tool(binary: str, argv: tuple[str, ...], allowed: frozenset[str]) -> None:
subcommand = _first_positional(binary, argv)
if subcommand not in allowed:
raise PolicyError(f"{binary} {subcommand} is outside the read-only boundary")
def _check_git(argv: tuple[str, ...], mode: str) -> None:
subcommand = _first_positional("git", argv)
if subcommand == "config" and not {"--get", "--get-all", "--list"} & set(argv):
raise PolicyError("git config may only read")
if subcommand in GIT_READ_SUBCOMMANDS:
return
if subcommand in GIT_ARMED_SUBCOMMANDS and mode == ARMED:
if GIT_FORCE_FLAGS & set(argv):
raise PolicyError("git push may not force or fan out across refs")
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 _check_gitea(argv: tuple[str, ...], mode: str) -> None:
if len(argv) < 3:
raise PolicyError("gitea_api.py requires a method and a path")
method = argv[1].upper()
if method in GITEA_READ_METHODS:
return
if method in GITEA_ARMED_METHODS and mode == ARMED:
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":
raise PolicyError("sh is only reachable as sh -c <frozen template>")
if argv[2] not in _RENDERED:
raise PolicyError("sh -c script is not a frozen template rendering")
def check_argv(
argv: tuple[str, ...], mode: str = READ_ONLY, allow_impersonation: bool = False
) -> None:
"""Raise :class:`PolicyError` unless ``argv`` is safe to run in ``mode``.
``allow_impersonation`` is set only for the inner command of a
``kubectl exec``. Hermes probing its own refusal to impersonate is evidence;
the operator impersonating Hermes is not, so `--as` stays unreachable from
the outer, operator-side argv.
"""
if mode not in MODES:
raise PolicyError(f"unknown mode: {mode}")
if not argv:
raise PolicyError("empty command")
binary = argv[0] if argv[0] == GITEA_CLIENT else argv[0].rsplit("/", 1)[-1]
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, allow_impersonation)
if binary == "sh":
_check_shell(argv)
return
_reject_secret_paths(argv)
if binary == "kubectl":
_check_kubectl(argv, mode, allow_impersonation)
elif binary == "flux":
_check_read_only_tool("flux", argv, FLUX_READ_SUBCOMMANDS)
elif binary == "helm":
_check_read_only_tool("helm", argv, HELM_READ_SUBCOMMANDS)
elif binary == "git":
_check_git(argv, mode)
elif binary == "hermes":
_check_hermes(argv)
else:
_check_gitea(argv, mode)