diff --git a/credential-change-requests/CCR-2026-0039-fluid-telegram-hall-of-helix-operator-matrix.yaml b/credential-change-requests/CCR-2026-0039-fluid-telegram-hall-of-helix-operator-matrix.yaml new file mode 100644 index 0000000..a44483d --- /dev/null +++ b/credential-change-requests/CCR-2026-0039-fluid-telegram-hall-of-helix-operator-matrix.yaml @@ -0,0 +1,98 @@ +id: CCR-2026-0039 +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-operator-matrix +title: fluid-telegram hall-of-helix attended operator KV lane (per-path matrix, OIDC aal2) +status: proposed +created: '2026-10-09' +updated: '2026-10-09' +requester: + agent: railiance-platform + reason: RPF-WP-0035-T03. Founder approved ops-mason plan fluid-telegram-operator-credential-lane + 2026-10-09 (approved_spec_sha256 41a0ca2746aa93b6ee9d1ca631517d680ade95c56e7a38b75a55209ef2316b73) + and ruled the tenant segment stays coulomb (ops-mason message b72ff77d). Design + docs/credential-lane-designs/fluid-telegram-operator-kv.md; key-cape confirmed groups + and aal2 claims (message b2ed890f, key-cape e0b5fae). +review: + required: true + required_approvers: + - platform-owner + - fluid-telegram-owner + - key-cape-owner + comments: [] +target: + domain: infotech + tenant: coulomb + workload: fluid-telegram + environment: production + purpose: Let a member of fluid-telegram-operators with a verified second factor run the + attended provisioner for campaign hall-of-helix with exactly the four-entry matrix. +openbao: + mount: platform + campaign_path: platform/workloads/coulomb/fluid-telegram/hall-of-helix + policy_name: workload-kv-fluid-telegram-hall-of-helix-operator + policy_file: openbao/policies/workload-kv-fluid-telegram-hall-of-helix-operator.hcl + boundary_overlay_file: openbao/fluid-telegram/agent-deny-overlay.hcl + matrix: + - entry: operator-app + fields: [api_id, api_hash] + capabilities: [read] + - entry: operator-session + fields: [session_b64] + capabilities: [create, read, update] + - entry: bot-token + fields: [token] + capabilities: [create, read, update] + - entry: redaction-salt + fields: [salt] + capabilities: [create, read] + auth: + method: oidc + mount: netkingdom + role: fluid-telegram-hall-of-helix-workload-kv + role_file: openbao/auth/fluid-telegram-hall-of-helix-oidc-role.json + bound_claims_confirmed: false +founder_confirmation_items: +- bound_claims_confirmed stays false until the founder confirms groups=[fluid-telegram-operators] + (LLDAP group, single member tegwick) and /assurance/level=aal2 against the key-cape + confirmation b2ed890f. applier-dry-run refuses until then. +- The matrix grants no metadata, list, patch or delete. The salt is create-only by + ACL; first-write CAS 0 is the consumer's job (FT-WP-0002) and is verified by an + owner-run probe, not by ACL parameters. +- The apply appends exact coding-agent denies on all four data and metadata paths to + the live agent-high-risk-boundary policy, preserving its other content verbatim, + and refuses if only some of those denies are already present. +access_frontdoor: + type: none + catalog_id: none +risk: + level: high + notes: + - operator-session is a full Telegram account session. Membership of + fluid-telegram-operators is the whole access decision; adding a member widens + this lane's reach. + - Tokens are 15m (30m maximum), no default policy, num_uses 0 so MTProto session + persistence is not interrupted; the provisioner self-revokes at completion. +verification: + positive: + - Readback after apply matches source for boundary denies, policy text and role + (done by applier-apply; prints nothing). + - An approved member's token reports exactly the matrix capabilities on each entry. + negative: + - Parent, sibling workload and sibling campaign paths report deny. + - A non-member, or a member without aal2, cannot obtain the role. + - Coding-agent identities report deny on all four data and metadata paths. + - Owner-run disposable probe: salt create succeeds, a second write is denied. + activation_conditions: + - Founder approval (three reviews) and bound_claims_confirmed true. + - Custody moved from the infotech prefix with scripts/fluid_telegram_custody_move.py. + evidence: [] +lifecycle: + deactivate: Delete auth/netkingdom/role/fluid-telegram-hall-of-helix-workload-kv first, + then the policy; KV entries stay in custody pending an explicit retention decision. + rotate: Session compromise needs Telegram-side session revocation; bot-token rotation + needs provider replacement; salt rotation needs a data-owner decision. + compromised: Delete the role, revoke issued tokens, revoke the Telegram session. +state_hub: + workplan: RPF-WP-0035 + task: RPF-WP-0035-T03 diff --git a/docs/credential-lane-designs/fluid-telegram-operator-kv.md b/docs/credential-lane-designs/fluid-telegram-operator-kv.md index 98abf0e..8bdc694 100644 --- a/docs/credential-lane-designs/fluid-telegram-operator-kv.md +++ b/docs/credential-lane-designs/fluid-telegram-operator-kv.md @@ -1,6 +1,8 @@ # Fluid-telegram attended operator KV lane -Status: proposed, not provisioned. Owner: railiance-platform, RPF-WP-0033. +Status: source ready as CCR-2026-0039 (2026-10-09), not provisioned. Owner: +railiance-platform, RPF-WP-0033 → RPF-WP-0035-T03. Tenant `coulomb` ruled by +the founder 2026-10-09. Demand: State Hub message `170b9127-f18f-4045-8f64-5c211a3aa187`. Construction reference: `../ops-mason/plans/fluid-telegram-operator-credential-lane.md`. Consumer source: `../fluid-telegram/internal/secrets/secrets.go`, `session.go` diff --git a/openbao/auth/fluid-telegram-hall-of-helix-oidc-role.json b/openbao/auth/fluid-telegram-hall-of-helix-oidc-role.json new file mode 100644 index 0000000..b9d301b --- /dev/null +++ b/openbao/auth/fluid-telegram-hall-of-helix-oidc-role.json @@ -0,0 +1,24 @@ +{ + "role_type": "oidc", + "allowed_redirect_uris": [ + "http://localhost:8250/oidc/callback", + "http://127.0.0.1:8250/oidc/callback", + "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback" + ], + "oidc_scopes": ["openid", "profile", "email", "groups"], + "user_claim": "sub", + "groups_claim": "groups", + "bound_claims_type": "string", + "bound_claims": { + "groups": ["fluid-telegram-operators"], + "/assurance/level": "aal2" + }, + "token_policies": ["workload-kv-fluid-telegram-hall-of-helix-operator"], + "token_no_default_policy": true, + "token_type": "service", + "token_ttl": "15m", + "token_max_ttl": "30m", + "token_explicit_max_ttl": "30m", + "token_num_uses": 0, + "verbose_oidc_logging": false +} diff --git a/openbao/fluid-telegram/agent-deny-overlay.hcl b/openbao/fluid-telegram/agent-deny-overlay.hcl new file mode 100644 index 0000000..42095ee --- /dev/null +++ b/openbao/fluid-telegram/agent-deny-overlay.hcl @@ -0,0 +1,9 @@ +# CCR-2026-0039: coding-agent denies for platform/workloads/coulomb/fluid-telegram/hall-of-helix (data and metadata). +path "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/operator-app" { capabilities = ["deny"] } +path "platform/metadata/workloads/coulomb/fluid-telegram/hall-of-helix/operator-app" { capabilities = ["deny"] } +path "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/operator-session" { capabilities = ["deny"] } +path "platform/metadata/workloads/coulomb/fluid-telegram/hall-of-helix/operator-session" { capabilities = ["deny"] } +path "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/bot-token" { capabilities = ["deny"] } +path "platform/metadata/workloads/coulomb/fluid-telegram/hall-of-helix/bot-token" { capabilities = ["deny"] } +path "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/redaction-salt" { capabilities = ["deny"] } +path "platform/metadata/workloads/coulomb/fluid-telegram/hall-of-helix/redaction-salt" { capabilities = ["deny"] } diff --git a/openbao/policies/agent-high-risk-boundary.hcl b/openbao/policies/agent-high-risk-boundary.hcl index eb3eb85..6f9775e 100644 --- a/openbao/policies/agent-high-risk-boundary.hcl +++ b/openbao/policies/agent-high-risk-boundary.hcl @@ -143,3 +143,13 @@ path "platform/data/workloads/approval-engine/audit-sender" { capabilities = ["d path "platform/metadata/workloads/approval-engine/audit-sender" { capabilities = ["deny"] } path "platform/data/workloads/informed-decision/audit-sender" { capabilities = ["deny"] } path "platform/metadata/workloads/informed-decision/audit-sender" { capabilities = ["deny"] } + +# CCR-2026-0039: coding-agent denies for platform/workloads/coulomb/fluid-telegram/hall-of-helix (data and metadata). +path "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/operator-app" { capabilities = ["deny"] } +path "platform/metadata/workloads/coulomb/fluid-telegram/hall-of-helix/operator-app" { capabilities = ["deny"] } +path "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/operator-session" { capabilities = ["deny"] } +path "platform/metadata/workloads/coulomb/fluid-telegram/hall-of-helix/operator-session" { capabilities = ["deny"] } +path "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/bot-token" { capabilities = ["deny"] } +path "platform/metadata/workloads/coulomb/fluid-telegram/hall-of-helix/bot-token" { capabilities = ["deny"] } +path "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/redaction-salt" { capabilities = ["deny"] } +path "platform/metadata/workloads/coulomb/fluid-telegram/hall-of-helix/redaction-salt" { capabilities = ["deny"] } diff --git a/openbao/policies/workload-kv-fluid-telegram-hall-of-helix-operator.hcl b/openbao/policies/workload-kv-fluid-telegram-hall-of-helix-operator.hcl new file mode 100644 index 0000000..19db1a7 --- /dev/null +++ b/openbao/policies/workload-kv-fluid-telegram-hall-of-helix-operator.hcl @@ -0,0 +1,16 @@ +# CCR-2026-0039 (RPF-WP-0035-T03): exact per-entry operator matrix; no metadata, list or delete. +path "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/operator-app" { + capabilities = ["read"] +} +path "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/operator-session" { + capabilities = ["create", "read", "update"] +} +path "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/bot-token" { + capabilities = ["create", "read", "update"] +} +path "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/redaction-salt" { + capabilities = ["create", "read"] +} +path "auth/token/revoke-self" { + capabilities = ["update"] +} diff --git a/scripts/credential-change.py b/scripts/credential-change.py index 20397e5..e129d15 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -516,8 +516,8 @@ def validate_ccr(path: Path) -> tuple[dict[str, Any], list[str], list[str]]: owner = importlib.util.module_from_spec(spec) spec.loader.exec_module(owner) errors.extend(owner.validate_contract(ccr)) - elif request_type == "service-jwt-login": - errors.extend(service_jwt_lane().validate(ccr, REPO_DIR)) + elif request_type in LANE_MODULES: + errors.extend(lane_module(ccr).validate(ccr, REPO_DIR)) else: errors.append("unsupported request_type") return ccr, errors, warnings @@ -819,6 +819,25 @@ def is_service_jwt(ccr: dict[str, Any]) -> bool: return ccr.get("request_type") == "service-jwt-login" +# Request types whose delegated applier lives in its own reviewed module. +LANE_MODULES = { + "service-jwt-login": "service_jwt_lane", + "workload-kv-operator-matrix": "operator_matrix_lane", +} + + +def lane_module(ccr: dict[str, Any]): + """Lazy-load the applier module for a lane request type, or None.""" + name = LANE_MODULES.get(ccr.get("request_type")) + if name is None: + return None + import importlib.util + spec = importlib.util.spec_from_file_location(name, REPO_DIR / f"scripts/{name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def applier_policy_violations(ccr: dict[str, Any]) -> list[str]: violations: list[str] = [] if ccr.get("request_type") != "workload-kv-read": @@ -946,8 +965,9 @@ def applier_policy_violations(ccr: dict[str, Any]) -> list[str]: def applier_readiness_blockers(ccr: dict[str, Any]) -> list[str]: - if is_service_jwt(ccr): - return service_jwt_lane().readiness_blockers(ccr, REPO_DIR, APPLIER_DRY_RUN_ALLOWED_STATUSES) + lane = lane_module(ccr) + if lane is not None: + return lane.readiness_blockers(ccr, REPO_DIR, APPLIER_DRY_RUN_ALLOWED_STATUSES) blockers: list[str] = [] status = ccr.get("status") if status not in APPLIER_DRY_RUN_ALLOWED_STATUSES: @@ -962,8 +982,9 @@ def applier_readiness_blockers(ccr: dict[str, Any]) -> list[str]: def applier_dry_run_payload(ccr: dict[str, Any], warnings: list[str]) -> dict[str, Any]: - if is_service_jwt(ccr): - return service_jwt_lane().dry_run_payload(ccr, REPO_DIR, warnings) + lane = lane_module(ccr) + if lane is not None: + return lane.dry_run_payload(ccr, REPO_DIR, warnings) openbao = ccr["openbao"] auth = openbao["auth"] auth_path = f"auth/{auth['mount']}/role/{auth['role']}" @@ -1052,8 +1073,9 @@ def applier_confirmation_phrase(ccr: dict[str, Any]) -> str: def delegated_apply_details(ccr: dict[str, Any], actor: str) -> list[str]: - if is_service_jwt(ccr): - return service_jwt_lane().apply_details(ccr, actor) + lane = lane_module(ccr) + if lane is not None: + return lane.apply_details(ccr, actor) openbao = ccr["openbao"] auth = openbao["auth"] return [ @@ -1214,8 +1236,8 @@ def render_runbook(payload: dict[str, Any]) -> str: def run_bao_metadata_apply(ccr: dict[str, Any], bao_bin: str, quiet: bool = False) -> None: - if is_service_jwt(ccr): - lane = service_jwt_lane() + lane = lane_module(ccr) + if lane is not None: try: lane.run_apply(ccr, REPO_DIR, bao_bin, quiet=quiet) except (lane.BaoError, subprocess.CalledProcessError) as exc: diff --git a/scripts/fluid_telegram_custody_move.py b/scripts/fluid_telegram_custody_move.py new file mode 100644 index 0000000..a10ab54 --- /dev/null +++ b/scripts/fluid_telegram_custody_move.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""RPF-WP-0035-T03: attended, silent move of fluid-telegram custody to tenant coulomb. + +Founder ruling 2026-10-09 (ops-mason message b72ff77d): the hall-of-helix entries +found under the undocumented infotech prefix move to the approved coulomb prefix +that CCR-2026-0039 grants. For each of bot-token, operator-app, operator-session +and redaction-salt this process reads the current version at +``platform/workloads/infotech/fluid-telegram/hall-of-helix/``, writes it to +``platform/workloads/coulomb/fluid-telegram/hall-of-helix/`` with CAS 0 +and verifies the readback. Only after all four are verified, and no source +changed during the run, are the infotech entries metadata-deleted. Values only +travel through this process's memory and the ``bao`` child's stdin. + +Run from a railiance-platform checkout at the reviewed commit: + + BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 \\ + warden access openbao-platform-admin-login --exec -- \\ + python3 scripts/fluid_telegram_custody_move.py \\ + --confirm 'MOVE fluid-telegram hall-of-helix to coulomb' \\ + --receipt "$HOME/ft-custody-move-receipt.json" + +``--dry-run`` touches only KV metadata. ``--keep-source`` copies and verifies +but does not delete. A rerun after a completed move reports ``already_moved``. + +The process prints nothing (stdout/stderr go to /dev/null). The optional +receipt (mode 0600, refused if it exists) holds versions, booleans and an +outcome code only. + +Exit codes: + 0 moved (or already moved / already identical); dry-run: a move would run + 1 unexpected internal error (contained) + 2 precondition refused before any write (confirmation, envelope, identity, + an entry absent at both prefixes, absence not provable, receipt exists) + 3 a coulomb entry exists and differs, or a current version is not live; + never overwritten + 4 write or delete failed + 5 verify failed: readback differs, or a source changed during the run + 6 dry-run only: some coulomb entries already exist +""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from tenant_engine_audit_core_sender_seed import Exit, bao, live_version, read_json # noqa: E402 + +SOURCE = 'workloads/infotech/fluid-telegram/hall-of-helix' +TARGET = 'workloads/coulomb/fluid-telegram/hall-of-helix' +ENTRIES = ('bot-token', 'operator-app', 'operator-session', 'redaction-salt') +CONFIRM = 'MOVE fluid-telegram hall-of-helix to coulomb' +SCHEMA = 'platform.fluid-telegram-custody-move.v1' + + +def meta(prefix, entry): + return read_json(f'platform/metadata/{prefix}/{entry}', absent_ok=True) + + +def data(prefix, entry, code=2): + raw = read_json(f'platform/data/{prefix}/{entry}', absent_ok=False, code=code) + try: + return raw['data'], raw['metadata']['version'] + except (KeyError, TypeError): + raise Exit(code, 'unparseable_read') from None + + +def run(args, receipt): + if args.confirm != CONFIRM: + raise Exit(2, 'exact_confirmation_required') + if (Path.home().parent.name != '.warden-attended-login' + or os.environ.get('BAO_TOKEN') or os.environ.get('VAULT_TOKEN')): + raise Exit(2, 'attended_warden_envelope_required') + ident = read_json('auth/token/lookup-self', absent_ok=False) + policies = ident.get('policies') or [] + if 'platform-admin' not in policies or 'root' in policies: + raise Exit(2, 'attended_platform_admin_required') + + rows = receipt['entries'] + plan = [] + for entry in ENTRIES: + src, dst = meta(SOURCE, entry), meta(TARGET, entry) + row = rows[entry] = {'source_present': src is not None, 'target_present': dst is not None} + if src is None and dst is None: + raise Exit(2, 'entry_absent_at_both_prefixes') + if src is not None: + if live_version(src) is None: + raise Exit(3, 'source_current_version_not_live') + row['source_version'] = src['current_version'] + if dst is not None: + if live_version(dst) is None: + raise Exit(3, 'target_current_version_not_live') + row['target_version'] = dst['current_version'] + plan.append((entry, src, dst)) + + if args.dry_run: + receipt['value_reads'] = 0 + if any(dst is not None for _, src, dst in plan if src is not None): + raise Exit(6, 'dry_run_target_present') + return 'dry_run_would_move' if any(src for _, src, _ in plan) else 'dry_run_already_moved' + + if all(src is None for _, src, _ in plan): + return 'already_moved' + + # Phase 1: copy and verify every entry before anything is deleted. + for entry, src, dst in plan: + if src is None: + continue + value, version = data(SOURCE, entry) + if version != src['current_version']: + raise Exit(5, 'source_changed_during_run') + row = rows[entry] + if dst is not None: + current, _ = data(TARGET, entry, code=3) + if current != value: + raise Exit(3, 'target_differs') + row['outcome'] = 'already_identical' + continue + result = bao(['write', '-format=json', f'platform/data/{TARGET}/{entry}', '-'], + payload={'options': {'cas': 0}, 'data': value}) + if result.returncode != 0: + raise Exit(4, 'write_failed') + back, back_version = data(TARGET, entry, code=5) + if back != value or back_version != 1: + raise Exit(5, 'readback_mismatch') + row['target_version'] = 1 + row['outcome'] = 'copied' + + for entry, src, _ in plan: + if src is None: + continue + again = meta(SOURCE, entry) + if again is None or again.get('current_version') != src['current_version']: + raise Exit(5, 'source_changed_during_run') + + if args.keep_source: + return 'copied_source_kept' + + # Phase 2: remove the undocumented infotech entries (all versions). + for entry, src, _ in plan: + if src is None: + continue + if bao(['delete', f'platform/metadata/{SOURCE}/{entry}']).returncode != 0: + raise Exit(4, 'source_delete_failed') + if meta(SOURCE, entry) is not None: + raise Exit(5, 'source_still_present') + rows[entry]['source_deleted'] = True + return 'moved' + + +def main(argv=None): + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, 1) + os.dup2(devnull, 2) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--confirm', default='') + parser.add_argument('--receipt', type=Path) + parser.add_argument('--dry-run', action='store_true') + parser.add_argument('--keep-source', action='store_true') + try: + args = parser.parse_args(argv) + except SystemExit: + return 2 + receipt = {'schema': SCHEMA, 'mode': 'dry-run' if args.dry_run else 'move', + 'source': 'platform/' + SOURCE, 'target': 'platform/' + TARGET, + 'entries': {}, 'credential_values_emitted': False} + fd = None + if args.receipt is not None: + try: + fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + except OSError: + return 2 + try: + receipt['outcome'] = run(args, receipt) + code = 0 + except Exit as exc: + receipt['outcome'] = exc.reason + code = exc.code + except Exception: + receipt['outcome'] = 'contained_internal_error' + code = 1 + receipt['exit_code'] = code + if fd is not None: + os.write(fd, (json.dumps(receipt, indent=2, sort_keys=True) + '\n').encode()) + os.close(fd) + return code + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/kv_duplicate_retire.py b/scripts/kv_duplicate_retire.py new file mode 100644 index 0000000..c5748c6 --- /dev/null +++ b/scripts/kv_duplicate_retire.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Attended, silent retirement of a duplicate KV entry nothing may still read. + +Founder ruling 2026-10-09 (ops-mason message b72ff77d): +``platform/workloads/activity-core/default/llm-connect-provider-secrets`` (v1, +2026-06-18) duplicates the live +``platform/workloads/activity-core/llm-connect/llm-connect-provider-secrets`` and +no source references it. Before the metadata delete this process proves: + +* the canonical entry exists with a live current version; +* the duplicate exists with exactly the expected version count; +* no live ACL policy other than platform-admin grants a non-deny capability on + the duplicate's data or metadata path (exact, ``+`` and trailing ``*`` globs). + +No value is read. Run from a railiance-platform checkout: + + BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 \\ + warden access openbao-platform-admin-login --exec -- \\ + python3 scripts/kv_duplicate_retire.py \\ + --confirm 'RETIRE activity-core/default/llm-connect-provider-secrets' \\ + --receipt "$HOME/kv-duplicate-retire-receipt.json" + +``--dry-run`` runs every check and deletes nothing. The process prints nothing; +the receipt (mode 0600, refused if it exists) holds versions, booleans, the +names of granting policies and an outcome code. + +Exit codes: 0 retired / dry-run clean / already absent; 1 contained internal +error; 2 precondition refused; 3 a live policy still grants the duplicate; +4 delete failed; 5 duplicate still present after delete. +""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from tenant_engine_audit_core_sender_seed import Exit, bao, live_version, read_json # noqa: E402 + +DUPLICATE = 'workloads/activity-core/default/llm-connect-provider-secrets' +CANONICAL = 'workloads/activity-core/llm-connect/llm-connect-provider-secrets' +EXPECTED_VERSIONS = 1 +CONFIRM = 'RETIRE activity-core/default/llm-connect-provider-secrets' +SCHEMA = 'platform.kv-duplicate-retire.v1' +EXEMPT_POLICIES = {'platform-admin', 'root'} +STANZA_RE = re.compile(r'path\s+"([^"]+)"\s*\{(.*?)\}', re.S) +CAPS_RE = re.compile(r'capabilities\s*=\s*\[([^\]]*)\]') + + +def glob_matches(pattern, path): + regex = ''.join('[^/]+' if part == '+' else re.escape(part) + for part in re.split(r'(\+)', pattern.rstrip('*'))) + return re.fullmatch(regex + ('.*' if pattern.endswith('*') else ''), path) is not None + + +def granting_stanzas(policy_text, paths): + """Patterns in ``policy_text`` that grant anything but deny on one of ``paths``.""" + hits = [] + for pattern, body in STANZA_RE.findall(policy_text): + caps = CAPS_RE.search(body) + names = {c.strip().strip('"') for c in caps.group(1).split(',')} if caps else set() + if names - {'deny', ''} and 'deny' not in names and any(glob_matches(pattern, p) for p in paths): + hits.append(pattern) + return hits + + +def policy_names(): + result = bao(['policy', 'list', '-format=json']) + if result.returncode != 0: + raise Exit(2, 'policy_list_failed') + try: + names = json.loads(result.stdout) + except ValueError: + raise Exit(2, 'policy_list_unparseable') from None + if not isinstance(names, list) or not names: + raise Exit(2, 'policy_list_unparseable') + return names + + +def run(args, receipt): + if args.confirm != CONFIRM: + raise Exit(2, 'exact_confirmation_required') + if (Path.home().parent.name != '.warden-attended-login' + or os.environ.get('BAO_TOKEN') or os.environ.get('VAULT_TOKEN')): + raise Exit(2, 'attended_warden_envelope_required') + ident = read_json('auth/token/lookup-self', absent_ok=False) + policies = ident.get('policies') or [] + if 'platform-admin' not in policies or 'root' in policies: + raise Exit(2, 'attended_platform_admin_required') + + canonical = read_json('platform/metadata/' + CANONICAL, absent_ok=True) + receipt['canonical_live'] = canonical is not None and live_version(canonical) is not None + if not receipt['canonical_live']: + raise Exit(2, 'canonical_not_live') + duplicate = read_json('platform/metadata/' + DUPLICATE, absent_ok=True) + receipt['duplicate_present_before'] = duplicate is not None + if duplicate is None: + return 'already_absent' + receipt['duplicate_versions'] = len(duplicate.get('versions') or {}) + if duplicate.get('current_version') != EXPECTED_VERSIONS or receipt['duplicate_versions'] != EXPECTED_VERSIONS: + raise Exit(2, 'duplicate_version_unexpected') + + paths = ['platform/data/' + DUPLICATE, 'platform/metadata/' + DUPLICATE] + granting = {} + for name in policy_names(): + if name in EXEMPT_POLICIES: + continue + result = bao(['policy', 'read', name]) + if result.returncode != 0: + raise Exit(2, 'policy_read_failed') + hits = granting_stanzas(result.stdout.decode(), paths) + if hits: + granting[name] = hits + receipt['granting_policies'] = granting + if granting: + raise Exit(3, 'live_policy_grants_duplicate') + if args.dry_run: + return 'dry_run_would_retire' + + if bao(['delete', 'platform/metadata/' + DUPLICATE]).returncode != 0: + raise Exit(4, 'delete_failed') + if read_json('platform/metadata/' + DUPLICATE, absent_ok=True) is not None: + raise Exit(5, 'duplicate_still_present') + receipt['duplicate_deleted'] = True + return 'retired' + + +def main(argv=None): + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, 1) + os.dup2(devnull, 2) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--confirm', default='') + parser.add_argument('--receipt', type=Path) + parser.add_argument('--dry-run', action='store_true') + try: + args = parser.parse_args(argv) + except SystemExit: + return 2 + receipt = {'schema': SCHEMA, 'mode': 'dry-run' if args.dry_run else 'retire', + 'duplicate': 'platform/' + DUPLICATE, 'canonical': 'platform/' + CANONICAL, + 'value_reads': 0} + fd = None + if args.receipt is not None: + try: + fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + except OSError: + return 2 + try: + receipt['outcome'] = run(args, receipt) + code = 0 + except Exit as exc: + receipt['outcome'] = exc.reason + code = exc.code + except Exception: + receipt['outcome'] = 'contained_internal_error' + code = 1 + receipt['exit_code'] = code + if fd is not None: + os.write(fd, (json.dumps(receipt, indent=2, sort_keys=True) + '\n').encode()) + os.close(fd) + return code + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/operator_matrix_lane.py b/scripts/operator_matrix_lane.py new file mode 100644 index 0000000..136b14a --- /dev/null +++ b/scripts/operator_matrix_lane.py @@ -0,0 +1,358 @@ +"""workload-kv-operator-matrix CCRs: one attended OIDC role, one per-path KV policy. + +RPF-WP-0035-T03 (design docs/credential-lane-designs/fluid-telegram-operator-kv.md). +Loaded by scripts/credential-change.py for request_type workload-kv-operator-matrix. +Unlike workload-kv-read, the policy is a reviewed matrix of exact entries, each +with its own capabilities (read, create, update), and the apply also adds exact +coding-agent denies on every entry's data and metadata path to the shared +agent-high-risk-boundary policy. Live unrelated boundary content is preserved +verbatim. Nothing here reads, prints or accepts a secret value; the role is +written to an existing OIDC mount, never a new one. +""" +from __future__ import annotations + +import importlib.util +import json +import re +import tempfile +from pathlib import Path +from typing import Any + +REQUEST_TYPE = "workload-kv-operator-matrix" +ALLOWED_AUTH_MOUNTS = {"netkingdom"} +ALLOWED_CAPABILITIES = ("create", "read", "update") +SELF_GRANTS = (("auth/token/revoke-self", "update"),) +BOUNDARY_POLICY = "agent-high-risk-boundary" +BOUNDARY_FILE = "openbao/policies/agent-high-risk-boundary.hcl" +CAMPAIGN_RE = re.compile(r"^platform/workloads/[a-z0-9-]+/[a-z0-9-]+/[a-z0-9-]+$") +ENTRY_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +FIELD_RE = re.compile(r"^[a-z][a-z0-9_]*$") +POLICY_NAME_RE = re.compile(r"^workload-kv-[a-z0-9-]+-operator$") +ROLE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +GROUP_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +OIDC_SCOPES = ["openid", "profile", "email", "groups"] +# Loopback CLI callbacks and the operator-tunnel UI callback; never a public listener. +ALLOWED_REDIRECT_URIS = { + "http://localhost:8250/oidc/callback", + "http://127.0.0.1:8250/oidc/callback", + "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback", +} +MAX_TTL_SECONDS = 15 * 60 +MAX_EXPLICIT_TTL_SECONDS = 30 * 60 +ROLE_KEYS = { + "role_type", "allowed_redirect_uris", "oidc_scopes", "user_claim", "groups_claim", + "bound_claims_type", "bound_claims", "token_policies", "token_no_default_policy", + "token_type", "token_ttl", "token_max_ttl", "token_explicit_max_ttl", + "token_num_uses", "verbose_oidc_logging", +} +DURATION_KEYS = ("token_ttl", "token_max_ttl", "token_explicit_max_ttl") + + +def _service_jwt_lane(): + spec = importlib.util.spec_from_file_location( + "service_jwt_lane", Path(__file__).resolve().parent / "service_jwt_lane.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_shared = _service_jwt_lane() +ttl_seconds = _shared.ttl_seconds +normalized = _shared.normalized +BaoError = _shared.BaoError +_run = _shared._run +_read_json = _shared._read_json + + +def _kv_paths(ccr: dict[str, Any], entry: str) -> tuple[str, str]: + campaign = ccr["openbao"]["campaign_path"] + mount, rest = campaign.split("/", 1) + return f"{mount}/data/{rest}/{entry}", f"{mount}/metadata/{rest}/{entry}" + + +def generated_policy_hcl(ccr: dict[str, Any]) -> str: + lines = [f"# {ccr['id']} (RPF-WP-0035-T03): exact per-entry operator matrix; no metadata, list or delete."] + for row in ccr["openbao"]["matrix"]: + data_path, _ = _kv_paths(ccr, row["entry"]) + caps = ", ".join(f'"{c}"' for c in ALLOWED_CAPABILITIES if c in row["capabilities"]) + lines += [f'path "{data_path}" {{', f" capabilities = [{caps}]", "}"] + for path, capability in SELF_GRANTS: + lines += [f'path "{path}" {{', f' capabilities = ["{capability}"]', "}"] + return "\n".join(lines) + "\n" + + +def boundary_stanzas(ccr: dict[str, Any]) -> list[str]: + stanzas = [] + for row in ccr["openbao"]["matrix"]: + for path in _kv_paths(ccr, row["entry"]): + stanzas.append(f'path "{path}" {{ capabilities = ["deny"] }}') + return stanzas + + +def generated_boundary_overlay(ccr: dict[str, Any]) -> str: + header = f"# {ccr['id']}: coding-agent denies for {ccr['openbao']['campaign_path']} (data and metadata)." + return "\n".join([header, *boundary_stanzas(ccr)]) + "\n" + + +def _flat(text: str) -> set[str]: + return {" ".join(line.split()) for line in text.splitlines() if line.strip()} + + +def boundary_presence(ccr: dict[str, Any], policy_text: str) -> tuple[int, int]: + present = _flat(policy_text) + stanzas = boundary_stanzas(ccr) + return sum(1 for s in stanzas if " ".join(s.split()) in present), len(stanzas) + + +def role_payload(ccr: dict[str, Any], repo_dir: Path) -> dict[str, Any]: + return _shared._load_json(repo_dir, ccr["openbao"]["auth"]["role_file"], "openbao.auth.role_file", []) + + +def _validate_matrix(openbao: dict[str, Any], errors: list[str]) -> None: + matrix = openbao.get("matrix") + if not isinstance(matrix, list) or not matrix: + errors.append("openbao.matrix must list the exact entries") + return + seen: set[str] = set() + for row in matrix: + if not isinstance(row, dict): + errors.append("openbao.matrix rows must be objects") + continue + entry = str(row.get("entry") or "") + if not ENTRY_RE.match(entry): + errors.append(f"openbao.matrix entry must be one exact segment: {entry!r}") + if entry in seen: + errors.append(f"openbao.matrix entry is duplicated: {entry}") + seen.add(entry) + fields = row.get("fields") + if not isinstance(fields, list) or not fields or not all(FIELD_RE.match(str(f)) for f in fields): + errors.append(f"openbao.matrix {entry} fields must be safe field names") + caps = row.get("capabilities") + if not isinstance(caps, list) or not caps or len(set(caps)) != len(caps): + errors.append(f"openbao.matrix {entry} capabilities must be a non-empty unique list") + continue + unknown = sorted(set(caps) - set(ALLOWED_CAPABILITIES)) + if unknown: + errors.append(f"openbao.matrix {entry} has unsupported capabilities: {unknown}") + if "read" not in caps: + errors.append(f"openbao.matrix {entry} must include read") + + +def _validate_role(role: dict[str, Any], policy_name: str, errors: list[str]) -> None: + extra = set(role) - ROLE_KEYS + if extra: + errors.append(f"role has keys outside the reviewed set: {sorted(extra)}") + if role.get("role_type") != "oidc" or role.get("user_claim") != "sub": + errors.append("role must be role_type oidc with user_claim sub") + if role.get("groups_claim") != "groups" or role.get("oidc_scopes") != OIDC_SCOPES: + errors.append(f"role must use groups_claim groups and oidc_scopes {OIDC_SCOPES}") + uris = role.get("allowed_redirect_uris") + if not isinstance(uris, list) or not uris or not set(uris) <= ALLOWED_REDIRECT_URIS: + errors.append("role allowed_redirect_uris must be a subset of the approved loopback/tunnel callbacks") + if role.get("token_policies") != [policy_name]: + errors.append("role token_policies must be exactly [openbao.policy_name]") + if role.get("token_no_default_policy") is not True: + errors.append("role must set token_no_default_policy true") + if role.get("token_type") != "service": + errors.append("role token_type must be service") + if role.get("verbose_oidc_logging") is not False: + errors.append("role must disable verbose_oidc_logging") + claims = role.get("bound_claims") + if role.get("bound_claims_type") != "string" or not isinstance(claims, dict): + errors.append("role bound_claims must be string-typed") + claims = {} + if set(claims) != {"groups", "/assurance/level"}: + errors.append("role bound_claims must be exactly groups and /assurance/level") + groups = claims.get("groups") + if not isinstance(groups, list) or len(groups) != 1 or not GROUP_RE.match(str(groups[0])): + errors.append("role bound_claims.groups must be one exact group") + if claims.get("/assurance/level") != "aal2": + errors.append("role bound_claims./assurance/level must be aal2") + ttl = ttl_seconds(role.get("token_ttl")) + max_ttl = ttl_seconds(role.get("token_max_ttl")) + explicit = ttl_seconds(role.get("token_explicit_max_ttl")) + if ttl is None or not 0 < ttl <= MAX_TTL_SECONDS: + errors.append("role token_ttl must be set and at most 15m") + if any(v is None or not 0 < v <= MAX_EXPLICIT_TTL_SECONDS for v in (max_ttl, explicit)): + errors.append("role token_max_ttl and token_explicit_max_ttl must be set and at most 30m") + elif ttl is not None and ttl > min(max_ttl, explicit): + errors.append("role token_ttl must not exceed its maximums") + uses = role.get("token_num_uses") + if not isinstance(uses, int) or isinstance(uses, bool) or uses < 0: + errors.append("role token_num_uses must be a non-negative integer") + + +def validate(ccr: dict[str, Any], repo_dir: Path) -> list[str]: + """Schema plus every boundary the delegated applier relies on.""" + errors: list[str] = [] + openbao = ccr.get("openbao") + if not isinstance(openbao, dict): + return ["openbao must be an object"] + auth = openbao.get("auth") + if not isinstance(auth, dict): + return ["openbao.auth must be an object"] + if (ccr.get("target") or {}).get("environment") != "production": + errors.append("target.environment must be production") + review = ccr.get("review") or {} + if review.get("required") is not True or not review.get("required_approvers"): + errors.append("review.required must be true with required_approvers") + + campaign = str(openbao.get("campaign_path") or "") + if not CAMPAIGN_RE.match(campaign): + errors.append("openbao.campaign_path must be platform/workloads///") + _validate_matrix(openbao, errors) + if errors: + return errors + + policy_name = str(openbao.get("policy_name") or "") + if not POLICY_NAME_RE.match(policy_name): + errors.append("openbao.policy_name must be workload-kv-<...>-operator") + policy_file = str(openbao.get("policy_file") or "") + if policy_file != f"openbao/policies/{policy_name}.hcl": + errors.append("openbao.policy_file must be openbao/policies/.hcl") + elif not (repo_dir / policy_file).exists(): + errors.append(f"openbao.policy_file does not exist: {policy_file}") + elif normalized((repo_dir / policy_file).read_text(encoding="utf-8")) != normalized(generated_policy_hcl(ccr)): + errors.append("openbao.policy_file does not match the generated operator matrix policy") + + overlay_file = str(openbao.get("boundary_overlay_file") or "") + overlay_path = (repo_dir / overlay_file).resolve() + if not overlay_file or not overlay_path.exists(): + errors.append("openbao.boundary_overlay_file must name an existing file") + elif normalized(overlay_path.read_text(encoding="utf-8")) != normalized(generated_boundary_overlay(ccr)): + errors.append("openbao.boundary_overlay_file does not match the generated agent denies") + boundary_text = (repo_dir / BOUNDARY_FILE).read_text(encoding="utf-8") + present, total = boundary_presence(ccr, boundary_text) + if present != total: + errors.append(f"{BOUNDARY_FILE} must carry all {total} agent denies for this matrix (has {present})") + + if auth.get("method") != "oidc": + errors.append("openbao.auth.method must be oidc") + if auth.get("mount") not in ALLOWED_AUTH_MOUNTS: + errors.append(f"openbao.auth.mount must be one of {sorted(ALLOWED_AUTH_MOUNTS)}") + if not ROLE_RE.match(str(auth.get("role") or "")): + errors.append("openbao.auth.role contains unsafe characters") + if not isinstance(auth.get("bound_claims_confirmed"), bool): + errors.append("openbao.auth.bound_claims_confirmed must be a boolean") + role = _shared._load_json(repo_dir, str(auth.get("role_file") or ""), "openbao.auth.role_file", errors) + if role: + _validate_role(role, policy_name, errors) + return errors + + +def readiness_blockers(ccr: dict[str, Any], repo_dir: Path, allowed_statuses: set[str]) -> list[str]: + blockers: list[str] = [] + if ccr.get("status") not in allowed_statuses: + blockers.append( + "applier requires approved, applied, verified, or active CCR status, " + f"got {ccr.get('status')}" + ) + if ccr["openbao"]["auth"].get("bound_claims_confirmed") is not True: + blockers.append("applier requires confirmed OpenBao auth binding") + blockers.extend(validate(ccr, repo_dir)) + return blockers + + +def dry_run_payload(ccr: dict[str, Any], repo_dir: Path, warnings: list[str]) -> dict[str, Any]: + openbao = ccr["openbao"] + auth = openbao["auth"] + return { + "id": ccr["id"], + "title": ccr["title"], + "status": ccr["status"], + "environment": ccr["target"]["environment"], + "warnings": warnings, + "source_artifacts": {}, + "mutations": [ + {"kind": "boundary_append_if_absent", "openbao_path": f"sys/policies/acl/{BOUNDARY_POLICY}", + "source": openbao["boundary_overlay_file"], + "payload": {"preserves_live_policy": True, "refuses_partial_presence": True, + "denies": boundary_stanzas(ccr)}}, + {"kind": "policy_write", "openbao_path": f"sys/policies/acl/{openbao['policy_name']}", + "policy_name": openbao["policy_name"], "source": openbao["policy_file"], + "body": generated_policy_hcl(ccr).rstrip()}, + {"kind": "auth_role_write", "openbao_path": f"auth/{auth['mount']}/role/{auth['role']}", + "payload": role_payload(ccr, repo_dir), + "precondition": f"auth/{auth['mount']} exists with type oidc (never enabled here)"}, + {"kind": "readback_compare", "openbao_path": "boundary denies, policy, role", + "payload": {"prints": "booleans only"}}, + ], + "out_of_scope": [ + "secret value reads or writes (custody moves use their own silent helper)", + "token creation or login", + "auth mount enable/config changes", + "changes to any other role or policy", + ], + "required_evidence": [ + "CCR id and approval reference", + "readback equality for boundary denies, policy and role", + "owner verification (ops-mason MASON-WP-0005-T05): positive matrix, sibling/agent/non-member denial, salt CAS 0", + ], + } + + +def apply_details(ccr: dict[str, Any], actor: str) -> list[str]: + openbao = ccr["openbao"] + auth = openbao["auth"] + return [ + f"Delegated metadata applier ran as {actor} using local bao CLI ambient authority.", + f"Agent boundary denies ensured: sys/policies/acl/{BOUNDARY_POLICY} ({len(boundary_stanzas(ccr))} exact paths)", + f"Policy metadata write: sys/policies/acl/{openbao['policy_name']}", + f"OIDC role metadata write: auth/{auth['mount']}/role/{auth['role']}", + "Readback matched source for boundary denies, policy and role.", + "No secret values were read, written, printed, or accepted in argv.", + ] + + +def readback_mismatches(ccr: dict[str, Any], repo_dir: Path, bao_bin: str) -> list[str]: + openbao = ccr["openbao"] + auth = openbao["auth"] + mismatches: list[str] = [] + boundary = _run(bao_bin, ["policy", "read", BOUNDARY_POLICY], repo_dir, quiet=True, capture=True) + present, total = boundary_presence(ccr, boundary) + if present != total: + mismatches.append("boundary policy lacks agent denies") + policy = _run(bao_bin, ["policy", "read", openbao["policy_name"]], repo_dir, quiet=True, capture=True) + if normalized(policy) != normalized(generated_policy_hcl(ccr)): + mismatches.append("policy text differs from source") + role = _read_json(bao_bin, ["read", f"auth/{auth['mount']}/role/{auth['role']}"], repo_dir).get("data", {}) + for key, want in role_payload(ccr, repo_dir).items(): + got = role.get(key) + if key in DURATION_KEYS: + same = ttl_seconds(got) == ttl_seconds(want) + elif key in ("allowed_redirect_uris", "oidc_scopes"): + same = sorted(got or []) == sorted(want) + else: + same = got == want + if not same: + mismatches.append(f"role {key} differs") + return mismatches + + +def run_apply(ccr: dict[str, Any], repo_dir: Path, bao_bin: str, quiet: bool = False) -> None: + openbao = ccr["openbao"] + auth = openbao["auth"] + mounts = _read_json(bao_bin, ["auth", "list"], repo_dir) + mounts = mounts.get("data", mounts) + if (mounts.get(f"{auth['mount']}/") or {}).get("type") != "oidc": + raise BaoError(f"auth mount {auth['mount']}/ is absent or not oidc; this lane never enables it") + + current = _run(bao_bin, ["policy", "read", BOUNDARY_POLICY], repo_dir, quiet=True, capture=True) + present, total = boundary_presence(ccr, current) + if 0 < present < total: + raise BaoError("boundary policy holds some but not all of this matrix's denies; review, do not merge") + with tempfile.TemporaryDirectory() as tmp: + if present == 0: + overlay = (repo_dir / openbao["boundary_overlay_file"]).read_text(encoding="utf-8") + boundary_path = Path(tmp) / "boundary.hcl" + # Preserve live unrelated policy verbatim; add only the reviewed denies. + boundary_path.write_text(current.rstrip("\n") + "\n\n" + overlay, encoding="utf-8") + _run(bao_bin, ["policy", "write", BOUNDARY_POLICY, str(boundary_path)], repo_dir, quiet) + _run(bao_bin, ["policy", "write", openbao["policy_name"], openbao["policy_file"]], repo_dir, quiet) + role_path = Path(tmp) / "role.json" + role_path.write_text(json.dumps(role_payload(ccr, repo_dir), sort_keys=True) + "\n") + _run(bao_bin, ["write", f"auth/{auth['mount']}/role/{auth['role']}", f"@{role_path}"], repo_dir, quiet) + mismatches = readback_mismatches(ccr, repo_dir, bao_bin) + if mismatches: + raise BaoError("readback mismatch: " + "; ".join(mismatches)) diff --git a/tests/test_fluid_telegram_custody_move.py b/tests/test_fluid_telegram_custody_move.py new file mode 100644 index 0000000..20f03b1 --- /dev/null +++ b/tests/test_fluid_telegram_custody_move.py @@ -0,0 +1,144 @@ +"""Offline tests for the fluid-telegram custody move helper against a fake ``bao``. + +Runs the helper as a real subprocess with a fake ``bao`` first on PATH, so the +silence contract (empty stdout and stderr) is tested end to end. +""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'scripts/fluid_telegram_custody_move.py' +CONFIRM = 'MOVE fluid-telegram hall-of-helix to coulomb' +SRC = 'workloads/infotech/fluid-telegram/hall-of-helix' +DST = 'workloads/coulomb/fluid-telegram/hall-of-helix' +VALUES = { + 'bot-token': {'token': 'bt-' + 'Q1w2' * 10}, + 'operator-app': {'api_id': '1234567', 'api_hash': 'ah-' + 'E3r4' * 8}, + 'operator-session': {'session_b64': 'c2Vzc2lvbg' * 12}, + 'redaction-salt': {'salt': 'sa-' + 'T5y6' * 8}, +} + +FAKE_BAO = textwrap.dedent('''\ + #!/usr/bin/env python3 + import json, os, sys + sf = os.environ['FAKE_BAO_STATE'] + st = json.load(open(sf)) + st['calls'].append(sys.argv[1:]) + def save(): json.dump(st, open(sf, 'w')) + def out(obj): save(); print(json.dumps(obj)); sys.exit(0) + def missing(): save(); sys.stderr.write('No value found at x\\n'); sys.exit(2) + a = sys.argv[1:] + print('Success! noise', file=sys.stderr) + if a[0] == 'read': + p = a[-1] + if p == 'auth/token/lookup-self': + out({'data': st['identity']}) + mount, kind, rest = p.split('/', 2) + e = st['kv'].get(rest) + if not e: missing() + cv = len(e['versions']) + if kind == 'metadata': + out({'data': {'current_version': cv, 'versions': {str(i + 1): {} for i in range(cv)}}}) + out({'data': {'data': e['versions'][-1], 'metadata': {'version': cv}}}) + if a[0] == 'write': + p = a[-2].split('/', 2)[2] + body = json.loads(sys.stdin.read()) + if st.get('fail_write'): save(); sys.exit(2) + e = st['kv'].setdefault(p, {'versions': []}) + if body['options']['cas'] != len(e['versions']): save(); sys.exit(2) + e['versions'].append(body['data']) + out({'data': {'version': len(e['versions'])}}) + if a[0] == 'delete': + st['kv'].pop(a[1].split('/', 2)[2], None) + save(); sys.exit(0) + save(); sys.exit(9) +''') + + +class CustodyMove(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + (self.tmp / 'bin').mkdir() + fake = self.tmp / 'bin/bao' + fake.write_text(FAKE_BAO) + fake.chmod(0o755) + self.home = self.tmp / '.warden-attended-login' / 'session' + self.home.mkdir(parents=True) + kv = {f'{SRC}/{k}': {'versions': [v]} for k, v in VALUES.items()} + kv[f'{SRC}/operator-session']['versions'] = [{'session_b64': 'old'}] * 3 + [VALUES['operator-session']] + self.state = {'calls': [], 'identity': {'policies': ['default', 'platform-admin']}, 'kv': kv} + + def run_helper(self, *extra, confirm=CONFIRM): + (self.tmp / 'state.json').write_text(json.dumps(self.state)) + receipt = self.tmp / f'receipt-{len(list(self.tmp.glob("receipt-*")))}.json' + env = {k: v for k, v in os.environ.items() if k not in ('BAO_TOKEN', 'VAULT_TOKEN')} + env.update(PATH=f'{self.tmp / "bin"}:{env["PATH"]}', HOME=str(self.home), + FAKE_BAO_STATE=str(self.tmp / 'state.json')) + proc = subprocess.run([sys.executable, str(SCRIPT), '--confirm', confirm, + '--receipt', str(receipt), *extra], + env=env, capture_output=True, text=True) + self.assertEqual((proc.stdout, proc.stderr), ('', '')) + self.state = json.loads((self.tmp / 'state.json').read_text()) + text = receipt.read_text() + for value in VALUES.values(): + for secret in value.values(): + self.assertNotIn(secret, text) + return proc.returncode, json.loads(text) + + def test_moves_all_four_then_deletes_sources(self): + code, receipt = self.run_helper() + self.assertEqual((code, receipt['outcome']), (0, 'moved')) + for entry, value in VALUES.items(): + self.assertEqual(self.state['kv'][f'{DST}/{entry}']['versions'], [value]) + self.assertNotIn(f'{SRC}/{entry}', self.state['kv']) + self.assertTrue(receipt['entries'][entry]['source_deleted']) + self.assertEqual(receipt['entries']['operator-session']['source_version'], 4) + self.assertFalse(receipt['credential_values_emitted']) + + def test_rerun_after_move_is_already_moved(self): + self.run_helper() + code, receipt = self.run_helper() + self.assertEqual((code, receipt['outcome']), (0, 'already_moved')) + + def test_dry_run_reads_no_values(self): + code, receipt = self.run_helper('--dry-run') + self.assertEqual((code, receipt['outcome']), (0, 'dry_run_would_move')) + self.assertFalse(any(c[0] == 'read' and '/data/' in c[-1] for c in self.state['calls'])) + self.assertFalse(any(c[0] in ('write', 'delete') for c in self.state['calls'])) + + def test_keep_source_copies_without_deleting(self): + code, receipt = self.run_helper('--keep-source') + self.assertEqual((code, receipt['outcome']), (0, 'copied_source_kept')) + self.assertIn(f'{SRC}/bot-token', self.state['kv']) + self.assertIn(f'{DST}/bot-token', self.state['kv']) + + def test_differing_target_refuses_and_deletes_nothing(self): + self.state['kv'][f'{DST}/redaction-salt'] = {'versions': [{'salt': 'other'}]} + code, receipt = self.run_helper() + self.assertEqual((code, receipt['outcome']), (3, 'target_differs')) + self.assertFalse(any(c[0] == 'delete' for c in self.state['calls'])) + self.assertEqual(self.state['kv'][f'{DST}/redaction-salt']['versions'], [{'salt': 'other'}]) + + def test_write_failure_deletes_nothing(self): + self.state['fail_write'] = True + code, receipt = self.run_helper() + self.assertEqual((code, receipt['outcome']), (4, 'write_failed')) + self.assertFalse(any(c[0] == 'delete' for c in self.state['calls'])) + + def test_refuses_without_confirmation_identity_or_envelope(self): + code, receipt = self.run_helper(confirm='MOVE') + self.assertEqual((code, receipt['outcome']), (2, 'exact_confirmation_required')) + self.state['identity'] = {'policies': ['root', 'platform-admin']} + code, receipt = self.run_helper() + self.assertEqual((code, receipt['outcome']), (2, 'attended_platform_admin_required')) + self.assertFalse(any(c[0] in ('write', 'delete') for c in self.state['calls'])) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_kv_duplicate_retire.py b/tests/test_kv_duplicate_retire.py new file mode 100644 index 0000000..270a642 --- /dev/null +++ b/tests/test_kv_duplicate_retire.py @@ -0,0 +1,115 @@ +"""Offline tests for the duplicate KV retirement helper against a fake ``bao``.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'scripts/kv_duplicate_retire.py' +spec = importlib.util.spec_from_file_location('kv_duplicate_retire', SCRIPT) +helper = importlib.util.module_from_spec(spec) +sys.path.insert(0, str(ROOT / 'scripts')) +spec.loader.exec_module(helper) + +CONFIRM = 'RETIRE activity-core/default/llm-connect-provider-secrets' +DUP = 'workloads/activity-core/default/llm-connect-provider-secrets' +CANON = 'workloads/activity-core/llm-connect/llm-connect-provider-secrets' + +FAKE_BAO = textwrap.dedent('''\ + #!/usr/bin/env python3 + import json, os, sys + sf = os.environ['FAKE_BAO_STATE'] + st = json.load(open(sf)) + st['calls'].append(sys.argv[1:]) + def save(): json.dump(st, open(sf, 'w')) + def out(text): save(); print(text); sys.exit(0) + a = [x for x in sys.argv[1:] if x != '-format=json'] + if a[:2] == ['policy', 'list']: out(json.dumps(sorted(st['policies']))) + if a[:2] == ['policy', 'read']: out(st['policies'][a[2]]) + if a[0] == 'read': + if a[1] == 'auth/token/lookup-self': out(json.dumps({'data': st['identity']})) + rest = a[1].split('/', 2)[2] + if rest not in st['kv']: save(); sys.stderr.write('No value found\\n'); sys.exit(2) + n = st['kv'][rest] + out(json.dumps({'data': {'current_version': n, 'versions': {str(i + 1): {} for i in range(n)}}})) + if a[0] == 'delete': + st['kv'].pop(a[1].split('/', 2)[2], None); save(); sys.exit(0) + save(); sys.exit(9) +''') + +CLEAN = { + 'platform-admin': 'path "platform/*" { capabilities = ["read", "delete"] }', + 'agent-high-risk-boundary': f'path "platform/data/{DUP}" {{ capabilities = ["deny"] }}', + 'workload-kv-read-activity-core': f'path "platform/data/{CANON}" {{\n capabilities = ["read"]\n}}', +} + + +class GlobMatching(unittest.TestCase): + def test_exact_plus_and_trailing_star(self): + path = f'platform/data/{DUP}' + self.assertTrue(helper.glob_matches(path, path)) + self.assertTrue(helper.glob_matches('platform/data/workloads/activity-core/*', path)) + self.assertTrue(helper.glob_matches('platform/data/workloads/+/default/*', path)) + self.assertFalse(helper.glob_matches('platform/data/workloads/activity-core/llm-connect/*', path)) + self.assertFalse(helper.glob_matches('platform/data/workloads/+/llm-connect/*', path)) + + +class Retire(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + (self.tmp / 'bin').mkdir() + (self.tmp / 'bin/bao').write_text(FAKE_BAO) + (self.tmp / 'bin/bao').chmod(0o755) + self.home = self.tmp / '.warden-attended-login' / 'session' + self.home.mkdir(parents=True) + self.state = {'calls': [], 'identity': {'policies': ['platform-admin']}, + 'policies': dict(CLEAN), 'kv': {DUP: 1, CANON: 3}} + + def run_helper(self, *extra): + (self.tmp / 'state.json').write_text(json.dumps(self.state)) + receipt = self.tmp / f'r{len(self.state["calls"])}.json' + env = {k: v for k, v in os.environ.items() if k not in ('BAO_TOKEN', 'VAULT_TOKEN')} + env.update(PATH=f'{self.tmp / "bin"}:{env["PATH"]}', HOME=str(self.home), + FAKE_BAO_STATE=str(self.tmp / 'state.json')) + proc = subprocess.run([sys.executable, str(SCRIPT), '--confirm', CONFIRM, + '--receipt', str(receipt), *extra], env=env, capture_output=True, text=True) + self.assertEqual((proc.stdout, proc.stderr), ('', '')) + self.state = json.loads((self.tmp / 'state.json').read_text()) + return proc.returncode, json.loads(receipt.read_text()) + + def test_retires_when_no_policy_grants(self): + code, receipt = self.run_helper() + self.assertEqual((code, receipt['outcome']), (0, 'retired')) + self.assertNotIn(DUP, self.state['kv']) + self.assertIn(CANON, self.state['kv']) + self.assertFalse(any(c[0] == 'read' and '/data/' in c[-1] for c in self.state['calls'])) + + def test_dry_run_deletes_nothing(self): + code, receipt = self.run_helper('--dry-run') + self.assertEqual((code, receipt['outcome']), (0, 'dry_run_would_retire')) + self.assertIn(DUP, self.state['kv']) + + def test_wildcard_grant_blocks(self): + self.state['policies']['coding-agent'] = 'path "platform/data/workloads/activity-core/*" { capabilities = ["read"] }' + code, receipt = self.run_helper() + self.assertEqual((code, receipt['outcome']), (3, 'live_policy_grants_duplicate')) + self.assertEqual(list(receipt['granting_policies']), ['coding-agent']) + self.assertIn(DUP, self.state['kv']) + + def test_unexpected_versions_or_missing_canonical_refuse(self): + self.state['kv'][DUP] = 2 + code, receipt = self.run_helper() + self.assertEqual((code, receipt['outcome']), (2, 'duplicate_version_unexpected')) + self.state['kv'] = {DUP: 1} + code, receipt = self.run_helper() + self.assertEqual((code, receipt['outcome']), (2, 'canonical_not_live')) + self.assertIn(DUP, self.state['kv']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_operator_matrix_lane.py b/tests/test_operator_matrix_lane.py new file mode 100644 index 0000000..f68b1a2 --- /dev/null +++ b/tests/test_operator_matrix_lane.py @@ -0,0 +1,172 @@ +"""RPF-WP-0035-T03: workload-kv-operator-matrix lane (CCR-2026-0039).""" +from __future__ import annotations + +import copy +import importlib.util +import json +import os +import stat +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +CCR_PATH = ROOT / "credential-change-requests/CCR-2026-0039-fluid-telegram-hall-of-helix-operator-matrix.yaml" +CAMPAIGN = "platform/data/workloads/coulomb/fluid-telegram/hall-of-helix" + + +def load(name: str, rel: str): + spec = importlib.util.spec_from_file_location(name, ROOT / rel) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +lane = load("operator_matrix_lane", "scripts/operator_matrix_lane.py") +cc = load("credential_change_cli_matrix", "scripts/credential-change.py") + + +def ccr() -> dict: + return yaml.safe_load(CCR_PATH.read_text()) + + +# Stateful fake: policy/role writes are stored and read back; every call is logged. +FAKE_BAO = r'''#!/usr/bin/env python3 +import json, os, sys +path = os.environ["FAKE_BAO_STATE"] +state = json.load(open(path)) +open(os.environ["FAKE_BAO_LOG"], "a").write(json.dumps(sys.argv[1:]) + "\n") +args = [a for a in sys.argv[1:] if a != "-format=json"] +if args[:2] == ["auth", "list"]: + print(json.dumps({k: {"type": v} for k, v in state["mounts"].items()})) +elif args[:2] == ["policy", "read"]: + if args[2] not in state["policies"]: + sys.exit(2) + print(state["policies"][args[2]]) +elif args[:2] == ["policy", "write"]: + state["policies"][args[2]] = open(args[3]).read() +elif args[0] == "write": + state["roles"][args[1]] = json.load(open(args[2][1:])) +elif args[0] == "read": + print(json.dumps({"data": state["roles"][args[1]]})) +json.dump(state, open(path, "w")) +''' + +LIVE_BOUNDARY = 'path "platform/data/workloads/forgejo/forgejo-admin" {\n capabilities = ["deny"]\n}\n' + + +class OperatorMatrixLaneTests(unittest.TestCase): + def test_ccr_validates_and_waits_for_approval_and_binding(self): + _ccr, errors, _w = cc.validate_ccr(CCR_PATH) + self.assertEqual(errors, []) + blockers = cc.applier_readiness_blockers(_ccr) + self.assertIn("applier requires confirmed OpenBao auth binding", blockers) + self.assertTrue(any("got proposed" in b for b in blockers)) + + def test_policy_is_the_exact_matrix_and_nothing_else(self): + policy = (ROOT / ccr()["openbao"]["policy_file"]).read_text() + self.assertNotIn("metadata", policy.split("\n", 1)[1]) + self.assertNotIn("*", policy) + for word in ("delete", "list", "patch", "sudo"): + self.assertNotIn(f'"{word}"', policy) + self.assertIn(f'path "{CAMPAIGN}/redaction-salt" {{\n capabilities = ["create", "read"]', policy) + self.assertIn(f'path "{CAMPAIGN}/operator-app" {{\n capabilities = ["read"]', policy) + + def test_repo_boundary_carries_all_eight_agent_denies(self): + text = (ROOT / lane.BOUNDARY_FILE).read_text() + self.assertEqual(lane.boundary_presence(ccr(), text), (8, 8)) + + def test_validator_rejects_widening(self): + cases = [ + ("wildcard entry", lambda c: c["openbao"]["matrix"][0].update(entry="*")), + ("unknown capability", lambda c: c["openbao"]["matrix"][0]["capabilities"].append("delete")), + ("no read", lambda c: c["openbao"]["matrix"][3].update(capabilities=["create"])), + ("duplicate entry", lambda c: c["openbao"]["matrix"].append(copy.deepcopy(c["openbao"]["matrix"][0]))), + ("campaign wildcard", lambda c: c["openbao"].update(campaign_path="platform/workloads/coulomb/fluid-telegram/*")), + ("other auth mount", lambda c: c["openbao"]["auth"].update(mount="kubernetes")), + ] + for label, mutate in cases: + with self.subTest(label): + c = ccr() + mutate(c) + self.assertNotEqual(lane.validate(c, ROOT), [], label) + + def test_validator_rejects_bad_role_file(self): + good = json.loads((ROOT / ccr()["openbao"]["auth"]["role_file"]).read_text()) + cases = [ + ("public callback", {"allowed_redirect_uris": ["https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback"]}), + ("no aal2", {"bound_claims": {"groups": ["fluid-telegram-operators"]}}), + ("two groups", {"bound_claims": {"groups": ["a", "b"], "/assurance/level": "aal2"}}), + ("default policy", {"token_no_default_policy": False}), + ("long ttl", {"token_ttl": "60m"}), + ("long max", {"token_explicit_max_ttl": "2h"}), + ("extra key", {"token_period": "15m"}), + ] + for label, change in cases: + with self.subTest(label): + with tempfile.TemporaryDirectory(dir=ROOT / "openbao/auth") as tmp: + role_file = Path(tmp) / "role.json" + role_file.write_text(json.dumps({**good, **change})) + c = ccr() + c["openbao"]["auth"]["role_file"] = str(role_file.relative_to(ROOT)) + self.assertNotEqual(lane.validate(c, ROOT), [], label) + + def _apply(self, boundary: str, mounts: dict | None = None): + tmp = Path(tempfile.mkdtemp()) + state = {"mounts": mounts if mounts is not None else {"netkingdom/": "oidc"}, + "policies": {lane.BOUNDARY_POLICY: boundary}, "roles": {}} + (tmp / "state.json").write_text(json.dumps(state)) + bao = tmp / "bao" + bao.write_text(FAKE_BAO) + bao.chmod(bao.stat().st_mode | stat.S_IEXEC) + os.environ["FAKE_BAO_STATE"] = str(tmp / "state.json") + os.environ["FAKE_BAO_LOG"] = str(tmp / "log") + (tmp / "log").write_text("") + error = None + try: + lane.run_apply(ccr(), ROOT, str(bao), quiet=True) + except lane.BaoError as exc: + error = exc + state = json.loads((tmp / "state.json").read_text()) + writes = [json.loads(l) for l in (tmp / "log").read_text().splitlines() + if json.loads(l)[0] == "write" or json.loads(l)[:2] == ["policy", "write"]] + return error, state, writes + + def test_apply_appends_denies_preserving_live_boundary_and_reads_back(self): + error, state, writes = self._apply(LIVE_BOUNDARY) + self.assertIsNone(error) + boundary = state["policies"][lane.BOUNDARY_POLICY] + self.assertTrue(boundary.startswith(LIVE_BOUNDARY.rstrip("\n"))) + self.assertEqual(lane.boundary_presence(ccr(), boundary), (8, 8)) + self.assertEqual([w[:3] for w in writes][:2], [ + ["policy", "write", lane.BOUNDARY_POLICY], + ["policy", "write", "workload-kv-fluid-telegram-hall-of-helix-operator"], + ]) + self.assertIn("auth/netkingdom/role/fluid-telegram-hall-of-helix-workload-kv", state["roles"]) + + def test_apply_skips_boundary_when_denies_already_live(self): + overlay = (ROOT / ccr()["openbao"]["boundary_overlay_file"]).read_text() + error, state, writes = self._apply(LIVE_BOUNDARY + overlay) + self.assertIsNone(error) + self.assertNotIn(["policy", "write", lane.BOUNDARY_POLICY], [w[:3] for w in writes]) + + def test_apply_refuses_partial_boundary_before_any_write(self): + partial = LIVE_BOUNDARY + lane.boundary_stanzas(ccr())[0] + "\n" + error, _state, writes = self._apply(partial) + self.assertIsNotNone(error) + self.assertEqual(writes, []) + + def test_apply_refuses_missing_or_foreign_oidc_mount_before_any_write(self): + for mounts in ({}, {"netkingdom/": "jwt"}): + with self.subTest(mounts=mounts): + error, _state, writes = self._apply(LIVE_BOUNDARY, mounts) + self.assertIsNotNone(error) + self.assertEqual(writes, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md index 4afbeeb..d355a5c 100644 --- a/workplans/RPF-WP-0035-credential-lane-implementation.md +++ b/workplans/RPF-WP-0035-credential-lane-implementation.md @@ -71,6 +71,33 @@ issuer/audience/subject/claims and unrelated secret access fail, bounded TTL and revocation are proven, the consumer explicitly opts into the verified contract, and rollback/cleanup receipts contain no secret material. +2026-10-09 (T03 source): founder ruled the tenant segment stays `coulomb` +(ops-mason `b72ff77d`). New CCR request type `workload-kv-operator-matrix` +(`scripts/operator_matrix_lane.py`): exact per-entry capabilities, OIDC role on +the existing `netkingdom` mount only, and exact agent denies on all four data +and metadata paths appended to the live `agent-high-risk-boundary` (live content +preserved; partial presence refused). `scripts/fluid_telegram_custody_move.py` +copies the four undocumented `infotech` entries with CAS 0, verifies all, then +metadata-deletes the sources. `scripts/kv_duplicate_retire.py` retires +`activity-core/default/llm-connect-provider-secrets` only if no live non-admin +policy grants it. Founder session (each helper prints nothing; read the receipts): + +```bash +cd ~/railiance-platform +export BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 +W='warden access openbao-platform-admin-login --exec --' +# 1. dry-runs (no value reads, no writes) +$W python3 scripts/fluid_telegram_custody_move.py --dry-run --confirm 'MOVE fluid-telegram hall-of-helix to coulomb' --receipt ~/ft-move-dry.json +$W python3 scripts/kv_duplicate_retire.py --dry-run --confirm 'RETIRE activity-core/default/llm-connect-provider-secrets' --receipt ~/kv-retire-dry.json +# 2. lane: approve, confirm binding, apply +python3 scripts/credential-change.py approve CCR-2026-0039 --reviewer "platform-owner, fluid-telegram-owner, key-cape-owner (founder Bernd Worsch)" --comment "Approve ops-mason plan fluid-telegram-operator-credential-lane (spec 41a0ca27): four-entry matrix at coulomb/fluid-telegram/hall-of-helix, OIDC role on netkingdom, agent denies." \ + && python3 scripts/credential-change.py confirm-binding CCR-2026-0039 --reviewer founder --comment "groups=[fluid-telegram-operators] (LLDAP, member tegwick) and /assurance/level=aal2 match key-cape b2ed890f." \ + && $W python3 scripts/credential-change.py applier-apply CCR-2026-0039 --confirm "DELEGATED APPLY CCR-2026-0039" --actor founder --record-state-hub --quiet +# 3. custody move, then duplicate retirement +$W python3 scripts/fluid_telegram_custody_move.py --confirm 'MOVE fluid-telegram hall-of-helix to coulomb' --receipt ~/ft-move.json +$W python3 scripts/kv_duplicate_retire.py --confirm 'RETIRE activity-core/default/llm-connect-provider-secrets' --receipt ~/kv-retire.json +``` + 2026-10-03: the historical D1 routing was withdrawn because provision/delivery already completed. Owner routing now uses decision `ab85ae8a-12a0-4945-ae64-1d3d86d5b26f`, specifically independent archive readback and coordinated interim audit bearer revocation acceptance. `needs_human` and the wait remain; resolving a decision requires owner receipt re-check, not automatic completion. 2026-10-03 (CUST-WP-0075): gate check. SECRETS-WP-0007-T04 is done (2026-09-27), so the gate named in the design is open. It is not the last one: the design says to confirm the exact live client registration and protected client-side custody before writing, and positive-login proof needs a real KeyCape service JWT. KeyCape's contract (`openbao-service-auth-contract.md`) and KEY-WP-0009 claim source registration only; the live registration of `secrets-engine-openbao` and its client-credential custody are "a separate approved custody action" that no workplan task owns yet (SECRETS-WP-0008-T06 waits on this task). Re-qualified with `blocked_on: message-from:key-cape`; key-cape was asked for the owning task id, which will replace it as `depends_on`. The design stays proposed and no child command was written. Not approvable in D1 yet. @@ -140,7 +167,7 @@ id: RPF-WP-0035-T03 status: wait needs_human: true decision_id: 62aa95e4-3ccc-4867-8806-5e3db005b483 -blocking_reason: "MASON-WP-0005-T02 source contract is done (ops-mason message 79570eb8). The attended policy/schema apply remains with the open provisioning decision." +blocking_reason: "Source ready 2026-10-09: CCR-2026-0039 (request type workload-kv-operator-matrix), silent custody move and duplicate-retire helpers. Waits on one attended founder session (commands in the 2026-10-09 T03 note), then ops-mason verification (MASON-WP-0005-T05) and the owner salt CAS probe." priority: high state_hub_task_id: "f47e5bc5-6f3c-54bb-aa35-260ad03892ba" ```