@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 +1 -1
- package/src/lib/declared-mcp-guard.js +112 -7
package/package.json
CHANGED
|
@@ -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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
163
|
-
|
|
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)}`);
|