@commonlyai/cli 0.1.50 → 0.1.52
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 +1 -1
- package/src/commands/agent.js +19 -8
- package/src/lib/adapters/claude.js +63 -10
- package/src/lib/adapters/codex.js +18 -2
- package/src/lib/daemon-supervisor.js +39 -10
- package/src/lib/default-environment.js +83 -0
- package/src/lib/environment.js +27 -2
- package/src/lib/sandbox/mode.js +36 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -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
|
-
*
|
|
306
|
-
*
|
|
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
|
-
|
|
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"
|
|
354
|
-
+ '
|
|
355
|
-
+ '
|
|
356
|
-
+ '
|
|
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
|
|
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 {
|
|
65
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
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
|
-
|
|
443
|
-
|
|
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,
|
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
@@ -353,12 +368,26 @@ export const createDaemonSupervisor = ({
|
|
|
353
368
|
seats.set(key, seat);
|
|
354
369
|
}
|
|
355
370
|
seat.desired = true;
|
|
356
|
-
const localToken = loadToken(row.agentName);
|
|
357
|
-
seat.adapter = row.runtime?.adapter || localToken?.adapter || seat.adapter || null;
|
|
358
|
-
seat.model = row.runtime?.model || localToken?.environment?.model || seat.model || null;
|
|
359
|
-
seat.effort = row.runtime?.effort || localToken?.environment?.effort || seat.effort || null;
|
|
360
371
|
// eslint-disable-next-line no-await-in-loop
|
|
361
372
|
const ready = await ensureToken(row);
|
|
373
|
+
// Report what the seat actually RUNS, which is the record: `agent run`
|
|
374
|
+
// reads it once at boot, and ensureToken above may have just rewritten it
|
|
375
|
+
// (a mint, or a config change). Reading it AFTER ensureToken is the point
|
|
376
|
+
// — the same tick's write is the record the seat starts from, so the very
|
|
377
|
+
// first heartbeat is already about the running seat.
|
|
378
|
+
//
|
|
379
|
+
// The row's runtime is a COMPATIBILITY OVERLAY for a seat with no record
|
|
380
|
+
// at all, never an override of a declared environment (see the overlay
|
|
381
|
+
// comment above ensureToken): leading with it here reported a model the
|
|
382
|
+
// seat was not running, because the spawn path gives the declared
|
|
383
|
+
// environment precedence over runtime.model/effort (environmentFor, and
|
|
384
|
+
// the TASK-065 measurement). ensureToken also REFUSES a change it cannot
|
|
385
|
+
// apply — a requested adapter this machine does not have — so the record
|
|
386
|
+
// is the honest answer there too: it names the adapter that kept running.
|
|
387
|
+
const current = loadToken(row.agentName);
|
|
388
|
+
seat.adapter = current ? (current.adapter || null) : (row.runtime?.adapter || null);
|
|
389
|
+
seat.model = current ? (current.environment?.model || null) : (row.runtime?.model || null);
|
|
390
|
+
seat.effort = current ? (current.environment?.effort || null) : (row.runtime?.effort || null);
|
|
362
391
|
if (ready === 'changed' && seat.child) {
|
|
363
392
|
// desired stays true, so the exit handler respawns with the updated
|
|
364
393
|
// record — the restart path IS the D6 path, no second spawner.
|
|
@@ -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
|
+
};
|
package/src/lib/environment.js
CHANGED
|
@@ -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'
|
|
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(
|
|
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 };
|