toolstile 0.2.0 → 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/fix.js ADDED
@@ -0,0 +1,622 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { applyEdits, modify } from "jsonc-parser";
4
+ import { stringify as stringifyToml } from "smol-toml";
5
+ import { withoutTomlTable, writeText } from "./agents.js";
6
+ import { isPinned, packageOf, secretsOf, shellPath, words } from "./checks.js";
7
+ import { audit } from "./index.js";
8
+ import { parseConfig } from "./parse.js";
9
+ import { checklist } from "./prompt.js";
10
+ import { tildify, wrap } from "./report.js";
11
+ import { CLIENT_NAMES, discover, pointerJoin } from "./sources.js";
12
+ import { stateDir } from "./state.js";
13
+ const label = (s) => `${s.name} (${CLIENT_NAMES[s.client]})`;
14
+ const MASK = "••••";
15
+ /** How each client writes a reference to an environment variable, where it supports one. */
16
+ const REFERENCE = {
17
+ "claude-code": (name) => `\${${name}}`,
18
+ gemini: (name) => `\${${name}}`,
19
+ cursor: (name) => `\${env:${name}}`,
20
+ windsurf: (name) => `\${env:${name}}`,
21
+ vscode: (name) => `\${env:${name}}`,
22
+ };
23
+ const envName = (text) => {
24
+ const name = text.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
25
+ return /^[0-9]/.test(name) ? `_${name}` : name || "SECRET";
26
+ };
27
+ /** The file `--fix` keeps moved secrets in, next to the CLI's credentials. */
28
+ export const secretsPath = (opts) => join(stateDir(opts), "secrets.env");
29
+ const quote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
30
+ /** Reads `export NAME='value'` lines written by an earlier `--fix`. */
31
+ function readSecrets(path) {
32
+ const out = new Map();
33
+ let text = "";
34
+ try {
35
+ text = readFileSync(path, "utf8");
36
+ }
37
+ catch {
38
+ return out;
39
+ }
40
+ for (const line of text.split("\n")) {
41
+ const m = /^export ([A-Za-z_][A-Za-z0-9_]*)='(.*)'$/.exec(line);
42
+ if (m)
43
+ out.set(m[1], m[2].replace(/'\\''/g, "'"));
44
+ }
45
+ return out;
46
+ }
47
+ /** The startup file of the user's shell, and the line that loads the secrets file from it. */
48
+ export function profileFor(opts, secretsFile) {
49
+ if (opts.platform === "win32")
50
+ return null;
51
+ const shell = basename(opts.env.SHELL ?? "");
52
+ const file = shellPath(secretsFile, opts.home);
53
+ if (shell === "fish") {
54
+ const config = opts.env.XDG_CONFIG_HOME ?? join(opts.home, ".config");
55
+ return { path: join(config, "fish", "conf.d", "toolstile.fish"), line: `test -f ${file}; and source ${file}` };
56
+ }
57
+ const path = shell === "zsh"
58
+ ? join(opts.env.ZDOTDIR ?? opts.home, ".zshrc")
59
+ : shell === "bash"
60
+ ? join(opts.home, opts.platform === "darwin" ? ".bash_profile" : ".bashrc")
61
+ : join(opts.home, ".profile");
62
+ return { path, line: `[ -f ${file} ] && . ${file} # MCP server secrets, added by toolstile audit --fix` };
63
+ }
64
+ /** Picks a variable name per secret: one name per value, never one already holding something else. */
65
+ class Names {
66
+ env;
67
+ taken = new Map();
68
+ byValue = new Map();
69
+ constructor(stored, env) {
70
+ this.env = env;
71
+ for (const [name, value] of stored)
72
+ this.claim(name, value);
73
+ }
74
+ claim(name, value) {
75
+ this.taken.set(name, value);
76
+ if (!this.byValue.has(value))
77
+ this.byValue.set(value, name);
78
+ }
79
+ /** Whether `name` is free for `value`: unused, or already holding that same value. */
80
+ fits(name, value) {
81
+ const held = this.taken.get(name) ?? this.env[name];
82
+ return held === undefined || held === value;
83
+ }
84
+ pick(bases, value, exact = false) {
85
+ const known = this.byValue.get(value);
86
+ if (known && !exact)
87
+ return known;
88
+ const candidates = exact
89
+ ? bases.filter((name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
90
+ : [...bases, ...[2, 3, 4, 5, 6, 7, 8, 9].map((n) => `${bases[0]}_${n}`)].map(envName);
91
+ for (const name of candidates) {
92
+ if (this.fits(name, value)) {
93
+ this.claim(name, value);
94
+ return name;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ }
100
+ /** Plans moving one secret into a variable, or returns why it has to be moved by hand. */
101
+ function moveSecret(sec, names) {
102
+ const s = sec.server;
103
+ const [where, key] = sec.keys;
104
+ const at = (...keys) => [...s.path, ...keys];
105
+ const pointer = sec.location.pointer;
106
+ const server = envName(s.name);
107
+ const scheme = /^(Bearer|token)\s+(\S+)$/i.exec(sec.value.trim());
108
+ if (/[\r\n]/.test(sec.value))
109
+ return "it spans several lines, so it cannot go in the secrets file";
110
+ if (s.client === "codex") {
111
+ if (where === "env" && key) {
112
+ // Codex passes only variables named in env_vars, under their own name, so the key is kept as written.
113
+ const name = names.pick([key], sec.value, true);
114
+ if (!name)
115
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
116
+ ? `${key} is already set to something else in your environment`
117
+ : `${key} is not a name a shell variable can have`;
118
+ return {
119
+ ops: [
120
+ { type: "remove", file: s.file, path: at("env", key), was: sec.value },
121
+ { type: "append", file: s.file, path: at("env_vars"), value: name },
122
+ ],
123
+ change: { where: pointer, from: `"${MASK}"`, to: `removed; env_vars = ["${name}"] passes $${name} through` },
124
+ secret: { name, value: sec.value },
125
+ };
126
+ }
127
+ const bearer = (where === "http_headers" && /^authorization$/i.test(key ?? "") && scheme?.[1]?.toLowerCase() === "bearer") || where === "bearer_token";
128
+ if (bearer) {
129
+ const raw = where === "bearer_token" ? sec.value.trim() : scheme[2];
130
+ const name = names.pick([`${server}_TOKEN`], raw);
131
+ if (!name)
132
+ return "no free variable name";
133
+ return {
134
+ ops: [
135
+ { type: "remove", file: s.file, path: at(...sec.keys), was: sec.value },
136
+ { type: "set", file: s.file, path: at("bearer_token_env_var"), value: name },
137
+ ],
138
+ change: { where: pointer, from: `"${scheme ? "Bearer " : ""}${MASK}"`, to: `removed; bearer_token_env_var = "${name}"` },
139
+ secret: { name, value: raw },
140
+ };
141
+ }
142
+ if (where === "http_headers" && key) {
143
+ const name = names.pick([`${server}_${key}`], sec.value);
144
+ if (!name)
145
+ return "no free variable name";
146
+ return {
147
+ ops: [
148
+ { type: "remove", file: s.file, path: at("http_headers", key), was: sec.value },
149
+ { type: "set", file: s.file, path: at("env_http_headers", key), value: name },
150
+ ],
151
+ change: { where: pointer, from: `"${MASK}"`, to: `removed; env_http_headers.${key} = "${name}"` },
152
+ secret: { name, value: sec.value },
153
+ };
154
+ }
155
+ return "only env and header values can be moved for you";
156
+ }
157
+ const ref = REFERENCE[s.client];
158
+ if (!ref)
159
+ return `${CLIENT_NAMES[s.client]} does not read environment variables in its config`;
160
+ if (where === "env" && key) {
161
+ const name = names.pick([key, `${server}_${key}`], sec.value);
162
+ if (!name)
163
+ return "no free variable name";
164
+ return {
165
+ ops: [{ type: "set", file: s.file, path: at("env", key), value: ref(name), was: sec.value }],
166
+ change: { where: pointer, from: `"${MASK}"`, to: `"${ref(name)}"` },
167
+ secret: { name, value: sec.value },
168
+ };
169
+ }
170
+ if (where === "headers" && key) {
171
+ // `Bearer <token>` keeps its scheme in the config and only the token moves.
172
+ const [prefix, value, base] = scheme ? [`${scheme[1]} `, scheme[2], `${server}_TOKEN`] : ["", sec.value, `${server}_${key}`];
173
+ const name = names.pick([base], value);
174
+ if (!name)
175
+ return "no free variable name";
176
+ return {
177
+ ops: [{ type: "set", file: s.file, path: at("headers", key), value: `${prefix}${ref(name)}`, was: sec.value }],
178
+ change: { where: pointer, from: `"${prefix}${MASK}"`, to: `"${prefix}${ref(name)}"` },
179
+ secret: { name, value },
180
+ };
181
+ }
182
+ return "only env and header values can be moved for you";
183
+ }
184
+ const registryUrl = (registry, name, tag) => registry === "npm"
185
+ ? `https://registry.npmjs.org/${name.replace("/", "%2F")}/${encodeURIComponent(tag)}`
186
+ : `https://pypi.org/pypi/${encodeURIComponent(name)}/json`;
187
+ async function currentVersion(fetcher, registry, name, tag) {
188
+ try {
189
+ const res = await fetcher(registryUrl(registry, name, tag), { signal: AbortSignal.timeout(8000) });
190
+ if (!res.ok)
191
+ return null;
192
+ const body = (await res.json());
193
+ const version = registry === "npm" ? body.version : body.info?.version;
194
+ return typeof version === "string" && /^[\w.+-]+$/.test(version) ? version : null;
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ /** Plans an exact-version pin, or returns why it has to be done by hand. */
201
+ async function pinPackage(s, fetcher) {
202
+ const pkg = packageOf(s);
203
+ if (!pkg || isPinned(pkg))
204
+ return null;
205
+ const w = words(s).list;
206
+ const launcher = basename(w[0] ?? "").replace(/\.(cmd|exe)$/i, "").toLowerCase();
207
+ if (pkg.registry === "docker")
208
+ return "pin an image digest or an exact tag by hand";
209
+ if (pkg.argIndex < 0 || s.args[pkg.argIndex] !== w[pkg.wordIndex])
210
+ return "the package is inside a single command string";
211
+ if (pkg.registry === "pypi" && (launcher === "pipx" || /[[\]]/.test(pkg.name)))
212
+ return "pin it by hand";
213
+ const tag = pkg.version ?? "latest";
214
+ if (pkg.registry === "pypi" ? pkg.version !== null : /[\^~*xX><| ]/.test(tag))
215
+ return `${pkg.name}@${tag} is a range; pick the version by hand`;
216
+ const version = await currentVersion(fetcher, pkg.registry, pkg.name, tag);
217
+ if (!version)
218
+ return `could not look up the current version of ${pkg.name}`;
219
+ const fromFlag = w[pkg.wordIndex - 1] === "--from";
220
+ const spec = pkg.registry === "pypi" && fromFlag ? `${pkg.name}==${version}` : `${pkg.name}@${version}`;
221
+ const before = s.args[pkg.argIndex];
222
+ const pointer = pointerJoin(pointerJoin(s.pointer, "args"), String(pkg.argIndex));
223
+ return {
224
+ looked: true,
225
+ fix: {
226
+ rule: "unpinned-package",
227
+ severity: "medium",
228
+ label: `${label(s)}: pin ${pkg.name} to ${version}`,
229
+ file: s.file,
230
+ changes: [{ where: pointer, from: `"${before}"`, to: `"${spec}"` }],
231
+ checked: true,
232
+ ops: [{ type: "set", file: s.file, path: [...s.path, "args", pkg.argIndex], value: spec, was: before }],
233
+ secrets: [],
234
+ },
235
+ };
236
+ }
237
+ /**
238
+ * Works out every change `audit --fix` can make, without making any. Looks up the current
239
+ * version of unpinned npm and PyPI packages; nothing else leaves the machine.
240
+ */
241
+ export async function planFixes(opts) {
242
+ const d = discover(opts);
243
+ const fixes = [];
244
+ const manual = [];
245
+ const secretsFile = secretsPath(opts);
246
+ const profile = profileFor(opts, secretsFile);
247
+ const saved = readSecrets(secretsFile);
248
+ const fish = profile?.path.endsWith(".fish") ?? false;
249
+ const names = new Names(saved, opts.env);
250
+ for (const s of d.servers) {
251
+ const secrets = secretsOf(s);
252
+ if (!secrets.length)
253
+ continue;
254
+ if (!profile) {
255
+ manual.push({ severity: "high", subject: label(s), reason: "move its secrets into environment variables by hand" });
256
+ continue;
257
+ }
258
+ const moves = [];
259
+ for (const sec of secrets) {
260
+ let move = moveSecret(sec, names);
261
+ // fish reads backslashes inside single quotes as escapes.
262
+ if (typeof move !== "string" && fish && move.secret.value.includes("\\"))
263
+ move = "it contains a backslash, which fish would read differently";
264
+ if (typeof move === "string")
265
+ manual.push({ severity: "high", subject: `${label(s)} ${sec.location.pointer}`, reason: move });
266
+ else
267
+ moves.push(move);
268
+ }
269
+ if (!moves.length)
270
+ continue;
271
+ const vars = [...new Set(moves.map((m) => m.secret.name))];
272
+ fixes.push({
273
+ rule: "plaintext-secret",
274
+ severity: "high",
275
+ label: `${label(s)}: move ${moves.length === 1 ? "secret" : `${moves.length} secrets`} to ${vars.map((v) => `$${v}`).join(", ")}`,
276
+ file: s.file,
277
+ changes: moves.map((m) => m.change),
278
+ checked: true,
279
+ ops: moves.flatMap((m) => m.ops),
280
+ // A value already set under that name, by the secrets file or the user's own shell setup, is
281
+ // not saved again: a second copy would outlive the user rotating their own.
282
+ secrets: moves.map((m) => m.secret).filter((x) => saved.get(x.name) !== x.value && opts.env[x.name] !== x.value),
283
+ });
284
+ }
285
+ const secretFiles = new Set(d.servers.filter((s) => secretsOf(s).length).map((s) => s.file));
286
+ for (const f of d.files) {
287
+ if (f.mode === undefined || !f.exposure || !secretFiles.has(f.path))
288
+ continue;
289
+ fixes.push({
290
+ rule: "readable-config",
291
+ severity: "medium",
292
+ label: `${tildify(f.path, opts.home)}: readable only by you`,
293
+ file: f.path,
294
+ changes: [{ where: "(file permissions)", from: f.mode.toString(8).padStart(3, "0"), to: "600" }],
295
+ checked: true,
296
+ ops: [{ type: "chmod", file: f.path, mode: 0o600 }],
297
+ secrets: [],
298
+ });
299
+ }
300
+ const fetcher = opts.fetch ?? fetch;
301
+ const pins = await Promise.all(d.servers.map((s) => pinPackage(s, fetcher)));
302
+ let lookedUp = 0;
303
+ pins.forEach((pin, i) => {
304
+ if (pin === null)
305
+ return;
306
+ if (typeof pin === "string")
307
+ manual.push({ severity: "medium", subject: `${label(d.servers[i])} unpinned package`, reason: pin });
308
+ else {
309
+ lookedUp++;
310
+ fixes.push(pin.fix);
311
+ }
312
+ });
313
+ for (const a of d.approvals) {
314
+ const enableAll = a.location.pointer === "enableAllProjectMcpServers";
315
+ fixes.push({
316
+ rule: "auto-approved-tools",
317
+ severity: "medium",
318
+ label: enableAll
319
+ ? `${CLIENT_NAMES[a.client]}: ask before using a project's MCP servers`
320
+ : `${CLIENT_NAMES[a.client]}: ask before ${a.subject} tools run`,
321
+ file: a.location.file,
322
+ changes: [
323
+ enableAll
324
+ ? { where: "enableAllProjectMcpServers", from: "true", to: "removed" }
325
+ : { where: a.location.pointer, from: `"${a.subject}"`, to: "removed" },
326
+ ],
327
+ checked: false,
328
+ ops: [
329
+ enableAll
330
+ ? { type: "remove", file: a.location.file, path: ["enableAllProjectMcpServers"], was: true }
331
+ : { type: "remove-item", file: a.location.file, path: ["permissions", "allow"], value: a.subject },
332
+ ],
333
+ secrets: [],
334
+ });
335
+ }
336
+ for (const s of d.servers) {
337
+ if (s.url) {
338
+ try {
339
+ const u = new URL(s.url);
340
+ if (u.protocol === "http:" && !/^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)$/i.test(u.hostname))
341
+ manual.push({ severity: "high", subject: `${label(s)} uses http://`, reason: "switch to https:// if the server offers it" });
342
+ }
343
+ catch { }
344
+ }
345
+ if (s.autoApproved.length)
346
+ manual.push({ severity: "low", subject: `${label(s)} auto-approves ${s.autoApproved.length} tools`, reason: "keep only read-only tools on its list" });
347
+ }
348
+ const order = { high: 0, medium: 1, low: 2, info: 3 };
349
+ fixes.sort((a, b) => order[a.severity] - order[b.severity]);
350
+ manual.sort((a, b) => order[a.severity] - order[b.severity]);
351
+ return { fixes, manual, secretsFile, profile, lookedUp };
352
+ }
353
+ /** Keys sorted, so two parses of the same data compare equal whatever order the file uses. */
354
+ const canonical = (value) => JSON.stringify(value, (_key, v) => v && typeof v === "object" && !Array.isArray(v)
355
+ ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
356
+ : v);
357
+ /** Follows `path` in `data`, creating objects on the way when `create` is set. */
358
+ function parentOf(data, path, create) {
359
+ let node = data;
360
+ for (const key of path.slice(0, -1)) {
361
+ if (node[key] === undefined && create)
362
+ node[key] = {};
363
+ if (typeof node[key] !== "object" || node[key] === null)
364
+ return null;
365
+ node = node[key];
366
+ }
367
+ return node;
368
+ }
369
+ /** Applies an edit to parsed data; this is what the file must parse to afterwards. */
370
+ function applyToData(data, op) {
371
+ const last = op.path[op.path.length - 1];
372
+ const parent = parentOf(data, op.path, op.type === "set" || op.type === "append");
373
+ if (!parent)
374
+ return;
375
+ if (op.type === "set")
376
+ parent[last] = op.value;
377
+ else if (op.type === "remove")
378
+ delete parent[last];
379
+ else if (op.type === "append") {
380
+ const list = Array.isArray(parent[last]) ? parent[last] : [];
381
+ if (!list.includes(op.value))
382
+ parent[last] = [...list, op.value];
383
+ }
384
+ else if (Array.isArray(parent[last])) {
385
+ parent[last] = parent[last].filter((x) => x !== op.value);
386
+ }
387
+ }
388
+ /**
389
+ * The file's new text, with comments and layout kept for JSON. A Codex server's table is
390
+ * rewritten from its edited data at the end of the file. Throws when the result would not
391
+ * parse to exactly the intended data.
392
+ */
393
+ function editedText(file, text, ops) {
394
+ const bom = text.charCodeAt(0) === 0xfeff ? "\ufeff" : "";
395
+ let body = bom ? text.slice(1) : text;
396
+ const expected = parseConfig(file, body);
397
+ const valueAt = (path) => path.reduce((node, key) => node?.[key], expected);
398
+ for (const op of ops)
399
+ if ((op.type === "set" || op.type === "remove") && "was" in op && op.was !== undefined && valueAt(op.path) !== op.was)
400
+ throw new Error("the file changed after the changes were listed; run --fix again");
401
+ for (const op of ops)
402
+ applyToData(expected, op);
403
+ if (file.endsWith(".toml")) {
404
+ const servers = expected.mcp_servers ?? {};
405
+ for (const name of new Set(ops.map((op) => String(op.path[1])))) {
406
+ // Tables emptied by the edit (an `env` whose only secret moved) are dropped.
407
+ const server = servers[name];
408
+ for (const [key, value] of Object.entries(server))
409
+ if (value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date) && !Object.keys(value).length)
410
+ delete server[key];
411
+ body = withoutTomlTable(body, name, stringifyToml({ mcp_servers: { [name]: server } }));
412
+ }
413
+ }
414
+ else {
415
+ const indent = body.match(/\n([ \t]+)"/)?.[1] ?? " ";
416
+ const formattingOptions = { insertSpaces: !indent.includes("\t"), tabSize: indent.includes("\t") ? 1 : indent.length };
417
+ for (const op of ops) {
418
+ let path = op.path;
419
+ let value = op.type === "set" ? op.value : undefined;
420
+ if (op.type === "remove-item" || op.type === "append") {
421
+ const list = (path.reduce((node, key) => node?.[key], parseConfig(file, body)) ?? []);
422
+ if (op.type === "append")
423
+ value = list.includes(op.value) ? list : [...list, op.value];
424
+ else {
425
+ const index = list.indexOf(op.value);
426
+ if (index === -1)
427
+ continue;
428
+ path = [...path, index];
429
+ }
430
+ }
431
+ body = applyEdits(body, modify(body, path, value, { formattingOptions }));
432
+ }
433
+ }
434
+ let actual;
435
+ try {
436
+ actual = parseConfig(file, body);
437
+ }
438
+ catch {
439
+ actual = undefined;
440
+ }
441
+ if (canonical(actual) !== canonical(expected))
442
+ throw new Error("the edited file did not match the planned change");
443
+ return bom + body;
444
+ }
445
+ /**
446
+ * Makes the chosen changes. Every edited file is checked in memory first; secrets are saved
447
+ * and the original files backed up before any config is rewritten.
448
+ */
449
+ export function applyFixes(plan, chosen, opts) {
450
+ const failed = [];
451
+ const byFile = new Map();
452
+ for (const fix of chosen)
453
+ byFile.set(fix.file, [...(byFile.get(fix.file) ?? []), fix]);
454
+ const texts = new Map();
455
+ for (const [file, fixes] of byFile) {
456
+ const ops = fixes.flatMap((f) => f.ops).filter((op) => op.type !== "chmod");
457
+ if (!ops.length)
458
+ continue;
459
+ try {
460
+ const before = readFileSync(file, "utf8");
461
+ const after = editedText(file, before, ops);
462
+ if (after !== before)
463
+ texts.set(file, { before, after });
464
+ }
465
+ catch (error) {
466
+ const reason = error.code ? "the file could not be read" : error.message;
467
+ for (const fix of fixes)
468
+ if (fix.ops.some((op) => op.type !== "chmod"))
469
+ failed.push({ fix, reason });
470
+ }
471
+ }
472
+ const ok = chosen.filter((fix) => !failed.some((f) => f.fix === fix));
473
+ // Secrets first: a config never points at a variable that was not saved.
474
+ const secrets = ok.flatMap((f) => f.secrets);
475
+ let profileAdded = false;
476
+ if (secrets.length) {
477
+ const stored = readSecrets(plan.secretsFile);
478
+ let text = "";
479
+ try {
480
+ text = readFileSync(plan.secretsFile, "utf8");
481
+ }
482
+ catch {
483
+ text = "# MCP server secrets moved out of agent configs by toolstile audit --fix.\n# Only your user can read this file. Loaded by your shell; see the line in your shell profile.\n";
484
+ }
485
+ for (const { name, value } of secrets) {
486
+ if (stored.get(name) === value)
487
+ continue;
488
+ stored.set(name, value);
489
+ text = `${text.replace(/\n?$/, "\n")}export ${name}=${quote(value)}\n`;
490
+ }
491
+ mkdirSync(dirname(plan.secretsFile), { recursive: true, mode: 0o700 });
492
+ writeText(plan.secretsFile, text);
493
+ chmodSync(plan.secretsFile, 0o600);
494
+ if (plan.profile) {
495
+ let profile = "";
496
+ try {
497
+ profile = readFileSync(plan.profile.path, "utf8");
498
+ }
499
+ catch { }
500
+ if (!profile.includes(shellPath(plan.secretsFile, opts.home)) && !profile.includes(plan.secretsFile)) {
501
+ const mode = existsSync(plan.profile.path) ? statSync(plan.profile.path).mode & 0o777 : 0o644;
502
+ writeText(plan.profile.path, `${profile}${profile && !profile.endsWith("\n") ? "\n" : ""}\n${plan.profile.line}\n`);
503
+ chmodSync(plan.profile.path, mode);
504
+ profileAdded = true;
505
+ }
506
+ }
507
+ }
508
+ let backups = null;
509
+ if (texts.size) {
510
+ const stamp = (opts.now ?? new Date()).toISOString().replace(/[:.]/g, "-");
511
+ backups = join(dirname(plan.secretsFile), "backups", stamp);
512
+ mkdirSync(backups, { recursive: true, mode: 0o700 });
513
+ const used = new Set();
514
+ for (const file of texts.keys()) {
515
+ const flat = tildify(file, opts.home).replace(/^~[\\/]/, "").replace(/[\\/:]/g, "_");
516
+ let name = flat;
517
+ for (let n = 2; used.has(name); n++)
518
+ name = `${flat}.${n}`;
519
+ used.add(name);
520
+ const copy = join(backups, name);
521
+ copyFileSync(file, copy);
522
+ chmodSync(copy, 0o600);
523
+ }
524
+ for (const [file, { after }] of texts)
525
+ writeText(file, after);
526
+ }
527
+ for (const fix of ok)
528
+ for (const op of fix.ops)
529
+ if (op.type === "chmod")
530
+ chmodSync(op.file, op.mode);
531
+ return { applied: ok, failed, backups, profileAdded };
532
+ }
533
+ /** `toolstile audit --fix`: shows every change it can make, lets the user pick, then makes them. */
534
+ export async function runFix(o) {
535
+ const s = o.style;
536
+ const width = Math.max(40, Math.min(o.stdout.columns || 80, 100) - 1);
537
+ const out = (text = "") => o.stdout.write(`${text}\n`);
538
+ const para = (text, indent) => indent + wrap(text, width - indent.length, indent);
539
+ out();
540
+ out(`${s.bold("toolstile audit --fix")} ${s.dim("· nothing changes until you confirm")}`);
541
+ out();
542
+ const before = audit(o);
543
+ const plan = await planFixes(o);
544
+ if (plan.lookedUp) {
545
+ out(s.dim(`Looked up the current version of ${plan.lookedUp} package${plan.lookedUp === 1 ? "" : "s"} on npm or PyPI.`));
546
+ out();
547
+ }
548
+ if (!plan.fixes.length && !plan.manual.length) {
549
+ out(before.grade === null ? "No MCP servers found." : "Nothing to fix.");
550
+ out();
551
+ return 0;
552
+ }
553
+ if (plan.fixes.length) {
554
+ out(s.bold(`Changes it can make (${plan.fixes.length})`));
555
+ out();
556
+ const pad = " ".repeat(13);
557
+ plan.fixes.forEach((fix, i) => {
558
+ const n = `${i + 1}.`.padEnd(4);
559
+ const off = fix.checked ? "" : s.dim(" (off at first: changes how the agent works)");
560
+ out(` ${n}${s.sev(fix.severity, fix.severity.toUpperCase().padEnd(7))}${fix.label}${off}`);
561
+ for (const c of fix.changes) {
562
+ const where = `${tildify(fix.file, o.home)} ${c.where}`;
563
+ out(s.dim(where.length <= width - 13 ? `${pad}${where}` : para(where, pad)));
564
+ out(`${pad}${s.sev("high", "-")} ${c.from}`);
565
+ out(`${pad}${s.grade("A", "+")} ${c.to}`);
566
+ }
567
+ });
568
+ out();
569
+ if (plan.fixes.some((f) => f.secrets.length || f.rule === "plaintext-secret") && plan.profile) {
570
+ out(para(`Moved secrets are saved in ${tildify(plan.secretsFile, o.home)}, which only you can read, and loaded by one line in ${tildify(plan.profile.path, o.home)}. Original files are backed up first.`, " "));
571
+ out();
572
+ }
573
+ }
574
+ if (plan.manual.length) {
575
+ out(s.bold(`Needs a change by hand (${plan.manual.length})`));
576
+ for (const m of plan.manual)
577
+ out(para(`${s.sev(m.severity, "•")} ${m.subject}: ${s.dim(m.reason)}`, " ").replace(/^ {4}/, " "));
578
+ out(s.dim(para("Run toolstile audit for the reasons and fixes.", " ")));
579
+ out();
580
+ }
581
+ if (!plan.fixes.length || o.dryRun)
582
+ return 0;
583
+ let chosen;
584
+ if (o.yes)
585
+ chosen = plan.fixes.filter((f) => f.checked);
586
+ else if (o.stdin.isTTY && o.stdout.isTTY) {
587
+ const picked = await checklist("Apply which changes?", plan.fixes.map((f, i) => ({ label: `${i + 1}. ${f.label}`, checked: f.checked })), o.stdin, o.stdout);
588
+ if (!picked) {
589
+ out("Cancelled; nothing changed.");
590
+ return 0;
591
+ }
592
+ chosen = plan.fixes.filter((_, i) => picked[i]);
593
+ }
594
+ else {
595
+ out("Nothing changed. Run this in a terminal to choose changes, or add --yes to apply the ones that start selected.");
596
+ return 0;
597
+ }
598
+ if (!chosen.length) {
599
+ out("Nothing selected; nothing changed.");
600
+ return 0;
601
+ }
602
+ const result = applyFixes(plan, chosen, o);
603
+ out();
604
+ for (const fix of result.applied)
605
+ out(` ${s.bold("✓")} ${fix.label}`);
606
+ for (const { fix, reason } of result.failed)
607
+ out(` ${s.sev("high", "✗")} ${fix.label}: ${reason}; left unchanged`);
608
+ out();
609
+ if (result.backups)
610
+ out(s.dim(para(`Original files: ${tildify(result.backups, o.home)} (they still hold any moved secrets; delete them once your agents work).`, " ")));
611
+ if (result.applied.some((f) => f.rule === "plaintext-secret")) {
612
+ const load = `. ${shellPath(plan.secretsFile, o.home)}`;
613
+ out(para(`Next: open a new terminal (or run ${s.bold(load)}) and restart your agents, so they start with the new variables.${result.profileAdded ? ` Added the line that loads them to ${tildify(plan.profile.path, o.home)}.` : ""} Rotate any secret that was in a shared or synced file.`, " "));
614
+ }
615
+ const after = audit(o);
616
+ if (before.grade !== null && after.grade !== null) {
617
+ out();
618
+ out(` ${s.grade(before.grade, `Grade ${before.grade}`)} ${s.dim(`${before.score}`)} → ${s.bold(s.grade(after.grade, `Grade ${after.grade}`))} ${s.dim(`${after.score}/100`)}`);
619
+ }
620
+ out();
621
+ return result.failed.length ? 1 : 0;
622
+ }
package/dist/prompt.d.ts CHANGED
@@ -4,7 +4,11 @@ export interface Choice {
4
4
  hint?: string;
5
5
  checked: boolean;
6
6
  }
7
- /** Terminal checklist: arrows (or j/k) move, space toggles, a toggles all, enter saves. */
7
+ /**
8
+ * Terminal checklist: arrows (or j/k) move, space toggles, a toggles all, enter saves.
9
+ * Every line is fitted to the terminal width, and a list taller than the terminal scrolls: a
10
+ * wrapped or scrolled-off line breaks the redraw, which would leave copies of the title behind.
11
+ */
8
12
  export declare function checklist(title: string, choices: Choice[], input: ReadStream, output: WriteStream, options?: {
9
13
  single?: boolean;
10
14
  }): Promise<boolean[] | null>;