toolstile 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,211 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { parseConfig } from "./parse.js";
4
+ export const CLIENT_NAMES = {
5
+ "claude-code": "Claude Code",
6
+ "claude-desktop": "Claude Desktop",
7
+ cursor: "Cursor",
8
+ codex: "Codex",
9
+ vscode: "VS Code",
10
+ windsurf: "Windsurf",
11
+ gemini: "Gemini CLI",
12
+ cline: "Cline",
13
+ };
14
+ function appData({ home, platform, env }) {
15
+ if (platform === "darwin")
16
+ return join(home, "Library", "Application Support");
17
+ if (platform === "win32")
18
+ return env.APPDATA ?? join(home, "AppData", "Roaming");
19
+ return env.XDG_CONFIG_HOME ?? join(home, ".config");
20
+ }
21
+ function candidates(opts) {
22
+ const { home, cwd, env } = opts;
23
+ const data = appData(opts);
24
+ const codexHome = env.CODEX_HOME ?? join(home, ".codex");
25
+ const clineSettings = ["globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"];
26
+ const list = [
27
+ { client: "claude-code", path: join(home, ".claude.json"), kind: "claude-json" },
28
+ { client: "claude-code", path: join(home, ".claude", "settings.json"), kind: "claude-settings" },
29
+ { client: "claude-desktop", path: join(data, "Claude", "claude_desktop_config.json"), kind: "mcp" },
30
+ { client: "cursor", path: join(home, ".cursor", "mcp.json"), kind: "mcp" },
31
+ { client: "codex", path: join(codexHome, "config.toml"), kind: "codex" },
32
+ { client: "windsurf", path: join(home, ".codeium", "windsurf", "mcp_config.json"), kind: "mcp" },
33
+ { client: "gemini", path: join(home, ".gemini", "settings.json"), kind: "mcp" },
34
+ ];
35
+ for (const edition of ["Code", "Code - Insiders"]) {
36
+ list.push({ client: "vscode", path: join(data, edition, "User", "settings.json"), kind: "vscode-settings" }, { client: "vscode", path: join(data, edition, "User", "mcp.json"), kind: "vscode-mcp" }, { client: "cline", path: join(data, edition, "User", ...clineSettings), kind: "mcp" });
37
+ }
38
+ list.push({ client: "cline", path: join(data, "Cursor", "User", ...clineSettings), kind: "mcp" });
39
+ if (cwd) {
40
+ const dir = resolve(cwd);
41
+ list.push({ client: "claude-code", path: join(dir, ".mcp.json"), kind: "mcp" }, { client: "claude-code", path: join(dir, ".claude", "settings.json"), kind: "claude-settings" }, { client: "claude-code", path: join(dir, ".claude", "settings.local.json"), kind: "claude-settings" }, { client: "cursor", path: join(dir, ".cursor", "mcp.json"), kind: "mcp" }, { client: "vscode", path: join(dir, ".vscode", "mcp.json"), kind: "vscode-mcp" }, { client: "gemini", path: join(dir, ".gemini", "settings.json"), kind: "mcp" }, { client: "codex", path: join(dir, ".codex", "config.toml"), kind: "codex" });
42
+ }
43
+ const seen = new Set();
44
+ return list.filter((c) => (seen.has(c.path) ? false : (seen.add(c.path), true)));
45
+ }
46
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
47
+ const str = (v) => (typeof v === "string" ? v : undefined);
48
+ const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
49
+ function stringRecord(v) {
50
+ const out = {};
51
+ if (isObject(v))
52
+ for (const [k, x] of Object.entries(v))
53
+ if (typeof x === "string")
54
+ out[k] = x;
55
+ return out;
56
+ }
57
+ /** Appends a key to a pointer, quoting keys that are not plain identifiers. */
58
+ export function pointerJoin(base, key) {
59
+ const part = /^[A-Za-z_$][\w$-]*$/.test(key) ? key : /^\d+$/.test(key) ? `[${key}]` : `[${JSON.stringify(key)}]`;
60
+ if (!base)
61
+ return part;
62
+ return part.startsWith("[") ? `${base}${part}` : `${base}.${part}`;
63
+ }
64
+ function toServer(client, file, pointer, name, v) {
65
+ if (!isObject(v))
66
+ return null;
67
+ const extraSecrets = {};
68
+ const bearer = str(v.bearer_token);
69
+ if (bearer)
70
+ extraSecrets.bearer_token = bearer;
71
+ return {
72
+ client,
73
+ file,
74
+ pointer,
75
+ name,
76
+ command: str(v.command),
77
+ args: strings(v.args),
78
+ env: stringRecord(v.env),
79
+ url: str(v.url) ?? str(v.serverUrl) ?? str(v.httpUrl),
80
+ headers: { ...stringRecord(v.headers), ...stringRecord(v.http_headers) },
81
+ extraSecrets,
82
+ autoApproved: [...strings(v.alwaysAllow), ...strings(v.autoApprove)],
83
+ };
84
+ }
85
+ function serversAt(client, file, base, v) {
86
+ if (!isObject(v))
87
+ return [];
88
+ const out = [];
89
+ for (const [name, def] of Object.entries(v)) {
90
+ const server = toServer(client, file, pointerJoin(base, name), name, def);
91
+ if (server)
92
+ out.push(server);
93
+ }
94
+ return out;
95
+ }
96
+ function claudeApprovals(file) {
97
+ const data = file.data;
98
+ if (!isObject(data))
99
+ return [];
100
+ const out = [];
101
+ if (data.enableAllProjectMcpServers === true) {
102
+ out.push({
103
+ client: file.client,
104
+ location: { file: file.path, pointer: "enableAllProjectMcpServers" },
105
+ subject: "every MCP server in project .mcp.json files",
106
+ });
107
+ }
108
+ const allow = isObject(data.permissions) ? strings(data.permissions.allow) : [];
109
+ allow.forEach((rule, i) => {
110
+ // `mcp__server` and `mcp__server__*` approve every tool of a server; named tools are deliberate.
111
+ if (!rule.startsWith("mcp__"))
112
+ return;
113
+ const rest = rule.slice("mcp__".length);
114
+ if (rest === "*" || !rest.includes("__") || rest.endsWith("__*")) {
115
+ out.push({
116
+ client: file.client,
117
+ location: { file: file.path, pointer: `permissions.allow[${i}]` },
118
+ subject: rule,
119
+ });
120
+ }
121
+ });
122
+ return out;
123
+ }
124
+ /**
125
+ * Who besides the owner can read a file, given its mode and the modes of every folder above it.
126
+ * A group or other user needs read on the file and execute (traverse) on each folder.
127
+ */
128
+ export function exposureOf(fileMode, ancestorModes) {
129
+ if (fileMode & 0o004 && ancestorModes.every((m) => m & 0o001))
130
+ return "others";
131
+ if (fileMode & 0o040 && ancestorModes.every((m) => m & 0o010))
132
+ return "group";
133
+ return null;
134
+ }
135
+ function ancestorModes(path) {
136
+ const modes = [];
137
+ let dir = dirname(path);
138
+ for (;;) {
139
+ try {
140
+ modes.push(statSync(dir).mode & 0o777);
141
+ }
142
+ catch {
143
+ // An unreadable folder is not traversable by us either; treat it as closed.
144
+ modes.push(0);
145
+ }
146
+ const parent = dirname(dir);
147
+ if (parent === dir)
148
+ return modes;
149
+ dir = parent;
150
+ }
151
+ }
152
+ export function discover(opts) {
153
+ const result = { files: [], unreadable: [], servers: [], approvals: [] };
154
+ for (const c of candidates(opts)) {
155
+ let text;
156
+ let mode;
157
+ try {
158
+ const stat = statSync(c.path);
159
+ if (!stat.isFile())
160
+ continue;
161
+ mode = opts.platform === "win32" ? undefined : stat.mode & 0o777;
162
+ text = readFileSync(c.path, "utf8");
163
+ }
164
+ catch {
165
+ continue;
166
+ }
167
+ let data;
168
+ try {
169
+ data = parseConfig(c.path, text);
170
+ }
171
+ catch (error) {
172
+ result.unreadable.push({ client: c.client, path: c.path, error: error.message });
173
+ continue;
174
+ }
175
+ const exposure = mode === undefined ? undefined : exposureOf(mode, ancestorModes(c.path));
176
+ const file = { client: c.client, path: c.path, data, mode, exposure };
177
+ result.files.push(file);
178
+ if (!isObject(data))
179
+ continue;
180
+ switch (c.kind) {
181
+ case "mcp":
182
+ result.servers.push(...serversAt(c.client, c.path, "mcpServers", data.mcpServers));
183
+ break;
184
+ case "claude-json":
185
+ result.servers.push(...serversAt(c.client, c.path, "mcpServers", data.mcpServers));
186
+ if (isObject(data.projects)) {
187
+ for (const [project, conf] of Object.entries(data.projects)) {
188
+ if (!isObject(conf))
189
+ continue;
190
+ const base = pointerJoin(pointerJoin("projects", project), "mcpServers");
191
+ result.servers.push(...serversAt(c.client, c.path, base, conf.mcpServers));
192
+ }
193
+ }
194
+ break;
195
+ case "claude-settings":
196
+ result.approvals.push(...claudeApprovals(file));
197
+ break;
198
+ case "codex":
199
+ result.servers.push(...serversAt(c.client, c.path, "mcp_servers", data.mcp_servers));
200
+ break;
201
+ case "vscode-settings":
202
+ if (isObject(data.mcp))
203
+ result.servers.push(...serversAt(c.client, c.path, "mcp.servers", data.mcp.servers));
204
+ break;
205
+ case "vscode-mcp":
206
+ result.servers.push(...serversAt(c.client, c.path, "servers", data.servers));
207
+ break;
208
+ }
209
+ }
210
+ return result;
211
+ }
@@ -0,0 +1,69 @@
1
+ /** `info` findings are context only and do not affect the score. */
2
+ export type Severity = "high" | "medium" | "low" | "info";
3
+ export type ClientId = "claude-code" | "claude-desktop" | "cursor" | "codex" | "vscode" | "windsurf" | "gemini" | "cline";
4
+ export interface Location {
5
+ /** Absolute path of the config file. */
6
+ file: string;
7
+ /** Dotted path to the value inside the file, for example `mcpServers.github.env.TOKEN`. */
8
+ pointer: string;
9
+ }
10
+ export interface ConfigFile {
11
+ client: ClientId;
12
+ path: string;
13
+ /** Parsed file contents. */
14
+ data: unknown;
15
+ /** Unix permission bits, when available. */
16
+ mode?: number;
17
+ /** Who besides the owner can read the file, counting the folders above it. */
18
+ exposure?: "group" | "others" | null;
19
+ }
20
+ export interface ServerEntry {
21
+ client: ClientId;
22
+ file: string;
23
+ /** Pointer to the server object, for example `mcpServers.github`. */
24
+ pointer: string;
25
+ name: string;
26
+ command?: string;
27
+ args: string[];
28
+ env: Record<string, string>;
29
+ url?: string;
30
+ headers: Record<string, string>;
31
+ /** Other string fields that may carry credentials, such as Codex `bearer_token`. */
32
+ extraSecrets: Record<string, string>;
33
+ /** Tools listed under client auto-approval keys (`alwaysAllow`, `autoApprove`). */
34
+ autoApproved: string[];
35
+ }
36
+ /** Client settings that approve MCP tools without asking. */
37
+ export interface ApprovalSetting {
38
+ client: ClientId;
39
+ location: Location;
40
+ /** What is approved, for example `mcp__github` or `all project servers`. */
41
+ subject: string;
42
+ }
43
+ export interface Finding {
44
+ rule: string;
45
+ severity: Severity;
46
+ title: string;
47
+ detail: string;
48
+ fix: string;
49
+ locations: Location[];
50
+ /** Points this finding takes off the score; repeats of one root cause count once. */
51
+ weight: number;
52
+ }
53
+ export interface AuditResult {
54
+ files: {
55
+ client: ClientId;
56
+ path: string;
57
+ }[];
58
+ /** Files that exist but could not be parsed. */
59
+ unreadable: {
60
+ client: ClientId;
61
+ path: string;
62
+ error: string;
63
+ }[];
64
+ servers: number;
65
+ clients: ClientId[];
66
+ findings: Finding[];
67
+ score: number | null;
68
+ grade: string | null;
69
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "toolstile",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "security",
9
+ "audit",
10
+ "claude",
11
+ "cursor",
12
+ "codex",
13
+ "secrets"
14
+ ],
15
+ "homepage": "https://github.com/toolstile/cli#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/toolstile/cli.git"
19
+ },
20
+ "bugs": {
21
+ "email": "support@toolstile.com"
22
+ },
23
+ "author": "Akshit Kr Nagpal <hello@akshit.io> (https://akshit.io)",
24
+ "license": "MIT",
25
+ "type": "module",
26
+ "bin": {
27
+ "toolstile": "dist/cli.js"
28
+ },
29
+ "exports": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "scripts": {
38
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "vitest run",
41
+ "prepublishOnly": "pnpm typecheck && pnpm build && pnpm test"
42
+ },
43
+ "dependencies": {
44
+ "smol-toml": "^1.9.1"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.19.0",
48
+ "typescript": "^5.9.3",
49
+ "vitest": "^4.0.0"
50
+ },
51
+ "packageManager": "pnpm@11.20.0",
52
+ "optionalDependencies": {
53
+ "@resvg/resvg-js": "^2.6.2"
54
+ }
55
+ }