@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.
- package/dist/index.js +102 -17
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return
|
|
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
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
24
|
+
"@vincemakes/kiso-core": "0.24.4"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.2",
|