@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 +15 -0
- package/dist/index.cjs +98 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.js +99 -43
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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' |
|
|
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' |
|
|
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,
|
|
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
|
|
66
|
-
if (
|
|
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
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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")
|
|
355
|
-
|
|
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
|
-
|
|
391
|
-
return { error: JSON.stringify({ ok: false, error: "forbidden_path", path: file }) };
|
|
392
|
-
}
|
|
445
|
+
let abs;
|
|
393
446
|
try {
|
|
394
|
-
|
|
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
|
|
1550
|
-
if (
|
|
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,
|
|
1611
|
+
return listViaBackend(backend, relative3, path, max);
|
|
1556
1612
|
}
|
|
1557
|
-
return listViaLocalFs(projectRoot,
|
|
1613
|
+
return listViaLocalFs(projectRoot, relative3, path, max);
|
|
1558
1614
|
}
|
|
1559
1615
|
});
|
|
1560
1616
|
}
|
|
1561
|
-
async function listViaLocalFs(projectRoot,
|
|
1562
|
-
const boundary = resolveDirBoundary(
|
|
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,
|
|
1624
|
+
async function listViaBackend(backend, relative3, originalPath, max) {
|
|
1569
1625
|
let names;
|
|
1570
1626
|
try {
|
|
1571
|
-
names = await backend.list(
|
|
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 =
|
|
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(
|
|
1652
|
+
function resolveDirBoundary(relative3, projectRoot, originalPath) {
|
|
1597
1653
|
try {
|
|
1598
|
-
const absolutePath =
|
|
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
|
|
1821
|
-
if (
|
|
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:
|
|
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:
|
|
1888
|
+
return JSON.stringify({ ok: false, error: "binary_file", path, size: stat3.size });
|
|
1833
1889
|
}
|
|
1834
|
-
onRead?.(
|
|
1835
|
-
return JSON.stringify({ ok: true, content: renderView(raw, view), 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
|
|
1875
|
-
if (
|
|
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:
|
|
1936
|
+
size: stat3.size,
|
|
1881
1937
|
limit: MAX_FILE_SIZE
|
|
1882
1938
|
});
|
|
1883
1939
|
}
|
|
1884
|
-
if (await isBinaryProbe(handle, Number(
|
|
1885
|
-
return JSON.stringify({ ok: false, error: "binary_file", path, 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?.(
|
|
1889
|
-
return JSON.stringify({ ok: true, content: renderView(raw, view), 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
|
|
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:
|
|
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
|
|
2839
|
-
const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(
|
|
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);
|