toolstile 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Akshit Kr Nagpal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,105 @@
1
- # Temporary Holding Version
1
+ # toolstile
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Check the MCP server configs your AI agents use, in one command:
4
+
5
+ ```sh
6
+ npx toolstile audit
7
+ ```
8
+
9
+ It reads the configs of Claude Code, Claude Desktop, Cursor, Codex, VS Code,
10
+ Windsurf, Gemini CLI and Cline and grades them for:
11
+
12
+ | Check | Severity |
13
+ | --- | --- |
14
+ | API keys, tokens and passwords written into config files, URLs or arguments | high |
15
+ | Remote servers reached over plain `http://` | high |
16
+ | Containers run with `--privileged` or the Docker socket | high |
17
+ | The same credential pasted into several clients' configs | medium |
18
+ | Config files holding secrets that other users can read | medium |
19
+ | `npx`, `uvx` and `docker` servers that run whatever version is newest | medium |
20
+ | Filesystem servers that can read your whole home folder or disk | medium |
21
+ | Permission rules that approve every tool of a server without asking | medium |
22
+ | Tools on a client's auto-approve list | low |
23
+ | The same server set up separately in several clients | low |
24
+
25
+ ```text
26
+ toolstile audit · local MCP config check · nothing leaves this machine
27
+
28
+ Scanned 4 files from Claude Code, Cursor, Codex: 10 MCP servers.
29
+
30
+ Grade C 67/100 1 high · 2 medium · 3 low
31
+
32
+ HIGH Plaintext secret in github (Cursor)
33
+ A GitHub token is written directly into the config file. Anything that can
34
+ read the file, including backups, dotfile syncs and other tools, can use it.
35
+ ~/.cursor/mcp.json mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN
36
+ Fix: Reference an environment variable instead (for example ${API_TOKEN}) …
37
+ ```
38
+
39
+ ## It only reads
40
+
41
+ - Reads config files on your machine. Writes nothing unless you ask for a card.
42
+ - Makes no network requests and collects no telemetry.
43
+ - Never prints, stores or sends secret values. Findings name the file and key,
44
+ for example `mcpServers.github.env.GITHUB_TOKEN`, and the kind of secret.
45
+ - About 1,100 lines of TypeScript with one runtime dependency
46
+ ([smol-toml](https://github.com/squirrelchat/smol-toml), for Codex's TOML
47
+ config) and an optional one ([resvg-js](https://github.com/thx/resvg-js), only
48
+ to render `--card` as PNG). Read it before you run it.
49
+
50
+ ## Options
51
+
52
+ ```text
53
+ --json Print results as JSON
54
+ --card [file] Write a shareable grade card (default toolstile-audit.png;
55
+ use a .svg name for SVG). Contains only the grade and counts.
56
+ --project <dir> Also scan project configs in <dir> (default: current directory)
57
+ --no-project Scan only user-level configs
58
+ --fail-on <level> Exit with code 1 if any finding is at or above high, medium or low
59
+ ```
60
+
61
+ `--fail-on high` makes it usable in CI or a pre-commit hook for repositories that
62
+ check in `.mcp.json`, `.cursor/mcp.json` or `.vscode/mcp.json`.
63
+
64
+ ## Share your grade
65
+
66
+ ```sh
67
+ npx toolstile audit --card
68
+ ```
69
+
70
+ writes a 1200×630 image with your grade and counts (no server names, paths or
71
+ values) and prints a ready-to-post link.
72
+
73
+ ## Files it reads
74
+
75
+ | Client | User config | Project config |
76
+ | --- | --- | --- |
77
+ | Claude Code | `~/.claude.json`, `~/.claude/settings.json` | `.mcp.json`, `.claude/settings*.json` |
78
+ | Claude Desktop | `claude_desktop_config.json` in the app's config folder | |
79
+ | Cursor | `~/.cursor/mcp.json` | `.cursor/mcp.json` |
80
+ | Codex | `$CODEX_HOME/config.toml` (default `~/.codex`) | `.codex/config.toml` |
81
+ | VS Code | user `settings.json` and `mcp.json` | `.vscode/mcp.json` |
82
+ | Windsurf | `~/.codeium/windsurf/mcp_config.json` | |
83
+ | Gemini CLI | `~/.gemini/settings.json` | `.gemini/settings.json` |
84
+ | Cline | `cline_mcp_settings.json` in VS Code or Cursor storage | |
85
+
86
+ ## Use it from code
87
+
88
+ ```ts
89
+ import { audit } from "toolstile";
90
+
91
+ const { grade, findings } = audit();
92
+ ```
93
+
94
+ ## About
95
+
96
+ Built by [Akshit Kr Nagpal](https://akshit.io) as part of
97
+ [Toolstile](https://toolstile.com), an MCP gateway that keeps upstream
98
+ credentials off your machines and lets you choose which tools each agent can use.
99
+ The audit works on its own and does not require a Toolstile account.
100
+
101
+ Questions and reports: support@toolstile.com.
102
+
103
+ ## License
104
+
105
+ [MIT](LICENSE)
package/dist/card.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { AuditResult } from "./types.js";
2
+ /** One line for posting. Contains only counts, never names, paths or values. */
3
+ export declare function shareText(r: AuditResult): string;
4
+ export declare const shareUrl: (r: AuditResult) => string;
5
+ /** A 1200x630 social card. Contains only the grade and counts. */
6
+ export declare function renderCard(r: AuditResult): string;
package/dist/card.js ADDED
@@ -0,0 +1,51 @@
1
+ import { counts } from "./report.js";
2
+ const GRADE_COLOR = {
3
+ A: "#69D5A0",
4
+ B: "#69D5A0",
5
+ C: "#F0BA7F",
6
+ D: "#FF92A5",
7
+ F: "#FF92A5",
8
+ };
9
+ const escape = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
10
+ /** One line for posting. Contains only counts, never names, paths or values. */
11
+ export function shareText(r) {
12
+ const c = counts(r.findings);
13
+ const parts = [
14
+ c.secrets && `${c.secrets} plaintext secret${c.secrets === 1 ? "" : "s"}`,
15
+ `${r.servers} MCP server${r.servers === 1 ? "" : "s"} across ${r.clients.length} client${r.clients.length === 1 ? "" : "s"}`,
16
+ ].filter(Boolean);
17
+ return `My AI agents' MCP setup scored ${r.grade} (${r.score}/100): ${parts.join(", ")}. Check yours: npx toolstile audit`;
18
+ }
19
+ export const shareUrl = (r) => `https://x.com/intent/post?text=${encodeURIComponent(shareText(r))}`;
20
+ /** A 1200x630 social card. Contains only the grade and counts. */
21
+ export function renderCard(r) {
22
+ const c = counts(r.findings);
23
+ const grade = r.grade ?? "–";
24
+ const color = GRADE_COLOR[grade] ?? "#B2BDD1";
25
+ const stats = [
26
+ [String(c.secrets), c.secrets === 1 ? "plaintext secret" : "plaintext secrets"],
27
+ [String(c.high), "high-risk issues"],
28
+ [String(r.servers), r.servers === 1 ? "MCP server" : "MCP servers"],
29
+ [String(r.clients.length), r.clients.length === 1 ? "AI client" : "AI clients"],
30
+ ];
31
+ const font = "'IBM Plex Sans', 'Inter', 'Helvetica Neue', Arial, sans-serif";
32
+ const mono = "'IBM Plex Mono', 'SFMono-Regular', Menlo, Consolas, monospace";
33
+ const statSvg = stats
34
+ .map(([value, label], i) => `
35
+ <g transform="translate(${560 + (i % 2) * 300}, ${250 + Math.floor(i / 2) * 150})">
36
+ <text font-family="${font}" font-size="64" font-weight="600" fill="#EEF2FA">${escape(value)}</text>
37
+ <text y="40" font-family="${font}" font-size="24" fill="#B2BDD1">${escape(label)}</text>
38
+ </g>`)
39
+ .join("");
40
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
41
+ <rect width="1200" height="630" fill="#0E1320"/>
42
+ <rect x="40" y="40" width="1120" height="550" rx="12" fill="#161D2D"/>
43
+ <text x="96" y="130" font-family="${font}" font-size="30" font-weight="600" fill="#EEF2FA">My AI agents' MCP config</text>
44
+ <text x="96" y="168" font-family="${font}" font-size="22" fill="#B2BDD1">Score ${r.score ?? "–"}/100</text>
45
+ <text x="96" y="470" font-family="${font}" font-size="320" font-weight="700" fill="${color}">${escape(grade)}</text>${statSvg}
46
+ <line x1="96" y1="520" x2="1104" y2="520" stroke="#364158"/>
47
+ <text x="96" y="560" font-family="${mono}" font-size="26" fill="#7D97FF">npx toolstile audit</text>
48
+ <text x="1104" y="560" text-anchor="end" font-family="${font}" font-size="22" fill="#B2BDD1">toolstile.com</text>
49
+ </svg>
50
+ `;
51
+ }
@@ -0,0 +1,18 @@
1
+ import { type Discovery } from "./sources.js";
2
+ import type { Finding, ServerEntry } from "./types.js";
3
+ interface Package {
4
+ registry: "npm" | "pypi" | "docker";
5
+ name: string;
6
+ version: string | null;
7
+ argIndex: number;
8
+ }
9
+ /** Finds the package a launcher such as npx, uvx or docker runs. */
10
+ export declare function packageOf(s: ServerEntry): Package | null;
11
+ /** A stable identity for the same server configured in several places. */
12
+ export declare function identityOf(s: ServerEntry): string | null;
13
+ export declare function runChecks(d: Discovery, home: string): Finding[];
14
+ export declare function score(findings: Finding[]): {
15
+ score: number;
16
+ grade: string;
17
+ };
18
+ export {};
package/dist/checks.js ADDED
@@ -0,0 +1,322 @@
1
+ import { createHash } from "node:crypto";
2
+ import { posix, win32 } from "node:path";
3
+ import { argSecrets, classify, urlSecret } from "./secrets.js";
4
+ import { CLIENT_NAMES, pointerJoin } from "./sources.js";
5
+ const label = (s) => `${s.name} (${CLIENT_NAMES[s.client]})`;
6
+ const at = (s, ...keys) => ({
7
+ file: s.file,
8
+ pointer: keys.reduce(pointerJoin, s.pointer),
9
+ });
10
+ const hash = (v) => createHash("sha256").update(v.trim()).digest("hex");
11
+ function secretsOf(s) {
12
+ const out = [];
13
+ const add = (value, kind, ...keys) => out.push({ server: s, location: at(s, ...keys), kind, hash: hash(value) });
14
+ for (const [k, v] of Object.entries(s.env)) {
15
+ const kind = classify(k, v);
16
+ if (kind)
17
+ add(v, kind, "env", k);
18
+ }
19
+ for (const [k, v] of Object.entries(s.headers)) {
20
+ const kind = classify(k, v);
21
+ if (kind)
22
+ add(v, kind, "headers", k);
23
+ }
24
+ for (const [k, v] of Object.entries(s.extraSecrets)) {
25
+ const kind = classify(k, v);
26
+ if (kind)
27
+ add(v, kind, k);
28
+ }
29
+ for (const { index, label: kind } of argSecrets(s.args))
30
+ add(s.args[index], kind, "args", String(index));
31
+ if (s.url) {
32
+ const kind = urlSecret(s.url);
33
+ if (kind)
34
+ add(s.url, kind, "url");
35
+ }
36
+ return out;
37
+ }
38
+ const LAUNCHERS = {
39
+ npx: "npm",
40
+ bunx: "npm",
41
+ pnpx: "npm",
42
+ uvx: "pypi",
43
+ };
44
+ /** Splits `command` and `args` into words, handling configs that put the whole command in one string. */
45
+ function words(s) {
46
+ if (!s.command)
47
+ return [];
48
+ const command = s.args.length === 0 && /\s/.test(s.command.trim()) ? s.command.trim().split(/\s+/) : [s.command];
49
+ return [...command, ...s.args];
50
+ }
51
+ const base = (p) => win32.basename(posix.basename(p)).replace(/\.(cmd|exe)$/i, "");
52
+ /** Finds the package a launcher such as npx, uvx or docker runs. */
53
+ export function packageOf(s) {
54
+ const w = words(s);
55
+ if (w.length === 0)
56
+ return null;
57
+ const cmd = base(w[0]);
58
+ const sub = w.slice(1);
59
+ let registry = LAUNCHERS[cmd];
60
+ let skip = 0;
61
+ if (!registry) {
62
+ if (cmd === "pipx" && sub[0] === "run")
63
+ [registry, skip] = ["pypi", 1];
64
+ else if ((cmd === "pnpm" || cmd === "yarn" || cmd === "bun") && sub[0] === "dlx")
65
+ [registry, skip] = ["npm", 1];
66
+ else if (cmd === "bun" && sub[0] === "x")
67
+ [registry, skip] = ["npm", 1];
68
+ else if (cmd === "uv" && sub[0] === "tool" && sub[1] === "run")
69
+ [registry, skip] = ["pypi", 2];
70
+ else if (cmd === "docker" && sub[0] === "run")
71
+ [registry, skip] = ["docker", 1];
72
+ }
73
+ if (!registry)
74
+ return null;
75
+ const rest = sub.slice(skip);
76
+ const offset = 1 + skip;
77
+ // Flags that take a value; their value is not the package.
78
+ const valued = new Set(["-p", "--package", "--from", "--with", "-e", "--env", "-v", "--volume", "--name", "--network", "--mount", "--env-file", "-w", "--workdir", "--entrypoint", "-u", "--user", "--python"]);
79
+ for (let i = 0; i < rest.length; i++) {
80
+ const a = rest[i];
81
+ if (a === "--")
82
+ continue;
83
+ if (a.startsWith("-")) {
84
+ if (valued.has(a)) {
85
+ if ((a === "--package" || a === "-p" || a === "--from") && registry !== "docker") {
86
+ return parsePackage(registry, rest[i + 1] ?? "", offset + i + 1);
87
+ }
88
+ i++;
89
+ }
90
+ continue;
91
+ }
92
+ return parsePackage(registry, a, offset + i);
93
+ }
94
+ return null;
95
+ }
96
+ function parsePackage(registry, spec, argIndex) {
97
+ if (!spec)
98
+ return null;
99
+ if (registry === "docker") {
100
+ const at = spec.indexOf("@sha256:");
101
+ if (at > 0)
102
+ return { registry, name: spec.slice(0, at), version: spec.slice(at + 1), argIndex };
103
+ const slash = spec.lastIndexOf("/");
104
+ const colon = spec.lastIndexOf(":");
105
+ return colon > slash
106
+ ? { registry, name: spec.slice(0, colon), version: spec.slice(colon + 1), argIndex }
107
+ : { registry, name: spec, version: null, argIndex };
108
+ }
109
+ if (registry === "pypi") {
110
+ const m = /^([A-Za-z0-9._\-[\],]+?)(?:(==|@|>=|~=)(.+))?$/.exec(spec);
111
+ if (!m)
112
+ return null;
113
+ return { registry, name: m[1], version: m[2] === "==" || m[2] === "@" ? m[3] : null, argIndex };
114
+ }
115
+ if (/^(https?:|git\+|file:|\.|\/)/.test(spec))
116
+ return null;
117
+ const at = spec.indexOf("@", spec.startsWith("@") ? 1 : 0);
118
+ return at > 0
119
+ ? { registry, name: spec.slice(0, at), version: spec.slice(at + 1), argIndex }
120
+ : { registry, name: spec, version: null, argIndex };
121
+ }
122
+ const isPinned = (p) => p.version !== null &&
123
+ p.version !== "latest" &&
124
+ p.version !== "next" &&
125
+ !/[\^~*xX><]/.test(p.version) &&
126
+ (p.registry !== "npm" || /^\d+\.\d+\.\d+/.test(p.version) || p.version.startsWith("sha"));
127
+ /** A stable identity for the same server configured in several places. */
128
+ export function identityOf(s) {
129
+ if (s.url) {
130
+ try {
131
+ const u = new URL(s.url);
132
+ return `url:${u.host}${u.pathname.replace(/\/+$/, "")}`;
133
+ }
134
+ catch {
135
+ return `url:${s.url}`;
136
+ }
137
+ }
138
+ const pkg = packageOf(s);
139
+ if (pkg)
140
+ return `${pkg.registry}:${pkg.name.toLowerCase()}`;
141
+ const w = words(s);
142
+ return w.length ? `cmd:${w.join(" ")}` : null;
143
+ }
144
+ const LOOPBACK = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)$/i;
145
+ function broadRoots(s, home) {
146
+ const pkg = packageOf(s);
147
+ const isFs = pkg ? /server-filesystem|mcp-server-filesystem|filesystem-mcp/i.test(pkg.name) : false;
148
+ if (!isFs || !pkg)
149
+ return [];
150
+ const homes = new Set([home, home.replace(/\/+$/, ""), "~", "~/", "$HOME", "${HOME}", "%USERPROFILE%"]);
151
+ return words(s)
152
+ .slice(pkg.argIndex + 1)
153
+ .filter((a) => a === "/" || /^[A-Za-z]:\\?$/.test(a) || homes.has(a) || homes.has(a.replace(/\/+$/, "")));
154
+ }
155
+ const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
156
+ export function runChecks(d, home) {
157
+ const findings = [];
158
+ const secrets = d.servers.flatMap(secretsOf);
159
+ // Plaintext secrets, grouped per server.
160
+ const byServer = new Map();
161
+ for (const s of secrets)
162
+ byServer.set(s.server, [...(byServer.get(s.server) ?? []), s]);
163
+ for (const [server, list] of byServer) {
164
+ const kinds = [...new Set(list.map((s) => s.kind))].join(", ");
165
+ const one = list.length === 1;
166
+ const what = one ? `${/^[aeiou]/i.test(kinds) ? "An" : "A"} ${kinds} is` : `${list.length} secrets (${kinds}) are`;
167
+ findings.push({
168
+ rule: "plaintext-secret",
169
+ severity: "high",
170
+ title: `Plaintext secret in ${label(server)}`,
171
+ detail: `${what} written directly into the config file. Anything that can read the file, including backups, dotfile syncs and other tools, can use ${one ? "it" : "them"}.`,
172
+ fix: "Reference an environment variable instead (for example ${API_TOKEN}, or \"Bearer ${API_TOKEN}\" in a header), or use the server's OAuth sign-in. Rotate the secret if the file was ever shared or synced.",
173
+ locations: list.map((s) => s.location),
174
+ });
175
+ }
176
+ // The same secret copied into several files.
177
+ const byHash = new Map();
178
+ for (const s of secrets)
179
+ byHash.set(s.hash, [...(byHash.get(s.hash) ?? []), s]);
180
+ for (const list of byHash.values()) {
181
+ const files = new Set(list.map((s) => s.location.file));
182
+ if (files.size < 2)
183
+ continue;
184
+ findings.push({
185
+ rule: "copied-secret",
186
+ severity: "medium",
187
+ title: `Same ${list[0].kind} copied into ${files.size} files`,
188
+ detail: `One credential is pasted into ${files.size} separate config files (${[...new Set(list.map((s) => CLIENT_NAMES[s.server.client]))].join(", ")}). Rotating or revoking it means finding every copy.`,
189
+ fix: "Keep one copy in your environment or keychain and reference it from each client, or route the clients through one gateway.",
190
+ locations: list.map((s) => s.location),
191
+ });
192
+ }
193
+ // Config files that hold secrets but are readable by other users.
194
+ const secretFiles = new Set(secrets.map((s) => s.location.file));
195
+ for (const f of d.files) {
196
+ if (f.mode === undefined || !secretFiles.has(f.path) || (f.mode & 0o077) === 0)
197
+ continue;
198
+ findings.push({
199
+ rule: "readable-config",
200
+ severity: "medium",
201
+ title: "Config file with secrets is readable by other users",
202
+ detail: `This file contains plaintext secrets and has permissions ${f.mode.toString(8).padStart(3, "0")}.`,
203
+ fix: `Restrict it to your user: chmod 600 "${f.path}"`,
204
+ locations: [{ file: f.path, pointer: "(file permissions)" }],
205
+ });
206
+ }
207
+ for (const s of d.servers) {
208
+ if (s.url) {
209
+ try {
210
+ const u = new URL(s.url);
211
+ if (u.protocol === "http:" && !LOOPBACK.test(u.hostname)) {
212
+ findings.push({
213
+ rule: "insecure-transport",
214
+ severity: "high",
215
+ title: `Unencrypted remote server: ${label(s)}`,
216
+ detail: `${u.host} is reached over plain http://, so requests, tool results and any credentials travel unencrypted.`,
217
+ fix: "Use the server's https:// endpoint.",
218
+ locations: [at(s, "url")],
219
+ });
220
+ }
221
+ }
222
+ catch {
223
+ // Not a URL; nothing to check.
224
+ }
225
+ }
226
+ const pkg = packageOf(s);
227
+ if (pkg && !isPinned(pkg)) {
228
+ const where = pkg.registry === "docker" ? "image" : "package";
229
+ findings.push({
230
+ rule: "unpinned-package",
231
+ severity: "medium",
232
+ title: `Unpinned ${where}: ${label(s)}`,
233
+ detail: `${pkg.name}${pkg.version ? `@${pkg.version}` : ""} resolves to whatever version is newest each time the server starts, so a compromised or broken release runs with this server's access.`,
234
+ fix: pkg.registry === "docker"
235
+ ? `Pin an image digest or exact tag, for example ${pkg.name}:<version>.`
236
+ : `Pin an exact version, for example ${pkg.name}${pkg.registry === "pypi" ? "==" : "@"}<version>.`,
237
+ locations: [at(s, "args", String(Math.max(0, pkg.argIndex - 1)))],
238
+ });
239
+ }
240
+ const w = words(s);
241
+ if (pkg?.registry === "docker") {
242
+ const risky = w.filter((a) => a === "--privileged" || /\/var\/run\/docker\.sock/.test(a) || a === "--net=host" || a === "--network=host");
243
+ if (risky.length) {
244
+ findings.push({
245
+ rule: "privileged-container",
246
+ severity: "high",
247
+ title: `Container with host-level access: ${label(s)}`,
248
+ detail: `The server runs with ${[...new Set(risky)].join(", ")}, which gives it effective control of the host.`,
249
+ fix: "Remove --privileged and the Docker socket mount unless the server truly needs them.",
250
+ locations: [at(s, "args")],
251
+ });
252
+ }
253
+ }
254
+ const roots = broadRoots(s, home);
255
+ if (roots.length) {
256
+ findings.push({
257
+ rule: "broad-filesystem",
258
+ severity: "medium",
259
+ title: `Filesystem server can read ${roots.includes("/") ? "the whole disk" : "your whole home folder"}: ${label(s)}`,
260
+ detail: `Allowed root ${roots.join(", ")} includes SSH keys, cloud credentials and every other config file.`,
261
+ fix: "Allow only the project folders the agent needs.",
262
+ locations: [at(s, "args")],
263
+ });
264
+ }
265
+ if (s.autoApproved.length) {
266
+ findings.push({
267
+ rule: "auto-approved-tools",
268
+ severity: "low",
269
+ title: `${s.autoApproved.length} tool${s.autoApproved.length === 1 ? "" : "s"} run without asking: ${label(s)}`,
270
+ detail: `Auto-approved: ${s.autoApproved.slice(0, 6).join(", ")}${s.autoApproved.length > 6 ? ", …" : ""}. A prompt injection in any content the agent reads can call these.`,
271
+ fix: "Keep auto-approval to read-only tools.",
272
+ locations: [at(s)],
273
+ });
274
+ }
275
+ }
276
+ for (const a of d.approvals) {
277
+ const all = a.subject === "mcp__*" || a.subject.startsWith("every");
278
+ findings.push({
279
+ rule: "auto-approved-tools",
280
+ severity: "medium",
281
+ title: all
282
+ ? `${CLIENT_NAMES[a.client]} approves ${a.subject === "mcp__*" ? "every MCP tool" : a.subject} without asking`
283
+ : `${CLIENT_NAMES[a.client]} runs every tool of ${a.subject.replace(/^mcp__/, "").replace(/__\*$/, "")} without asking`,
284
+ detail: `Permission rule ${a.subject} skips the confirmation prompt, including for write and delete tools. A prompt injection in a web page, issue or email the agent reads can call them.`,
285
+ fix: "Allow specific read-only tools (mcp__server__tool) instead of whole servers.",
286
+ locations: [a.location],
287
+ });
288
+ }
289
+ // The same server configured separately in several clients.
290
+ const byIdentity = new Map();
291
+ for (const s of d.servers) {
292
+ const id = identityOf(s);
293
+ if (id)
294
+ byIdentity.set(id, [...(byIdentity.get(id) ?? []), s]);
295
+ }
296
+ for (const list of byIdentity.values()) {
297
+ const clients = [...new Set(list.map((s) => s.client))];
298
+ if (clients.length < 2)
299
+ continue;
300
+ findings.push({
301
+ rule: "duplicated-server",
302
+ severity: "low",
303
+ title: `${list[0].name} is set up separately in ${clients.length} clients`,
304
+ detail: `Configured in ${clients.map((c) => CLIENT_NAMES[c]).join(", ")}. Each copy has its own credentials and settings to keep in sync, rotate and revoke.`,
305
+ fix: "Configure it once and share it across agents.",
306
+ locations: list.map((s) => at(s)),
307
+ });
308
+ }
309
+ return findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
310
+ }
311
+ const PENALTY = { high: 15, medium: 6, low: 2 };
312
+ const CAP = { high: 60, medium: 30, low: 10 };
313
+ export function score(findings) {
314
+ let total = 100;
315
+ for (const sev of ["high", "medium", "low"]) {
316
+ const n = findings.filter((f) => f.severity === sev).length;
317
+ total -= Math.min(n * PENALTY[sev], CAP[sev]);
318
+ }
319
+ total = Math.max(0, total);
320
+ const grade = total >= 90 ? "A" : total >= 80 ? "B" : total >= 65 ? "C" : total >= 50 ? "D" : "F";
321
+ return { score: total, grade };
322
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};