security: bind Playwright activation and recovery to exact issuer state
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
tegwick 2026-10-13 23:39:58 +02:00
parent 5dd74dfbdf
commit eba6e1fdbb
4 changed files with 536 additions and 1 deletions

View file

@ -97,6 +97,73 @@ through the transport owner. No immediate global revocation is claimed.
## Verification and completion
### Guarded activation patch preparation
`sso-mfa/k8s/keycape/playwright-activation.py` now implements the private proposal,
ordered patch preparation and readback checks for the existing protected owner
path. It has no CLI or Kubernetes connection. `build` takes original Secret and
Deployment API objects, the complete identity-reference inventory and the exact
reviewed source revision. Its result contains the private original registry and
must remain inside that execution path. Only `summary` is a public receipt.
Source revision and digest bindings do not constitute an approval or custody grant.
The adapter admits one replica, `Recreate`, the pinned immutable KeyCape image,
the expected registry mount and explicit environment bindings. It preserves
unrelated settings and refuses differing bindings. `check_registry` pins the
native verifier's binary digest, checks both configurations using their exact
environment-variable names and policy-store mode, passes private input on stdin,
clears ambient environment overrides and contains diagnostics. The operator must
separately establish verifier compatibility with the deployed image; a locally
built verifier is not production artifact admission.
The source deployment cannot atomically update its registry and environment:
an environment edit starts a rollout, and pending authorization codes are
process-local. The prepared sequence therefore requires an explicit exclusive
maintenance window with a planned authentication interruption:
1. `stop`: bind the original object UIDs, resource versions, data and complete
Deployment spec; require the original single replica Ready; scale to zero.
2. Wait for the controller to observe that generation with zero replicas and
for **every** pod consuming `keycape-config` to disappear. Supply an unfiltered,
complete `sso` PodList; terminating pods still count. Direct/projected volumes,
env references and init/ephemeral containers are checked.
3. `registry`: conditionally write the exact proposed registry data while stopped.
4. `bindings`: conditionally add the agent environment bindings while still stopped.
5. `resume`: after fresh matching registry/spec and quiescence checks, restore one
replica. Then the protected executor must verify generation, exact pod/image,
readiness, real issuance and existing human/service login. Scaling is not that
acceptance proof.
Fresh reads of **both** objects precede every `propose_step`. Each patch tests
the target UID, resourceVersion and complete data/spec before replacement. Use
`verify_response` on the API response and subsequent fresh GET, retaining each
sanitized receipt. Refuse and stop on any mismatch or unknown outcome; no automatic
retry or stale whole-registry replay. Kubernetes offers no cross-object transaction,
so concurrent issuer writers must be excluded by the owner maintenance procedure.
Rollback uses the retained original proposal, never a newly inferred baseline:
`rollback-stop`, observed quiescence, `rollback-registry`, `rollback-bindings`,
`rollback-resume`, then the same native readiness/login verification. Exact
original/candidate matching preserves registrations and bindings that predated
this proposal. Any unrelated intervening data/spec change refuses rollback for
owner reconciliation. No completed changes means no rollback outage. Retain
private recovery state after failure; the adapter neither writes it to disk nor
destroys credentials. Token expiry and connection-termination limits above remain.
Before wiring this into live execution, T02 still requires real identity/custody
references, the protected executor and recovery-state custody, cluster/current
image pinning, the exact supervisor disposition, and source/GitOps reconciliation.
Do not invoke the generic browser helper with a fabricated `--case-acceptance`.
Activation tests use the independent `jsonpatch` Python package to apply RFC 6902
patches to synthetic API objects (test prerequisites: PyYAML and jsonpatch).
They check ordered/partial recovery, created-only preservation, stale writes,
quiescence, revision/readback refusal and verifier containment. Controller state
is simulated; neither these tests nor native `check-config` replace live API and
issuer acceptance.
### Registration and consumer checks
Python tests exercise preservation, add/no-op/remove, lifetime bounds, identity
conflicts, distinct credentials and contained invalid-input output. The native
Go fixture `tests/playwright_registration_native_test.go` is copied into a

View file

@ -0,0 +1,230 @@
"""Private, digest-bound Playwright registry/Deployment patch preparation.
No CLI or Kubernetes client: the existing protected owner execution path must
provide fresh objects, an independently approved proposal digest, admitted
custody, exclusive maintenance, and readiness/login verification. Proposal and
patch bodies contain private registry data; never log or serialize them to chat.
Only summary() is an output receipt. This library grants no execution authority.
"""
from __future__ import annotations
import base64
import copy
import hashlib
import importlib.util
import json
from pathlib import Path
import re
import subprocess
spec = importlib.util.spec_from_file_location('playwright_registration', Path(__file__).with_name('playwright-registration.py'))
registration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(registration)
require = registration.require
Refused = registration.Refused
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'),
allow_nan=False).encode()).hexdigest()
def target(obj, kind, name):
require(obj.get('kind') == kind and obj.get('metadata', {}).get('name') == name
and obj['metadata'].get('namespace') == 'sso', 'unexpected_target')
require(not obj['metadata'].get('deletionTimestamp') and
not obj['metadata'].get('ownerReferences'), 'target_not_independently_available')
return registration.registry.pin.safe_metadata(obj)
def container(specification):
items = specification['template']['spec']['containers']
matches = [item for item in items if item.get('name') == 'keycape']
require(len(matches) == 1, 'keycape_container_required')
return matches[0]
def stopped(specification):
result = copy.deepcopy(specification)
result['replicas'] = 0
return result
def build(secret, deployment, identities, source_revision):
secret_meta = target(secret, 'Secret', 'keycape-config')
deploy_meta = target(deployment, 'Deployment', 'keycape')
require(not secret.get('immutable'), 'mutable_registry_required')
require(re.fullmatch(r'[0-9a-f]{40}', source_revision or '') is not None, 'exact_source_revision_required')
original = deployment['spec']
require(original.get('replicas') == 1 and original.get('strategy', {}).get('type') == 'Recreate'
and not original.get('paused'), 'single_recreate_issuer_required')
current = container(original)
require(re.fullmatch(r'forgejo\.coulomb\.social/coulomb/key-cape@sha256:[0-9a-f]{64}',
current.get('image', '')) is not None, 'immutable_owner_image_required')
require(not current.get('envFrom'), 'explicit_env_bindings_required')
env = current.get('env', [])
require(isinstance(env, list) and all(isinstance(e, dict) and isinstance(e.get('name'), str) for e in env),
'env_shape_invalid')
names = [e['name'] for e in env]
require(len(names) == len(set(names)), 'duplicate_env_name')
require([e for e in env if e['name'] == 'KEYCAPE_CONFIG'] ==
[{'name': 'KEYCAPE_CONFIG', 'value': '/etc/keycape/config.yaml'}], 'config_path_drift')
volumes = original['template']['spec'].get('volumes', [])
mounts = current.get('volumeMounts', [])
expected = [v['name'] for v in volumes if v.get('secret', {}).get('secretName') == 'keycape-config']
require(len(expected) == 1 and any(m.get('name') == expected[0] and
m.get('mountPath') == '/etc/keycape' and m.get('readOnly') is True and
not m.get('subPath') and not m.get('subPathExpr') for m in mounts), 'registry_mount_drift')
encoded, created = registration.prepare_registry(secret, identities)
proposed_data = dict(secret['data'], **{'config.yaml': encoded})
proposed_spec = copy.deepcopy(original)
proposed_env = container(proposed_spec).setdefault('env', [])
added_env = []
records = registration.clients(identities)
for record in records[1:]:
binding = registration.registry.env_binding(record)
existing = [e for e in proposed_env if e['name'] == binding['name']]
require(not existing or existing == [binding], 'existing_binding_requires_reconciliation')
if not existing:
proposed_env.append(binding)
added_env.append(binding['name'])
return {'schema': 'netkingdom.playwright-activation.v1', 'source_revision': source_revision,
'secret_identity': secret_meta, 'deployment_identity': deploy_meta,
'before_data': copy.deepcopy(secret['data']), 'after_data': proposed_data,
'before_spec': copy.deepcopy(original), 'after_spec': proposed_spec,
'identity_inventory': copy.deepcopy(identities), 'created_client_ids': created,
'created_env_names': added_env}
def summary(proposal):
changed = proposal['before_data'] != proposal['after_data'] or proposal['before_spec'] != proposal['after_spec']
return {'schema': proposal['schema'], 'proposal_digest': digest(proposal),
'source_revision': proposal['source_revision'],
'created_client_ids': proposal['created_client_ids'],
'created_env_names': proposal['created_env_names'],
'change_needed': changed, 'maintenance_required': changed,
'live_activated': False, 'custody_admitted': False}
def check_registry(proposal, binary, expected_binary_sha256):
"""Run the exact admitted native verifier, no ambient env or raw diagnostics."""
path = Path(binary)
require(path.is_file() and not path.is_symlink() and
hashlib.sha256(path.read_bytes()).hexdigest() == expected_binary_sha256,
'native_verifier_digest_mismatch')
for stage in ('before', 'after'):
env_names = [e['name'] for e in container(proposal[stage + '_spec']).get('env', [])]
command = [str(path), 'check-config', '--config', '/dev/stdin', '--env-names',
','.join(env_names) or '__NO_ENV_NAMES__']
if 'KEYCAPE_POLICY_PATH' in env_names:
command.append('--policy-store')
try:
result = subprocess.run(command, input=base64.b64decode(proposal[stage + '_data']['config.yaml'], validate=True),
capture_output=True, timeout=60, env={'PATH': '/usr/bin:/bin'})
report = json.loads(result.stdout)
valid = result.returncode == 0 and report.get('valid') is True and not report.get('errors')
except Exception:
valid = False
require(valid, 'native_registry_check_failed')
return {'original_registry_valid': True, 'proposed_registry_valid': True,
'verifier_sha256': expected_binary_sha256}
def quiescent(deployment, pods):
"""Caller supplies an unfiltered complete sso PodList from the same cluster."""
status = deployment.get('status', {})
require(status.get('observedGeneration') == deployment['metadata'].get('generation')
and deployment['spec'].get('replicas') == 0 and
all(status.get(k, 0) == 0 for k in ['replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas']),
'issuer_not_stopped')
require(pods.get('kind') == 'PodList' and not pods.get('metadata', {}).get('continue')
and isinstance(pods.get('items'), list), 'complete_namespace_pod_list_required')
# Terminating pods are still consumers. Do not infer quiescence from readiness.
for pod in pods['items']:
require(pod.get('metadata', {}).get('namespace') == 'sso', 'pod_inventory_namespace_mismatch')
pod_spec = pod.get('spec', {})
uses_registry = any(v.get('secret', {}).get('secretName') == 'keycape-config' or
any(s.get('secret', {}).get('name') == 'keycape-config'
for s in v.get('projected', {}).get('sources', []))
for v in pod_spec.get('volumes', []))
for c in pod_spec.get('containers', []) + pod_spec.get('initContainers', []) + pod_spec.get('ephemeralContainers', []):
uses_registry = uses_registry or any(e.get('secretRef', {}).get('name') == 'keycape-config'
for e in c.get('envFrom', [])) or any(
e.get('valueFrom', {}).get('secretKeyRef', {}).get('name') == 'keycape-config'
for e in c.get('env', []))
require(not uses_registry, 'registry_consumer_still_present')
def propose_step(proposal, approved_digest, phase, secret, deployment, pods):
"""Return one private CAS patch or None for an exact no-op; never execute it.
The caller must freshly read BOTH objects before EACH phase, verify every
response, and stop on unknown outcomes. The digest binds content, not authority.
Kubernetes has no cross-object transaction; exclusive maintenance is required.
"""
require(digest(proposal) == approved_digest, 'proposal_revision_changed')
secret_meta = target(secret, 'Secret', 'keycape-config')
deploy_meta = target(deployment, 'Deployment', 'keycape')
require(secret_meta['uid'] == proposal['secret_identity']['uid'] and
deploy_meta['uid'] == proposal['deployment_identity']['uid'], 'target_identity_changed')
old_data, new_data = proposal['before_data'], proposal['after_data']
old_spec, new_spec = proposal['before_spec'], proposal['after_spec']
data, current = secret['data'], deployment['spec']
require(data == old_data or data == new_data, 'registry_drift_stop_without_stale_replay')
require(any(current == item for item in [old_spec, new_spec, stopped(old_spec), stopped(new_spec)]),
'deployment_drift_stop_without_stale_replay')
require(phase in ('stop', 'registry', 'bindings', 'resume', 'rollback-stop',
'rollback-registry', 'rollback-bindings', 'rollback-resume'), 'phase_invalid')
if phase.startswith('rollback-') and data == old_data and current == old_spec:
return None
if old_data == new_data and old_spec == new_spec:
require(data == old_data and current == old_spec, 'noop_state_changed')
return None
if phase == 'stop':
require(secret_meta == proposal['secret_identity'] and deploy_meta == proposal['deployment_identity']
and data == old_data and current == old_spec, 'initial_revision_changed')
status = deployment.get('status', {})
require(status.get('observedGeneration') == deployment['metadata'].get('generation') and
all(status.get(k) == 1 for k in ['replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas']),
'initial_issuer_not_ready')
obj, field, wanted = deployment, 'spec', stopped(old_spec)
elif phase == 'rollback-stop':
obj, field, wanted = deployment, 'spec', stopped(current)
else:
quiescent(deployment, pods)
if phase == 'registry':
require(current == stopped(old_spec) and data == old_data, 'registry_phase_out_of_order')
obj, field, wanted = secret, 'data', new_data
elif phase == 'bindings':
require(current == stopped(old_spec) and data == new_data, 'bindings_phase_out_of_order')
obj, field, wanted = deployment, 'spec', stopped(new_spec)
elif phase == 'resume':
require(current == stopped(new_spec) and data == new_data, 'resume_phase_out_of_order')
obj, field, wanted = deployment, 'spec', new_spec
elif phase == 'rollback-registry':
obj, field, wanted = secret, 'data', old_data
elif phase == 'rollback-bindings':
require(data == old_data, 'restore_registry_before_bindings')
obj, field, wanted = deployment, 'spec', stopped(old_spec)
else:
require(data == old_data and current == stopped(old_spec), 'rollback_resume_out_of_order')
obj, field, wanted = deployment, 'spec', old_spec
if obj[field] == wanted:
return None
metadata = registration.registry.pin.safe_metadata(obj)
patch = [{'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']},
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']},
{'op': 'test', 'path': '/' + field, 'value': copy.deepcopy(obj[field])},
{'op': 'replace', 'path': '/' + field, 'value': copy.deepcopy(wanted)}]
return {'kind': obj['kind'], 'namespace': 'sso', 'name': obj['metadata']['name'],
'uid': metadata['uid'], 'field': field, 'expected': copy.deepcopy(wanted),
'proposal_digest': approved_digest, 'phase': phase, 'patch': patch}
def verify_response(operation, obj):
"""Also use on a fresh GET after an acknowledged patch; failures stop the caller."""
metadata = target(obj, operation['kind'], operation['name'])
require(metadata['uid'] == operation['uid'] and obj.get(operation['field']) == operation['expected'],
'mutation_readback_mismatch')
return {'phase': operation['phase'], 'proposal_digest': operation['proposal_digest'],
'uid': metadata['uid'], 'resource_version': metadata['resource_version'], 'readback_matches': True}

View file

@ -0,0 +1,221 @@
import base64
import copy
import hashlib
import importlib.util
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import jsonpatch
ROOT = Path(__file__).parent
def load(name, filename):
spec = importlib.util.spec_from_file_location(name, ROOT / filename)
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
return module
a = load('activation', 'playwright-activation.py')
f = load('registration_fixture', 'test_playwright_registration.py')
REVISION = '5dd74dfbdf3fd38adb1bfc1bdc1326181435f0e9'
IDENTITIES = [f.IDENTITY, dict(f.IDENTITY, agent_id='second', service_subject='service:second')]
def fixtures():
secret, raw = f.fixture()
secret.update(kind='Secret', apiVersion='v1')
secret['metadata'].update(name='keycape-config', namespace='sso', uid='00000000-0000-4000-8000-000000000001')
deployment = {'apiVersion': 'apps/v1', 'kind': 'Deployment', 'metadata': {
'name': 'keycape', 'namespace': 'sso', 'uid': '00000000-0000-4000-8000-000000000002',
'resourceVersion': '12', 'generation': 5}, 'spec': {'replicas': 1, 'strategy': {'type': 'Recreate'},
'template': {'metadata': {'labels': {'app': 'keycape'}}, 'spec': {
'containers': [{'name': 'keycape', 'image': 'forgejo.coulomb.social/coulomb/key-cape@sha256:' + 'a' * 64,
'env': [{'name': 'KEYCAPE_CONFIG', 'value': '/etc/keycape/config.yaml'}],
'volumeMounts': [{'name': 'registry', 'mountPath': '/etc/keycape', 'readOnly': True}]}],
'volumes': [{'name': 'registry', 'secret': {'secretName': 'keycape-config'}}]}}}}
controller(deployment)
return secret, deployment
def controller(deployment):
# Simulates observed controller completion, separate from patch admission.
replicas = deployment['spec']['replicas']
deployment['status'] = {'observedGeneration': deployment['metadata']['generation'],
**{k: replicas for k in ['replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas']}}
class World:
def __init__(self, secret, deployment):
self.secret, self.deployment = copy.deepcopy(secret), copy.deepcopy(deployment)
self.pods = {'kind': 'PodList', 'metadata': {}, 'items': []}
self.history = []
def step(self, proposal, phase, observe=True):
operation = a.propose_step(proposal, a.digest(proposal), phase, self.secret, self.deployment, self.pods)
if operation is None: return None
attr = 'secret' if operation['kind'] == 'Secret' else 'deployment'
# Independent RFC 6902 implementation verifies actual generated CAS tests.
updated = jsonpatch.apply_patch(getattr(self, attr), operation['patch'], in_place=False)
updated['metadata']['resourceVersion'] = str(int(updated['metadata']['resourceVersion']) + 1)
if attr == 'deployment': updated['metadata']['generation'] += 1
self.history.append(phase)
setattr(self, attr, updated)
receipt = a.verify_response(operation, updated)
if attr == 'deployment' and observe: controller(updated)
return receipt
class ActivationTests(unittest.TestCase):
def setUp(self):
self.secret, self.deployment = fixtures()
self.proposal = a.build(self.secret, self.deployment, IDENTITIES, REVISION)
def test_exact_sequence_keeps_original_source_and_returns_safe_receipts(self):
world = World(self.secret, self.deployment)
original = copy.deepcopy((self.secret, self.deployment))
for phase in ['stop', 'registry', 'bindings', 'resume']:
receipt = world.step(self.proposal, phase)
self.assertTrue(receipt['readback_matches'])
self.assertNotIn('synthetic-private-marker', json.dumps(receipt))
self.assertEqual(world.deployment['spec']['replicas'], 1)
self.assertEqual(world.secret['data'], self.proposal['after_data'])
self.assertEqual((self.secret, self.deployment), original)
self.assertNotIn('synthetic-private-marker', json.dumps(a.summary(self.proposal)))
def test_rollback_preserves_preexisting_public_and_agent_registrations(self):
encoded, _ = a.registration.prepare_registry(self.secret, [IDENTITIES[0]])
self.secret['data']['config.yaml'] = encoded
binding = a.registration.registry.env_binding(a.registration.agent_client(IDENTITIES[0]))
a.container(self.deployment['spec'])['env'].append(binding)
proposal = a.build(self.secret, self.deployment, IDENTITIES, REVISION)
self.assertEqual(proposal['created_client_ids'], ['playwright-agent-second'])
world = World(self.secret, self.deployment)
for phase in ['stop', 'registry', 'bindings', 'resume', 'rollback-stop',
'rollback-registry', 'rollback-bindings', 'rollback-resume']:
world.step(proposal, phase)
self.assertEqual(world.secret['data'], self.secret['data'])
self.assertEqual(world.deployment['spec'], self.deployment['spec'])
def test_each_partial_state_recovers_without_removing_original_entries(self):
phases = ['stop', 'registry', 'bindings', 'resume']
for count in range(5):
world = World(self.secret, self.deployment)
for phase in phases[:count]: world.step(self.proposal, phase)
for phase in ['rollback-stop', 'rollback-registry', 'rollback-bindings', 'rollback-resume']:
world.step(self.proposal, phase)
self.assertEqual(world.secret['data'], self.secret['data'])
self.assertEqual(world.deployment['spec'], self.deployment['spec'])
def test_identical_complete_registration_is_noop_without_outage(self):
world = World(self.secret, self.deployment)
for phase in ['stop', 'registry', 'bindings', 'resume']: world.step(self.proposal, phase)
proposal = a.build(world.secret, world.deployment, IDENTITIES, REVISION)
self.assertFalse(a.summary(proposal)['change_needed'])
before = len(world.history)
for phase in ['stop', 'registry', 'bindings', 'resume']: self.assertIsNone(world.step(proposal, phase))
self.assertEqual(len(world.history), before)
def test_rollback_before_any_execution_never_stops_the_issuer(self):
world = World(self.secret, self.deployment)
for phase in ['rollback-stop', 'rollback-registry', 'rollback-bindings', 'rollback-resume']:
self.assertIsNone(world.step(self.proposal, phase))
self.assertEqual(world.history, [])
def test_reordered_phases_and_unobserved_controller_are_refused(self):
world = World(self.secret, self.deployment)
for phase in ['registry', 'bindings', 'resume']:
with self.subTest(phase=phase), self.assertRaises(a.Refused): world.step(self.proposal, phase)
world.step(self.proposal, 'stop', observe=False)
with self.assertRaises(a.Refused): world.step(self.proposal, 'registry')
controller(world.deployment)
with self.assertRaises(a.Refused): world.step(self.proposal, 'resume')
def test_terminating_direct_projected_and_env_consumers_prevent_registry_write(self):
specs = [{'volumes': [{'secret': {'secretName': 'keycape-config'}}]},
{'volumes': [{'projected': {'sources': [{'secret': {'name': 'keycape-config'}}]}}]},
{'containers': [{'envFrom': [{'secretRef': {'name': 'keycape-config'}}]}]},
{'initContainers': [{'env': [{'valueFrom': {'secretKeyRef': {'name': 'keycape-config'}}}]}]}]
for spec in specs:
world = World(self.secret, self.deployment); world.step(self.proposal, 'stop')
world.pods['items'] = [{'metadata': {'namespace': 'sso', 'deletionTimestamp': 'synthetic'}, 'spec': spec}]
with self.subTest(spec=spec), self.assertRaises(a.Refused): world.step(self.proposal, 'registry')
def test_partial_pod_inventory_refused(self):
world = World(self.secret, self.deployment); world.step(self.proposal, 'stop')
world.pods['metadata']['continue'] = 'another-page'
with self.assertRaises(a.Refused): world.step(self.proposal, 'registry')
def test_original_revision_and_proposal_digest_are_bound(self):
for attr in ['secret', 'deployment']:
world = World(self.secret, self.deployment)
getattr(world, attr)['metadata']['resourceVersion'] = '999'
with self.subTest(attr=attr), self.assertRaises(a.Refused): world.step(self.proposal, 'stop')
changed = copy.deepcopy(self.proposal); changed['after_data']['other'] = 'changed'
with self.assertRaises(a.Refused):
a.propose_step(changed, a.digest(self.proposal), 'stop', self.secret, self.deployment, {'kind': 'PodList', 'items': []})
def test_independent_jsonpatch_rejects_racing_version_or_spec(self):
operation = a.propose_step(self.proposal, a.digest(self.proposal), 'stop',
self.secret, self.deployment, {'kind': 'PodList', 'items': []})
for field in ['version', 'spec']:
raced = copy.deepcopy(self.deployment)
if field == 'version': raced['metadata']['resourceVersion'] = '999'
else: raced['spec']['replicas'] = 2
with self.subTest(field=field), self.assertRaises(jsonpatch.JsonPatchTestFailed):
jsonpatch.apply_patch(raced, operation['patch'])
def test_private_registry_patch_checks_complete_data_and_uid(self):
world = World(self.secret, self.deployment); world.step(self.proposal, 'stop')
operation = a.propose_step(self.proposal, a.digest(self.proposal), 'registry', world.secret, world.deployment, world.pods)
for field in ['data', 'uid']:
raced = copy.deepcopy(world.secret)
if field == 'data': raced['data']['other'] = 'racing-write'
else: raced['metadata']['uid'] = 'recreated'
with self.subTest(field=field), self.assertRaises(jsonpatch.JsonPatchTestFailed):
jsonpatch.apply_patch(raced, operation['patch'])
def test_unrelated_registry_or_deployment_drift_refuses_rollback(self):
for field in ['data', 'spec']:
world = World(self.secret, self.deployment)
for phase in ['stop', 'registry', 'bindings']: world.step(self.proposal, phase)
if field == 'data': world.secret['data']['unrelated-new-field'] = 'keep'
else: world.deployment['spec']['template']['metadata']['labels']['new'] = 'keep'
with self.subTest(field=field), self.assertRaises(a.Refused): world.step(self.proposal, 'rollback-registry')
def test_readback_mismatch_refused_without_raw_output(self):
operation = a.propose_step(self.proposal, a.digest(self.proposal), 'stop', self.secret, self.deployment, {})
with self.assertRaisesRegex(a.Refused, '^mutation_readback_mismatch$'):
a.verify_response(operation, self.deployment)
def test_unsupported_deployment_and_binding_drift_refused(self):
changes = [lambda d: d['spec'].update(replicas=2),
lambda d: d['spec']['strategy'].update(type='RollingUpdate'),
lambda d: a.container(d['spec']).update(image='unreviewed:latest'),
lambda d: a.container(d['spec']).update(envFrom=[{'secretRef': {'name': 'other'}}]),
lambda d: a.container(d['spec'])['env'].append({'name': 'KEYCAPE_CONFIG', 'value': 'other'}),
lambda d: a.container(d['spec'])['env'].append({'name': 'KEYCAPE_PLAYWRIGHT_AGENT_TEST_AGENT_CLIENT_SECRET', 'value': 'synthetic-private-marker'})]
for change in changes:
deployment = copy.deepcopy(self.deployment); change(deployment)
with self.subTest(change=change), self.assertRaises(a.Refused):
a.build(self.secret, deployment, IDENTITIES, REVISION)
def test_native_verifier_is_pinned_contained_and_ignores_ambient_environment(self):
with tempfile.TemporaryDirectory() as folder:
binary = Path(folder) / 'verifier'; binary.write_bytes(b'synthetic-binary')
sha = hashlib.sha256(binary.read_bytes()).hexdigest()
with patch.object(a.subprocess, 'run') as run:
run.return_value = subprocess.CompletedProcess([], 0, b'{"valid": true, "errors": []}', b'')
self.assertTrue(a.check_registry(self.proposal, binary, sha)['proposed_registry_valid'])
self.assertEqual(run.call_count, 2)
self.assertEqual(run.call_args.kwargs['env'], {'PATH': '/usr/bin:/bin'})
self.assertIn('synthetic-private-marker', run.call_args.kwargs['input'].decode())
self.assertNotIn('synthetic-private-marker', str(run.call_args.args))
run.return_value = subprocess.CompletedProcess([], 1, b'{"valid": false, "errors": ["synthetic-private-marker"]}', b'synthetic-private-marker')
with self.assertRaisesRegex(a.Refused, '^native_registry_check_failed$'):
a.check_registry(self.proposal, binary, sha)
with self.assertRaisesRegex(a.Refused, '^native_verifier_digest_mismatch$'):
a.check_registry(self.proposal, binary, 'wrong')
if __name__ == '__main__': unittest.main()

View file

@ -12,6 +12,7 @@ created: "2026-10-13"
updated: "2026-10-13"
depends_on: []
related: [CUST-WP-0080, RPF-WP-0056, RAPP-PLAYWRIGHT-WP-0004]
state_hub_workstream_id: "06ba98d5-609e-5aaa-9bf7-4c4f4460e377"
---
Accept NetKingdom's concrete implementation request
@ -29,6 +30,7 @@ admin identity, kubeconfig, SSH identity or existing OpenBao credential.
id: NK-WP-0050-T01
status: done
priority: high
state_hub_task_id: "64893d94-9717-5812-b8e3-7cabe095fcdc"
```
`docs/playwright-registration.md` owns the bounded registration contract.
@ -52,9 +54,10 @@ and local fixtures are not issuer-owner or real consumer acceptance.
```task
id: NK-WP-0050-T02
status: todo
status: progress
priority: high
depends_on: [NK-WP-0050-T01]
state_hub_task_id: "a9dadd07-f136-5dc7-9229-7a9dbb941f0d"
```
Resolve the exact real agent records and Platform custody/delivery source task;
@ -65,6 +68,19 @@ action before any needed privileged approval. Source work can proceed while
owner acceptance is collected; custody and live grants must not be invented.
Neither compiler output nor another client's acceptance authorizes live apply.
2026-10-13: private activation patch preparation implemented in
`playwright-activation.py`. Original/candidate registry and Deployment state bind
an exact proposal digest. Ordered stop/configure/resume avoids a registry/env
startup race in the single-Recreate issuer. Native verifier digest checks, fresh
object UID/resource-version/data/spec conditions, complete pod-quiescence checks,
readback and exact original-state rollback retain pre-existing registrations and
bindings. No mutation occurs from this module; the protected executor owns fresh
reads, admitted custody, actual approval and live acceptance. Source tests cover
partial recovery and independent RFC 6902 patch application; native KeyCape
check-config accepts both synthetic configurations. Real identity/custody owner
returns, executor/recovery-state integration, exact current image verification
and the supervised maintenance action remain open.
## Verify issuer and real consumer acceptance
```task
@ -73,6 +89,7 @@ status: wait
priority: high
depends_on: [NK-WP-0050-T02, RPF-WP-0056-T03]
blocking_reason: "Await owner-scoped registry/custody activation, native cluster authentication and individual transport, then real human and agent acceptance under CUST-WP-0080-T05."
state_hub_task_id: "5a3aaf57-de5c-5a03-bc99-9bcf2fc5d95e"
```
Retain real human/agent issuance and identity/tenant/audience/scope/expiry proof,