toolstile 0.2.1 → 0.4.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 +71 -15
- package/dist/agents.d.ts +7 -0
- package/dist/agents.js +20 -6
- package/dist/api.d.ts +2 -0
- package/dist/checks.d.ts +23 -1
- package/dist/checks.js +25 -5
- package/dist/cli.js +46 -3
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +28 -3
- 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/state.d.ts +5 -1
- package/dist/state.js +19 -2
- 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.
|
|
@@ -109,7 +165,7 @@ values) and prints a ready-to-post link. To post the text report instead, add
|
|
|
109
165
|
|
|
110
166
|
## Connect your agents to Toolstile
|
|
111
167
|
|
|
112
|
-
If you use [Toolstile](https://toolstile.com), the CLI can also sign in and set
|
|
168
|
+
If you use [Toolstile](https://www.toolstile.com), the CLI can also sign in and set
|
|
113
169
|
up the agents on a machine for you:
|
|
114
170
|
|
|
115
171
|
```sh
|
|
@@ -155,7 +211,7 @@ dashboard.
|
|
|
155
211
|
|
|
156
212
|
| Option | Command | Meaning |
|
|
157
213
|
| --- | --- | --- |
|
|
158
|
-
| `--server <url>` | `auth login` | Toolstile address (default `https://toolstile.com`, or `TOOLSTILE_URL`) |
|
|
214
|
+
| `--server <url>` | `auth login` | Toolstile address (default `https://app.toolstile.com`, or `TOOLSTILE_URL`) |
|
|
159
215
|
| `--no-browser` | `auth login` | Do not try to open a browser on this machine |
|
|
160
216
|
| `--enable <agents>` | `mcp setup` | Turn these on without prompting (comma-separated) |
|
|
161
217
|
| `--disable <agents>` | `mcp setup` | Turn these off without prompting |
|
|
@@ -173,7 +229,7 @@ const { grade, findings } = audit();
|
|
|
173
229
|
## About
|
|
174
230
|
|
|
175
231
|
Built by [Akshit Kr Nagpal](https://akshit.io) as part of
|
|
176
|
-
[Toolstile](https://toolstile.com), an MCP gateway that keeps upstream
|
|
232
|
+
[Toolstile](https://www.toolstile.com), an MCP gateway that keeps upstream
|
|
177
233
|
credentials off your machines and lets you choose which tools each agent can use.
|
|
178
234
|
The audit works on its own and does not require a Toolstile account.
|
|
179
235
|
|
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/api.d.ts
CHANGED
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.
|
|
@@ -52,7 +68,7 @@ login prints a link and a code. Open the link on any device with a browser
|
|
|
52
68
|
reach this machine, so it works over SSH and on servers without a browser.
|
|
53
69
|
|
|
54
70
|
Options
|
|
55
|
-
--server <url> Toolstile address (default https://toolstile.com, or TOOLSTILE_URL)
|
|
71
|
+
--server <url> Toolstile address (default https://app.toolstile.com, or TOOLSTILE_URL)
|
|
56
72
|
--no-browser Do not try to open a browser on this machine
|
|
57
73
|
`;
|
|
58
74
|
const MCP_HELP = `toolstile mcp: connect the agents on this machine to Toolstile
|
|
@@ -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/commands.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export declare class UserError extends Error {
|
|
|
23
23
|
}
|
|
24
24
|
/** HTTPS, except a loopback server for local development. */
|
|
25
25
|
export declare function normalizeServer(value: string): string;
|
|
26
|
+
/** A gateway URL the server reported: HTTPS (or loopback HTTP) with no credentials, else undefined. */
|
|
27
|
+
export declare function gatewayUrl(value: unknown): string | undefined;
|
|
26
28
|
export declare function login(ctx: Context, options: {
|
|
27
29
|
server?: string;
|
|
28
30
|
browser: boolean;
|
package/dist/commands.js
CHANGED
|
@@ -20,13 +20,31 @@ export function normalizeServer(value) {
|
|
|
20
20
|
url = new URL(value);
|
|
21
21
|
}
|
|
22
22
|
catch {
|
|
23
|
-
throw new UserError(`--server must be a URL, for example https://toolstile.com`);
|
|
23
|
+
throw new UserError(`--server must be a URL, for example https://app.toolstile.com`);
|
|
24
24
|
}
|
|
25
25
|
const loopback = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\])$/.test(url.hostname);
|
|
26
26
|
if (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))
|
|
27
27
|
throw new UserError("--server must use https://");
|
|
28
28
|
return url.origin;
|
|
29
29
|
}
|
|
30
|
+
/** A gateway URL the server reported: HTTPS (or loopback HTTP) with no credentials, else undefined. */
|
|
31
|
+
export function gatewayUrl(value) {
|
|
32
|
+
if (typeof value !== "string")
|
|
33
|
+
return undefined;
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(value);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const loopback = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\])$/.test(url.hostname);
|
|
42
|
+
if (url.username || url.password || url.search || url.hash)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))
|
|
45
|
+
return undefined;
|
|
46
|
+
return url.href;
|
|
47
|
+
}
|
|
30
48
|
function defaultOpenBrowser(ctx) {
|
|
31
49
|
return (url) => {
|
|
32
50
|
// Over SSH or without a display the URL and code are enough; nothing to open.
|
|
@@ -180,6 +198,8 @@ export async function setup(ctx, options) {
|
|
|
180
198
|
const both = enable.find((c) => disable.includes(c));
|
|
181
199
|
if (both)
|
|
182
200
|
throw new UserError(`${CLIENT_NAMES[both]} is in both --enable and --disable.`);
|
|
201
|
+
// The gateway can be on another host; only an HTTPS URL from this server is used.
|
|
202
|
+
const endpoint = gatewayUrl(account.mcpUrl) ?? `${state.server}/mcp`;
|
|
183
203
|
const workspace = await chooseWorkspace(ctx, state, account.workspaces, options.workspace);
|
|
184
204
|
state.workspace = { id: workspace.id, slug: workspace.slug, name: workspace.name };
|
|
185
205
|
saveState(state, stateEnv(ctx));
|
|
@@ -254,6 +274,10 @@ export async function setup(ctx, options) {
|
|
|
254
274
|
const want = wanted.has(target.client);
|
|
255
275
|
const link = state.agents[target.client];
|
|
256
276
|
if (want) {
|
|
277
|
+
if (live(target.client) && link && link.endpoint !== endpoint) {
|
|
278
|
+
link.endpoint = endpoint;
|
|
279
|
+
saveState(state, stateEnv(ctx));
|
|
280
|
+
}
|
|
257
281
|
if (!live(target.client)) {
|
|
258
282
|
if (link)
|
|
259
283
|
await removeRemote(link).catch(() => undefined);
|
|
@@ -263,6 +287,7 @@ export async function setup(ctx, options) {
|
|
|
263
287
|
id: created.id,
|
|
264
288
|
workspaceId: workspace.id,
|
|
265
289
|
server: state.server,
|
|
290
|
+
endpoint,
|
|
266
291
|
label: created.label,
|
|
267
292
|
token: created.token,
|
|
268
293
|
};
|
|
@@ -324,7 +349,7 @@ export async function setup(ctx, options) {
|
|
|
324
349
|
}
|
|
325
350
|
if ([...wanted].length) {
|
|
326
351
|
print(ctx);
|
|
327
|
-
print(ctx, `Choose which MCP servers they can use: ${state.server}
|
|
352
|
+
print(ctx, `Choose which MCP servers they can use: ${state.server}/${workspace.slug}/access`);
|
|
328
353
|
}
|
|
329
354
|
return manual.length ? 1 : 0;
|
|
330
355
|
}
|
|
@@ -338,7 +363,7 @@ export async function proxy(ctx, agent) {
|
|
|
338
363
|
throw new UserError(`${CLIENT_NAMES[client]} is not enabled on this machine. Run toolstile mcp setup.`);
|
|
339
364
|
await runProxy({
|
|
340
365
|
// The token goes only to the server that issued it, whatever the environment says.
|
|
341
|
-
endpoint: `${link.server}/mcp`,
|
|
366
|
+
endpoint: link.endpoint ?? `${link.server}/mcp`,
|
|
342
367
|
token: link.token,
|
|
343
368
|
input: ctx.stdin,
|
|
344
369
|
output: ctx.stdout,
|