@commonlyai/cli 0.1.1 → 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.
- package/package.json +2 -2
- package/src/commands/agent.js +122 -32
- package/src/lib/adapters/claude.js +320 -64
- package/src/lib/adapters/codex.js +234 -11
- package/src/lib/environment.js +10 -2
- package/src/lib/sandbox/bwrap.js +11 -0
- package/src/lib/sandbox/seatbelt.js +417 -0
- package/src/lib/session-store.js +63 -0
|
@@ -38,9 +38,24 @@
|
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
40
|
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
|
|
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
|
//
|
|
@@ -71,10 +86,191 @@ const buildPrompt = (prompt, memoryLongTerm) => {
|
|
|
71
86
|
return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
|
|
72
87
|
};
|
|
73
88
|
|
|
89
|
+
// ── MCP wiring — codex consumes MCP servers via `-c mcp_servers.*` overrides ─
|
|
90
|
+
//
|
|
91
|
+
// Same substitution contract as claude.js (see that file for the placeholder
|
|
92
|
+
// rationale): ${COMMONLY_AGENT_TOKEN} / ${COMMONLY_API_URL} in the declared
|
|
93
|
+
// env spec are replaced with the wrapper's per-(agent, pod) runtime values at
|
|
94
|
+
// spawn time. Before this block the codex adapter silently ignored
|
|
95
|
+
// `environment.mcp`, so a codex agent had NO sanctioned posting tool — the
|
|
96
|
+
// 2026-07-22 as-operator attribution incident was a codex agent falling back
|
|
97
|
+
// to the operator's CLI profile because commonly_* tools were never wired.
|
|
98
|
+
//
|
|
99
|
+
// Codex-specific constraints:
|
|
100
|
+
// - stdio/command servers only (no url transport here); url-only entries
|
|
101
|
+
// are skipped rather than half-wired.
|
|
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.
|
|
106
|
+
const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
|
|
107
|
+
const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
|
|
108
|
+
|
|
109
|
+
const substitutePlaceholders = (value, ctx) => {
|
|
110
|
+
if (typeof value !== 'string') return value;
|
|
111
|
+
if (!value.includes('${COMMONLY_')) return value;
|
|
112
|
+
const subs = {
|
|
113
|
+
COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
|
|
114
|
+
COMMONLY_API_URL: ctx.instanceUrl || '',
|
|
115
|
+
COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
|
|
116
|
+
};
|
|
117
|
+
return value.replace(PLACEHOLDER_RE, (whole, key) => (
|
|
118
|
+
SUBSTITUTION_KEYS.includes(key) && subs[key] ? subs[key] : whole
|
|
119
|
+
));
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// JSON string escaping is a valid subset of TOML basic-string escaping, so
|
|
123
|
+
// JSON.stringify doubles as the TOML quoter for command/args/env values.
|
|
124
|
+
const toml = (s) => JSON.stringify(String(s));
|
|
125
|
+
|
|
126
|
+
const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
|
|
127
|
+
const flags = [];
|
|
128
|
+
const forwardedEnv = {};
|
|
129
|
+
for (const server of mcpServers || []) {
|
|
130
|
+
if (!server?.name || !Array.isArray(server.command) || !server.command.length) continue;
|
|
131
|
+
const [command, ...rest] = server.command.map((a) => substitutePlaceholders(a, ctx));
|
|
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
|
+
);
|
|
143
|
+
if (rest.length) {
|
|
144
|
+
flags.push('-c', `mcp_servers.${server.name}.args=[${rest.map(toml).join(',')}]`);
|
|
145
|
+
}
|
|
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
|
+
}
|
|
165
|
+
if (envEntries.length) {
|
|
166
|
+
flags.push('-c', `mcp_servers.${server.name}.env={${envEntries.join(', ')}}`);
|
|
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);
|
|
229
|
+
}
|
|
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
|
+
];
|
|
261
|
+
};
|
|
262
|
+
|
|
74
263
|
// Build the argv after the `codex` binary. Resume vs new turn is a
|
|
75
264
|
// subcommand-level distinction in modern codex, not an option flag — keep
|
|
76
265
|
// that detail isolated here so the spawn path stays linear.
|
|
77
|
-
const buildArgs = ({
|
|
266
|
+
const buildArgs = ({
|
|
267
|
+
sessionId,
|
|
268
|
+
prompt,
|
|
269
|
+
outputFile,
|
|
270
|
+
mcpFlags = [],
|
|
271
|
+
publicSandboxMode = null,
|
|
272
|
+
}) => {
|
|
273
|
+
const publicSandbox = publicSandboxMode !== null;
|
|
78
274
|
// `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's
|
|
79
275
|
// bubblewrap (bwrap) sandbox + approval prompts. bwrap needs CAP_SYS_ADMIN
|
|
80
276
|
// or unprivileged user-namespaces — neither available to standard k8s
|
|
@@ -83,25 +279,39 @@ const buildArgs = ({ sessionId, prompt, outputFile }) => {
|
|
|
83
279
|
// slave: Permission denied" inside cloud-codex pods (verified 2026-05-15).
|
|
84
280
|
// The pod is the security perimeter — agent identity is isolated, workspace
|
|
85
281
|
// is PVC-scoped, no host mounts. bwrap inside the pod is redundant.
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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'];
|
|
89
294
|
const common = [
|
|
90
295
|
'--json',
|
|
91
296
|
'--skip-git-repo-check',
|
|
92
|
-
|
|
297
|
+
...executionPolicy,
|
|
298
|
+
...mcpFlags,
|
|
93
299
|
'-o',
|
|
94
300
|
outputFile,
|
|
95
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'] : [];
|
|
96
306
|
if (sessionId) {
|
|
97
307
|
// Place <sessionId> immediately after the `exec resume` subcommand so a
|
|
98
308
|
// future codex parser change can't accidentally consume it as the value
|
|
99
309
|
// of a preceding flag (e.g. -o). Codex's CLI signature is documented as
|
|
100
310
|
// `codex exec resume [OPTIONS] [SESSION_ID] [PROMPT]`, and this ordering
|
|
101
311
|
// matches that intent unambiguously regardless of clap version.
|
|
102
|
-
return ['exec', 'resume', sessionId, ...common, prompt];
|
|
312
|
+
return [...prefix, 'exec', 'resume', sessionId, ...common, prompt];
|
|
103
313
|
}
|
|
104
|
-
return ['exec', ...common, prompt];
|
|
314
|
+
return [...prefix, 'exec', ...common, prompt];
|
|
105
315
|
};
|
|
106
316
|
|
|
107
317
|
// Stream-parse JSONL stdout. Codex emits one event per line; partial lines
|
|
@@ -215,16 +425,29 @@ export default {
|
|
|
215
425
|
const outputFile = join(dir, 'last-message.txt');
|
|
216
426
|
|
|
217
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;
|
|
218
435
|
const args = buildArgs({
|
|
219
436
|
sessionId: ctx.sessionId || null,
|
|
220
437
|
prompt: fullPrompt,
|
|
221
438
|
outputFile,
|
|
439
|
+
mcpFlags: mcp.flags,
|
|
440
|
+
publicSandboxMode,
|
|
222
441
|
});
|
|
442
|
+
const childEnv = { ...(ctx.env || process.env), ...mcp.forwardedEnv };
|
|
443
|
+
if (publicSandboxMode !== null) {
|
|
444
|
+
childEnv.CODEX_HOME = await preparePublicCodexHome(ctx);
|
|
445
|
+
}
|
|
223
446
|
|
|
224
447
|
const { threadId } = await runCodex({
|
|
225
448
|
args,
|
|
226
449
|
cwd: ctx.cwd,
|
|
227
|
-
env:
|
|
450
|
+
env: childEnv,
|
|
228
451
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
229
452
|
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
230
453
|
});
|
package/src/lib/environment.js
CHANGED
|
@@ -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([
|
|
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 {
|
|
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');
|
package/src/lib/sandbox/bwrap.js
CHANGED
|
@@ -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);
|