diff --git a/Makefile b/Makefile index a8fa063..d7755f8 100644 --- a/Makefile +++ b/Makefile @@ -218,7 +218,7 @@ bounded-execution-ready: ## Readiness before a window, read-only (NK-WP-0058-T03 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 \ - tools/bounded-execution-readiness/tests + tools/bounded-execution-readiness/tests tools/bounded-execution-run/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 5413283..91d65d9 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -373,7 +373,7 @@ | 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 | 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-T04 | done | — | 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 | | task | NK-WP-0058-T07 | 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 a88c326..d4b4c8c 100644 --- a/docs/bounded-execution-contract-table.md +++ b/docs/bounded-execution-contract-table.md @@ -400,9 +400,9 @@ Argo pin with live) cover the gap. - **Owner request to ops-warden:** strip `OPENBAO_TOKEN` too, or every token variable by pattern (T07). - **The second pattern:** net-kingdom's window commands assume a token in the shell instead. They run several commands, and `pilot-run.sh` keeps that session open across a whole run. -- **Proposal:** run `executor-lanes.py window-open`, `window-close` and the T03 readiness check as warden lanes. They - already write their own receipts and print one line. `pilot-run.sh` then opens one lane per command and needs no - shell login. +- **Done (T04, 2026-11-05):** `tools/bounded-execution-run/bx.py` runs the window commands and the OpenBao status + as silent children of the warden lane (`executor-lanes.py --quiet`). `ready` and `run` each need one login, + and no shell login is left open. `pilot-run.sh` is deleted. ### F1: the founder's steps, 2026-11-04/05 @@ -424,7 +424,8 @@ Argo pin with live) cover the gap. There are about 45 commands across three runbooks, with five kinds of values carried by hand: UID and resource version, approval id, CCR digests, receipt numbers and `trust_satisfied`. That fits the reported ~50 rounds. T04's state file removes the carried values: `ready`, `request`, `run` and `status` read and write it, and the receipt -numbers come from the tool. +numbers come from the tool. **Done (T04):** `bx ready`, `bx request`, the approval, `bx run`, `bx status`. Sections 5 +and 6 of the T06 runbook now describe only these. ### F2: the browser identity diff --git a/sso-mfa/k8s/bounded-executor/T06-PILOT-RUNBOOK.md b/sso-mfa/k8s/bounded-executor/T06-PILOT-RUNBOOK.md index bf3439d..6fca5c4 100644 --- a/sso-mfa/k8s/bounded-executor/T06-PILOT-RUNBOOK.md +++ b/sso-mfa/k8s/bounded-executor/T06-PILOT-RUNBOOK.md @@ -18,8 +18,8 @@ Each step prints a value-free result. Paste it back, and claude-code verifies it | 2 | Accept the case | State Hub decision | `security-cases/nk-bind-executor-approval.yaml` becomes `accepted` | | 3 | Custody and audit sender | railiance-platform, audit-core | approve CCR-2026-0060, -0061, -0063; fence, policies, roles; audit seed; mint; ESO | | 4 | KeyCape client | net-kingdom | bind the env var and register the client in one KeyCape restart | -| 5 | Admission | net-kingdom (claude-code) | `admissions/BEA-2026-0002.yaml` for the window you choose | -| 6 | The run | rapp, net-kingdom | request, approve at aal2, open the window, run, close the window, verify | +| 5 | Admission | net-kingdom (claude-code) | `admissions/BEA-YYYY-NNNN.yaml` for the window you choose | +| 6 | The run | net-kingdom `bx` | `ready`, `request`, approve at aal2, `run`, `status` | ## 1. Owner ingress (T16) @@ -115,84 +115,48 @@ per scope. The excess-scope refusal, the custody denials and the RBAC can-i deni ## 5. Admission (claude-code prepares, you check) -claude-code runs, for the window you name (at most 2 h): +claude-code runs `bounded-executor/prepare_admission.py` for the window you name (at most 2 h), with `--commit` set +to the commit the pinned image was built from. It commits `admissions/BEA-YYYY-NNNN.yaml`. Read it: the plan, +checks, revert steps, budget and window are what you will approve. + +## 6. The run: four commands and the approval (NK-WP-0058-T04) + +Run from `~/net-kingdom` after `git pull --ff-only` here and in `~/rapp-bounded-executor`: ```bash -python3 bounded-executor/prepare_admission.py --id BEA-2026-0002 --commit 9bd7bf0 \ - --not-before 2026-11-05T09:00:00Z --hours 2 --out admissions/BEA-2026-0002.yaml +BX="python3 tools/bounded-execution-run/bx.py" +$BX ready admissions/BEA-YYYY-NNNN.yaml # one platform-admin login; expect "ready": true +$BX request # expect "outcome": "presented" +# approve the memo in informed-decision: browser profile of admin, aal2, fresh OTP +$BX run # one platform-admin login; preflight, open, run, close +$BX status # expect "outcome": "completed", "window_closed": true ``` -It commits the file. The pins are the files inside the pinned image (`sha256:2926d7d9…`, built from `9bd7bf0`). -Read it: the plan, the checks, the revert steps, the budget and the window are what you will approve. +Then tell claude-code. It checks the receipts in `docs/evidence/-/` read-only, confirms the run in +audit-core, runs posture feedback and records the evidence. -## 6. The run +- **Each command prints one value-free line and is safe to repeat.** `run` never starts a second run for an + admission, and the state file keeps the approval id, so nothing is copied by hand. +- **`ready` and `run` each open one `warden access openbao-platform-admin-login` login** (browser, MFA). Its child + is silent and writes the receipts. In `run`, the window closes in the same child, also when the run fails. +- **Not ready?** The line names each failed check. `run` refuses to open the window unless the preflight Job + passes, so fix the cause and repeat. +- **Kill switch, at any moment:** `kubectl --kubeconfig ~/.kube/config-railiance01 -n sso delete job + bounded-executor-`. The executor finishes the step in flight, then reverts. Before consume it spends nothing. +- **Emergency close:** `$BX close` deletes the write role; it is safe to repeat. -```bash -K="kubectl --kubeconfig $HOME/.kube/config-railiance01" -cd ~/rapp-bounded-executor && git pull --ff-only && cd ~/net-kingdom && git pull --ff-only -A=~/net-kingdom/admissions/BEA-2026-0002.yaml -CFG=$(mktemp --suffix=.json) -python3 -c "import json; c=json.load(open('$HOME/rapp-bounded-executor/examples/config.pilot.json')); c['trust_satisfied']=['runtime_secret_trust','runtime_identity_trust']; json.dump(c, open('$CFG','w'), indent=2)" -# trust_satisfied is your attestation, recorded in the run's result: OpenBao custody and KeyCape identity are healthy -``` +| Result | Meaning | +| --- | --- | +| `completed` | rotated, delivered, restarted, journey passed | +| `reverted` | a check failed, and the previous value is back, checked | +| `refused` | nothing was spent | +| `not_started` | the preflight or the window did not pass; no window stayed open | +| `stopped`, `unknown`, `revert_failed` | the alert in the result receipt names the step | -**6a. Request.** The executor files the approval as `service:nk-bind-executor` and presents it in informed-decision: +### Identity hygiene -```bash -cd ~/rapp-bounded-executor && make request ADMISSION=$A CONFIG=$CFG KUBECTL="$K" -# -> {"outcome": "presented", "approval_id": "...", ...}; note the approval_id -``` - -**6b. Approve.** Open the memo in informed-decision and approve it. You must be signed in at aal2 (OTP in this login). - -**6c. Open the window.** This creates CCR-2026-0058's write role for this admission only: - -```bash -cd ~/net-kingdom && bao login -method=oidc -path=netkingdom role=platform-admin -W=$(sha256sum ~/railiance-platform/credential-change-requests/CCR-2026-0058-*.yaml | cut -d' ' -f1) -J=$(sha256sum ~/railiance-platform/credential-change-requests/CCR-2026-0059-*.yaml | cut -d' ' -f1) -E=docs/evidence/$(date +%F)-nk56-t06-pilot; mkdir -p "$E" -python3 tools/service-binds/executor-lanes.py window-open --admission $A --write-ccr-sha256 "$W" \ - --journey-ccr-sha256 "$J" --receipt "$E/01-window-open.json" # {"status": "window_opened", ...} -``` - -**6d. Run.** Put the approval id from 6a in place of `APPROVAL`: - -```bash -cd ~/rapp-bounded-executor && make run ADMISSION=$A APPROVAL_ID=APPROVAL CONFIG=$CFG KUBECTL="$K" -make run-status ADMISSION_ID=BEA-2026-0002 KUBECTL="$K" -$K -n sso logs job/bounded-executor-bea-2026-0002 > ~/net-kingdom/$E/02-result.json -``` - -The last line of the log is the value-free result: - -| Exit | Result | Meaning | -| --- | --- | --- | -| 0 | `completed` | rotated, delivered, restarted, journey passed | -| 3 | `reverted` | a check failed, and the previous value is back, checked | -| 4 | `refused` | nothing was spent | -| 5 | `stopped`, `unknown` or `revert_failed` | the alert names the step | -| 6 | consumed but not in custody | — | - -**Kill switch**, at any moment of the run: - -```bash -$K -n sso delete job bounded-executor-bea-2026-0002 -``` - -The executor finishes the step in flight, then reverts what it changed (grace period = the budget). Before consume -it refuses and spends nothing. - -**6e. Close the window** right after the result, or at window end at the latest: - -```bash -cd ~/net-kingdom && python3 tools/service-binds/executor-lanes.py window-close --admission $A \ - --receipt "$E/03-window-close.json" # {"status": "window_closed", ...} -``` - -**6f. Tell claude-code.** It does the rest read-only: - -- it confirms the run's started event and receipt in audit-core (operator read by `correlation_id`); -- it validates the pair against receipt schema v0.1; -- it runs the posture-feedback report over the receipts (T07); -- it records the evidence and closes T06. +- Keep one browser profile per identity, and approve only in the `admin` profile. On 2026-11-04 the approval came + from whichever identity was signed in (`platform-root`) and was refused. +- Before approving, check the signed-in subject in informed-decision. Sign in again with OTP if the memo doesn't + list: access-engine lists memos only within a 12-hour MFA window and opens them within 15 minutes. +- The warden logins for `ready` and `run` are `platform-admin` in OpenBao, through KeyCape as `admin`. diff --git a/sso-mfa/k8s/bounded-executor/pilot-run.sh b/sso-mfa/k8s/bounded-executor/pilot-run.sh deleted file mode 100755 index 85fa26f..0000000 --- a/sso-mfa/k8s/bounded-executor/pilot-run.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# NK-WP-0056-T06 founder-run pilot: open the window, run one admission, ALWAYS close the window. -# sso-mfa/k8s/bounded-executor/pilot-run.sh ADMISSION_FILE APPROVAL_ID EVIDENCE_DIR -# Needs an attended `bao login -method=oidc -path=netkingdom role=platform-admin` in this shell first, and the -# approval already approved in informed-decision by the admission's approver. Prints value-free results only. -# The close runs from an EXIT trap, so a failed or interrupted run never leaves the write role open -# (2026-11-05: a diagnostic window stayed open overnight). -set -euo pipefail -A=${1:?admission file}; APPROVAL=${2:?approval id}; E=${3:?evidence dir} -NK=$HOME/net-kingdom; RAPP=$HOME/rapp-bounded-executor; PLAT=$HOME/railiance-platform -K="kubectl --kubeconfig $HOME/.kube/config-railiance01" -export BAO_ADDR=${BAO_ADDR:-http://127.0.0.1:18200} VAULT_ADDR=${VAULT_ADDR:-http://127.0.0.1:18200} -mkdir -p "$E" -ID=$(python3 -c "import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))['spec']['id'])" "$A") -JOB=bounded-executor-$(echo "$ID" | tr 'A-Z' 'a-z') -W=$(sha256sum "$PLAT"/credential-change-requests/CCR-2026-0058-*.yaml | cut -d' ' -f1) -J=$(sha256sum "$PLAT"/credential-change-requests/CCR-2026-0059-*.yaml | cut -d' ' -f1) -n=$(ls "$E" 2>/dev/null | wc -l) -close() { - python3 "$NK/tools/service-binds/executor-lanes.py" window-close --admission "$A" --receipt "$E/$(printf %02d $((n + 3)))-window-close.json" || true -} -python3 "$NK/tools/service-binds/executor-lanes.py" window-open --admission "$A" --write-ccr-sha256 "$W" \ - --journey-ccr-sha256 "$J" --receipt "$E/$(printf %02d $((n + 1)))-window-open.json" -trap close EXIT -CFG=$(mktemp --suffix=.json) -python3 -c "import json,sys; c=json.load(open(sys.argv[1])); c['trust_satisfied']=['runtime_secret_trust','runtime_identity_trust']; json.dump(c, open(sys.argv[2],'w'), indent=2)" "$RAPP/examples/config.pilot.json" "$CFG" -$K -n sso delete job "$JOB" --ignore-not-found >/dev/null; $K -n sso delete configmap "$JOB" --ignore-not-found >/dev/null -(cd "$RAPP" && make run ADMISSION="$A" APPROVAL_ID="$APPROVAL" CONFIG="$CFG" KUBECTL="$K") -# Either condition ends the wait (kubectl wait ANDs repeated --for; seen 2026-11-05, the completed run hung it). -for _ in $(seq 1 300); do - st=$($K -n sso get "job/$JOB" -o jsonpath='{.status.succeeded}{.status.failed}') - [ -n "$st" ] && break - sleep 5 -done -$K -n sso logs "job/$JOB" > "$E/$(printf %02d $((n + 2)))-result.json" -tail -40 "$E/$(printf %02d $((n + 2)))-result.json" diff --git a/tests/test_executor_lanes.py b/tests/test_executor_lanes.py index 7f1c7a5..afa56a8 100644 --- a/tests/test_executor_lanes.py +++ b/tests/test_executor_lanes.py @@ -279,3 +279,14 @@ def test_status_is_read_only_and_reports_every_check(tmp_path, monkeypatch): r = {} m.status(r) assert (r['write_role_absent'], r['cas_required'], r['ready_for_window']) == (False, False, False) + + +def test_quiet_prints_nothing_and_still_writes_the_receipt(tmp_path, monkeypatch, capsys): + """--quiet makes the window commands silent children of warden access --exec (NK-WP-0058-T04).""" + m, fake, d = build(tmp_path, monkeypatch) + run(m, 'setup', d) + receipt = tmp_path / 'status.json' + monkeypatch.setattr(sys, 'argv', ['executor-lanes.py', 'status', '--receipt', str(receipt), '--quiet']) + assert m.main() == 0 + assert capsys.readouterr() == ('', '') + assert json.loads(receipt.read_text())['ready_for_window'] is True diff --git a/tools/bounded-execution-run/bx.py b/tools/bounded-execution-run/bx.py new file mode 100644 index 0000000..07003dd --- /dev/null +++ b/tools/bounded-execution-run/bx.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Founder commands for one bounded-execution run (NK-WP-0058-T04). Each prints one value-free line. + + BX="python3 tools/bounded-execution-run/bx.py" + $BX ready admissions/BEA-YYYY-NNNN.yaml # readiness; one platform-admin login (OpenBao status) + $BX request # file the approval; the id goes into the state file + (approve the memo in informed-decision: browser profile of admin, aal2 with OTP) + $BX run # preflight, then window open, run, close; one platform-admin login + $BX status # the run's state and receipts, no login + $BX close # emergency: delete the write role; safe to repeat + +Inputs live in a state file (~/.local/state/net-kingdom/bounded-execution/.json), not in shell variables. +The admission named last is the current one; --admission selects another. Receipts are numbered by the tool in +docs/evidence/-/. Each command is safe to repeat: `run` never starts a second run for an admission. +The OpenBao steps run as silent children of `warden access openbao-platform-admin-login --exec`; the window +is closed in the same child, also when the run fails or is interrupted. + +--attest names the trust states you attest for the run (default: runtime_secret_trust,runtime_identity_trust). +They are recorded in the state file and the run config, never edited in by hand. +""" +import argparse +import datetime as dt +import hashlib +import json +import os +import re +import subprocess +import sys +import time +from pathlib import Path + +import yaml + +REPO = Path(__file__).resolve().parents[2] +HOME = Path.home() +STATE_DIR = Path(os.environ.get('BX_STATE_DIR', HOME / '.local/state/net-kingdom/bounded-execution')) +RAPP = Path(os.environ.get('BX_RAPP', REPO.parent / 'rapp-bounded-executor')) +PLATFORM = Path(os.environ.get('BX_PLATFORM', REPO.parent / 'railiance-platform')) +KUBECONFIG = Path(os.environ.get('BX_KUBECONFIG', HOME / '.kube/config-railiance01')) +BAO_ADDR = os.environ.get('BAO_ADDR', 'http://127.0.0.1:18200') +LANE = ['warden', 'access', 'openbao-platform-admin-login', '--exec', '--'] +LANES_TOOL = REPO / 'tools/service-binds/executor-lanes.py' +READY_TOOL = REPO / 'tools/bounded-execution-readiness/ready.py' +CCRS = {'write': 'CCR-2026-0058-*.yaml', 'journey': 'CCR-2026-0059-*.yaml'} +DELIVERIES = ('keycape-nk-bind-executor-approval-client', 'bounded-executor-tokens') +ATTEST = 'runtime_secret_trust,runtime_identity_trust' +WARDEN_EXIT = {10: 'login_failed', 11: 'child_failed', 12: 'child_output', 13: 'revoke_unconfirmed', + 14: 'cleanup_failed'} + + +class Runner: + """Every outside command goes through here; tests replace it.""" + + def run(self, argv, env=None, timeout=900): + full = {**os.environ, **(env or {})} + done = subprocess.run([str(a) for a in argv], capture_output=True, env=full, timeout=timeout) + return done.returncode, done.stdout.decode(errors='replace') + + def sleep(self, seconds): + time.sleep(seconds) + + +def kubectl(): + return f'kubectl --kubeconfig {KUBECONFIG}' + + +def json_blocks(text): + """The value-free JSON results in a log: one-line objects and the executor's indented result blocks.""" + out = [] + for block in re.findall(r'^\{\n.*?\n\}$', text, re.M | re.S) + re.findall(r'^\{.*\}$', text, re.M): + try: + out.append(json.loads(block)) + except ValueError: + pass + return out + + +class Bx: + def __init__(self, runner=None, now=None): + self.r = runner or Runner() + self.now = now or (lambda: dt.datetime.now(dt.timezone.utc)) + + # ------------------------------------------------------------------ state and receipts + def state_path(self, admission_id): + return STATE_DIR / f'{admission_id}.json' + + def load(self, admission=None): + if admission: + doc = yaml.safe_load(Path(admission).read_text()) + sid = doc['spec']['id'] + path = self.state_path(sid) + if path.exists(): + return json.loads(path.read_text()) + evidence = REPO / 'docs/evidence' / f"{self.now():%Y-%m-%d}-{sid.lower()}" + return {'admission_id': sid, 'admission': str(Path(admission).resolve()), 'evidence': str(evidence), + 'approval_id': None, 'attest': None, 'run': None} + current = STATE_DIR / 'current' + if not current.exists(): + raise SystemExit(self.line({'status': 'refused', 'reason': 'no_admission', 'next': 'bx ready ADMISSION'})) + return json.loads(self.state_path(current.read_text().strip()).read_text()) + + def save(self, state): + STATE_DIR.mkdir(parents=True, exist_ok=True) + path = self.state_path(state['admission_id']) + path.write_text(json.dumps(state, indent=2, sort_keys=True) + '\n') + (STATE_DIR / 'current').write_text(state['admission_id'] + '\n') + + def receipt(self, state, name): + folder = Path(state['evidence']) + folder.mkdir(parents=True, exist_ok=True) + numbers = [int(p.name[:2]) for p in folder.glob('[0-9][0-9]-*')] + return folder / f'{max(numbers, default=0) + 1:02d}-{name}.json' + + def write(self, path, data): + path.write_text(json.dumps(data, indent=2, sort_keys=True) + '\n') + return path + + def config(self, state): + """The run config: the rapp's pilot config with the attested trust states. Non-secret.""" + cfg = json.loads((RAPP / 'examples/config.pilot.json').read_text()) + cfg['trust_satisfied'] = list(state['attest']) + path = STATE_DIR / f"{state['admission_id']}.config.json" + path.write_text(json.dumps(cfg, indent=2) + '\n') + return path + + @staticmethod + def line(data): + return json.dumps(data, separators=(', ', ': ')) + + def lane(self, child): + """Run a silent child under one platform-admin login. The child gets an isolated HOME from warden, so every + path it needs is passed as an absolute path here.""" + env = {'BAO_ADDR': BAO_ADDR, 'VAULT_ADDR': BAO_ADDR, 'PYTHONWARNINGS': 'ignore', 'BX_STATE_DIR': STATE_DIR, + 'BX_RAPP': RAPP, 'BX_PLATFORM': PLATFORM, 'BX_KUBECONFIG': KUBECONFIG, 'NK_PLATFORM_ROOT': PLATFORM} + code, _ = self.r.run(LANE + child, env={k: str(v) for k, v in env.items()}) + return code + + # ------------------------------------------------------------------ commands + def ready(self, admission, attest): + state = self.load(admission) + state['attest'] = state['attest'] or attest.split(',') + self.save(state) + rec = self.receipt(state, 'readiness') + self.r.run(['python3', READY_TOOL, '--admission', state['admission'], + '--approval-id', state['approval_id'] or 'none', '--receipt', rec]) + agent = (json.loads(rec.read_text()) if rec.exists() + else {'agent_checks_pass': False, 'failures': ['no_receipt']}) + custody = {} + for name in DELIVERIES: + code, out = self.r.run(kubectl().split() + ['-n', 'sso', 'get', 'externalsecret', name, '-o', + 'jsonpath={.status.conditions[?(@.type=="Ready")].status}']) + custody[name] = code == 0 and out.strip() == 'True' + self.write(self.receipt(state, 'custody'), {'action': 'custody-delivery', 'ready': custody, + 'values_recorded': False}) + status = self.receipt(state, 'lanes-status') + warden = self.lane(['python3', LANES_TOOL, 'status', '--receipt', status, '--quiet']) + lanes = json.loads(status.read_text()) if status.exists() else {} + failures = list(agent.get('failures', [])) + [f'custody:{n}' for n, ok in custody.items() if not ok] + if not lanes.get('ready_for_window'): + failures.append('openbao:' + (lanes.get('reason') or WARDEN_EXIT.get(warden, 'not_ready_for_window'))) + state['ready'] = {'at': f'{self.now():%Y-%m-%dT%H:%M:%SZ}', 'failures': failures} + self.save(state) + nxt = 'bx run' if state['approval_id'] else 'bx request' + return self.line({'ready': not failures, 'admission': state['admission_id'], 'failures': failures, + 'next': nxt if not failures else 'fix the failures, then bx ready again'}) + + def request(self): + state = self.load() + if state['approval_id']: + return self.line({'approval_id': state['approval_id'], 'status': 'already_requested', + 'next': 'approve as admin (aal2), then bx run'}) + code, out = self.r.run(['make', '-C', RAPP, 'request', f"ADMISSION={state['admission']}", + f'CONFIG={self.config(state)}', f'KUBECTL={kubectl()}']) + result = next((b for b in reversed(json_blocks(out)) if 'outcome' in b), {}) + self.write(self.receipt(state, 'request'), {**result, 'exit': code}) + if result.get('approval_id'): + state['approval_id'] = result['approval_id'] + self.save(state) + return self.line({'approval_id': result.get('approval_id'), 'outcome': result.get('outcome'), + 'next': 'open the memo as admin (aal2, fresh OTP), approve, then bx run' + if result.get('outcome') == 'presented' else 'bx request again (idempotent)'}) + + def run(self): + state = self.load() + if state.get('run') and state['run'].get('started'): + return self.line({**self.summary(state), 'status': 'already_run'}) + if not state['approval_id']: + return self.line({'status': 'refused', 'reason': 'no_approval', 'next': 'bx request'}) + self.config(state) + code = self.lane(['python3', Path(__file__).resolve(), '_run-inner', '--state', + self.state_path(state['admission_id'])]) + state = json.loads(self.state_path(state['admission_id']).read_text()) + if code and not (state.get('run') or {}).get('finished'): + state['run'] = {**(state.get('run') or {}), 'warden': WARDEN_EXIT.get(code, f'exit_{code}')} + self.save(state) + return self.line(self.summary(state)) + + def run_inner(self, state_file): + """The silent lane child: preflight, then window open, run, poll, and close in `finally`. Prints nothing.""" + state = json.loads(Path(state_file).read_text()) + cfg = STATE_DIR / f"{state['admission_id']}.config.json" + k = kubectl() + run = state['run'] = {'started': False, 'receipts': []} + + def note(path): + run['receipts'].append(Path(path).name) + self.save(state) + + _, out = self.r.run(['make', '-C', RAPP, 'preflight', f"ADMISSION={state['admission']}", + f"APPROVAL_ID={state['approval_id']}", f'CONFIG={cfg}', f'KUBECTL={k}']) + report = next((b for b in reversed(json_blocks(out)) if b.get('mode') == 'preflight-only'), + {'ready': False, 'failures': ['preflight_no_report']}) + note(self.write(self.receipt(state, 'preflight'), report)) + if not report.get('ready'): + run.update(outcome='not_started', reason='preflight_not_ready', failures=report.get('failures')) + return self.save(state) + digests = {} + for lane, pattern in CCRS.items(): + files = sorted((PLATFORM / 'credential-change-requests').glob(pattern)) + digests[lane] = hashlib.sha256(files[0].read_bytes()).hexdigest() + opened = self.receipt(state, 'window-open') + try: + self.r.run(['python3', LANES_TOOL, 'window-open', '--admission', state['admission'], + '--write-ccr-sha256', digests['write'], '--journey-ccr-sha256', digests['journey'], + '--receipt', opened, '--quiet']) + note(opened) + if json.loads(opened.read_text()).get('status') != 'window_opened': + run.update(outcome='not_started', reason='window_not_opened') + return + run['started'] = True + self.save(state) + code, _ = self.r.run(['make', '-C', RAPP, 'run', f"ADMISSION={state['admission']}", + f"APPROVAL_ID={state['approval_id']}", f'CONFIG={cfg}', f'KUBECTL={k}']) + if code: + run.update(outcome='not_started', reason='job_not_created') + return + job = 'bounded-executor-' + state['admission_id'].lower() + budget = yaml.safe_load(Path(state['admission']).read_text())['spec']['budget']['wall_time_seconds'] + for _ in range(int(budget / 5) + 60): + _, st = self.r.run(k.split() + ['-n', 'sso', 'get', f'job/{job}', '-o', + 'jsonpath={.status.succeeded}{.status.failed}']) + if st.strip(): + break + self.r.sleep(5) + _, logs = self.r.run(k.split() + ['-n', 'sso', 'logs', f'job/{job}']) + result = next((b for b in reversed(json_blocks(logs)) if 'outcome' in b), + {'outcome': 'unknown', 'reason': 'no_result_in_log'}) + note(self.write(self.receipt(state, 'result'), result)) + run.update(outcome=result.get('outcome'), reason=result.get('reason'), run_id=result.get('run_id')) + finally: + closed = self.receipt(state, 'window-close') + self.r.run(['python3', LANES_TOOL, 'window-close', '--admission', state['admission'], + '--receipt', closed, '--quiet']) + if closed.exists(): + note(closed) + run['window_closed'] = json.loads(closed.read_text()).get('status') == 'window_closed' + run['finished'] = True + self.save(state) + + def summary(self, state): + run = state.get('run') or {} + return {'admission': state['admission_id'], 'approval_id': state['approval_id'], + 'outcome': run.get('outcome'), 'reason': run.get('reason'), 'window_closed': run.get('window_closed'), + 'warden': run.get('warden'), 'error': run.get('error'), 'receipts': len(run.get('receipts', [])), + 'evidence': os.path.relpath(state['evidence'], REPO)} + + def status(self): + return self.line(self.summary(self.load())) + + def close(self): + state = self.load() + rec = self.receipt(state, 'window-close') + code = self.lane(['python3', LANES_TOOL, 'window-close', '--admission', state['admission'], '--receipt', rec, + '--quiet']) + closed = json.loads(rec.read_text()) if rec.exists() else {} + return self.line({'status': closed.get('status', 'failed'), 'reason': closed.get('reason'), + 'warden': WARDEN_EXIT.get(code) if code else None}) + + +def main(argv=None, bx=None): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument('command', choices=['ready', 'request', 'run', 'status', 'close', '_run-inner']) + p.add_argument('admission', nargs='?') + p.add_argument('--attest', default=ATTEST) + p.add_argument('--state', help=argparse.SUPPRESS) + a = p.parse_args(argv) + bx = bx or Bx() + if a.command == '_run-inner': # a silent lane child: any output would fail the lane, so record, never print + try: + bx.run_inner(a.state) + except Exception as error: + state = json.loads(Path(a.state).read_text()) + state['run'] = {**(state.get('run') or {}), 'error': type(error).__name__, 'finished': True} + Path(a.state).write_text(json.dumps(state, indent=2, sort_keys=True) + '\n') + return 0 + if a.command == 'ready': + if not a.admission: + p.error('ready takes the admission file') + print(bx.ready(a.admission, a.attest)) + return 0 + if a.admission: + bx.save(bx.load(a.admission)) + print({'request': bx.request, 'run': bx.run, 'status': bx.status, 'close': bx.close}[a.command]()) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/bounded-execution-run/tests/test_bx.py b/tools/bounded-execution-run/tests/test_bx.py new file mode 100644 index 0000000..292d22a --- /dev/null +++ b/tools/bounded-execution-run/tests/test_bx.py @@ -0,0 +1,183 @@ +"""bx.py on stubs (NK-WP-0058-T04): every outside command is faked; no cluster, no OpenBao, no warden.""" +import contextlib +import importlib.util +import io +import json +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_file_location('bx', REPO / 'tools/bounded-execution-run/bx.py') +bx = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bx) +ADMISSION = REPO / 'admissions/BEA-2026-0003.yaml' + + +def arg(argv, name): + return argv[argv.index(name) + 1] + + +class Fake: + def __init__(self, tool, preflight_ready=True, window_opens=True, result='completed', logs_raise=False): + self.tool, self.calls, self.child_output = tool, [], '' + self.preflight_ready, self.window_opens, self.result, self.logs_raise = ( + preflight_ready, window_opens, result, logs_raise) + + def write(self, argv, data): + Path(arg(argv, '--receipt')).write_text(json.dumps(data)) + + def run(self, argv, env=None, timeout=900): + argv = [str(a) for a in argv] + joined = ' '.join(argv) + if argv[:3] == ['warden', 'access', 'openbao-platform-admin-login']: + self.calls.append('lane') + assert env['BX_STATE_DIR'] and env['BX_KUBECONFIG'] # absolute paths: the child's HOME is isolated + child = argv[argv.index('--') + 1:] + if '_run-inner' in child: + with contextlib.redirect_stdout(io.StringIO()) as out, contextlib.redirect_stderr(out): + self.tool.main(['_run-inner', '--state', arg(child, '--state')], bx=self.tool_bx) + self.child_output += out.getvalue() + return 0, '' + return self.run(child) + if 'ready.py' in joined: + self.calls.append('readiness') + self.write(argv, {'agent_checks_pass': True, 'failures': []}) + return 0, '' + if 'externalsecret' in joined: + self.calls.append('custody') + return 0, 'True' + if 'executor-lanes.py' in joined: + action = argv[2] + self.calls.append(action) + assert '--quiet' in argv + if action == 'status': + self.write(argv, {'status': 'status_read', 'ready_for_window': True}) + elif action == 'window-open': + self.write(argv, {'status': 'window_opened' if self.window_opens else 'failed'}) + elif action == 'window-close': + self.write(argv, {'status': 'window_closed'}) + return 0, '' + if argv[:1] == ['make']: + target = argv[3] + self.calls.append('make ' + target) + if target == 'request': + return 0, 'kubectl ...\n{\n "approval_id": "apr-9",\n "outcome": "presented"\n}\n' + if target == 'preflight': + report = {'mode': 'preflight-only', 'ready': self.preflight_ready, 'failures': + [] if self.preflight_ready else ['preflight:approval']} + return (0 if self.preflight_ready else 2), json.dumps(report, indent=2) + '\n' + if target == 'run': + return 0, 'kubectl create -f - < .rendered/run.yaml\n' + if 'get' in argv and any(a.startswith('job/') for a in argv): + self.calls.append('poll') + return 0, '1' + if 'logs' in argv: + self.calls.append('logs') + if self.logs_raise: + raise TimeoutError + return 0, json.dumps({'outcome': self.result, 'run_id': 'r-1', 'effects': True}, indent=2) + '\n' + raise AssertionError(argv) + + def sleep(self, seconds): + pass + + +@pytest.fixture +def env(tmp_path, monkeypatch): + monkeypatch.setattr(bx, 'STATE_DIR', tmp_path / 'state') + monkeypatch.setattr(bx, 'REPO', tmp_path / 'repo') + rapp = tmp_path / 'rapp' + (rapp / 'examples').mkdir(parents=True) + (rapp / 'examples/config.pilot.json').write_text(json.dumps({'executor_identity': 'service:nk-bind-executor'})) + monkeypatch.setattr(bx, 'RAPP', rapp) + ccrs = tmp_path / 'platform/credential-change-requests' + ccrs.mkdir(parents=True) + for name in ('CCR-2026-0058-w.yaml', 'CCR-2026-0059-j.yaml'): + (ccrs / name).write_text('id: x\n') + monkeypatch.setattr(bx, 'PLATFORM', tmp_path / 'platform') + return tmp_path + + +def tool(**kw): + fake = Fake(bx, **kw) + fake.tool_bx = bx.Bx(fake) + return fake, fake.tool_bx + + +def cli(b, *argv): + with contextlib.redirect_stdout(io.StringIO()) as out: + assert bx.main(list(argv), bx=b) == 0 + lines = out.getvalue().splitlines() + assert len(lines) == 1 # one value-free line per command + return json.loads(lines[0]) + + +def test_full_run_is_four_commands_plus_the_approval(env): + fake, b = tool() + assert cli(b, 'ready', str(ADMISSION))['next'] == 'bx request' + assert cli(b, 'request')['approval_id'] == 'apr-9' + done = cli(b, 'run') + assert (done['outcome'], done['window_closed'], done['receipts']) == ('completed', True, 4) + assert cli(b, 'status')['outcome'] == 'completed' + assert fake.calls == ['readiness', 'custody', 'custody', 'lane', 'status', 'make request', 'lane', + 'make preflight', 'window-open', 'make run', 'poll', 'logs', 'window-close'] + assert fake.child_output == '' # the lane child is silent + evidence = sorted(p.name for p in (env / 'repo/docs/evidence').glob('*/*')) + assert [n[:2] for n in evidence] == [f'{i:02d}' for i in range(1, 9)] + assert evidence[4:] == ['05-preflight.json', '06-window-open.json', '07-result.json', '08-window-close.json'] + state = json.loads((env / 'state/BEA-2026-0003.json').read_text()) + assert state['attest'] == ['runtime_secret_trust', 'runtime_identity_trust'] + cfg = json.loads((env / 'state/BEA-2026-0003.config.json').read_text()) + assert cfg['trust_satisfied'] == state['attest'] + + +def test_commands_are_safe_to_repeat(env): + fake, b = tool() + cli(b, 'ready', str(ADMISSION)) + cli(b, 'request') + assert cli(b, 'request')['status'] == 'already_requested' + cli(b, 'run') + calls = len(fake.calls) + assert cli(b, 'run')['status'] == 'already_run' + assert len(fake.calls) == calls # no second run, no second login + + +def test_preflight_not_ready_never_opens_the_window(env): + fake, b = tool(preflight_ready=False) + cli(b, 'ready', str(ADMISSION)) + cli(b, 'request') + done = cli(b, 'run') + assert (done['outcome'], done['reason']) == ('not_started', 'preflight_not_ready') + assert 'window-open' not in fake.calls and 'make run' not in fake.calls + fake.preflight_ready = True + assert cli(b, 'run')['outcome'] == 'completed' # a run that never started may be retried + + +@pytest.mark.parametrize('kw, outcome', [({'window_opens': False}, 'not_started'), ({'result': 'reverted'}, 'reverted')]) +def test_the_window_is_always_closed(env, kw, outcome): + fake, b = tool(**kw) + cli(b, 'ready', str(ADMISSION)) + cli(b, 'request') + done = cli(b, 'run') + assert done['outcome'] == outcome and fake.calls[-1] == 'window-close' and done['window_closed'] + + +def test_an_error_in_the_child_still_closes_and_stays_silent(env): + fake, b = tool(logs_raise=True) + cli(b, 'ready', str(ADMISSION)) + cli(b, 'request') + done = cli(b, 'run') + assert done['error'] == 'TimeoutError' and done['window_closed'] is True + assert fake.calls[-1] == 'window-close' and fake.child_output == '' + + +def test_close_is_one_lane_and_idempotent(env): + fake, b = tool() + cli(b, 'ready', str(ADMISSION)) + assert cli(b, 'close')['status'] == 'window_closed' + assert cli(b, 'close')['status'] == 'window_closed' + + +def test_instructions_fit_120_characters(): + assert all(len(line) <= 120 for line in bx.__doc__.splitlines()) diff --git a/tools/service-binds/executor-lanes.py b/tools/service-binds/executor-lanes.py index bb7b0a6..b7e4abf 100644 --- a/tools/service-binds/executor-lanes.py +++ b/tools/service-binds/executor-lanes.py @@ -331,6 +331,8 @@ def main(): p.add_argument('--kubeconfig', help='admin kubeconfig; required for verify') p.add_argument('--admission', help='the admitted BEA file; required for window-open and window-close') p.add_argument('--receipt', required=True) + p.add_argument('--quiet', action='store_true', + help='print nothing; the receipt is the result (a silent child of warden access --exec)') 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) @@ -351,8 +353,9 @@ def main(): 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', 'ready_for_window', 'write_role_absent') - if k in r})) + if not a.quiet: + 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 40bb1ef..2bcb6a8 100644 --- a/workplans/NK-WP-0058-bounded-execution-operability.md +++ b/workplans/NK-WP-0058-bounded-execution-operability.md @@ -343,7 +343,7 @@ founder step for the next admission. The readiness check is not yet in CI; that ```task id: NK-WP-0058-T04 -status: todo +status: done priority: high assignee: claude-code depends_on: [NK-WP-0058-T03] @@ -365,6 +365,29 @@ needs: a browser profile per identity, and when a fresh OTP login is due. **Done when:** a full pilot run is at most five short founder commands plus the approval in informed-decision. The commands are tested on stubs, and T06's runbook shrinks accordingly. +**Result, 2026-11-05.** `tools/bounded-execution-run/bx.py`: `ready`, `request`, (the approval), `run` and `status` +make four commands plus the approval. `close` is for emergencies. + +- **State file:** inputs live in `~/.local/state/net-kingdom/bounded-execution/.json`. That covers the + admission, the approval id, the attested trust states and the evidence folder. The run config is generated from + the rapp's config, never edited by hand. +- **Receipts:** numbered by the tool in `docs/evidence/-/`. +- **Safe to repeat:** every command. `run` never starts a second run for an admission; a run that never started + may be retried. +- **One line per command,** value-free. +- **Two logins in a run:** `ready` and `run` each open one `warden access openbao-platform-admin-login` login. + The child is silent (`executor-lanes.py --quiet`) and writes the receipts. `run` does preflight, window open, + run, poll and result, and closes the window in `finally`, also on an error inside the child. This resolves O3. +- **Tests:** 8 tests on stubs cover the full flow, repeats, a not-ready preflight never opening the window, the + window always closing, a silent child on error, and instruction lines of at most 120 characters. 134 in + `make bounded-executor-test`. +- **Runbook:** sections 5 and 6 of the T06 runbook are replaced by the four commands, a result table and identity + hygiene: a browser profile per identity, a check of the signed-in subject, and a fresh OTP when a memo doesn't + list. `pilot-run.sh` is deleted. +- **Not yet live-proven:** first live use needs the new image, a rapp re-pin and a new admission. Whether warden + passes `BAO_ADDR` and the `BX_*` variables through to the child is confirmed only from its playbook ("the caller + passes BAO_ADDR"), so the first `bx ready` is the live check. + ### Agent status visibility without secrets ```task