diff --git a/docs/security-case-execution-kernel-v1.md b/docs/security-case-execution-kernel-v1.md index 35289d7..13bec32 100644 --- a/docs/security-case-execution-kernel-v1.md +++ b/docs/security-case-execution-kernel-v1.md @@ -125,3 +125,30 @@ is now the tested producer revision. Its evidence module is imported lazily only by `check-evidence`, outside this admission path; Warden nevertheless pins and copies it with the complete reviewed producer set. Missing or modified bytes in any of the four modules refuse admission. The test covers every dependency. + + +## Original-intent consistency boundary — 2026-10-17 + +`OriginalIntent` and the optional additive +`reconcile_original(ReconcileContext(original, attempt))` callback retain the +original run separately from a fresh reconciliation attempt. Existing one-argument +reconcile adapters remain source-compatible. Intent and pre-dispatch receipt are +committed in one SQLite transaction; lookup now requires their exact attempt, +actor, step/owner/target, source and pin correspondence. Missing legacy context, +an orphan receipt, changed context or an invalid bounded field contract refuses; +there is no backfill or new runtime holder. + +Original intent serialization accepts only the fixed reviewed Invocation/Pins +field contract and bounded operational identifiers. Duplicate, oversized and +malformed original records refuse. Current Executor.admit leaves tenant and realm +empty: those reserved fields are not admitted tenant/realm provenance, and future +nonempty coordinates need an authoritative owner contract. Raw owner payloads, +credentials, parameters and caller output are not part of this context contract. + +Local dispatch correspondence is not independent owner proof. A real owner must +still authenticate the exact original created-only result and original revocation, +current native revision and the fresh attempt's session/grant evidence before +recovery can count. No actual original holder/verifier or native adapter is added. +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. diff --git a/src/warden/case_run.py b/src/warden/case_run.py index 331ce11..552c33d 100644 --- a/src/warden/case_run.py +++ b/src/warden/case_run.py @@ -180,16 +180,76 @@ class OriginalIntent: """Durable record of the original mutating dispatch, written before dispatch. Binds the original Invocation (attempt, step, owner, target, actor, principal, - lane), all pins, the admitted adapter source and the tenant/realm provenance - the caller was admitted with. It is evidence of what Warden dispatched, not - owner proof of creation: owners still authenticate created-only provenance. + lane), all pins and the admitted adapter source. Current admit leaves tenant + and realm empty; their reserved fields are not admitted provenance. This is + local dispatch correspondence, not owner proof of creation. Owners still + authenticate original created-only provenance independently. """ binding: str invocation: Invocation source: str + def validate(self) -> None: + """Fixed bounded metadata only; tenant/realm are not authority claims. + + Current admit leaves tenant/realm empty. Nonempty future coordinates need + separate owner provenance; serializability alone must never supply it. + """ + if ( + type(self.invocation) is not Invocation + or type(self.invocation.pins) is not Pins + ): + raise Refused("exact original invocation required") + i = self.invocation + fields = (i.step, i.owner, i.target, i.principal, i.lane, i.attempt_id) + if ( + not all( + type(value) is str and IDENTIFIER.fullmatch(value) for value in fields + ) + or type(i.actor) is not str + or i.actor not in ("human", "agent") + or type(i.operation) is not str + or i.operation not in ("run", "rollback") + or not all( + type(value) is str and (not value or IDENTIFIER.fullmatch(value)) + for value in (i.tenant, i.realm) + ) + or type(self.source) is not str + or not DIGEST.fullmatch(self.source) + or type(self.binding) is not str + or self.binding != f"{i.step}:{i.target}" + ): + raise Refused("invalid original intent metadata") + if set(Invocation.__dataclass_fields__) != { + "step", + "owner", + "target", + "actor", + "principal", + "lane", + "pins", + "attempt_id", + "operation", + "tenant", + "realm", + }: + raise Refused("original invocation field contract changed") + if not all( + type(v) is str and DIGEST.fullmatch(v) + for v in ( + i.pins.case, + i.pins.case_type, + i.pins.adapter, + i.pins.catalog, + i.pins.acceptance, + i.pins.run_sheet, + ) + ): + raise Refused("invalid original intent pins") + def payload(self) -> str: + self.validate() return json.dumps( {"schema": INTENT_SCHEMA, "binding": self.binding, "source": self.source} | asdict(self.invocation), @@ -199,13 +259,44 @@ class OriginalIntent: @classmethod def decode(cls, raw: str) -> OriginalIntent: - obj = json.loads(raw) - fields = {f for f in Invocation.__dataclass_fields__} - if set(obj) != fields | {"schema", "binding", "source"} or obj["schema"] != INTENT_SCHEMA: - raise Refused("unknown original intent schema") - inv = {k: obj[k] for k in fields} - inv["pins"] = Pins(**inv["pins"]) - return cls(obj["binding"], Invocation(**inv), obj["source"]) + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Refused("duplicate original intent field") + result[key] = value + return result + + try: + if type(raw) is not str or len(raw.encode()) > MAX_SOURCE_BYTES: + raise Refused("original intent input exceeds bound") + obj = json.loads(raw, object_pairs_hook=unique) + fields = { + "step", + "owner", + "target", + "actor", + "principal", + "lane", + "pins", + "attempt_id", + "operation", + "tenant", + "realm", + } + if ( + type(obj) is not dict + or set(obj) != fields | {"schema", "binding", "source"} + or obj["schema"] != INTENT_SCHEMA + ): + raise Refused("unknown original intent schema") + inv = {k: obj[k] for k in fields} + inv["pins"] = Pins(**inv["pins"]) + result = cls(obj["binding"], Invocation(**inv), obj["source"]) + result.validate() + return result + except (TypeError, ValueError, KeyError): + raise Refused("invalid original intent record") from None @dataclass(frozen=True, slots=True) @@ -256,28 +347,76 @@ class Journal: "CREATE TABLE IF NOT EXISTS intents (seq INTEGER PRIMARY KEY, attempt_id TEXT NOT NULL UNIQUE, binding TEXT NOT NULL, payload TEXT NOT NULL)" ) - def original_intent(self, binding: str, current: Invocation, source: str) -> OriginalIntent: + def original_intent( + self, binding: str, current: Invocation, source: str + ) -> OriginalIntent: """Latest original run intent for binding, refused unless it matches exactly. Missing or mismatched records refuse; the step stays uncertain. """ row = self.db.execute( - "SELECT payload FROM intents WHERE binding=? ORDER BY seq DESC LIMIT 1", + "SELECT attempt_id,payload FROM intents WHERE binding=? ORDER BY seq DESC LIMIT 1", (binding,), ).fetchone() if row is None: raise Refused("original intent record missing; step stays uncertain") - intent = OriginalIntent.decode(row[0]) + intent = OriginalIntent.decode(row[1]) o = intent.invocation + if row[0] != o.attempt_id: + raise Refused("original intent attempt identity differs") if intent.binding != binding or o.operation != "run": raise Refused("original intent binding mismatch") if o.pins != current.pins: raise Refused("original intent pins mismatch") if intent.source != source: raise Refused("original intent source mismatch") - for name in ("step", "owner", "target", "principal", "lane", "tenant", "realm"): + for name in ( + "step", + "owner", + "target", + "actor", + "principal", + "lane", + "tenant", + "realm", + ): if getattr(o, name) != getattr(current, name): raise Refused(f"original intent {name} mismatch") + # Match the persisted original running receipt. An orphan context or a + # payload from another attempt cannot become the current lookup key. + receipts = self.db.execute( + "SELECT payload FROM receipts WHERE binding=? ORDER BY seq DESC LIMIT ?", + (binding, MAX_HISTORY + 1), + ).fetchall() + if len(receipts) > MAX_HISTORY: + raise Refused("original intent receipt history exceeds bound") + matched = False + try: + for (raw,) in receipts: + if type(raw) is not str or len(raw.encode()) > MAX_SOURCE_BYTES: + raise Refused("original receipt input exceeds bound") + payload = json.loads(raw) + if ( + payload.get("attempt_id") != o.attempt_id + or payload.get("operation") != "run" + ): + continue + last = payload.get("receipts", [])[-1] + if last.get("outcome") != "running": + continue + if ( + payload.get("journal_schema") != "warden.case.step-journal.v1" + or payload.get("step_id") != o.step + or payload.get("owner") != o.owner + or payload.get("pins") != asdict(o.pins) + ): + raise Refused("original running receipt binding differs") + matched = True + break + except (TypeError, ValueError, KeyError, IndexError, AttributeError): + raise Refused("invalid original running receipt") from None + if not matched: + raise Refused("original running receipt missing; no backfill") return intent @contextmanager @@ -355,6 +494,23 @@ class Journal: invocation: Invocation | None = None, intent: OriginalIntent | None = None, ): + if intent is not None: + if type(intent) is not OriginalIntent: + raise Refused("exact original intent required") + intent.validate() + expected = ( + Outcome.RUNNING + if intent.invocation.operation == "run" + else Outcome.ROLLBACK_RUNNING + ) + if ( + intent.binding != binding + or intent.invocation != invocation + or intent.invocation.pins != state.pins + or not state.last + or state.last.outcome is not expected + ): + raise Refused("original intent and running receipt differ") self.db.execute("BEGIN IMMEDIATE") try: if intent is not None: diff --git a/tests/test_case_original_context_consistency.py b/tests/test_case_original_context_consistency.py new file mode 100644 index 0000000..ddd429d --- /dev/null +++ b/tests/test_case_original_context_consistency.py @@ -0,0 +1,198 @@ +"""Original-intent consistency checks with generated metadata only. + +Reuse the existing synthetic owner rig. These checks do not provide a native +holder or independently authenticate original creation from local SQL/JSON. +""" + +from dataclasses import asdict, replace +import json +from pathlib import Path +import sqlite3 +import subprocess +import sys +import pytest +from tests.test_case_run import rig as rig +from warden.case_receipt_state import Outcome, Refused +from warden.case_run import OriginalIntent + + +def interrupted(rig): + executor, args, calls, sources, kwargs = rig + old = executor.adapters[args[0].adapter] + seen = [] + + def lost(inv): + calls.append(inv) + raise TimeoutError("generated lost response") + + def original(ctx): + seen.append(ctx) + return old.reconcile(ctx.attempt) + + executor.adapters[old.reference] = replace( + old, invoke=lost, reconcile=None, reconcile_original=original + ) + assert executor.run(*args).last.outcome is Outcome.UNCERTAIN + return executor, args, calls, old, seen + + +def alter_payload(executor, **changes): + row = executor.journal.db.execute( + "SELECT seq,payload FROM intents ORDER BY seq DESC LIMIT 1" + ).fetchone() + obj = json.loads(row[1]) + obj.update(changes) + executor.journal.db.execute( + "UPDATE intents SET payload=? WHERE seq=?", (json.dumps(obj), row[0]) + ) + + +def test_original_actor_mismatch_is_refused(rig): + executor, args, calls, old, _ = interrupted(rig) + alter_payload(executor, actor="agent") + with pytest.raises(Refused): + executor.journal.original_intent( + "mint-secret:fixture-client", args[1], old.source + ) + + +def test_payload_attempt_id_must_match_sql_column_and_original_receipt(rig): + executor, args, calls, old, _ = interrupted(rig) + actual = calls[0].attempt_id + alter_payload(executor, attempt_id="attempt-unrelated") + with pytest.raises(Refused): + executor.journal.original_intent( + "mint-secret:fixture-client", args[1], old.source + ) + assert ( + executor.journal.db.execute("SELECT attempt_id FROM intents").fetchone()[0] + == actual + ) + + +def test_orphan_intent_without_original_running_receipt_is_refused(rig): + executor, args, calls, old, _ = interrupted(rig) + executor.journal.db.execute( + "DELETE FROM receipts WHERE seq=(SELECT MIN(seq) FROM receipts)" + ) + assert ( + executor.journal.load("mint-secret:fixture-client", args[2]).last.outcome + is Outcome.UNCERTAIN + ) + with pytest.raises(Refused): + executor.journal.original_intent( + "mint-secret:fixture-client", args[1], old.source + ) + + +def test_serializer_refuses_arbitrary_values_outside_normal_admit(rig): + _, args, _, _, _ = rig + inv = replace( + args[1], + target={"unexpected": "synthetic-owner-payload"}, + operation="run", + attempt_id="attempt-fixture", + ) + record = OriginalIntent("mint-secret:fixture-client", inv, "sha256:" + "1" * 64) + with pytest.raises(Refused): + OriginalIntent.decode(record.payload()) + # This is a direct serialization API gap, not an observed normal-admit leak. + + # Owner trust is the existing synthetic fixture; this proves call shape only. + + +@pytest.mark.parametrize( + "field,value", + [ + ("principal", "another-person"), + ("tenant", "another-tenant"), + ("realm", "another-realm"), + ], +) +def test_existing_lookup_does_refuse_stored_principal_tenant_realm_mismatch( + rig, field, value +): + executor, args, _, old, _ = interrupted(rig) + alter_payload(executor, **{field: value}) + with pytest.raises(Refused): + executor.journal.original_intent( + "mint-secret:fixture-client", args[1], old.source + ) + + +def test_existing_atomic_insert_failure_prevents_dispatch_and_both_records(rig): + executor, args, calls, _, _ = rig + executor.journal.db.execute( + "CREATE TRIGGER fail_receipt BEFORE INSERT ON receipts BEGIN SELECT RAISE(ABORT,'fixture'); END" + ) + with pytest.raises(sqlite3.IntegrityError): + executor.run(*args) + assert not calls + assert executor.journal.db.execute("SELECT COUNT(*) FROM intents").fetchone() == ( + 0, + ) + assert executor.journal.db.execute("SELECT COUNT(*) FROM receipts").fetchone() == ( + 0, + ) + + +def test_existing_missing_original_refuses_before_callback(rig): + executor, args, _, _, seen = interrupted(rig) + executor.journal.db.execute("DELETE FROM intents") + with pytest.raises(Refused): + executor.reconcile(*args) + assert not seen + assert ( + executor.journal.load("mint-secret:fixture-client", args[2]).last.outcome + is Outcome.UNCERTAIN + ) + + +def test_existing_current_parent_revocation_remains_mandatory(rig): + executor, args, _, _, _ = interrupted(rig) + old = executor.evidence_verifier + + def refuse_session(purpose, source, evidence): + return ( + False + if purpose.startswith("session-revocation:") + else old(purpose, source, evidence) + ) + + executor.evidence_verifier = refuse_session + with pytest.raises(Refused): + executor.reconcile(*args) + assert ( + executor.journal.load("mint-secret:fixture-client", args[2]).last.outcome + is Outcome.UNCERTAIN + ) + + +def test_existing_intent_survives_actual_distinct_interpreter(rig): + executor, args, calls, old, _ = interrupted(rig) + code = """import json,sys +from pathlib import Path +from warden.case_run import Journal,Invocation +from warden.case_receipt_state import Pins +obj=json.load(sys.stdin);obj['pins']=Pins(**obj['pins']);j=Journal(Path(sys.argv[1])) +with j.locked(): + i=j.original_intent('mint-secret:fixture-client',Invocation(**obj),sys.argv[2]) + print(json.dumps({'attempt':i.invocation.attempt_id,'operation':i.invocation.operation})) +""" + result = subprocess.run( + [sys.executable, "-B", "-c", code, str(executor.journal.directory), old.source], + input=json.dumps(asdict(args[1])), + text=True, + capture_output=True, + timeout=15, + env={ + "PATH": "/usr/bin:/bin", + "PYTHONPATH": str(Path(__file__).parents[1] / "src"), + "PYTHONDONTWRITEBYTECODE": "1", + }, + check=True, + ) + assert json.loads(result.stdout) == { + "attempt": calls[0].attempt_id, + "operation": "run", + } diff --git a/workplans/WARDEN-WP-0044-security-case-follow-through.md b/workplans/WARDEN-WP-0044-security-case-follow-through.md index 40f1b5b..f641324 100644 --- a/workplans/WARDEN-WP-0044-security-case-follow-through.md +++ b/workplans/WARDEN-WP-0044-security-case-follow-through.md @@ -168,14 +168,37 @@ Invocation — attempt, step, owner, target, actor, principal, lane, all six pin tenant, realm — plus admitted adapter source) in the same SQLite transaction as the pre-dispatch journal row. `Journal.original_intent(binding, current, source)` returns it after restart and refuses on a missing record or any -pins/source/step/owner/target/principal/lane/tenant/realm mismatch, before any +pins/source/step/owner/target/actor/principal/lane/tenant/realm mismatch, before any journal write, so the step stays uncertain. An adapter's `reconcile_original(ReconcileContext(original, attempt))` receives the original intent separately from the new reconcile attempt. This is Warden's record of what -it dispatched, not owner created-only provenance; tenant/realm are as supplied at -admission. Owner descriptors/adapters, contained session integration, Forgejo +it dispatched, not owner created-only provenance. Current Executor.admit leaves +tenant/realm empty; the reserved coordinates establish no admitted provenance. +Owner descriptors/adapters, contained session integration, Forgejo materialization and the native run remain open. +2026-10-17 source consistency follow-through: retained the additive intents table +and reconcile_original callback and fixed four reproduced local defects: original +actor mismatch, payload/SQL attempt-ID mismatch, orphan intent without its +matching original running receipt, and arbitrary nonmetadata values accepted by +the original-intent serializer. Exact bounded field validation, duplicate-free +bounded decode and save-time intent/invocation/running-state equality now preserve +the original receipt linkage before owner lookup. No new context store, native +verifier, trust root, adapter activation or authority migration was introduced. + +The actual canonical focused kernel and new consistency suite passes 54 checks; +owner-installed Ruff check/format passes. The existing generated-data/synthetic +owner fixture and a distinct interpreter prove source correspondence/refusal, +not independent native custody or creation. Earlier comparison probes and failed +preparation are retained. Current caller and fresh session/grant revocation checks +remain required; original owner payload/revocation, exact native readback and +created-only provenance still need independent authenticated owner admission. +No JSON/SQL context, unknown tenant/realm, changed run-sheet or legacy missing +intent supplies recovery, replay or undo authority. T03 remains progress and all +native acceptance gates/statuses/dependencies/UUIDs remain unchanged. Evidence: +Custodian original-context-compare-r2 and original-context-owner-followthrough-r3 +under docs/changes/netkingdom-target-2026-10-17/; publication/projection is separate. + Agreed with railiance-platform (RPF-WP-0054-T01, 2026-10-09): - `mint-secret` runs `credential.py generate --grant keycape/client-secret-generate --client-id --case-ref --case-file ` as the caller's