@commonlyai/cli 0.1.179 → 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.
@@ -60,7 +60,7 @@ export const ROW_SLICING_CUE = 'Slicing: when a row is too large for one turn, s
60
60
  export const buildMemoryPreamble = (
61
61
  prompt,
62
62
  memoryLongTerm,
63
- { freshSession = false, workCues = [] } = {},
63
+ { freshSession = false, workCues = [], memoryWithheld = false } = {},
64
64
  ) => {
65
65
  // `null` is the UNREADABLE signal, and it is deliberately not the same value
66
66
  // as `''`. Telling a seat whose token was revoked that "nothing has ever been
@@ -68,7 +68,14 @@ export const buildMemoryPreamble = (
68
68
  // defect this cue exists to fix — one state over. When we could not read, say
69
69
  // that, and say nothing about what is stored.
70
70
  let context;
71
- if (memoryLongTerm === null) {
71
+ if (memoryWithheld) {
72
+ // A workspace seat's turn that runs public: not unreadable and not empty,
73
+ // so neither of those cues is true. Its memory tools are off as well.
74
+ context = `=== Context (your persistent memory) ===\n`
75
+ + `(withheld this turn — this turn runs as a public seat because not every `
76
+ + `pod you answer in is confirmed private, so your memory stays out of it and `
77
+ + `the memory tools are off. This says nothing about what you have saved.)`;
78
+ } else if (memoryLongTerm === null) {
72
79
  context = `=== Context (your persistent memory) ===\n`
73
80
  + `(unreadable this turn — the memory read failed, so this says NOTHING `
74
81
  + `about what you have saved. Do not treat it as empty and do not re-save `
@@ -96,7 +103,9 @@ export const buildMemoryPreamble = (
96
103
  + 'This is a fresh session. Read the persistent memory context above before acting; '
97
104
  + 'it carries durable state from prior sessions.\n';
98
105
  let sessionEndCue = '=== Before this session ends ===\n';
99
- if (memoryLongTerm === null) {
106
+ if (memoryWithheld) {
107
+ sessionEndCue += 'Memory is withheld this turn, so there is nothing to save.';
108
+ } else if (memoryLongTerm === null) {
100
109
  sessionEndCue += 'Memory was unreadable on this fresh session. Do not treat it as empty or '
101
110
  + 'write a replacement based on this cue.';
102
111
  } else if (memoryLongTerm) {
@@ -70,7 +70,9 @@ export const hostSandboxRefusal = ({
70
70
  if (!Object.prototype.hasOwnProperty.call(ADAPTER_LABELS, adapterName)) return null;
71
71
  const sandbox = normalizeSandboxTrust(environment?.sandbox);
72
72
  if (!sandbox || typeof sandbox !== 'object') return null;
73
- const mode = sandbox.mode ?? (sandbox.trust === 'public' ? resolvePublicSandboxMode(sandbox, platform) : undefined);
73
+ // Every host-confined trust (public, and workspace since TASK-294) derives its
74
+ // mode here; any other trust gets back only the mode it declared.
75
+ const mode = sandbox.mode ?? resolvePublicSandboxMode(sandbox, platform);
74
76
  if (mode !== 'bwrap' && !SEATBELT_MODES.has(mode)) return null;
75
77
 
76
78
  if (platform === 'win32') return windowsSandboxMessage(adapterName);
@@ -28,14 +28,21 @@
28
28
  * NOT used by the codex adapter. Codex's `mode` is read-vs-write access, not a
29
29
  * host mechanism — its permission profiles run on both platforms — so its
30
30
  * derived default is 'workspace' everywhere.
31
+ *
32
+ * A `workspace` trust (TASK-294) resolves the same way: it is confined by the
33
+ * same host mechanism and differs only in what the policy inside allows. The
34
+ * grant-broker guard still reads the trust itself, not this mode, so resolving
35
+ * it here does not make a workspace seat eligible for a room grant.
31
36
  */
32
37
  import { effectiveSandboxTrust } from '../environment.js';
33
38
 
34
39
  export const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']);
35
40
 
41
+ const HOST_CONFINED_TRUSTS = new Set(['public', 'workspace']);
42
+
36
43
  export const resolvePublicSandboxMode = (sandbox, platform = process.platform) => {
37
44
  const declared = sandbox?.mode;
38
- if (effectiveSandboxTrust(sandbox?.trust) !== 'public') return declared;
45
+ if (!HOST_CONFINED_TRUSTS.has(effectiveSandboxTrust(sandbox?.trust))) return declared;
39
46
  if (declared !== undefined && declared !== null) return declared;
40
47
  return platform === 'darwin' ? 'workspace' : 'bwrap';
41
48
  };
@@ -300,6 +300,17 @@ const escapeSbplString = (value) => String(value)
300
300
 
301
301
  const literal = (path) => `(literal "${escapeSbplString(path)}")`;
302
302
  const subpath = (path) => `(subpath "${escapeSbplString(path)}")`;
303
+ // Every .env and .env.* file under the workspace, at any depth, and everything
304
+ // inside a directory of that name: a real project keeps one beside each
305
+ // package, not only at its root (TASK-294). An SBPL regex literal takes no
306
+ // escapes, so a path it cannot hold is refused.
307
+ const envFilesUnder = (workspace) => {
308
+ if (/["\r\n]/.test(workspace)) {
309
+ throw new Error('Seatbelt workspacePath must not contain a quote or a line break');
310
+ }
311
+ const pattern = workspace.replace(/[\\^$.|?*+()[\]{}]/g, '\\$&');
312
+ return `(regex #"^${pattern}/(.*/)?\\.env([./].*)?$")`;
313
+ };
303
314
 
304
315
  const assertAbsolutePath = (path, label) => {
305
316
  if (!path || !isAbsolute(path)) {
@@ -356,6 +367,10 @@ export const buildSeatbeltProfile = ({
356
367
  readOnlyDirectories = [],
357
368
  loopbackNetworkPorts = [],
358
369
  allowClaudeRuntimeAccess = false,
370
+ // Names directly inside a writable workspace that stay read-only. A
371
+ // workspace seat granted write keeps its project's .git read-only, so every
372
+ // change it makes is one `git status` shows the person (TASK-294).
373
+ readOnlyWorkspaceEntries = [],
359
374
  }) => {
360
375
  for (const [path, label] of [
361
376
  [workspacePath, 'workspacePath'],
@@ -375,6 +390,11 @@ export const buildSeatbeltProfile = ({
375
390
  if (!['read', 'write'].includes(workspaceAccess)) {
376
391
  throw new Error('Seatbelt workspaceAccess must be read or write');
377
392
  }
393
+ for (const entry of readOnlyWorkspaceEntries) {
394
+ if (typeof entry !== 'string' || !entry || entry.includes('/') || entry === '.' || entry === '..') {
395
+ throw new Error('Seatbelt readOnlyWorkspaceEntries entries must be names inside the workspace');
396
+ }
397
+ }
378
398
 
379
399
  const resolvedWorkspace = realpathOrSelf(workspacePath);
380
400
  const resolvedExecutable = realpathOrSelf(executablePath);
@@ -411,6 +431,12 @@ export const buildSeatbeltProfile = ({
411
431
  `(allow file-read* file-test-existence ${subpath(path)})`,
412
432
  `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(path)}"))`,
413
433
  ].join('\n'))).join('\n');
434
+ // A symlinked entry is held at both its name and its target, so the write
435
+ // cannot land through whichever path the link resolves to.
436
+ const readOnlyWorkspaceRules = [...new Set(readOnlyWorkspaceEntries.flatMap((entry) => {
437
+ const entryPath = join(resolvedWorkspace, entry);
438
+ return [entryPath, realpathOrSelf(entryPath)];
439
+ }))].map((path) => `(deny file-write* ${subpath(path)})`).join('\n');
414
440
  const loopbackNetworkRules = [...new Set(loopbackNetworkPorts)].map((port) => (
415
441
  `(allow network-outbound (remote tcp "localhost:${port}"))`
416
442
  )).join('\n');
@@ -444,7 +470,8 @@ export const buildSeatbeltProfile = ({
444
470
  // paths are not. Explicit deny wins over the broad workspace allow.
445
471
  `(deny file-read* file-write* ${subpath(join(resolvedWorkspace, '.commonly'))})`,
446
472
  `(deny file-read* file-write* ${subpath(join(resolvedWorkspace, '.codex'))})`,
447
- `(deny file-read* file-write* ${literal(join(resolvedWorkspace, '.env'))})`,
473
+ `(deny file-read* file-write* ${envFilesUnder(resolvedWorkspace)})`,
474
+ readOnlyWorkspaceRules,
448
475
  ...claudeRuntimeRules,
449
476
  ].filter(Boolean).join('\n\n');
450
477
  };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The two facts a `workspace`-trust seat depends on (TASK-294), as pure
3
+ * predicates so the attach path, the first-run bootstrap and the per-spawn
4
+ * check all read them the same way.
5
+ *
6
+ * 1. Every pod the seat answers in is CONFIRMED private: not publicly
7
+ * readable, not listed, and invite-only. Visibility alone is not enough,
8
+ * because any agent can add itself to an open pod an agent created, listed
9
+ * or not. A pod whose fields the server did not report counts as public:
10
+ * the workspace tier lets the room read the start directory, so an unknown
11
+ * room fails closed.
12
+ * 2. The start directory is a directory worth exposing to that room, and not
13
+ * one that contains the operator's whole account: `/`, `$HOME`, an ancestor
14
+ * of `$HOME`, or a directory inside one of the credential stores the public
15
+ * policy denies by name.
16
+ */
17
+ import { realpathSync } from 'fs';
18
+ import { homedir } from 'os';
19
+ import {
20
+ isAbsolute, join, relative, resolve,
21
+ } from 'path';
22
+
23
+ /** A pod is private only when the server says so in all three fields. */
24
+ export const podIsConfirmedPrivate = (pod) => (
25
+ typeof pod?.publicRead === 'boolean'
26
+ && typeof pod?.communityListed === 'boolean'
27
+ && !pod.publicRead
28
+ && !pod.communityListed
29
+ && pod.joinPolicy === 'invite-only'
30
+ );
31
+
32
+ /** True only for a non-empty list in which every pod is confirmed private. */
33
+ export const allPodsConfirmedPrivate = (pods) => (
34
+ Array.isArray(pods) && pods.length > 0 && pods.every(podIsConfirmedPrivate)
35
+ );
36
+
37
+ // The stores claude.js denies by absolute path for a public seat. A start
38
+ // directory inside one of them would put the seat's own credentials in reach.
39
+ const CREDENTIAL_DIRS = ['.commonly', '.claude', '.codex', '.ssh', '.aws', '.config'];
40
+
41
+ const realOrResolved = (p) => {
42
+ try {
43
+ return realpathSync(p);
44
+ } catch {
45
+ return resolve(p);
46
+ }
47
+ };
48
+
49
+ const isInsideOrEqual = (child, parent) => {
50
+ const rel = relative(parent, child);
51
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
52
+ };
53
+
54
+ /**
55
+ * Why `dir` cannot be a workspace seat's start directory, or `null` when it can.
56
+ * Symlinks are resolved first, so a link to `$HOME` is refused as `$HOME`.
57
+ */
58
+ export const workspaceStartDirRefusal = (dir, { home = homedir() } = {}) => {
59
+ if (typeof dir !== 'string' || !isAbsolute(dir)) {
60
+ return 'the start directory is not an absolute path';
61
+ }
62
+ const real = realOrResolved(dir);
63
+ const realHome = realOrResolved(home);
64
+ if (real === '/') return 'the start directory is /';
65
+ if (isInsideOrEqual(realHome, real)) {
66
+ return real === realHome
67
+ ? 'the start directory is your home directory'
68
+ : 'the start directory contains your home directory';
69
+ }
70
+ const store = CREDENTIAL_DIRS.find((name) => isInsideOrEqual(real, join(realHome, name)));
71
+ if (store) return `the start directory is inside ~/${store}`;
72
+ return null;
73
+ };
74
+
75
+ const PUBLIC_POD_REASON = 'a pod it answers in is publicly readable or open to join, or did not report whether it is';
76
+ // claude.js refuses a workspace seat under bwrap, so off macOS the default
77
+ // would be a seat that fails every turn rather than one that reads a project.
78
+ const NOT_MACOS_REASON = 'an agent works in its folder only on macOS for now';
79
+
80
+ /**
81
+ * Where a first-run claude seat may read (TASK-294), from three facts the
82
+ * bootstrap already holds: the adapter it detected, the rooms its token
83
+ * answers in, and the directory the person ran it from. It returns the start
84
+ * directory only when all three allow it, and otherwise the reason the seat
85
+ * keeps the `public` default it had before, plus a `fix` when the person can
86
+ * change the outcome by starting somewhere else. Any other adapter gets `null`:
87
+ * `workspace` is claude-only for now, so there is nothing to decide.
88
+ */
89
+ export const firstRunWorkspace = ({
90
+ adapterName, installations, startDir, home, platform = process.platform,
91
+ } = {}) => {
92
+ if (adapterName !== 'claude') return null;
93
+ if (platform !== 'darwin') return { workspacePath: null, reason: NOT_MACOS_REASON };
94
+ if (!allPodsConfirmedPrivate(installations)) {
95
+ return { workspacePath: null, reason: PUBLIC_POD_REASON };
96
+ }
97
+ const refusal = workspaceStartDirRefusal(startDir, home === undefined ? {} : { home });
98
+ if (refusal) {
99
+ return {
100
+ workspacePath: null,
101
+ reason: refusal,
102
+ fix: 'To let it read a project, run this command from that project\'s folder.',
103
+ };
104
+ }
105
+ return { workspacePath: realOrResolved(startDir), reason: null };
106
+ };
107
+
108
+ /**
109
+ * Where one turn of a seat runs, and under which trust. A pod can turn public
110
+ * after attach, so a `workspace` seat's rooms are re-read before every turn
111
+ * (`installations` is that read, or `null` when it failed). A turn that cannot
112
+ * confirm them all private runs as a `public` seat in its scratch folder: the
113
+ * confinement it would have had without the workspace. Any other trust runs
114
+ * where and as it was started.
115
+ */
116
+ export const workspaceTurnPlacement = ({
117
+ environment, workspacePath, installations, scratchDir,
118
+ } = {}) => {
119
+ const cwd = workspacePath || scratchDir;
120
+ if (environment?.sandbox?.trust !== 'workspace' || allPodsConfirmedPrivate(installations)) {
121
+ return { environment, cwd, reason: null };
122
+ }
123
+ // Its write/web grants widen the workspace tier only, so the public turn
124
+ // drops them rather than carry them into a room strangers can read.
125
+ const { allow: _grants, ...sandbox } = environment.sandbox;
126
+ return {
127
+ environment: { ...environment, sandbox: { ...sandbox, trust: 'public' } },
128
+ cwd: scratchDir,
129
+ reason: installations === null ? 'its pods could not be read' : PUBLIC_POD_REASON,
130
+ };
131
+ };
132
+
133
+ export default {
134
+ podIsConfirmedPrivate,
135
+ allPodsConfirmedPrivate,
136
+ workspaceStartDirRefusal,
137
+ firstRunWorkspace,
138
+ workspaceTurnPlacement,
139
+ };