From c535edf72cbda089402f28a24c9e53b4c6da97cf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Sun, 27 Sep 2026 19:36:42 -0400 Subject: [PATCH] Bound self-host candidates to clean commits --- README.md | 4 +- docs/PORTABLE_AGENT_FLEET_SPEC.md | 7 ++ ...grity-note-2026-09-27-portable-kernel.yaml | 2 +- src/self-host/service.mjs | 83 ++++++++++++++++--- test/fleet/self-host.test.mjs | 34 +++++++- 5 files changed, 116 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f799890..94311a6 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,9 @@ deployment provider, so configuration alone cannot trigger an external action. TORCH source checkouts can stage a self-host candidate without changing the active version. Candidate build runs the complete local acceptance, lint, and -syntax gates before writing its receipt: +syntax gates before writing its receipt. Git checkout staging is bound to a +clean exact commit, copies tracked source plus its local dependency tree, +excludes unrelated untracked files, and refuses escaping symbolic links: ```bash node bin/torch.mjs candidate plan --source . --json diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index ffc9205..4a05776 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -1336,6 +1336,13 @@ Candidate releases MUST run side by side with stable using separate state, socket, lock, port, and fixture namespaces. Candidate tests MUST not control the live development fleet. +For a Git source checkout, candidate staging MUST bind the artifact to the +exact clean tracked commit. It may include the installed dependency tree needed +to execute acceptance, but MUST exclude arbitrary untracked files and reject +modified tracked input. Artifact-local relative dependency links are permitted; +links that escape the candidate root are refused. Non-Git inputs are treated as +already-prepared release artifacts and every contained entry is inventoried. + Candidate acceptance includes: - initialize and analyze a fixture repository; diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index 9fb9192..b23982f 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -121,7 +121,7 @@ test_integrity_note: - id: SCN-candidate-isolation given: an inactive candidate in a namespace separate from project and stable runtime state when: acceptance is incomplete, complete, or the accepted files are later modified - then: incomplete staging is removed, successful staging stays inactive, read-only status writes nothing, and stale validation blocks activation + then: incomplete staging is removed, successful staging stays inactive, read-only status writes nothing, stale validation blocks activation, Git staging binds to a clean exact commit, local dependencies remain runnable, and unrelated untracked files never enter the artifact - id: SCN-atomic-upgrade-rollback given: accepted forward-and-rollback-compatible versions installed side by side when: activation is interrupted after the atomic pointer swap and the service restarts diff --git a/src/self-host/service.mjs b/src/self-host/service.mjs index 3552a6c..e6020bc 100644 --- a/src/self-host/service.mjs +++ b/src/self-host/service.mjs @@ -1,9 +1,10 @@ import { createHash, randomUUID } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs'; -import { basename, dirname, join, relative, resolve, sep } from 'node:path'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { TorchError } from '../kernel/errors.mjs'; import { torchDataHome } from '../kernel/paths.mjs'; @@ -55,22 +56,33 @@ function inside(parent, child) { return resolve(child).startsWith(root); } -function inventory(root, { excludeValidation = false } = {}) { +function artifactPath(root, path) { + return relative(root, path).split(sep).join('/'); +} + +function inventory(root, { excludeValidation = false, include = () => true } = {}) { const records = []; const visit = (directory) => { for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { if (entry.name === '.git') continue; const path = join(directory, entry.name); - const name = relative(root, path); + const name = artifactPath(root, path); if (excludeValidation && name === '.torch-validation.json') continue; + if (!include(name, entry)) continue; const stat = lstatSync(path); if (stat.isSymbolicLink()) { - throw new TorchError(`Candidate artifacts may not contain symbolic links: ${name}`, { - code: 'CANDIDATE_SYMLINK_REFUSED', details: { path: name }, - }); + const link = readlinkSync(path); + const target = resolve(dirname(path), link); + if (isAbsolute(link) || !inside(root, target) || !existsSync(target)) { + throw new TorchError(`Candidate symbolic link must resolve inside the artifact: ${name}`, { + code: 'CANDIDATE_SYMLINK_REFUSED', details: { path: name, target: link }, + }); + } + records.push({ path: name, bytes: Buffer.byteLength(link), type: 'symlink', link }); } if (stat.isDirectory()) visit(path); - else if (stat.isFile()) records.push({ path: name, bytes: stat.size }); + else if (stat.isFile()) records.push({ path: name, bytes: stat.size, type: 'file' }); + else if (stat.isSymbolicLink()) { /* recorded above */ } else throw new TorchError(`Unsupported candidate artifact entry: ${name}`, { code: 'CANDIDATE_ENTRY_REFUSED', details: { path: name }, }); @@ -83,11 +95,43 @@ function inventory(root, { excludeValidation = false } = {}) { function treeHash(root, records) { const digest = createHash('sha256'); for (const record of records) { - digest.update(record.path).update('\0').update(readFileSync(join(root, record.path))).update('\0'); + digest.update(record.path).update('\0').update(record.type).update('\0'); + digest.update(record.type === 'symlink' ? record.link : readFileSync(join(root, record.path))).update('\0'); } return digest.digest('hex'); } +function gitSourceDescriptor(root) { + let topLevel; + try { + topLevel = execFileSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { return null; } + if (resolve(topLevel) !== resolve(root)) return null; + const dirty = execFileSync('git', ['-C', root, 'status', '--porcelain=v1', '--untracked-files=no'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + if (dirty) throw new TorchError('Candidate source has modified tracked files', { + code: 'CANDIDATE_SOURCE_DIRTY', details: dirty.split('\n'), + }); + const commit = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + const tracked = new Set(execFileSync('git', ['-C', root, 'ls-files', '-z'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }).split('\0').filter(Boolean)); + const include = (name, entry) => name === 'node_modules' + || name.startsWith('node_modules/') + || tracked.has(name) + || (entry.isDirectory() && [...tracked].some((path) => path.startsWith(`${name}/`))); + return { mode: 'git-commit', commit, include }; +} + +function sourceDescriptor(root) { + return gitSourceDescriptor(root) ?? { mode: 'artifact', commit: null, include: () => true }; +} + function validateReleaseMetadata(root, requestedVersion) { const packagePath = join(root, 'package.json'); const releasePath = join(root, 'torch-release.json'); @@ -152,7 +196,8 @@ export class VersionService { }); } const metadata = validateReleaseMetadata(sourceRoot, version); - const records = inventory(sourceRoot); + const descriptor = sourceDescriptor(sourceRoot); + const records = inventory(sourceRoot, { include: descriptor.include }); if (records.some((record) => record.path === '.torch-validation.json')) { throw new TorchError('Candidate source contains TORCH-owned validation state', { code: 'CANDIDATE_RESERVED_FILE', details: { path: '.torch-validation.json' }, @@ -163,6 +208,7 @@ export class VersionService { action: 'candidate-install', source: sourceRoot, destination, version: metadata.version, digest: treeHash(sourceRoot, records), files: records.length, bytes: records.reduce((total, record) => total + record.bytes, 0), + sourceMode: descriptor.mode, sourceCommit: descriptor.commit, compatibility: metadata.release.state, conflicts: existsSync(destination) ? [{ code: 'VERSION_ALREADY_INSTALLED', version: metadata.version }] : [], mutationPerformed: false, @@ -184,9 +230,23 @@ export class VersionService { mkdirSync(this.versionsRoot, { recursive: true }); const staging = join(this.versionsRoot, `.candidate-${plan.version}-${randomUUID()}`); try { + const descriptor = sourceDescriptor(plan.source); + if (descriptor.mode !== plan.sourceMode || descriptor.commit !== plan.sourceCommit) { + throw new TorchError('Candidate source identity changed after planning', { + code: 'CANDIDATE_SOURCE_CHANGED', + details: { + planned: { mode: plan.sourceMode, commit: plan.sourceCommit }, + actual: { mode: descriptor.mode, commit: descriptor.commit }, + }, + }); + } cpSync(plan.source, staging, { - recursive: true, errorOnExist: true, force: false, - filter: (path) => basename(path) !== '.git', + recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true, + filter: (path) => { + if (resolve(path) === resolve(plan.source)) return true; + const name = artifactPath(plan.source, path); + return basename(path) !== '.git' && descriptor.include(name, lstatSync(path)); + }, }); validateReleaseMetadata(staging, plan.version); const copiedRecords = inventory(staging); @@ -206,6 +266,7 @@ export class VersionService { atomicJson(join(staging, '.torch-validation.json'), { schema: 'torch.dev/candidate-validation/v1alpha1', version: plan.version, digest: plan.digest, validatedAt: this.now().toISOString(), + source: { mode: plan.sourceMode, commit: plan.sourceCommit }, scenarios: CANDIDATE_ACCEPTANCE_SCENARIOS, evidence: acceptance.evidence ?? [], passed: true, }); renameSync(staging, plan.destination); diff --git a/test/fleet/self-host.test.mjs b/test/fleet/self-host.test.mjs index 2cb9c7c..ba60f7c 100644 --- a/test/fleet/self-host.test.mjs +++ b/test/fleet/self-host.test.mjs @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -60,6 +63,35 @@ test('SCN-candidate-isolation: staging requires complete acceptance and never ch () => service.activate('1.0.0'), (error) => error.code === 'CANDIDATE_VALIDATION_STALE', ); + + const checkout = candidate(root, '1.1.0', 'bounded'); + mkdirSync(join(checkout, 'node_modules', 'fixture-dependency'), { recursive: true }); + mkdirSync(join(checkout, 'node_modules', '.bin'), { recursive: true }); + writeFileSync(join(checkout, 'node_modules', 'fixture-dependency', 'index.js'), 'export const dependency = true;\n'); + symlinkSync('../fixture-dependency/index.js', join(checkout, 'node_modules', '.bin', 'fixture-dependency')); + writeFileSync(join(checkout, '.gitignore'), 'node_modules/\nlocal-secret.env\n'); + execFileSync('git', ['init', '-b', 'main', checkout]); + execFileSync('git', ['-C', checkout, 'config', 'user.email', 'torch-test@example.invalid']); + execFileSync('git', ['-C', checkout, 'config', 'user.name', 'TORCH Test']); + execFileSync('git', ['-C', checkout, 'add', '.']); + execFileSync('git', ['-C', checkout, 'commit', '-m', 'candidate source']); + writeFileSync(join(checkout, 'local-secret.env'), 'DO_NOT_COPY=true\n'); + + const boundedPlan = service.planCandidate({ source: checkout }); + assert.equal(boundedPlan.sourceMode, 'git-commit'); + assert.match(boundedPlan.sourceCommit, /^[0-9a-f]{40}$/); + const bounded = service.installCandidate({ source: checkout, validate: accepted }); + assert.equal(existsSync(join(bounded.destination, 'local-secret.env')), false); + assert.equal(existsSync(join(bounded.destination, 'node_modules', '.bin', 'fixture-dependency')), true); + assert.deepEqual(JSON.parse(readFileSync(bounded.validation, 'utf8')).source, { + mode: 'git-commit', commit: boundedPlan.sourceCommit, + }); + + writeFileSync(join(checkout, 'bin', 'torch.mjs'), 'export const marker = "dirty";\n'); + assert.throws( + () => service.planCandidate({ source: checkout }), + (error) => error.code === 'CANDIDATE_SOURCE_DIRTY', + ); }); test('SCN-atomic-upgrade-rollback: a crash after pointer swap reconciles and rollback restores the exact prior version', () => {