@commonlyai/cli 0.1.55 → 0.1.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.55",
3
+ "version": "0.1.56",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -1,6 +1,7 @@
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
5
 
5
6
  import { seatBaseline } from './default-environment.js';
6
7
 
@@ -148,7 +149,23 @@ export const createDaemonSupervisor = ({
148
149
  return Object.keys(fallback).length ? { value: fallback, declared: false } : null;
149
150
  };
150
151
 
151
- // Ensure ~/.commonly/tokens/<name>.json exists so `agent run` can boot.
152
+ // A declared environment runs on THIS machine as the operator. Refuse any
153
+ // declared stdio command that is not the shipped commonly MCP server or one
154
+ // the operator already installed here, and any http server that would be
155
+ // handed the seat token off the instance's origin. Refusal keeps the current
156
+ // seat (or skips the mint) and says which server was kept off the machine;
157
+ // it never adopts a partial environment.
158
+ const admitDeclared = (row, environment, existing) => {
159
+ const audit = auditDeclaredMcp(environment, {
160
+ instanceUrl: existing?.instanceUrl || record.instanceUrl,
161
+ allowedStdioEntries: installedStdioEntries(existing),
162
+ });
163
+ if (audit.ok) return true;
164
+ log(`[${row.agentName}] refusing the declared environment — it would not stay on this machine's terms:`);
165
+ for (const refusal of audit.refusals) log(`[${row.agentName}] ${refusal}`);
166
+ return false;
167
+ };
168
+
152
169
  // The mint refuses to clobber an existing token (409 token_exists); the
153
170
  // binding to THIS machine is the owner's explicit takeover choice (D3), so
154
171
  // that refusal is answered with rotate:true — loudly.
@@ -162,6 +179,7 @@ export const createDaemonSupervisor = ({
162
179
  // once at boot). A row with NO declared model leaves the record alone —
163
180
  // never strip an operator's hand-set environment.
164
181
  const wanted = environmentFor(row);
182
+ if (wanted?.declared && !admitDeclared(row, wanted.value, existing)) return false;
165
183
  const declaredAdapter = row.runtime && typeof row.runtime === 'object'
166
184
  && typeof row.runtime.adapter === 'string'
167
185
  ? row.runtime.adapter.trim().toLowerCase()
@@ -259,6 +277,8 @@ export const createDaemonSupervisor = ({
259
277
  return false;
260
278
  }
261
279
  }
280
+ const declaredAtMint = environmentFor(row);
281
+ if (declaredAtMint?.declared && !admitDeclared(row, declaredAtMint.value, null)) return false;
262
282
  const body = { agentName: row.agentName, instanceId: row.instanceId };
263
283
  let minted;
264
284
  try {
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Guard for a server-declared `environment.mcp` before the daemon adopts it.
3
+ *
4
+ * The daemon projects `AgentInstallation.config.environment` onto the OWNER's
5
+ * machine: every declared stdio server is spawned as the operator, and every
6
+ * declared http server is handed the seat token wherever the declaration puts
7
+ * the `${COMMONLY_AGENT_TOKEN}` placeholder. The registry PATCH that writes
8
+ * that declaration was pod-member gated (Vera, Connectors 69500, 2026-09-18),
9
+ * so a plain member could run a command on the owner's laptop or ship the
10
+ * token to a host they control. The server fix is owner/admin gating; this is
11
+ * the daemon's own layer, which must hold even if the server is wrong again.
12
+ *
13
+ * Two rules, both fail-closed:
14
+ * stdio — the ENTRY must be the shipped commonly MCP server — command
15
+ * `npx -y @commonlyai/mcp@<tag>`, env limited to the two canonical
16
+ * placeholders, no args/cwd — or equal, as a whole entry, to one
17
+ * the operator already installed by hand in the local token record.
18
+ * Command alone is not enough: the shipped command with
19
+ * `NODE_OPTIONS=--import=data:…` executes code, with
20
+ * `npm_config_registry` fetches the package from an attacker, and
21
+ * with a literal `COMMONLY_API_URL=https://attacker…` posts the
22
+ * token there (sprint-review, Sharpen 69526/69534).
23
+ * http — the url, with ONLY the two instance placeholders resolved and
24
+ * nothing else expanded, must parse to the instance's own origin
25
+ * (scheme + host + port). The grant broker declares
26
+ * `${COMMONLY_API_URL}/api/mcp/grants/…`, so it passes.
27
+ * The rule is origin-based, not placeholder-based, because the claude CLI
28
+ * expands `${VAR}` and `${VAR:-default}` in url and headers from its own
29
+ * environment: `?t=${COMMONLY_AGENT_TOKEN:-}` is not the literal placeholder
30
+ * and still becomes the token, and outside the public sandbox that
31
+ * environment is the operator's, so `?k=${GITHUB_TOKEN}` leaks too (Vera,
32
+ * Connectors 69519). So any `${` other than the three known placeholders,
33
+ * anywhere in an entry — url, headers, command, env — is refused outright.
34
+ */
35
+
36
+ const URL_PLACEHOLDERS = ['${COMMONLY_API_URL}', '${COMMONLY_INSTANCE_URL}'];
37
+ const KNOWN_PLACEHOLDERS = [...URL_PLACEHOLDERS, '${COMMONLY_AGENT_TOKEN}'];
38
+ const SHIPPED_PACKAGE = /^@commonlyai\/mcp(@[A-Za-z0-9._-]+)?$/;
39
+
40
+ export const isShippedCommonlyMcpCommand = (command) => (
41
+ Array.isArray(command)
42
+ && command.length === 3
43
+ && command[0] === 'npx'
44
+ && command[1] === '-y'
45
+ && typeof command[2] === 'string'
46
+ && SHIPPED_PACKAGE.test(command[2])
47
+ );
48
+
49
+ const CANONICAL_STDIO_ENV = {
50
+ COMMONLY_API_URL: '${COMMONLY_API_URL}',
51
+ COMMONLY_AGENT_TOKEN: '${COMMONLY_AGENT_TOKEN}',
52
+ };
53
+
54
+ // The execution-relevant shape of a stdio entry: everything that decides what
55
+ // runs and with what. `name` and `transport` are identity, not execution.
56
+ const executionShape = (server) => JSON.stringify({
57
+ command: Array.isArray(server.command) ? server.command : null,
58
+ args: Array.isArray(server.args) && server.args.length ? server.args : null,
59
+ cwd: typeof server.cwd === 'string' ? server.cwd : null,
60
+ env: server.env && typeof server.env === 'object'
61
+ ? Object.fromEntries(Object.entries(server.env).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
62
+ : null,
63
+ });
64
+
65
+ // The shipped server exactly: its command, only its own env keys at their
66
+ // canonical placeholder values, nothing else that changes what executes.
67
+ export const isShippedCommonlyMcpEntry = (server) => {
68
+ if (!server || typeof server !== 'object') return false;
69
+ if (!isShippedCommonlyMcpCommand(server.command)) return false;
70
+ if (Array.isArray(server.args) && server.args.length) return false;
71
+ if (server.cwd !== undefined) return false;
72
+ const env = server.env && typeof server.env === 'object' ? server.env : {};
73
+ return Object.entries(env).every(([key, value]) => CANONICAL_STDIO_ENV[key] === value);
74
+ };
75
+
76
+ // True when a string still contains `${` after the known placeholders are
77
+ // removed — a `${VAR}`, `${VAR:-default}` or any other expansion the CLI
78
+ // would resolve from an environment this declaration does not own.
79
+ const hasForeignExpansion = (value) => {
80
+ if (typeof value !== 'string') return false;
81
+ let rest = value;
82
+ for (const placeholder of KNOWN_PLACEHOLDERS) rest = rest.split(placeholder).join('');
83
+ return rest.includes('${');
84
+ };
85
+
86
+ const stringsOf = (server) => {
87
+ const out = [];
88
+ if (typeof server.url === 'string') out.push(server.url);
89
+ for (const bag of [server.headers, server.env]) {
90
+ if (bag && typeof bag === 'object') out.push(...Object.values(bag));
91
+ }
92
+ if (Array.isArray(server.command)) out.push(...server.command);
93
+ if (Array.isArray(server.args)) out.push(...server.args);
94
+ return out.filter((v) => typeof v === 'string');
95
+ };
96
+
97
+ const originOf = (url, instanceUrl) => {
98
+ let expanded = String(url);
99
+ for (const placeholder of URL_PLACEHOLDERS) expanded = expanded.split(placeholder).join(instanceUrl);
100
+ try {
101
+ return new URL(expanded).origin;
102
+ } catch {
103
+ return null;
104
+ }
105
+ };
106
+
107
+ /**
108
+ * @returns {{ ok: boolean, refusals: string[] }} — one refusal line per
109
+ * offending server, naming it, so the daemon log says exactly what was kept
110
+ * off the machine.
111
+ */
112
+ export const auditDeclaredMcp = (environment, { instanceUrl, allowedStdioEntries = [] } = {}) => {
113
+ const refusals = [];
114
+ const servers = environment && typeof environment === 'object' && Array.isArray(environment.mcp)
115
+ ? environment.mcp : [];
116
+ let instanceOrigin = null;
117
+ try {
118
+ instanceOrigin = new URL(instanceUrl).origin;
119
+ } catch {
120
+ instanceOrigin = null;
121
+ }
122
+
123
+ servers.forEach((server, index) => {
124
+ if (!server || typeof server !== 'object') {
125
+ refusals.push(`mcp[${index}] is not an object`);
126
+ return;
127
+ }
128
+ const name = typeof server.name === 'string' && server.name ? server.name : `mcp[${index}]`;
129
+ const transport = server.transport || 'stdio';
130
+ // One shape per entry. The adapters classified by which field was present,
131
+ // so `{transport:'http', url:<instance>, command:['sh','-c',…]}` passed an
132
+ // origin check here and ran as stdio there with the token substituted
133
+ // (#1764 fixes the adapters; the guard refuses the shape outright).
134
+ if (server.command !== undefined && server.url !== undefined) {
135
+ refusals.push(`'${name}': declares both a command and a url; one entry is one transport`);
136
+ return;
137
+ }
138
+ if (transport === 'stdio' && server.url !== undefined) {
139
+ refusals.push(`'${name}': stdio entry carries a url`);
140
+ return;
141
+ }
142
+ if ((transport === 'http' || transport === 'sse') && server.command !== undefined) {
143
+ refusals.push(`'${name}': ${transport} entry carries a command`);
144
+ return;
145
+ }
146
+ const foreign = stringsOf(server).find(hasForeignExpansion);
147
+ if (foreign !== undefined) {
148
+ refusals.push(`'${name}': ${JSON.stringify(foreign)} contains an expansion other than the instance placeholders; the CLI would resolve it from this machine's environment`);
149
+ return;
150
+ }
151
+ if (transport === 'stdio') {
152
+ if (isShippedCommonlyMcpEntry(server)) return;
153
+ const shape = executionShape(server);
154
+ if (allowedStdioEntries.some((allowed) => executionShape(allowed) === shape)) return;
155
+ const why = isShippedCommonlyMcpCommand(server.command)
156
+ ? `carries env/args/cwd beyond the shipped server's own (${Object.keys(server.env || {}).filter((k) => CANONICAL_STDIO_ENV[k] !== server.env[k]).join(', ') || 'args/cwd'})`
157
+ : `command ${JSON.stringify(server.command)} is not the shipped commonly MCP server`;
158
+ refusals.push(`'${name}': declared stdio entry ${why}, and no entry installed on this machine matches it as a whole`);
159
+ return;
160
+ }
161
+ if (transport === 'http' || transport === 'sse') {
162
+ const origin = originOf(server.url, instanceUrl);
163
+ if (origin && instanceOrigin && origin === instanceOrigin) return;
164
+ refusals.push(`'${name}': ${transport} server ${JSON.stringify(server.url)} resolves to origin ${origin || '(unparseable)'}, not this instance (${instanceOrigin || instanceUrl}); a declared http server may only be the instance itself`);
165
+ return;
166
+ }
167
+ refusals.push(`'${name}': unknown transport ${JSON.stringify(transport)}`);
168
+ });
169
+
170
+ return { ok: refusals.length === 0, refusals };
171
+ };
172
+
173
+ /** The stdio entries an operator has already placed in a local token record. */
174
+ export const installedStdioEntries = (record) => (
175
+ Array.isArray(record?.environment?.mcp)
176
+ ? record.environment.mcp
177
+ .filter((s) => s && typeof s === 'object' && (s.transport || 'stdio') === 'stdio' && Array.isArray(s.command))
178
+ : []
179
+ );