diff --git a/sso-mfa/k8s/keycape/test_admit_1_2_adapter_descriptors.py b/sso-mfa/k8s/keycape/test_admit_1_2_adapter_descriptors.py new file mode 100644 index 0000000..a3d5d63 --- /dev/null +++ b/sso-mfa/k8s/keycape/test_admit_1_2_adapter_descriptors.py @@ -0,0 +1,98 @@ +"""Kernel admission of every 1.2 descriptor with fixture-only trust (NK48-R1-F1). + +Descriptor.decode alone missed that the kernel refuses an agent mint. Each of the +seven composed descriptors goes through Warden's own Executor.admit here, with the +real 1.2 type, the 1.2 forgejo example case and the reviewed browser validator. +Adapters are stand-ins carrying only the pinned reference and source; nothing is +dispatched. Admission here is a fixture, not owner admission or a pin. +""" +from dataclasses import replace +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import yaml + +from warden.case_run import Adapter, Executor, Journal, digest +from warden.case_receipt_state import Refused +import warden.case_inputs as inputs + +ROOT = Path(__file__).parent +REPO = ROOT.parents[2] +spec = importlib.util.spec_from_file_location('compose_12_admit', ROOT / 'compose-1.2-descriptors.py') +c = importlib.util.module_from_spec(spec) +spec.loader.exec_module(c) + +TYPE_DIR = REPO / 'security-case-type-revisions/1.2/oidc-browser-relying-party' +ARTIFACT = json.loads((ROOT / 'descriptor-composition-1.2.candidate.json').read_text()) +EVIDENCE = {'fixture-acceptance', 'fixture-materialization', 'fixture-descriptor', 'fixture-session'} + + +def unused(*_): + raise AssertionError('admission must not dispatch') + + +class AdmitDescriptorSetTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + case = yaml.safe_load((TYPE_DIR / 'examples/forgejo.yaml').read_bytes()) + case['spec'].update(status='materialized', acceptance={ + 'at': '2026-10-17', 'record': 'fixture-only acceptance, not an owner record', + 'by': [{'name': 'fixture', 'role': 'owner'}, {'name': 'fixture', 'role': 'founder'}]}) + self.case_source = yaml.safe_dump(case).encode() + self.descriptors = dict(c.adapter_descriptors(ARTIFACT['check_config_digest'], + ARTIFACT['params']['consumer_owner_repo'])) + + def admit(self, desc, *, actor=None, lane=None): + desc_bytes = c.encode(desc) + typ = yaml.safe_load((TYPE_DIR / 'type.yaml').read_bytes()) + typ['spec']['execution_descriptors'] = {desc.step: digest(desc_bytes)} + adapter = Adapter(desc.adapter, desc.adapter_source, unused, unused) + executor = Executor(Journal(Path(self.temp.name) / desc.step), + {adapter.reference: adapter}, + lambda purpose, source, evidence: evidence in EVIDENCE, + browser_inputs=inputs.BrowserInputs(REPO)) + with mock.patch.dict(os.environ): + os.environ.pop('WARDEN_AGENT_ID', None) + return executor.admit(self.case_source, yaml.safe_dump(typ).encode(), desc_bytes, + b'fixture-only-catalog', principal='founder', actor=actor or desc.actor, + lane=lane or desc.lane, acceptance_evidence='fixture-acceptance', + materialization_evidence='fixture-materialization', + descriptor_evidence='fixture-descriptor', session_evidence='fixture-session') + + def test_composed_descriptors_are_the_committed_artifact(self): + self.assertEqual({s: digest(c.encode(d)) for s, d in self.descriptors.items()}, + ARTIFACT['execution_descriptors']) + + def test_every_1_2_descriptor_passes_kernel_admission(self): + self.assertEqual(len(self.descriptors), 7) + for step, desc in self.descriptors.items(): + with self.subTest(step=step): + admitted_desc, invocation, _ = self.admit(desc) + self.assertEqual(admitted_desc, desc) + self.assertEqual((invocation.step, invocation.owner, invocation.lane, + invocation.actor, invocation.target), + (desc.step, desc.owner, desc.lane, desc.actor, 'forgejo')) + + def test_mint_secret_admits_only_a_human_actor(self): + mint = self.descriptors['mint-secret'] + self.assertEqual((mint.effect, mint.actor), ('mint', 'human')) + with self.assertRaisesRegex(Refused, 'agent mint is forbidden'): + self.admit(replace(mint, actor='agent')) + with self.assertRaisesRegex(Refused, 'principal actor or lane mismatch'): + self.admit(mint, actor='agent') + + def test_wrong_lane_is_refused(self): + for step, desc in self.descriptors.items(): + with self.subTest(step=step), self.assertRaisesRegex( + Refused, 'principal actor or lane mismatch'): + self.admit(desc, lane='wrong-lane') + + +if __name__ == '__main__': + unittest.main() diff --git a/workplans/NK-WP-0048-security-case-types.md b/workplans/NK-WP-0048-security-case-types.md index 85ca12d..087d4bd 100644 --- a/workplans/NK-WP-0048-security-case-types.md +++ b/workplans/NK-WP-0048-security-case-types.md @@ -1397,8 +1397,16 @@ finding NK48-R1-F1) showed that unchanged kernel a45cf47 refuses every forbidden"), so the `agent` descriptor could never be admitted. Adapter, tests and composer comment updated and the artifact regenerated (mint-secret descriptor digest now `sha256:8c40c2884f8ba15e8cb6c98f33419c395e54fce8ef33d6e1a34782cf3f1fcfa9`); -98 tests pass against a45cf47. Open: no test yet runs `Executor.admit` on the -1.2 descriptors. The set needs an independent r2 review. +98 tests pass against a45cf47. The set needs an independent r2 review. + +2026-10-17 `test_admit_1_2_adapter_descriptors.py` runs Warden's own +`Executor.admit` on all seven composed 1.2 descriptors, with the real 1.2 +type, the 1.2 forgejo example case, the reviewed `BrowserInputs` validator and +fixture-only trust (stand-in adapters, nothing dispatched). It also pins that +an agent mint descriptor is refused ("agent mint is forbidden") and that a +wrong lane is refused for every step. Against the old `agent` revision +(582ab9f) it fails as expected. Combined suite: 102 passed on ops-warden a45cf47 +and on current ops-warden. Fixture admission is not owner admission or a pin. 2026-10-17 founder decision: the mint-secret actor is `agent`, not `warden`. The kernel is unchanged. The adapter, its test and the composer were updated and