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 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 users can read | medium |
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
- | The same server set up separately in several clients | 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.
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 C 67/100 1 high · 2 medium · 3 low
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 but are readable by other users.
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 || !secretFiles.has(f.path) || (f.mode & 0o077) === 0)
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 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}"`,
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: "low",
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 credentials and settings to keep in sync, rotate and revoke.`,
305
- fix: "Configure it once and share it across agents.",
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
- const PENALTY = { high: 15, medium: 6, low: 2 };
312
- const CAP = { high: 60, medium: 30, low: 10 };
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
- 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";
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 result = audit({ home, cwd: args.project });
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
- export declare function renderReport(r: AuditResult, home: string, s: Style): string;
17
- /** JSON output for scripts. Paths are included; secret values never are. */
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: wrap(2, 22),
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
- export function renderReport(r, home, s) {
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 summary = r.findings.length
41
- ? [c.high && s.sev("high", `${c.high} high`), c.medium && s.sev("medium", `${c.medium} medium`), c.low && s.sev("low", `${c.low} low`)]
42
- .filter(Boolean)
43
- .join(s.dim(" · "))
44
- : "no issues found";
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
- for (const f of r.findings) {
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
- lines.push(s.dim("Secret values are never printed. Share your grade (no secrets, no paths): npx toolstile audit --card"), s.dim("Keep credentials off your machines and control each agent's tools: https://toolstile.com/?ref=cli"), "");
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 file = { client: c.client, path: c.path, data, mode };
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
- export type Severity = "high" | "medium" | "low";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolstile",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Audit the MCP server configs your AI agents use for plaintext secrets, unpinned packages, auto-approved tools and duplicated setups. Local and read-only.",
5
5
  "keywords": [
6
6
  "mcp",