@commonlyai/cli 0.1.183 → 0.1.185
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/node_modules/@commonlyai/seats/src/record/format.js +7 -1
- package/node_modules/@commonlyai/seats/src/trace/events.js +2 -2
- package/node_modules/@commonlyai/seats/src/valet/env.js +43 -11
- package/node_modules/@commonlyai/seats/src/valet/grant.js +9 -4
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +60 -27
- package/package.json +1 -1
- package/src/lib/adapters/claude.js +175 -30
- package/src/lib/sandbox/bwrap.js +23 -1
|
@@ -371,7 +371,13 @@ const CHECKS = {
|
|
|
371
371
|
optionalArgsDigest(data, 'tool.call');
|
|
372
372
|
return data;
|
|
373
373
|
},
|
|
374
|
-
'credential.granted': (data) =>
|
|
374
|
+
'credential.granted': (data) => {
|
|
375
|
+
const names = envNames(data.names ?? [], 'credential.granted.names');
|
|
376
|
+
if (data.injected === undefined) return { ...data, names };
|
|
377
|
+
const injected = envNames(data.injected, 'credential.granted.injected');
|
|
378
|
+
if (!injected.every((name) => names.includes(name))) fail('credential.granted.injected must be a subset of names');
|
|
379
|
+
return { ...data, names, injected };
|
|
380
|
+
},
|
|
375
381
|
'credential.denied': (data) => {
|
|
376
382
|
envNames([data.name], 'credential.denied.name');
|
|
377
383
|
return data;
|
|
@@ -23,8 +23,8 @@ export const EVENT_TYPES = Object.freeze([
|
|
|
23
23
|
'usage', // { inputTokens?, outputTokens?, totalTokens?, cost? } null-not-zero when unknown
|
|
24
24
|
'liveness.stalled', // { idleMs, windowMs }
|
|
25
25
|
'liveness.recovery', // { action: 'cancel'|'kill'|'retry', attempt }
|
|
26
|
-
'credential.granted', // { names: [...] } names only, never values
|
|
27
|
-
'credential.denied', // { name, reason?: 'not_granted'|'unresolved'|'too_short' }
|
|
26
|
+
'credential.granted', // { names: [...], injected?: [...] } names only, never values
|
|
27
|
+
'credential.denied', // { name, reason?: 'not_granted'|'unresolved'|'too_short'|'no_upstream' }
|
|
28
28
|
'process.exited', // { code, signal }
|
|
29
29
|
// Record kinds with no earlier trace name (docs/record-v1.md has the full data).
|
|
30
30
|
'task.claimed', // { leaseUntil? } mirrors the board's claim; the board stays the source of truth
|
|
@@ -19,6 +19,7 @@ import { traceOf } from '../trace/events.js';
|
|
|
19
19
|
import { runFailure } from '../failures.js';
|
|
20
20
|
import { MIN_CREDENTIAL_LENGTH } from '../upstream-refusal.js';
|
|
21
21
|
import { validateGrant } from './grant.js';
|
|
22
|
+
import { startProviderProxy } from '../wire/provider-proxy.js';
|
|
22
23
|
|
|
23
24
|
// Same spirit as the goose adapter's SAFE_ENV: locale, terminal and TLS trust
|
|
24
25
|
// only. USER/LOGNAME are left out on purpose; they name the account whose
|
|
@@ -104,8 +105,8 @@ const grantIssued = (meta) => {
|
|
|
104
105
|
};
|
|
105
106
|
|
|
106
107
|
/**
|
|
107
|
-
* prepareRunEnv({ grant, baseEnv, resolveSecret, root?, require?, trace?, ctx?, grantMeta? })
|
|
108
|
-
* → { env, home, cleanup, granted: [names], grantId? }
|
|
108
|
+
* prepareRunEnv({ grant, baseEnv, resolveSecret, root?, require?, trace?, ctx?, grantMeta?, upstreams? })
|
|
109
|
+
* → { env, home, cleanup, granted: [names], injected: { NAME: { baseURL, wire } }, grantId? }
|
|
109
110
|
*
|
|
110
111
|
* `require` lists credential names the adapter cannot run without; one the
|
|
111
112
|
* grant does not name fails the run as credential_denied before any spawn.
|
|
@@ -114,14 +115,25 @@ const grantIssued = (meta) => {
|
|
|
114
115
|
* silently lacks its key fails later and less legibly, and a value that short
|
|
115
116
|
* would slip past the refusal-text reader's redaction.
|
|
116
117
|
*
|
|
118
|
+
* A credential the grant marks `inject: true` never reaches the env. The host
|
|
119
|
+
* names its provider in `upstreams[NAME]` ({ baseURL, wire }, wire defaulting
|
|
120
|
+
* to openai-chat); the run gets a loopback provider proxy holding the value in
|
|
121
|
+
* memory, env[NAME] becomes that proxy's token, and `injected[NAME].baseURL`
|
|
122
|
+
* is where the host points the CLI. cleanup() closes the proxies. One with no
|
|
123
|
+
* upstream fails as credential_denied (no_upstream) before it is resolved.
|
|
124
|
+
* The proxy listens on 127.0.0.1, so a run without the host's network
|
|
125
|
+
* (bwrap --unshare-net) cannot reach it yet.
|
|
126
|
+
*
|
|
117
127
|
* Events go to `trace`, else ctx.trace: grant.issued (only with grantMeta,
|
|
118
|
-
* first), then credential.granted {names} or credential.denied
|
|
128
|
+
* first), then credential.granted {names, injected?} or credential.denied
|
|
129
|
+
* {name, reason}. `injected` lists the names held by a proxy; the rest of
|
|
130
|
+
* `names` are held by the run.
|
|
119
131
|
* Values never enter an event or a message, and a hook that offers
|
|
120
|
-
* redact(values) is handed the resolved values
|
|
121
|
-
* out of agent text too.
|
|
132
|
+
* redact(values) is handed the resolved values and each proxy token, so the
|
|
133
|
+
* record can scrub them out of agent text too.
|
|
122
134
|
*/
|
|
123
135
|
export const prepareRunEnv = async ({
|
|
124
|
-
grant, baseEnv = {}, resolveSecret, root, require: required = [], trace, ctx, grantMeta,
|
|
136
|
+
grant, baseEnv = {}, resolveSecret, root, require: required = [], trace, ctx, grantMeta, upstreams = {},
|
|
125
137
|
} = {}) => {
|
|
126
138
|
const valid = validateGrant(grant);
|
|
127
139
|
const hook = trace && typeof trace.emit === 'function' ? trace : traceOf(ctx);
|
|
@@ -132,6 +144,7 @@ export const prepareRunEnv = async ({
|
|
|
132
144
|
not_granted: 'is not in the run grant',
|
|
133
145
|
unresolved: 'could not be resolved',
|
|
134
146
|
too_short: `resolved to a value shorter than ${MIN_CREDENTIAL_LENGTH} characters`,
|
|
147
|
+
no_upstream: 'is marked inject, but the host named no upstream for it',
|
|
135
148
|
};
|
|
136
149
|
const deny = (name, reason, values) => {
|
|
137
150
|
hook.emit('credential.denied', { name, reason });
|
|
@@ -143,11 +156,18 @@ export const prepareRunEnv = async ({
|
|
|
143
156
|
if (missing) throw deny(missing, 'not_granted', []);
|
|
144
157
|
|
|
145
158
|
const { home, dirs } = await makeHome(root);
|
|
146
|
-
const
|
|
159
|
+
const proxies = [];
|
|
160
|
+
const cleanup = async () => {
|
|
161
|
+
await Promise.all(proxies.splice(0).map((proxy) => proxy.close()));
|
|
162
|
+
await rm(home, { recursive: true, force: true });
|
|
163
|
+
};
|
|
147
164
|
const env = { ...pickBaseEnv(baseEnv, realHomes), HOME: home, ...dirs };
|
|
148
165
|
const values = [];
|
|
166
|
+
const injected = {};
|
|
149
167
|
try {
|
|
150
168
|
for (const spec of valid.credentials) {
|
|
169
|
+
const upstream = spec.inject ? upstreams[spec.name] : undefined;
|
|
170
|
+
if (spec.inject && !upstream) throw deny(spec.name, 'no_upstream', values);
|
|
151
171
|
let value;
|
|
152
172
|
// A resolver's error text may quote the secret it failed on; drop it.
|
|
153
173
|
try { value = await resolveOne(spec, { baseEnv, resolveSecret }); } catch { value = undefined; }
|
|
@@ -156,14 +176,26 @@ export const prepareRunEnv = async ({
|
|
|
156
176
|
// refusal-text reader would not redact it. Refuse it here, before spawn.
|
|
157
177
|
if (value.length < MIN_CREDENTIAL_LENGTH) throw deny(spec.name, 'too_short', values);
|
|
158
178
|
values.push(value);
|
|
159
|
-
|
|
179
|
+
if (upstream) {
|
|
180
|
+
const wire = upstream.wire ?? 'openai-chat';
|
|
181
|
+
const proxy = await startProviderProxy({
|
|
182
|
+
provider: { baseURL: upstream.baseURL }, keyMaterial: value, wire, adapterLabel: 'Valet',
|
|
183
|
+
});
|
|
184
|
+
proxies.push(proxy);
|
|
185
|
+
env[spec.name] = proxy.token;
|
|
186
|
+
injected[spec.name] = { baseURL: proxy.baseURL, wire };
|
|
187
|
+
} else {
|
|
188
|
+
env[spec.name] = value;
|
|
189
|
+
}
|
|
160
190
|
}
|
|
161
191
|
} catch (error) {
|
|
162
192
|
await cleanup();
|
|
163
193
|
throw error;
|
|
164
194
|
}
|
|
165
|
-
|
|
195
|
+
// A proxy token authorizes provider calls until cleanup, so the record scrubs it too.
|
|
196
|
+
if (typeof hook.redact === 'function') hook.redact([...values, ...proxies.map((proxy) => proxy.token)]);
|
|
166
197
|
const names = valid.credentials.map((c) => c.name);
|
|
167
|
-
|
|
168
|
-
|
|
198
|
+
const proxied = Object.keys(injected);
|
|
199
|
+
hook.emit('credential.granted', proxied.length ? { names, injected: proxied } : { names });
|
|
200
|
+
return { env, home, cleanup, granted: names, injected, ...(issued ? { grantId: issued.grantId } : {}) };
|
|
169
201
|
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* and which tool calls it may make without asking, only after asking, or never.
|
|
4
4
|
*
|
|
5
5
|
* {
|
|
6
|
-
* credentials: [{ name: 'OPENAI_API_KEY', from: 'env'|'file'|'resolver', ref? }],
|
|
6
|
+
* credentials: [{ name: 'OPENAI_API_KEY', from: 'env'|'file'|'resolver', ref?, inject? }],
|
|
7
7
|
* tools: { allow: [pattern], ask: [pattern], deny: [pattern] },
|
|
8
8
|
* }
|
|
9
9
|
*
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* path in `ref`; `from: 'resolver'` asks the host's resolveSecret(name, spec),
|
|
12
12
|
* with `ref` as an opaque hint for it.
|
|
13
13
|
*
|
|
14
|
+
* `inject: true` keeps the value out of the run: its env gets a per-run proxy
|
|
15
|
+
* token under the name instead, and a loopback provider proxy adds the real
|
|
16
|
+
* key to outbound requests. Default false: the run holds the value.
|
|
17
|
+
*
|
|
14
18
|
* A tool pattern is `<kind>` or `<kind>:<title glob>`, where kind is one of
|
|
15
19
|
* ACP's ToolKind values or `*`. `execute` matches every execute call;
|
|
16
20
|
* `execute:git status*` matches execute calls whose title starts that way;
|
|
@@ -61,8 +65,8 @@ const parsePattern = (pattern, where) => {
|
|
|
61
65
|
const validateCredential = (credential, index, seen) => {
|
|
62
66
|
const where = `credentials[${index}]`;
|
|
63
67
|
if (!isPlainObject(credential)) fail(`${where} must be an object`);
|
|
64
|
-
refuseUnknownKeys(credential, ['name', 'from', 'ref'], `${where}.`);
|
|
65
|
-
const { name, from, ref } = credential;
|
|
68
|
+
refuseUnknownKeys(credential, ['name', 'from', 'ref', 'inject'], `${where}.`);
|
|
69
|
+
const { name, from, ref, inject } = credential;
|
|
66
70
|
if (typeof name !== 'string' || !ENV_NAME.test(name)) fail(`${where}.name must be an ENV_NAME (A-Z, 0-9, _)`);
|
|
67
71
|
if (RESERVED_NAME.test(name)) fail(`${where}.name ${name} is reserved for the run env`);
|
|
68
72
|
if (seen.has(name)) fail(`${where}.name ${name} is granted twice`);
|
|
@@ -71,7 +75,8 @@ const validateCredential = (credential, index, seen) => {
|
|
|
71
75
|
if (ref !== undefined && (typeof ref !== 'string' || !ref)) fail(`${where}.ref must be a non-empty string`);
|
|
72
76
|
if (from === 'env' && ref !== undefined && !ENV_NAME.test(ref)) fail(`${where}.ref must be an ENV_NAME for from: env`);
|
|
73
77
|
if (from === 'file' && (ref === undefined || !ref.startsWith('/'))) fail(`${where}.ref must be an absolute path for from: file`);
|
|
74
|
-
|
|
78
|
+
if (inject !== undefined && typeof inject !== 'boolean') fail(`${where}.inject must be true or false`);
|
|
79
|
+
return Object.freeze({ name, from, ...(ref === undefined ? {} : { ref }), ...(inject ? { inject } : {}) });
|
|
75
80
|
};
|
|
76
81
|
|
|
77
82
|
/** Validate and freeze a grant. Throws on the first problem; never repairs one. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Transform } from 'node:stream';
|
|
2
2
|
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
-
/** The per-spawn loopback proxy
|
|
3
|
+
/** The per-spawn loopback proxy that holds a provider key for a CLI (OpenAI chat, Anthropic Messages, OpenAI Responses). */
|
|
4
4
|
import { randomBytes, timingSafeEqual } from 'crypto';
|
|
5
5
|
import { constants as fsConstants, realpathSync } from 'fs';
|
|
6
6
|
import { createServer, request as httpRequest } from 'http';
|
|
@@ -167,11 +167,30 @@ const readProviderKey = async (keyFile, adapterLabel) => {
|
|
|
167
167
|
}
|
|
168
168
|
};
|
|
169
169
|
|
|
170
|
+
// The routes a CLI on each wire may call, relative to the configured base
|
|
171
|
+
// path, and the header the real key goes out in. Anything else is refused.
|
|
172
|
+
const WIRES = Object.freeze({
|
|
173
|
+
'openai-chat': { routes: [['POST', 'chat/completions'], ['GET', 'models']], auth: 'bearer' },
|
|
174
|
+
'anthropic-messages': {
|
|
175
|
+
routes: [['POST', 'v1/messages'], ['POST', 'v1/messages/count_tokens'], ['GET', 'v1/models']],
|
|
176
|
+
auth: 'x-api-key',
|
|
177
|
+
},
|
|
178
|
+
'openai-responses': { routes: [['POST', 'responses'], ['GET', 'models']], auth: 'bearer' },
|
|
179
|
+
});
|
|
180
|
+
export const PROVIDER_WIRES = Object.freeze(Object.keys(WIRES));
|
|
181
|
+
|
|
182
|
+
const presents = (value, expected) => {
|
|
183
|
+
const supplied = Buffer.from(typeof value === 'string' ? value : '');
|
|
184
|
+
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
|
185
|
+
};
|
|
186
|
+
|
|
170
187
|
export const startProviderProxy = async ({
|
|
171
|
-
provider, keyFile, keyMaterial, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode',
|
|
188
|
+
provider, keyFile, keyMaterial, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode', wire = 'openai-chat',
|
|
172
189
|
// When set, every completion must request this model and be served by it.
|
|
173
190
|
pinnedModel,
|
|
174
191
|
}) => {
|
|
192
|
+
const wireSpec = WIRES[wire];
|
|
193
|
+
if (!wireSpec) throw new Error(`${adapterLabel} provider wire must be one of ${PROVIDER_WIRES.join(', ')}`);
|
|
175
194
|
if (pinnedModel !== undefined && (typeof pinnedModel !== 'string' || !pinnedModel.trim())) {
|
|
176
195
|
throw new Error(`${adapterLabel} pinnedModel must be a non-empty string`);
|
|
177
196
|
}
|
|
@@ -189,8 +208,13 @@ export const startProviderProxy = async ({
|
|
|
189
208
|
const upstreamKey = keyMaterial === undefined ? await readProviderKey(keyFile, adapterLabel) : keyMaterial;
|
|
190
209
|
if (typeof upstreamKey !== 'string' || !upstreamKey || /[\r\n]/.test(upstreamKey)) throw new Error('Invalid provider key');
|
|
191
210
|
const proxyToken = randomBytes(32).toString('base64url');
|
|
192
|
-
|
|
193
|
-
|
|
211
|
+
// A caller that hands the token to its child in memory needs no file.
|
|
212
|
+
if (tokenFile) {
|
|
213
|
+
await writeFile(tokenFile, proxyToken, { encoding: 'utf8', mode: 0o600 });
|
|
214
|
+
await chmod(tokenFile, 0o600);
|
|
215
|
+
}
|
|
216
|
+
const bearerToken = Buffer.from(`Bearer ${proxyToken}`);
|
|
217
|
+
const bareToken = Buffer.from(proxyToken);
|
|
194
218
|
|
|
195
219
|
const logRefusedRequest = (method, requestPath) => {
|
|
196
220
|
let normalizedPath = '/';
|
|
@@ -204,29 +228,34 @@ export const startProviderProxy = async ({
|
|
|
204
228
|
process.stderr.write(`[${adapterLabel.toLowerCase()}] provider proxy refused ${safeMethod} ${normalizedPath}\n`);
|
|
205
229
|
};
|
|
206
230
|
|
|
231
|
+
// A refusal answers before the request body has been read. A keep-alive
|
|
232
|
+
// client that reuses that socket can have its next request reset, so every
|
|
233
|
+
// refusal on every wire also closes the connection.
|
|
234
|
+
const refuse = (incoming, outgoing, status, headers = {}, body = undefined) => {
|
|
235
|
+
incoming.resume();
|
|
236
|
+
outgoing.writeHead(status, { ...headers, connection: 'close' });
|
|
237
|
+
outgoing.end(body);
|
|
238
|
+
};
|
|
239
|
+
|
|
207
240
|
const server = createServer((incoming, outgoing) => {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
incoming
|
|
212
|
-
outgoing.writeHead(401).end();
|
|
241
|
+
// Anthropic clients send their key as x-api-key, the others as a Bearer.
|
|
242
|
+
if (!presents(incoming.headers.authorization, bearerToken)
|
|
243
|
+
&& !presents(incoming.headers['x-api-key'], bareToken)) {
|
|
244
|
+
refuse(incoming, outgoing, 401);
|
|
213
245
|
return;
|
|
214
246
|
}
|
|
215
247
|
if (!['GET', 'POST'].includes(incoming.method || '')) {
|
|
216
|
-
incoming
|
|
217
|
-
outgoing.writeHead(405).end();
|
|
248
|
+
refuse(incoming, outgoing, 405);
|
|
218
249
|
return;
|
|
219
250
|
}
|
|
220
251
|
const rejectRoute = (path) => {
|
|
221
252
|
logRefusedRequest(incoming.method, path);
|
|
222
|
-
incoming
|
|
223
|
-
|
|
224
|
-
outgoing.end(`Unsupported provider request: ${incoming.method || 'UNKNOWN'} ${path}`);
|
|
253
|
+
refuse(incoming, outgoing, 403, { 'content-type': 'text/plain; charset=utf-8' },
|
|
254
|
+
`Unsupported provider request: ${incoming.method || 'UNKNOWN'} ${path}`);
|
|
225
255
|
};
|
|
226
256
|
const rejectBadRequest = () => {
|
|
227
257
|
logRefusedRequest(incoming.method, incoming.url);
|
|
228
|
-
incoming
|
|
229
|
-
outgoing.writeHead(400).end();
|
|
258
|
+
refuse(incoming, outgoing, 400);
|
|
230
259
|
};
|
|
231
260
|
|
|
232
261
|
let target;
|
|
@@ -241,8 +270,8 @@ export const startProviderProxy = async ({
|
|
|
241
270
|
return;
|
|
242
271
|
}
|
|
243
272
|
// The CLI owns this local request path, so never use it as an outbound
|
|
244
|
-
// URL.
|
|
245
|
-
//
|
|
273
|
+
// URL. Each wire needs only the fixed routes in WIRES; parse the
|
|
274
|
+
// incoming URL against a loopback sentinel, then select a
|
|
246
275
|
// constant endpoint after matching the configured base path exactly.
|
|
247
276
|
// This keeps a CLI request from turning the proxy into an arbitrary
|
|
248
277
|
// path/URL fetcher (including paths on a local provider host).
|
|
@@ -252,17 +281,13 @@ export const startProviderProxy = async ({
|
|
|
252
281
|
return;
|
|
253
282
|
}
|
|
254
283
|
const configuredPath = basePath === '/' ? '' : basePath;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
endpoint = 'chat/completions';
|
|
259
|
-
} else if (incoming.method === 'GET'
|
|
260
|
-
&& requested.pathname === `${configuredPath}/models`) {
|
|
261
|
-
endpoint = 'models';
|
|
262
|
-
} else {
|
|
284
|
+
const route = wireSpec.routes.find(([method, path]) => incoming.method === method
|
|
285
|
+
&& requested.pathname === `${configuredPath}/${path}`);
|
|
286
|
+
if (!route) {
|
|
263
287
|
rejectRoute(requested.pathname);
|
|
264
288
|
return;
|
|
265
289
|
}
|
|
290
|
+
const endpoint = route[1];
|
|
266
291
|
target = {
|
|
267
292
|
path: `${configuredPath}/${endpoint}`,
|
|
268
293
|
protocol: upstream.protocol,
|
|
@@ -281,7 +306,8 @@ export const startProviderProxy = async ({
|
|
|
281
306
|
'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
282
307
|
'x-api-key',
|
|
283
308
|
].includes(name.toLowerCase())));
|
|
284
|
-
|
|
309
|
+
if (wireSpec.auth === 'x-api-key') headers['x-api-key'] = upstreamKey;
|
|
310
|
+
else headers.authorization = `Bearer ${upstreamKey}`;
|
|
285
311
|
headers.host = target.host;
|
|
286
312
|
headers['accept-encoding'] = 'identity';
|
|
287
313
|
const pinned = pinnedModel !== undefined && target.path.endsWith('/chat/completions');
|
|
@@ -378,6 +404,12 @@ export const startProviderProxy = async ({
|
|
|
378
404
|
});
|
|
379
405
|
});
|
|
380
406
|
|
|
407
|
+
// A WebSocket client (codex on the Responses wire) falls back to HTTP SSE on
|
|
408
|
+
// 426. Nothing is relayed over an upgrade, so no token check is needed first.
|
|
409
|
+
server.on('upgrade', (_incoming, socket) => {
|
|
410
|
+
socket.end('HTTP/1.1 426 Upgrade Required\r\nconnection: close\r\ncontent-length: 0\r\n\r\n');
|
|
411
|
+
});
|
|
412
|
+
|
|
381
413
|
await new Promise((resolve, reject) => {
|
|
382
414
|
server.once('error', reject);
|
|
383
415
|
const started = () => {
|
|
@@ -399,6 +431,7 @@ export const startProviderProxy = async ({
|
|
|
399
431
|
const proxyBaseURL = new URL(upstream.pathname, `http://127.0.0.1:${port}`).toString();
|
|
400
432
|
return {
|
|
401
433
|
baseURL: proxyBaseURL,
|
|
434
|
+
token: proxyToken,
|
|
402
435
|
tokenFile,
|
|
403
436
|
socketPath,
|
|
404
437
|
port,
|
package/package.json
CHANGED
|
@@ -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 };
|
|
@@ -732,6 +824,9 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
732
824
|
// get the jail only — a derived Linux seat kept Bash/Write/WebFetch and ran on
|
|
733
825
|
// the operator's own Claude settings inside the namespace (Vera 69578).
|
|
734
826
|
const publicBwrapSandbox = sandboxTrust === 'public' && sandboxMode === 'bwrap';
|
|
827
|
+
// A workspace seat on Linux: the same jail, with the workspace tier's policy
|
|
828
|
+
// and a HOME of its own outside the project.
|
|
829
|
+
const workspaceBwrapSandbox = sandboxTrust === 'workspace' && sandboxMode === 'bwrap';
|
|
735
830
|
const commitHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_HOOKS_PATH || null;
|
|
736
831
|
const originalHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH || null;
|
|
737
832
|
const commitHookExecutables = commitHooksPath
|
|
@@ -754,12 +849,6 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
754
849
|
// non-string) used to fall through to the bare `claude` spawn below — an
|
|
755
850
|
// unconfined seat with a public record, which is how a record can claim
|
|
756
851
|
// 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
852
|
if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') {
|
|
764
853
|
throw new Error(
|
|
765
854
|
`${sandboxTrust} Claude agents require an enforced sandbox mode `
|
|
@@ -818,23 +907,71 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
818
907
|
};
|
|
819
908
|
}
|
|
820
909
|
|
|
821
|
-
if (publicBwrapSandbox) {
|
|
910
|
+
if (publicBwrapSandbox || workspaceBwrapSandbox) {
|
|
822
911
|
// The mode is chosen here, so the mechanism is verified here: a missing
|
|
823
912
|
// bwrap must fail the seat's derivation with an actionable message, not
|
|
824
913
|
// every one of its spawns with a bare ENOENT (Wren 69586). On a non-Linux
|
|
825
914
|
// host this reports the macOS-only message, same as the wrapper would.
|
|
826
915
|
const bwrap = (ctx._detectBwrap || detectBwrap)();
|
|
827
916
|
if (!bwrap.available) {
|
|
828
|
-
throw new Error(
|
|
917
|
+
throw new Error(`${sandboxTrust} Claude agents require bwrap: ${bwrap.error}`);
|
|
918
|
+
}
|
|
919
|
+
if (workspaceBwrapSandbox && !ctx.publicClaudeState) {
|
|
920
|
+
throw new Error('workspace Claude state was not prepared before argv construction');
|
|
829
921
|
}
|
|
830
922
|
innerArgv = [
|
|
831
923
|
...innerArgv,
|
|
832
|
-
...buildPublicClaudePolicyArgs(allowedPatterns,
|
|
924
|
+
...buildPublicClaudePolicyArgs(allowedPatterns, workspaceBwrapSandbox
|
|
925
|
+
? { trust: sandboxTrust, allow: grants, memoryWithheld: ctx.memoryWithheld === true }
|
|
926
|
+
: { memoryWithheld: ctx.memoryWithheld === true }),
|
|
833
927
|
...(ctx.publicSkillArgs || []),
|
|
834
928
|
];
|
|
835
929
|
} else if (allowedPatterns.length > 0) {
|
|
836
930
|
innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
|
|
837
931
|
}
|
|
932
|
+
if (workspaceBwrapSandbox) {
|
|
933
|
+
// Seatbelt denies writes under .git by path, present or not. A bwrap mount
|
|
934
|
+
// needs the path to exist, so a writable project without a .git could get
|
|
935
|
+
// one from the child, hooks included, that the person's next git command
|
|
936
|
+
// runs. Refuse that rather than create anything in their project.
|
|
937
|
+
if (grants.includes('write')) {
|
|
938
|
+
const gitEntry = await statOrNull(join(ctx.cwd, '.git'));
|
|
939
|
+
if (!gitEntry || gitEntry.isSymbolicLink()) {
|
|
940
|
+
throw new Error('workspace Claude agents with write on Linux need the project to be a git '
|
|
941
|
+
+ `repository (run \`git init\` in ${ctx.cwd}), so its .git can be held read-only`);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
const claudeBin = resolveClaudePath(claudeEnv);
|
|
945
|
+
const { statePath } = ctx.publicClaudeState;
|
|
946
|
+
let claudeInstall = null;
|
|
947
|
+
try {
|
|
948
|
+
claudeInstall = dirname(realpathSync(claudeBin));
|
|
949
|
+
} catch { /* an unresolvable binary fails at spawn with its own error */ }
|
|
950
|
+
const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
|
|
951
|
+
workspacePath: ctx.cwd,
|
|
952
|
+
workspaceReadOnly: !grants.includes('write'),
|
|
953
|
+
readOnlyWorkspaceEntries: ['.git'],
|
|
954
|
+
homePath: statePath,
|
|
955
|
+
writablePaths: [statePath],
|
|
956
|
+
readOnlyPaths: [
|
|
957
|
+
ctx.mcpConfigDir, commitHooksPath, originalHooksPath,
|
|
958
|
+
...(ctx.mcpReadDirectories || []),
|
|
959
|
+
...(ctx.workspaceGit?.readDirectories || []),
|
|
960
|
+
...(ctx.workspaceGit?.readPaths || []),
|
|
961
|
+
].filter(Boolean),
|
|
962
|
+
// The binaries the jail runs: claude (a user-local install is outside
|
|
963
|
+
// the default binds, and its link points into a versions folder), the
|
|
964
|
+
// node that runs the stdio MCP servers, and the git the history tool runs.
|
|
965
|
+
optionalReadOnlyPaths: [...new Set([
|
|
966
|
+
dirname(claudeBin), claudeInstall, dirname(process.execPath),
|
|
967
|
+
ctx.workspaceGit ? dirname(ctx.workspaceGit.executable) : null,
|
|
968
|
+
// A claude not found on PATH is left bare and fails at spawn; its
|
|
969
|
+
// dirname is '.', which is not a path to bind.
|
|
970
|
+
].filter((p) => p && isAbsolute(p)))],
|
|
971
|
+
});
|
|
972
|
+
// TASK-440 reads the transcript from the HOME the child wrote under.
|
|
973
|
+
return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv, childHome: statePath };
|
|
974
|
+
}
|
|
838
975
|
if (sandboxMode === 'bwrap') {
|
|
839
976
|
const claudeBin = resolveClaudePath(claudeEnv);
|
|
840
977
|
const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
|
|
@@ -974,6 +1111,8 @@ export default {
|
|
|
974
1111
|
&& PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox));
|
|
975
1112
|
const publicBwrapSandbox = publicTrust
|
|
976
1113
|
&& resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
|
|
1114
|
+
const workspaceBwrapSandbox = ctx.environment?.sandbox?.trust === 'workspace'
|
|
1115
|
+
&& resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
|
|
977
1116
|
try {
|
|
978
1117
|
// Trust is normalized at entry (legacy `internal` reads as public). The
|
|
979
1118
|
// MCP policy follows trust alone, whatever confines the seat: a public
|
|
@@ -981,6 +1120,12 @@ export default {
|
|
|
981
1120
|
// (Rhea on #2247).
|
|
982
1121
|
if (publicNativeSandbox) {
|
|
983
1122
|
publicClaudeState = await preparePublicClaudeState(ctx);
|
|
1123
|
+
} else if (workspaceBwrapSandbox) {
|
|
1124
|
+
// Checked before the state is prepared, so a host that cannot run the
|
|
1125
|
+
// jail never gets a copy of the login.
|
|
1126
|
+
const bwrap = (ctx._detectBwrap || detectBwrap)();
|
|
1127
|
+
if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`);
|
|
1128
|
+
publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx);
|
|
984
1129
|
}
|
|
985
1130
|
if (publicTrust) {
|
|
986
1131
|
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
|
|