@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/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
@@ -64,8 +64,8 @@ function realpathOfDeepestExisting(path$1) {
64
64
  } catch {
65
65
  }
66
66
  try {
67
- const stat2 = fs.lstatSync(path$1);
68
- if (stat2.isSymbolicLink()) {
67
+ const stat3 = fs.lstatSync(path$1);
68
+ if (stat3.isSymbolicLink()) {
69
69
  const target = fs.readlinkSync(path$1);
70
70
  const parentReal = realpathOfDeepestExisting(path.dirname(path$1));
71
71
  const parentBase = parentReal ?? path.dirname(path$1);
@@ -267,7 +267,14 @@ function matchesAt(lines, pattern, at, eq) {
267
267
  function seekSequence(lines, pattern, searchStart, eof) {
268
268
  if (pattern.length === 0) return searchStart;
269
269
  if (pattern.length > lines.length) return null;
270
- const start = eof ? Math.max(searchStart, lines.length - pattern.length) : searchStart;
270
+ const starts = eof ? [Math.max(searchStart, lines.length - pattern.length), searchStart] : [searchStart];
271
+ for (const start of starts) {
272
+ const hit = searchFrom(lines, pattern, start);
273
+ if (hit !== null) return hit;
274
+ }
275
+ return null;
276
+ }
277
+ function searchFrom(lines, pattern, start) {
271
278
  for (const eq of LADDER) {
272
279
  for (let i = start; i <= lines.length - pattern.length; i++) {
273
280
  if (matchesAt(lines, pattern, i, eq)) return i;
@@ -324,12 +331,28 @@ async function applyV4APatch(projectRoot, patch) {
324
331
  const detail = err instanceof V4APatchError ? err.message : String(err);
325
332
  return JSON.stringify({ ok: false, error: "parse_error", detail });
326
333
  }
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 });
334
+ try {
335
+ const plan = await buildPlan(projectRoot, hunks);
336
+ if ("error" in plan) return plan.error;
337
+ await executePlan(plan.ops);
338
+ return JSON.stringify({ ok: true, files_patched: plan.patched });
339
+ } catch (err) {
340
+ return JSON.stringify({
341
+ ok: false,
342
+ error: "io_error",
343
+ detail: err instanceof Error ? err.message : String(err)
344
+ });
345
+ }
346
+ }
347
+ function hunkTargets(hunk) {
348
+ if (hunk.kind === "update" && hunk.movePath) return [hunk.path, hunk.movePath];
349
+ return [hunk.path];
331
350
  }
332
351
  async function buildPlan(projectRoot, hunks) {
352
+ const dup = firstDuplicateTarget(hunks);
353
+ if (dup !== null) {
354
+ return { error: JSON.stringify({ ok: false, error: "duplicate_target", path: dup }) };
355
+ }
333
356
  const ops = [];
334
357
  const patched = [];
335
358
  for (const hunk of hunks) {
@@ -340,6 +363,16 @@ async function buildPlan(projectRoot, hunks) {
340
363
  }
341
364
  return { ops, patched };
342
365
  }
366
+ function firstDuplicateTarget(hunks) {
367
+ const seen = /* @__PURE__ */ new Set();
368
+ for (const hunk of hunks) {
369
+ for (const t of hunkTargets(hunk)) {
370
+ if (seen.has(t)) return t;
371
+ seen.add(t);
372
+ }
373
+ }
374
+ return null;
375
+ }
343
376
  async function executePlan(ops) {
344
377
  for (const op of ops) {
345
378
  if ("rm" in op) {
@@ -350,11 +383,29 @@ async function executePlan(ops) {
350
383
  }
351
384
  }
352
385
  }
386
+ async function pathExists(abs) {
387
+ try {
388
+ await promises.stat(abs);
389
+ return true;
390
+ } catch {
391
+ return false;
392
+ }
393
+ }
353
394
  async function planHunk(projectRoot, hunk) {
354
395
  const scope = v4aScope(projectRoot, hunk.path);
355
396
  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 }] };
397
+ if (hunk.kind === "add") {
398
+ if (await pathExists(scope.abs)) {
399
+ return { error: JSON.stringify({ ok: false, error: "file_exists", path: hunk.path }) };
400
+ }
401
+ return { ops: [{ write: { abs: scope.abs, content: hunk.content } }] };
402
+ }
403
+ if (hunk.kind === "delete") {
404
+ if (!await pathExists(scope.abs)) {
405
+ return { error: JSON.stringify({ ok: false, error: "not_found", path: hunk.path }) };
406
+ }
407
+ return { ops: [{ rm: scope.abs }] };
408
+ }
358
409
  return planUpdate(projectRoot, hunk, scope.abs);
359
410
  }
360
411
  async function planUpdate(projectRoot, hunk, abs) {
@@ -388,20 +439,25 @@ async function planUpdate(projectRoot, hunk, abs) {
388
439
  if ("error" in dest) return dest;
389
440
  return { ops: [{ write: { abs: dest.abs, content: updated } }, { rm: abs }] };
390
441
  }
442
+ var FORBIDDEN_SEGMENTS = /* @__PURE__ */ new Set([".env", ".git", "node_modules", ".theo"]);
443
+ function isForbiddenRel(rel) {
444
+ return rel.split(/[/\\]/).filter(Boolean).some((s) => s !== ".env.example" && (FORBIDDEN_SEGMENTS.has(s) || /^\.env\./.test(s)));
445
+ }
391
446
  function v4aScope(projectRoot, file) {
392
- if (isForbiddenPath(file)) {
393
- return { error: JSON.stringify({ ok: false, error: "forbidden_path", path: file }) };
394
- }
447
+ let abs;
395
448
  try {
396
- const abs = safePathJoin(projectRoot, file);
449
+ abs = safePathJoin(projectRoot, file);
397
450
  assertNoSymlinkEscape(abs, projectRoot);
398
- return { abs };
399
451
  } catch (err) {
400
452
  if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
401
453
  return { error: JSON.stringify({ ok: false, error: "path_traversal", path: file }) };
402
454
  }
403
455
  throw err;
404
456
  }
457
+ if (isForbiddenPath(file) || isForbiddenRel(path.relative(projectRoot, abs))) {
458
+ return { error: JSON.stringify({ ok: false, error: "forbidden_path", path: file }) };
459
+ }
460
+ return { abs };
405
461
  }
406
462
  function createSessionArtifactStore(options) {
407
463
  const { dir } = options;
@@ -1548,29 +1604,29 @@ function createListDirTool(opts) {
1548
1604
  path: zod.z.string().min(1).describe("Project-relative directory path. Use '.' for root.")
1549
1605
  }),
1550
1606
  handler: async ({ path }, ctx) => {
1551
- const relative2 = path === "" || path === "." ? "." : path;
1552
- if (relative2 !== "." && isForbiddenPath(relative2)) {
1607
+ const relative3 = path === "" || path === "." ? "." : path;
1608
+ if (relative3 !== "." && isForbiddenPath(relative3)) {
1553
1609
  return JSON.stringify({ ok: false, error: "forbidden_path", path });
1554
1610
  }
1555
1611
  if (filesystem$1) {
1556
1612
  const backend = await filesystem.resolveFilesystem(filesystem$1, ctx ?? {});
1557
- return listViaBackend(backend, relative2, path, max);
1613
+ return listViaBackend(backend, relative3, path, max);
1558
1614
  }
1559
- return listViaLocalFs(projectRoot, relative2, path, max);
1615
+ return listViaLocalFs(projectRoot, relative3, path, max);
1560
1616
  }
1561
1617
  });
1562
1618
  }
1563
- async function listViaLocalFs(projectRoot, relative2, originalPath, max) {
1564
- const boundary = resolveDirBoundary(relative2, projectRoot, originalPath);
1619
+ async function listViaLocalFs(projectRoot, relative3, originalPath, max) {
1620
+ const boundary = resolveDirBoundary(relative3, projectRoot, originalPath);
1565
1621
  if ("error" in boundary) return boundary.error;
1566
1622
  const readResult = await readDirSafe(boundary.absolutePath, originalPath);
1567
1623
  if ("error" in readResult) return readResult.error;
1568
1624
  return formatListing(readResult.dirents, max);
1569
1625
  }
1570
- async function listViaBackend(backend, relative2, originalPath, max) {
1626
+ async function listViaBackend(backend, relative3, originalPath, max) {
1571
1627
  let names;
1572
1628
  try {
1573
- names = await backend.list(relative2);
1629
+ names = await backend.list(relative3);
1574
1630
  } catch (err) {
1575
1631
  if (err instanceof filesystem.FileNotFoundError) {
1576
1632
  return JSON.stringify({ ok: false, error: "not_found", path: originalPath });
@@ -1584,7 +1640,7 @@ async function listViaBackend(backend, relative2, originalPath, max) {
1584
1640
  const windowed = names.slice(0, max);
1585
1641
  const entries = await Promise.all(
1586
1642
  windowed.map(async (name) => {
1587
- const child = relative2 === "." ? name : `${relative2}/${name}`;
1643
+ const child = relative3 === "." ? name : `${relative3}/${name}`;
1588
1644
  let type = "file";
1589
1645
  try {
1590
1646
  type = (await backend.stat(child)).isDirectory ? "directory" : "file";
@@ -1595,9 +1651,9 @@ async function listViaBackend(backend, relative2, originalPath, max) {
1595
1651
  );
1596
1652
  return JSON.stringify({ ok: true, entries, truncated: totalCount > max, totalCount });
1597
1653
  }
1598
- function resolveDirBoundary(relative2, projectRoot, originalPath) {
1654
+ function resolveDirBoundary(relative3, projectRoot, originalPath) {
1599
1655
  try {
1600
- const absolutePath = relative2 === "." ? projectRoot : safePathJoin(projectRoot, relative2);
1656
+ const absolutePath = relative3 === "." ? projectRoot : safePathJoin(projectRoot, relative3);
1601
1657
  assertNoSymlinkEscape(absolutePath, projectRoot);
1602
1658
  return { absolutePath };
1603
1659
  } catch (err) {
@@ -1819,22 +1875,22 @@ function createReadFileTool(opts) {
1819
1875
  }
1820
1876
  async function readViaBackend(backend, path, view, onRead) {
1821
1877
  try {
1822
- const stat2 = await backend.stat(path);
1823
- if (stat2.size > MAX_FILE_SIZE) {
1878
+ const stat3 = await backend.stat(path);
1879
+ if (stat3.size > MAX_FILE_SIZE) {
1824
1880
  return JSON.stringify({
1825
1881
  ok: false,
1826
1882
  error: "too_large",
1827
1883
  path,
1828
- size: stat2.size,
1884
+ size: stat3.size,
1829
1885
  limit: MAX_FILE_SIZE
1830
1886
  });
1831
1887
  }
1832
1888
  const raw = await backend.readFile(path);
1833
1889
  if (raw.includes("\0")) {
1834
- return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
1890
+ return JSON.stringify({ ok: false, error: "binary_file", path, size: stat3.size });
1835
1891
  }
1836
- onRead?.(stat2.mtimeMs);
1837
- return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat2.size });
1892
+ onRead?.(stat3.mtimeMs);
1893
+ return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat3.size });
1838
1894
  } catch (err) {
1839
1895
  if (err instanceof filesystem.FileNotFoundError) {
1840
1896
  return JSON.stringify({ ok: false, error: "not_found", path });
@@ -1873,22 +1929,22 @@ async function openHandleSafe(absolutePath, path) {
1873
1929
  }
1874
1930
  }
1875
1931
  async function readContent(handle, path, view, onRead) {
1876
- const stat2 = await handle.stat();
1877
- if (stat2.size > MAX_FILE_SIZE) {
1932
+ const stat3 = await handle.stat();
1933
+ if (stat3.size > MAX_FILE_SIZE) {
1878
1934
  return JSON.stringify({
1879
1935
  ok: false,
1880
1936
  error: "too_large",
1881
1937
  path,
1882
- size: stat2.size,
1938
+ size: stat3.size,
1883
1939
  limit: MAX_FILE_SIZE
1884
1940
  });
1885
1941
  }
1886
- if (await isBinaryProbe(handle, Number(stat2.size))) {
1887
- return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
1942
+ if (await isBinaryProbe(handle, Number(stat3.size))) {
1943
+ return JSON.stringify({ ok: false, error: "binary_file", path, size: stat3.size });
1888
1944
  }
1889
1945
  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 });
1946
+ onRead?.(stat3.mtimeMs);
1947
+ return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat3.size });
1892
1948
  }
1893
1949
  async function isBinaryProbe(handle, size) {
1894
1950
  const probeLen = Math.min(BINARY_PROBE_BYTES, size);
@@ -2804,12 +2860,12 @@ async function writeViaBackend(backend, path, content, guard) {
2804
2860
  if (rbw) return rbw;
2805
2861
  expectedMtime = current ?? void 0;
2806
2862
  }
2807
- const stat2 = await backend.writeFile(
2863
+ const stat3 = await backend.writeFile(
2808
2864
  path,
2809
2865
  content,
2810
2866
  expectedMtime !== void 0 ? { expectedMtime } : void 0
2811
2867
  );
2812
- return JSON.stringify({ ok: true, path, bytes: stat2.size });
2868
+ return JSON.stringify({ ok: true, path, bytes: stat3.size });
2813
2869
  } catch (err) {
2814
2870
  return backendErrorToJson(err, path);
2815
2871
  }
@@ -2837,8 +2893,8 @@ async function isBinaryFile(absolutePath) {
2837
2893
  return false;
2838
2894
  }
2839
2895
  try {
2840
- const stat2 = await handle.stat();
2841
- const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(stat2.size));
2896
+ const stat3 = await handle.stat();
2897
+ const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(stat3.size));
2842
2898
  if (probeLen <= 0) return false;
2843
2899
  const probe = Buffer.alloc(probeLen);
2844
2900
  const { bytesRead } = await handle.read(probe, 0, probeLen, 0);