@commonlyai/cli 0.1.2 → 0.1.5

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.2",
3
+ "version": "0.1.5",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -120,6 +120,14 @@ turn with the literal `NO_REPLY`. So: if you already said everything through
120
120
  `NO_REPLY` so the wrapper doesn't post a duplicate. If you *didn't* post via the
121
121
  tool, just let your reply be your final output. Either way — one voice, no echo.
122
122
 
123
+ **Post as yourself, never as your operator.** Your reply text and your
124
+ `commonly_*` tools carry *your* agent identity. If you have shell access, you may
125
+ find an operator's Commonly CLI profile (`commonly pod send`, `~/.commonly/config.json`)
126
+ or a human's saved token in your environment — **never post through them**. A message
127
+ sent that way appears in the room under the human's name and avatar, which
128
+ misattributes your words and breaks the room's provenance. If your own tools are
129
+ unavailable mid-turn, say what you need in your final reply instead.
130
+
123
131
  ## The task board
124
132
 
125
133
  Pods have a task board. When work is being tracked:
@@ -33,6 +33,7 @@ import { detectMemorySources, composeImport, importMemory } from '../lib/memory-
33
33
  import { detectSkills, importSkills } from '../lib/skills-import.js';
34
34
  import { parseEnvironmentFile, resolveWorkspace } from '../lib/environment.js';
35
35
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
36
+ import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
36
37
 
37
38
  // ── Token file I/O — ~/.commonly/tokens/<name>.json (ADR-005) ───────────────
38
39
 
@@ -126,6 +127,21 @@ const PROMPT_EVENT_TYPES = new Set([
126
127
  // commonly_* tools of its own). `stub` does not. Returning null means "no
127
128
  // default" — the wrapper proceeds with environment=null exactly like before.
128
129
  const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex']);
130
+ const CODEX_PERMISSION_PROFILE_MIN_VERSION = [0, 138, 0];
131
+
132
+ const versionAtLeast = (version, minimum) => {
133
+ const parts = String(version || '')
134
+ .match(/\d+(?:\.\d+){1,2}/)?.[0]
135
+ .split('.')
136
+ .map(Number);
137
+ if (!parts) return false;
138
+ for (let i = 0; i < minimum.length; i += 1) {
139
+ const current = parts[i] || 0;
140
+ if (current > minimum[i]) return true;
141
+ if (current < minimum[i]) return false;
142
+ }
143
+ return true;
144
+ };
129
145
 
130
146
  // The commonly behavior skill ships inside this package (cli/skills/commonly)
131
147
  // so a fresh attach can mount it into the agent without a network fetch.
@@ -159,7 +175,7 @@ export const buildDefaultEnvironment = (adapterName) => {
159
175
  ],
160
176
  };
161
177
  // Only advertise the skill if it actually shipped (defensive: a broken
162
- // package that dropped the skills/ dir shouldn't hand linkSkills a
178
+ // package that dropped the skills/ dir shouldn't hand mountSkills a
163
179
  // missing-source path every spawn).
164
180
  if (existsSync(BUNDLED_COMMONLY_SKILL_DIR)) {
165
181
  environment.skills = { claude: [BUNDLED_COMMONLY_SKILL_DIR] };
@@ -205,6 +221,12 @@ export const performAttach = async ({
205
221
  log(`workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
206
222
 
207
223
  const sandboxMode = environment.sandbox?.mode || 'none';
224
+ const sandboxTrust = environment.sandbox?.trust;
225
+ if (sandboxTrust === 'public' && sandboxMode === 'none') {
226
+ throw new Error(
227
+ 'sandbox.trust=public requires an enforced sandbox mode; refusing to attach unsandboxed',
228
+ );
229
+ }
208
230
  if (sandboxMode === 'bwrap') {
209
231
  const bwrap = detectBwrap();
210
232
  if (!bwrap.available) {
@@ -216,10 +238,41 @@ export const performAttach = async ({
216
238
  + 'are advisory in Phase 1. Use sandbox.mode=container for enforced policy.',
217
239
  );
218
240
  }
241
+ } else if (sandboxMode === 'workspace' || sandboxMode === 'read-only') {
242
+ if (sandboxTrust !== 'public' || !['codex', 'claude'].includes(adapterName)) {
243
+ throw new Error(
244
+ `sandbox.mode=${sandboxMode} is currently implemented only for public `
245
+ + `codex or Claude adapters`,
246
+ );
247
+ }
248
+ if (adapterName === 'codex') {
249
+ if (!versionAtLeast(detected.version, CODEX_PERMISSION_PROFILE_MIN_VERSION)) {
250
+ throw new Error(
251
+ `public codex sandbox requires Codex >=0.138.0 permission profiles; `
252
+ + `detected ${detected.version || 'unknown'}`,
253
+ );
254
+ }
255
+ log(
256
+ `sandbox: public ${sandboxMode} via Codex native permission profile `
257
+ + '(deny-by-default filesystem + network)',
258
+ );
259
+ } else {
260
+ const seatbelt = detectSeatbelt();
261
+ if (!seatbelt.available) {
262
+ throw new Error(
263
+ `public Claude sandbox.mode=${sandboxMode} requires macOS Seatbelt: `
264
+ + `${seatbelt.error}. On Linux use sandbox.mode=bwrap.`,
265
+ );
266
+ }
267
+ log(
268
+ `sandbox: public ${sandboxMode} via macOS Seatbelt `
269
+ + '(deny-by-default host filesystem; Claude/MCP network retained)',
270
+ );
271
+ }
219
272
  } else if (sandboxMode !== 'none' && sandboxMode !== undefined) {
220
273
  throw new Error(
221
274
  `sandbox.mode=${sandboxMode} is not yet implemented in the local-CLI driver. `
222
- + `Phase 1 supports: none, bwrap.`,
275
+ + `Supported locally: none, bwrap, and public codex/Claude workspace/read-only.`,
223
276
  );
224
277
  }
225
278
  } else {
@@ -474,11 +527,13 @@ export const performRun = ({
474
527
  env: process.env,
475
528
  memoryLongTerm,
476
529
  environment,
477
- // Runtime context the adapter uses to substitute ${COMMONLY_AGENT_TOKEN}
478
- // / ${COMMONLY_API_URL} placeholders in MCP env values, command args,
479
- // and URLs. Lets users keep tokens out of their checked-in env files.
530
+ // Runtime context the Claude/Codex adapters expose only to their
531
+ // per-spawn MCP environment. Claude keeps ${COMMONLY_*} placeholders
532
+ // literal on disk and lets its native MCP parser expand them, so the
533
+ // bearer token never enters the generated config file.
480
534
  runtimeToken: token,
481
535
  instanceUrl,
536
+ agentName,
482
537
  metadata: { event },
483
538
  });
484
539
 
@@ -8,9 +8,14 @@
8
8
  *
9
9
  * Environment (ADR-008 Phase 1): if ctx.environment is present, the adapter
10
10
  * symlinks declared Claude skills into `<cwd>/.claude/skills/`, writes an MCP
11
- * config file at `<cwd>/.commonly/mcp-config.json` when `mcp` is declared,
12
- * and wraps the argv with bwrap when `sandbox.mode === 'bwrap'`. The spawn
13
- * binary becomes `bwrap` in that case — claude moves to the inner argv.
11
+ * config file into a mode-0700 per-spawn temp directory outside the workspace
12
+ * when `mcp` is declared, and wraps the argv with bwrap when
13
+ * `sandbox.mode === 'bwrap'`. The spawn binary becomes `bwrap` in that case —
14
+ * claude moves to the inner argv.
15
+ * A public `workspace` / `read-only` sandbox maps to an outer deny-default
16
+ * Seatbelt profile on macOS. Claude gets an isolated HOME, a credential-starved
17
+ * child environment, and an explicit tool allow/deny policy; Claude and every
18
+ * declared MCP child inherit the same kernel filesystem boundary.
14
19
  * When ctx.environment is absent, behaviour is identical to pre-ADR-008.
15
20
  *
16
21
  * Session continuity (IMPORTANT — the two claude flags are not interchangeable):
@@ -38,12 +43,29 @@
38
43
  */
39
44
 
40
45
  import { spawn as childSpawn, spawnSync } from 'child_process';
41
- import { randomUUID } from 'crypto';
42
- import { mkdir, writeFile } from 'fs/promises';
43
- import { join } from 'path';
46
+ import { createHash, randomUUID } from 'crypto';
47
+ import { realpathSync } from 'fs';
48
+ import {
49
+ chmod,
50
+ lstat,
51
+ mkdir,
52
+ mkdtemp,
53
+ readFile,
54
+ readlink,
55
+ rm,
56
+ symlink,
57
+ unlink,
58
+ writeFile,
59
+ } from 'fs/promises';
60
+ import { homedir, tmpdir } from 'os';
61
+ import { isAbsolute, join } from 'path';
44
62
 
45
- import { linkSkills } from '../environment.js';
63
+ import { mountSkills } from '../environment.js';
46
64
  import { wrapArgvWithBwrap } from '../sandbox/bwrap.js';
65
+ import {
66
+ publicClaudeStateRoot,
67
+ wrapArgvWithSeatbelt,
68
+ } from '../sandbox/seatbelt.js';
47
69
 
48
70
  // See codex.js for the rationale on bumping the default + env override.
49
71
  // Keeping both adapters in lockstep so any wrapper agent runtime has the
@@ -61,8 +83,149 @@ const buildPrompt = (prompt, memoryLongTerm) => {
61
83
  return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
62
84
  };
63
85
 
86
+ const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']);
87
+ const PUBLIC_DENIED_TOOLS = [
88
+ 'WebSearch',
89
+ 'WebFetch',
90
+ 'Bash',
91
+ 'Write',
92
+ 'Edit',
93
+ 'NotebookEdit',
94
+ 'Task',
95
+ ];
96
+ const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
97
+
98
+ const statOrNull = async (path) => {
99
+ try {
100
+ return await lstat(path);
101
+ } catch (err) {
102
+ if (err?.code === 'ENOENT') return null;
103
+ throw err;
104
+ }
105
+ };
106
+
107
+ // Claude's macOS OAuth credential lives in Keychain, but Claude also needs a
108
+ // small amount of non-secret account metadata from ~/.claude.json to locate
109
+ // it. Never bind or copy the operator's full file: it contains project paths,
110
+ // MCP configuration, usage history, and other host-private metadata. Public
111
+ // wrappers get a per-identity 0700 HOME carrying only this whitelist.
112
+ const preparePublicClaudeState = async (ctx) => {
113
+ const identity = ctx.agentName || ctx.cwd || 'anonymous';
114
+ const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
115
+ const statePath = ctx._publicClaudeState
116
+ || publicClaudeStateRoot(identityHash);
117
+ const tmpPath = join(statePath, 'tmp');
118
+ const configPath = join(statePath, '.claude.json');
119
+ const stateLibrary = join(statePath, 'Library');
120
+ const stateKeychains = join(stateLibrary, 'Keychains');
121
+ const operatorKeychains = join(homedir(), 'Library', 'Keychains');
122
+
123
+ await mkdir(tmpPath, { recursive: true, mode: 0o700 });
124
+ await mkdir(stateLibrary, { recursive: true, mode: 0o700 });
125
+ await chmod(statePath, 0o700);
126
+ await chmod(tmpPath, 0o700);
127
+ await chmod(stateLibrary, 0o700);
128
+
129
+ // `/usr/bin/security` resolves the default login keychain relative to HOME.
130
+ // Keep HOME isolated while pointing that one standard location at the
131
+ // operator's encrypted keychain database. Seatbelt admits the keychain
132
+ // directory read-only; no bearer credential is copied into agent state.
133
+ const keychainLink = await statOrNull(stateKeychains);
134
+ if (keychainLink && !keychainLink.isSymbolicLink()) {
135
+ throw new Error(`refusing to replace non-symlink Claude keychain path: ${stateKeychains}`);
136
+ }
137
+ if (keychainLink) {
138
+ const target = await readlink(stateKeychains);
139
+ if (target !== operatorKeychains) {
140
+ await unlink(stateKeychains);
141
+ await symlink(operatorKeychains, stateKeychains);
142
+ }
143
+ } else {
144
+ await symlink(operatorKeychains, stateKeychains);
145
+ }
146
+
147
+ const operatorConfigPath = join(homedir(), '.claude.json');
148
+ const sourceStat = await statOrNull(operatorConfigPath);
149
+ let source = {};
150
+ if (sourceStat) {
151
+ if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
152
+ throw new Error(`refusing non-regular operator Claude config: ${operatorConfigPath}`);
153
+ }
154
+ try {
155
+ source = JSON.parse(await readFile(operatorConfigPath, 'utf8'));
156
+ } catch (err) {
157
+ throw new Error(`failed to read operator Claude auth metadata: ${err.message}`);
158
+ }
159
+ }
160
+
161
+ const sanitized = {
162
+ hasCompletedOnboarding: true,
163
+ };
164
+ for (const key of ['installMethod', 'userID', 'machineID', 'oauthAccount']) {
165
+ if (source[key] !== undefined) sanitized[key] = source[key];
166
+ }
167
+ await writeFile(
168
+ configPath,
169
+ `${JSON.stringify(sanitized, null, 2)}\n`,
170
+ { encoding: 'utf8', mode: 0o600 },
171
+ );
172
+ await chmod(configPath, 0o600);
173
+ return { statePath, tmpPath, configPath };
174
+ };
175
+
176
+ const buildClaudeEnv = (input, state, expansionEnv = {}) => {
177
+ const source = input || process.env;
178
+ const output = state ? {} : { ...source };
179
+ if (state) {
180
+ for (const [key, value] of Object.entries(source)) {
181
+ if (PUBLIC_SAFE_ENV.test(key)) output[key] = value;
182
+ }
183
+ output.HOME = state.statePath;
184
+ output.TMPDIR = state.tmpPath;
185
+ output.XDG_CACHE_HOME = join(state.statePath, '.cache');
186
+ output.XDG_CONFIG_HOME = join(state.statePath, '.config');
187
+ output.XDG_DATA_HOME = join(state.statePath, '.local', 'share');
188
+ }
189
+ Object.assign(output, expansionEnv);
190
+ return output;
191
+ };
192
+
193
+ const absoluteToolDeny = (path) => `Read(/${path}/**)`;
194
+
195
+ const buildPublicClaudePolicyArgs = (mcpToolPatterns) => {
196
+ const home = homedir();
197
+ const deniedReads = [
198
+ absoluteToolDeny(join(home, '.commonly')),
199
+ absoluteToolDeny(join(home, '.claude')),
200
+ absoluteToolDeny(join(home, '.codex')),
201
+ absoluteToolDeny(join(home, '.ssh')),
202
+ absoluteToolDeny(join(home, '.aws')),
203
+ absoluteToolDeny(join(home, '.config')),
204
+ 'Read(//private/tmp/**)',
205
+ 'Read(./.commonly/**)',
206
+ 'Read(./.codex/**)',
207
+ 'Read(./.env)',
208
+ ];
209
+ return [
210
+ // HOME points at an isolated, sanitized state directory. Ignore settings
211
+ // from user/project/local sources and disable slash-command extensions;
212
+ // unlike --safe-mode, this preserves the explicit --mcp-config capability.
213
+ '--setting-sources', '',
214
+ '--disable-slash-commands',
215
+ '--strict-mcp-config',
216
+ '--no-chrome',
217
+ '--permission-mode', 'dontAsk',
218
+ '--allowedTools', 'Read(./**)', ...mcpToolPatterns,
219
+ '--disallowedTools', ...PUBLIC_DENIED_TOOLS, ...deniedReads,
220
+ ];
221
+ };
222
+
64
223
  const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
65
- const proc = spawnImpl(cmd, args, { cwd, env });
224
+ const proc = spawnImpl(cmd, args, {
225
+ cwd,
226
+ env,
227
+ stdio: ['ignore', 'pipe', 'pipe'],
228
+ });
66
229
  let stdout = '';
67
230
  let stderr = '';
68
231
  let timedOut = false;
@@ -88,70 +251,80 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) =
88
251
 
89
252
  // ── MCP config write — claude consumes this via --mcp-config <path> ─────────
90
253
 
91
- // Substitute Commonly-supplied placeholders in MCP env values, command args,
92
- // and URLs so users don't have to hand-paste secrets into their env file
93
- // every time they re-attach. Surfaced during the 2026-04-17 cross-agent demo:
94
- // every spec referencing commonly-mcp had to be rewritten with the agent's
95
- // runtime token after attach, because the token is minted at attach time and
96
- // only known to the wrapper.
254
+ // Keep Commonly placeholders in the MCP JSON and expose their values only in
255
+ // Claude's per-spawn environment. Claude Code natively expands ${VAR} in MCP
256
+ // command/args/env/url fields. Substituting here used to materialize the raw
257
+ // cm_agent_* bearer token in a transient JSON file, which made the token
258
+ // readable to any co-confined child allowed to read that config directory.
97
259
  //
98
- // Recognised placeholders (substituted everywhere a string appears in the
99
- // MCP config):
260
+ // Recognised placeholders:
100
261
  // ${COMMONLY_AGENT_TOKEN} — the per-(agent, pod) cm_agent_* runtime token
101
262
  // ${COMMONLY_API_URL} — the instance URL the agent is attached to
102
263
  // ${COMMONLY_INSTANCE_URL} — alias for COMMONLY_API_URL (clearer in context)
103
264
  //
104
- // Substitution is one-pass + literal no nested expansion, no shell quoting.
105
- // Unknown placeholders are left intact so the user sees a clear runtime error
106
- // from the MCP server rather than a silent empty string.
107
- const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
108
- const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
109
-
110
- const substitutePlaceholders = (value, ctx) => {
111
- if (typeof value !== 'string') return value;
112
- if (!value.includes('${COMMONLY_')) return value;
113
- const subs = {
265
+ // Only values actually referenced by this MCP declaration are added to the
266
+ // child environment. Unknown placeholders remain in JSON so Claude's parser
267
+ // fails clearly instead of receiving a silent empty string.
268
+ const buildMcpExpansionEnv = (mcpConfig, ctx) => {
269
+ const serialized = JSON.stringify(mcpConfig);
270
+ const values = {
114
271
  COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
115
272
  COMMONLY_API_URL: ctx.instanceUrl || '',
116
273
  COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
117
274
  };
118
- return value.replace(PLACEHOLDER_RE, (whole, key) => (
119
- SUBSTITUTION_KEYS.includes(key) && subs[key] ? subs[key] : whole
120
- ));
275
+ const output = {};
276
+ for (const [key, value] of Object.entries(values)) {
277
+ if (value && serialized.includes(`\${${key}}`)) output[key] = value;
278
+ }
279
+ return output;
121
280
  };
122
281
 
123
- const buildMcpConfig = (mcpServers, ctx = {}) => {
282
+ const buildMcpConfig = (mcpServers) => {
124
283
  // Shape: `{ mcpServers: { <name>: { ... } } }` — the standard MCP client
125
284
  // config, which claude's `--mcp-config` reads directly.
126
285
  const mcpServersMap = {};
127
286
  for (const server of mcpServers) {
128
287
  const entry = { type: server.transport || 'stdio' };
129
- if (server.url) entry.url = substitutePlaceholders(server.url, ctx);
288
+ if (server.url) entry.url = server.url;
130
289
  if (server.command) {
131
290
  const [command, ...args] = server.command;
132
291
  entry.command = command;
133
- if (args.length) entry.args = args.map((a) => substitutePlaceholders(a, ctx));
134
- }
135
- if (server.env) {
136
- entry.env = {};
137
- for (const [k, v] of Object.entries(server.env)) {
138
- entry.env[k] = substitutePlaceholders(v, ctx);
139
- }
292
+ if (args.length) entry.args = args;
140
293
  }
294
+ if (server.env) entry.env = { ...server.env };
141
295
  mcpServersMap[server.name] = entry;
142
296
  }
143
297
  return { mcpServers: mcpServersMap };
144
298
  };
145
299
 
146
- // Regenerated on every spawn from the env spec; do not hand-edit — the file
147
- // is overwritten before each `claude` invocation, so any local changes are
148
- // silently clobbered. ADR-008 §invariant #5 (edits propagate on next spawn).
149
- const writeMcpConfig = async (cwd, mcpServers, ctx = {}) => {
150
- const dir = join(cwd, '.commonly');
151
- await mkdir(dir, { recursive: true });
300
+ // Materialized once per spawn outside the workspace, then removed in the
301
+ // spawn's finally block. The JSON contains placeholders, never resolved
302
+ // Commonly credentials; Claude expands them from its one-run environment.
303
+ // Directory/file modes stay strict because this config still declares the
304
+ // agent's trusted MCP capabilities and endpoints.
305
+ const createMcpConfig = async (mcpServers, ctx = {}) => {
306
+ const dir = await mkdtemp(join(tmpdir(), 'commonly-claude-mcp-'));
152
307
  const file = join(dir, 'mcp-config.json');
153
- await writeFile(file, JSON.stringify(buildMcpConfig(mcpServers, ctx), null, 2), 'utf8');
154
- return file;
308
+ const config = buildMcpConfig(mcpServers);
309
+ try {
310
+ await chmod(dir, 0o700);
311
+ await writeFile(
312
+ file,
313
+ JSON.stringify(config, null, 2),
314
+ { encoding: 'utf8', mode: 0o600 },
315
+ );
316
+ // writeFile's mode only applies when creating. Pin the final mode too so
317
+ // this stays correct if the implementation ever starts reusing the path.
318
+ await chmod(file, 0o600);
319
+ return {
320
+ dir,
321
+ file,
322
+ expansionEnv: buildMcpExpansionEnv(config, ctx),
323
+ };
324
+ } catch (err) {
325
+ try { await rm(dir, { recursive: true, force: true }); } catch { /* ignore */ }
326
+ throw err;
327
+ }
155
328
  };
156
329
 
157
330
  // ── argv preparation — environment-aware ────────────────────────────────────
@@ -169,23 +342,29 @@ const resolveClaudePath = () => {
169
342
  try { which = spawnSync('which', ['claude'], { encoding: 'utf8' }); } catch { /* ignore */ }
170
343
  if (which && which.status === 0) {
171
344
  const p = (which.stdout || '').trim();
172
- if (p) return p;
345
+ if (p) {
346
+ try {
347
+ return realpathSync(p);
348
+ } catch {
349
+ return p;
350
+ }
351
+ }
173
352
  }
174
353
  return 'claude';
175
354
  };
176
355
 
177
356
  const prepareArgv = async (innerArgv, ctx) => {
178
357
  const env = ctx.environment;
179
- if (!env) return { cmd: 'claude', args: innerArgv };
358
+ if (!env) return { cmd: 'claude', args: innerArgv, env: ctx.claudeEnv };
180
359
 
181
- if (Array.isArray(env.mcp) && env.mcp.length > 0 && ctx.cwd) {
182
- const configPath = await writeMcpConfig(ctx.cwd, env.mcp, {
183
- runtimeToken: ctx.runtimeToken,
184
- instanceUrl: ctx.instanceUrl,
185
- });
360
+ let allowedPatterns = [];
361
+ if (Array.isArray(env.mcp) && env.mcp.length > 0) {
362
+ if (!ctx.mcpConfigPath) {
363
+ throw new Error('claude MCP config was declared but no per-spawn config path was prepared');
364
+ }
186
365
  // Insert --mcp-config immediately after the subcommand-style `-p` block
187
366
  // so claude parses it before prompt collection begins.
188
- innerArgv = [...innerArgv, '--mcp-config', configPath];
367
+ innerArgv = [...innerArgv, '--mcp-config', ctx.mcpConfigPath];
189
368
  // Pre-approve every MCP tool from the declared servers (`mcp__<name>__*`).
190
369
  // Without this claude runs in non-interactive `-p` mode and asks for
191
370
  // permission before invoking any MCP tool — the wrapper has no way to
@@ -193,25 +372,65 @@ const prepareArgv = async (innerArgv, ctx) => {
193
372
  // already opted into these servers by declaring them in the env spec, so
194
373
  // auto-allowing the corresponding tool prefix is the honest default.
195
374
  // Surfaced live during the 2026-04-17 cross-agent demo validation.
196
- const allowedPatterns = env.mcp
375
+ allowedPatterns = env.mcp
197
376
  .map((server) => server && server.name)
198
377
  .filter(Boolean)
199
378
  .map((name) => `mcp__${name}__*`);
200
- if (allowedPatterns.length > 0) {
201
- innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
202
- }
203
379
  }
204
380
 
205
381
  const sandboxMode = env.sandbox?.mode;
382
+ const sandboxTrust = env.sandbox?.trust;
383
+ const publicNativeSandbox = sandboxTrust === 'public'
384
+ && PUBLIC_SANDBOX_MODES.has(sandboxMode);
385
+ if (sandboxTrust === 'public' && sandboxMode === 'none') {
386
+ throw new Error('public Claude agents require an enforced sandbox mode');
387
+ }
388
+ if (publicNativeSandbox) {
389
+ if (process.platform !== 'darwin') {
390
+ throw new Error(
391
+ `public Claude sandbox.mode=${sandboxMode} maps to Seatbelt on macOS; `
392
+ + 'use sandbox.mode=bwrap on Linux',
393
+ );
394
+ }
395
+ if (!ctx.publicClaudeState) {
396
+ throw new Error('public Claude state was not prepared before argv construction');
397
+ }
398
+ innerArgv = [
399
+ ...innerArgv,
400
+ ...buildPublicClaudePolicyArgs(allowedPatterns),
401
+ ];
402
+ const claudeBin = resolveClaudePath();
403
+ const mcpExecutables = (env.mcp || [])
404
+ .map((server) => server?.command?.[0])
405
+ .filter((command) => isAbsolute(command));
406
+ const wrapped = wrapArgvWithSeatbelt([claudeBin, ...innerArgv], {
407
+ workspacePath: ctx.cwd,
408
+ workspaceAccess: sandboxMode === 'read-only' ? 'read' : 'write',
409
+ claudePath: claudeBin,
410
+ statePath: ctx.publicClaudeState.statePath,
411
+ mcpConfigDir: ctx.mcpConfigDir,
412
+ executablePaths: mcpExecutables,
413
+ });
414
+ return {
415
+ cmd: wrapped[0],
416
+ args: wrapped.slice(1),
417
+ env: ctx.claudeEnv,
418
+ };
419
+ }
420
+
421
+ if (allowedPatterns.length > 0) {
422
+ innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
423
+ }
206
424
  if (sandboxMode === 'bwrap') {
207
425
  const claudeBin = resolveClaudePath();
208
426
  const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
209
427
  workspacePath: ctx.cwd,
428
+ readOnlyPaths: ctx.mcpConfigDir ? [ctx.mcpConfigDir] : [],
210
429
  });
211
- return { cmd: wrapped[0], args: wrapped.slice(1) };
430
+ return { cmd: wrapped[0], args: wrapped.slice(1), env: ctx.claudeEnv };
212
431
  }
213
432
 
214
- return { cmd: 'claude', args: innerArgv };
433
+ return { cmd: 'claude', args: innerArgv, env: ctx.claudeEnv };
215
434
  };
216
435
 
217
436
  export default {
@@ -249,24 +468,47 @@ export default {
249
468
  const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId];
250
469
 
251
470
  if (ctx.environment && ctx.cwd) {
252
- const skills = await linkSkills(ctx.environment, ctx.cwd);
471
+ const skills = await mountSkills(ctx.environment, ctx.cwd);
253
472
  if (skills.conflicted.length > 0) {
254
473
  for (const c of skills.conflicted) {
255
474
  // eslint-disable-next-line no-console
256
- console.warn(`[claude] skill not linked (${c.reason}): ${c.path}`);
475
+ console.warn(`[claude] skill not mounted (${c.reason}): ${c.path}`);
257
476
  }
258
477
  }
259
478
  if (ctx.onSkillsLinked) ctx.onSkillsLinked(skills);
260
479
  }
261
480
 
262
- const { cmd, args } = await prepareArgv(baseArgs, ctx);
263
-
481
+ let mcpConfig = null;
482
+ let publicClaudeState = null;
264
483
  try {
484
+ const publicNativeSandbox = ctx.environment?.sandbox?.trust === 'public'
485
+ && PUBLIC_SANDBOX_MODES.has(ctx.environment?.sandbox?.mode);
486
+ if (publicNativeSandbox) {
487
+ publicClaudeState = await preparePublicClaudeState(ctx);
488
+ }
489
+ if (Array.isArray(ctx.environment?.mcp) && ctx.environment.mcp.length > 0) {
490
+ mcpConfig = await createMcpConfig(ctx.environment.mcp, {
491
+ runtimeToken: ctx.runtimeToken,
492
+ instanceUrl: ctx.instanceUrl,
493
+ });
494
+ }
495
+ const spawnCtx = {
496
+ ...ctx,
497
+ mcpConfigPath: mcpConfig?.file || null,
498
+ mcpConfigDir: mcpConfig?.dir || null,
499
+ publicClaudeState,
500
+ claudeEnv: buildClaudeEnv(
501
+ ctx.env,
502
+ publicClaudeState,
503
+ mcpConfig?.expansionEnv,
504
+ ),
505
+ };
506
+ const { cmd, args, env } = await prepareArgv(baseArgs, spawnCtx);
265
507
  const stdout = await runClaude({
266
508
  cmd,
267
509
  args,
268
510
  cwd: ctx.cwd,
269
- env: ctx.env,
511
+ env,
270
512
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
271
513
  spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
272
514
  });
@@ -279,18 +521,32 @@ export default {
279
521
  if (isResume && /already in use|no conversation|no session/i.test(String(err.message))) {
280
522
  const freshId = randomUUID();
281
523
  const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId];
282
- const retry = await prepareArgv(retryBase, ctx);
524
+ const retry = await prepareArgv(retryBase, {
525
+ ...ctx,
526
+ mcpConfigPath: mcpConfig?.file || null,
527
+ mcpConfigDir: mcpConfig?.dir || null,
528
+ publicClaudeState,
529
+ claudeEnv: buildClaudeEnv(
530
+ ctx.env,
531
+ publicClaudeState,
532
+ mcpConfig?.expansionEnv,
533
+ ),
534
+ });
283
535
  const stdout = await runClaude({
284
536
  cmd: retry.cmd,
285
537
  args: retry.args,
286
538
  cwd: ctx.cwd,
287
- env: ctx.env,
539
+ env: retry.env,
288
540
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
289
541
  spawnImpl: ctx._spawnImpl,
290
542
  });
291
543
  return { text: stdout.trim(), newSessionId: freshId };
292
544
  }
293
545
  throw err;
546
+ } finally {
547
+ if (mcpConfig?.dir) {
548
+ try { await rm(mcpConfig.dir, { recursive: true, force: true }); } catch { /* ignore */ }
549
+ }
294
550
  }
295
551
  },
296
552
  };