@wrongstack/tools 0.305.1 → 0.306.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.
Files changed (63) hide show
  1. package/dist/_shell-pick.d.ts +4 -5
  2. package/dist/_util.d.ts +22 -5
  3. package/dist/audit.d.ts +0 -1
  4. package/dist/audit.js +135 -46
  5. package/dist/bash.js +61 -37
  6. package/dist/browser/index.js +29 -9
  7. package/dist/browser/types.d.ts +7 -1
  8. package/dist/builtin.js +1294 -726
  9. package/dist/codebase-index/codebase-search-tool.d.ts +5 -0
  10. package/dist/codebase-index/index.js +223 -152
  11. package/dist/codebase-index/project-server.js +10 -11
  12. package/dist/diff.d.ts +5 -0
  13. package/dist/diff.js +78 -12
  14. package/dist/document.js +18 -6
  15. package/dist/edit.js +69 -16
  16. package/dist/exec.js +44 -22
  17. package/dist/fetch.js +13 -1
  18. package/dist/format.d.ts +4 -2
  19. package/dist/format.js +81 -31
  20. package/dist/glob.js +12 -4
  21. package/dist/grep.d.ts +2 -0
  22. package/dist/grep.js +15 -4
  23. package/dist/index.js +1359 -762
  24. package/dist/install.js +96 -37
  25. package/dist/kanban-tool-types.d.ts +6 -1
  26. package/dist/kanban.js +60 -0
  27. package/dist/languages/index.js +28 -13
  28. package/dist/lint.js +28 -13
  29. package/dist/logs.d.ts +0 -1
  30. package/dist/logs.js +44 -13
  31. package/dist/memory.d.ts +8 -0
  32. package/dist/memory.js +23 -3
  33. package/dist/mode.d.ts +1 -1
  34. package/dist/mode.js +3 -0
  35. package/dist/next-steps.d.ts +2 -3
  36. package/dist/next-steps.js +3 -3
  37. package/dist/outdated.d.ts +0 -3
  38. package/dist/outdated.js +89 -48
  39. package/dist/pack.js +1294 -726
  40. package/dist/plan.js +91 -3
  41. package/dist/process-registry.d.ts +8 -2
  42. package/dist/process-registry.js +28 -13
  43. package/dist/ps-slash.js +22 -12
  44. package/dist/read.js +10 -3
  45. package/dist/replace.d.ts +4 -0
  46. package/dist/replace.js +104 -7
  47. package/dist/search.d.ts +6 -0
  48. package/dist/search.js +47 -26
  49. package/dist/session-kanban.js +3 -1
  50. package/dist/skill.d.ts +6 -0
  51. package/dist/skill.js +9 -10
  52. package/dist/task.js +81 -2
  53. package/dist/test.js +28 -13
  54. package/dist/todo.js +79 -2
  55. package/dist/tool-icons.js +4 -2
  56. package/dist/tool-summary.d.ts +1 -1
  57. package/dist/tool-summary.js +76 -1
  58. package/dist/tool-tier.js +1294 -726
  59. package/dist/tree.js +9 -10
  60. package/dist/typecheck.d.ts +0 -2
  61. package/dist/typecheck.js +98 -31
  62. package/dist/write.js +58 -10
  63. package/package.json +4 -4
package/dist/diff.js CHANGED
@@ -4,8 +4,10 @@ import { statSync } from "node:fs";
4
4
  import * as fs from "node:fs/promises";
5
5
  import * as path2 from "node:path";
6
6
  import { buildChildEnv } from "@wrongstack/core/utils";
7
+ import { ToolValidationError } from "@wrongstack/core/types";
7
8
 
8
9
  // src/_util.ts
10
+ import * as fsp from "node:fs/promises";
9
11
  import * as path from "node:path";
10
12
  import * as Core from "@wrongstack/core/utils";
11
13
  function resolvePath(input, ctx) {
@@ -29,14 +31,48 @@ function ensureInsideRoot(absPath, ctx) {
29
31
  function safeResolve(input, ctx) {
30
32
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
31
33
  }
34
+ async function resolveRealInsideRoot(absPath, ctx) {
35
+ if (ctx.allowOutsideProjectRoot) return absPath;
36
+ const realRoots = await Promise.all(
37
+ allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r)))
38
+ );
39
+ let probe = absPath;
40
+ const pendingTail = [];
41
+ for (; ; ) {
42
+ let real;
43
+ try {
44
+ real = await fsp.realpath(probe);
45
+ } catch (err) {
46
+ if (err.code === "ENOENT") {
47
+ const parent = path.dirname(probe);
48
+ if (parent === probe) return absPath;
49
+ pendingTail.unshift(path.basename(probe));
50
+ probe = parent;
51
+ continue;
52
+ }
53
+ throw err;
54
+ }
55
+ if (isInsideAny(real, realRoots)) {
56
+ return pendingTail.length > 0 ? path.join(real, ...pendingTail) : real;
57
+ }
58
+ throw new Error(
59
+ `Path "${absPath}" resolves through a symlink outside project root "${realRoots[0]}"`
60
+ );
61
+ }
62
+ }
63
+ async function safeResolveReal(input, ctx) {
64
+ const abs = safeResolve(input, ctx);
65
+ return await resolveRealInsideRoot(abs, ctx);
66
+ }
32
67
 
33
68
  // src/diff.ts
34
69
  var MAX_FILE_DUMP_BYTES = 5 * 1024 * 1024;
70
+ var MAX_GIT_DIFF_CHARS = 1e5;
35
71
  var diffTool = {
36
72
  name: "diff",
37
73
  category: "Filesystem",
38
74
  description: "Show file content with line numbers, staged/working-tree diffs via git, or commit/branch diffs. A safer and more structured alternative to raw `git diff` via shell.",
39
- usageHint: 'USE FOR CODE REVIEW AND CHANGE INSPECTION:\n\n- `files` + no `a`/`b` \u2192 show file content with line numbers (NOT a unified diff; no +/- prefixes).\n- `a` and/or `b` \u2192 git-style commit/branch diff (unified format, real +/- prefixes).\n- `staged: true` \u2192 only show staged changes.\n- `mode` can be "unified", "stat", or "side-by-side" (only affects the git-diff path).\n\nNOTE: For a true file-vs-file unified diff, supply `a` and `b` so the tool delegates to `git diff`. The `files`-only path is a line-numbered dump, not a diff.\n\nThis tool has important safety guards against flag injection (see previous security findings).',
75
+ usageHint: 'USE FOR CODE REVIEW AND CHANGE INSPECTION:\n\n- `files` + no `a`/`b` \u2192 show file content with line numbers (NOT a unified diff; no +/- prefixes). Result `mode` is "dump".\n- `a` and/or `b` \u2192 git-style commit/branch diff (unified format, real +/- prefixes).\n- `staged: true` \u2192 only show staged changes.\n- `mode` only affects the git-diff path: "stat" runs `git diff --stat`; "side-by-side" is not supported and falls back to unified (result `mode` reports what was produced).\n- `context` sets the unified-diff context line count on the git path (`-U<n>`); the dump path has no context notion.\n\nNOTE: For a true file-vs-file unified diff, supply `a` and `b` so the tool delegates to `git diff`. The `files`-only path is a line-numbered dump, not a diff.\n\nThis tool has important safety guards against flag injection (see previous security findings).',
40
76
  permission: "auto",
41
77
  mutating: false,
42
78
  maxOutputBytes: 262144,
@@ -69,11 +105,12 @@ var diffTool = {
69
105
  mode: {
70
106
  type: "string",
71
107
  enum: ["unified", "side-by-side", "stat"],
72
- description: 'Output format. "unified" is default, "stat" shows summary only.'
108
+ description: 'Output format for the git-diff path. "unified" is default; "stat" shows a summary only; "side-by-side" is not supported and falls back to unified. The `files`-only dump path ignores this.'
73
109
  },
74
110
  context: {
75
111
  type: "integer",
76
- description: "Number of context lines for unified diffs (default: 3)."
112
+ minimum: 0,
113
+ description: "Number of context lines for git unified diffs (default: 3, passed as -U<n>). Ignored by the `files`-only dump path."
77
114
  }
78
115
  }
79
116
  },
@@ -86,16 +123,31 @@ var diffTool = {
86
123
  };
87
124
  async function gitDiff(input, ctx, signal) {
88
125
  if (input.a?.startsWith("-")) {
89
- throw new Error(`diff: unsafe ref "${input.a}" \u2014 refs may not begin with '-' (flag injection)`);
126
+ throw new ToolValidationError({
127
+ message: `diff: unsafe ref "${input.a}" \u2014 refs may not begin with '-' (flag injection)`,
128
+ field: "a"
129
+ });
90
130
  }
91
131
  if (input.b?.startsWith("-")) {
92
- throw new Error(`diff: unsafe ref "${input.b}" \u2014 refs may not begin with '-' (flag injection)`);
132
+ throw new ToolValidationError({
133
+ message: `diff: unsafe ref "${input.b}" \u2014 refs may not begin with '-' (flag injection)`,
134
+ field: "b"
135
+ });
93
136
  }
137
+ const requestedMode = input.mode ?? "unified";
138
+ const statMode = requestedMode === "stat";
139
+ const effectiveMode = statMode ? "stat" : "unified";
140
+ const sideBySideNote = requestedMode === "side-by-side" ? "side-by-side output is not supported; a unified diff was produced instead." : void 0;
94
141
  const gitDir = findGitDir(ctx.cwd);
95
142
  if (!gitDir) {
96
- return { diff: "", files: [], truncated: false, mode: "unified" };
143
+ return { diff: "", files: [], truncated: false, mode: effectiveMode };
97
144
  }
98
145
  const args = ["diff", "--no-color"];
146
+ if (statMode) args.push("--stat");
147
+ if (!statMode && input.context !== void 0) {
148
+ const contextLines = Math.max(0, Math.floor(input.context));
149
+ if (Number.isFinite(contextLines)) args.push(`-U${contextLines}`);
150
+ }
99
151
  if (input.staged) args.push("--staged");
100
152
  if (input.a) args.push(input.a);
101
153
  if (input.b) args.push(input.b);
@@ -104,11 +156,22 @@ async function gitDiff(input, ctx, signal) {
104
156
  args.push("--", ...files.map((f) => f.trim()));
105
157
  }
106
158
  const result = await runGit(args, gitDir, signal);
159
+ let diff = result.stdout;
160
+ let truncated = false;
161
+ if (diff.length > MAX_GIT_DIFF_CHARS) {
162
+ let clipped = diff.slice(0, MAX_GIT_DIFF_CHARS);
163
+ const nl = clipped.lastIndexOf("\n");
164
+ if (nl > 0) clipped = clipped.slice(0, nl);
165
+ diff = `${clipped}
166
+ \u2026[git diff truncated: ${result.stdout.length - clipped.length} of ${result.stdout.length} characters omitted]`;
167
+ truncated = true;
168
+ }
107
169
  return {
108
- diff: result.stdout,
170
+ diff,
109
171
  files: [],
110
- truncated: result.stdout.length > 1e5,
111
- mode: "unified"
172
+ truncated,
173
+ mode: effectiveMode,
174
+ note: sideBySideNote
112
175
  };
113
176
  }
114
177
  function findGitDir(cwd) {
@@ -154,13 +217,13 @@ async function fileDiff(input, ctx, _signal) {
154
217
  diff: "No files specified",
155
218
  files: [],
156
219
  truncated: false,
157
- mode: input.mode ?? "unified"
220
+ mode: "dump"
158
221
  };
159
222
  }
160
223
  const results = [];
161
224
  let truncated = false;
162
225
  for (const file of files) {
163
- const absPath = safeResolve(file, ctx);
226
+ const absPath = await safeResolveReal(file, ctx);
164
227
  const stat2 = await fs.stat(absPath).catch(() => null);
165
228
  if (!stat2?.isFile()) continue;
166
229
  if (stat2.size > MAX_FILE_DUMP_BYTES) {
@@ -178,7 +241,10 @@ async function fileDiff(input, ctx, _signal) {
178
241
  diff: results.join("\n\n"),
179
242
  files,
180
243
  truncated,
181
- mode: input.mode ?? "unified"
244
+ // Honest mode: this path always produces a line-numbered dump — it never
245
+ // honors `mode`, so it must not echo the requested value back.
246
+ mode: "dump",
247
+ note: input.mode !== void 0 ? "The `files`-only path is a line-numbered dump; `mode` only affects the git-diff path (`a`/`b`)." : void 0
182
248
  };
183
249
  }
184
250
  function formatWithLineNumbers(file, lines) {
package/dist/document.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/document.ts
2
2
  import * as fs from "node:fs/promises";
3
+ import * as path2 from "node:path";
3
4
 
4
5
  // src/_util.ts
5
6
  import * as path from "node:path";
@@ -30,8 +31,8 @@ function safeResolve(input, ctx) {
30
31
  var documentTool = {
31
32
  name: "document",
32
33
  category: "Project",
33
- description: "DEPRECATED \u2014 use the `auto_doc` tool with `dryRun: true` instead. This tool is a read-only preview stub that returns `skipped` candidates without generating real docstrings.",
34
- usageHint: "Deprecated: prefer `auto_doc` with `dryRun: true` for previewing, or `auto_doc` without dryRun for writing. This tool only lists undocumented symbols with placeholder comments \u2014 it does not generate real JSDoc/TSDoc.",
34
+ description: "DEPRECATED \u2014 read-only preview stub that lists undocumented symbols as `skipped` candidates. It never writes files and does not generate real docstrings. If the auto-doc plugin is enabled, use its `auto_doc` tool (with `dry_run: true` to preview) instead.",
35
+ usageHint: "Deprecated: this tool only lists undocumented symbols with placeholder comments \u2014 it does not generate real JSDoc/TSDoc and writes nothing. When the auto-doc plugin is enabled, prefer its `auto_doc` tool (`dry_run: true` for previewing, without it for writing).",
35
36
  permission: "auto",
36
37
  mutating: false,
37
38
  timeoutMs: 3e4,
@@ -67,7 +68,11 @@ var documentTool = {
67
68
  const results = [];
68
69
  let filesProcessed = 0;
69
70
  let itemsDocumented = 0;
70
- const fileList = input.files ? await resolveFiles(Array.isArray(input.files) ? input.files.join(",") : input.files, cwd) : input.path ? [safeResolve(input.path, ctx)] : [];
71
+ const fileList = input.files ? await resolveFiles(
72
+ Array.isArray(input.files) ? input.files.join(",") : input.files,
73
+ cwd,
74
+ ctx
75
+ ) : input.path ? [safeResolve(input.path, ctx)] : [];
71
76
  for (const absPath of fileList) {
72
77
  try {
73
78
  const content = await fs.readFile(absPath, "utf8");
@@ -100,11 +105,18 @@ var documentTool = {
100
105
  };
101
106
  }
102
107
  };
103
- async function resolveFiles(filesInput, cwd) {
104
- const files = Array.isArray(filesInput) ? filesInput : filesInput.split(",");
108
+ async function resolveFiles(filesInput, cwd, ctx) {
109
+ const files = filesInput.split(",");
105
110
  const resolved = [];
106
111
  for (const f of files) {
107
- const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
112
+ const entry = f.trim();
113
+ if (!entry) continue;
114
+ let absPath;
115
+ try {
116
+ absPath = ensureInsideRoot(path2.resolve(cwd, entry), ctx);
117
+ } catch {
118
+ continue;
119
+ }
108
120
  try {
109
121
  const stat2 = await fs.stat(absPath);
110
122
  if (stat2.isFile()) resolved.push(absPath);
package/dist/edit.js CHANGED
@@ -381,8 +381,35 @@ async function safeResolveReal(input, ctx) {
381
381
  const abs = safeResolve(input, ctx);
382
382
  return await resolveRealInsideRoot(abs, ctx);
383
383
  }
384
+ function truncateDiffPayload(diff, maxBytes) {
385
+ const total = Buffer.byteLength(diff, "utf8");
386
+ if (total <= maxBytes) return { text: diff, truncated: false };
387
+ const MARKER_RESERVE = 96;
388
+ let head = takeHeadBytes(diff, Math.max(0, maxBytes - MARKER_RESERVE));
389
+ const nl = head.lastIndexOf("\n");
390
+ if (nl > 0) head = head.slice(0, nl);
391
+ const kept = Buffer.byteLength(head, "utf8");
392
+ return {
393
+ text: `${head}
394
+ \u2026[diff truncated: ${total - kept} of ${total} bytes omitted]`,
395
+ truncated: true
396
+ };
397
+ }
398
+ function takeHeadBytes(s, maxBytes) {
399
+ if (maxBytes <= 0) return "";
400
+ if (Buffer.byteLength(s, "utf8") <= maxBytes) return s;
401
+ let lo = 0;
402
+ let hi = s.length;
403
+ while (lo < hi) {
404
+ const mid = Math.ceil((lo + hi) / 2);
405
+ if (Buffer.byteLength(s.slice(0, mid), "utf8") <= maxBytes) lo = mid;
406
+ else hi = mid - 1;
407
+ }
408
+ return s.slice(0, lo);
409
+ }
384
410
 
385
411
  // src/edit.ts
412
+ var MAX_DIFF_BYTES = 262144;
386
413
  var editTool = {
387
414
  name: "edit",
388
415
  category: "Filesystem",
@@ -393,17 +420,33 @@ var editTool = {
393
420
  useInstead: ["write", "patch"]
394
421
  },
395
422
  permission: "confirm",
423
+ // WS-046: gives permission decisions something to key on — the file being
424
+ // edited, so trust rules can scope by path.
425
+ subjectKey: "path",
396
426
  mutating: true,
397
427
  capabilities: ["fs.write"],
398
428
  icon: "edit",
399
429
  timeoutMs: 5e3,
430
+ maxOutputBytes: 262144,
400
431
  inputSchema: {
401
432
  type: "object",
402
433
  properties: {
403
- path: { type: "string" },
404
- old_string: { type: "string" },
405
- new_string: { type: "string" },
406
- replace_all: { type: "boolean" }
434
+ path: {
435
+ type: "string",
436
+ description: "Path to the file to edit \u2014 relative to the project root, or absolute inside it."
437
+ },
438
+ old_string: {
439
+ type: "string",
440
+ description: "The exact text to replace, including whitespace and indentation. Must be unique in the file unless `replace_all` is set \u2014 add surrounding lines to disambiguate."
441
+ },
442
+ new_string: {
443
+ type: "string",
444
+ description: "The exact replacement text (may be empty to delete `old_string`)."
445
+ },
446
+ replace_all: {
447
+ type: "boolean",
448
+ description: "Replace every occurrence instead of requiring a unique match. Only allowed when `old_string` matches exactly (or up to trailing whitespace) \u2014 fuzzy matches stay single-target."
449
+ }
407
450
  },
408
451
  required: ["path", "old_string", "new_string"]
409
452
  },
@@ -483,6 +526,9 @@ var editTool = {
483
526
  const oldLf = normalizeToLf(input.old_string);
484
527
  const newLf = normalizeToLf(input.new_string);
485
528
  if (oldLf === newLf) {
529
+ if (!fileLf.includes(oldLf)) {
530
+ throw noMatchError(input.path, fileLf, oldLf);
531
+ }
486
532
  if (autoRead) ctx.recordRead(absPath, updated.mtimeMs, "user", originalHash);
487
533
  return {
488
534
  path: absPath,
@@ -496,13 +542,7 @@ var editTool = {
496
542
  const ladder = findLadderMatches(fileLf, oldLf);
497
543
  if (!ladder) {
498
544
  opts?.signal?.throwIfAborted();
499
- const hint = nearestMatchHint(fileLf, oldLf);
500
- throw new ToolValidationError({
501
- message: `edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint.line}:
502
- ${hint.snippet}
503
- Compare this against your old_string and retry with the file's actual text.` : ""}`,
504
- field: "old_string"
505
- });
545
+ throw noMatchError(input.path, fileLf, oldLf);
506
546
  }
507
547
  const { tier, matches } = ladder;
508
548
  const count = matches.length;
@@ -564,16 +604,20 @@ Compare this against your old_string and retry with the file's actual text.` : "
564
604
  after: newFile
565
605
  });
566
606
  opts?.signal?.throwIfAborted();
567
- const diff = unifiedDiff(original, newFile, {
568
- fromFile: input.path,
569
- toFile: input.path
570
- });
607
+ const { text: diff, truncated: diffTruncated } = truncateDiffPayload(
608
+ unifiedDiff(original, newFile, {
609
+ fromFile: input.path,
610
+ toFile: input.path
611
+ }),
612
+ MAX_DIFF_BYTES
613
+ );
614
+ const diffNote = diffTruncated ? "Diff truncated to the 256 KiB output budget \u2014 the full edit is on disk." : void 0;
571
615
  const syntax = await checkSyntax(absPath, newFile, original).catch(() => void 0);
572
616
  let syntaxNote;
573
617
  if (syntax && syntax.errors.length > 0) {
574
618
  syntaxNote = syntax.preExisting ? `Syntax check: the file still has parse errors (they pre-date this edit) \u2014 see syntax_errors.` : `Syntax check: this edit introduced ${syntax.errors.length} parse error(s) \u2014 fix them now, see syntax_errors.`;
575
619
  }
576
- const notes = [autoReadNote, tierNote, syntaxNote].filter(Boolean);
620
+ const notes = [autoReadNote, tierNote, diffNote, syntaxNote].filter(Boolean);
577
621
  return {
578
622
  path: absPath,
579
623
  replacements: input.replace_all ? count : 1,
@@ -584,6 +628,15 @@ Compare this against your old_string and retry with the file's actual text.` : "
584
628
  };
585
629
  }
586
630
  };
631
+ function noMatchError(inputPath, fileLf, oldLf) {
632
+ const hint = nearestMatchHint(fileLf, oldLf);
633
+ return new ToolValidationError({
634
+ message: `edit: no match for old_string in "${inputPath}".${hint ? ` Nearest match near line ${hint.line}:
635
+ ${hint.snippet}
636
+ Compare this against your old_string and retry with the file's actual text.` : ""}`,
637
+ field: "old_string"
638
+ });
639
+ }
587
640
  export {
588
641
  editTool
589
642
  };
package/dist/exec.js CHANGED
@@ -829,8 +829,11 @@ var SENSITIVE_FLAG_PATTERNS = [
829
829
  /--(?:token|password|passwd|pwd|secret|api[-_]?key|api[-_]?secret|auth|credential|private[-_]?key|access[-_]?key|github[-_]?token|gh[-_]?token|bearer|jwt|oauth|pin|pincode|passphrase|access[-_]?token)(?:[=\s,][^\s]*)?/gi,
830
830
  // -t short flag (token): attached (-tVALUE), separated (-t VALUE), or -t=VALUE.
831
831
  // (?<![-\w]) anchors to a token start so we don't match the `-t` inside `--token`.
832
+ // The value must be token-like (>= 8 chars) so ordinary combined flags such
833
+ // as `tar -tf` / `ssh -tt` are not eaten. Global flag: EVERY occurrence is
834
+ // redacted, not just the first.
832
835
  // NOTE: synced with @wrongstack/core observability/redact-command.ts.
833
- /(?<![-\w])-t(?:[=\s]+)?[^\s,-]+/,
836
+ /(?<![-\w])-t(?:[=\s]+)?[^\s,-]{8,}/g,
834
837
  // -p|-password|-a (redis auth) short flags: attached + separated + =value.
835
838
  // Same token-start anchor; over-redaction is an accepted tradeoff for a
836
839
  // redaction function. Synced with core copy.
@@ -838,8 +841,9 @@ var SENSITIVE_FLAG_PATTERNS = [
838
841
  // env var–style secrets: TOKEN=x, API_KEY=y, etc.
839
842
  /(?:TOKEN|API_KEY|API_SECRET|AUTH_TOKEN|GITHUB_TOKEN|GH_TOKEN|BEARER|JWT|OAUTH|CREDENTIAL|SECRET|PRIVATE_KEY|PASSWORD|PASSWD)\s*[=:]\s*[^\s,]+/gi,
840
843
  // Generic high-entropy look: base64 strings >32 chars or hex strings >32 digits — but only
841
- // when preceded by a flag name (e.g. --github-token=EyJ...).
842
- /--\w*(?:token|key|secret|password|passwd|auth|credential)\w*[=\s,][A-Za-z0-9+/=]{32,}/
844
+ // when preceded by a flag name (e.g. --github-token=EyJ...). Global flag so
845
+ // every such flag in the command line is redacted, not just the first.
846
+ /--\w*(?:token|key|secret|password|passwd|auth|credential)\w*[=\s,][A-Za-z0-9+/=]{32,}/g
843
847
  ];
844
848
  function redactCommand(cmd) {
845
849
  let result = cmd;
@@ -928,11 +932,15 @@ var ProcessRegistryImpl = class {
928
932
  return Number.isInteger(pid) && pid > 1 && pid !== process.pid && pid !== process.ppid;
929
933
  }
930
934
  _canSignalProcessGroup(p) {
931
- return os.platform() !== "win32" && p.processGroupLeader === true && this._isSafeSignalPid(p.pid) && typeof p.child.pid === "number" && p.child.pid === p.pid;
935
+ return os.platform() !== "win32" && p.processGroupLeader === true && this._isSafeSignalPid(p.pid) && p.child !== null && typeof p.child.pid === "number" && p.child.pid === p.pid;
932
936
  }
933
937
  _killChildDirect(p, signal) {
934
938
  try {
935
- p.child.kill(signal);
939
+ if (p.child) {
940
+ p.child.kill(signal);
941
+ return;
942
+ }
943
+ if (this._isSafeSignalPid(p.pid)) process.kill(p.pid, signal);
936
944
  } catch {
937
945
  }
938
946
  }
@@ -1130,15 +1138,15 @@ var ProcessRegistryImpl = class {
1130
1138
  this._pruneStale(pid);
1131
1139
  const p = this.processes.get(pid);
1132
1140
  if (!p) return false;
1133
- if (p.killed) return true;
1141
+ if (p.killed && opts.force !== true) return true;
1134
1142
  if (p.protected && opts.includeProtected !== true) return false;
1135
1143
  if (opts.preserveBackground && p.background) return false;
1136
1144
  const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
1137
1145
  const isWin3 = os.platform() === "win32";
1138
1146
  if (isWin3) {
1139
- const liveRealChild = p.child.exitCode === null && typeof p.child.pid === "number";
1147
+ const liveRealChild = p.child === null || p.child.exitCode === null && typeof p.child.pid === "number";
1140
1148
  const directFallback = () => {
1141
- if (p.child.exitCode === null) {
1149
+ if (p.child && p.child.exitCode === null) {
1142
1150
  try {
1143
1151
  p.child.kill("SIGKILL");
1144
1152
  } catch {
@@ -1150,10 +1158,7 @@ var ProcessRegistryImpl = class {
1150
1158
  onSettled: directFallback
1151
1159
  })) {
1152
1160
  } else {
1153
- try {
1154
- p.child.kill(force ? "SIGKILL" : "SIGTERM");
1155
- } catch {
1156
- }
1161
+ this._killChildDirect(p, force ? "SIGKILL" : "SIGTERM");
1157
1162
  }
1158
1163
  p.killed = true;
1159
1164
  return true;
@@ -1164,7 +1169,7 @@ var ProcessRegistryImpl = class {
1164
1169
  } else {
1165
1170
  this._killPosix(p, "SIGTERM");
1166
1171
  const timer = setTimeout(() => {
1167
- if (this.processes.has(pid) && !p.child.killed) {
1172
+ if (this.processes.has(pid) && !p.child?.killed) {
1168
1173
  this._killPosix(p, "SIGKILL");
1169
1174
  }
1170
1175
  }, graceMs);
@@ -1219,6 +1224,16 @@ var ProcessRegistryImpl = class {
1219
1224
  * before reusing a PID, but we want to clean up before that becomes a risk.
1220
1225
  */
1221
1226
  _isStaleEntry(entry) {
1227
+ if (entry.child === null) {
1228
+ if (Date.now() - entry.startedAt <= 6e4) return false;
1229
+ if (os.platform() === "win32") return false;
1230
+ try {
1231
+ process.kill(entry.pid, 0);
1232
+ return false;
1233
+ } catch (err) {
1234
+ return err.code !== "EPERM";
1235
+ }
1236
+ }
1222
1237
  return entry.child.exitCode !== null && Date.now() - entry.startedAt > 6e4;
1223
1238
  }
1224
1239
  /**
@@ -1512,7 +1527,6 @@ var PersistentProcessRegistry = class {
1512
1527
  try {
1513
1528
  const data = await readRegistryFile(this.registryPath);
1514
1529
  data.instances.set(String(entry.pid), entry);
1515
- const child = null;
1516
1530
  this.baseRegistry.register({
1517
1531
  pid: entry.pid,
1518
1532
  name: entry.name,
@@ -1520,7 +1534,7 @@ var PersistentProcessRegistry = class {
1520
1534
  startedAt: entry.startedAt,
1521
1535
  sessionId: entry.sessionId,
1522
1536
  protected: entry.protected,
1523
- child
1537
+ child: null
1524
1538
  });
1525
1539
  await writeRegistryFile(this.registryPath, data);
1526
1540
  } finally {
@@ -2602,6 +2616,7 @@ function getExecAllowlist() {
2602
2616
  var MAX_ARGS = 20;
2603
2617
  var MAX_OUTPUT = 2e5;
2604
2618
  var DEFAULT_TIMEOUT_MS = 3e4;
2619
+ var MAX_TIMEOUT_MS = 6e5;
2605
2620
  var BLOCKED_ARG_PATTERNS = {
2606
2621
  python: [],
2607
2622
  // git --exec=<cmd> runs arbitrary commands via upload-pack/receive-pack;
@@ -2727,8 +2742,8 @@ var SAFE_DANGER = { level: "safe", reasons: [] };
2727
2742
  var execTool = {
2728
2743
  name: "exec",
2729
2744
  category: "Shell",
2730
- description: "Execute a **whitelisted, restricted set of commands** with strict argument validation. This is the **preferred and safer** alternative to the `bash` tool for running development tools (node, npm, pnpm, tsc, git, tests, linters, etc.). It prevents arbitrary command injection and limits what the model can do.",
2731
- usageHint: "PREFERRED SHELL TOOL for most cases.\n\nUse this instead of `bash` whenever possible.\n- `command` must be in the allowlist. Defaults cover JS (node/npm/pnpm/yarn/bun/deno/tsc/vitest/eslint/biome), Go (`go build`/`go test`), Rust (cargo), Python (python/pip), Ruby (gem/bundle), JVM (java/mvn/gradle), .NET (dotnet), native (make/cmake), and git. Users can extend it via `tools.exec.allow` in config.\n- Arguments are passed as a clean array (no shell interpretation).\n- `cwd` is validated to stay inside the project.\n- If a command is not allowlisted, the error explains how to add it; for one-off arbitrary commands, fall back to `bash` (with strong justification).\nThis tool significantly reduces the risk compared to full shell access.",
2745
+ description: "Execute a command from a **curated command roster** with argument validation and confirm gating. This is the **preferred** alternative to the `bash` tool for running development tools (node, npm, pnpm, tsc, git, tests, linters, etc.). It is NOT a sandbox \u2014 several rostered commands (node, python, powershell, \u2026) can run arbitrary code \u2014 so prefer least-privilege commands.",
2746
+ usageHint: "PREFERRED SHELL TOOL for most cases.\n\nUse this instead of `bash` whenever possible.\n- `command` must be in the allowlist. Defaults cover JS (node/npm/pnpm/yarn/bun/deno/tsc/vitest/eslint/biome), Go (`go build`/`go test`), Rust (cargo), Python (python/pip), Ruby (gem/bundle), JVM (java/mvn/gradle), .NET (dotnet), native (make/cmake), and git. Users can extend it via `tools.exec.allow` in config.\n- Arguments are passed as a clean array (no shell interpretation).\n- `cwd` is validated to stay inside the project.\n- If a command is not allowlisted, the error explains how to add it; for one-off arbitrary commands, fall back to `bash` (with strong justification).\nThe curated roster + confirm gating narrows the surface compared to full shell access, but this is not a sandbox \u2014 prefer least-privilege commands.",
2732
2747
  selection: {
2733
2748
  doNotUseWhen: "the operation requires pipes, redirection, shell expansion, or a non-allowlisted command.",
2734
2749
  useInstead: ["bash"]
@@ -2744,7 +2759,13 @@ var execTool = {
2744
2759
  subjectKey: "command",
2745
2760
  mutating: true,
2746
2761
  riskTier: "standard",
2747
- timeoutMs: DEFAULT_TIMEOUT_MS,
2762
+ // Executor-level abort ceiling. Must sit ABOVE the per-call timeout ceiling
2763
+ // (MAX_TIMEOUT_MS): the tool's own timer resolves with exit 124 + registry
2764
+ // tree-kill; the executor's AbortSignal.timeout is a blunt abort that would
2765
+ // otherwise fire first and discard the structured timeout result. The 10s
2766
+ // margin covers the kill/teardown window. (The executor additionally clamps
2767
+ // to config `tools.maxToolTimeoutMs`.)
2768
+ timeoutMs: MAX_TIMEOUT_MS + 1e4,
2748
2769
  capabilities: ["shell.restricted"],
2749
2770
  icon: "terminal",
2750
2771
  inputSchema: {
@@ -2765,7 +2786,7 @@ var execTool = {
2765
2786
  },
2766
2787
  timeout: {
2767
2788
  type: "integer",
2768
- description: "Per-command timeout in milliseconds."
2789
+ description: "Per-command timeout in milliseconds (default 30000, max 600000)."
2769
2790
  }
2770
2791
  },
2771
2792
  required: ["command"]
@@ -2809,7 +2830,7 @@ var execTool = {
2809
2830
  };
2810
2831
  }
2811
2832
  const args = (input.args ?? []).slice(0, MAX_ARGS);
2812
- const timeout = Math.max(1, Math.min(input.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS));
2833
+ const timeout = Math.max(1, Math.min(input.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS));
2813
2834
  const danger = detectDanger(cmd, args, dangerBypass);
2814
2835
  const killCheck = await checkExecKillCommand(cmd, args);
2815
2836
  if (killCheck.blocked) {
@@ -2837,15 +2858,16 @@ var execTool = {
2837
2858
  danger
2838
2859
  };
2839
2860
  }
2861
+ const defaultCwd = ctx.workingDir ?? ctx.cwd;
2840
2862
  let cwd;
2841
2863
  try {
2842
- cwd = input.cwd ? await safeResolveReal(input.cwd, ctx) : await safeResolveReal(ctx.cwd, ctx);
2864
+ cwd = input.cwd ? await safeResolveReal(input.cwd, ctx) : await safeResolveReal(defaultCwd, ctx);
2843
2865
  } catch {
2844
2866
  return {
2845
2867
  command: cmd,
2846
2868
  args,
2847
2869
  stdout: "",
2848
- stderr: `cwd "${input.cwd ?? ctx.cwd}" resolves outside project root`,
2870
+ stderr: `cwd "${input.cwd ?? defaultCwd}" resolves outside project root`,
2849
2871
  exitCode: 1,
2850
2872
  truncated: false,
2851
2873
  allowed: false,
package/dist/fetch.js CHANGED
@@ -102,6 +102,10 @@ async function guardedFetch(url, maxRedirects, signal, headers = {
102
102
  if (res.status < 300 || res.status > 399) {
103
103
  return res;
104
104
  }
105
+ try {
106
+ await res.body?.cancel();
107
+ } catch {
108
+ }
105
109
  redirectCount++;
106
110
  if (redirectCount > maxRedirects) {
107
111
  throw new FetchError({
@@ -184,6 +188,8 @@ TD.addRule("stripDangerousElements", {
184
188
  filter: ["script", "style", "noscript"],
185
189
  replacement: () => ""
186
190
  });
191
+ var PRUNED_BOILERPLATE_TAGS = /* @__PURE__ */ new Set(["nav", "header", "footer", "aside", "svg", "iframe"]);
192
+ TD.remove((node) => PRUNED_BOILERPLATE_TAGS.has(node.nodeName.toLowerCase()));
187
193
  var MAX_BYTES = 131072;
188
194
  var TIMEOUT_MS = 2e4;
189
195
  var combineSignals = (signals) => AbortSignal.any(signals);
@@ -213,7 +219,7 @@ var fetchTool = {
213
219
  format: {
214
220
  type: "string",
215
221
  enum: ["markdown", "text", "raw"],
216
- description: 'Output format. "markdown" is recommended for HTML pages.'
222
+ description: 'Output format. "markdown" is recommended for HTML pages; for non-HTML content types it falls back to plain text (JSON is pretty-printed).'
217
223
  }
218
224
  },
219
225
  required: ["url"]
@@ -248,6 +254,12 @@ var fetchTool = {
248
254
  });
249
255
  }
250
256
  const u = new URL(input.url);
257
+ if (u.username || u.password) {
258
+ throw new ToolValidationError2({
259
+ message: "fetch: URLs with embedded credentials (user:pass@host) are not allowed",
260
+ field: "url"
261
+ });
262
+ }
251
263
  if (u.protocol !== "https:" && u.protocol !== "http:") {
252
264
  throw new ToolValidationError2({
253
265
  message: `fetch: unsupported protocol "${u.protocol}"`,
package/dist/format.d.ts CHANGED
@@ -7,8 +7,10 @@ interface FormatInput {
7
7
  }
8
8
  interface FormatOutput {
9
9
  fixer: string;
10
- files_checked: number;
11
- files_changed: number;
10
+ /** Parsed from formatter output when confidently available; undefined otherwise. */
11
+ files_checked: number | undefined;
12
+ /** Parsed from formatter output when confidently available; undefined otherwise. */
13
+ files_changed: number | undefined;
12
14
  output: string;
13
15
  truncated: boolean;
14
16
  }