Skip to content
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,15 @@ npm exec --offline -- agentic-proofkit json-report-cli-adapter-source --language
The generated adapter remains caller-owned after materialization. It must be
reviewed, pinned to the installed package, and kept behind the same CLI/JSON
contract; it does not become a separate public SDK or proof authority.
A call that sends JSON input writes it to a file created with mode 0600 in a
fresh mode 0700 directory under the calling process's temporary directory, so
the caller needs a writable temporary directory, and gives the CLI that file,
opened read-only, as its standard input. Except on Windows, the file and its
directory are removed before the CLI starts. On Windows, which is not a
supported platform, file modes do not apply, and the file and directory are
removed when the call returns or throws. The input remains on disk after a call
only if the calling process ends before that removal or the removal fails; a
failed removal fails the call.

## Documentation And Boundaries

Expand Down
2 changes: 1 addition & 1 deletion internal/app/cli_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import (
)

const (
cliContractPublicABISHA256 = "aee6cc573bf7df8ce8861803134ff10d743f7b2ff6acf6623b4feabb59010205"
cliContractPublicABISHA256 = "a93308d7bb1e18a8de2744ccd00fa88cc451aed8fdabfbae156c55f4c4dc782e"
maxAggregateFileReadBytesForContractTest = 64 << 20
maxPackageManifestBytesForContractTest = 256 << 10
maxSourceFileBytesForContractTest = 8 << 20
Expand Down
4 changes: 2 additions & 2 deletions internal/app/command_contract_generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,10 @@ const typeScriptSourceTemplate = `/* Generated by agentic-proofkit json-report-c
*/

import { spawnSync } from "node:child_process";
import { closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, realpathSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";

const defaultMaxBuffer = 64 * 1024 * 1024;
const defaultMaxInputBytes = 32 * 1024 * 1024;
Expand Down Expand Up @@ -736,7 +737,10 @@ export function runProofkitJsonCommand<T = unknown>(
args: readonly string[],
options: ProofkitCommandRunOptions,
): ProofkitJsonCommandResult<T> {
const {child, outputFile} = runProofkitCommand(commandRoute, input, args, options);
return readProofkitJsonCommandResult<T>(commandRoute, runProofkitCommand(commandRoute, input, args, options));
}

function readProofkitJsonCommandResult<T>(commandRoute: string, {child, outputFile}: ReturnType<typeof runProofkitCommand>): ProofkitJsonCommandResult<T> {
if (child.status !== 0 && child.stdout.trim().length === 0) {
throw new Error(formatProofkitCliError(child.stderr.trim() || commandRoute + " failed with exit code " + String(child.status)));
}
Expand All @@ -760,7 +764,7 @@ export function runProofkitNoInputJsonCommand<T = unknown>(
args: readonly string[],
options: ProofkitCommandRunOptions,
): ProofkitJsonCommandResult<T> {
return runProofkitJsonCommand<T>(commandRoute, null, args, {...options, inputMode: "none"});
return readProofkitJsonCommandResult<T>(commandRoute, runProofkitCommand(commandRoute, null, args, options, "json-no-input"));
}

export function runProofkitTextCommand(
Expand All @@ -769,10 +773,7 @@ export function runProofkitTextCommand(
args: readonly string[],
options: ProofkitCommandRunOptions,
): ProofkitTextCommandResult {
if (options.jsonLayout !== undefined) {
throw new Error("Proofkit jsonLayout is valid only for JSON command output");
}
const {child, outputFile} = runProofkitCommand(commandRoute, input, args, options);
const {child, outputFile} = runProofkitCommand(commandRoute, input, args, options, "text");
if (child.status !== 0 && child.stdout.length === 0) {
throw new Error(formatProofkitCliError(child.stderr.trim() || commandRoute + " failed with exit code " + String(child.status)));
}
Expand All @@ -787,23 +788,24 @@ export function runProofkitTextCommand(
};
}

function runProofkitCommand(commandRoute: string, input: unknown, args: readonly string[], options: ProofkitCommandRunOptions) {
function runProofkitCommand(commandRoute: string, input: unknown, args: readonly string[], callerOptions: ProofkitCommandRunOptions, mode: "json" | "text" | "json-no-input" = "json") {
const options = snapshotProofkitRunOptions(callerOptions, mode === "json-no-input" ? "none" : undefined);
if (mode === "text" && options.jsonLayout !== undefined) {
throw new Error("Proofkit jsonLayout is valid only for JSON command output");
}
const routeTokens = admitCommandRoute(commandRoute);
admitRunOptions(options);
let prepared: {readonly args: readonly string[]; readonly outputFile: string | null};
let prepared: {readonly args: readonly string[]; readonly outputFile: string | null; readonly cwd: string};
try {
prepared = prepareOutputArgs(options.cwd, args);
} catch (error) {
throw new Error(formatProofkitCliError(error));
}
const processArgs = options.jsonLayout === undefined ? [] : ["--json-layout", options.jsonLayout];
const childArgs = options.inputMode === "none" ? [...processArgs, ...routeTokens, ...prepared.args] : [...processArgs, ...routeTokens, "--input", "-", ...prepared.args];
const childResult = spawnSync(options.binaryPath, childArgs, {
cwd: options.cwd,
env: options.env,
input: options.inputMode === "none" ? undefined : proofkitStableJsonString(input),
maxBuffer: options.maxBuffer ?? defaultMaxBuffer,
});
const executionOptions = Object.freeze({...options, cwd: prepared.cwd});
const inputText = options.inputMode === "none" ? null : proofkitStableJsonString(input);
const childResult = spawnProofkitChild(executionOptions, childArgs, inputText);
if (childResult.error !== undefined) {
throw new Error(formatProofkitCliError(childResult.error));
}
Expand All @@ -815,6 +817,104 @@ function runProofkitCommand(commandRoute: string, input: unknown, args: readonly
return {child, outputFile: prepared.outputFile};
}

// Capture before output admission or input serialization can run caller code.
// Node includes enumerable inherited env entries and omits undefined values.
// Its NODE_V8_COVERAGE fallback must also be captured, including absence, so
// spawn cannot substitute a later ambient value. Only owned records are frozen.
function snapshotProofkitRunOptions(caller: ProofkitCommandRunOptions, inputModeOverride?: "none"): ProofkitCommandRunOptions {
try {
let parentCwd: string | undefined;
let parentCwdError: unknown;
try { parentCwd = process.cwd(); } catch (error) { parentCwdError = error; }
const {binaryPath, cwd, env, inputMode, jsonLayout, maxBuffer} = caller;
let executionCwd = cwd;
if (cwd.length !== 0 && !isAbsolute(cwd)) {
if (parentCwd === undefined) throw parentCwdError;
executionCwd = parentCwd + (parentCwd.endsWith(sep) ? "" : sep) + cwd;
}
const sourceEnv = env ?? process.env;
const environment = Object.create(null) as Record<string, string | undefined>;
for (const key in sourceEnv) environment[key] = sourceEnv[key];
if (env == null || !Object.hasOwn(env, "NODE_V8_COVERAGE")) {
const coverage = process.env.NODE_V8_COVERAGE;
if (coverage) environment.NODE_V8_COVERAGE = coverage;
}
return Object.freeze({binaryPath, cwd: executionCwd, env: Object.freeze(environment), inputMode: inputModeOverride ?? inputMode, jsonLayout, maxBuffer});
} catch (error) {
throw new Error(formatProofkitCliError(error));
}
}

// The child never reads its standard input from spawnSync's input option. On
// Unix-like systems that option writes into a socket pair, shuts down writing and
// keeps its end open until the child exits, and on macOS a child blocked reading
// such a socket is sometimes not woken by the shutdown and then waits forever.
// Input goes through a regular file, which ends at its size; a call without input
// gives the child the null device.
function spawnProofkitChild(options: ProofkitCommandRunOptions, childArgs: readonly string[], inputText: string | null) {
try {
return inputText === null ?
spawnProofkitWithStdin(options, childArgs, "ignore") :
spawnProofkitWithInputFile(options, childArgs, inputText);
} catch (error) {
throw new Error(formatProofkitCliError(error));
}
}

// The input is written to a file created exclusively with mode 0600 in a fresh
// mode 0700 directory under the temporary directory of this process, and the
// child gets that file opened read-only. Except on Windows, the file and its
// directory are removed as soon as the file is open, before the child starts:
// the open descriptor alone keeps the input, so none remains on disk while the
// child runs, and a signal that ends this process then leaves none behind. When
// the call returns or throws, the descriptor is closed and the file and directory
// are removed if they have not been removed yet: on Windows, where an open file
// can keep its name until it is closed, after a throw before the early removal,
// and after a failed early removal. The removal uses path calls only, so it needs
// no descriptor of its own, and a failed removal fails the call.
function spawnProofkitWithInputFile(options: ProofkitCommandRunOptions, childArgs: readonly string[], inputText: string) {
const directory = mkdtempSync(join(tmpdir(), "proofkit-cli-stdin-"));
const inputPath = join(directory, "stdin");
let inputFd: number | null = null;
let removed = false;
try {
const writeFd = openSync(inputPath, "wx", 0o600);
try {
writeFileSync(writeFd, inputText, {encoding: "utf8"});
} finally {
closeSync(writeFd);
}
inputFd = openSync(inputPath, "r");
if (process.platform !== "win32") {
removeProofkitInputFile(inputPath, directory);
removed = true;
}
return spawnProofkitWithStdin(options, childArgs, inputFd);
} finally {
try {
if (inputFd !== null) closeSync(inputFd);
} finally {
if (!removed) removeProofkitInputFile(inputPath, directory);
}
}
}

function removeProofkitInputFile(inputPath: string, directory: string): void {
rmSync(inputPath, {force: true});
rmdirSync(directory);
}

function spawnProofkitWithStdin(options: ProofkitCommandRunOptions, childArgs: readonly string[], stdin: "ignore" | number) {
const environment = options.env === undefined ? undefined : Object.assign(Object.create(null), options.env);
if (environment !== undefined && !Object.hasOwn(environment, "NODE_V8_COVERAGE")) Object.defineProperty(environment, "NODE_V8_COVERAGE", {value: undefined});
return spawnSync(options.binaryPath, childArgs, {
cwd: options.cwd,
env: environment,
maxBuffer: options.maxBuffer ?? defaultMaxBuffer,
stdio: [stdin, "pipe", "pipe"],
});
}

function outputPathFromArgs(args: readonly string[]): string | null {
let outputPath: string | null = null;
for (let index = 0; index < args.length; index += 1) {
Expand All @@ -836,30 +936,30 @@ function outputPathFromArgs(args: readonly string[]): string | null {
return outputPath;
}

function prepareOutputArgs(cwd: string, args: readonly string[]): {readonly args: readonly string[]; readonly outputFile: string | null} {
function prepareOutputArgs(cwd: string, args: readonly string[]): {readonly args: readonly string[]; readonly outputFile: string | null; readonly cwd: string} {
const outputPath = outputPathFromArgs(args);
if (outputPath === null) {
return {args, outputFile: null};
return {args, outputFile: null, cwd};
}
const target = admitWritableOutputTarget(cwd, outputPath);
const outputIndex = args.indexOf("--output");
const canonicalArgs = args.map((argument, index) => index === outputIndex + 1 ? target.argument : argument);
return {args: canonicalArgs, outputFile: target.filePath};
return {args: canonicalArgs, outputFile: target.filePath, cwd: target.cwd};
}

function admitWritableOutputTarget(cwd: string, outputPath: string): {readonly argument: string; readonly filePath: string} {
function admitWritableOutputTarget(cwd: string, outputPath: string): {readonly argument: string; readonly filePath: string; readonly cwd: string} {
if (isAbsolute(outputPath)) {
throw new Error("--output requires a repository-relative path");
}
const root = realpathSync(cwd);
const root = realpathSync.native(cwd);
const lexicalTarget = resolve(root, outputPath);
assertPathInsideRoot(root, lexicalTarget, "--output must resolve inside the repository root");
ensureWritableParent(root, dirname(lexicalTarget));
const parent = realpathSync(dirname(lexicalTarget));
assertPathInsideRoot(root, parent, "--output parent must resolve inside the repository root", true);
const filePath = resolve(parent, basename(lexicalTarget));
requireRegularOutputTarget(filePath, true);
return {argument: relative(root, filePath), filePath};
return {argument: relative(root, filePath), filePath, cwd: root};
}

function ensureWritableParent(root: string, parentPath: string): void {
Expand Down
Loading
Loading