@commonlyai/cli 0.1.88 → 0.1.92

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.
@@ -1,7 +1,10 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
2
  import { homedir } from 'node:os';
3
3
  import { isAbsolute, resolve as pathResolve } from 'node:path';
4
- import { auditDeclaredMcp, installedStdioEntries } from './declared-mcp-guard.js';
4
+ import {
5
+ auditDeclaredMcp,
6
+ installedStdioEntries,
7
+ } from './declared-mcp-guard.js';
5
8
 
6
9
  import { seatBaseline } from './default-environment.js';
7
10
  import { withholdGrantBroker } from './grant-broker-guard.js';
@@ -150,20 +153,28 @@ export const createDaemonSupervisor = ({
150
153
  return Object.keys(fallback).length ? { value: fallback, declared: false } : null;
151
154
  };
152
155
 
156
+ // `provider` is host-local authority: /assigned intentionally omits it.
157
+ // Ignore a provider field even if a future or malformed row carries one,
158
+ // then restore only the local token record's value during adoption.
159
+ const withoutServerProvider = (environment) => {
160
+ const safe = { ...(environment || {}) };
161
+ delete safe.provider;
162
+ return safe;
163
+ };
164
+
153
165
  // A declared environment runs on THIS machine as the operator. Refuse any
154
166
  // declared stdio command that is not the shipped commonly MCP server or one
155
- // the operator already installed here, and any http server that would be
156
- // handed the seat token off the instance's origin. Refusal keeps the current
157
- // seat (or skips the mint) and says which server was kept off the machine;
158
- // it never adopts a partial environment.
167
+ // the operator already installed here, and any http server that would
168
+ // receive the seat token off-origin. Provider blocks are ignored from rows;
169
+ // the host-local token record is their only source.
159
170
  const admitDeclared = (row, environment, existing) => {
160
- const audit = auditDeclaredMcp(environment, {
171
+ const mcpAudit = auditDeclaredMcp(environment, {
161
172
  instanceUrl: existing?.instanceUrl || record.instanceUrl,
162
173
  allowedStdioEntries: installedStdioEntries(existing),
163
174
  });
164
- if (audit.ok) return true;
175
+ if (mcpAudit.refusals.length === 0) return true;
165
176
  log(`[${row.agentName}] refusing the declared environment — it would not stay on this machine's terms:`);
166
- for (const refusal of audit.refusals) log(`[${row.agentName}] ${refusal}`);
177
+ for (const refusal of mcpAudit.refusals) log(`[${row.agentName}] ${refusal}`);
167
178
  return false;
168
179
  };
169
180
 
@@ -222,10 +233,21 @@ export const createDaemonSupervisor = ({
222
233
  nextAdapter = declaredAdapter;
223
234
  adapterChanged = existing.adapter !== nextAdapter;
224
235
  }
236
+ if (existing.environment?.provider && nextAdapter !== 'opencode') {
237
+ log(`[${row.agentName}] host-local provider requires the opencode adapter — keeping the current seat`);
238
+ return false;
239
+ }
225
240
  if (wanted) {
241
+ const serverEnvironment = withoutServerProvider(wanted.value);
226
242
  const merged = wanted.declared
227
- ? wanted.value
228
- : { ...(existing.environment || {}), ...wanted.value };
243
+ ? serverEnvironment
244
+ : { ...(existing.environment || {}), ...serverEnvironment };
245
+ // The provider chooses a network endpoint and reads a host key file,
246
+ // so it is authored on this host. /assigned omits it; rehydrate the
247
+ // local block after applying the server's declared environment.
248
+ if (existing.environment?.provider) {
249
+ merged.provider = existing.environment.provider;
250
+ }
229
251
  // A server-declared environment replaces the local one, so a seat whose
230
252
  // declaration carries no mcp[] would come back tool-less. Re-apply the
231
253
  // shipped default here and below, or the seat silently loses every
@@ -325,7 +347,7 @@ export const createDaemonSupervisor = ({
325
347
  }
326
348
  if (!adapter) adapter = await resolveAdapter(row.runtime || null);
327
349
  if (!adapter) {
328
- log(`[${row.agentName}] no usable CLI adapter on this machine — install claude or codex, or attach manually`);
350
+ log(`[${row.agentName}] no usable CLI adapter on this machine — install claude, codex, or opencode, or attach manually`);
329
351
  return false;
330
352
  }
331
353
  const environment = environmentFor(row);
@@ -335,7 +357,7 @@ export const createDaemonSupervisor = ({
335
357
  // operator-authored, so this seat also gets the sandbox default — the
336
358
  // self-serve install's seat used to be born unconfined (TASK-052).
337
359
  const recordEnvironment = derive(
338
- environment ? environment.value : null,
360
+ environment ? withoutServerProvider(environment.value) : null,
339
361
  adapter,
340
362
  { sandbox: true },
341
363
  );
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Guard for a server-declared `environment.mcp` before the daemon adopts it.
2
+ * Guard server-declared `environment.mcp` before the daemon adopts it.
3
3
  *
4
4
  * The daemon projects `AgentInstallation.config.environment` onto the OWNER's
5
5
  * machine: every declared stdio server is spawned as the operator, and every
@@ -16,6 +16,7 @@
16
16
  * codex agent posted through the operator's own CLI profile because
17
17
  * it had no commonly_* tool of its own)
18
18
  * pi — stdio servers from the environment spec (adapters/pi.js)
19
+ * opencode — per-spawn config file with local/remote MCP entries (adapters/opencode.js)
19
20
  * `stub` has no consumption path and must keep being handed `environment: null`.
20
21
  *
21
22
  * The placeholders are substituted at spawn time by the adapter, so the
@@ -24,7 +25,7 @@
24
25
 
25
26
  import { normalizeSandboxTrust } from './environment.js';
26
27
 
27
- export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi']);
28
+ export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi', 'opencode']);
28
29
 
29
30
  /**
30
31
  * The adapters that can ENFORCE the default sandbox — a strict subset of the
@@ -41,7 +42,12 @@ export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi']);
41
42
  * here and hoped for. A derived pi seat used to be exactly this shape and would
42
43
  * have failed every spawn with `public-trust seats are not supported`.
43
44
  */
44
- export const ADAPTERS_WITH_DEFAULT_SANDBOX = new Set(['claude', 'codex']);
45
+ export const ADAPTERS_WITH_DEFAULT_SANDBOX = new Set(['claude', 'codex', 'opencode']);
46
+
47
+ // The server has not yet admitted OpenCode to its grant-broker confinement
48
+ // allowlist. Keep runtime sandbox support separate from the narrower set of
49
+ // adapters that may receive a room grant.
50
+ export const ADAPTERS_WITH_GRANT_BROKER = new Set(['claude', 'codex']);
45
51
 
46
52
  /**
47
53
  * The sandbox an unconfigured seat gets.
@@ -64,9 +70,11 @@ export const ADAPTERS_WITH_DEFAULT_SANDBOX = new Set(['claude', 'codex']);
64
70
  * moves a host fact into the database and breaks the day the seat is re-homed.
65
71
  * An explicit mode in a record still wins over the derived one.
66
72
  *
67
- * Confinement holds for claude and codex, and only they are ever handed this
68
- * block (ADAPTERS_WITH_DEFAULT_SANDBOX). A pi seat must never be: it fails
69
- * closed on a declared sandbox rather than run under a spec it cannot honour.
73
+ * Confinement holds for claude, codex, and opencode, and only they are handed
74
+ * this block (ADAPTERS_WITH_DEFAULT_SANDBOX). Grant-broker eligibility is
75
+ * narrower until the server admits OpenCode after its confinement proof.
76
+ * A pi seat must never be handed this block: it fails closed on a declared
77
+ * sandbox rather than run under a spec it cannot honour.
70
78
  */
71
79
  export const COMMONLY_DEFAULT_SANDBOX = Object.freeze({ trust: 'public' });
72
80
 
@@ -687,6 +687,41 @@ export const deliverChatReply = async ({
687
687
  ? { consecutive: response.consecutive }
688
688
  : {}),
689
689
  });
690
+ // The same route also answers HTTP 200 with `{ success: true, skipped: true,
691
+ // reason }` when it decided not to create a row: a duplicate inside the dedupe
692
+ // window, a silent or empty body, a heartbeat it keeps to itself. The wrapper
693
+ // used to read that as a delivery and log "posted … (single)" while nothing
694
+ // reached the pod (hq-support, 2026-10-09 08:10Z), so a silent seat could not
695
+ // be read from its own log. A skip is a completed request and not a delivery,
696
+ // exactly like a refusal, and it stops a sequence the same way.
697
+ const skipped = (response, messages, attemptedMessages) => {
698
+ const duplicate = response.duplicate && typeof response.duplicate === 'object'
699
+ ? response.duplicate
700
+ : null;
701
+ return {
702
+ mode: 'skipped',
703
+ messages,
704
+ attemptedMessages,
705
+ skipped: true,
706
+ reason: response.reason || 'message_skipped',
707
+ ...(duplicate
708
+ ? {
709
+ duplicate: {
710
+ ...(duplicate.id ? { id: String(duplicate.id) } : {}),
711
+ ...(typeof duplicate.dedupeWindowMinutes === 'number'
712
+ ? { dedupeWindowMinutes: duplicate.dedupeWindowMinutes }
713
+ : {}),
714
+ },
715
+ }
716
+ : {}),
717
+ };
718
+ };
719
+ const undelivered = (response) => response?.refused === true || response?.skipped === true;
720
+ const nonDelivery = (response, messages, attemptedMessages) => (
721
+ response?.refused === true
722
+ ? refused(response, messages, attemptedMessages)
723
+ : skipped(response, messages, attemptedMessages)
724
+ );
690
725
  // An atomic unit (a fenced block, an unbreakable word-run) can exceed the
691
726
  // limit by construction — splitForChat keeps it whole rather than breaking
692
727
  // its rendering. The tone contract's own rule covers it: over ~800 chars of
@@ -696,7 +731,7 @@ export const deliverChatReply = async ({
696
731
  const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
697
732
  if (chunks.length <= 1 && !hasIndivisibleOversize) {
698
733
  const response = await postMessage({ content: chunks[0] ?? text });
699
- if (response?.refused === true) return refused(response, 0, 1);
734
+ if (undelivered(response)) return nonDelivery(response, 0, 1);
700
735
  return { mode: 'single', messages: 1 };
701
736
  }
702
737
  if (chunks.length <= maxChunks && !hasIndivisibleOversize) {
@@ -704,7 +739,7 @@ export const deliverChatReply = async ({
704
739
  for (const chunk of chunks) {
705
740
  // eslint-disable-next-line no-await-in-loop
706
741
  const response = await postMessage({ content: chunk }); // in order, so the reply reads top-down
707
- if (response?.refused === true) return refused(response, messages, chunks.length);
742
+ if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
708
743
  messages += 1;
709
744
  }
710
745
  return { mode: 'split', messages };
@@ -724,7 +759,7 @@ export const deliverChatReply = async ({
724
759
  let posted = 0;
725
760
  try {
726
761
  const rootRes = await postMessage({ content: chunks[0] });
727
- if (rootRes?.refused === true) return refused(rootRes, 0, chunks.length);
762
+ if (undelivered(rootRes)) return nonDelivery(rootRes, 0, chunks.length);
728
763
  posted = 1;
729
764
  // The runtime route answers `res.json(result)` with the created row on
730
765
  // `result.message`. Accept either id field; refuse to guess if neither
@@ -736,7 +771,7 @@ export const deliverChatReply = async ({
736
771
  for (const chunk of chunks.slice(1)) {
737
772
  // eslint-disable-next-line no-await-in-loop
738
773
  const response = await postMessage({ content: chunk, threadRootId: String(rootId) });
739
- if (response?.refused === true) return refused(response, posted, chunks.length);
774
+ if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
740
775
  posted += 1;
741
776
  }
742
777
  return { mode: 'thread', messages: chunks.length, threadRootId: String(rootId) };
@@ -751,7 +786,7 @@ export const deliverChatReply = async ({
751
786
  for (const chunk of chunks.slice(posted)) {
752
787
  // eslint-disable-next-line no-await-in-loop
753
788
  const response = await postMessage({ content: chunk });
754
- if (response?.refused === true) return refused(response, posted, chunks.length);
789
+ if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
755
790
  posted += 1;
756
791
  }
757
792
  return { mode: 'thread-fallback', messages: chunks.length };
@@ -777,7 +812,7 @@ export const deliverChatReply = async ({
777
812
  ? chunks[0]
778
813
  : '(reply too large for chat — attached in full)';
779
814
  const response = await postMessage({ content: `${lead}\n\n${directive}` });
780
- if (response?.refused === true) return refused(response, 0, 1);
815
+ if (undelivered(response)) return nonDelivery(response, 0, 1);
781
816
  return { mode: 'attach', messages: 1 };
782
817
  } catch (err) {
783
818
  log(`attach fallback failed (${err.message}) — posting ${chunks.length} split messages instead`);
@@ -785,7 +820,7 @@ export const deliverChatReply = async ({
785
820
  for (const chunk of chunks) {
786
821
  // eslint-disable-next-line no-await-in-loop
787
822
  const response = await postMessage({ content: chunk });
788
- if (response?.refused === true) return refused(response, messages, chunks.length);
823
+ if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
789
824
  messages += 1;
790
825
  }
791
826
  return { mode: 'split-fallback', messages };
@@ -41,13 +41,26 @@ import { homedir } from 'os';
41
41
  // "persona and runtime are chosen separately" requires to mean anything for a
42
42
  // BYO seat, and what lets an identity card answer "what is this running".
43
43
  const ALLOWED_TOP_KEYS = new Set([
44
- 'version', 'workspace', 'sandbox', 'skills', 'mcp', 'model', 'effort',
44
+ 'version', 'workspace', 'sandbox', 'skills', 'mcp', 'model', 'effort', 'provider',
45
45
  ]);
46
46
  const ALLOWED_SANDBOX_MODES = new Set([
47
47
  'none', 'workspace', 'read-only', 'bwrap', 'firejail', 'container', 'managed',
48
48
  ]);
49
49
  const ALLOWED_SANDBOX_TRUST = new Set(['public']);
50
50
  const ALLOWED_NETWORK_POLICIES = new Set(['unrestricted', 'restricted']);
51
+ const INLINE_PROVIDER_CREDENTIAL_FIELD = /(?:key|token|secret|password|authorization|header|credential|auth)/i;
52
+
53
+ const findInlineProviderCredentialField = (value, path) => {
54
+ if (!value || typeof value !== 'object') return null;
55
+ for (const [key, nested] of Object.entries(value)) {
56
+ const normalized = key.replace(/[-_]/g, '').toLowerCase();
57
+ const fieldPath = `${path}.${key}`;
58
+ if (INLINE_PROVIDER_CREDENTIAL_FIELD.test(normalized)) return fieldPath;
59
+ const nestedPath = findInlineProviderCredentialField(nested, fieldPath);
60
+ if (nestedPath) return nestedPath;
61
+ }
62
+ return null;
63
+ };
51
64
 
52
65
  // `trust: 'internal'` was accepted by this schema and read by NO adapter. On the
53
66
  // attach path it could only name a mode the platform cannot resolve; on the
@@ -136,11 +149,24 @@ export const parseEnvironmentFile = async (absolutePath) => {
136
149
  // annotations. The caller is responsible for tracking envFileDir separately
137
150
  // (compute via `dirname(envPath)`) and passing it explicitly to
138
151
  // resolveWorkspace / mountSkills when relative paths in the spec need to
139
- // resolve. This keeps the spec safe to serialize and ship to the backend
140
- // (`config.environment` on AgentInstallation) without leaking $HOME layout.
152
+ // resolve. The provider block is host-local; API writers omit it entirely.
141
153
  return parsed;
142
154
  };
143
155
 
156
+ /**
157
+ * Remove the host-local provider declaration before an environment spec is
158
+ * sent to the Commonly API. The full spec remains in the local token record so
159
+ * the daemon can bind it to this host and the adapter can resolve its key file.
160
+ */
161
+ export const environmentForServer = (environment) => {
162
+ if (!environment || typeof environment !== 'object' || Array.isArray(environment)
163
+ || !Object.prototype.hasOwnProperty.call(environment, 'provider')) {
164
+ return environment;
165
+ }
166
+ const { provider: _hostLocalProvider, ...serverEnvironment } = environment;
167
+ return serverEnvironment;
168
+ };
169
+
144
170
  // ── validateEnvironmentSpec ─────────────────────────────────────────────────
145
171
 
146
172
  export const validateEnvironmentSpec = (spec) => {
@@ -172,6 +198,70 @@ export const validateEnvironmentSpec = (spec) => {
172
198
  }
173
199
  }
174
200
 
201
+ if (spec.provider !== undefined) {
202
+ if (!spec.provider || typeof spec.provider !== 'object' || Array.isArray(spec.provider)) {
203
+ errors.push('provider must be an object');
204
+ } else {
205
+ const allowedProviderKeys = new Set(['id', 'baseURL', 'keyFile', 'models']);
206
+ for (const key of Object.keys(spec.provider)) {
207
+ if (!allowedProviderKeys.has(key)) {
208
+ errors.push(`unknown provider key: ${JSON.stringify(key)} (allowed: id, baseURL, keyFile, models)`);
209
+ }
210
+ }
211
+ const { id, baseURL, keyFile, models } = spec.provider;
212
+ if (typeof id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(id)) {
213
+ errors.push('provider.id must be a safe OpenCode provider id');
214
+ }
215
+ if (typeof baseURL !== 'string' || baseURL.trim() === '') {
216
+ errors.push('provider.baseURL must be an HTTP(S) URL');
217
+ } else {
218
+ try {
219
+ const url = new URL(baseURL);
220
+ if (!['http:', 'https:'].includes(url.protocol)
221
+ || !url.hostname
222
+ || url.username || url.password || url.search || url.hash) {
223
+ errors.push('provider.baseURL must be an HTTP(S) URL without credentials, query, or fragment');
224
+ }
225
+ } catch {
226
+ errors.push('provider.baseURL must be an HTTP(S) URL');
227
+ }
228
+ }
229
+ if (typeof keyFile !== 'string' || !isAbsolute(keyFile)) {
230
+ errors.push('provider.keyFile must be an absolute path');
231
+ } else if (/[\r\n}]/.test(keyFile)) {
232
+ errors.push('provider.keyFile contains characters that cannot be used in an OpenCode file reference');
233
+ }
234
+ if (models !== undefined) {
235
+ if (!models || typeof models !== 'object' || Array.isArray(models)) {
236
+ errors.push('provider.models must be an object keyed by bare model id');
237
+ } else {
238
+ for (const [modelId, modelConfig] of Object.entries(models)) {
239
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(modelId)) {
240
+ errors.push(`provider.models key ${JSON.stringify(modelId)} must be a bare model id`);
241
+ }
242
+ if (!modelConfig || typeof modelConfig !== 'object' || Array.isArray(modelConfig)) {
243
+ errors.push(`provider.models[${JSON.stringify(modelId)}] must be an object`);
244
+ } else {
245
+ const credentialField = findInlineProviderCredentialField(
246
+ modelConfig,
247
+ `provider.models.${modelId}`,
248
+ );
249
+ if (credentialField) {
250
+ errors.push(`${credentialField} must not contain inline credentials; use provider.keyFile`);
251
+ }
252
+ }
253
+ }
254
+ }
255
+ }
256
+ if (spec.model === undefined) {
257
+ errors.push('model is required when provider is configured');
258
+ } else if (typeof spec.model === 'string'
259
+ && !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(spec.model)) {
260
+ errors.push('model must be a bare model id when provider is configured');
261
+ }
262
+ }
263
+ }
264
+
175
265
  // `effort` is the reasoning budget the claude adapter passes to `--effort`.
176
266
  // Unlike `model` it IS a closed set — the CLI documents exactly these — so a
177
267
  // typo fails here at attach time, not silently at the first spawn.
@@ -16,11 +16,11 @@
16
16
  * never reaches (a row naming no adapter, a backend older than the refusal, a
17
17
  * hand-written token file). Both halves emit the same `code`.
18
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.
19
+ * THE ADAPTER IS THE HOST-INDEPENDENT FACT, but sandbox support and broker
20
+ * admission are separate. OpenCode enforces a public sandbox locally; until
21
+ * the server allowlist admits its proven modes, only `claude` and `codex` may
22
+ * receive the grant broker. `pi` is also withheld because it refuses a declared
23
+ * sandbox rather than honouring it.
24
24
  *
25
25
  * ONE REASON THE SERVER DOES NOT HAVE: `sandbox_absent`. The server must ALLOW
26
26
  * an absent block (a daemon-provisioned seat's baseline is derived here and is
@@ -73,7 +73,7 @@
73
73
  * a foreign server cannot be drawn in by its spelling.
74
74
  */
75
75
  import { isGrantBrokerUrl } from './adapters/pi-mcp-client.mjs';
76
- import { ADAPTERS_WITH_DEFAULT_SANDBOX } from './default-environment.js';
76
+ import { ADAPTERS_WITH_GRANT_BROKER } from './default-environment.js';
77
77
  import { effectiveSandboxTrust } from './environment.js';
78
78
  import { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode } from './sandbox/mode.js';
79
79
 
@@ -177,7 +177,7 @@ export const declaresGrantBroker = (environment, opts) => (
177
177
  * The reason vocabulary mirrors the server's, plus `sandbox_absent`.
178
178
  */
179
179
  export const confinementReason = (environment, adapter, platform = process.platform) => {
180
- if (!ADAPTERS_WITH_DEFAULT_SANDBOX.has(adapter)) return 'adapter_cannot_confine';
180
+ if (!ADAPTERS_WITH_GRANT_BROKER.has(adapter)) return 'adapter_cannot_confine';
181
181
 
182
182
  const sandbox = environment?.sandbox;
183
183
  if (sandbox === null || typeof sandbox !== 'object' || Array.isArray(sandbox)) return 'sandbox_absent';
@@ -192,8 +192,8 @@ export const confinementReason = (environment, adapter, platform = process.platf
192
192
  const detailFor = (reason, adapter, environment) => {
193
193
  const drop = 'or drop the grant broker from this seat';
194
194
  if (reason === 'adapter_cannot_confine') {
195
- return `the seat runs the '${adapter || 'unresolved'}' adapter, which confines on no host — a declared sandbox is`
196
- + ' refused rather than enforced, and an absent one is never derived; move this seat to the claude or codex'
195
+ return `the seat runs the '${adapter || 'unresolved'}' adapter, which is not approved to receive the grant broker;`
196
+ + ' move this seat to the claude or codex'
197
197
  + ` adapter, ${drop}`;
198
198
  }
199
199
  if (reason === 'sandbox_absent') {
@@ -1,20 +1,20 @@
1
1
  /**
2
- * macOS Seatbelt wrapper for public Claude Code agents.
2
+ * macOS Seatbelt wrapper for public local-CLI agents.
3
3
  *
4
4
  * `sandbox-exec` is deprecated as a public API, but remains the host-native
5
5
  * kernel boundary available to local CLI wrappers on current macOS. This
6
- * profile starts from deny-by-default, admits only the system runtime Claude
6
+ * profile starts from deny-by-default, admits only the system runtime the CLI
7
7
  * needs, and then grants explicit access to:
8
- * - the selected Claude executable,
8
+ * - the selected CLI executable,
9
9
  * - one workspace (read/write or read-only),
10
- * - one isolated, token-free Claude state directory, and
10
+ * - one isolated state directory, and
11
11
  * - the adapter-owned transient MCP config directory (read-only).
12
12
  *
13
13
  * The static platform baseline is adapted from OpenAI Codex's Apache-2.0
14
14
  * Seatbelt policy:
15
15
  * https://github.com/openai/codex/tree/main/codex-rs/sandboxing/src
16
16
  *
17
- * Public Claude still needs outbound network access for the Anthropic API and
17
+ * Public seats still need outbound network access for their provider API and
18
18
  * declared MCP transports. Host confidentiality therefore comes from the
19
19
  * filesystem boundary, not a claim that all network syscalls are disabled.
20
20
  */
@@ -132,13 +132,11 @@ const PLATFORM_BASELINE = String.raw`
132
132
  (allow file-read-metadata (subpath "/var"))
133
133
  (allow file-read-metadata (subpath "/private/var"))
134
134
 
135
- ; The native Claude binary and declared MCP launchers may execute children.
136
- ; Claude bootstraps stdio MCP servers through /bin/sh, whose interpreter is
137
- ; /bin/bash on current macOS. An executable-level shell deny would therefore
138
- ; disable the declared capability too. The built-in Bash tool is denied in
139
- ; Claude argv; any trusted child that does run inherits this same Seatbelt
140
- ; profile, so host-secret reads and out-of-workspace writes remain kernel
141
- ; denied.
135
+ ; The wrapper and declared MCP launchers may execute children. Claude
136
+ ; bootstraps stdio MCP servers through /bin/sh, whose interpreter is /bin/bash
137
+ ; on current macOS. An executable-level shell deny would therefore disable
138
+ ; declared MCP capability too. Each adapter denies its built-in shell tool in
139
+ ; its own config; any declared child inherits this Seatbelt profile.
142
140
 
143
141
  (allow file-read-data file-read-metadata (subpath "/bin"))
144
142
  (allow file-read-data file-read-metadata (subpath "/sbin"))
@@ -264,7 +262,7 @@ const PLATFORM_BASELINE = String.raw`
264
262
  (local-name "com.apple.cfprefsd.agent"))
265
263
  (allow user-preference-read)
266
264
 
267
- ; Claude itself and declared MCP transports need outbound network. The model's
265
+ ; The selected provider and declared MCP transports need outbound network. The model's
268
266
  ; WebSearch/WebFetch/Bash tools are denied independently in the adapter argv.
269
267
  (allow network-outbound)
270
268
  (allow system-socket
@@ -329,29 +327,39 @@ export const detectSeatbelt = () => {
329
327
  export const buildSeatbeltProfile = ({
330
328
  workspacePath,
331
329
  workspaceAccess = 'write',
332
- claudePath,
330
+ executablePath,
333
331
  statePath,
334
332
  mcpConfigDir = null,
335
333
  executablePaths = [],
334
+ readOnlyPaths = [],
335
+ loopbackNetworkPorts = [],
336
+ allowClaudeRuntimeAccess = false,
336
337
  }) => {
337
338
  for (const [path, label] of [
338
339
  [workspacePath, 'workspacePath'],
339
- [claudePath, 'claudePath'],
340
+ [executablePath, 'executablePath'],
340
341
  [statePath, 'statePath'],
341
342
  ]) {
342
343
  assertAbsolutePath(path, label);
343
344
  }
344
345
  if (mcpConfigDir) assertAbsolutePath(mcpConfigDir, 'mcpConfigDir');
346
+ for (const path of readOnlyPaths) assertAbsolutePath(path, 'readOnlyPaths entry');
347
+ for (const port of loopbackNetworkPorts) {
348
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
349
+ throw new Error('Seatbelt loopbackNetworkPorts entries must be valid TCP ports');
350
+ }
351
+ }
345
352
  if (!['read', 'write'].includes(workspaceAccess)) {
346
353
  throw new Error('Seatbelt workspaceAccess must be read or write');
347
354
  }
348
355
 
349
356
  const resolvedWorkspace = realpathOrSelf(workspacePath);
350
- const resolvedClaude = realpathOrSelf(claudePath);
357
+ const resolvedExecutable = realpathOrSelf(executablePath);
351
358
  const resolvedState = realpathOrSelf(statePath);
352
359
  const resolvedMcpConfig = mcpConfigDir ? realpathOrSelf(mcpConfigDir) : null;
360
+ const resolvedReadOnlyPaths = [...new Set(readOnlyPaths.map(realpathOrSelf))];
353
361
  const executables = [...new Set(
354
- [resolvedClaude, process.execPath, ...executablePaths]
362
+ [resolvedExecutable, process.execPath, ...executablePaths]
355
363
  .filter(Boolean)
356
364
  .map(realpathOrSelf),
357
365
  )];
@@ -369,9 +377,27 @@ export const buildSeatbeltProfile = ({
369
377
  const mcpRule = resolvedMcpConfig
370
378
  ? `(allow file-read* file-test-existence ${subpath(resolvedMcpConfig)})`
371
379
  : '';
372
- const claudeTmp = `/private/tmp/claude-${typeof process.getuid === 'function' ? process.getuid() : '0'}`;
373
- const userKeychains = join(homedir(), 'Library', 'Keychains');
374
- const textEncoding = join(homedir(), '.CFUserTextEncoding');
380
+ const readOnlyRules = resolvedReadOnlyPaths.map((path) => (
381
+ `(allow file-read* file-test-existence ${literal(path)})`
382
+ )).join('\n');
383
+ const loopbackNetworkRules = [...new Set(loopbackNetworkPorts)].map((port) => (
384
+ `(allow network-outbound (remote tcp "localhost:${port}"))`
385
+ )).join('\n');
386
+ const claudeRuntimeRules = allowClaudeRuntimeAccess ? (() => {
387
+ const claudeTmp = `/private/tmp/claude-${typeof process.getuid === 'function' ? process.getuid() : '0'}`;
388
+ const userKeychains = join(homedir(), 'Library', 'Keychains');
389
+ const textEncoding = join(homedir(), '.CFUserTextEncoding');
390
+ return [
391
+ // Native Claude uses a uid-scoped /private/tmp directory. Admit that
392
+ // exact runtime directory; every sibling remains denied by default.
393
+ `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(claudeTmp)}"))`,
394
+ `(allow file-read* file-test-existence file-write* ${literal(claudeTmp)} ${subpath(claudeTmp)})`,
395
+ // Claude's security helper needs the encrypted login-keychain database
396
+ // and this non-secret locale file. Other wrappers do not get this access.
397
+ `(allow file-read* file-test-existence ${subpath(userKeychains)} ${literal(textEncoding)})`,
398
+ `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(userKeychains)}"))`,
399
+ ];
400
+ })() : [];
375
401
 
376
402
  return [
377
403
  PLATFORM_BASELINE.trim(),
@@ -380,22 +406,15 @@ export const buildSeatbeltProfile = ({
380
406
  `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(resolvedState)}"))`,
381
407
  workspaceRule,
382
408
  `(allow file-read* file-test-existence file-write* ${subpath(resolvedState)})`,
409
+ loopbackNetworkRules,
383
410
  mcpRule,
411
+ readOnlyRules,
384
412
  // The workspace is intentionally readable, but credential-like nested
385
413
  // paths are not. Explicit deny wins over the broad workspace allow.
386
414
  `(deny file-read* file-write* ${subpath(join(resolvedWorkspace, '.commonly'))})`,
387
415
  `(deny file-read* file-write* ${subpath(join(resolvedWorkspace, '.codex'))})`,
388
416
  `(deny file-read* file-write* ${literal(join(resolvedWorkspace, '.env'))})`,
389
- // Native Claude uses a uid-scoped /private/tmp directory even when
390
- // TMPDIR points elsewhere. Admit that exact runtime directory; every
391
- // sibling under /private/tmp remains denied by the default policy.
392
- `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(claudeTmp)}"))`,
393
- `(allow file-read* file-test-existence file-write* ${literal(claudeTmp)} ${subpath(claudeTmp)})`,
394
- // OAuth remains in macOS Keychain rather than a bearer-token file or
395
- // child environment variable. Claude's `security` helper needs the
396
- // encrypted login-keychain database plus this non-secret locale file.
397
- `(allow file-read* file-test-existence ${subpath(userKeychains)} ${literal(textEncoding)})`,
398
- `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(userKeychains)}"))`,
417
+ ...claudeRuntimeRules,
399
418
  ].filter(Boolean).join('\n\n');
400
419
  };
401
420