Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions dev/interop/isolation/probe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Opt-in OS boundary probe, separate from the no-process contract simulator.
import { execFile } from 'node:child_process'
import { chmodSync, copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createHash, randomUUID } from 'node:crypto'
import { promisify } from 'node:util'

export function validateImage(image) {
if (typeof image !== 'string' || !/^[a-z0-9][a-z0-9./:_-]*@sha256:[a-f0-9]{64}$/.test(image) || image.trim() !== image) throw new Error('Use an explicit image repository@sha256:digest')
return image
}

export function containerArgs({ image, name, workspace, script, role }) {
validateImage(image)
if (!/^flybot-probe-[a-z0-9-]+$/.test(name) || !['writer', 'verifier'].includes(role)) throw new Error('Invalid probe identity')
for (const path of [workspace, script]) {
if (!isAbsolute(path) || path === '/' || resolve(path) !== path || /[,\r\n]/.test(path)) throw new Error('Invalid probe mount')
}
if (dirname(workspace) !== dirname(script) || basename(workspace) !== role || basename(script) !== 'probe.mjs') throw new Error('Probe mounts must belong to its disposable root')
return ['create', '--name', name, '--read-only', '--network', 'none',
'--user', '1000:1000', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
'--memory', '128m', '--cpus', '1', '--pids-limit', '64',
'--tmpfs', '/tmp:rw,noexec,nosuid,nodev,size=16m',
'--mount', `type=bind,src=${workspace},dst=/work`,
'--mount', `type=bind,src=${script},dst=/probe.mjs,readonly`,
'--workdir', '/work', '--entrypoint', '/usr/bin/env', image,
'-i', 'PATH=/usr/local/bin:/usr/bin:/bin', 'TZ=UTC', 'node', '/probe.mjs', role]
}

const probeScript = `import assert from 'node:assert/strict'
import { readFileSync, writeFileSync } from 'node:fs'
import { createConnection } from 'node:net'
const role = process.argv[2]
const checks = {}
writeFileSync('/work/own-write.txt', role)
assert.equal(readFileSync('/work/own-write.txt', 'utf8'), role)
checks.ownWrite = true
for (const [name, path] of [['peerPrivateRead', '/peer/private.txt'], ['hostPrivateRead', '/host-private.txt']]) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: /peer/private.txt and /host-private.txt are unmounted paths, so this usually asserts ENOENT rather than mount confinement (EACCES). Fine for the documented synthetic probe; a stronger check would RO-bind the peer workspace and expect denial on a real mount.

assert.throws(() => readFileSync(path), error => ['ENOENT', 'EACCES', 'EPERM'].includes(error.code))
checks[name] = 'denied'
}
assert.throws(() => writeFileSync('/outside-workspace.txt', 'denied'), error => ['EROFS', 'EACCES', 'EPERM'].includes(error.code))
checks.rootWrite = 'denied'
assert.deepEqual(Object.keys(process.env).sort(), ['PATH', 'TZ'])
checks.ambientEnvironment = 'PATH,TZ only'
const networkCode = await new Promise((resolve, reject) => {
const socket = createConnection({ host: '192.0.2.1', port: 80 })
socket.setTimeout(1000, () => { socket.destroy(); reject(new Error('Network denial was not observed')) })
socket.once('connect', () => { socket.destroy(); reject(new Error('Unexpected network connection')) })
socket.once('error', error => { socket.destroy(); resolve(error.code) })
})
assert.equal(networkCode, 'ENETUNREACH')
checks.externalNetwork = networkCode
if (role === 'writer') writeFileSync('/work/result.json', JSON.stringify({ fixture: 'synthetic', total: 140 }))
else assert.deepEqual(JSON.parse(readFileSync('/work/result.json', 'utf8')), { fixture: 'synthetic', total: 140 })
checks.artifact = role === 'writer' ? 'produced' : 'verified'
console.log(JSON.stringify({ role, node: process.version, checks }))
`

async function docker(args, { signal } = {}) {
const env = { ...process.env }
// Select only the managed local daemon; keep registry/proxy configuration.
for (const key of ['DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_TLS', 'DOCKER_TLS_VERIFY', 'DOCKER_CERT_PATH']) delete env[key]
const { stdout } = await promisify(execFile)('docker', ['--host=unix:///var/run/docker.sock', ...args], {
env, signal, encoding: 'utf8', timeout: 20_000, maxBuffer: 128 * 1024,
})
return stdout.trim()
}

function verifyConfiguration(inspect) {
const config = inspect.HostConfig
assertConfigured(inspect.Config.User === '1000:1000', 'user')
assertConfigured(config.ReadonlyRootfs && config.NetworkMode === 'none', 'root/network')
assertConfigured(config.Memory === 128 * 1024 * 1024 && config.NanoCpus === 1_000_000_000 && config.PidsLimit === 64, 'resource limits')
assertConfigured(config.CapDrop?.includes('ALL') && config.SecurityOpt?.includes('no-new-privileges'), 'privileges')
assertConfigured(config.Privileged === false && config.PidMode !== 'host' && config.IpcMode !== 'host', 'namespaces')
assertConfigured(inspect.Mounts.length === 2 && inspect.Mounts.every(m => ['/work', '/probe.mjs'].includes(m.Destination)), 'mounts')
return { user: '1000:1000', readOnlyRoot: true, network: 'none', memoryBytes: config.Memory, nanoCpus: config.NanoCpus, pids: config.PidsLimit, capDrop: ['ALL'], noNewPrivileges: true }
}

function assertConfigured(value, name) {
if (!value) throw new Error(`Probe configuration mismatch: ${name}`)
}

export async function runIsolationProbe(image, { invoke = docker, signal } = {}) {
validateImage(image)
const started = new Date().toISOString()
const serverVersion = await invoke(['info', '--format', '{{.ServerVersion}}'], { signal })
const imageId = await invoke(['image', 'inspect', image, '--format', '{{.Id}}'], { signal })
const root = mkdtempSync(join(tmpdir(), 'flybot-isolation-'))
const names = []
const observations = []
let report
try {
chmodSync(root, 0o755)
const script = join(root, 'probe.mjs')
writeFileSync(script, probeScript, { mode: 0o444 })
writeFileSync(join(root, 'host-private.txt'), 'SYNTHETIC_HOST_PRIVATE')
for (const role of ['writer', 'verifier']) {
const workspace = join(root, role)
mkdirSync(workspace, { mode: 0o777 })
chmodSync(workspace, 0o777)
writeFileSync(join(workspace, 'private.txt'), `SYNTHETIC_${role.toUpperCase()}_PRIVATE`, { mode: 0o444 })
}
for (const role of ['writer', 'verifier']) {
if (role === 'verifier') copyFileSync(join(root, 'writer/result.json'), join(root, 'verifier/result.json'))
const name = `flybot-probe-${randomUUID()}-${role}`
names.push(name)
await invoke(containerArgs({ image, name, workspace: join(root, role), script, role }), { signal })
const configured = verifyConfiguration(JSON.parse(await invoke(['inspect', name], { signal }))[0])
const observed = JSON.parse(await invoke(['start', '--attach', name], { signal }))
assertConfigured(observed.role === role, 'role evidence')
const state = JSON.parse(await invoke(['inspect', name], { signal }))[0].State
assertConfigured(state.ExitCode === 0 && !state.Running && !state.OOMKilled, 'execution outcome')
observations.push({ ...observed, configured })
}
const bytes = readFileSync(join(root, 'writer/result.json'))
assertConfigured(bytes.equals(readFileSync(join(root, 'verifier/result.json'))), 'artifact handoff')
report = { schemaVersion: 1, purpose: 'synthetic-offline-isolation-probe', status: 'passed', started, ended: new Date().toISOString(), image, imageId, docker: serverVersion, artifactSha256: createHash('sha256').update(bytes).digest('hex'), observations }
} finally {
const failures = []
for (const name of names) {
try { await invoke(['rm', '--force', name]) } catch (error) {
if (!/No such container/.test(String(error.stderr))) failures.push(error)
}
}
rmSync(root, { recursive: true, force: true })
if (failures.length) throw new AggregateError(failures, 'Probe container cleanup failed')
}
signal?.throwIfAborted()
return { ...report, cleanup: 'completed' }
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
const controller = new AbortController()
const stop = () => controller.abort()
process.on('SIGINT', stop)
process.on('SIGTERM', stop)
try {
if (process.argv.length !== 4 || process.argv[2] !== '--image') throw new Error('Expected --image repository@sha256:digest')
console.log(JSON.stringify(await runIsolationProbe(process.argv[3], { signal: controller.signal }), null, 2))
} catch (error) {
console.error(JSON.stringify({ status: 'failed', category: error.code ?? error.name, message: 'Isolation probe did not pass; no runtime integration is authorized by this result.' }))
process.exitCode = 1
} finally {
process.removeListener('SIGINT', stop)
process.removeListener('SIGTERM', stop)
}
}
58 changes: 58 additions & 0 deletions dev/interop/test/isolation-probe.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { containerArgs, runIsolationProbe, validateImage } from '../isolation/probe.mjs'

const image = `node@sha256:${'a'.repeat(64)}`
const spec = { image, name: 'flybot-probe-synthetic-writer', workspace: '/tmp/pilot/writer', script: '/tmp/pilot/probe.mjs', role: 'writer' }

test('isolation probe refuses mutable image tags before starting resources', () => {
for (const value of ['', 'node:24', 'node@sha256:abc', `${image}\n`, '--privileged']) assert.throws(() => validateImage(value))
assert.equal(validateImage(image), image)
})

test('isolation probe limits mounts and drops execution privileges', () => {
const args = containerArgs(spec)
const value = flag => args[args.indexOf(flag) + 1]
assert.equal(value('--network'), 'none')
assert.equal(value('--user'), '1000:1000')
assert.equal(value('--cap-drop'), 'ALL')
assert.equal(value('--security-opt'), 'no-new-privileges')
assert.equal(value('--memory'), '128m')
assert.equal(value('--pids-limit'), '64')
assert.ok(args.includes('--read-only'))
assert.equal(args.filter(arg => arg === '--mount').length, 2)
assert.ok(args.includes('type=bind,src=/tmp/pilot/writer,dst=/work'))
assert.ok(args.includes('type=bind,src=/tmp/pilot/probe.mjs,dst=/probe.mjs,readonly'))
assert.ok(!args.some(arg => /privileged|docker\.sock|host=|network=host/.test(arg)))
assert.deepEqual(args.slice(args.indexOf(image) + 1), ['-i', 'PATH=/usr/local/bin:/usr/bin:/bin', 'TZ=UTC', 'node', '/probe.mjs', 'writer'])
assert.equal(value('--entrypoint'), '/usr/bin/env')
})

test('isolation probe rejects argument or mount injection and unknown roles', () => {
for (const patch of [
{ workspace: '/' }, { workspace: 'relative' }, { workspace: '/tmp/pilot,src=/' },
{ workspace: '/tmp/..' }, { script: '/other/probe.mjs' },
{ script: '/tmp/probe,readonly=false' }, { name: '--privileged' }, { role: 'shell' },
]) assert.throws(() => containerArgs({ ...spec, ...patch }))
})

test('partial container creation is cleaned up even after cancellation', async () => {
const calls = []
const controller = new AbortController()
await assert.rejects(runIsolationProbe(image, { signal: controller.signal, invoke: async (args, options) => {
calls.push(args)
if (args[0] === 'create') { controller.abort(); throw new Error('creation interrupted') }
if (args[0] === 'rm') assert.equal(options, undefined, 'cleanup must not use the aborted execution signal')
return 'synthetic'
} }), /creation interrupted/)
const create = calls.find(args => args[0] === 'create')
assert.deepEqual(calls.at(-1), ['rm', '--force', create[create.indexOf('--name') + 1]])
})

test('container cleanup failure cannot produce a passing isolation report', async () => {
await assert.rejects(runIsolationProbe(image, { invoke: async args => {
if (args[0] === 'create') throw new Error('creation failed')
if (args[0] === 'rm') throw new Error('daemon unavailable')
return 'synthetic'
} }), /cleanup failed/)
})
Loading
Loading