diff --git a/docs/provisioning-windows/2026-10-04-1000/informed-decision/checklist.md b/docs/provisioning-windows/2026-10-04-1000/informed-decision/checklist.md index 8f4729b..52bedfe 100644 --- a/docs/provisioning-windows/2026-10-04-1000/informed-decision/checklist.md +++ b/docs/provisioning-windows/2026-10-04-1000/informed-decision/checklist.md @@ -30,3 +30,12 @@ After successful metadata execution, continue owner-controlled ESO deployment, exact workload positive/negative proof, mounted projection and requester exchange. Keep the receipt-contract checks pending until observed. No credential rotation is included. Checklist complete: false; no live metadata applied by the agent. + +## Helper repair after exit 11 + +The first attended attempt reported child_failed. Installed CLI testing exposed +an absence-diagnostic mismatch, now fixed. The exact CCR digest and command +above remain valid. The child compares both current objects before any writes, +and now records fixed phase/exit metadata under +`docs/evidence/requester-metadata/`. No CLI output or credential data is retained. +Report only the Warden exit code; the agent can inspect the bounded local receipt. diff --git a/scripts/informed_decision_requester_metadata.py b/scripts/informed_decision_requester_metadata.py index c540ba9..924b6d5 100644 --- a/scripts/informed_decision_requester_metadata.py +++ b/scripts/informed_decision_requester_metadata.py @@ -3,6 +3,7 @@ Does not read/write KV values, change issuer custody, or deploy Kubernetes objects. """ import argparse +from datetime import datetime, timezone import hashlib import importlib.util import json @@ -10,6 +11,7 @@ import os from pathlib import Path import subprocess import sys +import uuid ROOT = Path(__file__).resolve().parents[1] CCR = ROOT / 'credential-change-requests/CCR-2026-0032.yaml' @@ -29,8 +31,12 @@ def cli(arguments, payload=None, *, missing_ok=False): except (OSError, subprocess.TimeoutExpired): raise Refused(4) from None if result.returncode: - # Only a structured API 404 is absence. Denial/transport failure is not. - if missing_ok and result.returncode == 2 and b'Code: 404' in result.stderr: + # bao read turns an empty 404 into this exact path-specific diagnostic. + # Do not treat arbitrary stderr containing 404, denial or timeout as absence. + if (missing_ok and arguments[:2] == ['read', '-format=json'] + and len(arguments) == 3 and result.returncode == 2 + and not result.stdout + and result.stderr.strip() == ('No value found at ' + arguments[2]).encode()): return None raise Refused(4) if arguments[0] == 'write' and not result.stdout.strip(): @@ -86,8 +92,10 @@ def source(expected_digest): return policy, payload -def apply(policy, payload, *, command=cli): +def apply(policy, payload, *, command=cli, phase=lambda value: None): + phase("read_policy") current_policy = command(['read', '-format=json', 'sys/policies/acl/' + POLICY], missing_ok=True) + phase('read_role') current_role = command(['read', '-format=json', ROLE], missing_ok=True) # Check BOTH before mutating either; differing objects require a fresh review. if current_policy is not None and current_policy.get('data', {}).get('policy') != policy: @@ -95,11 +103,15 @@ def apply(policy, payload, *, command=cli): if current_role is not None and not role_matches(current_role.get('data')): raise Refused(3) if current_policy is None: + phase('write_policy') command(['write', '-format=json', 'sys/policies/acl/' + POLICY, '-'], json.dumps({'policy': policy}).encode()) if current_role is None: + phase('write_role') command(['write', '-format=json', ROLE, '-'], json.dumps(payload).encode()) + phase('verify_policy') verified_policy = command(['read', '-format=json', 'sys/policies/acl/' + POLICY]) + phase('verify_role') verified_role = command(['read', '-format=json', ROLE]) if (verified_policy.get('data', {}).get('policy') != policy or not role_matches(verified_role.get('data'))): @@ -107,6 +119,10 @@ def apply(policy, payload, *, command=cli): def main(): + receipt = {'schema': 'requester-metadata-attempt.v1', 'ccr': 'CCR-2026-0032', + 'phase': 'arguments', 'exit_code': 2, 'native_delivery_verified': False} + def phase(value): + receipt['phase'] = value try: parser = argparse.ArgumentParser(add_help=False) parser.add_argument('--expected-ccr-sha256', required=True) @@ -118,20 +134,39 @@ def main(): args = parser.parse_args() finally: sys.stderr = original + phase('attended_boundary') if (Path.home().parent.name != '.warden-attended-login' or os.getenv('BAO_TOKEN') or os.getenv('VAULT_TOKEN')): raise Refused(2) + phase('source') policy, payload = source(args.expected_ccr_sha256) + phase('identity') identity = cli(['token', 'lookup', '-format=json']).get('data', {}) policies = identity.get('policies', []) if 'platform-admin' not in policies or 'root' in policies: raise Refused(2) - apply(policy, payload) + apply(policy, payload, phase=phase) + phase('metadata_verified') + receipt['exit_code'] = 0 return 0 except Refused as exc: + receipt['exit_code'] = exc.code return exc.code except BaseException: return 2 + finally: + # Fixed metadata only. Never persist CLI bytes, exception text or identity data. + try: + directory = ROOT / 'docs/evidence/requester-metadata' + directory.mkdir(parents=True, exist_ok=True) + receipt['at'] = datetime.now(timezone.utc).isoformat() + path = directory / (uuid.uuid4().hex + '.json') + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'w') as stream: + json.dump(receipt, stream, sort_keys=True) + stream.write('\n') + except BaseException: + return 6 # Receipt could not be retained; metadata may have changed. if __name__ == '__main__': diff --git a/tests/test_informed_decision_requester_metadata.py b/tests/test_informed_decision_requester_metadata.py index bb8899a..dbcb175 100644 --- a/tests/test_informed_decision_requester_metadata.py +++ b/tests/test_informed_decision_requester_metadata.py @@ -5,6 +5,11 @@ import json from pathlib import Path import subprocess import unittest +import tempfile +import http.server +import threading +import shutil +import os from unittest.mock import patch spec = importlib.util.spec_from_file_location('requester_metadata', Path(__file__).resolve().parents[1] / 'scripts/informed_decision_requester_metadata.py') @@ -89,8 +94,8 @@ class MetadataTests(unittest.TestCase): self.assertEqual(caught.exception.code, 5) def test_404_only_is_absence_and_failures_are_silent(self): - for code, stderr, missing in [(2, b'Code: 404', True), (2, b'Code: 403 SECRET-SENTINEL', False), (1, b'Code: 404 SECRET-SENTINEL', False)]: - with self.subTest(code=code, stderr=stderr), patch.object(m.subprocess, 'run', return_value=subprocess.CompletedProcess([], code, b'SECRET-SENTINEL', stderr)): + for code, stderr, missing in [(2, ('No value found at '+m.ROLE+'\n').encode(), True), (2, b'Code: 404', False), (2, b'Code: 403 SECRET-SENTINEL', False), (1, ('No value found at '+m.ROLE).encode(), False), (2, b'No value found at wrong-path', False)]: + with self.subTest(code=code, stderr=stderr), patch.object(m.subprocess, 'run', return_value=subprocess.CompletedProcess([], code, b'', stderr)): output = io.StringIO() with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): if missing: self.assertIsNone(m.cli(['read', '-format=json', m.ROLE], missing_ok=True)) @@ -103,17 +108,74 @@ class MetadataTests(unittest.TestCase): self.assertEqual(m.cli(['write', '-format=json', m.ROLE, '-'], b'{}'), {}) def test_proposed_source_refuses_before_network(self): - digest = m.hashlib.sha256(m.CCR.read_bytes()).hexdigest() - with patch.object(m.subprocess, 'run', side_effect=AssertionError('must remain offline')): - with self.assertRaises(m.Refused) as caught: m.source(digest) - self.assertEqual(caught.exception.code, 2) + with tempfile.TemporaryDirectory() as tmp: + ccr = Path(tmp) / 'proposed.yaml' + ccr.write_text(m.CCR.read_text().replace('status: approved', 'status: proposed')) + digest = m.hashlib.sha256(ccr.read_bytes()).hexdigest() + with patch.object(m, 'CCR', ccr), patch.object(m.subprocess, 'run', side_effect=AssertionError('must remain offline')): + with self.assertRaises(m.Refused) as caught: m.source(digest) + self.assertEqual(caught.exception.code, 2) + + @unittest.skipUnless(shutil.which('bao'), 'installed bao CLI required') + def test_installed_cli_missing_create_readback_and_idempotent_retry(self): + objects, writes = {}, [] + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + value = objects.get(self.path) + self.send_response(200 if value else 404) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({'data': value} if value else {'errors': []}).encode()) + def do_PUT(self): + value = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + writes.append(self.path) + objects[self.path] = value if '/sys/' in self.path else role() + self.send_response(204) + self.end_headers() + do_POST = do_PUT + def log_message(self, *args): pass + server = http.server.HTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, { + 'PATH': os.environ['PATH'], 'HOME': tmp, + 'BAO_ADDR': f'http://127.0.0.1:{server.server_port}', + 'BAO_TOKEN': 'synthetic-test-only', + }, clear=True): + m.apply('exact policy', {'audience': 'openbao'}) + m.apply('exact policy', {'audience': 'openbao'}) + self.assertEqual(writes, ['/v1/sys/policies/acl/' + m.POLICY, '/v1/' + m.ROLE]) + finally: + server.shutdown() + server.server_close() + thread.join() + + def test_failed_step_receipt_contains_only_fixed_metadata(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(m, 'ROOT', Path(tmp)), \ + patch.object(m.sys, 'argv', ['child', '--expected-ccr-sha256', 'fixture']), \ + patch.object(m.Path, 'home', return_value=Path('/tmp/.warden-attended-login/fixture')), \ + patch.dict(os.environ, {}, clear=True), \ + patch.object(m, 'source', return_value=('policy', {})), \ + patch.object(m, 'cli', side_effect=m.Refused(4)): + output = io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + self.assertEqual(m.main(), 4) + self.assertEqual(output.getvalue(), '') + receipt = json.loads(next((Path(tmp)/'docs/evidence/requester-metadata').glob('*.json')).read_text()) + self.assertEqual(receipt['phase'], 'identity') + self.assertEqual(receipt['exit_code'], 4) + self.assertEqual(set(receipt), {'schema', 'ccr', 'phase', 'exit_code', 'native_delivery_verified', 'at'}) def test_missing_arguments_silent(self): - with patch.object(m.sys, 'argv', ['metadata-child']): + with tempfile.TemporaryDirectory() as tmp, patch.object(m, 'ROOT', Path(tmp)), patch.object(m.sys, 'argv', ['metadata-child']): output = io.StringIO() with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): self.assertEqual(m.main(), 2) self.assertEqual(output.getvalue(), '') + receipt = json.loads(next((Path(tmp)/'docs/evidence/requester-metadata').glob('*.json')).read_text()) + self.assertEqual(receipt['phase'], 'arguments') + self.assertEqual(receipt['exit_code'], 2) if __name__ == '__main__': diff --git a/workplans/RPF-WP-0051-informed-decision-requester-workload-delivery.md b/workplans/RPF-WP-0051-informed-decision-requester-workload-delivery.md index 4e872a8..9e3c25e 100644 --- a/workplans/RPF-WP-0051-informed-decision-requester-workload-delivery.md +++ b/workplans/RPF-WP-0051-informed-decision-requester-workload-delivery.md @@ -122,3 +122,19 @@ exact ESO identity, audience openbao, 900-second limits and read-only projection The metadata child still requires the founder terminal OIDC/MFA. Native claims, delivery, negative proofs and revocation remain unproven under T03. No broader cutover or issuer rotation approval is inferred. + +## Attended child failure and bounded repair — 2026-10-04 + +Founder reported Warden exit 11; the local audit confirms child_failed. This +does not prove that metadata stayed unchanged or that revocation succeeded. +The installed bao binary against a synthetic HTTP 404 reproduces a helper bug: +it emits exact path-specific No value found, not Code: 404. Updated handling +accepts only the exact read diagnostic with exit 2 and empty stdout; denials, +wrong paths and other failures refuse. Real installed-CLI tests cover missing +objects, both writes/readbacks and an idempotent repeat without another write. + +Each child now retains only fixed phase/exit metadata in +docs/evidence/requester-metadata/*.json and remains silent. Twelve focused tests +pass. The approved CCR and permissions are unchanged. A fresh attended attempt +will compare both objects before any missing-only write and record its phase. +Native delivery remains pending; do not mark the earlier attempt successful.