diff --git a/docs/security-case-execution-kernel-v1.md b/docs/security-case-execution-kernel-v1.md index 13bec32..b9b87d2 100644 --- a/docs/security-case-execution-kernel-v1.md +++ b/docs/security-case-execution-kernel-v1.md @@ -152,3 +152,41 @@ recovery can count. No actual original holder/verifier or native adapter is adde NetKingdom's current registration adapter still has no reconciliation callback; source tests do not authorize its post-restart undo, replay or a changed caller/ session/run-sheet migration. WARDEN-WP-0044-T03 remains a native acceptance gate. + +## Current caller versus original session reference — 2026-10-18 source + +`Executor.admit` now accepts the optional `original_session_reference` for an +exact existing original-run continuation. It is metadata, never current +authentication. The normal `session_evidence` is independently verified as the +current caller's principal/actor/lane proof, including after restart, and remains +the evidence rechecked immediately before every journal write/owner callback. + +The optional reference preserves the original run-sheet digest rather than +replacing immutable provenance with the new login. Before admitting it, under +the existing journal lock, the kernel requires an existing matching step journal +and `Journal.original_intent` correspondence: the original running receipt, +attempt, owner/target/principal/actor/lane, all six pins and exact adapter source. +Missing/orphan/legacy context, a changed reference or caller and a stale current +session refuse. The reference must differ from the current evidence identifier; +the actual session owner must additionally establish distinct authenticated +token/session identity, not just two labels. No new original context is created, +no completed operation can replay, and uncertainty is not cleared by readmission. + +Omitting this option retains the old admission/pin semantics. Supplying a fresh +session alone still creates a different run sheet and cannot reuse a previous +journal. No existing descriptor, Invocation, Pins or receipt schema changes. +The new source seam does not implement changed-source/catalog/caller migration +or loss of rollback result; those still require the existing owner reconciliation +and recovery evidence. It is not general session management or another issuer. + +A source integration fixture runs the actual producer-owned KeyCape status CLI +over HTTPS, the actual Warden kernel and a separately compiled generated Go +consumer. It verifies revoked-parent refusal before writes, distinct inverse +authentication in another Python process, original journal pin retention, +no business replay, exact synthetic target restore and independent read-only +negative cleanup observation. Case/type/catalog/owner attestations and the +target are synthetic; this is not Forgejo/browser/native acceptance or a real +approval. KeyCape's proposed online profile remains unadmitted. The private +unsigned CLI observation is not portable execution authority; actual owner +custody and original grant ownership are still mandatory before an inactive +response can establish revocation. No native verifier or adapter is installed. diff --git a/src/warden/case_run.py b/src/warden/case_run.py index 60692b8..7d84bfb 100644 --- a/src/warden/case_run.py +++ b/src/warden/case_run.py @@ -663,7 +663,20 @@ class Executor: materialization_evidence: str, descriptor_evidence: str, session_evidence: str, + original_session_reference: str | None = None, ) -> tuple[Descriptor, Invocation, StepState]: + # Current authentication is distinct from an immutable original receipt + # reference. The latter can only resume an exact existing journal intent; + # it is never passed to the current-session verifier as authority. + receipt_session = session_evidence + if original_session_reference is not None: + if ( + type(original_session_reference) is not str + or not IDENTIFIER.fullmatch(original_session_reference) + or original_session_reference == session_evidence + ): + raise Refused("distinct original session reference required") + receipt_session = original_session_reference for source in (case_source, type_source, descriptor_source, catalog_source): bounded_source(source) descriptor = Descriptor.decode(descriptor_source) @@ -781,7 +794,7 @@ class Executor: acceptance_evidence, materialization_evidence, descriptor_evidence, - session_evidence, + receipt_session, ], separators=(",", ":"), ).encode() @@ -803,6 +816,13 @@ class Executor: descriptor.allowed_created, descriptor.retry_safe, ) + if original_session_reference is not None: + with self.journal.locked(): + binding = f"{invocation.step}:{invocation.target}" + existing = self.journal.load(binding, initial) + if not existing.last: + raise Refused("original dispatch journal required; no backfill") + self.journal.original_intent(binding, invocation, adapter.source) # Keep the mode and exact authenticated catalog bytes with this admission; # dispatch must not substitute a later ambient catalog or lose its mode. self._admitted[invocation] = ( diff --git a/tests/test_case_current_session_reference.py b/tests/test_case_current_session_reference.py new file mode 100644 index 0000000..8a474a3 --- /dev/null +++ b/tests/test_case_current_session_reference.py @@ -0,0 +1,105 @@ +"""Current caller authentication never replaces historical dispatch provenance.""" +from dataclasses import replace + +import pytest + +from tests.test_case_run import make_rig +from warden.case_receipt_state import Outcome, Refused +from warden.case_run import Executor, Journal + + +def fresh_verifier(executor, *, allow=True): + original = executor.evidence_verifier + def verify(purpose, source, evidence): + if purpose.startswith("caller-session:") and evidence == "fresh-session": + return allow + return original(purpose, source, evidence) + executor.evidence_verifier = verify + return verify + + +def rows(executor): + return tuple(executor.journal.db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + for table in ("receipts", "intents")) + + +def readmit(executor, sources, kwargs, reference="session"): + return executor.admit(*sources, **{**kwargs, "session_evidence": "fresh-session", + "original_session_reference": reference}) + + +def test_normal_new_session_cannot_rewrite_original_journal_pins(tmp_path): + executor, admitted, _, sources, kwargs = make_rig(tmp_path) + executor.run(*admitted) + fresh_verifier(executor) + before = rows(executor) + changed = executor.admit(*sources, **{**kwargs,"session_evidence":"fresh-session"}) + assert changed[1].pins != admitted[1].pins + with pytest.raises(Refused,match="journal pins changed"): + executor.rollback(*changed) + assert rows(executor) == before + + +def test_fresh_process_inverse_retains_original_pins_without_replay(tmp_path): + executor, admitted, calls, sources, kwargs = make_rig(tmp_path) + assert executor.run(*admitted).last.outcome is Outcome.SUCCESS + before = rows(executor) + fresh = Executor(Journal(executor.journal.directory), executor.adapters, + fresh_verifier(executor)) + renewed = readmit(fresh, sources, kwargs) + assert renewed[1].pins == admitted[1].pins + assert rows(fresh) == before + with pytest.raises(Refused): + fresh.run(*renewed) + assert len(calls) == 1 + assert fresh.rollback(*renewed).last.outcome is Outcome.ROLLED_BACK + + +@pytest.mark.parametrize("kind", ["missing-intent", "orphan-intent", "wrong-principal", "wrong-reference", "same-as-current", "invalid-reference", "inactive-current"]) +def test_original_reference_is_never_authentication_or_new_intent(tmp_path, kind): + executor, admitted, calls, sources, kwargs = make_rig(tmp_path) + if kind != "missing-intent": + executor.run(*admitted) + if kind == "orphan-intent": + executor.journal.db.execute("DELETE FROM intents") + if kind == "wrong-principal": + kwargs = {**kwargs,"principal":"another-operator"} + fresh_verifier(executor, allow=kind != "inactive-current") + before = rows(executor) + reference = {"wrong-reference": "unrelated-session", "same-as-current": "fresh-session", + "invalid-reference": "not valid"}.get(kind, "session") + with pytest.raises(Refused): + readmit(executor, sources, kwargs, reference) + assert rows(executor) == before + + +def test_unknown_effect_stays_unknown_after_current_readmission(tmp_path): + executor, admitted, calls, sources, kwargs = make_rig(tmp_path) + old = executor.adapters[admitted[0].adapter] + def interrupted(inv): + calls.append(inv) + raise KeyboardInterrupt + executor.adapters[old.reference] = replace(old, invoke=interrupted) + with pytest.raises(KeyboardInterrupt): + executor.run(*admitted) + fresh_verifier(executor) + before = rows(executor) + renewed = readmit(executor, sources, kwargs) + assert rows(executor) == before + with pytest.raises(Refused): + executor.run(*renewed, retry=True) + state = executor.journal.load("mint-secret:fixture-client", renewed[2]) + assert state.last.outcome is Outcome.UNCERTAIN + assert len(calls) == 1 + + +def test_current_session_revalidated_after_readmission_before_journal_write(tmp_path): + executor, admitted, _, sources, kwargs = make_rig(tmp_path) + executor.run(*admitted) + fresh_verifier(executor) + renewed = readmit(executor, sources, kwargs) + executor.evidence_verifier = lambda *_: False + before = rows(executor) + with pytest.raises(Refused, match="caller session no longer verified"): + executor.rollback(*renewed) + assert rows(executor) == before diff --git a/workplans/WARDEN-WP-0044-security-case-follow-through.md b/workplans/WARDEN-WP-0044-security-case-follow-through.md index 3ad2870..6c09ae9 100644 --- a/workplans/WARDEN-WP-0044-security-case-follow-through.md +++ b/workplans/WARDEN-WP-0044-security-case-follow-through.md @@ -151,6 +151,22 @@ state_hub_task_id: "bcbf6c91-2c04-5c16-ae8c-5d6c06fd6b89" ### T03 — Run and verify a case +2026-10-18 current-session continuation: the new optional +`Executor.admit(original_session_reference=...)` separates immutable original +run-sheet provenance from fresh independently verified current session evidence. +It requires the exact existing journal and original running intent under the +normal lock, refuses missing/orphan/changed context and retains all original +pins. Current authority is rechecked before every subsequent write/callback; +neither the original reference nor a fresh login can replay a completed effect +or clear uncertainty. No descriptor/Invocation/receipt schema or default +admission behavior changes. Source integration uses the actual KeyCape CLI over +HTTPS, a fresh Python owner process and actual Warden SQLite, with synthetic +case/catalog/owner/effect attestations only. Exact source/proof is retained by +CUST-WP-0080 under `keycape-current-client-r1`. Native owner mappings, current +operation/effect authority, credential custody, case/type/descriptor refreeze, +Forgejo inverse semantics and first native acceptance remain open; no lane, +adapter, runtime or caller scope is activated by this source change. + `warden case run ` executes the plan. Owner entry points run through the existing proxy as the caller; steps that need an attended login use the existing contained login envelope (silent command, session revoked afterwards),