@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 +1 -1
- package/skills/commonly/SKILL.md +8 -0
- package/src/commands/agent.js +60 -5
- package/src/lib/adapters/claude.js +323 -67
- package/src/lib/adapters/codex.js +185 -21
- package/src/lib/environment.js +95 -52
- package/src/lib/sandbox/bwrap.js +11 -0
- package/src/lib/sandbox/seatbelt.js +417 -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
|
//
|
|
@@ -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
|
-
// -
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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 =
|
|
120
|
-
|
|
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
|
|
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 = ({
|
|
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
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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
|
-
|
|
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:
|
|
278
|
-
|
|
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:
|
|
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
|
});
|
package/src/lib/environment.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* how an agent's runtime should be shaped: workspace path, sandbox mode, the
|
|
6
6
|
* Claude skills to mount, the MCP servers to expose. This module is the
|
|
7
7
|
* adapter-facing read-side: parse, validate, and realize the workspace +
|
|
8
|
-
* skill
|
|
8
|
+
* skill mounts on disk. Sandbox argv wrapping lives in `./sandbox/bwrap.js`
|
|
9
9
|
* because it is per-driver (Linux-only, bwrap-specific).
|
|
10
10
|
*
|
|
11
11
|
* Zero runtime deps: Node 20 has no built-in YAML, so we accept JSON only and
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* place to add a `.yaml` branch — the rest of the module is parser-agnostic.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
readFile, mkdir, lstat, cp, rm, chmod, readdir, writeFile,
|
|
19
|
+
} from 'fs/promises';
|
|
18
20
|
import { existsSync } from 'fs';
|
|
19
21
|
import { dirname, isAbsolute, join, resolve as pathResolve, basename } from 'path';
|
|
20
22
|
import { homedir } from 'os';
|
|
@@ -25,12 +27,15 @@ import { homedir } from 'os';
|
|
|
25
27
|
// relative skill paths) is NOT stored on the returned spec. Leaking the user's
|
|
26
28
|
// absolute filesystem path into `config.environment` sent to the backend
|
|
27
29
|
// exposes `$HOME` layout for zero server-side benefit. Callers pass
|
|
28
|
-
// `envFileDir` as a separate argument to resolveWorkspace /
|
|
30
|
+
// `envFileDir` as a separate argument to resolveWorkspace / mountSkills.
|
|
29
31
|
|
|
30
32
|
const ALLOWED_TOP_KEYS = new Set([
|
|
31
33
|
'version', 'workspace', 'sandbox', 'skills', 'mcp',
|
|
32
34
|
]);
|
|
33
|
-
const ALLOWED_SANDBOX_MODES = new Set([
|
|
35
|
+
const ALLOWED_SANDBOX_MODES = new Set([
|
|
36
|
+
'none', 'workspace', 'read-only', 'bwrap', 'firejail', 'container', 'managed',
|
|
37
|
+
]);
|
|
38
|
+
const ALLOWED_SANDBOX_TRUST = new Set(['public', 'internal']);
|
|
34
39
|
const ALLOWED_NETWORK_POLICIES = new Set(['unrestricted', 'restricted']);
|
|
35
40
|
|
|
36
41
|
const expandHome = (p) => {
|
|
@@ -82,7 +87,7 @@ export const parseEnvironmentFile = async (absolutePath) => {
|
|
|
82
87
|
// Return the bare spec — no envFileDir wrapping, no underscore-prefixed
|
|
83
88
|
// annotations. The caller is responsible for tracking envFileDir separately
|
|
84
89
|
// (compute via `dirname(envPath)`) and passing it explicitly to
|
|
85
|
-
// resolveWorkspace /
|
|
90
|
+
// resolveWorkspace / mountSkills when relative paths in the spec need to
|
|
86
91
|
// resolve. This keeps the spec safe to serialize and ship to the backend
|
|
87
92
|
// (`config.environment` on AgentInstallation) without leaking $HOME layout.
|
|
88
93
|
return parsed;
|
|
@@ -127,10 +132,15 @@ export const validateEnvironmentSpec = (spec) => {
|
|
|
127
132
|
if (typeof spec.sandbox !== 'object' || spec.sandbox === null) {
|
|
128
133
|
errors.push('sandbox must be an object');
|
|
129
134
|
} else {
|
|
130
|
-
const {
|
|
135
|
+
const {
|
|
136
|
+
mode, trust, network, filesystem,
|
|
137
|
+
} = spec.sandbox;
|
|
131
138
|
if (mode !== undefined && !ALLOWED_SANDBOX_MODES.has(mode)) {
|
|
132
139
|
errors.push(`sandbox.mode must be one of: ${[...ALLOWED_SANDBOX_MODES].join(', ')}`);
|
|
133
140
|
}
|
|
141
|
+
if (trust !== undefined && !ALLOWED_SANDBOX_TRUST.has(trust)) {
|
|
142
|
+
errors.push(`sandbox.trust must be one of: ${[...ALLOWED_SANDBOX_TRUST].join(', ')}`);
|
|
143
|
+
}
|
|
134
144
|
if (network !== undefined) {
|
|
135
145
|
if (typeof network !== 'object' || network === null) {
|
|
136
146
|
errors.push('sandbox.network must be an object');
|
|
@@ -238,35 +248,72 @@ export const resolveWorkspace = async (spec, agentName, envFileDir = null) => {
|
|
|
238
248
|
return { path: absPath, created };
|
|
239
249
|
};
|
|
240
250
|
|
|
241
|
-
// ──
|
|
251
|
+
// ── mountSkills ─────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
// Marks a skill directory as a Commonly-managed snapshot. Its presence is what
|
|
254
|
+
// licenses a refresh to delete and re-copy the directory: without it we cannot
|
|
255
|
+
// distinguish "a mount we made last spawn" from "a skill the user authored in
|
|
256
|
+
// their workspace", and clobbering the latter would destroy real work.
|
|
257
|
+
const MOUNT_MARKER = '.commonly-skill-mount';
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Recursively drop the write bit on every regular file in a mounted snapshot.
|
|
261
|
+
*
|
|
262
|
+
* Directories deliberately stay writable: unlinking a file requires write
|
|
263
|
+
* permission on its PARENT, so read-only directories would make the next
|
|
264
|
+
* refresh fail. This is defense-in-depth for the agent's own copy, not the
|
|
265
|
+
* load-bearing protection — that comes from the copy existing at all.
|
|
266
|
+
*/
|
|
267
|
+
const freezeFiles = async (dir) => {
|
|
268
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
const full = join(dir, entry.name);
|
|
271
|
+
if (entry.isDirectory()) {
|
|
272
|
+
// eslint-disable-next-line no-await-in-loop
|
|
273
|
+
await freezeFiles(full);
|
|
274
|
+
} else if (entry.isFile()) {
|
|
275
|
+
// eslint-disable-next-line no-await-in-loop
|
|
276
|
+
await chmod(full, 0o444);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
242
280
|
|
|
243
281
|
/**
|
|
244
|
-
*
|
|
282
|
+
* Mount each `skills.claude[]` source into `<workspacePath>/.claude/skills/`
|
|
283
|
+
* as a READ-ONLY SNAPSHOT COPY.
|
|
245
284
|
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
285
|
+
* Why a copy and not a symlink: a symlink is writable *through*, so an agent
|
|
286
|
+
* holding Write/Edit could edit the file that governs its own behaviour — and
|
|
287
|
+
* because these sources are usually files tracked in a repo (the bundled
|
|
288
|
+
* `cli/skills/commonly` is the common case), that edit lands in the operator's
|
|
289
|
+
* working tree. `npm publish` ships the working tree, not git HEAD, so a
|
|
290
|
+
* write-through edit can silently ride out in the next release. That is not
|
|
291
|
+
* hypothetical: @commonlyai/cli@0.1.4 shipped with the "post as yourself,
|
|
292
|
+
* never as your operator" guardrail (#702) missing for exactly this reason.
|
|
293
|
+
* The snapshot makes the data flow one-way: source → workspace, never back.
|
|
294
|
+
*
|
|
295
|
+
* Refreshed on every call — the claude adapter mounts per spawn — so edits to
|
|
296
|
+
* the source still reach the agent on its next turn. Only directories carrying
|
|
297
|
+
* MOUNT_MARKER are refreshed; anything else in the slot is reported as a
|
|
298
|
+
* conflict and left untouched.
|
|
299
|
+
*
|
|
300
|
+
* Legacy symlinks written by CLI ≤0.1.4 are replaced with a snapshot, since
|
|
301
|
+
* the symlink IS the vulnerable shape being retired.
|
|
252
302
|
*
|
|
253
303
|
* `envFileDir` is the directory of the env file (used to resolve relative
|
|
254
304
|
* skill paths). Optional — when omitted, relative paths fall back to
|
|
255
|
-
* `workspacePath`.
|
|
256
|
-
* matching the env file's location.
|
|
305
|
+
* `workspacePath`.
|
|
257
306
|
*
|
|
258
|
-
* Returns `{
|
|
259
|
-
* - `
|
|
260
|
-
* - `skipped`: string[] — already correctly linked (no-op)
|
|
307
|
+
* Returns `{ mounted, conflicted }`:
|
|
308
|
+
* - `mounted`: string[] — absolute source paths snapshotted
|
|
261
309
|
* - `conflicted`: Array<{path, reason}> where reason ∈
|
|
262
|
-
* '
|
|
310
|
+
* 'not-a-mount' | 'missing-source'
|
|
263
311
|
*/
|
|
264
|
-
export const
|
|
312
|
+
export const mountSkills = async (spec, workspacePath, envFileDir = null) => {
|
|
265
313
|
const sources = Array.isArray(spec?.skills?.claude) ? spec.skills.claude : [];
|
|
266
|
-
const
|
|
267
|
-
const skipped = [];
|
|
314
|
+
const mounted = [];
|
|
268
315
|
const conflicted = [];
|
|
269
|
-
if (sources.length === 0) return {
|
|
316
|
+
if (sources.length === 0) return { mounted, conflicted };
|
|
270
317
|
|
|
271
318
|
const skillsDir = join(workspacePath, '.claude', 'skills');
|
|
272
319
|
await mkdir(skillsDir, { recursive: true });
|
|
@@ -280,47 +327,43 @@ export const linkSkills = async (spec, workspacePath, envFileDir = null) => {
|
|
|
280
327
|
conflicted.push({ path: absSource, reason: 'missing-source' });
|
|
281
328
|
continue;
|
|
282
329
|
}
|
|
283
|
-
const
|
|
330
|
+
const destPath = join(skillsDir, basename(absSource));
|
|
284
331
|
|
|
285
|
-
let
|
|
286
|
-
let slotIsNonSymlink = false;
|
|
332
|
+
let slot = null;
|
|
287
333
|
try {
|
|
288
334
|
// eslint-disable-next-line no-await-in-loop
|
|
289
|
-
|
|
290
|
-
if (stat.isSymbolicLink()) {
|
|
291
|
-
// eslint-disable-next-line no-await-in-loop
|
|
292
|
-
existingTarget = await readlink(linkPath);
|
|
293
|
-
} else {
|
|
294
|
-
slotIsNonSymlink = true;
|
|
295
|
-
}
|
|
335
|
+
slot = await lstat(destPath);
|
|
296
336
|
} catch (err) {
|
|
297
337
|
// Only ENOENT means "slot is free, proceed." EACCES / EPERM / EIO are
|
|
298
|
-
// real failures that should surface
|
|
299
|
-
//
|
|
338
|
+
// real failures that should surface rather than becoming a confusing
|
|
339
|
+
// follow-on error from the copy below.
|
|
300
340
|
if (err.code !== 'ENOENT') throw err;
|
|
301
341
|
}
|
|
302
342
|
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const resolvedExisting = isAbsolute(existingTarget)
|
|
310
|
-
? existingTarget
|
|
311
|
-
: pathResolve(skillsDir, existingTarget);
|
|
312
|
-
if (resolvedExisting === absSource) {
|
|
313
|
-
skipped.push(absSource);
|
|
343
|
+
if (slot) {
|
|
344
|
+
const isLegacySymlink = slot.isSymbolicLink();
|
|
345
|
+
const isOwnedMount = slot.isDirectory() && existsSync(join(destPath, MOUNT_MARKER));
|
|
346
|
+
if (!isLegacySymlink && !isOwnedMount) {
|
|
347
|
+
// A real file, or a directory we did not create — user content.
|
|
348
|
+
conflicted.push({ path: absSource, reason: 'not-a-mount' });
|
|
314
349
|
continue;
|
|
315
350
|
}
|
|
316
|
-
|
|
317
|
-
|
|
351
|
+
// eslint-disable-next-line no-await-in-loop
|
|
352
|
+
await rm(destPath, { recursive: true, force: true });
|
|
318
353
|
}
|
|
319
354
|
|
|
320
355
|
// eslint-disable-next-line no-await-in-loop
|
|
321
|
-
await
|
|
322
|
-
|
|
356
|
+
await cp(absSource, destPath, { recursive: true });
|
|
357
|
+
// eslint-disable-next-line no-await-in-loop
|
|
358
|
+
await writeFile(
|
|
359
|
+
join(destPath, MOUNT_MARKER),
|
|
360
|
+
`mounted-from: ${absSource}\n`,
|
|
361
|
+
'utf8',
|
|
362
|
+
);
|
|
363
|
+
// eslint-disable-next-line no-await-in-loop
|
|
364
|
+
await freezeFiles(destPath);
|
|
365
|
+
mounted.push(absSource);
|
|
323
366
|
}
|
|
324
367
|
|
|
325
|
-
return {
|
|
368
|
+
return { mounted, conflicted };
|
|
326
369
|
};
|
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);
|