Prepare the read-only attended session for four open custody questions
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Four owner threads are each blocked on a fact only an authenticated read can
establish: whether the legacy whynot-design npm path still exists, which field
backs the authoritative lane, whether the two KeyCape approval policies match
repo source after an activation that recorded policy_applied false, which
netkingdom bound group claims already exist as input to CCR-2026-0019, and which
fields the governed backup lane carries.

Adds scripts/openbao_open_questions_session.py, which contains no mutating bao
verb and emits only a mode-0600 metadata receipt, plus the run-book in
docs/openbao-open-questions-session.md. Field-name resolution is a data read, so
the runner returns sorted key names and no value reaches argv, disk or the
receipt; that caveat is stated rather than glossed.

Not run. The rapp-qonto rotation and the RPF-WP-0029 provider invalidation are
explicitly out of scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLUjpv3ssxNRAEPPgLFnEB

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1275505@bnt-lap001
Assistant-Session: 97265baa-f08f-4032-b290-a1e2965a69c5
This commit is contained in:
codex 2026-09-09 20:07:51 +02:00
parent 251456acd2
commit f0c2fd58cd
2 changed files with 270 additions and 0 deletions

View file

@ -0,0 +1,103 @@
# Attended OpenBao session — four open custody questions
One founder-attended session that settles the questions currently blocking four
owner threads. Everything in it is read-only against OpenBao. It provisions
nothing, approves nothing, and rotates nothing.
Prepared 2026-09-09 under RPF-WP-0035. Not yet run.
## Why a session rather than a reply
Four owner threads are each blocked on a fact that only an authenticated read can
establish, and each has been answered so far with "I will not guess":
| Question | Thread | What is undecidable from repo source |
| --- | --- | --- |
| Q1 | secrets-engine `546403e4` | Whether the legacy path `secret/coulomb/whynot-design/npm/publish` still exists and holds a real token |
| Q2 | secrets-engine `546403e4` | Which field backs the authoritative npm lane. My CCR says `NPM_AUTH_TOKEN`; secrets-engine says `npm_token`. Field names are not visible in KV v2 metadata |
| Q3 | internal | The 2026-09-09 activation receipt records `policy_applied: false` for both KeyCape approval lanes while `role_applied: true`. Reads verified, so the policies must exist — but existence and repo agreement were never confirmed |
| Q4 | CCR-2026-0019 | Which netkingdom OIDC roles and bound group claims already exist, as input to the operator group claim that lane is missing |
| Q5 | risk-nexus `ee702ac9` | Which fields the governed backup lane actually carries, as RPF-WP-0029-T02 context |
## Authority and containment
Authority is the governed `openbao-platform-admin-login` lane: `founder_required`,
attended OIDC through `bao login -method=oidc -path=netkingdom role=platform-admin`.
The owner command is never run directly.
```sh
scripts/openbao-attended-exec.py -- \
/usr/bin/python3 /home/worsch/railiance-platform/scripts/openbao_open_questions_session.py \
--receipt /tmp/<new-unique-name>.json
```
`scripts/openbao-attended-exec.py` supplies the WSL browser launcher and then
`exec`s into `warden access openbao-platform-admin-login --exec`, so Warden keeps
the envelope: it captures both streams, owns the temporary token helper and
self-revokes when the command returns. Use a receipt path that does not exist —
the writer opens it `O_EXCL` at mode 0600 and refuses to overwrite.
The runner prints nothing. Its entire output is the receipt.
## What the runner reads, and the one honest caveat
| Step | Call | Emitted |
| --- | --- | --- |
| Q1 | `secrets list`, then `kv metadata get` on the legacy path if the mount exists | mount present, path present, current version, created/updated time |
| Q2 | `kv get` on `platform/workloads/coulomb/whynot-design/npm-publish` | **sorted field names only**, plus KV version |
| Q3 | `policy read` for both KeyCape approval policies | present, matches-repo-source, sha256 of each normalized text |
| Q4 | `list auth/netkingdom/role`, then `read` each | role names, bound claims, groups/user claim, token policies, TTL |
| Q5 | `kv get` on `platform/workloads/railiance/backup/offsite-lane` | **sorted field names only**, plus KV version |
**The caveat, stated plainly:** Q2 and Q5 are data reads. Field names cannot be
obtained from KV v2 metadata, so the value transits the runner's memory. It is
never printed, never passed on argv, never written to disk, and never placed in
the receipt — `field_names()` returns key names and the values go out of scope
with the function. If that is not acceptable for the backup lane in particular,
drop Q5: it is context for RISK-F-0010, not a dependency of it.
For Q5 the receipt records `predecessor_material_recorded: false`, and no value,
fingerprint, length or shape of any predecessor credential is captured — which is
the constraint risk-nexus set.
## What each answer unblocks
- **Q1 + Q2 → secrets-engine.** A pointer plus a field name closes their custody
question. If Q2 returns `npm_token`, my CCR-2026-0001 is wrong about the field
and I correct it; if it returns `NPM_AUTH_TOKEN`, their catalog entry is the one
that moves, as a reviewed lane change carrying its own approval. If Q1 finds a
populated legacy path, that is a stale duplicate holding a real token and
becomes its own disposition item — do not delete it inside this session.
- **Q3 → internal.** Either the policies match the repo, and the activation's
`policy_applied: false` meant "no change needed", or they drift and that is a
finding to fix under RPF-WP-0035 before anyone treats the verifier lanes as
clean.
- **Q4 → CCR-2026-0019.** Existing bound claims show what the estate already uses
for operator lanes. This is **input, not confirmation** — the authorized group
claim is NetKingdom's and KeyCape's to state, and the receipt says so in-line so
a later reader cannot mistake the listing for an approval.
- **Q5 → risk-nexus.** Confirms the governed lane is populated with the expected
field names, supporting the fail-closed argument already made on that thread.
## Explicitly out of scope
- **The rapp-qonto KeyCape client rotation** (ops-warden `c1aafba4`). It is a
two-custodian CAS write with a service restart and needs its own reviewed
request first. Do not fold it into a read-only session because the operator
happens to be authenticated.
- **Predecessor share invalidation** for RPF-WP-0029-T02. That is a provider-side
action in the Nextcloud UI, not an OpenBao operation, and it needs the provider
owner. It can share the same sitting, but it is a separate step with a separate
receipt.
- **Any write, seed, rotation or policy apply.** The runner contains no mutating
verb; if a question turns out to need one, it comes back as a request.
- **Client-side reads for the approval clients.** CCR-2026-0019/0020 remain
in_flight; this session does not advance them beyond Q4's input.
## After the session
1. Store the receipt under `docs/evidence/` with the run date, values absent.
2. Reply to secrets-engine (Q1/Q2), risk-nexus (Q5) and, if Q3 drifts, record the
finding against RPF-WP-0035.
3. If Q1 finds a populated legacy path, open a disposition item for it rather
than acting on it in the moment.

View file

@ -0,0 +1,167 @@
"""Read-only, silent owner session for the four open custody questions.
Runs inside Warden's attended login envelope. Reads no secret value into the
receipt: where a data read is unavoidable (field-name resolution), only sorted
key names leave this process. Applies nothing, writes nothing to OpenBao, and
records no approval. Warden owns the temporary token helper and self-revokes
after this command returns.
Questions settled:
Q1 Does legacy mount/path secret/coulomb/whynot-design/npm/publish exist?
Q2 Which field name backs the authoritative npm lane, NPM_AUTH_TOKEN or
npm_token? (secrets-engine message 546403e4)
Q3 Do the two KeyCape approval policies live in OpenBao and match the repo
source? (activation receipt recorded policy_applied false)
Q4 Which netkingdom OIDC roles and bound group claims already exist, as
non-binding input to CCR-2026-0019?
Q5 Governed backup lane field presence, as RPF-WP-0029-T02 / RISK-F-0010
context. Never the value, fingerprint, length or shape.
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
LEGACY_MOUNT = 'secret/'
LEGACY_PATH = 'secret/coulomb/whynot-design/npm/publish'
NPM_PATH = 'platform/workloads/coulomb/whynot-design/npm-publish'
BACKUP_PATH = 'platform/workloads/railiance/backup/offsite-lane'
POLICIES = {
'workload-kv-read-keycape-secrets-engine-approval':
'openbao/policies/workload-kv-read-keycape-secrets-engine-approval.hcl',
'workload-kv-read-keycape-approval-engine-operator':
'openbao/policies/workload-kv-read-keycape-approval-engine-operator.hcl',
}
FIELD_NAME_RE = re.compile(r'^[A-Za-z0-9_.-]{1,64}$')
def bao(*args, timeout=20):
"""Run one bao command. Returns (returncode, parsed-json-or-None)."""
result = subprocess.run(['bao', *args], capture_output=True, text=True, timeout=timeout)
if result.returncode:
return result.returncode, None
try:
return 0, json.loads(result.stdout)
except json.JSONDecodeError:
return 0, None
def field_names(payload):
"""Sorted key names only. A value never leaves this function."""
data = ((payload or {}).get('data') or {}).get('data') or {}
names = sorted(str(k) for k in data.keys())
if any(not FIELD_NAME_RE.match(n) for n in names):
raise ValueError('unexpected field-name shape; refusing to record')
return names
def kv_version(payload):
meta = ((payload or {}).get('data') or {}).get('metadata') or {}
return meta.get('version')
def normalized_policy(text):
lines = [line.strip() for line in text.splitlines()]
return '\n'.join(line for line in lines if line)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--receipt', type=Path, required=True)
args = parser.parse_args()
receipt = {
'schema': 'platform.openbao-open-questions-session.v1',
'observed_at': datetime.now(timezone.utc).isoformat(),
'operation': 'read-only observation',
'credential_values_emitted': False,
'openbao_mutations': 0,
'questions': ['Q1', 'Q2', 'Q3', 'Q4', 'Q5'],
}
# Q1 - legacy mount and path existence.
rc, mounts = bao('secrets', 'list', '-format=json')
legacy_mount_present = bool(mounts and LEGACY_MOUNT in mounts)
q1 = {'legacy_mount_present': legacy_mount_present, 'legacy_path': LEGACY_PATH}
if legacy_mount_present:
rc, meta = bao('kv', 'metadata', 'get', '-format=json', LEGACY_PATH)
q1['legacy_path_present'] = rc == 0
if rc == 0 and meta:
data = meta.get('data') or {}
q1['current_version'] = data.get('current_version')
q1['created_time'] = data.get('created_time')
q1['updated_time'] = data.get('updated_time')
else:
q1['legacy_path_present'] = False
receipt['q1_legacy_npm_path'] = q1
# Q2 - authoritative npm lane field names.
rc, payload = bao('kv', 'get', '-format=json', NPM_PATH)
receipt['q2_npm_lane'] = {
'path': NPM_PATH,
'readable': rc == 0,
'field_names': field_names(payload) if rc == 0 else None,
'kv_version': kv_version(payload) if rc == 0 else None,
'values_recorded': False,
}
# Q3 - KeyCape approval policy presence and drift.
q3 = {}
for name, rel in POLICIES.items():
rc, payload = bao('policy', 'read', '-format=json', name)
entry = {'present': rc == 0}
if rc == 0:
live = normalized_policy(((payload or {}).get('data') or {}).get('policy', ''))
source = normalized_policy((REPO / rel).read_text())
entry['matches_repo_source'] = live == source
entry['live_sha256'] = hashlib.sha256(live.encode()).hexdigest()
entry['source_sha256'] = hashlib.sha256(source.encode()).hexdigest()
q3[name] = entry
receipt['q3_keycape_policies'] = q3
# Q4 - existing netkingdom OIDC roles and bound claims (non-secret config).
rc, listing = bao('list', '-format=json', 'auth/netkingdom/role')
roles = {}
if rc == 0 and isinstance(listing, list):
for role in listing:
rc, payload = bao('read', '-format=json', f'auth/netkingdom/role/{role}')
if rc:
continue
data = (payload or {}).get('data') or {}
roles[str(role)] = {
'bound_claims': data.get('bound_claims'),
'groups_claim': data.get('groups_claim'),
'user_claim': data.get('user_claim'),
'token_policies': data.get('token_policies'),
'token_ttl': data.get('token_ttl'),
}
receipt['q4_netkingdom_roles'] = {
'listed': rc == 0,
'roles': roles,
'note': 'input only; the authorized operator group claim is confirmed by '
'NetKingdom/KeyCape, not inferred from this listing',
}
# Q5 - governed backup lane field presence.
rc, payload = bao('kv', 'get', '-format=json', BACKUP_PATH)
receipt['q5_backup_lane'] = {
'path': BACKUP_PATH,
'readable': rc == 0,
'field_names': field_names(payload) if rc == 0 else None,
'kv_version': kv_version(payload) if rc == 0 else None,
'predecessor_material_recorded': False,
}
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w') as stream:
json.dump(receipt, stream, indent=2)
stream.write('\n')
if __name__ == '__main__':
main()