Prepare exact metered requester extension with scope refusal tests
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e387-534d-70e3-ad53-4ea05676db8c
This commit is contained in:
parent
debf981097
commit
b15ab5774f
4 changed files with 216 additions and 0 deletions
28
docs/metered-requester-extension.md
Normal file
28
docs/metered-requester-extension.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Exact metered requester extension — awaiting operator approval
|
||||
|
||||
Existing work: RPF-WP-0035-T06, SECRETS-WP-0009-T03, REINAH-WP-0003-T06.
|
||||
This proposal does not change CCR-2026-0025's admitted scope until the operator
|
||||
explicitly approves it. No additional task or workplan is created.
|
||||
|
||||
Reviewed command: scripts/create-metered-approval-requests.sh, invoking the
|
||||
adjacent Python script with --clock-trust-file and --mandate. The command freezes
|
||||
SHA-256 22e640428e3a7ae8fc2363cf193db98381cd94e4be7ab009bc6703658d6fc544 of
|
||||
secrets-engine/docs/proposals/glas-metered-tool-renewal-20260927/native-pdp-inputs.json.
|
||||
|
||||
Exactly six unsigned requests: apply, verify, exec for each of
|
||||
`glas-claude-agent-dev-anthropic` and `activity-core-metered-worker-token`.
|
||||
The provider requires human control; the companion retains ordinary approval.
|
||||
The requester keeps approval:create only, the same exact KV reader, subject,
|
||||
audience, tenant, and 15-minute token limit. It verifies excess scopes and sibling
|
||||
reads are denied; client bytes remain in memory; Warden revokes its reader.
|
||||
It neither approves nor consumes requests and never retrieves either workload
|
||||
credential. It writes non-secret native receipts and refuses to overwrite a
|
||||
partial receipt or retry an uncertain create. Planned IDs persist before POST.
|
||||
|
||||
Four requester tests plus two existing token-time tests pass. Dry-run lists
|
||||
exactly six records. Actual creation still requires explicit operator mandate,
|
||||
fresh admitted Railiance Clock interval and attended reader execution. Native
|
||||
review and consume/backend admission remain after creation. The current human
|
||||
review HTTP adapter admits human-control records only: companion ordinary
|
||||
approval needs its own supported approver path; do not relabel it human-control
|
||||
or use the retired combined operator client to bypass that boundary.
|
||||
142
scripts/create-metered-approval-requests.py
Normal file
142
scripts/create-metered-approval-requests.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""Silent creation of exactly six reviewed metered activation requests; never approves or consumes."""
|
||||
import argparse,base64,json,os,stat,subprocess,time,sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime,timezone,timedelta
|
||||
from urllib.request import Request,build_opener,ProxyHandler,HTTPRedirectHandler
|
||||
from urllib.parse import urlencode
|
||||
from urllib.error import HTTPError
|
||||
import jwt
|
||||
ROOT=Path('/home/worsch/railiance-platform')
|
||||
RECEIPT=ROOT/'docs/evidence/2026-09-27-metered-approval-requests.json'
|
||||
POLICY='workload-kv-read-secrets-engine-requester-client'
|
||||
KV='platform/data/workloads/secrets-engine/approval-requester'
|
||||
ISSUER='https://kc.coulomb.social'
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self,*args,**kwargs): return None
|
||||
|
||||
def http(url,*,body=None,headers=None):
|
||||
req=Request(url,data=body,headers=headers or {})
|
||||
try:
|
||||
with build_opener(ProxyHandler({}),NoRedirect()).open(req,timeout=20) as response:
|
||||
content=response.read(1048577);status=response.status
|
||||
except HTTPError as error:
|
||||
status=error.code;content=error.read(1048577);error.close()
|
||||
if len(content)>1048576:raise ValueError('response_too_large')
|
||||
return status,json.loads(content)
|
||||
|
||||
def bao(*args):
|
||||
p=subprocess.run(['bao',*args],capture_output=True,text=True,timeout=20)
|
||||
if p.returncode:raise ValueError('metadata_failed')
|
||||
return json.loads(p.stdout)
|
||||
|
||||
def check_identity(d):
|
||||
policies=set(d.get('policies',[]))|set(d.get('identity_policies',[]))
|
||||
if POLICY not in policies or policies-{POLICY,'default'} or not d.get('entity_id') or not 0<d.get('ttl',0)<=900:
|
||||
raise ValueError('reader_identity_failed')
|
||||
|
||||
PACKET = Path('/home/worsch/secrets-engine/docs/proposals/glas-metered-tool-renewal-20260927/native-pdp-inputs.json')
|
||||
PACKET_SHA256 = '22e640428e3a7ae8fc2363cf193db98381cd94e4be7ab009bc6703658d6fc544'
|
||||
EXPECTED_LANES = {'glas-claude-agent-dev-anthropic': True, 'activity-core-metered-worker-token': False}
|
||||
WINDOW_END = datetime.fromisoformat('2026-09-28T18:20:00+00:00')
|
||||
|
||||
def prepare(rows):
|
||||
expected = {(lane, action) for lane in EXPECTED_LANES for action in ('apply', 'verify', 'exec')}
|
||||
if len(rows) != 6 or {(r['catalog'], r['action']) for r in rows} != expected:
|
||||
raise ValueError('exact_six_records_required')
|
||||
result = []
|
||||
for row in rows:
|
||||
req = row['request']; decision = row['decision']; native = decision['binding']
|
||||
lane = row['catalog']; human = EXPECTED_LANES[lane]
|
||||
if (req['action'] != row['action'] or req['subject'] != {'id':'secrets-engine','type':'service'}
|
||||
or req['tenant'] != 'tenant:platform' or req['resource']['id'] != 'catalog:'+lane
|
||||
or req['context'].get('human_control', False) is not human):
|
||||
raise ValueError('request_scope_drift')
|
||||
if (decision['provenance']['policy_package_digest'] != 'sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4'
|
||||
or decision['provenance']['policy_package'] != 'secrets-engine.catalog-lane.lifecycle'
|
||||
or decision['provenance']['policy_version'] != 'v2'
|
||||
or native['tenant'] != req['tenant'] or native['action'] != req['action']
|
||||
or native['subject']['id'] != req['subject']['id']
|
||||
or native['subject']['type'] != req['subject']['type']
|
||||
or native['context'] != req['context']
|
||||
or any(native['resource'].get(k) != v for k,v in req['resource'].items())):
|
||||
raise ValueError('native_evaluator_binding_drift')
|
||||
pdp_digest = native['request_digest']
|
||||
import re, hashlib, uuid
|
||||
if not re.fullmatch(r'sha256:[0-9a-f]{64}', pdp_digest):
|
||||
raise ValueError('native_digest_missing')
|
||||
binding = {'action': req['action'], 'actor':'secrets-engine', 'principal':'secrets-engine',
|
||||
'purpose':req['context']['purpose'], 'target':req['resource']}
|
||||
digest = 'sha256:'+hashlib.sha256(json.dumps(binding,sort_keys=True,separators=(',',':'),ensure_ascii=False).encode()).hexdigest()
|
||||
record = {'approval_id':str(uuid.uuid4()),'binding':binding,'binding_digest':digest,
|
||||
'action':req['action'],'human_control':human,'memo_version':1,'catalog':lane}
|
||||
memo = 'memo:metered-20260927-'+('provider' if human else 'worker')+'-'+req['action']
|
||||
result.append((memo,record,pdp_digest))
|
||||
return result
|
||||
|
||||
def requests():
|
||||
import hashlib
|
||||
raw = PACKET.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != PACKET_SHA256:
|
||||
raise ValueError('frozen_packet_drift')
|
||||
return prepare(json.loads(raw))
|
||||
|
||||
def main(receipt):
|
||||
prepared=requests()
|
||||
if Path.home().parent.name!='.warden-attended-login' or os.getenv('BAO_TOKEN') or os.getenv('VAULT_TOKEN'):raise ValueError('attended_reader_required')
|
||||
check_identity(bao('token','lookup','-format=json')['data'])
|
||||
for path,expected in [(KV,['read']),('platform/data/workloads/secrets-engine/approval-client',['deny']),('platform/metadata/workloads/secrets-engine',['deny'])]:
|
||||
if bao('token','capabilities','-format=json',path)!=expected:raise ValueError('reader_scope_failed')
|
||||
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')
|
||||
status,data=http('http://127.0.0.1:18200/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
|
||||
def exchange(scope,credential=secret):
|
||||
auth=base64.b64encode(('secrets-engine-requester:'+credential).encode()).decode()
|
||||
return http(ISSUER+'/token',body=urlencode({'grant_type':'client_credentials','scope':scope}).encode(),headers={'Authorization':'Basic '+auth,'Content-Type':'application/x-www-form-urlencoded'})
|
||||
status,tokens=exchange('approval:create')
|
||||
if status!=200:raise ValueError('requester_exchange_failed')
|
||||
token=tokens['access_token'];status,jwks=http(ISSUER+'/jwks')
|
||||
if status!=200:raise ValueError('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')
|
||||
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'],'verify_iat':False,'verify_exp':False,'verify_nbf':False})
|
||||
from t03_request_time import validate_token_time
|
||||
validate_token_time(claims, CLOCK.read())
|
||||
expected={'sub':'secrets-engine','tenant':'tenant:platform','principal_type':'service','scope':'approval:create','roles':['secrets-engine-requester'],'groups':[]}
|
||||
if any(claims.get(k)!=v for k,v in expected.items()) or claims['exp']-claims['iat']!=900:raise ValueError('requester_claims_failed')
|
||||
for scope in ('approval:approve','approval:consume','approval:read'):
|
||||
if exchange(scope)[0]!=400:raise ValueError('excess_scope_not_refused')
|
||||
if exchange('approval:create','invalid-synthetic-credential')[0]!=401:raise ValueError('wrong_secret_not_refused')
|
||||
receipt.update(phase='requester_verified',signature_verified=True,excess_scopes_refused=True,wrong_secret_refused=True,reader_scope_verified=True)
|
||||
receipt['planned_requests']=[{'memo_id':m.removeprefix('memo:'), 'approval_id':r['approval_id'], 'action':r['action'], 'catalog':r['catalog']} for m,r,_ in prepared]
|
||||
RECEIPT.write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
for memo,record,pdp_digest in prepared:
|
||||
now=datetime.fromtimestamp(CLOCK.read().lower_ns/1e9,timezone.utc)
|
||||
if now >= WINDOW_END:raise ValueError('spend_window_expired')
|
||||
body={'id':record['approval_id'],'binding':record['binding'],'validity':{'not_before':now.isoformat(),'expires_at':min(now+timedelta(hours=24),WINDOW_END).isoformat()},'required_count':1,'human_control':record['human_control'],'pdp_path':True,'pdp_digest':pdp_digest}
|
||||
receipt['phase']='create_attempt:'+record['action'];RECEIPT.write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
status,result=http('http://127.0.0.1:18281/v1/approvals',body=json.dumps(body).encode(),headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'})
|
||||
if status!=201 or result['status']!='requested' or result['entries'] or result['binding']['digest']!=record['binding_digest'] or result['binding']['human_control'] is not record['human_control'] or result['binding']['pdp_digest']!=pdp_digest:raise ValueError('request_creation_requires_reconciliation')
|
||||
receipt['requests'].append({'memo_id':memo.removeprefix('memo:'),'memo_version':record['memo_version'],'action':record['action'],'catalog':record['catalog'],'approval':result})
|
||||
RECEIPT.write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
receipt.update(status='created',phase='six_unapproved_requests_created',human_entries_created=False,approvals_consumed=False)
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser();p.add_argument('--clock-trust-file',type=Path);p.add_argument('--mandate',type=Path);p.add_argument('--dry-run',action='store_true');a=p.parse_args()
|
||||
if a.dry_run:
|
||||
rows=requests()
|
||||
print(json.dumps({'count':len(rows),'packet_sha256':PACKET_SHA256,'requests':[{'memo_id':m,'catalog':r['catalog'],'action':r['action'],'human_control':r['human_control']} for m,r,_ in rows],'posted':False}))
|
||||
raise SystemExit(0)
|
||||
if not a.clock_trust_file or not a.mandate:raise SystemExit('reviewed mandate and Clock admission required')
|
||||
mandate=json.loads(a.mandate.read_text())
|
||||
if mandate != {'status':'operator-approved','packet_sha256':PACKET_SHA256,'scope':'create-only-six-metered-requests','source':'explicit user approval in attended Codex session'}:raise SystemExit('exact requester mandate required')
|
||||
sys.path.insert(0,'/home/worsch/railiance-clock/src')
|
||||
from railiance_clock.client import Clock,FileTrust
|
||||
CLOCK=Clock(FileTrust(a.clock_trust_file));CLOCK.read()
|
||||
if RECEIPT.exists():raise SystemExit(1)
|
||||
receipt={'observed_at':datetime.now(timezone.utc).isoformat(),'status':'failed','phase':'preflight','requests':[],'credential_values_emitted':False,'packet_sha256':PACKET_SHA256}
|
||||
try:main(receipt)
|
||||
except Exception:raise SystemExit(1) from None
|
||||
finally:RECEIPT.write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
3
scripts/create-metered-approval-requests.sh
Executable file
3
scripts/create-metered-approval-requests.sh
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
# Credential-bearing child output remains inside the attended owner boundary.
|
||||
exec /home/worsch/informed-decision/.venv/bin/python -B /home/worsch/railiance-platform/scripts/create-metered-approval-requests.py "$@" >/dev/null 2>&1
|
||||
43
tests/test_metered_requester.py
Normal file
43
tests/test_metered_requester.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from pathlib import Path
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import unittest
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('metered_requester', root / 'scripts/create-metered-approval-requests.py')
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
class MeteredRequesterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.rows = json.loads(m.PACKET.read_text())
|
||||
|
||||
def test_exact_packet_preserves_separate_human_controls(self):
|
||||
records = m.requests()
|
||||
self.assertEqual(len(records), 6)
|
||||
self.assertEqual(sum(row[1]['human_control'] for row in records), 3)
|
||||
self.assertEqual(len({row[1]['approval_id'] for row in records}), 6)
|
||||
|
||||
def test_missing_duplicate_or_extra_requests_are_refused(self):
|
||||
for rows in (self.rows[:-1], self.rows + self.rows[:1], self.rows[:-1] + self.rows[:1]):
|
||||
with self.subTest(count=len(rows)), self.assertRaises(ValueError):
|
||||
m.prepare(rows)
|
||||
|
||||
def test_changed_actor_native_pins_or_human_control_are_refused(self):
|
||||
for change in ('actor', 'native', 'human', 'context'):
|
||||
rows = copy.deepcopy(self.rows)
|
||||
if change == 'actor': rows[0]['request']['subject']['id'] = 'other-service'
|
||||
if change == 'native': rows[0]['decision']['provenance']['policy_package_digest'] = 'sha256:wrong'
|
||||
if change == 'human': rows[0]['request']['context']['human_control'] = False
|
||||
if change == 'context': rows[0]['decision']['binding']['context']['purpose'] = 'different action'
|
||||
with self.subTest(change=change), self.assertRaises(ValueError):
|
||||
m.prepare(rows)
|
||||
|
||||
def test_frozen_packet_change_refused_before_credential_access(self):
|
||||
from unittest.mock import patch
|
||||
with patch.object(m, 'PACKET_SHA256', '0' * 64), self.assertRaises(ValueError):
|
||||
m.requests()
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue