@commonlyai/cli 0.1.180 → 0.1.181

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.180",
3
+ "version": "0.1.181",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -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
- // The daemon's baseline for a seat nobody wrote a spec for, sandbox
410
- // included: this record is derived from nothing the person declared.
411
- environment: seatBaseline(buildDefaultEnvironment(adapterName), adapterName, { sandbox: true }),
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
- || (trust === 'public' ? resolvePublicSandboxMode(sandbox, platform) : 'none');
847
- if (trust === 'public' && mode === 'none') {
956
+ || (confined ? resolvePublicSandboxMode(sandbox, platform) : 'none');
957
+ if (confined && mode === 'none') {
848
958
  throw new Error(
849
- 'sandbox.trust=public requires an enforced sandbox mode; refusing to attach unsandboxed',
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
- && (trust !== 'public' || !ADAPTERS_WITH_DEFAULT_SANDBOX.has(adapterName))) {
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 agentCwd = workspacePath || join(tmpdir(), 'commonly-agents', agentName);
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: sessionBinding });
1697
- let sessionId = getSession(agentName, eventPodId, { binding: sessionBinding });
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
- const memoryLongTerm = await readLongTerm(client, { onError });
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: agentCwd,
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: agentCwd,
2039
+ cwd: placement.cwd,
2040
+ skillMountDir: scratchCwd,
1890
2041
  env: commitAttribution?.env || process.env,
1891
2042
  commitAttributionEnv: commitAttribution?.sandboxEnv || null,
1892
2043
  memoryLongTerm,
1893
- environment: seatEnvironment,
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: sessionBinding,
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 { delimiter, isAbsolute, join } from 'path';
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
- const buildPublicClaudePolicyArgs = (mcpToolPatterns) => {
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, ...deniedReads,
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 publicNativeSandbox = sandboxTrust === 'public'
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
- if (sandboxTrust === 'public' && !publicNativeSandbox && sandboxMode !== 'bwrap') {
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
- 'public Claude agents require an enforced sandbox mode '
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' ? 'read' : 'write',
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: [...mcpExecutables, ...commitHookExecutables, ...originalHookExecutables],
801
+ executablePaths: [
802
+ ...mcpExecutables,
803
+ ...(ctx.workspaceGit ? [ctx.workspaceGit.executable] : []),
804
+ ...commitHookExecutables,
805
+ ...originalHookExecutables,
806
+ ],
703
807
  allowClaudeRuntimeAccess: true,
704
- readOnlyDirectories: ctx.mcpReadDirectories || [],
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 === 'public';
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
- if (ctx.environment && ctx.cwd) {
846
- const skills = await mountSkills(ctx.environment, ctx.cwd);
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
@@ -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
  }