toolstile 0.1.1 → 0.1.2
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 +12 -4
- package/dist/checks.d.ts +3 -0
- package/dist/checks.js +69 -20
- package/dist/parse.d.ts +15 -1
- package/dist/parse.js +72 -13
- package/dist/report.js +1 -1
- package/dist/secrets.js +3 -3
- package/dist/sources.js +18 -3
- package/dist/types.d.ts +4 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -6,8 +6,6 @@ Check the MCP server configs your AI agents use, in one command:
|
|
|
6
6
|
npx toolstile audit
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
The same package is also published as `@toolstile/cli` (`npx @toolstile/cli audit`).
|
|
10
|
-
|
|
11
9
|
It reads the configs of Claude Code, Claude Desktop, Cursor, Codex, VS Code,
|
|
12
10
|
Windsurf, Gemini CLI and Cline and grades them for:
|
|
13
11
|
|
|
@@ -46,7 +44,9 @@ HIGH Plaintext secret in github (Cursor)
|
|
|
46
44
|
- Makes no network requests and collects no telemetry.
|
|
47
45
|
- Never prints, stores or sends secret values. Findings name the file and key,
|
|
48
46
|
for example `mcpServers.github.env.GITHUB_TOKEN`, and the kind of secret.
|
|
49
|
-
-
|
|
47
|
+
- Never quotes a config file: one that cannot be read or parsed is listed with a
|
|
48
|
+
reason and position only, for example `invalid TOML at line 3, column 12`.
|
|
49
|
+
- About 1,400 lines of TypeScript with one runtime dependency
|
|
50
50
|
([smol-toml](https://github.com/squirrelchat/smol-toml), for Codex's TOML
|
|
51
51
|
config) and an optional one ([resvg-js](https://github.com/thx/resvg-js), only
|
|
52
52
|
to render `--card` as PNG). Read it before you run it.
|
|
@@ -66,6 +66,14 @@ HIGH Plaintext secret in github (Cursor)
|
|
|
66
66
|
`--fail-on high` makes it usable in CI or a pre-commit hook for repositories that
|
|
67
67
|
check in `.mcp.json`, `.cursor/mcp.json` or `.vscode/mcp.json`.
|
|
68
68
|
|
|
69
|
+
Exit codes:
|
|
70
|
+
|
|
71
|
+
| Code | Meaning |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| 0 | The audit ran, and no finding reached the `--fail-on` level (or `--fail-on` was not set) |
|
|
74
|
+
| 1 | A finding is at or above the `--fail-on` level |
|
|
75
|
+
| 2 | Invalid options, or the audit itself failed |
|
|
76
|
+
|
|
69
77
|
## Scoring
|
|
70
78
|
|
|
71
79
|
Each distinct problem has a weight: 10 for a leaked secret, plain-http remote or
|
|
@@ -117,7 +125,7 @@ Questions and reports: support@toolstile.com.
|
|
|
117
125
|
|
|
118
126
|
## Releases
|
|
119
127
|
|
|
120
|
-
|
|
128
|
+
The npm package is published by [GitHub Actions](.github/workflows/publish.yml)
|
|
121
129
|
with npm trusted publishing and provenance, so each release links back to the
|
|
122
130
|
commit and workflow that built it. No npm token is stored anywhere.
|
|
123
131
|
|
package/dist/checks.d.ts
CHANGED
|
@@ -4,6 +4,9 @@ interface Package {
|
|
|
4
4
|
registry: "npm" | "pypi" | "docker";
|
|
5
5
|
name: string;
|
|
6
6
|
version: string | null;
|
|
7
|
+
/** Index of the package in the unwrapped words. */
|
|
8
|
+
wordIndex: number;
|
|
9
|
+
/** Index of the package in `args`, or -1 when it is inside `command`. */
|
|
7
10
|
argIndex: number;
|
|
8
11
|
}
|
|
9
12
|
/** Finds the package a launcher such as npx, uvx or docker runs. */
|
package/dist/checks.js
CHANGED
|
@@ -41,20 +41,67 @@ const LAUNCHERS = {
|
|
|
41
41
|
pnpx: "npm",
|
|
42
42
|
uvx: "pypi",
|
|
43
43
|
};
|
|
44
|
-
|
|
44
|
+
const base = (p) => win32.basename(posix.basename(p)).replace(/\.(cmd|exe)$/i, "");
|
|
45
|
+
const split = (text) => text.trim().split(/\s+/).filter(Boolean);
|
|
46
|
+
/** WSL options that take a value. */
|
|
47
|
+
const WSL_VALUED = new Set(["-d", "--distribution", "--distribution-id", "-u", "--user", "--cd", "--shell-type"]);
|
|
48
|
+
/** Index of the first word after a `cmd /c` or `wsl` wrapper at `start`, or `start` when there is none. */
|
|
49
|
+
function afterWrapper(list, start) {
|
|
50
|
+
const name = base(list[start] ?? "").toLowerCase();
|
|
51
|
+
let j = start + 1;
|
|
52
|
+
if (name === "cmd") {
|
|
53
|
+
// cmd [/d] [/s] [/q] … /c|/k command
|
|
54
|
+
while (j < list.length && /^\/[a-z]$/i.test(list[j]) && !/^\/[ck]$/i.test(list[j]))
|
|
55
|
+
j++;
|
|
56
|
+
return /^\/[ck]$/i.test(list[j] ?? "") ? j + 1 : start;
|
|
57
|
+
}
|
|
58
|
+
if (name === "wsl") {
|
|
59
|
+
while (j < list.length) {
|
|
60
|
+
const a = list[j];
|
|
61
|
+
if (WSL_VALUED.has(a))
|
|
62
|
+
j += 2;
|
|
63
|
+
else if (a === "-e" || a === "--exec" || a === "--")
|
|
64
|
+
return j + 1;
|
|
65
|
+
else if (a.startsWith("-"))
|
|
66
|
+
j++;
|
|
67
|
+
else
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
return j;
|
|
71
|
+
}
|
|
72
|
+
return start;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Splits `command` and `args` into words, handling configs that put the whole command in one string,
|
|
76
|
+
* and skips Windows wrappers (`cmd /c npx …`, `wsl npx …`) so the real launcher comes first.
|
|
77
|
+
*/
|
|
45
78
|
function words(s) {
|
|
46
79
|
if (!s.command)
|
|
47
|
-
return [];
|
|
48
|
-
const
|
|
49
|
-
|
|
80
|
+
return { list: [], arg: [] };
|
|
81
|
+
const oneString = s.args.length === 0 && /\s/.test(s.command.trim());
|
|
82
|
+
let list = oneString ? split(s.command) : [s.command, ...s.args];
|
|
83
|
+
let arg = oneString ? list.map(() => -1) : list.map((_, i) => i - 1);
|
|
84
|
+
for (;;) {
|
|
85
|
+
const start = afterWrapper(list, 0);
|
|
86
|
+
if (start === 0 || start >= list.length)
|
|
87
|
+
break;
|
|
88
|
+
list = list.slice(start);
|
|
89
|
+
arg = arg.slice(start);
|
|
90
|
+
// `cmd /c "npx -y pkg"` passes the inner command as one argument.
|
|
91
|
+
if (list.length === 1 && /\s/.test(list[0].trim())) {
|
|
92
|
+
const from = arg[0];
|
|
93
|
+
list = split(list[0]);
|
|
94
|
+
arg = list.map(() => from);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { list, arg };
|
|
50
98
|
}
|
|
51
|
-
const base = (p) => win32.basename(posix.basename(p)).replace(/\.(cmd|exe)$/i, "");
|
|
52
99
|
/** Finds the package a launcher such as npx, uvx or docker runs. */
|
|
53
100
|
export function packageOf(s) {
|
|
54
|
-
const w = words(s);
|
|
101
|
+
const { list: w, arg } = words(s);
|
|
55
102
|
if (w.length === 0)
|
|
56
103
|
return null;
|
|
57
|
-
const cmd = base(w[0]);
|
|
104
|
+
const cmd = base(w[0]).toLowerCase();
|
|
58
105
|
const sub = w.slice(1);
|
|
59
106
|
let registry = LAUNCHERS[cmd];
|
|
60
107
|
let skip = 0;
|
|
@@ -74,6 +121,7 @@ export function packageOf(s) {
|
|
|
74
121
|
return null;
|
|
75
122
|
const rest = sub.slice(skip);
|
|
76
123
|
const offset = 1 + skip;
|
|
124
|
+
const at = (spec, i) => parsePackage(registry, spec, i, arg[i] ?? -1);
|
|
77
125
|
// Flags that take a value; their value is not the package.
|
|
78
126
|
const valued = new Set(["-p", "--package", "--from", "--with", "-e", "--env", "-v", "--volume", "--name", "--network", "--mount", "--env-file", "-w", "--workdir", "--entrypoint", "-u", "--user", "--python"]);
|
|
79
127
|
for (let i = 0; i < rest.length; i++) {
|
|
@@ -83,41 +131,42 @@ export function packageOf(s) {
|
|
|
83
131
|
if (a.startsWith("-")) {
|
|
84
132
|
if (valued.has(a)) {
|
|
85
133
|
if ((a === "--package" || a === "-p" || a === "--from") && registry !== "docker") {
|
|
86
|
-
return
|
|
134
|
+
return at(rest[i + 1] ?? "", offset + i + 1);
|
|
87
135
|
}
|
|
88
136
|
i++;
|
|
89
137
|
}
|
|
90
138
|
continue;
|
|
91
139
|
}
|
|
92
|
-
return
|
|
140
|
+
return at(a, offset + i);
|
|
93
141
|
}
|
|
94
142
|
return null;
|
|
95
143
|
}
|
|
96
|
-
function parsePackage(registry, spec, argIndex) {
|
|
144
|
+
function parsePackage(registry, spec, wordIndex, argIndex) {
|
|
97
145
|
if (!spec)
|
|
98
146
|
return null;
|
|
147
|
+
const where = { wordIndex, argIndex };
|
|
99
148
|
if (registry === "docker") {
|
|
100
149
|
const at = spec.indexOf("@sha256:");
|
|
101
150
|
if (at > 0)
|
|
102
|
-
return { registry, name: spec.slice(0, at), version: spec.slice(at + 1),
|
|
151
|
+
return { registry, name: spec.slice(0, at), version: spec.slice(at + 1), ...where };
|
|
103
152
|
const slash = spec.lastIndexOf("/");
|
|
104
153
|
const colon = spec.lastIndexOf(":");
|
|
105
154
|
return colon > slash
|
|
106
|
-
? { registry, name: spec.slice(0, colon), version: spec.slice(colon + 1),
|
|
107
|
-
: { registry, name: spec, version: null,
|
|
155
|
+
? { registry, name: spec.slice(0, colon), version: spec.slice(colon + 1), ...where }
|
|
156
|
+
: { registry, name: spec, version: null, ...where };
|
|
108
157
|
}
|
|
109
158
|
if (registry === "pypi") {
|
|
110
159
|
const m = /^([A-Za-z0-9._\-[\],]+?)(?:(==|@|>=|~=)(.+))?$/.exec(spec);
|
|
111
160
|
if (!m)
|
|
112
161
|
return null;
|
|
113
|
-
return { registry, name: m[1], version: m[2] === "==" || m[2] === "@" ? m[3] : null,
|
|
162
|
+
return { registry, name: m[1], version: m[2] === "==" || m[2] === "@" ? m[3] : null, ...where };
|
|
114
163
|
}
|
|
115
164
|
if (/^(https?:|git\+|file:|\.|\/)/.test(spec))
|
|
116
165
|
return null;
|
|
117
166
|
const at = spec.indexOf("@", spec.startsWith("@") ? 1 : 0);
|
|
118
167
|
return at > 0
|
|
119
|
-
? { registry, name: spec.slice(0, at), version: spec.slice(at + 1),
|
|
120
|
-
: { registry, name: spec, version: null,
|
|
168
|
+
? { registry, name: spec.slice(0, at), version: spec.slice(at + 1), ...where }
|
|
169
|
+
: { registry, name: spec, version: null, ...where };
|
|
121
170
|
}
|
|
122
171
|
const isPinned = (p) => p.version !== null &&
|
|
123
172
|
p.version !== "latest" &&
|
|
@@ -138,7 +187,7 @@ export function identityOf(s) {
|
|
|
138
187
|
const pkg = packageOf(s);
|
|
139
188
|
if (pkg)
|
|
140
189
|
return `${pkg.registry}:${pkg.name.toLowerCase()}`;
|
|
141
|
-
const w = words(s);
|
|
190
|
+
const w = words(s).list;
|
|
142
191
|
return w.length ? `cmd:${w.join(" ")}` : null;
|
|
143
192
|
}
|
|
144
193
|
const LOOPBACK = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)$/i;
|
|
@@ -149,7 +198,7 @@ function broadRoots(s, home) {
|
|
|
149
198
|
return [];
|
|
150
199
|
const homes = new Set([home, home.replace(/\/+$/, ""), "~", "~/", "$HOME", "${HOME}", "%USERPROFILE%"]);
|
|
151
200
|
return words(s)
|
|
152
|
-
.slice(pkg.
|
|
201
|
+
.list.slice(pkg.wordIndex + 1)
|
|
153
202
|
.filter((a) => a === "/" || /^[A-Za-z]:\\?$/.test(a) || homes.has(a) || homes.has(a.replace(/\/+$/, "")));
|
|
154
203
|
}
|
|
155
204
|
const SEVERITY_ORDER = { high: 0, medium: 1, low: 2, info: 3 };
|
|
@@ -250,11 +299,11 @@ export function runChecks(d, home) {
|
|
|
250
299
|
fix: pkg.registry === "docker"
|
|
251
300
|
? `Pin an image digest or exact tag, for example ${pkg.name}:<version>.`
|
|
252
301
|
: `Pin an exact version, for example ${pkg.name}${pkg.registry === "pypi" ? "==" : "@"}<version>.`,
|
|
253
|
-
locations: [at(s, "args", String(
|
|
302
|
+
locations: [pkg.argIndex >= 0 ? at(s, "args", String(pkg.argIndex)) : at(s, "command")],
|
|
254
303
|
weight: 4,
|
|
255
304
|
});
|
|
256
305
|
}
|
|
257
|
-
const w = words(s);
|
|
306
|
+
const w = words(s).list;
|
|
258
307
|
if (pkg?.registry === "docker") {
|
|
259
308
|
const risky = w.filter((a) => a === "--privileged" || /\/var\/run\/docker\.sock/.test(a) || a === "--net=host" || a === "--network=host");
|
|
260
309
|
if (risky.length) {
|
package/dist/parse.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* A config file that could not be parsed. The message is fixed text plus a position: parser
|
|
3
|
+
* messages quote the offending source line, which can hold a secret, so they are never kept.
|
|
4
|
+
*/
|
|
5
|
+
export declare class ConfigParseError extends Error {
|
|
6
|
+
readonly format: "JSON" | "TOML";
|
|
7
|
+
readonly line?: number | undefined;
|
|
8
|
+
readonly column?: number | undefined;
|
|
9
|
+
constructor(format: "JSON" | "TOML", line?: number | undefined, column?: number | undefined);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Parses JSON that may contain comments and trailing commas (VS Code style). Comments and
|
|
13
|
+
* trailing commas become spaces so parser positions still match the original text.
|
|
14
|
+
*/
|
|
2
15
|
export declare function parseJsonc(text: string): unknown;
|
|
16
|
+
/** Parses a config file. Throws only {@link ConfigParseError}, whose message never contains file text. */
|
|
3
17
|
export declare function parseConfig(path: string, text: string): unknown;
|
package/dist/parse.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
|
-
import { parse as parseToml } from "smol-toml";
|
|
2
|
-
/**
|
|
1
|
+
import { parse as parseToml, TomlError } from "smol-toml";
|
|
2
|
+
/**
|
|
3
|
+
* A config file that could not be parsed. The message is fixed text plus a position: parser
|
|
4
|
+
* messages quote the offending source line, which can hold a secret, so they are never kept.
|
|
5
|
+
*/
|
|
6
|
+
export class ConfigParseError extends Error {
|
|
7
|
+
format;
|
|
8
|
+
line;
|
|
9
|
+
column;
|
|
10
|
+
constructor(format, line, column) {
|
|
11
|
+
super(`invalid ${format}${line ? ` at line ${line}${column ? `, column ${column}` : ""}` : ""}`);
|
|
12
|
+
this.format = format;
|
|
13
|
+
this.line = line;
|
|
14
|
+
this.column = column;
|
|
15
|
+
this.name = "ConfigParseError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parses JSON that may contain comments and trailing commas (VS Code style). Comments and
|
|
20
|
+
* trailing commas become spaces so parser positions still match the original text.
|
|
21
|
+
*/
|
|
3
22
|
export function parseJsonc(text) {
|
|
4
23
|
let out = "";
|
|
5
24
|
let inString = false;
|
|
@@ -22,15 +41,19 @@ export function parseJsonc(text) {
|
|
|
22
41
|
out += ch;
|
|
23
42
|
}
|
|
24
43
|
else if (ch === "/" && next === "/") {
|
|
25
|
-
while (i < text.length && text[i] !== "\n")
|
|
44
|
+
while (i < text.length && text[i] !== "\n") {
|
|
45
|
+
out += blank(text[i]);
|
|
26
46
|
i++;
|
|
27
|
-
|
|
47
|
+
}
|
|
48
|
+
if (i < text.length)
|
|
49
|
+
out += "\n";
|
|
28
50
|
}
|
|
29
51
|
else if (ch === "/" && next === "*") {
|
|
30
|
-
i
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
52
|
+
const end = text.indexOf("*/", i + 2);
|
|
53
|
+
const stop = end === -1 ? text.length : end + 2;
|
|
54
|
+
for (; i < stop; i++)
|
|
55
|
+
out += blank(text[i]);
|
|
56
|
+
i--;
|
|
34
57
|
}
|
|
35
58
|
else {
|
|
36
59
|
out += ch;
|
|
@@ -38,6 +61,8 @@ export function parseJsonc(text) {
|
|
|
38
61
|
}
|
|
39
62
|
return JSON.parse(stripTrailingCommas(out));
|
|
40
63
|
}
|
|
64
|
+
/** Keeps line breaks so line numbers survive; everything else becomes a space. */
|
|
65
|
+
const blank = (ch) => (ch === "\n" || ch === "\r" ? ch : " ");
|
|
41
66
|
function stripTrailingCommas(text) {
|
|
42
67
|
let out = "";
|
|
43
68
|
let inString = false;
|
|
@@ -60,17 +85,51 @@ function stripTrailingCommas(text) {
|
|
|
60
85
|
let j = i + 1;
|
|
61
86
|
while (j < text.length && /\s/.test(text[j]))
|
|
62
87
|
j++;
|
|
63
|
-
if (text[j] === "}" || text[j] === "]")
|
|
88
|
+
if (text[j] === "}" || text[j] === "]") {
|
|
89
|
+
out += " ";
|
|
64
90
|
continue;
|
|
91
|
+
}
|
|
65
92
|
}
|
|
66
93
|
out += ch;
|
|
67
94
|
}
|
|
68
95
|
return out;
|
|
69
96
|
}
|
|
97
|
+
/** 1-based line and column of a character offset. */
|
|
98
|
+
function lineColumn(text, offset) {
|
|
99
|
+
const before = text.slice(0, Math.min(Math.max(offset, 0), text.length));
|
|
100
|
+
const lastBreak = before.lastIndexOf("\n");
|
|
101
|
+
return { line: before.split("\n").length, column: offset - lastBreak };
|
|
102
|
+
}
|
|
103
|
+
/** Reads only the position from a V8 JSON error; the rest of its message may quote the file. */
|
|
104
|
+
function jsonPosition(error, text) {
|
|
105
|
+
const message = error instanceof Error ? error.message : "";
|
|
106
|
+
const at = /\bposition (\d+)/.exec(message);
|
|
107
|
+
if (at)
|
|
108
|
+
return lineColumn(text, Number(at[1]));
|
|
109
|
+
if (/Unexpected end of JSON input/.test(message))
|
|
110
|
+
return lineColumn(text, text.length);
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
/** Parses a config file. Throws only {@link ConfigParseError}, whose message never contains file text. */
|
|
70
114
|
export function parseConfig(path, text) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
115
|
+
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
116
|
+
if (path.endsWith(".toml")) {
|
|
117
|
+
try {
|
|
118
|
+
return parseToml(body);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof TomlError)
|
|
122
|
+
throw new ConfigParseError("TOML", error.line, error.column);
|
|
123
|
+
throw new ConfigParseError("TOML");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (body.trim() === "")
|
|
74
127
|
return {};
|
|
75
|
-
|
|
128
|
+
try {
|
|
129
|
+
return parseJsonc(body);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const { line, column } = jsonPosition(error, body);
|
|
133
|
+
throw new ConfigParseError("JSON", line, column);
|
|
134
|
+
}
|
|
76
135
|
}
|
package/dist/report.js
CHANGED
|
@@ -60,7 +60,7 @@ export function renderReport(r, home, s, redacted = false) {
|
|
|
60
60
|
const clientList = r.clients.map((c) => CLIENT_NAMES[c]).join(", ");
|
|
61
61
|
lines.push(`Scanned ${plural(r.files.length, "file")} from ${clientList}: ${plural(r.servers, "MCP server")}.`);
|
|
62
62
|
for (const u of r.unreadable) {
|
|
63
|
-
lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}):
|
|
63
|
+
lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}): ${u.error}.`));
|
|
64
64
|
}
|
|
65
65
|
lines.push("");
|
|
66
66
|
if (r.grade !== null) {
|
package/dist/secrets.js
CHANGED
|
@@ -28,9 +28,9 @@ const PATTERNS = [
|
|
|
28
28
|
[/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, "JWT"],
|
|
29
29
|
[/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, "password in connection string"],
|
|
30
30
|
];
|
|
31
|
-
const SECRET_KEY = /(token|secret|passw(or)?d|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|credential|auth|bearer|session|cookie|\bpat\b|_pat$|^pat_)/i;
|
|
32
|
-
/** Key suffixes that name configuration about a secret rather than the secret itself. */
|
|
33
|
-
const NOT_SECRET_KEY = /(url|uri|endpoint|host|port|path|file|dir|type|mode|enabled|header|name|user(name)?|id|region|scope|scopes|method|env_var|_var)$/i;
|
|
31
|
+
const SECRET_KEY = /(token|secret|passw(or)?d|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|credential|auth|bearer|session|cookie|\bpat\b|_pat$|^pat_|(^|[_-])key$)/i;
|
|
32
|
+
/** Key suffixes that name configuration about a secret, or a public half, rather than the secret itself. */
|
|
33
|
+
const NOT_SECRET_KEY = /(url|uri|endpoint|host|port|path|file|dir|type|mode|enabled|header|name|user(name)?|id|region|scope|scopes|method|env_var|_var|public[_-]?key)$/i;
|
|
34
34
|
const PLACEHOLDER = /^(<.*>|\{.*\}|\[.*\]|x{4,}|\*{3,}|\.{3,}|your[-_ ].*|change[-_ ]?me|todo|placeholder|example|redacted|none|null|undefined|true|false|test|secret|token)$/i;
|
|
35
35
|
/** True for `${VAR}`, `${env:VAR}`, `$VAR`, `%VAR%` and `{env:VAR}` references. */
|
|
36
36
|
export function isReference(value) {
|
package/dist/sources.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { parseConfig } from "./parse.js";
|
|
3
|
+
import { ConfigParseError, parseConfig } from "./parse.js";
|
|
4
4
|
export const CLIENT_NAMES = {
|
|
5
5
|
"claude-code": "Claude Code",
|
|
6
6
|
"claude-desktop": "Claude Desktop",
|
|
@@ -149,6 +149,14 @@ function ancestorModes(path) {
|
|
|
149
149
|
dir = parent;
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
|
+
/** A fixed description of a read failure; system messages are not passed through. */
|
|
153
|
+
function readError(code) {
|
|
154
|
+
if (code === "EACCES" || code === "EPERM")
|
|
155
|
+
return "permission denied";
|
|
156
|
+
if (code === "EISDIR")
|
|
157
|
+
return "is a directory";
|
|
158
|
+
return code && /^E[A-Z]+$/.test(code) ? `could not read it (${code})` : "could not read it";
|
|
159
|
+
}
|
|
152
160
|
export function discover(opts) {
|
|
153
161
|
const result = { files: [], unreadable: [], servers: [], approvals: [] };
|
|
154
162
|
for (const c of candidates(opts)) {
|
|
@@ -161,7 +169,12 @@ export function discover(opts) {
|
|
|
161
169
|
mode = opts.platform === "win32" ? undefined : stat.mode & 0o777;
|
|
162
170
|
text = readFileSync(c.path, "utf8");
|
|
163
171
|
}
|
|
164
|
-
catch {
|
|
172
|
+
catch (error) {
|
|
173
|
+
const code = error.code;
|
|
174
|
+
// Absent files are normal; anything else means a config exists that we could not check.
|
|
175
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
176
|
+
result.unreadable.push({ client: c.client, path: c.path, error: readError(code) });
|
|
177
|
+
}
|
|
165
178
|
continue;
|
|
166
179
|
}
|
|
167
180
|
let data;
|
|
@@ -169,7 +182,9 @@ export function discover(opts) {
|
|
|
169
182
|
data = parseConfig(c.path, text);
|
|
170
183
|
}
|
|
171
184
|
catch (error) {
|
|
172
|
-
|
|
185
|
+
// parseConfig's messages are fixed text plus a position, never file contents.
|
|
186
|
+
const message = error instanceof ConfigParseError ? error.message : "could not parse it";
|
|
187
|
+
result.unreadable.push({ client: c.client, path: c.path, error: message });
|
|
173
188
|
continue;
|
|
174
189
|
}
|
|
175
190
|
const exposure = mode === undefined ? undefined : exposureOf(mode, ancestorModes(c.path));
|
package/dist/types.d.ts
CHANGED
|
@@ -55,7 +55,10 @@ export interface AuditResult {
|
|
|
55
55
|
client: ClientId;
|
|
56
56
|
path: string;
|
|
57
57
|
}[];
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Files that exist but could not be read or parsed. `error` is a fixed description with at most
|
|
60
|
+
* a line and column, for example `invalid TOML at line 3, column 12`; it never quotes the file.
|
|
61
|
+
*/
|
|
59
62
|
unreadable: {
|
|
60
63
|
client: ClientId;
|
|
61
64
|
path: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolstile",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Audit the MCP server configs your AI agents use for plaintext secrets, unpinned packages, auto-approved tools and duplicated setups. Local and read-only.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
39
39
|
"typecheck": "tsc --noEmit",
|
|
40
|
-
"test": "vitest run",
|
|
41
|
-
"prepublishOnly": "pnpm typecheck && pnpm
|
|
40
|
+
"test": "pnpm build && vitest run",
|
|
41
|
+
"prepublishOnly": "pnpm typecheck && pnpm test"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"smol-toml": "^1.9.1"
|