@commonlyai/cli 0.1.61 → 0.1.64

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.
@@ -40,6 +40,8 @@ import { dirname, join } from 'path';
40
40
  import { fileURLToPath } from 'url';
41
41
  import { buildMemoryPreamble } from '../memory-bridge.js';
42
42
  import { GRANT_BROKER_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs';
43
+ import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
44
+ import { removeCredentialFile, writeCredentialFile } from '../credential-file.js';
43
45
 
44
46
  const DEFAULT_TIMEOUT_MS = (() => {
45
47
  const fallback = 15 * 60 * 1000;
@@ -68,12 +70,17 @@ const BRIDGE_PATH = join(dirname(fileURLToPath(import.meta.url)), 'pi-commonly-m
68
70
  // Same substitution contract as claude.js / codex.js: ${COMMONLY_*}
69
71
  // placeholders in the declared MCP env are the wrapper's per-(agent, pod)
70
72
  // runtime values, filled at spawn time.
71
- const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
73
+ const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_TOKEN_FILE', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
72
74
  const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
73
75
  const substitutePlaceholders = (value, ctx) => {
74
76
  if (typeof value !== 'string' || !value.includes('${COMMONLY_')) return value;
75
77
  const subs = {
76
78
  COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
79
+ // The PATH of this spawn's credential file. The bridge reads the file and
80
+ // hands the VALUE to the server on fd 3, so the token still never reaches a
81
+ // child's environment — but the payload that travels to the bridge carries a
82
+ // path rather than the secret (TASK-083).
83
+ COMMONLY_TOKEN_FILE: ctx.credentialFile || '',
77
84
  COMMONLY_API_URL: ctx.instanceUrl || '',
78
85
  COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
79
86
  };
@@ -198,7 +205,14 @@ export const resolveMcpServers = (mcpServers, ctx = {}) => {
198
205
  carried.push({
199
206
  name: server.name,
200
207
  command: server.command.map((a) => substitutePlaceholders(a, ctx)),
201
- env: Object.fromEntries(Object.entries(server.env || {}).map(([k, v]) => [k, substitutePlaceholders(v, ctx)])),
208
+ // Rewritten before substitution, so our own server is handed the file
209
+ // (whose value the bridge pipes) instead of the token itself.
210
+ env: Object.fromEntries(Object.entries(
211
+ deliverSeatCredential(server, {
212
+ credentialFile: ctx.credentialFile,
213
+ label: 'pi',
214
+ }).env,
215
+ ).map(([k, v]) => [k, substitutePlaceholders(v, ctx)])),
202
216
  });
203
217
  continue;
204
218
  }
@@ -384,27 +398,55 @@ export default {
384
398
  const fullPrompt = buildMemoryPreamble(prompt, ctx.memoryLongTerm, { freshSession: !isResume });
385
399
  await writeFile(join(agentDir, 'models.json'), `${JSON.stringify(buildModelsJson(provider, model), null, 2)}\n`, { mode: 0o600 });
386
400
 
387
- const servers = resolveMcpServers(ctx.environment?.mcp, ctx);
388
- const childEnv = {
389
- ...baseEnv,
390
- PI_CODING_AGENT_DIR: agentDir,
391
- PI_SKIP_VERSION_CHECK: '1',
392
- };
393
- const args = buildArgs({
394
- prompt: fullPrompt, provider: provider.name, model, thinking, sessionId, isResume, sessionDir,
395
- bridge: servers.length ? (ctx._bridgePath || BRIDGE_PATH) : null,
401
+ // One credential file per spawn, inside this seat's own 0700 home — pi has no
402
+ // enforced sandbox, so the seat's home is the narrowest place that is still
403
+ // readable by the bridge. The value the server receives still travels on fd 3
404
+ // (pi-mcp-client's fd channel); the file is where the launcher puts it.
405
+ const credential = writeCredentialFile(ctx.runtimeToken, {
406
+ agentName: ctx.agentName || 'agent',
407
+ root: join(home, 'credentials'),
396
408
  });
409
+ try {
410
+ const servers = resolveMcpServers(ctx.environment?.mcp, {
411
+ ...ctx,
412
+ credentialFile: credential?.path || null,
413
+ });
414
+ const childEnv = {
415
+ ...baseEnv,
416
+ PI_CODING_AGENT_DIR: agentDir,
417
+ PI_SKIP_VERSION_CHECK: '1',
418
+ };
419
+ // `baseEnv` is normally the process environment, which carries the
420
+ // bootstrap export. Nothing below this process needs the value: the bridge
421
+ // is handed the servers, their resolved values included, over fd 3. It has
422
+ // to come out rather than merely stop being added, because pi's `bash`
423
+ // tool spawns children with `{ ...process.env }`, so a copy here is a copy
424
+ // in the seat's shell — and the file path goes in, so a hook resolves its
425
+ // credential without the value.
426
+ withholdRuntimeCredential(childEnv, {
427
+ credentialFile: credential?.path || null,
428
+ });
429
+ const args = buildArgs({
430
+ prompt: fullPrompt, provider: provider.name, model, thinking, sessionId, isResume, sessionDir,
431
+ bridge: servers.length ? (ctx._bridgePath || BRIDGE_PATH) : null,
432
+ });
397
433
 
398
- const reply = await runPi({
399
- args,
400
- cwd: ctx.cwd,
401
- env: childEnv,
402
- payload: servers.length ? JSON.stringify(servers) : undefined,
403
- timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
404
- spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
405
- });
406
- // Empty text with a clean exit is a silent turn; the run loop treats it
407
- // as NO_REPLY-shaped and re-delivers on its own rules.
408
- return { text: reply.text, newSessionId: sessionId };
434
+ const reply = await runPi({
435
+ args,
436
+ cwd: ctx.cwd,
437
+ env: childEnv,
438
+ payload: servers.length ? JSON.stringify(servers) : undefined,
439
+ timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
440
+ spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
441
+ });
442
+ // Empty text with a clean exit is a silent turn; the run loop treats it
443
+ // as NO_REPLY-shaped and re-delivers on its own rules.
444
+ return { text: reply.text, newSessionId: sessionId };
445
+ } finally {
446
+ // Best effort: the bridge has already read what it needs by the time the
447
+ // turn ends, and a token file that outlives its turn is a token file that
448
+ // sits in a home for the next one.
449
+ removeCredentialFile(credential);
450
+ }
409
451
  },
410
452
  };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * A per-spawn credential file: the launcher channel for the runtime token
3
+ * (TASK-082/083, ruled 2026-09-19 — "a credential file, only its PATH in the
4
+ * env").
5
+ *
6
+ * WHY A FILE AND NOT THE ENVIRONMENT. The token used to ride the runtime's own
7
+ * environment (claude expands `${COMMONLY_AGENT_TOKEN}` from it, codex forwards
8
+ * it through `mcp_servers.*.env_vars`), which means every child of the runtime
9
+ * inherited it — measured on 2026-09-19: a `@playwright/mcp` process held the
10
+ * seat's `COMMONLY_AGENT_TOKEN`, and a pi seat's MCP child held the daemon's
11
+ * `COMMONLY_LITELLM_KEY`. A PATH is not a secret, so handing the path to the
12
+ * runtime is safe even though the token it names is not, and an unrelated MCP
13
+ * server that inherits the runtime's environment gets nothing it can use.
14
+ *
15
+ * WHY PER SPAWN. The file lives only as long as one spawn of one runtime, in a
16
+ * directory only its owner can traverse (0700), with the file itself 0600. A
17
+ * fixed path would widen the window to "since the first spawn" and would let two
18
+ * concurrent seats share one credential by accident.
19
+ *
20
+ * The reader side is `readToken` in `commonly-mcp/src/client.js`, which resolves
21
+ * fd, then file, then environment — by declaration, and refuses to fall through
22
+ * from a declared source that cannot be read.
23
+ */
24
+ import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { homedir } from 'node:os';
27
+ import { randomBytes } from 'node:crypto';
28
+
29
+ /** The variable a child reads to find the credential. Never carries the token. */
30
+ export const CREDENTIAL_FILE_VAR = 'COMMONLY_TOKEN_FILE';
31
+
32
+ /**
33
+ * The variable that USED to carry the token itself, and still does for a server
34
+ * that cannot read anything else. Kept beside the file var so the two channels
35
+ * are named in one place rather than one per adapter.
36
+ */
37
+ export const CREDENTIAL_KEY = 'COMMONLY_AGENT_TOKEN';
38
+
39
+ /** Default root: inside the CLI's own state directory, not a world-readable /tmp. */
40
+ export const credentialRoot = () => join(homedir(), '.commonly', 'credentials');
41
+
42
+ /**
43
+ * Write `token` to a fresh 0600 file and return its path, or null when there is
44
+ * no token to write (a seat bootstrapping without one).
45
+ *
46
+ * `fs` is injectable so tests can assert the mode and the path shape without
47
+ * writing into the operator's home.
48
+ */
49
+ export const writeCredentialFile = (token, {
50
+ agentName = 'agent',
51
+ root = credentialRoot(),
52
+ fs = { mkdirSync, writeFileSync, chmodSync, rmSync },
53
+ now = () => Date.now(),
54
+ random = () => randomBytes(4).toString('hex'),
55
+ } = {}) => {
56
+ const value = typeof token === 'string' ? token.trim() : '';
57
+ if (!value) return null;
58
+ const dir = join(root, `${agentName}-${now()}-${random()}`);
59
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
60
+ const path = join(dir, 'token');
61
+ fs.writeFileSync(path, value, { mode: 0o600 });
62
+ // writeFileSync's mode is subject to the process umask, so a 0022 umask still
63
+ // produces 0644. chmod is the assertion, not a courtesy.
64
+ fs.chmodSync(path, 0o600);
65
+ return { path, dir };
66
+ };
67
+
68
+ /** Best-effort removal once the spawn that owns the file has ended. */
69
+ export const removeCredentialFile = (written, { fs = { rmSync } } = {}) => {
70
+ const dir = typeof written === 'string' ? dirname(written) : written?.dir;
71
+ if (!dir) return false;
72
+ try {
73
+ fs.rmSync(dir, { recursive: true, force: true });
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ };
@@ -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 = seatBaseline(merged, nextAdapter, {
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 = seatBaseline(existing.environment, nextAdapter, {
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 = seatBaseline(existing.environment, existing.adapter, {
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 = seatBaseline(
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
+ };
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from
2
2
  import { dirname, join, resolve as pathResolve, isAbsolute, relative } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { createHash } from 'crypto';
5
+ import { CREDENTIAL_FILE_VAR } from './credential-file.js';
5
6
 
6
7
  export const HOOK_EVENTS = ['PreToolUse', 'PostToolUse', 'Stop', 'SubagentStop'];
7
8
  export const DEFAULT_HOOK_TIMEOUT_MS = 3000;
@@ -155,9 +156,39 @@ export const writeHooksConfig = ({
155
156
  * positive decision returned by the Commonly endpoint; D7 does not let a
156
157
  * missing/slow ledger become an accidental write blocker.
157
158
  */
159
+ /**
160
+ * The hook's credential, resolved the way the runtime it runs inside resolves it.
161
+ *
162
+ * A hook is a child process of the seat's runtime, so it sees that runtime's
163
+ * environment — which is exactly the environment TASK-083 emptied of the token.
164
+ * Reading only COMMONLY_AGENT_TOKEN would therefore leave every hook on a
165
+ * migrated seat with no credential, and because this path fails open by design
166
+ * (the `hook_unavailable` return below), the symptom would be a tool-policy hook
167
+ * that silently stopped deciding anything. So the launcher file comes first, and
168
+ * the value variable stays as the fallback for a seat whose declaration has not
169
+ * migrated.
170
+ *
171
+ * Unlike the MCP reader, a declared-but-unreadable file does NOT throw here: a
172
+ * hook that dies is a hook the runtime reports as broken, and this function's
173
+ * documented posture is to fail open rather than become an accidental blocker.
174
+ */
175
+ export const resolveHookToken = ({
176
+ env = process.env,
177
+ readTokenFile = (path) => readFileSync(path, 'utf8'),
178
+ } = {}) => {
179
+ const path = env[CREDENTIAL_FILE_VAR];
180
+ if (typeof path === 'string' && path.trim() !== '') {
181
+ try {
182
+ const fromFile = readTokenFile(path).trim();
183
+ if (fromFile) return fromFile;
184
+ } catch { /* fall through to the value channel */ }
185
+ }
186
+ return env.COMMONLY_AGENT_TOKEN;
187
+ };
188
+
158
189
  export const forwardHookEvent = async ({
159
190
  endpoint,
160
- token = process.env.COMMONLY_AGENT_TOKEN,
191
+ token = resolveHookToken(),
161
192
  input = '',
162
193
  timeoutMs = DEFAULT_HOOK_TIMEOUT_MS,
163
194
  fetchImpl = globalThis.fetch,