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/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
- export declare function renderReport(r: AuditResult, home: string, s: Style, redacted?: boolean): string;
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
- export function renderReport(r, home, s, redacted = false) {
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 issues = r.findings.filter((f) => f.severity !== "info");
77
- const info = r.findings.filter((f) => f.severity === "info");
78
- for (const f of issues) {
79
- const tag = s.sev(f.severity, f.severity.toUpperCase().padEnd(6));
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(` ${f.title.replace(/ is set up separately in (\d+) clients$/, "")}: ${f.detail.split(".")[0].replace(/^Configured in /, "")}`));
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, pointer, name, v) {
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, pointerJoin(base, name), name, def);
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
- const base = pointerJoin(pointerJoin("projects", project), "mcpServers");
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.servers", data.mcp.servers));
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.2.1",
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": {