toolstile 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +125 -2
- package/dist/card.d.ts +6 -0
- package/dist/card.js +51 -0
- package/dist/checks.d.ts +24 -0
- package/dist/checks.js +343 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +160 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +25 -0
- package/dist/parse.d.ts +3 -0
- package/dist/parse.js +76 -0
- package/dist/report.d.ts +24 -0
- package/dist/report.js +106 -0
- package/dist/secrets.d.ts +18 -0
- package/dist/secrets.js +122 -0
- package/dist/sources.d.ts +27 -0
- package/dist/sources.js +211 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.js +1 -0
- package/package.json +53 -4
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,126 @@
|
|
|
1
|
-
#
|
|
1
|
+
# toolstile
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Check the MCP server configs your AI agents use, in one command:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx toolstile audit
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The same package is also published as `@toolstile/cli` (`npx @toolstile/cli audit`).
|
|
10
|
+
|
|
11
|
+
It reads the configs of Claude Code, Claude Desktop, Cursor, Codex, VS Code,
|
|
12
|
+
Windsurf, Gemini CLI and Cline and grades them for:
|
|
13
|
+
|
|
14
|
+
| Check | Severity |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| API keys, tokens and passwords written into config files, URLs or arguments | high |
|
|
17
|
+
| Remote servers reached over plain `http://` | high |
|
|
18
|
+
| Containers run with `--privileged` or the Docker socket | high |
|
|
19
|
+
| The same credential pasted into several clients' configs | medium |
|
|
20
|
+
| Config files holding secrets that other accounts can reach (file and folder permissions) | medium |
|
|
21
|
+
| `npx`, `uvx` and `docker` servers that run whatever version is newest | medium |
|
|
22
|
+
| Filesystem servers that can read your whole home folder or disk | medium |
|
|
23
|
+
| Permission rules that approve every tool of a server without asking | medium |
|
|
24
|
+
| Tools on a client's auto-approve list | low |
|
|
25
|
+
|
|
26
|
+
It also lists servers set up separately in several clients. That is context, not
|
|
27
|
+
scored: remote servers need an entry per client.
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
toolstile audit · local MCP config check · nothing leaves this machine
|
|
31
|
+
|
|
32
|
+
Scanned 4 files from Claude Code, Cursor, Codex: 10 MCP servers.
|
|
33
|
+
|
|
34
|
+
Grade B 77/100 1 high · 1 medium · 1 low
|
|
35
|
+
|
|
36
|
+
HIGH Plaintext secret in github (Cursor)
|
|
37
|
+
A GitHub token is written directly into the config file. Anything that can
|
|
38
|
+
read the file, including backups, dotfile syncs and other tools, can use it.
|
|
39
|
+
~/.cursor/mcp.json mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN
|
|
40
|
+
Fix: Reference an environment variable instead (for example ${API_TOKEN}) …
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## It only reads
|
|
44
|
+
|
|
45
|
+
- Reads config files on your machine. Writes nothing unless you ask for a card.
|
|
46
|
+
- Makes no network requests and collects no telemetry.
|
|
47
|
+
- Never prints, stores or sends secret values. Findings name the file and key,
|
|
48
|
+
for example `mcpServers.github.env.GITHUB_TOKEN`, and the kind of secret.
|
|
49
|
+
- About 1,100 lines of TypeScript with one runtime dependency
|
|
50
|
+
([smol-toml](https://github.com/squirrelchat/smol-toml), for Codex's TOML
|
|
51
|
+
config) and an optional one ([resvg-js](https://github.com/thx/resvg-js), only
|
|
52
|
+
to render `--card` as PNG). Read it before you run it.
|
|
53
|
+
|
|
54
|
+
## Options
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
--json Print results as JSON
|
|
58
|
+
--card [file] Write a shareable grade card (default toolstile-audit.png;
|
|
59
|
+
use a .svg name for SVG). Contains only the grade and counts.
|
|
60
|
+
--redact Hide your home folder and project paths, for posting the output
|
|
61
|
+
--project <dir> Also scan project configs in <dir> (default: current directory)
|
|
62
|
+
--no-project Scan only user-level configs
|
|
63
|
+
--fail-on <level> Exit with code 1 if any finding is at or above high, medium or low
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`--fail-on high` makes it usable in CI or a pre-commit hook for repositories that
|
|
67
|
+
check in `.mcp.json`, `.cursor/mcp.json` or `.vscode/mcp.json`.
|
|
68
|
+
|
|
69
|
+
## Scoring
|
|
70
|
+
|
|
71
|
+
Each distinct problem has a weight: 10 for a leaked secret, plain-http remote or
|
|
72
|
+
privileged container; 4 for readable secret files, unpinned packages, broad
|
|
73
|
+
filesystem access and whole-server auto-approval; 2 for each copied secret; 1 for
|
|
74
|
+
auto-approved tools. A secret pasted into several files counts as one leak. The
|
|
75
|
+
score is `100 / (1 + weight / 50)`: one leaked secret scores 83 (B), four score
|
|
76
|
+
about 56 (D). Grades: A 90+, B 75+, C 60+, D 40+, F below 40.
|
|
77
|
+
|
|
78
|
+
## Share your grade
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
npx toolstile audit --card
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
writes a 1200×630 image with your grade and counts (no server names, paths or
|
|
85
|
+
values) and prints a ready-to-post link. To post the text report instead, add
|
|
86
|
+
`--redact` so it shows `~` and `<project>` in place of your folders.
|
|
87
|
+
|
|
88
|
+
## Files it reads
|
|
89
|
+
|
|
90
|
+
| Client | User config | Project config |
|
|
91
|
+
| --- | --- | --- |
|
|
92
|
+
| Claude Code | `~/.claude.json`, `~/.claude/settings.json` | `.mcp.json`, `.claude/settings*.json` |
|
|
93
|
+
| Claude Desktop | `claude_desktop_config.json` in the app's config folder | |
|
|
94
|
+
| Cursor | `~/.cursor/mcp.json` | `.cursor/mcp.json` |
|
|
95
|
+
| Codex | `$CODEX_HOME/config.toml` (default `~/.codex`) | `.codex/config.toml` |
|
|
96
|
+
| VS Code | user `settings.json` and `mcp.json` | `.vscode/mcp.json` |
|
|
97
|
+
| Windsurf | `~/.codeium/windsurf/mcp_config.json` | |
|
|
98
|
+
| Gemini CLI | `~/.gemini/settings.json` | `.gemini/settings.json` |
|
|
99
|
+
| Cline | `cline_mcp_settings.json` in VS Code or Cursor storage | |
|
|
100
|
+
|
|
101
|
+
## Use it from code
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { audit } from "toolstile";
|
|
105
|
+
|
|
106
|
+
const { grade, findings } = audit();
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## About
|
|
110
|
+
|
|
111
|
+
Built by [Akshit Kr Nagpal](https://akshit.io) as part of
|
|
112
|
+
[Toolstile](https://toolstile.com), an MCP gateway that keeps upstream
|
|
113
|
+
credentials off your machines and lets you choose which tools each agent can use.
|
|
114
|
+
The audit works on its own and does not require a Toolstile account.
|
|
115
|
+
|
|
116
|
+
Questions and reports: support@toolstile.com.
|
|
117
|
+
|
|
118
|
+
## Releases
|
|
119
|
+
|
|
120
|
+
Both npm packages are published by [GitHub Actions](.github/workflows/publish.yml)
|
|
121
|
+
with npm trusted publishing and provenance, so each release links back to the
|
|
122
|
+
commit and workflow that built it. No npm token is stored anywhere.
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
[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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
+
}
|
package/dist/checks.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
/** A path for a shell command: `~/...` under the home folder, single-quoted when needed. */
|
|
14
|
+
export declare function shellPath(path: string, home: string): string;
|
|
15
|
+
export declare function runChecks(d: Discovery, home: string): Finding[];
|
|
16
|
+
/**
|
|
17
|
+
* 100 / (1 + weight / 50): one leaked secret (10) scores 83, four score about 50, and the score
|
|
18
|
+
* keeps separating bad setups from worse ones instead of bottoming out at zero.
|
|
19
|
+
*/
|
|
20
|
+
export declare function score(findings: Finding[]): {
|
|
21
|
+
score: number;
|
|
22
|
+
grade: string;
|
|
23
|
+
};
|
|
24
|
+
export {};
|
package/dist/checks.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
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, info: 3 };
|
|
156
|
+
/** Toolstile's own gateway is meant to be added to every client. */
|
|
157
|
+
const TOOLSTILE = /^url:([a-z0-9-]+\.)*toolstile\.com\//i;
|
|
158
|
+
/** A path for a shell command: `~/...` under the home folder, single-quoted when needed. */
|
|
159
|
+
export function shellPath(path, home) {
|
|
160
|
+
const quote = (p) => (/^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`);
|
|
161
|
+
return home && path.startsWith(home + "/") ? `~/${quote(path.slice(home.length + 1))}` : quote(path);
|
|
162
|
+
}
|
|
163
|
+
export function runChecks(d, home) {
|
|
164
|
+
const findings = [];
|
|
165
|
+
const secrets = d.servers.flatMap(secretsOf);
|
|
166
|
+
// Plaintext secrets, grouped per server.
|
|
167
|
+
const byServer = new Map();
|
|
168
|
+
for (const s of secrets)
|
|
169
|
+
byServer.set(s.server, [...(byServer.get(s.server) ?? []), s]);
|
|
170
|
+
const counted = new Set();
|
|
171
|
+
for (const [server, list] of byServer) {
|
|
172
|
+
const fresh = new Set(list.map((s) => s.hash).filter((h) => !counted.has(h)));
|
|
173
|
+
fresh.forEach((h) => counted.add(h));
|
|
174
|
+
const kinds = [...new Set(list.map((s) => s.kind))].join(", ");
|
|
175
|
+
const one = list.length === 1;
|
|
176
|
+
const what = one ? `${/^[aeiou]/i.test(kinds) ? "An" : "A"} ${kinds} is` : `${list.length} secrets (${kinds}) are`;
|
|
177
|
+
findings.push({
|
|
178
|
+
rule: "plaintext-secret",
|
|
179
|
+
severity: "high",
|
|
180
|
+
title: `Plaintext secret in ${label(server)}`,
|
|
181
|
+
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"}.`,
|
|
182
|
+
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.",
|
|
183
|
+
locations: list.map((s) => s.location),
|
|
184
|
+
// A secret pasted into several files is one leak; the copies are scored by copied-secret.
|
|
185
|
+
weight: 10 * fresh.size,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
// The same secret copied into several files.
|
|
189
|
+
const byHash = new Map();
|
|
190
|
+
for (const s of secrets)
|
|
191
|
+
byHash.set(s.hash, [...(byHash.get(s.hash) ?? []), s]);
|
|
192
|
+
for (const list of byHash.values()) {
|
|
193
|
+
const files = new Set(list.map((s) => s.location.file));
|
|
194
|
+
if (files.size < 2)
|
|
195
|
+
continue;
|
|
196
|
+
findings.push({
|
|
197
|
+
rule: "copied-secret",
|
|
198
|
+
severity: "medium",
|
|
199
|
+
title: `Same ${list[0].kind} copied into ${files.size} files`,
|
|
200
|
+
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.`,
|
|
201
|
+
fix: "Keep one copy in your environment or keychain and reference it from each client, or route the clients through one gateway.",
|
|
202
|
+
locations: list.map((s) => s.location),
|
|
203
|
+
weight: 2,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
// Config files that hold secrets and that other accounts on the machine can actually reach.
|
|
207
|
+
const secretFiles = new Set(secrets.map((s) => s.location.file));
|
|
208
|
+
for (const f of d.files) {
|
|
209
|
+
if (f.mode === undefined || !f.exposure || !secretFiles.has(f.path))
|
|
210
|
+
continue;
|
|
211
|
+
const [open, who] = f.exposure === "others" ? ["everyone", "every account on this machine"] : ["its group", "other accounts in that group"];
|
|
212
|
+
findings.push({
|
|
213
|
+
rule: "readable-config",
|
|
214
|
+
severity: "medium",
|
|
215
|
+
title: "Config file with secrets is readable by other accounts",
|
|
216
|
+
detail: `This file holds plaintext secrets and has permissions ${f.mode.toString(8).padStart(3, "0")}, and every folder above it is open to ${open}, so ${who} can read it.`,
|
|
217
|
+
fix: `Restrict it to your user: chmod 600 ${shellPath(f.path, home)}`,
|
|
218
|
+
locations: [{ file: f.path, pointer: "(file permissions)" }],
|
|
219
|
+
weight: 4,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
for (const s of d.servers) {
|
|
223
|
+
if (s.url) {
|
|
224
|
+
try {
|
|
225
|
+
const u = new URL(s.url);
|
|
226
|
+
if (u.protocol === "http:" && !LOOPBACK.test(u.hostname)) {
|
|
227
|
+
findings.push({
|
|
228
|
+
rule: "insecure-transport",
|
|
229
|
+
severity: "high",
|
|
230
|
+
title: `Unencrypted remote server: ${label(s)}`,
|
|
231
|
+
detail: `${u.host} is reached over plain http://, so requests, tool results and any credentials travel unencrypted.`,
|
|
232
|
+
fix: "Use the server's https:// endpoint.",
|
|
233
|
+
locations: [at(s, "url")],
|
|
234
|
+
weight: 10,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Not a URL; nothing to check.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const pkg = packageOf(s);
|
|
243
|
+
if (pkg && !isPinned(pkg)) {
|
|
244
|
+
const where = pkg.registry === "docker" ? "image" : "package";
|
|
245
|
+
findings.push({
|
|
246
|
+
rule: "unpinned-package",
|
|
247
|
+
severity: "medium",
|
|
248
|
+
title: `Unpinned ${where}: ${label(s)}`,
|
|
249
|
+
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.`,
|
|
250
|
+
fix: pkg.registry === "docker"
|
|
251
|
+
? `Pin an image digest or exact tag, for example ${pkg.name}:<version>.`
|
|
252
|
+
: `Pin an exact version, for example ${pkg.name}${pkg.registry === "pypi" ? "==" : "@"}<version>.`,
|
|
253
|
+
locations: [at(s, "args", String(Math.max(0, pkg.argIndex - 1)))],
|
|
254
|
+
weight: 4,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const w = words(s);
|
|
258
|
+
if (pkg?.registry === "docker") {
|
|
259
|
+
const risky = w.filter((a) => a === "--privileged" || /\/var\/run\/docker\.sock/.test(a) || a === "--net=host" || a === "--network=host");
|
|
260
|
+
if (risky.length) {
|
|
261
|
+
findings.push({
|
|
262
|
+
rule: "privileged-container",
|
|
263
|
+
severity: "high",
|
|
264
|
+
title: `Container with host-level access: ${label(s)}`,
|
|
265
|
+
detail: `The server runs with ${[...new Set(risky)].join(", ")}, which gives it effective control of the host.`,
|
|
266
|
+
fix: "Remove --privileged and the Docker socket mount unless the server truly needs them.",
|
|
267
|
+
locations: [at(s, "args")],
|
|
268
|
+
weight: 10,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const roots = broadRoots(s, home);
|
|
273
|
+
if (roots.length) {
|
|
274
|
+
findings.push({
|
|
275
|
+
rule: "broad-filesystem",
|
|
276
|
+
severity: "medium",
|
|
277
|
+
title: `Filesystem server can read ${roots.includes("/") ? "the whole disk" : "your whole home folder"}: ${label(s)}`,
|
|
278
|
+
detail: `Allowed root ${roots.join(", ")} includes SSH keys, cloud credentials and every other config file.`,
|
|
279
|
+
fix: "Allow only the project folders the agent needs.",
|
|
280
|
+
locations: [at(s, "args")],
|
|
281
|
+
weight: 4,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (s.autoApproved.length) {
|
|
285
|
+
findings.push({
|
|
286
|
+
rule: "auto-approved-tools",
|
|
287
|
+
severity: "low",
|
|
288
|
+
title: `${s.autoApproved.length} tool${s.autoApproved.length === 1 ? "" : "s"} run without asking: ${label(s)}`,
|
|
289
|
+
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.`,
|
|
290
|
+
fix: "Keep auto-approval to read-only tools.",
|
|
291
|
+
locations: [at(s)],
|
|
292
|
+
weight: 1,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
for (const a of d.approvals) {
|
|
297
|
+
const all = a.subject === "mcp__*" || a.subject.startsWith("every");
|
|
298
|
+
findings.push({
|
|
299
|
+
rule: "auto-approved-tools",
|
|
300
|
+
severity: "medium",
|
|
301
|
+
title: all
|
|
302
|
+
? `${CLIENT_NAMES[a.client]} approves ${a.subject === "mcp__*" ? "every MCP tool" : a.subject} without asking`
|
|
303
|
+
: `${CLIENT_NAMES[a.client]} runs every tool of ${a.subject.replace(/^mcp__/, "").replace(/__\*$/, "")} without asking`,
|
|
304
|
+
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.`,
|
|
305
|
+
fix: "Allow specific read-only tools (mcp__server__tool) instead of whole servers.",
|
|
306
|
+
locations: [a.location],
|
|
307
|
+
weight: 4,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
// The same server configured separately in several clients. Context only: remote servers need an
|
|
311
|
+
// entry per client, and copies that carry credentials are already scored by copied-secret.
|
|
312
|
+
const byIdentity = new Map();
|
|
313
|
+
for (const s of d.servers) {
|
|
314
|
+
const id = identityOf(s);
|
|
315
|
+
if (id && !TOOLSTILE.test(id))
|
|
316
|
+
byIdentity.set(id, [...(byIdentity.get(id) ?? []), s]);
|
|
317
|
+
}
|
|
318
|
+
for (const list of byIdentity.values()) {
|
|
319
|
+
const clients = [...new Set(list.map((s) => s.client))];
|
|
320
|
+
if (clients.length < 2)
|
|
321
|
+
continue;
|
|
322
|
+
findings.push({
|
|
323
|
+
rule: "duplicated-server",
|
|
324
|
+
severity: "info",
|
|
325
|
+
title: `${list[0].name} is set up separately in ${clients.length} clients`,
|
|
326
|
+
detail: `Configured in ${clients.map((c) => CLIENT_NAMES[c]).join(", ")}. Each copy has its own settings and sign-in to keep in sync and revoke.`,
|
|
327
|
+
fix: "Nothing to fix; listed so you know where each copy lives.",
|
|
328
|
+
locations: list.map((s) => at(s)),
|
|
329
|
+
weight: 0,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* 100 / (1 + weight / 50): one leaked secret (10) scores 83, four score about 50, and the score
|
|
336
|
+
* keeps separating bad setups from worse ones instead of bottoming out at zero.
|
|
337
|
+
*/
|
|
338
|
+
export function score(findings) {
|
|
339
|
+
const weight = findings.reduce((n, f) => n + f.weight, 0);
|
|
340
|
+
const total = Math.round(100 / (1 + weight / 50));
|
|
341
|
+
const grade = total >= 90 ? "A" : total >= 75 ? "B" : total >= 60 ? "C" : total >= 40 ? "D" : "F";
|
|
342
|
+
return { score: total, grade };
|
|
343
|
+
}
|
package/dist/cli.d.ts
ADDED