@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/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
|
|
68
|
-
if (
|
|
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
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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")
|
|
357
|
-
|
|
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
|
-
|
|
393
|
-
return { error: JSON.stringify({ ok: false, error: "forbidden_path", path: file }) };
|
|
394
|
-
}
|
|
447
|
+
let abs;
|
|
395
448
|
try {
|
|
396
|
-
|
|
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
|
|
1552
|
-
if (
|
|
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,
|
|
1613
|
+
return listViaBackend(backend, relative3, path, max);
|
|
1558
1614
|
}
|
|
1559
|
-
return listViaLocalFs(projectRoot,
|
|
1615
|
+
return listViaLocalFs(projectRoot, relative3, path, max);
|
|
1560
1616
|
}
|
|
1561
1617
|
});
|
|
1562
1618
|
}
|
|
1563
|
-
async function listViaLocalFs(projectRoot,
|
|
1564
|
-
const boundary = resolveDirBoundary(
|
|
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,
|
|
1626
|
+
async function listViaBackend(backend, relative3, originalPath, max) {
|
|
1571
1627
|
let names;
|
|
1572
1628
|
try {
|
|
1573
|
-
names = await backend.list(
|
|
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 =
|
|
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(
|
|
1654
|
+
function resolveDirBoundary(relative3, projectRoot, originalPath) {
|
|
1599
1655
|
try {
|
|
1600
|
-
const absolutePath =
|
|
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
|
|
1823
|
-
if (
|
|
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:
|
|
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:
|
|
1890
|
+
return JSON.stringify({ ok: false, error: "binary_file", path, size: stat3.size });
|
|
1835
1891
|
}
|
|
1836
|
-
onRead?.(
|
|
1837
|
-
return JSON.stringify({ ok: true, content: renderView(raw, view), 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
|
|
1877
|
-
if (
|
|
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:
|
|
1938
|
+
size: stat3.size,
|
|
1883
1939
|
limit: MAX_FILE_SIZE
|
|
1884
1940
|
});
|
|
1885
1941
|
}
|
|
1886
|
-
if (await isBinaryProbe(handle, Number(
|
|
1887
|
-
return JSON.stringify({ ok: false, error: "binary_file", path, 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?.(
|
|
1891
|
-
return JSON.stringify({ ok: true, content: renderView(raw, view), 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
|
|
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:
|
|
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
|
|
2841
|
-
const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(
|
|
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);
|