From 1b8261917e54c749d95e8a0e41a84b44327a2a6a Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 11 Oct 2026 02:21:03 +0200 Subject: [PATCH] Prepare reviewed durable case execution kernel and accepted layer pins Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- INTENT.md | 2 +- ...26-10-11-security-layer-v08-migration.json | 93 +++ ...on-kernel-independent-review.findings.json | 32 + ...ase-execution-kernel-independent-review.md | 55 ++ ...ion-kernel-independent-review.sources.json | 46 ++ docs/security-case-execution-kernel-v1.md | 86 +++ docs/security-case-execution-proposal.md | 136 ++++ registry/routing/catalog.yaml | 4 +- scripts/check_layer_conformance.py | 19 +- src/warden/case_receipt_state.py | 146 ++++ src/warden/case_run.py | 714 ++++++++++++++++++ tests/test_case_receipt_state.py | 124 +++ tests/test_case_run.py | 631 ++++++++++++++++ tests/test_layer_conformance.py | 34 +- tests/test_routing.py | 2 +- .../playbooks/netkingdom-layer-declaration.md | 33 +- ...EN-WP-0044-security-case-follow-through.md | 40 +- 17 files changed, 2163 insertions(+), 34 deletions(-) create mode 100644 docs/evidence/2026-10-11-security-layer-v08-migration.json create mode 100644 docs/reviews/2026-10-11-case-execution-kernel-independent-review.findings.json create mode 100644 docs/reviews/2026-10-11-case-execution-kernel-independent-review.md create mode 100644 docs/reviews/2026-10-11-case-execution-kernel-independent-review.sources.json create mode 100644 docs/security-case-execution-kernel-v1.md create mode 100644 docs/security-case-execution-proposal.md create mode 100644 src/warden/case_receipt_state.py create mode 100644 src/warden/case_run.py create mode 100644 tests/test_case_receipt_state.py create mode 100644 tests/test_case_run.py diff --git a/INTENT.md b/INTENT.md index 401328d..c643f16 100644 --- a/INTENT.md +++ b/INTENT.md @@ -22,7 +22,7 @@ assent: docs/adr/ADR-0010 # INTENT > **ops-warden is Staff, and PEP-shaped.** Declared here in its own voice, per -> `security-layer-model_v0.7` §11 — a layer stated *about* a repository by another +> `security-layer-model_v0.8` §11 — a layer stated *about* a repository by another > repository is not a declaration. The standard is **accepted**; its operative form > is `net-kingdom/SECURITY-COMPANION.md`. ops-warden's assent is `ADR-0010`, and its > reviews of v0.4, v0.6 and v0.7 are in `history/`. diff --git a/docs/evidence/2026-10-11-security-layer-v08-migration.json b/docs/evidence/2026-10-11-security-layer-v08-migration.json new file mode 100644 index 0000000..e41319f --- /dev/null +++ b/docs/evidence/2026-10-11-security-layer-v08-migration.json @@ -0,0 +1,93 @@ +{ + "owner": "ops-warden", + "accepted_standard_sha256": "3d8298560069ef471fba28d2b1245fb2309328f231dad1a667cdf0ce5a5a7c8b", + "accepted_source": "net-kingdom/canon/standards/security-layer-model_v0.8.md", + "acceptance_receipt": "net-kingdom/docs/security-layer-v0.8-acceptance.md", + "tests": { + "layer_conformance_passed": 31, + "source": "staged modified scripts/tests + read-only current source support", + "limitations": "mechanical declared scope only, not complete estate/runtime conformance", + "routing": { + "full_suite_passed": 99, + "full_suite_failed": 1, + "failure": "staging fixture missing unchanged wiki links; not product regression", + "focused_after_fixture_repair_passed": 2, + "focused_cases": [ + "every_wiki_ref_anchor_resolves", + "route_find_layer_declaration_resolves_to_reference_playbook" + ], + "environment": "WARDEN_MEMORY=0 and explicit staged WARDEN_ROUTING_CATALOG; no installed catalog/memory mutation" + } + }, + "checker_runs": [ + { + "argv": [], + "returncode": 0, + "stdout": "validated against: net-kingdom/canon/standards/security-layer-model_v0.8.md (accepted; sha256:3d8298560069ef471fba28d2b1245fb2309328f231dad1a667cdf0ce5a5a7c8b)\nscope: single repository ops-warden (catalogued in \u00a74); INTENT.md frontmatter, layer.yaml, src/warden/**/*.py\nPASS \u2014 4 module(s) with Tooling contact, all declared; validated against net-kingdom/canon/standards/security-layer-model_v0.8.md (accepted; sha256:3d8298560069ef471fba28d2b1245fb2309328f231dad1a667cdf0ce5a5a7c8b)\n", + "stderr": "" + }, + { + "argv": [ + "--report" + ], + "returncode": 0, + "stdout": "validated against: net-kingdom/canon/standards/security-layer-model_v0.8.md (accepted; sha256:3d8298560069ef471fba28d2b1245fb2309328f231dad1a667cdf0ce5a5a7c8b)\nscope: single repository ops-warden (catalogued in \u00a74); INTENT.md frontmatter, layer.yaml, src/warden/**/*.py\nops-warden \u2014 layer: Staff (declared in INTENT.md; \u00a711 governing form)\n layer.yaml: derived from INTENT.md, layer: staff\ndeclared by docs/adr/ADR-0010\n\n \u00a75.3 ssh-ca-signing-write src/warden/vault.py -> secrets-engine review 2026-11-28\n \u00a75.3 desk-paste-once-provision src/warden/desk.py -> secrets-engine review 2026-11-28\n \u00a75.1 taint-metadata-read src/warden/taint.py\n \u00a75.2 access-proxy-conduit src/warden/proxy.py\n \u00a75.2 caller-identity-token src/warden/caller_identity.py\n\n2 declared gap(s) \u2014 tracked non-conformance, not conformance (\u00a711).\n\nPASS \u2014 every direct Tooling contact maps to a declared shape; validated against net-kingdom/canon/standards/security-layer-model_v0.8.md (accepted; sha256:3d8298560069ef471fba28d2b1245fb2309328f231dad1a667cdf0ce5a5a7c8b)\n", + "stderr": "" + } + ], + "meaningful_regressions": { + "unicode_token_original_fold": "staff", + "original_accepted_non_ascii_token": true + }, + "source_changes": [ + { + "repo": "ops-warden", + "path": "scripts/check_layer_conformance.py", + "staged_path": "/tmp/layer-migration-stage/ops-warden/scripts/check_layer_conformance.py", + "original_sha256": "064be430b64072fd1620e6adc19b4e24a1790c9df06144a9670cd262faf66e8a", + "staged_sha256": "7262fae9fae7ed454c0765cc6859e83a74e59ef6eade849991e0b8ab9c66757b" + }, + { + "repo": "ops-warden", + "path": "tests/test_layer_conformance.py", + "staged_path": "/tmp/layer-migration-stage/ops-warden/tests/test_layer_conformance.py", + "original_sha256": "3d39b0a24a65263b2be9f18add2b9c69e12b7926716d610e8200d5c788c1b696", + "staged_sha256": "93e3a8805460c4a6a4fd78734cf4dd85947a46e23d16a0d6898a43e8ec39275d" + }, + { + "repo": "ops-warden", + "path": "INTENT.md", + "staged_path": "/tmp/layer-migration-stage/ops-warden/INTENT.md", + "original_sha256": "766c2d44851be7078a031575710843cd7d1278d665e874882c8e90980dcd0704", + "staged_sha256": "3e9382fefa52ee0970dfac133cc799656e5034760b26dccc1bf9bab65fc538d9" + }, + { + "repo": "ops-warden", + "path": "registry/routing/catalog.yaml", + "staged_path": "/tmp/layer-migration-stage/ops-warden/registry/routing/catalog.yaml", + "original_sha256": "32b0093cf5b93025d2aa90397247fef6ccce73cde5f477895a90ad09ddb48643", + "staged_sha256": "09ee40df21b0ad8cad420be4613afcb1731967f68128631a94f9b96f3f31b318" + }, + { + "repo": "ops-warden", + "path": "tests/test_routing.py", + "staged_path": "/tmp/layer-migration-stage/ops-warden/tests/test_routing.py", + "original_sha256": "e449a4e169ccb7021d1bc547c420628b2b618d713915b90cf68d0bcaf5d5430f", + "staged_sha256": "43998fccf67b1f0d1a6c8bde1a4768a32f8418fb72a8eda5bf321dd96c097d35" + }, + { + "repo": "ops-warden", + "path": "wiki/playbooks/netkingdom-layer-declaration.md", + "staged_path": "/tmp/layer-migration-stage/ops-warden/wiki/playbooks/netkingdom-layer-declaration.md", + "original_sha256": "2dbef2235afdc33203fb01582220370051cf6c9eefc7c7d482ff05ebd615fb64", + "staged_sha256": "6a409a1c474ce8759b01cf5a62e5b95bba297e167a05fd8f8b616abdf6414306" + } + ], + "knowledge_orientation": { + "package_version": "0.2.6", + "package_digest": "8d1d6a9d4d07fec7b619618de7dfe2be2254c1790f85f4198c7fe19db9751c02", + "start_status": "withheld due changed sources; source reviewed directly" + }, + "native_runtime_changed": false, + "pushed": false +} diff --git a/docs/reviews/2026-10-11-case-execution-kernel-independent-review.findings.json b/docs/reviews/2026-10-11-case-execution-kernel-independent-review.findings.json new file mode 100644 index 0000000..24375f4 --- /dev/null +++ b/docs/reviews/2026-10-11-case-execution-kernel-independent-review.findings.json @@ -0,0 +1,32 @@ +{ + "initial_kernel_sha256": "eb6ee0cd00b2704904782e71c214c34a91d060086b49fbd4ba1b86b2290da7a7", + "initial_fixture_tests_passed": 23, + "initial_independent_probe": { + "fd_output_escaped": true, + "provenance_exception_escaped": true, + "fixture_only": true, + "native_execution": false, + "subprocess_exit": 1 + }, + "findings_disposition": { + "unbounded_source_parsing": "fixed:256KiB caps before JSON/YAML", + "python_only_output_withholding": "fixed:serialized fd-level+Python containment", + "provenance_error_payload": "fixed:contained verifier with fixed Refused; BaseException sanitized preserving running", + "aggregate_revocation": "fixed:separate required broker/parent receipt proofs", + "prior_attempt_receipt_reuse": "fixed:result/proof purposes include all Pins+operation+sequence+nonce attempt identity", + "undeclared_secret_parameters": "author self-audit fixed explicit acknowledged-type parameter gate" + }, + "final_kernel_sha256": "3636d089bfa9dd3fe66971529305354ba1e7ed46572eeceaabeb3c9a064b76a8", + "final_fixture_tests_passed": 51, + "refined_containment_probe": { + "fd_output_escaped": false, + "provenance_exception_escaped": false, + "fixture_only": true, + "native_execution": false, + "subprocess_exit": 0 + }, + "refined_containment_probe_scope": "independent probe at preceding4edb183 kernel, unchanged containment in final3636d089; final51tests reverify all regressions", + "failed_outcomes_preserved": true, + "refinements_are_same_review_trial": true, + "native_verified": false +} diff --git a/docs/reviews/2026-10-11-case-execution-kernel-independent-review.md b/docs/reviews/2026-10-11-case-execution-kernel-independent-review.md new file mode 100644 index 0000000..e61b098 --- /dev/null +++ b/docs/reviews/2026-10-11-case-execution-kernel-independent-review.md @@ -0,0 +1,55 @@ +# Independent SecurityCase kernel review — 2026-10-11 + +Disposition: **agree to bounded local kernel preparation** at SHA-256 +`3636d089bfa9dd3fe66971529305354ba1e7ed46572eeceaabeb3c9a064b76a8`. +Review ID `WARDEN-REVIEW-20261011-CASE-KERNEL-v1`. Exact source/test/owner records +and accepted design are hashed in the adjacent sources receipt. This implements +part of accepted design 78ab1439, not live authority or executable type acceptance. + +The initial inspected kernel passed 23 fixture tests but independent synthetic +probes proved fd output escaped and raw provenance errors reached tracebacks. +Review also identified absent parser caps, aggregate revocation proof and missing +per-attempt binding. The findings receipt retains the failed revision and its +actual outcomes. Refinements are one trial, not separate scored successes. + +The final exact revision independently passes **51 tests** (37 kernel, 14 reducer). +Source review confirms 256 KiB pre-parser bounds; closed descriptor metadata; +exact case/type/catalog/descriptor pins; independently checked acceptance, +structural materialization and refreshed caller-session provenance; and explicit +undeclared/fixed/secret parameter refusal. Caller-asserted human labels cannot +satisfy the injected verifier. Agent mint remains refused after human admission. + +Private SQLite running intent commits before dispatch. Crash recovery persists +uncertainty without redispatch; success/no-change cannot replay. Safe retry needs +verified failed-before-change, unchanged pins, explicit retry and declared safety. +Each persisted attempt binds all exact Pins, operation, sequence and nonce. Owner +results and separate broker-grant/parent-session revocation proof purposes must +match it; stale proof reuse and absence of either required proof cannot succeed. +Reconciliation is a separate owner callback, not credential regeneration. + +Serialized Python plus OS-fd containment discards adapter/verifier output without +payload retention, including inherited synchronous subprocess output. Typed +errors and sanitized interruptions preserve running intent without raw messages. +This contains trusted fixed owner callbacks; it is no sandbox for arbitrary +in-process hostile code or background children. Production adapters must reuse +admitted attended-login containment and tie installed code to owner source pins. + +Rollback passes only verified case-created objects in reverse order, excludes +shared objects, refuses no-change undo and stops on uncertain/lost results. +Limits of 32 created identifiers and 128 transitions bound local receipts; owner +archival must precede further dispatch. Independent provenance checks remain +injected contracts, not an implemented authentication system. + +No installed production adapters, native provenance verifier or dispatch CLI +exist. No dynamic import, inferred shell execution or live argv adapter was +introduced. Descriptive types lack accepted machine descriptors and remain +plan-only; no CLI may dispatch this harness as a native run. Full ordered +run-sheet orchestration, verification inventory, contained login integration, +owner adapter/type acceptance, actual materialization and first live case proof +remain WARDEN-WP-0044-T03 and existing owner tasks. Uncertain undo still requires +owner reconciliation. T03 remains in progress. + +Fake tests verify local persistence/refusal/withholding only. They prove no +native identity, OpenBao grant/policy, consumer enforcement, independent audit +custody or production revocation. No token, live Secret, login, mutation or push +occurred during this review. diff --git a/docs/reviews/2026-10-11-case-execution-kernel-independent-review.sources.json b/docs/reviews/2026-10-11-case-execution-kernel-independent-review.sources.json new file mode 100644 index 0000000..4c81803 --- /dev/null +++ b/docs/reviews/2026-10-11-case-execution-kernel-independent-review.sources.json @@ -0,0 +1,46 @@ +{ + "review_id": "WARDEN-REVIEW-20261011-CASE-KERNEL-v1", + "disposition": "agree-bounded-local-kernel", + "approved_design_sha256": "78ab1439e4c5beb205aeccf15b1b2e706511be55ab195c5a3aa682f326c7c77c", + "sources": [ + { + "owner_path": "ops-warden/src/warden/case_run.py", + "reviewed_source_path": "/tmp/warden-case-execution-stage/src/warden/case_run.py", + "sha256": "3636d089bfa9dd3fe66971529305354ba1e7ed46572eeceaabeb3c9a064b76a8" + }, + { + "owner_path": "ops-warden/tests/test_case_run.py", + "reviewed_source_path": "/tmp/warden-case-execution-stage/tests/test_case_run.py", + "sha256": "1e70ceb858af0eb3a126132a3c3606706120c0caf58ca9d45deb43f217705c7e" + }, + { + "owner_path": "ops-warden/docs/security-case-execution-kernel-v1.md", + "reviewed_source_path": "/tmp/warden-case-execution-stage/docs/security-case-execution-kernel-v1.md", + "sha256": "ed5b6432ada71d1065fc837e3638e248cd4803241b6430d6ded07c25c0094c8d" + }, + { + "owner_path": "ops-warden/workplans/WARDEN-WP-0044-security-case-follow-through.md", + "reviewed_source_path": "/tmp/warden-case-execution-stage/workplans/WARDEN-WP-0044-security-case-follow-through.md", + "sha256": "c0029545bd2d8c015ae0cac87f6d53a2e5ee935b7bf5d5e1b81bf1ff5dbdc4f8" + }, + { + "owner_path": "/home/worsch/ops-warden/docs/security-case-execution-proposal.md", + "sha256": "78ab1439e4c5beb205aeccf15b1b2e706511be55ab195c5a3aa682f326c7c77c" + }, + { + "owner_path": "/home/worsch/net-kingdom/docs/security-case-execution-owner-review.md", + "sha256": "0f1637bfddbbdf50d336974791b7fbf4646efe7e32ca04beb3bf52effd0a28e6" + }, + { + "owner_path": "/home/worsch/the-custodian/docs/evidence/2026-10-10-security-consolidation/human-acceptance-receipts.json", + "sha256": "85007938d246b6fedb1ef323386720261e4ac55a9bf95f92c4915b226da357a7" + }, + { + "owner_path": "/home/worsch/ops-warden/src/warden/proxy.py", + "sha256": "2d12c432f21f529c5d67a59d584bffb55b8286bdc19ed0ec26d795f889ad989f" + } + ], + "findings_sha256": "d583435077e15cd5239c1c748d1fc079a73b70d8a9ef6c61d79869e93aa1699b", + "live_authority": false, + "native_complete": false +} diff --git a/docs/security-case-execution-kernel-v1.md b/docs/security-case-execution-kernel-v1.md new file mode 100644 index 0000000..bb9d775 --- /dev/null +++ b/docs/security-case-execution-kernel-v1.md @@ -0,0 +1,86 @@ +# Closed case execution kernel v1 — 2026-10-11 + +Owner implementation record for WARDEN-WP-0044-T03. The architectural proposal +at SHA256 78ab1439e4c5beb205aeccf15b1b2e706511be55ab195c5a3aa682f326c7c77c +was accepted through memo `infd-20261010-security-case-execution`, version 1, +approval e46b149b-ec3c-4b3b-98a7-dcb4934448ca, confirmed disposition +disp-cf44dacb-2117-4de7-8f90-cfa99f84f325 at 2026-10-10T23:23:31Z. The original +approved proposal remains byte-identical. That decision accepts source design; +it grants no execution session, credential or deployment authority. + +`src/warden/case_run.py` advances the pure reducer into an executable injected +owner harness with private durable receipts. Descriptor version +`warden.case.execution.v1` is a closed local implementation seam, not an accepted +NetKingdom type schema. It contains fixed owner/adapter/source digest, actor, +lane, target parameter, effect, reviewed created-object identifiers declared +retry safety and explicit required revocation scopes (parent-session and broker-grant). +A mint descriptor cannot omit either scope. Unknown fields, free shell text and secret parameters are refused. +An acknowledged type must additionally pin the descriptor bytes under its +`execution_descriptors` map and independent owner evidence must authenticate +that exact machine-seam revision. Existing descriptive types lack this map and +remain plan-only. Their acknowledged labels do not authorize this new seam. + +There are no installed owner adapters, native provenance verifier or dispatch CLI +in this change. Callables are injected only through a fixed adapter registry, +with immutable owner reference/source pins. A real integration must supply +independent owner/native verification or explicit attended manual evidence; +a bare JSON receipt identifier is insufficient. Fixture verifiers in tests +are deliberately fake and attest no native case. Caller sessions are independently +verified for exact principal/actor/lane at admission and again before dispatch; +input actor labels alone are never sufficient. Agent mint is refused, including +when WARDEN_AGENT_ID appears after initial human admission. + +Each admission pins exact case/type/descriptor/catalog bytes, acceptance reference +and run-sheet identity. Actual ops-mason materialization evidence is required +against the case source, separately from its status label. Case parameters supply +an exact metadata target. Source/session changes cannot inherit journal success. +Owner results have a closed metadata shape, result evidence fingerprint and +separate independently authenticated grant/parent-session revocation evidence +fingerprints. An aggregate revoked boolean alone cannot satisfy either scope. Unknown result payloads, unverified evidence, absent revocation and +created objects outside the reviewed scope become uncertain, never success. +Python and OS stdout/stderr, including inherited child-process output, are +discarded without buffering or retention under a serialized fd containment +context. Independent provenance verification uses the same containment and +sanitizes exceptions. All source inputs are capped at 256 KiB before parsing. +Error text is not copied to the journal. KeyboardInterrupt/SystemExit and other +owner interruptions are rethrown with a fixed message while retaining running +intent for crash recovery. + +The journal uses an owner-private 0700 directory and 0600 files, refuses symlinks +and insecure modes, uses SQLite FULL synchronization and commits running intent +before calling the adapter. A separate process lock prevents concurrent mutation. +A crash between dispatch and final receipt leaves running; the next lock holder +persists uncertain before refusing replay. Both intent and outcome remain in +history. Each persisted attempt has a unique identity bound to all exact pins, +operation (run/reconcile/rollback), sequence and nonce. Adapter results and both +revocation proof purposes carry that identity. Prior-attempt evidence cannot +verify a safe retry, even if a caller rewrites result labels. Descriptor-created +objects are capped at 32 and histories at 128 transitions, with dispatch stopped +before archival is required. A throw or lost response is not failed-before-change. A safe retry needs +verified failed-before-change, unchanged pins, descriptor retry-safe and explicit +retry. Success and no-change cannot replay. + +Reconciliation is an explicit fixed owner callback, never the mutation callback. +It may verify an already-present native CAS result as no-change/success, or prove +failed-before-change; it never tells Warden to regenerate a credential. Uncertain +or failed-after-change requires this owner-native inspection before any retry. +Owner proof and revocation remain mandatory. Rollback visits only objects in a +verified creation receipt, in reverse order; no-change has no undo and shared +objects are excluded. Unknown/lost undo blocks another rollback. + +The kernel operates individual admitted steps. Full ordered run-sheet orchestration, +verification inventory, contained login integration, native owner adapters and +CLI activation remain WARDEN-WP-0044-T03; no overall native-complete result is +implemented. Current Forgejo source is not materialized and has no acknowledged +machine descriptors. Its first native run remains an acceptance requirement, +with real materialization, separately authenticated broker-grant and parent-session +revocation receipts, session/owner receipts and consumer enforcement. This +implementation neither promotes a type nor changes any case status. + +Validation: 37 new kernel tests plus 14 reducer tests pass (51 total). Tests open +an independent SQLite connection inside dispatch to prove running was committed, +crash after mutation and reopen a fresh journal to prove no duplicate invocation, +lose a response then reconcile without mint, require explicit retry, check exact +pins/session expiry, withhold malicious output and unconfirmed revocation, and +prove created-only reverse undo plus uncertain-undo refusal. Ruff formatting and +checks pass. No live token or target action occurred. diff --git a/docs/security-case-execution-proposal.md b/docs/security-case-execution-proposal.md new file mode 100644 index 0000000..f061d43 --- /dev/null +++ b/docs/security-case-execution-proposal.md @@ -0,0 +1,136 @@ +# SecurityCase execution contract proposal — 2026-10-10 + +Status: proposed owner review; not an accepted runtime protocol or authority. +Owner: ops-warden. Related: WARDEN-WP-0044-T03, NK-WP-0048, MASON-WP-0010. + +`warden case plan` now gives a valid descriptive run, but the type's +`entry_point`, `auth`, verification `check` and rollback `action` fields are +prose. No `warden case run` or `rollback` implementation exists. A case becoming +`materialized` cannot turn those descriptions into executable commands. + +The proposal preserves caller authority and routes by owner. It introduces no +new credential, policy grant, shell command inferred from prose, or authority +for Warden to configure a consumer. Architectural contract acceptance belongs to +net-kingdom and adapter interfaces to their owners. Founder review is needed +only if the acknowledged type's authorization/session boundary changes; the +current absence of a machine contract is not an excuse to fabricate one. + +## Proposed machine seam + +Keep human descriptions. Add a separately validated `execution` reference to +steps and checks, resolving to owner-provided closed adapters. An adapter has: + +- immutable reference (owner repo, interface id, version, source digest); +- closed parameter schema (no secret-sensitivity fields; no free shell text); +- declared actor mode and catalog lane, which must agree with the case plan; +- explicit side-effect class and target identifier deriving from case parameters; +- idempotency/reconciliation contract and metadata-only receipt schema; +- supported rollback reference or explicit owner-only undo. + +Warden does not invent an argv contract for ExternalSecrets, group membership, +KeyCape registration or Forgejo configuration. Owner adapters may internally +call their reviewed tools. Warden invokes only the fixed entry point provided by +that adapter, with validated separate argv fields, and refuses unknown adapters +or templates. `entry_point` prose never goes to `shell=True` or `sh -c`. + +An owner may initially supply a receipt-only adapter: the run sheet records that +step as pending until the owner performs it and provides independent evidence. +This gives useful resumable orchestration before every live adapter exists, +without implying native completion or transferring ownership. + +## Run sheet and receipts + +The run sheet pins exact case bytes (`case-ref`), accepted type bytes/version, +owner adapter digests, catalog revision, acceptance reference and ordered step +identities. Changed pins require a newly reviewed sheet; never inherit previous +success into a different exact revision without explicit reconciliation. + +Each attempt yields an immutable metadata receipt containing these pins, step +id, owner, target, attempt id, result enum and evidence-reference fingerprints. +Allowed results: pending-owner, not-started, running, verified-success, +verified-no-change, failed-before-change, failed-after-change, uncertain. +Neither subprocess output nor arbitrary owner payload is copied into receipts. +Adapter error strings are not evidence and must not be logged verbatim. + +Stop on failure or uncertainty. A thrown exception or lost process receipt is +uncertain, not failed-before-change. Uncertain/failed-after-change blocks retry +until the owner reconciles target state. `verified-success` and +`verified-no-change` are not repeated. Retry after failed-before-change requires +the same pins and adapter's explicit retry-safe classification; a replay-safe +CAS0 receipt is reconciled, not retried into secret generation. State persistence +must write running before dispatch and result afterwards; interruption between +those writes leaves uncertain on resume. Outbox failures remain visible and do +not get rewritten as verified audit delivery. + +Owner receipt ingestion must authenticate provenance or be explicitly manual +attended evidence, never a bare trusted JSON argument. Source/local receipts are +attributive preparation until their owner contract defines independent native +verification. Overall native completion requires every applicable step and +verification plus confirmed session revocation; pending-owner is never success. + +## Sessions, values and rollback + +Reuse `proxy_attended_login_exec` containment/output suppression/revocation +rather than add a second login helper. A key-cape login lane and the OpenBao +broker issuer role are distinct grants; the executor must represent both and +never silently share/inherit a token. Grouping is permitted only within the +same declared identity/lane and preserves follow-through order. An agent +principal cannot dispatch generated-in-custody mint-secret. The broker continues +to rederive the exact case-ref independently. + +Rollback traverses successful step receipts in reverse order and only invokes +an owner's reviewed undo for target changes that receipt proves were created +by this case. Existing shared groups, membership and policies cannot be deleted +by interpreting a type rollback sentence literally. 'No change' yields no undo; +failed/uncertain states require reconciliation first. Do not restore a prior +secret value by extracting it into Warden; custody rollback is owner-native. + +## Owner acceptance requests to prepare + +1. net-kingdom: accept the closed machine seam separately from descriptive type + fields, document materialized = structural acceptance, and avoid gating that + state on mint-secret which is itself a follow-through step. Pin the revised + schema/type and keep type acceptance provenance explicit. +2. railiance-platform: provide a generate adapter over existing `credential.py` + exact `--grant`, `--client-id`, `--case-file`, `--case-ref` and receipt result + codes; clarify reconcile semantics for already-present CAS0 and grant/session + revocation evidence. No new credential is required by this proposal. +3. key-cape/net-kingdom: provide registration/group/env adapters and verify/undo + contracts in the existing attended boundary; do not hand Warden a user-engine + runtime token or a new broad Kubernetes credential. +4. railiance-apps/Forgejo: provide consumer OIDC + groups enforcement adapter or + receipt-only step, exact auth binding for CCR-2026-0042, and source/native + group-denial verification. This stays consumer ownership. +5. ops-mason: give a hash-pinned materialization receipt and list of objects + created vs pre-existing, sufficient for collision checks and scoped rollback. + +No coordination messages have been sent by preparation of this proposal. + +## Implementation and verification after owner seam acceptance + +Implement `case_run.py` with injected typed adapters and receipt storage, then +CLI run-sheet/receipt import/resume/rollback commands. Keep case_plan read-only. +Add type machine-seam validation without turning valid descriptive planning +into an unreviewed source-breaking schema migration. + +Required tests: rejected changed pins; unaccepted/unmaterialized cases; unsupported +adapter/argv refusal; agent mint refusal; pending owner keeps native gate open; +verified success never replayed; lost receipt resumes as uncertain; crash after +running persistence prevents duplicate side effect; failed-before-change retries +only with declared retry-safe adapter; failed-after-change needs owner reconcile; +malicious/secret output excluded; inability to revoke remains unverified; +rollback visits created successful objects only, preserves shared memberships, +and stops on uncertain undo. Inject runners; no live login or target required. + +WARDEN-WP-0044-T03 remains open. This packet resolves the precise source contract +deficit; it is not case materialization, a native run, or a type decision. + + +## Local reducer prototype + +`src/warden/case_receipt_state.py` is proposed local source preparation, not an +accepted wire format. Its pure immutable transitions can be tested without any +owner adapter or shell dispatch. Persist the returned running intent before an +external side effect; on a lost result, recover marks uncertainty and blocks +replay. No receipt provenance, durable storage, CLI dispatch or live action is +implemented. `tests/test_case_receipt_state.py` covers the failure boundaries. diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index 805cbba..8c0781b 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -51,8 +51,8 @@ entries: subsystem: NetKingdom layer-conformance routing warden_executes: false wiki_ref: wiki/playbooks/netkingdom-layer-declaration.md#worker-checklist - canon_ref: net-kingdom/canon/standards/security-layer-model_v0.7.md - reviewed: "2026-09-04" + canon_ref: net-kingdom/canon/standards/security-layer-model_v0.8.md + reviewed: "2026-10-11" status: active delegation: mode: permanent diff --git a/scripts/check_layer_conformance.py b/scripts/check_layer_conformance.py index 432c4a2..d05f234 100644 --- a/scripts/check_layer_conformance.py +++ b/scripts/check_layer_conformance.py @@ -58,16 +58,16 @@ DECL = ROOT / "layer.yaml" VALID_SHAPES = {"5.1", "5.2", "5.3"} -# What every run checks against, printed on every run (GH-DEC-2026-020 §4, -# GH-DEC-2026-021 §2). The accepted text is v0.7 at net-kingdom@66dc491; the -# decision records whose rulings this checker enforces beyond v0.7 are named with it. +# Accepted v0.8 publication; version provenance belongs to this run, never +# the standing layer declaration (§11). Exact publisher artifact is retained +# in net-kingdom/docs/security-layer-v0.8-acceptance.md. VALIDATED_AGAINST = ( - "net-kingdom/canon/standards/security-layer-model_v0.7.md (net-kingdom@66dc491) " - "as amended by GH-DEC-2026-017, GH-DEC-2026-020 and GH-DEC-2026-021 " - "(A9-A13, A12 r3; gate-house@39d9287)" + "net-kingdom/canon/standards/security-layer-model_v0.8.md " + "(accepted; sha256:3d8298560069ef471fba28d2b1245fb2309328f231dad1a667cdf0ce5a5a7c8b)" ) -# What every run ranges over. pep-stance.yaml is deliberately outside it. -SCOPE = "INTENT.md frontmatter, layer.yaml, src/warden/**/*.py" + +SCOPE = ("single repository ops-warden (catalogued in §4); " + "INTENT.md frontmatter, layer.yaml, src/warden/**/*.py") # A12 r2: a version of the standard or companion in any key or value of the # declaration. Keys: anything naming a standard/companion version. Values: a @@ -131,7 +131,8 @@ LAYER_VOCABULARY = {"taxonomy", "tooling", "engine", "staff"} def _fold(value: str) -> str: """ASCII case-fold, per §3 as amended: two spellings of a token are one token.""" - return str(value).strip().encode("ascii", "ignore").decode().lower() + return str(value).strip().translate(str.maketrans( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")) def load_governing_layer() -> str: diff --git a/src/warden/case_receipt_state.py b/src/warden/case_receipt_state.py new file mode 100644 index 0000000..03a53e9 --- /dev/null +++ b/src/warden/case_receipt_state.py @@ -0,0 +1,146 @@ +"""Proposed, pure case receipt reducer for WARDEN-WP-0044-T03. + +This prototype executes nothing and implements no accepted owner adapter API. +Callers MUST durably persist a returned running receipt before a side effect. +Only metadata identifiers/digests enter this model; no command output is accepted. +""" +from __future__ import annotations + +from dataclasses import dataclass, replace +from enum import Enum +import re + +_ID = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}\Z") +_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") + + +class Refused(ValueError): + """A transition requires an independent owner reconciliation or new review.""" + + +class Outcome(str, Enum): + RUNNING = "running" + SUCCESS = "verified-success" + NO_CHANGE = "verified-no-change" + FAILED_BEFORE = "failed-before-change" + FAILED_AFTER = "failed-after-change" + UNCERTAIN = "uncertain" + ROLLBACK_RUNNING = "rollback-running" + ROLLED_BACK = "verified-rolled-back" + ROLLBACK_UNCERTAIN = "rollback-uncertain" + + +@dataclass(frozen=True) +class Pins: + """Exact reviewed case/type/adapter/catalog/acceptance/run-sheet digests.""" + + case: str + case_type: str + adapter: str + catalog: str + acceptance: str + run_sheet: str + + def __post_init__(self) -> None: + if not all(_DIGEST.fullmatch(value) for value in vars(self).values()): + raise Refused("pins require sha256 digests") + + +@dataclass(frozen=True) +class Receipt: + attempt: int + outcome: Outcome + created: tuple[str, ...] = () + + +@dataclass(frozen=True) +class StepState: + step_id: str + owner: str + pins: Pins + allowed_created: tuple[str, ...] = () + retry_safe: bool = False + receipts: tuple[Receipt, ...] = () + + def __post_init__(self) -> None: + if not all(_ID.fullmatch(v) for v in (self.step_id, self.owner, *self.allowed_created)): + raise Refused("only metadata identifiers are supported") + if len(set(self.allowed_created)) != len(self.allowed_created): + raise Refused("duplicate reviewed object identifier") + + @property + def last(self) -> Receipt | None: + return self.receipts[-1] if self.receipts else None + + +def _same_pins(state: StepState, pins: Pins) -> None: + if state.pins != pins: + raise Refused("pins changed; a newly reviewed sheet is required") + + +def _append(state: StepState, outcome: Outcome, created: tuple[str, ...] = ()) -> StepState: + receipt = Receipt(len(state.receipts) + 1, outcome, created) + return replace(state, receipts=(*state.receipts, receipt)) + + +def begin(state: StepState, pins: Pins, *, retry: bool = False) -> StepState: + """Return intent to dispatch. Persist before calling an owner adapter.""" + _same_pins(state, pins) + last = state.last + if last is None: + if retry: + raise Refused("no failed attempt to retry") + elif not (last.outcome is Outcome.FAILED_BEFORE and state.retry_safe and retry): + raise Refused("step cannot dispatch or replay without owner reconciliation") + return _append(state, Outcome.RUNNING) + + +def finish(state: StepState, pins: Pins, outcome: Outcome, + *, created: tuple[str, ...] = ()) -> StepState: + """Accept only an adapter's verified, closed metadata result, never output.""" + _same_pins(state, pins) + if not state.last or state.last.outcome is not Outcome.RUNNING: + raise Refused("no running side effect") + if not isinstance(outcome, Outcome) or outcome not in (Outcome.SUCCESS, Outcome.NO_CHANGE, Outcome.FAILED_BEFORE, + Outcome.FAILED_AFTER, Outcome.UNCERTAIN): + raise Refused("unsupported adapter result") + if len(set(created)) != len(created) or not set(created) <= set(state.allowed_created): + raise Refused("created objects are outside the reviewed case") + if created and outcome not in (Outcome.SUCCESS, Outcome.FAILED_AFTER): + raise Refused("this result cannot claim created objects") + return _append(state, outcome, created) + + +def recover(state: StepState) -> StepState: + """Lost result after persisted dispatch is uncertain, never safe to retry.""" + if state.last and state.last.outcome is Outcome.RUNNING: + return _append(state, Outcome.UNCERTAIN) + if state.last and state.last.outcome is Outcome.ROLLBACK_RUNNING: + return _append(state, Outcome.ROLLBACK_UNCERTAIN, state.last.created) + return state + + +def rollback_objects(state: StepState, pins: Pins) -> tuple[str, ...]: + """Reviewed identifiers only, reverse created order; no shared-object undo.""" + _same_pins(state, pins) + last = state.last + if last is None or last.outcome is Outcome.NO_CHANGE: + return () + if last.outcome is not Outcome.SUCCESS: + raise Refused("rollback needs a verified successful creation receipt") + return tuple(reversed(last.created)) + + +def begin_rollback(state: StepState, pins: Pins) -> StepState: + objects = rollback_objects(state, pins) + if not objects: + raise Refused("no case-created objects to undo") + return _append(state, Outcome.ROLLBACK_RUNNING, objects) + + +def finish_rollback(state: StepState, pins: Pins, *, verified: bool) -> StepState: + _same_pins(state, pins) + if not state.last or state.last.outcome is not Outcome.ROLLBACK_RUNNING: + raise Refused("no running rollback") + outcome = Outcome.ROLLED_BACK if verified else Outcome.ROLLBACK_UNCERTAIN + return _append(state, outcome, state.last.created) diff --git a/src/warden/case_run.py b/src/warden/case_run.py new file mode 100644 index 0000000..7f1cce1 --- /dev/null +++ b/src/warden/case_run.py @@ -0,0 +1,714 @@ +"""Closed local execution kernel v1; no installed adapters or live CLI dispatch. + +An owner must acknowledge each descriptor and provide an independent provenance +verifier. Descriptive case types remain plan-only. Nothing executes shell prose. +""" + +from __future__ import annotations + +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from dataclasses import asdict, dataclass, replace +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import sqlite3 +import threading +import uuid +from typing import Callable + +import yaml +from .case_receipt_state import ( + Outcome, + Pins, + Receipt, + Refused, + StepState, + begin, + finish, + recover, + begin_rollback, + finish_rollback, +) + +IDENTIFIER = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9_.:/@-]{0,191}\Z") +MAX_SOURCE_BYTES = 262144 +MAX_HISTORY = 128 +OUTPUT_LOCK = threading.RLock() + + +def bounded_source(value: bytes): + if type(value) is not bytes or len(value) > MAX_SOURCE_BYTES: + raise Refused("source exceeds bounded input contract") + + +@contextmanager +def contained_output(): + """Discard Python and inherited OS stdout/stderr; serialize process fd changes.""" + with OUTPUT_LOCK: + saved = [] + null = os.open(os.devnull, os.O_WRONLY) + try: + for target in (1, 2): + saved.append((target, os.dup(target))) + os.dup2(null, target) + with redirect_stdout(_Discard()), redirect_stderr(_Discard()): + try: + yield + except Exception: + raise Refused("owner callback failed") from None + except BaseException: + raise OwnerInterrupted() from None + finally: + for target, original in saved: + os.dup2(original, target) + os.close(original) + os.close(null) + + +DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") + + +def digest(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +@dataclass(frozen=True, slots=True) +class Descriptor: + version: str + step: str + owner: str + adapter: str + adapter_source: str + actor: str + lane: str + target_parameter: str + effect: str + allowed_created: tuple[str, ...] + retry_safe: bool + revocation_scopes: tuple[str, ...] + + @classmethod + def decode(cls, source: bytes) -> Descriptor: + bounded_source(source) + try: + obj = json.loads(source) + if set(obj) != set(cls.__dataclass_fields__): + raise ValueError + if not isinstance(obj["allowed_created"], list): + raise ValueError + obj["allowed_created"] = tuple(obj["allowed_created"]) + if not isinstance(obj["revocation_scopes"], list): + raise ValueError + obj["revocation_scopes"] = tuple(obj["revocation_scopes"]) + result = cls(**obj) + identifiers = ( + result.step, + result.owner, + result.adapter, + result.lane, + result.target_parameter, + *result.allowed_created, + ) + if result.version != "warden.case.execution.v1" or not all( + isinstance(v, str) and IDENTIFIER.fullmatch(v) for v in identifiers + ): + raise ValueError + if not DIGEST.fullmatch(result.adapter_source) or result.actor not in ( + "human", + "agent", + ): + raise ValueError + if ( + result.effect not in ("read", "change", "mint") + or type(result.retry_safe) is not bool + ): + raise ValueError + scopes = set(result.revocation_scopes) + if ( + len(scopes) != len(result.revocation_scopes) + or not scopes <= {"parent-session", "broker-grant"} + or "parent-session" not in scopes + ): + raise ValueError + if result.effect == "mint" and "broker-grant" not in scopes: + raise ValueError + if len(result.allowed_created) > 32: + raise ValueError + if len(set(result.allowed_created)) != len(result.allowed_created): + raise ValueError + return result + except (ValueError, TypeError, KeyError): + raise Refused("invalid closed execution descriptor") from None + + +@dataclass(frozen=True, slots=True) +class OwnerResult: + outcome: Outcome + evidence: str + created: tuple[str, ...] = () + revoked: bool = False + session_revocation_evidence: str | None = None + grant_revocation_evidence: str | None = None + attempt_id: str = "" + operation: str = "" + + +@dataclass(frozen=True, slots=True) +class Invocation: + step: str + owner: str + target: str + actor: str + principal: str + lane: str + pins: Pins + attempt_id: str = "" + operation: str = "admission" + + +@dataclass(frozen=True, slots=True) +class Adapter: + reference: str + source: str + invoke: Callable[[Invocation], OwnerResult] + verify: Callable[[Invocation, OwnerResult], bool] + rollback: Callable[[Invocation, tuple[str, ...]], OwnerResult] | None = None + reconcile: Callable[[Invocation], OwnerResult] | None = None + + +class Journal: + """Private SQLite journal plus process lock; transaction commits before dispatch.""" + + def __init__(self, directory: Path): + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + if ( + directory.is_symlink() + or directory.stat().st_uid != os.getuid() + or directory.stat().st_mode & 0o077 + ): + raise Refused("journal directory is not private") + self.directory = directory + for name in ("journal.sqlite", "execution.lock"): + path = directory / name + fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + if os.fstat(fd).st_uid != os.getuid() or os.fstat(fd).st_mode & 0o077: + raise Refused("journal file is not private") + finally: + os.close(fd) + self.db = sqlite3.connect(directory / "journal.sqlite", isolation_level=None) + self.db.execute("PRAGMA synchronous=FULL") + self.db.execute( + "CREATE TABLE IF NOT EXISTS receipts (seq INTEGER PRIMARY KEY, binding TEXT NOT NULL, payload TEXT NOT NULL)" + ) + + @contextmanager + def locked(self): + with (self.directory / "execution.lock").open("rb") as fh: + try: + fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise Refused("case execution already active") from None + try: + yield + finally: + fcntl.flock(fh, fcntl.LOCK_UN) + + def load(self, binding: str, initial: StepState) -> StepState: + row = self.db.execute( + "SELECT payload FROM receipts WHERE binding=? ORDER BY seq DESC LIMIT 1", + (binding,), + ).fetchone() + if row is None: + return initial + obj = json.loads(row[0]) + expected = { + "step_id", + "owner", + "pins", + "allowed_created", + "retry_safe", + "receipts", + "journal_schema", + "evidence_digest", + "session_revoked", + "session_revocation_digest", + "grant_revocation_digest", + "attempt_id", + "operation", + } + if ( + set(obj) != expected + or obj["journal_schema"] != "warden.case.step-journal.v1" + ): + raise Refused("unknown journal receipt schema") + state = StepState( + obj["step_id"], + obj["owner"], + Pins(**obj["pins"]), + tuple(obj["allowed_created"]), + obj["retry_safe"], + tuple( + Receipt(r["attempt"], Outcome(r["outcome"]), tuple(r["created"])) + for r in obj["receipts"] + ), + ) + if ( + state.step_id, + state.owner, + state.pins, + state.allowed_created, + state.retry_safe, + ) != ( + initial.step_id, + initial.owner, + initial.pins, + initial.allowed_created, + initial.retry_safe, + ): + raise Refused("journal pins changed") + return state + + def save( + self, + binding: str, + state: StepState, + evidence: OwnerResult | None = None, + invocation: Invocation | None = None, + ): + self.db.execute("BEGIN IMMEDIATE") + try: + self.db.execute( + "INSERT INTO receipts(binding,payload) VALUES (?,?)", + ( + binding, + json.dumps( + asdict(state) + | { + "journal_schema": "warden.case.step-journal.v1", + "attempt_id": invocation.attempt_id if invocation else None, + "operation": invocation.operation if invocation else None, + "evidence_digest": digest(evidence.evidence.encode()) + if evidence + else None, + "session_revoked": evidence.revoked if evidence else None, + "session_revocation_digest": digest( + evidence.session_revocation_evidence.encode() + ) + if evidence and evidence.session_revocation_evidence + else None, + "grant_revocation_digest": digest( + evidence.grant_revocation_evidence.encode() + ) + if evidence and evidence.grant_revocation_evidence + else None, + }, + separators=(",", ":"), + ), + ), + ) + self.db.execute("COMMIT") + except BaseException: + self.db.execute("ROLLBACK") + raise + + +class Executor: + """Injected fixed owner adapters and provenance verifier; none ship by default. + + evidence verifier must authenticate owner custody/provenance or explicitly + attest attended manual evidence; a bare JSON reference is never sufficient. + It receives purpose, exact source digest, and named evidence reference. + """ + + def __init__( + self, + journal: Journal, + adapters: dict[str, Adapter], + evidence_verifier: Callable[[str, str, str], bool], + ): + self.journal, self.adapters, self.evidence_verifier = ( + journal, + dict(adapters), + evidence_verifier, + ) + self._admitted = {} + + def admit( + self, + case_source: bytes, + type_source: bytes, + descriptor_source: bytes, + catalog_source: bytes, + *, + principal: str, + actor: str, + lane: str, + acceptance_evidence: str, + materialization_evidence: str, + descriptor_evidence: str, + session_evidence: str, + ) -> tuple[Descriptor, Invocation, StepState]: + for source in (case_source, type_source, descriptor_source, catalog_source): + bounded_source(source) + descriptor = Descriptor.decode(descriptor_source) + try: + case = yaml.safe_load(case_source)["spec"] + case_type = yaml.safe_load(type_source)["spec"] + if not isinstance(case, dict) or not isinstance(case_type, dict): + raise ValueError + except (yaml.YAMLError, KeyError, TypeError, ValueError): + raise Refused("invalid case or type source shape") from None + if case.get("status") != "materialized" or not case.get("acceptance"): + raise Refused("case is not accepted and structurally materialized") + if case_type.get("status") not in ("acknowledged", "implemented"): + raise Refused("type is not acknowledged") + if case.get("type") != f"{case_type['id']}@{case_type['version']}": + raise Refused("case type pin mismatch") + if case_type.get("execution_descriptors", {}).get(descriptor.step) != digest( + descriptor_source + ): + raise Refused( + "type has no exact acknowledged machine descriptor; plan only" + ) + for purpose, source, evidence in ( + ("case-acceptance", digest(case_source), acceptance_evidence), + ( + "ops-mason-materialization", + digest(case_source), + materialization_evidence, + ), + ( + "owner-descriptor-acceptance:" + descriptor.owner, + digest(descriptor_source), + descriptor_evidence, + ), + ("type-machine-seam-acceptance", digest(type_source), descriptor_evidence), + ("catalog-routing-acceptance", digest(catalog_source), descriptor_evidence), + ( + f"caller-session:{actor}:{principal}:{lane}", + digest(case_source), + session_evidence, + ), + ): + if ( + not isinstance(evidence, str) + or not IDENTIFIER.fullmatch(evidence) + or self._verify_evidence(purpose, source, evidence) is not True + ): + raise Refused("independent owner evidence not verified") + if ( + not IDENTIFIER.fullmatch(principal) + or actor != descriptor.actor + or lane != descriptor.lane + ): + raise Refused("principal actor or lane mismatch") + if descriptor.effect == "mint" and ( + actor != "human" or os.getenv("WARDEN_AGENT_ID") + ): + raise Refused("agent mint is forbidden") + parameters = case.get("parameters", {}) + declared = { + item["name"]: item + for item in case_type.get("parameters", []) + if isinstance(item, dict) and isinstance(item.get("name"), str) + } + if ( + not isinstance(parameters, dict) + or descriptor.target_parameter not in declared + ): + raise Refused("target parameter is not declared by acknowledged type") + for name in parameters: + definition = declared.get(name) + if ( + definition is None + or definition.get("sensitivity") not in ("public", "operational") + or definition.get("case_settable") is False + ): + raise Refused("unknown, fixed or secret-sensitivity case parameter") + target = parameters.get(descriptor.target_parameter) + if not isinstance(target, str) or not IDENTIFIER.fullmatch(target): + raise Refused("target must be exact case metadata") + adapter = self.adapters.get(descriptor.adapter) + if adapter is None or (adapter.reference, adapter.source) != ( + descriptor.adapter, + descriptor.adapter_source, + ): + raise Refused("unknown adapter or owner source pin") + sheet = json.dumps( + [ + descriptor.step, + descriptor.owner, + target, + actor, + principal, + lane, + acceptance_evidence, + materialization_evidence, + descriptor_evidence, + session_evidence, + ], + separators=(",", ":"), + ).encode() + pins = Pins( + digest(case_source), + digest(type_source), + digest(descriptor_source), + digest(catalog_source), + digest(acceptance_evidence.encode()), + digest(sheet), + ) + invocation = Invocation( + descriptor.step, descriptor.owner, target, actor, principal, lane, pins + ) + initial = StepState( + descriptor.step, + descriptor.owner, + pins, + descriptor.allowed_created, + descriptor.retry_safe, + ) + self._admitted[invocation] = (descriptor, initial, session_evidence) + return descriptor, invocation, initial + + def _result( + self, adapter: Adapter, invocation: Invocation, result: OwnerResult + ) -> OwnerResult: + if type(result) is not OwnerResult or not isinstance(result.outcome, Outcome): + raise Refused("unknown owner result shape") + if type(result.created) is not tuple or not all( + isinstance(v, str) and IDENTIFIER.fullmatch(v) for v in result.created + ): + raise Refused("owner created-object result invalid") + if not isinstance(result.evidence, str) or not IDENTIFIER.fullmatch( + result.evidence + ): + raise Refused("owner evidence identifier invalid") + if adapter.verify(invocation, result) is not True or result.revoked is not True: + raise Refused("owner result or session revocation unverified") + if (result.attempt_id, result.operation) != ( + invocation.attempt_id, + invocation.operation, + ): + raise Refused("owner receipt belongs to another attempt") + descriptor = self._admitted[ + replace(invocation, attempt_id="", operation="admission") + ][0] + proof_fields = { + "parent-session": ( + f"session-revocation:{invocation.lane}:{invocation.operation}:{invocation.attempt_id}", + result.session_revocation_evidence, + ), + "broker-grant": ( + f"grant-revocation:{invocation.lane}:{invocation.operation}:{invocation.attempt_id}", + result.grant_revocation_evidence, + ), + } + proofs = [proof_fields[scope] for scope in descriptor.revocation_scopes] + for purpose, evidence in proofs: + if ( + not isinstance(evidence, str) + or not IDENTIFIER.fullmatch(evidence) + or self._verify_evidence(purpose, invocation.pins.case, evidence) + is not True + ): + raise Refused("independent grant or parent-session revocation absent") + return result + + def _verify_evidence(self, purpose, source, evidence): + try: + with contained_output(): + return self.evidence_verifier(purpose, source, evidence) is True + except Exception: + raise Refused("independent evidence verification failed") from None + + def _admission( + self, descriptor: Descriptor, invocation: Invocation, initial: StepState + ): + if self._admitted.get(invocation, ())[:2] != (descriptor, initial): + raise Refused("execution requires verified admission on this executor") + adapter = self.adapters.get(descriptor.adapter) + if adapter is None or (adapter.reference, adapter.source) != ( + descriptor.adapter, + descriptor.adapter_source, + ): + raise Refused("adapter changed after admission") + session = self._admitted[invocation][2] + if ( + self._verify_evidence( + f"caller-session:{invocation.actor}:{invocation.principal}:{invocation.lane}", + invocation.pins.case, + session, + ) + is not True + ): + raise Refused("caller session no longer verified") + if descriptor.effect == "mint" and os.getenv("WARDEN_AGENT_ID"): + raise Refused("agent mint is forbidden") + return adapter + + def _attempt(self, invocation: Invocation, state: StepState, operation: str): + identity = json.dumps( + [asdict(invocation.pins), operation, state.last.attempt, uuid.uuid4().hex], + separators=(",", ":"), + ).encode() + return replace( + invocation, + attempt_id="attempt-" + hashlib.sha256(identity).hexdigest(), + operation=operation, + ) + + def run( + self, + descriptor: Descriptor, + invocation: Invocation, + initial: StepState, + *, + retry=False, + ) -> StepState: + adapter = self._admission(descriptor, invocation, initial) + with self.journal.locked(): + binding = f"{invocation.step}:{invocation.target}" + state = self.journal.load(binding, initial) + recovered = recover(state) + if recovered != state: + self.journal.save(binding, recovered) + state = recovered + if len(state.receipts) > MAX_HISTORY - 4: + raise Refused("bounded journal history requires owner archival") + running = begin(state, invocation.pins, retry=retry) + dispatched = self._attempt(invocation, running, "run") + self.journal.save(binding, running, invocation=dispatched) + evidence = None + try: + with contained_output(): + result = self._result( + adapter, dispatched, adapter.invoke(dispatched) + ) + completed = finish( + running, invocation.pins, result.outcome, created=result.created + ) + evidence = result + except Exception: + completed = finish(running, invocation.pins, Outcome.UNCERTAIN) + self.journal.save(binding, completed, evidence, dispatched) + return completed + + def rollback( + self, descriptor: Descriptor, invocation: Invocation, initial: StepState + ) -> StepState: + adapter = self._admission(descriptor, invocation, initial) + with self.journal.locked(): + binding = f"{invocation.step}:{invocation.target}" + state = self.journal.load(binding, initial) + if recover(state) != state: + self.journal.save(binding, recover(state)) + raise Refused("lost rollback result requires owner reconciliation") + if adapter.rollback is None: + raise Refused("owner has no reviewed undo adapter") + if len(state.receipts) > MAX_HISTORY - 4: + raise Refused("bounded journal history requires owner archival") + running = begin_rollback(state, invocation.pins) + dispatched = self._attempt(invocation, running, "rollback") + self.journal.save(binding, running, invocation=dispatched) + evidence = None + try: + with contained_output(): + result = self._result( + adapter, + dispatched, + adapter.rollback(dispatched, running.last.created), + ) + verified = result.outcome is Outcome.ROLLED_BACK and not result.created + evidence = result if verified else None + except Exception: + verified = False + completed = finish_rollback(running, invocation.pins, verified=verified) + self.journal.save(binding, completed, evidence, dispatched) + return completed + + def reconcile( + self, descriptor: Descriptor, invocation: Invocation, initial: StepState + ) -> StepState: + """Explicit owner-native state inspection; never redispatches the mutation.""" + adapter = self._admission(descriptor, invocation, initial) + if adapter.reconcile is None: + raise Refused("owner reconciliation adapter absent") + with self.journal.locked(): + binding = f"{invocation.step}:{invocation.target}" + state = recover(self.journal.load(binding, initial)) + if not state.last or state.last.outcome not in ( + Outcome.UNCERTAIN, + Outcome.FAILED_AFTER, + ): + raise Refused("no uncertain mutation to reconcile") + if len(state.receipts) > MAX_HISTORY - 4: + raise Refused("bounded journal history requires owner archival") + state = replace( + state, + receipts=( + *state.receipts, + Receipt(len(state.receipts) + 1, Outcome.UNCERTAIN), + ), + ) + dispatched = self._attempt(invocation, state, "reconcile") + self.journal.save(binding, state, invocation=dispatched) + with contained_output(): + result = self._result( + adapter, dispatched, adapter.reconcile(dispatched) + ) + if result.outcome not in ( + Outcome.NO_CHANGE, + Outcome.SUCCESS, + Outcome.FAILED_BEFORE, + ): + raise Refused("owner did not reconcile target state") + # Validate created-object scope using the same closed result reducer. + finish( + begin( + StepState( + state.step_id, + state.owner, + state.pins, + state.allowed_created, + state.retry_safe, + ), + state.pins, + ), + state.pins, + result.outcome, + created=result.created, + ) + reconciled = StepState( + state.step_id, + state.owner, + state.pins, + state.allowed_created, + state.retry_safe, + ( + *state.receipts, + Receipt(len(state.receipts) + 1, result.outcome, result.created), + ), + ) + self.journal.save(binding, reconciled, result, dispatched) + return reconciled + + +class OwnerInterrupted(KeyboardInterrupt): + """Sanitized interruption preserves persisted running/crash recovery semantics.""" + + def __init__(self): + super().__init__("owner execution interrupted") + + +class _Discard: + """Never retain or forward adapter output, including unbounded malicious text.""" + + def write(self, text): + return len(text) + + def flush(self): + pass diff --git a/tests/test_case_receipt_state.py b/tests/test_case_receipt_state.py new file mode 100644 index 0000000..74ee9ea --- /dev/null +++ b/tests/test_case_receipt_state.py @@ -0,0 +1,124 @@ +"""Offline prototype regression cases; no owner adapter, credential or live use.""" +from dataclasses import FrozenInstanceError, replace +import pytest +from warden.case_receipt_state import ( + Outcome, Pins, Refused, StepState, begin, begin_rollback, finish, + finish_rollback, recover, rollback_objects, +) + + +@pytest.fixture +def pins(): + return Pins(*["sha256:" + character * 64 for character in "abcdef"]) + + +@pytest.fixture +def state(pins): + return StepState("mint-secret", "railiance-platform", pins, ("case-policy", "case-role")) + + +def test_records_running_before_dispatch_without_mutating_history(state, pins): + running = begin(state, pins) + assert state.receipts == () + assert running.last.outcome is Outcome.RUNNING + with pytest.raises(FrozenInstanceError): + running.last.attempt = 8 + + +def test_changed_exact_revision_refuses_dispatch(state, pins): + with pytest.raises(Refused): + begin(state, replace(pins, adapter="sha256:" + "0" * 64)) + + +def test_lost_receipt_becomes_uncertain_and_cannot_retry(state, pins): + resumed = recover(begin(state, pins)) + assert resumed.last.outcome is Outcome.UNCERTAIN + assert len(resumed.receipts) == 2 + with pytest.raises(Refused): + begin(replace(resumed, retry_safe=True), pins, retry=True) + + +@pytest.mark.parametrize("outcome", [Outcome.SUCCESS, Outcome.NO_CHANGE]) +def test_verified_success_or_no_change_never_replayed(state, pins, outcome): + done = finish(begin(state, pins), pins, outcome) + with pytest.raises(Refused): + begin(done, pins, retry=True) + + +def test_retry_requires_verified_no_change_failure_and_safe_adapter(state, pins): + failed = finish(begin(state, pins), pins, Outcome.FAILED_BEFORE) + with pytest.raises(Refused): + begin(failed, pins, retry=True) + safe = replace(failed, retry_safe=True) + with pytest.raises(Refused): + begin(safe, pins) + retry = begin(safe, pins, retry=True) + assert retry.last.outcome is Outcome.RUNNING + assert len(retry.receipts) == 3 + + +def test_failure_after_change_does_not_become_safe_retry_or_rollback(state, pins): + failed = finish(begin(state, pins), pins, Outcome.FAILED_AFTER, created=("case-policy",)) + with pytest.raises(Refused): + begin(replace(failed, retry_safe=True), pins, retry=True) + with pytest.raises(Refused): + rollback_objects(failed, pins) + + +def test_rollback_only_created_not_all_reviewed_objects(state, pins): + done = finish(begin(state, pins), pins, Outcome.SUCCESS, created=("case-role",)) + assert rollback_objects(done, pins) == ("case-role",) + assert "case-policy" not in rollback_objects(done, pins) + undo = begin_rollback(done, pins) + verified = finish_rollback(undo, pins, verified=True) + assert verified.last.outcome is Outcome.ROLLED_BACK + with pytest.raises(Refused): + begin_rollback(verified, pins) + + +def test_rollback_reverse_order_and_uncertain_undo_blocks_replay(state, pins): + done = finish(begin(state, pins), pins, Outcome.SUCCESS, + created=("case-policy", "case-role")) + assert rollback_objects(done, pins) == ("case-role", "case-policy") + uncertain = recover(begin_rollback(done, pins)) + assert uncertain.last.outcome is Outcome.ROLLBACK_UNCERTAIN + with pytest.raises(Refused): + begin_rollback(uncertain, pins) + + +def test_unverified_rollback_cannot_claim_success(state, pins): + done = finish(begin(state, pins), pins, Outcome.SUCCESS, created=("case-role",)) + failed = finish_rollback(begin_rollback(done, pins), pins, verified=False) + assert failed.last.outcome is Outcome.ROLLBACK_UNCERTAIN + + +def test_refuses_shared_objects_or_false_no_change_creation(state, pins): + running = begin(state, pins) + for outcome, created in [(Outcome.SUCCESS, ("shared-group",)), + (Outcome.NO_CHANGE, ("case-policy",)), + (Outcome.FAILED_BEFORE, ("case-policy",)), + (Outcome.SUCCESS, ("case-policy", "case-policy"))]: + with pytest.raises(Refused): + finish(running, pins, outcome, created=created) + + +def test_no_raw_payload_or_output_receipt_interface(state, pins): + with pytest.raises(TypeError): + finish(begin(state, pins), pins, Outcome.SUCCESS, output="sensitive output") + with pytest.raises(Refused): + StepState("step", "owner", pins, ("path with spaces or output",)) + with pytest.raises(Refused): + Pins("not-a-digest", pins.case_type, pins.adapter, pins.catalog, pins.acceptance, pins.run_sheet) + + +def test_out_of_order_result_and_noop_recovery(state, pins): + assert recover(state) is state + with pytest.raises(Refused): + finish(state, pins, Outcome.SUCCESS) + with pytest.raises(Refused): + finish_rollback(state, pins, verified=True) + + +def test_result_must_be_closed_enum_not_string(state, pins): + with pytest.raises(Refused): + finish(begin(state, pins), pins, "verified-success") diff --git a/tests/test_case_run.py b/tests/test_case_run.py new file mode 100644 index 0000000..8125e2c --- /dev/null +++ b/tests/test_case_run.py @@ -0,0 +1,631 @@ +"""Injected owner harness; these receipts are not native owner attestations.""" + +from dataclasses import asdict, replace +import json +import os +import subprocess +import sys +import sqlite3 +import pytest +import yaml +from warden.case_receipt_state import Outcome, Refused +from warden.case_run import Adapter, Descriptor, Executor, Journal, OwnerResult, digest + + +@pytest.fixture +def rig(tmp_path): + desc = Descriptor( + "warden.case.execution.v1", + "mint-secret", + "owner", + "owner/mint@v1", + "sha256:" + "1" * 64, + "human", + "issuer-lane", + "client_id", + "mint", + ("case-policy", "case-role"), + True, + ("parent-session", "broker-grant"), + ) + descriptor = json.dumps(asdict(desc)).encode() + typ = yaml.safe_dump( + { + "spec": { + "id": "test", + "version": "1", + "status": "acknowledged", + "parameters": [ + { + "name": "client_id", + "sensitivity": "operational", + "case_settable": True, + } + ], + "execution_descriptors": {"mint-secret": digest(descriptor)}, + } + } + ).encode() + case = yaml.safe_dump( + { + "spec": { + "id": "case-test", + "type": "test@1", + "status": "materialized", + "acceptance": "acceptance", + "parameters": {"client_id": "fixture-client"}, + } + } + ).encode() + calls = [] + + def invoke(inv): + calls.append(inv) + return bound( + inv, + OwnerResult( + Outcome.SUCCESS, + "owner-receipt", + ("case-policy", "case-role"), + True, + session_revocation_evidence="session-revoked", + grant_revocation_evidence="grant-revoked", + ), + ) + + adapter = Adapter( + "owner/mint@v1", + desc.adapter_source, + invoke, + lambda inv, result: result.evidence == "owner-receipt", + lambda inv, objects: bound( + inv, + OwnerResult( + Outcome.ROLLED_BACK, + "owner-receipt", + (), + True, + session_revocation_evidence="session-revoked", + grant_revocation_evidence="grant-revoked", + ), + ), + lambda inv: bound( + inv, + OwnerResult( + Outcome.NO_CHANGE, + "owner-receipt", + (), + True, + session_revocation_evidence="session-revoked", + grant_revocation_evidence="grant-revoked", + ), + ), + ) + verified = { + ("case-acceptance", digest(case), "acceptance"), + ("ops-mason-materialization", digest(case), "materialization"), + ("owner-descriptor-acceptance:owner", digest(descriptor), "descriptor"), + ("type-machine-seam-acceptance", digest(typ), "descriptor"), + ("catalog-routing-acceptance", digest(b"catalog"), "descriptor"), + ("caller-session:human:operator:issuer-lane", digest(case), "session"), + ("session-revocation:issuer-lane", digest(case), "session-revoked"), + ("grant-revocation:issuer-lane", digest(case), "grant-revoked"), + } + + def verifier(purpose, source, evidence): + if purpose.startswith(("session-revocation:", "grant-revocation:")): + scope, lane, operation, attempt = purpose.split(":") + return ( + source == digest(case) + and lane == "issuer-lane" + and operation in ("run", "rollback", "reconcile") + and evidence + == ("session-" if scope == "session-revocation" else "grant-") + attempt + ) + return (purpose, source, evidence) in verified + + executor = Executor( + Journal(tmp_path / "private"), {"owner/mint@v1": adapter}, verifier + ) + kwargs = dict( + principal="operator", + actor="human", + lane="issuer-lane", + acceptance_evidence="acceptance", + materialization_evidence="materialization", + descriptor_evidence="descriptor", + session_evidence="session", + ) + admitted = executor.admit(case, typ, descriptor, b"catalog", **kwargs) + return (executor, admitted, calls, (case, typ, descriptor, b"catalog"), kwargs) + + +def test_commit_running_is_visible_from_separate_connection_before_dispatch(rig): + executor, args, calls, _, _ = rig + old = executor.adapters[args[0].adapter] + + def inspect(inv): + other = sqlite3.connect(executor.journal.directory / "journal.sqlite") + payload = json.loads( + other.execute( + "SELECT payload FROM receipts ORDER BY seq DESC LIMIT 1" + ).fetchone()[0] + ) + assert payload["receipts"][-1]["outcome"] == "running" + return old.invoke(inv) + + executor.adapters[old.reference] = replace(old, invoke=inspect) + assert executor.run(*args).last.outcome is Outcome.SUCCESS + with pytest.raises(Refused): + executor.run(*args) + assert len(calls) == 1 + + +def test_crash_and_fresh_process_resume_never_duplicates_effect(rig): + executor, args, calls, sources, kwargs = rig + old = executor.adapters[args[0].adapter] + + def crash(inv): + calls.append(inv) + raise KeyboardInterrupt + + executor.adapters[old.reference] = replace(old, invoke=crash) + with pytest.raises(KeyboardInterrupt): + executor.run(*args) + fresh = Executor( + Journal(executor.journal.directory), + {old.reference: old}, + executor.evidence_verifier, + ) + admitted = fresh.admit(*sources, **kwargs) + with pytest.raises(Refused): + fresh.run(*admitted, retry=True) + state = fresh.journal.load("mint-secret:fixture-client", admitted[2]) + assert state.last.outcome is Outcome.UNCERTAIN + assert len(calls) == 1 + + +def test_lost_result_preserved_and_cas_reconciliation_does_not_mint(rig): + executor, args, calls, _, _ = rig + old = executor.adapters[args[0].adapter] + + def lost(inv): + calls.append(inv) + raise TimeoutError("SECRET-HIDDEN") + + executor.adapters[old.reference] = replace(old, invoke=lost) + assert executor.run(*args).last.outcome is Outcome.UNCERTAIN + assert executor.reconcile(*args).last.outcome is Outcome.NO_CHANGE + with pytest.raises(Refused): + executor.run(*args, retry=True) + assert len(calls) == 1 + assert ( + b"SECRET-HIDDEN" + not in (executor.journal.directory / "journal.sqlite").read_bytes() + ) + + +def test_failed_before_change_requires_explicit_retry_and_safe_owner(rig): + executor, args, calls, _, _ = rig + old = executor.adapters[args[0].adapter] + + def no_change(inv): + calls.append(inv) + return bound( + inv, + OwnerResult( + Outcome.FAILED_BEFORE, + "owner-receipt", + (), + True, + session_revocation_evidence="session-revoked", + grant_revocation_evidence="grant-revoked", + ), + ) + + executor.adapters[old.reference] = replace(old, invoke=no_change) + assert executor.run(*args).last.outcome is Outcome.FAILED_BEFORE + with pytest.raises(Refused): + executor.run(*args) + assert executor.run(*args, retry=True).last.outcome is Outcome.FAILED_BEFORE + assert len(calls) == 2 + + +@pytest.mark.parametrize( + "result", + [ + OwnerResult( + Outcome.SUCCESS, + "owner-receipt", + ("shared-policy",), + True, + session_revocation_evidence="session-revoked", + grant_revocation_evidence="grant-revoked", + ), + OwnerResult( + Outcome.SUCCESS, + "owner-receipt", + (), + False, + session_revocation_evidence="session-revoked", + grant_revocation_evidence="grant-revoked", + ), + OwnerResult( + Outcome.SUCCESS, + "unknown-receipt", + (), + True, + session_revocation_evidence="session-revoked", + grant_revocation_evidence="grant-revoked", + ), + {"outcome": "verified-success", "stdout": "SECRET-HIDDEN"}, + ], +) +def test_unknown_secret_payload_created_scope_or_missing_revocation_never_succeeds( + rig, result, capsys +): + executor, args, _, _, _ = rig + old = executor.adapters[args[0].adapter] + + def malicious(inv): + print("SECRET-HIDDEN" * 1000) + return result + + executor.adapters[old.reference] = replace(old, invoke=malicious) + assert executor.run(*args).last.outcome is Outcome.UNCERTAIN + assert capsys.readouterr().out == "" + assert ( + b"SECRET-HIDDEN" + not in (executor.journal.directory / "journal.sqlite").read_bytes() + ) + + +def test_rollback_reverses_only_verified_case_created_objects(rig): + executor, args, _, _, _ = rig + old = executor.adapters[args[0].adapter] + undos = [] + + def undo(inv, objects): + undos.append(objects) + return old.rollback(inv, objects) + + executor.adapters[old.reference] = replace(old, rollback=undo) + executor.run(*args) + assert executor.rollback(*args).last.outcome is Outcome.ROLLED_BACK + assert undos == [("case-role", "case-policy")] + assert "shared-policy" not in undos[0] + + +def test_uncertain_rollback_cannot_replay(rig): + executor, args, _, _, _ = rig + old = executor.adapters[args[0].adapter] + executor.run(*args) + executor.adapters[old.reference] = replace( + old, + rollback=lambda inv, objs: bound( + inv, + OwnerResult( + Outcome.ROLLED_BACK, + "owner-receipt", + (), + False, + session_revocation_evidence="session-revoked", + grant_revocation_evidence="grant-revoked", + ), + ), + ) + assert executor.rollback(*args).last.outcome is Outcome.ROLLBACK_UNCERTAIN + with pytest.raises(Refused): + executor.rollback(*args) + + +@pytest.mark.parametrize( + "change", ["materialization", "type-prose", "actor", "lane", "source", "target"] +) +def test_exact_admission_requires_owner_evidence_and_closed_source_pins(rig, change): + executor, _, _, sources, kwargs = rig + case, typ, descriptor, catalog = sources + kwargs = dict(kwargs) + if change == "materialization": + kwargs["materialization_evidence"] = "bare-json" + if change == "type-prose": + obj = yaml.safe_load(typ) + obj["spec"].pop("execution_descriptors") + typ = yaml.safe_dump(obj).encode() + if change == "actor": + kwargs["actor"] = "agent" + if change == "lane": + kwargs["lane"] = "other-lane" + if change == "source": + obj = json.loads(descriptor) + obj["adapter_source"] = "sha256:" + "2" * 64 + descriptor = json.dumps(obj).encode() + if change == "target": + obj = yaml.safe_load(case) + obj["spec"]["parameters"]["client_id"] = "other-target" + case = yaml.safe_dump(obj).encode() + with pytest.raises(Refused): + executor.admit(case, typ, descriptor, catalog, **kwargs) + + +def test_agent_env_cannot_dispatch_after_human_admission(rig, monkeypatch): + executor, args, calls, _, _ = rig + monkeypatch.setenv("WARDEN_AGENT_ID", "test-agent") + with pytest.raises(Refused): + executor.run(*args) + assert calls == [] + + +def test_forged_invocation_cannot_bypass_admission(rig): + executor, args, calls, _, _ = rig + with pytest.raises(Refused): + executor.run(args[0], replace(args[1], target="other-target"), args[2]) + assert calls == [] + + +def test_journal_rejects_unsafe_directory_and_symlink(tmp_path): + unsafe = tmp_path / "unsafe" + unsafe.mkdir(mode=493) + with pytest.raises(Refused): + Journal(unsafe) + private = tmp_path / "private" + private.mkdir(mode=448) + (private / "journal.sqlite").symlink_to(tmp_path / "outside") + with pytest.raises(OSError): + Journal(private) + + +def test_process_lock_blocks_parallel_dispatch(rig): + executor, args, calls, _, _ = rig + with executor.journal.locked(): + with pytest.raises(Refused): + executor.run(*args) + assert calls == [] + + +def test_changed_catalog_pins_cannot_inherit_old_success(rig): + executor, args, calls, sources, kwargs = rig + executor.run(*args) + with pytest.raises(Refused): + executor.admit(*sources[:3], b"changed-catalog", **kwargs) + assert len(calls) == 1 + + +def test_private_journal_does_not_retain_owner_payload_or_accept_unknown_schema(rig): + executor, args, _, _, _ = rig + executor.run(*args) + row = executor.journal.db.execute( + "SELECT seq,payload FROM receipts ORDER BY seq DESC LIMIT 1" + ).fetchone() + payload = json.loads(row[1]) + assert payload["journal_schema"] == "warden.case.step-journal.v1" + assert payload["evidence_digest"] == digest(b"owner-receipt") + assert payload["session_revoked"] is True + assert "owner-receipt" not in row[1] + payload["stdout"] = "UNTRUSTED-OUTPUT" + executor.journal.db.execute( + "UPDATE receipts SET payload=? WHERE seq=?", (json.dumps(payload), row[0]) + ) + with pytest.raises(Refused): + executor.run(*args) + + +def test_expired_caller_session_cannot_use_cached_admission(rig): + executor, args, calls, _, _ = rig + old = executor.evidence_verifier + executor.evidence_verifier = lambda purpose, source, evidence: ( + False + if purpose.startswith("caller-session:") + else old(purpose, source, evidence) + ) + with pytest.raises(Refused): + executor.run(*args) + assert calls == [] + + +@pytest.mark.parametrize("index", [0, 1, 2, 3]) +def test_source_sizes_are_bounded_before_parsing(rig, index): + executor, _, _, sources, kwargs = rig + oversized = list(sources) + oversized[index] = b"x" * 262145 + with pytest.raises(Refused, match="bounded"): + executor.admit(*oversized, **kwargs) + + +def test_owner_fd_and_inherited_subprocess_output_is_contained(rig, capfd): + executor, args, _, _, _ = rig + old = executor.adapters[args[0].adapter] + + def leaking(inv): + os.write(1, b"SYNTHETIC-SECRET") + os.write(2, b"SYNTHETIC-SECRET") + subprocess.run([sys.executable, "-c", 'print("SYNTHETIC-SECRET")'], check=True) + return old.invoke(inv) + + executor.adapters[old.reference] = replace(old, invoke=leaking) + assert executor.run(*args).last.outcome is Outcome.SUCCESS + observed = capfd.readouterr() + assert "SYNTHETIC-SECRET" not in observed.out + observed.err + + +def test_evidence_provider_output_and_error_never_escape(rig, capfd): + executor, _, calls, sources, kwargs = rig + + def broken(*args): + print("SYNTHETIC-SECRET") + os.write(2, b"SYNTHETIC-SECRET") + raise RuntimeError("SYNTHETIC-SECRET") + + executor.evidence_verifier = broken + with pytest.raises(Refused) as caught: + executor.admit(*sources, **kwargs) + assert "SYNTHETIC-SECRET" not in str(caught.value) + observed = capfd.readouterr() + assert "SYNTHETIC-SECRET" not in observed.out + observed.err + assert calls == [] + + +@pytest.mark.parametrize("missing", ["session", "grant"]) +def test_broker_grant_and_parent_session_have_separate_required_evidence(rig, missing): + executor, args, _, _, _ = rig + old = executor.adapters[args[0].adapter] + result = old.invoke(args[1]) + if missing == "session": + result = replace(result, session_revocation_evidence=None) + else: + result = replace(result, grant_revocation_evidence=None) + executor.adapters[old.reference] = replace(old, invoke=lambda inv: result) + assert executor.run(*args).last.outcome is Outcome.UNCERTAIN + + +def test_mint_descriptor_cannot_omit_broker_grant_scope(rig): + descriptor = json.loads(rig[3][2]) + descriptor["revocation_scopes"] = ["parent-session"] + with pytest.raises(Refused): + Descriptor.decode(json.dumps(descriptor).encode()) + + +def test_unsafe_owner_step_cannot_retry_even_after_verified_failed_before( + rig, tmp_path +): + executor, args, _, sources, kwargs = rig + descriptor = replace(args[0], retry_safe=False) + descriptor_source = json.dumps(asdict(descriptor)).encode() + type_doc = yaml.safe_load(sources[1]) + type_doc["spec"]["execution_descriptors"][descriptor.step] = digest( + descriptor_source + ) + type_source = yaml.safe_dump(type_doc).encode() + old_verifier = executor.evidence_verifier + + def verifier(purpose, source, evidence): + if purpose == "owner-descriptor-acceptance:owner": + return source == digest(descriptor_source) and evidence == "descriptor" + if purpose == "type-machine-seam-acceptance": + return source == digest(type_source) and evidence == "descriptor" + return old_verifier(purpose, source, evidence) + + adapter = executor.adapters[descriptor.adapter] + + def failed(inv): + return bound( + inv, + OwnerResult( + Outcome.FAILED_BEFORE, + "owner-receipt", + (), + True, + "session-revoked", + "grant-revoked", + ), + ) + + fresh = Executor( + Journal(tmp_path / "unsafe-retry"), + {adapter.reference: replace(adapter, invoke=failed)}, + verifier, + ) + admitted = fresh.admit( + sources[0], type_source, descriptor_source, sources[3], **kwargs + ) + assert fresh.run(*admitted).last.outcome is Outcome.FAILED_BEFORE + with pytest.raises(Refused): + fresh.run(*admitted, retry=True) + + +def bound(inv, result): + return replace( + result, + attempt_id=inv.attempt_id, + operation=inv.operation, + session_revocation_evidence=("session-" + inv.attempt_id) + if result.session_revocation_evidence + else None, + grant_revocation_evidence=("grant-" + inv.attempt_id) + if result.grant_revocation_evidence + else None, + ) + + +def test_old_grant_and_session_proofs_cannot_verify_a_new_retry(rig): + executor, args, _, _, _ = rig + adapter = executor.adapters[args[0].adapter] + retained = [] + + def attempts(inv): + if not retained: + result = bound( + inv, + OwnerResult( + Outcome.FAILED_BEFORE, + "owner-receipt", + (), + True, + "session-revoked", + "grant-revoked", + ), + ) + retained.append(result) + return result + # Even a caller rewriting labels cannot authenticate prior revocation + # evidence for the newly persisted attempt identity. + return replace(retained[0], attempt_id=inv.attempt_id, operation=inv.operation) + + executor.adapters[adapter.reference] = replace(adapter, invoke=attempts) + assert executor.run(*args).last.outcome is Outcome.FAILED_BEFORE + assert executor.run(*args, retry=True).last.outcome is Outcome.UNCERTAIN + + +def test_owner_interrupt_payload_is_sanitized_with_running_intent_retained(rig, capfd): + executor, args, _, _, _ = rig + adapter = executor.adapters[args[0].adapter] + + def interrupted(inv): + os.write(2, b"SYNTHETIC-SECRET") + raise KeyboardInterrupt("SYNTHETIC-SECRET") + + executor.adapters[adapter.reference] = replace(adapter, invoke=interrupted) + with pytest.raises(KeyboardInterrupt) as caught: + executor.run(*args) + assert "SYNTHETIC-SECRET" not in str(caught.value) + assert "SYNTHETIC-SECRET" not in "".join(capfd.readouterr()) + assert ( + executor.journal.load("mint-secret:fixture-client", args[2]).last.outcome + is Outcome.RUNNING + ) + + +def test_attempt_identity_changes_with_exact_catalog_revision_and_new_journal( + rig, tmp_path +): + executor, args, calls, sources, kwargs = rig + executor.run(*args) + original_attempt = calls[-1].attempt_id + new_catalog = b"independently-reviewed-catalog-v2" + old_verifier = executor.evidence_verifier + + def verifier(purpose, source, evidence): + if purpose == "catalog-routing-acceptance": + return source == digest(new_catalog) and evidence == "descriptor" + return old_verifier(purpose, source, evidence) + + fresh = Executor(Journal(tmp_path / "new-revision"), executor.adapters, verifier) + revised = fresh.admit(*sources[:3], new_catalog, **kwargs) + assert fresh.run(*revised).last.outcome is Outcome.SUCCESS + assert calls[-1].attempt_id != original_attempt + assert calls[-1].pins.catalog != calls[0].pins.catalog + + +def test_secret_sensitivity_parameter_cannot_reach_admission_or_journal(rig): + executor, _, _, sources, kwargs = rig + type_doc = yaml.safe_load(sources[1]) + type_doc["spec"]["parameters"][0]["sensitivity"] = "secret" + source = yaml.safe_dump(type_doc).encode() + old = executor.evidence_verifier + executor.evidence_verifier = lambda purpose, digest_value, evidence: ( + True + if purpose == "type-machine-seam-acceptance" + else old(purpose, digest_value, evidence) + ) + with pytest.raises(Refused, match="secret-sensitivity"): + executor.admit(sources[0], source, sources[2], sources[3], **kwargs) diff --git a/tests/test_layer_conformance.py b/tests/test_layer_conformance.py index a131324..1ba7165 100644 --- a/tests/test_layer_conformance.py +++ b/tests/test_layer_conformance.py @@ -1,4 +1,4 @@ -"""Layer-model conformance (security-layer-model_v0.4 §5, §11). +"""Layer-model conformance (security-layer-model_v0.8 §5, §11). Two things are checked here. §11 makes one of them mechanical: every direct Tooling client maps to a declared shape. §5.2 asks for the other: the conduit's @@ -317,3 +317,35 @@ def test_the_unknown_cell_is_marked_as_a_declared_gap(): assert "WARDEN-WP-0040" in text else: assert stance["unknown"] == "fail_closed" + + +def test_accepted_v08_run_provenance_and_catalog_scope(): + checker = _checker() + assert "security-layer-model_v0.8.md" in checker.VALIDATED_AGAINST + assert "sha256:3d8298560069ef471fba28d2b1245fb2309328f231dad1a667cdf0ce5a5a7c8b" in checker.VALIDATED_AGAINST + assert "single repository ops-warden (catalogued in §4)" in checker.SCOPE + + +def test_reference_semantics_propagate_identity_through_nested_sequences(): + checker = _checker() + for data in ( + {"nested": {"standard": ["Security Layer Model v0.8"]}}, + {"companion": [["Security Companion 0.3"]]}, + {"standard": {"alternate": ["Security Layer Model v0.7"]}}, + ): + # A dictionary begins a fresh identity key; only sequence descendants + # inherit the immediate standard:/companion: identity in the reference. + if isinstance(data.get("standard"), dict): + assert checker.find_version_pins(data) == [] + else: + assert checker.find_version_pins(data), data + assert checker.find_version_pins({"rationale": "Assented at v0.7; migrated at v0.8."}) == [] + assert checker.find_version_pins({"intent_version": "0.2.0", "schema_version": "0.1"}) == [] + + +def test_ascii_folding_never_discards_non_ascii_letters(): + checker = _checker() + assert checker._fold("Staff") == "staff" + assert checker._fold("Engine") == "engine" + # Deleting non-ASCII bytes would turn this fifth token into Staff. + assert checker._fold("Stαaff") not in checker.LAYER_VOCABULARY diff --git a/tests/test_routing.py b/tests/test_routing.py index d3de0c9..53b4913 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -173,7 +173,7 @@ def test_route_find_layer_declaration_resolves_to_reference_playbook(): assert entry.wiki_ref.endswith( "netkingdom-layer-declaration.md#worker-checklist" ) - assert entry.canon_ref.endswith("security-layer-model_v0.7.md") + assert entry.canon_ref.endswith("security-layer-model_v0.8.md") assert entry.exec_capable is False diff --git a/wiki/playbooks/netkingdom-layer-declaration.md b/wiki/playbooks/netkingdom-layer-declaration.md index 55be51e..24af535 100644 --- a/wiki/playbooks/netkingdom-layer-declaration.md +++ b/wiki/playbooks/netkingdom-layer-declaration.md @@ -11,11 +11,10 @@ reference declaration artifacts. It does not redefine the layer model. ## Worker checklist 1. Read `net-kingdom/SECURITY-COMPANION.md`, then use the accepted - `net-kingdom/canon/standards/security-layer-model_v0.7.md` for the normative - layer, Tooling-contact, and PEP obligations. `v0.8` is proposed, not accepted, - and its acceptance flip is held on amendments A9–A13 (`GH-DEC-2026-019`) — - but those amendments' substance **already governs** through the decision - record that authorises each of them, so declare to the shape below now. + `net-kingdom/canon/standards/security-layer-model_v0.8.md` for the normative + layer, Tooling-contact, and PEP obligations. Exact publication acceptance is + `net-kingdom/docs/security-layer-v0.8-acceptance.md`. Assent to a previous + boundary is provenance; it is not blanket assent to this current text. 2. **Declare in your own `INTENT.md` frontmatter.** That is the declaration (`GH-DEC-2026-017` §1). Use ops-warden's `layer.yaml` as the machine-readable reference *form*, which is a **derived** artifact that must be marked derived, @@ -168,23 +167,23 @@ def find_version_pins(node, where="", identity=False): # walk every parsed key rule"): **not reached** (`GH-DEC-2026-021` §1, A12 r3). A revision cited in prose is provenance, not a pin. The reference detector does not flag a bare `vN.N` outside a `standard:`/`companion:` value, and no allowlist is needed. - A12 r3 is out for assent; if it is rejected in favour of the literal reading, - the reference is widened to any `vN.N` in any value. + Accepted v0.8 carries A12 r3; arbitrary prose versions remain provenance. -**Name the text in force.** While v0.8's acceptance flip is held -(`GH-DEC-2026-019`), `VALIDATED_AGAINST` names the **accepted** text — -`security-layer-model_v0.7.md`, with a net-kingdom commit — plus the decision -records whose rulings the checker enforces beyond v0.7 (`GH-DEC-2026-017`, -`GH-DEC-2026-020`, `GH-DEC-2026-021`), with a gate-house commit recommended -(`GH-DEC-2026-021` §2). Naming the proposed v0.8 states a check against text -that does not yet govern. Change it in the same commit that follows the flip. -ops-warden's constant is the example: +**Name the text in force.** Following the accepted v0.8 flip, +`VALIDATED_AGAINST` names accepted `security-layer-model_v0.8.md` and its exact +publication digest. The output states single-repository scope, the repository's +§4 membership, and the owner-specific declaration/source checks. These are +run provenance, never standard-version pins in standing declarations. The +ops-warden constant is the reference: ```text -net-kingdom/canon/standards/security-layer-model_v0.7.md (net-kingdom@66dc491) -as amended by GH-DEC-2026-017, GH-DEC-2026-020 and GH-DEC-2026-021 (A9-A13, A12 r3; gate-house@39d9287) +net-kingdom/canon/standards/security-layer-model_v0.8.md (accepted; sha256:3d8298560069ef471fba28d2b1245fb2309328f231dad1a667cdf0ce5a5a7c8b) ``` +Old versions remain in rejection fixtures and historical review citations. +They must not be replaced mechanically: those fixtures prove that declaration +pins stay forbidden after the normative text changes. + **Citation.** Cite the ruling by the decision's body section: `GH-DEC-2026-017` §5, statute A12 (now A12 r2). This page's "§5" is canonical (`GH-DEC-2026-020`); the decision's `rationale:` part numbers are a summary and diff --git a/workplans/WARDEN-WP-0044-security-case-follow-through.md b/workplans/WARDEN-WP-0044-security-case-follow-through.md index e5f56b4..b9ef795 100644 --- a/workplans/WARDEN-WP-0044-security-case-follow-through.md +++ b/workplans/WARDEN-WP-0044-security-case-follow-through.md @@ -9,7 +9,7 @@ flavor: implementation owner: claude-code topic_slug: netkingdom created: "2026-10-08" -updated: "2026-10-09" +updated: "2026-10-11" depends_on: [] related_repos: - net-kingdom @@ -142,9 +142,9 @@ reaches the plan unrendered. ```task id: WARDEN-WP-0044-T03 -status: wait +status: progress depends_on: [WARDEN-WP-0044-T02, MASON-WP-0010-T03] -blocking_reason: "The Forgejo case must be materialized (MASON-WP-0010-T03) before a run can be built and proven against it." +blocking_reason: "Local durable execution kernel and injected failure harness implemented. Native acceptance still requires authenticated owner machine descriptors/adapters, actual Forgejo materialization receipt, contained session integration, ordered verification and first native run." priority: high state_hub_task_id: "bcbf6c91-2c04-5c16-ae8c-5d6c06fd6b89" ``` @@ -217,3 +217,37 @@ Let a type contribute reviewed catalog entries once, so that every case of that type routes without a new catalog edit. Cases add parameter values, not routes. `warden route find` should be able to answer "which SecurityCaseType covers this need?" before anyone builds by hand. + + +2026-10-10 consolidation source prototype (not an accepted owner adapter API): +`src/warden/case_receipt_state.py` provides a pure immutable receipt reducer, +with exact case/type/adapter/catalog/acceptance/run-sheet pins, running intent +before dispatch, uncertain recovery after a lost result, no success replay, +explicit retry-safe failed-before-change retry and created-only reverse undo. +It executes nothing and accepts no command output. The caller must durably +persist running before any future side effect; storage/authenticated owner +receipts/live adapters are not implemented. Focused offline tests cover changed +pins, lost receipts, retry/refusal, uncertain rollback and shared-object +preservation. Source proposal: `docs/security-case-execution-proposal.md`. + +T03 remains open: typed owner execution/receipt contracts require review, the +Forgejo structural materialization needs owner receipts, and CLI dispatch, +contained sessions, native verification and provenance remain unimplemented. +There is a circularity to resolve with net-kingdom/ops-mason: materialization +cannot require mint-secret if mint-secret is itself a follow-through step whose +planning requires materialized. No case status is changed by this prototype. + + +2026-10-11: the exact execution-design memo version1 has confirmed native human +acceptance (approval e46b149b-ec3c-4b3b-98a7-dcb4934448ca). The approved proposal +bytes remain unchanged. `src/warden/case_run.py` now implements a closed v1 local +execution seam, independent admission/evidence callbacks, private transactional +SQLite receipt storage, process locking, running-before-dispatch, crash recovery +to uncertainty, explicit safe retry, separately required broker-grant and parent-session revocation +proofs, native owner reconciliation and created-only +reverse rollback. Thirty-seven kernel tests and fourteen reducer tests pass; +ruff checks pass. See `docs/security-case-execution-kernel-v1.md` for exact scope +and receipt provenance. No installed native adapter or live CLI execution is +claimed. Current prose types stay plan-only; acknowledged descriptor/type machine +seams and authenticated owner/caller receipts are still required. T03 progresses +for implementation but cannot finish before ordered native Forgejo acceptance.