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 +68 -12
- package/dist/agents.d.ts +7 -0
- package/dist/agents.js +20 -6
- package/dist/checks.d.ts +23 -1
- package/dist/checks.js +25 -5
- package/dist/cli.js +45 -2
- package/dist/fix.d.ts +126 -0
- package/dist/fix.js +622 -0
- package/dist/prompt.d.ts +2 -2
- package/dist/prompt.js +15 -4
- package/dist/report.d.ts +3 -1
- package/dist/report.js +105 -23
- package/dist/sources.js +15 -10
- package/dist/types.d.ts +8 -0
- package/package.json +2 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
96
|
-
|
|
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
|
|
104
|
-
skipping = name ===
|
|
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 =
|
|
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 {};
|