atlas-iac/testing/tests/test_hermes_handoff_zero_evidence.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

244 lines
9.3 KiB
Python

"""Regressions for the zero-evidence fail-open in the absence checks.
``evaluate_names_absent`` used to return ``PASS`` when its step exited 0 with no
output, so five mandatory checks — the ones asserting that provider API keys,
forge credentials, cluster-admin bindings, and shared coordinator state are
*absent* — could report a pass on no evidence at all and turn a ``NO_GO`` into a
``GO``. These tests pin the real catalog specs, not synthetic look-alikes, and
reproduce both ways silence actually arrives: a POSIX pipeline whose exit status
comes from its last stage, and a ``kubectl -o jsonpath`` over drifted structure.
"""
from __future__ import annotations
import datetime
import shutil
import subprocess
import pytest
from testing.tests.test_hermes_handoff_support import load_handoff_module, outcome
catalog = load_handoff_module("hermes_handoff_catalog")
evaluators = load_handoff_module("hermes_handoff_evaluators")
harness_run = load_handoff_module("hermes_handoff_run")
model = load_handoff_module("hermes_handoff_model")
policy = load_handoff_module("hermes_handoff_policy")
# Every mandatory check whose whole claim is that something is not there.
ABSENCE_CHECKS = (
"access.no-cluster-admin-binding-for-agent",
"forge.worker-holds-no-git-credential-variable",
"identity.no-provider-api-key-in-manifest",
"identity.no-provider-api-key-in-pod",
"pool.coordinator-retains-sole-state-ownership",
)
def real_catalog() -> dict:
targets = catalog.Targets(now=datetime.datetime.now(datetime.UTC))
return {spec.id: spec for spec in harness_run.build_catalog(targets)}
def test_the_five_absence_checks_are_exactly_the_mandatory_names_absent_set() -> None:
"""If a sixth absence check appears it must be covered here too."""
specs = real_catalog()
found = {
identifier
for identifier, spec in specs.items()
if spec.rule == "names_absent" and spec.mandatory
}
assert found == set(ABSENCE_CHECKS)
@pytest.mark.parametrize("identifier", ABSENCE_CHECKS)
def test_a_real_absence_check_never_passes_on_zero_observations(
identifier: str,
) -> None:
"""rc=0 with no lines is drift, so the verdict must be NOT_RUN, not PASS."""
spec = real_catalog()[identifier]
step_key = spec.expect["step"]
silent = evaluators.evaluate(spec, {step_key: outcome(stdout="")})
assert silent.status == model.NOT_RUN
assert "no lines" in silent.reason
assert silent.status != model.PASS
whitespace = evaluators.evaluate(spec, {step_key: outcome(stdout="\n \n\t\n")})
assert whitespace.status == model.NOT_RUN
@pytest.mark.parametrize("identifier", ABSENCE_CHECKS)
def test_a_real_absence_check_still_passes_on_real_evidence(identifier: str) -> None:
"""The guard must not turn a healthy observation into a false NOT_RUN."""
spec = real_catalog()[identifier]
step_key = spec.expect["step"]
benign = "clusterrolebinding.rbac.authorization.k8s.io/system:basic-user\nPATH\n"
passed = evaluators.evaluate(spec, {step_key: outcome(stdout=benign)})
assert passed.status == model.PASS
assert passed.evidence["observed_count"] == 2
@pytest.mark.parametrize("identifier", ABSENCE_CHECKS)
def test_a_real_absence_check_still_fails_on_a_forbidden_name(identifier: str) -> None:
spec = real_catalog()[identifier]
step_key = spec.expect["step"]
offender = next(
iter(spec.expect.get("names", ()) or spec.expect.get("contains", ()))
)
verdict = evaluators.evaluate(
spec, {step_key: outcome(stdout=f"OTHER\n{offender}")}
)
assert verdict.status == model.FAIL
assert offender in verdict.reason
def test_zero_evidence_on_a_mandatory_absence_check_forces_no_go() -> None:
"""A NOT_RUN on a mandatory check must not be rounded up to a GO.
Every other check is held at PASS so the verdict turns on the five absence
checks alone: silence must cost the release its GO.
"""
specs = real_catalog()
silent, green = [], []
for identifier, spec in specs.items():
status = model.PASS
if identifier in ABSENCE_CHECKS:
evaluation = evaluators.evaluate(
spec, {spec.expect["step"]: outcome(stdout="")}
)
assert evaluation.status == model.NOT_RUN
status = evaluation.status
silent.append(model.CheckResult(spec=spec, status=status))
green.append(model.CheckResult(spec=spec, status=model.PASS))
assert all(result.blocking for result in silent if result.status == model.NOT_RUN)
assert model.Report(mode="read-only", started_at="t", results=silent).decision == (
model.NO_GO
)
assert (
model.Report(mode="read-only", started_at="t", results=green).decision
== model.GO
)
def test_a_broken_shell_pipeline_exits_zero_with_no_output() -> None:
"""The harness' own frozen template is reachable with rc=0 and 0 bytes.
A POSIX pipeline reports the status of its *last* stage, so a missing or
renamed upstream binary produces success-with-no-evidence rather than an
error. This is one of the two live paths that made the fail-open reachable.
"""
script = policy.render_shell("env_names").replace("/usr/bin/env", "/usr/bin/absent")
completed = subprocess.run( # noqa: S603
["/bin/sh", "-c", script],
capture_output=True,
text=True,
timeout=30,
check=False,
)
assert completed.returncode == 0
assert completed.stdout == ""
spec = real_catalog()["identity.no-provider-api-key-in-pod"]
verdict = evaluators.evaluate(
spec, {"env": outcome(stdout=completed.stdout, returncode=completed.returncode)}
)
assert verdict.status == model.NOT_RUN
DRIFTED_JSONPATH_OUTPUT = {
# A renamed parent — `containerz` — makes kubectl print nothing at all.
"renamed parent": "",
# A renamed leaf — `.nam` — makes it print only the separators.
"renamed leaf": "\n" * 6,
# A container filter that matches nothing behaves the same way.
"unmatched filter": "",
}
@pytest.mark.parametrize("shape", sorted(DRIFTED_JSONPATH_OUTPUT))
def test_drifted_kubectl_jsonpath_output_is_not_run(shape: str) -> None:
"""The exact bytes a drifted ``-o jsonpath`` emits must never read as a pass.
``kubectl -o jsonpath`` exits 0 when the structure it walks is missing, so a
renamed field, a renamed leaf, or an unmatched container filter all deliver
success with no usable evidence. Pinned as literal output so the regression
holds with no cluster and no network; the live rc=0 behaviour is asserted
separately by :func:`test_a_live_drifted_projection_still_exits_zero`.
"""
spec = real_catalog()["identity.no-provider-api-key-in-manifest"]
verdict = evaluators.evaluate(
spec, {"env": outcome(stdout=DRIFTED_JSONPATH_OUTPUT[shape])}
)
assert verdict.status == model.NOT_RUN
assert "no lines" in verdict.reason
def test_a_live_drifted_projection_still_exits_zero() -> None:
"""Confirm against the real tool that drift is success, not an error.
kubectl has no offline JSONPath renderer, so this reads the live agent
workload — a plain, read-only ``get`` with a name-only projection — and skips
when no cluster is reachable. The hermetic assertion above does not skip.
"""
kubectl = shutil.which("kubectl")
if kubectl is None: # pragma: no cover - kubectl ships with the release image
pytest.skip("kubectl is not installed in this environment")
base = ["--namespace", "hermes", "get", "deploy/hermes-agent", "-o"]
healthy = 'jsonpath={range .spec.template.spec.containers[*]}{.name}{"\\n"}{end}'
probe = subprocess.run( # noqa: S603
[kubectl, *base, healthy],
capture_output=True,
text=True,
timeout=60,
check=False,
)
if probe.returncode != 0:
pytest.skip("no read-only cluster access in this environment")
assert probe.stdout.strip()
for drifted in (
'jsonpath={range .spec.template.spec.containerz[*]}{.name}{"\\n"}{end}',
'jsonpath={range .spec.template.spec.containers[*]}{.nam}{"\\n"}{end}',
):
result = subprocess.run( # noqa: S603
[kubectl, *base, drifted],
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert result.returncode == 0
assert result.stdout.strip() == ""
spec = real_catalog()["identity.no-provider-api-key-in-manifest"]
verdict = evaluators.evaluate(spec, {"env": outcome(stdout=result.stdout)})
assert verdict.status == model.NOT_RUN
def test_every_absence_probe_yields_one_line_per_observed_item() -> None:
"""A healthy probe must produce evidence, or the guard would misfire.
The pool claim projection emits ``<volume>=<claim>`` so a template volume
without a PVC still counts as an observation; an empty result therefore only
happens when the object or the projection has drifted.
"""
spec = real_catalog()["pool.coordinator-retains-sole-state-ownership"]
argv = spec.steps[0].argv
assert '{.name}{"="}' in argv[-1]
verdict = evaluators.evaluate(spec, {"claims": outcome(stdout="tmp=\nconfig=\n")})
assert verdict.status == model.PASS
assert verdict.evidence["observed_count"] == 2
leaked = evaluators.evaluate(
spec, {"claims": outcome(stdout="state=hermes-agent-home\n")}
)
assert leaked.status == model.FAIL