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