@commonlyai/cli 0.1.180 → 0.1.182
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 +184 -18
- package/src/lib/adapters/claude.js +131 -17
- package/src/lib/adapters/codex.js +2 -1
- package/src/lib/adapters/dsh.js +2 -1
- package/src/lib/adapters/goose.js +2 -1
- package/src/lib/adapters/opencode.js +2 -0
- package/src/lib/adapters/pi.js +2 -1
- package/src/lib/adapters/qwen.js +2 -1
- package/src/lib/default-environment.js +5 -3
- package/src/lib/environment.js +43 -5
- package/src/lib/git-readonly-server.js +138 -0
- package/src/lib/git-readonly.js +340 -0
- package/src/lib/memory-bridge.js +12 -3
- package/src/lib/sandbox/host-check.js +3 -1
- package/src/lib/sandbox/mode.js +8 -1
- package/src/lib/sandbox/seatbelt.js +28 -1
- package/src/lib/workspace-trust.js +139 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -58,9 +58,12 @@ import {
|
|
|
58
58
|
environmentForServer,
|
|
59
59
|
normalizeSandboxTrust,
|
|
60
60
|
parseEnvironmentFile,
|
|
61
|
+
refuseWorkspaceTrust,
|
|
61
62
|
resolveWorkspace,
|
|
62
63
|
validateEnvironmentSpec,
|
|
64
|
+
WORKSPACE_SANDBOX_ALLOW,
|
|
63
65
|
} from '../lib/environment.js';
|
|
66
|
+
import { firstRunWorkspace, podIsConfirmedPrivate, workspaceTurnPlacement } from '../lib/workspace-trust.js';
|
|
64
67
|
import {
|
|
65
68
|
ADAPTERS_WITH_DEFAULT_MCP,
|
|
66
69
|
ADAPTERS_WITH_GRANT_BROKER,
|
|
@@ -297,6 +300,53 @@ export const printAttachRunGuidance = ({
|
|
|
297
300
|
// a foreground run re-bootstraps from the Connect page's env exports after the
|
|
298
301
|
// local record is removed; a daemon-managed seat is switched through the
|
|
299
302
|
// registry, which the daemon applies on its next pass.
|
|
303
|
+
// `run --allow write,web`: the grants a first-run workspace seat starts with
|
|
304
|
+
// (TASK-294). An unknown name is refused rather than dropped.
|
|
305
|
+
export const parseAllowGrants = (value) => {
|
|
306
|
+
if (value === undefined || value === null) return [];
|
|
307
|
+
const requested = String(value).split(',').map((grant) => grant.trim()).filter(Boolean);
|
|
308
|
+
if (!requested.length || requested.some((grant) => !WORKSPACE_SANDBOX_ALLOW.includes(grant))) {
|
|
309
|
+
throw new Error(`--allow takes ${WORKSPACE_SANDBOX_ALLOW.join(' and/or ')}, comma-separated `
|
|
310
|
+
+ `(got ${JSON.stringify(String(value))})`);
|
|
311
|
+
}
|
|
312
|
+
return WORKSPACE_SANDBOX_ALLOW.filter((grant) => requested.includes(grant));
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// Grants are fixed when the token file is written. A later --allow that
|
|
316
|
+
// differs is refused with the way to change them, never applied quietly.
|
|
317
|
+
export const checkRunAllowRequest = ({ record, requested = [] }) => {
|
|
318
|
+
if (!requested.length) return { ok: true };
|
|
319
|
+
const savedList = record?.environment?.sandbox?.allow || [];
|
|
320
|
+
const saved = WORKSPACE_SANDBOX_ALLOW.filter((grant) => savedList.includes(grant));
|
|
321
|
+
if (saved.join(',') === requested.join(',')) return { ok: true };
|
|
322
|
+
const name = record.agentName;
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
message: [
|
|
326
|
+
`'${name}' was set up with ${saved.length ? `--allow ${saved.join(',')}` : 'no --allow'}; `
|
|
327
|
+
+ `--allow ${requested.join(',')} was not applied.`,
|
|
328
|
+
`To change it: commonly agent detach ${name} --force, then re-run with the Connect-page `
|
|
329
|
+
+ `env exports and --allow ${requested.join(',')}.`,
|
|
330
|
+
].join('\n'),
|
|
331
|
+
};
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// What a first-run workspace seat may do, said once at bootstrap. A write
|
|
335
|
+
// grant hands it Bash, whose commands reach the network, so that line never
|
|
336
|
+
// claims the seat is offline.
|
|
337
|
+
const workspaceGrantLine = (workspacePath, allow) => {
|
|
338
|
+
const write = allow.includes('write');
|
|
339
|
+
const web = allow.includes('web');
|
|
340
|
+
if (!write) {
|
|
341
|
+
return web
|
|
342
|
+
? `this agent can read ${workspacePath}, run read-only git there, and fetch web pages; it cannot edit files.`
|
|
343
|
+
: `this agent can read ${workspacePath} and run read-only git there; it cannot edit files or reach the web.`;
|
|
344
|
+
}
|
|
345
|
+
return `this agent can read and edit files in ${workspacePath} and run commands there`
|
|
346
|
+
+ (web ? ', and fetch web pages' : '')
|
|
347
|
+
+ '. Its commands can reach the network. Its .git stays read-only, so it cannot commit.';
|
|
348
|
+
};
|
|
349
|
+
|
|
300
350
|
export const checkRunAdapterRequest = ({ record, requestedAdapter, tokenPath }) => {
|
|
301
351
|
const requested = String(requestedAdapter || '').trim().toLowerCase();
|
|
302
352
|
if (!requested) return { ok: true };
|
|
@@ -320,6 +370,10 @@ export const bootstrapAgentRecordFromEnv = async ({
|
|
|
320
370
|
adapterRegistry = { getAdapter, listAdapterNames },
|
|
321
371
|
adapterOverride = null,
|
|
322
372
|
log = () => {},
|
|
373
|
+
startDir = process.cwd(),
|
|
374
|
+
home,
|
|
375
|
+
allow = [],
|
|
376
|
+
platform = process.platform,
|
|
323
377
|
}) => {
|
|
324
378
|
const runtimeToken = (env.COMMONLY_AGENT_TOKEN || '').trim();
|
|
325
379
|
if (!runtimeToken) return null;
|
|
@@ -398,6 +452,28 @@ export const bootstrapAgentRecordFromEnv = async ({
|
|
|
398
452
|
});
|
|
399
453
|
if (bindingRefusal) throw new Error(bindingRefusal);
|
|
400
454
|
|
|
455
|
+
// The daemon's baseline for a seat nobody wrote a spec for, sandbox
|
|
456
|
+
// included: this record is derived from nothing the person declared.
|
|
457
|
+
const environment = seatBaseline(buildDefaultEnvironment(adapterName), adapterName, { sandbox: true });
|
|
458
|
+
// A claude seat whose every pod is confirmed private reads the directory
|
|
459
|
+
// it was started from (TASK-294); anything else keeps the public default.
|
|
460
|
+
const workspace = firstRunWorkspace({
|
|
461
|
+
adapterName, installations: installs, startDir, home, platform,
|
|
462
|
+
});
|
|
463
|
+
if (allow.length && !workspace?.workspacePath) {
|
|
464
|
+
throw new Error(`--allow ${allow.join(',')} applies only to an agent that works in the folder it `
|
|
465
|
+
+ 'starts in, and this one '
|
|
466
|
+
+ (workspace?.reason
|
|
467
|
+
? `would read only its scratch folder, because ${workspace.reason}.${workspace.fix ? ` ${workspace.fix}` : ''}`
|
|
468
|
+
: 'does not.'));
|
|
469
|
+
}
|
|
470
|
+
if (workspace?.workspacePath) {
|
|
471
|
+
log(workspaceGrantLine(workspace.workspacePath, allow));
|
|
472
|
+
} else if (workspace?.reason) {
|
|
473
|
+
log(`this agent can read only its own scratch folder, because ${workspace.reason}.`
|
|
474
|
+
+ (workspace.fix ? ` ${workspace.fix}` : ''));
|
|
475
|
+
}
|
|
476
|
+
|
|
401
477
|
return {
|
|
402
478
|
agentName,
|
|
403
479
|
displayName: identity?.displayName || primary?.displayName || agentName,
|
|
@@ -406,10 +482,10 @@ export const bootstrapAgentRecordFromEnv = async ({
|
|
|
406
482
|
instanceUrl,
|
|
407
483
|
runtimeToken,
|
|
408
484
|
adapter: adapterName,
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
workspacePath: null,
|
|
485
|
+
environment: workspace?.workspacePath
|
|
486
|
+
? { ...environment, sandbox: { trust: 'workspace', ...(allow.length ? { allow } : {}) } }
|
|
487
|
+
: environment,
|
|
488
|
+
workspacePath: workspace?.workspacePath || null,
|
|
413
489
|
};
|
|
414
490
|
};
|
|
415
491
|
|
|
@@ -616,6 +692,31 @@ export const assertSandboxDeclaredForPublicPod = async ({
|
|
|
616
692
|
return;
|
|
617
693
|
}
|
|
618
694
|
|
|
695
|
+
// A `workspace` seat lets the room read its start directory (TASK-294), so it
|
|
696
|
+
// needs the pod CONFIRMED private, not merely not known to be public: a pod
|
|
697
|
+
// whose visibility cannot be read fails closed here, the way dsh does above.
|
|
698
|
+
if (environment?.sandbox?.trust === 'workspace') {
|
|
699
|
+
let pod;
|
|
700
|
+
try {
|
|
701
|
+
pod = await client.get(`/api/pods/${podId}`);
|
|
702
|
+
} catch {
|
|
703
|
+
throw new Error(
|
|
704
|
+
'sandbox.trust=workspace requires confirmed private pod visibility before attach, '
|
|
705
|
+
+ 'and this pod\'s visibility could not be read',
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
if (podIsConfirmedPrivate(pod)) return;
|
|
709
|
+
const label = pod?.name ? `"${pod.name}"` : podId;
|
|
710
|
+
const reported = typeof pod?.publicRead === 'boolean' && typeof pod?.communityListed === 'boolean';
|
|
711
|
+
let why = 'did not report its visibility';
|
|
712
|
+
if (reported) why = pod.publicRead || pod.communityListed ? 'is publicly readable' : 'is open to join';
|
|
713
|
+
throw new Error(
|
|
714
|
+
`${label} ${why}, so sandbox.trust=workspace would let people you do not control read this `
|
|
715
|
+
+ 'agent\'s start directory. Declare "sandbox": { "trust": "public" } for this pod, or attach '
|
|
716
|
+
+ 'to a private, invite-only pod.',
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
619
720
|
// Normalized FIRST (Vera 71715, Wren 71718): the predicate below reads a
|
|
620
721
|
// legacy `internal` record as the `public` it means, the same mapping the
|
|
621
722
|
// attach gate and both adapters use. Reading it raw refused a record that
|
|
@@ -842,11 +943,20 @@ export const resolveAttachSandbox = ({
|
|
|
842
943
|
} = {}) => {
|
|
843
944
|
const sandbox = normalizeSandboxTrust(environment?.sandbox);
|
|
844
945
|
const trust = sandbox?.trust;
|
|
946
|
+
// `workspace` is claude-only for now (TASK-294). Refused here with the words
|
|
947
|
+
// the adapter would use at spawn, so the attach fails before anything is
|
|
948
|
+
// published rather than at the seat's first turn.
|
|
949
|
+
if (trust === 'workspace' && adapterName !== 'claude') {
|
|
950
|
+
refuseWorkspaceTrust(sandbox, adapterName || 'this');
|
|
951
|
+
}
|
|
952
|
+
// Both confined trusts take their mode from the host the same way; they
|
|
953
|
+
// differ in what the policy inside the sandbox allows, not in the sandbox.
|
|
954
|
+
const confined = trust === 'public' || trust === 'workspace';
|
|
845
955
|
const mode = sandbox?.mode
|
|
846
|
-
|| (
|
|
847
|
-
if (
|
|
956
|
+
|| (confined ? resolvePublicSandboxMode(sandbox, platform) : 'none');
|
|
957
|
+
if (confined && mode === 'none') {
|
|
848
958
|
throw new Error(
|
|
849
|
-
|
|
959
|
+
`sandbox.trust=${trust} requires an enforced sandbox mode; refusing to attach unsandboxed`,
|
|
850
960
|
);
|
|
851
961
|
}
|
|
852
962
|
// Moved in from the call site (Vera 71675): this is the check the raw compare
|
|
@@ -854,7 +964,7 @@ export const resolveAttachSandbox = ({
|
|
|
854
964
|
// pure — a mode and an adapter name — so it belongs with the derivation, and
|
|
855
965
|
// its witnesses live beside it.
|
|
856
966
|
if ((mode === 'workspace' || mode === 'read-only')
|
|
857
|
-
&& (
|
|
967
|
+
&& (!confined || !ADAPTERS_WITH_DEFAULT_SANDBOX.has(adapterName))) {
|
|
858
968
|
throw new Error(
|
|
859
969
|
`sandbox.mode=${mode} requires public trust and an adapter with sandbox enforcement`,
|
|
860
970
|
);
|
|
@@ -1438,8 +1548,42 @@ export const performRun = ({
|
|
|
1438
1548
|
// as binary-not-found — so we ensure it up front to avoid the confusing
|
|
1439
1549
|
// diagnostic. ADR-008: when an environment spec was attached, the resolved
|
|
1440
1550
|
// workspace path replaces the /tmp default.
|
|
1441
|
-
const
|
|
1551
|
+
const scratchCwd = join(tmpdir(), 'commonly-agents', agentName);
|
|
1552
|
+
const agentCwd = workspacePath || scratchCwd;
|
|
1442
1553
|
if (!existsSync(agentCwd)) mkdirSync(agentCwd, { recursive: true });
|
|
1554
|
+
// TASK-294: a pod can turn public after attach, so a workspace seat re-reads
|
|
1555
|
+
// its pods before every turn, and a turn it cannot confirm (a failed read
|
|
1556
|
+
// included) runs as a public seat in the scratch folder. The session is
|
|
1557
|
+
// bound to where and as what the turn runs, so a public turn never resumes
|
|
1558
|
+
// a conversation that read the workspace, whatever claude's resume would
|
|
1559
|
+
// find from another folder.
|
|
1560
|
+
let lastPlacementReason = null;
|
|
1561
|
+
const placeTurn = async () => {
|
|
1562
|
+
if (seatEnvironment?.sandbox?.trust !== 'workspace') {
|
|
1563
|
+
return { environment: seatEnvironment, cwd: agentCwd, binding: sessionBinding };
|
|
1564
|
+
}
|
|
1565
|
+
let installations = null;
|
|
1566
|
+
try {
|
|
1567
|
+
const identity = await client.get('/api/agents/runtime/installations', {}, {
|
|
1568
|
+
signal: AbortSignal.timeout(5000),
|
|
1569
|
+
});
|
|
1570
|
+
installations = Array.isArray(identity?.installations) ? identity.installations : [];
|
|
1571
|
+
} catch {
|
|
1572
|
+
installations = null;
|
|
1573
|
+
}
|
|
1574
|
+
const placement = workspaceTurnPlacement({
|
|
1575
|
+
environment: seatEnvironment, workspacePath: agentCwd, installations, scratchDir: scratchCwd,
|
|
1576
|
+
});
|
|
1577
|
+
if (placement.reason !== lastPlacementReason) {
|
|
1578
|
+
log(placement.reason
|
|
1579
|
+
? `this turn runs as a public seat in ${scratchCwd}, because ${placement.reason}`
|
|
1580
|
+
: `every pod is confirmed private again; turns read ${agentCwd}`);
|
|
1581
|
+
lastPlacementReason = placement.reason;
|
|
1582
|
+
}
|
|
1583
|
+
if (!existsSync(placement.cwd)) mkdirSync(placement.cwd, { recursive: true });
|
|
1584
|
+
const where = `${placement.environment.sandbox.trust}:${placement.cwd}`;
|
|
1585
|
+
return { ...placement, binding: sessionBinding ? `${sessionBinding}:${where}` : where };
|
|
1586
|
+
};
|
|
1443
1587
|
const batchLimit = Math.min(50, Math.max(1, Number(inboxBatchLimit) || 10));
|
|
1444
1588
|
|
|
1445
1589
|
const processEvent = async (event) => {
|
|
@@ -1690,11 +1834,12 @@ export const performRun = ({
|
|
|
1690
1834
|
const runTurn = async ({
|
|
1691
1835
|
event, eventPodId, prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger,
|
|
1692
1836
|
}) => {
|
|
1837
|
+
const placement = await placeTurn();
|
|
1693
1838
|
// TASK-440: a cold, large session starts fresh at wake instead of being
|
|
1694
1839
|
// resumed. The restart note leads the prompt; memory still reaches the
|
|
1695
1840
|
// fresh session through the adapter's preamble.
|
|
1696
|
-
const sessionEntry = getSessionEntry(agentName, eventPodId, { binding:
|
|
1697
|
-
let sessionId = getSession(agentName, eventPodId, { binding:
|
|
1841
|
+
const sessionEntry = getSessionEntry(agentName, eventPodId, { binding: placement.binding });
|
|
1842
|
+
let sessionId = getSession(agentName, eventPodId, { binding: placement.binding });
|
|
1698
1843
|
let recycleBriefing = null;
|
|
1699
1844
|
if (sessionId && shouldStartFreshSession({
|
|
1700
1845
|
lastTurn: sessionEntry?.lastTurn,
|
|
@@ -1711,8 +1856,13 @@ export const performRun = ({
|
|
|
1711
1856
|
sessionId = null;
|
|
1712
1857
|
}
|
|
1713
1858
|
// ADR-005 §Memory bridge: read long_term before spawn, inject via ctx,
|
|
1714
|
-
// and (if the adapter returns a summary) patch-sync back after.
|
|
1715
|
-
|
|
1859
|
+
// and (if the adapter returns a summary) patch-sync back after. A workspace
|
|
1860
|
+
// seat's turn that runs public (placement.reason) gets none of it: its
|
|
1861
|
+
// memory may hold what earlier turns read in the project, and what this
|
|
1862
|
+
// turn reads in an unconfirmed room must not reach the next workspace turn.
|
|
1863
|
+
// The adapter also denies the memory tools for it (memoryWithheld).
|
|
1864
|
+
const memoryWithheld = Boolean(placement.reason);
|
|
1865
|
+
const memoryLongTerm = memoryWithheld ? undefined : await readLongTerm(client, { onError });
|
|
1716
1866
|
|
|
1717
1867
|
// Sharpen TASK-129: focus is a turn-start read, not an enqueue-time
|
|
1718
1868
|
// snapshot. Read through the authorized runtime context route immediately
|
|
@@ -1856,7 +2006,7 @@ export const performRun = ({
|
|
|
1856
2006
|
// only for tests and has no commit-making child process.
|
|
1857
2007
|
if (adapter?.name !== 'stub') {
|
|
1858
2008
|
commitAttribution = prepareCommitAttribution({
|
|
1859
|
-
cwd:
|
|
2009
|
+
cwd: placement.cwd,
|
|
1860
2010
|
env: process.env,
|
|
1861
2011
|
agentName,
|
|
1862
2012
|
displayName,
|
|
@@ -1886,11 +2036,13 @@ export const performRun = ({
|
|
|
1886
2036
|
providerKey,
|
|
1887
2037
|
timeoutMs: effectiveTurnTimeoutMs,
|
|
1888
2038
|
sessionId,
|
|
1889
|
-
cwd:
|
|
2039
|
+
cwd: placement.cwd,
|
|
2040
|
+
skillMountDir: scratchCwd,
|
|
1890
2041
|
env: commitAttribution?.env || process.env,
|
|
1891
2042
|
commitAttributionEnv: commitAttribution?.sandboxEnv || null,
|
|
1892
2043
|
memoryLongTerm,
|
|
1893
|
-
|
|
2044
|
+
memoryWithheld,
|
|
2045
|
+
environment: placement.environment,
|
|
1894
2046
|
onSkillsLinked: ({ mounted, conflicted }) => {
|
|
1895
2047
|
skillMount = {
|
|
1896
2048
|
mounted: [...new Set([...serverSkills.mounted, ...mounted.map(reportedSkillName)])],
|
|
@@ -1934,7 +2086,7 @@ export const performRun = ({
|
|
|
1934
2086
|
// wake can decide whether to recycle. Adapters that report nothing keep
|
|
1935
2087
|
// resuming.
|
|
1936
2088
|
setSession(agentName, eventPodId, result.newSessionId, {
|
|
1937
|
-
binding:
|
|
2089
|
+
binding: placement.binding,
|
|
1938
2090
|
contextTokens: result.contextTokens ?? null,
|
|
1939
2091
|
});
|
|
1940
2092
|
}
|
|
@@ -2157,7 +2309,7 @@ export const performRun = ({
|
|
|
2157
2309
|
);
|
|
2158
2310
|
}
|
|
2159
2311
|
}
|
|
2160
|
-
if (result.memorySummary) {
|
|
2312
|
+
if (result.memorySummary && !memoryWithheld) {
|
|
2161
2313
|
try {
|
|
2162
2314
|
await syncBack(client, { summary: result.memorySummary });
|
|
2163
2315
|
log(`[${event.type}] memory synced (${Buffer.byteLength(result.memorySummary)} bytes)`);
|
|
@@ -3708,7 +3860,15 @@ Docs:
|
|
|
3708
3860
|
.option('--cascade-grace <n>', `Extra turns allowed when this seat was directly addressed; 0 disables the grace (env ${CASCADE_ENV_VARS.addressedGrace}, default ${CASCADE_DEFAULTS.addressedGrace})`)
|
|
3709
3861
|
.option('--cascade-reset <ms>', `Silence window that clears the streak (env ${CASCADE_ENV_VARS.resetMs}, default ${CASCADE_DEFAULTS.resetMs})`)
|
|
3710
3862
|
.option('--refusal-handoff <agent>', 'Agent to @mention when the model provider refuses a request\'s content; the request is acked as refused, never retried')
|
|
3863
|
+
.option('--allow <grants>', 'On first run of an agent that works in its folder, also let it edit files and run commands (write) and/or fetch web pages (web), comma-separated. Commands reach the network.')
|
|
3711
3864
|
.action(async (name, opts) => {
|
|
3865
|
+
let allow;
|
|
3866
|
+
try {
|
|
3867
|
+
allow = parseAllowGrants(opts.allow);
|
|
3868
|
+
} catch (err) {
|
|
3869
|
+
console.error(`${stamp()} [${name}] ${err.message}`);
|
|
3870
|
+
process.exit(1);
|
|
3871
|
+
}
|
|
3712
3872
|
let record = loadAgentToken(name);
|
|
3713
3873
|
let bootstrapped = false;
|
|
3714
3874
|
if (!record) {
|
|
@@ -3719,6 +3879,7 @@ Docs:
|
|
|
3719
3879
|
name,
|
|
3720
3880
|
adapterOverride: opts.adapter || null,
|
|
3721
3881
|
log: (line) => console.log(`${stamp()} [${name}] ${line}`),
|
|
3882
|
+
allow,
|
|
3722
3883
|
});
|
|
3723
3884
|
} catch (err) {
|
|
3724
3885
|
console.error(`${stamp()} [${name}] ${err.message}`);
|
|
@@ -3758,6 +3919,11 @@ Docs:
|
|
|
3758
3919
|
);
|
|
3759
3920
|
}
|
|
3760
3921
|
|
|
3922
|
+
const allowRequest = checkRunAllowRequest({ record, requested: allow });
|
|
3923
|
+
if (!allowRequest.ok) {
|
|
3924
|
+
console.error(`${stamp()} [${name}] ${allowRequest.message}`);
|
|
3925
|
+
process.exit(1);
|
|
3926
|
+
}
|
|
3761
3927
|
const adapterRequest = checkRunAdapterRequest({
|
|
3762
3928
|
record, requestedAdapter: opts.adapter, tokenPath: tokenFile(name),
|
|
3763
3929
|
});
|
|
@@ -63,7 +63,10 @@ import {
|
|
|
63
63
|
writeFile,
|
|
64
64
|
} from 'fs/promises';
|
|
65
65
|
import { homedir, tmpdir } from 'os';
|
|
66
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
delimiter, dirname, isAbsolute, join,
|
|
68
|
+
} from 'path';
|
|
69
|
+
import { fileURLToPath } from 'url';
|
|
67
70
|
|
|
68
71
|
import {
|
|
69
72
|
isLegacySandboxTrust,
|
|
@@ -135,6 +138,65 @@ const PUBLIC_DENIED_TOOLS = [
|
|
|
135
138
|
'NotebookEdit',
|
|
136
139
|
'Task',
|
|
137
140
|
];
|
|
141
|
+
// TASK-294: a workspace seat is confined as a public one is, except that its
|
|
142
|
+
// folder is the person's project, which the jail holds read-only unless the
|
|
143
|
+
// seat was granted write, and whose .git it holds read-only either way.
|
|
144
|
+
const isConfinedTrust = (trust) => trust === 'public' || trust === 'workspace';
|
|
145
|
+
// What each `sandbox.allow` grant moves out of the denies. `web` grants
|
|
146
|
+
// WebFetch alone: WebSearch, NotebookEdit and Task stay denied at every tier.
|
|
147
|
+
// Bash and Write land wherever the jail lets them, so the jail, not this list,
|
|
148
|
+
// bounds a write.
|
|
149
|
+
const WORKSPACE_ALLOW_TOOLS = {
|
|
150
|
+
write: ['Edit', 'Write', 'Bash'],
|
|
151
|
+
web: ['WebFetch'],
|
|
152
|
+
};
|
|
153
|
+
// The grants a seat's environment carries. They widen the workspace tier
|
|
154
|
+
// only, so any other trust gets none, whatever its record says.
|
|
155
|
+
const workspaceGrants = (sandbox) => (
|
|
156
|
+
sandbox?.trust === 'workspace' && Array.isArray(sandbox.allow) ? sandbox.allow : []
|
|
157
|
+
);
|
|
158
|
+
// A workspace seat reads its project's history through one read-only git tool
|
|
159
|
+
// (git-readonly-server.js). The server runs inside the jail, so the jail must
|
|
160
|
+
// admit its code (cli/src/lib, plus package.json for the module type) and the
|
|
161
|
+
// git binary it runs.
|
|
162
|
+
const WORKSPACE_GIT_SERVER = fileURLToPath(new URL('../git-readonly-server.js', import.meta.url));
|
|
163
|
+
const WORKSPACE_GIT_PACKAGE_JSON = fileURLToPath(new URL('../../../package.json', import.meta.url));
|
|
164
|
+
// macOS's /usr/bin/git is an xcrun shim, and it fails inside Seatbelt ("No
|
|
165
|
+
// developer tools were found"). So the jail gets the binary git itself runs:
|
|
166
|
+
// `git --exec-path`, resolved here on the host, holds a `git` that links to it.
|
|
167
|
+
const resolveRealGit = () => {
|
|
168
|
+
const result = spawnSync('git', ['--exec-path'], { encoding: 'utf8', timeout: 5000 });
|
|
169
|
+
const execDir = result?.status === 0 ? String(result.stdout || '').trim() : '';
|
|
170
|
+
if (!execDir) return null;
|
|
171
|
+
try {
|
|
172
|
+
return realpathSync(join(execDir, 'git'));
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const withWorkspaceGit = (ctx) => {
|
|
178
|
+
if (ctx.environment?.sandbox?.trust !== 'workspace' || !ctx.cwd) return ctx;
|
|
179
|
+
const git = resolveRealGit();
|
|
180
|
+
if (!git) {
|
|
181
|
+
// eslint-disable-next-line no-console
|
|
182
|
+
console.warn('[claude] git was not found on this machine, so this workspace seat has no git tool');
|
|
183
|
+
return ctx;
|
|
184
|
+
}
|
|
185
|
+
const server = {
|
|
186
|
+
name: 'workspace_git',
|
|
187
|
+
transport: 'stdio',
|
|
188
|
+
command: [process.execPath, WORKSPACE_GIT_SERVER, '--root', ctx.cwd, '--git', git],
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
...ctx,
|
|
192
|
+
environment: { ...ctx.environment, mcp: [...(ctx.environment.mcp || []), server] },
|
|
193
|
+
workspaceGit: {
|
|
194
|
+
executable: git,
|
|
195
|
+
readDirectories: [dirname(WORKSPACE_GIT_SERVER)],
|
|
196
|
+
readPaths: [WORKSPACE_GIT_PACKAGE_JSON],
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
};
|
|
138
200
|
const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
|
|
139
201
|
|
|
140
202
|
const statOrNull = async (path) => {
|
|
@@ -248,8 +310,13 @@ const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null,
|
|
|
248
310
|
|
|
249
311
|
const absoluteToolDeny = (path) => `Read(/${path}/**)`;
|
|
250
312
|
|
|
251
|
-
|
|
313
|
+
// The tools that read or write the seat's agent-wide memory.
|
|
314
|
+
const MEMORY_TOOLS = Object.freeze(['commonly_read_agent_memory', 'commonly_write_agent_memory', 'commonly_save_my_memory']);
|
|
315
|
+
|
|
316
|
+
const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [], memoryWithheld = false } = {}) => {
|
|
252
317
|
const home = homedir();
|
|
318
|
+
// `allow` is already narrowed to a workspace seat's grants (workspaceGrants).
|
|
319
|
+
const granted = allow.flatMap((grant) => WORKSPACE_ALLOW_TOOLS[grant] || []);
|
|
253
320
|
const deniedReads = [
|
|
254
321
|
absoluteToolDeny(join(home, '.commonly')),
|
|
255
322
|
absoluteToolDeny(join(home, '.claude')),
|
|
@@ -262,6 +329,26 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns) => {
|
|
|
262
329
|
'Read(./.codex/**)',
|
|
263
330
|
'Read(./.env)',
|
|
264
331
|
];
|
|
332
|
+
// A workspace seat reads a real project, so every .env variant at any depth,
|
|
333
|
+
// and what a .env directory holds, stays out of reach (the jail holds the
|
|
334
|
+
// same line). .git is denied too: its config can carry a remote URL with a
|
|
335
|
+
// token in it, and history comes through the read-only git tool, which runs
|
|
336
|
+
// in its own process. And the seat cannot install itself into a room partway
|
|
337
|
+
// through a turn: the per-turn check confirmed its pods private before the
|
|
338
|
+
// spawn.
|
|
339
|
+
const workspaceDenies = trust === 'workspace'
|
|
340
|
+
? [
|
|
341
|
+
'Read(./.env.*)', 'Read(./**/.env)', 'Read(./**/.env.*)',
|
|
342
|
+
'Read(./.env/**)', 'Read(./**/.env/**)', 'Read(./**/.env.*/**)',
|
|
343
|
+
'Read(./.git/**)', 'Read(./**/.git/**)',
|
|
344
|
+
...mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, 'commonly_self_install_into_pod')),
|
|
345
|
+
]
|
|
346
|
+
: [];
|
|
347
|
+
// A workspace seat's turn that runs public gets no memory in its prompt
|
|
348
|
+
// (agent.js), and no tool that would read or write it either.
|
|
349
|
+
const memoryDenies = memoryWithheld
|
|
350
|
+
? MEMORY_TOOLS.flatMap((tool) => mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, tool)))
|
|
351
|
+
: [];
|
|
265
352
|
return [
|
|
266
353
|
// HOME points at an isolated, sanitized state directory. Ignore settings
|
|
267
354
|
// from user/project/local sources and disable slash-command extensions;
|
|
@@ -271,8 +358,9 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns) => {
|
|
|
271
358
|
'--strict-mcp-config',
|
|
272
359
|
'--no-chrome',
|
|
273
360
|
'--permission-mode', 'dontAsk',
|
|
274
|
-
'--allowedTools', 'Read(./**)', ...mcpToolPatterns,
|
|
275
|
-
'--disallowedTools', ...PUBLIC_DENIED_TOOLS
|
|
361
|
+
'--allowedTools', 'Read(./**)', ...granted, ...mcpToolPatterns,
|
|
362
|
+
'--disallowedTools', ...PUBLIC_DENIED_TOOLS.filter((tool) => !granted.includes(tool)),
|
|
363
|
+
...deniedReads, ...workspaceDenies, ...memoryDenies,
|
|
276
364
|
];
|
|
277
365
|
};
|
|
278
366
|
|
|
@@ -636,7 +724,8 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
636
724
|
|
|
637
725
|
const sandboxTrust = env.sandbox?.trust;
|
|
638
726
|
const sandboxMode = resolvePublicSandboxMode(env.sandbox);
|
|
639
|
-
const
|
|
727
|
+
const grants = workspaceGrants(env.sandbox);
|
|
728
|
+
const publicNativeSandbox = isConfinedTrust(sandboxTrust)
|
|
640
729
|
&& PUBLIC_SANDBOX_MODES.has(sandboxMode);
|
|
641
730
|
// A public trust is ONE contract on both platforms: the jail bounds the
|
|
642
731
|
// filesystem and the policy bounds the tools. The bwrap branch below used to
|
|
@@ -665,9 +754,15 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
665
754
|
// non-string) used to fall through to the bare `claude` spawn below — an
|
|
666
755
|
// unconfined seat with a public record, which is how a record can claim
|
|
667
756
|
// confinement it never applies (Vera 69548).
|
|
668
|
-
|
|
757
|
+
// bwrap points the child's HOME at its folder (childHome below), so a
|
|
758
|
+
// workspace seat's transcripts would be written into the person's project.
|
|
759
|
+
if (sandboxTrust === 'workspace' && sandboxMode === 'bwrap') {
|
|
760
|
+
throw new Error('workspace Claude agents run on macOS only for now: under bwrap '
|
|
761
|
+
+ 'their transcripts would be written into the project folder');
|
|
762
|
+
}
|
|
763
|
+
if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') {
|
|
669
764
|
throw new Error(
|
|
670
|
-
|
|
765
|
+
`${sandboxTrust} Claude agents require an enforced sandbox mode `
|
|
671
766
|
+ `(workspace or read-only on macOS, bwrap elsewhere), got ${JSON.stringify(sandboxMode)}`,
|
|
672
767
|
);
|
|
673
768
|
}
|
|
@@ -683,7 +778,9 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
683
778
|
}
|
|
684
779
|
innerArgv = [
|
|
685
780
|
...innerArgv,
|
|
686
|
-
...buildPublicClaudePolicyArgs(allowedPatterns
|
|
781
|
+
...buildPublicClaudePolicyArgs(allowedPatterns, {
|
|
782
|
+
trust: sandboxTrust, allow: grants, memoryWithheld: ctx.memoryWithheld === true,
|
|
783
|
+
}),
|
|
687
784
|
...(ctx.publicSkillArgs || []),
|
|
688
785
|
];
|
|
689
786
|
const claudeBin = resolveClaudePath(claudeEnv);
|
|
@@ -695,13 +792,24 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
695
792
|
.filter((command) => typeof command === 'string' && isAbsolute(command));
|
|
696
793
|
const wrapped = wrapArgvWithSeatbelt([claudeBin, ...innerArgv], {
|
|
697
794
|
workspacePath: ctx.cwd,
|
|
698
|
-
workspaceAccess: sandboxMode === 'read-only'
|
|
795
|
+
workspaceAccess: sandboxMode === 'read-only'
|
|
796
|
+
|| (sandboxTrust === 'workspace' && !grants.includes('write')) ? 'read' : 'write',
|
|
797
|
+
readOnlyWorkspaceEntries: sandboxTrust === 'workspace' ? ['.git'] : [],
|
|
699
798
|
executablePath: claudeBin,
|
|
700
799
|
statePath: ctx.publicClaudeState.statePath,
|
|
701
800
|
mcpConfigDir: ctx.mcpConfigDir,
|
|
702
|
-
executablePaths: [
|
|
801
|
+
executablePaths: [
|
|
802
|
+
...mcpExecutables,
|
|
803
|
+
...(ctx.workspaceGit ? [ctx.workspaceGit.executable] : []),
|
|
804
|
+
...commitHookExecutables,
|
|
805
|
+
...originalHookExecutables,
|
|
806
|
+
],
|
|
703
807
|
allowClaudeRuntimeAccess: true,
|
|
704
|
-
readOnlyDirectories:
|
|
808
|
+
readOnlyDirectories: [
|
|
809
|
+
...(ctx.mcpReadDirectories || []),
|
|
810
|
+
...(ctx.workspaceGit?.readDirectories || []),
|
|
811
|
+
],
|
|
812
|
+
readOnlyPaths: ctx.workspaceGit?.readPaths || [],
|
|
705
813
|
});
|
|
706
814
|
return {
|
|
707
815
|
cmd: wrapped[0],
|
|
@@ -721,7 +829,7 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
721
829
|
}
|
|
722
830
|
innerArgv = [
|
|
723
831
|
...innerArgv,
|
|
724
|
-
...buildPublicClaudePolicyArgs(allowedPatterns),
|
|
832
|
+
...buildPublicClaudePolicyArgs(allowedPatterns, { memoryWithheld: ctx.memoryWithheld === true }),
|
|
725
833
|
...(ctx.publicSkillArgs || []),
|
|
726
834
|
];
|
|
727
835
|
} else if (allowedPatterns.length > 0) {
|
|
@@ -788,13 +896,13 @@ export default {
|
|
|
788
896
|
// one declaration is exactly how this seat crashed with "public Claude
|
|
789
897
|
// state was not prepared" instead of spawning confined.
|
|
790
898
|
const ctx = rawCtx.environment
|
|
791
|
-
? {
|
|
899
|
+
? withWorkspaceGit({
|
|
792
900
|
...rawCtx,
|
|
793
901
|
environment: {
|
|
794
902
|
...rawCtx.environment,
|
|
795
903
|
sandbox: normalizeSandboxTrust(rawCtx.environment.sandbox),
|
|
796
904
|
},
|
|
797
|
-
}
|
|
905
|
+
})
|
|
798
906
|
: rawCtx;
|
|
799
907
|
if (isLegacySandboxTrust(rawCtx.environment?.sandbox)) {
|
|
800
908
|
// eslint-disable-next-line no-console
|
|
@@ -812,11 +920,12 @@ export default {
|
|
|
812
920
|
// the only two paths that build a real prompt. buildPrompt handles
|
|
813
921
|
// undefined and '' as absence itself; it does not need a guard, it needs
|
|
814
922
|
// the value.
|
|
815
|
-
const publicTrust = ctx.environment?.sandbox?.trust
|
|
923
|
+
const publicTrust = isConfinedTrust(ctx.environment?.sandbox?.trust);
|
|
816
924
|
const workCues = publicTrust ? [] : WORK_CUES;
|
|
817
925
|
const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
|
|
818
926
|
freshSession: !isResume,
|
|
819
927
|
workCues,
|
|
928
|
+
memoryWithheld: ctx.memoryWithheld === true,
|
|
820
929
|
});
|
|
821
930
|
const sessionFlag = isResume ? '--resume' : '--session-id';
|
|
822
931
|
// Model pin from the ADR-008 environment spec. Absent it, claude picks its
|
|
@@ -842,8 +951,12 @@ export default {
|
|
|
842
951
|
|
|
843
952
|
let mountedSkills = [];
|
|
844
953
|
let skillMount = { mounted: [], conflicted: [] };
|
|
845
|
-
|
|
846
|
-
|
|
954
|
+
// A workspace seat's folder is the person's project, and mountSkills writes
|
|
955
|
+
// from this host process, outside any jail. Its skills are copied to the
|
|
956
|
+
// run loop's scratch folder instead and reach it as prompt text.
|
|
957
|
+
const skillDir = ctx.environment?.sandbox?.trust === 'workspace' ? ctx.skillMountDir : ctx.cwd;
|
|
958
|
+
if (ctx.environment && skillDir) {
|
|
959
|
+
const skills = await mountSkills(ctx.environment, skillDir);
|
|
847
960
|
mountedSkills = skills.mounted;
|
|
848
961
|
skillMount = skills;
|
|
849
962
|
if (skills.conflicted.length > 0) {
|
|
@@ -948,6 +1061,7 @@ export default {
|
|
|
948
1061
|
const freshPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
|
|
949
1062
|
freshSession: true,
|
|
950
1063
|
workCues,
|
|
1064
|
+
memoryWithheld: ctx.memoryWithheld === true,
|
|
951
1065
|
});
|
|
952
1066
|
const retryBase = ['-p', freshPrompt,
|
|
953
1067
|
'--output-format', 'text', '--session-id', freshId, ...modelArgs];
|
|
@@ -69,7 +69,7 @@ import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credent
|
|
|
69
69
|
import { prepareMcpSpawn, publicMcpUnavailableError, selectedMcpVersionDir } from '../mcp-home.js';
|
|
70
70
|
import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
71
71
|
import { resolveSkillSource, skillsAsText } from '../skill-prompt.js';
|
|
72
|
-
import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js';
|
|
72
|
+
import { isLegacySandboxTrust, normalizeSandboxTrust, refuseWorkspaceTrust } from '../environment.js';
|
|
73
73
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
74
74
|
|
|
75
75
|
// Default timeout for a single codex spawn (exec mode).
|
|
@@ -653,6 +653,7 @@ export default {
|
|
|
653
653
|
async spawn(prompt, ctx = {}) {
|
|
654
654
|
const seatModel = requireSeatModel(ctx.environment, 'codex');
|
|
655
655
|
const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox);
|
|
656
|
+
refuseWorkspaceTrust(sandbox, 'codex');
|
|
656
657
|
// Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and
|
|
657
658
|
// `null || ''` is `''` — which routed the unreadable case straight into
|
|
658
659
|
// the empty-memory branch and made the whole distinction unreachable from
|
package/src/lib/adapters/dsh.js
CHANGED
|
@@ -9,7 +9,7 @@ import { homedir, tmpdir } from 'os';
|
|
|
9
9
|
import { basename, join } from 'path';
|
|
10
10
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
11
11
|
import { commonlyMcpServer } from '../default-environment.js';
|
|
12
|
-
import { effectiveSandboxTrust, validateEnvironmentSpec } from '../environment.js';
|
|
12
|
+
import { effectiveSandboxTrust, refuseWorkspaceTrust, validateEnvironmentSpec } from '../environment.js';
|
|
13
13
|
import { deliverSeatCredential } from '../mcp-credential-delivery.js';
|
|
14
14
|
import { describeMcpCommand } from '../mcp-server-version.js';
|
|
15
15
|
import { writeCredentialFile } from '../credential-file.js';
|
|
@@ -23,6 +23,7 @@ const SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CER
|
|
|
23
23
|
|
|
24
24
|
export const assertPrivateDsh = (environment = {}) => {
|
|
25
25
|
const sandbox = environment?.sandbox || {};
|
|
26
|
+
refuseWorkspaceTrust(sandbox, 'dsh');
|
|
26
27
|
if (effectiveSandboxTrust(sandbox.trust) === 'public') {
|
|
27
28
|
throw new Error('dsh adapter: public-trust seats are not supported; nested Seatbelt fails closed');
|
|
28
29
|
}
|
|
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { StringDecoder } from 'node:string_decoder';
|
|
8
8
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
9
|
-
import { effectiveSandboxTrust, validateEnvironmentSpec } from '../environment.js';
|
|
9
|
+
import { effectiveSandboxTrust, refuseWorkspaceTrust, validateEnvironmentSpec } from '../environment.js';
|
|
10
10
|
import { writeCredentialFile } from '../credential-file.js';
|
|
11
11
|
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
12
12
|
import { prepareMcpSpawn } from '../mcp-home.js';
|
|
@@ -24,6 +24,7 @@ const SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CER
|
|
|
24
24
|
|
|
25
25
|
export const assertNoSandboxDeclared = (environment = {}) => {
|
|
26
26
|
const sandbox = environment?.sandbox || {};
|
|
27
|
+
refuseWorkspaceTrust(sandbox, 'goose');
|
|
27
28
|
if (effectiveSandboxTrust(sandbox.trust) === 'public') {
|
|
28
29
|
throw new Error('goose adapter: public-trust seats are not supported until the deny-by-default attack test passes');
|
|
29
30
|
}
|