refactor: adapter 구현중..

This commit is contained in:
DongHyeonka
2026-08-13 16:02:21 +09:00
parent 30ceac23c1
commit 4dc033cf33
72 changed files with 13370 additions and 1549 deletions
@@ -74,9 +74,9 @@
- Modify: `config/ci/gates.json`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
- [ ] Inventory every artifact still mapped to `generic-json-object` and export/reuse the producer's strict schema, including cross-field status/failure/count invariants. Do not treat a non-empty JSON object as semantic evidence.
- [ ] Replace substring-only JUnit/HTML acceptance with bounded well-formed document validation. Reject DTD/entities, malformed nesting, duplicate/invalid roots, and trailing non-whitespace content.
- [ ] Add invalid-but-pattern-matching fixtures for all structured kinds and a table proving every configured artifact resolves to a semantic validator.
- [x] Inventory every artifact still mapped to `generic-json-object` and export/reuse the producer's strict schema, including cross-field status/failure/count invariants. Do not treat a non-empty JSON object as semantic evidence.
- [x] Replace substring-only JUnit/HTML acceptance with bounded well-formed document validation. Reject DTD/entities, malformed nesting, duplicate/invalid roots, and trailing non-whitespace content.
- [x] Add invalid-but-pattern-matching fixtures for all structured kinds and a table proving every configured artifact resolves to a semantic validator.
- [ ] Run focused artifact tests, `corepack pnpm check:ci`, types, lint, and diff checks; commit separately so this evidence-quality closeout is independently reviewable.
### Task 4: One authoritative architecture graph
@@ -0,0 +1,598 @@
# Promotion Security Review Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the captured candidate archive and a strict exact-five bundle validator the only promotion authority while closing evidence, lifecycle, staging, output, freshness, and workflow gaps found by security review.
**Architecture:** Candidate identity and local verification are derived exclusively from the inode-captured tar stream. The finalizer revalidates archived subordinate evidence, provider signatures, freshness, role-separated trust, and then creates and validates an exact-five bundle before returning descriptor- and inode-bound cleanup metadata. Provider execution, CLI output publication, and workflow gating expose small injectable boundaries so failure and cleanup behavior can be tested directly.
**Tech Stack:** Node.js 24, TypeScript, Zod, Vitest, GNU tar, bubblewrap, Gitea Actions workflow generation.
## Global Constraints
- Work sequentially on the current clean `develop` HEAD and produce one review-fix commit.
- Every production change follows focused RED, observed expected failure, minimal GREEN, and regression verification.
- Candidate verification performs no checkout reads; test subprocesses from outside the checkout with contradictory canaries.
- Preserve real GNU tar and bubblewrap coverage; do not claim native uploader or atomic `renameat2`/`unlinkat` semantics.
- Gitea 1.26.4 and act_runner 1.0.0 exact-five upload/download/cancel behavior remains an explicitly documented external smoke boundary.
---
### Task 1: Canonical Captured Archive and Archived Local Authority
**Files:**
- Modify: `scripts/lib/release-candidate.ts`
- Modify: `scripts/lib/local-release-evidence.ts`
- Modify: `scripts/lib/ci-candidate-archive.ts`
- Delete: `scripts/lib/promotion-verifier.ts`
- Delete: `scripts/verify-provider-evidence.ts`
- Delete: `scripts/verify-supply-chain-promotion.ts`
- Modify: `tests/unit/security-followup.test.ts`
- Modify: `tests/unit/supply-chain.test.ts`
- Modify: `tests/integration/security-followup-archive.test.ts`
**Interfaces:**
- Consumes: captured tar bytes plus expected SHA-256.
- Produces: `withVerifiedCapturedCandidate()` callback data derived only from the extracted, exact-member, digest-verified tar; `verifyArchivedLocalEvidence()` independently recomputes all feasible archived checks.
- [x] Add failing tests for invalid tar, archive/tree mismatch, contradictory archived subordinate FAIL, exact archived policy bytes, and checkout-independent execution.
- [x] Run focused tests and record the expected RED diagnostics in the durable task report.
- [x] Archive the exact policy/verifier inputs required for independent release, supply-chain, dependency, license, vulnerability, and secret-scan checks.
- [x] Re-run producer checks against the extracted archive and require their result to agree with the assessment and member identities.
- [x] Remove the obsolete standalone PASS issuers and route all fixture checking through real captured tar/finalizer validation.
- [ ] Run focused archive, supply-chain, and integration tests to GREEN.
### Task 2: Exact-Five Validator and Role-Separated Trust
**Files:**
- Create: `scripts/lib/exact-promotion-bundle.ts`
- Create: `scripts/verify-exact-promotion-bundle.ts`
- Modify: `scripts/lib/provider-evidence.ts`
- Modify: `scripts/lib/promotion-stager.ts`
- Modify: `scripts/contracts/promotion-artifacts.ts`
- Modify: `tests/unit/security-followup.test.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Consumes: exactly five captured byte buffers and two trusted Ed25519 identities.
- Produces: `verifyExactPromotionBundle()` that requires literal verifier identity/version, provider and subordinate PASS states, exact hashes, and equal run/source/candidate/provider/trust fields.
- [x] Add failing tests for provider FAIL/absence, arbitrary provider hash, swapped roles, shared-field mismatch, archive/report digest mismatch, and identical role keys.
- [x] Run focused tests and record RED.
- [x] Implement strict exact-five parsing/cross-record validation and expose a downstream CLI command.
- [x] Reject equal DER-SPKI fingerprints and equal role key identity before evaluation/finalization.
- [x] Invoke exact-five validation inside the finalizer before publication; the full real-build fixture rerun remains sandbox-blocked below.
### Task 3: Provider Lifecycle and Freshness
**Files:**
- Modify: `scripts/lib/provider-supervisor.ts`
- Create: `scripts/lib/provider-process-runner.ts`
- Modify: `scripts/run-and-validate-provider.ts`
- Modify: `tests/unit/security-followup.test.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Consumes: a spawned bubblewrap child, injected timeout/clock, captured report.
- Produces: a runner that SIGKILLs on timeout but rejects only after `close`, and supervision that samples freshness after provider/report capture.
- [x] Add failing stubborn-descendant/short-timeout and sequence-clock expiry tests.
- [x] Run focused tests and record RED.
- [x] Extract the process runner, wait for close after timeout, and preserve the timeout diagnostic.
- [x] Issue provider timestamps immediately before execution, validate with a fresh clock after capture, and reject crossing expiry.
- [x] Run focused lifecycle tests, including real stubborn descendants, to GREEN; the shared real-build/bubblewrap fixture remains sandbox-blocked below.
### Task 4: Inode-Pinned Staging and Output-Failure Cleanup
**Files:**
- Modify: `scripts/lib/promotion-stager.ts`
- Create: `scripts/lib/stage-verified-promotion-cli.ts`
- Modify: `scripts/stage-verified-promotion.ts`
- Modify: `scripts/cleanup-verified-promotion.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Produces: `FinalizedPromotion.stagingIdentity` and a testable CLI function whose append failure invokes cleanup from the in-memory result.
- [x] Add failing tests for leaf replacement during writes, final visibility mismatch, partial-failure cleanup, GITHUB_OUTPUT open/write failure, and expiry during staging.
- [x] Run focused tests and record RED.
- [x] Open the created leaf with `O_DIRECTORY|O_NOFOLLOW`, write through `/proc/self/fd/<leafFd>`, pin dev/ino, require visible identity equality, and propagate identity through cleanup.
- [x] Force directory/file modes with `fchmod(0700/0400)` independent of a restrictive owner-preserving umask.
- [x] Extract CLI dependencies; on any post-finalization output failure call direct cleanup before rethrowing.
- [x] Revalidate evidence freshness before sealing and immediately before publication; isolated lifecycle/mode/output tests are GREEN and the shared real-build fixture remains sandbox-blocked below.
### Task 5: Workflow and Install Policy
**Files:**
- Modify: `package.json`
- Modify: `scripts/check-ci-contract.ts`
- Modify: `scripts/contracts/ci-gates.ts`
- Modify: `scripts/generate-ci-workflow.ts`
- Modify: `config/ci/gates.json`
- Modify: `.gitea/workflows/quality-gates.yml`
- Modify: `tests/unit/ci-workflow-generation.test.ts`
- Modify: `tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap`
**Interfaces:**
- Produces: promotion job `if: ${{ always() && needs.immutable_build.result == 'success' && needs.vulnerability_provider.result == 'success' && needs.provenance_provider.result == 'success' }}` and install-bearing script graph enforcement.
- [x] Add failing contract/generator tests for the job condition, upload without `always()`, missing cleanup outputs, and nested install scripts lacking `--ignore-scripts`.
- [x] Run focused tests and record RED.
- [x] Add `--ignore-scripts` to `verify:lockfile` and recursively reject each reachable install invocation without it.
- [x] Extend the typed job condition and render the explicit cancellation-resistant exact-needs predicate.
- [x] Regenerate workflow/snapshot and run workflow contract/byte tests to GREEN.
### Task 6: Fixtures, Documentation, Full Verification, and Commit
**Files:**
- Modify: `scripts/check-supply-chain-provider-fixtures.ts`
- Modify: `docs/security/supply-chain.md`
- Modify: `docs/operations/ci-quality-gates.md`
- Modify: `.superpowers/sdd/2026-08-01-release-evidence-remediation/task-4-report.md` (ignored durable report)
- [x] Replace plaintext candidate fixtures with a real tar and canonical captured-archive/exact-five validation.
- [x] Rewrite operator docs around the sole captured-archive/exact-five authority and retain the Gitea/runner external-smoke residual.
- [ ] Run focused fixtures, archive integration, workflow snapshot/bytes, full unit, types, lint, `check:ci`, and diff checks; escalate only a sandbox-caused EPERM.
- [x] Append all RED/GREEN and verification evidence/constraints to the durable report.
- [ ] Invoke verification-before-completion, review the complete diff, commit once, and report commit/range/status.
---
## Review-Fix Wave D: Sealed Bytes, Replay Context, Scan Trust, and Cancellation
**Constraint:** Work only in the existing uncommitted tree. Do not write `.git`, stage, or commit. Each task follows a focused RED→GREEN cycle and records sandbox `EPERM` separately from product failures.
### Task D1: Seal the actual staged inode bytes
**Files:** `scripts/lib/promotion-stager.ts`, `tests/unit/security-followup.test.ts`, `tests/unit/ci-artifact-contract.test.ts`
**Interface:** The staging writer captures each canonical file through the already-open leaf FD using `O_NOFOLLOW`; it requires a regular single-link inode, mode `0400`, stable dev/ino/size, and the declared SHA-256. `verifyExactPromotionBundle` receives only these captured staged buffers immediately before return.
- [x] Add RED tests for unlink/recreate and chmod/mutation after a file write.
- [x] Implement bounded descriptor-relative capture and exact-five seal validation.
- [x] Run focused staging tests to GREEN.
### Task D2: Bind downstream verification to external expected identity
**Files:** `scripts/lib/exact-promotion-bundle.ts`, `scripts/verify-exact-promotion-bundle.ts`, `tests/unit/ci-artifact-contract.test.ts`
**Interface:** `verifyExactPromotionBundle` requires `expected.run.id`, `expected.run.attempt`, `expected.sourceRevision`, and `expected.archiveSha256`; optional bundle/dist/lock/source-set digests are compared when supplied. The CLI obtains these values from dedicated environment variables and never derives them from the bundle.
- [x] Add a RED signed other-run replay test.
- [x] Implement external expected-context comparison in library and CLI.
- [x] Run exact-bundle tests to GREEN where the sandbox permits.
### Task D3: Pin mkdir-to-open identity
**Files:** `scripts/lib/promotion-stager.ts`, `tests/unit/security-followup.test.ts`, `docs/security/supply-chain.md`, `docs/operations/ci-quality-gates.md`
**Interface:** A post-mkdir/pre-open test hook can replace the leaf. The implementation compares mkdir-returned pathname metadata with the `O_DIRECTORY|O_NOFOLLOW` handle `fstat` before any write; it never uses pathname chmod.
- [x] Add a RED pre-open replacement test.
- [x] Compare created and opened metadata and reject replacement.
- [x] Document the residual portable Node same-UID pre-lstat/native-privilege boundary.
### Task D4: Conservatively parse install invocations
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`
**Interface:** A bounded shell/token parser recognizes `pnpm install|i`, `npm install|ci|i`, and `yarn install` after supported manager-global options with split or `=` values. Any reachable package-manager invocation that cannot be classified is rejected.
- [x] Add the eight required global-option/alias RED cases plus malformed fail-closed cases.
- [x] Implement tokenization and manager-specific invocation classification.
- [x] Run install-policy tests to GREEN.
### Task D5: Signed secret-scan attestation
**Files:** `scripts/lib/provider-evidence.ts`, `scripts/lib/provider-supervisor.ts`, `scripts/lib/provider-upload-validator.ts`, `scripts/lib/promotion-stager.ts`, `scripts/lib/exact-promotion-bundle.ts`, relevant unit/integration tests and docs.
**Interface:** Vulnerability evidence v2 contains a strict `secretScanAttestation` with `status: PASS`, local-assessment, source-set, policy, SARIF, and scan-input digests. The supervisor derives the expected tuple from captured archive members, exports it to the provider, and upload/final verification requires exact equality under the Ed25519 signature.
- [x] Add RED forged-empty-SARIF and attestation-mismatch tests.
- [x] Derive one captured-archive scan context and bind it through supervisor, signed schema, finalizer records, and exact validation.
- [x] Run provider/security tests to GREEN where the sandbox permits.
### Task D6: Cancellation-safe workflow and exact upload paths
**Files:** `scripts/contracts/ci-gates.ts`, `scripts/generate-ci-workflow.ts`, `config/ci/gates.json`, generated workflow/snapshot, workflow tests, and operations/security docs.
**Interface:** Promotion uses a typed dependency-success/no-job-if variant, so cancellation cannot be overridden by job-level `always()`. Step cleanup retains bare `always()` for ordinary failures. Upload documentation names the five canonical paths under `staging_root` and states cancellation cleanup remains a runner/native smoke boundary.
- [x] Add RED generator/contract assertions for no promotion job `if` and retained cleanup `always()`.
- [x] Regenerate workflow and snapshot after the typed condition change.
- [x] Correct operator/security wording and run workflow/CI checks to GREEN.
### Task D7: Verification
- [x] Run focused suites after each GREEN, then affected/full unit tests, all TypeScript targets, ESLint, `check:ci`, generated-byte check, and both diff checks.
- [x] Append exact PASS totals and sandbox-blocked commands to the ignored durable report.
- [x] Report modified files and remaining native/Gitea/unsandboxed verification boundaries; do not attempt git staging or commit.
## Wave E: Unified parser and downstream boundary review
### Task E1: One tokenized manager parser
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`
**Interface:** One parse result reports manager invocations, package-script dependencies, unsupported controls, and effective lifecycle suppression. Both graph traversal and install policy consume it. Single `&`, unknown manager grammar, and malformed options fail closed. The last valid `--ignore-scripts` assignment controls the effective value; false, contradictory, valueless, and malformed assignments are unsafe. Lifecycle-capable mutation builtins are never implicit repository scripts and require effective suppression.
- [x] Add RED tables for single-ampersand segmentation, false/override/malformed suppression, global-option run/implicit dependencies, yarn/corepack reachability, and builtin/script-name collisions.
- [x] Replace the regex traversal and separate install scan with one parser result.
- [x] Run the parser-focused and full workflow-generation suites.
### Task E2: Evaluator-owned secret-scan equality
**Files:** `scripts/lib/provider-evidence.ts`, `tests/unit/security-followup.test.ts`, finalizer tests.
**Interface:** `evaluatePromotionEvidence` itself compares the parsed vulnerability report's signed `secretScanAttestation` with `expected.secretScanAttestation`. A mismatch makes vulnerability and overall promotion status `FAIL_UNVERIFIED`, including the production finalizer path.
- [x] Add a RED evaluator mismatch test.
- [x] Implement exact equality before vulnerability PASS assignment.
- [x] Run security/provider-focused tests.
### Task E3: Downstream CLI exact-five contract
**Files:** `tests/unit/ci-artifact-contract.test.ts`, `tests/unit/security-followup.test.ts`, `scripts/verify-exact-promotion-bundle.ts` if required.
**Interface:** A real finalizer-produced canonical exact-five directory passes the downstream CLI when all required external expected values and trust keys are supplied. Every required expected variable missing or mismatched exits non-zero. Optional digests remain exact when present.
- [x] Add RED happy-path and required-env negative coverage using real finalizer output where sandbox execution permits.
- [x] Make only the minimal CLI/library changes needed for GREEN.
- [x] Separate child-process sandbox blockers from library assertions.
### Task E4: Verification
- [x] Run focused parser/security/CLI suites, TypeScript, ESLint, `check:ci`, and `git diff --check`.
- [x] Run full unit if feasible and report nested-process `EPERM` separately.
- [x] Do not stage or commit.
## Wave F: Manager parser boundary hardening
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`, stage, or commit. Add behavior tests before production changes and keep unsupported manager grammar fail-closed.
### Task F1: Workspace dispatch and authoritative script lookup
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`
**Interface:** The parser receives the authoritative root `scripts` record. Explicit `run` resolves a root script; pnpm/yarn implicit dispatch resolves only a known root script. Yarn `workspace` and `workspaces` dispatch are unsupported because the root graph does not load workspace package scripts. Builtin aliases are canonicalized before root-script lookup.
- [x] Add RED policy and graph tables for the three Yarn workspace dispatchers, pnpm `ln`, and unknown manager subcommands.
- [x] Remove workspace dispatchers from safe builtins, pass known root scripts into the parser, and canonicalize `pnpm ln` to lifecycle `link` before implicit lookup.
- [x] Run the focused dependency/lifecycle cases to GREEN.
### Task F2: Shell comments and lifecycle option state
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`
**Interface:** Unquoted `#` in a manager-bearing command is unsupported control syntax; quoted `#` remains ordinary token content. Lifecycle options are parsed in order into a canonical suppression state covering `--ignore-scripts`, `--no-ignore-scripts`, and `--config.ignore-scripts`; conflicting, malformed, unknown, or ineffective states fail closed. Other option-like lifecycle arguments require an explicit manager allowlist.
- [x] Add RED comment, negative suppression, supported positive, and unknown lifecycle-option tables.
- [x] Implement comment-aware tokenization and one ordered lifecycle argument parser.
- [x] Preserve the checked-in `--frozen-lockfile --ignore-scripts` path and run focused tests to GREEN.
### Task F3: Verification
- [x] Validate every checked-in package script through graph/install consumers without false positives.
- [x] Run the full workflow-generation file and related security tests.
- [x] Run all TypeScript targets, ESLint, `check:ci`, and `git diff --check`; report nested-process `EPERM` separately.
- [x] Update the durable report; do not stage or commit.
## Wave G: Verified cleanup and complete gate/parser preflight
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`, stage, or commit. Every production change follows a focused failing behavior test.
### Task G1: Verified-FD-only failure cleanup
**Files:** `scripts/lib/promotion-stager.ts`, `tests/unit/security-followup.test.ts`, security/operations documentation.
**Interface:** `openedIdentityVerified` becomes true only after the opened directory descriptor matches the post-`mkdir` device/inode. Failure cleanup may unlink canonical files or `rmdir` only through that verified descriptor and a still-matching visible identity. A mismatched opened descriptor and any visible replacement are close-only; a moved original directory remains for fixture/operator cleanup because portable Node cannot safely recover it.
- [x] Change the pre-open replacement regression to require both the replacement canary and displaced original directory to survive the failure.
- [x] Run the focused test to RED against parent-directory identity scanning.
- [x] Remove unverified inode discovery/recovery and gate descriptor cleanup on explicit identity verification.
- [x] Run staging race and cleanup tests to GREEN and document the native residual.
### Task G2: Contract-wide lifecycle preflight
**Files:** `scripts/contracts/ci-gates.ts`, `scripts/run-ci-gate.ts`, optional focused runner helper, `tests/unit/ci-workflow-generation.test.ts`.
**Interface:** `loadCiGateContract` runs `validateInstallScriptPolicy` over every unique contract command script after script existence and graph checks. The runner enters its execution callback only after this loader succeeds, enabling a no-execute regression without relying on a nested child process.
- [x] Add RED loader tables for contradictory npm suppression, pnpm config false, and `pnpm ln`, plus a production runner-boundary no-execute spy.
- [x] Enforce contract-command install policy and route runner execution through the preflight boundary.
- [x] Run loader/runner preflight tests to GREEN.
### Task G3: Foreign manifest scope by command class
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Manager-global options that change cwd, manifest or workspace scope are recorded during parsing. Explicit and implicit package-script dispatch with any such option is unsupported under the root-only graph. Lifecycle commands remain classifiable and are accepted only when their own ordered suppression/option grammar is safe.
- [x] Add RED policy+graph tables for pnpm filter/dir/`-C`, npm workspace/prefix, and Yarn cwd dispatch.
- [x] Add positive externally scoped lifecycle cases with verified suppression.
- [x] Track scope options and reject only package-script dispatch; run focused tests to GREEN.
### Task G4: Argument-sensitive builtin grammar and verification
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`, durable report.
**Interface:** Broad command-name-only safe builtins are replaced by exact per-manager read-only invocations. Init/explore/Yarn npm namespaces are unsupported. Audit is accepted only as an exact bare read-only command; `fix` and all unknown arguments are rejected.
- [x] Add RED policy+graph coverage for npm init/explore/audit-fix and Yarn npm publish, plus a bare-audit positive.
- [x] Replace permissive builtin lookup with exact argument grammar.
- [x] Audit every current package script for graph/policy false positives.
- [x] Run staging/parser/no-execute/workflow/security suites, all TypeScript targets, ESLint, `check:ci`, and `git diff --check`; record sandbox `EPERM` separately and do not stage or commit.
## Wave H: npm post-script scope-option boundary
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`, stage, or commit. Reproduce every reviewer command in a failing test before changing the parser.
### Task H1: Explicit and implicit npm dispatch arguments
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`.
**Interface:** After an explicit `npm run`/`run-script` dependency or an implicit npm lifecycle script, manager options before the first literal `--` are parsed using an exact harmless allowlist. Workspace/prefix selectors (`--workspace`, `-w`, `--workspaces`, `--prefix`, including supported attached/equal forms) and unknown manager options fail closed. Tokens after the first literal `--` are script arguments and cannot change the authoritative manifest scope.
- [x] Add RED policy-and-graph coverage for all seven reviewer inputs, short/equal forms, unknown pre-delimiter options, the literal `--` boundary, and ordinary current-tree dispatch.
- [x] Implement one npm post-script argument parser shared by explicit and implicit dispatch.
- [x] Run focused parser tests to GREEN.
### Task H2: Contract loader and runner boundary
**Files:** `tests/unit/ci-workflow-generation.test.ts`, contract preflight only if the RED test exposes a separate integration defect.
**Interface:** Every reviewer input is rejected by contract loading while the referenced root scripts exist and are otherwise safe. `withCiGatePreflight` must not enter its callback for any rejected command.
- [x] Add a table-driven loader/no-callback regression for the same seven reviewer inputs.
- [x] Run focused preflight tests to GREEN.
### Task H3: Verification
- [x] Re-audit current package scripts through graph and policy consumers.
- [x] Run workflow/security suites, all TypeScript targets, ESLint, `check:ci`, and `git diff --check`.
- [x] Record results in the durable report and do not stage or commit.
**Verification evidence:** The focused npm parser/preflight selection passed
31/31. The workflow file passed 208/210; its two remaining tests reached the
known nested-spawn sandbox boundary and reported `EPERM`. Security, supply-chain,
and local-promotion tests passed 64/64. All six TypeScript targets, ESLint,
`check:ci`, and `git diff --check` passed. Auditing the checked-in package found
zero policy failures across 109 scripts and zero graph failures across 108
entries (excluding the intentionally direct runner entry `ci:gate`). A broader
artifact-contract run passed 102 assertions and blocked 23 fixture cases at the
same nested `git ls-files` `EPERM` boundary. No `.git` write was performed.
Local pnpm 11.17 execution showed post-script `--filter`/`--dir` tokens arriving
in the root script's argv, and official Yarn run documentation defines all
parameters after the script name as script arguments. Those pre-existing
negative expectations were therefore corrected to positive regressions; only
npm receives the new post-script manager-option grammar.
## Wave I: npm hook closure and environment scope
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`,
stage, or commit. Add focused behavior tests and observe RED before each
production change.
### Task I1: npm pre/main/post dependency closure
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Explicit npm `run`/`run-script` and implicit
`start`/`stop`/`restart`/`test` return existing root-manifest lifecycle hooks in
`pre`, main, `post` order. Hooks are omitted only when ordered manager/tail
suppression is unambiguously effective before the first literal `--`; bare,
false, negative, contradictory, malformed, or post-delimiter suppression keeps
hook traversal active or fails closed.
- [x] Add RED policy/graph tables for nested, test, and restart pre/post hooks.
- [x] Add RED suppression positives and false/negative/contradictory/delimiter negatives.
- [x] Make npm tail parsing update the invocation suppression state and expand dependencies.
- [x] Run hook/parser tests to GREEN.
### Task I2: Tokenized npm scope environment
**Files:** `scripts/lib/package-script-graph.ts`, `scripts/contracts/ci-gates.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Case-insensitive assignments to `npm_config_workspace`,
`npm_config_workspaces`, or `npm_config_prefix` fail closed when their shell
segment executes npm. Direct assignment, `env`, `/usr/bin/env`, and an exported
assignment inherited by a later npm segment are covered without raw-substring
false positives for quoted text. `withCiGatePreflight` also rejects the same
sensitive keys inherited through `process.env` before entering its callback.
- [x] Add RED policy/graph coverage for all reviewer assignment forms and quoted/current-tree positives.
- [x] Add RED loader/no-callback coverage for command assignments and inherited process environment.
- [x] Implement token/segment assignment state and the preflight environment boundary.
- [x] Run environment/parser/preflight tests to GREEN.
### Task I3: Verification
- [x] Audit every current script through graph and policy consumers.
- [x] Run focused parser/preflight, workflow/security, all TypeScript targets,
ESLint, `check:ci`, and `git diff --check`.
- [x] Update durable operations/security documentation and record sandbox-only
nested spawn failures separately; do not stage or commit.
**Verification evidence:** Hook closure began RED 9/9 and GREEN 9/9;
ordered suppression began with 6 expected failures and finished GREEN 22/22;
wrapper/export/inherited environment coverage began with 7 expected failures
and finished GREEN 28/28. A final all-command-class environment RED 3/3
closed scoped lifecycle and builtin invocations. The combined Wave I focused
selection passed 59/59. The complete workflow file passed 269/271; its only
two failures were the existing nested child-spawn `EPERM` fixtures. Security,
supply-chain, and local-promotion tests passed 64/64. All six TypeScript
targets, ESLint, `check:ci`, and `git diff --check` passed. The checked-in tree
had zero policy failures across 109 scripts, zero graph failures across 108
entries after excluding the intentional direct runner entry `ci:gate`, and no
sensitive inherited npm scope environment. No `.git` write was performed.
## Wave J: coherent npm environment state and hook semantics
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`,
stage, or commit. Add each reviewer form as a failing regression before changing
the parser.
### Task J1: Stateful shell npm-scope environment analysis
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Shell segments carry a conservative npm-scope environment state
to later reachable npm invocations. The parser recognizes any static path whose
basename is `env`, optionally behind `command`, and rejects case-insensitive
scope assignments in direct or env-wrapper contexts. Static assignment/export
and `set -a` transitions are modeled across segments. Dynamic assignment names
and environment mutations that cannot be modeled accurately (`set +a`,
`unset`, `export -n`, `eval`, dot/source) make later npm dispatch unsupported.
Quoted harmless text and non-scope static assignments remain accepted; analysis
uses token and segment structure rather than raw substring matching.
- [x] Add RED policy/graph tables for every reviewer state transition, env path,
command wrapper, dynamic assignment name, and unsupported mutation.
- [x] Implement a shared tokenized shell-environment state machine and immediate
npm invocation environment inspection.
- [x] Add harmless quoted/static positive regressions and run focused tests GREEN.
### Task J2: Actual npm lifecycle-hook suppression semantics
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Existing pre/main/post hooks are traversed for `run`,
`run-script`, `start`, `stop`, `restart`, and `test`. Bare `--ignore-scripts`
means true and omits hooks just like explicit true. False, negative,
contradictory, malformed, and post-delimiter forms retain hook traversal or fail
closed according to the existing ordered grammar.
- [x] Add hook safety/order coverage for run-script, start, and stop.
- [x] Move bare suppression forms to positive regressions and retain all false,
negative, contradictory, and delimiter negatives.
- [x] Remove the explicitly-valued distinction and run focused tests GREEN.
### Task J3: Contract boundary and verification
- [x] Run every environment reviewer command through policy, graph, contract
loading, and `withCiGatePreflight`, asserting the callback is never entered.
- [x] Retain the inherited process-environment regression and audit the current
package tree for policy/graph false positives.
- [x] Run workflow/security suites, all TypeScript targets, ESLint, `check:ci`,
and `git diff --check`; record sandbox-only failures and do not stage or commit.
**Verification evidence:** The initial Wave J selection produced 38 expected
failures across loader/policy/graph environment cases and bare hook suppression,
then passed 84/84 after implementation. A separate unsupported dynamic env-wrapper
expansion regression went RED 2/2 and GREEN 2/2; the final combined selection
passed 86/86. The full workflow file passed 327/329, with only the existing two
nested child-spawn `EPERM` fixtures failing at the sandbox boundary. Security,
supply-chain, local-promotion, and promotion-readiness tests passed 71/71. All
six TypeScript targets, ESLint, `check:ci`, and `git diff --check` passed. The
checked-in tree had zero policy failures across 109 scripts, zero graph failures
across 108 entries after excluding the intentional direct runner entry `ci:gate`,
and no sensitive inherited npm scope environment. No `.git` write was performed.
## Wave K: common shell-prefix grammar
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`,
stage, or commit. Every wrapper/prefix reviewer command must be RED in all four
public enforcement paths before production changes.
### Task K1: Shared prefix parser and state-builtin targeting
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** A single token-based prefix helper consumes leading static
assignments, then exact `command`/`exec` wrapper chains and their supported
separator syntax. It reports the effective command token/index, whether parsing
is uncertain, and the leading assignments. Both immediate npm env inspection
and persistent `export`/`set` state updates use this result. `command --` is
accepted; unknown `command` options and unmodeled `exec` options before npm/env
fail closed. Static paths retain basename-`env` behavior.
- [x] Add common policy/graph RED cases for `exec env`, `exec /bin/env`,
`command exec env`, `command -- env`, assignment-prefixed `export`, and
assignment-prefixed `set -a`.
- [x] Reuse the same reviewer table through `loadCiGateContract` and
`withCiGatePreflight`, asserting rejection and no callback entry.
- [x] Implement the shared prefix parser, route immediate env inspection and
state-builtin updates through it, and run the reviewer selection GREEN.
- [x] Preserve split assignment/export ordering, dynamic LHS, quoted text,
harmless `MESSAGE=...`, and supported command-wrapper positives.
### Task K2: Hook selection and final verification
**Files:** `tests/unit/ci-workflow-generation.test.ts`, durable report.
- [x] Ensure the final focused selection explicitly includes the bare npm hook
suppression table as well as prefix/environment policy and runner tests.
- [x] Run the full workflow file and security/supply/local-promotion suites;
classify only the known nested-spawn sandbox failures separately.
- [x] Audit all current scripts through policy and graph, then run all TypeScript
targets, ESLint, `check:ci`, and `git diff --check`; do not stage or commit.
**Verification evidence:** The nine shared shell-prefix reviewer commands began
RED in both enforcement tables, producing 18 expected failures across
loader/runner and policy/graph, then passed 18/18 after the common parser was
connected. The prefix negatives plus harmless positives passed 39/39. The final
focused selection explicitly combined prefix cases, dynamic environment cases,
effective/bare npm hook suppression, and harmless positives and passed 106/106.
The complete workflow file passed 353/355; its only two failures were the known
nested child-spawn `EPERM` fixtures. Security, supply-chain, local-promotion, and
promotion-readiness tests passed 71/71. The current package tree had zero policy
failures across 109 scripts, zero graph failures across 108 entries after
excluding `ci:gate`, and no sensitive inherited npm scope environment. All six
TypeScript targets, ESLint, `check:ci`, and `git diff --check` passed. The shared
workspace was preserved and no `.git` write was performed.
## Wave L: structural manager-prefix gap rejection
**Constraint:** Continue in the shared uncommitted tree. Do not write `.git`,
stage, or commit. Generalize the existing parser; do not add wrapper names to an
allowlist.
### Task L1: Reject unmodeled tokens before package managers
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** The common shell-prefix result identifies the first effective
command after modeled assignments and `command`/`exec` wrappers. When manager
scanning later finds a package manager, every token between that effective
command position and the manager position must belong to a grammar explicitly
consumed by immediate env or corepack parsing. Otherwise the invocation is
unsupported. This structural rule covers `nice`, absolute-path `nice`, `nohup`,
and future unknown wrappers without naming them.
- [x] Add policy/graph RED coverage for the four env-wrapper reviewer commands
and direct unknown-wrapper manager commands (`nice npm`, `time pnpm`).
- [x] Reuse the env-wrapper reviewer commands through contract loading and
`withCiGatePreflight`, asserting the callback remains false.
- [x] Implement one structural gap check in manager parsing and run RED cases
GREEN without adding wrapper names.
- [x] Retain modeled assignment, `command`/`exec`/env/corepack, current-tree,
quoted echo, and harmless assignment positives.
### Task L2: Verification
- [x] Run a focused selection containing structural negatives and all modeled
prefix/environment positives.
- [x] Run the workflow and security/supply/local-promotion suites, current-tree
policy/graph/environment audit, all TypeScript targets, ESLint, `check:ci`, and
`git diff --check`; record sandbox-only failures and do not stage or commit.
**Verification evidence:** The six unmodeled-prefix reviewer commands began RED
in both enforcement tables, producing 12 expected loader/runner and policy/graph
failures, then passed 12/12 after one structural prefix-gap check was added. The
unmodeled negatives plus harmless/modeled positives passed 36/36. The final
focused Wave HL environment/prefix and effective/bare hook selection passed
121/121. The full workflow file passed 368/370, with only the two known nested
child-spawn `EPERM` fixtures failing at the sandbox boundary. Security,
supply-chain, local-promotion, and promotion-readiness tests passed 71/71. The
current package tree had zero policy failures across 109 scripts, zero graph
failures across 108 entries after excluding `ci:gate`, and no sensitive inherited
npm scope environment. All six TypeScript targets, ESLint, `check:ci`, and
`git diff --check` passed. No wrapper-name allowlist was added, the workspace was
preserved, and no `.git` write was performed.
@@ -0,0 +1,499 @@
# Provider Evidence Guardian Transaction Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make guardian startup cleanup derive authority only from identities allocated before spawn while preserving no-replace raw/sealed publication and immediate safe retry.
**Architecture:** Before spawn, the client pins the canonical raw/evidence directories and exclusively allocates nonce-private raw-staging/sealed-temp inodes whose handles and identities it retains. The guardian inherits directory fd 3/fd 4 and private-file fd 5/fd 6, binds strictly validated aliases to those inherited identities, and transfers raw authority with a no-replace hard link before authenticated READY. The client and guardian clean only pre-recorded identities; neither promotes a pathname-discovered inode to ownership.
**Tech Stack:** Node.js 24 TypeScript, Vitest, Linux file identities and procfs, systemd user scopes, bubblewrap, cgroup v2.
## Global Constraints
- Tasks 1-7 are the historical round-four/five record. Round-six Task 8 runs only in `/tmp/guardian-race-fix-y05lvLi1/repo` on top of `d781692`; never modify the original workspace, `/tmp/task3-integration-mU4L7J2u`, or the security-finalizer repository.
- Use RED-GREEN-REFACTOR for every production behavior change.
- Guardian argv contains only `process.execPath` and the trusted guardian script; its environment is empty, fd 3/fd 4 are the identity-pinned raw/evidence directories, and fd 5/fd 6 are the identity-pinned private raw/sealed allocations.
- Every request/ack is canonical length-prefixed JSON with exact ordered fields, strict UTF-8, no NUL, total bounds, a 32-byte nonce, and constant-time authentication.
- Canonical raw and sealed paths are derived from guardian `cwd` and provider kind; paths and identities are not accepted in the guard request.
- Provider wall timeout is at most 30 minutes and post-processing allowance is exactly 10 minutes; the guardian maximum lease is 40 minutes.
- Publication is no-replace and directory-durable. Abort/death/deadline cleans every raw/temp/final path that still names a pinned owned inode.
- Preserve all cleanup failures with the primary failure using `AggregateError`.
- Do not add PID-exhaustion loops or claim `RLIMIT_NPROC` enforcement.
- Do not run or report live systemd/bwrap tests as passing while the approval limit prevents execution.
- Never forward raw provider stdout/stderr bytes to supervisor or CI logs.
---
### Task 1: Versioned Transaction Protocol
**Files:**
- Modify: `scripts/lib/provider-guardian-protocol.ts`
- Modify: `tests/unit/task3-selective-integration.test.ts`
**Interfaces:**
- Produces:
```ts
type ProviderGuardianGuard = Readonly<{
kind: "vulnerability" | "provenance";
nonce: Buffer;
deadlineEpochMs: number;
}>;
type ProviderGuardianReady = Readonly<{
nonce: Buffer;
rawDev: number;
rawIno: number;
sealedTempLeaf: string;
sealedDev: number;
sealedIno: number;
}>;
type ProviderGuardianPublish = Readonly<{
nonce: Buffer;
sealedDev: number;
sealedIno: number;
size: number;
sha256: string;
}>;
function encodeProviderGuardianGuard(input: ProviderGuardianGuard): Buffer;
function decodeProviderGuardianReady(payload: Buffer, nonce: Buffer): ProviderGuardianReady;
function encodeProviderGuardianPublish(input: ProviderGuardianPublish): Buffer;
function decodeProviderGuardianPublished(payload: Buffer, nonce: Buffer): void;
function encodeProviderGuardianCommit(nonce: Buffer): Buffer;
```
- [ ] **Step 1: Write failing exact-protocol tests**
Assert that guard contains no path or identity, READY returns authenticated identities, publish binds exact identity/size/SHA-256, PUBLISHED authenticates the same nonce, and duplicate/reordered/trailing/oversized/invalid UTF-8/NUL/short-nonce frames fail.
```ts
expect(JSON.parse(encodeProviderGuardianGuard(guard).subarray(4).toString())).toEqual({
type: "guard", version: 2, kind: "vulnerability",
nonce: nonce.toString("hex"), deadlineEpochMs,
});
expect(() => decodeProviderGuardianReady(duplicateNoncePayload, nonce)).toThrow(/canonical|fields/u);
```
- [ ] **Step 2: Run focused RED**
Run: `node_modules/.bin/vitest run tests/unit/task3-selective-integration.test.ts --reporter=default --maxWorkers=1`
Expected: FAIL because the v2 guard/READY/publish/PUBLISHED APIs do not exist and the old guard still accepts identities.
- [ ] **Step 3: Implement the minimal v2 codecs**
Use one bounded prefix/strict decode utility, exact ordered key arrays, canonical re-encoding, lowercase 64-hex nonces/SHA-256, safe positive integers, and `timingSafeEqual` for every acknowledgement/authentication comparison.
- [ ] **Step 4: Run focused GREEN**
Run the Step 2 command and require the protocol tests to pass.
### Task 2: Guardian-Owned Raw and Sealed Transaction
**Files:**
- Modify: `scripts/lib/provider-raw-guardian.ts`
- Modify: `scripts/lib/provider-raw-cleanup.ts`
- Modify: `tests/unit/task3-selective-integration.test.ts`
**Interfaces:**
- Consumes: Task 1 codecs.
- Produces: real process state machine `guard -> READY -> publish -> PUBLISHED -> commitPending -> EOF success`.
- [ ] **Step 1: Write failing real-process creation tests**
Cover no-frame and partial-frame EOF with no files, authenticated READY-created raw/temp identities and modes, full-frame parent EOF cleanup before commit, deadline cleanup, and a near-timeout successful transaction.
```ts
child.stdin.end(partialFrame);
await completion;
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(await lstat(rawPath)).toMatchObject({ mode: expect.any(Number) });
```
- [ ] **Step 2: Verify creation RED**
Run the focused test and require failure because the existing guardian expects supervisor-created identity and emits line-based READY.
- [ ] **Step 3: Implement exclusive creation and READY**
Derive canonical leaves, create raw and random sealed sibling temp with `O_EXCL|O_NOFOLLOW`, set raw/temp `0600`, fstat identities, close raw, keep temp handle, and emit bounded READY. On every error, attempt all owned cleanup before nonzero exit.
- [ ] **Step 4: Write failing publish/state tests**
Write validated bytes to the pinned temp, request publish, require PUBLISHED and final mode/hash/identity, then verify commit waits for EOF. Send one later trailing byte after commit and require final cleanup/nonzero exit. Kill the parent after PUBLISHED and require raw/temp/final absence.
- [ ] **Step 5: Implement no-replace durable publish and serialized terminal cleanup**
Verify held descriptor/path identity, `nlink=1`, `0400`, size, and SHA-256. Use `link(temp, final)`, `unlink(temp)`, final lstat identity, and parent-directory fsync. Serialize frame and EOF handling so a publish/death race cannot bypass cleanup. Commit removes raw and sets `commitPending`; only clean EOF exits zero.
- [ ] **Step 6: Run real-process GREEN**
Run focused tests and require zero raw/temp/final/process residuals in every failure case.
### Task 3: Authenticated Client Lease and Fallback Cleanup
**Files:**
- Modify: `scripts/lib/provider-guardian-client.ts`
- Modify: `scripts/lib/validated-json-artifact.ts`
- Modify: `tests/unit/task3-selective-integration.test.ts`
- Modify: `tests/unit/validated-json-artifact.test.ts`
**Interfaces:**
- Produces:
```ts
type ProviderGuardianLease = Readonly<{
pid: number;
rawPath: string;
rawIdentity: Readonly<{ dev: number; ino: number }>;
sealedPath: string;
sealedTempPath: string;
sealedIdentity: Readonly<{ dev: number; ino: number }>;
prematureExit: Promise<Error>;
publish(bytes: Buffer): Promise<void>;
commit(): Promise<void>;
abort(): Promise<void>;
}>;
function serializeValidatedJsonArtifact(input: ValidatedJsonArtifactInput): Buffer;
```
- [ ] **Step 1: Write failing client transaction tests**
Require exact guardian argv and empty environment, READY identity capture, pinned temp write/fsync/mode, PUBLISHED wait, exactly-one terminal action, post-READY guardian SIGKILL cleanup of raw/temp/final, and cleanup error aggregation.
- [ ] **Step 2: Verify client RED**
Run focused and validated-writer tests. Expect missing publish/identity/serializer APIs.
- [ ] **Step 3: Implement serialization and lease**
Extract the existing schema-parse/pretty-JSON/newline serialization without changing `writeValidatedJsonArtifact`. Open the returned temp with `O_NOFOLLOW`, fstat identity, truncate/write/chmod `0400`/fsync/fstat/close, send authenticated publish metadata, and wait for PUBLISHED. Fallback cleanup attempts raw, temp, and final using READY identities and aggregates failures.
- [ ] **Step 4: Run client GREEN**
Run the Step 2 tests and require exact bytes, identities, cleanup, and no residual child.
### Task 4: Supervisor Transaction and Scope-Active Latch
**Files:**
- Modify: `scripts/run-and-validate-provider.ts`
- Modify: `tests/unit/task3-selective-integration.test.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Consumes: Task 3 lease and serializer.
- Produces: guardian-owned raw/provider execution, awaited publication, output append, commit/EOF, and scope-confined kill ownership.
- [ ] **Step 1: Write failing supervisor ordering/latch tests**
Require no `createProviderOutput`, lease start before provider, lease raw identity passed to scope, serialized bytes published before output append, commit after output append, and postprocess allowance included in lease. Add a pure scope-latch unit boundary or static contract proving the guardian callback can call `killProviderUnit` only while `scopeActive` is true.
- [ ] **Step 2: Verify supervisor RED**
Run focused tests and expect the old create/write/cleanup ordering assertions to fail.
- [ ] **Step 3: Integrate the lease transaction**
Start guardian in `executeProvider`, use READY raw path/identity for provider bind and capture, publish serialized validated evidence through the lease, append output, then commit. Remove supervisor raw creation and normal sealed writer publication. Keep only identity-bound lease fallback cleanup.
Set `PROVIDER_POSTPROCESS_TIMEOUT_MS = 600_000` and request `providerWallTimeoutMs + PROVIDER_POSTPROCESS_TIMEOUT_MS`.
- [ ] **Step 4: Implement scope-active guardian exit ownership**
Race an awaited scope-completion promise against termination. The guardian callback records its error and invokes termination only while `scopeActive`; the same function sets the latch false exactly once when kill/collection or normal collection completes. The callback never throws or creates an unobserved kill promise after the latch closes.
- [ ] **Step 5: Run supervisor GREEN**
Run focused tests and type/lint checks. Live systemd tests remain unexecuted and are not reported as passing.
### Task 5: Regression Fixtures and Documentation
**Files:**
- Modify: `tests/unit/task3-selective-integration.test.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
- Modify: `docs/operations/ci-quality-gates.md`
- Modify: `docs/security/supply-chain.md`
- Modify: `docs/superpowers/specs/2026-08-02-provider-raw-guardian-design.md`
- [ ] **Step 1: Complete real-process regressions**
Cover no/partial frame, parent kill near READY, post-READY guardian kill, PUBLISHED parent death, publish/commit race, later-chunk commit trailing data, deadline/near-timeout, same-workspace retry, and zero guardian/raw/temp/final residuals.
- [ ] **Step 2: Specify live regressions**
Add active-scope guardian kill, post-scope/precommit guardian kill, supervisor hard death after PUBLISHED with same-workspace retry, and detached descendant attempts for both an external marker and raw append. Every case requires zero cgroup/process/file residuals. Do not execute these tests under the current approval limit.
- [ ] **Step 3: Correct operations and security docs**
Document guardian-owned creation/publication, READY/PUBLISHED identities, ten-minute postprocess lease, commitPending/EOF success, no-replace link publication, scopeActive kill ownership, regular-file `GITHUB_OUTPUT`, and explicit live-test limitation.
- [ ] **Step 4: Fresh verification**
Run focused real-process tests, validated artifact tests, direct Node/test/recipe TypeScript configs, full lint, artifact schemas, CI contract, generated workflow byte check, and `git diff --check`. Record broad-suite sandbox `EPERM` separately and never convert unexecuted live tests into PASS.
- [ ] **Step 5: Review and commit round four**
Confirm only the isolated worktree changed, no protocol secret/path enters argv, cleanup checks both sealed names by identity, and only the temp `node_modules` symlink remains untracked. Create a separate round-four implementation commit above the design/plan commit.
### Task 6: Round-Five Pre-READY Recovery Authority
**Files:**
- Modify: `scripts/lib/provider-guardian-protocol.ts`
- Modify: `scripts/lib/provider-guardian-client.ts`
- Modify: `scripts/lib/provider-raw-guardian.ts`
- Test: `tests/unit/provider-guardian-transaction.test.ts`
**Interfaces:**
- Produces: `providerGuardianSealedTempLeaf(kind, nonce): string`, inherited raw/evidence directory fds 3/4, and descriptor-relative startup/lease cleanup.
- [x] **Step 1: Write failing pre-READY hard-death tests**
Start the real client without awaiting READY, observe its direct guardian child,
kill the guardian when either deterministic transaction leaf first appears, and
require startup rejection, zero raw/temp/final residuals, and a successful
same-workspace `startProviderGuardian(...).abort()` retry. Also require the temp
leaf computed before spawn to equal READY exactly and inherited fd 3/fd 4 to
remain directories during the lease.
- [x] **Step 2: Run focused RED**
Run: `node_modules/.bin/vitest run tests/unit/provider-guardian-transaction.test.ts --reporter=default --maxWorkers=1`
Expected: FAIL because the client has neither pre-spawn directory handles nor a
deterministic temp leaf and cannot clean a guardian killed before READY.
- [x] **Step 3: Implement pinned descriptor recovery**
Open and verify the canonical raw/evidence directories with
`O_DIRECTORY|O_NOFOLLOW`; derive the temp leaf from provider kind and the first
16 nonce bytes; spawn with those handles at fd 3/fd 4. Use only
`/proc/self/fd/<fd>/<leaf>` for guardian creation, publication, sync, and cleanup.
On startup failure, open each exact leaf through the still-live client
descriptor, fstat a regular single-link inode, close the discovery handle, and
run identity-bound quarantine/unlink. Aggregate primary, cleanup, and directory
close errors. Retain both handles until commit/abort terminates.
- [x] **Step 4: Run focused GREEN**
Run the Step 2 command and require the pre-READY kill/retry and all round-four
transaction tests to pass.
### Task 7: Round-Five Log Privacy and Terminal Fail-Closed Behavior
**Files:**
- Modify: `scripts/run-and-validate-provider.ts`
- Modify: `scripts/lib/provider-raw-guardian.ts`
- Test: `tests/unit/provider-guardian-transaction.test.ts`
- Test: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Consumes: Task 6 descriptor-pinned transaction.
- Produces: bounded discard of provider output and nonzero guardian termination even when diagnostic fds are closed.
- [x] **Step 1: Write failing privacy and closed-stderr tests**
Run a successful provider that both receives and prints a unique
`VULNERABILITY_PROVIDER_*` credential, then assert the credential is absent from
supervisor stdout/stderr while the sealed signed evidence succeeds. Replace the
FD-limit provider's stderr marker expectations with evidence/side-channel state.
Spawn a real guardian with stderr's read side destroyed, establish owned files,
then abort or send invalid input and require zero files plus a nonzero exit.
- [x] **Step 2: Run targeted RED**
Run the focused guardian and selected CI artifact tests. Expect credential
disclosure and the existing raw provider stderr marker assertions to fail the
new contract; the EPIPE case can exit without the required nonzero terminal.
The executable non-live RED observed six expected failures: missing deterministic
leaf/fd inheritance/output limiter, retained pre-READY raw, and closed-stderr
exit 0. The live credential-printing fixture is authored but remains NOT RUN.
- [x] **Step 3: Implement minimal privacy and terminal fixes**
Continue counting provider stdout/stderr bytes against the aggregate output
limit but discard captured bytes instead of retaining or forwarding them. Make
guardian fd-close and stderr diagnostics best effort, run cleanup first, and
place `process.exit(exitCode)` or self-`SIGKILL` in an unconditional final
branch that cannot be skipped by `EPIPE`/`EBADF`.
- [x] **Step 4: Run targeted GREEN and regression verification**
Run focused guardian tests, selected non-live privacy tests, Node/test
TypeScript, affected ESLint, docs readiness, and `git diff --check`. Do not run
live systemd/bwrap tests under the approval limit.
- [x] **Step 5: Commit round five implementation**
Commit production, tests, and operational/security documentation separately
above this round-five design/plan commit. Record live systemd/bwrap as NOT RUN.
Round-five verification record:
- Focused real-process/unit GREEN: 4 files, 52 tests passed.
- Direct Node and test TypeScript projects: PASS.
- Affected ESLint with zero warnings: PASS.
- Documentation readiness: `PASS_SCOPED`.
- `git diff --check`: PASS.
- Live systemd/bwrap credential, FD-limit, cgroup, and hard-death fixtures:
**NOT RUN** because the active approval limit forbids those executions.
### Task 8: Round-Six Pre-READY Inode Ownership
**Files:**
- Modify: `scripts/lib/provider-guardian-protocol.ts`
- Modify: `scripts/lib/provider-guardian-client.ts`
- Modify: `scripts/lib/provider-raw-guardian.ts`
- Test: `tests/unit/provider-guardian-transaction.test.ts`
- Modify: `docs/security/supply-chain.md`
- Modify: `docs/operations/ci-quality-gates.md`
- Modify: `docs/superpowers/specs/2026-08-02-provider-raw-guardian-design.md`
**Interfaces:**
- Produces:
```ts
function providerGuardianRawStagingLeaf(
kind: ProviderGuardianKind,
nonce: Buffer,
): string;
type RecoveryAuthority = Readonly<{
rawDirectoryHandle: FileHandle;
evidenceDirectoryHandle: FileHandle;
rawStagingHandle: FileHandle;
sealedTempHandle: FileHandle;
rawIdentity: Readonly<{ dev: number; ino: number }>;
sealedIdentity: Readonly<{ dev: number; ino: number }>;
rawStagingPinnedPath: string;
rawPinnedPath: string;
sealedTempPinnedPath: string;
sealedPinnedPath: string;
}>;
```
- [ ] **Step 1: Write the deterministic external-canary RED**
Create a temporary guardian fixture that writes a spawn marker and remains
alive without producing READY. Start the real client, wait for that marker (so
`assertRecoveryLeavesMissing` has completed), create a fixed-raw canary, kill
the direct guardian, and require startup rejection without canary deletion or
mutation.
```ts
const canaryBytes = Buffer.from("external-canary\n");
const canaryHandle = await open(rawPath, constants.O_CREAT | constants.O_EXCL |
constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
await canaryHandle.writeFile(canaryBytes);
const canaryIdentity = await canaryHandle.stat();
await canaryHandle.close();
process.kill(guardianPid, "SIGKILL");
await expect(starting).rejects.toThrow(/provider guardian/u);
expect(await readFile(rawPath)).toEqual(canaryBytes);
expect(await lstat(rawPath)).toMatchObject({
dev: canaryIdentity.dev,
ino: canaryIdentity.ino,
});
```
- [ ] **Step 2: Run the canary RED and confirm the ownership bug**
Run:
`node_modules/.bin/vitest run tests/unit/provider-guardian-transaction.test.ts -t "preserves an external raw canary" --reporter=default --maxWorkers=1`
Expected: FAIL with `ENOENT` when reading the canary because
`discoverAndCleanupOwnedLeaf` opens the current raw pathname and promotes the
external inode to cleanup authority.
- [ ] **Step 3: Add private-leaf derivation and client allocations**
Derive raw staging and sealed temp from the same first 16 nonce bytes:
```ts
return `.${baseLeaf(kind)}.guardian-${nonce.subarray(0, 16).toString("hex")}.raw.tmp`;
```
Through the pinned directory paths, create raw staging and sealed temp with
`O_RDWR|O_CREAT|O_EXCL|O_NOFOLLOW`, mode `0600`; require regular file, link count
one, mode `0600`, and size zero; store identities before spawn. Spawn with fd
3-fd 6. If allocation, validation, or spawn fails, identity-clean every private
alias and close every opened handle while preserving primary and cleanup/close
errors in one `AggregateError`.
- [ ] **Step 4: Add concurrency, bootstrap, and link-before-READY RED tests**
Add real-process tests that require:
```ts
// no/partial frame: bootstrap-owned private aliases are removed
child.stdin!.end(partialFrame);
await expect(readdir(rawDirectory)).resolves.toEqual([]);
// same kind: exactly one READY lease, loser never removes winner raw
const results = await Promise.allSettled([startProviderGuardian(input), startProviderGuardian(input)]);
expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1);
expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1);
// canonical raw link exists but READY has not been accepted
process.kill(guardianPid, "SIGKILL");
await expect(starting).rejects.toThrow(/provider guardian/u);
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
```
The link-before-READY test watches only the fixed raw basename, obtains the
direct child pid before the event, and kills on that exact link event so private
allocation events cannot satisfy the synchronization point. Each test performs
a same-workspace retry and requires no owned private/canonical residue.
- [ ] **Step 5: Implement guardian bootstrap identity binding**
At process bootstrap, fstat fd 5/fd 6 and read `/proc/self/fd/5|6`. Accept an
alias only if `dirname(readlink)` is the canonical expected directory, basename
is a direct child matching the exact raw-staging or sealed-temp lowercase-hex
grammar, both names encode the same kind/nonce prefix, and descriptor-relative
lstat equals the inherited fd identity/type/mode/size/link count. Store the fd
identity before reading any pathname; the pathname only becomes an alias for
that identity.
On valid guard, require exact `providerGuardianRawStagingLeaf(kind, nonce)` and
`providerGuardianSealedTempLeaf(kind, nonce)` matches. Use
`link(rawStaging, rawCanonical)` without replacement, check both aliases equal
the inherited raw identity with link count two, unlink raw staging, fsync fd 3,
and check raw canonical remains the same identity with link count one before
READY. Use the inherited sealed identity for READY and publication.
- [ ] **Step 6: Replace discovery cleanup and close all private fds**
Delete `discoverAndCleanupOwnedLeaf`. Client pre-READY and fallback cleanup
attempts raw staging/canonical with only `recovery.rawIdentity`, then sealed
temp/final with only `recovery.sealedIdentity`. Guardian no/partial-frame and
terminal cleanup uses only its bootstrap fd identities and bound aliases.
On success and every failure branch, attempt all cleanup first, close guardian
fd 5/fd 6 duplicates and client fd 3-fd 6 handles exactly once, and append every
close failure to the existing aggregate. Never open a current leaf to obtain a
new cleanup identity.
- [ ] **Step 7: Run focused GREEN and regressions**
Run:
```bash
node_modules/.bin/vitest run tests/unit/provider-guardian-transaction.test.ts --reporter=default --maxWorkers=1
node_modules/.bin/vitest run tests/unit/provider-output-limiter.test.ts tests/unit/ci-artifact-contract.test.ts --reporter=default --maxWorkers=1
node_modules/.bin/tsc --project tsconfig.node.json
node_modules/.bin/tsc --project tsconfig.test.json
node_modules/.bin/eslint scripts/lib/provider-guardian-protocol.ts scripts/lib/provider-guardian-client.ts scripts/lib/provider-raw-guardian.ts tests/unit/provider-guardian-transaction.test.ts --max-warnings=0
node scripts/verify-documentation-readiness.ts
git diff --check
```
Require focused tests, Node/test TypeScript, affected ESLint, documentation
readiness, and whitespace verification to pass. Live systemd/bwrap fixtures
remain **NOT RUN** under the current approval limit.
- [ ] **Step 8: Review and commit round six**
Confirm the original workspace, `/tmp/task3-integration-mU4L7J2u`, and the
security-finalizer repository are unchanged; only the temporary `node_modules`
symlink is untracked. Commit production/tests/docs together above design commit
`0b1a1db` and report the isolated path, commit SHA, RED evidence, and fresh GREEN
evidence.
@@ -0,0 +1,222 @@
# Provider Evidence Guardian Transaction Design
## Goal
Make one guardian process own the provider evidence filesystem transaction from
raw creation through sealed publication. A supervisor or provider hard death
must leave neither canonical raw evidence nor a guardian-owned sealed temp/final
inode, and the same workspace must be immediately retryable. Only the complete
authenticated `publish -> PUBLISHED -> commit -> EOF` sequence preserves the
canonical sealed artifact.
## Chosen Ownership Boundary
The guardian owns filesystem identity and publication. The supervisor retains
archive, trust, schema, signature, and evidence validation. This avoids two
unsafe alternatives:
- Keeping raw-only guardianship would leave the sealed rename-to-supervisor-death
cleanup gap.
- Moving evidence validation into the guardian would duplicate security policy
and make the helper unnecessarily privileged and complex.
The client opens the canonical `provider-evidence/untrusted` and
`provider-evidence` directories with `O_DIRECTORY|O_NOFOLLOW` before spawning
the guardian. Those identity-pinned directory descriptors are inherited as fd 3
and fd 4; they are never encoded in argv or the environment. The canonical raw
and final leaves are fixed by provider kind. Before spawn, the client exclusively
creates a nonce-private raw staging inode and the nonce-private sealed temp
inode, records both identities, and inherits their open descriptors as fd 5 and
fd 6. The guardian validates each inherited descriptor against its
descriptor-relative pathname, then publishes raw staging to the fixed raw leaf
with a no-replace hard link. Startup recovery authority is therefore an inode
identity acquired before spawn, never an identity discovered later from an
expected pathname. Both processes perform transaction I/O through
`/proc/self/fd/<fd>/<leaf>` so pathname substitution cannot redirect creation or
recovery into another directory. No raw path, sealed path, identity, provider
command, or credential is an argv value. The initial request contains only the
version, kind, random control nonce, and absolute deadline.
## Transaction Invariants
1. Before a complete valid guard frame, the guardian has not published a
canonical filesystem object. The client may have allocated only zero-byte,
mode `0600`, nonce-private raw staging and sealed temp inodes whose identities
it already holds. EOF with no frame or a partial frame removes both allocations.
2. Before spawning, the client validates that both pinned descriptors name the
expected canonical directories; computes fixed raw/final leaves and
nonce-private raw-staging/sealed-temp leaves; and creates the private leaves
with `O_CREAT|O_EXCL|O_NOFOLLOW`, mode `0600`, size zero, and link count one.
It retains both handles and inherits them as fd 5/fd 6 in addition to directory
fd 3/fd 4.
3. At bootstrap, the guardian fstats fd 5/fd 6, reads only their
`/proc/self/fd/5|6` link targets, and accepts each basename only when it is a
direct child of the canonical fd 3/fd 4 directory and matches the exact
provider-kind/32-lowercase-hex private-leaf grammar. It then requires
descriptor-relative lstat of that basename to match the already-fstat fd
identity, type, mode, size, and link count. This binds a deletion alias to an
inherited identity; it never promotes a pathname-discovered identity to
ownership. The two basenames must encode the same kind and nonce prefix.
4. After guard validation, the guardian verifies that the received kind/nonce
derives those exact bootstrapped private leaves. It verifies fd 5/fd 6 remain
regular zero-byte single-link `0600` files and exactly match the derived
private pathnames. It
uses `link(raw staging, canonical raw)` without replacement, verifies both
names have the inherited raw identity and link count two, unlinks the private
raw name, fsyncs the raw directory, and verifies the canonical raw link count
is one. READY is emitted only after this authority transfer succeeds.
5. READY is authenticated by the request nonce and returns raw dev/inode plus
sealed temp leaf/dev/inode. The supervisor starts the provider only after it
validates this exact bounded response with constant-time nonce equality.
6. The supervisor writes only schema-validated sealed bytes to the temp inode.
It opens with `O_NOFOLLOW`, checks dev/inode before and after writing, applies
mode `0400`, writes the complete bounded bytes, fsyncs, and closes.
7. Publish metadata contains the nonce, sealed dev/inode, byte length, and
SHA-256. The guardian checks the held descriptor and temp pathname identity,
regular-file type, link count, exact mode/size/hash, and canonical final-path
absence.
8. Publication uses atomic no-replace `link(temp, final)`, then unlinks temp and
fsyncs the parent directory. If death occurs between link and unlink, both
names refer to the same pinned inode and both are cleanup candidates.
9. PUBLISHED is authenticated and is emitted only after final pathname identity
and directory durability are verified.
10. Commit is legal only after PUBLISHED. It removes the pinned raw inode and
enters `commitPending`; it does not exit. EOF with no pending bytes is the
sole success terminal and preserves only the sealed final inode.
11. Any data after commit, including a separate later chunk, is a protocol error.
EOF/abort/deadline/protocol failure before the success terminal cleans raw,
temp, and final only when each path still names the guardian-owned identity.
12. If the guardian dies before READY is accepted, the client attempts cleanup
of raw staging, canonical raw, sealed temp, and sealed final aliases using
only the two identities recorded before spawn. A current pathname is never
opened and promoted to an owned identity. A competing canary or same-kind
transaction therefore survives every startup failure.
13. Cleanup attempts every owned target and reports cleanup failures together
with the primary failure using `AggregateError` at the supervisor boundary.
Client fd 3-fd 6 handles and guardian fd 5/fd 6 duplicates are closed on
every success and failure branch; close errors join the same aggregate rather
than skipping remaining cleanup.
Client-side exclusive private allocation is the startup ownership token. The
guardian accepts that token only after inherited-fd, descriptor-relative
pathname, type, mode, size, and link-count checks. Every cleanup identity is
recorded at allocation or authenticated READY; pathname discovery never creates
authority. Creation, validation, link, unlink, chmod, fstat, close, publish,
sync, and cleanup failures all fail closed.
## Bounded Authenticated Protocol
Every control or acknowledgement message is a four-byte big-endian length plus
canonical JSON with an exact ordered field set, strict UTF-8, no NUL, and a
total payload bound. Unknown, duplicate, reordered, oversized, truncated, or
trailing fields are rejected.
The state sequence is:
```text
guard -> READY(raw identity, sealed temp identity)
-> publish(size, sha256, sealed identity)
-> PUBLISHED(sealed identity)
-> commit
-> EOF success
```
All messages carry the same 32-byte random nonce. READY and PUBLISHED are
validated with `timingSafeEqual`; publish and commit are authenticated the same
way. Commit merely changes state, so a byte delivered in a later chunk before
EOF remains observable and causes fail-closed cleanup.
The maximum initial lease is the provider wall timeout plus a fixed ten-minute
post-processing allowance. The provider timeout remains bounded at 30 minutes,
so the guardian maximum is 40 minutes. Near-provider-timeout tests must show
that valid publication still has post-processing time, while an expired lease
cleans all owned objects.
## Supervisor and Scope Exit Ownership
The lease exposes raw/temp/final identities, `publish(bytes)`, `commit()`,
`abort()`, and a non-rejecting premature-exit promise. The client knows all
possible leaves and both startup identities before spawn and retains its pinned
directory and private-file handles until the lease terminates. Before READY it
cleans only aliases that still match those recorded identities. After READY it
checks the guardian response against the same identities and fallback-cleans
raw, temp, and final by identity if the guardian dies.
Provider waiting owns an explicit `scopeActive` latch. A guardian exit starts
whole-scope kill and collection only while that latch is true. Once the scope
completion path has collected the unit, the callback records a lifecycle error
but cannot start an unawaited kill. Publication and terminal commit observe the
guardian exit through their normal awaited failure path and clean sealed state.
Provider stdout and stderr are untrusted secret-bearing byte streams. The
supervisor counts and bounds them for resource enforcement but never forwards
their raw bytes into supervisor/CI stdout or stderr, on either success or
failure. Functional provider assertions use signed evidence or a non-log side
channel. Sealing/output I/O is allowed to settle; the design does not claim
OS-level cancellation. `GITHUB_OUTPUT` is a runner-owned regular file. After
output append succeeds, commit makes the guardian remove raw and EOF completes
the transaction.
Guardian diagnostics are best-effort only. A closed stderr or control descriptor
must not turn a fail-closed branch into a resolved operation or exit zero:
diagnostic and fd-close failures are absorbed after cleanup, and a nonzero exit
or requested fatal signal is issued unconditionally.
## Failure and Recovery
- No/partial guard EOF: no canonical raw or sealed object is published. The
guardian removes both nonce-private allocations through aliases that bootstrap
already bound to inherited fd identities, without needing kind/nonce from a
complete control frame.
- A competing canonical raw canary or another same-kind attempt causes
no-replace link failure. The loser removes only its private identities and
never removes the winner or canary.
- Guardian death after linking raw but before READY: the client uses its
pre-recorded raw identity to clean both private and canonical aliases and its
pre-recorded sealed identity for temp/final aliases, then retries the same
workspace immediately.
- Parent death after creation but before READY: stdout/control pipe failure or
EOF makes the still-running guardian clean both owned objects.
- Guardian death after READY: the supervisor knows raw and sealed identities and
cleans raw, temp, and final fallbacks.
- Supervisor death after PUBLISHED: guardian EOF cleans raw and the published
final inode, including the link/unlink intermediate state.
- Publish or commit race: serialized guardian state completes the current file
operation, then applies EOF/protocol failure cleanup; success requires clean
EOF after commitPending.
- Cleanup failure: remaining targets are still attempted and every error is
preserved; PASS is impossible.
There is one bounded crash window before spawn: if the client itself is killed
after private allocation but before the guardian is created, zero-byte `0600`
nonce-private leaves can remain. They contain no provider or credential bytes
and cannot occupy the fixed canonical raw/final names, so they do not block an
immediate same-kind retry. Automatic pathname sweeping is intentionally omitted
because an unproven stale pathname is not deletion authority.
After each observable managed-process failure, tests require canonical raw,
private staging/temp, canonical final, guardian, and provider cgroup residual
counts to be zero before retrying the same workspace successfully. The
documented pre-spawn client hard-death window is the sole residual exception.
## Verification
Real-process tests cover no/partial frames, a competing raw canary, same-kind
concurrency, guardian `SIGKILL` after raw link but before READY followed by
same-workspace retry, parent death around READY, valid
READY identities, EOF/deadline cleanup, publish/PUBLISHED, post-scope guardian
death, supervisor death after publication, commit trailing bytes in a later
chunk, closed-stderr fail-closed termination, near-timeout publication, and no
residual guardian/files. A provider that successfully prints a supplied
credential is verified not to expose it through supervisor stdout/stderr. Live fixtures
also specify active-scope guardian kill, detached-child external marker/raw
append suppression, cgroup collection, and same-workspace retry. Live
systemd/bwrap execution remains explicitly unverified when the approval limit
prevents running it.
The external-canary regression waits for a test guardian spawn marker before
creating the fixed raw file, proving that the initial absence check has already
completed. The fixed raw bytes and dev/inode must remain unchanged after startup
rejection. The pre-READY link regression watches only the fixed raw basename,
kills the exact direct child on that link event, and requires identity-bound
cleanup plus an immediate same-workspace retry.