diff --git a/.github/workflows/opencode-bwrap-egress.yml b/.github/workflows/opencode-bwrap-egress.yml new file mode 100644 index 0000000000..06ed235d94 --- /dev/null +++ b/.github/workflows/opencode-bwrap-egress.yml @@ -0,0 +1,43 @@ +name: OpenCode bwrap egress + +on: + pull_request: + paths: + - 'cli/src/lib/adapters/opencode.js' + - 'cli/src/lib/provider-proxy.js' + - 'cli/src/lib/instance-proxy.js' + - 'cli/src/lib/sandbox/bwrap.js' + - 'cli/src/lib/sandbox/opencode-loopback.mjs' + - 'commonly-mcp/**' + - 'cli/__tests__/opencode.bwrap-egress.test.mjs' + - 'cli/__tests__/instance-proxy.test.mjs' + - 'scripts/fixtures/opencode-mcp-probe.mjs' + - 'scripts/verify-opencode-bwrap-mcp.mjs' + - '.github/workflows/opencode-bwrap-egress.yml' + +permissions: + contents: read + +jobs: + isolated-egress: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Exercise provider and Commonly MCP in a disposable namespace + run: | + docker run --rm --privileged --security-opt apparmor=unconfined \ + --mount "type=bind,src=$PWD,dst=/source,readonly" node:22 bash -lc ' + set -euo pipefail + apt-get update -qq + apt-get install -y -qq bubblewrap iproute2 >/dev/null + mkdir -p /work + cp -R /source/cli /source/commonly-mcp /source/scripts /work/ + rm -rf /work/cli/node_modules /work/commonly-mcp/node_modules + npm ci --prefix /work/cli --silent + npm ci --prefix /work/commonly-mcp --silent + chown -R node:node /work + su node -s /bin/bash -c "cd /work/cli && COMMONLY_REQUIRE_BWRAP_SMOKE=1 node --experimental-vm-modules node_modules/.bin/jest __tests__/opencode.bwrap-egress.test.mjs __tests__/instance-proxy.test.mjs --runInBand" + su node -s /bin/bash -c "node /work/scripts/verify-opencode-bwrap-mcp.mjs" + ' diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index d1fd43ff8c..ac8b33c951 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -957,12 +957,19 @@ describe('opencode external provider config', () => { _platform: 'linux', _detectBwrap: () => ({ available: true, path: '/usr/bin/bwrap' }), _spawnImpl: spawn.impl, - _wrapArgvWithBwrap: (argv, _environment, opts) => { + _wrapArgvWithBwrap: (argv, wrappedEnvironment, opts) => { + expect(wrappedEnvironment.sandbox.network.policy).toBe('restricted'); expect(opts.readOnlyPaths).toEqual([ expect.stringMatching(/commonly-opencode-/), '/usr/local/bin/opencode', process.execPath, + expect.stringMatching(/opencode-loopback\.mjs$/), ]); + expect(opts.optionalReadOnlyPaths).toEqual(['/usr/sbin', '/sbin']); + expect(opts.enableLoopback).toBe(true); + expect(argv.slice(0, 2)).toEqual([process.execPath, expect.stringMatching(/opencode-loopback\.mjs$/)]); + expect(argv[2]).toMatch(/provider-proxy\.sock$/); + expect(argv[4]).toBe('-'); expect(opts.readOnlyPaths).not.toContain(realKey); expect(opts.readOnlyPaths).not.toContain(realAuthPath); return ['/usr/bin/bwrap', '--test', ...argv]; @@ -971,17 +978,19 @@ describe('opencode external provider config', () => { }); }); - test('refuses a public bwrap provider when restricted networking cannot reach the loopback proxy', async () => { + test('public bwrap uses its isolated proxy even when restricted networking is requested', async () => { await withTemp(async (root) => { const workspace = join(root, 'workspace'); + const keyFile = join(root, 'provider-key'); await mkdir(workspace, { recursive: true }); + await writeFile(keyFile, 'provider-key', { mode: 0o600 }); const spawn = makeSpawnImpl(); - await expect(opencode.spawn('public provider turn', { + await opencode.spawn('public provider turn', { cwd: workspace, env: { PATH: process.env.PATH }, environment: { model: 'gpt-5.4', - provider: { id: 'litellm', baseURL: 'https://llm.example.test/v1', keyFile: '/unused/provider-key' }, + provider: { id: 'litellm', baseURL: 'https://llm.example.test/v1', keyFile }, sandbox: { trust: 'public', mode: 'bwrap', network: { policy: 'restricted' }, }, @@ -991,8 +1000,14 @@ describe('opencode external provider config', () => { _platform: 'linux', _detectBwrap: () => ({ available: true, path: '/usr/bin/bwrap' }), _spawnImpl: spawn.impl, - })).rejects.toThrow(/restricted cannot reach the loopback proxy/); - expect(spawn.calls).toHaveLength(0); + _wrapArgvWithBwrap: (argv, environment, opts) => { + expect(environment.sandbox.network.policy).toBe('restricted'); + expect(opts.enableLoopback).toBe(true); + expect(argv[2]).toMatch(/provider-proxy\.sock$/); + return ['/usr/bin/bwrap', '--test', ...argv]; + }, + }); + expect(spawn.calls).toHaveLength(1); }); }); @@ -1554,7 +1569,9 @@ describe('opencode adapter — spawn()', () => { expect(opts.workspacePath).toBe(workspace); expect(opts.readOnlyPaths).toEqual([ expect.any(String), '/usr/local/bin/opencode', process.execPath, + expect.stringMatching(/opencode-loopback\.mjs$/), ]); + expect(opts.optionalReadOnlyPaths).toEqual(['/usr/sbin', '/sbin']); expect(opts.readOnlyPaths).not.toContain(keyFile); expect(opts.readOnlyPaths).not.toContain(join(xdgData, 'opencode', 'auth.json')); expect(opts).not.toHaveProperty('loopbackNetworkPorts'); diff --git a/cli/__tests__/bwrap.test.mjs b/cli/__tests__/bwrap.test.mjs index 3474168b5d..52f28dd9f9 100644 --- a/cli/__tests__/bwrap.test.mjs +++ b/cli/__tests__/bwrap.test.mjs @@ -120,6 +120,23 @@ describe('wrapArgvWithBwrap', () => { expect(argv).not.toContain('--share-net'); }); + itLinux('allows loopback setup only inside an isolated network namespace', () => { + const argv = wrapArgvWithBwrap( + ['node', 'bridge.mjs'], + { sandbox: { network: { policy: 'restricted' } } }, + { workspacePath: '/tmp/ws', enableLoopback: true }, + ); + expect(argv).toContain('--unshare-net'); + expect(argv).not.toContain('--share-net'); + expect(argv.slice(argv.indexOf('--uid'), argv.indexOf('--uid') + 4)) + .toEqual(['--uid', '0', '--gid', '0']); + expect(argv.slice(argv.indexOf('--cap-add'), argv.indexOf('--cap-add') + 4)) + .toEqual(['--cap-add', 'CAP_NET_ADMIN', '--cap-add', 'CAP_SETPCAP']); + expect(() => wrapArgvWithBwrap( + ['node', 'bridge.mjs'], {}, { workspacePath: '/tmp/ws', enableLoopback: true }, + )).toThrow(/requires an isolated network namespace/); + }); + itLinux('appends user read-outside paths as --ro-bind-try', () => { const argv = wrapArgvWithBwrap( ['claude'], diff --git a/cli/__tests__/instance-proxy.test.mjs b/cli/__tests__/instance-proxy.test.mjs new file mode 100644 index 0000000000..9d9867f404 --- /dev/null +++ b/cli/__tests__/instance-proxy.test.mjs @@ -0,0 +1,84 @@ +import { createServer } from 'http'; +import { connect } from 'net'; +import { once } from 'events'; +import { mkdtemp, rm, stat } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { startInstanceProxy } from '../src/lib/instance-proxy.js'; + +test.each(['client disconnect', 'proxy shutdown'])( + '%s cancels a pending instance request', async (ending) => { + const root = await mkdtemp(join(tmpdir(), 'commonly-instance-proxy-')); + const upstreamSockets = new Set(); + let received; + const hit = new Promise((resolve) => { received = resolve; }); + const target = createServer(() => { received(); }); + target.on('connection', (socket) => { + upstreamSockets.add(socket); + socket.on('close', () => upstreamSockets.delete(socket)); + }); + await new Promise((resolve) => target.listen(0, '127.0.0.1', resolve)); + const socketPath = join(root, 'instance.sock'); + let proxy; + try { + proxy = await startInstanceProxy({ + instanceUrl: `http://127.0.0.1:${target.address().port}`, + socketPath, + }); + expect((await stat(socketPath)).mode & 0o777).toBe(0o600); + const client = connect(socketPath); + await once(client, 'connect'); + client.write('GET /api/agents/runtime/pods/probe/context HTTP/1.1\r\nHost: proxy\r\n\r\n'); + await hit; + if (ending === 'client disconnect') { + const closed = once(client, 'close'); + client.destroy(); + await closed; + } else { + await proxy.close(); + proxy = null; + client.destroy(); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(upstreamSockets.size).toBe(0); + } finally { + await proxy?.close(); + target.closeAllConnections(); + await new Promise((resolve) => target.close(resolve)); + await rm(root, { recursive: true, force: true }); + } + }, +); + +test('closes the downstream client when an upstream response ends incomplete', async () => { + const root = await mkdtemp(join(tmpdir(), 'commonly-instance-proxy-')); + const target = createServer((_request, response) => { + response.writeHead(200, { 'content-length': '100' }); + response.write('partial'); + setTimeout(() => response.destroy(), 25); + }); + await new Promise((resolve) => target.listen(0, '127.0.0.1', resolve)); + let proxy; + let client; + let timeout; + try { + proxy = await startInstanceProxy({ + instanceUrl: `http://127.0.0.1:${target.address().port}`, + socketPath: join(root, 'instance.sock'), + }); + client = connect(proxy.socketPath); + await once(client, 'connect'); + const closed = new Promise((resolve) => client.once('close', () => resolve(true))); + client.write('GET /v1/models HTTP/1.1\r\nHost: proxy\r\n\r\n'); + await once(client, 'data'); + const timedOut = new Promise((resolve) => { timeout = setTimeout(() => resolve(false), 500); }); + expect(await Promise.race([closed, timedOut])).toBe(true); + } finally { + clearTimeout(timeout); + client?.destroy(); + await proxy?.close(); + target.closeAllConnections(); + await new Promise((resolve) => target.close(resolve)); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/cli/__tests__/opencode.bwrap-egress.test.mjs b/cli/__tests__/opencode.bwrap-egress.test.mjs new file mode 100644 index 0000000000..2f83adbb26 --- /dev/null +++ b/cli/__tests__/opencode.bwrap-egress.test.mjs @@ -0,0 +1,127 @@ +import { spawn, spawnSync } from 'child_process'; +import { createServer } from 'http'; +import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { detectBwrap, wrapArgvWithBwrap } from '../src/lib/sandbox/bwrap.js'; +import { startProviderProxy } from '../src/lib/provider-proxy.js'; +import { startInstanceProxy } from '../src/lib/instance-proxy.js'; + +const LOOPBACK_BRIDGE = fileURLToPath(new URL('../src/lib/sandbox/opencode-loopback.mjs', import.meta.url)); +const PORT = 8765; +const INSTANCE_PORT = 8766; +const run = (cmd, args) => new Promise((resolve, reject) => { + const child = spawn(cmd, args, { timeout: 15_000 }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); +}); +const listen = (server) => new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); +}); +const close = (server) => new Promise((resolve) => server.close(resolve)); + +test('public bwrap reaches its provider proxy but refuses other host and internet ports', async () => { + const available = process.platform === 'linux' && detectBwrap().available + && spawnSync('ip', ['-V']).status === 0; + if (!available) { + if (process.env.COMMONLY_REQUIRE_BWRAP_SMOKE === '1') { + throw new Error('dedicated bwrap smoke requires a working Linux namespace and iproute2'); + } + return; + } + + const root = await mkdtemp(join(tmpdir(), 'commonly-egress-')); + const privateRoot = await mkdtemp(join(tmpdir(), 'commonly-egress-key-')); + const workspace = join(root, 'workspace'); + const proxyDir = join(root, 'proxy'); + const keyFile = join(privateRoot, 'key'); + const tokenFile = join(proxyDir, 'token'); + const socketPath = join(proxyDir, 'proxy.sock'); + const instanceSocketPath = join(proxyDir, 'instance.sock'); + const childFile = join(workspace, 'probe.mjs'); + await Promise.all([mkdir(workspace), mkdir(proxyDir)]); + await writeFile(keyFile, 'upstream-key', { mode: 0o600 }); + await chmod(keyFile, 0o600); + const upstream = createServer((req, res) => { + if (req.url !== '/v1/models' || req.headers.authorization !== 'Bearer upstream-key') { + res.writeHead(403).end(); + } else res.writeHead(200).end('models'); + }); + const unrelated = createServer((_req, res) => res.writeHead(200).end('wrong-port')); + let proxy; + let instanceProxy; + const instance = createServer((req, res) => { + if (req.url === '/api/agents/runtime/pods/pod-1/context' + && req.headers.authorization === 'Bearer seat-token') res.writeHead(200).end('context'); + else res.writeHead(403).end(); + }); + try { + const upstreamPort = await listen(upstream); + const unrelatedPort = await listen(unrelated); + const instancePort = await listen(instance); + proxy = await startProviderProxy({ + provider: { baseURL: `http://127.0.0.1:${upstreamPort}/v1` }, + keyFile, tokenFile, socketPath, isolatedPort: PORT, + }); + expect((await stat(socketPath)).mode & 0o777).toBe(0o600); + instanceProxy = await startInstanceProxy({ + instanceUrl: `http://127.0.0.1:${instancePort}`, + socketPath: instanceSocketPath, + }); + expect((await stat(instanceSocketPath)).mode & 0o777).toBe(0o600); + await writeFile(childFile, ` + import { readFileSync } from 'fs'; + import { get } from 'http'; + import { connect } from 'net'; + import { spawnSync } from 'child_process'; + const token = readFileSync(process.argv[2], 'utf8'); + const request = (port, path, headers = {}) => new Promise((resolve) => { + const req = get({ hostname: '127.0.0.1', port, path, headers, timeout: 1000 }, (res) => { + res.resume(); res.on('end', () => resolve(res.statusCode)); + }); + req.on('timeout', () => req.destroy()); + req.on('error', () => resolve(0)); + }); + const internet = await new Promise((resolve) => { + const conn = connect({ host: '8.8.8.8', port: 53, timeout: 1000 }); + conn.on('connect', () => { conn.destroy(); resolve(false); }); + conn.on('timeout', () => { conn.destroy(); resolve(true); }); + conn.on('error', () => resolve(true)); + }); + const allowed = await request(${PORT}, '/v1/models', { authorization: 'Bearer ' + token }); + const context = await request(${INSTANCE_PORT}, '/api/agents/runtime/pods/pod-1/context', + { authorization: 'Bearer seat-token' }); + const otherHostPort = await request(${unrelatedPort}, '/'); + const netAdminDropped = spawnSync('ip', ['link', 'set', 'lo', 'down']).status !== 0; + const capabilitiesDropped = /^CapEff:\\s*0+$/m.test(readFileSync('/proc/self/status', 'utf8')); + process.stdout.write(JSON.stringify({ allowed, context, otherHostPort, internet, netAdminDropped, capabilitiesDropped })); + `); + const argv = wrapArgvWithBwrap([ + process.execPath, LOOPBACK_BRIDGE, socketPath, String(PORT), + instanceSocketPath, String(INSTANCE_PORT), + process.execPath, childFile, tokenFile, + ], { sandbox: { network: { policy: 'restricted' } } }, { + workspacePath: workspace, + readOnlyPaths: [proxyDir, process.execPath, LOOPBACK_BRIDGE], + optionalReadOnlyPaths: ['/usr/sbin', '/sbin'], + enableLoopback: true, + }); + const result = await run(argv[0], argv.slice(1)); + expect(result).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(result.stdout)).toEqual({ + allowed: 200, context: 200, otherHostPort: 0, internet: true, + netAdminDropped: true, capabilitiesDropped: true, + }); + } finally { + await proxy?.close(); + await instanceProxy?.close(); + await Promise.all([close(upstream), close(unrelated), close(instance)]); + await rm(root, { recursive: true, force: true }); + await rm(privateRoot, { recursive: true, force: true }); + } +}, 25_000); diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index 8776761de4..cd77742804 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -9,8 +9,8 @@ * stay in the host adapter and reach the configured provider through a * per-spawn loopback proxy; OpenCode receives only that proxy's bearer. * This protects the key, not provider spend: the model can use its seat's key - * budget, and bwrap's existing shared networking is not a per-port egress - * fence. + * budget. On Linux the process has its own network namespace, with loopback + * bridges to the fixed provider and Commonly instance origins. * Config is generated per spawn. OpenCode merges config sources, therefore the * global config directory is isolated and project config is explicitly off; * the environment spec is the sole source of MCP servers for this run. @@ -34,6 +34,7 @@ import { writeFile, } from 'fs/promises'; import { homedir, tmpdir } from 'os'; +import { fileURLToPath } from 'url'; import { dirname, join, resolve as pathResolve, } from 'path'; @@ -53,8 +54,12 @@ import { resolvePublicSandboxMode } from '../sandbox/mode.js'; import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js'; import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js'; import { isAnswerableToolError } from '../public-tool-error.js'; +import { startInstanceProxy } from '../instance-proxy.js'; const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; +const ISOLATED_PROXY_PORT = 8765; +const ISOLATED_INSTANCE_PORT = 8766; +const LOOPBACK_BRIDGE = fileURLToPath(new URL('../sandbox/opencode-loopback.mjs', import.meta.url)); const TERMINATION_GRACE_MS = 5000; // Bump only with a passing real sandbox-exec smoke against the new version. const TESTED_OPENCODE_VERSIONS = Object.freeze(['1.18.35', '1.19.0']); @@ -658,10 +663,6 @@ export default { if (isPublic && !provider) { throw new Error('public OpenCode seats require environment.provider; the implicit public tier and operator auth.json are unsupported for public trust'); } - if (isPublic && sandboxMode === 'bwrap' && provider - && sandbox?.network?.policy === 'restricted') { - throw new Error('public OpenCode provider proxy requires shared bwrap networking; sandbox.network.policy=restricted cannot reach the loopback proxy'); - } const providerKeyFile = await validateProviderEnvironment(ctx.environment, ctx.cwd); const sourceEnv = ctx.env || process.env; @@ -727,6 +728,7 @@ export default { }); let openCodeHome = null; let providerProxy = null; + let instanceProxy = null; try { openCodeHome = await prepareOpenCodeDataHome(ctx, { publicSeat: isPublic }); if (isPublic && provider) { @@ -736,6 +738,16 @@ export default { provider, keyFile: providerKeyFile, tokenFile: join(tempDir, 'provider-proxy-token'), + ...(sandboxMode === 'bwrap' ? { + socketPath: join(tempDir, 'provider-proxy.sock'), + isolatedPort: ISOLATED_PROXY_PORT, + } : {}), + }); + } + if (isPublic && sandboxMode === 'bwrap' && ctx.instanceUrl) { + instanceProxy = await startInstanceProxy({ + instanceUrl: ctx.instanceUrl, + socketPath: join(tempDir, 'instance-proxy.sock'), }); } const stateHome = isPublic ? join(openCodeHome.root, 'state') : null; @@ -750,7 +762,8 @@ export default { const mcpReadDirectories = []; const mcp = buildMcpConfig(ctx.environment?.mcp, { runtimeToken: ctx.runtimeToken, - instanceUrl: ctx.instanceUrl, + instanceUrl: instanceProxy + ? `http://127.0.0.1:${ISOLATED_INSTANCE_PORT}` : ctx.instanceUrl, credentialFile: credential?.path || null, publicSeat: isPublic, unavailableMcp, @@ -805,10 +818,15 @@ export default { } const bwrap = (ctx._detectBwrap || detectBwrap)(); if (!bwrap.available) throw new Error(`public OpenCode agents require bwrap: ${bwrap.error}`); - const wrapped = (ctx._wrapArgvWithBwrap || wrapArgvWithBwrap)([binary, ...args], { + const wrapped = (ctx._wrapArgvWithBwrap || wrapArgvWithBwrap)([ + process.execPath, LOOPBACK_BRIDGE, + providerProxy.socketPath, String(ISOLATED_PROXY_PORT), + instanceProxy?.socketPath || '-', String(ISOLATED_INSTANCE_PORT), binary, ...args, + ], { ...ctx.environment, sandbox: { ...sandbox, + network: { ...sandbox?.network, policy: 'restricted' }, filesystem: { ...(sandbox?.filesystem || {}), 'write-outside': [ @@ -824,8 +842,11 @@ export default { tempDir, binary, process.execPath, + LOOPBACK_BRIDGE, ...mcpReadDirectories, ], + optionalReadOnlyPaths: ['/usr/sbin', '/sbin'], + enableLoopback: true, }); [cmd, ...spawnArgs] = wrapped; } else if (isPublic) { @@ -864,6 +885,7 @@ export default { ...(result.usage ? { usage: result.usage } : {}), }; } finally { + try { await instanceProxy?.close(); } catch { /* ignore */ } try { await providerProxy?.close(); } catch { /* ignore */ } try { await rm(tempDir, { recursive: true, force: true }); } catch { /* ignore */ } } diff --git a/cli/src/lib/instance-proxy.js b/cli/src/lib/instance-proxy.js new file mode 100644 index 0000000000..e93362b4a9 --- /dev/null +++ b/cli/src/lib/instance-proxy.js @@ -0,0 +1,67 @@ +/** A per-spawn Unix-socket relay to one declared Commonly instance origin. */ +import { createServer, request as httpRequest } from 'http'; +import { request as httpsRequest } from 'https'; +import { chmod, unlink } from 'fs/promises'; + +export const startInstanceProxy = async ({ instanceUrl, socketPath }) => { + const target = new URL(instanceUrl); + if (!['http:', 'https:'].includes(target.protocol) + || target.username || target.password || target.search || target.hash + || target.pathname !== '/') { + throw new Error('Commonly instance proxy requires a bare http(s) origin'); + } + const transport = target.protocol === 'https:' ? httpsRequest : httpRequest; + const upstreams = new Set(); + const server = createServer((incoming, outgoing) => { + const path = incoming.url || ''; + if (!path.startsWith('/') || path.startsWith('//') + || incoming.method === 'CONNECT' || incoming.headers.upgrade) { + incoming.resume(); + outgoing.writeHead(400).end(); + return; + } + const headers = { ...incoming.headers, host: target.host }; + delete headers.connection; + delete headers['proxy-authorization']; + delete headers['proxy-connection']; + const upstream = transport(target, { + method: incoming.method, + path, + headers, + }, (response) => { + outgoing.writeHead(response.statusCode || 502, response.headers); + response.pipe(outgoing); + response.on('error', () => outgoing.destroy()); + response.on('close', () => { + if (!response.complete) outgoing.destroy(); + }); + }); + upstreams.add(upstream); + upstream.on('close', () => upstreams.delete(upstream)); + outgoing.on('close', () => upstream.destroy()); + upstream.on('error', () => { + if (outgoing.destroyed) return; + if (!outgoing.headersSent) outgoing.writeHead(502); + outgoing.end(); + }); + incoming.pipe(upstream); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.removeListener('error', reject); + resolve(); + }); + }); + await chmod(socketPath, 0o600); + return { + socketPath, + close: async () => { + const closed = new Promise((resolve) => server.close(resolve)); + for (const upstream of upstreams) upstream.destroy(); + server.closeAllConnections(); + await closed; + await unlink(socketPath).catch((err) => { if (err.code !== 'ENOENT') throw err; }); + }, + }; +}; diff --git a/cli/src/lib/provider-proxy.js b/cli/src/lib/provider-proxy.js index 7cf05f48c2..9101fcb780 100644 --- a/cli/src/lib/provider-proxy.js +++ b/cli/src/lib/provider-proxy.js @@ -3,7 +3,7 @@ import { randomBytes, timingSafeEqual } from 'crypto'; import { constants as fsConstants, realpathSync } from 'fs'; import { createServer, request as httpRequest } from 'http'; import { request as httpsRequest } from 'https'; -import { chmod, lstat, open, writeFile } from 'fs/promises'; +import { chmod, lstat, open, unlink, writeFile } from 'fs/promises'; import { isAbsolute, relative, sep } from 'path'; const isPathWithin = (parent, candidate) => { @@ -68,9 +68,13 @@ const readProviderKey = async (keyFile, adapterLabel) => { }; export const startProviderProxy = async ({ - provider, keyFile, tokenFile, adapterLabel = 'OpenCode', + provider, keyFile, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode', }) => { const upstream = new URL(provider.baseURL); + if (socketPath && (!isAbsolute(socketPath) + || !Number.isInteger(isolatedPort) || isolatedPort < 1 || isolatedPort > 65535)) { + throw new Error(`${adapterLabel} isolated proxy requires an absolute socket path and valid port`); + } if (!['http:', 'https:'].includes(upstream.protocol) || upstream.username || upstream.password || upstream.search || upstream.hash) { throw new Error(`${adapterLabel} provider.baseURL must be an http(s) URL without embedded credentials or query data`); @@ -204,19 +208,33 @@ export const startProviderProxy = async ({ await new Promise((resolve, reject) => { server.once('error', reject); - server.listen(0, '127.0.0.1', () => { + const started = () => { server.removeListener('error', reject); resolve(); - }); + }; + if (socketPath) server.listen(socketPath, started); + else server.listen(0, '127.0.0.1', started); }); - const { port } = server.address(); + if (socketPath) { + try { + await chmod(socketPath, 0o600); + } catch (err) { + await new Promise((resolve) => server.close(resolve)); + throw err; + } + } + const port = socketPath ? isolatedPort : server.address().port; const proxyBaseURL = new URL(upstream.pathname, `http://127.0.0.1:${port}`).toString(); return { baseURL: proxyBaseURL, tokenFile, + socketPath, port, close: () => new Promise((resolve) => { - server.close(() => resolve()); + server.close(async () => { + if (socketPath) await unlink(socketPath).catch(() => {}); + resolve(); + }); server.closeAllConnections?.(); }), }; diff --git a/cli/src/lib/sandbox/bwrap.js b/cli/src/lib/sandbox/bwrap.js index b83bf157a8..1a967d9c32 100644 --- a/cli/src/lib/sandbox/bwrap.js +++ b/cli/src/lib/sandbox/bwrap.js @@ -86,10 +86,9 @@ export const detectBwrap = ({ spawn = spawnSync, platform = process.platform } = * * Network policy honesty (ADR-008 §invariant #4 informs this): * - unrestricted → --share-net (host networking inside the namespace) - * - restricted → --unshare-net (NO network at all). Host-allowlist - * filtering needs an outbound proxy or per-host TLS interception, neither - * of which is in Phase 1 scope. Attach-time emits a loud warning so the - * user sees the gap before they think they're protected. + * - restricted → --unshare-net (no external network). OpenCode may opt + * into a private loopback bridge to its per-spawn Unix-socket proxy. + * Host-allowlist filtering is not supplied by bwrap itself. * * The expansion order matches `bwrap`'s left-to-right argument application: * isolation flags first, then binds (later binds win on collision), then the @@ -112,10 +111,17 @@ export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => { const networkFlags = policy === 'restricted' ? ['--unshare-net'] : ['--share-net']; + if (opts.enableLoopback && policy !== 'restricted') { + throw new Error('bwrap loopback bridge requires an isolated network namespace'); + } const flags = [ '--unshare-all', ...networkFlags, + ...(opts.enableLoopback ? [ + '--uid', '0', '--gid', '0', + '--cap-add', 'CAP_NET_ADMIN', '--cap-add', 'CAP_SETPCAP', + ] : []), '--die-with-parent', '--new-session', '--proc', '/proc', @@ -148,6 +154,12 @@ export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => { } flags.push('--ro-bind', p, p); } + for (const p of opts.optionalReadOnlyPaths || []) { + if (!p || !isAbsolute(p)) { + throw new Error('wrapArgvWithBwrap opts.optionalReadOnlyPaths entries must be absolute paths'); + } + flags.push('--ro-bind-try', p, p); + } flags.push('--bind', opts.workspacePath, opts.workspacePath); flags.push('--chdir', opts.workspacePath); diff --git a/cli/src/lib/sandbox/opencode-loopback.mjs b/cli/src/lib/sandbox/opencode-loopback.mjs new file mode 100644 index 0000000000..c3ca184a06 --- /dev/null +++ b/cli/src/lib/sandbox/opencode-loopback.mjs @@ -0,0 +1,68 @@ +/** + * Runs inside OpenCode's private bwrap network namespace. The only route to + * the host is through the two fixed-origin Unix sockets bound for this spawn. + */ +import { spawn, spawnSync } from 'child_process'; +import { existsSync } from 'fs'; +import { createServer, connect } from 'net'; + +const [socketPath, portText, instanceSocketPath, instancePortText, binary, ...args] = process.argv.slice(2); +const port = Number(portText); +const instancePort = Number(instancePortText); +if (!socketPath || !instanceSocketPath || !binary + || !Number.isInteger(port) || port < 1 || port > 65535 + || !Number.isInteger(instancePort) || instancePort < 1 || instancePort > 65535 + || port === instancePort) { + throw new Error('OpenCode loopback bridge requires distinct provider and instance ports'); +} + +const ip = ['/usr/sbin/ip', '/sbin/ip', '/usr/bin/ip', '/bin/ip'].find(existsSync); +if (!ip) throw new Error('OpenCode isolated loopback requires iproute2'); +const up = spawnSync(ip, ['link', 'set', 'lo', 'up'], { encoding: 'utf8' }); +if (up.error || up.status !== 0) { + throw new Error(`OpenCode isolated loopback could not start: ${up.error?.message || up.stderr?.trim() || up.status}`); +} +const setpriv = ['/usr/bin/setpriv', '/bin/setpriv'].find(existsSync); +if (!setpriv) throw new Error('OpenCode isolated loopback requires setpriv'); + +const sockets = new Set(); +const makeBridge = (path) => createServer((local) => { + const proxy = connect(path); + sockets.add(local); + sockets.add(proxy); + local.on('close', () => sockets.delete(local)); + proxy.on('close', () => sockets.delete(proxy)); + local.on('error', () => proxy.destroy()); + proxy.on('error', () => local.destroy()); + local.pipe(proxy).pipe(local); +}); +const providerBridge = makeBridge(socketPath); +const instanceBridge = instanceSocketPath === '-' ? null : makeBridge(instanceSocketPath); +providerBridge.on('error', (err) => { throw err; }); +instanceBridge?.on('error', (err) => { throw err; }); +providerBridge.listen(port, '127.0.0.1', () => { + const startChild = () => { + // Only this bridge needs CAP_NET_ADMIN to raise lo and CAP_SETPCAP to drop + // both capabilities before starting the model process. + const child = spawn(setpriv, [ + '--bounding-set=-net_admin,-setpcap', + '--inh-caps=-net_admin,-setpcap', + '--ambient-caps=-net_admin,-setpcap', + binary, ...args, + ], { stdio: 'inherit', env: process.env }); + const forward = (signal) => { if (!child.killed) child.kill(signal); }; + process.on('SIGTERM', () => forward('SIGTERM')); + process.on('SIGINT', () => forward('SIGINT')); + child.on('error', (err) => { + process.stderr.write(`${err.message}\n`); + }); + child.on('close', (code, signal) => { + for (const socket of sockets) socket.destroy(); + providerBridge.close(); + if (instanceBridge) instanceBridge.close(() => { process.exitCode = code ?? (signal === 'SIGTERM' ? 143 : 1); }); + else process.exitCode = code ?? (signal === 'SIGTERM' ? 143 : 1); + }); + }; + if (instanceBridge) instanceBridge.listen(instancePort, '127.0.0.1', startChild); + else startChild(); +}); diff --git a/docs/agents/LOCAL_CLI_WRAPPER.md b/docs/agents/LOCAL_CLI_WRAPPER.md index ae0c5c12c1..5a4d8e02a5 100644 --- a/docs/agents/LOCAL_CLI_WRAPPER.md +++ b/docs/agents/LOCAL_CLI_WRAPPER.md @@ -215,6 +215,15 @@ If you want to purge an agent's identity entirely (admin-only), contact a Common See [ADR-005 §Adapter pattern](../adr/ADR-005-local-cli-wrapper-driver.md) for how to add a new one. +Public OpenCode seats on Linux require `bwrap`, `iproute2` (`ip`), and +`setpriv` (util-linux). The adapter starts a private network namespace. A +short-lived bridge brings up only its loopback interface and forwards its +provider port and declared Commonly instance port to fixed-origin host Unix-socket proxies. The OpenCode child +starts after the bridge drops its namespace capabilities. Other host ports and +internet addresses are unreachable; missing isolation tools stop the turn. +Run `cli/__tests__/opencode.bwrap-egress.test.mjs` on a Linux host that permits +bubblewrap namespaces to check the socket path and refused connections. + --- ## Memory bridge diff --git a/scripts/fixtures/opencode-mcp-probe.mjs b/scripts/fixtures/opencode-mcp-probe.mjs new file mode 100644 index 0000000000..787f4c1fbc --- /dev/null +++ b/scripts/fixtures/opencode-mcp-probe.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import { readFileSync } from 'fs'; +import { spawn } from 'child_process'; + +if (process.argv.includes('--version')) { + process.stdout.write('1.19.0\n'); + process.exit(0); +} + +const config = JSON.parse(readFileSync(process.env.OPENCODE_CONFIG, 'utf8')); +const provider = config.provider.local.options; +const token = readFileSync(provider.apiKey.match(/^\{file:(.*)\}$/)[1], 'utf8'); +const response = await fetch(`${provider.baseURL}/models`, { + headers: { authorization: `Bearer ${token}` }, +}); +const mcp = config.mcp.commonly; +const child = spawn(mcp.command[0], mcp.command.slice(1), { + env: { ...process.env, ...mcp.environment }, + stdio: ['pipe', 'pipe', 'inherit'], +}); +let buffer = ''; +let serial = 0; +const pending = new Map(); +child.stdout.on('data', (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf('\n')) >= 0) { + const message = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (message.id) pending.get(message.id)?.(message); + } +}); +const rpc = (method, params) => new Promise((resolve, reject) => { + const id = ++serial; + const timer = setTimeout(() => reject(new Error(`MCP RPC timed out: ${method}`)), 5000); + pending.set(id, (result) => { + clearTimeout(timer); + pending.delete(id); + resolve(result); + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); +}); +await rpc('initialize', { + protocolVersion: '2024-11-05', capabilities: {}, + clientInfo: { name: 'opencode-egress-probe', version: '1' }, +}); +child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`); +const tools = await rpc('tools/list', {}); +const context = await rpc('tools/call', { + name: 'commonly_get_context', arguments: { podId: 'probe' }, +}); +const post = await rpc('tools/call', { + name: 'commonly_post_message', arguments: { podId: 'probe', content: 'probe' }, +}); +child.stdin.end(); +await new Promise((resolve) => child.on('close', resolve)); +process.stdout.write(`${JSON.stringify({ + type: 'text', sessionID: 'probe', + part: { type: 'text', text: JSON.stringify({ + provider: response.status, + mcpTools: tools.result.tools.length, + apiUrl: mcp.environment.COMMONLY_API_URL, + context: context.result, + post: post.result, + }) }, +})}\n`); diff --git a/scripts/verify-opencode-bwrap-mcp.mjs b/scripts/verify-opencode-bwrap-mcp.mjs new file mode 100644 index 0000000000..ed3f2b1b6a --- /dev/null +++ b/scripts/verify-opencode-bwrap-mcp.mjs @@ -0,0 +1,79 @@ +/** Actual OpenCode adapter + stdio Commonly MCP transport in a Linux namespace. */ +import assert from 'assert/strict'; +import { createServer } from 'http'; +import { chmod, cp, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import opencode from '../cli/src/lib/adapters/opencode.js'; +import { commonlyMcpServer } from '../cli/src/lib/default-environment.js'; + +const repo = fileURLToPath(new URL('../', import.meta.url)); +const root = await mkdtemp(join(tmpdir(), 'commonly-bwrap-mcp-')); +const workspace = join(root, 'workspace'); +const mcpHome = join(root, 'mcp-home'); +const packageDir = join(mcpHome, '0.3.13/node_modules/@commonlyai/mcp'); +await mkdir(workspace); +await mkdir(packageDir, { recursive: true }); +await cp(join(repo, 'commonly-mcp'), packageDir, { recursive: true }); +await writeFile(join(mcpHome, 'current'), '0.3.13\n'); +await writeFile(join(mcpHome, '.warm.lock'), 'probe'); +process.env.COMMONLY_MCP_HOME = mcpHome; +const binary = join(workspace, 'opencode'); +await cp(join(repo, 'scripts/fixtures/opencode-mcp-probe.mjs'), binary); +await chmod(binary, 0o755); +const keyFile = join(root, 'key'); +await writeFile(keyFile, 'fake-provider-key', { mode: 0o600 }); + +let apiHits = 0; +let providerHits = 0; +const api = createServer((req, res) => { + apiHits += 1; + assert.equal(req.headers.authorization, 'Bearer cm_agent_mcp_probe'); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true, path: req.url })); +}); +const provider = createServer((req, res) => { + providerHits += 1; + assert.equal(req.headers.authorization, 'Bearer fake-provider-key'); + res.setHeader('content-type', 'application/json'); + res.end('{"data":[]}'); +}); +const listen = (server) => new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); +}); +const close = (server) => new Promise((resolve) => server.close(resolve)); +const apiUrl = `http://127.0.0.1:${await listen(api)}`; +const providerUrl = `http://127.0.0.1:${await listen(provider)}/v1`; +try { + const result = await opencode.spawn('MCP egress probe', { + cwd: workspace, + agentName: 'mcp-probe', + runtimeToken: 'cm_agent_mcp_probe', + instanceUrl: apiUrl, + env: { PATH: process.env.PATH }, + timeoutMs: 20_000, + _binaryPath: binary, + _opencodeHomeRoot: join(root, 'state'), + environment: { + model: 'test', + provider: { id: 'local', baseURL: providerUrl, keyFile }, + sandbox: { trust: 'public', mode: 'bwrap' }, + mcp: [commonlyMcpServer()], + }, + }); + const observed = JSON.parse(result.text); + assert.equal(observed.provider, 200); + assert.ok(observed.mcpTools > 0); + assert.equal(observed.apiUrl, 'http://127.0.0.1:8766'); + assert.equal(observed.context.isError, undefined); + assert.equal(observed.post.isError, undefined); + assert.equal(apiHits, 2); + assert.equal(providerHits, 1); + process.stdout.write(`OpenCode bwrap provider + Commonly MCP: ${observed.mcpTools} tools, ${apiHits} API calls\n`); +} finally { + api.closeAllConnections(); + provider.closeAllConnections(); + await Promise.all([close(api), close(provider)]); + await rm(root, { recursive: true, force: true }); +}