Handle actual Bao absence response and retain safe attempt phases
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
parent
a7bc33b867
commit
f0c9a15235
4 changed files with 133 additions and 11 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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__':
|
||||
|
|
|
|||
|
|
@ -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__':
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue