atlas-iac/scripts/ops/hermes_handoff_policy.py
jenkins b6ae6225f6 hermes: source handoff forge evidence through the scm broker
The acceptance harness pinned a forge client that has never existed in any
commit or pod (/opt/coordinator/gitea_api.py, digest f0943db4..., GIT/POST
grammar, an askpass helper). Every Gitea-backed check was therefore
unrunnable as merged. Point the harness at the credential-isolated SCM
broker client that actually ships in the agent pod.

- policy: GITEA_CLIENT=/opt/scm/gitea_api.py; trust /opt/scm/ instead of
  the phantom /opt/coordinator/; admit the client's real grammar
  (`read <api-path>`, exactly one path) with the same atlas/titan-iac pin
  and dot-segment rejection; bare HTTP methods are refused in every mode.
  The armed POST/PATCH/DELETE windows remain but are documented as
  deferred: the deployed client cannot execute them.
- exec: pin the client digest to the sha256 of
  services/hermes/scm-common/scripts/gitea_api.py — the exact file the
  hermes-scm-boundary-v2 ConfigMap mounts at /opt/scm/gitea_api.py — so
  the pin is derivable from merged source and equal to the deployed
  client. gitea_api.py gains a narrow /api/v1/user identity read in
  _authorize_read (see below), so the pin is the NEW source hash
  76efd16dedbeb74425b12fbbdbfaa391854771292077e0463bf22706855ae6dc.
  Drop the dangling GIT_ASKPASS (no helper exists; broker git needs
  none) and swap /opt/coordinator for /opt/scm in SAFE_PATH.
- checks: all forge/baseline/lineage probes use (client, "read", path).
  The SELF-vantage identity checks now truthfully assert the *broker's*
  forge identity (the only one the platform can exercise) is not an
  administrator and holds push-scoped, non-administrative repository
  authority; the administrative-route check asserts the broker read
  allowlist's live refusal of branch_protections. The remote-main step
  keeps `origin` (the broker remote exists only in pool workspaces and
  the broker origin is cluster-local); its rationale now tells the
  operator to ensure origin fetchability.
- gitea_api.py/_authorize_read: allow exactly `/api/v1/user` (no query,
  no sibling routes) as operation "identity" so the harness can prove
  the broker identity is not an administrator. The broker imports the
  same module, so one reviewed edit covers both sides of the boundary.
- rules: DENIAL_MARKERS now match the client's real refusal lines
  ("SCM broker request failed with HTTP 400/403" and the no-credential
  rejection) and drop "gitea api returned http 403", which the client
  never emits; a broker 404 is deliberately not denial evidence.
- ephemeral: index/verification reads use the real grammar; manual
  cleanup guidance now says close/delete require operator forge
  credentials (the client exposes no mutation besides create-draft);
  armed mode is documented as deferred until the probes are rebuilt on
  the broker's bounded mutation surface.
- docs: broker vantage/evidence section, operator prerequisites (broker
  healthy, no /vault/secrets/gitea-token anywhere on the harness path,
  current ConfigMap mount, operator-side client + origin fetchability),
  armed-mode deferral.
- tests: read-grammar accepted / GET refused in every mode, /opt/scm
  attestation pin proven equal to the merged source digest, real
  denial-marker matching, /api/v1/user identity route bounds; the
  repository-pin mutant probe speaks the new grammar. Full handoff +
  gitea + broker families pass (952 tests), mutation gate 13/13, per-file
  line+branch coverage >=95%, all touched sources within the 500-line cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 18:21:38 -03:00

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 = "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/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 Atlas titan-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)