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
This commit is contained in:
parent
3eb648846e
commit
1b8261917e
17 changed files with 2163 additions and 34 deletions
|
|
@ -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/`.
|
||||
|
|
|
|||
93
docs/evidence/2026-10-11-security-layer-v08-migration.json
Normal file
93
docs/evidence/2026-10-11-security-layer-v08-migration.json
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
86
docs/security-case-execution-kernel-v1.md
Normal file
86
docs/security-case-execution-kernel-v1.md
Normal file
|
|
@ -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.
|
||||
136
docs/security-case-execution-proposal.md
Normal file
136
docs/security-case-execution-proposal.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
146
src/warden/case_receipt_state.py
Normal file
146
src/warden/case_receipt_state.py
Normal file
|
|
@ -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)
|
||||
714
src/warden/case_run.py
Normal file
714
src/warden/case_run.py
Normal file
|
|
@ -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
|
||||
124
tests/test_case_receipt_state.py
Normal file
124
tests/test_case_receipt_state.py
Normal file
|
|
@ -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")
|
||||
631
tests/test_case_run.py
Normal file
631
tests/test_case_run.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue