From 936748602e3ca28535b39441cc5cb11776cd95d9 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 22 Oct 2026 19:14:17 -0600 Subject: [PATCH 1/6] fix: patch image dependencies and support repeat runtime rolls --- README.md | 10 ++- docker/Dockerfile | 4 +- docker/Dockerfile.cli-runtime | 4 +- scripts/install-harness.sh | 21 ++++++ scripts/npm-security.json | 4 ++ scripts/python-security-overrides.txt | 6 +- scripts/verify-pool-runtime-roll.ts | 32 ++++++++++ scripts/verify-runtime-security.py | 92 +++++++++++++++++++++++++++ src/runtime/pool-maintenance.ts | 40 +++++++++--- 9 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 scripts/npm-security.json create mode 100644 scripts/verify-runtime-security.py diff --git a/README.md b/README.md index d89e125..58d57b5 100644 --- a/README.md +++ b/README.md @@ -1240,7 +1240,8 @@ Retained native-session API jobs do not yet enter this one-shot capture scope. ### Individually maintained Docker pool slots The bearer-protected `/admin/pools/status` endpoint reports explicit adopted pool -identities and durable roll receipts. Maintenance requires `BRIDGE_BEARER`, even +identities, per-slot immutable image bindings, and durable roll receipts/history. +Maintenance requires `BRIDGE_BEARER`, even on loopback. Adoption requires Linux with the existing `ss` socket inventory tool; unavailable or ambiguous listener evidence refuses ownership transfer. Run adoption from a native Linux bridge service in the same host network namespace as the @@ -1270,7 +1271,12 @@ No image is pulled and no volume is deleted by this interface. `pool-maintenance.json` under the bridge data directory records exact old and candidate identities before each Docker transition. After an interrupted roll, -retry the same original ID and image; a different intent is refused. After owner +retry the same original ID and image; a different unfinished intent is refused. +Once a transaction is promoted, drain its current candidate ID and request a +different immutable local image to begin the next transaction. The owner appends +the completed receipt to history before recording the new intent, retaining the +original adoption identities and every stopped predecessor. Missing local images +refuse without changing the completed journal. After owner restart, adopted slots remain drained until the same roll request verifies and re-admits each completed candidate. The journal contains no credential values. Use the registered service deployer to activate merged source; an unchanged diff --git a/docker/Dockerfile b/docker/Dockerfile index cdb2810..84f8ec9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -35,8 +35,8 @@ RUN curl -fsSL https://opencode.ai/install | bash \ # install with an absolute path. Symlink both into /usr/local/bin so # they're on PATH at runtime AND survive the /root/.kimi volume mount # (which only covers auth state). -COPY scripts/install-harness.sh scripts/python-security-overrides.txt /usr/local/share/cli-bridge/ -RUN sh /usr/local/share/cli-bridge/install-harness.sh kimi +COPY scripts/install-harness.sh scripts/python-security-overrides.txt scripts/npm-security.json /usr/local/share/cli-bridge/ +RUN CLI_BRIDGE_UPGRADE_IMAGE_NPM=1 sh /usr/local/share/cli-bridge/install-harness.sh kimi # Factory droid, Sourcegraph amp, Forge left out by default — install # manually on hosts that need them. They change rapidly. diff --git a/docker/Dockerfile.cli-runtime b/docker/Dockerfile.cli-runtime index 7415c0d..4ce7455 100644 --- a/docker/Dockerfile.cli-runtime +++ b/docker/Dockerfile.cli-runtime @@ -50,8 +50,8 @@ RUN apt-get update \ # --- CLI installs (Pi is pinned to its qualified RPC version) --- # Source of truth lives in scripts/install-harness.sh so host installs, # subset images, and the all-in-one runtime cannot drift. -COPY scripts/install-harness.sh scripts/python-security-overrides.txt /usr/local/share/cli-bridge/ -RUN set -eu; \ +COPY scripts/install-harness.sh scripts/python-security-overrides.txt scripts/npm-security.json /usr/local/share/cli-bridge/ +RUN set -eu; export CLI_BRIDGE_UPGRADE_IMAGE_NPM=1; \ if [ "$CLI_BRIDGE_HARNESSES" = "all" ]; then \ sh /usr/local/share/cli-bridge/install-harness.sh all; \ else \ diff --git a/scripts/install-harness.sh b/scripts/install-harness.sh index 32461f3..0a7a040 100755 --- a/scripts/install-harness.sh +++ b/scripts/install-harness.sh @@ -8,6 +8,27 @@ if [ -z "$harness" ]; then exit 2 fi +# Image-only npm self-upgrade; host harness installation keeps its existing npm. +# The descriptor owns the qualified publisher version and archive integrity. +if [ "${CLI_BRIDGE_UPGRADE_IMAGE_NPM:-0}" = "1" ]; then + npm_security_tmp="$(mktemp -d)" + trap 'rm -rf "$npm_security_tmp"' EXIT HUP INT TERM + npm_security_version="$(node -p 'require(process.argv[1]).version' "$harness_script_dir/npm-security.json")" + curl --fail --silent --show-error --location --max-time 120 \ + "https://registry.npmjs.org/npm/-/npm-$npm_security_version.tgz" --output "$npm_security_tmp/npm.tgz" + node --input-type=module - "$harness_script_dir/npm-security.json" "$npm_security_tmp/npm.tgz" <<'VERIFY_NPM' +import { readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +const descriptor = JSON.parse(readFileSync(process.argv[2], 'utf8')) +const actual = 'sha512-' + createHash('sha512').update(readFileSync(process.argv[3])).digest('base64') +if (actual !== descriptor.integrity) throw new Error('npm security archive integrity mismatch') +VERIFY_NPM + npm install --global --ignore-scripts --no-audit --no-fund "$npm_security_tmp/npm.tgz" + test "$(npm --version)" = "$npm_security_version" + rm -rf "$npm_security_tmp" + trap - EXIT HUP INT TERM +fi + install_claude() { npm install -g @anthropic-ai/claude-code@latest } diff --git a/scripts/npm-security.json b/scripts/npm-security.json new file mode 100644 index 0000000..5b22e58 --- /dev/null +++ b/scripts/npm-security.json @@ -0,0 +1,4 @@ +{ + "version": "11.21.1", + "integrity": "sha512-YTzL6QBEJXyHcTu0JiUc7RKMp+mzlJtl7rGLmERaXUQeFHIMc9atdr6G9M9C8vDWbv40epoTQAzNL2/PtZVDhQ==" +} diff --git a/scripts/python-security-overrides.txt b/scripts/python-security-overrides.txt index a3376a3..7de319d 100644 --- a/scripts/python-security-overrides.txt +++ b/scripts/python-security-overrides.txt @@ -1,2 +1,6 @@ -# Kimi pins an older Pillow; this image owns the audited security override. +# Audited Kimi dependency overrides; shared by both shipped image recipes. pillow==13.0.0 +aiohttp==3.14.4 +asyncssh==2.23.1 +lxml==6.1.0 +lxml_html_clean==0.4.5 diff --git a/scripts/verify-pool-runtime-roll.ts b/scripts/verify-pool-runtime-roll.ts index 3913291..11fade9 100644 --- a/scripts/verify-pool-runtime-roll.ts +++ b/scripts/verify-pool-runtime-roll.ts @@ -27,6 +27,7 @@ const owned = new Set() const volumes = new Set() const checks: string[] = [] let oldImage = '' +let nextImage = '' let pool: ContainerPool | undefined let appOwner: Awaited> | undefined let server: ReturnType | undefined @@ -64,6 +65,7 @@ try { const seed = await command(['create', '--name', `${prefix}-seed`, image]) owned.add(seed) oldImage = await command(['commit', '--change', `LABEL cli-bridge.proof=${suffix}`, seed]) + nextImage = await command(['commit', '--change', `LABEL cli-bridge.proof-next=${suffix}`, seed]) await command(['rm', '-v', seed]); owned.delete(seed) pool = await ContainerPool.create({ size: 2, image: oldImage, namePrefix: `${prefix}-leases`, @@ -144,7 +146,36 @@ try { assert.ok(status.pools[0]!.slots.every((slot) => !slot.draining)) assert.equal(readFileSync(join(data, 'pool-maintenance.json'), 'utf8').includes(token), false) checks.push('actual HTTP owner restart reconciles completed receipts and re-admits verified slots without secrets in journal') + const firstRoll = JSON.parse(readFileSync(join(data, 'pool-maintenance.json'), 'utf8')).pools[0].rolls['0'] as RollReceipt + const currentId = firstRoll.candidateId! + assert.equal((await api(`/admin/pools/${prefix}/slots/0/drain`, { expectedId: currentId })).status, 200) + const completedJournal = readFileSync(join(data, 'pool-maintenance.json'), 'utf8') + assert.equal((await api(`/admin/pools/${prefix}/slots/0/roll`, { expectedId: currentId, imageId: `sha256:${'0'.repeat(64)}` })).status, 409) + assert.equal(readFileSync(join(data, 'pool-maintenance.json'), 'utf8'), completedJournal) + const secondResponse = await api(`/admin/pools/${prefix}/slots/0/roll`, { expectedId: currentId, imageId: nextImage }) + assert.equal(secondResponse.status, 200) + const second = (await secondResponse.json() as { receipt: RollReceipt }).receipt + owned.add(second.candidateId!) + assert.notEqual(second.nonce, firstRoll.nonce) + assert.equal(second.oldId, currentId) + assert.equal((await dockerEngine('GET', `/containers/${currentId}/json`)).State.Running, false) + assert.equal((await dockerEngine('GET', `/containers/${slots[0]!.id}/json`)).State.Running, false) + assert.equal(await command(['exec', second.candidateId!, 'cat', '/root/.config/opencode/proof-marker']), 'retained') + await stopApp() + await startApp() + assert.equal((await api(`/admin/pools/${prefix}/slots/0/roll`, { expectedId: currentId, imageId: nextImage })).status, 200) + assert.equal((await api(`/admin/pools/${prefix}/slots/0/roll`, { expectedId: slots[0]!.id, imageId: image })).status, 409) + const journal = JSON.parse(readFileSync(join(data, 'pool-maintenance.json'), 'utf8')).pools[0] + assert.deepEqual(journal.slots, slots) + assert.deepEqual(journal.history['0'], [firstRoll]) + assert.equal(journal.rolls['0'].candidateId, second.candidateId) + const repeatedStatus = await (await api('/admin/pools/status')).json() as { pools: Array<{ slots: Array<{ containerId: string; draining: boolean }> }> } + assert.equal(repeatedStatus.pools[0]!.slots[0]!.containerId, second.candidateId) + assert.equal(repeatedStatus.pools[0]!.slots[0]!.draining, false) + checks.push('second immutable artifact appends prior receipt, preserves original identities and volumes, rejects stale intent, and reconciles after owner restart') + checks.push('missing second artifact refuses without changing completed journal') const result = { complete: true, checks, old_fixture_image: oldImage, candidate_image: image, + second_candidate_image: nextImage, production_node_upgrade_proven: false, no_model_calls: true } const output = process.env.CLI_ROLL_PROOF_OUTPUT if (output) writeFileSync(output, `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 }) @@ -156,5 +187,6 @@ try { for (const id of owned) await dockerCli(['rm', '-f', '-v', id]) for (const volume of volumes) await dockerCli(['volume', 'rm', volume]) if (oldImage) await dockerCli(['image', 'rm', oldImage]) + if (nextImage) await dockerCli(['image', 'rm', nextImage]) rmSync(root, { recursive: true, force: true }) } diff --git a/scripts/verify-runtime-security.py b/scripts/verify-runtime-security.py new file mode 100644 index 0000000..bd39ced --- /dev/null +++ b/scripts/verify-runtime-security.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Real shipped-image npm/Python/native proof. Uses no credentials or model calls.""" +import json +from pathlib import Path +import subprocess +import sys + + +image = sys.argv[1] +if not image.startswith('sha256:') or len(image) != 71: + raise SystemExit('an existing immutable Docker image ID is required') +descriptor = json.loads((Path(__file__).parent / 'npm-security.json').read_text()) +overrides = dict(line.strip().split('==') for line in + (Path(__file__).parent / 'python-security-overrides.txt').read_text().splitlines() + if line.strip() and not line.startswith('#')) + + +def run(entrypoint, arguments, source=None): + result = subprocess.run(['docker', 'run', '--rm', '-i', '--entrypoint', entrypoint, + image, *arguments], input=source, capture_output=True, + text=True, timeout=120) + if result.returncode: + raise RuntimeError(f'image proof failed ({entrypoint}): {result.stderr[-2000:]}') + return result.stdout.strip() + + +versions = {cli: run(cli, ['--version']) for cli in ('node', 'npm', 'claude', 'codex', 'opencode', 'kimi', 'gemini', 'pi')} +if versions['npm'] != descriptor['version']: + raise SystemExit('the image npm version does not match its security owner') +npm_dependencies = json.loads(run('node', ['-e', "console.log(JSON.stringify(Object.fromEntries(['brace-expansion','undici'].map(n=>[n,require('/usr/local/lib/node_modules/npm/node_modules/'+n+'/package.json').version]))))"])) +python_source = r''' +import asyncio, email.parser, importlib.metadata, io, json, pathlib, re +import aiohttp, aiohttp.web, asyncssh, kimi_cli +from lxml import etree +from lxml_html_clean import Cleaner +from PIL import Image +expected = json.loads(__import__('sys').argv[1]) +normalize = lambda value: re.sub(r'[-_.]+', '-', value).lower() +installed = {name: importlib.metadata.version(name) for name in expected} +if installed != expected: + raise SystemExit('the actual Kimi environment does not match security overrides') +cache = {} +for metadata in pathlib.Path('/root/.cache/uv/archive-v0').glob('*/*.dist-info/METADATA'): + parsed = email.parser.Parser().parsestr(metadata.read_text()) + name = normalize(parsed['Name'] or '') + for wanted, version in expected.items(): + if name == normalize(wanted): + cache.setdefault(wanted, []).append({'version': parsed['Version'], 'path': str(metadata)}) + if parsed['Version'] != version: + raise SystemExit('the UV archive retains a superseded security package') +if set(cache) != set(expected): + raise SystemExit('targeted UV archive coverage is incomplete') +async def roundtrip(): + app = aiohttp.web.Application() + app.router.add_get('/', lambda request: aiohttp.web.Response(text='native-http-ok')) + runner = aiohttp.web.AppRunner(app) + await runner.setup() + site = aiohttp.web.TCPSite(runner, '127.0.0.1', 0) + await site.start() + try: + port = site._server.sockets[0].getsockname()[1] + async with aiohttp.ClientSession() as session: + async with session.get(f'http://127.0.0.1:{port}/') as response: + if await response.text() != 'native-http-ok': + raise SystemExit('actual aiohttp loopback round trip failed') + finally: + await runner.cleanup() +asyncio.run(roundtrip()) +key = asyncssh.generate_private_key('ssh-ed25519') +public = asyncssh.import_public_key(key.export_public_key()) +signature = key.sign(b'local-security-proof', b'ssh-ed25519') +if not public.verify(b'local-security-proof', signature): + raise SystemExit('actual AsyncSSH signature round trip failed') +cleaned = Cleaner(scripts=True).clean_html('

native-xml-ok

') +if '')[0].tag != 'child': + raise SystemExit('actual lxml native parser failed') +for fmt in ('PNG', 'JPEG', 'WEBP'): + data = io.BytesIO() + Image.new('RGB', (32, 24), (20, 100, 200)).save(data, format=fmt) + data.seek(0) + loaded = Image.open(data) + loaded.load() + if loaded.size != (32, 24): + raise SystemExit('actual Pillow native codec failed') +print(json.dumps({'installed': installed, 'uv_archives': cache, + 'native': ['aiohttp-loopback', 'asyncssh-ed25519', 'lxml-parse-clean', 'Pillow-PNG-JPEG-WEBP']})) +''' +python = json.loads(run('/root/.local/share/uv/tools/kimi-cli/bin/python', ['-', json.dumps(overrides)], python_source)) +print(json.dumps({'image_id': image, 'cli_versions': versions, 'npm_bundled_packages': npm_dependencies, + 'python': python, 'model_calls': 0}, indent=2)) diff --git a/src/runtime/pool-maintenance.ts b/src/runtime/pool-maintenance.ts index 85eb339..8f27e0a 100644 --- a/src/runtime/pool-maintenance.ts +++ b/src/runtime/pool-maintenance.ts @@ -6,7 +6,7 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { z } from 'zod' import { ContainerPool } from '../executors/container-pool.js' -import { inspectIdle, replaceRuntime, type RollReceipt } from '../executors/docker-runtime-roll.js' +import { dockerEngine, inspectIdle, replaceRuntime, type RollReceipt } from '../executors/docker-runtime-roll.js' import { registeredPools } from '../routes/metrics.js' import { acquireDataDirectoryLock, type DataDirectoryLock } from './single-instance.js' @@ -21,7 +21,8 @@ const rollSchema = z.object({ oldId: identity, imageId: z.string().regex(/^sha25 nonce: z.string().regex(/^[a-f0-9]{32}$/u), candidateId: identity.optional(), retainedName: z.string().optional(), phase: z.enum(['intent', 'candidate', 'verified', 'stopped', 'retained', 'promoted']), node: z.string().optional(), }).strict() -const recordSchema = adoptionRequest.extend({ rolls: z.record(z.string(), rollSchema), image: z.string() }).strict() +const recordSchema = adoptionRequest.extend({ rolls: z.record(z.string(), rollSchema), image: z.string(), + history: z.record(z.string(), z.array(rollSchema)).default({}), }).strict() type Record = z.infer /** Linux's kernel socket inventory covers IPv4, IPv6 and non-loopback binds. */ @@ -71,7 +72,7 @@ export class PoolMaintenance { if (this.pools.has(record.prefix)) return this.pools.get(record.prefix)! const ids = record.slots.map((slot) => { const roll = record.rolls[String(slot.index)] - return roll?.phase === 'promoted' ? roll.candidateId! : slot.id + return roll ? (roll.phase === 'promoted' ? roll.candidateId! : roll.oldId) : slot.id }) const pool = ContainerPool.adopt({ size: ids.length, image: record.image, namePrefix: record.prefix, oauthMode: 'share', slotMaxHoldMs: 0 }, ids) @@ -91,8 +92,16 @@ export class PoolMaintenance { } snapshot() { - return [...this.pools].map(([name, pool]) => ({ prefix: name, slots: pool.slotSnapshot(), - rolls: Object.values(this.records.find((r) => r.prefix === name)!.rolls) })) + return [...this.pools].map(([name, pool]) => { + const record = this.records.find((r) => r.prefix === name)! + const slots = pool.slotSnapshot().map((slot) => { + const transactions = [...(record.history[String(slot.index)] ?? []), record.rolls[String(slot.index)]] + const receipt = transactions.find((roll) => roll?.candidateId === slot.containerId) + const imageId = receipt?.imageId ?? (record.slots[slot.index]?.id === slot.containerId ? record.expectedImage : null) + return { ...slot, imageId } + }) + return { prefix: name, slots, rolls: Object.values(record.rolls), history: record.history } + }) } async adopt(input: z.infer) { @@ -119,7 +128,7 @@ export class PoolMaintenance { if (image && image !== info.Image) throw new Error('adoption requires one known runtime image') image = info.Image } - const record = { ...input, legacyDataDir: realpathSync(input.legacyDataDir), image, rolls: {} } + const record = { ...input, legacyDataDir: realpathSync(input.legacyDataDir), image, rolls: {}, history: {} } this.records.push(record) this.save() this.restore(record) @@ -148,18 +157,31 @@ export class PoolMaintenance { const slot = record.slots[index] if (!slot || slot.index !== index) throw new Error('unknown slot') const previous = record.rolls[String(index)] - if (previous && (previous.oldId !== expectedId || previous.imageId !== imageId)) throw new Error('roll intent differs from durable receipt') - const receipt: RollReceipt = previous ?? { oldId: expectedId, imageId, + const retry = previous?.oldId === expectedId && previous.imageId === imageId + if (previous && !retry && (previous.phase !== 'promoted' || previous.candidateId !== expectedId || previous.imageId === imageId)) { + throw new Error('new roll requires the completed transaction current identity and a different image') + } + const receipt: RollReceipt = retry ? previous! : { oldId: expectedId, imageId, name: `${name}-${index}`, nonce: randomBytes(16).toString('hex'), phase: 'intent' } this.operating.add(name) try { - if (previous?.phase === 'promoted') { + if (retry && previous?.phase === 'promoted') { const current = pool.slotSnapshot()[index]! pool.drainSlot(index, current.containerId) await pool.rollSlot(index, current.containerId, imageId, async (_id, arm) => await replaceRuntime(previous, () => this.save(), arm)) return previous } + if (!retry) { + const current = pool.slotSnapshot()[index]! + if (current.containerId !== expectedId || !current.draining || current.busy || current.maintenance) { + throw new Error('new roll requires the drained idle current slot') + } + // Missing local artifacts never replace a completed durable receipt. + const image = await dockerEngine('GET', `/images/${imageId}/json`) + if (image.Id !== imageId) throw new Error('runtime image identity differs') + if (previous) (record.history[String(index)] ??= []).push(structuredClone(previous)) + } // Persist the identity before the first Docker write; failures stay drained. record.rolls[String(index)] = receipt this.save() From 6e7be8632ef87bdf91ca32547b262edad823e8f7 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 22 Oct 2026 19:15:34 -0600 Subject: [PATCH 2/6] fix: bind native image proof to each shipped recipe --- scripts/verify-runtime-security.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/verify-runtime-security.py b/scripts/verify-runtime-security.py index bd39ced..87e0f12 100644 --- a/scripts/verify-runtime-security.py +++ b/scripts/verify-runtime-security.py @@ -2,13 +2,17 @@ """Real shipped-image npm/Python/native proof. Uses no credentials or model calls.""" import json from pathlib import Path +import re import subprocess import sys image = sys.argv[1] -if not image.startswith('sha256:') or len(image) != 71: +kind = sys.argv[2] +if not re.fullmatch(r'sha256:[a-f0-9]{64}', image): raise SystemExit('an existing immutable Docker image ID is required') +if kind not in ('runtime', 'bridge'): + raise SystemExit('the producer must specify runtime (all harnesses) or bridge') descriptor = json.loads((Path(__file__).parent / 'npm-security.json').read_text()) overrides = dict(line.strip().split('==') for line in (Path(__file__).parent / 'python-security-overrides.txt').read_text().splitlines() @@ -24,7 +28,10 @@ def run(entrypoint, arguments, source=None): return result.stdout.strip() -versions = {cli: run(cli, ['--version']) for cli in ('node', 'npm', 'claude', 'codex', 'opencode', 'kimi', 'gemini', 'pi')} +harnesses = ['claude', 'codex', 'opencode', 'kimi'] +if kind == 'runtime': + harnesses += ['gemini', 'pi'] +versions = {cli: run(cli, ['--version']) for cli in ['node', 'npm', *harnesses]} if versions['npm'] != descriptor['version']: raise SystemExit('the image npm version does not match its security owner') npm_dependencies = json.loads(run('node', ['-e', "console.log(JSON.stringify(Object.fromEntries(['brace-expansion','undici'].map(n=>[n,require('/usr/local/lib/node_modules/npm/node_modules/'+n+'/package.json').version]))))"])) @@ -88,5 +95,5 @@ async def roundtrip(): 'native': ['aiohttp-loopback', 'asyncssh-ed25519', 'lxml-parse-clean', 'Pillow-PNG-JPEG-WEBP']})) ''' python = json.loads(run('/root/.local/share/uv/tools/kimi-cli/bin/python', ['-', json.dumps(overrides)], python_source)) -print(json.dumps({'image_id': image, 'cli_versions': versions, 'npm_bundled_packages': npm_dependencies, +print(json.dumps({'image_id': image, 'recipe': kind, 'cli_versions': versions, 'npm_bundled_packages': npm_dependencies, 'python': python, 'model_calls': 0}, indent=2)) From ad7e8f6eededcb104aa4c9753fcdd6213b9be1b3 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 22 Oct 2026 19:16:03 -0600 Subject: [PATCH 3/6] fix: bound and isolate native image proof containers --- scripts/verify-runtime-security.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/verify-runtime-security.py b/scripts/verify-runtime-security.py index 87e0f12..5d65c62 100644 --- a/scripts/verify-runtime-security.py +++ b/scripts/verify-runtime-security.py @@ -3,6 +3,7 @@ import json from pathlib import Path import re +import secrets import subprocess import sys @@ -20,12 +21,17 @@ def run(entrypoint, arguments, source=None): - result = subprocess.run(['docker', 'run', '--rm', '-i', '--entrypoint', entrypoint, - image, *arguments], input=source, capture_output=True, - text=True, timeout=120) - if result.returncode: - raise RuntimeError(f'image proof failed ({entrypoint}): {result.stderr[-2000:]}') - return result.stdout.strip() + name = 'cli-security-proof-' + secrets.token_hex(8) + try: + result = subprocess.run(['docker', 'run', '--rm', '-i', '--name', name, + '--network', 'none', '--entrypoint', entrypoint, + image, *arguments], input=source, capture_output=True, + text=True, timeout=120) + if result.returncode: + raise RuntimeError(f'image proof failed ({entrypoint}): {result.stderr[-2000:]}') + return result.stdout.strip() + finally: + subprocess.run(['docker', 'rm', '-f', '-v', name], capture_output=True, timeout=30) harnesses = ['claude', 'codex', 'opencode', 'kimi'] From dd4d623574f88667c51f0e7ab5ef10745e8d51e4 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 22 Oct 2026 19:17:27 -0600 Subject: [PATCH 4/6] fix: identify shipped images for the existing security scanner --- docker/Dockerfile | 1 + docker/Dockerfile.cli-runtime | 1 + scripts/verify-runtime-security.py | 7 ++++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 84f8ec9..f8b52d6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,5 @@ FROM node:24.22.1-trixie-slim AS base +LABEL org.opencontainers.image.source="https://github.com/drewstone/cli-bridge" WORKDIR /app # Build toolchain + CLI-install prereqs. curl is needed for the opencode diff --git a/docker/Dockerfile.cli-runtime b/docker/Dockerfile.cli-runtime index 4ce7455..13a869b 100644 --- a/docker/Dockerfile.cli-runtime +++ b/docker/Dockerfile.cli-runtime @@ -32,6 +32,7 @@ # active backend needs. FROM node:24.22.1-trixie-slim +LABEL org.opencontainers.image.source="https://github.com/drewstone/cli-bridge" ARG CLI_BRIDGE_HARNESSES=all diff --git a/scripts/verify-runtime-security.py b/scripts/verify-runtime-security.py index 5d65c62..d34c5c8 100644 --- a/scripts/verify-runtime-security.py +++ b/scripts/verify-runtime-security.py @@ -14,6 +14,11 @@ raise SystemExit('an existing immutable Docker image ID is required') if kind not in ('runtime', 'bridge'): raise SystemExit('the producer must specify runtime (all harnesses) or bridge') +source = subprocess.run(['docker', 'image', 'inspect', image, '--format', + '{{index .Config.Labels "org.opencontainers.image.source"}}'], + check=True, capture_output=True, text=True, timeout=30).stdout.strip() +if source != 'https://github.com/drewstone/cli-bridge': + raise SystemExit('the image is missing its maintained source ownership label') descriptor = json.loads((Path(__file__).parent / 'npm-security.json').read_text()) overrides = dict(line.strip().split('==') for line in (Path(__file__).parent / 'python-security-overrides.txt').read_text().splitlines() @@ -101,5 +106,5 @@ async def roundtrip(): 'native': ['aiohttp-loopback', 'asyncssh-ed25519', 'lxml-parse-clean', 'Pillow-PNG-JPEG-WEBP']})) ''' python = json.loads(run('/root/.local/share/uv/tools/kimi-cli/bin/python', ['-', json.dumps(overrides)], python_source)) -print(json.dumps({'image_id': image, 'recipe': kind, 'cli_versions': versions, 'npm_bundled_packages': npm_dependencies, +print(json.dumps({'image_id': image, 'source': source, 'recipe': kind, 'cli_versions': versions, 'npm_bundled_packages': npm_dependencies, 'python': python, 'model_calls': 0}, indent=2)) From e3165d2a013e4295b2611c3abcc05990923f852a Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 22 Oct 2026 19:20:40 -0600 Subject: [PATCH 5/6] fix: reap owned native proof containers on producer interruption --- scripts/verify-runtime-security.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/verify-runtime-security.py b/scripts/verify-runtime-security.py index d34c5c8..e24f187 100644 --- a/scripts/verify-runtime-security.py +++ b/scripts/verify-runtime-security.py @@ -4,10 +4,19 @@ from pathlib import Path import re import secrets +import signal import subprocess import sys +def interrupted(number, _frame): + raise SystemExit(128 + number) + + +for interrupt in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + signal.signal(interrupt, interrupted) + + image = sys.argv[1] kind = sys.argv[2] if not re.fullmatch(r'sha256:[a-f0-9]{64}', image): From d4890a72b80895327c8cfdeaeea78d23dcfb1dde Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 22 Oct 2026 19:23:44 -0600 Subject: [PATCH 6/6] docs: record runtime vulnerability reduction prediction --- .agent/climb.jsonl | 1 + 1 file changed, 1 insertion(+) diff --git a/.agent/climb.jsonl b/.agent/climb.jsonl index d22fc5d..3514752 100644 --- a/.agent/climb.jsonl +++ b/.agent/climb.jsonl @@ -1,3 +1,4 @@ {"id": "cli-bridge-interface4-receiver-20261011", "project": "cli-bridge", "layer": "system", "hill": "Crash-safe cloud-to-local context admission", "lever": "adopt canonical request and durable allocated environment binding", "prediction": "The exact profile route will accept after the public contract is upgraded; unsupported allocation fields will have no durable admission", "outcome": "PENDING", "timestamp": "2026-10-11T23:25:00Z", "baseline": {"status": "Public request material rejects profile at Interface 2.13.1", "codeVersion": "7ee852e9079638f59e514aa73110e4103a2e7478", "instrument": "tests/context-transfer.test.ts"}, "result": {"status": "Implementation authored; published Interface floor and green gate pending"}, "reasoning": "Frozen narrow Beelink gate failed in canonical request digest serialization before HTTP. The receiver also needs a separately persisted actual environment identity for an unbound accepted plan."} {"id": "cli-bridge-native-context-owner-20261012", "project": "cli-bridge", "layer": "system", "hill": "Crash-safe cloud-to-local context admission", "lever": "backend-owned typed native initialization and durable native acknowledgement phases", "prediction": "One real pinned Codex conformance case preserves exact selected role and text-part history across receiver restart into the first model input, with zero model calls before authoritative receipt and zero repeated native allocation/import after lost HTTP receipt; uncertain native ACK cases remain fenced", "outcome": "PENDING", "timestamp": "2026-10-12T00:50:44Z", "baseline": {"status": "Receiver source issued a context receipt after readiness and fed selected history through the prompt path; no qualified native first-turn evidence", "codeVersion": "b8585902da9e77872c23c63b836cd5ec8bb42b1d", "instrument": "tests/codex-context-transfer-real.test.ts"}, "result": {"status": "Backend native import, durable phase CAS, ordinary dispatch fence and real-binary conformance authored; frozen dependency floor and gate pending"}, "reasoning": "Use the backend owning profile materialization, executor, auth, and native state. Official Codex public injection checkpoints before ACK. Owned native probes show public history projections cannot reconcile an uncertain injection; hold that operation instead of repeating it. Qualify the first real model input through the consumer."} {"id": "cli-bridge-codex-attach-20261014", "project": "cli-bridge", "layer": "system", "hill": "CLI Bridge routes that accept a Braid profile handoff", "lever": "selected-only Interface 5.2.1 request, attach reported only for the qualified Codex native import, first turn bound to the provider attach route", "prediction": "Braid main plan_fork and execute_fork to a Codex profile over this bridge is allowed, the transfer is imported into a fresh native Codex thread before the first turn, and that turn is admitted with only the new user input", "outcome": "PARTIAL", "timestamp": "2026-10-14T21:45:00Z", "baseline": {"value": 0, "measurement": {"metric": "bridge model routes reporting contextTransfer.attach", "unit": "routes", "selection": "the codex and claude-code routes of a bridge built from cli-bridge main before #258", "selectBy": "version", "n": 2, "basis": "measured", "codeVersion": "cli-bridge@68ece80", "perRelease": true, "instrument": "GET /v1/capabilities?model= on main source (src/sessions/retained/capabilities.ts reports no attach)", "calibration": {"verdict": "UNCALIBRATED", "next": "read the same endpoint on a deployed main bridge"}}}, "result": {"value": 1, "measurement": {"metric": "bridge model routes reporting contextTransfer.attach", "unit": "routes", "selection": "the codex/openai/gpt-5.5 and claude-code/anthropic/claude-opus-5-5 routes of a bridge started from fix/context-transfer-interface4-20261011 with codex-cli 0.162.1", "selectBy": "version", "n": 2, "basis": "measured", "codeVersion": "cli-bridge@afe3da1", "perRelease": true, "instrument": "GET /v1/capabilities?model= on beelink1, then a Braid main 5193bc1 braid rpc plan_fork and execute_fork with profileRef and runner codex", "calibration": {"verdict": "PASS", "check": "the same Braid run reached execute_fork acceptance, a Codex rollout holding the selected messages before the first turn, and first-turn admission", "against": "the claude-code route, which reports no contextTransfer and is refused"}}}, "reasoning": "Codex is the one route that imports into a fresh native session. The first answer could not be produced: every Codex seat is at its usage limit until 2026-10-19 and the Beelink host Codex logins fail refresh (401), so the model step of the live proof remains open."} +{"id": "cli-runtime-fixed-high-20261023", "project": "cli-bridge", "layer": "system", "hill": "Vendor-fixable HIGH findings in the shipped CLI runtime", "lever": "publisher-qualified npm self-upgrade, shared Kimi overrides and append-only per-slot runtime transactions", "prediction": "Reduce the ten retained vendor-fixable HIGH findings to zero in the rebuilt runtime, retain zero CRITICAL, then serve the artifact on all eight independently drained slots without losing credentials or predecessor evidence.", "outcome": "PENDING", "timestamp": "2026-10-23T01:23:19.601645+00:00", "baseline": {"value": 10, "measurement": {"metric": "vendor-fixable HIGH findings in shipped runtime image", "unit": "findings", "n": 1, "selection": "all CRITICAL/HIGH findings with FixedVersion in the retained f24a12 runtime Trivy report; one immutable artifact serves eight slots", "selectBy": "version", "basis": "computed", "codeVersion": "sha256:f24a12b08eb3deafec20639413bdd6c538846b501a00e77f3f36ec18005bdd10", "perRelease": true, "instrument": "retained runtime-trivy.json plus live authenticated pool maintenance receipts", "calibration": {"verdict": "PASS", "check": "six package names and exact package paths were matched to retained scanner findings; eight live slot image/config/rootfs identities verified", "against": "retained immutable runtime Trivy JSON and eight Docker image bindings"}}}, "result": {"status": "Real Docker/authenticated HTTP repeated-artifact and restart integration passed209s at9367486; image native/scan pass awaits admitted floor+12GiB producer, and live second roll remains pending.", "value": 209, "measurement": {"metric": "actual repeated-artifact Docker and authenticated HTTP integration gate duration", "unit": "seconds", "n": 1, "selection": "the exact9367486 frozen install/typecheck/Docker/HTTP gate beelink2-a2bb99ec5e50c9cddefb6c8842500c8b", "selectBy": "sample", "basis": "measured", "codeVersion": "936748602e3ca28535b39441cc5cb11776cd95d9", "perRelease": true, "instrument": "beelink-gate durable receipt and real Docker/HTTP proof output"}}, "reasoning": "Patch the existing image owners and roll through the existing authenticated slot owner. Count UV cache archives as shipped packages and retain remaining unfixed HIGH findings; the scheduled native host-security owner will scan actual local source-labeled immutable images."}