@commonlyai/cli 0.1.184 → 0.1.185

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.184",
3
+ "version": "0.1.185",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -55,8 +55,10 @@ import {
55
55
  lstat,
56
56
  mkdir,
57
57
  mkdtemp,
58
+ open,
58
59
  readFile,
59
60
  readlink,
61
+ rename,
60
62
  rm,
61
63
  symlink,
62
64
  unlink,
@@ -208,6 +210,29 @@ const statOrNull = async (path) => {
208
210
  }
209
211
  };
210
212
 
213
+ // The non-secret account metadata Claude needs from ~/.claude.json to find its
214
+ // login, and nothing else from that file.
215
+ const claudeAuthMetadata = async () => {
216
+ const operatorConfigPath = join(homedir(), '.claude.json');
217
+ const sourceStat = await statOrNull(operatorConfigPath);
218
+ let source = {};
219
+ if (sourceStat) {
220
+ if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
221
+ throw new Error(`refusing non-regular operator Claude config: ${operatorConfigPath}`);
222
+ }
223
+ try {
224
+ source = JSON.parse(await readFile(operatorConfigPath, 'utf8'));
225
+ } catch (err) {
226
+ throw new Error(`failed to read operator Claude auth metadata: ${err.message}`);
227
+ }
228
+ }
229
+ const sanitized = { hasCompletedOnboarding: true };
230
+ for (const key of ['installMethod', 'userID', 'machineID', 'oauthAccount']) {
231
+ if (source[key] !== undefined) sanitized[key] = source[key];
232
+ }
233
+ return sanitized;
234
+ };
235
+
211
236
  // Claude's macOS OAuth credential lives in Keychain, but Claude also needs a
212
237
  // small amount of non-secret account metadata from ~/.claude.json to locate
213
238
  // it. Never bind or copy the operator's full file: it contains project paths,
@@ -248,35 +273,102 @@ const preparePublicClaudeState = async (ctx) => {
248
273
  await symlink(operatorKeychains, stateKeychains);
249
274
  }
250
275
 
251
- const operatorConfigPath = join(homedir(), '.claude.json');
252
- const sourceStat = await statOrNull(operatorConfigPath);
253
- let source = {};
254
- if (sourceStat) {
255
- if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
256
- throw new Error(`refusing non-regular operator Claude config: ${operatorConfigPath}`);
257
- }
258
- try {
259
- source = JSON.parse(await readFile(operatorConfigPath, 'utf8'));
260
- } catch (err) {
261
- throw new Error(`failed to read operator Claude auth metadata: ${err.message}`);
262
- }
263
- }
264
-
265
- const sanitized = {
266
- hasCompletedOnboarding: true,
267
- };
268
- for (const key of ['installMethod', 'userID', 'machineID', 'oauthAccount']) {
269
- if (source[key] !== undefined) sanitized[key] = source[key];
270
- }
271
276
  await writeFile(
272
277
  configPath,
273
- `${JSON.stringify(sanitized, null, 2)}\n`,
278
+ `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`,
274
279
  { encoding: 'utf8', mode: 0o600 },
275
280
  );
276
281
  await chmod(configPath, 0o600);
277
282
  return { statePath, tmpPath, configPath };
278
283
  };
279
284
 
285
+ // Writes a 0600 file into a directory the confined child can also write. The
286
+ // directory must still be a real directory, and the file is written beside the
287
+ // target and renamed over it, so a symlink the child left at either name is
288
+ // replaced, never followed into another of the person's files.
289
+ const replacePrivateFile = async (dir, name, content) => {
290
+ const dirStat = await statOrNull(dir);
291
+ if (!dirStat || dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
292
+ throw new Error(`refusing to write Claude state into a non-directory: ${dir}`);
293
+ }
294
+ const target = join(dir, name);
295
+ const staged = join(dir, `.${name}.${randomUUID()}.tmp`);
296
+ await writeFile(staged, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
297
+ try {
298
+ await chmod(staged, 0o600);
299
+ await rename(staged, target);
300
+ } catch (err) {
301
+ await rm(staged, { force: true });
302
+ throw err;
303
+ }
304
+ return target;
305
+ };
306
+
307
+ // A workspace seat on Linux runs under bwrap, which has no keychain. Claude
308
+ // Code on Linux keeps its login in <config dir>/.credentials.json, so the
309
+ // seat's own state dir gets a copy of the person's, taken fresh each spawn:
310
+ // their own credential on their own machine, the exposure the macOS keychain
311
+ // arm already has. Only the copy is inside the jail. Nothing is written back:
312
+ // the jail can write its copy, and a write-back would let it replace the
313
+ // person's login with one of its choosing. A missing login removes the copy,
314
+ // so a person who logged out is not still signed in here.
315
+ const claudeLoginSource = (env) => join(
316
+ env?.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
317
+ '.credentials.json',
318
+ );
319
+
320
+ // Opened once, without following a symlink, and checked on the open handle,
321
+ // so the file read is the file checked (a FIFO cannot hold the open either).
322
+ const carryClaudeLogin = async (env, configDir) => {
323
+ const source = claudeLoginSource(env);
324
+ const refuse = () => new Error(`refusing a Claude login that is not a regular file: ${source}`);
325
+ let handle;
326
+ try {
327
+ handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
328
+ } catch (err) {
329
+ if (err?.code === 'ENOENT') {
330
+ await rm(join(configDir, '.credentials.json'), { force: true });
331
+ return null;
332
+ }
333
+ if (err?.code === 'ELOOP') throw refuse();
334
+ throw err;
335
+ }
336
+ try {
337
+ if (!(await handle.stat()).isFile()) throw refuse();
338
+ return replacePrivateFile(configDir, '.credentials.json', await handle.readFile('utf8'));
339
+ } finally {
340
+ await handle.close();
341
+ }
342
+ };
343
+
344
+ // State for a workspace seat under bwrap: the same per-seat folder the macOS
345
+ // path uses, outside the person's project, with the auth metadata whitelist
346
+ // and the login copy. The jail binds this folder writable and makes it HOME,
347
+ // so transcripts land here rather than in the project.
348
+ const prepareWorkspaceBwrapClaudeState = async (ctx) => {
349
+ const identity = ctx.agentName || ctx.cwd || 'anonymous';
350
+ const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
351
+ const statePath = ctx._publicClaudeState || publicClaudeStateRoot(identityHash);
352
+ const tmpPath = join(statePath, 'tmp');
353
+ const configDir = join(statePath, '.claude');
354
+ await mkdir(statePath, { recursive: true, mode: 0o700 });
355
+ await chmod(statePath, 0o700);
356
+ for (const dir of [tmpPath, configDir]) {
357
+ const existing = await statOrNull(dir);
358
+ if (existing && (existing.isSymbolicLink() || !existing.isDirectory())) {
359
+ throw new Error(`refusing Claude state path that is not a directory: ${dir}`);
360
+ }
361
+ if (!existing) await mkdir(dir, { mode: 0o700 });
362
+ await chmod(dir, 0o700);
363
+ }
364
+
365
+ const configPath = await replacePrivateFile(
366
+ statePath, '.claude.json', `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`,
367
+ );
368
+ const loginPath = await carryClaudeLogin(ctx.env, configDir);
369
+ return { statePath, tmpPath, configPath, loginPath };
370
+ };
371
+
280
372
  const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null, publicSeat = false) => {
281
373
  const source = input || process.env;
282
374
  const output = state || publicSeat ? {} : { ...source };
@@ -732,6 +824,9 @@ const prepareArgv = async (innerArgv, ctx) => {
732
824
  // get the jail only — a derived Linux seat kept Bash/Write/WebFetch and ran on
733
825
  // the operator's own Claude settings inside the namespace (Vera 69578).
734
826
  const publicBwrapSandbox = sandboxTrust === 'public' && sandboxMode === 'bwrap';
827
+ // A workspace seat on Linux: the same jail, with the workspace tier's policy
828
+ // and a HOME of its own outside the project.
829
+ const workspaceBwrapSandbox = sandboxTrust === 'workspace' && sandboxMode === 'bwrap';
735
830
  const commitHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_HOOKS_PATH || null;
736
831
  const originalHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH || null;
737
832
  const commitHookExecutables = commitHooksPath
@@ -754,12 +849,6 @@ const prepareArgv = async (innerArgv, ctx) => {
754
849
  // non-string) used to fall through to the bare `claude` spawn below — an
755
850
  // unconfined seat with a public record, which is how a record can claim
756
851
  // confinement it never applies (Vera 69548).
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
852
  if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') {
764
853
  throw new Error(
765
854
  `${sandboxTrust} Claude agents require an enforced sandbox mode `
@@ -818,23 +907,71 @@ const prepareArgv = async (innerArgv, ctx) => {
818
907
  };
819
908
  }
820
909
 
821
- if (publicBwrapSandbox) {
910
+ if (publicBwrapSandbox || workspaceBwrapSandbox) {
822
911
  // The mode is chosen here, so the mechanism is verified here: a missing
823
912
  // bwrap must fail the seat's derivation with an actionable message, not
824
913
  // every one of its spawns with a bare ENOENT (Wren 69586). On a non-Linux
825
914
  // host this reports the macOS-only message, same as the wrapper would.
826
915
  const bwrap = (ctx._detectBwrap || detectBwrap)();
827
916
  if (!bwrap.available) {
828
- throw new Error(`public Claude agents require bwrap: ${bwrap.error}`);
917
+ throw new Error(`${sandboxTrust} Claude agents require bwrap: ${bwrap.error}`);
918
+ }
919
+ if (workspaceBwrapSandbox && !ctx.publicClaudeState) {
920
+ throw new Error('workspace Claude state was not prepared before argv construction');
829
921
  }
830
922
  innerArgv = [
831
923
  ...innerArgv,
832
- ...buildPublicClaudePolicyArgs(allowedPatterns, { memoryWithheld: ctx.memoryWithheld === true }),
924
+ ...buildPublicClaudePolicyArgs(allowedPatterns, workspaceBwrapSandbox
925
+ ? { trust: sandboxTrust, allow: grants, memoryWithheld: ctx.memoryWithheld === true }
926
+ : { memoryWithheld: ctx.memoryWithheld === true }),
833
927
  ...(ctx.publicSkillArgs || []),
834
928
  ];
835
929
  } else if (allowedPatterns.length > 0) {
836
930
  innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
837
931
  }
932
+ if (workspaceBwrapSandbox) {
933
+ // Seatbelt denies writes under .git by path, present or not. A bwrap mount
934
+ // needs the path to exist, so a writable project without a .git could get
935
+ // one from the child, hooks included, that the person's next git command
936
+ // runs. Refuse that rather than create anything in their project.
937
+ if (grants.includes('write')) {
938
+ const gitEntry = await statOrNull(join(ctx.cwd, '.git'));
939
+ if (!gitEntry || gitEntry.isSymbolicLink()) {
940
+ throw new Error('workspace Claude agents with write on Linux need the project to be a git '
941
+ + `repository (run \`git init\` in ${ctx.cwd}), so its .git can be held read-only`);
942
+ }
943
+ }
944
+ const claudeBin = resolveClaudePath(claudeEnv);
945
+ const { statePath } = ctx.publicClaudeState;
946
+ let claudeInstall = null;
947
+ try {
948
+ claudeInstall = dirname(realpathSync(claudeBin));
949
+ } catch { /* an unresolvable binary fails at spawn with its own error */ }
950
+ const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
951
+ workspacePath: ctx.cwd,
952
+ workspaceReadOnly: !grants.includes('write'),
953
+ readOnlyWorkspaceEntries: ['.git'],
954
+ homePath: statePath,
955
+ writablePaths: [statePath],
956
+ readOnlyPaths: [
957
+ ctx.mcpConfigDir, commitHooksPath, originalHooksPath,
958
+ ...(ctx.mcpReadDirectories || []),
959
+ ...(ctx.workspaceGit?.readDirectories || []),
960
+ ...(ctx.workspaceGit?.readPaths || []),
961
+ ].filter(Boolean),
962
+ // The binaries the jail runs: claude (a user-local install is outside
963
+ // the default binds, and its link points into a versions folder), the
964
+ // node that runs the stdio MCP servers, and the git the history tool runs.
965
+ optionalReadOnlyPaths: [...new Set([
966
+ dirname(claudeBin), claudeInstall, dirname(process.execPath),
967
+ ctx.workspaceGit ? dirname(ctx.workspaceGit.executable) : null,
968
+ // A claude not found on PATH is left bare and fails at spawn; its
969
+ // dirname is '.', which is not a path to bind.
970
+ ].filter((p) => p && isAbsolute(p)))],
971
+ });
972
+ // TASK-440 reads the transcript from the HOME the child wrote under.
973
+ return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv, childHome: statePath };
974
+ }
838
975
  if (sandboxMode === 'bwrap') {
839
976
  const claudeBin = resolveClaudePath(claudeEnv);
840
977
  const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
@@ -974,6 +1111,8 @@ export default {
974
1111
  && PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox));
975
1112
  const publicBwrapSandbox = publicTrust
976
1113
  && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
1114
+ const workspaceBwrapSandbox = ctx.environment?.sandbox?.trust === 'workspace'
1115
+ && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
977
1116
  try {
978
1117
  // Trust is normalized at entry (legacy `internal` reads as public). The
979
1118
  // MCP policy follows trust alone, whatever confines the seat: a public
@@ -981,6 +1120,12 @@ export default {
981
1120
  // (Rhea on #2247).
982
1121
  if (publicNativeSandbox) {
983
1122
  publicClaudeState = await preparePublicClaudeState(ctx);
1123
+ } else if (workspaceBwrapSandbox) {
1124
+ // Checked before the state is prepared, so a host that cannot run the
1125
+ // jail never gets a copy of the login.
1126
+ const bwrap = (ctx._detectBwrap || detectBwrap)();
1127
+ if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`);
1128
+ publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx);
984
1129
  }
985
1130
  if (publicTrust) {
986
1131
  const prompt = await publicSkillPromptArgs(mountedSkills);
@@ -161,7 +161,29 @@ export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => {
161
161
  flags.push('--ro-bind-try', p, p);
162
162
  }
163
163
 
164
- flags.push('--bind', opts.workspacePath, opts.workspacePath);
164
+ // A HOME the adapter keeps outside the workspace (a workspace seat's state)
165
+ // is bound writable on its own, so the child's transcripts and login stay
166
+ // out of the person's project.
167
+ for (const p of opts.writablePaths || []) {
168
+ if (!p || !isAbsolute(p)) {
169
+ throw new Error('wrapArgvWithBwrap opts.writablePaths entries must be absolute paths');
170
+ }
171
+ flags.push('--bind', p, p);
172
+ }
173
+
174
+ flags.push(opts.workspaceReadOnly ? '--ro-bind' : '--bind', opts.workspacePath, opts.workspacePath);
175
+ // Entries held read-only inside a writable workspace (its .git). Bound after
176
+ // the workspace so they land on top of it. In a writable workspace a missing
177
+ // entry cannot be protected — the child could create it, and a planted
178
+ // .git/hooks runs on the person's next git command — so the bind is strict
179
+ // there and bwrap fails rather than starts.
180
+ for (const entry of opts.readOnlyWorkspaceEntries || []) {
181
+ if (!entry || isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) {
182
+ throw new Error('wrapArgvWithBwrap opts.readOnlyWorkspaceEntries entries must be relative names inside the workspace');
183
+ }
184
+ const p = join(opts.workspacePath, entry);
185
+ flags.push(opts.workspaceReadOnly ? '--ro-bind-try' : '--ro-bind', p, p);
186
+ }
165
187
  flags.push('--chdir', opts.workspacePath);
166
188
  flags.push('--setenv', 'HOME', opts.homePath || opts.workspacePath);
167
189