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 os
import stat
from datetime import datetime, timezone
from pathlib import Path
from urllib.error import HTTPError
from urllib.parse import urlencode
@ -24,6 +25,10 @@ class NoRedirect(HTTPRedirectHandler):
return None
class Refused(Exception):
pass
def http(url, *, body=None, headers=None):
req = Request(url, data=body, headers=headers or {})
try:
@ -35,44 +40,72 @@ def http(url, *, body=None, headers=None):
content = error.read(1048577)
error.close()
if len(content) > 1048576:
raise ValueError('response_too_large')
return status, json.loads(content) if content else {}
raise Refused('response_too_large')
if not content:
return status, {}
try:
return status, json.loads(content)
except json.JSONDecodeError:
raise Refused('non_json_http_response')
def bao(*args):
import subprocess
p = subprocess.run(['bao', *args], capture_output=True, text=True, timeout=20)
if p.returncode:
raise ValueError('metadata_failed')
raise Refused('metadata_failed')
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):
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:
raise ValueError('reader_identity_failed')
if POLICY not in policies or policies - {POLICY, 'default'}:
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):
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'])
for path, expected in ((KV, ['read']), (SIBLING, ['deny']), (PARENT, ['deny'])):
result = bao('token', 'capabilities', '-format=json', path)
if isinstance(result, dict):
result = result.get('data', result).get('capabilities', result)
if result != expected:
raise ValueError('reader_scope_failed')
receipt['phase'] = 'identity_ok'
if capabilities(bao('token', 'capabilities', '-format=json', KV)) != ['read']:
raise Refused('reader_scope_failed:kv')
if not is_deny(bao('token', 'capabilities', '-format=json', SIBLING)):
raise Refused('reader_scope_failed:sibling')
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'
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():
raise ValueError('private_helper_required')
addr = os.environ.get('BAO_ADDR', 'http://127.0.0.1:18200').rstrip('/')
status, data = http(addr + '/v1/' + KV + '?version=1', headers={'X-Vault-Token': helper.read_text().strip()})
if status != 200 or data['data']['metadata']['version'] != 1:
raise ValueError('requester_delivery_failed')
secret = data['data']['data']['CLIENT_SECRET']
del data
raise Refused('private_helper_required')
secret = secret_from_read(bao('read', '-format=json', KV))
receipt['phase'] = 'secret_present'
receipt['secret_present'] = True
def exchange(scope, credential=secret):
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'})
status, tokens = exchange('approval:create')
if status != 200:
raise ValueError('requester_exchange_failed')
if status != 200 or 'access_token' not in tokens:
raise Refused('requester_exchange_failed')
receipt['phase'] = 'exchange_ok'
token = tokens['access_token']
status, jwks = http(ISSUER + '/jwks')
if status != 200:
raise ValueError('jwks_failed')
raise Refused('jwks_failed')
header = jwt.get_unverified_header(token)
keys = [key for key in jwks['keys'] if key['kid'] == header.get('kid')]
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,
audience='approval-engine', options={'strict_aud': True, 'require': ['sub', 'iat', 'exp', 'iss', 'aud']})
expected = {'sub': 'informed-decision', 'tenant': 'tenant:platform', 'principal_type': 'service',
'scope': 'approval:create', 'roles': ['informed-decision-sitting-requester'], 'groups': []}
if any(claims.get(key) != value for key, value in expected.items()) or claims['exp'] - claims['iat'] != 900:
raise ValueError('requester_claims_failed')
roles = claims.get('roles')
if isinstance(roles, str):
roles = [roles]
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'):
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:
raise ValueError('wrong_secret_not_refused')
raise Refused('wrong_secret_not_refused')
receipt.update(status='verified', phase='requester_verified', signature_verified=True,
excess_scopes_refused=True, wrong_secret_refused=True, reader_scope_verified=True,
sitting_post=False)
@ -108,12 +156,18 @@ def main(receipt):
if __name__ == '__main__':
if RECEIPT.exists():
raise SystemExit(1)
receipt = {'observed_at': __import__('datetime').datetime.now(__import__('datetime').timezone.utc).isoformat(),
try:
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}
try:
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
finally:
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.POLICY, 'workload-kv-read-informed-decision-sitting-requester-client')
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__':

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`
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.