@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 +1 -1
- package/src/lib/adapters/claude.js +175 -30
- package/src/lib/sandbox/bwrap.js +23 -1
package/package.json
CHANGED
|
@@ -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(
|
|
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(
|
|
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,
|
|
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);
|
package/src/lib/sandbox/bwrap.js
CHANGED
|
@@ -161,7 +161,29 @@ export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => {
|
|
|
161
161
|
flags.push('--ro-bind-try', p, p);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
|
|
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
|
|