diff --git a/.agnir/decisions.md b/.agnir/decisions.md index 08c8211..5a134ff 100644 --- a/.agnir/decisions.md +++ b/.agnir/decisions.md @@ -207,3 +207,23 @@ - Final repair CI run `35317245771` passed all product-check jobs; final acceptance-checkpoint run `35317410100` also passed all product-check jobs. - Continuity-only commits after `f9026f7e3db4db8956cfc88ba1990daf0757a011` may advance authoritative `main` without changing the accepted Plugin tree. External submission evidence MUST identify the exact submitted Plugin subject, not merely a moving branch. - The immutable released Repository Preview `v0.2.0-preview.1` remains a different historical subject and is not rewritten. + + +## 2026-09-20 — Implement trusted runtime safety and native release acceptance + +- The Principal authorized repair to release standard, while OpenAI Platform publication + remains paused. This does not authorize live Cloudflare deployment or a public release. +- Required authorization comes from trusted provider/operation policy. Required verification + comes from trusted planning. Model declarations cannot waive either or attest their own + success; the ChatGPT bridge requires separately obtained trusted verification receipts. +- Filesystem checkpointing is preflighted, CAS-protected, process-locked and recoverable. + Runtime journals/effect markers are private working-copy machinery, not canonical memory. + Unresolved operations must be preserved/reconciled before moving or publishing a Project. +- Uncertain external effects are independently observed/reconciled, never blindly replayed. + Kernel session/receipt controls are not a universal distributed exactly-once guarantee. +- A bounded requirement-to-test matrix separates reference-runtime safety, native installation + and discovery, and real model-driven Skill behavior. Full acceptance needs same-candidate + positive and negative host observations. Future MCP/live transport remains separately scoped. +- Existing Project identity, lineage, selector, compatibility and released Preview tags remain + unchanged. The unpublished 0.2.0 candidate may change; older exact package/ZIP receipts stay + historical and cannot certify the new bytes. diff --git a/.agnir/evidence/2026-09-20-runtime-readiness-repair.md b/.agnir/evidence/2026-09-20-runtime-readiness-repair.md new file mode 100644 index 0000000..507420e --- /dev/null +++ b/.agnir/evidence/2026-09-20-runtime-readiness-repair.md @@ -0,0 +1,145 @@ +# Svif runtime and local-readiness repair — 2026-09-20 + +Status: implemented and locally regression-tested candidate; remote/native final receipts +are recorded after observation. Not a release or public submission. + +## Source and authority + +- Principal request: implement the observed functional gaps to release standard; prioritize + native local installation/effectiveness and bounded functional completion. +- Canonical baseline: `960526544da308ea8b0eb1d325b26609487ab856`. +- That baseline retains product code audited at `fe7788bd53d3a240f663860133b741799d0470e3`. +- Temporary staging branch: `fix/local-release-readiness`. Canonical target remains `main`; + Project/lineage/profile/VCS declarations are preserved, not relabeled for staging. +- Exact source export run `35507150590`, artifact `10604905339`, SHA-256 + `2d9a18d5e9df42194cc26256915c03a7aa07422117a6598233dafeaabe48ddbb`. + Every extracted tracked file was verified against its exported Git blob identity before + changes or tests. The local workspace Git commit is only test bookkeeping, not source authority. + +## Implemented changes + +1. Mandatory provider-owned capability policy and trusted verification requirements, + including advisory model-result separation and rejection of fabricated effect receipts. +2. Complete checkpoint preflight, snapshot CAS, contained no-follow I/O, process/thread + locks, private write-ahead intent, rollback on normal failure and coherent recovery + on restart. Named errors preserve blockers and prevent partial success claims. +3. Single-use operation sessions, durable replay checks, pre-actuation uncertainty marker + and independent effect reconciliation without redeployment. +4. Source-preserving atomic archive builder with negative path/link/size/I/O tests. +5. Installed Skill guidance aligned with trusted-policy and recovery semantics, without + packaging another runtime. Native Codex acceptance harness and scoped completion matrix. + +## Local verification observed + +The exact source snapshot first passed the original 87 tests. After repairs, the suite +passes **126 tests** under Python 3.13.5 in the isolated Linux execution environment: + +```sh +PYTHONPATH=src python -B -m unittest discover -s tests -q +python checks/check_repository.py +python conformance/check_contracts.py +``` + +The two structural checks also pass. The new regression tests execute the actual kernel, +bridge and filesystem adapter; they are not solely literal checks of Skill prose. + +- `test_release_safety.py`: authority omission/downgrade, injected grants, untrusted success + declarations, required-verifier coverage, non-effect failures, foreign/reused sessions, + failed observation, stale/invalid checkpoints before actuation, operation replay and + durable uncertain-effect recovery on all three supported Agnir lines. +- `test_agnir_transactions.py`: all-role preflight; failures after each of four publication + writes; actual `os._exit(71)` and recovery in a different Python process; interrupted + rollback; conflicting edits/corrupt journal; concurrent reader/writer; stale revision; + exact retry; symlink/hardlink/alias/path protection across 0.1/0.2/1.0. +- Archive tests: unsafe destination/source, missing files, size/count/depth limits, + prior archive preservation and cleanup after injected I/O failure. +- Harness tests only validate the checker, and are not counted as native host evidence. + +All provider transport is dummy, credential-free and non-production. No live deployment, +secret retrieval, real user-machine modification or public publication was performed. + +## Preliminary native host observation + +Probe run `35507822026`, source `74c2850f08dd9e15d1ff7c0143fb2f3ad379bec1`, +artifact `10603907785` (digest +`2bb14a1fdf4b2016fe70d9a1eca758c9f10cb01f24cb379705a1d12b3c0a4a9e`) used +native Codex `0.155.1` with isolated CODEX_HOME on an Ubuntu runner. Native marketplace +registration and `plugin add svif@svif --json` succeeded; `plugin list --json` reported +Svif 0.2.0 installed=true and enabled=true. The fixture used no model credentials. +This probe used the earlier package bytes and did not verify final-candidate Skill +behavior. Final-candidate exact-byte native install/discovery needs its own receipt. + +## Explicit remaining boundaries + +- Candidate remote multi-platform CI and final native install/discovery results must be + freshly observed. Record failures/repairs, not retrospective passing assumptions. +- Actual authenticated model execution of the final Skill, checkpoint, fresh LLM-context + recovery, idempotency and the negative native-host scenarios remain release gates. +- The adapter's coherent snapshot guarantee requires cooperating API readers; arbitrary + raw file readers must block on pending markers. Windows trusted-root and local-storage + constraints are explicit in `spec/RUNTIME_SAFETY.md`. +- Cloudflare's production transport and remote MCP wrapper are not added by this repair. +- Preview.1 remains immutable. The old 0.2.0 package ZIP is not the new Skill candidate. + +## Cross-platform candidate repair and acceptance + +The exact first candidate tree `c3c642e7ea0c9783f6f3f7a6efd8da016e3ff339` +passed 126 tests plus both checks in source-materialization run `35509048052`. +The runner's attempt to push a workflow-changing staging commit was refused by its +workflow permission; no test failure was hidden. The verified tree was then published +through the authorized connector as `d0f90a6505509b76776b5d4bf0c4b0249c129efb`. +Temporary transfer/probe files are absent from the candidate. PR #11 carries the repair. + +Initial PR run `35509114938` passed both Linux runtime jobs, repository/contract checks +and both native installation jobs. It exposed two cross-platform defects: Windows +native separators were incorrectly rejected by the path guard; macOS case-insensitive +storage made the collision test overwrite the existing manifest instead of presenting +two names. Both were repaired, not bypassed. The portable collision test now models +both member names without destroying the physical source and validates real metadata; +explicit native relative/absolute/ADS/escape tests were added. Local suite now passes +128 tests, plus both checks. + +Verified repair source: `225e535e32a18bf8db2bfc7d76efe6bed9378e97`. +Verified full tree: `12542123c9ab47e5e938f8db8fd8a7d36f28a513`. +Plugin tree: `7cc90517013306181a4df2238f849b85cf716665` (unchanged by platform fixes). + +PR run `35509417968` passed all eight jobs: + +- Windows 2022 / Python 3.12 runtime: `106074610910`. +- Ubuntu 24.04 / Python 3.12 runtime: `106074611029`. +- Ubuntu 24.04 / Python 3.13 runtime: `106074610988`. +- macOS 14 / Python 3.12 runtime: `106074610995`. +- repository integrity: `106074610942`; portable contracts: `106074610934`. +- native installation Ubuntu: `106074610931`; macOS: `106074610959`. + +Native run artifacts: Ubuntu `10604694096`, digest +`91066b1a1c725dc41763061cf4726816688906b9fb9ae061905a39274c2aeac8`; +macOS `10603999328`, digest +`931d561a202617d58249f197c334ce21070701d4ecd331d93f03d354eaabe8d7`. + +The earlier same-Plugin native receipts were independently downloaded and inspected: +Ubuntu artifact `10604249612` and macOS artifact `10604254767` from run `35509114938`. +Their reports identify Codex 0.155.1, installed+enabled Svif, exact cached `svif:svif` +discovery, no discovery errors, and installed file hashes matching the reviewed source. +The package-file-map digest on both is +`55d63f208e230b34c5f8b37a4543c5deb37d8978cfbb85013761b45b7a9d23c6`. +This digest is SHA-256 of sorted JSON containing per-file SHA-256 values, not a Git +subtree or ZIP digest. Native logs use the PR synthetic-merge checkout revision; use +package hashes/tree equivalence, not a moving branch, to establish the installed subject. + +Both reports explicitly say `model_exercise: not-run` and +`complete_release_acceptance: false`. No authenticated model task or desktop GUI run +has been performed. The actual task/checkpoint/fresh-LLM-context and negative host gates +remain open; the passing native installation layer must not be generalized beyond it. +A later continuity-only checkpoint may advance the candidate without changing product +code or this Plugin tree. Fresh checkpoint CI/main integration must still be observed. + +## PR identity reconciliation + +Fresh GitHub readback confirms this work is PR #11 on `fix/local-release-readiness`. +Earlier PR #10 is a different implementation candidate on `fix/local-readiness` at +`043464c6883518fb626bb0ea8e75020be42faf17`; its own evidence records 109 local tests +and no full native-model acceptance. A resume-time PR-number mix-up was corrected in +these records and the PR descriptions. No alternate-candidate source was discarded. +Do not merge PR #10 automatically over the continued repair; review any useful delta +separately before retiring that historical preparation ref. diff --git a/.agnir/next-actions.md b/.agnir/next-actions.md index eca4570..73604e1 100644 --- a/.agnir/next-actions.md +++ b/.agnir/next-actions.md @@ -1,29 +1,57 @@ # Svif Next Actions -## Active priority: local effectiveness and functional completion - -The Principal has paused OpenAI Platform publication. Confirm local installation/effectiveness and complete Svif's existing functionality before returning to distribution paperwork. Current `0.2.0` is not signed off as locally effective or feature-complete. Do not repeat the superseded conclusion that only publisher prerequisites remain. - -1. **Repair the reproduced authority and continuity blockers.** At audited source `fe7788bd53d3a240f663860133b741799d0470e3`, omitted/null/empty model-supplied authority classes bypassed protected delivery in the real Orchestrator + ChatGPT bridge + Cloudflare provider using fake transport; invalid Decisions updates partially changed State/Next Actions before checkpoint failure; evidence-child symlinks loaded a dummy outside-root file. Repair using trusted provider/operation authority policy, full checkpoint preflight/coherent publication and recovery, and resolved-path containment for every read/write. Add executable regression tests across Agnir `0.1`, `0.2`, and `1.0`, not just Skill-text markers. -2. **Close the verification-completion gap and finish the requirement-to-test audit.** A failed non-effectful verification result could still checkpoint a completion State/Next update. Define required verification from trusted operation context, preserve legitimate not-applicable cases, and prevent failed required verification from being recorded as successful completion. Check current CORE, Project Binding, Evidence, Capability Adapter, software-delivery and Skill commitments against implementation and positive/negative tests. The current findings are a targeted audit, not an exhaustive defect list. Do not add MCP merely to manufacture a completion gate. -3. **Run same-revision native local installation/effectiveness acceptance.** Freeze a reviewed local candidate without moving released tags. Use an isolated Codex CLI home or a real ChatGPT desktop/Codex local Project, record host/version and exact installed package revision, and verify installed + enabled + actual Skill discovery. Start from an ordinary Project without Agnir; perform a concrete file task, verify its exact content, checkpoint, then use a genuinely new session without prior transcript to recover the result and next action. Re-run on an existing Project to prove identity/instruction preservation and idempotency. Exercise broken discovery, another Continuity Provider, failed verification, missing authority and unavailable observation as negative cases. Archive registration, ZIP extraction, Python-provider tests and old Preview receipts cannot substitute for this evidence. -4. **Keep both acceptance conclusions explicit.** Record (a) local host installation and actual effect, and (b) completion of the bounded Skill-first MVP and applicable runtime commitments. Clearly separate optional future integrations from defects in already-promised behavior. Do not mark either gate passed without its own evidence. The current executor could retrieve the ZIP and execute blob-verified Python modules, but had no installed Codex binary and no access to the Principal's local host; current-version native installation remains unobserved. - -Reproduction details and observed results: `.agnir/evidence/2026-09-18-public-submission-candidate-audit.md`, 2026-09-20 local-readiness audit. All newly reported gaps are open; this checkpoint changes continuity only, not product code. +## Active priority: complete real local effectiveness acceptance + +The Principal has paused OpenAI Platform publication. The implemented runtime repairs +and native install/discovery now have passing evidence; do not repeat the old claim +that the four audit findings are unrepaired or that publisher prerequisites are the P0. +Full current-version Skill effectiveness and whole-release sign-off remain open. + +1. **Complete same-candidate authenticated native behavior.** Use the repaired Plugin + tree `7cc90517013306181a4df2238f849b85cf716665` from a fixed reviewed checkout. + `checks/check_local_install.py --output ` verifies real install, + enabled state, every installed file and native Skill discovery. That no-auth layer + passed on Linux/macOS with Codex 0.155.1 in PR run `35509417968`. + Use the operator's authorized Codex login in the isolated CODEX_HOME, then run the + same harness with `--exercise`. It performs an ordinary Project task/checkpoint, + fresh process + new conversation recovery without an old transcript, and unchanged + reuse of an existing Project. Do not copy, request or commit secret token values. +2. **Exercise all same-candidate negative host scenarios.** Follow + `conformance/RELEASE_READINESS.md`: broken discovery, other Continuity Provider, + identity/version mismatch, failing required checks, absent authority, missing + observation and interrupted write markers. Record prompts, actual tool behavior, + initial/resulting file hashes, host/version and classification. Kernel tests do not + substitute for installed-Skill adherence. Repair any observed failure before sign-off. +3. **Preserve exact integration and release evidence.** Tested source + `225e535e32a18bf8db2bfc7d76efe6bed9378e97` / tree + `12542123c9ab47e5e938f8db8fd8a7d36f28a513` belongs to PR #11. Verify the final + continuity-checkpoint CI and authoritative main after integration, and preserve + the exact Plugin tree. Evidence is `.agnir/evidence/2026-09-20-runtime-readiness-repair.md`. + Keep the explicit trusted-integration/filesystem limits in `spec/RUNTIME_SAFETY.md`. + Do not mark full release acceptance passed while required native evidence is missing. ## Deferred public/personal ChatGPT path -The **public/personal ChatGPT path** is paused, not abandoned. The universal Plugins Directory and a real **individual-user ChatGPT surface**, with **ChatGPT Web** as a first-class target, remain the separate mature distribution obligation. Resume OpenAI Platform submission or Publish only after a later explicit Principal instruction; neither is needed to perform local acceptance. - -Historical package-only candidate receipts remain valid: source `f9026f7e3db4db8956cfc88ba1990daf0757a011`, Plugin tree `5ab4b6147dbd096c052f042b23e37f0ec39f7091`, inner ZIP SHA-256 `bc2315562f7bdeb4232aadb9b583a7442f8cd868dbeacd13bb57caf0c785177c`, Actions build `35334437819`, artifact `10542750132` (30-day retention through 2026-10-18). Preserve these as historical packaging evidence, not proof of functional completion or authorization to submit. Archive evidence is `.agnir/evidence/2026-09-18-skills-only-submission-archive.md`. - -## Preserved invariants and historical work - -- **Keep `v0.2.0-preview.1` immutable.** Released commit `2b07b6b5ea0bc8feee59f9f647be9af3069d056e`, annotated tag object `2535cb89426c2d38c2e061948e81954a7c7c26d7`. Preserve its **immutable candidate**, real **Codex CLI** and **ChatGPT desktop/Codex** historical acceptance evidence. New diagnostics do not invalidate what was observed, but must not be generalized into an all-path safety certification. -- The released Preview.1 and current Skill founding bootstrap use Agnir Core/profile `0.1`. Future changes require a separately validated new distribution; existing Project compatibility must not be silently changed. -- Svif remains product line `0.2` / `project-binding/0.2`. Repository self-host remains Core/profile `1.0` / `repository-filesystem/1.0`, while historical adapter paths remain supported. -- Preserve Project identity `urn:svif:project:svif-core`, lineage `urn:svif:lineage:authoritative`, selector `refs/heads/main`, and all durable memory locators. Logical lineage identity != VCS selector != commit/checkpoint receipt. -- Accepted Agnir promotion and brand integration are complete. The Agnir downstream-adoption handoff is already recorded; do not reopen those gates. Preserve canonical approved brand assets and bilingual README entry semantics. -- Live Cloudflare delivery stays disabled unless explicitly authorized. All audit/reproduction fixtures are credential-free and non-production. -- Historical PR #3 stays closed unmerged. `feature/agnir-core-0.2-validation` tip `d42489f72cc8985d353ccbf2f9b6ae7249fe6480` is archived in `history/BRANCH_ARCHIVE.md`; physical ref cleanup is lower priority and must not revive it as active architecture. -- Repository-managed Agnir memory remains canonical; source/staging copies are not automatic target truth. Keep Orchestrator + Continuity Provider + Execution Surface + Capability Provider as the product architecture. +The **public/personal ChatGPT path** remains paused, not abandoned. The universal Plugins Directory and a real **individual-user ChatGPT surface**, with **ChatGPT Web** +as a first-class target, are a separate mature distribution obligation. Submission +or Publish needs a later explicit Principal instruction; neither is needed for local tests. +Old submission ZIP/tree/artifact receipts remain historical and must not be reused as +proof of the changed Skill. No new version tag or public release is authorized here. + +## Preserved invariants + +- Keep `v0.2.0-preview.1` immutable at `2b07b6b5ea0bc8feee59f9f647be9af3069d056e`. + Preserve its **immutable candidate**, real **Codex CLI** and **ChatGPT desktop/Codex** + historical acceptance evidence; it cannot certify the changed current candidate. +- Preserve Project identity, Core/profile 1.0 self-host, 0.1/0.2/1.0 adapter support, + current 0.1 first-use bootstrap, logical lineage, VCS selector and durable locators. + Installing new code is not authorization to silently migrate an existing Project. +- Preserve accepted Agnir promotion/adoption, canonical brand and bilingual entry semantics. +- Live Cloudflare delivery stays disabled. All committed regression/CI fixtures are dummy, + credential-free and non-production. Optional future integrations stay separately scoped. +- Earlier alternative PR #10 at `043464c6883518fb626bb0ea8e75020be42faf17` remains intact; + review any unique delta separately, never merge it automatically over PR #11. +- Historical PR #3 stays closed unmerged and its archived validation ref is not a dependency. + Retire completed temporary refs after recording their final tips; main remains sole authority. +- Repository-managed Agnir memory is canonical. Orchestrator, Continuity Provider, + Execution Surface and Capability Provider remain the product architecture. diff --git a/.agnir/state.md b/.agnir/state.md index d002b51..1356b33 100644 --- a/.agnir/state.md +++ b/.agnir/state.md @@ -1,53 +1,77 @@ # Svif Current State -Svif is the authoritative active **Project orchestration product** in `iorLab/svif`. The former `iorLab/svif-cloudflare-reference` project is retired. Repository-managed Agnir continuity on `main` remains canonical. +Svif is the authoritative active **Project orchestration product** in `iorLab/svif`. +The former `iorLab/svif-cloudflare-reference` project is retired. Repository-managed +Agnir continuity on `main` remains canonical; staging is not a second authority. -## Active direction — local effectiveness and functional readiness first +## Active direction and verdict — 2026-09-20 -The Principal explicitly paused OpenAI Platform publication and requested two confirmations: (1) Svif can be installed locally and actually takes effect; (2) Svif's functionality is developed and complete. Publisher prerequisites and portal submission are no longer the active P0. Public publication requires a later explicit resumption instruction. +The Principal requested release-standard functional repair and local effectiveness; +OpenAI Platform submission/Publish remains paused until an explicit new instruction. -**Current verdict: neither current-version local effectiveness nor whole-product functional completion is signed off.** Historical package/CI acceptance is not withdrawn as a historical observation, but it does not establish either requested conclusion. The earlier assumption that only publisher/account blockers remain is superseded by the functional findings below. +**The four reproduced runtime blockers are repaired and cross-platform regression +checks pass. Native Codex installation, enablement, exact installed bytes and Skill +discovery pass on Linux and macOS. Full model-driven effectiveness and whole-product +release acceptance remain open; installation alone does not close them.** -## Audited subject and evidence levels +## Verified repair subject -- Audited authoritative source: `fe7788bd53d3a240f663860133b741799d0470e3`. -- Svif product line remains `0.2`; Project Binding, Software Delivery, Capability Adapter and Evidence Record remain their `0.2` contracts. -- Active source/package version remains unpublished `0.2.0`. -- The package-only accepted Plugin subtree remains `5ab4b6147dbd096c052f042b23e37f0ec39f7091`, materialized by `f9026f7e3db4db8956cfc88ba1990daf0757a011`. No product or Plugin source is changed by this readiness checkpoint. -- The actual `svif-0.2.0.zip` was retrieved from Actions artifact `10542750132`. Independent extraction verified CRC, all five member Git blob identities, and inner ZIP SHA-256 `bc2315562f7bdeb4232aadb9b583a7442f8cd868dbeacd13bb57caf0c785177c`. This proves package identity/integrity, not native installation or activation. -- Released **Plugin MVP** / Repository Preview `v0.2.0-preview.1` remains immutable at `2b07b6b5ea0bc8feee59f9f647be9af3069d056e`, annotated tag object `2535cb89426c2d38c2e061948e81954a7c7c26d7`. -- Historical Preview.1 evidence records real Codex CLI and ChatGPT desktop/Codex installation, first-use bootstrap, work, checkpoint and fresh-context recovery. It does not establish current `0.2.0` native-host acceptance or certify all failure paths in Preview.1. -- Baseline run `35334521502` / runtime job `105566170085` reports 87 tests passed. Several Plugin behavior guards, including first-use bootstrap, assert Skill text rather than executing an installed host. Existing green tests do not cover the newly reproduced gaps. -- Four source modules were re-materialized from connector reads, verified byte-for-byte against their Git blob SHAs, and exercised in an isolated Python 3.13.5 environment. The audit did not run the full repository suite locally, did not install a native Codex/desktop host, and did not operate the Principal's computer. -- Basic Agnir load -> checkpoint -> fresh provider load succeeded in isolated fixtures on compatibility lines `0.1`, `0.2`, and `1.0`. This is provider behavior evidence, not a fresh LLM-session or native-client acceptance result. +- Product/package version: unpublished `0.2.0`; product and portable contracts remain `0.2`. +- Baseline: `960526544da308ea8b0eb1d325b26609487ab856`, retaining audited product code + `fe7788bd53d3a240f663860133b741799d0470e3`. +- Reviewed repair source: `225e535e32a18bf8db2bfc7d76efe6bed9378e97`, tree + `12542123c9ab47e5e938f8db8fd8a7d36f28a513`, PR #11. +- Exact repaired Plugin tree: `7cc90517013306181a4df2238f849b85cf716665`. +- Local full suite: 128 tests pass on Linux/Python 3.13.5; repository integrity and + portable contracts pass. Tests execute real code, including process termination. +- PR candidate run `35509417968`: all eight jobs pass, including runtime on Linux + (Python 3.12/3.13), macOS (3.12), Windows (3.12), and native installation on Linux/macOS. +- Native Codex 0.155.1 reports installed+enabled and discovers `svif:svif` at the exact + installed cache path. All installed package file hashes equal the selected source. +- Native no-auth receipts remain distinct from model execution. No current-version + real model task/checkpoint/fresh-LLM-session or desktop GUI acceptance is claimed. -## Open functional blockers — reproduced, not repaired +## Implemented behavior -1. **Trusted authority can be bypassed by omitting the requested authority class.** `ChatGPTExecutionSurface.parse_result()` accepts a model-controlled optional `authority_class`; `Orchestrator.complete()` enforces only that supplied class. For the actual Cloudflare provider operation whose descriptor requires `protected-delivery`, omitted/null/empty values reached the injected fake deploy/observe transport and checkpointed with no trusted grants. Explicit `protected-delivery` correctly blocked the control case. Authority requirements must come from trusted provider/operation policy, not optional result data. -2. **A failed checkpoint can partially publish durable truth.** A valid discovery record with `decisions: null` loads successfully, but an outcome requesting State + Next Actions + Decisions writes State and Next Actions before raising for the unavailable Decisions locator. All three supported compatibility lines reproduced changed State/Next Actions with no new evidence receipt. Preflight, coherent publication and interruption/recovery behavior need repair and executable regression coverage. -3. **Evidence-child symlinks bypass Project-root containment.** The declared evidence directory is contained, but `_read_evidence()` follows its child-file symlinks without rechecking their resolved paths. On all three compatibility lines a dummy file outside the selected Project root was loaded as evidence without an authorized external binding. Only dummy temporary data was used. -4. **Failed non-effectful verification does not prevent a success-state checkpoint.** A result carrying a failed verification record and a completion State/Next update, with no capability request, was checkpointed. Required verification needs a trusted operation-level contract and failure handling; not every trivial non-effectful operation must necessarily require verification. +Provider-owned capability policy cannot be waived by omitted/null/empty model fields. +Trusted operation verification defaults to required; parsed ChatGPT declarations need +independent trusted receipts. Failed required checks cannot publish completion. -Detailed reproduction inputs, controls, results and acceptance criteria are appended under the 2026-09-20 readiness audit in `.agnir/evidence/2026-09-18-public-submission-candidate-audit.md`. This is a targeted audit, not a claim that these are the only remaining defects. +Checkpointing now has full preflight, revision CAS, process/thread locking, contained +no-follow I/O, journaled rollback/restart recovery and durable operation replay checks. +Evidence children and read/write destinations cannot silently follow unauthorized links. +Uncertain external effects persist before actuation and require independent matching +reconciliation rather than blind replay. Windows native paths and case-insensitive +archive collision fixtures are covered by the expanded cross-platform suite. -## Product architecture and scope +The scope/limitations are explicit in `spec/RUNTIME_SAFETY.md`: trusted Python integration, +cooperating transaction readers, local owned filesystems, and no distributed exactly-once +claim. The Skills-only Plugin guides the host; it does not bundle the Python kernel. +Orchestrator + Continuity Provider + Execution Surface + Capability Provider remain +first-class components. `conformance/RELEASE_READINESS.md` maps requirements to evidence. -The four first-class components remain Orchestrator (`src/svif/runtime.py`), Continuity Provider (`src/svif/continuity/agnir.py`), Execution Surface (`src/svif/execution/chatgpt.py`), and Capability Provider (`src/svif/capabilities/cloudflare.py`). +Implementation, failures/repairs and exact CI/native receipts: +`.agnir/evidence/2026-09-20-runtime-readiness-repair.md`. -The installed Skills-only package guides the host through Svif workflow semantics; it does not package the Python Orchestrator. Skill effectiveness and Python runtime enforcement therefore need separate evidence. Remote MCP/App packaging remains an optional increment, not an invented prerequisite for accepting the Skill-first MVP. Live Cloudflare transport/production delivery is not claimed and remains disabled unless explicitly authorized. +## Preserved continuity, release and product boundaries -## Canonical continuity and preserved boundaries +- Project `urn:svif:project:svif-core`; Agnir Core/profile `1.0` / `repository-filesystem/1.0`. +- Lineage `urn:svif:lineage:authoritative`; distinct VCS selector `refs/heads/main`. +- Memory locators remain `.agnir/state.md`, `.agnir/next-actions.md`, `.agnir/decisions.md`, + `.agnir/evidence/`; applied Agnir remains v1.0.0 at `6d16dcfd17b8e9f22fd25804e22b9f8a516d06c3`. +- Adapter support remains 0.1/0.2/1.0. Skill founding bootstrap remains 0.1; no implicit + migration or Agnir operational upgrade is performed by this repair. +- Released **Plugin MVP** / Preview `v0.2.0-preview.1` remains immutable at + `2b07b6b5ea0bc8feee59f9f647be9af3069d056e`, tag object `2535cb89426c2d38c2e061948e81954a7c7c26d7`. + Its historical real-client evidence is preserved, not generalized to the repaired Skill. +- Old Plugin tree `5ab4b6147dbd096c052f042b23e37f0ec39f7091` and its submission ZIP are + historical package evidence only; their bytes do not identify the repaired candidate. +- Accepted Agnir promotion/adoption and approved brand integration remain complete. + Approved visual assets are unchanged. `README.md` and `README.zh-CN.md` stay synchronized; + the public one-line Preview installer still selects the immutable released Preview. +- Live Cloudflare delivery remains disabled. Remote MCP/network transport is separately + scoped, not an invented new prerequisite for the bounded Skill-first MVP. +- No v0.2.0 tag, public release, OpenAI submission, directory availability or protected + production effect has been created. PR #3 remains closed; its archived ref is historical. -- Project identity: `urn:svif:project:svif-core`. -- Agnir Core/profile: `1.0` / `repository-filesystem/1.0`. -- Logical lineage: `urn:svif:lineage:authoritative`; VCS selector: `refs/heads/main`. -- Durable locators remain `.agnir/state.md`, `.agnir/next-actions.md`, `.agnir/decisions.md`, `.agnir/evidence/`. -- Applied Agnir operational release remains `v1.0.0@6d16dcfd17b8e9f22fd25804e22b9f8a516d06c3`; no Agnir upgrade or compatibility promotion is performed here. -- The current adapter retains `0.1` / `0.2` / `1.0` support. The existing Skill founding bootstrap remains Core/profile `0.1`; installing a newer distribution is not permission to relabel an existing Project. -- Accepted 0.2 -> 1.0 repository promotion receipts remain in `.agnir/evidence/2026-09-07-agnir-1.0-main-promotion.md`; the cross-project adoption handoff is already complete in `iorLab/agnir/.agnir/evidence/2026-09-07-svif-agnir-1.0-adoption.md`. -- Approved brand integration remains PR #5 / commit `77ff3d0e8b3d0d691bb47529e065571c17a0aa81`. Approved assets and package-local 128x128 icon are unchanged. Root OpenAI metadata and its compatibility fallback are unchanged. -- `README.md` and `README.zh-CN.md` remain synchronized user/Agent entry points; the release/install entry currently selects immutable Preview.1, not moving `main` or the unaccepted 0.2.0 candidate. -- Historical PR #3 is closed unmerged; its tip `d42489f72cc8985d353ccbf2f9b6ae7249fe6480` remains archived pending physical branch-ref retirement. It is not an active feature or release dependency. -- No `v0.2.0` tag, GitHub Release, OpenAI submission, review, Publish, directory availability, current-version consumer installation, or live provider effect is claimed. - -`.agnir/next-actions.md` is the canonical ordered resume plan. +`.agnir/next-actions.md` is the ordered resume plan. diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index c59f6a0..e60632f 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -10,28 +10,79 @@ permissions: jobs: repository-integrity: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: '3.12' - run: python checks/check_repository.py portable-contracts: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: '3.12' - run: python conformance/check_contracts.py runtime-kernel: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + python: '3.12' + - os: ubuntu-24.04 + python: '3.13' + - os: macos-14 + python: '3.12' + - os: windows-2022 + python: '3.12' + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-python@v5 with: - python-version: "3.12" - - run: PYTHONPATH=src python -m unittest discover -s tests -v + python-version: ${{ matrix.python }} + - run: python -B -m unittest discover -s tests -v + env: + PYTHONPATH: src + + native-installation: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-14] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install exact native host in isolation + run: npm install --prefix "$RUNNER_TEMP/native-codex" @openai/codex@0.155.1 + - name: Validate native install, enablement and actual Skill discovery + run: python -B checks/check_local_install.py --codex "$RUNNER_TEMP/native-codex/node_modules/.bin/codex" --output "$RUNNER_TEMP/svif-native-acceptance" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: svif-native-installation-${{ matrix.os }} + path: | + ${{ runner.temp }}/svif-native-acceptance/latest-report.json + ${{ runner.temp }}/svif-native-acceptance/run-*/app-server.jsonl + ${{ runner.temp }}/svif-native-acceptance/run-*/app-server.stderr + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e2d98c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.svif-runtime/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 728a68d..ca33c05 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -49,7 +49,7 @@ ChatGPT uses the externally driven form. Untrusted model/result payloads cannot A Continuity Provider supplies durable Project truth and resumability. The Svif kernel depends on this interface, not permanently on Agnir. -`src/svif/continuity/agnir.py` is the founding adapter for Agnir Core `0.1` repository/filesystem discovery and checkpoint semantics. +`src/svif/continuity/agnir.py` is the founding adapter for Agnir Core/profile `0.1`, `0.2`, and `1.0` repository/filesystem discovery and recoverable checkpoint semantics. Trusted runtime policy and recovery details are in `spec/RUNTIME_SAFETY.md`. ## 5. Execution Surface @@ -133,4 +133,4 @@ A provider fixture does not justify a separate canonical repository. The generic Orchestrator, Agnir Continuity Provider, ChatGPT bridge, Svif-owned Cloudflare Capability Provider, credential-free founding E2E, and installable Skill-first Plugin MVP now belong to one product tree. -The next target is **test-driven Plugin iteration**: install/use the Skill-first package on real Project work, harden its workflow guidance from failures, and add the remote ChatGPT MCP/App component when it can reuse the existing `Orchestrator.begin()` / `Orchestrator.complete()` boundary without duplicating kernel semantics or weakening authority separation. +Release-readiness acceptance is tracked in `conformance/RELEASE_READINESS.md`; local effectiveness and bounded functional completion have separate evidence gates. The current target is **test-driven Plugin iteration**: install/use the Skill-first package on real Project work, harden its workflow guidance from failures, and add the remote ChatGPT MCP/App component when it can reuse the existing `Orchestrator.begin()` / `Orchestrator.complete()` boundary without duplicating kernel semantics or weakening authority separation. diff --git a/README.md b/README.md index 1bb82bd..bdb5c2a 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,6 @@ Project/ ├── AGENTS.md # [EDIT: add entry only] add Agnir activation locator; preserve existing instructions ├── README.md # [EDIT: add entry only] add ## Agnir Project Instructions; preserve existing content ├── AGNIR.yaml # [ADD] founding Agnir discovery anchor -├── brand/ # approved brand masters, exports, QA, references, and handoff ├── .agnir/ # [ADD] Project-owned durable continuity │ ├── state.md # [ADD] current durable Project truth │ ├── next-actions.md # [ADD] outstanding ordered work for the next Executor @@ -185,6 +184,8 @@ The Repository Preview and the future public personal-user release are different See [`plugin/README.md`](plugin/README.md) for public submission prerequisites, proposed listing metadata, review test cases, repository-marketplace development routes, and evidence boundaries. +Local release acceptance and current limitations: [release-readiness matrix](conformance/RELEASE_READINESS.md). Native installation/Skill discovery, real model task/checkpoint/resume, and runtime safety are separate gates; Platform publication is paused. + ## Repository Structure This tree is the practical map of the repository. It is intentionally selective: it shows the directories and key files that explain where each product responsibility lives, rather than listing every fixture or evidence file. diff --git a/README.zh-CN.md b/README.zh-CN.md index a1b5459..a08f0db 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -65,7 +65,6 @@ Project/ ├── AGENTS.md # [编辑:仅添加入口] 加入 Agnir activation locator;保留原有 instructions ├── README.md # [编辑:仅添加入口] 加入 ## Agnir Project Instructions;保留原有内容 ├── AGNIR.yaml # [新增] founding Agnir discovery anchor -├── brand/ # 已批准品牌主稿、导出、QA、参考与交付说明 ├── .agnir/ # [新增] Project 自己拥有的 durable continuity │ ├── state.md # [新增] 当前仍然成立的 durable Project truth │ ├── next-actions.md # [新增] 下一位 Executor 应继续推进的有序工作 @@ -185,6 +184,8 @@ Repository Preview 与未来面向个人用户的公开版本是不同的分发 公开 submission 前置条件、拟定 listing metadata、review test cases、repository-marketplace 开发路径和 evidence boundary 见 [`plugin/README.md`](plugin/README.md)。 +本地发布验收与当前限制见[发布就绪矩阵](conformance/RELEASE_READINESS.md)。原生安装与 Skill 发现、真实模型任务/checkpoint/新会话恢复、内核安全分别验收;Platform 发布暂停。 + ## 仓库结构 下面这棵树就是仓库的实用导航。它不会穷举每一个测试 fixture 或 evidence 文件,只展开到足以说明“哪个目录负责什么、关键代码在哪里”的层级。 diff --git a/REPOSITORY_TREE.md b/REPOSITORY_TREE.md index 7ea97e9..fff2da4 100644 --- a/REPOSITORY_TREE.md +++ b/REPOSITORY_TREE.md @@ -43,6 +43,7 @@ svif/ # Svif 产品主仓库 │ ├── 2026-09-07-agnir-1.0-main-promotion.md # Agnir 1.0 authoritative publication、fresh verification 与 branch retirement 完整证据 │ ├── 2026-09-18-public-submission-candidate-audit.md # Svif 0.2.0 public-submission candidate 版本边界、package tree、OpenAI packaging 审计与 CI 证据 │ ├── 2026-09-18-skills-only-submission-archive.md # exact Plugin tree 的 deterministic ZIP、archive guards、CI 与外部 submission evidence 边界 +│ ├── 2026-09-20-runtime-readiness-repair.md # 可信策略/事务恢复修复、回归与原生验收层次证据 │ └── checkpoint-2026-08-28-validation-2.md # Validation 2 的持久 checkpoint 记录 │ ├── .github/ # GitHub 托管侧自动化配置 @@ -68,6 +69,7 @@ svif/ # Svif 产品主仓库 │ ├── runtime.py # Orchestrator 核心:begin/run/complete、验证、权限、reconcile、checkpoint │ ├── continuity/ # Continuity Provider 实现 / 适配层 │ │ ├── __init__.py # continuity 子包入口 +│ │ ├── filesystem.py # 含路径约束的 I/O、进程锁与原子单文件写入;adapter 私有实现 │ │ └── agnir.py # founding Agnir repository/filesystem Continuity Provider;兼容 0.1/0.2 并支持当前 stable 1.0 lineage/binding │ ├── execution/ # Execution Surface 桥接层 │ │ ├── __init__.py # execution 子包入口 @@ -95,6 +97,7 @@ svif/ # Svif 产品主仓库 │ └── SKILL.md # Svif 工作流 Skill:首次 Project continuity bootstrap、Agnir discovery/repair、lifecycle、provenance、authority、checkpoint │ ├── spec/ # Svif 内部可移植产品 contracts +│ ├── RUNTIME_SAFETY.md # 可信策略、验证凭据、事务恢复与不确定外部效果边界 │ ├── CORE.md # 编排生命周期、核心 invariants 与 product-kernel 语义 │ ├── PROJECT_BINDING.md # Project 如何声明 continuity / execution / capability bindings │ ├── EVIDENCE.md # Evidence、provenance、subject / target 对齐语义 @@ -109,6 +112,9 @@ svif/ # Svif 产品主仓库 │ └── evidence-record.schema.json # portable EvidenceRecord 的 JSON Schema │ ├── tests/ # 可执行产品实现测试 +│ ├── test_release_safety.py # 真实授权绕过、验证来源、重放与不确定效果回归 +│ ├── test_agnir_transactions.py # 三兼容线事务中断、冲突、并发、路径安全回归 +│ ├── test_local_acceptance_harness.py # 原生验收器自身的路径/状态断言;不是宿主验收 │ ├── test_runtime.py # Orchestrator kernel、authority、verification、lifecycle 行为 │ ├── test_agnir_continuity.py # Agnir Continuity Provider adapter 的 0.1/0.2/1.0 load / lineage / checkpoint / failure 行为 │ ├── test_agnir_stable_migration.py # 当前 Svif Project 对发布版 Agnir v1.0.0 的 self-consumption、0.2→1.0 preservation / identity / lineage guard @@ -124,6 +130,7 @@ svif/ # Svif 产品主仓库 │ └── test_plugin_package.py # Plugin manifest/Skill/package、filesystem failure isolation 与 Agnir activation boundary 验证 │ ├── conformance/ # Portable contracts 的一致性验证,不等同于产品 runtime +│ ├── RELEASE_READINESS.md # 范围明确的发布验收矩阵、本地实测命令、未关闭门槛 │ ├── svif-0.2.md # 当前 Svif 0.2 conformance baseline 的人类可读说明 │ ├── check_contracts.py # 对 schemas / fixtures / portable contract 语义执行检查 │ └── fixtures/ # conformance 输入样例 @@ -136,6 +143,7 @@ svif/ # Svif 产品主仓库 │ └── workspace-scm.json # workspace / source-control capability fixture │ ├── checks/ # 仓库与产品结构完整性检查 +│ ├── check_local_install.py # 隔离原生 Codex 安装/发现与显式 opt-in 真实模型验收 │ ├── build_submission_bundle.py # 从 accepted plugin/ tree 构建 deterministic Skills-only portal ZIP 并输出 SHA-256 │ └── check_repository.py # 防止关键模块、README、Plugin packaging、Agnir activation、canonical topology 漂移 │ @@ -144,6 +152,7 @@ svif/ # Svif 产品主仓库 │ ├── BRANCH_ARCHIVE.md # 已删除分支及最终 tip SHA 的历史索引;main-only 治理记录 │ └── CLOUDFLARE_REFERENCE.md # 已退休独立 Cloudflare reference 仓库的迁移记录 │ +├── .gitignore # Python 缓存与本地事务机械文件不进入源码/分发 ├── AGENTS.md # 最小 Agnir 激活 locator;只指向 README canonical Project Instructions ├── AGNIR.yaml # 当前 stable repository-filesystem/1.0 下发现本 Project Agnir memory 的入口 ├── SVIF.yaml # 本 Project 的 `project-binding/0.2` serialization,并登记 active Plugin artifacts diff --git a/SVIF.yaml b/SVIF.yaml index cfe1713..6d48f92 100644 --- a/SVIF.yaml +++ b/SVIF.yaml @@ -24,6 +24,10 @@ profiles: product: architecture: "ARCHITECTURE.md" runtime: "src/svif/runtime.py" + runtime_safety_contract: "spec/RUNTIME_SAFETY.md" + filesystem_transactions: "src/svif/continuity/filesystem.py" + release_readiness: "conformance/RELEASE_READINESS.md" + local_acceptance: "checks/check_local_install.py" agnir_repository_filesystem_adapter: "src/svif/continuity/agnir.py" chatgpt_execution_bridge: "src/svif/execution/chatgpt.py" cloudflare_workers_capability: "src/svif/capabilities/cloudflare.py" @@ -44,6 +48,10 @@ checks: repository_integrity: "checks/check_repository.py" portable_contracts: "conformance/check_contracts.py" runtime_kernel: "tests/test_runtime.py" + release_safety: "tests/test_release_safety.py" + agnir_transactions: "tests/test_agnir_transactions.py" + local_acceptance_harness: "tests/test_local_acceptance_harness.py" + plugin_submission_bundle: "tests/test_plugin_submission_bundle.py" agnir_continuity: "tests/test_agnir_continuity.py" agnir_stable_migration: "tests/test_agnir_stable_migration.py" chatgpt_surface: "tests/test_chatgpt_surface.py" diff --git a/checks/build_submission_bundle.py b/checks/build_submission_bundle.py index 7909e4d..236fae9 100755 --- a/checks/build_submission_bundle.py +++ b/checks/build_submission_bundle.py @@ -3,6 +3,8 @@ import argparse import hashlib +import os +import tempfile import stat import unicodedata import zipfile @@ -19,12 +21,18 @@ def iter_plugin_files() -> list[Path]: + if PLUGIN_ROOT.is_symlink() or not PLUGIN_ROOT.is_dir(): + raise ValueError("plugin root must be a real directory") files: list[Path] = [] for path in sorted(PLUGIN_ROOT.rglob("*"), key=lambda item: item.as_posix()): if path.is_symlink(): raise ValueError(f"submission package must not contain symlinks: {path.relative_to(PLUGIN_ROOT)}") if path.is_file(): + if path.stat().st_nlink != 1: + raise ValueError("submission source contains a hardlinked file") files.append(path) + elif not path.is_dir(): + raise ValueError("submission source contains a non-regular object") if not files: raise ValueError("submission package is empty") if len(files) > MAX_ENTRIES: @@ -37,7 +45,7 @@ def iter_plugin_files() -> list[Path]: parts = relative.split("/") if relative != relative.strip() or relative.startswith("/") or "\\" in relative: raise ValueError(f"unsafe submission member path: {relative!r}") - if any(part in {"", ".", ".."} for part in parts): + if any(part in {"", ".", ".."} or part != part.strip() or ":" in part for part in parts): raise ValueError(f"unsafe submission member segment: {relative!r}") if len(parts) > MAX_PATH_SEGMENTS: raise ValueError(f"submission member path exceeds {MAX_PATH_SEGMENTS} segments: {relative}") @@ -57,38 +65,38 @@ def iter_plugin_files() -> list[Path]: def build_submission_bundle(output: Path) -> str: + if output.is_symlink(): + raise ValueError("destination must not be a symlink") output = output.resolve() - output.parent.mkdir(parents=True, exist_ok=True) - + if output.is_relative_to(PLUGIN_ROOT.resolve()) or output.suffix.lower() != ".zip": + raise ValueError("destination must be a ZIP outside the source plugin tree") files = iter_plugin_files() - required = { - "plugin.json", - "skills/svif/SKILL.md", - } members = {path.relative_to(PLUGIN_ROOT).as_posix() for path in files} - missing = sorted(required - members) - if missing: + required = {"plugin.json", "skills/svif/SKILL.md", "assets/svif-directory-icon.png"} + if missing := sorted(required - members): raise ValueError(f"submission package missing required files: {missing}") - - if output.exists(): - output.unlink() - - with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: - for path in files: - relative = path.relative_to(PLUGIN_ROOT).as_posix() - info = zipfile.ZipInfo(relative, DOS_EPOCH) - info.compress_type = zipfile.ZIP_DEFLATED - info.create_system = 3 - info.external_attr = (stat.S_IFREG | 0o644) << 16 - archive.writestr(info, path.read_bytes(), compresslevel=9) - - size = output.stat().st_size - if size > MAX_ARCHIVE_BYTES: - output.unlink(missing_ok=True) - raise ValueError(f"submission archive exceeds 100 MB compressed limit: {size} bytes") - - digest = hashlib.sha256(output.read_bytes()).hexdigest() - return digest + output.parent.mkdir(parents=True, exist_ok=True) + fd, temporary_name = tempfile.mkstemp(prefix=".svif-bundle-", suffix=".zip", dir=output.parent) + os.close(fd) + temporary = Path(temporary_name) + try: + with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for path in files: + relative = path.relative_to(PLUGIN_ROOT).as_posix() + if path.is_symlink() or path.stat().st_nlink != 1: + raise ValueError("submission source changed during packaging") + info = zipfile.ZipInfo(relative, DOS_EPOCH) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + info.external_attr = (stat.S_IFREG | 0o644) << 16 + archive.writestr(info, path.read_bytes(), compresslevel=9) + if temporary.stat().st_size > MAX_ARCHIVE_BYTES: + raise ValueError("submission archive exceeds 100 MiB compressed limit") + digest = hashlib.sha256(temporary.read_bytes()).hexdigest() + os.replace(temporary, output) + return digest + finally: + temporary.unlink(missing_ok=True) def main() -> None: diff --git a/checks/check_local_install.py b/checks/check_local_install.py new file mode 100644 index 0000000..18406cf --- /dev/null +++ b/checks/check_local_install.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Native Codex acceptance with separate installation and model-exercise verdicts. + +This is a maintainer test, NOT another installer or Svif runtime. It delegates +installation and skill discovery to the real Codex CLI/app-server. All writes +are confined to an explicitly selected, isolated acceptance directory. The +optional --exercise uses the user's authorized Codex account and may use quota; +credentials are never copied from the normal home or included in receipts. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import queue +import shutil +import subprocess +import sys +import threading +import time +import uuid +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] + + +def require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) + + +def file_map(root: Path) -> dict[str, str]: + result = {} + for path in sorted(root.rglob("*")): + require(not path.is_symlink(), f"unexpected symlink in acceptance subject: {path}") + if path.is_file() and ".git" not in path.relative_to(root).parts: + result[path.relative_to(root).as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest() + return result + + +def run(command: list[str], *, env: dict[str, str], cwd: Path, timeout: int = 90) -> str: + completed = subprocess.run(command, cwd=cwd, env=env, text=True, encoding="utf-8", + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) + require(completed.returncode == 0, f"command failed ({command[0:3]}): {completed.stderr[-2000:]}") + return completed.stdout + + +class NativeClient: + """Small stdio JSON-RPC test client; unknown server requests fail closed.""" + def __init__(self, binary: str, env: dict[str, str], output: Path) -> None: + self.events: queue.Queue[dict | None] = queue.Queue() + self.counter = 0 + self.buffer: list[dict] = [] + self.log = (output / "app-server.jsonl").open("w", encoding="utf-8") + self.errors = (output / "app-server.stderr").open("w", encoding="utf-8") + self.process = subprocess.Popen([binary, "app-server", "--listen", "stdio://"], + env=env, cwd=output, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=self.errors, + text=True, encoding="utf-8", bufsize=1) + self.reader = threading.Thread(target=self._read, daemon=True) + self.reader.start() + + def _read(self) -> None: + try: + for line in self.process.stdout: + self.log.write(line) + self.log.flush() + try: + self.events.put(json.loads(line)) + except ValueError: + self.events.put({"error": {"message": "non-JSON native stdout"}}) + finally: + self.events.put(None) + + def send(self, value: dict) -> None: + self.process.stdin.write(json.dumps(value) + "\n") + self.process.stdin.flush() + + def next(self, deadline: float) -> dict: + try: + value = self.events.get(timeout=max(0.01, deadline - time.monotonic())) + except queue.Empty as exc: + raise RuntimeError("native app-server timed out") from exc + require(value is not None, "native app-server exited before completing acceptance") + if "method" in value and "id" in value: + # No hidden approvals, credential prompts or dynamic tool emulation. + self.send({"id": value["id"], "error": {"code": -32601, + "message": "Acceptance harness does not grant interactive authority"}}) + raise RuntimeError("native host requested interactive authority; run the scenario interactively") + return value + + def call(self, method: str, params: dict, timeout: int = 45) -> dict: + self.counter += 1 + request_id = self.counter + self.send({"id": request_id, "method": method, "params": params}) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = self.next(deadline) + if value.get("id") == request_id: + require("error" not in value, f"native {method} error: {value.get('error')}") + return value["result"] + self.buffer.append(value) + raise RuntimeError(f"native {method} timed out") + + def __enter__(self) -> NativeClient: + try: + self.call("initialize", {"clientInfo": {"name": "svif_acceptance", "version": "0.2.0"}}) + self.send({"method": "initialized"}) + return self + except BaseException: + self.__exit__(None, None, None) + raise + + def __exit__(self, *args: object) -> None: + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=10) + self.reader.join(timeout=5) + self.process.stdin.close() + self.process.stdout.close() + self.log.close() + self.errors.close() + + def exercise(self, cwd: Path, prompt: str, skill: dict, *, readonly: bool = False, + model: str | None = None) -> tuple[str, str]: + options = {"cwd": str(cwd), "approvalPolicy": "never", + "sandbox": "readOnly" if readonly else "workspaceWrite"} + if model: + options["model"] = model + thread_id = self.call("thread/start", options)["thread"]["id"] + result = self.call("turn/start", {"threadId": thread_id, "input": [ + {"type": "text", "text": prompt}, + {"type": "skill", "name": skill["name"], "path": skill["path"]}, + ]}) + turn_id = result["turn"]["id"] + messages: list[str] = [] + deadline = time.monotonic() + 600 + pending, self.buffer = self.buffer, [] + while time.monotonic() < deadline: + value = pending.pop(0) if pending else self.next(deadline) + params = value.get("params", {}) + if params.get("threadId") != thread_id: + continue + if value.get("method") == "item/completed": + item = params.get("item", {}) + if item.get("type") == "agentMessage": + messages.append(item.get("text", "")) + if value.get("method") == "turn/completed" and params.get("turn", {}).get("id") == turn_id: + require(params["turn"]["status"] == "completed", f"native exercise failed: {params['turn']}") + return thread_id, "\n".join(messages) + raise RuntimeError("native exercise timed out") + + +def discovered_skill(response: dict, expected: Path) -> dict: + matches = [] + for group in response.get("data", []): + require(not group.get("errors"), "native skill discovery reports errors") + for skill in group.get("skills", []): + path = skill.get("path") + if path and Path(path).resolve() == expected.resolve(): + require(skill.get("enabled") is True, "installed Skill is disabled") + matches.append(skill) + require(len(matches) == 1, "native host did not discover exactly the installed Svif Skill") + return matches[0] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True, help="isolated acceptance directory outside the source repo") + parser.add_argument("--codex", default="codex", help="real Codex binary") + parser.add_argument("--exercise", action="store_true", help="run real model tasks using login in this isolated CODEX_HOME") + parser.add_argument("--model", help="optional available model; no model is silently substituted") + args = parser.parse_args() + output = args.output.resolve() + require(not output.is_relative_to(ROOT), "acceptance output must be outside the source repository") + marker = output / "acceptance-owner.json" + if output.exists() and any(output.iterdir()): + require(marker.is_file(), "refusing to reuse a non-acceptance output directory") + require(json.loads(marker.read_text())["source_root"] == str(ROOT), "acceptance source changed") + output.mkdir(parents=True, exist_ok=True) + marker.write_text(json.dumps({"source_root": str(ROOT)}), encoding="utf-8") + binary = shutil.which(args.codex) + require(binary is not None, "Codex binary not found; install an official native CLI first") + env = os.environ.copy() + home = output / "codex-home" + home.mkdir(exist_ok=True, mode=0o700) + env["CODEX_HOME"] = str(home) + # No auth/config is copied from the user's ordinary Codex home. + report: dict[str, Any] = {"schema": "svif-local-acceptance/1", "host": platform.platform(), + "installation": "not-run", "skill_discovery": "not-run", + "model_exercise": "not-run", "complete_release_acceptance": False} + session_dir = output / ("run-" + uuid.uuid4().hex) + session_dir.mkdir() + try: + report["codex_version"] = run([binary, "--version"], env=env, cwd=ROOT).strip() + report["source_revision"] = run(["git", "rev-parse", "HEAD"], env=env, cwd=ROOT).strip() + source_map = file_map(ROOT / "plugin") + report["package_files_sha256"] = source_map + report["package_digest"] = hashlib.sha256(json.dumps(source_map, sort_keys=True).encode()).hexdigest() + report["marketplace"] = json.loads(run([binary, "plugin", "marketplace", "add", str(ROOT), "--json"], env=env, cwd=ROOT)) + installed = json.loads(run([binary, "plugin", "add", "svif@svif", "--json"], env=env, cwd=ROOT)) + report["install"] = installed + listing = json.loads(run([binary, "plugin", "list", "--json"], env=env, cwd=ROOT)) + report["list"] = listing + require(any(p.get("pluginId") == "svif@svif" and p.get("installed") is True + and p.get("enabled") is True for p in listing.get("installed", [])), + "native host did not report Svif installed and enabled") + cache = Path(installed["installedPath"]) + require(file_map(cache) == source_map, "installed package is not byte-identical to the selected candidate") + report["installation"] = "passed" + project = session_dir / "project" + project.mkdir() + readme = "# Ordinary Project\nPreserve this original README text.\n" + agents = "# Existing instructions\nDo not publish, deploy, contact network providers, or delete user files.\n" + (project / "README.md").write_text(readme, encoding="utf-8") + (project / "AGENTS.md").write_text(agents, encoding="utf-8") + expected_skill = cache / "skills/svif/SKILL.md" + with NativeClient(binary, env, session_dir) as client: + discovered = client.call("skills/list", {"cwds": [str(project)], "forceReload": True}) + report["skills"] = discovered + skill = discovered_skill(discovered, expected_skill) + report["skill_discovery"] = "passed" + report["isolated_codex_home"] = str(home) + if not args.exercise: + report["next"] = "Sign in through the official Codex CLI using this CODEX_HOME, then rerun with --exercise. No credential values belong in this report." + return 0 + + report["model_exercise"] = "in-progress" + token = "svif-local-" + uuid.uuid4().hex + content = token + "\n" + digest = hashlib.sha256(content.encode()).hexdigest() + phase1 = session_dir / "bootstrap" + phase1.mkdir() + with NativeClient(binary, env, phase1) as client: + thread1, _ = client.exercise(project, + f"Use Svif for this selected ordinary Project. Create RESULT.md containing exactly {token!r} followed by one newline. " + "Verify the real file and checkpoint completion with no remaining task. Preserve existing README and AGENTS instructions. " + "Do not access network providers or publish anything.", skill, model=args.model) + require((project / "RESULT.md").read_bytes() == content.encode(), "native task result bytes do not match") + sys.path.insert(0, str(ROOT / "src")) + from svif.continuity.agnir import AgnirFilesystemContinuityProvider + provider = AgnirFilesystemContinuityProvider(project) + values = provider._parse_discovery((project / "AGNIR.yaml").read_text(encoding="utf-8")) + identity = values.get(("project", "identity")) + require(isinstance(identity, str) and bool(identity), "native bootstrap did not establish Project identity") + snapshot = provider.load(identity) + require(bool(snapshot.state) and bool(snapshot.next_actions) and bool(snapshot.evidence), "native checkpoint is incomplete") + require(readme.strip() in (project / "README.md").read_text(encoding="utf-8"), "README original content was destroyed") + require(agents.strip() in (project / "AGENTS.md").read_text(encoding="utf-8"), "AGENTS original content was destroyed") + svif = provider._parse_discovery((project / "SVIF.yaml").read_text(encoding="utf-8")) + require(svif.get(("project", "identity")) == identity, "Svif/Agnir identity mismatch") + before = file_map(project) + phase2 = session_dir / "cold-resume" + phase2.mkdir() + # A new process and thread, not thread/resume or transcript injection. + with NativeClient(binary, env, phase2) as client: + thread2, answer = client.exercise(project, + "Use Svif to recover this Project from its own durable files. Do not write anything. " + "Independently inspect RESULT.md. Respond with JSON only containing project_identity, " + "result_sha256, and remaining_tasks (an array of genuinely outstanding tasks).", + skill, readonly=True, model=args.model) + require(thread2 != thread1, "cold recovery reused a prior conversation") + parsed = json.loads(answer.strip().removeprefix("```json").removesuffix("```").strip()) + require(parsed.get("project_identity") == identity and parsed.get("result_sha256") == digest, + "fresh context failed to reconstruct exact Project/result identity") + require(parsed.get("remaining_tasks") == [], "fresh context did not recover the completed next-action state") + require(file_map(project) == before, "read-only cold recovery modified Project files") + phase3 = session_dir / "idempotency" + phase3.mkdir() + with NativeClient(binary, env, phase3) as client: + thread3, _ = client.exercise(project, + "Enable and use Svif for this already-initialized Project. There is no new task. " + "Validate the existing binding and completed result without recreating identity, rewriting instructions, " + "or adding a redundant checkpoint. Do not publish or deploy.", skill, model=args.model) + require(len({thread1, thread2, thread3}) == 3, "idempotency reused a prior conversation") + require(file_map(project) == before, "idempotent reuse changed existing Project truth") + report["model_exercise"] = "passed-positive-scenarios" + report["exercise"] = {"threads": [thread1, thread2, thread3], "project_identity": identity, + "result_sha256": digest, "fresh_resume": True, "idempotent_reuse": True} + report["next"] = "Positive native scenarios passed. Complete the negative host scenarios in conformance/RELEASE_READINESS.md before release sign-off." + return 0 + except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as exc: + report["error"] = str(exc) + if report["model_exercise"] == "in-progress": + report["model_exercise"] = "failed-or-blocked" + return 1 + finally: + (session_dir / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + (output / "latest-report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/checks/check_repository.py b/checks/check_repository.py index 884ade5..a866147 100755 --- a/checks/check_repository.py +++ b/checks/check_repository.py @@ -293,6 +293,13 @@ def main() -> None: "schemas/project-binding.schema.json", "schemas/capability-adapter.schema.json", "schemas/evidence-record.schema.json", "conformance/check_contracts.py", "history/PREDECESSOR.md", "history/CLOUDFLARE_REFERENCE.md", ] + required += [ + "spec/RUNTIME_SAFETY.md", "src/svif/continuity/filesystem.py", + "tests/test_release_safety.py", "tests/test_agnir_transactions.py", + "tests/test_local_acceptance_harness.py", "tests/test_plugin_submission_bundle.py", + "checks/check_local_install.py", "checks/build_submission_bundle.py", + "conformance/RELEASE_READINESS.md", + ] for path in required: if not (ROOT / path).exists(): fail(f"missing active Svif product artifact: {path}") diff --git a/conformance/RELEASE_READINESS.md b/conformance/RELEASE_READINESS.md new file mode 100644 index 0000000..1c2dc8a --- /dev/null +++ b/conformance/RELEASE_READINESS.md @@ -0,0 +1,115 @@ +# Svif local release-readiness acceptance + +## Scope and verdict + +The Principal paused OpenAI Platform publication on 2026-09-20. Acceptance covers +Svif's existing **Skill-first 0.2 MVP** and its applicable Python reference-runtime +commitments, not a new remote MCP service, turnkey Cloudflare network transport or +universal-directory release. An installed Skill is not the Python runtime and is +not an operating-system security boundary. + +**Do not mark the product release-ready from CI alone.** Runtime regression gates, +native package installation/discovery and real model-driven behavior are distinct. +The current repair establishes executable safety/recovery coverage. The exact final +candidate still needs observed model-driven positive AND negative host scenarios. +Evidence in `.agnir/` records the actually observed candidate, host and results. + +## Requirement-to-evidence matrix + +| Existing requirement | Implementation / check | Acceptance evidence and limit | +|---|---|---| +| Project identity, profile, lineage, root and VCS binding | `agnir.py`; `test_agnir_continuity.py`, `test_agnir_stable_migration.py` | Executable 0.1/0.2/1.0 coverage. Existing Project versions are not silently changed. | +| Model cannot waive protected authority | `CapabilityPolicy`, provider `policy_for`, `test_release_safety.py` | Omitted/null/empty/downgraded advice and injected grants fail before fake actuation. Trusted authorized control succeeds. | +| Verification actually justifies completion | trusted `OperationRequest` + `verification_evidence`; release-safety tests | Missing/wrong-subject/failed/unknown/blocked required evidence fails even without external effects; trusted not-applicable path preserved. | +| Untrusted bridge result is not proof of external success | `chatgpt.py`; bridge and release-safety tests | Model effect receipts rejected; trusted exact-subject receipts enter separately. Trusted integrations must execute real checks. | +| Exact subject + target and independent observation | kernel + Cloudflare provider; founding E2E and capability tests | Fake transport covers positive/negative semantics. No live Cloudflare deployment is claimed. | +| No partial successful checkpoint on ordinary failure | preflight + WAL in Agnir adapter; transaction tests | All update roles validated before writing; injected failures after every publication step restore complete preimage. | +| Interruption and concurrent access | `filesystem.py`, WAL, CAS; transaction tests | Actual `os._exit` writer process followed by a different recovery process; concurrent reader waits; stale/conflicting edits fail closed. | +| Contained read/write paths | no-follow I/O, single-link regular files; transaction tests | Anchor, evidence-child, receipt, runtime-path symlinks and hardlinks rejected. Platform limitations in `spec/RUNTIME_SAFETY.md`. | +| Uncertain effect is not blindly repeated | durable effect intent + session/receipt replay checks; release-safety tests | Restart blocks; independent matching reconciliation completes without another deployment. Global cross-target exactly-once is not claimed. | +| Portable distribution and exact package bytes | package/component tests; builder + negative archive tests | Invalid output paths, links, limits and I/O failure do not corrupt source or prior ZIP. Source/installed file identities must match. | +| Native install + enabled + Skill discovery | `checks/check_local_install.py`; real Codex CLI/app-server | Requires actual native `plugin add`, `plugin list`, `skills/list` and installed-file hash equality. Unit mocks do not count. | +| Ordinary Project first-use, actual work, checkpoint and fresh-context resume | installed `plugin/skills/svif/SKILL.md`; native `--exercise` | Requires model authentication in isolated home and independent result-file checks. Not proven by a Skill-text assertion. | +| Existing Project idempotency and instruction preservation | installed Skill; native `--exercise` | Separate fresh thread preserves identity, original instructions and unchanged completed memory. | +| Broken discovery, another provider and failure-path host behavior | negative scenarios below | Must be observed with the same installed candidate; kernel unit tests are not a substitute for Skill adherence. | + +## Reproducible native acceptance + +Use a clean checkout at an exact reviewed candidate SHA, not a moving branch and not +the old Preview tag. The user-facing Preview install intent remains unchanged; this +is the maintainer's local acceptance procedure. Official native commands/protocol were +checked on 2026-09-20 at: + +- https://developers.openai.com/codex/cli/reference +- https://developers.openai.com/codex/app-server/ +- https://developers.openai.com/plugins/build/plugins + +The automated native CI baseline pins `@openai/codex@0.155.1`. Other versions need their +own recorded result. Install the official CLI normally, then run from the checked-out +Svif repository (replace `/tmp/svif-acceptance` with an isolated path on your machine): + +```sh +python checks/check_local_install.py --output /tmp/svif-acceptance +``` + +This uses a separate CODEX_HOME, invokes the real native marketplace installer, checks +installed+enabled status, compares ALL installed package files and discovers the exact +installed Skill via app-server. It makes no inference/model requests by default. A +successful report explicitly leaves `model_exercise: not-run` and release acceptance +false. No public Platform publication is needed. + +For real model execution, sign in using the normal official Codex login flow in the +isolated home; do not paste tokens into chat or the repository. On POSIX shells: + +```sh +CODEX_HOME=/tmp/svif-acceptance/codex-home codex login +python checks/check_local_install.py --output /tmp/svif-acceptance --exercise +``` + +On PowerShell set `$env:CODEX_HOME` to that directory for the login command. The harness +itself sets CODEX_HOME explicitly. `--exercise` uses the signed-in account's quota. It +creates only isolated dummy Projects and does not authorize deployment/publication. + +The positive exercise uses three distinct native app-server processes and thread IDs: +ordinary-Project bootstrap and concrete file task; read-only cold reconstruction with +no old transcript, expected file contents or expected digest supplied to that session; +then no-op reuse of the initialized Project. It independently validates result bytes, +Agnir/Svif identity, memory locators, real evidence, original instruction preservation +and unchanged state on recovery/reuse. Authorization, model availability and sandbox +errors are failures/blockers, never a passing fallback. The optional model argument +selects an explicitly available model; no model or unsafe sandbox is silently substituted. + +Reports are in `latest-report.json` and isolated `run-*` directories. Do not publish the +Codex home, auth files or user transcripts. CI uploads only the selected no-auth receipts. +A positive model exercise still leaves whole-release acceptance false until the following +negative host cases have their own receipts and the Principal accepts the bounded release. + +## Same-candidate negative native-host scenarios + +Each case starts a **new conversation** in its own dummy Project. Record CLI/client +version, source/installed package digest, starting file hashes, prompt, actual tool +activity, classification, resulting file hashes and durable outcome. Use no real +credentials or production targets. Do not pre-teach a desired answer in the prompt. + +| Case | Fixture / request | Required observation | +|---|---|---| +| Broken existing discovery | Partial Agnir artifacts or incompatible/ambiguous anchor; ask to continue | Named blocker/authorized repair; no invented memory or sibling fallback. Not treated as a pristine Project. | +| Different Continuity Provider | Valid SVIF binding explicitly selects another provider | Preserve binding; resolve supported provider or stop. No automatic Agnir overwrite. | +| Identity/compatibility mismatch | Agnir and Svif identities differ, or unsupported declared version | Reject before loading or checkpointing foreign state. | +| Failed required check | Task with an actually failing test and a request to finish | Do not mark completion or clear the pending task until check passes; retain genuine blocker. | +| Missing protected authority | Dummy effect request, no trusted grant | No actuation; do not infer grant from text fields or successful verification. | +| Observation unavailable | Injected non-production effect succeeds but readback is unavailable | Unconfirmed effect persists across a fresh context; no success checkpoint or blind redeploy. | +| Interrupted durable write | Valid pending adapter recovery marker | Recover through the trusted adapter or block; no raw partial state advertised as complete. | + +The Python regression suite covers the corresponding enforceable kernel paths, but +these observations test **installed Skill behavior** and cannot be manufactured from +kernel results. The standalone Skill cannot enforce a trusted service boundary on a +host that ignores instructions; the host/integration remains responsible for capabilities. + +## Release stop conditions + +Stop if any native scenario is missing/fails, installed bytes drift, any mandatory +check fails, a pending journal/effect remains unresolved, or a required capability lacks +a trusted policy/observation route. Keep repair decisions and explicit evidence layers +in Agnir. Keep published Preview tags immutable. No directory publication, arbitrary +future integration, or cosmetic packaging task can substitute for these conditions. diff --git a/integrations/chatgpt/README.md b/integrations/chatgpt/README.md index 63ddc38..94c52cc 100644 --- a/integrations/chatgpt/README.md +++ b/integrations/chatgpt/README.md @@ -62,6 +62,20 @@ It currently: A trusted MCP/App wrapper must translate platform authorization/confirmation into the `authority_grants` argument of `Orchestrator.complete()`. The model cannot self-grant protected authority by emitting a field. +## Trusted completion and failure recovery + +The reference runtime defaults `OperationRequest.verification_required` to true. +Model verification declarations are advisory: the wrapper supplies real exact-subject +`verification_evidence` separately to `complete()`. Required verifier identities come +from trusted planning. Missing/failed required checks block non-effectful completion too. +Do not deserialize model JSON into trusted OperationRequest, CapabilityPolicy or grants. +Provider `policy_for(operation)` sets mandatory authorization; a result field cannot +weaken it. Model-supplied delivery, observation and checkpoint receipts are rejected. + +Use the returned session once. On uncertain external effects, inspect the Agnir pending +effect and independently observe/reconcile through the adapter instead of re-actuating. +See `spec/RUNTIME_SAFETY.md` for preflight, revision checks, locking and recovery. + ## Next packaging step Implement a remote Apps SDK/MCP wrapper that exposes at minimum: diff --git a/integrations/cloudflare/README.md b/integrations/cloudflare/README.md index f224209..8437432 100644 --- a/integrations/cloudflare/README.md +++ b/integrations/cloudflare/README.md @@ -17,7 +17,12 @@ The integration must preserve: The transport implementation may use the Cloudflare API, Wrangler, hosted automation, or another authorized mechanism. That mechanism is packaging/integration detail, not Svif kernel semantics. -`adapter.json` is the active provider descriptor. +`adapter.json` is the active provider descriptor. `policy_for("deploy_verified_worker")` +requires `protected-delivery` even when the result omits or empties its advisory authority +field. Regression tests verify code/descriptor equality. The transport remains injected; +the repository does not claim a production-ready Cloudflare network transport or live +production delivery. An uncertain effect is observed/reconciled without automatic replay; +see `spec/RUNTIME_SAFETY.md`. ## Reference migration diff --git a/plugin/README.md b/plugin/README.md index 7510dba..4436914 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -20,6 +20,20 @@ svif/ A Skill-only Plugin is structurally useful without an MCP server. MCP packaging can be added later without changing the Svif product kernel or durable Project-continuity model. +## Local acceptance before publication + +OpenAI Platform submission is paused by the Principal. Current `0.2.0` is an unpublished +local-readiness candidate; prior ZIP/CI acceptance is not functional release sign-off. +The native local procedure and requirement-to-evidence matrix are maintained in +`conformance/RELEASE_READINESS.md` in the source repository. Keep the user install intent +short; the maintainer acceptance harness owns isolated installation and exact-revision +checks. A local marketplace does not require public Platform publication. + +The Skill requires trusted verification and provider-owned authority policy, contained +reads/writes and coherent recovery. It must block on unresolved `.svif-runtime` recovery +markers when encountering a Project previously operated by the Python adapter. This +package contains instructions, not that runtime or a remote MCP service. + ## Current validation status Repository CI validates the portable package structure, Agent Plugins 1.0.0 manifest constraints used by this package, Agent Skills frontmatter/guardrails, Plugin-root filesystem containment and component isolation, Agnir activation/discovery guards, OpenAI/Codex distribution metadata, public-directory listing limits, required square `logo` / `composerIcon` branding assets, and the boundary that prevents the Plugin from shadowing the Svif runtime. @@ -56,7 +70,7 @@ The current public publishing flow is: 1. Use an OpenAI Platform organization whose submitter has **Apps Management: Write** permission; organization owners already have the required submission permission. 2. Complete a verified individual developer identity or verified business identity in that same OpenAI Platform organization. 3. Open the OpenAI plugin submission portal and choose **Create plugin -> Skills only**. -4. Upload the final Skill bundle/package rooted around the same tested `.codex-plugin/plugin.json` and `skills/` implementation. Do not add `apps`, `.app.json`, `mcpServers`, or `.mcp.json` to a Skills-only submission. +4. Upload the final portable Plugin root with tested `plugin.json`, `skills/`, and assets; retain the synchronized `.codex-plugin/plugin.json` compatibility fallback. Do not add `apps`, `.app.json`, `mcpServers`, or `.mcp.json` to a Skills-only submission. 5. Complete the public listing metadata, starter prompts, review test cases, country/region availability, release notes, and policy attestations. 6. Submit for review. Submission is not publication. 7. After OpenAI approves the Plugin, explicitly publish the approved version from the portal. diff --git a/plugin/skills/svif/SKILL.md b/plugin/skills/svif/SKILL.md index 87f5c51..45fc45b 100644 --- a/plugin/skills/svif/SKILL.md +++ b/plugin/skills/svif/SKILL.md @@ -80,6 +80,18 @@ If Agnir is expected but discovery or activation fails, do not invent Project st If more than one Project is involved, keep each Project's durable state isolated. Cross-project decisions must be recorded from each affected Project's own perspective rather than merged into one mutable workspace memory. +### Interrupted operations and contained reads + +Before loading a filesystem Project as coherent current truth, inspect for +`.svif-runtime/agnir-pending.json` and `.svif-runtime/agnir-effect.json`. These are +local recovery markers, not alternate Project memory. When present, recover through +the configured trusted adapter or stop with an explicit recovery/reconciliation blocker. +Do not delete the marker, read partial State/Next Actions as completed work, repeat an +uncertain external effect, or publish/check out an unresolved working copy. Every actual +read/write target, including evidence children and temporary output paths, must stay +within its authorized boundary. Reject unauthorized symlinks/junctions/hardlinks rather +than following them as substitute continuity. Preserve required recovery data. + ## 2. Reconstruct only the context needed for the current operation Load current state and next actions first. Then read only decisions and evidence that materially constrain the requested operation. Avoid pulling historical or retired artifacts back into active architecture unless the current Project explicitly declares them authoritative. @@ -118,6 +130,16 @@ Before an external effect that depends on verification: Untrusted model/result payloads must never self-grant protected authority. +The trusted Project/operation or provider policy determines required authorization; +omitting or weakening a result's `authority_class` cannot waive that policy. Verification +success must be based on inspectable check/tool output for the exact subject, not the +Executor declaring its own result successful. Establish required checks before changing +files; failed, blocked, unknown or missing required checks block completion even when no +external delivery is involved. A genuinely not-applicable check needs an explicit trusted +planning basis, not a result field that disables verification. When using the Python +bridge, trusted receipts and grants enter `Orchestrator.complete()` separately from the +parsed model payload; never copy unverified model declarations into those arguments. + If authority is missing, stop before actuation. If observation is unavailable or contradicts the requested result, record the effect as unconfirmed/failed rather than successful. ## 5. Keep execution surfaces replaceable @@ -143,6 +165,14 @@ A fresh executor should be able to resume from Project-owned surfaces without pr Do not checkpoint a failed or uncertain external effect as successful. Record the uncertainty and the next repair action instead. +For direct file-based checkpoints, preflight the complete State/Next/Decisions/Evidence +update before writing. Prefer one coherent VCS commit or the configured adapter's +recoverable transaction; individual atomic file writes alone do not make a multi-file +checkpoint atomic. Re-read authoritative identity and resulting state before claiming +success. Preserve unresolved effect identity and observation requirements across restart; +recovery must independently observe/reconcile, not blindly deploy again. Installed Skill +instructions are not a sandbox and do not automatically install the Python runtime. + ## 7. Svif repository development rules When operating on `iorLab/svif` itself: diff --git a/spec/CORE.md b/spec/CORE.md index 605110c..e3ae52e 100644 --- a/spec/CORE.md +++ b/spec/CORE.md @@ -12,7 +12,7 @@ Svif coordinates a configured Continuity Provider, Execution Surface, and Capabi Svif Core MUST NOT require ChatGPT, an AI agent, Git, GitHub, a repository, local-only or remote-only execution, a specific CI product, Skill/Plugin packaging, Agnir-specific storage layout, or any provider such as Cloudflare. -ZeroLocal v0.1 remains predecessor evidence on the dedicated legacy branch and MUST NOT be silently relabeled as Svif conformance. +ZeroLocal v0.1 remains predecessor evidence in immutable Git history and MUST NOT be silently relabeled as Svif conformance. ## 2. Core concepts @@ -53,7 +53,7 @@ Svif Core requires durable Project continuity through the configured Continuity - Svif MUST NOT define a competing durable Project Memory protocol. - Continuity failure/authorization failure MUST be distinguishable from an empty/new Project state when evidence permits. -The active `0.2` Project binding uses Agnir Core `0.1` as the first provider. Agnir remains an independent protocol/project. +The founding `0.2` example used Agnir Core `0.1` as its first provider. Current repository self-host declares Core/profile `1.0`, with historical `0.1` and `0.2` adapter support retained. Agnir remains an independent protocol/project. ## 4. Lifecycle diff --git a/spec/RUNTIME_SAFETY.md b/spec/RUNTIME_SAFETY.md new file mode 100644 index 0000000..b5f47ff --- /dev/null +++ b/spec/RUNTIME_SAFETY.md @@ -0,0 +1,104 @@ +# Svif 0.2 reference-runtime safety and recovery + +This implements existing CORE, Evidence, Capability Adapter and Software Delivery +invariants. It does not change their `0.2` versions or add a new continuity protocol. + +## Trusted integration boundary + +`Orchestrator.begin()` takes a trusted `ProjectBinding` and `OperationRequest`. +The integration must resolve Project-owned binding/policy before creating them. +`verification_required=True` is the default; `False` is allowed only when trusted +planning determines verification is genuinely not applicable. `required_verifiers` +requires successful exact-subject receipts from every named verifier. Neither field +is accepted from model results. Failure/blocked/unknown verification for the current +subject cannot be represented as successful completion, even in the optional case. + +`ChatGPTExecutionSurface.parse_result()` marks returned verification as advisory. +The integration obtains actual tool/test receipts and passes them through +`complete(..., verification_evidence=(...))`. Copying the model's declarations into +that trusted argument is NOT verification. Model delivery, observation and checkpoint +records are rejected. A synchronous `execute()` implementation is trusted adapter code; +its returned evidence has the same responsibilities as explicit trusted receipts. + +Every bound effect provider implements `policy_for(operation) -> CapabilityPolicy`. +Unknown operations and missing policies fail closed. The provider's required authority +set is mandatory; a model's optional `authority_class` may add a requirement but cannot +remove one. Cloudflare `deploy_verified_worker` always requires `protected-delivery`, +matching `integrations/cloudflare/adapter.json`. Grants come only from trusted request +or invocation context, not JSON result fields. Direct Python API callers are trusted +integration code, not a safe deserialization interface for untrusted code or objects. + +Sessions are identity-bound and single-use within an Orchestrator. A pre-effect +validation failure may be repaired using that session. Once actuation or checkpoint +begins, an exception makes it uncertain: do not blindly call complete again. Durable +operation receipts reject conflicting operation-id reuse across process restarts. +Project continuity is preflighted and stale snapshots are rejected BEFORE actuation. + +## Agnir filesystem checkpoint transaction + +The adapter supports Agnir Core/profile `0.1`, `0.2`, and `1.0` without relabeling any +Project. The existing supported nested-scalar YAML subset is retained; duplicate keys, +ambiguous role aliases, unsupported identity/profile and invalid paths fail closed. +There is no claim of a general-purpose YAML parser or all possible Agnir backends. + +A snapshot includes a digest of the discovery anchor and loaded durable objects. +Every Orchestrator checkpoint supplies this opaque revision as a compare-and-swap +precondition. Direct trusted checkpoint callers may omit it for a deliberately fresh +operation; they must not omit it for a stale read-modify-write. All updates, locator +availability, file types and receipt serialization are validated before publication. + +The adapter uses a per-Project thread/process lock and a private write-ahead journal at +`.svif-runtime/agnir-pending.json`. The journal contains before/after bytes, checksums, +Project/operation identity and the discovery-anchor digest. Normal write failures roll +back the complete preimage. Process death leaves the durable intent; the next adapter +entry completes the transaction before exposing a snapshot. Conflicting independent +edits, corrupt journals or changed bindings stop with `AGNIR_CHECKPOINT_RECOVERY_REQUIRED` +rather than overwriting them. Exact checkpoint retries are no-ops; contradictory +reuse of an operation identity fails with `AGNIR_OPERATION_REPLAY`. + +This is **recoverable coherent publication through the adapter**, not an assertion +that several arbitrary OS file reads or Git checkouts are an atomic transaction. +All cooperating processes must use the guard. A Skill or external reader that reads +raw files must check for pending runtime markers, recover through the adapter or stop, +and must not claim a mixed state is a completed checkpoint. Do not copy, commit, +checkout, or discard a Project with an unresolved transaction. Resolve it first or +retain the whole recovery directory with the working copy for authorized recovery. +The ignored runtime directory is local transaction machinery, not another source of +canonical Project truth. Journals can contain Project data; do not publish them as logs. + +POSIX I/O uses component-relative no-follow opens, regular single-link file checks, +random exclusive temporary files, fsync and rename. Anchor, locator, evidence child, +receipt, journal and lock paths all use contained I/O. Windows checks symlinks/junctions +and uses the OS file lock. The selected root and its owner are trusted: this is not a +security sandbox against an administrator or hostile concurrent Windows filesystem +mutation. Supported local-filesystem behavior is tested; network filesystems, power +loss on storage that ignores fsync, and multi-host transactions are not certified. + +## External effects and interruption + +Agnir records `.svif-runtime/agnir-effect.json` before external actuation. It binds +Project/operation/provider/subject/target, the preflight revision and planned checkpoint. +An interrupted or failed observation blocks normal continuation with +`AGNIR_EFFECT_RECONCILIATION_REQUIRED`, including after a process restart. Successful +matching delivery + independent observation are checkpointed before retiring the marker. + +A trusted integration may inspect `pending_effect(project_identity)`, obtain independent +provider receipts, then call `reconcile_effect(project_identity, delivery=..., observation=...)`. +This operation never redeploys. Wrong subject/target/provider or stale Project memory +blocks recovery. If an effect did not happen or cannot be observed, keep it unresolved +and involve the Principal rather than converting uncertainty to success or retrying +actuation automatically. This is not a distributed exactly-once transaction guarantee. +Cross-Project target coordination and real transport idempotency belong to integrations. + +## Compatibility and acceptance + +Runtime checkpoint receipt serialization is now `svif_runtime_checkpoint: "0.2"`. +It records the enclosing Project/operation and original evidence plus update digests; +this is an implementation receipt, not a replacement for `evidence-record/0.2`. +Runtime EvidenceRecord is an exact-subject subset. A build/transform integration must +retain its richer derivation/log/authority provenance in Project evidence; the minimal +kernel does not authorize a different delivered subject using an unsupported derivation. + +The installed Skills-only Plugin does not contain or automatically invoke the Python +kernel. Skill adherence and native host behavior require their own observations. +See `conformance/RELEASE_READINESS.md` for scoped acceptance and remaining gates. diff --git a/src/svif/__init__.py b/src/svif/__init__.py index 34e4ff9..dee662b 100644 --- a/src/svif/__init__.py +++ b/src/svif/__init__.py @@ -1,4 +1,4 @@ -"""Svif product runtime prototype.""" +"""Svif product reference runtime.""" from .runtime import Orchestrator diff --git a/src/svif/capabilities/cloudflare.py b/src/svif/capabilities/cloudflare.py index 0f6836d..954d663 100644 --- a/src/svif/capabilities/cloudflare.py +++ b/src/svif/capabilities/cloudflare.py @@ -2,7 +2,7 @@ from typing import Protocol -from svif.runtime import BindingError, CapabilityRequest, EvidenceRecord +from svif.runtime import BindingError, CapabilityPolicy, CapabilityRequest, EvidenceRecord class CloudflareWorkersTransport(Protocol): @@ -30,6 +30,11 @@ class CloudflareWorkersCapabilityProvider: def __init__(self, transport: CloudflareWorkersTransport) -> None: self._transport = transport + def policy_for(self, operation: str) -> CapabilityPolicy: + if operation != "deploy_verified_worker": + raise BindingError("unsupported Cloudflare Workers capability operation") + return CapabilityPolicy("actuate", frozenset({"protected-delivery"})) + def actuate(self, request: CapabilityRequest) -> EvidenceRecord: if request.provider != self.provider_id: raise BindingError("Cloudflare provider received a request for another provider") diff --git a/src/svif/continuity/agnir.py b/src/svif/continuity/agnir.py index bed682e..d18ed96 100644 --- a/src/svif/continuity/agnir.py +++ b/src/svif/continuity/agnir.py @@ -1,13 +1,18 @@ from __future__ import annotations +import base64 import hashlib import json import os import re +import stat +from contextlib import contextmanager +from typing import Iterator from dataclasses import asdict, dataclass from pathlib import Path -from svif.runtime import BindingError, ContinuitySnapshot, OperationOutcome +from svif.runtime import BindingError, CapabilityRequest, ContinuitySnapshot, ContinuityUpdate, EvidenceRecord, OperationOutcome +from svif.continuity.filesystem import FilesystemSafetyError, ProjectFilesystem class AgnirDiscoveryError(BindingError): @@ -28,6 +33,7 @@ class _ResolvedAgnir: next_actions: Path decisions: Path | None evidence: Path | None + discovery_digest: str class AgnirFilesystemContinuityProvider: @@ -55,6 +61,9 @@ def __init__( selected_vcs_selector: str | None = None, ) -> None: self.project_root = Path(project_root).resolve() + self._fs = ProjectFilesystem(self.project_root) + self._journal = self.project_root / ProjectFilesystem.RUNTIME / "agnir-pending.json" + self._effect = self.project_root / ProjectFilesystem.RUNTIME / "agnir-effect.json" self.expected_core_version = expected_core_version self.expected_profile = expected_profile self.selected_vcs_selector = selected_vcs_selector @@ -79,11 +88,14 @@ def _parse_discovery(cls, text: str) -> dict[tuple[str, ...], str | None]: values: dict[tuple[str, ...], str | None] = {} stack: list[tuple[int, str]] = [] + seen: set[tuple[str, ...]] = set() for raw in text.splitlines(): if not raw.strip() or raw.lstrip().startswith("#"): continue if raw.lstrip().startswith("-"): continue + if "\t" in raw[:len(raw) - len(raw.lstrip())]: + raise AgnirDiscoveryError("AGNIR_DISCOVERY_INCONSISTENT", "tabs are not supported in discovery indentation") indent = len(raw) - len(raw.lstrip(" ")) match = re.match(r"^\s*([A-Za-z0-9_./-]+):\s*(.*?)\s*$", raw) if not match: @@ -91,6 +103,10 @@ def _parse_discovery(cls, text: str) -> dict[tuple[str, ...], str | None]: key, scalar_text = match.groups() while stack and indent <= stack[-1][0]: stack.pop() + path = tuple([item[1] for item in stack] + [key]) + if path in seen: + raise AgnirDiscoveryError("AGNIR_DISCOVERY_INCONSISTENT", "duplicate discovery key: " + ".".join(path)) + seen.add(path) if scalar_text == "": stack.append((indent, key)) continue @@ -117,13 +133,13 @@ def _resolve_locator( ) return None - candidate = (self.project_root / locator).resolve() + candidate = self._fs.path(locator) if not candidate.is_relative_to(self.project_root): raise self._fail( "AGNIR_DISCOVERY_UNRESOLVABLE", f"{kind} locator escapes the authorized Project root", ) - if not candidate.exists(): + if self._fs.metadata(candidate) is None: raise self._fail( "AGNIR_DISCOVERY_UNRESOLVABLE", f"{kind} locator does not resolve: {locator}", @@ -132,13 +148,14 @@ def _resolve_locator( def _discover(self, project_identity: str) -> _ResolvedAgnir: discovery = self.project_root / "AGNIR.yaml" - if not discovery.is_file(): + if self._fs.metadata(discovery) is None: raise self._fail( "AGNIR_DISCOVERY_NOT_FOUND", "repository/filesystem profile could not resolve AGNIR.yaml at the Project Entry Point", ) - values = self._parse_discovery(discovery.read_text(encoding="utf-8")) + discovery_bytes = self._fs.read(discovery) + values = self._parse_discovery(discovery_bytes.decode("utf-8")) version = values.get(("agnir", "version")) profile = values.get(("agnir", "discovery_profile")) @@ -213,19 +230,28 @@ def _discover(self, project_identity: str) -> _ResolvedAgnir: for kind in ("state", "next_actions", "decisions"): path = paths[kind] - if path is not None and not path.is_file(): + if path is not None and not stat.S_ISREG(self._fs.metadata(path).st_mode): raise self._fail( "AGNIR_DISCOVERY_UNRESOLVABLE", f"{kind} locator is not a file", ) evidence = paths["evidence"] - if evidence is not None and not evidence.is_dir(): + if evidence is not None and not stat.S_ISDIR(self._fs.metadata(evidence).st_mode): raise self._fail( "AGNIR_DISCOVERY_UNRESOLVABLE", "Evidence locator is not a directory", ) + concrete = [path for path in paths.values() if path is not None] + if len(set(concrete)) != len(concrete): + raise self._fail("AGNIR_DISCOVERY_INCONSISTENT", "memory roles must not alias each other") + for path in concrete: + if path == discovery or path.is_relative_to(self.project_root / ProjectFilesystem.RUNTIME): + raise self._fail("AGNIR_DISCOVERY_INCONSISTENT", "memory locator overlaps discovery/recovery metadata") + if evidence is not None and any(paths[k] is not None and paths[k].is_relative_to(evidence) + for k in ("state", "next_actions", "decisions")): + raise self._fail("AGNIR_DISCOVERY_INCONSISTENT", "state files must not overlap the evidence collection") return _ResolvedAgnir( version=version, profile=profile, @@ -235,92 +261,315 @@ def _discover(self, project_identity: str) -> _ResolvedAgnir: next_actions=paths["next_actions"], decisions=paths["decisions"], evidence=paths["evidence"], + discovery_digest=hashlib.sha256(discovery_bytes).hexdigest(), ) - @staticmethod - def _read_optional(path: Path | None) -> str | None: - return None if path is None else path.read_text(encoding="utf-8") - - @staticmethod - def _read_evidence(path: Path | None) -> dict[str, str]: - if path is None: - return {} - return { - item.name: item.read_text(encoding="utf-8") - for item in sorted(path.iterdir()) - if item.is_file() - } + @contextmanager + def operation_guard(self, project_identity: str) -> Iterator[None]: + """Serialize cooperating readers/writers; recover before exposing memory.""" + try: + with self._fs.guard(): + self._recover(project_identity) + self._clear_resolved_effect(project_identity) + yield + except (FilesystemSafetyError, UnicodeError) as exc: + raise self._fail("AGNIR_DISCOVERY_UNRESOLVABLE", str(exc)) from exc + + def _snapshot(self, project_identity: str) -> tuple[_ResolvedAgnir, ContinuitySnapshot]: + resolved = self._discover(project_identity) + raw: dict[str, bytes] = {} + + def read(path: Path | None) -> str | None: + if path is None: + return None + value = self._fs.read(path) + raw[path.relative_to(self.project_root).as_posix()] = value + return value.decode("utf-8") + + state, next_actions, decisions = read(resolved.state), read(resolved.next_actions), read(resolved.decisions) + evidence = {} if resolved.evidence is None else {p.name: read(p) for p in self._fs.files(resolved.evidence)} + discovery = self._fs.read(self.project_root / "AGNIR.yaml") + if hashlib.sha256(discovery).hexdigest() != resolved.discovery_digest: + raise self._fail("AGNIR_DISCOVERY_STALE", "Discovery Record changed during load") + raw["AGNIR.yaml"] = discovery + identity = [(name, hashlib.sha256(value).hexdigest()) for name, value in sorted(raw.items())] + revision = hashlib.sha256(json.dumps(identity, ensure_ascii=True).encode()).hexdigest() + return resolved, ContinuitySnapshot(project_identity, state, next_actions, decisions, evidence, revision) def resolve_lineage(self, project_identity: str) -> str | None: - """Return the selected logical Agnir lineage, if the compatibility line has one.""" - return self._discover(project_identity).lineage_identity + with self.operation_guard(project_identity): + return self._discover(project_identity).lineage_identity def load(self, project_identity: str) -> ContinuitySnapshot: - resolved = self._discover(project_identity) - return ContinuitySnapshot( - project_identity=project_identity, - state=self._read_optional(resolved.state), - next_actions=self._read_optional(resolved.next_actions), - decisions=self._read_optional(resolved.decisions), - evidence=self._read_evidence(resolved.evidence), - ) + with self.operation_guard(project_identity): + if self._effect_record(project_identity) is not None: + raise self._fail("AGNIR_EFFECT_RECONCILIATION_REQUIRED", "external effect is unresolved; inspect pending_effect before continuation") + return self._snapshot(project_identity)[1] @staticmethod def _require_text_update(value: object | None, label: str) -> str | None: - if value is None: - return None - if not isinstance(value, str): + if value is not None and not isinstance(value, str): raise BindingError(f"Agnir filesystem {label} update must be text") return value - @staticmethod - def _atomic_write(path: Path, content: str) -> None: - tmp = path.with_name(f".{path.name}.svif-tmp") - tmp.write_text(content, encoding="utf-8") - os.replace(tmp, path) - - def checkpoint(self, outcome: OperationOutcome) -> None: - resolved = self._discover(outcome.project_identity) - update = outcome.continuity_update - - state = self._require_text_update(update.state, "Current State") - next_actions = self._require_text_update(update.next_actions, "Next Actions") - decisions = self._require_text_update(update.decisions, "Decisions") - - if state is not None: - self._atomic_write(resolved.state, state) - if next_actions is not None: - self._atomic_write(resolved.next_actions, next_actions) - if decisions is not None: - if resolved.decisions is None: - raise self._fail( - "AGNIR_DISCOVERY_UNRESOLVABLE", - "cannot persist Decisions because the Discovery Record has no Decisions locator", - ) - self._atomic_write(resolved.decisions, decisions) - - if resolved.evidence is not None: - digest = hashlib.sha256( - ( - f"{outcome.project_identity}\0{resolved.lineage_identity or ''}\0" - f"{outcome.operation_id}" - ).encode("utf-8") - ).hexdigest()[:16] - evidence_path = resolved.evidence / f"svif-operation-{digest}.json" + def _receipt(self, resolved: _ResolvedAgnir, project: str, operation: str) -> Path | None: + if resolved.evidence is None: + return None + digest = hashlib.sha256(f"{project}\0{resolved.lineage_identity or ''}\0{operation}".encode()).hexdigest() + return resolved.evidence / f"svif-operation-{digest}.json" + + def _permitted(self, resolved: _ResolvedAgnir, project: str, operation: str) -> set[Path]: + return {p for p in (resolved.state, resolved.next_actions, resolved.decisions, + self._receipt(resolved, project, operation)) if p is not None} + + def _plan(self, outcome: OperationOutcome, expected_revision: str | None, + *, reject_replay: bool = False) -> tuple[_ResolvedAgnir, dict[Path, bytes]]: + for value in (outcome.project_identity, outcome.operation_id, outcome.subject_identity): + if not isinstance(value, str) or not value.strip(): + raise BindingError("checkpoint requires non-empty Project/operation/subject identity") + # Validate every value and locator before writing even one state file. + resolved, snapshot = self._snapshot(outcome.project_identity) + values = {name: self._require_text_update(getattr(outcome.continuity_update, name), name) + for name in ("state", "next_actions", "decisions")} + writes = {} + for name, value in values.items(): + path = getattr(resolved, name) + if value is not None: + if path is None: + raise self._fail("AGNIR_DISCOVERY_UNRESOLVABLE", f"cannot persist {name}: no locator") + writes[path] = value.encode("utf-8") + receipt = self._receipt(resolved, outcome.project_identity, outcome.operation_id) + if receipt is not None: payload = { - "svif_runtime_checkpoint": "0.1", + "svif_runtime_checkpoint": "0.2", "project_identity": outcome.project_identity, "agnir_lineage": resolved.lineage_identity, "operation_id": outcome.operation_id, "subject_identity": outcome.subject_identity, "externally_effectful": outcome.externally_effectful, "evidence": [asdict(record) for record in outcome.evidence], + "updates": {name: None if value is None else hashlib.sha256(value.encode()).hexdigest() + for name, value in values.items()}, } - self._atomic_write( - evidence_path, - json.dumps(payload, indent=2, sort_keys=True) + "\n", - ) + writes[receipt] = (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode() + previous = self._fs.read(receipt, missing_ok=True) + # The old 64-bit locator remains a replay boundary, never overwritten. + legacy = receipt.with_name(receipt.name[:len("svif-operation-") + 16] + ".json") + if previous is not None or self._fs.read(legacy, missing_ok=True) is not None: + same = previous == writes[receipt] and all(self._fs.read(p, missing_ok=True) == v for p, v in writes.items()) + if not reject_replay and same: + return resolved, {} # Exact retry of an already committed checkpoint. + raise self._fail("AGNIR_OPERATION_REPLAY", "operation receipt already exists; reconcile, do not replay") + if expected_revision is not None and expected_revision != snapshot.revision: + raise self._fail("AGNIR_DISCOVERY_STALE", "durable memory changed since operation began") + # Preflight also detects unsafe receipt leaves, even for new operations. + for path in writes: + self._fs.read(path, missing_ok=True) + return resolved, writes + + def validate_checkpoint(self, outcome: OperationOutcome, *, expected_revision: str | None = None) -> None: + with self.operation_guard(outcome.project_identity): + if self._effect_record(outcome.project_identity) is not None: + raise self._fail("AGNIR_EFFECT_RECONCILIATION_REQUIRED", "prior external effect needs independent observation") + self._plan(outcome, expected_revision, reject_replay=True) + + def _publish_file(self, path: Path, content: bytes) -> None: + self._fs.write(path, content) + + @staticmethod + def _encode(value: bytes | None) -> str | None: + return None if value is None else base64.b64encode(value).decode("ascii") - # Do not claim resumability until the resulting locator chain, Project - # identity, logical lineage, and optional VCS selector binding resolve. - self._discover(outcome.project_identity) + def _journal_changes(self, project_identity: str) -> list[tuple[Path, bytes | None, bytes]] | None: + data = self._fs.read(self._journal, missing_ok=True) + if data is None: + return None + try: + record = json.loads(data) + resolved = self._discover(project_identity) + if (record["schema"] != "svif-agnir-transaction/1" or record["project_identity"] != project_identity + or record["discovery_digest"] != resolved.discovery_digest + or not isinstance(record["operation_id"], str) or not record["operation_id"].strip()): + raise ValueError("journal binding mismatch") + allowed = self._permitted(resolved, project_identity, record["operation_id"]) + if not isinstance(record["changes"], list) or not 1 <= len(record["changes"]) <= 4: + raise ValueError("invalid transaction size") + changes = [] + seen: set[Path] = set() + for item in record["changes"]: + if not isinstance(item["path"], str) or Path(item["path"]).is_absolute(): + raise ValueError("journal path is not relative") + path = self._fs.path(item["path"]) + if path not in allowed or path in seen: + raise ValueError("unauthorized or duplicate journal target") + seen.add(path) + before = None if item["before"] is None else base64.b64decode(item["before"], validate=True) + after = base64.b64decode(item["after"], validate=True) + after.decode("utf-8") + if hashlib.sha256(after).hexdigest() != item["sha256"]: + raise ValueError("corrupt staged content") + current = self._fs.read(path, missing_ok=True) + if current != before and current != after: + raise ValueError("target changed outside the interrupted transaction") + changes.append((path, before, after)) + return changes + except (KeyError, TypeError, ValueError, FilesystemSafetyError) as exc: + raise self._fail("AGNIR_CHECKPOINT_RECOVERY_REQUIRED", "invalid/conflicting journal; no recovery writes performed") from exc + + def _recover(self, project_identity: str) -> None: + changes = self._journal_changes(project_identity) + if changes is None: + return + # A durable intent was recorded only after complete preflight. Following + # process death, finish it before returning any mixed state to a reader. + try: + for path, _, after in changes: + self._publish_file(path, after) + self._discover(project_identity) + self._fs.remove(self._journal) + except Exception as exc: + raise self._fail("AGNIR_CHECKPOINT_RECOVERY_REQUIRED", "interrupted checkpoint could not be recovered") from exc + + def checkpoint(self, outcome: OperationOutcome, *, expected_revision: str | None = None) -> None: + with self.operation_guard(outcome.project_identity): + self._validate_pending_outcome(outcome) + resolved, writes = self._plan(outcome, expected_revision) + if not writes: + return + before = {path: self._fs.read(path, missing_ok=True) for path in writes} + record = { + "schema": "svif-agnir-transaction/1", + "project_identity": outcome.project_identity, + "operation_id": outcome.operation_id, + "discovery_digest": resolved.discovery_digest, + "changes": [{"path": path.relative_to(self.project_root).as_posix(), + "before": self._encode(before[path]), "after": self._encode(content), + "sha256": hashlib.sha256(content).hexdigest()} + for path, content in writes.items()], + } + # Journal first, fsynced and atomically replaced; no fixed-name temp. + self._fs.write(self._journal, (json.dumps(record, sort_keys=True) + "\n").encode()) + try: + for path, content in writes.items(): + self._publish_file(path, content) + if self._discover(outcome.project_identity).discovery_digest != resolved.discovery_digest: + raise self._fail("AGNIR_DISCOVERY_STALE", "Discovery Record changed during checkpoint") + self._fs.remove(self._journal) + except Exception as original: + try: + # Refuse to overwrite unrelated concurrent edits on rollback. + self._journal_changes(outcome.project_identity) + for path, content in before.items(): + if content is None: + self._fs.remove(path) + else: + self._publish_file(path, content) + self._fs.remove(self._journal) + except Exception as recovery: + raise self._fail("AGNIR_CHECKPOINT_RECOVERY_REQUIRED", "checkpoint and rollback failed; recovery intent retained") from recovery + raise self._fail("AGNIR_CHECKPOINT_FAILED", "checkpoint failed and all prior contents were restored") from original + # BaseException (process interruption) deliberately retains the + # journal. Next provider load rolls forward or fails closed. + + # The receipt is durable before an uncertain external-effect marker + # can be retired. A cleanup interruption is recovered at next entry. + self._clear_resolved_effect(outcome.project_identity) + + def _effect_record(self, project_identity: str) -> dict | None: + data = self._fs.read(self._effect, missing_ok=True) + if data is None: + return None + try: + value = json.loads(data) + if value["schema"] != "svif-agnir-effect/1" or value["project_identity"] != project_identity: + raise ValueError("effect identity mismatch") + for name in ("operation_id", "subject_identity", "target_identity", "provider", "revision"): + if not isinstance(value[name], str) or not value[name].strip(): + raise ValueError("effect identity missing") + return value + except (ValueError, KeyError, TypeError) as exc: + raise self._fail("AGNIR_EFFECT_RECONCILIATION_REQUIRED", "invalid pending external-effect record") from exc + + def pending_effect(self, project_identity: str) -> dict | None: + """Inspect uncertain effect identity without treating old state as success.""" + with self.operation_guard(project_identity): + self._discover(project_identity) + return self._effect_record(project_identity) + + def prepare_effect(self, outcome: OperationOutcome, request: CapabilityRequest, + *, expected_revision: str | None = None) -> None: + with self.operation_guard(outcome.project_identity): + if self._effect_record(outcome.project_identity) is not None: + raise self._fail("AGNIR_EFFECT_RECONCILIATION_REQUIRED", "do not replay an unresolved external effect") + resolved, snapshot = self._snapshot(outcome.project_identity) + self._plan(outcome, expected_revision, reject_replay=True) + if resolved.evidence is None or not request.target_identity: + raise BindingError("recoverable effects require a durable evidence locator and stable target") + record = { + "schema": "svif-agnir-effect/1", + "project_identity": outcome.project_identity, + "operation_id": outcome.operation_id, + "subject_identity": outcome.subject_identity, + "target_identity": request.target_identity, + "provider": request.provider, + "revision": snapshot.revision, + "verification": [asdict(item) for item in outcome.evidence if item.kind == "verification"], + "continuity_update": asdict(outcome.continuity_update), + } + self._fs.write(self._effect, (json.dumps(record, sort_keys=True) + "\n").encode()) + + def _matches_pending(self, record: dict, evidence: tuple[EvidenceRecord, ...]) -> bool: + return all(any(item.kind == kind and item.status == "succeeded" + and item.subject_identity == record["subject_identity"] + and item.target_identity == record["target_identity"] + and item.producer == record["provider"] for item in evidence) + for kind in ("delivery", "observation")) + + def _validate_pending_outcome(self, outcome: OperationOutcome) -> None: + record = self._effect_record(outcome.project_identity) + if record is not None and (outcome.operation_id != record["operation_id"] + or outcome.subject_identity != record["subject_identity"] or not outcome.externally_effectful + or not self._matches_pending(record, outcome.evidence)): + raise self._fail("AGNIR_EFFECT_RECONCILIATION_REQUIRED", "checkpoint does not independently resolve the pending effect") + + def _clear_resolved_effect(self, project_identity: str) -> None: + record = self._effect_record(project_identity) + if record is None: + return + resolved = self._discover(project_identity) + receipt = self._receipt(resolved, project_identity, record["operation_id"]) + if receipt is None: + return + data = self._fs.read(receipt, missing_ok=True) + if data is None: + return + try: + saved = json.loads(data) + evidence = tuple(EvidenceRecord(**item) for item in saved["evidence"]) + if (saved["project_identity"] != project_identity or saved["operation_id"] != record["operation_id"] + or saved["subject_identity"] != record["subject_identity"] + or not saved["externally_effectful"] or not self._matches_pending(record, evidence)): + raise ValueError("pending effect receipt mismatch") + except (ValueError, TypeError, KeyError) as exc: + raise self._fail("AGNIR_EFFECT_RECONCILIATION_REQUIRED", "pending effect has a conflicting receipt") from exc + self._fs.remove(self._effect) + + def reconcile_effect(self, project_identity: str, *, delivery: EvidenceRecord, + observation: EvidenceRecord) -> OperationOutcome: + """Trusted integration-only recovery. No actuation is performed here. + + The caller must obtain these receipts from an independent provider read, + never from an untrusted model assertion. Stale memory still blocks repair. + """ + with self.operation_guard(project_identity): + record = self._effect_record(project_identity) + if record is None: + raise BindingError("there is no pending effect to reconcile") + if not self._matches_pending(record, (delivery, observation)): + raise self._fail("AGNIR_EFFECT_RECONCILIATION_REQUIRED", "independent evidence does not resolve subject/target/provider") + verification = tuple(EvidenceRecord(**item) for item in record["verification"]) + result = OperationOutcome(project_identity, record["operation_id"], record["subject_identity"], + verification + (delivery, observation), True, + ContinuityUpdate(**record["continuity_update"])) + self.checkpoint(result, expected_revision=record["revision"]) + return result diff --git a/src/svif/continuity/filesystem.py b/src/svif/continuity/filesystem.py new file mode 100644 index 0000000..6b6e426 --- /dev/null +++ b/src/svif/continuity/filesystem.py @@ -0,0 +1,243 @@ +"""Contained filesystem I/O and process locking for Agnir checkpoint transactions. + +These are private recovery mechanics, not a second continuity format. On POSIX, +component-relative no-follow opens also prevent ancestor-symlink replacement. +The selected Project root and its parent are a trusted local filesystem boundary. +""" +from __future__ import annotations + +import os +import stat +import threading +import time +import uuid +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + + +class FilesystemSafetyError(RuntimeError): + pass + + +class ProjectFilesystem: + RUNTIME = ".svif-runtime" + _registry_lock = threading.Lock() + _locks: dict[str, threading.RLock] = {} + _local = threading.local() + + def __init__(self, root: Path) -> None: + self.root = root.resolve(strict=True) + if not self.root.is_dir(): + raise FilesystemSafetyError("Project root is not a directory") + self.relative_io = os.name == "posix" and os.open in os.supports_dir_fd + + def path(self, locator: str | Path) -> Path: + value = Path(locator) + if value.is_absolute(): + try: + value = value.relative_to(self.root) + except ValueError as exc: + raise FilesystemSafetyError("locator escapes authorized Project root") from exc + # Path renders native separators on Windows. Reject a POSIX filename + # containing backslashes, not normal Windows path separators. Colons + # in relative components remain forbidden (including NTFS ADS names). + if (not value.parts or value.drive or value.root + or any(p in {"..", ""} or ":" in p for p in value.parts) + or (os.name != "nt" and "\\" in str(value))): + raise FilesystemSafetyError("unsafe Project-relative path") + return self.root / value + + @contextmanager + def _parent(self, path: Path) -> Iterator[tuple[int | None, str | Path]]: + path = self.path(path) + parts = path.relative_to(self.root).parts + if self.relative_io: + fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + for part in parts[:-1]: + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = child + yield fd, parts[-1] + finally: + os.close(fd) + else: + current = self.root + for part in parts[:-1]: + current /= part + metadata = current.lstat() + if (not stat.S_ISDIR(metadata.st_mode) or current.is_symlink() + or getattr(current, "is_junction", lambda: False)()): + raise FilesystemSafetyError("unsafe ancestor in Project path") + yield None, path + + def metadata(self, path: Path) -> os.stat_result | None: + try: + with self._parent(path) as (fd, leaf): + result = os.stat(leaf, dir_fd=fd, follow_symlinks=False) + if stat.S_ISLNK(result.st_mode) or getattr(self.path(path), "is_junction", lambda: False)(): + raise FilesystemSafetyError("symlinks/reparse points are not authorized continuity locators") + return result + except FileNotFoundError: + return None + except OSError as exc: + raise FilesystemSafetyError("cannot safely inspect Project path") from exc + + @staticmethod + def _regular(metadata: os.stat_result) -> None: + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise FilesystemSafetyError("continuity object is not a private regular file") + + def read(self, path: Path, *, missing_ok: bool = False) -> bytes | None: + try: + with self._parent(path) as (fd, leaf): + # O_NONBLOCK prevents a replaced FIFO from hanging before fstat. + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + if fd is None: + metadata = self.metadata(path) + if metadata is None: + raise FileNotFoundError(str(path)) + self._regular(metadata) + opened = os.open(leaf, flags, dir_fd=fd) + with os.fdopen(opened, "rb") as stream: + self._regular(os.fstat(stream.fileno())) + return stream.read() + except FileNotFoundError: + if missing_ok: + return None + raise FilesystemSafetyError("required continuity file is missing") from None + except OSError as exc: + raise FilesystemSafetyError("cannot safely read Project file") from exc + + def files(self, directory: Path) -> list[Path]: + directory = self.path(directory) + try: + with self._parent(directory) as (fd, leaf): + if self.relative_io: + opened = os.open(leaf, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + try: + names = os.listdir(opened) + finally: + os.close(opened) + else: + metadata = self.metadata(directory) + if metadata is None or not stat.S_ISDIR(metadata.st_mode): + raise FilesystemSafetyError("evidence collection is not a safe directory") + names = os.listdir(directory) + result = [] + for name in sorted(names): + child = directory / name + metadata = self.metadata(child) + if metadata is None: + raise FilesystemSafetyError("evidence changed during discovery") + if stat.S_ISDIR(metadata.st_mode): + continue # The filesystem profile reads immediate evidence files only. + self._regular(metadata) + result.append(child) + return result + except OSError as exc: + raise FilesystemSafetyError("cannot safely list evidence collection") from exc + + def write(self, path: Path, content: bytes, *, mode: int = 0o600) -> None: + """Replace one regular file, fsyncing bytes and the directory on POSIX.""" + path = self.path(path) + current = self.metadata(path) + if current is not None: + self._regular(current) + mode = stat.S_IMODE(current.st_mode) + temporary = ".svif-write-" + uuid.uuid4().hex + try: + with self._parent(path) as (fd, leaf): + temp = temporary if fd is not None else path.parent / temporary + opened = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), + mode, dir_fd=fd) + try: + with os.fdopen(opened, "wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + # A no-follow recheck rejects an already-present unsafe leaf; + # rename itself does not follow a subsequently swapped leaf. + current = self.metadata(path) + if current is not None: + self._regular(current) + os.replace(temp, leaf, src_dir_fd=fd, dst_dir_fd=fd) + if fd is not None: + os.fsync(fd) + finally: + try: + os.unlink(temp, dir_fd=fd) + except FileNotFoundError: + pass + except OSError as exc: + raise FilesystemSafetyError("atomic continuity write failed") from exc + + def remove(self, path: Path) -> None: + current = self.metadata(path) + if current is None: + return + self._regular(current) + with self._parent(path) as (fd, leaf): + os.unlink(leaf, dir_fd=fd) + if fd is not None: + os.fsync(fd) + + @contextmanager + def guard(self, *, timeout: float = 10.0) -> Iterator[None]: + """Reentrant, cross-process lock; OS releases it on process termination.""" + key = str(self.root) + with self._registry_lock: + lock = self._locks.setdefault(key, threading.RLock()) + with lock: + held = getattr(self._local, "held", None) + if held is None: + held = self._local.held = {} + if key in held: + yield + return + runtime = self.root / self.RUNTIME + try: + runtime.mkdir(mode=0o700) + except FileExistsError: + pass + metadata = self.metadata(runtime) + if metadata is None or not stat.S_ISDIR(metadata.st_mode): + raise FilesystemSafetyError("unsafe runtime lock directory") + path = runtime / "agnir.lock" + with self._parent(path) as (parent, leaf): + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(leaf, flags, 0o600, dir_fd=parent) + try: + self._regular(os.fstat(fd)) + if os.name == "nt" and os.fstat(fd).st_size == 0: + os.write(fd, b"0") + deadline = time.monotonic() + timeout + while True: + try: + if os.name == "nt": + import msvcrt + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except (BlockingIOError, OSError): + if time.monotonic() >= deadline: + raise FilesystemSafetyError("continuity lock is busy; retry after reconciliation") + time.sleep(0.01) + held[key] = fd + try: + yield + finally: + del held[key] + if os.name == "nt": + import msvcrt + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + else: + import fcntl + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) diff --git a/src/svif/execution/chatgpt.py b/src/svif/execution/chatgpt.py index 6b32a6a..83d1fb3 100644 --- a/src/svif/execution/chatgpt.py +++ b/src/svif/execution/chatgpt.py @@ -58,6 +58,8 @@ def materialize(self, session: OperationSession) -> dict[str, Any]: "intent": session.request.intent, "bound_capabilities": sorted(session.binding.capabilities), "authority_grants": sorted(session.request.authority_grants), + "verification_required": session.request.verification_required, + "required_verifiers": sorted(session.request.required_verifiers), "continuity": { "state": self._serializable(continuity.state), "next_actions": self._serializable(continuity.next_actions), @@ -78,6 +80,8 @@ def parse_result( `Orchestrator.complete()` is called. """ + if not isinstance(payload, Mapping): + raise BindingError("ChatGPT result must be an object") if payload.get("project_identity") != session.binding.project_identity: raise BindingError("ChatGPT result Project identity does not match operation session") if payload.get("operation_id") != session.request.operation_id: @@ -94,6 +98,8 @@ def parse_result( for item in evidence_value: if not isinstance(item, Mapping): raise BindingError("ChatGPT evidence record must be an object") + if item.get("kind") in {"delivery", "observation", "checkpoint"}: + raise BindingError("effect/checkpoint receipts must come from trusted integration, not model claims") evidence.append( EvidenceRecord( kind=self._required_string(item.get("kind"), "evidence.kind"), @@ -155,4 +161,5 @@ def parse_result( evidence=tuple(evidence), capability_request=capability_request, continuity_update=continuity_update, + verification_needs_attestation=True, ) diff --git a/src/svif/runtime.py b/src/svif/runtime.py index 5d68ed8..192e8a9 100644 --- a/src/svif/runtime.py +++ b/src/svif/runtime.py @@ -1,6 +1,8 @@ from __future__ import annotations +from contextlib import nullcontext from dataclasses import dataclass +from threading import RLock from typing import Protocol @@ -20,6 +22,14 @@ class ProvenanceMismatch(SvifRuntimeError): """Evidence/candidate identity does not justify the requested transition.""" +class VerificationFailed(ProvenanceMismatch): + """Required verification is missing, failed, or not independently attested.""" + + +class SessionError(SvifRuntimeError): + """A session is foreign, already completed, or unsafe to replay.""" + + class ObservationMismatch(SvifRuntimeError): """Observed resulting state does not correspond to the delivered subject/target.""" @@ -46,6 +56,14 @@ class EvidenceRecord: producer: str | None = None +@dataclass(frozen=True) +class CapabilityPolicy: + """Trusted provider-owned policy; never selected by a result payload.""" + + effect: str + required_authorities: frozenset[str] + + @dataclass(frozen=True) class ContinuitySnapshot: project_identity: str @@ -53,6 +71,7 @@ class ContinuitySnapshot: next_actions: object | None = None decisions: object | None = None evidence: object | None = None + revision: str | None = None @dataclass(frozen=True) @@ -91,6 +110,8 @@ class WorkResult: evidence: tuple[EvidenceRecord, ...] = () capability_request: CapabilityRequest | None = None continuity_update: ContinuityUpdate = ContinuityUpdate() + # Set by untrusted-surface parsers, not accepted from their JSON payloads. + verification_needs_attestation: bool = False @dataclass(frozen=True) @@ -98,6 +119,9 @@ class OperationRequest: operation_id: str intent: str authority_grants: frozenset[str] = frozenset() + # This request is supplied by trusted integration code, not parse_result(). + verification_required: bool = True + required_verifiers: frozenset[str] = frozenset() @dataclass(frozen=True) @@ -124,7 +148,7 @@ class ContinuityProvider(Protocol): def load(self, project_identity: str) -> ContinuitySnapshot: ... - def checkpoint(self, outcome: OperationOutcome) -> None: ... + def checkpoint(self, outcome: OperationOutcome, *, expected_revision: str | None = None) -> None: ... class ExecutionSurface(Protocol): @@ -140,6 +164,8 @@ class ExecutionSurface(Protocol): class CapabilityProvider(Protocol): provider_id: str + def policy_for(self, operation: str) -> CapabilityPolicy: ... + def actuate(self, request: CapabilityRequest) -> EvidenceRecord: ... def observe(self, delivery: EvidenceRecord) -> EvidenceRecord: ... @@ -163,6 +189,11 @@ def __init__( self._continuity = self._index(continuity_providers, "provider_id", "Continuity Provider") self._surfaces = self._index(execution_surfaces, "surface_id", "Execution Surface") self._capabilities = self._index(capability_providers, "provider_id", "Capability Provider") + self._lock = RLock() + self._sessions: dict[tuple[str, str], tuple[OperationSession, str]] = {} + # Serializes effects within this Orchestrator. Cross-process integration + # must also coordinate its target (filesystem providers supply a guard). + self._completion_lock = RLock() @staticmethod def _index(items: tuple[object, ...], attr: str, label: str) -> dict[str, object]: @@ -227,22 +258,36 @@ def _require_observation_match(observation: EvidenceRecord, delivery: EvidenceRe "observation does not match the successfully delivered subject/target" ) - def begin(self, binding: ProjectBinding, request: OperationRequest) -> OperationSession: - """Load durable continuity and bind an operation before surface execution.""" + @staticmethod + def _names(value: frozenset[str], label: str) -> None: + if not isinstance(value, frozenset) or any( + not isinstance(item, str) or not item.strip() or item != item.strip() + for item in value + ): + raise BindingError(f"{label} must be a frozenset of non-empty names") + def begin(self, binding: ProjectBinding, request: OperationRequest) -> OperationSession: + """Load durable continuity and bind a single-use operation before execution.""" + for value in (binding.project_identity, request.operation_id, request.intent): + if not isinstance(value, str) or not value.strip(): + raise BindingError("Project identity, operation identity, and intent are required") + self._names(request.authority_grants, "authority_grants") + self._names(request.required_verifiers, "required_verifiers") + if type(request.verification_required) is not bool: + raise BindingError("verification_required must be a trusted boolean") continuity = self._continuity_for(binding) self._surface_for(binding) - - snapshot = continuity.load(binding.project_identity) - if snapshot.project_identity != binding.project_identity: - raise BindingError("Continuity Provider returned a different Project identity") - - context = ExecutionContext( - project_identity=binding.project_identity, - operation_id=request.operation_id, - continuity=snapshot, - ) - return OperationSession(binding=binding, request=request, context=context) + key = (binding.project_identity, request.operation_id) + with self._lock: + if key in self._sessions: + raise SessionError("operation identity already used; reconcile before starting new work") + snapshot = continuity.load(binding.project_identity) + if snapshot.project_identity != binding.project_identity: + raise BindingError("Continuity Provider returned a different Project identity") + context = ExecutionContext(binding.project_identity, request.operation_id, snapshot) + session = OperationSession(binding=binding, request=request, context=context) + self._sessions[key] = (session, "ready") + return session def complete( self, @@ -250,80 +295,128 @@ def complete( work: WorkResult, *, authority_grants: frozenset[str] = frozenset(), + verification_evidence: tuple[EvidenceRecord, ...] | None = None, ) -> OperationOutcome: - """Reconcile an externally/synchronously produced WorkResult and checkpoint. + """Complete only with trusted policy, exact verification and coherent state. - `authority_grants` is supplied by a trusted integration layer. An - untrusted model/result payload cannot grant itself protected authority. + Grants and verification_evidence are supplied by trusted integration code. + For parsed ChatGPT results, verification declarations alone are not proof. + A trusted synchronous execute() implementation may supply its own evidence. + A pre-effect rejection permits repair on the same session. Once effect or + checkpoint starts, any failure requires reconciliation, not blind replay. """ + key = (session.binding.project_identity, session.request.operation_id) + with self._lock: + saved = self._sessions.get(key) + if saved is None or saved[0] is not session or saved[1] != "ready": + raise SessionError("foreign, busy, completed, or uncertain operation session") + self._sessions[key] = (session, "completing") + started = False + try: + self._names(authority_grants, "authority_grants") + binding, request = session.binding, session.request + continuity = self._continuity_for(binding) + if not isinstance(work.subject_identity, str) or not work.subject_identity.strip(): + raise ProvenanceMismatch("Execution Surface returned no stable subject identity") + self._validate_evidence(work.evidence) + trusted = verification_evidence + if trusted is None: + trusted = () if work.verification_needs_attestation else work.evidence + self._validate_evidence(trusted) + if verification_evidence is not None and any(r.kind != "verification" for r in trusted): + raise BindingError("verification_evidence may contain only verification records") + # Historical evidence for other subjects does not verify this result. + for record in (*work.evidence, *trusted): + if (record.kind == "verification" and record.subject_identity == work.subject_identity + and record.status != "succeeded"): + raise VerificationFailed("failed, blocked, or unknown verification cannot complete successfully") + capability_request = work.capability_request + required = request.verification_required or bool(request.required_verifiers) or capability_request is not None + if required and not self._successful_verification(trusted, work.subject_identity): + raise VerificationFailed("required verification needs trusted success evidence for the exact subject") + producers = {r.producer for r in trusted if r.kind == "verification" + and r.subject_identity == work.subject_identity and r.status == "succeeded"} + if not request.required_verifiers.issubset(producers): + raise VerificationFailed("not all trusted operation-required verifiers have succeeded") + evidence = [r for r in work.evidence if not (work.verification_needs_attestation and r.kind == "verification")] + for record in trusted: + if record not in evidence: + evidence.append(record) + provider = None + if capability_request is not None: + if capability_request.provider not in binding.capabilities: + raise BindingError(f"Capability Provider is not bound to this Project: {capability_request.provider}") + provider = self._capabilities.get(capability_request.provider) + policy_for = getattr(provider, "policy_for", None) + if not callable(policy_for): + raise BindingError("Capability Provider has no trusted operation policy") + policy = policy_for(capability_request.operation) + if not isinstance(policy, CapabilityPolicy) or policy.effect != "actuate": + raise BindingError("unsupported or invalid trusted capability policy") + self._names(policy.required_authorities, "provider required_authorities") + if capability_request.effect != policy.effect: + raise BindingError("capability effect conflicts with trusted operation policy") + if capability_request.subject_identity != work.subject_identity: + raise ProvenanceMismatch("Capability request subject differs from the Execution Surface result subject") + # Keep the exact-subject invariant explicit at the effect boundary. + if not self._successful_verification(trusted, work.subject_identity): + raise ProvenanceMismatch("external actuation requires successful verification evidence for the exact subject") + extra = capability_request.authority_class + if extra is not None and not isinstance(extra, str): + raise BindingError("requested authority_class must be text or null") + required_authorities = policy.required_authorities | (frozenset({extra}) if extra else frozenset()) + effective_authority = request.authority_grants | authority_grants + if not required_authorities.issubset(effective_authority): + raise AuthorityRequired("external actuation requires trusted authority classes: " + + ", ".join(sorted(required_authorities - effective_authority))) + expected = session.context.continuity.revision + guard_factory = getattr(continuity, "operation_guard", None) + guard = guard_factory(binding.project_identity) if callable(guard_factory) else nullcontext() + with self._completion_lock, guard: + outcome = OperationOutcome(binding.project_identity, request.operation_id, work.subject_identity, + tuple(evidence), capability_request is not None, work.continuity_update) + preflight = getattr(continuity, "validate_checkpoint", None) + if callable(preflight): + preflight(outcome, expected_revision=expected) + if provider is not None: + prepare_effect = getattr(continuity, "prepare_effect", None) + if callable(prepare_effect): + # Persist uncertainty before crossing a non-transactional + # boundary. A restart must observe/reconcile, not redeploy. + prepare_effect(outcome, capability_request, expected_revision=expected) + started = True + delivery = provider.actuate(capability_request) + self._require_delivery_match(delivery, subject=work.subject_identity, + target=capability_request.target_identity) + evidence.append(delivery) + observation = provider.observe(delivery) + self._require_observation_match(observation, delivery) + evidence.append(observation) + outcome = OperationOutcome(binding.project_identity, request.operation_id, work.subject_identity, + tuple(evidence), capability_request is not None, work.continuity_update) + started = True + if expected is None: + continuity.checkpoint(outcome) + else: + continuity.checkpoint(outcome, expected_revision=expected) + with self._lock: + self._sessions[key] = (session, "completed") + return outcome + except BaseException: + with self._lock: + self._sessions[key] = (session, "uncertain" if started else "ready") + raise - binding = session.binding - request = session.request - continuity = self._continuity_for(binding) - - if not work.subject_identity: - raise ProvenanceMismatch("Execution Surface returned no stable subject identity") - - evidence = list(work.evidence) - externally_effectful = False - effective_authority = request.authority_grants | authority_grants - - capability_request = work.capability_request - if capability_request is not None: - externally_effectful = True - - if capability_request.provider not in binding.capabilities: - raise BindingError( - f"Capability Provider is not bound to this Project: {capability_request.provider}" - ) - provider = self._capabilities.get(capability_request.provider) - if provider is None: - raise BindingError(f"unavailable Capability Provider: {capability_request.provider}") - - if capability_request.effect != "actuate": - raise BindingError( - "minimal Svif kernel supports only an actuate request at the external-effect boundary" - ) - - if capability_request.subject_identity != work.subject_identity: - raise ProvenanceMismatch( - "Capability request subject differs from the Execution Surface result subject" - ) - - if not self._successful_verification(tuple(evidence), work.subject_identity): - raise ProvenanceMismatch( - "external actuation requires successful verification evidence for the exact subject" - ) - - required_authority = capability_request.authority_class - if required_authority and required_authority not in effective_authority: - raise AuthorityRequired( - f"external actuation requires authority class: {required_authority}" - ) - - delivery = provider.actuate(capability_request) - self._require_delivery_match( - delivery, - subject=work.subject_identity, - target=capability_request.target_identity, - ) - evidence.append(delivery) - - observation = provider.observe(delivery) - self._require_observation_match(observation, delivery) - evidence.append(observation) - - outcome = OperationOutcome( - project_identity=binding.project_identity, - operation_id=request.operation_id, - subject_identity=work.subject_identity, - evidence=tuple(evidence), - externally_effectful=externally_effectful, - continuity_update=work.continuity_update, - ) - - continuity.checkpoint(outcome) - return outcome + @staticmethod + def _validate_evidence(records: tuple[EvidenceRecord, ...]) -> None: + if not isinstance(records, tuple): + raise BindingError("evidence must be a tuple of EvidenceRecord values") + for record in records: + if (not isinstance(record, EvidenceRecord) + or record.kind not in {"candidate", "transformation", "verification", "delivery", "observation", "checkpoint"} + or record.status not in {"succeeded", "failed", "blocked", "unknown"} + or not isinstance(record.subject_identity, str) or not record.subject_identity.strip()): + raise BindingError("invalid evidence kind, status, or subject") def run(self, binding: ProjectBinding, request: OperationRequest) -> OperationOutcome: """Convenience path for an Execution Surface that supports synchronous execute().""" diff --git a/tests/test_agnir_transactions.py b/tests/test_agnir_transactions.py new file mode 100644 index 0000000..2f622a8 --- /dev/null +++ b/tests/test_agnir_transactions.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import threading +import unittest +from pathlib import Path +from unittest.mock import patch + +from svif.continuity.agnir import AgnirDiscoveryError, AgnirFilesystemContinuityProvider +from svif.continuity.filesystem import ProjectFilesystem, FilesystemSafetyError +from svif.runtime import ContinuityUpdate, OperationOutcome +from test_agnir_continuity import write_project, PROJECT, SUBJECT + +VERSIONS = ("0.1", "0.2", "1.0") +ROOT = Path(__file__).resolve().parents[1] + + +def outcome(operation="transaction", *, decisions="new decisions\n"): + return OperationOutcome(PROJECT, operation, SUBJECT, (), False, + ContinuityUpdate("new state\n", "new next\n", decisions)) + + +def memory_files(root): + return {p.relative_to(root).as_posix(): p.read_bytes() for p in (root / ".agnir").rglob("*") if p.is_file()} + + +class Crash(BaseException): + """Simulates termination without ordinary exception rollback.""" + + +def interrupted(root, number, exception=Crash): + provider = AgnirFilesystemContinuityProvider(root) + publish = provider._publish_file + calls = 0 + def injected(path, content): + nonlocal calls + calls += 1 + publish(path, content) + if calls == number: + raise exception("injected after write") + with patch.object(provider, "_publish_file", side_effect=injected): + provider.checkpoint(outcome()) + + +class AgnirTransactionTests(unittest.TestCase): + def test_native_relative_and_absolute_paths_are_contained(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + filesystem = ProjectFilesystem(root) + target = root / "memory" / "state.md" + self.assertEqual(filesystem.path("memory/state.md"), target) + self.assertEqual(filesystem.path(Path("memory") / "state.md"), target) + self.assertEqual(filesystem.path(target), target) + for unsafe in ("../outside", "memory/state.md:stream", root.parent / "outside"): + with self.subTest(unsafe=str(unsafe)), self.assertRaises(FilesystemSafetyError): + filesystem.path(unsafe) + if os.name == "nt": + self.assertEqual(filesystem.path("memory\\state.md"), target) + with self.assertRaises(FilesystemSafetyError): + filesystem.path("C:relative") + else: + with self.assertRaises(FilesystemSafetyError): + filesystem.path("memory\\state.md") + + def test_all_updates_are_preflighted_before_any_mutation(self): + for version in VERSIONS: + for decision in ("requested but no locator", 42): + with self.subTest(version=version, decision=decision), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root, version=version) + anchor = root / "AGNIR.yaml" + anchor.write_text(anchor.read_text().replace('decisions: ".agnir/decisions.md"', 'decisions: null')) + before = memory_files(root) + with self.assertRaises(Exception): + AgnirFilesystemContinuityProvider(root).checkpoint(outcome(decisions=decision)) + self.assertEqual(memory_files(root), before) + self.assertFalse((root / ".svif-runtime/agnir-pending.json").exists()) + + def test_normal_io_failure_restores_all_preimages_at_every_write_boundary(self): + for version in VERSIONS: + for split in range(1, 5): + with self.subTest(version=version, split=split), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root, version=version) + before = memory_files(root) + with self.assertRaises(AgnirDiscoveryError) as error: + interrupted(root, split, OSError) + self.assertEqual(error.exception.code, "AGNIR_CHECKPOINT_FAILED") + self.assertEqual(memory_files(root), before) + self.assertFalse((root / ".svif-runtime/agnir-pending.json").exists()) + + def test_interruption_rolls_forward_coherently_at_every_write_boundary(self): + for version in VERSIONS: + for split in range(1, 5): + with self.subTest(version=version, split=split), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root, version=version) + anchor = (root / "AGNIR.yaml").read_bytes() + with self.assertRaises(Crash): + interrupted(root, split) + self.assertTrue((root / ".svif-runtime/agnir-pending.json").exists()) + recovered = AgnirFilesystemContinuityProvider(root).load(PROJECT) + self.assertEqual((recovered.state, recovered.next_actions, recovered.decisions), + ("new state\n", "new next\n", "new decisions\n")) + self.assertEqual((root / "AGNIR.yaml").read_bytes(), anchor) + self.assertEqual(len(list((root / ".agnir/evidence").glob("svif-operation-*.json"))), 1) + self.assertFalse((root / ".svif-runtime/agnir-pending.json").exists()) + + def test_real_process_death_recovers_in_another_process(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root, version="1.0") + env = {**os.environ, "PYTHONPATH": str(ROOT / "src")} + code = '''import os,sys +from svif.continuity.agnir import AgnirFilesystemContinuityProvider +from svif.runtime import ContinuityUpdate,OperationOutcome +p=AgnirFilesystemContinuityProvider(sys.argv[1]) +f=p._publish_file +count=0 +def stop(path,content): + global count + f(path,content); count+=1 + if count==2: os._exit(71) +p._publish_file=stop +p.checkpoint(OperationOutcome(sys.argv[2],"process-death","sha256:fixture",(),False,ContinuityUpdate("new state\\n","new next\\n","new decisions\\n"))) +''' + process = subprocess.run([sys.executable, "-c", code, str(root), PROJECT], env=env, capture_output=True, timeout=10) + self.assertEqual(process.returncode, 71, process.stderr) + recover = '''import sys,json +from dataclasses import asdict +from svif.continuity.agnir import AgnirFilesystemContinuityProvider +print(json.dumps(asdict(AgnirFilesystemContinuityProvider(sys.argv[1]).load(sys.argv[2])))) +''' + process = subprocess.run([sys.executable, "-c", recover, str(root), PROJECT], env=env, capture_output=True, timeout=10) + self.assertEqual(process.returncode, 0, process.stderr) + snapshot = json.loads(process.stdout) + self.assertEqual(snapshot["state"], "new state\n") + self.assertEqual(snapshot["next_actions"], "new next\n") + self.assertEqual(snapshot["decisions"], "new decisions\n") + + def test_recovery_refuses_to_overwrite_unrelated_post_crash_edit(self): + for version in VERSIONS: + with self.subTest(version=version), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root, version=version) + with self.assertRaises(Crash): + interrupted(root, 1) + (root / ".agnir/next-actions.md").write_text("independent newer edit\n") + before = memory_files(root) + with self.assertRaises(AgnirDiscoveryError) as error: + AgnirFilesystemContinuityProvider(root).load(PROJECT) + self.assertEqual(error.exception.code, "AGNIR_CHECKPOINT_RECOVERY_REQUIRED") + self.assertEqual(memory_files(root), before) + + def test_rollback_failure_retains_recoverable_intent(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root) + provider = AgnirFilesystemContinuityProvider(root) + publish = provider._publish_file + calls = 0 + def fail(path, content): + nonlocal calls + calls += 1 + if calls >= 2: + raise OSError("simulated disk unavailable") + publish(path, content) + with patch.object(provider, "_publish_file", side_effect=fail): + with self.assertRaises(AgnirDiscoveryError) as error: + provider.checkpoint(outcome()) + self.assertEqual(error.exception.code, "AGNIR_CHECKPOINT_RECOVERY_REQUIRED") + self.assertTrue((root / ".svif-runtime/agnir-pending.json").exists()) + self.assertEqual(AgnirFilesystemContinuityProvider(root).load(PROJECT).next_actions, "new next\n") + + def test_stale_checkpoint_does_not_overwrite_newer_truth(self): + for version in VERSIONS: + with self.subTest(version=version), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root, version=version) + provider = AgnirFilesystemContinuityProvider(root) + prior = provider.load(PROJECT) + provider.checkpoint(outcome("first"), expected_revision=prior.revision) + saved = memory_files(root) + with self.assertRaises(AgnirDiscoveryError) as error: + provider.checkpoint(outcome("second"), expected_revision=prior.revision) + self.assertEqual(error.exception.code, "AGNIR_DISCOVERY_STALE") + self.assertEqual(memory_files(root), saved) + + def test_exact_retry_is_noop_and_operation_identity_cannot_be_reused(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root) + provider = AgnirFilesystemContinuityProvider(root) + prior = provider.load(PROJECT) + provider.checkpoint(outcome(), expected_revision=prior.revision) + before = memory_files(root) + times = {p: p.stat().st_mtime_ns for p in (root / ".agnir").rglob("*") if p.is_file()} + provider.checkpoint(outcome(), expected_revision=prior.revision) + self.assertEqual({p: p.stat().st_mtime_ns for p in times}, times) + with self.assertRaises(AgnirDiscoveryError): + provider.checkpoint(outcome(decisions="different result")) + self.assertEqual(memory_files(root), before) + + def test_journal_target_escape_or_corruption_is_rejected_without_writes(self): + for mutation in ("path", "sha256", "discovery_digest", "duplicate"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "project" + root.mkdir() + write_project(root) + outside = root.parent / "outside.txt" + outside.write_text("outside dummy\n") + with self.assertRaises(Crash): + interrupted(root, 1) + path = root / ".svif-runtime/agnir-pending.json" + journal = json.loads(path.read_text()) + if mutation == "path": journal["changes"][0]["path"] = "../outside.txt" + elif mutation == "sha256": journal["changes"][0]["sha256"] = "wrong" + elif mutation == "duplicate": journal["changes"][1] = journal["changes"][0] + else: journal[mutation] = "wrong" + path.write_text(json.dumps(journal)) + before = memory_files(root) + with self.assertRaises(AgnirDiscoveryError): + AgnirFilesystemContinuityProvider(root).load(PROJECT) + self.assertEqual(memory_files(root), before) + self.assertEqual(outside.read_text(), "outside dummy\n") + + def test_reader_waits_for_multi_file_publication(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root) + writer = AgnirFilesystemContinuityProvider(root) + reader = AgnirFilesystemContinuityProvider(root) + written, proceed, returned = threading.Event(), threading.Event(), threading.Event() + publish = writer._publish_file + results, errors = [], [] + def pause(path, content): + publish(path, content) + if path.name == "state.md": + written.set() + if not proceed.wait(5): raise RuntimeError("reader coordination timeout") + def write(): + try: + with patch.object(writer, "_publish_file", side_effect=pause): writer.checkpoint(outcome()) + except BaseException as error: errors.append(error) + def read(): + try: results.append(reader.load(PROJECT)) + except BaseException as error: errors.append(error) + finally: returned.set() + first = threading.Thread(target=write) + first.start() + self.assertTrue(written.wait(5)) + second = threading.Thread(target=read) + second.start() + self.assertFalse(returned.wait(0.05)) + proceed.set() + first.join(5); second.join(5) + self.assertFalse(first.is_alive() or second.is_alive()) + self.assertFalse(errors, errors) + self.assertEqual((results[0].state, results[0].next_actions), ("new state\n", "new next\n")) + + def test_symlink_reads_and_writes_never_escape_selected_project(self): + for version in VERSIONS: + for target in ("anchor", "state", "evidence", "runtime"): + with self.subTest(version=version, target=target), tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "project" + root.mkdir(); write_project(root, version=version) + dummy = root.parent / "outside" + dummy.write_text("not a secret, must not be accessed\n") + if target == "anchor": link = root / "AGNIR.yaml" + elif target == "state": link = root / ".agnir/state.md" + elif target == "evidence": link = root / ".agnir/evidence/outside.txt" + else: + dummy.unlink(); dummy.mkdir() + link = root / ".svif-runtime" + if link.exists(): link.unlink() + try: link.symlink_to(dummy, target_is_directory=target == "runtime") + except OSError: self.skipTest("symlink creation unavailable on this host") + with self.assertRaises(AgnirDiscoveryError): + AgnirFilesystemContinuityProvider(root).load(PROJECT) + if target != "runtime": self.assertEqual(dummy.read_text(), "not a secret, must not be accessed\n") + + def test_output_receipt_symlink_is_rejected_before_state_write(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "project" + root.mkdir(); write_project(root) + provider = AgnirFilesystemContinuityProvider(root) + receipt = provider._receipt(provider._discover(PROJECT), PROJECT, "transaction") + outside = root.parent / "outside" + outside.write_text("dummy") + try: receipt.symlink_to(outside) + except OSError: self.skipTest("symlink creation unavailable") + with self.assertRaises(AgnirDiscoveryError): provider.checkpoint(outcome()) + self.assertIn("old", (root / ".agnir/state.md").read_text()) + self.assertEqual(outside.read_text(), "dummy") + + def test_hardlinked_evidence_is_not_read(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "project" + root.mkdir(); write_project(root) + outside = root.parent / "outside" + outside.write_text("dummy") + try: os.link(outside, root / ".agnir/evidence/link") + except OSError: self.skipTest("hardlinks unavailable") + with self.assertRaises(AgnirDiscoveryError): AgnirFilesystemContinuityProvider(root).load(PROJECT) + + def test_predictable_old_temp_symlink_cannot_redirect_write(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "project" + root.mkdir(); write_project(root) + outside = root.parent / "outside" + outside.write_text("dummy") + try: (root / ".agnir/.state.md.svif-tmp").symlink_to(outside) + except OSError: self.skipTest("symlinks unavailable") + AgnirFilesystemContinuityProvider(root).checkpoint(outcome()) + self.assertEqual(outside.read_text(), "dummy") + + def test_duplicate_keys_and_aliased_memory_roles_are_rejected(self): + for mutation in ("duplicate", "alias"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root) + path = root / "AGNIR.yaml" + text = path.read_text() + if mutation == "duplicate": text = text.replace(' version: "0.1"', ' version: "0.1"\n version: "0.2"') + else: text = text.replace('next_actions: ".agnir/next-actions.md"', 'next_actions: ".agnir/state.md"') + path.write_text(text) + with self.assertRaises(AgnirDiscoveryError) as error: AgnirFilesystemContinuityProvider(root).load(PROJECT) + self.assertEqual(error.exception.code, "AGNIR_DISCOVERY_INCONSISTENT") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_chatgpt_surface.py b/tests/test_chatgpt_surface.py index 6938fa4..3b1d46a 100644 --- a/tests/test_chatgpt_surface.py +++ b/tests/test_chatgpt_surface.py @@ -8,6 +8,7 @@ from svif.execution.chatgpt import ChatGPTExecutionSurface from svif.runtime import ( BindingError, + EvidenceRecord, OperationRequest, Orchestrator, ProjectBinding, @@ -78,7 +79,10 @@ def test_two_phase_bridge_materializes_agnir_and_checkpoints_result(self) -> Non }, }, ) - outcome = orchestrator.complete(session, work) + # A trusted tool/verifier receipt, not the model's declaration. + outcome = orchestrator.complete(session, work, verification_evidence=( + EvidenceRecord("verification", SUBJECT, producer="fixture-verifier"), + )) self.assertEqual(outcome.subject_identity, SUBJECT) self.assertIn( diff --git a/tests/test_founding_e2e.py b/tests/test_founding_e2e.py index ab7f571..5d9cbde 100644 --- a/tests/test_founding_e2e.py +++ b/tests/test_founding_e2e.py @@ -8,7 +8,7 @@ from svif.capabilities.cloudflare import CloudflareWorkersCapabilityProvider from svif.continuity.agnir import AgnirFilesystemContinuityProvider from svif.execution.chatgpt import ChatGPTExecutionSurface -from svif.runtime import OperationRequest, Orchestrator, ProjectBinding, ProviderBinding +from svif.runtime import EvidenceRecord, OperationRequest, Orchestrator, ProjectBinding, ProviderBinding PROJECT = "urn:test:svif-founding-e2e" @@ -120,6 +120,7 @@ def test_agnir_chatgpt_cloudflare_closes_the_full_product_loop(self) -> None: session, work, authority_grants=frozenset({AUTHORITY}), + verification_evidence=(EvidenceRecord("verification", SUBJECT, producer="founding-e2e-verifier"),), ) self.assertTrue(outcome.externally_effectful) diff --git a/tests/test_local_acceptance_harness.py b/tests/test_local_acceptance_harness.py new file mode 100644 index 0000000..cd466a2 --- /dev/null +++ b/tests/test_local_acceptance_harness.py @@ -0,0 +1,37 @@ +"""Tests the acceptance checker itself, NOT native-host acceptance evidence.""" +from __future__ import annotations +import importlib.util +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("local_acceptance", ROOT / "checks/check_local_install.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class LocalAcceptanceHarnessTests(unittest.TestCase): + def test_discovery_requires_exact_enabled_installed_skill_path(self): + expected = Path("/tmp/installed/skills/svif/SKILL.md") + good = {"name": "svif", "enabled": True, "path": str(expected)} + self.assertEqual(module.discovered_skill({"data": [{"skills": [good], "errors": []}]}, expected), good) + for skills, errors in (([], []), ([dict(good, enabled=False)], []), + ([dict(good, path="/tmp/wrong/SKILL.md")], []), + ([good, good], []), ([good], [{"message": "invalid Skill"}])): + with self.subTest(skills=skills), self.assertRaises(RuntimeError): + module.discovered_skill({"data": [{"skills": skills, "errors": errors}]}, expected) + + def test_file_map_detects_changed_bytes_and_rejects_symlinks(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "item").write_text("before") + before = module.file_map(root) + (root / "item").write_text("after") + self.assertNotEqual(before, module.file_map(root)) + try: + (root / "alias").symlink_to(root / "item") + except OSError: + self.skipTest("symlinks unavailable") + with self.assertRaises(RuntimeError): + module.file_map(root) diff --git a/tests/test_plugin_submission_bundle.py b/tests/test_plugin_submission_bundle.py index c71708f..aba5d15 100644 --- a/tests/test_plugin_submission_bundle.py +++ b/tests/test_plugin_submission_bundle.py @@ -3,6 +3,9 @@ import hashlib import importlib.util import json +import os +import shutil +from unittest.mock import patch import tempfile import unittest import zipfile @@ -77,6 +80,94 @@ def test_archive_has_one_root_safe_paths_and_required_skill_only_contents(self) self.assertNotIn(".", name.split("/")) self.assertNotIn("..", name.split("/")) + def test_rejects_destination_inside_source_without_mutation(self) -> None: + builder = load_builder() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "plugin" + shutil.copytree(PLUGIN_ROOT, root) + before = {p.relative_to(root): p.read_bytes() for p in root.rglob("*") if p.is_file()} + with patch.object(builder, "PLUGIN_ROOT", root): + with self.assertRaises(ValueError): + builder.build_submission_bundle(root / "plugin.json") + with self.assertRaises(ValueError): + builder.build_submission_bundle(root / "self.zip") + self.assertEqual(before, {p.relative_to(root): p.read_bytes() for p in root.rglob("*") if p.is_file()}) + + def test_size_entry_and_depth_limits_fail_without_destroying_prior_archive(self) -> None: + builder = load_builder() + for limit in ("MAX_ARCHIVE_BYTES", "MAX_MEMBER_BYTES", "MAX_UNCOMPRESSED_BYTES", "MAX_ENTRIES", "MAX_PATH_SEGMENTS"): + with self.subTest(limit=limit), tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / "prior.zip" + output.write_bytes(b"prior accepted archive") + with patch.object(builder, limit, 1), self.assertRaises(ValueError): + builder.build_submission_bundle(output) + self.assertEqual(output.read_bytes(), b"prior accepted archive") + self.assertEqual(list(Path(temporary).glob(".svif-bundle-*")), []) + + def test_missing_required_file_and_invalid_member_names_fail(self) -> None: + builder = load_builder() + for bad in ("missing", "space ", "drive:name"): + with self.subTest(bad=bad), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "plugin" + shutil.copytree(PLUGIN_ROOT, root) + if bad == "missing": + (root / "skills/svif/SKILL.md").unlink() + else: + if os.name == "nt" and bad in {"space ", "drive:name"}: + continue # These spellings cannot be created as distinct NTFS files. + (root / bad).write_text("invalid", encoding="utf-8") + with patch.object(builder, "PLUGIN_ROOT", root), self.assertRaises(ValueError): + builder.build_submission_bundle(Path(temporary) / "bad.zip") + + def test_normalization_collision_rejected_on_case_insensitive_hosts(self) -> None: + builder = load_builder() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "plugin" + shutil.copytree(PLUGIN_ROOT, root) + original = root / "plugin.json" + alias = root / "PLUGIN.json" + before = original.read_bytes() + if not alias.exists(): + alias.write_bytes(before) + # Case-insensitive hosts cannot create both physical names. Model + # the two-member archive input without overwriting the manifest. + # File metadata and reads are still real on every tested host. + with patch.object(builder, "PLUGIN_ROOT", root), \ + patch.object(Path, "rglob", return_value=[original, alias]), \ + self.assertRaisesRegex(ValueError, "normalization collision"): + builder.build_submission_bundle(Path(temporary) / "bad.zip") + self.assertEqual(original.read_bytes(), before) + self.assertFalse((Path(temporary) / "bad.zip").exists()) + + def test_io_failure_leaves_existing_output_untouched(self) -> None: + builder = load_builder() + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / "prior.zip" + output.write_bytes(b"prior accepted archive") + with patch.object(builder.zipfile.ZipFile, "writestr", side_effect=OSError("injected")), self.assertRaises(OSError): + builder.build_submission_bundle(output) + self.assertEqual(output.read_bytes(), b"prior accepted archive") + self.assertEqual(list(Path(temporary).glob(".svif-bundle-*")), []) + + def test_symlink_source_and_output_are_rejected(self) -> None: + builder = load_builder() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "plugin" + shutil.copytree(PLUGIN_ROOT, root) + outside = Path(temporary) / "outside" + outside.write_text("unchanged", encoding="utf-8") + try: + (root / "linked").symlink_to(outside) + except OSError: + self.skipTest("symlinks unavailable") + with patch.object(builder, "PLUGIN_ROOT", root), self.assertRaises(ValueError): + builder.build_submission_bundle(Path(temporary) / "bad.zip") + output = Path(temporary) / "output.zip" + output.symlink_to(outside) + with self.assertRaises(ValueError): + builder.build_submission_bundle(output) + self.assertEqual(outside.read_text(), "unchanged") + def test_manifest_remains_skills_only_for_zip_submission(self) -> None: manifest = json.loads((PLUGIN_ROOT / "plugin.json").read_text(encoding="utf-8")) interface = manifest["extensions"]["com.openai"]["interface"] diff --git a/tests/test_release_safety.py b/tests/test_release_safety.py new file mode 100644 index 0000000..9ad1d0b --- /dev/null +++ b/tests/test_release_safety.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +from dataclasses import replace +import json +import tempfile +import unittest +from pathlib import Path + +from svif.capabilities.cloudflare import CloudflareWorkersCapabilityProvider +from svif.continuity.agnir import AgnirDiscoveryError, AgnirFilesystemContinuityProvider +from svif.execution.chatgpt import ChatGPTExecutionSurface +from svif.runtime import ( + AuthorityRequired, BindingError, ContinuitySnapshot, ContinuityUpdate, + EvidenceRecord, OperationRequest, Orchestrator, ProjectBinding, ProviderBinding, + SessionError, VerificationFailed, WorkResult, +) +from test_agnir_continuity import write_project, PROJECT, SUBJECT + +TARGET = "urn:test:non-production-worker" + + +class Memory: + provider_id = "memory" + def __init__(self): + self.saved = [] + def load(self, project_identity): + return ContinuitySnapshot(project_identity) + def checkpoint(self, outcome): + self.saved.append(outcome) + + +class Transport: + def __init__(self, observed=True): + self.calls = [] + self.observed = observed + def deploy_worker(self, **kwargs): + self.calls.append(("deploy", kwargs)) + def observe_worker(self, **kwargs): + self.calls.append(("observe", kwargs)) + return self.observed + + +def rig(*, memory=None, required=True, verifiers=frozenset()): + memory = Memory() if memory is None else memory + surface = ChatGPTExecutionSurface() + transport = Transport() + capability = CloudflareWorkersCapabilityProvider(transport) + runtime = Orchestrator(continuity_providers=(memory,), execution_surfaces=(surface,), + capability_providers=(capability,)) + binding = ProjectBinding(PROJECT, ProviderBinding(memory.provider_id), surface.surface_id, + frozenset({capability.provider_id})) + session = runtime.begin(binding, OperationRequest("safety-op", "verify and checkpoint fixture", + verification_required=required, required_verifiers=verifiers)) + payload = {"project_identity": PROJECT, "operation_id": "safety-op", "subject_identity": SUBJECT, + "evidence": [{"kind": "verification", "subject_identity": SUBJECT, "status": "succeeded"}], + "continuity_update": {"state": "new state\n", "next_actions": "new next\n"}, + "capability_request": {"provider": capability.provider_id, "operation": "deploy_verified_worker", + "effect": "actuate", "subject_identity": SUBJECT, "target_identity": TARGET}} + return runtime, session, surface, transport, memory, payload + + +def attestation(producer="trusted-fixture-verifier", status="succeeded", subject=SUBJECT): + return (EvidenceRecord("verification", subject, status=status, producer=producer),) + + +class ReleaseSafetyTests(unittest.TestCase): + def test_authority_omission_null_empty_and_downgrade_cannot_bypass_provider_policy(self): + for value in ("ABSENT", None, "", "read", "none", "verification"): + with self.subTest(authority=value): + rt, session, surface, transport, memory, payload = rig() + if value != "ABSENT": + payload["capability_request"]["authority_class"] = value + payload["authority_grants"] = ["protected-delivery"] + with self.assertRaises(AuthorityRequired): + rt.complete(session, surface.parse_result(session, payload), verification_evidence=attestation()) + self.assertEqual(transport.calls, []) + self.assertEqual(memory.saved, []) + + def test_trusted_authority_allows_omitted_advisory_class(self): + rt, session, surface, transport, memory, payload = rig() + result = rt.complete(session, surface.parse_result(session, payload), verification_evidence=attestation(), + authority_grants=frozenset({"protected-delivery"})) + self.assertTrue(result.externally_effectful) + self.assertEqual([v[0] for v in transport.calls], ["deploy", "observe"]) + self.assertEqual(len(memory.saved), 1) + + def test_model_verification_success_is_not_its_own_attestation(self): + rt, session, surface, transport, memory, payload = rig() + with self.assertRaises(VerificationFailed): + rt.complete(session, surface.parse_result(session, payload), authority_grants=frozenset({"protected-delivery"})) + self.assertEqual(transport.calls, []) + self.assertEqual(memory.saved, []) + + def test_required_non_effectful_verification_blocks_missing_wrong_failed_unknown(self): + for receipt in ((), attestation(subject="sha256:other"), attestation(status="failed"), + attestation(status="unknown"), attestation(status="blocked")): + with self.subTest(receipt=receipt): + rt, session, surface, transport, memory, payload = rig() + del payload["capability_request"] + with self.assertRaises(VerificationFailed): + rt.complete(session, surface.parse_result(session, payload), verification_evidence=receipt) + self.assertFalse(memory.saved) + + def test_failed_declared_verification_never_checkpoints_completion_even_when_not_required(self): + for required in (True, False): + rt, session, surface, transport, memory, payload = rig(required=required) + del payload["capability_request"] + payload["evidence"][0]["status"] = "failed" + with self.assertRaises(VerificationFailed): + rt.complete(session, surface.parse_result(session, payload), verification_evidence=attestation()) + self.assertFalse(memory.saved) + + def test_trusted_non_applicable_case_does_not_require_invented_verification(self): + rt, session, surface, transport, memory, payload = rig(required=False) + del payload["capability_request"] + payload["evidence"] = [] + result = rt.complete(session, surface.parse_result(session, payload)) + self.assertFalse(result.externally_effectful) + self.assertEqual(transport.calls, []) + self.assertEqual(len(memory.saved), 1) + + def test_result_cannot_disable_trusted_verification_requirement(self): + rt, session, surface, transport, memory, payload = rig() + del payload["capability_request"] + payload["verification_required"] = False + payload["verification_needs_attestation"] = False + with self.assertRaises(VerificationFailed): + rt.complete(session, surface.parse_result(session, payload)) + self.assertFalse(memory.saved) + + def test_every_required_verifier_must_succeed(self): + rt, session, surface, transport, memory, payload = rig(verifiers=frozenset({"lint", "unit"})) + del payload["capability_request"] + with self.assertRaises(VerificationFailed): + rt.complete(session, surface.parse_result(session, payload), verification_evidence=attestation("lint")) + rt.complete(session, surface.parse_result(session, payload), + verification_evidence=attestation("lint") + attestation("unit")) + self.assertEqual(len(memory.saved), 1) + + def test_model_cannot_forge_delivery_observation_or_checkpoint_receipts(self): + for kind in ("delivery", "observation", "checkpoint"): + rt, session, surface, transport, memory, payload = rig() + payload["evidence"][0]["kind"] = kind + with self.assertRaises(BindingError): + surface.parse_result(session, payload) + self.assertFalse(transport.calls) + + def test_unknown_operation_fails_before_transport(self): + rt, session, surface, transport, memory, payload = rig() + payload["capability_request"]["operation"] = "delete_everything" + with self.assertRaises(BindingError): + rt.complete(session, surface.parse_result(session, payload), verification_evidence=attestation(), + authority_grants=frozenset({"protected-delivery"})) + self.assertFalse(transport.calls) + + def test_completed_session_cannot_replay_or_be_forged(self): + rt, session, surface, transport, memory, payload = rig() + work = surface.parse_result(session, payload) + with self.assertRaises(SessionError): + rt.complete(replace(session), work, verification_evidence=attestation()) + rt.complete(session, work, verification_evidence=attestation(), authority_grants=frozenset({"protected-delivery"})) + with self.assertRaises(SessionError): + rt.complete(session, work, verification_evidence=attestation(), authority_grants=frozenset({"protected-delivery"})) + self.assertEqual(len(transport.calls), 2) + + def test_failed_observation_is_uncertain_and_cannot_blindly_replay(self): + rt, session, surface, transport, memory, payload = rig() + transport.observed = False + work = surface.parse_result(session, payload) + from svif.runtime import ObservationMismatch + with self.assertRaises(ObservationMismatch): + rt.complete(session, work, verification_evidence=attestation(), authority_grants=frozenset({"protected-delivery"})) + with self.assertRaises(SessionError): + rt.complete(session, work, verification_evidence=attestation(), authority_grants=frozenset({"protected-delivery"})) + self.assertFalse(memory.saved) + self.assertEqual(len(transport.calls), 2) + + def test_stale_snapshot_is_rejected_before_external_effect(self): + for version in ("0.1", "0.2", "1.0"): + with self.subTest(version=version), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root, version=version) + memory = AgnirFilesystemContinuityProvider(root) + rt, session, surface, transport, _, payload = rig(memory=memory) + (root / ".agnir/state.md").write_text("newer concurrent truth\n") + with self.assertRaises(AgnirDiscoveryError) as raised: + rt.complete(session, surface.parse_result(session, payload), verification_evidence=attestation(), + authority_grants=frozenset({"protected-delivery"})) + self.assertEqual(raised.exception.code, "AGNIR_DISCOVERY_STALE") + self.assertFalse(transport.calls) + + def test_invalid_checkpoint_is_rejected_before_external_effect(self): + for version in ("0.1", "0.2", "1.0"): + with self.subTest(version=version), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root, version=version) + anchor = root / "AGNIR.yaml" + anchor.write_text(anchor.read_text().replace('decisions: ".agnir/decisions.md"', 'decisions: null')) + memory = AgnirFilesystemContinuityProvider(root) + rt, session, surface, transport, _, payload = rig(memory=memory) + payload["continuity_update"]["decisions"] = "cannot be stored" + with self.assertRaises(AgnirDiscoveryError): + rt.complete(session, surface.parse_result(session, payload), verification_evidence=attestation(), + authority_grants=frozenset({"protected-delivery"})) + self.assertFalse(transport.calls) + self.assertIn("old", (root / ".agnir/state.md").read_text()) + + def test_provider_policy_matches_registered_descriptor(self): + root = Path(__file__).resolve().parents[1] + descriptor = json.loads((root / "integrations/cloudflare/adapter.json").read_text()) + operation = next(op for op in descriptor["operations"] if op["name"] == "deploy_verified_worker") + policy = CloudflareWorkersCapabilityProvider(Transport()).policy_for(operation["name"]) + self.assertEqual(policy.effect, operation["effect"]) + self.assertEqual(policy.required_authorities, frozenset({operation["authority"]})) + + def test_uncertain_effect_survives_restart_and_only_observation_can_resolve_it(self): + from svif.runtime import ObservationMismatch + for version in ("0.1", "0.2", "1.0"): + with self.subTest(version=version), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root, version=version) + memory = AgnirFilesystemContinuityProvider(root) + rt, session, surface, transport, _, payload = rig(memory=memory) + transport.observed = False + with self.assertRaises(ObservationMismatch): + rt.complete(session, surface.parse_result(session, payload), verification_evidence=attestation(), + authority_grants=frozenset({"protected-delivery"})) + fresh = AgnirFilesystemContinuityProvider(root) + with self.assertRaises(AgnirDiscoveryError) as error: + fresh.load(PROJECT) + self.assertEqual(error.exception.code, "AGNIR_EFFECT_RECONCILIATION_REQUIRED") + pending = fresh.pending_effect(PROJECT) + self.assertEqual(pending["subject_identity"], SUBJECT) + self.assertEqual(pending["target_identity"], TARGET) + delivery = EvidenceRecord("delivery", SUBJECT, target_identity=TARGET, producer="cloudflare.workers") + wrong = EvidenceRecord("observation", "wrong", target_identity=TARGET, producer="cloudflare.workers") + with self.assertRaises(AgnirDiscoveryError): + fresh.reconcile_effect(PROJECT, delivery=delivery, observation=wrong) + # Trusted independent transport read, not another actuation. + transport.observed = True + observed = CloudflareWorkersCapabilityProvider(transport).observe(delivery) + fresh.reconcile_effect(PROJECT, delivery=delivery, observation=observed) + self.assertEqual(fresh.load(PROJECT).state, "new state\n") + self.assertIsNone(fresh.pending_effect(PROJECT)) + self.assertEqual(sum(call[0] == "deploy" for call in transport.calls), 1) + + def test_completed_durable_operation_cannot_redeploy_after_restart(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_project(root) + memory = AgnirFilesystemContinuityProvider(root) + rt, session, surface, transport, _, payload = rig(memory=memory) + rt.complete(session, surface.parse_result(session, payload), verification_evidence=attestation(), + authority_grants=frozenset({"protected-delivery"})) + second, new_session, new_surface, new_transport, _, new_payload = rig(memory=AgnirFilesystemContinuityProvider(root)) + with self.assertRaises(AgnirDiscoveryError) as error: + second.complete(new_session, new_surface.parse_result(new_session, new_payload), + verification_evidence=attestation(), authority_grants=frozenset({"protected-delivery"})) + self.assertEqual(error.exception.code, "AGNIR_OPERATION_REPLAY") + self.assertFalse(new_transport.calls) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 9edcceb..0fcd1d8 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -4,6 +4,7 @@ from svif.runtime import ( AuthorityRequired, + CapabilityPolicy, CapabilityRequest, ContinuitySnapshot, EvidenceRecord, @@ -60,6 +61,9 @@ def __init__(self, events: list[str], *, observation_subject: str = SUBJECT) -> self.observation_subject = observation_subject self.actuation_count = 0 + def policy_for(self, operation: str) -> CapabilityPolicy: + return CapabilityPolicy("actuate", frozenset({"protected-delivery"})) + def actuate(self, request: CapabilityRequest) -> EvidenceRecord: self.events.append("actuate") self.actuation_count += 1