diff --git a/docs/evidence/2026-10-13-task-execution-source-correction.json b/docs/evidence/2026-10-13-task-execution-source-correction.json new file mode 100644 index 0000000..e30ec81 --- /dev/null +++ b/docs/evidence/2026-10-13-task-execution-source-correction.json @@ -0,0 +1,16 @@ +{ + "schema": "platform-source-correction/v1", + "observed_at": "2026-10-13T18:53:24.250702+00:00", + "task": "RPF-WP-0035-T12", + "source_sha256": { + "scripts/task_execution_plan.py": "f81bd36523c42ce880111f774409cb7c554c6ffc06c2686a1864e6e6cd70111a", + "tests/test_task_execution_plan.py": "14c587c05a612955f6fc972ad3967a051b36fb127aecc246a28168331a034468", + "docs/task-execution-plan-compiler.md": "cf28c8e1ab702a66d5608f56566f184afd8d41b2693a38034e001de46f023b94" + }, + "initial_regression": "cached catalogue validator replacement caused proposal refusal despite unchanged pinned source; regression failed before correction", + "test_command": "PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/home/worsch/secrets-engine/src python3 -m pytest -q tests/test_task_execution_plan.py tests/test_task_execution_journal.py", + "compiler_tests_passed": 45, + "journal_tests_passed": 15, + "native_execution_authorized": false, + "independent_integration_review": "pending" +} diff --git a/docs/task-execution-plan-compiler.md b/docs/task-execution-plan-compiler.md index e4c9d7e..32c440c 100644 --- a/docs/task-execution-plan-compiler.md +++ b/docs/task-execution-plan-compiler.md @@ -25,7 +25,13 @@ No other lane module, mount enable/config, KV read/write, token/SecretID deliver AppRole mutation, caller HCL/JSON body, paths or command argv can be compiled. The catalogue comes from a separately owner-pinned local Secrets catalogue file, -loaded by the actual installed/pinned `secrets_engine.catalog.validate_entry`. +loaded by `secrets_engine.catalog.validate_entry` executed directly from the +verified source snapshot, together with its pinned package/errors/redactor. +Each compilation uses temporary private module names and removes them on success +or failure. Canonical application imports remain untouched. Cached modules and +timestamp-based bytecode cannot substitute different code for the verified bytes; +new Secrets Engine imports outside the closed source set refuse. This is source +consistency within the admitted Python runtime, not a Python security sandbox. Its catalogue id, existing mount, logical path, fields, production stage and approval CCR reference must match the owner CCR. Engine-managed mounts and auth are refused: existing delivery-auth metadata can be carried, but is never applied @@ -91,8 +97,14 @@ Token custody/revoke, temporary object removal, owner-private recovery after unk and truthful native readback remain gates. General lifecycle/apply/exec/default migration and SecretsT06/RPF35T02/T12 cannot close from this source component. -Validation:40 offline tests pass, covering real owner K8s/OIDC body generation, +Validation:45 compiler tests and 15 journal tests pass, covering real owner K8s/OIDC body generation, exact catalogue unicode digest interoperability, refusal of alternate action/kind/ path/body/ordinal, re-pinned root policy, nonapproved/unconfirmed/replayed CCR, engine-managed catalogue, forged immutable plan, source drift/duplicates and symlink/hardlink/writable source. No native action, credential/Secret access or owner checkout writes. + +October 13 correction: a regression first demonstrated that a cached validator +could replace the hashed source. Additional cases cover cached dependencies, +invalid catalogue acceptance through a substituted validator, and private-module +cleanup on both success and refusal. The October 11 v2 review remains evidence +for its original bytes only; it does not attest this corrected revision. diff --git a/scripts/task_execution_plan.py b/scripts/task_execution_plan.py index 9b33263..d8cb3e5 100644 --- a/scripts/task_execution_plan.py +++ b/scripts/task_execution_plan.py @@ -5,15 +5,17 @@ Six fixed calls on two structure objects. Recompile immediately before each call Canonical PDP request digest is opaque: the authenticated mediator verifies it. """ from __future__ import annotations +import builtins import hashlib import json import os from pathlib import Path import re import stat +import sys import types from dataclasses import dataclass -from uuid import UUID +from uuid import UUID, uuid4 import yaml class Refused(Exception): pass @@ -123,6 +125,49 @@ class Compiler: finally: if fd is not None: os.close(fd) + def _catalogue_entry(self, snapshot): + # Execute the verified bytes, not cached imports or timestamp-valid .pyc + # files. Private names support dataclass annotation lookup without replacing + # the application's secrets_engine modules, even during concurrent compiles. + prefix = '_platform_catalogue_' + uuid4().hex + modules = {} + registered = [] + + def snapshot_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == 'secrets_engine' or name.startswith('secrets_engine.'): + need(level == 0 and name in modules, 'unpinned_catalogue_import') + module = modules[name] + if fromlist: + need(all(hasattr(module, item) for item in fromlist), 'unpinned_catalogue_import') + return module + return modules['secrets_engine'] + need(level == 0, 'unpinned_catalogue_import') + return builtins.__import__(name, globals, locals, fromlist, level) + + try: + sources = [('secrets_engine', self.dependencies[0]), + ('secrets_engine.errors', self.dependencies[1]), + ('secrets_engine.redact', self.dependencies[2]), + ('secrets_engine.catalog', self.catalog_code)] + for name, path in sources: + private_name = prefix + name[len('secrets_engine'):] + module = types.ModuleType(private_name) + module.__file__ = str(path) + module.__package__ = prefix + module.__dict__['__builtins__'] = dict(vars(builtins), __import__=snapshot_import) + modules[name] = module + sys.modules[private_name] = module + registered.append(private_name) + if name != 'secrets_engine': + setattr(modules['secrets_engine'], name.rsplit('.', 1)[1], module) + exec(compile(snapshot[str(path)], str(path), 'exec'), module.__dict__) + return modules['secrets_engine.catalog'].validate_entry( + yaml.load(snapshot[str(self.catalog)], Loader=UniqueLoader), + source='') + finally: + for name in registered: + sys.modules.pop(name, None) + def _derive(self): # No native operations, CLI applier, subprocess, network or ambient credentials. snapshot={p:self.read(Path(p)) for p in self.pins} @@ -147,13 +192,7 @@ class Compiler: generated=owner.generated_policy_hcl(ccr) need(owner.normalized_policy_body(snapshot[str(policy)].decode())==owner.normalized_policy_body(generated),'owner_policy_not_generated') # Use the actual consumer catalog validator/defaults, not an alternative broker catalog. - import secrets_engine.catalog as catalogue - import secrets_engine.errors as errors_module - import secrets_engine.redact as redact_module - import secrets_engine - for module,p in [(catalogue,self.catalog_code),(secrets_engine,self.dependencies[0]),(errors_module,self.dependencies[1]),(redact_module,self.dependencies[2])]: - need(Path(module.__file__).resolve()==p,'catalogue_runtime_source_mismatch') - entry=catalogue.validate_entry(yaml.load(snapshot[str(self.catalog)],Loader=UniqueLoader),source='') + entry=self._catalogue_entry(snapshot) need(entry.id==ccr['access_frontdoor']['catalog_id'] and entry.stage=='prod' and ccr['target']['environment']=='production' and entry.kind=='kv' and entry.mount_management=='existing' diff --git a/tests/test_task_execution_plan.py b/tests/test_task_execution_plan.py index ca43408..95e4e62 100644 --- a/tests/test_task_execution_plan.py +++ b/tests/test_task_execution_plan.py @@ -144,3 +144,52 @@ def test_oidc_role_body_reuses_owner_auth_payload_and_existing_mount(compiler): assert call.path=='auth/netkingdom/role/synthetic-operator-workload-kv-read' assert role['role_type']=='oidc' and role['policies']=='workload-kv-read-issue-core-runtime' assert role['bound_claims']==source['openbao']['auth']['bound_claims'] + + +def test_cached_catalogue_code_cannot_replace_verified_source(compiler, monkeypatch): + import secrets_engine.catalog as catalogue + expected = compiler.proposal() + + def stale_validator(*args, **kwargs): + raise AssertionError('cached code differs from the pinned source') + + monkeypatch.setattr(catalogue, 'validate_entry', stale_validator) + assert compiler.proposal() == expected + + +def test_cached_catalogue_dependency_cannot_replace_verified_source(compiler, monkeypatch): + import secrets_engine.catalog as catalogue + expected = compiler.proposal() + + def stale_redactor(*args, **kwargs): + raise AssertionError('cached dependency differs from the pinned source') + + monkeypatch.setattr(catalogue, 'looks_secret', stale_redactor) + assert compiler.proposal() == expected + + +def test_cached_validator_cannot_admit_invalid_pinned_catalogue(compiler, monkeypatch): + import secrets_engine.catalog as catalogue + accepted = catalogue.validate_entry(yaml.safe_load(compiler.catalog.read_text()), source='fixture') + data = yaml.safe_load(compiler.catalog.read_text()) + del data['rotation'] + compiler.catalog.write_text(yaml.safe_dump(data)) + compiler = repin(compiler, compiler.catalog) + monkeypatch.setattr(catalogue, 'validate_entry', lambda *a, **kw: accepted) + with pytest.raises(m.Refused): + compiler.proposal() + + +@pytest.mark.parametrize('invalid', [False, True]) +def test_snapshot_modules_removed_without_replacing_ambient_modules(compiler, invalid): + import secrets_engine.catalog + before = {k: v for k, v in sys.modules.items() if k.startswith('secrets_engine')} + if invalid: + compiler.catalog.write_text('id: incomplete\n') + compiler = repin(compiler, compiler.catalog) + with pytest.raises(m.Refused): + compiler.proposal() + else: + compiler.proposal() + assert {k: v for k, v in sys.modules.items() if k.startswith('secrets_engine')} == before + assert not any(k.startswith('_platform_catalogue_') for k in sys.modules) diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md index bac4676..ebd890f 100644 --- a/workplans/RPF-WP-0035-credential-lane-implementation.md +++ b/workplans/RPF-WP-0035-credential-lane-implementation.md @@ -8,7 +8,7 @@ status: blocked flavor: implementation owner: codex created: "2026-09-05" -updated: "2026-10-11" +updated: "2026-10-13" related: - RPF-WP-0032 - RPF-WP-0033 @@ -1213,3 +1213,15 @@ owner-private unknown recovery remain unimplemented integration/native gates. Actual CCR42 has no eligible approved pilot/catalogue; no source metadata is invented. T02 and original negative/capability/native evidence remain unchanged; T11/T13 history retained, no defaultflip/nativego or SecretsT06 closure follows. + + +2026-10-13 T12 source correction: the compiler hashed the catalogue sources but +used cached Python modules with matching paths, which did not establish executing +those exact bytes. A failing regression reproduced the substitution. Catalogue, +package and dependencies now execute from the verified snapshot in temporary +private modules; ambient modules remain untouched and cleanup runs on refusal. +45 compiler plus 15 journal tests pass. The v2 source review remains historical; +this change needs its own integration review. T12 retains all existing full +contract, identity/consume, dispatch, custody and native acceptance requirements. +RPF-IN-0012 remains open for the independent contract/wrapped-delivery return; +this code correction does not authorize either operation.