24 KiB
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/repoon top ofd781692; 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.execPathand 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
cwdand 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_NPROCenforcement. - 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:
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.
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.
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:
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. -
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.
- 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.
- 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.
- 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.
-
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.
- 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.
- 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.
- 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.
- 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:
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.
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:
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:
// 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:
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.