@theokit/sdk-tools 0.20.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -14,9 +14,15 @@ import { InteractiveProvider } from '@theokit/sdk/interactive';
14
14
  * security-checked) before ANY write. A parse error, context mismatch, or path violation anywhere ⇒ typed
15
15
  * error and ZERO writes (stronger than Codex, which writes file-by-file and can leave partial writes).
16
16
  *
17
- * Return shape (always a JSON string):
17
+ * Return shape (always a JSON string — never throws on a bad patch):
18
18
  * - `{ ok: true, files_patched: string[] }`
19
- * - `{ ok: false, error: 'parse_error' | 'path_traversal' | 'forbidden_path' | 'not_found' | 'patch_failed' }`
19
+ * - `{ ok: false, error: 'parse_error' | 'path_traversal' | 'forbidden_path' | 'not_found' |
20
+ * 'patch_failed' | 'duplicate_target' | 'file_exists' | 'io_error' }`
21
+ *
22
+ * Security/robustness (M18 review): forbidden secrets (`.env`/`.git`/`node_modules`/`.theo`) are blocked
23
+ * at ANY path depth and against absolute-path spelling; a file touched by two hunks is rejected
24
+ * (`duplicate_target`); Add over an existing file is rejected (`file_exists`); Delete of a missing file
25
+ * is `not_found`; any unexpected fs error maps to `io_error` (the handler never throws).
20
26
  */
21
27
 
22
28
  interface CreateApplyPatchToolOptions {
package/dist/index.d.ts CHANGED
@@ -14,9 +14,15 @@ import { InteractiveProvider } from '@theokit/sdk/interactive';
14
14
  * security-checked) before ANY write. A parse error, context mismatch, or path violation anywhere ⇒ typed
15
15
  * error and ZERO writes (stronger than Codex, which writes file-by-file and can leave partial writes).
16
16
  *
17
- * Return shape (always a JSON string):
17
+ * Return shape (always a JSON string — never throws on a bad patch):
18
18
  * - `{ ok: true, files_patched: string[] }`
19
- * - `{ ok: false, error: 'parse_error' | 'path_traversal' | 'forbidden_path' | 'not_found' | 'patch_failed' }`
19
+ * - `{ ok: false, error: 'parse_error' | 'path_traversal' | 'forbidden_path' | 'not_found' |
20
+ * 'patch_failed' | 'duplicate_target' | 'file_exists' | 'io_error' }`
21
+ *
22
+ * Security/robustness (M18 review): forbidden secrets (`.env`/`.git`/`node_modules`/`.theo`) are blocked
23
+ * at ANY path depth and against absolute-path spelling; a file touched by two hunks is rejected
24
+ * (`duplicate_target`); Add over an existing file is rejected (`file_exists`); Delete of a missing file
25
+ * is `not_found`; any unexpected fs error maps to `io_error` (the handler never throws).
20
26
  */
21
27
 
22
28
  interface CreateApplyPatchToolOptions {
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { rm, mkdir, writeFile, readFile, readdir, open, stat, copyFile } from 'fs/promises';
2
- import { dirname, join, relative, isAbsolute, resolve, sep } from 'path';
2
+ import { dirname, relative, join, isAbsolute, resolve, sep } from 'path';
3
3
  import { Tool, ConfigurationError } from '@theokit/sdk';
4
4
  import { z } from 'zod';
5
5
  import { existsSync, statSync, mkdirSync, writeFileSync, realpathSync, readFileSync, lstatSync, readlinkSync, readdirSync } from 'fs';
@@ -62,8 +62,8 @@ function realpathOfDeepestExisting(path) {
62
62
  } catch {
63
63
  }
64
64
  try {
65
- const stat2 = lstatSync(path);
66
- if (stat2.isSymbolicLink()) {
65
+ const stat3 = lstatSync(path);
66
+ if (stat3.isSymbolicLink()) {
67
67
  const target = readlinkSync(path);
68
68
  const parentReal = realpathOfDeepestExisting(dirname(path));
69
69
  const parentBase = parentReal ?? dirname(path);
@@ -265,7 +265,14 @@ function matchesAt(lines, pattern, at, eq) {
265
265
  function seekSequence(lines, pattern, searchStart, eof) {
266
266
  if (pattern.length === 0) return searchStart;
267
267
  if (pattern.length > lines.length) return null;
268
- const start = eof ? Math.max(searchStart, lines.length - pattern.length) : searchStart;
268
+ const starts = eof ? [Math.max(searchStart, lines.length - pattern.length), searchStart] : [searchStart];
269
+ for (const start of starts) {
270
+ const hit = searchFrom(lines, pattern, start);
271
+ if (hit !== null) return hit;
272
+ }
273
+ return null;
274
+ }
275
+ function searchFrom(lines, pattern, start) {
269
276
  for (const eq of LADDER) {
270
277
  for (let i = start; i <= lines.length - pattern.length; i++) {
271
278
  if (matchesAt(lines, pattern, i, eq)) return i;
@@ -322,12 +329,28 @@ async function applyV4APatch(projectRoot, patch) {
322
329
  const detail = err instanceof V4APatchError ? err.message : String(err);
323
330
  return JSON.stringify({ ok: false, error: "parse_error", detail });
324
331
  }
325
- const plan = await buildPlan(projectRoot, hunks);
326
- if ("error" in plan) return plan.error;
327
- await executePlan(plan.ops);
328
- return JSON.stringify({ ok: true, files_patched: plan.patched });
332
+ try {
333
+ const plan = await buildPlan(projectRoot, hunks);
334
+ if ("error" in plan) return plan.error;
335
+ await executePlan(plan.ops);
336
+ return JSON.stringify({ ok: true, files_patched: plan.patched });
337
+ } catch (err) {
338
+ return JSON.stringify({
339
+ ok: false,
340
+ error: "io_error",
341
+ detail: err instanceof Error ? err.message : String(err)
342
+ });
343
+ }
344
+ }
345
+ function hunkTargets(hunk) {
346
+ if (hunk.kind === "update" && hunk.movePath) return [hunk.path, hunk.movePath];
347
+ return [hunk.path];
329
348
  }
330
349
  async function buildPlan(projectRoot, hunks) {
350
+ const dup = firstDuplicateTarget(hunks);
351
+ if (dup !== null) {
352
+ return { error: JSON.stringify({ ok: false, error: "duplicate_target", path: dup }) };
353
+ }
331
354
  const ops = [];
332
355
  const patched = [];
333
356
  for (const hunk of hunks) {
@@ -338,6 +361,16 @@ async function buildPlan(projectRoot, hunks) {
338
361
  }
339
362
  return { ops, patched };
340
363
  }
364
+ function firstDuplicateTarget(hunks) {
365
+ const seen = /* @__PURE__ */ new Set();
366
+ for (const hunk of hunks) {
367
+ for (const t of hunkTargets(hunk)) {
368
+ if (seen.has(t)) return t;
369
+ seen.add(t);
370
+ }
371
+ }
372
+ return null;
373
+ }
341
374
  async function executePlan(ops) {
342
375
  for (const op of ops) {
343
376
  if ("rm" in op) {
@@ -348,11 +381,29 @@ async function executePlan(ops) {
348
381
  }
349
382
  }
350
383
  }
384
+ async function pathExists(abs) {
385
+ try {
386
+ await stat(abs);
387
+ return true;
388
+ } catch {
389
+ return false;
390
+ }
391
+ }
351
392
  async function planHunk(projectRoot, hunk) {
352
393
  const scope = v4aScope(projectRoot, hunk.path);
353
394
  if ("error" in scope) return scope;
354
- if (hunk.kind === "add") return { ops: [{ write: { abs: scope.abs, content: hunk.content } }] };
355
- if (hunk.kind === "delete") return { ops: [{ rm: scope.abs }] };
395
+ if (hunk.kind === "add") {
396
+ if (await pathExists(scope.abs)) {
397
+ return { error: JSON.stringify({ ok: false, error: "file_exists", path: hunk.path }) };
398
+ }
399
+ return { ops: [{ write: { abs: scope.abs, content: hunk.content } }] };
400
+ }
401
+ if (hunk.kind === "delete") {
402
+ if (!await pathExists(scope.abs)) {
403
+ return { error: JSON.stringify({ ok: false, error: "not_found", path: hunk.path }) };
404
+ }
405
+ return { ops: [{ rm: scope.abs }] };
406
+ }
356
407
  return planUpdate(projectRoot, hunk, scope.abs);
357
408
  }
358
409
  async function planUpdate(projectRoot, hunk, abs) {
@@ -386,20 +437,25 @@ async function planUpdate(projectRoot, hunk, abs) {
386
437
  if ("error" in dest) return dest;
387
438
  return { ops: [{ write: { abs: dest.abs, content: updated } }, { rm: abs }] };
388
439
  }
440
+ var FORBIDDEN_SEGMENTS = /* @__PURE__ */ new Set([".env", ".git", "node_modules", ".theo"]);
441
+ function isForbiddenRel(rel) {
442
+ return rel.split(/[/\\]/).filter(Boolean).some((s) => s !== ".env.example" && (FORBIDDEN_SEGMENTS.has(s) || /^\.env\./.test(s)));
443
+ }
389
444
  function v4aScope(projectRoot, file) {
390
- if (isForbiddenPath(file)) {
391
- return { error: JSON.stringify({ ok: false, error: "forbidden_path", path: file }) };
392
- }
445
+ let abs;
393
446
  try {
394
- const abs = safePathJoin(projectRoot, file);
447
+ abs = safePathJoin(projectRoot, file);
395
448
  assertNoSymlinkEscape(abs, projectRoot);
396
- return { abs };
397
449
  } catch (err) {
398
450
  if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
399
451
  return { error: JSON.stringify({ ok: false, error: "path_traversal", path: file }) };
400
452
  }
401
453
  throw err;
402
454
  }
455
+ if (isForbiddenPath(file) || isForbiddenRel(relative(projectRoot, abs))) {
456
+ return { error: JSON.stringify({ ok: false, error: "forbidden_path", path: file }) };
457
+ }
458
+ return { abs };
403
459
  }
404
460
  function createSessionArtifactStore(options) {
405
461
  const { dir } = options;
@@ -1546,29 +1602,29 @@ function createListDirTool(opts) {
1546
1602
  path: z.string().min(1).describe("Project-relative directory path. Use '.' for root.")
1547
1603
  }),
1548
1604
  handler: async ({ path }, ctx) => {
1549
- const relative2 = path === "" || path === "." ? "." : path;
1550
- if (relative2 !== "." && isForbiddenPath(relative2)) {
1605
+ const relative3 = path === "" || path === "." ? "." : path;
1606
+ if (relative3 !== "." && isForbiddenPath(relative3)) {
1551
1607
  return JSON.stringify({ ok: false, error: "forbidden_path", path });
1552
1608
  }
1553
1609
  if (filesystem) {
1554
1610
  const backend = await resolveFilesystem(filesystem, ctx ?? {});
1555
- return listViaBackend(backend, relative2, path, max);
1611
+ return listViaBackend(backend, relative3, path, max);
1556
1612
  }
1557
- return listViaLocalFs(projectRoot, relative2, path, max);
1613
+ return listViaLocalFs(projectRoot, relative3, path, max);
1558
1614
  }
1559
1615
  });
1560
1616
  }
1561
- async function listViaLocalFs(projectRoot, relative2, originalPath, max) {
1562
- const boundary = resolveDirBoundary(relative2, projectRoot, originalPath);
1617
+ async function listViaLocalFs(projectRoot, relative3, originalPath, max) {
1618
+ const boundary = resolveDirBoundary(relative3, projectRoot, originalPath);
1563
1619
  if ("error" in boundary) return boundary.error;
1564
1620
  const readResult = await readDirSafe(boundary.absolutePath, originalPath);
1565
1621
  if ("error" in readResult) return readResult.error;
1566
1622
  return formatListing(readResult.dirents, max);
1567
1623
  }
1568
- async function listViaBackend(backend, relative2, originalPath, max) {
1624
+ async function listViaBackend(backend, relative3, originalPath, max) {
1569
1625
  let names;
1570
1626
  try {
1571
- names = await backend.list(relative2);
1627
+ names = await backend.list(relative3);
1572
1628
  } catch (err) {
1573
1629
  if (err instanceof FileNotFoundError) {
1574
1630
  return JSON.stringify({ ok: false, error: "not_found", path: originalPath });
@@ -1582,7 +1638,7 @@ async function listViaBackend(backend, relative2, originalPath, max) {
1582
1638
  const windowed = names.slice(0, max);
1583
1639
  const entries = await Promise.all(
1584
1640
  windowed.map(async (name) => {
1585
- const child = relative2 === "." ? name : `${relative2}/${name}`;
1641
+ const child = relative3 === "." ? name : `${relative3}/${name}`;
1586
1642
  let type = "file";
1587
1643
  try {
1588
1644
  type = (await backend.stat(child)).isDirectory ? "directory" : "file";
@@ -1593,9 +1649,9 @@ async function listViaBackend(backend, relative2, originalPath, max) {
1593
1649
  );
1594
1650
  return JSON.stringify({ ok: true, entries, truncated: totalCount > max, totalCount });
1595
1651
  }
1596
- function resolveDirBoundary(relative2, projectRoot, originalPath) {
1652
+ function resolveDirBoundary(relative3, projectRoot, originalPath) {
1597
1653
  try {
1598
- const absolutePath = relative2 === "." ? projectRoot : safePathJoin(projectRoot, relative2);
1654
+ const absolutePath = relative3 === "." ? projectRoot : safePathJoin(projectRoot, relative3);
1599
1655
  assertNoSymlinkEscape(absolutePath, projectRoot);
1600
1656
  return { absolutePath };
1601
1657
  } catch (err) {
@@ -1817,22 +1873,22 @@ function createReadFileTool(opts) {
1817
1873
  }
1818
1874
  async function readViaBackend(backend, path, view, onRead) {
1819
1875
  try {
1820
- const stat2 = await backend.stat(path);
1821
- if (stat2.size > MAX_FILE_SIZE) {
1876
+ const stat3 = await backend.stat(path);
1877
+ if (stat3.size > MAX_FILE_SIZE) {
1822
1878
  return JSON.stringify({
1823
1879
  ok: false,
1824
1880
  error: "too_large",
1825
1881
  path,
1826
- size: stat2.size,
1882
+ size: stat3.size,
1827
1883
  limit: MAX_FILE_SIZE
1828
1884
  });
1829
1885
  }
1830
1886
  const raw = await backend.readFile(path);
1831
1887
  if (raw.includes("\0")) {
1832
- return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
1888
+ return JSON.stringify({ ok: false, error: "binary_file", path, size: stat3.size });
1833
1889
  }
1834
- onRead?.(stat2.mtimeMs);
1835
- return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat2.size });
1890
+ onRead?.(stat3.mtimeMs);
1891
+ return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat3.size });
1836
1892
  } catch (err) {
1837
1893
  if (err instanceof FileNotFoundError) {
1838
1894
  return JSON.stringify({ ok: false, error: "not_found", path });
@@ -1871,22 +1927,22 @@ async function openHandleSafe(absolutePath, path) {
1871
1927
  }
1872
1928
  }
1873
1929
  async function readContent(handle, path, view, onRead) {
1874
- const stat2 = await handle.stat();
1875
- if (stat2.size > MAX_FILE_SIZE) {
1930
+ const stat3 = await handle.stat();
1931
+ if (stat3.size > MAX_FILE_SIZE) {
1876
1932
  return JSON.stringify({
1877
1933
  ok: false,
1878
1934
  error: "too_large",
1879
1935
  path,
1880
- size: stat2.size,
1936
+ size: stat3.size,
1881
1937
  limit: MAX_FILE_SIZE
1882
1938
  });
1883
1939
  }
1884
- if (await isBinaryProbe(handle, Number(stat2.size))) {
1885
- return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
1940
+ if (await isBinaryProbe(handle, Number(stat3.size))) {
1941
+ return JSON.stringify({ ok: false, error: "binary_file", path, size: stat3.size });
1886
1942
  }
1887
1943
  const raw = await handle.readFile({ encoding: "utf-8" });
1888
- onRead?.(stat2.mtimeMs);
1889
- return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat2.size });
1944
+ onRead?.(stat3.mtimeMs);
1945
+ return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat3.size });
1890
1946
  }
1891
1947
  async function isBinaryProbe(handle, size) {
1892
1948
  const probeLen = Math.min(BINARY_PROBE_BYTES, size);
@@ -2802,12 +2858,12 @@ async function writeViaBackend(backend, path, content, guard) {
2802
2858
  if (rbw) return rbw;
2803
2859
  expectedMtime = current ?? void 0;
2804
2860
  }
2805
- const stat2 = await backend.writeFile(
2861
+ const stat3 = await backend.writeFile(
2806
2862
  path,
2807
2863
  content,
2808
2864
  expectedMtime !== void 0 ? { expectedMtime } : void 0
2809
2865
  );
2810
- return JSON.stringify({ ok: true, path, bytes: stat2.size });
2866
+ return JSON.stringify({ ok: true, path, bytes: stat3.size });
2811
2867
  } catch (err) {
2812
2868
  return backendErrorToJson(err, path);
2813
2869
  }
@@ -2835,8 +2891,8 @@ async function isBinaryFile(absolutePath) {
2835
2891
  return false;
2836
2892
  }
2837
2893
  try {
2838
- const stat2 = await handle.stat();
2839
- const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(stat2.size));
2894
+ const stat3 = await handle.stat();
2895
+ const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(stat3.size));
2840
2896
  if (probeLen <= 0) return false;
2841
2897
  const probe = Buffer.alloc(probeLen);
2842
2898
  const { bytesRead } = await handle.read(probe, 0, probeLen, 0);