@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.
- package/package.json +1 -1
- package/src/commands/agent.js +217 -14
- package/src/commands/daemon.js +2 -2
- package/src/lib/adapters/claude.js +2 -1
- package/src/lib/adapters/index.js +2 -0
- package/src/lib/adapters/opencode.js +923 -0
- package/src/lib/daemon-supervisor.js +34 -12
- package/src/lib/declared-mcp-guard.js +1 -1
- package/src/lib/default-environment.js +13 -5
- package/src/lib/enforcement.js +42 -7
- package/src/lib/environment.js +93 -3
- package/src/lib/grant-broker-guard.js +9 -9
- package/src/lib/sandbox/seatbelt.js +49 -30
|
@@ -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 {
|
|
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
|
|
156
|
-
//
|
|
157
|
-
//
|
|
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
|
|
171
|
+
const mcpAudit = auditDeclaredMcp(environment, {
|
|
161
172
|
instanceUrl: existing?.instanceUrl || record.instanceUrl,
|
|
162
173
|
allowedStdioEntries: installedStdioEntries(existing),
|
|
163
174
|
});
|
|
164
|
-
if (
|
|
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
|
|
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
|
-
?
|
|
228
|
-
: { ...(existing.environment || {}), ...
|
|
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
|
|
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
|
|
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
|
|
68
|
-
* block (ADAPTERS_WITH_DEFAULT_SANDBOX).
|
|
69
|
-
*
|
|
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
|
|
package/src/lib/enforcement.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
823
|
+
if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
|
|
789
824
|
messages += 1;
|
|
790
825
|
}
|
|
791
826
|
return { mode: 'split-fallback', messages };
|
package/src/lib/environment.js
CHANGED
|
@@ -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.
|
|
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,
|
|
20
|
-
*
|
|
21
|
-
* `
|
|
22
|
-
*
|
|
23
|
-
* sandbox rather than honouring it
|
|
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 {
|
|
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 (!
|
|
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
|
|
196
|
-
+ '
|
|
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
|
|
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
|
|
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
|
|
8
|
+
* - the selected CLI executable,
|
|
9
9
|
* - one workspace (read/write or read-only),
|
|
10
|
-
* - one isolated
|
|
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
|
|
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
|
|
136
|
-
;
|
|
137
|
-
;
|
|
138
|
-
;
|
|
139
|
-
;
|
|
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
|
-
;
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
|
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
|
-
[
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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
|
|