Repository navigation
Audit runtime reuse and probe the offline isolation boundary #223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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']]) { | ||
| 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) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit:
/peer/private.txtand/host-private.txtare unmounted paths, so this usually assertsENOENTrather 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.