@commonlyai/cli 0.1.184 → 0.1.186
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 +31 -2
- package/src/lib/adapters/claude.js +193 -30
- package/src/lib/sandbox/bwrap.js +23 -1
- package/src/lib/sandbox/host-check.js +14 -8
- package/src/lib/workspace-trust.js +6 -3
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -1563,13 +1563,15 @@ export const performRun = ({
|
|
|
1563
1563
|
return { environment: seatEnvironment, cwd: agentCwd, binding: sessionBinding };
|
|
1564
1564
|
}
|
|
1565
1565
|
let installations = null;
|
|
1566
|
+
let readError = null;
|
|
1566
1567
|
try {
|
|
1567
1568
|
const identity = await client.get('/api/agents/runtime/installations', {}, {
|
|
1568
1569
|
signal: AbortSignal.timeout(5000),
|
|
1569
1570
|
});
|
|
1570
1571
|
installations = Array.isArray(identity?.installations) ? identity.installations : [];
|
|
1571
|
-
} catch {
|
|
1572
|
+
} catch (err) {
|
|
1572
1573
|
installations = null;
|
|
1574
|
+
readError = err;
|
|
1573
1575
|
}
|
|
1574
1576
|
const placement = workspaceTurnPlacement({
|
|
1575
1577
|
environment: seatEnvironment, workspacePath: agentCwd, installations, scratchDir: scratchCwd,
|
|
@@ -1582,7 +1584,7 @@ export const performRun = ({
|
|
|
1582
1584
|
}
|
|
1583
1585
|
if (!existsSync(placement.cwd)) mkdirSync(placement.cwd, { recursive: true });
|
|
1584
1586
|
const where = `${placement.environment.sandbox.trust}:${placement.cwd}`;
|
|
1585
|
-
return { ...placement, binding: sessionBinding ? `${sessionBinding}:${where}` : where };
|
|
1587
|
+
return { ...placement, readError, binding: sessionBinding ? `${sessionBinding}:${where}` : where };
|
|
1586
1588
|
};
|
|
1587
1589
|
const batchLimit = Math.min(50, Math.max(1, Number(inboxBatchLimit) || 10));
|
|
1588
1590
|
|
|
@@ -1835,6 +1837,33 @@ export const performRun = ({
|
|
|
1835
1837
|
event, eventPodId, prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger,
|
|
1836
1838
|
}) => {
|
|
1837
1839
|
const placement = await placeTurn();
|
|
1840
|
+
// A workspace turn placed public (its pod turned public, or its pods could
|
|
1841
|
+
// not be read) is confined as a public seat, which this host may not be
|
|
1842
|
+
// able to do: Claude Code on Linux has only the workspace jail. Refuse it
|
|
1843
|
+
// as the run start would, before anything is read or spawned. The model
|
|
1844
|
+
// never runs and nothing is posted. A public pod is a settled answer, so a
|
|
1845
|
+
// human's message is handed to another listener (claim-outcome.js). A
|
|
1846
|
+
// failed read is not: it throws, so the event stays unacked and comes back
|
|
1847
|
+
// through the spawn-retry backoff instead of being refused for good.
|
|
1848
|
+
if (placement.reason) {
|
|
1849
|
+
const turnRefusal = hostSandboxRefusal({ environment: placement.environment, adapterName: adapter.name });
|
|
1850
|
+
if (turnRefusal && placement.readError) {
|
|
1851
|
+
throw Object.assign(
|
|
1852
|
+
new Error(`turn deferred, because its pods could not be read: ${placement.readError.message}`, {
|
|
1853
|
+
cause: placement.readError,
|
|
1854
|
+
}),
|
|
1855
|
+
{ status: placement.readError.status },
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
if (turnRefusal) {
|
|
1859
|
+
log(`turn refused, because ${placement.reason}: ${turnRefusal.replace(/\n\s*/g, ' ')}`);
|
|
1860
|
+
return {
|
|
1861
|
+
outcome: 'no_action',
|
|
1862
|
+
refused: { reason: 'delivery-refused' },
|
|
1863
|
+
reason: 'host-cannot-confine-turn',
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1838
1867
|
// TASK-440: a cold, large session starts fresh at wake instead of being
|
|
1839
1868
|
// resumed. The restart note leads the prompt; memory still reaches the
|
|
1840
1869
|
// fresh session through the adapter's preamble.
|
|
@@ -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 };
|
|
@@ -696,6 +788,25 @@ const resolveClaudePath = (env = process.env) => {
|
|
|
696
788
|
return 'claude';
|
|
697
789
|
};
|
|
698
790
|
|
|
791
|
+
// Where the claude this host would run is installed: the folder the Linux
|
|
792
|
+
// workspace jail has to bind, since it binds the system binary folders only.
|
|
793
|
+
// Null when the binary cannot be located (a bare name, a dangling link).
|
|
794
|
+
const resolveClaudeInstall = (env) => {
|
|
795
|
+
const claudeBin = resolveClaudePath(env);
|
|
796
|
+
if (!isAbsolute(claudeBin)) return { claudeBin, claudeInstall: null };
|
|
797
|
+
try {
|
|
798
|
+
return { claudeBin, claudeInstall: dirname(realpathSync(claudeBin)) };
|
|
799
|
+
} catch {
|
|
800
|
+
return { claudeBin, claudeInstall: null };
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// A jail that cannot carry claude would fail inside at every spawn.
|
|
805
|
+
const claudeInstallRefusal = (claudeBin) => new Error(
|
|
806
|
+
'workspace Claude agents on Linux need Claude Code on PATH, so the sandbox can carry its install; '
|
|
807
|
+
+ `\`${claudeBin}\` could not be resolved`,
|
|
808
|
+
);
|
|
809
|
+
|
|
699
810
|
const prepareArgv = async (innerArgv, ctx) => {
|
|
700
811
|
const env = ctx.environment;
|
|
701
812
|
const claudeEnv = withClaudePath(ctx.claudeEnv);
|
|
@@ -732,6 +843,9 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
732
843
|
// get the jail only — a derived Linux seat kept Bash/Write/WebFetch and ran on
|
|
733
844
|
// the operator's own Claude settings inside the namespace (Vera 69578).
|
|
734
845
|
const publicBwrapSandbox = sandboxTrust === 'public' && sandboxMode === 'bwrap';
|
|
846
|
+
// A workspace seat on Linux: the same jail, with the workspace tier's policy
|
|
847
|
+
// and a HOME of its own outside the project.
|
|
848
|
+
const workspaceBwrapSandbox = sandboxTrust === 'workspace' && sandboxMode === 'bwrap';
|
|
735
849
|
const commitHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_HOOKS_PATH || null;
|
|
736
850
|
const originalHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH || null;
|
|
737
851
|
const commitHookExecutables = commitHooksPath
|
|
@@ -754,12 +868,6 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
754
868
|
// non-string) used to fall through to the bare `claude` spawn below — an
|
|
755
869
|
// unconfined seat with a public record, which is how a record can claim
|
|
756
870
|
// 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
871
|
if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') {
|
|
764
872
|
throw new Error(
|
|
765
873
|
`${sandboxTrust} Claude agents require an enforced sandbox mode `
|
|
@@ -818,23 +926,68 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
818
926
|
};
|
|
819
927
|
}
|
|
820
928
|
|
|
821
|
-
if (publicBwrapSandbox) {
|
|
929
|
+
if (publicBwrapSandbox || workspaceBwrapSandbox) {
|
|
822
930
|
// The mode is chosen here, so the mechanism is verified here: a missing
|
|
823
931
|
// bwrap must fail the seat's derivation with an actionable message, not
|
|
824
932
|
// every one of its spawns with a bare ENOENT (Wren 69586). On a non-Linux
|
|
825
933
|
// host this reports the macOS-only message, same as the wrapper would.
|
|
826
934
|
const bwrap = (ctx._detectBwrap || detectBwrap)();
|
|
827
935
|
if (!bwrap.available) {
|
|
828
|
-
throw new Error(
|
|
936
|
+
throw new Error(`${sandboxTrust} Claude agents require bwrap: ${bwrap.error}`);
|
|
937
|
+
}
|
|
938
|
+
if (workspaceBwrapSandbox && !ctx.publicClaudeState) {
|
|
939
|
+
throw new Error('workspace Claude state was not prepared before argv construction');
|
|
829
940
|
}
|
|
830
941
|
innerArgv = [
|
|
831
942
|
...innerArgv,
|
|
832
|
-
...buildPublicClaudePolicyArgs(allowedPatterns,
|
|
943
|
+
...buildPublicClaudePolicyArgs(allowedPatterns, workspaceBwrapSandbox
|
|
944
|
+
? { trust: sandboxTrust, allow: grants, memoryWithheld: ctx.memoryWithheld === true }
|
|
945
|
+
: { memoryWithheld: ctx.memoryWithheld === true }),
|
|
833
946
|
...(ctx.publicSkillArgs || []),
|
|
834
947
|
];
|
|
835
948
|
} else if (allowedPatterns.length > 0) {
|
|
836
949
|
innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
|
|
837
950
|
}
|
|
951
|
+
if (workspaceBwrapSandbox) {
|
|
952
|
+
// Seatbelt denies writes under .git by path, present or not. A bwrap mount
|
|
953
|
+
// needs the path to exist, so a writable project without a .git could get
|
|
954
|
+
// one from the child, hooks included, that the person's next git command
|
|
955
|
+
// runs. Refuse that rather than create anything in their project.
|
|
956
|
+
if (grants.includes('write')) {
|
|
957
|
+
const gitEntry = await statOrNull(join(ctx.cwd, '.git'));
|
|
958
|
+
if (!gitEntry || gitEntry.isSymbolicLink()) {
|
|
959
|
+
throw new Error('workspace Claude agents with write on Linux need the project to be a git '
|
|
960
|
+
+ `repository (run \`git init\` in ${ctx.cwd}), so its .git can be held read-only`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
const { claudeBin, claudeInstall } = resolveClaudeInstall(claudeEnv);
|
|
964
|
+
if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
|
|
965
|
+
const { statePath } = ctx.publicClaudeState;
|
|
966
|
+
const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
|
|
967
|
+
workspacePath: ctx.cwd,
|
|
968
|
+
workspaceReadOnly: !grants.includes('write'),
|
|
969
|
+
readOnlyWorkspaceEntries: ['.git'],
|
|
970
|
+
homePath: statePath,
|
|
971
|
+
writablePaths: [statePath],
|
|
972
|
+
readOnlyPaths: [
|
|
973
|
+
ctx.mcpConfigDir, commitHooksPath, originalHooksPath,
|
|
974
|
+
...(ctx.mcpReadDirectories || []),
|
|
975
|
+
...(ctx.workspaceGit?.readDirectories || []),
|
|
976
|
+
...(ctx.workspaceGit?.readPaths || []),
|
|
977
|
+
].filter(Boolean),
|
|
978
|
+
// The binaries the jail runs: claude (a user-local install is outside
|
|
979
|
+
// the default binds, and its link points into a versions folder), the
|
|
980
|
+
// node that runs the stdio MCP servers, and the git the history tool runs.
|
|
981
|
+
optionalReadOnlyPaths: [...new Set([
|
|
982
|
+
dirname(claudeBin), claudeInstall, dirname(process.execPath),
|
|
983
|
+
ctx.workspaceGit ? dirname(ctx.workspaceGit.executable) : null,
|
|
984
|
+
// A claude not found on PATH is left bare and fails at spawn; its
|
|
985
|
+
// dirname is '.', which is not a path to bind.
|
|
986
|
+
].filter((p) => p && isAbsolute(p)))],
|
|
987
|
+
});
|
|
988
|
+
// TASK-440 reads the transcript from the HOME the child wrote under.
|
|
989
|
+
return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv, childHome: statePath };
|
|
990
|
+
}
|
|
838
991
|
if (sandboxMode === 'bwrap') {
|
|
839
992
|
const claudeBin = resolveClaudePath(claudeEnv);
|
|
840
993
|
const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
|
|
@@ -974,6 +1127,8 @@ export default {
|
|
|
974
1127
|
&& PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox));
|
|
975
1128
|
const publicBwrapSandbox = publicTrust
|
|
976
1129
|
&& resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
|
|
1130
|
+
const workspaceBwrapSandbox = ctx.environment?.sandbox?.trust === 'workspace'
|
|
1131
|
+
&& resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
|
|
977
1132
|
try {
|
|
978
1133
|
// Trust is normalized at entry (legacy `internal` reads as public). The
|
|
979
1134
|
// MCP policy follows trust alone, whatever confines the seat: a public
|
|
@@ -981,6 +1136,14 @@ export default {
|
|
|
981
1136
|
// (Rhea on #2247).
|
|
982
1137
|
if (publicNativeSandbox) {
|
|
983
1138
|
publicClaudeState = await preparePublicClaudeState(ctx);
|
|
1139
|
+
} else if (workspaceBwrapSandbox) {
|
|
1140
|
+
// Checked before the state is prepared, so a host that cannot run the
|
|
1141
|
+
// jail never gets a copy of the login.
|
|
1142
|
+
const bwrap = (ctx._detectBwrap || detectBwrap)();
|
|
1143
|
+
if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`);
|
|
1144
|
+
const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
|
|
1145
|
+
if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
|
|
1146
|
+
publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx);
|
|
984
1147
|
}
|
|
985
1148
|
if (publicTrust) {
|
|
986
1149
|
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
|
|
|
@@ -46,15 +46,17 @@ export const windowsSandboxMessage = (adapterName) => {
|
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Claude Code on Linux (WSL included)
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
49
|
+
* Claude Code on Linux (WSL included) for any seat but a workspace one. Only
|
|
50
|
+
* the workspace jail (#2784) carries what Claude needs: its install, a HOME
|
|
51
|
+
* of its own and a copy of the login. The public jail binds the system binary
|
|
52
|
+
* folders only, so a confined spawn could not even start Claude (Ubuntu run,
|
|
53
|
+
* 2026-10-16), and giving it the login would hand strangers a jail that holds
|
|
54
|
+
* it. A workspace seat needs every pod it answers in to be invite-only.
|
|
54
55
|
*/
|
|
55
56
|
export const linuxClaudeMessage = () => [
|
|
56
|
-
'Commonly runs a connected Claude Code inside a sandbox
|
|
57
|
-
'
|
|
57
|
+
'Commonly runs a connected Claude Code inside a sandbox. On Linux (WSL included) it does that only',
|
|
58
|
+
'for an agent whose pods are all invite-only, and this one is not, so it cannot listen for @mentions here. Instead:',
|
|
59
|
+
' - From an invite-only pod: run the commands its Connect page shows, from your project\'s folder.',
|
|
58
60
|
' - Use Commonly from your own Claude Code sessions: the Connect page shows a `claude mcp add` command.',
|
|
59
61
|
HOSTED_LINE,
|
|
60
62
|
].join('\n');
|
|
@@ -76,7 +78,11 @@ export const hostSandboxRefusal = ({
|
|
|
76
78
|
if (mode !== 'bwrap' && !SEATBELT_MODES.has(mode)) return null;
|
|
77
79
|
|
|
78
80
|
if (platform === 'win32') return windowsSandboxMessage(adapterName);
|
|
79
|
-
|
|
81
|
+
// A workspace claude seat falls through to the bubblewrap probe below; the
|
|
82
|
+
// probe failing, for any reason, is a refusal like any other adapter's.
|
|
83
|
+
if (mode === 'bwrap' && adapterName === 'claude' && sandbox.trust !== 'workspace') {
|
|
84
|
+
return linuxClaudeMessage();
|
|
85
|
+
}
|
|
80
86
|
if (mode === 'bwrap') {
|
|
81
87
|
const bwrap = detectBwrap();
|
|
82
88
|
return bwrap.available ? null : `This seat runs in a bubblewrap sandbox, which is not available here. ${bwrap.error}`;
|
|
@@ -73,9 +73,10 @@ export const workspaceStartDirRefusal = (dir, { home = homedir() } = {}) => {
|
|
|
73
73
|
};
|
|
74
74
|
|
|
75
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
|
|
76
|
+
// claude.js runs a workspace seat under Seatbelt on macOS and under bwrap on
|
|
77
|
+
// Linux (#2784). Native Windows has no sandbox for it, so there the default
|
|
77
78
|
// would be a seat that fails every turn rather than one that reads a project.
|
|
78
|
-
const
|
|
79
|
+
const UNSUPPORTED_OS_REASON = 'an agent works in its folder only on macOS and Linux for now';
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
82
|
* Where a first-run claude seat may read (TASK-294), from three facts the
|
|
@@ -90,7 +91,9 @@ export const firstRunWorkspace = ({
|
|
|
90
91
|
adapterName, installations, startDir, home, platform = process.platform,
|
|
91
92
|
} = {}) => {
|
|
92
93
|
if (adapterName !== 'claude') return null;
|
|
93
|
-
if (platform !== 'darwin'
|
|
94
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
95
|
+
return { workspacePath: null, reason: UNSUPPORTED_OS_REASON };
|
|
96
|
+
}
|
|
94
97
|
if (!allPodsConfirmedPrivate(installations)) {
|
|
95
98
|
return { workspacePath: null, reason: PUBLIC_POD_REASON };
|
|
96
99
|
}
|