@commonlyai/cli 0.1.48 → 0.1.51

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.48",
3
+ "version": "0.1.51",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -40,6 +40,7 @@ import {
40
40
  readPodFocus,
41
41
  } from '../lib/pod-focus.js';
42
42
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
43
+ import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
43
44
  import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
44
45
  import {
45
46
  DEFAULT_HOOK_TIMEOUT_MS,
@@ -302,8 +303,9 @@ export const buildDefaultEnvironment = (adapterName) => {
302
303
  * enforced sandbox.
303
304
  *
304
305
  * The public-agent sandbox is real and attack-tested, but it only engages once
305
- * `sandbox.trust` and `sandbox.mode` are declared: `sandbox.mode` defaults to
306
- * `'none'`, and nothing previously connected "this pod is public" to "this
306
+ * an enforced sandbox is declared: `sandbox.trust: 'public'` (the adapters then
307
+ * resolve the mode for the host at spawn) or an explicit non-'none'
308
+ * `sandbox.mode`. Nothing previously connected "this pod is public" to "this
307
309
  * agent must be confined". An agent attached with no sandbox block simply ran
308
310
  * unconfined, silently, with the operator none the wiser.
309
311
  *
@@ -327,7 +329,12 @@ export const assertSandboxDeclaredForPublicPod = async ({
327
329
 
328
330
  const mode = environment?.sandbox?.mode;
329
331
  const trust = environment?.sandbox?.trust;
330
- const declared = Boolean(trust) && Boolean(mode) && mode !== 'none';
332
+ // An ENFORCED declaration is one the adapters act on: a public trust (whose
333
+ // mode they resolve per host at spawn) or an explicit non-'none' mode. This
334
+ // mirrors the daemon's predicate in lib/default-environment.js, so the shape
335
+ // the daemon writes for an unconfigured seat is one attach also accepts.
336
+ const declared = mode !== 'none'
337
+ && (trust === 'public' || typeof mode === 'string');
331
338
  if (declared) return;
332
339
 
333
340
  let pod = null;
@@ -350,10 +357,11 @@ export const assertSandboxDeclaredForPublicPod = async ({
350
357
  + 'people you do not control — but its environment declares no sandbox, and '
351
358
  + 'an undeclared sandbox means NO sandbox.\n\n'
352
359
  + 'Add a sandbox block to the environment file and retry:\n\n'
353
- + ' "sandbox": { "trust": "public", "mode": "read-only" }\n\n'
354
- + 'Modes for a public agent: "read-only" or "workspace" (macOS Seatbelt / '
355
- + 'Linux bwrap). To attach an agent to a private pod instead, pass that '
356
- + 'pod id.',
360
+ + ' "sandbox": { "trust": "public" }\n\n'
361
+ + 'That is the whole declaration: the adapters pick the enforced mode for '
362
+ + 'the host (Seatbelt on macOS, bwrap on Linux). Pin one explicitly with '
363
+ + '"mode": "read-only" or "workspace" if you want a specific access level. '
364
+ + 'To attach an agent to a private pod instead, pass that pod id.',
357
365
  );
358
366
  };
359
367
 
@@ -501,7 +509,10 @@ export const performAttach = async ({
501
509
  workspace = await resolveWorkspace(environment, agentName, dirname(envPath));
502
510
  log(`workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
503
511
 
504
- const sandboxMode = environment.sandbox?.mode || 'none';
512
+ const sandboxMode = environment.sandbox?.mode
513
+ || (environment.sandbox?.trust === 'public'
514
+ ? resolvePublicSandboxMode(environment.sandbox)
515
+ : 'none');
505
516
  const sandboxTrust = environment.sandbox?.trust;
506
517
  if (sandboxTrust === 'public' && sandboxMode === 'none') {
507
518
  throw new Error(
@@ -61,8 +61,13 @@ import {
61
61
  import { homedir, tmpdir } from 'os';
62
62
  import { delimiter, isAbsolute, join } from 'path';
63
63
 
64
- import { mountSkills } from '../environment.js';
65
- import { wrapArgvWithBwrap } from '../sandbox/bwrap.js';
64
+ import {
65
+ isLegacySandboxTrust,
66
+ mountSkills,
67
+ normalizeSandboxTrust,
68
+ } from '../environment.js';
69
+ import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
70
+ import { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode } from '../sandbox/mode.js';
66
71
  import {
67
72
  publicClaudeStateRoot,
68
73
  wrapArgvWithSeatbelt,
@@ -82,7 +87,6 @@ const DEFAULT_TIMEOUT_MS = (() => {
82
87
 
83
88
  const buildPrompt = buildMemoryPreamble;
84
89
 
85
- const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']);
86
90
  const PUBLIC_DENIED_TOOLS = [
87
91
  'WebSearch',
88
92
  'WebFetch',
@@ -410,12 +414,25 @@ const prepareArgv = async (innerArgv, ctx) => {
410
414
  .map((name) => `mcp__${name}__*`);
411
415
  }
412
416
 
413
- const sandboxMode = env.sandbox?.mode;
414
417
  const sandboxTrust = env.sandbox?.trust;
418
+ const sandboxMode = resolvePublicSandboxMode(env.sandbox);
415
419
  const publicNativeSandbox = sandboxTrust === 'public'
416
420
  && PUBLIC_SANDBOX_MODES.has(sandboxMode);
417
- if (sandboxTrust === 'public' && sandboxMode === 'none') {
418
- throw new Error('public Claude agents require an enforced sandbox mode');
421
+ // A public trust is ONE contract on both platforms: the jail bounds the
422
+ // filesystem and the policy bounds the tools. The bwrap branch below used to
423
+ // get the jail only — a derived Linux seat kept Bash/Write/WebFetch and ran on
424
+ // the operator's own Claude settings inside the namespace (Vera 69578).
425
+ const publicBwrapSandbox = sandboxTrust === 'public' && sandboxMode === 'bwrap';
426
+ // A public trust MUST resolve to an enforced mode. Absent mode is resolved
427
+ // above; anything else that lands here (the literal 'none', a typo, a
428
+ // non-string) used to fall through to the bare `claude` spawn below — an
429
+ // unconfined seat with a public record, which is how a record can claim
430
+ // confinement it never applies (Vera 69548).
431
+ if (sandboxTrust === 'public' && !publicNativeSandbox && sandboxMode !== 'bwrap') {
432
+ throw new Error(
433
+ 'public Claude agents require an enforced sandbox mode '
434
+ + `(workspace or read-only on macOS, bwrap elsewhere), got ${JSON.stringify(sandboxMode)}`,
435
+ );
419
436
  }
420
437
  if (publicNativeSandbox) {
421
438
  if (process.platform !== 'darwin') {
@@ -450,7 +467,20 @@ const prepareArgv = async (innerArgv, ctx) => {
450
467
  };
451
468
  }
452
469
 
453
- if (allowedPatterns.length > 0) {
470
+ if (publicBwrapSandbox) {
471
+ // The mode is chosen here, so the mechanism is verified here: a missing
472
+ // bwrap must fail the seat's derivation with an actionable message, not
473
+ // every one of its spawns with a bare ENOENT (Wren 69586). On a non-Linux
474
+ // host this reports the macOS-only message, same as the wrapper would.
475
+ const bwrap = (ctx._detectBwrap || detectBwrap)();
476
+ if (!bwrap.available) {
477
+ throw new Error(`public Claude agents require bwrap: ${bwrap.error}`);
478
+ }
479
+ innerArgv = [
480
+ ...innerArgv,
481
+ ...buildPublicClaudePolicyArgs(allowedPatterns),
482
+ ];
483
+ } else if (allowedPatterns.length > 0) {
454
484
  innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
455
485
  }
456
486
  if (sandboxMode === 'bwrap') {
@@ -461,7 +491,6 @@ const prepareArgv = async (innerArgv, ctx) => {
461
491
  });
462
492
  return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv };
463
493
  }
464
-
465
494
  return { cmd: 'claude', args: innerArgv, env: claudeEnv };
466
495
  };
467
496
 
@@ -499,7 +528,31 @@ export default {
499
528
  }
500
529
  },
501
530
 
502
- async spawn(prompt, ctx = {}) {
531
+ async spawn(prompt, rawCtx = {}) {
532
+ // `sandbox.trust: 'internal'` is refused for new declarations and read as
533
+ // `public` for a record that already carries it; such a seat is confined
534
+ // where it can be and refuses to derive where it cannot (Wren 69585).
535
+ // Resolved ONCE here and threaded to BOTH consumers — the public-state
536
+ // preparation below and the argv builder — because two independent reads of
537
+ // one declaration is exactly how this seat crashed with "public Claude
538
+ // state was not prepared" instead of spawning confined.
539
+ const ctx = rawCtx.environment
540
+ ? {
541
+ ...rawCtx,
542
+ environment: {
543
+ ...rawCtx.environment,
544
+ sandbox: normalizeSandboxTrust(rawCtx.environment.sandbox),
545
+ },
546
+ }
547
+ : rawCtx;
548
+ if (isLegacySandboxTrust(rawCtx.environment?.sandbox)) {
549
+ // eslint-disable-next-line no-console
550
+ console.warn(
551
+ '[claude] sandbox.trust=internal is no longer accepted: it reads as '
552
+ + 'confinement and engaged none. Resolving this seat as trust=public, so '
553
+ + 'it is confined or it refuses to derive — never unconfined (Wren 69585).',
554
+ );
555
+ }
503
556
  const isResume = !!ctx.sessionId;
504
557
  const sessionId = ctx.sessionId || randomUUID();
505
558
  // Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and
@@ -546,7 +599,7 @@ export default {
546
599
  let publicClaudeState = null;
547
600
  try {
548
601
  const publicNativeSandbox = ctx.environment?.sandbox?.trust === 'public'
549
- && PUBLIC_SANDBOX_MODES.has(ctx.environment?.sandbox?.mode);
602
+ && PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox));
550
603
  if (publicNativeSandbox) {
551
604
  publicClaudeState = await preparePublicClaudeState(ctx);
552
605
  }
@@ -57,6 +57,7 @@ import {
57
57
  resolve as pathResolve,
58
58
  } from 'path';
59
59
  import { buildMemoryPreamble } from '../memory-bridge.js';
60
+ import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js';
60
61
 
61
62
  // Default timeout for a single codex spawn (exec mode).
62
63
  //
@@ -439,8 +440,23 @@ export default {
439
440
  runtimeToken: ctx.runtimeToken,
440
441
  instanceUrl: ctx.instanceUrl,
441
442
  });
442
- const publicSandboxMode = ctx.environment?.sandbox?.trust === 'public'
443
- ? ctx.environment?.sandbox?.mode || 'unset'
443
+ // A derived record stores `trust: 'public'` and no mode (the block is
444
+ // platform-independent; see cli/src/lib/default-environment.js). Codex's
445
+ // mode only selects read vs write access — its permission profiles run on
446
+ // both macOS and Linux — so the derived default is `workspace`, and an
447
+ // explicit mode in the record still wins. Before this, a mode-less public
448
+ // record threw `got unset` and no codex seat spawned at all.
449
+ if (isLegacySandboxTrust(ctx.environment?.sandbox)) {
450
+ // eslint-disable-next-line no-console
451
+ console.warn(
452
+ '[codex] sandbox.trust=internal is no longer accepted: it reads as '
453
+ + 'confinement and engaged none. Resolving this seat as trust=public '
454
+ + '(Wren 69585).',
455
+ );
456
+ }
457
+ const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox);
458
+ const publicSandboxMode = sandbox?.trust === 'public'
459
+ ? sandbox?.mode ?? 'workspace'
444
460
  : null;
445
461
  const args = buildArgs({
446
462
  sessionId: ctx.sessionId || null,
@@ -12,10 +12,12 @@
12
12
  */
13
13
 
14
14
  import { Type } from 'typebox';
15
- import { connectMcp, readServers, toPiResult } from './pi-mcp-client.mjs';
15
+ import { connectMcp, takeServers, toPiResult } from './pi-mcp-client.mjs';
16
16
 
17
17
  export default async function commonlyMcpBridge(pi) {
18
- const servers = readServers(process.env.COMMONLY_PI_MCP);
18
+ // Read once and remove: the list carries the seat token, and pi's bash tool
19
+ // inherits this process's env (see takeServers).
20
+ const servers = takeServers(process.env);
19
21
  const clients = [];
20
22
  for (const server of servers) {
21
23
  const client = connectMcp(server);
@@ -78,6 +78,19 @@ export const toPiResult = (result) => {
78
78
  return { content: [{ type: 'text', text: result?.isError ? `error: ${text}` : text }], details: { isError: !!result?.isError } };
79
79
  };
80
80
 
81
+ /**
82
+ * Read the server list and REMOVE it from the environment. The list carries each
83
+ * server's substituted env — the seat's bearer token among it — and pi's `bash`
84
+ * tool spawns with `{ ...process.env }` (pi's getShellEnv), so leaving it in place
85
+ * lets one `env` from the model print the token. The MCP children already hold
86
+ * their own env from spawn time; nothing else reads this variable.
87
+ */
88
+ export const takeServers = (env = process.env) => {
89
+ const servers = readServers(env.COMMONLY_PI_MCP);
90
+ delete env.COMMONLY_PI_MCP;
91
+ return servers;
92
+ };
93
+
81
94
  export const readServers = (raw) => {
82
95
  if (!raw) return [];
83
96
  try { return JSON.parse(raw).filter((s) => s?.name && Array.isArray(s.command) && s.command.length); } catch { return []; }
@@ -145,6 +145,17 @@ export const sessionExists = async (sessionDir, sessionId) => {
145
145
  }
146
146
  };
147
147
 
148
+ /** Fail closed: pi cannot enforce `environment.sandbox`, so a seat that declares one does not start. */
149
+ export const assertNoSandboxDeclared = (environment = {}) => {
150
+ const sandbox = environment?.sandbox || {};
151
+ if (sandbox.trust === 'public') {
152
+ throw new Error('pi adapter: public-trust seats are not supported — pi has no enforced sandbox; do not attach a pi seat to a stranger-readable pod');
153
+ }
154
+ if (sandbox.mode && sandbox.mode !== 'none') {
155
+ throw new Error(`pi adapter: sandbox.mode=${sandbox.mode} cannot be enforced by pi; remove it or use the claude/codex adapter`);
156
+ }
157
+ };
158
+
148
159
  export const buildArgs = ({
149
160
  prompt, provider, model, thinking, sessionId, isResume, sessionDir, bridge,
150
161
  }) => [
@@ -214,6 +225,10 @@ export default {
214
225
  },
215
226
 
216
227
  async spawn(prompt, ctx = {}) {
228
+ // pi has no enforced sandbox. claude.js and codex.js refuse a public-trust
229
+ // seat they cannot confine; so does this adapter, and it refuses any declared
230
+ // sandbox mode rather than run unconfined under a spec that promised one.
231
+ assertNoSandboxDeclared(ctx.environment);
217
232
  const provider = resolveProvider(ctx.environment);
218
233
  const model = ctx.environment?.model || DEFAULT_MODEL;
219
234
  const thinking = thinkingFor(ctx.environment?.effort);
@@ -2,7 +2,7 @@ import { isDeepStrictEqual } from 'node:util';
2
2
  import { homedir } from 'node:os';
3
3
  import { isAbsolute, resolve as pathResolve } from 'node:path';
4
4
 
5
- import { withDefaultMcpServer } from './default-environment.js';
5
+ import { seatBaseline } from './default-environment.js';
6
6
 
7
7
  /**
8
8
  * ADR-026 Phase 2, slice 2: the resident supervision loop behind
@@ -190,7 +190,15 @@ export const createDaemonSupervisor = ({
190
190
  // declaration carries no mcp[] would come back tool-less. Re-apply the
191
191
  // shipped default here and below, or the seat silently loses every
192
192
  // commonly_* tool the next time the UI edits its model (TASK-048).
193
- const nextEnvironment = withDefaultMcpServer(merged, nextAdapter);
193
+ //
194
+ // A DECLARED environment also gets the sandbox default when it names
195
+ // none: the declaration is not the operator's, and `sandbox.mode`
196
+ // defaults to 'none' in the adapters, so an omitted block is an
197
+ // unconfined seat (TASK-052). A local record with no environment at
198
+ // all is in the same position — nobody has authored anything.
199
+ const nextEnvironment = seatBaseline(merged, nextAdapter, {
200
+ sandbox: wanted.declared || !existing.environment,
201
+ });
194
202
  const workspacePath = workspacePathFor(nextEnvironment);
195
203
  const nextRecord = {
196
204
  ...existing,
@@ -209,7 +217,9 @@ export const createDaemonSupervisor = ({
209
217
  if (adapterChanged) {
210
218
  // An adapter that consumes mcp[] must not be started on a record that
211
219
  // declares none, even when only the adapter itself changed.
212
- const nextEnvironment = withDefaultMcpServer(existing.environment, nextAdapter);
220
+ const nextEnvironment = seatBaseline(existing.environment, nextAdapter, {
221
+ sandbox: !existing.environment,
222
+ });
213
223
  saveToken(row.agentName, {
214
224
  ...existing,
215
225
  adapter: nextAdapter,
@@ -226,7 +236,9 @@ export const createDaemonSupervisor = ({
226
236
  // stays tool-less for as long as it runs. Heal it here, and only when
227
237
  // the environment actually changed — otherwise every tick rewrites the
228
238
  // file and restarts the seat forever.
229
- const nextEnvironment = withDefaultMcpServer(existing.environment, existing.adapter);
239
+ const nextEnvironment = seatBaseline(existing.environment, existing.adapter, {
240
+ sandbox: !existing.environment,
241
+ });
230
242
  if (!isDeepStrictEqual(existing.environment || null, nextEnvironment || null)) {
231
243
  saveToken(row.agentName, { ...existing, environment: nextEnvironment });
232
244
  log('record predates the commonly MCP baseline — restarting the seat to load it');
@@ -277,10 +289,13 @@ export const createDaemonSupervisor = ({
277
289
  const environment = environmentFor(row);
278
290
  // A seat installed server-side (no local `agent attach`) arrives with no
279
291
  // mcp[] at all: without the default it spawns a CLI that has no commonly_*
280
- // tools and cannot post. See lib/default-environment.js.
281
- const recordEnvironment = withDefaultMcpServer(
292
+ // tools and cannot post. See lib/default-environment.js. Nothing here is
293
+ // operator-authored, so this seat also gets the sandbox default — the
294
+ // self-serve install's seat used to be born unconfined (TASK-052).
295
+ const recordEnvironment = seatBaseline(
282
296
  environment ? environment.value : null,
283
297
  adapter,
298
+ { sandbox: true },
284
299
  );
285
300
  saveToken(row.agentName, {
286
301
  agentName: row.agentName,
@@ -24,6 +24,51 @@
24
24
 
25
25
  export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi']);
26
26
 
27
+ /**
28
+ * The adapters that can ENFORCE the default sandbox — a strict subset of the
29
+ * ones that consume `mcp[]`.
30
+ *
31
+ * `pi` is absent deliberately. It has no sandbox path until #1740's transport
32
+ * work gives it one, and since #1727 it REFUSES TO START on a spec that
33
+ * declares one: `assertNoSandboxDeclared` in adapters/pi.js throws on
34
+ * `trust: 'public'` and on any `mode` other than 'none'. So handing pi this
35
+ * block is not a harmless no-op — it is an unspawnable seat. A pi seat gets the
36
+ * `mcp[]` half only, and the residual is that such a seat runs unconfined: that
37
+ * belongs to the row that owns pi confinement, not to a declaration written
38
+ * here and hoped for. A derived pi seat used to be exactly this shape and would
39
+ * have failed every spawn with `public-trust seats are not supported`.
40
+ */
41
+ export const ADAPTERS_WITH_DEFAULT_SANDBOX = new Set(['claude', 'codex']);
42
+
43
+ /**
44
+ * The sandbox an unconfigured seat gets.
45
+ *
46
+ * `sandbox.mode` defaults to `'none'` in the adapters, so an ABSENT sandbox
47
+ * block means NO sandbox — the seat runs unconfined on the operator's machine
48
+ * while taking instructions from whoever is in the room. That is what a
49
+ * self-serve install shipped (C4-6 / TASK-052): the install declares no
50
+ * environment, the daemon projected nothing, and the socket was born with no
51
+ * confinement and no way to notice.
52
+ *
53
+ * `trust: 'public'` is the conservative declaration — "this seat takes
54
+ * instructions from people I do not control" — and it is the one that engages
55
+ * the real sandbox in the adapters.
56
+ *
57
+ * NO MODE is stored, deliberately. The record is platform-independent and the
58
+ * host is not: the adapters read a public trust with no mode as Seatbelt
59
+ * workspace on macOS and bwrap elsewhere. `mode: 'workspace'` in the row is
60
+ * refused on Linux, and `mode: 'bwrap'` is meaningless on macOS, so either one
61
+ * moves a host fact into the database and breaks the day the seat is re-homed.
62
+ * An explicit mode in a record still wins over the derived one.
63
+ *
64
+ * Confinement holds for claude and codex, and only they are ever handed this
65
+ * block (ADAPTERS_WITH_DEFAULT_SANDBOX). A pi seat must never be: it fails
66
+ * closed on a declared sandbox rather than run under a spec it cannot honour.
67
+ */
68
+ export const COMMONLY_DEFAULT_SANDBOX = Object.freeze({ trust: 'public' });
69
+
70
+ export const defaultSeatSandbox = () => ({ ...COMMONLY_DEFAULT_SANDBOX });
71
+
27
72
  export const COMMONLY_MCP_SERVER_NAME = 'commonly';
28
73
 
29
74
  export const commonlyMcpServer = () => ({
@@ -68,3 +113,41 @@ export const withDefaultMcpServer = (environment, adapterName) => {
68
113
  }
69
114
  return { ...(environment || {}), mcp: [...(declared || []), commonlyMcpServer()] };
70
115
  };
116
+
117
+ /**
118
+ * Ensure the environment declares an ENFORCED sandbox, touching nothing that
119
+ * is already enforced. Returns the SAME reference when nothing needs adding.
120
+ *
121
+ * Only for environments this daemon derived from the server's declaration or
122
+ * from nothing at all — never for a local record the operator authored. A
123
+ * declared `mode: 'none'` is replaced rather than honoured: absence and 'none'
124
+ * are the same thing to the adapter, and this is the path that decides what an
125
+ * undeclared sandbox means.
126
+ */
127
+ export const withDefaultSandbox = (environment) => {
128
+ if (environment !== null && environment !== undefined
129
+ && (typeof environment !== 'object' || Array.isArray(environment))) {
130
+ return environment;
131
+ }
132
+ const sandbox = environment?.sandbox;
133
+ // An ENFORCED declaration is one the adapters act on: a public trust (whose
134
+ // mode they resolve, and refuse to spawn without) or an explicit non-'none'
135
+ // mode. `mode: 'none'`, an empty block and a missing one are all the same
136
+ // thing to a spawn — no confinement — so all three are replaced here.
137
+ const enforced = sandbox !== null && typeof sandbox === 'object'
138
+ && sandbox.mode !== 'none'
139
+ && (sandbox.trust === 'public' || typeof sandbox.mode === 'string');
140
+ if (enforced) return environment;
141
+ return { ...(environment || {}), sandbox: defaultSeatSandbox() };
142
+ };
143
+
144
+ /**
145
+ * The full baseline a seat gets from the daemon: the kernel MCP server, plus an
146
+ * enforced sandbox when the environment is one the daemon derived rather than
147
+ * one the operator wrote (`sandbox: true` at the call sites).
148
+ */
149
+ export const seatBaseline = (environment, adapterName, { sandbox = false } = {}) => {
150
+ const withMcp = withDefaultMcpServer(environment, adapterName);
151
+ if (!sandbox || !ADAPTERS_WITH_DEFAULT_SANDBOX.has(adapterName)) return withMcp;
152
+ return withDefaultSandbox(withMcp);
153
+ };
@@ -46,9 +46,30 @@ const ALLOWED_TOP_KEYS = new Set([
46
46
  const ALLOWED_SANDBOX_MODES = new Set([
47
47
  'none', 'workspace', 'read-only', 'bwrap', 'firejail', 'container', 'managed',
48
48
  ]);
49
- const ALLOWED_SANDBOX_TRUST = new Set(['public', 'internal']);
49
+ const ALLOWED_SANDBOX_TRUST = new Set(['public']);
50
50
  const ALLOWED_NETWORK_POLICIES = new Set(['unrestricted', 'restricted']);
51
51
 
52
+ // `trust: 'internal'` was accepted by this schema and read by NO adapter. On the
53
+ // attach path it could only name a mode the platform cannot resolve; on the
54
+ // daemon path it was silently inert — claude fell through to a bare unconfined
55
+ // spawn and codex took `--dangerously-bypass-approvals-and-sandbox` — so a
56
+ // declaration that reads as "confine me, not as a public agent" meant the
57
+ // opposite of what it said (Vera 69592). It is refused for new declarations
58
+ // above, and a record that already carries it is resolved TOWARD confinement,
59
+ // never toward the bare spawn (Wren 69585): `internal` is read as `public`, so
60
+ // such a seat is confined where it can be and refuses to derive where it
61
+ // cannot. A real middle trust arrives as its own adapter that reads it.
62
+ export const LEGACY_SANDBOX_TRUST = Object.freeze({ internal: 'public' });
63
+ export const isLegacySandboxTrust = (sandbox) => (
64
+ typeof sandbox?.trust === 'string'
65
+ && Object.prototype.hasOwnProperty.call(LEGACY_SANDBOX_TRUST, sandbox.trust)
66
+ );
67
+ export const normalizeSandboxTrust = (sandbox) => (
68
+ isLegacySandboxTrust(sandbox)
69
+ ? { ...sandbox, trust: LEGACY_SANDBOX_TRUST[sandbox.trust] }
70
+ : sandbox
71
+ );
72
+
52
73
  const expandHome = (p) => {
53
74
  if (!p || typeof p !== 'string') return p;
54
75
  if (p === '~') return homedir();
@@ -172,7 +193,11 @@ export const validateEnvironmentSpec = (spec) => {
172
193
  errors.push(`sandbox.mode must be one of: ${[...ALLOWED_SANDBOX_MODES].join(', ')}`);
173
194
  }
174
195
  if (trust !== undefined && !ALLOWED_SANDBOX_TRUST.has(trust)) {
175
- errors.push(`sandbox.trust must be one of: ${[...ALLOWED_SANDBOX_TRUST].join(', ')}`);
196
+ errors.push(
197
+ "sandbox.trust must be 'public' — it marks a seat anyone in the pod can "
198
+ + 'talk to; omit it for your own seat '
199
+ + `(got ${JSON.stringify(trust)})`,
200
+ );
176
201
  }
177
202
  if (network !== undefined) {
178
203
  if (typeof network !== 'object' || network === null) {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Which host mechanism enforces a public seat.
3
+ *
4
+ * A stored environment is platform-independent and the host is not. The daemon
5
+ * derives `sandbox: { trust: 'public' }` with NO mode (lib/default-environment.js)
6
+ * because `mode: 'workspace'` is Seatbelt on macOS and is refused on Linux,
7
+ * while `mode: 'bwrap'` is meaningless on macOS — writing either into the row
8
+ * moves a host fact into the database and breaks the day the seat is re-homed
9
+ * (Wren 69545).
10
+ *
11
+ * So the mode is resolved here, at the point that knows the host:
12
+ * darwin → 'workspace' (the claude adapter maps it to Seatbelt)
13
+ * else → 'bwrap' (bubblewrap; attach checks it is installed)
14
+ *
15
+ * Resolution is PUBLIC-only on purpose. A non-public declaration keeps whatever
16
+ * mode it wrote, and an adapter that cannot enforce it is expected to refuse
17
+ * rather than quietly spawn unconfined. An explicit mode on a public
18
+ * declaration still wins — including a wrong one, which the caller then
19
+ * refuses (Vera 69548: an unresolvable public mode must not fall through to a
20
+ * bare, unconfined spawn).
21
+ *
22
+ * NOT used by the codex adapter. Codex's `mode` is read-vs-write access, not a
23
+ * host mechanism — its permission profiles run on both platforms — so its
24
+ * derived default is 'workspace' everywhere.
25
+ */
26
+
27
+ export const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']);
28
+
29
+ export const resolvePublicSandboxMode = (sandbox, platform = process.platform) => {
30
+ const declared = sandbox?.mode;
31
+ if (sandbox?.trust !== 'public') return declared;
32
+ if (declared !== undefined && declared !== null) return declared;
33
+ return platform === 'darwin' ? 'workspace' : 'bwrap';
34
+ };
35
+
36
+ export default { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode };