toolstile 0.1.0 → 0.1.2

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
@@ -15,19 +15,21 @@ Windsurf, Gemini CLI and Cline and grades them for:
15
15
  | Remote servers reached over plain `http://` | high |
16
16
  | Containers run with `--privileged` or the Docker socket | high |
17
17
  | The same credential pasted into several clients' configs | medium |
18
- | Config files holding secrets that other users can read | medium |
18
+ | Config files holding secrets that other accounts can reach (file and folder permissions) | medium |
19
19
  | `npx`, `uvx` and `docker` servers that run whatever version is newest | medium |
20
20
  | Filesystem servers that can read your whole home folder or disk | medium |
21
21
  | Permission rules that approve every tool of a server without asking | medium |
22
22
  | Tools on a client's auto-approve list | low |
23
- | The same server set up separately in several clients | low |
23
+
24
+ It also lists servers set up separately in several clients. That is context, not
25
+ scored: remote servers need an entry per client.
24
26
 
25
27
  ```text
26
28
  toolstile audit · local MCP config check · nothing leaves this machine
27
29
 
28
30
  Scanned 4 files from Claude Code, Cursor, Codex: 10 MCP servers.
29
31
 
30
- Grade C 67/100 1 high · 2 medium · 3 low
32
+ Grade B 77/100 1 high · 1 medium · 1 low
31
33
 
32
34
  HIGH Plaintext secret in github (Cursor)
33
35
  A GitHub token is written directly into the config file. Anything that can
@@ -42,7 +44,9 @@ HIGH Plaintext secret in github (Cursor)
42
44
  - Makes no network requests and collects no telemetry.
43
45
  - Never prints, stores or sends secret values. Findings name the file and key,
44
46
  for example `mcpServers.github.env.GITHUB_TOKEN`, and the kind of secret.
45
- - About 1,100 lines of TypeScript with one runtime dependency
47
+ - Never quotes a config file: one that cannot be read or parsed is listed with a
48
+ reason and position only, for example `invalid TOML at line 3, column 12`.
49
+ - About 1,400 lines of TypeScript with one runtime dependency
46
50
  ([smol-toml](https://github.com/squirrelchat/smol-toml), for Codex's TOML
47
51
  config) and an optional one ([resvg-js](https://github.com/thx/resvg-js), only
48
52
  to render `--card` as PNG). Read it before you run it.
@@ -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,23 @@ 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
+ Exit codes:
70
+
71
+ | Code | Meaning |
72
+ | --- | --- |
73
+ | 0 | The audit ran, and no finding reached the `--fail-on` level (or `--fail-on` was not set) |
74
+ | 1 | A finding is at or above the `--fail-on` level |
75
+ | 2 | Invalid options, or the audit itself failed |
76
+
77
+ ## Scoring
78
+
79
+ Each distinct problem has a weight: 10 for a leaked secret, plain-http remote or
80
+ privileged container; 4 for readable secret files, unpinned packages, broad
81
+ filesystem access and whole-server auto-approval; 2 for each copied secret; 1 for
82
+ auto-approved tools. A secret pasted into several files counts as one leak. The
83
+ score is `100 / (1 + weight / 50)`: one leaked secret scores 83 (B), four score
84
+ about 56 (D). Grades: A 90+, B 75+, C 60+, D 40+, F below 40.
85
+
64
86
  ## Share your grade
65
87
 
66
88
  ```sh
@@ -68,7 +90,8 @@ npx toolstile audit --card
68
90
  ```
69
91
 
70
92
  writes a 1200×630 image with your grade and counts (no server names, paths or
71
- values) and prints a ready-to-post link.
93
+ values) and prints a ready-to-post link. To post the text report instead, add
94
+ `--redact` so it shows `~` and `<project>` in place of your folders.
72
95
 
73
96
  ## Files it reads
74
97
 
@@ -100,6 +123,12 @@ The audit works on its own and does not require a Toolstile account.
100
123
 
101
124
  Questions and reports: support@toolstile.com.
102
125
 
126
+ ## Releases
127
+
128
+ The npm package is published by [GitHub Actions](.github/workflows/publish.yml)
129
+ with npm trusted publishing and provenance, so each release links back to the
130
+ commit and workflow that built it. No npm token is stored anywhere.
131
+
103
132
  ## License
104
133
 
105
134
  [MIT](LICENSE)
package/dist/checks.d.ts CHANGED
@@ -4,13 +4,22 @@ interface Package {
4
4
  registry: "npm" | "pypi" | "docker";
5
5
  name: string;
6
6
  version: string | null;
7
+ /** Index of the package in the unwrapped words. */
8
+ wordIndex: number;
9
+ /** Index of the package in `args`, or -1 when it is inside `command`. */
7
10
  argIndex: number;
8
11
  }
9
12
  /** Finds the package a launcher such as npx, uvx or docker runs. */
10
13
  export declare function packageOf(s: ServerEntry): Package | null;
11
14
  /** A stable identity for the same server configured in several places. */
12
15
  export declare function identityOf(s: ServerEntry): string | null;
16
+ /** A path for a shell command: `~/...` under the home folder, single-quoted when needed. */
17
+ export declare function shellPath(path: string, home: string): string;
13
18
  export declare function runChecks(d: Discovery, home: string): Finding[];
19
+ /**
20
+ * 100 / (1 + weight / 50): one leaked secret (10) scores 83, four score about 50, and the score
21
+ * keeps separating bad setups from worse ones instead of bottoming out at zero.
22
+ */
14
23
  export declare function score(findings: Finding[]): {
15
24
  score: number;
16
25
  grade: string;
package/dist/checks.js CHANGED
@@ -41,20 +41,67 @@ const LAUNCHERS = {
41
41
  pnpx: "npm",
42
42
  uvx: "pypi",
43
43
  };
44
- /** Splits `command` and `args` into words, handling configs that put the whole command in one string. */
44
+ const base = (p) => win32.basename(posix.basename(p)).replace(/\.(cmd|exe)$/i, "");
45
+ const split = (text) => text.trim().split(/\s+/).filter(Boolean);
46
+ /** WSL options that take a value. */
47
+ const WSL_VALUED = new Set(["-d", "--distribution", "--distribution-id", "-u", "--user", "--cd", "--shell-type"]);
48
+ /** Index of the first word after a `cmd /c` or `wsl` wrapper at `start`, or `start` when there is none. */
49
+ function afterWrapper(list, start) {
50
+ const name = base(list[start] ?? "").toLowerCase();
51
+ let j = start + 1;
52
+ if (name === "cmd") {
53
+ // cmd [/d] [/s] [/q] … /c|/k command
54
+ while (j < list.length && /^\/[a-z]$/i.test(list[j]) && !/^\/[ck]$/i.test(list[j]))
55
+ j++;
56
+ return /^\/[ck]$/i.test(list[j] ?? "") ? j + 1 : start;
57
+ }
58
+ if (name === "wsl") {
59
+ while (j < list.length) {
60
+ const a = list[j];
61
+ if (WSL_VALUED.has(a))
62
+ j += 2;
63
+ else if (a === "-e" || a === "--exec" || a === "--")
64
+ return j + 1;
65
+ else if (a.startsWith("-"))
66
+ j++;
67
+ else
68
+ break;
69
+ }
70
+ return j;
71
+ }
72
+ return start;
73
+ }
74
+ /**
75
+ * Splits `command` and `args` into words, handling configs that put the whole command in one string,
76
+ * and skips Windows wrappers (`cmd /c npx …`, `wsl npx …`) so the real launcher comes first.
77
+ */
45
78
  function words(s) {
46
79
  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];
80
+ return { list: [], arg: [] };
81
+ const oneString = s.args.length === 0 && /\s/.test(s.command.trim());
82
+ let list = oneString ? split(s.command) : [s.command, ...s.args];
83
+ let arg = oneString ? list.map(() => -1) : list.map((_, i) => i - 1);
84
+ for (;;) {
85
+ const start = afterWrapper(list, 0);
86
+ if (start === 0 || start >= list.length)
87
+ break;
88
+ list = list.slice(start);
89
+ arg = arg.slice(start);
90
+ // `cmd /c "npx -y pkg"` passes the inner command as one argument.
91
+ if (list.length === 1 && /\s/.test(list[0].trim())) {
92
+ const from = arg[0];
93
+ list = split(list[0]);
94
+ arg = list.map(() => from);
95
+ }
96
+ }
97
+ return { list, arg };
50
98
  }
51
- const base = (p) => win32.basename(posix.basename(p)).replace(/\.(cmd|exe)$/i, "");
52
99
  /** Finds the package a launcher such as npx, uvx or docker runs. */
53
100
  export function packageOf(s) {
54
- const w = words(s);
101
+ const { list: w, arg } = words(s);
55
102
  if (w.length === 0)
56
103
  return null;
57
- const cmd = base(w[0]);
104
+ const cmd = base(w[0]).toLowerCase();
58
105
  const sub = w.slice(1);
59
106
  let registry = LAUNCHERS[cmd];
60
107
  let skip = 0;
@@ -74,6 +121,7 @@ export function packageOf(s) {
74
121
  return null;
75
122
  const rest = sub.slice(skip);
76
123
  const offset = 1 + skip;
124
+ const at = (spec, i) => parsePackage(registry, spec, i, arg[i] ?? -1);
77
125
  // Flags that take a value; their value is not the package.
78
126
  const valued = new Set(["-p", "--package", "--from", "--with", "-e", "--env", "-v", "--volume", "--name", "--network", "--mount", "--env-file", "-w", "--workdir", "--entrypoint", "-u", "--user", "--python"]);
79
127
  for (let i = 0; i < rest.length; i++) {
@@ -83,41 +131,42 @@ export function packageOf(s) {
83
131
  if (a.startsWith("-")) {
84
132
  if (valued.has(a)) {
85
133
  if ((a === "--package" || a === "-p" || a === "--from") && registry !== "docker") {
86
- return parsePackage(registry, rest[i + 1] ?? "", offset + i + 1);
134
+ return at(rest[i + 1] ?? "", offset + i + 1);
87
135
  }
88
136
  i++;
89
137
  }
90
138
  continue;
91
139
  }
92
- return parsePackage(registry, a, offset + i);
140
+ return at(a, offset + i);
93
141
  }
94
142
  return null;
95
143
  }
96
- function parsePackage(registry, spec, argIndex) {
144
+ function parsePackage(registry, spec, wordIndex, argIndex) {
97
145
  if (!spec)
98
146
  return null;
147
+ const where = { wordIndex, argIndex };
99
148
  if (registry === "docker") {
100
149
  const at = spec.indexOf("@sha256:");
101
150
  if (at > 0)
102
- return { registry, name: spec.slice(0, at), version: spec.slice(at + 1), argIndex };
151
+ return { registry, name: spec.slice(0, at), version: spec.slice(at + 1), ...where };
103
152
  const slash = spec.lastIndexOf("/");
104
153
  const colon = spec.lastIndexOf(":");
105
154
  return colon > slash
106
- ? { registry, name: spec.slice(0, colon), version: spec.slice(colon + 1), argIndex }
107
- : { registry, name: spec, version: null, argIndex };
155
+ ? { registry, name: spec.slice(0, colon), version: spec.slice(colon + 1), ...where }
156
+ : { registry, name: spec, version: null, ...where };
108
157
  }
109
158
  if (registry === "pypi") {
110
159
  const m = /^([A-Za-z0-9._\-[\],]+?)(?:(==|@|>=|~=)(.+))?$/.exec(spec);
111
160
  if (!m)
112
161
  return null;
113
- return { registry, name: m[1], version: m[2] === "==" || m[2] === "@" ? m[3] : null, argIndex };
162
+ return { registry, name: m[1], version: m[2] === "==" || m[2] === "@" ? m[3] : null, ...where };
114
163
  }
115
164
  if (/^(https?:|git\+|file:|\.|\/)/.test(spec))
116
165
  return null;
117
166
  const at = spec.indexOf("@", spec.startsWith("@") ? 1 : 0);
118
167
  return at > 0
119
- ? { registry, name: spec.slice(0, at), version: spec.slice(at + 1), argIndex }
120
- : { registry, name: spec, version: null, argIndex };
168
+ ? { registry, name: spec.slice(0, at), version: spec.slice(at + 1), ...where }
169
+ : { registry, name: spec, version: null, ...where };
121
170
  }
122
171
  const isPinned = (p) => p.version !== null &&
123
172
  p.version !== "latest" &&
@@ -138,7 +187,7 @@ export function identityOf(s) {
138
187
  const pkg = packageOf(s);
139
188
  if (pkg)
140
189
  return `${pkg.registry}:${pkg.name.toLowerCase()}`;
141
- const w = words(s);
190
+ const w = words(s).list;
142
191
  return w.length ? `cmd:${w.join(" ")}` : null;
143
192
  }
144
193
  const LOOPBACK = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)$/i;
@@ -149,10 +198,17 @@ function broadRoots(s, home) {
149
198
  return [];
150
199
  const homes = new Set([home, home.replace(/\/+$/, ""), "~", "~/", "$HOME", "${HOME}", "%USERPROFILE%"]);
151
200
  return words(s)
152
- .slice(pkg.argIndex + 1)
201
+ .list.slice(pkg.wordIndex + 1)
153
202
  .filter((a) => a === "/" || /^[A-Za-z]:\\?$/.test(a) || homes.has(a) || homes.has(a.replace(/\/+$/, "")));
154
203
  }
155
- const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
204
+ const SEVERITY_ORDER = { high: 0, medium: 1, low: 2, info: 3 };
205
+ /** Toolstile's own gateway is meant to be added to every client. */
206
+ const TOOLSTILE = /^url:([a-z0-9-]+\.)*toolstile\.com\//i;
207
+ /** A path for a shell command: `~/...` under the home folder, single-quoted when needed. */
208
+ export function shellPath(path, home) {
209
+ const quote = (p) => (/^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`);
210
+ return home && path.startsWith(home + "/") ? `~/${quote(path.slice(home.length + 1))}` : quote(path);
211
+ }
156
212
  export function runChecks(d, home) {
157
213
  const findings = [];
158
214
  const secrets = d.servers.flatMap(secretsOf);
@@ -160,7 +216,10 @@ export function runChecks(d, home) {
160
216
  const byServer = new Map();
161
217
  for (const s of secrets)
162
218
  byServer.set(s.server, [...(byServer.get(s.server) ?? []), s]);
219
+ const counted = new Set();
163
220
  for (const [server, list] of byServer) {
221
+ const fresh = new Set(list.map((s) => s.hash).filter((h) => !counted.has(h)));
222
+ fresh.forEach((h) => counted.add(h));
164
223
  const kinds = [...new Set(list.map((s) => s.kind))].join(", ");
165
224
  const one = list.length === 1;
166
225
  const what = one ? `${/^[aeiou]/i.test(kinds) ? "An" : "A"} ${kinds} is` : `${list.length} secrets (${kinds}) are`;
@@ -171,6 +230,8 @@ export function runChecks(d, home) {
171
230
  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
231
  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
232
  locations: list.map((s) => s.location),
233
+ // A secret pasted into several files is one leak; the copies are scored by copied-secret.
234
+ weight: 10 * fresh.size,
174
235
  });
175
236
  }
176
237
  // The same secret copied into several files.
@@ -188,20 +249,23 @@ export function runChecks(d, home) {
188
249
  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
250
  fix: "Keep one copy in your environment or keychain and reference it from each client, or route the clients through one gateway.",
190
251
  locations: list.map((s) => s.location),
252
+ weight: 2,
191
253
  });
192
254
  }
193
- // Config files that hold secrets but are readable by other users.
255
+ // Config files that hold secrets and that other accounts on the machine can actually reach.
194
256
  const secretFiles = new Set(secrets.map((s) => s.location.file));
195
257
  for (const f of d.files) {
196
- if (f.mode === undefined || !secretFiles.has(f.path) || (f.mode & 0o077) === 0)
258
+ if (f.mode === undefined || !f.exposure || !secretFiles.has(f.path))
197
259
  continue;
260
+ const [open, who] = f.exposure === "others" ? ["everyone", "every account on this machine"] : ["its group", "other accounts in that group"];
198
261
  findings.push({
199
262
  rule: "readable-config",
200
263
  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}"`,
264
+ title: "Config file with secrets is readable by other accounts",
265
+ 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.`,
266
+ fix: `Restrict it to your user: chmod 600 ${shellPath(f.path, home)}`,
204
267
  locations: [{ file: f.path, pointer: "(file permissions)" }],
268
+ weight: 4,
205
269
  });
206
270
  }
207
271
  for (const s of d.servers) {
@@ -216,6 +280,7 @@ export function runChecks(d, home) {
216
280
  detail: `${u.host} is reached over plain http://, so requests, tool results and any credentials travel unencrypted.`,
217
281
  fix: "Use the server's https:// endpoint.",
218
282
  locations: [at(s, "url")],
283
+ weight: 10,
219
284
  });
220
285
  }
221
286
  }
@@ -234,10 +299,11 @@ export function runChecks(d, home) {
234
299
  fix: pkg.registry === "docker"
235
300
  ? `Pin an image digest or exact tag, for example ${pkg.name}:<version>.`
236
301
  : `Pin an exact version, for example ${pkg.name}${pkg.registry === "pypi" ? "==" : "@"}<version>.`,
237
- locations: [at(s, "args", String(Math.max(0, pkg.argIndex - 1)))],
302
+ locations: [pkg.argIndex >= 0 ? at(s, "args", String(pkg.argIndex)) : at(s, "command")],
303
+ weight: 4,
238
304
  });
239
305
  }
240
- const w = words(s);
306
+ const w = words(s).list;
241
307
  if (pkg?.registry === "docker") {
242
308
  const risky = w.filter((a) => a === "--privileged" || /\/var\/run\/docker\.sock/.test(a) || a === "--net=host" || a === "--network=host");
243
309
  if (risky.length) {
@@ -248,6 +314,7 @@ export function runChecks(d, home) {
248
314
  detail: `The server runs with ${[...new Set(risky)].join(", ")}, which gives it effective control of the host.`,
249
315
  fix: "Remove --privileged and the Docker socket mount unless the server truly needs them.",
250
316
  locations: [at(s, "args")],
317
+ weight: 10,
251
318
  });
252
319
  }
253
320
  }
@@ -260,6 +327,7 @@ export function runChecks(d, home) {
260
327
  detail: `Allowed root ${roots.join(", ")} includes SSH keys, cloud credentials and every other config file.`,
261
328
  fix: "Allow only the project folders the agent needs.",
262
329
  locations: [at(s, "args")],
330
+ weight: 4,
263
331
  });
264
332
  }
265
333
  if (s.autoApproved.length) {
@@ -270,6 +338,7 @@ export function runChecks(d, home) {
270
338
  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
339
  fix: "Keep auto-approval to read-only tools.",
272
340
  locations: [at(s)],
341
+ weight: 1,
273
342
  });
274
343
  }
275
344
  }
@@ -284,13 +353,15 @@ export function runChecks(d, home) {
284
353
  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
354
  fix: "Allow specific read-only tools (mcp__server__tool) instead of whole servers.",
286
355
  locations: [a.location],
356
+ weight: 4,
287
357
  });
288
358
  }
289
- // The same server configured separately in several clients.
359
+ // The same server configured separately in several clients. Context only: remote servers need an
360
+ // entry per client, and copies that carry credentials are already scored by copied-secret.
290
361
  const byIdentity = new Map();
291
362
  for (const s of d.servers) {
292
363
  const id = identityOf(s);
293
- if (id)
364
+ if (id && !TOOLSTILE.test(id))
294
365
  byIdentity.set(id, [...(byIdentity.get(id) ?? []), s]);
295
366
  }
296
367
  for (const list of byIdentity.values()) {
@@ -299,24 +370,23 @@ export function runChecks(d, home) {
299
370
  continue;
300
371
  findings.push({
301
372
  rule: "duplicated-server",
302
- severity: "low",
373
+ severity: "info",
303
374
  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.",
375
+ 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.`,
376
+ fix: "Nothing to fix; listed so you know where each copy lives.",
306
377
  locations: list.map((s) => at(s)),
378
+ weight: 0,
307
379
  });
308
380
  }
309
381
  return findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
310
382
  }
311
- const PENALTY = { high: 15, medium: 6, low: 2 };
312
- const CAP = { high: 60, medium: 30, low: 10 };
383
+ /**
384
+ * 100 / (1 + weight / 50): one leaked secret (10) scores 83, four score about 50, and the score
385
+ * keeps separating bad setups from worse ones instead of bottoming out at zero.
386
+ */
313
387
  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";
388
+ const weight = findings.reduce((n, f) => n + f.weight, 0);
389
+ const total = Math.round(100 / (1 + weight / 50));
390
+ const grade = total >= 90 ? "A" : total >= 75 ? "B" : total >= 60 ? "C" : total >= 40 ? "D" : "F";
321
391
  return { score: total, grade };
322
392
  }
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/parse.d.ts CHANGED
@@ -1,3 +1,17 @@
1
- /** Parses JSON that may contain comments and trailing commas (VS Code style). */
1
+ /**
2
+ * A config file that could not be parsed. The message is fixed text plus a position: parser
3
+ * messages quote the offending source line, which can hold a secret, so they are never kept.
4
+ */
5
+ export declare class ConfigParseError extends Error {
6
+ readonly format: "JSON" | "TOML";
7
+ readonly line?: number | undefined;
8
+ readonly column?: number | undefined;
9
+ constructor(format: "JSON" | "TOML", line?: number | undefined, column?: number | undefined);
10
+ }
11
+ /**
12
+ * Parses JSON that may contain comments and trailing commas (VS Code style). Comments and
13
+ * trailing commas become spaces so parser positions still match the original text.
14
+ */
2
15
  export declare function parseJsonc(text: string): unknown;
16
+ /** Parses a config file. Throws only {@link ConfigParseError}, whose message never contains file text. */
3
17
  export declare function parseConfig(path: string, text: string): unknown;
package/dist/parse.js CHANGED
@@ -1,5 +1,24 @@
1
- import { parse as parseToml } from "smol-toml";
2
- /** Parses JSON that may contain comments and trailing commas (VS Code style). */
1
+ import { parse as parseToml, TomlError } from "smol-toml";
2
+ /**
3
+ * A config file that could not be parsed. The message is fixed text plus a position: parser
4
+ * messages quote the offending source line, which can hold a secret, so they are never kept.
5
+ */
6
+ export class ConfigParseError extends Error {
7
+ format;
8
+ line;
9
+ column;
10
+ constructor(format, line, column) {
11
+ super(`invalid ${format}${line ? ` at line ${line}${column ? `, column ${column}` : ""}` : ""}`);
12
+ this.format = format;
13
+ this.line = line;
14
+ this.column = column;
15
+ this.name = "ConfigParseError";
16
+ }
17
+ }
18
+ /**
19
+ * Parses JSON that may contain comments and trailing commas (VS Code style). Comments and
20
+ * trailing commas become spaces so parser positions still match the original text.
21
+ */
3
22
  export function parseJsonc(text) {
4
23
  let out = "";
5
24
  let inString = false;
@@ -22,15 +41,19 @@ export function parseJsonc(text) {
22
41
  out += ch;
23
42
  }
24
43
  else if (ch === "/" && next === "/") {
25
- while (i < text.length && text[i] !== "\n")
44
+ while (i < text.length && text[i] !== "\n") {
45
+ out += blank(text[i]);
26
46
  i++;
27
- out += "\n";
47
+ }
48
+ if (i < text.length)
49
+ out += "\n";
28
50
  }
29
51
  else if (ch === "/" && next === "*") {
30
- i += 2;
31
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
32
- i++;
33
- i++;
52
+ const end = text.indexOf("*/", i + 2);
53
+ const stop = end === -1 ? text.length : end + 2;
54
+ for (; i < stop; i++)
55
+ out += blank(text[i]);
56
+ i--;
34
57
  }
35
58
  else {
36
59
  out += ch;
@@ -38,6 +61,8 @@ export function parseJsonc(text) {
38
61
  }
39
62
  return JSON.parse(stripTrailingCommas(out));
40
63
  }
64
+ /** Keeps line breaks so line numbers survive; everything else becomes a space. */
65
+ const blank = (ch) => (ch === "\n" || ch === "\r" ? ch : " ");
41
66
  function stripTrailingCommas(text) {
42
67
  let out = "";
43
68
  let inString = false;
@@ -60,17 +85,51 @@ function stripTrailingCommas(text) {
60
85
  let j = i + 1;
61
86
  while (j < text.length && /\s/.test(text[j]))
62
87
  j++;
63
- if (text[j] === "}" || text[j] === "]")
88
+ if (text[j] === "}" || text[j] === "]") {
89
+ out += " ";
64
90
  continue;
91
+ }
65
92
  }
66
93
  out += ch;
67
94
  }
68
95
  return out;
69
96
  }
97
+ /** 1-based line and column of a character offset. */
98
+ function lineColumn(text, offset) {
99
+ const before = text.slice(0, Math.min(Math.max(offset, 0), text.length));
100
+ const lastBreak = before.lastIndexOf("\n");
101
+ return { line: before.split("\n").length, column: offset - lastBreak };
102
+ }
103
+ /** Reads only the position from a V8 JSON error; the rest of its message may quote the file. */
104
+ function jsonPosition(error, text) {
105
+ const message = error instanceof Error ? error.message : "";
106
+ const at = /\bposition (\d+)/.exec(message);
107
+ if (at)
108
+ return lineColumn(text, Number(at[1]));
109
+ if (/Unexpected end of JSON input/.test(message))
110
+ return lineColumn(text, text.length);
111
+ return {};
112
+ }
113
+ /** Parses a config file. Throws only {@link ConfigParseError}, whose message never contains file text. */
70
114
  export function parseConfig(path, text) {
71
- if (path.endsWith(".toml"))
72
- return parseToml(text);
73
- if (text.trim() === "")
115
+ const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
116
+ if (path.endsWith(".toml")) {
117
+ try {
118
+ return parseToml(body);
119
+ }
120
+ catch (error) {
121
+ if (error instanceof TomlError)
122
+ throw new ConfigParseError("TOML", error.line, error.column);
123
+ throw new ConfigParseError("TOML");
124
+ }
125
+ }
126
+ if (body.trim() === "")
74
127
  return {};
75
- return parseJsonc(text);
128
+ try {
129
+ return parseJsonc(body);
130
+ }
131
+ catch (error) {
132
+ const { line, column } = jsonPosition(error, body);
133
+ throw new ConfigParseError("JSON", line, column);
134
+ }
76
135
  }
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) {
@@ -32,19 +60,22 @@ export function renderReport(r, home, s) {
32
60
  const clientList = r.clients.map((c) => CLIENT_NAMES[c]).join(", ");
33
61
  lines.push(`Scanned ${plural(r.files.length, "file")} from ${clientList}: ${plural(r.servers, "MCP server")}.`);
34
62
  for (const u of r.unreadable) {
35
- lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}): could not parse it.`));
63
+ lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}): ${u.error}.`));
36
64
  }
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/secrets.js CHANGED
@@ -28,9 +28,9 @@ const PATTERNS = [
28
28
  [/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, "JWT"],
29
29
  [/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, "password in connection string"],
30
30
  ];
31
- const SECRET_KEY = /(token|secret|passw(or)?d|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|credential|auth|bearer|session|cookie|\bpat\b|_pat$|^pat_)/i;
32
- /** Key suffixes that name configuration about a secret rather than the secret itself. */
33
- const NOT_SECRET_KEY = /(url|uri|endpoint|host|port|path|file|dir|type|mode|enabled|header|name|user(name)?|id|region|scope|scopes|method|env_var|_var)$/i;
31
+ const SECRET_KEY = /(token|secret|passw(or)?d|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|credential|auth|bearer|session|cookie|\bpat\b|_pat$|^pat_|(^|[_-])key$)/i;
32
+ /** Key suffixes that name configuration about a secret, or a public half, rather than the secret itself. */
33
+ const NOT_SECRET_KEY = /(url|uri|endpoint|host|port|path|file|dir|type|mode|enabled|header|name|user(name)?|id|region|scope|scopes|method|env_var|_var|public[_-]?key)$/i;
34
34
  const PLACEHOLDER = /^(<.*>|\{.*\}|\[.*\]|x{4,}|\*{3,}|\.{3,}|your[-_ ].*|change[-_ ]?me|todo|placeholder|example|redacted|none|null|undefined|true|false|test|secret|token)$/i;
35
35
  /** True for `${VAR}`, `${env:VAR}`, `$VAR`, `%VAR%` and `{env:VAR}` references. */
36
36
  export function isReference(value) {
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,6 +1,6 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
- import { join, resolve } from "node:path";
3
- import { parseConfig } from "./parse.js";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { ConfigParseError, parseConfig } from "./parse.js";
4
4
  export const CLIENT_NAMES = {
5
5
  "claude-code": "Claude Code",
6
6
  "claude-desktop": "Claude Desktop",
@@ -121,6 +121,42 @@ 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
+ }
152
+ /** A fixed description of a read failure; system messages are not passed through. */
153
+ function readError(code) {
154
+ if (code === "EACCES" || code === "EPERM")
155
+ return "permission denied";
156
+ if (code === "EISDIR")
157
+ return "is a directory";
158
+ return code && /^E[A-Z]+$/.test(code) ? `could not read it (${code})` : "could not read it";
159
+ }
124
160
  export function discover(opts) {
125
161
  const result = { files: [], unreadable: [], servers: [], approvals: [] };
126
162
  for (const c of candidates(opts)) {
@@ -133,7 +169,12 @@ export function discover(opts) {
133
169
  mode = opts.platform === "win32" ? undefined : stat.mode & 0o777;
134
170
  text = readFileSync(c.path, "utf8");
135
171
  }
136
- catch {
172
+ catch (error) {
173
+ const code = error.code;
174
+ // Absent files are normal; anything else means a config exists that we could not check.
175
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
176
+ result.unreadable.push({ client: c.client, path: c.path, error: readError(code) });
177
+ }
137
178
  continue;
138
179
  }
139
180
  let data;
@@ -141,10 +182,13 @@ export function discover(opts) {
141
182
  data = parseConfig(c.path, text);
142
183
  }
143
184
  catch (error) {
144
- result.unreadable.push({ client: c.client, path: c.path, error: error.message });
185
+ // parseConfig's messages are fixed text plus a position, never file contents.
186
+ const message = error instanceof ConfigParseError ? error.message : "could not parse it";
187
+ result.unreadable.push({ client: c.client, path: c.path, error: message });
145
188
  continue;
146
189
  }
147
- const file = { client: c.client, path: c.path, data, mode };
190
+ const exposure = mode === undefined ? undefined : exposureOf(mode, ancestorModes(c.path));
191
+ const file = { client: c.client, path: c.path, data, mode, exposure };
148
192
  result.files.push(file);
149
193
  if (!isObject(data))
150
194
  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,13 +47,18 @@ 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: {
50
55
  client: ClientId;
51
56
  path: string;
52
57
  }[];
53
- /** Files that exist but could not be parsed. */
58
+ /**
59
+ * Files that exist but could not be read or parsed. `error` is a fixed description with at most
60
+ * a line and column, for example `invalid TOML at line 3, column 12`; it never quotes the file.
61
+ */
54
62
  unreadable: {
55
63
  client: ClientId;
56
64
  path: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolstile",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
@@ -37,8 +37,8 @@
37
37
  "scripts": {
38
38
  "build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
39
39
  "typecheck": "tsc --noEmit",
40
- "test": "vitest run",
41
- "prepublishOnly": "pnpm typecheck && pnpm build && pnpm test"
40
+ "test": "pnpm build && vitest run",
41
+ "prepublishOnly": "pnpm typecheck && pnpm test"
42
42
  },
43
43
  "dependencies": {
44
44
  "smol-toml": "^1.9.1"