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