Classify sitting-requester exchange failures and retry failed receipts.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

The reader login succeeded; the child died at preflight. Use bao for the
KV read, accept parent/sibling deny shapes, and record a failure class.

Assistant: grok
Assistant-Session: 01a0a23b-3bf0-7341-b4e5-9dc05f72573a
This commit is contained in:
codex 2026-09-15 20:51:28 +02:00
parent 73178f4ae2
commit 9ce42da97e
3 changed files with 95 additions and 32 deletions

View file

@ -3,6 +3,7 @@ import base64
import json import json
import os import os
import stat import stat
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.parse import urlencode from urllib.parse import urlencode
@ -24,6 +25,10 @@ class NoRedirect(HTTPRedirectHandler):
return None return None
class Refused(Exception):
pass
def http(url, *, body=None, headers=None): def http(url, *, body=None, headers=None):
req = Request(url, data=body, headers=headers or {}) req = Request(url, data=body, headers=headers or {})
try: try:
@ -35,44 +40,72 @@ def http(url, *, body=None, headers=None):
content = error.read(1048577) content = error.read(1048577)
error.close() error.close()
if len(content) > 1048576: if len(content) > 1048576:
raise ValueError('response_too_large') raise Refused('response_too_large')
return status, json.loads(content) if content else {} if not content:
return status, {}
try:
return status, json.loads(content)
except json.JSONDecodeError:
raise Refused('non_json_http_response')
def bao(*args): def bao(*args):
import subprocess import subprocess
p = subprocess.run(['bao', *args], capture_output=True, text=True, timeout=20) p = subprocess.run(['bao', *args], capture_output=True, text=True, timeout=20)
if p.returncode: if p.returncode:
raise ValueError('metadata_failed') raise Refused('metadata_failed')
return json.loads(p.stdout) return json.loads(p.stdout)
def capabilities(result):
if isinstance(result, dict):
result = result.get('data', result).get('capabilities', result)
return result if isinstance(result, list) else []
def is_deny(result):
caps = set(capabilities(result))
return caps <= {'deny'} or not caps.intersection({'read', 'create', 'update', 'delete', 'list', 'patch', 'sudo'})
def check_identity(data): def check_identity(data):
policies = set(data.get('policies', [])) | set(data.get('identity_policies', [])) policies = set(data.get('policies', [])) | set(data.get('identity_policies', []))
if POLICY not in policies or policies - {POLICY, 'default'} or not data.get('entity_id') or not 0 < data.get('ttl', 0) <= 900: if POLICY not in policies or policies - {POLICY, 'default'}:
raise ValueError('reader_identity_failed') raise Refused('reader_identity_failed')
if not data.get('entity_id') or not 0 < int(data.get('ttl') or 0) <= 900:
raise Refused('reader_identity_failed')
def secret_from_read(payload):
body = payload.get('data', payload)
inner = body.get('data', body)
version = (body.get('metadata') or {}).get('version')
secret = inner.get('CLIENT_SECRET') if isinstance(inner, dict) else None
if version != 1 or not secret:
raise Refused('requester_delivery_failed')
return secret
def main(receipt): def main(receipt):
if Path.home().parent.name != '.warden-attended-login' or os.getenv('BAO_TOKEN') or os.getenv('VAULT_TOKEN'): if Path.home().parent.name != '.warden-attended-login' or os.getenv('BAO_TOKEN') or os.getenv('VAULT_TOKEN'):
raise ValueError('attended_reader_required') raise Refused('attended_reader_required')
receipt['phase'] = 'envelope_ok'
check_identity(bao('token', 'lookup', '-format=json')['data']) check_identity(bao('token', 'lookup', '-format=json')['data'])
for path, expected in ((KV, ['read']), (SIBLING, ['deny']), (PARENT, ['deny'])): receipt['phase'] = 'identity_ok'
result = bao('token', 'capabilities', '-format=json', path) if capabilities(bao('token', 'capabilities', '-format=json', KV)) != ['read']:
if isinstance(result, dict): raise Refused('reader_scope_failed:kv')
result = result.get('data', result).get('capabilities', result) if not is_deny(bao('token', 'capabilities', '-format=json', SIBLING)):
if result != expected: raise Refused('reader_scope_failed:sibling')
raise ValueError('reader_scope_failed') if not is_deny(bao('token', 'capabilities', '-format=json', PARENT)):
raise Refused('reader_scope_failed:parent')
receipt['phase'] = 'scope_ok'
helper = Path.home() / '.vault-token' helper = Path.home() / '.vault-token'
info = helper.lstat() info = helper.lstat()
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600 or info.st_uid != os.getuid(): if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600 or info.st_uid != os.getuid():
raise ValueError('private_helper_required') raise Refused('private_helper_required')
addr = os.environ.get('BAO_ADDR', 'http://127.0.0.1:18200').rstrip('/') secret = secret_from_read(bao('read', '-format=json', KV))
status, data = http(addr + '/v1/' + KV + '?version=1', headers={'X-Vault-Token': helper.read_text().strip()}) receipt['phase'] = 'secret_present'
if status != 200 or data['data']['metadata']['version'] != 1: receipt['secret_present'] = True
raise ValueError('requester_delivery_failed')
secret = data['data']['data']['CLIENT_SECRET']
del data
def exchange(scope, credential=secret): def exchange(scope, credential=secret):
auth = base64.b64encode((CLIENT_ID + ':' + credential).encode()).decode() auth = base64.b64encode((CLIENT_ID + ':' + credential).encode()).decode()
@ -80,27 +113,42 @@ def main(receipt):
headers={'Authorization': 'Basic ' + auth, 'Content-Type': 'application/x-www-form-urlencoded'}) headers={'Authorization': 'Basic ' + auth, 'Content-Type': 'application/x-www-form-urlencoded'})
status, tokens = exchange('approval:create') status, tokens = exchange('approval:create')
if status != 200: if status != 200 or 'access_token' not in tokens:
raise ValueError('requester_exchange_failed') raise Refused('requester_exchange_failed')
receipt['phase'] = 'exchange_ok'
token = tokens['access_token'] token = tokens['access_token']
status, jwks = http(ISSUER + '/jwks') status, jwks = http(ISSUER + '/jwks')
if status != 200: if status != 200:
raise ValueError('jwks_failed') raise Refused('jwks_failed')
header = jwt.get_unverified_header(token) header = jwt.get_unverified_header(token)
keys = [key for key in jwks['keys'] if key['kid'] == header.get('kid')] keys = [key for key in jwks['keys'] if key['kid'] == header.get('kid')]
if header.get('alg') != 'RS256' or len(keys) != 1: if header.get('alg') != 'RS256' or len(keys) != 1:
raise ValueError('signing_key_failed') raise Refused('signing_key_failed')
claims = jwt.decode(token, jwt.PyJWK.from_dict(keys[0]).key, algorithms=['RS256'], issuer=ISSUER, claims = jwt.decode(token, jwt.PyJWK.from_dict(keys[0]).key, algorithms=['RS256'], issuer=ISSUER,
audience='approval-engine', options={'strict_aud': True, 'require': ['sub', 'iat', 'exp', 'iss', 'aud']}) audience='approval-engine', options={'strict_aud': True, 'require': ['sub', 'iat', 'exp', 'iss', 'aud']})
expected = {'sub': 'informed-decision', 'tenant': 'tenant:platform', 'principal_type': 'service', roles = claims.get('roles')
'scope': 'approval:create', 'roles': ['informed-decision-sitting-requester'], 'groups': []} if isinstance(roles, str):
if any(claims.get(key) != value for key, value in expected.items()) or claims['exp'] - claims['iat'] != 900: roles = [roles]
raise ValueError('requester_claims_failed') if claims.get('sub') != 'informed-decision':
raise Refused('requester_claims_failed:sub')
if claims.get('tenant') != 'tenant:platform':
raise Refused('requester_claims_failed:tenant')
if claims.get('principal_type') != 'service':
raise Refused('requester_claims_failed:principal_type')
if claims.get('scope') != 'approval:create':
raise Refused('requester_claims_failed:scope')
if roles != ['informed-decision-sitting-requester']:
raise Refused('requester_claims_failed:roles')
if claims.get('groups') not in (None, []):
raise Refused('requester_claims_failed:groups')
if int(claims['exp']) - int(claims['iat']) != 900:
raise Refused('requester_claims_failed:ttl')
receipt['phase'] = 'claims_ok'
for scope in ('approval:approve', 'approval:consume', 'approval:read'): for scope in ('approval:approve', 'approval:consume', 'approval:read'):
if exchange(scope)[0] != 400: if exchange(scope)[0] != 400:
raise ValueError('excess_scope_not_refused') raise Refused('excess_scope_not_refused')
if exchange('approval:create', 'invalid-synthetic-credential')[0] != 401: if exchange('approval:create', 'invalid-synthetic-credential')[0] != 401:
raise ValueError('wrong_secret_not_refused') raise Refused('wrong_secret_not_refused')
receipt.update(status='verified', phase='requester_verified', signature_verified=True, receipt.update(status='verified', phase='requester_verified', signature_verified=True,
excess_scopes_refused=True, wrong_secret_refused=True, reader_scope_verified=True, excess_scopes_refused=True, wrong_secret_refused=True, reader_scope_verified=True,
sitting_post=False) sitting_post=False)
@ -108,12 +156,18 @@ def main(receipt):
if __name__ == '__main__': if __name__ == '__main__':
if RECEIPT.exists(): if RECEIPT.exists():
raise SystemExit(1) try:
receipt = {'observed_at': __import__('datetime').datetime.now(__import__('datetime').timezone.utc).isoformat(), existing = json.loads(RECEIPT.read_text())
except Exception:
raise SystemExit(1)
if existing.get('status') == 'verified':
raise SystemExit(1)
receipt = {'observed_at': datetime.now(timezone.utc).isoformat(),
'status': 'failed', 'phase': 'preflight', 'credential_values_emitted': False, 'sitting_post': False} 'status': 'failed', 'phase': 'preflight', 'credential_values_emitted': False, 'sitting_post': False}
try: try:
main(receipt) main(receipt)
except Exception: except Exception as error:
receipt['failure'] = str(error) if isinstance(error, Refused) else 'contained_operation_failed'
raise SystemExit(1) from None raise SystemExit(1) from None
finally: finally:
RECEIPT.write_text(json.dumps(receipt, indent=2) + '\n') RECEIPT.write_text(json.dumps(receipt, indent=2) + '\n')

View file

@ -45,6 +45,10 @@ class SittingRequesterExchangeTests(unittest.TestCase):
self.assertEqual(exchange.CLIENT_ID, 'informed-decision-sitting-requester') self.assertEqual(exchange.CLIENT_ID, 'informed-decision-sitting-requester')
self.assertEqual(exchange.POLICY, 'workload-kv-read-informed-decision-sitting-requester-client') self.assertEqual(exchange.POLICY, 'workload-kv-read-informed-decision-sitting-requester-client')
self.assertIn('approval-requester', exchange.SIBLING) self.assertIn('approval-requester', exchange.SIBLING)
self.assertTrue(exchange.is_deny(['deny']))
self.assertTrue(exchange.is_deny([]))
self.assertFalse(exchange.is_deny(['read']))
self.assertEqual(exchange.capabilities({'data': {'capabilities': ['read']}}), ['read'])
if __name__ == '__main__': if __name__ == '__main__':

View file

@ -61,3 +61,8 @@ applied. No sitting POST. Remaining: create-only token-exchange proof
Exchange proof uses reader lane `informed-decision-sitting-requester-login` Exchange proof uses reader lane `informed-decision-sitting-requester-login`
and `scripts/prove-sitting-requester-exchange.sh`. It does not POST sittings. and `scripts/prove-sitting-requester-exchange.sh`. It does not POST sittings.
2026-09-15T18:47Z reader login reached a helper-backed session; the child
failed at `preflight` with no failure class. A first browser sign-in did not
complete. Retry records a named failure class, reads KV through `bao`, and
overwrites a failed receipt only.