@commonlyai/cli 0.1.182 → 0.1.183

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { Transform } from 'node:stream';
2
+ import { StringDecoder } from 'node:string_decoder';
2
3
  /** The per-spawn loopback proxy shared by OpenAI-compatible CLI adapters. */
3
4
  import { randomBytes, timingSafeEqual } from 'crypto';
4
5
  import { constants as fsConstants, realpathSync } from 'fs';
@@ -40,6 +41,71 @@ export const redactProviderMaterial = (material) => {
40
41
  });
41
42
  };
42
43
 
44
+ const MAX_PINNED_REQUEST_BYTES = 8 * 1024 * 1024;
45
+ const MAX_PINNED_RESPONSE_BYTES = 16 * 1024 * 1024;
46
+
47
+ /**
48
+ * Checks that every completion an upstream serves names the pinned model.
49
+ * SSE is checked line by line, and nothing is released until a line has named
50
+ * the pinned model; after that only checked lines are released, so a chunk
51
+ * naming another model never reaches the CLI. Any other body is held whole and
52
+ * released only after its `model` is checked. A body that names no model is
53
+ * unverified, which counts as a failure: the pin is only as good as the check.
54
+ */
55
+ const servedModelCheck = ({ pinned, stream, onServed }) => {
56
+ // A chunk boundary can fall inside a multi-byte character; the decoder
57
+ // carries the partial bytes into the next chunk instead of mangling both.
58
+ const decoder = new StringDecoder('utf8');
59
+ let pending = '';
60
+ let held = '';
61
+ let named = false;
62
+ let size = 0;
63
+ const check = (value) => {
64
+ if (typeof value !== 'string') return;
65
+ named = true;
66
+ onServed(value);
67
+ if (value !== pinned) throw new Error(`served model ${JSON.stringify(value)} is not the pinned ${JSON.stringify(pinned)}`);
68
+ };
69
+ const checkLine = (line) => {
70
+ const data = /^data:\s?(.*)$/.exec(line.replace(/\r$/, ''))?.[1];
71
+ if (data === undefined || data.trim() === '[DONE]' || !data.trim()) return;
72
+ let parsed;
73
+ try { parsed = JSON.parse(data); } catch { return; }
74
+ check(parsed?.model);
75
+ };
76
+ return new Transform({
77
+ transform(chunk, _encoding, done) {
78
+ size += chunk.length;
79
+ if (size > MAX_PINNED_RESPONSE_BYTES) { done(new Error('provider response exceeded the pinned-model check limit')); return; }
80
+ pending += decoder.write(chunk);
81
+ if (!stream) { done(); return; }
82
+ const end = pending.lastIndexOf('\n');
83
+ if (end < 0) { done(); return; }
84
+ const complete = pending.slice(0, end + 1);
85
+ pending = pending.slice(end + 1);
86
+ try { complete.split('\n').forEach(checkLine); } catch (error) { done(error); return; }
87
+ held += complete;
88
+ if (!named) { done(); return; }
89
+ const release = held;
90
+ held = '';
91
+ done(null, release);
92
+ },
93
+ flush(done) {
94
+ pending += decoder.end();
95
+ try {
96
+ if (stream) checkLine(pending);
97
+ else {
98
+ let parsed;
99
+ try { parsed = JSON.parse(pending); } catch { parsed = null; }
100
+ check(parsed?.model);
101
+ }
102
+ if (!named) throw new Error(`provider response named no model; the pinned ${JSON.stringify(pinned)} cannot be verified`);
103
+ } catch (error) { done(error); return; }
104
+ done(null, held + pending);
105
+ },
106
+ });
107
+ };
108
+
43
109
  const isPathWithin = (parent, candidate) => {
44
110
  const fromParent = relative(parent, candidate);
45
111
  return fromParent === '' || (fromParent !== '..'
@@ -103,7 +169,13 @@ const readProviderKey = async (keyFile, adapterLabel) => {
103
169
 
104
170
  export const startProviderProxy = async ({
105
171
  provider, keyFile, keyMaterial, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode',
172
+ // When set, every completion must request this model and be served by it.
173
+ pinnedModel,
106
174
  }) => {
175
+ if (pinnedModel !== undefined && (typeof pinnedModel !== 'string' || !pinnedModel.trim())) {
176
+ throw new Error(`${adapterLabel} pinnedModel must be a non-empty string`);
177
+ }
178
+ const modelState = { requested: new Set(), served: new Set(), refused: 0, failures: [] };
107
179
  const upstream = new URL(provider.baseURL);
108
180
  if (socketPath && (!isAbsolute(socketPath)
109
181
  || !Number.isInteger(isolatedPort) || isolatedPort < 1 || isolatedPort > 65535)) {
@@ -212,42 +284,98 @@ export const startProviderProxy = async ({
212
284
  headers.authorization = `Bearer ${upstreamKey}`;
213
285
  headers.host = target.host;
214
286
  headers['accept-encoding'] = 'identity';
215
- const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)({
216
- protocol: target.protocol,
217
- hostname: target.hostname,
218
- port: target.port,
219
- path: target.path,
220
- method: incoming.method,
221
- headers,
222
- }, (response) => {
223
- if (response.statusCode >= 300 && response.statusCode < 400) {
224
- response.resume();
225
- outgoing.writeHead(502).end('Provider redirects are not supported');
226
- return;
227
- }
228
- if (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity') {
229
- response.resume(); outgoing.writeHead(502).end('Compressed provider responses are not supported'); return;
287
+ const pinned = pinnedModel !== undefined && target.path.endsWith('/chat/completions');
288
+ const forward = (body) => {
289
+ let streamed = false;
290
+ if (body) {
291
+ headers['content-length'] = String(body.length);
292
+ try { streamed = JSON.parse(body.toString('utf8'))?.stream === true; } catch { /* checked before forward */ }
230
293
  }
231
- const responseHeaders = Object.fromEntries(Object.entries(response.headers)
232
- .filter(([name]) => ![
233
- 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
234
- 'te', 'trailer', 'transfer-encoding', 'upgrade',
235
- ].includes(name.toLowerCase())));
236
- delete responseHeaders['content-length'];
237
- for (const [name, value] of Object.entries(responseHeaders)) {
238
- if (typeof value === 'string') responseHeaders[name] = value.split(upstreamKey).join('[redacted]');
239
- else if (Array.isArray(value)) responseHeaders[name] = value.map((entry) => entry.split(upstreamKey).join('[redacted]'));
240
- }
241
- outgoing.writeHead(response.statusCode || 502, responseHeaders);
242
- response.pipe(redactProviderMaterial(upstreamKey)).pipe(outgoing);
294
+ const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)({
295
+ protocol: target.protocol,
296
+ hostname: target.hostname,
297
+ port: target.port,
298
+ path: target.path,
299
+ method: incoming.method,
300
+ headers,
301
+ }, (response) => {
302
+ if (response.statusCode >= 300 && response.statusCode < 400) {
303
+ response.resume();
304
+ outgoing.writeHead(502).end('Provider redirects are not supported');
305
+ return;
306
+ }
307
+ if (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity') {
308
+ response.resume(); outgoing.writeHead(502).end('Compressed provider responses are not supported'); return;
309
+ }
310
+ const responseHeaders = Object.fromEntries(Object.entries(response.headers)
311
+ .filter(([name]) => ![
312
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
313
+ 'te', 'trailer', 'transfer-encoding', 'upgrade',
314
+ ].includes(name.toLowerCase())));
315
+ delete responseHeaders['content-length'];
316
+ for (const [name, value] of Object.entries(responseHeaders)) {
317
+ if (typeof value === 'string') responseHeaders[name] = value.split(upstreamKey).join('[redacted]');
318
+ else if (Array.isArray(value)) responseHeaders[name] = value.map((entry) => entry.split(upstreamKey).join('[redacted]'));
319
+ }
320
+ const ok = response.statusCode >= 200 && response.statusCode < 300;
321
+ if (!pinned || !ok) {
322
+ outgoing.writeHead(response.statusCode || 502, responseHeaders);
323
+ response.pipe(redactProviderMaterial(upstreamKey)).pipe(outgoing);
324
+ return;
325
+ }
326
+ const sse = streamed || /text\/event-stream/i.test(String(response.headers['content-type'] || ''));
327
+ const check = servedModelCheck({
328
+ pinned: pinnedModel, stream: sse, onServed: (served) => modelState.served.add(served),
329
+ });
330
+ const fail = (error) => {
331
+ modelState.failures.push(error.message);
332
+ process.stderr.write(`[${adapterLabel.toLowerCase()}] provider proxy: ${error.message}\n`);
333
+ response.destroy();
334
+ if (!outgoing.headersSent) outgoing.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }).end('Served model check failed');
335
+ else outgoing.destroy();
336
+ };
337
+ check.on('error', fail);
338
+ // A stream's headers go out at once; a whole body waits for its check.
339
+ if (sse) outgoing.writeHead(response.statusCode, responseHeaders);
340
+ else check.once('data', () => { if (!outgoing.headersSent) outgoing.writeHead(response.statusCode, responseHeaders); });
341
+ response.pipe(check).pipe(redactProviderMaterial(upstreamKey)).pipe(outgoing);
342
+ });
343
+ request.on('error', () => {
344
+ if (!outgoing.headersSent) outgoing.writeHead(502);
345
+ outgoing.end('Provider request failed');
346
+ });
347
+ outgoing.on('close', () => request.destroy());
348
+ incoming.on('aborted', () => request.destroy());
349
+ if (body) request.end(body);
350
+ else incoming.pipe(request);
351
+ };
352
+ if (!pinned) {
353
+ forward(null);
354
+ return;
355
+ }
356
+ // A pinned proxy reads the request first: a completion for any other model
357
+ // is refused here, before the provider bills it.
358
+ const chunks = [];
359
+ let size = 0;
360
+ incoming.on('data', (chunk) => {
361
+ size += chunk.length;
362
+ if (size <= MAX_PINNED_REQUEST_BYTES) chunks.push(chunk);
243
363
  });
244
- request.on('error', () => {
245
- if (!outgoing.headersSent) outgoing.writeHead(502);
246
- outgoing.end('Provider request failed');
364
+ incoming.on('end', () => {
365
+ if (size > MAX_PINNED_REQUEST_BYTES) { outgoing.writeHead(413).end(); return; }
366
+ const body = Buffer.concat(chunks);
367
+ let requested;
368
+ try { requested = JSON.parse(body.toString('utf8'))?.model; } catch { requested = undefined; }
369
+ if (requested !== pinnedModel) {
370
+ modelState.refused += 1;
371
+ process.stderr.write(`[${adapterLabel.toLowerCase()}] provider proxy refused a completion for an unpinned model\n`);
372
+ outgoing.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
373
+ outgoing.end(`This seat is pinned to ${pinnedModel}`);
374
+ return;
375
+ }
376
+ modelState.requested.add(requested);
377
+ forward(body);
247
378
  });
248
- outgoing.on('close', () => request.destroy());
249
- incoming.on('aborted', () => request.destroy());
250
- incoming.pipe(request);
251
379
  });
252
380
 
253
381
  await new Promise((resolve, reject) => {
@@ -274,6 +402,14 @@ export const startProviderProxy = async ({
274
402
  tokenFile,
275
403
  socketPath,
276
404
  port,
405
+ /** The pin's verdict so far: what was asked for, what was served, and what failed. */
406
+ modelCheck: () => ({
407
+ pinned: pinnedModel ?? null,
408
+ requested: [...modelState.requested],
409
+ served: [...modelState.served],
410
+ refused: modelState.refused,
411
+ failures: [...modelState.failures],
412
+ }),
277
413
  close: () => new Promise((resolve) => {
278
414
  server.close(async () => {
279
415
  if (socketPath) await unlink(socketPath).catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.182",
3
+ "version": "0.1.183",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -1,12 +1,28 @@
1
1
  import { observeSpawn } from '../runtime-observation.js';
2
- /** DeepSeek Harness 0.2.1-rc.2, private seats only. Configuration is per spawn. */
2
+ /**
3
+ * DeepSeek Harness 0.2.1-rc.2, private seats only. Configuration is per spawn.
4
+ *
5
+ * A seat runs plain, or confined: `sandbox: { mode: 'workspace', network:
6
+ * { policy: 'restricted' } }` runs dsh inside one Seatbelt profile that writes
7
+ * only the workspace and the seat's home, reaches only three loopback ports
8
+ * (the provider proxy, the Commonly instance relay and the GitHub egress
9
+ * proxy), and cannot reach the Security services the keychain sits behind.
10
+ * The provider proxy is pinned to the seat's model and checks the model each
11
+ * response names. dsh's own sandbox is turned off inside it, because a second
12
+ * Seatbelt cannot be applied under the first.
13
+ */
14
+ import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process-group';
3
15
  import { requireSeatModel } from './seat-model.js';
4
16
  import { spawn as childSpawn, spawnSync } from 'child_process';
5
17
  import { createHash } from 'crypto';
6
- import { existsSync } from 'fs';
18
+ import { existsSync, realpathSync } from 'fs';
7
19
  import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises';
8
20
  import { homedir, tmpdir } from 'os';
9
- import { basename, join } from 'path';
21
+ import { basename, delimiter, isAbsolute, join, sep } from 'path';
22
+ import { startEgressProxy } from '../egress-proxy.js';
23
+ import { startInstanceProxy } from '../instance-proxy.js';
24
+ import { prepareMcpSpawn, selectedMcpVersionDir } from '../mcp-home.js';
25
+ import { detectSeatbelt, wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js';
10
26
  import { buildMemoryPreamble } from '../memory-bridge.js';
11
27
  import { commonlyMcpServer } from '../default-environment.js';
12
28
  import { effectiveSandboxTrust, refuseWorkspaceTrust, validateEnvironmentSpec } from '../environment.js';
@@ -21,15 +37,56 @@ const PROXY_KEY_ENV = 'COMMONLY_DSH_PROXY_KEY';
21
37
  const MAX_TIMEOUT_MS = 15 * 60 * 1000;
22
38
  const SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
23
39
 
24
- export const assertPrivateDsh = (environment = {}) => {
40
+ /** True for the one confined shape this adapter enforces; throws on any other sandbox. */
41
+ export const isConfinedDsh = (environment = {}) => {
25
42
  const sandbox = environment?.sandbox || {};
26
43
  refuseWorkspaceTrust(sandbox, 'dsh');
27
44
  if (effectiveSandboxTrust(sandbox.trust) === 'public') {
28
45
  throw new Error('dsh adapter: public-trust seats are not supported; nested Seatbelt fails closed');
29
46
  }
30
- if (sandbox.mode !== undefined) {
31
- throw new Error(`dsh adapter: sandbox.mode=${sandbox.mode} cannot be enforced by this adapter`);
47
+ if (sandbox.mode === undefined) return false;
48
+ const { mode, network, ...rest } = sandbox;
49
+ const networkKeys = Object.keys(network || {});
50
+ if (mode === 'workspace' && network?.policy === 'restricted'
51
+ && networkKeys.length === 1 && Object.keys(rest).length === 0) return true;
52
+ throw new Error(`dsh adapter: sandbox.mode=${mode} cannot be enforced by this adapter; `
53
+ + "the one confined shape is { mode: 'workspace', network: { policy: 'restricted' } }");
54
+ };
55
+
56
+ export const assertPrivateDsh = (environment = {}) => { isConfinedDsh(environment); };
57
+
58
+ const onPath = (name, env) => {
59
+ if (isAbsolute(name)) return existsSync(name) ? name : null;
60
+ for (const dir of String(env?.PATH || '').split(delimiter)) {
61
+ if (dir && existsSync(join(dir, name))) return join(dir, name);
32
62
  }
63
+ return null;
64
+ };
65
+
66
+ /**
67
+ * The script a dsh executable runs, and the outermost node_modules holding it:
68
+ * a confined run starts that script with this node, and reads that tree only.
69
+ */
70
+ export const resolveDshEntry = (binary, env) => {
71
+ const found = onPath(binary, env);
72
+ if (!found) throw new Error(`dsh adapter: a confined seat could not find ${binary} on PATH`);
73
+ const entry = realpathSync(found);
74
+ const parts = entry.split(sep);
75
+ const root = parts.indexOf('node_modules');
76
+ if (root < 1 || !/\.(?:c|m)?js$/.test(entry)) {
77
+ throw new Error(`dsh adapter: a confined seat runs dsh from its node package; ${found} resolves to ${entry}`);
78
+ }
79
+ return { entry, packages: parts.slice(0, root + 1).join(sep) };
80
+ };
81
+
82
+ /** Fail a confined turn whose provider traffic broke the model pin. */
83
+ export const assertServedModel = (check, credentials) => {
84
+ if (!check.refused && !check.failures.length) return;
85
+ const parts = [
86
+ ...(check.refused ? [`${check.refused} request(s) for a model other than ${check.pinned} refused`] : []),
87
+ ...check.failures,
88
+ ];
89
+ throw adapterFailure('dsh', `served model check failed: ${parts.join('; ')}`, { credentials, limit: 500 });
33
90
  };
34
91
 
35
92
  /** A stable home per agent and Commonly instance, never the operator's ~/.dsh. */
@@ -71,9 +128,17 @@ export const resolveCommonlyMcp = (declared, ctx) => {
71
128
  if (servers.length !== 1 || servers[0]?.name !== 'commonly') {
72
129
  throw new Error('dsh adapter: PR 1 supports only the Commonly MCP server');
73
130
  }
74
- const server = servers[0];
75
- if ((server.transport || 'stdio') !== 'stdio'
76
- || !isCommonlyCommand(server.command) || !describeMcpCommand(server.command)?.isCommonly) {
131
+ const isCommonly = (entry) => (entry.transport || 'stdio') === 'stdio'
132
+ && isCommonlyCommand(entry.command) && !!describeMcpCommand(entry.command)?.isCommonly;
133
+ if (!isCommonly(servers[0])) {
134
+ throw new Error('dsh adapter: Commonly MCP must be a recognized stdio @commonlyai/mcp command');
135
+ }
136
+ // A confined seat runs the warmed build it can read, never a package runner,
137
+ // and that build is held to the same check as the declaration.
138
+ const server = ctx.prepareCommand
139
+ ? { ...servers[0], command: ctx.prepareCommand(servers[0].command) }
140
+ : servers[0];
141
+ if (!isCommonly(server)) {
77
142
  throw new Error('dsh adapter: Commonly MCP must be a recognized stdio @commonlyai/mcp command');
78
143
  }
79
144
  const delivery = deliverSeatCredential(server, {
@@ -101,7 +166,9 @@ export const resolveCommonlyMcp = (declared, ctx) => {
101
166
  };
102
167
 
103
168
  /** JSON is valid YAML; a generated patch has no expression or quoting ambiguity. */
104
- export const buildPatch = ({ proxyBaseURL, model, cwd, mcp, effort, deepseekOfficial = false }) => ([
169
+ export const buildPatch = ({
170
+ proxyBaseURL, model, cwd, mcp, effort, deepseekOfficial = false, confined = false,
171
+ }) => ([
105
172
  { id: 'llm-pi-ai', config: { providers: {
106
173
  commonly: {
107
174
  apiKeyEnv: PROXY_KEY_ENV, api: 'openai-completions', baseURL: proxyBaseURL,
@@ -124,7 +191,9 @@ export const buildPatch = ({ proxyBaseURL, model, cwd, mcp, effort, deepseekOffi
124
191
  { id: 'llm-deepseek', disabled: true },
125
192
  { id: 'llm-deepseek-account', disabled: true },
126
193
  { id: 'session-log-deepseek', config: { enabled: false } },
127
- { id: 'sandbox-policy', config: { mode: 'workspace-write', workspaceRoot: cwd } },
194
+ // Confined, the outer Seatbelt is the sandbox: dsh's own would nest a second
195
+ // one, which macOS refuses, so it is off.
196
+ { id: 'sandbox-policy', config: { mode: confined ? 'danger-full-access' : 'workspace-write', workspaceRoot: cwd } },
128
197
  { insert: [mcp] },
129
198
  ]);
130
199
 
@@ -169,8 +238,10 @@ const parseEvent = (line, state) => {
169
238
  }
170
239
  };
171
240
 
172
- export const runDsh = ({ binary, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn, onSpawn, controls = {} }) => new Promise((resolve, reject) => {
173
- const proc = spawnImpl(binary, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
241
+ export const runDsh = ({
242
+ binary, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
243
+ }) => new Promise((resolve, reject) => {
244
+ const proc = spawnInOwnGroup(spawnImpl, binary, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup });
174
245
  observeSpawn(proc, onSpawn, controls);
175
246
  const state = { sessionId: null, final: null, error: null, buffer: '' };
176
247
  let stderr = '';
@@ -252,7 +323,14 @@ export default {
252
323
 
253
324
  async spawn(prompt, ctx = {}) {
254
325
  const seatModel = requireSeatModel(ctx.environment, 'dsh', true);
255
- assertPrivateDsh(ctx.environment);
326
+ const confined = isConfinedDsh(ctx.environment);
327
+ if (confined) {
328
+ if ((ctx._platform || process.platform) !== 'darwin') {
329
+ throw new Error('dsh adapter: a confined seat needs Seatbelt, which is macOS only');
330
+ }
331
+ const seatbelt = (ctx._detectSeatbelt || detectSeatbelt)();
332
+ if (!seatbelt.available) throw new Error(`dsh adapter: a confined seat needs Seatbelt: ${seatbelt.error}`);
333
+ }
256
334
  if (!ctx.environment?.provider || !ctx.environment?.model) {
257
335
  throw new Error('dsh requires environment.provider and environment.model');
258
336
  }
@@ -270,19 +348,39 @@ export default {
270
348
  const tempDir = await mkdtemp(join(tmpdir(), 'commonly-dsh-'));
271
349
  await chmod(tempDir, 0o700);
272
350
  let proxy;
351
+ let relay;
352
+ let egress;
273
353
  try {
274
354
  const credential = writeCredentialFile(ctx.runtimeToken, { agentName: 'commonly', root: tempDir });
275
355
  proxy = await startProviderProxy({
276
356
  provider: ctx.environment.provider, keyFile,
277
357
  tokenFile: join(tempDir, 'provider-proxy-token'), adapterLabel: 'dsh',
358
+ ...(confined ? { pinnedModel: seatModel } : {}),
278
359
  });
279
360
  const bearer = (await readFile(proxy.tokenFile, 'utf8')).trim();
361
+ const mcpDirectories = [];
362
+ if (confined) {
363
+ relay = await startInstanceProxy({ instanceUrl: ctx.instanceUrl });
364
+ egress = await (ctx._startEgressProxy || startEgressProxy)();
365
+ }
280
366
  const mcp = resolveCommonlyMcp(ctx.environment?.mcp, {
281
- credentialFile: credential.path, instanceUrl: ctx.instanceUrl,
367
+ credentialFile: credential.path,
368
+ instanceUrl: confined ? relay.url : ctx.instanceUrl,
369
+ ...(confined ? {
370
+ prepareCommand: (command) => {
371
+ const warmed = (ctx._prepareMcpSpawn || prepareMcpSpawn)(command, { apiUrl: ctx.instanceUrl, publicSeat: true });
372
+ const directory = warmed && (ctx._selectedMcpVersionDir || selectedMcpVersionDir)(warmed);
373
+ if (!directory) {
374
+ throw new Error('dsh adapter: a confined seat runs only a warmed Commonly MCP build, and none is ready on this host');
375
+ }
376
+ mcpDirectories.push(directory);
377
+ return [process.execPath, warmed[1]];
378
+ },
379
+ } : {}),
282
380
  });
283
381
  const patchData = buildPatch({
284
382
  proxyBaseURL: proxy.baseURL, model: seatModel,
285
- cwd: ctx.cwd, mcp, effort: ctx.environment.effort,
383
+ cwd: ctx.cwd, mcp, effort: ctx.environment.effort, confined,
286
384
  deepseekOfficial: new URL(ctx.environment.provider.baseURL).hostname === 'api.deepseek.com',
287
385
  });
288
386
  const patch = JSON.stringify(patchData, null, 2);
@@ -300,11 +398,51 @@ export default {
300
398
  const requested = Number(ctx.timeoutMs);
301
399
  const timeoutMs = Number.isFinite(requested) && requested > 0
302
400
  ? Math.min(requested, MAX_TIMEOUT_MS) : MAX_TIMEOUT_MS;
303
- return await runDsh({
304
- binary, args, cwd: ctx.cwd, env, timeoutMs,
305
- credentials: [...spawnCredentials(ctx, []), bearer], spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: { model: passedModel || null, effort: passedProvider.reasoning || null },
306
- });
401
+ let command = binary;
402
+ let commandArgs = args;
403
+ if (confined) {
404
+ const { entry, packages } = resolveDshEntry(binary, sourceEnv);
405
+ const tempWork = join(home, 'tmp');
406
+ await mkdir(tempWork, { recursive: true, mode: 0o700 });
407
+ env.TMPDIR = tempWork;
408
+ for (const name of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) env[name] = egress.url;
409
+ env.NO_PROXY = '127.0.0.1,localhost';
410
+ env.no_proxy = env.NO_PROXY;
411
+ [command, ...commandArgs] = (ctx._wrapArgvWithSeatbelt || wrapArgvWithSeatbelt)([process.execPath, entry, ...args], {
412
+ workspacePath: ctx.cwd,
413
+ workspaceAccess: 'write',
414
+ executablePath: process.execPath,
415
+ statePath: home,
416
+ mcpConfigDir: tempDir,
417
+ network: 'loopback',
418
+ loopbackNetworkPorts: [proxy.port, relay.port, egress.port],
419
+ readOnlyDirectories: [packages, ...mcpDirectories],
420
+ });
421
+ }
422
+ const credentials = [...spawnCredentials(ctx, []), bearer];
423
+ let result;
424
+ let failure;
425
+ try {
426
+ result = await runDsh({
427
+ binary: command, args: commandArgs, cwd: ctx.cwd, env, timeoutMs, credentials,
428
+ spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, ownGroup: wantsOwnProcessGroup(ctx),
429
+ controls: { model: passedModel || null, effort: passedProvider.reasoning || null },
430
+ });
431
+ } catch (error) {
432
+ failure = error;
433
+ }
434
+ // A broken pin is the reason a turn failed, whatever dsh said about it.
435
+ if (confined) {
436
+ const { refusals } = egress.stats();
437
+ // eslint-disable-next-line no-console
438
+ if (refusals.length) console.warn(`[dsh] egress refused: ${refusals.map((r) => `${r.target} (${r.reason})`).join(', ')}`);
439
+ assertServedModel(proxy.modelCheck(), credentials);
440
+ }
441
+ if (failure) throw failure;
442
+ return result;
307
443
  } finally {
444
+ try { await egress?.close(); } catch { /* best effort */ }
445
+ try { await relay?.close(); } catch { /* best effort */ }
308
446
  try { await proxy?.close(); } catch { /* best effort */ }
309
447
  await rm(tempDir, { recursive: true, force: true });
310
448
  }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * A per-spawn loopback CONNECT proxy that reaches only a fixed list of hosts.
3
+ *
4
+ * A confined run gets no outbound network of its own (Seatbelt `network:
5
+ * 'loopback'`); HTTPS_PROXY points it here. Only CONNECT to an allowlisted
6
+ * name on an allowlisted port is opened, and the name is resolved here and
7
+ * the connection made to the address resolved, so a name that resolves to
8
+ * loopback or a private network is refused rather than followed. Plain HTTP
9
+ * requests are refused: the proxy never sees the content of what it carries.
10
+ *
11
+ * There is no proxy credential. Everything the proxy reaches, any process on
12
+ * the host already reaches; it gives the confined run a route, not authority.
13
+ */
14
+ import { createServer } from 'http';
15
+ import { BlockList, connect, isIP } from 'net';
16
+ import { lookup } from 'dns/promises';
17
+
18
+ // GitHub's download hosts only. github.com and api.github.com accept writes
19
+ // (git push, gists, issues) made with any token a run is handed, and a tunnel
20
+ // cannot tell a read from a write, so they would be an outbound route for
21
+ // anything the run can read. These hosts only serve content.
22
+ export const GITHUB_CONTENT_HOSTS = Object.freeze([
23
+ 'codeload.github.com',
24
+ 'raw.githubusercontent.com',
25
+ 'objects.githubusercontent.com',
26
+ ]);
27
+
28
+ const CONNECT_TIMEOUT_MS = 10_000;
29
+ const MAX_REFUSALS_KEPT = 20;
30
+
31
+ const nonPublic = new BlockList();
32
+ for (const [address, prefix] of [
33
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
34
+ ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16],
35
+ ['198.18.0.0', 15], ['224.0.0.0', 4], ['240.0.0.0', 4],
36
+ ]) nonPublic.addSubnet(address, prefix, 'ipv4');
37
+ for (const [address, prefix] of [
38
+ ['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8], ['64:ff9b::', 96],
39
+ ]) nonPublic.addSubnet(address, prefix, 'ipv6');
40
+
41
+ /** True for an address no public host name should resolve to. */
42
+ export const isNonPublicAddress = (address) => {
43
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
44
+ if (mapped) return nonPublic.check(mapped[1], 'ipv4');
45
+ const family = isIP(address);
46
+ if (!family) return true;
47
+ return nonPublic.check(address, family === 6 ? 'ipv6' : 'ipv4');
48
+ };
49
+
50
+ /** `host:port` from a CONNECT target, or null when it is not one. */
51
+ const parseTarget = (target) => {
52
+ const match = /^([A-Za-z0-9.-]+):(\d{1,5})$/.exec(target || '');
53
+ if (!match) return null;
54
+ const port = Number(match[2]);
55
+ if (port < 1 || port > 65535) return null;
56
+ return { host: match[1].toLowerCase().replace(/\.$/, ''), port };
57
+ };
58
+
59
+ /**
60
+ * startEgressProxy({ allowHosts?, allowPorts? }) → { port, url, refusals, close }
61
+ *
62
+ * Listens on 127.0.0.1 at an ephemeral port. `_resolve` and `_allowPrivate`
63
+ * exist for tests only, so a test can reach a loopback upstream.
64
+ */
65
+ export const startEgressProxy = async ({
66
+ allowHosts = GITHUB_CONTENT_HOSTS,
67
+ allowPorts = [443],
68
+ _resolve = (host) => lookup(host, { all: true, verbatim: true }),
69
+ _allowPrivate = false,
70
+ } = {}) => {
71
+ const hosts = new Set(allowHosts.map((host) => String(host).toLowerCase()));
72
+ const ports = new Set(allowPorts);
73
+ if (!hosts.size || [...ports].some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
74
+ throw new Error('Egress proxy needs at least one host and valid TCP ports');
75
+ }
76
+ const refusals = [];
77
+ let opened = 0;
78
+ const refuse = (socket, status, target, reason) => {
79
+ refusals.push({ target, reason });
80
+ if (refusals.length > MAX_REFUSALS_KEPT) refusals.shift();
81
+ if (!socket.destroyed) socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
82
+ };
83
+ const sockets = new Set();
84
+ const track = (socket) => {
85
+ sockets.add(socket);
86
+ socket.on('close', () => sockets.delete(socket));
87
+ };
88
+
89
+ const server = createServer((incoming, outgoing) => {
90
+ incoming.resume();
91
+ refusals.push({ target: String(incoming.url || '').slice(0, 200), reason: 'not CONNECT' });
92
+ if (refusals.length > MAX_REFUSALS_KEPT) refusals.shift();
93
+ outgoing.writeHead(405, { Connection: 'close' }).end();
94
+ });
95
+ server.on('connection', track);
96
+ server.on('connect', async (request, client, head) => {
97
+ client.on('error', () => {});
98
+ const target = parseTarget(request.url);
99
+ const label = String(request.url || '').slice(0, 200);
100
+ if (!target) { refuse(client, '400 Bad Request', label, 'malformed target'); return; }
101
+ if (isIP(target.host)) { refuse(client, '403 Forbidden', label, 'address literal'); return; }
102
+ if (!hosts.has(target.host) || !ports.has(target.port)) {
103
+ refuse(client, '403 Forbidden', label, 'not allowlisted');
104
+ return;
105
+ }
106
+ let addresses;
107
+ try {
108
+ addresses = (await _resolve(target.host)).map((entry) => entry.address);
109
+ } catch {
110
+ refuse(client, '502 Bad Gateway', label, 'did not resolve');
111
+ return;
112
+ }
113
+ // Every address must be public, not just the one used: a name that
114
+ // resolves to both is not one this proxy was asked to reach.
115
+ if (!addresses.length || (!_allowPrivate && addresses.some(isNonPublicAddress))) {
116
+ refuse(client, '403 Forbidden', label, 'resolves to a non-public address');
117
+ return;
118
+ }
119
+ if (client.destroyed) return;
120
+ const upstream = connect({ host: addresses[0], port: target.port });
121
+ track(upstream);
122
+ let established = false;
123
+ client.on('close', () => upstream.destroy());
124
+ upstream.setTimeout(CONNECT_TIMEOUT_MS, () => upstream.destroy());
125
+ upstream.on('error', () => {});
126
+ upstream.on('close', () => {
127
+ if (established) client.destroy();
128
+ else refuse(client, '502 Bad Gateway', label, 'upstream unreachable');
129
+ });
130
+ upstream.once('connect', () => {
131
+ upstream.setTimeout(0);
132
+ established = true;
133
+ opened += 1;
134
+ client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
135
+ if (head?.length) upstream.write(head);
136
+ upstream.pipe(client);
137
+ client.pipe(upstream);
138
+ });
139
+ });
140
+
141
+ await new Promise((resolve, reject) => {
142
+ server.once('error', reject);
143
+ server.listen(0, '127.0.0.1', () => {
144
+ server.removeListener('error', reject);
145
+ resolve();
146
+ });
147
+ });
148
+ const { port } = server.address();
149
+ return {
150
+ port,
151
+ url: `http://127.0.0.1:${port}`,
152
+ /** { opened, refusals: [{ target, reason }] } — the last 20 refusals. */
153
+ stats: () => ({ opened, refusals: refusals.map((entry) => ({ ...entry })) }),
154
+ close: async () => {
155
+ const closed = new Promise((resolve) => server.close(resolve));
156
+ for (const socket of sockets) socket.destroy();
157
+ await closed;
158
+ },
159
+ };
160
+ };
@@ -1,4 +1,8 @@
1
- /** A per-spawn Unix-socket relay to one declared Commonly instance origin. */
1
+ /**
2
+ * A per-spawn relay to one declared Commonly instance origin, on a Unix
3
+ * socket, or on an ephemeral loopback TCP port when no socketPath is given
4
+ * (for a Seatbelt run whose only network is loopback).
5
+ */
2
6
  import { createServer, request as httpRequest } from 'http';
3
7
  import { request as httpsRequest } from 'https';
4
8
  import { chmod, unlink } from 'fs/promises';
@@ -48,20 +52,25 @@ export const startInstanceProxy = async ({ instanceUrl, socketPath }) => {
48
52
  });
49
53
  await new Promise((resolve, reject) => {
50
54
  server.once('error', reject);
51
- server.listen(socketPath, () => {
55
+ const listening = () => {
52
56
  server.removeListener('error', reject);
53
57
  resolve();
54
- });
58
+ };
59
+ if (socketPath) server.listen(socketPath, listening);
60
+ else server.listen(0, '127.0.0.1', listening);
55
61
  });
56
- await chmod(socketPath, 0o600);
62
+ if (socketPath) await chmod(socketPath, 0o600);
63
+ const port = socketPath ? null : server.address().port;
57
64
  return {
58
- socketPath,
65
+ socketPath: socketPath || null,
66
+ port,
67
+ url: port ? `http://127.0.0.1:${port}` : null,
59
68
  close: async () => {
60
69
  const closed = new Promise((resolve) => server.close(resolve));
61
70
  for (const upstream of upstreams) upstream.destroy();
62
71
  server.closeAllConnections();
63
72
  await closed;
64
- await unlink(socketPath).catch((err) => { if (err.code !== 'ENOENT') throw err; });
73
+ if (socketPath) await unlink(socketPath).catch((err) => { if (err.code !== 'ENOENT') throw err; });
65
74
  },
66
75
  };
67
76
  };
@@ -274,7 +274,12 @@ const PLATFORM_BASELINE = String.raw`
274
274
  ; Apple's App Sandbox grants every app this same domain.
275
275
  (allow user-preference-read
276
276
  (preference-domain "kCFPreferencesAnyApplication"))
277
+ `;
277
278
 
279
+ // Open network, the tail of the default baseline. A `network: 'loopback'`
280
+ // profile leaves it out: no outbound except the loopback ports it lists, and
281
+ // none of the Security framework services the keychain is reached through.
282
+ const OPEN_NETWORK = String.raw`
278
283
  ; The selected provider and declared MCP transports need outbound network. The model's
279
284
  ; WebSearch/WebFetch/Bash tools are denied independently in the adapter argv.
280
285
  (allow network-outbound)
@@ -367,6 +372,8 @@ export const buildSeatbeltProfile = ({
367
372
  readOnlyDirectories = [],
368
373
  loopbackNetworkPorts = [],
369
374
  allowClaudeRuntimeAccess = false,
375
+ // 'open' (the default) or 'loopback': only the listed loopback ports.
376
+ network = 'open',
370
377
  // Names directly inside a writable workspace that stay read-only. A
371
378
  // workspace seat granted write keeps its project's .git read-only, so every
372
379
  // change it makes is one `git status` shows the person (TASK-294).
@@ -387,6 +394,9 @@ export const buildSeatbeltProfile = ({
387
394
  throw new Error('Seatbelt loopbackNetworkPorts entries must be valid TCP ports');
388
395
  }
389
396
  }
397
+ if (!['open', 'loopback'].includes(network)) {
398
+ throw new Error('Seatbelt network must be open or loopback');
399
+ }
390
400
  if (!['read', 'write'].includes(workspaceAccess)) {
391
401
  throw new Error('Seatbelt workspaceAccess must be read or write');
392
402
  }
@@ -456,7 +466,7 @@ export const buildSeatbeltProfile = ({
456
466
  })() : [];
457
467
 
458
468
  return [
459
- PLATFORM_BASELINE.trim(),
469
+ (network === 'open' ? `${PLATFORM_BASELINE}${OPEN_NETWORK}` : PLATFORM_BASELINE).trim(),
460
470
  executableRules,
461
471
  executableSymlinkRules,
462
472
  `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(resolvedWorkspace)}"))`,