diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cae5252 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Build-time inputs (§6.1). These are compiled into the bundle by Vite, so +# everything here is public by definition. Never put a secret in this file or in +# any `.env*` file: a frontend has no confidential storage, and a value that +# reaches the browser has been published. +# +# Runtime configuration — API endpoints, auth mode, telemetry, capability +# switches — is NOT here. It lives in `config/runtime/.json` and is +# materialized into `dist/config.json` at build time, so it can be changed +# without rebuilding. See docs/architecture/layers.md. +# +# Copy to `.env.local` (git-ignored) to override locally. + +# Identifies the build in release manifests and the runtime document. +# CI supplies the real value; a developer build falls back to "local-build". +VITE_BUILD_ID=local-build + +# Source revision the bundle was produced from. +VITE_COMMIT_SHA=local + +# Sub-path the app is served under. Must start and end with "/". +# Feeds the router, the Service Worker scope and Vite's asset base together. +VITE_ROUTER_BASE_PATH=/ + +# Where the browser fetches the runtime document from at boot. +VITE_RUNTIME_CONFIG_URL=/config.json diff --git a/.gitignore b/.gitignore index e6b3beb..2269a05 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ artifacts/storybook/ artifacts/tests/storybook/ artifacts/tests/visual/ !artifacts/**/.gitkeep + +# Local environment overrides. `.env.example` is the tracked template; every +# other `.env*` file is a developer's own machine and never enters the repo. +.env +.env.* +!.env.example diff --git a/config/ci/gates.json b/config/ci/gates.json index d6d806a..1b36ea9 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -472,6 +472,11 @@ "id": "check-ci", "script": "check:ci", "expect": "pass" + }, + { + "id": "check-release-admission", + "script": "check:release-admission", + "expect": "pass" } ], "artifactSchemas": [ @@ -732,6 +737,12 @@ "id": "sarif-secret-scan", "kind": "sarif", "maxBytes": 67108864 + }, + { + "id": "json-deployment-admission", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "deployment-admission" } ], "artifacts": [ @@ -1602,6 +1613,21 @@ "producerCommandIds": [ "check-ci" ] + }, + { + "id": "artifact-artifacts-release-deployment-admission-json", + "path": "artifacts/release/deployment-admission.json", + "schemaId": "json-deployment-admission", + "production": "command-generated", + "producerCommandIds": [ + "check-release-admission" + ] + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-027-txt", + "path": "artifacts/quality/gates/FE-GATE-027.txt", + "schemaId": "text", + "production": "runner-generated" } ], "gates": [ @@ -2049,6 +2075,18 @@ "artifact-artifacts-performance-lab-json" ], "retentionClassId": "release-coherence" + }, + { + "id": "FE-GATE-027", + "name": "release-admission", + "commandIds": [ + "check-release-admission" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-027-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-release-deployment-admission-json" + ], + "retentionClassId": "release-coherence" } ], "stages": [ @@ -2083,7 +2121,8 @@ "FE-GATE-014", "FE-GATE-015", "FE-GATE-019", - "FE-GATE-026" + "FE-GATE-026", + "FE-GATE-027" ] }, { diff --git a/config/runtime/development.json b/config/runtime/development.json new file mode 100644 index 0000000..02901fe --- /dev/null +++ b/config/runtime/development.json @@ -0,0 +1,16 @@ +{ + "APP_ENV": "development", + "API_BASE_URL": "https://api.dev.example.com/", + "REQUEST_TIMEOUT_MS": 15000, + "MAX_RETRY_ATTEMPTS": 2, + "TELEMETRY_ENABLED": false, + "AUTH_MODE": "external", + "CONFIG_SCHEMA_VERSION": "2.0", + "RELEASE_MANIFEST_URL": "/release-manifest.json", + "CAPABILITY_OVERRIDES": { + "REALTIME": "DEFAULT", + "WEB_WORKER": "DEFAULT", + "SERVICE_WORKER": "DEFAULT", + "OFFLINE_COMMANDS": "DEFAULT" + } +} diff --git a/config/runtime/local.json b/config/runtime/local.json new file mode 100644 index 0000000..38c616b --- /dev/null +++ b/config/runtime/local.json @@ -0,0 +1,16 @@ +{ + "APP_ENV": "local", + "API_BASE_URL": "http://localhost:8080/", + "REQUEST_TIMEOUT_MS": 10000, + "MAX_RETRY_ATTEMPTS": 2, + "TELEMETRY_ENABLED": false, + "AUTH_MODE": "demo", + "CONFIG_SCHEMA_VERSION": "2.0", + "RELEASE_MANIFEST_URL": "/release-manifest.json", + "CAPABILITY_OVERRIDES": { + "REALTIME": "DEFAULT", + "WEB_WORKER": "DEFAULT", + "SERVICE_WORKER": "DEFAULT", + "OFFLINE_COMMANDS": "DEFAULT" + } +} diff --git a/config/runtime/production.json b/config/runtime/production.json new file mode 100644 index 0000000..87943ce --- /dev/null +++ b/config/runtime/production.json @@ -0,0 +1,17 @@ +{ + "APP_ENV": "production", + "API_BASE_URL": "https://api.example.com/", + "REQUEST_TIMEOUT_MS": 10000, + "MAX_RETRY_ATTEMPTS": 2, + "TELEMETRY_ENABLED": true, + "TELEMETRY_ENDPOINT": "https://telemetry.example.com/v1/events", + "AUTH_MODE": "external", + "CONFIG_SCHEMA_VERSION": "2.0", + "RELEASE_MANIFEST_URL": "/release-manifest.json", + "CAPABILITY_OVERRIDES": { + "REALTIME": "DEFAULT", + "WEB_WORKER": "DEFAULT", + "SERVICE_WORKER": "DEFAULT", + "OFFLINE_COMMANDS": "DEFAULT" + } +} diff --git a/config/runtime/staging.json b/config/runtime/staging.json new file mode 100644 index 0000000..b7d7887 --- /dev/null +++ b/config/runtime/staging.json @@ -0,0 +1,17 @@ +{ + "APP_ENV": "staging", + "API_BASE_URL": "https://api.staging.example.com/", + "REQUEST_TIMEOUT_MS": 10000, + "MAX_RETRY_ATTEMPTS": 2, + "TELEMETRY_ENABLED": true, + "TELEMETRY_ENDPOINT": "https://telemetry.staging.example.com/v1/events", + "AUTH_MODE": "external", + "CONFIG_SCHEMA_VERSION": "2.0", + "RELEASE_MANIFEST_URL": "/release-manifest.json", + "CAPABILITY_OVERRIDES": { + "REALTIME": "DEFAULT", + "WEB_WORKER": "DEFAULT", + "SERVICE_WORKER": "DEFAULT", + "OFFLINE_COMMANDS": "DEFAULT" + } +} diff --git a/package.json b/package.json index c91469a..0c62356 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "dev": "vite", "build": "node scripts/build-frontend.ts", + "build:profile": "node scripts/generate-runtime-config.ts", "build:release-candidate": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security && corepack pnpm verify:release && node scripts/verify-supply-chain-artifacts.ts && node scripts/create-release-candidate.ts", "preview": "vite preview", "lint": "eslint src scripts tests recipes .storybook vite.config.ts vitest.config.ts playwright*.config.ts --max-warnings=0", @@ -21,6 +22,7 @@ "check:i18n:fixture": "node scripts/check-i18n.ts --fixture", "check:adapter-inventory": "node scripts/check-adapter-inventory.ts", "check:remediation-ledger": "node scripts/check-remediation-ledger.ts", + "check:release-admission": "node scripts/check-release-admission.ts", "check:diagnostics": "node scripts/check-diagnostics.ts", "check:diagnostics:fixture": "node scripts/check-diagnostics.ts --fixture", "check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test && corepack pnpm check:types:recipes && corepack pnpm check:types:web-worker && corepack pnpm check:types:service-worker", diff --git a/scripts/build-frontend.ts b/scripts/build-frontend.ts index 26657cc..d3f670e 100644 --- a/scripts/build-frontend.ts +++ b/scripts/build-frontend.ts @@ -13,12 +13,19 @@ import { INSTALLED_RUNTIME_CAPABILITIES } from "../src/features/installed-runtim * 1. clean dist and .generated/frontend-runtime * 2. generate contractSet and build-info source * 3. Vite app build (emptyOutDir = true) - * 4. scan app dist and generate the static asset source - * 5. ACTIVE only: Vite Service Worker build (emptyOutDir = false) - * 6. generate Release Manifest V2 and the build manifest + * 4. materialize dist/config.json from the declared APP_PROFILE + * 5. scan app dist and generate the static asset source + * 6. ACTIVE only: Vite Service Worker build (emptyOutDir = false) + * 7. generate Release Manifest V2 and the build manifest * - * Steps 4 and 5 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES` + * Steps 5 and 6 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES` * and `null`: those modes never run an active worker build. + * + * Step 4 has to follow the Vite build and precede the asset scan. Vite copies + * `public/` verbatim, so without it every build — including a production one — + * ships the local runtime document; and the Service Worker hashes the emitted + * `config.json`, so the profile must be in place before that inventory is + * taken. */ const selection = INSTALLED_RUNTIME_CAPABILITIES.serviceWorker; @@ -45,10 +52,13 @@ run("node", ["scripts/generate-contract-set.ts"]); // 3. app build run("npx", ["vite", "build"]); +// 4. runtime config for the declared profile +run("node", ["scripts/generate-runtime-config.ts"]); + if (buildsActiveWorker) { - // 4. hashed asset inventory + // 5. hashed asset inventory run("node", ["scripts/generate-service-worker-assets.ts", "dist"]); - // 5. service worker build + // 6. service worker build run("npx", ["vite", "build", "--config", "vite.service-worker.config.ts"]); } else { process.stdout.write( diff --git a/scripts/check-release-admission.ts b/scripts/check-release-admission.ts new file mode 100644 index 0000000..ff85207 --- /dev/null +++ b/scripts/check-release-admission.ts @@ -0,0 +1,108 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import process from "node:process"; + +import { + DEPLOYMENT_TARGETS, + findAdmissionViolations, + isDeploymentTarget, + type AdmissionInput, +} from "../src/contracts/deployment-admission.ts"; +import { parseRuntimeConfigArtifact } from "../src/contracts/release-artifacts.ts"; + +/** + * §6.4 / FE-GATE-027. Refuses to admit an artifact to an environment it was not + * built for. + * + * Release coherence already proves the artifacts agree with each other. It + * cannot prove they belong in production, because a local build is coherent + * with itself: `APP_ENV: local`, `AUTH_MODE: demo` and a loopback API pass + * every existing gate. This gate closes that by making the destination an + * explicit, declared input and refusing anything that does not match it. + * + * It fails closed in both directions. An undeclared destination is a refusal, + * not a default, so an artifact can never be admitted by omission; and every + * rule is stated as a reason to refuse, so an unreadable field cannot pass. + */ + +const RUNTIME_CONFIG_PATH = "dist/config.json"; +const RECORD_PATH = "artifacts/release/deployment-admission.json"; + +async function main(): Promise { + const declared = process.env["RELEASE_TARGET"]; + if (!isDeploymentTarget(declared)) { + process.stderr.write( + "release admission refused: RELEASE_TARGET must be declared as one of " + + `${DEPLOYMENT_TARGETS.join(", ")}; received ${ + declared === undefined ? "nothing" : declared + }.\n` + + "An artifact is never admitted by default — name the environment it is for.\n", + ); + process.exitCode = 1; + return; + } + + let document: unknown; + try { + document = JSON.parse(await readFile(RUNTIME_CONFIG_PATH, "utf8")); + } catch (error) { + process.stderr.write( + `release admission refused: ${RUNTIME_CONFIG_PATH} is unreadable: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + process.exitCode = 1; + return; + } + + let config: AdmissionInput; + try { + config = parseRuntimeConfigArtifact(document) as AdmissionInput; + } catch (error) { + process.stderr.write( + `release admission refused: ${RUNTIME_CONFIG_PATH} is not a valid runtime config: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + process.exitCode = 1; + return; + } + + const violations = findAdmissionViolations(declared, config); + await mkdir("artifacts/release", { recursive: true }); + await writeFile( + RECORD_PATH, + `${JSON.stringify( + { + schemaVersion: 1, + target: declared, + appEnv: config.APP_ENV, + authMode: config.AUTH_MODE, + apiBaseUrl: config.API_BASE_URL, + buildId: config.BUILD_ID ?? null, + releaseId: config.RELEASE_ID ?? null, + status: violations.length === 0 ? "ADMITTED" : "REFUSED", + violations, + }, + null, + 2, + )}\n`, + "utf8", + ); + + if (violations.length > 0) { + process.stderr.write( + `release admission refused for ${declared}:\n${violations + .map((violation) => ` ${violation.field}: ${violation.reason}`) + .join("\n")}\n`, + ); + process.exitCode = 1; + return; + } + process.stdout.write( + `release admission: ${declared} ADMITTED ` + + `(APP_ENV=${config.APP_ENV}, AUTH_MODE=${config.AUTH_MODE}, ` + + `API=${config.API_BASE_URL}); record at ${RECORD_PATH}\n`, + ); +} + +await main(); diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts index dff8946..37be0d0 100644 --- a/scripts/contracts/ci-gates.ts +++ b/scripts/contracts/ci-gates.ts @@ -247,6 +247,7 @@ const artifactSchemaSchema = z.discriminatedUnion("kind", [ "provider-provenance", "provider-verification", "ci-contract-report", + "deployment-admission", ]), }) .strict(), diff --git a/scripts/contracts/release-artifacts.ts b/scripts/contracts/release-artifacts.ts index 24e40cc..c0e5ae9 100644 --- a/scripts/contracts/release-artifacts.ts +++ b/scripts/contracts/release-artifacts.ts @@ -670,6 +670,27 @@ export const labPerformanceArtifactSchema = z }) .strict(); +/** + * FE-GATE-027. The record of which environment an artifact was admitted to, and + * every reason it was refused. Refusals are kept in the artifact so a rejected + * promotion leaves evidence rather than only a non-zero exit code. + */ +export const deploymentAdmissionArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + target: z.enum(["local", "development", "staging", "production"]), + appEnv: z.enum(["local", "development", "staging", "production"]), + authMode: z.enum(["external", "demo"]), + apiBaseUrl: nonEmptyString, + buildId: nonEmptyString.nullable(), + releaseId: nonEmptyString.nullable(), + status: z.enum(["ADMITTED", "REFUSED"]), + violations: z.array( + z.object({ field: nonEmptyString, reason: nonEmptyString }).strict(), + ), + }) + .strict(); + export const releaseVerificationArtifactSchema = z .object({ schemaVersion: z.literal(1), diff --git a/scripts/generate-runtime-config.ts b/scripts/generate-runtime-config.ts new file mode 100644 index 0000000..9ca4ed2 --- /dev/null +++ b/scripts/generate-runtime-config.ts @@ -0,0 +1,93 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +import { + DEPLOYMENT_TARGETS, + isDeploymentTarget, + type DeploymentTarget, +} from "../src/contracts/deployment-admission.ts"; +import { runtimeConfigV2ArtifactSchema } from "../src/contracts/release-artifacts.ts"; + +/** + * §6.4. Materializes `dist/config.json` from the profile the build declares. + * + * `public/` is copied verbatim into `dist/`, so before this step the runtime + * document that shipped with every build was the local one — `APP_ENV: local`, + * `AUTH_MODE: demo`, a loopback API — regardless of what the build was for. + * The profile is the source of truth instead, and the only values a deployment + * may inject are the ones it actually owns: its endpoints and its identity. + * + * The result is validated against the same V2 schema the browser will apply, so + * an override cannot produce a document that only fails at boot. + */ + +const PROFILE_DIRECTORY = "config/runtime"; +const OUTPUT_PATH = "dist/config.json"; + +/** + * Deployment-supplied values. Everything else is fixed by the profile so a + * deployment cannot quietly widen what was reviewed. + */ +const OVERRIDES = Object.freeze({ + API_BASE_URL: "RUNTIME_API_BASE_URL", + TELEMETRY_ENDPOINT: "RUNTIME_TELEMETRY_ENDPOINT", +} as const); + +export async function generateRuntimeConfig( + target: DeploymentTarget, + environment: NodeJS.ProcessEnv = process.env, +): Promise> { + const profilePath = path.join(PROFILE_DIRECTORY, `${target}.json`); + const source: unknown = JSON.parse(await readFile(profilePath, "utf8")); + if (source === null || typeof source !== "object" || Array.isArray(source)) { + throw new TypeError(`${profilePath}: runtime profile must be an object`); + } + const draft: Record = { ...(source as Record) }; + if (draft["APP_ENV"] !== target) { + throw new Error( + `${profilePath}: declares APP_ENV ${String(draft["APP_ENV"])}, expected ${target}`, + ); + } + for (const [field, variable] of Object.entries(OVERRIDES)) { + const supplied = environment[variable]; + if (supplied !== undefined && supplied !== "") draft[field] = supplied; + } + const buildId = environment["VITE_BUILD_ID"] ?? "local-build"; + const releaseId = environment["RELEASE_ID"] ?? "local-release"; + draft["BUILD_ID"] = buildId; + draft["RELEASE_ID"] = releaseId; + + const parsed = runtimeConfigV2ArtifactSchema.safeParse(draft); + if (!parsed.success) { + const issues = parsed.error.issues + .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) + .join("\n "); + throw new Error(`${profilePath}: runtime config is invalid\n ${issues}`); + } + return draft; +} + +function resolveTarget(environment: NodeJS.ProcessEnv): DeploymentTarget { + const declared = environment["APP_PROFILE"] ?? "local"; + if (!isDeploymentTarget(declared)) { + throw new Error( + `APP_PROFILE must be one of ${DEPLOYMENT_TARGETS.join(", ")}; received ${declared}`, + ); + } + return declared; +} + +async function main(): Promise { + const target = resolveTarget(process.env); + const config = await generateRuntimeConfig(target); + await writeFile(OUTPUT_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + process.stdout.write( + `runtime config: ${target} profile written to ${OUTPUT_PATH} ` + + `(APP_ENV=${String(config["APP_ENV"])}, AUTH_MODE=${String(config["AUTH_MODE"])})\n`, + ); +} + +if (process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]))) { + await main(); +} diff --git a/scripts/lib/ci-artifact-validator.ts b/scripts/lib/ci-artifact-validator.ts index 9693bdf..2c30946 100644 --- a/scripts/lib/ci-artifact-validator.ts +++ b/scripts/lib/ci-artifact-validator.ts @@ -35,6 +35,7 @@ import { registryGovernanceRunArtifactSchema, registryCompatibilityFixturesArtifactSchema, registrySnapshotArtifactSchema, + deploymentAdmissionArtifactSchema, releaseVerificationArtifactSchema, reproducibleBuildArtifactSchema, runbookRecordArtifactSchema, @@ -238,6 +239,7 @@ const executableJsonSchemas: Readonly> = "provider-provenance": provenanceProviderAttestationSchema, "provider-verification": providerVerificationArtifactSchema, "ci-contract-report": ciContractReportSchema, + "deployment-admission": deploymentAdmissionArtifactSchema, }); export function hasCiArtifactSemanticValidator( diff --git a/scripts/lib/ci-candidate-archive.ts b/scripts/lib/ci-candidate-archive.ts index db5cfcd..741904c 100644 --- a/scripts/lib/ci-candidate-archive.ts +++ b/scripts/lib/ci-candidate-archive.ts @@ -5,7 +5,6 @@ import type { FileHandle } from "node:fs/promises"; import { lstat, mkdir, - mkdtemp, open, readFile, readdir, @@ -28,6 +27,10 @@ import { assertSafePublishLeaf, ensureSafePublishDirectory, } from "./ci-gate-log.ts"; +import { + makePrivateTemporaryDirectory, + withPrivateUmask, +} from "./private-filesystem.ts"; const MAX_ARCHIVE_BYTES = 268_435_456; const MAX_CANDIDATE_FILES = 4_096; @@ -147,11 +150,11 @@ export async function verifyCiCandidateArchive( path.dirname(extractionTarget), ); await assertSafePublishLeaf(extractionTarget, input.extractTo); - extractionRoot = await mkdtemp( + extractionRoot = makePrivateTemporaryDirectory( path.join(path.dirname(extractionTarget), `.${path.basename(extractionTarget)}.verified-`), ); } else { - extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-candidate-archive-")); + extractionRoot = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-candidate-archive-")); } let published = false; try { @@ -221,7 +224,7 @@ export async function verifyCapturedCiCandidateArchive( throw new Error("candidate archive SHA-256 mismatch"); } const captured = await materializeCapturedArchive(archive); - const extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-captured-candidate-")); + const extractionRoot = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-captured-candidate-")); try { const manifest = preflightArchiveHandle(captured.handle); extractArchiveHandle(captured.handle, extractionRoot); @@ -318,25 +321,33 @@ function preflightArchiveHandle(archiveHandle: FileHandle): ReleaseCandidateMani } function extractArchiveHandle(archiveHandle: FileHandle, extractionRoot: string): void { - const extracted = spawnSync( - TAR_EXECUTABLE, - [ - "--extract", - "--gzip", - "--file", - "/proc/self/fd/3", - "--directory", - extractionRoot, - "--no-same-owner", - "--no-same-permissions", - ], - { - encoding: "utf8", - maxBuffer: 1_048_576, - timeout: 30_000, - env: TAR_ENVIRONMENT, - stdio: ["ignore", "pipe", "pipe", archiveHandle.fd], - }, + // `--no-same-permissions` is what keeps an untrusted archive from choosing + // its own modes, but it hands the decision to the inherited umask instead. + // Under a hardened `umask 077x` tar then creates directories it cannot + // descend into and extraction fails part-way. Pinning the umask for the + // duration makes the extracted tree exactly private, whatever the caller's + // ambient state is. `spawnSync` keeps this window free of interleaved work. + const extracted = withPrivateUmask(() => + spawnSync( + TAR_EXECUTABLE, + [ + "--extract", + "--gzip", + "--file", + "/proc/self/fd/3", + "--directory", + extractionRoot, + "--no-same-owner", + "--no-same-permissions", + ], + { + encoding: "utf8", + maxBuffer: 1_048_576, + timeout: 30_000, + env: TAR_ENVIRONMENT, + stdio: ["ignore", "pipe", "pipe", archiveHandle.fd], + }, + ), ); if (extracted.status !== 0 || extracted.signal || extracted.error) { throw new Error( @@ -592,7 +603,7 @@ function readManifestFromArchive(archiveHandle: FileHandle): ReleaseCandidateMan async function materializeCapturedArchive( archive: Buffer, ): Promise> { - const root = await mkdtemp(path.join(tmpdir(), "ci-captured-archive-")); + const root = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-captured-archive-")); const file = path.join(root, "candidate.tar.gz"); let handle: FileHandle | undefined; try { @@ -601,6 +612,11 @@ async function materializeCapturedArchive( constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600, ); + // `open` subtracts the umask too. The extractor re-opens this file by + // `/proc/self/fd/N` from a child process, and that re-open is a real + // permission check, so a umask-zeroed mode makes `tar` fail to read the + // candidate it was just handed. + await handle.chmod(0o600); await handle.writeFile(archive); await handle.sync(); await unlink(file); diff --git a/scripts/lib/private-filesystem.ts b/scripts/lib/private-filesystem.ts new file mode 100644 index 0000000..edc45a4 --- /dev/null +++ b/scripts/lib/private-filesystem.ts @@ -0,0 +1,36 @@ +import { mkdirSync, mkdtempSync } from "node:fs"; + +/** + * Creation modes that must not depend on the caller's ambient umask. + * + * `mkdir(path, { mode: 0o700 })` and `open(path, ..., 0o600)` are requests, not + * guarantees: the kernel subtracts the process umask from every one of them. A + * runner hardened with `umask 0777` therefore produces directories nobody can + * enter and files nobody can read, and the failure surfaces far from its cause + * — as `tar` failing to mkdir a nested path, or as EACCES opening a staging + * leaf this process created moments earlier. + * + * Release evidence has to be exactly private, so the mode is pinned rather than + * inherited. The pin is held across a synchronous call only: nothing else in + * this process can interleave, so the global umask is never observably changed. + */ +const PRIVATE_UMASK = 0o077; + +export function withPrivateUmask(operation: () => T): T { + const previous = process.umask(PRIVATE_UMASK); + try { + return operation(); + } finally { + process.umask(previous); + } +} + +/** Creates a uniquely named private directory under `prefix`. */ +export function makePrivateTemporaryDirectory(prefix: string): string { + return withPrivateUmask(() => mkdtempSync(prefix)); +} + +/** Creates `target` privately, failing if it already exists. */ +export function makePrivateDirectory(target: string): void { + withPrivateUmask(() => mkdirSync(target, { mode: 0o700 })); +} diff --git a/scripts/lib/promotion-stager.ts b/scripts/lib/promotion-stager.ts index 67fcebe..8571683 100644 --- a/scripts/lib/promotion-stager.ts +++ b/scripts/lib/promotion-stager.ts @@ -6,7 +6,6 @@ import { import { constants } from "node:fs"; import { lstat, - mkdir, open, readdir, rm, @@ -38,6 +37,7 @@ import { } from "./ci-candidate-archive.ts"; import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; +import { makePrivateDirectory } from "./private-filesystem.ts"; export type StagedFile = Readonly<{ @@ -334,6 +334,18 @@ export async function cleanupFinalizedPromotion(input: Readonly<{ await dependencies.beforeRemove?.(); const visibleParent = await lstat(parent); assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity); + // Re-bind the name to the inode before removing anything. + // + // The removals below run through the pinned staging descriptor, so they + // always reach the owned inode even after the name has been re-pointed + // somewhere else. That is safe for the substitute, but it destroys this + // promotion's exact five first and only reports the substitution + // afterwards — a caller that retries then finds a half-emptied staging + // directory and no way to tell a completed cleanup from an interrupted + // one. Detecting the swap here makes the failure total: nothing is + // removed unless the leaf still is what was validated. + assertStagingIdentity(await lstat(descriptorExpected), input.stagingIdentity); + assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity); for (const name of PROMOTED_FILE_NAMES) { await rm(path.join(stagingDescriptorRoot, name), { force: false }); } @@ -456,7 +468,7 @@ export async function publishPrivatePromotionStaging( try { const procMetadata = await stat(descriptorRoot); if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable"); - await mkdir(descriptorStaging, { mode: 0o700 }); + makePrivateDirectory(descriptorStaging); ownsStaging = true; const createdStaging = await lstat(descriptorStaging); if (!createdStaging.isDirectory() || createdStaging.isSymbolicLink()) { diff --git a/scripts/lib/provider-cgroup.ts b/scripts/lib/provider-cgroup.ts index 3e94e1e..74ae58d 100644 --- a/scripts/lib/provider-cgroup.ts +++ b/scripts/lib/provider-cgroup.ts @@ -112,6 +112,21 @@ export function systemdRunProviderArguments( export type ProviderScopeFrame = Readonly<{ bwrapInput: Buffer; + /** + * The sandboxed command, kept out of the args file on purpose. + * + * `bwrap --args FD` splices the file's options into the option stream, but + * bubblewrap stops at the first non-option and never propagates the command + * back out of the recursive parse. A command written into the args file is + * therefore silently dropped and bubblewrap exits with its usage text, so + * the sandbox is never entered and the provider produces no evidence at all. + * Only the options may be hidden; the command travels on real argv. + * + * Nothing secret lives here: credentials and the provider command reach the + * sandbox through `--setenv` inside the args file, and this vector only ever + * names `prlimit` and a shell that expands `$PROVIDER_COMMAND`. + */ + bwrapCommand: readonly string[]; reportPath: string; reportDev: number; reportIno: number; @@ -126,8 +141,10 @@ export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer { ) { throw new TypeError("provider scope frame is invalid"); } + assertBwrapCommand(input.bwrapCommand); const payload = Buffer.from(JSON.stringify({ bwrapInputBase64: input.bwrapInput.toString("base64"), + bwrapCommand: [...input.bwrapCommand], reportPath: input.reportPath, reportDev: input.reportDev, reportIno: input.reportIno, @@ -138,13 +155,36 @@ export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer { return frame; } +/** + * The command vector bubblewrap will exec. It has to be an absolute executable + * so the sandbox never resolves it through a `PATH` the caller controls. + */ +export function assertBwrapCommand(command: readonly string[]): void { + if ( + !Array.isArray(command) || command.length === 0 || + typeof command[0] !== "string" || !command[0].startsWith("/") || + command.some((argument) => + typeof argument !== "string" || argument.includes("\0"), + ) + ) { + throw new TypeError("provider bwrap command is invalid"); + } +} + export function encodeProviderBwrapInput( - arguments_: readonly string[], + optionArguments: readonly string[], environment: Readonly>, ): Buffer { - if (arguments_.some((argument) => argument.includes("\0"))) { + if (optionArguments.some((argument) => argument.includes("\0"))) { throw new TypeError("provider bwrap argument is invalid"); } + // A bare `--` ends bubblewrap's option stream. Inside an args file that also + // ends the recursive parse, so everything after it is discarded rather than + // executed. Refusing it here keeps the drop from being reintroduced by a + // caller that appends a command to the option list. + if (optionArguments.includes("--")) { + throw new TypeError("provider bwrap options may not terminate the option stream"); + } const entries = Object.entries(environment).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0, ); @@ -155,7 +195,7 @@ export function encodeProviderBwrapInput( } const input = ["--clearenv"]; for (const [name, value] of entries) input.push("--setenv", name, value ?? ""); - input.push(...arguments_); + input.push(...optionArguments); return Buffer.from(`${input.join("\0")}\0`); } diff --git a/scripts/lib/provider-scope-wrapper.ts b/scripts/lib/provider-scope-wrapper.ts index ebe2c16..018be10 100644 --- a/scripts/lib/provider-scope-wrapper.ts +++ b/scripts/lib/provider-scope-wrapper.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; -import { closeSync, createReadStream, writeSync } from "node:fs"; +import { closeSync, writeSync } from "node:fs"; +import { Socket } from "node:net"; import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts"; @@ -10,7 +11,18 @@ let expectedBytes: number | undefined; let provider: ReturnType | undefined; let providerClosed = false; let livenessLost = false; -const liveness = createReadStream("", { fd: 0, autoClose: false }); +/** + * The supervisor keeps this pipe open for the scope's whole life — that is how + * parent loss is observed — and only ever writes one frame into it. + * + * It must be read through libuv's event loop, not through `fs`. An `fs` read + * runs a blocking `read(2)` on a threadpool thread, and on a pipe with a live + * writer that call never returns. Closing the descriptor does not interrupt it, + * so once bubblewrap exits the wrapper deadlocks in `process.exit` waiting to + * join that thread: the scope outlives the provider, the supervisor's wall + * clock expires, and a completed provider is reported as a timeout kill. + */ +const liveness = openLivenessChannel(); liveness.on("data", (chunk: Buffer | string) => { if (provider) { @@ -46,10 +58,17 @@ function launchProvider(payload: Buffer): void { throw new TypeError("provider scope frame identity does not match its launch identity"); } const bwrapInput = Buffer.from(frame.bwrapInputBase64, "base64"); - provider = spawn("/usr/bin/bwrap", ["--args", "0"], { + // The options are read from fd 0; the command must stay on real argv because + // bubblewrap discards whatever follows the option stream inside an args file. + provider = spawn("/usr/bin/bwrap", ["--args", "0", "--", ...frame.bwrapCommand], { detached: true, stdio: ["pipe", "inherit", "inherit"], }); + // bubblewrap can exit before the options are fully written — a usage error + // closes fd 0 immediately. Without this the EPIPE would surface as an + // unhandled stream error and the scope would be torn down as a crash rather + // than reported as the provider exit it is. + provider.stdin?.once("error", () => {}); provider.stdin?.end(bwrapInput); provider.once("error", (error) => finishProvider(frame, null, null, error)); provider.once("close", (code, signal) => finishProvider(frame, code, signal)); @@ -102,18 +121,34 @@ function terminateForProtocolFailure(message: string): void { terminateForParentLoss(); } +function openLivenessChannel(): Socket { + try { + return new Socket({ fd: 0, readable: true, writable: false }); + } catch (error) { + // Without an observable parent this process cannot be trusted to notice + // supervisor loss, and an unsupervised sandbox is worse than no run. + writeSync(2, `provider scope liveness channel is unavailable: ${ + error instanceof Error ? error.message : String(error) + }\n`); + process.exit(125); + } +} + function closeLivenessInput(): void { liveness.removeAllListeners(); liveness.destroy(); try { closeSync(0); } catch (error) { + // `Socket.destroy()` owns the descriptor and closes it itself, so a second + // close is expected rather than exceptional. if (!hasErrorCode(error, "EBADF")) throw error; } } function parseFrame(payload: Buffer): Readonly<{ bwrapInputBase64: string; + bwrapCommand: readonly string[]; reportPath: string; reportDev: number; reportIno: number; @@ -127,14 +162,26 @@ function parseFrame(payload: Buffer): Readonly<{ ) { throw new TypeError("provider scope frame payload is invalid"); } + assertBwrapCommand(value.bwrapCommand); return { bwrapInputBase64: value.bwrapInputBase64, + bwrapCommand: Object.freeze([...value.bwrapCommand]), reportPath: value.reportPath, reportDev: Number(value.reportDev), reportIno: Number(value.reportIno), }; } +function assertBwrapCommand(value: unknown): asserts value is readonly string[] { + if ( + !Array.isArray(value) || value.length === 0 || + typeof value[0] !== "string" || !value[0].startsWith("/") || + value.some((argument) => typeof argument !== "string" || argument.includes("\0")) + ) { + throw new TypeError("provider scope frame command is invalid"); + } +} + function parseReportIdentity(arguments_: readonly string[]): Readonly<{ cpuSeconds: number; reportPath: string; diff --git a/scripts/run-and-validate-provider.ts b/scripts/run-and-validate-provider.ts index c710f77..6b37785 100644 --- a/scripts/run-and-validate-provider.ts +++ b/scripts/run-and-validate-provider.ts @@ -281,14 +281,25 @@ async function runProviderInSandbox( "--remount-ro", "/", "--bind", reportAbsolute, reportAbsolute, "--chdir", workspaceRoot, - "--", "/usr/bin/prlimit", + ); + /** + * Everything above is a bubblewrap *option* and travels in the args file, so + * host paths never reach `/proc//cmdline`. The command below cannot: an + * args file's option stream ends at the first non-option and bubblewrap drops + * the remainder, so a command written there is never executed. It stays on + * real argv, and it is safe there because the provider command and its + * credentials are passed as `--setenv PROVIDER_COMMAND` inside the args file + * and only expanded by the innermost shell. + */ + const bwrapCommand = [ + "/usr/bin/prlimit", "--core=0:0", "--fsize=8388607:8388607", "--nofile=64:64", `--cpu=${cpuSeconds}:${cpuSeconds}`, "--", "/bin/sh", "-eu", "-c", 'exec /bin/sh -eu -c "$PROVIDER_COMMAND"', - ); + ]; const unitName = formatProviderCgroupUnitName( providerKind, process.pid, @@ -301,6 +312,7 @@ async function runProviderInSandbox( }); const scopeFrame = encodeProviderScopeFrame({ bwrapInput, + bwrapCommand, reportPath: reportAbsolute, reportDev: reportIdentity.dev, reportIno: reportIdentity.ino, diff --git a/src/adapters/http/http-execution-v3.ts b/src/adapters/http/http-execution-v3.ts index 9b72b71..9cb8070 100644 --- a/src/adapters/http/http-execution-v3.ts +++ b/src/adapters/http/http-execution-v3.ts @@ -269,6 +269,17 @@ export type ContractHttpExecutorDependencies = Readonly<{ baseUrl: string; /** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */ maxRetryAttempts: number; + /** + * §6.1 / §8.5. `REQUEST_TIMEOUT_MS` from Runtime Config, as a ceiling only. + * + * The contract owns each operation's deadline, because the deadline is part + * of what the operation promises. A deployment still has to be able to hold + * the whole app to something stricter than the sum of its contracts, so this + * value may only shorten a deadline, never extend one — the same direction + * `CAPABILITY_OVERRIDES` is allowed to move in. Absent, contracts stand + * exactly as written. + */ + requestDeadlineCeilingMs?: number; /** The installed profile registry; the executor never invents a profile. */ authProfiles?: InstalledRestAuthProfiles; attachCredentials( @@ -373,6 +384,13 @@ export function createContractHttpExecutor( dependencies.readBoundedResponseBytes ?? readBoundedBytes; const now = dependencies.monotonicNow ?? (() => performance.now()); const random = dependencies.random ?? Math.random; + const deadlineCeilingMs = dependencies.requestDeadlineCeilingMs; + const effectiveDeadlineMs = (contractDeadlineMs: number): number => + typeof deadlineCeilingMs === "number" && + Number.isFinite(deadlineCeilingMs) && + deadlineCeilingMs > 0 + ? Math.min(contractDeadlineMs, deadlineCeilingMs) + : contractDeadlineMs; const sleep = dependencies.sleep ?? ((ms: number, signal: AbortSignal) => @@ -400,7 +418,8 @@ export function createContractHttpExecutor( // §8.5. One monotonic deadline covers credential resolution, encoding, // backoff, every physical attempt, body read and validation. const startedAt = now(); - const deadlineAt = startedAt + policy.totalDeadlineMs; + const totalDeadlineMs = effectiveDeadlineMs(policy.totalDeadlineMs); + const deadlineAt = startedAt + totalDeadlineMs; const remaining = () => deadlineAt - now(); let attemptState: PhysicalAttemptState = "PREPARING"; @@ -451,7 +470,7 @@ export function createContractHttpExecutor( const lifetimeDeadlineTimer = setTimeout(() => { terminalCancellation ??= "DEADLINE"; lifetimeController.abort(); - }, policy.totalDeadlineMs); + }, totalDeadlineMs); let lifetimeDisposed = false; const disposeLifetime = () => { if (lifetimeDisposed) return; diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts index fcbc038..6a653a2 100644 --- a/src/bootstrap/runtime-adapters.ts +++ b/src/bootstrap/runtime-adapters.ts @@ -394,6 +394,11 @@ export async function createRuntimeAdapters( const contractHttp = createContractHttpExecutor({ baseUrl: config.API_BASE_URL, maxRetryAttempts: config.MAX_RETRY_ATTEMPTS, + // §6.1. `REQUEST_TIMEOUT_MS` was declared, validated and then dropped on the + // floor here: every V3 operation ran on its contract's own 10s deadline and + // the deployment dial did nothing. It is a ceiling, so it can tighten an + // operation but never loosen one. + requestDeadlineCeilingMs: config.REQUEST_TIMEOUT_MS, fetcher: context.fetcher, // §7.7. The installed registry owns Fetch credentials and the exact // credential-header sets; this collaborator only supplies proof headers. diff --git a/src/contracts/deployment-admission.ts b/src/contracts/deployment-admission.ts new file mode 100644 index 0000000..0813910 --- /dev/null +++ b/src/contracts/deployment-admission.ts @@ -0,0 +1,160 @@ +import type { RuntimeConfigArtifact } from "./release-artifacts.ts"; + +/** + * §6.4. Which environment an artifact is allowed to be deployed to. + * + * Release coherence answers "do these artifacts describe each other?". It does + * not answer "is this the artifact production should receive?", and the two are + * not the same question: a build whose runtime document says `APP_ENV: local`, + * `AUTH_MODE: demo` and `API_BASE_URL: http://localhost:8080/` is perfectly + * coherent with itself. Without an admission step such a build is a valid + * release candidate, and the only thing standing between it and production is + * that nobody happened to promote it. + * + * Admission is therefore a separate, declared decision: a caller states the + * target it intends, and this module says whether the artifact may go there. + * Every rule below is a refusal, so an unrecognised target or an unreadable + * field fails closed rather than passing by omission. + */ + +export const DEPLOYMENT_TARGETS = Object.freeze([ + "local", + "development", + "staging", + "production", +] as const); + +export type DeploymentTarget = (typeof DEPLOYMENT_TARGETS)[number]; + +/** + * Targets that serve real users over the public internet. They carry the full + * rule set; `local` and `development` only have to be honest about what they + * are. + */ +const PUBLIC_TARGETS: ReadonlySet = new Set([ + "staging", + "production", +]); + +/** Placeholder identifiers a developer build emits when nothing supplied one. */ +const PLACEHOLDER_IDENTIFIERS: ReadonlySet = new Set([ + "local-build", + "local-release", + "local", + "dev", + "unknown", +]); + +export type AdmissionViolation = Readonly<{ field: string; reason: string }>; + +export type AdmissionInput = RuntimeConfigArtifact & + Readonly<{ BUILD_ID?: string; RELEASE_ID?: string }>; + +export function isDeploymentTarget(value: unknown): value is DeploymentTarget { + return ( + typeof value === "string" && + (DEPLOYMENT_TARGETS as readonly string[]).includes(value) + ); +} + +/** + * Every reason this artifact may not be deployed to `target`. An empty list is + * the only admission. + */ +export function findAdmissionViolations( + target: DeploymentTarget, + config: AdmissionInput, +): readonly AdmissionViolation[] { + const violations: AdmissionViolation[] = []; + if (config.APP_ENV !== target) { + violations.push({ + field: "APP_ENV", + reason: `artifact declares ${config.APP_ENV} but is being admitted to ${target}`, + }); + } + if (!PUBLIC_TARGETS.has(target)) return Object.freeze(violations); + + if (config.AUTH_MODE !== "external") { + violations.push({ + field: "AUTH_MODE", + reason: `${target} requires an external identity provider, not ${config.AUTH_MODE}`, + }); + } + violations.push(...publicEndpointViolations("API_BASE_URL", config.API_BASE_URL)); + if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) { + violations.push({ + field: "TELEMETRY_ENDPOINT", + reason: "telemetry is enabled without an endpoint", + }); + } + if (config.TELEMETRY_ENDPOINT) { + violations.push( + ...publicEndpointViolations("TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT), + ); + } + for (const field of ["BUILD_ID", "RELEASE_ID"] as const) { + const value = config[field]; + if (typeof value !== "string" || value.length === 0) { + violations.push({ field, reason: `${target} requires a build identity` }); + continue; + } + if (PLACEHOLDER_IDENTIFIERS.has(value.toLowerCase())) { + violations.push({ + field, + reason: `${value} is a developer placeholder, not a released identity`, + }); + } + } + return Object.freeze(violations); +} + +function publicEndpointViolations( + field: string, + value: string, +): readonly AdmissionViolation[] { + let url: URL; + try { + url = new URL(value); + } catch { + return [{ field, reason: "is not an absolute URL" }]; + } + const violations: AdmissionViolation[] = []; + if (url.protocol !== "https:") { + violations.push({ field, reason: `${url.protocol} is not permitted; use https` }); + } + if (isNonPublicHost(url.hostname)) { + violations.push({ + field, + reason: `${url.hostname} is not reachable from a user's browser`, + }); + } + return violations; +} + +/** + * Hosts that only resolve inside the machine or network that built the + * artifact. A deployment pointing at one of these is a developer configuration + * that escaped, not a production endpoint. + */ +function isNonPublicHost(hostname: string): boolean { + const host = hostname.toLowerCase().replace(/^\[|\]$/gu, ""); + if ( + host === "localhost" || + host.endsWith(".localhost") || + host === "::1" || + host === "0.0.0.0" || + host === "::" + ) { + return true; + } + const octets = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(host); + if (!octets) return false; + const [first, second] = [Number(octets[1]), Number(octets[2])]; + return ( + first === 127 || + first === 10 || + (first === 192 && second === 168) || + (first === 172 && second >= 16 && second <= 31) || + (first === 169 && second === 254) + ); +} diff --git a/tests/unit/ci-artifact-contract.test.ts b/tests/unit/ci-artifact-contract.test.ts index 9b52fd8..9b49c9c 100644 --- a/tests/unit/ci-artifact-contract.test.ts +++ b/tests/unit/ci-artifact-contract.test.ts @@ -842,12 +842,15 @@ describe("candidate archive and provider upload boundaries", () => { expect((await readdir(path.dirname(sealedPath))).filter((leaf) => leaf.includes(".guardian-"))) .toEqual([]); + // `providerWriter` is only a path; the retry has to materialise the script + // it names or the clean-retry claim is proven by a module-not-found error. + await writeFile(fixture.providerWriter, providerV2WriterSource()); const retried = runProviderSupervisor(fixture, { command: `node ${JSON.stringify(fixture.providerWriter)}`, sealedPath, }); expect(retried.status, retried.stderr).toBe(0); - }, 15_000); + }, 20_000); it("collects the whole provider scope when its supervisor dies", async () => { const fixture = await createProviderFixture(); @@ -1272,9 +1275,17 @@ describe("verified promotion finalizer", () => { "provider-verification.json": valid["promotion-verification.json"], "promotion-verification.json": valid["provider-verification.json"], }; + // `{}` never reaches the digest comparison: it fails the report schema + // first, so this case asserted a decode error while claiming to cover the + // digest branch. The substitution has to be a structurally valid report + // that simply is not the one the verification records committed to. + const divergentReport = JSON.parse( + valid["vulnerability-report.json"].toString("utf8"), + ) as Record; + divergentReport.provider = "divergent-provider"; const reportMismatch = { ...valid, - "vulnerability-report.json": Buffer.from("{}\n"), + "vulnerability-report.json": jsonBytes(divergentReport), }; const absent = { ...valid } as Partial; delete absent["provider-verification.json"]; @@ -1513,7 +1524,10 @@ describe("verified promotion finalizer", () => { }), ).rejects.toThrow(/leaf.*identity|staging leaf/u); await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n"); - await expect(readdir(saved)).resolves.toEqual([]); + // A substituted leaf aborts the cleanup before anything is removed, so the + // promotion this call owned is still intact and a retry sees a coherent + // directory rather than a half-emptied one. + expect((await readdir(saved)).sort()).toEqual([...PROMOTED_FILE_NAMES].sort()); }, 30_000); it("never deletes an unrelated leaf substituted after cleanup validation", async () => { @@ -1681,8 +1695,19 @@ async function readCgroupPids(cgroupRoot: string): Promise { async function waitForDirectProviderChildren(supervisorPid: number): Promise { for (let attempt = 0; attempt < 120; attempt += 1) { const childrenPath = `/proc/${supervisorPid}/task/${supervisorPid}/children`; - const children = (await readFile(childrenPath, "utf8")) - .trim().split(/\s+/u).filter(Boolean).map(Number); + let listing: string; + try { + listing = await readFile(childrenPath, "utf8"); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + throw new Error( + "provider supervisor exited before its children could be observed", + { cause: error }, + ); + } + throw error; + } + const children = listing.trim().split(/\s+/u).filter(Boolean).map(Number); const arguments_ = children.flatMap((pid) => { try { return [showProcessArguments(pid)]; diff --git a/tests/unit/deployment-admission.test.ts b/tests/unit/deployment-admission.test.ts new file mode 100644 index 0000000..eb44038 --- /dev/null +++ b/tests/unit/deployment-admission.test.ts @@ -0,0 +1,166 @@ +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + DEPLOYMENT_TARGETS, + findAdmissionViolations, + isDeploymentTarget, + type AdmissionInput, +} from "../../src/contracts/deployment-admission.ts"; +import { runtimeConfigV2ArtifactSchema } from "../../src/contracts/release-artifacts.ts"; +import { generateRuntimeConfig } from "../../scripts/generate-runtime-config.ts"; + +const PRODUCTION_ARTIFACT: AdmissionInput = Object.freeze({ + APP_ENV: "production", + API_BASE_URL: "https://api.example.com/", + REQUEST_TIMEOUT_MS: 10_000, + MAX_RETRY_ATTEMPTS: 2, + TELEMETRY_ENABLED: false, + AUTH_MODE: "external", + CONFIG_SCHEMA_VERSION: "2.0", + RELEASE_MANIFEST_URL: "/release-manifest.json", + BUILD_ID: "20260815.42", + RELEASE_ID: "r-2026.08.15-1", + CAPABILITY_OVERRIDES: Object.freeze({ + REALTIME: "DEFAULT", + WEB_WORKER: "DEFAULT", + SERVICE_WORKER: "DEFAULT", + OFFLINE_COMMANDS: "DEFAULT", + }), +}) as AdmissionInput; + +const LOCAL_ARTIFACT: AdmissionInput = Object.freeze({ + ...PRODUCTION_ARTIFACT, + APP_ENV: "local", + API_BASE_URL: "http://localhost:8080/", + AUTH_MODE: "demo", + BUILD_ID: "local-build", + RELEASE_ID: "local-release", +}) as AdmissionInput; + +describe("deployment admission", () => { + it("admits an artifact only to the environment it declares", () => { + expect(findAdmissionViolations("production", PRODUCTION_ARTIFACT)).toEqual([]); + expect(findAdmissionViolations("local", LOCAL_ARTIFACT)).toEqual([]); + }); + + it("refuses the exact local build that release coherence used to approve", () => { + // The review's strongest reproduction: FE-GATE-015 passed on a build whose + // runtime document was APP_ENV=local / AUTH_MODE=demo / loopback API. Each + // of those is now an independent refusal, so fixing one does not admit it. + const violations = findAdmissionViolations("production", LOCAL_ARTIFACT); + const fields = violations.map((violation) => violation.field); + expect(fields).toContain("APP_ENV"); + expect(fields).toContain("AUTH_MODE"); + expect(fields).toContain("API_BASE_URL"); + expect(fields).toContain("BUILD_ID"); + expect(fields).toContain("RELEASE_ID"); + }); + + it("refuses endpoints a browser on the public internet cannot reach", () => { + for (const host of [ + "http://api.example.com/", + "https://localhost/", + "https://127.0.0.1/", + "https://10.0.0.5/", + "https://192.168.1.10/", + "https://172.16.4.4/", + "https://169.254.169.254/", + "https://[::1]/", + ]) { + const violations = findAdmissionViolations("production", { + ...PRODUCTION_ARTIFACT, + API_BASE_URL: host, + } as AdmissionInput); + expect(violations.map((violation) => violation.field), host).toContain( + "API_BASE_URL", + ); + } + }); + + it("permits a routable public host", () => { + expect( + findAdmissionViolations("production", { + ...PRODUCTION_ARTIFACT, + API_BASE_URL: "https://api.172.16.example.com/", + } as AdmissionInput), + ).toEqual([]); + }); + + it("refuses a placeholder identity on a public target", () => { + for (const buildId of ["local-build", "local", "dev", "unknown", ""]) { + const violations = findAdmissionViolations("production", { + ...PRODUCTION_ARTIFACT, + BUILD_ID: buildId, + } as AdmissionInput); + expect(violations.map((violation) => violation.field), buildId).toContain( + "BUILD_ID", + ); + } + }); + + it("treats an unknown target as not a target at all", () => { + for (const value of ["prod", "PRODUCTION", "", undefined, null, 1]) { + expect(isDeploymentTarget(value), String(value)).toBe(false); + } + for (const target of DEPLOYMENT_TARGETS) { + expect(isDeploymentTarget(target)).toBe(true); + } + }); +}); + +describe("runtime config profiles", () => { + it("ships one valid profile per deployment target", async () => { + const files = (await readdir("config/runtime")).sort(); + expect(files).toEqual( + [...DEPLOYMENT_TARGETS].map((target) => `${target}.json`).sort(), + ); + for (const target of DEPLOYMENT_TARGETS) { + const source: unknown = JSON.parse( + await readFile(path.join("config/runtime", `${target}.json`), "utf8"), + ); + const parsed = runtimeConfigV2ArtifactSchema.safeParse({ + ...(source as Record), + BUILD_ID: "20260815.42", + RELEASE_ID: "r-1", + }); + expect(parsed.success, `${target}: ${JSON.stringify(parsed.error?.issues)}`).toBe( + true, + ); + expect((source as Record)["APP_ENV"]).toBe(target); + } + }); + + it("produces an admissible document for every public target", async () => { + for (const target of ["staging", "production"] as const) { + const config = await generateRuntimeConfig(target, { + VITE_BUILD_ID: "20260815.42", + RELEASE_ID: "r-2026.08.15-1", + }); + expect( + findAdmissionViolations(target, config as unknown as AdmissionInput), + ).toEqual([]); + } + }); + + it("refuses a deployment override that would make the document unservable", async () => { + await expect( + generateRuntimeConfig("production", { + VITE_BUILD_ID: "20260815.42", + RELEASE_ID: "r-1", + RUNTIME_API_BASE_URL: "http://api.example.com/", + }), + ).rejects.toThrow(/runtime config is invalid/u); + }); + + it("keeps a developer build from carrying a released identity by default", async () => { + const config = await generateRuntimeConfig("local", {}); + expect(config["BUILD_ID"]).toBe("local-build"); + expect( + findAdmissionViolations("production", config as unknown as AdmissionInput) + .length, + ).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/http-execution-v3.test.ts b/tests/unit/http-execution-v3.test.ts index 8b5dd5c..8d4b585 100644 --- a/tests/unit/http-execution-v3.test.ts +++ b/tests/unit/http-execution-v3.test.ts @@ -66,6 +66,76 @@ async function flushMicrotasks(): Promise { await Promise.resolve(); } +describe("runtime request deadline ceiling", () => { + /** + * `REQUEST_TIMEOUT_MS` was validated by the runtime config schema and then + * never handed to the V3 executor, so the deployment dial did nothing and + * every operation ran on its contract's own deadline. It is a ceiling: it may + * tighten an operation, never loosen one. + */ + async function settlesWithin( + contractDeadlineMs: number, + ceilingMs: number | undefined, + advanceMs: number, + ): Promise { + vi.useFakeTimers(); + try { + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + ...(ceilingMs === undefined ? {} : { requestDeadlineCeilingMs: ceilingMs }), + attachCredentials: () => ({ kind: "READY", headers: {} }), + // A request that only ever ends by being cut off, so what settles it is + // exactly the deadline under test. + fetcher: (_input, init) => + new Promise((_resolve, reject) => { + const signal = (init as RequestInit | undefined)?.signal; + signal?.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true }, + ); + }), + }); + let settled = false; + const pending = executor + .execute(operation({ deadlineMs: contractDeadlineMs }), {}, { + routeId: ROUTE_ID, + scope, + }) + .then( + () => { settled = true; }, + () => { settled = true; }, + ); + await vi.advanceTimersByTimeAsync(advanceMs); + await flushMicrotasks(); + const observed = settled; + if (!observed) await vi.advanceTimersByTimeAsync(contractDeadlineMs + 1_000); + await pending; + return observed; + } finally { + vi.useRealTimers(); + } + } + + it("applies the tighter of the contract and deployment bounds", async () => { + await expect(settlesWithin(5_000, 500, 800)).resolves.toBe(true); + await expect(settlesWithin(5_000, undefined, 800)).resolves.toBe(false); + }); + + it("never extends a contract deadline", async () => { + await expect(settlesWithin(500, 60_000, 800)).resolves.toBe(true); + }); + + it("ignores a ceiling that is not a usable duration", async () => { + for (const ceiling of [0, -1, Number.NaN]) { + await expect(settlesWithin(5_000, ceiling, 800), String(ceiling)).resolves.toBe( + false, + ); + } + }); +}); + describe("descriptor-driven HTTP execution lifetime", () => { it("normalizes a read-side 429 to the non-applicable effect vocabulary", async () => { const executor = createContractHttpExecutor({ diff --git a/tests/unit/task3-selective-integration.test.ts b/tests/unit/task3-selective-integration.test.ts index cfe7606..a2a86b0 100644 --- a/tests/unit/task3-selective-integration.test.ts +++ b/tests/unit/task3-selective-integration.test.ts @@ -70,8 +70,19 @@ describe("selective Task 3 contract closure", () => { "--signal=SIGKILL", unit, ]); + // `bwrap --args FD` stops parsing at the first non-option and never hands + // the remainder back, so a command placed in the args file is dropped and + // bubblewrap exits with its usage text. Refusing `--` in the option stream + // is what keeps that silent no-sandbox launch from returning. + expect(() => + encodeProviderBwrapInput( + ["--unshare-net", "--", "/usr/bin/prlimit"], + { PROVIDER_COMMAND: command }, + ), + ).toThrow(/terminate the option stream/u); const frame = encodeProviderScopeFrame({ bwrapInput: Buffer.from("private-bwrap-vector\0"), + bwrapCommand: ["/usr/bin/prlimit", "--nofile=64:64", "--", "/bin/sh", "-eu", "-c", 'exec /bin/sh -eu -c "$PROVIDER_COMMAND"'], reportPath: "/exact/report.json", reportDev: 12, reportIno: 34, @@ -81,6 +92,27 @@ describe("selective Task 3 contract closure", () => { Buffer.from("private-bwrap-vector\0").toString("base64"), ); expect(launch.join("\0")).not.toContain("private-bwrap-vector"); + // The command vector rides on real argv, so it must never be able to carry + // the secret that the args file exists to hide. + expect(frame.subarray(4).toString("utf8")).not.toContain(credential); + expect(() => + encodeProviderScopeFrame({ + bwrapInput: Buffer.from("x\0"), + bwrapCommand: [], + reportPath: "/exact/report.json", + reportDev: 12, + reportIno: 34, + }), + ).toThrow(/bwrap command is invalid/u); + expect(() => + encodeProviderScopeFrame({ + bwrapInput: Buffer.from("x\0"), + bwrapCommand: ["prlimit"], + reportPath: "/exact/report.json", + reportDev: 12, + reportIno: 34, + }), + ).toThrow(/bwrap command is invalid/u); }); it("removes only the pinned raw inode during parent-loss cleanup", async () => { diff --git a/vite.config.ts b/vite.config.ts index 5f6ef6a..7fc05d4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,28 @@ import tailwindcss from "@tailwindcss/vite"; import { viteModuleInventoryPlugin } from "./scripts/lib/vite-module-inventory.ts"; +/** + * §6.1. One sub-path, declared once. + * + * `VITE_ROUTER_BASE_PATH` already drives the router and the Service Worker + * scope. Vite's asset `base` was left at its default, so a build served from + * `/app/` emitted root-absolute asset URLs and loaded nothing: the three + * consumers of the same setting disagreed. They are read from one value here so + * a sub-path deployment is coherent or fails at build time. + */ +function routerBasePath(environment: NodeJS.ProcessEnv): string { + const declared = environment["VITE_ROUTER_BASE_PATH"]; + if (declared === undefined || declared === "") return "/"; + if (!declared.startsWith("/") || !declared.endsWith("/")) { + throw new Error( + `VITE_ROUTER_BASE_PATH must start and end with "/"; received ${declared}`, + ); + } + return declared; +} + export default defineConfig({ + base: routerBasePath(process.env), plugins: [react(), tailwindcss(), viteModuleInventoryPlugin()], build: { manifest: true,