@vincemakes/kiso-tools-node 0.53.0 → 0.54.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/dist/index.d.ts +10 -0
- package/dist/index.js +115 -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`
|
|
@@ -316,6 +325,7 @@ interface SettledTask {
|
|
|
316
325
|
}
|
|
317
326
|
type ShellInput = {
|
|
318
327
|
command: string;
|
|
328
|
+
description?: string;
|
|
319
329
|
timeoutMs?: number;
|
|
320
330
|
foregroundMs?: number;
|
|
321
331
|
background?: boolean;
|
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,16 @@ 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
|
+
// the worker compares workspace-relative paths, so the stops are named that way: in the workspace too, where an
|
|
676
|
+
// absolute excludeRoots entry used to be passed as is and never matched (a primary at ~ walked ~/.kiso)
|
|
677
|
+
excluded: reach.base === null
|
|
678
|
+
? (opts.excludeRoots ?? []).map((ex) => relative(realOrSelf(opts.workspaceRoot), realOrSelf(ex)).split(sep).join("/"))
|
|
679
|
+
: [...(opts.excludeRoots ?? []), ...(opts.stopRoots ?? [])].map((ex) => relative(diskPath(reach.base), diskPath(ex)).split(sep).join("/")),
|
|
612
680
|
maxEntries: MAX_DIR_ENTRIES,
|
|
613
681
|
maxVisited: opts.limits?.searchMaxFiles ?? SEARCH_MAX_FILES,
|
|
614
682
|
deadline,
|
|
@@ -620,7 +688,8 @@ export function listDirTool(opts) {
|
|
|
620
688
|
const walk = outcome.reply;
|
|
621
689
|
if (walk?.error !== undefined)
|
|
622
690
|
return { content: `list_dir failed: ${walk.error}`, isError: true, errorKind: "fatal" };
|
|
623
|
-
|
|
691
|
+
// beyond the workspace a result is an absolute path: a relative one would name a file in the primary
|
|
692
|
+
const files = (walk?.files ?? []).map((f) => (reach.base !== null ? join(reach.base, f) : f));
|
|
624
693
|
const body = files.length ? cap(files.join("\n")) : `(no match for ${glob})`;
|
|
625
694
|
// TWO truncations, different remedies, and neither claimed
|
|
626
695
|
// when it did not happen: a walk that silently returns less
|
|
@@ -795,8 +864,10 @@ export function searchTextTool(opts) {
|
|
|
795
864
|
promptGuidelines: ["narrow the pattern when the result caps — never re-run a broad search"],
|
|
796
865
|
execute: async ({ pattern, path, caseSensitive }, ctx) => {
|
|
797
866
|
let root;
|
|
867
|
+
let reach;
|
|
798
868
|
try {
|
|
799
|
-
|
|
869
|
+
reach = resolveReach(opts, path ?? ".", ctx?.sessionId, false);
|
|
870
|
+
root = reach.full;
|
|
800
871
|
}
|
|
801
872
|
catch (err) {
|
|
802
873
|
if (err instanceof PathEscapeError)
|
|
@@ -828,9 +899,13 @@ export function searchTextTool(opts) {
|
|
|
828
899
|
return p;
|
|
829
900
|
}
|
|
830
901
|
};
|
|
831
|
-
const searchRootReal = realOrSelf(root);
|
|
832
|
-
|
|
833
|
-
|
|
902
|
+
const searchRootReal = reach.base !== null ? diskPath(root) : realOrSelf(root);
|
|
903
|
+
// beyond the workspace a match names an absolute path; a single file's is relative to its folder
|
|
904
|
+
const searchBase = reach.base === null ? null : single !== null ? dirname(searchRootReal) : diskPath(reach.base);
|
|
905
|
+
// beyond the workspace, by the disk's own spelling: a Windows short name and its long name are one folder
|
|
906
|
+
const canon = reach.base !== null ? diskPath : realOrSelf;
|
|
907
|
+
const excluded = [...(opts.excludeRoots ?? []), ...(reach.base !== null ? (opts.stopRoots ?? []) : [])]
|
|
908
|
+
.map(canon)
|
|
834
909
|
.filter((ex) => !(searchRootReal === ex || searchRootReal.startsWith(`${ex}/`)))
|
|
835
910
|
.map((ex) => relative(searchRootReal, ex));
|
|
836
911
|
const maxFileBytes = opts.limits?.searchMaxFileBytes ?? SEARCH_MAX_FILE_BYTES;
|
|
@@ -844,7 +919,7 @@ export function searchTextTool(opts) {
|
|
|
844
919
|
// root uses: `full` is walked from a realpath'd root, and making
|
|
845
920
|
// a path relative between a resolved and an unresolved base
|
|
846
921
|
// 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);
|
|
922
|
+
{ 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
923
|
if (outcome.kind === "aborted")
|
|
849
924
|
return { content: "search_text aborted", isError: true, errorKind: "fatal" };
|
|
850
925
|
if (outcome.kind === "error")
|
|
@@ -852,7 +927,7 @@ export function searchTextTool(opts) {
|
|
|
852
927
|
const r = outcome.reply;
|
|
853
928
|
if (r?.error !== undefined)
|
|
854
929
|
return { content: `search_text failed: ${r.error}`, isError: true, errorKind: "fatal" };
|
|
855
|
-
const matches = r?.matches ?? [];
|
|
930
|
+
const matches = (r?.matches ?? []).map((m) => (searchBase !== null ? under(searchBase, m) : m));
|
|
856
931
|
const stopped = outcome.kind === "timeout" || (r?.stopped ?? false);
|
|
857
932
|
let content = matches.length ? cap(matches.join("\n")) : "(no matches)";
|
|
858
933
|
if (r !== undefined) {
|
|
@@ -908,8 +983,10 @@ export function writeFileTool(opts) {
|
|
|
908
983
|
// tolerance in the reader, strictness in the comparison.
|
|
909
984
|
let expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
|
|
910
985
|
let full;
|
|
986
|
+
let reachRoot;
|
|
987
|
+
let reachParent;
|
|
911
988
|
try {
|
|
912
|
-
full =
|
|
989
|
+
({ full, root: reachRoot, parent: reachParent } = resolveReach(opts, path, ctx?.sessionId, true));
|
|
913
990
|
}
|
|
914
991
|
catch (err) {
|
|
915
992
|
if (err instanceof PathEscapeError)
|
|
@@ -977,6 +1054,9 @@ export function writeFileTool(opts) {
|
|
|
977
1054
|
// workspace; the directories this call creates are removed again
|
|
978
1055
|
// if the file never lands.
|
|
979
1056
|
let createdDirs;
|
|
1057
|
+
// beyond the workspace: the folder must still be the one checked BEFORE anything is created or written in it (the review's M7)
|
|
1058
|
+
if (movedUnder(reachParent, full))
|
|
1059
|
+
return escapeResult(`${path}: its folder changed while the write was prepared — nothing was written`);
|
|
980
1060
|
if (!exists) {
|
|
981
1061
|
try {
|
|
982
1062
|
createdDirs = mkdirSync(dirname(full), { recursive: true });
|
|
@@ -1015,9 +1095,18 @@ export function writeFileTool(opts) {
|
|
|
1015
1095
|
// mode, so it is copied onto the temp first).
|
|
1016
1096
|
if (exists)
|
|
1017
1097
|
preservedMode = statSync(full).mode & 0o7777;
|
|
1098
|
+
if (movedUnder(reachParent, full)) {
|
|
1099
|
+
dropCreatedDirs();
|
|
1100
|
+
return escapeResult(`${path}: its folder changed while the write was prepared — nothing was written`);
|
|
1101
|
+
}
|
|
1018
1102
|
writeFileSync(tmp, content, "utf8");
|
|
1019
1103
|
if (preservedMode !== undefined)
|
|
1020
1104
|
chmodSync(tmp, preservedMode);
|
|
1105
|
+
if (movedUnder(reachParent, full)) {
|
|
1106
|
+
unlinkSync(tmp);
|
|
1107
|
+
dropCreatedDirs();
|
|
1108
|
+
return escapeResult(`${path}: its folder changed while the write was prepared — nothing was written`);
|
|
1109
|
+
}
|
|
1021
1110
|
committing = true;
|
|
1022
1111
|
if (!exists) {
|
|
1023
1112
|
// WR-1A ②: link(2) or refusal — atomic no-clobber, fail
|
|
@@ -1043,7 +1132,7 @@ export function writeFileTool(opts) {
|
|
|
1043
1132
|
// the write landed outside the workspace — say so instead of
|
|
1044
1133
|
// claiming success. (The write itself cannot be undone.)
|
|
1045
1134
|
const written = realpathSync(full);
|
|
1046
|
-
if (!isWithin(realpathSync(
|
|
1135
|
+
if (!isWithin(realpathSync(reachRoot), written) || landedInStop(opts, path, written, reachParent)) {
|
|
1047
1136
|
// WR-1A ①: the rename HAPPENED — this is a post-effect
|
|
1048
1137
|
// verification failure, fatal, never a precondition.
|
|
1049
1138
|
return postEffectEscape("write", path);
|
|
@@ -1259,8 +1348,10 @@ export function editFileTool(opts) {
|
|
|
1259
1348
|
return { content: shape, isError: true, errorKind: "invalid_input" };
|
|
1260
1349
|
const { hunks, single } = shape;
|
|
1261
1350
|
let full;
|
|
1351
|
+
let reachRoot;
|
|
1352
|
+
let reachParent;
|
|
1262
1353
|
try {
|
|
1263
|
-
full =
|
|
1354
|
+
({ full, root: reachRoot, parent: reachParent } = resolveReach(opts, path, ctx?.sessionId, true));
|
|
1264
1355
|
}
|
|
1265
1356
|
catch (err) {
|
|
1266
1357
|
if (err instanceof PathEscapeError)
|
|
@@ -1383,6 +1474,8 @@ export function editFileTool(opts) {
|
|
|
1383
1474
|
// ONE buffer is written and hashed: the revision returned is the
|
|
1384
1475
|
// file's on disk, CRLF or not (the review's finding)
|
|
1385
1476
|
const bytesOut = Buffer.from(crlf ? edited.replace(/\n/g, "\r\n") : edited, "utf8");
|
|
1477
|
+
if (movedUnder(reachParent, full))
|
|
1478
|
+
return escapeResult(`${path}: its folder changed while the edit was prepared — nothing was written`);
|
|
1386
1479
|
writeFileSync(tmp, bytesOut);
|
|
1387
1480
|
chmodSync(tmp, preservedMode);
|
|
1388
1481
|
// WR-1A ③: revalidate against the citation right before the
|
|
@@ -1392,9 +1485,13 @@ export function editFileTool(opts) {
|
|
|
1392
1485
|
unlinkSync(tmp);
|
|
1393
1486
|
return stale;
|
|
1394
1487
|
}
|
|
1488
|
+
if (movedUnder(reachParent, full)) {
|
|
1489
|
+
unlinkSync(tmp);
|
|
1490
|
+
return escapeResult(`${path}: its folder changed while the edit was prepared — nothing was written`);
|
|
1491
|
+
}
|
|
1395
1492
|
renameSync(tmp, full);
|
|
1396
1493
|
const written = realpathSync(full);
|
|
1397
|
-
if (!isWithin(realpathSync(
|
|
1494
|
+
if (!isWithin(realpathSync(reachRoot), written) || landedInStop(opts, path, written, reachParent)) {
|
|
1398
1495
|
// WR-1A ①: post-effect — fatal, the rename already landed.
|
|
1399
1496
|
return postEffectEscape("edit", path);
|
|
1400
1497
|
}
|
|
@@ -1483,6 +1580,8 @@ export function shellTool(opts) {
|
|
|
1483
1580
|
type: "object",
|
|
1484
1581
|
properties: {
|
|
1485
1582
|
command: { type: "string", description: "The command to run" },
|
|
1583
|
+
// background-task names step 2 (kiso-doc plan rev 3, owner-approved): a name for people, never read by the executor
|
|
1584
|
+
description: { type: "string", description: "Short user-facing description of the intended action, not its status or result." },
|
|
1486
1585
|
foregroundMs: { type: "number", description: "How long to wait for the result, in ms (default 60000); if you need the result to go on, allow enough time" },
|
|
1487
1586
|
background: {
|
|
1488
1587
|
type: "boolean",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tools-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.1",
|
|
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.1",
|
|
29
29
|
"ignore": "^7.0.9"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|