import { execFile } from "node:child_process"; import { existsSync, readdirSync, realpathSync, statSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; const DEFAULT_GATE_TIMEOUT_MS = 180_000; const MAX_GATE_TIMEOUT_MS = 600_000; const MAX_GATE_STREAM_BYTES = 64 * 1024; const MAX_RESULT_TEXT_CHARS = 2_000; const MAX_RESULTS = 256; const MAX_URLS = 32; const MAX_BUILD_COMMAND_CHARS = 4_096; const MAX_PATH_CHARS = 1_024; const EDIT_SCOPE_TIMEOUT_MS = 30_000; export const FOUNDATION_HOST_CAPABILITIES = ["foundationGate", "validateEditScope"] as const; export type FoundationHostCapability = (typeof FOUNDATION_HOST_CAPABILITIES)[number]; export interface FoundationGateOptions { foundation: string; appSrc: string; buildCmd?: string | null; baseline?: string | null; urls?: string[]; loginUrl?: string | null; /** Ask the host to provide a fresh, manager-owned screenshot directory. */ captureScreenshots?: boolean; } export interface FoundationGateCheckResult { gate: string; pass: boolean; exitCode: number | null; ms: number; stdout: string; stderr: string; } export interface FoundationGateData { summary: { gates: number; failed: number }; results: FoundationGateCheckResult[]; } export interface FoundationGateResult { ok: boolean; exitCode: number | null; signal: NodeJS.Signals | null; timedOut: boolean; aborted: boolean; durationMs: number; data?: FoundationGateData; stdout: string; stderr: string; summary: string; error?: string; command: { file: string; args: string[] }; /** Host-owned screenshot directory, present only for capture runs. */ screenshotDir?: string; /** Bounded absolute PNG paths collected by the host after the gate exits. */ screenshotPaths?: string[]; } export interface FoundationExecResult { stdout: string; stderr: string; exitCode: number | null; signal: NodeJS.Signals | null; timedOut: boolean; aborted: boolean; } export type FoundationExecRunner = ( file: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number; maxBuffer: number; signal?: AbortSignal; shell: false; }, ) => Promise; export interface RunFoundationGateOptions { timeoutMs?: number; signal?: AbortSignal; runner?: FoundationExecRunner; /** Host-selected directory outside the checkout; scripts cannot choose it. */ screenshotDir?: string; } const DEFAULT_FOUNDATION_RUNNER: FoundationExecRunner = (file, args, options) => new Promise((resolveResult) => { execFile( file, args, { cwd: options.cwd, env: options.env, timeout: options.timeoutMs, maxBuffer: options.maxBuffer, signal: options.signal, shell: false, encoding: "utf8", }, (error, stdout, stderr) => { const failure = error as | (Error & { code?: string | number; signal?: NodeJS.Signals; killed?: boolean; }) | null; resolveResult({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), exitCode: failure ? (typeof failure.code === "number" ? failure.code : null) : 0, signal: failure?.signal ?? null, timedOut: failure?.killed === true && failure?.signal === "SIGTERM" && options.signal?.aborted !== true, aborted: options.signal?.aborted === true || failure?.name === "AbortError" || failure?.code === "ABORT_ERR", }); }, ); }); interface ValidatedGateInvocation { scriptPath: string; args: string[]; env: NodeJS.ProcessEnv; } export async function runFoundationGate( cwd: string, input: FoundationGateOptions, options: RunFoundationGateOptions = {}, ): Promise { const startedAt = Date.now(); let invocation: ValidatedGateInvocation; try { invocation = validateGateInvocation(cwd, input, options.screenshotDir); } catch (error) { const message = error instanceof Error ? error.message : String(error); return gateFailure({ command: { file: process.execPath, args: [] }, durationMs: Date.now() - startedAt, summary: "Foundation gate input rejected.", error: message, }); } const command = { file: process.execPath, args: [invocation.scriptPath, ...invocation.args] }; const timeoutMs = normalizeTimeout(options.timeoutMs); let processResult: FoundationExecResult; try { processResult = await (options.runner ?? DEFAULT_FOUNDATION_RUNNER)(command.file, command.args, { cwd, env: invocation.env, timeoutMs, maxBuffer: MAX_GATE_STREAM_BYTES, signal: options.signal, shell: false, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return gateFailure({ command, durationMs: Date.now() - startedAt, summary: "Foundation gate process could not be started.", error: message, }); } const durationMs = Date.now() - startedAt; const stdout = compactText(processResult.stdout, MAX_RESULT_TEXT_CHARS * 2); const stderr = compactText(processResult.stderr, MAX_RESULT_TEXT_CHARS * 2); if (processResult.aborted) { return gateFailure({ ...processResult, command, durationMs, stdout, stderr, summary: "Foundation gate aborted." }); } if (processResult.timedOut) { return gateFailure({ ...processResult, command, durationMs, stdout, stderr, summary: `Foundation gate timed out after ${timeoutMs}ms.`, }); } const parsed = parseGateData(processResult.stdout); if (!parsed.ok) { return gateFailure({ ...processResult, command, durationMs, stdout, stderr, summary: "Foundation gate returned malformed JSON evidence.", error: parsed.error, }); } const data = parsed.data; let screenshotPaths: string[] | undefined; if (input.captureScreenshots) { try { screenshotPaths = collectScreenshotPaths(options.screenshotDir as string); } catch (error) { const message = error instanceof Error ? error.message : String(error); return gateFailure({ ...processResult, command, durationMs, data, stdout, stderr, summary: "Foundation screenshot evidence is missing or invalid.", error: message, screenshotDir: options.screenshotDir, }); } } const passed = processResult.exitCode === 0 && data.summary.failed === 0 && data.results.every((result) => result.pass === true); return { ok: passed, exitCode: processResult.exitCode, signal: processResult.signal, timedOut: false, aborted: false, durationMs, data, stdout, stderr, summary: passed ? `Foundation gates passed (${data.summary.gates} gate(s)).` : `Foundation gates failed (${data.summary.failed}/${data.summary.gates} failed; exit ${String(processResult.exitCode)}).`, command, screenshotDir: input.captureScreenshots ? options.screenshotDir : undefined, screenshotPaths, }; } function validateGateInvocation( cwd: string, input: FoundationGateOptions, screenshotDir: string | undefined, ): ValidatedGateInvocation { if (!input || typeof input !== "object") throw new Error("gate options must be an object"); const repoRoot = realpathSync(cwd); const foundation = validateContainedPath(repoRoot, input.foundation, "foundation", true); const appSrc = validateContainedPath(repoRoot, input.appSrc, "appSrc", true); const baseline = input.baseline ? validateContainedPath(repoRoot, input.baseline, "baseline", true) : undefined; if (input.captureScreenshots !== undefined && typeof input.captureScreenshots !== "boolean") { throw new Error("captureScreenshots must be boolean"); } if (input.captureScreenshots && !screenshotDir) { throw new Error("screenshot capture is unavailable without a host-owned output directory"); } if (input.buildCmd != null) { if (typeof input.buildCmd !== "string") throw new Error("buildCmd must be a string"); if (input.buildCmd.length > MAX_BUILD_COMMAND_CHARS) throw new Error("buildCmd is too long"); if (/\0|\r|\n/.test(input.buildCmd)) throw new Error("buildCmd must be a single NUL-free line"); } const urls = validateUrls(input.urls ?? [], "urls"); const loginUrl = input.loginUrl ? validateUrl(input.loginUrl, "loginUrl") : undefined; const scriptPath = resolve(foundation.absolute, "scripts", "run-foundation-gates.mjs"); ensureRealPathInside(repoRoot, scriptPath, "foundation gate script"); if (!existsSync(scriptPath) || !statSync(scriptPath).isFile()) { throw new Error(`foundation gate script not found: ${input.foundation}/scripts/run-foundation-gates.mjs`); } const args = ["--app-src", appSrc.relative]; if (input.buildCmd) args.push("--build-cmd", input.buildCmd); if (baseline) args.push("--baseline", baseline.relative); for (const url of urls) args.push("--url", url); if (input.captureScreenshots && screenshotDir) args.push("--shot-dir", screenshotDir); args.push("--json"); return { scriptPath, args, env: loginUrl ? { ...process.env, PROPORTIONS_LOGIN_URL: loginUrl } : { ...process.env }, }; } function validateContainedPath( repoRoot: string, value: unknown, label: string, mustExist: boolean, ): { relative: string; absolute: string } { if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`); if (value.length > MAX_PATH_CHARS) throw new Error(`${label} is too long`); if (/\0|\r|\n/.test(value)) throw new Error(`${label} must be NUL-free and single-line`); if (isAbsolute(value)) throw new Error(`${label} must be repo-relative`); const absolute = resolve(repoRoot, value); const rel = relative(repoRoot, absolute).replaceAll("\\", "/"); if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) { throw new Error(`${label} must stay inside the repository`); } if (mustExist) ensureRealPathInside(repoRoot, absolute, label); return { relative: rel, absolute }; } function ensureRealPathInside(repoRoot: string, path: string, label: string): void { if (!existsSync(path)) throw new Error(`${label} does not exist: ${path}`); const real = realpathSync(path); const rel = relative(repoRoot, real); if (rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel)) { throw new Error(`${label} resolves outside the repository`); } } function validateUrls(values: unknown[], label: string): string[] { if (!Array.isArray(values)) throw new Error(`${label} must be an array`); if (values.length > MAX_URLS) throw new Error(`${label} may contain at most ${MAX_URLS} URLs`); return values.map((value, index) => validateUrl(value, `${label}[${index}]`)); } function validateUrl(value: unknown, label: string): string { if (typeof value !== "string" || value.length === 0 || value.length > 2_048) { throw new Error(`${label} must be a non-empty URL no longer than 2048 characters`); } let url: URL; try { url = new URL(value); } catch { throw new Error(`${label} must be a valid URL`); } if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${label} must use http or https`); if (url.username || url.password) throw new Error(`${label} must not contain credentials`); return value; } function parseGateData(stdout: string): { ok: true; data: FoundationGateData } | { ok: false; error: string } { if (Buffer.byteLength(stdout, "utf8") > MAX_GATE_STREAM_BYTES) { return { ok: false, error: `gate stdout exceeds ${MAX_GATE_STREAM_BYTES} bytes` }; } let parsed: unknown; try { parsed = JSON.parse(stdout.trim()); } catch { return { ok: false, error: "gate stdout is not valid JSON" }; } if (!isRecord(parsed) || !isRecord(parsed.summary) || !Array.isArray(parsed.results)) { return { ok: false, error: "gate JSON must contain object summary and array results" }; } const gates = parsed.summary.gates; const failed = parsed.summary.failed; if (!isNonNegativeInteger(gates) || !isNonNegativeInteger(failed) || failed > gates) { return { ok: false, error: "gate summary.gates/failed must be consistent non-negative integers" }; } if (parsed.results.length > MAX_RESULTS) return { ok: false, error: `gate results exceed ${MAX_RESULTS} entries` }; const results: FoundationGateCheckResult[] = []; for (const [index, value] of parsed.results.entries()) { if (!isRecord(value)) return { ok: false, error: `gate results[${index}] must be an object` }; if (typeof value.gate !== "string" || value.gate.length === 0 || value.gate.length > 256) { return { ok: false, error: `gate results[${index}].gate must be a bounded non-empty string` }; } if (typeof value.pass !== "boolean") return { ok: false, error: `gate results[${index}].pass must be boolean` }; if (value.exitCode !== null && !Number.isInteger(value.exitCode)) { return { ok: false, error: `gate results[${index}].exitCode must be an integer or null` }; } if (typeof value.ms !== "number" || !Number.isFinite(value.ms) || value.ms < 0) { return { ok: false, error: `gate results[${index}].ms must be a non-negative number` }; } if (typeof value.stdout !== "string" || typeof value.stderr !== "string") { return { ok: false, error: `gate results[${index}] stdout/stderr must be strings` }; } results.push({ gate: value.gate, pass: value.pass, exitCode: value.exitCode as number | null, ms: value.ms, stdout: compactText(value.stdout, MAX_RESULT_TEXT_CHARS), stderr: compactText(value.stderr, MAX_RESULT_TEXT_CHARS), }); } const actualFailed = results.filter((result) => !result.pass).length; if (gates !== results.length || failed !== actualFailed) { return { ok: false, error: "gate summary does not match the result list" }; } return { ok: true, data: { summary: { gates, failed }, results } }; } function normalizeTimeout(value: number | undefined): number { if (value === undefined) return DEFAULT_GATE_TIMEOUT_MS; if (!Number.isFinite(value) || value <= 0) return DEFAULT_GATE_TIMEOUT_MS; return Math.min(MAX_GATE_TIMEOUT_MS, Math.floor(value)); } function gateFailure( fields: Partial & Pick, ): FoundationGateResult { return { ok: false, exitCode: fields.exitCode ?? null, signal: fields.signal ?? null, timedOut: fields.timedOut ?? false, aborted: fields.aborted ?? false, durationMs: fields.durationMs, data: fields.data, stdout: fields.stdout ?? "", stderr: fields.stderr ?? "", summary: fields.summary, error: fields.error, command: fields.command, screenshotDir: fields.screenshotDir, screenshotPaths: fields.screenshotPaths, }; } function collectScreenshotPaths(directory: string): string[] { if (!existsSync(directory) || !statSync(directory).isDirectory()) { throw new Error("host screenshot directory was not created"); } const pending = [directory]; const screenshots: string[] = []; while (pending.length > 0) { const current = pending.pop() as string; for (const entry of readdirSync(current, { withFileTypes: true })) { const path = resolve(current, entry.name); if (entry.isSymbolicLink()) throw new Error("screenshot directory must not contain symbolic links"); if (entry.isDirectory()) { pending.push(path); continue; } if (entry.isFile() && entry.name.toLowerCase().endsWith(".png")) { screenshots.push(path); if (screenshots.length > 128) throw new Error("screenshot evidence exceeds 128 PNG files"); } } } screenshots.sort(); if (screenshots.length === 0) throw new Error("the Foundation gate produced no PNG screenshots"); return screenshots; } export const FOUNDATION_ALWAYS_DENIED_PREFIXES = [ "third_party/", ".github/", "vendor/", ".git/", ".pi/", "node_modules/", ] as const; const FOUNDATION_ALWAYS_DENIED_FILES = new Set([ ".gitattributes", ".gitignore", ".gitmodules", "AGENTS.md", "CLAUDE.md", ]); export interface ValidateEditScopeOptions { allowGlobs: string[]; denyGlobs?: string[]; } export interface ValidateEditScopeResult { ok: boolean; changedPaths: string[]; violatingPaths: string[]; violations: Array<{ path: string; reason: string }>; summary: string; error?: string; } export type FoundationGitRunner = ( file: string, args: string[], options: { cwd: string; timeoutMs: number; maxBuffer: number; signal?: AbortSignal; shell: false }, ) => Promise<{ stdout: string }>; const DEFAULT_GIT_RUNNER: FoundationGitRunner = (file, args, options) => new Promise((resolveResult, reject) => { execFile( file, args, { cwd: options.cwd, timeout: options.timeoutMs, maxBuffer: options.maxBuffer, signal: options.signal, shell: false, encoding: "utf8", }, (error, stdout) => { if (error) reject(error); else resolveResult({ stdout: String(stdout ?? "") }); }, ); }); export interface ValidateEditScopeHostOptions { signal?: AbortSignal; runner?: FoundationGitRunner; /** Host-owned initial HEAD; includes committed workflow changes in validation. */ baseRef?: string; } export async function collectChangedPaths(cwd: string, options: ValidateEditScopeHostOptions = {}): Promise { const { stdout: rootOutput } = await (options.runner ?? DEFAULT_GIT_RUNNER)("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: EDIT_SCOPE_TIMEOUT_MS, maxBuffer: MAX_GATE_STREAM_BYTES, signal: options.signal, shell: false, }); const repoRoot = rootOutput.trim(); if (!repoRoot) throw new Error("git did not return a repository root"); const runner = options.runner ?? DEFAULT_GIT_RUNNER; const { stdout } = await runner("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: repoRoot, timeoutMs: EDIT_SCOPE_TIMEOUT_MS, maxBuffer: MAX_GATE_STREAM_BYTES, signal: options.signal, shell: false, }); const paths = new Set(parsePorcelainV1Z(stdout)); if (options.baseRef) { const { stdout: committed } = await runner( "git", ["diff", "--name-status", "-z", "--find-renames", "--find-copies", `${options.baseRef}...HEAD`, "--"], { cwd: repoRoot, timeoutMs: EDIT_SCOPE_TIMEOUT_MS, maxBuffer: MAX_GATE_STREAM_BYTES, signal: options.signal, shell: false, }, ); for (const path of parseNameStatusZ(committed)) paths.add(path); } return [...paths].sort(); } export function parsePorcelainV1Z(porcelain: string): string[] { const tokens = porcelain.split("\0"); const paths = new Set(); for (let index = 0; index < tokens.length; index++) { const token = tokens[index]; if (!token) continue; if (token.length < 4 || token[2] !== " ") throw new Error("malformed git porcelain record"); const status = token.slice(0, 2); paths.add(normalizeChangedPath(token.slice(3))); if (status.includes("R") || status.includes("C")) { const secondPath = tokens[++index]; if (!secondPath) throw new Error("malformed git rename/copy record"); paths.add(normalizeChangedPath(secondPath)); } } return [...paths].sort(); } export function parseNameStatusZ(output: string): string[] { const tokens = output.split("\0"); const paths = new Set(); for (let index = 0; index < tokens.length; index++) { const status = tokens[index]; if (!status) continue; if (!/^[ACDMRTUXB][0-9]{0,3}$/.test(status)) throw new Error("malformed git name-status record"); const firstPath = tokens[++index]; if (!firstPath) throw new Error("git name-status record is missing a path"); paths.add(normalizeChangedPath(firstPath)); if (status.startsWith("R") || status.startsWith("C")) { const secondPath = tokens[++index]; if (!secondPath) throw new Error("git rename/copy record is missing its destination"); paths.add(normalizeChangedPath(secondPath)); } } return [...paths].sort(); } export async function resolveGitHead(cwd: string, options: ValidateEditScopeHostOptions = {}): Promise { const { stdout } = await (options.runner ?? DEFAULT_GIT_RUNNER)("git", ["rev-parse", "HEAD"], { cwd, timeoutMs: EDIT_SCOPE_TIMEOUT_MS, maxBuffer: MAX_GATE_STREAM_BYTES, signal: options.signal, shell: false, }); const head = stdout.trim(); if (!/^[0-9a-f]{40,64}$/i.test(head)) throw new Error("git returned an invalid HEAD object id"); return head; } export async function validateEditScope( cwd: string, input: ValidateEditScopeOptions, options: ValidateEditScopeHostOptions = {}, ): Promise { let allowGlobs: string[]; let denyGlobs: string[]; try { if (!input || typeof input !== "object") throw new Error("scope options must be an object"); allowGlobs = validateGlobs(input.allowGlobs, "allowGlobs", true); denyGlobs = validateGlobs(input.denyGlobs ?? [], "denyGlobs", false); } catch (error) { const message = error instanceof Error ? error.message : String(error); return scopeFailure(message); } let changedPaths: string[]; try { changedPaths = await collectChangedPaths(cwd, options); } catch (error) { const message = error instanceof Error ? error.message : String(error); return scopeFailure(`could not collect changed paths: ${message}`); } const violations: Array<{ path: string; reason: string }> = []; for (const path of changedPaths) { const alwaysDenied = alwaysDeniedReason(path); if (alwaysDenied) { violations.push({ path, reason: alwaysDenied }); continue; } const deny = denyGlobs.find((glob) => matchesGlob(path, glob)); if (deny) { violations.push({ path, reason: `matches editDeny pattern ${JSON.stringify(deny)}` }); continue; } if (!allowGlobs.some((glob) => matchesGlob(path, glob))) { violations.push({ path, reason: "does not match any editAllow pattern" }); } } return { ok: violations.length === 0, changedPaths, violatingPaths: violations.map((violation) => violation.path), violations, summary: violations.length === 0 ? `Edit scope passed for ${changedPaths.length} changed path(s).` : `Edit scope failed for ${violations.length} of ${changedPaths.length} changed path(s).`, }; } function scopeFailure(error: string): ValidateEditScopeResult { return { ok: false, changedPaths: [], violatingPaths: [], violations: [{ path: "(scope validation)", reason: error }], summary: "Edit scope validation failed closed.", error, }; } function validateGlobs(value: unknown, label: string, required: boolean): string[] { if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { throw new Error(`${label} must be an array of strings`); } if (required && value.length === 0) throw new Error(`${label} must not be empty`); if (value.length > 256) throw new Error(`${label} contains too many patterns`); return value.map((entry, index) => normalizeGlob(entry as string, `${label}[${index}]`)); } function normalizeGlob(value: string, label: string): string { if (!value || value !== value.trim()) throw new Error(`${label} must be a trimmed non-empty pattern`); if (value.length > MAX_PATH_CHARS) throw new Error(`${label} is too long`); if (isAbsolute(value) || /\0|\r|\n|\\/.test(value)) throw new Error(`${label} must be a safe repo-relative pattern`); if (value.startsWith("!") || /[{}[\]]/.test(value)) throw new Error(`${label} uses unsupported glob syntax`); const segments = value.split("/"); if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) { throw new Error(`${label} must not contain empty, dot, or traversal segments`); } return value; } function matchesGlob(path: string, glob: string): boolean { let expression = "^"; for (let index = 0; index < glob.length; index++) { const char = glob[index]; if (char === "*" && glob[index + 1] === "*") { if (glob[index + 2] === "/") { expression += "(?:.*/)?"; index += 2; } else { expression += ".*"; index += 1; } } else if (char === "*") { expression += "[^/]*"; } else if (char === "?") { expression += "[^/]"; } else { expression += escapeRegex(char); } } expression += "$"; return new RegExp(expression).test(path); } function alwaysDeniedReason(path: string): string | undefined { const basename = path.split("/").at(-1) ?? path; if (FOUNDATION_ALWAYS_DENIED_FILES.has(basename)) return "repository-control file is always denied"; const prefix = FOUNDATION_ALWAYS_DENIED_PREFIXES.find( (candidate) => path === candidate.slice(0, -1) || path.startsWith(candidate), ); return prefix ? `always-denied prefix ${JSON.stringify(prefix)}` : undefined; } function normalizeChangedPath(path: string): string { if (!path || /\0|\r|\n/.test(path) || isAbsolute(path)) throw new Error("unsafe path in git status output"); const normalized = path.replaceAll("\\", "/"); if (normalized === ".." || normalized.startsWith("../") || normalized.split("/").includes("..")) { throw new Error("traversal path in git status output"); } return normalized; } function compactText(value: string, maxChars: number): string { if (value.length <= maxChars) return value; const half = Math.floor((maxChars - 40) / 2); return `${value.slice(0, half)}\n… …\n${value.slice(-half)}`; } function escapeRegex(value: string): string { return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function isNonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }