267 lines
11 KiB
Python
267 lines
11 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Text-shaped evaluators for the handoff acceptance harness.
|
||
|
|
|
||
|
|
These rules cover evidence that arrives as a refusal, an exit status, or a list
|
||
|
|
of names. The ``denied`` rule is the load-bearing one: it refuses to classify on
|
||
|
|
an authorization review alone — ``kubectl auth can-i`` reports what RBAC says,
|
||
|
|
not what the API server does — so a real request must have been made and
|
||
|
|
refused before it will pass.
|
||
|
|
|
||
|
|
Importing this module also registers the JSON-shaped rules, so a caller only has
|
||
|
|
to import one place to have the full catalog vocabulary available.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import re
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from hermes_handoff_exec import Outcome
|
||
|
|
from hermes_handoff_json_rules import evaluate_flux_health # noqa: F401 (registers rules)
|
||
|
|
from hermes_handoff_model import ATTEMPT, FAIL, NOT_RUN, PASS, REVIEW, CheckSpec
|
||
|
|
from hermes_handoff_rules import (
|
||
|
|
EVALUATORS,
|
||
|
|
Evaluation,
|
||
|
|
evaluate,
|
||
|
|
evaluator,
|
||
|
|
lines,
|
||
|
|
looks_denied,
|
||
|
|
missing_steps,
|
||
|
|
step_keys,
|
||
|
|
)
|
||
|
|
|
||
|
|
__all__ = ["EVALUATORS", "Evaluation", "evaluate", "evaluator"]
|
||
|
|
|
||
|
|
|
||
|
|
@evaluator("denied")
|
||
|
|
def evaluate_denied(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
|
||
|
|
"""Pass only when the review says no and a real request was refused."""
|
||
|
|
blocked = missing_steps(spec, outcomes)
|
||
|
|
if blocked:
|
||
|
|
return blocked
|
||
|
|
attempts = step_keys(spec, ATTEMPT)
|
||
|
|
if not attempts:
|
||
|
|
return Evaluation(NOT_RUN, "deny checks require a real attempt, not only a review")
|
||
|
|
evidence: dict[str, Any] = {}
|
||
|
|
for key in step_keys(spec, REVIEW):
|
||
|
|
verdict = outcomes[key].stdout.strip().lower()
|
||
|
|
evidence[key] = verdict
|
||
|
|
if verdict != "no":
|
||
|
|
return Evaluation(
|
||
|
|
FAIL, f"authorization review {key} reported {verdict or 'nothing'}", evidence
|
||
|
|
)
|
||
|
|
for key in attempts:
|
||
|
|
outcome = outcomes[key]
|
||
|
|
if outcome.ok:
|
||
|
|
return Evaluation(FAIL, f"attempt {key} succeeded but must be refused", evidence)
|
||
|
|
if not looks_denied(outcome):
|
||
|
|
return Evaluation(
|
||
|
|
NOT_RUN,
|
||
|
|
f"attempt {key} failed without a recognisable refusal (rc={outcome.returncode})",
|
||
|
|
evidence,
|
||
|
|
)
|
||
|
|
evidence[key] = "refused"
|
||
|
|
return Evaluation(PASS, "review and live attempt were both refused", evidence)
|
||
|
|
|
||
|
|
|
||
|
|
@evaluator("allowed")
|
||
|
|
def evaluate_allowed(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
|
||
|
|
"""Pass when every required step succeeded and every review said yes."""
|
||
|
|
blocked = missing_steps(spec, outcomes)
|
||
|
|
if blocked:
|
||
|
|
return blocked
|
||
|
|
evidence: dict[str, Any] = {}
|
||
|
|
for step in spec.steps:
|
||
|
|
outcome = outcomes[step.key]
|
||
|
|
if step.kind == REVIEW:
|
||
|
|
verdict = outcome.stdout.strip().lower()
|
||
|
|
evidence[step.key] = verdict
|
||
|
|
if verdict != "yes":
|
||
|
|
return Evaluation(
|
||
|
|
FAIL,
|
||
|
|
f"authorization review {step.key} reported {verdict or 'nothing'}",
|
||
|
|
evidence,
|
||
|
|
)
|
||
|
|
continue
|
||
|
|
if not outcome.ok:
|
||
|
|
return Evaluation(FAIL, f"step {step.key} failed (rc={outcome.returncode})", evidence)
|
||
|
|
evidence[step.key] = "succeeded"
|
||
|
|
return Evaluation(PASS, "required access is present", evidence)
|
||
|
|
|
||
|
|
|
||
|
|
def _matched(observed: set[str], patterns: tuple[str, ...]) -> set[str]:
|
||
|
|
return {name for pattern in patterns for name in observed if pattern in name}
|
||
|
|
|
||
|
|
|
||
|
|
def _line_step(spec: CheckSpec, outcomes: dict[str, Outcome]) -> tuple[set[str], str] | Evaluation:
|
||
|
|
key = spec.expect["step"]
|
||
|
|
outcome = outcomes[key]
|
||
|
|
if not outcome.ok:
|
||
|
|
return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
|
||
|
|
return (set(lines(outcome)), key)
|
||
|
|
|
||
|
|
|
||
|
|
@evaluator("names_absent")
|
||
|
|
def evaluate_names_absent(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
|
||
|
|
"""Pass when no forbidden name or substring appears in a step's lines."""
|
||
|
|
blocked = missing_steps(spec, outcomes)
|
||
|
|
if blocked:
|
||
|
|
return blocked
|
||
|
|
resolved = _line_step(spec, outcomes)
|
||
|
|
if isinstance(resolved, Evaluation):
|
||
|
|
return resolved
|
||
|
|
observed, _ = resolved
|
||
|
|
offenders = sorted(
|
||
|
|
(observed & set(spec.expect.get("names", ())))
|
||
|
|
| _matched(observed, tuple(spec.expect.get("contains", ())))
|
||
|
|
)
|
||
|
|
evidence = {"observed_count": len(observed), "offenders": offenders}
|
||
|
|
if offenders:
|
||
|
|
return Evaluation(FAIL, f"forbidden names present: {', '.join(offenders)}", evidence)
|
||
|
|
return Evaluation(PASS, "no forbidden names are present", evidence)
|
||
|
|
|
||
|
|
|
||
|
|
@evaluator("names_present")
|
||
|
|
def evaluate_names_present(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
|
||
|
|
"""Pass when every required name, and every required substring, appears."""
|
||
|
|
blocked = missing_steps(spec, outcomes)
|
||
|
|
if blocked:
|
||
|
|
return blocked
|
||
|
|
resolved = _line_step(spec, outcomes)
|
||
|
|
if isinstance(resolved, Evaluation):
|
||
|
|
return resolved
|
||
|
|
observed, _ = resolved
|
||
|
|
missing = sorted(set(spec.expect.get("names", ())) - observed)
|
||
|
|
missing += sorted(
|
||
|
|
pattern
|
||
|
|
for pattern in spec.expect.get("contains", ())
|
||
|
|
if not any(pattern in name for name in observed)
|
||
|
|
)
|
||
|
|
evidence = {"observed_count": len(observed), "missing": missing}
|
||
|
|
if missing:
|
||
|
|
return Evaluation(FAIL, f"required names absent: {', '.join(missing)}", evidence)
|
||
|
|
if not observed:
|
||
|
|
return Evaluation(NOT_RUN, "step produced no lines to examine", evidence)
|
||
|
|
return Evaluation(PASS, "every required name is present", evidence)
|
||
|
|
|
||
|
|
|
||
|
|
@evaluator("stdout_matches")
|
||
|
|
def evaluate_stdout_matches(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
|
||
|
|
"""Compare a step's trimmed stdout against an expected value or set."""
|
||
|
|
blocked = missing_steps(spec, outcomes)
|
||
|
|
if blocked:
|
||
|
|
return blocked
|
||
|
|
key = spec.expect["step"]
|
||
|
|
outcome = outcomes[key]
|
||
|
|
if not outcome.ok:
|
||
|
|
return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
|
||
|
|
actual = outcome.stdout.strip()
|
||
|
|
expected = spec.expect["equals"]
|
||
|
|
allowed = expected if isinstance(expected, list) else [expected]
|
||
|
|
evidence = {"observed": actual}
|
||
|
|
if actual not in allowed:
|
||
|
|
return Evaluation(FAIL, f"observed {actual!r}, expected one of {allowed!r}", evidence)
|
||
|
|
return Evaluation(PASS, "output matches the expected value", evidence)
|
||
|
|
|
||
|
|
|
||
|
|
@evaluator("distinct_count")
|
||
|
|
def evaluate_distinct_count(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
|
||
|
|
"""Pass when a step yields enough distinct rows — and, optionally, exactly N.
|
||
|
|
|
||
|
|
Distinctness is the point for placement checks: three worker pods pinned to
|
||
|
|
one node satisfy a replica count and defeat the isolation the replica count
|
||
|
|
was standing in for.
|
||
|
|
"""
|
||
|
|
blocked = missing_steps(spec, outcomes)
|
||
|
|
if blocked:
|
||
|
|
return blocked
|
||
|
|
key = spec.expect["step"]
|
||
|
|
outcome = outcomes[key]
|
||
|
|
if not outcome.ok:
|
||
|
|
return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
|
||
|
|
values = lines(outcome)
|
||
|
|
distinct = sorted(set(values))
|
||
|
|
minimum = spec.expect["minimum"]
|
||
|
|
evidence = {"total": len(values), "distinct": len(distinct), "values": distinct[:16]}
|
||
|
|
if not values:
|
||
|
|
return Evaluation(NOT_RUN, f"step {key} produced no rows to count", evidence)
|
||
|
|
if len(distinct) < minimum:
|
||
|
|
return Evaluation(
|
||
|
|
FAIL, f"{len(distinct)} distinct values, expected at least {minimum}", evidence
|
||
|
|
)
|
||
|
|
total_equals = spec.expect.get("total_equals")
|
||
|
|
if total_equals is not None and len(values) != total_equals:
|
||
|
|
return Evaluation(FAIL, f"{len(values)} rows, expected exactly {total_equals}", evidence)
|
||
|
|
return Evaluation(PASS, f"{len(distinct)} distinct values observed", evidence)
|
||
|
|
|
||
|
|
|
||
|
|
@evaluator("lines_match")
|
||
|
|
def evaluate_lines_match(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
|
||
|
|
"""Pass when every data row of a tabular listing matches a shape.
|
||
|
|
|
||
|
|
Some surfaces only speak tables. A blank leading column is exactly what the
|
||
|
|
session-render regression looked like, so asserting the shape of each row is
|
||
|
|
a real check rather than a proxy for one.
|
||
|
|
"""
|
||
|
|
blocked = missing_steps(spec, outcomes)
|
||
|
|
if blocked:
|
||
|
|
return blocked
|
||
|
|
key = spec.expect["step"]
|
||
|
|
outcome = outcomes[key]
|
||
|
|
if not outcome.ok:
|
||
|
|
return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
|
||
|
|
pattern = re.compile(spec.expect["pattern"])
|
||
|
|
rows = outcome.stdout.splitlines()[spec.expect.get("skip", 0) :]
|
||
|
|
rows = [row for row in rows if row.strip()]
|
||
|
|
offenders = [row[:60] for row in rows if not pattern.search(row)]
|
||
|
|
evidence = {"rows": len(rows), "offenders": offenders[:8]}
|
||
|
|
if len(rows) < spec.expect.get("minimum", 1):
|
||
|
|
return Evaluation(
|
||
|
|
NOT_RUN, f"step {key} produced {len(rows)} data rows, expected at least "
|
||
|
|
f"{spec.expect.get('minimum', 1)}", evidence
|
||
|
|
)
|
||
|
|
if offenders:
|
||
|
|
return Evaluation(FAIL, f"{len(offenders)} row(s) do not match the expected shape", evidence)
|
||
|
|
return Evaluation(PASS, f"all {len(rows)} rows match the expected shape", evidence)
|
||
|
|
|
||
|
|
|
||
|
|
@evaluator("vantages_agree")
|
||
|
|
def evaluate_vantages_agree(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
|
||
|
|
"""Pass only when two independent observations report the same fact.
|
||
|
|
|
||
|
|
A disagreement is a finding in its own right: it usually means the manifest
|
||
|
|
on record and the workload actually running have drifted apart, and either
|
||
|
|
one alone would have looked clean.
|
||
|
|
"""
|
||
|
|
blocked = missing_steps(spec, outcomes)
|
||
|
|
if blocked:
|
||
|
|
return blocked
|
||
|
|
left_key, right_key = spec.expect["steps"]
|
||
|
|
observations: dict[str, list[str]] = {}
|
||
|
|
for key in (left_key, right_key):
|
||
|
|
outcome = outcomes[key]
|
||
|
|
if not outcome.ok:
|
||
|
|
return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
|
||
|
|
observations[key] = sorted(lines(outcome))
|
||
|
|
evidence = {key: value[:16] for key, value in observations.items()}
|
||
|
|
if observations[left_key] != observations[right_key]:
|
||
|
|
return Evaluation(FAIL, f"vantages disagree: {left_key} and {right_key} differ", evidence)
|
||
|
|
if not observations[left_key]:
|
||
|
|
return Evaluation(NOT_RUN, "both vantages returned nothing to compare", evidence)
|
||
|
|
return Evaluation(PASS, "both vantages report the same state", evidence)
|
||
|
|
|
||
|
|
|
||
|
|
@evaluator("not_armed")
|
||
|
|
def evaluate_not_armed(spec: CheckSpec, _outcomes: dict[str, Outcome]) -> Evaluation:
|
||
|
|
"""Report the fixed NOT_RUN a default, read-only run must produce."""
|
||
|
|
return Evaluation(
|
||
|
|
NOT_RUN,
|
||
|
|
spec.expect.get("reason", "ephemeral mutation mode is not armed"),
|
||
|
|
{"armed": False},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# Two observations of the same fact agree whether or not they came from
|
||
|
|
# different vantages; the dual-vantage case is just the important one.
|
||
|
|
EVALUATORS["steps_agree"] = evaluate_vantages_agree
|