@vincemakes/kiso-tools-node 0.52.0 → 0.54.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/dist/index.d.ts +9 -0
- package/dist/index.js +109 -16
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -83,6 +83,11 @@ export declare function resolveWithinRoot(root: string, input: string): string;
|
|
|
83
83
|
* approval UI, so the human and the tool see THE SAME path.
|
|
84
84
|
*/
|
|
85
85
|
export declare function canonicalTargetPath(input: string): string;
|
|
86
|
+
/** The Work's added folders, and whether its mode lets the file tools go anywhere else. */
|
|
87
|
+
export interface FileAccess {
|
|
88
|
+
readonly added: readonly string[];
|
|
89
|
+
readonly outside: "deny" | "allow";
|
|
90
|
+
}
|
|
86
91
|
export interface WorkspaceToolsOptions {
|
|
87
92
|
/** The workspace the tools may touch; everything else is refused. */
|
|
88
93
|
readonly workspaceRoot: string;
|
|
@@ -112,6 +117,10 @@ export interface WorkspaceToolsOptions {
|
|
|
112
117
|
* widens: every other tool, and every relative path, stays inside the
|
|
113
118
|
* workspace. */
|
|
114
119
|
readonly extraReadRoots?: readonly string[];
|
|
120
|
+
/** ADR-0058 Amendment 19: what the file tools may reach beyond the workspace, asked once per call. Absent: nothing, as before. */
|
|
121
|
+
readonly access?: (sessionId: string | undefined) => FileAccess;
|
|
122
|
+
/** ADR-0058 Amendment 19: folders that reach never enters (the credential homes, kiso's own). The workspace is not limited by them. */
|
|
123
|
+
readonly stopRoots?: readonly string[];
|
|
115
124
|
/** ADR-0058 (3b): the session's tasks. With them the shell promotes a
|
|
116
125
|
* command that outlives its wait instead of killing it, can start one
|
|
117
126
|
* in the background, `task_stop` joins the tools, and `read_file`
|
package/dist/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import { defineTool } from "@vincemakes/kiso-core";
|
|
|
30
30
|
// WR-1/WR-1A — the revision-guard primitives (unit-tested in wr1a-coda):
|
|
31
31
|
import { strippedShellEnv } from "./secret-env.js";
|
|
32
32
|
import { contentRevision, normalizeRevision, postEffectEscape, precondition, publishNewFile, revalidateBeforeRename } from "./wr1.js";
|
|
33
|
-
import { isProtectedPath, protectedIdentity, protectedRefusalText, readUnlessProtected } from "./protected.js";
|
|
33
|
+
import { diskPath, isProtectedPath, protectedIdentity, protectedRefusalText, readUnlessProtected } from "./protected.js";
|
|
34
34
|
import { CORPUS_MAX_DEPTH, globToRegExp } from "./corpus.js";
|
|
35
35
|
import { describeSearchMiss, regionForSearch } from "./search-miss.js";
|
|
36
36
|
/**
|
|
@@ -205,7 +205,69 @@ function resolveReadable(opts, input, sessionId) {
|
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
|
-
return
|
|
208
|
+
return resolveReach(opts, input, sessionId, false);
|
|
209
|
+
}
|
|
210
|
+
/** Beyond the workspace: has the target's folder come to resolve somewhere else since it was checked (a swap to a symlink)? */
|
|
211
|
+
function movedUnder(parent, full) {
|
|
212
|
+
return parent !== null && diskPath(dirname(full)) !== parent;
|
|
213
|
+
}
|
|
214
|
+
/** Beyond the workspace, after the write: did it land in a stop path after all? Not an OS sandbox; a check that says so instead of claiming success. */
|
|
215
|
+
function landedInStop(opts, input, written, parent) {
|
|
216
|
+
if (parent === null)
|
|
217
|
+
return false;
|
|
218
|
+
try {
|
|
219
|
+
guardReach(opts, input, diskPath(written), true);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** A result named under `base`, by concatenation: a match line holds the file's text, which a path join would normalize. */
|
|
227
|
+
const under = (base, rest) => (base.endsWith(sep) ? `${base}${rest}` : `${base}${sep}${rest}`);
|
|
228
|
+
/** The new reach stops short of these, on the canonical path: never the primary, whose rules do not change. */
|
|
229
|
+
function guardReach(opts, input, full, writing) {
|
|
230
|
+
for (const stop of opts.stopRoots ?? []) {
|
|
231
|
+
if (isWithin(diskPath(stop), diskPath(full)))
|
|
232
|
+
throw new PathEscapeError(`${input} is in ${stop}, which the file tools do not reach outside the workspace`);
|
|
233
|
+
}
|
|
234
|
+
if (writing && full.split(sep).some((seg) => seg.toLowerCase() === ".git" || seg.toLowerCase() === ".kiso")) {
|
|
235
|
+
throw new PathEscapeError(`${input} is inside a .git or .kiso folder, which the file tools do not write outside the workspace`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The one resolution every file tool uses: the workspace as before, else (an absolute path only) one of the Work's added folders,
|
|
240
|
+
* else, where the host allows it, anywhere outside. `access` is read once per call, so an add, a removal or a mode change applies
|
|
241
|
+
* at the next call. What it cannot place is refused exactly as `resolveWithinRoot` refuses it.
|
|
242
|
+
*/
|
|
243
|
+
function resolveReach(opts, input, sessionId, writing) {
|
|
244
|
+
const primary = () => ({ full: resolveWithinRoot(opts.workspaceRoot, input), root: opts.workspaceRoot, base: null, parent: null });
|
|
245
|
+
if (opts.access === undefined || !isAbsolute(input))
|
|
246
|
+
return primary();
|
|
247
|
+
const rootReal = realpathSync(opts.workspaceRoot);
|
|
248
|
+
if (isWithin(opts.workspaceRoot, input) || isWithin(rootReal, input))
|
|
249
|
+
return primary();
|
|
250
|
+
const access = opts.access(sessionId);
|
|
251
|
+
for (const root of access.added) {
|
|
252
|
+
if (!existsSync(root))
|
|
253
|
+
continue;
|
|
254
|
+
const real = realpathSync(root);
|
|
255
|
+
// stored canonical when it was added: a root that now resolves elsewhere (replaced by a symlink) grants nothing until added again
|
|
256
|
+
if (real !== root)
|
|
257
|
+
continue;
|
|
258
|
+
for (const base of [root, real]) {
|
|
259
|
+
if (!isWithin(base, input))
|
|
260
|
+
continue;
|
|
261
|
+
const full = resolveWithinRoot(root, relative(base, input) || ".");
|
|
262
|
+
guardReach(opts, input, full, writing);
|
|
263
|
+
return { full, root, base: diskPath(real), parent: diskPath(dirname(full)) };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (access.outside !== "allow")
|
|
267
|
+
return primary();
|
|
268
|
+
const full = diskPath(resolve(input));
|
|
269
|
+
guardReach(opts, input, full, writing);
|
|
270
|
+
return { full, root: dirname(full), base: full, parent: diskPath(dirname(full)) };
|
|
209
271
|
}
|
|
210
272
|
export function resolveWithinRoot(root, input) {
|
|
211
273
|
const rootReal = realpathSync(root);
|
|
@@ -578,7 +640,8 @@ export function listDirTool(opts) {
|
|
|
578
640
|
promptGuidelines: ["narrow to a subdirectory when the listing caps at 200 entries"],
|
|
579
641
|
execute: async ({ path, glob }, ctx) => {
|
|
580
642
|
try {
|
|
581
|
-
const
|
|
643
|
+
const reach = resolveReach(opts, path ?? ".", ctx?.sessionId, false);
|
|
644
|
+
const dir = reach.full;
|
|
582
645
|
// ACI-8: with a pattern this is a recursive search over the
|
|
583
646
|
// SAME corpus `search_text` uses — one definition, so the two
|
|
584
647
|
// cannot drift the way they had.
|
|
@@ -604,11 +667,12 @@ export function listDirTool(opts) {
|
|
|
604
667
|
token: 0,
|
|
605
668
|
// both realpath'd: a root and a start resolved differently
|
|
606
669
|
// named every file `../../…` under a symlinked workspace
|
|
607
|
-
workspaceRoot: realOrSelf(opts.workspaceRoot),
|
|
670
|
+
workspaceRoot: realOrSelf(reach.base ?? opts.workspaceRoot),
|
|
608
671
|
walkFrom: realOrSelf(dir),
|
|
609
672
|
source: re.source,
|
|
610
673
|
flags: re.flags,
|
|
611
|
-
|
|
674
|
+
// beyond the workspace the walk is relative to `base`, and its stops are named that way
|
|
675
|
+
excluded: reach.base === null ? (opts.excludeRoots ?? []) : [...(opts.excludeRoots ?? []), ...(opts.stopRoots ?? [])].map((ex) => relative(diskPath(reach.base), diskPath(ex)).split(sep).join("/")),
|
|
612
676
|
maxEntries: MAX_DIR_ENTRIES,
|
|
613
677
|
maxVisited: opts.limits?.searchMaxFiles ?? SEARCH_MAX_FILES,
|
|
614
678
|
deadline,
|
|
@@ -620,7 +684,8 @@ export function listDirTool(opts) {
|
|
|
620
684
|
const walk = outcome.reply;
|
|
621
685
|
if (walk?.error !== undefined)
|
|
622
686
|
return { content: `list_dir failed: ${walk.error}`, isError: true, errorKind: "fatal" };
|
|
623
|
-
|
|
687
|
+
// beyond the workspace a result is an absolute path: a relative one would name a file in the primary
|
|
688
|
+
const files = (walk?.files ?? []).map((f) => (reach.base !== null ? join(reach.base, f) : f));
|
|
624
689
|
const body = files.length ? cap(files.join("\n")) : `(no match for ${glob})`;
|
|
625
690
|
// TWO truncations, different remedies, and neither claimed
|
|
626
691
|
// when it did not happen: a walk that silently returns less
|
|
@@ -795,8 +860,10 @@ export function searchTextTool(opts) {
|
|
|
795
860
|
promptGuidelines: ["narrow the pattern when the result caps — never re-run a broad search"],
|
|
796
861
|
execute: async ({ pattern, path, caseSensitive }, ctx) => {
|
|
797
862
|
let root;
|
|
863
|
+
let reach;
|
|
798
864
|
try {
|
|
799
|
-
|
|
865
|
+
reach = resolveReach(opts, path ?? ".", ctx?.sessionId, false);
|
|
866
|
+
root = reach.full;
|
|
800
867
|
}
|
|
801
868
|
catch (err) {
|
|
802
869
|
if (err instanceof PathEscapeError)
|
|
@@ -828,9 +895,13 @@ export function searchTextTool(opts) {
|
|
|
828
895
|
return p;
|
|
829
896
|
}
|
|
830
897
|
};
|
|
831
|
-
const searchRootReal = realOrSelf(root);
|
|
832
|
-
|
|
833
|
-
|
|
898
|
+
const searchRootReal = reach.base !== null ? diskPath(root) : realOrSelf(root);
|
|
899
|
+
// beyond the workspace a match names an absolute path; a single file's is relative to its folder
|
|
900
|
+
const searchBase = reach.base === null ? null : single !== null ? dirname(searchRootReal) : diskPath(reach.base);
|
|
901
|
+
// beyond the workspace, by the disk's own spelling: a Windows short name and its long name are one folder
|
|
902
|
+
const canon = reach.base !== null ? diskPath : realOrSelf;
|
|
903
|
+
const excluded = [...(opts.excludeRoots ?? []), ...(reach.base !== null ? (opts.stopRoots ?? []) : [])]
|
|
904
|
+
.map(canon)
|
|
834
905
|
.filter((ex) => !(searchRootReal === ex || searchRootReal.startsWith(`${ex}/`)))
|
|
835
906
|
.map((ex) => relative(searchRootReal, ex));
|
|
836
907
|
const maxFileBytes = opts.limits?.searchMaxFileBytes ?? SEARCH_MAX_FILE_BYTES;
|
|
@@ -844,7 +915,7 @@ export function searchTextTool(opts) {
|
|
|
844
915
|
// root uses: `full` is walked from a realpath'd root, and making
|
|
845
916
|
// a path relative between a resolved and an unresolved base
|
|
846
917
|
// yields `../..` the moment a symlink sits between them.
|
|
847
|
-
{ token: 0, root: searchRootReal, workspaceRoot: realOrSelf(opts.workspaceRoot), single, pattern, flags, excluded, protectedIdentity: protectedIdentity(opts.protectedFiles), maxFileBytes, maxFiles, deadline, maxMatches: MAX_SEARCH_MATCHES, sniffBytes: BINARY_SNIFF_BYTES }, deadline, ctx.signal);
|
|
918
|
+
{ token: 0, root: searchRootReal, workspaceRoot: searchBase ?? realOrSelf(opts.workspaceRoot), single, pattern, flags, excluded, protectedIdentity: protectedIdentity(opts.protectedFiles), maxFileBytes, maxFiles, deadline, maxMatches: MAX_SEARCH_MATCHES, sniffBytes: BINARY_SNIFF_BYTES }, deadline, ctx.signal);
|
|
848
919
|
if (outcome.kind === "aborted")
|
|
849
920
|
return { content: "search_text aborted", isError: true, errorKind: "fatal" };
|
|
850
921
|
if (outcome.kind === "error")
|
|
@@ -852,7 +923,7 @@ export function searchTextTool(opts) {
|
|
|
852
923
|
const r = outcome.reply;
|
|
853
924
|
if (r?.error !== undefined)
|
|
854
925
|
return { content: `search_text failed: ${r.error}`, isError: true, errorKind: "fatal" };
|
|
855
|
-
const matches = r?.matches ?? [];
|
|
926
|
+
const matches = (r?.matches ?? []).map((m) => (searchBase !== null ? under(searchBase, m) : m));
|
|
856
927
|
const stopped = outcome.kind === "timeout" || (r?.stopped ?? false);
|
|
857
928
|
let content = matches.length ? cap(matches.join("\n")) : "(no matches)";
|
|
858
929
|
if (r !== undefined) {
|
|
@@ -908,8 +979,10 @@ export function writeFileTool(opts) {
|
|
|
908
979
|
// tolerance in the reader, strictness in the comparison.
|
|
909
980
|
let expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
|
|
910
981
|
let full;
|
|
982
|
+
let reachRoot;
|
|
983
|
+
let reachParent;
|
|
911
984
|
try {
|
|
912
|
-
full =
|
|
985
|
+
({ full, root: reachRoot, parent: reachParent } = resolveReach(opts, path, ctx?.sessionId, true));
|
|
913
986
|
}
|
|
914
987
|
catch (err) {
|
|
915
988
|
if (err instanceof PathEscapeError)
|
|
@@ -977,6 +1050,9 @@ export function writeFileTool(opts) {
|
|
|
977
1050
|
// workspace; the directories this call creates are removed again
|
|
978
1051
|
// if the file never lands.
|
|
979
1052
|
let createdDirs;
|
|
1053
|
+
// beyond the workspace: the folder must still be the one checked BEFORE anything is created or written in it (the review's M7)
|
|
1054
|
+
if (movedUnder(reachParent, full))
|
|
1055
|
+
return escapeResult(`${path}: its folder changed while the write was prepared — nothing was written`);
|
|
980
1056
|
if (!exists) {
|
|
981
1057
|
try {
|
|
982
1058
|
createdDirs = mkdirSync(dirname(full), { recursive: true });
|
|
@@ -1015,9 +1091,18 @@ export function writeFileTool(opts) {
|
|
|
1015
1091
|
// mode, so it is copied onto the temp first).
|
|
1016
1092
|
if (exists)
|
|
1017
1093
|
preservedMode = statSync(full).mode & 0o7777;
|
|
1094
|
+
if (movedUnder(reachParent, full)) {
|
|
1095
|
+
dropCreatedDirs();
|
|
1096
|
+
return escapeResult(`${path}: its folder changed while the write was prepared — nothing was written`);
|
|
1097
|
+
}
|
|
1018
1098
|
writeFileSync(tmp, content, "utf8");
|
|
1019
1099
|
if (preservedMode !== undefined)
|
|
1020
1100
|
chmodSync(tmp, preservedMode);
|
|
1101
|
+
if (movedUnder(reachParent, full)) {
|
|
1102
|
+
unlinkSync(tmp);
|
|
1103
|
+
dropCreatedDirs();
|
|
1104
|
+
return escapeResult(`${path}: its folder changed while the write was prepared — nothing was written`);
|
|
1105
|
+
}
|
|
1021
1106
|
committing = true;
|
|
1022
1107
|
if (!exists) {
|
|
1023
1108
|
// WR-1A ②: link(2) or refusal — atomic no-clobber, fail
|
|
@@ -1043,7 +1128,7 @@ export function writeFileTool(opts) {
|
|
|
1043
1128
|
// the write landed outside the workspace — say so instead of
|
|
1044
1129
|
// claiming success. (The write itself cannot be undone.)
|
|
1045
1130
|
const written = realpathSync(full);
|
|
1046
|
-
if (!isWithin(realpathSync(
|
|
1131
|
+
if (!isWithin(realpathSync(reachRoot), written) || landedInStop(opts, path, written, reachParent)) {
|
|
1047
1132
|
// WR-1A ①: the rename HAPPENED — this is a post-effect
|
|
1048
1133
|
// verification failure, fatal, never a precondition.
|
|
1049
1134
|
return postEffectEscape("write", path);
|
|
@@ -1259,8 +1344,10 @@ export function editFileTool(opts) {
|
|
|
1259
1344
|
return { content: shape, isError: true, errorKind: "invalid_input" };
|
|
1260
1345
|
const { hunks, single } = shape;
|
|
1261
1346
|
let full;
|
|
1347
|
+
let reachRoot;
|
|
1348
|
+
let reachParent;
|
|
1262
1349
|
try {
|
|
1263
|
-
full =
|
|
1350
|
+
({ full, root: reachRoot, parent: reachParent } = resolveReach(opts, path, ctx?.sessionId, true));
|
|
1264
1351
|
}
|
|
1265
1352
|
catch (err) {
|
|
1266
1353
|
if (err instanceof PathEscapeError)
|
|
@@ -1383,6 +1470,8 @@ export function editFileTool(opts) {
|
|
|
1383
1470
|
// ONE buffer is written and hashed: the revision returned is the
|
|
1384
1471
|
// file's on disk, CRLF or not (the review's finding)
|
|
1385
1472
|
const bytesOut = Buffer.from(crlf ? edited.replace(/\n/g, "\r\n") : edited, "utf8");
|
|
1473
|
+
if (movedUnder(reachParent, full))
|
|
1474
|
+
return escapeResult(`${path}: its folder changed while the edit was prepared — nothing was written`);
|
|
1386
1475
|
writeFileSync(tmp, bytesOut);
|
|
1387
1476
|
chmodSync(tmp, preservedMode);
|
|
1388
1477
|
// WR-1A ③: revalidate against the citation right before the
|
|
@@ -1392,9 +1481,13 @@ export function editFileTool(opts) {
|
|
|
1392
1481
|
unlinkSync(tmp);
|
|
1393
1482
|
return stale;
|
|
1394
1483
|
}
|
|
1484
|
+
if (movedUnder(reachParent, full)) {
|
|
1485
|
+
unlinkSync(tmp);
|
|
1486
|
+
return escapeResult(`${path}: its folder changed while the edit was prepared — nothing was written`);
|
|
1487
|
+
}
|
|
1395
1488
|
renameSync(tmp, full);
|
|
1396
1489
|
const written = realpathSync(full);
|
|
1397
|
-
if (!isWithin(realpathSync(
|
|
1490
|
+
if (!isWithin(realpathSync(reachRoot), written) || landedInStop(opts, path, written, reachParent)) {
|
|
1398
1491
|
// WR-1A ①: post-effect — fatal, the rename already landed.
|
|
1399
1492
|
return postEffectEscape("edit", path);
|
|
1400
1493
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tools-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"description": "kiso coding tools for Node hosts \u2014 read file, list directory, search text, write/edit file, shell command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"test": "vitest run"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@vincemakes/kiso-core": "0.
|
|
28
|
+
"@vincemakes/kiso-core": "0.54.0",
|
|
29
29
|
"ignore": "^7.0.9"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|