atlas-iac/testing/tests/test_hermes_handoff_policy.py
Hermes Agent d208a89d9d fix(hermes): close the zero-evidence fail-open in absence checks
evaluate_names_absent returned PASS when its step exited 0 with no output,
so five mandatory checks - the ones asserting that provider API keys, forge
credentials, a cluster-admin binding, and shared coordinator state are
absent - could report a pass on no evidence and turn a NO_GO into a GO.
Both name rules now resolve their step through one guard in _line_step, so
zero observations are NOT_RUN. Regressions pin all five real catalog specs
plus both reachable silence paths: a POSIX pipeline whose status comes from
its last stage, and a drifted kubectl -o jsonpath. The pool claim projection
emits one <volume>=<claim> line per template volume so a volume without a
PVC still counts as an observation rather than reading as drift.

Also closes the review's reachable hardening and evidence defects:

- pin Gitea paths to atlas/titan-iac on an exact segment boundary and
  reject relative segments, including percent-encoded ones
- forbid impersonation structurally in every mode and vantage; the inner
  command of kubectl exec is re-checked rather than exempted, and
  validate_catalog no longer guards only the operator vantage
- drop flux and helm from the binary allowlist; they had no pinned release
  digest, so no allowlisted binary can now be admitted that the executor
  would refuse to attest
- remove the inert --concurrency and --expect-telegram-sessions flags and
  the dead concurrency bound; Telegram continuity stays mandatory
- read the ephemeral pull index page by page, treat the create response as
  an authoritative source for the pull number, close every number either
  source names, and surface residue_ref plus exact manual_cleanup commands
  when creation is uncertain
- keep executable_path and executable_sha256 on unrecorded bulk-evidence
  steps so withholding bytes never withholds binary attestation
- revert the repo-wide hygiene legacy-exception mechanism; the contract
  change here is purely additive and the four pre-existing over-cap files
  are left to the canonical contract change in PR #14/#15
- correct the runbook ruff format scope so the documented command passes

Split hermes_handoff_arming.py out of hermes_handoff_ephemeral.py to keep
both modules under the 500-line cap. All 16 handoff modules hold at least
95% line and branch coverage; the mutation gate is 13/13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:24:37 +00:00

368 lines
13 KiB
Python

"""Contracts for the fail-closed command policy of the handoff harness."""
from __future__ import annotations
import pytest
from testing.tests.test_hermes_handoff_support import load_handoff_module
policy = load_handoff_module("hermes_handoff_policy")
def check(*argv: str, mode: str | None = None) -> None:
policy.check_argv(argv, mode or policy.READ_ONLY)
@pytest.mark.parametrize(
"argv",
[
("kubectl", "--namespace", "hermes", "get", "pods", "-o", "name"),
("kubectl", "auth", "can-i", "get", "secrets", "--all-namespaces"),
("kubectl", "get", "nodes", "-o", "name"),
(
"kubectl",
"get",
"pods",
"-o",
policy.projection("jsonpath={.status.phase}"),
),
("git", "merge-base", "--is-ancestor", "a", "b"),
("git", "ls-remote", "origin", "refs/heads/main"),
("hermes", "kanban", "list", "--json"),
("hermes", "sessions", "list", "--source", "telegram"),
("hermes", "status"),
(policy.GITEA_CLIENT, "GET", "/api/v1/user"),
],
)
def test_read_only_commands_are_permitted(argv: tuple[str, ...]) -> None:
check(*argv)
@pytest.mark.parametrize(
"argv",
[
(),
("vault", "read", "kv/x"),
("curl", "https://example.dev"),
("rm", "-rf", "/"),
("kubectl", "get", "pods", "--token", "abc"),
("kubectl", "get", "pods"),
("kubectl", "get", "pods", "--as=system:admin"),
("kubectl", "get", "secrets", "-o", "name"),
("kubectl", "get", "pods,secrets/x", "-o", "name"),
("kubectl", "describe", "pod/x"),
("kubectl", "config", "view", "--raw"),
("kubectl", "auth", "reconcile", "-f", "x"),
("kubectl", "delete", "pod/x", "--dry-run=client"),
("kubectl", "delete", "pod/x", "--", "--dry-run=server"),
("kubectl", "get", "pods", "-o=json"),
("kubectl", "get", "pods", "-o", "jsonpath={.spec.containers[*].env[*].value}"),
("kubectl", "port-forward", "svc/x", "80"),
("kubectl", "exec", "pod"),
("kubectl", "exec", "pod", "--"),
("kubectl",),
("flux", "get", "kustomizations", "--all-namespaces"),
("flux", "reconcile", "kustomization", "x"),
("flux",),
("helm", "list", "--all-namespaces"),
("helm", "upgrade", "x"),
("helm", "get", "values", "x"),
("git", "commit", "-m", "x"),
("git", "push", "origin", "HEAD:refs/heads/x"),
("git", "fetch", "origin"),
("git", "config", "core.sshCommand", "x"),
("git", "-ccore.sshCommand=x", "ls-remote", "origin"),
("git", "--git-dir=/tmp/x", "status"),
("hermes", "kanban", "complete", "t_1"),
("hermes",),
(policy.GITEA_CLIENT, "POST", "/api/v1/x"),
(policy.GITEA_CLIENT, "GET"),
("sh", "-c", "rm -rf /"),
("sh", "echo"),
("git", "log", "/etc/shadow"),
],
)
def test_unsafe_commands_are_refused(argv: tuple[str, ...]) -> None:
with pytest.raises(policy.PolicyError):
check(*argv)
def test_unknown_mode_is_refused() -> None:
with pytest.raises(policy.PolicyError, match="unknown mode"):
policy.check_argv(("kubectl", "get", "pods"), "whatever")
def test_dry_run_mutation_commands_are_never_constructible() -> None:
with pytest.raises(policy.PolicyError, match="dry-run"):
check("kubectl", "patch", "deploy/x", "--dry-run=server")
def test_a_resource_named_after_a_subcommand_cannot_smuggle_a_delete() -> None:
"""`-n get` must not make a delete look like a read."""
with pytest.raises(policy.PolicyError):
check("kubectl", "delete", "-n", "get", "configmap", "x")
@pytest.mark.parametrize("flag", ["--as", "--as-group", "--as-uid"])
@pytest.mark.parametrize("mode", ["read-only", "ephemeral-armed"])
def test_impersonation_is_refused_from_every_vantage_and_mode(
flag: str, mode: str
) -> None:
"""No vantage may impersonate: not the operator, and not inside a pod.
The inner command of ``kubectl exec`` used to be granted impersonation
unconditionally, so a self/switchyard/node/chat step carrying ``--as`` was
accepted. The inner argv is now re-checked under the same rule.
"""
for argv in (
("kubectl", "get", "namespaces", "-o", "name", flag, "system:admin"),
("kubectl", "get", "ns", "-o", "name", f"{flag}=system:admin"),
):
with pytest.raises(policy.PolicyError, match="forbidden argument"):
check(*argv, mode=mode)
inner = (
"kubectl",
"exec",
"pod",
"--",
"/usr/local/bin/kubectl",
"get",
"ns",
"-o",
"name",
flag,
"system:admin",
)
with pytest.raises(policy.PolicyError, match="forbidden argument"):
check(*inner, mode=mode)
def test_exec_validates_its_inner_command() -> None:
script = policy.render_shell("env_names")
policy._RENDERED.add(script)
check(
"kubectl", "exec", "--namespace", "hermes", "pod", "--", "/bin/sh", "-c", script
)
check(
"kubectl",
"exec",
"pod",
"--",
"/usr/local/bin/kubectl",
"get",
"ns",
"-o",
"name",
)
with pytest.raises(policy.PolicyError):
check("kubectl", "exec", "pod", "--", "bash", "-c", "id")
def test_the_repository_pin_has_an_exact_boundary_and_rejects_dot_segments() -> None:
"""A prefix match without a boundary let a look-alike repository through."""
for path in (
"/api/v1/repos/atlas/titan-iac-evil/pulls",
"/api/v1/repos/atlas/titan-iacx",
"/api/v1/repos/atlas/titan-iac../pulls",
):
with pytest.raises(policy.PolicyError):
check(policy.GITEA_CLIENT, "GET", path)
for path in (
"/api/v1/repos/atlas/titan-iac/../../user/tokens",
"/api/v1/repos/atlas/titan-iac/%2e%2e/%2e%2e/user/tokens",
"/api/v1/repos/atlas/titan-iac/%252e%252e/user/tokens",
"/api/v1/repos/atlas/titan-iac/./pulls",
"/api/v1/repos/atlas/titan-iac//pulls",
):
with pytest.raises(policy.PolicyError, match="relative segments"):
check(policy.GITEA_CLIENT, "GET", path)
check(policy.GITEA_CLIENT, "GET", "/api/v1/repos/atlas/titan-iac")
check(policy.GITEA_CLIENT, "GET", "/api/v1/repos/atlas/titan-iac/pulls?state=all")
def test_every_allowlisted_binary_is_release_attestable() -> None:
"""An allowlisted binary with no pinned digest is dead, misleading config."""
exec_module = load_handoff_module("hermes_handoff_exec")
assert set(exec_module.EXPECTED_SHA256) == set(policy.ALLOWED_BINARIES)
assert set(exec_module.EXPECTED_PATHS) == set(policy.ALLOWED_BINARIES)
assert set(exec_module.POD_COMMAND_PATHS) == set(policy.ALLOWED_BINARIES)
assert set(exec_module.CONTEXT_AWARE_BINARIES) <= set(policy.ALLOWED_BINARIES)
def test_shell_templates_reject_credential_roots() -> None:
with pytest.raises(policy.PolicyError, match="credential paths"):
policy.shell("json_fields", path="/runtime-access/token", fields="state")
with pytest.raises(policy.PolicyError, match="credential-bearing"):
policy.shell("json_fields", path="/tmp/health.json", fields="state,token")
def test_armed_mode_opens_exactly_the_mutations_it_should() -> None:
ref = "ephemeral/hermes-handoff-acceptance/acceptance1"
policy.arm_ephemeral_policy(ref)
policy.register_ephemeral_pull(9)
check("git", "push", "origin", f"HEAD:refs/heads/{ref}", mode=policy.ARMED)
root = "/api/v1/repos/atlas/titan-iac"
check(
policy.GITEA_CLIENT,
"POST",
f"{root}/pulls",
"--field",
"title=WIP: Hermes handoff acceptance ephemeral probe",
"--field",
f"head={ref}",
"--field",
"base=main",
"--field",
"body=Ephemeral acceptance probe. Closed and deleted by the harness.",
mode=policy.ARMED,
)
check(
policy.GITEA_CLIENT,
"PATCH",
f"{root}/pulls/9",
"--field",
"state=closed",
mode=policy.ARMED,
)
check(policy.GITEA_CLIENT, "DELETE", f"{root}/branches/{ref}", mode=policy.ARMED)
with pytest.raises(policy.PolicyError, match="may not force"):
check(
"git",
"push",
"--force",
"origin",
f"HEAD:refs/heads/{ref}",
mode=policy.ARMED,
)
with pytest.raises(policy.PolicyError, match="may not force"):
check("git", "push", "--all", "origin", mode=policy.ARMED)
with pytest.raises(policy.PolicyError, match="not permitted"):
check(policy.GITEA_CLIENT, "PUT", f"{root}/pulls/9", mode=policy.ARMED)
def test_arming_and_projection_registration_fail_closed() -> None:
with pytest.raises(policy.PolicyError, match="exact ephemeral ref"):
policy.arm_ephemeral_policy("main")
policy._ARMED_REF = ""
with pytest.raises(policy.PolicyError, match="registration is malformed"):
policy.register_ephemeral_pull(9)
policy.arm_ephemeral_policy("ephemeral/hermes-handoff-acceptance/acceptance1")
for candidate in (
"wide",
"jsonpath={.status.token}",
"jsonpath={.metadata.annotations}",
"jsonpath={.metadata.labels.other}",
"jsonpath={.spec.containers[*].env[*].value}",
):
with pytest.raises(policy.PolicyError):
policy.projection(candidate)
with pytest.raises(policy.PolicyError):
check("/usr/bin/awk", "x")
with pytest.raises(policy.PolicyError):
check("git", "--exec-path=/tmp", "status")
def test_every_frozen_template_renders_and_is_accepted() -> None:
parameters = {
"account": "hermes-agent",
"askpass": "/opt/coordinator/gitea_askpass.sh",
"fields": "state,model",
"limit": "200",
"path": "/host-etc/passwd",
"url": "https://scm.example.dev/atlas/repo.git",
}
for name in policy.SHELL_TEMPLATES:
required = policy.template_parameters(name)
argv = policy.shell(name, **{key: parameters[key] for key in required})
policy.check_argv(argv)
assert argv[0] == "sh"
def test_template_rendering_rejects_unknown_names_and_unsafe_parameters() -> None:
with pytest.raises(policy.PolicyError, match="unknown shell template"):
policy.render_shell("nope")
with pytest.raises(policy.PolicyError, match="expects parameters"):
policy.render_shell("json_fields")
with pytest.raises(policy.PolicyError, match="unsafe shell parameter"):
policy.render_shell("json_fields", path="/tmp;rm", fields="state")
with pytest.raises(policy.PolicyError, match="unsafe shell parameter"):
policy.render_shell("json_fields", path="$(id)", fields="state")
def test_positionals_skip_flags_and_stop_at_the_boundary() -> None:
argv = ("kubectl", "--namespace", "hermes", "get", "pods", "--", "ignored")
assert list(policy.positionals(argv)) == ["get", "pods"]
assert list(policy.positionals(("kubectl", "--all-namespaces", "get"))) == ["get"]
def test_a_subcommand_must_precede_its_resource_arguments() -> None:
with pytest.raises(policy.PolicyError):
check("kubectl", "pods", "get")
@pytest.mark.parametrize(
"argv",
[
("/usr/bin/../../tmp/kubectl", "get", "pods"),
("/tmp/reviewer/kubectl", "get", "pods"),
("kubectl", "get", "pods", "-o", "go-template=x"),
("kubectl", "get", "pods", "-o", "jsonpath={.status.token}"),
("kubectl", "get", "pods", "-o", "jsonpath={.metadata.annotations}"),
("kubectl", "get", "pods", "-o", "jsonpath={.spec.containers[*].env[*].value}"),
("git", "-C", "/tmp", "status"),
("git", "--work-tree=/tmp", "status"),
(policy.GITEA_CLIENT, "GET", "not-an-api-path"),
(policy.GITEA_CLIENT, "GET", "/api/v1/repos/atlas/other"),
],
)
def test_provenance_projection_and_repository_bypasses_are_rejected(argv) -> None:
with pytest.raises(policy.PolicyError):
check(*argv)
def test_armed_writes_are_pinned_to_exact_remote_ref_and_endpoints() -> None:
ref = "ephemeral/hermes-handoff-acceptance/acceptance1"
policy.arm_ephemeral_policy(ref)
policy.register_ephemeral_pull(9)
with pytest.raises(policy.PolicyError, match="pinned to origin"):
check(
"git",
"push",
"upstream",
"HEAD:refs/heads/ephemeral/hermes-handoff-acceptance/acceptance1",
mode=policy.ARMED,
)
with pytest.raises(policy.PolicyError, match="preflighted ephemeral ref"):
check("git", "push", "origin", "HEAD:refs/heads/other", mode=policy.ARMED)
with pytest.raises(policy.PolicyError):
check(
policy.GITEA_CLIENT,
"DELETE",
"/api/v1/repos/atlas/titan-iac",
mode=policy.ARMED,
)
with pytest.raises(policy.PolicyError):
check(
policy.GITEA_CLIENT,
"PATCH",
"/api/v1/repos/atlas/titan-iac/pulls/8",
"--field",
"state=closed",
mode=policy.ARMED,
)
with pytest.raises(policy.PolicyError):
check(
policy.GITEA_CLIENT,
"POST",
"/api/v1/repos/atlas/titan-iac/pulls",
"--field",
"title=WIP: wrong branch",
"--field",
"head=main",
"--field",
"base=main",
mode=policy.ARMED,
)