diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index d4eb51b..44c659d 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -2635,3 +2635,83 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` + +# Candidate engine and artifact sealing Phase A (2026-10-01) + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-candidate-engine-pure-fixture + given: Hermetic frozen fixture descriptors, bounded command data, explicit environment and a fixture artifact directory + when: The isolated Phase-A engine observes a child execution + then: Only bounded parent-observed terminal data and a nonpromotable SourceAttestation are returned + - id: SCN-artifact-manifest-seal + given: Fixed regular artifact bytes in a hermetic temporary directory + when: The parent seals the directory + then: Canonical sorted paths, byte lengths and SHA-256 values are write-once and verify unchanged + observable_outcomes: + - Raw exit, error, signal, timeout and overflow observations; zero exit plus overflow is not success + - No receipt identity, writer capability, registered-state handle or consumer eligibility + - Capped canonical manifests and refusals for links, traversal, special files, overwrite and post-seal drift + determinism_controls: + - Fixed bytes, injected executors and isolated temporary directories + - No retries, sleeps, network, registered control-plane state or provider process + anti_cheat_rationale: + prevents: + - Hard-coded PASS or fixture receipt promotion + - Ambient environment or registered-state capability leakage + - Snapshot/golden rubber-stamping and unbounded output masking + - Link, traversal, overwrite or post-seal artifact substitution + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +# Candidate engine and artifact sealing Phase A corrective boundaries (2026-10-01) + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-candidate-engine-boundary-limits + given: "A fixture run with candidate execution limits" + when: "A required bound is missing, zero, oversized, or supplied to the injected native-shaped executor" + then: "Execution refuses before start or receives finite timeout and output caps without ambient defaults" + - id: SCN-candidate-engine-utf8-overflow + given: "A parent observes multi-byte UTF-8 output larger than its byte cap" + when: "The child exits zero with output overflow" + then: "Only a valid byte-bounded prefix is retained and the terminal observation is non-success" + - id: SCN-candidate-engine-immutable-input-and-environment + given: "A candidate input byte snapshot and explicit deterministic environment" + when: "The digest or byte length is absent or mismatched, an unallowlisted environment is requested, or caller data mutates" + then: "The run refuses before execution or preserves its independent immutable snapshot" + - id: SCN-artifact-manifest-bytewise-and-caps + given: "Fixed artifacts with bytewise-distinct UTF-8 paths and configured evidence limits" + when: "The parent seals files at or beyond count, file, aggregate, path, and manifest limits" + then: "Canonical UTF-8 byte ordering is stable and every over-limit artifact refuses" + - id: SCN-artifact-manifest-root-and-manifest-boundaries + given: "A candidate artifact root or final manifest path" + when: "A link, directory/special node, pre-existing seal, or oversized manifest is encountered" + then: "No path is followed or parsed as evidence and write-once sealing refuses" + - id: SCN-artifact-manifest-complete-drift + given: "A previously sealed manifest" + when: "An artifact is added or the manifest is replaced" + then: "Verification rejects drift or an invalid seal" + observable_outcomes: + - "UTF-8 retained bytes never exceed maxOutputBytes; overflow including exit zero is non-success" + - "Missing or mismatched input byte snapshots, unallowlisted environment names, and invalid bounds do not invoke the executor" + - "Manifest lstat/open/read caps, path ordering, link/special refusal, and seal-drift errors are externally observable" + determinism_controls: + - "Injected fixture executor, fixed byte buffers, temporary directories, and no ambient environment inheritance" + - "No network, registered control-plane state, provider process, or live receipt adapter" + anti_cheat_rationale: + prevents: + - "Character-count truncation retaining more bytes than the declared cap" + - "Ambient or syntactically valid but unapproved environment capability leakage" + - "Caller-supplied digest labels impersonating verified immutable input" + - "Link-following, unbounded manifest parsing, locale-dependent ordering, or post-seal substitution" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/src/checks/artifact-manifest.mjs b/src/checks/artifact-manifest.mjs new file mode 100644 index 0000000..5410c8a --- /dev/null +++ b/src/checks/artifact-manifest.mjs @@ -0,0 +1,218 @@ +import { createHash } from 'node:crypto'; +import { + closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync, writeFileSync, +} from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { TorchError } from '../kernel/errors.mjs'; + +export const ARTIFACT_MANIFEST_FILE = '.torch-artifact-manifest-v1.json'; +export const ARTIFACT_MANIFEST_LIMITS = Object.freeze({ + maxFiles: 64, + maxFileBytes: 1_048_576, + maxTotalBytes: 8_388_608, + maxPathBytes: 4_096, + maxManifestBytes: 65_536, +}); + +function fail(message, code, details) { + throw new TorchError(message, { code, details }); +} + +function canonicalJson(value) { + return JSON.stringify(value); +} + +function digest(value) { + return createHash('sha256').update(canonicalJson(value)).digest('hex'); +} + +function limits(input = {}) { + if (!input || typeof input !== 'object' || Array.isArray(input)) fail('Artifact limits must be an object', 'CANDIDATE_ARTIFACT_LIMIT_INVALID'); + const allowed = new Set(Object.keys(ARTIFACT_MANIFEST_LIMITS)); + for (const key of Object.keys(input)) { + if (!allowed.has(key)) fail('Artifact limit is unsupported', 'CANDIDATE_ARTIFACT_LIMIT_INVALID', { key }); + } + const result = {}; + for (const [key, maximum] of Object.entries(ARTIFACT_MANIFEST_LIMITS)) { + const value = input[key] ?? maximum; + if (!Number.isInteger(value) || value < 1 || value > maximum) fail('Artifact limit is invalid', 'CANDIDATE_ARTIFACT_LIMIT_INVALID', { key, value, maximum }); + result[key] = value; + } + return result; +} + +function bytewisePathOrder(left, right) { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); +} + +export function assertArtifactRelativePath(path, maxPathBytes = ARTIFACT_MANIFEST_LIMITS.maxPathBytes) { + if (typeof path !== 'string' || !path || isAbsolute(path) || path.includes('\\') + || path.includes('\0') || Buffer.byteLength(path, 'utf8') > maxPathBytes + || path.split('/').some((part) => !part || part === '.' || part === '..')) { + fail('Artifact path is not canonical relative evidence', 'CANDIDATE_ARTIFACT_PATH_INVALID', { path }); + } + return path; +} + +export function artifactNodeKind(stat, path) { + if (stat.isSymbolicLink()) fail('Artifact cannot be a link', 'CANDIDATE_ARTIFACT_LINK_REJECTED', { path }); + if (stat.isDirectory()) return 'directory'; + if (stat.isFile()) return 'file'; + fail('Artifact must be a regular file or directory', 'CANDIDATE_ARTIFACT_SPECIAL_REJECTED', { path }); +} + +function lstatOrNull(path) { + try { return lstatSync(path); } + catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +function readRegularFileCapped(path, limit, label) { + const beforePath = lstatOrNull(path); + if (!beforePath) fail(`${label} is missing`, 'CANDIDATE_ARTIFACT_MANIFEST_MISSING', { path }); + artifactNodeKind(beforePath, path); + if (!beforePath.isFile()) fail(`${label} must be a regular file`, 'CANDIDATE_ARTIFACT_SPECIAL_REJECTED', { path }); + if (beforePath.size > limit) fail(`${label} exceeds its byte limit`, 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED', { path, limit }); + + let descriptor; + try { + descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = fstatSync(descriptor); + if (!before.isFile()) fail(`${label} must be a regular file`, 'CANDIDATE_ARTIFACT_SPECIAL_REJECTED', { path }); + if (before.size > limit) fail(`${label} exceeds its byte limit`, 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED', { path, limit }); + const buffer = Buffer.alloc(before.size + 1); + let offset = 0; + while (offset < buffer.length) { + const read = readSync(descriptor, buffer, offset, buffer.length - offset, offset); + if (read === 0) break; + offset += read; + } + const after = fstatSync(descriptor); + if (after.size !== before.size || offset !== before.size) { + fail(`${label} changed while being read`, 'CANDIDATE_ARTIFACT_SEAL_DRIFT', { path }); + } + return buffer.subarray(0, before.size); + } catch (error) { + if (error instanceof TorchError) throw error; + fail(`${label} cannot be read as a regular file`, 'CANDIDATE_ARTIFACT_LINK_REJECTED', { path }); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function inventory(root, configuredLimits) { + const files = []; + let totalBytes = 0; + function visit(current, parent = '') { + for (const name of readdirSync(current).sort(bytewisePathOrder)) { + if (!parent && name === ARTIFACT_MANIFEST_FILE) continue; + const path = parent ? `${parent}/${name}` : name; + assertArtifactRelativePath(path, configuredLimits.maxPathBytes); + const absolute = join(current, name); + const stat = lstatSync(absolute); + const kind = artifactNodeKind(stat, path); + if (kind === 'directory') visit(absolute, path); + else { + if (stat.size > configuredLimits.maxFileBytes) fail('Artifact exceeds per-file limit', 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED', { path }); + totalBytes += stat.size; + if (files.length + 1 > configuredLimits.maxFiles || totalBytes > configuredLimits.maxTotalBytes) { + fail('Artifact manifest exceeds aggregate limits', 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED'); + } + const bytes = readRegularFileCapped(absolute, configuredLimits.maxFileBytes, 'Artifact'); + if (bytes.length !== stat.size || bytes.length > configuredLimits.maxFileBytes) { + fail('Artifact changed while being read', 'CANDIDATE_ARTIFACT_SEAL_DRIFT', { path }); + } + files.push({ path, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }); + } + } + } + visit(root); + return { files: files.sort((left, right) => bytewisePathOrder(left.path, right.path)), totalBytes }; +} + +function validateManifest(manifest, configuredLimits) { + if (!manifest || manifest.schema !== 'torch.dev/artifact-manifest/v1alpha1' || !Array.isArray(manifest.files) + || !Number.isInteger(manifest.totalBytes) || typeof manifest.sealDigest !== 'string') { + fail('Artifact manifest is malformed', 'CANDIDATE_ARTIFACT_MANIFEST_INVALID'); + } + const manifestKeys = new Set(['schema', 'files', 'totalBytes', 'sealDigest']); + for (const key of Object.keys(manifest)) { + if (!manifestKeys.has(key)) fail('Artifact manifest contains unsupported fields', 'CANDIDATE_ARTIFACT_MANIFEST_INVALID', { key }); + } + const seen = new Set(); + let totalBytes = 0; + for (const record of manifest.files) { + if (!record || typeof record !== 'object' || Array.isArray(record) + || Object.keys(record).some((key) => !new Set(['path', 'bytes', 'sha256']).has(key))) { + fail('Artifact manifest record is invalid', 'CANDIDATE_ARTIFACT_MANIFEST_INVALID'); + } + assertArtifactRelativePath(record.path, configuredLimits.maxPathBytes); + if (seen.has(record.path)) fail('Artifact manifest contains duplicate paths', 'CANDIDATE_ARTIFACT_MANIFEST_INVALID'); + seen.add(record.path); + if (!Number.isInteger(record.bytes) || record.bytes < 0 || record.bytes > configuredLimits.maxFileBytes + || !/^[0-9a-f]{64}$/.test(record.sha256 ?? '')) fail('Artifact manifest record is invalid', 'CANDIDATE_ARTIFACT_MANIFEST_INVALID'); + totalBytes += record.bytes; + } + if (manifest.files.length > configuredLimits.maxFiles || totalBytes !== manifest.totalBytes || totalBytes > configuredLimits.maxTotalBytes) { + fail('Artifact manifest limits do not match', 'CANDIDATE_ARTIFACT_MANIFEST_INVALID'); + } + const sorted = [...manifest.files].sort((left, right) => bytewisePathOrder(left.path, right.path)); + if (canonicalJson(sorted) !== canonicalJson(manifest.files)) fail('Artifact manifest paths are not canonical', 'CANDIDATE_ARTIFACT_MANIFEST_INVALID'); + const payload = { schema: manifest.schema, files: manifest.files, totalBytes: manifest.totalBytes }; + if (digest(payload) !== manifest.sealDigest) fail('Artifact manifest seal digest is invalid', 'CANDIDATE_ARTIFACT_SEAL_INVALID'); + return payload; +} + +function ownedRoot(directory) { + if (typeof directory !== 'string' || !directory) fail('Artifact directory is missing', 'CANDIDATE_ARTIFACT_DIRECTORY_INVALID'); + const initial = lstatOrNull(directory); + if (!initial) fail('Artifact directory is missing', 'CANDIDATE_ARTIFACT_DIRECTORY_INVALID'); + if (initial.isSymbolicLink()) fail('Artifact directory cannot be a link', 'CANDIDATE_ARTIFACT_LINK_REJECTED', { directory }); + if (!initial.isDirectory()) fail('Artifact directory is not a directory', 'CANDIDATE_ARTIFACT_DIRECTORY_INVALID'); + const root = realpathSync(directory); + if (!lstatSync(root).isDirectory()) fail('Artifact directory is not a directory', 'CANDIDATE_ARTIFACT_DIRECTORY_INVALID'); + return root; +} + +export function sealArtifactManifest({ directory, limits: requestedLimits } = {}) { + const root = ownedRoot(directory); + const configuredLimits = limits(requestedLimits); + const path = join(root, ARTIFACT_MANIFEST_FILE); + if (lstatOrNull(path)) fail('Artifact manifest is already sealed', 'CANDIDATE_ARTIFACT_ALREADY_SEALED'); + const records = inventory(root, configuredLimits); + const payload = { schema: 'torch.dev/artifact-manifest/v1alpha1', ...records }; + const manifest = { ...payload, sealDigest: digest(payload) }; + const serialized = Buffer.from(`${canonicalJson(manifest)}\n`, 'utf8'); + if (serialized.length > configuredLimits.maxManifestBytes) { + fail('Artifact manifest exceeds its byte limit', 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED'); + } + writeFileSync(path, serialized, { mode: 0o600, flag: 'wx' }); + return Object.freeze({ ...manifest, manifestPath: path }); +} + +export function verifyArtifactManifest({ directory, limits: requestedLimits } = {}) { + const root = ownedRoot(directory); + const configuredLimits = limits(requestedLimits); + const path = join(root, ARTIFACT_MANIFEST_FILE); + const manifestStat = lstatOrNull(path); + if (!manifestStat) fail('Artifact manifest is missing', 'CANDIDATE_ARTIFACT_MANIFEST_MISSING'); + artifactNodeKind(manifestStat, ARTIFACT_MANIFEST_FILE); + if (!manifestStat.isFile()) fail('Artifact manifest must be a regular file', 'CANDIDATE_ARTIFACT_SPECIAL_REJECTED'); + if (manifestStat.size > configuredLimits.maxManifestBytes) { + fail('Artifact manifest exceeds its byte limit', 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED'); + } + let manifest; + try { manifest = JSON.parse(readRegularFileCapped(path, configuredLimits.maxManifestBytes, 'Artifact manifest').toString('utf8')); } + catch { fail('Artifact manifest cannot be parsed', 'CANDIDATE_ARTIFACT_MANIFEST_INVALID'); } + const payload = validateManifest(manifest, configuredLimits); + const current = inventory(root, configuredLimits); + if (canonicalJson(current) !== canonicalJson({ files: payload.files, totalBytes: payload.totalBytes })) { + fail('Artifact contents changed after sealing', 'CANDIDATE_ARTIFACT_SEAL_DRIFT'); + } + if (resolve(path) !== join(root, ARTIFACT_MANIFEST_FILE) || relative(root, path).startsWith('..')) { + fail('Artifact manifest escaped its directory', 'CANDIDATE_ARTIFACT_DIRECTORY_INVALID'); + } + return Object.freeze({ ...manifest, manifestPath: path, verified: true }); +} diff --git a/src/checks/candidate-engine.mjs b/src/checks/candidate-engine.mjs new file mode 100644 index 0000000..df52268 --- /dev/null +++ b/src/checks/candidate-engine.mjs @@ -0,0 +1,224 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { TorchError } from '../kernel/errors.mjs'; + +export const CANDIDATE_ENGINE_LIMITS = Object.freeze({ + maxInputBytes: 1_048_576, + maxDefinitionBytes: 65_536, + maxArgs: 64, + maxArgBytes: 4_096, + maxEnvironmentEntries: 3, + maxEnvironmentNameBytes: 128, + maxEnvironmentValueBytes: 4_096, + maxOutputBytes: 65_536, + maxArtifactPathBytes: 4_096, + timeoutMs: 60_000, +}); + +/** Deterministic variables only; no ambient process environment is inherited. */ +export const CANDIDATE_ENGINE_ENV_ALLOWLIST = Object.freeze({ + LANG: Object.freeze(['C.UTF-8']), + LC_ALL: Object.freeze(['C']), + TZ: Object.freeze(['UTC']), +}); + +function fail(message, code, details) { + throw new TorchError(message, { code, details }); +} + +function byteLength(value) { + return Buffer.byteLength(value, 'utf8'); +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function exactObject(value, allowed, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(`${label} must be an object`, 'CANDIDATE_ENGINE_INPUT_INVALID'); + } + for (const key of Object.keys(value)) { + if (!allowed.has(key)) fail(`${label} contains unsupported capability data`, 'CANDIDATE_ENGINE_CAPABILITY_FORBIDDEN', { key }); + } +} + +function boundedString(value, limit, label) { + if (typeof value !== 'string' || !value || byteLength(value) > limit) { + fail(`${label} is missing or exceeds its limit`, 'CANDIDATE_ENGINE_INPUT_INVALID', { label, limit }); + } + return value; +} + +function normalizeLimits(limits) { + exactObject(limits, new Set(Object.keys(CANDIDATE_ENGINE_LIMITS)), 'limits'); + const normalized = {}; + for (const [key, maximum] of Object.entries(CANDIDATE_ENGINE_LIMITS)) { + if (!Object.hasOwn(limits, key)) { + fail(`Candidate-engine limit is required: ${key}`, 'CANDIDATE_ENGINE_LIMIT_INVALID', { key }); + } + const value = limits[key]; + if (!Number.isInteger(value) || value < 1 || value > maximum) { + fail(`Invalid candidate-engine limit: ${key}`, 'CANDIDATE_ENGINE_LIMIT_INVALID', { key, value, maximum }); + } + normalized[key] = value; + } + return Object.freeze(normalized); +} + +function freeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const item of Object.values(value)) freeze(item); + Object.freeze(value); + } + return value; +} + +function normalizeInput(input, limits) { + exactObject(input, new Set(['directory', 'digest', 'bytes', 'byteLength']), 'input'); + const directory = boundedString(input.directory, limits.maxArtifactPathBytes, 'input.directory'); + if (!Buffer.isBuffer(input.bytes) && !(input.bytes instanceof Uint8Array)) { + fail('Candidate input bytes are required', 'CANDIDATE_ENGINE_INPUT_INVALID'); + } + const snapshot = Buffer.from(input.bytes); + if (snapshot.length > limits.maxInputBytes || !Number.isInteger(input.byteLength) + || input.byteLength !== snapshot.length || typeof input.digest !== 'string' + || !/^[0-9a-f]{64}$/i.test(input.digest)) { + fail('Candidate input must be a bounded immutable byte snapshot', 'CANDIDATE_ENGINE_INPUT_INVALID'); + } + const observedDigest = sha256(snapshot); + if (input.digest.toLowerCase() !== observedDigest) { + fail('Candidate input digest does not bind the supplied bytes', 'CANDIDATE_ENGINE_INPUT_INVALID'); + } + return freeze({ directory, digest: observedDigest, bytes: snapshot.length }); +} + +function normalizeDefinition(definition, limits) { + exactObject(definition, new Set(['command', 'args']), 'definition'); + const command = boundedString(definition.command, limits.maxArgBytes, 'definition.command'); + const args = normalizeArgs(definition.args ?? [], limits, 'definition.args'); + const normalized = { command, args }; + if (byteLength(JSON.stringify(normalized)) > limits.maxDefinitionBytes) { + fail('Candidate definition exceeds its limit', 'CANDIDATE_ENGINE_INPUT_INVALID'); + } + return freeze(normalized); +} + +function normalizeArgs(args, limits, label = 'args') { + if (!Array.isArray(args) || args.length > limits.maxArgs) { + fail(`${label} exceeds its count limit`, 'CANDIDATE_ENGINE_INPUT_INVALID'); + } + return freeze(args.map((value) => boundedString(value, limits.maxArgBytes, label))); +} + +function normalizeEnvironment(environment, limits) { + exactObject(environment, new Set(Object.keys(environment ?? {})), 'environment'); + const entries = Object.entries(environment); + if (entries.length > limits.maxEnvironmentEntries) { + fail('Candidate environment exceeds its entry limit', 'CANDIDATE_ENGINE_INPUT_INVALID'); + } + const normalized = {}; + for (const [key, value] of entries.sort(([left], [right]) => Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')))) { + const allowedValues = CANDIDATE_ENGINE_ENV_ALLOWLIST[key]; + if (!allowedValues || byteLength(key) > limits.maxEnvironmentNameBytes) { + fail('Candidate environment name is not allowlisted', 'CANDIDATE_ENGINE_ENVIRONMENT_INVALID', { key }); + } + const bounded = boundedString(value, limits.maxEnvironmentValueBytes, `environment.${key}`); + if (!allowedValues.includes(bounded)) { + fail('Candidate environment value is not allowlisted', 'CANDIDATE_ENGINE_ENVIRONMENT_INVALID', { key }); + } + normalized[key] = bounded; + } + return freeze(normalized); +} + +function safeUtf8Prefix(bytes, limit) { + const capped = Math.min(bytes.length, limit); + const decoder = new TextDecoder('utf-8', { fatal: true }); + for (let length = capped; length >= 0; length -= 1) { + try { + const text = decoder.decode(bytes.subarray(0, length)); + if (byteLength(text) <= limit) return text; + } catch { + // Shorten until the prefix ends on a complete UTF-8 sequence. + } + } + return ''; +} + +function boundedOutput(value, limit) { + const output = Buffer.isBuffer(value) ? Buffer.from(value) + : Buffer.from(typeof value === 'string' ? value : value?.toString?.() ?? '', 'utf8'); + const bytes = output.length; + const text = safeUtf8Prefix(output, bytes > limit ? limit : bytes); + return freeze({ bytes, retainedBytes: byteLength(text), text, overflow: bytes > limit }); +} + +function normalizeError(error) { + if (!error) return null; + return freeze({ + code: typeof error.code === 'string' ? boundedOutput(error.code, 128).text : null, + message: boundedOutput(typeof error.message === 'string' ? error.message : String(error), 512).text, + }); +} + +function terminalObservation(result, limits) { + const stdout = boundedOutput(result?.stdout, limits.maxOutputBytes); + const stderr = boundedOutput(result?.stderr, limits.maxOutputBytes); + const error = normalizeError(result?.error); + const signal = typeof result?.signal === 'string' ? result.signal : null; + const exitStatus = Number.isInteger(result?.status) ? result.status : null; + const timedOut = result?.timedOut === true || error?.code === 'ETIMEDOUT'; + const overflow = stdout.overflow || stderr.overflow || error?.code === 'ENOBUFS'; + let kind = 'exit'; + if (timedOut) kind = 'timeout'; + else if (overflow) kind = 'overflow'; + else if (signal) kind = 'signal'; + else if (error) kind = 'error'; + return freeze({ + kind, exitStatus, signal, error, timedOut, overflow, + success: kind === 'exit' && exitStatus === 0, + stdout, stderr, + }); +} + +/** + * Fixture-only Phase-A seam. It deliberately exposes a raw parent observation, + * never a receipt, capability, ControlPlane handle, or registered project root. + */ +export function runCandidateEngine(options = {}) { + exactObject(options, new Set(['input', 'definition', 'args', 'environment', 'artifactDirectory', 'executor', 'limits']), 'candidate engine options'); + const limits = normalizeLimits(options.limits); + const input = normalizeInput(options.input, limits); + const definition = normalizeDefinition(options.definition, limits); + const args = normalizeArgs(options.args ?? [], limits); + const environment = normalizeEnvironment(options.environment ?? {}, limits); + const artifactDirectory = boundedString(options.artifactDirectory, limits.maxArtifactPathBytes, 'artifactDirectory'); + const executor = options.executor ?? ((command, commandArgs, execution) => spawnSync(command, commandArgs, execution)); + if (typeof executor !== 'function') fail('Candidate executor must be a function', 'CANDIDATE_ENGINE_INPUT_INVALID'); + + const commandArgs = freeze([...definition.args, ...args]); + const execution = freeze({ + cwd: input.directory, + env: environment, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: limits.maxOutputBytes, + timeout: limits.timeoutMs, + killSignal: 'SIGTERM', + artifactDirectory, + }); + let raw; + try { + raw = executor(definition.command, commandArgs, execution); + } catch (error) { + raw = { status: null, error }; + } + const terminal = terminalObservation(raw, limits); + return freeze({ + schema: 'torch.dev/candidate-engine-observation/v1alpha1', + fixtureOnly: true, + sourceAttestation: freeze({ schema: 'torch.dev/source-attestation/v1alpha1', promotion: 'nonpromotable' }), + input, definition, args, environment, artifactDirectory, limits, terminal, + }); +} diff --git a/test/fleet/candidate-artifact-manifest.test.mjs b/test/fleet/candidate-artifact-manifest.test.mjs new file mode 100644 index 0000000..e90fd4a --- /dev/null +++ b/test/fleet/candidate-artifact-manifest.test.mjs @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + mkdirSync, mkdtempSync, readFileSync, renameSync, symlinkSync, unlinkSync, writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + ARTIFACT_MANIFEST_FILE, artifactNodeKind, assertArtifactRelativePath, sealArtifactManifest, verifyArtifactManifest, +} from '../../src/checks/artifact-manifest.mjs'; + +function fixture() { + return mkdtempSync(join(tmpdir(), 'torch-candidate-manifest-')); +} + +function write(root, path, content) { + const target = join(root, path); + mkdirSync(join(target, '..'), { recursive: true }); + writeFileSync(target, content); +} + +function manifestBytesFor(path, content) { + const bytes = Buffer.from(content, 'utf8'); + const payload = { + schema: 'torch.dev/artifact-manifest/v1alpha1', + files: [{ path, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }], + totalBytes: bytes.length, + }; + const manifest = { + ...payload, + sealDigest: createHash('sha256').update(JSON.stringify(payload)).digest('hex'), + }; + return Buffer.byteLength(`${JSON.stringify(manifest)}\n`, 'utf8'); +} + +test('SCN-artifact-manifest-seal: regular fixture artifacts seal as canonical sorted hash and length evidence regardless of creation order', () => { + const first = fixture(); + const second = fixture(); + write(first, 'z/proof.txt', 'zeta'); + write(first, 'a/proof.txt', 'alpha'); + write(second, 'a/proof.txt', 'alpha'); + write(second, 'z/proof.txt', 'zeta'); + const left = sealArtifactManifest({ directory: first }); + const right = sealArtifactManifest({ directory: second }); + assert.deepEqual(left.files, right.files); + assert.equal(left.sealDigest, right.sealDigest); + assert.deepEqual(left.files.map((file) => file.path), ['a/proof.txt', 'z/proof.txt']); + assert.equal(left.totalBytes, 9); + assert.equal(verifyArtifactManifest({ directory: first }).verified, true); + assert.throws(() => sealArtifactManifest({ directory: first }), { code: 'CANDIDATE_ARTIFACT_ALREADY_SEALED' }); +}); + +test('SCN-artifact-manifest-negative-paths: traversal, links, special files, caps and duplicate manifest paths refuse', () => { + assert.throws(() => assertArtifactRelativePath('../escape'), { code: 'CANDIDATE_ARTIFACT_PATH_INVALID' }); + assert.throws(() => assertArtifactRelativePath('/absolute'), { code: 'CANDIDATE_ARTIFACT_PATH_INVALID' }); + + const linked = fixture(); + write(linked, 'safe.txt', 'safe'); + symlinkSync('safe.txt', join(linked, 'linked.txt')); + assert.throws(() => sealArtifactManifest({ directory: linked }), { code: 'CANDIDATE_ARTIFACT_LINK_REJECTED' }); + + const limited = fixture(); + write(limited, 'large.txt', '12345'); + assert.throws(() => sealArtifactManifest({ directory: limited, limits: { maxFileBytes: 4 } }), + { code: 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED' }); + + assert.throws(() => artifactNodeKind({ + isSymbolicLink: () => false, isDirectory: () => false, isFile: () => false, + }, 'pipe'), { code: 'CANDIDATE_ARTIFACT_SPECIAL_REJECTED' }); + + const malformed = fixture(); + write(malformed, 'proof.txt', 'proof'); + sealArtifactManifest({ directory: malformed }); + const path = join(malformed, ARTIFACT_MANIFEST_FILE); + const record = JSON.parse(readFileSync(path, 'utf8')); + record.files.push({ ...record.files[0] }); + writeFileSync(path, JSON.stringify(record)); + assert.throws(() => verifyArtifactManifest({ directory: malformed }), { code: 'CANDIDATE_ARTIFACT_MANIFEST_INVALID' }); +}); + +test('SCN-artifact-manifest-drift: post-seal mutation or overwrite is detected and cannot become retained evidence', () => { + const root = fixture(); + write(root, 'proof.txt', 'before'); + sealArtifactManifest({ directory: root }); + write(root, 'proof.txt', 'after'); + assert.throws(() => verifyArtifactManifest({ directory: root }), { code: 'CANDIDATE_ARTIFACT_SEAL_DRIFT' }); +}); + +test('SCN-artifact-manifest-bytewise-and-caps: UTF-8 bytewise path ordering and every declared cap are deterministic', () => { + const first = fixture(); + const second = fixture(); + write(first, 'รค.txt', 'umlaut'); + write(first, 'z.txt', 'zed'); + write(second, 'z.txt', 'zed'); + write(second, 'รค.txt', 'umlaut'); + const left = sealArtifactManifest({ directory: first }); + const right = sealArtifactManifest({ directory: second }); + assert.deepEqual(left.files.map((file) => file.path), ['z.txt', 'รค.txt']); + assert.deepEqual(left.files, right.files); + assert.equal(left.sealDigest, right.sealDigest); + + const count = fixture(); + write(count, 'one.txt', '1'); + write(count, 'two.txt', '2'); + assert.throws(() => sealArtifactManifest({ directory: count, limits: { maxFiles: 1 } }), { code: 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED' }); + const countPass = fixture(); + write(countPass, 'one.txt', '1'); + assert.equal(sealArtifactManifest({ directory: countPass, limits: { maxFiles: 1 } }).files.length, 1); + + const perFile = fixture(); + write(perFile, 'proof.txt', '1234'); + assert.equal(sealArtifactManifest({ directory: perFile, limits: { maxFileBytes: 4 } }).files[0].bytes, 4); + const perFileFail = fixture(); + write(perFileFail, 'proof.txt', '12345'); + assert.throws(() => sealArtifactManifest({ directory: perFileFail, limits: { maxFileBytes: 4 } }), { code: 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED' }); + + const aggregate = fixture(); + write(aggregate, 'one.txt', '12'); + write(aggregate, 'two.txt', '34'); + assert.throws(() => sealArtifactManifest({ directory: aggregate, limits: { maxTotalBytes: 3 } }), { code: 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED' }); + const aggregatePass = fixture(); + write(aggregatePass, 'one.txt', '12'); + write(aggregatePass, 'two.txt', '34'); + assert.equal(sealArtifactManifest({ directory: aggregatePass, limits: { maxTotalBytes: 4 } }).totalBytes, 4); + + const paths = fixture(); + write(paths, 'abcd.txt', 'x'); + assert.throws(() => sealArtifactManifest({ directory: paths, limits: { maxPathBytes: 7 } }), { code: 'CANDIDATE_ARTIFACT_PATH_INVALID' }); + const pathsPass = fixture(); + write(pathsPass, 'abc.txt', 'x'); + assert.equal(sealArtifactManifest({ directory: pathsPass, limits: { maxPathBytes: 7 } }).files.length, 1); + + const manifestCap = fixture(); + write(manifestCap, 'proof.txt', 'proof'); + assert.throws(() => sealArtifactManifest({ directory: manifestCap, limits: { maxManifestBytes: 16 } }), { code: 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED' }); + const exactManifestBytes = manifestBytesFor('proof.txt', 'proof'); + const manifestAtCap = fixture(); + write(manifestAtCap, 'proof.txt', 'proof'); + assert.equal(sealArtifactManifest({ directory: manifestAtCap, limits: { maxManifestBytes: exactManifestBytes } }).files.length, 1); + const manifestOverCap = fixture(); + write(manifestOverCap, 'proof.txt', 'proof'); + assert.throws(() => sealArtifactManifest({ directory: manifestOverCap, limits: { maxManifestBytes: exactManifestBytes - 1 } }), + { code: 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED' }); + assert.throws(() => sealArtifactManifest({ directory: fixture(), limits: { unknownCap: 1 } }), { code: 'CANDIDATE_ARTIFACT_LIMIT_INVALID' }); +}); + +test('SCN-artifact-manifest-root-and-manifest-boundaries: root or manifest links, special nodes and oversized manifests never become evidence', () => { + const actual = fixture(); + write(actual, 'proof.txt', 'proof'); + const linkedRoot = `${actual}-link`; + symlinkSync(actual, linkedRoot); + assert.throws(() => sealArtifactManifest({ directory: linkedRoot }), { code: 'CANDIDATE_ARTIFACT_LINK_REJECTED' }); + + const sealed = fixture(); + write(sealed, 'proof.txt', 'proof'); + sealArtifactManifest({ directory: sealed }); + const manifest = join(sealed, ARTIFACT_MANIFEST_FILE); + const replacement = `${manifest}.replacement`; + renameSync(manifest, replacement); + symlinkSync(replacement, manifest); + assert.throws(() => verifyArtifactManifest({ directory: sealed }), { code: 'CANDIDATE_ARTIFACT_LINK_REJECTED' }); + assert.throws(() => sealArtifactManifest({ directory: sealed }), { code: 'CANDIDATE_ARTIFACT_ALREADY_SEALED' }); + + const oversized = fixture(); + write(oversized, 'proof.txt', 'proof'); + sealArtifactManifest({ directory: oversized }); + writeFileSync(join(oversized, ARTIFACT_MANIFEST_FILE), 'x'.repeat(80)); + assert.throws(() => verifyArtifactManifest({ directory: oversized, limits: { maxManifestBytes: 64 } }), + { code: 'CANDIDATE_ARTIFACT_LIMIT_EXCEEDED' }); + + const special = fixture(); + write(special, 'proof.txt', 'proof'); + sealArtifactManifest({ directory: special }); + const specialManifest = join(special, ARTIFACT_MANIFEST_FILE); + unlinkSync(specialManifest); + mkdirSync(specialManifest); + assert.throws(() => verifyArtifactManifest({ directory: special }), { code: 'CANDIDATE_ARTIFACT_SPECIAL_REJECTED' }); +}); + +test('SCN-artifact-manifest-complete-drift: adding artifacts or replacing the manifest cannot preserve a sealed record', () => { + const root = fixture(); + write(root, 'proof.txt', 'before'); + sealArtifactManifest({ directory: root }); + write(root, 'added.txt', 'after'); + assert.throws(() => verifyArtifactManifest({ directory: root }), { code: 'CANDIDATE_ARTIFACT_SEAL_DRIFT' }); + + const replaced = fixture(); + write(replaced, 'proof.txt', 'before'); + sealArtifactManifest({ directory: replaced }); + writeFileSync(join(replaced, ARTIFACT_MANIFEST_FILE), JSON.stringify({ schema: 'torch.dev/artifact-manifest/v1alpha1', files: [], totalBytes: 0, sealDigest: '0'.repeat(64) })); + assert.throws(() => verifyArtifactManifest({ directory: replaced }), { code: 'CANDIDATE_ARTIFACT_SEAL_INVALID' }); +}); diff --git a/test/fleet/candidate-engine.test.mjs b/test/fleet/candidate-engine.test.mjs new file mode 100644 index 0000000..83b625e --- /dev/null +++ b/test/fleet/candidate-engine.test.mjs @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { CANDIDATE_ENGINE_LIMITS, runCandidateEngine } from '../../src/checks/candidate-engine.mjs'; + +function engineLimits(overrides = {}) { + return { ...CANDIDATE_ENGINE_LIMITS, ...overrides }; +} + +function fixtureInput() { + const bytes = Buffer.from('fixture input bytes', 'utf8'); + return { + directory: mkdtempSync(join(tmpdir(), 'torch-candidate-engine-')), + digest: createHash('sha256').update(bytes).digest('hex'), + bytes, + byteLength: bytes.length, + }; +} + +function fixtureOptions(overrides = {}) { + return { + input: fixtureInput(), + definition: { command: 'fixture-command', args: ['--fixed'] }, + args: ['--bounded'], + environment: { TZ: 'UTC' }, + artifactDirectory: mkdtempSync(join(tmpdir(), 'torch-candidate-artifacts-')), + executor: () => ({ status: 0, stdout: 'fixture output', stderr: '' }), + limits: engineLimits(), + ...overrides, + }; +} + +test('SCN-candidate-engine-pure-fixture: immutable bounded fixture execution returns only parent-observed nonpromotable evidence', () => { + let invocation; + const options = fixtureOptions({ + executor: (command, args, execution) => { + invocation = { command, args, execution }; + return { status: 0, stdout: 'fixture output', stderr: '' }; + }, + }); + const observed = runCandidateEngine(options); + assert.equal(observed.fixtureOnly, true); + assert.deepEqual(observed.sourceAttestation, { + schema: 'torch.dev/source-attestation/v1alpha1', promotion: 'nonpromotable', + }); + assert.equal(Object.hasOwn(observed.sourceAttestation, 'receiptId'), false); + assert.equal(observed.terminal.success, true); + assert.equal(observed.terminal.kind, 'exit'); + assert.deepEqual(invocation.args, ['--fixed', '--bounded']); + assert.equal(invocation.execution.cwd, options.input.directory); + assert.deepEqual(invocation.execution.env, { TZ: 'UTC' }); + assert.equal(Object.isFrozen(invocation.execution), true); + assert.equal(invocation.execution.timeout, CANDIDATE_ENGINE_LIMITS.timeoutMs); + assert.equal(invocation.execution.maxBuffer, CANDIDATE_ENGINE_LIMITS.maxOutputBytes); + assert.equal(Object.hasOwn(invocation.execution, 'token'), false); + assert.equal(Object.hasOwn(invocation.execution, 'receiptWriter'), false); + assert.equal(Object.hasOwn(invocation.execution, 'controlPlane'), false); +}); + +test('SCN-candidate-engine-limits: invalid bounded inputs, arguments, environment and artifact descriptors refuse before execution', () => { + let executions = 0; + const executor = () => { executions += 1; return { status: 0 }; }; + assert.throws(() => runCandidateEngine(fixtureOptions({ executor, input: { directory: '/tmp', digest: 'bad', bytes: Buffer.from('x'), byteLength: 1 } })), + { code: 'CANDIDATE_ENGINE_INPUT_INVALID' }); + assert.throws(() => runCandidateEngine(fixtureOptions({ executor, args: ['x'.repeat(8)], limits: engineLimits({ maxArgBytes: 4 }) })), + { code: 'CANDIDATE_ENGINE_INPUT_INVALID' }); + assert.throws(() => runCandidateEngine(fixtureOptions({ executor, environment: { bad: '1' } })), + { code: 'CANDIDATE_ENGINE_ENVIRONMENT_INVALID' }); + assert.throws(() => runCandidateEngine(fixtureOptions({ executor, artifactDirectory: 'x'.repeat(5), limits: engineLimits({ maxArtifactPathBytes: 4 }) })), + { code: 'CANDIDATE_ENGINE_INPUT_INVALID' }); + assert.throws(() => runCandidateEngine(fixtureOptions({ executor, limits: engineLimits({ maxInputBytes: 4 }) })), + { code: 'CANDIDATE_ENGINE_INPUT_INVALID' }); + assert.throws(() => runCandidateEngine(fixtureOptions({ executor, environment: { TZ: 'UTC', LANG: 'C.UTF-8' }, limits: engineLimits({ maxEnvironmentEntries: 1 }) })), + { code: 'CANDIDATE_ENGINE_INPUT_INVALID' }); + assert.equal(executions, 0, 'refusal must occur before a candidate executor starts'); +}); + +test('SCN-candidate-engine-terminal-observation: overflow, including exit zero plus ENOBUFS, is raw parent evidence and never success', () => { + const overflow = runCandidateEngine(fixtureOptions({ + limits: engineLimits({ maxOutputBytes: 4 }), + executor: () => ({ status: 0, error: Object.assign(new Error('overflow'), { code: 'ENOBUFS' }), stdout: 'too many bytes' }), + })); + assert.equal(overflow.terminal.exitStatus, 0); + assert.equal(overflow.terminal.kind, 'overflow'); + assert.equal(overflow.terminal.success, false); + assert.equal(overflow.terminal.overflow, true); + + const timeout = runCandidateEngine(fixtureOptions({ + executor: () => ({ status: null, timedOut: true, error: Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }) }), + })); + assert.equal(timeout.terminal.kind, 'timeout'); + assert.equal(timeout.terminal.success, false); + + const signal = runCandidateEngine(fixtureOptions({ executor: () => ({ status: null, signal: 'SIGTERM' }) })); + assert.equal(signal.terminal.kind, 'signal'); + assert.equal(signal.terminal.success, false); +}); + +test('SCN-candidate-engine-capability-boundary: registered-state and writer-shaped inputs are rejected before execution', () => { + let executions = 0; + const options = fixtureOptions({ executor: () => { executions += 1; return { status: 0 }; } }); + options.controlPlane = {}; + assert.throws(() => runCandidateEngine(options), { code: 'CANDIDATE_ENGINE_CAPABILITY_FORBIDDEN' }); + assert.equal(executions, 0); +}); + +test('SCN-candidate-engine-boundary-limits: every execution bound is explicit, finite and supplied to the native-shaped executor options', () => { + let executions = 0; + const executor = () => { executions += 1; return { status: 0 }; }; + const missingTimeout = engineLimits(); + delete missingTimeout.timeoutMs; + const missingOutput = engineLimits(); + delete missingOutput.maxOutputBytes; + const missingInput = engineLimits(); + delete missingInput.maxInputBytes; + for (const limits of [missingTimeout, missingOutput, missingInput, engineLimits({ timeoutMs: 0 }), engineLimits({ timeoutMs: 60_001 })]) { + assert.throws(() => runCandidateEngine(fixtureOptions({ executor, limits })), { code: 'CANDIDATE_ENGINE_LIMIT_INVALID' }); + } + assert.equal(executions, 0, 'missing or invalid bounds refuse before execution'); + + let execution; + runCandidateEngine(fixtureOptions({ executor: (_command, _args, options) => { execution = options; return { status: 0 }; } })); + assert.deepEqual(Object.keys(execution).sort(), ['artifactDirectory', 'cwd', 'encoding', 'env', 'killSignal', 'maxBuffer', 'stdio', 'timeout']); + assert.equal(execution.timeout, CANDIDATE_ENGINE_LIMITS.timeoutMs); + assert.equal(execution.maxBuffer, CANDIDATE_ENGINE_LIMITS.maxOutputBytes); +}); + +test('SCN-candidate-engine-utf8-overflow: retained output is a valid UTF-8 prefix inside the byte cap and cannot become success', () => { + const fourBytes = runCandidateEngine(fixtureOptions({ + limits: engineLimits({ maxOutputBytes: 4 }), + executor: () => ({ status: 0, stdout: '๐Ÿ˜€๐Ÿ˜€', stderr: '' }), + })); + assert.equal(fourBytes.terminal.stdout.text, '๐Ÿ˜€'); + assert.equal(Buffer.byteLength(fourBytes.terminal.stdout.text, 'utf8'), 4); + assert.equal(fourBytes.terminal.overflow, true); + assert.equal(fourBytes.terminal.success, false); + + const threeBytes = runCandidateEngine(fixtureOptions({ + limits: engineLimits({ maxOutputBytes: 3 }), + executor: () => ({ status: 0, stdout: '๐Ÿ˜€๐Ÿ˜€', stderr: '' }), + })); + assert.equal(threeBytes.terminal.stdout.text, ''); + assert.equal(Buffer.byteLength(threeBytes.terminal.stdout.text, 'utf8') <= 3, true); + assert.equal(threeBytes.terminal.stdout.text.includes('\uFFFD'), false); + assert.equal(threeBytes.terminal.success, false); +}); + +test('SCN-candidate-engine-immutable-input-and-environment: only matched byte snapshots and allowlisted deterministic environment reach the executor', () => { + let executions = 0; + const executor = () => { executions += 1; return { status: 0 }; }; + const mismatched = fixtureInput(); + mismatched.digest = '0'.repeat(64); + assert.throws(() => runCandidateEngine(fixtureOptions({ executor, input: mismatched })), { code: 'CANDIDATE_ENGINE_INPUT_INVALID' }); + assert.throws(() => runCandidateEngine(fixtureOptions({ executor, input: { directory: '/tmp', digest: '0'.repeat(64), byteLength: 0 } })), + { code: 'CANDIDATE_ENGINE_INPUT_INVALID' }); + assert.throws(() => runCandidateEngine(fixtureOptions({ executor, environment: { UNREVIEWED_SECRET: '1' } })), + { code: 'CANDIDATE_ENGINE_ENVIRONMENT_INVALID' }); + assert.equal(executions, 0, 'invalid snapshot or environment refuses before execution'); + + const input = fixtureInput(); + const definition = { command: 'fixture-command', args: ['--fixed'] }; + const args = ['--bounded']; + const environment = { TZ: 'UTC' }; + let invocation; + const observed = runCandidateEngine(fixtureOptions({ + input, definition, args, environment, + executor: (_command, commandArgs, execution) => { + input.bytes.fill(0); + definition.args[0] = '--mutated'; + args[0] = '--mutated'; + environment.TZ = 'America/New_York'; + invocation = { commandArgs, execution }; + return { status: 0 }; + }, + })); + assert.deepEqual(invocation.commandArgs, ['--fixed', '--bounded']); + assert.deepEqual(invocation.execution.env, { TZ: 'UTC' }); + assert.deepEqual(observed.input, { + directory: input.directory, + digest: createHash('sha256').update(Buffer.from('fixture input bytes')).digest('hex'), + bytes: Buffer.byteLength('fixture input bytes'), + }); +});