@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 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 { full: resolveWithinRoot(opts.workspaceRoot, input), root: opts.workspaceRoot };
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 dir = resolveWithinRoot(opts.workspaceRoot, path ?? ".");
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
- excluded: opts.excludeRoots ?? [],
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
- const files = walk?.files ?? [];
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
- root = resolveWithinRoot(opts.workspaceRoot, path ?? ".");
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
- const excluded = (opts.excludeRoots ?? [])
833
- .map(realOrSelf)
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 = resolveWithinRoot(opts.workspaceRoot, path);
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(opts.workspaceRoot), written)) {
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 = resolveWithinRoot(opts.workspaceRoot, path);
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(opts.workspaceRoot), written)) {
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.52.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.52.0",
28
+ "@vincemakes/kiso-core": "0.54.0",
29
29
  "ignore": "^7.0.9"
30
30
  },
31
31
  "devDependencies": {