@commonlyai/cli 0.1.74 → 0.1.76

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.74",
3
+ "version": "0.1.76",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -20,10 +20,23 @@
20
20
  * `npm_config_registry` fetches the package from an attacker, and
21
21
  * with a literal `COMMONLY_API_URL=https://attacker…` posts the
22
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.
23
+ * http — the ENTRY, `name` aside, must BE the one http server the instance
24
+ * ships: the grant broker (`grantBrokerServer`,
25
+ * backend/services/grantBrokerProjectionService.ts). `transport`
26
+ * exactly `http`; the url, with ONLY the two instance placeholders
27
+ * resolved and nothing else expanded, of our own origin at
28
+ * `/api/mcp/grants/<id>`, with no query, hash or userinfo; headers
29
+ * exactly the single Bearer token placeholder; and no other field.
30
+ * `sse` is not admitted at all: nothing ships it, and its GET is the
31
+ * method every 3xx route in this repo answers. Pinning the ORIGIN
32
+ * alone was not enough — a same-origin url whose path is a GET route
33
+ * that redirects (`/api/auth/oauth/*`, both Slack and Discord
34
+ * callbacks) passed the origin check and let the client leave the
35
+ * instance on the hop, and an added header would have travelled with
36
+ * it (TASK-150). Matched exactly rather than folded: the set this
37
+ * guard ADMITS has to equal the set that is TESTED for the absence of
38
+ * a redirect, and folding case or collapsing slashes widens what is
39
+ * admitted while narrowing what is withheld.
27
40
  * The rule is origin-based, not placeholder-based, because the claude CLI
28
41
  * expands `${VAR}` and `${VAR:-default}` in url and headers from its own
29
42
  * environment: `?t=${COMMONLY_AGENT_TOKEN:-}` is not the literal placeholder
@@ -31,6 +44,9 @@
31
44
  * environment is the operator's, so `?k=${GITHUB_TOKEN}` leaks too (Vera,
32
45
  * Connectors 69519). So any `${` other than the three known placeholders,
33
46
  * anywhere in an entry — url, headers, command, env — is refused outright.
47
+ *
48
+ * The guarantee is instance-specific: a self-hosted proxy that redirects
49
+ * `/api/mcp/grants/…` breaks it in a way no test in this repo can see.
34
50
  */
35
51
 
36
52
  const URL_PLACEHOLDERS = ['${COMMONLY_API_URL}', '${COMMONLY_INSTANCE_URL}'];
@@ -76,6 +92,91 @@ export const isShippedCommonlyMcpEntry = (server) => {
76
92
  // True when a string still contains `${` after the known placeholders are
77
93
  // removed — a `${VAR}`, `${VAR:-default}` or any other expansion the CLI
78
94
  // would resolve from an environment this declaration does not own.
95
+ // A field the guard cannot READ is not a field it can judge. `env` and
96
+ // `headers` are spread by every reader (`{ ...server.env }`), so a STRING there
97
+ // becomes single-character keys and the declaration is silently dropped rather
98
+ // than honoured — the next consumer that parses the string instead would honour
99
+ // it, and this guard would have admitted the entry on the strength of its other
100
+ // fields (TASK-069: judge the whole entry, not selected fields). `args` is read
101
+ // only through `Array.isArray`, and `cwd` through `typeof === 'string'` in
102
+ // `executionShape`, so a value of the wrong type there is dropped the same way —
103
+ // and for `cwd` that drop is load-bearing: the normalised shape then equals an
104
+ // installed entry that declares NO cwd, so the entry is admitted as one the
105
+ // operator already installed (Vera, hold on #1915).
106
+ // Fail closed: an unreadable shape is a refusal, never an absence.
107
+ // The one http entry the server ships: url and headers as `grantBrokerServer`
108
+ // writes them (`GRANT_BROKER_URL` / `GRANT_BROKER_AUTHORIZATION`).
109
+ const GRANT_BROKER_PATH = /^\/api\/mcp\/grants\/[A-Za-z0-9_-]+$/;
110
+ const GRANT_BROKER_AUTHORIZATION = 'Bearer ${COMMONLY_AGENT_TOKEN}';
111
+
112
+ const sameOriginAsInstance = (url, instanceUrl) => {
113
+ const origin = originOf(url, instanceUrl);
114
+ if (!origin) return false;
115
+ try {
116
+ return origin === new URL(instanceUrl).origin;
117
+ } catch {
118
+ return false;
119
+ }
120
+ };
121
+
122
+ // The url is judged by what a client would carry, not by the substring the
123
+ // origin check happens to read: the path has to be the grants path with ONE id
124
+ // segment, and a query, a hash or userinfo is a field the origin comparison
125
+ // does not look at but the request does carry.
126
+ const grantBrokerUrl = (url, instanceUrl) => {
127
+ if (!sameOriginAsInstance(url, instanceUrl)) return false;
128
+ let expanded = String(url);
129
+ for (const placeholder of URL_PLACEHOLDERS) expanded = expanded.split(placeholder).join(instanceUrl);
130
+ let parsed;
131
+ try {
132
+ parsed = new URL(expanded);
133
+ } catch {
134
+ return false;
135
+ }
136
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) return false;
137
+ return GRANT_BROKER_PATH.test(parsed.pathname);
138
+ };
139
+
140
+ /**
141
+ * The shipped grant broker, matched as a WHOLE entry (the http twin of
142
+ * `isShippedCommonlyMcpEntry`): `name` is identity — the projection suffixes it
143
+ * when one seat holds several grants — and every other field has to equal what
144
+ * `grantBrokerServer` writes, with no field added, because a field the client
145
+ * reads and this guard does not is exactly the shape the whole-entry rule
146
+ * exists to refuse (TASK-069).
147
+ */
148
+ export const isShippedGrantBrokerEntry = (server, instanceUrl) => {
149
+ if (!server || typeof server !== 'object') return false;
150
+ if (server.transport !== 'http') return false;
151
+ const keys = Object.keys(server).filter((key) => key !== 'name').sort();
152
+ if (keys.join(',') !== 'headers,transport,url') return false;
153
+ const headers = server.headers;
154
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return false;
155
+ const headerKeys = Object.keys(headers);
156
+ if (headerKeys.length !== 1 || headerKeys[0] !== 'Authorization') return false;
157
+ if (headers.Authorization !== GRANT_BROKER_AUTHORIZATION) return false;
158
+ return grantBrokerUrl(server.url, instanceUrl);
159
+ };
160
+
161
+ const unreadableField = (server) => {
162
+ for (const key of ['env', 'headers']) {
163
+ const value = server[key];
164
+ if (value === undefined || value === null) continue;
165
+ if (typeof value !== 'object' || Array.isArray(value)) {
166
+ return `${key} is of type ${typeof value}, not an object`;
167
+ }
168
+ const badValue = Object.entries(value).find(([, v]) => typeof v !== 'string');
169
+ if (badValue) return `${key}.${badValue[0]} is not a string`;
170
+ }
171
+ if (server.cwd !== undefined && server.cwd !== null && typeof server.cwd !== 'string') {
172
+ return `cwd is of type ${typeof server.cwd}, not a string`;
173
+ }
174
+ if (server.args === undefined || server.args === null) return null;
175
+ if (!Array.isArray(server.args)) return `args is of type ${typeof server.args}, not an array`;
176
+ if (server.args.some((a) => typeof a !== 'string')) return 'args carries a non-string';
177
+ return null;
178
+ };
179
+
79
180
  const hasForeignExpansion = (value) => {
80
181
  if (typeof value !== 'string') return false;
81
182
  let rest = value;
@@ -127,6 +228,11 @@ export const auditDeclaredMcp = (environment, { instanceUrl, allowedStdioEntries
127
228
  }
128
229
  const name = typeof server.name === 'string' && server.name ? server.name : `mcp[${index}]`;
129
230
  const transport = server.transport || 'stdio';
231
+ const unreadable = unreadableField(server);
232
+ if (unreadable) {
233
+ refusals.push(`'${name}': declares ${unreadable}, so this guard cannot judge what the entry would run; refusing it rather than reading the field as absent`);
234
+ return;
235
+ }
130
236
  // One shape per entry. The adapters classified by which field was present,
131
237
  // so `{transport:'http', url:<instance>, command:['sh','-c',…]}` passed an
132
238
  // origin check here and ran as stdio there with the token substituted
@@ -159,9 +265,8 @@ export const auditDeclaredMcp = (environment, { instanceUrl, allowedStdioEntries
159
265
  return;
160
266
  }
161
267
  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`);
268
+ if (transport === 'http' && isShippedGrantBrokerEntry(server, instanceUrl)) return;
269
+ refusals.push(`'${name}': declared ${transport} entry ${JSON.stringify(server.url)} is not the one http server this instance ships; a declaration is admitted only as the grant broker (transport 'http', url ${instanceOrigin || instanceUrl}/api/mcp/grants/<id> with no query or fragment, headers exactly {'Authorization': 'Bearer \${COMMONLY_AGENT_TOKEN}'}, and no other field), and 'sse' is not admitted at all`);
165
270
  return;
166
271
  }
167
272
  refusals.push(`'${name}': unknown transport ${JSON.stringify(transport)}`);