diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index 44c659d..bed22bc 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -2715,3 +2715,42 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` + +# Candidate source B2 frozen-input and fence boundaries (2026-10-01) + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-candidate-bootstrap-one-shot + given: "An issued, nonce-bound native admission" + when: "It is opened, replayed, expired, foreign, or malformed" + then: "Only one bounded private bootstrap opens; every invalid case refuses before child start and exposes no authority handle" + - id: SCN-candidate-input-consumption + given: "A frozen project-local source snapshot" + when: "The original source changes before the fixed Node child reads its cwd" + then: "The child observes the captured bytes; snapshot drift refuses" + - id: SCN-candidate-execution-boundary + given: "A fixed command and explicit bounded environment" + when: "Ambient PATH/HOME/cache fallback or output overflow is attempted" + then: "Execution refuses or remains non-success" + - id: SCN-candidate-fence-closure + given: "A private PID1 namespace observation" + when: "Descendant closure is absent or uncertain" + then: "The outcome is UNKNOWN/INCOMPLETE, never terminal PASS" + observable_outcomes: + - "No DB, ControlPlane, writer, receipt adapter, or authority FD reaches the child" + - "Product input links and special nodes refuse; frozen product S stays under 64MiB/file and 512MiB total" + - "Artifact output remains M/artifactManifest; receipt-adapter A is absent and unclaimed" + determinism_controls: + - "Temporary source trees, fixed Node executable, explicit empty ambient environment, bounded streams, and injected observations" + - "No live policy registration, installation, network, cache, or provider process" + anti_cheat_rationale: + prevents: + - "Reading mutated live source instead of captured bytes" + - "Replaying a private admission or relabeling fixture output as a receipt" + - "Treating PGID emptiness, zero exit, or Chromium metadata as descendant/binary proof" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/src/checks/candidate-execution-fence-init.mjs b/src/checks/candidate-execution-fence-init.mjs new file mode 100644 index 0000000..bbadb03 --- /dev/null +++ b/src/checks/candidate-execution-fence-init.mjs @@ -0,0 +1,3 @@ +// Private init entrypoint. It is never a CLI surface: bootstrap is supplied only on inherited FD3 and acknowledgement only on FD4. +import { closeSync, readFileSync, writeFileSync } from 'node:fs'; +export function readPrivateFenceBootstrapV2() { const frame = readFileSync(3, 'utf8'); if (Buffer.byteLength(frame, 'utf8') > 65_536) throw new Error('CANDIDATE_FENCE_FRAME_TOO_LARGE'); const value = JSON.parse(frame); if (!value?.nonce || !value?.generation) throw new Error('CANDIDATE_FENCE_FRAME_INVALID'); writeFileSync(4, JSON.stringify({ nonce: value.nonce, generation: value.generation })); closeSync(3); closeSync(4); return Object.freeze({ nonce: value.nonce, generation: value.generation, admissionFdsClosed: true }); } diff --git a/src/checks/candidate-execution-fence.mjs b/src/checks/candidate-execution-fence.mjs new file mode 100644 index 0000000..4990a2f --- /dev/null +++ b/src/checks/candidate-execution-fence.mjs @@ -0,0 +1,6 @@ +import { TorchError } from '../kernel/errors.mjs'; +const fail = (message, code, details) => { throw new TorchError(message, { code, details }); }; +export function createCandidateExecutionFenceV2({ bootstrap, backend = 'private-pid1-namespace' } = {}) { if (!bootstrap?.transport?.closesBeforeCandidateSpawn || backend !== 'private-pid1-namespace') fail('Candidate fence has no supported private bootstrap', 'CANDIDATE_FENCE_UNAVAILABLE'); return Object.freeze({ schema: 'torch.dev/candidate-execution-fence/v2alpha1', backend, nonce: bootstrap.nonce, closed: false }); } +/** A direct exit or empty PGID is deliberately insufficient: missing observed PID1/reap proof is UNKNOWN. */ +export function observeCandidateExecutionFenceV2({ fence, observation = {} } = {}) { if (!fence || fence.closed) fail('Candidate fence is unavailable', 'CANDIDATE_FENCE_UNAVAILABLE'); const closed = observation.privatePid1 === true && observation.namespaceObserved === true && observation.descendantsReaped === true && observation.admissionFdsClosed === true; return Object.freeze({ schema: 'torch.dev/candidate-terminal-proof/v2alpha1', outcome: closed ? 'terminal' : 'unknown', terminalProof: closed, reason: closed ? null : 'descendant-closure-unobserved', observation: Object.freeze({ privatePid1: observation.privatePid1 === true, namespaceObserved: observation.namespaceObserved === true, descendantsReaped: observation.descendantsReaped === true, admissionFdsClosed: observation.admissionFdsClosed === true }) }); } +export function closeCandidateExecutionFenceV2(fence) { if (!fence || fence.closed) fail('Candidate fence is already closed', 'CANDIDATE_FENCE_UNAVAILABLE'); return Object.freeze({ ...fence, closed: true }); } diff --git a/src/checks/candidate-input-snapshot.mjs b/src/checks/candidate-input-snapshot.mjs new file mode 100644 index 0000000..20f2fe8 --- /dev/null +++ b/src/checks/candidate-input-snapshot.mjs @@ -0,0 +1,69 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, writeSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { TorchError } from '../kernel/errors.mjs'; + +export const CANDIDATE_PRODUCT_INPUT_LIMITS_V2 = Object.freeze({ maxRecords: 8192, maxPathUtf8Bytes: 4096, maxFileBytes: 67_108_864, maxTotalBytes: 536_870_912, maxFrameBytes: 1_048_576, chunkBytes: 65_536 }); +const fail = (message, code, details) => { throw new TorchError(message, { code, details }); }; +const order = (a, b) => Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')); +const bytes = (value) => Buffer.byteLength(value, 'utf8'); +function pathOf(value, limits) { + if (typeof value !== 'string' || !value || isAbsolute(value) || value.includes('\\') || value.includes('\0') || bytes(value) > limits.maxPathUtf8Bytes || value.split('/').some((part) => !part || part === '.' || part === '..' || part === '.git')) fail('Candidate snapshot path is invalid', 'CANDIDATE_SNAPSHOT_PATH_INVALID', { path: value }); + return value; +} +function normalizeLimits(value = {}) { + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((key) => !Object.hasOwn(CANDIDATE_PRODUCT_INPUT_LIMITS_V2, key))) fail('Candidate snapshot limits are invalid', 'CANDIDATE_SNAPSHOT_LIMIT_INVALID'); + const result = {}; + for (const [key, maximum] of Object.entries(CANDIDATE_PRODUCT_INPUT_LIMITS_V2)) { const v = value[key] ?? maximum; if (!Number.isInteger(v) || v < 1 || v > maximum) fail('Candidate snapshot limit is invalid', 'CANDIDATE_SNAPSHOT_LIMIT_INVALID', { key, value: v }); result[key] = v; } + return Object.freeze(result); +} +function regular(path) { const stat = lstatSync(path); if (stat.isSymbolicLink()) fail('Candidate snapshot rejects links', 'CANDIDATE_SNAPSHOT_LINK_REJECTED', { path }); if (!stat.isFile() && !stat.isDirectory()) fail('Candidate snapshot rejects special files', 'CANDIDATE_SNAPSHOT_SPECIAL_REJECTED', { path }); return stat; } +function files(root, paths, limits) { + const result = []; + const visit = (rel) => { const absolute = join(root, rel); const stat = regular(absolute); if (stat.isDirectory()) for (const name of readdirSync(absolute).sort(order)) visit(`${rel}/${name}`); else { if (stat.size > limits.maxFileBytes) fail('Candidate snapshot file cap exceeded', 'CANDIDATE_SNAPSHOT_LIMIT_EXCEEDED', { path: rel }); result.push({ path: rel, size: stat.size, executable: Boolean(stat.mode & 0o111) }); } }; + for (const path of [...new Set(paths.map((p) => pathOf(p, limits)))].sort(order)) visit(path); + if (!result.length || result.length > limits.maxRecords) fail('Candidate snapshot record cap exceeded', 'CANDIDATE_SNAPSHOT_LIMIT_EXCEEDED'); + return result.sort((a, b) => order(a.path, b.path)); +} +function copyAndHash(source, destination, expected, limits) { + let inFd; let outFd; + try { + inFd = openSync(source, constants.O_RDONLY | constants.O_NOFOLLOW); const before = fstatSync(inFd); + if (!before.isFile() || before.size !== expected.size || before.size > limits.maxFileBytes) fail('Candidate snapshot source drifted', 'CANDIDATE_SNAPSHOT_DRIFT', { path: expected.path }); + outFd = openSync(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, expected.executable ? 0o500 : 0o400); + const hash = createHash('sha256'); const buffer = Buffer.alloc(limits.chunkBytes); let total = 0; + for (;;) { const count = readSync(inFd, buffer, 0, buffer.length, null); if (!count) break; total += count; if (total > limits.maxFileBytes) fail('Candidate snapshot file cap exceeded', 'CANDIDATE_SNAPSHOT_LIMIT_EXCEEDED'); hash.update(buffer.subarray(0, count)); let offset = 0; while (offset < count) offset += writeSync(outFd, buffer, offset, count - offset); } + const after = fstatSync(inFd); if (total !== before.size || after.size !== before.size) fail('Candidate snapshot source changed while read', 'CANDIDATE_SNAPSHOT_DRIFT', { path: expected.path }); + return { path: expected.path, bytes: total, sha256: hash.digest('hex'), executable: expected.executable }; + } finally { if (inFd !== undefined) closeSync(inFd); if (outFd !== undefined) closeSync(outFd); } +} +function hashRegularFile(path, expected, limits) { + let fd; + try { + fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); const before = fstatSync(fd); + if (!before.isFile() || before.size !== expected.bytes || before.size > limits.maxFileBytes) fail('Candidate snapshot drifted', 'CANDIDATE_SNAPSHOT_DRIFT', { path: expected.path }); + const hash = createHash('sha256'); const buffer = Buffer.alloc(limits.chunkBytes); let total = 0; + for (;;) { const count = readSync(fd, buffer, 0, buffer.length, null); if (!count) break; total += count; if (total > limits.maxFileBytes) fail('Candidate snapshot cap exceeded', 'CANDIDATE_SNAPSHOT_LIMIT_EXCEEDED'); hash.update(buffer.subarray(0, count)); } + const after = fstatSync(fd); if (total !== before.size || after.size !== before.size) fail('Candidate snapshot drifted while read', 'CANDIDATE_SNAPSHOT_DRIFT', { path: expected.path }); + return { path: expected.path, bytes: total, sha256: hash.digest('hex'), executable: expected.executable }; + } finally { if (fd !== undefined) closeSync(fd); } +} +/** Private caller seam: callers obtain inputRoot from the returned opaque snapshot, never from a policy selector. */ +export function captureCandidateInputSnapshotV2({ sourceRoot, destinationRoot, paths, limits: requestedLimits = {}, id = randomUUID() } = {}) { + const limits = normalizeLimits(requestedLimits); if (typeof sourceRoot !== 'string' || typeof destinationRoot !== 'string' || !Array.isArray(paths)) fail('Candidate snapshot request is invalid', 'CANDIDATE_SNAPSHOT_INPUT_INVALID'); + const source = resolve(sourceRoot); const destination = resolve(destinationRoot); if (source === destination || !relative(source, destination).startsWith('..')) fail('Candidate snapshot destination must be outside source', 'CANDIDATE_SNAPSHOT_INPUT_INVALID'); + const listed = files(source, paths, limits); mkdirSync(destination, { recursive: false, mode: 0o700 }); let totalBytes = 0; const records = []; + for (const item of listed) { const target = join(destination, item.path); mkdirSync(resolve(target, '..'), { recursive: true, mode: 0o700 }); const record = copyAndHash(join(source, item.path), target, item, limits); totalBytes += record.bytes; if (totalBytes > limits.maxTotalBytes) fail('Candidate snapshot total cap exceeded', 'CANDIDATE_SNAPSHOT_LIMIT_EXCEEDED'); records.push(record); } + const manifest = Object.freeze({ schema: 'torch.dev/candidate-input-manifest/v2alpha1', id, files: Object.freeze(records), totalBytes, digest: createHash('sha256').update(JSON.stringify(records)).digest('hex') }); + return Object.freeze({ schema: 'torch.dev/candidate-input-snapshot/v2alpha1', inputRoot: destination, manifest, limits }); +} +export function verifyCandidateInputSnapshotV2(snapshot = {}) { + if (snapshot?.schema !== 'torch.dev/candidate-input-snapshot/v2alpha1' || !snapshot.manifest?.files || typeof snapshot.inputRoot !== 'string') fail('Candidate snapshot is malformed', 'CANDIDATE_SNAPSHOT_INVALID'); + const listed = files(snapshot.inputRoot, snapshot.manifest.files.map((file) => file.path), snapshot.limits); + const observed = listed.map((file) => hashRegularFile(join(snapshot.inputRoot, file.path), { ...file, bytes: file.size }, snapshot.limits)); + if (JSON.stringify(observed) !== JSON.stringify(snapshot.manifest.files) + || createHash('sha256').update(JSON.stringify(observed)).digest('hex') !== snapshot.manifest.digest) { + fail('Candidate snapshot content drifted', 'CANDIDATE_SNAPSHOT_DRIFT'); + } + return Object.freeze({ verified: true, digest: snapshot.manifest.digest, files: observed.length, totalBytes: snapshot.manifest.totalBytes }); +} diff --git a/src/checks/candidate-source-bootstrap.mjs b/src/checks/candidate-source-bootstrap.mjs new file mode 100644 index 0000000..ccf0af2 --- /dev/null +++ b/src/checks/candidate-source-bootstrap.mjs @@ -0,0 +1,16 @@ +import { randomBytes } from 'node:crypto'; +import { TorchError } from '../kernel/errors.mjs'; + +const live = new WeakSet(); const used = new Set(); +const fail = (message, code, details) => { throw new TorchError(message, { code, details }); }; +/** CheckService-only issuer. The returned object is intentionally not serializable and carries no DB/adapter/writer capability. */ +export function issueCandidateNativeAdmissionV2({ projectId, areaId, checkId, runId, generation, policyDigest, engineDigest, expiresAt } = {}) { + for (const [key, value] of Object.entries({ projectId, areaId, checkId, runId, generation, policyDigest, engineDigest, expiresAt })) if (typeof value !== 'string' || !value) fail('Candidate native admission is incomplete', 'CANDIDATE_NATIVE_ADMISSION_INVALID', { key }); + const admission = Object.freeze({ schema: 'torch.dev/candidate-native-admission/v2alpha1', projectId, areaId, checkId, runId, generation, policyDigest, engineDigest, expiresAt, nonce: randomBytes(32).toString('base64url') }); live.add(admission); return admission; +} +export function openCandidateSourceBootstrapV2(admission, { now = () => new Date() } = {}) { + if (!live.has(admission) || used.has(admission.nonce)) fail('Candidate native admission is unavailable or replayed', 'CANDIDATE_NATIVE_ADMISSION_REPLAY'); + if (Number.isNaN(Date.parse(admission.expiresAt)) || Date.parse(admission.expiresAt) <= now().getTime()) fail('Candidate native admission expired', 'CANDIDATE_NATIVE_ADMISSION_EXPIRED'); + used.add(admission.nonce); live.delete(admission); + return Object.freeze({ schema: 'torch.dev/candidate-bootstrap/v2alpha1', nonce: admission.nonce, runId: admission.runId, generation: admission.generation, policyDigest: admission.policyDigest, engineDigest: admission.engineDigest, expiresAt: admission.expiresAt, transport: Object.freeze({ inputFd: 3, acknowledgementFd: 4, closesBeforeCandidateSpawn: true }) }); +} diff --git a/src/checks/candidate-source-execution.mjs b/src/checks/candidate-source-execution.mjs new file mode 100644 index 0000000..30151d8 --- /dev/null +++ b/src/checks/candidate-source-execution.mjs @@ -0,0 +1,15 @@ +import { spawnSync } from 'node:child_process'; +import { isAbsolute } from 'node:path'; +import { TorchError } from '../kernel/errors.mjs'; +import { verifyCandidateInputSnapshotV2 } from './candidate-input-snapshot.mjs'; +const fail = (message, code, details) => { throw new TorchError(message, { code, details }); }; +export const CANDIDATE_EXECUTION_LIMITS_V2 = Object.freeze({ timeoutMs: 300000, maxOutputBytes: 1048576, maxArgs: 64, maxArgBytes: 4096 }); +function bound(value, limit, name) { if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > limit) fail('Candidate execution value is invalid', 'CANDIDATE_EXECUTION_INVALID', { name }); return value; } +export function runCandidateSourceExecutionV2({ command, args = [], inputSnapshot, environment = {}, executor = spawnSync, limits = CANDIDATE_EXECUTION_LIMITS_V2 } = {}) { + if (!isAbsolute(command) || inputSnapshot?.schema !== 'torch.dev/candidate-input-snapshot/v2alpha1' || typeof executor !== 'function' || !Array.isArray(args) || args.length > limits.maxArgs || Object.keys(environment).some((key) => ['PATH', 'HOME', 'NODE_PATH', 'npm_config_cache'].includes(key))) fail('Candidate execution request is invalid', 'CANDIDATE_EXECUTION_INVALID'); + verifyCandidateInputSnapshotV2(inputSnapshot); + const safeArgs = args.map((arg) => bound(arg, limits.maxArgBytes, 'arg')); const env = Object.freeze(Object.fromEntries(Object.entries(environment).sort())); + let raw; try { raw = executor(command, safeArgs, { cwd: inputSnapshot.inputRoot, env, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], timeout: limits.timeoutMs, maxBuffer: limits.maxOutputBytes, killSignal: 'SIGTERM' }); } catch (error) { raw = { status: null, error }; } + const stdout = Buffer.from(raw?.stdout ?? ''); const stderr = Buffer.from(raw?.stderr ?? ''); const overflow = stdout.length > limits.maxOutputBytes || stderr.length > limits.maxOutputBytes || raw?.error?.code === 'ENOBUFS'; const timeout = raw?.error?.code === 'ETIMEDOUT' || raw?.timedOut === true; + return Object.freeze({ schema: 'torch.dev/candidate-source-execution/v2alpha1', terminal: Object.freeze({ kind: timeout ? 'timeout' : overflow ? 'overflow' : raw?.signal ? 'signal' : raw?.error ? 'error' : 'exit', exitStatus: Number.isInteger(raw?.status) ? raw.status : null, signal: raw?.signal ?? null, overflow, timeout, success: !timeout && !overflow && !raw?.signal && !raw?.error && raw?.status === 0 }), output: Object.freeze({ stdout: stdout.subarray(0, limits.maxOutputBytes), stderr: stderr.subarray(0, limits.maxOutputBytes) }) }); +} diff --git a/src/checks/service.mjs b/src/checks/service.mjs index fcc3458..422200f 100644 --- a/src/checks/service.mjs +++ b/src/checks/service.mjs @@ -84,6 +84,19 @@ function captureGeneratedArtifacts({ generatedDirectory, commit, checkId, defini }; } +// B2 admission boundary only. Kernel owns policy parsing/registration and the parent; +// this service must never synthesize either from cwd, environment, or caller JSON. +function assertCandidateNativeAdmissionAvailable(definition) { + if (!Object.hasOwn(definition ?? {}, 'candidate_execution_policy')) return; + if (!definition.candidate_execution_policy) { + throw new TorchError('Candidate execution policy is not registered', { code: 'CANDIDATE_SNAPSHOT_POLICY_UNDECLARED' }); + } + // A registered policy alone is not the genuine parent issuer or receipt adapter. + throw new TorchError('Candidate native parent bootstrap is unavailable', { + code: 'CANDIDATE_PARENT_NATIVE_BOOTSTRAP_UNAVAILABLE', + }); +} + export class CheckService { constructor({ repositoryRoot, controlPlane, resourceService = null, @@ -330,6 +343,8 @@ export class CheckService { if (missing.length) throw new TorchError('Prepared check requires resource leases', { code: 'CHECK_BLOCKED', details: missing.map((resourceId) => ({ code: 'RESOURCE_LEASE_REQUIRED', resourceId })), }); + // Deliberately before snapshot verification, state mutation, artifacts, or child spawn. + assertCandidateNativeAdmissionAvailable(currentDefinition); verifyCheckSnapshot(item.snapshot, { stateRoot: this.stateRoot }); const claimed = this.controlPlane.database.prepare(`UPDATE prepared_checks SET state = 'running', runner_json = ? WHERE id = ? AND project_id = ? AND area_id = ? AND state = 'prepared'`) diff --git a/test/fleet/candidate-execution-fence.test.mjs b/test/fleet/candidate-execution-fence.test.mjs new file mode 100644 index 0000000..7b7590c --- /dev/null +++ b/test/fleet/candidate-execution-fence.test.mjs @@ -0,0 +1,5 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createCandidateExecutionFenceV2, observeCandidateExecutionFenceV2 } from '../../src/checks/candidate-execution-fence.mjs'; +const bootstrap = { nonce: 'n', transport: { closesBeforeCandidateSpawn: true } }; +test('SCN-candidate-fence-closure: double-fork/escaped-descendant uncertainty is UNKNOWN, never terminal proof', () => { const fence = createCandidateExecutionFenceV2({ bootstrap }); const unknown = observeCandidateExecutionFenceV2({ fence, observation: { privatePid1: true, namespaceObserved: true, admissionFdsClosed: true, descendantsReaped: false } }); assert.equal(unknown.outcome, 'unknown'); assert.equal(unknown.terminalProof, false); const terminal = observeCandidateExecutionFenceV2({ fence, observation: { privatePid1: true, namespaceObserved: true, admissionFdsClosed: true, descendantsReaped: true } }); assert.equal(terminal.terminalProof, true); }); diff --git a/test/fleet/candidate-input-consumption.test.mjs b/test/fleet/candidate-input-consumption.test.mjs new file mode 100644 index 0000000..3aac9fb --- /dev/null +++ b/test/fleet/candidate-input-consumption.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { captureCandidateInputSnapshotV2, verifyCandidateInputSnapshotV2 } from '../../src/checks/candidate-input-snapshot.mjs'; +import { runCandidateSourceExecutionV2 } from '../../src/checks/candidate-source-execution.mjs'; +function roots() { const root = mkdtempSync(join(tmpdir(), 'torch-b2-source-')); mkdirSync(join(root, 'src')); writeFileSync(join(root, 'src', 'input.txt'), 'captured'); return { root, snapshot: `${root}-snapshot` }; } +test('SCN-candidate-input-consumption: child execution seam receives frozen cwd bytes after original source mutation', () => { const { root, snapshot: destination } = roots(); const input = captureCandidateInputSnapshotV2({ sourceRoot: root, destinationRoot: destination, paths: ['src'] }); writeFileSync(join(root, 'src', 'input.txt'), 'mutated'); const result = runCandidateSourceExecutionV2({ command: process.execPath, args: [], inputSnapshot: input, executor: (_command, _args, execution) => ({ status: 0, stdout: readFileSync(join(execution.cwd, 'src', 'input.txt')) }) }); assert.equal(result.terminal.success, true); assert.equal(result.output.stdout.toString(), 'captured'); assert.equal(verifyCandidateInputSnapshotV2(input).verified, true); }); +test('SCN-candidate-input-drift: frozen-input mutation refuses rather than relabeling evidence', () => { const { root, snapshot: destination } = roots(); const input = captureCandidateInputSnapshotV2({ sourceRoot: root, destinationRoot: destination, paths: ['src'] }); const file = join(input.inputRoot, 'src', 'input.txt'); chmodSync(file, 0o600); writeFileSync(file, 'drift'); assert.throws(() => verifyCandidateInputSnapshotV2(input), { code: 'CANDIDATE_SNAPSHOT_DRIFT' }); }); diff --git a/test/fleet/candidate-snapshot-policy.test.mjs b/test/fleet/candidate-snapshot-policy.test.mjs new file mode 100644 index 0000000..61c2352 --- /dev/null +++ b/test/fleet/candidate-snapshot-policy.test.mjs @@ -0,0 +1,7 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { captureCandidateInputSnapshotV2 } from '../../src/checks/candidate-input-snapshot.mjs'; +test('SCN-candidate-snapshot-policy: product links and S caps refuse before a snapshot is admitted', () => { const root = mkdtempSync(join(tmpdir(), 'torch-b2-policy-')); writeFileSync(join(root, 'file'), 'x'); symlinkSync('file', join(root, 'link')); assert.throws(() => captureCandidateInputSnapshotV2({ sourceRoot: root, destinationRoot: `${root}-snapshot`, paths: ['link'] }), { code: 'CANDIDATE_SNAPSHOT_LINK_REJECTED' }); }); diff --git a/test/fleet/candidate-source-bootstrap.test.mjs b/test/fleet/candidate-source-bootstrap.test.mjs new file mode 100644 index 0000000..95116dc --- /dev/null +++ b/test/fleet/candidate-source-bootstrap.test.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { issueCandidateNativeAdmissionV2, openCandidateSourceBootstrapV2 } from '../../src/checks/candidate-source-bootstrap.mjs'; + +const issued = (expiresAt = '2030-01-01T00:00:00.000Z') => issueCandidateNativeAdmissionV2({ projectId: 'project', areaId: 'work-integration', checkId: 'test', runId: 'run', generation: 'g1', policyDigest: 'a'.repeat(64), engineDigest: 'b'.repeat(64), expiresAt }); +test('SCN-candidate-bootstrap-one-shot: only issued native admission opens once and supplies no authority handles', () => { + assert.throws(() => openCandidateSourceBootstrapV2({}), { code: 'CANDIDATE_NATIVE_ADMISSION_REPLAY' }); + const admission = issued(); const bootstrap = openCandidateSourceBootstrapV2(admission, { now: () => new Date('2029-01-01T00:00:00.000Z') }); + assert.equal(bootstrap.transport.closesBeforeCandidateSpawn, true); assert.deepEqual(Object.keys(bootstrap).sort(), ['engineDigest', 'expiresAt', 'generation', 'nonce', 'policyDigest', 'runId', 'schema', 'transport']); + assert.equal(Object.hasOwn(bootstrap, 'controlPlane'), false); assert.throws(() => openCandidateSourceBootstrapV2(admission, { now: () => new Date('2029-01-01T00:00:00.000Z') }), { code: 'CANDIDATE_NATIVE_ADMISSION_REPLAY' }); +}); +test('SCN-candidate-bootstrap-expiry: expired admission refuses before any child protocol exists', () => assert.throws(() => openCandidateSourceBootstrapV2(issued('2020-01-01T00:00:00.000Z')), { code: 'CANDIDATE_NATIVE_ADMISSION_EXPIRED' })); diff --git a/test/fleet/candidate-source-execution.test.mjs b/test/fleet/candidate-source-execution.test.mjs new file mode 100644 index 0000000..11b5f0e --- /dev/null +++ b/test/fleet/candidate-source-execution.test.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { captureCandidateInputSnapshotV2 } from '../../src/checks/candidate-input-snapshot.mjs'; +import { runCandidateSourceExecutionV2 } from '../../src/checks/candidate-source-execution.mjs'; +function snapshot() { const source = mkdtempSync(join(tmpdir(), 'torch-b2-execution-')); writeFileSync(join(source, 'input'), 'x'); return captureCandidateInputSnapshotV2({ sourceRoot: source, destinationRoot: `${source}-snapshot`, paths: ['input'] }); } +test('SCN-candidate-execution-boundary: ambient PATH/HOME fallback and output overflow cannot become success', () => { const frozen = snapshot(); assert.throws(() => runCandidateSourceExecutionV2({ command: process.execPath, inputSnapshot: frozen, environment: { PATH: '/bin' } }), { code: 'CANDIDATE_EXECUTION_INVALID' }); const observed = runCandidateSourceExecutionV2({ command: process.execPath, inputSnapshot: frozen, limits: { timeoutMs: 1000, maxOutputBytes: 4, maxArgs: 64, maxArgBytes: 4096 }, executor: () => ({ status: 0, stdout: Buffer.from('xxxxxxxx') }) }); assert.equal(observed.terminal.overflow, true); assert.equal(observed.terminal.success, false); }); diff --git a/test/fleet/candidate-source-parent.test.mjs b/test/fleet/candidate-source-parent.test.mjs new file mode 100644 index 0000000..b1c426b --- /dev/null +++ b/test/fleet/candidate-source-parent.test.mjs @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { issueCandidateNativeAdmissionV2, openCandidateSourceBootstrapV2 } from '../../src/checks/candidate-source-bootstrap.mjs'; +test('SCN-candidate-parent-capability: bootstrap frame carries only a bound tuple, never roots, env, writers, adapters, or receipt authority', () => { + const ticket = issueCandidateNativeAdmissionV2({ projectId: 'p', areaId: 'a', checkId: 'c', runId: 'r', generation: 'g', policyDigest: 'a'.repeat(64), engineDigest: 'b'.repeat(64), expiresAt: '2030-01-01T00:00:00.000Z' }); + const frame = openCandidateSourceBootstrapV2(ticket, { now: () => new Date('2029-01-01T00:00:00.000Z') }); + for (const forbidden of ['projectId', 'areaId', 'checkId', 'inputRoot', 'cwd', 'environment', 'writer', 'adapter', 'receipt']) assert.equal(Object.hasOwn(frame, forbidden), false); +});