160 lines
7.2 KiB
Python
160 lines
7.2 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, inner: bool = False) -> None:
|
||
|
|
policy.check_argv(argv, mode or policy.READ_ONLY, allow_impersonation=inner)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"argv",
|
||
|
|
[
|
||
|
|
("kubectl", "--namespace", "hermes", "get", "pods"),
|
||
|
|
("kubectl", "auth", "can-i", "get", "secrets", "--all-namespaces"),
|
||
|
|
("kubectl", "get", "secrets", "--all-namespaces", "-o", "name"),
|
||
|
|
("kubectl", "get", "secrets", "--output=name"),
|
||
|
|
("kubectl", "logs", "deploy/x", "--tail", "1"),
|
||
|
|
("kubectl", "--context", "atlas", "get", "nodes", "-o", "json"),
|
||
|
|
("flux", "get", "kustomizations", "--all-namespaces"),
|
||
|
|
("helm", "list", "--all-namespaces"),
|
||
|
|
("git", "merge-base", "--is-ancestor", "a", "b"),
|
||
|
|
("git", "config", "--get", "remote.origin.url"),
|
||
|
|
("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", "fragment"),
|
||
|
|
[
|
||
|
|
((), "empty command"),
|
||
|
|
(("vault", "read", "kv/x"), "forbidden binary"),
|
||
|
|
(("curl", "https://example.dev"), "forbidden binary"),
|
||
|
|
(("rm", "-rf", "/"), "allowlist"),
|
||
|
|
(("kubectl", "get", "pods", "--token", "abc"), "forbidden argument"),
|
||
|
|
(("kubectl", "get", "pods", "--as=system:admin"), "forbidden argument"),
|
||
|
|
(("kubectl", "get", "secrets", "-A"), "-o name"),
|
||
|
|
(("kubectl", "describe", "secrets", "x"), "-o name"),
|
||
|
|
(("kubectl", "delete", "-n", "get", "secrets"), "requires a dry run"),
|
||
|
|
(("kubectl", "port-forward", "svc/x", "80"), "outside the read-only boundary"),
|
||
|
|
(("kubectl", "exec", "pod"), "-- command boundary"),
|
||
|
|
(("kubectl", "exec", "pod", "--"), "inner command"),
|
||
|
|
(("kubectl",), "outside the read-only boundary"),
|
||
|
|
(("flux", "reconcile", "kustomization", "x"), "outside the read-only boundary"),
|
||
|
|
(("flux",), "requires a subcommand"),
|
||
|
|
(("helm", "upgrade", "x"), "outside the read-only boundary"),
|
||
|
|
(("git", "commit", "-m", "x"), "not permitted"),
|
||
|
|
(("git", "push", "origin", "HEAD:refs/heads/x"), "not permitted"),
|
||
|
|
(("git", "config", "user.name", "x"), "may only read"),
|
||
|
|
(("hermes", "kanban", "complete", "t_1"), "outside the read-only boundary"),
|
||
|
|
(("hermes",), "requires a subcommand"),
|
||
|
|
((policy.GITEA_CLIENT, "POST", "/api/v1/x"), "not permitted"),
|
||
|
|
((policy.GITEA_CLIENT, "GET"), "method and a path"),
|
||
|
|
(("sh", "-c", "rm -rf /"), "frozen template"),
|
||
|
|
(("sh", "echo"), "frozen template"),
|
||
|
|
(("git", "log", "/etc/shadow"), "credential path"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_unsafe_commands_are_refused(argv: tuple[str, ...], fragment: str) -> None:
|
||
|
|
with pytest.raises(policy.PolicyError) as caught:
|
||
|
|
check(*argv)
|
||
|
|
assert fragment in str(caught.value)
|
||
|
|
|
||
|
|
|
||
|
|
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_reaches_mutating_subcommands_without_writing() -> None:
|
||
|
|
check("kubectl", "--namespace", "hermes", "patch", "deploy/x", "--dry-run=server")
|
||
|
|
check("kubectl", "create", "configmap", "probe", "--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, match="requires a dry run"):
|
||
|
|
check("kubectl", "delete", "-n", "get", "configmap", "x")
|
||
|
|
|
||
|
|
|
||
|
|
def test_impersonation_is_operator_forbidden_and_in_pod_permitted() -> None:
|
||
|
|
with pytest.raises(policy.PolicyError, match="forbidden argument"):
|
||
|
|
check("kubectl", "get", "namespaces", "--as", "system:admin")
|
||
|
|
check("kubectl", "get", "namespaces", "--as", "system:admin", inner=True)
|
||
|
|
|
||
|
|
|
||
|
|
def test_exec_validates_its_inner_command_and_tolerates_impersonation_inside() -> None:
|
||
|
|
inner = policy.shell("path_readable", path="/tmp")
|
||
|
|
check("kubectl", "exec", "--namespace", "hermes", "pod", "--", *inner)
|
||
|
|
check("kubectl", "exec", "pod", "--", "kubectl", "get", "ns", "--as", "system:admin")
|
||
|
|
with pytest.raises(policy.PolicyError, match="allowlist"):
|
||
|
|
check("kubectl", "exec", "pod", "--", "bash", "-c", "id")
|
||
|
|
|
||
|
|
|
||
|
|
def test_exec_of_a_shell_template_naming_a_secret_path_is_permitted() -> None:
|
||
|
|
"""The path scan stops at `--`; the tail is a reviewed template, not free text."""
|
||
|
|
inner = policy.shell("account_lock_state", account="hermes-agent", path="/host-etc/shadow")
|
||
|
|
check("kubectl", "exec", "--namespace", "hermes", "pod", "--", *inner)
|
||
|
|
|
||
|
|
|
||
|
|
def test_armed_mode_opens_exactly_the_mutations_it_should() -> None:
|
||
|
|
check("git", "push", "origin", "HEAD:refs/heads/x", mode=policy.ARMED)
|
||
|
|
check(policy.GITEA_CLIENT, "POST", "/api/v1/x", mode=policy.ARMED)
|
||
|
|
check(policy.GITEA_CLIENT, "DELETE", "/api/v1/x", mode=policy.ARMED)
|
||
|
|
with pytest.raises(policy.PolicyError, match="may not force"):
|
||
|
|
check("git", "push", "--force", "origin", "x", 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", "/api/v1/x", mode=policy.ARMED)
|
||
|
|
|
||
|
|
|
||
|
|
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("path_mode")
|
||
|
|
with pytest.raises(policy.PolicyError, match="unsafe shell parameter"):
|
||
|
|
policy.render_shell("path_mode", path="/tmp; rm -rf /")
|
||
|
|
with pytest.raises(policy.PolicyError, match="unsafe shell parameter"):
|
||
|
|
policy.render_shell("path_mode", path="$(id)")
|
||
|
|
|
||
|
|
|
||
|
|
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, match="must precede"):
|
||
|
|
check("kubectl", "pods", "get")
|