@wrongstack/tools 0.264.0 → 0.267.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.
Files changed (90) hide show
  1. package/dist/audit.js +154 -11
  2. package/dist/audit.js.map +1 -1
  3. package/dist/bash.js +138 -2
  4. package/dist/bash.js.map +1 -1
  5. package/dist/batch-tool-use.js +1 -0
  6. package/dist/batch-tool-use.js.map +1 -1
  7. package/dist/builtin.d.ts +20 -1
  8. package/dist/builtin.js +796 -340
  9. package/dist/builtin.js.map +1 -1
  10. package/dist/circuit-breaker.d.ts +20 -0
  11. package/dist/circuit-breaker.js +40 -2
  12. package/dist/circuit-breaker.js.map +1 -1
  13. package/dist/codebase-index/index.d.ts +16 -0
  14. package/dist/codebase-index/index.js +59 -25
  15. package/dist/codebase-index/index.js.map +1 -1
  16. package/dist/codebase-index/worker.js +56 -25
  17. package/dist/codebase-index/worker.js.map +1 -1
  18. package/dist/diff.js +14 -7
  19. package/dist/diff.js.map +1 -1
  20. package/dist/document.js +14 -8
  21. package/dist/document.js.map +1 -1
  22. package/dist/edit.d.ts +1 -0
  23. package/dist/edit.js +33 -22
  24. package/dist/edit.js.map +1 -1
  25. package/dist/exec.js +140 -3
  26. package/dist/exec.js.map +1 -1
  27. package/dist/fetch.js +1 -0
  28. package/dist/fetch.js.map +1 -1
  29. package/dist/format.js +153 -11
  30. package/dist/format.js.map +1 -1
  31. package/dist/git.d.ts +7 -0
  32. package/dist/git.js +20 -2
  33. package/dist/git.js.map +1 -1
  34. package/dist/glob.js +14 -7
  35. package/dist/glob.js.map +1 -1
  36. package/dist/grep.js +14 -7
  37. package/dist/grep.js.map +1 -1
  38. package/dist/index.d.ts +55 -3
  39. package/dist/index.js +957 -341
  40. package/dist/index.js.map +1 -1
  41. package/dist/install.js +153 -11
  42. package/dist/install.js.map +1 -1
  43. package/dist/json.js +1 -0
  44. package/dist/json.js.map +1 -1
  45. package/dist/lint.js +153 -11
  46. package/dist/lint.js.map +1 -1
  47. package/dist/logs.js +14 -7
  48. package/dist/logs.js.map +1 -1
  49. package/dist/memory.js +1 -0
  50. package/dist/memory.js.map +1 -1
  51. package/dist/mode.js +1 -0
  52. package/dist/mode.js.map +1 -1
  53. package/dist/outdated.js +21 -10
  54. package/dist/outdated.js.map +1 -1
  55. package/dist/pack.js +765 -339
  56. package/dist/pack.js.map +1 -1
  57. package/dist/patch.js +14 -7
  58. package/dist/patch.js.map +1 -1
  59. package/dist/process-registry.d.ts +56 -2
  60. package/dist/process-registry.js +138 -3
  61. package/dist/process-registry.js.map +1 -1
  62. package/dist/read.d.ts +3 -0
  63. package/dist/read.js +124 -22
  64. package/dist/read.js.map +1 -1
  65. package/dist/replace.js +14 -7
  66. package/dist/replace.js.map +1 -1
  67. package/dist/scaffold.js +14 -7
  68. package/dist/scaffold.js.map +1 -1
  69. package/dist/search.js +1 -0
  70. package/dist/search.js.map +1 -1
  71. package/dist/test.js +153 -11
  72. package/dist/test.js.map +1 -1
  73. package/dist/todo.js +1 -0
  74. package/dist/todo.js.map +1 -1
  75. package/dist/tool-help.js +1 -0
  76. package/dist/tool-help.js.map +1 -1
  77. package/dist/tool-icons.d.ts +20 -0
  78. package/dist/tool-icons.js +130 -0
  79. package/dist/tool-icons.js.map +1 -0
  80. package/dist/tool-search.js +1 -0
  81. package/dist/tool-search.js.map +1 -1
  82. package/dist/tool-use.js +1 -0
  83. package/dist/tool-use.js.map +1 -1
  84. package/dist/tree.js +14 -7
  85. package/dist/tree.js.map +1 -1
  86. package/dist/typecheck.js +153 -11
  87. package/dist/typecheck.js.map +1 -1
  88. package/dist/write.js +21 -15
  89. package/dist/write.js.map +1 -1
  90. package/package.json +6 -2
package/dist/read.d.ts CHANGED
@@ -4,12 +4,15 @@ interface ReadInput {
4
4
  path: string;
5
5
  offset?: number | undefined;
6
6
  limit?: number | undefined;
7
+ mode?: 'content' | 'summary' | undefined;
7
8
  }
8
9
  interface ReadOutput {
9
10
  text: string;
10
11
  total_lines: number;
11
12
  encoding: string;
12
13
  truncated: boolean;
14
+ cached?: boolean | undefined;
15
+ note?: string | undefined;
13
16
  }
14
17
  declare const readTool: Tool<ReadInput, ReadOutput>;
15
18
 
package/dist/read.js CHANGED
@@ -1,28 +1,35 @@
1
1
  import * as fsp from 'node:fs/promises';
2
- import * as path from 'node:path';
3
- import '@wrongstack/core';
4
2
  import { toErrorMessage } from '@wrongstack/core/utils';
3
+ import * as path from 'node:path';
4
+ import * as Core from '@wrongstack/core';
5
5
 
6
6
  // src/read.ts
7
7
  function resolvePath(input, ctx) {
8
8
  return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);
9
9
  }
10
+ function allowedRoots(ctx) {
11
+ return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];
12
+ }
13
+ function isInsideAny(target, roots) {
14
+ return roots.some((root) => {
15
+ const rel = path.relative(root, target);
16
+ return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
17
+ });
18
+ }
10
19
  function ensureInsideRoot(absPath, ctx) {
11
- if (ctx.allowOutsideProjectRoot) return path.resolve(absPath);
12
- const root = path.resolve(ctx.projectRoot);
13
20
  const target = path.resolve(absPath);
14
- const rel = path.relative(root, target);
15
- if (rel.startsWith("..") || path.isAbsolute(rel)) {
16
- throw new Error(`Path "${absPath}" is outside project root "${root}"`);
17
- }
18
- return target;
21
+ if (ctx.allowOutsideProjectRoot) return target;
22
+ if (isInsideAny(target, allowedRoots(ctx))) return target;
23
+ throw new Error(`Path "${absPath}" is outside project root "${path.resolve(ctx.projectRoot)}"`);
19
24
  }
20
25
  function safeResolve(input, ctx) {
21
26
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
22
27
  }
23
28
  async function assertRealInsideRoot(absPath, ctx) {
24
29
  if (ctx.allowOutsideProjectRoot) return;
25
- const realRoot = await fsp.realpath(ctx.projectRoot).catch(() => path.resolve(ctx.projectRoot));
30
+ const realRoots = await Promise.all(
31
+ allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r)))
32
+ );
26
33
  let probe = absPath;
27
34
  for (; ; ) {
28
35
  let real;
@@ -37,13 +44,10 @@ async function assertRealInsideRoot(absPath, ctx) {
37
44
  }
38
45
  throw err;
39
46
  }
40
- const rel = path.relative(realRoot, real);
41
- if (rel.startsWith("..") || path.isAbsolute(rel)) {
42
- throw new Error(
43
- `Path "${absPath}" resolves through a symlink outside project root "${realRoot}"`
44
- );
45
- }
46
- return;
47
+ if (isInsideAny(real, realRoots)) return;
48
+ throw new Error(
49
+ `Path "${absPath}" resolves through a symlink outside project root "${realRoots[0]}"`
50
+ );
47
51
  }
48
52
  }
49
53
  async function safeResolveReal(input, ctx) {
@@ -58,6 +62,8 @@ function isBinaryBuffer(buf) {
58
62
  }
59
63
  return false;
60
64
  }
65
+
66
+ // src/read.ts
61
67
  var MAX_BYTES = 5 * 1024 * 1024;
62
68
  var readTool = {
63
69
  name: "read",
@@ -67,6 +73,7 @@ var readTool = {
67
73
  permission: "auto",
68
74
  mutating: false,
69
75
  capabilities: ["fs.read"],
76
+ icon: "file",
70
77
  maxOutputBytes: 262144,
71
78
  timeoutMs: 5e3,
72
79
  inputSchema: {
@@ -83,6 +90,11 @@ var readTool = {
83
90
  limit: {
84
91
  type: "integer",
85
92
  description: "Maximum number of lines to return (default is 2000)."
93
+ },
94
+ mode: {
95
+ type: "string",
96
+ enum: ["content", "summary"],
97
+ description: "Return full line-numbered content (default) or a compact file summary with imports/exports/symbols."
86
98
  }
87
99
  },
88
100
  required: ["path"]
@@ -96,14 +108,27 @@ var readTool = {
96
108
  } catch (err) {
97
109
  const code = err.code;
98
110
  if (code === "ENOENT") throw new Error(`read: file not found "${input.path}"`);
99
- throw new Error(
100
- `read: failed to stat "${input.path}": ${toErrorMessage(err)}`
101
- );
111
+ throw new Error(`read: failed to stat "${input.path}": ${toErrorMessage(err)}`);
102
112
  }
103
113
  if (!stat2.isFile()) throw new Error(`read: "${input.path}" is not a regular file`);
104
114
  if (stat2.size > MAX_BYTES) {
105
115
  throw new Error(`read: file too large (${stat2.size} bytes, limit ${MAX_BYTES})`);
106
116
  }
117
+ const offset = Math.max(1, input.offset ?? 1);
118
+ const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
119
+ const prior = getReadRangeRecord(ctx, absPath);
120
+ const requestedEnd = prior ? Math.min(offset + limit - 1, prior.totalLines) : offset + limit - 1;
121
+ if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat2.mtimeMs, offset, requestedEnd)) {
122
+ ctx.recordRead(absPath, stat2.mtimeMs);
123
+ return {
124
+ text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat2.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
125
+ total_lines: prior.totalLines,
126
+ encoding: "utf8",
127
+ truncated: requestedEnd < prior.totalLines,
128
+ cached: true,
129
+ note: "Repeated read suppressed to save tokens."
130
+ };
131
+ }
107
132
  const buf = await fsp.readFile(absPath);
108
133
  if (isBinaryBuffer(buf)) {
109
134
  throw new Error(`read: "${input.path}" appears to be binary`);
@@ -111,17 +136,38 @@ var readTool = {
111
136
  const text = buf.toString("utf8");
112
137
  const allLines = text.split(/\r\n|\r|\n/);
113
138
  const total = allLines.length;
114
- const offset = Math.max(1, input.offset ?? 1);
115
- const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
139
+ if (input.mode === "summary") {
140
+ ctx.recordRead(absPath, stat2.mtimeMs);
141
+ rememberReadRange(ctx, absPath, stat2.mtimeMs, total, 1, Math.min(total, 200));
142
+ return {
143
+ text: summarizeFile(input.path, stat2.size, allLines),
144
+ total_lines: total,
145
+ encoding: "utf8",
146
+ truncated: total > 200,
147
+ note: "Summary mode returned compact structure instead of full file content."
148
+ };
149
+ }
116
150
  if (limit === 0) {
117
151
  ctx.recordRead(absPath, stat2.mtimeMs);
152
+ rememberReadRange(ctx, absPath, stat2.mtimeMs, total, 1, 0);
118
153
  return { text: "", total_lines: total, encoding: "utf8", truncated: total > 0 };
119
154
  }
155
+ if (offset > total) {
156
+ ctx.recordRead(absPath, stat2.mtimeMs);
157
+ rememberReadRange(ctx, absPath, stat2.mtimeMs, total, total + 1, total + 1);
158
+ return {
159
+ text: `[offset ${offset} is past end of file "${input.path}" \u2014 file has ${total} line(s). Do not retry this offset.]`,
160
+ total_lines: total,
161
+ encoding: "utf8",
162
+ truncated: false
163
+ };
164
+ }
120
165
  const slice = allLines.slice(offset - 1, offset - 1 + limit);
121
166
  const truncated = offset - 1 + slice.length < total;
122
167
  const width = String(offset + slice.length - 1).length;
123
168
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
124
169
  ctx.recordRead(absPath, stat2.mtimeMs);
170
+ rememberReadRange(ctx, absPath, stat2.mtimeMs, total, offset, offset + slice.length - 1);
125
171
  return {
126
172
  text: numbered,
127
173
  total_lines: total,
@@ -130,6 +176,62 @@ var readTool = {
130
176
  };
131
177
  }
132
178
  };
179
+ var READ_RANGES_META_KEY = "tools.read.ranges.v1";
180
+ function getReadRanges(ctx) {
181
+ const existing = ctx.meta[READ_RANGES_META_KEY];
182
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) {
183
+ return existing;
184
+ }
185
+ const next = {};
186
+ ctx.meta[READ_RANGES_META_KEY] = next;
187
+ return next;
188
+ }
189
+ function getReadRangeRecord(ctx, absPath) {
190
+ return getReadRanges(ctx)[absPath];
191
+ }
192
+ function rememberReadRange(ctx, absPath, mtimeMs, totalLines, start, end) {
193
+ if (end < start) return;
194
+ const ranges = getReadRanges(ctx);
195
+ const prior = ranges[absPath];
196
+ const nextRanges = prior && Math.abs(prior.mtimeMs - mtimeMs) <= 1 ? prior.ranges.slice() : [];
197
+ nextRanges.push({ start, end });
198
+ ranges[absPath] = {
199
+ mtimeMs,
200
+ totalLines,
201
+ ranges: mergeRanges(nextRanges)
202
+ };
203
+ }
204
+ function coversRange(record, mtimeMs, start, end) {
205
+ if (Math.abs(record.mtimeMs - mtimeMs) > 1) return false;
206
+ return record.ranges.some((range) => range.start <= start && range.end >= end);
207
+ }
208
+ function mergeRanges(ranges) {
209
+ const sorted = ranges.slice().sort((a, b) => a.start - b.start);
210
+ const merged = [];
211
+ for (const range of sorted) {
212
+ const last = merged[merged.length - 1];
213
+ if (!last || range.start > last.end + 1) {
214
+ merged.push({ ...range });
215
+ continue;
216
+ }
217
+ last.end = Math.max(last.end, range.end);
218
+ }
219
+ return merged;
220
+ }
221
+ function summarizeFile(filePath, bytes, lines) {
222
+ const interesting = lines.map((line, index) => ({ line: line.trim(), number: index + 1 })).filter(
223
+ ({ line }) => /^(import\s|export\s|class\s|interface\s|type\s|function\s|const\s+\w+\s*=|let\s+\w+\s*=|var\s+\w+\s*=|def\s+|async\s+function\s)/.test(
224
+ line
225
+ )
226
+ ).slice(0, 80).map(({ line, number }) => `${number}: ${line}`);
227
+ return [
228
+ `summary: ${filePath}`,
229
+ `bytes=${bytes}`,
230
+ `total_lines=${lines.length}`,
231
+ interesting.length > 0 ? `symbols/imports:
232
+ ${interesting.join("\n")}` : "symbols/imports: (none detected)"
233
+ ].join("\n");
234
+ }
133
235
 
134
236
  export { readTool };
135
237
  //# sourceMappingURL=read.js.map
package/dist/read.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/_util.ts","../src/read.ts"],"names":["stat","fs"],"mappings":"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","file":"read.js","sourcesContent":["import * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm → yarn → npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n // If allowOutsideProjectRoot is true, skip the project-root restriction.\n if (ctx.allowOutsideProjectRoot) return path.resolve(absPath);\n const root = path.resolve(ctx.projectRoot);\n const target = path.resolve(absPath);\n const rel = path.relative(root, target);\n if (rel.startsWith('..') || path.isAbsolute(rel)) {\n throw new Error(`Path \"${absPath}\" is outside project root \"${root}\"`);\n }\n return target;\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root→out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink — macOS `/var`→`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // If allowOutsideProjectRoot is true, skip the symlink-escape check.\n if (ctx.allowOutsideProjectRoot) return;\n const realRoot = await fsp.realpath(ctx.projectRoot).catch(() => path.resolve(ctx.projectRoot));\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n const rel = path.relative(realRoot, real);\n if (rel.startsWith('..') || path.isAbsolute(rel)) {\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoot}\"`,\n );\n }\n return;\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// ─── Command-output normalization (token-saving) ────────────────────────────\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) — never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only — it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `… ⟨repeated ${run}×⟩`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends — the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n…[truncated ${total - kept} bytes]…\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI → collapse\n * carriage-return progress → trim trailing whitespace → collapse identical\n * consecutive lines → squeeze blank-line runs → head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines → 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n","import * as fs from 'node:fs/promises';\nimport type { Tool } from '@wrongstack/core';\nimport { isBinaryBuffer, safeResolveReal } from './_util.js';\nimport { toErrorMessage } from '@wrongstack/core/utils';\n\ninterface ReadInput {\n path: string;\n offset?: number | undefined;\n limit?: number | undefined;\n}\n\ninterface ReadOutput {\n text: string;\n total_lines: number;\n encoding: string;\n truncated: boolean;\n}\n\nconst MAX_BYTES = 5 * 1024 * 1024;\n\nexport const readTool: Tool<ReadInput, ReadOutput> = {\n name: 'read',\n category: 'Filesystem',\n description:\n 'Read the contents of a file with line numbers. This is the primary way to inspect source code, configuration, or any text file before making changes. ' +\n 'Lines are returned 1-indexed with a ` N| ` prefix for easy reference in edits.',\n usageHint:\n 'FOUNDATIONAL TOOL — call this before almost any edit operation.\\n\\n' +\n 'Best practices:\\n' +\n '- Always read a file before using `edit`, `replace`, or `write` on it (the system often requires it for safety).\\n' +\n '- Use `offset` + `limit` for very large files instead of reading everything at once.\\n' +\n '- Default limit is generous (2000 lines) but can be increased.\\n' +\n '- The output format is designed to be directly usable as context for `edit` operations.',\n permission: 'auto',\n mutating: false,\n capabilities: ['fs.read'],\n maxOutputBytes: 262_144,\n timeoutMs: 5_000,\n inputSchema: {\n type: 'object',\n properties: {\n path: {\n type: 'string',\n description: 'Path to the file (relative to project root or absolute within project).',\n },\n offset: {\n type: 'integer',\n description: '1-based starting line number. Use together with `limit` for large files.',\n },\n limit: {\n type: 'integer',\n description: 'Maximum number of lines to return (default is 2000).',\n },\n },\n required: ['path'],\n },\n async execute(input, ctx) {\n if (!input?.path) throw new Error('read: path is required');\n const absPath = await safeResolveReal(input.path, ctx);\n\n let stat: Awaited<ReturnType<typeof fs.stat>>;\n try {\n stat = await fs.stat(absPath);\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') throw new Error(`read: file not found \"${input.path}\"`);\n throw new Error(\n `read: failed to stat \"${input.path}\": ${toErrorMessage(err)}`,\n );\n }\n if (!stat.isFile()) throw new Error(`read: \"${input.path}\" is not a regular file`);\n if (stat.size > MAX_BYTES) {\n throw new Error(`read: file too large (${stat.size} bytes, limit ${MAX_BYTES})`);\n }\n\n const buf = await fs.readFile(absPath);\n if (isBinaryBuffer(buf)) {\n throw new Error(`read: \"${input.path}\" appears to be binary`);\n }\n\n const text = buf.toString('utf8');\n const allLines = text.split(/\\r\\n|\\r|\\n/);\n const total = allLines.length;\n const offset = Math.max(1, input.offset ?? 1);\n const limit = Math.max(0, Math.min(input.limit ?? 2000, 5000));\n if (limit === 0) {\n ctx.recordRead(absPath, stat.mtimeMs);\n return { text: '', total_lines: total, encoding: 'utf8', truncated: total > 0 };\n }\n const slice = allLines.slice(offset - 1, offset - 1 + limit);\n const truncated = offset - 1 + slice.length < total;\n\n const width = String(offset + slice.length - 1).length;\n const numbered = slice\n .map((line, i) => `${String(offset + i).padStart(width, ' ')}→${line}`)\n .join('\\n');\n\n ctx.recordRead(absPath, stat.mtimeMs);\n\n return {\n text: numbered,\n total_lines: total,\n encoding: 'utf8',\n truncated,\n };\n },\n};\n"]}
1
+ {"version":3,"sources":["../src/_util.ts","../src/read.ts"],"names":["stat","fs"],"mappings":"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","file":"read.js","sourcesContent":["import * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm → yarn → npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\n/**\n * Roots every file tool may always reach, even in restricted mode: the\n * project root and the user-global `~/.wrongstack` directory (config, memory,\n * sessions, skills). `~/.wrongstack` honors the `WRONGSTACK_HOME` override.\n */\nfunction allowedRoots(ctx: Context): string[] {\n return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];\n}\n\n/** True if `target` is `root` itself or nested inside any of `roots`. */\nfunction isInsideAny(target: string, roots: string[]): boolean {\n return roots.some((root) => {\n const rel = path.relative(root, target);\n return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n });\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const target = path.resolve(absPath);\n // Unrestricted filesystem access: skip the project-root containment check.\n if (ctx.allowOutsideProjectRoot) return target;\n if (isInsideAny(target, allowedRoots(ctx))) return target;\n throw new Error(`Path \"${absPath}\" is outside project root \"${path.resolve(ctx.projectRoot)}\"`);\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root→out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink — macOS `/var`→`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // Unrestricted filesystem access: no symlink-escape check to perform.\n if (ctx.allowOutsideProjectRoot) return;\n // Compare like-for-like against the realpath of each always-allowed root\n // (project root + ~/.wrongstack), since a root may itself be a symlink.\n const realRoots = await Promise.all(\n allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r))),\n );\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n if (isInsideAny(real, realRoots)) return;\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoots[0]}\"`,\n );\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// ─── Command-output normalization (token-saving) ────────────────────────────\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) — never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only — it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `… ⟨repeated ${run}×⟩`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends — the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n…[truncated ${total - kept} bytes]…\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI → collapse\n * carriage-return progress → trim trailing whitespace → collapse identical\n * consecutive lines → squeeze blank-line runs → head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines → 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n","import * as fs from 'node:fs/promises';\nimport type { Tool } from '@wrongstack/core';\nimport { toErrorMessage } from '@wrongstack/core/utils';\nimport { isBinaryBuffer, safeResolveReal } from './_util.js';\n\ninterface ReadInput {\n path: string;\n offset?: number | undefined;\n limit?: number | undefined;\n mode?: 'content' | 'summary' | undefined;\n}\n\ninterface ReadOutput {\n text: string;\n total_lines: number;\n encoding: string;\n truncated: boolean;\n cached?: boolean | undefined;\n note?: string | undefined;\n}\n\nconst MAX_BYTES = 5 * 1024 * 1024;\n\nexport const readTool: Tool<ReadInput, ReadOutput> = {\n name: 'read',\n category: 'Filesystem',\n description:\n 'Read the contents of a file with line numbers. This is the primary way to inspect source code, configuration, or any text file before making changes. ' +\n 'Lines are returned 1-indexed with a ` N| ` prefix for easy reference in edits.',\n usageHint:\n 'FOUNDATIONAL TOOL — call this before almost any edit operation.\\n\\n' +\n 'Best practices:\\n' +\n '- Always read a file before using `edit`, `replace`, or `write` on it (the system often requires it for safety).\\n' +\n '- Use `offset` + `limit` for very large files instead of reading everything at once.\\n' +\n '- Default limit is generous (2000 lines) but can be increased.\\n' +\n '- The output format is designed to be directly usable as context for `edit` operations.',\n permission: 'auto',\n mutating: false,\n capabilities: ['fs.read'],\n icon: 'file',\n maxOutputBytes: 262_144,\n timeoutMs: 5_000,\n inputSchema: {\n type: 'object',\n properties: {\n path: {\n type: 'string',\n description: 'Path to the file (relative to project root or absolute within project).',\n },\n offset: {\n type: 'integer',\n description: '1-based starting line number. Use together with `limit` for large files.',\n },\n limit: {\n type: 'integer',\n description: 'Maximum number of lines to return (default is 2000).',\n },\n mode: {\n type: 'string',\n enum: ['content', 'summary'],\n description:\n 'Return full line-numbered content (default) or a compact file summary with imports/exports/symbols.',\n },\n },\n required: ['path'],\n },\n async execute(input, ctx) {\n if (!input?.path) throw new Error('read: path is required');\n const absPath = await safeResolveReal(input.path, ctx);\n\n let stat: Awaited<ReturnType<typeof fs.stat>>;\n try {\n stat = await fs.stat(absPath);\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') throw new Error(`read: file not found \"${input.path}\"`);\n throw new Error(`read: failed to stat \"${input.path}\": ${toErrorMessage(err)}`);\n }\n if (!stat.isFile()) throw new Error(`read: \"${input.path}\" is not a regular file`);\n if (stat.size > MAX_BYTES) {\n throw new Error(`read: file too large (${stat.size} bytes, limit ${MAX_BYTES})`);\n }\n\n const offset = Math.max(1, input.offset ?? 1);\n const limit = Math.max(0, Math.min(input.limit ?? 2000, 5000));\n const prior = getReadRangeRecord(ctx, absPath);\n const requestedEnd = prior\n ? Math.min(offset + limit - 1, prior.totalLines)\n : offset + limit - 1;\n if (\n input.mode !== 'summary' &&\n limit > 0 &&\n prior &&\n coversRange(prior, stat.mtimeMs, offset, requestedEnd)\n ) {\n ctx.recordRead(absPath, stat.mtimeMs);\n return {\n text:\n `[unchanged since previous read: \"${input.path}\" mtime=${Math.round(stat.mtimeMs)}; ` +\n `requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,\n total_lines: prior.totalLines,\n encoding: 'utf8',\n truncated: requestedEnd < prior.totalLines,\n cached: true,\n note: 'Repeated read suppressed to save tokens.',\n };\n }\n\n const buf = await fs.readFile(absPath);\n if (isBinaryBuffer(buf)) {\n throw new Error(`read: \"${input.path}\" appears to be binary`);\n }\n\n const text = buf.toString('utf8');\n const allLines = text.split(/\\r\\n|\\r|\\n/);\n const total = allLines.length;\n if (input.mode === 'summary') {\n ctx.recordRead(absPath, stat.mtimeMs);\n rememberReadRange(ctx, absPath, stat.mtimeMs, total, 1, Math.min(total, 200));\n return {\n text: summarizeFile(input.path, stat.size, allLines),\n total_lines: total,\n encoding: 'utf8',\n truncated: total > 200,\n note: 'Summary mode returned compact structure instead of full file content.',\n };\n }\n if (limit === 0) {\n ctx.recordRead(absPath, stat.mtimeMs);\n rememberReadRange(ctx, absPath, stat.mtimeMs, total, 1, 0);\n return { text: '', total_lines: total, encoding: 'utf8', truncated: total > 0 };\n }\n // Offset past EOF: return an explicit message instead of an empty string.\n // Without this, models with weak instruction-following (e.g. k2p7) see an\n // empty result, assume the read failed transiently, and retry the exact\n // same offset indefinitely — a tight tool-use loop that burns iterations\n // and context without making progress.\n if (offset > total) {\n ctx.recordRead(absPath, stat.mtimeMs);\n rememberReadRange(ctx, absPath, stat.mtimeMs, total, total + 1, total + 1);\n return {\n text: `[offset ${offset} is past end of file \"${input.path}\" — file has ${total} line(s). Do not retry this offset.]`,\n total_lines: total,\n encoding: 'utf8',\n truncated: false,\n };\n }\n\n const slice = allLines.slice(offset - 1, offset - 1 + limit);\n const truncated = offset - 1 + slice.length < total;\n\n const width = String(offset + slice.length - 1).length;\n const numbered = slice\n .map((line, i) => `${String(offset + i).padStart(width, ' ')}→${line}`)\n .join('\\n');\n\n ctx.recordRead(absPath, stat.mtimeMs);\n rememberReadRange(ctx, absPath, stat.mtimeMs, total, offset, offset + slice.length - 1);\n\n return {\n text: numbered,\n total_lines: total,\n encoding: 'utf8',\n truncated,\n };\n },\n};\n\ninterface ReadRangeRecord {\n mtimeMs: number;\n totalLines: number;\n ranges: Array<{ start: number; end: number }>;\n}\n\nconst READ_RANGES_META_KEY = 'tools.read.ranges.v1';\n\nfunction getReadRanges(ctx: import('@wrongstack/core').Context): Record<string, ReadRangeRecord> {\n const existing = ctx.meta[READ_RANGES_META_KEY];\n if (existing && typeof existing === 'object' && !Array.isArray(existing)) {\n return existing as Record<string, ReadRangeRecord>;\n }\n const next: Record<string, ReadRangeRecord> = {};\n ctx.meta[READ_RANGES_META_KEY] = next;\n return next;\n}\n\nfunction getReadRangeRecord(\n ctx: import('@wrongstack/core').Context,\n absPath: string,\n): ReadRangeRecord | undefined {\n return getReadRanges(ctx)[absPath];\n}\n\nfunction rememberReadRange(\n ctx: import('@wrongstack/core').Context,\n absPath: string,\n mtimeMs: number,\n totalLines: number,\n start: number,\n end: number,\n): void {\n if (end < start) return;\n const ranges = getReadRanges(ctx);\n const prior = ranges[absPath];\n const nextRanges = prior && Math.abs(prior.mtimeMs - mtimeMs) <= 1 ? prior.ranges.slice() : [];\n nextRanges.push({ start, end });\n ranges[absPath] = {\n mtimeMs,\n totalLines,\n ranges: mergeRanges(nextRanges),\n };\n}\n\nfunction coversRange(\n record: ReadRangeRecord,\n mtimeMs: number,\n start: number,\n end: number,\n): boolean {\n if (Math.abs(record.mtimeMs - mtimeMs) > 1) return false;\n return record.ranges.some((range) => range.start <= start && range.end >= end);\n}\n\nfunction mergeRanges(\n ranges: Array<{ start: number; end: number }>,\n): Array<{ start: number; end: number }> {\n const sorted = ranges.slice().sort((a, b) => a.start - b.start);\n const merged: Array<{ start: number; end: number }> = [];\n for (const range of sorted) {\n const last = merged[merged.length - 1];\n if (!last || range.start > last.end + 1) {\n merged.push({ ...range });\n continue;\n }\n last.end = Math.max(last.end, range.end);\n }\n return merged;\n}\n\nfunction summarizeFile(filePath: string, bytes: number, lines: string[]): string {\n const interesting = lines\n .map((line, index) => ({ line: line.trim(), number: index + 1 }))\n .filter(({ line }) =>\n /^(import\\s|export\\s|class\\s|interface\\s|type\\s|function\\s|const\\s+\\w+\\s*=|let\\s+\\w+\\s*=|var\\s+\\w+\\s*=|def\\s+|async\\s+function\\s)/.test(\n line,\n ),\n )\n .slice(0, 80)\n .map(({ line, number }) => `${number}: ${line}`);\n return [\n `summary: ${filePath}`,\n `bytes=${bytes}`,\n `total_lines=${lines.length}`,\n interesting.length > 0\n ? `symbols/imports:\\n${interesting.join('\\n')}`\n : 'symbols/imports: (none detected)',\n ].join('\\n');\n}\n"]}
package/dist/replace.js CHANGED
@@ -1,3 +1,4 @@
1
+ import * as Core from '@wrongstack/core';
1
2
  import { compileGlob, detectNewlineStyle, normalizeToLf, expectDefined, toStyle, atomicWrite, unifiedDiff, buildChildEnv } from '@wrongstack/core';
2
3
  import { spawn } from 'node:child_process';
3
4
  import * as fs from 'node:fs/promises';
@@ -48,15 +49,20 @@ function compileUserRegex(pattern, flags) {
48
49
  function resolvePath(input, ctx) {
49
50
  return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);
50
51
  }
52
+ function allowedRoots(ctx) {
53
+ return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];
54
+ }
55
+ function isInsideAny(target, roots) {
56
+ return roots.some((root) => {
57
+ const rel = path.relative(root, target);
58
+ return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
59
+ });
60
+ }
51
61
  function ensureInsideRoot(absPath, ctx) {
52
- if (ctx.allowOutsideProjectRoot) return path.resolve(absPath);
53
- const root = path.resolve(ctx.projectRoot);
54
62
  const target = path.resolve(absPath);
55
- const rel = path.relative(root, target);
56
- if (rel.startsWith("..") || path.isAbsolute(rel)) {
57
- throw new Error(`Path "${absPath}" is outside project root "${root}"`);
58
- }
59
- return target;
63
+ if (ctx.allowOutsideProjectRoot) return target;
64
+ if (isInsideAny(target, allowedRoots(ctx))) return target;
65
+ throw new Error(`Path "${absPath}" is outside project root "${path.resolve(ctx.projectRoot)}"`);
60
66
  }
61
67
  function safeResolve(input, ctx) {
62
68
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
@@ -79,6 +85,7 @@ var replaceTool = {
79
85
  permission: "confirm",
80
86
  mutating: true,
81
87
  capabilities: ["fs.write"],
88
+ icon: "edit",
82
89
  timeoutMs: 3e4,
83
90
  inputSchema: {
84
91
  type: "object",
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/_regex.ts","../src/_util.ts","../src/replace.ts"],"names":["lstat","path2","stat","resolve"],"mappings":"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","file":"replace.js","sourcesContent":["/**\n * Compile a user-supplied regex with conservative bounds against ReDoS.\n *\n * Node's regex engine (V8) is backtracking-based and cannot interrupt a\n * synchronous match — a pattern like `(a+)+$` against a sufficiently long\n * line will pin a worker for seconds. The executor's outer `timeoutMs` only\n * fires between async boundaries, so a long regex eval inside a sync loop\n * is uninterruptible.\n *\n * We can't fully prevent ReDoS without an alternative engine (re2-wasm), but\n * we can sharply limit the blast radius:\n *\n * 1. Cap pattern length — practically all legitimate user patterns are\n * under 256 characters. A 4 KB pattern is almost certainly malicious\n * or a copy-paste accident.\n * 2. Reject patterns containing the most obvious super-linear structures.\n * This is a coarse filter (false-positives are likely; we accept that\n * for hostile-input contexts).\n *\n * Callers should additionally bound the *subject* length (e.g. by capping\n * line size before matching).\n */\n\nconst MAX_PATTERN_LEN = 256;\n\n// Heuristics for catastrophic-backtracking constructs. Not exhaustive; bias\n// toward false-positives in tools that accept LLM-generated input.\nconst DANGEROUS_PATTERNS: ReadonlyArray<RegExp> = [\n // (a+)+, (.*)+, etc — nested quantifier on a group with internal quantifier\n /(\\([^)]*[+*][^)]*\\))[+*]/,\n /(\\(\\?:[^)]*[+*][^)]*\\))[+*]/,\n // Adjacent quantifiers: a++ a*+\n /[+*]{2,}/,\n // Quantifier on alternation with length 2+\n /\\([^|)]+\\|[^)]+\\)[+*][+*]/,\n // Greedy quantifier inside lookahead/lookbehind — (?!.*a+)\n /[([][^)\\]]*[+*][^)\\]]*[)\\]][^)]*\\?\\??/,\n];\n\nexport interface CompileResult {\n ok: true;\n regex: RegExp;\n}\n\nexport interface CompileFail {\n ok: false;\n reason: string;\n}\n\nexport function compileUserRegex(pattern: string, flags: string): CompileResult | CompileFail {\n if (typeof pattern !== 'string') {\n return { ok: false, reason: 'pattern must be a string' };\n }\n if (pattern.length === 0) {\n return { ok: false, reason: 'pattern is empty' };\n }\n if (pattern.length > MAX_PATTERN_LEN) {\n return { ok: false, reason: `pattern exceeds ${MAX_PATTERN_LEN} characters` };\n }\n for (const rx of DANGEROUS_PATTERNS) {\n if (rx.test(pattern)) {\n return {\n ok: false,\n reason:\n 'pattern looks vulnerable to catastrophic backtracking — rewrite without nested quantifiers',\n };\n }\n }\n try {\n return { ok: true, regex: new RegExp(pattern, flags) };\n } catch (err) {\n return {\n ok: false,\n reason: err instanceof Error ? err.message : 'invalid regex',\n };\n }\n}\n\n/**\n * Truncate a subject line to a safe length for synchronous regex eval.\n * The cap is conservative; tools that need exact-line matching against very\n * long lines should use ripgrep externally rather than the native walker.\n */\nexport const MAX_SUBJECT_LEN = 64 * 1024;\n\nexport function capSubject(line: string): string {\n return line.length > MAX_SUBJECT_LEN ? line.slice(0, MAX_SUBJECT_LEN) : line;\n}\n","import * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm → yarn → npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n // If allowOutsideProjectRoot is true, skip the project-root restriction.\n if (ctx.allowOutsideProjectRoot) return path.resolve(absPath);\n const root = path.resolve(ctx.projectRoot);\n const target = path.resolve(absPath);\n const rel = path.relative(root, target);\n if (rel.startsWith('..') || path.isAbsolute(rel)) {\n throw new Error(`Path \"${absPath}\" is outside project root \"${root}\"`);\n }\n return target;\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root→out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink — macOS `/var`→`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // If allowOutsideProjectRoot is true, skip the symlink-escape check.\n if (ctx.allowOutsideProjectRoot) return;\n const realRoot = await fsp.realpath(ctx.projectRoot).catch(() => path.resolve(ctx.projectRoot));\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n const rel = path.relative(realRoot, real);\n if (rel.startsWith('..') || path.isAbsolute(rel)) {\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoot}\"`,\n );\n }\n return;\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// ─── Command-output normalization (token-saving) ────────────────────────────\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) — never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only — it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `… ⟨repeated ${run}×⟩`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends — the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n…[truncated ${total - kept} bytes]…\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI → collapse\n * carriage-return progress → trim trailing whitespace → collapse identical\n * consecutive lines → squeeze blank-line runs → head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines → 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n","import { expectDefined } from '@wrongstack/core';\nimport { spawn } from 'node:child_process';\nimport * as fs from 'node:fs/promises';\nimport * as path from 'node:path';\nimport {\n atomicWrite,\n buildChildEnv,\n compileGlob,\n detectNewlineStyle,\n normalizeToLf,\n toStyle,\n unifiedDiff,\n} from '@wrongstack/core';\nimport type { Context, Tool } from '@wrongstack/core';\nimport { compileUserRegex } from './_regex.js';\nimport { isBinaryBuffer, safeResolve } from './_util.js';\ninterface ReplaceInput {\n pattern: string;\n replacement: string;\n files: string | string[];\n glob?: string | undefined;\n replace_all?: boolean | undefined;\n dry_run?: boolean | undefined;\n}\n\ninterface ReplaceOutput {\n files_modified: number;\n total_replacements: number;\n results: { path: string; replacements: number; diff?: string | undefined }[];\n dry_run: boolean;\n}\n\nconst DEFAULT_IGNORE = ['node_modules', '.git', 'dist', 'build', '.next', 'coverage'];\n\nexport const replaceTool: Tool<ReplaceInput, ReplaceOutput> = {\n name: 'replace',\n category: 'Transform',\n description:\n 'Perform a search-and-replace across multiple files using a regex pattern. ' +\n 'This is a powerful bulk transformation tool. Always use `dry_run: true` first on anything non-trivial.',\n usageHint:\n 'DANGEROUS IF USED CARELESSLY — review the diff output carefully.\\n\\n' +\n 'Recommended workflow:\\n' +\n '1. Start with `dry_run: true` to see exactly what would change.\\n' +\n '2. Use a specific enough `pattern` (and `glob` / `files`) to avoid accidental broad changes.\\n' +\n '3. `replace_all` controls whether only the first match per file or all matches are replaced.\\n' +\n 'This tool is excellent for large-scale refactors (renaming, import updates, etc.) but must be used with caution.',\n permission: 'confirm',\n mutating: true,\n capabilities: ['fs.write'],\n timeoutMs: 30_000,\n inputSchema: {\n type: 'object',\n properties: {\n pattern: { type: 'string', description: 'Regex pattern to match' },\n replacement: { type: 'string', description: 'Replacement string' },\n files: {\n type: 'string',\n description: 'File(s) to target: single path, comma-separated list, or glob pattern',\n },\n glob: { type: 'string', description: 'Additional glob filter (e.g. \"*.ts\")' },\n replace_all: {\n type: 'boolean',\n description: 'Replace all occurrences in each file (default: true)',\n },\n dry_run: { type: 'boolean', description: 'Preview changes without writing' },\n },\n required: ['pattern', 'replacement', 'files'],\n },\n async execute(input: ReplaceInput, ctx: Context) {\n if (!input?.pattern) throw new Error('replace: pattern is required');\n if (input.replacement === undefined) throw new Error('replace: replacement is required');\n if (!input?.files) throw new Error('replace: files is required');\n\n const replaceAll = input.replace_all ?? true;\n // Always compile with 'g' so matchAll() works — matchAll throws\n // TypeError on non-global regexes. The replaceAll flag controls\n // how many matches we act on, not whether the regex is global.\n const compiled = compileUserRegex(input.pattern, 'g');\n if (!compiled.ok) {\n throw new Error(`replace: ${compiled.reason}`);\n }\n const re = compiled.regex;\n const globRe = input.glob ? compileGlob(input.glob) : null;\n const dryRun = input.dry_run ?? false;\n\n const filesInput = Array.isArray(input.files) ? input.files.join(',') : input.files;\n const fileList = await resolveFiles(filesInput, ctx, globRe);\n\n // Resolve the project root through realpath ONCE so the sandbox check\n // below compares like-for-like with realpath(file). The project root\n // itself can be a symlink or short name — e.g. macOS temp dirs live under\n // /var -> /private/var, and Windows CI runners expose an 8.3 short name\n // (C:\\Users\\RUNNER~1\\...). Comparing realpath(file) against the raw root\n // then makes every legitimately-inside file look \"outside\" and skips it.\n const realRoot = await fs.realpath(ctx.projectRoot).catch(() => ctx.projectRoot);\n\n const results: ReplaceOutput['results'] = [];\n let totalReplacements = 0;\n\n for (const absPath of fileList) {\n // Use lstat to detect symlinks. resolveFiles already applies\n // safeResolve, but a symlink with a target outside the project\n // root would still pass that string check — explicitly skip it\n // so we never read or write through a link.\n const lstat = await fs.lstat(absPath).catch((err) => {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;\n /* v8 ignore next -- non-ENOENT lstat failure (EACCES etc.) is a defensive rethrow. */\n throw err;\n });\n if (!lstat || !lstat.isFile()) continue;\n if (lstat.isSymbolicLink()) continue;\n\n // Cross-check via realpath: if the resolved target lives outside the\n // project root (e.g. a bind mount or a parent-dir traversal we missed),\n // skip rather than rewrite through it.\n let realPath: string;\n try {\n realPath = await fs.realpath(absPath);\n } catch {\n /* v8 ignore next -- realpath failing after a successful lstat is a TOCTOU race; defensive. */\n continue;\n }\n const rel = path.relative(realRoot, realPath);\n if (rel.startsWith('..') || path.isAbsolute(rel)) continue;\n\n // Now stat the real target so we use its mode for atomicWrite.\n const stat = await fs.stat(realPath).catch(() => null);\n if (!stat || !stat.isFile()) continue;\n\n let content: string;\n try {\n const buf = await fs.readFile(realPath);\n if (isBinaryBuffer(buf)) continue;\n content = buf.toString('utf8');\n } catch {\n /* v8 ignore next -- readFile failing after a successful stat is a TOCTOU race; defensive. */\n continue;\n }\n\n const style = detectNewlineStyle(content);\n const contentLf = normalizeToLf(content);\n re.lastIndex = 0;\n const allMatches = [...contentLf.matchAll(re)];\n if (allMatches.length === 0) continue;\n\n // When replace_all is false, only act on the first match.\n const matches = replaceAll ? allMatches : allMatches.slice(0, 1);\n const count = matches.length;\n\n // Rebuild: splice the replacement into each match position from\n // right to left so earlier indices stay valid.\n let newContentLf = contentLf;\n for (let i = matches.length - 1; i >= 0; i--) {\n const m = expectDefined(matches[i]);\n newContentLf =\n newContentLf.slice(0, m.index) +\n input.replacement +\n newContentLf.slice(expectDefined(m.index) + m[0].length);\n }\n re.lastIndex = 0;\n totalReplacements += count;\n\n if (!dryRun) {\n const newContent = toStyle(newContentLf, style);\n // Write to the real path (already validated inside project root)\n // so atomicWrite's temp-and-rename can't be redirected through a\n // freshly-planted symlink at absPath.\n await atomicWrite(realPath, newContent, { mode: stat.mode & 0o777 });\n }\n\n const diff =\n dryRun || matches.length > 0\n ? unifiedDiff(content, toStyle(newContentLf, style), {\n fromFile: absPath,\n toFile: absPath,\n })\n : undefined;\n\n results.push({\n path: absPath,\n replacements: matches.length,\n diff,\n });\n }\n\n return {\n files_modified: results.length,\n total_replacements: totalReplacements,\n results,\n dry_run: dryRun,\n };\n },\n};\n\nasync function resolveFiles(\n filesInput: string,\n ctx: Context,\n extraGlob?: RegExp | null | undefined,\n): Promise<string[]> {\n const base = ctx.cwd;\n const normalized = filesInput.trim();\n\n if (normalized.startsWith('**/') || normalized.startsWith('*') || normalized.includes('**')) {\n return await globFiles(normalized, base, extraGlob);\n }\n\n const parts = normalized\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n const resolved: string[] = [];\n\n for (const p of parts) {\n const absPath = safeResolve(p, ctx);\n const stat = await fs.stat(absPath).catch(() => null);\n if (stat?.isFile()) {\n resolved.push(absPath);\n }\n }\n\n return resolved;\n}\n\nasync function globFiles(\n pattern: string,\n base: string,\n extraGlob?: RegExp | null | undefined,\n): Promise<string[]> {\n\n const rgAvailable = await checkRg();\n if (rgAvailable) {\n try {\n const { promise } = spawnRgFind(pattern, base);\n return await promise;\n } catch {\n // fall through\n }\n }\n\n return await globNative(pattern, base, extraGlob);\n}\n\nfunction checkRg(): Promise<boolean> {\n return new Promise((resolve) => {\n try {\n const p = spawn('rg', ['--version'], { env: buildChildEnv(), stdio: 'ignore', windowsHide: true });\n p.on('error', () => resolve(false));\n p.on('close', (code) => resolve(code === 0));\n } catch {\n resolve(false);\n }\n });\n}\n\nfunction spawnRgFind(pattern: string, base: string): { promise: Promise<string[]> } {\n const args = ['--files', '--glob', pattern, base];\n // 30-second safety net to prevent zombie rg processes. Unlike the main\n // grep tool, glob file enumeration is fast and should never need more time.\n const child = spawn('rg', args, {\n signal: AbortSignal.timeout(30_000),\n env: buildChildEnv(),\n stdio: ['ignore', 'pipe', 'pipe'],\n windowsHide: true,\n });\n let buf = '';\n child.stdout?.on('data', (chunk: Buffer) => {\n buf += chunk.toString();\n });\n return {\n promise: new Promise((resolve, reject) => {\n child.on('error', reject);\n child.on('close', () => {\n resolve(buf.split('\\n').filter(Boolean));\n });\n }),\n };\n}\n\nasync function globNative(\n pattern: string,\n base: string,\n extraGlob?: RegExp | null | undefined,\n): Promise<string[]> {\n const results: string[] = [];\n const globRe = compileGlob(pattern);\n\n const walk = async (dir: string): Promise<void> => {\n let entries: import('node:fs').Dirent[];\n try {\n entries = await fs.readdir(dir, { withFileTypes: true });\n } catch {\n /* v8 ignore next -- unreadable directory during the walk; defensive. */\n return;\n }\n for (const e of entries) {\n if (DEFAULT_IGNORE.includes(e.name)) continue;\n const full = path.join(dir, e.name);\n // Dirent.isSymbolicLink() uses readdir's d_type, which may not detect\n // directory symlinks on Windows (d_type = DT_UNKNOWN). Defensive stat\n // call: skip any entry whose lstat shows a symlink — file or directory.\n try {\n const stat = await fs.lstat(full);\n if (stat.isSymbolicLink()) continue;\n } catch {\n // lstat fails for very unusual entries (e.g. broken symlinks to deleted\n // files on NFS); skip safely rather than surfacing an error.\n /* v8 ignore next -- lstat failing on a readdir entry is a rare NFS/race case; defensive. */\n continue;\n }\n if (e.isDirectory()) {\n await walk(full);\n } else if (e.isFile()) {\n const name = e.name;\n if (globRe.test(name) || globRe.test(full)) {\n if (extraGlob && !extraGlob.test(name) && !extraGlob.test(full)) continue;\n results.push(full);\n }\n globRe.lastIndex = 0;\n if (extraGlob) extraGlob.lastIndex = 0;\n }\n }\n };\n\n await walk(base);\n return results;\n}\n"]}
1
+ {"version":3,"sources":["../src/_regex.ts","../src/_util.ts","../src/replace.ts"],"names":["lstat","path2","stat","resolve"],"mappings":"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","file":"replace.js","sourcesContent":["/**\n * Compile a user-supplied regex with conservative bounds against ReDoS.\n *\n * Node's regex engine (V8) is backtracking-based and cannot interrupt a\n * synchronous match — a pattern like `(a+)+$` against a sufficiently long\n * line will pin a worker for seconds. The executor's outer `timeoutMs` only\n * fires between async boundaries, so a long regex eval inside a sync loop\n * is uninterruptible.\n *\n * We can't fully prevent ReDoS without an alternative engine (re2-wasm), but\n * we can sharply limit the blast radius:\n *\n * 1. Cap pattern length — practically all legitimate user patterns are\n * under 256 characters. A 4 KB pattern is almost certainly malicious\n * or a copy-paste accident.\n * 2. Reject patterns containing the most obvious super-linear structures.\n * This is a coarse filter (false-positives are likely; we accept that\n * for hostile-input contexts).\n *\n * Callers should additionally bound the *subject* length (e.g. by capping\n * line size before matching).\n */\n\nconst MAX_PATTERN_LEN = 256;\n\n// Heuristics for catastrophic-backtracking constructs. Not exhaustive; bias\n// toward false-positives in tools that accept LLM-generated input.\nconst DANGEROUS_PATTERNS: ReadonlyArray<RegExp> = [\n // (a+)+, (.*)+, etc — nested quantifier on a group with internal quantifier\n /(\\([^)]*[+*][^)]*\\))[+*]/,\n /(\\(\\?:[^)]*[+*][^)]*\\))[+*]/,\n // Adjacent quantifiers: a++ a*+\n /[+*]{2,}/,\n // Quantifier on alternation with length 2+\n /\\([^|)]+\\|[^)]+\\)[+*][+*]/,\n // Greedy quantifier inside lookahead/lookbehind — (?!.*a+)\n /[([][^)\\]]*[+*][^)\\]]*[)\\]][^)]*\\?\\??/,\n];\n\nexport interface CompileResult {\n ok: true;\n regex: RegExp;\n}\n\nexport interface CompileFail {\n ok: false;\n reason: string;\n}\n\nexport function compileUserRegex(pattern: string, flags: string): CompileResult | CompileFail {\n if (typeof pattern !== 'string') {\n return { ok: false, reason: 'pattern must be a string' };\n }\n if (pattern.length === 0) {\n return { ok: false, reason: 'pattern is empty' };\n }\n if (pattern.length > MAX_PATTERN_LEN) {\n return { ok: false, reason: `pattern exceeds ${MAX_PATTERN_LEN} characters` };\n }\n for (const rx of DANGEROUS_PATTERNS) {\n if (rx.test(pattern)) {\n return {\n ok: false,\n reason:\n 'pattern looks vulnerable to catastrophic backtracking — rewrite without nested quantifiers',\n };\n }\n }\n try {\n return { ok: true, regex: new RegExp(pattern, flags) };\n } catch (err) {\n return {\n ok: false,\n reason: err instanceof Error ? err.message : 'invalid regex',\n };\n }\n}\n\n/**\n * Truncate a subject line to a safe length for synchronous regex eval.\n * The cap is conservative; tools that need exact-line matching against very\n * long lines should use ripgrep externally rather than the native walker.\n */\nexport const MAX_SUBJECT_LEN = 64 * 1024;\n\nexport function capSubject(line: string): string {\n return line.length > MAX_SUBJECT_LEN ? line.slice(0, MAX_SUBJECT_LEN) : line;\n}\n","import * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm → yarn → npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\n/**\n * Roots every file tool may always reach, even in restricted mode: the\n * project root and the user-global `~/.wrongstack` directory (config, memory,\n * sessions, skills). `~/.wrongstack` honors the `WRONGSTACK_HOME` override.\n */\nfunction allowedRoots(ctx: Context): string[] {\n return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];\n}\n\n/** True if `target` is `root` itself or nested inside any of `roots`. */\nfunction isInsideAny(target: string, roots: string[]): boolean {\n return roots.some((root) => {\n const rel = path.relative(root, target);\n return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n });\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const target = path.resolve(absPath);\n // Unrestricted filesystem access: skip the project-root containment check.\n if (ctx.allowOutsideProjectRoot) return target;\n if (isInsideAny(target, allowedRoots(ctx))) return target;\n throw new Error(`Path \"${absPath}\" is outside project root \"${path.resolve(ctx.projectRoot)}\"`);\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root→out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink — macOS `/var`→`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // Unrestricted filesystem access: no symlink-escape check to perform.\n if (ctx.allowOutsideProjectRoot) return;\n // Compare like-for-like against the realpath of each always-allowed root\n // (project root + ~/.wrongstack), since a root may itself be a symlink.\n const realRoots = await Promise.all(\n allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r))),\n );\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n if (isInsideAny(real, realRoots)) return;\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoots[0]}\"`,\n );\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// ─── Command-output normalization (token-saving) ────────────────────────────\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) — never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only — it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `… ⟨repeated ${run}×⟩`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends — the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n…[truncated ${total - kept} bytes]…\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI → collapse\n * carriage-return progress → trim trailing whitespace → collapse identical\n * consecutive lines → squeeze blank-line runs → head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines → 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n","import { expectDefined } from '@wrongstack/core';\nimport { spawn } from 'node:child_process';\nimport * as fs from 'node:fs/promises';\nimport * as path from 'node:path';\nimport {\n atomicWrite,\n buildChildEnv,\n compileGlob,\n detectNewlineStyle,\n normalizeToLf,\n toStyle,\n unifiedDiff,\n} from '@wrongstack/core';\nimport type { Context, Tool } from '@wrongstack/core';\nimport { compileUserRegex } from './_regex.js';\nimport { isBinaryBuffer, safeResolve } from './_util.js';\ninterface ReplaceInput {\n pattern: string;\n replacement: string;\n files: string | string[];\n glob?: string | undefined;\n replace_all?: boolean | undefined;\n dry_run?: boolean | undefined;\n}\n\ninterface ReplaceOutput {\n files_modified: number;\n total_replacements: number;\n results: { path: string; replacements: number; diff?: string | undefined }[];\n dry_run: boolean;\n}\n\nconst DEFAULT_IGNORE = ['node_modules', '.git', 'dist', 'build', '.next', 'coverage'];\n\nexport const replaceTool: Tool<ReplaceInput, ReplaceOutput> = {\n name: 'replace',\n category: 'Transform',\n description:\n 'Perform a search-and-replace across multiple files using a regex pattern. ' +\n 'This is a powerful bulk transformation tool. Always use `dry_run: true` first on anything non-trivial.',\n usageHint:\n 'DANGEROUS IF USED CARELESSLY — review the diff output carefully.\\n\\n' +\n 'Recommended workflow:\\n' +\n '1. Start with `dry_run: true` to see exactly what would change.\\n' +\n '2. Use a specific enough `pattern` (and `glob` / `files`) to avoid accidental broad changes.\\n' +\n '3. `replace_all` controls whether only the first match per file or all matches are replaced.\\n' +\n 'This tool is excellent for large-scale refactors (renaming, import updates, etc.) but must be used with caution.',\n permission: 'confirm',\n mutating: true,\n capabilities: ['fs.write'],\n icon: 'edit',\n timeoutMs: 30_000,\n inputSchema: {\n type: 'object',\n properties: {\n pattern: { type: 'string', description: 'Regex pattern to match' },\n replacement: { type: 'string', description: 'Replacement string' },\n files: {\n type: 'string',\n description: 'File(s) to target: single path, comma-separated list, or glob pattern',\n },\n glob: { type: 'string', description: 'Additional glob filter (e.g. \"*.ts\")' },\n replace_all: {\n type: 'boolean',\n description: 'Replace all occurrences in each file (default: true)',\n },\n dry_run: { type: 'boolean', description: 'Preview changes without writing' },\n },\n required: ['pattern', 'replacement', 'files'],\n },\n async execute(input: ReplaceInput, ctx: Context) {\n if (!input?.pattern) throw new Error('replace: pattern is required');\n if (input.replacement === undefined) throw new Error('replace: replacement is required');\n if (!input?.files) throw new Error('replace: files is required');\n\n const replaceAll = input.replace_all ?? true;\n // Always compile with 'g' so matchAll() works — matchAll throws\n // TypeError on non-global regexes. The replaceAll flag controls\n // how many matches we act on, not whether the regex is global.\n const compiled = compileUserRegex(input.pattern, 'g');\n if (!compiled.ok) {\n throw new Error(`replace: ${compiled.reason}`);\n }\n const re = compiled.regex;\n const globRe = input.glob ? compileGlob(input.glob) : null;\n const dryRun = input.dry_run ?? false;\n\n const filesInput = Array.isArray(input.files) ? input.files.join(',') : input.files;\n const fileList = await resolveFiles(filesInput, ctx, globRe);\n\n // Resolve the project root through realpath ONCE so the sandbox check\n // below compares like-for-like with realpath(file). The project root\n // itself can be a symlink or short name — e.g. macOS temp dirs live under\n // /var -> /private/var, and Windows CI runners expose an 8.3 short name\n // (C:\\Users\\RUNNER~1\\...). Comparing realpath(file) against the raw root\n // then makes every legitimately-inside file look \"outside\" and skips it.\n const realRoot = await fs.realpath(ctx.projectRoot).catch(() => ctx.projectRoot);\n\n const results: ReplaceOutput['results'] = [];\n let totalReplacements = 0;\n\n for (const absPath of fileList) {\n // Use lstat to detect symlinks. resolveFiles already applies\n // safeResolve, but a symlink with a target outside the project\n // root would still pass that string check — explicitly skip it\n // so we never read or write through a link.\n const lstat = await fs.lstat(absPath).catch((err) => {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;\n /* v8 ignore next -- non-ENOENT lstat failure (EACCES etc.) is a defensive rethrow. */\n throw err;\n });\n if (!lstat || !lstat.isFile()) continue;\n if (lstat.isSymbolicLink()) continue;\n\n // Cross-check via realpath: if the resolved target lives outside the\n // project root (e.g. a bind mount or a parent-dir traversal we missed),\n // skip rather than rewrite through it.\n let realPath: string;\n try {\n realPath = await fs.realpath(absPath);\n } catch {\n /* v8 ignore next -- realpath failing after a successful lstat is a TOCTOU race; defensive. */\n continue;\n }\n const rel = path.relative(realRoot, realPath);\n if (rel.startsWith('..') || path.isAbsolute(rel)) continue;\n\n // Now stat the real target so we use its mode for atomicWrite.\n const stat = await fs.stat(realPath).catch(() => null);\n if (!stat || !stat.isFile()) continue;\n\n let content: string;\n try {\n const buf = await fs.readFile(realPath);\n if (isBinaryBuffer(buf)) continue;\n content = buf.toString('utf8');\n } catch {\n /* v8 ignore next -- readFile failing after a successful stat is a TOCTOU race; defensive. */\n continue;\n }\n\n const style = detectNewlineStyle(content);\n const contentLf = normalizeToLf(content);\n re.lastIndex = 0;\n const allMatches = [...contentLf.matchAll(re)];\n if (allMatches.length === 0) continue;\n\n // When replace_all is false, only act on the first match.\n const matches = replaceAll ? allMatches : allMatches.slice(0, 1);\n const count = matches.length;\n\n // Rebuild: splice the replacement into each match position from\n // right to left so earlier indices stay valid.\n let newContentLf = contentLf;\n for (let i = matches.length - 1; i >= 0; i--) {\n const m = expectDefined(matches[i]);\n newContentLf =\n newContentLf.slice(0, m.index) +\n input.replacement +\n newContentLf.slice(expectDefined(m.index) + m[0].length);\n }\n re.lastIndex = 0;\n totalReplacements += count;\n\n if (!dryRun) {\n const newContent = toStyle(newContentLf, style);\n // Write to the real path (already validated inside project root)\n // so atomicWrite's temp-and-rename can't be redirected through a\n // freshly-planted symlink at absPath.\n await atomicWrite(realPath, newContent, { mode: stat.mode & 0o777 });\n }\n\n const diff =\n dryRun || matches.length > 0\n ? unifiedDiff(content, toStyle(newContentLf, style), {\n fromFile: absPath,\n toFile: absPath,\n })\n : undefined;\n\n results.push({\n path: absPath,\n replacements: matches.length,\n diff,\n });\n }\n\n return {\n files_modified: results.length,\n total_replacements: totalReplacements,\n results,\n dry_run: dryRun,\n };\n },\n};\n\nasync function resolveFiles(\n filesInput: string,\n ctx: Context,\n extraGlob?: RegExp | null | undefined,\n): Promise<string[]> {\n const base = ctx.cwd;\n const normalized = filesInput.trim();\n\n if (normalized.startsWith('**/') || normalized.startsWith('*') || normalized.includes('**')) {\n return await globFiles(normalized, base, extraGlob);\n }\n\n const parts = normalized\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n const resolved: string[] = [];\n\n for (const p of parts) {\n const absPath = safeResolve(p, ctx);\n const stat = await fs.stat(absPath).catch(() => null);\n if (stat?.isFile()) {\n resolved.push(absPath);\n }\n }\n\n return resolved;\n}\n\nasync function globFiles(\n pattern: string,\n base: string,\n extraGlob?: RegExp | null | undefined,\n): Promise<string[]> {\n\n const rgAvailable = await checkRg();\n if (rgAvailable) {\n try {\n const { promise } = spawnRgFind(pattern, base);\n return await promise;\n } catch {\n // fall through\n }\n }\n\n return await globNative(pattern, base, extraGlob);\n}\n\nfunction checkRg(): Promise<boolean> {\n return new Promise((resolve) => {\n try {\n const p = spawn('rg', ['--version'], { env: buildChildEnv(), stdio: 'ignore', windowsHide: true });\n p.on('error', () => resolve(false));\n p.on('close', (code) => resolve(code === 0));\n } catch {\n resolve(false);\n }\n });\n}\n\nfunction spawnRgFind(pattern: string, base: string): { promise: Promise<string[]> } {\n const args = ['--files', '--glob', pattern, base];\n // 30-second safety net to prevent zombie rg processes. Unlike the main\n // grep tool, glob file enumeration is fast and should never need more time.\n const child = spawn('rg', args, {\n signal: AbortSignal.timeout(30_000),\n env: buildChildEnv(),\n stdio: ['ignore', 'pipe', 'pipe'],\n windowsHide: true,\n });\n let buf = '';\n child.stdout?.on('data', (chunk: Buffer) => {\n buf += chunk.toString();\n });\n return {\n promise: new Promise((resolve, reject) => {\n child.on('error', reject);\n child.on('close', () => {\n resolve(buf.split('\\n').filter(Boolean));\n });\n }),\n };\n}\n\nasync function globNative(\n pattern: string,\n base: string,\n extraGlob?: RegExp | null | undefined,\n): Promise<string[]> {\n const results: string[] = [];\n const globRe = compileGlob(pattern);\n\n const walk = async (dir: string): Promise<void> => {\n let entries: import('node:fs').Dirent[];\n try {\n entries = await fs.readdir(dir, { withFileTypes: true });\n } catch {\n /* v8 ignore next -- unreadable directory during the walk; defensive. */\n return;\n }\n for (const e of entries) {\n if (DEFAULT_IGNORE.includes(e.name)) continue;\n const full = path.join(dir, e.name);\n // Dirent.isSymbolicLink() uses readdir's d_type, which may not detect\n // directory symlinks on Windows (d_type = DT_UNKNOWN). Defensive stat\n // call: skip any entry whose lstat shows a symlink — file or directory.\n try {\n const stat = await fs.lstat(full);\n if (stat.isSymbolicLink()) continue;\n } catch {\n // lstat fails for very unusual entries (e.g. broken symlinks to deleted\n // files on NFS); skip safely rather than surfacing an error.\n /* v8 ignore next -- lstat failing on a readdir entry is a rare NFS/race case; defensive. */\n continue;\n }\n if (e.isDirectory()) {\n await walk(full);\n } else if (e.isFile()) {\n const name = e.name;\n if (globRe.test(name) || globRe.test(full)) {\n if (extraGlob && !extraGlob.test(name) && !extraGlob.test(full)) continue;\n results.push(full);\n }\n globRe.lastIndex = 0;\n if (extraGlob) extraGlob.lastIndex = 0;\n }\n }\n };\n\n await walk(base);\n return results;\n}\n"]}
package/dist/scaffold.js CHANGED
@@ -1,20 +1,26 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
+ import * as Core from '@wrongstack/core';
3
4
  import { atomicWrite } from '@wrongstack/core';
4
5
 
5
6
  // src/scaffold.ts
6
7
  function resolvePath(input, ctx) {
7
8
  return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);
8
9
  }
10
+ function allowedRoots(ctx) {
11
+ return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];
12
+ }
13
+ function isInsideAny(target, roots) {
14
+ return roots.some((root) => {
15
+ const rel = path.relative(root, target);
16
+ return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
17
+ });
18
+ }
9
19
  function ensureInsideRoot(absPath, ctx) {
10
- if (ctx.allowOutsideProjectRoot) return path.resolve(absPath);
11
- const root = path.resolve(ctx.projectRoot);
12
20
  const target = path.resolve(absPath);
13
- const rel = path.relative(root, target);
14
- if (rel.startsWith("..") || path.isAbsolute(rel)) {
15
- throw new Error(`Path "${absPath}" is outside project root "${root}"`);
16
- }
17
- return target;
21
+ if (ctx.allowOutsideProjectRoot) return target;
22
+ if (isInsideAny(target, allowedRoots(ctx))) return target;
23
+ throw new Error(`Path "${absPath}" is outside project root "${path.resolve(ctx.projectRoot)}"`);
18
24
  }
19
25
  function safeResolve(input, ctx) {
20
26
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
@@ -120,6 +126,7 @@ var scaffoldTool = {
120
126
  permission: "confirm",
121
127
  mutating: true,
122
128
  capabilities: ["fs.write.outside-project", "fs.write"],
129
+ icon: "scaffold",
123
130
  timeoutMs: 3e4,
124
131
  inputSchema: {
125
132
  type: "object",