toolstile 0.1.0 → 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/README.md +25 -4
- package/dist/checks.d.ts +6 -0
- package/dist/checks.js +41 -20
- package/dist/cli.js +10 -5
- package/dist/report.d.ts +8 -2
- package/dist/report.js +53 -11
- package/dist/sources.d.ts +5 -0
- package/dist/sources.js +31 -2
- package/dist/types.d.ts +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Check the MCP server configs your AI agents use, in one command:
|
|
|
6
6
|
npx toolstile audit
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+
The same package is also published as `@toolstile/cli` (`npx @toolstile/cli audit`).
|
|
10
|
+
|
|
9
11
|
It reads the configs of Claude Code, Claude Desktop, Cursor, Codex, VS Code,
|
|
10
12
|
Windsurf, Gemini CLI and Cline and grades them for:
|
|
11
13
|
|
|
@@ -15,19 +17,21 @@ Windsurf, Gemini CLI and Cline and grades them for:
|
|
|
15
17
|
| Remote servers reached over plain `http://` | high |
|
|
16
18
|
| Containers run with `--privileged` or the Docker socket | high |
|
|
17
19
|
| The same credential pasted into several clients' configs | medium |
|
|
18
|
-
| Config files holding secrets that other
|
|
20
|
+
| Config files holding secrets that other accounts can reach (file and folder permissions) | medium |
|
|
19
21
|
| `npx`, `uvx` and `docker` servers that run whatever version is newest | medium |
|
|
20
22
|
| Filesystem servers that can read your whole home folder or disk | medium |
|
|
21
23
|
| Permission rules that approve every tool of a server without asking | medium |
|
|
22
24
|
| Tools on a client's auto-approve list | low |
|
|
23
|
-
|
|
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.
|
|
24
28
|
|
|
25
29
|
```text
|
|
26
30
|
toolstile audit · local MCP config check · nothing leaves this machine
|
|
27
31
|
|
|
28
32
|
Scanned 4 files from Claude Code, Cursor, Codex: 10 MCP servers.
|
|
29
33
|
|
|
30
|
-
Grade
|
|
34
|
+
Grade B 77/100 1 high · 1 medium · 1 low
|
|
31
35
|
|
|
32
36
|
HIGH Plaintext secret in github (Cursor)
|
|
33
37
|
A GitHub token is written directly into the config file. Anything that can
|
|
@@ -53,6 +57,7 @@ HIGH Plaintext secret in github (Cursor)
|
|
|
53
57
|
--json Print results as JSON
|
|
54
58
|
--card [file] Write a shareable grade card (default toolstile-audit.png;
|
|
55
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
|
|
56
61
|
--project <dir> Also scan project configs in <dir> (default: current directory)
|
|
57
62
|
--no-project Scan only user-level configs
|
|
58
63
|
--fail-on <level> Exit with code 1 if any finding is at or above high, medium or low
|
|
@@ -61,6 +66,15 @@ HIGH Plaintext secret in github (Cursor)
|
|
|
61
66
|
`--fail-on high` makes it usable in CI or a pre-commit hook for repositories that
|
|
62
67
|
check in `.mcp.json`, `.cursor/mcp.json` or `.vscode/mcp.json`.
|
|
63
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
|
+
|
|
64
78
|
## Share your grade
|
|
65
79
|
|
|
66
80
|
```sh
|
|
@@ -68,7 +82,8 @@ npx toolstile audit --card
|
|
|
68
82
|
```
|
|
69
83
|
|
|
70
84
|
writes a 1200×630 image with your grade and counts (no server names, paths or
|
|
71
|
-
values) and prints a ready-to-post link.
|
|
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.
|
|
72
87
|
|
|
73
88
|
## Files it reads
|
|
74
89
|
|
|
@@ -100,6 +115,12 @@ The audit works on its own and does not require a Toolstile account.
|
|
|
100
115
|
|
|
101
116
|
Questions and reports: support@toolstile.com.
|
|
102
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
|
+
|
|
103
124
|
## License
|
|
104
125
|
|
|
105
126
|
[MIT](LICENSE)
|
package/dist/checks.d.ts
CHANGED
|
@@ -10,7 +10,13 @@ interface Package {
|
|
|
10
10
|
export declare function packageOf(s: ServerEntry): Package | null;
|
|
11
11
|
/** A stable identity for the same server configured in several places. */
|
|
12
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;
|
|
13
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
|
+
*/
|
|
14
20
|
export declare function score(findings: Finding[]): {
|
|
15
21
|
score: number;
|
|
16
22
|
grade: string;
|
package/dist/checks.js
CHANGED
|
@@ -152,7 +152,14 @@ function broadRoots(s, home) {
|
|
|
152
152
|
.slice(pkg.argIndex + 1)
|
|
153
153
|
.filter((a) => a === "/" || /^[A-Za-z]:\\?$/.test(a) || homes.has(a) || homes.has(a.replace(/\/+$/, "")));
|
|
154
154
|
}
|
|
155
|
-
const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
|
|
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
|
+
}
|
|
156
163
|
export function runChecks(d, home) {
|
|
157
164
|
const findings = [];
|
|
158
165
|
const secrets = d.servers.flatMap(secretsOf);
|
|
@@ -160,7 +167,10 @@ export function runChecks(d, home) {
|
|
|
160
167
|
const byServer = new Map();
|
|
161
168
|
for (const s of secrets)
|
|
162
169
|
byServer.set(s.server, [...(byServer.get(s.server) ?? []), s]);
|
|
170
|
+
const counted = new Set();
|
|
163
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));
|
|
164
174
|
const kinds = [...new Set(list.map((s) => s.kind))].join(", ");
|
|
165
175
|
const one = list.length === 1;
|
|
166
176
|
const what = one ? `${/^[aeiou]/i.test(kinds) ? "An" : "A"} ${kinds} is` : `${list.length} secrets (${kinds}) are`;
|
|
@@ -171,6 +181,8 @@ export function runChecks(d, home) {
|
|
|
171
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"}.`,
|
|
172
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.",
|
|
173
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,
|
|
174
186
|
});
|
|
175
187
|
}
|
|
176
188
|
// The same secret copied into several files.
|
|
@@ -188,20 +200,23 @@ export function runChecks(d, home) {
|
|
|
188
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.`,
|
|
189
201
|
fix: "Keep one copy in your environment or keychain and reference it from each client, or route the clients through one gateway.",
|
|
190
202
|
locations: list.map((s) => s.location),
|
|
203
|
+
weight: 2,
|
|
191
204
|
});
|
|
192
205
|
}
|
|
193
|
-
// Config files that hold secrets
|
|
206
|
+
// Config files that hold secrets and that other accounts on the machine can actually reach.
|
|
194
207
|
const secretFiles = new Set(secrets.map((s) => s.location.file));
|
|
195
208
|
for (const f of d.files) {
|
|
196
|
-
if (f.mode === undefined || !
|
|
209
|
+
if (f.mode === undefined || !f.exposure || !secretFiles.has(f.path))
|
|
197
210
|
continue;
|
|
211
|
+
const [open, who] = f.exposure === "others" ? ["everyone", "every account on this machine"] : ["its group", "other accounts in that group"];
|
|
198
212
|
findings.push({
|
|
199
213
|
rule: "readable-config",
|
|
200
214
|
severity: "medium",
|
|
201
|
-
title: "Config file with secrets is readable by other
|
|
202
|
-
detail: `This file
|
|
203
|
-
fix: `Restrict it to your user: chmod 600
|
|
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)}`,
|
|
204
218
|
locations: [{ file: f.path, pointer: "(file permissions)" }],
|
|
219
|
+
weight: 4,
|
|
205
220
|
});
|
|
206
221
|
}
|
|
207
222
|
for (const s of d.servers) {
|
|
@@ -216,6 +231,7 @@ export function runChecks(d, home) {
|
|
|
216
231
|
detail: `${u.host} is reached over plain http://, so requests, tool results and any credentials travel unencrypted.`,
|
|
217
232
|
fix: "Use the server's https:// endpoint.",
|
|
218
233
|
locations: [at(s, "url")],
|
|
234
|
+
weight: 10,
|
|
219
235
|
});
|
|
220
236
|
}
|
|
221
237
|
}
|
|
@@ -235,6 +251,7 @@ export function runChecks(d, home) {
|
|
|
235
251
|
? `Pin an image digest or exact tag, for example ${pkg.name}:<version>.`
|
|
236
252
|
: `Pin an exact version, for example ${pkg.name}${pkg.registry === "pypi" ? "==" : "@"}<version>.`,
|
|
237
253
|
locations: [at(s, "args", String(Math.max(0, pkg.argIndex - 1)))],
|
|
254
|
+
weight: 4,
|
|
238
255
|
});
|
|
239
256
|
}
|
|
240
257
|
const w = words(s);
|
|
@@ -248,6 +265,7 @@ export function runChecks(d, home) {
|
|
|
248
265
|
detail: `The server runs with ${[...new Set(risky)].join(", ")}, which gives it effective control of the host.`,
|
|
249
266
|
fix: "Remove --privileged and the Docker socket mount unless the server truly needs them.",
|
|
250
267
|
locations: [at(s, "args")],
|
|
268
|
+
weight: 10,
|
|
251
269
|
});
|
|
252
270
|
}
|
|
253
271
|
}
|
|
@@ -260,6 +278,7 @@ export function runChecks(d, home) {
|
|
|
260
278
|
detail: `Allowed root ${roots.join(", ")} includes SSH keys, cloud credentials and every other config file.`,
|
|
261
279
|
fix: "Allow only the project folders the agent needs.",
|
|
262
280
|
locations: [at(s, "args")],
|
|
281
|
+
weight: 4,
|
|
263
282
|
});
|
|
264
283
|
}
|
|
265
284
|
if (s.autoApproved.length) {
|
|
@@ -270,6 +289,7 @@ export function runChecks(d, home) {
|
|
|
270
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.`,
|
|
271
290
|
fix: "Keep auto-approval to read-only tools.",
|
|
272
291
|
locations: [at(s)],
|
|
292
|
+
weight: 1,
|
|
273
293
|
});
|
|
274
294
|
}
|
|
275
295
|
}
|
|
@@ -284,13 +304,15 @@ export function runChecks(d, home) {
|
|
|
284
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.`,
|
|
285
305
|
fix: "Allow specific read-only tools (mcp__server__tool) instead of whole servers.",
|
|
286
306
|
locations: [a.location],
|
|
307
|
+
weight: 4,
|
|
287
308
|
});
|
|
288
309
|
}
|
|
289
|
-
// The same server configured separately in several clients.
|
|
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.
|
|
290
312
|
const byIdentity = new Map();
|
|
291
313
|
for (const s of d.servers) {
|
|
292
314
|
const id = identityOf(s);
|
|
293
|
-
if (id)
|
|
315
|
+
if (id && !TOOLSTILE.test(id))
|
|
294
316
|
byIdentity.set(id, [...(byIdentity.get(id) ?? []), s]);
|
|
295
317
|
}
|
|
296
318
|
for (const list of byIdentity.values()) {
|
|
@@ -299,24 +321,23 @@ export function runChecks(d, home) {
|
|
|
299
321
|
continue;
|
|
300
322
|
findings.push({
|
|
301
323
|
rule: "duplicated-server",
|
|
302
|
-
severity: "
|
|
324
|
+
severity: "info",
|
|
303
325
|
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
|
|
305
|
-
fix: "
|
|
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.",
|
|
306
328
|
locations: list.map((s) => at(s)),
|
|
329
|
+
weight: 0,
|
|
307
330
|
});
|
|
308
331
|
}
|
|
309
332
|
return findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
310
333
|
}
|
|
311
|
-
|
|
312
|
-
|
|
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
|
+
*/
|
|
313
338
|
export function score(findings) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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";
|
|
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";
|
|
321
342
|
return { score: total, grade };
|
|
322
343
|
}
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { renderCard, shareText, shareUrl } from "./card.js";
|
|
6
6
|
import { audit } from "./index.js";
|
|
7
|
-
import { renderJson, renderReport, style } from "./report.js";
|
|
7
|
+
import { redact, renderJson, renderReport, style } from "./report.js";
|
|
8
8
|
const HELP = `toolstile: audit the MCP server configs your AI agents use
|
|
9
9
|
|
|
10
10
|
Usage
|
|
@@ -22,6 +22,7 @@ Options
|
|
|
22
22
|
--json Print results as JSON
|
|
23
23
|
--card [file] Write a shareable grade card (default toolstile-audit.png;
|
|
24
24
|
use a .svg name for SVG). Contains only the grade and counts.
|
|
25
|
+
--redact Hide your home folder and project paths, for posting the output
|
|
25
26
|
--project <dir> Also scan project configs in <dir> (default: current directory)
|
|
26
27
|
--no-project Scan only user-level configs
|
|
27
28
|
--fail-on <level> Exit with code 1 if any finding is at or above high, medium or low
|
|
@@ -33,7 +34,7 @@ function version() {
|
|
|
33
34
|
return pkg.version;
|
|
34
35
|
}
|
|
35
36
|
function parseArgs(argv) {
|
|
36
|
-
const args = { json: false, card: null, project: process.cwd(), failOn: null };
|
|
37
|
+
const args = { json: false, redact: false, card: null, project: process.cwd(), failOn: null };
|
|
37
38
|
const rest = [...argv];
|
|
38
39
|
if (rest[0] === "audit")
|
|
39
40
|
rest.shift();
|
|
@@ -57,6 +58,9 @@ function parseArgs(argv) {
|
|
|
57
58
|
case "--json":
|
|
58
59
|
args.json = true;
|
|
59
60
|
break;
|
|
61
|
+
case "--redact":
|
|
62
|
+
args.redact = true;
|
|
63
|
+
break;
|
|
60
64
|
case "--card": {
|
|
61
65
|
const next = inline ?? rest[i + 1];
|
|
62
66
|
if (next !== undefined && !next.startsWith("-")) {
|
|
@@ -106,7 +110,7 @@ async function writeCard(path, svg) {
|
|
|
106
110
|
return fallback;
|
|
107
111
|
}
|
|
108
112
|
}
|
|
109
|
-
const RANK = { high: 3, medium: 2, low: 1 };
|
|
113
|
+
const RANK = { high: 3, medium: 2, low: 1, info: 0 };
|
|
110
114
|
async function main() {
|
|
111
115
|
let args;
|
|
112
116
|
try {
|
|
@@ -125,13 +129,14 @@ async function main() {
|
|
|
125
129
|
return 0;
|
|
126
130
|
}
|
|
127
131
|
const home = homedir();
|
|
128
|
-
const
|
|
132
|
+
const raw = audit({ home, cwd: args.project });
|
|
133
|
+
const result = args.redact ? redact(raw, home, args.project) : raw;
|
|
129
134
|
const color = !args.json && process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
130
135
|
const s = style(color);
|
|
131
136
|
if (args.json)
|
|
132
137
|
process.stdout.write(renderJson(result));
|
|
133
138
|
else
|
|
134
|
-
process.stdout.write(renderReport(result, home, s));
|
|
139
|
+
process.stdout.write(renderReport(result, home, s, args.redact));
|
|
135
140
|
if (args.card) {
|
|
136
141
|
if (result.grade === null) {
|
|
137
142
|
process.stderr.write("No MCP servers found, so there is no grade to put on a card.\n");
|
package/dist/report.d.ts
CHANGED
|
@@ -11,8 +11,14 @@ export declare function counts(findings: Finding[]): {
|
|
|
11
11
|
high: number;
|
|
12
12
|
medium: number;
|
|
13
13
|
low: number;
|
|
14
|
+
info: number;
|
|
14
15
|
secrets: number;
|
|
15
16
|
};
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Returns a copy safe to post publicly: the home folder becomes `~`, the scanned project folder
|
|
19
|
+
* becomes `<project>`, and project paths inside pointers (Claude Code's `projects["/path"]`) are hidden.
|
|
20
|
+
*/
|
|
21
|
+
export declare function redact(r: AuditResult, home: string, project: string | null): AuditResult;
|
|
22
|
+
export declare function renderReport(r: AuditResult, home: string, s: Style, redacted?: boolean): string;
|
|
23
|
+
/** JSON output for scripts. Paths are included unless redacted; secret values never are. */
|
|
18
24
|
export declare function renderJson(r: AuditResult): string;
|
package/dist/report.js
CHANGED
|
@@ -5,10 +5,11 @@ export function style(color) {
|
|
|
5
5
|
const yellow = wrap(33, 39);
|
|
6
6
|
const blue = wrap(34, 39);
|
|
7
7
|
const green = wrap(32, 39);
|
|
8
|
+
const dim = wrap(2, 22);
|
|
8
9
|
return {
|
|
9
10
|
bold: wrap(1, 22),
|
|
10
|
-
dim
|
|
11
|
-
sev: (sev, s) => (sev === "high" ? red(s) : sev === "medium" ? yellow(s) : blue(s)),
|
|
11
|
+
dim,
|
|
12
|
+
sev: (sev, s) => (sev === "high" ? red(s) : sev === "medium" ? yellow(s) : sev === "low" ? blue(s) : dim(s)),
|
|
12
13
|
grade: (g, s) => (g === "A" || g === "B" ? green(s) : g === "C" ? yellow(s) : red(s)),
|
|
13
14
|
};
|
|
14
15
|
}
|
|
@@ -19,10 +20,37 @@ export function counts(findings) {
|
|
|
19
20
|
high: findings.filter((f) => f.severity === "high").length,
|
|
20
21
|
medium: findings.filter((f) => f.severity === "medium").length,
|
|
21
22
|
low: findings.filter((f) => f.severity === "low").length,
|
|
23
|
+
info: findings.filter((f) => f.severity === "info").length,
|
|
22
24
|
secrets: findings.filter((f) => f.rule === "plaintext-secret").reduce((n, f) => n + f.locations.length, 0),
|
|
23
25
|
};
|
|
24
26
|
}
|
|
25
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Returns a copy safe to post publicly: the home folder becomes `~`, the scanned project folder
|
|
29
|
+
* becomes `<project>`, and project paths inside pointers (Claude Code's `projects["/path"]`) are hidden.
|
|
30
|
+
*/
|
|
31
|
+
export function redact(r, home, project) {
|
|
32
|
+
const scrub = (text) => {
|
|
33
|
+
let out = text.replace(/projects\["(?:[^"\\]|\\.)*"\]/g, 'projects["…"]');
|
|
34
|
+
if (project)
|
|
35
|
+
out = out.split(project).join("<project>");
|
|
36
|
+
if (home)
|
|
37
|
+
out = out.split(home).join("~");
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
...r,
|
|
42
|
+
files: r.files.map((f) => ({ ...f, path: scrub(f.path) })),
|
|
43
|
+
unreadable: r.unreadable.map((u) => ({ ...u, path: scrub(u.path), error: scrub(u.error) })),
|
|
44
|
+
findings: r.findings.map((f) => ({
|
|
45
|
+
...f,
|
|
46
|
+
title: scrub(f.title),
|
|
47
|
+
detail: scrub(f.detail),
|
|
48
|
+
fix: scrub(f.fix),
|
|
49
|
+
locations: f.locations.map((l) => ({ file: scrub(l.file), pointer: scrub(l.pointer) })),
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function renderReport(r, home, s, redacted = false) {
|
|
26
54
|
const lines = [];
|
|
27
55
|
lines.push("", `${s.bold("toolstile audit")} ${s.dim("· local MCP config check · nothing leaves this machine")}`, "");
|
|
28
56
|
if (r.files.length === 0 && r.unreadable.length === 0) {
|
|
@@ -37,14 +65,17 @@ export function renderReport(r, home, s) {
|
|
|
37
65
|
lines.push("");
|
|
38
66
|
if (r.grade !== null) {
|
|
39
67
|
const c = counts(r.findings);
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
68
|
+
const parts = [
|
|
69
|
+
c.high && s.sev("high", `${c.high} high`),
|
|
70
|
+
c.medium && s.sev("medium", `${c.medium} medium`),
|
|
71
|
+
c.low && s.sev("low", `${c.low} low`),
|
|
72
|
+
].filter(Boolean);
|
|
73
|
+
const summary = parts.length ? parts.join(s.dim(" · ")) : "no issues found";
|
|
45
74
|
lines.push(` ${s.bold(s.grade(r.grade, `Grade ${r.grade}`))} ${s.dim(`${r.score}/100`)} ${summary}`, "");
|
|
46
75
|
}
|
|
47
|
-
|
|
76
|
+
const issues = r.findings.filter((f) => f.severity !== "info");
|
|
77
|
+
const info = r.findings.filter((f) => f.severity === "info");
|
|
78
|
+
for (const f of issues) {
|
|
48
79
|
const tag = s.sev(f.severity, f.severity.toUpperCase().padEnd(6));
|
|
49
80
|
lines.push(`${tag} ${s.bold(f.title)}`);
|
|
50
81
|
lines.push(` ${f.detail}`);
|
|
@@ -55,10 +86,21 @@ export function renderReport(r, home, s) {
|
|
|
55
86
|
lines.push(s.dim(` … and ${f.locations.length - shown.length} more`));
|
|
56
87
|
lines.push(` ${s.dim("Fix:")} ${f.fix}`, "");
|
|
57
88
|
}
|
|
58
|
-
|
|
89
|
+
if (info.length) {
|
|
90
|
+
lines.push(s.dim("Also set up in more than one client (not scored):"));
|
|
91
|
+
for (const f of info)
|
|
92
|
+
lines.push(s.dim(` ${f.title.replace(/ is set up separately in (\d+) clients$/, "")}: ${f.detail.split(".")[0].replace(/^Configured in /, "")}`));
|
|
93
|
+
lines.push("");
|
|
94
|
+
}
|
|
95
|
+
lines.push(s.dim("Secret values are never printed."));
|
|
96
|
+
if (!redacted)
|
|
97
|
+
lines.push(s.dim("Posting this? Run with --redact to hide your paths, or --card for an image with only the grade."));
|
|
98
|
+
else
|
|
99
|
+
lines.push(s.dim("Share an image with only the grade: npx toolstile audit --card"));
|
|
100
|
+
lines.push(s.dim("Keep credentials off your machines and control each agent's tools: https://toolstile.com/?ref=cli"), "");
|
|
59
101
|
return lines.join("\n");
|
|
60
102
|
}
|
|
61
|
-
/** JSON output for scripts. Paths are included; secret values never are. */
|
|
103
|
+
/** JSON output for scripts. Paths are included unless redacted; secret values never are. */
|
|
62
104
|
export function renderJson(r) {
|
|
63
105
|
return JSON.stringify(r, null, 2) + "\n";
|
|
64
106
|
}
|
package/dist/sources.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ export interface ScanOptions {
|
|
|
9
9
|
export declare const CLIENT_NAMES: Record<ClientId, string>;
|
|
10
10
|
/** Appends a key to a pointer, quoting keys that are not plain identifiers. */
|
|
11
11
|
export declare function pointerJoin(base: string, key: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Who besides the owner can read a file, given its mode and the modes of every folder above it.
|
|
14
|
+
* A group or other user needs read on the file and execute (traverse) on each folder.
|
|
15
|
+
*/
|
|
16
|
+
export declare function exposureOf(fileMode: number, ancestorModes: number[]): "group" | "others" | null;
|
|
12
17
|
export interface Discovery {
|
|
13
18
|
files: ConfigFile[];
|
|
14
19
|
unreadable: {
|
package/dist/sources.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { parseConfig } from "./parse.js";
|
|
4
4
|
export const CLIENT_NAMES = {
|
|
5
5
|
"claude-code": "Claude Code",
|
|
@@ -121,6 +121,34 @@ function claudeApprovals(file) {
|
|
|
121
121
|
});
|
|
122
122
|
return out;
|
|
123
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Who besides the owner can read a file, given its mode and the modes of every folder above it.
|
|
126
|
+
* A group or other user needs read on the file and execute (traverse) on each folder.
|
|
127
|
+
*/
|
|
128
|
+
export function exposureOf(fileMode, ancestorModes) {
|
|
129
|
+
if (fileMode & 0o004 && ancestorModes.every((m) => m & 0o001))
|
|
130
|
+
return "others";
|
|
131
|
+
if (fileMode & 0o040 && ancestorModes.every((m) => m & 0o010))
|
|
132
|
+
return "group";
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
function ancestorModes(path) {
|
|
136
|
+
const modes = [];
|
|
137
|
+
let dir = dirname(path);
|
|
138
|
+
for (;;) {
|
|
139
|
+
try {
|
|
140
|
+
modes.push(statSync(dir).mode & 0o777);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// An unreadable folder is not traversable by us either; treat it as closed.
|
|
144
|
+
modes.push(0);
|
|
145
|
+
}
|
|
146
|
+
const parent = dirname(dir);
|
|
147
|
+
if (parent === dir)
|
|
148
|
+
return modes;
|
|
149
|
+
dir = parent;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
124
152
|
export function discover(opts) {
|
|
125
153
|
const result = { files: [], unreadable: [], servers: [], approvals: [] };
|
|
126
154
|
for (const c of candidates(opts)) {
|
|
@@ -144,7 +172,8 @@ export function discover(opts) {
|
|
|
144
172
|
result.unreadable.push({ client: c.client, path: c.path, error: error.message });
|
|
145
173
|
continue;
|
|
146
174
|
}
|
|
147
|
-
const
|
|
175
|
+
const exposure = mode === undefined ? undefined : exposureOf(mode, ancestorModes(c.path));
|
|
176
|
+
const file = { client: c.client, path: c.path, data, mode, exposure };
|
|
148
177
|
result.files.push(file);
|
|
149
178
|
if (!isObject(data))
|
|
150
179
|
continue;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
/** `info` findings are context only and do not affect the score. */
|
|
2
|
+
export type Severity = "high" | "medium" | "low" | "info";
|
|
2
3
|
export type ClientId = "claude-code" | "claude-desktop" | "cursor" | "codex" | "vscode" | "windsurf" | "gemini" | "cline";
|
|
3
4
|
export interface Location {
|
|
4
5
|
/** Absolute path of the config file. */
|
|
@@ -13,6 +14,8 @@ export interface ConfigFile {
|
|
|
13
14
|
data: unknown;
|
|
14
15
|
/** Unix permission bits, when available. */
|
|
15
16
|
mode?: number;
|
|
17
|
+
/** Who besides the owner can read the file, counting the folders above it. */
|
|
18
|
+
exposure?: "group" | "others" | null;
|
|
16
19
|
}
|
|
17
20
|
export interface ServerEntry {
|
|
18
21
|
client: ClientId;
|
|
@@ -44,6 +47,8 @@ export interface Finding {
|
|
|
44
47
|
detail: string;
|
|
45
48
|
fix: string;
|
|
46
49
|
locations: Location[];
|
|
50
|
+
/** Points this finding takes off the score; repeats of one root cause count once. */
|
|
51
|
+
weight: number;
|
|
47
52
|
}
|
|
48
53
|
export interface AuditResult {
|
|
49
54
|
files: {
|
package/package.json
CHANGED