toolstile 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,34 +27,90 @@ scored: remote servers need an entry per client.
27
27
  ```text
28
28
  toolstile audit · local MCP config check · nothing leaves this machine
29
29
 
30
- Scanned 4 files from Claude Code, Cursor, Codex: 10 MCP servers.
30
+ Grade C 71/100 2 high · 1 medium
31
+ 10 MCP servers in 4 files from Claude Code, Cursor, Codex
32
+
33
+ HIGH Plaintext secrets (2)
34
+ Anything that can read these files can use the credentials, including
35
+ backups, dotfile syncs and other tools.
36
+
37
+ • github (Cursor) GitHub token
38
+ ~/.cursor/mcp.json mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN
39
+ • linear (Claude Code) authorization header
40
+ ~/.claude.json mcpServers.linear.headers.Authorization
41
+
42
+ Fix Move them into environment variables, or use the server's OAuth sign-in.
43
+ Rotate any secret in a file that was shared or synced.
44
+ …
45
+ Fix them: npx toolstile audit --fix (shows every change; you pick which to apply)
46
+ ```
47
+
48
+ ## Fix what it finds
49
+
50
+ ```sh
51
+ npx toolstile audit --fix
52
+ ```
31
53
 
32
- Grade B 77/100 1 high · 1 medium · 1 low
54
+ lists every change it can make, as a diff with secret values hidden, then lets
55
+ you pick which to apply (space toggles, enter applies):
33
56
 
34
- HIGH Plaintext secret in github (Cursor)
35
- A GitHub token is written directly into the config file. Anything that can
36
- read the file, including backups, dotfile syncs and other tools, can use it.
37
- ~/.cursor/mcp.json mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN
38
- Fix: Reference an environment variable instead (for example ${API_TOKEN}) …
57
+ ```text
58
+ 1. HIGH github (Cursor): move secret to $GITHUB_PERSONAL_ACCESS_TOKEN
59
+ ~/.cursor/mcp.json mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN
60
+ - "••••"
61
+ + "${env:GITHUB_PERSONAL_ACCESS_TOKEN}"
62
+ 2. MEDIUM linear (Claude Code): pin linear-mcp to 1.4.2
63
+ ~/.claude.json mcpServers.linear.args[1]
64
+ - "linear-mcp"
65
+ + "linear-mcp@1.4.2"
39
66
  ```
40
67
 
68
+ | Finding | What `--fix` does |
69
+ | --- | --- |
70
+ | Plaintext secret in `env` or a header | Replaces the value with a variable reference in the client's own syntax (`${NAME}` for Claude Code and Gemini CLI, `${env:NAME}` for Cursor, VS Code and Windsurf, `env_vars` / `bearer_token_env_var` / `env_http_headers` for Codex). The value goes into `~/.config/toolstile/secrets.env` (mode 600), and one line in your shell profile (`~/.zshrc`, `~/.bash_profile` or `~/.bashrc`, `~/.profile`, or fish's `conf.d`) loads it. A secret shared by several clients becomes one variable. |
71
+ | Config file with secrets readable by other accounts | `chmod 600` |
72
+ | Unpinned npm or PyPI package | Pins the version that is current now, looked up on npm or PyPI |
73
+ | Rule that approves a whole server, or `enableAllProjectMcpServers` | Removes it. These start unselected, since the agent then asks before each tool. |
74
+
75
+ Everything else (secrets in arguments or URLs, Claude Desktop and Cline, which
76
+ cannot read variables, Windows, `http://` servers, Docker images, broad
77
+ filesystem roots and auto-approve lists) is listed with what to change by hand.
78
+
79
+ Each edited file is checked before it is written: it must parse to exactly the
80
+ planned result, or it is left alone. JSON files keep their comments and layout;
81
+ a Codex server's table is rewritten in place. The original files are copied to
82
+ `~/.config/toolstile/backups/` first. They still hold the old secrets, so delete
83
+ them once your agents work. After moving secrets, open a new terminal and restart
84
+ your agents so they start with the variables, and rotate any secret that sat in a
85
+ shared or synced file.
86
+
87
+ `--dry-run` only shows the changes; `--yes` applies the ones that start selected
88
+ without asking.
89
+
41
90
  ## The audit only reads
42
91
 
43
- - Reads config files on your machine. Writes nothing unless you ask for a card.
44
- - Makes no network requests and collects no telemetry. (Only the separate
45
- `auth` and `mcp` commands below talk to Toolstile, and only when you run them.)
92
+ - Reads config files on your machine. Writes nothing unless you ask for a card,
93
+ or run `--fix` and confirm the changes.
94
+ - Makes no network requests and collects no telemetry. (`--fix` looks up the
95
+ current version of the unpinned packages it offers to pin on npm or PyPI. Only
96
+ the separate `auth` and `mcp` commands below talk to Toolstile, and only when
97
+ you run them.)
46
98
  - Never prints, stores or sends secret values. Findings name the file and key,
47
99
  for example `mcpServers.github.env.GITHUB_TOKEN`, and the kind of secret.
48
100
  - Never quotes a config file: one that cannot be read or parsed is listed with a
49
101
  reason and position only, for example `invalid TOML at line 3, column 12`.
50
- - About 2,500 lines of TypeScript with one runtime dependency
102
+ - About 3,500 lines of TypeScript with two runtime dependencies
51
103
  ([smol-toml](https://github.com/squirrelchat/smol-toml), for Codex's TOML
52
- config) and an optional one ([resvg-js](https://github.com/thx/resvg-js), only
104
+ config, and [jsonc-parser](https://github.com/microsoft/node-jsonc-parser), so
105
+ `--fix` keeps comments and layout in JSON files) and an optional one ([resvg-js](https://github.com/thx/resvg-js), only
53
106
  to render `--card` as PNG). Read it before you run it.
54
107
 
55
108
  ## Options
56
109
 
57
110
  ```text
111
+ --fix Suggest fixes and apply the ones you select
112
+ --yes With --fix: apply the fixes that start selected, without asking
113
+ --dry-run With --fix: only show the changes
58
114
  --json Print results as JSON
59
115
  --card [file] Write a shareable grade card (default toolstile-audit.png;
60
116
  use a .svg name for SVG). Contains only the grade and counts.
package/dist/agents.d.ts CHANGED
@@ -29,6 +29,13 @@ export declare class ManualEditError extends Error {
29
29
  readonly snippet: string;
30
30
  constructor(target: Target, reason: string, snippet: string);
31
31
  }
32
+ /** Replaces the file in one step and keeps its permission bits; a symlink keeps pointing at it. */
33
+ export declare function writeText(link: string, text: string): void;
34
+ /**
35
+ * Drops `[mcp_servers.<name>]` and its sub-tables, keeping every other line and comment. With
36
+ * `replacement`, that text goes where the table was (or at the end when there was none).
37
+ */
38
+ export declare function withoutTomlTable(text: string, server?: string, replacement?: string): string;
32
39
  /** The current entry, if the agent's config has one. */
33
40
  export declare function readEntry(target: Target): unknown;
34
41
  /**
package/dist/agents.js CHANGED
@@ -65,7 +65,7 @@ function readText(path) {
65
65
  }
66
66
  }
67
67
  /** Replaces the file in one step and keeps its permission bits; a symlink keeps pointing at it. */
68
- function writeText(link, text) {
68
+ export function writeText(link, text) {
69
69
  let path = link;
70
70
  let mode = 0o600;
71
71
  try {
@@ -92,20 +92,34 @@ function jsonEntry(target, command) {
92
92
  function tomlEntry(command) {
93
93
  return `[mcp_servers.${ENTRY}]\ncommand = ${JSON.stringify(command.command)}\nargs = [${command.args.map((a) => JSON.stringify(a)).join(", ")}]\n`;
94
94
  }
95
- /** Drops `[mcp_servers.toolstile]` and its sub-tables, keeping every other line and comment. */
96
- function withoutTomlEntry(text) {
95
+ /**
96
+ * Drops `[mcp_servers.<name>]` and its sub-tables, keeping every other line and comment. With
97
+ * `replacement`, that text goes where the table was (or at the end when there was none).
98
+ */
99
+ export function withoutTomlTable(text, server = ENTRY, replacement) {
97
100
  const lines = text.split("\n");
98
101
  const out = [];
99
102
  let skipping = false;
103
+ const plain = (header) => header.replace(/\s+/g, "").replace(/"/g, "");
104
+ const table = plain(`mcp_servers.${server}`);
100
105
  for (const line of lines) {
101
106
  const header = line.match(/^\s*\[\s*([^\]]+?)\s*\]\s*(#.*)?$/)?.[1];
102
107
  if (header !== undefined) {
103
- const name = header.replace(/\s+/g, "").replace(/"/g, "");
104
- skipping = name === `mcp_servers.${ENTRY}` || name.startsWith(`mcp_servers.${ENTRY}.`);
108
+ const name = plain(header);
109
+ skipping = name === table || name.startsWith(`${table}.`);
110
+ }
111
+ if (skipping && replacement !== undefined) {
112
+ out.push(replacement.replace(/\n+$/, ""), "");
113
+ replacement = undefined;
105
114
  }
106
115
  if (!skipping)
107
116
  out.push(line);
108
117
  }
118
+ if (replacement !== undefined) {
119
+ while (out.length && out[out.length - 1].trim() === "")
120
+ out.pop();
121
+ out.push(...(out.length ? [""] : []), replacement.replace(/\n+$/, ""), "");
122
+ }
109
123
  return out.join("\n").replace(/\n{3,}$/, "\n\n");
110
124
  }
111
125
  /** The current entry, if the agent's config has one. */
@@ -140,7 +154,7 @@ export function writeEntry(target, command) {
140
154
  const existing = current?.[ENTRY];
141
155
  if (!command && (existing === undefined || !isProxyEntry(existing)))
142
156
  return "unchanged";
143
- let next = withoutTomlEntry(text);
157
+ let next = withoutTomlTable(text);
144
158
  if (command)
145
159
  next = `${next.replace(/\s*$/, "")}${next.trim() ? "\n\n" : ""}${tomlEntry(command)}`;
146
160
  let parsed;
package/dist/checks.d.ts CHANGED
@@ -1,5 +1,26 @@
1
1
  import { type Discovery } from "./sources.js";
2
- import type { Finding, ServerEntry } from "./types.js";
2
+ import type { Finding, Location, ServerEntry } from "./types.js";
3
+ export interface Secret {
4
+ server: ServerEntry;
5
+ location: Location;
6
+ /** Keys below the server object, for example `["env", "TOKEN"]`. */
7
+ keys: string[];
8
+ kind: string;
9
+ hash: string;
10
+ /** Kept in memory only, for `audit --fix` to move; never printed. */
11
+ value: string;
12
+ }
13
+ export declare function secretsOf(s: ServerEntry): Secret[];
14
+ interface Words {
15
+ list: string[];
16
+ /** For each word, its index in `args`, or -1 when it comes from `command`. */
17
+ arg: number[];
18
+ }
19
+ /**
20
+ * Splits `command` and `args` into words, handling configs that put the whole command in one string,
21
+ * and skips Windows wrappers (`cmd /c npx …`, `wsl npx …`) so the real launcher comes first.
22
+ */
23
+ export declare function words(s: ServerEntry): Words;
3
24
  interface Package {
4
25
  registry: "npm" | "pypi" | "docker";
5
26
  name: string;
@@ -11,6 +32,7 @@ interface Package {
11
32
  }
12
33
  /** Finds the package a launcher such as npx, uvx or docker runs. */
13
34
  export declare function packageOf(s: ServerEntry): Package | null;
35
+ export declare const isPinned: (p: Package) => boolean;
14
36
  /** A stable identity for the same server configured in several places. */
15
37
  export declare function identityOf(s: ServerEntry): string | null;
16
38
  /** A path for a shell command: `~/...` under the home folder, single-quoted when needed. */
package/dist/checks.js CHANGED
@@ -8,9 +8,9 @@ const at = (s, ...keys) => ({
8
8
  pointer: keys.reduce(pointerJoin, s.pointer),
9
9
  });
10
10
  const hash = (v) => createHash("sha256").update(v.trim()).digest("hex");
11
- function secretsOf(s) {
11
+ export function secretsOf(s) {
12
12
  const out = [];
13
- const add = (value, kind, ...keys) => out.push({ server: s, location: at(s, ...keys), kind, hash: hash(value) });
13
+ const add = (value, kind, ...keys) => out.push({ server: s, location: at(s, ...keys), keys, kind, hash: hash(value), value });
14
14
  for (const [k, v] of Object.entries(s.env)) {
15
15
  const kind = classify(k, v);
16
16
  if (kind)
@@ -19,7 +19,7 @@ function secretsOf(s) {
19
19
  for (const [k, v] of Object.entries(s.headers)) {
20
20
  const kind = classify(k, v);
21
21
  if (kind)
22
- add(v, kind, "headers", k);
22
+ add(v, kind, s.headerKeys[k] ?? "headers", k);
23
23
  }
24
24
  for (const [k, v] of Object.entries(s.extraSecrets)) {
25
25
  const kind = classify(k, v);
@@ -75,7 +75,7 @@ function afterWrapper(list, start) {
75
75
  * Splits `command` and `args` into words, handling configs that put the whole command in one string,
76
76
  * and skips Windows wrappers (`cmd /c npx …`, `wsl npx …`) so the real launcher comes first.
77
77
  */
78
- function words(s) {
78
+ export function words(s) {
79
79
  if (!s.command)
80
80
  return { list: [], arg: [] };
81
81
  const oneString = s.args.length === 0 && /\s/.test(s.command.trim());
@@ -168,7 +168,7 @@ function parsePackage(registry, spec, wordIndex, argIndex) {
168
168
  ? { registry, name: spec.slice(0, at), version: spec.slice(at + 1), ...where }
169
169
  : { registry, name: spec, version: null, ...where };
170
170
  }
171
- const isPinned = (p) => p.version !== null &&
171
+ export const isPinned = (p) => p.version !== null &&
172
172
  p.version !== "latest" &&
173
173
  p.version !== "next" &&
174
174
  !/[\^~*xX><]/.test(p.version) &&
@@ -227,6 +227,8 @@ export function runChecks(d, home) {
227
227
  rule: "plaintext-secret",
228
228
  severity: "high",
229
229
  title: `Plaintext secret in ${label(server)}`,
230
+ subject: label(server),
231
+ note: kinds,
230
232
  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"}.`,
231
233
  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.",
232
234
  locations: list.map((s) => s.location),
@@ -246,6 +248,8 @@ export function runChecks(d, home) {
246
248
  rule: "copied-secret",
247
249
  severity: "medium",
248
250
  title: `Same ${list[0].kind} copied into ${files.size} files`,
251
+ subject: `${list[0].kind} in ${[...new Set(list.map((s) => s.server.name))].join(", ")}`,
252
+ note: [...new Set(list.map((s) => CLIENT_NAMES[s.server.client]))].join(", "),
249
253
  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.`,
250
254
  fix: "Keep one copy in your environment or keychain and reference it from each client, or route the clients through one gateway.",
251
255
  locations: list.map((s) => s.location),
@@ -262,6 +266,8 @@ export function runChecks(d, home) {
262
266
  rule: "readable-config",
263
267
  severity: "medium",
264
268
  title: "Config file with secrets is readable by other accounts",
269
+ subject: f.path,
270
+ note: `permissions ${f.mode.toString(8).padStart(3, "0")}, open to ${open}`,
265
271
  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
272
  fix: `Restrict it to your user: chmod 600 ${shellPath(f.path, home)}`,
267
273
  locations: [{ file: f.path, pointer: "(file permissions)" }],
@@ -277,6 +283,8 @@ export function runChecks(d, home) {
277
283
  rule: "insecure-transport",
278
284
  severity: "high",
279
285
  title: `Unencrypted remote server: ${label(s)}`,
286
+ subject: label(s),
287
+ note: `http://${u.host}`,
280
288
  detail: `${u.host} is reached over plain http://, so requests, tool results and any credentials travel unencrypted.`,
281
289
  fix: "Use the server's https:// endpoint.",
282
290
  locations: [at(s, "url")],
@@ -295,6 +303,8 @@ export function runChecks(d, home) {
295
303
  rule: "unpinned-package",
296
304
  severity: "medium",
297
305
  title: `Unpinned ${where}: ${label(s)}`,
306
+ subject: label(s),
307
+ note: `${pkg.name}${pkg.version ? `@${pkg.version}` : ""}`,
298
308
  detail: `${pkg.name}${pkg.version ? `@${pkg.version}` : ""} resolves to whatever version is newest each time the server starts, so a compromised or broken release runs with this server's access.`,
299
309
  fix: pkg.registry === "docker"
300
310
  ? `Pin an image digest or exact tag, for example ${pkg.name}:<version>.`
@@ -311,6 +321,8 @@ export function runChecks(d, home) {
311
321
  rule: "privileged-container",
312
322
  severity: "high",
313
323
  title: `Container with host-level access: ${label(s)}`,
324
+ subject: label(s),
325
+ note: [...new Set(risky)].join(", "),
314
326
  detail: `The server runs with ${[...new Set(risky)].join(", ")}, which gives it effective control of the host.`,
315
327
  fix: "Remove --privileged and the Docker socket mount unless the server truly needs them.",
316
328
  locations: [at(s, "args")],
@@ -324,6 +336,8 @@ export function runChecks(d, home) {
324
336
  rule: "broad-filesystem",
325
337
  severity: "medium",
326
338
  title: `Filesystem server can read ${roots.includes("/") ? "the whole disk" : "your whole home folder"}: ${label(s)}`,
339
+ subject: label(s),
340
+ note: `allowed root ${roots.join(", ")}`,
327
341
  detail: `Allowed root ${roots.join(", ")} includes SSH keys, cloud credentials and every other config file.`,
328
342
  fix: "Allow only the project folders the agent needs.",
329
343
  locations: [at(s, "args")],
@@ -335,6 +349,8 @@ export function runChecks(d, home) {
335
349
  rule: "auto-approved-tools",
336
350
  severity: "low",
337
351
  title: `${s.autoApproved.length} tool${s.autoApproved.length === 1 ? "" : "s"} run without asking: ${label(s)}`,
352
+ subject: label(s),
353
+ note: `${s.autoApproved.slice(0, 4).join(", ")}${s.autoApproved.length > 4 ? `, +${s.autoApproved.length - 4} more` : ""}`,
338
354
  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.`,
339
355
  fix: "Keep auto-approval to read-only tools.",
340
356
  locations: [at(s)],
@@ -350,6 +366,8 @@ export function runChecks(d, home) {
350
366
  title: all
351
367
  ? `${CLIENT_NAMES[a.client]} approves ${a.subject === "mcp__*" ? "every MCP tool" : a.subject} without asking`
352
368
  : `${CLIENT_NAMES[a.client]} runs every tool of ${a.subject.replace(/^mcp__/, "").replace(/__\*$/, "")} without asking`,
369
+ subject: `${a.subject} (${CLIENT_NAMES[a.client]})`,
370
+ note: all ? "every tool" : "every tool of the server",
353
371
  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.`,
354
372
  fix: "Allow specific read-only tools (mcp__server__tool) instead of whole servers.",
355
373
  locations: [a.location],
@@ -372,6 +390,8 @@ export function runChecks(d, home) {
372
390
  rule: "duplicated-server",
373
391
  severity: "info",
374
392
  title: `${list[0].name} is set up separately in ${clients.length} clients`,
393
+ subject: list[0].name,
394
+ note: clients.map((c) => CLIENT_NAMES[c]).join(", "),
375
395
  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
396
  fix: "Nothing to fix; listed so you know where each copy lives.",
377
397
  locations: list.map((s) => at(s)),
package/dist/cli.js CHANGED
@@ -5,12 +5,14 @@ import { resolve } from "node:path";
5
5
  import { createApi } from "./api.js";
6
6
  import { renderCard, shareText, shareUrl } from "./card.js";
7
7
  import { login, logout, proxy, setup, UserError } from "./commands.js";
8
+ import { runFix } from "./fix.js";
8
9
  import { audit } from "./index.js";
9
10
  import { redact, renderJson, renderReport, style } from "./report.js";
10
11
  const HELP = `toolstile: audit your MCP configs and connect your agents to Toolstile
11
12
 
12
13
  Usage
13
14
  npx toolstile audit [options] Check local MCP configs (offline, read-only)
15
+ npx toolstile audit --fix Review suggested fixes and apply the ones you pick
14
16
  npx toolstile auth login Sign in, from any machine, with a code
15
17
  npx toolstile auth logout Sign out on this machine
16
18
  npx toolstile mcp setup Choose which agents here use Toolstile
@@ -32,7 +34,21 @@ filesystem access and unencrypted remote servers.
32
34
  It only reads files. It sends nothing over the network and never
33
35
  prints secret values.
34
36
 
37
+ With --fix it lists every change it can make, then applies the ones
38
+ you select:
39
+ - moves plaintext secrets into environment variables, saved in an
40
+ owner-only file your shell loads, and references them from the config
41
+ - restricts config files that hold secrets to your user (chmod 600)
42
+ - pins unpinned npm and PyPI packages to their current version (the
43
+ only network use: it looks those versions up on npm and PyPI)
44
+ - removes rules that approve whole servers without asking (off at first)
45
+ Original files are backed up before they change. Findings it cannot fix
46
+ safely are listed with what to do by hand.
47
+
35
48
  Options
49
+ --fix Suggest fixes and apply the ones you select
50
+ --yes With --fix: apply the fixes that start selected, without asking
51
+ --dry-run With --fix: only show the changes
36
52
  --json Print results as JSON
37
53
  --card [file] Write a shareable grade card (default toolstile-audit.png;
38
54
  use a .svg name for SVG). Contains only the grade and counts.
@@ -80,7 +96,7 @@ function version() {
80
96
  return pkg.version;
81
97
  }
82
98
  function parseArgs(argv) {
83
- const args = { json: false, redact: false, card: null, project: process.cwd(), failOn: null };
99
+ const args = { fix: false, yes: false, dryRun: false, json: false, redact: false, card: null, project: process.cwd(), failOn: null };
84
100
  const rest = [...argv];
85
101
  if (rest[0] === "audit")
86
102
  rest.shift();
@@ -101,6 +117,16 @@ function parseArgs(argv) {
101
117
  case "-v":
102
118
  case "--version":
103
119
  return "version";
120
+ case "--fix":
121
+ args.fix = true;
122
+ break;
123
+ case "--yes":
124
+ case "-y":
125
+ args.yes = true;
126
+ break;
127
+ case "--dry-run":
128
+ args.dryRun = true;
129
+ break;
104
130
  case "--json":
105
131
  args.json = true;
106
132
  break;
@@ -136,6 +162,10 @@ function parseArgs(argv) {
136
162
  throw new Error(`Unknown option: ${a}`);
137
163
  }
138
164
  }
165
+ if ((args.yes || args.dryRun) && !args.fix)
166
+ throw new Error(`${args.yes ? "--yes" : "--dry-run"} only works with --fix`);
167
+ if (args.fix && (args.json || args.card || args.redact || args.failOn))
168
+ throw new Error("--fix cannot be combined with --json, --card, --redact or --fail-on");
139
169
  return args;
140
170
  }
141
171
  async function writeCard(path, svg) {
@@ -260,6 +290,19 @@ async function main() {
260
290
  return 0;
261
291
  }
262
292
  const home = homedir();
293
+ if (args.fix) {
294
+ return runFix({
295
+ home,
296
+ cwd: args.project,
297
+ platform: process.platform,
298
+ env: process.env,
299
+ stdin: process.stdin,
300
+ stdout: process.stdout,
301
+ style: style(process.stdout.isTTY === true && !process.env.NO_COLOR),
302
+ yes: args.yes,
303
+ dryRun: args.dryRun,
304
+ });
305
+ }
263
306
  const raw = audit({ home, cwd: args.project });
264
307
  const result = args.redact ? redact(raw, home, args.project) : raw;
265
308
  const color = !args.json && process.stdout.isTTY === true && !process.env.NO_COLOR;
@@ -267,7 +310,7 @@ async function main() {
267
310
  if (args.json)
268
311
  process.stdout.write(renderJson(result));
269
312
  else
270
- process.stdout.write(renderReport(result, home, s, args.redact));
313
+ process.stdout.write(renderReport(result, home, s, args.redact, process.stdout.columns || 80));
271
314
  if (args.card) {
272
315
  if (result.grade === null) {
273
316
  process.stderr.write("No MCP servers found, so there is no grade to put on a card.\n");
package/dist/fix.d.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { type Style } from "./report.js";
2
+ import { type ScanOptions } from "./sources.js";
3
+ import type { Severity } from "./types.js";
4
+ type Key = string | number;
5
+ /** One edit to a config file. TOML edits rewrite the server's table from the edited data. */
6
+ /** `was` is the value the plan saw; a file changed since then is left alone. */
7
+ type Op = {
8
+ type: "set";
9
+ file: string;
10
+ path: Key[];
11
+ value: unknown;
12
+ was?: unknown;
13
+ } | {
14
+ type: "remove";
15
+ file: string;
16
+ path: Key[];
17
+ was?: unknown;
18
+ }
19
+ /** Removes a string from an array, wherever it sits by the time the edit runs. */
20
+ | {
21
+ type: "remove-item";
22
+ file: string;
23
+ path: Key[];
24
+ value: string;
25
+ }
26
+ /** Adds a string to an array (created when missing) unless it is already there. */
27
+ | {
28
+ type: "append";
29
+ file: string;
30
+ path: Key[];
31
+ value: string;
32
+ } | {
33
+ type: "chmod";
34
+ file: string;
35
+ mode: number;
36
+ };
37
+ export interface Fix {
38
+ rule: string;
39
+ severity: Severity;
40
+ /** One line for the checklist. */
41
+ label: string;
42
+ file: string;
43
+ /** What changes where. Secret values are shown as •••• and never printed. */
44
+ changes: Change[];
45
+ /** Selected at first; approval changes alter how the agent works, so they start off. */
46
+ checked: boolean;
47
+ ops: Op[];
48
+ /** Variables this fix moves into the secrets file. */
49
+ secrets: {
50
+ name: string;
51
+ value: string;
52
+ }[];
53
+ }
54
+ export interface Change {
55
+ /** Key inside the file, or a description such as `permissions`. */
56
+ where: string;
57
+ from: string;
58
+ to: string;
59
+ }
60
+ export interface ManualFix {
61
+ severity: Severity;
62
+ subject: string;
63
+ reason: string;
64
+ }
65
+ export interface Plan {
66
+ fixes: Fix[];
67
+ manual: ManualFix[];
68
+ secretsFile: string;
69
+ /** The shell file that loads the secrets file, or null where `--fix` cannot load one (Windows). */
70
+ profile: {
71
+ path: string;
72
+ line: string;
73
+ } | null;
74
+ /** Packages whose current version was looked up on npm or PyPI. */
75
+ lookedUp: number;
76
+ }
77
+ export interface PlanOptions extends ScanOptions {
78
+ fetch?: typeof fetch;
79
+ }
80
+ /** The file `--fix` keeps moved secrets in, next to the CLI's credentials. */
81
+ export declare const secretsPath: (opts: {
82
+ home: string;
83
+ env: Record<string, string | undefined>;
84
+ platform: NodeJS.Platform;
85
+ }) => string;
86
+ /** The startup file of the user's shell, and the line that loads the secrets file from it. */
87
+ export declare function profileFor(opts: PlanOptions, secretsFile: string): Plan["profile"];
88
+ /**
89
+ * Works out every change `audit --fix` can make, without making any. Looks up the current
90
+ * version of unpinned npm and PyPI packages; nothing else leaves the machine.
91
+ */
92
+ export declare function planFixes(opts: PlanOptions): Promise<Plan>;
93
+ export interface ApplyResult {
94
+ applied: Fix[];
95
+ failed: {
96
+ fix: Fix;
97
+ reason: string;
98
+ }[];
99
+ backups: string | null;
100
+ profileAdded: boolean;
101
+ }
102
+ /**
103
+ * Makes the chosen changes. Every edited file is checked in memory first; secrets are saved
104
+ * and the original files backed up before any config is rewritten.
105
+ */
106
+ export declare function applyFixes(plan: Plan, chosen: Fix[], opts: {
107
+ home: string;
108
+ now?: Date;
109
+ }): ApplyResult;
110
+ export interface FixRun extends PlanOptions {
111
+ stdin: NodeJS.ReadableStream & {
112
+ isTTY?: boolean;
113
+ };
114
+ stdout: NodeJS.WritableStream & {
115
+ isTTY?: boolean;
116
+ columns?: number;
117
+ };
118
+ style: Style;
119
+ /** Apply the changes that start selected, without asking. */
120
+ yes: boolean;
121
+ /** Only show the changes. */
122
+ dryRun: boolean;
123
+ }
124
+ /** `toolstile audit --fix`: shows every change it can make, lets the user pick, then makes them. */
125
+ export declare function runFix(o: FixRun): Promise<number>;
126
+ export {};