@theokit/sdk-tools 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.1
4
+
5
+ ### Patch Changes
6
+
7
+ - apply_patch (V4A) M18 review fixes — a security-critical writing tool hardened after adversarial review:
8
+ - **Security:** the forbidden-secret guard now blocks `.env`/`.git`/`node_modules`/`.theo` at ANY path
9
+ depth (not just the first segment) and defeats absolute-path spelling (`<root>/.env`) — closing a hole
10
+ where a nested `sub/.git/hooks/…` or an absolute secret path could be written.
11
+ - **Contract:** every fs error (`EISDIR`/`ENOTDIR`/`EACCES`/…) maps to a typed `{ ok: false, error: 'io_error' }`
12
+ instead of throwing out of the handler (the "always JSON" contract now holds).
13
+ - **Atomicity:** a file touched by two hunks is rejected (`duplicate_target`) — no silent lost-update.
14
+ - **Safety:** Add over an existing file is rejected (`file_exists`); Delete of a missing file is `not_found`.
15
+ - **Matching:** `*** End of File` edits of the last line now apply (eof anchoring made a hint with a
16
+ general-search fallback, fixing the phantom-trailing-newline case).
17
+
3
18
  ## 0.20.0
4
19
 
5
20
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -4,105 +4,17 @@ var promises = require('fs/promises');
4
4
  var path = require('path');
5
5
  var sdk = require('@theokit/sdk');
6
6
  var zod = require('zod');
7
- var fs = require('fs');
8
7
  var pathSafety = require('@theokit/sdk/path-safety');
9
8
  var persistence = require('@theokit/sdk/persistence');
10
9
  var filesystem = require('@theokit/sdk/filesystem');
11
10
  var child_process = require('child_process');
11
+ var fs = require('fs');
12
12
  var sandbox = require('@theokit/sdk/sandbox');
13
13
  var interactive = require('@theokit/sdk/interactive');
14
14
  var promises$1 = require('dns/promises');
15
15
  var net = require('net');
16
16
 
17
17
  // src/apply-patch.ts
18
- var PathTraversalError = class extends sdk.ConfigurationError {
19
- name = "PathTraversalError";
20
- constructor(input, resolvedPath) {
21
- super(`Path traversal attempt: ${input} \u2192 ${resolvedPath}`, {
22
- code: "path_traversal"
23
- });
24
- }
25
- };
26
- var ForbiddenPathError = class extends sdk.ConfigurationError {
27
- name = "ForbiddenPathError";
28
- constructor(path) {
29
- super(
30
- `Path '${path}' is in the sensitive-file blocklist (.env, .git/, node_modules/, .theo/, lock files)`,
31
- {
32
- code: "forbidden_path"
33
- }
34
- );
35
- }
36
- };
37
- function safePathJoin(base, ...parts) {
38
- if (base === "") {
39
- throw new Error("safePathJoin: base must be non-empty");
40
- }
41
- const baseResolved = path.resolve(base);
42
- const target = path.resolve(base, ...parts);
43
- if (target !== baseResolved && !target.startsWith(baseResolved + path.sep)) {
44
- throw new PathTraversalError(parts.join("/"), target);
45
- }
46
- return target;
47
- }
48
- function assertNoSymlinkEscape(path$1, base) {
49
- let baseResolved;
50
- try {
51
- baseResolved = fs.realpathSync(base);
52
- } catch {
53
- baseResolved = path.resolve(base);
54
- }
55
- const resolved = realpathOfDeepestExisting(path$1);
56
- if (resolved === void 0) return;
57
- if (resolved !== baseResolved && !resolved.startsWith(baseResolved + path.sep)) {
58
- throw new PathTraversalError(`symlink ${path$1}`, resolved);
59
- }
60
- }
61
- function realpathOfDeepestExisting(path$1) {
62
- try {
63
- return fs.realpathSync(path$1);
64
- } catch {
65
- }
66
- try {
67
- const stat2 = fs.lstatSync(path$1);
68
- if (stat2.isSymbolicLink()) {
69
- const target = fs.readlinkSync(path$1);
70
- const parentReal = realpathOfDeepestExisting(path.dirname(path$1));
71
- const parentBase = parentReal ?? path.dirname(path$1);
72
- return path.resolve(parentBase, target);
73
- }
74
- } catch {
75
- }
76
- let cursor = path.dirname(path$1);
77
- let suffix = path$1.slice(cursor.length);
78
- while (cursor !== path.dirname(cursor)) {
79
- try {
80
- const real = fs.realpathSync(cursor);
81
- return path.resolve(real, `.${suffix}`);
82
- } catch {
83
- suffix = path$1.slice(path.dirname(cursor).length);
84
- cursor = path.dirname(cursor);
85
- }
86
- }
87
- return void 0;
88
- }
89
- var LOCK_FILES = /* @__PURE__ */ new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
90
- function isForbiddenPath(input) {
91
- const normalized = input.replace(/\\/g, "/").replace(/^\.\//, "");
92
- if (normalized.length === 0) return false;
93
- const segments = normalized.split("/").filter((s) => s.length > 0);
94
- if (segments.length === 0) return false;
95
- const first = segments[0];
96
- if (first === ".env.example") return false;
97
- if (first === ".env") return true;
98
- if (/^\.env\./.test(first)) return true;
99
- if (first === ".git") return true;
100
- if (first === "node_modules") return true;
101
- if (first === ".theo") return true;
102
- const basename = segments[segments.length - 1];
103
- if (LOCK_FILES.has(basename)) return true;
104
- return false;
105
- }
106
18
 
107
19
  // src/internal/v4a-patch.ts
108
20
  var BEGIN = "*** Begin Patch";
@@ -267,7 +179,14 @@ function matchesAt(lines, pattern, at, eq) {
267
179
  function seekSequence(lines, pattern, searchStart, eof) {
268
180
  if (pattern.length === 0) return searchStart;
269
181
  if (pattern.length > lines.length) return null;
270
- const start = eof ? Math.max(searchStart, lines.length - pattern.length) : searchStart;
182
+ const starts = eof ? [Math.max(searchStart, lines.length - pattern.length), searchStart] : [searchStart];
183
+ for (const start of starts) {
184
+ const hit = searchFrom(lines, pattern, start);
185
+ if (hit !== null) return hit;
186
+ }
187
+ return null;
188
+ }
189
+ function searchFrom(lines, pattern, start) {
271
190
  for (const eq of LADDER) {
272
191
  for (let i = start; i <= lines.length - pattern.length; i++) {
273
192
  if (matchesAt(lines, pattern, i, eq)) return i;
@@ -324,12 +243,28 @@ async function applyV4APatch(projectRoot, patch) {
324
243
  const detail = err instanceof V4APatchError ? err.message : String(err);
325
244
  return JSON.stringify({ ok: false, error: "parse_error", detail });
326
245
  }
327
- const plan = await buildPlan(projectRoot, hunks);
328
- if ("error" in plan) return plan.error;
329
- await executePlan(plan.ops);
330
- return JSON.stringify({ ok: true, files_patched: plan.patched });
246
+ try {
247
+ const plan = await buildPlan(projectRoot, hunks);
248
+ if ("error" in plan) return plan.error;
249
+ await executePlan(plan.ops);
250
+ return JSON.stringify({ ok: true, files_patched: plan.patched });
251
+ } catch (err) {
252
+ return JSON.stringify({
253
+ ok: false,
254
+ error: "io_error",
255
+ detail: err instanceof Error ? err.message : String(err)
256
+ });
257
+ }
258
+ }
259
+ function hunkTargets(hunk) {
260
+ if (hunk.kind === "update" && hunk.movePath) return [hunk.path, hunk.movePath];
261
+ return [hunk.path];
331
262
  }
332
263
  async function buildPlan(projectRoot, hunks) {
264
+ const dup = firstDuplicateTarget(hunks);
265
+ if (dup !== null) {
266
+ return { error: JSON.stringify({ ok: false, error: "duplicate_target", path: dup }) };
267
+ }
333
268
  const ops = [];
334
269
  const patched = [];
335
270
  for (const hunk of hunks) {
@@ -340,6 +275,16 @@ async function buildPlan(projectRoot, hunks) {
340
275
  }
341
276
  return { ops, patched };
342
277
  }
278
+ function firstDuplicateTarget(hunks) {
279
+ const seen = /* @__PURE__ */ new Set();
280
+ for (const hunk of hunks) {
281
+ for (const t of hunkTargets(hunk)) {
282
+ if (seen.has(t)) return t;
283
+ seen.add(t);
284
+ }
285
+ }
286
+ return null;
287
+ }
343
288
  async function executePlan(ops) {
344
289
  for (const op of ops) {
345
290
  if ("rm" in op) {
@@ -350,11 +295,29 @@ async function executePlan(ops) {
350
295
  }
351
296
  }
352
297
  }
298
+ async function pathExists(abs) {
299
+ try {
300
+ await promises.stat(abs);
301
+ return true;
302
+ } catch {
303
+ return false;
304
+ }
305
+ }
353
306
  async function planHunk(projectRoot, hunk) {
354
307
  const scope = v4aScope(projectRoot, hunk.path);
355
308
  if ("error" in scope) return scope;
356
- if (hunk.kind === "add") return { ops: [{ write: { abs: scope.abs, content: hunk.content } }] };
357
- if (hunk.kind === "delete") return { ops: [{ rm: scope.abs }] };
309
+ if (hunk.kind === "add") {
310
+ if (await pathExists(scope.abs)) {
311
+ return { error: JSON.stringify({ ok: false, error: "file_exists", path: hunk.path }) };
312
+ }
313
+ return { ops: [{ write: { abs: scope.abs, content: hunk.content } }] };
314
+ }
315
+ if (hunk.kind === "delete") {
316
+ if (!await pathExists(scope.abs)) {
317
+ return { error: JSON.stringify({ ok: false, error: "not_found", path: hunk.path }) };
318
+ }
319
+ return { ops: [{ rm: scope.abs }] };
320
+ }
358
321
  return planUpdate(projectRoot, hunk, scope.abs);
359
322
  }
360
323
  async function planUpdate(projectRoot, hunk, abs) {
@@ -388,20 +351,25 @@ async function planUpdate(projectRoot, hunk, abs) {
388
351
  if ("error" in dest) return dest;
389
352
  return { ops: [{ write: { abs: dest.abs, content: updated } }, { rm: abs }] };
390
353
  }
354
+ var FORBIDDEN_SEGMENTS = /* @__PURE__ */ new Set([".env", ".git", "node_modules", ".theo"]);
355
+ function isForbiddenRel(rel) {
356
+ return rel.split(/[/\\]/).filter(Boolean).some((s) => s !== ".env.example" && (FORBIDDEN_SEGMENTS.has(s) || /^\.env\./.test(s)));
357
+ }
391
358
  function v4aScope(projectRoot, file) {
392
- if (isForbiddenPath(file)) {
393
- return { error: JSON.stringify({ ok: false, error: "forbidden_path", path: file }) };
394
- }
359
+ let abs;
395
360
  try {
396
- const abs = safePathJoin(projectRoot, file);
397
- assertNoSymlinkEscape(abs, projectRoot);
398
- return { abs };
361
+ abs = pathSafety.safePathJoin(projectRoot, file);
362
+ pathSafety.assertNoSymlinkEscape(abs, projectRoot);
399
363
  } catch (err) {
400
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
364
+ if (err instanceof pathSafety.PathTraversalError || err instanceof pathSafety.ForbiddenPathError) {
401
365
  return { error: JSON.stringify({ ok: false, error: "path_traversal", path: file }) };
402
366
  }
403
367
  throw err;
404
368
  }
369
+ if (pathSafety.isForbiddenPath(file) || isForbiddenRel(path.relative(projectRoot, abs))) {
370
+ return { error: JSON.stringify({ ok: false, error: "forbidden_path", path: file }) };
371
+ }
372
+ return { abs };
405
373
  }
406
374
  function createSessionArtifactStore(options) {
407
375
  const { dir } = options;
@@ -609,7 +577,7 @@ async function editViaBackend(filesystem$1, ctx, path, old_string, new_string) {
609
577
  return JSON.stringify({ ok: true, replacements: 1 });
610
578
  }
611
579
  async function editViaLocal(projectRoot, path, old_string, new_string) {
612
- const absolutePath = safePathJoin(projectRoot, path);
580
+ const absolutePath = pathSafety.safePathJoin(projectRoot, path);
613
581
  let content;
614
582
  try {
615
583
  content = await promises.readFile(absolutePath, "utf-8");
@@ -627,14 +595,14 @@ async function editViaLocal(projectRoot, path, old_string, new_string) {
627
595
  return JSON.stringify({ ok: true, replacements: 1 });
628
596
  }
629
597
  function editScopeError(path, projectRoot) {
630
- if (isForbiddenPath(path)) {
598
+ if (pathSafety.isForbiddenPath(path)) {
631
599
  return JSON.stringify({ ok: false, error: "forbidden_path", path });
632
600
  }
633
601
  try {
634
- assertNoSymlinkEscape(safePathJoin(projectRoot, path), projectRoot);
602
+ pathSafety.assertNoSymlinkEscape(pathSafety.safePathJoin(projectRoot, path), projectRoot);
635
603
  return null;
636
604
  } catch (err) {
637
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
605
+ if (err instanceof pathSafety.PathTraversalError || err instanceof pathSafety.ForbiddenPathError) {
638
606
  return JSON.stringify({ ok: false, error: "path_traversal", path });
639
607
  }
640
608
  throw err;
@@ -720,11 +688,11 @@ function formatError(message, code) {
720
688
  function checkPathScope(path, projectRoot) {
721
689
  if (path === void 0 || path === "") return null;
722
690
  try {
723
- const abs = safePathJoin(projectRoot, path);
724
- assertNoSymlinkEscape(abs, projectRoot);
691
+ const abs = pathSafety.safePathJoin(projectRoot, path);
692
+ pathSafety.assertNoSymlinkEscape(abs, projectRoot);
725
693
  return null;
726
694
  } catch (err) {
727
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
695
+ if (err instanceof pathSafety.PathTraversalError || err instanceof pathSafety.ForbiddenPathError) {
728
696
  return JSON.stringify({ ok: false, error: "path_traversal", path });
729
697
  }
730
698
  throw err;
@@ -744,25 +712,25 @@ function createSettleGate(timer) {
744
712
  }
745
713
  };
746
714
  }
747
- function armTimeoutKill(child, timeoutMs, onTimeout, resolve2) {
715
+ function armTimeoutKill(child, timeoutMs, onTimeout, resolve) {
748
716
  const timer = setTimeout(() => {
749
717
  gate.fire(() => {
750
718
  try {
751
719
  process.kill(-(child.pid ?? 0), "SIGKILL");
752
720
  } catch {
753
721
  }
754
- resolve2(onTimeout());
722
+ resolve(onTimeout());
755
723
  });
756
724
  }, timeoutMs);
757
725
  const gate = createSettleGate(timer);
758
726
  return gate;
759
727
  }
760
- function attachChildSettlers(child, gate, onClose, onError, resolve2) {
728
+ function attachChildSettlers(child, gate, onClose, onError, resolve) {
761
729
  child.on("close", (code) => {
762
- gate.fire(() => resolve2(onClose(code)));
730
+ gate.fire(() => resolve(onClose(code)));
763
731
  });
764
732
  child.on("error", (err) => {
765
- gate.fire(() => resolve2(onError(err)));
733
+ gate.fire(() => resolve(onError(err)));
766
734
  });
767
735
  }
768
736
 
@@ -829,7 +797,7 @@ function formatGitResult(result, timeoutMs) {
829
797
  return JSON.stringify({ ok: true, diff: result.stdout, truncated: result.truncated });
830
798
  }
831
799
  function runGitProcess(cwd, args, timeoutMs, maxStdoutBytes) {
832
- return new Promise((resolve2) => {
800
+ return new Promise((resolve) => {
833
801
  const child = child_process.spawn("git", args, { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] });
834
802
  const stdoutChunks = [];
835
803
  const stderrChunks = [];
@@ -839,7 +807,7 @@ function runGitProcess(cwd, args, timeoutMs, maxStdoutBytes) {
839
807
  child,
840
808
  timeoutMs,
841
809
  () => ({ kind: "timeout" }),
842
- resolve2
810
+ resolve
843
811
  );
844
812
  child.stdout.on("data", (chunk) => {
845
813
  if (gate.settled()) return;
@@ -869,7 +837,7 @@ function runGitProcess(cwd, args, timeoutMs, maxStdoutBytes) {
869
837
  return code === 0 ? { kind: "ok", stdout, truncated } : { kind: "error", stderr };
870
838
  },
871
839
  (err) => ({ kind: "error", stderr: err.message }),
872
- resolve2
840
+ resolve
873
841
  );
874
842
  });
875
843
  }
@@ -895,7 +863,7 @@ function createGlobTool(opts) {
895
863
  await walkDirBackend(backend, searchRel, searchRel, regex, found, 0);
896
864
  return JSON.stringify({ ok: true, files: found.sort(), count: found.length });
897
865
  }
898
- const searchRoot = cwd ? safePathJoin(projectRoot, cwd) : projectRoot;
866
+ const searchRoot = cwd ? pathSafety.safePathJoin(projectRoot, cwd) : projectRoot;
899
867
  const files = [];
900
868
  await walkDir(searchRoot, searchRoot, regex, files);
901
869
  const relativePaths = files.map((f) => path.relative(projectRoot, f)).sort();
@@ -906,10 +874,10 @@ function createGlobTool(opts) {
906
874
  function globScopeError(cwd, projectRoot) {
907
875
  if (!cwd) return null;
908
876
  try {
909
- assertNoSymlinkEscape(safePathJoin(projectRoot, cwd), projectRoot);
877
+ pathSafety.assertNoSymlinkEscape(pathSafety.safePathJoin(projectRoot, cwd), projectRoot);
910
878
  return null;
911
879
  } catch (err) {
912
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
880
+ if (err instanceof pathSafety.PathTraversalError || err instanceof pathSafety.ForbiddenPathError) {
913
881
  return JSON.stringify({ ok: false, error: "path_traversal", path: cwd });
914
882
  }
915
883
  throw err;
@@ -1548,29 +1516,29 @@ function createListDirTool(opts) {
1548
1516
  path: zod.z.string().min(1).describe("Project-relative directory path. Use '.' for root.")
1549
1517
  }),
1550
1518
  handler: async ({ path }, ctx) => {
1551
- const relative2 = path === "" || path === "." ? "." : path;
1552
- if (relative2 !== "." && isForbiddenPath(relative2)) {
1519
+ const relative3 = path === "" || path === "." ? "." : path;
1520
+ if (relative3 !== "." && pathSafety.isForbiddenPath(relative3)) {
1553
1521
  return JSON.stringify({ ok: false, error: "forbidden_path", path });
1554
1522
  }
1555
1523
  if (filesystem$1) {
1556
1524
  const backend = await filesystem.resolveFilesystem(filesystem$1, ctx ?? {});
1557
- return listViaBackend(backend, relative2, path, max);
1525
+ return listViaBackend(backend, relative3, path, max);
1558
1526
  }
1559
- return listViaLocalFs(projectRoot, relative2, path, max);
1527
+ return listViaLocalFs(projectRoot, relative3, path, max);
1560
1528
  }
1561
1529
  });
1562
1530
  }
1563
- async function listViaLocalFs(projectRoot, relative2, originalPath, max) {
1564
- const boundary = resolveDirBoundary(relative2, projectRoot, originalPath);
1531
+ async function listViaLocalFs(projectRoot, relative3, originalPath, max) {
1532
+ const boundary = resolveDirBoundary(relative3, projectRoot, originalPath);
1565
1533
  if ("error" in boundary) return boundary.error;
1566
1534
  const readResult = await readDirSafe(boundary.absolutePath, originalPath);
1567
1535
  if ("error" in readResult) return readResult.error;
1568
1536
  return formatListing(readResult.dirents, max);
1569
1537
  }
1570
- async function listViaBackend(backend, relative2, originalPath, max) {
1538
+ async function listViaBackend(backend, relative3, originalPath, max) {
1571
1539
  let names;
1572
1540
  try {
1573
- names = await backend.list(relative2);
1541
+ names = await backend.list(relative3);
1574
1542
  } catch (err) {
1575
1543
  if (err instanceof filesystem.FileNotFoundError) {
1576
1544
  return JSON.stringify({ ok: false, error: "not_found", path: originalPath });
@@ -1584,7 +1552,7 @@ async function listViaBackend(backend, relative2, originalPath, max) {
1584
1552
  const windowed = names.slice(0, max);
1585
1553
  const entries = await Promise.all(
1586
1554
  windowed.map(async (name) => {
1587
- const child = relative2 === "." ? name : `${relative2}/${name}`;
1555
+ const child = relative3 === "." ? name : `${relative3}/${name}`;
1588
1556
  let type = "file";
1589
1557
  try {
1590
1558
  type = (await backend.stat(child)).isDirectory ? "directory" : "file";
@@ -1595,13 +1563,13 @@ async function listViaBackend(backend, relative2, originalPath, max) {
1595
1563
  );
1596
1564
  return JSON.stringify({ ok: true, entries, truncated: totalCount > max, totalCount });
1597
1565
  }
1598
- function resolveDirBoundary(relative2, projectRoot, originalPath) {
1566
+ function resolveDirBoundary(relative3, projectRoot, originalPath) {
1599
1567
  try {
1600
- const absolutePath = relative2 === "." ? projectRoot : safePathJoin(projectRoot, relative2);
1601
- assertNoSymlinkEscape(absolutePath, projectRoot);
1568
+ const absolutePath = relative3 === "." ? projectRoot : pathSafety.safePathJoin(projectRoot, relative3);
1569
+ pathSafety.assertNoSymlinkEscape(absolutePath, projectRoot);
1602
1570
  return { absolutePath };
1603
1571
  } catch (err) {
1604
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
1572
+ if (err instanceof pathSafety.PathTraversalError || err instanceof pathSafety.ForbiddenPathError) {
1605
1573
  return { error: JSON.stringify({ ok: false, error: "path_traversal", path: originalPath }) };
1606
1574
  }
1607
1575
  throw err;
@@ -1763,7 +1731,7 @@ function isForbiddenAtAnyDepth(path) {
1763
1731
  });
1764
1732
  }
1765
1733
  function forbiddenReadError(path$1, allowAbsolute) {
1766
- if (isForbiddenPath(path$1)) {
1734
+ if (pathSafety.isForbiddenPath(path$1)) {
1767
1735
  return JSON.stringify({ ok: false, error: "forbidden_path", path: path$1 });
1768
1736
  }
1769
1737
  if (allowAbsolute && path.isAbsolute(path$1) && isForbiddenAtAnyDepth(path$1)) {
@@ -1819,22 +1787,22 @@ function createReadFileTool(opts) {
1819
1787
  }
1820
1788
  async function readViaBackend(backend, path, view, onRead) {
1821
1789
  try {
1822
- const stat2 = await backend.stat(path);
1823
- if (stat2.size > MAX_FILE_SIZE) {
1790
+ const stat3 = await backend.stat(path);
1791
+ if (stat3.size > MAX_FILE_SIZE) {
1824
1792
  return JSON.stringify({
1825
1793
  ok: false,
1826
1794
  error: "too_large",
1827
1795
  path,
1828
- size: stat2.size,
1796
+ size: stat3.size,
1829
1797
  limit: MAX_FILE_SIZE
1830
1798
  });
1831
1799
  }
1832
1800
  const raw = await backend.readFile(path);
1833
1801
  if (raw.includes("\0")) {
1834
- return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
1802
+ return JSON.stringify({ ok: false, error: "binary_file", path, size: stat3.size });
1835
1803
  }
1836
- onRead?.(stat2.mtimeMs);
1837
- return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat2.size });
1804
+ onRead?.(stat3.mtimeMs);
1805
+ return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat3.size });
1838
1806
  } catch (err) {
1839
1807
  if (err instanceof filesystem.FileNotFoundError) {
1840
1808
  return JSON.stringify({ ok: false, error: "not_found", path });
@@ -1850,11 +1818,11 @@ function resolveBoundary(path$1, projectRoot, allowAbsolute) {
1850
1818
  return { absolutePath: path$1 };
1851
1819
  }
1852
1820
  try {
1853
- const absolutePath = safePathJoin(projectRoot, path$1);
1854
- assertNoSymlinkEscape(absolutePath, projectRoot);
1821
+ const absolutePath = pathSafety.safePathJoin(projectRoot, path$1);
1822
+ pathSafety.assertNoSymlinkEscape(absolutePath, projectRoot);
1855
1823
  return { absolutePath };
1856
1824
  } catch (err) {
1857
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
1825
+ if (err instanceof pathSafety.PathTraversalError || err instanceof pathSafety.ForbiddenPathError) {
1858
1826
  return { error: JSON.stringify({ ok: false, error: "path_traversal", path: path$1 }) };
1859
1827
  }
1860
1828
  throw err;
@@ -1873,22 +1841,22 @@ async function openHandleSafe(absolutePath, path) {
1873
1841
  }
1874
1842
  }
1875
1843
  async function readContent(handle, path, view, onRead) {
1876
- const stat2 = await handle.stat();
1877
- if (stat2.size > MAX_FILE_SIZE) {
1844
+ const stat3 = await handle.stat();
1845
+ if (stat3.size > MAX_FILE_SIZE) {
1878
1846
  return JSON.stringify({
1879
1847
  ok: false,
1880
1848
  error: "too_large",
1881
1849
  path,
1882
- size: stat2.size,
1850
+ size: stat3.size,
1883
1851
  limit: MAX_FILE_SIZE
1884
1852
  });
1885
1853
  }
1886
- if (await isBinaryProbe(handle, Number(stat2.size))) {
1887
- return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
1854
+ if (await isBinaryProbe(handle, Number(stat3.size))) {
1855
+ return JSON.stringify({ ok: false, error: "binary_file", path, size: stat3.size });
1888
1856
  }
1889
1857
  const raw = await handle.readFile({ encoding: "utf-8" });
1890
- onRead?.(stat2.mtimeMs);
1891
- return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat2.size });
1858
+ onRead?.(stat3.mtimeMs);
1859
+ return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat3.size });
1892
1860
  }
1893
1861
  async function isBinaryProbe(handle, size) {
1894
1862
  const probeLen = Math.min(BINARY_PROBE_BYTES, size);
@@ -1976,7 +1944,7 @@ function createRunVitestTool(opts) {
1976
1944
  });
1977
1945
  }
1978
1946
  function validateVitestScope(path, projectRoot) {
1979
- if (path !== void 0 && path !== "" && isForbiddenPath(path)) {
1947
+ if (path !== void 0 && path !== "" && pathSafety.isForbiddenPath(path)) {
1980
1948
  return JSON.stringify({ ok: false, error: "forbidden_path", path });
1981
1949
  }
1982
1950
  return checkPathScope(path, projectRoot);
@@ -2022,7 +1990,7 @@ function appendCapped(chunks, chunk, current, cap) {
2022
1990
  return current + chunk.length;
2023
1991
  }
2024
1992
  function runProcess(cwd, command, args, timeoutMs, maxStdoutBytes) {
2025
- return new Promise((resolve2) => {
1993
+ return new Promise((resolve) => {
2026
1994
  const child = child_process.spawn(command, args, {
2027
1995
  cwd,
2028
1996
  detached: true,
@@ -2035,7 +2003,7 @@ function runProcess(cwd, command, args, timeoutMs, maxStdoutBytes) {
2035
2003
  child,
2036
2004
  timeoutMs,
2037
2005
  () => ({ kind: "timeout" }),
2038
- resolve2
2006
+ resolve
2039
2007
  );
2040
2008
  child.stdout.on("data", (chunk) => {
2041
2009
  if (gate.settled()) return;
@@ -2054,7 +2022,7 @@ function runProcess(cwd, command, args, timeoutMs, maxStdoutBytes) {
2054
2022
  exitCode: code ?? 0
2055
2023
  }),
2056
2024
  (err) => ({ kind: "spawn_error", message: err.message }),
2057
- resolve2
2025
+ resolve
2058
2026
  );
2059
2027
  });
2060
2028
  }
@@ -2135,11 +2103,11 @@ function resolveSearchScope(path$1, projectRoot, allowAbsolute) {
2135
2103
  return { scopeAbs: scopeRel };
2136
2104
  }
2137
2105
  try {
2138
- const scopeAbs = scopeRel === "." ? projectRoot : safePathJoin(projectRoot, scopeRel);
2139
- assertNoSymlinkEscape(scopeAbs, projectRoot);
2106
+ const scopeAbs = scopeRel === "." ? projectRoot : pathSafety.safePathJoin(projectRoot, scopeRel);
2107
+ pathSafety.assertNoSymlinkEscape(scopeAbs, projectRoot);
2140
2108
  return { scopeAbs };
2141
2109
  } catch (err) {
2142
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
2110
+ if (err instanceof pathSafety.PathTraversalError || err instanceof pathSafety.ForbiddenPathError) {
2143
2111
  return { error: JSON.stringify({ ok: false, error: "path_traversal", path: path$1 }) };
2144
2112
  }
2145
2113
  throw err;
@@ -2148,7 +2116,7 @@ function resolveSearchScope(path$1, projectRoot, allowAbsolute) {
2148
2116
  async function handleEntry(entry, absDir, state) {
2149
2117
  const entryAbs = path.join(absDir, entry.name);
2150
2118
  const entryRel = path.relative(state.projectRoot, entryAbs);
2151
- if (isForbiddenPath(entryRel)) return;
2119
+ if (pathSafety.isForbiddenPath(entryRel)) return;
2152
2120
  if (entry.isDirectory()) {
2153
2121
  await walk(entryAbs, state);
2154
2122
  return;
@@ -2214,10 +2182,10 @@ function resolveScopeRel(path$1, projectRoot, allowAbsolute) {
2214
2182
  if (scopeRel === "") return { rel: "" };
2215
2183
  if (allowAbsolute && path.isAbsolute(scopeRel)) return { rel: scopeRel };
2216
2184
  try {
2217
- assertNoSymlinkEscape(safePathJoin(projectRoot, scopeRel), projectRoot);
2185
+ pathSafety.assertNoSymlinkEscape(pathSafety.safePathJoin(projectRoot, scopeRel), projectRoot);
2218
2186
  return { rel: scopeRel };
2219
2187
  } catch (err) {
2220
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
2188
+ if (err instanceof pathSafety.PathTraversalError || err instanceof pathSafety.ForbiddenPathError) {
2221
2189
  return { error: JSON.stringify({ ok: false, error: "path_traversal", path: path$1 }) };
2222
2190
  }
2223
2191
  throw err;
@@ -2239,7 +2207,7 @@ async function walkBackend(backend, dirRel, state, depth) {
2239
2207
  }
2240
2208
  async function handleBackendEntry(backend, dirRel, name, state, depth) {
2241
2209
  const entryRel = dirRel === "" ? name : `${dirRel}/${name}`;
2242
- if (isForbiddenPath(entryRel)) return;
2210
+ if (pathSafety.isForbiddenPath(entryRel)) return;
2243
2211
  let st;
2244
2212
  try {
2245
2213
  st = await backend.stat(entryRel);
@@ -2305,7 +2273,7 @@ function createShellTool(opts) {
2305
2273
  });
2306
2274
  }
2307
2275
  function runShell(cwd, command, timeoutMs) {
2308
- return new Promise((resolve2) => {
2276
+ return new Promise((resolve) => {
2309
2277
  const child = child_process.spawn("/bin/sh", ["-c", command], {
2310
2278
  cwd,
2311
2279
  detached: true,
@@ -2319,7 +2287,7 @@ function runShell(cwd, command, timeoutMs) {
2319
2287
  child,
2320
2288
  timeoutMs,
2321
2289
  () => ({ kind: "timeout" }),
2322
- (result) => resolve2(formatResult(result, timeoutMs))
2290
+ (result) => resolve(formatResult(result, timeoutMs))
2323
2291
  );
2324
2292
  child.stdout.on("data", (chunk) => {
2325
2293
  if (gate.settled()) return;
@@ -2357,7 +2325,7 @@ function runShell(cwd, command, timeoutMs) {
2357
2325
  exitCode: code
2358
2326
  }),
2359
2327
  (err) => ({ kind: "error", message: err.message }),
2360
- (result) => resolve2(formatResult(result, timeoutMs))
2328
+ (result) => resolve(formatResult(result, timeoutMs))
2361
2329
  );
2362
2330
  });
2363
2331
  }
@@ -2739,7 +2707,7 @@ function createWriteFileTool(opts) {
2739
2707
  content: zod.z.string().describe("UTF-8 content to write.")
2740
2708
  }),
2741
2709
  handler: async ({ path, content }, ctx) => {
2742
- if (isForbiddenPath(path)) {
2710
+ if (pathSafety.isForbiddenPath(path)) {
2743
2711
  return JSON.stringify({ ok: false, error: "forbidden_path", path });
2744
2712
  }
2745
2713
  if (filesystem$1) {
@@ -2761,10 +2729,10 @@ function readBeforeWriteError(guard, path, currentMtimeMs) {
2761
2729
  async function writeViaLocalFs(projectRoot, path$1, content, guard) {
2762
2730
  let absolutePath;
2763
2731
  try {
2764
- absolutePath = safePathJoin(projectRoot, path$1);
2765
- assertNoSymlinkEscape(absolutePath, projectRoot);
2732
+ absolutePath = pathSafety.safePathJoin(projectRoot, path$1);
2733
+ pathSafety.assertNoSymlinkEscape(absolutePath, projectRoot);
2766
2734
  } catch (err) {
2767
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
2735
+ if (err instanceof pathSafety.PathTraversalError || err instanceof pathSafety.ForbiddenPathError) {
2768
2736
  return JSON.stringify({ ok: false, error: "path_traversal", path: path$1 });
2769
2737
  }
2770
2738
  throw err;
@@ -2804,12 +2772,12 @@ async function writeViaBackend(backend, path, content, guard) {
2804
2772
  if (rbw) return rbw;
2805
2773
  expectedMtime = current ?? void 0;
2806
2774
  }
2807
- const stat2 = await backend.writeFile(
2775
+ const stat3 = await backend.writeFile(
2808
2776
  path,
2809
2777
  content,
2810
2778
  expectedMtime !== void 0 ? { expectedMtime } : void 0
2811
2779
  );
2812
- return JSON.stringify({ ok: true, path, bytes: stat2.size });
2780
+ return JSON.stringify({ ok: true, path, bytes: stat3.size });
2813
2781
  } catch (err) {
2814
2782
  return backendErrorToJson(err, path);
2815
2783
  }
@@ -2837,8 +2805,8 @@ async function isBinaryFile(absolutePath) {
2837
2805
  return false;
2838
2806
  }
2839
2807
  try {
2840
- const stat2 = await handle.stat();
2841
- const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(stat2.size));
2808
+ const stat3 = await handle.stat();
2809
+ const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(stat3.size));
2842
2810
  if (probeLen <= 0) return false;
2843
2811
  const probe = Buffer.alloc(probeLen);
2844
2812
  const { bytesRead } = await handle.read(probe, 0, probeLen, 0);