@commonlyai/cli 0.1.75 → 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.75",
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}'];
@@ -88,6 +104,60 @@ export const isShippedCommonlyMcpEntry = (server) => {
88
104
  // installed entry that declares NO cwd, so the entry is admitted as one the
89
105
  // operator already installed (Vera, hold on #1915).
90
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
+
91
161
  const unreadableField = (server) => {
92
162
  for (const key of ['env', 'headers']) {
93
163
  const value = server[key];
@@ -195,9 +265,8 @@ export const auditDeclaredMcp = (environment, { instanceUrl, allowedStdioEntries
195
265
  return;
196
266
  }
197
267
  if (transport === 'http' || transport === 'sse') {
198
- const origin = originOf(server.url, instanceUrl);
199
- if (origin && instanceOrigin && origin === instanceOrigin) return;
200
- 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`);
201
270
  return;
202
271
  }
203
272
  refusals.push(`'${name}': unknown transport ${JSON.stringify(transport)}`);