From b15ab5774f1e037de40004f8b33852283920fee0 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 27 Sep 2026 20:34:52 +0200 Subject: [PATCH] Prepare exact metered requester extension with scope refusal tests Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e387-534d-70e3-ad53-4ea05676db8c --- docs/metered-requester-extension.md | 28 ++++ scripts/create-metered-approval-requests.py | 142 ++++++++++++++++++++ scripts/create-metered-approval-requests.sh | 3 + tests/test_metered_requester.py | 43 ++++++ 4 files changed, 216 insertions(+) create mode 100644 docs/metered-requester-extension.md create mode 100644 scripts/create-metered-approval-requests.py create mode 100755 scripts/create-metered-approval-requests.sh create mode 100644 tests/test_metered_requester.py diff --git a/docs/metered-requester-extension.md b/docs/metered-requester-extension.md new file mode 100644 index 0000000..c2dbc3d --- /dev/null +++ b/docs/metered-requester-extension.md @@ -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. diff --git a/scripts/create-metered-approval-requests.py b/scripts/create-metered-approval-requests.py new file mode 100644 index 0000000..582cde9 --- /dev/null +++ b/scripts/create-metered-approval-requests.py @@ -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= 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') diff --git a/scripts/create-metered-approval-requests.sh b/scripts/create-metered-approval-requests.sh new file mode 100755 index 0000000..693e692 --- /dev/null +++ b/scripts/create-metered-approval-requests.sh @@ -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 diff --git a/tests/test_metered_requester.py b/tests/test_metered_requester.py new file mode 100644 index 0000000..6d7e801 --- /dev/null +++ b/tests/test_metered_requester.py @@ -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()