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/dist/report.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export declare function counts(findings: Finding[]): {
|
|
|
19
19
|
* becomes `<project>`, and project paths inside pointers (Claude Code's `projects["/path"]`) are hidden.
|
|
20
20
|
*/
|
|
21
21
|
export declare function redact(r: AuditResult, home: string, project: string | null): AuditResult;
|
|
22
|
-
|
|
22
|
+
/** Greedy word wrap; every line after the first starts with `indent`. Long words are kept whole. */
|
|
23
|
+
export declare function wrap(text: string, width: number, indent?: string): string;
|
|
24
|
+
export declare function renderReport(r: AuditResult, home: string, s: Style, redacted?: boolean, columns?: number): string;
|
|
23
25
|
/** JSON output for scripts. Paths are included unless redacted; secret values never are. */
|
|
24
26
|
export declare function renderJson(r: AuditResult): string;
|
package/dist/report.js
CHANGED
|
@@ -44,25 +44,85 @@ export function redact(r, home, project) {
|
|
|
44
44
|
findings: r.findings.map((f) => ({
|
|
45
45
|
...f,
|
|
46
46
|
title: scrub(f.title),
|
|
47
|
+
subject: scrub(f.subject),
|
|
48
|
+
note: scrub(f.note),
|
|
47
49
|
detail: scrub(f.detail),
|
|
48
50
|
fix: scrub(f.fix),
|
|
49
51
|
locations: f.locations.map((l) => ({ file: scrub(l.file), pointer: scrub(l.pointer) })),
|
|
50
52
|
})),
|
|
51
53
|
};
|
|
52
54
|
}
|
|
53
|
-
|
|
55
|
+
/** How each kind of finding is explained once, above the places it was found. */
|
|
56
|
+
const GROUPS = {
|
|
57
|
+
"plaintext-secret": {
|
|
58
|
+
heading: "Plaintext secrets",
|
|
59
|
+
why: "Anything that can read these files can use the credentials, including backups, dotfile syncs and other tools.",
|
|
60
|
+
fix: "Move them into environment variables, or use the server's OAuth sign-in. Rotate any secret in a file that was shared or synced.",
|
|
61
|
+
},
|
|
62
|
+
"insecure-transport": {
|
|
63
|
+
heading: "Unencrypted remote servers",
|
|
64
|
+
why: "Requests, tool results and any credentials travel over plain http://.",
|
|
65
|
+
fix: "Use the server's https:// endpoint.",
|
|
66
|
+
},
|
|
67
|
+
"privileged-container": {
|
|
68
|
+
heading: "Containers with host-level access",
|
|
69
|
+
why: "--privileged, the Docker socket and host networking give the server effective control of this machine.",
|
|
70
|
+
fix: "Remove them unless the server truly needs them.",
|
|
71
|
+
},
|
|
72
|
+
"copied-secret": {
|
|
73
|
+
heading: "Credentials copied into several files",
|
|
74
|
+
why: "Rotating or revoking one of these means finding every copy.",
|
|
75
|
+
fix: "Keep one copy in your environment or keychain and reference it from each client, or route the clients through one gateway.",
|
|
76
|
+
},
|
|
77
|
+
"readable-config": {
|
|
78
|
+
heading: "Files with secrets that other accounts can read",
|
|
79
|
+
why: "The file and every folder above it are open, so other accounts on this machine can read the secrets.",
|
|
80
|
+
fix: "Restrict each file to your user with chmod 600.",
|
|
81
|
+
},
|
|
82
|
+
"unpinned-package": {
|
|
83
|
+
heading: "Unpinned packages",
|
|
84
|
+
why: "Each start runs whatever version is newest, so a compromised or broken release runs with the server's access.",
|
|
85
|
+
fix: "Pin an exact version (an image digest or exact tag for Docker).",
|
|
86
|
+
},
|
|
87
|
+
"broad-filesystem": {
|
|
88
|
+
heading: "Filesystem servers with broad access",
|
|
89
|
+
why: "The allowed root includes SSH keys, cloud credentials and every other config file.",
|
|
90
|
+
fix: "Allow only the project folders the agent needs.",
|
|
91
|
+
},
|
|
92
|
+
"auto-approved-tools": {
|
|
93
|
+
heading: "Tools that run without asking",
|
|
94
|
+
why: "A prompt injection in a web page, issue or email the agent reads can call these, including write and delete tools.",
|
|
95
|
+
fix: "Approve specific read-only tools (mcp__server__tool) instead of whole servers.",
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
/** Greedy word wrap; every line after the first starts with `indent`. Long words are kept whole. */
|
|
99
|
+
export function wrap(text, width, indent = "") {
|
|
100
|
+
const lines = [];
|
|
101
|
+
let line = "";
|
|
102
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
103
|
+
const room = width - (lines.length ? indent.length : 0);
|
|
104
|
+
if (line && line.length + 1 + word.length > room) {
|
|
105
|
+
lines.push(line);
|
|
106
|
+
line = word;
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
line = line ? `${line} ${word}` : word;
|
|
110
|
+
}
|
|
111
|
+
if (line)
|
|
112
|
+
lines.push(line);
|
|
113
|
+
return lines.join(`\n${indent}`);
|
|
114
|
+
}
|
|
115
|
+
const SEVERITIES = ["high", "medium", "low"];
|
|
116
|
+
export function renderReport(r, home, s, redacted = false, columns = 80) {
|
|
117
|
+
// Wide terminals still get short lines: they are easier to read.
|
|
118
|
+
const width = Math.max(40, Math.min(columns, 100) - 1);
|
|
119
|
+
const para = (text, indent = " ") => indent + wrap(text, width - indent.length, indent);
|
|
54
120
|
const lines = [];
|
|
55
121
|
lines.push("", `${s.bold("toolstile audit")} ${s.dim("· local MCP config check · nothing leaves this machine")}`, "");
|
|
56
122
|
if (r.files.length === 0 && r.unreadable.length === 0) {
|
|
57
123
|
lines.push("No MCP client configs found.", s.dim("Looked for Claude Code, Claude Desktop, Cursor, Codex, VS Code, Windsurf, Gemini CLI and Cline."), "");
|
|
58
124
|
return lines.join("\n");
|
|
59
125
|
}
|
|
60
|
-
const clientList = r.clients.map((c) => CLIENT_NAMES[c]).join(", ");
|
|
61
|
-
lines.push(`Scanned ${plural(r.files.length, "file")} from ${clientList}: ${plural(r.servers, "MCP server")}.`);
|
|
62
|
-
for (const u of r.unreadable) {
|
|
63
|
-
lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}): ${u.error}.`));
|
|
64
|
-
}
|
|
65
|
-
lines.push("");
|
|
66
126
|
if (r.grade !== null) {
|
|
67
127
|
const c = counts(r.findings);
|
|
68
128
|
const parts = [
|
|
@@ -71,33 +131,55 @@ export function renderReport(r, home, s, redacted = false) {
|
|
|
71
131
|
c.low && s.sev("low", `${c.low} low`),
|
|
72
132
|
].filter(Boolean);
|
|
73
133
|
const summary = parts.length ? parts.join(s.dim(" · ")) : "no issues found";
|
|
74
|
-
lines.push(` ${s.bold(s.grade(r.grade, `Grade ${r.grade}`))} ${s.dim(`${r.score}/100`)} ${summary}
|
|
134
|
+
lines.push(` ${s.bold(s.grade(r.grade, `Grade ${r.grade}`))} ${s.dim(`${r.score}/100`)} ${summary}`);
|
|
75
135
|
}
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
for (const
|
|
79
|
-
|
|
80
|
-
lines.push(`${tag} ${s.bold(f.title)}`);
|
|
81
|
-
lines.push(` ${f.detail}`);
|
|
82
|
-
const shown = f.locations.slice(0, 5);
|
|
83
|
-
for (const l of shown)
|
|
84
|
-
lines.push(s.dim(` ${tildify(l.file, home)} ${l.pointer}`));
|
|
85
|
-
if (f.locations.length > shown.length)
|
|
86
|
-
lines.push(s.dim(` … and ${f.locations.length - shown.length} more`));
|
|
87
|
-
lines.push(` ${s.dim("Fix:")} ${f.fix}`, "");
|
|
136
|
+
const clientList = r.clients.map((c) => CLIENT_NAMES[c]).join(", ");
|
|
137
|
+
lines.push(s.dim(para(`${plural(r.servers, "MCP server")} in ${plural(r.files.length, "file")} from ${clientList}`)));
|
|
138
|
+
for (const u of r.unreadable) {
|
|
139
|
+
lines.push(s.dim(para(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}): ${u.error}.`)));
|
|
88
140
|
}
|
|
141
|
+
lines.push("");
|
|
142
|
+
// One block per kind of problem: what it is and how to fix it once, then each place it was found.
|
|
143
|
+
for (const severity of SEVERITIES) {
|
|
144
|
+
const byRule = new Map();
|
|
145
|
+
for (const f of r.findings)
|
|
146
|
+
if (f.severity === severity)
|
|
147
|
+
byRule.set(f.rule, [...(byRule.get(f.rule) ?? []), f]);
|
|
148
|
+
for (const [rule, list] of byRule) {
|
|
149
|
+
const group = GROUPS[rule];
|
|
150
|
+
const tag = s.sev(severity, severity.toUpperCase());
|
|
151
|
+
lines.push(`${tag} ${s.bold(group?.heading ?? list[0].title)} ${s.dim(`(${list.length})`)}`);
|
|
152
|
+
lines.push(para(group?.why ?? list[0].detail), "");
|
|
153
|
+
for (const f of list) {
|
|
154
|
+
const note = f.note ? ` ${s.dim(f.note)}` : "";
|
|
155
|
+
lines.push(` ${s.sev(severity, "•")} ${tildify(f.subject, home)}${note}`);
|
|
156
|
+
const shown = f.locations.filter((l) => l.file !== f.subject).slice(0, 4);
|
|
157
|
+
for (const l of shown) {
|
|
158
|
+
const where = `${tildify(l.file, home)} ${l.pointer}`;
|
|
159
|
+
lines.push(s.dim(where.length <= width - 4 ? ` ${where}` : para(where, " ")));
|
|
160
|
+
}
|
|
161
|
+
const hidden = f.locations.filter((l) => l.file !== f.subject).length - shown.length;
|
|
162
|
+
if (hidden > 0)
|
|
163
|
+
lines.push(s.dim(` … and ${hidden} more`));
|
|
164
|
+
}
|
|
165
|
+
lines.push("", ` ${s.bold("Fix")} ${wrap(group?.fix ?? list[0].fix, width - 6, " ")}`, "");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const info = r.findings.filter((f) => f.severity === "info");
|
|
89
169
|
if (info.length) {
|
|
90
170
|
lines.push(s.dim("Also set up in more than one client (not scored):"));
|
|
91
171
|
for (const f of info)
|
|
92
|
-
lines.push(s.dim(
|
|
172
|
+
lines.push(s.dim(para(`${f.subject}: ${f.note}`)));
|
|
93
173
|
lines.push("");
|
|
94
174
|
}
|
|
175
|
+
if (r.findings.some((f) => f.severity !== "info"))
|
|
176
|
+
lines.push(`${s.bold("Fix them:")} npx toolstile audit --fix ${s.dim("(shows every change; you pick which to apply)")}`, "");
|
|
95
177
|
lines.push(s.dim("Secret values are never printed."));
|
|
96
178
|
if (!redacted)
|
|
97
|
-
lines.push(s.dim("Posting this? Run with --redact to hide your paths, or --card for an image with only the grade."));
|
|
179
|
+
lines.push(s.dim(wrap("Posting this? Run with --redact to hide your paths, or --card for an image with only the grade.", width)));
|
|
98
180
|
else
|
|
99
181
|
lines.push(s.dim("Share an image with only the grade: npx toolstile audit --card"));
|
|
100
|
-
lines.push(s.dim("Keep credentials off your machines and control each agent's tools: https://toolstile.com/?ref=cli"), "");
|
|
182
|
+
lines.push(s.dim(wrap("Keep credentials off your machines and control each agent's tools: https://toolstile.com/?ref=cli", width)), "");
|
|
101
183
|
return lines.join("\n");
|
|
102
184
|
}
|
|
103
185
|
/** JSON output for scripts. Paths are included unless redacted; secret values never are. */
|
package/dist/sources.js
CHANGED
|
@@ -61,23 +61,29 @@ export function pointerJoin(base, key) {
|
|
|
61
61
|
return part;
|
|
62
62
|
return part.startsWith("[") ? `${base}${part}` : `${base}.${part}`;
|
|
63
63
|
}
|
|
64
|
-
function toServer(client, file,
|
|
64
|
+
function toServer(client, file, path, name, v) {
|
|
65
65
|
if (!isObject(v))
|
|
66
66
|
return null;
|
|
67
67
|
const extraSecrets = {};
|
|
68
68
|
const bearer = str(v.bearer_token);
|
|
69
69
|
if (bearer)
|
|
70
70
|
extraSecrets.bearer_token = bearer;
|
|
71
|
+
const headerKeys = {};
|
|
72
|
+
for (const key of ["headers", "http_headers"])
|
|
73
|
+
for (const name of Object.keys(stringRecord(v[key])))
|
|
74
|
+
headerKeys[name] = key;
|
|
71
75
|
return {
|
|
72
76
|
client,
|
|
73
77
|
file,
|
|
74
|
-
pointer,
|
|
78
|
+
pointer: path.reduce(pointerJoin, ""),
|
|
79
|
+
path,
|
|
75
80
|
name,
|
|
76
81
|
command: str(v.command),
|
|
77
82
|
args: strings(v.args),
|
|
78
83
|
env: stringRecord(v.env),
|
|
79
84
|
url: str(v.url) ?? str(v.serverUrl) ?? str(v.httpUrl),
|
|
80
85
|
headers: { ...stringRecord(v.headers), ...stringRecord(v.http_headers) },
|
|
86
|
+
headerKeys,
|
|
81
87
|
extraSecrets,
|
|
82
88
|
autoApproved: [...strings(v.alwaysAllow), ...strings(v.autoApprove)],
|
|
83
89
|
};
|
|
@@ -87,7 +93,7 @@ function serversAt(client, file, base, v) {
|
|
|
87
93
|
return [];
|
|
88
94
|
const out = [];
|
|
89
95
|
for (const [name, def] of Object.entries(v)) {
|
|
90
|
-
const server = toServer(client, file,
|
|
96
|
+
const server = toServer(client, file, [...base, name], name, def);
|
|
91
97
|
if (server)
|
|
92
98
|
out.push(server);
|
|
93
99
|
}
|
|
@@ -194,16 +200,15 @@ export function discover(opts) {
|
|
|
194
200
|
continue;
|
|
195
201
|
switch (c.kind) {
|
|
196
202
|
case "mcp":
|
|
197
|
-
result.servers.push(...serversAt(c.client, c.path, "mcpServers", data.mcpServers));
|
|
203
|
+
result.servers.push(...serversAt(c.client, c.path, ["mcpServers"], data.mcpServers));
|
|
198
204
|
break;
|
|
199
205
|
case "claude-json":
|
|
200
|
-
result.servers.push(...serversAt(c.client, c.path, "mcpServers", data.mcpServers));
|
|
206
|
+
result.servers.push(...serversAt(c.client, c.path, ["mcpServers"], data.mcpServers));
|
|
201
207
|
if (isObject(data.projects)) {
|
|
202
208
|
for (const [project, conf] of Object.entries(data.projects)) {
|
|
203
209
|
if (!isObject(conf))
|
|
204
210
|
continue;
|
|
205
|
-
|
|
206
|
-
result.servers.push(...serversAt(c.client, c.path, base, conf.mcpServers));
|
|
211
|
+
result.servers.push(...serversAt(c.client, c.path, ["projects", project, "mcpServers"], conf.mcpServers));
|
|
207
212
|
}
|
|
208
213
|
}
|
|
209
214
|
break;
|
|
@@ -211,14 +216,14 @@ export function discover(opts) {
|
|
|
211
216
|
result.approvals.push(...claudeApprovals(file));
|
|
212
217
|
break;
|
|
213
218
|
case "codex":
|
|
214
|
-
result.servers.push(...serversAt(c.client, c.path, "mcp_servers", data.mcp_servers));
|
|
219
|
+
result.servers.push(...serversAt(c.client, c.path, ["mcp_servers"], data.mcp_servers));
|
|
215
220
|
break;
|
|
216
221
|
case "vscode-settings":
|
|
217
222
|
if (isObject(data.mcp))
|
|
218
|
-
result.servers.push(...serversAt(c.client, c.path, "mcp
|
|
223
|
+
result.servers.push(...serversAt(c.client, c.path, ["mcp", "servers"], data.mcp.servers));
|
|
219
224
|
break;
|
|
220
225
|
case "vscode-mcp":
|
|
221
|
-
result.servers.push(...serversAt(c.client, c.path, "servers", data.servers));
|
|
226
|
+
result.servers.push(...serversAt(c.client, c.path, ["servers"], data.servers));
|
|
222
227
|
break;
|
|
223
228
|
}
|
|
224
229
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -22,12 +22,16 @@ export interface ServerEntry {
|
|
|
22
22
|
file: string;
|
|
23
23
|
/** Pointer to the server object, for example `mcpServers.github`. */
|
|
24
24
|
pointer: string;
|
|
25
|
+
/** The same location as keys, for editing the file. */
|
|
26
|
+
path: string[];
|
|
25
27
|
name: string;
|
|
26
28
|
command?: string;
|
|
27
29
|
args: string[];
|
|
28
30
|
env: Record<string, string>;
|
|
29
31
|
url?: string;
|
|
30
32
|
headers: Record<string, string>;
|
|
33
|
+
/** The config key each header came from: `headers`, or Codex's `http_headers`. */
|
|
34
|
+
headerKeys: Record<string, string>;
|
|
31
35
|
/** Other string fields that may carry credentials, such as Codex `bearer_token`. */
|
|
32
36
|
extraSecrets: Record<string, string>;
|
|
33
37
|
/** Tools listed under client auto-approval keys (`alwaysAllow`, `autoApprove`). */
|
|
@@ -44,6 +48,10 @@ export interface Finding {
|
|
|
44
48
|
rule: string;
|
|
45
49
|
severity: Severity;
|
|
46
50
|
title: string;
|
|
51
|
+
/** What the finding is about, for example `github (Cursor)` or a file path. */
|
|
52
|
+
subject: string;
|
|
53
|
+
/** A short specific, for example the kind of secret or the package; may be empty. */
|
|
54
|
+
note: string;
|
|
47
55
|
detail: string;
|
|
48
56
|
fix: string;
|
|
49
57
|
locations: Location[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolstile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Audit the MCP server configs your AI agents use for plaintext secrets, unpinned packages, auto-approved tools and duplicated setups, and connect those agents to Toolstile without putting tokens in their configs.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"prepublishOnly": "pnpm typecheck && pnpm test"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
+
"jsonc-parser": "^3.3.1",
|
|
46
47
|
"smol-toml": "^1.9.1"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|