From f4d2c090bb292a85f9484753f9c1679b96a71a12 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 05:03:53 +0100 Subject: [PATCH 1/7] feat(artifacts): execute default pre-submit checks --- .../AUTH_HANDOFF.md | 9 + .../CHUNK_MAP.md | 4 +- .../DISCOVERY.md | 85 +++ .../STATUS.md | 20 +- ...-ART-001-04B2-default-checker-execution.md | 106 ++- ...S-ART-001-04B2-internal-review-evidence.md | 59 ++ .../WS-ART-001-04B2-pr-trust-bundle.md | 120 +++ .github/workflows/backend.yml | 20 + backend/app/adapters/artifacts/__init__.py | 1 + backend/app/core/config.py | 10 + backend/app/interfaces/artifact_operations.py | 19 +- backend/app/modules/artifacts/preparation.py | 189 ++++- backend/app/modules/artifacts/sources.py | 11 +- .../modules/artifacts/submission_archive.py | 324 ++++++++- .../artifacts/submission_materialization.py | 145 ++++ .../modules/checkers/pre_submit_defaults.py | 84 +++ .../modules/checkers/pre_submit_execution.py | 357 +++++++++ backend/app/modules/checkers/runner.py | 85 +-- backend/scripts/run_test_lanes.py | 2 + backend/tests/test_artifact_cleanup_wiring.py | 5 + backend/tests/test_checker_materialization.py | 244 +++++++ backend/tests/test_config.py | 7 + .../test_default_pre_submit_execution.py | 683 ++++++++++++++++++ docs/architecture_checker_framework.md | 10 + docs/architecture_data_model.md | 7 + docs/architecture_lockdown.md | 7 + docs/glossary.md | 5 + docs/operations_authorization_service.md | 12 +- docs/roadmap_status.md | 5 + docs/spec_artifact_storage_service.md | 16 + docs/spec_authorization_service.md | 8 + docs/template_checker_policy.md | 6 + docs/template_submission_artifact_policy.md | 5 + docs/template_submission_packet.md | 8 + 34 files changed, 2576 insertions(+), 102 deletions(-) create mode 100644 .agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-internal-review-evidence.md create mode 100644 .agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-pr-trust-bundle.md create mode 100644 backend/app/modules/artifacts/submission_materialization.py create mode 100644 backend/app/modules/checkers/pre_submit_defaults.py create mode 100644 backend/app/modules/checkers/pre_submit_execution.py create mode 100644 backend/tests/test_checker_materialization.py create mode 100644 backend/tests/test_default_pre_submit_execution.py diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md index 04c916a38..b6283a64e 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md @@ -144,6 +144,15 @@ Each fixed service action retains its canonical provisioned service identity, matrix row, resource facts, terminal reauthorization, and separate activation evidence. No human grant supplies fixed service authority. +04B2 defines the pre-submit materializer facts as task, assignment, project, +effective submission-artifact-policy ID, pre-submit checker-policy ID, +process-local prepared generation, effective-plan hash, catalogue-manifest hash, +archive SHA-256/byte count, and semantic-manifest hash. The fixed +`workstream.artifact.materializer` capability must be consumed before any +prepared-byte read, ZIP open, workspace reservation, or checker result. ART +ships only a deny-by-default adapter while the action is planned; XINT-06A owns +the later activation after 04B3. + ## Fail-Closed Rule An ART implementation contract stops if its required AUTH registration or diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md index 7997344af..e1a87cd4f 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md @@ -30,8 +30,8 @@ they cross multiple L1 boundaries. | `WS-ART-001-04A2` | Add bounded one-outer-ZIP intake and archive-safety inspection in private scratch. | L1 | Merged PR #266 | | `WS-ART-001-04A3` | Add canonical semantic manifest, executable normalization, and unchanged-work gate. | L1 | Merged PR #268 | | `WS-ART-001-04A4` | Former early removal of the legacy independently invocable caller-owned submission-precheck route and contract. | L1 | Superseded by PLAN5; complete removal belongs to 05B | -| `WS-ART-001-04B1` | Add the single versioned checker catalogue and compile one effective execution plan from platform defaults plus locked project policy. | L1 | Active implementation | -| `WS-ART-001-04B2` | Materialize the sealed manifest tree once and execute the mandatory platform/default catalogue phases. | L1 | Proposed after 04B1 | +| `WS-ART-001-04B1` | Add the single versioned checker catalogue and compile one effective execution plan from platform defaults plus locked project policy. | L1 | Merged PR #276 | +| `WS-ART-001-04B2` | Materialize the sealed manifest tree once and execute the mandatory platform/default catalogue phases. | L1 | Active implementation | | `WS-ART-001-04B3` | Execute locked project-policy rules through the same plan and persist one bounded immutable evidence set. | L1 | Proposed after 04B2 | | `WS-ART-001-04C1` | Reauthorize and atomically persist capacity plus durable put intent, then write the checked ZIP once. | L1 | Proposed after XINT-06A | | `WS-ART-001-04C2` | Reuse verification/recovery to publish one capacity-charged ready admission and compose the hidden continuous endpoint. | L1 | Proposed after 04C1 | diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/DISCOVERY.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/DISCOVERY.md index 9153caa8a..9f72d4f65 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/DISCOVERY.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/DISCOVERY.md @@ -278,3 +278,88 @@ embedded in the compiled `PreSubmitCheckerPolicy`. The existing task-locked compiled-bundle hash therefore commits to the exact default snapshot without a second task-lock field; runtime derives and records the effective-plan hash from that same snapshot plus the locked project rules. + +## 2026-08-05 ART-04B2 Default Checker Execution Discovery + +Observations on merged `main` at `bb77ff4a`: + +- `ArtifactPreparationService` already owns the one process-local + `PreparedArtifact`, its server-computed archive commitment, a read-only + anonymous second-pass stream, deadline enforcement, and idempotent release. + `PreparedArtifact.inspect()` is therefore the only acceptable outer-ZIP read + seam for 04B2. +- `ArtifactScratchManager.extraction_workspace()` already provides the bounded, + private, crash-recoverable workspace required for a projected checker tree. + 04B2 must extend this existing custody path; it must not create another scratch + manager or use direct temporary paths. +- Its current recursive cleanup incorrectly reuses `maximum_files` (default 8), + while archive admission permits 2,000 entries, and workspace expansion is not + charged as a separate byte/entry reservation. 04B2 must repair that shared + quota contract before projecting any untrusted tree. +- `SubmissionArchiveInspector` is the canonical 04A2 ZIP implementation and + returns normalized paths, entry types, per-file SHA-256/size, and normalized + executable intent. `SubmissionManifest` is the sole 04A3 semantic identity. + Materialization must reuse those exact facts and reject any projected mismatch + before exposing the tree to checker adapters. +- `PreparedBundleMaterializationRequest` and + `ArtifactMaterializationPort.materialize_prepared_bundle()` already reserve + the hidden fixed-service seam. The AUTH catalogue maps + `artifact.pre_submit.checker_input.materialize` exclusively to + `workstream.artifact.materializer`; `AUTH_ART_04B` is its catalogue custodian, + while XINT-06A is the later planned activation point after hidden 04B3. +- ART-04B1 now owns the single immutable catalogue and effective execution plan. + No execution module exists yet. The platform/default executor must consume the + exact plan identity and ordered entries rather than reconstructing checker + names, dependencies, classifications, or enabled state. +- The legacy checker runner consumes mutable pre-Submission/task ORM objects and + caller-shaped packet manifests. Its small pure validation helpers may inform + behavior, but its registry/context is not the authoritative 04B2 boundary and + must not become a second catalogue or a dependency of the sealed tree. +- No `SealedSubmissionTree`, bounded catalogue-result type, + `test_checker_materialization.py`, or + `test_default_pre_submit_execution.py` exists on current main. + +Implementation constraints derived from current code: + +- Add one canonical projection capability alongside the canonical ZIP inspector, + so structural validation and extraction cannot drift into separate ZIP + implementations. Projection re-reads each member once, compares the exact 04A + entry facts, writes with no-follow/exclusive semantics, and seals fixed modes + before returning a process-local capability. +- A sealed-tree capability may expose bounded trusted reads to checker adapters, + but it may not expose a serializable scratch path, execute a submitted file, or + outlive its `ArtifactScratchManager` workspace. +- The implementation uses callback-scoped ownership: the prepared-artifact owner + authorizes first, reserves an expanded-byte/entry workspace, projects and seals + the tree, dispatches adapters, then cleans the workspace before returning only + bounded results. This removes any optional-close or escaped-path lifetime. +- Execute only custody, identity, materialization, and default-policy entries in + 04B2. Project-policy entries remain untouched for 04B3, and no result becomes + durable in this chunk. +- Default-policy configuration is not read back from the merged project policy. + Each adapter's closed Workstream-default semantics are versioned by the exact + catalogue entry ID/version and catalogue-manifest hash already committed into + the effective plan. Changing those semantics requires a definition-version and + catalogue-version decision rather than an untracked runtime constant change. +- Custody and identity adapters validate the already-produced 04A typed facts; + they do not perform a third archive inspection or compute a competing identity. + The materialization entry is the gate after which default-policy adapters may + receive the sealed-tree capability. +- Result envelopes must be fixed-size, path-redacted, plan-bound, and explicit + about pass, blocking failure, advisory warning, or advisory-disabled state. + Dependency failure stops dependent dispatch; a disabled mandatory definition + is infrastructure-unavailable and can never be emitted as success. +- Prepared authorization is consumed through the existing opaque handle at the + hidden fixed-service composition seam before any prepared-byte read, ZIP open, + workspace reservation, or checker fact. Until XINT-06A activates the action + owned in the catalogue by `AUTH_ART_04B`, production composition remains fail + closed; tests use existing AUTH fixtures/capabilities, never a production + ART-local fake protocol. + +Plan-review correction: the canonical projection must be a method of +`SubmissionArchiveInspector` or use a private traversal shared solely inside +`submission_archive.py`. The 04B2 executor accepts a closed phase slice and +rejects or ignores no entries: encountering a project-policy or policy-primitive +entry in its dispatch set is a caller/plan error, while the normal complete plan +is sliced deterministically before dispatch. Tests must also prove the legacy +checker registry and standalone precheck path are not consulted. diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md index 35c28d131..82aa49562 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md @@ -124,18 +124,14 @@ supersedes 04A4, makes 04B1 the next implementation chunk, and assigns complete legacy route/public-service/internal-guard/caller-package removal to the 05B admission-backed Submission cutover. -`WS-ART-001-04B1` is active on its bounded worktree. It owns only the immutable -typed catalogue, startup-fixed availability validation, migration of the -existing compiler away from parallel primitive maps, and pure composition of -one lineage-bound effective plan. It performs no ZIP read, scratch -materialization, checker execution, durable write, route exposure, or AUTH -activation. - -The implementation and deterministic evidence are complete in draft PR #276. -All required internal reviewer tracks ran successfully after their valid -findings were repaired. CodeRabbit completed substantive review and its two -actionable threads were repaired and resolved. The ready PR now waits only for -the final hosted Backend rerun on the repaired head. +`WS-ART-001-04B1` merged through PR #276 and supplies the immutable typed +catalogue plus lineage-bound effective plan. `WS-ART-001-04B2` is implemented +and internally reviewed on its bounded branch; hosted PR gates and human merge +remain pending. It owns only fixed-service authorization before byte access, +quota-reserved callback-scoped sealed ZIP projection, platform/default phase +execution, bounded non-durable results, and cleanup. It performs no +project-policy execution, durable evidence/admission/Submission write, route +exposure, provider I/O, or AUTH activation. ## Gate diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B2-default-checker-execution.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B2-default-checker-execution.md index 6e0bd05a6..e767819e4 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B2-default-checker-execution.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B2-default-checker-execution.md @@ -1,6 +1,6 @@ # Chunk Contract: WS-ART-001-04B2 - Default Checker Execution -Initiative: `WS-ART-001` | Risk: L1 | Status: Proposed after 04B1 +Initiative: `WS-ART-001` | Risk: L1 | Status: Active implementation Artifact contract phase: `upload_admission` @@ -31,31 +31,103 @@ rules or persist the final evidence set yet. ## Acceptance Criteria - one sealed materialization is derived from the 04A manifest and generation; +- the sealed tree is a callback-scoped, non-serializable `SealedSubmissionTree` + capability: projection and checker dispatch complete inside one + `ArtifactScratchManager` workspace lifetime, and only bounded result values + return after mandatory cleanup; no bare path or close-optional tree escapes; +- the hidden execution request carries the exact 04B1 effective plan and hash, + catalogue manifest hash and ordered phase slice, plus the 04A archive + commitment, inspection, semantic manifest, change-gate result, and + process-local prepared-artifact generation that the materializer validates; + policy row IDs alone are not executable input; - archive and projected file hashes/sizes/types/executable flags agree before a checker can read the tree; -- fixed canonical read-only/read-execute/read-traverse modes are used and the - executable flag never grants execution; +- focused mismatch tests cover missing and extra entries, normalized-path and + file/directory type drift, per-file SHA-256 and byte-count drift, executable + drift, and aggregate-count drift; every case denies checker access and proves + cleanup with no durable/provider effect; +- fixed sealed modes are `0400` for non-executable regular files, `0500` for + executable regular files, and `0500` for directories; executable intent is + intentionally projected for semantic parity, but the callback capability + exposes neither paths nor a subprocess/shell/execution primitive; tests prove + no execution helper is reachable from the 04B2 phase slice; +- workspace reservations account for projected expanded bytes and entries under + the aggregate scratch quota. The cleanup bound is a separate startup-fixed + workspace-entry limit at least as large as the configured accepted archive + entry limit; worst-case nested cleanup at that limit is tested; - mandatory catalogue unavailability, authorization denial, integrity drift, cancellation, timeout, or scratch exhaustion fails before checker access and creates no durable/provider effect; - platform/default entries execute in deterministic dependency order and emit bounded path-redacted results carrying entry ID/version and plan identity; +- the closed entry-result vocabulary is `passed`, `warning`, `failed`, + `advisory_disabled`, and `dependency_not_run`; terminal execution failures + separately distinguish contributor-blocking checker failure, retryable + infrastructure unavailable, authority denied/unavailable, cancellation, + timeout, scratch exhaustion, and integrity incident; +- no result or failure uses the product review values `accept`, + `needs_revision`, or `reject`; +- the executable phase slice is closed to `custody`, `identity`, + `materialization`, and `default_policy`; `project_policy` entries and policy + primitives are never dispatched by 04B2; +- every `default_policy` adapter consumes only typed server-owned input and the + immutable Workstream-default semantics committed by its catalogue entry ID, + version, and catalogue-manifest hash; it never reads the merged project policy + or a project-rule configuration; - disabled advisory entries are explicit; disabled mandatory entries fail closed and cannot appear as passing or skipped-success; +- executor tests independently reject stale/forged plan identity, unknown + dispatch capability, duplicate result identity, and disabled mandatory state; + dependency or blocking default failure stops dependent/later dispatch, while + an advisory warning remains non-blocking and explicit; - cleanup is bounded and idempotent on every terminal path; +- projection-specific tests cover cancellation during member projection and + sealing, timeout before and during checker access, scratch exhaustion before + workspace exposure, adapter failure after sealed-tree handoff, and repeated + cleanup/close; - tests prove pre-submit and post-submit projection parity for Unix executable, non-Unix/invalid mode, symlink/special rejection, and permission-only revision cases; neither projection preserves arbitrary archive modes; +- parity is proved through one shared projection contract and future + post-submit-compatible adapter fixtures; this chunk does not implement the + post-submit workflow; - the behavior remains hidden and process-local for later 04B3/04C composition; +- archive projection is implemented inside the canonical + `SubmissionArchiveInspector` traversal boundary (or a private iterator shared + by its inspection and projection methods), with no second ZIP validation or + extraction implementation outside `submission_archive.py`; +- tests prove 04B2 does not call the legacy checker registry, standalone + precheck service, or `pre_submit_static_feedback`; those paths remain frozen + legacy behavior until 05B removes them; +- pure packet/attestation/quality predicates are shared where their semantics + match, but the platform sensitive-path adapter uses the narrow + high-confidence default set and never inherits the legacy runner's broad + token/secret/dependency-directory heuristics; +- the production planned/unavailable materializer authority denies before + workspace or checker access, while a bounded fixed-service test authority can + exercise the hidden process-local path without creating an ART-local protocol; +- `workstream.artifact.materializer` authority for + `artifact.pre_submit.checker_input.materialize` is required before + `PreparedArtifact.inspect()`, ZIP open, workspace reservation/creation, or any + projected/checker fact. The catalogue custodian is `AUTH_ART_04B`; the planned + cross-initiative activation remains XINT-06A after 04B3. +- projection uses component-by-component descriptor-relative directory/file + creation under the pinned workspace descriptor, `O_NOFOLLOW | O_EXCL` for + files, no `ZipFile.extract*` or path-string writes, exact manifest collision + checks, descriptor-owned chmod, post-write hash/size/type verification, and + directory fsync before the tree capability is exposed; - focused subsystem coverage is at least 90 percent and repository coverage remains at least 78 percent. ## Verification ```bash -(cd backend && .venv/bin/pytest tests/test_checker_materialization.py tests/test_default_pre_submit_execution.py tests/test_artifact_scratch_manager.py tests/test_submission_archive.py tests/test_submission_manifest.py tests/test_submission_change_gate.py -q) +(cd backend && .venv/bin/pytest tests/test_checker_materialization.py tests/test_default_pre_submit_execution.py tests/test_checker_catalogue.py tests/test_artifact_preparation.py tests/test_submission_archive.py tests/test_submission_manifest.py tests/test_submission_change_gate.py tests/test_config.py -q) (metadata_dir="$(mktemp -d)" && trap 'rm -rf "$metadata_dir"' EXIT && (cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/postgres .venv/bin/python scripts/run_isolated_tests.py --metadata-json "$metadata_dir/result.json" --timeout-seconds 12600 -- .venv/bin/python -m pytest -q --ignore=tests/test_isolated_database_runner.py --cov=app --cov-report=term-missing --cov-fail-under=78)) -(cd backend && .venv/bin/coverage report --include='app/modules/artifacts/*,app/modules/checkers/*' --precision=2 --fail-under=90) +(cd backend && .venv/bin/coverage report --include='app/modules/artifacts/*' --precision=2 --fail-under=90) +(cd backend && .venv/bin/coverage report --include='app/modules/checkers/*' --precision=2 --fail-under=90) +(cd backend && .venv/bin/coverage report --include='app/core/cancellation.py,app/core/config.py,app/core/file_locks.py' --precision=2 --fail-under=90) +(cd backend && .venv/bin/coverage report --include='app/interfaces/artifact_operations.py,app/interfaces/artifacts.py' --precision=2 --fail-under=90) (cd backend && .venv/bin/ruff check app tests) python3 scripts/check_stale_artifact_contracts.py PYTHONPATH=. python3 scripts/test_lightweight_agent_gates.py @@ -76,6 +148,30 @@ coverage report --include='app/interfaces/artifact_operations.py,app/interfaces/ If implementation does not change one of those surfaces, its existing hosted gate remains unchanged; it may not be removed or weakened. +## Required Documentation + +Implementation must reconcile the hidden capability, scratch quota, result +taxonomy, default/project phase ownership, executable projection, and planned +AUTH wording in: + +- `docs/spec_artifact_storage_service.md`; +- `docs/architecture_checker_framework.md`; +- `docs/architecture_data_model.md`; +- `docs/template_submission_artifact_policy.md`; +- `docs/template_submission_packet.md`; +- `docs/template_checker_policy.md`; +- `docs/architecture_lockdown.md` and `docs/glossary.md` where their canonical + terms require it; +- `docs/spec_authorization_service.md` and + `docs/operations_authorization_service.md` only if the exact resource/guard or + planned-denial wording changes; +- `docs/roadmap_status.md`, initiative `STATUS.md`, `CHUNK_MAP.md`, and final + review evidence after implementation. + +These updates describe hidden 04B2 execution only. They must not change the +legacy public standalone precheck contract before 05B or imply contributor +preparation, durable evidence, admission, or Submission is active. + ## Required Reviewers Senior engineering, architecture, QA/test, security/auth, product/ops, diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-internal-review-evidence.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-internal-review-evidence.md new file mode 100644 index 000000000..ea8055d57 --- /dev/null +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-internal-review-evidence.md @@ -0,0 +1,59 @@ +# WS-ART-001-04B2 Internal Review Evidence + +## Scope + +Hidden fixed-service materialization and Workstream-default pre-submission +execution only. Project-policy execution, durable checker evidence, admission, +Submission creation, route exposure, provider I/O, and AUTH activation remain +out of scope. + +## Deterministic evidence + +- Focused materialization/default execution: 29 tests passed. +- Contract-focused ART/catalogue/preparation/archive/manifest/change/config + suite passed. +- Ruff passed for the backend application, tests, and changed lane script. +- Hosted semantic-lane collection and evidence validation passed after adding + both new test modules to the canonical lane inventory. +- Stale artifact contracts, lightweight agent gates, Markdown links, stale + wording, and diff integrity passed. +- Hosted full Backend Gates remain required for repository coverage at 78 + percent and the ART/checker/core/interface 90 percent reports. + +## Reviewer results + +- Architecture: PASS after removing the public unauthorized + `PreparedArtifact` processing seam. +- Security/auth: PASS after removing reachable filesystem authority and + clearing retained callback bytes and entries during close. +- QA: PASS after direct stale/unknown/duplicate/disabled, cancellation, + timeout, adapter-failure, cleanup, and project-policy isolation proofs. +- Senior engineering: PASS after replacing the fd-bearing tree capability and + correcting warning metadata. +- Reuse/dedup: PASS after sharing pure attestation/quality semantics and using + the typed platform capability enum. +- Product/ops: PASS WITH LOW RISK; no lifecycle, review, compensation, or + reputation effect. The platform/default slice is canonically derived from + the locked full plan and remains hidden. +- CI integrity: PASS after adding both new modules to semantic test lanes; no + coverage floor was weakened. +- Test delta: PASS; no removed, skipped, weakened, or bypassed test. +- Docs: PASS after repairing roadmap structure and reconciling canonical ART, + checker, AUTH, glossary, roadmap, and template wording. + +## Resolved findings + +- Closed the unauthorized prepared-artifact workspace bypass. +- Removed mutable directory-fd authority from checker callbacks. +- Revoked retained byte and entry facts when callback scope closes. +- Propagated cancellation/deadline abort before checker execution while still + completing scratch cleanup. +- Restored the shared guide-extraction workspace cleanup bound. +- Consolidated duplicated default semantics and enum-backed dispatch. +- Added direct fail-closed and terminal-path tests plus CI lane ownership. + +## Remaining external gates + +GitHub Backend Gates, CodeRabbit, and human review are external checks. Human +merge ownership remains with the repository owner; this evidence does not +authorize merge. diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-pr-trust-bundle.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-pr-trust-bundle.md new file mode 100644 index 000000000..64b15edb4 --- /dev/null +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-pr-trust-bundle.md @@ -0,0 +1,120 @@ +# WS-ART-001-04B2 PR Trust Bundle + +## Chunk + +`WS-ART-001-04B2` — default checker execution. + +## Goal + +Materialize one prepared contributor ZIP only after fixed-service authority, +project its exact 04A semantic manifest into bounded private scratch, and run +only the ordered Workstream platform/default pre-submission slice. + +## Human-approved intent + +Workstream owns the generic default pre-submission checks. Project-specific +rules remain a later phase of the same effective plan and do not execute in +this chunk. Production authority remains planned and unavailable. + +## What changed + +- Added the hidden authorized materialization service and exact AUTH facts. +- Added quota-charged canonical ZIP projection with fixed executable semantics. +- Added a callback-scoped capability containing immutable verified bytes and + manifest facts, revoked before scratch cleanup completes. +- Added platform/default dispatch with bounded path-redacted results. +- Shared attestation and quality predicates with the existing checker path. +- Added scratch workspace byte/entry accounting and crash-cleanup compatibility. +- Added focused failure/concurrency tests, semantic lane ownership, coverage + reports, and canonical documentation. + +## Why it changed + +04B1 locked what must run. 04B2 supplies the hidden execution boundary needed +before project-policy execution and durable evidence can be composed safely. + +## Design chosen + +AUTH is consumed before any prepared-byte or workspace access. The canonical +archive inspector alone projects the verified tree. Checker code receives no +path, provider handle, scratch handle, directory fd, or serializable authority. +Cancellation and deadlines abort before checker execution while cleanup still +runs to completion. + +## Alternatives rejected + +- Public `PreparedArtifact` processing: bypassed fixed-service authority. +- Direct ZIP extraction or a second extractor: duplicated the canonical safety + boundary. +- Filesystem descriptor capability: allowed callback mutation. +- Legacy checker registry execution: would preserve the authority being + replaced and mix project-policy work into 04B2. + +## Scope control + +No public route, project-policy execution, durable evidence, admission, +Submission, review decision, provider I/O, AUTH activation, or legacy removal. + +## Product behavior + +None is publicly active. Results are non-durable internal values: +`passed`, `warning`, `failed`, `advisory_disabled`, and +`dependency_not_run`; they are not review decisions. + +## Acceptance criteria proof + +Tests prove authority-before-access, exact plan/catalogue/archive/manifest +identity, closed dispatch, disabled mandatory failure, advisory visibility, +project-policy isolation, semantic drift rejection, scratch capacity, fixed +modes, callback revocation, cancellation/deadline abort, cleanup, and no legacy +runner dependency. + +## Tests/checks run + +- 29 focused materialization/default tests passed. +- Contract-focused ART and checker suites passed. +- Ruff, compilation, semantic-lane collection/validation, stale contract, + lightweight agent, Markdown-link, stale-wording, and diff checks passed. +- Full coverage and integration execution is delegated to hosted Backend Gates. + +## Test delta + +Two focused modules were added; existing cleanup/config tests were strengthened. +No test was removed, skipped, xfailed, or weakened. + +## CI integrity + +The repository 78 percent floor remains. Existing checker 90 percent coverage +remains, and ART module, cancellation/lock, and artifact-interface 90 percent +reports are explicit. Both new tests are assigned to semantic lanes. + +## Reviewer results + +Architecture, security, QA, senior engineering, reuse, CI integrity, test +delta, and docs passed. Product/ops passed with one non-blocking observation +that the platform/default slice is derived from the locked full plan. + +## External review + +Pending GitHub Backend Gates and CodeRabbit on the draft PR. + +## Remaining risks + +The production materializer remains unavailable until AUTH activation. This +chunk intentionally creates no durable checker evidence or admission. + +## Follow-up work + +04B3 owns project-policy execution and durable checker evidence. Later AUTH +activation and the 05B admission-backed cutover remain separate ordered work. + +## Human review focus + +- Confirm no byte/workspace access precedes fixed-service AUTH. +- Confirm the callback sees exactly the 04A manifest tree and cannot retain + scratch authority or content after close. +- Confirm no project-policy or legacy public precheck path executes. + +## Human merge ownership + +Only the repository owner may approve and merge this PR. diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index ce0f289a7..a171a0e1e 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -329,6 +329,26 @@ jobs: --precision=2 --fail-under=90 + - name: Artifact module coverage + working-directory: backend + run: coverage report --include='app/modules/artifacts/*' --precision=2 --fail-under=90 + + - name: Artifact cancellation and lock coverage + working-directory: backend + run: >- + coverage report + --include='app/core/cancellation.py,app/core/file_locks.py' + --precision=2 + --fail-under=90 + + - name: Artifact interface coverage + working-directory: backend + run: >- + coverage report + --include='app/interfaces/artifact_operations.py,app/interfaces/artifacts.py' + --precision=2 + --fail-under=90 + - name: External service adapter coverage working-directory: backend run: coverage report --include='app/interfaces/external_services.py' --precision=2 --fail-under=90 diff --git a/backend/app/adapters/artifacts/__init__.py b/backend/app/adapters/artifacts/__init__.py index c4d7a8a99..09fdb4a6f 100644 --- a/backend/app/adapters/artifacts/__init__.py +++ b/backend/app/adapters/artifacts/__init__.py @@ -102,6 +102,7 @@ def artifact_preparation_limits(settings: Settings) -> ArtifactPreparationLimits cleanup_margin_seconds=settings.artifact_scratch_cleanup_margin_seconds, stream_buffer_bytes=settings.artifact_stream_buffer_bytes, maximum_source_bytes=settings.artifact_maximum_bytes, + maximum_workspace_entries=settings.artifact_scratch_maximum_workspace_entries, ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ac6c548e4..c077ff9e5 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -187,6 +187,9 @@ class Settings(BaseSettings): ) artifact_scratch_maximum_files: int = Field(default=8, ge=1, le=1024) artifact_scratch_maximum_concurrency: int = Field(default=4, ge=1, le=1024) + artifact_scratch_maximum_workspace_entries: int = Field( + default=2_000, ge=1, le=100_000 + ) artifact_scratch_minimum_free_bytes: int = Field( default=512 * 1024 * 1024, ge=0, @@ -476,6 +479,13 @@ def validate_artifact_storage(self) -> Settings: """ if self.artifact_scratch_maximum_concurrency > self.artifact_scratch_maximum_files: raise ValueError("artifact scratch concurrency cannot exceed its file limit") + if ( + self.artifact_scratch_maximum_workspace_entries + < self.artifact_submission_zip_maximum_entries + ): + raise ValueError( + "artifact scratch workspace entry limit cannot be below ZIP entry limit" + ) if ( self.artifact_submission_zip_maximum_entry_bytes > self.artifact_submission_zip_maximum_expanded_bytes diff --git a/backend/app/interfaces/artifact_operations.py b/backend/app/interfaces/artifact_operations.py index 4a309a0b8..32a0ddd19 100644 --- a/backend/app/interfaces/artifact_operations.py +++ b/backend/app/interfaces/artifact_operations.py @@ -8,8 +8,18 @@ from uuid import UUID from app.modules.artifacts.sources import ArtifactCommitment, PreparedArtifact +from app.modules.artifacts.submission_archive import SubmissionArchiveInspectionResult +from app.modules.artifacts.submission_manifest import ( + SubmissionChangeGateResult, + SubmissionManifest, +) from app.modules.authorization.prepared import PreparedAuthorizationHandle from app.modules.authorization.runtime import AuthorizationContext +from app.modules.checkers.effective_plan import EffectivePreSubmissionExecutionPlan +from app.modules.checkers.pre_submit_execution import ( + DefaultPreSubmissionExecutionResult, + SubmissionPacketView, +) __all__ = ( "ArtifactAuditResourceType", @@ -258,7 +268,7 @@ class SubmissionBundlePreparationRequest: @dataclass(frozen=True, slots=True) class PreparedBundleMaterializationRequest: - """Process-local prepared bytes and exact policy selectors.""" + """Process-local prepared bytes and exact 04A/04B1 execution facts.""" prepared_authorization: PreparedAuthorizationHandle task_id: UUID @@ -266,6 +276,11 @@ class PreparedBundleMaterializationRequest: submission_artifact_policy_id: UUID checker_policy_id: UUID prepared_artifact: PreparedArtifact + effective_plan: EffectivePreSubmissionExecutionPlan + inspection: SubmissionArchiveInspectionResult + manifest: SubmissionManifest + change_gate: SubmissionChangeGateResult + packet: SubmissionPacketView @dataclass(frozen=True, slots=True) @@ -357,7 +372,7 @@ class ArtifactMaterializationPort(Protocol): async def materialize_prepared_bundle( self, request: PreparedBundleMaterializationRequest, - ) -> object: + ) -> DefaultPreSubmissionExecutionResult: """Materialize one process-local prepared bundle generation.""" async def materialize_guide_source( diff --git a/backend/app/modules/artifacts/preparation.py b/backend/app/modules/artifacts/preparation.py index dc91bc585..559525bff 100644 --- a/backend/app/modules/artifacts/preparation.py +++ b/backend/app/modules/artifacts/preparation.py @@ -43,7 +43,7 @@ HARD_MAXIMUM_ARTIFACT_BYTES = 512 * 1024 * 1024 _InspectionResult = TypeVar("_InspectionResult") -_LEDGER_VERSION = 2 +_LEDGER_VERSION = 3 _LEDGER_MAXIMUM_BYTES = 1024 * 1024 _RESERVATION_ID = re.compile(r"^[0-9a-f]{32}$") _WORKSPACE_ID = re.compile(r"^extract_[0-9a-f]{32}$") @@ -85,6 +85,7 @@ class ArtifactPreparationLimits: cleanup_margin_seconds: float = 300.0 stream_buffer_bytes: int = 1024 * 1024 maximum_source_bytes: int = HARD_MAXIMUM_ARTIFACT_BYTES + maximum_workspace_entries: int = 2_000 def __post_init__(self) -> None: """Reject limits that cannot enforce the v0.1 scratch contract.""" @@ -95,6 +96,7 @@ def __post_init__(self) -> None: self.minimum_free_bytes, self.stream_buffer_bytes, self.maximum_source_bytes, + self.maximum_workspace_entries, ) if any(type(value) is not int for value in integer_fields): raise ValueError("artifact preparation integer limits are invalid") @@ -112,6 +114,8 @@ def __post_init__(self) -> None: self.maximum_source_bytes > HARD_MAXIMUM_ARTIFACT_BYTES ): raise ValueError("artifact preparation source limit exceeds 512 MiB") + if self.maximum_workspace_entries <= 0 or self.maximum_workspace_entries > 100_000: + raise ValueError("artifact workspace entry limit is invalid") durations = ( self.reservation_ttl_seconds, self.total_deadline_seconds, @@ -376,16 +380,44 @@ async def cleanup_stale(self, *, now_unix_ns: int | None = None) -> int: self._pending_allocation_releases.pop(reservation_id, None) return len(cleaned_reservation_ids) + len(cleaned_workspace_names) - def _reserve_workspace_sync(self, workspace_name: str) -> None: + def _reserve_workspace_sync( + self, + workspace_name: str, + *, + reserved_bytes: int = 0, + maximum_entries: int | None = None, + ) -> None: """Publish crash-recovery ownership before creating a workspace.""" + if maximum_entries is None: + maximum_entries = self._limits.maximum_workspace_entries + if ( + type(reserved_bytes) is not int + or reserved_bytes < 0 + or reserved_bytes > HARD_MAXIMUM_ARTIFACT_BYTES + or type(maximum_entries) is not int + or maximum_entries < 0 + or maximum_entries > self._limits.maximum_workspace_entries + ): + raise ValueError("artifact workspace reservation is invalid") now = time.time_ns() expires_at = now + int(self._limits.reservation_ttl_seconds * 1_000_000_000) with self._locked_ledger(): ledger = self._read_ledger() workspaces = list(ledger["workspaces"]) + current_reserved = sum( + entry["reserved_bytes"] for entry in ledger["reservations"] + workspaces + ) + if current_reserved + reserved_bytes > self._limits.aggregate_reserved_bytes: + raise ArtifactScratchCapacityError("artifact scratch byte limit reached") + filesystem = os.fstatvfs(self._workspaces_fd) + available = filesystem.f_bavail * filesystem.f_frsize + if available < current_reserved + reserved_bytes + self._limits.minimum_free_bytes: + raise ArtifactScratchCapacityError("artifact scratch free-space floor reached") workspaces.append( { "workspace_name": workspace_name, + "reserved_bytes": reserved_bytes, + "maximum_entries": maximum_entries, "created_at_unix_ns": now, "expires_at_unix_ns": expires_at, "owner_pid": os.getpid(), @@ -414,11 +446,20 @@ def _release_workspace_sync(self, workspace_name: str) -> None: ) @contextmanager - def extraction_workspace(self) -> Iterator[Path]: + def extraction_workspace( + self, + *, + reserved_bytes: int = 0, + maximum_entries: int | None = None, + ) -> Iterator[Path]: """Own one crash-recoverable private extraction workspace.""" with self._tracked_operation(): workspace_name = f"extract_{uuid4().hex}" - self._reserve_workspace_sync(workspace_name) + self._reserve_workspace_sync( + workspace_name, + reserved_bytes=reserved_bytes, + maximum_entries=maximum_entries, + ) workspace_fd = -1 try: os.mkdir(workspace_name, mode=0o700, dir_fd=self._workspaces_fd) @@ -480,13 +521,20 @@ async def release_reservation() -> None: def _cleanup_workspace_sync(self, workspace_name: str) -> None: """Remove bounded workspace residue without following links.""" + ledger = self._read_ledger() + matching = [ + entry for entry in ledger["workspaces"] if entry["workspace_name"] == workspace_name + ] + if len(matching) != 1: + raise ArtifactScratchIntegrityError("artifact extraction workspace is invalid") workspace_fd = os.open( workspace_name, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0), dir_fd=self._workspaces_fd, ) try: - remaining_entries = [self._limits.maximum_files] + os.fchmod(workspace_fd, 0o700) + remaining_entries = [matching[0]["maximum_entries"]] self._cleanup_workspace_entries_sync(workspace_fd, remaining_entries) finally: os.close(workspace_fd) @@ -499,6 +547,7 @@ def _cleanup_workspace_entries_sync( remaining_entries: list[int], ) -> None: """Remove a bounded tree bottom-up without following links.""" + os.fchmod(directory_fd, 0o700) for entry in os.listdir(directory_fd): remaining_entries[0] -= 1 if remaining_entries[0] < 0: @@ -688,7 +737,7 @@ def _initialize_layout_locked(self) -> None: os.fsync(self._root_fd) def _validate_existing_root_marker(self) -> None: - """Require the root marker to match this manager's canonical limits.""" + """Require exact limits, with a safe-empty upgrade from the prior marker.""" descriptor = os.open( _ROOT_MARKER, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), @@ -696,15 +745,55 @@ def _validate_existing_root_marker(self) -> None: ) try: self._assert_private_file_descriptor(descriptor, expected_mode=0o600) - marker = os.read(descriptor, len(self._root_marker_content) + 1) - if marker != self._root_marker_content: - raise ValueError("artifact scratch root marker is invalid") + marker = os.read(descriptor, max(len(self._root_marker_content), 256) + 1) + finally: + os.close(descriptor) + if marker == self._root_marker_content: + return + if marker != self._legacy_marker_content(self._limits): + raise ValueError("artifact scratch root marker is invalid") + ledger = self._read_ledger_optional() + if ledger is None or ledger["reservations"] or ledger["workspaces"]: + raise ValueError("artifact scratch root marker cannot migrate while in use") + descriptor = os.open( + _ROOT_MARKER, + os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0), + dir_fd=self._root_fd, + ) + try: + self._assert_private_file_descriptor(descriptor, expected_mode=0o600) + self._write_all(descriptor, memoryview(self._root_marker_content)) + os.fsync(descriptor) finally: os.close(descriptor) + os.fsync(self._root_fd) @staticmethod def _marker_content(limits: ArtifactPreparationLimits) -> bytes: """Bind one scratch root to a single canonical cross-process limit set.""" + payload = { + "aggregate_reserved_bytes": limits.aggregate_reserved_bytes, + "cleanup_margin_seconds": float(limits.cleanup_margin_seconds), + "maximum_concurrency": limits.maximum_concurrency, + "maximum_files": limits.maximum_files, + "maximum_source_bytes": limits.maximum_source_bytes, + "maximum_workspace_entries": limits.maximum_workspace_entries, + "minimum_free_bytes": limits.minimum_free_bytes, + "reservation_ttl_seconds": float(limits.reservation_ttl_seconds), + "stream_buffer_bytes": limits.stream_buffer_bytes, + "total_deadline_seconds": float(limits.total_deadline_seconds), + } + canonical = json.dumps( + payload, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return _ROOT_MARKER_PREFIX + hashlib.sha256(canonical).hexdigest().encode("ascii") + b"\n" + + @staticmethod + def _legacy_marker_content(limits: ArtifactPreparationLimits) -> bytes: + """Reconstruct the pre-04B2 marker for one safe-empty local upgrade.""" payload = { "aggregate_reserved_bytes": limits.aggregate_reserved_bytes, "cleanup_margin_seconds": float(limits.cleanup_margin_seconds), @@ -783,7 +872,9 @@ def _allocate_sync(self) -> tuple[_ScratchReservation, int]: ledger = self._read_ledger() entries = list(ledger["reservations"]) previous_entries = list(entries) - reserved_bytes = sum(entry["reserved_bytes"] for entry in entries) + reserved_bytes = sum(entry["reserved_bytes"] for entry in entries) + sum( + entry["reserved_bytes"] for entry in ledger["workspaces"] + ) if len(entries) >= self._limits.maximum_files: raise ArtifactScratchCapacityError("artifact scratch file limit reached") if len(entries) >= self._limits.maximum_concurrency: @@ -963,10 +1054,12 @@ def _release_sync(self, reservation: _ScratchReservation) -> None: def _usage_sync(self) -> ArtifactScratchUsage: """Read aggregate ledger usage while holding the cross-process lock.""" with self._locked_ledger(): - entries = self._read_ledger()["reservations"] + ledger = self._read_ledger() + entries = ledger["reservations"] return ArtifactScratchUsage( reservation_count=len(entries), - reserved_bytes=sum(entry["reserved_bytes"] for entry in entries), + reserved_bytes=sum(entry["reserved_bytes"] for entry in entries) + + sum(entry["reserved_bytes"] for entry in ledger["workspaces"]), ) def _stale_reservation_ids_sync(self, now_unix_ns: int) -> tuple[str, ...]: @@ -1119,10 +1212,26 @@ def _read_ledger_optional(self) -> dict[str, Any] | None: raise ArtifactScratchIntegrityError("artifact scratch ledger is invalid") from exc if ( isinstance(ledger, dict) - and set(ledger) == {"version", "reservations"} and ledger.get("version") == _LEDGER_VERSION + and set(ledger) == {"version", "reservations"} ): ledger = {**ledger, "workspaces": []} + if isinstance(ledger, dict) and ledger.get("version") == 2: + if set(ledger) == {"version", "reservations"}: + ledger = {**ledger, "workspaces": []} + if set(ledger) == {"version", "reservations", "workspaces"}: + ledger = { + **ledger, + "version": _LEDGER_VERSION, + "workspaces": [ + { + **entry, + "reserved_bytes": 0, + "maximum_entries": self._limits.maximum_workspace_entries, + } + for entry in ledger["workspaces"] + ], + } self._validate_ledger(ledger) return ledger @@ -1238,6 +1347,8 @@ def _validate_ledger(self, ledger: object) -> None: seen_workspaces: set[str] = set() expected_workspace = { "workspace_name", + "reserved_bytes", + "maximum_entries", "created_at_unix_ns", "expires_at_unix_ns", "owner_pid", @@ -1248,6 +1359,8 @@ def _validate_ledger(self, ledger: object) -> None: raise ArtifactScratchIntegrityError("artifact scratch ledger is invalid") workspace_name = entry["workspace_name"] integer_values = ( + entry["reserved_bytes"], + entry["maximum_entries"], entry["created_at_unix_ns"], entry["expires_at_unix_ns"], entry["owner_pid"], @@ -1257,6 +1370,8 @@ def _validate_ledger(self, ledger: object) -> None: or _WORKSPACE_ID.fullmatch(workspace_name) is None or workspace_name in seen_workspaces or any(type(value) is not int or value < 0 for value in integer_values) + or entry["reserved_bytes"] > HARD_MAXIMUM_ARTIFACT_BYTES + or entry["maximum_entries"] > self._limits.maximum_workspace_entries or entry["expires_at_unix_ns"] <= entry["created_at_unix_ns"] or entry["owner_pid"] <= 0 or not isinstance(entry["owner_process_identity"], str) @@ -1578,6 +1693,54 @@ def inspect_and_cleanup() -> _InspectionResult: "artifact preparation deadline exceeded" ) from None + async def _process_prepared_submission( + self, + prepared: PreparedArtifact, + processor: Any, + *, + reserved_bytes: int, + maximum_entries: int, + ) -> _InspectionResult: + """Serve only the authority-gated hidden submission materializer.""" + if type(prepared) is not PreparedArtifact or prepared._owner is not self: + raise ArtifactScratchIntegrityError("prepared artifact source is unavailable") + binding = prepared._binding + active = self._active.get(binding) + if active is None or not active.handle_issued or active.stream_claimed: + raise ArtifactScratchIntegrityError("prepared artifact source is unavailable") + + def process_and_cleanup() -> _InspectionResult: + with self._manager.extraction_workspace( + reserved_bytes=reserved_bytes, + maximum_entries=maximum_entries, + ) as workspace: + active.reader.seek(0) + try: + return processor.process(active.reader, workspace) + finally: + active.reader.seek(0) + + operation = asyncio.create_task(self._run_io(process_and_cleanup)) + try: + async with asyncio.timeout_at(active.deadline): + return await asyncio.shield(operation) + except TimeoutError: + processor.abort() + try: + await await_cancellation_resistant(operation) + except BaseException: + pass + raise ArtifactPreparationDeadlineError( + "artifact preparation deadline exceeded" + ) from None + except asyncio.CancelledError as cancellation: + processor.abort() + try: + await await_cancellation_resistant(operation) + except BaseException: + pass + raise cancellation from None + async def _release_unhanded_preparation( self, pending: _PendingPreparationCleanup, diff --git a/backend/app/modules/artifacts/sources.py b/backend/app/modules/artifacts/sources.py index 21136f60b..93cd10d18 100644 --- a/backend/app/modules/artifacts/sources.py +++ b/backend/app/modules/artifacts/sources.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from pathlib import Path from typing import BinaryIO, Protocol, TypeVar, final +from uuid import UUID, uuid4 from app.core.cancellation import await_completion_preserving_cancellation @@ -165,7 +166,7 @@ def __repr__(self) -> str: class PreparedArtifact: """Lifecycle owner for one prepared source and its scratch reservation.""" - __slots__ = ("_binding", "_closed", "_committed_source", "_owner") + __slots__ = ("_binding", "_closed", "_committed_source", "_generation_id", "_owner") def __init__(self, *_: object, **__: object) -> None: """Reject all direct construction.""" @@ -188,6 +189,7 @@ def _from_preparation_service( prepared._owner = owner prepared._binding = binding prepared._closed = False + prepared._generation_id = uuid4() source = object.__new__(CommittedArtifactSource) source._owner = owner source._binding = binding @@ -202,6 +204,13 @@ def commitment(self) -> ArtifactCommitment: """Return the server-computed commitment.""" return self._committed_source.commitment + @property + def generation_id(self) -> UUID: + """Return the unguessable process-local generation bound to this handle.""" + if self._closed: + raise RuntimeError("prepared artifact is closed") + return self._generation_id + @property def committed_source(self) -> CommittedArtifactSource: """Return the sealed commitment and second-pass stream pair.""" diff --git a/backend/app/modules/artifacts/submission_archive.py b/backend/app/modules/artifacts/submission_archive.py index 287a7e54f..078bd6d89 100644 --- a/backend/app/modules/artifacts/submission_archive.py +++ b/backend/app/modules/artifacts/submission_archive.py @@ -7,10 +7,12 @@ import hashlib import math from pathlib import PurePosixPath +from pathlib import Path +import os import stat import struct import time -from typing import BinaryIO +from typing import BinaryIO, Callable, Mapping, TypeVar import unicodedata import zipfile import zlib @@ -19,6 +21,7 @@ _READ_BYTES = 1024 * 1024 +_ProjectionResult = TypeVar("_ProjectionResult") class SubmissionArchiveFailureCode(StrEnum): @@ -68,6 +71,121 @@ class SubmissionArchiveInspectionResult: total_expanded_bytes: int +def _read_projected_file( + root_fd: int, + entries: tuple[SubmissionArchiveEntry, ...], + normalized_path: str, + maximum_bytes: int, +) -> bytes: + entry = next( + ( + item + for item in entries + if item.normalized_path == normalized_path + and item.entry_type is SubmissionArchiveEntryType.FILE + ), + None, + ) + if entry is None or entry.byte_count > maximum_bytes: + raise SubmissionArchiveRejectedError(SubmissionArchiveFailureCode.LIMIT_EXCEEDED) + descriptor = _open_beneath( + root_fd, + normalized_path, + os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), + ) + try: + details = os.fstat(descriptor) + expected_mode = 0o500 if entry.executable else 0o400 + if ( + not stat.S_ISREG(details.st_mode) + or details.st_mode & 0o777 != expected_mode + or details.st_size != entry.byte_count + ): + raise SubmissionArchiveRejectedError( + SubmissionArchiveFailureCode.INTEGRITY_FAILURE + ) + content = bytearray() + while chunk := os.read(descriptor, min(_READ_BYTES, maximum_bytes + 1)): + content.extend(chunk) + if len(content) > maximum_bytes: + raise SubmissionArchiveRejectedError( + SubmissionArchiveFailureCode.LIMIT_EXCEEDED + ) + if f"sha256:{hashlib.sha256(content).hexdigest()}" != entry.sha256: + raise SubmissionArchiveRejectedError( + SubmissionArchiveFailureCode.INTEGRITY_FAILURE + ) + return bytes(content) + finally: + os.close(descriptor) + + +class SealedSubmissionTree: + """Callback-scoped read capability over one canonical projected tree.""" + + __slots__ = ("_closed", "_content", "_entries") + + def __init__( + self, + *, + entries: tuple[SubmissionArchiveEntry, ...], + content: Mapping[str, bytes], + ) -> None: + self._entries = entries + self._content = dict(content) + self._closed = False + + @property + def entries(self) -> tuple[SubmissionArchiveEntry, ...]: + """Return immutable server-owned entry facts, never a local path.""" + self._assert_open() + return self._entries + + def read_file(self, normalized_path: str, *, maximum_bytes: int) -> bytes: + """Read one exact regular file through a bounded descriptor-relative operation.""" + self._assert_open() + if type(maximum_bytes) is not int or maximum_bytes < 0: + raise ValueError("sealed tree read limit is invalid") + content = self._content.get(normalized_path) + if content is None or len(content) > maximum_bytes: + raise SubmissionArchiveRejectedError( + SubmissionArchiveFailureCode.LIMIT_EXCEEDED + ) + entry = next( + ( + item + for item in self._entries + if item.normalized_path == normalized_path + and item.entry_type is SubmissionArchiveEntryType.FILE + ), + None, + ) + if ( + entry is None + or len(content) != entry.byte_count + or f"sha256:{hashlib.sha256(content).hexdigest()}" != entry.sha256 + ): + raise SubmissionArchiveRejectedError( + SubmissionArchiveFailureCode.INTEGRITY_FAILURE + ) + return content + + def _close(self) -> None: + self._content.clear() + self._entries = () + self._closed = True + + def _assert_open(self) -> None: + if self._closed: + raise RuntimeError("sealed submission tree is closed") + + def __reduce__(self): + raise TypeError("sealed submission tree is process-local") + + def __repr__(self) -> str: + return f"SealedSubmissionTree(entries={len(self._entries)}, closed={self._closed})" + + @dataclass(frozen=True, slots=True) class SubmissionArchiveLimits: """Startup-fixed limits for one outer ZIP inspection.""" @@ -147,6 +265,171 @@ def inspect(self, reader: BinaryIO) -> SubmissionArchiveInspectionResult: total_expanded_bytes=total, ) + def project_and_run( + self, + reader: BinaryIO, + workspace: Path, + *, + expected: SubmissionArchiveInspectionResult, + callback: Callable[[SealedSubmissionTree], _ProjectionResult], + ) -> _ProjectionResult: + """Project the exact inspected ZIP and keep the tree inside one callback lifetime.""" + observed = self.inspect(reader) + if observed != expected: + self._reject(SubmissionArchiveFailureCode.INTEGRITY_FAILURE) + reader.seek(0) + root_fd = os.open( + workspace, + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0), + ) + root_details = os.fstat(root_fd) + if not stat.S_ISDIR(root_details.st_mode) or root_details.st_mode & 0o077: + os.close(root_fd) + self._reject(SubmissionArchiveFailureCode.INTEGRITY_FAILURE) + tree: SealedSubmissionTree | None = None + callback_started = False + projection_started = time.monotonic() + try: + with zipfile.ZipFile(reader, allowZip64=True) as archive: + infos: dict[str, zipfile.ZipInfo] = {} + for info in archive.infolist(): + normalized_path, entry_type = self._validated_path(info) + if entry_type is SubmissionArchiveEntryType.FILE: + if normalized_path in infos: + self._reject(SubmissionArchiveFailureCode.COLLISION) + infos[normalized_path] = info + directories = sorted( + ( + entry + for entry in expected.entries + if entry.entry_type is SubmissionArchiveEntryType.DIRECTORY + ), + key=lambda entry: (len(PurePosixPath(entry.normalized_path).parts), entry.normalized_path), + ) + for entry in directories: + _mkdir_beneath(root_fd, entry.normalized_path) + for entry in expected.entries: + if entry.entry_type is not SubmissionArchiveEntryType.FILE: + continue + info = infos.pop(entry.normalized_path, None) + if info is None: + self._reject(SubmissionArchiveFailureCode.INTEGRITY_FAILURE) + self._project_file( + archive, + info, + root_fd=root_fd, + expected=entry, + started=projection_started, + ) + if infos: + self._reject(SubmissionArchiveFailureCode.INTEGRITY_FAILURE) + for entry in reversed(directories): + directory_fd = _open_beneath( + root_fd, + entry.normalized_path, + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + try: + os.fchmod(directory_fd, 0o500) + os.fsync(directory_fd) + finally: + os.close(directory_fd) + os.fchmod(root_fd, 0o500) + os.fsync(root_fd) + content = self._seal_projected_content(root_fd, expected.entries) + tree = SealedSubmissionTree( + entries=expected.entries, + content=content, + ) + callback_started = True + return callback(tree) + except SubmissionArchiveRejectedError: + raise + except (OSError, ValueError, zipfile.BadZipFile, RuntimeError): + if callback_started: + raise + self._reject(SubmissionArchiveFailureCode.INTEGRITY_FAILURE) + finally: + if tree is not None: + tree._close() + os.close(root_fd) + + def _seal_projected_content( + self, + root_fd: int, + entries: tuple[SubmissionArchiveEntry, ...], + ) -> dict[str, bytes]: + """Snapshot verified files without exposing filesystem authority.""" + return { + entry.normalized_path: _read_projected_file( + root_fd, + entries, + entry.normalized_path, + entry.byte_count, + ) + for entry in entries + if entry.entry_type is SubmissionArchiveEntryType.FILE + } + + def _project_file( + self, + archive: zipfile.ZipFile, + info: zipfile.ZipInfo, + *, + root_fd: int, + expected: SubmissionArchiveEntry, + started: float, + ) -> None: + parent_fd, filename = _open_parent_beneath(root_fd, expected.normalized_path) + descriptor: int | None = None + try: + descriptor = os.open( + filename, + os.O_CREAT + | os.O_EXCL + | os.O_WRONLY + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=parent_fd, + ) + digest = hashlib.sha256() + byte_count = 0 + with archive.open(info, "r") as member: + while chunk := member.read(_READ_BYTES): + self._check_deadline(started) + byte_count += len(chunk) + if byte_count > expected.byte_count: + self._reject(SubmissionArchiveFailureCode.INTEGRITY_FAILURE) + digest.update(chunk) + view = memoryview(chunk) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise OSError("submission projection write failed") + view = view[written:] + if ( + byte_count != expected.byte_count + or f"sha256:{digest.hexdigest()}" != expected.sha256 + ): + self._reject(SubmissionArchiveFailureCode.INTEGRITY_FAILURE) + os.fsync(descriptor) + os.fchmod(descriptor, 0o500 if expected.executable else 0o400) + details = os.fstat(descriptor) + if ( + not stat.S_ISREG(details.st_mode) + or details.st_size != expected.byte_count + or details.st_mode & 0o777 != (0o500 if expected.executable else 0o400) + ): + self._reject(SubmissionArchiveFailureCode.INTEGRITY_FAILURE) + os.fsync(descriptor) + os.fsync(parent_fd) + finally: + if descriptor is not None: + os.close(descriptor) + os.close(parent_fd) + def _read_entries( self, archive: zipfile.ZipFile, infos: list[zipfile.ZipInfo], *, started: float ) -> list[SubmissionArchiveEntry]: @@ -451,3 +734,42 @@ def _check_deadline(self, started: float) -> None: @staticmethod def _reject(code: SubmissionArchiveFailureCode) -> None: raise SubmissionArchiveRejectedError(code) + + +def _open_parent_beneath(root_fd: int, normalized_path: str) -> tuple[int, str]: + """Open the validated parent chain without following any component.""" + parts = PurePosixPath(normalized_path).parts + descriptor = os.dup(root_fd) + try: + for part in parts[:-1]: + child = os.open( + part, + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0), + dir_fd=descriptor, + ) + os.close(descriptor) + descriptor = child + return descriptor, parts[-1] + except BaseException: + os.close(descriptor) + raise + + +def _open_beneath(root_fd: int, normalized_path: str, flags: int) -> int: + parent_fd, filename = _open_parent_beneath(root_fd, normalized_path) + try: + return os.open(filename, flags, dir_fd=parent_fd) + finally: + os.close(parent_fd) + + +def _mkdir_beneath(root_fd: int, normalized_path: str) -> None: + """Create one exact validated directory under an already-created parent.""" + parent_fd, filename = _open_parent_beneath(root_fd, normalized_path) + try: + os.mkdir(filename, mode=0o700, dir_fd=parent_fd) + os.fsync(parent_fd) + finally: + os.close(parent_fd) diff --git a/backend/app/modules/artifacts/submission_materialization.py b/backend/app/modules/artifacts/submission_materialization.py new file mode 100644 index 000000000..2a906e555 --- /dev/null +++ b/backend/app/modules/artifacts/submission_materialization.py @@ -0,0 +1,145 @@ +"""Hidden authorized materialization for one prepared contributor bundle.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, final +from uuid import UUID + +from app.interfaces.artifact_operations import PreparedBundleMaterializationRequest +from app.modules.actors.service_identities import ServiceIdentity +from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError +from app.modules.artifacts.preparation import ArtifactPreparationService +from app.modules.artifacts.submission_archive import SubmissionArchiveInspector +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.prepared import PreparedAuthorizationHandle +from app.modules.checkers.catalogue import PreSubmissionCheckerCatalogue +from app.modules.checkers.pre_submit_execution import ( + DefaultPreSubmissionExecutionInput, + DefaultPreSubmissionExecutionResult, + DefaultPreSubmissionProcessor, + PreSubmissionInfrastructureUnavailable, +) + + +@final +@dataclass(frozen=True, slots=True) +class PreSubmitMaterializationAuthorityFacts: + """Exact process-local resource facts bound to fixed materializer authority.""" + + task_id: UUID + assignment_id: UUID + project_id: UUID + submission_artifact_policy_id: UUID + checker_policy_id: UUID + prepared_generation_id: UUID + plan_sha256: str + catalogue_manifest_sha256: str + archive_sha256: str + archive_byte_count: int + semantic_manifest_sha256: str + + +class PreSubmitMaterializationAuthorization(Protocol): + """Adapter over AUTH's opaque transaction-bound prepared capability.""" + + async def consume( + self, + *, + service_identity: ServiceIdentity, + action_id: ActionId, + prepared_authorization: PreparedAuthorizationHandle, + facts: PreSubmitMaterializationAuthorityFacts, + ) -> None: ... + + +class DenyPreSubmitMaterializationAuthorization: + """Keep production byte access unavailable until XINT-06A activation.""" + + async def consume( + self, + *, + service_identity: ServiceIdentity, + action_id: ActionId, + prepared_authorization: PreparedAuthorizationHandle, + facts: PreSubmitMaterializationAuthorityFacts, + ) -> None: + del service_identity, action_id, prepared_authorization, facts + raise ArtifactAuthorityDeniedError( + "pre-submit checker input materialization is unavailable" + ) + + +class PreparedBundleMaterializationService: + """Authorize, project, execute, and clean one process-local bundle.""" + + def __init__( + self, + *, + authorization: PreSubmitMaterializationAuthorization, + preparation: ArtifactPreparationService, + archive_inspector: SubmissionArchiveInspector, + catalogue: PreSubmissionCheckerCatalogue, + ) -> None: + self._authorization = authorization + self._preparation = preparation + self._archive_inspector = archive_inspector + self._catalogue = catalogue + + async def materialize_prepared_bundle( + self, + request: PreparedBundleMaterializationRequest, + ) -> DefaultPreSubmissionExecutionResult: + """Consume fixed-service authority before any byte or workspace access.""" + facts = self._authority_facts(request) + await self._authorization.consume( + service_identity=ServiceIdentity.ARTIFACT_MATERIALIZER, + action_id=ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + prepared_authorization=request.prepared_authorization, + facts=facts, + ) + processor = DefaultPreSubmissionProcessor( + archive_inspector=self._archive_inspector, + catalogue=self._catalogue, + execution_input=DefaultPreSubmissionExecutionInput( + plan=request.effective_plan, + commitment=request.prepared_artifact.commitment, + inspection=request.inspection, + manifest=request.manifest, + change_gate=request.change_gate, + packet=request.packet, + ), + ) + return await self._preparation._process_prepared_submission( + request.prepared_artifact, + processor, + reserved_bytes=request.manifest.total_expanded_bytes, + maximum_entries=request.manifest.entry_count, + ) + + def _authority_facts( + self, + request: PreparedBundleMaterializationRequest, + ) -> PreSubmitMaterializationAuthorityFacts: + plan = request.effective_plan + if ( + request.submission_artifact_policy_id != plan.lineage.effective_policy_id + or request.checker_policy_id != plan.lineage.pre_submit_policy_id + ): + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_materialization_context_invalid" + ) + commitment = request.prepared_artifact.commitment + return PreSubmitMaterializationAuthorityFacts( + task_id=request.task_id, + assignment_id=request.assignment_id, + project_id=plan.lineage.project_id, + submission_artifact_policy_id=request.submission_artifact_policy_id, + checker_policy_id=request.checker_policy_id, + prepared_generation_id=request.prepared_artifact.generation_id, + plan_sha256=plan.plan_sha256, + catalogue_manifest_sha256=plan.catalogue_manifest_sha256, + archive_sha256=commitment.sha256, + archive_byte_count=commitment.byte_count, + semantic_manifest_sha256=request.manifest.sha256, + ) diff --git a/backend/app/modules/checkers/pre_submit_defaults.py b/backend/app/modules/checkers/pre_submit_defaults.py new file mode 100644 index 000000000..5d9b231bc --- /dev/null +++ b/backend/app/modules/checkers/pre_submit_defaults.py @@ -0,0 +1,84 @@ +"""Shared pure semantics for Workstream-default pre-submission checks.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable, Sequence + + +GENERIC_ATTESTATIONS = frozenset({"ok", "done", "yes", "i agree", "confirmed"}) +IGNORED_ATTESTATION_TERM_WORDS = frozenset( + {"a", "an", "and", "for", "of", "the", "exclusion"} +) +CONFIDENTIALITY_TERMS = ("confidential", "private", "client data", "proprietary") +CREDENTIAL_TERMS = ("credential", "secret", "token", "password", "api key") +SOURCE_PLATFORM_TERMS = ( + "source material", + "source code", + "platform artifact", + "copied artifact", + "copied platform", +) +LOW_QUALITY_GENERATED_PATTERNS = ( + "todo", + "placeholder", + "lorem ipsum", + "sample output", + "dummy", + "generated by chatgpt", + "as an ai language model", +) + + +def normalize_policy_token(value: str) -> str: + """Normalize a policy key or machine term for deterministic comparison.""" + normalized = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()) + return normalized.strip("_") + + +def attestation_term_is_satisfied(normalized_attestation: str, term: str) -> bool: + """Return whether an attestation satisfies one machine-readable term key.""" + normalized_term = normalize_policy_token(term) + if not normalized_term: + return True + if normalized_term in normalized_attestation: + return True + attestation_words = set(normalized_attestation.split("_")) + required_words = [ + word + for word in normalized_term.split("_") + if word and word not in IGNORED_ATTESTATION_TERM_WORDS + ] + return all(word in attestation_words for word in required_words) + + +def attestation_validation_facts( + worker_attestation: str, + *, + required_terms: Sequence[str] = (), +) -> dict[str, bool | list[str]]: + """Return the canonical facts used by both pre-submission execution paths.""" + normalized = " ".join(worker_attestation.strip().lower().split()) + normalized_for_terms = normalize_policy_token(worker_attestation) + return { + "has_required_length": len(normalized) >= 40, + "has_non_generic_text": normalized not in GENERIC_ATTESTATIONS, + "has_confidentiality_term": any(term in normalized for term in CONFIDENTIALITY_TERMS), + "has_credential_term": any(term in normalized for term in CREDENTIAL_TERMS), + "has_source_or_platform_term": any( + term in normalized for term in SOURCE_PLATFORM_TERMS + ), + "missing_attestation_terms": [ + term + for term in required_terms + if not attestation_term_is_satisfied(normalized_for_terms, term) + ], + } + + +def matched_low_quality_patterns(parts: Iterable[str]) -> tuple[str, ...]: + """Return deterministic categories without retaining contributor text.""" + haystack = "\n".join(parts).casefold() + return tuple( + sorted(pattern for pattern in LOW_QUALITY_GENERATED_PATTERNS if pattern in haystack) + ) diff --git a/backend/app/modules/checkers/pre_submit_execution.py b/backend/app/modules/checkers/pre_submit_execution.py new file mode 100644 index 000000000..bb1ea2d6a --- /dev/null +++ b/backend/app/modules/checkers/pre_submit_execution.py @@ -0,0 +1,357 @@ +"""Hidden plan-bound execution of Workstream-default pre-submission checks.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from pathlib import PurePosixPath +import threading +from typing import BinaryIO + +from app.core.hashing import canonical_json_hash +from app.modules.artifacts.sources import ArtifactCommitment +from app.modules.artifacts.submission_archive import ( + SealedSubmissionTree, + SubmissionArchiveInspectionResult, + SubmissionArchiveInspector, +) +from app.modules.artifacts.submission_manifest import ( + SubmissionChangeGateResult, + SubmissionManifest, + build_submission_manifest, +) +from app.modules.checkers.catalogue import ( + PRE_SUBMISSION_RESULT_SCHEMA_VERSION, + PreSubmissionCheckerCatalogue, + PreSubmissionCheckerPhase, + PreSubmissionCheckerState, + PreSubmissionPlatformCapability, +) +from app.modules.checkers.effective_plan import ( + EffectivePreSubmissionExecutionPlan, + EffectivePreSubmissionPlanEntry, +) +from app.modules.checkers.pre_submit_defaults import ( + attestation_validation_facts, + matched_low_quality_patterns, +) + + +_EXECUTED_PHASES = frozenset( + { + PreSubmissionCheckerPhase.CUSTODY.value, + PreSubmissionCheckerPhase.IDENTITY.value, + PreSubmissionCheckerPhase.MATERIALIZATION.value, + PreSubmissionCheckerPhase.DEFAULT_POLICY.value, + } +) +_FORBIDDEN_EXACT_NAMES = frozenset({".env", "id_rsa", "id_ed25519"}) +_FORBIDDEN_DIRECTORY_NAMES = frozenset({".git"}) +_FORBIDDEN_SUFFIXES = (".pem", ".key") + + +class DefaultPreSubmissionExecutionError(RuntimeError): + """Fail hidden execution without creating a durable checker effect.""" + + +class PreSubmissionInfrastructureUnavailable(DefaultPreSubmissionExecutionError): + """Fail closed for an impossible or disabled mandatory execution state.""" + + +class DefaultPreSubmissionResultStatus(StrEnum): + PASSED = "passed" + WARNING = "warning" + ADVISORY_DISABLED = "advisory_disabled" + DEPENDENCY_NOT_RUN = "dependency_not_run" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class SubmissionPacketView: + """Bounded contributor-authored text accompanying server-owned ZIP facts.""" + + summary: str + contributor_attestation: str + + def __post_init__(self) -> None: + for value in (self.summary, self.contributor_attestation): + if type(value) is not str or len(value.encode("utf-8")) > 64 * 1024: + raise ValueError("submission packet text is invalid") + + +@dataclass(frozen=True, slots=True) +class DefaultPreSubmissionEntryResult: + """One bounded path-redacted result bound to its exact plan entry.""" + + schema_version: str + plan_sha256: str + entry_id: str + entry_version: str + status: DefaultPreSubmissionResultStatus + failure_code: str | None + message_code: str + metadata: tuple[tuple[str, int | bool | str], ...] = () + + +@dataclass(frozen=True, slots=True) +class DefaultPreSubmissionExecutionResult: + """Complete non-durable 04B2 phase result returned after scratch cleanup.""" + + plan_sha256: str + eligible: bool + entries: tuple[DefaultPreSubmissionEntryResult, ...] + + +@dataclass(frozen=True, slots=True) +class DefaultPreSubmissionExecutionInput: + """Exact 04A/04B1 facts required by the default executor.""" + + plan: EffectivePreSubmissionExecutionPlan + commitment: ArtifactCommitment + inspection: SubmissionArchiveInspectionResult + manifest: SubmissionManifest + change_gate: SubmissionChangeGateResult + packet: SubmissionPacketView + + +class DefaultPreSubmissionProcessor: + """Prepared-artifact processor that owns projection and default dispatch.""" + + def __init__( + self, + *, + archive_inspector: SubmissionArchiveInspector, + catalogue: PreSubmissionCheckerCatalogue, + execution_input: DefaultPreSubmissionExecutionInput, + ) -> None: + self._archive_inspector = archive_inspector + self._catalogue = catalogue + self._input = execution_input + self._aborted = threading.Event() + + def abort(self) -> None: + """Prevent checker callback access after caller cancellation or timeout.""" + self._aborted.set() + + def process(self, reader: BinaryIO, workspace: Path) -> DefaultPreSubmissionExecutionResult: + """Project once and execute only the closed platform/default phase slice.""" + self._validate_input() + return self._archive_inspector.project_and_run( + reader, + workspace, + expected=self._input.inspection, + callback=self._execute_unless_aborted, + ) + + def _execute_unless_aborted( + self, + tree: SealedSubmissionTree, + ) -> DefaultPreSubmissionExecutionResult: + if self._aborted.is_set(): + raise DefaultPreSubmissionExecutionError("pre_submission_execution_aborted") + return self._execute(tree) + + def _validate_input(self) -> None: + plan = self._input.plan + if canonical_json_hash(plan.as_dict()) != plan.plan_sha256: + raise PreSubmissionInfrastructureUnavailable("pre_submission_plan_identity_invalid") + if plan.catalogue_manifest_sha256 != self._catalogue.manifest_sha256: + raise PreSubmissionInfrastructureUnavailable("pre_submission_catalogue_stale") + if build_submission_manifest(self._input.inspection) != self._input.manifest: + raise PreSubmissionInfrastructureUnavailable("submission_manifest_identity_unavailable") + if ( + self._input.change_gate.archive_sha256 != self._input.commitment.sha256 + or self._input.change_gate.archive_byte_count != self._input.commitment.byte_count + or self._input.change_gate.manifest != self._input.manifest + ): + raise PreSubmissionInfrastructureUnavailable("submission_change_identity_invalid") + + def _execute(self, tree: SealedSubmissionTree) -> DefaultPreSubmissionExecutionResult: + results: list[DefaultPreSubmissionEntryResult] = [] + statuses: dict[str, DefaultPreSubmissionResultStatus] = {} + blocked = False + for entry in self._input.plan.entries: + if entry.phase not in _EXECUTED_PHASES: + continue + if entry.dispatch_kind != "platform_capability": + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_dispatch_kind_invalid" + ) + definition = self._catalogue.definition(entry.definition_id) + if ( + entry.definition_version != definition.version + or entry.dispatch_capability != definition.dispatch_capability + or entry.classification != definition.classification.value + or entry.state != definition.state.value + ): + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_plan_entry_stale" + ) + unmet = any( + statuses.get(dependency) + not in { + DefaultPreSubmissionResultStatus.PASSED, + DefaultPreSubmissionResultStatus.WARNING, + DefaultPreSubmissionResultStatus.ADVISORY_DISABLED, + } + for dependency in entry.dependencies + ) + if blocked or unmet: + result = self._result( + entry, + DefaultPreSubmissionResultStatus.DEPENDENCY_NOT_RUN, + message_code="dependency_not_run", + ) + elif entry.state == PreSubmissionCheckerState.DISABLED.value: + if definition.classification.mandatory: + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_infrastructure_unavailable" + ) + result = self._result( + entry, + DefaultPreSubmissionResultStatus.ADVISORY_DISABLED, + message_code="advisory_disabled", + ) + else: + result = self._dispatch(entry, tree) + if result.status is DefaultPreSubmissionResultStatus.FAILED: + blocked = True + if entry.definition_id in statuses: + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_duplicate_result" + ) + statuses[entry.definition_id] = result.status + results.append(result) + expected_ids = { + entry.definition_id + for entry in self._input.plan.entries + if entry.phase in _EXECUTED_PHASES + } + if set(statuses) != expected_ids: + raise PreSubmissionInfrastructureUnavailable("pre_submission_result_incomplete") + return DefaultPreSubmissionExecutionResult( + plan_sha256=self._input.plan.plan_sha256, + eligible=not blocked, + entries=tuple(results), + ) + + def _dispatch( + self, + entry: EffectivePreSubmissionPlanEntry, + tree: SealedSubmissionTree, + ) -> DefaultPreSubmissionEntryResult: + try: + capability = PreSubmissionPlatformCapability(entry.dispatch_capability) + except ValueError as exc: + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_dispatch_capability_unknown" + ) from exc + if entry.phase in { + PreSubmissionCheckerPhase.CUSTODY.value, + PreSubmissionCheckerPhase.IDENTITY.value, + }: + return self._result(entry, DefaultPreSubmissionResultStatus.PASSED) + if entry.phase == PreSubmissionCheckerPhase.MATERIALIZATION.value: + if tree.entries != self._input.manifest.entries: + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_materialization_unavailable" + ) + return self._result( + entry, + DefaultPreSubmissionResultStatus.PASSED, + metadata=(("entry_count", len(tree.entries)),), + ) + if capability is PreSubmissionPlatformCapability.SUBMISSION_PACKET: + missing = int(not self._input.packet.summary.strip()) + int( + not self._input.packet.contributor_attestation.strip() + ) + return self._blocking_or_pass(entry, missing, "submission_packet_invalid") + if capability is PreSubmissionPlatformCapability.ATTESTATION: + facts = attestation_validation_facts( + self._input.packet.contributor_attestation + ) + missing = sum( + not bool(facts[key]) + for key in ( + "has_required_length", + "has_non_generic_text", + "has_confidentiality_term", + "has_credential_term", + "has_source_or_platform_term", + ) + ) + return self._blocking_or_pass(entry, missing, "attestation_missing") + if capability is PreSubmissionPlatformCapability.SENSITIVE_PATH: + matches = sum( + _is_high_confidence_sensitive(item.normalized_path) for item in tree.entries + ) + return self._blocking_or_pass(entry, matches, "sensitive_path_forbidden") + if capability is not PreSubmissionPlatformCapability.QUALITY_WARNING: + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_dispatch_capability_unknown" + ) + matches = matched_low_quality_patterns( + ( + self._input.packet.summary, + self._input.packet.contributor_attestation, + *(item.normalized_path for item in tree.entries), + ) + ) + if matches: + return self._result( + entry, + DefaultPreSubmissionResultStatus.WARNING, + message_code="quality_signal_warning", + metadata=(("matched_category_count", len(matches)),), + ) + return self._result(entry, DefaultPreSubmissionResultStatus.PASSED) + + def _blocking_or_pass( + self, + entry: EffectivePreSubmissionPlanEntry, + finding_count: int, + message_code: str, + ) -> DefaultPreSubmissionEntryResult: + return self._result( + entry, + ( + DefaultPreSubmissionResultStatus.FAILED + if finding_count + else DefaultPreSubmissionResultStatus.PASSED + ), + failure_code=entry.failure_code if finding_count else None, + message_code=message_code if finding_count else "passed", + metadata=(("finding_count", finding_count),) if finding_count else (), + ) + + def _result( + self, + entry: EffectivePreSubmissionPlanEntry, + status: DefaultPreSubmissionResultStatus, + *, + failure_code: str | None = None, + message_code: str = "passed", + metadata: tuple[tuple[str, int | bool | str], ...] = (), + ) -> DefaultPreSubmissionEntryResult: + return DefaultPreSubmissionEntryResult( + schema_version=PRE_SUBMISSION_RESULT_SCHEMA_VERSION, + plan_sha256=self._input.plan.plan_sha256, + entry_id=entry.definition_id, + entry_version=entry.definition_version, + status=status, + failure_code=failure_code, + message_code=message_code, + metadata=metadata, + ) + + +def _is_high_confidence_sensitive(normalized_path: str) -> bool: + parts = tuple(part.casefold() for part in PurePosixPath(normalized_path).parts) + if not parts: + return False + return ( + parts[-1] in _FORBIDDEN_EXACT_NAMES + or parts[-1].endswith(_FORBIDDEN_SUFFIXES) + or any(part in _FORBIDDEN_DIRECTORY_NAMES for part in parts) + ) diff --git a/backend/app/modules/checkers/runner.py b/backend/app/modules/checkers/runner.py index 2582e99bb..6ae46fd8b 100644 --- a/backend/app/modules/checkers/runner.py +++ b/backend/app/modules/checkers/runner.py @@ -14,6 +14,11 @@ from app.modules.tasks.models import Submission, WorkstreamTask from app.modules.tasks.schemas import SubmissionCreate +from app.modules.checkers.pre_submit_defaults import ( + attestation_validation_facts, + matched_low_quality_patterns, + normalize_policy_token as _normalize_policy_token, +) CHECKER_STATUS_PASSED = "passed" CHECKER_STATUS_WARNING = "warning" @@ -29,26 +34,6 @@ ROUTING_TASK_SETUP_BLOCKED = "task_setup_blocked" HASH_TOKEN_PATTERN = re.compile(r"^sha256:\S+$") -GENERIC_ATTESTATIONS = {"ok", "done", "yes", "i agree", "confirmed"} -IGNORED_ATTESTATION_TERM_WORDS = {"a", "an", "and", "for", "of", "the", "exclusion"} -CONFIDENTIALITY_TERMS = ("confidential", "private", "client data", "proprietary") -CREDENTIAL_TERMS = ("credential", "secret", "token", "password", "api key") -SOURCE_PLATFORM_TERMS = ( - "source material", - "source code", - "platform artifact", - "copied artifact", - "copied platform", -) -LOW_QUALITY_GENERATED_PATTERNS = ( - "todo", - "placeholder", - "lorem ipsum", - "sample output", - "dummy", - "generated by chatgpt", - "as an ai language model", -) FORBIDDEN_EXACT_SEGMENTS = { ".env", ".git", @@ -485,12 +470,6 @@ def _evidence_integrity_outcome(manifest: list[dict], evidence_items: list[dict] ) -def _normalize_policy_token(value: str) -> str: - """Normalize a policy key or machine term for deterministic comparison.""" - normalized = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()) - return normalized.strip("_") - - def _evidence_candidate_tokens(evidence_item: dict) -> set[str]: """Return policy tokens exposed by one submitted evidence item.""" candidates = { @@ -638,22 +617,6 @@ def _required_attestation_terms(effective_policy: dict | None) -> list[str]: return [str(term) for term in effective_policy.get("attestation_terms", []) if term] -def _attestation_term_is_satisfied(normalized_attestation: str, term: str) -> bool: - """Return whether an attestation satisfies one machine-readable term key.""" - normalized_term = _normalize_policy_token(term) - if not normalized_term: - return True - if normalized_term in normalized_attestation: - return True - attestation_words = set(normalized_attestation.split("_")) - required_words = [ - word - for word in normalized_term.split("_") - if word and word not in IGNORED_ATTESTATION_TERM_WORDS - ] - return all(word in attestation_words for word in required_words) - - def _path_matches_forbidden_pattern(path: str, pattern: str) -> bool: """Return whether a normalized path matches a forbidden policy pattern.""" normalized = path.lower() @@ -760,25 +723,18 @@ def _confidentiality_attestation_outcome( required_terms: list[str] | None = None, ) -> CheckerOutcome: """Validate confidentiality and project-required attestation terms.""" - normalized = " ".join(worker_attestation.strip().lower().split()) - normalized_for_terms = _normalize_policy_token(worker_attestation) - has_required_length = len(normalized) >= 40 - has_non_generic_text = normalized not in GENERIC_ATTESTATIONS - has_confidentiality = any(term in normalized for term in CONFIDENTIALITY_TERMS) - has_credentials = any(term in normalized for term in CREDENTIAL_TERMS) - has_source_platform = any(term in normalized for term in SOURCE_PLATFORM_TERMS) - missing_terms = [ - term - for term in (required_terms or []) - if not _attestation_term_is_satisfied(normalized_for_terms, term) - ] + facts = attestation_validation_facts( + worker_attestation, + required_terms=required_terms or (), + ) + missing_terms = facts["missing_attestation_terms"] if not all( [ - has_required_length, - has_non_generic_text, - has_confidentiality, - has_credentials, - has_source_platform, + facts["has_required_length"], + facts["has_non_generic_text"], + facts["has_confidentiality_term"], + facts["has_credential_term"], + facts["has_source_or_platform_term"], not missing_terms, ] ): @@ -790,10 +746,10 @@ def _confidentiality_attestation_outcome( "and copied source or platform artifacts." ), metadata={ - "has_required_length": has_required_length, - "has_confidentiality_term": has_confidentiality, - "has_credential_term": has_credentials, - "has_source_or_platform_term": has_source_platform, + "has_required_length": facts["has_required_length"], + "has_confidentiality_term": facts["has_confidentiality_term"], + "has_credential_term": facts["has_credential_term"], + "has_source_or_platform_term": facts["has_source_or_platform_term"], "missing_attestation_terms": missing_terms, }, ) @@ -814,8 +770,7 @@ def _low_quality_generated_artifacts_outcome( haystack_parts.extend(str(entry.get("artifact", "")) for entry in manifest) haystack_parts.extend(str(entry.get("notes", "")) for entry in manifest) haystack_parts.extend(str(item.get("label", "")) for item in evidence_items) - haystack = "\n".join(haystack_parts).lower() - matched = sorted(pattern for pattern in LOW_QUALITY_GENERATED_PATTERNS if pattern in haystack) + matched = matched_low_quality_patterns(haystack_parts) if not matched: return None return _warning( diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index 7fcc9d1a5..5680ec950 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -94,6 +94,7 @@ class TestLane: "tests/test_artifact_authorization.py", "tests/test_artifact_internal_authorization.py", "tests/test_artifact_cleanup_wiring.py", + "tests/test_checker_materialization.py", "tests/test_artifact_preparation.py", "tests/test_artifact_store_conformance.py", "tests/test_artifact_verification.py", @@ -160,6 +161,7 @@ class TestLane: ( "tests/test_checker_catalogue.py", "tests/test_checkers.py", + "tests/test_default_pre_submit_execution.py", "tests/test_review_queue_persistence.py", "tests/test_review_lease_persistence.py", "tests/test_tasks.py", diff --git a/backend/tests/test_artifact_cleanup_wiring.py b/backend/tests/test_artifact_cleanup_wiring.py index 20535a527..466c91d6a 100644 --- a/backend/tests/test_artifact_cleanup_wiring.py +++ b/backend/tests/test_artifact_cleanup_wiring.py @@ -264,6 +264,7 @@ def test_preparation_limit_mapping_uses_every_canonical_setting(tmp_path: Path) artifact_scratch_aggregate_reserved_bytes=3 * 512 * 1024 * 1024, artifact_scratch_maximum_files=7, artifact_scratch_maximum_concurrency=3, + artifact_scratch_maximum_workspace_entries=4_000, artifact_scratch_minimum_free_bytes=123, artifact_scratch_reservation_ttl_seconds=3000, artifact_preparation_total_deadline_seconds=2000, @@ -277,6 +278,10 @@ def test_preparation_limit_mapping_uses_every_canonical_setting(tmp_path: Path) assert limits.aggregate_reserved_bytes == settings.artifact_scratch_aggregate_reserved_bytes assert limits.maximum_files == settings.artifact_scratch_maximum_files assert limits.maximum_concurrency == settings.artifact_scratch_maximum_concurrency + assert ( + limits.maximum_workspace_entries + == settings.artifact_scratch_maximum_workspace_entries + ) assert limits.minimum_free_bytes == settings.artifact_scratch_minimum_free_bytes assert limits.reservation_ttl_seconds == settings.artifact_scratch_reservation_ttl_seconds assert limits.total_deadline_seconds == settings.artifact_preparation_total_deadline_seconds diff --git a/backend/tests/test_checker_materialization.py b/backend/tests/test_checker_materialization.py new file mode 100644 index 000000000..be26e39c7 --- /dev/null +++ b/backend/tests/test_checker_materialization.py @@ -0,0 +1,244 @@ +"""Proof for canonical sealed submission-tree projection and scratch custody.""" + +from __future__ import annotations + +from io import BytesIO +from dataclasses import replace +import os +from pathlib import Path +import stat +import zipfile + +import pytest + +from app.modules.artifacts.preparation import ( + HARD_MAXIMUM_ARTIFACT_BYTES, + ArtifactPreparationLimits, + ArtifactScratchCapacityError, + ArtifactScratchManager, +) +from app.modules.artifacts.submission_archive import ( + SubmissionArchiveEntryType, + SubmissionArchiveFailureCode, + SubmissionArchiveInspector, + SubmissionArchiveLimits, + SubmissionArchiveRejectedError, +) +from app.modules.artifacts.submission_manifest import build_submission_manifest + + +def _zip(*, executable: bool = False) -> bytes: + output = BytesIO() + info = zipfile.ZipInfo("src/run.sh") + info.create_system = 3 + info.external_attr = (stat.S_IFREG | (0o755 if executable else 0o644)) << 16 + with zipfile.ZipFile(output, "w") as archive: + archive.writestr(info, b"echo bounded\n") + archive.writestr("README.md", b"proof\n") + return output.getvalue() + + +def _limits(**changes: object) -> ArtifactPreparationLimits: + values = { + "aggregate_reserved_bytes": 2 * HARD_MAXIMUM_ARTIFACT_BYTES, + "maximum_files": 2, + "maximum_concurrency": 2, + "minimum_free_bytes": 0, + "reservation_ttl_seconds": 30.0, + "total_deadline_seconds": 10.0, + "cleanup_margin_seconds": 5.0, + "stream_buffer_bytes": 1024, + "maximum_source_bytes": 1024 * 1024, + "maximum_workspace_entries": 2_000, + } + values.update(changes) + return ArtifactPreparationLimits(**values) + + +class _ProjectionProcessor: + def __init__(self, inspector, inspection): + self.inspector = inspector + self.inspection = inspection + self.escaped_tree = None + self.retained_content = None + + def process(self, reader, workspace): + def verify(tree): + self.escaped_tree = tree + self.retained_content = tree._content + assert not hasattr(tree, "path") + assert not hasattr(tree, "execute") + assert not hasattr(tree, "_root_fd") + assert not hasattr(tree, "_read") + assert tree.read_file("README.md", maximum_bytes=16) == b"proof\n" + assert tree.read_file("src/run.sh", maximum_bytes=32) == b"echo bounded\n" + return tuple((entry.normalized_path, entry.executable) for entry in tree.entries) + + return self.inspector.project_and_run( + reader, + workspace, + expected=self.inspection, + callback=verify, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("executable", (False, True)) +async def test_projection_is_callback_scoped_and_cleanup_is_complete( + tmp_path: Path, executable: bool +) -> None: + data = _zip(executable=executable) + inspector = SubmissionArchiveInspector(SubmissionArchiveLimits()) + inspection = inspector.inspect(BytesIO(data)) + manager = ArtifactScratchManager(root=tmp_path / "scratch", limits=_limits()) + processor = _ProjectionProcessor(inspector, inspection) + + with manager.extraction_workspace( + reserved_bytes=inspection.total_expanded_bytes, + maximum_entries=inspection.entry_count, + ) as workspace: + result = processor.process(BytesIO(data), workspace) + + assert ("src/run.sh", executable) in result + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + assert processor.escaped_tree is not None + with pytest.raises(RuntimeError, match="closed"): + processor.escaped_tree.read_file("README.md", maximum_bytes=16) + assert processor.retained_content == {} + assert processor.escaped_tree._entries == () + manager.close() + + +@pytest.mark.asyncio +async def test_workspace_expansion_is_charged_before_projection(tmp_path: Path) -> None: + data = _zip() + inspector = SubmissionArchiveInspector(SubmissionArchiveLimits()) + inspection = inspector.inspect(BytesIO(data)) + manager = ArtifactScratchManager( + root=tmp_path / "scratch", + limits=_limits(aggregate_reserved_bytes=HARD_MAXIMUM_ARTIFACT_BYTES), + ) + reservation, descriptor = await manager.allocate() + os.close(descriptor) + with pytest.raises(ArtifactScratchCapacityError, match="byte limit"): + with manager.extraction_workspace( + reserved_bytes=inspection.total_expanded_bytes, + maximum_entries=inspection.entry_count, + ): + raise AssertionError("workspace must not be exposed") + + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + await manager.release(reservation) + manager.close() + + +@pytest.mark.parametrize( + "drift", + ("path", "type", "sha256", "byte_count", "executable", "entry_count", "aggregate"), +) +def test_projection_rejects_manifest_drift_before_callback( + tmp_path: Path, drift: str +) -> None: + data = _zip() + inspector = SubmissionArchiveInspector(SubmissionArchiveLimits()) + inspection = inspector.inspect(BytesIO(data)) + manifest = build_submission_manifest(inspection) + called = False + + def callback(_tree): + nonlocal called + called = True + + entries = list(inspection.entries) + file_index = next(index for index, entry in enumerate(entries) if entry.sha256 is not None) + if drift == "path": + entries[file_index] = replace(entries[file_index], normalized_path="changed.txt") + elif drift == "type": + entries[file_index] = replace( + entries[file_index], entry_type=SubmissionArchiveEntryType.DIRECTORY + ) + elif drift == "sha256": + entries[file_index] = replace(entries[file_index], sha256="sha256:" + "0" * 64) + elif drift == "byte_count": + entries[file_index] = replace( + entries[file_index], byte_count=entries[file_index].byte_count + 1 + ) + elif drift == "executable": + entries[file_index] = replace( + entries[file_index], executable=not entries[file_index].executable + ) + drifted = replace( + inspection, + entries=tuple(entries), + entry_count=inspection.entry_count + (1 if drift == "entry_count" else 0), + total_expanded_bytes=( + inspection.total_expanded_bytes + (1 if drift == "aggregate" else 0) + ), + ) + with pytest.raises(SubmissionArchiveRejectedError) as caught: + inspector.project_and_run( + BytesIO(data), + tmp_path, + expected=drifted, + callback=callback, + ) + assert manifest.sha256 + assert caught.value.code is SubmissionArchiveFailureCode.INTEGRITY_FAILURE + assert called is False + + +def test_workspace_cleanup_bound_is_separate_from_prepared_file_limit(tmp_path: Path) -> None: + manager = ArtifactScratchManager( + root=tmp_path / "scratch", + limits=_limits(maximum_files=1, maximum_concurrency=1, maximum_workspace_entries=8), + ) + with manager.extraction_workspace(reserved_bytes=8, maximum_entries=8) as workspace: + workspace_fd = os.open(workspace, os.O_RDONLY | os.O_DIRECTORY) + try: + for index in range(8): + descriptor = os.open( + f"entry-{index}", + os.O_CREAT | os.O_EXCL | os.O_WRONLY, + 0o600, + dir_fd=workspace_fd, + ) + os.close(descriptor) + finally: + os.close(workspace_fd) + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + manager.close() + + +def test_adapter_failure_after_handoff_revokes_tree_and_cleans_workspace( + tmp_path: Path, +) -> None: + data = _zip() + inspector = SubmissionArchiveInspector(SubmissionArchiveLimits()) + inspection = inspector.inspect(BytesIO(data)) + manager = ArtifactScratchManager(root=tmp_path / "scratch", limits=_limits()) + escaped = None + + def fail(tree): + nonlocal escaped + escaped = tree + assert tree.read_file("README.md", maximum_bytes=16) == b"proof\n" + raise RuntimeError("adapter failed") + + with pytest.raises(RuntimeError, match="adapter failed"): + with manager.extraction_workspace( + reserved_bytes=inspection.total_expanded_bytes, + maximum_entries=inspection.entry_count, + ) as workspace: + inspector.project_and_run( + BytesIO(data), + workspace, + expected=inspection, + callback=fail, + ) + + assert escaped is not None + with pytest.raises(RuntimeError, match="closed"): + escaped.read_file("README.md", maximum_bytes=16) + assert escaped._content == {} + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + manager.close() diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 7a16b0476..607c1dcdd 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -701,6 +701,7 @@ def test_artifact_scratch_settings_are_bounded_and_separate(tmp_path) -> None: artifact_scratch_aggregate_reserved_bytes=2 * 512 * 1024 * 1024, artifact_scratch_maximum_files=4, artifact_scratch_maximum_concurrency=2, + artifact_scratch_maximum_workspace_entries=2_000, artifact_scratch_minimum_free_bytes=128 * 1024 * 1024, artifact_scratch_reservation_ttl_seconds=2400, artifact_preparation_total_deadline_seconds=1800, @@ -708,6 +709,7 @@ def test_artifact_scratch_settings_are_bounded_and_separate(tmp_path) -> None: ) assert settings.artifact_scratch_root == tmp_path / "scratch" assert settings.artifact_scratch_maximum_concurrency == 2 + assert settings.artifact_scratch_maximum_workspace_entries == 2_000 with pytest.raises(ValidationError, match="concurrency cannot exceed"): Settings( @@ -720,6 +722,11 @@ def test_artifact_scratch_settings_are_bounded_and_separate(tmp_path) -> None: artifact_preparation_total_deadline_seconds=500, artifact_scratch_cleanup_margin_seconds=100, ) + with pytest.raises(ValidationError, match="workspace entry limit cannot be below"): + Settings( + artifact_submission_zip_maximum_entries=9, + artifact_scratch_maximum_workspace_entries=8, + ) with pytest.raises(ValidationError, match="roots must be separate"): Settings( artifact_local_root=tmp_path / "artifacts", diff --git a/backend/tests/test_default_pre_submit_execution.py b/backend/tests/test_default_pre_submit_execution.py new file mode 100644 index 000000000..5d18f4773 --- /dev/null +++ b/backend/tests/test_default_pre_submit_execution.py @@ -0,0 +1,683 @@ +"""Proof for plan-bound hidden Workstream-default pre-submit execution.""" + +from __future__ import annotations + +import asyncio +from io import BytesIO +from dataclasses import replace +from pathlib import Path +import threading +import zipfile +from uuid import uuid4 + +import pytest + +from app.core.hashing import canonical_json_hash +from app.interfaces.artifact_operations import PreparedBundleMaterializationRequest +from app.modules.artifacts.preparation import ( + HARD_MAXIMUM_ARTIFACT_BYTES, + ArtifactPreparationLimits, + ArtifactPreparationService, + ArtifactPreparationDeadlineError, + ArtifactScratchManager, +) +from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError +from app.modules.artifacts.submission_archive import ( + SubmissionArchiveInspector, + SubmissionArchiveLimits, +) +from app.modules.artifacts.submission_manifest import ( + build_submission_manifest, + evaluate_submission_change, +) +from app.modules.artifacts.submission_materialization import ( + DenyPreSubmitMaterializationAuthorization, + PreparedBundleMaterializationService, +) +from app.modules.authorization.prepared import PreparedAuthorizationHandle +from app.modules.checkers.catalogue import ( + PreSubmissionCheckerPhase, + build_pre_submission_checker_catalogue, +) +from app.modules.checkers.compiler import compile_effective_project_submission_artifact_policy +from app.modules.checkers.effective_plan import ( + EffectivePreSubmissionPlanLineage, + compile_effective_pre_submission_execution_plan, +) +from app.modules.checkers.pre_submit_execution import ( + DefaultPreSubmissionResultStatus, + PreSubmissionInfrastructureUnavailable, + SubmissionPacketView, +) + + +async def _bytes(value: bytes): + yield value + + +def _archive(path: str = "task.toml") -> bytes: + output = BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr(path, b"[task]\nname='proof'\n") + return output.getvalue() + + +def _effective_policy() -> dict[str, object]: + defaults = { + "required_packet_fields": ["summary", "worker_attestation"], + "forbidden_artifacts": [{"pattern": ".env"}, {"pattern": ".git/**"}], + "attestation_terms": ["rights_confirmed"], + } + return { + "workstream_default_policy": defaults, + "project_policy": {}, + "required_packet_fields": defaults["required_packet_fields"], + "required_artifacts": [{"key": "task.toml", "required": True}], + "required_evidence": [{"key": "results", "required": True}], + "forbidden_artifacts": defaults["forbidden_artifacts"], + "attestation_terms": defaults["attestation_terms"], + "manifest_required": True, + "artifact_hash_required": True, + "artifact_hash_algorithm": "sha256", + "allowed_storage_schemes": ["s3"], + "maximum_file_size_bytes": 1_000_000, + "maximum_package_size_bytes": 5_000_000, + "packaging": {"package_required": True, "allowed_package_formats": ["zip"]}, + } + + +def _plan(catalogue): + policy = _effective_policy() + policy_hash = canonical_json_hash(policy) + compiled = compile_effective_project_submission_artifact_policy(policy, policy_hash) + lineage = EffectivePreSubmissionPlanLineage( + project_id=uuid4(), + guide_id=uuid4(), + guide_version=1, + source_snapshot_id=uuid4(), + source_snapshot_hash="sha256:" + "1" * 64, + effective_policy_id=uuid4(), + effective_policy_hash=policy_hash, + pre_submit_policy_id=uuid4(), + pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash, + ) + return compile_effective_pre_submission_execution_plan( + lineage=lineage, + effective_policy=policy, + compiled_bundle=compiled.compiled_bundle, + catalogue=catalogue, + ) + + +def _rehash_plan(plan, *, entries=None, catalogue_manifest_sha256=None): + changed = replace( + plan, + entries=plan.entries if entries is None else tuple(entries), + catalogue_manifest_sha256=( + plan.catalogue_manifest_sha256 + if catalogue_manifest_sha256 is None + else catalogue_manifest_sha256 + ), + ) + return replace(changed, plan_sha256=canonical_json_hash(changed.as_dict())) + + +def _limits() -> ArtifactPreparationLimits: + return ArtifactPreparationLimits( + aggregate_reserved_bytes=2 * HARD_MAXIMUM_ARTIFACT_BYTES, + maximum_files=2, + maximum_concurrency=2, + minimum_free_bytes=0, + reservation_ttl_seconds=30, + total_deadline_seconds=10, + cleanup_margin_seconds=5, + stream_buffer_bytes=1024, + maximum_source_bytes=1024 * 1024, + maximum_workspace_entries=2_000, + ) + + +class _AllowAuthority: + def __init__(self) -> None: + self.facts = None + + async def consume(self, **values): + self.facts = values["facts"] + + +def _handle() -> PreparedAuthorizationHandle: + return object.__new__(PreparedAuthorizationHandle) + + +async def _request(tmp_path: Path, *, path: str = "task.toml", catalogue=None): + selected_catalogue = catalogue or build_pre_submission_checker_catalogue() + plan = _plan(selected_catalogue) + data = _archive(path) + inspector = SubmissionArchiveInspector(SubmissionArchiveLimits()) + manager = ArtifactScratchManager(root=tmp_path / "scratch", limits=_limits()) + preparation = ArtifactPreparationService(manager) + prepared = await preparation.prepare(_bytes(data), media_type="application/zip") + inspection = await prepared.inspect(inspector) + manifest = build_submission_manifest(inspection) + change = evaluate_submission_change( + commitment=prepared.commitment, + manifest=manifest, + predecessor=None, + predecessor_exists=False, + ) + request = PreparedBundleMaterializationRequest( + prepared_authorization=_handle(), + task_id=uuid4(), + assignment_id=uuid4(), + submission_artifact_policy_id=plan.lineage.effective_policy_id, + checker_policy_id=plan.lineage.pre_submit_policy_id, + prepared_artifact=prepared, + effective_plan=plan, + inspection=inspection, + manifest=manifest, + change_gate=change, + packet=SubmissionPacketView( + summary="Completed exact project work.", + contributor_attestation=( + "I confirm no confidential client data, credentials, or copied source " + "material is included in this submission." + ), + ), + ) + return request, inspector, manager, preparation, selected_catalogue + + +@pytest.mark.asyncio +async def test_authority_denial_precedes_workspace_and_checker_access(tmp_path: Path) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + service = PreparedBundleMaterializationService( + authorization=DenyPreSubmitMaterializationAuthorization(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + ) + + with pytest.raises(ArtifactAuthorityDeniedError): + await service.materialize_prepared_bundle(request) + + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_default_executor_uses_plan_order_and_never_dispatches_project_rules( + tmp_path: Path, +) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + authority = _AllowAuthority() + service = PreparedBundleMaterializationService( + authorization=authority, + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + ) + + result = await service.materialize_prepared_bundle(request) + + expected = [ + entry.definition_id + for entry in request.effective_plan.entries + if entry.phase + in { + PreSubmissionCheckerPhase.CUSTODY.value, + PreSubmissionCheckerPhase.IDENTITY.value, + PreSubmissionCheckerPhase.MATERIALIZATION.value, + PreSubmissionCheckerPhase.DEFAULT_POLICY.value, + } + ] + assert [entry.entry_id for entry in result.entries] == expected + assert all(not entry.entry_id.startswith("policy.") for entry in result.entries) + assert all(entry.status is DefaultPreSubmissionResultStatus.PASSED for entry in result.entries) + assert result.eligible is True + assert authority.facts is not None + assert authority.facts.prepared_generation_id == request.prepared_artifact.generation_id + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_blocking_default_stops_later_dependency_without_review_decision( + tmp_path: Path, +) -> None: + request, inspector, manager, preparation, catalogue = await _request( + tmp_path, path=".env" + ) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + ) + + result = await service.materialize_prepared_bundle(request) + by_id = {entry.entry_id: entry for entry in result.entries} + + assert by_id["artifact.sensitive_paths.high_confidence"].status is ( + DefaultPreSubmissionResultStatus.FAILED + ) + assert by_id["artifact.quality.placeholder_signal"].status is ( + DefaultPreSubmissionResultStatus.DEPENDENCY_NOT_RUN + ) + assert result.eligible is False + assert all( + value not in {"accept", "needs_revision", "reject"} + for entry in result.entries + for value in (entry.status.value, entry.message_code, entry.failure_code) + if value is not None + ) + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_disabled_advisory_is_explicit_and_not_skipped_success(tmp_path: Path) -> None: + catalogue = build_pre_submission_checker_catalogue( + disabled_entry_ids=frozenset({"artifact.quality.placeholder_signal"}) + ) + request, inspector, manager, preparation, _ = await _request( + tmp_path, catalogue=catalogue + ) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + ) + + result = await service.materialize_prepared_bundle(request) + advisory = next( + entry + for entry in result.entries + if entry.entry_id == "artifact.quality.placeholder_signal" + ) + + assert advisory.status is DefaultPreSubmissionResultStatus.ADVISORY_DISABLED + assert result.eligible is True + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_quality_warning_emits_only_a_bounded_category_count(tmp_path: Path) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + request = replace( + request, + packet=replace(request.packet, summary="Completed work; TODO placeholder removed."), + ) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + ) + + result = await service.materialize_prepared_bundle(request) + warning = next( + entry + for entry in result.entries + if entry.entry_id == "artifact.quality.placeholder_signal" + ) + + assert warning.status is DefaultPreSubmissionResultStatus.WARNING + assert warning.metadata == (("matched_category_count", 2),) + assert result.eligible is True + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_forged_plan_identity_fails_closed_and_cleans_workspace(tmp_path: Path) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + forged = replace(request.effective_plan, plan_sha256="sha256:" + "0" * 64) + request = replace(request, effective_plan=forged) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + ) + + with pytest.raises(PreSubmissionInfrastructureUnavailable, match="plan_identity"): + await service.materialize_prepared_bundle(request) + + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("invalid_state", ("stale_entry", "duplicate", "unknown")) +async def test_invalid_executor_state_fails_closed_and_cleans_workspace( + tmp_path: Path, + invalid_state: str, +) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + entries = list(request.effective_plan.entries) + target_index = next( + index + for index, entry in enumerate(entries) + if entry.definition_id == "submission.packet.required_fields" + ) + selected_catalogue = catalogue + if invalid_state == "stale_entry": + entries[target_index] = replace(entries[target_index], definition_version="stale") + elif invalid_state == "duplicate": + entries.insert(target_index + 1, entries[target_index]) + else: + unknown = replace(entries[target_index], dispatch_capability="unknown.capability") + entries[target_index] = unknown + original = catalogue.definition(unknown.definition_id) + + class _UnknownCatalogue: + manifest_sha256 = catalogue.manifest_sha256 + + def definition(self, definition_id): + definition = catalogue.definition(definition_id) + if definition_id != original.stable_id: + return definition + + class _UnknownDefinition: + dispatch_capability = "unknown.capability" + + def __getattr__(self, name): + return getattr(definition, name) + + return _UnknownDefinition() + + selected_catalogue = _UnknownCatalogue() + request = replace( + request, + effective_plan=_rehash_plan(request.effective_plan, entries=entries), + ) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=selected_catalogue, + ) + + with pytest.raises(PreSubmissionInfrastructureUnavailable): + await service.materialize_prepared_bundle(request) + + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_disabled_mandatory_executor_state_fails_closed(tmp_path: Path) -> None: + request, inspector, manager, preparation, _ = await _request(tmp_path) + catalogue = build_pre_submission_checker_catalogue( + disabled_entry_ids=frozenset({"artifact.outer_zip.valid"}) + ) + entries = [ + replace(entry, state="disabled") + if entry.definition_id == "artifact.outer_zip.valid" + else entry + for entry in request.effective_plan.entries + ] + request = replace( + request, + effective_plan=_rehash_plan( + request.effective_plan, + entries=entries, + catalogue_manifest_sha256=catalogue.manifest_sha256, + ), + ) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + ) + + with pytest.raises(PreSubmissionInfrastructureUnavailable): + await service.materialize_prepared_bundle(request) + + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_default_execution_ignores_project_only_forbidden_rule(tmp_path: Path) -> None: + catalogue = build_pre_submission_checker_catalogue() + policy = _effective_policy() + project_rule = {"pattern": "project-only.blocked"} + policy["project_policy"] = {"forbidden_artifacts": [project_rule]} + policy["forbidden_artifacts"] = [*policy["forbidden_artifacts"], project_rule] + policy_hash = canonical_json_hash(policy) + compiled = compile_effective_project_submission_artifact_policy(policy, policy_hash) + lineage = replace( + _plan(catalogue).lineage, + effective_policy_hash=policy_hash, + pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash, + ) + plan = compile_effective_pre_submission_execution_plan( + lineage=lineage, + effective_policy=policy, + compiled_bundle=compiled.compiled_bundle, + catalogue=catalogue, + ) + request, inspector, manager, preparation, _ = await _request( + tmp_path, + path="project-only.blocked", + catalogue=catalogue, + ) + request = replace( + request, + submission_artifact_policy_id=plan.lineage.effective_policy_id, + checker_policy_id=plan.lineage.pre_submit_policy_id, + effective_plan=plan, + ) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + ) + + result = await service.materialize_prepared_bundle(request) + + assert result.eligible is True + assert all(not entry.entry_id.startswith("policy.") for entry in result.entries) + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_legacy_precheck_runner_is_not_an_execution_dependency( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + import app.modules.checkers.runner as legacy_runner + + def forbidden(*_args, **_kwargs): + raise AssertionError("legacy precheck path was called") + + monkeypatch.setattr(legacy_runner, "pre_submit_static_feedback", forbidden) + monkeypatch.setattr(legacy_runner, "default_checker_registry", forbidden) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + ) + + result = await service.materialize_prepared_bundle(request) + + assert result.eligible is True + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_authorized_cancellation_cleans_before_propagating( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + entered = threading.Event() + release = threading.Event() + from app.modules.checkers.pre_submit_execution import DefaultPreSubmissionProcessor + + original = DefaultPreSubmissionProcessor.process + + def blocking_process(self, reader, workspace): + entered.set() + assert release.wait(timeout=5) + return original(self, reader, workspace) + + monkeypatch.setattr(DefaultPreSubmissionProcessor, "process", blocking_process) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + ) + task = asyncio.create_task(service.materialize_prepared_bundle(request)) + assert await asyncio.to_thread(entered.wait, 5) + task.cancel() + release.set() + + with pytest.raises(asyncio.CancelledError): + await task + + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_cancellation_during_member_projection_cleans_workspace( + tmp_path: Path, +) -> None: + request, _inspector, manager, preparation, catalogue = await _request(tmp_path) + entered = threading.Event() + release = threading.Event() + + class _BlockingProjectionInspector(SubmissionArchiveInspector): + def _project_file(self, *args, **kwargs): + entered.set() + assert release.wait(timeout=5) + return super()._project_file(*args, **kwargs) + + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=_BlockingProjectionInspector(SubmissionArchiveLimits()), + catalogue=catalogue, + ) + task = asyncio.create_task(service.materialize_prepared_bundle(request)) + assert await asyncio.to_thread(entered.wait, 5) + task.cancel() + release.set() + + with pytest.raises(asyncio.CancelledError): + await task + + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_timeout_during_checker_access_cleans_workspace( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + entered = threading.Event() + release = threading.Event() + from app.modules.checkers.pre_submit_execution import DefaultPreSubmissionProcessor + + original = DefaultPreSubmissionProcessor.process + + def blocking_process(self, reader, workspace): + entered.set() + assert release.wait(timeout=5) + return original(self, reader, workspace) + + monkeypatch.setattr(DefaultPreSubmissionProcessor, "process", blocking_process) + preparation._active[request.prepared_artifact._binding].deadline = ( + asyncio.get_running_loop().time() + 0.01 + ) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + ) + task = asyncio.create_task(service.materialize_prepared_bundle(request)) + assert await asyncio.to_thread(entered.wait, 5) + await asyncio.sleep(0.02) + release.set() + + with pytest.raises(ArtifactPreparationDeadlineError): + await task + + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("terminal", ("cancel", "timeout")) +async def test_terminal_event_during_sealing_precedes_checker_access_and_cleans( + tmp_path: Path, + terminal: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + request, _inspector, manager, preparation, catalogue = await _request(tmp_path) + entered = threading.Event() + release = threading.Event() + + class _BlockingSealInspector(SubmissionArchiveInspector): + def _seal_projected_content(self, root_fd, entries): + entered.set() + assert release.wait(timeout=5) + return super()._seal_projected_content(root_fd, entries) + + from app.modules.checkers.pre_submit_execution import DefaultPreSubmissionProcessor + + checker_called = threading.Event() + original_execute = DefaultPreSubmissionProcessor._execute + + def observed_execute(self, tree): + checker_called.set() + return original_execute(self, tree) + + monkeypatch.setattr(DefaultPreSubmissionProcessor, "_execute", observed_execute) + + if terminal == "timeout": + preparation._active[request.prepared_artifact._binding].deadline = ( + asyncio.get_running_loop().time() + 0.01 + ) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=_BlockingSealInspector(SubmissionArchiveLimits()), + catalogue=catalogue, + ) + task = asyncio.create_task(service.materialize_prepared_bundle(request)) + assert await asyncio.to_thread(entered.wait, 5) + if terminal == "cancel": + task.cancel() + else: + await asyncio.sleep(0.02) + release.set() + + expected = asyncio.CancelledError if terminal == "cancel" else ArtifactPreparationDeadlineError + with pytest.raises(expected): + await task + + assert checker_called.is_set() is False + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + await request.prepared_artifact.close() + manager.close() diff --git a/docs/architecture_checker_framework.md b/docs/architecture_checker_framework.md index 3af7fee01..b72c182e3 100644 --- a/docs/architecture_checker_framework.md +++ b/docs/architecture_checker_framework.md @@ -250,6 +250,16 @@ Workstream default submission artifact rules require: advisory or locked project-specific unless an exact generic custody risk is proven +The hidden 04B2 executor consumes the exact 04B1 plan identity and only its +`custody`, `identity`, `materialization`, and `default_policy` phase slice. It +validates the 04A commitment, inspection, semantic manifest, and change-gate +facts, projects one callback-scoped sealed tree through ART scratch custody, and +returns bounded entry results: `passed`, `warning`, `failed`, +`advisory_disabled`, or `dependency_not_run`. It does not consult the legacy +checker registry or standalone precheck, does not run `project_policy` +primitives, and does not persist checker evidence. Fixed materializer authority +remains planned/unavailable until XINT-06A. + Project policy adds required artifacts, evidence requirements, stricter forbidden artifacts, stricter packaging rules, and project-specific attestation requirements. The generated project `PreSubmitCheckerPolicy` is persisted with a compiled diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 201b3bc39..1422eac54 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -798,6 +798,13 @@ After that cutover no independent precheck route or client-owned manifest can reproduce the authoritative result. `POST /api/v1/tasks/{id}/submissions` consumes the verified ready admission and does not receive scratch paths or rerun the pre-submit plan. +Before that cutover, hidden ART-04B2 establishes the execution boundary without +exposing a route. The fixed materializer authorizes before any prepared-byte +read or workspace reservation. One callback-scoped sealed tree is checked +against the server commitment and semantic manifest, only Workstream platform +and default phases execute, and the tree is cleaned before bounded non-durable +results return. Project-policy execution and immutable evidence remain 04B3. + Blocking pre-submit failures prevent submission creation, create no submission row, no submission version, no task transition to `submitted`, and no submission-created audit event. Workstream still writes a task audit event named diff --git a/docs/architecture_lockdown.md b/docs/architecture_lockdown.md index 924871bd1..7523a2f4a 100644 --- a/docs/architecture_lockdown.md +++ b/docs/architecture_lockdown.md @@ -136,6 +136,13 @@ is audit evidence, not a product review decision. The independently invocable legacy preflight route remains frozen until ART-05B removes it together with the legacy Submission path; it is never an alternate authority for this flow. +Hidden ART-04B2 authorizes the fixed materializer before any prepared-byte read, +reserves the complete expanded workspace budget, verifies one canonical sealed +tree against the 04A identities, and runs only platform/default catalogue +entries. The callback-scoped tree and all paths are destroyed before bounded +non-durable results return. Project-policy execution and durable evidence remain +separate 04B3 work; AUTH activation remains XINT-06A. + Tasks lock to the active guide version at creation or screening time before entering `READY`. Material guide changes require a new guide version. For guide and context resolution, TaskAssignment contributes only its `task_id`; diff --git a/docs/glossary.md b/docs/glossary.md index df368dcd9..46ea84084 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -229,6 +229,11 @@ The deterministic merge of Workstream's default submission artifact policy and t The server-generated project checker matrix produced from the effective project submission artifact policy and one immutable default-catalogue snapshot. The compiled bundle embeds the catalogue version, canonical manifest digest, ordered entry ID/version/configuration hashes, and enabled/disabled state. Its compiled bundle hash therefore commits transitively to that exact snapshot, and each task locks that hash before entering the contributor pipeline. Runtime uses the same snapshot to derive the effective-plan hash. It runs against the uploaded ZIP in bounded scratch before Workstream creates a submission. A failed preparation returns `pre_submission_checker_failed` with bounded same-request details. The old standalone preflight route remains frozen legacy behavior until ART-05B removes it with the legacy Submission path; it is not an alternate authority for this policy. Results never use review decision values: `accept`, `needs_revision`, or `reject`. +The hidden Workstream-default execution slice uses the closed entry statuses +`passed`, `warning`, `failed`, `advisory_disabled`, and +`dependency_not_run`. These are checker-execution facts, not review decisions. +Project-policy entries execute later through the same plan. + ## pre_submission_checker_failed The contributor-facing domain error code returned when submission-bundle preparation is blocked by pre-submit checks. It includes bounded structured pass/fail/warning details in the same response and is not a review decision. It must not be stored as `accept`, `needs_revision`, or `reject`. diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 9000fcf24..986cb6847 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -808,7 +808,17 @@ denial, the composition root first rolls back ART state, and AUTH's public bounded restage operation commits the same denial in a clean AUTH-only transaction. The two guide binding/read adapters do not use that exception. Still-planned fixed-service preparation still issues no handle. When a planned -foundation action enters its ART adapter with an exact resource context, its +pre-submit materializer request is diagnosed, expect denial before any ZIP read +or scratch workspace reservation and no ART checker result. The future XINT-06A +activation must bind task, assignment, project, effective-policy ID, +pre-submit-policy ID, process-local prepared generation, effective-plan and +catalogue hashes, archive digest/size, and semantic-manifest hash to +`workstream.artifact.materializer` plus +`artifact.pre_submit.checker_input.materialize`. A human contributor grant does +not substitute for this fixed-service authority, and no prepared handle is +serialized into a worker or durable record. + +When a planned foundation action enters its ART adapter with an exact resource context, its bounded `action_unavailable` denial follows the same rollback-then-clean-restage path. diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 7cc02cbbb..9731791b8 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -89,6 +89,11 @@ remain outside v0.1. - Guide materialization from persisted artifact-processing evidence. - Fixed-service guide-source reads and binding creation with authorization, custody, lineage, rate-control, and stale-generation enforcement. +- Hidden contributor-ZIP default pre-submit execution: fixed-service authority + precedes byte access, one manifest-verified callback-scoped scratch tree runs + only platform/default catalogue phases, and cleanup precedes bounded + non-durable results. Project-policy execution, durable evidence, contributor + preparation, admission, and Submission remain unavailable. ## Integration In Progress diff --git a/docs/spec_artifact_storage_service.md b/docs/spec_artifact_storage_service.md index 1c5428266..cbfb1c166 100644 --- a/docs/spec_artifact_storage_service.md +++ b/docs/spec_artifact_storage_service.md @@ -843,6 +843,7 @@ The preparation settings use the standard `WORKSTREAM_` environment prefix: | `WORKSTREAM_ARTIFACT_SCRATCH_AGGREGATE_RESERVED_BYTES` | `2147483648` | Maximum bytes reserved across the shared root. Each active preparation reserves the full 512 MiB hard maximum. | | `WORKSTREAM_ARTIFACT_SCRATCH_MAXIMUM_FILES` | `8` | Maximum live scratch reservations/files. | | `WORKSTREAM_ARTIFACT_SCRATCH_MAXIMUM_CONCURRENCY` | `4` | Maximum concurrent preparations; cannot exceed the file limit. | +| `WORKSTREAM_ARTIFACT_SCRATCH_MAXIMUM_WORKSPACE_ENTRIES` | `2000` | Maximum entries removable from one private processing workspace; it must be at least the accepted submission-ZIP entry limit. | | `WORKSTREAM_ARTIFACT_SCRATCH_MINIMUM_FREE_BYTES` | `536870912` | Free-space floor retained in addition to the next full reservation. | | `WORKSTREAM_ARTIFACT_SCRATCH_RESERVATION_TTL_SECONDS` | `2400` | Database-independent wall-clock expiry for abandoned reservations. | | `WORKSTREAM_ARTIFACT_PREPARATION_TOTAL_DEADLINE_SECONDS` | `1800` | Total first-pass and provider-consumption deadline. | @@ -874,6 +875,21 @@ approved operational change. | `WORKSTREAM_ARTIFACT_SUBMISSION_ZIP_MAXIMUM_COMPRESSION_RATIO` | `100` | `10000` | Maximum expanded-to-compressed ratio for one file. | | `WORKSTREAM_ARTIFACT_SUBMISSION_ZIP_MAXIMUM_INSPECTION_SECONDS` | `300` | `1800` | Complete synchronous inspector deadline inside the preparation deadline. | +04B2 uses the same ledger to reserve a submission workspace's complete expanded +byte count and entry count before projection. Workspace reservations participate +in aggregate byte/free-space checks but do not become artifact storage. Cleanup +uses the workspace's own bounded entry reservation rather than the live +prepared-file limit, so every archive accepted at the configured entry ceiling +is removable after any terminal outcome. + +After fixed-service authorization, the canonical outer-ZIP inspector projects +the already-inspected manifest into one callback-scoped sealed tree using +descriptor-relative creation. Regular files use fixed `0400` or normalized +executable `0500`; directories use `0500`. Checker adapters receive no scratch +path, shell, subprocess, or execution primitive. The tree is destroyed before +only bounded, path-redacted platform/default results return. 04B2 neither runs +project-policy primitives nor persists final checker evidence. + Pre-submission checker catalogue configuration is separate from ZIP inspection bounds: diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 71e80af3d..d122f06bd 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -539,6 +539,14 @@ closed: | `workstream.review.artifact_reference_reconciliation` | `review.artifact_reference.reconcile` | | `workstream.review.projection` | `review.projection.rebuild` | +The hidden 04B2 prepared resource binds task, assignment, project, effective +submission-artifact-policy ID, pre-submit checker-policy ID, process-local +prepared generation, plan hash, catalogue-manifest hash, archive SHA-256/byte +count, and semantic-manifest hash. The fixed materializer consumes the opaque +prepared handle before any prepared-byte read, ZIP open, workspace reservation, +or checker fact. `AUTH_ART_04B` remains the catalogue owner; XINT-06A later +activates the action after hidden 04B3. ART does not activate it. + `workstream.project.setup` was the eighth fixed identity when AUTH-12B merged; 02C expands the current registry to fourteen identities. AUTH-12E activates only `project.guide_sufficiency.run` for the exact internal setup-service command; diff --git a/docs/template_checker_policy.md b/docs/template_checker_policy.md index 7d4f15403..1aeebded0 100644 --- a/docs/template_checker_policy.md +++ b/docs/template_checker_policy.md @@ -79,6 +79,12 @@ Submission path; it is not part of this effective policy. Pre-submit failures do `CheckerRun` records and do not return review decision values: `accept`, `needs_revision`, or `reject`. +The hidden platform/default phase uses `passed`, `warning`, `failed`, +`advisory_disabled`, and `dependency_not_run`. It authorizes and destroys one +callback-scoped sealed ART tree before returning these non-durable results. +Project-policy primitives execute in the later phase of the same effective +plan. + ## Checker Registry Fields Each checker definition specifies: diff --git a/docs/template_submission_artifact_policy.md b/docs/template_submission_artifact_policy.md index 32e8d9546..9ad08caac 100644 --- a/docs/template_submission_artifact_policy.md +++ b/docs/template_submission_artifact_policy.md @@ -94,6 +94,11 @@ Default required packet fields: - summary - contributor attestation +The hidden Workstream-default executor consumes these catalogue-owned semantics +without reading project-rule configuration. It validates one server-generated +sealed tree and emits only bounded, path-redacted same-request results. Project +rules are a later phase of the same effective plan, not a second checker API. + Workstream generates the archive commitment and semantic artifact manifest after safely inspecting the submitted outer ZIP. Clients do not supply an artifact hash manifest as packet input; later APIs may expose an immutable diff --git a/docs/template_submission_packet.md b/docs/template_submission_packet.md index 0e6009bcb..a517627a8 100644 --- a/docs/template_submission_packet.md +++ b/docs/template_submission_packet.md @@ -61,6 +61,14 @@ and does not return review decision values: `accept`, `needs_revision`, or until ART-05B removes it with the legacy Submission path; it is not an authoritative result for this packet. +The hidden default phase first validates ART's server-generated commitment, +semantic manifest, change result, and one sealed scratch projection. Its +path-redacted same-request entries use `passed`, `warning`, `failed`, +`advisory_disabled`, or `dependency_not_run`; infrastructure, authority, +integrity, timeout, cancellation, and scratch-capacity failures remain distinct +from contributor checker failures. Project-specific rules run later through the +same effective plan, not through a second API or registry. + ## Submission Bundle Manifest Workstream generates this manifest from normalized directory/file paths, entry From faf40e79de3c54d589a4439e3cd8373db721b8c4 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 07:02:04 +0100 Subject: [PATCH 2/7] fix(ci): resolve hosted artifact gates --- ...S-ART-001-04B2-external-review-response.md | 36 +++++++++++++++++++ .../WS-ART-001-04B2-pr-trust-bundle.md | 5 ++- .github/workflows/backend.yml | 5 +-- .../modules/checkers/pre_submit_execution.py | 11 ++++++ docs/operations_authorization_service.md | 6 ++-- 5 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 .agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md new file mode 100644 index 000000000..c1f862eb3 --- /dev/null +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md @@ -0,0 +1,36 @@ +# WS-ART-001-04B2 External Review Response + +## Comments addressed + +- GitHub Agent Gates reported stale human-worker authorization wording. The + planned materializer note now uses the canonical uploader/project-role grant + and concrete Celery-message wording; the exact stale-doc gate passes. +- Hosted docstring coverage reported 79.7 percent after the new executor was + added. All ten missing 04B2 executor docstrings were added; the exact hosted + command now reports 80.1 percent. +- Schema lanes emitted secondary missing-evidence errors because the always-run + timing step assumed the lane directory existed after an earlier failure. The + timing step now creates its exact private lane directory before writing. + +## Comments deferred + +- CodeRabbit reported only review rate limiting. It produced no actionable + code comment to defer. + +## Human decisions needed + +None. The repository owner explicitly approved repairing both the ART issue and +the shared CI failure-path defect. + +## Commands rerun + +- `ruff check backend/app/modules/checkers/pre_submit_execution.py` +- `docstr-coverage --config backend/.docstr.yaml` — 80.1 percent +- `python scripts/check_stale_authorization_docs.py` +- focused CI-lane and default-execution tests +- `git diff --check` + +## Remaining risks + +Replacement hosted checks must validate the pushed commit. CodeRabbit remains +rate-limited unless its external quota becomes available. diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-pr-trust-bundle.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-pr-trust-bundle.md index 64b15edb4..df4da0de1 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-pr-trust-bundle.md @@ -96,7 +96,10 @@ that the platform/default slice is derived from the locked full plan. ## External review -Pending GitHub Backend Gates and CodeRabbit on the draft PR. +The first GitHub run found stale AUTH wording, a docstring-floor regression, +and a failure-path lane-directory assumption. All three are fixed and recorded +in the external review response; replacement hosted checks are pending. +CodeRabbit is externally rate-limited and reported no actionable comment. ## Remaining risks diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index a171a0e1e..3bb4b2864 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -145,9 +145,10 @@ jobs: if: ${{ always() }} working-directory: backend shell: bash - run: >- + run: | + install -d -m 700 '.ci/lane-bundle/${{ matrix.lane }}' printf '%s\n' '${{ steps.identity.outputs.job_start_epoch }}' - > '.ci/lane-bundle/${{ matrix.lane }}/job-start-epoch.txt' + > '.ci/lane-bundle/${{ matrix.lane }}/job-start-epoch.txt' - name: Upload authenticated lane ${{ matrix.lane }} if: ${{ always() }} diff --git a/backend/app/modules/checkers/pre_submit_execution.py b/backend/app/modules/checkers/pre_submit_execution.py index bb1ea2d6a..acd28c352 100644 --- a/backend/app/modules/checkers/pre_submit_execution.py +++ b/backend/app/modules/checkers/pre_submit_execution.py @@ -60,6 +60,8 @@ class PreSubmissionInfrastructureUnavailable(DefaultPreSubmissionExecutionError) class DefaultPreSubmissionResultStatus(StrEnum): + """Closed non-review status vocabulary for one default checker result.""" + PASSED = "passed" WARNING = "warning" ADVISORY_DISABLED = "advisory_disabled" @@ -75,6 +77,7 @@ class SubmissionPacketView: contributor_attestation: str def __post_init__(self) -> None: + """Reject unbounded or malformed contributor packet text.""" for value in (self.summary, self.contributor_attestation): if type(value) is not str or len(value.encode("utf-8")) > 64 * 1024: raise ValueError("submission packet text is invalid") @@ -125,6 +128,7 @@ def __init__( catalogue: PreSubmissionCheckerCatalogue, execution_input: DefaultPreSubmissionExecutionInput, ) -> None: + """Bind one exact inspector, catalogue, and immutable execution input.""" self._archive_inspector = archive_inspector self._catalogue = catalogue self._input = execution_input @@ -148,11 +152,13 @@ def _execute_unless_aborted( self, tree: SealedSubmissionTree, ) -> DefaultPreSubmissionExecutionResult: + """Deny checker access after caller cancellation or deadline expiry.""" if self._aborted.is_set(): raise DefaultPreSubmissionExecutionError("pre_submission_execution_aborted") return self._execute(tree) def _validate_input(self) -> None: + """Fail closed unless every plan, archive, and manifest fact agrees.""" plan = self._input.plan if canonical_json_hash(plan.as_dict()) != plan.plan_sha256: raise PreSubmissionInfrastructureUnavailable("pre_submission_plan_identity_invalid") @@ -168,6 +174,7 @@ def _validate_input(self) -> None: raise PreSubmissionInfrastructureUnavailable("submission_change_identity_invalid") def _execute(self, tree: SealedSubmissionTree) -> DefaultPreSubmissionExecutionResult: + """Run the ordered platform/default phase slice exactly once.""" results: list[DefaultPreSubmissionEntryResult] = [] statuses: dict[str, DefaultPreSubmissionResultStatus] = {} blocked = False @@ -241,6 +248,7 @@ def _dispatch( entry: EffectivePreSubmissionPlanEntry, tree: SealedSubmissionTree, ) -> DefaultPreSubmissionEntryResult: + """Dispatch one catalogue-validated platform capability.""" try: capability = PreSubmissionPlatformCapability(entry.dispatch_capability) except ValueError as exc: @@ -313,6 +321,7 @@ def _blocking_or_pass( finding_count: int, message_code: str, ) -> DefaultPreSubmissionEntryResult: + """Build one blocking failure or successful bounded result.""" return self._result( entry, ( @@ -334,6 +343,7 @@ def _result( message_code: str = "passed", metadata: tuple[tuple[str, int | bool | str], ...] = (), ) -> DefaultPreSubmissionEntryResult: + """Build one result bound to the exact plan and definition version.""" return DefaultPreSubmissionEntryResult( schema_version=PRE_SUBMISSION_RESULT_SCHEMA_VERSION, plan_sha256=self._input.plan.plan_sha256, @@ -347,6 +357,7 @@ def _result( def _is_high_confidence_sensitive(normalized_path: str) -> bool: + """Match only the narrow Workstream-default sensitive-path set.""" parts = tuple(part.casefold() for part in PurePosixPath(normalized_path).parts) if not parts: return False diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 986cb6847..1bf7066d6 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -814,9 +814,9 @@ activation must bind task, assignment, project, effective-policy ID, pre-submit-policy ID, process-local prepared generation, effective-plan and catalogue hashes, archive digest/size, and semantic-manifest hash to `workstream.artifact.materializer` plus -`artifact.pre_submit.checker_input.materialize`. A human contributor grant does -not substitute for this fixed-service authority, and no prepared handle is -serialized into a worker or durable record. +`artifact.pre_submit.checker_input.materialize`. The uploader's project role +grant does not substitute for this fixed-service authority, and no prepared +handle is serialized into a Celery message or durable record. When a planned foundation action enters its ART adapter with an exact resource context, its bounded `action_unavailable` denial follows the same rollback-then-clean-restage From 55c8df1bce81a64239bb90715732f0f4ad121ec0 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 08:33:21 +0100 Subject: [PATCH 3/7] fix(checkers): align shared execution tests --- .../reviews/WS-ART-001-04B2-external-review-response.md | 6 ++++++ backend/tests/test_checkers.py | 7 ++++--- backend/tests/test_ci_test_lanes.py | 1 + 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md index c1f862eb3..6fdf36e7e 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md @@ -11,6 +11,11 @@ - Schema lanes emitted secondary missing-evidence errors because the always-run timing step assumed the lane directory existed after an earlier failure. The timing step now creates its exact private lane directory before writing. +- The first complete sharded run exposed two test-contract drifts: the semantic + lane ownership assertion omitted the new default-execution module, and an + existing checker test still referenced a private helper moved into the shared + pure-semantics module. Both tests now assert the intended 04B2 ownership and + import boundary directly. ## Comments deferred @@ -28,6 +33,7 @@ the shared CI failure-path defect. - `docstr-coverage --config backend/.docstr.yaml` — 80.1 percent - `python scripts/check_stale_authorization_docs.py` - focused CI-lane and default-execution tests +- focused checker, lane-contract, and default-execution tests - `git diff --check` ## Remaining risks diff --git a/backend/tests/test_checkers.py b/backend/tests/test_checkers.py index 7c004b242..77f875bbb 100644 --- a/backend/tests/test_checkers.py +++ b/backend/tests/test_checkers.py @@ -39,6 +39,7 @@ ) from app.modules.checkers.models import CheckerResult, CheckerRun from app.modules.checkers import runner as checker_runner_module +from app.modules.checkers.pre_submit_defaults import attestation_term_is_satisfied from app.modules.checkers.runner import ( CheckerContext, CheckerNameConflict, @@ -1304,12 +1305,12 @@ def test_attestation_and_policy_projection_helpers_preserve_required_only_rules( assert checker_runner_module._required_evidence_keys(None) == [] assert checker_runner_module._forbidden_artifact_patterns(None) == [] assert checker_runner_module._required_attestation_terms(None) == [] - assert checker_runner_module._attestation_term_is_satisfied("anything", "") - assert checker_runner_module._attestation_term_is_satisfied( + assert attestation_term_is_satisfied("anything", "") + assert attestation_term_is_satisfied( "i_confirm_original_work", "original work", ) - assert not checker_runner_module._attestation_term_is_satisfied( + assert not attestation_term_is_satisfied( "i_confirm_original_work", "client confidentiality", ) diff --git a/backend/tests/test_ci_test_lanes.py b/backend/tests/test_ci_test_lanes.py index 483123526..7fe8781ea 100644 --- a/backend/tests/test_ci_test_lanes.py +++ b/backend/tests/test_ci_test_lanes.py @@ -44,6 +44,7 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: assert modules_by_lane["task_lifecycle"] == { "tests/test_checker_catalogue.py", "tests/test_checkers.py", + "tests/test_default_pre_submit_execution.py", "tests/test_review_lease_persistence.py", "tests/test_review_queue_persistence.py", "tests/test_tasks.py", From 8af091d8eb780fd212cbcb0129fa7c39a3ad63a3 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 08:48:28 +0100 Subject: [PATCH 4/7] fix(artifacts): address 04B2 review findings --- .../DISCOVERY.md | 6 ++- ...-ART-001-04B2-default-checker-execution.md | 2 +- ...S-ART-001-04B2-external-review-response.md | 10 +++- .../WS-ART-001-04B2-pr-trust-bundle.md | 3 +- .github/workflows/backend.yml | 2 +- backend/app/modules/artifacts/preparation.py | 21 +++++--- .../artifacts/submission_materialization.py | 2 + .../modules/checkers/pre_submit_defaults.py | 41 ++++++++------ .../modules/checkers/pre_submit_execution.py | 43 +++++++-------- backend/app/modules/checkers/runner.py | 28 ++++++---- backend/tests/test_checker_materialization.py | 18 ++++--- .../test_default_pre_submit_execution.py | 54 +++++++++++++------ docs/roadmap_status.md | 10 ++-- docs/spec_artifact_storage_service.md | 4 +- 14 files changed, 149 insertions(+), 95 deletions(-) diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/DISCOVERY.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/DISCOVERY.md index 9f72d4f65..52c49c02d 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/DISCOVERY.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/DISCOVERY.md @@ -353,8 +353,10 @@ Implementation constraints derived from current code: hidden fixed-service composition seam before any prepared-byte read, ZIP open, workspace reservation, or checker fact. Until XINT-06A activates the action owned in the catalogue by `AUTH_ART_04B`, production composition remains fail - closed; tests use existing AUTH fixtures/capabilities, never a production - ART-local fake protocol. + closed. Because AUTH intentionally has not activated this action, tests use a + typed, bounded protocol double and opaque handle sentinel; the denial path + uses production `DenyPreSubmitMaterializationAuthorization`. Live AUTH proof + remains owned by XINT-06A. Plan-review correction: the canonical projection must be a method of `SubmissionArchiveInspector` or use a private traversal shared solely inside diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B2-default-checker-execution.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B2-default-checker-execution.md index e767819e4..97a4b6ea7 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B2-default-checker-execution.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B2-default-checker-execution.md @@ -141,7 +141,7 @@ This chunk must additionally prove or preserve exactly: ```bash coverage report --include='app/modules/artifacts/*' --precision=2 --fail-under=90 coverage report --include='app/modules/checkers/*' --precision=2 --fail-under=90 -coverage report --include='app/core/cancellation.py,app/core/file_locks.py' --precision=2 --fail-under=90 +coverage report --include='app/core/cancellation.py,app/core/config.py,app/core/file_locks.py' --precision=2 --fail-under=90 coverage report --include='app/interfaces/artifact_operations.py,app/interfaces/artifacts.py' --precision=2 --fail-under=90 ``` diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md index 6fdf36e7e..8d46e5176 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md @@ -16,11 +16,17 @@ existing checker test still referenced a private helper moved into the shared pure-semantics module. Both tests now assert the intended 04B2 ownership and import boundary directly. +- CodeRabbit's completed review found fail-closed ordering, dependency taxonomy, + attestation semantics, CI timing redirection, compatibility-shape, typing, + test-strength, and evidence-ledger issues. The valid findings are corrected + on the current head; operational memory-alert sizing remains an explicit + pre-activation deployment concern rather than application code in this chunk. ## Comments deferred -- CodeRabbit reported only review rate limiting. It produced no actionable - code comment to defer. +- The suggested API/worker container memory alert is deferred to deployment + activation because 04B2 keeps the route hidden and adds no deployment sizing + surface. The existing configured aggregate scratch quota remains the bound. ## Human decisions needed diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-pr-trust-bundle.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-pr-trust-bundle.md index df4da0de1..45470dc7f 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-pr-trust-bundle.md @@ -99,7 +99,8 @@ that the platform/default slice is derived from the locked full plan. The first GitHub run found stale AUTH wording, a docstring-floor regression, and a failure-path lane-directory assumption. All three are fixed and recorded in the external review response; replacement hosted checks are pending. -CodeRabbit is externally rate-limited and reported no actionable comment. +CodeRabbit's earlier review was externally rate-limited and produced no findings; +its later completed review produced findings tracked in the external-review response. ## Remaining risks diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 3bb4b2864..fc91249f8 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -147,7 +147,7 @@ jobs: shell: bash run: | install -d -m 700 '.ci/lane-bundle/${{ matrix.lane }}' - printf '%s\n' '${{ steps.identity.outputs.job_start_epoch }}' + printf '%s\n' '${{ steps.identity.outputs.job_start_epoch }}' \ > '.ci/lane-bundle/${{ matrix.lane }}/job-start-epoch.txt' - name: Upload authenticated lane ${{ matrix.lane }} diff --git a/backend/app/modules/artifacts/preparation.py b/backend/app/modules/artifacts/preparation.py index 559525bff..8c6edab5b 100644 --- a/backend/app/modules/artifacts/preparation.py +++ b/backend/app/modules/artifacts/preparation.py @@ -17,7 +17,7 @@ import stat as stat_module import threading import time -from typing import Any, BinaryIO, TypeVar +from typing import Any, BinaryIO, Protocol, TypeVar from uuid import uuid4 from app.interfaces.artifacts import ( @@ -43,6 +43,7 @@ HARD_MAXIMUM_ARTIFACT_BYTES = 512 * 1024 * 1024 _InspectionResult = TypeVar("_InspectionResult") +_InspectionResultCo = TypeVar("_InspectionResultCo", covariant=True) _LEDGER_VERSION = 3 _LEDGER_MAXIMUM_BYTES = 1024 * 1024 _RESERVATION_ID = re.compile(r"^[0-9a-f]{32}$") @@ -54,6 +55,16 @@ _ROOT_MARKER_PREFIX = b"workstream-artifact-scratch-v1:" +class PreparedSubmissionProcessor(Protocol[_InspectionResultCo]): + """Project one prepared submission inside a bounded scratch workspace.""" + + def process(self, reader: BinaryIO, workspace: Path) -> _InspectionResultCo: + """Return bounded results without retaining either capability.""" + + def abort(self) -> None: + """Deny checker access after cancellation or deadline expiry.""" + + class ArtifactScratchCapacityError(ArtifactStoreUnavailableError): """Raised when bounded scratch capacity cannot admit another source.""" @@ -1210,12 +1221,6 @@ def _read_ledger_optional(self) -> dict[str, Any] | None: ledger = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise ArtifactScratchIntegrityError("artifact scratch ledger is invalid") from exc - if ( - isinstance(ledger, dict) - and ledger.get("version") == _LEDGER_VERSION - and set(ledger) == {"version", "reservations"} - ): - ledger = {**ledger, "workspaces": []} if isinstance(ledger, dict) and ledger.get("version") == 2: if set(ledger) == {"version", "reservations"}: ledger = {**ledger, "workspaces": []} @@ -1696,7 +1701,7 @@ def inspect_and_cleanup() -> _InspectionResult: async def _process_prepared_submission( self, prepared: PreparedArtifact, - processor: Any, + processor: PreparedSubmissionProcessor[_InspectionResult], *, reserved_bytes: int, maximum_entries: int, diff --git a/backend/app/modules/artifacts/submission_materialization.py b/backend/app/modules/artifacts/submission_materialization.py index 2a906e555..f82a37fab 100644 --- a/backend/app/modules/artifacts/submission_materialization.py +++ b/backend/app/modules/artifacts/submission_materialization.py @@ -110,6 +110,8 @@ async def materialize_prepared_bundle( packet=request.packet, ), ) + # Intentional friend call: this is the sole authority-gated caller, and + # the preparation byte-access surface must remain private. return await self._preparation._process_prepared_submission( request.prepared_artifact, processor, diff --git a/backend/app/modules/checkers/pre_submit_defaults.py b/backend/app/modules/checkers/pre_submit_defaults.py index 5d9b231bc..231d27dfe 100644 --- a/backend/app/modules/checkers/pre_submit_defaults.py +++ b/backend/app/modules/checkers/pre_submit_defaults.py @@ -4,12 +4,11 @@ import re from collections.abc import Iterable, Sequence +from dataclasses import dataclass GENERIC_ATTESTATIONS = frozenset({"ok", "done", "yes", "i agree", "confirmed"}) -IGNORED_ATTESTATION_TERM_WORDS = frozenset( - {"a", "an", "and", "for", "of", "the", "exclusion"} -) +IGNORED_ATTESTATION_TERM_WORDS = frozenset({"a", "an", "and", "for", "of", "the", "exclusion"}) CONFIDENTIALITY_TERMS = ("confidential", "private", "client data", "proprietary") CREDENTIAL_TERMS = ("credential", "secret", "token", "password", "api key") SOURCE_PLATFORM_TERMS = ( @@ -49,31 +48,41 @@ def attestation_term_is_satisfied(normalized_attestation: str, term: str) -> boo for word in normalized_term.split("_") if word and word not in IGNORED_ATTESTATION_TERM_WORDS ] - return all(word in attestation_words for word in required_words) + return bool(required_words) and all(word in attestation_words for word in required_words) + + +@dataclass(frozen=True, slots=True) +class AttestationValidationFacts: + """Canonical attestation facts shared by both pre-submission paths.""" + + has_required_length: bool + has_non_generic_text: bool + has_confidentiality_term: bool + has_credential_term: bool + has_source_or_platform_term: bool + missing_attestation_terms: tuple[str, ...] def attestation_validation_facts( worker_attestation: str, *, required_terms: Sequence[str] = (), -) -> dict[str, bool | list[str]]: +) -> AttestationValidationFacts: """Return the canonical facts used by both pre-submission execution paths.""" normalized = " ".join(worker_attestation.strip().lower().split()) normalized_for_terms = normalize_policy_token(worker_attestation) - return { - "has_required_length": len(normalized) >= 40, - "has_non_generic_text": normalized not in GENERIC_ATTESTATIONS, - "has_confidentiality_term": any(term in normalized for term in CONFIDENTIALITY_TERMS), - "has_credential_term": any(term in normalized for term in CREDENTIAL_TERMS), - "has_source_or_platform_term": any( - term in normalized for term in SOURCE_PLATFORM_TERMS - ), - "missing_attestation_terms": [ + return AttestationValidationFacts( + has_required_length=len(normalized) >= 40, + has_non_generic_text=normalized not in GENERIC_ATTESTATIONS, + has_confidentiality_term=any(term in normalized for term in CONFIDENTIALITY_TERMS), + has_credential_term=any(term in normalized for term in CREDENTIAL_TERMS), + has_source_or_platform_term=any(term in normalized for term in SOURCE_PLATFORM_TERMS), + missing_attestation_terms=tuple( term for term in required_terms if not attestation_term_is_satisfied(normalized_for_terms, term) - ], - } + ), + ) def matched_low_quality_patterns(parts: Iterable[str]) -> tuple[str, ...]: diff --git a/backend/app/modules/checkers/pre_submit_execution.py b/backend/app/modules/checkers/pre_submit_execution.py index acd28c352..31b6795a0 100644 --- a/backend/app/modules/checkers/pre_submit_execution.py +++ b/backend/app/modules/checkers/pre_submit_execution.py @@ -178,23 +178,35 @@ def _execute(self, tree: SealedSubmissionTree) -> DefaultPreSubmissionExecutionR results: list[DefaultPreSubmissionEntryResult] = [] statuses: dict[str, DefaultPreSubmissionResultStatus] = {} blocked = False + executed_ids = { + entry.definition_id + for entry in self._input.plan.entries + if entry.phase in _EXECUTED_PHASES + } for entry in self._input.plan.entries: if entry.phase not in _EXECUTED_PHASES: continue if entry.dispatch_kind != "platform_capability": + raise PreSubmissionInfrastructureUnavailable("pre_submission_dispatch_kind_invalid") + definition = self._catalogue.definition(entry.definition_id) + if entry.definition_id in statuses: + raise PreSubmissionInfrastructureUnavailable("pre_submission_duplicate_result") + if ( + entry.state == PreSubmissionCheckerState.DISABLED.value + and definition.classification.mandatory + ): raise PreSubmissionInfrastructureUnavailable( - "pre_submission_dispatch_kind_invalid" + "pre_submission_infrastructure_unavailable" ) - definition = self._catalogue.definition(entry.definition_id) if ( entry.definition_version != definition.version or entry.dispatch_capability != definition.dispatch_capability or entry.classification != definition.classification.value or entry.state != definition.state.value ): - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_plan_entry_stale" - ) + raise PreSubmissionInfrastructureUnavailable("pre_submission_plan_entry_stale") + if any(dependency not in executed_ids for dependency in entry.dependencies): + raise PreSubmissionInfrastructureUnavailable("pre_submission_dependency_invalid") unmet = any( statuses.get(dependency) not in { @@ -211,10 +223,6 @@ def _execute(self, tree: SealedSubmissionTree) -> DefaultPreSubmissionExecutionR message_code="dependency_not_run", ) elif entry.state == PreSubmissionCheckerState.DISABLED.value: - if definition.classification.mandatory: - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_infrastructure_unavailable" - ) result = self._result( entry, DefaultPreSubmissionResultStatus.ADVISORY_DISABLED, @@ -224,18 +232,9 @@ def _execute(self, tree: SealedSubmissionTree) -> DefaultPreSubmissionExecutionR result = self._dispatch(entry, tree) if result.status is DefaultPreSubmissionResultStatus.FAILED: blocked = True - if entry.definition_id in statuses: - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_duplicate_result" - ) statuses[entry.definition_id] = result.status results.append(result) - expected_ids = { - entry.definition_id - for entry in self._input.plan.entries - if entry.phase in _EXECUTED_PHASES - } - if set(statuses) != expected_ids: + if set(statuses) != executed_ids: raise PreSubmissionInfrastructureUnavailable("pre_submission_result_incomplete") return DefaultPreSubmissionExecutionResult( plan_sha256=self._input.plan.plan_sha256, @@ -276,11 +275,9 @@ def _dispatch( ) return self._blocking_or_pass(entry, missing, "submission_packet_invalid") if capability is PreSubmissionPlatformCapability.ATTESTATION: - facts = attestation_validation_facts( - self._input.packet.contributor_attestation - ) + facts = attestation_validation_facts(self._input.packet.contributor_attestation) missing = sum( - not bool(facts[key]) + not getattr(facts, key) for key in ( "has_required_length", "has_non_generic_text", diff --git a/backend/app/modules/checkers/runner.py b/backend/app/modules/checkers/runner.py index 6ae46fd8b..7883a3e5e 100644 --- a/backend/app/modules/checkers/runner.py +++ b/backend/app/modules/checkers/runner.py @@ -15,6 +15,7 @@ from app.modules.tasks.models import Submission, WorkstreamTask from app.modules.tasks.schemas import SubmissionCreate from app.modules.checkers.pre_submit_defaults import ( + LOW_QUALITY_GENERATED_PATTERNS, attestation_validation_facts, matched_low_quality_patterns, normalize_policy_token as _normalize_policy_token, @@ -727,14 +728,14 @@ def _confidentiality_attestation_outcome( worker_attestation, required_terms=required_terms or (), ) - missing_terms = facts["missing_attestation_terms"] + missing_terms = facts.missing_attestation_terms if not all( [ - facts["has_required_length"], - facts["has_non_generic_text"], - facts["has_confidentiality_term"], - facts["has_credential_term"], - facts["has_source_or_platform_term"], + facts.has_required_length, + facts.has_non_generic_text, + facts.has_confidentiality_term, + facts.has_credential_term, + facts.has_source_or_platform_term, not missing_terms, ] ): @@ -746,10 +747,10 @@ def _confidentiality_attestation_outcome( "and copied source or platform artifacts." ), metadata={ - "has_required_length": facts["has_required_length"], - "has_confidentiality_term": facts["has_confidentiality_term"], - "has_credential_term": facts["has_credential_term"], - "has_source_or_platform_term": facts["has_source_or_platform_term"], + "has_required_length": facts.has_required_length, + "has_confidentiality_term": facts.has_confidentiality_term, + "has_credential_term": facts.has_credential_term, + "has_source_or_platform_term": facts.has_source_or_platform_term, "missing_attestation_terms": missing_terms, }, ) @@ -776,7 +777,12 @@ def _low_quality_generated_artifacts_outcome( return _warning( "check_low_quality_generated_artifacts", "Submission contains placeholder or generic generated-output signals.", - metadata={"matched_pattern_count": len(matched), "matched_categories": matched}, + metadata={ + "matched_pattern_count": len(matched), + "matched_categories": [ + pattern for pattern in LOW_QUALITY_GENERATED_PATTERNS if pattern in matched + ], + }, ) diff --git a/backend/tests/test_checker_materialization.py b/backend/tests/test_checker_materialization.py index be26e39c7..c04ffc208 100644 --- a/backend/tests/test_checker_materialization.py +++ b/backend/tests/test_checker_materialization.py @@ -136,9 +136,7 @@ async def test_workspace_expansion_is_charged_before_projection(tmp_path: Path) "drift", ("path", "type", "sha256", "byte_count", "executable", "entry_count", "aggregate"), ) -def test_projection_rejects_manifest_drift_before_callback( - tmp_path: Path, drift: str -) -> None: +def test_projection_rejects_manifest_drift_before_callback(tmp_path: Path, drift: str) -> None: data = _zip() inspector = SubmissionArchiveInspector(SubmissionArchiveLimits()) inspection = inspector.inspect(BytesIO(data)) @@ -171,9 +169,7 @@ def callback(_tree): inspection, entries=tuple(entries), entry_count=inspection.entry_count + (1 if drift == "entry_count" else 0), - total_expanded_bytes=( - inspection.total_expanded_bytes + (1 if drift == "aggregate" else 0) - ), + total_expanded_bytes=(inspection.total_expanded_bytes + (1 if drift == "aggregate" else 0)), ) with pytest.raises(SubmissionArchiveRejectedError) as caught: inspector.project_and_run( @@ -195,14 +191,20 @@ def test_workspace_cleanup_bound_is_separate_from_prepared_file_limit(tmp_path: with manager.extraction_workspace(reserved_bytes=8, maximum_entries=8) as workspace: workspace_fd = os.open(workspace, os.O_RDONLY | os.O_DIRECTORY) try: - for index in range(8): + current_fd = os.dup(workspace_fd) + for index in range(4): + os.mkdir(f"level-{index}", mode=0o700, dir_fd=current_fd) + next_fd = os.open(f"level-{index}", os.O_RDONLY | os.O_DIRECTORY, dir_fd=current_fd) descriptor = os.open( f"entry-{index}", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600, - dir_fd=workspace_fd, + dir_fd=next_fd, ) os.close(descriptor) + os.close(current_fd) + current_fd = next_fd + os.close(current_fd) finally: os.close(workspace_fd) assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] diff --git a/backend/tests/test_default_pre_submit_execution.py b/backend/tests/test_default_pre_submit_execution.py index 5d18f4773..1a4e10318 100644 --- a/backend/tests/test_default_pre_submit_execution.py +++ b/backend/tests/test_default_pre_submit_execution.py @@ -7,6 +7,7 @@ from dataclasses import replace from pathlib import Path import threading +from typing import cast import zipfile from uuid import uuid4 @@ -140,13 +141,21 @@ def _limits() -> ArtifactPreparationLimits: class _AllowAuthority: def __init__(self) -> None: self.facts = None + self.action_id = None + self.service_identity = None async def consume(self, **values): self.facts = values["facts"] + self.action_id = values["action_id"] + self.service_identity = values["service_identity"] + + +class _TestPreparedAuthorizationHandle: + """Typed sentinel accepted only by the bounded authorization protocol double.""" def _handle() -> PreparedAuthorizationHandle: - return object.__new__(PreparedAuthorizationHandle) + return cast(PreparedAuthorizationHandle, _TestPreparedAuthorizationHandle()) async def _request(tmp_path: Path, *, path: str = "task.toml", catalogue=None): @@ -236,7 +245,22 @@ async def test_default_executor_uses_plan_order_and_never_dispatches_project_rul assert all(entry.status is DefaultPreSubmissionResultStatus.PASSED for entry in result.entries) assert result.eligible is True assert authority.facts is not None + assert authority.action_id.value == "artifact.pre_submit.checker_input.materialize" + assert authority.service_identity.value == "workstream.artifact.materializer" + assert authority.facts.task_id == request.task_id + assert authority.facts.assignment_id == request.assignment_id + assert authority.facts.project_id == request.effective_plan.lineage.project_id + assert authority.facts.submission_artifact_policy_id == request.submission_artifact_policy_id + assert authority.facts.checker_policy_id == request.checker_policy_id assert authority.facts.prepared_generation_id == request.prepared_artifact.generation_id + assert authority.facts.plan_sha256 == request.effective_plan.plan_sha256 + assert ( + authority.facts.catalogue_manifest_sha256 + == request.effective_plan.catalogue_manifest_sha256 + ) + assert authority.facts.archive_sha256 == request.prepared_artifact.commitment.sha256 + assert authority.facts.archive_byte_count == request.prepared_artifact.commitment.byte_count + assert authority.facts.semantic_manifest_sha256 == request.manifest.sha256 assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] await request.prepared_artifact.close() manager.close() @@ -246,9 +270,7 @@ async def test_default_executor_uses_plan_order_and_never_dispatches_project_rul async def test_blocking_default_stops_later_dependency_without_review_decision( tmp_path: Path, ) -> None: - request, inspector, manager, preparation, catalogue = await _request( - tmp_path, path=".env" - ) + request, inspector, manager, preparation, catalogue = await _request(tmp_path, path=".env") service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, @@ -281,9 +303,7 @@ async def test_disabled_advisory_is_explicit_and_not_skipped_success(tmp_path: P catalogue = build_pre_submission_checker_catalogue( disabled_entry_ids=frozenset({"artifact.quality.placeholder_signal"}) ) - request, inspector, manager, preparation, _ = await _request( - tmp_path, catalogue=catalogue - ) + request, inspector, manager, preparation, _ = await _request(tmp_path, catalogue=catalogue) service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, @@ -293,9 +313,7 @@ async def test_disabled_advisory_is_explicit_and_not_skipped_success(tmp_path: P result = await service.materialize_prepared_bundle(request) advisory = next( - entry - for entry in result.entries - if entry.entry_id == "artifact.quality.placeholder_signal" + entry for entry in result.entries if entry.entry_id == "artifact.quality.placeholder_signal" ) assert advisory.status is DefaultPreSubmissionResultStatus.ADVISORY_DISABLED @@ -320,9 +338,7 @@ async def test_quality_warning_emits_only_a_bounded_category_count(tmp_path: Pat result = await service.materialize_prepared_bundle(request) warning = next( - entry - for entry in result.entries - if entry.entry_id == "artifact.quality.placeholder_signal" + entry for entry in result.entries if entry.entry_id == "artifact.quality.placeholder_signal" ) assert warning.status is DefaultPreSubmissionResultStatus.WARNING @@ -353,10 +369,18 @@ async def test_forged_plan_identity_fails_closed_and_cleans_workspace(tmp_path: @pytest.mark.asyncio -@pytest.mark.parametrize("invalid_state", ("stale_entry", "duplicate", "unknown")) +@pytest.mark.parametrize( + ("invalid_state", "expected_message"), + ( + ("stale_entry", "pre_submission_plan_entry_stale"), + ("duplicate", "pre_submission_duplicate_result"), + ("unknown", "pre_submission_dispatch_capability_unknown"), + ), +) async def test_invalid_executor_state_fails_closed_and_cleans_workspace( tmp_path: Path, invalid_state: str, + expected_message: str, ) -> None: request, inspector, manager, preparation, catalogue = await _request(tmp_path) entries = list(request.effective_plan.entries) @@ -403,7 +427,7 @@ def __getattr__(self, name): catalogue=selected_catalogue, ) - with pytest.raises(PreSubmissionInfrastructureUnavailable): + with pytest.raises(PreSubmissionInfrastructureUnavailable, match=expected_message): await service.materialize_prepared_bundle(request) assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 9731791b8..5d5a8ed8a 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -89,17 +89,17 @@ remain outside v0.1. - Guide materialization from persisted artifact-processing evidence. - Fixed-service guide-source reads and binding creation with authorization, custody, lineage, rate-control, and stale-generation enforcement. +## Integration In Progress + +The following areas have merged planning, contracts, or partial foundations, +but are not all complete as one production path: + - Hidden contributor-ZIP default pre-submit execution: fixed-service authority precedes byte access, one manifest-verified callback-scoped scratch tree runs only platform/default catalogue phases, and cleanup precedes bounded non-durable results. Project-policy execution, durable evidence, contributor preparation, admission, and Submission remain unavailable. -## Integration In Progress - -The following areas have merged planning, contracts, or partial foundations, -but are not all complete as one production path: - - integration of the merged review/revision policy and authorization readiness contracts into hidden REV lifecycle behavior; - hidden review queue, reviewer assignment/claim, immutable decisions, and revision diff --git a/docs/spec_artifact_storage_service.md b/docs/spec_artifact_storage_service.md index cbfb1c166..78af9caa5 100644 --- a/docs/spec_artifact_storage_service.md +++ b/docs/spec_artifact_storage_service.md @@ -886,8 +886,8 @@ After fixed-service authorization, the canonical outer-ZIP inspector projects the already-inspected manifest into one callback-scoped sealed tree using descriptor-relative creation. Regular files use fixed `0400` or normalized executable `0500`; directories use `0500`. Checker adapters receive no scratch -path, shell, subprocess, or execution primitive. The tree is destroyed before -only bounded, path-redacted platform/default results return. 04B2 neither runs +path, shell, subprocess, or execution primitive. The tree is destroyed first; +only then do bounded, path-redacted platform/default results return. 04B2 neither runs project-policy primitives nor persists final checker evidence. Pre-submission checker catalogue configuration is separate from ZIP inspection From 2f6b3273ff704f6f17fa30fab70513cc9e8281d7 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 09:27:18 +0100 Subject: [PATCH 5/7] test(artifacts): align scratch ledger compatibility --- backend/tests/test_artifact_preparation.py | 38 ++++++++++++++-------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/backend/tests/test_artifact_preparation.py b/backend/tests/test_artifact_preparation.py index 35c7e9d7d..a4b6a23f1 100644 --- a/backend/tests/test_artifact_preparation.py +++ b/backend/tests/test_artifact_preparation.py @@ -962,16 +962,10 @@ def test_limits_and_roots_fail_closed(tmp_path: Path) -> None: invalid_temp.write_bytes(b"keep") for path in invalid_marker_root.iterdir(): path.chmod(0o600) - before = { - path.name: path.read_bytes() - for path in invalid_marker_root.iterdir() - } + before = {path.name: path.read_bytes() for path in invalid_marker_root.iterdir()} with pytest.raises(ValueError, match="marker is invalid"): ArtifactScratchManager(root=invalid_marker_root, limits=preparation_limits()) - assert { - path.name: path.read_bytes() - for path in invalid_marker_root.iterdir() - } == before + assert {path.name: path.read_bytes() for path in invalid_marker_root.iterdir()} == before @pytest.mark.parametrize( @@ -1240,13 +1234,14 @@ async def test_malformed_ledger_and_layout_fail_closed(tmp_path: Path) -> None: @pytest.mark.asyncio -async def test_prior_scratch_ledger_shape_is_normalized_on_reopen(tmp_path: Path) -> None: - """Preserve restart compatibility when workspace custody is introduced.""" +async def test_version_two_scratch_ledger_is_normalized_on_reopen(tmp_path: Path) -> None: + """Preserve restart compatibility for the genuine version-two ledger.""" root = tmp_path / "scratch" initial = ArtifactScratchManager(root=root, limits=preparation_limits()) initial.close() ledger_path = root / ".ledger.json" ledger = json.loads(ledger_path.read_text(encoding="utf-8")) + ledger["version"] = 2 ledger.pop("workspaces") ledger_path.write_text(json.dumps(ledger), encoding="utf-8") @@ -1255,10 +1250,27 @@ async def test_prior_scratch_ledger_shape_is_normalized_on_reopen(tmp_path: Path os.close(descriptor) await reopened.release(reservation) - assert json.loads(ledger_path.read_text(encoding="utf-8"))["workspaces"] == [] + normalized = json.loads(ledger_path.read_text(encoding="utf-8")) + assert normalized["version"] == 3 + assert normalized["workspaces"] == [] reopened.close() +@pytest.mark.asyncio +async def test_truncated_version_three_scratch_ledger_fails_closed(tmp_path: Path) -> None: + """Reject a current-version ledger missing workspace custody state.""" + root = tmp_path / "scratch" + initial = ArtifactScratchManager(root=root, limits=preparation_limits()) + initial.close() + ledger_path = root / ".ledger.json" + ledger = json.loads(ledger_path.read_text(encoding="utf-8")) + ledger.pop("workspaces") + ledger_path.write_text(json.dumps(ledger), encoding="utf-8") + + with pytest.raises(ArtifactScratchIntegrityError, match="ledger is invalid"): + ArtifactScratchManager(root=root, limits=preparation_limits()) + + @pytest.mark.asyncio async def test_shared_scratch_root_rejects_mismatched_process_limits(tmp_path: Path) -> None: """Fail closed when another process is configured with different root limits.""" @@ -1915,9 +1927,7 @@ async def test_stale_cleanup_reclaims_reservation_after_pid_reuse(tmp_path: Path cleaner = ArtifactScratchManager(root=tmp_path / "scratch", limits=limits) cleaner._read_process_identity = lambda _: "f" * 64 - assert await cleaner.cleanup_stale( - now_unix_ns=reservation.expires_at_unix_ns - ) == 1 + assert await cleaner.cleanup_stale(now_unix_ns=reservation.expires_at_unix_ns) == 1 assert (await cleaner.usage()).reservation_count == 0 cleaner.close() owner._owned_reservations.clear() From 482c26e3fc2c1fb9f36ca38306d38d0f6b338797 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 09:46:16 +0100 Subject: [PATCH 6/7] test(artifacts): restore hosted coverage floor --- backend/tests/test_artifact_preparation.py | 100 +++++++++++++++++- .../test_default_pre_submit_execution.py | 28 +++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_artifact_preparation.py b/backend/tests/test_artifact_preparation.py index a4b6a23f1..d17c73767 100644 --- a/backend/tests/test_artifact_preparation.py +++ b/backend/tests/test_artifact_preparation.py @@ -1238,11 +1238,22 @@ async def test_version_two_scratch_ledger_is_normalized_on_reopen(tmp_path: Path """Preserve restart compatibility for the genuine version-two ledger.""" root = tmp_path / "scratch" initial = ArtifactScratchManager(root=root, limits=preparation_limits()) + owner_process_identity = initial._owner_process_identity initial.close() ledger_path = root / ".ledger.json" ledger = json.loads(ledger_path.read_text(encoding="utf-8")) ledger["version"] = 2 - ledger.pop("workspaces") + workspace_name = f"extract_{'a' * 32}" + (root / "workspaces" / workspace_name).mkdir(mode=0o700) + ledger["workspaces"] = [ + { + "workspace_name": workspace_name, + "created_at_unix_ns": 1, + "expires_at_unix_ns": 10**20, + "owner_pid": os.getpid(), + "owner_process_identity": owner_process_identity, + } + ] ledger_path.write_text(json.dumps(ledger), encoding="utf-8") reopened = ArtifactScratchManager(root=root, limits=preparation_limits()) @@ -1252,7 +1263,17 @@ async def test_version_two_scratch_ledger_is_normalized_on_reopen(tmp_path: Path normalized = json.loads(ledger_path.read_text(encoding="utf-8")) assert normalized["version"] == 3 - assert normalized["workspaces"] == [] + assert normalized["workspaces"] == [ + { + "workspace_name": workspace_name, + "reserved_bytes": 0, + "maximum_entries": preparation_limits().maximum_workspace_entries, + "created_at_unix_ns": 1, + "expires_at_unix_ns": 10**20, + "owner_pid": os.getpid(), + "owner_process_identity": owner_process_identity, + } + ] reopened.close() @@ -1271,6 +1292,81 @@ async def test_truncated_version_three_scratch_ledger_fails_closed(tmp_path: Pat ArtifactScratchManager(root=root, limits=preparation_limits()) +def test_legacy_safe_empty_root_marker_upgrades_once(tmp_path: Path) -> None: + """Upgrade only the exact prior marker while the scratch root is empty.""" + limits = preparation_limits() + root = tmp_path / "scratch" + initial = ArtifactScratchManager(root=root, limits=limits) + initial.close() + marker = root / ".workstream-artifact-scratch-v1" + marker.write_bytes(ArtifactScratchManager._legacy_marker_content(limits)) + + reopened = ArtifactScratchManager(root=root, limits=limits) + reopened.close() + + assert marker.read_bytes() == ArtifactScratchManager._marker_content(limits) + + +@pytest.mark.asyncio +async def test_ledger_read_failures_are_explicit_and_bounded(tmp_path: Path) -> None: + """Reject missing, oversized, and malformed coordination documents.""" + for name, payload in ( + ("missing", None), + ("oversized", b" " * (1024 * 1024 + 1)), + ("malformed", b"{"), + ): + root = tmp_path / name + manager = ArtifactScratchManager(root=root, limits=preparation_limits()) + ledger_path = root / ".ledger.json" + if payload is None: + ledger_path.unlink() + else: + ledger_path.write_bytes(payload) + with pytest.raises(ArtifactScratchIntegrityError, match="ledger"): + await manager.usage() + manager.close() + + +def test_ledger_schema_rejects_each_invalid_fact_family(tmp_path: Path) -> None: + """Exercise every fail-closed ledger schema family independently.""" + manager = ArtifactScratchManager(root=tmp_path / "scratch", limits=preparation_limits()) + reservation = { + "reservation_id": "a" * 32, + "filename": f"prep_{'a' * 32}.bin", + "reserved_bytes": HARD_MAXIMUM_ARTIFACT_BYTES, + "created_at_unix_ns": 1, + "expires_at_unix_ns": 2, + "owner_pid": 1, + "owner_process_identity": "b" * 64, + } + workspace = { + "workspace_name": f"extract_{'c' * 32}", + "reserved_bytes": 0, + "maximum_entries": 1, + "created_at_unix_ns": 1, + "expires_at_unix_ns": 2, + "owner_pid": 1, + "owner_process_identity": "d" * 64, + } + valid = {"version": 3, "reservations": [], "workspaces": []} + invalid_ledgers = ( + {}, + {**valid, "version": 2}, + {**valid, "reservations": {}}, + {**valid, "reservations": [{}]}, + {**valid, "reservations": [{**reservation, "reservation_id": "bad"}]}, + {**valid, "reservations": [{**reservation, "created_at_unix_ns": -1}]}, + {**valid, "reservations": [{**reservation, "reserved_bytes": 0}]}, + {**valid, "workspaces": {}}, + {**valid, "workspaces": [{}]}, + {**valid, "workspaces": [{**workspace, "workspace_name": "bad"}]}, + ) + for ledger in invalid_ledgers: + with pytest.raises(ArtifactScratchIntegrityError, match="ledger is invalid"): + manager._validate_ledger(ledger) + manager.close() + + @pytest.mark.asyncio async def test_shared_scratch_root_rejects_mismatched_process_limits(tmp_path: Path) -> None: """Fail closed when another process is configured with different root limits.""" diff --git a/backend/tests/test_default_pre_submit_execution.py b/backend/tests/test_default_pre_submit_execution.py index 1a4e10318..c07ecacf2 100644 --- a/backend/tests/test_default_pre_submit_execution.py +++ b/backend/tests/test_default_pre_submit_execution.py @@ -214,6 +214,34 @@ async def test_authority_denial_precedes_workspace_and_checker_access(tmp_path: manager.close() +@pytest.mark.asyncio +async def test_materializer_rejects_policy_lineage_mismatch_before_authority( + tmp_path: Path, +) -> None: + """Reject caller-selected policy identity before consuming authority.""" + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + authority = _AllowAuthority() + service = PreparedBundleMaterializationService( + authorization=authority, + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + ) + + with pytest.raises( + PreSubmissionInfrastructureUnavailable, + match="pre_submission_materialization_context_invalid", + ): + await service.materialize_prepared_bundle( + replace(request, submission_artifact_policy_id=uuid4()) + ) + + assert authority.facts is None + await request.prepared_artifact.close() + await preparation.release_prepared_artifact(request.prepared_artifact._binding) + manager.close() + + @pytest.mark.asyncio async def test_default_executor_uses_plan_order_and_never_dispatches_project_rules( tmp_path: Path, From 4da3d26060bf3c6a02af5eaf550795d177937044 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 10:00:19 +0100 Subject: [PATCH 7/7] refactor(artifacts): make blocking projection boundary explicit --- .../reviews/WS-ART-001-04B2-external-review-response.md | 6 +++--- backend/app/modules/artifacts/preparation.py | 8 ++++---- backend/app/modules/checkers/pre_submit_execution.py | 6 ++++-- backend/tests/test_checker_materialization.py | 4 ++-- backend/tests/test_default_pre_submit_execution.py | 8 ++++---- 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md index 8d46e5176..96370f7c0 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B2-external-review-response.md @@ -16,7 +16,7 @@ existing checker test still referenced a private helper moved into the shared pure-semantics module. Both tests now assert the intended 04B2 ownership and import boundary directly. -- CodeRabbit's completed review found fail-closed ordering, dependency taxonomy, +- CodeRabbit's reported findings identified fail-closed ordering, dependency taxonomy, attestation semantics, CI timing redirection, compatibility-shape, typing, test-strength, and evidence-ledger issues. The valid findings are corrected on the current head; operational memory-alert sizing remains an explicit @@ -44,5 +44,5 @@ the shared CI failure-path defect. ## Remaining risks -Replacement hosted checks must validate the pushed commit. CodeRabbit remains -rate-limited unless its external quota becomes available. +Hosted checks passed on the repaired implementation head. CodeRabbit's final +incremental verification remains subject to its external rate limit. diff --git a/backend/app/modules/artifacts/preparation.py b/backend/app/modules/artifacts/preparation.py index 8c6edab5b..78150f30f 100644 --- a/backend/app/modules/artifacts/preparation.py +++ b/backend/app/modules/artifacts/preparation.py @@ -56,10 +56,10 @@ class PreparedSubmissionProcessor(Protocol[_InspectionResultCo]): - """Project one prepared submission inside a bounded scratch workspace.""" + """Blocking projection capability used only by the bounded async adapter.""" - def process(self, reader: BinaryIO, workspace: Path) -> _InspectionResultCo: - """Return bounded results without retaining either capability.""" + def process_blocking(self, reader: BinaryIO, workspace: Path) -> _InspectionResultCo: + """Return bounded results while executing outside the event loop.""" def abort(self) -> None: """Deny checker access after cancellation or deadline expiry.""" @@ -1721,7 +1721,7 @@ def process_and_cleanup() -> _InspectionResult: ) as workspace: active.reader.seek(0) try: - return processor.process(active.reader, workspace) + return processor.process_blocking(active.reader, workspace) finally: active.reader.seek(0) diff --git a/backend/app/modules/checkers/pre_submit_execution.py b/backend/app/modules/checkers/pre_submit_execution.py index 31b6795a0..81176b835 100644 --- a/backend/app/modules/checkers/pre_submit_execution.py +++ b/backend/app/modules/checkers/pre_submit_execution.py @@ -138,8 +138,10 @@ def abort(self) -> None: """Prevent checker callback access after caller cancellation or timeout.""" self._aborted.set() - def process(self, reader: BinaryIO, workspace: Path) -> DefaultPreSubmissionExecutionResult: - """Project once and execute only the closed platform/default phase slice.""" + def process_blocking( + self, reader: BinaryIO, workspace: Path + ) -> DefaultPreSubmissionExecutionResult: + """Project and execute inside preparation's bounded blocking adapter.""" self._validate_input() return self._archive_inspector.project_and_run( reader, diff --git a/backend/tests/test_checker_materialization.py b/backend/tests/test_checker_materialization.py index c04ffc208..4d2fe6232 100644 --- a/backend/tests/test_checker_materialization.py +++ b/backend/tests/test_checker_materialization.py @@ -62,7 +62,7 @@ def __init__(self, inspector, inspection): self.escaped_tree = None self.retained_content = None - def process(self, reader, workspace): + def process_blocking(self, reader, workspace): def verify(tree): self.escaped_tree = tree self.retained_content = tree._content @@ -97,7 +97,7 @@ async def test_projection_is_callback_scoped_and_cleanup_is_complete( reserved_bytes=inspection.total_expanded_bytes, maximum_entries=inspection.entry_count, ) as workspace: - result = processor.process(BytesIO(data), workspace) + result = processor.process_blocking(BytesIO(data), workspace) assert ("src/run.sh", executable) in result assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] diff --git a/backend/tests/test_default_pre_submit_execution.py b/backend/tests/test_default_pre_submit_execution.py index c07ecacf2..f63262569 100644 --- a/backend/tests/test_default_pre_submit_execution.py +++ b/backend/tests/test_default_pre_submit_execution.py @@ -579,14 +579,14 @@ async def test_authorized_cancellation_cleans_before_propagating( release = threading.Event() from app.modules.checkers.pre_submit_execution import DefaultPreSubmissionProcessor - original = DefaultPreSubmissionProcessor.process + original = DefaultPreSubmissionProcessor.process_blocking def blocking_process(self, reader, workspace): entered.set() assert release.wait(timeout=5) return original(self, reader, workspace) - monkeypatch.setattr(DefaultPreSubmissionProcessor, "process", blocking_process) + monkeypatch.setattr(DefaultPreSubmissionProcessor, "process_blocking", blocking_process) service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, @@ -649,14 +649,14 @@ async def test_timeout_during_checker_access_cleans_workspace( release = threading.Event() from app.modules.checkers.pre_submit_execution import DefaultPreSubmissionProcessor - original = DefaultPreSubmissionProcessor.process + original = DefaultPreSubmissionProcessor.process_blocking def blocking_process(self, reader, workspace): entered.set() assert release.wait(timeout=5) return original(self, reader, workspace) - monkeypatch.setattr(DefaultPreSubmissionProcessor, "process", blocking_process) + monkeypatch.setattr(DefaultPreSubmissionProcessor, "process_blocking", blocking_process) preparation._active[request.prepared_artifact._binding].deadline = ( asyncio.get_running_loop().time() + 0.01 )