@commonlyai/cli 0.1.2 → 0.1.4

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.
@@ -38,9 +38,24 @@
38
38
  */
39
39
 
40
40
  import { spawn as childSpawn, spawnSync } from 'child_process';
41
- import { mkdtemp, readFile, rm } from 'fs/promises';
42
- import { tmpdir } from 'os';
43
- import { join } from 'path';
41
+ import { createHash } from 'crypto';
42
+ import {
43
+ chmod,
44
+ lstat,
45
+ mkdir,
46
+ mkdtemp,
47
+ readFile,
48
+ readlink,
49
+ rm,
50
+ symlink,
51
+ unlink,
52
+ } from 'fs/promises';
53
+ import { homedir, tmpdir } from 'os';
54
+ import {
55
+ dirname,
56
+ join,
57
+ resolve as pathResolve,
58
+ } from 'path';
44
59
 
45
60
  // Default timeout for a single codex spawn (exec mode).
46
61
  //
@@ -84,9 +99,10 @@ const buildPrompt = (prompt, memoryLongTerm) => {
84
99
  // Codex-specific constraints:
85
100
  // - stdio/command servers only (no url transport here); url-only entries
86
101
  // are skipped rather than half-wired.
87
- // - Declared env MUST ride inside the mcp_servers.<name>.env table codex
88
- // does not pass its parent env to the MCP child it spawns (the PR #398
89
- // cloud-codex lesson; same failure mode locally).
102
+ // - Token-bearing values ride through `env_vars`, never a `-c ...env=...`
103
+ // argv override. Command lines are visible to other same-user processes
104
+ // unless the OS sandbox blocks process inspection; keeping bearer tokens
105
+ // out of argv is an independent defense.
90
106
  const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
91
107
  const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
92
108
 
@@ -109,26 +125,152 @@ const toml = (s) => JSON.stringify(String(s));
109
125
 
110
126
  const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
111
127
  const flags = [];
128
+ const forwardedEnv = {};
112
129
  for (const server of mcpServers || []) {
113
130
  if (!server?.name || !Array.isArray(server.command) || !server.command.length) continue;
114
131
  const [command, ...rest] = server.command.map((a) => substitutePlaceholders(a, ctx));
115
132
  flags.push('-c', `mcp_servers.${server.name}.command=${toml(command)}`);
133
+ // The user opted into every server present in the environment spec.
134
+ // Public permission profiles + approval_policy=never otherwise auto-deny
135
+ // side-effecting MCP calls, silently removing the agent's Commonly tools.
136
+ // Mirror Claude's --allowedTools behavior: declared MCP servers are the
137
+ // capability boundary, and all of their tools are non-interactively
138
+ // approved inside that boundary.
139
+ flags.push(
140
+ '-c',
141
+ `mcp_servers.${server.name}.default_tools_approval_mode="approve"`,
142
+ );
116
143
  if (rest.length) {
117
144
  flags.push('-c', `mcp_servers.${server.name}.args=[${rest.map(toml).join(',')}]`);
118
145
  }
119
- const envEntries = Object.entries(server.env || {})
120
- .map(([k, v]) => `${k} = ${toml(substitutePlaceholders(v, ctx))}`);
146
+ const envEntries = [];
147
+ const envVars = [];
148
+ for (const [key, rawValue] of Object.entries(server.env || {})) {
149
+ const value = substitutePlaceholders(rawValue, ctx);
150
+ const carriesRuntimeToken = !!ctx.runtimeToken
151
+ && typeof value === 'string'
152
+ && value.includes(ctx.runtimeToken);
153
+ if (carriesRuntimeToken) {
154
+ if (forwardedEnv[key] !== undefined && forwardedEnv[key] !== value) {
155
+ throw new Error(
156
+ `codex MCP servers declare conflicting token-bearing values for env var ${key}`,
157
+ );
158
+ }
159
+ forwardedEnv[key] = value;
160
+ envVars.push(key);
161
+ } else {
162
+ envEntries.push(`${key} = ${toml(value)}`);
163
+ }
164
+ }
121
165
  if (envEntries.length) {
122
166
  flags.push('-c', `mcp_servers.${server.name}.env={${envEntries.join(', ')}}`);
123
167
  }
168
+ if (envVars.length) {
169
+ flags.push('-c', `mcp_servers.${server.name}.env_vars=[${envVars.map(toml).join(',')}]`);
170
+ }
171
+ }
172
+ return { flags, forwardedEnv };
173
+ };
174
+
175
+ const PUBLIC_PERMISSION_PROFILE = 'commonly_public';
176
+ const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']);
177
+
178
+ const statOrNull = async (path) => {
179
+ try {
180
+ return await lstat(path);
181
+ } catch (err) {
182
+ if (err?.code === 'ENOENT') return null;
183
+ throw err;
184
+ }
185
+ };
186
+
187
+ // Codex reads $CODEX_HOME/AGENTS.md before model-generated commands enter the
188
+ // OS sandbox. Pointing a public run at the operator's normal ~/.codex would
189
+ // therefore expose global private instructions even though shell reads of
190
+ // ~/.codex are denied. Give every public wrapper identity its own persistent
191
+ // Codex home for sessions/state, with only an auth symlink back to the
192
+ // operator credential. The symlink itself lives under ~/.commonly, which the
193
+ // permission profile denies to model-generated commands.
194
+ const preparePublicCodexHome = async (ctx) => {
195
+ const operatorHome = ctx.env?.CODEX_HOME
196
+ || process.env.CODEX_HOME
197
+ || join(homedir(), '.codex');
198
+ const identity = ctx.agentName || ctx.cwd || 'anonymous';
199
+ const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
200
+ const publicHome = ctx._publicCodexHome
201
+ || join(homedir(), '.commonly', 'codex-homes', identityHash);
202
+ if (pathResolve(publicHome) === pathResolve(operatorHome)) {
203
+ throw new Error('public codex home must be isolated from the operator CODEX_HOME');
204
+ }
205
+
206
+ await mkdir(publicHome, { recursive: true, mode: 0o700 });
207
+ await chmod(publicHome, 0o700);
208
+
209
+ const sourceAuth = join(operatorHome, 'auth.json');
210
+ const targetAuth = join(publicHome, 'auth.json');
211
+ const sourceStat = await statOrNull(sourceAuth);
212
+ const targetStat = await statOrNull(targetAuth);
213
+ if (targetStat && !targetStat.isSymbolicLink()) {
214
+ throw new Error(`refusing to replace non-symlink public Codex credential: ${targetAuth}`);
215
+ }
216
+ if (sourceStat) {
217
+ const currentTarget = targetStat
218
+ ? pathResolve(dirname(targetAuth), await readlink(targetAuth))
219
+ : null;
220
+ if (currentTarget !== pathResolve(sourceAuth)) {
221
+ if (targetStat) await unlink(targetAuth);
222
+ await symlink(sourceAuth, targetAuth);
223
+ }
224
+ } else if (targetStat) {
225
+ // A stale symlink could unexpectedly authenticate through an old home
226
+ // after the operator intentionally logged out. Remove it and let Codex
227
+ // fail closed with its normal "not logged in" error.
228
+ await unlink(targetAuth);
124
229
  }
125
- return flags;
230
+ return publicHome;
231
+ };
232
+
233
+ const publicPermissionProfileFlags = (mode) => {
234
+ if (!PUBLIC_SANDBOX_MODES.has(mode)) {
235
+ throw new Error(
236
+ `public codex agents require sandbox.mode=workspace or read-only, got ${mode || 'unset'}`,
237
+ );
238
+ }
239
+ const workspaceAccess = mode === 'read-only' ? 'read' : 'write';
240
+ const filesystem = [
241
+ '":minimal"="read"',
242
+ '"~/.commonly"="deny"',
243
+ '"~/.claude"="deny"',
244
+ '"~/.codex"="deny"',
245
+ '"~/.ssh"="deny"',
246
+ '"~/.aws"="deny"',
247
+ '"~/.config"="deny"',
248
+ '"/private/tmp"="deny"',
249
+ `":workspace_roots"={"."="${workspaceAccess}",".commonly/**"="deny",".codex/**"="deny","*.env"="deny","*/*.env"="deny","*/*/*.env"="deny"}`,
250
+ ].join(',');
251
+ return [
252
+ '-c', `default_permissions=${toml(PUBLIC_PERMISSION_PROFILE)}`,
253
+ '-c', `permissions.${PUBLIC_PERMISSION_PROFILE}.filesystem={${filesystem}}`,
254
+ '-c', `permissions.${PUBLIC_PERMISSION_PROFILE}.network.enabled=false`,
255
+ // The MCP launcher receives explicitly forwarded env_vars separately.
256
+ // Model-generated shell commands inherit only a small non-secret core.
257
+ '-c', 'shell_environment_policy.inherit="core"',
258
+ '-c', 'shell_environment_policy.ignore_default_excludes=false',
259
+ '-c', 'shell_environment_policy.include_only=["PATH","HOME","TMPDIR","LANG","LC_*"]',
260
+ ];
126
261
  };
127
262
 
128
263
  // Build the argv after the `codex` binary. Resume vs new turn is a
129
264
  // subcommand-level distinction in modern codex, not an option flag — keep
130
265
  // that detail isolated here so the spawn path stays linear.
131
- const buildArgs = ({ sessionId, prompt, outputFile, mcpFlags = [] }) => {
266
+ const buildArgs = ({
267
+ sessionId,
268
+ prompt,
269
+ outputFile,
270
+ mcpFlags = [],
271
+ publicSandboxMode = null,
272
+ }) => {
273
+ const publicSandbox = publicSandboxMode !== null;
132
274
  // `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's
133
275
  // bubblewrap (bwrap) sandbox + approval prompts. bwrap needs CAP_SYS_ADMIN
134
276
  // or unprivileged user-namespaces — neither available to standard k8s
@@ -137,26 +279,39 @@ const buildArgs = ({ sessionId, prompt, outputFile, mcpFlags = [] }) => {
137
279
  // slave: Permission denied" inside cloud-codex pods (verified 2026-05-15).
138
280
  // The pod is the security perimeter — agent identity is isolated, workspace
139
281
  // is PVC-scoped, no host mounts. bwrap inside the pod is redundant.
140
- // On a laptop wrapper run (sam-local-codex etc.) the same flag is fine: the
141
- // operator's machine is already the security boundary they signed up for
142
- // when running `commonly agent run`.
282
+ // Public laptop wrappers are different: untrusted pod content must never
283
+ // inherit that bypass. Codex >=0.138 permission profiles provide a native
284
+ // deny-by-default read/write/network boundary on macOS and Linux. Do not
285
+ // pass legacy `--sandbox` alongside them: Codex documents that the legacy
286
+ // mode disables permission-profile composition.
287
+ const executionPolicy = publicSandbox
288
+ ? [
289
+ '--ignore-user-config',
290
+ '--ignore-rules',
291
+ ...publicPermissionProfileFlags(publicSandboxMode),
292
+ ]
293
+ : ['--dangerously-bypass-approvals-and-sandbox'];
143
294
  const common = [
144
295
  '--json',
145
296
  '--skip-git-repo-check',
146
- '--dangerously-bypass-approvals-and-sandbox',
297
+ ...executionPolicy,
147
298
  ...mcpFlags,
148
299
  '-o',
149
300
  outputFile,
150
301
  ];
302
+ // Approval policy is a global option in Codex 0.144, so it must precede
303
+ // the `exec` subcommand. A denied operation is returned to the model;
304
+ // non-interactive public agents never hang waiting for an operator.
305
+ const prefix = publicSandbox ? ['--ask-for-approval', 'never'] : [];
151
306
  if (sessionId) {
152
307
  // Place <sessionId> immediately after the `exec resume` subcommand so a
153
308
  // future codex parser change can't accidentally consume it as the value
154
309
  // of a preceding flag (e.g. -o). Codex's CLI signature is documented as
155
310
  // `codex exec resume [OPTIONS] [SESSION_ID] [PROMPT]`, and this ordering
156
311
  // matches that intent unambiguously regardless of clap version.
157
- return ['exec', 'resume', sessionId, ...common, prompt];
312
+ return [...prefix, 'exec', 'resume', sessionId, ...common, prompt];
158
313
  }
159
- return ['exec', ...common, prompt];
314
+ return [...prefix, 'exec', ...common, prompt];
160
315
  };
161
316
 
162
317
  // Stream-parse JSONL stdout. Codex emits one event per line; partial lines
@@ -270,20 +425,29 @@ export default {
270
425
  const outputFile = join(dir, 'last-message.txt');
271
426
 
272
427
  try {
428
+ const mcp = buildMcpOverrideArgs(ctx.environment?.mcp, {
429
+ runtimeToken: ctx.runtimeToken,
430
+ instanceUrl: ctx.instanceUrl,
431
+ });
432
+ const publicSandboxMode = ctx.environment?.sandbox?.trust === 'public'
433
+ ? ctx.environment?.sandbox?.mode || 'unset'
434
+ : null;
273
435
  const args = buildArgs({
274
436
  sessionId: ctx.sessionId || null,
275
437
  prompt: fullPrompt,
276
438
  outputFile,
277
- mcpFlags: buildMcpOverrideArgs(ctx.environment?.mcp, {
278
- runtimeToken: ctx.runtimeToken,
279
- instanceUrl: ctx.instanceUrl,
280
- }),
439
+ mcpFlags: mcp.flags,
440
+ publicSandboxMode,
281
441
  });
442
+ const childEnv = { ...(ctx.env || process.env), ...mcp.forwardedEnv };
443
+ if (publicSandboxMode !== null) {
444
+ childEnv.CODEX_HOME = await preparePublicCodexHome(ctx);
445
+ }
282
446
 
283
447
  const { threadId } = await runCodex({
284
448
  args,
285
449
  cwd: ctx.cwd,
286
- env: ctx.env,
450
+ env: childEnv,
287
451
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
288
452
  spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
289
453
  });
@@ -30,7 +30,10 @@ import { homedir } from 'os';
30
30
  const ALLOWED_TOP_KEYS = new Set([
31
31
  'version', 'workspace', 'sandbox', 'skills', 'mcp',
32
32
  ]);
33
- const ALLOWED_SANDBOX_MODES = new Set(['none', 'bwrap', 'firejail', 'container', 'managed']);
33
+ const ALLOWED_SANDBOX_MODES = new Set([
34
+ 'none', 'workspace', 'read-only', 'bwrap', 'firejail', 'container', 'managed',
35
+ ]);
36
+ const ALLOWED_SANDBOX_TRUST = new Set(['public', 'internal']);
34
37
  const ALLOWED_NETWORK_POLICIES = new Set(['unrestricted', 'restricted']);
35
38
 
36
39
  const expandHome = (p) => {
@@ -127,10 +130,15 @@ export const validateEnvironmentSpec = (spec) => {
127
130
  if (typeof spec.sandbox !== 'object' || spec.sandbox === null) {
128
131
  errors.push('sandbox must be an object');
129
132
  } else {
130
- const { mode, network, filesystem } = spec.sandbox;
133
+ const {
134
+ mode, trust, network, filesystem,
135
+ } = spec.sandbox;
131
136
  if (mode !== undefined && !ALLOWED_SANDBOX_MODES.has(mode)) {
132
137
  errors.push(`sandbox.mode must be one of: ${[...ALLOWED_SANDBOX_MODES].join(', ')}`);
133
138
  }
139
+ if (trust !== undefined && !ALLOWED_SANDBOX_TRUST.has(trust)) {
140
+ errors.push(`sandbox.trust must be one of: ${[...ALLOWED_SANDBOX_TRUST].join(', ')}`);
141
+ }
134
142
  if (network !== undefined) {
135
143
  if (typeof network !== 'object' || network === null) {
136
144
  errors.push('sandbox.network must be an object');
@@ -117,6 +117,17 @@ export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => {
117
117
  flags.push('--bind-try', p, p);
118
118
  }
119
119
 
120
+ // Adapter-owned transient inputs (for example Claude's per-spawn MCP
121
+ // config) live outside the workspace so model-visible Read(./**) cannot
122
+ // reach them. Bind only the exact directories the adapter prepared; they
123
+ // remain read-only inside the namespace and disappear after the spawn.
124
+ for (const p of opts.readOnlyPaths || []) {
125
+ if (!p || !isAbsolute(p)) {
126
+ throw new Error('wrapArgvWithBwrap opts.readOnlyPaths entries must be absolute paths');
127
+ }
128
+ flags.push('--ro-bind', p, p);
129
+ }
130
+
120
131
  flags.push('--bind', opts.workspacePath, opts.workspacePath);
121
132
  flags.push('--chdir', opts.workspacePath);
122
133
  flags.push('--setenv', 'HOME', opts.workspacePath);