@getmcpm/cli
Advanced tools
| #!/usr/bin/env node | ||
| import { | ||
| coloredOutput | ||
| } from "./chunk-E3T224S3.js"; | ||
| import { | ||
| isConfineBackendAvailable, | ||
| isWrapped | ||
| } from "./chunk-WYSMWP2R.js"; | ||
| import { | ||
| sanitizeForTerminal | ||
| } from "./chunk-FEXJHHDM.js"; | ||
| import { | ||
| getAdapter | ||
| } from "./chunk-W4IAFBUN.js"; | ||
| import { | ||
| isSupportedPlatform, | ||
| parsePlaceholder | ||
| } from "./chunk-NPJ3SGGS.js"; | ||
| import { | ||
| CLIENT_IDS, | ||
| getConfigPath | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import { | ||
| detectSecretLabels | ||
| } from "./chunk-U7N6FRYF.js"; | ||
| // src/utils/format-entry.ts | ||
| function formatMcpEntryCommand(entry, fallback = "\u2014") { | ||
| if (entry.url) return entry.url; | ||
| if (entry.command) { | ||
| const args = entry.args?.join(" ") ?? ""; | ||
| return args ? `${entry.command} ${args}` : entry.command; | ||
| } | ||
| return fallback; | ||
| } | ||
| // src/commands/doctor.ts | ||
| import { access } from "fs/promises"; | ||
| // src/config/drift.ts | ||
| async function collectClientStates(deps) { | ||
| const clients = await deps.detectClients(); | ||
| const states = []; | ||
| for (const clientId of clients) { | ||
| try { | ||
| const servers = await deps.getAdapter(clientId).read(deps.getPath(clientId)); | ||
| states.push({ clientId, servers }); | ||
| } catch { | ||
| } | ||
| } | ||
| return states; | ||
| } | ||
| function fieldProjection(entry) { | ||
| return { | ||
| command: entry.command ?? "", | ||
| args: JSON.stringify(entry.args ?? []), | ||
| "env keys": JSON.stringify(Object.keys(entry.env ?? {}).sort()), | ||
| url: entry.url ?? "", | ||
| "header keys": JSON.stringify(Object.keys(entry.headers ?? {}).sort()) | ||
| }; | ||
| } | ||
| var COMPARED_FIELDS = ["command", "args", "env keys", "url", "header keys"]; | ||
| function divergingFields(entries) { | ||
| const projections = entries.map(fieldProjection); | ||
| return COMPARED_FIELDS.filter((field) => { | ||
| const distinct = new Set(projections.map((p) => p[field])); | ||
| return distinct.size > 1; | ||
| }); | ||
| } | ||
| function buildDriftModel(states) { | ||
| const clients = states.map((s) => s.clientId).sort(); | ||
| const byName = /* @__PURE__ */ new Map(); | ||
| for (const { clientId, servers: servers2 } of states) { | ||
| for (const [name, entry] of Object.entries(servers2)) { | ||
| const list = byName.get(name) ?? []; | ||
| list.push({ clientId, entry }); | ||
| byName.set(name, list); | ||
| } | ||
| } | ||
| const servers = []; | ||
| for (const name of [...byName.keys()].sort()) { | ||
| const holders = byName.get(name); | ||
| const present = holders.map((h) => h.clientId).sort(); | ||
| const presentSet = new Set(present); | ||
| const absent = clients.filter((c) => !presentSet.has(c)); | ||
| const fields = holders.length > 1 ? divergingFields(holders.map((h) => h.entry)) : []; | ||
| const conflict = fields.length > 0; | ||
| servers.push({ | ||
| name, | ||
| present, | ||
| absent, | ||
| conflict, | ||
| ...conflict ? { conflictFields: fields } : {} | ||
| }); | ||
| } | ||
| const drifted = servers.filter((s) => s.absent.length > 0 || s.conflict).length; | ||
| return { clients, servers, inSync: servers.length - drifted, drifted }; | ||
| } | ||
| // src/scanner/config-secrets.ts | ||
| var GENERIC_LABEL = "secret-named key holds a plaintext value"; | ||
| var SECRET_KEY_RE = /(?:^|_)(?:PASSWORD|PASSWD|PASSPHRASE|SECRET|TOKEN|PAT|APIKEY|AUTHORIZATION|CREDENTIALS?|(?:API|ACCESS|PRIVATE|SECRET|SESSION|SIGNING|ENCRYPTION)_KEY)(?:_|$)/; | ||
| var NON_SECRET_QUALIFIER_RE = /(?:^|_)(?:URL|URI|ENDPOINT|HOST|PORT|ID|NAME|PATH|FILE|DIR|ENABLED|DISABLED|TYPE|MODE|REGION|TIMEOUT|VERSION|PUBLIC|FORMAT|HEADER|PREFIX|SUFFIX|COUNT|SIZE|TTL|EXPIRY|EXPIRES|ISSUER|AUDIENCE|ALGORITHM|ALG|SCOPE|METHOD)(?:_|$)/; | ||
| function normalizeKey(key) { | ||
| return key.toUpperCase().replace(/-/g, "_"); | ||
| } | ||
| function keyLooksSecret(key) { | ||
| const k = normalizeKey(key); | ||
| return SECRET_KEY_RE.test(k) && !NON_SECRET_QUALIFIER_RE.test(k); | ||
| } | ||
| function valueLooksPlaintextSecret(value) { | ||
| const v = value.trim(); | ||
| if (v.length < 6) return false; | ||
| if (parsePlaceholder(value) !== null) return false; | ||
| if (/\$\{[^}]*\}/.test(v)) return false; | ||
| if (/^\$[A-Za-z_]/.test(v)) return false; | ||
| if (/^%[A-Za-z_][A-Za-z0-9_]*%([\\/].*)?$/.test(v)) return false; | ||
| if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return false; | ||
| if (/^[~./]/.test(v) || /^[A-Za-z]:[\\/]/.test(v) || /^\\\\/.test(v)) return false; | ||
| if (/^(true|false|\d+)$/i.test(v)) return false; | ||
| return true; | ||
| } | ||
| function scanMap(server, field, map) { | ||
| if (!map) return []; | ||
| const out = []; | ||
| for (const [key, value] of Object.entries(map)) { | ||
| if (typeof value !== "string") continue; | ||
| if (parsePlaceholder(value) !== null) continue; | ||
| const labels = detectSecretLabels(value); | ||
| if (labels.length > 0) { | ||
| out.push({ server, field, key, label: labels.join(", ") }); | ||
| continue; | ||
| } | ||
| if (keyLooksSecret(key) && valueLooksPlaintextSecret(value)) { | ||
| out.push({ server, field, key, label: GENERIC_LABEL }); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| function scanServerConfigSecrets(server, entry) { | ||
| return [...scanMap(server, "env", entry.env), ...scanMap(server, "header", entry.headers)]; | ||
| } | ||
| function scanConfigSecrets(servers) { | ||
| return Object.entries(servers).flatMap(([name, entry]) => scanServerConfigSecrets(name, entry)); | ||
| } | ||
| // src/commands/doctor.ts | ||
| import "commander"; | ||
| import os from "os"; | ||
| import { execFile } from "child_process"; | ||
| var RUNTIMES = ["npx", "uvx", "docker"]; | ||
| var CLIENT_LABELS = { | ||
| "claude-desktop": "Claude Desktop", | ||
| "claude-code": "Claude Code", | ||
| cursor: "Cursor", | ||
| vscode: "VS Code", | ||
| windsurf: "Windsurf", | ||
| "gemini-cli": "Gemini CLI" | ||
| }; | ||
| var RUNTIME_INSTALL_HINTS = { | ||
| npx: "install Node.js from https://nodejs.org", | ||
| uvx: "install uv from https://docs.astral.sh/uv/", | ||
| docker: "install Docker from https://docs.docker.com/get-docker/" | ||
| }; | ||
| async function buildDoctorModel(deps) { | ||
| const { getAdapter: getAdapter2, getConfigPath: getConfigPath2, checkConfigExists, execCheck } = deps; | ||
| const reads = await Promise.all( | ||
| CLIENT_IDS.map(async (clientId) => { | ||
| const exists = await checkConfigExists(clientId); | ||
| if (!exists) return { clientId, read: { exists: false, malformed: false, servers: null } }; | ||
| try { | ||
| const servers = await getAdapter2(clientId).read(getConfigPath2(clientId)); | ||
| return { clientId, read: { exists: true, malformed: false, servers } }; | ||
| } catch { | ||
| return { clientId, read: { exists: true, malformed: true, servers: null } }; | ||
| } | ||
| }) | ||
| ); | ||
| const issues = []; | ||
| const clients = reads.map(({ clientId, read }) => { | ||
| const label = CLIENT_LABELS[clientId]; | ||
| if (read.malformed) { | ||
| issues.push({ | ||
| kind: "malformed-config", | ||
| message: `Config file for ${label} is malformed \u2014 fix the JSON syntax.` | ||
| }); | ||
| } | ||
| const servers = read.servers ?? {}; | ||
| const entries = Object.values(servers); | ||
| return { | ||
| id: clientId, | ||
| label, | ||
| exists: read.exists, | ||
| malformed: read.malformed, | ||
| serverCount: entries.length, | ||
| guardedCount: entries.filter(isWrapped).length | ||
| }; | ||
| }); | ||
| const runtimes = await Promise.all( | ||
| RUNTIMES.map(async (name) => ({ name, available: await execCheck(name) })) | ||
| ); | ||
| const runtimeAvailable = new Map(runtimes.map((r) => [r.name, r.available])); | ||
| for (const { clientId, read } of reads) { | ||
| if (!read.servers) continue; | ||
| for (const [serverName, entry] of Object.entries(read.servers)) { | ||
| const cmd = entry.command; | ||
| if (!cmd) continue; | ||
| if (RUNTIMES.includes(cmd) && runtimeAvailable.get(cmd) === false) { | ||
| issues.push({ | ||
| kind: "missing-runtime", | ||
| message: `Server '${serverName}' in ${CLIENT_LABELS[clientId]} uses '${cmd}' but ${cmd} is not installed.` | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| const driftStates = reads.flatMap( | ||
| ({ clientId, read }) => read.servers ? [{ clientId, servers: read.servers }] : [] | ||
| ); | ||
| const crossClient = driftStates.length >= 2 ? toCrossClient(driftStates) : null; | ||
| const secrets = reads.flatMap( | ||
| ({ clientId, read }) => read.servers ? scanConfigSecrets(read.servers).map((f) => ({ client: clientId, ...f })) : [] | ||
| ); | ||
| return { | ||
| schemaVersion: 1, | ||
| clients, | ||
| runtimes, | ||
| crossClient, | ||
| secrets, | ||
| issues, | ||
| ok: issues.length === 0 | ||
| }; | ||
| } | ||
| function toCrossClient(states) { | ||
| const drift = buildDriftModel(states); | ||
| const entries = []; | ||
| for (const server of drift.servers) { | ||
| if (server.conflict) { | ||
| entries.push({ | ||
| name: server.name, | ||
| kind: "conflict", | ||
| present: [...server.present], | ||
| absent: [...server.absent], | ||
| fields: server.conflictFields ? [...server.conflictFields] : void 0 | ||
| }); | ||
| } else if (server.absent.length > 0) { | ||
| entries.push({ | ||
| name: server.name, | ||
| kind: "absent", | ||
| present: [...server.present], | ||
| absent: [...server.absent] | ||
| }); | ||
| } | ||
| } | ||
| return { | ||
| consistent: drift.drifted === 0, | ||
| clientCount: drift.clients.length, | ||
| serverCount: drift.servers.length, | ||
| drift: entries | ||
| }; | ||
| } | ||
| function renderDoctorText(model, output) { | ||
| output(""); | ||
| output("mcpm doctor"); | ||
| output(""); | ||
| for (const c of model.clients) { | ||
| if (!c.exists) { | ||
| output(` \u2717 ${c.label} \u2014 config not found`); | ||
| } else if (c.malformed) { | ||
| output(` \u2717 ${c.label} \u2014 config malformed (JSON parse error)`); | ||
| } else { | ||
| const word = c.serverCount === 1 ? "server" : "servers"; | ||
| output(` \u2713 ${c.label} \u2014 config found, ${c.serverCount} ${word}`); | ||
| } | ||
| } | ||
| output(""); | ||
| output("Runtimes:"); | ||
| for (const r of model.runtimes) { | ||
| if (r.available) { | ||
| output(` \u2713 ${r.name} available`); | ||
| } else { | ||
| output(` \u2717 ${r.name} not found \u2014 ${RUNTIME_INSTALL_HINTS[r.name]}`); | ||
| } | ||
| } | ||
| if (model.crossClient) { | ||
| const cc = model.crossClient; | ||
| output(""); | ||
| output("Cross-client (advisory):"); | ||
| if (cc.consistent) { | ||
| const word = cc.serverCount === 1 ? "server" : "servers"; | ||
| output(` \u2713 ${cc.serverCount} ${word} consistent across ${cc.clientCount} clients`); | ||
| } else { | ||
| for (const d of cc.drift) { | ||
| if (d.kind === "conflict") { | ||
| output(` \u26A0 ${d.name} \u2014 config differs (${d.fields.join(", ")}) across ${d.present.join(", ")}`); | ||
| } else { | ||
| output(` \u26A0 ${d.name} \u2014 in ${d.present.join(", ")}; missing in ${d.absent.join(", ")}`); | ||
| } | ||
| } | ||
| output(" Run `mcpm sync --check` for the full matrix (advisory, not a failure)."); | ||
| } | ||
| } | ||
| if (model.secrets.length > 0) { | ||
| output(""); | ||
| output("Plaintext secrets (advisory):"); | ||
| for (const s of model.secrets) { | ||
| output( | ||
| ` \u26A0 ${s.client} \xB7 ${sanitizeForTerminal(s.server)} \xB7 ${s.field} '${sanitizeForTerminal(s.key)}' \u2014 ${s.label}` | ||
| ); | ||
| } | ||
| if (model.secrets.some((s) => s.field === "env")) { | ||
| output( | ||
| " Move env secrets to the encrypted store: `mcpm secrets set <server> <KEY>` or re-install with `--secrets keychain`." | ||
| ); | ||
| } | ||
| if (model.secrets.some((s) => s.field === "header")) { | ||
| output( | ||
| " Header secrets have no keychain path yet \u2014 rotate the credential and keep it out of committed config." | ||
| ); | ||
| } | ||
| } | ||
| if (model.issues.length > 0) { | ||
| output(""); | ||
| output("Issues:"); | ||
| for (const issue of model.issues) { | ||
| output(` \u26A0 ${issue.message}`); | ||
| } | ||
| output(""); | ||
| output("Critical issues found. Run the commands above to resolve them."); | ||
| return; | ||
| } | ||
| output(""); | ||
| output("No critical issues found."); | ||
| } | ||
| function buildDoctorReport(model, env) { | ||
| return { | ||
| schemaVersion: 1, | ||
| mcpm: env.mcpm, | ||
| node: env.node, | ||
| os: `${env.platform} ${env.arch} ${env.osRelease}`, | ||
| confineBackend: env.confineBackend, | ||
| secretStore: env.secretStore, | ||
| // Redaction: drop the label + every server name; keep only counts. | ||
| clients: model.clients.map(({ id, exists, malformed, serverCount, guardedCount }) => ({ | ||
| id, | ||
| exists, | ||
| malformed, | ||
| serverCount, | ||
| guardedCount | ||
| })), | ||
| runtimes: model.runtimes, | ||
| issues: { | ||
| malformedConfigs: model.issues.filter((i) => i.kind === "malformed-config").length, | ||
| missingRuntime: model.issues.filter((i) => i.kind === "missing-runtime").length, | ||
| plaintextSecrets: model.secrets.length | ||
| } | ||
| }; | ||
| } | ||
| function renderReportText(r) { | ||
| const lines = []; | ||
| lines.push("mcpm doctor --report (redacted \u2014 no server names or args)"); | ||
| lines.push(`mcpm: ${r.mcpm}`); | ||
| lines.push(`node: ${r.node}`); | ||
| lines.push(`os: ${r.os}`); | ||
| lines.push(`confine backend: ${r.confineBackend ? "available" : "unavailable"}`); | ||
| lines.push(`secret store: ${r.secretStore}`); | ||
| lines.push(""); | ||
| lines.push("clients:"); | ||
| for (const c of r.clients) { | ||
| if (!c.exists) { | ||
| lines.push(` ${c.id}: not found`); | ||
| } else if (c.malformed) { | ||
| lines.push(` ${c.id}: config malformed`); | ||
| } else { | ||
| const guarded = c.guardedCount > 0 ? `, ${c.guardedCount} guarded` : ""; | ||
| lines.push(` ${c.id}: ${c.serverCount} servers${guarded}`); | ||
| } | ||
| } | ||
| lines.push("runtimes:"); | ||
| for (const rt of r.runtimes) { | ||
| lines.push(` ${rt.name}: ${rt.available ? "available" : "missing"}`); | ||
| } | ||
| lines.push( | ||
| `issues: ${r.issues.malformedConfigs} malformed config(s), ${r.issues.missingRuntime} missing-runtime, ${r.issues.plaintextSecrets} plaintext secret(s)` | ||
| ); | ||
| return lines.join("\n"); | ||
| } | ||
| async function doctorHandler(deps, opts = {}) { | ||
| const model = await buildDoctorModel(deps); | ||
| if (opts.report) { | ||
| const env = opts.reportEnv ?? gatherReportEnv(); | ||
| deps.output(renderReportText(buildDoctorReport(model, env))); | ||
| } else if (opts.json) { | ||
| deps.output(JSON.stringify(model, null, 2)); | ||
| } else { | ||
| renderDoctorText(model, deps.output); | ||
| } | ||
| return model.ok ? 0 : 1; | ||
| } | ||
| function makeCheckConfigExists(getConfigPathFn) { | ||
| return async (clientId) => { | ||
| try { | ||
| await access(getConfigPathFn(clientId)); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| }; | ||
| } | ||
| var checkConfigExistsDefault = makeCheckConfigExists(getConfigPath); | ||
| var ALLOWED_RUNTIME_CMDS = /* @__PURE__ */ new Set(["npx", "uvx", "docker"]); | ||
| function execCheckDefault(cmd) { | ||
| if (!ALLOWED_RUNTIME_CMDS.has(cmd)) return Promise.resolve(false); | ||
| return new Promise((resolve) => { | ||
| const which = process.platform === "win32" ? "where" : "which"; | ||
| execFile(which, [cmd], (err) => { | ||
| resolve(err === null); | ||
| }); | ||
| }); | ||
| } | ||
| function gatherReportEnv() { | ||
| return { | ||
| mcpm: "0.29.1", | ||
| node: process.version, | ||
| platform: process.platform, | ||
| arch: process.arch, | ||
| osRelease: os.release(), | ||
| confineBackend: isConfineBackendAvailable(), | ||
| secretStore: isSupportedPlatform() ? "os-keychain" : "machine-key" | ||
| }; | ||
| } | ||
| function registerDoctorCommand(program) { | ||
| program.command("doctor").description("Check MCP setup health and report issues").option("--json", "emit the structured DoctorModel as JSON (shape UNSTABLE; NOT redacted \u2014 includes server names, use --report to share publicly)").option("--report", "emit a redacted, pasteable env snapshot for bug reports (no server names/args)").action(async (options) => { | ||
| const plain = options.json || options.report; | ||
| const deps = { | ||
| getAdapter, | ||
| getConfigPath, | ||
| checkConfigExists: checkConfigExistsDefault, | ||
| execCheck: execCheckDefault, | ||
| output: plain ? (t) => console.log(t) : coloredOutput | ||
| }; | ||
| const exitCode = await doctorHandler(deps, { json: options.json, report: options.report }); | ||
| process.exit(exitCode); | ||
| }); | ||
| } | ||
| export { | ||
| formatMcpEntryCommand, | ||
| collectClientStates, | ||
| buildDriftModel, | ||
| buildDoctorModel, | ||
| makeCheckConfigExists, | ||
| execCheckDefault, | ||
| registerDoctorCommand | ||
| }; | ||
| //# sourceMappingURL=chunk-DR7HERUD.js.map |
| {"version":3,"sources":["../src/utils/format-entry.ts","../src/commands/doctor.ts","../src/config/drift.ts","../src/scanner/config-secrets.ts"],"sourcesContent":["/**\n * Shared formatting helpers for McpServerEntry display.\n */\n\nimport type { McpServerEntry } from \"../config/adapters/index.js\";\n\n/**\n * Returns the display string for an MCP server entry's command/URL column.\n *\n * @param entry - The server entry to format.\n * @param fallback - String to return when neither url nor command is present.\n */\nexport function formatMcpEntryCommand(\n entry: McpServerEntry,\n fallback = \"\\u2014\"\n): string {\n if (entry.url) return entry.url;\n if (entry.command) {\n const args = entry.args?.join(\" \") ?? \"\";\n return args ? `${entry.command} ${args}` : entry.command;\n }\n return fallback;\n}\n","/**\n * `mcpm doctor` command handler.\n *\n * Checks MCP setup health and reports issues:\n * - Which AI clients have config files\n * - Whether config files are valid JSON\n * - Which runtimes (npx, uvx, docker) are available\n * - Whether installed servers reference available runtimes\n *\n * Returns 0 for no critical issues, 1 for critical issues.\n * All external dependencies are injected for testability.\n *\n * D7: the check logic is split into a pure `buildDoctorModel` (a structured\n * `DoctorModel`) and renderers. `--json` emits the model; `--report` emits a\n * redacted, name-free env snapshot for bug reports; the MCP-server `handleDoctor`\n * reuses the same model (fixing its formerly-hardcoded `issues: []`).\n */\n\nimport { access } from \"fs/promises\";\nimport type { ClientId } from \"../config/paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"../config/adapters/index.js\";\nimport type { getConfigPath } from \"../config/paths.js\";\nimport { buildDriftModel, type ClientState } from \"../config/drift.js\";\nimport { isWrapped } from \"../guard/wrap.js\";\nimport { scanConfigSecrets, type ConfigSecretFinding } from \"../scanner/config-secrets.js\";\nimport { sanitizeForTerminal } from \"../guard/sanitize.js\";\n\n// ---------------------------------------------------------------------------\n// Deps interface\n// ---------------------------------------------------------------------------\n\nexport interface DoctorDeps {\n getAdapter: (clientId: ClientId) => ConfigAdapter;\n getConfigPath: typeof getConfigPath;\n /** Returns true if the config file exists for this client. */\n checkConfigExists: (clientId: ClientId) => Promise<boolean>;\n /** Returns true if the given executable is available on PATH. */\n execCheck: (cmd: string) => Promise<boolean>;\n output: (text: string) => void;\n}\n\n/** The subset of deps the pure model builder needs (no output, no detector). */\nexport type DoctorModelDeps = Pick<\n DoctorDeps,\n \"getAdapter\" | \"getConfigPath\" | \"checkConfigExists\" | \"execCheck\"\n>;\n\n// ---------------------------------------------------------------------------\n// Structured model (D7 — one shape for text/json/report/MCP consumers)\n// ---------------------------------------------------------------------------\n\nexport interface DoctorClientHealth {\n id: ClientId;\n label: string;\n exists: boolean;\n malformed: boolean;\n serverCount: number;\n /** Servers wrapped by the guard relay (subset of serverCount). */\n guardedCount: number;\n}\n\nexport interface DoctorRuntimeHealth {\n name: Runtime;\n available: boolean;\n}\n\nexport interface DoctorDriftEntry {\n name: string;\n kind: \"conflict\" | \"absent\";\n present: string[];\n absent: string[];\n /** Present only for `kind: \"conflict\"`. */\n fields?: string[];\n}\n\nexport interface DoctorCrossClient {\n consistent: boolean;\n clientCount: number;\n serverCount: number;\n drift: DoctorDriftEntry[];\n}\n\nexport interface DoctorIssue {\n kind: \"malformed-config\" | \"missing-runtime\";\n message: string;\n}\n\nexport interface DoctorSecretFinding {\n client: ClientId;\n server: string;\n field: ConfigSecretFinding[\"field\"];\n /** The env var / header NAME — never the value (F9 redaction contract). */\n key: string;\n label: string;\n}\n\nexport interface DoctorModel {\n schemaVersion: 1;\n clients: DoctorClientHealth[];\n runtimes: DoctorRuntimeHealth[];\n /** Advisory cross-client consistency; null when <2 clients have a readable config. */\n crossClient: DoctorCrossClient | null;\n /** Plaintext secrets in client config — advisory (F9); does NOT affect `ok`/exit. */\n secrets: DoctorSecretFinding[];\n /** Critical issues — these drive the exit code. */\n issues: DoctorIssue[];\n /** true iff issues is empty. */\n ok: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst RUNTIMES = [\"npx\", \"uvx\", \"docker\"] as const;\n\ntype Runtime = (typeof RUNTIMES)[number];\n\nconst CLIENT_LABELS: Record<ClientId, string> = {\n \"claude-desktop\": \"Claude Desktop\",\n \"claude-code\": \"Claude Code\",\n cursor: \"Cursor\",\n vscode: \"VS Code\",\n windsurf: \"Windsurf\",\n \"gemini-cli\": \"Gemini CLI\",\n};\n\nconst RUNTIME_INSTALL_HINTS: Record<Runtime, string> = {\n npx: \"install Node.js from https://nodejs.org\",\n uvx: \"install uv from https://docs.astral.sh/uv/\",\n docker: \"install Docker from https://docs.docker.com/get-docker/\",\n};\n\n// ---------------------------------------------------------------------------\n// Model builder (pure — no output)\n// ---------------------------------------------------------------------------\n\ninterface ClientRead {\n exists: boolean;\n malformed: boolean;\n servers: Record<string, McpServerEntry> | null;\n}\n\n/**\n * Runs every health check and returns the structured model. No side effects\n * beyond the injected reads; safe to call from the CLI, `--json`, `--report`,\n * and the MCP `handleDoctor` tool.\n */\nexport async function buildDoctorModel(deps: DoctorModelDeps): Promise<DoctorModel> {\n const { getAdapter, getConfigPath, checkConfigExists, execCheck } = deps;\n\n // 1. Read each known client's config.\n const reads = await Promise.all(\n CLIENT_IDS.map(async (clientId): Promise<{ clientId: ClientId; read: ClientRead }> => {\n const exists = await checkConfigExists(clientId);\n if (!exists) return { clientId, read: { exists: false, malformed: false, servers: null } };\n try {\n const servers = await getAdapter(clientId).read(getConfigPath(clientId));\n return { clientId, read: { exists: true, malformed: false, servers } };\n } catch {\n return { clientId, read: { exists: true, malformed: true, servers: null } };\n }\n })\n );\n\n const issues: DoctorIssue[] = [];\n\n const clients: DoctorClientHealth[] = reads.map(({ clientId, read }) => {\n const label = CLIENT_LABELS[clientId];\n if (read.malformed) {\n issues.push({\n kind: \"malformed-config\",\n message: `Config file for ${label} is malformed — fix the JSON syntax.`,\n });\n }\n const servers = read.servers ?? {};\n const entries = Object.values(servers);\n return {\n id: clientId,\n label,\n exists: read.exists,\n malformed: read.malformed,\n serverCount: entries.length,\n guardedCount: entries.filter(isWrapped).length,\n };\n });\n\n // 2. Runtime availability.\n const runtimes: DoctorRuntimeHealth[] = await Promise.all(\n RUNTIMES.map(async (name) => ({ name, available: await execCheck(name) }))\n );\n const runtimeAvailable = new Map(runtimes.map((r) => [r.name as string, r.available]));\n\n // 3. Cross-check: servers whose command is a tracked-but-unavailable runtime.\n for (const { clientId, read } of reads) {\n if (!read.servers) continue;\n for (const [serverName, entry] of Object.entries(read.servers)) {\n const cmd = entry.command;\n if (!cmd) continue; // HTTP/URL server — no runtime needed.\n if (RUNTIMES.includes(cmd as Runtime) && runtimeAvailable.get(cmd) === false) {\n issues.push({\n kind: \"missing-runtime\",\n message: `Server '${serverName}' in ${CLIENT_LABELS[clientId]} uses '${cmd}' but ${cmd} is not installed.`,\n });\n }\n }\n }\n\n // 4. Cross-client consistency (advisory — never an issue, never fails doctor).\n const driftStates: ClientState[] = reads.flatMap(({ clientId, read }) =>\n read.servers ? [{ clientId, servers: read.servers }] : []\n );\n const crossClient = driftStates.length >= 2 ? toCrossClient(driftStates) : null;\n\n // 5. Plaintext-secret scan (advisory — never an issue, never fails doctor).\n const secrets: DoctorSecretFinding[] = reads.flatMap(({ clientId, read }) =>\n read.servers ? scanConfigSecrets(read.servers).map((f) => ({ client: clientId, ...f })) : []\n );\n\n return {\n schemaVersion: 1,\n clients,\n runtimes,\n crossClient,\n secrets,\n issues,\n ok: issues.length === 0,\n };\n}\n\nfunction toCrossClient(states: ClientState[]): DoctorCrossClient {\n const drift = buildDriftModel(states);\n const entries: DoctorDriftEntry[] = [];\n for (const server of drift.servers) {\n // buildDriftModel returns readonly arrays — copy into the mutable public model.\n if (server.conflict) {\n entries.push({\n name: server.name,\n kind: \"conflict\",\n present: [...server.present],\n absent: [...server.absent],\n fields: server.conflictFields ? [...server.conflictFields] : undefined,\n });\n } else if (server.absent.length > 0) {\n entries.push({\n name: server.name,\n kind: \"absent\",\n present: [...server.present],\n absent: [...server.absent],\n });\n }\n }\n return {\n consistent: drift.drifted === 0,\n clientCount: drift.clients.length,\n serverCount: drift.servers.length,\n drift: entries,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Human-readable renderer (byte-identical to the pre-D7 output)\n// ---------------------------------------------------------------------------\n\nexport function renderDoctorText(model: DoctorModel, output: (text: string) => void): void {\n output(\"\");\n output(\"mcpm doctor\");\n output(\"\");\n\n for (const c of model.clients) {\n if (!c.exists) {\n output(` ✗ ${c.label} — config not found`);\n } else if (c.malformed) {\n output(` ✗ ${c.label} — config malformed (JSON parse error)`);\n } else {\n const word = c.serverCount === 1 ? \"server\" : \"servers\";\n output(` ✓ ${c.label} — config found, ${c.serverCount} ${word}`);\n }\n }\n\n output(\"\");\n output(\"Runtimes:\");\n for (const r of model.runtimes) {\n if (r.available) {\n output(` ✓ ${r.name} available`);\n } else {\n output(` ✗ ${r.name} not found — ${RUNTIME_INSTALL_HINTS[r.name]}`);\n }\n }\n\n if (model.crossClient) {\n const cc = model.crossClient;\n output(\"\");\n output(\"Cross-client (advisory):\");\n if (cc.consistent) {\n const word = cc.serverCount === 1 ? \"server\" : \"servers\";\n output(` ✓ ${cc.serverCount} ${word} consistent across ${cc.clientCount} clients`);\n } else {\n for (const d of cc.drift) {\n if (d.kind === \"conflict\") {\n output(` ⚠ ${d.name} — config differs (${d.fields!.join(\", \")}) across ${d.present.join(\", \")}`);\n } else {\n output(` ⚠ ${d.name} — in ${d.present.join(\", \")}; missing in ${d.absent.join(\", \")}`);\n }\n }\n output(\" Run `mcpm sync --check` for the full matrix (advisory, not a failure).\");\n }\n }\n\n if (model.secrets.length > 0) {\n output(\"\");\n output(\"Plaintext secrets (advisory):\");\n for (const s of model.secrets) {\n // s.server / s.key are attacker-influenceable (registry env-var names, imported\n // configs) — strip ANSI/OSC so a crafted key can't erase or spoof the advisory.\n output(\n ` ⚠ ${s.client} · ${sanitizeForTerminal(s.server)} · ${s.field} '${sanitizeForTerminal(s.key)}' — ${s.label}`\n );\n }\n // Remediation is field-specific: the keychain/placeholder path is env-only\n // (guard resolves placeholders in env, not headers; HTTP servers aren't wrapped).\n if (model.secrets.some((s) => s.field === \"env\")) {\n output(\n \" Move env secrets to the encrypted store: `mcpm secrets set <server> <KEY>` or re-install with `--secrets keychain`.\"\n );\n }\n if (model.secrets.some((s) => s.field === \"header\")) {\n output(\n \" Header secrets have no keychain path yet — rotate the credential and keep it out of committed config.\"\n );\n }\n }\n\n if (model.issues.length > 0) {\n output(\"\");\n output(\"Issues:\");\n for (const issue of model.issues) {\n output(` ⚠ ${issue.message}`);\n }\n output(\"\");\n output(\"Critical issues found. Run the commands above to resolve them.\");\n return;\n }\n\n output(\"\");\n output(\"No critical issues found.\");\n}\n\n// ---------------------------------------------------------------------------\n// Redacted report (D7 — pasteable env snapshot, NO server names/args)\n// ---------------------------------------------------------------------------\n\nexport interface DoctorReportEnv {\n mcpm: string;\n node: string;\n platform: string;\n arch: string;\n osRelease: string;\n confineBackend: boolean;\n secretStore: \"os-keychain\" | \"machine-key\";\n}\n\nexport interface DoctorReport {\n schemaVersion: 1;\n mcpm: string;\n node: string;\n os: string;\n confineBackend: boolean;\n secretStore: \"os-keychain\" | \"machine-key\";\n clients: Array<Omit<DoctorClientHealth, \"label\">>;\n runtimes: DoctorRuntimeHealth[];\n /** Counts only — issue messages + secret keys embed server names, so NOT included. */\n issues: { malformedConfigs: number; missingRuntime: number; plaintextSecrets: number };\n}\n\nexport function buildDoctorReport(model: DoctorModel, env: DoctorReportEnv): DoctorReport {\n return {\n schemaVersion: 1,\n mcpm: env.mcpm,\n node: env.node,\n os: `${env.platform} ${env.arch} ${env.osRelease}`,\n confineBackend: env.confineBackend,\n secretStore: env.secretStore,\n // Redaction: drop the label + every server name; keep only counts.\n clients: model.clients.map(({ id, exists, malformed, serverCount, guardedCount }) => ({\n id,\n exists,\n malformed,\n serverCount,\n guardedCount,\n })),\n runtimes: model.runtimes,\n issues: {\n malformedConfigs: model.issues.filter((i) => i.kind === \"malformed-config\").length,\n missingRuntime: model.issues.filter((i) => i.kind === \"missing-runtime\").length,\n plaintextSecrets: model.secrets.length,\n },\n };\n}\n\nexport function renderReportText(r: DoctorReport): string {\n const lines: string[] = [];\n lines.push(\"mcpm doctor --report (redacted — no server names or args)\");\n lines.push(`mcpm: ${r.mcpm}`);\n lines.push(`node: ${r.node}`);\n lines.push(`os: ${r.os}`);\n lines.push(`confine backend: ${r.confineBackend ? \"available\" : \"unavailable\"}`);\n lines.push(`secret store: ${r.secretStore}`);\n lines.push(\"\");\n lines.push(\"clients:\");\n for (const c of r.clients) {\n if (!c.exists) {\n lines.push(` ${c.id}: not found`);\n } else if (c.malformed) {\n lines.push(` ${c.id}: config malformed`);\n } else {\n const guarded = c.guardedCount > 0 ? `, ${c.guardedCount} guarded` : \"\";\n lines.push(` ${c.id}: ${c.serverCount} servers${guarded}`);\n }\n }\n lines.push(\"runtimes:\");\n for (const rt of r.runtimes) {\n lines.push(` ${rt.name}: ${rt.available ? \"available\" : \"missing\"}`);\n }\n lines.push(\n `issues: ${r.issues.malformedConfigs} malformed config(s), ${r.issues.missingRuntime} missing-runtime, ${r.issues.plaintextSecrets} plaintext secret(s)`\n );\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Handler\n// ---------------------------------------------------------------------------\n\nexport interface DoctorOpts {\n json?: boolean;\n report?: boolean;\n /** Injected in --report mode; the Commander action supplies the real env. */\n reportEnv?: DoctorReportEnv;\n}\n\n/**\n * Core logic for `mcpm doctor`.\n * @returns Exit code: 0 = healthy, 1 = critical issues found.\n */\nexport async function doctorHandler(deps: DoctorDeps, opts: DoctorOpts = {}): Promise<number> {\n const model = await buildDoctorModel(deps);\n\n if (opts.report) {\n const env = opts.reportEnv ?? gatherReportEnv();\n deps.output(renderReportText(buildDoctorReport(model, env)));\n } else if (opts.json) {\n deps.output(JSON.stringify(model, null, 2));\n } else {\n renderDoctorText(model, deps.output);\n }\n\n return model.ok ? 0 : 1;\n}\n\n// ---------------------------------------------------------------------------\n// Commander registration\n// ---------------------------------------------------------------------------\n\nimport { Command } from \"commander\";\nimport os from \"os\";\nimport { execFile } from \"child_process\";\nimport { getConfigPath as _getConfigPath, CLIENT_IDS } from \"../config/paths.js\";\nimport { getAdapter as getAdapterDefault } from \"../config/index.js\";\nimport { coloredOutput } from \"../utils/output.js\";\nimport { isConfineBackendAvailable } from \"../guard/confine/apply.js\";\nimport { isSupportedPlatform as isKeychainSupported } from \"../store/os-keychain.js\";\n\n/** Factory so callers that inject a custom getConfigPath (e.g. the MCP server) get honored. */\nexport function makeCheckConfigExists(\n getConfigPathFn: (clientId: ClientId) => string\n): (clientId: ClientId) => Promise<boolean> {\n return async (clientId: ClientId): Promise<boolean> => {\n try {\n await access(getConfigPathFn(clientId));\n return true;\n } catch {\n return false;\n }\n };\n}\n\nconst checkConfigExistsDefault = makeCheckConfigExists(_getConfigPath);\n\nconst ALLOWED_RUNTIME_CMDS = new Set<string>([\"npx\", \"uvx\", \"docker\"]);\n\nexport function execCheckDefault(cmd: string): Promise<boolean> {\n if (!ALLOWED_RUNTIME_CMDS.has(cmd)) return Promise.resolve(false);\n return new Promise((resolve) => {\n const which = process.platform === \"win32\" ? \"where\" : \"which\";\n execFile(which, [cmd], (err) => {\n resolve(err === null);\n });\n });\n}\n\n/** Gathers the impure environment fields for `--report`. */\nfunction gatherReportEnv(): DoctorReportEnv {\n return {\n mcpm: __PKG_VERSION__,\n node: process.version,\n platform: process.platform,\n arch: process.arch,\n osRelease: os.release(),\n confineBackend: isConfineBackendAvailable(),\n secretStore: isKeychainSupported() ? \"os-keychain\" : \"machine-key\",\n };\n}\n\nexport function registerDoctorCommand(program: Command): void {\n program\n .command(\"doctor\")\n .description(\"Check MCP setup health and report issues\")\n .option(\"--json\", \"emit the structured DoctorModel as JSON (shape UNSTABLE; NOT redacted — includes server names, use --report to share publicly)\")\n .option(\"--report\", \"emit a redacted, pasteable env snapshot for bug reports (no server names/args)\")\n .action(async (options: { json?: boolean; report?: boolean }) => {\n // --json / --report are machine/paste output — never colorize.\n const plain = options.json || options.report;\n const deps: DoctorDeps = {\n getAdapter: getAdapterDefault,\n getConfigPath: _getConfigPath,\n checkConfigExists: checkConfigExistsDefault,\n execCheck: execCheckDefault,\n output: plain ? (t) => console.log(t) : coloredOutput,\n };\n\n const exitCode = await doctorHandler(deps, { json: options.json, report: options.report });\n process.exit(exitCode);\n });\n}\n","/**\n * Cross-client config-drift model (pure, injectable).\n *\n * `mcpm diff` answers \"installed vs declared stack\" in ONE direction. This module\n * answers the symmetric N-client question: for every server name, which clients\n * have it, which are missing it, and do the clients that DO have it agree on the\n * server's shape? It is the shared core behind `mcpm sync --check` and the doctor\n * \"Cross-client\" section.\n *\n * Design notes:\n * - Read-only. No writes, no registry/lock/network — it only reads client configs\n * (the collect loop mirrors diff.ts:76-93 / export.ts).\n * - `buildDriftModel` is pure and takes already-collected `ClientState[]` so the\n * doctor command can feed it the reads it already did (no double I/O).\n * - Conflict comparison is over command + ordered args + env KEY set + url +\n * header KEY set. It NEVER compares env / header VALUES — those are secrets, and\n * two clients legitimately hold the same key with a per-machine value.\n *\n * Exports: DriftDeps, ClientState, ServerDrift, DriftModel, collectClientStates,\n * buildDriftModel.\n */\n\nimport type { ClientId } from \"./paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"./adapters/index.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface DriftDeps {\n detectClients: () => Promise<ClientId[]>;\n getAdapter: (clientId: ClientId) => Pick<ConfigAdapter, \"read\">;\n getPath: (clientId: ClientId) => string;\n}\n\n/** A single client's full set of MCP server entries (one successful read). */\nexport interface ClientState {\n readonly clientId: ClientId;\n readonly servers: Record<string, McpServerEntry>;\n}\n\nexport interface ServerDrift {\n readonly name: string;\n /** Clients (with readable configs) that declare this server. */\n readonly present: readonly ClientId[];\n /** Clients (with readable configs) that lack this server. */\n readonly absent: readonly ClientId[];\n /** True when the `present` clients disagree on the server's shape. */\n readonly conflict: boolean;\n /** Which fields diverge among the `present` clients (only when conflict). */\n readonly conflictFields?: readonly string[];\n}\n\nexport interface DriftModel {\n /** Clients considered — those whose config was readable. Sorted. */\n readonly clients: readonly ClientId[];\n /** One entry per distinct server name, sorted by name. */\n readonly servers: readonly ServerDrift[];\n /** Servers present in every considered client with no shape conflict. */\n readonly inSync: number;\n /** Servers with at least one absence or a shape conflict. */\n readonly drifted: number;\n}\n\n// ---------------------------------------------------------------------------\n// Collection (I/O)\n// ---------------------------------------------------------------------------\n\n/**\n * Read each detected client's config into a `ClientState`. Clients whose config\n * is unreadable (missing / malformed) are skipped — never throws — so a single\n * broken config can't blind the whole cross-client view (same posture as\n * `diff` / `export`).\n */\nexport async function collectClientStates(deps: DriftDeps): Promise<ClientState[]> {\n const clients = await deps.detectClients();\n const states: ClientState[] = [];\n for (const clientId of clients) {\n try {\n const servers = await deps.getAdapter(clientId).read(deps.getPath(clientId));\n states.push({ clientId, servers });\n } catch {\n // Skip unreadable clients (missing or malformed config).\n }\n }\n return states;\n}\n\n// ---------------------------------------------------------------------------\n// Drift model (pure)\n// ---------------------------------------------------------------------------\n\n/**\n * Per-field canonical projection used for conflict detection. Each value is a\n * stable string; two entries conflict on a field iff their projected strings\n * differ. Deliberately excludes env / header VALUES (secrets) and the per-client\n * `disabled` flag (an intentional per-client toggle, not a definition drift).\n */\nfunction fieldProjection(entry: McpServerEntry): Record<string, string> {\n return {\n command: entry.command ?? \"\",\n args: JSON.stringify(entry.args ?? []),\n \"env keys\": JSON.stringify(Object.keys(entry.env ?? {}).sort()),\n url: entry.url ?? \"\",\n \"header keys\": JSON.stringify(Object.keys(entry.headers ?? {}).sort()),\n };\n}\n\nconst COMPARED_FIELDS = [\"command\", \"args\", \"env keys\", \"url\", \"header keys\"] as const;\n\n/** Fields on which the given entries (≥1) disagree. Empty ⇒ all identical. */\nfunction divergingFields(entries: readonly McpServerEntry[]): string[] {\n const projections = entries.map(fieldProjection);\n return COMPARED_FIELDS.filter((field) => {\n const distinct = new Set(projections.map((p) => p[field]));\n return distinct.size > 1;\n });\n}\n\nexport function buildDriftModel(states: readonly ClientState[]): DriftModel {\n const clients = states.map((s) => s.clientId).sort();\n\n // Gather, per server name, the clients that declare it and their entries.\n const byName = new Map<string, Array<{ clientId: ClientId; entry: McpServerEntry }>>();\n for (const { clientId, servers } of states) {\n for (const [name, entry] of Object.entries(servers)) {\n const list = byName.get(name) ?? [];\n list.push({ clientId, entry });\n byName.set(name, list);\n }\n }\n\n const servers: ServerDrift[] = [];\n for (const name of [...byName.keys()].sort()) {\n const holders = byName.get(name)!;\n const present = holders.map((h) => h.clientId).sort();\n const presentSet = new Set(present);\n const absent = clients.filter((c) => !presentSet.has(c));\n\n const fields = holders.length > 1 ? divergingFields(holders.map((h) => h.entry)) : [];\n const conflict = fields.length > 0;\n\n servers.push({\n name,\n present,\n absent,\n conflict,\n ...(conflict ? { conflictFields: fields } : {}),\n });\n }\n\n const drifted = servers.filter((s) => s.absent.length > 0 || s.conflict).length;\n return { clients, servers, inSync: servers.length - drifted, drifted };\n}\n","/**\n * Plaintext-secret scan over client MCP config (F9 · PR1).\n *\n * mcpm ships an encrypted secret store + OS keychain, but a server's env/header\n * values are routinely pasted in plaintext (24k+ such leaks documented in the\n * wild). This read-only scan flags them so `doctor` can nudge the user toward\n * `mcpm secrets` / keychain mode.\n *\n * REDACTION CONTRACT: a finding carries the KEY name and a LABEL only — NEVER the\n * matched value. Values already stored as `mcpm:keychain:` placeholders are\n * skipped (they are the safe state, not a leak).\n *\n * Two detectors:\n * 1. value-shape — the sweep-hardened `detectSecretLabels` patterns (AWS /\n * GitHub / OpenAI / … keys). Near-zero false positives.\n * 2. secret-named key — a tight key-name heuristic for generic passwords/tokens\n * no value-regex matches, gated by strong non-secret-qualifier (URL/ID/NAME/…)\n * and non-secret-value (reference/URL/path/flag) exclusions + a benign corpus.\n *\n * Pure: no I/O. The caller (doctor) supplies the already-read config.\n */\n\nimport type { McpServerEntry } from \"../config/adapters/index.js\";\nimport { detectSecretLabels } from \"./patterns.js\";\nimport { parsePlaceholder } from \"../store/keychain.js\";\n\nexport interface ConfigSecretFinding {\n /** Server name as it appears in the client config. */\n server: string;\n /** Which value map the secret sits in. */\n field: \"env\" | \"header\";\n /** The env var / header NAME. Never the value. */\n key: string;\n /** What was matched (e.g. \"AWS access key\"). Never the value. */\n label: string;\n}\n\n/** Label for a key-heuristic hit (detector 2). Value-free by construction. */\nconst GENERIC_LABEL = \"secret-named key holds a plaintext value\";\n\n// Secret-indicating whole words. Matched against the key normalized to\n// upper-case with '-'→'_' (so `X-API-Key` reads as `X_API_KEY`). Bare `KEY` is\n// deliberately NOT a word (PUBLIC_KEY / KEY_ID / SORT_KEY are not secrets) — only\n// the listed `*_KEY` compounds count.\nconst SECRET_KEY_RE =\n /(?:^|_)(?:PASSWORD|PASSWD|PASSPHRASE|SECRET|TOKEN|PAT|APIKEY|AUTHORIZATION|CREDENTIALS?|(?:API|ACCESS|PRIVATE|SECRET|SESSION|SIGNING|ENCRYPTION)_KEY)(?:_|$)/;\n\n// Tokens that mean the field is a descriptor of a secret, not the secret itself\n// (an id, url, name, endpoint, …). Any one vetoes a key-name match, so\n// `TOKEN_URL` / `AWS_ACCESS_KEY_ID` / `SECRET_NAME` / `PUBLIC_KEY` do not fire.\n// KNOWN GAP (advisory tool, accepted): the veto matches a qualifier ANYWHERE in the\n// key, so `ID_TOKEN` (where `ID` is the credential TYPE, not a descriptor) is missed.\n// A suffix-anchored fix would newly false-POSITIVE on `MAPBOX_PUBLIC_TOKEN`; since a\n// false negative in an advisory scan is acceptable but a false positive is not, we\n// keep the anywhere-match.\nconst NON_SECRET_QUALIFIER_RE =\n /(?:^|_)(?:URL|URI|ENDPOINT|HOST|PORT|ID|NAME|PATH|FILE|DIR|ENABLED|DISABLED|TYPE|MODE|REGION|TIMEOUT|VERSION|PUBLIC|FORMAT|HEADER|PREFIX|SUFFIX|COUNT|SIZE|TTL|EXPIRY|EXPIRES|ISSUER|AUDIENCE|ALGORITHM|ALG|SCOPE|METHOD)(?:_|$)/;\n\nfunction normalizeKey(key: string): string {\n return key.toUpperCase().replace(/-/g, \"_\");\n}\n\nfunction keyLooksSecret(key: string): boolean {\n const k = normalizeKey(key);\n return SECRET_KEY_RE.test(k) && !NON_SECRET_QUALIFIER_RE.test(k);\n}\n\n/** True when the value is plausibly a real plaintext secret (not a ref/URL/flag). */\nfunction valueLooksPlaintextSecret(value: string): boolean {\n const v = value.trim();\n if (v.length < 6) return false; // too short to be a credential\n if (parsePlaceholder(value) !== null) return false; // mcpm keychain placeholder\n // Reference, not a literal secret. `${...}` is matched ANYWHERE (not just leading):\n // `Bearer ${input:key}` / `Bearer ${env:VAR}` is VS Code / Cursor / Claude Code's\n // documented header idiom — the recommended SAFE state. Detector 1 already ran on\n // the raw value, so a shaped credential embedded alongside a ref is still caught.\n if (/\\$\\{[^}]*\\}/.test(v)) return false; // ${VAR} template (embedded or leading)\n if (/^\\$[A-Za-z_]/.test(v)) return false; // leading $VAR reference\n if (/^%[A-Za-z_][A-Za-z0-9_]*%([\\\\/].*)?$/.test(v)) return false; // %VAR% ref or %VAR%-rooted path\n // A URI of ANY scheme: real endpoints AND secret-manager references that are the\n // safe state — op:// (1Password), vault:// (Vault). ACCEPTED FALSE-NEGATIVE: a URI\n // that itself CARRIES a credential (connection-string userinfo postgres://u:p@host,\n // or a query-param secret like otpauth://…?secret=SEED) is excluded too. Detector 1\n // still catches any prefix-shaped credential embedded in the value, and the bare\n // (non-URI) secret form is still caught by detector 2. Zero-FP is the hard invariant;\n // re-catching these would need query-param parsing that risks FPs on real endpoints.\n if (/^[a-z][a-z0-9+.-]*:\\/\\//i.test(v)) return false;\n // Filesystem path — POSIX (~ . /) or Windows (drive-letter, UNC).\n if (/^[~./]/.test(v) || /^[A-Za-z]:[\\\\/]/.test(v) || /^\\\\\\\\/.test(v)) return false;\n if (/^(true|false|\\d+)$/i.test(v)) return false; // boolean / plain number\n return true;\n}\n\nfunction scanMap(\n server: string,\n field: \"env\" | \"header\",\n map: Record<string, string> | undefined\n): ConfigSecretFinding[] {\n if (!map) return [];\n const out: ConfigSecretFinding[] = [];\n for (const [key, value] of Object.entries(map)) {\n if (typeof value !== \"string\") continue;\n if (parsePlaceholder(value) !== null) continue; // already stored safely — not a leak\n const labels = detectSecretLabels(value);\n if (labels.length > 0) {\n // Value-shape is the more specific, higher-confidence signal — ONE finding per\n // (field, key) even when several patterns match (e.g. a Bearer-wrapped ghp_\n // token hits both), so the --report count is not inflated. Skip the heuristic.\n out.push({ server, field, key, label: labels.join(\", \") });\n continue;\n }\n if (keyLooksSecret(key) && valueLooksPlaintextSecret(value)) {\n out.push({ server, field, key, label: GENERIC_LABEL });\n }\n }\n return out;\n}\n\n/** Scan one server's env + headers for plaintext secrets. */\nexport function scanServerConfigSecrets(\n server: string,\n entry: McpServerEntry\n): ConfigSecretFinding[] {\n return [...scanMap(server, \"env\", entry.env), ...scanMap(server, \"header\", entry.headers)];\n}\n\n/** Scan every server in a client's config. */\nexport function scanConfigSecrets(\n servers: Record<string, McpServerEntry>\n): ConfigSecretFinding[] {\n return Object.entries(servers).flatMap(([name, entry]) => scanServerConfigSecrets(name, entry));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AAYO,SAAS,sBACd,OACA,WAAW,UACH;AACR,MAAI,MAAM,IAAK,QAAO,MAAM;AAC5B,MAAI,MAAM,SAAS;AACjB,UAAM,OAAO,MAAM,MAAM,KAAK,GAAG,KAAK;AACtC,WAAO,OAAO,GAAG,MAAM,OAAO,IAAI,IAAI,KAAK,MAAM;AAAA,EACnD;AACA,SAAO;AACT;;;ACJA,SAAS,cAAc;;;ACwDvB,eAAsB,oBAAoB,MAAyC;AACjF,QAAM,UAAU,MAAM,KAAK,cAAc;AACzC,QAAM,SAAwB,CAAC;AAC/B,aAAW,YAAY,SAAS;AAC9B,QAAI;AACF,YAAM,UAAU,MAAM,KAAK,WAAW,QAAQ,EAAE,KAAK,KAAK,QAAQ,QAAQ,CAAC;AAC3E,aAAO,KAAK,EAAE,UAAU,QAAQ,CAAC;AAAA,IACnC,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO;AACT;AAYA,SAAS,gBAAgB,OAA+C;AACtE,SAAO;AAAA,IACL,SAAS,MAAM,WAAW;AAAA,IAC1B,MAAM,KAAK,UAAU,MAAM,QAAQ,CAAC,CAAC;AAAA,IACrC,YAAY,KAAK,UAAU,OAAO,KAAK,MAAM,OAAO,CAAC,CAAC,EAAE,KAAK,CAAC;AAAA,IAC9D,KAAK,MAAM,OAAO;AAAA,IAClB,eAAe,KAAK,UAAU,OAAO,KAAK,MAAM,WAAW,CAAC,CAAC,EAAE,KAAK,CAAC;AAAA,EACvE;AACF;AAEA,IAAM,kBAAkB,CAAC,WAAW,QAAQ,YAAY,OAAO,aAAa;AAG5E,SAAS,gBAAgB,SAA8C;AACrE,QAAM,cAAc,QAAQ,IAAI,eAAe;AAC/C,SAAO,gBAAgB,OAAO,CAAC,UAAU;AACvC,UAAM,WAAW,IAAI,IAAI,YAAY,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AACzD,WAAO,SAAS,OAAO;AAAA,EACzB,CAAC;AACH;AAEO,SAAS,gBAAgB,QAA4C;AAC1E,QAAM,UAAU,OAAO,IAAI,CAAC,MAAM,EAAE,QAAQ,EAAE,KAAK;AAGnD,QAAM,SAAS,oBAAI,IAAkE;AACrF,aAAW,EAAE,UAAU,SAAAA,SAAQ,KAAK,QAAQ;AAC1C,eAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQA,QAAO,GAAG;AACnD,YAAM,OAAO,OAAO,IAAI,IAAI,KAAK,CAAC;AAClC,WAAK,KAAK,EAAE,UAAU,MAAM,CAAC;AAC7B,aAAO,IAAI,MAAM,IAAI;AAAA,IACvB;AAAA,EACF;AAEA,QAAM,UAAyB,CAAC;AAChC,aAAW,QAAQ,CAAC,GAAG,OAAO,KAAK,CAAC,EAAE,KAAK,GAAG;AAC5C,UAAM,UAAU,OAAO,IAAI,IAAI;AAC/B,UAAM,UAAU,QAAQ,IAAI,CAAC,MAAM,EAAE,QAAQ,EAAE,KAAK;AACpD,UAAM,aAAa,IAAI,IAAI,OAAO;AAClC,UAAM,SAAS,QAAQ,OAAO,CAAC,MAAM,CAAC,WAAW,IAAI,CAAC,CAAC;AAEvD,UAAM,SAAS,QAAQ,SAAS,IAAI,gBAAgB,QAAQ,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,CAAC;AACpF,UAAM,WAAW,OAAO,SAAS;AAEjC,YAAQ,KAAK;AAAA,MACX;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,GAAI,WAAW,EAAE,gBAAgB,OAAO,IAAI,CAAC;AAAA,IAC/C,CAAC;AAAA,EACH;AAEA,QAAM,UAAU,QAAQ,OAAO,CAAC,MAAM,EAAE,OAAO,SAAS,KAAK,EAAE,QAAQ,EAAE;AACzE,SAAO,EAAE,SAAS,SAAS,QAAQ,QAAQ,SAAS,SAAS,QAAQ;AACvE;;;ACnHA,IAAM,gBAAgB;AAMtB,IAAM,gBACJ;AAUF,IAAM,0BACJ;AAEF,SAAS,aAAa,KAAqB;AACzC,SAAO,IAAI,YAAY,EAAE,QAAQ,MAAM,GAAG;AAC5C;AAEA,SAAS,eAAe,KAAsB;AAC5C,QAAM,IAAI,aAAa,GAAG;AAC1B,SAAO,cAAc,KAAK,CAAC,KAAK,CAAC,wBAAwB,KAAK,CAAC;AACjE;AAGA,SAAS,0BAA0B,OAAwB;AACzD,QAAM,IAAI,MAAM,KAAK;AACrB,MAAI,EAAE,SAAS,EAAG,QAAO;AACzB,MAAI,iBAAiB,KAAK,MAAM,KAAM,QAAO;AAK7C,MAAI,cAAc,KAAK,CAAC,EAAG,QAAO;AAClC,MAAI,eAAe,KAAK,CAAC,EAAG,QAAO;AACnC,MAAI,uCAAuC,KAAK,CAAC,EAAG,QAAO;AAQ3D,MAAI,2BAA2B,KAAK,CAAC,EAAG,QAAO;AAE/C,MAAI,SAAS,KAAK,CAAC,KAAK,kBAAkB,KAAK,CAAC,KAAK,QAAQ,KAAK,CAAC,EAAG,QAAO;AAC7E,MAAI,sBAAsB,KAAK,CAAC,EAAG,QAAO;AAC1C,SAAO;AACT;AAEA,SAAS,QACP,QACA,OACA,KACuB;AACvB,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,QAAM,MAA6B,CAAC;AACpC,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,GAAG,GAAG;AAC9C,QAAI,OAAO,UAAU,SAAU;AAC/B,QAAI,iBAAiB,KAAK,MAAM,KAAM;AACtC,UAAM,SAAS,mBAAmB,KAAK;AACvC,QAAI,OAAO,SAAS,GAAG;AAIrB,UAAI,KAAK,EAAE,QAAQ,OAAO,KAAK,OAAO,OAAO,KAAK,IAAI,EAAE,CAAC;AACzD;AAAA,IACF;AACA,QAAI,eAAe,GAAG,KAAK,0BAA0B,KAAK,GAAG;AAC3D,UAAI,KAAK,EAAE,QAAQ,OAAO,KAAK,OAAO,cAAc,CAAC;AAAA,IACvD;AAAA,EACF;AACA,SAAO;AACT;AAGO,SAAS,wBACd,QACA,OACuB;AACvB,SAAO,CAAC,GAAG,QAAQ,QAAQ,OAAO,MAAM,GAAG,GAAG,GAAG,QAAQ,QAAQ,UAAU,MAAM,OAAO,CAAC;AAC3F;AAGO,SAAS,kBACd,SACuB;AACvB,SAAO,OAAO,QAAQ,OAAO,EAAE,QAAQ,CAAC,CAAC,MAAM,KAAK,MAAM,wBAAwB,MAAM,KAAK,CAAC;AAChG;;;AF6UA,OAAwB;AACxB,OAAO,QAAQ;AACf,SAAS,gBAAgB;AAhWzB,IAAM,WAAW,CAAC,OAAO,OAAO,QAAQ;AAIxC,IAAM,gBAA0C;AAAA,EAC9C,kBAAkB;AAAA,EAClB,eAAe;AAAA,EACf,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,cAAc;AAChB;AAEA,IAAM,wBAAiD;AAAA,EACrD,KAAK;AAAA,EACL,KAAK;AAAA,EACL,QAAQ;AACV;AAiBA,eAAsB,iBAAiB,MAA6C;AAClF,QAAM,EAAE,YAAAC,aAAY,eAAAC,gBAAe,mBAAmB,UAAU,IAAI;AAGpE,QAAM,QAAQ,MAAM,QAAQ;AAAA,IAC1B,WAAW,IAAI,OAAO,aAAgE;AACpF,YAAM,SAAS,MAAM,kBAAkB,QAAQ;AAC/C,UAAI,CAAC,OAAQ,QAAO,EAAE,UAAU,MAAM,EAAE,QAAQ,OAAO,WAAW,OAAO,SAAS,KAAK,EAAE;AACzF,UAAI;AACF,cAAM,UAAU,MAAMD,YAAW,QAAQ,EAAE,KAAKC,eAAc,QAAQ,CAAC;AACvE,eAAO,EAAE,UAAU,MAAM,EAAE,QAAQ,MAAM,WAAW,OAAO,QAAQ,EAAE;AAAA,MACvE,QAAQ;AACN,eAAO,EAAE,UAAU,MAAM,EAAE,QAAQ,MAAM,WAAW,MAAM,SAAS,KAAK,EAAE;AAAA,MAC5E;AAAA,IACF,CAAC;AAAA,EACH;AAEA,QAAM,SAAwB,CAAC;AAE/B,QAAM,UAAgC,MAAM,IAAI,CAAC,EAAE,UAAU,KAAK,MAAM;AACtE,UAAM,QAAQ,cAAc,QAAQ;AACpC,QAAI,KAAK,WAAW;AAClB,aAAO,KAAK;AAAA,QACV,MAAM;AAAA,QACN,SAAS,mBAAmB,KAAK;AAAA,MACnC,CAAC;AAAA,IACH;AACA,UAAM,UAAU,KAAK,WAAW,CAAC;AACjC,UAAM,UAAU,OAAO,OAAO,OAAO;AACrC,WAAO;AAAA,MACL,IAAI;AAAA,MACJ;AAAA,MACA,QAAQ,KAAK;AAAA,MACb,WAAW,KAAK;AAAA,MAChB,aAAa,QAAQ;AAAA,MACrB,cAAc,QAAQ,OAAO,SAAS,EAAE;AAAA,IAC1C;AAAA,EACF,CAAC;AAGD,QAAM,WAAkC,MAAM,QAAQ;AAAA,IACpD,SAAS,IAAI,OAAO,UAAU,EAAE,MAAM,WAAW,MAAM,UAAU,IAAI,EAAE,EAAE;AAAA,EAC3E;AACA,QAAM,mBAAmB,IAAI,IAAI,SAAS,IAAI,CAAC,MAAM,CAAC,EAAE,MAAgB,EAAE,SAAS,CAAC,CAAC;AAGrF,aAAW,EAAE,UAAU,KAAK,KAAK,OAAO;AACtC,QAAI,CAAC,KAAK,QAAS;AACnB,eAAW,CAAC,YAAY,KAAK,KAAK,OAAO,QAAQ,KAAK,OAAO,GAAG;AAC9D,YAAM,MAAM,MAAM;AAClB,UAAI,CAAC,IAAK;AACV,UAAI,SAAS,SAAS,GAAc,KAAK,iBAAiB,IAAI,GAAG,MAAM,OAAO;AAC5E,eAAO,KAAK;AAAA,UACV,MAAM;AAAA,UACN,SAAS,WAAW,UAAU,QAAQ,cAAc,QAAQ,CAAC,UAAU,GAAG,SAAS,GAAG;AAAA,QACxF,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF;AAGA,QAAM,cAA6B,MAAM;AAAA,IAAQ,CAAC,EAAE,UAAU,KAAK,MACjE,KAAK,UAAU,CAAC,EAAE,UAAU,SAAS,KAAK,QAAQ,CAAC,IAAI,CAAC;AAAA,EAC1D;AACA,QAAM,cAAc,YAAY,UAAU,IAAI,cAAc,WAAW,IAAI;AAG3E,QAAM,UAAiC,MAAM;AAAA,IAAQ,CAAC,EAAE,UAAU,KAAK,MACrE,KAAK,UAAU,kBAAkB,KAAK,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,QAAQ,UAAU,GAAG,EAAE,EAAE,IAAI,CAAC;AAAA,EAC7F;AAEA,SAAO;AAAA,IACL,eAAe;AAAA,IACf;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,IAAI,OAAO,WAAW;AAAA,EACxB;AACF;AAEA,SAAS,cAAc,QAA0C;AAC/D,QAAM,QAAQ,gBAAgB,MAAM;AACpC,QAAM,UAA8B,CAAC;AACrC,aAAW,UAAU,MAAM,SAAS;AAElC,QAAI,OAAO,UAAU;AACnB,cAAQ,KAAK;AAAA,QACX,MAAM,OAAO;AAAA,QACb,MAAM;AAAA,QACN,SAAS,CAAC,GAAG,OAAO,OAAO;AAAA,QAC3B,QAAQ,CAAC,GAAG,OAAO,MAAM;AAAA,QACzB,QAAQ,OAAO,iBAAiB,CAAC,GAAG,OAAO,cAAc,IAAI;AAAA,MAC/D,CAAC;AAAA,IACH,WAAW,OAAO,OAAO,SAAS,GAAG;AACnC,cAAQ,KAAK;AAAA,QACX,MAAM,OAAO;AAAA,QACb,MAAM;AAAA,QACN,SAAS,CAAC,GAAG,OAAO,OAAO;AAAA,QAC3B,QAAQ,CAAC,GAAG,OAAO,MAAM;AAAA,MAC3B,CAAC;AAAA,IACH;AAAA,EACF;AACA,SAAO;AAAA,IACL,YAAY,MAAM,YAAY;AAAA,IAC9B,aAAa,MAAM,QAAQ;AAAA,IAC3B,aAAa,MAAM,QAAQ;AAAA,IAC3B,OAAO;AAAA,EACT;AACF;AAMO,SAAS,iBAAiB,OAAoB,QAAsC;AACzF,SAAO,EAAE;AACT,SAAO,aAAa;AACpB,SAAO,EAAE;AAET,aAAW,KAAK,MAAM,SAAS;AAC7B,QAAI,CAAC,EAAE,QAAQ;AACb,aAAO,YAAO,EAAE,KAAK,0BAAqB;AAAA,IAC5C,WAAW,EAAE,WAAW;AACtB,aAAO,YAAO,EAAE,KAAK,6CAAwC;AAAA,IAC/D,OAAO;AACL,YAAM,OAAO,EAAE,gBAAgB,IAAI,WAAW;AAC9C,aAAO,YAAO,EAAE,KAAK,yBAAoB,EAAE,WAAW,IAAI,IAAI,EAAE;AAAA,IAClE;AAAA,EACF;AAEA,SAAO,EAAE;AACT,SAAO,WAAW;AAClB,aAAW,KAAK,MAAM,UAAU;AAC9B,QAAI,EAAE,WAAW;AACf,aAAO,YAAO,EAAE,IAAI,YAAY;AAAA,IAClC,OAAO;AACL,aAAO,YAAO,EAAE,IAAI,qBAAgB,sBAAsB,EAAE,IAAI,CAAC,EAAE;AAAA,IACrE;AAAA,EACF;AAEA,MAAI,MAAM,aAAa;AACrB,UAAM,KAAK,MAAM;AACjB,WAAO,EAAE;AACT,WAAO,0BAA0B;AACjC,QAAI,GAAG,YAAY;AACjB,YAAM,OAAO,GAAG,gBAAgB,IAAI,WAAW;AAC/C,aAAO,YAAO,GAAG,WAAW,IAAI,IAAI,sBAAsB,GAAG,WAAW,UAAU;AAAA,IACpF,OAAO;AACL,iBAAW,KAAK,GAAG,OAAO;AACxB,YAAI,EAAE,SAAS,YAAY;AACzB,iBAAO,YAAO,EAAE,IAAI,2BAAsB,EAAE,OAAQ,KAAK,IAAI,CAAC,YAAY,EAAE,QAAQ,KAAK,IAAI,CAAC,EAAE;AAAA,QAClG,OAAO;AACL,iBAAO,YAAO,EAAE,IAAI,cAAS,EAAE,QAAQ,KAAK,IAAI,CAAC,gBAAgB,EAAE,OAAO,KAAK,IAAI,CAAC,EAAE;AAAA,QACxF;AAAA,MACF;AACA,aAAO,0EAA0E;AAAA,IACnF;AAAA,EACF;AAEA,MAAI,MAAM,QAAQ,SAAS,GAAG;AAC5B,WAAO,EAAE;AACT,WAAO,+BAA+B;AACtC,eAAW,KAAK,MAAM,SAAS;AAG7B;AAAA,QACE,YAAO,EAAE,MAAM,SAAM,oBAAoB,EAAE,MAAM,CAAC,SAAM,EAAE,KAAK,KAAK,oBAAoB,EAAE,GAAG,CAAC,YAAO,EAAE,KAAK;AAAA,MAC9G;AAAA,IACF;AAGA,QAAI,MAAM,QAAQ,KAAK,CAAC,MAAM,EAAE,UAAU,KAAK,GAAG;AAChD;AAAA,QACE;AAAA,MACF;AAAA,IACF;AACA,QAAI,MAAM,QAAQ,KAAK,CAAC,MAAM,EAAE,UAAU,QAAQ,GAAG;AACnD;AAAA,QACE;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,MAAI,MAAM,OAAO,SAAS,GAAG;AAC3B,WAAO,EAAE;AACT,WAAO,SAAS;AAChB,eAAW,SAAS,MAAM,QAAQ;AAChC,aAAO,YAAO,MAAM,OAAO,EAAE;AAAA,IAC/B;AACA,WAAO,EAAE;AACT,WAAO,gEAAgE;AACvE;AAAA,EACF;AAEA,SAAO,EAAE;AACT,SAAO,2BAA2B;AACpC;AA6BO,SAAS,kBAAkB,OAAoB,KAAoC;AACxF,SAAO;AAAA,IACL,eAAe;AAAA,IACf,MAAM,IAAI;AAAA,IACV,MAAM,IAAI;AAAA,IACV,IAAI,GAAG,IAAI,QAAQ,IAAI,IAAI,IAAI,IAAI,IAAI,SAAS;AAAA,IAChD,gBAAgB,IAAI;AAAA,IACpB,aAAa,IAAI;AAAA;AAAA,IAEjB,SAAS,MAAM,QAAQ,IAAI,CAAC,EAAE,IAAI,QAAQ,WAAW,aAAa,aAAa,OAAO;AAAA,MACpF;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF,EAAE;AAAA,IACF,UAAU,MAAM;AAAA,IAChB,QAAQ;AAAA,MACN,kBAAkB,MAAM,OAAO,OAAO,CAAC,MAAM,EAAE,SAAS,kBAAkB,EAAE;AAAA,MAC5E,gBAAgB,MAAM,OAAO,OAAO,CAAC,MAAM,EAAE,SAAS,iBAAiB,EAAE;AAAA,MACzE,kBAAkB,MAAM,QAAQ;AAAA,IAClC;AAAA,EACF;AACF;AAEO,SAAS,iBAAiB,GAAyB;AACxD,QAAM,QAAkB,CAAC;AACzB,QAAM,KAAK,gEAA2D;AACtE,QAAM,KAAK,oBAAoB,EAAE,IAAI,EAAE;AACvC,QAAM,KAAK,oBAAoB,EAAE,IAAI,EAAE;AACvC,QAAM,KAAK,oBAAoB,EAAE,EAAE,EAAE;AACrC,QAAM,KAAK,oBAAoB,EAAE,iBAAiB,cAAc,aAAa,EAAE;AAC/E,QAAM,KAAK,oBAAoB,EAAE,WAAW,EAAE;AAC9C,QAAM,KAAK,EAAE;AACb,QAAM,KAAK,UAAU;AACrB,aAAW,KAAK,EAAE,SAAS;AACzB,QAAI,CAAC,EAAE,QAAQ;AACb,YAAM,KAAK,KAAK,EAAE,EAAE,aAAa;AAAA,IACnC,WAAW,EAAE,WAAW;AACtB,YAAM,KAAK,KAAK,EAAE,EAAE,oBAAoB;AAAA,IAC1C,OAAO;AACL,YAAM,UAAU,EAAE,eAAe,IAAI,KAAK,EAAE,YAAY,aAAa;AACrE,YAAM,KAAK,KAAK,EAAE,EAAE,KAAK,EAAE,WAAW,WAAW,OAAO,EAAE;AAAA,IAC5D;AAAA,EACF;AACA,QAAM,KAAK,WAAW;AACtB,aAAW,MAAM,EAAE,UAAU;AAC3B,UAAM,KAAK,KAAK,GAAG,IAAI,KAAK,GAAG,YAAY,cAAc,SAAS,EAAE;AAAA,EACtE;AACA,QAAM;AAAA,IACJ,WAAW,EAAE,OAAO,gBAAgB,yBAAyB,EAAE,OAAO,cAAc,qBAAqB,EAAE,OAAO,gBAAgB;AAAA,EACpI;AACA,SAAO,MAAM,KAAK,IAAI;AACxB;AAiBA,eAAsB,cAAc,MAAkB,OAAmB,CAAC,GAAoB;AAC5F,QAAM,QAAQ,MAAM,iBAAiB,IAAI;AAEzC,MAAI,KAAK,QAAQ;AACf,UAAM,MAAM,KAAK,aAAa,gBAAgB;AAC9C,SAAK,OAAO,iBAAiB,kBAAkB,OAAO,GAAG,CAAC,CAAC;AAAA,EAC7D,WAAW,KAAK,MAAM;AACpB,SAAK,OAAO,KAAK,UAAU,OAAO,MAAM,CAAC,CAAC;AAAA,EAC5C,OAAO;AACL,qBAAiB,OAAO,KAAK,MAAM;AAAA,EACrC;AAEA,SAAO,MAAM,KAAK,IAAI;AACxB;AAgBO,SAAS,sBACd,iBAC0C;AAC1C,SAAO,OAAO,aAAyC;AACrD,QAAI;AACF,YAAM,OAAO,gBAAgB,QAAQ,CAAC;AACtC,aAAO;AAAA,IACT,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACF;AAEA,IAAM,2BAA2B,sBAAsB,aAAc;AAErE,IAAM,uBAAuB,oBAAI,IAAY,CAAC,OAAO,OAAO,QAAQ,CAAC;AAE9D,SAAS,iBAAiB,KAA+B;AAC9D,MAAI,CAAC,qBAAqB,IAAI,GAAG,EAAG,QAAO,QAAQ,QAAQ,KAAK;AAChE,SAAO,IAAI,QAAQ,CAAC,YAAY;AAC9B,UAAM,QAAQ,QAAQ,aAAa,UAAU,UAAU;AACvD,aAAS,OAAO,CAAC,GAAG,GAAG,CAAC,QAAQ;AAC9B,cAAQ,QAAQ,IAAI;AAAA,IACtB,CAAC;AAAA,EACH,CAAC;AACH;AAGA,SAAS,kBAAmC;AAC1C,SAAO;AAAA,IACL,MAAM;AAAA,IACN,MAAM,QAAQ;AAAA,IACd,UAAU,QAAQ;AAAA,IAClB,MAAM,QAAQ;AAAA,IACd,WAAW,GAAG,QAAQ;AAAA,IACtB,gBAAgB,0BAA0B;AAAA,IAC1C,aAAa,oBAAoB,IAAI,gBAAgB;AAAA,EACvD;AACF;AAEO,SAAS,sBAAsB,SAAwB;AAC5D,UACG,QAAQ,QAAQ,EAChB,YAAY,0CAA0C,EACtD,OAAO,UAAU,qIAAgI,EACjJ,OAAO,YAAY,gFAAgF,EACnG,OAAO,OAAO,YAAkD;AAE/D,UAAM,QAAQ,QAAQ,QAAQ,QAAQ;AACtC,UAAM,OAAmB;AAAA,MACvB;AAAA,MACA;AAAA,MACA,mBAAmB;AAAA,MACnB,WAAW;AAAA,MACX,QAAQ,QAAQ,CAAC,MAAM,QAAQ,IAAI,CAAC,IAAI;AAAA,IAC1C;AAEA,UAAM,WAAW,MAAM,cAAc,MAAM,EAAE,MAAM,QAAQ,MAAM,QAAQ,QAAQ,OAAO,CAAC;AACzF,YAAQ,KAAK,QAAQ;AAAA,EACvB,CAAC;AACL;","names":["servers","getAdapter","getConfigPath"]} |
| #!/usr/bin/env node | ||
| import { | ||
| handleLock, | ||
| lockPathFor | ||
| } from "./chunk-C6CAHFQX.js"; | ||
| import { | ||
| parseSecretsMode, | ||
| resolveInstallEntry, | ||
| validateRemoteUrl | ||
| } from "./chunk-LNGTDYGN.js"; | ||
| import { | ||
| readPins | ||
| } from "./chunk-DDCTUMSZ.js"; | ||
| import { | ||
| DEFAULT_MIN_RELEASE_AGE_HOURS, | ||
| assessReleaseAge, | ||
| stdoutOutput | ||
| } from "./chunk-E3T224S3.js"; | ||
| import { | ||
| fetchNpmProvenance, | ||
| isEnoent, | ||
| isLockedRegistryServer, | ||
| isRegistryServer, | ||
| isUrlServer, | ||
| parseLockFile, | ||
| parseStackFile | ||
| } from "./chunk-RTI2GLYX.js"; | ||
| import { | ||
| checkScannerAvailable, | ||
| scanTier2 | ||
| } from "./chunk-F6CHEUGO.js"; | ||
| import { | ||
| EXTERNAL_SCAN_MAX, | ||
| computeTrustScore, | ||
| nativeTrustScore | ||
| } from "./chunk-LSNEZAFR.js"; | ||
| import { | ||
| getAdapter | ||
| } from "./chunk-W4IAFBUN.js"; | ||
| import { | ||
| confirm | ||
| } from "./chunk-2PWW3Q5Q.js"; | ||
| import { | ||
| isNewUnguarded | ||
| } from "./chunk-MLVDFLDQ.js"; | ||
| import { | ||
| compareIntegrity, | ||
| fetchNpmIntegrity | ||
| } from "./chunk-7RJXJERN.js"; | ||
| import { | ||
| RegistryClient | ||
| } from "./chunk-V4AA4ZL5.js"; | ||
| import { | ||
| applyKeychainSecrets, | ||
| setSecrets | ||
| } from "./chunk-NPJ3SGGS.js"; | ||
| import { | ||
| detectInstalledClients | ||
| } from "./chunk-6R7TL5O2.js"; | ||
| import { | ||
| getConfigPath | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import { | ||
| assessServerStatus, | ||
| extractRegistryMeta, | ||
| scanTier1 | ||
| } from "./chunk-U7N6FRYF.js"; | ||
| // src/stack/policy.ts | ||
| function checkTrustPolicy(input2) { | ||
| const { serverName, currentScore, currentMaxPossible, lockedSnapshot, policy } = input2; | ||
| if (policy === void 0) { | ||
| return { pass: true }; | ||
| } | ||
| const currentPct = toPct(currentScore, currentMaxPossible); | ||
| if (policy.minTrustScore !== void 0 && currentPct < policy.minTrustScore) { | ||
| return { | ||
| pass: false, | ||
| reason: `"${serverName}" trust score ${currentPct}% is below the minimum policy threshold of ${policy.minTrustScore}%.` | ||
| }; | ||
| } | ||
| if (policy.blockOnScoreDrop === true && lockedSnapshot !== void 0) { | ||
| const { currentNativeScore, currentNativeMaxPossible } = input2; | ||
| if (currentNativeScore === void 0 || currentNativeMaxPossible === void 0) { | ||
| throw new Error( | ||
| "blockOnScoreDrop requires the current native trust figures (currentNativeScore / currentNativeMaxPossible). This is a bug \u2014 report it rather than working around it." | ||
| ); | ||
| } | ||
| const lockedNative = recoverLockedNative( | ||
| lockedSnapshot, | ||
| currentNativeMaxPossible | ||
| ); | ||
| const curPct = toPct(currentNativeScore, currentNativeMaxPossible); | ||
| const lockPct = toPct(lockedNative.score, lockedNative.maxPossible); | ||
| if (curPct < lockPct) { | ||
| return { | ||
| pass: false, | ||
| reason: `"${serverName}" trust score dropped from ${lockPct}% to ${curPct}% (mcpm-native evidence, excluding unverifiable external-scanner credit) since the lock file was created.` + (lockedNative.basis === "legacy-bound" ? ` This lock predates native-evidence drop checks and was written with an external scanner credited, so the baseline is an upper bound \u2014 re-run \`mcpm lock\` to record an exact one.` : lockedNative.basis === "out-of-range" ? ( | ||
| // Deliberately NOT phrased as tampering: an upward re-weighting of the | ||
| // external bucket would make that accusation false for an older mcpm | ||
| // reading a newer lock. Re-locking is the remedy either way. | ||
| ` This lock records trust figures outside the range this version can interpret, so the baseline is an upper bound \u2014 re-run \`mcpm lock\` to record an exact one.` | ||
| ) : ` If you recently upgraded mcpm, new scanner findings can lower scores \u2014 re-run \`mcpm lock\` to refresh snapshots if the drop is expected.`) | ||
| }; | ||
| } | ||
| } | ||
| if (policy.minReleaseAgeHours !== void 0 && input2.releaseAge?.blocksArmedGate === true) { | ||
| const { ageHours, status } = input2.releaseAge; | ||
| if (status === "future") { | ||
| return { | ||
| pass: false, | ||
| reason: `"${serverName}" has a publish timestamp in the future; treated as within the minimum release age of ${policy.minReleaseAgeHours} hour(s) required by policy.` | ||
| }; | ||
| } | ||
| if (ageHours === null) { | ||
| return { | ||
| pass: false, | ||
| reason: `"${serverName}" release is of unverifiable age (publish timestamp ${status === "absent" ? "missing from registry metadata" : "could not be parsed"}), and the policy requires a minimum release age of ${policy.minReleaseAgeHours} hour(s).` | ||
| }; | ||
| } | ||
| return { | ||
| pass: false, | ||
| reason: `"${serverName}" release is ${ageHours} hour(s) old, below the minimum release age of ${policy.minReleaseAgeHours} hour(s) required by policy.` | ||
| }; | ||
| } | ||
| if (policy.blockInstallScripts === true && input2.hasInstallScriptFindings === true) { | ||
| return { | ||
| pass: false, | ||
| reason: `"${serverName}" resolves to a launcher that runs install scripts, and the policy blocks install scripts.` | ||
| }; | ||
| } | ||
| return { pass: true }; | ||
| } | ||
| function toPct(score, maxPossible) { | ||
| if (maxPossible <= 0) return 0; | ||
| return Math.round(score / maxPossible * 100); | ||
| } | ||
| function isUsableCredit(credit, locked, nativeMax) { | ||
| if (locked.maxPossible === nativeMax && credit > 0) return false; | ||
| return credit >= 0 && credit <= EXTERNAL_SCAN_MAX && credit <= locked.score; | ||
| } | ||
| function recoverLockedNative(locked, nativeMax) { | ||
| const bounded = (score, basis) => { | ||
| const clamped = Math.max(0, Math.min(nativeMax, score)); | ||
| return { | ||
| score: clamped, | ||
| maxPossible: nativeMax, | ||
| // A computation that had to be clamped was not exact, whatever produced it — an | ||
| // out-of-range `score` reaches here the same way an out-of-range credit does, | ||
| // since both are unbounded `z.number()`. Reporting it as exact would hand the | ||
| // user the "you upgraded mcpm" remedy for a lock that actually needs re-locking. | ||
| basis: basis === "exact" && clamped !== score ? "out-of-range" : basis | ||
| }; | ||
| }; | ||
| if (locked.externalScanCredit !== void 0) { | ||
| return isUsableCredit(locked.externalScanCredit, locked, nativeMax) ? bounded(locked.score - locked.externalScanCredit, "exact") : bounded(locked.score, "out-of-range"); | ||
| } | ||
| if (locked.maxPossible === nativeMax) { | ||
| return bounded(locked.score, "exact"); | ||
| } | ||
| return bounded(locked.score, "legacy-bound"); | ||
| } | ||
| // src/stack/env.ts | ||
| import { readFile } from "fs/promises"; | ||
| var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; | ||
| var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "__defineGetter__", "__defineSetter__"]); | ||
| async function parseEnvFile(filePath) { | ||
| let raw; | ||
| try { | ||
| raw = await readFile(filePath, "utf-8"); | ||
| } catch (err) { | ||
| if (isEnoent(err)) { | ||
| return { vars: {}, warnings: [] }; | ||
| } | ||
| throw err; | ||
| } | ||
| return parseEnvString(raw); | ||
| } | ||
| function parseEnvString(content) { | ||
| const vars = {}; | ||
| const warnings = []; | ||
| const lines = content.split("\n"); | ||
| for (let i = 0; i < lines.length; i++) { | ||
| const lineNum = i + 1; | ||
| const raw = lines[i]; | ||
| const trimmed = raw.trim(); | ||
| if (trimmed === "" || trimmed.startsWith("#")) { | ||
| continue; | ||
| } | ||
| const eqIndex = trimmed.indexOf("="); | ||
| if (eqIndex === -1) { | ||
| warnings.push(`Line ${lineNum}: skipped malformed line (no = sign)`); | ||
| continue; | ||
| } | ||
| const key = trimmed.slice(0, eqIndex).trim(); | ||
| if (key === "") { | ||
| warnings.push(`Line ${lineNum}: skipped line with empty key`); | ||
| continue; | ||
| } | ||
| if (!ENV_KEY_RE.test(key) || UNSAFE_KEYS.has(key)) { | ||
| warnings.push( | ||
| `Line ${lineNum}: skipped invalid key "${key}"` | ||
| ); | ||
| continue; | ||
| } | ||
| let value = trimmed.slice(eqIndex + 1).trim(); | ||
| if (!value.startsWith('"') && !value.startsWith("'")) { | ||
| const commentIndex = value.indexOf(" #"); | ||
| if (commentIndex !== -1) { | ||
| value = value.slice(0, commentIndex).trim(); | ||
| } | ||
| } | ||
| if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { | ||
| value = value.slice(1, -1); | ||
| } | ||
| vars[key] = value; | ||
| } | ||
| return { vars, warnings }; | ||
| } | ||
| // src/stack/frozen-verify.ts | ||
| function memoizeIntegrity(fetch) { | ||
| const cache = /* @__PURE__ */ new Map(); | ||
| return (identifier, npmVersion) => { | ||
| const key = `${identifier}\0${npmVersion}`; | ||
| let p = cache.get(key); | ||
| if (p === void 0) { | ||
| p = fetch(identifier, npmVersion); | ||
| cache.set(key, p); | ||
| } | ||
| return p; | ||
| }; | ||
| } | ||
| async function classifyIntegrity(lockFile, fetchNpmIntegrity2) { | ||
| const registryEntries = Object.entries(lockFile.servers).filter( | ||
| ([, locked]) => isLockedRegistryServer(locked) | ||
| ); | ||
| const npmEntries = registryEntries.filter(([, l]) => l.registryType === "npm"); | ||
| const npmNames = new Set(npmEntries.map(([name]) => name)); | ||
| const unenforceable = Object.keys(lockFile.servers).filter((name) => !npmNames.has(name)); | ||
| const checkable = npmEntries.filter(([, l]) => l.npmIntegrity !== void 0); | ||
| const absentBaseline = npmEntries.filter(([, l]) => l.npmIntegrity === void 0).map(([name]) => name); | ||
| const fresh = await Promise.all( | ||
| checkable.map(([, l]) => fetchNpmIntegrity2(l.identifier, l.npmIntegrity.npmVersion)) | ||
| ); | ||
| const drift = []; | ||
| const formatOnly = []; | ||
| const couldNotVerify = []; | ||
| for (let i = 0; i < checkable.length; i++) { | ||
| const [name, locked] = checkable[i]; | ||
| const baseline = locked.npmIntegrity; | ||
| const snap = fresh[i]; | ||
| const coord = { name, identifier: locked.identifier, npmVersion: baseline.npmVersion }; | ||
| if (snap === void 0) { | ||
| couldNotVerify.push(coord); | ||
| continue; | ||
| } | ||
| const cmp = compareIntegrity(baseline.integrity, snap.integrity); | ||
| if (cmp === "equal") continue; | ||
| if (cmp === "differ") { | ||
| drift.push({ ...coord, oldIntegrity: baseline.integrity, newIntegrity: snap.integrity }); | ||
| } else { | ||
| formatOnly.push(coord); | ||
| } | ||
| } | ||
| return { drift, formatOnly, couldNotVerify, absentBaseline, unenforceable, checkedNpmCount: checkable.length }; | ||
| } | ||
| function frozenVerdict(c) { | ||
| const noBaselines = c.absentBaseline.length > 0 && c.checkedNpmCount === 0; | ||
| const blocks = []; | ||
| for (const d of c.drift) { | ||
| blocks.push({ | ||
| name: d.name, | ||
| reason: "drift", | ||
| identifier: d.identifier, | ||
| npmVersion: d.npmVersion, | ||
| oldIntegrity: d.oldIntegrity, | ||
| newIntegrity: d.newIntegrity | ||
| }); | ||
| } | ||
| for (const f of c.formatOnly) { | ||
| blocks.push({ name: f.name, reason: "format", identifier: f.identifier, npmVersion: f.npmVersion }); | ||
| } | ||
| for (const v of c.couldNotVerify) { | ||
| blocks.push({ name: v.name, reason: "could-not-verify", identifier: v.identifier, npmVersion: v.npmVersion }); | ||
| } | ||
| if (!noBaselines) { | ||
| for (const name of c.absentBaseline) { | ||
| blocks.push({ name, reason: "missing-baseline" }); | ||
| } | ||
| } | ||
| return { | ||
| ok: !noBaselines && blocks.length === 0, | ||
| noBaselines, | ||
| blocks, | ||
| unenforceable: c.unenforceable, | ||
| checkedNpmCount: c.checkedNpmCount | ||
| }; | ||
| } | ||
| // src/stack/frozen-provenance.ts | ||
| function verifiedBaseline(locked) { | ||
| const prov = locked.provenance; | ||
| if (prov?.status !== "attested" || prov.verification?.outcome !== "verified") { | ||
| return void 0; | ||
| } | ||
| return { | ||
| npmVersion: prov.npmVersion, | ||
| signerSan: prov.verification.signerSan, | ||
| signerIssuer: prov.verification.signerIssuer | ||
| }; | ||
| } | ||
| async function classifyProvenance(lockFile, fetchNpmIntegrity2, fetchNpmProvenance2) { | ||
| const checked = Object.entries(lockFile.servers).filter( | ||
| ([, l]) => isLockedRegistryServer(l) | ||
| ).filter(([, l]) => l.registryType === "npm").map(([name, l]) => ({ name, locked: l, baseline: verifiedBaseline(l) })).filter( | ||
| (e) => e.baseline !== void 0 | ||
| ); | ||
| const blocks = (await Promise.all( | ||
| checked.map(async ({ name, locked, baseline }) => { | ||
| const coord = { name, identifier: locked.identifier, npmVersion: baseline.npmVersion }; | ||
| try { | ||
| const integ = await fetchNpmIntegrity2(locked.identifier, baseline.npmVersion); | ||
| if (integ === void 0) { | ||
| return { | ||
| ...coord, | ||
| reason: "unverifiable", | ||
| detail: "could not fetch npm's published integrity to bind the attestation" | ||
| }; | ||
| } | ||
| const fresh = await fetchNpmProvenance2(locked.identifier, baseline.npmVersion, { | ||
| integritySri: integ.integrity | ||
| }); | ||
| return classifyOne(coord, baseline, fresh); | ||
| } catch { | ||
| return { | ||
| ...coord, | ||
| reason: "unverifiable", | ||
| detail: "re-verification errored this run (fetcher threw)" | ||
| }; | ||
| } | ||
| }) | ||
| )).filter((b) => b !== void 0); | ||
| return { ok: blocks.length === 0, blocks, checkedVerifiedCount: checked.length }; | ||
| } | ||
| function classifyOne(coord, baseline, fresh) { | ||
| if (fresh === void 0) { | ||
| return { ...coord, reason: "unverifiable", detail: "no fresh attestation record this run (offline or endpoint error)" }; | ||
| } | ||
| if (fresh.status === "unsigned") { | ||
| return { ...coord, reason: "regression", detail: "the attestation that verified at lock time is no longer published (now unsigned)" }; | ||
| } | ||
| if (fresh.status !== "attested") { | ||
| return { ...coord, reason: "unverifiable", detail: "attestation shape is no longer a recognizable SLSA record" }; | ||
| } | ||
| const v = fresh.verification; | ||
| if (v === void 0) { | ||
| return { ...coord, reason: "unverifiable", detail: "attestation present but cryptographic verification did not run this fetch" }; | ||
| } | ||
| if (v.outcome === "could-not-verify") { | ||
| return { ...coord, reason: "regression", detail: `attestation no longer cryptographically verifies (${v.reason ?? "crypto failure"})` }; | ||
| } | ||
| if (baseline.signerSan === void 0) { | ||
| return { ...coord, reason: "unverifiable", detail: "verified baseline lacks a recorded signer SAN \u2014 cannot assert signer equality; re-lock to record it" }; | ||
| } | ||
| if (v.signerSan !== baseline.signerSan || v.signerIssuer !== baseline.signerIssuer) { | ||
| const deltas = []; | ||
| if (v.signerSan !== baseline.signerSan) { | ||
| deltas.push(`SAN ${baseline.signerSan ?? "(none)"} \u2192 ${v.signerSan ?? "(none)"}`); | ||
| } | ||
| if (v.signerIssuer !== baseline.signerIssuer) { | ||
| deltas.push(`issuer ${baseline.signerIssuer ?? "(none)"} \u2192 ${v.signerIssuer ?? "(none)"}`); | ||
| } | ||
| return { ...coord, reason: "signer-changed", detail: `signer identity changed: ${deltas.join("; ")}` }; | ||
| } | ||
| return void 0; | ||
| } | ||
| // src/guard/shadow.ts | ||
| function detectNameCollisions(inventory) { | ||
| const ownersByTool = /* @__PURE__ */ new Map(); | ||
| for (const [server, tools] of inventory) { | ||
| for (const tool of tools) { | ||
| let owners = ownersByTool.get(tool); | ||
| if (owners === void 0) { | ||
| owners = /* @__PURE__ */ new Set(); | ||
| ownersByTool.set(tool, owners); | ||
| } | ||
| owners.add(server); | ||
| } | ||
| } | ||
| const findings = []; | ||
| for (const [toolName, owners] of ownersByTool) { | ||
| if (owners.size >= 2) { | ||
| findings.push({ toolName, servers: [...owners].sort() }); | ||
| } | ||
| } | ||
| return findings.sort((a, b) => a.toolName.localeCompare(b.toolName)); | ||
| } | ||
| function buildInventoryFromPins(pins, serverNames) { | ||
| const inventory = /* @__PURE__ */ new Map(); | ||
| for (const name of serverNames) { | ||
| inventory.set(name, toolNamesFor(pins, name)); | ||
| } | ||
| return inventory; | ||
| } | ||
| function toolNamesFor(pins, name) { | ||
| return Object.hasOwn(pins.servers, name) ? Object.keys(pins.servers[name]) : []; | ||
| } | ||
| function serversWithoutBaseline(pins, serverNames) { | ||
| return serverNames.filter((name) => toolNamesFor(pins, name).length === 0); | ||
| } | ||
| // src/commands/up.ts | ||
| import "commander"; | ||
| import chalk from "chalk"; | ||
| import { input, password } from "@inquirer/prompts"; | ||
| function trustFigure(trust, options) { | ||
| if (options.minTrustFloor === void 0) { | ||
| return `${trust.score}/${trust.maxPossible}`; | ||
| } | ||
| const native = nativeTrustScore(trust); | ||
| if (native.excludedExternalCredit === 0) { | ||
| return `${trust.score}/${trust.maxPossible}`; | ||
| } | ||
| return `${native.score}/${native.maxPossible} against the floor, ${trust.score}/${trust.maxPossible} with the external scanner`; | ||
| } | ||
| async function handleUp(options, deps) { | ||
| if (options.secrets === "keychain" && options.ci) { | ||
| throw new Error( | ||
| "--secrets keychain cannot be combined with --ci (it would persist secrets to the CI runner's keychain). Use --secrets plaintext in CI." | ||
| ); | ||
| } | ||
| const stackPath = options.stackFile ?? "mcpm.yaml"; | ||
| const lockPath = lockPathFor(stackPath); | ||
| const stackFile = await parseStackFile(stackPath); | ||
| let lockFile = await parseLockFile(lockPath); | ||
| if (lockFile === null) { | ||
| deps.output("No lock file found. Running mcpm lock first..."); | ||
| await deps.runLock(stackPath); | ||
| lockFile = await parseLockFile(lockPath); | ||
| if (lockFile === null) { | ||
| throw new Error("Failed to create lock file."); | ||
| } | ||
| } | ||
| const clients = await deps.detectClients(); | ||
| if (clients.length === 0) { | ||
| throw new Error("No supported AI clients found."); | ||
| } | ||
| const serverEntries = filterByProfile(stackFile, options.profile); | ||
| if (serverEntries.length === 0) { | ||
| deps.output("No servers match the selected profile."); | ||
| return; | ||
| } | ||
| if (options.frozen === true || stackFile.policy?.frozen === true) { | ||
| await runFrozenPass(lockFile, deps); | ||
| } | ||
| const envFileVars = options.allowEnvFile === false ? { vars: {}, warnings: [] } : await parseEnvFile(".env"); | ||
| const scannerAvailable = await deps.checkScannerAvailable(); | ||
| if (options.dryRun) { | ||
| deps.output("Dry run \u2014 no changes will be made.\n"); | ||
| } | ||
| if (!options.dryRun) { | ||
| await backupConfigs(clients, deps); | ||
| } | ||
| const results = []; | ||
| const previousConsented = deps.readUnguardedConsent ? await deps.readUnguardedConsent() : []; | ||
| const consentedUnguarded = new Set(previousConsented); | ||
| for (const [name, server] of serverEntries) { | ||
| const locked = lockFile.servers[name]; | ||
| try { | ||
| const result = await processServer({ | ||
| name, | ||
| server, | ||
| locked, | ||
| policy: stackFile.policy, | ||
| clients, | ||
| scannerAvailable, | ||
| envFileVars: envFileVars.vars, | ||
| consentedUnguarded, | ||
| options, | ||
| deps | ||
| }); | ||
| results.push(result); | ||
| deps.recordResult?.({ name, status: result.status }); | ||
| deps.output(` ${statusIcon(result.status)} ${name}: ${result.message}`); | ||
| } catch (err) { | ||
| const failure = { | ||
| name, | ||
| status: "failed", | ||
| message: err instanceof Error ? err.message : String(err) | ||
| }; | ||
| results.push(failure); | ||
| deps.recordResult?.({ name, status: "failed" }); | ||
| deps.output(` ${statusIcon("failed")} ${name}: ${failure.message}`); | ||
| } | ||
| } | ||
| if (options.strict && !options.dryRun) { | ||
| await handleStrictRemoval(stackFile, clients, options, deps, results); | ||
| } | ||
| const urlServerNames = new Set( | ||
| serverEntries.filter(([, s]) => isUrlServer(s)).map(([n]) => n) | ||
| ); | ||
| const installedUnguarded = results.filter((r) => r.status === "installed" && urlServerNames.has(r.name)).map((r) => r.name).sort(); | ||
| if (installedUnguarded.length > 0 && !options.dryRun) { | ||
| const newlyConsented = installedUnguarded.filter((n) => !consentedUnguarded.has(n)); | ||
| if (isNewUnguarded(installedUnguarded, previousConsented)) { | ||
| const alreadyCount = installedUnguarded.length - newlyConsented.length; | ||
| const alreadyNote = alreadyCount > 0 ? ` (+${alreadyCount} previously consented)` : ""; | ||
| deps.output( | ||
| ` | ||
| \u26A0 UNGUARDED: the following URL/HTTP-transport server(s) now run WITHOUT runtime inspection (no relay wraps a non-stdio transport): ${newlyConsented.join(", ")}${alreadyNote}. This grants consent \u2014 it does NOT add protection. The only true fix is a streamable-HTTP relay (not yet implemented). Future \`up\` runs stay quiet unless a NEW unguarded server appears.` | ||
| ); | ||
| if (deps.recordUnguardedConsent) { | ||
| await deps.recordUnguardedConsent(newlyConsented).catch(() => void 0); | ||
| } | ||
| } else { | ||
| deps.output( | ||
| ` | ||
| ${installedUnguarded.length} server(s) running unguarded (previously consented): ${installedUnguarded.join(", ")}` | ||
| ); | ||
| } | ||
| } | ||
| if (options.frozen !== true && stackFile.policy?.frozen !== true) { | ||
| await runIntegrityPass(lockFile, deps); | ||
| } | ||
| let shadowCollisions = 0; | ||
| if (options.checkShadowing === true || stackFile.policy?.checkShadowing === true) { | ||
| shadowCollisions = await runShadowPass( | ||
| serverEntries.map(([name]) => name), | ||
| deps | ||
| ); | ||
| } | ||
| const installed = results.filter((r) => r.status === "installed").length; | ||
| const blocked = results.filter((r) => r.status === "blocked").length; | ||
| const failed = results.filter((r) => r.status === "failed").length; | ||
| const skipped = results.filter((r) => r.status === "skipped").length; | ||
| const removed = results.filter((r) => r.status === "removed").length; | ||
| const unguarded = installedUnguarded.length; | ||
| deps.output( | ||
| ` | ||
| ${installed} installed, ${skipped} skipped, ${blocked} blocked, ${failed} failed` + (removed > 0 ? `, ${removed} removed` : "") + (unguarded > 0 ? `, ${unguarded} unguarded` : "") | ||
| ); | ||
| const totalSecretsStored = results.reduce( | ||
| (sum, r) => sum + (r.storedSecrets ?? 0), | ||
| 0 | ||
| ); | ||
| if (options.secrets === "keychain" && totalSecretsStored > 0 && !options.dryRun) { | ||
| deps.output( | ||
| "Secrets stored encrypted at rest in ~/.mcpm. With an OS keychain this protects against other-user/offline access (not same-user processes); without one a machine-derived key is used that guards casual local inspection only, NOT file exfiltration \u2014 run `mcpm secrets migrate` once a keychain is available. Run `mcpm guard enable` (then restart your IDE) so they resolve at launch." | ||
| ); | ||
| } | ||
| if (blocked > 0 || failed > 0) { | ||
| throw new Error(`${blocked + failed} server(s) could not be installed.`); | ||
| } | ||
| if (shadowCollisions > 0 && options.ci) { | ||
| throw new Error( | ||
| `${shadowCollisions} cross-server tool-name collision(s) detected (--ci). Resolve the shadowing (rename/remove a duplicate tool) or drop --check-shadowing.` | ||
| ); | ||
| } | ||
| } | ||
| function filterByProfile(stackFile, profile) { | ||
| return Object.entries(stackFile.servers).filter(([, server]) => { | ||
| const profiles = isRegistryServer(server) || isUrlServer(server) ? server.profiles : void 0; | ||
| if (!profiles) return true; | ||
| if (!profile) return true; | ||
| return profiles.includes(profile); | ||
| }); | ||
| } | ||
| async function backupConfigs(clients, deps) { | ||
| const { readFile: readFile2, writeFile } = await import("fs/promises"); | ||
| for (const clientId of clients) { | ||
| try { | ||
| const adapter = deps.getAdapter(clientId); | ||
| const configPath = deps.getPath(clientId); | ||
| const content = await readFile2(configPath, "utf-8"); | ||
| await writeFile(`${configPath}.bak`, content, { | ||
| encoding: "utf-8", | ||
| mode: 384 | ||
| }); | ||
| } catch { | ||
| } | ||
| } | ||
| } | ||
| async function processServer(input2) { | ||
| const { name, server, locked, policy, clients, scannerAvailable, envFileVars, options, deps } = input2; | ||
| if (isUrlServer(server)) { | ||
| return processUrlServer(name, server.url, clients, policy, input2.consentedUnguarded, options, deps); | ||
| } | ||
| if (!locked || !isLockedRegistryServer(locked)) { | ||
| return { name, status: "failed", message: "Not found in lock file. Run mcpm lock." }; | ||
| } | ||
| const serverEntry = await deps.getServer(name, locked.version); | ||
| const statusGate = assessServerStatus(serverEntry); | ||
| if (statusGate.blocks) { | ||
| return { | ||
| name, | ||
| status: "blocked", | ||
| message: `deleted from the MCP registry${statusGate.statusMessage ? ` (${statusGate.statusMessage})` : ""}` | ||
| }; | ||
| } | ||
| const tier1 = deps.scanTier1(serverEntry); | ||
| let findings = [...tier1]; | ||
| if (scannerAvailable) { | ||
| const tier2 = await deps.scanTier2(name); | ||
| findings = [...findings, ...tier2]; | ||
| } | ||
| const registryMeta = extractRegistryMeta(serverEntry); | ||
| const releaseAge = assessReleaseAge({ | ||
| publishedAt: registryMeta.publishedAt, | ||
| now: (deps.now ?? Date.now)(), | ||
| minAgeHours: options.minTrustFloor !== void 0 ? DEFAULT_MIN_RELEASE_AGE_HOURS : policy?.minReleaseAgeHours ?? DEFAULT_MIN_RELEASE_AGE_HOURS | ||
| }); | ||
| if (releaseAge.finding) { | ||
| findings = [...findings, releaseAge.finding]; | ||
| } | ||
| const trustInput = { | ||
| findings, | ||
| healthCheckPassed: null, | ||
| hasExternalScanner: scannerAvailable, | ||
| registryMeta | ||
| }; | ||
| const trustScore = deps.computeTrustScore(trustInput); | ||
| const nativeTrust = nativeTrustScore(trustScore); | ||
| if (options.minTrustFloor !== void 0 && nativeTrust.score < options.minTrustFloor) { | ||
| return { | ||
| name, | ||
| status: "blocked", | ||
| message: `trust score ${nativeTrust.score}/${nativeTrust.maxPossible} is below the required floor of ${options.minTrustFloor}` + (nativeTrust.excludedExternalCredit > 0 ? ` (the external scanner's ${nativeTrust.excludedExternalCredit} points do not count toward the floor)` : "") | ||
| }; | ||
| } | ||
| const policyResult = checkTrustPolicy({ | ||
| serverName: name, | ||
| currentScore: trustScore.score, | ||
| currentMaxPossible: trustScore.maxPossible, | ||
| // TODOS #35: the blockOnScoreDrop tripwire compares native evidence, so a fake | ||
| // MCPM_EXTERNAL_SCANNER cannot mask a drop. `nativeTrust` is computed above. | ||
| currentNativeScore: nativeTrust.score, | ||
| currentNativeMaxPossible: nativeTrust.maxPossible, | ||
| lockedSnapshot: locked.trust, | ||
| policy, | ||
| releaseAge: { | ||
| ageHours: releaseAge.ageHours, | ||
| status: releaseAge.status, | ||
| blocksArmedGate: releaseAge.blocksArmedGate | ||
| }, | ||
| hasInstallScriptFindings: findings.some((f) => f.type === "install-script") | ||
| }); | ||
| if (!policyResult.pass) { | ||
| return { name, status: "blocked", message: policyResult.reason }; | ||
| } | ||
| if (options.dryRun) { | ||
| return { | ||
| name, | ||
| status: "skipped", | ||
| message: `would install v${locked.version} (trust: ${trustFigure(trustScore, options)})` | ||
| }; | ||
| } | ||
| const { env: envVars, storedCount } = await resolveEnvVars(name, server, envFileVars, options, deps); | ||
| const installedClients = []; | ||
| const clientErrors = []; | ||
| for (const clientId of clients) { | ||
| try { | ||
| const entry = resolveInstallEntry(serverEntry, clientId); | ||
| const entryWithEnv = { | ||
| ...entry, | ||
| ...Object.keys(envVars).length > 0 ? { env: { ...entry.env, ...envVars } } : {} | ||
| }; | ||
| const adapter = deps.getAdapter(clientId); | ||
| const configPath = deps.getPath(clientId); | ||
| await adapter.addServer(configPath, name, entryWithEnv, { force: true }); | ||
| installedClients.push(clientId); | ||
| } catch (err) { | ||
| clientErrors.push(`${clientId}: ${err instanceof Error ? err.message : String(err)}`); | ||
| } | ||
| } | ||
| if (installedClients.length === 0) { | ||
| return { | ||
| name, | ||
| status: "failed", | ||
| message: `could not write to any client (${clientErrors.join("; ")})` | ||
| }; | ||
| } | ||
| const partialNote = clientErrors.length > 0 ? ` (warning: failed on ${clientErrors.join("; ")})` : ""; | ||
| return { | ||
| name, | ||
| status: "installed", | ||
| message: `v${locked.version} (trust: ${trustFigure(trustScore, options)})${partialNote}`, | ||
| storedSecrets: storedCount | ||
| }; | ||
| } | ||
| async function runIntegrityPass(lockFile, deps) { | ||
| const c = await classifyIntegrity(lockFile, deps.fetchNpmIntegrity); | ||
| for (const d of c.drift) { | ||
| const oldShort = d.oldIntegrity.slice(0, 16); | ||
| const newShort = d.newIntegrity.slice(0, 16); | ||
| deps.output( | ||
| ` | ||
| \u26A0 INTEGRITY DRIFT: npm's published record for ${d.identifier}@${d.npmVersion} changed since you locked it (dist.integrity ${oldShort}\u2026 \u2192 ${newShort}\u2026). A published version's integrity is meant to be immutable, so this can mean a supply-chain republish \u2014 but it can also be a legitimate republish or a different registry. mcpm checks the registry's published record, not the code your agent runs. This is a warning only \u2014 it does not block \`mcpm up\`; npx/uvx fetch and run the actual package independently when the server starts (possibly from a different mirror). Re-run \`mcpm lock\` if this change is expected.` | ||
| ); | ||
| } | ||
| for (const f of c.formatOnly) { | ||
| deps.output( | ||
| ` | ||
| \u26A0 ${f.name}: npm changed the integrity format for ${f.identifier}@${f.npmVersion}, so mcpm cannot compare its published record against your locked baseline (mcpm checks the registry's published record, not the code your agent runs). Re-run \`mcpm lock\` to refresh the baseline.` | ||
| ); | ||
| } | ||
| if (c.couldNotVerify.length > 0) { | ||
| deps.output( | ||
| ` | ||
| could not verify npm integrity for ${c.couldNotVerify.length} server(s) this run (no drift result is not proof of integrity).` | ||
| ); | ||
| } | ||
| if (c.absentBaseline.length > 0) { | ||
| deps.output( | ||
| ` | ||
| integrity baseline missing for ${c.absentBaseline.length} npm server(s) \u2014 re-run \`mcpm lock\` with network access to enable drift detection.` | ||
| ); | ||
| } | ||
| } | ||
| async function runFrozenPass(lockFile, deps) { | ||
| const fetchIntegrity = memoizeIntegrity(deps.fetchNpmIntegrity); | ||
| const [v, pv] = await Promise.all([ | ||
| classifyIntegrity(lockFile, fetchIntegrity).then(frozenVerdict), | ||
| classifyProvenance(lockFile, fetchIntegrity, deps.fetchNpmProvenance) | ||
| ]); | ||
| const provBlocks = pv.blocks; | ||
| if (v.unenforceable.length > 0) { | ||
| deps.output( | ||
| ` | ||
| ${v.unenforceable.length} server(s) (pypi/oci/url) have no integrity baseline mechanism \u2014 \`--frozen\` cannot enforce them (multi-registry pinning is deferred).` | ||
| ); | ||
| } | ||
| if (v.noBaselines && provBlocks.length === 0) { | ||
| throw new Error( | ||
| "--frozen: this lock has no integrity baselines (it predates them, or was last locked offline). Run `mcpm lock` online once to record them, then `mcpm up --frozen`." | ||
| ); | ||
| } | ||
| if (v.ok && provBlocks.length === 0) return; | ||
| const integrityMessages = v.blocks.map((b) => { | ||
| switch (b.reason) { | ||
| case "drift": | ||
| return `\u2717 FROZEN: npm's published record for ${b.identifier}@${b.npmVersion} changed since you locked it (dist.integrity ${b.oldIntegrity.slice(0, 16)}\u2026 \u2192 ${b.newIntegrity.slice(0, 16)}\u2026). --frozen refuses to install on integrity drift. Re-pin with \`mcpm lock\` only if this change is expected.`; | ||
| case "format": | ||
| return `\u2717 FROZEN: cannot compare npm's published record for ${b.identifier}@${b.npmVersion} against your locked baseline (integrity format changed). Re-run \`mcpm lock\` to refresh it.`; | ||
| case "could-not-verify": | ||
| return `\u2717 FROZEN: could not verify npm's published record for ${b.identifier}@${b.npmVersion} this run (offline, a yanked version, or no comparable dist.integrity). --frozen requires proof the record matches your lock \u2014 this may be a transient registry error, so re-run; if it persists, drop --frozen.`; | ||
| case "missing-baseline": | ||
| return `\u2717 FROZEN: no integrity baseline recorded for ${b.name}, though other servers in this lock have one. Re-run \`mcpm lock\` online to record it, then \`mcpm up --frozen\`.`; | ||
| default: { | ||
| const _never = b; | ||
| throw new Error(`unhandled frozen block reason: ${JSON.stringify(_never)}`); | ||
| } | ||
| } | ||
| }); | ||
| const provenanceMessages = provBlocks.map(frozenProvenanceMessage); | ||
| const noticeMessages = v.noBaselines ? [ | ||
| "\u26A0 FROZEN: this lock has no integrity baselines (predates them / locked offline) \u2014 run `mcpm lock` online to record them." | ||
| ] : []; | ||
| const allMessages = [...noticeMessages, ...integrityMessages, ...provenanceMessages]; | ||
| deps.output(` | ||
| ${allMessages.join("\n")}`); | ||
| deps.output("\nmcpm verifies the registry's published record, not the code your agent runs at launch."); | ||
| const failed = /* @__PURE__ */ new Set([...v.blocks.map((b) => b.name), ...provBlocks.map((b) => b.name)]); | ||
| throw new Error( | ||
| `frozen: ${failed.size} server(s) failed verification; nothing was installed.` | ||
| ); | ||
| } | ||
| function frozenProvenanceMessage(b) { | ||
| switch (b.reason) { | ||
| case "signer-changed": | ||
| return `\u2717 FROZEN: the cryptographic signer for ${b.identifier}@${b.npmVersion} changed since you locked it (${b.detail}). --frozen refuses to install on a provenance signer swap. Re-pin with \`mcpm lock\` only if this re-sign is expected.`; | ||
| case "regression": | ||
| return `\u2717 FROZEN: provenance for ${b.identifier}@${b.npmVersion} regressed \u2014 it cryptographically verified when you locked it and no longer does (${b.detail}). --frozen refuses to install. If npm's record is unchanged, your mcpm/@sigstore version may have changed since you locked (e.g. after an mcpm upgrade); if that regression is expected, remove this server's stale lock entry and re-lock to re-baseline (a plain \`mcpm lock\` keeps the prior verified baseline).`; | ||
| case "unverifiable": | ||
| return `\u2717 FROZEN: could not cryptographically re-verify provenance for ${b.identifier}@${b.npmVersion} this run (${b.detail}). --frozen requires proof the attestation still verifies \u2014 this may be a transient error, so re-run; if it persists, investigate before dropping --frozen.`; | ||
| default: { | ||
| const _never = b.reason; | ||
| throw new Error(`unhandled provenance block reason: ${JSON.stringify(_never)}`); | ||
| } | ||
| } | ||
| } | ||
| async function runShadowPass(serverNames, deps) { | ||
| if (deps.readPins === void 0) { | ||
| deps.output("\n\u26A0 shadow check skipped: no pins reader available in this context."); | ||
| return 0; | ||
| } | ||
| let pins; | ||
| try { | ||
| pins = await deps.readPins(); | ||
| } catch { | ||
| deps.output( | ||
| "\n\u26A0 shadow check skipped: ~/.mcpm/pins.json is unreadable (integrity check or corruption)." | ||
| ); | ||
| return 0; | ||
| } | ||
| const findings = detectNameCollisions(buildInventoryFromPins(pins, serverNames)); | ||
| const noBaseline = serversWithoutBaseline(pins, serverNames); | ||
| const checked = serverNames.length - noBaseline.length; | ||
| deps.output( | ||
| ` | ||
| Shadow check: compared guarded tool inventories for ${checked} of ${serverNames.length} server(s).` | ||
| ); | ||
| if (noBaseline.length > 0) { | ||
| deps.output( | ||
| ` ${noBaseline.length} server(s) have NO guard baseline yet (${noBaseline.join(", ")}) \u2014 this check cannot see their tools, so a clean result does NOT mean no shadowing. Run them under \`mcpm guard\` (then re-run \`mcpm up\`) to include them.` | ||
| ); | ||
| } | ||
| for (const f of findings) { | ||
| deps.output( | ||
| ` | ||
| \u26A0 SHADOW: tool "${f.toolName}" is exposed by ${f.servers.length} servers (${f.servers.join(", ")}). A lower-trust server can shadow a tool meant for another, so agent calls to "${f.toolName}" are ambiguous. This can also be benign (two servers of the same kind legitimately export the same tool). Review which server should own it. (Exact-name match only \u2014 a homoglyph/case variant evades this check.)` | ||
| ); | ||
| } | ||
| return findings.length; | ||
| } | ||
| async function processUrlServer(name, url, clients, policy, consentedUnguarded, options, deps) { | ||
| if (options.allowUrlServers === false) { | ||
| return { | ||
| name, | ||
| status: "blocked", | ||
| message: "URL servers are not permitted via the MCP surface" | ||
| }; | ||
| } | ||
| const consented = options.allowUnguarded === true || policy?.allowUrlServers === true || consentedUnguarded.has(name); | ||
| if (!consented) { | ||
| return { | ||
| name, | ||
| status: "blocked", | ||
| message: "URL/HTTP-transport server runs UNGUARDED \u2014 no runtime inspection is possible (mcpm's guard relay only wraps stdio servers). Re-run with --allow-unguarded or set policy.allowUrlServers: true to install it WITHOUT protection." | ||
| }; | ||
| } | ||
| let urlError; | ||
| try { | ||
| validateRemoteUrl(url); | ||
| } catch (err) { | ||
| urlError = err instanceof Error ? err.message : String(err); | ||
| } | ||
| const cursorClients = clients.filter((c) => c === "cursor"); | ||
| if (cursorClients.length === 0) { | ||
| return { | ||
| name, | ||
| status: "skipped", | ||
| message: "URL server \u2014 no Cursor client detected (only Cursor supports URL transport)" | ||
| }; | ||
| } | ||
| if (options.dryRun) { | ||
| return urlError ? { name, status: "skipped", message: `would reject URL ${url}: ${urlError}` } : { name, status: "skipped", message: `would install URL ${url} to Cursor` }; | ||
| } | ||
| if (urlError) { | ||
| return { name, status: "blocked", message: urlError }; | ||
| } | ||
| for (const clientId of cursorClients) { | ||
| const adapter = deps.getAdapter(clientId); | ||
| const configPath = deps.getPath(clientId); | ||
| await adapter.addServer(configPath, name, { url }, { force: true }); | ||
| } | ||
| return { name, status: "installed", message: `URL ${url} \u2192 Cursor` }; | ||
| } | ||
| async function resolveEnvVars(serverName, server, envFileVars, options, deps) { | ||
| const envDecl = isRegistryServer(server) || isUrlServer(server) ? server.env : void 0; | ||
| if (!envDecl) return { env: {}, storedCount: 0 }; | ||
| const resolved = {}; | ||
| const secretKeys = /* @__PURE__ */ new Set(); | ||
| for (const [key, decl] of Object.entries(envDecl)) { | ||
| const fromEnv = options.allowProcessEnv === false ? void 0 : process.env[key]; | ||
| const fromFile = envFileVars[key]; | ||
| const fromDefault = decl.default; | ||
| let value; | ||
| if (fromEnv !== void 0) { | ||
| value = fromEnv; | ||
| } else if (fromFile !== void 0) { | ||
| value = fromFile; | ||
| } else if (fromDefault !== void 0) { | ||
| value = fromDefault; | ||
| } else if (decl.required) { | ||
| if (options.ci) { | ||
| throw new Error( | ||
| `Required env var "${key}" for "${serverName}" is not set. Set it in process.env or .env file (--ci mode, no interactive prompt).` | ||
| ); | ||
| } | ||
| value = await deps.promptEnvVar(key, decl.secret); | ||
| } | ||
| if (value === void 0) continue; | ||
| resolved[key] = value; | ||
| if (decl.secret) secretKeys.add(key); | ||
| } | ||
| return applyKeychainSecrets({ | ||
| serverName, | ||
| resolvedEnv: resolved, | ||
| isSecret: (key) => secretKeys.has(key), | ||
| mode: options.secrets ?? "plaintext", | ||
| setSecrets: deps.setSecrets | ||
| }); | ||
| } | ||
| async function handleStrictRemoval(stackFile, clients, options, deps, results) { | ||
| const declaredNames = new Set(Object.keys(stackFile.servers)); | ||
| for (const clientId of clients) { | ||
| const adapter = deps.getAdapter(clientId); | ||
| const configPath = deps.getPath(clientId); | ||
| const installed = await adapter.read(configPath); | ||
| for (const name of Object.keys(installed)) { | ||
| if (declaredNames.has(name)) continue; | ||
| if (options.ci && !options.yes) { | ||
| throw new Error( | ||
| `--strict --ci requires --yes to remove servers not in mcpm.yaml. Server "${name}" in ${clientId} would be removed.` | ||
| ); | ||
| } | ||
| if (!options.ci && options.yes !== true) { | ||
| const confirmed = await deps.confirm( | ||
| `Remove "${name}" from ${clientId}? (not in mcpm.yaml)` | ||
| ); | ||
| if (!confirmed) continue; | ||
| } | ||
| await adapter.removeServer(configPath, name); | ||
| results.push({ | ||
| name, | ||
| status: "removed", | ||
| message: `removed from ${clientId} (not in mcpm.yaml)` | ||
| }); | ||
| deps.recordResult?.({ name, status: "removed" }); | ||
| deps.output(` - ${name}: removed from ${clientId}`); | ||
| } | ||
| } | ||
| } | ||
| function statusIcon(status) { | ||
| switch (status) { | ||
| case "installed": | ||
| return "\u2713"; | ||
| case "removed": | ||
| return "\u2212"; | ||
| case "skipped": | ||
| return "\u2022"; | ||
| case "blocked": | ||
| return "\u2717"; | ||
| case "failed": | ||
| return "\u2717"; | ||
| default: | ||
| return "?"; | ||
| } | ||
| } | ||
| function registerUpCommand(program) { | ||
| program.command("up").description("Install all servers from mcpm.yaml with trust verification").option("-f, --file <path>", "path to mcpm.yaml", "mcpm.yaml").option("-p, --profile <name>", "install only servers matching this profile").option("--dry-run", "show what would be installed without making changes").option("--ci", "CI mode: no interactive prompts, exit nonzero on failure").option("--strict", "remove servers not declared in mcpm.yaml").option("-y, --yes", "skip confirmation prompts (required with --strict --ci)").option("--secrets <mode>", "where to store secret env vars: 'keychain' (encrypted in ~/.mcpm, resolved by mcpm guard at launch) or 'plaintext' (default); 'keychain' is rejected with --ci", parseSecretsMode).option("--allow-unguarded", "permit URL/HTTP-transport servers to run WITHOUT runtime guard inspection (no relay wraps a non-stdio transport); records consent so future runs stay quiet").option("--check-shadowing", "report tool-name collisions across guarded servers (a shadowing signal); advisory interactively, exits nonzero under --ci").option("--frozen", "fail closed: BEFORE installing, verify every locked npm server's published integrity AND re-verify Sigstore provenance for crypto-verified servers, then BLOCK (install nothing, exit nonzero) on integrity drift / provenance regression / unverifiable / missing baseline \u2014 the CI supply-chain freeze gate").action( | ||
| async (opts) => { | ||
| const client = new RegistryClient(); | ||
| const { readUnguardedConsent, writeUnguardedConsent, mergeUnguarded } = await import("./unguarded-GJO5WRM7.js"); | ||
| try { | ||
| await handleUp( | ||
| { | ||
| stackFile: opts.file, | ||
| profile: opts.profile, | ||
| dryRun: opts.dryRun, | ||
| ci: opts.ci, | ||
| strict: opts.strict, | ||
| yes: opts.yes, | ||
| secrets: opts.secrets, | ||
| allowUnguarded: opts.allowUnguarded, | ||
| checkShadowing: opts.checkShadowing, | ||
| frozen: opts.frozen | ||
| }, | ||
| { | ||
| detectClients: detectInstalledClients, | ||
| getAdapter, | ||
| getPath: getConfigPath, | ||
| getServer: (name, version) => client.getServer(name, version), | ||
| scanTier1, | ||
| checkScannerAvailable, | ||
| scanTier2: (name) => scanTier2(name), | ||
| computeTrustScore, | ||
| now: () => Date.now(), | ||
| runLock: async (stackFile) => { | ||
| const { writeFile } = await import("fs/promises"); | ||
| await handleLock( | ||
| { stackFile }, | ||
| { | ||
| getServerVersions: (name) => client.getServerVersions(name), | ||
| getServer: (name, v) => client.getServer(name, v), | ||
| scanTier1, | ||
| checkScannerAvailable, | ||
| scanTier2: (name) => scanTier2(name), | ||
| computeTrustScore, | ||
| now: () => Date.now(), | ||
| writeLockFile: (path, content) => writeFile(path, content, { encoding: "utf-8", mode: 384 }), | ||
| fetchNpmIntegrity, | ||
| // F8/B3: auto-lock must record the crypto-`verified` provenance | ||
| // baseline too, or the verify-time gate is vacuous for up-locked repos. | ||
| fetchNpmProvenance: (id, ver, sri) => fetchNpmProvenance(id, ver, { integritySri: sri }), | ||
| output: stdoutOutput | ||
| } | ||
| ); | ||
| }, | ||
| confirm, | ||
| promptEnvVar: async (name, isSecret) => { | ||
| if (isSecret) { | ||
| return password({ message: `${name}:` }); | ||
| } | ||
| return input({ message: `${name}:` }); | ||
| }, | ||
| output: stdoutOutput, | ||
| setSecrets, | ||
| fetchNpmIntegrity, | ||
| fetchNpmProvenance: (id, v, o) => fetchNpmProvenance(id, v, o), | ||
| readPins, | ||
| readUnguardedConsent, | ||
| recordUnguardedConsent: async (names) => { | ||
| const previous = await readUnguardedConsent(); | ||
| await writeUnguardedConsent(mergeUnguarded(previous, names)); | ||
| } | ||
| } | ||
| ); | ||
| } catch (err) { | ||
| console.error(chalk.red(err.message)); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| ); | ||
| } | ||
| export { | ||
| memoizeIntegrity, | ||
| classifyIntegrity, | ||
| frozenVerdict, | ||
| classifyProvenance, | ||
| handleUp, | ||
| registerUpCommand | ||
| }; | ||
| //# sourceMappingURL=chunk-KGCQLI22.js.map |
Sorry, the diff of this file is too big to display
| #!/usr/bin/env node | ||
| import { | ||
| DEFAULT_MIN_RELEASE_AGE_HOURS, | ||
| assessReleaseAge, | ||
| stdoutOutput | ||
| } from "./chunk-E3T224S3.js"; | ||
| import { | ||
| checkScannerAvailable, | ||
| scanTier2 | ||
| } from "./chunk-F6CHEUGO.js"; | ||
| import { | ||
| computeTrustScore | ||
| } from "./chunk-LSNEZAFR.js"; | ||
| import { | ||
| getAdapter | ||
| } from "./chunk-W4IAFBUN.js"; | ||
| import { | ||
| confirm | ||
| } from "./chunk-2PWW3Q5Q.js"; | ||
| import { | ||
| applyKeychainSecrets, | ||
| setSecrets | ||
| } from "./chunk-NPJ3SGGS.js"; | ||
| import { | ||
| detectInstalledClients | ||
| } from "./chunk-6R7TL5O2.js"; | ||
| import { | ||
| CLIENT_IDS, | ||
| getConfigPath | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import { | ||
| addInstalledServer | ||
| } from "./chunk-4QDJ3I7X.js"; | ||
| import { | ||
| DANGEROUS_FLAG_PREFIXES, | ||
| argvTokens, | ||
| assessServerStatus, | ||
| extractRegistryMeta, | ||
| levelColor, | ||
| scanTier1, | ||
| scoreBar | ||
| } from "./chunk-U7N6FRYF.js"; | ||
| // src/commands/install.ts | ||
| import { InvalidArgumentError } from "commander"; | ||
| import chalk from "chalk"; | ||
| import { input, password } from "@inquirer/prompts"; | ||
| function validateRemoteUrl(url) { | ||
| let parsed; | ||
| try { | ||
| parsed = new URL(url); | ||
| } catch { | ||
| throw new Error(`Invalid remote URL: "${url}"`); | ||
| } | ||
| if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { | ||
| throw new Error( | ||
| `Remote URL must use http or https protocol, got: "${parsed.protocol}"` | ||
| ); | ||
| } | ||
| if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) { | ||
| throw new Error( | ||
| `Remote URL must use https for non-loopback hosts (plaintext http is vulnerable to interception), got: "${url}"` | ||
| ); | ||
| } | ||
| } | ||
| function isLoopbackHost(hostname) { | ||
| const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); | ||
| return h === "localhost" || h.endsWith(".localhost") || h === "127.0.0.1" || h === "::1"; | ||
| } | ||
| var NPM_IDENTIFIER_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; | ||
| var PYPI_IDENTIFIER_RE = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/; | ||
| var OCI_IDENTIFIER_RE = /^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)*:[a-zA-Z0-9._-]+$/; | ||
| function validateIdentifier(identifier, registryType) { | ||
| const patterns = { | ||
| npm: NPM_IDENTIFIER_RE, | ||
| pypi: PYPI_IDENTIFIER_RE, | ||
| oci: OCI_IDENTIFIER_RE | ||
| }; | ||
| const re = patterns[registryType]; | ||
| if (re && !re.test(identifier)) { | ||
| throw new Error( | ||
| `Rejected potentially malicious ${registryType} identifier: "${identifier}"` | ||
| ); | ||
| } | ||
| } | ||
| function normalizeRuntimeArgs(args) { | ||
| return args.flatMap(argvTokens); | ||
| } | ||
| var SAFE_ARG_PATTERNS = [ | ||
| // Generic boolean flags (--allow-write, --read-only, --no-sandbox, etc.) | ||
| /^--[a-zA-Z][\w-]*$/, | ||
| // Single-dash short flags the live registry legitimately declares (-i, -y, -p). | ||
| // EXACTLY one alpha char — no bundled tail. Allowing a tail (-rmodule, -eCODE) | ||
| // would let a dangerous flag bundle its payload and slip past the Layer-1 | ||
| // DANGEROUS_FLAG_PREFIXES check, which only rejects the exact token (-e/-r) or | ||
| // its '=' form. The live registry's short flags are all single-letter, so the | ||
| // narrow form loses no real coverage while closing the bundling bypass. | ||
| /^-[a-zA-Z]$/, | ||
| // Generic --key=value flags with safe value characters | ||
| // Blocks shell metacharacters: ; | $ ` & ( ) { } < > ! ' " | ||
| /^--[a-zA-Z][\w-]+=[\w./@:, -]+$/, | ||
| // Bare absolute paths (Unix: /path/to/dir) | ||
| /^\/[\w.@/ -]+$/, | ||
| // Home-relative paths (~/Documents) | ||
| /^~[\w.@/ -]*$/, | ||
| // Bare positional arguments (no dashes, no path traversal) | ||
| /^[a-zA-Z0-9][\w.@/-]*$/ | ||
| ]; | ||
| function validateRuntimeArgs(args) { | ||
| for (const arg of args) { | ||
| if (/(?:^|[=\\/])\.\.(?:[\\/]|$)/.test(arg)) { | ||
| throw new Error(`Rejected path traversal in runtime argument: "${arg}"`); | ||
| } | ||
| const isDangerous = DANGEROUS_FLAG_PREFIXES.some( | ||
| (prefix) => arg === prefix || arg.startsWith(`${prefix}=`) | ||
| ); | ||
| if (isDangerous) { | ||
| throw new Error(`Rejected dangerous runtime argument: "${arg}"`); | ||
| } | ||
| const isSafe = SAFE_ARG_PATTERNS.some((pattern) => pattern.test(arg)); | ||
| if (!isSafe) { | ||
| throw new Error(`Rejected unrecognized runtime argument: "${arg}"`); | ||
| } | ||
| } | ||
| } | ||
| function resolveInstallEntry(serverEntry, clientId) { | ||
| const { server } = serverEntry; | ||
| if (clientId === "cursor" && server.remotes && server.remotes.length > 0) { | ||
| const httpRemote = server.remotes.find( | ||
| (r) => r.type === "streamable-http" || r.type === "sse" | ||
| ); | ||
| if (httpRemote) { | ||
| validateRemoteUrl(httpRemote.url); | ||
| const headers = {}; | ||
| for (const h of httpRemote.headers) { | ||
| headers[h.name] = ""; | ||
| } | ||
| return { | ||
| url: httpRemote.url, | ||
| ...Object.keys(headers).length > 0 ? { headers } : {} | ||
| }; | ||
| } | ||
| } | ||
| const npmPkg = server.packages.find((p) => p.registryType === "npm"); | ||
| const pypiPkg = server.packages.find((p) => p.registryType === "pypi"); | ||
| const ociPkg = server.packages.find((p) => p.registryType === "oci"); | ||
| if (npmPkg) { | ||
| validateIdentifier(npmPkg.identifier, "npm"); | ||
| const rtArgs = normalizeRuntimeArgs(npmPkg.runtimeArguments ?? []); | ||
| validateRuntimeArgs(rtArgs); | ||
| return { | ||
| command: "npx", | ||
| args: ["-y", npmPkg.identifier, ...rtArgs] | ||
| }; | ||
| } | ||
| if (pypiPkg) { | ||
| validateIdentifier(pypiPkg.identifier, "pypi"); | ||
| const rtArgs = normalizeRuntimeArgs(pypiPkg.runtimeArguments ?? []); | ||
| validateRuntimeArgs(rtArgs); | ||
| return { | ||
| command: "uvx", | ||
| args: [pypiPkg.identifier, ...rtArgs] | ||
| }; | ||
| } | ||
| if (ociPkg) { | ||
| validateIdentifier(ociPkg.identifier, "oci"); | ||
| const rtArgs = normalizeRuntimeArgs(ociPkg.runtimeArguments ?? []); | ||
| validateRuntimeArgs(rtArgs); | ||
| return { | ||
| command: "docker", | ||
| args: ["run", "--rm", "-i", ociPkg.identifier, ...rtArgs] | ||
| }; | ||
| } | ||
| if (clientId === "cursor" && server.remotes && server.remotes.length > 0) { | ||
| const remote = server.remotes[0]; | ||
| validateRemoteUrl(remote.url); | ||
| return { url: remote.url }; | ||
| } | ||
| throw new Error( | ||
| `No install path found for server "${server.name}": no packages and no compatible remotes.` | ||
| ); | ||
| } | ||
| function formatTrustScore(trustScore) { | ||
| const { score, maxPossible, level, breakdown } = trustScore; | ||
| const levelLabel = levelColor(level.toUpperCase()); | ||
| const bar = scoreBar(score, maxPossible); | ||
| const lines = [ | ||
| `${bar} ${score}/${maxPossible} ${levelLabel}`, | ||
| ` \u251C\u2500 Health check: ${breakdown.healthCheck > 0 ? "not yet run" : "failed or skipped"}`, | ||
| ` \u251C\u2500 Tool descriptions: ${breakdown.staticScan === 40 ? "CLEAN (no injection patterns)" : `score ${breakdown.staticScan}/40`}`, | ||
| ` \u251C\u2500 Package: publisher verification ${breakdown.registryMeta > 0 ? "passed" : "unverified"}`, | ||
| ` \u2514\u2500 External scan: ${breakdown.externalScan > 0 ? `passed (${breakdown.externalScan}/20)` : "not available (set MCPM_EXTERNAL_SCANNER for deeper analysis)"}` | ||
| ]; | ||
| return lines.join("\n"); | ||
| } | ||
| async function handleInstall(name, options, deps) { | ||
| const { | ||
| registryClient, | ||
| detectClients, | ||
| getAdapter: getAdapter2, | ||
| getConfigPath: getConfigPath2, | ||
| scanTier1: scanTier12, | ||
| checkScannerAvailable: checkScannerAvailable2, | ||
| scanTier2: scanTier22, | ||
| computeTrustScore: computeTrustScore2, | ||
| addToStore, | ||
| confirm: confirm2, | ||
| promptEnvVars, | ||
| output | ||
| } = deps; | ||
| const serverEntry = await registryClient.getServer(name); | ||
| const statusGate = assessServerStatus(serverEntry); | ||
| if (statusGate.blocks) { | ||
| if (options.json === true) { | ||
| output( | ||
| JSON.stringify( | ||
| { | ||
| name, | ||
| error: "server_delisted", | ||
| status: statusGate.status, | ||
| message: statusGate.statusMessage ?? null | ||
| }, | ||
| null, | ||
| 2 | ||
| ) | ||
| ); | ||
| } | ||
| throw new Error( | ||
| `"${name}" has been deleted from the MCP registry${statusGate.statusMessage ? ` (${statusGate.statusMessage})` : ""}. Installation aborted.` | ||
| ); | ||
| } | ||
| const tier1Findings = scanTier12(serverEntry); | ||
| const scannerAvailable = await checkScannerAvailable2(); | ||
| let allFindings = [...tier1Findings]; | ||
| if (scannerAvailable) { | ||
| const tier2Findings = await scanTier22(name); | ||
| allFindings = [...allFindings, ...tier2Findings]; | ||
| } | ||
| const registryMeta = extractRegistryMeta(serverEntry); | ||
| const releaseAge = assessReleaseAge({ | ||
| publishedAt: registryMeta.publishedAt, | ||
| now: (deps.now ?? Date.now)(), | ||
| minAgeHours: options.minReleaseAge ?? DEFAULT_MIN_RELEASE_AGE_HOURS | ||
| }); | ||
| if (releaseAge.finding) { | ||
| allFindings = [...allFindings, releaseAge.finding]; | ||
| } | ||
| const trustScoreInput = { | ||
| findings: allFindings, | ||
| healthCheckPassed: null, | ||
| // health check not yet run at this point | ||
| hasExternalScanner: scannerAvailable, | ||
| registryMeta | ||
| }; | ||
| const trustScore = computeTrustScore2(trustScoreInput); | ||
| if (options.minTrust !== void 0 && trustScore.score < options.minTrust) { | ||
| if (options.json === true) { | ||
| output( | ||
| JSON.stringify( | ||
| { | ||
| name, | ||
| error: "min_trust_not_met", | ||
| score: trustScore.score, | ||
| maxPossible: trustScore.maxPossible, | ||
| required: options.minTrust, | ||
| level: trustScore.level | ||
| }, | ||
| null, | ||
| 2 | ||
| ) | ||
| ); | ||
| } | ||
| throw new Error( | ||
| `Trust score ${trustScore.score}/${trustScore.maxPossible} is below the required minimum of ${options.minTrust}. Installation aborted.` | ||
| ); | ||
| } | ||
| if (options.minReleaseAge !== void 0 && options.allowFresh !== true && releaseAge.blocksArmedGate) { | ||
| if (options.json === true) { | ||
| output( | ||
| JSON.stringify( | ||
| { | ||
| name, | ||
| error: "release_age_not_met", | ||
| ageHours: releaseAge.ageHours, | ||
| required: options.minReleaseAge, | ||
| reason: releaseAge.status | ||
| }, | ||
| null, | ||
| 2 | ||
| ) | ||
| ); | ||
| } | ||
| const tail = "Installation aborted. Use --allow-fresh to bypass."; | ||
| throw new Error( | ||
| releaseAge.status === "future" ? `Release publish timestamp is in the future (clock skew or forged metadata); treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}` : releaseAge.status === "unparseable" ? `Release publish timestamp could not be parsed; treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}` : releaseAge.status === "absent" ? `Release publish timestamp is missing from the registry metadata, so release age cannot be verified against the ${options.minReleaseAge}-hour minimum. ${tail}` : `Release age ${releaseAge.ageHours}h is below the required minimum of ${options.minReleaseAge}h. ${tail}` | ||
| ); | ||
| } | ||
| const jsonMode = options.json === true; | ||
| if (!jsonMode) { | ||
| output(formatTrustScore(trustScore)); | ||
| output(""); | ||
| } | ||
| if (options.yes !== true) { | ||
| let shouldProceed; | ||
| if (trustScore.level === "risky") { | ||
| if (!jsonMode) { | ||
| output("\x1B[31mWARNING: This server has a low trust score and may be risky to install.\x1B[0m"); | ||
| output("\x1B[31mSecurity findings indicate potential dangers. Proceed with extreme caution.\x1B[0m"); | ||
| } | ||
| shouldProceed = await confirm2( | ||
| "I understand the risks and want to install this server anyway. Continue?" | ||
| ); | ||
| } else if (trustScore.level === "caution") { | ||
| if (!jsonMode) { | ||
| output("\x1B[33mCAUTION: This server has a moderate trust score. Review the details above.\x1B[0m"); | ||
| } | ||
| shouldProceed = await confirm2(`Install '${name}'? (caution recommended)`); | ||
| } else { | ||
| shouldProceed = await confirm2(`Install '${name}'?`); | ||
| } | ||
| if (!shouldProceed) { | ||
| if (!jsonMode) output("Installation cancelled."); | ||
| return; | ||
| } | ||
| } | ||
| let targetClients = await detectClients(); | ||
| if (targetClients.length === 0) { | ||
| throw new Error( | ||
| "No supported AI clients found. Install Claude Desktop, Cursor, VS Code, or Windsurf first." | ||
| ); | ||
| } | ||
| if (options.client !== void 0) { | ||
| if (!CLIENT_IDS.includes(options.client)) { | ||
| throw new Error( | ||
| `Unknown client "${options.client}". Valid values: ${CLIENT_IDS.join(", ")}.` | ||
| ); | ||
| } | ||
| const requestedId = options.client; | ||
| if (!targetClients.includes(requestedId)) { | ||
| throw new Error( | ||
| `Client "${requestedId}" is not installed on this machine.` | ||
| ); | ||
| } | ||
| targetClients = [requestedId]; | ||
| } | ||
| if (options.force !== true) { | ||
| for (const clientId of targetClients) { | ||
| const adapter = getAdapter2(clientId); | ||
| const configPath = getConfigPath2(clientId); | ||
| const existing = await adapter.read(configPath); | ||
| if (Object.prototype.hasOwnProperty.call(existing, name)) { | ||
| throw new Error( | ||
| `Server '${name}' is already installed in ${clientId}. Use --force to overwrite.` | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| const { server } = serverEntry; | ||
| const bestPkg = server.packages.find((p) => p.registryType === "npm") ?? server.packages.find((p) => p.registryType === "pypi") ?? server.packages.find((p) => p.registryType === "oci") ?? server.packages[0]; | ||
| const envVarDefs = bestPkg?.environmentVariables ?? []; | ||
| const resolvedEnvVars = await promptEnvVars(envVarDefs); | ||
| const secretsMode = options.secrets ?? "plaintext"; | ||
| const { env: envForConfig, storedCount: storedSecretCount } = await applyKeychainSecrets({ | ||
| serverName: name, | ||
| resolvedEnv: resolvedEnvVars, | ||
| isSecret: (key) => envVarDefs.find((d) => d.name === key)?.isSecret === true, | ||
| mode: secretsMode, | ||
| setSecrets: deps.setSecrets | ||
| }); | ||
| const resolvedEntries = /* @__PURE__ */ new Map(); | ||
| for (const clientId of targetClients) { | ||
| resolvedEntries.set(clientId, resolveInstallEntry(serverEntry, clientId)); | ||
| } | ||
| const isUnguardedEntry = [...resolvedEntries.values()].some( | ||
| (e) => e.url !== void 0 && e.command === void 0 | ||
| ); | ||
| if (isUnguardedEntry) { | ||
| if (options.allowUrlServers === false) { | ||
| throw new Error( | ||
| `Server '${name}' uses a URL/HTTP transport and is not permitted via the MCP surface.` | ||
| ); | ||
| } | ||
| const previousConsented = deps.readUnguardedConsent ? await deps.readUnguardedConsent() : []; | ||
| const alreadyConsented = previousConsented.includes(name); | ||
| const consented = options.allowUnguarded === true || alreadyConsented; | ||
| if (!consented) { | ||
| throw new Error( | ||
| `Server '${name}' uses a URL/HTTP transport and runs UNGUARDED \u2014 no runtime inspection is possible (mcpm's guard relay only wraps stdio servers). Re-run with --allow-unguarded to install it WITHOUT protection.` | ||
| ); | ||
| } | ||
| if (!alreadyConsented) { | ||
| if (!jsonMode) { | ||
| output( | ||
| "\x1B[33m\u26A0 UNGUARDED: this URL/HTTP-transport server runs WITHOUT runtime inspection (the guard relay only wraps stdio servers). This grants consent \u2014 it does NOT add protection. The only true fix is a streamable-HTTP relay (not yet implemented).\x1B[0m" | ||
| ); | ||
| } | ||
| if (deps.recordUnguardedConsent) { | ||
| await deps.recordUnguardedConsent([name]).catch(() => void 0); | ||
| } | ||
| } | ||
| } | ||
| const installedClients = []; | ||
| for (const clientId of targetClients) { | ||
| const adapter = getAdapter2(clientId); | ||
| const configPath = getConfigPath2(clientId); | ||
| const rawEntry = resolvedEntries.get(clientId); | ||
| const entry = { | ||
| ...rawEntry, | ||
| ...Object.keys(envForConfig).length > 0 ? { env: { ...rawEntry.env ?? {}, ...envForConfig } } : {} | ||
| }; | ||
| await adapter.addServer(configPath, name, entry, { force: options.force }); | ||
| installedClients.push(clientId); | ||
| } | ||
| if (!options.json) { | ||
| if (secretsMode === "keychain" && storedSecretCount > 0) { | ||
| output( | ||
| `\x1B[32mStored ${storedSecretCount} secret(s) encrypted at rest in ~/.mcpm. With an OS keychain this protects against other-user/offline access (not same-user processes); without one a machine-derived key is used that guards casual local inspection only, NOT file exfiltration \u2014 run \`mcpm secrets migrate\` once a keychain is available. Run \`mcpm guard enable\` (then restart your IDE) so they resolve at launch \u2014 until guard wraps this server it receives the literal placeholder.\x1B[0m` | ||
| ); | ||
| } else { | ||
| const hasSecrets = envVarDefs.some((ev) => ev.isSecret && resolvedEnvVars[ev.name]); | ||
| if (hasSecrets) { | ||
| output( | ||
| "\x1B[33mNote: API keys are stored as plaintext in client config files. Ensure config files have appropriate permissions (chmod 600).\x1B[0m" | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| const storeEntry = { | ||
| name, | ||
| version: serverEntry.server.version, | ||
| clients: [...installedClients], | ||
| installedAt: (/* @__PURE__ */ new Date()).toISOString() | ||
| }; | ||
| await addToStore(storeEntry); | ||
| if (options.json === true) { | ||
| const result = { | ||
| name, | ||
| version: serverEntry.server.version, | ||
| clients: installedClients, | ||
| trustScore: { | ||
| score: trustScore.score, | ||
| maxPossible: trustScore.maxPossible, | ||
| level: trustScore.level | ||
| } | ||
| }; | ||
| output(JSON.stringify(result, null, 2)); | ||
| return; | ||
| } | ||
| const clientList = installedClients.join(", "); | ||
| output(`\x1B[32mInstalled '${name}' successfully into: ${clientList}\x1B[0m`); | ||
| } | ||
| async function promptEnvVarsDefault(vars) { | ||
| if (vars.length === 0) return {}; | ||
| const result = {}; | ||
| for (const envVar of vars) { | ||
| if (!envVar.isRequired && !envVar.isSecret) continue; | ||
| const defaultVal = envVar.default ?? ""; | ||
| const promptMessage = envVar.description ? `${envVar.name} (${envVar.description}):` : `${envVar.name}:`; | ||
| let prompted; | ||
| if (envVar.isSecret) { | ||
| prompted = await password({ message: promptMessage }); | ||
| if (!prompted && defaultVal) { | ||
| prompted = defaultVal; | ||
| } | ||
| } else { | ||
| prompted = await input({ message: promptMessage, default: defaultVal }); | ||
| } | ||
| if (prompted) { | ||
| result[envVar.name] = prompted; | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| function parseSecretsMode(raw) { | ||
| if (raw !== "keychain" && raw !== "plaintext") { | ||
| throw new InvalidArgumentError( | ||
| `--secrets must be "keychain" or "plaintext", got: "${raw}"` | ||
| ); | ||
| } | ||
| return raw; | ||
| } | ||
| function parseMinTrust(raw) { | ||
| if (!/^\d+$/.test(raw)) { | ||
| throw new InvalidArgumentError( | ||
| `--min-trust must be an integer between 0 and 100, got: "${raw}"` | ||
| ); | ||
| } | ||
| const n = Number(raw); | ||
| if (n < 0 || n > 100) { | ||
| throw new InvalidArgumentError( | ||
| `--min-trust must be an integer between 0 and 100, got: "${raw}"` | ||
| ); | ||
| } | ||
| return n; | ||
| } | ||
| function parseMinReleaseAge(raw) { | ||
| if (!/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) { | ||
| throw new InvalidArgumentError( | ||
| `--min-release-age must be a non-negative integer number of hours, got: "${raw}"` | ||
| ); | ||
| } | ||
| return Number(raw); | ||
| } | ||
| function registerInstallCommand(program) { | ||
| program.command("install <name>").description("Install an MCP server from the registry").option("-c, --client <id>", "install to a specific client only").option("-y, --yes", "skip all confirmation prompts").option("-f, --force", "overwrite if server already installed").option("--skip-health-check", "skip post-install health check").option("--json", "output result as JSON").option("--min-trust <n>", "abort install if pre-install trust score is below this threshold (0-100; health check runs after install)", parseMinTrust).option("--min-release-age <hours>", "abort install if the release is younger than this many hours OR its publish timestamp is missing/unparseable (fail-closed when set; also sets the scoring cooldown threshold; bypass with --allow-fresh)", parseMinReleaseAge).option("--allow-fresh", "bypass the --min-release-age gate (including the missing-timestamp block)").option("--secrets <mode>", "where to store secret env vars: 'keychain' (encrypted in ~/.mcpm, resolved by mcpm guard at launch) or 'plaintext' (default)", parseSecretsMode).option("--allow-unguarded", "permit a URL/HTTP-transport server to run WITHOUT runtime guard inspection (no relay wraps a non-stdio transport); records consent so future installs stay quiet").action(async (name, opts) => { | ||
| const { RegistryClient } = await import("./client-3RPMRFZL.js"); | ||
| const client = new RegistryClient(); | ||
| const { readUnguardedConsent, writeUnguardedConsent, mergeUnguarded } = await import("./unguarded-GJO5WRM7.js"); | ||
| const installOptions = { | ||
| client: opts.client, | ||
| yes: opts.yes, | ||
| force: opts.force, | ||
| skipHealthCheck: opts.skipHealthCheck, | ||
| json: opts.json, | ||
| minTrust: opts.minTrust, | ||
| minReleaseAge: opts.minReleaseAge, | ||
| allowFresh: opts.allowFresh, | ||
| secrets: opts.secrets, | ||
| allowUnguarded: opts.allowUnguarded | ||
| }; | ||
| const installDeps = { | ||
| registryClient: client, | ||
| detectClients: detectInstalledClients, | ||
| getAdapter, | ||
| getConfigPath, | ||
| scanTier1, | ||
| checkScannerAvailable, | ||
| scanTier2: (serverName) => scanTier2(serverName), | ||
| computeTrustScore, | ||
| addToStore: addInstalledServer, | ||
| confirm, | ||
| promptEnvVars: promptEnvVarsDefault, | ||
| output: stdoutOutput, | ||
| setSecrets, | ||
| now: () => Date.now(), | ||
| readUnguardedConsent, | ||
| recordUnguardedConsent: async (names) => { | ||
| const previous = await readUnguardedConsent(); | ||
| await writeUnguardedConsent(mergeUnguarded(previous, names)); | ||
| } | ||
| }; | ||
| try { | ||
| await handleInstall(name, installOptions, installDeps); | ||
| } catch (err) { | ||
| if (installOptions.json !== true) { | ||
| console.error(chalk.red(err.message)); | ||
| } | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| } | ||
| export { | ||
| validateRemoteUrl, | ||
| resolveInstallEntry, | ||
| parseSecretsMode, | ||
| parseMinTrust, | ||
| registerInstallCommand | ||
| }; | ||
| //# sourceMappingURL=chunk-LNGTDYGN.js.map |
| {"version":3,"sources":["../src/commands/install.ts"],"sourcesContent":["/**\n * `mcpm install <name>` command handler.\n *\n * Wires together: registry fetch → trust assessment → user confirmation →\n * client detection → env var prompting → config write → store record.\n *\n * All external dependencies are injected for testability.\n *\n * Exports:\n * - handleInstall() — injectable handler for testing\n * - resolveInstallEntry() — pure function: ServerEntry + ClientId → McpServerEntry\n * - formatTrustScore() — pure function: TrustScore → formatted string\n * - registerInstallCommand() — Commander registration\n */\n\nimport { CLIENT_IDS } from \"../config/paths.js\";\nimport type { ClientId } from \"../config/paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"../config/adapters/index.js\";\nimport type { ServerEntry, EnvVar } from \"../registry/types.js\";\nimport { argvTokens, type RuntimeArgument } from \"../registry/argument-tokens.js\";\nimport type { Finding } from \"../scanner/tier1.js\";\nimport type { TrustScore, TrustScoreInput } from \"../scanner/trust-score.js\";\nimport type { InstalledServer } from \"../store/servers.js\";\nimport { scoreBar, levelColor, extractRegistryMeta } from \"../utils/format-trust.js\";\nimport { assessReleaseAge, DEFAULT_MIN_RELEASE_AGE_HOURS } from \"../scanner/cooldown.js\";\nimport { assessServerStatus } from \"../scanner/registry-status.js\";\nimport { DANGEROUS_FLAG_PREFIXES } from \"../scanner/patterns.js\";\nimport { applyKeychainSecrets, type SecretsMode, setSecrets as _setSecrets } from \"../store/keychain.js\";\n\n// ---------------------------------------------------------------------------\n// URL validation — guard against malicious remote URLs\n// ---------------------------------------------------------------------------\n\n/**\n * Validate a remote URL before it is written to any IDE config file.\n * Only http: and https: protocols are permitted.\n */\nexport function validateRemoteUrl(url: string): void {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(`Invalid remote URL: \"${url}\"`);\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") {\n throw new Error(\n `Remote URL must use http or https protocol, got: \"${parsed.protocol}\"`\n );\n }\n // M4a: plaintext http to a non-loopback host is interceptable once written to an\n // IDE config. Allow http only for loopback (local dev servers); require https for\n // every other host. https is always allowed.\n if (parsed.protocol === \"http:\" && !isLoopbackHost(parsed.hostname)) {\n throw new Error(\n `Remote URL must use https for non-loopback hosts (plaintext http is ` +\n `vulnerable to interception), got: \"${url}\"`\n );\n }\n}\n\n/**\n * True for localhost / loopback literals, where plaintext http is acceptable.\n * Recognizes localhost / *.localhost / 127.0.0.1 / ::1. Exotic loopback spellings\n * (IPv4-mapped `::ffff:127.0.0.1`, `127.x.x.x`, decimal/octal/hex IPs) are NOT\n * recognized and fall through to the https requirement — over-rejection only, never\n * a bypass (a non-loopback host can never be mistaken for loopback).\n */\nfunction isLoopbackHost(hostname: string): boolean {\n const h = hostname.toLowerCase().replace(/^\\[|\\]$/g, \"\"); // strip IPv6 brackets\n return (\n h === \"localhost\" ||\n h.endsWith(\".localhost\") ||\n h === \"127.0.0.1\" ||\n h === \"::1\"\n );\n}\n\nconst NPM_IDENTIFIER_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$/;\nconst PYPI_IDENTIFIER_RE = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/;\nconst OCI_IDENTIFIER_RE =\n /^[a-z0-9]+([._-][a-z0-9]+)*(\\/[a-z0-9]+([._-][a-z0-9]+)*)*:[a-zA-Z0-9._-]+$/;\n\n/**\n * Validate a package identifier against the expected pattern for its registry\n * type. Throws if the identifier looks potentially malicious.\n */\nexport function validateIdentifier(identifier: string, registryType: string): void {\n const patterns: Record<string, RegExp> = {\n npm: NPM_IDENTIFIER_RE,\n pypi: PYPI_IDENTIFIER_RE,\n oci: OCI_IDENTIFIER_RE,\n };\n const re = patterns[registryType];\n if (re && !re.test(identifier)) {\n throw new Error(\n `Rejected potentially malicious ${registryType} identifier: \"${identifier}\"`\n );\n }\n}\n\n/**\n * Render runtimeArguments from the registry into a launch argv slice.\n *\n * Delegates to argvTokens (name + value, never valueHint) so the SAME function\n * defines both what gets executed here and what the F4 dangerous-flag scan\n * matches in scanner/patterns.ts — they cannot diverge. valueHint (a\n * documentation placeholder like \"directory\") is deliberately not rendered:\n * emitting it would inject a bogus literal argument. The injection scanner\n * (scanner/tier1.ts) uses argumentTokens instead, which DOES read valueHint as\n * user-facing text; that divergence is intentional and documented there.\n */\nfunction normalizeRuntimeArgs(\n args: ReadonlyArray<RuntimeArgument>\n): string[] {\n return args.flatMap(argvTokens);\n}\n\n/**\n * Allowlist of safe runtime argument shapes.\n * After dangerous flags are rejected, arguments must match one of these\n * patterns. This blocks shell metacharacters and path traversal while\n * allowing the wide range of flags real MCP servers use.\n */\nconst SAFE_ARG_PATTERNS: readonly RegExp[] = [\n // Generic boolean flags (--allow-write, --read-only, --no-sandbox, etc.)\n /^--[a-zA-Z][\\w-]*$/,\n // Single-dash short flags the live registry legitimately declares (-i, -y, -p).\n // EXACTLY one alpha char — no bundled tail. Allowing a tail (-rmodule, -eCODE)\n // would let a dangerous flag bundle its payload and slip past the Layer-1\n // DANGEROUS_FLAG_PREFIXES check, which only rejects the exact token (-e/-r) or\n // its '=' form. The live registry's short flags are all single-letter, so the\n // narrow form loses no real coverage while closing the bundling bypass.\n /^-[a-zA-Z]$/,\n // Generic --key=value flags with safe value characters\n // Blocks shell metacharacters: ; | $ ` & ( ) { } < > ! ' \"\n /^--[a-zA-Z][\\w-]+=[\\w./@:, -]+$/,\n // Bare absolute paths (Unix: /path/to/dir)\n /^\\/[\\w.@/ -]+$/,\n // Home-relative paths (~/Documents)\n /^~[\\w.@/ -]*$/,\n // Bare positional arguments (no dashes, no path traversal)\n /^[a-zA-Z0-9][\\w.@/-]*$/,\n];\n\n/**\n * Validate runtime arguments from the registry.\n * Two-layer defense: reject known-dangerous Node.js flags first,\n * then require remaining args to match safe structural patterns.\n */\nexport function validateRuntimeArgs(args: string[]): void {\n for (const arg of args) {\n // Layer 0 (M4b): reject a \"..\" path-traversal segment anywhere in the argument\n // — \"../x\", \"a/../../etc/passwd\", \"--config=../secret\". A \"..\" segment is one\n // bounded by start-of-arg, \"=\" (flag value), or a path separator on the left,\n // and a separator or end-of-arg on the right. The Layer-2 allowlist permits \".\"\n // and \"/\" inside values, so without this a traversal would slip through; a\n // non-traversal double dot like \"--range=1..10\" is left untouched.\n if (/(?:^|[=\\\\/])\\.\\.(?:[\\\\/]|$)/.test(arg)) {\n throw new Error(`Rejected path traversal in runtime argument: \"${arg}\"`);\n }\n\n // Layer 1: reject dangerous Node.js flags\n const isDangerous = DANGEROUS_FLAG_PREFIXES.some(\n (prefix) => arg === prefix || arg.startsWith(`${prefix}=`)\n );\n if (isDangerous) {\n throw new Error(`Rejected dangerous runtime argument: \"${arg}\"`);\n }\n\n // Layer 2: require safe structural pattern\n const isSafe = SAFE_ARG_PATTERNS.some((pattern) => pattern.test(arg));\n if (!isSafe) {\n throw new Error(`Rejected unrecognized runtime argument: \"${arg}\"`);\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface InstallOptions {\n client?: string;\n yes?: boolean;\n force?: boolean;\n skipHealthCheck?: boolean;\n json?: boolean;\n minTrust?: number;\n minReleaseAge?: number;\n allowFresh?: boolean;\n secrets?: SecretsMode;\n /**\n * H9 (fail-closed): per-invocation consent (`--allow-unguarded`) to install a\n * URL/HTTP-transport server that runs UNGUARDED (the guard relay only wraps a\n * stdio transport — a non-stdio remote gets ZERO runtime inspection). When\n * neither this nor a name already in the persistent consent store grants it,\n * such a server is DENIED. DISTINCT from `allowUrlServers`, the MCP-surface\n * kill-switch: `allowUrlServers === false` ALWAYS wins.\n */\n allowUnguarded?: boolean;\n /**\n * Whether URL/HTTP-transport servers may be installed at all. DEFAULT\n * (undefined/true) preserves CLI behavior. The MCP surface passes `false` so a\n * url-transport server is recorded as blocked instead of written to a config —\n * an untrusted caller can never reach the unguarded run path.\n */\n allowUrlServers?: boolean;\n}\n\nexport interface InstallDeps {\n registryClient: { getServer: (name: string) => Promise<ServerEntry> };\n detectClients: () => Promise<ClientId[]>;\n getAdapter: (clientId: ClientId) => ConfigAdapter;\n getConfigPath: (clientId: ClientId) => string;\n scanTier1: (server: ServerEntry) => Finding[];\n checkScannerAvailable: () => Promise<boolean>;\n scanTier2: (name: string) => Promise<Finding[]>;\n computeTrustScore: (input: TrustScoreInput) => TrustScore;\n addToStore: (server: InstalledServer) => Promise<void>;\n confirm: (message: string) => Promise<boolean>;\n promptEnvVars: (vars: EnvVar[]) => Promise<Record<string, string>>;\n output: (text: string) => void;\n /** Optional; required only when options.secrets === \"keychain\". */\n setSecrets?: (server: string, values: Record<string, string>) => Promise<void>;\n /** Epoch-ms clock for release-age assessment; defaults to Date.now at the CLI boundary. */\n now?: () => number;\n /**\n * H9: read the persistent set of server names previously consented to run\n * unguarded. Injectable for tests; defaults to the real store at the CLI\n * boundary. When omitted, no server is treated as previously-consented.\n */\n readUnguardedConsent?: () => Promise<string[]>;\n /**\n * H9: persist (union into the store) the name newly consented to run\n * unguarded. Injectable for tests; defaults to the real store. Called once\n * after a url server is installed under fresh consent.\n */\n recordUnguardedConsent?: (names: readonly string[]) => Promise<void>;\n}\n\n// ---------------------------------------------------------------------------\n// resolveInstallEntry — pure function, no I/O\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve the McpServerEntry for a given server + clientId.\n *\n * Decision tree:\n * 1. Cursor + server has HTTP remote → produce { url, headers } entry\n * 2. Otherwise pick from packages[]: npm → pypi → oci (first available)\n * 3. npm: { command: 'npx', args: ['-y', identifier, ...runtimeArgs], env }\n * 4. pypi: { command: 'uvx', args: [identifier, ...runtimeArgs], env }\n * 5. docker: { command: 'docker', args: ['run', '--rm', '-i', image], env }\n * 6. If no packages and no usable remote: throw\n */\nexport function resolveInstallEntry(\n serverEntry: ServerEntry,\n clientId: ClientId\n): McpServerEntry {\n const { server } = serverEntry;\n\n // Rule 1: Cursor + HTTP remote → streamable-http entry\n if (clientId === \"cursor\" && server.remotes && server.remotes.length > 0) {\n const httpRemote = server.remotes.find(\n (r) => r.type === \"streamable-http\" || r.type === \"sse\"\n );\n if (httpRemote) {\n validateRemoteUrl(httpRemote.url);\n // Build headers record if any\n const headers: Record<string, string> = {};\n for (const h of httpRemote.headers) {\n headers[h.name] = \"\";\n }\n return {\n url: httpRemote.url,\n ...(Object.keys(headers).length > 0 ? { headers } : {}),\n };\n }\n }\n\n // Rule 2: Pick best package by priority: npm → pypi → oci\n const npmPkg = server.packages.find((p) => p.registryType === \"npm\");\n const pypiPkg = server.packages.find((p) => p.registryType === \"pypi\");\n const ociPkg = server.packages.find((p) => p.registryType === \"oci\");\n\n if (npmPkg) {\n validateIdentifier(npmPkg.identifier, \"npm\");\n const rtArgs = normalizeRuntimeArgs(npmPkg.runtimeArguments ?? []);\n validateRuntimeArgs(rtArgs);\n return {\n command: \"npx\",\n args: [\"-y\", npmPkg.identifier, ...rtArgs],\n };\n }\n\n if (pypiPkg) {\n validateIdentifier(pypiPkg.identifier, \"pypi\");\n const rtArgs = normalizeRuntimeArgs(pypiPkg.runtimeArguments ?? []);\n validateRuntimeArgs(rtArgs);\n return {\n command: \"uvx\",\n args: [pypiPkg.identifier, ...rtArgs],\n };\n }\n\n if (ociPkg) {\n validateIdentifier(ociPkg.identifier, \"oci\");\n const rtArgs = normalizeRuntimeArgs(ociPkg.runtimeArguments ?? []);\n validateRuntimeArgs(rtArgs);\n return {\n command: \"docker\",\n args: [\"run\", \"--rm\", \"-i\", ociPkg.identifier, ...rtArgs],\n };\n }\n\n // Rule 3: Cursor-only path — HTTP remote with no packages\n if (clientId === \"cursor\" && server.remotes && server.remotes.length > 0) {\n const remote = server.remotes[0];\n validateRemoteUrl(remote.url);\n return { url: remote.url };\n }\n\n throw new Error(\n `No install path found for server \"${server.name}\": no packages and no compatible remotes.`\n );\n}\n\n// ---------------------------------------------------------------------------\n// formatTrustScore — pure function, rich display\n// ---------------------------------------------------------------------------\n\n/**\n * Format a trust score as a visual progress bar with breakdown details.\n */\nexport function formatTrustScore(trustScore: TrustScore): string {\n const { score, maxPossible, level, breakdown } = trustScore;\n\n const levelLabel = levelColor(level.toUpperCase());\n const bar = scoreBar(score, maxPossible);\n\n const lines: string[] = [\n `${bar} ${score}/${maxPossible} ${levelLabel}`,\n ` \\u251C\\u2500 Health check: ${breakdown.healthCheck > 0 ? \"not yet run\" : \"failed or skipped\"}`,\n ` \\u251C\\u2500 Tool descriptions: ${breakdown.staticScan === 40 ? \"CLEAN (no injection patterns)\" : `score ${breakdown.staticScan}/40`}`,\n ` \\u251C\\u2500 Package: publisher verification ${breakdown.registryMeta > 0 ? \"passed\" : \"unverified\"}`,\n ` \\u2514\\u2500 External scan: ${breakdown.externalScan > 0 ? `passed (${breakdown.externalScan}/20)` : \"not available (set MCPM_EXTERNAL_SCANNER for deeper analysis)\"}`,\n ];\n\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// handleInstall — main handler\n// ---------------------------------------------------------------------------\n\n/**\n * Core handler for `mcpm install <name>`.\n * All dependencies are injected for hermetic testability.\n */\nexport async function handleInstall(\n name: string,\n options: InstallOptions,\n deps: InstallDeps\n): Promise<void> {\n const {\n registryClient,\n detectClients,\n getAdapter,\n getConfigPath,\n scanTier1,\n checkScannerAvailable,\n scanTier2,\n computeTrustScore,\n addToStore,\n confirm,\n promptEnvVars,\n output,\n } = deps;\n\n // -------------------------------------------------------------------------\n // Step 1: Fetch server metadata\n // -------------------------------------------------------------------------\n const serverEntry = await registryClient.getServer(name);\n\n // -------------------------------------------------------------------------\n // Step 1b: registry-delisting gate (fail closed, before any scan/output)\n // -------------------------------------------------------------------------\n // If the registry itself marks this server \"deleted\" (removed/withdrawn),\n // refuse to install. Fail-SAFE: ONLY an explicit \"deleted\" blocks; a\n // \"deprecated\" or absent/unknown status does not (surfaced as an advisory\n // finding by scanTier1 instead). See scanner/registry-status.ts.\n const statusGate = assessServerStatus(serverEntry);\n if (statusGate.blocks) {\n if (options.json === true) {\n output(\n JSON.stringify(\n {\n name,\n error: \"server_delisted\",\n status: statusGate.status,\n message: statusGate.statusMessage ?? null,\n },\n null,\n 2\n )\n );\n }\n throw new Error(\n `\"${name}\" has been deleted from the MCP registry${statusGate.statusMessage ? ` (${statusGate.statusMessage})` : \"\"}. Installation aborted.`\n );\n }\n\n // -------------------------------------------------------------------------\n // Step 2: Trust assessment\n // -------------------------------------------------------------------------\n const tier1Findings = scanTier1(serverEntry);\n const scannerAvailable = await checkScannerAvailable();\n\n let allFindings: Finding[] = [...tier1Findings];\n if (scannerAvailable) {\n const tier2Findings = await scanTier2(name);\n allFindings = [...allFindings, ...tier2Findings];\n }\n\n // Release-age cooldown: assessed ONCE so the score finding and the Step 2c\n // gate can never disagree — passing --min-release-age below 24 therefore also\n // lowers the scoring cooldown threshold (documented in the flag help text).\n // The medium finding lands unconditionally for fresh releases, with or\n // without the gate — that is the inversion fix, independent of the gate.\n const registryMeta = extractRegistryMeta(serverEntry);\n const releaseAge = assessReleaseAge({\n publishedAt: registryMeta.publishedAt,\n now: (deps.now ?? Date.now)(),\n minAgeHours: options.minReleaseAge ?? DEFAULT_MIN_RELEASE_AGE_HOURS,\n });\n if (releaseAge.finding) {\n allFindings = [...allFindings, releaseAge.finding];\n }\n\n const trustScoreInput: TrustScoreInput = {\n findings: allFindings,\n healthCheckPassed: null, // health check not yet run at this point\n hasExternalScanner: scannerAvailable,\n registryMeta,\n };\n\n const trustScore = computeTrustScore(trustScoreInput);\n\n // -------------------------------------------------------------------------\n // Step 2b: --min-trust gate (checked before any output or confirmation)\n // -------------------------------------------------------------------------\n if (options.minTrust !== undefined && trustScore.score < options.minTrust) {\n if (options.json === true) {\n output(\n JSON.stringify(\n {\n name,\n error: \"min_trust_not_met\",\n score: trustScore.score,\n maxPossible: trustScore.maxPossible,\n required: options.minTrust,\n level: trustScore.level,\n },\n null,\n 2\n )\n );\n }\n // The denominator is 80 unless the external-scanner bucket was credited, which is\n // the default — so a hardcoded /100 understated every score by 20 points of scale\n // and made a flawless 62/80 (77.5%) read as 62%.\n throw new Error(\n `Trust score ${trustScore.score}/${trustScore.maxPossible} is below the required minimum of ${options.minTrust}. Installation aborted.`\n );\n }\n\n // -------------------------------------------------------------------------\n // Step 2c: --min-release-age gate (checked before any output or confirmation)\n // -------------------------------------------------------------------------\n // Fail-closed when armed: a MISSING publish timestamp blocks too (blocksArmedGate)\n // — otherwise a registry/compromised mirror could defeat the gate by omitting\n // _meta (publishedAt is .optional() in OfficialMetaSchema). The score finding\n // stays fail-open for absent; only the explicitly armed gate hardens.\n if (\n options.minReleaseAge !== undefined &&\n options.allowFresh !== true &&\n releaseAge.blocksArmedGate\n ) {\n if (options.json === true) {\n output(\n JSON.stringify(\n {\n name,\n error: \"release_age_not_met\",\n ageHours: releaseAge.ageHours,\n required: options.minReleaseAge,\n reason: releaseAge.status,\n },\n null,\n 2\n )\n );\n }\n const tail = \"Installation aborted. Use --allow-fresh to bypass.\";\n throw new Error(\n releaseAge.status === \"future\"\n ? `Release publish timestamp is in the future (clock skew or forged metadata); treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}`\n : releaseAge.status === \"unparseable\"\n ? `Release publish timestamp could not be parsed; treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}`\n : releaseAge.status === \"absent\"\n ? `Release publish timestamp is missing from the registry metadata, so release age cannot be verified against the ${options.minReleaseAge}-hour minimum. ${tail}`\n : `Release age ${releaseAge.ageHours}h is below the required minimum of ${options.minReleaseAge}h. ${tail}`\n );\n }\n\n // -------------------------------------------------------------------------\n // Step 3: Display trust score and confirm\n // -------------------------------------------------------------------------\n // In --json mode suppress all human-readable output; only the final JSON\n // is written to stdout.\n const jsonMode = options.json === true;\n\n if (!jsonMode) {\n output(formatTrustScore(trustScore));\n output(\"\");\n }\n\n if (options.yes !== true) {\n let shouldProceed: boolean;\n\n if (trustScore.level === \"risky\") {\n if (!jsonMode) {\n output(\"\\u001b[31mWARNING: This server has a low trust score and may be risky to install.\\u001b[0m\");\n output(\"\\u001b[31mSecurity findings indicate potential dangers. Proceed with extreme caution.\\u001b[0m\");\n }\n shouldProceed = await confirm(\n \"I understand the risks and want to install this server anyway. Continue?\"\n );\n } else if (trustScore.level === \"caution\") {\n if (!jsonMode) {\n output(\"\\u001b[33mCAUTION: This server has a moderate trust score. Review the details above.\\u001b[0m\");\n }\n shouldProceed = await confirm(`Install '${name}'? (caution recommended)`);\n } else {\n // GREEN — brief display, proceed\n shouldProceed = await confirm(`Install '${name}'?`);\n }\n\n if (!shouldProceed) {\n if (!jsonMode) output(\"Installation cancelled.\");\n return;\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 4: Detect and filter clients\n // -------------------------------------------------------------------------\n let targetClients = await detectClients();\n\n if (targetClients.length === 0) {\n throw new Error(\n \"No supported AI clients found. Install Claude Desktop, Cursor, VS Code, or Windsurf first.\"\n );\n }\n\n if (options.client !== undefined) {\n if (!CLIENT_IDS.includes(options.client as ClientId)) {\n throw new Error(\n `Unknown client \"${options.client}\". Valid values: ${CLIENT_IDS.join(\", \")}.`\n );\n }\n const requestedId = options.client as ClientId;\n if (!targetClients.includes(requestedId)) {\n throw new Error(\n `Client \"${requestedId}\" is not installed on this machine.`\n );\n }\n targetClients = [requestedId];\n }\n\n // -------------------------------------------------------------------------\n // Step 5: Check for already-installed (unless --force)\n // -------------------------------------------------------------------------\n if (options.force !== true) {\n for (const clientId of targetClients) {\n const adapter = getAdapter(clientId);\n const configPath = getConfigPath(clientId);\n const existing = await adapter.read(configPath);\n if (Object.prototype.hasOwnProperty.call(existing, name)) {\n throw new Error(\n `Server '${name}' is already installed in ${clientId}. Use --force to overwrite.`\n );\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 6: Resolve env vars to prompt for\n // -------------------------------------------------------------------------\n // Collect env vars from the best-match package\n const { server } = serverEntry;\n const bestPkg =\n server.packages.find((p) => p.registryType === \"npm\") ??\n server.packages.find((p) => p.registryType === \"pypi\") ??\n server.packages.find((p) => p.registryType === \"oci\") ??\n server.packages[0];\n\n const envVarDefs: EnvVar[] = bestPkg?.environmentVariables ?? [];\n const resolvedEnvVars = await promptEnvVars(envVarDefs);\n\n // Step 6b: In keychain mode, persist secret-flagged values encrypted and swap\n // them for `mcpm:keychain:…` placeholders, so no plaintext is written to any\n // client config. Non-secret vars stay inline; each secret is stored once and\n // reused for every client. The placeholder resolves at launch only while mcpm\n // guard wraps the server (run-inner.ts → resolveEnvPlaceholders). The swap\n // (and the \"no plaintext in config\" invariant) lives in applyKeychainSecrets.\n const secretsMode: SecretsMode = options.secrets ?? \"plaintext\";\n const { env: envForConfig, storedCount: storedSecretCount } = await applyKeychainSecrets({\n serverName: name,\n resolvedEnv: resolvedEnvVars,\n isSecret: (key) => envVarDefs.find((d) => d.name === key)?.isSecret === true,\n mode: secretsMode,\n setSecrets: deps.setSecrets,\n });\n\n // -------------------------------------------------------------------------\n // Step 7: Resolve (and thereby validate) each client's entry up front\n // -------------------------------------------------------------------------\n // resolveInstallEntry throws on an invalid identifier, so resolving here\n // before any config is written preserves fail-fast validation. The resolved\n // entries are reused in Step 8 to avoid recomputing them.\n const resolvedEntries = new Map<ClientId, McpServerEntry>();\n for (const clientId of targetClients) {\n resolvedEntries.set(clientId, resolveInstallEntry(serverEntry, clientId));\n }\n\n // -------------------------------------------------------------------------\n // Step 7b: H9 fail-closed gate for URL/HTTP-transport servers\n // -------------------------------------------------------------------------\n // A resolved entry with a `url` and no `command` runs UNGUARDED — the guard\n // relay only wraps a stdio process, so a non-stdio remote gets ZERO runtime\n // inspection. Mirror processUrlServer (up.ts): the MCP-surface kill-switch\n // (allowUrlServers === false) ALWAYS wins; otherwise DENY unless explicit\n // informed consent (`--allow-unguarded` this run, or a name already in the\n // persistent consent store). This is informed consent, NOT protection.\n const isUnguardedEntry = [...resolvedEntries.values()].some(\n (e) => e.url !== undefined && e.command === undefined\n );\n if (isUnguardedEntry) {\n if (options.allowUrlServers === false) {\n throw new Error(\n `Server '${name}' uses a URL/HTTP transport and is not permitted via the MCP surface.`\n );\n }\n const previousConsented = deps.readUnguardedConsent\n ? await deps.readUnguardedConsent()\n : [];\n const alreadyConsented = previousConsented.includes(name);\n const consented = options.allowUnguarded === true || alreadyConsented;\n if (!consented) {\n throw new Error(\n `Server '${name}' uses a URL/HTTP transport and runs UNGUARDED — no runtime ` +\n `inspection is possible (mcpm's guard relay only wraps stdio servers). ` +\n `Re-run with --allow-unguarded to install it WITHOUT protection.`\n );\n }\n // First-time consent: warn once and persist so a future install stays quiet.\n if (!alreadyConsented) {\n if (!jsonMode) {\n output(\n \"\\x1b[33m⚠ UNGUARDED: this URL/HTTP-transport server runs WITHOUT runtime \" +\n \"inspection (the guard relay only wraps stdio servers). This grants consent — \" +\n \"it does NOT add protection. The only true fix is a streamable-HTTP relay \" +\n \"(not yet implemented).\\x1b[0m\"\n );\n }\n if (deps.recordUnguardedConsent) {\n await deps.recordUnguardedConsent([name]).catch(() => undefined);\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 8: Write config to each client and record in store\n // -------------------------------------------------------------------------\n const installedClients: ClientId[] = [];\n\n for (const clientId of targetClients) {\n const adapter = getAdapter(clientId);\n const configPath = getConfigPath(clientId);\n const rawEntry = resolvedEntries.get(clientId)!;\n\n // Merge env vars into the entry (immutable). In keychain mode envForConfig\n // carries placeholders in place of secret values; otherwise it === resolvedEnvVars.\n const entry: McpServerEntry = {\n ...rawEntry,\n ...(Object.keys(envForConfig).length > 0\n ? { env: { ...(rawEntry.env ?? {}), ...envForConfig } }\n : {}),\n };\n\n await adapter.addServer(configPath, name, entry, { force: options.force });\n installedClients.push(clientId);\n }\n\n // -------------------------------------------------------------------------\n // Step 8b: Secret-storage notice\n // -------------------------------------------------------------------------\n if (!options.json) {\n if (secretsMode === \"keychain\" && storedSecretCount > 0) {\n output(\n `\\x1b[32mStored ${storedSecretCount} secret(s) encrypted at rest in ~/.mcpm. ` +\n \"With an OS keychain this protects against other-user/offline access (not \" +\n \"same-user processes); without one a machine-derived key is used that guards \" +\n \"casual local inspection only, NOT file exfiltration — run `mcpm secrets migrate` \" +\n \"once a keychain is available. \" +\n \"Run `mcpm guard enable` (then restart your IDE) so they resolve at launch — \" +\n \"until guard wraps this server it receives the literal placeholder.\\x1b[0m\"\n );\n } else {\n const hasSecrets = envVarDefs.some((ev) => ev.isSecret && resolvedEnvVars[ev.name]);\n if (hasSecrets) {\n output(\n \"\\x1b[33mNote: API keys are stored as plaintext in client config files. \" +\n \"Ensure config files have appropriate permissions (chmod 600).\\x1b[0m\"\n );\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 9: Record in store\n // -------------------------------------------------------------------------\n const storeEntry: InstalledServer = {\n name,\n version: serverEntry.server.version,\n clients: [...installedClients],\n installedAt: new Date().toISOString(),\n };\n await addToStore(storeEntry);\n\n // -------------------------------------------------------------------------\n // Step 10: Output result\n // -------------------------------------------------------------------------\n if (options.json === true) {\n const result = {\n name,\n version: serverEntry.server.version,\n clients: installedClients,\n trustScore: {\n score: trustScore.score,\n maxPossible: trustScore.maxPossible,\n level: trustScore.level,\n },\n };\n output(JSON.stringify(result, null, 2));\n return;\n }\n\n const clientList = installedClients.join(\", \");\n output(`\\u001b[32mInstalled '${name}' successfully into: ${clientList}\\u001b[0m`);\n}\n\n// ---------------------------------------------------------------------------\n// Commander registration\n// ---------------------------------------------------------------------------\n\nimport { Command, InvalidArgumentError } from \"commander\";\nimport chalk from \"chalk\";\nimport { input, password } from \"@inquirer/prompts\";\nimport { detectInstalledClients as _detectClients } from \"../config/detector.js\";\nimport { getConfigPath as _getConfigPath } from \"../config/paths.js\";\nimport { addInstalledServer as _addToStore } from \"../store/servers.js\";\nimport { scanTier1 as _scanTier1 } from \"../scanner/tier1.js\";\nimport { checkScannerAvailable as _checkScannerAvailable, scanTier2 as _scanTier2 } from \"../scanner/tier2.js\";\nimport { computeTrustScore as _computeTrustScore } from \"../scanner/trust-score.js\";\nimport { getAdapter as getAdapterDefault } from \"../config/index.js\";\nimport { confirm } from \"../utils/confirm.js\";\nimport { stdoutOutput } from \"../utils/output.js\";\n\nasync function promptEnvVarsDefault(\n vars: EnvVar[]\n): Promise<Record<string, string>> {\n if (vars.length === 0) return {};\n\n const result: Record<string, string> = {};\n for (const envVar of vars) {\n if (!envVar.isRequired && !envVar.isSecret) continue;\n\n const defaultVal = envVar.default ?? \"\";\n const promptMessage = envVar.description\n ? `${envVar.name} (${envVar.description}):`\n : `${envVar.name}:`;\n\n let prompted: string;\n if (envVar.isSecret) {\n // Use password prompt to mask secret input — value is never echoed to the terminal\n prompted = await password({ message: promptMessage });\n if (!prompted && defaultVal) {\n prompted = defaultVal;\n }\n } else {\n prompted = await input({ message: promptMessage, default: defaultVal });\n }\n\n if (prompted) {\n result[envVar.name] = prompted;\n }\n }\n return result;\n}\n\nexport function parseSecretsMode(raw: string): SecretsMode {\n if (raw !== \"keychain\" && raw !== \"plaintext\") {\n throw new InvalidArgumentError(\n `--secrets must be \"keychain\" or \"plaintext\", got: \"${raw}\"`\n );\n }\n return raw;\n}\n\nexport function parseMinTrust(raw: string): number {\n // Reject anything that isn't plain decimal digits (blocks hex \"0x50\", scientific\n // notation \"1e2\", spaces, empty string, and negative sign before range check).\n if (!/^\\d+$/.test(raw)) {\n throw new InvalidArgumentError(\n `--min-trust must be an integer between 0 and 100, got: \"${raw}\"`\n );\n }\n const n = Number(raw);\n if (n < 0 || n > 100) {\n throw new InvalidArgumentError(\n `--min-trust must be an integer between 0 and 100, got: \"${raw}\"`\n );\n }\n return n;\n}\n\nexport function parseMinReleaseAge(raw: string): number {\n // Same regex-first discipline as parseMinTrust: blocks hex \"0x18\", \"1e2\",\n // spaces, empty string, negatives. Safe-integer check guards absurd lengths.\n if (!/^\\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) {\n throw new InvalidArgumentError(\n `--min-release-age must be a non-negative integer number of hours, got: \"${raw}\"`\n );\n }\n return Number(raw);\n}\n\nexport function registerInstallCommand(program: Command): void {\n program\n .command(\"install <name>\")\n .description(\"Install an MCP server from the registry\")\n .option(\"-c, --client <id>\", \"install to a specific client only\")\n .option(\"-y, --yes\", \"skip all confirmation prompts\")\n .option(\"-f, --force\", \"overwrite if server already installed\")\n .option(\"--skip-health-check\", \"skip post-install health check\")\n .option(\"--json\", \"output result as JSON\")\n .option(\"--min-trust <n>\", \"abort install if pre-install trust score is below this threshold (0-100; health check runs after install)\", parseMinTrust)\n .option(\"--min-release-age <hours>\", \"abort install if the release is younger than this many hours OR its publish timestamp is missing/unparseable (fail-closed when set; also sets the scoring cooldown threshold; bypass with --allow-fresh)\", parseMinReleaseAge)\n .option(\"--allow-fresh\", \"bypass the --min-release-age gate (including the missing-timestamp block)\")\n .option(\"--secrets <mode>\", \"where to store secret env vars: 'keychain' (encrypted in ~/.mcpm, resolved by mcpm guard at launch) or 'plaintext' (default)\", parseSecretsMode)\n .option(\"--allow-unguarded\", \"permit a URL/HTTP-transport server to run WITHOUT runtime guard inspection (no relay wraps a non-stdio transport); records consent so future installs stay quiet\")\n .action(async (name: string, opts: { client?: string; yes?: boolean; force?: boolean; skipHealthCheck?: boolean; json?: boolean; minTrust?: number; minReleaseAge?: number; allowFresh?: boolean; secrets?: SecretsMode; allowUnguarded?: boolean }) => {\n const { RegistryClient } = await import(\"../registry/client.js\");\n const client = new RegistryClient();\n const { readUnguardedConsent, writeUnguardedConsent, mergeUnguarded } = await import(\n \"../guard/unguarded.js\"\n );\n\n const installOptions: InstallOptions = {\n client: opts.client,\n yes: opts.yes,\n force: opts.force,\n skipHealthCheck: opts.skipHealthCheck,\n json: opts.json,\n minTrust: opts.minTrust,\n minReleaseAge: opts.minReleaseAge,\n allowFresh: opts.allowFresh,\n secrets: opts.secrets,\n allowUnguarded: opts.allowUnguarded,\n };\n\n const installDeps: InstallDeps = {\n registryClient: client,\n detectClients: _detectClients,\n getAdapter: getAdapterDefault,\n getConfigPath: _getConfigPath,\n scanTier1: _scanTier1,\n checkScannerAvailable: _checkScannerAvailable,\n scanTier2: (serverName: string) => _scanTier2(serverName),\n computeTrustScore: _computeTrustScore,\n addToStore: _addToStore,\n confirm,\n promptEnvVars: promptEnvVarsDefault,\n output: stdoutOutput,\n setSecrets: _setSecrets,\n now: () => Date.now(),\n readUnguardedConsent,\n recordUnguardedConsent: async (names) => {\n const previous = await readUnguardedConsent();\n await writeUnguardedConsent(mergeUnguarded(previous, names));\n },\n };\n\n try {\n await handleInstall(name, installOptions, installDeps);\n } catch (err) {\n if (installOptions.json !== true) {\n console.error(chalk.red((err as Error).message));\n }\n process.exit(1);\n }\n });\n}\n"],"mappings":"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","names":["getAdapter","getConfigPath","scanTier1","checkScannerAvailable","scanTier2","computeTrustScore","confirm"]} |
| #!/usr/bin/env node | ||
| import { | ||
| getStorePath, | ||
| readJson, | ||
| withStoreLock, | ||
| writeJson | ||
| } from "./chunk-3X76P3FG.js"; | ||
| // src/store/keychain.ts | ||
| import { createHash, randomBytes, webcrypto } from "crypto"; | ||
| import os from "os"; | ||
| // src/store/os-keychain.ts | ||
| import { spawn } from "child_process"; | ||
| import { readFile, writeFile } from "fs/promises"; | ||
| import path from "path"; | ||
| var SERVICE = "mcpm"; | ||
| var ACCOUNT = "secret-store-master-key"; | ||
| var DPAPI_BLOB_FILE = "master-key.dpapi"; | ||
| var EXEC_TIMEOUT_MS = 5e3; | ||
| function run(command, args, opts = {}) { | ||
| return new Promise((resolve) => { | ||
| const child = spawn(command, args, { | ||
| timeout: EXEC_TIMEOUT_MS, | ||
| env: opts.env ?? process.env, | ||
| stdio: ["pipe", "pipe", "pipe"] | ||
| }); | ||
| let stdout = ""; | ||
| let stderr = ""; | ||
| let settled = false; | ||
| const settle = (r) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| resolve(r); | ||
| }; | ||
| child.stdout.on("data", (d) => stdout += d.toString()); | ||
| child.stderr.on("data", (d) => stderr += d.toString()); | ||
| child.on("error", () => settle({ code: null, stdout, stderr })); | ||
| child.on("close", (code) => settle({ code, stdout, stderr })); | ||
| if (opts.input !== void 0) { | ||
| child.stdin.on("error", () => { | ||
| }); | ||
| child.stdin.end(opts.input); | ||
| } else { | ||
| child.stdin.end(); | ||
| } | ||
| }); | ||
| } | ||
| function isSupportedPlatform() { | ||
| if (process.env.MCPM_DISABLE_OS_KEYCHAIN === "1") return false; | ||
| return process.platform === "darwin" || process.platform === "linux" || process.platform === "win32"; | ||
| } | ||
| async function darwinGet() { | ||
| const r = await run("security", [ | ||
| "find-generic-password", | ||
| "-a", | ||
| ACCOUNT, | ||
| "-s", | ||
| SERVICE, | ||
| "-w" | ||
| ]); | ||
| if (r.code !== 0) return null; | ||
| return decodeKey(r.stdout.trim()); | ||
| } | ||
| async function darwinStore(keyB64) { | ||
| const r = await run("security", [ | ||
| "add-generic-password", | ||
| "-a", | ||
| ACCOUNT, | ||
| "-s", | ||
| SERVICE, | ||
| "-U", | ||
| "-w", | ||
| keyB64 | ||
| ]); | ||
| return r.code === 0; | ||
| } | ||
| async function linuxGet() { | ||
| const r = await run("secret-tool", ["lookup", "service", SERVICE, "account", ACCOUNT]); | ||
| if (r.code !== 0) return null; | ||
| const value = r.stdout.trim(); | ||
| if (value.length === 0) return null; | ||
| return decodeKey(value); | ||
| } | ||
| async function linuxStore(keyB64) { | ||
| const r = await run( | ||
| "secret-tool", | ||
| ["store", "--label=mcpm secret store master key", "service", SERVICE, "account", ACCOUNT], | ||
| { input: keyB64 } | ||
| ); | ||
| return r.code === 0; | ||
| } | ||
| var PS_PROTECT = "$ErrorActionPreference='Stop';Add-Type -AssemblyName System.Security;$b=[Convert]::FromBase64String($env:MCPM_KEY_B64);$p=[Security.Cryptography.ProtectedData]::Protect($b,$null,'CurrentUser');[Convert]::ToBase64String($p)"; | ||
| var PS_UNPROTECT = "$ErrorActionPreference='Stop';Add-Type -AssemblyName System.Security;$b=[Convert]::FromBase64String($env:MCPM_BLOB_B64);$p=[Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser');[Convert]::ToBase64String($p)"; | ||
| async function blobPath() { | ||
| return path.join(await getStorePath(), DPAPI_BLOB_FILE); | ||
| } | ||
| async function windowsGet() { | ||
| let blob; | ||
| try { | ||
| blob = (await readFile(await blobPath(), "utf8")).trim(); | ||
| } catch { | ||
| return null; | ||
| } | ||
| if (blob.length === 0) return null; | ||
| const r = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", PS_UNPROTECT], { | ||
| env: { ...process.env, MCPM_BLOB_B64: blob } | ||
| }); | ||
| if (r.code !== 0) return null; | ||
| return decodeKey(r.stdout.trim()); | ||
| } | ||
| async function windowsStore(keyB64) { | ||
| const r = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", PS_PROTECT], { | ||
| env: { ...process.env, MCPM_KEY_B64: keyB64 } | ||
| }); | ||
| if (r.code !== 0 || r.stdout.trim().length === 0) return false; | ||
| try { | ||
| await writeFile(await blobPath(), r.stdout.trim(), { mode: 384 }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function decodeKey(b64) { | ||
| try { | ||
| const buf = Buffer.from(b64, "base64"); | ||
| return buf.length === 32 ? buf : null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function getStoredKey() { | ||
| if (!isSupportedPlatform()) return null; | ||
| switch (process.platform) { | ||
| case "darwin": | ||
| return darwinGet(); | ||
| case "linux": | ||
| return linuxGet(); | ||
| case "win32": | ||
| return windowsGet(); | ||
| default: | ||
| return null; | ||
| } | ||
| } | ||
| async function storeKey(key) { | ||
| if (!isSupportedPlatform()) return false; | ||
| if (key.length !== 32) return false; | ||
| const keyB64 = key.toString("base64"); | ||
| switch (process.platform) { | ||
| case "darwin": | ||
| return darwinStore(keyB64); | ||
| case "linux": | ||
| return linuxStore(keyB64); | ||
| case "win32": | ||
| return windowsStore(keyB64); | ||
| default: | ||
| return false; | ||
| } | ||
| } | ||
| // src/store/keychain.ts | ||
| var STORE_FILE = "secrets.enc.json"; | ||
| var PLACEHOLDER_PREFIX = "mcpm:keychain:"; | ||
| var PBKDF2_ITERATIONS = 6e5; | ||
| var SCHEME_KEYCHAIN = "k1"; | ||
| var HKDF_INFO = new TextEncoder().encode("mcpm-secret-store-v1"); | ||
| var MACHINE_PASSPHRASE = new TextEncoder().encode( | ||
| `mcpm:${os.hostname()}:${os.userInfo().username}` | ||
| ); | ||
| var SAFE_ID_RE = /^[a-zA-Z0-9._-]{1,256}$/; | ||
| function assertSafeId(value, label) { | ||
| if (!SAFE_ID_RE.test(value)) { | ||
| throw new Error(`Invalid ${label}: "${value}" \u2014 must match [a-zA-Z0-9._-], max 256 chars`); | ||
| } | ||
| } | ||
| function validatedStoreKey(server, key) { | ||
| assertSafeId(server, "server"); | ||
| assertSafeId(key, "key"); | ||
| return `${server}/${key}`; | ||
| } | ||
| var _masterKey; | ||
| async function readMasterKey() { | ||
| if (_masterKey) return _masterKey.value; | ||
| const value = await getStoredKey(); | ||
| _masterKey = { value }; | ||
| return value; | ||
| } | ||
| async function getOrCreateMasterKey() { | ||
| const existing = await readMasterKey(); | ||
| if (existing) return existing; | ||
| if (!isSupportedPlatform()) return null; | ||
| return withStoreLock(async () => { | ||
| const raced = await getStoredKey(); | ||
| if (raced) { | ||
| _masterKey = { value: raced }; | ||
| return raced; | ||
| } | ||
| const key = randomBytes(32); | ||
| const value = await storeKey(key) ? key : null; | ||
| _masterKey = { value }; | ||
| return value; | ||
| }); | ||
| } | ||
| var _hkdfMaterial; | ||
| function hkdfMaterial(masterKey) { | ||
| if (!_hkdfMaterial || !_hkdfMaterial.key.equals(masterKey)) { | ||
| _hkdfMaterial = { | ||
| key: masterKey, | ||
| material: webcrypto.subtle.importKey("raw", masterKey, "HKDF", false, ["deriveKey"]) | ||
| }; | ||
| } | ||
| return _hkdfMaterial.material; | ||
| } | ||
| async function deriveKeychainKey(masterKey, salt) { | ||
| const material = await hkdfMaterial(masterKey); | ||
| return webcrypto.subtle.deriveKey( | ||
| { name: "HKDF", salt, info: HKDF_INFO, hash: "SHA-256" }, | ||
| material, | ||
| { name: "AES-GCM", length: 256 }, | ||
| false, | ||
| ["encrypt", "decrypt"] | ||
| ); | ||
| } | ||
| var _keyMaterialPromise = null; | ||
| function getMachineKeyMaterial() { | ||
| if (!_keyMaterialPromise) { | ||
| _keyMaterialPromise = webcrypto.subtle.importKey( | ||
| "raw", | ||
| MACHINE_PASSPHRASE, | ||
| { name: "PBKDF2" }, | ||
| false, | ||
| ["deriveKey"] | ||
| ); | ||
| } | ||
| return _keyMaterialPromise; | ||
| } | ||
| async function deriveMachineKey(salt) { | ||
| const keyMaterial = await getMachineKeyMaterial(); | ||
| return webcrypto.subtle.deriveKey( | ||
| { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, | ||
| keyMaterial, | ||
| { name: "AES-GCM", length: 256 }, | ||
| false, | ||
| ["encrypt", "decrypt"] | ||
| ); | ||
| } | ||
| var hex = (buf) => Buffer.from(buf instanceof Uint8Array ? buf : new Uint8Array(buf)).toString("hex"); | ||
| async function encrypt(plaintext) { | ||
| const salt = randomBytes(16); | ||
| const iv = randomBytes(12); | ||
| const masterKey = await getOrCreateMasterKey(); | ||
| const key = masterKey ? await deriveKeychainKey(masterKey, salt) : await deriveMachineKey(salt); | ||
| const cipherBuf = await webcrypto.subtle.encrypt( | ||
| { name: "AES-GCM", iv }, | ||
| key, | ||
| new TextEncoder().encode(plaintext) | ||
| ); | ||
| const body = [hex(salt), hex(iv), hex(cipherBuf)].join(":"); | ||
| return masterKey ? `${SCHEME_KEYCHAIN}:${body}` : body; | ||
| } | ||
| async function decryptWith(key, ivHex, cipherHex) { | ||
| const plainBuf = await webcrypto.subtle.decrypt( | ||
| { name: "AES-GCM", iv: Buffer.from(ivHex, "hex") }, | ||
| key, | ||
| Buffer.from(cipherHex, "hex") | ||
| ); | ||
| return new TextDecoder().decode(plainBuf); | ||
| } | ||
| async function decrypt(stored) { | ||
| const parts = stored.split(":"); | ||
| if (parts.length === 4 && parts[0] === SCHEME_KEYCHAIN) { | ||
| const [, saltHex, ivHex, cipherHex] = parts; | ||
| const masterKey = await readMasterKey(); | ||
| if (!masterKey) { | ||
| throw new Error( | ||
| "Cannot decrypt: this secret is protected by the OS keychain master key, which is unavailable on this machine/account (or the keychain entry was removed)." | ||
| ); | ||
| } | ||
| const key = await deriveKeychainKey(masterKey, Buffer.from(saltHex, "hex")); | ||
| return decryptWith(key, ivHex, cipherHex); | ||
| } | ||
| if (parts.length === 3) { | ||
| const [saltHex, ivHex, cipherHex] = parts; | ||
| const key = await deriveMachineKey(Buffer.from(saltHex, "hex")); | ||
| return decryptWith(key, ivHex, cipherHex); | ||
| } | ||
| throw new Error("Invalid ciphertext format"); | ||
| } | ||
| async function readStore() { | ||
| return await readJson(STORE_FILE) ?? {}; | ||
| } | ||
| async function decryptFromSnapshot(store, sk) { | ||
| const stored = store[sk]; | ||
| return stored ? decrypt(stored) : null; | ||
| } | ||
| async function setSecret(server, key, value) { | ||
| const sk = validatedStoreKey(server, key); | ||
| const encrypted = await encrypt(value); | ||
| await withStoreLock(async () => { | ||
| const store = await readStore(); | ||
| await writeJson(STORE_FILE, { ...store, [sk]: encrypted }); | ||
| }); | ||
| } | ||
| async function setSecrets(server, values) { | ||
| const encrypted = {}; | ||
| for (const [key, value] of Object.entries(values)) { | ||
| encrypted[validatedStoreKey(server, key)] = await encrypt(value); | ||
| } | ||
| await withStoreLock(async () => { | ||
| const store = await readStore(); | ||
| await writeJson(STORE_FILE, { ...store, ...encrypted }); | ||
| }); | ||
| } | ||
| async function getSecret(server, key) { | ||
| const sk = validatedStoreKey(server, key); | ||
| return decryptFromSnapshot(await readStore(), sk); | ||
| } | ||
| async function deleteSecret(server, key) { | ||
| const sk = validatedStoreKey(server, key); | ||
| let removed = false; | ||
| await withStoreLock(async () => { | ||
| const store = await readStore(); | ||
| if (!(sk in store)) return; | ||
| const { [sk]: _removed, ...rest } = store; | ||
| await writeJson(STORE_FILE, rest); | ||
| removed = true; | ||
| }); | ||
| return removed; | ||
| } | ||
| function toPlaceholder(server, key) { | ||
| return `${PLACEHOLDER_PREFIX}${server}/${key}`; | ||
| } | ||
| function parsePlaceholder(value) { | ||
| if (!value.startsWith(PLACEHOLDER_PREFIX)) return null; | ||
| const rest = value.slice(PLACEHOLDER_PREFIX.length); | ||
| const slashIdx = rest.indexOf("/"); | ||
| if (slashIdx === -1) return null; | ||
| return { server: rest.slice(0, slashIdx), key: rest.slice(slashIdx + 1) }; | ||
| } | ||
| async function resolveEnvPlaceholders(env) { | ||
| const passthrough = {}; | ||
| const placeholders = []; | ||
| for (const [name, value] of Object.entries(env)) { | ||
| if (value === void 0) continue; | ||
| const placeholder = parsePlaceholder(value); | ||
| if (placeholder === null) { | ||
| passthrough[name] = value; | ||
| continue; | ||
| } | ||
| placeholders.push({ name, ...placeholder }); | ||
| } | ||
| if (placeholders.length === 0) return passthrough; | ||
| return withStoreLock(async () => { | ||
| const store = await readStore(); | ||
| const resolved = { ...passthrough }; | ||
| for (const { name, server, key } of placeholders) { | ||
| const sk = validatedStoreKey(server, key); | ||
| const secret = await decryptFromSnapshot(store, sk); | ||
| if (secret === null) { | ||
| throw new Error( | ||
| `Secret "${server}/${key}" not found. Run \`mcpm secrets set ${server} ${key}\` to store it.` | ||
| ); | ||
| } | ||
| resolved[name] = secret; | ||
| } | ||
| return resolved; | ||
| }); | ||
| } | ||
| function deriveKeychainId(name) { | ||
| const sanitized = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 200); | ||
| const hash = createHash("sha256").update(name).digest("hex").slice(0, 12); | ||
| return `${sanitized}-${hash}`; | ||
| } | ||
| async function listAll() { | ||
| const store = await readStore(); | ||
| const grouped = {}; | ||
| for (const storeKey2 of Object.keys(store)) { | ||
| const slashIdx = storeKey2.indexOf("/"); | ||
| if (slashIdx === -1) continue; | ||
| const server = storeKey2.slice(0, slashIdx); | ||
| const key = storeKey2.slice(slashIdx + 1); | ||
| (grouped[server] ??= []).push(key); | ||
| } | ||
| return grouped; | ||
| } | ||
| async function activeSecretBackend() { | ||
| return await readMasterKey() !== null ? "os-keychain" : "machine-key"; | ||
| } | ||
| async function migrateToKeychain() { | ||
| const masterKey = await getOrCreateMasterKey(); | ||
| if (!masterKey) return { migrated: 0, failed: 0, total: 0, usingKeychain: false }; | ||
| return withStoreLock(async () => { | ||
| const store = await readStore(); | ||
| const entries = Object.entries(store); | ||
| const next = {}; | ||
| let migrated = 0; | ||
| let failed = 0; | ||
| for (const [sk, value] of entries) { | ||
| const isLegacy = value.split(":").length === 3; | ||
| if (!isLegacy) { | ||
| next[sk] = value; | ||
| continue; | ||
| } | ||
| try { | ||
| const plain = await decrypt(value); | ||
| next[sk] = await encrypt(plain); | ||
| migrated++; | ||
| } catch { | ||
| next[sk] = value; | ||
| failed++; | ||
| } | ||
| } | ||
| if (migrated > 0) await writeJson(STORE_FILE, next); | ||
| return { migrated, failed, total: entries.length, usingKeychain: true }; | ||
| }); | ||
| } | ||
| async function applyKeychainSecrets(opts) { | ||
| if (opts.mode !== "keychain") { | ||
| return { env: opts.resolvedEnv, storedCount: 0 }; | ||
| } | ||
| if (!opts.setSecrets) { | ||
| throw new Error("Keychain secret storage is unavailable."); | ||
| } | ||
| const keychainId = deriveKeychainId(opts.serverName); | ||
| const env = {}; | ||
| const toStore = {}; | ||
| for (const [key, value] of Object.entries(opts.resolvedEnv)) { | ||
| if (opts.isSecret(key)) { | ||
| toStore[key] = value; | ||
| env[key] = toPlaceholder(keychainId, key); | ||
| } else { | ||
| env[key] = value; | ||
| } | ||
| } | ||
| const storedCount = Object.keys(toStore).length; | ||
| if (storedCount > 0) { | ||
| await opts.setSecrets(keychainId, toStore); | ||
| } | ||
| return { env, storedCount }; | ||
| } | ||
| function placeholderEnvKeys(env) { | ||
| if (!env) return []; | ||
| return Object.entries(env).filter(([, v]) => typeof v === "string" && parsePlaceholder(v) !== null).map(([k]) => k); | ||
| } | ||
| export { | ||
| isSupportedPlatform, | ||
| setSecret, | ||
| setSecrets, | ||
| getSecret, | ||
| deleteSecret, | ||
| toPlaceholder, | ||
| parsePlaceholder, | ||
| resolveEnvPlaceholders, | ||
| deriveKeychainId, | ||
| listAll, | ||
| activeSecretBackend, | ||
| migrateToKeychain, | ||
| applyKeychainSecrets, | ||
| placeholderEnvKeys | ||
| }; | ||
| //# sourceMappingURL=chunk-NPJ3SGGS.js.map |
| {"version":3,"sources":["../src/store/keychain.ts","../src/store/os-keychain.ts"],"sourcesContent":["/**\n * Encrypted-at-rest secret storage using Node's built-in crypto.subtle.\n * No native dependencies (contrast: keytar requires node-gyp).\n *\n * Two encryption schemes (security #15):\n * - keychain (\"k1:\" tag): AES-GCM key derived via HKDF from a random 32-byte\n * master key held in the OS credential store (store/os-keychain.ts). The\n * master key never touches disk, so a copied secrets.enc.json cannot be\n * decrypted on another machine/account — real exfiltration resistance.\n * - machine (legacy, untagged): AES-GCM key derived via PBKDF2 from\n * hostname+username. This is NOT a secret (it is recoverable by anyone who\n * copies the store file), so it guards only against casual local inspection.\n * Used as a fallback where no OS keychain is available (headless/CI) and to\n * decrypt entries written before the keychain upgrade.\n *\n * New secrets use the keychain scheme whenever an OS keychain is available;\n * `migrateToKeychain()` upgrades pre-existing machine-scheme entries.\n *\n * Storage format: ~/.mcpm/secrets.enc.json\n * { \"server/KEY\": \"k1:<salt_hex>:<iv_hex>:<ct_hex>\" } // keychain scheme\n * { \"server/KEY\": \"<salt_hex>:<iv_hex>:<ct_hex>\" } // legacy machine scheme\n *\n * Placeholder format used in config files:\n * \"mcpm:keychain:server/KEY\"\n */\n\nimport { createHash, randomBytes, webcrypto } from \"node:crypto\";\nimport os from \"node:os\";\nimport { readJson, writeJson } from \"./index.js\";\nimport { withStoreLock } from \"./atomic.js\";\nimport { getStoredKey, isSupportedPlatform, storeKey } from \"./os-keychain.js\";\n\nconst STORE_FILE = \"secrets.enc.json\";\nconst PLACEHOLDER_PREFIX = \"mcpm:keychain:\";\nconst PBKDF2_ITERATIONS = 600_000;\n\n// Scheme tag prefixing keychain-scheme entries: \"k1:<salt>:<iv>:<ct>\". Legacy\n// machine-scheme entries are unprefixed (\"<salt>:<iv>:<ct>\"), so decrypt() routes\n// on the part count — keeping old entries readable after the upgrade (issue #15).\nconst SCHEME_KEYCHAIN = \"k1\";\nconst HKDF_INFO = new TextEncoder().encode(\"mcpm-secret-store-v1\");\n\n// Legacy/fallback machine passphrase. This is NOT a secret: hostname + username\n// are recoverable by anyone who copies the store file, so the machine scheme\n// guards only against casual local inspection — never file exfiltration. The\n// keychain scheme (OS-held master key, below) supersedes it whenever an OS\n// credential store is available; this remains for environments without one\n// (headless/CI) and to decrypt pre-existing entries (issue #15).\nconst MACHINE_PASSPHRASE = new TextEncoder().encode(\n `mcpm:${os.hostname()}:${os.userInfo().username}`\n);\n\n// ---------------------------------------------------------------------------\n// Input validation\n// ---------------------------------------------------------------------------\n\nconst SAFE_ID_RE = /^[a-zA-Z0-9._-]{1,256}$/;\n\nfunction assertSafeId(value: string, label: string): void {\n if (!SAFE_ID_RE.test(value)) {\n throw new Error(`Invalid ${label}: \"${value}\" — must match [a-zA-Z0-9._-], max 256 chars`);\n }\n}\n\nfunction validatedStoreKey(server: string, key: string): string {\n assertSafeId(server, \"server\");\n assertSafeId(key, \"key\");\n return `${server}/${key}`;\n}\n\n// ---------------------------------------------------------------------------\n// Master key (OS credential store) — security #15\n// ---------------------------------------------------------------------------\n//\n// A single random 32-byte key lives in the OS keychain (store/os-keychain.ts);\n// per-value AES keys are derived from it via HKDF. Because the master key is\n// never written to ~/.mcpm, a copied secrets.enc.json cannot be decrypted on\n// another machine/account. When no OS keychain is available, encrypt() falls\n// back to the machine scheme below.\n//\n// Byte annotations below spell out `<ArrayBuffer>` on purpose. Bare `Buffer` /\n// `Uint8Array` default to `ArrayBufferLike` (which includes SharedArrayBuffer),\n// and @types/node 26 narrowed WebCrypto's `BufferSource` to reject shared views,\n// so a bare annotation no longer assigns to importKey/deriveKey. The values were\n// always correct — `randomBytes()` and `Buffer.from(hex)` are both ArrayBuffer-\n// backed — only the annotations were wider. Do not widen them back: under the\n// pinned @types/node 22 that compiles clean, and CI's per-leg typecheck against\n// @types/node@<matrix node> is the only thing that catches it.\n\nlet _masterKey: { value: Buffer<ArrayBuffer> | null } | undefined;\n\n/** Read the stored master key (never creates one); memoized per process. */\nasync function readMasterKey(): Promise<Buffer<ArrayBuffer> | null> {\n if (_masterKey) return _masterKey.value;\n const value = await getStoredKey();\n _masterKey = { value };\n return value;\n}\n\n/**\n * Return the master key, creating and persisting a fresh random one if none\n * exists yet and the platform has a usable credential store. Creation runs\n * under the store lock so two concurrent first-writes cannot generate two keys\n * and leave one writer's secret undecryptable.\n */\nasync function getOrCreateMasterKey(): Promise<Buffer<ArrayBuffer> | null> {\n const existing = await readMasterKey();\n if (existing) return existing;\n if (!isSupportedPlatform()) return null;\n return withStoreLock(async () => {\n const raced = await getStoredKey(); // another process may have just created it\n if (raced) {\n _masterKey = { value: raced };\n return raced;\n }\n const key = randomBytes(32);\n // If the keychain write fails, memoize null: this session falls back to the\n // machine scheme for the rest of its life (no retry). That is the safe,\n // honest outcome — activeSecretBackend() will report \"machine-key\" and\n // `secrets set` warns the user — rather than half-using an unpersisted key.\n const value = (await storeKey(key)) ? key : null;\n _masterKey = { value };\n return value;\n });\n}\n\n// ---------------------------------------------------------------------------\n// Key derivation — keychain scheme (HKDF) and machine scheme (PBKDF2)\n// ---------------------------------------------------------------------------\n\n// HKDF importKey is cheap but stable per master key; cache it per-process.\nlet _hkdfMaterial: { key: Buffer<ArrayBuffer>; material: Promise<webcrypto.CryptoKey> } | undefined;\n\nfunction hkdfMaterial(masterKey: Buffer<ArrayBuffer>): Promise<webcrypto.CryptoKey> {\n if (!_hkdfMaterial || !_hkdfMaterial.key.equals(masterKey)) {\n _hkdfMaterial = {\n key: masterKey,\n material: webcrypto.subtle.importKey(\"raw\", masterKey, \"HKDF\", false, [\"deriveKey\"]),\n };\n }\n return _hkdfMaterial.material;\n}\n\nasync function deriveKeychainKey(\n masterKey: Buffer<ArrayBuffer>,\n salt: Uint8Array<ArrayBuffer>\n): Promise<webcrypto.CryptoKey> {\n const material = await hkdfMaterial(masterKey);\n return webcrypto.subtle.deriveKey(\n { name: \"HKDF\", salt, info: HKDF_INFO, hash: \"SHA-256\" },\n material,\n { name: \"AES-GCM\", length: 256 },\n false,\n [\"encrypt\", \"decrypt\"]\n );\n}\n\n// Cache the PBKDF2 importKey step — it is cheap but never varies within a\n// process. The expensive derivation still runs per value (random per-value salt).\nlet _keyMaterialPromise: Promise<webcrypto.CryptoKey> | null = null;\n\nfunction getMachineKeyMaterial(): Promise<webcrypto.CryptoKey> {\n if (!_keyMaterialPromise) {\n _keyMaterialPromise = webcrypto.subtle.importKey(\n \"raw\",\n MACHINE_PASSPHRASE,\n { name: \"PBKDF2\" },\n false,\n [\"deriveKey\"]\n );\n }\n return _keyMaterialPromise;\n}\n\nasync function deriveMachineKey(salt: Uint8Array<ArrayBuffer>): Promise<webcrypto.CryptoKey> {\n const keyMaterial = await getMachineKeyMaterial();\n return webcrypto.subtle.deriveKey(\n { name: \"PBKDF2\", salt, iterations: PBKDF2_ITERATIONS, hash: \"SHA-256\" },\n keyMaterial,\n { name: \"AES-GCM\", length: 256 },\n false,\n [\"encrypt\", \"decrypt\"]\n );\n}\n\n// ---------------------------------------------------------------------------\n// Encryption helpers\n// ---------------------------------------------------------------------------\n\nconst hex = (buf: ArrayBuffer | Uint8Array): string =>\n Buffer.from(buf instanceof Uint8Array ? buf : new Uint8Array(buf)).toString(\"hex\");\n\nasync function encrypt(plaintext: string): Promise<string> {\n const salt = randomBytes(16);\n const iv = randomBytes(12);\n const masterKey = await getOrCreateMasterKey();\n const key = masterKey\n ? await deriveKeychainKey(masterKey, salt)\n : await deriveMachineKey(salt);\n const cipherBuf = await webcrypto.subtle.encrypt(\n { name: \"AES-GCM\", iv },\n key,\n new TextEncoder().encode(plaintext)\n );\n const body = [hex(salt), hex(iv), hex(cipherBuf)].join(\":\");\n // Keychain-scheme entries are tagged so decrypt() can route; machine-scheme\n // entries stay in the legacy unprefixed format for backward compatibility.\n return masterKey ? `${SCHEME_KEYCHAIN}:${body}` : body;\n}\n\nasync function decryptWith(\n key: webcrypto.CryptoKey,\n ivHex: string,\n cipherHex: string\n): Promise<string> {\n const plainBuf = await webcrypto.subtle.decrypt(\n { name: \"AES-GCM\", iv: Buffer.from(ivHex, \"hex\") },\n key,\n Buffer.from(cipherHex, \"hex\")\n );\n return new TextDecoder().decode(plainBuf);\n}\n\nasync function decrypt(stored: string): Promise<string> {\n const parts = stored.split(\":\");\n // Keychain scheme: \"k1:<salt>:<iv>:<ct>\"\n if (parts.length === 4 && parts[0] === SCHEME_KEYCHAIN) {\n const [, saltHex, ivHex, cipherHex] = parts;\n const masterKey = await readMasterKey();\n if (!masterKey) {\n throw new Error(\n \"Cannot decrypt: this secret is protected by the OS keychain master key, \" +\n \"which is unavailable on this machine/account (or the keychain entry was removed).\"\n );\n }\n const key = await deriveKeychainKey(masterKey, Buffer.from(saltHex, \"hex\"));\n return decryptWith(key, ivHex, cipherHex);\n }\n // Legacy machine scheme: \"<salt>:<iv>:<ct>\"\n if (parts.length === 3) {\n const [saltHex, ivHex, cipherHex] = parts;\n const key = await deriveMachineKey(Buffer.from(saltHex, \"hex\"));\n return decryptWith(key, ivHex, cipherHex);\n }\n throw new Error(\"Invalid ciphertext format\");\n}\n\n// ---------------------------------------------------------------------------\n// Store helpers\n// ---------------------------------------------------------------------------\n\nasync function readStore(): Promise<Record<string, string>> {\n return (await readJson<Record<string, string>>(STORE_FILE)) ?? {};\n}\n\n// Decrypt a single stored value identified by its store key against an\n// already-loaded store snapshot. Shared by the unlocked per-key getSecret and\n// the locked snapshot resolver so both decode entries identically.\nasync function decryptFromSnapshot(\n store: Record<string, string>,\n sk: string\n): Promise<string | null> {\n const stored = store[sk];\n return stored ? decrypt(stored) : null;\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport async function setSecret(server: string, key: string, value: string): Promise<void> {\n const sk = validatedStoreKey(server, key);\n // Encrypt outside the lock (it does not depend on stored state) to keep the\n // critical section short, then read-merge-write atomically under the lock.\n const encrypted = await encrypt(value);\n await withStoreLock(async () => {\n const store = await readStore();\n await writeJson(STORE_FILE, { ...store, [sk]: encrypted });\n });\n}\n\n/**\n * Store multiple secrets for one server in a single read-modify-write, so the\n * batch is all-or-nothing: either every value is persisted or none is (no\n * orphaned half-written secrets if one encrypt fails — security review MED-1).\n */\nexport async function setSecrets(\n server: string,\n values: Record<string, string>\n): Promise<void> {\n // Encrypt every value first (no dependency on stored state), then read-merge-\n // write atomically under the lock so a concurrent writer cannot lost-update\n // this batch.\n const encrypted: Record<string, string> = {};\n for (const [key, value] of Object.entries(values)) {\n encrypted[validatedStoreKey(server, key)] = await encrypt(value);\n }\n await withStoreLock(async () => {\n const store = await readStore();\n await writeJson(STORE_FILE, { ...store, ...encrypted });\n });\n}\n\n// Intentionally UNLOCKED: a read-only single-key lookup. It accepts eventual\n// consistency (it may observe a concurrent writer's snapshot before or after a\n// mutation, never a torn one — writeJson swaps the file atomically via rename).\n// The consistency-sensitive path is resolveEnvPlaceholders, which takes the\n// lock and reads one snapshot for all keys.\nexport async function getSecret(server: string, key: string): Promise<string | null> {\n const sk = validatedStoreKey(server, key);\n return decryptFromSnapshot(await readStore(), sk);\n}\n\n/** Returns true if a secret was removed, false if no such secret existed. */\nexport async function deleteSecret(server: string, key: string): Promise<boolean> {\n const sk = validatedStoreKey(server, key);\n let removed = false;\n await withStoreLock(async () => {\n const store = await readStore();\n if (!(sk in store)) return;\n const { [sk]: _removed, ...rest } = store;\n await writeJson(STORE_FILE, rest);\n removed = true;\n });\n return removed;\n}\n\n/** Produces the placeholder string stored in config files. */\nexport function toPlaceholder(server: string, key: string): string {\n return `${PLACEHOLDER_PREFIX}${server}/${key}`;\n}\n\n/** Parses a placeholder string. Returns null if not a placeholder. */\nexport function parsePlaceholder(value: string): { server: string; key: string } | null {\n if (!value.startsWith(PLACEHOLDER_PREFIX)) return null;\n const rest = value.slice(PLACEHOLDER_PREFIX.length);\n const slashIdx = rest.indexOf(\"/\");\n if (slashIdx === -1) return null;\n return { server: rest.slice(0, slashIdx), key: rest.slice(slashIdx + 1) };\n}\n\n/**\n * Resolve any `mcpm:keychain:server/KEY` placeholder values in an env map to\n * their decrypted secrets. Non-placeholder values pass through unchanged;\n * `undefined` values are dropped. Throws if a placeholder references a secret\n * that is not stored.\n *\n * The decrypted values exist only in the returned in-memory object — they are\n * never written to disk. `mcpm guard run --inner` calls this to inject secrets\n * into a wrapped server's child process without storing plaintext in client\n * config files.\n *\n * Reads the store as a single CONSISTENT SNAPSHOT under `withStoreLock`: all\n * placeholders are resolved against one read taken while holding the same lock\n * the write paths (setSecret/setSecrets/deleteSecret) hold. This closes the\n * read-after-delete race where a concurrent `secrets delete` during guard\n * startup made a per-key unlocked lookup observe a torn state and throw \"Secret\n * not found\" — the exact race the lock was added to prevent. This is the only\n * caller (guard/run-inner.ts), invoked at the top level and NOT from inside an\n * already-held store lock, so acquiring the lock here cannot self-deadlock.\n */\nexport async function resolveEnvPlaceholders(\n env: NodeJS.ProcessEnv\n): Promise<Record<string, string>> {\n // Parse placeholders up front so the locked critical section is just one read\n // plus decryption — no validation or iteration over non-placeholder values.\n const passthrough: Record<string, string> = {};\n const placeholders: Array<{ name: string; server: string; key: string }> = [];\n for (const [name, value] of Object.entries(env)) {\n if (value === undefined) continue;\n const placeholder = parsePlaceholder(value);\n if (placeholder === null) {\n passthrough[name] = value;\n continue;\n }\n placeholders.push({ name, ...placeholder });\n }\n\n // No secrets to resolve — skip the lock entirely.\n if (placeholders.length === 0) return passthrough;\n\n return withStoreLock(async () => {\n const store = await readStore();\n const resolved: Record<string, string> = { ...passthrough };\n for (const { name, server, key } of placeholders) {\n const sk = validatedStoreKey(server, key);\n const secret = await decryptFromSnapshot(store, sk);\n if (secret === null) {\n throw new Error(\n `Secret \"${server}/${key}\" not found. ` +\n `Run \\`mcpm secrets set ${server} ${key}\\` to store it.`\n );\n }\n resolved[name] = secret;\n }\n return resolved;\n });\n}\n\n/**\n * Derive a keychain-safe server id from a (possibly slash-containing) server\n * name. Registry ids like \"io.github.owner/repo\" contain `/`, which is invalid\n * for a keychain id (assertSafeId) and would break placeholder parsing (which\n * splits on the first `/`).\n *\n * The sanitised prefix keeps the id human-recognisable; a sha256 suffix makes\n * the mapping INJECTIVE, so two names that differ only in unsafe characters\n * (e.g. \"owner/repo\" vs \"owner_repo\") can never collide into one secret\n * namespace (security review CRIT-1). Deterministic: the same name always maps\n * to the same id, so a placeholder written at install resolves at launch.\n */\nexport function deriveKeychainId(name: string): string {\n const sanitized = name.replace(/[^a-zA-Z0-9._-]/g, \"_\").slice(0, 200);\n const hash = createHash(\"sha256\").update(name).digest(\"hex\").slice(0, 12);\n return `${sanitized}-${hash}`;\n}\n\n/**\n * List all stored secrets grouped by server name. Returns only key names —\n * decrypted values are never read or returned.\n *\n * Intentionally UNLOCKED: read-only enumeration, eventual consistency.\n */\nexport async function listAll(): Promise<Record<string, string[]>> {\n const store = await readStore();\n const grouped: Record<string, string[]> = {};\n for (const storeKey of Object.keys(store)) {\n const slashIdx = storeKey.indexOf(\"/\");\n if (slashIdx === -1) continue;\n const server = storeKey.slice(0, slashIdx);\n const key = storeKey.slice(slashIdx + 1);\n (grouped[server] ??= []).push(key);\n }\n return grouped;\n}\n\n// ---------------------------------------------------------------------------\n// Backend status + migration (security #15)\n// ---------------------------------------------------------------------------\n\n/**\n * Which backend protected the most recent write — a backward-looking reflection,\n * not a forward-looking prediction. Returns \"os-keychain\" only when a master key\n * is actually present. Intended to be called AFTER a `setSecret`/`migrate` (as\n * `mcpm secrets set` does), where it accurately reports the scheme just used —\n * including reporting \"machine-key\" when a keychain write silently failed and\n * fell back. Called standalone before any write on a keychain-capable machine\n * with no key yet, it returns \"machine-key\" (no key created yet); the next write\n * would create one and use \"os-keychain\".\n */\nexport async function activeSecretBackend(): Promise<\"os-keychain\" | \"machine-key\"> {\n return (await readMasterKey()) !== null ? \"os-keychain\" : \"machine-key\";\n}\n\n/**\n * Re-encrypt every legacy machine-scheme entry under the OS keychain master\n * key, so existing secrets gain the same exfiltration resistance as new ones.\n *\n * No-op (`usingKeychain: false`) when no OS keychain is available. Per-entry\n * failures are isolated: a legacy entry this machine key can no longer decrypt\n * (e.g. written on a different machine) is counted in `failed` and left\n * untouched rather than aborting the whole migration.\n */\nexport async function migrateToKeychain(): Promise<{\n migrated: number;\n failed: number;\n total: number;\n usingKeychain: boolean;\n}> {\n const masterKey = await getOrCreateMasterKey();\n if (!masterKey) return { migrated: 0, failed: 0, total: 0, usingKeychain: false };\n return withStoreLock(async () => {\n const store = await readStore();\n const entries = Object.entries(store);\n const next: Record<string, string> = {};\n let migrated = 0;\n let failed = 0;\n for (const [sk, value] of entries) {\n const isLegacy = value.split(\":\").length === 3;\n if (!isLegacy) {\n next[sk] = value; // already keychain-scheme (or unknown) — leave as-is\n continue;\n }\n try {\n const plain = await decrypt(value); // legacy machine scheme\n next[sk] = await encrypt(plain); // re-encrypt under the keychain master key\n migrated++;\n } catch {\n next[sk] = value; // undecryptable legacy entry — leave untouched\n failed++;\n }\n }\n if (migrated > 0) await writeJson(STORE_FILE, next);\n return { migrated, failed, total: entries.length, usingKeychain: true };\n });\n}\n\n/**\n * How secret-flagged env vars are persisted.\n * - \"plaintext\": written directly into the client config (legacy default).\n * - \"keychain\": stored AES-GCM-encrypted; config gets a `mcpm:keychain:…`\n * placeholder that mcpm guard resolves at launch.\n */\nexport type SecretsMode = \"plaintext\" | \"keychain\";\n\n/**\n * Resolve a server's env map for writing to a client config under the given\n * secrets mode. In \"keychain\" mode, every key for which `isSecret(key)` is true\n * is stored encrypted via `setSecret` and replaced with a `mcpm:keychain:…`\n * placeholder; all other values pass through. In \"plaintext\" mode the input is\n * returned unchanged. This is the single place the \"no plaintext secret in\n * config\" invariant is enforced — install and up both go through it.\n *\n * Throws if keychain mode is requested without a `setSecret` implementation.\n */\nexport async function applyKeychainSecrets(opts: {\n serverName: string;\n resolvedEnv: Record<string, string>;\n isSecret: (key: string) => boolean;\n mode: SecretsMode;\n setSecrets?: (server: string, values: Record<string, string>) => Promise<void>;\n}): Promise<{ env: Record<string, string>; storedCount: number }> {\n if (opts.mode !== \"keychain\") {\n return { env: opts.resolvedEnv, storedCount: 0 };\n }\n if (!opts.setSecrets) {\n throw new Error(\"Keychain secret storage is unavailable.\");\n }\n const keychainId = deriveKeychainId(opts.serverName);\n const env: Record<string, string> = {};\n const toStore: Record<string, string> = {};\n for (const [key, value] of Object.entries(opts.resolvedEnv)) {\n if (opts.isSecret(key)) {\n toStore[key] = value;\n env[key] = toPlaceholder(keychainId, key);\n } else {\n env[key] = value;\n }\n }\n const storedCount = Object.keys(toStore).length;\n // Persist all secrets in one atomic batch BEFORE returning the env that the\n // caller writes to config — so we never write a placeholder for a secret that\n // failed to store (all-or-nothing; security review MED-1).\n if (storedCount > 0) {\n await opts.setSecrets(keychainId, toStore);\n }\n return { env, storedCount };\n}\n\n/**\n * Return the keys of `env` whose value is a `mcpm:keychain:…` placeholder.\n * Used by `mcpm guard disable` to warn about secrets that will no longer\n * resolve once guard stops wrapping the server.\n */\nexport function placeholderEnvKeys(env: Record<string, string> | undefined): string[] {\n if (!env) return [];\n return Object.entries(env)\n .filter(([, v]) => typeof v === \"string\" && parsePlaceholder(v) !== null)\n .map(([k]) => k);\n}\n","/**\n * Zero-native-dependency access to the operating system's credential store.\n *\n * The secret store (store/keychain.ts) holds a single random 32-byte *master\n * key* here; every stored secret is AES-GCM-encrypted with a subkey derived\n * from it. Keeping the master key in the OS credential store — not on disk —\n * is what makes a copied `~/.mcpm/secrets.enc.json` undecryptable off-machine\n * (security issue #15).\n *\n * No native modules (no `keytar`/node-gyp). We shell out to the platform's\n * built-in tooling:\n * - macOS: `security` (login Keychain) — generic password item\n * - Linux: `secret-tool` (libsecret/Secret Service) — schema attributes\n * - Windows: DPAPI via PowerShell `ProtectedData` — blob in ~/.mcpm\n *\n * Every operation is best-effort: if the platform tool is missing, the Secret\n * Service is unavailable (headless Linux, CI), or any call fails, the function\n * resolves to \"unavailable\"/null and the caller falls back to the legacy\n * machine-derived key (casual-inspection only — see store/keychain.ts).\n *\n * Set `MCPM_DISABLE_OS_KEYCHAIN=1` to force the fallback (used by the test\n * suite so it never touches the developer's real Keychain, and available to\n * users who prefer not to use the OS store).\n */\n\nimport { spawn } from \"node:child_process\";\nimport { readFile, writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { getStorePath } from \"./index.js\";\n\nconst SERVICE = \"mcpm\";\nconst ACCOUNT = \"secret-store-master-key\";\nconst DPAPI_BLOB_FILE = \"master-key.dpapi\";\nconst EXEC_TIMEOUT_MS = 5_000;\n\n/** Result of a child-process run. `code === null` means spawn failed (ENOENT). */\ninterface RunResult {\n code: number | null;\n stdout: string;\n stderr: string;\n}\n\n/**\n * Run a command to completion, optionally feeding `input` to stdin. Never\n * rejects: a missing binary (ENOENT) or any spawn error resolves to\n * `{ code: null }` so callers treat it uniformly as \"unavailable\".\n */\nfunction run(\n command: string,\n args: string[],\n opts: { input?: string; env?: NodeJS.ProcessEnv } = {}\n): Promise<RunResult> {\n return new Promise((resolve) => {\n const child = spawn(command, args, {\n timeout: EXEC_TIMEOUT_MS,\n env: opts.env ?? process.env,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n let stdout = \"\";\n let stderr = \"\";\n // `error` (ENOENT/EACCES) and `close` can both fire for one spawn; settle\n // exactly once. `close` (not `exit`) is used so stdout is fully drained\n // before we resolve.\n let settled = false;\n const settle = (r: RunResult): void => {\n if (settled) return;\n settled = true;\n resolve(r);\n };\n child.stdout.on(\"data\", (d) => (stdout += d.toString()));\n child.stderr.on(\"data\", (d) => (stderr += d.toString()));\n child.on(\"error\", () => settle({ code: null, stdout, stderr }));\n child.on(\"close\", (code) => settle({ code, stdout, stderr }));\n if (opts.input !== undefined) {\n child.stdin.on(\"error\", () => {\n /* child may have exited before stdin flush; swallow EPIPE */\n });\n child.stdin.end(opts.input);\n } else {\n child.stdin.end();\n }\n });\n}\n\n/** True on a platform we know how to drive (and not explicitly disabled). */\nexport function isSupportedPlatform(): boolean {\n if (process.env.MCPM_DISABLE_OS_KEYCHAIN === \"1\") return false;\n return (\n process.platform === \"darwin\" ||\n process.platform === \"linux\" ||\n process.platform === \"win32\"\n );\n}\n\n// ---------------------------------------------------------------------------\n// macOS — security(1) generic-password items in the login Keychain\n// ---------------------------------------------------------------------------\n\nasync function darwinGet(): Promise<Buffer<ArrayBuffer> | null> {\n // -w prints only the password; exit 44 when the item does not exist.\n const r = await run(\"security\", [\n \"find-generic-password\",\n \"-a\",\n ACCOUNT,\n \"-s\",\n SERVICE,\n \"-w\",\n ]);\n if (r.code !== 0) return null;\n return decodeKey(r.stdout.trim());\n}\n\nasync function darwinStore(keyB64: string): Promise<boolean> {\n // -U updates the item if it already exists instead of erroring.\n //\n // Tradeoff: `security` has no reliable non-interactive stdin path for the\n // password, so the (base64) master key is passed in argv and is briefly\n // visible to a *same-user* `ps` during the child's lifetime. This is a narrow,\n // write-only window: the read path (darwinGet) returns the key on stdout to\n // this parent only, and cross-user argv is not readable. A same-user attacker\n // who can `ps` can already read this process's memory, so this does not widen\n // the trust boundary the keychain establishes (off-machine file exfiltration).\n const r = await run(\"security\", [\n \"add-generic-password\",\n \"-a\",\n ACCOUNT,\n \"-s\",\n SERVICE,\n \"-U\",\n \"-w\",\n keyB64,\n ]);\n return r.code === 0;\n}\n\n// ---------------------------------------------------------------------------\n// Linux — secret-tool (libsecret). Value is read from / written to stdin.\n// ---------------------------------------------------------------------------\n\nasync function linuxGet(): Promise<Buffer<ArrayBuffer> | null> {\n const r = await run(\"secret-tool\", [\"lookup\", \"service\", SERVICE, \"account\", ACCOUNT]);\n if (r.code !== 0) return null;\n const value = r.stdout.trim(); // consistent with darwin/windows; tolerate \\r\\n / spaces\n if (value.length === 0) return null;\n return decodeKey(value);\n}\n\nasync function linuxStore(keyB64: string): Promise<boolean> {\n const r = await run(\n \"secret-tool\",\n [\"store\", \"--label=mcpm secret store master key\", \"service\", SERVICE, \"account\", ACCOUNT],\n { input: keyB64 }\n );\n return r.code === 0;\n}\n\n// ---------------------------------------------------------------------------\n// Windows — DPAPI (CurrentUser scope) via PowerShell. The protected blob is\n// stored in ~/.mcpm; DPAPI ties decryption to the Windows user account, so a\n// copied blob cannot be unprotected by another account or on another machine.\n// The plaintext key is passed through an env var, never argv (process list).\n// ---------------------------------------------------------------------------\n\nconst PS_PROTECT =\n \"$ErrorActionPreference='Stop';\" +\n \"Add-Type -AssemblyName System.Security;\" +\n \"$b=[Convert]::FromBase64String($env:MCPM_KEY_B64);\" +\n \"$p=[Security.Cryptography.ProtectedData]::Protect($b,$null,'CurrentUser');\" +\n \"[Convert]::ToBase64String($p)\";\n\nconst PS_UNPROTECT =\n \"$ErrorActionPreference='Stop';\" +\n \"Add-Type -AssemblyName System.Security;\" +\n \"$b=[Convert]::FromBase64String($env:MCPM_BLOB_B64);\" +\n \"$p=[Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser');\" +\n \"[Convert]::ToBase64String($p)\";\n\nasync function blobPath(): Promise<string> {\n return path.join(await getStorePath(), DPAPI_BLOB_FILE);\n}\n\nasync function windowsGet(): Promise<Buffer<ArrayBuffer> | null> {\n let blob: string;\n try {\n blob = (await readFile(await blobPath(), \"utf8\")).trim();\n } catch {\n return null; // no blob stored yet\n }\n if (blob.length === 0) return null;\n const r = await run(\"powershell\", [\"-NoProfile\", \"-NonInteractive\", \"-Command\", PS_UNPROTECT], {\n env: { ...process.env, MCPM_BLOB_B64: blob },\n });\n if (r.code !== 0) return null;\n return decodeKey(r.stdout.trim());\n}\n\nasync function windowsStore(keyB64: string): Promise<boolean> {\n const r = await run(\"powershell\", [\"-NoProfile\", \"-NonInteractive\", \"-Command\", PS_PROTECT], {\n env: { ...process.env, MCPM_KEY_B64: keyB64 },\n });\n if (r.code !== 0 || r.stdout.trim().length === 0) return false;\n try {\n await writeFile(await blobPath(), r.stdout.trim(), { mode: 0o600 });\n return true;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Shared helpers + public dispatch\n// ---------------------------------------------------------------------------\n\n/**\n * Decode a base64 master key, rejecting anything that is not exactly 32 bytes.\n * Returns `Buffer<ArrayBuffer>` rather than bare `Buffer` so the key still\n * assigns to WebCrypto's `BufferSource` in keychain.ts — see the note there.\n */\nfunction decodeKey(b64: string): Buffer<ArrayBuffer> | null {\n try {\n const buf = Buffer.from(b64, \"base64\");\n return buf.length === 32 ? buf : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Read the stored master key, or null if none is stored / the store is\n * unavailable. Never creates a key.\n */\nexport async function getStoredKey(): Promise<Buffer<ArrayBuffer> | null> {\n if (!isSupportedPlatform()) return null;\n switch (process.platform) {\n case \"darwin\":\n return darwinGet();\n case \"linux\":\n return linuxGet();\n case \"win32\":\n return windowsGet();\n default:\n return null;\n }\n}\n\n/**\n * Persist `key` (exactly 32 bytes) in the OS credential store. Returns true on\n * success, false if the store is unavailable or the write failed.\n */\nexport async function storeKey(key: Buffer): Promise<boolean> {\n if (!isSupportedPlatform()) return false;\n if (key.length !== 32) return false;\n const keyB64 = key.toString(\"base64\");\n switch (process.platform) {\n case \"darwin\":\n return darwinStore(keyB64);\n case \"linux\":\n return linuxStore(keyB64);\n case \"win32\":\n return windowsStore(keyB64);\n default:\n return false;\n }\n}\n"],"mappings":"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","names":["storeKey"]} |
| #!/usr/bin/env node | ||
| import { | ||
| confineSandboxRoot, | ||
| hashConfineProfile, | ||
| readConfineStore, | ||
| withProfile, | ||
| writeConfineStore | ||
| } from "./chunk-544DEV2D.js"; | ||
| import { | ||
| SANDBOX_EXEC_PATH, | ||
| defaultWrapContext, | ||
| isConfineBackendAvailable, | ||
| isWrapped, | ||
| unwrapEntry, | ||
| wrapEntry | ||
| } from "./chunk-WYSMWP2R.js"; | ||
| import "./chunk-OIFKZA4V.js"; | ||
| import { | ||
| sanitizeForTerminal | ||
| } from "./chunk-FEXJHHDM.js"; | ||
| import { | ||
| getAdapter | ||
| } from "./chunk-W4IAFBUN.js"; | ||
| import { | ||
| isNewUnguarded, | ||
| mergeUnguarded, | ||
| readUnguardedConsent, | ||
| writeUnguardedConsent | ||
| } from "./chunk-MLVDFLDQ.js"; | ||
| import { | ||
| placeholderEnvKeys | ||
| } from "./chunk-NPJ3SGGS.js"; | ||
| import { | ||
| detectInstalledClients | ||
| } from "./chunk-6R7TL5O2.js"; | ||
| import { | ||
| getConfigPath | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import "./chunk-3X76P3FG.js"; | ||
| // src/guard/cli.ts | ||
| import chalk from "chalk"; | ||
| // src/guard/orchestrator.ts | ||
| import { copyFile } from "fs/promises"; | ||
| function enableGuardAcrossClients(deps, filter) { | ||
| return runAcrossClients(deps, "enable", filter); | ||
| } | ||
| function disableGuardAcrossClients(deps, filter) { | ||
| return runAcrossClients(deps, "disable", filter); | ||
| } | ||
| async function runAcrossClients(deps, action, filter) { | ||
| const targetClients = await selectTargetClients(deps, filter?.client); | ||
| const consentedUnguarded = action === "enable" && deps.readUnguardedConsent ? await deps.readUnguardedConsent() : []; | ||
| const plans = await Promise.all( | ||
| targetClients.map( | ||
| (clientId) => planForClient(clientId, deps, action, filter?.server, consentedUnguarded) | ||
| ) | ||
| ); | ||
| for (const plan of plans) { | ||
| if (plan.transforms.length === 0) continue; | ||
| const configPath = deps.getConfigPath(plan.clientId); | ||
| await copyFile(configPath, `${configPath}.guard-${action}.bak`).catch(() => void 0); | ||
| } | ||
| const reports = []; | ||
| for (const plan of plans) { | ||
| reports.push(await applyPlan(plan, deps)); | ||
| } | ||
| if (filter?.server !== void 0) { | ||
| const matched = reports.some((r) => r.servers.some((s) => s.name === filter.server)); | ||
| if (!matched) { | ||
| throw new Error( | ||
| `--server "${filter.server}" not found in any detected client config. Run \`mcpm guard status\` to see available servers.` | ||
| ); | ||
| } | ||
| } | ||
| if (deps.recordUnguardedConsent) { | ||
| const nowUnguarded = [ | ||
| ...new Set( | ||
| reports.flatMap((r) => r.servers.filter((s) => s.status === "unguarded").map((s) => s.name)) | ||
| ) | ||
| ]; | ||
| const previous = new Set(consentedUnguarded); | ||
| const newlyConsented = nowUnguarded.filter((n) => !previous.has(n)); | ||
| if (newlyConsented.length > 0) { | ||
| await deps.recordUnguardedConsent(newlyConsented).catch(() => void 0); | ||
| } | ||
| } | ||
| return summarize(action, reports); | ||
| } | ||
| async function statusAcrossClients(deps) { | ||
| const targetClients = await deps.detectClients(); | ||
| const clients = await Promise.all( | ||
| targetClients.map(async (clientId) => { | ||
| try { | ||
| const adapter = deps.getAdapter(clientId); | ||
| const entries = await adapter.read(deps.getConfigPath(clientId)); | ||
| const servers = Object.entries(entries).map(([name, entry]) => ({ | ||
| name, | ||
| wrapped: isWrapped(entry), | ||
| // A non-stdio (url-transport) entry has no command — it cannot be | ||
| // wrapped, so even when "not wrapped" it is specifically UNGUARDED. | ||
| unguarded: !isWrapped(entry) && !entry.command | ||
| })); | ||
| return { | ||
| clientId, | ||
| wrapped: servers.filter((s) => s.wrapped).length, | ||
| unwrapped: servers.filter((s) => !s.wrapped).length, | ||
| servers | ||
| }; | ||
| } catch (err) { | ||
| return { | ||
| clientId, | ||
| wrapped: 0, | ||
| unwrapped: 0, | ||
| servers: [], | ||
| error: err instanceof Error ? err.message : String(err) | ||
| }; | ||
| } | ||
| }) | ||
| ); | ||
| return { | ||
| clients, | ||
| totalWrapped: clients.reduce((sum, c) => sum + c.wrapped, 0), | ||
| totalUnwrapped: clients.reduce((sum, c) => sum + c.unwrapped, 0) | ||
| }; | ||
| } | ||
| async function selectTargetClients(deps, clientFilter) { | ||
| const detected = await deps.detectClients(); | ||
| if (clientFilter === void 0) return detected; | ||
| if (!detected.includes(clientFilter)) { | ||
| throw new Error( | ||
| `Client "${clientFilter}" not detected. Available: ${detected.join(", ") || "(none)"}` | ||
| ); | ||
| } | ||
| return [clientFilter]; | ||
| } | ||
| async function planForClient(clientId, deps, action, serverFilter, consentedUnguarded) { | ||
| const adapter = deps.getAdapter(clientId); | ||
| let entries; | ||
| try { | ||
| entries = await adapter.read(deps.getConfigPath(clientId)); | ||
| } catch (err) { | ||
| return { | ||
| clientId, | ||
| action, | ||
| transforms: [], | ||
| skipped: [], | ||
| unguarded: [], | ||
| readError: err instanceof Error ? err.message : String(err) | ||
| }; | ||
| } | ||
| const transforms = []; | ||
| const skipped = []; | ||
| const unguarded = []; | ||
| const consentedSet = new Set(consentedUnguarded); | ||
| for (const [name, entry] of Object.entries(entries)) { | ||
| if (serverFilter !== void 0 && name !== serverFilter) continue; | ||
| if (action === "enable") { | ||
| if (isWrapped(entry)) { | ||
| skipped.push({ name, reason: "already wrapped" }); | ||
| continue; | ||
| } | ||
| if (!entry.command) { | ||
| if (deps.allowUnguarded === true || consentedSet.has(name)) { | ||
| unguarded.push({ | ||
| name, | ||
| reason: "unguarded (consented) \u2014 runs WITHOUT runtime inspection; this grants consent, it does not add protection. The only true fix is a streamable-HTTP relay (not yet implemented)." | ||
| }); | ||
| } else { | ||
| skipped.push({ | ||
| name, | ||
| reason: "DENIED: URL/HTTP-transport server runs UNGUARDED \u2014 no runtime inspection is possible (the guard relay only wraps stdio servers). Re-run `mcpm guard enable --allow-unguarded` to permit it without protection." | ||
| }); | ||
| } | ||
| continue; | ||
| } | ||
| transforms.push({ | ||
| name, | ||
| nextEntry: wrapEntry(name, entry, deps.wrapContext, deps.confineMarkers?.get(name)) | ||
| }); | ||
| } else { | ||
| if (!isWrapped(entry)) { | ||
| skipped.push({ name, reason: "not wrapped" }); | ||
| continue; | ||
| } | ||
| const unwrapped = unwrapEntry(entry); | ||
| if (unwrapped === null) { | ||
| skipped.push({ name, reason: "wrap marker malformed; .bak restore may be required" }); | ||
| continue; | ||
| } | ||
| transforms.push({ name, nextEntry: unwrapped }); | ||
| } | ||
| } | ||
| return { clientId, action, transforms, skipped, unguarded }; | ||
| } | ||
| async function applyPlan(plan, deps) { | ||
| if (plan.readError) { | ||
| return { | ||
| clientId: plan.clientId, | ||
| servers: [], | ||
| error: plan.readError | ||
| }; | ||
| } | ||
| const adapter = deps.getAdapter(plan.clientId); | ||
| const configPath = deps.getConfigPath(plan.clientId); | ||
| const servers = []; | ||
| for (const { name, nextEntry } of plan.transforms) { | ||
| try { | ||
| await adapter.replaceServer(configPath, name, nextEntry); | ||
| servers.push({ name, status: plan.action === "enable" ? "wrapped" : "unwrapped" }); | ||
| } catch (err) { | ||
| servers.push({ | ||
| name, | ||
| status: "skipped", | ||
| reason: err instanceof Error ? err.message : String(err) | ||
| }); | ||
| } | ||
| } | ||
| for (const skip of plan.skipped) { | ||
| servers.push({ name: skip.name, status: "skipped", reason: skip.reason }); | ||
| } | ||
| for (const u of plan.unguarded) { | ||
| servers.push({ name: u.name, status: "unguarded", reason: u.reason }); | ||
| } | ||
| return { clientId: plan.clientId, servers }; | ||
| } | ||
| function summarize(action, reports) { | ||
| let totalChanged = 0; | ||
| let totalSkipped = 0; | ||
| let totalUnguarded = 0; | ||
| let errors = 0; | ||
| for (const report of reports) { | ||
| if (report.error) errors++; | ||
| for (const server of report.servers) { | ||
| if (server.status === "wrapped" || server.status === "unwrapped") totalChanged++; | ||
| else if (server.status === "unguarded") totalUnguarded++; | ||
| else totalSkipped++; | ||
| } | ||
| } | ||
| return { action, clients: reports, totalChanged, totalSkipped, totalUnguarded, errors }; | ||
| } | ||
| // src/guard/cli.ts | ||
| import os from "os"; | ||
| // src/guard/confine/derive.ts | ||
| import { createHash } from "crypto"; | ||
| import path from "path"; | ||
| var SECRET_DIR_SEGMENTS = [ | ||
| // SSH / cloud / package-registry / signing credentials. | ||
| ".ssh", | ||
| ".aws", | ||
| ".gnupg", | ||
| ".config/gh", | ||
| ".config/gcloud", | ||
| ".npmrc", | ||
| ".docker", | ||
| ".kube", | ||
| ".netrc", | ||
| ".git-credentials", | ||
| ".cargo/credentials", | ||
| ".cargo/credentials.toml", | ||
| ".pypirc", | ||
| // OS keychains + browser cookie stores (highest-value credential theft). | ||
| "Library/Keychains", | ||
| "Library/Application Support/Google/Chrome", | ||
| "Library/Application Support/Firefox", | ||
| "Library/Cookies", | ||
| // mcpm's own store (secrets.enc.json, pins, policy, the confine store itself). | ||
| ".mcpm", | ||
| // Sibling MCP client configs (each can hold another server's plaintext secrets). | ||
| "Library/Application Support/Claude", | ||
| "Library/Application Support/Cursor", | ||
| "Library/Application Support/Code/User", | ||
| "Library/Application Support/Windsurf", | ||
| ".cursor", | ||
| ".vscode", | ||
| ".codeium", | ||
| ".claude.json", | ||
| // Claude Code user-global config (see src/config/paths.ts getConfigPath) | ||
| ".claude", | ||
| // Claude Code also keeps state under ~/.claude/ | ||
| ".gemini" | ||
| // Gemini CLI user-global config (~/.gemini/settings.json) | ||
| ]; | ||
| var WRITE_ALLOW_HOME_SEGMENTS = [".npm", ".cache", "Library/Caches"]; | ||
| var WRITE_ALLOW_STATIC = [ | ||
| "/tmp", | ||
| "/private/tmp", | ||
| "/var/tmp", | ||
| "/var/folders", | ||
| "/private/var/folders", | ||
| "/dev" | ||
| ]; | ||
| var LAUNCHER_COMMANDS = /* @__PURE__ */ new Set([ | ||
| "npx", | ||
| "npm", | ||
| "pnpm", | ||
| "yarn", | ||
| "bun", | ||
| "bunx", | ||
| "uv", | ||
| "uvx", | ||
| "pip", | ||
| "pip3", | ||
| "pipx", | ||
| "pipenv", | ||
| "poetry", | ||
| "docker" | ||
| ]); | ||
| function commandBasename(command) { | ||
| const base = path.basename(command).toLowerCase(); | ||
| const dot = base.indexOf("."); | ||
| return dot === -1 ? base : base.slice(0, dot); | ||
| } | ||
| function classifyNet(command) { | ||
| return LAUNCHER_COMMANDS.has(commandBasename(command)) ? "all" : "none"; | ||
| } | ||
| function safeServerSegment(serverName) { | ||
| if (serverName.length === 0) throw new Error("confine: empty server name"); | ||
| const cleaned = serverName.replace(/[^A-Za-z0-9._@-]/g, "_").replace(/\.{2,}/g, "_").replace(/^\.+/, "_"); | ||
| const suffix = createHash("sha256").update(serverName).digest("hex").slice(0, 8); | ||
| return `${cleaned}-${suffix}`; | ||
| } | ||
| function deriveDefaultProfile(input) { | ||
| if (input.command.length === 0) throw new Error("confine: empty command"); | ||
| const scratchDir = path.join(input.sandboxRoot, safeServerSegment(input.serverName)); | ||
| const readDeny = SECRET_DIR_SEGMENTS.map((seg) => path.join(input.home, seg)); | ||
| const writeAllow = [ | ||
| scratchDir, | ||
| ...WRITE_ALLOW_STATIC, | ||
| input.tmpDir, | ||
| ...WRITE_ALLOW_HOME_SEGMENTS.map((seg) => path.join(input.home, seg)) | ||
| ]; | ||
| return { | ||
| tier: "standard", | ||
| require_confine: input.requireConfine === true, | ||
| read_deny: dedupeSorted(readDeny), | ||
| write_allow: dedupeSorted(writeAllow), | ||
| net: input.net ?? classifyNet(input.command), | ||
| scratch_dir: scratchDir, | ||
| captured_at: input.capturedAt | ||
| }; | ||
| } | ||
| function dedupeSorted(paths) { | ||
| return [...new Set(paths)].sort(); | ||
| } | ||
| // src/guard/cli.ts | ||
| var CLIENT_LABELS = { | ||
| "claude-desktop": "Claude Desktop", | ||
| "claude-code": "Claude Code", | ||
| cursor: "Cursor", | ||
| vscode: "VS Code", | ||
| windsurf: "Windsurf", | ||
| "gemini-cli": "Gemini CLI" | ||
| }; | ||
| function buildDeps(extra = {}) { | ||
| return { | ||
| detectClients: detectInstalledClients, | ||
| getAdapter, | ||
| getConfigPath, | ||
| wrapContext: defaultWrapContext(), | ||
| readUnguardedConsent, | ||
| recordUnguardedConsent: async (names) => { | ||
| const previous = await readUnguardedConsent(); | ||
| await writeUnguardedConsent(mergeUnguarded(previous, names)); | ||
| }, | ||
| ...extra | ||
| }; | ||
| } | ||
| async function runEnableCommand(opts) { | ||
| const previousConsented = await readUnguardedConsent(); | ||
| if (opts.dryRun === true) { | ||
| await printEnableDryRun(opts); | ||
| return; | ||
| } | ||
| if (opts.confine === "off") { | ||
| opts.write("OS confinement: skipped (--confine off).\n"); | ||
| } | ||
| let confineMarkers; | ||
| if (opts.confine === "standard") { | ||
| try { | ||
| confineMarkers = await computeConfineMarkers({ | ||
| client: opts.client, | ||
| server: opts.server, | ||
| write: opts.write | ||
| }); | ||
| } catch (err) { | ||
| opts.write( | ||
| chalk.yellow(` | ||
| \u26A0 OS confinement aborted: ${sanitizeForTerminal(err.message)}`) + "\n" | ||
| ); | ||
| return; | ||
| } | ||
| } | ||
| const deps = buildDeps({ allowUnguarded: opts.allowUnguarded, confineMarkers }); | ||
| const summary = await enableGuardAcrossClients(deps, opts); | ||
| printEnableDisable(summary, opts); | ||
| printUnguardedWarning(summary, previousConsented, opts); | ||
| if (confineMarkers !== void 0) printConfineNotice(confineMarkers, opts); | ||
| printRestartReminder(opts); | ||
| } | ||
| async function printEnableDryRun(opts) { | ||
| const deps = buildDeps({ allowUnguarded: opts.allowUnguarded }); | ||
| const status = await statusAcrossClients(deps); | ||
| const confineNote = opts.confine === "standard" ? " (with OS confinement)" : ""; | ||
| opts.write(`Dry-run: planned wraps${confineNote} | ||
| `); | ||
| for (const c of status.clients) { | ||
| if (opts.client !== void 0 && c.clientId !== opts.client) continue; | ||
| const candidates = c.servers.filter((s) => { | ||
| if (opts.server !== void 0 && s.name !== opts.server) return false; | ||
| return !s.wrapped; | ||
| }); | ||
| opts.write(` ${CLIENT_LABELS[c.clientId]}: would wrap ${candidates.length} server(s) | ||
| `); | ||
| for (const s of candidates) opts.write(` + ${s.name} | ||
| `); | ||
| } | ||
| } | ||
| async function collectConfineTargets(opts) { | ||
| const targets = /* @__PURE__ */ new Map(); | ||
| let clients; | ||
| try { | ||
| clients = await detectInstalledClients(); | ||
| } catch { | ||
| return targets; | ||
| } | ||
| if (opts.client !== void 0) clients = clients.filter((c) => c === opts.client); | ||
| for (const clientId of clients) { | ||
| let entries; | ||
| try { | ||
| entries = await getAdapter(clientId).read(getConfigPath(clientId)); | ||
| } catch { | ||
| continue; | ||
| } | ||
| for (const [name, entry] of Object.entries(entries)) { | ||
| if (opts.server !== void 0 && name !== opts.server) continue; | ||
| if (!entry.command || isWrapped(entry)) continue; | ||
| if (!targets.has(name)) targets.set(name, { command: entry.command, args: entry.args }); | ||
| } | ||
| } | ||
| return targets; | ||
| } | ||
| async function computeConfineMarkers(opts) { | ||
| const targets = await collectConfineTargets(opts); | ||
| if (targets.size === 0) return /* @__PURE__ */ new Map(); | ||
| let store; | ||
| try { | ||
| store = await readConfineStore(); | ||
| } catch (err) { | ||
| throw new Error( | ||
| `cannot read the confine store (~/.mcpm/guard-confine.yaml): ${err.message} Review it for unauthorized changes; if you edited it intentionally, restore or remove it. Refusing to enroll \u2014 the store was left untouched.` | ||
| ); | ||
| } | ||
| const { markers, storeToWrite } = await resolveConfineMarkers(targets, store, opts); | ||
| if (storeToWrite !== null) await writeConfineStore(storeToWrite); | ||
| return markers; | ||
| } | ||
| async function resolveConfineMarkers(targets, store, opts) { | ||
| const markers = /* @__PURE__ */ new Map(); | ||
| const home = os.homedir(); | ||
| const sandboxRoot = await confineSandboxRoot(); | ||
| const tmpDir = os.tmpdir(); | ||
| const capturedAt = (/* @__PURE__ */ new Date()).toISOString(); | ||
| let next = store; | ||
| let added = 0; | ||
| for (const [name, target] of targets) { | ||
| const existing = Object.hasOwn(store.servers, name) ? store.servers[name] : void 0; | ||
| if (existing !== void 0) { | ||
| markers.set(name, { | ||
| profileHash: hashConfineProfile(existing), | ||
| required: existing.require_confine | ||
| }); | ||
| continue; | ||
| } | ||
| const profile = deriveDefaultProfile({ | ||
| serverName: name, | ||
| command: target.command, | ||
| args: target.args, | ||
| home, | ||
| sandboxRoot, | ||
| tmpDir, | ||
| capturedAt | ||
| }); | ||
| next = withProfile(next, name, profile); | ||
| added += 1; | ||
| markers.set(name, { | ||
| profileHash: hashConfineProfile(profile), | ||
| required: profile.require_confine | ||
| }); | ||
| } | ||
| return { markers, storeToWrite: added > 0 ? next : null }; | ||
| } | ||
| function printConfineNotice(confineMarkers, opts) { | ||
| if (confineMarkers.size === 0) { | ||
| opts.write("\nOS confinement: no unwrapped stdio servers to enroll.\n"); | ||
| return; | ||
| } | ||
| opts.write( | ||
| ` | ||
| \u{1F512} ${confineMarkers.size} server(s) enrolled in OS confinement (standard tier). Run \`mcpm guard doctor-confine\` for status. | ||
| ` | ||
| ); | ||
| if (!isConfineBackendAvailable()) { | ||
| opts.write( | ||
| chalk.yellow( | ||
| "\u26A0 No OS sandbox backend on this platform \u2014 enrolled servers run UNCONFINED until a backend is present (they are NOT blocked; a require_confine server would fail closed)." | ||
| ) + "\n" | ||
| ); | ||
| } | ||
| } | ||
| async function runDoctorConfineCommand(opts) { | ||
| const backendAvailable = isConfineBackendAvailable(); | ||
| let servers = []; | ||
| let storeError; | ||
| try { | ||
| const store = await readConfineStore(); | ||
| servers = Object.entries(store.servers).map(([name, p]) => ({ | ||
| name, | ||
| tier: p.tier, | ||
| net: p.net, | ||
| requireConfine: p.require_confine | ||
| })); | ||
| } catch (err) { | ||
| storeError = err.message; | ||
| } | ||
| opts.write( | ||
| opts.json === true ? renderDoctorConfineJson(backendAvailable, servers, storeError) : renderDoctorConfineText(backendAvailable, servers, storeError) | ||
| ); | ||
| } | ||
| function renderDoctorConfineJson(backendAvailable, servers, storeError) { | ||
| return JSON.stringify( | ||
| { | ||
| platform: process.platform, | ||
| backendAvailable, | ||
| sandboxExecPath: process.platform === "darwin" ? SANDBOX_EXEC_PATH : null, | ||
| // sanitize: an OS error message can carry control chars / ANSI (parity | ||
| // with the text branch, which already sanitizes). | ||
| storeError: storeError !== void 0 ? sanitizeForTerminal(storeError) : null, | ||
| servers | ||
| }, | ||
| null, | ||
| 2 | ||
| ) + "\n"; | ||
| } | ||
| function renderDoctorConfineText(backendAvailable, servers, storeError) { | ||
| const out = ["mcpm guard doctor-confine", ""]; | ||
| out.push(` platform : ${process.platform}`); | ||
| let backendLine = ` sandbox backend : ${backendAvailable ? "available" : "UNAVAILABLE"}`; | ||
| if (process.platform === "darwin") backendLine += ` (${SANDBOX_EXEC_PATH})`; | ||
| out.push(backendLine); | ||
| if (!backendAvailable) { | ||
| out.push( | ||
| " \u2192 enrolled servers run UNCONFINED here (hybrid posture); a require_confine server fails closed." | ||
| ); | ||
| } | ||
| out.push(""); | ||
| if (storeError !== void 0) { | ||
| out.push(` \u26A0 could not read the confine store: ${sanitizeForTerminal(storeError)}`, ""); | ||
| return out.join("\n"); | ||
| } | ||
| if (servers.length === 0) { | ||
| out.push(" No servers enrolled in confinement. Enroll with `mcpm guard enable --confine`.", ""); | ||
| return out.join("\n"); | ||
| } | ||
| out.push(` Enrolled servers (${servers.length}):`); | ||
| for (const s of servers) { | ||
| out.push(` ${sanitizeForTerminal(s.name)} \u2014 tier=${s.tier} net=${s.net} require_confine=${s.requireConfine}`); | ||
| } | ||
| out.push("", " Run `mcpm guard status` for per-client wrap state.", ""); | ||
| return out.join("\n"); | ||
| } | ||
| function printUnguardedWarning(summary, previousConsented, opts) { | ||
| const current = [ | ||
| ...new Set( | ||
| summary.clients.flatMap( | ||
| (c) => c.servers.filter((s) => s.status === "unguarded").map((s) => s.name) | ||
| ) | ||
| ) | ||
| ].sort(); | ||
| if (current.length === 0) return; | ||
| if (isNewUnguarded(current, previousConsented)) { | ||
| const prev = new Set(previousConsented); | ||
| const newlyConsented = current.filter((n) => !prev.has(n)); | ||
| const alreadyCount = current.length - newlyConsented.length; | ||
| opts.write( | ||
| chalk.yellow( | ||
| "\n\u26A0 UNGUARDED: the following server(s) run WITHOUT runtime inspection \u2014 the guard relay cannot wrap a non-stdio (URL/HTTP) transport:" | ||
| ) + "\n" | ||
| ); | ||
| for (const name of newlyConsented) opts.write(` \u26A0 ${sanitizeForTerminal(name)} | ||
| `); | ||
| if (alreadyCount > 0) { | ||
| opts.write(` (+${alreadyCount} previously consented) | ||
| `); | ||
| } | ||
| opts.write( | ||
| "This grants consent to run them UNGUARDED \u2014 it does NOT add protection. The only true fix is a streamable-HTTP relay (not yet implemented). Future `enable` runs stay quiet unless a NEW unguarded server appears.\n" | ||
| ); | ||
| } else { | ||
| opts.write( | ||
| ` | ||
| ${current.length} server(s) running unguarded (previously consented): ${current.map((n) => sanitizeForTerminal(n)).join(", ")} | ||
| ` | ||
| ); | ||
| } | ||
| } | ||
| async function runDisableCommand(opts) { | ||
| const deps = buildDeps(); | ||
| const summary = await disableGuardAcrossClients(deps, opts); | ||
| printEnableDisable(summary, opts); | ||
| printRestartReminder(opts); | ||
| await warnUnresolvablePlaceholders(opts); | ||
| } | ||
| async function warnUnresolvablePlaceholders(opts) { | ||
| let clients; | ||
| try { | ||
| clients = await detectInstalledClients(); | ||
| } catch { | ||
| return; | ||
| } | ||
| const affected = []; | ||
| for (const clientId of clients) { | ||
| if (opts.client !== void 0 && clientId !== opts.client) continue; | ||
| let entries; | ||
| try { | ||
| entries = await getAdapter(clientId).read(getConfigPath(clientId)); | ||
| } catch (err) { | ||
| if (err.code !== "ENOENT") { | ||
| opts.write( | ||
| ` (could not read ${CLIENT_LABELS[clientId]} config: ${sanitizeForTerminal(err.message)}) | ||
| ` | ||
| ); | ||
| } | ||
| continue; | ||
| } | ||
| for (const [name, entry] of Object.entries(entries)) { | ||
| if (opts.server !== void 0 && name !== opts.server) continue; | ||
| const keys = placeholderEnvKeys(entry.env); | ||
| if (keys.length > 0) affected.push({ client: clientId, server: name, keys }); | ||
| } | ||
| } | ||
| if (affected.length === 0) return; | ||
| opts.write( | ||
| "\n\x1B[33mwarning: these servers reference encrypted secrets (mcpm:keychain:\u2026) that only resolve while mcpm guard is enabled. Without guard they receive the literal placeholder and will fail to start:\x1B[0m\n" | ||
| ); | ||
| for (const a of affected) { | ||
| opts.write( | ||
| ` - ${sanitizeForTerminal(a.server)} (${CLIENT_LABELS[a.client]}): ${formatAffectedKeys(a.keys)} | ||
| ` | ||
| ); | ||
| } | ||
| opts.write( | ||
| "Re-enable with `mcpm guard enable`, or replace those env values with plaintext.\n" | ||
| ); | ||
| } | ||
| function formatAffectedKeys(keys) { | ||
| return keys.map((k) => sanitizeForTerminal(k)).join(", "); | ||
| } | ||
| async function runStatusCommand(opts) { | ||
| const deps = buildDeps(); | ||
| const status = await statusAcrossClients(deps); | ||
| if (status.clients.length === 0) { | ||
| if (opts.helpFallback) { | ||
| opts.helpFallback(); | ||
| return; | ||
| } | ||
| opts.write("No MCP clients detected.\n"); | ||
| return; | ||
| } | ||
| if (status.totalWrapped === 0 && opts.helpFallback) { | ||
| opts.helpFallback(); | ||
| return; | ||
| } | ||
| printStatus(status, opts); | ||
| } | ||
| function printEnableDisable(summary, opts) { | ||
| const verb = summary.action === "enable" ? "wrapped" : "unwrapped"; | ||
| opts.write(`mcpm guard ${summary.action}: ${summary.totalChanged} ${verb}, `); | ||
| opts.write(`${summary.totalSkipped} skipped`); | ||
| if (summary.totalUnguarded > 0) { | ||
| opts.write(`, ${summary.totalUnguarded} unguarded`); | ||
| } | ||
| opts.write(`, ${summary.errors} error(s) | ||
| `); | ||
| for (const client of summary.clients) { | ||
| printClientReport(client, opts); | ||
| } | ||
| } | ||
| function printClientReport(report, opts) { | ||
| opts.write(` ${CLIENT_LABELS[report.clientId]}: | ||
| `); | ||
| if (report.error !== void 0) { | ||
| opts.write(` error: ${sanitizeForTerminal(report.error)} | ||
| `); | ||
| return; | ||
| } | ||
| if (report.servers.length === 0) { | ||
| opts.write(` (no servers) | ||
| `); | ||
| return; | ||
| } | ||
| for (const s of report.servers) { | ||
| const symbol = s.status === "wrapped" ? "+" : s.status === "unwrapped" ? "-" : s.status === "unguarded" ? "\u26A0" : "\xB7"; | ||
| const reason = s.reason !== void 0 ? ` (${sanitizeForTerminal(s.reason)})` : ""; | ||
| opts.write(` ${symbol} ${sanitizeForTerminal(s.name)}${reason} | ||
| `); | ||
| } | ||
| } | ||
| function printStatus(status, opts) { | ||
| opts.write(`mcpm guard status: ${status.totalWrapped} wrapped, ${status.totalUnwrapped} unwrapped | ||
| `); | ||
| for (const c of status.clients) { | ||
| opts.write(` ${CLIENT_LABELS[c.clientId]}: ${c.wrapped} wrapped / ${c.unwrapped} unwrapped | ||
| `); | ||
| if (c.error !== void 0) { | ||
| opts.write(` error: ${sanitizeForTerminal(c.error)} | ||
| `); | ||
| continue; | ||
| } | ||
| for (const s of c.servers) { | ||
| const marker = s.wrapped ? "+" : s.unguarded ? "\u26A0 UNGUARDED" : "\xB7"; | ||
| opts.write(` ${marker} ${sanitizeForTerminal(s.name)} | ||
| `); | ||
| } | ||
| } | ||
| } | ||
| function printRestartReminder(opts) { | ||
| opts.write( | ||
| "\n\u2192 Restart your IDE (Claude Desktop / Cursor / VS Code / Windsurf) for changes to take effect.\n" | ||
| ); | ||
| } | ||
| async function runCleanupCommand(opts) { | ||
| const deps = buildDeps(); | ||
| const status = await statusAcrossClients(deps); | ||
| const installedServerNames = /* @__PURE__ */ new Set(); | ||
| for (const c of status.clients) { | ||
| for (const s of c.servers) installedServerNames.add(s.name); | ||
| } | ||
| const { readPins, writePins, clearServerPins, PinsIntegrityError } = await import("./pins-ETT4XWEP.js"); | ||
| let pins; | ||
| try { | ||
| pins = await readPins(); | ||
| } catch (err) { | ||
| if (err instanceof PinsIntegrityError) { | ||
| opts.write( | ||
| `mcpm guard cleanup: cannot read ~/.mcpm/pins.json \u2014 integrity check failed. | ||
| ${err.message} | ||
| Refusing to prune until this is resolved. | ||
| ` | ||
| ); | ||
| } else { | ||
| opts.write( | ||
| `mcpm guard cleanup: cannot read ~/.mcpm/pins.json \u2014 ${err.message} | ||
| Refusing to prune until this is resolved. | ||
| ` | ||
| ); | ||
| } | ||
| return; | ||
| } | ||
| const orphanPinned = []; | ||
| for (const serverName of Object.keys(pins.servers)) { | ||
| if (!installedServerNames.has(serverName)) orphanPinned.push(serverName); | ||
| } | ||
| if (orphanPinned.length === 0) { | ||
| opts.write("mcpm guard cleanup: nothing to prune (0 orphan pins, 0 orphan wraps).\n"); | ||
| return; | ||
| } | ||
| opts.write(`mcpm guard cleanup: ${orphanPinned.length} orphan pin entr${orphanPinned.length === 1 ? "y" : "ies"} found: | ||
| `); | ||
| for (const s of orphanPinned) opts.write(` - ${sanitizeForTerminal(s)} | ||
| `); | ||
| if (!opts.apply) { | ||
| opts.write("\nDry run. Re-run with --yes to prune.\n"); | ||
| return; | ||
| } | ||
| let next = pins; | ||
| for (const serverName of orphanPinned) next = clearServerPins(next, serverName); | ||
| await writePins(next); | ||
| opts.write(` | ||
| Pruned ${orphanPinned.length} orphan pin entr${orphanPinned.length === 1 ? "y" : "ies"} from ~/.mcpm/pins.json. | ||
| `); | ||
| } | ||
| export { | ||
| computeConfineMarkers, | ||
| formatAffectedKeys, | ||
| printUnguardedWarning, | ||
| runCleanupCommand, | ||
| runDisableCommand, | ||
| runDoctorConfineCommand, | ||
| runEnableCommand, | ||
| runStatusCommand | ||
| }; | ||
| //# sourceMappingURL=cli-UNXIMIVR.js.map |
Sorry, the diff of this file is too big to display
| #!/usr/bin/env node | ||
| import { | ||
| activeSecretBackend, | ||
| applyKeychainSecrets, | ||
| deleteSecret, | ||
| deriveKeychainId, | ||
| getSecret, | ||
| listAll, | ||
| migrateToKeychain, | ||
| parsePlaceholder, | ||
| placeholderEnvKeys, | ||
| resolveEnvPlaceholders, | ||
| setSecret, | ||
| setSecrets, | ||
| toPlaceholder | ||
| } from "./chunk-NPJ3SGGS.js"; | ||
| import "./chunk-3X76P3FG.js"; | ||
| export { | ||
| activeSecretBackend, | ||
| applyKeychainSecrets, | ||
| deleteSecret, | ||
| deriveKeychainId, | ||
| getSecret, | ||
| listAll, | ||
| migrateToKeychain, | ||
| parsePlaceholder, | ||
| placeholderEnvKeys, | ||
| resolveEnvPlaceholders, | ||
| setSecret, | ||
| setSecrets, | ||
| toPlaceholder | ||
| }; | ||
| //# sourceMappingURL=keychain-KDOUTVCT.js.map |
| {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]} |
| #!/usr/bin/env node | ||
| import { | ||
| buildDriftFinding, | ||
| buildHandshakeDriftFinding, | ||
| classifyDrift, | ||
| classifyHandshakeDrift, | ||
| inspectForDrift, | ||
| inspectHandshakeForDrift | ||
| } from "./chunk-5W5Z3VZG.js"; | ||
| import { | ||
| PolicyIntegrityError, | ||
| expireStale, | ||
| readPolicy | ||
| } from "./chunk-CYYYMOUS.js"; | ||
| import { | ||
| hasToolsList, | ||
| inspectFrame, | ||
| mergeInspect, | ||
| withReplyToOrigin | ||
| } from "./chunk-XLPT6EJQ.js"; | ||
| import { | ||
| hashConfineProfile, | ||
| loadProfile | ||
| } from "./chunk-544DEV2D.js"; | ||
| import { | ||
| OWASP_MCP_TOP_10 | ||
| } from "./chunk-4ANBMGU5.js"; | ||
| import { | ||
| fieldHashesOf, | ||
| handshakeCapabilityKeys, | ||
| handshakeFieldHashesOf, | ||
| hashHandshake, | ||
| hashToolDefinition, | ||
| lookupHandshake, | ||
| readPins, | ||
| writePins | ||
| } from "./chunk-DDCTUMSZ.js"; | ||
| import { | ||
| hashOriginalEntry, | ||
| isConfineBackendAvailable, | ||
| wrapForConfinement | ||
| } from "./chunk-WYSMWP2R.js"; | ||
| import "./chunk-OIFKZA4V.js"; | ||
| import { | ||
| sanitizeForTerminal | ||
| } from "./chunk-FEXJHHDM.js"; | ||
| import { | ||
| resolveEnvPlaceholders | ||
| } from "./chunk-NPJ3SGGS.js"; | ||
| import { | ||
| getStorePath | ||
| } from "./chunk-3X76P3FG.js"; | ||
| import { | ||
| ACTION_RANK, | ||
| defaultActionForFinding, | ||
| inspectMessage | ||
| } from "./chunk-WT6V33F2.js"; | ||
| // src/guard/relay.ts | ||
| import { spawn } from "child_process"; | ||
| import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"; | ||
| var GUARD_BLOCK_ERROR_CODE = -32099; | ||
| function makeBlockResponse(blocked, result) { | ||
| if (!("id" in blocked) || blocked.id === void 0) return null; | ||
| const finding = result.findings[0]; | ||
| return { | ||
| jsonrpc: "2.0", | ||
| id: blocked.id, | ||
| error: { | ||
| code: GUARD_BLOCK_ERROR_CODE, | ||
| message: "BLOCKED by mcpm-guard", | ||
| data: finding ? { | ||
| signature_id: finding.signature_id, | ||
| category: finding.category, | ||
| severity: finding.severity, | ||
| matched_text_excerpt: finding.matched_text_excerpt, | ||
| remediation: finding.remediation | ||
| } : void 0 | ||
| } | ||
| }; | ||
| } | ||
| var SAFE_ENV_PASSTHROUGH = /* @__PURE__ */ new Set([ | ||
| "PATH", | ||
| "HOME", | ||
| "TMPDIR", | ||
| "TEMP", | ||
| "TMP", | ||
| "LANG", | ||
| "LC_ALL", | ||
| "USER", | ||
| "SHELL" | ||
| ]); | ||
| function buildSafeEnv(source = process.env) { | ||
| const out = {}; | ||
| for (const [k, v] of Object.entries(source)) { | ||
| if (SAFE_ENV_PASSTHROUGH.has(k) || k.startsWith("LC_")) out[k] = v; | ||
| } | ||
| return out; | ||
| } | ||
| var MAX_BUFFER_BYTES = 64 * 1024 * 1024; | ||
| function startRelay(opts) { | ||
| const env = opts.env ?? buildSafeEnv(); | ||
| const child = opts.spawnChild ? opts.spawnChild(opts.command, opts.args, env) : spawn(opts.command, [...opts.args], { | ||
| env, | ||
| stdio: ["pipe", "pipe", "inherit"] | ||
| // stderr passthrough — preserves IDE diagnostics | ||
| }); | ||
| const forwardSignal = (sig) => { | ||
| if (!child.killed) child.kill(sig); | ||
| }; | ||
| let settled = false; | ||
| let resolveExit; | ||
| const exit = new Promise((resolve) => { | ||
| resolveExit = resolve; | ||
| }); | ||
| child.on("error", (err) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| process.off("SIGTERM", forwardSignal); | ||
| process.off("SIGINT", forwardSignal); | ||
| const code = err.code ?? "SPAWN-FAILED"; | ||
| opts.onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "child->parent", | ||
| action: "block", | ||
| findings: [ | ||
| { | ||
| signature_id: "spawn-failure", | ||
| category: "RELAY", | ||
| severity: "critical", | ||
| target: "tool_response", | ||
| matched_text_excerpt: `${code}: ${err.message}`, | ||
| remediation: "The wrapped MCP server binary failed to start. Verify the command exists and is executable." | ||
| } | ||
| ] | ||
| }); | ||
| process.stderr.write(`[mcpm-guard] SPAWN-FAILED ${opts.command}: ${code} | ||
| `); | ||
| child.stdout?.destroy(); | ||
| child.stdin?.destroy(); | ||
| resolveExit(1); | ||
| }); | ||
| child.stdin?.on("error", (err) => { | ||
| const code = err.code; | ||
| if (code !== "EPIPE" && code !== "ERR_STREAM_DESTROYED") { | ||
| opts.onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "parent->child", | ||
| action: "warn", | ||
| findings: [] | ||
| }); | ||
| } | ||
| }); | ||
| const writeToChild = (bytes) => { | ||
| if (child.stdin && !child.stdin.destroyed) child.stdin.write(bytes); | ||
| }; | ||
| wireDirection({ | ||
| source: opts.parentIn, | ||
| target: writeToChild, | ||
| targetEnd: () => child.stdin?.end(), | ||
| parentOut: opts.parentOut, | ||
| inspect: opts.inspectParentRequest, | ||
| direction: "parent->child", | ||
| onEvent: opts.onEvent, | ||
| // Symmetry only — a parent-INITIATED block replies to the client (parentOut), | ||
| // so this is unused for this direction (no replyToOrigin on parent requests). | ||
| replyToSource: (bytes) => opts.parentOut.write(bytes) | ||
| }); | ||
| if (child.stdout) { | ||
| wireDirection({ | ||
| source: child.stdout, | ||
| target: (bytes) => opts.parentOut.write(bytes), | ||
| targetEnd: () => void 0, | ||
| // never end parentOut on child exit | ||
| parentOut: opts.parentOut, | ||
| inspect: opts.inspectChildResponse, | ||
| direction: "child->parent", | ||
| onEvent: opts.onEvent, | ||
| // H7: a blocked server-INITIATED request (sampling/elicitation) errors | ||
| // back to the SERVER (child.stdin), not the client. | ||
| replyToSource: writeToChild | ||
| }); | ||
| } | ||
| process.on("SIGTERM", forwardSignal); | ||
| process.on("SIGINT", forwardSignal); | ||
| child.on("exit", (code) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| process.off("SIGTERM", forwardSignal); | ||
| process.off("SIGINT", forwardSignal); | ||
| resolveExit(code ?? 0); | ||
| }); | ||
| return { child, exit }; | ||
| } | ||
| function wireDirection(w) { | ||
| const buffer = new ReadBuffer(); | ||
| let bufferedBytes = 0; | ||
| w.source.on("data", (chunk) => { | ||
| bufferedBytes += chunk.byteLength; | ||
| if (bufferedBytes > MAX_BUFFER_BYTES) { | ||
| w.onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: w.direction, | ||
| action: "block", | ||
| findings: [] | ||
| }); | ||
| w.source.destroy(); | ||
| return; | ||
| } | ||
| buffer.append(chunk); | ||
| let msg; | ||
| try { | ||
| msg = buffer.readMessage(); | ||
| } catch { | ||
| w.onEvent?.(malformedFrameEvent(w.direction)); | ||
| w.source.destroy(); | ||
| return; | ||
| } | ||
| while (msg !== null) { | ||
| bufferedBytes = 0; | ||
| const decision = w.inspect?.(msg); | ||
| if (decision?.action === "block") { | ||
| logEvent(decision, w.direction, w.onEvent); | ||
| const errResp = makeBlockResponse(msg, decision); | ||
| if (errResp !== null) { | ||
| if (decision.replyToOrigin === true) w.replyToSource(serializeMessage(errResp)); | ||
| else w.parentOut.write(serializeMessage(errResp)); | ||
| } | ||
| } else { | ||
| logEvent(decision, w.direction, w.onEvent); | ||
| w.target(serializeMessage(msg)); | ||
| } | ||
| try { | ||
| msg = buffer.readMessage(); | ||
| } catch { | ||
| w.onEvent?.(malformedFrameEvent(w.direction)); | ||
| w.source.destroy(); | ||
| return; | ||
| } | ||
| } | ||
| }); | ||
| w.source.on("end", () => { | ||
| w.targetEnd(); | ||
| }); | ||
| } | ||
| function malformedFrameEvent(direction) { | ||
| return { | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction, | ||
| action: "block", | ||
| findings: [ | ||
| { | ||
| signature_id: "malformed-frame", | ||
| category: "RELAY", | ||
| severity: "critical", | ||
| target: "tool_response", | ||
| matched_text_excerpt: "malformed JSON-RPC frame on stdio", | ||
| remediation: "The wrapped MCP server emitted a non-JSON-RPC line (e.g. a startup banner). It must write only JSON-RPC frames to stdout." | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| function logEvent(result, direction, onEvent) { | ||
| if (!result || result.findings.length === 0) return; | ||
| onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction, | ||
| action: result.action, | ||
| findings: result.findings | ||
| }); | ||
| } | ||
| // src/guard/event-log.ts | ||
| import { appendFile, mkdir } from "fs/promises"; | ||
| import path from "path"; | ||
| var EVENT_LOG_FILENAME = "guard-events.jsonl"; | ||
| var _warnedOnFailure = false; | ||
| async function eventLogPath() { | ||
| return path.join(await getStorePath(), EVENT_LOG_FILENAME); | ||
| } | ||
| function buildEventLogEntry(event, serverName) { | ||
| return { | ||
| ts: event.ts, | ||
| server_name: sanitizeForTerminal(serverName), | ||
| direction: event.direction, | ||
| action: event.action, | ||
| findings: event.findings.map((f) => ({ | ||
| signature_id: f.signature_id, | ||
| category: f.category, | ||
| severity: f.severity, | ||
| target: f.target, | ||
| matched_text_excerpt: f.matched_text_excerpt | ||
| })) | ||
| }; | ||
| } | ||
| async function appendEvent(event, serverName) { | ||
| try { | ||
| const filePath = await eventLogPath(); | ||
| await mkdir(path.dirname(filePath), { recursive: true, mode: 448 }); | ||
| const line = `${JSON.stringify(buildEventLogEntry(event, serverName))} | ||
| `; | ||
| await appendFile(filePath, line, { encoding: "utf-8", mode: 384 }); | ||
| } catch (err) { | ||
| if (!_warnedOnFailure) { | ||
| _warnedOnFailure = true; | ||
| process.stderr.write( | ||
| `[mcpm-guard] event log write failed (logging will continue silently): ${err instanceof Error ? err.message : String(err)} | ||
| ` | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| // src/guard/confine/decide.ts | ||
| function decideConfine(input) { | ||
| const { profile, markerHash, markerRequired, backendAvailable } = input; | ||
| const mustConfine = markerRequired || profile?.require_confine === true; | ||
| if (profile !== null) { | ||
| if (markerHash === null) { | ||
| return mustConfine ? { action: "fail-closed", reason: "confine marker stripped on a required server", event: "confine-marker-stripped" } : { action: "unconfined", reason: "confine marker stripped", event: "confine-marker-stripped" }; | ||
| } | ||
| if (hashConfineProfile(profile) !== markerHash) { | ||
| return { action: "fail-closed", reason: "confine profile hash mismatch (tamper)", event: "confine-hash-mismatch" }; | ||
| } | ||
| if (!backendAvailable) { | ||
| return mustConfine ? { action: "fail-closed", reason: "no confine backend on a required server", event: "confine-backend-missing" } : { action: "unconfined", reason: "no confine backend on this platform", event: "confine-backend-missing" }; | ||
| } | ||
| return { action: "confine", reason: "confined", event: "confine-applied" }; | ||
| } | ||
| if (markerRequired) { | ||
| return { action: "fail-closed", reason: "confine required but no stored profile (store missing?)", event: "confine-profile-missing" }; | ||
| } | ||
| if (markerHash !== null) { | ||
| return { action: "unconfined", reason: "confine marker present but no stored profile", event: "confine-profile-missing" }; | ||
| } | ||
| return { action: "unconfined", reason: "not confined" }; | ||
| } | ||
| // src/guard/run-inner.ts | ||
| var SIGNATURE_LIST_VERSION = "owasp-mcp-top-10@v0.5.0"; | ||
| function applyPolicy(result, policy) { | ||
| const overrides = policy.signature_overrides ?? []; | ||
| if (overrides.length === 0) return result; | ||
| const byId = new Map(overrides.map((o) => [o.id, o])); | ||
| let highest = "pass"; | ||
| const kept = []; | ||
| for (const f of result.findings) { | ||
| const o = byId.get(f.signature_id); | ||
| let perFindingAction; | ||
| if (o === void 0) { | ||
| perFindingAction = defaultActionForFinding(f); | ||
| kept.push(f); | ||
| } else if (o.action === "ignore") { | ||
| continue; | ||
| } else if (o.action === "log_only") { | ||
| perFindingAction = "pass"; | ||
| kept.push(f); | ||
| } else { | ||
| perFindingAction = o.action; | ||
| kept.push(f); | ||
| } | ||
| if (ACTION_RANK[perFindingAction] > ACTION_RANK[highest]) highest = perFindingAction; | ||
| } | ||
| return withReplyToOrigin({ action: highest, findings: kept }, result.replyToOrigin === true); | ||
| } | ||
| function confineGuardEvent(event, reason, action, severity) { | ||
| return { | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "parent->child", | ||
| action, | ||
| findings: [ | ||
| { | ||
| signature_id: event, | ||
| category: "CONFINE", | ||
| severity, | ||
| target: "tool_response", | ||
| matched_text_excerpt: reason, | ||
| remediation: "See docs/GUARD.md \u2014 `mcpm guard confine`." | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| async function runInner(parsed) { | ||
| const safeName = sanitizeForTerminal(parsed.serverName); | ||
| if (typeof parsed.origHash === "string" && parsed.origHash.length > 0) { | ||
| const recomputed = hashOriginalEntry(parsed.command, parsed.args, parsed.declaredEnvKeys); | ||
| if (recomputed !== parsed.origHash) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] ORIG-HASH-MISMATCH ${safeName}: the wrapped command/args/declared-env no longer match the integrity hash embedded at \`mcpm guard enable\` time \u2014 the client config entry may have been edited or tampered with. Starting anyway (advisory); a future mcpm release will refuse to start on mismatch. Review ~/.mcpm/guard-events.jsonl, and if you changed the entry on purpose re-run \`mcpm guard enable\` to re-pin it. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| { | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "parent->child", | ||
| action: "warn", | ||
| findings: [ | ||
| { | ||
| signature_id: "orig-hash-mismatch", | ||
| category: "RELAY", | ||
| severity: "high", | ||
| target: "tool_response", | ||
| matched_text_excerpt: "wrap-marker integrity: recomputed hash != embedded --orig-hash", | ||
| remediation: "Re-run `mcpm guard enable` to re-pin, or restore the original wrapped entry in the client config." | ||
| } | ||
| ] | ||
| }, | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| } | ||
| const logEvent2 = (event) => { | ||
| if (event.action === "block" || event.action === "warn") { | ||
| process.stderr.write( | ||
| `[mcpm-guard] ${event.action.toUpperCase()} ${safeName} ${event.findings.map((f) => f.signature_id).join(",")} | ||
| ` | ||
| ); | ||
| void appendEvent(event, parsed.serverName); | ||
| } | ||
| }; | ||
| let pinsSnapshot; | ||
| try { | ||
| pinsSnapshot = await readPins(); | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] PINS-READ-ERROR: ${safeName} could not load ~/.mcpm/pins.json: ${err.message} | ||
| Refusing to start the relay \u2014 running with rug-pull (schema-drift) protection silently disabled is more dangerous than not starting. Review ~/.mcpm/guard-events.jsonl for unauthorized activity. If you intentionally changed pins.json, run \`mcpm guard reset-integrity\`. | ||
| ` | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| const policy = expireStale( | ||
| await readPolicy().catch((err) => { | ||
| if (err instanceof PolicyIntegrityError) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] POLICY-INTEGRITY-ERROR: ${safeName} ${err.message} | ||
| Falling back to full enforcement (ignoring guard-policy.yaml) for this session. | ||
| ` | ||
| ); | ||
| } else { | ||
| process.stderr.write( | ||
| `[mcpm-guard] POLICY-READ-ERROR: ${err.message} | ||
| ` | ||
| ); | ||
| } | ||
| return {}; | ||
| }) | ||
| ); | ||
| const pausedUntilFuture = policy.paused_until !== void 0 && new Date(policy.paused_until) > /* @__PURE__ */ new Date(); | ||
| const sessionState = { | ||
| firstHashes: /* @__PURE__ */ new Map(), | ||
| revalidationArmed: false, | ||
| handshakeSeenHash: null | ||
| }; | ||
| const baselineForDrift = pinsSnapshot; | ||
| const inspectChild = (msg) => { | ||
| if (pausedUntilFuture) return { action: "pass", findings: [] }; | ||
| if (isToolsListChangedNotification(msg)) { | ||
| sessionState.revalidationArmed = true; | ||
| return { action: "pass", findings: [] }; | ||
| } | ||
| const statelessResult = inspectFrame(msg); | ||
| let driftResult = { action: "pass", findings: [] }; | ||
| if (hasToolsList(msg)) { | ||
| driftResult = inspectForDriftSync(msg, parsed.serverName, baselineForDrift, sessionState); | ||
| void (async () => { | ||
| await inspectForDrift(msg, parsed.serverName, { | ||
| read: () => readPins().catch(() => pinsSnapshot), | ||
| write: writePins, | ||
| signatureListVersion: SIGNATURE_LIST_VERSION | ||
| }); | ||
| pinsSnapshot = await readPins().catch(() => pinsSnapshot); | ||
| })(); | ||
| } else if (isInitializeResult(msg)) { | ||
| driftResult = inspectHandshakeDriftSync(msg, parsed.serverName, baselineForDrift, sessionState); | ||
| void (async () => { | ||
| await inspectHandshakeForDrift(msg, parsed.serverName, { | ||
| read: () => readPins().catch(() => pinsSnapshot), | ||
| write: writePins, | ||
| signatureListVersion: SIGNATURE_LIST_VERSION | ||
| }); | ||
| pinsSnapshot = await readPins().catch(() => pinsSnapshot); | ||
| })(); | ||
| } | ||
| return applyPolicy(mergeInspect(statelessResult, driftResult), policy); | ||
| }; | ||
| const inspectParent = (msg) => { | ||
| if (pausedUntilFuture) return { action: "pass", findings: [] }; | ||
| return applyPolicy(inspectMessage(msg, OWASP_MCP_TOP_10), policy); | ||
| }; | ||
| const baselineEnv = buildSafeEnv(process.env); | ||
| const childEnvSource = { ...baselineEnv }; | ||
| for (const key of parsed.declaredEnvKeys) { | ||
| const value = process.env[key]; | ||
| if (value !== void 0) childEnvSource[key] = value; | ||
| } | ||
| let childEnv; | ||
| try { | ||
| childEnv = await resolveEnvPlaceholders(childEnvSource); | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] SECRET-MISSING ${safeName} ${err.message} | ||
| ` | ||
| ); | ||
| return 1; | ||
| } | ||
| if (parsed.confineProfileHash !== void 0 && !/^[0-9a-f]{64}$/.test(parsed.confineProfileHash)) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-BLOCK ${safeName}: malformed --confine-profile-hash in the wrap marker (the client config entry may be tampered or corrupt). Refusing to start. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent( | ||
| "confine-marker-malformed", | ||
| "malformed confine profile hash", | ||
| "block", | ||
| "critical" | ||
| ), | ||
| parsed.serverName | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| let spawnCommand = parsed.command; | ||
| let spawnArgs = parsed.args; | ||
| let confineProfile = null; | ||
| try { | ||
| confineProfile = await loadProfile(parsed.serverName); | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-STORE-ERROR ${safeName}: ${err.message} | ||
| ` | ||
| ); | ||
| } | ||
| const confineDecision = decideConfine({ | ||
| profile: confineProfile, | ||
| markerHash: parsed.confineProfileHash ?? null, | ||
| markerRequired: parsed.confineRequired === true, | ||
| backendAvailable: isConfineBackendAvailable() | ||
| }); | ||
| if (confineDecision.action === "fail-closed") { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-BLOCK ${safeName}: ${confineDecision.reason}. Refusing to start (this server is marked require-confine). Run \`mcpm guard doctor-confine\` to check the backend, and review ~/.mcpm/guard-events.jsonl. | ||
| ` | ||
| ); | ||
| if (confineDecision.event !== void 0) { | ||
| void appendEvent( | ||
| confineGuardEvent(confineDecision.event, confineDecision.reason, "block", "critical"), | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| process.exit(1); | ||
| } | ||
| if (confineDecision.action === "confine" && confineProfile !== null) { | ||
| const wrapped = wrapForConfinement(confineProfile, parsed.command, parsed.args); | ||
| if (wrapped !== null) { | ||
| spawnCommand = wrapped.command; | ||
| spawnArgs = wrapped.args; | ||
| void appendEvent( | ||
| confineGuardEvent( | ||
| confineDecision.event ?? "confine-applied", | ||
| confineDecision.reason, | ||
| "pass", | ||
| "low" | ||
| ), | ||
| parsed.serverName | ||
| ); | ||
| } else { | ||
| const required = parsed.confineRequired === true || confineProfile.require_confine; | ||
| if (required) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-BLOCK ${safeName}: sandbox backend became unavailable at spawn (require-confine). Refusing to start. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent( | ||
| "confine-backend-missing", | ||
| "backend unavailable at wrap", | ||
| "block", | ||
| "critical" | ||
| ), | ||
| parsed.serverName | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-UNCONFINED ${safeName}: sandbox backend unavailable at wrap \u2014 running unconfined. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent("confine-backend-missing", "backend unavailable at wrap", "warn", "high"), | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| } else if (confineDecision.event !== void 0) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-UNCONFINED ${safeName}: ${confineDecision.reason} \u2014 running unconfined. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent(confineDecision.event, confineDecision.reason, "warn", "high"), | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| const handle = startRelay({ | ||
| command: spawnCommand, | ||
| args: spawnArgs, | ||
| env: childEnv, | ||
| parentIn: process.stdin, | ||
| parentOut: process.stdout, | ||
| inspectChildResponse: inspectChild, | ||
| inspectParentRequest: inspectParent, | ||
| onEvent: logEvent2 | ||
| }); | ||
| return handle.exit; | ||
| } | ||
| function sanitizeLabel(s) { | ||
| return sanitizeForTerminal(s, 128); | ||
| } | ||
| function inspectForDriftSync(msg, serverName, baseline, state) { | ||
| const armed = state.revalidationArmed; | ||
| state.revalidationArmed = false; | ||
| const result = msg.result; | ||
| const tools = Array.isArray(result?.tools) ? result.tools : []; | ||
| const findings = []; | ||
| for (const rawTool of tools) { | ||
| const finding = inspectToolDrift(rawTool, serverName, baseline, state, armed); | ||
| if (finding !== null) findings.push(finding); | ||
| } | ||
| const action = findings.reduce((acc, f) => { | ||
| const a = defaultActionForFinding(f); | ||
| return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc; | ||
| }, "pass"); | ||
| return { action, findings }; | ||
| } | ||
| function inspectToolDrift(rawTool, serverName, baseline, state, armed) { | ||
| if (rawTool === null || typeof rawTool !== "object") return null; | ||
| const tool = rawTool; | ||
| const toolName = typeof tool.name === "string" ? tool.name : null; | ||
| if (toolName === null) return null; | ||
| const fields = { | ||
| description: typeof tool.description === "string" ? tool.description : null, | ||
| schema: tool.inputSchema ?? tool.schema, | ||
| annotations: tool.annotations | ||
| }; | ||
| const liveWhole = hashToolDefinition(fields); | ||
| const liveFields = fieldHashesOf(fields); | ||
| const serverPins = Object.hasOwn(baseline.servers, serverName) ? baseline.servers[serverName] : void 0; | ||
| const pinned = serverPins && Object.hasOwn(serverPins, toolName) ? serverPins[toolName] : void 0; | ||
| const sessionKey = `${serverName}::${toolName}`; | ||
| const firstSeen = state.firstHashes.get(sessionKey); | ||
| if (!armed && firstSeen !== void 0 && firstSeen !== liveWhole) { | ||
| return inSessionDriftFinding(serverName, toolName, firstSeen, liveWhole); | ||
| } | ||
| if (firstSeen === void 0 || armed) state.firstHashes.set(sessionKey, liveWhole); | ||
| if (!pinned || pinned.current_hash === null) return null; | ||
| if (liveWhole === pinned.current_hash) return null; | ||
| const cls = classifyDrift(pinned, liveFields); | ||
| const newDescriptionExcerpt = typeof tool.description === "string" ? sanitizeForTerminal(tool.description, 80) : void 0; | ||
| return buildDriftFinding({ | ||
| cls, | ||
| safeServer: sanitizeLabel(serverName), | ||
| safeTool: sanitizeLabel(toolName), | ||
| expected: pinned.current_hash, | ||
| actual: liveWhole, | ||
| newDescriptionExcerpt | ||
| }); | ||
| } | ||
| function inSessionDriftFinding(serverName, toolName, firstSeen, liveWhole) { | ||
| return { | ||
| signature_id: "schema-drift-in-session", | ||
| category: "OWASP-MCP-1", | ||
| severity: "critical", | ||
| target: "tool_description", | ||
| matched_text_excerpt: `${sanitizeLabel(toolName)}: ${firstSeen.slice(7, 19)}\u2026 \u2192 ${liveWhole.slice(7, 19)}\u2026 (same session)`, | ||
| remediation: `Server "${sanitizeLabel(serverName)}" delivered two different schemas for tool "${sanitizeLabel(toolName)}" in the same session. This is a rug-pull attempt; restart the IDE and reinspect the server's source.` | ||
| }; | ||
| } | ||
| function isToolsListChangedNotification(msg) { | ||
| if (!("method" in msg)) return false; | ||
| if (msg.method !== "notifications/tools/list_changed") return false; | ||
| return !("result" in msg); | ||
| } | ||
| function isInitializeResult(msg) { | ||
| if (!("result" in msg)) return false; | ||
| const result = msg.result; | ||
| return result !== null && typeof result === "object" && typeof result.protocolVersion === "string"; | ||
| } | ||
| function inspectHandshakeDriftSync(msg, serverName, baseline, state) { | ||
| const result = msg.result; | ||
| if (result === null || typeof result !== "object") return { action: "pass", findings: [] }; | ||
| const liveFields = handshakeFieldHashesOf(result); | ||
| const liveCapKeys = handshakeCapabilityKeys(result); | ||
| const liveWhole = hashHandshake(liveFields); | ||
| const seen = state.handshakeSeenHash; | ||
| if (seen !== null && seen !== liveWhole) { | ||
| return warnResult(handshakeInSessionFinding(serverName, seen, liveWhole)); | ||
| } | ||
| if (seen === null) state.handshakeSeenHash = liveWhole; | ||
| const pinned = lookupHandshake(baseline, serverName); | ||
| if (pinned === void 0) return { action: "pass", findings: [] }; | ||
| if (liveWhole === pinned.current_hash || pinned.previous_hashes.includes(liveWhole)) { | ||
| return { action: "pass", findings: [] }; | ||
| } | ||
| const cls = classifyHandshakeDrift(pinned, liveFields, liveCapKeys); | ||
| const findings = buildHandshakeDriftFinding({ | ||
| cls, | ||
| safeServer: sanitizeLabel(serverName) | ||
| }); | ||
| const action = findings.reduce((acc, f) => { | ||
| const a = defaultActionForFinding(f); | ||
| return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc; | ||
| }, "pass"); | ||
| return { action, findings }; | ||
| } | ||
| function warnResult(finding) { | ||
| return { action: defaultActionForFinding(finding), findings: [finding] }; | ||
| } | ||
| function handshakeInSessionFinding(serverName, firstSeen, liveWhole) { | ||
| return { | ||
| signature_id: "handshake-drift-in-session", | ||
| category: "OWASP-MCP-1", | ||
| severity: "high", | ||
| target: "initialize_instructions", | ||
| matched_text_excerpt: `${sanitizeLabel(serverName)}: ${firstSeen.slice(7, 19)}\u2026 \u2192 ${liveWhole.slice(7, 19)}\u2026 (same session)`, | ||
| remediation: `Server "${sanitizeLabel(serverName)}" delivered two different initialize handshakes in the same session \u2014 initialize should occur once. Inspect the wrapped command; this is a warn-only signal and does not block the session.` | ||
| }; | ||
| } | ||
| export { | ||
| applyPolicy, | ||
| inspectForDriftSync, | ||
| inspectHandshakeDriftSync, | ||
| isInitializeResult, | ||
| isToolsListChangedNotification, | ||
| runInner | ||
| }; | ||
| //# sourceMappingURL=run-inner-E2H7X7PM.js.map |
Sorry, the diff of this file is too big to display
| #!/usr/bin/env node | ||
| import { | ||
| buildDoctorModel, | ||
| execCheckDefault, | ||
| formatMcpEntryCommand, | ||
| makeCheckConfigExists | ||
| } from "./chunk-DR7HERUD.js"; | ||
| import { | ||
| resolveInstallEntry | ||
| } from "./chunk-LNGTDYGN.js"; | ||
| import { | ||
| readPins | ||
| } from "./chunk-DDCTUMSZ.js"; | ||
| import "./chunk-E3T224S3.js"; | ||
| import { | ||
| fetchNpmProvenance | ||
| } from "./chunk-RTI2GLYX.js"; | ||
| import "./chunk-WYSMWP2R.js"; | ||
| import "./chunk-OIFKZA4V.js"; | ||
| import "./chunk-FEXJHHDM.js"; | ||
| import "./chunk-F6CHEUGO.js"; | ||
| import { | ||
| nativeTrustScore | ||
| } from "./chunk-LSNEZAFR.js"; | ||
| import "./chunk-UNGY7RTE.js"; | ||
| import "./chunk-W4IAFBUN.js"; | ||
| import "./chunk-2PWW3Q5Q.js"; | ||
| import { | ||
| fetchNpmIntegrity | ||
| } from "./chunk-7RJXJERN.js"; | ||
| import "./chunk-K4U7EXLG.js"; | ||
| import "./chunk-NPJ3SGGS.js"; | ||
| import "./chunk-6R7TL5O2.js"; | ||
| import { | ||
| CLIENT_IDS | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import "./chunk-4QDJ3I7X.js"; | ||
| import "./chunk-3X76P3FG.js"; | ||
| import { | ||
| extractRegistryMeta | ||
| } from "./chunk-U7N6FRYF.js"; | ||
| import "./chunk-WT6V33F2.js"; | ||
| // src/server/index.ts | ||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| // src/server/tools.ts | ||
| import { z } from "zod"; | ||
| var serverName = z.string().min(1).max(256); | ||
| var clientId = z.enum(CLIENT_IDS); | ||
| var NoArgsInput = z.strictObject({}); | ||
| var SearchInput = z.strictObject({ | ||
| query: z.string().min(1).max(200), | ||
| limit: z.number().int().min(1).max(100).optional().default(20) | ||
| }); | ||
| var InstallInput = z.strictObject({ | ||
| name: serverName, | ||
| client: clientId.optional(), | ||
| minTrustScore: z.number().min(0).max(100).optional().default(50) | ||
| }); | ||
| var InfoInput = z.strictObject({ | ||
| name: serverName | ||
| }); | ||
| var ListInput = z.strictObject({ | ||
| client: clientId.optional() | ||
| }); | ||
| var RemoveInput = z.strictObject({ | ||
| name: serverName, | ||
| client: clientId.optional() | ||
| }); | ||
| var SetupInput = z.strictObject({ | ||
| description: z.string().min(1).max(1e3), | ||
| client: clientId.optional(), | ||
| minTrustScore: z.number().min(0).max(100).optional().default(50) | ||
| }); | ||
| var UpInput = z.strictObject({ | ||
| stackFile: z.string().optional().default("mcpm.yaml"), | ||
| profile: z.string().optional(), | ||
| dryRun: z.boolean().optional().default(false) | ||
| }); | ||
| // src/server/handlers.ts | ||
| import path from "path"; | ||
| var SERVER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}$/; | ||
| function validateMcpServerName(name) { | ||
| if (typeof name !== "string" || name.length === 0 || name.length > 256) { | ||
| throw new Error(`Invalid server name: must be a non-empty string under 256 characters.`); | ||
| } | ||
| if (!SERVER_NAME_RE.test(name)) { | ||
| throw new Error( | ||
| `Invalid server name format: "${name}". Expected format: "namespace/server-name" (alphanumeric, dots, hyphens, underscores only).` | ||
| ); | ||
| } | ||
| } | ||
| function computeTrust(entry, deps) { | ||
| const findings = deps.scanTier1(entry); | ||
| return deps.computeTrustScore({ | ||
| findings, | ||
| healthCheckPassed: null, | ||
| hasExternalScanner: false, | ||
| registryMeta: extractRegistryMeta(entry) | ||
| }); | ||
| } | ||
| async function resolveClients(requestedClient, deps) { | ||
| const detected = await deps.detectClients(); | ||
| if (detected.length === 0) { | ||
| throw new Error("No supported AI clients found."); | ||
| } | ||
| if (requestedClient !== void 0) { | ||
| if (!CLIENT_IDS.includes(requestedClient)) { | ||
| throw new Error( | ||
| `Unknown client "${requestedClient}". Valid values: ${CLIENT_IDS.join(", ")}.` | ||
| ); | ||
| } | ||
| const id = requestedClient; | ||
| if (!detected.includes(id)) { | ||
| throw new Error(`Client "${requestedClient}" is not installed.`); | ||
| } | ||
| return [id]; | ||
| } | ||
| return detected; | ||
| } | ||
| async function handleSearch(args, deps) { | ||
| const entries = await deps.registrySearch(args.query, args.limit); | ||
| const servers = entries.map((entry) => { | ||
| const trust = computeTrust(entry, deps); | ||
| return { | ||
| name: entry.server.name, | ||
| description: entry.server.description ?? "", | ||
| version: entry.server.version, | ||
| trustScore: trust.score | ||
| }; | ||
| }); | ||
| return { servers }; | ||
| } | ||
| var DEFAULT_MIN_TRUST_SCORE = 50; | ||
| var HARD_TRUST_FLOOR = 25; | ||
| function effectiveMinTrustScore(requested) { | ||
| return Math.max(requested ?? DEFAULT_MIN_TRUST_SCORE, HARD_TRUST_FLOOR); | ||
| } | ||
| async function handleInstall(args, deps, preResolved) { | ||
| validateMcpServerName(args.name); | ||
| const entry = preResolved?.entry ?? await deps.registryGetServer(args.name); | ||
| const trust = preResolved?.trust ?? computeTrust(entry, deps); | ||
| const minScore = effectiveMinTrustScore(args.minTrustScore); | ||
| const nativeTrust = nativeTrustScore(trust); | ||
| if (nativeTrust.score < minScore) { | ||
| throw new Error( | ||
| `Server "${args.name}" has trust score ${nativeTrust.score}/${nativeTrust.maxPossible} (level: ${trust.level}), which is below the minimum threshold of ${minScore}. ` + (nativeTrust.excludedExternalCredit > 0 ? `An external scanner's ${nativeTrust.excludedExternalCredit} points are excluded from this floor because mcpm cannot verify them. ` : "") + `Install rejected for safety. Use mcpm CLI with --yes to override after manual review.` | ||
| ); | ||
| } | ||
| const clients = await resolveClients(args.client, deps); | ||
| const planned = clients.map((clientId2) => { | ||
| const mcpEntry = resolveInstallEntry(entry, clientId2); | ||
| if (mcpEntry.url !== void 0 && mcpEntry.command === void 0) { | ||
| throw new Error( | ||
| `Server "${args.name}" uses a URL/HTTP transport and runs UNGUARDED (the guard relay only wraps stdio servers). Installing it is not permitted via the MCP surface. Use the mcpm CLI with --allow-unguarded after manual review.` | ||
| ); | ||
| } | ||
| return { | ||
| clientId: clientId2, | ||
| adapter: deps.getAdapter(clientId2), | ||
| configPath: deps.getConfigPath(clientId2), | ||
| mcpEntry | ||
| }; | ||
| }); | ||
| const done = []; | ||
| try { | ||
| for (const p of planned) { | ||
| await p.adapter.addServer(p.configPath, args.name, p.mcpEntry); | ||
| done.push(p); | ||
| } | ||
| await deps.addToStore({ | ||
| name: args.name, | ||
| version: entry.server.version, | ||
| clients: done.map((p) => p.clientId), | ||
| installedAt: (/* @__PURE__ */ new Date()).toISOString() | ||
| }); | ||
| } catch (err) { | ||
| const stranded = await rollbackInstall(done, args.name); | ||
| if (stranded.length > 0) { | ||
| throw new Error( | ||
| `${err instanceof Error ? err.message : String(err)} | ||
| Rollback incomplete: "${args.name}" is STILL INSTALLED in ${stranded.join(", ")}. Remove it with \`mcpm remove ${args.name}\` before retrying.` | ||
| ); | ||
| } | ||
| throw err; | ||
| } | ||
| return { | ||
| installed: true, | ||
| name: args.name, | ||
| version: entry.server.version, | ||
| clients: done.map((p) => p.clientId), | ||
| trustScore: trust | ||
| }; | ||
| } | ||
| async function rollbackInstall(done, name) { | ||
| const stranded = []; | ||
| for (const p of done) { | ||
| try { | ||
| await p.adapter.removeServer(p.configPath, name); | ||
| } catch { | ||
| stranded.push(p.clientId); | ||
| } | ||
| } | ||
| return stranded; | ||
| } | ||
| async function handleInfo(args, deps) { | ||
| validateMcpServerName(args.name); | ||
| const entry = await deps.registryGetServer(args.name); | ||
| const trust = computeTrust(entry, deps); | ||
| return { | ||
| name: entry.server.name, | ||
| description: entry.server.description ?? "", | ||
| version: entry.server.version, | ||
| packages: entry.server.packages.map((p) => ({ | ||
| registryType: p.registryType, | ||
| identifier: p.identifier | ||
| })), | ||
| trustScore: trust | ||
| }; | ||
| } | ||
| async function handleList(args, deps) { | ||
| const clients = await resolveClients(args.client, deps); | ||
| const servers = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| const installed = await adapter.read(configPath); | ||
| for (const [name, entry] of Object.entries(installed)) { | ||
| const command = formatMcpEntryCommand(entry, "unknown"); | ||
| servers.push({ name, client: clientId2, command }); | ||
| } | ||
| } | ||
| return { servers }; | ||
| } | ||
| async function handleRemove(args, deps) { | ||
| validateMcpServerName(args.name); | ||
| const clients = await resolveClients(args.client, deps); | ||
| const removedClients = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| try { | ||
| await adapter.removeServer(configPath, args.name); | ||
| removedClients.push(clientId2); | ||
| } catch { | ||
| } | ||
| } | ||
| if (removedClients.length === 0) { | ||
| throw new Error(`Server "${args.name}" not found in any client config.`); | ||
| } | ||
| try { | ||
| await deps.removeFromStore(args.name); | ||
| } catch { | ||
| } | ||
| return { removed: true, name: args.name, clients: removedClients }; | ||
| } | ||
| async function handleAudit(deps) { | ||
| const clients = await deps.detectClients(); | ||
| const results = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| const installed = await adapter.read(configPath); | ||
| for (const name of Object.keys(installed)) { | ||
| try { | ||
| const entry = await deps.registryGetServer(name); | ||
| const trust = computeTrust(entry, deps); | ||
| results.push({ name, client: clientId2, trustScore: trust }); | ||
| } catch { | ||
| results.push({ | ||
| name, | ||
| client: clientId2, | ||
| trustScore: { score: 0, maxPossible: 80, level: "risky", breakdown: { healthCheck: 0, staticScan: 0, externalScan: 0, registryMeta: 0 } } | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| return { results }; | ||
| } | ||
| async function handleDoctor(deps) { | ||
| return buildDoctorModel({ | ||
| getAdapter: deps.getAdapter, | ||
| getConfigPath: deps.getConfigPath, | ||
| checkConfigExists: makeCheckConfigExists(deps.getConfigPath), | ||
| execCheck: execCheckDefault | ||
| }); | ||
| } | ||
| async function handleSetup(args, deps) { | ||
| if (!args.description.trim()) { | ||
| throw new Error("Could not extract any keywords from empty description."); | ||
| } | ||
| const keywords = extractKeywords(args.description); | ||
| const minScore = Math.max( | ||
| effectiveMinTrustScore(args.minTrustScore), | ||
| DEFAULT_MIN_TRUST_SCORE | ||
| ); | ||
| const installed = []; | ||
| const skipped = []; | ||
| const searchResults = await Promise.all( | ||
| keywords.map( | ||
| (kw) => deps.registrySearch(kw, 5).then((entries) => ({ ok: true, entries })).catch((err) => ({ | ||
| ok: false, | ||
| error: err instanceof Error ? err.message : String(err) | ||
| })) | ||
| ) | ||
| ); | ||
| const seenNames = /* @__PURE__ */ new Set(); | ||
| for (let i = 0; i < keywords.length; i++) { | ||
| const keyword = keywords[i]; | ||
| const outcome = searchResults[i]; | ||
| if (!outcome.ok) { | ||
| skipped.push({ name: keyword, reason: `Registry search failed: ${outcome.error}` }); | ||
| continue; | ||
| } | ||
| const entries = outcome.entries; | ||
| if (entries.length === 0) { | ||
| skipped.push({ name: keyword, reason: `No servers found for "${keyword}"` }); | ||
| continue; | ||
| } | ||
| let bestEntry = null; | ||
| let bestTrust = null; | ||
| for (const entry of entries) { | ||
| if (seenNames.has(entry.server.name)) continue; | ||
| const trust = computeTrust(entry, deps); | ||
| if (bestTrust === null || trust.score > bestTrust.score) { | ||
| bestEntry = entry; | ||
| bestTrust = trust; | ||
| } | ||
| } | ||
| if (bestEntry === null || bestTrust === null) { | ||
| skipped.push({ name: keyword, reason: "All results already installed or duplicated" }); | ||
| continue; | ||
| } | ||
| const bestNative = nativeTrustScore(bestTrust); | ||
| if (bestNative.score < minScore) { | ||
| skipped.push({ | ||
| name: bestEntry.server.name, | ||
| reason: `Trust score ${bestNative.score}/${bestNative.maxPossible} is below minimum ${minScore}` + (bestNative.excludedExternalCredit > 0 ? ` (an external scanner's ${bestNative.excludedExternalCredit} points are excluded \u2014 mcpm cannot verify them)` : "") | ||
| }); | ||
| continue; | ||
| } | ||
| try { | ||
| await handleInstall( | ||
| { name: bestEntry.server.name, client: args.client }, | ||
| deps, | ||
| { entry: bestEntry, trust: bestTrust } | ||
| ); | ||
| seenNames.add(bestEntry.server.name); | ||
| installed.push({ name: bestEntry.server.name, trustScore: bestTrust }); | ||
| } catch (err) { | ||
| skipped.push({ | ||
| name: bestEntry.server.name, | ||
| reason: `Install failed: ${err.message}` | ||
| }); | ||
| } | ||
| } | ||
| const note = installed.length > 0 ? "Restart your AI client to use the newly installed servers." : void 0; | ||
| return { installed, skipped, ...note ? { note } : {} }; | ||
| } | ||
| async function handleMcpUp(args, deps) { | ||
| const stackFile = args.stackFile ?? "mcpm.yaml"; | ||
| const resolved = path.resolve(process.cwd(), stackFile); | ||
| if (resolved !== process.cwd() && !resolved.startsWith(process.cwd() + path.sep)) { | ||
| throw new Error("stackFile must be within the working directory"); | ||
| } | ||
| { | ||
| const { realpath } = await import("fs/promises"); | ||
| try { | ||
| const [realStack, realCwd] = await Promise.all([ | ||
| realpath(resolved), | ||
| realpath(process.cwd()) | ||
| ]); | ||
| if (realStack !== realCwd && !realStack.startsWith(realCwd + path.sep)) { | ||
| throw new Error("stackFile must be within the working directory"); | ||
| } | ||
| } catch (err) { | ||
| const code = err.code ?? ""; | ||
| if (!["ENOENT", "ELOOP", "ENOTDIR"].includes(code)) throw err; | ||
| } | ||
| } | ||
| const { handleUp } = await import("./up-IF5B5AKH.js"); | ||
| const { writeFile } = await import("fs/promises"); | ||
| const { handleLock } = await import("./lock-3JKW72C5.js"); | ||
| const { RegistryClient } = await import("./client-3RPMRFZL.js"); | ||
| const { scanTier1: st1 } = await import("./tier1-OPUMS3NX.js"); | ||
| const { checkScannerAvailable: csa, scanTier2: st2 } = await import("./tier2-PI43NCHZ.js"); | ||
| const { computeTrustScore: cts } = await import("./trust-score-BAGF67DE.js"); | ||
| const client = new RegistryClient(); | ||
| const outputLines = []; | ||
| const records = []; | ||
| let thrownError; | ||
| try { | ||
| await handleUp( | ||
| { | ||
| stackFile, | ||
| profile: args.profile, | ||
| dryRun: args.dryRun, | ||
| ci: true, | ||
| yes: false, | ||
| // MCP surface lockdown (fixes C, D & H1): never auto-read ambient | ||
| // secrets from process.env OR the working-directory .env file, and never | ||
| // install URL servers (they bypass the registry trust gate). All three | ||
| // default to true on the CLI; the MCP (untrusted-caller) surface opts in | ||
| // to the locked-down behavior. | ||
| allowProcessEnv: false, | ||
| allowUrlServers: false, | ||
| allowEnvFile: false, | ||
| // M2: the batch `up` path must honor the same non-overridable trust floor | ||
| // the single-install MCP tool enforces (issue #24), so a low-trust server | ||
| // an agent could not install via mcpm_install can't slip in via mcpm_up. | ||
| minTrustFloor: HARD_TRUST_FLOOR | ||
| }, | ||
| { | ||
| detectClients: deps.detectClients, | ||
| getAdapter: deps.getAdapter, | ||
| getPath: deps.getConfigPath, | ||
| getServer: (name, version) => client.getServer(name, version), | ||
| scanTier1: st1, | ||
| checkScannerAvailable: csa, | ||
| scanTier2: (name) => st2(name), | ||
| computeTrustScore: cts, | ||
| runLock: async (stackFile2) => { | ||
| await handleLock( | ||
| { stackFile: stackFile2 }, | ||
| { | ||
| getServerVersions: (name) => client.getServerVersions(name), | ||
| getServer: (name, v) => client.getServer(name, v), | ||
| scanTier1: st1, | ||
| checkScannerAvailable: csa, | ||
| scanTier2: (name) => st2(name), | ||
| computeTrustScore: cts, | ||
| writeLockFile: (path2, content) => writeFile(path2, content, { encoding: "utf-8", mode: 384 }), | ||
| fetchNpmIntegrity, | ||
| fetchNpmProvenance: (id, ver, sri) => fetchNpmProvenance(id, ver, { integritySri: sri }), | ||
| output: (text) => outputLines.push(text) | ||
| } | ||
| ); | ||
| }, | ||
| // Issue #22: never auto-confirm on the MCP (no-human-in-loop) surface. | ||
| // The previous `async () => true` blanket-approved every confirmation, | ||
| // including strict-mode *removals* of servers not in mcpm.yaml — a | ||
| // prompt-injected agent could silently mutate client configs. Refusing | ||
| // confirmation here means destructive prompts are declined; the trust | ||
| // policy still gates installs via checkTrustPolicy in handleUp. | ||
| confirm: async () => false, | ||
| promptEnvVar: async () => "", | ||
| output: (text) => outputLines.push(text), | ||
| fetchNpmIntegrity, | ||
| // F8/B3: wire the provenance re-check on the MCP surface too, or a | ||
| // policy.frozen: true stack run through mcpm_up would silently skip it. | ||
| fetchNpmProvenance: (id, v, o) => fetchNpmProvenance(id, v, o), | ||
| readPins, | ||
| recordResult: (r) => records.push(r) | ||
| } | ||
| ); | ||
| } catch (err) { | ||
| thrownError = err instanceof Error ? err.message : String(err); | ||
| } | ||
| const installed = []; | ||
| const blocked = []; | ||
| const failed = []; | ||
| const skipped = []; | ||
| if (records.length > 0) { | ||
| for (const r of records) { | ||
| switch (r.status) { | ||
| case "installed": | ||
| installed.push(r.name); | ||
| break; | ||
| case "blocked": | ||
| blocked.push(r.name); | ||
| break; | ||
| case "failed": | ||
| failed.push(r.name); | ||
| break; | ||
| case "skipped": | ||
| case "removed": | ||
| skipped.push(r.name); | ||
| break; | ||
| } | ||
| } | ||
| } else { | ||
| for (const line of outputLines) { | ||
| if (line.includes("\u2713")) installed.push(line.trim()); | ||
| else if (line.includes("\u2717") && line.includes("blocked")) blocked.push(line.trim()); | ||
| else if (line.includes("\u2717")) failed.push(line.trim()); | ||
| else if (line.includes("\u2022")) skipped.push(line.trim()); | ||
| } | ||
| } | ||
| return { | ||
| installed, | ||
| blocked, | ||
| failed, | ||
| skipped, | ||
| ...thrownError !== void 0 ? { error: thrownError } : {}, | ||
| ...installed.length > 0 ? { note: "Restart your AI client to use the newly installed servers." } : {} | ||
| }; | ||
| } | ||
| var STOPWORDS = /\b(i need|set up|access|work with|connect to|a server that|a server for|to|the|a|an|my|for|and|with)\b/gi; | ||
| function extractKeywords(description) { | ||
| const cleaned = description.toLowerCase().replace(STOPWORDS, " ").replace(/[,&]/g, " "); | ||
| const tokens = cleaned.split(/\s+/).map((s) => s.trim()).filter((s) => s.length > 2); | ||
| if (tokens.length > 5) { | ||
| return [cleaned.replace(/\s+/g, " ").trim()]; | ||
| } | ||
| return tokens.length > 0 ? tokens : [description.trim()]; | ||
| } | ||
| // src/server/index.ts | ||
| async function createDeps() { | ||
| const { RegistryClient } = await import("./client-3RPMRFZL.js"); | ||
| const { detectInstalledClients } = await import("./detector-ZI4OWRCJ.js"); | ||
| const { getConfigPath } = await import("./paths-US27HRTP.js"); | ||
| const { getAdapter } = await import("./config-XMU247VO.js"); | ||
| const { scanTier1 } = await import("./tier1-OPUMS3NX.js"); | ||
| const { computeTrustScore } = await import("./trust-score-BAGF67DE.js"); | ||
| const { addInstalledServer, removeInstalledServer } = await import("./servers-IS6ZWSYC.js"); | ||
| const client = new RegistryClient(); | ||
| return { | ||
| registrySearch: async (query, limit) => { | ||
| const result = await client.searchServers(query, { limit }); | ||
| return result.servers; | ||
| }, | ||
| registryGetServer: (name) => client.getServer(name), | ||
| detectClients: detectInstalledClients, | ||
| getAdapter, | ||
| getConfigPath, | ||
| scanTier1, | ||
| computeTrustScore, | ||
| addToStore: addInstalledServer, | ||
| removeFromStore: removeInstalledServer | ||
| }; | ||
| } | ||
| function registerTools(server, deps) { | ||
| server.registerTool("mcpm_search", { | ||
| description: "Search the MCP registry for servers with trust scores", | ||
| inputSchema: SearchInput, | ||
| annotations: { readOnlyHint: true } | ||
| }, async (args) => { | ||
| const result = await handleSearch(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_install", { | ||
| description: "Install an MCP server with trust assessment", | ||
| inputSchema: InstallInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleInstall(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_info", { | ||
| description: "Show full details and trust score for an MCP server", | ||
| inputSchema: InfoInput, | ||
| annotations: { readOnlyHint: true } | ||
| }, async (args) => { | ||
| const result = await handleInfo(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_list", { | ||
| description: "List installed MCP servers across AI clients", | ||
| inputSchema: ListInput, | ||
| annotations: { readOnlyHint: true } | ||
| }, async (args) => { | ||
| const result = await handleList(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_remove", { | ||
| description: "Remove an MCP server from client configs", | ||
| inputSchema: RemoveInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleRemove(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_audit", { | ||
| inputSchema: NoArgsInput, | ||
| description: "Scan all installed servers and produce trust report", | ||
| annotations: { readOnlyHint: true } | ||
| }, async () => { | ||
| const result = await handleAudit(deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_doctor", { | ||
| inputSchema: NoArgsInput, | ||
| description: "Check MCP setup health", | ||
| annotations: { readOnlyHint: true } | ||
| }, async () => { | ||
| const result = await handleDoctor(deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_setup", { | ||
| description: "Install MCP servers from a natural language description", | ||
| inputSchema: SetupInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleSetup(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_up", { | ||
| description: "Install all servers from an mcpm.yaml stack file with trust verification. Equivalent to docker-compose up for MCP servers. Runs trust re-assessment and blocks servers that violate the trust policy. Pass profile to install only servers matching that profile, or dryRun to preview what would be installed without making changes.", | ||
| inputSchema: UpInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleMcpUp(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| } | ||
| async function startServer() { | ||
| const deps = await createDeps(); | ||
| const server = new McpServer({ | ||
| name: "mcpm", | ||
| // Issue #22: advertise the real package version (injected by tsup at build), | ||
| // not a hardcoded stale "0.1.0". | ||
| version: "0.29.1" | ||
| }); | ||
| registerTools(server, deps); | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
| } | ||
| export { | ||
| registerTools, | ||
| startServer | ||
| }; | ||
| //# sourceMappingURL=server-B4TJ4OGL.js.map |
| {"version":3,"sources":["../src/server/index.ts","../src/server/tools.ts","../src/server/handlers.ts"],"sourcesContent":["/**\n * MCP server for mcpm — exposes search, install, audit, and setup as tools.\n *\n * Uses @modelcontextprotocol/sdk with stdio transport.\n * All logic delegates to handlers.ts which wraps existing mcpm functions.\n */\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport {\n NoArgsInput,\n SearchInput,\n InstallInput,\n InfoInput,\n ListInput,\n RemoveInput,\n SetupInput,\n UpInput,\n} from \"./tools.js\";\nimport {\n handleSearch,\n handleInstall,\n handleInfo,\n handleList,\n handleRemove,\n handleAudit,\n handleDoctor,\n handleSetup,\n handleMcpUp,\n} from \"./handlers.js\";\nimport type { ServerDeps } from \"./handlers.js\";\n\n// ---------------------------------------------------------------------------\n// Wire up real dependencies\n// ---------------------------------------------------------------------------\n\nasync function createDeps(): Promise<ServerDeps> {\n const { RegistryClient } = await import(\"../registry/client.js\");\n const { detectInstalledClients } = await import(\"../config/detector.js\");\n const { getConfigPath } = await import(\"../config/paths.js\");\n const { getAdapter } = await import(\"../config/index.js\");\n const { scanTier1 } = await import(\"../scanner/tier1.js\");\n const { computeTrustScore } = await import(\"../scanner/trust-score.js\");\n const { addInstalledServer, removeInstalledServer } = await import(\"../store/servers.js\");\n\n const client = new RegistryClient();\n\n return {\n registrySearch: async (query, limit) => {\n const result = await client.searchServers(query, { limit });\n return result.servers;\n },\n registryGetServer: (name) => client.getServer(name),\n detectClients: detectInstalledClients,\n getAdapter,\n getConfigPath,\n scanTier1,\n computeTrustScore,\n addToStore: addInstalledServer,\n removeFromStore: removeInstalledServer,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Server setup\n// ---------------------------------------------------------------------------\n\n/**\n * Register every mcpm tool on the server. Extracted from startServer so the\n * registration can be unit-tested (fix F.1): a test spies registerTool and\n * asserts every TOOL_DEFINITIONS name is registered exactly once, guarding\n * against future tool/registration divergence.\n *\n * `server` is typed loosely as `Pick<McpServer, \"registerTool\">` so tests can\n * pass a lightweight spy without constructing a full McpServer.\n */\nexport function registerTools(\n server: Pick<McpServer, \"registerTool\">,\n deps: ServerDeps\n): void {\n // Register tools using registerTool API\n server.registerTool(\"mcpm_search\", {\n description: \"Search the MCP registry for servers with trust scores\",\n inputSchema: SearchInput,\n annotations: { readOnlyHint: true },\n }, async (args) => {\n const result = await handleSearch(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_install\", {\n description: \"Install an MCP server with trust assessment\",\n inputSchema: InstallInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleInstall(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_info\", {\n description: \"Show full details and trust score for an MCP server\",\n inputSchema: InfoInput,\n annotations: { readOnlyHint: true },\n }, async (args) => {\n const result = await handleInfo(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_list\", {\n description: \"List installed MCP servers across AI clients\",\n inputSchema: ListInput,\n annotations: { readOnlyHint: true },\n }, async (args) => {\n const result = await handleList(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_remove\", {\n description: \"Remove an MCP server from client configs\",\n inputSchema: RemoveInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleRemove(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_audit\", {\n inputSchema: NoArgsInput,\n description: \"Scan all installed servers and produce trust report\",\n annotations: { readOnlyHint: true },\n }, async () => {\n const result = await handleAudit(deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_doctor\", {\n inputSchema: NoArgsInput,\n description: \"Check MCP setup health\",\n annotations: { readOnlyHint: true },\n }, async () => {\n const result = await handleDoctor(deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_setup\", {\n description: \"Install MCP servers from a natural language description\",\n inputSchema: SetupInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleSetup(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_up\", {\n description: \"Install all servers from an mcpm.yaml stack file with trust verification. Equivalent to docker-compose up for MCP servers. Runs trust re-assessment and blocks servers that violate the trust policy. Pass profile to install only servers matching that profile, or dryRun to preview what would be installed without making changes.\",\n inputSchema: UpInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleMcpUp(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n}\n\nexport async function startServer(): Promise<void> {\n const deps = await createDeps();\n\n const server = new McpServer({\n name: \"mcpm\",\n // Issue #22: advertise the real package version (injected by tsup at build),\n // not a hardcoded stale \"0.1.0\".\n version: __PKG_VERSION__,\n });\n\n registerTools(server, deps);\n\n // Start stdio transport\n const transport = new StdioServerTransport();\n await server.connect(transport);\n}\n","/**\n * MCP tool definitions for mcpm serve.\n *\n * Each tool has a name, description, and Zod input schema.\n * Handlers are in handlers.ts.\n */\n\nimport { z } from \"zod\";\nimport { CLIENT_IDS } from \"../config/paths.js\";\n\nexport const TOOL_DEFINITIONS = [\n {\n name: \"mcpm_search\",\n description: \"Search the MCP registry for servers. Returns results with trust scores.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n query: { type: \"string\", description: \"Search query (substring match on server name)\" },\n limit: { type: \"number\", description: \"Max results to return (default 20)\" },\n },\n required: [\"query\"],\n },\n },\n {\n name: \"mcpm_install\",\n description: \"Install an MCP server from the registry into detected AI client configs. Runs trust assessment automatically. Rejects servers below the minimum trust score (default 50).\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n name: { type: \"string\", description: \"Server name (e.g. io.github.domdomegg/filesystem-mcp)\" },\n client: { type: \"string\", description: \"Install to specific client only (claude-desktop, cursor, vscode, windsurf)\" },\n minTrustScore: { type: \"number\", description: \"Minimum trust score to allow install (default 50, range 0-100)\" },\n },\n required: [\"name\"],\n },\n },\n {\n name: \"mcpm_info\",\n description: \"Show full details for an MCP server including trust score breakdown.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n name: { type: \"string\", description: \"Server name\" },\n },\n required: [\"name\"],\n },\n },\n {\n name: \"mcpm_list\",\n description: \"List all installed MCP servers across detected AI clients.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n client: { type: \"string\", description: \"Filter to specific client\" },\n },\n required: [],\n },\n },\n {\n name: \"mcpm_remove\",\n description: \"Remove an MCP server from AI client configs.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n name: { type: \"string\", description: \"Server name to remove\" },\n client: { type: \"string\", description: \"Remove from specific client only\" },\n },\n required: [\"name\"],\n },\n },\n {\n name: \"mcpm_audit\",\n description: \"Scan all installed MCP servers and produce a trust report with scores.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {},\n required: [],\n },\n },\n {\n name: \"mcpm_doctor\",\n description: \"Check MCP setup health: detected clients, available runtimes, configuration issues.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {},\n required: [],\n },\n },\n {\n name: \"mcpm_setup\",\n description: \"Install MCP servers from a natural language description. Searches, evaluates trust, installs the best match for each keyword. Example: 'filesystem and GitHub' installs filesystem + GitHub servers.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n description: { type: \"string\", description: \"What you need (e.g. 'filesystem access and GitHub integration')\" },\n client: { type: \"string\", description: \"Install to specific client only\" },\n minTrustScore: { type: \"number\", description: \"Minimum trust score to auto-install (default 50, range 0-100)\" },\n },\n required: [\"description\"],\n },\n },\n {\n name: \"mcpm_up\",\n description: \"Install all servers from an mcpm.yaml stack file with trust verification. Equivalent to docker-compose up for MCP servers. Runs trust re-assessment and blocks servers that violate the trust policy. Pass profile to install only servers matching that profile, or dryRun to preview what would be installed without making changes.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n stackFile: { type: \"string\", description: \"Path to mcpm.yaml (default: mcpm.yaml in CWD)\" },\n profile: { type: \"string\", description: \"Install only servers matching this profile\" },\n dryRun: { type: \"boolean\", description: \"Show what would be installed without making changes\" },\n },\n required: [],\n },\n },\n] as const;\n\n// Shared field schemas (security #31): a bounded server-name string and a closed\n// client enum, so the Zod layer — not just the runtime `validateMcpServerName` /\n// `CLIENT_IDS.includes` checks in handlers.ts — is the declarative enforcement\n// point. The objects below are `strictObject` so unknown keys are rejected\n// instead of silently dropped.\n//\n// These are passed to `registerTool` WHOLE (not via `.shape`) — see\n// server/index.ts. That distinction is load-bearing: the SDK accepts either a\n// raw shape or a full schema, but a raw shape is rebuilt as a plain\n// `z.object(shape)`, which silently DROPS the object-level `strict` setting.\n// Per-field constraints (the length bound, the client enum) survive either way;\n// strictness does not.\n//\n// Passing the whole schema means the SDK rejects unknown keys with a JSON-RPC\n// -32602 `unrecognized_keys` error, AND advertises `additionalProperties: false`\n// in `tools/list` so a caller can see the contract before calling. Verified over\n// a real in-memory MCP transport in server-strict-schema.test.ts.\n//\n// The runtime guards in handlers.ts (`validateMcpServerName`, `CLIENT_IDS`)\n// remain as defence in depth.\nconst serverName = z.string().min(1).max(256);\nconst clientId = z.enum(CLIENT_IDS);\n\n/**\n * Zero-argument tools (`mcpm_audit`, `mcpm_doctor`) still declare a CLOSED\n * schema rather than omitting `inputSchema` entirely. Omitting it advertises no\n * `additionalProperties: false`, so any argument a caller passes is silently\n * ignored — for a tool that takes nothing, that means EVERY argument is\n * silently ignored. An empty strict object makes the contract explicit and\n * turns a mistaken call into a clear error.\n */\nexport const NoArgsInput = z.strictObject({});\n\nexport const SearchInput = z.strictObject({\n query: z.string().min(1).max(200),\n limit: z.number().int().min(1).max(100).optional().default(20),\n});\n\nexport const InstallInput = z.strictObject({\n name: serverName,\n client: clientId.optional(),\n minTrustScore: z.number().min(0).max(100).optional().default(50),\n});\n\nexport const InfoInput = z.strictObject({\n name: serverName,\n});\n\nexport const ListInput = z.strictObject({\n client: clientId.optional(),\n});\n\nexport const RemoveInput = z.strictObject({\n name: serverName,\n client: clientId.optional(),\n});\n\nexport const SetupInput = z.strictObject({\n description: z.string().min(1).max(1000),\n client: clientId.optional(),\n minTrustScore: z.number().min(0).max(100).optional().default(50),\n});\n\nexport const UpInput = z.strictObject({\n stackFile: z.string().optional().default(\"mcpm.yaml\"),\n profile: z.string().optional(),\n dryRun: z.boolean().optional().default(false),\n});\n","/**\n * MCP tool handlers for mcpm serve.\n *\n * Each handler wraps existing mcpm logic and returns structured JSON.\n * All dependencies are injectable for testability.\n */\n\nimport path from \"node:path\";\nimport type { ClientId } from \"../config/paths.js\";\nimport { CLIENT_IDS } from \"../config/paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"../config/adapters/index.js\";\nimport type { ServerEntry } from \"../registry/types.js\";\nimport type { Finding } from \"../scanner/tier1.js\";\nimport type { TrustScore, TrustScoreInput } from \"../scanner/trust-score.js\";\nimport { nativeTrustScore } from \"../scanner/trust-score.js\";\nimport { extractRegistryMeta } from \"../utils/format-trust.js\";\nimport { formatMcpEntryCommand } from \"../utils/format-entry.js\";\nimport { resolveInstallEntry } from \"../commands/install.js\";\nimport { buildDoctorModel, makeCheckConfigExists, execCheckDefault } from \"../commands/doctor.js\";\nimport { fetchNpmIntegrity as _fetchNpmIntegrity } from \"../registry/npm-integrity.js\";\nimport { fetchNpmProvenance as _fetchNpmProvenance } from \"../registry/npm-provenance.js\";\nimport { readPins as _readPins } from \"../guard/pins.js\";\n\n// ---------------------------------------------------------------------------\n// Input validation for MCP server tool arguments\n// ---------------------------------------------------------------------------\n\n/**\n * Server name pattern for MCP registry names.\n * Format: \"namespace/server-name\" — alphanumeric with dots, hyphens, underscores.\n * Max length 256 to prevent abuse. Must not contain shell metacharacters,\n * path traversal sequences, or control characters.\n */\nconst SERVER_NAME_RE =\n /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}\\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}$/;\n\n/**\n * Validate a server name received from an MCP tool call.\n * This is the trust boundary — AI agents provide these strings, and they\n * could be influenced by prompt injection or adversarial inputs.\n */\nfunction validateMcpServerName(name: string): void {\n if (typeof name !== \"string\" || name.length === 0 || name.length > 256) {\n throw new Error(`Invalid server name: must be a non-empty string under 256 characters.`);\n }\n if (!SERVER_NAME_RE.test(name)) {\n throw new Error(\n `Invalid server name format: \"${name}\". Expected format: \"namespace/server-name\" ` +\n `(alphanumeric, dots, hyphens, underscores only).`\n );\n }\n}\n\n// ---------------------------------------------------------------------------\n// Dependency injection types\n// ---------------------------------------------------------------------------\n\nexport interface ServerDeps {\n registrySearch: (query: string, limit: number) => Promise<ServerEntry[]>;\n registryGetServer: (name: string) => Promise<ServerEntry>;\n detectClients: () => Promise<ClientId[]>;\n getAdapter: (clientId: ClientId) => ConfigAdapter;\n getConfigPath: (clientId: ClientId) => string;\n scanTier1: (server: ServerEntry) => Finding[];\n computeTrustScore: (input: TrustScoreInput) => TrustScore;\n addToStore: (server: { name: string; version: string; clients: ClientId[]; installedAt: string }) => Promise<void>;\n removeFromStore: (name: string) => Promise<void>;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * F4 scope note: this helper deliberately does NOT include the\n * release-cooldown finding (ServerDeps has no injectable clock; the F4 spec\n * file list excludes server/). Consequence: mcpm_install / mcpm_search score\n * a fresh (<24h) package up to 5 points higher than CLI install/why AND than\n * the sibling mcpm_up tool (which inherits the finding via up.ts\n * processServer), and HARD_TRUST_FLOOR evaluates that inflated score — do NOT\n * compensate by raising the floor. Fast-follow is mechanical:\n * ServerDeps += now?: () => number, then append\n * assessReleaseAge({...}).finding here; no schema changes.\n */\nfunction computeTrust(entry: ServerEntry, deps: ServerDeps): TrustScore {\n const findings = deps.scanTier1(entry);\n return deps.computeTrustScore({\n findings,\n healthCheckPassed: null,\n hasExternalScanner: false,\n registryMeta: extractRegistryMeta(entry),\n });\n}\n\nasync function resolveClients(\n requestedClient: string | undefined,\n deps: ServerDeps\n): Promise<ClientId[]> {\n const detected = await deps.detectClients();\n if (detected.length === 0) {\n throw new Error(\"No supported AI clients found.\");\n }\n if (requestedClient !== undefined) {\n if (!CLIENT_IDS.includes(requestedClient as ClientId)) {\n throw new Error(\n `Unknown client \"${requestedClient}\". Valid values: ${CLIENT_IDS.join(\", \")}.`\n );\n }\n const id = requestedClient as ClientId;\n if (!detected.includes(id)) {\n throw new Error(`Client \"${requestedClient}\" is not installed.`);\n }\n return [id];\n }\n return detected;\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nexport async function handleSearch(\n args: { query: string; limit: number },\n deps: ServerDeps\n): Promise<object> {\n const entries = await deps.registrySearch(args.query, args.limit);\n const servers = entries.map((entry) => {\n const trust = computeTrust(entry, deps);\n return {\n name: entry.server.name,\n description: entry.server.description ?? \"\",\n version: entry.server.version,\n trustScore: trust.score,\n };\n });\n return { servers };\n}\n\n/** Default minimum trust score for MCP server tool installs (no human in the loop). */\nconst DEFAULT_MIN_TRUST_SCORE = 50;\n\n/**\n * Hard, non-overridable trust floor for the MCP server surface (issue #24).\n *\n * The MCP `minTrustScore` input accepts `0`, which a prompt-injected agent could\n * pass to disable the install gate entirely. We clamp the effective threshold to\n * `Math.max(userValue, HARD_TRUST_FLOOR)` so no caller-supplied value can lower\n * the gate below this floor. This protects the no-human-in-loop path; the CLI\n * (with a human confirmation prompt) is the only place to install below it.\n *\n * Lowering the gate is only half of it. A score can also be pushed UP to meet\n * the gate, and `MCPM_EXTERNAL_SCANNER` is caller-supplied too — so the floor is\n * evaluated against `nativeTrustScore`, which excludes the external bucket's\n * unverifiable credit (TODOS #33).\n */\nconst HARD_TRUST_FLOOR = 25;\n\n/** Clamp a requested minimum trust score so it can never sink below the floor. */\nfunction effectiveMinTrustScore(requested: number | undefined): number {\n return Math.max(requested ?? DEFAULT_MIN_TRUST_SCORE, HARD_TRUST_FLOOR);\n}\n\nexport async function handleInstall(\n args: { name: string; client?: string; minTrustScore?: number },\n deps: ServerDeps,\n preResolved?: { entry: ServerEntry; trust: TrustScore }\n): Promise<object> {\n validateMcpServerName(args.name);\n const entry = preResolved?.entry ?? await deps.registryGetServer(args.name);\n const trust = preResolved?.trust ?? computeTrust(entry, deps);\n\n // Security gate: reject servers below the minimum trust score.\n // Unlike the CLI path which has a human confirmation prompt, the MCP server\n // path is driven by AI agents with no human in the loop. A malicious prompt\n // could trick an agent into installing a dangerous server, so we enforce a\n // hard trust floor here. Issue #24: minTrustScore:0 must NOT disable the gate —\n // the effective threshold is clamped to HARD_TRUST_FLOOR.\n //\n // TODOS #33: compared against mcpm's OWN evidence. `computeTrust` above already\n // passes `hasExternalScanner: false`, so today this subtracts nothing — it is\n // here so that wiring a scanner into this path later cannot silently reopen the\n // floor, which is the failure mode #33 found on the sibling `mcpm_up` path.\n const minScore = effectiveMinTrustScore(args.minTrustScore);\n const nativeTrust = nativeTrustScore(trust);\n if (nativeTrust.score < minScore) {\n throw new Error(\n `Server \"${args.name}\" has trust score ${nativeTrust.score}/${nativeTrust.maxPossible} ` +\n `(level: ${trust.level}), which is below the minimum threshold of ${minScore}. ` +\n (nativeTrust.excludedExternalCredit > 0\n ? `An external scanner's ${nativeTrust.excludedExternalCredit} points are excluded from this floor because mcpm cannot verify them. `\n : \"\") +\n `Install rejected for safety. Use mcpm CLI with --yes to override after manual review.`\n );\n }\n\n const clients = await resolveClients(args.client, deps);\n\n // PRE-FLIGHT: resolve and validate EVERY client's entry before touching a single\n // config. resolveInstallEntry's URL rule is cursor-ONLY, so a server carrying both\n // an npm package and an http remote used to install cleanly on claude-desktop and\n // only THEN hit the H9 deny on cursor — the agent was told the install failed while\n // a live execution surface sat in Claude Desktop, with no store record of it.\n const planned = clients.map((clientId) => {\n const mcpEntry = resolveInstallEntry(entry, clientId);\n // H9 (fail-closed): a URL/HTTP-transport entry (url, no command) runs\n // UNGUARDED — the guard relay only wraps a stdio process. The MCP surface is\n // driven by an untrusted agent with no human in the loop and no\n // `--allow-unguarded` opt-in, so url-transport installs are HARD-DENIED here\n // (mirrors the batch `up` MCP wiring's allowUrlServers:false kill-switch).\n if (mcpEntry.url !== undefined && mcpEntry.command === undefined) {\n throw new Error(\n `Server \"${args.name}\" uses a URL/HTTP transport and runs UNGUARDED ` +\n `(the guard relay only wraps stdio servers). Installing it is not permitted ` +\n `via the MCP surface. Use the mcpm CLI with --allow-unguarded after manual review.`\n );\n }\n return {\n clientId,\n adapter: deps.getAdapter(clientId),\n configPath: deps.getConfigPath(clientId),\n mcpEntry,\n };\n });\n\n // APPLY as a unit. Any failure — a client write or the store write — unwinds every\n // write already made, so this tool never reports failure over a live install.\n // The store write is inside the transaction on purpose: configs written without a\n // store record are invisible to `mcpm list` / `audit` and survive `mcpm remove`.\n const done: PlannedInstall[] = [];\n try {\n for (const p of planned) {\n await p.adapter.addServer(p.configPath, args.name, p.mcpEntry);\n done.push(p);\n }\n await deps.addToStore({\n name: args.name,\n version: entry.server.version,\n clients: done.map((p) => p.clientId),\n installedAt: new Date().toISOString(),\n });\n } catch (err) {\n const stranded = await rollbackInstall(done, args.name);\n if (stranded.length > 0) {\n // Rollback is best-effort and can fail too. Swallowing that would report a\n // clean failure over a server that is still installed — name it instead.\n throw new Error(\n `${err instanceof Error ? err.message : String(err)}\\n\\n` +\n `Rollback incomplete: \"${args.name}\" is STILL INSTALLED in ${stranded.join(\", \")}. ` +\n `Remove it with \\`mcpm remove ${args.name}\\` before retrying.`\n );\n }\n throw err;\n }\n\n return {\n installed: true,\n name: args.name,\n version: entry.server.version,\n clients: done.map((p) => p.clientId),\n trustScore: trust,\n };\n}\n\n/** One client's fully-resolved install, validated and ready to write. */\ntype PlannedInstall = {\n clientId: ClientId;\n adapter: ConfigAdapter;\n configPath: string;\n mcpEntry: McpServerEntry;\n};\n\n/**\n * Undo the client-config writes already made by a failed install.\n * @returns the clients that could NOT be rolled back (still installed).\n */\nasync function rollbackInstall(done: PlannedInstall[], name: string): Promise<ClientId[]> {\n const stranded: ClientId[] = [];\n for (const p of done) {\n try {\n await p.adapter.removeServer(p.configPath, name);\n } catch {\n stranded.push(p.clientId);\n }\n }\n return stranded;\n}\n\nexport async function handleInfo(\n args: { name: string },\n deps: ServerDeps\n): Promise<object> {\n validateMcpServerName(args.name);\n const entry = await deps.registryGetServer(args.name);\n const trust = computeTrust(entry, deps);\n return {\n name: entry.server.name,\n description: entry.server.description ?? \"\",\n version: entry.server.version,\n packages: entry.server.packages.map((p) => ({\n registryType: p.registryType,\n identifier: p.identifier,\n })),\n trustScore: trust,\n };\n}\n\nexport async function handleList(\n args: { client?: string },\n deps: ServerDeps\n): Promise<object> {\n const clients = await resolveClients(args.client, deps);\n const servers: Array<{ name: string; client: string; command: string }> = [];\n\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n const installed = await adapter.read(configPath);\n\n for (const [name, entry] of Object.entries(installed)) {\n const command = formatMcpEntryCommand(entry, \"unknown\");\n servers.push({ name, client: clientId, command });\n }\n }\n\n return { servers };\n}\n\nexport async function handleRemove(\n args: { name: string; client?: string },\n deps: ServerDeps\n): Promise<object> {\n validateMcpServerName(args.name);\n const clients = await resolveClients(args.client, deps);\n const removedClients: ClientId[] = [];\n\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n try {\n await adapter.removeServer(configPath, args.name);\n removedClients.push(clientId);\n } catch {\n // Server not in this client, skip\n }\n }\n\n if (removedClients.length === 0) {\n throw new Error(`Server \"${args.name}\" not found in any client config.`);\n }\n\n try {\n await deps.removeFromStore(args.name);\n } catch {\n // Not in store, fine\n }\n\n return { removed: true, name: args.name, clients: removedClients };\n}\n\nexport async function handleAudit(deps: ServerDeps): Promise<object> {\n const clients = await deps.detectClients();\n const results: Array<{ name: string; client: string; trustScore: TrustScore }> = [];\n\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n const installed = await adapter.read(configPath);\n\n for (const name of Object.keys(installed)) {\n try {\n const entry = await deps.registryGetServer(name);\n const trust = computeTrust(entry, deps);\n results.push({ name, client: clientId, trustScore: trust });\n } catch {\n results.push({\n name,\n client: clientId,\n trustScore: { score: 0, maxPossible: 80, level: \"risky\", breakdown: { healthCheck: 0, staticScan: 0, externalScan: 0, registryMeta: 0 } },\n });\n }\n }\n }\n\n return { results };\n}\n\nexport async function handleDoctor(deps: ServerDeps): Promise<object> {\n // Reuse the CLI's structured model so this tool reports real issues instead of\n // the formerly-hardcoded `issues: []` (D7). Honors the injected getConfigPath.\n return buildDoctorModel({\n getAdapter: deps.getAdapter,\n getConfigPath: deps.getConfigPath,\n checkConfigExists: makeCheckConfigExists(deps.getConfigPath),\n execCheck: execCheckDefault,\n });\n}\n\nexport async function handleSetup(\n args: { description: string; client?: string; minTrustScore: number },\n deps: ServerDeps\n): Promise<object> {\n if (!args.description.trim()) {\n throw new Error(\"Could not extract any keywords from empty description.\");\n }\n const keywords = extractKeywords(args.description);\n\n // Issue #24: clamp to the hard floor so minTrustScore:0 can't disable the gate\n // on the no-human-in-loop setup path either.\n //\n // Also clamp UP to handleInstall's own default. This pre-filter delegates to\n // handleInstall without forwarding minTrustScore, so the enforcing gate always\n // applies DEFAULT_MIN_TRUST_SCORE. With a requested 25-49 the two disagreed:\n // the pre-filter waved the server through and handleInstall then refused it,\n // reporting a trust rejection as \"Install failed\" and quoting a threshold the\n // caller never asked for. Taking the stricter of the two makes the pre-filter\n // report exactly what the enforcing gate will do. Deliberately NOT fixed by\n // forwarding minTrustScore instead -- that would let a caller-supplied 30\n // LOWER the gate this path enforces today.\n const minScore = Math.max(\n effectiveMinTrustScore(args.minTrustScore),\n DEFAULT_MIN_TRUST_SCORE,\n );\n\n const installed: Array<{ name: string; trustScore: TrustScore }> = [];\n const skipped: Array<{ name: string; reason: string }> = [];\n\n // Parallel search pass — all keywords searched concurrently. Capture the\n // thrown error per keyword so a registry outage is distinguishable from a\n // genuine empty result (both otherwise look like \"no servers\").\n type SearchOutcome =\n | { ok: true; entries: ServerEntry[] }\n | { ok: false; error: string };\n const searchResults: SearchOutcome[] = await Promise.all(\n keywords.map((kw) =>\n deps\n .registrySearch(kw, 5)\n .then((entries): SearchOutcome => ({ ok: true, entries }))\n .catch((err): SearchOutcome => ({\n ok: false,\n error: err instanceof Error ? err.message : String(err),\n }))\n )\n );\n\n const seenNames = new Set<string>();\n\n // Sequential evaluate/install pass (installs depend on previous state)\n for (let i = 0; i < keywords.length; i++) {\n const keyword = keywords[i];\n const outcome = searchResults[i];\n\n if (!outcome.ok) {\n skipped.push({ name: keyword, reason: `Registry search failed: ${outcome.error}` });\n continue;\n }\n\n const entries = outcome.entries;\n\n if (entries.length === 0) {\n skipped.push({ name: keyword, reason: `No servers found for \"${keyword}\"` });\n continue;\n }\n\n let bestEntry: ServerEntry | null = null;\n let bestTrust: TrustScore | null = null;\n\n for (const entry of entries) {\n if (seenNames.has(entry.server.name)) continue;\n const trust = computeTrust(entry, deps);\n if (bestTrust === null || trust.score > bestTrust.score) {\n bestEntry = entry;\n bestTrust = trust;\n }\n }\n\n if (bestEntry === null || bestTrust === null) {\n skipped.push({ name: keyword, reason: \"All results already installed or duplicated\" });\n continue;\n }\n\n // TODOS #33: the same native-evidence rule as the sibling gates. handleInstall\n // below re-checks and is the enforcing gate, so this pre-filter exists to\n // produce an accurate \"skipped\" reason rather than an \"Install failed\" one —\n // but it must agree with it, or a server rejected downstream gets reported\n // under the wrong heading with a score that was never compared.\n const bestNative = nativeTrustScore(bestTrust);\n if (bestNative.score < minScore) {\n skipped.push({\n name: bestEntry.server.name,\n reason:\n `Trust score ${bestNative.score}/${bestNative.maxPossible} is below minimum ${minScore}` +\n (bestNative.excludedExternalCredit > 0\n ? ` (an external scanner's ${bestNative.excludedExternalCredit} points are excluded — mcpm cannot verify them)`\n : \"\"),\n });\n continue;\n }\n\n try {\n await handleInstall(\n { name: bestEntry.server.name, client: args.client },\n deps,\n { entry: bestEntry, trust: bestTrust }\n );\n seenNames.add(bestEntry.server.name);\n installed.push({ name: bestEntry.server.name, trustScore: bestTrust });\n } catch (err) {\n skipped.push({\n name: bestEntry.server.name,\n reason: `Install failed: ${(err as Error).message}`,\n });\n }\n }\n\n const note = installed.length > 0\n ? \"Restart your AI client to use the newly installed servers.\"\n : undefined;\n\n return { installed, skipped, ...(note ? { note } : {}) };\n}\n\n// ---------------------------------------------------------------------------\n// mcpm_up — batch install from stack file\n// ---------------------------------------------------------------------------\n\nexport async function handleMcpUp(\n args: { stackFile?: string; profile?: string; dryRun?: boolean },\n deps: ServerDeps\n): Promise<{\n installed: string[];\n blocked: string[];\n failed: string[];\n skipped: string[];\n error?: string;\n note?: string;\n}> {\n // Validate stackFile path (AI agent trust boundary). Zod defaults stackFile to\n // \"mcpm.yaml\", so the old `if (args.stackFile !== undefined)` guard was dead.\n // Enforce real containment unconditionally via resolved paths: path.resolve\n // normalizes Windows backslashes and \"..\", so this catches traversal and\n // absolute escapes that string-only checks miss.\n const stackFile = args.stackFile ?? \"mcpm.yaml\";\n const resolved = path.resolve(process.cwd(), stackFile);\n if (\n resolved !== process.cwd() &&\n !resolved.startsWith(process.cwd() + path.sep)\n ) {\n throw new Error(\"stackFile must be within the working directory\");\n }\n // M3: the lexical check above catches \"../\" and absolute escapes, but NOT a\n // symlink that lives inside cwd yet points outside it — the file reader would\n // follow it (arbitrary out-of-tree read). Resolve the REAL path and re-check.\n // realpath throws ENOENT when the file does not exist yet; that's fine — handleUp\n // reports the missing file. A containment failure thrown inside the try is not\n // an ErrnoException, so the catch re-throws it.\n {\n const { realpath } = await import(\"node:fs/promises\");\n try {\n const [realStack, realCwd] = await Promise.all([\n realpath(resolved),\n realpath(process.cwd()),\n ]);\n if (realStack !== realCwd && !realStack.startsWith(realCwd + path.sep)) {\n throw new Error(\"stackFile must be within the working directory\");\n }\n } catch (err) {\n // ENOENT (no such file), ELOOP (circular symlink), and ENOTDIR (a path\n // component is a file) all mean \"no real path to contain\" — fall through and\n // let handleUp report the missing/invalid file. Re-throwing them would leak a\n // raw internal ErrnoException (with stack) to the untrusted caller. The\n // containment Error thrown just above has no `.code`, so it still propagates.\n const code = (err as NodeJS.ErrnoException).code ?? \"\";\n if (![\"ENOENT\", \"ELOOP\", \"ENOTDIR\"].includes(code)) throw err;\n }\n }\n\n const { handleUp } = await import(\"../commands/up.js\");\n const { writeFile } = await import(\"fs/promises\");\n const { handleLock } = await import(\"../commands/lock.js\");\n const { RegistryClient } = await import(\"../registry/client.js\");\n const { scanTier1: st1 } = await import(\"../scanner/tier1.js\");\n const { checkScannerAvailable: csa, scanTier2: st2 } = await import(\"../scanner/tier2.js\");\n const { computeTrustScore: cts } = await import(\"../scanner/trust-score.js\");\n\n const client = new RegistryClient();\n const outputLines: string[] = [];\n // Fix A/D: structured per-server results from handleUp. Authoritative source\n // for categorization — emoji-scraping cannot distinguish blocked from failed.\n const records: Array<{ name: string; status: string }> = [];\n let thrownError: string | undefined;\n\n try {\n await handleUp(\n {\n stackFile,\n profile: args.profile,\n dryRun: args.dryRun,\n ci: true,\n yes: false,\n // MCP surface lockdown (fixes C, D & H1): never auto-read ambient\n // secrets from process.env OR the working-directory .env file, and never\n // install URL servers (they bypass the registry trust gate). All three\n // default to true on the CLI; the MCP (untrusted-caller) surface opts in\n // to the locked-down behavior.\n allowProcessEnv: false,\n allowUrlServers: false,\n allowEnvFile: false,\n // M2: the batch `up` path must honor the same non-overridable trust floor\n // the single-install MCP tool enforces (issue #24), so a low-trust server\n // an agent could not install via mcpm_install can't slip in via mcpm_up.\n minTrustFloor: HARD_TRUST_FLOOR,\n },\n {\n detectClients: deps.detectClients,\n getAdapter: deps.getAdapter,\n getPath: deps.getConfigPath,\n getServer: (name, version?) => client.getServer(name, version),\n scanTier1: st1,\n checkScannerAvailable: csa,\n scanTier2: (name) => st2(name),\n computeTrustScore: cts,\n runLock: async (stackFile) => {\n await handleLock(\n { stackFile },\n {\n getServerVersions: (name) => client.getServerVersions(name),\n getServer: (name, v?) => client.getServer(name, v),\n scanTier1: st1,\n checkScannerAvailable: csa,\n scanTier2: (name) => st2(name),\n computeTrustScore: cts,\n writeLockFile: (path, content) =>\n writeFile(path, content, { encoding: \"utf-8\", mode: 0o600 }),\n fetchNpmIntegrity: _fetchNpmIntegrity,\n fetchNpmProvenance: (id, ver, sri) => _fetchNpmProvenance(id, ver, { integritySri: sri }),\n output: (text) => outputLines.push(text),\n }\n );\n },\n // Issue #22: never auto-confirm on the MCP (no-human-in-loop) surface.\n // The previous `async () => true` blanket-approved every confirmation,\n // including strict-mode *removals* of servers not in mcpm.yaml — a\n // prompt-injected agent could silently mutate client configs. Refusing\n // confirmation here means destructive prompts are declined; the trust\n // policy still gates installs via checkTrustPolicy in handleUp.\n confirm: async () => false,\n promptEnvVar: async () => \"\",\n output: (text) => outputLines.push(text),\n fetchNpmIntegrity: _fetchNpmIntegrity,\n // F8/B3: wire the provenance re-check on the MCP surface too, or a\n // policy.frozen: true stack run through mcpm_up would silently skip it.\n fetchNpmProvenance: (id, v, o) => _fetchNpmProvenance(id, v, o),\n readPins: _readPins,\n recordResult: (r) => records.push(r),\n }\n );\n } catch (err) {\n // Fix A: handleUp throws on early/whole-batch failures (no clients, lock-file\n // creation failure, missing required env in CI, the summary \"N could not be\n // installed\" throw, etc.). The previous bare catch swallowed these into a\n // clean-looking empty result. Capture the message so the caller can never\n // mistake a thrown failure for success.\n thrownError = err instanceof Error ? err.message : String(err);\n }\n\n const installed: string[] = [];\n const blocked: string[] = [];\n const failed: string[] = [];\n const skipped: string[] = [];\n\n if (records.length > 0) {\n // Authoritative path (fix D, F.3/F.5): categorize from handleUp's typed\n // per-server statuses. Unlike emoji-scraping, this reliably separates\n // \"blocked\" (policy/URL-lockdown) from \"failed\".\n for (const r of records) {\n switch (r.status) {\n case \"installed\": installed.push(r.name); break;\n case \"blocked\": blocked.push(r.name); break;\n case \"failed\": failed.push(r.name); break;\n case \"skipped\":\n case \"removed\": skipped.push(r.name); break;\n }\n }\n } else {\n // Fallback for the no-record path (e.g. a throw before any server is\n // processed): preserve the original output-line parsing.\n for (const line of outputLines) {\n if (line.includes(\"\\u2713\")) installed.push(line.trim());\n else if (line.includes(\"\\u2717\") && line.includes(\"blocked\")) blocked.push(line.trim());\n else if (line.includes(\"\\u2717\")) failed.push(line.trim());\n else if (line.includes(\"\\u2022\")) skipped.push(line.trim());\n }\n }\n\n // Fix A, refined for M1: a thrown handleUp failure MUST be signaled \\u2014 but only\n // via the top-level `error` field (set in the return below). The previous\n // version pushed the error *message* into `failed`, which is contracted to hold\n // server NAMES; a consumer iterating it as names got a stray sentence. `error`\n // is the authoritative batch-failure signal; `failed` stays names-only (genuine\n // per-server failures are already recorded into it above via `records`).\n\n return {\n installed,\n blocked,\n failed,\n skipped,\n ...(thrownError !== undefined ? { error: thrownError } : {}),\n ...(installed.length > 0\n ? { note: \"Restart your AI client to use the newly installed servers.\" }\n : {}),\n };\n}\n\n// ---------------------------------------------------------------------------\n// Keyword extraction\n// ---------------------------------------------------------------------------\n\nconst STOPWORDS = /\\b(i need|set up|access|work with|connect to|a server that|a server for|to|the|a|an|my|for|and|with)\\b/gi;\n\nexport function extractKeywords(description: string): string[] {\n const cleaned = description\n .toLowerCase()\n .replace(STOPWORDS, \" \")\n .replace(/[,&]/g, \" \");\n\n const tokens = cleaned\n .split(/\\s+/)\n .map((s) => s.trim())\n .filter((s) => s.length > 2);\n\n // If splitting produced too many tokens, use the full cleaned string\n if (tokens.length > 5) {\n return [cleaned.replace(/\\s+/g, \" \").trim()];\n }\n\n return tokens.length > 0 ? tokens : [description.trim()];\n}\n\n"],"mappings":"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","names":["clientId","stackFile","path"]} |
| #!/usr/bin/env node | ||
| import { | ||
| handleUp, | ||
| registerUpCommand | ||
| } from "./chunk-KGCQLI22.js"; | ||
| import "./chunk-C6CAHFQX.js"; | ||
| import "./chunk-LNGTDYGN.js"; | ||
| import "./chunk-DDCTUMSZ.js"; | ||
| import "./chunk-E3T224S3.js"; | ||
| import "./chunk-RTI2GLYX.js"; | ||
| import "./chunk-OIFKZA4V.js"; | ||
| import "./chunk-FEXJHHDM.js"; | ||
| import "./chunk-F6CHEUGO.js"; | ||
| import "./chunk-LSNEZAFR.js"; | ||
| import "./chunk-UNGY7RTE.js"; | ||
| import "./chunk-W4IAFBUN.js"; | ||
| import "./chunk-2PWW3Q5Q.js"; | ||
| import "./chunk-MLVDFLDQ.js"; | ||
| import "./chunk-7RJXJERN.js"; | ||
| import "./chunk-V4AA4ZL5.js"; | ||
| import "./chunk-32VRWVOF.js"; | ||
| import "./chunk-K4U7EXLG.js"; | ||
| import "./chunk-NPJ3SGGS.js"; | ||
| import "./chunk-6R7TL5O2.js"; | ||
| import "./chunk-R4R2VPDA.js"; | ||
| import "./chunk-4QDJ3I7X.js"; | ||
| import "./chunk-3X76P3FG.js"; | ||
| import "./chunk-U7N6FRYF.js"; | ||
| import "./chunk-WT6V33F2.js"; | ||
| export { | ||
| handleUp, | ||
| registerUpCommand | ||
| }; | ||
| //# sourceMappingURL=up-IF5B5AKH.js.map |
| {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]} |
+2
-2
| { | ||
| "name": "@getmcpm/cli", | ||
| "version": "0.29.0", | ||
| "version": "0.29.1", | ||
| "mcpName": "io.github.getmcpm/cli", | ||
@@ -35,3 +35,3 @@ "description": "MCP package manager — search, install, and audit MCP servers across Claude Desktop, Cursor, VS Code, and Windsurf", | ||
| "engines": { | ||
| "node": ">=22.9.0" | ||
| "node": "^22.22.2 || ^24.15.0 || >=26.0.0" | ||
| }, | ||
@@ -38,0 +38,0 @@ "dependencies": { |
| #!/usr/bin/env node | ||
| import { | ||
| DEFAULT_MIN_RELEASE_AGE_HOURS, | ||
| assessReleaseAge, | ||
| stdoutOutput | ||
| } from "./chunk-E3T224S3.js"; | ||
| import { | ||
| checkScannerAvailable, | ||
| scanTier2 | ||
| } from "./chunk-F6CHEUGO.js"; | ||
| import { | ||
| computeTrustScore | ||
| } from "./chunk-LSNEZAFR.js"; | ||
| import { | ||
| getAdapter | ||
| } from "./chunk-W4IAFBUN.js"; | ||
| import { | ||
| confirm | ||
| } from "./chunk-2PWW3Q5Q.js"; | ||
| import { | ||
| applyKeychainSecrets, | ||
| setSecrets | ||
| } from "./chunk-GZ3WCRLG.js"; | ||
| import { | ||
| detectInstalledClients | ||
| } from "./chunk-6R7TL5O2.js"; | ||
| import { | ||
| CLIENT_IDS, | ||
| getConfigPath | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import { | ||
| addInstalledServer | ||
| } from "./chunk-4QDJ3I7X.js"; | ||
| import { | ||
| DANGEROUS_FLAG_PREFIXES, | ||
| argvTokens, | ||
| assessServerStatus, | ||
| extractRegistryMeta, | ||
| levelColor, | ||
| scanTier1, | ||
| scoreBar | ||
| } from "./chunk-U7N6FRYF.js"; | ||
| // src/commands/install.ts | ||
| import { InvalidArgumentError } from "commander"; | ||
| import chalk from "chalk"; | ||
| import { input, password } from "@inquirer/prompts"; | ||
| function validateRemoteUrl(url) { | ||
| let parsed; | ||
| try { | ||
| parsed = new URL(url); | ||
| } catch { | ||
| throw new Error(`Invalid remote URL: "${url}"`); | ||
| } | ||
| if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { | ||
| throw new Error( | ||
| `Remote URL must use http or https protocol, got: "${parsed.protocol}"` | ||
| ); | ||
| } | ||
| if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) { | ||
| throw new Error( | ||
| `Remote URL must use https for non-loopback hosts (plaintext http is vulnerable to interception), got: "${url}"` | ||
| ); | ||
| } | ||
| } | ||
| function isLoopbackHost(hostname) { | ||
| const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); | ||
| return h === "localhost" || h.endsWith(".localhost") || h === "127.0.0.1" || h === "::1"; | ||
| } | ||
| var NPM_IDENTIFIER_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; | ||
| var PYPI_IDENTIFIER_RE = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/; | ||
| var OCI_IDENTIFIER_RE = /^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)*:[a-zA-Z0-9._-]+$/; | ||
| function validateIdentifier(identifier, registryType) { | ||
| const patterns = { | ||
| npm: NPM_IDENTIFIER_RE, | ||
| pypi: PYPI_IDENTIFIER_RE, | ||
| oci: OCI_IDENTIFIER_RE | ||
| }; | ||
| const re = patterns[registryType]; | ||
| if (re && !re.test(identifier)) { | ||
| throw new Error( | ||
| `Rejected potentially malicious ${registryType} identifier: "${identifier}"` | ||
| ); | ||
| } | ||
| } | ||
| function normalizeRuntimeArgs(args) { | ||
| return args.flatMap(argvTokens); | ||
| } | ||
| var SAFE_ARG_PATTERNS = [ | ||
| // Generic boolean flags (--allow-write, --read-only, --no-sandbox, etc.) | ||
| /^--[a-zA-Z][\w-]*$/, | ||
| // Single-dash short flags the live registry legitimately declares (-i, -y, -p). | ||
| // EXACTLY one alpha char — no bundled tail. Allowing a tail (-rmodule, -eCODE) | ||
| // would let a dangerous flag bundle its payload and slip past the Layer-1 | ||
| // DANGEROUS_FLAG_PREFIXES check, which only rejects the exact token (-e/-r) or | ||
| // its '=' form. The live registry's short flags are all single-letter, so the | ||
| // narrow form loses no real coverage while closing the bundling bypass. | ||
| /^-[a-zA-Z]$/, | ||
| // Generic --key=value flags with safe value characters | ||
| // Blocks shell metacharacters: ; | $ ` & ( ) { } < > ! ' " | ||
| /^--[a-zA-Z][\w-]+=[\w./@:, -]+$/, | ||
| // Bare absolute paths (Unix: /path/to/dir) | ||
| /^\/[\w.@/ -]+$/, | ||
| // Home-relative paths (~/Documents) | ||
| /^~[\w.@/ -]*$/, | ||
| // Bare positional arguments (no dashes, no path traversal) | ||
| /^[a-zA-Z0-9][\w.@/-]*$/ | ||
| ]; | ||
| function validateRuntimeArgs(args) { | ||
| for (const arg of args) { | ||
| if (/(?:^|[=\\/])\.\.(?:[\\/]|$)/.test(arg)) { | ||
| throw new Error(`Rejected path traversal in runtime argument: "${arg}"`); | ||
| } | ||
| const isDangerous = DANGEROUS_FLAG_PREFIXES.some( | ||
| (prefix) => arg === prefix || arg.startsWith(`${prefix}=`) | ||
| ); | ||
| if (isDangerous) { | ||
| throw new Error(`Rejected dangerous runtime argument: "${arg}"`); | ||
| } | ||
| const isSafe = SAFE_ARG_PATTERNS.some((pattern) => pattern.test(arg)); | ||
| if (!isSafe) { | ||
| throw new Error(`Rejected unrecognized runtime argument: "${arg}"`); | ||
| } | ||
| } | ||
| } | ||
| function resolveInstallEntry(serverEntry, clientId) { | ||
| const { server } = serverEntry; | ||
| if (clientId === "cursor" && server.remotes && server.remotes.length > 0) { | ||
| const httpRemote = server.remotes.find( | ||
| (r) => r.type === "streamable-http" || r.type === "sse" | ||
| ); | ||
| if (httpRemote) { | ||
| validateRemoteUrl(httpRemote.url); | ||
| const headers = {}; | ||
| for (const h of httpRemote.headers) { | ||
| headers[h.name] = ""; | ||
| } | ||
| return { | ||
| url: httpRemote.url, | ||
| ...Object.keys(headers).length > 0 ? { headers } : {} | ||
| }; | ||
| } | ||
| } | ||
| const npmPkg = server.packages.find((p) => p.registryType === "npm"); | ||
| const pypiPkg = server.packages.find((p) => p.registryType === "pypi"); | ||
| const ociPkg = server.packages.find((p) => p.registryType === "oci"); | ||
| if (npmPkg) { | ||
| validateIdentifier(npmPkg.identifier, "npm"); | ||
| const rtArgs = normalizeRuntimeArgs(npmPkg.runtimeArguments ?? []); | ||
| validateRuntimeArgs(rtArgs); | ||
| return { | ||
| command: "npx", | ||
| args: ["-y", npmPkg.identifier, ...rtArgs] | ||
| }; | ||
| } | ||
| if (pypiPkg) { | ||
| validateIdentifier(pypiPkg.identifier, "pypi"); | ||
| const rtArgs = normalizeRuntimeArgs(pypiPkg.runtimeArguments ?? []); | ||
| validateRuntimeArgs(rtArgs); | ||
| return { | ||
| command: "uvx", | ||
| args: [pypiPkg.identifier, ...rtArgs] | ||
| }; | ||
| } | ||
| if (ociPkg) { | ||
| validateIdentifier(ociPkg.identifier, "oci"); | ||
| const rtArgs = normalizeRuntimeArgs(ociPkg.runtimeArguments ?? []); | ||
| validateRuntimeArgs(rtArgs); | ||
| return { | ||
| command: "docker", | ||
| args: ["run", "--rm", "-i", ociPkg.identifier, ...rtArgs] | ||
| }; | ||
| } | ||
| if (clientId === "cursor" && server.remotes && server.remotes.length > 0) { | ||
| const remote = server.remotes[0]; | ||
| validateRemoteUrl(remote.url); | ||
| return { url: remote.url }; | ||
| } | ||
| throw new Error( | ||
| `No install path found for server "${server.name}": no packages and no compatible remotes.` | ||
| ); | ||
| } | ||
| function formatTrustScore(trustScore) { | ||
| const { score, maxPossible, level, breakdown } = trustScore; | ||
| const levelLabel = levelColor(level.toUpperCase()); | ||
| const bar = scoreBar(score, maxPossible); | ||
| const lines = [ | ||
| `${bar} ${score}/${maxPossible} ${levelLabel}`, | ||
| ` \u251C\u2500 Health check: ${breakdown.healthCheck > 0 ? "not yet run" : "failed or skipped"}`, | ||
| ` \u251C\u2500 Tool descriptions: ${breakdown.staticScan === 40 ? "CLEAN (no injection patterns)" : `score ${breakdown.staticScan}/40`}`, | ||
| ` \u251C\u2500 Package: publisher verification ${breakdown.registryMeta > 0 ? "passed" : "unverified"}`, | ||
| ` \u2514\u2500 External scan: ${breakdown.externalScan > 0 ? `passed (${breakdown.externalScan}/20)` : "not available (set MCPM_EXTERNAL_SCANNER for deeper analysis)"}` | ||
| ]; | ||
| return lines.join("\n"); | ||
| } | ||
| async function handleInstall(name, options, deps) { | ||
| const { | ||
| registryClient, | ||
| detectClients, | ||
| getAdapter: getAdapter2, | ||
| getConfigPath: getConfigPath2, | ||
| scanTier1: scanTier12, | ||
| checkScannerAvailable: checkScannerAvailable2, | ||
| scanTier2: scanTier22, | ||
| computeTrustScore: computeTrustScore2, | ||
| addToStore, | ||
| confirm: confirm2, | ||
| promptEnvVars, | ||
| output | ||
| } = deps; | ||
| const serverEntry = await registryClient.getServer(name); | ||
| const statusGate = assessServerStatus(serverEntry); | ||
| if (statusGate.blocks) { | ||
| if (options.json === true) { | ||
| output( | ||
| JSON.stringify( | ||
| { | ||
| name, | ||
| error: "server_delisted", | ||
| status: statusGate.status, | ||
| message: statusGate.statusMessage ?? null | ||
| }, | ||
| null, | ||
| 2 | ||
| ) | ||
| ); | ||
| } | ||
| throw new Error( | ||
| `"${name}" has been deleted from the MCP registry${statusGate.statusMessage ? ` (${statusGate.statusMessage})` : ""}. Installation aborted.` | ||
| ); | ||
| } | ||
| const tier1Findings = scanTier12(serverEntry); | ||
| const scannerAvailable = await checkScannerAvailable2(); | ||
| let allFindings = [...tier1Findings]; | ||
| if (scannerAvailable) { | ||
| const tier2Findings = await scanTier22(name); | ||
| allFindings = [...allFindings, ...tier2Findings]; | ||
| } | ||
| const registryMeta = extractRegistryMeta(serverEntry); | ||
| const releaseAge = assessReleaseAge({ | ||
| publishedAt: registryMeta.publishedAt, | ||
| now: (deps.now ?? Date.now)(), | ||
| minAgeHours: options.minReleaseAge ?? DEFAULT_MIN_RELEASE_AGE_HOURS | ||
| }); | ||
| if (releaseAge.finding) { | ||
| allFindings = [...allFindings, releaseAge.finding]; | ||
| } | ||
| const trustScoreInput = { | ||
| findings: allFindings, | ||
| healthCheckPassed: null, | ||
| // health check not yet run at this point | ||
| hasExternalScanner: scannerAvailable, | ||
| registryMeta | ||
| }; | ||
| const trustScore = computeTrustScore2(trustScoreInput); | ||
| if (options.minTrust !== void 0 && trustScore.score < options.minTrust) { | ||
| if (options.json === true) { | ||
| output( | ||
| JSON.stringify( | ||
| { | ||
| name, | ||
| error: "min_trust_not_met", | ||
| score: trustScore.score, | ||
| maxPossible: trustScore.maxPossible, | ||
| required: options.minTrust, | ||
| level: trustScore.level | ||
| }, | ||
| null, | ||
| 2 | ||
| ) | ||
| ); | ||
| } | ||
| throw new Error( | ||
| `Trust score ${trustScore.score}/${trustScore.maxPossible} is below the required minimum of ${options.minTrust}. Installation aborted.` | ||
| ); | ||
| } | ||
| if (options.minReleaseAge !== void 0 && options.allowFresh !== true && releaseAge.blocksArmedGate) { | ||
| if (options.json === true) { | ||
| output( | ||
| JSON.stringify( | ||
| { | ||
| name, | ||
| error: "release_age_not_met", | ||
| ageHours: releaseAge.ageHours, | ||
| required: options.minReleaseAge, | ||
| reason: releaseAge.status | ||
| }, | ||
| null, | ||
| 2 | ||
| ) | ||
| ); | ||
| } | ||
| const tail = "Installation aborted. Use --allow-fresh to bypass."; | ||
| throw new Error( | ||
| releaseAge.status === "future" ? `Release publish timestamp is in the future (clock skew or forged metadata); treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}` : releaseAge.status === "unparseable" ? `Release publish timestamp could not be parsed; treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}` : releaseAge.status === "absent" ? `Release publish timestamp is missing from the registry metadata, so release age cannot be verified against the ${options.minReleaseAge}-hour minimum. ${tail}` : `Release age ${releaseAge.ageHours}h is below the required minimum of ${options.minReleaseAge}h. ${tail}` | ||
| ); | ||
| } | ||
| const jsonMode = options.json === true; | ||
| if (!jsonMode) { | ||
| output(formatTrustScore(trustScore)); | ||
| output(""); | ||
| } | ||
| if (options.yes !== true) { | ||
| let shouldProceed; | ||
| if (trustScore.level === "risky") { | ||
| if (!jsonMode) { | ||
| output("\x1B[31mWARNING: This server has a low trust score and may be risky to install.\x1B[0m"); | ||
| output("\x1B[31mSecurity findings indicate potential dangers. Proceed with extreme caution.\x1B[0m"); | ||
| } | ||
| shouldProceed = await confirm2( | ||
| "I understand the risks and want to install this server anyway. Continue?" | ||
| ); | ||
| } else if (trustScore.level === "caution") { | ||
| if (!jsonMode) { | ||
| output("\x1B[33mCAUTION: This server has a moderate trust score. Review the details above.\x1B[0m"); | ||
| } | ||
| shouldProceed = await confirm2(`Install '${name}'? (caution recommended)`); | ||
| } else { | ||
| shouldProceed = await confirm2(`Install '${name}'?`); | ||
| } | ||
| if (!shouldProceed) { | ||
| if (!jsonMode) output("Installation cancelled."); | ||
| return; | ||
| } | ||
| } | ||
| let targetClients = await detectClients(); | ||
| if (targetClients.length === 0) { | ||
| throw new Error( | ||
| "No supported AI clients found. Install Claude Desktop, Cursor, VS Code, or Windsurf first." | ||
| ); | ||
| } | ||
| if (options.client !== void 0) { | ||
| if (!CLIENT_IDS.includes(options.client)) { | ||
| throw new Error( | ||
| `Unknown client "${options.client}". Valid values: ${CLIENT_IDS.join(", ")}.` | ||
| ); | ||
| } | ||
| const requestedId = options.client; | ||
| if (!targetClients.includes(requestedId)) { | ||
| throw new Error( | ||
| `Client "${requestedId}" is not installed on this machine.` | ||
| ); | ||
| } | ||
| targetClients = [requestedId]; | ||
| } | ||
| if (options.force !== true) { | ||
| for (const clientId of targetClients) { | ||
| const adapter = getAdapter2(clientId); | ||
| const configPath = getConfigPath2(clientId); | ||
| const existing = await adapter.read(configPath); | ||
| if (Object.prototype.hasOwnProperty.call(existing, name)) { | ||
| throw new Error( | ||
| `Server '${name}' is already installed in ${clientId}. Use --force to overwrite.` | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| const { server } = serverEntry; | ||
| const bestPkg = server.packages.find((p) => p.registryType === "npm") ?? server.packages.find((p) => p.registryType === "pypi") ?? server.packages.find((p) => p.registryType === "oci") ?? server.packages[0]; | ||
| const envVarDefs = bestPkg?.environmentVariables ?? []; | ||
| const resolvedEnvVars = await promptEnvVars(envVarDefs); | ||
| const secretsMode = options.secrets ?? "plaintext"; | ||
| const { env: envForConfig, storedCount: storedSecretCount } = await applyKeychainSecrets({ | ||
| serverName: name, | ||
| resolvedEnv: resolvedEnvVars, | ||
| isSecret: (key) => envVarDefs.find((d) => d.name === key)?.isSecret === true, | ||
| mode: secretsMode, | ||
| setSecrets: deps.setSecrets | ||
| }); | ||
| const resolvedEntries = /* @__PURE__ */ new Map(); | ||
| for (const clientId of targetClients) { | ||
| resolvedEntries.set(clientId, resolveInstallEntry(serverEntry, clientId)); | ||
| } | ||
| const isUnguardedEntry = [...resolvedEntries.values()].some( | ||
| (e) => e.url !== void 0 && e.command === void 0 | ||
| ); | ||
| if (isUnguardedEntry) { | ||
| if (options.allowUrlServers === false) { | ||
| throw new Error( | ||
| `Server '${name}' uses a URL/HTTP transport and is not permitted via the MCP surface.` | ||
| ); | ||
| } | ||
| const previousConsented = deps.readUnguardedConsent ? await deps.readUnguardedConsent() : []; | ||
| const alreadyConsented = previousConsented.includes(name); | ||
| const consented = options.allowUnguarded === true || alreadyConsented; | ||
| if (!consented) { | ||
| throw new Error( | ||
| `Server '${name}' uses a URL/HTTP transport and runs UNGUARDED \u2014 no runtime inspection is possible (mcpm's guard relay only wraps stdio servers). Re-run with --allow-unguarded to install it WITHOUT protection.` | ||
| ); | ||
| } | ||
| if (!alreadyConsented) { | ||
| if (!jsonMode) { | ||
| output( | ||
| "\x1B[33m\u26A0 UNGUARDED: this URL/HTTP-transport server runs WITHOUT runtime inspection (the guard relay only wraps stdio servers). This grants consent \u2014 it does NOT add protection. The only true fix is a streamable-HTTP relay (not yet implemented).\x1B[0m" | ||
| ); | ||
| } | ||
| if (deps.recordUnguardedConsent) { | ||
| await deps.recordUnguardedConsent([name]).catch(() => void 0); | ||
| } | ||
| } | ||
| } | ||
| const installedClients = []; | ||
| for (const clientId of targetClients) { | ||
| const adapter = getAdapter2(clientId); | ||
| const configPath = getConfigPath2(clientId); | ||
| const rawEntry = resolvedEntries.get(clientId); | ||
| const entry = { | ||
| ...rawEntry, | ||
| ...Object.keys(envForConfig).length > 0 ? { env: { ...rawEntry.env ?? {}, ...envForConfig } } : {} | ||
| }; | ||
| await adapter.addServer(configPath, name, entry, { force: options.force }); | ||
| installedClients.push(clientId); | ||
| } | ||
| if (!options.json) { | ||
| if (secretsMode === "keychain" && storedSecretCount > 0) { | ||
| output( | ||
| `\x1B[32mStored ${storedSecretCount} secret(s) encrypted at rest in ~/.mcpm. With an OS keychain this protects against other-user/offline access (not same-user processes); without one a machine-derived key is used that guards casual local inspection only, NOT file exfiltration \u2014 run \`mcpm secrets migrate\` once a keychain is available. Run \`mcpm guard enable\` (then restart your IDE) so they resolve at launch \u2014 until guard wraps this server it receives the literal placeholder.\x1B[0m` | ||
| ); | ||
| } else { | ||
| const hasSecrets = envVarDefs.some((ev) => ev.isSecret && resolvedEnvVars[ev.name]); | ||
| if (hasSecrets) { | ||
| output( | ||
| "\x1B[33mNote: API keys are stored as plaintext in client config files. Ensure config files have appropriate permissions (chmod 600).\x1B[0m" | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| const storeEntry = { | ||
| name, | ||
| version: serverEntry.server.version, | ||
| clients: [...installedClients], | ||
| installedAt: (/* @__PURE__ */ new Date()).toISOString() | ||
| }; | ||
| await addToStore(storeEntry); | ||
| if (options.json === true) { | ||
| const result = { | ||
| name, | ||
| version: serverEntry.server.version, | ||
| clients: installedClients, | ||
| trustScore: { | ||
| score: trustScore.score, | ||
| maxPossible: trustScore.maxPossible, | ||
| level: trustScore.level | ||
| } | ||
| }; | ||
| output(JSON.stringify(result, null, 2)); | ||
| return; | ||
| } | ||
| const clientList = installedClients.join(", "); | ||
| output(`\x1B[32mInstalled '${name}' successfully into: ${clientList}\x1B[0m`); | ||
| } | ||
| async function promptEnvVarsDefault(vars) { | ||
| if (vars.length === 0) return {}; | ||
| const result = {}; | ||
| for (const envVar of vars) { | ||
| if (!envVar.isRequired && !envVar.isSecret) continue; | ||
| const defaultVal = envVar.default ?? ""; | ||
| const promptMessage = envVar.description ? `${envVar.name} (${envVar.description}):` : `${envVar.name}:`; | ||
| let prompted; | ||
| if (envVar.isSecret) { | ||
| prompted = await password({ message: promptMessage }); | ||
| if (!prompted && defaultVal) { | ||
| prompted = defaultVal; | ||
| } | ||
| } else { | ||
| prompted = await input({ message: promptMessage, default: defaultVal }); | ||
| } | ||
| if (prompted) { | ||
| result[envVar.name] = prompted; | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| function parseSecretsMode(raw) { | ||
| if (raw !== "keychain" && raw !== "plaintext") { | ||
| throw new InvalidArgumentError( | ||
| `--secrets must be "keychain" or "plaintext", got: "${raw}"` | ||
| ); | ||
| } | ||
| return raw; | ||
| } | ||
| function parseMinTrust(raw) { | ||
| if (!/^\d+$/.test(raw)) { | ||
| throw new InvalidArgumentError( | ||
| `--min-trust must be an integer between 0 and 100, got: "${raw}"` | ||
| ); | ||
| } | ||
| const n = Number(raw); | ||
| if (n < 0 || n > 100) { | ||
| throw new InvalidArgumentError( | ||
| `--min-trust must be an integer between 0 and 100, got: "${raw}"` | ||
| ); | ||
| } | ||
| return n; | ||
| } | ||
| function parseMinReleaseAge(raw) { | ||
| if (!/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) { | ||
| throw new InvalidArgumentError( | ||
| `--min-release-age must be a non-negative integer number of hours, got: "${raw}"` | ||
| ); | ||
| } | ||
| return Number(raw); | ||
| } | ||
| function registerInstallCommand(program) { | ||
| program.command("install <name>").description("Install an MCP server from the registry").option("-c, --client <id>", "install to a specific client only").option("-y, --yes", "skip all confirmation prompts").option("-f, --force", "overwrite if server already installed").option("--skip-health-check", "skip post-install health check").option("--json", "output result as JSON").option("--min-trust <n>", "abort install if pre-install trust score is below this threshold (0-100; health check runs after install)", parseMinTrust).option("--min-release-age <hours>", "abort install if the release is younger than this many hours OR its publish timestamp is missing/unparseable (fail-closed when set; also sets the scoring cooldown threshold; bypass with --allow-fresh)", parseMinReleaseAge).option("--allow-fresh", "bypass the --min-release-age gate (including the missing-timestamp block)").option("--secrets <mode>", "where to store secret env vars: 'keychain' (encrypted in ~/.mcpm, resolved by mcpm guard at launch) or 'plaintext' (default)", parseSecretsMode).option("--allow-unguarded", "permit a URL/HTTP-transport server to run WITHOUT runtime guard inspection (no relay wraps a non-stdio transport); records consent so future installs stay quiet").action(async (name, opts) => { | ||
| const { RegistryClient } = await import("./client-3RPMRFZL.js"); | ||
| const client = new RegistryClient(); | ||
| const { readUnguardedConsent, writeUnguardedConsent, mergeUnguarded } = await import("./unguarded-GJO5WRM7.js"); | ||
| const installOptions = { | ||
| client: opts.client, | ||
| yes: opts.yes, | ||
| force: opts.force, | ||
| skipHealthCheck: opts.skipHealthCheck, | ||
| json: opts.json, | ||
| minTrust: opts.minTrust, | ||
| minReleaseAge: opts.minReleaseAge, | ||
| allowFresh: opts.allowFresh, | ||
| secrets: opts.secrets, | ||
| allowUnguarded: opts.allowUnguarded | ||
| }; | ||
| const installDeps = { | ||
| registryClient: client, | ||
| detectClients: detectInstalledClients, | ||
| getAdapter, | ||
| getConfigPath, | ||
| scanTier1, | ||
| checkScannerAvailable, | ||
| scanTier2: (serverName) => scanTier2(serverName), | ||
| computeTrustScore, | ||
| addToStore: addInstalledServer, | ||
| confirm, | ||
| promptEnvVars: promptEnvVarsDefault, | ||
| output: stdoutOutput, | ||
| setSecrets, | ||
| now: () => Date.now(), | ||
| readUnguardedConsent, | ||
| recordUnguardedConsent: async (names) => { | ||
| const previous = await readUnguardedConsent(); | ||
| await writeUnguardedConsent(mergeUnguarded(previous, names)); | ||
| } | ||
| }; | ||
| try { | ||
| await handleInstall(name, installOptions, installDeps); | ||
| } catch (err) { | ||
| if (installOptions.json !== true) { | ||
| console.error(chalk.red(err.message)); | ||
| } | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| } | ||
| export { | ||
| validateRemoteUrl, | ||
| resolveInstallEntry, | ||
| parseSecretsMode, | ||
| parseMinTrust, | ||
| registerInstallCommand | ||
| }; | ||
| //# sourceMappingURL=chunk-7QRDQF55.js.map |
| {"version":3,"sources":["../src/commands/install.ts"],"sourcesContent":["/**\n * `mcpm install <name>` command handler.\n *\n * Wires together: registry fetch → trust assessment → user confirmation →\n * client detection → env var prompting → config write → store record.\n *\n * All external dependencies are injected for testability.\n *\n * Exports:\n * - handleInstall() — injectable handler for testing\n * - resolveInstallEntry() — pure function: ServerEntry + ClientId → McpServerEntry\n * - formatTrustScore() — pure function: TrustScore → formatted string\n * - registerInstallCommand() — Commander registration\n */\n\nimport { CLIENT_IDS } from \"../config/paths.js\";\nimport type { ClientId } from \"../config/paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"../config/adapters/index.js\";\nimport type { ServerEntry, EnvVar } from \"../registry/types.js\";\nimport { argvTokens, type RuntimeArgument } from \"../registry/argument-tokens.js\";\nimport type { Finding } from \"../scanner/tier1.js\";\nimport type { TrustScore, TrustScoreInput } from \"../scanner/trust-score.js\";\nimport type { InstalledServer } from \"../store/servers.js\";\nimport { scoreBar, levelColor, extractRegistryMeta } from \"../utils/format-trust.js\";\nimport { assessReleaseAge, DEFAULT_MIN_RELEASE_AGE_HOURS } from \"../scanner/cooldown.js\";\nimport { assessServerStatus } from \"../scanner/registry-status.js\";\nimport { DANGEROUS_FLAG_PREFIXES } from \"../scanner/patterns.js\";\nimport { applyKeychainSecrets, type SecretsMode, setSecrets as _setSecrets } from \"../store/keychain.js\";\n\n// ---------------------------------------------------------------------------\n// URL validation — guard against malicious remote URLs\n// ---------------------------------------------------------------------------\n\n/**\n * Validate a remote URL before it is written to any IDE config file.\n * Only http: and https: protocols are permitted.\n */\nexport function validateRemoteUrl(url: string): void {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(`Invalid remote URL: \"${url}\"`);\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") {\n throw new Error(\n `Remote URL must use http or https protocol, got: \"${parsed.protocol}\"`\n );\n }\n // M4a: plaintext http to a non-loopback host is interceptable once written to an\n // IDE config. Allow http only for loopback (local dev servers); require https for\n // every other host. https is always allowed.\n if (parsed.protocol === \"http:\" && !isLoopbackHost(parsed.hostname)) {\n throw new Error(\n `Remote URL must use https for non-loopback hosts (plaintext http is ` +\n `vulnerable to interception), got: \"${url}\"`\n );\n }\n}\n\n/**\n * True for localhost / loopback literals, where plaintext http is acceptable.\n * Recognizes localhost / *.localhost / 127.0.0.1 / ::1. Exotic loopback spellings\n * (IPv4-mapped `::ffff:127.0.0.1`, `127.x.x.x`, decimal/octal/hex IPs) are NOT\n * recognized and fall through to the https requirement — over-rejection only, never\n * a bypass (a non-loopback host can never be mistaken for loopback).\n */\nfunction isLoopbackHost(hostname: string): boolean {\n const h = hostname.toLowerCase().replace(/^\\[|\\]$/g, \"\"); // strip IPv6 brackets\n return (\n h === \"localhost\" ||\n h.endsWith(\".localhost\") ||\n h === \"127.0.0.1\" ||\n h === \"::1\"\n );\n}\n\nconst NPM_IDENTIFIER_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$/;\nconst PYPI_IDENTIFIER_RE = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/;\nconst OCI_IDENTIFIER_RE =\n /^[a-z0-9]+([._-][a-z0-9]+)*(\\/[a-z0-9]+([._-][a-z0-9]+)*)*:[a-zA-Z0-9._-]+$/;\n\n/**\n * Validate a package identifier against the expected pattern for its registry\n * type. Throws if the identifier looks potentially malicious.\n */\nexport function validateIdentifier(identifier: string, registryType: string): void {\n const patterns: Record<string, RegExp> = {\n npm: NPM_IDENTIFIER_RE,\n pypi: PYPI_IDENTIFIER_RE,\n oci: OCI_IDENTIFIER_RE,\n };\n const re = patterns[registryType];\n if (re && !re.test(identifier)) {\n throw new Error(\n `Rejected potentially malicious ${registryType} identifier: \"${identifier}\"`\n );\n }\n}\n\n/**\n * Render runtimeArguments from the registry into a launch argv slice.\n *\n * Delegates to argvTokens (name + value, never valueHint) so the SAME function\n * defines both what gets executed here and what the F4 dangerous-flag scan\n * matches in scanner/patterns.ts — they cannot diverge. valueHint (a\n * documentation placeholder like \"directory\") is deliberately not rendered:\n * emitting it would inject a bogus literal argument. The injection scanner\n * (scanner/tier1.ts) uses argumentTokens instead, which DOES read valueHint as\n * user-facing text; that divergence is intentional and documented there.\n */\nfunction normalizeRuntimeArgs(\n args: ReadonlyArray<RuntimeArgument>\n): string[] {\n return args.flatMap(argvTokens);\n}\n\n/**\n * Allowlist of safe runtime argument shapes.\n * After dangerous flags are rejected, arguments must match one of these\n * patterns. This blocks shell metacharacters and path traversal while\n * allowing the wide range of flags real MCP servers use.\n */\nconst SAFE_ARG_PATTERNS: readonly RegExp[] = [\n // Generic boolean flags (--allow-write, --read-only, --no-sandbox, etc.)\n /^--[a-zA-Z][\\w-]*$/,\n // Single-dash short flags the live registry legitimately declares (-i, -y, -p).\n // EXACTLY one alpha char — no bundled tail. Allowing a tail (-rmodule, -eCODE)\n // would let a dangerous flag bundle its payload and slip past the Layer-1\n // DANGEROUS_FLAG_PREFIXES check, which only rejects the exact token (-e/-r) or\n // its '=' form. The live registry's short flags are all single-letter, so the\n // narrow form loses no real coverage while closing the bundling bypass.\n /^-[a-zA-Z]$/,\n // Generic --key=value flags with safe value characters\n // Blocks shell metacharacters: ; | $ ` & ( ) { } < > ! ' \"\n /^--[a-zA-Z][\\w-]+=[\\w./@:, -]+$/,\n // Bare absolute paths (Unix: /path/to/dir)\n /^\\/[\\w.@/ -]+$/,\n // Home-relative paths (~/Documents)\n /^~[\\w.@/ -]*$/,\n // Bare positional arguments (no dashes, no path traversal)\n /^[a-zA-Z0-9][\\w.@/-]*$/,\n];\n\n/**\n * Validate runtime arguments from the registry.\n * Two-layer defense: reject known-dangerous Node.js flags first,\n * then require remaining args to match safe structural patterns.\n */\nexport function validateRuntimeArgs(args: string[]): void {\n for (const arg of args) {\n // Layer 0 (M4b): reject a \"..\" path-traversal segment anywhere in the argument\n // — \"../x\", \"a/../../etc/passwd\", \"--config=../secret\". A \"..\" segment is one\n // bounded by start-of-arg, \"=\" (flag value), or a path separator on the left,\n // and a separator or end-of-arg on the right. The Layer-2 allowlist permits \".\"\n // and \"/\" inside values, so without this a traversal would slip through; a\n // non-traversal double dot like \"--range=1..10\" is left untouched.\n if (/(?:^|[=\\\\/])\\.\\.(?:[\\\\/]|$)/.test(arg)) {\n throw new Error(`Rejected path traversal in runtime argument: \"${arg}\"`);\n }\n\n // Layer 1: reject dangerous Node.js flags\n const isDangerous = DANGEROUS_FLAG_PREFIXES.some(\n (prefix) => arg === prefix || arg.startsWith(`${prefix}=`)\n );\n if (isDangerous) {\n throw new Error(`Rejected dangerous runtime argument: \"${arg}\"`);\n }\n\n // Layer 2: require safe structural pattern\n const isSafe = SAFE_ARG_PATTERNS.some((pattern) => pattern.test(arg));\n if (!isSafe) {\n throw new Error(`Rejected unrecognized runtime argument: \"${arg}\"`);\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface InstallOptions {\n client?: string;\n yes?: boolean;\n force?: boolean;\n skipHealthCheck?: boolean;\n json?: boolean;\n minTrust?: number;\n minReleaseAge?: number;\n allowFresh?: boolean;\n secrets?: SecretsMode;\n /**\n * H9 (fail-closed): per-invocation consent (`--allow-unguarded`) to install a\n * URL/HTTP-transport server that runs UNGUARDED (the guard relay only wraps a\n * stdio transport — a non-stdio remote gets ZERO runtime inspection). When\n * neither this nor a name already in the persistent consent store grants it,\n * such a server is DENIED. DISTINCT from `allowUrlServers`, the MCP-surface\n * kill-switch: `allowUrlServers === false` ALWAYS wins.\n */\n allowUnguarded?: boolean;\n /**\n * Whether URL/HTTP-transport servers may be installed at all. DEFAULT\n * (undefined/true) preserves CLI behavior. The MCP surface passes `false` so a\n * url-transport server is recorded as blocked instead of written to a config —\n * an untrusted caller can never reach the unguarded run path.\n */\n allowUrlServers?: boolean;\n}\n\nexport interface InstallDeps {\n registryClient: { getServer: (name: string) => Promise<ServerEntry> };\n detectClients: () => Promise<ClientId[]>;\n getAdapter: (clientId: ClientId) => ConfigAdapter;\n getConfigPath: (clientId: ClientId) => string;\n scanTier1: (server: ServerEntry) => Finding[];\n checkScannerAvailable: () => Promise<boolean>;\n scanTier2: (name: string) => Promise<Finding[]>;\n computeTrustScore: (input: TrustScoreInput) => TrustScore;\n addToStore: (server: InstalledServer) => Promise<void>;\n confirm: (message: string) => Promise<boolean>;\n promptEnvVars: (vars: EnvVar[]) => Promise<Record<string, string>>;\n output: (text: string) => void;\n /** Optional; required only when options.secrets === \"keychain\". */\n setSecrets?: (server: string, values: Record<string, string>) => Promise<void>;\n /** Epoch-ms clock for release-age assessment; defaults to Date.now at the CLI boundary. */\n now?: () => number;\n /**\n * H9: read the persistent set of server names previously consented to run\n * unguarded. Injectable for tests; defaults to the real store at the CLI\n * boundary. When omitted, no server is treated as previously-consented.\n */\n readUnguardedConsent?: () => Promise<string[]>;\n /**\n * H9: persist (union into the store) the name newly consented to run\n * unguarded. Injectable for tests; defaults to the real store. Called once\n * after a url server is installed under fresh consent.\n */\n recordUnguardedConsent?: (names: readonly string[]) => Promise<void>;\n}\n\n// ---------------------------------------------------------------------------\n// resolveInstallEntry — pure function, no I/O\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve the McpServerEntry for a given server + clientId.\n *\n * Decision tree:\n * 1. Cursor + server has HTTP remote → produce { url, headers } entry\n * 2. Otherwise pick from packages[]: npm → pypi → oci (first available)\n * 3. npm: { command: 'npx', args: ['-y', identifier, ...runtimeArgs], env }\n * 4. pypi: { command: 'uvx', args: [identifier, ...runtimeArgs], env }\n * 5. docker: { command: 'docker', args: ['run', '--rm', '-i', image], env }\n * 6. If no packages and no usable remote: throw\n */\nexport function resolveInstallEntry(\n serverEntry: ServerEntry,\n clientId: ClientId\n): McpServerEntry {\n const { server } = serverEntry;\n\n // Rule 1: Cursor + HTTP remote → streamable-http entry\n if (clientId === \"cursor\" && server.remotes && server.remotes.length > 0) {\n const httpRemote = server.remotes.find(\n (r) => r.type === \"streamable-http\" || r.type === \"sse\"\n );\n if (httpRemote) {\n validateRemoteUrl(httpRemote.url);\n // Build headers record if any\n const headers: Record<string, string> = {};\n for (const h of httpRemote.headers) {\n headers[h.name] = \"\";\n }\n return {\n url: httpRemote.url,\n ...(Object.keys(headers).length > 0 ? { headers } : {}),\n };\n }\n }\n\n // Rule 2: Pick best package by priority: npm → pypi → oci\n const npmPkg = server.packages.find((p) => p.registryType === \"npm\");\n const pypiPkg = server.packages.find((p) => p.registryType === \"pypi\");\n const ociPkg = server.packages.find((p) => p.registryType === \"oci\");\n\n if (npmPkg) {\n validateIdentifier(npmPkg.identifier, \"npm\");\n const rtArgs = normalizeRuntimeArgs(npmPkg.runtimeArguments ?? []);\n validateRuntimeArgs(rtArgs);\n return {\n command: \"npx\",\n args: [\"-y\", npmPkg.identifier, ...rtArgs],\n };\n }\n\n if (pypiPkg) {\n validateIdentifier(pypiPkg.identifier, \"pypi\");\n const rtArgs = normalizeRuntimeArgs(pypiPkg.runtimeArguments ?? []);\n validateRuntimeArgs(rtArgs);\n return {\n command: \"uvx\",\n args: [pypiPkg.identifier, ...rtArgs],\n };\n }\n\n if (ociPkg) {\n validateIdentifier(ociPkg.identifier, \"oci\");\n const rtArgs = normalizeRuntimeArgs(ociPkg.runtimeArguments ?? []);\n validateRuntimeArgs(rtArgs);\n return {\n command: \"docker\",\n args: [\"run\", \"--rm\", \"-i\", ociPkg.identifier, ...rtArgs],\n };\n }\n\n // Rule 3: Cursor-only path — HTTP remote with no packages\n if (clientId === \"cursor\" && server.remotes && server.remotes.length > 0) {\n const remote = server.remotes[0];\n validateRemoteUrl(remote.url);\n return { url: remote.url };\n }\n\n throw new Error(\n `No install path found for server \"${server.name}\": no packages and no compatible remotes.`\n );\n}\n\n// ---------------------------------------------------------------------------\n// formatTrustScore — pure function, rich display\n// ---------------------------------------------------------------------------\n\n/**\n * Format a trust score as a visual progress bar with breakdown details.\n */\nexport function formatTrustScore(trustScore: TrustScore): string {\n const { score, maxPossible, level, breakdown } = trustScore;\n\n const levelLabel = levelColor(level.toUpperCase());\n const bar = scoreBar(score, maxPossible);\n\n const lines: string[] = [\n `${bar} ${score}/${maxPossible} ${levelLabel}`,\n ` \\u251C\\u2500 Health check: ${breakdown.healthCheck > 0 ? \"not yet run\" : \"failed or skipped\"}`,\n ` \\u251C\\u2500 Tool descriptions: ${breakdown.staticScan === 40 ? \"CLEAN (no injection patterns)\" : `score ${breakdown.staticScan}/40`}`,\n ` \\u251C\\u2500 Package: publisher verification ${breakdown.registryMeta > 0 ? \"passed\" : \"unverified\"}`,\n ` \\u2514\\u2500 External scan: ${breakdown.externalScan > 0 ? `passed (${breakdown.externalScan}/20)` : \"not available (set MCPM_EXTERNAL_SCANNER for deeper analysis)\"}`,\n ];\n\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// handleInstall — main handler\n// ---------------------------------------------------------------------------\n\n/**\n * Core handler for `mcpm install <name>`.\n * All dependencies are injected for hermetic testability.\n */\nexport async function handleInstall(\n name: string,\n options: InstallOptions,\n deps: InstallDeps\n): Promise<void> {\n const {\n registryClient,\n detectClients,\n getAdapter,\n getConfigPath,\n scanTier1,\n checkScannerAvailable,\n scanTier2,\n computeTrustScore,\n addToStore,\n confirm,\n promptEnvVars,\n output,\n } = deps;\n\n // -------------------------------------------------------------------------\n // Step 1: Fetch server metadata\n // -------------------------------------------------------------------------\n const serverEntry = await registryClient.getServer(name);\n\n // -------------------------------------------------------------------------\n // Step 1b: registry-delisting gate (fail closed, before any scan/output)\n // -------------------------------------------------------------------------\n // If the registry itself marks this server \"deleted\" (removed/withdrawn),\n // refuse to install. Fail-SAFE: ONLY an explicit \"deleted\" blocks; a\n // \"deprecated\" or absent/unknown status does not (surfaced as an advisory\n // finding by scanTier1 instead). See scanner/registry-status.ts.\n const statusGate = assessServerStatus(serverEntry);\n if (statusGate.blocks) {\n if (options.json === true) {\n output(\n JSON.stringify(\n {\n name,\n error: \"server_delisted\",\n status: statusGate.status,\n message: statusGate.statusMessage ?? null,\n },\n null,\n 2\n )\n );\n }\n throw new Error(\n `\"${name}\" has been deleted from the MCP registry${statusGate.statusMessage ? ` (${statusGate.statusMessage})` : \"\"}. Installation aborted.`\n );\n }\n\n // -------------------------------------------------------------------------\n // Step 2: Trust assessment\n // -------------------------------------------------------------------------\n const tier1Findings = scanTier1(serverEntry);\n const scannerAvailable = await checkScannerAvailable();\n\n let allFindings: Finding[] = [...tier1Findings];\n if (scannerAvailable) {\n const tier2Findings = await scanTier2(name);\n allFindings = [...allFindings, ...tier2Findings];\n }\n\n // Release-age cooldown: assessed ONCE so the score finding and the Step 2c\n // gate can never disagree — passing --min-release-age below 24 therefore also\n // lowers the scoring cooldown threshold (documented in the flag help text).\n // The medium finding lands unconditionally for fresh releases, with or\n // without the gate — that is the inversion fix, independent of the gate.\n const registryMeta = extractRegistryMeta(serverEntry);\n const releaseAge = assessReleaseAge({\n publishedAt: registryMeta.publishedAt,\n now: (deps.now ?? Date.now)(),\n minAgeHours: options.minReleaseAge ?? DEFAULT_MIN_RELEASE_AGE_HOURS,\n });\n if (releaseAge.finding) {\n allFindings = [...allFindings, releaseAge.finding];\n }\n\n const trustScoreInput: TrustScoreInput = {\n findings: allFindings,\n healthCheckPassed: null, // health check not yet run at this point\n hasExternalScanner: scannerAvailable,\n registryMeta,\n };\n\n const trustScore = computeTrustScore(trustScoreInput);\n\n // -------------------------------------------------------------------------\n // Step 2b: --min-trust gate (checked before any output or confirmation)\n // -------------------------------------------------------------------------\n if (options.minTrust !== undefined && trustScore.score < options.minTrust) {\n if (options.json === true) {\n output(\n JSON.stringify(\n {\n name,\n error: \"min_trust_not_met\",\n score: trustScore.score,\n maxPossible: trustScore.maxPossible,\n required: options.minTrust,\n level: trustScore.level,\n },\n null,\n 2\n )\n );\n }\n // The denominator is 80 unless the external-scanner bucket was credited, which is\n // the default — so a hardcoded /100 understated every score by 20 points of scale\n // and made a flawless 62/80 (77.5%) read as 62%.\n throw new Error(\n `Trust score ${trustScore.score}/${trustScore.maxPossible} is below the required minimum of ${options.minTrust}. Installation aborted.`\n );\n }\n\n // -------------------------------------------------------------------------\n // Step 2c: --min-release-age gate (checked before any output or confirmation)\n // -------------------------------------------------------------------------\n // Fail-closed when armed: a MISSING publish timestamp blocks too (blocksArmedGate)\n // — otherwise a registry/compromised mirror could defeat the gate by omitting\n // _meta (publishedAt is .optional() in OfficialMetaSchema). The score finding\n // stays fail-open for absent; only the explicitly armed gate hardens.\n if (\n options.minReleaseAge !== undefined &&\n options.allowFresh !== true &&\n releaseAge.blocksArmedGate\n ) {\n if (options.json === true) {\n output(\n JSON.stringify(\n {\n name,\n error: \"release_age_not_met\",\n ageHours: releaseAge.ageHours,\n required: options.minReleaseAge,\n reason: releaseAge.status,\n },\n null,\n 2\n )\n );\n }\n const tail = \"Installation aborted. Use --allow-fresh to bypass.\";\n throw new Error(\n releaseAge.status === \"future\"\n ? `Release publish timestamp is in the future (clock skew or forged metadata); treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}`\n : releaseAge.status === \"unparseable\"\n ? `Release publish timestamp could not be parsed; treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}`\n : releaseAge.status === \"absent\"\n ? `Release publish timestamp is missing from the registry metadata, so release age cannot be verified against the ${options.minReleaseAge}-hour minimum. ${tail}`\n : `Release age ${releaseAge.ageHours}h is below the required minimum of ${options.minReleaseAge}h. ${tail}`\n );\n }\n\n // -------------------------------------------------------------------------\n // Step 3: Display trust score and confirm\n // -------------------------------------------------------------------------\n // In --json mode suppress all human-readable output; only the final JSON\n // is written to stdout.\n const jsonMode = options.json === true;\n\n if (!jsonMode) {\n output(formatTrustScore(trustScore));\n output(\"\");\n }\n\n if (options.yes !== true) {\n let shouldProceed: boolean;\n\n if (trustScore.level === \"risky\") {\n if (!jsonMode) {\n output(\"\\u001b[31mWARNING: This server has a low trust score and may be risky to install.\\u001b[0m\");\n output(\"\\u001b[31mSecurity findings indicate potential dangers. Proceed with extreme caution.\\u001b[0m\");\n }\n shouldProceed = await confirm(\n \"I understand the risks and want to install this server anyway. Continue?\"\n );\n } else if (trustScore.level === \"caution\") {\n if (!jsonMode) {\n output(\"\\u001b[33mCAUTION: This server has a moderate trust score. Review the details above.\\u001b[0m\");\n }\n shouldProceed = await confirm(`Install '${name}'? (caution recommended)`);\n } else {\n // GREEN — brief display, proceed\n shouldProceed = await confirm(`Install '${name}'?`);\n }\n\n if (!shouldProceed) {\n if (!jsonMode) output(\"Installation cancelled.\");\n return;\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 4: Detect and filter clients\n // -------------------------------------------------------------------------\n let targetClients = await detectClients();\n\n if (targetClients.length === 0) {\n throw new Error(\n \"No supported AI clients found. Install Claude Desktop, Cursor, VS Code, or Windsurf first.\"\n );\n }\n\n if (options.client !== undefined) {\n if (!CLIENT_IDS.includes(options.client as ClientId)) {\n throw new Error(\n `Unknown client \"${options.client}\". Valid values: ${CLIENT_IDS.join(\", \")}.`\n );\n }\n const requestedId = options.client as ClientId;\n if (!targetClients.includes(requestedId)) {\n throw new Error(\n `Client \"${requestedId}\" is not installed on this machine.`\n );\n }\n targetClients = [requestedId];\n }\n\n // -------------------------------------------------------------------------\n // Step 5: Check for already-installed (unless --force)\n // -------------------------------------------------------------------------\n if (options.force !== true) {\n for (const clientId of targetClients) {\n const adapter = getAdapter(clientId);\n const configPath = getConfigPath(clientId);\n const existing = await adapter.read(configPath);\n if (Object.prototype.hasOwnProperty.call(existing, name)) {\n throw new Error(\n `Server '${name}' is already installed in ${clientId}. Use --force to overwrite.`\n );\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 6: Resolve env vars to prompt for\n // -------------------------------------------------------------------------\n // Collect env vars from the best-match package\n const { server } = serverEntry;\n const bestPkg =\n server.packages.find((p) => p.registryType === \"npm\") ??\n server.packages.find((p) => p.registryType === \"pypi\") ??\n server.packages.find((p) => p.registryType === \"oci\") ??\n server.packages[0];\n\n const envVarDefs: EnvVar[] = bestPkg?.environmentVariables ?? [];\n const resolvedEnvVars = await promptEnvVars(envVarDefs);\n\n // Step 6b: In keychain mode, persist secret-flagged values encrypted and swap\n // them for `mcpm:keychain:…` placeholders, so no plaintext is written to any\n // client config. Non-secret vars stay inline; each secret is stored once and\n // reused for every client. The placeholder resolves at launch only while mcpm\n // guard wraps the server (run-inner.ts → resolveEnvPlaceholders). The swap\n // (and the \"no plaintext in config\" invariant) lives in applyKeychainSecrets.\n const secretsMode: SecretsMode = options.secrets ?? \"plaintext\";\n const { env: envForConfig, storedCount: storedSecretCount } = await applyKeychainSecrets({\n serverName: name,\n resolvedEnv: resolvedEnvVars,\n isSecret: (key) => envVarDefs.find((d) => d.name === key)?.isSecret === true,\n mode: secretsMode,\n setSecrets: deps.setSecrets,\n });\n\n // -------------------------------------------------------------------------\n // Step 7: Resolve (and thereby validate) each client's entry up front\n // -------------------------------------------------------------------------\n // resolveInstallEntry throws on an invalid identifier, so resolving here\n // before any config is written preserves fail-fast validation. The resolved\n // entries are reused in Step 8 to avoid recomputing them.\n const resolvedEntries = new Map<ClientId, McpServerEntry>();\n for (const clientId of targetClients) {\n resolvedEntries.set(clientId, resolveInstallEntry(serverEntry, clientId));\n }\n\n // -------------------------------------------------------------------------\n // Step 7b: H9 fail-closed gate for URL/HTTP-transport servers\n // -------------------------------------------------------------------------\n // A resolved entry with a `url` and no `command` runs UNGUARDED — the guard\n // relay only wraps a stdio process, so a non-stdio remote gets ZERO runtime\n // inspection. Mirror processUrlServer (up.ts): the MCP-surface kill-switch\n // (allowUrlServers === false) ALWAYS wins; otherwise DENY unless explicit\n // informed consent (`--allow-unguarded` this run, or a name already in the\n // persistent consent store). This is informed consent, NOT protection.\n const isUnguardedEntry = [...resolvedEntries.values()].some(\n (e) => e.url !== undefined && e.command === undefined\n );\n if (isUnguardedEntry) {\n if (options.allowUrlServers === false) {\n throw new Error(\n `Server '${name}' uses a URL/HTTP transport and is not permitted via the MCP surface.`\n );\n }\n const previousConsented = deps.readUnguardedConsent\n ? await deps.readUnguardedConsent()\n : [];\n const alreadyConsented = previousConsented.includes(name);\n const consented = options.allowUnguarded === true || alreadyConsented;\n if (!consented) {\n throw new Error(\n `Server '${name}' uses a URL/HTTP transport and runs UNGUARDED — no runtime ` +\n `inspection is possible (mcpm's guard relay only wraps stdio servers). ` +\n `Re-run with --allow-unguarded to install it WITHOUT protection.`\n );\n }\n // First-time consent: warn once and persist so a future install stays quiet.\n if (!alreadyConsented) {\n if (!jsonMode) {\n output(\n \"\\x1b[33m⚠ UNGUARDED: this URL/HTTP-transport server runs WITHOUT runtime \" +\n \"inspection (the guard relay only wraps stdio servers). This grants consent — \" +\n \"it does NOT add protection. The only true fix is a streamable-HTTP relay \" +\n \"(not yet implemented).\\x1b[0m\"\n );\n }\n if (deps.recordUnguardedConsent) {\n await deps.recordUnguardedConsent([name]).catch(() => undefined);\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 8: Write config to each client and record in store\n // -------------------------------------------------------------------------\n const installedClients: ClientId[] = [];\n\n for (const clientId of targetClients) {\n const adapter = getAdapter(clientId);\n const configPath = getConfigPath(clientId);\n const rawEntry = resolvedEntries.get(clientId)!;\n\n // Merge env vars into the entry (immutable). In keychain mode envForConfig\n // carries placeholders in place of secret values; otherwise it === resolvedEnvVars.\n const entry: McpServerEntry = {\n ...rawEntry,\n ...(Object.keys(envForConfig).length > 0\n ? { env: { ...(rawEntry.env ?? {}), ...envForConfig } }\n : {}),\n };\n\n await adapter.addServer(configPath, name, entry, { force: options.force });\n installedClients.push(clientId);\n }\n\n // -------------------------------------------------------------------------\n // Step 8b: Secret-storage notice\n // -------------------------------------------------------------------------\n if (!options.json) {\n if (secretsMode === \"keychain\" && storedSecretCount > 0) {\n output(\n `\\x1b[32mStored ${storedSecretCount} secret(s) encrypted at rest in ~/.mcpm. ` +\n \"With an OS keychain this protects against other-user/offline access (not \" +\n \"same-user processes); without one a machine-derived key is used that guards \" +\n \"casual local inspection only, NOT file exfiltration — run `mcpm secrets migrate` \" +\n \"once a keychain is available. \" +\n \"Run `mcpm guard enable` (then restart your IDE) so they resolve at launch — \" +\n \"until guard wraps this server it receives the literal placeholder.\\x1b[0m\"\n );\n } else {\n const hasSecrets = envVarDefs.some((ev) => ev.isSecret && resolvedEnvVars[ev.name]);\n if (hasSecrets) {\n output(\n \"\\x1b[33mNote: API keys are stored as plaintext in client config files. \" +\n \"Ensure config files have appropriate permissions (chmod 600).\\x1b[0m\"\n );\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 9: Record in store\n // -------------------------------------------------------------------------\n const storeEntry: InstalledServer = {\n name,\n version: serverEntry.server.version,\n clients: [...installedClients],\n installedAt: new Date().toISOString(),\n };\n await addToStore(storeEntry);\n\n // -------------------------------------------------------------------------\n // Step 10: Output result\n // -------------------------------------------------------------------------\n if (options.json === true) {\n const result = {\n name,\n version: serverEntry.server.version,\n clients: installedClients,\n trustScore: {\n score: trustScore.score,\n maxPossible: trustScore.maxPossible,\n level: trustScore.level,\n },\n };\n output(JSON.stringify(result, null, 2));\n return;\n }\n\n const clientList = installedClients.join(\", \");\n output(`\\u001b[32mInstalled '${name}' successfully into: ${clientList}\\u001b[0m`);\n}\n\n// ---------------------------------------------------------------------------\n// Commander registration\n// ---------------------------------------------------------------------------\n\nimport { Command, InvalidArgumentError } from \"commander\";\nimport chalk from \"chalk\";\nimport { input, password } from \"@inquirer/prompts\";\nimport { detectInstalledClients as _detectClients } from \"../config/detector.js\";\nimport { getConfigPath as _getConfigPath } from \"../config/paths.js\";\nimport { addInstalledServer as _addToStore } from \"../store/servers.js\";\nimport { scanTier1 as _scanTier1 } from \"../scanner/tier1.js\";\nimport { checkScannerAvailable as _checkScannerAvailable, scanTier2 as _scanTier2 } from \"../scanner/tier2.js\";\nimport { computeTrustScore as _computeTrustScore } from \"../scanner/trust-score.js\";\nimport { getAdapter as getAdapterDefault } from \"../config/index.js\";\nimport { confirm } from \"../utils/confirm.js\";\nimport { stdoutOutput } from \"../utils/output.js\";\n\nasync function promptEnvVarsDefault(\n vars: EnvVar[]\n): Promise<Record<string, string>> {\n if (vars.length === 0) return {};\n\n const result: Record<string, string> = {};\n for (const envVar of vars) {\n if (!envVar.isRequired && !envVar.isSecret) continue;\n\n const defaultVal = envVar.default ?? \"\";\n const promptMessage = envVar.description\n ? `${envVar.name} (${envVar.description}):`\n : `${envVar.name}:`;\n\n let prompted: string;\n if (envVar.isSecret) {\n // Use password prompt to mask secret input — value is never echoed to the terminal\n prompted = await password({ message: promptMessage });\n if (!prompted && defaultVal) {\n prompted = defaultVal;\n }\n } else {\n prompted = await input({ message: promptMessage, default: defaultVal });\n }\n\n if (prompted) {\n result[envVar.name] = prompted;\n }\n }\n return result;\n}\n\nexport function parseSecretsMode(raw: string): SecretsMode {\n if (raw !== \"keychain\" && raw !== \"plaintext\") {\n throw new InvalidArgumentError(\n `--secrets must be \"keychain\" or \"plaintext\", got: \"${raw}\"`\n );\n }\n return raw;\n}\n\nexport function parseMinTrust(raw: string): number {\n // Reject anything that isn't plain decimal digits (blocks hex \"0x50\", scientific\n // notation \"1e2\", spaces, empty string, and negative sign before range check).\n if (!/^\\d+$/.test(raw)) {\n throw new InvalidArgumentError(\n `--min-trust must be an integer between 0 and 100, got: \"${raw}\"`\n );\n }\n const n = Number(raw);\n if (n < 0 || n > 100) {\n throw new InvalidArgumentError(\n `--min-trust must be an integer between 0 and 100, got: \"${raw}\"`\n );\n }\n return n;\n}\n\nexport function parseMinReleaseAge(raw: string): number {\n // Same regex-first discipline as parseMinTrust: blocks hex \"0x18\", \"1e2\",\n // spaces, empty string, negatives. Safe-integer check guards absurd lengths.\n if (!/^\\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) {\n throw new InvalidArgumentError(\n `--min-release-age must be a non-negative integer number of hours, got: \"${raw}\"`\n );\n }\n return Number(raw);\n}\n\nexport function registerInstallCommand(program: Command): void {\n program\n .command(\"install <name>\")\n .description(\"Install an MCP server from the registry\")\n .option(\"-c, --client <id>\", \"install to a specific client only\")\n .option(\"-y, --yes\", \"skip all confirmation prompts\")\n .option(\"-f, --force\", \"overwrite if server already installed\")\n .option(\"--skip-health-check\", \"skip post-install health check\")\n .option(\"--json\", \"output result as JSON\")\n .option(\"--min-trust <n>\", \"abort install if pre-install trust score is below this threshold (0-100; health check runs after install)\", parseMinTrust)\n .option(\"--min-release-age <hours>\", \"abort install if the release is younger than this many hours OR its publish timestamp is missing/unparseable (fail-closed when set; also sets the scoring cooldown threshold; bypass with --allow-fresh)\", parseMinReleaseAge)\n .option(\"--allow-fresh\", \"bypass the --min-release-age gate (including the missing-timestamp block)\")\n .option(\"--secrets <mode>\", \"where to store secret env vars: 'keychain' (encrypted in ~/.mcpm, resolved by mcpm guard at launch) or 'plaintext' (default)\", parseSecretsMode)\n .option(\"--allow-unguarded\", \"permit a URL/HTTP-transport server to run WITHOUT runtime guard inspection (no relay wraps a non-stdio transport); records consent so future installs stay quiet\")\n .action(async (name: string, opts: { client?: string; yes?: boolean; force?: boolean; skipHealthCheck?: boolean; json?: boolean; minTrust?: number; minReleaseAge?: number; allowFresh?: boolean; secrets?: SecretsMode; allowUnguarded?: boolean }) => {\n const { RegistryClient } = await import(\"../registry/client.js\");\n const client = new RegistryClient();\n const { readUnguardedConsent, writeUnguardedConsent, mergeUnguarded } = await import(\n \"../guard/unguarded.js\"\n );\n\n const installOptions: InstallOptions = {\n client: opts.client,\n yes: opts.yes,\n force: opts.force,\n skipHealthCheck: opts.skipHealthCheck,\n json: opts.json,\n minTrust: opts.minTrust,\n minReleaseAge: opts.minReleaseAge,\n allowFresh: opts.allowFresh,\n secrets: opts.secrets,\n allowUnguarded: opts.allowUnguarded,\n };\n\n const installDeps: InstallDeps = {\n registryClient: client,\n detectClients: _detectClients,\n getAdapter: getAdapterDefault,\n getConfigPath: _getConfigPath,\n scanTier1: _scanTier1,\n checkScannerAvailable: _checkScannerAvailable,\n scanTier2: (serverName: string) => _scanTier2(serverName),\n computeTrustScore: _computeTrustScore,\n addToStore: _addToStore,\n confirm,\n promptEnvVars: promptEnvVarsDefault,\n output: stdoutOutput,\n setSecrets: _setSecrets,\n now: () => Date.now(),\n readUnguardedConsent,\n recordUnguardedConsent: async (names) => {\n const previous = await readUnguardedConsent();\n await writeUnguardedConsent(mergeUnguarded(previous, names));\n },\n };\n\n try {\n await handleInstall(name, installOptions, installDeps);\n } catch (err) {\n if (installOptions.json !== true) {\n console.error(chalk.red((err as Error).message));\n }\n process.exit(1);\n }\n });\n}\n"],"mappings":"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","names":["getAdapter","getConfigPath","scanTier1","checkScannerAvailable","scanTier2","computeTrustScore","confirm"]} |
| #!/usr/bin/env node | ||
| import { | ||
| getStorePath, | ||
| readJson, | ||
| withStoreLock, | ||
| writeJson | ||
| } from "./chunk-3X76P3FG.js"; | ||
| // src/store/keychain.ts | ||
| import { createHash, randomBytes, webcrypto } from "crypto"; | ||
| import os from "os"; | ||
| // src/store/os-keychain.ts | ||
| import { spawn } from "child_process"; | ||
| import { readFile, writeFile } from "fs/promises"; | ||
| import path from "path"; | ||
| var SERVICE = "mcpm"; | ||
| var ACCOUNT = "secret-store-master-key"; | ||
| var DPAPI_BLOB_FILE = "master-key.dpapi"; | ||
| var EXEC_TIMEOUT_MS = 5e3; | ||
| function run(command, args, opts = {}) { | ||
| return new Promise((resolve) => { | ||
| const child = spawn(command, args, { | ||
| timeout: EXEC_TIMEOUT_MS, | ||
| env: opts.env ?? process.env, | ||
| stdio: ["pipe", "pipe", "pipe"] | ||
| }); | ||
| let stdout = ""; | ||
| let stderr = ""; | ||
| let settled = false; | ||
| const settle = (r) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| resolve(r); | ||
| }; | ||
| child.stdout.on("data", (d) => stdout += d.toString()); | ||
| child.stderr.on("data", (d) => stderr += d.toString()); | ||
| child.on("error", () => settle({ code: null, stdout, stderr })); | ||
| child.on("close", (code) => settle({ code, stdout, stderr })); | ||
| if (opts.input !== void 0) { | ||
| child.stdin.on("error", () => { | ||
| }); | ||
| child.stdin.end(opts.input); | ||
| } else { | ||
| child.stdin.end(); | ||
| } | ||
| }); | ||
| } | ||
| function isSupportedPlatform() { | ||
| if (process.env.MCPM_DISABLE_OS_KEYCHAIN === "1") return false; | ||
| return process.platform === "darwin" || process.platform === "linux" || process.platform === "win32"; | ||
| } | ||
| async function darwinGet() { | ||
| const r = await run("security", [ | ||
| "find-generic-password", | ||
| "-a", | ||
| ACCOUNT, | ||
| "-s", | ||
| SERVICE, | ||
| "-w" | ||
| ]); | ||
| if (r.code !== 0) return null; | ||
| return decodeKey(r.stdout.trim()); | ||
| } | ||
| async function darwinStore(keyB64) { | ||
| const r = await run("security", [ | ||
| "add-generic-password", | ||
| "-a", | ||
| ACCOUNT, | ||
| "-s", | ||
| SERVICE, | ||
| "-U", | ||
| "-w", | ||
| keyB64 | ||
| ]); | ||
| return r.code === 0; | ||
| } | ||
| async function linuxGet() { | ||
| const r = await run("secret-tool", ["lookup", "service", SERVICE, "account", ACCOUNT]); | ||
| if (r.code !== 0) return null; | ||
| const value = r.stdout.trim(); | ||
| if (value.length === 0) return null; | ||
| return decodeKey(value); | ||
| } | ||
| async function linuxStore(keyB64) { | ||
| const r = await run( | ||
| "secret-tool", | ||
| ["store", "--label=mcpm secret store master key", "service", SERVICE, "account", ACCOUNT], | ||
| { input: keyB64 } | ||
| ); | ||
| return r.code === 0; | ||
| } | ||
| var PS_PROTECT = "$ErrorActionPreference='Stop';Add-Type -AssemblyName System.Security;$b=[Convert]::FromBase64String($env:MCPM_KEY_B64);$p=[Security.Cryptography.ProtectedData]::Protect($b,$null,'CurrentUser');[Convert]::ToBase64String($p)"; | ||
| var PS_UNPROTECT = "$ErrorActionPreference='Stop';Add-Type -AssemblyName System.Security;$b=[Convert]::FromBase64String($env:MCPM_BLOB_B64);$p=[Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser');[Convert]::ToBase64String($p)"; | ||
| async function blobPath() { | ||
| return path.join(await getStorePath(), DPAPI_BLOB_FILE); | ||
| } | ||
| async function windowsGet() { | ||
| let blob; | ||
| try { | ||
| blob = (await readFile(await blobPath(), "utf8")).trim(); | ||
| } catch { | ||
| return null; | ||
| } | ||
| if (blob.length === 0) return null; | ||
| const r = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", PS_UNPROTECT], { | ||
| env: { ...process.env, MCPM_BLOB_B64: blob } | ||
| }); | ||
| if (r.code !== 0) return null; | ||
| return decodeKey(r.stdout.trim()); | ||
| } | ||
| async function windowsStore(keyB64) { | ||
| const r = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", PS_PROTECT], { | ||
| env: { ...process.env, MCPM_KEY_B64: keyB64 } | ||
| }); | ||
| if (r.code !== 0 || r.stdout.trim().length === 0) return false; | ||
| try { | ||
| await writeFile(await blobPath(), r.stdout.trim(), { mode: 384 }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function decodeKey(b64) { | ||
| try { | ||
| const buf = Buffer.from(b64, "base64"); | ||
| return buf.length === 32 ? buf : null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function getStoredKey() { | ||
| if (!isSupportedPlatform()) return null; | ||
| switch (process.platform) { | ||
| case "darwin": | ||
| return darwinGet(); | ||
| case "linux": | ||
| return linuxGet(); | ||
| case "win32": | ||
| return windowsGet(); | ||
| default: | ||
| return null; | ||
| } | ||
| } | ||
| async function storeKey(key) { | ||
| if (!isSupportedPlatform()) return false; | ||
| if (key.length !== 32) return false; | ||
| const keyB64 = key.toString("base64"); | ||
| switch (process.platform) { | ||
| case "darwin": | ||
| return darwinStore(keyB64); | ||
| case "linux": | ||
| return linuxStore(keyB64); | ||
| case "win32": | ||
| return windowsStore(keyB64); | ||
| default: | ||
| return false; | ||
| } | ||
| } | ||
| // src/store/keychain.ts | ||
| var STORE_FILE = "secrets.enc.json"; | ||
| var PLACEHOLDER_PREFIX = "mcpm:keychain:"; | ||
| var PBKDF2_ITERATIONS = 6e5; | ||
| var SCHEME_KEYCHAIN = "k1"; | ||
| var HKDF_INFO = new TextEncoder().encode("mcpm-secret-store-v1"); | ||
| var MACHINE_PASSPHRASE = new TextEncoder().encode( | ||
| `mcpm:${os.hostname()}:${os.userInfo().username}` | ||
| ); | ||
| var SAFE_ID_RE = /^[a-zA-Z0-9._-]{1,256}$/; | ||
| function assertSafeId(value, label) { | ||
| if (!SAFE_ID_RE.test(value)) { | ||
| throw new Error(`Invalid ${label}: "${value}" \u2014 must match [a-zA-Z0-9._-], max 256 chars`); | ||
| } | ||
| } | ||
| function validatedStoreKey(server, key) { | ||
| assertSafeId(server, "server"); | ||
| assertSafeId(key, "key"); | ||
| return `${server}/${key}`; | ||
| } | ||
| var _masterKey; | ||
| async function readMasterKey() { | ||
| if (_masterKey) return _masterKey.value; | ||
| const value = await getStoredKey(); | ||
| _masterKey = { value }; | ||
| return value; | ||
| } | ||
| async function getOrCreateMasterKey() { | ||
| const existing = await readMasterKey(); | ||
| if (existing) return existing; | ||
| if (!isSupportedPlatform()) return null; | ||
| return withStoreLock(async () => { | ||
| const raced = await getStoredKey(); | ||
| if (raced) { | ||
| _masterKey = { value: raced }; | ||
| return raced; | ||
| } | ||
| const key = randomBytes(32); | ||
| const value = await storeKey(key) ? key : null; | ||
| _masterKey = { value }; | ||
| return value; | ||
| }); | ||
| } | ||
| var _hkdfMaterial; | ||
| function hkdfMaterial(masterKey) { | ||
| if (!_hkdfMaterial || !_hkdfMaterial.key.equals(masterKey)) { | ||
| _hkdfMaterial = { | ||
| key: masterKey, | ||
| material: webcrypto.subtle.importKey("raw", masterKey, "HKDF", false, ["deriveKey"]) | ||
| }; | ||
| } | ||
| return _hkdfMaterial.material; | ||
| } | ||
| async function deriveKeychainKey(masterKey, salt) { | ||
| const material = await hkdfMaterial(masterKey); | ||
| return webcrypto.subtle.deriveKey( | ||
| { name: "HKDF", salt, info: HKDF_INFO, hash: "SHA-256" }, | ||
| material, | ||
| { name: "AES-GCM", length: 256 }, | ||
| false, | ||
| ["encrypt", "decrypt"] | ||
| ); | ||
| } | ||
| var _keyMaterialPromise = null; | ||
| function getMachineKeyMaterial() { | ||
| if (!_keyMaterialPromise) { | ||
| _keyMaterialPromise = webcrypto.subtle.importKey( | ||
| "raw", | ||
| MACHINE_PASSPHRASE, | ||
| { name: "PBKDF2" }, | ||
| false, | ||
| ["deriveKey"] | ||
| ); | ||
| } | ||
| return _keyMaterialPromise; | ||
| } | ||
| async function deriveMachineKey(salt) { | ||
| const keyMaterial = await getMachineKeyMaterial(); | ||
| return webcrypto.subtle.deriveKey( | ||
| { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, | ||
| keyMaterial, | ||
| { name: "AES-GCM", length: 256 }, | ||
| false, | ||
| ["encrypt", "decrypt"] | ||
| ); | ||
| } | ||
| var hex = (buf) => Buffer.from(buf instanceof Uint8Array ? buf : new Uint8Array(buf)).toString("hex"); | ||
| async function encrypt(plaintext) { | ||
| const salt = randomBytes(16); | ||
| const iv = randomBytes(12); | ||
| const masterKey = await getOrCreateMasterKey(); | ||
| const key = masterKey ? await deriveKeychainKey(masterKey, salt) : await deriveMachineKey(salt); | ||
| const cipherBuf = await webcrypto.subtle.encrypt( | ||
| { name: "AES-GCM", iv }, | ||
| key, | ||
| new TextEncoder().encode(plaintext) | ||
| ); | ||
| const body = [hex(salt), hex(iv), hex(cipherBuf)].join(":"); | ||
| return masterKey ? `${SCHEME_KEYCHAIN}:${body}` : body; | ||
| } | ||
| async function decryptWith(key, ivHex, cipherHex) { | ||
| const plainBuf = await webcrypto.subtle.decrypt( | ||
| { name: "AES-GCM", iv: Buffer.from(ivHex, "hex") }, | ||
| key, | ||
| Buffer.from(cipherHex, "hex") | ||
| ); | ||
| return new TextDecoder().decode(plainBuf); | ||
| } | ||
| async function decrypt(stored) { | ||
| const parts = stored.split(":"); | ||
| if (parts.length === 4 && parts[0] === SCHEME_KEYCHAIN) { | ||
| const [, saltHex, ivHex, cipherHex] = parts; | ||
| const masterKey = await readMasterKey(); | ||
| if (!masterKey) { | ||
| throw new Error( | ||
| "Cannot decrypt: this secret is protected by the OS keychain master key, which is unavailable on this machine/account (or the keychain entry was removed)." | ||
| ); | ||
| } | ||
| const key = await deriveKeychainKey(masterKey, Buffer.from(saltHex, "hex")); | ||
| return decryptWith(key, ivHex, cipherHex); | ||
| } | ||
| if (parts.length === 3) { | ||
| const [saltHex, ivHex, cipherHex] = parts; | ||
| const key = await deriveMachineKey(Buffer.from(saltHex, "hex")); | ||
| return decryptWith(key, ivHex, cipherHex); | ||
| } | ||
| throw new Error("Invalid ciphertext format"); | ||
| } | ||
| async function readStore() { | ||
| return await readJson(STORE_FILE) ?? {}; | ||
| } | ||
| async function decryptFromSnapshot(store, sk) { | ||
| const stored = store[sk]; | ||
| return stored ? decrypt(stored) : null; | ||
| } | ||
| async function setSecret(server, key, value) { | ||
| const sk = validatedStoreKey(server, key); | ||
| const encrypted = await encrypt(value); | ||
| await withStoreLock(async () => { | ||
| const store = await readStore(); | ||
| await writeJson(STORE_FILE, { ...store, [sk]: encrypted }); | ||
| }); | ||
| } | ||
| async function setSecrets(server, values) { | ||
| const encrypted = {}; | ||
| for (const [key, value] of Object.entries(values)) { | ||
| encrypted[validatedStoreKey(server, key)] = await encrypt(value); | ||
| } | ||
| await withStoreLock(async () => { | ||
| const store = await readStore(); | ||
| await writeJson(STORE_FILE, { ...store, ...encrypted }); | ||
| }); | ||
| } | ||
| async function getSecret(server, key) { | ||
| const sk = validatedStoreKey(server, key); | ||
| return decryptFromSnapshot(await readStore(), sk); | ||
| } | ||
| async function deleteSecret(server, key) { | ||
| const sk = validatedStoreKey(server, key); | ||
| let removed = false; | ||
| await withStoreLock(async () => { | ||
| const store = await readStore(); | ||
| if (!(sk in store)) return; | ||
| const { [sk]: _removed, ...rest } = store; | ||
| await writeJson(STORE_FILE, rest); | ||
| removed = true; | ||
| }); | ||
| return removed; | ||
| } | ||
| function toPlaceholder(server, key) { | ||
| return `${PLACEHOLDER_PREFIX}${server}/${key}`; | ||
| } | ||
| function parsePlaceholder(value) { | ||
| if (!value.startsWith(PLACEHOLDER_PREFIX)) return null; | ||
| const rest = value.slice(PLACEHOLDER_PREFIX.length); | ||
| const slashIdx = rest.indexOf("/"); | ||
| if (slashIdx === -1) return null; | ||
| return { server: rest.slice(0, slashIdx), key: rest.slice(slashIdx + 1) }; | ||
| } | ||
| async function resolveEnvPlaceholders(env) { | ||
| const passthrough = {}; | ||
| const placeholders = []; | ||
| for (const [name, value] of Object.entries(env)) { | ||
| if (value === void 0) continue; | ||
| const placeholder = parsePlaceholder(value); | ||
| if (placeholder === null) { | ||
| passthrough[name] = value; | ||
| continue; | ||
| } | ||
| placeholders.push({ name, ...placeholder }); | ||
| } | ||
| if (placeholders.length === 0) return passthrough; | ||
| return withStoreLock(async () => { | ||
| const store = await readStore(); | ||
| const resolved = { ...passthrough }; | ||
| for (const { name, server, key } of placeholders) { | ||
| const sk = validatedStoreKey(server, key); | ||
| const secret = await decryptFromSnapshot(store, sk); | ||
| if (secret === null) { | ||
| throw new Error( | ||
| `Secret "${server}/${key}" not found. Run \`mcpm secrets set ${server} ${key}\` to store it.` | ||
| ); | ||
| } | ||
| resolved[name] = secret; | ||
| } | ||
| return resolved; | ||
| }); | ||
| } | ||
| function deriveKeychainId(name) { | ||
| const sanitized = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 200); | ||
| const hash = createHash("sha256").update(name).digest("hex").slice(0, 12); | ||
| return `${sanitized}-${hash}`; | ||
| } | ||
| async function listAll() { | ||
| const store = await readStore(); | ||
| const grouped = {}; | ||
| for (const storeKey2 of Object.keys(store)) { | ||
| const slashIdx = storeKey2.indexOf("/"); | ||
| if (slashIdx === -1) continue; | ||
| const server = storeKey2.slice(0, slashIdx); | ||
| const key = storeKey2.slice(slashIdx + 1); | ||
| (grouped[server] ??= []).push(key); | ||
| } | ||
| return grouped; | ||
| } | ||
| async function activeSecretBackend() { | ||
| return await readMasterKey() !== null ? "os-keychain" : "machine-key"; | ||
| } | ||
| async function migrateToKeychain() { | ||
| const masterKey = await getOrCreateMasterKey(); | ||
| if (!masterKey) return { migrated: 0, failed: 0, total: 0, usingKeychain: false }; | ||
| return withStoreLock(async () => { | ||
| const store = await readStore(); | ||
| const entries = Object.entries(store); | ||
| const next = {}; | ||
| let migrated = 0; | ||
| let failed = 0; | ||
| for (const [sk, value] of entries) { | ||
| const isLegacy = value.split(":").length === 3; | ||
| if (!isLegacy) { | ||
| next[sk] = value; | ||
| continue; | ||
| } | ||
| try { | ||
| const plain = await decrypt(value); | ||
| next[sk] = await encrypt(plain); | ||
| migrated++; | ||
| } catch { | ||
| next[sk] = value; | ||
| failed++; | ||
| } | ||
| } | ||
| if (migrated > 0) await writeJson(STORE_FILE, next); | ||
| return { migrated, failed, total: entries.length, usingKeychain: true }; | ||
| }); | ||
| } | ||
| async function applyKeychainSecrets(opts) { | ||
| if (opts.mode !== "keychain") { | ||
| return { env: opts.resolvedEnv, storedCount: 0 }; | ||
| } | ||
| if (!opts.setSecrets) { | ||
| throw new Error("Keychain secret storage is unavailable."); | ||
| } | ||
| const keychainId = deriveKeychainId(opts.serverName); | ||
| const env = {}; | ||
| const toStore = {}; | ||
| for (const [key, value] of Object.entries(opts.resolvedEnv)) { | ||
| if (opts.isSecret(key)) { | ||
| toStore[key] = value; | ||
| env[key] = toPlaceholder(keychainId, key); | ||
| } else { | ||
| env[key] = value; | ||
| } | ||
| } | ||
| const storedCount = Object.keys(toStore).length; | ||
| if (storedCount > 0) { | ||
| await opts.setSecrets(keychainId, toStore); | ||
| } | ||
| return { env, storedCount }; | ||
| } | ||
| function placeholderEnvKeys(env) { | ||
| if (!env) return []; | ||
| return Object.entries(env).filter(([, v]) => typeof v === "string" && parsePlaceholder(v) !== null).map(([k]) => k); | ||
| } | ||
| export { | ||
| isSupportedPlatform, | ||
| setSecret, | ||
| setSecrets, | ||
| getSecret, | ||
| deleteSecret, | ||
| toPlaceholder, | ||
| parsePlaceholder, | ||
| resolveEnvPlaceholders, | ||
| deriveKeychainId, | ||
| listAll, | ||
| activeSecretBackend, | ||
| migrateToKeychain, | ||
| applyKeychainSecrets, | ||
| placeholderEnvKeys | ||
| }; | ||
| //# sourceMappingURL=chunk-GZ3WCRLG.js.map |
| {"version":3,"sources":["../src/store/keychain.ts","../src/store/os-keychain.ts"],"sourcesContent":["/**\n * Encrypted-at-rest secret storage using Node's built-in crypto.subtle.\n * No native dependencies (contrast: keytar requires node-gyp).\n *\n * Two encryption schemes (security #15):\n * - keychain (\"k1:\" tag): AES-GCM key derived via HKDF from a random 32-byte\n * master key held in the OS credential store (store/os-keychain.ts). The\n * master key never touches disk, so a copied secrets.enc.json cannot be\n * decrypted on another machine/account — real exfiltration resistance.\n * - machine (legacy, untagged): AES-GCM key derived via PBKDF2 from\n * hostname+username. This is NOT a secret (it is recoverable by anyone who\n * copies the store file), so it guards only against casual local inspection.\n * Used as a fallback where no OS keychain is available (headless/CI) and to\n * decrypt entries written before the keychain upgrade.\n *\n * New secrets use the keychain scheme whenever an OS keychain is available;\n * `migrateToKeychain()` upgrades pre-existing machine-scheme entries.\n *\n * Storage format: ~/.mcpm/secrets.enc.json\n * { \"server/KEY\": \"k1:<salt_hex>:<iv_hex>:<ct_hex>\" } // keychain scheme\n * { \"server/KEY\": \"<salt_hex>:<iv_hex>:<ct_hex>\" } // legacy machine scheme\n *\n * Placeholder format used in config files:\n * \"mcpm:keychain:server/KEY\"\n */\n\nimport { createHash, randomBytes, webcrypto } from \"node:crypto\";\nimport os from \"node:os\";\nimport { readJson, writeJson } from \"./index.js\";\nimport { withStoreLock } from \"./atomic.js\";\nimport { getStoredKey, isSupportedPlatform, storeKey } from \"./os-keychain.js\";\n\nconst STORE_FILE = \"secrets.enc.json\";\nconst PLACEHOLDER_PREFIX = \"mcpm:keychain:\";\nconst PBKDF2_ITERATIONS = 600_000;\n\n// Scheme tag prefixing keychain-scheme entries: \"k1:<salt>:<iv>:<ct>\". Legacy\n// machine-scheme entries are unprefixed (\"<salt>:<iv>:<ct>\"), so decrypt() routes\n// on the part count — keeping old entries readable after the upgrade (issue #15).\nconst SCHEME_KEYCHAIN = \"k1\";\nconst HKDF_INFO = new TextEncoder().encode(\"mcpm-secret-store-v1\");\n\n// Legacy/fallback machine passphrase. This is NOT a secret: hostname + username\n// are recoverable by anyone who copies the store file, so the machine scheme\n// guards only against casual local inspection — never file exfiltration. The\n// keychain scheme (OS-held master key, below) supersedes it whenever an OS\n// credential store is available; this remains for environments without one\n// (headless/CI) and to decrypt pre-existing entries (issue #15).\nconst MACHINE_PASSPHRASE = new TextEncoder().encode(\n `mcpm:${os.hostname()}:${os.userInfo().username}`\n);\n\n// ---------------------------------------------------------------------------\n// Input validation\n// ---------------------------------------------------------------------------\n\nconst SAFE_ID_RE = /^[a-zA-Z0-9._-]{1,256}$/;\n\nfunction assertSafeId(value: string, label: string): void {\n if (!SAFE_ID_RE.test(value)) {\n throw new Error(`Invalid ${label}: \"${value}\" — must match [a-zA-Z0-9._-], max 256 chars`);\n }\n}\n\nfunction validatedStoreKey(server: string, key: string): string {\n assertSafeId(server, \"server\");\n assertSafeId(key, \"key\");\n return `${server}/${key}`;\n}\n\n// ---------------------------------------------------------------------------\n// Master key (OS credential store) — security #15\n// ---------------------------------------------------------------------------\n//\n// A single random 32-byte key lives in the OS keychain (store/os-keychain.ts);\n// per-value AES keys are derived from it via HKDF. Because the master key is\n// never written to ~/.mcpm, a copied secrets.enc.json cannot be decrypted on\n// another machine/account. When no OS keychain is available, encrypt() falls\n// back to the machine scheme below.\n\nlet _masterKey: { value: Buffer | null } | undefined;\n\n/** Read the stored master key (never creates one); memoized per process. */\nasync function readMasterKey(): Promise<Buffer | null> {\n if (_masterKey) return _masterKey.value;\n const value = await getStoredKey();\n _masterKey = { value };\n return value;\n}\n\n/**\n * Return the master key, creating and persisting a fresh random one if none\n * exists yet and the platform has a usable credential store. Creation runs\n * under the store lock so two concurrent first-writes cannot generate two keys\n * and leave one writer's secret undecryptable.\n */\nasync function getOrCreateMasterKey(): Promise<Buffer | null> {\n const existing = await readMasterKey();\n if (existing) return existing;\n if (!isSupportedPlatform()) return null;\n return withStoreLock(async () => {\n const raced = await getStoredKey(); // another process may have just created it\n if (raced) {\n _masterKey = { value: raced };\n return raced;\n }\n const key = randomBytes(32);\n // If the keychain write fails, memoize null: this session falls back to the\n // machine scheme for the rest of its life (no retry). That is the safe,\n // honest outcome — activeSecretBackend() will report \"machine-key\" and\n // `secrets set` warns the user — rather than half-using an unpersisted key.\n const value = (await storeKey(key)) ? key : null;\n _masterKey = { value };\n return value;\n });\n}\n\n// ---------------------------------------------------------------------------\n// Key derivation — keychain scheme (HKDF) and machine scheme (PBKDF2)\n// ---------------------------------------------------------------------------\n\n// HKDF importKey is cheap but stable per master key; cache it per-process.\nlet _hkdfMaterial: { key: Buffer; material: Promise<webcrypto.CryptoKey> } | undefined;\n\nfunction hkdfMaterial(masterKey: Buffer): Promise<webcrypto.CryptoKey> {\n if (!_hkdfMaterial || !_hkdfMaterial.key.equals(masterKey)) {\n _hkdfMaterial = {\n key: masterKey,\n material: webcrypto.subtle.importKey(\"raw\", masterKey, \"HKDF\", false, [\"deriveKey\"]),\n };\n }\n return _hkdfMaterial.material;\n}\n\nasync function deriveKeychainKey(masterKey: Buffer, salt: Uint8Array): Promise<webcrypto.CryptoKey> {\n const material = await hkdfMaterial(masterKey);\n return webcrypto.subtle.deriveKey(\n { name: \"HKDF\", salt, info: HKDF_INFO, hash: \"SHA-256\" },\n material,\n { name: \"AES-GCM\", length: 256 },\n false,\n [\"encrypt\", \"decrypt\"]\n );\n}\n\n// Cache the PBKDF2 importKey step — it is cheap but never varies within a\n// process. The expensive derivation still runs per value (random per-value salt).\nlet _keyMaterialPromise: Promise<webcrypto.CryptoKey> | null = null;\n\nfunction getMachineKeyMaterial(): Promise<webcrypto.CryptoKey> {\n if (!_keyMaterialPromise) {\n _keyMaterialPromise = webcrypto.subtle.importKey(\n \"raw\",\n MACHINE_PASSPHRASE,\n { name: \"PBKDF2\" },\n false,\n [\"deriveKey\"]\n );\n }\n return _keyMaterialPromise;\n}\n\nasync function deriveMachineKey(salt: Uint8Array): Promise<webcrypto.CryptoKey> {\n const keyMaterial = await getMachineKeyMaterial();\n return webcrypto.subtle.deriveKey(\n { name: \"PBKDF2\", salt, iterations: PBKDF2_ITERATIONS, hash: \"SHA-256\" },\n keyMaterial,\n { name: \"AES-GCM\", length: 256 },\n false,\n [\"encrypt\", \"decrypt\"]\n );\n}\n\n// ---------------------------------------------------------------------------\n// Encryption helpers\n// ---------------------------------------------------------------------------\n\nconst hex = (buf: ArrayBuffer | Uint8Array): string =>\n Buffer.from(buf instanceof Uint8Array ? buf : new Uint8Array(buf)).toString(\"hex\");\n\nasync function encrypt(plaintext: string): Promise<string> {\n const salt = randomBytes(16);\n const iv = randomBytes(12);\n const masterKey = await getOrCreateMasterKey();\n const key = masterKey\n ? await deriveKeychainKey(masterKey, salt)\n : await deriveMachineKey(salt);\n const cipherBuf = await webcrypto.subtle.encrypt(\n { name: \"AES-GCM\", iv },\n key,\n new TextEncoder().encode(plaintext)\n );\n const body = [hex(salt), hex(iv), hex(cipherBuf)].join(\":\");\n // Keychain-scheme entries are tagged so decrypt() can route; machine-scheme\n // entries stay in the legacy unprefixed format for backward compatibility.\n return masterKey ? `${SCHEME_KEYCHAIN}:${body}` : body;\n}\n\nasync function decryptWith(\n key: webcrypto.CryptoKey,\n ivHex: string,\n cipherHex: string\n): Promise<string> {\n const plainBuf = await webcrypto.subtle.decrypt(\n { name: \"AES-GCM\", iv: Buffer.from(ivHex, \"hex\") },\n key,\n Buffer.from(cipherHex, \"hex\")\n );\n return new TextDecoder().decode(plainBuf);\n}\n\nasync function decrypt(stored: string): Promise<string> {\n const parts = stored.split(\":\");\n // Keychain scheme: \"k1:<salt>:<iv>:<ct>\"\n if (parts.length === 4 && parts[0] === SCHEME_KEYCHAIN) {\n const [, saltHex, ivHex, cipherHex] = parts;\n const masterKey = await readMasterKey();\n if (!masterKey) {\n throw new Error(\n \"Cannot decrypt: this secret is protected by the OS keychain master key, \" +\n \"which is unavailable on this machine/account (or the keychain entry was removed).\"\n );\n }\n const key = await deriveKeychainKey(masterKey, Buffer.from(saltHex, \"hex\"));\n return decryptWith(key, ivHex, cipherHex);\n }\n // Legacy machine scheme: \"<salt>:<iv>:<ct>\"\n if (parts.length === 3) {\n const [saltHex, ivHex, cipherHex] = parts;\n const key = await deriveMachineKey(Buffer.from(saltHex, \"hex\"));\n return decryptWith(key, ivHex, cipherHex);\n }\n throw new Error(\"Invalid ciphertext format\");\n}\n\n// ---------------------------------------------------------------------------\n// Store helpers\n// ---------------------------------------------------------------------------\n\nasync function readStore(): Promise<Record<string, string>> {\n return (await readJson<Record<string, string>>(STORE_FILE)) ?? {};\n}\n\n// Decrypt a single stored value identified by its store key against an\n// already-loaded store snapshot. Shared by the unlocked per-key getSecret and\n// the locked snapshot resolver so both decode entries identically.\nasync function decryptFromSnapshot(\n store: Record<string, string>,\n sk: string\n): Promise<string | null> {\n const stored = store[sk];\n return stored ? decrypt(stored) : null;\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport async function setSecret(server: string, key: string, value: string): Promise<void> {\n const sk = validatedStoreKey(server, key);\n // Encrypt outside the lock (it does not depend on stored state) to keep the\n // critical section short, then read-merge-write atomically under the lock.\n const encrypted = await encrypt(value);\n await withStoreLock(async () => {\n const store = await readStore();\n await writeJson(STORE_FILE, { ...store, [sk]: encrypted });\n });\n}\n\n/**\n * Store multiple secrets for one server in a single read-modify-write, so the\n * batch is all-or-nothing: either every value is persisted or none is (no\n * orphaned half-written secrets if one encrypt fails — security review MED-1).\n */\nexport async function setSecrets(\n server: string,\n values: Record<string, string>\n): Promise<void> {\n // Encrypt every value first (no dependency on stored state), then read-merge-\n // write atomically under the lock so a concurrent writer cannot lost-update\n // this batch.\n const encrypted: Record<string, string> = {};\n for (const [key, value] of Object.entries(values)) {\n encrypted[validatedStoreKey(server, key)] = await encrypt(value);\n }\n await withStoreLock(async () => {\n const store = await readStore();\n await writeJson(STORE_FILE, { ...store, ...encrypted });\n });\n}\n\n// Intentionally UNLOCKED: a read-only single-key lookup. It accepts eventual\n// consistency (it may observe a concurrent writer's snapshot before or after a\n// mutation, never a torn one — writeJson swaps the file atomically via rename).\n// The consistency-sensitive path is resolveEnvPlaceholders, which takes the\n// lock and reads one snapshot for all keys.\nexport async function getSecret(server: string, key: string): Promise<string | null> {\n const sk = validatedStoreKey(server, key);\n return decryptFromSnapshot(await readStore(), sk);\n}\n\n/** Returns true if a secret was removed, false if no such secret existed. */\nexport async function deleteSecret(server: string, key: string): Promise<boolean> {\n const sk = validatedStoreKey(server, key);\n let removed = false;\n await withStoreLock(async () => {\n const store = await readStore();\n if (!(sk in store)) return;\n const { [sk]: _removed, ...rest } = store;\n await writeJson(STORE_FILE, rest);\n removed = true;\n });\n return removed;\n}\n\n/** Produces the placeholder string stored in config files. */\nexport function toPlaceholder(server: string, key: string): string {\n return `${PLACEHOLDER_PREFIX}${server}/${key}`;\n}\n\n/** Parses a placeholder string. Returns null if not a placeholder. */\nexport function parsePlaceholder(value: string): { server: string; key: string } | null {\n if (!value.startsWith(PLACEHOLDER_PREFIX)) return null;\n const rest = value.slice(PLACEHOLDER_PREFIX.length);\n const slashIdx = rest.indexOf(\"/\");\n if (slashIdx === -1) return null;\n return { server: rest.slice(0, slashIdx), key: rest.slice(slashIdx + 1) };\n}\n\n/**\n * Resolve any `mcpm:keychain:server/KEY` placeholder values in an env map to\n * their decrypted secrets. Non-placeholder values pass through unchanged;\n * `undefined` values are dropped. Throws if a placeholder references a secret\n * that is not stored.\n *\n * The decrypted values exist only in the returned in-memory object — they are\n * never written to disk. `mcpm guard run --inner` calls this to inject secrets\n * into a wrapped server's child process without storing plaintext in client\n * config files.\n *\n * Reads the store as a single CONSISTENT SNAPSHOT under `withStoreLock`: all\n * placeholders are resolved against one read taken while holding the same lock\n * the write paths (setSecret/setSecrets/deleteSecret) hold. This closes the\n * read-after-delete race where a concurrent `secrets delete` during guard\n * startup made a per-key unlocked lookup observe a torn state and throw \"Secret\n * not found\" — the exact race the lock was added to prevent. This is the only\n * caller (guard/run-inner.ts), invoked at the top level and NOT from inside an\n * already-held store lock, so acquiring the lock here cannot self-deadlock.\n */\nexport async function resolveEnvPlaceholders(\n env: NodeJS.ProcessEnv\n): Promise<Record<string, string>> {\n // Parse placeholders up front so the locked critical section is just one read\n // plus decryption — no validation or iteration over non-placeholder values.\n const passthrough: Record<string, string> = {};\n const placeholders: Array<{ name: string; server: string; key: string }> = [];\n for (const [name, value] of Object.entries(env)) {\n if (value === undefined) continue;\n const placeholder = parsePlaceholder(value);\n if (placeholder === null) {\n passthrough[name] = value;\n continue;\n }\n placeholders.push({ name, ...placeholder });\n }\n\n // No secrets to resolve — skip the lock entirely.\n if (placeholders.length === 0) return passthrough;\n\n return withStoreLock(async () => {\n const store = await readStore();\n const resolved: Record<string, string> = { ...passthrough };\n for (const { name, server, key } of placeholders) {\n const sk = validatedStoreKey(server, key);\n const secret = await decryptFromSnapshot(store, sk);\n if (secret === null) {\n throw new Error(\n `Secret \"${server}/${key}\" not found. ` +\n `Run \\`mcpm secrets set ${server} ${key}\\` to store it.`\n );\n }\n resolved[name] = secret;\n }\n return resolved;\n });\n}\n\n/**\n * Derive a keychain-safe server id from a (possibly slash-containing) server\n * name. Registry ids like \"io.github.owner/repo\" contain `/`, which is invalid\n * for a keychain id (assertSafeId) and would break placeholder parsing (which\n * splits on the first `/`).\n *\n * The sanitised prefix keeps the id human-recognisable; a sha256 suffix makes\n * the mapping INJECTIVE, so two names that differ only in unsafe characters\n * (e.g. \"owner/repo\" vs \"owner_repo\") can never collide into one secret\n * namespace (security review CRIT-1). Deterministic: the same name always maps\n * to the same id, so a placeholder written at install resolves at launch.\n */\nexport function deriveKeychainId(name: string): string {\n const sanitized = name.replace(/[^a-zA-Z0-9._-]/g, \"_\").slice(0, 200);\n const hash = createHash(\"sha256\").update(name).digest(\"hex\").slice(0, 12);\n return `${sanitized}-${hash}`;\n}\n\n/**\n * List all stored secrets grouped by server name. Returns only key names —\n * decrypted values are never read or returned.\n *\n * Intentionally UNLOCKED: read-only enumeration, eventual consistency.\n */\nexport async function listAll(): Promise<Record<string, string[]>> {\n const store = await readStore();\n const grouped: Record<string, string[]> = {};\n for (const storeKey of Object.keys(store)) {\n const slashIdx = storeKey.indexOf(\"/\");\n if (slashIdx === -1) continue;\n const server = storeKey.slice(0, slashIdx);\n const key = storeKey.slice(slashIdx + 1);\n (grouped[server] ??= []).push(key);\n }\n return grouped;\n}\n\n// ---------------------------------------------------------------------------\n// Backend status + migration (security #15)\n// ---------------------------------------------------------------------------\n\n/**\n * Which backend protected the most recent write — a backward-looking reflection,\n * not a forward-looking prediction. Returns \"os-keychain\" only when a master key\n * is actually present. Intended to be called AFTER a `setSecret`/`migrate` (as\n * `mcpm secrets set` does), where it accurately reports the scheme just used —\n * including reporting \"machine-key\" when a keychain write silently failed and\n * fell back. Called standalone before any write on a keychain-capable machine\n * with no key yet, it returns \"machine-key\" (no key created yet); the next write\n * would create one and use \"os-keychain\".\n */\nexport async function activeSecretBackend(): Promise<\"os-keychain\" | \"machine-key\"> {\n return (await readMasterKey()) !== null ? \"os-keychain\" : \"machine-key\";\n}\n\n/**\n * Re-encrypt every legacy machine-scheme entry under the OS keychain master\n * key, so existing secrets gain the same exfiltration resistance as new ones.\n *\n * No-op (`usingKeychain: false`) when no OS keychain is available. Per-entry\n * failures are isolated: a legacy entry this machine key can no longer decrypt\n * (e.g. written on a different machine) is counted in `failed` and left\n * untouched rather than aborting the whole migration.\n */\nexport async function migrateToKeychain(): Promise<{\n migrated: number;\n failed: number;\n total: number;\n usingKeychain: boolean;\n}> {\n const masterKey = await getOrCreateMasterKey();\n if (!masterKey) return { migrated: 0, failed: 0, total: 0, usingKeychain: false };\n return withStoreLock(async () => {\n const store = await readStore();\n const entries = Object.entries(store);\n const next: Record<string, string> = {};\n let migrated = 0;\n let failed = 0;\n for (const [sk, value] of entries) {\n const isLegacy = value.split(\":\").length === 3;\n if (!isLegacy) {\n next[sk] = value; // already keychain-scheme (or unknown) — leave as-is\n continue;\n }\n try {\n const plain = await decrypt(value); // legacy machine scheme\n next[sk] = await encrypt(plain); // re-encrypt under the keychain master key\n migrated++;\n } catch {\n next[sk] = value; // undecryptable legacy entry — leave untouched\n failed++;\n }\n }\n if (migrated > 0) await writeJson(STORE_FILE, next);\n return { migrated, failed, total: entries.length, usingKeychain: true };\n });\n}\n\n/**\n * How secret-flagged env vars are persisted.\n * - \"plaintext\": written directly into the client config (legacy default).\n * - \"keychain\": stored AES-GCM-encrypted; config gets a `mcpm:keychain:…`\n * placeholder that mcpm guard resolves at launch.\n */\nexport type SecretsMode = \"plaintext\" | \"keychain\";\n\n/**\n * Resolve a server's env map for writing to a client config under the given\n * secrets mode. In \"keychain\" mode, every key for which `isSecret(key)` is true\n * is stored encrypted via `setSecret` and replaced with a `mcpm:keychain:…`\n * placeholder; all other values pass through. In \"plaintext\" mode the input is\n * returned unchanged. This is the single place the \"no plaintext secret in\n * config\" invariant is enforced — install and up both go through it.\n *\n * Throws if keychain mode is requested without a `setSecret` implementation.\n */\nexport async function applyKeychainSecrets(opts: {\n serverName: string;\n resolvedEnv: Record<string, string>;\n isSecret: (key: string) => boolean;\n mode: SecretsMode;\n setSecrets?: (server: string, values: Record<string, string>) => Promise<void>;\n}): Promise<{ env: Record<string, string>; storedCount: number }> {\n if (opts.mode !== \"keychain\") {\n return { env: opts.resolvedEnv, storedCount: 0 };\n }\n if (!opts.setSecrets) {\n throw new Error(\"Keychain secret storage is unavailable.\");\n }\n const keychainId = deriveKeychainId(opts.serverName);\n const env: Record<string, string> = {};\n const toStore: Record<string, string> = {};\n for (const [key, value] of Object.entries(opts.resolvedEnv)) {\n if (opts.isSecret(key)) {\n toStore[key] = value;\n env[key] = toPlaceholder(keychainId, key);\n } else {\n env[key] = value;\n }\n }\n const storedCount = Object.keys(toStore).length;\n // Persist all secrets in one atomic batch BEFORE returning the env that the\n // caller writes to config — so we never write a placeholder for a secret that\n // failed to store (all-or-nothing; security review MED-1).\n if (storedCount > 0) {\n await opts.setSecrets(keychainId, toStore);\n }\n return { env, storedCount };\n}\n\n/**\n * Return the keys of `env` whose value is a `mcpm:keychain:…` placeholder.\n * Used by `mcpm guard disable` to warn about secrets that will no longer\n * resolve once guard stops wrapping the server.\n */\nexport function placeholderEnvKeys(env: Record<string, string> | undefined): string[] {\n if (!env) return [];\n return Object.entries(env)\n .filter(([, v]) => typeof v === \"string\" && parsePlaceholder(v) !== null)\n .map(([k]) => k);\n}\n","/**\n * Zero-native-dependency access to the operating system's credential store.\n *\n * The secret store (store/keychain.ts) holds a single random 32-byte *master\n * key* here; every stored secret is AES-GCM-encrypted with a subkey derived\n * from it. Keeping the master key in the OS credential store — not on disk —\n * is what makes a copied `~/.mcpm/secrets.enc.json` undecryptable off-machine\n * (security issue #15).\n *\n * No native modules (no `keytar`/node-gyp). We shell out to the platform's\n * built-in tooling:\n * - macOS: `security` (login Keychain) — generic password item\n * - Linux: `secret-tool` (libsecret/Secret Service) — schema attributes\n * - Windows: DPAPI via PowerShell `ProtectedData` — blob in ~/.mcpm\n *\n * Every operation is best-effort: if the platform tool is missing, the Secret\n * Service is unavailable (headless Linux, CI), or any call fails, the function\n * resolves to \"unavailable\"/null and the caller falls back to the legacy\n * machine-derived key (casual-inspection only — see store/keychain.ts).\n *\n * Set `MCPM_DISABLE_OS_KEYCHAIN=1` to force the fallback (used by the test\n * suite so it never touches the developer's real Keychain, and available to\n * users who prefer not to use the OS store).\n */\n\nimport { spawn } from \"node:child_process\";\nimport { readFile, writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { getStorePath } from \"./index.js\";\n\nconst SERVICE = \"mcpm\";\nconst ACCOUNT = \"secret-store-master-key\";\nconst DPAPI_BLOB_FILE = \"master-key.dpapi\";\nconst EXEC_TIMEOUT_MS = 5_000;\n\n/** Result of a child-process run. `code === null` means spawn failed (ENOENT). */\ninterface RunResult {\n code: number | null;\n stdout: string;\n stderr: string;\n}\n\n/**\n * Run a command to completion, optionally feeding `input` to stdin. Never\n * rejects: a missing binary (ENOENT) or any spawn error resolves to\n * `{ code: null }` so callers treat it uniformly as \"unavailable\".\n */\nfunction run(\n command: string,\n args: string[],\n opts: { input?: string; env?: NodeJS.ProcessEnv } = {}\n): Promise<RunResult> {\n return new Promise((resolve) => {\n const child = spawn(command, args, {\n timeout: EXEC_TIMEOUT_MS,\n env: opts.env ?? process.env,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n let stdout = \"\";\n let stderr = \"\";\n // `error` (ENOENT/EACCES) and `close` can both fire for one spawn; settle\n // exactly once. `close` (not `exit`) is used so stdout is fully drained\n // before we resolve.\n let settled = false;\n const settle = (r: RunResult): void => {\n if (settled) return;\n settled = true;\n resolve(r);\n };\n child.stdout.on(\"data\", (d) => (stdout += d.toString()));\n child.stderr.on(\"data\", (d) => (stderr += d.toString()));\n child.on(\"error\", () => settle({ code: null, stdout, stderr }));\n child.on(\"close\", (code) => settle({ code, stdout, stderr }));\n if (opts.input !== undefined) {\n child.stdin.on(\"error\", () => {\n /* child may have exited before stdin flush; swallow EPIPE */\n });\n child.stdin.end(opts.input);\n } else {\n child.stdin.end();\n }\n });\n}\n\n/** True on a platform we know how to drive (and not explicitly disabled). */\nexport function isSupportedPlatform(): boolean {\n if (process.env.MCPM_DISABLE_OS_KEYCHAIN === \"1\") return false;\n return (\n process.platform === \"darwin\" ||\n process.platform === \"linux\" ||\n process.platform === \"win32\"\n );\n}\n\n// ---------------------------------------------------------------------------\n// macOS — security(1) generic-password items in the login Keychain\n// ---------------------------------------------------------------------------\n\nasync function darwinGet(): Promise<Buffer | null> {\n // -w prints only the password; exit 44 when the item does not exist.\n const r = await run(\"security\", [\n \"find-generic-password\",\n \"-a\",\n ACCOUNT,\n \"-s\",\n SERVICE,\n \"-w\",\n ]);\n if (r.code !== 0) return null;\n return decodeKey(r.stdout.trim());\n}\n\nasync function darwinStore(keyB64: string): Promise<boolean> {\n // -U updates the item if it already exists instead of erroring.\n //\n // Tradeoff: `security` has no reliable non-interactive stdin path for the\n // password, so the (base64) master key is passed in argv and is briefly\n // visible to a *same-user* `ps` during the child's lifetime. This is a narrow,\n // write-only window: the read path (darwinGet) returns the key on stdout to\n // this parent only, and cross-user argv is not readable. A same-user attacker\n // who can `ps` can already read this process's memory, so this does not widen\n // the trust boundary the keychain establishes (off-machine file exfiltration).\n const r = await run(\"security\", [\n \"add-generic-password\",\n \"-a\",\n ACCOUNT,\n \"-s\",\n SERVICE,\n \"-U\",\n \"-w\",\n keyB64,\n ]);\n return r.code === 0;\n}\n\n// ---------------------------------------------------------------------------\n// Linux — secret-tool (libsecret). Value is read from / written to stdin.\n// ---------------------------------------------------------------------------\n\nasync function linuxGet(): Promise<Buffer | null> {\n const r = await run(\"secret-tool\", [\"lookup\", \"service\", SERVICE, \"account\", ACCOUNT]);\n if (r.code !== 0) return null;\n const value = r.stdout.trim(); // consistent with darwin/windows; tolerate \\r\\n / spaces\n if (value.length === 0) return null;\n return decodeKey(value);\n}\n\nasync function linuxStore(keyB64: string): Promise<boolean> {\n const r = await run(\n \"secret-tool\",\n [\"store\", \"--label=mcpm secret store master key\", \"service\", SERVICE, \"account\", ACCOUNT],\n { input: keyB64 }\n );\n return r.code === 0;\n}\n\n// ---------------------------------------------------------------------------\n// Windows — DPAPI (CurrentUser scope) via PowerShell. The protected blob is\n// stored in ~/.mcpm; DPAPI ties decryption to the Windows user account, so a\n// copied blob cannot be unprotected by another account or on another machine.\n// The plaintext key is passed through an env var, never argv (process list).\n// ---------------------------------------------------------------------------\n\nconst PS_PROTECT =\n \"$ErrorActionPreference='Stop';\" +\n \"Add-Type -AssemblyName System.Security;\" +\n \"$b=[Convert]::FromBase64String($env:MCPM_KEY_B64);\" +\n \"$p=[Security.Cryptography.ProtectedData]::Protect($b,$null,'CurrentUser');\" +\n \"[Convert]::ToBase64String($p)\";\n\nconst PS_UNPROTECT =\n \"$ErrorActionPreference='Stop';\" +\n \"Add-Type -AssemblyName System.Security;\" +\n \"$b=[Convert]::FromBase64String($env:MCPM_BLOB_B64);\" +\n \"$p=[Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser');\" +\n \"[Convert]::ToBase64String($p)\";\n\nasync function blobPath(): Promise<string> {\n return path.join(await getStorePath(), DPAPI_BLOB_FILE);\n}\n\nasync function windowsGet(): Promise<Buffer | null> {\n let blob: string;\n try {\n blob = (await readFile(await blobPath(), \"utf8\")).trim();\n } catch {\n return null; // no blob stored yet\n }\n if (blob.length === 0) return null;\n const r = await run(\"powershell\", [\"-NoProfile\", \"-NonInteractive\", \"-Command\", PS_UNPROTECT], {\n env: { ...process.env, MCPM_BLOB_B64: blob },\n });\n if (r.code !== 0) return null;\n return decodeKey(r.stdout.trim());\n}\n\nasync function windowsStore(keyB64: string): Promise<boolean> {\n const r = await run(\"powershell\", [\"-NoProfile\", \"-NonInteractive\", \"-Command\", PS_PROTECT], {\n env: { ...process.env, MCPM_KEY_B64: keyB64 },\n });\n if (r.code !== 0 || r.stdout.trim().length === 0) return false;\n try {\n await writeFile(await blobPath(), r.stdout.trim(), { mode: 0o600 });\n return true;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Shared helpers + public dispatch\n// ---------------------------------------------------------------------------\n\n/** Decode a base64 master key, rejecting anything that is not exactly 32 bytes. */\nfunction decodeKey(b64: string): Buffer | null {\n try {\n const buf = Buffer.from(b64, \"base64\");\n return buf.length === 32 ? buf : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Read the stored master key, or null if none is stored / the store is\n * unavailable. Never creates a key.\n */\nexport async function getStoredKey(): Promise<Buffer | null> {\n if (!isSupportedPlatform()) return null;\n switch (process.platform) {\n case \"darwin\":\n return darwinGet();\n case \"linux\":\n return linuxGet();\n case \"win32\":\n return windowsGet();\n default:\n return null;\n }\n}\n\n/**\n * Persist `key` (exactly 32 bytes) in the OS credential store. Returns true on\n * success, false if the store is unavailable or the write failed.\n */\nexport async function storeKey(key: Buffer): Promise<boolean> {\n if (!isSupportedPlatform()) return false;\n if (key.length !== 32) return false;\n const keyB64 = key.toString(\"base64\");\n switch (process.platform) {\n case \"darwin\":\n return darwinStore(keyB64);\n case \"linux\":\n return linuxStore(keyB64);\n case \"win32\":\n return windowsStore(keyB64);\n default:\n return false;\n }\n}\n"],"mappings":"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","names":["storeKey"]} |
| #!/usr/bin/env node | ||
| import { | ||
| coloredOutput | ||
| } from "./chunk-E3T224S3.js"; | ||
| import { | ||
| isConfineBackendAvailable, | ||
| isWrapped | ||
| } from "./chunk-WYSMWP2R.js"; | ||
| import { | ||
| sanitizeForTerminal | ||
| } from "./chunk-FEXJHHDM.js"; | ||
| import { | ||
| getAdapter | ||
| } from "./chunk-W4IAFBUN.js"; | ||
| import { | ||
| isSupportedPlatform, | ||
| parsePlaceholder | ||
| } from "./chunk-GZ3WCRLG.js"; | ||
| import { | ||
| CLIENT_IDS, | ||
| getConfigPath | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import { | ||
| detectSecretLabels | ||
| } from "./chunk-U7N6FRYF.js"; | ||
| // src/utils/format-entry.ts | ||
| function formatMcpEntryCommand(entry, fallback = "\u2014") { | ||
| if (entry.url) return entry.url; | ||
| if (entry.command) { | ||
| const args = entry.args?.join(" ") ?? ""; | ||
| return args ? `${entry.command} ${args}` : entry.command; | ||
| } | ||
| return fallback; | ||
| } | ||
| // src/commands/doctor.ts | ||
| import { access } from "fs/promises"; | ||
| // src/config/drift.ts | ||
| async function collectClientStates(deps) { | ||
| const clients = await deps.detectClients(); | ||
| const states = []; | ||
| for (const clientId of clients) { | ||
| try { | ||
| const servers = await deps.getAdapter(clientId).read(deps.getPath(clientId)); | ||
| states.push({ clientId, servers }); | ||
| } catch { | ||
| } | ||
| } | ||
| return states; | ||
| } | ||
| function fieldProjection(entry) { | ||
| return { | ||
| command: entry.command ?? "", | ||
| args: JSON.stringify(entry.args ?? []), | ||
| "env keys": JSON.stringify(Object.keys(entry.env ?? {}).sort()), | ||
| url: entry.url ?? "", | ||
| "header keys": JSON.stringify(Object.keys(entry.headers ?? {}).sort()) | ||
| }; | ||
| } | ||
| var COMPARED_FIELDS = ["command", "args", "env keys", "url", "header keys"]; | ||
| function divergingFields(entries) { | ||
| const projections = entries.map(fieldProjection); | ||
| return COMPARED_FIELDS.filter((field) => { | ||
| const distinct = new Set(projections.map((p) => p[field])); | ||
| return distinct.size > 1; | ||
| }); | ||
| } | ||
| function buildDriftModel(states) { | ||
| const clients = states.map((s) => s.clientId).sort(); | ||
| const byName = /* @__PURE__ */ new Map(); | ||
| for (const { clientId, servers: servers2 } of states) { | ||
| for (const [name, entry] of Object.entries(servers2)) { | ||
| const list = byName.get(name) ?? []; | ||
| list.push({ clientId, entry }); | ||
| byName.set(name, list); | ||
| } | ||
| } | ||
| const servers = []; | ||
| for (const name of [...byName.keys()].sort()) { | ||
| const holders = byName.get(name); | ||
| const present = holders.map((h) => h.clientId).sort(); | ||
| const presentSet = new Set(present); | ||
| const absent = clients.filter((c) => !presentSet.has(c)); | ||
| const fields = holders.length > 1 ? divergingFields(holders.map((h) => h.entry)) : []; | ||
| const conflict = fields.length > 0; | ||
| servers.push({ | ||
| name, | ||
| present, | ||
| absent, | ||
| conflict, | ||
| ...conflict ? { conflictFields: fields } : {} | ||
| }); | ||
| } | ||
| const drifted = servers.filter((s) => s.absent.length > 0 || s.conflict).length; | ||
| return { clients, servers, inSync: servers.length - drifted, drifted }; | ||
| } | ||
| // src/scanner/config-secrets.ts | ||
| var GENERIC_LABEL = "secret-named key holds a plaintext value"; | ||
| var SECRET_KEY_RE = /(?:^|_)(?:PASSWORD|PASSWD|PASSPHRASE|SECRET|TOKEN|PAT|APIKEY|AUTHORIZATION|CREDENTIALS?|(?:API|ACCESS|PRIVATE|SECRET|SESSION|SIGNING|ENCRYPTION)_KEY)(?:_|$)/; | ||
| var NON_SECRET_QUALIFIER_RE = /(?:^|_)(?:URL|URI|ENDPOINT|HOST|PORT|ID|NAME|PATH|FILE|DIR|ENABLED|DISABLED|TYPE|MODE|REGION|TIMEOUT|VERSION|PUBLIC|FORMAT|HEADER|PREFIX|SUFFIX|COUNT|SIZE|TTL|EXPIRY|EXPIRES|ISSUER|AUDIENCE|ALGORITHM|ALG|SCOPE|METHOD)(?:_|$)/; | ||
| function normalizeKey(key) { | ||
| return key.toUpperCase().replace(/-/g, "_"); | ||
| } | ||
| function keyLooksSecret(key) { | ||
| const k = normalizeKey(key); | ||
| return SECRET_KEY_RE.test(k) && !NON_SECRET_QUALIFIER_RE.test(k); | ||
| } | ||
| function valueLooksPlaintextSecret(value) { | ||
| const v = value.trim(); | ||
| if (v.length < 6) return false; | ||
| if (parsePlaceholder(value) !== null) return false; | ||
| if (/\$\{[^}]*\}/.test(v)) return false; | ||
| if (/^\$[A-Za-z_]/.test(v)) return false; | ||
| if (/^%[A-Za-z_][A-Za-z0-9_]*%([\\/].*)?$/.test(v)) return false; | ||
| if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return false; | ||
| if (/^[~./]/.test(v) || /^[A-Za-z]:[\\/]/.test(v) || /^\\\\/.test(v)) return false; | ||
| if (/^(true|false|\d+)$/i.test(v)) return false; | ||
| return true; | ||
| } | ||
| function scanMap(server, field, map) { | ||
| if (!map) return []; | ||
| const out = []; | ||
| for (const [key, value] of Object.entries(map)) { | ||
| if (typeof value !== "string") continue; | ||
| if (parsePlaceholder(value) !== null) continue; | ||
| const labels = detectSecretLabels(value); | ||
| if (labels.length > 0) { | ||
| out.push({ server, field, key, label: labels.join(", ") }); | ||
| continue; | ||
| } | ||
| if (keyLooksSecret(key) && valueLooksPlaintextSecret(value)) { | ||
| out.push({ server, field, key, label: GENERIC_LABEL }); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| function scanServerConfigSecrets(server, entry) { | ||
| return [...scanMap(server, "env", entry.env), ...scanMap(server, "header", entry.headers)]; | ||
| } | ||
| function scanConfigSecrets(servers) { | ||
| return Object.entries(servers).flatMap(([name, entry]) => scanServerConfigSecrets(name, entry)); | ||
| } | ||
| // src/commands/doctor.ts | ||
| import "commander"; | ||
| import os from "os"; | ||
| import { execFile } from "child_process"; | ||
| var RUNTIMES = ["npx", "uvx", "docker"]; | ||
| var CLIENT_LABELS = { | ||
| "claude-desktop": "Claude Desktop", | ||
| "claude-code": "Claude Code", | ||
| cursor: "Cursor", | ||
| vscode: "VS Code", | ||
| windsurf: "Windsurf", | ||
| "gemini-cli": "Gemini CLI" | ||
| }; | ||
| var RUNTIME_INSTALL_HINTS = { | ||
| npx: "install Node.js from https://nodejs.org", | ||
| uvx: "install uv from https://docs.astral.sh/uv/", | ||
| docker: "install Docker from https://docs.docker.com/get-docker/" | ||
| }; | ||
| async function buildDoctorModel(deps) { | ||
| const { getAdapter: getAdapter2, getConfigPath: getConfigPath2, checkConfigExists, execCheck } = deps; | ||
| const reads = await Promise.all( | ||
| CLIENT_IDS.map(async (clientId) => { | ||
| const exists = await checkConfigExists(clientId); | ||
| if (!exists) return { clientId, read: { exists: false, malformed: false, servers: null } }; | ||
| try { | ||
| const servers = await getAdapter2(clientId).read(getConfigPath2(clientId)); | ||
| return { clientId, read: { exists: true, malformed: false, servers } }; | ||
| } catch { | ||
| return { clientId, read: { exists: true, malformed: true, servers: null } }; | ||
| } | ||
| }) | ||
| ); | ||
| const issues = []; | ||
| const clients = reads.map(({ clientId, read }) => { | ||
| const label = CLIENT_LABELS[clientId]; | ||
| if (read.malformed) { | ||
| issues.push({ | ||
| kind: "malformed-config", | ||
| message: `Config file for ${label} is malformed \u2014 fix the JSON syntax.` | ||
| }); | ||
| } | ||
| const servers = read.servers ?? {}; | ||
| const entries = Object.values(servers); | ||
| return { | ||
| id: clientId, | ||
| label, | ||
| exists: read.exists, | ||
| malformed: read.malformed, | ||
| serverCount: entries.length, | ||
| guardedCount: entries.filter(isWrapped).length | ||
| }; | ||
| }); | ||
| const runtimes = await Promise.all( | ||
| RUNTIMES.map(async (name) => ({ name, available: await execCheck(name) })) | ||
| ); | ||
| const runtimeAvailable = new Map(runtimes.map((r) => [r.name, r.available])); | ||
| for (const { clientId, read } of reads) { | ||
| if (!read.servers) continue; | ||
| for (const [serverName, entry] of Object.entries(read.servers)) { | ||
| const cmd = entry.command; | ||
| if (!cmd) continue; | ||
| if (RUNTIMES.includes(cmd) && runtimeAvailable.get(cmd) === false) { | ||
| issues.push({ | ||
| kind: "missing-runtime", | ||
| message: `Server '${serverName}' in ${CLIENT_LABELS[clientId]} uses '${cmd}' but ${cmd} is not installed.` | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| const driftStates = reads.flatMap( | ||
| ({ clientId, read }) => read.servers ? [{ clientId, servers: read.servers }] : [] | ||
| ); | ||
| const crossClient = driftStates.length >= 2 ? toCrossClient(driftStates) : null; | ||
| const secrets = reads.flatMap( | ||
| ({ clientId, read }) => read.servers ? scanConfigSecrets(read.servers).map((f) => ({ client: clientId, ...f })) : [] | ||
| ); | ||
| return { | ||
| schemaVersion: 1, | ||
| clients, | ||
| runtimes, | ||
| crossClient, | ||
| secrets, | ||
| issues, | ||
| ok: issues.length === 0 | ||
| }; | ||
| } | ||
| function toCrossClient(states) { | ||
| const drift = buildDriftModel(states); | ||
| const entries = []; | ||
| for (const server of drift.servers) { | ||
| if (server.conflict) { | ||
| entries.push({ | ||
| name: server.name, | ||
| kind: "conflict", | ||
| present: [...server.present], | ||
| absent: [...server.absent], | ||
| fields: server.conflictFields ? [...server.conflictFields] : void 0 | ||
| }); | ||
| } else if (server.absent.length > 0) { | ||
| entries.push({ | ||
| name: server.name, | ||
| kind: "absent", | ||
| present: [...server.present], | ||
| absent: [...server.absent] | ||
| }); | ||
| } | ||
| } | ||
| return { | ||
| consistent: drift.drifted === 0, | ||
| clientCount: drift.clients.length, | ||
| serverCount: drift.servers.length, | ||
| drift: entries | ||
| }; | ||
| } | ||
| function renderDoctorText(model, output) { | ||
| output(""); | ||
| output("mcpm doctor"); | ||
| output(""); | ||
| for (const c of model.clients) { | ||
| if (!c.exists) { | ||
| output(` \u2717 ${c.label} \u2014 config not found`); | ||
| } else if (c.malformed) { | ||
| output(` \u2717 ${c.label} \u2014 config malformed (JSON parse error)`); | ||
| } else { | ||
| const word = c.serverCount === 1 ? "server" : "servers"; | ||
| output(` \u2713 ${c.label} \u2014 config found, ${c.serverCount} ${word}`); | ||
| } | ||
| } | ||
| output(""); | ||
| output("Runtimes:"); | ||
| for (const r of model.runtimes) { | ||
| if (r.available) { | ||
| output(` \u2713 ${r.name} available`); | ||
| } else { | ||
| output(` \u2717 ${r.name} not found \u2014 ${RUNTIME_INSTALL_HINTS[r.name]}`); | ||
| } | ||
| } | ||
| if (model.crossClient) { | ||
| const cc = model.crossClient; | ||
| output(""); | ||
| output("Cross-client (advisory):"); | ||
| if (cc.consistent) { | ||
| const word = cc.serverCount === 1 ? "server" : "servers"; | ||
| output(` \u2713 ${cc.serverCount} ${word} consistent across ${cc.clientCount} clients`); | ||
| } else { | ||
| for (const d of cc.drift) { | ||
| if (d.kind === "conflict") { | ||
| output(` \u26A0 ${d.name} \u2014 config differs (${d.fields.join(", ")}) across ${d.present.join(", ")}`); | ||
| } else { | ||
| output(` \u26A0 ${d.name} \u2014 in ${d.present.join(", ")}; missing in ${d.absent.join(", ")}`); | ||
| } | ||
| } | ||
| output(" Run `mcpm sync --check` for the full matrix (advisory, not a failure)."); | ||
| } | ||
| } | ||
| if (model.secrets.length > 0) { | ||
| output(""); | ||
| output("Plaintext secrets (advisory):"); | ||
| for (const s of model.secrets) { | ||
| output( | ||
| ` \u26A0 ${s.client} \xB7 ${sanitizeForTerminal(s.server)} \xB7 ${s.field} '${sanitizeForTerminal(s.key)}' \u2014 ${s.label}` | ||
| ); | ||
| } | ||
| if (model.secrets.some((s) => s.field === "env")) { | ||
| output( | ||
| " Move env secrets to the encrypted store: `mcpm secrets set <server> <KEY>` or re-install with `--secrets keychain`." | ||
| ); | ||
| } | ||
| if (model.secrets.some((s) => s.field === "header")) { | ||
| output( | ||
| " Header secrets have no keychain path yet \u2014 rotate the credential and keep it out of committed config." | ||
| ); | ||
| } | ||
| } | ||
| if (model.issues.length > 0) { | ||
| output(""); | ||
| output("Issues:"); | ||
| for (const issue of model.issues) { | ||
| output(` \u26A0 ${issue.message}`); | ||
| } | ||
| output(""); | ||
| output("Critical issues found. Run the commands above to resolve them."); | ||
| return; | ||
| } | ||
| output(""); | ||
| output("No critical issues found."); | ||
| } | ||
| function buildDoctorReport(model, env) { | ||
| return { | ||
| schemaVersion: 1, | ||
| mcpm: env.mcpm, | ||
| node: env.node, | ||
| os: `${env.platform} ${env.arch} ${env.osRelease}`, | ||
| confineBackend: env.confineBackend, | ||
| secretStore: env.secretStore, | ||
| // Redaction: drop the label + every server name; keep only counts. | ||
| clients: model.clients.map(({ id, exists, malformed, serverCount, guardedCount }) => ({ | ||
| id, | ||
| exists, | ||
| malformed, | ||
| serverCount, | ||
| guardedCount | ||
| })), | ||
| runtimes: model.runtimes, | ||
| issues: { | ||
| malformedConfigs: model.issues.filter((i) => i.kind === "malformed-config").length, | ||
| missingRuntime: model.issues.filter((i) => i.kind === "missing-runtime").length, | ||
| plaintextSecrets: model.secrets.length | ||
| } | ||
| }; | ||
| } | ||
| function renderReportText(r) { | ||
| const lines = []; | ||
| lines.push("mcpm doctor --report (redacted \u2014 no server names or args)"); | ||
| lines.push(`mcpm: ${r.mcpm}`); | ||
| lines.push(`node: ${r.node}`); | ||
| lines.push(`os: ${r.os}`); | ||
| lines.push(`confine backend: ${r.confineBackend ? "available" : "unavailable"}`); | ||
| lines.push(`secret store: ${r.secretStore}`); | ||
| lines.push(""); | ||
| lines.push("clients:"); | ||
| for (const c of r.clients) { | ||
| if (!c.exists) { | ||
| lines.push(` ${c.id}: not found`); | ||
| } else if (c.malformed) { | ||
| lines.push(` ${c.id}: config malformed`); | ||
| } else { | ||
| const guarded = c.guardedCount > 0 ? `, ${c.guardedCount} guarded` : ""; | ||
| lines.push(` ${c.id}: ${c.serverCount} servers${guarded}`); | ||
| } | ||
| } | ||
| lines.push("runtimes:"); | ||
| for (const rt of r.runtimes) { | ||
| lines.push(` ${rt.name}: ${rt.available ? "available" : "missing"}`); | ||
| } | ||
| lines.push( | ||
| `issues: ${r.issues.malformedConfigs} malformed config(s), ${r.issues.missingRuntime} missing-runtime, ${r.issues.plaintextSecrets} plaintext secret(s)` | ||
| ); | ||
| return lines.join("\n"); | ||
| } | ||
| async function doctorHandler(deps, opts = {}) { | ||
| const model = await buildDoctorModel(deps); | ||
| if (opts.report) { | ||
| const env = opts.reportEnv ?? gatherReportEnv(); | ||
| deps.output(renderReportText(buildDoctorReport(model, env))); | ||
| } else if (opts.json) { | ||
| deps.output(JSON.stringify(model, null, 2)); | ||
| } else { | ||
| renderDoctorText(model, deps.output); | ||
| } | ||
| return model.ok ? 0 : 1; | ||
| } | ||
| function makeCheckConfigExists(getConfigPathFn) { | ||
| return async (clientId) => { | ||
| try { | ||
| await access(getConfigPathFn(clientId)); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| }; | ||
| } | ||
| var checkConfigExistsDefault = makeCheckConfigExists(getConfigPath); | ||
| var ALLOWED_RUNTIME_CMDS = /* @__PURE__ */ new Set(["npx", "uvx", "docker"]); | ||
| function execCheckDefault(cmd) { | ||
| if (!ALLOWED_RUNTIME_CMDS.has(cmd)) return Promise.resolve(false); | ||
| return new Promise((resolve) => { | ||
| const which = process.platform === "win32" ? "where" : "which"; | ||
| execFile(which, [cmd], (err) => { | ||
| resolve(err === null); | ||
| }); | ||
| }); | ||
| } | ||
| function gatherReportEnv() { | ||
| return { | ||
| mcpm: "0.29.0", | ||
| node: process.version, | ||
| platform: process.platform, | ||
| arch: process.arch, | ||
| osRelease: os.release(), | ||
| confineBackend: isConfineBackendAvailable(), | ||
| secretStore: isSupportedPlatform() ? "os-keychain" : "machine-key" | ||
| }; | ||
| } | ||
| function registerDoctorCommand(program) { | ||
| program.command("doctor").description("Check MCP setup health and report issues").option("--json", "emit the structured DoctorModel as JSON (shape UNSTABLE; NOT redacted \u2014 includes server names, use --report to share publicly)").option("--report", "emit a redacted, pasteable env snapshot for bug reports (no server names/args)").action(async (options) => { | ||
| const plain = options.json || options.report; | ||
| const deps = { | ||
| getAdapter, | ||
| getConfigPath, | ||
| checkConfigExists: checkConfigExistsDefault, | ||
| execCheck: execCheckDefault, | ||
| output: plain ? (t) => console.log(t) : coloredOutput | ||
| }; | ||
| const exitCode = await doctorHandler(deps, { json: options.json, report: options.report }); | ||
| process.exit(exitCode); | ||
| }); | ||
| } | ||
| export { | ||
| formatMcpEntryCommand, | ||
| collectClientStates, | ||
| buildDriftModel, | ||
| buildDoctorModel, | ||
| makeCheckConfigExists, | ||
| execCheckDefault, | ||
| registerDoctorCommand | ||
| }; | ||
| //# sourceMappingURL=chunk-RUSWATRY.js.map |
| {"version":3,"sources":["../src/utils/format-entry.ts","../src/commands/doctor.ts","../src/config/drift.ts","../src/scanner/config-secrets.ts"],"sourcesContent":["/**\n * Shared formatting helpers for McpServerEntry display.\n */\n\nimport type { McpServerEntry } from \"../config/adapters/index.js\";\n\n/**\n * Returns the display string for an MCP server entry's command/URL column.\n *\n * @param entry - The server entry to format.\n * @param fallback - String to return when neither url nor command is present.\n */\nexport function formatMcpEntryCommand(\n entry: McpServerEntry,\n fallback = \"\\u2014\"\n): string {\n if (entry.url) return entry.url;\n if (entry.command) {\n const args = entry.args?.join(\" \") ?? \"\";\n return args ? `${entry.command} ${args}` : entry.command;\n }\n return fallback;\n}\n","/**\n * `mcpm doctor` command handler.\n *\n * Checks MCP setup health and reports issues:\n * - Which AI clients have config files\n * - Whether config files are valid JSON\n * - Which runtimes (npx, uvx, docker) are available\n * - Whether installed servers reference available runtimes\n *\n * Returns 0 for no critical issues, 1 for critical issues.\n * All external dependencies are injected for testability.\n *\n * D7: the check logic is split into a pure `buildDoctorModel` (a structured\n * `DoctorModel`) and renderers. `--json` emits the model; `--report` emits a\n * redacted, name-free env snapshot for bug reports; the MCP-server `handleDoctor`\n * reuses the same model (fixing its formerly-hardcoded `issues: []`).\n */\n\nimport { access } from \"fs/promises\";\nimport type { ClientId } from \"../config/paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"../config/adapters/index.js\";\nimport type { getConfigPath } from \"../config/paths.js\";\nimport { buildDriftModel, type ClientState } from \"../config/drift.js\";\nimport { isWrapped } from \"../guard/wrap.js\";\nimport { scanConfigSecrets, type ConfigSecretFinding } from \"../scanner/config-secrets.js\";\nimport { sanitizeForTerminal } from \"../guard/sanitize.js\";\n\n// ---------------------------------------------------------------------------\n// Deps interface\n// ---------------------------------------------------------------------------\n\nexport interface DoctorDeps {\n getAdapter: (clientId: ClientId) => ConfigAdapter;\n getConfigPath: typeof getConfigPath;\n /** Returns true if the config file exists for this client. */\n checkConfigExists: (clientId: ClientId) => Promise<boolean>;\n /** Returns true if the given executable is available on PATH. */\n execCheck: (cmd: string) => Promise<boolean>;\n output: (text: string) => void;\n}\n\n/** The subset of deps the pure model builder needs (no output, no detector). */\nexport type DoctorModelDeps = Pick<\n DoctorDeps,\n \"getAdapter\" | \"getConfigPath\" | \"checkConfigExists\" | \"execCheck\"\n>;\n\n// ---------------------------------------------------------------------------\n// Structured model (D7 — one shape for text/json/report/MCP consumers)\n// ---------------------------------------------------------------------------\n\nexport interface DoctorClientHealth {\n id: ClientId;\n label: string;\n exists: boolean;\n malformed: boolean;\n serverCount: number;\n /** Servers wrapped by the guard relay (subset of serverCount). */\n guardedCount: number;\n}\n\nexport interface DoctorRuntimeHealth {\n name: Runtime;\n available: boolean;\n}\n\nexport interface DoctorDriftEntry {\n name: string;\n kind: \"conflict\" | \"absent\";\n present: string[];\n absent: string[];\n /** Present only for `kind: \"conflict\"`. */\n fields?: string[];\n}\n\nexport interface DoctorCrossClient {\n consistent: boolean;\n clientCount: number;\n serverCount: number;\n drift: DoctorDriftEntry[];\n}\n\nexport interface DoctorIssue {\n kind: \"malformed-config\" | \"missing-runtime\";\n message: string;\n}\n\nexport interface DoctorSecretFinding {\n client: ClientId;\n server: string;\n field: ConfigSecretFinding[\"field\"];\n /** The env var / header NAME — never the value (F9 redaction contract). */\n key: string;\n label: string;\n}\n\nexport interface DoctorModel {\n schemaVersion: 1;\n clients: DoctorClientHealth[];\n runtimes: DoctorRuntimeHealth[];\n /** Advisory cross-client consistency; null when <2 clients have a readable config. */\n crossClient: DoctorCrossClient | null;\n /** Plaintext secrets in client config — advisory (F9); does NOT affect `ok`/exit. */\n secrets: DoctorSecretFinding[];\n /** Critical issues — these drive the exit code. */\n issues: DoctorIssue[];\n /** true iff issues is empty. */\n ok: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst RUNTIMES = [\"npx\", \"uvx\", \"docker\"] as const;\n\ntype Runtime = (typeof RUNTIMES)[number];\n\nconst CLIENT_LABELS: Record<ClientId, string> = {\n \"claude-desktop\": \"Claude Desktop\",\n \"claude-code\": \"Claude Code\",\n cursor: \"Cursor\",\n vscode: \"VS Code\",\n windsurf: \"Windsurf\",\n \"gemini-cli\": \"Gemini CLI\",\n};\n\nconst RUNTIME_INSTALL_HINTS: Record<Runtime, string> = {\n npx: \"install Node.js from https://nodejs.org\",\n uvx: \"install uv from https://docs.astral.sh/uv/\",\n docker: \"install Docker from https://docs.docker.com/get-docker/\",\n};\n\n// ---------------------------------------------------------------------------\n// Model builder (pure — no output)\n// ---------------------------------------------------------------------------\n\ninterface ClientRead {\n exists: boolean;\n malformed: boolean;\n servers: Record<string, McpServerEntry> | null;\n}\n\n/**\n * Runs every health check and returns the structured model. No side effects\n * beyond the injected reads; safe to call from the CLI, `--json`, `--report`,\n * and the MCP `handleDoctor` tool.\n */\nexport async function buildDoctorModel(deps: DoctorModelDeps): Promise<DoctorModel> {\n const { getAdapter, getConfigPath, checkConfigExists, execCheck } = deps;\n\n // 1. Read each known client's config.\n const reads = await Promise.all(\n CLIENT_IDS.map(async (clientId): Promise<{ clientId: ClientId; read: ClientRead }> => {\n const exists = await checkConfigExists(clientId);\n if (!exists) return { clientId, read: { exists: false, malformed: false, servers: null } };\n try {\n const servers = await getAdapter(clientId).read(getConfigPath(clientId));\n return { clientId, read: { exists: true, malformed: false, servers } };\n } catch {\n return { clientId, read: { exists: true, malformed: true, servers: null } };\n }\n })\n );\n\n const issues: DoctorIssue[] = [];\n\n const clients: DoctorClientHealth[] = reads.map(({ clientId, read }) => {\n const label = CLIENT_LABELS[clientId];\n if (read.malformed) {\n issues.push({\n kind: \"malformed-config\",\n message: `Config file for ${label} is malformed — fix the JSON syntax.`,\n });\n }\n const servers = read.servers ?? {};\n const entries = Object.values(servers);\n return {\n id: clientId,\n label,\n exists: read.exists,\n malformed: read.malformed,\n serverCount: entries.length,\n guardedCount: entries.filter(isWrapped).length,\n };\n });\n\n // 2. Runtime availability.\n const runtimes: DoctorRuntimeHealth[] = await Promise.all(\n RUNTIMES.map(async (name) => ({ name, available: await execCheck(name) }))\n );\n const runtimeAvailable = new Map(runtimes.map((r) => [r.name as string, r.available]));\n\n // 3. Cross-check: servers whose command is a tracked-but-unavailable runtime.\n for (const { clientId, read } of reads) {\n if (!read.servers) continue;\n for (const [serverName, entry] of Object.entries(read.servers)) {\n const cmd = entry.command;\n if (!cmd) continue; // HTTP/URL server — no runtime needed.\n if (RUNTIMES.includes(cmd as Runtime) && runtimeAvailable.get(cmd) === false) {\n issues.push({\n kind: \"missing-runtime\",\n message: `Server '${serverName}' in ${CLIENT_LABELS[clientId]} uses '${cmd}' but ${cmd} is not installed.`,\n });\n }\n }\n }\n\n // 4. Cross-client consistency (advisory — never an issue, never fails doctor).\n const driftStates: ClientState[] = reads.flatMap(({ clientId, read }) =>\n read.servers ? [{ clientId, servers: read.servers }] : []\n );\n const crossClient = driftStates.length >= 2 ? toCrossClient(driftStates) : null;\n\n // 5. Plaintext-secret scan (advisory — never an issue, never fails doctor).\n const secrets: DoctorSecretFinding[] = reads.flatMap(({ clientId, read }) =>\n read.servers ? scanConfigSecrets(read.servers).map((f) => ({ client: clientId, ...f })) : []\n );\n\n return {\n schemaVersion: 1,\n clients,\n runtimes,\n crossClient,\n secrets,\n issues,\n ok: issues.length === 0,\n };\n}\n\nfunction toCrossClient(states: ClientState[]): DoctorCrossClient {\n const drift = buildDriftModel(states);\n const entries: DoctorDriftEntry[] = [];\n for (const server of drift.servers) {\n // buildDriftModel returns readonly arrays — copy into the mutable public model.\n if (server.conflict) {\n entries.push({\n name: server.name,\n kind: \"conflict\",\n present: [...server.present],\n absent: [...server.absent],\n fields: server.conflictFields ? [...server.conflictFields] : undefined,\n });\n } else if (server.absent.length > 0) {\n entries.push({\n name: server.name,\n kind: \"absent\",\n present: [...server.present],\n absent: [...server.absent],\n });\n }\n }\n return {\n consistent: drift.drifted === 0,\n clientCount: drift.clients.length,\n serverCount: drift.servers.length,\n drift: entries,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Human-readable renderer (byte-identical to the pre-D7 output)\n// ---------------------------------------------------------------------------\n\nexport function renderDoctorText(model: DoctorModel, output: (text: string) => void): void {\n output(\"\");\n output(\"mcpm doctor\");\n output(\"\");\n\n for (const c of model.clients) {\n if (!c.exists) {\n output(` ✗ ${c.label} — config not found`);\n } else if (c.malformed) {\n output(` ✗ ${c.label} — config malformed (JSON parse error)`);\n } else {\n const word = c.serverCount === 1 ? \"server\" : \"servers\";\n output(` ✓ ${c.label} — config found, ${c.serverCount} ${word}`);\n }\n }\n\n output(\"\");\n output(\"Runtimes:\");\n for (const r of model.runtimes) {\n if (r.available) {\n output(` ✓ ${r.name} available`);\n } else {\n output(` ✗ ${r.name} not found — ${RUNTIME_INSTALL_HINTS[r.name]}`);\n }\n }\n\n if (model.crossClient) {\n const cc = model.crossClient;\n output(\"\");\n output(\"Cross-client (advisory):\");\n if (cc.consistent) {\n const word = cc.serverCount === 1 ? \"server\" : \"servers\";\n output(` ✓ ${cc.serverCount} ${word} consistent across ${cc.clientCount} clients`);\n } else {\n for (const d of cc.drift) {\n if (d.kind === \"conflict\") {\n output(` ⚠ ${d.name} — config differs (${d.fields!.join(\", \")}) across ${d.present.join(\", \")}`);\n } else {\n output(` ⚠ ${d.name} — in ${d.present.join(\", \")}; missing in ${d.absent.join(\", \")}`);\n }\n }\n output(\" Run `mcpm sync --check` for the full matrix (advisory, not a failure).\");\n }\n }\n\n if (model.secrets.length > 0) {\n output(\"\");\n output(\"Plaintext secrets (advisory):\");\n for (const s of model.secrets) {\n // s.server / s.key are attacker-influenceable (registry env-var names, imported\n // configs) — strip ANSI/OSC so a crafted key can't erase or spoof the advisory.\n output(\n ` ⚠ ${s.client} · ${sanitizeForTerminal(s.server)} · ${s.field} '${sanitizeForTerminal(s.key)}' — ${s.label}`\n );\n }\n // Remediation is field-specific: the keychain/placeholder path is env-only\n // (guard resolves placeholders in env, not headers; HTTP servers aren't wrapped).\n if (model.secrets.some((s) => s.field === \"env\")) {\n output(\n \" Move env secrets to the encrypted store: `mcpm secrets set <server> <KEY>` or re-install with `--secrets keychain`.\"\n );\n }\n if (model.secrets.some((s) => s.field === \"header\")) {\n output(\n \" Header secrets have no keychain path yet — rotate the credential and keep it out of committed config.\"\n );\n }\n }\n\n if (model.issues.length > 0) {\n output(\"\");\n output(\"Issues:\");\n for (const issue of model.issues) {\n output(` ⚠ ${issue.message}`);\n }\n output(\"\");\n output(\"Critical issues found. Run the commands above to resolve them.\");\n return;\n }\n\n output(\"\");\n output(\"No critical issues found.\");\n}\n\n// ---------------------------------------------------------------------------\n// Redacted report (D7 — pasteable env snapshot, NO server names/args)\n// ---------------------------------------------------------------------------\n\nexport interface DoctorReportEnv {\n mcpm: string;\n node: string;\n platform: string;\n arch: string;\n osRelease: string;\n confineBackend: boolean;\n secretStore: \"os-keychain\" | \"machine-key\";\n}\n\nexport interface DoctorReport {\n schemaVersion: 1;\n mcpm: string;\n node: string;\n os: string;\n confineBackend: boolean;\n secretStore: \"os-keychain\" | \"machine-key\";\n clients: Array<Omit<DoctorClientHealth, \"label\">>;\n runtimes: DoctorRuntimeHealth[];\n /** Counts only — issue messages + secret keys embed server names, so NOT included. */\n issues: { malformedConfigs: number; missingRuntime: number; plaintextSecrets: number };\n}\n\nexport function buildDoctorReport(model: DoctorModel, env: DoctorReportEnv): DoctorReport {\n return {\n schemaVersion: 1,\n mcpm: env.mcpm,\n node: env.node,\n os: `${env.platform} ${env.arch} ${env.osRelease}`,\n confineBackend: env.confineBackend,\n secretStore: env.secretStore,\n // Redaction: drop the label + every server name; keep only counts.\n clients: model.clients.map(({ id, exists, malformed, serverCount, guardedCount }) => ({\n id,\n exists,\n malformed,\n serverCount,\n guardedCount,\n })),\n runtimes: model.runtimes,\n issues: {\n malformedConfigs: model.issues.filter((i) => i.kind === \"malformed-config\").length,\n missingRuntime: model.issues.filter((i) => i.kind === \"missing-runtime\").length,\n plaintextSecrets: model.secrets.length,\n },\n };\n}\n\nexport function renderReportText(r: DoctorReport): string {\n const lines: string[] = [];\n lines.push(\"mcpm doctor --report (redacted — no server names or args)\");\n lines.push(`mcpm: ${r.mcpm}`);\n lines.push(`node: ${r.node}`);\n lines.push(`os: ${r.os}`);\n lines.push(`confine backend: ${r.confineBackend ? \"available\" : \"unavailable\"}`);\n lines.push(`secret store: ${r.secretStore}`);\n lines.push(\"\");\n lines.push(\"clients:\");\n for (const c of r.clients) {\n if (!c.exists) {\n lines.push(` ${c.id}: not found`);\n } else if (c.malformed) {\n lines.push(` ${c.id}: config malformed`);\n } else {\n const guarded = c.guardedCount > 0 ? `, ${c.guardedCount} guarded` : \"\";\n lines.push(` ${c.id}: ${c.serverCount} servers${guarded}`);\n }\n }\n lines.push(\"runtimes:\");\n for (const rt of r.runtimes) {\n lines.push(` ${rt.name}: ${rt.available ? \"available\" : \"missing\"}`);\n }\n lines.push(\n `issues: ${r.issues.malformedConfigs} malformed config(s), ${r.issues.missingRuntime} missing-runtime, ${r.issues.plaintextSecrets} plaintext secret(s)`\n );\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Handler\n// ---------------------------------------------------------------------------\n\nexport interface DoctorOpts {\n json?: boolean;\n report?: boolean;\n /** Injected in --report mode; the Commander action supplies the real env. */\n reportEnv?: DoctorReportEnv;\n}\n\n/**\n * Core logic for `mcpm doctor`.\n * @returns Exit code: 0 = healthy, 1 = critical issues found.\n */\nexport async function doctorHandler(deps: DoctorDeps, opts: DoctorOpts = {}): Promise<number> {\n const model = await buildDoctorModel(deps);\n\n if (opts.report) {\n const env = opts.reportEnv ?? gatherReportEnv();\n deps.output(renderReportText(buildDoctorReport(model, env)));\n } else if (opts.json) {\n deps.output(JSON.stringify(model, null, 2));\n } else {\n renderDoctorText(model, deps.output);\n }\n\n return model.ok ? 0 : 1;\n}\n\n// ---------------------------------------------------------------------------\n// Commander registration\n// ---------------------------------------------------------------------------\n\nimport { Command } from \"commander\";\nimport os from \"os\";\nimport { execFile } from \"child_process\";\nimport { getConfigPath as _getConfigPath, CLIENT_IDS } from \"../config/paths.js\";\nimport { getAdapter as getAdapterDefault } from \"../config/index.js\";\nimport { coloredOutput } from \"../utils/output.js\";\nimport { isConfineBackendAvailable } from \"../guard/confine/apply.js\";\nimport { isSupportedPlatform as isKeychainSupported } from \"../store/os-keychain.js\";\n\n/** Factory so callers that inject a custom getConfigPath (e.g. the MCP server) get honored. */\nexport function makeCheckConfigExists(\n getConfigPathFn: (clientId: ClientId) => string\n): (clientId: ClientId) => Promise<boolean> {\n return async (clientId: ClientId): Promise<boolean> => {\n try {\n await access(getConfigPathFn(clientId));\n return true;\n } catch {\n return false;\n }\n };\n}\n\nconst checkConfigExistsDefault = makeCheckConfigExists(_getConfigPath);\n\nconst ALLOWED_RUNTIME_CMDS = new Set<string>([\"npx\", \"uvx\", \"docker\"]);\n\nexport function execCheckDefault(cmd: string): Promise<boolean> {\n if (!ALLOWED_RUNTIME_CMDS.has(cmd)) return Promise.resolve(false);\n return new Promise((resolve) => {\n const which = process.platform === \"win32\" ? \"where\" : \"which\";\n execFile(which, [cmd], (err) => {\n resolve(err === null);\n });\n });\n}\n\n/** Gathers the impure environment fields for `--report`. */\nfunction gatherReportEnv(): DoctorReportEnv {\n return {\n mcpm: __PKG_VERSION__,\n node: process.version,\n platform: process.platform,\n arch: process.arch,\n osRelease: os.release(),\n confineBackend: isConfineBackendAvailable(),\n secretStore: isKeychainSupported() ? \"os-keychain\" : \"machine-key\",\n };\n}\n\nexport function registerDoctorCommand(program: Command): void {\n program\n .command(\"doctor\")\n .description(\"Check MCP setup health and report issues\")\n .option(\"--json\", \"emit the structured DoctorModel as JSON (shape UNSTABLE; NOT redacted — includes server names, use --report to share publicly)\")\n .option(\"--report\", \"emit a redacted, pasteable env snapshot for bug reports (no server names/args)\")\n .action(async (options: { json?: boolean; report?: boolean }) => {\n // --json / --report are machine/paste output — never colorize.\n const plain = options.json || options.report;\n const deps: DoctorDeps = {\n getAdapter: getAdapterDefault,\n getConfigPath: _getConfigPath,\n checkConfigExists: checkConfigExistsDefault,\n execCheck: execCheckDefault,\n output: plain ? (t) => console.log(t) : coloredOutput,\n };\n\n const exitCode = await doctorHandler(deps, { json: options.json, report: options.report });\n process.exit(exitCode);\n });\n}\n","/**\n * Cross-client config-drift model (pure, injectable).\n *\n * `mcpm diff` answers \"installed vs declared stack\" in ONE direction. This module\n * answers the symmetric N-client question: for every server name, which clients\n * have it, which are missing it, and do the clients that DO have it agree on the\n * server's shape? It is the shared core behind `mcpm sync --check` and the doctor\n * \"Cross-client\" section.\n *\n * Design notes:\n * - Read-only. No writes, no registry/lock/network — it only reads client configs\n * (the collect loop mirrors diff.ts:76-93 / export.ts).\n * - `buildDriftModel` is pure and takes already-collected `ClientState[]` so the\n * doctor command can feed it the reads it already did (no double I/O).\n * - Conflict comparison is over command + ordered args + env KEY set + url +\n * header KEY set. It NEVER compares env / header VALUES — those are secrets, and\n * two clients legitimately hold the same key with a per-machine value.\n *\n * Exports: DriftDeps, ClientState, ServerDrift, DriftModel, collectClientStates,\n * buildDriftModel.\n */\n\nimport type { ClientId } from \"./paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"./adapters/index.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface DriftDeps {\n detectClients: () => Promise<ClientId[]>;\n getAdapter: (clientId: ClientId) => Pick<ConfigAdapter, \"read\">;\n getPath: (clientId: ClientId) => string;\n}\n\n/** A single client's full set of MCP server entries (one successful read). */\nexport interface ClientState {\n readonly clientId: ClientId;\n readonly servers: Record<string, McpServerEntry>;\n}\n\nexport interface ServerDrift {\n readonly name: string;\n /** Clients (with readable configs) that declare this server. */\n readonly present: readonly ClientId[];\n /** Clients (with readable configs) that lack this server. */\n readonly absent: readonly ClientId[];\n /** True when the `present` clients disagree on the server's shape. */\n readonly conflict: boolean;\n /** Which fields diverge among the `present` clients (only when conflict). */\n readonly conflictFields?: readonly string[];\n}\n\nexport interface DriftModel {\n /** Clients considered — those whose config was readable. Sorted. */\n readonly clients: readonly ClientId[];\n /** One entry per distinct server name, sorted by name. */\n readonly servers: readonly ServerDrift[];\n /** Servers present in every considered client with no shape conflict. */\n readonly inSync: number;\n /** Servers with at least one absence or a shape conflict. */\n readonly drifted: number;\n}\n\n// ---------------------------------------------------------------------------\n// Collection (I/O)\n// ---------------------------------------------------------------------------\n\n/**\n * Read each detected client's config into a `ClientState`. Clients whose config\n * is unreadable (missing / malformed) are skipped — never throws — so a single\n * broken config can't blind the whole cross-client view (same posture as\n * `diff` / `export`).\n */\nexport async function collectClientStates(deps: DriftDeps): Promise<ClientState[]> {\n const clients = await deps.detectClients();\n const states: ClientState[] = [];\n for (const clientId of clients) {\n try {\n const servers = await deps.getAdapter(clientId).read(deps.getPath(clientId));\n states.push({ clientId, servers });\n } catch {\n // Skip unreadable clients (missing or malformed config).\n }\n }\n return states;\n}\n\n// ---------------------------------------------------------------------------\n// Drift model (pure)\n// ---------------------------------------------------------------------------\n\n/**\n * Per-field canonical projection used for conflict detection. Each value is a\n * stable string; two entries conflict on a field iff their projected strings\n * differ. Deliberately excludes env / header VALUES (secrets) and the per-client\n * `disabled` flag (an intentional per-client toggle, not a definition drift).\n */\nfunction fieldProjection(entry: McpServerEntry): Record<string, string> {\n return {\n command: entry.command ?? \"\",\n args: JSON.stringify(entry.args ?? []),\n \"env keys\": JSON.stringify(Object.keys(entry.env ?? {}).sort()),\n url: entry.url ?? \"\",\n \"header keys\": JSON.stringify(Object.keys(entry.headers ?? {}).sort()),\n };\n}\n\nconst COMPARED_FIELDS = [\"command\", \"args\", \"env keys\", \"url\", \"header keys\"] as const;\n\n/** Fields on which the given entries (≥1) disagree. Empty ⇒ all identical. */\nfunction divergingFields(entries: readonly McpServerEntry[]): string[] {\n const projections = entries.map(fieldProjection);\n return COMPARED_FIELDS.filter((field) => {\n const distinct = new Set(projections.map((p) => p[field]));\n return distinct.size > 1;\n });\n}\n\nexport function buildDriftModel(states: readonly ClientState[]): DriftModel {\n const clients = states.map((s) => s.clientId).sort();\n\n // Gather, per server name, the clients that declare it and their entries.\n const byName = new Map<string, Array<{ clientId: ClientId; entry: McpServerEntry }>>();\n for (const { clientId, servers } of states) {\n for (const [name, entry] of Object.entries(servers)) {\n const list = byName.get(name) ?? [];\n list.push({ clientId, entry });\n byName.set(name, list);\n }\n }\n\n const servers: ServerDrift[] = [];\n for (const name of [...byName.keys()].sort()) {\n const holders = byName.get(name)!;\n const present = holders.map((h) => h.clientId).sort();\n const presentSet = new Set(present);\n const absent = clients.filter((c) => !presentSet.has(c));\n\n const fields = holders.length > 1 ? divergingFields(holders.map((h) => h.entry)) : [];\n const conflict = fields.length > 0;\n\n servers.push({\n name,\n present,\n absent,\n conflict,\n ...(conflict ? { conflictFields: fields } : {}),\n });\n }\n\n const drifted = servers.filter((s) => s.absent.length > 0 || s.conflict).length;\n return { clients, servers, inSync: servers.length - drifted, drifted };\n}\n","/**\n * Plaintext-secret scan over client MCP config (F9 · PR1).\n *\n * mcpm ships an encrypted secret store + OS keychain, but a server's env/header\n * values are routinely pasted in plaintext (24k+ such leaks documented in the\n * wild). This read-only scan flags them so `doctor` can nudge the user toward\n * `mcpm secrets` / keychain mode.\n *\n * REDACTION CONTRACT: a finding carries the KEY name and a LABEL only — NEVER the\n * matched value. Values already stored as `mcpm:keychain:` placeholders are\n * skipped (they are the safe state, not a leak).\n *\n * Two detectors:\n * 1. value-shape — the sweep-hardened `detectSecretLabels` patterns (AWS /\n * GitHub / OpenAI / … keys). Near-zero false positives.\n * 2. secret-named key — a tight key-name heuristic for generic passwords/tokens\n * no value-regex matches, gated by strong non-secret-qualifier (URL/ID/NAME/…)\n * and non-secret-value (reference/URL/path/flag) exclusions + a benign corpus.\n *\n * Pure: no I/O. The caller (doctor) supplies the already-read config.\n */\n\nimport type { McpServerEntry } from \"../config/adapters/index.js\";\nimport { detectSecretLabels } from \"./patterns.js\";\nimport { parsePlaceholder } from \"../store/keychain.js\";\n\nexport interface ConfigSecretFinding {\n /** Server name as it appears in the client config. */\n server: string;\n /** Which value map the secret sits in. */\n field: \"env\" | \"header\";\n /** The env var / header NAME. Never the value. */\n key: string;\n /** What was matched (e.g. \"AWS access key\"). Never the value. */\n label: string;\n}\n\n/** Label for a key-heuristic hit (detector 2). Value-free by construction. */\nconst GENERIC_LABEL = \"secret-named key holds a plaintext value\";\n\n// Secret-indicating whole words. Matched against the key normalized to\n// upper-case with '-'→'_' (so `X-API-Key` reads as `X_API_KEY`). Bare `KEY` is\n// deliberately NOT a word (PUBLIC_KEY / KEY_ID / SORT_KEY are not secrets) — only\n// the listed `*_KEY` compounds count.\nconst SECRET_KEY_RE =\n /(?:^|_)(?:PASSWORD|PASSWD|PASSPHRASE|SECRET|TOKEN|PAT|APIKEY|AUTHORIZATION|CREDENTIALS?|(?:API|ACCESS|PRIVATE|SECRET|SESSION|SIGNING|ENCRYPTION)_KEY)(?:_|$)/;\n\n// Tokens that mean the field is a descriptor of a secret, not the secret itself\n// (an id, url, name, endpoint, …). Any one vetoes a key-name match, so\n// `TOKEN_URL` / `AWS_ACCESS_KEY_ID` / `SECRET_NAME` / `PUBLIC_KEY` do not fire.\n// KNOWN GAP (advisory tool, accepted): the veto matches a qualifier ANYWHERE in the\n// key, so `ID_TOKEN` (where `ID` is the credential TYPE, not a descriptor) is missed.\n// A suffix-anchored fix would newly false-POSITIVE on `MAPBOX_PUBLIC_TOKEN`; since a\n// false negative in an advisory scan is acceptable but a false positive is not, we\n// keep the anywhere-match.\nconst NON_SECRET_QUALIFIER_RE =\n /(?:^|_)(?:URL|URI|ENDPOINT|HOST|PORT|ID|NAME|PATH|FILE|DIR|ENABLED|DISABLED|TYPE|MODE|REGION|TIMEOUT|VERSION|PUBLIC|FORMAT|HEADER|PREFIX|SUFFIX|COUNT|SIZE|TTL|EXPIRY|EXPIRES|ISSUER|AUDIENCE|ALGORITHM|ALG|SCOPE|METHOD)(?:_|$)/;\n\nfunction normalizeKey(key: string): string {\n return key.toUpperCase().replace(/-/g, \"_\");\n}\n\nfunction keyLooksSecret(key: string): boolean {\n const k = normalizeKey(key);\n return SECRET_KEY_RE.test(k) && !NON_SECRET_QUALIFIER_RE.test(k);\n}\n\n/** True when the value is plausibly a real plaintext secret (not a ref/URL/flag). */\nfunction valueLooksPlaintextSecret(value: string): boolean {\n const v = value.trim();\n if (v.length < 6) return false; // too short to be a credential\n if (parsePlaceholder(value) !== null) return false; // mcpm keychain placeholder\n // Reference, not a literal secret. `${...}` is matched ANYWHERE (not just leading):\n // `Bearer ${input:key}` / `Bearer ${env:VAR}` is VS Code / Cursor / Claude Code's\n // documented header idiom — the recommended SAFE state. Detector 1 already ran on\n // the raw value, so a shaped credential embedded alongside a ref is still caught.\n if (/\\$\\{[^}]*\\}/.test(v)) return false; // ${VAR} template (embedded or leading)\n if (/^\\$[A-Za-z_]/.test(v)) return false; // leading $VAR reference\n if (/^%[A-Za-z_][A-Za-z0-9_]*%([\\\\/].*)?$/.test(v)) return false; // %VAR% ref or %VAR%-rooted path\n // A URI of ANY scheme: real endpoints AND secret-manager references that are the\n // safe state — op:// (1Password), vault:// (Vault). ACCEPTED FALSE-NEGATIVE: a URI\n // that itself CARRIES a credential (connection-string userinfo postgres://u:p@host,\n // or a query-param secret like otpauth://…?secret=SEED) is excluded too. Detector 1\n // still catches any prefix-shaped credential embedded in the value, and the bare\n // (non-URI) secret form is still caught by detector 2. Zero-FP is the hard invariant;\n // re-catching these would need query-param parsing that risks FPs on real endpoints.\n if (/^[a-z][a-z0-9+.-]*:\\/\\//i.test(v)) return false;\n // Filesystem path — POSIX (~ . /) or Windows (drive-letter, UNC).\n if (/^[~./]/.test(v) || /^[A-Za-z]:[\\\\/]/.test(v) || /^\\\\\\\\/.test(v)) return false;\n if (/^(true|false|\\d+)$/i.test(v)) return false; // boolean / plain number\n return true;\n}\n\nfunction scanMap(\n server: string,\n field: \"env\" | \"header\",\n map: Record<string, string> | undefined\n): ConfigSecretFinding[] {\n if (!map) return [];\n const out: ConfigSecretFinding[] = [];\n for (const [key, value] of Object.entries(map)) {\n if (typeof value !== \"string\") continue;\n if (parsePlaceholder(value) !== null) continue; // already stored safely — not a leak\n const labels = detectSecretLabels(value);\n if (labels.length > 0) {\n // Value-shape is the more specific, higher-confidence signal — ONE finding per\n // (field, key) even when several patterns match (e.g. a Bearer-wrapped ghp_\n // token hits both), so the --report count is not inflated. Skip the heuristic.\n out.push({ server, field, key, label: labels.join(\", \") });\n continue;\n }\n if (keyLooksSecret(key) && valueLooksPlaintextSecret(value)) {\n out.push({ server, field, key, label: GENERIC_LABEL });\n }\n }\n return out;\n}\n\n/** Scan one server's env + headers for plaintext secrets. */\nexport function scanServerConfigSecrets(\n server: string,\n entry: McpServerEntry\n): ConfigSecretFinding[] {\n return [...scanMap(server, \"env\", entry.env), ...scanMap(server, \"header\", entry.headers)];\n}\n\n/** Scan every server in a client's config. */\nexport function scanConfigSecrets(\n servers: Record<string, McpServerEntry>\n): ConfigSecretFinding[] {\n return Object.entries(servers).flatMap(([name, entry]) => scanServerConfigSecrets(name, entry));\n}\n"],"mappings":"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","names":["servers","getAdapter","getConfigPath"]} |
| #!/usr/bin/env node | ||
| import { | ||
| handleLock, | ||
| lockPathFor | ||
| } from "./chunk-C6CAHFQX.js"; | ||
| import { | ||
| parseSecretsMode, | ||
| resolveInstallEntry, | ||
| validateRemoteUrl | ||
| } from "./chunk-7QRDQF55.js"; | ||
| import { | ||
| readPins | ||
| } from "./chunk-DDCTUMSZ.js"; | ||
| import { | ||
| DEFAULT_MIN_RELEASE_AGE_HOURS, | ||
| assessReleaseAge, | ||
| stdoutOutput | ||
| } from "./chunk-E3T224S3.js"; | ||
| import { | ||
| fetchNpmProvenance, | ||
| isEnoent, | ||
| isLockedRegistryServer, | ||
| isRegistryServer, | ||
| isUrlServer, | ||
| parseLockFile, | ||
| parseStackFile | ||
| } from "./chunk-RTI2GLYX.js"; | ||
| import { | ||
| checkScannerAvailable, | ||
| scanTier2 | ||
| } from "./chunk-F6CHEUGO.js"; | ||
| import { | ||
| EXTERNAL_SCAN_MAX, | ||
| computeTrustScore, | ||
| nativeTrustScore | ||
| } from "./chunk-LSNEZAFR.js"; | ||
| import { | ||
| getAdapter | ||
| } from "./chunk-W4IAFBUN.js"; | ||
| import { | ||
| confirm | ||
| } from "./chunk-2PWW3Q5Q.js"; | ||
| import { | ||
| isNewUnguarded | ||
| } from "./chunk-MLVDFLDQ.js"; | ||
| import { | ||
| compareIntegrity, | ||
| fetchNpmIntegrity | ||
| } from "./chunk-7RJXJERN.js"; | ||
| import { | ||
| RegistryClient | ||
| } from "./chunk-V4AA4ZL5.js"; | ||
| import { | ||
| applyKeychainSecrets, | ||
| setSecrets | ||
| } from "./chunk-GZ3WCRLG.js"; | ||
| import { | ||
| detectInstalledClients | ||
| } from "./chunk-6R7TL5O2.js"; | ||
| import { | ||
| getConfigPath | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import { | ||
| assessServerStatus, | ||
| extractRegistryMeta, | ||
| scanTier1 | ||
| } from "./chunk-U7N6FRYF.js"; | ||
| // src/stack/policy.ts | ||
| function checkTrustPolicy(input2) { | ||
| const { serverName, currentScore, currentMaxPossible, lockedSnapshot, policy } = input2; | ||
| if (policy === void 0) { | ||
| return { pass: true }; | ||
| } | ||
| const currentPct = toPct(currentScore, currentMaxPossible); | ||
| if (policy.minTrustScore !== void 0 && currentPct < policy.minTrustScore) { | ||
| return { | ||
| pass: false, | ||
| reason: `"${serverName}" trust score ${currentPct}% is below the minimum policy threshold of ${policy.minTrustScore}%.` | ||
| }; | ||
| } | ||
| if (policy.blockOnScoreDrop === true && lockedSnapshot !== void 0) { | ||
| const { currentNativeScore, currentNativeMaxPossible } = input2; | ||
| if (currentNativeScore === void 0 || currentNativeMaxPossible === void 0) { | ||
| throw new Error( | ||
| "blockOnScoreDrop requires the current native trust figures (currentNativeScore / currentNativeMaxPossible). This is a bug \u2014 report it rather than working around it." | ||
| ); | ||
| } | ||
| const lockedNative = recoverLockedNative( | ||
| lockedSnapshot, | ||
| currentNativeMaxPossible | ||
| ); | ||
| const curPct = toPct(currentNativeScore, currentNativeMaxPossible); | ||
| const lockPct = toPct(lockedNative.score, lockedNative.maxPossible); | ||
| if (curPct < lockPct) { | ||
| return { | ||
| pass: false, | ||
| reason: `"${serverName}" trust score dropped from ${lockPct}% to ${curPct}% (mcpm-native evidence, excluding unverifiable external-scanner credit) since the lock file was created.` + (lockedNative.basis === "legacy-bound" ? ` This lock predates native-evidence drop checks and was written with an external scanner credited, so the baseline is an upper bound \u2014 re-run \`mcpm lock\` to record an exact one.` : lockedNative.basis === "out-of-range" ? ( | ||
| // Deliberately NOT phrased as tampering: an upward re-weighting of the | ||
| // external bucket would make that accusation false for an older mcpm | ||
| // reading a newer lock. Re-locking is the remedy either way. | ||
| ` This lock records trust figures outside the range this version can interpret, so the baseline is an upper bound \u2014 re-run \`mcpm lock\` to record an exact one.` | ||
| ) : ` If you recently upgraded mcpm, new scanner findings can lower scores \u2014 re-run \`mcpm lock\` to refresh snapshots if the drop is expected.`) | ||
| }; | ||
| } | ||
| } | ||
| if (policy.minReleaseAgeHours !== void 0 && input2.releaseAge?.blocksArmedGate === true) { | ||
| const { ageHours, status } = input2.releaseAge; | ||
| if (status === "future") { | ||
| return { | ||
| pass: false, | ||
| reason: `"${serverName}" has a publish timestamp in the future; treated as within the minimum release age of ${policy.minReleaseAgeHours} hour(s) required by policy.` | ||
| }; | ||
| } | ||
| if (ageHours === null) { | ||
| return { | ||
| pass: false, | ||
| reason: `"${serverName}" release is of unverifiable age (publish timestamp ${status === "absent" ? "missing from registry metadata" : "could not be parsed"}), and the policy requires a minimum release age of ${policy.minReleaseAgeHours} hour(s).` | ||
| }; | ||
| } | ||
| return { | ||
| pass: false, | ||
| reason: `"${serverName}" release is ${ageHours} hour(s) old, below the minimum release age of ${policy.minReleaseAgeHours} hour(s) required by policy.` | ||
| }; | ||
| } | ||
| if (policy.blockInstallScripts === true && input2.hasInstallScriptFindings === true) { | ||
| return { | ||
| pass: false, | ||
| reason: `"${serverName}" resolves to a launcher that runs install scripts, and the policy blocks install scripts.` | ||
| }; | ||
| } | ||
| return { pass: true }; | ||
| } | ||
| function toPct(score, maxPossible) { | ||
| if (maxPossible <= 0) return 0; | ||
| return Math.round(score / maxPossible * 100); | ||
| } | ||
| function isUsableCredit(credit, locked, nativeMax) { | ||
| if (locked.maxPossible === nativeMax && credit > 0) return false; | ||
| return credit >= 0 && credit <= EXTERNAL_SCAN_MAX && credit <= locked.score; | ||
| } | ||
| function recoverLockedNative(locked, nativeMax) { | ||
| const bounded = (score, basis) => { | ||
| const clamped = Math.max(0, Math.min(nativeMax, score)); | ||
| return { | ||
| score: clamped, | ||
| maxPossible: nativeMax, | ||
| // A computation that had to be clamped was not exact, whatever produced it — an | ||
| // out-of-range `score` reaches here the same way an out-of-range credit does, | ||
| // since both are unbounded `z.number()`. Reporting it as exact would hand the | ||
| // user the "you upgraded mcpm" remedy for a lock that actually needs re-locking. | ||
| basis: basis === "exact" && clamped !== score ? "out-of-range" : basis | ||
| }; | ||
| }; | ||
| if (locked.externalScanCredit !== void 0) { | ||
| return isUsableCredit(locked.externalScanCredit, locked, nativeMax) ? bounded(locked.score - locked.externalScanCredit, "exact") : bounded(locked.score, "out-of-range"); | ||
| } | ||
| if (locked.maxPossible === nativeMax) { | ||
| return bounded(locked.score, "exact"); | ||
| } | ||
| return bounded(locked.score, "legacy-bound"); | ||
| } | ||
| // src/stack/env.ts | ||
| import { readFile } from "fs/promises"; | ||
| var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; | ||
| var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "__defineGetter__", "__defineSetter__"]); | ||
| async function parseEnvFile(filePath) { | ||
| let raw; | ||
| try { | ||
| raw = await readFile(filePath, "utf-8"); | ||
| } catch (err) { | ||
| if (isEnoent(err)) { | ||
| return { vars: {}, warnings: [] }; | ||
| } | ||
| throw err; | ||
| } | ||
| return parseEnvString(raw); | ||
| } | ||
| function parseEnvString(content) { | ||
| const vars = {}; | ||
| const warnings = []; | ||
| const lines = content.split("\n"); | ||
| for (let i = 0; i < lines.length; i++) { | ||
| const lineNum = i + 1; | ||
| const raw = lines[i]; | ||
| const trimmed = raw.trim(); | ||
| if (trimmed === "" || trimmed.startsWith("#")) { | ||
| continue; | ||
| } | ||
| const eqIndex = trimmed.indexOf("="); | ||
| if (eqIndex === -1) { | ||
| warnings.push(`Line ${lineNum}: skipped malformed line (no = sign)`); | ||
| continue; | ||
| } | ||
| const key = trimmed.slice(0, eqIndex).trim(); | ||
| if (key === "") { | ||
| warnings.push(`Line ${lineNum}: skipped line with empty key`); | ||
| continue; | ||
| } | ||
| if (!ENV_KEY_RE.test(key) || UNSAFE_KEYS.has(key)) { | ||
| warnings.push( | ||
| `Line ${lineNum}: skipped invalid key "${key}"` | ||
| ); | ||
| continue; | ||
| } | ||
| let value = trimmed.slice(eqIndex + 1).trim(); | ||
| if (!value.startsWith('"') && !value.startsWith("'")) { | ||
| const commentIndex = value.indexOf(" #"); | ||
| if (commentIndex !== -1) { | ||
| value = value.slice(0, commentIndex).trim(); | ||
| } | ||
| } | ||
| if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { | ||
| value = value.slice(1, -1); | ||
| } | ||
| vars[key] = value; | ||
| } | ||
| return { vars, warnings }; | ||
| } | ||
| // src/stack/frozen-verify.ts | ||
| function memoizeIntegrity(fetch) { | ||
| const cache = /* @__PURE__ */ new Map(); | ||
| return (identifier, npmVersion) => { | ||
| const key = `${identifier}\0${npmVersion}`; | ||
| let p = cache.get(key); | ||
| if (p === void 0) { | ||
| p = fetch(identifier, npmVersion); | ||
| cache.set(key, p); | ||
| } | ||
| return p; | ||
| }; | ||
| } | ||
| async function classifyIntegrity(lockFile, fetchNpmIntegrity2) { | ||
| const registryEntries = Object.entries(lockFile.servers).filter( | ||
| ([, locked]) => isLockedRegistryServer(locked) | ||
| ); | ||
| const npmEntries = registryEntries.filter(([, l]) => l.registryType === "npm"); | ||
| const npmNames = new Set(npmEntries.map(([name]) => name)); | ||
| const unenforceable = Object.keys(lockFile.servers).filter((name) => !npmNames.has(name)); | ||
| const checkable = npmEntries.filter(([, l]) => l.npmIntegrity !== void 0); | ||
| const absentBaseline = npmEntries.filter(([, l]) => l.npmIntegrity === void 0).map(([name]) => name); | ||
| const fresh = await Promise.all( | ||
| checkable.map(([, l]) => fetchNpmIntegrity2(l.identifier, l.npmIntegrity.npmVersion)) | ||
| ); | ||
| const drift = []; | ||
| const formatOnly = []; | ||
| const couldNotVerify = []; | ||
| for (let i = 0; i < checkable.length; i++) { | ||
| const [name, locked] = checkable[i]; | ||
| const baseline = locked.npmIntegrity; | ||
| const snap = fresh[i]; | ||
| const coord = { name, identifier: locked.identifier, npmVersion: baseline.npmVersion }; | ||
| if (snap === void 0) { | ||
| couldNotVerify.push(coord); | ||
| continue; | ||
| } | ||
| const cmp = compareIntegrity(baseline.integrity, snap.integrity); | ||
| if (cmp === "equal") continue; | ||
| if (cmp === "differ") { | ||
| drift.push({ ...coord, oldIntegrity: baseline.integrity, newIntegrity: snap.integrity }); | ||
| } else { | ||
| formatOnly.push(coord); | ||
| } | ||
| } | ||
| return { drift, formatOnly, couldNotVerify, absentBaseline, unenforceable, checkedNpmCount: checkable.length }; | ||
| } | ||
| function frozenVerdict(c) { | ||
| const noBaselines = c.absentBaseline.length > 0 && c.checkedNpmCount === 0; | ||
| const blocks = []; | ||
| for (const d of c.drift) { | ||
| blocks.push({ | ||
| name: d.name, | ||
| reason: "drift", | ||
| identifier: d.identifier, | ||
| npmVersion: d.npmVersion, | ||
| oldIntegrity: d.oldIntegrity, | ||
| newIntegrity: d.newIntegrity | ||
| }); | ||
| } | ||
| for (const f of c.formatOnly) { | ||
| blocks.push({ name: f.name, reason: "format", identifier: f.identifier, npmVersion: f.npmVersion }); | ||
| } | ||
| for (const v of c.couldNotVerify) { | ||
| blocks.push({ name: v.name, reason: "could-not-verify", identifier: v.identifier, npmVersion: v.npmVersion }); | ||
| } | ||
| if (!noBaselines) { | ||
| for (const name of c.absentBaseline) { | ||
| blocks.push({ name, reason: "missing-baseline" }); | ||
| } | ||
| } | ||
| return { | ||
| ok: !noBaselines && blocks.length === 0, | ||
| noBaselines, | ||
| blocks, | ||
| unenforceable: c.unenforceable, | ||
| checkedNpmCount: c.checkedNpmCount | ||
| }; | ||
| } | ||
| // src/stack/frozen-provenance.ts | ||
| function verifiedBaseline(locked) { | ||
| const prov = locked.provenance; | ||
| if (prov?.status !== "attested" || prov.verification?.outcome !== "verified") { | ||
| return void 0; | ||
| } | ||
| return { | ||
| npmVersion: prov.npmVersion, | ||
| signerSan: prov.verification.signerSan, | ||
| signerIssuer: prov.verification.signerIssuer | ||
| }; | ||
| } | ||
| async function classifyProvenance(lockFile, fetchNpmIntegrity2, fetchNpmProvenance2) { | ||
| const checked = Object.entries(lockFile.servers).filter( | ||
| ([, l]) => isLockedRegistryServer(l) | ||
| ).filter(([, l]) => l.registryType === "npm").map(([name, l]) => ({ name, locked: l, baseline: verifiedBaseline(l) })).filter( | ||
| (e) => e.baseline !== void 0 | ||
| ); | ||
| const blocks = (await Promise.all( | ||
| checked.map(async ({ name, locked, baseline }) => { | ||
| const coord = { name, identifier: locked.identifier, npmVersion: baseline.npmVersion }; | ||
| try { | ||
| const integ = await fetchNpmIntegrity2(locked.identifier, baseline.npmVersion); | ||
| if (integ === void 0) { | ||
| return { | ||
| ...coord, | ||
| reason: "unverifiable", | ||
| detail: "could not fetch npm's published integrity to bind the attestation" | ||
| }; | ||
| } | ||
| const fresh = await fetchNpmProvenance2(locked.identifier, baseline.npmVersion, { | ||
| integritySri: integ.integrity | ||
| }); | ||
| return classifyOne(coord, baseline, fresh); | ||
| } catch { | ||
| return { | ||
| ...coord, | ||
| reason: "unverifiable", | ||
| detail: "re-verification errored this run (fetcher threw)" | ||
| }; | ||
| } | ||
| }) | ||
| )).filter((b) => b !== void 0); | ||
| return { ok: blocks.length === 0, blocks, checkedVerifiedCount: checked.length }; | ||
| } | ||
| function classifyOne(coord, baseline, fresh) { | ||
| if (fresh === void 0) { | ||
| return { ...coord, reason: "unverifiable", detail: "no fresh attestation record this run (offline or endpoint error)" }; | ||
| } | ||
| if (fresh.status === "unsigned") { | ||
| return { ...coord, reason: "regression", detail: "the attestation that verified at lock time is no longer published (now unsigned)" }; | ||
| } | ||
| if (fresh.status !== "attested") { | ||
| return { ...coord, reason: "unverifiable", detail: "attestation shape is no longer a recognizable SLSA record" }; | ||
| } | ||
| const v = fresh.verification; | ||
| if (v === void 0) { | ||
| return { ...coord, reason: "unverifiable", detail: "attestation present but cryptographic verification did not run this fetch" }; | ||
| } | ||
| if (v.outcome === "could-not-verify") { | ||
| return { ...coord, reason: "regression", detail: `attestation no longer cryptographically verifies (${v.reason ?? "crypto failure"})` }; | ||
| } | ||
| if (baseline.signerSan === void 0) { | ||
| return { ...coord, reason: "unverifiable", detail: "verified baseline lacks a recorded signer SAN \u2014 cannot assert signer equality; re-lock to record it" }; | ||
| } | ||
| if (v.signerSan !== baseline.signerSan || v.signerIssuer !== baseline.signerIssuer) { | ||
| const deltas = []; | ||
| if (v.signerSan !== baseline.signerSan) { | ||
| deltas.push(`SAN ${baseline.signerSan ?? "(none)"} \u2192 ${v.signerSan ?? "(none)"}`); | ||
| } | ||
| if (v.signerIssuer !== baseline.signerIssuer) { | ||
| deltas.push(`issuer ${baseline.signerIssuer ?? "(none)"} \u2192 ${v.signerIssuer ?? "(none)"}`); | ||
| } | ||
| return { ...coord, reason: "signer-changed", detail: `signer identity changed: ${deltas.join("; ")}` }; | ||
| } | ||
| return void 0; | ||
| } | ||
| // src/guard/shadow.ts | ||
| function detectNameCollisions(inventory) { | ||
| const ownersByTool = /* @__PURE__ */ new Map(); | ||
| for (const [server, tools] of inventory) { | ||
| for (const tool of tools) { | ||
| let owners = ownersByTool.get(tool); | ||
| if (owners === void 0) { | ||
| owners = /* @__PURE__ */ new Set(); | ||
| ownersByTool.set(tool, owners); | ||
| } | ||
| owners.add(server); | ||
| } | ||
| } | ||
| const findings = []; | ||
| for (const [toolName, owners] of ownersByTool) { | ||
| if (owners.size >= 2) { | ||
| findings.push({ toolName, servers: [...owners].sort() }); | ||
| } | ||
| } | ||
| return findings.sort((a, b) => a.toolName.localeCompare(b.toolName)); | ||
| } | ||
| function buildInventoryFromPins(pins, serverNames) { | ||
| const inventory = /* @__PURE__ */ new Map(); | ||
| for (const name of serverNames) { | ||
| inventory.set(name, toolNamesFor(pins, name)); | ||
| } | ||
| return inventory; | ||
| } | ||
| function toolNamesFor(pins, name) { | ||
| return Object.hasOwn(pins.servers, name) ? Object.keys(pins.servers[name]) : []; | ||
| } | ||
| function serversWithoutBaseline(pins, serverNames) { | ||
| return serverNames.filter((name) => toolNamesFor(pins, name).length === 0); | ||
| } | ||
| // src/commands/up.ts | ||
| import "commander"; | ||
| import chalk from "chalk"; | ||
| import { input, password } from "@inquirer/prompts"; | ||
| function trustFigure(trust, options) { | ||
| if (options.minTrustFloor === void 0) { | ||
| return `${trust.score}/${trust.maxPossible}`; | ||
| } | ||
| const native = nativeTrustScore(trust); | ||
| if (native.excludedExternalCredit === 0) { | ||
| return `${trust.score}/${trust.maxPossible}`; | ||
| } | ||
| return `${native.score}/${native.maxPossible} against the floor, ${trust.score}/${trust.maxPossible} with the external scanner`; | ||
| } | ||
| async function handleUp(options, deps) { | ||
| if (options.secrets === "keychain" && options.ci) { | ||
| throw new Error( | ||
| "--secrets keychain cannot be combined with --ci (it would persist secrets to the CI runner's keychain). Use --secrets plaintext in CI." | ||
| ); | ||
| } | ||
| const stackPath = options.stackFile ?? "mcpm.yaml"; | ||
| const lockPath = lockPathFor(stackPath); | ||
| const stackFile = await parseStackFile(stackPath); | ||
| let lockFile = await parseLockFile(lockPath); | ||
| if (lockFile === null) { | ||
| deps.output("No lock file found. Running mcpm lock first..."); | ||
| await deps.runLock(stackPath); | ||
| lockFile = await parseLockFile(lockPath); | ||
| if (lockFile === null) { | ||
| throw new Error("Failed to create lock file."); | ||
| } | ||
| } | ||
| const clients = await deps.detectClients(); | ||
| if (clients.length === 0) { | ||
| throw new Error("No supported AI clients found."); | ||
| } | ||
| const serverEntries = filterByProfile(stackFile, options.profile); | ||
| if (serverEntries.length === 0) { | ||
| deps.output("No servers match the selected profile."); | ||
| return; | ||
| } | ||
| if (options.frozen === true || stackFile.policy?.frozen === true) { | ||
| await runFrozenPass(lockFile, deps); | ||
| } | ||
| const envFileVars = options.allowEnvFile === false ? { vars: {}, warnings: [] } : await parseEnvFile(".env"); | ||
| const scannerAvailable = await deps.checkScannerAvailable(); | ||
| if (options.dryRun) { | ||
| deps.output("Dry run \u2014 no changes will be made.\n"); | ||
| } | ||
| if (!options.dryRun) { | ||
| await backupConfigs(clients, deps); | ||
| } | ||
| const results = []; | ||
| const previousConsented = deps.readUnguardedConsent ? await deps.readUnguardedConsent() : []; | ||
| const consentedUnguarded = new Set(previousConsented); | ||
| for (const [name, server] of serverEntries) { | ||
| const locked = lockFile.servers[name]; | ||
| try { | ||
| const result = await processServer({ | ||
| name, | ||
| server, | ||
| locked, | ||
| policy: stackFile.policy, | ||
| clients, | ||
| scannerAvailable, | ||
| envFileVars: envFileVars.vars, | ||
| consentedUnguarded, | ||
| options, | ||
| deps | ||
| }); | ||
| results.push(result); | ||
| deps.recordResult?.({ name, status: result.status }); | ||
| deps.output(` ${statusIcon(result.status)} ${name}: ${result.message}`); | ||
| } catch (err) { | ||
| const failure = { | ||
| name, | ||
| status: "failed", | ||
| message: err instanceof Error ? err.message : String(err) | ||
| }; | ||
| results.push(failure); | ||
| deps.recordResult?.({ name, status: "failed" }); | ||
| deps.output(` ${statusIcon("failed")} ${name}: ${failure.message}`); | ||
| } | ||
| } | ||
| if (options.strict && !options.dryRun) { | ||
| await handleStrictRemoval(stackFile, clients, options, deps, results); | ||
| } | ||
| const urlServerNames = new Set( | ||
| serverEntries.filter(([, s]) => isUrlServer(s)).map(([n]) => n) | ||
| ); | ||
| const installedUnguarded = results.filter((r) => r.status === "installed" && urlServerNames.has(r.name)).map((r) => r.name).sort(); | ||
| if (installedUnguarded.length > 0 && !options.dryRun) { | ||
| const newlyConsented = installedUnguarded.filter((n) => !consentedUnguarded.has(n)); | ||
| if (isNewUnguarded(installedUnguarded, previousConsented)) { | ||
| const alreadyCount = installedUnguarded.length - newlyConsented.length; | ||
| const alreadyNote = alreadyCount > 0 ? ` (+${alreadyCount} previously consented)` : ""; | ||
| deps.output( | ||
| ` | ||
| \u26A0 UNGUARDED: the following URL/HTTP-transport server(s) now run WITHOUT runtime inspection (no relay wraps a non-stdio transport): ${newlyConsented.join(", ")}${alreadyNote}. This grants consent \u2014 it does NOT add protection. The only true fix is a streamable-HTTP relay (not yet implemented). Future \`up\` runs stay quiet unless a NEW unguarded server appears.` | ||
| ); | ||
| if (deps.recordUnguardedConsent) { | ||
| await deps.recordUnguardedConsent(newlyConsented).catch(() => void 0); | ||
| } | ||
| } else { | ||
| deps.output( | ||
| ` | ||
| ${installedUnguarded.length} server(s) running unguarded (previously consented): ${installedUnguarded.join(", ")}` | ||
| ); | ||
| } | ||
| } | ||
| if (options.frozen !== true && stackFile.policy?.frozen !== true) { | ||
| await runIntegrityPass(lockFile, deps); | ||
| } | ||
| let shadowCollisions = 0; | ||
| if (options.checkShadowing === true || stackFile.policy?.checkShadowing === true) { | ||
| shadowCollisions = await runShadowPass( | ||
| serverEntries.map(([name]) => name), | ||
| deps | ||
| ); | ||
| } | ||
| const installed = results.filter((r) => r.status === "installed").length; | ||
| const blocked = results.filter((r) => r.status === "blocked").length; | ||
| const failed = results.filter((r) => r.status === "failed").length; | ||
| const skipped = results.filter((r) => r.status === "skipped").length; | ||
| const removed = results.filter((r) => r.status === "removed").length; | ||
| const unguarded = installedUnguarded.length; | ||
| deps.output( | ||
| ` | ||
| ${installed} installed, ${skipped} skipped, ${blocked} blocked, ${failed} failed` + (removed > 0 ? `, ${removed} removed` : "") + (unguarded > 0 ? `, ${unguarded} unguarded` : "") | ||
| ); | ||
| const totalSecretsStored = results.reduce( | ||
| (sum, r) => sum + (r.storedSecrets ?? 0), | ||
| 0 | ||
| ); | ||
| if (options.secrets === "keychain" && totalSecretsStored > 0 && !options.dryRun) { | ||
| deps.output( | ||
| "Secrets stored encrypted at rest in ~/.mcpm. With an OS keychain this protects against other-user/offline access (not same-user processes); without one a machine-derived key is used that guards casual local inspection only, NOT file exfiltration \u2014 run `mcpm secrets migrate` once a keychain is available. Run `mcpm guard enable` (then restart your IDE) so they resolve at launch." | ||
| ); | ||
| } | ||
| if (blocked > 0 || failed > 0) { | ||
| throw new Error(`${blocked + failed} server(s) could not be installed.`); | ||
| } | ||
| if (shadowCollisions > 0 && options.ci) { | ||
| throw new Error( | ||
| `${shadowCollisions} cross-server tool-name collision(s) detected (--ci). Resolve the shadowing (rename/remove a duplicate tool) or drop --check-shadowing.` | ||
| ); | ||
| } | ||
| } | ||
| function filterByProfile(stackFile, profile) { | ||
| return Object.entries(stackFile.servers).filter(([, server]) => { | ||
| const profiles = isRegistryServer(server) || isUrlServer(server) ? server.profiles : void 0; | ||
| if (!profiles) return true; | ||
| if (!profile) return true; | ||
| return profiles.includes(profile); | ||
| }); | ||
| } | ||
| async function backupConfigs(clients, deps) { | ||
| const { readFile: readFile2, writeFile } = await import("fs/promises"); | ||
| for (const clientId of clients) { | ||
| try { | ||
| const adapter = deps.getAdapter(clientId); | ||
| const configPath = deps.getPath(clientId); | ||
| const content = await readFile2(configPath, "utf-8"); | ||
| await writeFile(`${configPath}.bak`, content, { | ||
| encoding: "utf-8", | ||
| mode: 384 | ||
| }); | ||
| } catch { | ||
| } | ||
| } | ||
| } | ||
| async function processServer(input2) { | ||
| const { name, server, locked, policy, clients, scannerAvailable, envFileVars, options, deps } = input2; | ||
| if (isUrlServer(server)) { | ||
| return processUrlServer(name, server.url, clients, policy, input2.consentedUnguarded, options, deps); | ||
| } | ||
| if (!locked || !isLockedRegistryServer(locked)) { | ||
| return { name, status: "failed", message: "Not found in lock file. Run mcpm lock." }; | ||
| } | ||
| const serverEntry = await deps.getServer(name, locked.version); | ||
| const statusGate = assessServerStatus(serverEntry); | ||
| if (statusGate.blocks) { | ||
| return { | ||
| name, | ||
| status: "blocked", | ||
| message: `deleted from the MCP registry${statusGate.statusMessage ? ` (${statusGate.statusMessage})` : ""}` | ||
| }; | ||
| } | ||
| const tier1 = deps.scanTier1(serverEntry); | ||
| let findings = [...tier1]; | ||
| if (scannerAvailable) { | ||
| const tier2 = await deps.scanTier2(name); | ||
| findings = [...findings, ...tier2]; | ||
| } | ||
| const registryMeta = extractRegistryMeta(serverEntry); | ||
| const releaseAge = assessReleaseAge({ | ||
| publishedAt: registryMeta.publishedAt, | ||
| now: (deps.now ?? Date.now)(), | ||
| minAgeHours: options.minTrustFloor !== void 0 ? DEFAULT_MIN_RELEASE_AGE_HOURS : policy?.minReleaseAgeHours ?? DEFAULT_MIN_RELEASE_AGE_HOURS | ||
| }); | ||
| if (releaseAge.finding) { | ||
| findings = [...findings, releaseAge.finding]; | ||
| } | ||
| const trustInput = { | ||
| findings, | ||
| healthCheckPassed: null, | ||
| hasExternalScanner: scannerAvailable, | ||
| registryMeta | ||
| }; | ||
| const trustScore = deps.computeTrustScore(trustInput); | ||
| const nativeTrust = nativeTrustScore(trustScore); | ||
| if (options.minTrustFloor !== void 0 && nativeTrust.score < options.minTrustFloor) { | ||
| return { | ||
| name, | ||
| status: "blocked", | ||
| message: `trust score ${nativeTrust.score}/${nativeTrust.maxPossible} is below the required floor of ${options.minTrustFloor}` + (nativeTrust.excludedExternalCredit > 0 ? ` (the external scanner's ${nativeTrust.excludedExternalCredit} points do not count toward the floor)` : "") | ||
| }; | ||
| } | ||
| const policyResult = checkTrustPolicy({ | ||
| serverName: name, | ||
| currentScore: trustScore.score, | ||
| currentMaxPossible: trustScore.maxPossible, | ||
| // TODOS #35: the blockOnScoreDrop tripwire compares native evidence, so a fake | ||
| // MCPM_EXTERNAL_SCANNER cannot mask a drop. `nativeTrust` is computed above. | ||
| currentNativeScore: nativeTrust.score, | ||
| currentNativeMaxPossible: nativeTrust.maxPossible, | ||
| lockedSnapshot: locked.trust, | ||
| policy, | ||
| releaseAge: { | ||
| ageHours: releaseAge.ageHours, | ||
| status: releaseAge.status, | ||
| blocksArmedGate: releaseAge.blocksArmedGate | ||
| }, | ||
| hasInstallScriptFindings: findings.some((f) => f.type === "install-script") | ||
| }); | ||
| if (!policyResult.pass) { | ||
| return { name, status: "blocked", message: policyResult.reason }; | ||
| } | ||
| if (options.dryRun) { | ||
| return { | ||
| name, | ||
| status: "skipped", | ||
| message: `would install v${locked.version} (trust: ${trustFigure(trustScore, options)})` | ||
| }; | ||
| } | ||
| const { env: envVars, storedCount } = await resolveEnvVars(name, server, envFileVars, options, deps); | ||
| const installedClients = []; | ||
| const clientErrors = []; | ||
| for (const clientId of clients) { | ||
| try { | ||
| const entry = resolveInstallEntry(serverEntry, clientId); | ||
| const entryWithEnv = { | ||
| ...entry, | ||
| ...Object.keys(envVars).length > 0 ? { env: { ...entry.env, ...envVars } } : {} | ||
| }; | ||
| const adapter = deps.getAdapter(clientId); | ||
| const configPath = deps.getPath(clientId); | ||
| await adapter.addServer(configPath, name, entryWithEnv, { force: true }); | ||
| installedClients.push(clientId); | ||
| } catch (err) { | ||
| clientErrors.push(`${clientId}: ${err instanceof Error ? err.message : String(err)}`); | ||
| } | ||
| } | ||
| if (installedClients.length === 0) { | ||
| return { | ||
| name, | ||
| status: "failed", | ||
| message: `could not write to any client (${clientErrors.join("; ")})` | ||
| }; | ||
| } | ||
| const partialNote = clientErrors.length > 0 ? ` (warning: failed on ${clientErrors.join("; ")})` : ""; | ||
| return { | ||
| name, | ||
| status: "installed", | ||
| message: `v${locked.version} (trust: ${trustFigure(trustScore, options)})${partialNote}`, | ||
| storedSecrets: storedCount | ||
| }; | ||
| } | ||
| async function runIntegrityPass(lockFile, deps) { | ||
| const c = await classifyIntegrity(lockFile, deps.fetchNpmIntegrity); | ||
| for (const d of c.drift) { | ||
| const oldShort = d.oldIntegrity.slice(0, 16); | ||
| const newShort = d.newIntegrity.slice(0, 16); | ||
| deps.output( | ||
| ` | ||
| \u26A0 INTEGRITY DRIFT: npm's published record for ${d.identifier}@${d.npmVersion} changed since you locked it (dist.integrity ${oldShort}\u2026 \u2192 ${newShort}\u2026). A published version's integrity is meant to be immutable, so this can mean a supply-chain republish \u2014 but it can also be a legitimate republish or a different registry. mcpm checks the registry's published record, not the code your agent runs. This is a warning only \u2014 it does not block \`mcpm up\`; npx/uvx fetch and run the actual package independently when the server starts (possibly from a different mirror). Re-run \`mcpm lock\` if this change is expected.` | ||
| ); | ||
| } | ||
| for (const f of c.formatOnly) { | ||
| deps.output( | ||
| ` | ||
| \u26A0 ${f.name}: npm changed the integrity format for ${f.identifier}@${f.npmVersion}, so mcpm cannot compare its published record against your locked baseline (mcpm checks the registry's published record, not the code your agent runs). Re-run \`mcpm lock\` to refresh the baseline.` | ||
| ); | ||
| } | ||
| if (c.couldNotVerify.length > 0) { | ||
| deps.output( | ||
| ` | ||
| could not verify npm integrity for ${c.couldNotVerify.length} server(s) this run (no drift result is not proof of integrity).` | ||
| ); | ||
| } | ||
| if (c.absentBaseline.length > 0) { | ||
| deps.output( | ||
| ` | ||
| integrity baseline missing for ${c.absentBaseline.length} npm server(s) \u2014 re-run \`mcpm lock\` with network access to enable drift detection.` | ||
| ); | ||
| } | ||
| } | ||
| async function runFrozenPass(lockFile, deps) { | ||
| const fetchIntegrity = memoizeIntegrity(deps.fetchNpmIntegrity); | ||
| const [v, pv] = await Promise.all([ | ||
| classifyIntegrity(lockFile, fetchIntegrity).then(frozenVerdict), | ||
| classifyProvenance(lockFile, fetchIntegrity, deps.fetchNpmProvenance) | ||
| ]); | ||
| const provBlocks = pv.blocks; | ||
| if (v.unenforceable.length > 0) { | ||
| deps.output( | ||
| ` | ||
| ${v.unenforceable.length} server(s) (pypi/oci/url) have no integrity baseline mechanism \u2014 \`--frozen\` cannot enforce them (multi-registry pinning is deferred).` | ||
| ); | ||
| } | ||
| if (v.noBaselines && provBlocks.length === 0) { | ||
| throw new Error( | ||
| "--frozen: this lock has no integrity baselines (it predates them, or was last locked offline). Run `mcpm lock` online once to record them, then `mcpm up --frozen`." | ||
| ); | ||
| } | ||
| if (v.ok && provBlocks.length === 0) return; | ||
| const integrityMessages = v.blocks.map((b) => { | ||
| switch (b.reason) { | ||
| case "drift": | ||
| return `\u2717 FROZEN: npm's published record for ${b.identifier}@${b.npmVersion} changed since you locked it (dist.integrity ${b.oldIntegrity.slice(0, 16)}\u2026 \u2192 ${b.newIntegrity.slice(0, 16)}\u2026). --frozen refuses to install on integrity drift. Re-pin with \`mcpm lock\` only if this change is expected.`; | ||
| case "format": | ||
| return `\u2717 FROZEN: cannot compare npm's published record for ${b.identifier}@${b.npmVersion} against your locked baseline (integrity format changed). Re-run \`mcpm lock\` to refresh it.`; | ||
| case "could-not-verify": | ||
| return `\u2717 FROZEN: could not verify npm's published record for ${b.identifier}@${b.npmVersion} this run (offline, a yanked version, or no comparable dist.integrity). --frozen requires proof the record matches your lock \u2014 this may be a transient registry error, so re-run; if it persists, drop --frozen.`; | ||
| case "missing-baseline": | ||
| return `\u2717 FROZEN: no integrity baseline recorded for ${b.name}, though other servers in this lock have one. Re-run \`mcpm lock\` online to record it, then \`mcpm up --frozen\`.`; | ||
| default: { | ||
| const _never = b; | ||
| throw new Error(`unhandled frozen block reason: ${JSON.stringify(_never)}`); | ||
| } | ||
| } | ||
| }); | ||
| const provenanceMessages = provBlocks.map(frozenProvenanceMessage); | ||
| const noticeMessages = v.noBaselines ? [ | ||
| "\u26A0 FROZEN: this lock has no integrity baselines (predates them / locked offline) \u2014 run `mcpm lock` online to record them." | ||
| ] : []; | ||
| const allMessages = [...noticeMessages, ...integrityMessages, ...provenanceMessages]; | ||
| deps.output(` | ||
| ${allMessages.join("\n")}`); | ||
| deps.output("\nmcpm verifies the registry's published record, not the code your agent runs at launch."); | ||
| const failed = /* @__PURE__ */ new Set([...v.blocks.map((b) => b.name), ...provBlocks.map((b) => b.name)]); | ||
| throw new Error( | ||
| `frozen: ${failed.size} server(s) failed verification; nothing was installed.` | ||
| ); | ||
| } | ||
| function frozenProvenanceMessage(b) { | ||
| switch (b.reason) { | ||
| case "signer-changed": | ||
| return `\u2717 FROZEN: the cryptographic signer for ${b.identifier}@${b.npmVersion} changed since you locked it (${b.detail}). --frozen refuses to install on a provenance signer swap. Re-pin with \`mcpm lock\` only if this re-sign is expected.`; | ||
| case "regression": | ||
| return `\u2717 FROZEN: provenance for ${b.identifier}@${b.npmVersion} regressed \u2014 it cryptographically verified when you locked it and no longer does (${b.detail}). --frozen refuses to install. If npm's record is unchanged, your mcpm/@sigstore version may have changed since you locked (e.g. after an mcpm upgrade); if that regression is expected, remove this server's stale lock entry and re-lock to re-baseline (a plain \`mcpm lock\` keeps the prior verified baseline).`; | ||
| case "unverifiable": | ||
| return `\u2717 FROZEN: could not cryptographically re-verify provenance for ${b.identifier}@${b.npmVersion} this run (${b.detail}). --frozen requires proof the attestation still verifies \u2014 this may be a transient error, so re-run; if it persists, investigate before dropping --frozen.`; | ||
| default: { | ||
| const _never = b.reason; | ||
| throw new Error(`unhandled provenance block reason: ${JSON.stringify(_never)}`); | ||
| } | ||
| } | ||
| } | ||
| async function runShadowPass(serverNames, deps) { | ||
| if (deps.readPins === void 0) { | ||
| deps.output("\n\u26A0 shadow check skipped: no pins reader available in this context."); | ||
| return 0; | ||
| } | ||
| let pins; | ||
| try { | ||
| pins = await deps.readPins(); | ||
| } catch { | ||
| deps.output( | ||
| "\n\u26A0 shadow check skipped: ~/.mcpm/pins.json is unreadable (integrity check or corruption)." | ||
| ); | ||
| return 0; | ||
| } | ||
| const findings = detectNameCollisions(buildInventoryFromPins(pins, serverNames)); | ||
| const noBaseline = serversWithoutBaseline(pins, serverNames); | ||
| const checked = serverNames.length - noBaseline.length; | ||
| deps.output( | ||
| ` | ||
| Shadow check: compared guarded tool inventories for ${checked} of ${serverNames.length} server(s).` | ||
| ); | ||
| if (noBaseline.length > 0) { | ||
| deps.output( | ||
| ` ${noBaseline.length} server(s) have NO guard baseline yet (${noBaseline.join(", ")}) \u2014 this check cannot see their tools, so a clean result does NOT mean no shadowing. Run them under \`mcpm guard\` (then re-run \`mcpm up\`) to include them.` | ||
| ); | ||
| } | ||
| for (const f of findings) { | ||
| deps.output( | ||
| ` | ||
| \u26A0 SHADOW: tool "${f.toolName}" is exposed by ${f.servers.length} servers (${f.servers.join(", ")}). A lower-trust server can shadow a tool meant for another, so agent calls to "${f.toolName}" are ambiguous. This can also be benign (two servers of the same kind legitimately export the same tool). Review which server should own it. (Exact-name match only \u2014 a homoglyph/case variant evades this check.)` | ||
| ); | ||
| } | ||
| return findings.length; | ||
| } | ||
| async function processUrlServer(name, url, clients, policy, consentedUnguarded, options, deps) { | ||
| if (options.allowUrlServers === false) { | ||
| return { | ||
| name, | ||
| status: "blocked", | ||
| message: "URL servers are not permitted via the MCP surface" | ||
| }; | ||
| } | ||
| const consented = options.allowUnguarded === true || policy?.allowUrlServers === true || consentedUnguarded.has(name); | ||
| if (!consented) { | ||
| return { | ||
| name, | ||
| status: "blocked", | ||
| message: "URL/HTTP-transport server runs UNGUARDED \u2014 no runtime inspection is possible (mcpm's guard relay only wraps stdio servers). Re-run with --allow-unguarded or set policy.allowUrlServers: true to install it WITHOUT protection." | ||
| }; | ||
| } | ||
| let urlError; | ||
| try { | ||
| validateRemoteUrl(url); | ||
| } catch (err) { | ||
| urlError = err instanceof Error ? err.message : String(err); | ||
| } | ||
| const cursorClients = clients.filter((c) => c === "cursor"); | ||
| if (cursorClients.length === 0) { | ||
| return { | ||
| name, | ||
| status: "skipped", | ||
| message: "URL server \u2014 no Cursor client detected (only Cursor supports URL transport)" | ||
| }; | ||
| } | ||
| if (options.dryRun) { | ||
| return urlError ? { name, status: "skipped", message: `would reject URL ${url}: ${urlError}` } : { name, status: "skipped", message: `would install URL ${url} to Cursor` }; | ||
| } | ||
| if (urlError) { | ||
| return { name, status: "blocked", message: urlError }; | ||
| } | ||
| for (const clientId of cursorClients) { | ||
| const adapter = deps.getAdapter(clientId); | ||
| const configPath = deps.getPath(clientId); | ||
| await adapter.addServer(configPath, name, { url }, { force: true }); | ||
| } | ||
| return { name, status: "installed", message: `URL ${url} \u2192 Cursor` }; | ||
| } | ||
| async function resolveEnvVars(serverName, server, envFileVars, options, deps) { | ||
| const envDecl = isRegistryServer(server) || isUrlServer(server) ? server.env : void 0; | ||
| if (!envDecl) return { env: {}, storedCount: 0 }; | ||
| const resolved = {}; | ||
| const secretKeys = /* @__PURE__ */ new Set(); | ||
| for (const [key, decl] of Object.entries(envDecl)) { | ||
| const fromEnv = options.allowProcessEnv === false ? void 0 : process.env[key]; | ||
| const fromFile = envFileVars[key]; | ||
| const fromDefault = decl.default; | ||
| let value; | ||
| if (fromEnv !== void 0) { | ||
| value = fromEnv; | ||
| } else if (fromFile !== void 0) { | ||
| value = fromFile; | ||
| } else if (fromDefault !== void 0) { | ||
| value = fromDefault; | ||
| } else if (decl.required) { | ||
| if (options.ci) { | ||
| throw new Error( | ||
| `Required env var "${key}" for "${serverName}" is not set. Set it in process.env or .env file (--ci mode, no interactive prompt).` | ||
| ); | ||
| } | ||
| value = await deps.promptEnvVar(key, decl.secret); | ||
| } | ||
| if (value === void 0) continue; | ||
| resolved[key] = value; | ||
| if (decl.secret) secretKeys.add(key); | ||
| } | ||
| return applyKeychainSecrets({ | ||
| serverName, | ||
| resolvedEnv: resolved, | ||
| isSecret: (key) => secretKeys.has(key), | ||
| mode: options.secrets ?? "plaintext", | ||
| setSecrets: deps.setSecrets | ||
| }); | ||
| } | ||
| async function handleStrictRemoval(stackFile, clients, options, deps, results) { | ||
| const declaredNames = new Set(Object.keys(stackFile.servers)); | ||
| for (const clientId of clients) { | ||
| const adapter = deps.getAdapter(clientId); | ||
| const configPath = deps.getPath(clientId); | ||
| const installed = await adapter.read(configPath); | ||
| for (const name of Object.keys(installed)) { | ||
| if (declaredNames.has(name)) continue; | ||
| if (options.ci && !options.yes) { | ||
| throw new Error( | ||
| `--strict --ci requires --yes to remove servers not in mcpm.yaml. Server "${name}" in ${clientId} would be removed.` | ||
| ); | ||
| } | ||
| if (!options.ci && options.yes !== true) { | ||
| const confirmed = await deps.confirm( | ||
| `Remove "${name}" from ${clientId}? (not in mcpm.yaml)` | ||
| ); | ||
| if (!confirmed) continue; | ||
| } | ||
| await adapter.removeServer(configPath, name); | ||
| results.push({ | ||
| name, | ||
| status: "removed", | ||
| message: `removed from ${clientId} (not in mcpm.yaml)` | ||
| }); | ||
| deps.recordResult?.({ name, status: "removed" }); | ||
| deps.output(` - ${name}: removed from ${clientId}`); | ||
| } | ||
| } | ||
| } | ||
| function statusIcon(status) { | ||
| switch (status) { | ||
| case "installed": | ||
| return "\u2713"; | ||
| case "removed": | ||
| return "\u2212"; | ||
| case "skipped": | ||
| return "\u2022"; | ||
| case "blocked": | ||
| return "\u2717"; | ||
| case "failed": | ||
| return "\u2717"; | ||
| default: | ||
| return "?"; | ||
| } | ||
| } | ||
| function registerUpCommand(program) { | ||
| program.command("up").description("Install all servers from mcpm.yaml with trust verification").option("-f, --file <path>", "path to mcpm.yaml", "mcpm.yaml").option("-p, --profile <name>", "install only servers matching this profile").option("--dry-run", "show what would be installed without making changes").option("--ci", "CI mode: no interactive prompts, exit nonzero on failure").option("--strict", "remove servers not declared in mcpm.yaml").option("-y, --yes", "skip confirmation prompts (required with --strict --ci)").option("--secrets <mode>", "where to store secret env vars: 'keychain' (encrypted in ~/.mcpm, resolved by mcpm guard at launch) or 'plaintext' (default); 'keychain' is rejected with --ci", parseSecretsMode).option("--allow-unguarded", "permit URL/HTTP-transport servers to run WITHOUT runtime guard inspection (no relay wraps a non-stdio transport); records consent so future runs stay quiet").option("--check-shadowing", "report tool-name collisions across guarded servers (a shadowing signal); advisory interactively, exits nonzero under --ci").option("--frozen", "fail closed: BEFORE installing, verify every locked npm server's published integrity AND re-verify Sigstore provenance for crypto-verified servers, then BLOCK (install nothing, exit nonzero) on integrity drift / provenance regression / unverifiable / missing baseline \u2014 the CI supply-chain freeze gate").action( | ||
| async (opts) => { | ||
| const client = new RegistryClient(); | ||
| const { readUnguardedConsent, writeUnguardedConsent, mergeUnguarded } = await import("./unguarded-GJO5WRM7.js"); | ||
| try { | ||
| await handleUp( | ||
| { | ||
| stackFile: opts.file, | ||
| profile: opts.profile, | ||
| dryRun: opts.dryRun, | ||
| ci: opts.ci, | ||
| strict: opts.strict, | ||
| yes: opts.yes, | ||
| secrets: opts.secrets, | ||
| allowUnguarded: opts.allowUnguarded, | ||
| checkShadowing: opts.checkShadowing, | ||
| frozen: opts.frozen | ||
| }, | ||
| { | ||
| detectClients: detectInstalledClients, | ||
| getAdapter, | ||
| getPath: getConfigPath, | ||
| getServer: (name, version) => client.getServer(name, version), | ||
| scanTier1, | ||
| checkScannerAvailable, | ||
| scanTier2: (name) => scanTier2(name), | ||
| computeTrustScore, | ||
| now: () => Date.now(), | ||
| runLock: async (stackFile) => { | ||
| const { writeFile } = await import("fs/promises"); | ||
| await handleLock( | ||
| { stackFile }, | ||
| { | ||
| getServerVersions: (name) => client.getServerVersions(name), | ||
| getServer: (name, v) => client.getServer(name, v), | ||
| scanTier1, | ||
| checkScannerAvailable, | ||
| scanTier2: (name) => scanTier2(name), | ||
| computeTrustScore, | ||
| now: () => Date.now(), | ||
| writeLockFile: (path, content) => writeFile(path, content, { encoding: "utf-8", mode: 384 }), | ||
| fetchNpmIntegrity, | ||
| // F8/B3: auto-lock must record the crypto-`verified` provenance | ||
| // baseline too, or the verify-time gate is vacuous for up-locked repos. | ||
| fetchNpmProvenance: (id, ver, sri) => fetchNpmProvenance(id, ver, { integritySri: sri }), | ||
| output: stdoutOutput | ||
| } | ||
| ); | ||
| }, | ||
| confirm, | ||
| promptEnvVar: async (name, isSecret) => { | ||
| if (isSecret) { | ||
| return password({ message: `${name}:` }); | ||
| } | ||
| return input({ message: `${name}:` }); | ||
| }, | ||
| output: stdoutOutput, | ||
| setSecrets, | ||
| fetchNpmIntegrity, | ||
| fetchNpmProvenance: (id, v, o) => fetchNpmProvenance(id, v, o), | ||
| readPins, | ||
| readUnguardedConsent, | ||
| recordUnguardedConsent: async (names) => { | ||
| const previous = await readUnguardedConsent(); | ||
| await writeUnguardedConsent(mergeUnguarded(previous, names)); | ||
| } | ||
| } | ||
| ); | ||
| } catch (err) { | ||
| console.error(chalk.red(err.message)); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| ); | ||
| } | ||
| export { | ||
| memoizeIntegrity, | ||
| classifyIntegrity, | ||
| frozenVerdict, | ||
| classifyProvenance, | ||
| handleUp, | ||
| registerUpCommand | ||
| }; | ||
| //# sourceMappingURL=chunk-TX4RGSW7.js.map |
Sorry, the diff of this file is too big to display
| #!/usr/bin/env node | ||
| import { | ||
| confineSandboxRoot, | ||
| hashConfineProfile, | ||
| readConfineStore, | ||
| withProfile, | ||
| writeConfineStore | ||
| } from "./chunk-544DEV2D.js"; | ||
| import { | ||
| SANDBOX_EXEC_PATH, | ||
| defaultWrapContext, | ||
| isConfineBackendAvailable, | ||
| isWrapped, | ||
| unwrapEntry, | ||
| wrapEntry | ||
| } from "./chunk-WYSMWP2R.js"; | ||
| import "./chunk-OIFKZA4V.js"; | ||
| import { | ||
| sanitizeForTerminal | ||
| } from "./chunk-FEXJHHDM.js"; | ||
| import { | ||
| getAdapter | ||
| } from "./chunk-W4IAFBUN.js"; | ||
| import { | ||
| isNewUnguarded, | ||
| mergeUnguarded, | ||
| readUnguardedConsent, | ||
| writeUnguardedConsent | ||
| } from "./chunk-MLVDFLDQ.js"; | ||
| import { | ||
| placeholderEnvKeys | ||
| } from "./chunk-GZ3WCRLG.js"; | ||
| import { | ||
| detectInstalledClients | ||
| } from "./chunk-6R7TL5O2.js"; | ||
| import { | ||
| getConfigPath | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import "./chunk-3X76P3FG.js"; | ||
| // src/guard/cli.ts | ||
| import chalk from "chalk"; | ||
| // src/guard/orchestrator.ts | ||
| import { copyFile } from "fs/promises"; | ||
| function enableGuardAcrossClients(deps, filter) { | ||
| return runAcrossClients(deps, "enable", filter); | ||
| } | ||
| function disableGuardAcrossClients(deps, filter) { | ||
| return runAcrossClients(deps, "disable", filter); | ||
| } | ||
| async function runAcrossClients(deps, action, filter) { | ||
| const targetClients = await selectTargetClients(deps, filter?.client); | ||
| const consentedUnguarded = action === "enable" && deps.readUnguardedConsent ? await deps.readUnguardedConsent() : []; | ||
| const plans = await Promise.all( | ||
| targetClients.map( | ||
| (clientId) => planForClient(clientId, deps, action, filter?.server, consentedUnguarded) | ||
| ) | ||
| ); | ||
| for (const plan of plans) { | ||
| if (plan.transforms.length === 0) continue; | ||
| const configPath = deps.getConfigPath(plan.clientId); | ||
| await copyFile(configPath, `${configPath}.guard-${action}.bak`).catch(() => void 0); | ||
| } | ||
| const reports = []; | ||
| for (const plan of plans) { | ||
| reports.push(await applyPlan(plan, deps)); | ||
| } | ||
| if (filter?.server !== void 0) { | ||
| const matched = reports.some((r) => r.servers.some((s) => s.name === filter.server)); | ||
| if (!matched) { | ||
| throw new Error( | ||
| `--server "${filter.server}" not found in any detected client config. Run \`mcpm guard status\` to see available servers.` | ||
| ); | ||
| } | ||
| } | ||
| if (deps.recordUnguardedConsent) { | ||
| const nowUnguarded = [ | ||
| ...new Set( | ||
| reports.flatMap((r) => r.servers.filter((s) => s.status === "unguarded").map((s) => s.name)) | ||
| ) | ||
| ]; | ||
| const previous = new Set(consentedUnguarded); | ||
| const newlyConsented = nowUnguarded.filter((n) => !previous.has(n)); | ||
| if (newlyConsented.length > 0) { | ||
| await deps.recordUnguardedConsent(newlyConsented).catch(() => void 0); | ||
| } | ||
| } | ||
| return summarize(action, reports); | ||
| } | ||
| async function statusAcrossClients(deps) { | ||
| const targetClients = await deps.detectClients(); | ||
| const clients = await Promise.all( | ||
| targetClients.map(async (clientId) => { | ||
| try { | ||
| const adapter = deps.getAdapter(clientId); | ||
| const entries = await adapter.read(deps.getConfigPath(clientId)); | ||
| const servers = Object.entries(entries).map(([name, entry]) => ({ | ||
| name, | ||
| wrapped: isWrapped(entry), | ||
| // A non-stdio (url-transport) entry has no command — it cannot be | ||
| // wrapped, so even when "not wrapped" it is specifically UNGUARDED. | ||
| unguarded: !isWrapped(entry) && !entry.command | ||
| })); | ||
| return { | ||
| clientId, | ||
| wrapped: servers.filter((s) => s.wrapped).length, | ||
| unwrapped: servers.filter((s) => !s.wrapped).length, | ||
| servers | ||
| }; | ||
| } catch (err) { | ||
| return { | ||
| clientId, | ||
| wrapped: 0, | ||
| unwrapped: 0, | ||
| servers: [], | ||
| error: err instanceof Error ? err.message : String(err) | ||
| }; | ||
| } | ||
| }) | ||
| ); | ||
| return { | ||
| clients, | ||
| totalWrapped: clients.reduce((sum, c) => sum + c.wrapped, 0), | ||
| totalUnwrapped: clients.reduce((sum, c) => sum + c.unwrapped, 0) | ||
| }; | ||
| } | ||
| async function selectTargetClients(deps, clientFilter) { | ||
| const detected = await deps.detectClients(); | ||
| if (clientFilter === void 0) return detected; | ||
| if (!detected.includes(clientFilter)) { | ||
| throw new Error( | ||
| `Client "${clientFilter}" not detected. Available: ${detected.join(", ") || "(none)"}` | ||
| ); | ||
| } | ||
| return [clientFilter]; | ||
| } | ||
| async function planForClient(clientId, deps, action, serverFilter, consentedUnguarded) { | ||
| const adapter = deps.getAdapter(clientId); | ||
| let entries; | ||
| try { | ||
| entries = await adapter.read(deps.getConfigPath(clientId)); | ||
| } catch (err) { | ||
| return { | ||
| clientId, | ||
| action, | ||
| transforms: [], | ||
| skipped: [], | ||
| unguarded: [], | ||
| readError: err instanceof Error ? err.message : String(err) | ||
| }; | ||
| } | ||
| const transforms = []; | ||
| const skipped = []; | ||
| const unguarded = []; | ||
| const consentedSet = new Set(consentedUnguarded); | ||
| for (const [name, entry] of Object.entries(entries)) { | ||
| if (serverFilter !== void 0 && name !== serverFilter) continue; | ||
| if (action === "enable") { | ||
| if (isWrapped(entry)) { | ||
| skipped.push({ name, reason: "already wrapped" }); | ||
| continue; | ||
| } | ||
| if (!entry.command) { | ||
| if (deps.allowUnguarded === true || consentedSet.has(name)) { | ||
| unguarded.push({ | ||
| name, | ||
| reason: "unguarded (consented) \u2014 runs WITHOUT runtime inspection; this grants consent, it does not add protection. The only true fix is a streamable-HTTP relay (not yet implemented)." | ||
| }); | ||
| } else { | ||
| skipped.push({ | ||
| name, | ||
| reason: "DENIED: URL/HTTP-transport server runs UNGUARDED \u2014 no runtime inspection is possible (the guard relay only wraps stdio servers). Re-run `mcpm guard enable --allow-unguarded` to permit it without protection." | ||
| }); | ||
| } | ||
| continue; | ||
| } | ||
| transforms.push({ | ||
| name, | ||
| nextEntry: wrapEntry(name, entry, deps.wrapContext, deps.confineMarkers?.get(name)) | ||
| }); | ||
| } else { | ||
| if (!isWrapped(entry)) { | ||
| skipped.push({ name, reason: "not wrapped" }); | ||
| continue; | ||
| } | ||
| const unwrapped = unwrapEntry(entry); | ||
| if (unwrapped === null) { | ||
| skipped.push({ name, reason: "wrap marker malformed; .bak restore may be required" }); | ||
| continue; | ||
| } | ||
| transforms.push({ name, nextEntry: unwrapped }); | ||
| } | ||
| } | ||
| return { clientId, action, transforms, skipped, unguarded }; | ||
| } | ||
| async function applyPlan(plan, deps) { | ||
| if (plan.readError) { | ||
| return { | ||
| clientId: plan.clientId, | ||
| servers: [], | ||
| error: plan.readError | ||
| }; | ||
| } | ||
| const adapter = deps.getAdapter(plan.clientId); | ||
| const configPath = deps.getConfigPath(plan.clientId); | ||
| const servers = []; | ||
| for (const { name, nextEntry } of plan.transforms) { | ||
| try { | ||
| await adapter.replaceServer(configPath, name, nextEntry); | ||
| servers.push({ name, status: plan.action === "enable" ? "wrapped" : "unwrapped" }); | ||
| } catch (err) { | ||
| servers.push({ | ||
| name, | ||
| status: "skipped", | ||
| reason: err instanceof Error ? err.message : String(err) | ||
| }); | ||
| } | ||
| } | ||
| for (const skip of plan.skipped) { | ||
| servers.push({ name: skip.name, status: "skipped", reason: skip.reason }); | ||
| } | ||
| for (const u of plan.unguarded) { | ||
| servers.push({ name: u.name, status: "unguarded", reason: u.reason }); | ||
| } | ||
| return { clientId: plan.clientId, servers }; | ||
| } | ||
| function summarize(action, reports) { | ||
| let totalChanged = 0; | ||
| let totalSkipped = 0; | ||
| let totalUnguarded = 0; | ||
| let errors = 0; | ||
| for (const report of reports) { | ||
| if (report.error) errors++; | ||
| for (const server of report.servers) { | ||
| if (server.status === "wrapped" || server.status === "unwrapped") totalChanged++; | ||
| else if (server.status === "unguarded") totalUnguarded++; | ||
| else totalSkipped++; | ||
| } | ||
| } | ||
| return { action, clients: reports, totalChanged, totalSkipped, totalUnguarded, errors }; | ||
| } | ||
| // src/guard/cli.ts | ||
| import os from "os"; | ||
| // src/guard/confine/derive.ts | ||
| import { createHash } from "crypto"; | ||
| import path from "path"; | ||
| var SECRET_DIR_SEGMENTS = [ | ||
| // SSH / cloud / package-registry / signing credentials. | ||
| ".ssh", | ||
| ".aws", | ||
| ".gnupg", | ||
| ".config/gh", | ||
| ".config/gcloud", | ||
| ".npmrc", | ||
| ".docker", | ||
| ".kube", | ||
| ".netrc", | ||
| ".git-credentials", | ||
| ".cargo/credentials", | ||
| ".cargo/credentials.toml", | ||
| ".pypirc", | ||
| // OS keychains + browser cookie stores (highest-value credential theft). | ||
| "Library/Keychains", | ||
| "Library/Application Support/Google/Chrome", | ||
| "Library/Application Support/Firefox", | ||
| "Library/Cookies", | ||
| // mcpm's own store (secrets.enc.json, pins, policy, the confine store itself). | ||
| ".mcpm", | ||
| // Sibling MCP client configs (each can hold another server's plaintext secrets). | ||
| "Library/Application Support/Claude", | ||
| "Library/Application Support/Cursor", | ||
| "Library/Application Support/Code/User", | ||
| "Library/Application Support/Windsurf", | ||
| ".cursor", | ||
| ".vscode", | ||
| ".codeium", | ||
| ".claude.json", | ||
| // Claude Code user-global config (see src/config/paths.ts getConfigPath) | ||
| ".claude", | ||
| // Claude Code also keeps state under ~/.claude/ | ||
| ".gemini" | ||
| // Gemini CLI user-global config (~/.gemini/settings.json) | ||
| ]; | ||
| var WRITE_ALLOW_HOME_SEGMENTS = [".npm", ".cache", "Library/Caches"]; | ||
| var WRITE_ALLOW_STATIC = [ | ||
| "/tmp", | ||
| "/private/tmp", | ||
| "/var/tmp", | ||
| "/var/folders", | ||
| "/private/var/folders", | ||
| "/dev" | ||
| ]; | ||
| var LAUNCHER_COMMANDS = /* @__PURE__ */ new Set([ | ||
| "npx", | ||
| "npm", | ||
| "pnpm", | ||
| "yarn", | ||
| "bun", | ||
| "bunx", | ||
| "uv", | ||
| "uvx", | ||
| "pip", | ||
| "pip3", | ||
| "pipx", | ||
| "pipenv", | ||
| "poetry", | ||
| "docker" | ||
| ]); | ||
| function commandBasename(command) { | ||
| const base = path.basename(command).toLowerCase(); | ||
| const dot = base.indexOf("."); | ||
| return dot === -1 ? base : base.slice(0, dot); | ||
| } | ||
| function classifyNet(command) { | ||
| return LAUNCHER_COMMANDS.has(commandBasename(command)) ? "all" : "none"; | ||
| } | ||
| function safeServerSegment(serverName) { | ||
| if (serverName.length === 0) throw new Error("confine: empty server name"); | ||
| const cleaned = serverName.replace(/[^A-Za-z0-9._@-]/g, "_").replace(/\.{2,}/g, "_").replace(/^\.+/, "_"); | ||
| const suffix = createHash("sha256").update(serverName).digest("hex").slice(0, 8); | ||
| return `${cleaned}-${suffix}`; | ||
| } | ||
| function deriveDefaultProfile(input) { | ||
| if (input.command.length === 0) throw new Error("confine: empty command"); | ||
| const scratchDir = path.join(input.sandboxRoot, safeServerSegment(input.serverName)); | ||
| const readDeny = SECRET_DIR_SEGMENTS.map((seg) => path.join(input.home, seg)); | ||
| const writeAllow = [ | ||
| scratchDir, | ||
| ...WRITE_ALLOW_STATIC, | ||
| input.tmpDir, | ||
| ...WRITE_ALLOW_HOME_SEGMENTS.map((seg) => path.join(input.home, seg)) | ||
| ]; | ||
| return { | ||
| tier: "standard", | ||
| require_confine: input.requireConfine === true, | ||
| read_deny: dedupeSorted(readDeny), | ||
| write_allow: dedupeSorted(writeAllow), | ||
| net: input.net ?? classifyNet(input.command), | ||
| scratch_dir: scratchDir, | ||
| captured_at: input.capturedAt | ||
| }; | ||
| } | ||
| function dedupeSorted(paths) { | ||
| return [...new Set(paths)].sort(); | ||
| } | ||
| // src/guard/cli.ts | ||
| var CLIENT_LABELS = { | ||
| "claude-desktop": "Claude Desktop", | ||
| "claude-code": "Claude Code", | ||
| cursor: "Cursor", | ||
| vscode: "VS Code", | ||
| windsurf: "Windsurf", | ||
| "gemini-cli": "Gemini CLI" | ||
| }; | ||
| function buildDeps(extra = {}) { | ||
| return { | ||
| detectClients: detectInstalledClients, | ||
| getAdapter, | ||
| getConfigPath, | ||
| wrapContext: defaultWrapContext(), | ||
| readUnguardedConsent, | ||
| recordUnguardedConsent: async (names) => { | ||
| const previous = await readUnguardedConsent(); | ||
| await writeUnguardedConsent(mergeUnguarded(previous, names)); | ||
| }, | ||
| ...extra | ||
| }; | ||
| } | ||
| async function runEnableCommand(opts) { | ||
| const previousConsented = await readUnguardedConsent(); | ||
| if (opts.dryRun === true) { | ||
| await printEnableDryRun(opts); | ||
| return; | ||
| } | ||
| if (opts.confine === "off") { | ||
| opts.write("OS confinement: skipped (--confine off).\n"); | ||
| } | ||
| let confineMarkers; | ||
| if (opts.confine === "standard") { | ||
| try { | ||
| confineMarkers = await computeConfineMarkers({ | ||
| client: opts.client, | ||
| server: opts.server, | ||
| write: opts.write | ||
| }); | ||
| } catch (err) { | ||
| opts.write( | ||
| chalk.yellow(` | ||
| \u26A0 OS confinement aborted: ${sanitizeForTerminal(err.message)}`) + "\n" | ||
| ); | ||
| return; | ||
| } | ||
| } | ||
| const deps = buildDeps({ allowUnguarded: opts.allowUnguarded, confineMarkers }); | ||
| const summary = await enableGuardAcrossClients(deps, opts); | ||
| printEnableDisable(summary, opts); | ||
| printUnguardedWarning(summary, previousConsented, opts); | ||
| if (confineMarkers !== void 0) printConfineNotice(confineMarkers, opts); | ||
| printRestartReminder(opts); | ||
| } | ||
| async function printEnableDryRun(opts) { | ||
| const deps = buildDeps({ allowUnguarded: opts.allowUnguarded }); | ||
| const status = await statusAcrossClients(deps); | ||
| const confineNote = opts.confine === "standard" ? " (with OS confinement)" : ""; | ||
| opts.write(`Dry-run: planned wraps${confineNote} | ||
| `); | ||
| for (const c of status.clients) { | ||
| if (opts.client !== void 0 && c.clientId !== opts.client) continue; | ||
| const candidates = c.servers.filter((s) => { | ||
| if (opts.server !== void 0 && s.name !== opts.server) return false; | ||
| return !s.wrapped; | ||
| }); | ||
| opts.write(` ${CLIENT_LABELS[c.clientId]}: would wrap ${candidates.length} server(s) | ||
| `); | ||
| for (const s of candidates) opts.write(` + ${s.name} | ||
| `); | ||
| } | ||
| } | ||
| async function collectConfineTargets(opts) { | ||
| const targets = /* @__PURE__ */ new Map(); | ||
| let clients; | ||
| try { | ||
| clients = await detectInstalledClients(); | ||
| } catch { | ||
| return targets; | ||
| } | ||
| if (opts.client !== void 0) clients = clients.filter((c) => c === opts.client); | ||
| for (const clientId of clients) { | ||
| let entries; | ||
| try { | ||
| entries = await getAdapter(clientId).read(getConfigPath(clientId)); | ||
| } catch { | ||
| continue; | ||
| } | ||
| for (const [name, entry] of Object.entries(entries)) { | ||
| if (opts.server !== void 0 && name !== opts.server) continue; | ||
| if (!entry.command || isWrapped(entry)) continue; | ||
| if (!targets.has(name)) targets.set(name, { command: entry.command, args: entry.args }); | ||
| } | ||
| } | ||
| return targets; | ||
| } | ||
| async function computeConfineMarkers(opts) { | ||
| const targets = await collectConfineTargets(opts); | ||
| if (targets.size === 0) return /* @__PURE__ */ new Map(); | ||
| let store; | ||
| try { | ||
| store = await readConfineStore(); | ||
| } catch (err) { | ||
| throw new Error( | ||
| `cannot read the confine store (~/.mcpm/guard-confine.yaml): ${err.message} Review it for unauthorized changes; if you edited it intentionally, restore or remove it. Refusing to enroll \u2014 the store was left untouched.` | ||
| ); | ||
| } | ||
| const { markers, storeToWrite } = await resolveConfineMarkers(targets, store, opts); | ||
| if (storeToWrite !== null) await writeConfineStore(storeToWrite); | ||
| return markers; | ||
| } | ||
| async function resolveConfineMarkers(targets, store, opts) { | ||
| const markers = /* @__PURE__ */ new Map(); | ||
| const home = os.homedir(); | ||
| const sandboxRoot = await confineSandboxRoot(); | ||
| const tmpDir = os.tmpdir(); | ||
| const capturedAt = (/* @__PURE__ */ new Date()).toISOString(); | ||
| let next = store; | ||
| let added = 0; | ||
| for (const [name, target] of targets) { | ||
| const existing = Object.hasOwn(store.servers, name) ? store.servers[name] : void 0; | ||
| if (existing !== void 0) { | ||
| markers.set(name, { | ||
| profileHash: hashConfineProfile(existing), | ||
| required: existing.require_confine | ||
| }); | ||
| continue; | ||
| } | ||
| const profile = deriveDefaultProfile({ | ||
| serverName: name, | ||
| command: target.command, | ||
| args: target.args, | ||
| home, | ||
| sandboxRoot, | ||
| tmpDir, | ||
| capturedAt | ||
| }); | ||
| next = withProfile(next, name, profile); | ||
| added += 1; | ||
| markers.set(name, { | ||
| profileHash: hashConfineProfile(profile), | ||
| required: profile.require_confine | ||
| }); | ||
| } | ||
| return { markers, storeToWrite: added > 0 ? next : null }; | ||
| } | ||
| function printConfineNotice(confineMarkers, opts) { | ||
| if (confineMarkers.size === 0) { | ||
| opts.write("\nOS confinement: no unwrapped stdio servers to enroll.\n"); | ||
| return; | ||
| } | ||
| opts.write( | ||
| ` | ||
| \u{1F512} ${confineMarkers.size} server(s) enrolled in OS confinement (standard tier). Run \`mcpm guard doctor-confine\` for status. | ||
| ` | ||
| ); | ||
| if (!isConfineBackendAvailable()) { | ||
| opts.write( | ||
| chalk.yellow( | ||
| "\u26A0 No OS sandbox backend on this platform \u2014 enrolled servers run UNCONFINED until a backend is present (they are NOT blocked; a require_confine server would fail closed)." | ||
| ) + "\n" | ||
| ); | ||
| } | ||
| } | ||
| async function runDoctorConfineCommand(opts) { | ||
| const backendAvailable = isConfineBackendAvailable(); | ||
| let servers = []; | ||
| let storeError; | ||
| try { | ||
| const store = await readConfineStore(); | ||
| servers = Object.entries(store.servers).map(([name, p]) => ({ | ||
| name, | ||
| tier: p.tier, | ||
| net: p.net, | ||
| requireConfine: p.require_confine | ||
| })); | ||
| } catch (err) { | ||
| storeError = err.message; | ||
| } | ||
| opts.write( | ||
| opts.json === true ? renderDoctorConfineJson(backendAvailable, servers, storeError) : renderDoctorConfineText(backendAvailable, servers, storeError) | ||
| ); | ||
| } | ||
| function renderDoctorConfineJson(backendAvailable, servers, storeError) { | ||
| return JSON.stringify( | ||
| { | ||
| platform: process.platform, | ||
| backendAvailable, | ||
| sandboxExecPath: process.platform === "darwin" ? SANDBOX_EXEC_PATH : null, | ||
| // sanitize: an OS error message can carry control chars / ANSI (parity | ||
| // with the text branch, which already sanitizes). | ||
| storeError: storeError !== void 0 ? sanitizeForTerminal(storeError) : null, | ||
| servers | ||
| }, | ||
| null, | ||
| 2 | ||
| ) + "\n"; | ||
| } | ||
| function renderDoctorConfineText(backendAvailable, servers, storeError) { | ||
| const out = ["mcpm guard doctor-confine", ""]; | ||
| out.push(` platform : ${process.platform}`); | ||
| let backendLine = ` sandbox backend : ${backendAvailable ? "available" : "UNAVAILABLE"}`; | ||
| if (process.platform === "darwin") backendLine += ` (${SANDBOX_EXEC_PATH})`; | ||
| out.push(backendLine); | ||
| if (!backendAvailable) { | ||
| out.push( | ||
| " \u2192 enrolled servers run UNCONFINED here (hybrid posture); a require_confine server fails closed." | ||
| ); | ||
| } | ||
| out.push(""); | ||
| if (storeError !== void 0) { | ||
| out.push(` \u26A0 could not read the confine store: ${sanitizeForTerminal(storeError)}`, ""); | ||
| return out.join("\n"); | ||
| } | ||
| if (servers.length === 0) { | ||
| out.push(" No servers enrolled in confinement. Enroll with `mcpm guard enable --confine`.", ""); | ||
| return out.join("\n"); | ||
| } | ||
| out.push(` Enrolled servers (${servers.length}):`); | ||
| for (const s of servers) { | ||
| out.push(` ${sanitizeForTerminal(s.name)} \u2014 tier=${s.tier} net=${s.net} require_confine=${s.requireConfine}`); | ||
| } | ||
| out.push("", " Run `mcpm guard status` for per-client wrap state.", ""); | ||
| return out.join("\n"); | ||
| } | ||
| function printUnguardedWarning(summary, previousConsented, opts) { | ||
| const current = [ | ||
| ...new Set( | ||
| summary.clients.flatMap( | ||
| (c) => c.servers.filter((s) => s.status === "unguarded").map((s) => s.name) | ||
| ) | ||
| ) | ||
| ].sort(); | ||
| if (current.length === 0) return; | ||
| if (isNewUnguarded(current, previousConsented)) { | ||
| const prev = new Set(previousConsented); | ||
| const newlyConsented = current.filter((n) => !prev.has(n)); | ||
| const alreadyCount = current.length - newlyConsented.length; | ||
| opts.write( | ||
| chalk.yellow( | ||
| "\n\u26A0 UNGUARDED: the following server(s) run WITHOUT runtime inspection \u2014 the guard relay cannot wrap a non-stdio (URL/HTTP) transport:" | ||
| ) + "\n" | ||
| ); | ||
| for (const name of newlyConsented) opts.write(` \u26A0 ${sanitizeForTerminal(name)} | ||
| `); | ||
| if (alreadyCount > 0) { | ||
| opts.write(` (+${alreadyCount} previously consented) | ||
| `); | ||
| } | ||
| opts.write( | ||
| "This grants consent to run them UNGUARDED \u2014 it does NOT add protection. The only true fix is a streamable-HTTP relay (not yet implemented). Future `enable` runs stay quiet unless a NEW unguarded server appears.\n" | ||
| ); | ||
| } else { | ||
| opts.write( | ||
| ` | ||
| ${current.length} server(s) running unguarded (previously consented): ${current.map((n) => sanitizeForTerminal(n)).join(", ")} | ||
| ` | ||
| ); | ||
| } | ||
| } | ||
| async function runDisableCommand(opts) { | ||
| const deps = buildDeps(); | ||
| const summary = await disableGuardAcrossClients(deps, opts); | ||
| printEnableDisable(summary, opts); | ||
| printRestartReminder(opts); | ||
| await warnUnresolvablePlaceholders(opts); | ||
| } | ||
| async function warnUnresolvablePlaceholders(opts) { | ||
| let clients; | ||
| try { | ||
| clients = await detectInstalledClients(); | ||
| } catch { | ||
| return; | ||
| } | ||
| const affected = []; | ||
| for (const clientId of clients) { | ||
| if (opts.client !== void 0 && clientId !== opts.client) continue; | ||
| let entries; | ||
| try { | ||
| entries = await getAdapter(clientId).read(getConfigPath(clientId)); | ||
| } catch (err) { | ||
| if (err.code !== "ENOENT") { | ||
| opts.write( | ||
| ` (could not read ${CLIENT_LABELS[clientId]} config: ${sanitizeForTerminal(err.message)}) | ||
| ` | ||
| ); | ||
| } | ||
| continue; | ||
| } | ||
| for (const [name, entry] of Object.entries(entries)) { | ||
| if (opts.server !== void 0 && name !== opts.server) continue; | ||
| const keys = placeholderEnvKeys(entry.env); | ||
| if (keys.length > 0) affected.push({ client: clientId, server: name, keys }); | ||
| } | ||
| } | ||
| if (affected.length === 0) return; | ||
| opts.write( | ||
| "\n\x1B[33mwarning: these servers reference encrypted secrets (mcpm:keychain:\u2026) that only resolve while mcpm guard is enabled. Without guard they receive the literal placeholder and will fail to start:\x1B[0m\n" | ||
| ); | ||
| for (const a of affected) { | ||
| opts.write( | ||
| ` - ${sanitizeForTerminal(a.server)} (${CLIENT_LABELS[a.client]}): ${formatAffectedKeys(a.keys)} | ||
| ` | ||
| ); | ||
| } | ||
| opts.write( | ||
| "Re-enable with `mcpm guard enable`, or replace those env values with plaintext.\n" | ||
| ); | ||
| } | ||
| function formatAffectedKeys(keys) { | ||
| return keys.map((k) => sanitizeForTerminal(k)).join(", "); | ||
| } | ||
| async function runStatusCommand(opts) { | ||
| const deps = buildDeps(); | ||
| const status = await statusAcrossClients(deps); | ||
| if (status.clients.length === 0) { | ||
| if (opts.helpFallback) { | ||
| opts.helpFallback(); | ||
| return; | ||
| } | ||
| opts.write("No MCP clients detected.\n"); | ||
| return; | ||
| } | ||
| if (status.totalWrapped === 0 && opts.helpFallback) { | ||
| opts.helpFallback(); | ||
| return; | ||
| } | ||
| printStatus(status, opts); | ||
| } | ||
| function printEnableDisable(summary, opts) { | ||
| const verb = summary.action === "enable" ? "wrapped" : "unwrapped"; | ||
| opts.write(`mcpm guard ${summary.action}: ${summary.totalChanged} ${verb}, `); | ||
| opts.write(`${summary.totalSkipped} skipped`); | ||
| if (summary.totalUnguarded > 0) { | ||
| opts.write(`, ${summary.totalUnguarded} unguarded`); | ||
| } | ||
| opts.write(`, ${summary.errors} error(s) | ||
| `); | ||
| for (const client of summary.clients) { | ||
| printClientReport(client, opts); | ||
| } | ||
| } | ||
| function printClientReport(report, opts) { | ||
| opts.write(` ${CLIENT_LABELS[report.clientId]}: | ||
| `); | ||
| if (report.error !== void 0) { | ||
| opts.write(` error: ${sanitizeForTerminal(report.error)} | ||
| `); | ||
| return; | ||
| } | ||
| if (report.servers.length === 0) { | ||
| opts.write(` (no servers) | ||
| `); | ||
| return; | ||
| } | ||
| for (const s of report.servers) { | ||
| const symbol = s.status === "wrapped" ? "+" : s.status === "unwrapped" ? "-" : s.status === "unguarded" ? "\u26A0" : "\xB7"; | ||
| const reason = s.reason !== void 0 ? ` (${sanitizeForTerminal(s.reason)})` : ""; | ||
| opts.write(` ${symbol} ${sanitizeForTerminal(s.name)}${reason} | ||
| `); | ||
| } | ||
| } | ||
| function printStatus(status, opts) { | ||
| opts.write(`mcpm guard status: ${status.totalWrapped} wrapped, ${status.totalUnwrapped} unwrapped | ||
| `); | ||
| for (const c of status.clients) { | ||
| opts.write(` ${CLIENT_LABELS[c.clientId]}: ${c.wrapped} wrapped / ${c.unwrapped} unwrapped | ||
| `); | ||
| if (c.error !== void 0) { | ||
| opts.write(` error: ${sanitizeForTerminal(c.error)} | ||
| `); | ||
| continue; | ||
| } | ||
| for (const s of c.servers) { | ||
| const marker = s.wrapped ? "+" : s.unguarded ? "\u26A0 UNGUARDED" : "\xB7"; | ||
| opts.write(` ${marker} ${sanitizeForTerminal(s.name)} | ||
| `); | ||
| } | ||
| } | ||
| } | ||
| function printRestartReminder(opts) { | ||
| opts.write( | ||
| "\n\u2192 Restart your IDE (Claude Desktop / Cursor / VS Code / Windsurf) for changes to take effect.\n" | ||
| ); | ||
| } | ||
| async function runCleanupCommand(opts) { | ||
| const deps = buildDeps(); | ||
| const status = await statusAcrossClients(deps); | ||
| const installedServerNames = /* @__PURE__ */ new Set(); | ||
| for (const c of status.clients) { | ||
| for (const s of c.servers) installedServerNames.add(s.name); | ||
| } | ||
| const { readPins, writePins, clearServerPins, PinsIntegrityError } = await import("./pins-ETT4XWEP.js"); | ||
| let pins; | ||
| try { | ||
| pins = await readPins(); | ||
| } catch (err) { | ||
| if (err instanceof PinsIntegrityError) { | ||
| opts.write( | ||
| `mcpm guard cleanup: cannot read ~/.mcpm/pins.json \u2014 integrity check failed. | ||
| ${err.message} | ||
| Refusing to prune until this is resolved. | ||
| ` | ||
| ); | ||
| } else { | ||
| opts.write( | ||
| `mcpm guard cleanup: cannot read ~/.mcpm/pins.json \u2014 ${err.message} | ||
| Refusing to prune until this is resolved. | ||
| ` | ||
| ); | ||
| } | ||
| return; | ||
| } | ||
| const orphanPinned = []; | ||
| for (const serverName of Object.keys(pins.servers)) { | ||
| if (!installedServerNames.has(serverName)) orphanPinned.push(serverName); | ||
| } | ||
| if (orphanPinned.length === 0) { | ||
| opts.write("mcpm guard cleanup: nothing to prune (0 orphan pins, 0 orphan wraps).\n"); | ||
| return; | ||
| } | ||
| opts.write(`mcpm guard cleanup: ${orphanPinned.length} orphan pin entr${orphanPinned.length === 1 ? "y" : "ies"} found: | ||
| `); | ||
| for (const s of orphanPinned) opts.write(` - ${sanitizeForTerminal(s)} | ||
| `); | ||
| if (!opts.apply) { | ||
| opts.write("\nDry run. Re-run with --yes to prune.\n"); | ||
| return; | ||
| } | ||
| let next = pins; | ||
| for (const serverName of orphanPinned) next = clearServerPins(next, serverName); | ||
| await writePins(next); | ||
| opts.write(` | ||
| Pruned ${orphanPinned.length} orphan pin entr${orphanPinned.length === 1 ? "y" : "ies"} from ~/.mcpm/pins.json. | ||
| `); | ||
| } | ||
| export { | ||
| computeConfineMarkers, | ||
| formatAffectedKeys, | ||
| printUnguardedWarning, | ||
| runCleanupCommand, | ||
| runDisableCommand, | ||
| runDoctorConfineCommand, | ||
| runEnableCommand, | ||
| runStatusCommand | ||
| }; | ||
| //# sourceMappingURL=cli-JBJQ3BY2.js.map |
Sorry, the diff of this file is too big to display
| #!/usr/bin/env node | ||
| import { | ||
| activeSecretBackend, | ||
| applyKeychainSecrets, | ||
| deleteSecret, | ||
| deriveKeychainId, | ||
| getSecret, | ||
| listAll, | ||
| migrateToKeychain, | ||
| parsePlaceholder, | ||
| placeholderEnvKeys, | ||
| resolveEnvPlaceholders, | ||
| setSecret, | ||
| setSecrets, | ||
| toPlaceholder | ||
| } from "./chunk-GZ3WCRLG.js"; | ||
| import "./chunk-3X76P3FG.js"; | ||
| export { | ||
| activeSecretBackend, | ||
| applyKeychainSecrets, | ||
| deleteSecret, | ||
| deriveKeychainId, | ||
| getSecret, | ||
| listAll, | ||
| migrateToKeychain, | ||
| parsePlaceholder, | ||
| placeholderEnvKeys, | ||
| resolveEnvPlaceholders, | ||
| setSecret, | ||
| setSecrets, | ||
| toPlaceholder | ||
| }; | ||
| //# sourceMappingURL=keychain-FSY3TEBD.js.map |
| {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]} |
| #!/usr/bin/env node | ||
| import { | ||
| buildDriftFinding, | ||
| buildHandshakeDriftFinding, | ||
| classifyDrift, | ||
| classifyHandshakeDrift, | ||
| inspectForDrift, | ||
| inspectHandshakeForDrift | ||
| } from "./chunk-5W5Z3VZG.js"; | ||
| import { | ||
| PolicyIntegrityError, | ||
| expireStale, | ||
| readPolicy | ||
| } from "./chunk-CYYYMOUS.js"; | ||
| import { | ||
| hasToolsList, | ||
| inspectFrame, | ||
| mergeInspect, | ||
| withReplyToOrigin | ||
| } from "./chunk-XLPT6EJQ.js"; | ||
| import { | ||
| hashConfineProfile, | ||
| loadProfile | ||
| } from "./chunk-544DEV2D.js"; | ||
| import { | ||
| OWASP_MCP_TOP_10 | ||
| } from "./chunk-4ANBMGU5.js"; | ||
| import { | ||
| fieldHashesOf, | ||
| handshakeCapabilityKeys, | ||
| handshakeFieldHashesOf, | ||
| hashHandshake, | ||
| hashToolDefinition, | ||
| lookupHandshake, | ||
| readPins, | ||
| writePins | ||
| } from "./chunk-DDCTUMSZ.js"; | ||
| import { | ||
| hashOriginalEntry, | ||
| isConfineBackendAvailable, | ||
| wrapForConfinement | ||
| } from "./chunk-WYSMWP2R.js"; | ||
| import "./chunk-OIFKZA4V.js"; | ||
| import { | ||
| sanitizeForTerminal | ||
| } from "./chunk-FEXJHHDM.js"; | ||
| import { | ||
| resolveEnvPlaceholders | ||
| } from "./chunk-GZ3WCRLG.js"; | ||
| import { | ||
| getStorePath | ||
| } from "./chunk-3X76P3FG.js"; | ||
| import { | ||
| ACTION_RANK, | ||
| defaultActionForFinding, | ||
| inspectMessage | ||
| } from "./chunk-WT6V33F2.js"; | ||
| // src/guard/relay.ts | ||
| import { spawn } from "child_process"; | ||
| import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"; | ||
| var GUARD_BLOCK_ERROR_CODE = -32099; | ||
| function makeBlockResponse(blocked, result) { | ||
| if (!("id" in blocked) || blocked.id === void 0) return null; | ||
| const finding = result.findings[0]; | ||
| return { | ||
| jsonrpc: "2.0", | ||
| id: blocked.id, | ||
| error: { | ||
| code: GUARD_BLOCK_ERROR_CODE, | ||
| message: "BLOCKED by mcpm-guard", | ||
| data: finding ? { | ||
| signature_id: finding.signature_id, | ||
| category: finding.category, | ||
| severity: finding.severity, | ||
| matched_text_excerpt: finding.matched_text_excerpt, | ||
| remediation: finding.remediation | ||
| } : void 0 | ||
| } | ||
| }; | ||
| } | ||
| var SAFE_ENV_PASSTHROUGH = /* @__PURE__ */ new Set([ | ||
| "PATH", | ||
| "HOME", | ||
| "TMPDIR", | ||
| "TEMP", | ||
| "TMP", | ||
| "LANG", | ||
| "LC_ALL", | ||
| "USER", | ||
| "SHELL" | ||
| ]); | ||
| function buildSafeEnv(source = process.env) { | ||
| const out = {}; | ||
| for (const [k, v] of Object.entries(source)) { | ||
| if (SAFE_ENV_PASSTHROUGH.has(k) || k.startsWith("LC_")) out[k] = v; | ||
| } | ||
| return out; | ||
| } | ||
| var MAX_BUFFER_BYTES = 64 * 1024 * 1024; | ||
| function startRelay(opts) { | ||
| const env = opts.env ?? buildSafeEnv(); | ||
| const child = opts.spawnChild ? opts.spawnChild(opts.command, opts.args, env) : spawn(opts.command, [...opts.args], { | ||
| env, | ||
| stdio: ["pipe", "pipe", "inherit"] | ||
| // stderr passthrough — preserves IDE diagnostics | ||
| }); | ||
| const forwardSignal = (sig) => { | ||
| if (!child.killed) child.kill(sig); | ||
| }; | ||
| let settled = false; | ||
| let resolveExit; | ||
| const exit = new Promise((resolve) => { | ||
| resolveExit = resolve; | ||
| }); | ||
| child.on("error", (err) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| process.off("SIGTERM", forwardSignal); | ||
| process.off("SIGINT", forwardSignal); | ||
| const code = err.code ?? "SPAWN-FAILED"; | ||
| opts.onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "child->parent", | ||
| action: "block", | ||
| findings: [ | ||
| { | ||
| signature_id: "spawn-failure", | ||
| category: "RELAY", | ||
| severity: "critical", | ||
| target: "tool_response", | ||
| matched_text_excerpt: `${code}: ${err.message}`, | ||
| remediation: "The wrapped MCP server binary failed to start. Verify the command exists and is executable." | ||
| } | ||
| ] | ||
| }); | ||
| process.stderr.write(`[mcpm-guard] SPAWN-FAILED ${opts.command}: ${code} | ||
| `); | ||
| child.stdout?.destroy(); | ||
| child.stdin?.destroy(); | ||
| resolveExit(1); | ||
| }); | ||
| child.stdin?.on("error", (err) => { | ||
| const code = err.code; | ||
| if (code !== "EPIPE" && code !== "ERR_STREAM_DESTROYED") { | ||
| opts.onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "parent->child", | ||
| action: "warn", | ||
| findings: [] | ||
| }); | ||
| } | ||
| }); | ||
| const writeToChild = (bytes) => { | ||
| if (child.stdin && !child.stdin.destroyed) child.stdin.write(bytes); | ||
| }; | ||
| wireDirection({ | ||
| source: opts.parentIn, | ||
| target: writeToChild, | ||
| targetEnd: () => child.stdin?.end(), | ||
| parentOut: opts.parentOut, | ||
| inspect: opts.inspectParentRequest, | ||
| direction: "parent->child", | ||
| onEvent: opts.onEvent, | ||
| // Symmetry only — a parent-INITIATED block replies to the client (parentOut), | ||
| // so this is unused for this direction (no replyToOrigin on parent requests). | ||
| replyToSource: (bytes) => opts.parentOut.write(bytes) | ||
| }); | ||
| if (child.stdout) { | ||
| wireDirection({ | ||
| source: child.stdout, | ||
| target: (bytes) => opts.parentOut.write(bytes), | ||
| targetEnd: () => void 0, | ||
| // never end parentOut on child exit | ||
| parentOut: opts.parentOut, | ||
| inspect: opts.inspectChildResponse, | ||
| direction: "child->parent", | ||
| onEvent: opts.onEvent, | ||
| // H7: a blocked server-INITIATED request (sampling/elicitation) errors | ||
| // back to the SERVER (child.stdin), not the client. | ||
| replyToSource: writeToChild | ||
| }); | ||
| } | ||
| process.on("SIGTERM", forwardSignal); | ||
| process.on("SIGINT", forwardSignal); | ||
| child.on("exit", (code) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| process.off("SIGTERM", forwardSignal); | ||
| process.off("SIGINT", forwardSignal); | ||
| resolveExit(code ?? 0); | ||
| }); | ||
| return { child, exit }; | ||
| } | ||
| function wireDirection(w) { | ||
| const buffer = new ReadBuffer(); | ||
| let bufferedBytes = 0; | ||
| w.source.on("data", (chunk) => { | ||
| bufferedBytes += chunk.byteLength; | ||
| if (bufferedBytes > MAX_BUFFER_BYTES) { | ||
| w.onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: w.direction, | ||
| action: "block", | ||
| findings: [] | ||
| }); | ||
| w.source.destroy(); | ||
| return; | ||
| } | ||
| buffer.append(chunk); | ||
| let msg; | ||
| try { | ||
| msg = buffer.readMessage(); | ||
| } catch { | ||
| w.onEvent?.(malformedFrameEvent(w.direction)); | ||
| w.source.destroy(); | ||
| return; | ||
| } | ||
| while (msg !== null) { | ||
| bufferedBytes = 0; | ||
| const decision = w.inspect?.(msg); | ||
| if (decision?.action === "block") { | ||
| logEvent(decision, w.direction, w.onEvent); | ||
| const errResp = makeBlockResponse(msg, decision); | ||
| if (errResp !== null) { | ||
| if (decision.replyToOrigin === true) w.replyToSource(serializeMessage(errResp)); | ||
| else w.parentOut.write(serializeMessage(errResp)); | ||
| } | ||
| } else { | ||
| logEvent(decision, w.direction, w.onEvent); | ||
| w.target(serializeMessage(msg)); | ||
| } | ||
| try { | ||
| msg = buffer.readMessage(); | ||
| } catch { | ||
| w.onEvent?.(malformedFrameEvent(w.direction)); | ||
| w.source.destroy(); | ||
| return; | ||
| } | ||
| } | ||
| }); | ||
| w.source.on("end", () => { | ||
| w.targetEnd(); | ||
| }); | ||
| } | ||
| function malformedFrameEvent(direction) { | ||
| return { | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction, | ||
| action: "block", | ||
| findings: [ | ||
| { | ||
| signature_id: "malformed-frame", | ||
| category: "RELAY", | ||
| severity: "critical", | ||
| target: "tool_response", | ||
| matched_text_excerpt: "malformed JSON-RPC frame on stdio", | ||
| remediation: "The wrapped MCP server emitted a non-JSON-RPC line (e.g. a startup banner). It must write only JSON-RPC frames to stdout." | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| function logEvent(result, direction, onEvent) { | ||
| if (!result || result.findings.length === 0) return; | ||
| onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction, | ||
| action: result.action, | ||
| findings: result.findings | ||
| }); | ||
| } | ||
| // src/guard/event-log.ts | ||
| import { appendFile, mkdir } from "fs/promises"; | ||
| import path from "path"; | ||
| var EVENT_LOG_FILENAME = "guard-events.jsonl"; | ||
| var _warnedOnFailure = false; | ||
| async function eventLogPath() { | ||
| return path.join(await getStorePath(), EVENT_LOG_FILENAME); | ||
| } | ||
| function buildEventLogEntry(event, serverName) { | ||
| return { | ||
| ts: event.ts, | ||
| server_name: sanitizeForTerminal(serverName), | ||
| direction: event.direction, | ||
| action: event.action, | ||
| findings: event.findings.map((f) => ({ | ||
| signature_id: f.signature_id, | ||
| category: f.category, | ||
| severity: f.severity, | ||
| target: f.target, | ||
| matched_text_excerpt: f.matched_text_excerpt | ||
| })) | ||
| }; | ||
| } | ||
| async function appendEvent(event, serverName) { | ||
| try { | ||
| const filePath = await eventLogPath(); | ||
| await mkdir(path.dirname(filePath), { recursive: true, mode: 448 }); | ||
| const line = `${JSON.stringify(buildEventLogEntry(event, serverName))} | ||
| `; | ||
| await appendFile(filePath, line, { encoding: "utf-8", mode: 384 }); | ||
| } catch (err) { | ||
| if (!_warnedOnFailure) { | ||
| _warnedOnFailure = true; | ||
| process.stderr.write( | ||
| `[mcpm-guard] event log write failed (logging will continue silently): ${err instanceof Error ? err.message : String(err)} | ||
| ` | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| // src/guard/confine/decide.ts | ||
| function decideConfine(input) { | ||
| const { profile, markerHash, markerRequired, backendAvailable } = input; | ||
| const mustConfine = markerRequired || profile?.require_confine === true; | ||
| if (profile !== null) { | ||
| if (markerHash === null) { | ||
| return mustConfine ? { action: "fail-closed", reason: "confine marker stripped on a required server", event: "confine-marker-stripped" } : { action: "unconfined", reason: "confine marker stripped", event: "confine-marker-stripped" }; | ||
| } | ||
| if (hashConfineProfile(profile) !== markerHash) { | ||
| return { action: "fail-closed", reason: "confine profile hash mismatch (tamper)", event: "confine-hash-mismatch" }; | ||
| } | ||
| if (!backendAvailable) { | ||
| return mustConfine ? { action: "fail-closed", reason: "no confine backend on a required server", event: "confine-backend-missing" } : { action: "unconfined", reason: "no confine backend on this platform", event: "confine-backend-missing" }; | ||
| } | ||
| return { action: "confine", reason: "confined", event: "confine-applied" }; | ||
| } | ||
| if (markerRequired) { | ||
| return { action: "fail-closed", reason: "confine required but no stored profile (store missing?)", event: "confine-profile-missing" }; | ||
| } | ||
| if (markerHash !== null) { | ||
| return { action: "unconfined", reason: "confine marker present but no stored profile", event: "confine-profile-missing" }; | ||
| } | ||
| return { action: "unconfined", reason: "not confined" }; | ||
| } | ||
| // src/guard/run-inner.ts | ||
| var SIGNATURE_LIST_VERSION = "owasp-mcp-top-10@v0.5.0"; | ||
| function applyPolicy(result, policy) { | ||
| const overrides = policy.signature_overrides ?? []; | ||
| if (overrides.length === 0) return result; | ||
| const byId = new Map(overrides.map((o) => [o.id, o])); | ||
| let highest = "pass"; | ||
| const kept = []; | ||
| for (const f of result.findings) { | ||
| const o = byId.get(f.signature_id); | ||
| let perFindingAction; | ||
| if (o === void 0) { | ||
| perFindingAction = defaultActionForFinding(f); | ||
| kept.push(f); | ||
| } else if (o.action === "ignore") { | ||
| continue; | ||
| } else if (o.action === "log_only") { | ||
| perFindingAction = "pass"; | ||
| kept.push(f); | ||
| } else { | ||
| perFindingAction = o.action; | ||
| kept.push(f); | ||
| } | ||
| if (ACTION_RANK[perFindingAction] > ACTION_RANK[highest]) highest = perFindingAction; | ||
| } | ||
| return withReplyToOrigin({ action: highest, findings: kept }, result.replyToOrigin === true); | ||
| } | ||
| function confineGuardEvent(event, reason, action, severity) { | ||
| return { | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "parent->child", | ||
| action, | ||
| findings: [ | ||
| { | ||
| signature_id: event, | ||
| category: "CONFINE", | ||
| severity, | ||
| target: "tool_response", | ||
| matched_text_excerpt: reason, | ||
| remediation: "See docs/GUARD.md \u2014 `mcpm guard confine`." | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| async function runInner(parsed) { | ||
| const safeName = sanitizeForTerminal(parsed.serverName); | ||
| if (typeof parsed.origHash === "string" && parsed.origHash.length > 0) { | ||
| const recomputed = hashOriginalEntry(parsed.command, parsed.args, parsed.declaredEnvKeys); | ||
| if (recomputed !== parsed.origHash) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] ORIG-HASH-MISMATCH ${safeName}: the wrapped command/args/declared-env no longer match the integrity hash embedded at \`mcpm guard enable\` time \u2014 the client config entry may have been edited or tampered with. Starting anyway (advisory); a future mcpm release will refuse to start on mismatch. Review ~/.mcpm/guard-events.jsonl, and if you changed the entry on purpose re-run \`mcpm guard enable\` to re-pin it. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| { | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "parent->child", | ||
| action: "warn", | ||
| findings: [ | ||
| { | ||
| signature_id: "orig-hash-mismatch", | ||
| category: "RELAY", | ||
| severity: "high", | ||
| target: "tool_response", | ||
| matched_text_excerpt: "wrap-marker integrity: recomputed hash != embedded --orig-hash", | ||
| remediation: "Re-run `mcpm guard enable` to re-pin, or restore the original wrapped entry in the client config." | ||
| } | ||
| ] | ||
| }, | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| } | ||
| const logEvent2 = (event) => { | ||
| if (event.action === "block" || event.action === "warn") { | ||
| process.stderr.write( | ||
| `[mcpm-guard] ${event.action.toUpperCase()} ${safeName} ${event.findings.map((f) => f.signature_id).join(",")} | ||
| ` | ||
| ); | ||
| void appendEvent(event, parsed.serverName); | ||
| } | ||
| }; | ||
| let pinsSnapshot; | ||
| try { | ||
| pinsSnapshot = await readPins(); | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] PINS-READ-ERROR: ${safeName} could not load ~/.mcpm/pins.json: ${err.message} | ||
| Refusing to start the relay \u2014 running with rug-pull (schema-drift) protection silently disabled is more dangerous than not starting. Review ~/.mcpm/guard-events.jsonl for unauthorized activity. If you intentionally changed pins.json, run \`mcpm guard reset-integrity\`. | ||
| ` | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| const policy = expireStale( | ||
| await readPolicy().catch((err) => { | ||
| if (err instanceof PolicyIntegrityError) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] POLICY-INTEGRITY-ERROR: ${safeName} ${err.message} | ||
| Falling back to full enforcement (ignoring guard-policy.yaml) for this session. | ||
| ` | ||
| ); | ||
| } else { | ||
| process.stderr.write( | ||
| `[mcpm-guard] POLICY-READ-ERROR: ${err.message} | ||
| ` | ||
| ); | ||
| } | ||
| return {}; | ||
| }) | ||
| ); | ||
| const pausedUntilFuture = policy.paused_until !== void 0 && new Date(policy.paused_until) > /* @__PURE__ */ new Date(); | ||
| const sessionState = { | ||
| firstHashes: /* @__PURE__ */ new Map(), | ||
| revalidationArmed: false, | ||
| handshakeSeenHash: null | ||
| }; | ||
| const baselineForDrift = pinsSnapshot; | ||
| const inspectChild = (msg) => { | ||
| if (pausedUntilFuture) return { action: "pass", findings: [] }; | ||
| if (isToolsListChangedNotification(msg)) { | ||
| sessionState.revalidationArmed = true; | ||
| return { action: "pass", findings: [] }; | ||
| } | ||
| const statelessResult = inspectFrame(msg); | ||
| let driftResult = { action: "pass", findings: [] }; | ||
| if (hasToolsList(msg)) { | ||
| driftResult = inspectForDriftSync(msg, parsed.serverName, baselineForDrift, sessionState); | ||
| void (async () => { | ||
| await inspectForDrift(msg, parsed.serverName, { | ||
| read: () => readPins().catch(() => pinsSnapshot), | ||
| write: writePins, | ||
| signatureListVersion: SIGNATURE_LIST_VERSION | ||
| }); | ||
| pinsSnapshot = await readPins().catch(() => pinsSnapshot); | ||
| })(); | ||
| } else if (isInitializeResult(msg)) { | ||
| driftResult = inspectHandshakeDriftSync(msg, parsed.serverName, baselineForDrift, sessionState); | ||
| void (async () => { | ||
| await inspectHandshakeForDrift(msg, parsed.serverName, { | ||
| read: () => readPins().catch(() => pinsSnapshot), | ||
| write: writePins, | ||
| signatureListVersion: SIGNATURE_LIST_VERSION | ||
| }); | ||
| pinsSnapshot = await readPins().catch(() => pinsSnapshot); | ||
| })(); | ||
| } | ||
| return applyPolicy(mergeInspect(statelessResult, driftResult), policy); | ||
| }; | ||
| const inspectParent = (msg) => { | ||
| if (pausedUntilFuture) return { action: "pass", findings: [] }; | ||
| return applyPolicy(inspectMessage(msg, OWASP_MCP_TOP_10), policy); | ||
| }; | ||
| const baselineEnv = buildSafeEnv(process.env); | ||
| const childEnvSource = { ...baselineEnv }; | ||
| for (const key of parsed.declaredEnvKeys) { | ||
| const value = process.env[key]; | ||
| if (value !== void 0) childEnvSource[key] = value; | ||
| } | ||
| let childEnv; | ||
| try { | ||
| childEnv = await resolveEnvPlaceholders(childEnvSource); | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] SECRET-MISSING ${safeName} ${err.message} | ||
| ` | ||
| ); | ||
| return 1; | ||
| } | ||
| if (parsed.confineProfileHash !== void 0 && !/^[0-9a-f]{64}$/.test(parsed.confineProfileHash)) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-BLOCK ${safeName}: malformed --confine-profile-hash in the wrap marker (the client config entry may be tampered or corrupt). Refusing to start. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent( | ||
| "confine-marker-malformed", | ||
| "malformed confine profile hash", | ||
| "block", | ||
| "critical" | ||
| ), | ||
| parsed.serverName | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| let spawnCommand = parsed.command; | ||
| let spawnArgs = parsed.args; | ||
| let confineProfile = null; | ||
| try { | ||
| confineProfile = await loadProfile(parsed.serverName); | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-STORE-ERROR ${safeName}: ${err.message} | ||
| ` | ||
| ); | ||
| } | ||
| const confineDecision = decideConfine({ | ||
| profile: confineProfile, | ||
| markerHash: parsed.confineProfileHash ?? null, | ||
| markerRequired: parsed.confineRequired === true, | ||
| backendAvailable: isConfineBackendAvailable() | ||
| }); | ||
| if (confineDecision.action === "fail-closed") { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-BLOCK ${safeName}: ${confineDecision.reason}. Refusing to start (this server is marked require-confine). Run \`mcpm guard doctor-confine\` to check the backend, and review ~/.mcpm/guard-events.jsonl. | ||
| ` | ||
| ); | ||
| if (confineDecision.event !== void 0) { | ||
| void appendEvent( | ||
| confineGuardEvent(confineDecision.event, confineDecision.reason, "block", "critical"), | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| process.exit(1); | ||
| } | ||
| if (confineDecision.action === "confine" && confineProfile !== null) { | ||
| const wrapped = wrapForConfinement(confineProfile, parsed.command, parsed.args); | ||
| if (wrapped !== null) { | ||
| spawnCommand = wrapped.command; | ||
| spawnArgs = wrapped.args; | ||
| void appendEvent( | ||
| confineGuardEvent( | ||
| confineDecision.event ?? "confine-applied", | ||
| confineDecision.reason, | ||
| "pass", | ||
| "low" | ||
| ), | ||
| parsed.serverName | ||
| ); | ||
| } else { | ||
| const required = parsed.confineRequired === true || confineProfile.require_confine; | ||
| if (required) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-BLOCK ${safeName}: sandbox backend became unavailable at spawn (require-confine). Refusing to start. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent( | ||
| "confine-backend-missing", | ||
| "backend unavailable at wrap", | ||
| "block", | ||
| "critical" | ||
| ), | ||
| parsed.serverName | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-UNCONFINED ${safeName}: sandbox backend unavailable at wrap \u2014 running unconfined. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent("confine-backend-missing", "backend unavailable at wrap", "warn", "high"), | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| } else if (confineDecision.event !== void 0) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-UNCONFINED ${safeName}: ${confineDecision.reason} \u2014 running unconfined. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent(confineDecision.event, confineDecision.reason, "warn", "high"), | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| const handle = startRelay({ | ||
| command: spawnCommand, | ||
| args: spawnArgs, | ||
| env: childEnv, | ||
| parentIn: process.stdin, | ||
| parentOut: process.stdout, | ||
| inspectChildResponse: inspectChild, | ||
| inspectParentRequest: inspectParent, | ||
| onEvent: logEvent2 | ||
| }); | ||
| return handle.exit; | ||
| } | ||
| function sanitizeLabel(s) { | ||
| return sanitizeForTerminal(s, 128); | ||
| } | ||
| function inspectForDriftSync(msg, serverName, baseline, state) { | ||
| const armed = state.revalidationArmed; | ||
| state.revalidationArmed = false; | ||
| const result = msg.result; | ||
| const tools = Array.isArray(result?.tools) ? result.tools : []; | ||
| const findings = []; | ||
| for (const rawTool of tools) { | ||
| const finding = inspectToolDrift(rawTool, serverName, baseline, state, armed); | ||
| if (finding !== null) findings.push(finding); | ||
| } | ||
| const action = findings.reduce((acc, f) => { | ||
| const a = defaultActionForFinding(f); | ||
| return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc; | ||
| }, "pass"); | ||
| return { action, findings }; | ||
| } | ||
| function inspectToolDrift(rawTool, serverName, baseline, state, armed) { | ||
| if (rawTool === null || typeof rawTool !== "object") return null; | ||
| const tool = rawTool; | ||
| const toolName = typeof tool.name === "string" ? tool.name : null; | ||
| if (toolName === null) return null; | ||
| const fields = { | ||
| description: typeof tool.description === "string" ? tool.description : null, | ||
| schema: tool.inputSchema ?? tool.schema, | ||
| annotations: tool.annotations | ||
| }; | ||
| const liveWhole = hashToolDefinition(fields); | ||
| const liveFields = fieldHashesOf(fields); | ||
| const serverPins = Object.hasOwn(baseline.servers, serverName) ? baseline.servers[serverName] : void 0; | ||
| const pinned = serverPins && Object.hasOwn(serverPins, toolName) ? serverPins[toolName] : void 0; | ||
| const sessionKey = `${serverName}::${toolName}`; | ||
| const firstSeen = state.firstHashes.get(sessionKey); | ||
| if (!armed && firstSeen !== void 0 && firstSeen !== liveWhole) { | ||
| return inSessionDriftFinding(serverName, toolName, firstSeen, liveWhole); | ||
| } | ||
| if (firstSeen === void 0 || armed) state.firstHashes.set(sessionKey, liveWhole); | ||
| if (!pinned || pinned.current_hash === null) return null; | ||
| if (liveWhole === pinned.current_hash) return null; | ||
| const cls = classifyDrift(pinned, liveFields); | ||
| const newDescriptionExcerpt = typeof tool.description === "string" ? sanitizeForTerminal(tool.description, 80) : void 0; | ||
| return buildDriftFinding({ | ||
| cls, | ||
| safeServer: sanitizeLabel(serverName), | ||
| safeTool: sanitizeLabel(toolName), | ||
| expected: pinned.current_hash, | ||
| actual: liveWhole, | ||
| newDescriptionExcerpt | ||
| }); | ||
| } | ||
| function inSessionDriftFinding(serverName, toolName, firstSeen, liveWhole) { | ||
| return { | ||
| signature_id: "schema-drift-in-session", | ||
| category: "OWASP-MCP-1", | ||
| severity: "critical", | ||
| target: "tool_description", | ||
| matched_text_excerpt: `${sanitizeLabel(toolName)}: ${firstSeen.slice(7, 19)}\u2026 \u2192 ${liveWhole.slice(7, 19)}\u2026 (same session)`, | ||
| remediation: `Server "${sanitizeLabel(serverName)}" delivered two different schemas for tool "${sanitizeLabel(toolName)}" in the same session. This is a rug-pull attempt; restart the IDE and reinspect the server's source.` | ||
| }; | ||
| } | ||
| function isToolsListChangedNotification(msg) { | ||
| if (!("method" in msg)) return false; | ||
| if (msg.method !== "notifications/tools/list_changed") return false; | ||
| return !("result" in msg); | ||
| } | ||
| function isInitializeResult(msg) { | ||
| if (!("result" in msg)) return false; | ||
| const result = msg.result; | ||
| return result !== null && typeof result === "object" && typeof result.protocolVersion === "string"; | ||
| } | ||
| function inspectHandshakeDriftSync(msg, serverName, baseline, state) { | ||
| const result = msg.result; | ||
| if (result === null || typeof result !== "object") return { action: "pass", findings: [] }; | ||
| const liveFields = handshakeFieldHashesOf(result); | ||
| const liveCapKeys = handshakeCapabilityKeys(result); | ||
| const liveWhole = hashHandshake(liveFields); | ||
| const seen = state.handshakeSeenHash; | ||
| if (seen !== null && seen !== liveWhole) { | ||
| return warnResult(handshakeInSessionFinding(serverName, seen, liveWhole)); | ||
| } | ||
| if (seen === null) state.handshakeSeenHash = liveWhole; | ||
| const pinned = lookupHandshake(baseline, serverName); | ||
| if (pinned === void 0) return { action: "pass", findings: [] }; | ||
| if (liveWhole === pinned.current_hash || pinned.previous_hashes.includes(liveWhole)) { | ||
| return { action: "pass", findings: [] }; | ||
| } | ||
| const cls = classifyHandshakeDrift(pinned, liveFields, liveCapKeys); | ||
| const findings = buildHandshakeDriftFinding({ | ||
| cls, | ||
| safeServer: sanitizeLabel(serverName) | ||
| }); | ||
| const action = findings.reduce((acc, f) => { | ||
| const a = defaultActionForFinding(f); | ||
| return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc; | ||
| }, "pass"); | ||
| return { action, findings }; | ||
| } | ||
| function warnResult(finding) { | ||
| return { action: defaultActionForFinding(finding), findings: [finding] }; | ||
| } | ||
| function handshakeInSessionFinding(serverName, firstSeen, liveWhole) { | ||
| return { | ||
| signature_id: "handshake-drift-in-session", | ||
| category: "OWASP-MCP-1", | ||
| severity: "high", | ||
| target: "initialize_instructions", | ||
| matched_text_excerpt: `${sanitizeLabel(serverName)}: ${firstSeen.slice(7, 19)}\u2026 \u2192 ${liveWhole.slice(7, 19)}\u2026 (same session)`, | ||
| remediation: `Server "${sanitizeLabel(serverName)}" delivered two different initialize handshakes in the same session \u2014 initialize should occur once. Inspect the wrapped command; this is a warn-only signal and does not block the session.` | ||
| }; | ||
| } | ||
| export { | ||
| applyPolicy, | ||
| inspectForDriftSync, | ||
| inspectHandshakeDriftSync, | ||
| isInitializeResult, | ||
| isToolsListChangedNotification, | ||
| runInner | ||
| }; | ||
| //# sourceMappingURL=run-inner-WH5ZSWBR.js.map |
Sorry, the diff of this file is too big to display
| #!/usr/bin/env node | ||
| import { | ||
| buildDoctorModel, | ||
| execCheckDefault, | ||
| formatMcpEntryCommand, | ||
| makeCheckConfigExists | ||
| } from "./chunk-RUSWATRY.js"; | ||
| import { | ||
| resolveInstallEntry | ||
| } from "./chunk-7QRDQF55.js"; | ||
| import { | ||
| readPins | ||
| } from "./chunk-DDCTUMSZ.js"; | ||
| import "./chunk-E3T224S3.js"; | ||
| import { | ||
| fetchNpmProvenance | ||
| } from "./chunk-RTI2GLYX.js"; | ||
| import "./chunk-WYSMWP2R.js"; | ||
| import "./chunk-OIFKZA4V.js"; | ||
| import "./chunk-FEXJHHDM.js"; | ||
| import "./chunk-F6CHEUGO.js"; | ||
| import { | ||
| nativeTrustScore | ||
| } from "./chunk-LSNEZAFR.js"; | ||
| import "./chunk-UNGY7RTE.js"; | ||
| import "./chunk-W4IAFBUN.js"; | ||
| import "./chunk-2PWW3Q5Q.js"; | ||
| import { | ||
| fetchNpmIntegrity | ||
| } from "./chunk-7RJXJERN.js"; | ||
| import "./chunk-K4U7EXLG.js"; | ||
| import "./chunk-GZ3WCRLG.js"; | ||
| import "./chunk-6R7TL5O2.js"; | ||
| import { | ||
| CLIENT_IDS | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import "./chunk-4QDJ3I7X.js"; | ||
| import "./chunk-3X76P3FG.js"; | ||
| import { | ||
| extractRegistryMeta | ||
| } from "./chunk-U7N6FRYF.js"; | ||
| import "./chunk-WT6V33F2.js"; | ||
| // src/server/index.ts | ||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| // src/server/tools.ts | ||
| import { z } from "zod"; | ||
| var serverName = z.string().min(1).max(256); | ||
| var clientId = z.enum(CLIENT_IDS); | ||
| var NoArgsInput = z.strictObject({}); | ||
| var SearchInput = z.strictObject({ | ||
| query: z.string().min(1).max(200), | ||
| limit: z.number().int().min(1).max(100).optional().default(20) | ||
| }); | ||
| var InstallInput = z.strictObject({ | ||
| name: serverName, | ||
| client: clientId.optional(), | ||
| minTrustScore: z.number().min(0).max(100).optional().default(50) | ||
| }); | ||
| var InfoInput = z.strictObject({ | ||
| name: serverName | ||
| }); | ||
| var ListInput = z.strictObject({ | ||
| client: clientId.optional() | ||
| }); | ||
| var RemoveInput = z.strictObject({ | ||
| name: serverName, | ||
| client: clientId.optional() | ||
| }); | ||
| var SetupInput = z.strictObject({ | ||
| description: z.string().min(1).max(1e3), | ||
| client: clientId.optional(), | ||
| minTrustScore: z.number().min(0).max(100).optional().default(50) | ||
| }); | ||
| var UpInput = z.strictObject({ | ||
| stackFile: z.string().optional().default("mcpm.yaml"), | ||
| profile: z.string().optional(), | ||
| dryRun: z.boolean().optional().default(false) | ||
| }); | ||
| // src/server/handlers.ts | ||
| import path from "path"; | ||
| var SERVER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}$/; | ||
| function validateMcpServerName(name) { | ||
| if (typeof name !== "string" || name.length === 0 || name.length > 256) { | ||
| throw new Error(`Invalid server name: must be a non-empty string under 256 characters.`); | ||
| } | ||
| if (!SERVER_NAME_RE.test(name)) { | ||
| throw new Error( | ||
| `Invalid server name format: "${name}". Expected format: "namespace/server-name" (alphanumeric, dots, hyphens, underscores only).` | ||
| ); | ||
| } | ||
| } | ||
| function computeTrust(entry, deps) { | ||
| const findings = deps.scanTier1(entry); | ||
| return deps.computeTrustScore({ | ||
| findings, | ||
| healthCheckPassed: null, | ||
| hasExternalScanner: false, | ||
| registryMeta: extractRegistryMeta(entry) | ||
| }); | ||
| } | ||
| async function resolveClients(requestedClient, deps) { | ||
| const detected = await deps.detectClients(); | ||
| if (detected.length === 0) { | ||
| throw new Error("No supported AI clients found."); | ||
| } | ||
| if (requestedClient !== void 0) { | ||
| if (!CLIENT_IDS.includes(requestedClient)) { | ||
| throw new Error( | ||
| `Unknown client "${requestedClient}". Valid values: ${CLIENT_IDS.join(", ")}.` | ||
| ); | ||
| } | ||
| const id = requestedClient; | ||
| if (!detected.includes(id)) { | ||
| throw new Error(`Client "${requestedClient}" is not installed.`); | ||
| } | ||
| return [id]; | ||
| } | ||
| return detected; | ||
| } | ||
| async function handleSearch(args, deps) { | ||
| const entries = await deps.registrySearch(args.query, args.limit); | ||
| const servers = entries.map((entry) => { | ||
| const trust = computeTrust(entry, deps); | ||
| return { | ||
| name: entry.server.name, | ||
| description: entry.server.description ?? "", | ||
| version: entry.server.version, | ||
| trustScore: trust.score | ||
| }; | ||
| }); | ||
| return { servers }; | ||
| } | ||
| var DEFAULT_MIN_TRUST_SCORE = 50; | ||
| var HARD_TRUST_FLOOR = 25; | ||
| function effectiveMinTrustScore(requested) { | ||
| return Math.max(requested ?? DEFAULT_MIN_TRUST_SCORE, HARD_TRUST_FLOOR); | ||
| } | ||
| async function handleInstall(args, deps, preResolved) { | ||
| validateMcpServerName(args.name); | ||
| const entry = preResolved?.entry ?? await deps.registryGetServer(args.name); | ||
| const trust = preResolved?.trust ?? computeTrust(entry, deps); | ||
| const minScore = effectiveMinTrustScore(args.minTrustScore); | ||
| const nativeTrust = nativeTrustScore(trust); | ||
| if (nativeTrust.score < minScore) { | ||
| throw new Error( | ||
| `Server "${args.name}" has trust score ${nativeTrust.score}/${nativeTrust.maxPossible} (level: ${trust.level}), which is below the minimum threshold of ${minScore}. ` + (nativeTrust.excludedExternalCredit > 0 ? `An external scanner's ${nativeTrust.excludedExternalCredit} points are excluded from this floor because mcpm cannot verify them. ` : "") + `Install rejected for safety. Use mcpm CLI with --yes to override after manual review.` | ||
| ); | ||
| } | ||
| const clients = await resolveClients(args.client, deps); | ||
| const planned = clients.map((clientId2) => { | ||
| const mcpEntry = resolveInstallEntry(entry, clientId2); | ||
| if (mcpEntry.url !== void 0 && mcpEntry.command === void 0) { | ||
| throw new Error( | ||
| `Server "${args.name}" uses a URL/HTTP transport and runs UNGUARDED (the guard relay only wraps stdio servers). Installing it is not permitted via the MCP surface. Use the mcpm CLI with --allow-unguarded after manual review.` | ||
| ); | ||
| } | ||
| return { | ||
| clientId: clientId2, | ||
| adapter: deps.getAdapter(clientId2), | ||
| configPath: deps.getConfigPath(clientId2), | ||
| mcpEntry | ||
| }; | ||
| }); | ||
| const done = []; | ||
| try { | ||
| for (const p of planned) { | ||
| await p.adapter.addServer(p.configPath, args.name, p.mcpEntry); | ||
| done.push(p); | ||
| } | ||
| await deps.addToStore({ | ||
| name: args.name, | ||
| version: entry.server.version, | ||
| clients: done.map((p) => p.clientId), | ||
| installedAt: (/* @__PURE__ */ new Date()).toISOString() | ||
| }); | ||
| } catch (err) { | ||
| const stranded = await rollbackInstall(done, args.name); | ||
| if (stranded.length > 0) { | ||
| throw new Error( | ||
| `${err instanceof Error ? err.message : String(err)} | ||
| Rollback incomplete: "${args.name}" is STILL INSTALLED in ${stranded.join(", ")}. Remove it with \`mcpm remove ${args.name}\` before retrying.` | ||
| ); | ||
| } | ||
| throw err; | ||
| } | ||
| return { | ||
| installed: true, | ||
| name: args.name, | ||
| version: entry.server.version, | ||
| clients: done.map((p) => p.clientId), | ||
| trustScore: trust | ||
| }; | ||
| } | ||
| async function rollbackInstall(done, name) { | ||
| const stranded = []; | ||
| for (const p of done) { | ||
| try { | ||
| await p.adapter.removeServer(p.configPath, name); | ||
| } catch { | ||
| stranded.push(p.clientId); | ||
| } | ||
| } | ||
| return stranded; | ||
| } | ||
| async function handleInfo(args, deps) { | ||
| validateMcpServerName(args.name); | ||
| const entry = await deps.registryGetServer(args.name); | ||
| const trust = computeTrust(entry, deps); | ||
| return { | ||
| name: entry.server.name, | ||
| description: entry.server.description ?? "", | ||
| version: entry.server.version, | ||
| packages: entry.server.packages.map((p) => ({ | ||
| registryType: p.registryType, | ||
| identifier: p.identifier | ||
| })), | ||
| trustScore: trust | ||
| }; | ||
| } | ||
| async function handleList(args, deps) { | ||
| const clients = await resolveClients(args.client, deps); | ||
| const servers = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| const installed = await adapter.read(configPath); | ||
| for (const [name, entry] of Object.entries(installed)) { | ||
| const command = formatMcpEntryCommand(entry, "unknown"); | ||
| servers.push({ name, client: clientId2, command }); | ||
| } | ||
| } | ||
| return { servers }; | ||
| } | ||
| async function handleRemove(args, deps) { | ||
| validateMcpServerName(args.name); | ||
| const clients = await resolveClients(args.client, deps); | ||
| const removedClients = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| try { | ||
| await adapter.removeServer(configPath, args.name); | ||
| removedClients.push(clientId2); | ||
| } catch { | ||
| } | ||
| } | ||
| if (removedClients.length === 0) { | ||
| throw new Error(`Server "${args.name}" not found in any client config.`); | ||
| } | ||
| try { | ||
| await deps.removeFromStore(args.name); | ||
| } catch { | ||
| } | ||
| return { removed: true, name: args.name, clients: removedClients }; | ||
| } | ||
| async function handleAudit(deps) { | ||
| const clients = await deps.detectClients(); | ||
| const results = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| const installed = await adapter.read(configPath); | ||
| for (const name of Object.keys(installed)) { | ||
| try { | ||
| const entry = await deps.registryGetServer(name); | ||
| const trust = computeTrust(entry, deps); | ||
| results.push({ name, client: clientId2, trustScore: trust }); | ||
| } catch { | ||
| results.push({ | ||
| name, | ||
| client: clientId2, | ||
| trustScore: { score: 0, maxPossible: 80, level: "risky", breakdown: { healthCheck: 0, staticScan: 0, externalScan: 0, registryMeta: 0 } } | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| return { results }; | ||
| } | ||
| async function handleDoctor(deps) { | ||
| return buildDoctorModel({ | ||
| getAdapter: deps.getAdapter, | ||
| getConfigPath: deps.getConfigPath, | ||
| checkConfigExists: makeCheckConfigExists(deps.getConfigPath), | ||
| execCheck: execCheckDefault | ||
| }); | ||
| } | ||
| async function handleSetup(args, deps) { | ||
| if (!args.description.trim()) { | ||
| throw new Error("Could not extract any keywords from empty description."); | ||
| } | ||
| const keywords = extractKeywords(args.description); | ||
| const minScore = Math.max( | ||
| effectiveMinTrustScore(args.minTrustScore), | ||
| DEFAULT_MIN_TRUST_SCORE | ||
| ); | ||
| const installed = []; | ||
| const skipped = []; | ||
| const searchResults = await Promise.all( | ||
| keywords.map( | ||
| (kw) => deps.registrySearch(kw, 5).then((entries) => ({ ok: true, entries })).catch((err) => ({ | ||
| ok: false, | ||
| error: err instanceof Error ? err.message : String(err) | ||
| })) | ||
| ) | ||
| ); | ||
| const seenNames = /* @__PURE__ */ new Set(); | ||
| for (let i = 0; i < keywords.length; i++) { | ||
| const keyword = keywords[i]; | ||
| const outcome = searchResults[i]; | ||
| if (!outcome.ok) { | ||
| skipped.push({ name: keyword, reason: `Registry search failed: ${outcome.error}` }); | ||
| continue; | ||
| } | ||
| const entries = outcome.entries; | ||
| if (entries.length === 0) { | ||
| skipped.push({ name: keyword, reason: `No servers found for "${keyword}"` }); | ||
| continue; | ||
| } | ||
| let bestEntry = null; | ||
| let bestTrust = null; | ||
| for (const entry of entries) { | ||
| if (seenNames.has(entry.server.name)) continue; | ||
| const trust = computeTrust(entry, deps); | ||
| if (bestTrust === null || trust.score > bestTrust.score) { | ||
| bestEntry = entry; | ||
| bestTrust = trust; | ||
| } | ||
| } | ||
| if (bestEntry === null || bestTrust === null) { | ||
| skipped.push({ name: keyword, reason: "All results already installed or duplicated" }); | ||
| continue; | ||
| } | ||
| const bestNative = nativeTrustScore(bestTrust); | ||
| if (bestNative.score < minScore) { | ||
| skipped.push({ | ||
| name: bestEntry.server.name, | ||
| reason: `Trust score ${bestNative.score}/${bestNative.maxPossible} is below minimum ${minScore}` + (bestNative.excludedExternalCredit > 0 ? ` (an external scanner's ${bestNative.excludedExternalCredit} points are excluded \u2014 mcpm cannot verify them)` : "") | ||
| }); | ||
| continue; | ||
| } | ||
| try { | ||
| await handleInstall( | ||
| { name: bestEntry.server.name, client: args.client }, | ||
| deps, | ||
| { entry: bestEntry, trust: bestTrust } | ||
| ); | ||
| seenNames.add(bestEntry.server.name); | ||
| installed.push({ name: bestEntry.server.name, trustScore: bestTrust }); | ||
| } catch (err) { | ||
| skipped.push({ | ||
| name: bestEntry.server.name, | ||
| reason: `Install failed: ${err.message}` | ||
| }); | ||
| } | ||
| } | ||
| const note = installed.length > 0 ? "Restart your AI client to use the newly installed servers." : void 0; | ||
| return { installed, skipped, ...note ? { note } : {} }; | ||
| } | ||
| async function handleMcpUp(args, deps) { | ||
| const stackFile = args.stackFile ?? "mcpm.yaml"; | ||
| const resolved = path.resolve(process.cwd(), stackFile); | ||
| if (resolved !== process.cwd() && !resolved.startsWith(process.cwd() + path.sep)) { | ||
| throw new Error("stackFile must be within the working directory"); | ||
| } | ||
| { | ||
| const { realpath } = await import("fs/promises"); | ||
| try { | ||
| const [realStack, realCwd] = await Promise.all([ | ||
| realpath(resolved), | ||
| realpath(process.cwd()) | ||
| ]); | ||
| if (realStack !== realCwd && !realStack.startsWith(realCwd + path.sep)) { | ||
| throw new Error("stackFile must be within the working directory"); | ||
| } | ||
| } catch (err) { | ||
| const code = err.code ?? ""; | ||
| if (!["ENOENT", "ELOOP", "ENOTDIR"].includes(code)) throw err; | ||
| } | ||
| } | ||
| const { handleUp } = await import("./up-3RX6WQ4D.js"); | ||
| const { writeFile } = await import("fs/promises"); | ||
| const { handleLock } = await import("./lock-3JKW72C5.js"); | ||
| const { RegistryClient } = await import("./client-3RPMRFZL.js"); | ||
| const { scanTier1: st1 } = await import("./tier1-OPUMS3NX.js"); | ||
| const { checkScannerAvailable: csa, scanTier2: st2 } = await import("./tier2-PI43NCHZ.js"); | ||
| const { computeTrustScore: cts } = await import("./trust-score-BAGF67DE.js"); | ||
| const client = new RegistryClient(); | ||
| const outputLines = []; | ||
| const records = []; | ||
| let thrownError; | ||
| try { | ||
| await handleUp( | ||
| { | ||
| stackFile, | ||
| profile: args.profile, | ||
| dryRun: args.dryRun, | ||
| ci: true, | ||
| yes: false, | ||
| // MCP surface lockdown (fixes C, D & H1): never auto-read ambient | ||
| // secrets from process.env OR the working-directory .env file, and never | ||
| // install URL servers (they bypass the registry trust gate). All three | ||
| // default to true on the CLI; the MCP (untrusted-caller) surface opts in | ||
| // to the locked-down behavior. | ||
| allowProcessEnv: false, | ||
| allowUrlServers: false, | ||
| allowEnvFile: false, | ||
| // M2: the batch `up` path must honor the same non-overridable trust floor | ||
| // the single-install MCP tool enforces (issue #24), so a low-trust server | ||
| // an agent could not install via mcpm_install can't slip in via mcpm_up. | ||
| minTrustFloor: HARD_TRUST_FLOOR | ||
| }, | ||
| { | ||
| detectClients: deps.detectClients, | ||
| getAdapter: deps.getAdapter, | ||
| getPath: deps.getConfigPath, | ||
| getServer: (name, version) => client.getServer(name, version), | ||
| scanTier1: st1, | ||
| checkScannerAvailable: csa, | ||
| scanTier2: (name) => st2(name), | ||
| computeTrustScore: cts, | ||
| runLock: async (stackFile2) => { | ||
| await handleLock( | ||
| { stackFile: stackFile2 }, | ||
| { | ||
| getServerVersions: (name) => client.getServerVersions(name), | ||
| getServer: (name, v) => client.getServer(name, v), | ||
| scanTier1: st1, | ||
| checkScannerAvailable: csa, | ||
| scanTier2: (name) => st2(name), | ||
| computeTrustScore: cts, | ||
| writeLockFile: (path2, content) => writeFile(path2, content, { encoding: "utf-8", mode: 384 }), | ||
| fetchNpmIntegrity, | ||
| fetchNpmProvenance: (id, ver, sri) => fetchNpmProvenance(id, ver, { integritySri: sri }), | ||
| output: (text) => outputLines.push(text) | ||
| } | ||
| ); | ||
| }, | ||
| // Issue #22: never auto-confirm on the MCP (no-human-in-loop) surface. | ||
| // The previous `async () => true` blanket-approved every confirmation, | ||
| // including strict-mode *removals* of servers not in mcpm.yaml — a | ||
| // prompt-injected agent could silently mutate client configs. Refusing | ||
| // confirmation here means destructive prompts are declined; the trust | ||
| // policy still gates installs via checkTrustPolicy in handleUp. | ||
| confirm: async () => false, | ||
| promptEnvVar: async () => "", | ||
| output: (text) => outputLines.push(text), | ||
| fetchNpmIntegrity, | ||
| // F8/B3: wire the provenance re-check on the MCP surface too, or a | ||
| // policy.frozen: true stack run through mcpm_up would silently skip it. | ||
| fetchNpmProvenance: (id, v, o) => fetchNpmProvenance(id, v, o), | ||
| readPins, | ||
| recordResult: (r) => records.push(r) | ||
| } | ||
| ); | ||
| } catch (err) { | ||
| thrownError = err instanceof Error ? err.message : String(err); | ||
| } | ||
| const installed = []; | ||
| const blocked = []; | ||
| const failed = []; | ||
| const skipped = []; | ||
| if (records.length > 0) { | ||
| for (const r of records) { | ||
| switch (r.status) { | ||
| case "installed": | ||
| installed.push(r.name); | ||
| break; | ||
| case "blocked": | ||
| blocked.push(r.name); | ||
| break; | ||
| case "failed": | ||
| failed.push(r.name); | ||
| break; | ||
| case "skipped": | ||
| case "removed": | ||
| skipped.push(r.name); | ||
| break; | ||
| } | ||
| } | ||
| } else { | ||
| for (const line of outputLines) { | ||
| if (line.includes("\u2713")) installed.push(line.trim()); | ||
| else if (line.includes("\u2717") && line.includes("blocked")) blocked.push(line.trim()); | ||
| else if (line.includes("\u2717")) failed.push(line.trim()); | ||
| else if (line.includes("\u2022")) skipped.push(line.trim()); | ||
| } | ||
| } | ||
| return { | ||
| installed, | ||
| blocked, | ||
| failed, | ||
| skipped, | ||
| ...thrownError !== void 0 ? { error: thrownError } : {}, | ||
| ...installed.length > 0 ? { note: "Restart your AI client to use the newly installed servers." } : {} | ||
| }; | ||
| } | ||
| var STOPWORDS = /\b(i need|set up|access|work with|connect to|a server that|a server for|to|the|a|an|my|for|and|with)\b/gi; | ||
| function extractKeywords(description) { | ||
| const cleaned = description.toLowerCase().replace(STOPWORDS, " ").replace(/[,&]/g, " "); | ||
| const tokens = cleaned.split(/\s+/).map((s) => s.trim()).filter((s) => s.length > 2); | ||
| if (tokens.length > 5) { | ||
| return [cleaned.replace(/\s+/g, " ").trim()]; | ||
| } | ||
| return tokens.length > 0 ? tokens : [description.trim()]; | ||
| } | ||
| // src/server/index.ts | ||
| async function createDeps() { | ||
| const { RegistryClient } = await import("./client-3RPMRFZL.js"); | ||
| const { detectInstalledClients } = await import("./detector-ZI4OWRCJ.js"); | ||
| const { getConfigPath } = await import("./paths-US27HRTP.js"); | ||
| const { getAdapter } = await import("./config-XMU247VO.js"); | ||
| const { scanTier1 } = await import("./tier1-OPUMS3NX.js"); | ||
| const { computeTrustScore } = await import("./trust-score-BAGF67DE.js"); | ||
| const { addInstalledServer, removeInstalledServer } = await import("./servers-IS6ZWSYC.js"); | ||
| const client = new RegistryClient(); | ||
| return { | ||
| registrySearch: async (query, limit) => { | ||
| const result = await client.searchServers(query, { limit }); | ||
| return result.servers; | ||
| }, | ||
| registryGetServer: (name) => client.getServer(name), | ||
| detectClients: detectInstalledClients, | ||
| getAdapter, | ||
| getConfigPath, | ||
| scanTier1, | ||
| computeTrustScore, | ||
| addToStore: addInstalledServer, | ||
| removeFromStore: removeInstalledServer | ||
| }; | ||
| } | ||
| function registerTools(server, deps) { | ||
| server.registerTool("mcpm_search", { | ||
| description: "Search the MCP registry for servers with trust scores", | ||
| inputSchema: SearchInput, | ||
| annotations: { readOnlyHint: true } | ||
| }, async (args) => { | ||
| const result = await handleSearch(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_install", { | ||
| description: "Install an MCP server with trust assessment", | ||
| inputSchema: InstallInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleInstall(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_info", { | ||
| description: "Show full details and trust score for an MCP server", | ||
| inputSchema: InfoInput, | ||
| annotations: { readOnlyHint: true } | ||
| }, async (args) => { | ||
| const result = await handleInfo(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_list", { | ||
| description: "List installed MCP servers across AI clients", | ||
| inputSchema: ListInput, | ||
| annotations: { readOnlyHint: true } | ||
| }, async (args) => { | ||
| const result = await handleList(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_remove", { | ||
| description: "Remove an MCP server from client configs", | ||
| inputSchema: RemoveInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleRemove(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_audit", { | ||
| inputSchema: NoArgsInput, | ||
| description: "Scan all installed servers and produce trust report", | ||
| annotations: { readOnlyHint: true } | ||
| }, async () => { | ||
| const result = await handleAudit(deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_doctor", { | ||
| inputSchema: NoArgsInput, | ||
| description: "Check MCP setup health", | ||
| annotations: { readOnlyHint: true } | ||
| }, async () => { | ||
| const result = await handleDoctor(deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_setup", { | ||
| description: "Install MCP servers from a natural language description", | ||
| inputSchema: SetupInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleSetup(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_up", { | ||
| description: "Install all servers from an mcpm.yaml stack file with trust verification. Equivalent to docker-compose up for MCP servers. Runs trust re-assessment and blocks servers that violate the trust policy. Pass profile to install only servers matching that profile, or dryRun to preview what would be installed without making changes.", | ||
| inputSchema: UpInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleMcpUp(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| } | ||
| async function startServer() { | ||
| const deps = await createDeps(); | ||
| const server = new McpServer({ | ||
| name: "mcpm", | ||
| // Issue #22: advertise the real package version (injected by tsup at build), | ||
| // not a hardcoded stale "0.1.0". | ||
| version: "0.29.0" | ||
| }); | ||
| registerTools(server, deps); | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
| } | ||
| export { | ||
| registerTools, | ||
| startServer | ||
| }; | ||
| //# sourceMappingURL=server-NHD3D4OL.js.map |
| {"version":3,"sources":["../src/server/index.ts","../src/server/tools.ts","../src/server/handlers.ts"],"sourcesContent":["/**\n * MCP server for mcpm — exposes search, install, audit, and setup as tools.\n *\n * Uses @modelcontextprotocol/sdk with stdio transport.\n * All logic delegates to handlers.ts which wraps existing mcpm functions.\n */\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport {\n NoArgsInput,\n SearchInput,\n InstallInput,\n InfoInput,\n ListInput,\n RemoveInput,\n SetupInput,\n UpInput,\n} from \"./tools.js\";\nimport {\n handleSearch,\n handleInstall,\n handleInfo,\n handleList,\n handleRemove,\n handleAudit,\n handleDoctor,\n handleSetup,\n handleMcpUp,\n} from \"./handlers.js\";\nimport type { ServerDeps } from \"./handlers.js\";\n\n// ---------------------------------------------------------------------------\n// Wire up real dependencies\n// ---------------------------------------------------------------------------\n\nasync function createDeps(): Promise<ServerDeps> {\n const { RegistryClient } = await import(\"../registry/client.js\");\n const { detectInstalledClients } = await import(\"../config/detector.js\");\n const { getConfigPath } = await import(\"../config/paths.js\");\n const { getAdapter } = await import(\"../config/index.js\");\n const { scanTier1 } = await import(\"../scanner/tier1.js\");\n const { computeTrustScore } = await import(\"../scanner/trust-score.js\");\n const { addInstalledServer, removeInstalledServer } = await import(\"../store/servers.js\");\n\n const client = new RegistryClient();\n\n return {\n registrySearch: async (query, limit) => {\n const result = await client.searchServers(query, { limit });\n return result.servers;\n },\n registryGetServer: (name) => client.getServer(name),\n detectClients: detectInstalledClients,\n getAdapter,\n getConfigPath,\n scanTier1,\n computeTrustScore,\n addToStore: addInstalledServer,\n removeFromStore: removeInstalledServer,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Server setup\n// ---------------------------------------------------------------------------\n\n/**\n * Register every mcpm tool on the server. Extracted from startServer so the\n * registration can be unit-tested (fix F.1): a test spies registerTool and\n * asserts every TOOL_DEFINITIONS name is registered exactly once, guarding\n * against future tool/registration divergence.\n *\n * `server` is typed loosely as `Pick<McpServer, \"registerTool\">` so tests can\n * pass a lightweight spy without constructing a full McpServer.\n */\nexport function registerTools(\n server: Pick<McpServer, \"registerTool\">,\n deps: ServerDeps\n): void {\n // Register tools using registerTool API\n server.registerTool(\"mcpm_search\", {\n description: \"Search the MCP registry for servers with trust scores\",\n inputSchema: SearchInput,\n annotations: { readOnlyHint: true },\n }, async (args) => {\n const result = await handleSearch(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_install\", {\n description: \"Install an MCP server with trust assessment\",\n inputSchema: InstallInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleInstall(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_info\", {\n description: \"Show full details and trust score for an MCP server\",\n inputSchema: InfoInput,\n annotations: { readOnlyHint: true },\n }, async (args) => {\n const result = await handleInfo(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_list\", {\n description: \"List installed MCP servers across AI clients\",\n inputSchema: ListInput,\n annotations: { readOnlyHint: true },\n }, async (args) => {\n const result = await handleList(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_remove\", {\n description: \"Remove an MCP server from client configs\",\n inputSchema: RemoveInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleRemove(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_audit\", {\n inputSchema: NoArgsInput,\n description: \"Scan all installed servers and produce trust report\",\n annotations: { readOnlyHint: true },\n }, async () => {\n const result = await handleAudit(deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_doctor\", {\n inputSchema: NoArgsInput,\n description: \"Check MCP setup health\",\n annotations: { readOnlyHint: true },\n }, async () => {\n const result = await handleDoctor(deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_setup\", {\n description: \"Install MCP servers from a natural language description\",\n inputSchema: SetupInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleSetup(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_up\", {\n description: \"Install all servers from an mcpm.yaml stack file with trust verification. Equivalent to docker-compose up for MCP servers. Runs trust re-assessment and blocks servers that violate the trust policy. Pass profile to install only servers matching that profile, or dryRun to preview what would be installed without making changes.\",\n inputSchema: UpInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleMcpUp(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n}\n\nexport async function startServer(): Promise<void> {\n const deps = await createDeps();\n\n const server = new McpServer({\n name: \"mcpm\",\n // Issue #22: advertise the real package version (injected by tsup at build),\n // not a hardcoded stale \"0.1.0\".\n version: __PKG_VERSION__,\n });\n\n registerTools(server, deps);\n\n // Start stdio transport\n const transport = new StdioServerTransport();\n await server.connect(transport);\n}\n","/**\n * MCP tool definitions for mcpm serve.\n *\n * Each tool has a name, description, and Zod input schema.\n * Handlers are in handlers.ts.\n */\n\nimport { z } from \"zod\";\nimport { CLIENT_IDS } from \"../config/paths.js\";\n\nexport const TOOL_DEFINITIONS = [\n {\n name: \"mcpm_search\",\n description: \"Search the MCP registry for servers. Returns results with trust scores.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n query: { type: \"string\", description: \"Search query (substring match on server name)\" },\n limit: { type: \"number\", description: \"Max results to return (default 20)\" },\n },\n required: [\"query\"],\n },\n },\n {\n name: \"mcpm_install\",\n description: \"Install an MCP server from the registry into detected AI client configs. Runs trust assessment automatically. Rejects servers below the minimum trust score (default 50).\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n name: { type: \"string\", description: \"Server name (e.g. io.github.domdomegg/filesystem-mcp)\" },\n client: { type: \"string\", description: \"Install to specific client only (claude-desktop, cursor, vscode, windsurf)\" },\n minTrustScore: { type: \"number\", description: \"Minimum trust score to allow install (default 50, range 0-100)\" },\n },\n required: [\"name\"],\n },\n },\n {\n name: \"mcpm_info\",\n description: \"Show full details for an MCP server including trust score breakdown.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n name: { type: \"string\", description: \"Server name\" },\n },\n required: [\"name\"],\n },\n },\n {\n name: \"mcpm_list\",\n description: \"List all installed MCP servers across detected AI clients.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n client: { type: \"string\", description: \"Filter to specific client\" },\n },\n required: [],\n },\n },\n {\n name: \"mcpm_remove\",\n description: \"Remove an MCP server from AI client configs.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n name: { type: \"string\", description: \"Server name to remove\" },\n client: { type: \"string\", description: \"Remove from specific client only\" },\n },\n required: [\"name\"],\n },\n },\n {\n name: \"mcpm_audit\",\n description: \"Scan all installed MCP servers and produce a trust report with scores.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {},\n required: [],\n },\n },\n {\n name: \"mcpm_doctor\",\n description: \"Check MCP setup health: detected clients, available runtimes, configuration issues.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {},\n required: [],\n },\n },\n {\n name: \"mcpm_setup\",\n description: \"Install MCP servers from a natural language description. Searches, evaluates trust, installs the best match for each keyword. Example: 'filesystem and GitHub' installs filesystem + GitHub servers.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n description: { type: \"string\", description: \"What you need (e.g. 'filesystem access and GitHub integration')\" },\n client: { type: \"string\", description: \"Install to specific client only\" },\n minTrustScore: { type: \"number\", description: \"Minimum trust score to auto-install (default 50, range 0-100)\" },\n },\n required: [\"description\"],\n },\n },\n {\n name: \"mcpm_up\",\n description: \"Install all servers from an mcpm.yaml stack file with trust verification. Equivalent to docker-compose up for MCP servers. Runs trust re-assessment and blocks servers that violate the trust policy. Pass profile to install only servers matching that profile, or dryRun to preview what would be installed without making changes.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n stackFile: { type: \"string\", description: \"Path to mcpm.yaml (default: mcpm.yaml in CWD)\" },\n profile: { type: \"string\", description: \"Install only servers matching this profile\" },\n dryRun: { type: \"boolean\", description: \"Show what would be installed without making changes\" },\n },\n required: [],\n },\n },\n] as const;\n\n// Shared field schemas (security #31): a bounded server-name string and a closed\n// client enum, so the Zod layer — not just the runtime `validateMcpServerName` /\n// `CLIENT_IDS.includes` checks in handlers.ts — is the declarative enforcement\n// point. The objects below are `strictObject` so unknown keys are rejected\n// instead of silently dropped.\n//\n// These are passed to `registerTool` WHOLE (not via `.shape`) — see\n// server/index.ts. That distinction is load-bearing: the SDK accepts either a\n// raw shape or a full schema, but a raw shape is rebuilt as a plain\n// `z.object(shape)`, which silently DROPS the object-level `strict` setting.\n// Per-field constraints (the length bound, the client enum) survive either way;\n// strictness does not.\n//\n// Passing the whole schema means the SDK rejects unknown keys with a JSON-RPC\n// -32602 `unrecognized_keys` error, AND advertises `additionalProperties: false`\n// in `tools/list` so a caller can see the contract before calling. Verified over\n// a real in-memory MCP transport in server-strict-schema.test.ts.\n//\n// The runtime guards in handlers.ts (`validateMcpServerName`, `CLIENT_IDS`)\n// remain as defence in depth.\nconst serverName = z.string().min(1).max(256);\nconst clientId = z.enum(CLIENT_IDS);\n\n/**\n * Zero-argument tools (`mcpm_audit`, `mcpm_doctor`) still declare a CLOSED\n * schema rather than omitting `inputSchema` entirely. Omitting it advertises no\n * `additionalProperties: false`, so any argument a caller passes is silently\n * ignored — for a tool that takes nothing, that means EVERY argument is\n * silently ignored. An empty strict object makes the contract explicit and\n * turns a mistaken call into a clear error.\n */\nexport const NoArgsInput = z.strictObject({});\n\nexport const SearchInput = z.strictObject({\n query: z.string().min(1).max(200),\n limit: z.number().int().min(1).max(100).optional().default(20),\n});\n\nexport const InstallInput = z.strictObject({\n name: serverName,\n client: clientId.optional(),\n minTrustScore: z.number().min(0).max(100).optional().default(50),\n});\n\nexport const InfoInput = z.strictObject({\n name: serverName,\n});\n\nexport const ListInput = z.strictObject({\n client: clientId.optional(),\n});\n\nexport const RemoveInput = z.strictObject({\n name: serverName,\n client: clientId.optional(),\n});\n\nexport const SetupInput = z.strictObject({\n description: z.string().min(1).max(1000),\n client: clientId.optional(),\n minTrustScore: z.number().min(0).max(100).optional().default(50),\n});\n\nexport const UpInput = z.strictObject({\n stackFile: z.string().optional().default(\"mcpm.yaml\"),\n profile: z.string().optional(),\n dryRun: z.boolean().optional().default(false),\n});\n","/**\n * MCP tool handlers for mcpm serve.\n *\n * Each handler wraps existing mcpm logic and returns structured JSON.\n * All dependencies are injectable for testability.\n */\n\nimport path from \"node:path\";\nimport type { ClientId } from \"../config/paths.js\";\nimport { CLIENT_IDS } from \"../config/paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"../config/adapters/index.js\";\nimport type { ServerEntry } from \"../registry/types.js\";\nimport type { Finding } from \"../scanner/tier1.js\";\nimport type { TrustScore, TrustScoreInput } from \"../scanner/trust-score.js\";\nimport { nativeTrustScore } from \"../scanner/trust-score.js\";\nimport { extractRegistryMeta } from \"../utils/format-trust.js\";\nimport { formatMcpEntryCommand } from \"../utils/format-entry.js\";\nimport { resolveInstallEntry } from \"../commands/install.js\";\nimport { buildDoctorModel, makeCheckConfigExists, execCheckDefault } from \"../commands/doctor.js\";\nimport { fetchNpmIntegrity as _fetchNpmIntegrity } from \"../registry/npm-integrity.js\";\nimport { fetchNpmProvenance as _fetchNpmProvenance } from \"../registry/npm-provenance.js\";\nimport { readPins as _readPins } from \"../guard/pins.js\";\n\n// ---------------------------------------------------------------------------\n// Input validation for MCP server tool arguments\n// ---------------------------------------------------------------------------\n\n/**\n * Server name pattern for MCP registry names.\n * Format: \"namespace/server-name\" — alphanumeric with dots, hyphens, underscores.\n * Max length 256 to prevent abuse. Must not contain shell metacharacters,\n * path traversal sequences, or control characters.\n */\nconst SERVER_NAME_RE =\n /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}\\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}$/;\n\n/**\n * Validate a server name received from an MCP tool call.\n * This is the trust boundary — AI agents provide these strings, and they\n * could be influenced by prompt injection or adversarial inputs.\n */\nfunction validateMcpServerName(name: string): void {\n if (typeof name !== \"string\" || name.length === 0 || name.length > 256) {\n throw new Error(`Invalid server name: must be a non-empty string under 256 characters.`);\n }\n if (!SERVER_NAME_RE.test(name)) {\n throw new Error(\n `Invalid server name format: \"${name}\". Expected format: \"namespace/server-name\" ` +\n `(alphanumeric, dots, hyphens, underscores only).`\n );\n }\n}\n\n// ---------------------------------------------------------------------------\n// Dependency injection types\n// ---------------------------------------------------------------------------\n\nexport interface ServerDeps {\n registrySearch: (query: string, limit: number) => Promise<ServerEntry[]>;\n registryGetServer: (name: string) => Promise<ServerEntry>;\n detectClients: () => Promise<ClientId[]>;\n getAdapter: (clientId: ClientId) => ConfigAdapter;\n getConfigPath: (clientId: ClientId) => string;\n scanTier1: (server: ServerEntry) => Finding[];\n computeTrustScore: (input: TrustScoreInput) => TrustScore;\n addToStore: (server: { name: string; version: string; clients: ClientId[]; installedAt: string }) => Promise<void>;\n removeFromStore: (name: string) => Promise<void>;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * F4 scope note: this helper deliberately does NOT include the\n * release-cooldown finding (ServerDeps has no injectable clock; the F4 spec\n * file list excludes server/). Consequence: mcpm_install / mcpm_search score\n * a fresh (<24h) package up to 5 points higher than CLI install/why AND than\n * the sibling mcpm_up tool (which inherits the finding via up.ts\n * processServer), and HARD_TRUST_FLOOR evaluates that inflated score — do NOT\n * compensate by raising the floor. Fast-follow is mechanical:\n * ServerDeps += now?: () => number, then append\n * assessReleaseAge({...}).finding here; no schema changes.\n */\nfunction computeTrust(entry: ServerEntry, deps: ServerDeps): TrustScore {\n const findings = deps.scanTier1(entry);\n return deps.computeTrustScore({\n findings,\n healthCheckPassed: null,\n hasExternalScanner: false,\n registryMeta: extractRegistryMeta(entry),\n });\n}\n\nasync function resolveClients(\n requestedClient: string | undefined,\n deps: ServerDeps\n): Promise<ClientId[]> {\n const detected = await deps.detectClients();\n if (detected.length === 0) {\n throw new Error(\"No supported AI clients found.\");\n }\n if (requestedClient !== undefined) {\n if (!CLIENT_IDS.includes(requestedClient as ClientId)) {\n throw new Error(\n `Unknown client \"${requestedClient}\". Valid values: ${CLIENT_IDS.join(\", \")}.`\n );\n }\n const id = requestedClient as ClientId;\n if (!detected.includes(id)) {\n throw new Error(`Client \"${requestedClient}\" is not installed.`);\n }\n return [id];\n }\n return detected;\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nexport async function handleSearch(\n args: { query: string; limit: number },\n deps: ServerDeps\n): Promise<object> {\n const entries = await deps.registrySearch(args.query, args.limit);\n const servers = entries.map((entry) => {\n const trust = computeTrust(entry, deps);\n return {\n name: entry.server.name,\n description: entry.server.description ?? \"\",\n version: entry.server.version,\n trustScore: trust.score,\n };\n });\n return { servers };\n}\n\n/** Default minimum trust score for MCP server tool installs (no human in the loop). */\nconst DEFAULT_MIN_TRUST_SCORE = 50;\n\n/**\n * Hard, non-overridable trust floor for the MCP server surface (issue #24).\n *\n * The MCP `minTrustScore` input accepts `0`, which a prompt-injected agent could\n * pass to disable the install gate entirely. We clamp the effective threshold to\n * `Math.max(userValue, HARD_TRUST_FLOOR)` so no caller-supplied value can lower\n * the gate below this floor. This protects the no-human-in-loop path; the CLI\n * (with a human confirmation prompt) is the only place to install below it.\n *\n * Lowering the gate is only half of it. A score can also be pushed UP to meet\n * the gate, and `MCPM_EXTERNAL_SCANNER` is caller-supplied too — so the floor is\n * evaluated against `nativeTrustScore`, which excludes the external bucket's\n * unverifiable credit (TODOS #33).\n */\nconst HARD_TRUST_FLOOR = 25;\n\n/** Clamp a requested minimum trust score so it can never sink below the floor. */\nfunction effectiveMinTrustScore(requested: number | undefined): number {\n return Math.max(requested ?? DEFAULT_MIN_TRUST_SCORE, HARD_TRUST_FLOOR);\n}\n\nexport async function handleInstall(\n args: { name: string; client?: string; minTrustScore?: number },\n deps: ServerDeps,\n preResolved?: { entry: ServerEntry; trust: TrustScore }\n): Promise<object> {\n validateMcpServerName(args.name);\n const entry = preResolved?.entry ?? await deps.registryGetServer(args.name);\n const trust = preResolved?.trust ?? computeTrust(entry, deps);\n\n // Security gate: reject servers below the minimum trust score.\n // Unlike the CLI path which has a human confirmation prompt, the MCP server\n // path is driven by AI agents with no human in the loop. A malicious prompt\n // could trick an agent into installing a dangerous server, so we enforce a\n // hard trust floor here. Issue #24: minTrustScore:0 must NOT disable the gate —\n // the effective threshold is clamped to HARD_TRUST_FLOOR.\n //\n // TODOS #33: compared against mcpm's OWN evidence. `computeTrust` above already\n // passes `hasExternalScanner: false`, so today this subtracts nothing — it is\n // here so that wiring a scanner into this path later cannot silently reopen the\n // floor, which is the failure mode #33 found on the sibling `mcpm_up` path.\n const minScore = effectiveMinTrustScore(args.minTrustScore);\n const nativeTrust = nativeTrustScore(trust);\n if (nativeTrust.score < minScore) {\n throw new Error(\n `Server \"${args.name}\" has trust score ${nativeTrust.score}/${nativeTrust.maxPossible} ` +\n `(level: ${trust.level}), which is below the minimum threshold of ${minScore}. ` +\n (nativeTrust.excludedExternalCredit > 0\n ? `An external scanner's ${nativeTrust.excludedExternalCredit} points are excluded from this floor because mcpm cannot verify them. `\n : \"\") +\n `Install rejected for safety. Use mcpm CLI with --yes to override after manual review.`\n );\n }\n\n const clients = await resolveClients(args.client, deps);\n\n // PRE-FLIGHT: resolve and validate EVERY client's entry before touching a single\n // config. resolveInstallEntry's URL rule is cursor-ONLY, so a server carrying both\n // an npm package and an http remote used to install cleanly on claude-desktop and\n // only THEN hit the H9 deny on cursor — the agent was told the install failed while\n // a live execution surface sat in Claude Desktop, with no store record of it.\n const planned = clients.map((clientId) => {\n const mcpEntry = resolveInstallEntry(entry, clientId);\n // H9 (fail-closed): a URL/HTTP-transport entry (url, no command) runs\n // UNGUARDED — the guard relay only wraps a stdio process. The MCP surface is\n // driven by an untrusted agent with no human in the loop and no\n // `--allow-unguarded` opt-in, so url-transport installs are HARD-DENIED here\n // (mirrors the batch `up` MCP wiring's allowUrlServers:false kill-switch).\n if (mcpEntry.url !== undefined && mcpEntry.command === undefined) {\n throw new Error(\n `Server \"${args.name}\" uses a URL/HTTP transport and runs UNGUARDED ` +\n `(the guard relay only wraps stdio servers). Installing it is not permitted ` +\n `via the MCP surface. Use the mcpm CLI with --allow-unguarded after manual review.`\n );\n }\n return {\n clientId,\n adapter: deps.getAdapter(clientId),\n configPath: deps.getConfigPath(clientId),\n mcpEntry,\n };\n });\n\n // APPLY as a unit. Any failure — a client write or the store write — unwinds every\n // write already made, so this tool never reports failure over a live install.\n // The store write is inside the transaction on purpose: configs written without a\n // store record are invisible to `mcpm list` / `audit` and survive `mcpm remove`.\n const done: PlannedInstall[] = [];\n try {\n for (const p of planned) {\n await p.adapter.addServer(p.configPath, args.name, p.mcpEntry);\n done.push(p);\n }\n await deps.addToStore({\n name: args.name,\n version: entry.server.version,\n clients: done.map((p) => p.clientId),\n installedAt: new Date().toISOString(),\n });\n } catch (err) {\n const stranded = await rollbackInstall(done, args.name);\n if (stranded.length > 0) {\n // Rollback is best-effort and can fail too. Swallowing that would report a\n // clean failure over a server that is still installed — name it instead.\n throw new Error(\n `${err instanceof Error ? err.message : String(err)}\\n\\n` +\n `Rollback incomplete: \"${args.name}\" is STILL INSTALLED in ${stranded.join(\", \")}. ` +\n `Remove it with \\`mcpm remove ${args.name}\\` before retrying.`\n );\n }\n throw err;\n }\n\n return {\n installed: true,\n name: args.name,\n version: entry.server.version,\n clients: done.map((p) => p.clientId),\n trustScore: trust,\n };\n}\n\n/** One client's fully-resolved install, validated and ready to write. */\ntype PlannedInstall = {\n clientId: ClientId;\n adapter: ConfigAdapter;\n configPath: string;\n mcpEntry: McpServerEntry;\n};\n\n/**\n * Undo the client-config writes already made by a failed install.\n * @returns the clients that could NOT be rolled back (still installed).\n */\nasync function rollbackInstall(done: PlannedInstall[], name: string): Promise<ClientId[]> {\n const stranded: ClientId[] = [];\n for (const p of done) {\n try {\n await p.adapter.removeServer(p.configPath, name);\n } catch {\n stranded.push(p.clientId);\n }\n }\n return stranded;\n}\n\nexport async function handleInfo(\n args: { name: string },\n deps: ServerDeps\n): Promise<object> {\n validateMcpServerName(args.name);\n const entry = await deps.registryGetServer(args.name);\n const trust = computeTrust(entry, deps);\n return {\n name: entry.server.name,\n description: entry.server.description ?? \"\",\n version: entry.server.version,\n packages: entry.server.packages.map((p) => ({\n registryType: p.registryType,\n identifier: p.identifier,\n })),\n trustScore: trust,\n };\n}\n\nexport async function handleList(\n args: { client?: string },\n deps: ServerDeps\n): Promise<object> {\n const clients = await resolveClients(args.client, deps);\n const servers: Array<{ name: string; client: string; command: string }> = [];\n\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n const installed = await adapter.read(configPath);\n\n for (const [name, entry] of Object.entries(installed)) {\n const command = formatMcpEntryCommand(entry, \"unknown\");\n servers.push({ name, client: clientId, command });\n }\n }\n\n return { servers };\n}\n\nexport async function handleRemove(\n args: { name: string; client?: string },\n deps: ServerDeps\n): Promise<object> {\n validateMcpServerName(args.name);\n const clients = await resolveClients(args.client, deps);\n const removedClients: ClientId[] = [];\n\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n try {\n await adapter.removeServer(configPath, args.name);\n removedClients.push(clientId);\n } catch {\n // Server not in this client, skip\n }\n }\n\n if (removedClients.length === 0) {\n throw new Error(`Server \"${args.name}\" not found in any client config.`);\n }\n\n try {\n await deps.removeFromStore(args.name);\n } catch {\n // Not in store, fine\n }\n\n return { removed: true, name: args.name, clients: removedClients };\n}\n\nexport async function handleAudit(deps: ServerDeps): Promise<object> {\n const clients = await deps.detectClients();\n const results: Array<{ name: string; client: string; trustScore: TrustScore }> = [];\n\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n const installed = await adapter.read(configPath);\n\n for (const name of Object.keys(installed)) {\n try {\n const entry = await deps.registryGetServer(name);\n const trust = computeTrust(entry, deps);\n results.push({ name, client: clientId, trustScore: trust });\n } catch {\n results.push({\n name,\n client: clientId,\n trustScore: { score: 0, maxPossible: 80, level: \"risky\", breakdown: { healthCheck: 0, staticScan: 0, externalScan: 0, registryMeta: 0 } },\n });\n }\n }\n }\n\n return { results };\n}\n\nexport async function handleDoctor(deps: ServerDeps): Promise<object> {\n // Reuse the CLI's structured model so this tool reports real issues instead of\n // the formerly-hardcoded `issues: []` (D7). Honors the injected getConfigPath.\n return buildDoctorModel({\n getAdapter: deps.getAdapter,\n getConfigPath: deps.getConfigPath,\n checkConfigExists: makeCheckConfigExists(deps.getConfigPath),\n execCheck: execCheckDefault,\n });\n}\n\nexport async function handleSetup(\n args: { description: string; client?: string; minTrustScore: number },\n deps: ServerDeps\n): Promise<object> {\n if (!args.description.trim()) {\n throw new Error(\"Could not extract any keywords from empty description.\");\n }\n const keywords = extractKeywords(args.description);\n\n // Issue #24: clamp to the hard floor so minTrustScore:0 can't disable the gate\n // on the no-human-in-loop setup path either.\n //\n // Also clamp UP to handleInstall's own default. This pre-filter delegates to\n // handleInstall without forwarding minTrustScore, so the enforcing gate always\n // applies DEFAULT_MIN_TRUST_SCORE. With a requested 25-49 the two disagreed:\n // the pre-filter waved the server through and handleInstall then refused it,\n // reporting a trust rejection as \"Install failed\" and quoting a threshold the\n // caller never asked for. Taking the stricter of the two makes the pre-filter\n // report exactly what the enforcing gate will do. Deliberately NOT fixed by\n // forwarding minTrustScore instead -- that would let a caller-supplied 30\n // LOWER the gate this path enforces today.\n const minScore = Math.max(\n effectiveMinTrustScore(args.minTrustScore),\n DEFAULT_MIN_TRUST_SCORE,\n );\n\n const installed: Array<{ name: string; trustScore: TrustScore }> = [];\n const skipped: Array<{ name: string; reason: string }> = [];\n\n // Parallel search pass — all keywords searched concurrently. Capture the\n // thrown error per keyword so a registry outage is distinguishable from a\n // genuine empty result (both otherwise look like \"no servers\").\n type SearchOutcome =\n | { ok: true; entries: ServerEntry[] }\n | { ok: false; error: string };\n const searchResults: SearchOutcome[] = await Promise.all(\n keywords.map((kw) =>\n deps\n .registrySearch(kw, 5)\n .then((entries): SearchOutcome => ({ ok: true, entries }))\n .catch((err): SearchOutcome => ({\n ok: false,\n error: err instanceof Error ? err.message : String(err),\n }))\n )\n );\n\n const seenNames = new Set<string>();\n\n // Sequential evaluate/install pass (installs depend on previous state)\n for (let i = 0; i < keywords.length; i++) {\n const keyword = keywords[i];\n const outcome = searchResults[i];\n\n if (!outcome.ok) {\n skipped.push({ name: keyword, reason: `Registry search failed: ${outcome.error}` });\n continue;\n }\n\n const entries = outcome.entries;\n\n if (entries.length === 0) {\n skipped.push({ name: keyword, reason: `No servers found for \"${keyword}\"` });\n continue;\n }\n\n let bestEntry: ServerEntry | null = null;\n let bestTrust: TrustScore | null = null;\n\n for (const entry of entries) {\n if (seenNames.has(entry.server.name)) continue;\n const trust = computeTrust(entry, deps);\n if (bestTrust === null || trust.score > bestTrust.score) {\n bestEntry = entry;\n bestTrust = trust;\n }\n }\n\n if (bestEntry === null || bestTrust === null) {\n skipped.push({ name: keyword, reason: \"All results already installed or duplicated\" });\n continue;\n }\n\n // TODOS #33: the same native-evidence rule as the sibling gates. handleInstall\n // below re-checks and is the enforcing gate, so this pre-filter exists to\n // produce an accurate \"skipped\" reason rather than an \"Install failed\" one —\n // but it must agree with it, or a server rejected downstream gets reported\n // under the wrong heading with a score that was never compared.\n const bestNative = nativeTrustScore(bestTrust);\n if (bestNative.score < minScore) {\n skipped.push({\n name: bestEntry.server.name,\n reason:\n `Trust score ${bestNative.score}/${bestNative.maxPossible} is below minimum ${minScore}` +\n (bestNative.excludedExternalCredit > 0\n ? ` (an external scanner's ${bestNative.excludedExternalCredit} points are excluded — mcpm cannot verify them)`\n : \"\"),\n });\n continue;\n }\n\n try {\n await handleInstall(\n { name: bestEntry.server.name, client: args.client },\n deps,\n { entry: bestEntry, trust: bestTrust }\n );\n seenNames.add(bestEntry.server.name);\n installed.push({ name: bestEntry.server.name, trustScore: bestTrust });\n } catch (err) {\n skipped.push({\n name: bestEntry.server.name,\n reason: `Install failed: ${(err as Error).message}`,\n });\n }\n }\n\n const note = installed.length > 0\n ? \"Restart your AI client to use the newly installed servers.\"\n : undefined;\n\n return { installed, skipped, ...(note ? { note } : {}) };\n}\n\n// ---------------------------------------------------------------------------\n// mcpm_up — batch install from stack file\n// ---------------------------------------------------------------------------\n\nexport async function handleMcpUp(\n args: { stackFile?: string; profile?: string; dryRun?: boolean },\n deps: ServerDeps\n): Promise<{\n installed: string[];\n blocked: string[];\n failed: string[];\n skipped: string[];\n error?: string;\n note?: string;\n}> {\n // Validate stackFile path (AI agent trust boundary). Zod defaults stackFile to\n // \"mcpm.yaml\", so the old `if (args.stackFile !== undefined)` guard was dead.\n // Enforce real containment unconditionally via resolved paths: path.resolve\n // normalizes Windows backslashes and \"..\", so this catches traversal and\n // absolute escapes that string-only checks miss.\n const stackFile = args.stackFile ?? \"mcpm.yaml\";\n const resolved = path.resolve(process.cwd(), stackFile);\n if (\n resolved !== process.cwd() &&\n !resolved.startsWith(process.cwd() + path.sep)\n ) {\n throw new Error(\"stackFile must be within the working directory\");\n }\n // M3: the lexical check above catches \"../\" and absolute escapes, but NOT a\n // symlink that lives inside cwd yet points outside it — the file reader would\n // follow it (arbitrary out-of-tree read). Resolve the REAL path and re-check.\n // realpath throws ENOENT when the file does not exist yet; that's fine — handleUp\n // reports the missing file. A containment failure thrown inside the try is not\n // an ErrnoException, so the catch re-throws it.\n {\n const { realpath } = await import(\"node:fs/promises\");\n try {\n const [realStack, realCwd] = await Promise.all([\n realpath(resolved),\n realpath(process.cwd()),\n ]);\n if (realStack !== realCwd && !realStack.startsWith(realCwd + path.sep)) {\n throw new Error(\"stackFile must be within the working directory\");\n }\n } catch (err) {\n // ENOENT (no such file), ELOOP (circular symlink), and ENOTDIR (a path\n // component is a file) all mean \"no real path to contain\" — fall through and\n // let handleUp report the missing/invalid file. Re-throwing them would leak a\n // raw internal ErrnoException (with stack) to the untrusted caller. The\n // containment Error thrown just above has no `.code`, so it still propagates.\n const code = (err as NodeJS.ErrnoException).code ?? \"\";\n if (![\"ENOENT\", \"ELOOP\", \"ENOTDIR\"].includes(code)) throw err;\n }\n }\n\n const { handleUp } = await import(\"../commands/up.js\");\n const { writeFile } = await import(\"fs/promises\");\n const { handleLock } = await import(\"../commands/lock.js\");\n const { RegistryClient } = await import(\"../registry/client.js\");\n const { scanTier1: st1 } = await import(\"../scanner/tier1.js\");\n const { checkScannerAvailable: csa, scanTier2: st2 } = await import(\"../scanner/tier2.js\");\n const { computeTrustScore: cts } = await import(\"../scanner/trust-score.js\");\n\n const client = new RegistryClient();\n const outputLines: string[] = [];\n // Fix A/D: structured per-server results from handleUp. Authoritative source\n // for categorization — emoji-scraping cannot distinguish blocked from failed.\n const records: Array<{ name: string; status: string }> = [];\n let thrownError: string | undefined;\n\n try {\n await handleUp(\n {\n stackFile,\n profile: args.profile,\n dryRun: args.dryRun,\n ci: true,\n yes: false,\n // MCP surface lockdown (fixes C, D & H1): never auto-read ambient\n // secrets from process.env OR the working-directory .env file, and never\n // install URL servers (they bypass the registry trust gate). All three\n // default to true on the CLI; the MCP (untrusted-caller) surface opts in\n // to the locked-down behavior.\n allowProcessEnv: false,\n allowUrlServers: false,\n allowEnvFile: false,\n // M2: the batch `up` path must honor the same non-overridable trust floor\n // the single-install MCP tool enforces (issue #24), so a low-trust server\n // an agent could not install via mcpm_install can't slip in via mcpm_up.\n minTrustFloor: HARD_TRUST_FLOOR,\n },\n {\n detectClients: deps.detectClients,\n getAdapter: deps.getAdapter,\n getPath: deps.getConfigPath,\n getServer: (name, version?) => client.getServer(name, version),\n scanTier1: st1,\n checkScannerAvailable: csa,\n scanTier2: (name) => st2(name),\n computeTrustScore: cts,\n runLock: async (stackFile) => {\n await handleLock(\n { stackFile },\n {\n getServerVersions: (name) => client.getServerVersions(name),\n getServer: (name, v?) => client.getServer(name, v),\n scanTier1: st1,\n checkScannerAvailable: csa,\n scanTier2: (name) => st2(name),\n computeTrustScore: cts,\n writeLockFile: (path, content) =>\n writeFile(path, content, { encoding: \"utf-8\", mode: 0o600 }),\n fetchNpmIntegrity: _fetchNpmIntegrity,\n fetchNpmProvenance: (id, ver, sri) => _fetchNpmProvenance(id, ver, { integritySri: sri }),\n output: (text) => outputLines.push(text),\n }\n );\n },\n // Issue #22: never auto-confirm on the MCP (no-human-in-loop) surface.\n // The previous `async () => true` blanket-approved every confirmation,\n // including strict-mode *removals* of servers not in mcpm.yaml — a\n // prompt-injected agent could silently mutate client configs. Refusing\n // confirmation here means destructive prompts are declined; the trust\n // policy still gates installs via checkTrustPolicy in handleUp.\n confirm: async () => false,\n promptEnvVar: async () => \"\",\n output: (text) => outputLines.push(text),\n fetchNpmIntegrity: _fetchNpmIntegrity,\n // F8/B3: wire the provenance re-check on the MCP surface too, or a\n // policy.frozen: true stack run through mcpm_up would silently skip it.\n fetchNpmProvenance: (id, v, o) => _fetchNpmProvenance(id, v, o),\n readPins: _readPins,\n recordResult: (r) => records.push(r),\n }\n );\n } catch (err) {\n // Fix A: handleUp throws on early/whole-batch failures (no clients, lock-file\n // creation failure, missing required env in CI, the summary \"N could not be\n // installed\" throw, etc.). The previous bare catch swallowed these into a\n // clean-looking empty result. Capture the message so the caller can never\n // mistake a thrown failure for success.\n thrownError = err instanceof Error ? err.message : String(err);\n }\n\n const installed: string[] = [];\n const blocked: string[] = [];\n const failed: string[] = [];\n const skipped: string[] = [];\n\n if (records.length > 0) {\n // Authoritative path (fix D, F.3/F.5): categorize from handleUp's typed\n // per-server statuses. Unlike emoji-scraping, this reliably separates\n // \"blocked\" (policy/URL-lockdown) from \"failed\".\n for (const r of records) {\n switch (r.status) {\n case \"installed\": installed.push(r.name); break;\n case \"blocked\": blocked.push(r.name); break;\n case \"failed\": failed.push(r.name); break;\n case \"skipped\":\n case \"removed\": skipped.push(r.name); break;\n }\n }\n } else {\n // Fallback for the no-record path (e.g. a throw before any server is\n // processed): preserve the original output-line parsing.\n for (const line of outputLines) {\n if (line.includes(\"\\u2713\")) installed.push(line.trim());\n else if (line.includes(\"\\u2717\") && line.includes(\"blocked\")) blocked.push(line.trim());\n else if (line.includes(\"\\u2717\")) failed.push(line.trim());\n else if (line.includes(\"\\u2022\")) skipped.push(line.trim());\n }\n }\n\n // Fix A, refined for M1: a thrown handleUp failure MUST be signaled \\u2014 but only\n // via the top-level `error` field (set in the return below). The previous\n // version pushed the error *message* into `failed`, which is contracted to hold\n // server NAMES; a consumer iterating it as names got a stray sentence. `error`\n // is the authoritative batch-failure signal; `failed` stays names-only (genuine\n // per-server failures are already recorded into it above via `records`).\n\n return {\n installed,\n blocked,\n failed,\n skipped,\n ...(thrownError !== undefined ? { error: thrownError } : {}),\n ...(installed.length > 0\n ? { note: \"Restart your AI client to use the newly installed servers.\" }\n : {}),\n };\n}\n\n// ---------------------------------------------------------------------------\n// Keyword extraction\n// ---------------------------------------------------------------------------\n\nconst STOPWORDS = /\\b(i need|set up|access|work with|connect to|a server that|a server for|to|the|a|an|my|for|and|with)\\b/gi;\n\nexport function extractKeywords(description: string): string[] {\n const cleaned = description\n .toLowerCase()\n .replace(STOPWORDS, \" \")\n .replace(/[,&]/g, \" \");\n\n const tokens = cleaned\n .split(/\\s+/)\n .map((s) => s.trim())\n .filter((s) => s.length > 2);\n\n // If splitting produced too many tokens, use the full cleaned string\n if (tokens.length > 5) {\n return [cleaned.replace(/\\s+/g, \" \").trim()];\n }\n\n return tokens.length > 0 ? tokens : [description.trim()];\n}\n\n"],"mappings":"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","names":["clientId","stackFile","path"]} |
| #!/usr/bin/env node | ||
| import { | ||
| handleUp, | ||
| registerUpCommand | ||
| } from "./chunk-TX4RGSW7.js"; | ||
| import "./chunk-C6CAHFQX.js"; | ||
| import "./chunk-7QRDQF55.js"; | ||
| import "./chunk-DDCTUMSZ.js"; | ||
| import "./chunk-E3T224S3.js"; | ||
| import "./chunk-RTI2GLYX.js"; | ||
| import "./chunk-OIFKZA4V.js"; | ||
| import "./chunk-FEXJHHDM.js"; | ||
| import "./chunk-F6CHEUGO.js"; | ||
| import "./chunk-LSNEZAFR.js"; | ||
| import "./chunk-UNGY7RTE.js"; | ||
| import "./chunk-W4IAFBUN.js"; | ||
| import "./chunk-2PWW3Q5Q.js"; | ||
| import "./chunk-MLVDFLDQ.js"; | ||
| import "./chunk-7RJXJERN.js"; | ||
| import "./chunk-V4AA4ZL5.js"; | ||
| import "./chunk-32VRWVOF.js"; | ||
| import "./chunk-K4U7EXLG.js"; | ||
| import "./chunk-GZ3WCRLG.js"; | ||
| import "./chunk-6R7TL5O2.js"; | ||
| import "./chunk-R4R2VPDA.js"; | ||
| import "./chunk-4QDJ3I7X.js"; | ||
| import "./chunk-3X76P3FG.js"; | ||
| import "./chunk-U7N6FRYF.js"; | ||
| import "./chunk-WT6V33F2.js"; | ||
| export { | ||
| handleUp, | ||
| registerUpCommand | ||
| }; | ||
| //# sourceMappingURL=up-3RX6WQ4D.js.map |
| {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]} |
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1857179
0.05%