From 876c3f6647798daff56086e03d413f4559bd32a1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 4 Nov 2026 01:39:36 +0100 Subject: [PATCH] AUDIT-WP-0018-T04: founder-run seed of the nk-bounded-executor bearer; T05 live activation waits - scripts/seed-nk-bounded-executor-sender.py (+ silent attended wrapper): one attended process mints once, writes CCR-2026-0063 custody (platform/workloads/net-kingdom/bounded-executor/audit-sender, cas 0) and appends the sender to the audit-core registry (cas), syncs audit-core-senders and compares by hash, restarts the receiver, probes the live scope without writing an event, and checks sso/nk-bind-executor-audit by hash when it exists. Receipt only; no value printed or stored. - Refuses unless CCR-2026-0063 is approved by its three owners, cluster is railiance01, receiver image and live scope ConfigMap match, the ingress policy exists and the coding-agent boundary covers the custody path. - tests/test_seed_nk_bounded_executor_sender.py: 16 synthetic tests, including the probe run against the real receiver over HTTP. - docs/bounded-executor-receipts.md: 2026-11-04 live state and founder runbook (single-object ingress apply, preflight, seed, verify). - Read-only check: ingress policy not live; other nine policies match. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 523928@bnt-lap001 Assistant-Session: 6c737afb-9c14-4152-9f02-477214b9c350 --- WORK-RECORDS.md | 2 + docs/bounded-executor-receipts.md | 111 +++++ ...ttended-seed-nk-bounded-executor-sender.sh | 12 + scripts/seed-nk-bounded-executor-sender.py | 427 ++++++++++++++++++ tests/test_seed_nk_bounded_executor_sender.py | 250 ++++++++++ ...P-0018-bounded-executor-receipt-custody.md | 54 ++- 6 files changed, 855 insertions(+), 1 deletion(-) create mode 100755 scripts/attended-seed-nk-bounded-executor-sender.sh create mode 100755 scripts/seed-nk-bounded-executor-sender.py create mode 100644 tests/test_seed_nk_bounded_executor_sender.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index c684867..59a2d60 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -115,6 +115,8 @@ | task | AUDIT-WP-0018-T01 | done | — | workplans/AUDIT-WP-0018-bounded-executor-receipt-custody.md | | task | AUDIT-WP-0018-T02 | done | — | workplans/AUDIT-WP-0018-bounded-executor-receipt-custody.md | | task | AUDIT-WP-0018-T03 | done | — | workplans/AUDIT-WP-0018-bounded-executor-receipt-custody.md | +| task | AUDIT-WP-0018-T04 | done | — | workplans/AUDIT-WP-0018-bounded-executor-receipt-custody.md | +| task | AUDIT-WP-0018-T05 | wait | — | workplans/AUDIT-WP-0018-bounded-executor-receipt-custody.md | | intake | AUDIT-IN-0001 | closed | — | intakes/intakes.md | | intake | AUDIT-IN-0002 | closed | — | intakes/intakes.md | | intake | AUDIT-IN-0003 | closed | — | intakes/intakes.md | diff --git a/docs/bounded-executor-receipts.md b/docs/bounded-executor-receipts.md index f5a390d..a4f9830 100644 --- a/docs/bounded-executor-receipts.md +++ b/docs/bounded-executor-receipts.md @@ -229,3 +229,114 @@ Released 2026-11-03: receiver `sha256:5f3d01c8…` (built from 2b8c909) and the scope ConfigMap carrying `closing_classes` are live on railiance01. The detection is inert until the `nk-bounded-executor` token is seeded and the executor emits. + +## Status 2026-11-04 — what is still needed live (NK-WP-0056-T15/T16) + +Checked read-only on 2026-11-04 with the default (limited) identity: + +- **Ingress: not live.** `audit-core-nk-bounded-executor-ingress` is the only + object in `deploy/networkpolicies.yaml` that is absent on railiance01. The + nine other policies in that file match the live specs exactly. The + executor's own egress (`sso/bounded-executor`, to `audit-core` pods on 8080) is + already live. +- **Scope ConfigMap:** the limited identity cannot read ConfigMaps in + `audit-core`. The release on 2026-11-03 is recorded in 06d0029. The seed + script below refuses unless the live entry equals `deploy/senders-scope.json`. +- **Bearer: not minted.** The registry is OpenBao + `platform/workloads/audit-core/senders` (field `senders.json`). ExternalSecret + `audit-core/audit-core-senders` projects it into the receiver. The same + pattern served tenant-engine and the CCR-2026-0021/0022 senders. + +How the token reaches both sides. One founder-run process, +`scripts/seed-nk-bounded-executor-sender.py`, does the following: + +1. It mints one bearer. +2. It writes the bearer with cas 0 to + `platform/workloads/net-kingdom/bounded-executor/audit-sender` (`AUDIT_TOKEN`, + `CUSTODY_REQUEST=CCR-2026-0063`). +3. It appends the `nk-bounded-executor` entry to the registry. The entry is the + declared scope plus the token, written with cas on the version read. Every + other sender stays byte-equal. +4. It force-syncs `audit-core-senders` and compares the token by SHA-256 only. +5. It restarts the receiver. +6. It proves the bearer without writing an event. Inside the receiver pod, with + the bearer on stdin: + - a read gets 403 (write-only); + - reconciliation for its own source and tenant `platform` gets 200; + - tenant `tenant:platform` gets 403; + - another source gets 403. + An unknown bearer gets 401. +7. It force-syncs `sso/nk-bind-executor-audit` and compares `audit-token` by + hash, but only if that ExternalSecret exists. + +The script is silent. The receipt holds versions, booleans and a 12-hex +fingerprint. A rerun reuses its own custody and writes nothing new. It refuses +foreign or drifted custody, a sender registered without custody, a token +collision and a concurrent registry write. Tests: +`tests/test_seed_nk_bounded_executor_sender.py`. + +The script refuses before any write unless all of the following hold: + +- CCR-2026-0063 is `approved` or later, with all three owner reviews; +- the cluster is railiance01 (kube-system UID); +- the receiver runs the pinned image; +- the live scope ConfigMap matches; +- the ingress policy exists; +- the coding-agent boundary already denies the custody data and metadata paths. + +It does not create the reader policy, the role or the boundary +(railiance-platform). It also does not create the `sso` ClusterSecretStore and +ExternalSecret, which belong to the rapp-bounded-executor package (CCR-2026-0063 +`delivery.target`). + +## Founder runbook — nk-bounded-executor sender + +Run these in order. Steps 1 and 2 can run now. Step 3 depends on CCR-2026-0063: +railiance-platform must have it approved by platform-owner, audit-core-owner and +net-kingdom-owner, and must have applied its reader policy, role and +coding-agent boundary entries. + +**1. Ingress (NK-WP-0056-T16).** This applies exactly one object. Run the server +dry-run first, then the apply: + +```bash +cd /home/worsch/audit-core && python3 -c 'import sys,yaml; d=[x for x in yaml.safe_load_all(open("deploy/networkpolicies.yaml")) if x and x["metadata"]["name"]=="audit-core-nk-bounded-executor-ingress"]; assert len(d)==1; yaml.safe_dump(d[0],sys.stdout)' | KUBECONFIG=/home/worsch/.kube/config-railiance01 kubectl apply --dry-run=server -f - +cd /home/worsch/audit-core && python3 -c 'import sys,yaml; d=[x for x in yaml.safe_load_all(open("deploy/networkpolicies.yaml")) if x and x["metadata"]["name"]=="audit-core-nk-bounded-executor-ingress"]; assert len(d)==1; yaml.safe_dump(d[0],sys.stdout)' | KUBECONFIG=/home/worsch/.kube/config-railiance01 kubectl apply -f - +``` + +**2. Read-only preflight** (prints no secrets): + +```bash +KUBECONFIG=/home/worsch/.kube/config-railiance01 kubectl -n audit-core get configmap audit-core-senders-scope -o jsonpath='{.data.senders-scope\.json}' | python3 -c 'import sys,json; print([e for e in json.load(sys.stdin) if e["name"]=="nk-bounded-executor"])' +KUBECONFIG=/home/worsch/.kube/config-railiance01 kubectl -n audit-core get deploy audit-core -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}' +bridge status +``` + +The image must end in +`sha256:5f3d01c8a1f90411a1429451b46c66a9ac9262fc7fce68790c16e22eeb7871a6`. +The `openbao-ui-railiance01` tunnel must be up. + +**3. Seed and reload (NK-WP-0056-T15). Needs CCR-2026-0063 approved and applied.** +This is one silent command. Read the result from the receipt, not from the +terminal: + +```bash +BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 warden access openbao-platform-admin-login --exec -- /home/worsch/audit-core/scripts/attended-seed-nk-bounded-executor-sender.sh /home/worsch/audit-core/docs/evidence/nk-bounded-executor-sender-seed-r1.json +``` + +The receipt is created exclusively. If you rerun, use `-r2.json`. The receiver +restart is part of this step. No separate rollout is needed. + +**4. Verify** (read-only): + +```bash +python3 -c 'import json; r=json.load(open("/home/worsch/audit-core/docs/evidence/nk-bounded-executor-sender-seed-r1.json")); print(r["status"], r.get("error"), r.get("receiver_probe"), r.get("registry_secret_hash_match"), r.get("sso_secret_hash_match"), r["credential_values_emitted"])' +KUBECONFIG=/home/worsch/.kube/config-railiance01 kubectl -n audit-core get networkpolicy audit-core-nk-bounded-executor-ingress +``` + +The expected status is `seeded_pending_sso_delivery`, or `seeded_and_delivered` +once the rapp package's ExternalSecret exists. The expected probe is +`{"read": 403, "own_tenant": 200, "other_tenant": 403, "other_source": 403}`. +After the rapp package applies `sso/nk-bind-executor-audit`, rerun step 3 with +`-r2.json`. It writes no custody or registry and confirms the `sso` copy by +hash. diff --git a/scripts/attended-seed-nk-bounded-executor-sender.sh b/scripts/attended-seed-nk-bounded-executor-sender.sh new file mode 100755 index 0000000..a6bc850 --- /dev/null +++ b/scripts/attended-seed-nk-bounded-executor-sender.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Silent child for the attended nk-bounded-executor sender seed (AUDIT-WP-0018-T04, +# NK-WP-0056-T15, CCR-2026-0063). Founder-run under +# warden access openbao-platform-admin-login --exec -- +# Results go to the receipt only; any output would fail the warden envelope closed. +# Inside the lane HOME is the warden envelope, so the kubeconfig default is absolute. +set -euo pipefail +receipt="${1:?receipt path required}" +exec python3 "$(dirname "$0")/seed-nk-bounded-executor-sender.py" \ + --kubeconfig "${RAILIANCE01_KUBECONFIG:-/home/worsch/.kube/config-railiance01}" \ + --confirm 'SEED nk-bounded-executor CCR-2026-0063' \ + --receipt "$receipt" >/dev/null 2>&1 diff --git a/scripts/seed-nk-bounded-executor-sender.py b/scripts/seed-nk-bounded-executor-sender.py new file mode 100755 index 0000000..a3b612c --- /dev/null +++ b/scripts/seed-nk-bounded-executor-sender.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +"""Attended seed of the nk-bounded-executor sender bearer (AUDIT-WP-0018-T04). + +NetKingdom NK-WP-0056-T15. railiance-platform CCR-2026-0063 holds the custody +contract. One process mints one bearer and writes it to the two places that need it: + + custody: platform/data/workloads/net-kingdom/bounded-executor/audit-sender + fields AUDIT_TOKEN, CUSTODY_REQUEST=CCR-2026-0063 (cas 0) + registry: platform/data/workloads/audit-core/senders + one appended entry "nk-bounded-executor" (cas on the version read) + +It then syncs ExternalSecret audit-core/audit-core-senders, restarts the receiver, +and proves without writing an event that the live receiver knows the bearer +with the exact registered scope. If railiance-platform's ExternalSecret +sso/nk-bind-executor-audit exists, the script syncs it and compares the token by hash. + +This script is silent. The wrapper discards stdout and stderr, so `warden access --exec` +fails closed on any output. The token is never printed, never passed on a command line +and never written to the receipt. The receipt holds versions, booleans and a +12-hex SHA-256 fingerprint. + +Re-running is safe. Existing custody that this request wrote (version 1, +CUSTODY_REQUEST matching) is reused. A registry entry that equals the desired +entry is not rewritten. Anything else refuses and changes nothing: a sender +without custody, a drifted entry, another request's custody, a token +collision or a concurrent registry write. + +Founder-run only. The exact command is in docs/bounded-executor-receipts.md, section +"Founder runbook". The script does not create OpenBao policies or roles, the coding-agent +boundary, the ClusterSecretStore or the ExternalSecret in sso. Those belong to +railiance-platform and the rapp-bounded-executor package. +""" +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import os +import re +import secrets +import subprocess +import time +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SENDER = "nk-bounded-executor" +SOURCE = "net-kingdom.bounded-executor" +TENANT = "platform" +CCR_ID = "CCR-2026-0063" +CCR_FILE = Path("/home/worsch/railiance-platform/credential-change-requests/" + "CCR-2026-0063-nk-bind-executor-audit-sender.yaml") +CCR_APPROVERS = ("platform-owner", "audit-core-owner", "net-kingdom-owner") +CONFIRM = "SEED nk-bounded-executor CCR-2026-0063" + +REGISTRY = "platform/data/workloads/audit-core/senders" +CUSTODY_KV = "platform/workloads/net-kingdom/bounded-executor/audit-sender" +CUSTODY_DATA = "platform/data/workloads/net-kingdom/bounded-executor/audit-sender" +CUSTODY_META = "platform/metadata/workloads/net-kingdom/bounded-executor/audit-sender" +BOUNDARY = "sys/policies/acl/agent-high-risk-boundary" + +CLUSTER_UID = "a553c742-0115-43d4-99a4-a5ca56fe0786" # railiance01 kube-system +SSO_SECRET = "nk-bind-executor-audit" +SSO_KEY = "audit-token" +TOKEN_SHAPE = re.compile(r"[A-Za-z0-9_-]{64}") + +# Runs inside the receiver pod. The bearer arrives on stdin and leaves only as +# HTTP status codes. No request writes an event. +RECEIVER_PROBE = r'''import json,sys,urllib.request,urllib.error +tok=sys.stdin.read().strip() +def get(p,auth=True): + r=urllib.request.Request("http://127.0.0.1:8080"+p) + if auth: r.add_header("Authorization","Bearer "+tok) + try: + with urllib.request.urlopen(r,timeout=10) as x: return x.status + except urllib.error.HTTPError as e: return e.code +w="since=2026-01-01T00:00:00%2B00:00&until=2026-01-02T00:00:00%2B00:00" +print(json.dumps({ + "read":get("/v1/events/probe-not-an-id"), + "own_tenant":get("/v1/reconciliation?source=net-kingdom.bounded-executor&tenant=platform&"+w), + "other_tenant":get("/v1/reconciliation?source=net-kingdom.bounded-executor&tenant=tenant:platform&"+w), + "other_source":get("/v1/reconciliation?source=tenant-engine&tenant=platform&"+w)})) +''' +PROBE_EXPECTED = {"read": 403, "own_tenant": 200, "other_tenant": 403, "other_source": 403} + + +class Refused(Exception): + """A named refusal. The message is a reason code, never a value.""" + + +class NotFound(Exception): + """The OpenBao path holds no value.""" + + +def require(condition: bool, code: str) -> None: + if not condition: + raise Refused(code) + + +def fingerprint(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest()[:12] + + +# --- contract --------------------------------------------------------------- + +def scope_entry(path: Path = ROOT / "deploy" / "senders-scope.json") -> dict: + rows = json.loads(path.read_text()) + entry = next((r for r in rows if r.get("name") == SENDER), None) + require(entry is not None, "scope_entry_missing") + require(entry.get("sources") == [SOURCE] and entry.get("tenants") == [TENANT] + and entry.get("may_write") is True and entry.get("may_read") is False + and entry.get("secret_policy") == "reject" + and entry.get("evidence_kind") == "load-bearing", "scope_entry_drift") + return entry + + +def desired(token: str, scope: dict) -> dict: + """Registry entry: exactly the declared scope plus the bearer.""" + entry = {"name": SENDER, "tokens": [token]} + entry.update({k: copy.deepcopy(v) for k, v in scope.items() if k != "name"}) + return entry + + +def check_ccr(doc: dict) -> None: + require(doc.get("id") == CCR_ID, "ccr_identity_mismatch") + require(doc.get("status") in {"approved", "applied", "verified", "active"}, "approved_ccr_required") + comments = (doc.get("review") or {}).get("comments") or [] + for owner in CCR_APPROVERS: + require(any(isinstance(c, dict) and c.get("decision") == "approved" + and owner in str(c.get("reviewer", "")) for c in comments), + "named_owner_reviews_required") + ob = doc.get("openbao") or {} + require(ob.get("kv_path") == CUSTODY_KV and ob.get("fields") == ["AUDIT_TOKEN", "CUSTODY_REQUEST"] + and ob.get("metadata_read") is False, "ccr_custody_contract_mismatch") + delivery = doc.get("delivery") or {} + require(delivery.get("namespace") == "sso", "ccr_delivery_mismatch") + + +def load_ccr(path: Path = CCR_FILE) -> tuple[dict, str]: + import yaml # system PyYAML; only needed for the CCR file + + raw = path.read_bytes() + return yaml.safe_load(raw), hashlib.sha256(raw).hexdigest() + + +# --- process boundaries ----------------------------------------------------- + +def bao(args: list[str], *, payload: dict | None = None) -> dict: + env = os.environ.copy() + for name in ("BAO_TOKEN", "VAULT_TOKEN", "OPENBAO_TOKEN"): + env.pop(name, None) + proc = subprocess.run(["bao", *args], + input=None if payload is None else json.dumps(payload).encode(), + capture_output=True, env=env, check=False, timeout=60) + if proc.returncode != 0: + err = proc.stderr.decode(errors="replace").lower() # inspected, never emitted + if "no value found" in err or "404" in err: + raise NotFound() + raise Refused("bao_failed") + return json.loads(proc.stdout.decode() or "{}") + + +class Kube: + def __init__(self, kubeconfig: str): + self.base = ["kubectl", "--kubeconfig", kubeconfig, "--request-timeout=30s"] + + def run(self, args: list[str], *, stdin: bytes | None = None) -> subprocess.CompletedProcess: + return subprocess.run([*self.base, *args], input=stdin, capture_output=True, + check=False, timeout=90) + + def json(self, args: list[str], code: str = "kubectl_failed") -> dict: + proc = self.run([*args, "-o", "json"]) + require(proc.returncode == 0, code) + return json.loads(proc.stdout.decode()) + + def exists(self, args: list[str]) -> bool: + return self.run([*args, "-o", "name"]).returncode == 0 + + +# --- OpenBao ---------------------------------------------------------------- + +def snapshot(call=bao) -> tuple[int, dict, list, set]: + raw = call(["read", "-format=json", REGISTRY])["data"] + version = raw["metadata"]["version"] + require(type(version) is int and version > 0, "registry_version_missing") + body = raw["data"] + require(isinstance(body, dict) and isinstance(body.get("senders.json"), str), "registry_shape_invalid") + try: + rows = json.loads(body["senders.json"]) + except ValueError: + raise Refused("registry_shape_invalid") from None + require(isinstance(rows, list) and bool(rows), "registry_must_preserve_existing_senders") + names: set = set() + tokens: set = set() + for row in rows: + require(isinstance(row, dict) and isinstance(row.get("name"), str) and row["name"] + and row["name"] not in names, "registry_identity_invalid_or_duplicate") + names.add(row["name"]) + values = row.get("tokens") or ([row["token"]] if row.get("token") else []) + require(isinstance(values, list) and bool(values), "registry_tokens_invalid") + for value in values: + require(isinstance(value, str) and value and value not in tokens, "registry_token_collision") + tokens.add(value) + return version, body, rows, tokens + + +def read_custody(call=bao) -> dict | None: + try: + return call(["read", "-format=json", CUSTODY_DATA])["data"] + except NotFound: + return None + + +def seed(scope: dict, receipt: dict, save, call=bao) -> str: + """Mint or reuse the bearer, then merge it into the registry. Returns the token.""" + version, body, rows, used = snapshot(call) + originals = copy.deepcopy(rows) + found = next((r for r in rows if r["name"] == SENDER), None) + receipt.update(registry_version_before=version, other_senders=len(rows) - (found is not None)) + existing = read_custody(call) + if existing is None: + require(found is None, "registry_sender_without_custody") + token = secrets.token_urlsafe(48) + require(TOKEN_SHAPE.fullmatch(token) is not None and token not in used, "sender_token_collision") + result = call(["write", "-format=json", CUSTODY_DATA, "-"], + payload={"options": {"cas": 0}, + "data": {"AUDIT_TOKEN": token, "CUSTODY_REQUEST": CCR_ID}}) + require((result.get("data") or {}).get("version") == 1, "initial_custody_version_mismatch") + receipt.update(custody="created", custody_version=1) + else: + data = existing.get("data") or {} + require(existing["metadata"]["version"] == 1 and set(data) == {"AUDIT_TOKEN", "CUSTODY_REQUEST"} + and data["CUSTODY_REQUEST"] == CCR_ID, "custody_provenance_mismatch") + token = data["AUDIT_TOKEN"] + require(isinstance(token, str) and TOKEN_SHAPE.fullmatch(token) is not None, "custody_token_shape") + if found is None: + require(token not in used, "sender_token_collision") + receipt.update(custody="reused", custody_version=1) + receipt["token_sha256_12"] = fingerprint(token) + want = desired(token, scope) + if found is not None: + require(found == want, "registered_sender_drift") + receipt["registry"] = "already_present" + else: + receipt["phase"] = "registry_cas" + save() + updated = [*rows, want] + payload = dict(body, **{"senders.json": json.dumps(updated, separators=(",", ":"))}) + call(["write", "-format=json", REGISTRY, "-"], payload={"options": {"cas": version}, "data": payload}) + receipt["registry"] = "appended" + after_version, after_body, after_rows, _ = snapshot(call) + require(all(r in after_rows for r in originals) and want in after_rows + and len(after_rows) == len(originals) + (found is None) + and all(after_body.get(k) == v for k, v in body.items() if k != "senders.json"), + "registry_readback_mismatch") + receipt.update(registry_version_after=after_version, unchanged_other_senders=True) + save() + return token + + +# --- Kubernetes ------------------------------------------------------------- + +def secret_entry_sha(kube: Kube) -> str | None: + import base64 + + secret = kube.json(["-n", "audit-core", "get", "secret", "audit-core-senders"]) + raw = (secret.get("data") or {}).get("senders.json") + if not raw: + return None + rows = json.loads(base64.b64decode(raw)) + row = next((r for r in rows if isinstance(r, dict) and r.get("name") == SENDER), None) + if not row or not row.get("tokens"): + return None + return hashlib.sha256(row["tokens"][-1].encode()).hexdigest() + + +def wait_for(check, timeout: int, interval: int = 3) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + try: + if check(): + return True + except Exception: + pass + time.sleep(interval) + return False + + +def receiver_pod(kube: Kube, image: str) -> str | None: + dep = kube.json(["-n", "audit-core", "get", "deployment", "audit-core"]) + if dep["spec"]["template"]["spec"]["containers"][0]["image"] != image: + return None + if dep.get("status", {}).get("readyReplicas") != 1 or \ + dep["status"].get("observedGeneration", 0) < dep["metadata"]["generation"]: + return None + pods = kube.json(["-n", "audit-core", "get", "pods", "-l", + "app.kubernetes.io/name=audit-core,app.kubernetes.io/component=receiver"])["items"] + if len(pods) != 1 or pods[0]["metadata"].get("deletionTimestamp"): + return None + statuses = pods[0].get("status", {}).get("containerStatuses") or [] + if not statuses or not all(s.get("ready") for s in statuses) \ + or pods[0]["spec"]["containers"][0]["image"] != image: + return None + return pods[0]["metadata"]["name"] + + +def live_scope_matches(kube: Kube, scope: dict) -> bool: + cm = kube.json(["-n", "audit-core", "get", "configmap", "audit-core-senders-scope"], "scope_configmap_unreadable") + rows = json.loads((cm.get("data") or {}).get("senders-scope.json") or "[]") + return next((r for r in rows if r.get("name") == SENDER), None) == scope + + +def expected_image() -> str: + text = (ROOT / "deploy" / "audit-core.yaml").read_text() + match = re.search(r"image:\s*(forgejo\.coulomb\.social/coulomb/audit-core@sha256:[0-9a-f]{64})", text) + require(match is not None, "receiver_digest_required") + return match.group(1) + + +def probe(kube: Kube, pod: str, token: str) -> dict: + proc = kube.run(["-n", "audit-core", "exec", "-i", pod, "-c", "audit-core", "--", + "python", "-c", RECEIVER_PROBE], stdin=token.encode()) + require(proc.returncode == 0, "receiver_probe_failed") + return json.loads(proc.stdout.decode()) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--kubeconfig", required=True) + parser.add_argument("--confirm", required=True) + parser.add_argument("--receipt", type=Path, required=True) + args = parser.parse_args(argv) + receipt: dict = {"schema": "audit-core.sender-seed.v1", "sender": SENDER, "ccr": CCR_ID, + "custody_path": CUSTODY_KV, "status": "refused", + "credential_values_emitted": False, + "started_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} + fd = os.open(args.receipt, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + + def save() -> None: + os.lseek(fd, 0, os.SEEK_SET) + os.ftruncate(fd, 0) + os.write(fd, (json.dumps(receipt, indent=2) + "\n").encode()) + os.fsync(fd) + + try: + save() + require(args.confirm == CONFIRM, "exact_confirmation_required") + scope = scope_entry() + ccr, ccr_sha = load_ccr() + check_ccr(ccr) + receipt["ccr_sha256"] = ccr_sha + require(Path.home().parent.name == ".warden-attended-login", "attended_warden_envelope_required") + require(not any(os.environ.get(n) for n in ("BAO_TOKEN", "VAULT_TOKEN", "OPENBAO_TOKEN")), + "token_env_must_be_unset") + identity = bao(["token", "lookup", "-format=json"])["data"] + policies = identity.get("policies") or [] + require("platform-admin" in policies and "root" not in policies, "attended_platform_admin_required") + + kube = Kube(args.kubeconfig) + ns = kube.json(["get", "namespace", "kube-system"], "not_railiance01") + require(ns.get("metadata", {}).get("uid") == CLUSTER_UID, "not_railiance01") + image = expected_image() + require(receiver_pod(kube, image) is not None, "receiver_not_current") + require(live_scope_matches(kube, scope), "live_scope_configmap_mismatch") + require(kube.exists(["-n", "audit-core", "get", "networkpolicy", + "audit-core-nk-bounded-executor-ingress"]), "ingress_policy_not_applied") + boundary = bao(["read", "-format=json", BOUNDARY])["data"]["policy"] + require(all(f'"{p}"' in boundary for p in (CUSTODY_DATA, CUSTODY_META)), + "coding_agent_boundary_missing") + receipt.update(phase="preflight_ok", receiver_image=image) + save() + + token = seed(scope, receipt, save) + want = hashlib.sha256(token.encode()).hexdigest() + + receipt["phase"] = "registry_sync" + save() + require(kube.run(["-n", "audit-core", "annotate", "externalsecret", "audit-core-senders", + f"force-sync={int(time.time())}", "--overwrite"]).returncode == 0, + "externalsecret_annotate_failed") + require(wait_for(lambda: secret_entry_sha(kube) == want, 120), "registry_secret_sync_timeout") + receipt["registry_secret_hash_match"] = True + + receipt["phase"] = "receiver_reload" + save() + require(kube.run(["-n", "audit-core", "rollout", "restart", "deployment/audit-core"]).returncode == 0, + "receiver_restart_failed") + time.sleep(5) + holder: dict = {} + require(wait_for(lambda: holder.update(pod=receiver_pod(kube, image)) or holder["pod"], 240, 5), + "receiver_not_ready_after_restart") + result = probe(kube, holder["pod"], token) + receipt["receiver_probe"] = result + require(result == PROBE_EXPECTED, "receiver_probe_mismatch") + + receipt["phase"] = "sso_delivery" + save() + if kube.exists(["-n", "sso", "get", "externalsecret", SSO_SECRET]): + kube.run(["-n", "sso", "annotate", "externalsecret", SSO_SECRET, + f"force-sync={int(time.time())}", "--overwrite"]) + + def sso_sha() -> bool: + import base64 + secret = kube.json(["-n", "sso", "get", "secret", SSO_SECRET]) + value = base64.b64decode((secret.get("data") or {})[SSO_KEY]).decode() + return hashlib.sha256(value.encode()).hexdigest() == want + + require(wait_for(sso_sha, 120), "sso_secret_hash_mismatch") + receipt.update(sso_secret_hash_match=True, status="seeded_and_delivered") + else: + receipt.update(sso_secret_hash_match=None, status="seeded_pending_sso_delivery") + receipt["phase"] = "done" + receipt["finished_at"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat() + save() + return 0 + except Exception as exc: + receipt["status"] = "refused" + receipt["error"] = str(exc) if isinstance(exc, Refused) else "contained_operation_failed" + save() + return 1 + finally: + os.close(fd) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_seed_nk_bounded_executor_sender.py b/tests/test_seed_nk_bounded_executor_sender.py new file mode 100644 index 0000000..8b468ed --- /dev/null +++ b/tests/test_seed_nk_bounded_executor_sender.py @@ -0,0 +1,250 @@ +"""AUDIT-WP-0018-T04. Seed script for the nk-bounded-executor bearer, on fixtures. + +Uses a fake OpenBao and obviously fake token strings. No real credential, no +cluster. +""" +import copy +import importlib.util +import json +import sys +from pathlib import Path +import subprocess +import threading +from wsgiref.simple_server import WSGIRequestHandler, make_server + +import pytest + +from audit_core.ingestion import IngestionApplication +from audit_core.senders import SenderRegistry +from audit_core.sqlite_backend import SQLiteAuditBackend + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "seed-nk-bounded-executor-sender.py" +SPEC = importlib.util.spec_from_file_location("seed_nk_bounded_executor_sender", SCRIPT) +SEED = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = SEED +SPEC.loader.exec_module(SEED) + +OTHER = [ + {"name": "tenant-engine", "tokens": ["fake-tenant-engine-not-a-secret"], + "sources": ["tenant-engine"], "tenants": ["*"]}, + {"name": "operator", "tokens": ["fake-operator-not-a-secret"], "sources": ["*"], + "tenants": ["*"], "may_write": False, "may_read": True}, +] +FAKE = "f" * 64 # matches the minted shape, obviously not a secret + + +class FakeBao: + """KV v2 with cas enforcement for the registry and the custody path.""" + + def __init__(self, rows=None, custody=None, extra_fields=None): + self.kv = { + SEED.REGISTRY: [1, dict({"senders.json": json.dumps(rows if rows is not None else OTHER)}, + **(extra_fields or {}))], + } + if custody is not None: + self.kv[SEED.CUSTODY_DATA] = [1, custody] + self.writes = [] + + def __call__(self, args, *, payload=None): + if args[0] == "read": + path = args[-1] + if path not in self.kv: + raise SEED.NotFound() + version, data = self.kv[path] + return {"data": {"metadata": {"version": version}, "data": copy.deepcopy(data)}} + assert args[0] == "write" + path = args[-2] + current = self.kv.get(path, [0, None])[0] + assert payload["options"]["cas"] == current, "cas must match the version read" + self.writes.append(path) + self.kv[path] = [current + 1, copy.deepcopy(payload["data"])] + return {"data": {"version": current + 1}} + + +class _Quiet(WSGIRequestHandler): + def log_message(self, *args): + pass + + +def run_seed(fake): + receipt = {} + token = SEED.seed(SEED.scope_entry(), receipt, lambda: None, call=fake) + return token, receipt + + +def registry_rows(fake): + return json.loads(fake.kv[SEED.REGISTRY][1]["senders.json"]) + + +def test_first_run_mints_once_into_custody_and_registry(): + fake = FakeBao(extra_fields={"unrelated": "kept"}) + token, receipt = run_seed(fake) + assert SEED.TOKEN_SHAPE.fullmatch(token) + assert fake.writes == [SEED.CUSTODY_DATA, SEED.REGISTRY] + assert fake.kv[SEED.CUSTODY_DATA][1] == {"AUDIT_TOKEN": token, "CUSTODY_REQUEST": "CCR-2026-0063"} + rows = registry_rows(fake) + assert rows[:2] == OTHER + assert rows[2] == SEED.desired(token, SEED.scope_entry()) + assert fake.kv[SEED.REGISTRY][1]["unrelated"] == "kept" + assert receipt["custody"] == "created" and receipt["registry"] == "appended" + assert receipt["token_sha256_12"] == SEED.fingerprint(token) + assert token not in json.dumps(receipt) + + +def test_rerun_reuses_and_writes_nothing(): + fake = FakeBao() + token, _ = run_seed(fake) + fake.writes.clear() + again, receipt = run_seed(fake) + assert again == token and fake.writes == [] + assert receipt["custody"] == "reused" and receipt["registry"] == "already_present" + + +def test_resume_after_custody_written_but_registry_not(): + fake = FakeBao(custody={"AUDIT_TOKEN": FAKE, "CUSTODY_REQUEST": "CCR-2026-0063"}) + token, receipt = run_seed(fake) + assert token == FAKE and fake.writes == [SEED.REGISTRY] + assert receipt["custody"] == "reused" and receipt["registry"] == "appended" + + +@pytest.mark.parametrize("custody, code", [ + ({"AUDIT_TOKEN": FAKE, "CUSTODY_REQUEST": "CCR-2026-0099"}, "custody_provenance_mismatch"), + ({"AUDIT_TOKEN": FAKE}, "custody_provenance_mismatch"), + ({"AUDIT_TOKEN": "short", "CUSTODY_REQUEST": "CCR-2026-0063"}, "custody_token_shape"), +]) +def test_foreign_or_malformed_custody_refuses(custody, code): + fake = FakeBao(custody=custody) + with pytest.raises(SEED.Refused, match=code): + run_seed(fake) + assert fake.writes == [] + + +def test_registered_sender_without_custody_refuses(): + rows = OTHER + [{"name": "nk-bounded-executor", "tokens": [FAKE]}] + fake = FakeBao(rows=rows) + with pytest.raises(SEED.Refused, match="registry_sender_without_custody"): + run_seed(fake) + assert fake.writes == [] + + +def test_drifted_registry_entry_refuses(): + entry = SEED.desired(FAKE, SEED.scope_entry()) + entry["tenants"] = ["*"] + fake = FakeBao(rows=OTHER + [entry], + custody={"AUDIT_TOKEN": FAKE, "CUSTODY_REQUEST": "CCR-2026-0063"}) + with pytest.raises(SEED.Refused, match="registered_sender_drift"): + run_seed(fake) + assert fake.writes == [] + + +def test_custody_token_already_used_by_another_sender_refuses(): + rows = OTHER + [{"name": "someone", "tokens": [FAKE]}] + fake = FakeBao(rows=rows, custody={"AUDIT_TOKEN": FAKE, "CUSTODY_REQUEST": "CCR-2026-0063"}) + with pytest.raises(SEED.Refused, match="sender_token_collision"): + run_seed(fake) + + +def test_empty_or_duplicate_registry_refuses(): + with pytest.raises(SEED.Refused, match="registry_must_preserve_existing_senders"): + run_seed(FakeBao(rows=[])) + with pytest.raises(SEED.Refused, match="registry_identity_invalid_or_duplicate"): + run_seed(FakeBao(rows=OTHER + [OTHER[0]])) + + +def test_concurrent_registry_removal_is_caught_on_readback(): + class Lossy(FakeBao): + def __call__(self, args, *, payload=None): + result = super().__call__(args, payload=payload) + if args[0] == "write" and args[-2] == SEED.REGISTRY: + rows = registry_rows(self)[1:] # someone else dropped tenant-engine + self.kv[SEED.REGISTRY][1]["senders.json"] = json.dumps(rows) + return result + + with pytest.raises(SEED.Refused, match="registry_readback_mismatch"): + run_seed(Lossy()) + + +# --- contract checks ---------------------------------------------------------- + +def approved_ccr(): + doc, _ = SEED.load_ccr() + doc = copy.deepcopy(doc) + doc["status"] = "approved" + doc["review"]["comments"] = [ + {"reviewer": owner, "decision": "approved"} for owner in SEED.CCR_APPROVERS + ] + return doc + + +@pytest.mark.skipif(not SEED.CCR_FILE.exists(), reason="railiance-platform checkout absent") +def test_ccr_contract_as_drafted_matches_and_refuses_until_approved(): + pytest.importorskip("yaml") + SEED.check_ccr(approved_ccr()) + proposed, _ = SEED.load_ccr() + with pytest.raises(SEED.Refused, match="approved_ccr_required"): + SEED.check_ccr(proposed) + partial = approved_ccr() + partial["review"]["comments"] = partial["review"]["comments"][:2] + with pytest.raises(SEED.Refused, match="named_owner_reviews_required"): + SEED.check_ccr(partial) + moved = approved_ccr() + moved["openbao"]["kv_path"] = "platform/workloads/net-kingdom/other" + with pytest.raises(SEED.Refused, match="ccr_custody_contract_mismatch"): + SEED.check_ccr(moved) + + +def test_wrong_confirmation_refuses_before_any_call(tmp_path, monkeypatch): + monkeypatch.setattr(SEED, "bao", lambda *a, **k: pytest.fail("bao called")) + receipt = tmp_path / "r.json" + rc = SEED.main(["--kubeconfig", "/nonexistent", "--confirm", "yes", "--receipt", str(receipt)]) + assert rc == 1 + doc = json.loads(receipt.read_text()) + assert doc["error"] == "exact_confirmation_required" and doc["credential_values_emitted"] is False + # Receipt is never overwritten: a second run needs a fresh path. + with pytest.raises(FileExistsError): + SEED.main(["--kubeconfig", "/nonexistent", "--confirm", SEED.CONFIRM, "--receipt", str(receipt)]) + + +def test_wrapper_is_silent_and_pins_the_confirmation(): + text = (ROOT / "scripts" / "attended-seed-nk-bounded-executor-sender.sh").read_text() + assert ">/dev/null 2>&1" in text and SEED.CONFIRM in text + + +# --- the seeded entry and the live probe, against the real receiver ----------- + +def test_seeded_entry_yields_the_declared_identity_with_the_live_overlay(): + entry = SEED.desired(FAKE, SEED.scope_entry()) + reg = SenderRegistry.from_env({ + "AUDIT_CORE_SENDERS": json.dumps(OTHER + [entry]), + "AUDIT_CORE_SENDERS_SCOPE_PATH": str(ROOT / "deploy" / "senders-scope.json"), + }) + identity = reg.authenticate(f"Bearer {FAKE}") + assert identity.name == "nk-bounded-executor" + assert identity.sources == frozenset({"net-kingdom.bounded-executor"}) + assert identity.tenants == frozenset({"platform"}) + assert identity.may_write and not identity.may_read + assert identity.secret_policy == "reject" and identity.is_load_bearing + + +def test_probe_expectations_match_the_receiver(tmp_path): + """Runs the probe source itself against the real receiver over HTTP.""" + entry = SEED.desired(FAKE, SEED.scope_entry()) + reg = SenderRegistry.from_env({"AUDIT_CORE_SENDERS": json.dumps(OTHER + [entry])}) + app = IngestionApplication(SQLiteAuditBackend(str(tmp_path / "p.db")), reg) + server = make_server("127.0.0.1", 0, app, handler_class=_Quiet) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + source = SEED.RECEIVER_PROBE.replace("127.0.0.1:8080", f"127.0.0.1:{server.server_port}") + + def run(token): + proc = subprocess.run([sys.executable, "-c", source], input=token.encode(), + capture_output=True, check=True, timeout=30) + return json.loads(proc.stdout) + + assert run(FAKE) == SEED.PROBE_EXPECTED + # An unknown bearer gets 401 everywhere, so the expected pattern proves registration. + assert set(run("fake-unknown").values()) == {401} + finally: + server.shutdown() diff --git a/workplans/AUDIT-WP-0018-bounded-executor-receipt-custody.md b/workplans/AUDIT-WP-0018-bounded-executor-receipt-custody.md index d86d514..bdd626d 100644 --- a/workplans/AUDIT-WP-0018-bounded-executor-receipt-custody.md +++ b/workplans/AUDIT-WP-0018-bounded-executor-receipt-custody.md @@ -9,7 +9,7 @@ flavor: implementation owner: audit-core topic_slug: audit-evidence-dependencies created: "2026-10-31" -updated: "2026-11-03" +updated: "2026-11-04" related: - NK-WP-0056 - NK-WP-0040 @@ -129,6 +129,58 @@ Code: `audit_core/stream_findings.py` (`evaluate_closing`) and completed. The finding stays inert until `nk-bounded-executor` has a seeded token and emits. +## Prepare the founder-run seed of the sender bearer + +```task +id: AUDIT-WP-0018-T04 +status: done +priority: high +``` + +NK-WP-0056-T15 needs a bearer for `nk-bounded-executor`. Audit-core's registry +must hold the bearer, and so must railiance-platform's CCR-2026-0063 custody path +`platform/workloads/net-kingdom/bounded-executor/audit-sender` (`AUDIT_TOKEN`). +ESO projects that path to `sso/nk-bind-executor-audit:audit-token`. + +Done 2026-11-04. The script +`scripts/seed-nk-bounded-executor-sender.py` has a silent wrapper, +`scripts/attended-seed-nk-bounded-executor-sender.sh`, and runs in one attended +process: + +- mint once; +- custody written with cas 0; +- registry merged with cas; +- `audit-core-senders` synced and checked by hash; +- receiver restarted; +- a non-writing probe of the live scope; +- the `sso` copy checked by hash, when its ExternalSecret exists. + +Tests: `tests/test_seed_nk_bounded_executor_sender.py` (16, synthetic). Founder +commands: `docs/bounded-executor-receipts.md`, "Founder runbook". A read-only +check on 2026-11-04 found the ingress policy not live. The other nine policies +in `deploy/networkpolicies.yaml` match live. + +## Founder-run live activation of the sender and ingress + +```task +id: AUDIT-WP-0018-T05 +status: wait +priority: high +``` + +Run the runbook steps in order: + +1. **Ingress apply (NK-WP-0056-T16):** runnable now. +2. **Read-only preflight.** +3. **Seed and reload (NK-WP-0056-T15):** CCR-2026-0063 must be approved by its + three owners. Railiance-platform must also have applied the reader policy, + the role and the coding-agent boundary entries. +4. **Verify from the receipt.** + +`sso` delivery is complete only when rapp-bounded-executor carries the +ClusterSecretStore and the ExternalSecret `nk-bind-executor-audit`, and mounts +key `audit-token`. Today it mounts `bounded-executor-tokens:audit-core`. + ## Founder decision 2026-10-31 Approved by the founder (`proposed` -> `ready`) after the NK-WP-0056-T02 owner answers (hub decision 19afd15e). Founder adopted the `execution.run.started` event plus one receipt per run with an admission-level target/action, `steps[]` detail and labelled `decision_refs`.