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.
378 lines
15 KiB
Python
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)
|