@shomra/agent 0.3.29 → 0.3.30

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.
Files changed (77) hide show
  1. package/package.json +1 -1
  2. package/src/agents/hook-command.mjs +1 -1
  3. package/src/artifacts/matchers.mjs +7 -0
  4. package/src/cli/flags.mjs +2 -2
  5. package/src/cli/help-sections.mjs +7 -0
  6. package/src/cli/help.mjs +1 -1
  7. package/src/commands/check.mjs +3 -11
  8. package/src/commands/gate.mjs +26 -4
  9. package/src/commands/git-hooks.mjs +2 -2
  10. package/src/commands/ledger.mjs +0 -1
  11. package/src/commands/mcp-add.mjs +2 -1
  12. package/src/commands/memory-scan.mjs +135 -47
  13. package/src/commands/pr.mjs +7 -10
  14. package/src/commands/provenance.mjs +8 -13
  15. package/src/commands/scan.mjs +7 -1
  16. package/src/commands/secrets.mjs +4 -5
  17. package/src/core/git-exec.mjs +79 -0
  18. package/src/core/yaml-lite.mjs +300 -0
  19. package/src/core/zip-lite.mjs +37 -0
  20. package/src/detect/local-redact.mjs +1 -3
  21. package/src/detect/sast/rules-config.mjs +1 -1
  22. package/src/detect/sast/scanner.mjs +1 -1
  23. package/src/detect/signals/agent-frameworks.mjs +231 -0
  24. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  25. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  26. package/src/detect/signals/agentic-shim.mjs +82 -0
  27. package/src/detect/signals/artifacts.mjs +7 -35
  28. package/src/detect/signals/chat-template.mjs +211 -0
  29. package/src/detect/signals/ci-workflow.mjs +169 -0
  30. package/src/detect/signals/gate.mjs +77 -9
  31. package/src/detect/signals/guardrail-shape.mjs +564 -0
  32. package/src/detect/signals/guardrail-surface.mjs +221 -0
  33. package/src/detect/signals/injection.mjs +8 -0
  34. package/src/detect/signals/inspect-shim.mjs +7 -0
  35. package/src/detect/signals/instruction-paths.mjs +60 -0
  36. package/src/detect/signals/manifests.mjs +302 -0
  37. package/src/detect/signals/mcp-advisories.mjs +109 -0
  38. package/src/detect/signals/mcp-config.mjs +598 -0
  39. package/src/detect/signals/memory-directives.mjs +661 -0
  40. package/src/detect/signals/memory-locations.mjs +158 -0
  41. package/src/detect/signals/memory.mjs +47 -29
  42. package/src/detect/signals/model-config-rules.mjs +655 -0
  43. package/src/detect/signals/model-config.mjs +61 -0
  44. package/src/detect/signals/prose-context.mjs +6 -9
  45. package/src/detect/signals/scan.mjs +4 -4
  46. package/src/detect/signals/secret-scanner.mjs +241 -0
  47. package/src/detect/signals/secrets.mjs +1 -48
  48. package/src/detect/signals/shell.mjs +3 -3
  49. package/src/gate/advisories.mjs +16 -0
  50. package/src/gate/batch.mjs +10 -0
  51. package/src/gate/environment.mjs +8 -53
  52. package/src/guard/artifact-paths.mjs +107 -0
  53. package/src/guard/classify.mjs +165 -7
  54. package/src/guard/command-resolve.mjs +35 -5
  55. package/src/guard/memory-write.mjs +218 -0
  56. package/src/guard/prompt-guard.mjs +0 -1
  57. package/src/guard/tool-guard.mjs +52 -77
  58. package/src/inventory/agent-posture.mjs +236 -57
  59. package/src/inventory/artifacts/classify.mjs +10 -1
  60. package/src/inventory/artifacts/discover.mjs +113 -3
  61. package/src/inventory/artifacts/extensions.mjs +70 -0
  62. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  63. package/src/inventory/artifacts/limits.mjs +1 -1
  64. package/src/inventory/artifacts/plugins.mjs +105 -0
  65. package/src/inventory/artifacts/roots.mjs +40 -0
  66. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  67. package/src/inventory/discovery/all.mjs +4 -0
  68. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  69. package/src/inventory/discovery/coding-agents.mjs +19 -4
  70. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  71. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  72. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  73. package/src/inventory/env-redirect.mjs +148 -0
  74. package/src/inventory/grant-extract.mjs +463 -0
  75. package/src/inventory/project-roots.mjs +108 -0
  76. package/src/inventory/vscode-state.mjs +153 -0
  77. package/src/mcp/server-tools.mjs +1 -1
@@ -0,0 +1,598 @@
1
+ import { assessUrl } from './egress.mjs';
2
+ import { MALICIOUS_PACKAGE_SEED, POPULAR_PACKAGES, editDistance } from './packages.mjs';
3
+ import { SECRET_PATTERNS, isPlaceholderSecret } from './secrets.mjs';
4
+ import { parseLooseToml } from '../../inventory/grant-extract.mjs';
5
+ import { parseYaml } from '../../core/yaml-lite.mjs';
6
+
7
+ const MAX_SERVERS = 200;
8
+ const MAX_ARGS = 64;
9
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
10
+ const str = (v) => (typeof v === 'string' && v.trim() ? v : null);
11
+ const strList = (v) => (Array.isArray(v) ? v.filter((x) => x != null && typeof x !== 'object').map(String).slice(0, MAX_ARGS) : []);
12
+
13
+ /* ─── documents ──────────────────────────────────────────────────────────── */
14
+
15
+ function stripJsonComments(s) {
16
+ return String(s).replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"])\/\/.*$/gm, '$1').replace(/,(\s*[}\]])/g, '$1');
17
+ }
18
+
19
+ export function parseConfigDoc(text, path = '') {
20
+ if (typeof text !== 'string' || !text.trim()) return null;
21
+ if (/\.toml$/i.test(path)) return parseLooseToml(text);
22
+ if (/\.ya?ml$/i.test(path)) return parseYaml(text);
23
+ try { return JSON.parse(stripJsonComments(text)); } catch { /* fall through */ }
24
+ if (/^\s*\[/m.test(text) && /=/.test(text)) return parseLooseToml(text);
25
+ return null;
26
+ }
27
+
28
+
29
+ const looksLikeServer = (v) => isObj(v) && ['command', 'cmd', 'url', 'httpUrl', 'serverUrl', 'uri', 'type', 'args'].some((k) => k in v);
30
+
31
+ function asMap(v) {
32
+ if (isObj(v)) return v;
33
+ if (Array.isArray(v) && v.some((x) => isObj(x) && typeof x.name === 'string')) {
34
+ const out = {};
35
+ for (const x of v.slice(0, MAX_SERVERS)) if (isObj(x) && typeof x.name === 'string') out[x.name] = x;
36
+ return out;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ export function findMcpServerMap(json) {
42
+ if (!isObj(json)) return null;
43
+ let found = asMap(json.mcpServers ?? json.servers ?? json.context_servers ?? json.contextServers ?? json.mcp_servers ?? json.mcp_server ?? json['mcp.servers']);
44
+ if (!found) {
45
+ const ext = json.extensions;
46
+ if (isObj(ext) && Object.values(ext).some((v) => isObj(v) && (v.cmd || v.command || v.uri || v.url || v.type))) found = ext;
47
+ }
48
+ if (!found) {
49
+ for (const key of ['mcp', 'mcpConfig', 'modelContextProtocol', 'mcp_config']) {
50
+ const nested = json[key];
51
+ if (!isObj(nested)) continue;
52
+ const inner = asMap(nested.servers ?? nested.mcpServers ?? nested.context_servers ?? nested.mcp_servers);
53
+ if (inner) { found = inner; break; }
54
+ if (Object.values(nested).some(looksLikeServer)) { found = nested; break; }
55
+ }
56
+ }
57
+ if (isObj(json.projects)) {
58
+ const merged = { ...(found ?? {}) };
59
+ let added = 0;
60
+ for (const [projectPath, proj] of Object.entries(json.projects).slice(0, MAX_SERVERS)) {
61
+ const m = isObj(proj) ? asMap(proj.mcpServers) : null;
62
+ if (!m) continue;
63
+ for (const [name, entry] of Object.entries(m)) {
64
+ if (!isObj(entry)) continue;
65
+ const base = projectPath.replace(/\\/g, '/').split('/').filter(Boolean).pop() || projectPath;
66
+ merged[name in merged ? `${name} (${base})` : name] = { ...entry, __scope: projectPath };
67
+ added++;
68
+ }
69
+ }
70
+ if (added) found = merged;
71
+ }
72
+ return found && Object.keys(found).length ? found : null;
73
+ }
74
+
75
+ export function normalizeServerEntry(name, e) {
76
+ const entry = isObj(e) ? e : {};
77
+ let command = null;
78
+ let args = strList(entry.args);
79
+ let env = null;
80
+ const rawCmd = entry.command ?? entry.cmd ?? entry.executable ?? null;
81
+ if (typeof rawCmd === 'string') command = rawCmd;
82
+ else if (Array.isArray(rawCmd)) {
83
+ const argv = strList(rawCmd);
84
+ command = argv[0] ?? null;
85
+ args = [...argv.slice(1), ...args].slice(0, MAX_ARGS);
86
+ } else if (isObj(rawCmd)) {
87
+ command = str(rawCmd.path) ?? str(rawCmd.command);
88
+ args = [...strList(rawCmd.args), ...args].slice(0, MAX_ARGS);
89
+ if (isObj(rawCmd.env)) env = rawCmd.env;
90
+ }
91
+ for (const k of ['env', 'envs', 'environment']) if (isObj(entry[k])) env = { ...(env ?? {}), ...entry[k] };
92
+ let headers = null;
93
+ for (const k of ['headers', 'http_headers', 'httpHeaders']) if (isObj(entry[k])) headers = { ...(headers ?? {}), ...entry[k] };
94
+ const url = str(entry.url) ?? str(entry.httpUrl) ?? str(entry.serverUrl) ?? str(entry.endpoint) ?? str(entry.uri);
95
+
96
+ const approve = [];
97
+ for (const k of ['alwaysAllow', 'autoApprove', 'autoApproveTools', 'auto_approve']) {
98
+ const v = entry[k];
99
+ if (v === true) approve.push('*');
100
+ else approve.push(...strList(v));
101
+ }
102
+ const AUTO_MODE = /^(?:auto|approve|always|never[-_]?ask)$/i;
103
+ if (typeof entry.default_tools_approval_mode === 'string' && AUTO_MODE.test(entry.default_tools_approval_mode)) approve.push('*');
104
+ if (isObj(entry.tools)) {
105
+ for (const [tool, cfg] of Object.entries(entry.tools).slice(0, 100)) if (isObj(cfg) && typeof cfg.approval_mode === 'string' && AUTO_MODE.test(cfg.approval_mode)) approve.push(tool);
106
+ }
107
+ const headersHelper = str(entry.headersHelper);
108
+ return {
109
+ name,
110
+ command,
111
+ args,
112
+ env,
113
+ headers,
114
+ url,
115
+ type: str(entry.type) ?? str(entry.transport) ?? str(entry.transportType),
116
+ cwd: str(entry.cwd) ?? str(entry.working_directory) ?? str(entry.workingDirectory),
117
+ envFile: str(entry.envFile) ?? str(entry.env_file),
118
+ headersHelper,
119
+ autoApprove: approve.length ? [...new Set(approve)].slice(0, 40) : null,
120
+ trusted: entry.trust === true || entry.trusted === true,
121
+ disabled: entry.disabled === true || entry.enabled === false,
122
+ authDeclared: !!(entry.oauth || entry.auth || entry.authProviderType || entry.bearer_token_env_var || entry.bearerTokenEnvVar || isObj(entry.env_http_headers) || headersHelper || entry.authDeclared === true),
123
+ scope: str(entry.__scope) ?? str(entry.scope),
124
+ };
125
+ }
126
+
127
+
128
+ export function extensionBundleServer(json) {
129
+ if (!isObj(json) || !isObj(json.server)) return null;
130
+ const cfg = json.server.mcp_config ?? json.server.mcpConfig;
131
+ const declared = typeof json.dxt_version === 'string' || typeof json.mcpb_version === 'string' || typeof json.manifest_version === 'string';
132
+ if (!isObj(cfg) || (!declared && !json.server.entry_point)) return null;
133
+ return normalizeServerEntry(String(json.name || json.display_name || 'extension'), cfg);
134
+ }
135
+
136
+ export function readMcpServers(json) {
137
+ const map = findMcpServerMap(json);
138
+ if (!map) {
139
+ const ext = extensionBundleServer(json);
140
+ return ext ? [ext] : null;
141
+ }
142
+ return Object.entries(map).slice(0, MAX_SERVERS).map(([name, entry]) => normalizeServerEntry(name, entry));
143
+ }
144
+
145
+ /* ─── launch parsing (launch-spec.ts) ────────────────────────────────────── */
146
+
147
+ const NPM_RUNNERS = new Set(['npx', 'npm', 'pnpm', 'yarn', 'bunx', 'bun']);
148
+ const PY_RUNNERS = new Set(['uvx', 'uv', 'pipx', 'pip', 'pip3', 'python', 'python3', 'poetry', 'pdm', 'hatch']);
149
+ const LAUNCH_SKIP = new Set(['exec', 'dlx', 'run', 'runx', 'install', 'add', 'create', 'tool', '-y', '--yes', '-m', '--from', '--with', '--quiet', '-q']);
150
+ const REGISTRY_FLAGS = /^(?:--registry|--index-url|-i|--extra-index-url|--default-index|--index)$/;
151
+
152
+ function registryOverride(tokens) {
153
+ for (let i = 1; i < tokens.length; i++) {
154
+ const t = tokens[i];
155
+ const eq = t.indexOf('=');
156
+ const flag = eq > 0 ? t.slice(0, eq) : t;
157
+ if (!REGISTRY_FLAGS.test(flag)) continue;
158
+ const url = eq > 0 ? t.slice(eq + 1) : tokens[i + 1] ?? '';
159
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) continue;
160
+ return { flag, url, plaintext: /^http:\/\//i.test(url), extra: flag === '--extra-index-url' || flag === '--index' };
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function nonRegistrySource(spec, tokens, ecosystem) {
166
+ const all = [spec, ...tokens];
167
+ const shorthand = ecosystem === 'npm' && /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.-]+(?:#.*)?$/.test(spec) && !/\.(?:[cm]?[jt]s|py|json|sh)$/i.test(spec);
168
+ const hit = all.find((t) => /^(?:git\+|git:\/\/|github:|gitlab:|bitbucket:|gist:)/i.test(t)) ?? (shorthand ? spec : null);
169
+ if (hit) return { kind: 'git', ref: hit, commitPinned: /[#@][0-9a-f]{40}\b/i.test(hit) };
170
+ const url = all.find((t) => /^https?:\/\/\S+\.(?:tgz|tar\.gz|tar|zip|whl)(?:[?#]\S*)?$/i.test(t));
171
+ if (url) return { kind: 'url', ref: url, commitPinned: false };
172
+ const file = all.find((t) => /^file:/i.test(t));
173
+ if (file) return { kind: 'file', ref: file, commitPinned: false };
174
+ return null;
175
+ }
176
+
177
+ /** `C:\\Program Files\\nodejs\\npx.cmd` is npx - strip the Windows launcher extension (launch-spec.ts programName) */
178
+ export function programName(command) {
179
+ const base = String(command ?? '').trim().split(/[\\/]/).pop() ?? '';
180
+ return base.toLowerCase().replace(/\.(?:exe|cmd|bat|ps1)$/, '');
181
+ }
182
+
183
+ export function parseLaunch(command, args) {
184
+ const tokens = [command, ...(args ?? [])].filter(Boolean).map(String);
185
+ const none = { ecosystem: null, runner: null, pkg: null, spec: null, version: null, autoInstall: false, unpinned: false, source: null, registry: null };
186
+ if (!tokens.length) return none;
187
+ const head = programName(tokens[0]);
188
+ const ecosystem = NPM_RUNNERS.has(head) ? 'npm' : PY_RUNNERS.has(head) ? 'python' : null;
189
+ if (!ecosystem) return none;
190
+ const autoInstall = tokens.some((t) => /^--?(y|yes)$/i.test(t)) || tokens.includes('dlx') || (ecosystem === 'python' && (head === 'uvx' || head === 'pipx' || tokens.includes('--from')));
191
+ const registry = registryOverride(tokens);
192
+ let spec = null;
193
+ for (let i = 1; i < tokens.length && ecosystem === 'npm'; i++) {
194
+ const t = tokens[i];
195
+ if (t.startsWith('--package=')) { spec = t.slice('--package='.length); break; }
196
+ if ((t === '--package' || t === '-p') && tokens[i + 1] && !tokens[i + 1].startsWith('-')) { spec = tokens[i + 1]; break; }
197
+ }
198
+ for (let i = 1; i < tokens.length && !spec; i++) {
199
+ const t = tokens[i];
200
+ if (t.startsWith('-') || LAUNCH_SKIP.has(t)) continue;
201
+ if (registry && t === registry.url) continue;
202
+ spec = t;
203
+ break;
204
+ }
205
+ if (!spec) return { ...none, ecosystem, runner: head, autoInstall, registry };
206
+ const source = nonRegistrySource(spec, tokens.slice(1), ecosystem);
207
+ if (source) return { ecosystem, runner: head, pkg: spec, spec, version: null, autoInstall, unpinned: !source.commitPinned, source, registry };
208
+ let pkg;
209
+ let version;
210
+ if (ecosystem === 'npm') {
211
+ const at = spec.lastIndexOf('@');
212
+ version = at > 0 ? spec.slice(at + 1) : null;
213
+ pkg = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/').replace(/@[\d^~].*$/, '') : spec.split('@')[0];
214
+ } else {
215
+ const m = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?\s*(?:([=<>!~]+)\s*(.+))?$/.exec(spec);
216
+ pkg = m ? m[1] : spec;
217
+ version = m && m[2] === '==' ? (m[3] ?? null) : null;
218
+ }
219
+ const unpinned = !version || /^(latest|next|canary|\*|x|main|master)$/i.test(version) || !/^[~^]?\d/.test(version);
220
+ return { ecosystem, runner: head, pkg: pkg || null, spec, version, autoInstall, unpinned, source: null, registry };
221
+ }
222
+
223
+ const SHELLS = /^(?:sh|bash|zsh|dash|ksh|fish|cmd|powershell|pwsh)(?:\.exe)?$/i;
224
+ const SCRIPT_META = /[;&|`<>\n]|\$\(|\$\{?[A-Za-z_]|%[A-Za-z_]\w*%/;
225
+ const splitWords = (s) => [...s.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
226
+
227
+ export function unwrapShell(command, args) {
228
+ const raw = String(command ?? '').trim();
229
+ if (!raw) return null;
230
+ let toks = (args ?? []).map(String);
231
+ let head = raw;
232
+ if (/\s/.test(raw) && !/^[a-z]:\\[^"]*\\[^\\]*$/i.test(raw)) {
233
+ const words = splitWords(raw);
234
+ head = words[0] ?? raw;
235
+ toks = [...words.slice(1), ...toks];
236
+ }
237
+ const shell = (head.split(/[\\/]/).pop() ?? head).toLowerCase();
238
+ if (!SHELLS.test(shell)) return null;
239
+ const isCmd = /^cmd(\.exe)?$/.test(shell);
240
+ const isPwsh = /^(powershell|pwsh)(\.exe)?$/.test(shell);
241
+ for (let i = 0; i < toks.length; i++) {
242
+ const t = toks[i];
243
+ const inline = isCmd ? /^\/[ck]$/i.test(t) : isPwsh ? /^-(?:c|command)$/i.test(t) : /^-[a-z]*c[a-z]*$/i.test(t);
244
+ const encoded = isPwsh && /^-(?:e|ec|en|enc|enco|encod|encode|encoded|encodedcommand)$/i.test(t);
245
+ if (!inline && !encoded) continue;
246
+ const rest = toks.slice(i + 1);
247
+ const script = (isCmd || isPwsh ? rest.join(' ') : rest[0] ?? '').trim();
248
+ const compound = !encoded && SCRIPT_META.test(script);
249
+ const words = !encoded && !compound ? splitWords(script) : [];
250
+ return { shell, flag: t, script: script.slice(0, 400), encoded, compound, inner: words.length ? { command: words[0], args: words.slice(1) } : null };
251
+ }
252
+ return null;
253
+ }
254
+
255
+ export function launchPull(command, args) {
256
+ const wrap = unwrapShell(command, args);
257
+ const launch = wrap?.inner ? parseLaunch(wrap.inner.command, wrap.inner.args) : parseLaunch(command, (args ?? []).map(String));
258
+ if (!launch.pkg || launch.source || !launch.ecosystem) return null;
259
+ const version = !launch.unpinned && launch.version ? launch.version.replace(/^[~^=v]+/, '') : null;
260
+ return { ecosystem: launch.ecosystem === 'python' ? 'pypi' : 'npm', name: launch.pkg, version };
261
+ }
262
+
263
+ const EVAL_FLAGS = [
264
+ [/^(?:node|nodejs|bun)(?:\.exe)?$/i, /^(?:-e|--eval|-p|--print)$/],
265
+ [/^deno(?:\.exe)?$/i, /^eval$/],
266
+ [/^(?:python[\d.]*|py)(?:\.exe)?$/i, /^-c$/],
267
+ [/^(?:ruby|perl)(?:\.exe)?$/i, /^-[eE]$/],
268
+ [/^php(?:\.exe)?$/i, /^-r$/],
269
+ [/^osascript$/i, /^-e$/],
270
+ ];
271
+
272
+ function inlineEval(command, args) {
273
+ const interpreter = programName(command);
274
+ const pair = EVAL_FLAGS.find(([re]) => re.test(interpreter));
275
+ if (!pair) return null;
276
+ const toks = (args ?? []).map(String);
277
+ const i = toks.findIndex((t) => pair[1].test(t));
278
+ return i === -1 ? null : { interpreter, flag: toks[i] };
279
+ }
280
+
281
+ export function classifyScope(rawArg) {
282
+ const raw = String(rawArg ?? '').trim().replace(/^["']|["']$/g, '');
283
+ if (!raw) return null;
284
+ const slashed = raw.replace(/\\/g, '/').replace(/^(?:\$\{?HOME\}?|%USERPROFILE%|\$\{?USERPROFILE\}?|\$env:USERPROFILE)(?=\/|$)/i, '~');
285
+ if (!(/^([a-zA-Z]:)?\//.test(slashed) || /^~/.test(slashed))) return null;
286
+ const p = (slashed.replace(/^[a-zA-Z]:/, '') || '/').replace(/(.)\/{1,256}$/, '$1');
287
+ const norm = p.toLowerCase();
288
+ if (norm === '' || norm === '/' || /^\/(mnt|media)$/.test(norm)) return { raw, label: 'the filesystem root', severity: 'CRITICAL' };
289
+ if (/^~$/.test(p) || /^\/(users|home)\/[^/]+$/.test(norm) || /^\/root$/.test(norm)) return { raw, label: 'a user home directory', severity: 'HIGH' };
290
+ if (/^\/(etc|var|usr|bin|sbin|boot|sys|proc|windows|program files.*)$/.test(norm)) return { raw, label: `a system directory (${raw})`, severity: 'HIGH' };
291
+ if (/(^|\/)\.(ssh|aws|gnupg|kube|docker|config|azure|gcloud)$/.test(norm)) return { raw, label: `a credential directory (${raw})`, severity: 'CRITICAL' };
292
+ return null;
293
+ }
294
+
295
+ const MOUNTABLE_MCP_PACKAGES = /(server-)?filesystem|mcp-fs|@modelcontextprotocol\/server-filesystem|mcp-server-file/i;
296
+ const SCOPE_FLAG_RE = /^--?(?:repository|repo|root|roots|allowed[-_]?(?:dirs?|directories|paths?|roots?)|dirs?|directory|workspace(?:[-_]?(?:root|dir))?|base[-_]?(?:dir|path)|path|project[-_]?(?:root|dir)|mount|sandbox[-_]?root)$/i;
297
+ const SCOPE_ENV_RE = /(?:ALLOWED|ROOT|WORKSPACE|MOUNT|SANDBOX|BASE|PROJECT)[_-]?(?:DIRS?|DIRECTOR(?:Y|IES)|PATHS?|ROOTS?|FOLDERS?)?$/i;
298
+ const SCOPE_ENV_NOUN = /DIR|PATH|ROOT|FOLDER|WORKSPACE|MOUNT/i;
299
+
300
+ /* ─── known-vulnerable.ts ────────────────────────────────────────────────── */
301
+
302
+ export const KNOWN_VULNERABLE_MCP = [
303
+ { ecosystem: 'npm', pkg: 'mcp-remote', ranges: [{ from: '0.0.5', fixed: '0.1.16' }], cve: 'CVE-2025-6514', severity: 'CRITICAL' },
304
+ { ecosystem: 'npm', pkg: '@modelcontextprotocol/inspector', ranges: [{ fixed: '0.14.1' }], cve: 'CVE-2025-49596', severity: 'CRITICAL' },
305
+ { ecosystem: 'npm', pkg: '@modelcontextprotocol/server-filesystem', ranges: [{ fixed: '0.6.3' }, { from: '2025.0.0', fixed: '2025.7.1' }], cve: 'CVE-2025-53109 / CVE-2025-53110', severity: 'HIGH' },
306
+ { ecosystem: 'python', pkg: 'mcp-server-git', ranges: [{ fixed: '2025.12.18' }], cve: 'CVE-2025-68143 / CVE-2025-68144 / CVE-2025-68145', severity: 'HIGH' },
307
+ ];
308
+ const vparts = (v) => v.replace(/^[v=~^]+/, '').split(/[.+-]/).map((p) => (/^\d+$/.test(p) ? Number(p) : NaN)).filter((n) => !Number.isNaN(n));
309
+ function versionLt(a, b) {
310
+ const x = vparts(a), y = vparts(b);
311
+ for (let i = 0; i < Math.max(x.length, y.length); i++) { const d = (x[i] ?? 0) - (y[i] ?? 0); if (d) return d < 0; }
312
+ return false;
313
+ }
314
+ export function knownVulnerable(ecosystem, pkg, version) {
315
+ if (!ecosystem || !pkg || !version || !/^[v=]?\d/.test(version)) return null;
316
+ return KNOWN_VULNERABLE_MCP.find((k) => k.ecosystem === ecosystem && k.pkg === pkg.toLowerCase() &&
317
+ k.ranges.some((r) => (!r.from || !versionLt(version, r.from)) && versionLt(version, r.fixed))) ?? null;
318
+ }
319
+
320
+ /* ─── the rules (launch-surface.ts) ──────────────────────────────────────── */
321
+
322
+ const REFERENCE_RE = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\{\{[^}]*\}\}|%[A-Za-z_][A-Za-z0-9_]*%|^<[^>]+>$|^(?:op|vault|secret|keychain|azurekeyvault|gcpsm|aws-sm):\/\//i;
323
+ const DEFAULT_IN_REF_RE = /\$\{[A-Za-z_]\w*:-([^}]+)\}/;
324
+ const SECRET_KEY_RE = /\b(\w*(?:secret|token|passw(?:or)?d|api[_-]?key|apikey|access[_-]?key|client[_-]?secret|auth[_-]?token|private[_-]?key|credential|session[_-]?key|encryption[_-]?key|signing[_-]?key)\w*)\b/i;
325
+ const HEADER_CRED_KEY_RE = /^(?:authorization|proxy-authorization|x-api-key|api-key|x-auth-token|x-access-token|cookie|x-goog-api-key)$/i;
326
+ const NOT_A_CREDENTIAL_KEY_RE = /(?:_|-|^)(?:URL|URI|ENDPOINT|HOST|PATH|FILE|DIR|NAME|TYPE|MODE|TTL|EXPIRY|EXPIRES|EXPIRATION|LENGTH|HEADER|ENV[_-]?VAR|VAR|ID|REGION|SCOPE|SCOPES|LIMIT|COUNT)$/i;
327
+ const PASSWORD_KEY_RE = /passw(?:or)?d|passwd|pwd|passphrase/i;
328
+ const URL_CRED_PARAM_RE = /^(?:api[-_]?key|apikey|key|token|access[-_]?token|auth[-_]?token|auth|secret|client[-_]?secret|password|pwd|sig|signature|session|x-api-key)$/i;
329
+
330
+ function isPlaceholderValue(v) {
331
+ const low = v.toLowerCase();
332
+ if (/(your|my|the|some|placeholder|example|sample|dummy|test|fake|changeme|redacted|x{3,64}|\.\.\.|todo|replace|insert|here|value|token|secret|key)$/i.test(low)) return true;
333
+ if (/^[x*.\-_0]+$/i.test(v) || /(.)\1{7,}/.test(v) || /^(?:abc|123|test|foo|bar|qwerty)/i.test(low)) return true;
334
+ return /token[-_ ]?here|xxx+|0000+|1234567890/i.test(v);
335
+ }
336
+ function entropy(s) {
337
+ const f = new Map();
338
+ for (const c of s) f.set(c, (f.get(c) ?? 0) + 1);
339
+ let bits = 0;
340
+ for (const n of f.values()) { const p = n / s.length; bits -= p * Math.log2(p); }
341
+ return bits;
342
+ }
343
+
344
+ const CRED_FLAG_RE = /^--?(?:api[-_]?key|apikey|token|access[-_]?token|auth[-_]?token|bearer[-_]?token|secret|client[-_]?secret|password|passwd|pat|github[-_]?token)$/i;
345
+
346
+ /** `--api-key <v>`, `docker -e KEY=<v>`, `--header "Authorization: Bearer <v>"` (launch-surface.ts argCredentials) */
347
+ export function argCredentials(args) {
348
+ const out = {};
349
+ const toks = (args ?? []).map(String);
350
+ for (let i = 0; i < toks.length; i++) {
351
+ const t = toks[i];
352
+ const eq = t.indexOf('=');
353
+ const flag = t.startsWith('-') && eq > 0 ? t.slice(0, eq) : t;
354
+ const next = eq > 0 && t.startsWith('-') ? t.slice(eq + 1) : toks[i + 1] ?? '';
355
+ if (CRED_FLAG_RE.test(flag)) out[`arg ${flag}`] = next;
356
+ else if ((flag === '-e' || flag === '--env') && /^[A-Za-z_][\w]*=/.test(next)) out[next.slice(0, next.indexOf('='))] = next.slice(next.indexOf('=') + 1);
357
+ else if (flag === '--header' || flag === '-H') { const m = /^\s*([\w-]+)\s*:\s*(.+)$/.exec(next); if (m) out[m[1]] = m[2]; }
358
+ }
359
+ return out;
360
+ }
361
+
362
+ function literalCredential(key, raw, header) {
363
+ if (typeof raw !== 'string') return null;
364
+ let v = raw.trim();
365
+ const dflt = DEFAULT_IN_REF_RE.exec(v);
366
+ if (dflt) v = dflt[1].trim();
367
+ else if (REFERENCE_RE.test(v)) return null;
368
+ v = v.replace(/^(?:bearer|basic|token|apikey)\s+/i, '');
369
+ if (v.length < 8 || /^(?:true|false|yes|no|on|off|null|none|\d+)$/i.test(v)) return null;
370
+ if (/^(?:[a-z]+:\/\/|\/|~|\.{1,2}\/|[a-z]:\\)/i.test(v) || /^[A-Z][A-Z0-9_]+$/.test(v)) return null;
371
+ const credKey = header ? HEADER_CRED_KEY_RE.test(key) || SECRET_KEY_RE.test(key) : SECRET_KEY_RE.test(key);
372
+ if (!credKey || NOT_A_CREDENTIAL_KEY_RE.test(key) || isPlaceholderSecret(v) || /token[-_ ]?here/i.test(v)) return null;
373
+ if (header && HEADER_CRED_KEY_RE.test(key)) return v;
374
+ if (isPlaceholderValue(v)) return null;
375
+ if (PASSWORD_KEY_RE.test(key)) return v;
376
+ if (v.length < 16) return null;
377
+ return entropy(v) >= (/^[0-9a-f]+$/i.test(v) ? 3.0 : 3.3) ? v : null;
378
+ }
379
+
380
+ function urlCredential(raw) {
381
+ let u;
382
+ try { u = new URL(String(raw)); } catch { return null; }
383
+ if (u.password && !REFERENCE_RE.test(decodeURIComponent(u.password)) && !isPlaceholderValue(u.password)) return 'the URL userinfo';
384
+ for (const [k, v] of u.searchParams) if (URL_CRED_PARAM_RE.test(k) && v.length >= 8 && !REFERENCE_RE.test(v) && !isPlaceholderValue(v)) return `the "${k}" query parameter`;
385
+ return null;
386
+ }
387
+
388
+ const DROP_DIR_RE =
389
+ /^(?:\/tmp\/|\/var\/tmp\/|\/dev\/shm\/|\/private\/tmp\/|~\/(?:\.cache|Downloads)\/|\$\{?TMPDIR\}?\/|%TEMP%|%TMP%|\$env:TE?MP\/|[a-z]:\/users\/[^/]+\/(?:appdata\/local\/temp|downloads)\/|[a-z]:\/windows\/temp\/|%LOCALAPPDATA%\/temp\/|%USERPROFILE%\/downloads\/)/i;
390
+ const isDropPath = (v) => DROP_DIR_RE.test(String(v ?? '').replace(/\\/g, '/'));
391
+ const BRIDGE_PKG_RE = /^(?:mcp-remote|mcp-proxy|supergateway|@modelcontextprotocol\/proxy)$/i;
392
+ const INDIRECT_LAUNCHERS = /^(?:npx|npm|pnpm|yarn|bunx|uvx|uv|pipx|docker|podman|nerdctl)$/i;
393
+ const INTERPRETERS = /^(?:node|deno|bun|python[\d.]*|ruby|perl|php|sh|bash|zsh|dash|osascript|pwsh|powershell)$/i;
394
+ const RELATIVE_SCRIPT_RE = /^(?:\.{1,2}[\\/])?[\w.-]+(?:[\\/][\w.-]+)*\.(?:[cm]?js|ts|py|sh|rb|pl|php|ps1|bat|cmd|exe)$/i;
395
+ const WILDCARD_TOOL_RE = /^\s*(?:\*+|all|any|\.\*)\s*$/i;
396
+ const EXEC_TOOL_RE = /(?:^|[_\-.])(?:exec|execute|run|shell|bash|zsh|powershell|terminal|command|cmd|eval|script|spawn|process)(?:[_\-.]|$)|^(?:bash|shell|terminal|exec)$/i;
397
+ const WRITE_TOOL_RE = /(?:write|edit|create|delete|remove|rm|move|rename|push|commit|merge|deploy|send|post|publish|upload|drop|update|insert|kill|transfer|pay|apply|patch)/i;
398
+ const LOADER_ENV = /^(?:NODE_OPTIONS|LD_PRELOAD|DYLD_INSERT_LIBRARIES|PYTHONSTARTUP|BASH_ENV|PERL5OPT|RUBYOPT)$/;
399
+
400
+
401
+ export function gradeServer(s) {
402
+ const out = [];
403
+ const push = (severity, title, remediationText, anchor) => out.push({ severity, title, remediationText, anchor: anchor ?? s.name });
404
+ const wrap = unwrapShell(s.command, s.args);
405
+ const eff = wrap?.inner ? { ...s, command: wrap.inner.command, args: wrap.inner.args } : s;
406
+ const cmdLine = [s.command, ...(s.args ?? [])].filter(Boolean).join(' ');
407
+
408
+ // transport
409
+ const u = assessUrlWs(s.url);
410
+ if (u) {
411
+ if (u.suspiciousHost) push('HIGH', `MCP server "${s.name}" is hosted on a data-exfiltration endpoint`, 'Remove this server and treat any credential in its config as compromised.', u.url);
412
+ if (u.metadataEndpoint) push('CRITICAL', `MCP server "${s.name}" targets the cloud metadata endpoint`, 'Remove this server immediately and rotate the instance role credentials.', u.url);
413
+ else if (u.privateNetwork) push('LOW', `MCP server "${s.name}" points at a private-network address`, 'Keep local-only servers out of committed configuration.', u.url);
414
+ if (u.plaintext && !u.privateNetwork) push('MEDIUM', u.websocket ? `MCP server "${s.name}" uses an unencrypted WebSocket (ws://)` : `MCP server "${s.name}" uses plaintext HTTP`, 'Use an https:// (wss://) endpoint and require an authenticated token.', u.url);
415
+ if (u.rawIp && !u.privateNetwork) push('LOW', `MCP server "${s.name}" is addressed by raw IP`, 'Use a DNS hostname with a valid TLS certificate.', u.url);
416
+ if (!u.privateNetwork) {
417
+ const keys = Object.keys({ ...(s.headers ?? {}), ...(s.env ?? {}) });
418
+ const authish = keys.some((k) => /(^|[_-])(authorization|auth|token|key|secret|bearer|api[_-]?key|credential|password|cookie)/i.test(k));
419
+ if (!authish && !s.authDeclared && !urlCredential(u.url)) push('MEDIUM', `Remote MCP server "${s.name}" declares no authentication`, 'Require an authenticated token and pass it through an environment reference.', u.url);
420
+ }
421
+ }
422
+
423
+ if (!s.url) {
424
+ const bridged = parseLaunch(eff.command, eff.args ?? []);
425
+ const target = bridged.pkg && BRIDGE_PKG_RE.test(bridged.pkg) ? (eff.args ?? []).find((a) => /^(?:https?|wss?):\/\//i.test(String(a))) : null;
426
+ if (target) for (const f of gradeServer({ name: s.name, url: target, headers: { ...(s.headers ?? {}), ...argCredentials(eff.args) } })) out.push(f);
427
+ }
428
+
429
+ // shells & inline code
430
+ if (wrap) {
431
+ const isCmd = /^cmd(\.exe)?$/.test(wrap.shell);
432
+ if (wrap.encoded) push('HIGH', `MCP server "${s.name}" launches an encoded PowerShell command`, 'Replace the encoded command with the plain command it stands for.', wrap.flag);
433
+ else if (/\$\{user_config\.[\w.-]+\}/.test(wrap.script)) push('HIGH', `MCP server "${s.name}" substitutes user configuration into a shell command line`, 'Pass the setting as its own argv entry or an environment variable the program reads - never inside a shell string.', wrap.flag);
434
+ else if (wrap.compound) push('MEDIUM', `MCP server "${s.name}" is launched as a shell script`, 'Launch the server binary directly with its arguments as a list.', wrap.flag);
435
+ else if (!isCmd && wrap.inner) push('LOW', `MCP server "${s.name}" is launched through ${wrap.shell}`, `Set "command" to "${wrap.inner.command}" and "args" to its arguments directly.`, wrap.flag);
436
+ }
437
+ const ev = inlineEval(s.command, s.args);
438
+ if (ev) push('MEDIUM', `MCP server "${s.name}" runs inline code from its config`, 'Move the code into a checked-in file (or a pinned package) and launch that instead.', ev.flag);
439
+
440
+ // tools the client runs without asking
441
+ if (!s.disabled) {
442
+ if (s.trusted) push('HIGH', `MCP server "${s.name}" is trusted - none of its tool calls ask first`, 'Remove "trust": true and allow-list the read-only tools that are safe to run unprompted.', /"?trust"?\s*[:=]\s*true/);
443
+ const tools = s.autoApprove ?? [];
444
+ const wildcard = tools.some((t) => WILDCARD_TOOL_RE.test(t));
445
+ const exec = tools.filter((t) => EXEC_TOOL_RE.test(t));
446
+ const write = tools.filter((t) => !EXEC_TOOL_RE.test(t) && WRITE_TOOL_RE.test(t));
447
+ if (wildcard || exec.length || write.length) {
448
+ push(wildcard || exec.length ? 'HIGH' : 'MEDIUM',
449
+ `MCP server "${s.name}" auto-approves ${wildcard ? 'every tool' : exec.length ? 'command-running tools' : 'tools that change things'}`,
450
+ 'Keep only read-only tools on the auto-approve list; let the client ask for anything that writes, sends, deletes or runs a command.',
451
+ wildcard ? tools.find((t) => WILDCARD_TOOL_RE.test(t)) : exec[0] ?? write[0]);
452
+ }
453
+ }
454
+
455
+ // containers
456
+ const cbase = programName(eff.command);
457
+ if (['docker', 'podman', 'nerdctl'].includes(cbase)) {
458
+ const joined = (eff.args ?? []).join(' ');
459
+ const escapes = [];
460
+ if (/(^|\s)--privileged(\s|=|$)/.test(joined)) escapes.push('--privileged');
461
+ if (/docker\.sock/i.test(joined)) escapes.push('docker.sock');
462
+ if (/(?:-v|--volume|--mount)[\s=](?:type=bind,)?(?:source=|src=)?(\/|~|\/etc|\/root|\/home|\$HOME)(?::|,|\s|$)/.test(joined)) escapes.push('host mount');
463
+ if (/--(?:network|net|pid|ipc|uts)[\s=]host\b/.test(joined)) escapes.push('host namespace');
464
+ if (escapes.length) push(escapes.some((e) => e !== 'host namespace') ? 'HIGH' : 'MEDIUM', `MCP server "${s.name}" runs a container with host-level access`, 'Drop --privileged and host mounts; never expose the Docker socket.', escapes[0] === 'docker.sock' ? 'docker.sock' : escapes[0]);
465
+ const image = (eff.args ?? []).find((t, i, a) => !t.startsWith('-') && !['run', 'exec', 'create', 'start'].includes(t) && !/^-/.test(a[i - 1] ?? '') );
466
+ if (image && !/@sha256:/.test(image)) {
467
+ const tag = image.includes(':') ? image.slice(image.lastIndexOf(':') + 1) : null;
468
+ if (!tag || /^(latest|main|master|dev|edge|nightly)$/i.test(tag)) push('LOW', `MCP server "${s.name}" runs an unpinned container image`, 'Pin the image by digest (image@sha256:…).', image);
469
+ }
470
+ }
471
+
472
+ // credentials
473
+ const envBlob = JSON.stringify(s.env ?? {}) + '\n' + JSON.stringify(s.headers ?? {}) + '\n' + String(s.url ?? '');
474
+ let named = false;
475
+ for (const { re } of SECRET_PATTERNS) {
476
+ const m = re.exec(envBlob) ?? re.exec(cmdLine);
477
+ if (!m || isPlaceholderSecret(m[0])) continue;
478
+ push('CRITICAL', `Static credential in MCP server "${s.name}"`, 'Rotate the credential and pass it via an environment reference resolved at runtime.', re);
479
+ named = true;
480
+ break;
481
+ }
482
+ const lits = [];
483
+ for (const [where, block] of [['env', s.env], ['headers', s.headers], ['args', argCredentials(s.args)]]) {
484
+ for (const [k, v] of Object.entries(block ?? {})) {
485
+ const lit = literalCredential(k, v, where !== 'env');
486
+ if (lit && !SECRET_PATTERNS.some(({ re }) => re.test(lit))) lits.push(k);
487
+ }
488
+ }
489
+ if (lits.length) push('HIGH', `MCP server "${s.name}" stores a credential as a literal in ${lits.length === 1 ? `"${lits[0]}"` : `${lits.length} fields`}`, 'Rotate the value and reference it ("${VAR}", "${env:VAR}", a VS Code password input, Codex bearer_token_env_var) instead of storing it.', lits[0]);
490
+ for (const raw of [s.url, ...(s.args ?? [])].filter((x) => typeof x === 'string' && /^[a-z][a-z0-9+.-]*:\/\//i.test(x))) {
491
+ if (named || SECRET_PATTERNS.some(({ re }) => re.test(raw)) || !urlCredential(raw)) continue;
492
+ push('HIGH', `MCP server "${s.name}" carries a credential in its URL`, 'Move the credential into an Authorization header resolved from an environment reference, and rotate it.', raw.slice(0, 60));
493
+ break;
494
+ }
495
+ for (const [k, v] of Object.entries(s.env ?? {})) {
496
+ if (LOADER_ENV.test(k) && /(--require|-r\s|--import|--loader|\.so\b|\.dylib\b|[/\\])/.test(String(v))) {
497
+ push(splitWords(String(v)).some((w) => isDropPath(w)) ? 'CRITICAL' : 'HIGH', `MCP server "${s.name}" is launched with an execution hook in its environment (${k})`, `Remove ${k} from the server's env block.`, k);
498
+ }
499
+ }
500
+
501
+ // droppers
502
+ if (s.envFile && isDropPath(s.envFile)) push('HIGH', `MCP server "${s.name}" loads its environment from a world-writable file`, 'Keep the env file next to the config, readable only by its owner.', s.envFile);
503
+ const helper = String(s.headersHelper ?? '').trim();
504
+ const helperPath = helper ? splitWords(helper).find((v) => isDropPath(v)) : null;
505
+ if (helperPath) push('CRITICAL', `MCP server "${s.name}" runs a file from a world-writable directory`, 'Move the helper into the repository or a package the org controls.', helperPath);
506
+ const command = String(eff.command ?? '').trim();
507
+ if (command && !INDIRECT_LAUNCHERS.test(programName(command))) {
508
+ const base = programName(command);
509
+ const argv = (eff.args ?? []).filter((v) => !v.startsWith('-'));
510
+ const candidates = INTERPRETERS.test(base) ? argv : [command, ...argv];
511
+ let dropped = candidates.find((v) => isDropPath(v));
512
+ const cwd = String(s.cwd ?? '').trim();
513
+ if (!dropped && cwd && isDropPath(/[\\/]$/.test(cwd) ? cwd : cwd + '/')) {
514
+ const rel = candidates.find((v) => RELATIVE_SCRIPT_RE.test(v));
515
+ if (rel) {
516
+ let end = cwd.length;
517
+ while (end > 0 && (cwd[end - 1] === '/' || cwd[end - 1] === '\\')) end--;
518
+ dropped = `${cwd.slice(0, end)}/${rel.replace(/^\.[\\/]/, '')}`;
519
+ }
520
+ }
521
+ if (dropped && !helperPath) push('CRITICAL', `MCP server "${s.name}" runs a file from a world-writable directory`, 'Move the server into the repository or a package the org controls, and pin it.', dropped);
522
+ }
523
+
524
+ // what runs
525
+ const launch = parseLaunch(eff.command, eff.args ?? []);
526
+ const pkg = launch.pkg;
527
+ const mounted = new Set();
528
+ if (pkg) {
529
+ const vuln = knownVulnerable(launch.ecosystem, pkg, launch.version);
530
+ if (vuln) push(vuln.severity, `MCP server "${s.name}" is pinned to a vulnerable release of ${pkg} (${vuln.cve})`, `Upgrade "${pkg}" to ${vuln.ranges[vuln.ranges.length - 1].fixed} or later and pin that release.`, launch.spec);
531
+ const r = launch.registry;
532
+ if (r?.plaintext) push('HIGH', `MCP server "${s.name}" installs from a registry over plaintext HTTP`, 'Use an https:// registry.', r.url);
533
+ else if (r?.extra) push('MEDIUM', `MCP server "${s.name}" resolves packages from an extra index (dependency confusion)`, 'Use a single index that proxies the public one, or pin an exact version and hash.', r.url);
534
+ if (launch.source) {
535
+ const src = launch.source;
536
+ push(src.commitPinned ? 'LOW' : 'MEDIUM',
537
+ src.commitPinned ? `MCP server "${s.name}" runs code from a git commit, not a registry` : `MCP server "${s.name}" runs code straight from ${src.kind === 'git' ? 'a git branch' : src.kind === 'url' ? 'a download URL' : 'a local path'}`,
538
+ 'Pin to a full commit SHA or, better, a published registry release pinned to an exact version.', src.ref);
539
+ } else {
540
+ if (MALICIOUS_PACKAGE_SEED.has(pkg)) push('CRITICAL', `MCP server "${s.name}" runs a known-malicious package`, 'Remove this server and audit for compromise.', pkg);
541
+ else {
542
+ const squat = POPULAR_PACKAGES.find((p) => p !== pkg && editDistance(pkg, p) === 1);
543
+ if (squat) push('MEDIUM', `Possible typosquat in "${s.name}": ${pkg}`, `Confirm the intended package is "${squat}", not "${pkg}", and pin it.`, pkg);
544
+ }
545
+ if (launch.unpinned || launch.autoInstall) {
546
+ push('LOW', launch.unpinned ? (launch.autoInstall ? `MCP server "${s.name}" auto-installs an unpinned package` : `MCP server "${s.name}" pulls an unpinned package`) : `MCP server "${s.name}" auto-installs a pinned package at launch`,
547
+ launch.unpinned ? `Pin "${pkg}" to a reviewed version and vet upgrades before adopting them.` : 'Vendor the package or install it ahead of time if the launch-time fetch matters.', pkg);
548
+ }
549
+ if (MOUNTABLE_MCP_PACKAGES.test(pkg)) {
550
+ for (const a of eff.args ?? []) {
551
+ if (!a || a.startsWith('-') || a === pkg) continue;
552
+ const scope = classifyScope(a);
553
+ if (scope) { mounted.add(scope.raw); push(scope.severity, `MCP server "${s.name}" is mounted at ${scope.label}`, 'Mount the narrowest directory the task needs (e.g. the project folder).', scope.raw); }
554
+ }
555
+ }
556
+ }
557
+ }
558
+
559
+ // scope spelled as a flag or env var
560
+ const toks = (eff.args ?? []).map(String);
561
+ const scopes = [];
562
+ for (let i = 0; i < toks.length; i++) {
563
+ const t = toks[i];
564
+ const eq = t.indexOf('=');
565
+ const flag = eq > 0 ? t.slice(0, eq) : t;
566
+ if (!flag.startsWith('-') || !SCOPE_FLAG_RE.test(flag)) continue;
567
+ for (const v of (eq > 0 ? t.slice(eq + 1) : toks[i + 1] ?? '').split(/[;,]/)) { const sc = classifyScope(v); if (sc) scopes.push({ ...sc, via: flag }); }
568
+ }
569
+ for (const [k, v] of Object.entries(s.env ?? {})) {
570
+ if (typeof v !== 'string' || !SCOPE_ENV_RE.test(k) || !SCOPE_ENV_NOUN.test(k)) continue;
571
+ for (const part of v.split(/[;,]|:(?=[/~])/)) { const sc = classifyScope(part); if (sc) scopes.push({ ...sc, via: k }); }
572
+ }
573
+ for (const sc of scopes) {
574
+ if (mounted.has(sc.raw)) continue;
575
+ mounted.add(sc.raw);
576
+ push(sc.severity, `MCP server "${s.name}" is scoped to ${sc.label}`, `Point ${sc.via} at the narrowest directory the task needs, not "${sc.raw}".`, sc.raw);
577
+ }
578
+ return out;
579
+ }
580
+
581
+ function assessUrlWs(raw) {
582
+ const s = String(raw ?? '').trim();
583
+ if (!s) return null;
584
+ const ws = /^wss?:\/\//i.test(s);
585
+ const a = assessUrl(ws ? s.replace(/^ws/i, 'http') : s);
586
+ if (!a) return null;
587
+ return { ...a, url: s, websocket: ws };
588
+ }
589
+
590
+ /** Every finding a config document earns - the local gate's MCP half. */
591
+ export function gradeMcpDocument(content, path = '') {
592
+ const doc = parseConfigDoc(content, path);
593
+ const servers = doc ? readMcpServers(doc) : null;
594
+ if (!servers) return { servers: null, findings: [] };
595
+ const findings = [];
596
+ for (const s of servers) for (const f of gradeServer(s)) findings.push({ ...f, server: s.name });
597
+ return { servers, findings };
598
+ }