@vincemakes/kiso-tools-node 0.24.3 → 0.24.4

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.
Files changed (2) hide show
  1. package/dist/index.js +102 -17
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -17,7 +17,8 @@
17
17
  * states what was dropped (deterministic per file state), so the model
18
18
  * always has a path to the full content.
19
19
  */
20
- import { execFileSync, spawn } from "node:child_process";
20
+ import { execFile, execFileSync, spawn } from "node:child_process";
21
+ import { promisify } from "node:util";
21
22
  import { chmodSync, existsSync, linkSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, appendFileSync, mkdirSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
22
23
  import { readdir } from "node:fs/promises";
23
24
  import { createHash } from "node:crypto";
@@ -186,12 +187,41 @@ export function canonicalTargetPath(input) {
186
187
  * - non-regular files (sockets, devices, fifos): refused.
187
188
  * Returns a denial reason, or null when the file is safe to read.
188
189
  */
189
- function inodeReadPolicy(root, full) {
190
+ /** DC-52 — the guard's verdict, per (dev, ino), for this process. The
191
+ * scan is the expensive thing; the answer is a fact about an inode and
192
+ * does not change under us within a call. */
193
+ const inodeVerdict = new Map();
194
+ /**
195
+ * DC-52 — BOUNDED, ASYNCHRONOUS, AND SILENT.
196
+ *
197
+ * This was `execFileSync("find", [root, "-xdev", "-inum", …])` with no
198
+ * `stdio`, run once per multi-link file. Three faults in one line:
199
+ *
200
+ * 1. no `stdio` gives the child the PARENT'S stderr, which is the
201
+ * terminal — `find: …: Operation not permitted` went straight past
202
+ * the compositor's frame and over the composer;
203
+ * 2. the scan is unbounded, and SYNCHRONOUS: with the workspace root
204
+ * at `~` a single call is tens of seconds with the event loop
205
+ * frozen, so `esc` does nothing and the whole product looks hung;
206
+ * 3. it ran for `search_text` too, which returns a 160-character
207
+ * excerpt — a disk traversal to decide whether a line may be
208
+ * quoted is not a trade anyone would make.
209
+ *
210
+ * Now: `execFile` with a 2s budget, stderr discarded, verdict cached.
211
+ * A scan that does not finish inside the budget is fail-closed exactly
212
+ * as an unverifiable one always was — refused, never hung. Fault 3 is
213
+ * answered by `search_text` not calling this at all.
214
+ */
215
+ async function inodeReadPolicy(root, full) {
190
216
  const st = statSync(full);
191
217
  if (!st.isFile())
192
218
  return `not a regular file — refusing to read (${full})`;
193
219
  if (st.nlink <= 1)
194
220
  return null;
221
+ const key = `${st.dev}:${st.ino}`;
222
+ const cached = inodeVerdict.get(key);
223
+ if (cached !== undefined)
224
+ return cached;
195
225
  // round 4: the link count is verified STRUCTURALLY, never by counting
196
226
  // newline-split text. `find -print0` emits NUL-separated paths — a file
197
227
  // named "inside\nspoof" is ONE path, not two — and every match is then
@@ -205,7 +235,15 @@ function inodeReadPolicy(root, full) {
205
235
  let inside = 0;
206
236
  try {
207
237
  const rootReal = realpathSync(root);
208
- const out = execFileSync("find", [rootReal, "-xdev", "-inum", String(st.ino), "-print0"], { encoding: "utf8", maxBuffer: 1 << 20 });
238
+ const { stdout: out } = await promisify(execFile)("find", [rootReal, "-xdev", "-inum", String(st.ino), "-print0"],
239
+ // DC-52: the child never outlives the budget, and its stderr
240
+ // never reaches the terminal. The second is the ASYNC form's
241
+ // own doing and is the whole reason to prefer it here:
242
+ // `execFileSync` without an explicit `stdio` gives the child
243
+ // the PARENT'S stderr, which is how `find: … Operation not
244
+ // permitted` got over the composer. `execFile` pipes both
245
+ // streams into the callback; there is nowhere for it to go.
246
+ { encoding: "utf8", maxBuffer: 1 << 20, timeout: INODE_SCAN_MS });
209
247
  for (const path of out.split("\0")) {
210
248
  if (path === "")
211
249
  continue;
@@ -223,12 +261,15 @@ function inodeReadPolicy(root, full) {
223
261
  catch {
224
262
  inside = -1; // cannot verify — refuse (fail-closed)
225
263
  }
226
- if (inside < 0 || inside < st.nlink) {
227
- const verified = inside < 0 ? "unverifiable" : `${inside}/${st.nlink}`;
228
- return `file has hard links outside the workspace (${verified} inside) — refusing to read (${full})`;
229
- }
230
- return null;
264
+ const verdict = inside < 0 || inside < st.nlink
265
+ ? `file has hard links outside the workspace (${inside < 0 ? "unverifiable" : `${inside}/${st.nlink}`} inside) — refusing to read (${full})`
266
+ : null;
267
+ inodeVerdict.set(key, verdict);
268
+ return verdict;
231
269
  }
270
+ /** DC-52 — the guard's budget. A scan that outruns it is fail-closed,
271
+ * which is what an unverifiable scan has always been. */
272
+ const INODE_SCAN_MS = 2_000;
232
273
  /** The "… N more lines" note — the actionable continuation: the exact
233
274
  * line the next read must start at, so the model can always reach the
234
275
  * full content in ranges (the red line). */
@@ -262,7 +303,7 @@ export function readFileTool(opts) {
262
303
  execute: async ({ path, offset, limit }) => {
263
304
  try {
264
305
  const full = resolveWithinRoot(opts.workspaceRoot, path);
265
- const denied = inodeReadPolicy(opts.workspaceRoot, full);
306
+ const denied = await inodeReadPolicy(opts.workspaceRoot, full);
266
307
  if (denied !== null)
267
308
  return escapeResult(denied);
268
309
  // WR-1: hash the raw bytes BEFORE decoding — the revision is a
@@ -442,6 +483,9 @@ export function searchTextTool(opts) {
442
483
  // depth cap and the node_modules/dotfile skip stay.
443
484
  const matches = [];
444
485
  let totalMatches = 0;
486
+ // DC-52 — what the search did NOT look at, so the note can say so.
487
+ let multiLink = 0;
488
+ let unreadableDirs = 0;
445
489
  // R3 — the walk YIELDS. It was `readdirSync` + `readFileSync` all
446
490
  // the way down inside an `async` body, which is the shape that
447
491
  // blocks Node's event loop for the whole traversal: measured at
@@ -470,14 +514,28 @@ export function searchTextTool(opts) {
470
514
  const scanFile = async (full) => {
471
515
  await breathe();
472
516
  try {
473
- // round 8: same inode boundary as read_file a hard link
474
- // to an external inode is not searched. round 4 (adversarial):
475
- // the link count is verified against the WORKSPACE
476
- // root, not the search subroot a link that lives
477
- // inside the workspace but outside the search dir is
478
- // legal and must not be silently skipped.
479
- if (inodeReadPolicy(opts.workspaceRoot, full) !== null)
517
+ // DC-52 SEARCH DOES NOT RUN THE INODE GUARD.
518
+ //
519
+ // Round 8 gave search the same inode boundary read_file
520
+ // has, and the boundary is right; what was wrong is the
521
+ // price. The guard's verification is a `find` over the
522
+ // whole workspace root, once per multi-link file — with
523
+ // the root at `~` that is tens of seconds each, and it
524
+ // ran on the owner's machine for eight minutes without
525
+ // finishing. A search returns a 160-character excerpt of
526
+ // a line. No excerpt is worth a disk traversal.
527
+ //
528
+ // So a multi-link file is SKIPPED, and counted, and the
529
+ // count is said. That is fail-closed at zero cost: the
530
+ // external-link case the guard exists for is refused
531
+ // exactly as before, and the legal case is refused too,
532
+ // which is a loss of coverage rather than of safety.
533
+ // read_file keeps the guard (bounded, above), and that
534
+ // is where the file's contents can actually be had.
535
+ if (statSync(full).nlink > 1) {
536
+ multiLink += 1;
480
537
  return;
538
+ }
481
539
  const text = readFileSync(full, "utf8");
482
540
  for (const [i, line] of text.split("\n").entries()) {
483
541
  if (regex.test(line)) {
@@ -495,7 +553,28 @@ export function searchTextTool(opts) {
495
553
  const walk = async (dir, depth) => {
496
554
  if (depth > 8)
497
555
  return;
498
- for (const entry of await readdir(dir, { withFileTypes: true })) {
556
+ // DC-52 a directory the OS REFUSES is skipped, not fatal.
557
+ //
558
+ // An unreadable FILE has always been skipped (the catch in
559
+ // scanFile); an unreadable DIRECTORY threw out of the walk
560
+ // and failed the whole tool. On macOS that is not an edge
561
+ // case — `~/Library/Accounts` and its neighbours are TCC
562
+ // protected, so a search anywhere under `~` died on one of
563
+ // them. The asymmetry was the defect: same fact, same
564
+ // remedy.
565
+ let entries;
566
+ try {
567
+ entries = await readdir(dir, { withFileTypes: true });
568
+ }
569
+ catch (err) {
570
+ const code = err.code;
571
+ if (code === "EACCES" || code === "EPERM") {
572
+ unreadableDirs += 1;
573
+ return;
574
+ }
575
+ throw err;
576
+ }
577
+ for (const entry of entries) {
499
578
  if (entry.name.startsWith(".") || entry.name === "node_modules")
500
579
  continue;
501
580
  const full = join(dir, entry.name);
@@ -515,6 +594,12 @@ export function searchTextTool(opts) {
515
594
  return { content: `search_text failed: ${err.message}`, isError: true, errorKind: "fatal" };
516
595
  }
517
596
  let content = matches.length ? cap(matches.join("\n")) : "(no matches)";
597
+ // DC-52: what was NOT searched is said. A silent skip is a
598
+ // result the model cannot tell is incomplete.
599
+ if (multiLink > 0)
600
+ content += `\n… ${multiLink} multi-link ${multiLink === 1 ? "file" : "files"} skipped (read_file verifies them individually)`;
601
+ if (unreadableDirs > 0)
602
+ content += `\n… ${unreadableDirs} unreadable ${unreadableDirs === 1 ? "directory" : "directories"} skipped`;
518
603
  if (totalMatches > matches.length) {
519
604
  // R-C item 2: the N of M form — the cap names its
520
605
  // continuation (narrow the pattern for more).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tools-node",
3
- "version": "0.24.3",
3
+ "version": "0.24.4",
4
4
  "description": "kiso coding tools for Node hosts — read file, list directory, search text, write/edit file, shell command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "test": "vitest run"
22
22
  },
23
23
  "dependencies": {
24
- "@vincemakes/kiso-core": "0.24.3"
24
+ "@vincemakes/kiso-core": "0.24.4"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",