atlas-iac/scripts/ops/hermes_handoff_redaction.py
Hermes Agent 8f00545828 hermes: add a fail-closed full-handoff acceptance harness
Decides whether the Hermes platform handoff is fit to release, and refuses
to round an absence of evidence up to a pass.

The harness is read-only by default and classifies 71 checks PASS / FAIL /
NOT_RUN / NOT_APPLICABLE. Any mandatory FAIL or NOT_RUN is NO_GO, and so is a
harness-level problem: an unreachable vantage, a catalog entry whose evidence
no longer exists, an expired deadline, or an evaluator that raised.

Evidence comes from two vantages that cannot cover for each other: an external
read-only operator kubeconfig, and the Hermes agent probing itself from inside
its own pod. Before any check runs, the harness asks each vantage who it is and
stops if they are the same principal, because dual-vantage evidence from one
identity is a restatement rather than a corroboration. `--as` is rejected for
every operator-side command and reachable only as the inner command of a
`kubectl exec`, so impersonation can never stand in for a real self-probe. A
deny check needs a live refused request, not only an authorization review.

Two safety properties are structural rather than conventional, enforced where
an argv becomes a subprocess: the default mode mutates nothing (mutating verbs
require a server dry run; there is deliberately no live TokenRequest probe,
because a successful one would mint a real credential), and no probe can pull a
credential value into a report (no vault/sops/curl, secrets readable only with
-o name, environment probes list names, shell only through frozen reviewed
templates). Captures are bounded before they are screened, and the rendered
report is re-screened before it is written.

Mutation lives behind a separate arming flag with an exact confirmation phrase,
a caller-supplied unique ref, a preflight that refuses a protected push target
before any network call, and a cleanup whose verification is itself mandatory.
A default run reports those four checks NOT_RUN.

The catalog is declarative so a reviewer reads what is asserted rather than how
it is plumbed, and so structural properties can be proven over every entry
before a run. Catalog drift surfaces as NOT_RUN, which stops the release.

docs/hermes_full_handoff_acceptance.md carries the merge order for PRs #14-#18
on top of the merged #13 baseline, the image build and Flux rollout, the
rollback point for each step, the go/no-go checklist, and the limits that are
asserted rather than exercised.

Validation: 295 handoff tests pass with 100% line coverage on all 15 new
modules; the full unit suite is 647 passed with two failures that reproduce
unchanged on origin/main; Ruff, py_compile, kustomize render, and a diff
credential screen are clean; a live read-only run against Atlas returns NO_GO
for the pre-merge cluster with no unscreened fields in the report.
2026-08-17 10:14:17 +00:00

224 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""Credential redaction and output bounding for the handoff acceptance harness.
Every string the harness records — command output, failure reasons, evidence
values — passes through :func:`redact` before it reaches a report. Screening is
a fail-closed accident barrier, not proof that text is secret-free: a short
secret under an innocuous key is not reliably distinguishable from prose. The
harness therefore also never asks for a secret in the first place. Probes read
names, shapes, and status fields; they never read environment values, token
files, private keys, cookies, or Vault responses.
Bounding is part of the same barrier. An unbounded capture is how a debug dump
or a stack trace carrying an authorization header ends up in an artifact, so
captures are truncated at a byte budget before they are screened.
"""
from __future__ import annotations
import re
from typing import Any
REDACTED = "[redacted]"
DEFAULT_MAX_BYTES = 64 * 1024
TRUNCATION_NOTE = "[truncated]"
# Keys whose value is never safe to record, matched against a normalised
# (lowercased, separator-stripped) form of the key.
SENSITIVE_KEY_SUFFIXES = (
"accesskey",
"accesskeyid",
"apikey",
"authtoken",
"clientsecret",
"connectionstring",
"cookie",
"credential",
"credentials",
"passphrase",
"passwd",
"password",
"privatekey",
"secret",
"secretkey",
"sessionkey",
"signature",
"signingkey",
"token",
"webhook",
)
SENSITIVE_KEY_EXACT = frozenset(
{
"auth",
"authorization",
"bearer",
"pat",
"pw",
"sas",
"setcookie",
}
)
# A bare `key:` is a structural field name across Kubernetes and TOML far more
# often than it is a credential, so it is screened by value shape and by the
# token patterns rather than by name. The compound forms above still match.
# Values that cannot hold a credential: booleans, numbers, and anything too
# short to be one. Screening them corrupts structured evidence — a redacted
# `automountServiceAccountToken` is exactly the fact a reader needs — without
# protecting anything.
MIN_SECRET_LENGTH = 8
NON_SECRET_LITERALS = frozenset({"true", "false", "null", "none", "yes", "no", "0", "1", ""})
_KEY_NOISE_RE = re.compile(r"[^a-z0-9]+")
# Ordered: structural matches first so a nested token is not partially rewritten
# by a looser rule.
_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(
r"-----BEGIN[ A-Z0-9-]{0,64}-----.*?-----END[ A-Z0-9-]{0,64}-----",
re.DOTALL,
),
re.compile(r"(?i)\b(?:authorization|proxy-authorization)\s*:\s*\S.*"),
re.compile(r"(?i)\b(?:set-)?cookie\s*:\s*\S.*"),
re.compile(r"(?i)\b(?:bearer|basic|token)\s+[A-Za-z0-9+/_=.:~-]{8,}"),
re.compile(r"(?i)\b[a-z][a-z0-9+.-]{1,20}://[^\s/@:]{1,128}:[^\s/@]{1,256}@"),
re.compile(r"\bssh-(?:rsa|ed25519|dss|ecdsa-[A-Za-z0-9-]+)\s+[A-Za-z0-9+/]{16,}={0,3}"),
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}"),
re.compile(r"(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{16,}"),
re.compile(r"(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{16,}"),
re.compile(r"(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{16,}"),
re.compile(r"(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{16,}"),
re.compile(r"(?<![A-Za-z0-9])sk-(?:ant-|proj-)?[A-Za-z0-9_-]{16,}"),
re.compile(r"(?<![A-Za-z0-9])(?:gta|gto|gitea|forgejo)_[A-Za-z0-9_-]{16,}"),
re.compile(r"(?<![A-Za-z0-9])hvs\.[A-Za-z0-9_-]{16,}"),
re.compile(r"(?<![A-Za-z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}"),
re.compile(r"(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}"),
re.compile(r"(?<![A-Za-z0-9])npm_[A-Za-z0-9]{16,}"),
)
# `key: value` / `key=value` where the key names a credential.
_ASSIGNMENT_RE = re.compile(
r"""(?x)
(?P<quote>["']?)
(?P<key>[A-Za-z][A-Za-z0-9_.\ -]{0,63})
(?P=quote)
(?P<gap>\s{0,8}[:=]\s{0,8})
(?P<value>"[^"\n]{0,4096}"|'[^'\n]{0,4096}'|[^\s,;}\n][^,;}\n]{0,4096})
"""
)
# Long, mixed-class runs. Deliberately conservative: prose and hex digests of
# fewer than 44 characters are left alone so commit SHAs stay readable.
_ENTROPY_RE = re.compile(r"(?<![A-Za-z0-9+/_=-])[A-Za-z0-9+/_=-]{44,}(?![A-Za-z0-9+/_=-])")
def normalise_key(key: str) -> str:
"""Return a key lowercased with separators removed, for suffix matching."""
return _KEY_NOISE_RE.sub("", key.lower())
def is_sensitive_key(key: str) -> bool:
"""Report whether a key name means its value must never be recorded."""
compact = normalise_key(key)
if not compact:
return False
if compact in SENSITIVE_KEY_EXACT:
return True
return compact.endswith(SENSITIVE_KEY_SUFFIXES)
def _is_high_entropy(candidate: str) -> bool:
"""Report whether a long run mixes enough character classes to be a token."""
classes = sum(
(
any(character.islower() for character in candidate),
any(character.isupper() for character in candidate),
any(character.isdigit() for character in candidate),
any(character in "+/_=-" for character in candidate),
)
)
return classes >= 3
def could_hold_secret(value: str) -> bool:
"""Report whether a raw value is long and opaque enough to be a credential."""
stripped = value.strip().strip("\"'")
if stripped.lower() in NON_SECRET_LITERALS or len(stripped) < MIN_SECRET_LENGTH:
return False
try:
float(stripped)
except ValueError:
return True
return False
def _redact_assignment(match: re.Match[str]) -> str:
value = match.group("value")
if not is_sensitive_key(match.group("key")) or not could_hold_secret(value):
return match.group(0)
# Preserve the original quoting so screened JSON stays parsable JSON.
quoted = value.strip()[:1] in {'"', "'"}
replacement = f'"{REDACTED}"' if quoted else REDACTED
key_quote = match.group("quote")
return f"{key_quote}{match.group('key')}{key_quote}{match.group('gap')}{replacement}"
def _redact_entropy(match: re.Match[str]) -> str:
return REDACTED if _is_high_entropy(match.group(0)) else match.group(0)
def redact(text: str) -> str:
"""Return ``text`` with credential-shaped substrings replaced."""
if not text:
return text
for pattern in _PATTERNS:
text = pattern.sub(REDACTED, text)
text = _ASSIGNMENT_RE.sub(_redact_assignment, text)
return _ENTROPY_RE.sub(_redact_entropy, text)
def bound(text: str, max_bytes: int = DEFAULT_MAX_BYTES) -> tuple[str, bool]:
"""Truncate ``text`` to a UTF-8 byte budget, reporting whether it was cut."""
if max_bytes <= 0:
return ("", bool(text))
encoded = text.encode("utf-8", "replace")
if len(encoded) <= max_bytes:
return (text, False)
kept = encoded[:max_bytes].decode("utf-8", "ignore")
return (f"{kept}\n{TRUNCATION_NOTE}", True)
def safe_text(text: str, max_bytes: int = DEFAULT_MAX_BYTES) -> tuple[str, bool]:
"""Bound then redact, in that order, so a huge dump cannot outrun screening."""
bounded, truncated = bound(text, max_bytes)
return (redact(bounded), truncated)
def scrub(value: Any, max_bytes: int = DEFAULT_MAX_BYTES) -> Any:
"""Recursively bound and redact a JSON-shaped value.
Mapping keys are preserved (names are evidence); values under a sensitive
key are dropped outright rather than pattern-screened, because a short
secret would survive screening.
"""
if isinstance(value, str):
return safe_text(value, max_bytes)[0]
if isinstance(value, dict):
return {
str(key): REDACTED
if is_sensitive_key(str(key)) and could_hold_secret(str(item))
else scrub(item, max_bytes)
for key, item in value.items()
}
if isinstance(value, (list, tuple)):
return [scrub(item, max_bytes) for item in value]
return value
def contains_credential_shape(text: str) -> bool:
"""Report whether screening would rewrite ``text``.
Used by the harness self-test to prove a rendered report is clean before it
is written, and by the unit suite as an assertion helper.
"""
return redact(text) != text