diff --git a/Makefile b/Makefile index 9ada088..a8fa063 100644 --- a/Makefile +++ b/Makefile @@ -212,8 +212,13 @@ bounded-execution-admission-check: ## Validate the example admissions (read-only execution-attribution-receipt-test: ## Receipt schema v0.1 validator; fixtures match the executor (NK-WP-0056-T15) python3 -m pytest tools/execution-attribution-receipt/tests +bounded-execution-ready: ## Readiness before a window, read-only (NK-WP-0058-T03): ADMISSION= APPROVAL_ID= RECEIPT= + @test -n "$(ADMISSION)" -a -n "$(APPROVAL_ID)" -a -n "$(RECEIPT)" || (echo "ADMISSION=, APPROVAL_ID= and RECEIPT= are required" >&2; exit 2) + python3 tools/bounded-execution-readiness/ready.py --admission $(ADMISSION) --approval-id $(APPROVAL_ID) --receipt $(RECEIPT) + bounded-executor-test: ## Bounded executor RBAC and declaration tests; the pilot admission inside its declared grants (NK-WP-0056-T03) - python3 -m pytest sso-mfa/k8s/bounded-executor bounded-executor/tests tests/test_executor_lanes.py tests/test_write_role_audit.py + python3 -m pytest sso-mfa/k8s/bounded-executor bounded-executor/tests tests/test_executor_lanes.py tests/test_write_role_audit.py \ + tools/bounded-execution-readiness/tests python3 tools/service-identity/validate.py tools/service-identity/declarations/bounded-executor.yaml \ --admissions examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 10ee2c9..5413283 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -372,7 +372,7 @@ | task | NK-WP-0057-T04 | todo | — | workplans/NK-WP-0057-decision-routing-by-responsibility.md | | task | NK-WP-0058-T01 | done | — | workplans/NK-WP-0058-bounded-execution-operability.md | | task | NK-WP-0058-T02 | todo | — | workplans/NK-WP-0058-bounded-execution-operability.md | -| task | NK-WP-0058-T03 | progress | — | workplans/NK-WP-0058-bounded-execution-operability.md | +| task | NK-WP-0058-T03 | done | — | workplans/NK-WP-0058-bounded-execution-operability.md | | task | NK-WP-0058-T04 | todo | — | workplans/NK-WP-0058-bounded-execution-operability.md | | task | NK-WP-0058-T05 | todo | — | workplans/NK-WP-0058-bounded-execution-operability.md | | task | NK-WP-0058-T06 | todo | — | workplans/NK-WP-0058-bounded-execution-operability.md | diff --git a/docs/bounded-execution-contract-table.md b/docs/bounded-execution-contract-table.md index 908c8dd..a88c326 100644 --- a/docs/bounded-execution-contract-table.md +++ b/docs/bounded-execution-contract-table.md @@ -189,10 +189,11 @@ resolve through the rapp config and must equal the routing recipient for the mem (`plan.source.files`). - **Gap:** no record links the digest to the commit to the admission pins, so "the admission's pins match the pinned image" cannot be checked. -- **Proposal:** - - the image workflow writes a value-free pin record (commit, file sha256s, digest); - - rapp pins that record; - - the renderer refuses when the admission's file hashes are not the image's (T03). +- **Resolved without new records (T03, 2026-11-05):** the image is tagged `main-` by its workflow, and + the executor's own `source` preflight check compares the admission's pinned file hashes with the files inside + the running image. rapp `make preflight` runs that check in the pinned image before a window opens, so a pin + mismatch shows as `preflight:source` ahead of the window. Remaining hand step: copying the digest into the rapp + `Makefile`. ### R2: config, Job and exit codes @@ -215,7 +216,7 @@ resolve through the rapp config and must equal the routing recipient for the mem - E1: lane names and policy text, from the CCRs; - E5: the approver, compared until it has one source; - E8: the RBAC copy; - - R1: the pin record; + - R1: the pin chain, now checked by the preflight `source` check (T03); - R2: the config schema. - **delete:** nothing yet. The candidates are the hand copies named under merge, once they are derived or checked. - **owner decision** (to NK-WP-0057-T01 or the named owner): diff --git a/tests/test_executor_lanes.py b/tests/test_executor_lanes.py index 9ce263f..7f1c7a5 100644 --- a/tests/test_executor_lanes.py +++ b/tests/test_executor_lanes.py @@ -262,3 +262,20 @@ def test_lane_error_keeps_its_fixed_code(tmp_path, monkeypatch): assert m.reason_of(lane_error('command_failed')) == 'LaneError:command_failed' assert m.reason_of(lane_error('not a fixed code')) == 'LaneError' assert m.reason_of(ValueError('anything')) == 'ValueError' + + +def test_status_is_read_only_and_reports_every_check(tmp_path, monkeypatch): + m, fake, d = build(tmp_path, monkeypatch) + run(m, 'setup', d) + before = json.dumps(fake.store, sort_keys=True), fake.cas + r = {} + m.status(r) + assert r == {'phase': 'done', 'status': 'status_read', 'write_role_absent': True, 'agent_fence_present': True, + 'cas_required': True, 'ready_for_window': True} + assert (json.dumps(fake.store, sort_keys=True), fake.cas) == before # nothing written + path, now = admission_at(tmp_path, m) + m.window_open(d, path, {}, now=now) + fake.cas = False + r = {} + m.status(r) + assert (r['write_role_absent'], r['cas_required'], r['ready_for_window']) == (False, False, False) diff --git a/tools/bounded-execution-readiness/ready.py b/tools/bounded-execution-readiness/ready.py new file mode 100644 index 0000000..27007dc --- /dev/null +++ b/tools/bounded-execution-readiness/ready.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Readiness check before a bounded-execution window opens (NK-WP-0058-T03). Read-only, value-free. + + python3 tools/bounded-execution-readiness/ready.py --admission admissions/BEA-....yaml --approval-id ID \ + --receipt EVIDENCE/NN-readiness.json [--siblings DIR] [--now ISO] + +What the agent identity can check, from the owners' committed source at the revisions that matter: + + admission valid (the admission validator), executor named, window not closed + approver_chain the admission's approvers resolve through the rapp config; informed-decision, both at the + revision Argo pins and on main (live is one of the two), registers this executor and routes its + review group to the first approver's subject (access-engine lists a memo only to that + recipient, policy v14 recipient_ok) + argo_pin the pinned informed-decision files equal its main branch (warning only: live may be newer) + profile access-engine's present_existing profile for the executor matches informed-decision's registry + ccrs CCR-2026-0058..0061 and -0063 are in railiance-platform's RUNBOOK_ALLOWED_STATUSES (approved, + applied, verified, active), every bound_claims_confirmed true + case the approval-client case is materialized or live; its type's bytes match the broker's hash pin + +What it cannot see is named as one read-only founder command each (`founder_checks`): the write role and fence +(OpenBao), the preflight Job (reachability, lanes, source pins in the pinned image, trust, window, approval) and +the custody deliveries (ExternalSecret Ready). Exit 0 when every agent check passes, 1 otherwise. +""" +import argparse +import datetime as dt +import hashlib +import importlib.util +import json +import os +import re +import subprocess +from pathlib import Path + +import yaml + +REPO = Path(__file__).resolve().parents[2] +CASE = 'security-cases/nk-bind-executor-approval.yaml' +CCRS = ('CCR-2026-0058', 'CCR-2026-0059', 'CCR-2026-0060', 'CCR-2026-0061', 'CCR-2026-0063') +ARGO_APP = 'argocd/railiance01/applications/informed-decision.yaml' +POLICY = 'examples/informed-decision-present-existing-review/policy.md' +BROKER = 'scripts/keycape_client_secret_generate.py' +RAPP_CONFIG = 'examples/config.pilot.json' + + +def load_validator(): + spec = importlib.util.spec_from_file_location('bea_validate_ready', + REPO / 'tools/bounded-execution-admission/validate.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def when(text): + return dt.datetime.fromisoformat(text.replace('Z', '+00:00')) + + +class Report: + def __init__(self): + self.checks = [] + + def add(self, check, ok, reason=None, warn=False): + result = 'pass' if ok else ('warn' if warn else 'fail') + self.checks.append({'check': check, 'result': result, **({'reason': reason} if reason and not ok else {})}) + return ok + + +def git_show(repo, rev, path): + done = subprocess.run(['git', '-C', str(repo), 'show', f'{rev}:{path}'], capture_output=True) + if done.returncode: raise FileNotFoundError(f'{repo.name}:{rev}:{path}') + return done.stdout + + +def check_admission(report, doc, now): + bea = load_validator() + result = bea.evaluate(doc, json.loads(bea.SCHEMA.read_text())) + rules = ','.join(sorted({f['rule'] for f in result['findings']})) + report.add('admission_valid', result['valid'], 'admission_invalid:' + rules) + spec = doc['spec'] + report.add('window_not_closed', now < when(spec['window']['not_after']), 'window_closed') + + +def check_approver_chain(report, spec, cfg, infd, rev, label='pinned'): + names = spec['approval']['approvers'] + subjects = [cfg.get('approver_subjects', {}).get(n) for n in names] + if label == 'pinned': + if not report.add('approvers_resolve', all(subjects), 'approver_not_in_rapp_config'): + return None + report.add('single_approver', len(names) == 1, 'routing_has_one_recipient_per_group') + elif not all(subjects): + return None + groups = json.loads(git_show(infd, rev, 'deploy/lifecycle/review-groups.json')) + submitters = json.loads(git_show(infd, rev, 'deploy/lifecycle/intake-submitters.json')) + row = submitters.get(cfg['runtime_principal']) + if not report.add(f'executor_registered:{label}', row is not None, 'runtime_principal_not_in_intake_submitters'): + return None + actor = (row.get('present_existing') or {}).get('approval_actor') + report.add(f'approval_actor_matches:{label}', actor == cfg['executor_identity'], 'approval_actor_differs') + report.add(f'memo_class_admitted:{label}', 'bounded-execution' in row.get('memo_classes', []), 'memo_class_missing') + recipients = {g: (groups.get(g) or {}).get('subject') for g in row.get('review_groups', [])} + report.add(f'recipient_is_approver:{label}', bool(recipients) and set(recipients.values()) == {subjects[0]}, + 'review_group_routes_elsewhere') + return row + + +def check_argo_pin(report, infd, rev): + for name in ('review-groups.json', 'intake-submitters.json'): + same = git_show(infd, rev, 'deploy/lifecycle/' + name) == git_show(infd, 'HEAD', 'deploy/lifecycle/' + name) + report.add('argo_pin_matches_source:' + name, same, 'pinned_revision_differs_from_main', warn=True) + + +def check_profile(report, row, cfg, access_engine): + text = (access_engine / POLICY).read_text() + block = re.search(r'present_existing_profiles := \{(.*?)\n\}', text, re.S) + entry = block and re.search(r'"' + re.escape(cfg['executor_identity']) + r'": \{(.*?)\}', block.group(1), re.S) + if not report.add('profile_present', bool(entry), 'executor_not_in_present_existing_profiles'): + return + fields = dict(re.findall(r'"(\w+)": "([^"]*)"', entry.group(1))) + report.add('profile_submitter_matches', row is not None and fields.get('intake_submitter') == row['submitter_id'], + 'intake_submitter_differs') + report.add('profile_memo_class_matches', fields.get('memo_class') == 'bounded-execution', 'memo_class_differs') + + +def confirmations(node): + if isinstance(node, dict): + for key, value in node.items(): + if key == 'bound_claims_confirmed': yield value + else: yield from confirmations(value) + elif isinstance(node, list): + for value in node: yield from confirmations(value) + + +def ccr_statuses(platform): + """The owner's own set of statuses under which a CCR may be acted on (credential-change.py), not a copy.""" + spec = importlib.util.spec_from_file_location('credential_change_ready', platform / 'scripts/credential-change.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return set(module.RUNBOOK_ALLOWED_STATUSES) + + +def check_ccrs(report, platform): + folder = platform / 'credential-change-requests' + allowed = ccr_statuses(platform) + for ccr in CCRS: + files = sorted(folder.glob(ccr + '-*.yaml')) + if not report.add(ccr + ':present', len(files) == 1, 'ccr_file_missing'): + continue + doc = yaml.safe_load(files[0].read_text()) + report.add(ccr + ':approved', doc.get('status') in allowed, 'status_' + str(doc.get('status'))) + report.add(ccr + ':bindings_confirmed', all(v is True for v in confirmations(doc)), 'binding_not_confirmed') + + +def check_case(report, platform): + case = yaml.safe_load((REPO / CASE).read_text())['spec'] + report.add('case_materialized', case.get('status') in ('materialized', 'live'), 'case_' + str(case.get('status'))) + pins = {ref: (path, digest) for ref, path, digest in re.findall( + r"'([a-z0-9-]+@[0-9.]+)': \(\s*'(security-case-types/[^']+)',\s*'([0-9a-f]{64})'\)", + (platform / BROKER).read_text())} + pinned = pins.get(case.get('type')) + if not report.add('case_type_acknowledged', pinned is not None, 'type_not_admitted_by_broker'): + return + path, digest = pinned + report.add('case_type_bytes_match_broker', hashlib.sha256((REPO / path).read_bytes()).hexdigest() == digest, + 'type_yaml_changed_since_acknowledgment') + + +def founder_checks(admission, approval_id): + """One read-only command each, for what the agent identity cannot read (NK-WP-0058-T05 status table).""" + k = 'kubectl --kubeconfig ~/.kube/config-railiance01 -n sso' + jsonpath = "-o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}'" + return [ + {'id': 'write_role_and_fence', 'needs': 'platform-admin OpenBao login', + 'command': 'python3 tools/service-binds/executor-lanes.py status --receipt /NN-status.json', + 'expect': 'ready_for_window: true'}, + {'id': 'preflight_job', 'needs': 'admin kubeconfig', + 'command': f'cd ~/rapp-bounded-executor && make preflight ADMISSION={admission} APPROVAL_ID={approval_id} ' + 'CONFIG= KUBECTL="kubectl --kubeconfig ~/.kube/config-railiance01"', + 'expect': '"ready": true'}, + {'id': 'custody_delivery', 'needs': 'admin kubeconfig', + 'command': f'for e in keycape-nk-bind-executor-approval-client bounded-executor-tokens; do ' + f'{k} get externalsecret $e {jsonpath}; echo; done', + 'expect': 'True, twice'}, + ] + + +def run(admission_path, approval_id, siblings, now): + report = Report() + doc = yaml.safe_load(Path(admission_path).read_text()) + spec = doc['spec'] + check_admission(report, doc, now) + platform, infd = siblings / 'railiance-platform', siblings / 'informed-decision' + access_engine = siblings / 'access-engine' + cfg = json.loads((siblings / 'rapp-bounded-executor' / RAPP_CONFIG).read_text()) + report.add('executor_named', spec['executor']['identity'] == cfg['executor_identity'], 'admission_names_another') + rev = yaml.safe_load((platform / ARGO_APP).read_text())['spec']['source']['targetRevision'] + row = check_approver_chain(report, spec, cfg, infd, rev) + check_argo_pin(report, infd, rev) + # live is the Argo-pinned revision or a newer founder-run release (2026-11-04: the pin lagged): hold both + check_approver_chain(report, spec, cfg, infd, 'HEAD', label='main') + check_profile(report, row, cfg, access_engine) + check_ccrs(report, platform) + check_case(report, platform) + failures = [c['check'] for c in report.checks if c['result'] == 'fail'] + return {'action': 'bounded-execution-readiness', 'admission': spec['id'], 'values_recorded': False, + 'at': now.strftime('%Y-%m-%dT%H:%M:%SZ'), 'argo_pin': rev[:12], 'agent_checks_pass': not failures, + 'failures': failures, 'warnings': [c['check'] for c in report.checks if c['result'] == 'warn'], + 'checks': report.checks, 'founder_checks': founder_checks(admission_path, approval_id)} + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument('--admission', required=True) + p.add_argument('--approval-id', required=True) + p.add_argument('--receipt', required=True) + p.add_argument('--siblings', type=Path, default=Path(os.environ.get('NK_SIBLINGS', REPO.parent))) + p.add_argument('--now', type=when, default=None) + a = p.parse_args(argv) + now = a.now or dt.datetime.now(dt.timezone.utc) + fd = os.open(a.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + r = run(a.admission, a.approval_id, a.siblings, now) + except Exception as error: # value-free: a missing file or revision is named, nothing else is echoed + r = {'action': 'bounded-execution-readiness', 'agent_checks_pass': False, 'values_recorded': False, + 'failures': ['error:' + type(error).__name__], + 'reason': str(error) if isinstance(error, FileNotFoundError) else type(error).__name__} + with os.fdopen(fd, 'w') as out: + json.dump(r, out, indent=2, sort_keys=True); out.write('\n') + print(json.dumps({'agent_checks_pass': r['agent_checks_pass'], 'failures': r['failures'], + 'warnings': r.get('warnings', []), + 'founder_checks': [f['id'] for f in r.get('founder_checks', [])]})) + return 0 if r['agent_checks_pass'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/bounded-execution-readiness/tests/test_ready.py b/tools/bounded-execution-readiness/tests/test_ready.py new file mode 100644 index 0000000..407e625 --- /dev/null +++ b/tools/bounded-execution-readiness/tests/test_ready.py @@ -0,0 +1,137 @@ +"""ready.py against synthetic sibling repositories (NK-WP-0058-T03). No network, no cluster, no secret values.""" +import hashlib +import importlib.util +import json +import subprocess +from pathlib import Path + +import pytest +import yaml + +REPO = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_file_location('ready', REPO / 'tools/bounded-execution-readiness/ready.py') +ready = importlib.util.module_from_spec(spec) +spec.loader.exec_module(ready) + +ADMISSION = REPO / 'admissions/BEA-2026-0003.yaml' +INSIDE = ready.when('2026-11-05T08:25:00Z') +ADMIN = 'uid=admin,ou=people,dc=netkingdom,dc=local' +ROOT_DN = 'uid=platform-root,ou=people,dc=netkingdom,dc=local' +EXECUTOR = 'service:nk-bind-executor' +SA = 'system:serviceaccount:sso:nk-bind-executor' +TYPE = 'security-case-types/oidc-service-client/type.yaml' + + +def git(repo, *args): + subprocess.run(['git', '-C', str(repo), *args], check=True, capture_output=True) + + +def lifecycle(repo, recipient, submitter='nk-bind-executor'): + d = repo / 'deploy/lifecycle' + d.mkdir(parents=True, exist_ok=True) + (d / 'review-groups.json').write_text(json.dumps({'net-kingdom-admins': {'subject': recipient}})) + (d / 'intake-submitters.json').write_text(json.dumps({SA: { + 'submitter_id': submitter, 'memo_classes': ['bounded-execution'], 'review_groups': ['net-kingdom-admins'], + 'present_existing': {'approval_actor': EXECUTOR, 'actions': ['bounded-execution']}}})) + git(repo, 'add', '-A') + git(repo, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'lifecycle') + return subprocess.run(['git', '-C', str(repo), 'rev-parse', 'HEAD'], capture_output=True, text=True).stdout.strip() + + +def siblings(tmp_path, *, pin_recipient=ADMIN, main_recipient=None, ccr_status='approved', confirmed=True, + type_digest=None, profile_submitter='nk-bind-executor'): + root = tmp_path / 'siblings' + infd = root / 'informed-decision' + infd.mkdir(parents=True) + git(infd, 'init', '-q') + pin = lifecycle(infd, pin_recipient) + if main_recipient: + lifecycle(infd, main_recipient) + plat = root / 'railiance-platform' + app = plat / ready.ARGO_APP + app.parent.mkdir(parents=True) + app.write_text(yaml.safe_dump({'spec': {'source': {'targetRevision': pin, 'path': 'deploy/lifecycle'}}})) + (plat / 'scripts').mkdir() + (plat / 'scripts/credential-change.py').write_text( + 'APPLY_ALLOWED_STATUSES = {"approved"}\nPOST_APPLY_STATUSES = {"applied", "verified", "active"}\n' + 'RUNBOOK_ALLOWED_STATUSES = APPLY_ALLOWED_STATUSES | POST_APPLY_STATUSES\n') + digest = type_digest or hashlib.sha256((REPO / TYPE).read_bytes()).hexdigest() + (plat / ready.BROKER).write_text("ACKNOWLEDGED_TYPES = {\n 'oidc-service-client@0.2': (\n" + f" '{TYPE}',\n '{digest}'),\n}}\n") + ccrs = plat / 'credential-change-requests' + ccrs.mkdir() + for ccr in ready.CCRS: + (ccrs / f'{ccr}-synthetic.yaml').write_text(yaml.safe_dump( + {'id': ccr, 'status': ccr_status, 'openbao': {'auth': {'bound_claims_confirmed': confirmed}}})) + ae = root / 'access-engine' / ready.POLICY + ae.parent.mkdir(parents=True) + ae.write_text('present_existing_profiles := {\n "service:nk-bind-executor": {\n' + f' "intake_submitter": "{profile_submitter}",\n "memo_class": "bounded-execution",\n' + ' "approval_action": "bounded-execution",\n },\n}\n') + rapp = root / 'rapp-bounded-executor' / ready.RAPP_CONFIG + rapp.parent.mkdir(parents=True) + rapp.write_text(json.dumps({'executor_identity': EXECUTOR, 'runtime_principal': SA, + 'approver_subjects': {'admin': ADMIN}})) + return root + + +def results(r): + return {c['check']: c['result'] for c in r['checks']} + + +def test_clean_chain_passes_and_names_the_founder_checks(tmp_path): + r = ready.run(ADMISSION, 'apr-1', siblings(tmp_path), INSIDE) + assert r['agent_checks_pass'] and r['failures'] == [] and r['warnings'] == [] + assert [f['id'] for f in r['founder_checks']] == ['write_role_and_fence', 'preflight_job', 'custody_delivery'] + assert 'APPROVAL_ID=apr-1' in r['founder_checks'][1]['command'] + assert r['values_recorded'] is False + + +def test_replay_2026_11_04_routing_to_platform_root_on_main_is_caught(tmp_path): + """The Argo pin lagged a founder-run release that routed net-kingdom-admins to platform-root.""" + r = ready.run(ADMISSION, 'apr-1', siblings(tmp_path, main_recipient=ROOT_DN), INSIDE) + assert not r['agent_checks_pass'] + assert 'recipient_is_approver:main' in r['failures'] + assert set(r['warnings']) == {'argo_pin_matches_source:review-groups.json'} + assert results(r)['recipient_is_approver:pinned'] == 'pass' + + +def test_routing_elsewhere_at_the_pin(tmp_path): + r = ready.run(ADMISSION, 'apr-1', siblings(tmp_path, pin_recipient=ROOT_DN), INSIDE) + assert {'recipient_is_approver:pinned', 'recipient_is_approver:main'} <= set(r['failures']) + + +@pytest.mark.parametrize('kwargs, failed', [ + ({'ccr_status': 'proposed'}, 'CCR-2026-0058:approved'), + ({'ccr_status': 'verified'}, None), + ({'confirmed': False}, 'CCR-2026-0061:bindings_confirmed'), + ({'type_digest': '0' * 64}, 'case_type_bytes_match_broker'), + ({'profile_submitter': 'someone-else'}, 'profile_submitter_matches'), +]) +def test_each_owner_contract_is_checked(tmp_path, kwargs, failed): + r = ready.run(ADMISSION, 'apr-1', siblings(tmp_path, **kwargs), INSIDE) + if failed is None: + assert r['agent_checks_pass'], r['failures'] + else: + assert failed in r['failures'] + + +def test_closed_window_and_receipt_handling(tmp_path): + root = siblings(tmp_path) + r = ready.run(ADMISSION, 'apr-1', root, ready.when('2026-11-06T00:00:00Z')) + assert 'window_not_closed' in r['failures'] + receipt = tmp_path / 'r.json' + assert ready.main(['--admission', str(ADMISSION), '--approval-id', 'apr-1', '--receipt', str(receipt), + '--siblings', str(root), '--now', '2026-11-05T08:25:00Z']) == 0 + assert json.loads(receipt.read_text())['agent_checks_pass'] is True + with pytest.raises(FileExistsError): + ready.main(['--admission', str(ADMISSION), '--approval-id', 'apr-1', '--receipt', str(receipt), + '--siblings', str(root)]) + + +def test_a_missing_sibling_fails_by_name(tmp_path): + receipt = tmp_path / 'r.json' + assert ready.main(['--admission', str(ADMISSION), '--approval-id', 'a', '--receipt', str(receipt), + '--siblings', str(tmp_path / 'none')]) == 1 + r = json.loads(receipt.read_text()) + assert r['failures'] == ['error:FileNotFoundError'] and r['agent_checks_pass'] is False diff --git a/tools/service-binds/executor-lanes.py b/tools/service-binds/executor-lanes.py index 1c3d5d9..bb7b0a6 100644 --- a/tools/service-binds/executor-lanes.py +++ b/tools/service-binds/executor-lanes.py @@ -16,6 +16,8 @@ window-open --admission FILE: for one admitted run (T06): the admission is valid create the write role exactly as CCR-2026-0058 declares it. window-close --admission FILE: after the run's receipt or at window end, whichever comes first: delete the write role and prove it is gone. Always allowed, also late; needs no CCR digests. +status: read-only (NK-WP-0058-T03): write role absent, coding-agent fence, cas_required; ready_for_window if all hold. + No write and no CCR digests; safe to repeat. Receipts hold closed booleans only. The journey values are read into memory to check their field names, and never printed or written. """ @@ -279,6 +281,17 @@ def window_open(digests, admission_path, receipt, now=None): ccr_sha256=digests) +def status(receipt): + """Read-only (NK-WP-0058-T03 readiness): no write, no role change, no CCR digests. Every check is reported, not + only the first that fails. `ready_for_window` means a window may be opened: the write role is absent, the fence + holds and cas_required is set on the bind key.""" + receipt['phase'] = 'identity'; attended_platform_admin() + receipt['phase'] = 'read' + checks = {'write_role_absent': write_role_absent(), 'agent_fence_present': fence_present(), + 'cas_required': cas_required()} + receipt.update(phase='done', status='status_read', **checks, ready_for_window=all(checks.values())) + + def window_close(admission_path, receipt): """Delete the write role after the run's receipt or at window end (whichever comes first) and prove it is gone. Needs no open window: closing late must always be possible.""" @@ -312,7 +325,7 @@ def reason_of(error): def main(): p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - p.add_argument('action', choices=['setup', 'verify', 'window-open', 'window-close']) + p.add_argument('action', choices=['setup', 'verify', 'window-open', 'window-close', 'status']) p.add_argument('--write-ccr-sha256', help='sha256 of the CCR-2026-0058 file (all but window-close)') p.add_argument('--journey-ccr-sha256', help='sha256 of the CCR-2026-0059 file (all but window-close)') p.add_argument('--kubeconfig', help='admin kubeconfig; required for verify') @@ -321,7 +334,7 @@ def main(): a = p.parse_args() if a.action == 'verify' and not a.kubeconfig: p.error('--kubeconfig is required for verify') if a.action.startswith('window-') and not a.admission: p.error('--admission is required for ' + a.action) - if a.action != 'window-close' and not (a.write_ccr_sha256 and a.journey_ccr_sha256): + if a.action not in ('window-close', 'status') and not (a.write_ccr_sha256 and a.journey_ccr_sha256): p.error('--write-ccr-sha256 and --journey-ccr-sha256 are required for ' + a.action) digests = {'write': a.write_ccr_sha256, 'journey': a.journey_ccr_sha256} fd = os.open(a.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) @@ -331,13 +344,15 @@ def main(): if a.action == 'setup': setup(digests, r) elif a.action == 'verify': verify(digests, a.kubeconfig, r) elif a.action == 'window-open': window_open(digests, a.admission, r) + elif a.action == 'status': status(r) else: window_close(a.admission, r) except Exception as error: r.update(status='failed', reason=reason_of(error)) if r['reason'] in HINTS: r['hint'] = HINTS[r['reason']] finally: with os.fdopen(fd, 'w') as out: json.dump(r, out, indent=2, sort_keys=True); out.write('\n') - print(json.dumps({k: r[k] for k in ('status', 'phase', 'reason', 'hint') if k in r})) + print(json.dumps({k: r[k] for k in ('status', 'phase', 'reason', 'hint', 'ready_for_window', 'write_role_absent') + if k in r})) return int(r['status'] == 'failed') diff --git a/workplans/NK-WP-0058-bounded-execution-operability.md b/workplans/NK-WP-0058-bounded-execution-operability.md index bc45549..40bb1ef 100644 --- a/workplans/NK-WP-0058-bounded-execution-operability.md +++ b/workplans/NK-WP-0058-bounded-execution-operability.md @@ -279,7 +279,7 @@ duplicates in other repos, a removal request is sent to their owners and recorde ```task id: NK-WP-0058-T03 -status: progress +status: done priority: high assignee: claude-code depends_on: [NK-WP-0058-T01, NK-WP-0058-T09] @@ -307,6 +307,38 @@ read-only founder command that supplies it. **Done when:** the readiness check is tested on stubs, runs with the agent identity wherever possible, and `--preflight-only` reports every failure it finds by phase and endpoint. +**Result, 2026-11-05.** Three steps. + +1. **Named causes.** + - `executor-lanes.py`'s identity phase distinguishes `openbao_unreachable`, `openbao_sealed` and + `platform_admin_login_required`, with a hint. `LaneError` keeps its fixed code. + - `approval_entry:`. +2. **`cli.py --preflight-only`.** + - It checks reachability per endpoint, then sets up without the write role, Kubernetes or directory clients. + - It runs all seven preflight checks by phase, with no consume, emit or write. Exit 0 ready, 4 not. + - The real preflight uses the same named checks. +3. **The readiness command.** + - `tools/bounded-execution-readiness/ready.py` (`make bounded-execution-ready`) runs with the agent identity + from the owners' source. It checks: + - the admission; + - the approver chain at both the Argo-pinned revision and main of informed-decision; + - the access-engine profile against the registry; + - the CCRs, against the owner's `RUNBOOK_ALLOWED_STATUSES`; + - the case, and the type bytes against the broker's pin. + - It names three read-only founder commands for the rest: + - `executor-lanes.py status` (new: write role, fence, `cas_required`); + - rapp `make preflight` (new: the Job runs `--preflight-only` in the pinned image, with only the openbao + token); + - the ExternalSecret `Ready` reads. + - A replay test shows it catches 2026-11-04: routing to `platform-root` on main while the Argo pin lagged. + - Against the real sources it passes for BEA-2026-0003. + +Tests: 125 in `make bounded-executor-test`, 31 in rapp `make test`. R1 needs no pin record: the preflight's `source` +check compares the admission's pins with the files in the pinned image. + +**Before first live use,** the executor changed, so a new image build and a rapp re-pin are needed. That is a +founder step for the next admission. The readiness check is not yet in CI; that is part of T02. + ### Founder interface: few, short, stateful steps ```task