@commonlyai/cli 0.1.60 → 0.1.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { spawn } from 'node:child_process';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
closeSync, readFileSync, existsSync,
|
|
23
|
+
} from 'node:fs';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* The grant broker's path. wren's ruling for the daemon-side half of TASK-063:
|
|
@@ -86,10 +89,172 @@ export const connectMcp = (server, opts = {}) => (typeof server?.url === 'string
|
|
|
86
89
|
? connectHttpMcp(server, opts)
|
|
87
90
|
: connectStdioMcp(server, opts));
|
|
88
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The credential channel (TASK-078, ruled 2026-09-19: "take the token out of
|
|
94
|
+
* the environment", inherited pipe).
|
|
95
|
+
*
|
|
96
|
+
* `connectStdioMcp` spawns each declared stdio server with
|
|
97
|
+
* `{...process.env, ...env}`, and the default declaration puts the seat's
|
|
98
|
+
* runtime token in that `env` map. So the token sat in the MCP child's
|
|
99
|
+
* environment, where any same-user process could read it back with
|
|
100
|
+
* `ps eww <pid>` or `/proc/<pid>/environ` — including, on a shared host, a
|
|
101
|
+
* process the seat is not allowed to talk to.
|
|
102
|
+
*
|
|
103
|
+
* Now the token rides an inherited pipe on fd 3 and the child's environment
|
|
104
|
+
* carries only a pointer to it (`COMMONLY_TOKEN_FD=3`) — not a secret. The
|
|
105
|
+
* child end of that pipe is read to EOF.
|
|
106
|
+
*
|
|
107
|
+
* THE PIPE IS ONLY AVAILABLE WHERE WE SPAWN. This is the pi path; claude and
|
|
108
|
+
* codex let their own CLI start the server (claude expands `${VAR}` in its own
|
|
109
|
+
* process env, codex rides `mcp_servers.*.env_vars`), so a pipe opened here
|
|
110
|
+
* never reaches that grandchild. Those two still hand the token over in their
|
|
111
|
+
* runtime's environment, which is a separate, still-open half of the same row.
|
|
112
|
+
*
|
|
113
|
+
* THE OLD SERVER STILL WORKS. `@commonlyai/mcp` only learned to read the pipe in
|
|
114
|
+
* 0.3.11, and a seat may pin an older one — the sprint seats run a staging
|
|
115
|
+
* checkout of 0.3.4 — so a declaration whose command names an older
|
|
116
|
+
* `@commonlyai/mcp` keeps the environment variable AS WELL, with a warning that
|
|
117
|
+
* names the pin. An operator can also opt out explicitly with
|
|
118
|
+
* `COMMONLY_TOKEN_CHANNEL=env` in the entry's own env. Otherwise the token is
|
|
119
|
+
* piped and the environment is left clean.
|
|
120
|
+
*/
|
|
121
|
+
export const CREDENTIAL_KEY = 'COMMONLY_AGENT_TOKEN';
|
|
122
|
+
export const CREDENTIAL_FD_VAR = 'COMMONLY_TOKEN_FD';
|
|
123
|
+
export const CREDENTIAL_CHANNEL_VAR = 'COMMONLY_TOKEN_CHANNEL';
|
|
124
|
+
export const CREDENTIAL_FD = 3;
|
|
125
|
+
|
|
126
|
+
/** The `@commonlyai/mcp` release whose `loadConfig` reads the pipe channel. */
|
|
127
|
+
export const PIPE_READER_VERSION = [0, 3, 11];
|
|
128
|
+
|
|
129
|
+
const MCP_PACKAGE = '@commonlyai/mcp';
|
|
130
|
+
|
|
131
|
+
const parseVersion = (spec) => {
|
|
132
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(spec || '').trim());
|
|
133
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const olderThanPipeReader = (version) => {
|
|
137
|
+
if (!version) return null;
|
|
138
|
+
for (let i = 0; i < 3; i += 1) {
|
|
139
|
+
if (version[i] !== PIPE_READER_VERSION[i]) return version[i] < PIPE_READER_VERSION[i];
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* What an `@commonlyai/mcp` command would run, or null when the command cannot
|
|
146
|
+
* be identified as that package at all.
|
|
147
|
+
*
|
|
148
|
+
* Two shapes matter: `npx [-y] @commonlyai/mcp@<spec>` (a spec is a version, or
|
|
149
|
+
* `latest`/absent, which resolves to whatever is published — never treated as
|
|
150
|
+
* old), and a local checkout, `node <path>/src/index.js`, which is what the
|
|
151
|
+
* staging seats run; for that one the package.json beside it is the only honest
|
|
152
|
+
* answer, and a package.json naming something else means this is not our server.
|
|
153
|
+
*
|
|
154
|
+
* `{ isCommonly: true, version: null }` means "our server, version unknown" —
|
|
155
|
+
* an unpinned npx spec, whose whole point is that it tracks the published one.
|
|
156
|
+
* `null` as the return value means "not identifiable as our server", which is a
|
|
157
|
+
* different answer and takes a different branch: a stranger's server gets its
|
|
158
|
+
* declaration honoured unchanged.
|
|
159
|
+
*/
|
|
160
|
+
export const describeMcpCommand = (command, { readTextFile = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null) } = {}) => {
|
|
161
|
+
if (!Array.isArray(command) || command.length === 0) return null;
|
|
162
|
+
const parts = command.map(String);
|
|
163
|
+
const pkgArg = parts.find((p) => p.includes(MCP_PACKAGE));
|
|
164
|
+
if (pkgArg) {
|
|
165
|
+
const at = pkgArg.lastIndexOf('@');
|
|
166
|
+
if (at <= pkgArg.indexOf(MCP_PACKAGE)) return { isCommonly: true, version: null };
|
|
167
|
+
return { isCommonly: true, version: parseVersion(pkgArg.slice(at + 1)) };
|
|
168
|
+
}
|
|
169
|
+
const scriptPath = parts.find((p) => p.endsWith('.js') || p.endsWith('.mjs'));
|
|
170
|
+
if (!scriptPath) return null;
|
|
171
|
+
// `src/index.js` → `../package.json`; also try one level further up, because a
|
|
172
|
+
// bin shim can live in `bin/` beside `src/`.
|
|
173
|
+
for (const candidate of [join(dirname(scriptPath), '..', 'package.json'), join(dirname(scriptPath), 'package.json')]) {
|
|
174
|
+
let raw;
|
|
175
|
+
try {
|
|
176
|
+
raw = readTextFile(candidate);
|
|
177
|
+
} catch {
|
|
178
|
+
raw = null;
|
|
179
|
+
}
|
|
180
|
+
if (!raw) continue;
|
|
181
|
+
try {
|
|
182
|
+
const pkg = JSON.parse(raw);
|
|
183
|
+
if (!pkg || typeof pkg !== 'object') continue;
|
|
184
|
+
if (pkg.name === MCP_PACKAGE) return { isCommonly: true, version: parseVersion(pkg.version) };
|
|
185
|
+
// A package.json that names another package settles it: not ours, so its
|
|
186
|
+
// declaration is none of this function's business.
|
|
187
|
+
return null;
|
|
188
|
+
} catch {
|
|
189
|
+
// A malformed package.json is not an answer; keep looking.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Split a declared env map into the child's environment and the credential to
|
|
197
|
+
* hand over the pipe.
|
|
198
|
+
*
|
|
199
|
+
* Returns `{ env, credential, keepInEnv }`. `keepInEnv` is true only when the
|
|
200
|
+
* server cannot read the pipe — it predates the reader, it is somebody else's
|
|
201
|
+
* server, or the declaration opted out explicitly. Everything else gets the
|
|
202
|
+
* pointer variable and no secret.
|
|
203
|
+
*/
|
|
204
|
+
export const splitCredential = (env, command, { onWarn = (m) => process.stderr.write(`${m}\n`) } = {}) => {
|
|
205
|
+
const declared = { ...(env || {}) };
|
|
206
|
+
const credential = declared[CREDENTIAL_KEY];
|
|
207
|
+
const requested = String(declared[CREDENTIAL_CHANNEL_VAR] || '').trim().toLowerCase();
|
|
208
|
+
delete declared[CREDENTIAL_CHANNEL_VAR];
|
|
209
|
+
if (!credential) {
|
|
210
|
+
delete declared[CREDENTIAL_KEY];
|
|
211
|
+
return { env: declared, credential: null, keepInEnv: false };
|
|
212
|
+
}
|
|
213
|
+
if (requested === 'env') {
|
|
214
|
+
return { env: declared, credential: null, keepInEnv: true };
|
|
215
|
+
}
|
|
216
|
+
const server = describeMcpCommand(command);
|
|
217
|
+
if (!server) {
|
|
218
|
+
// Not identifiable as @commonlyai/mcp. A declaration that put this key in a
|
|
219
|
+
// stranger's environment asked for it to be there, and that server has no
|
|
220
|
+
// reason to know about a pipe; changing its contract is not this change's
|
|
221
|
+
// business.
|
|
222
|
+
return { env: declared, credential: null, keepInEnv: true };
|
|
223
|
+
}
|
|
224
|
+
if (server.version && olderThanPipeReader(server.version) === true) {
|
|
225
|
+
onWarn(`[pi-mcp-client] ${command[0]} runs ${MCP_PACKAGE} ${server.version.join('.')}, which predates the pipe channel (0.3.11): keeping the token in the child environment. Unpin it, or set ${CREDENTIAL_CHANNEL_VAR}=env to say so on purpose.`);
|
|
226
|
+
return { env: declared, credential: null, keepInEnv: true };
|
|
227
|
+
}
|
|
228
|
+
delete declared[CREDENTIAL_KEY];
|
|
229
|
+
declared[CREDENTIAL_FD_VAR] = String(CREDENTIAL_FD);
|
|
230
|
+
return { env: declared, credential, keepInEnv: false };
|
|
231
|
+
};
|
|
232
|
+
|
|
89
233
|
/** A minimal MCP stdio client: initialize, tools/list, tools/call. */
|
|
90
|
-
export const connectStdioMcp = ({
|
|
234
|
+
export const connectStdioMcp = ({
|
|
235
|
+
name, command, env,
|
|
236
|
+
}, {
|
|
237
|
+
spawnImpl = spawn, timeoutMs = 60_000, onWarn,
|
|
238
|
+
} = {}) => {
|
|
91
239
|
const [cmd, ...args] = command;
|
|
92
|
-
const
|
|
240
|
+
const { env: declaredEnv, credential, keepInEnv } = splitCredential(env, command, onWarn ? { onWarn } : {});
|
|
241
|
+
// The inherited environment is stripped of the key unless an old server has to
|
|
242
|
+
// read it there: the daemon's own environment is not a channel into a child,
|
|
243
|
+
// and `...process.env` used to make it one.
|
|
244
|
+
const childEnv = { ...process.env, ...declaredEnv };
|
|
245
|
+
if (!keepInEnv) delete childEnv[CREDENTIAL_KEY];
|
|
246
|
+
const stdio = credential ? ['pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'];
|
|
247
|
+
const proc = spawnImpl(cmd, args, { env: childEnv, stdio });
|
|
248
|
+
if (credential) {
|
|
249
|
+
const channel = proc.stdio && proc.stdio[CREDENTIAL_FD];
|
|
250
|
+
if (!channel) {
|
|
251
|
+
// Fail loudly rather than fall back: the environment it would fall back to
|
|
252
|
+
// is the thing this change exists to empty.
|
|
253
|
+
throw new Error(`${name}: no fd ${CREDENTIAL_FD} pipe to carry the runtime token`);
|
|
254
|
+
}
|
|
255
|
+
channel.on('error', () => {});
|
|
256
|
+
channel.end(credential);
|
|
257
|
+
}
|
|
93
258
|
const pending = new Map();
|
|
94
259
|
let nextId = 1;
|
|
95
260
|
let buffer = '';
|
|
@@ -4,6 +4,7 @@ import { isAbsolute, resolve as pathResolve } from 'node:path';
|
|
|
4
4
|
import { auditDeclaredMcp, installedStdioEntries } from './declared-mcp-guard.js';
|
|
5
5
|
|
|
6
6
|
import { seatBaseline } from './default-environment.js';
|
|
7
|
+
import { withholdGrantBroker } from './grant-broker-guard.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* ADR-026 Phase 2, slice 2: the resident supervision loop behind
|
|
@@ -172,6 +173,27 @@ export const createDaemonSupervisor = ({
|
|
|
172
173
|
// Returns 'ready' | 'changed' (record updated — the seat must restart to
|
|
173
174
|
// load it) | false.
|
|
174
175
|
const ensureToken = async (row) => {
|
|
176
|
+
// One derive for this seat, so the broker refusal cannot be applied at three
|
|
177
|
+
// of four sites: `seatBaseline` is where an environment becomes the one the
|
|
178
|
+
// seat RUNS under, and `withholdGrantBroker` asks the same question the
|
|
179
|
+
// server asks (`can this seat confine a granter's authority?`) for the cases
|
|
180
|
+
// the server cannot see — a row naming no adapter, a backend older than the
|
|
181
|
+
// refusal, a record written by hand. Entry-level per wren 69829: the broker
|
|
182
|
+
// entry is withheld and the seat still starts, because it was never promised
|
|
183
|
+
// confinement. `record.instanceUrl` is what makes the injected url
|
|
184
|
+
// identifiable at all — the record holds the UNRESOLVED
|
|
185
|
+
// `${COMMONLY_API_URL}/api/mcp/grants/<id>` placeholder.
|
|
186
|
+
const derive = (environment, adapter, options) => withholdGrantBroker(
|
|
187
|
+
seatBaseline(environment, adapter, options),
|
|
188
|
+
adapter,
|
|
189
|
+
{
|
|
190
|
+
instanceUrl: record.instanceUrl,
|
|
191
|
+
onRefuse: (refusal, names) => log(
|
|
192
|
+
`[${row.agentName}] ${refusal.code} (${refusal.reason}) — withholding ${names.join(', ')}: ${refusal.detail}`,
|
|
193
|
+
),
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
|
|
175
197
|
const existing = loadToken(row.agentName);
|
|
176
198
|
if (existing) {
|
|
177
199
|
// A model changed in the UI reaches the seat here: update the record,
|
|
@@ -214,7 +236,7 @@ export const createDaemonSupervisor = ({
|
|
|
214
236
|
// defaults to 'none' in the adapters, so an omitted block is an
|
|
215
237
|
// unconfined seat (TASK-052). A local record with no environment at
|
|
216
238
|
// all is in the same position — nobody has authored anything.
|
|
217
|
-
const nextEnvironment =
|
|
239
|
+
const nextEnvironment = derive(merged, nextAdapter, {
|
|
218
240
|
sandbox: wanted.declared || !existing.environment,
|
|
219
241
|
});
|
|
220
242
|
const workspacePath = workspacePathFor(nextEnvironment);
|
|
@@ -235,7 +257,7 @@ export const createDaemonSupervisor = ({
|
|
|
235
257
|
if (adapterChanged) {
|
|
236
258
|
// An adapter that consumes mcp[] must not be started on a record that
|
|
237
259
|
// declares none, even when only the adapter itself changed.
|
|
238
|
-
const nextEnvironment =
|
|
260
|
+
const nextEnvironment = derive(existing.environment, nextAdapter, {
|
|
239
261
|
sandbox: !existing.environment,
|
|
240
262
|
});
|
|
241
263
|
saveToken(row.agentName, {
|
|
@@ -254,7 +276,7 @@ export const createDaemonSupervisor = ({
|
|
|
254
276
|
// stays tool-less for as long as it runs. Heal it here, and only when
|
|
255
277
|
// the environment actually changed — otherwise every tick rewrites the
|
|
256
278
|
// file and restarts the seat forever.
|
|
257
|
-
const nextEnvironment =
|
|
279
|
+
const nextEnvironment = derive(existing.environment, existing.adapter, {
|
|
258
280
|
sandbox: !existing.environment,
|
|
259
281
|
});
|
|
260
282
|
if (!isDeepStrictEqual(existing.environment || null, nextEnvironment || null)) {
|
|
@@ -312,7 +334,7 @@ export const createDaemonSupervisor = ({
|
|
|
312
334
|
// tools and cannot post. See lib/default-environment.js. Nothing here is
|
|
313
335
|
// operator-authored, so this seat also gets the sandbox default — the
|
|
314
336
|
// self-serve install's seat used to be born unconfined (TASK-052).
|
|
315
|
-
const recordEnvironment =
|
|
337
|
+
const recordEnvironment = derive(
|
|
316
338
|
environment ? environment.value : null,
|
|
317
339
|
adapter,
|
|
318
340
|
{ sandbox: true },
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Withhold the grant broker from a seat this daemon cannot confine (TASK-063).
|
|
3
|
+
*
|
|
4
|
+
* The grant arrives as an injected MCP server, and confinement is declared in a
|
|
5
|
+
* different field — `sandbox` — so the two can disagree: a seat can be handed a
|
|
6
|
+
* granter's authority and no confinement. wren's ruling (69799) is REFUSE, not
|
|
7
|
+
* derive, and it is ENTRY-level (69829): the broker entry is withheld and the
|
|
8
|
+
* seat runs, because the seat was never promised confinement — a seat-level
|
|
9
|
+
* refusal is #1727's case only (a declared sandbox the host cannot enforce).
|
|
10
|
+
*
|
|
11
|
+
* This is the daemon half of one refusal with TWO emitters. The server refuses
|
|
12
|
+
* at the projection (`backend/services/grantBrokerConfinement.ts`) for rows it
|
|
13
|
+
* can judge host-independently; the daemon decides the rest, because it is the
|
|
14
|
+
* layer that knows this host, that resolved the adapter locally, and that holds
|
|
15
|
+
* the record the seat actually runs from — including records the projection
|
|
16
|
+
* never reaches (a row naming no adapter, a backend older than the refusal, a
|
|
17
|
+
* hand-written token file). Both halves emit the same `code`.
|
|
18
|
+
*
|
|
19
|
+
* THE ADAPTER IS THE HOST-INDEPENDENT FACT, so the set of adapters this daemon
|
|
20
|
+
* can confine is the set that derives an enforced sandbox — `claude` and
|
|
21
|
+
* `codex` (`ADAPTERS_WITH_DEFAULT_SANDBOX`, imported rather than retyped).
|
|
22
|
+
* Anything else, `pi` included, confines on no host: `pi` refuses a declared
|
|
23
|
+
* sandbox rather than honouring it, so an undeclared one is never derived.
|
|
24
|
+
*
|
|
25
|
+
* ONE REASON THE SERVER DOES NOT HAVE: `sandbox_absent`. The server must ALLOW
|
|
26
|
+
* an absent block (a daemon-provisioned seat's baseline is derived here and is
|
|
27
|
+
* not visible from the installation row — `quill` carries no sandbox key), so
|
|
28
|
+
* absence is this layer's to judge. Reaching it here means the baseline did not
|
|
29
|
+
* supply one, which happens for a local record with no environment of its own
|
|
30
|
+
* (`sandbox: !existing.environment` at the derive sites): nobody authored a
|
|
31
|
+
* sandbox and nothing derived one, so the seat would run unconfined.
|
|
32
|
+
*
|
|
33
|
+
* THE URL IS THE FACT, AND THE URL THIS LAYER HOLDS USUALLY CANNOT BE PARSED.
|
|
34
|
+
* Measured on the live fleet: the broker url in a token record is the
|
|
35
|
+
* UNRESOLVED placeholder `"${COMMONLY_API_URL}/api/mcp/grants/<id>"` (c4-smoke,
|
|
36
|
+
* the only record declaring one). `new URL()` throws on that string, so
|
|
37
|
+
* `isGrantBrokerUrl` — which the pi adapter calls AFTER substituting — returns
|
|
38
|
+
* false here. A daemon-side predicate that reused it unchanged would match
|
|
39
|
+
* nothing and refuse nothing while reading as if it enforced something. So the
|
|
40
|
+
* known placeholders are resolved to this instance first, and an entry counts
|
|
41
|
+
* only when it is OUR broker: our origin, or a relative/placeholder-prefixed
|
|
42
|
+
* path. A foreign server that happens to live under `/api/mcp/grants/` is not
|
|
43
|
+
* our grant and is left alone.
|
|
44
|
+
*
|
|
45
|
+
* AND A MISS HERE IS FAIL-OPEN, so the path is NORMALIZED before it is judged.
|
|
46
|
+
* Measured (vera, 70369): a bound instance spelled `https://api.commonly.me/`
|
|
47
|
+
* turns `"${COMMONLY_API_URL}/api/mcp/grants/g1"` into
|
|
48
|
+
* `https://api.commonly.me//api/mcp/grants/g1`, whose pathname starts `//api/`
|
|
49
|
+
* and matches nothing — the entry goes unrecognised and the broker RIDES into a
|
|
50
|
+
* seat this daemon just decided it cannot confine. Reachable: `agent.js` takes
|
|
51
|
+
* `instanceUrl` from `COMMONLY_API_URL` with a bare `.trim()`, while `config.js`
|
|
52
|
+
* is what strips the slash.
|
|
53
|
+
*
|
|
54
|
+
* The enforcement is ONE mechanism: duplicate slashes are collapsed before the
|
|
55
|
+
* path predicate sees them. It covers the doubled slash whichever side produced
|
|
56
|
+
* it — the join, a hand-written record, and a protocol-relative spelling OF THE
|
|
57
|
+
* PATH (`//api/mcp/grants/g1`, which collapses to the broker's path) — where
|
|
58
|
+
* stripping the instance's trailing slash covers only the join, and a mutation
|
|
59
|
+
* showed the two were redundant here (removing the strip reddened nothing).
|
|
60
|
+
*
|
|
61
|
+
* A URL WITH NO SCHEME HAS TWO READINGS, and both are taken (vera, 70372, who
|
|
62
|
+
* corrected a first draft of this paragraph for claiming the second was
|
|
63
|
+
* covered). Collapsing alone reads `//api.commonly.me/api/mcp/grants/g1` as the
|
|
64
|
+
* PATH `/api.commonly.me/api/...`, which is not the broker's path and measures
|
|
65
|
+
* false — a fail-open, because URL semantics say that string names THIS
|
|
66
|
+
* instance. So a schemeless value is also resolved against the bound instance,
|
|
67
|
+
* and counts when that reading lands on our origin and the broker's path. A
|
|
68
|
+
* foreign host is left alone under either reading; the path reading is what the
|
|
69
|
+
* shipped declarations use, and a record is free to hold the other.
|
|
70
|
+
*
|
|
71
|
+
* Collapsing can only turn a miss into a match, and a match here means WITHHOLD,
|
|
72
|
+
* so it moves in the safe direction; origin equality is still required first, so
|
|
73
|
+
* a foreign server cannot be drawn in by its spelling.
|
|
74
|
+
*/
|
|
75
|
+
import { isGrantBrokerUrl } from './adapters/pi-mcp-client.mjs';
|
|
76
|
+
import { ADAPTERS_WITH_DEFAULT_SANDBOX } from './default-environment.js';
|
|
77
|
+
import { LEGACY_SANDBOX_TRUST } from './environment.js';
|
|
78
|
+
import { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode } from './sandbox/mode.js';
|
|
79
|
+
|
|
80
|
+
/** One typed code, two emitters (`decidedBy` says which one spoke). */
|
|
81
|
+
export const GRANT_BROKER_REFUSAL_CODE = 'grant_broker_unconfined';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every mode an adapter enforces for a public seat: the cli's own public set
|
|
85
|
+
* ({workspace, read-only}) plus `bwrap`, which `resolvePublicSandboxMode`
|
|
86
|
+
* resolves to on Linux and claude implements there. Derived from the cli's
|
|
87
|
+
* constants rather than restated, so a mode added to one is not silently
|
|
88
|
+
* missing from the other.
|
|
89
|
+
*/
|
|
90
|
+
export const ENFORCING_MODES = new Set([...PUBLIC_SANDBOX_MODES, 'bwrap']);
|
|
91
|
+
|
|
92
|
+
const URL_PLACEHOLDERS = ['${COMMONLY_API_URL}', '${COMMONLY_INSTANCE_URL}'];
|
|
93
|
+
const PARSE_ANCHOR = 'https://grant-declaration.invalid';
|
|
94
|
+
|
|
95
|
+
/** `internal` → `public`; anything else (including absent) is itself. */
|
|
96
|
+
const effectiveTrust = (trust) => (
|
|
97
|
+
typeof trust === 'string' && Object.prototype.hasOwnProperty.call(LEGACY_SANDBOX_TRUST, trust)
|
|
98
|
+
? LEGACY_SANDBOX_TRUST[trust]
|
|
99
|
+
: trust
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A bound instance is only trimmed here (`agent.js` does the same). Any
|
|
104
|
+
* trailing slash it carries is deliberately left alone: the doubled slash it
|
|
105
|
+
* would manufacture is answered by `collapseSlashes` below, one mechanism for
|
|
106
|
+
* every spelling rather than two that a mutation cannot tell apart.
|
|
107
|
+
*/
|
|
108
|
+
const resolveInstance = (instanceUrl) => (
|
|
109
|
+
typeof instanceUrl === 'string' ? instanceUrl.trim() : ''
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `//api/mcp/grants/g1` and `/api/mcp/grants/g1` are the same path written two
|
|
114
|
+
* ways, and only one of them is ours to withhold — the absolute branch needs it
|
|
115
|
+
* for a doubled slash after the origin, the relative branch for a
|
|
116
|
+
* protocol-relative spelling of the path, i.e. one that names no host (a
|
|
117
|
+
* protocol-relative URL naming a HOST is a different string and is not ours).
|
|
118
|
+
* Collapsing is one-directional (a miss becomes a match) and never widens the
|
|
119
|
+
* ORIGIN check above it.
|
|
120
|
+
*/
|
|
121
|
+
const collapseSlashes = (path) => path.replace(/\/{2,}/g, '/');
|
|
122
|
+
|
|
123
|
+
const parseUrl = (value, baseUrl) => {
|
|
124
|
+
try {
|
|
125
|
+
return baseUrl ? new URL(value, baseUrl) : new URL(value);
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Does this one declaration name the grant broker WE inject?
|
|
133
|
+
*
|
|
134
|
+
* Resolves the declaration's own placeholders against the instance this daemon
|
|
135
|
+
* is bound to, then asks the same path predicate the adapters use — so the
|
|
136
|
+
* expanded spelling and the placeholder spelling agree, and an entry on another
|
|
137
|
+
* origin does not match just because its path resembles ours.
|
|
138
|
+
*/
|
|
139
|
+
export const isOurGrantBroker = (server, { instanceUrl } = {}) => {
|
|
140
|
+
const url = server?.url;
|
|
141
|
+
if (typeof url !== 'string' || url === '') return false;
|
|
142
|
+
|
|
143
|
+
const base = resolveInstance(instanceUrl);
|
|
144
|
+
let resolvedUrl = url;
|
|
145
|
+
for (const placeholder of URL_PLACEHOLDERS) {
|
|
146
|
+
if (!resolvedUrl.includes(placeholder)) continue;
|
|
147
|
+
// A placeholder we cannot resolve is not ours to judge; an entry that is
|
|
148
|
+
// only a placeholder has no origin to compare and no path to match.
|
|
149
|
+
if (base === '') return false;
|
|
150
|
+
resolvedUrl = resolvedUrl.split(placeholder).join(base);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const parsed = parseUrl(resolvedUrl);
|
|
154
|
+
const ours = base === '' ? null : parseUrl(base);
|
|
155
|
+
const onOurOrigin = (url) => url.origin + collapseSlashes(url.pathname) + url.search;
|
|
156
|
+
|
|
157
|
+
// Every candidate below is first proven to be on OUR origin, so an entry that
|
|
158
|
+
// merely resembles the broker is left alone; a match means WITHHOLD.
|
|
159
|
+
const candidates = [];
|
|
160
|
+
if (parsed) {
|
|
161
|
+
// An absolute url: our origin, or it is not our grant however its path reads.
|
|
162
|
+
if (ours && parsed.origin === ours.origin) candidates.push(onOurOrigin(parsed));
|
|
163
|
+
} else if (resolvedUrl.startsWith('/')) {
|
|
164
|
+
// A schemeless url, read BOTH ways — as a path (anchored so its path can be
|
|
165
|
+
// judged), and as a protocol-relative reference to the bound instance.
|
|
166
|
+
candidates.push(PARSE_ANCHOR + collapseSlashes(resolvedUrl));
|
|
167
|
+
const against = ours ? parseUrl(resolvedUrl, base) : null;
|
|
168
|
+
if (against && against.origin === ours.origin) candidates.push(onOurOrigin(against));
|
|
169
|
+
}
|
|
170
|
+
// An unparseable value that is neither — an unknown `${...}` expansion, a
|
|
171
|
+
// malformed string — is not an entry this daemon can identify as the broker
|
|
172
|
+
// it injects, and the injected spelling is one of the two handled above.
|
|
173
|
+
return candidates.some((candidate) => isGrantBrokerUrl(candidate));
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** True when the environment declares our grant broker at all. */
|
|
177
|
+
export const declaresGrantBroker = (environment, opts) => (
|
|
178
|
+
Array.isArray(environment?.mcp) && environment.mcp.some((server) => isOurGrantBroker(server, opts))
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Why this daemon cannot confine a seat running `adapter` with this
|
|
183
|
+
* environment — or `null` when confinement is enforced and the broker may ride.
|
|
184
|
+
* The reason vocabulary mirrors the server's, plus `sandbox_absent`.
|
|
185
|
+
*/
|
|
186
|
+
export const confinementReason = (environment, adapter, platform = process.platform) => {
|
|
187
|
+
if (!ADAPTERS_WITH_DEFAULT_SANDBOX.has(adapter)) return 'adapter_cannot_confine';
|
|
188
|
+
|
|
189
|
+
const sandbox = environment?.sandbox;
|
|
190
|
+
if (sandbox === null || typeof sandbox !== 'object' || Array.isArray(sandbox)) return 'sandbox_absent';
|
|
191
|
+
const declared = sandbox;
|
|
192
|
+
if (declared.mode === 'none') return 'sandbox_mode_none';
|
|
193
|
+
if (effectiveTrust(declared.trust) !== 'public') return 'sandbox_trust_not_public';
|
|
194
|
+
const mode = resolvePublicSandboxMode(declared, platform);
|
|
195
|
+
if (typeof mode !== 'string' || !ENFORCING_MODES.has(mode)) return 'sandbox_mode_unenforceable';
|
|
196
|
+
return null;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const detailFor = (reason, adapter, environment) => {
|
|
200
|
+
const drop = 'or drop the grant broker from this seat';
|
|
201
|
+
if (reason === 'adapter_cannot_confine') {
|
|
202
|
+
return `the seat runs the '${adapter || 'unresolved'}' adapter, which confines on no host — a declared sandbox is`
|
|
203
|
+
+ ' refused rather than enforced, and an absent one is never derived; move this seat to the claude or codex'
|
|
204
|
+
+ ` adapter, ${drop}`;
|
|
205
|
+
}
|
|
206
|
+
if (reason === 'sandbox_absent') {
|
|
207
|
+
return 'this seat declares no sandbox block and this daemon derives one only for the claude and codex adapters,'
|
|
208
|
+
+ ` so it would run with a granter's authority and no confinement; declare sandbox.trust 'public' ${drop}`;
|
|
209
|
+
}
|
|
210
|
+
if (reason === 'sandbox_mode_none') {
|
|
211
|
+
return "the declared sandbox.mode is 'none', which no host confines; declare a confining mode"
|
|
212
|
+
+ ` (e.g. 'workspace') ${drop}`;
|
|
213
|
+
}
|
|
214
|
+
if (reason === 'sandbox_mode_unenforceable') {
|
|
215
|
+
const shown = typeof environment?.sandbox?.mode === 'string'
|
|
216
|
+
? `'${environment.sandbox.mode}'`
|
|
217
|
+
: (JSON.stringify(environment?.sandbox?.mode) ?? String(environment?.sandbox?.mode));
|
|
218
|
+
return `the declared sandbox.mode is ${shown}, which no adapter enforces on any host;`
|
|
219
|
+
+ " declare one of 'workspace' / 'read-only' (or 'bwrap' on Linux)" + ` ${drop}`;
|
|
220
|
+
}
|
|
221
|
+
const trust = environment?.sandbox?.trust;
|
|
222
|
+
const shown = trust === undefined ? 'absent' : `'${String(trust)}'`;
|
|
223
|
+
return `the declared sandbox.trust is ${shown}${shown === 'absent' ? '' : ` (effective '${String(effectiveTrust(trust))}')`},`
|
|
224
|
+
+ " and no host confines a seat whose trust is not 'public'; declare sandbox.trust 'public'" + ` ${drop}`;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The env a seat may actually run with: the same environment when it declares
|
|
229
|
+
* no broker WE injected, or when this daemon can confine it; otherwise the same
|
|
230
|
+
* environment minus the broker, with the refusal handed to `onRefuse`.
|
|
231
|
+
*
|
|
232
|
+
* Identity is preserved when nothing is withheld — the derive sites use
|
|
233
|
+
* `isDeepStrictEqual` against the stored record as their dirty check, so
|
|
234
|
+
* returning a fresh object every tick would rewrite the record and restart the
|
|
235
|
+
* seat forever.
|
|
236
|
+
*/
|
|
237
|
+
export const withholdGrantBroker = (environment, adapter, {
|
|
238
|
+
instanceUrl,
|
|
239
|
+
onRefuse,
|
|
240
|
+
platform = process.platform,
|
|
241
|
+
} = {}) => {
|
|
242
|
+
if (!declaresGrantBroker(environment, { instanceUrl })) return environment;
|
|
243
|
+
const reason = confinementReason(environment, adapter, platform);
|
|
244
|
+
if (!reason) return environment;
|
|
245
|
+
|
|
246
|
+
const kept = environment.mcp.filter((server) => !isOurGrantBroker(server, { instanceUrl }));
|
|
247
|
+
const withheld = environment.mcp
|
|
248
|
+
.filter((server) => isOurGrantBroker(server, { instanceUrl }))
|
|
249
|
+
.map((server) => (typeof server?.name === 'string' && server.name ? server.name : '(unnamed)'));
|
|
250
|
+
if (typeof onRefuse === 'function') {
|
|
251
|
+
onRefuse({
|
|
252
|
+
code: GRANT_BROKER_REFUSAL_CODE,
|
|
253
|
+
decidedBy: 'daemon',
|
|
254
|
+
reason,
|
|
255
|
+
detail: detailFor(reason, adapter, environment),
|
|
256
|
+
}, withheld);
|
|
257
|
+
}
|
|
258
|
+
return { ...environment, mcp: kept };
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export default {
|
|
262
|
+
GRANT_BROKER_REFUSAL_CODE,
|
|
263
|
+
ENFORCING_MODES,
|
|
264
|
+
isOurGrantBroker,
|
|
265
|
+
declaresGrantBroker,
|
|
266
|
+
confinementReason,
|
|
267
|
+
withholdGrantBroker,
|
|
268
|
+
};
|