#!/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 ") 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)