@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 +15 -0
- package/dist/index.cjs +141 -173
- 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 +114 -146
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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")
|
|
357
|
-
|
|
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
|
-
|
|
393
|
-
return { error: JSON.stringify({ ok: false, error: "forbidden_path", path: file }) };
|
|
394
|
-
}
|
|
359
|
+
let abs;
|
|
395
360
|
try {
|
|
396
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
728
|
+
function attachChildSettlers(child, gate, onClose, onError, resolve) {
|
|
761
729
|
child.on("close", (code) => {
|
|
762
|
-
gate.fire(() =>
|
|
730
|
+
gate.fire(() => resolve(onClose(code)));
|
|
763
731
|
});
|
|
764
732
|
child.on("error", (err) => {
|
|
765
|
-
gate.fire(() =>
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1552
|
-
if (
|
|
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,
|
|
1525
|
+
return listViaBackend(backend, relative3, path, max);
|
|
1558
1526
|
}
|
|
1559
|
-
return listViaLocalFs(projectRoot,
|
|
1527
|
+
return listViaLocalFs(projectRoot, relative3, path, max);
|
|
1560
1528
|
}
|
|
1561
1529
|
});
|
|
1562
1530
|
}
|
|
1563
|
-
async function listViaLocalFs(projectRoot,
|
|
1564
|
-
const boundary = resolveDirBoundary(
|
|
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,
|
|
1538
|
+
async function listViaBackend(backend, relative3, originalPath, max) {
|
|
1571
1539
|
let names;
|
|
1572
1540
|
try {
|
|
1573
|
-
names = await backend.list(
|
|
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 =
|
|
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(
|
|
1566
|
+
function resolveDirBoundary(relative3, projectRoot, originalPath) {
|
|
1599
1567
|
try {
|
|
1600
|
-
const absolutePath =
|
|
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
|
|
1823
|
-
if (
|
|
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:
|
|
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:
|
|
1802
|
+
return JSON.stringify({ ok: false, error: "binary_file", path, size: stat3.size });
|
|
1835
1803
|
}
|
|
1836
|
-
onRead?.(
|
|
1837
|
-
return JSON.stringify({ ok: true, content: renderView(raw, view), 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
|
|
1877
|
-
if (
|
|
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:
|
|
1850
|
+
size: stat3.size,
|
|
1883
1851
|
limit: MAX_FILE_SIZE
|
|
1884
1852
|
});
|
|
1885
1853
|
}
|
|
1886
|
-
if (await isBinaryProbe(handle, Number(
|
|
1887
|
-
return JSON.stringify({ ok: false, error: "binary_file", path, 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?.(
|
|
1891
|
-
return JSON.stringify({ ok: true, content: renderView(raw, view), 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((
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
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) =>
|
|
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) =>
|
|
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
|
|
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:
|
|
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
|
|
2841
|
-
const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(
|
|
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);
|