@vincemakes/kiso-tools-node 0.39.1 → 0.40.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
@@ -127,6 +127,15 @@ export interface WorkspaceToolsOptions {
127
127
  * read.
128
128
  */
129
129
  readonly excludeRoots?: readonly string[];
130
+ /**
131
+ * ACCESS, where excludeRoots is discovery: files kiso NEVER serves to a
132
+ * model, however they are named (protected.ts) — read_file, edit_file
133
+ * and write_file refuse them, search_text never returns a line from
134
+ * them. The CLI passes its own credential store (and a user's own
135
+ * `protectedPaths`). A protected file's name-suffixed siblings (the
136
+ * writer's temp file and lock) share the protection.
137
+ */
138
+ readonly protectedFiles?: readonly string[];
130
139
  readonly limits?: {
131
140
  /** search_text: skip a file larger than this (default 1 MiB). */
132
141
  readonly searchMaxFileBytes?: number;
@@ -174,6 +183,11 @@ export declare function editFileTool(opts: WorkspaceToolsOptions): Tool<{
174
183
  expectedRevision?: string;
175
184
  }>;
176
185
  export { SHELL_STRIP_EXACT, strippedShellEnv } from "./secret-env.js";
186
+ export { PROTECTED_REFUSAL, diskPath, isProtectedPath, protectedIdentity, protectedRefusalText, readUnlessProtected, type ProtectedIdentity } from "./protected.js";
187
+ /** The search corpus's credential rule, by name — exported so the CLI's
188
+ * read-only shell allow holds a shell read to the same definition rather
189
+ * than a copy of it. */
190
+ export { isCredentialName } from "./corpus.js";
177
191
  export declare function shellTool(opts: WorkspaceToolsOptions): Tool<{
178
192
  command: string;
179
193
  timeoutMs?: number;
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { execFile, execFileSync, spawn } from "node:child_process";
21
21
  import { promisify } from "node:util";
22
- import { appendFileSync, chmodSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
22
+ import { appendFileSync, chmodSync, existsSync, linkSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
23
23
  import { Worker } from "node:worker_threads";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import { createHash } from "node:crypto";
@@ -29,6 +29,7 @@ import { defineTool } from "@vincemakes/kiso-core";
29
29
  // WR-1/WR-1A — the revision-guard primitives (unit-tested in wr1a-coda):
30
30
  import { strippedShellEnv } from "./secret-env.js";
31
31
  import { contentRevision, normalizeRevision, postEffectEscape, precondition, publishNewFile, revalidateBeforeRename } from "./wr1.js";
32
+ import { isProtectedPath, protectedIdentity, protectedRefusalText, readUnlessProtected } from "./protected.js";
32
33
  import { CORPUS_MAX_DEPTH, globToRegExp, walkCorpus } from "./corpus.js";
33
34
  import { describeSearchMiss } from "./search-miss.js";
34
35
  /**
@@ -353,6 +354,11 @@ export function readFileTool(opts) {
353
354
  const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
354
355
  try {
355
356
  const full = resolveWithinRoot(opts.workspaceRoot, path);
357
+ // the credential store is never served (protected.ts) — checked
358
+ // by the disk's own resolution and by inode, before any read
359
+ const guard = protectedIdentity(opts.protectedFiles);
360
+ if (isProtectedPath(full, guard))
361
+ return precondition(protectedRefusalText("read_file", path));
356
362
  const denied = await inodeReadPolicy(opts.workspaceRoot, full);
357
363
  if (denied !== null)
358
364
  return escapeResult(denied);
@@ -388,7 +394,10 @@ export function readFileTool(opts) {
388
394
  // 64 MiB read — about 100 ms, measured — against the 180+
389
395
  // seconds this finding is named for. Determinism is worth
390
396
  // more than that hitch.
391
- const bytes = readFileSync(full);
397
+ // one descriptor, compared by inode before a byte is read
398
+ const bytes = readUnlessProtected(full, guard);
399
+ if (bytes === null)
400
+ return precondition(protectedRefusalText("read_file", path));
392
401
  const content = bytes.toString("utf8");
393
402
  // The lines the file DISPLAYS: a trailing newline's empty split
394
403
  // element is not a line. Line k = split[k-1], 1-based.
@@ -737,7 +746,7 @@ export function searchTextTool(opts) {
737
746
  // root uses: `full` is walked from a realpath'd root, and making
738
747
  // a path relative between a resolved and an unresolved base
739
748
  // yields `../..` the moment a symlink sits between them.
740
- { token: 0, root: searchRootReal, workspaceRoot: realOrSelf(opts.workspaceRoot), single, pattern, flags, excluded, maxFileBytes, maxFiles, deadline, maxMatches: MAX_SEARCH_MATCHES, sniffBytes: BINARY_SNIFF_BYTES }, deadline, ctx.signal);
749
+ { 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);
741
750
  if (outcome.kind === "aborted")
742
751
  return { content: "search_text aborted", isError: true, errorKind: "fatal" };
743
752
  if (outcome.kind === "error")
@@ -807,6 +816,9 @@ export function writeFileTool(opts) {
807
816
  return escapeResult(err.message);
808
817
  throw err;
809
818
  }
819
+ const guard = protectedIdentity(opts.protectedFiles);
820
+ if (isProtectedPath(full, guard))
821
+ return precondition(protectedRefusalText("write_file", path));
810
822
  // WR-1 — the observed-revision stale-write guard. The decision
811
823
  // lattice runs BEFORE any bytes move; every refusal is a
812
824
  // precondition with one actionable sentence. What it proves:
@@ -843,7 +855,10 @@ export function writeFileTool(opts) {
843
855
  if (wSize > maxReadBytes) {
844
856
  return precondition(`write_file: ${path} is ${mib(wSize)} — too large to read (ceiling ${mib(maxReadBytes)}); use shell with sed/head to take a range`);
845
857
  }
846
- const current = contentRevision(readFileSync(full));
858
+ const held = readUnlessProtected(full, guard);
859
+ if (held === null)
860
+ return precondition(protectedRefusalText("write_file", path));
861
+ const current = contentRevision(held);
847
862
  if (current !== expectedRevision) {
848
863
  return precondition(`write_file: ${path} changed since ${expectedRevision} — read it again and cite its [rev:…] line, then re-apply the change`);
849
864
  }
@@ -1022,6 +1037,9 @@ export function editFileTool(opts) {
1022
1037
  return escapeResult(err.message);
1023
1038
  throw err;
1024
1039
  }
1040
+ const guard = protectedIdentity(opts.protectedFiles);
1041
+ if (isProtectedPath(full, guard))
1042
+ return precondition(protectedRefusalText("edit_file", path));
1025
1043
  // WR-1: edits always target an existing file — the revision is
1026
1044
  // not optional here, and the refusal teaches the protocol.
1027
1045
  if (expectedRevision === undefined) {
@@ -1054,7 +1072,9 @@ export function editFileTool(opts) {
1054
1072
  if (eSize > maxReadBytes) {
1055
1073
  return precondition(`edit_file: ${path} is ${mib(eSize)} — too large to read (ceiling ${mib(maxReadBytes)}); use shell with sed/head to take a range`);
1056
1074
  }
1057
- const bytes = readFileSync(full);
1075
+ const bytes = readUnlessProtected(full, guard);
1076
+ if (bytes === null)
1077
+ return precondition(protectedRefusalText("edit_file", path));
1058
1078
  const current = contentRevision(bytes);
1059
1079
  if (current !== expectedRevision) {
1060
1080
  return precondition(`edit_file: ${path} changed since ${expectedRevision} — read it again and cite its [rev:…] line, then re-apply the change`);
@@ -1145,6 +1165,11 @@ export function editFileTool(opts) {
1145
1165
  // with a hand-kept copy over there; the copy never learned the
1146
1166
  // declared names, which is how MCP children kept leaking.
1147
1167
  export { SHELL_STRIP_EXACT, strippedShellEnv } from "./secret-env.js";
1168
+ export { PROTECTED_REFUSAL, diskPath, isProtectedPath, protectedIdentity, protectedRefusalText, readUnlessProtected } from "./protected.js";
1169
+ /** The search corpus's credential rule, by name — exported so the CLI's
1170
+ * read-only shell allow holds a shell read to the same definition rather
1171
+ * than a copy of it. */
1172
+ export { isCredentialName } from "./corpus.js";
1148
1173
  export function shellTool(opts) {
1149
1174
  return defineTool({
1150
1175
  name: "shell",
@@ -0,0 +1,50 @@
1
+ /**
2
+ * kiso never serves its own credential store to a model.
3
+ *
4
+ * The 2026-09-14 incident: a session with its cwd at home called `read_file
5
+ * .kiso/auth.json` in default mode. The read was auto-allowed, and the
6
+ * store's API key and OAuth tokens went to the provider as conversation
7
+ * context. `excludeRoots` could not have stopped it — it governs what a walk
8
+ * DISCOVERS, not what a named path may ACCESS (its own header says so).
9
+ *
10
+ * This is the access rule, for the files the host names (`protectedFiles`):
11
+ * the credential store and its name-suffixed siblings (the writer's temp
12
+ * file and lock), plus whatever a user lists in their own config. A path is
13
+ * protected when the DISK resolves it to one of them — `realpath.native` of
14
+ * the longest existing prefix, so a symlink, a `..` through a symlink and a
15
+ * case variant all reach the same file — or when it is the same INODE (a
16
+ * hard link under another name).
17
+ *
18
+ * This module imports nothing beyond node itself, as secret-env.ts does:
19
+ * the same rule is read by the search worker and by the CLI's shell check.
20
+ */
21
+ export declare const PROTECTED_REFUSAL = "kiso never serves its own credential store to a model";
22
+ /** The path as the disk resolves it: realpath.native of the longest
23
+ * existing prefix, then the tail that does not exist yet. */
24
+ export declare function diskPath(p: string): string;
25
+ export interface ProtectedIdentity {
26
+ /** the protected files as the disk spells them */
27
+ readonly paths: readonly string[];
28
+ /** `dev:ino` of each protected file that exists — a hard link is the same file */
29
+ readonly inodes: readonly string[];
30
+ }
31
+ /** Resolve the protected list once; the per-path checks compare against it. */
32
+ export declare function protectedIdentity(files?: readonly string[]): ProtectedIdentity;
33
+ /** Whether a disk-resolved path is a protected file or one of its
34
+ * name-suffixed siblings (`auth.json.tmp-<pid>`, `auth.json.lock/…`). */
35
+ export declare function isProtectedDiskPath(d: string, id: ProtectedIdentity): boolean;
36
+ /** The whole question for one path: by where the disk resolves it, and by
37
+ * inode when it exists. */
38
+ export declare function isProtectedPath(p: string, id: ProtectedIdentity): boolean;
39
+ /**
40
+ * Read a file through ONE descriptor, and refuse it when that descriptor
41
+ * is a protected file. The path check before it judges a NAME, and a name
42
+ * can be swapped for a symlink between the check and the read; the
43
+ * descriptor is the file actually opened. Returns null when it is
44
+ * protected. (The sandbox is the guarantee; this closes the file tools'
45
+ * own check-then-use window.)
46
+ */
47
+ export declare function readUnlessProtected(full: string, id: ProtectedIdentity): Buffer | null;
48
+ /** The refusal a tool returns — a precondition (refused, never attempted),
49
+ * stated as permanent so a model does not retry it under another name. */
50
+ export declare function protectedRefusalText(tool: string, path: string): string;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * kiso never serves its own credential store to a model.
3
+ *
4
+ * The 2026-09-14 incident: a session with its cwd at home called `read_file
5
+ * .kiso/auth.json` in default mode. The read was auto-allowed, and the
6
+ * store's API key and OAuth tokens went to the provider as conversation
7
+ * context. `excludeRoots` could not have stopped it — it governs what a walk
8
+ * DISCOVERS, not what a named path may ACCESS (its own header says so).
9
+ *
10
+ * This is the access rule, for the files the host names (`protectedFiles`):
11
+ * the credential store and its name-suffixed siblings (the writer's temp
12
+ * file and lock), plus whatever a user lists in their own config. A path is
13
+ * protected when the DISK resolves it to one of them — `realpath.native` of
14
+ * the longest existing prefix, so a symlink, a `..` through a symlink and a
15
+ * case variant all reach the same file — or when it is the same INODE (a
16
+ * hard link under another name).
17
+ *
18
+ * This module imports nothing beyond node itself, as secret-env.ts does:
19
+ * the same rule is read by the search worker and by the CLI's shell check.
20
+ */
21
+ import { basename, dirname, join } from "node:path";
22
+ import { closeSync, fstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
23
+ export const PROTECTED_REFUSAL = "kiso never serves its own credential store to a model";
24
+ /** The path as the disk resolves it: realpath.native of the longest
25
+ * existing prefix, then the tail that does not exist yet. */
26
+ export function diskPath(p) {
27
+ let head = p;
28
+ const tail = [];
29
+ for (;;) {
30
+ try {
31
+ const real = realpathSync.native(head);
32
+ return tail.length > 0 ? join(real, ...tail) : real;
33
+ }
34
+ catch {
35
+ const parent = dirname(head);
36
+ if (parent === head)
37
+ return p;
38
+ tail.unshift(basename(head));
39
+ head = parent;
40
+ }
41
+ }
42
+ }
43
+ /** Resolve the protected list once; the per-path checks compare against it. */
44
+ export function protectedIdentity(files = []) {
45
+ const paths = files.map(diskPath);
46
+ const inodes = [];
47
+ for (const p of paths) {
48
+ try {
49
+ const st = statSync(p);
50
+ if (st.isFile())
51
+ inodes.push(`${st.dev}:${st.ino}`);
52
+ }
53
+ catch {
54
+ // not there yet — the path rule still holds
55
+ }
56
+ }
57
+ return { paths, inodes };
58
+ }
59
+ /** Whether a disk-resolved path is a protected file or one of its
60
+ * name-suffixed siblings (`auth.json.tmp-<pid>`, `auth.json.lock/…`). */
61
+ export function isProtectedDiskPath(d, id) {
62
+ return id.paths.some((pf) => d === pf || d.startsWith(`${pf}.`));
63
+ }
64
+ /** The whole question for one path: by where the disk resolves it, and by
65
+ * inode when it exists. */
66
+ export function isProtectedPath(p, id) {
67
+ if (id.paths.length === 0)
68
+ return false;
69
+ const d = diskPath(p);
70
+ if (isProtectedDiskPath(d, id))
71
+ return true;
72
+ if (id.inodes.length === 0)
73
+ return false;
74
+ try {
75
+ const st = statSync(d);
76
+ return st.isFile() && id.inodes.includes(`${st.dev}:${st.ino}`);
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ /**
83
+ * Read a file through ONE descriptor, and refuse it when that descriptor
84
+ * is a protected file. The path check before it judges a NAME, and a name
85
+ * can be swapped for a symlink between the check and the read; the
86
+ * descriptor is the file actually opened. Returns null when it is
87
+ * protected. (The sandbox is the guarantee; this closes the file tools'
88
+ * own check-then-use window.)
89
+ */
90
+ export function readUnlessProtected(full, id) {
91
+ const fd = openSync(full, "r");
92
+ try {
93
+ const st = fstatSync(fd);
94
+ if (id.inodes.includes(`${st.dev}:${st.ino}`))
95
+ return null;
96
+ return readFileSync(fd);
97
+ }
98
+ finally {
99
+ closeSync(fd);
100
+ }
101
+ }
102
+ /** The refusal a tool returns — a precondition (refused, never attempted),
103
+ * stated as permanent so a model does not retry it under another name. */
104
+ export function protectedRefusalText(tool, path) {
105
+ return `${tool}: ${path} — ${PROTECTED_REFUSAL}. This is permanent: do not retry it, under this path or another.`;
106
+ }
@@ -15,6 +15,7 @@
15
15
  * one `SearchReply` and exits. A call token rides both ways so a late
16
16
  * message from a superseded worker is ignored.
17
17
  */
18
+ import { type ProtectedIdentity } from "./protected.js";
18
19
  export interface SearchRequest {
19
20
  readonly token: number;
20
21
  readonly root: string;
@@ -29,6 +30,10 @@ export interface SearchRequest {
29
30
  readonly pattern: string;
30
31
  readonly flags: string;
31
32
  readonly excluded: readonly string[];
33
+ /** files never searched: kiso's credential store (protected.ts) — skipped
34
+ * without a hit, matched by inode and, for a protected name, by the
35
+ * disk's own resolution */
36
+ readonly protectedIdentity?: ProtectedIdentity;
32
37
  readonly maxFileBytes: number;
33
38
  readonly maxFiles: number;
34
39
  /** the call's wall-clock deadline (epoch ms): the walk stops COOPERATIVELY
@@ -19,6 +19,19 @@ import { open, readdir } from "node:fs/promises";
19
19
  import { basename, join, relative } from "node:path";
20
20
  import { corpusSkips, layersEntering, readLayer } from "./corpus.js";
21
21
  import { isMainThread, parentPort } from "node:worker_threads";
22
+ import { diskPath, isProtectedDiskPath } from "./protected.js";
23
+ /** A scanned file is protected by inode (the store itself, under any name
24
+ * or case), or — for a name that starts like a protected one, the writer's
25
+ * temp file and lock — by the disk's own resolution. The resolution runs
26
+ * only for such a name, so an ordinary search pays nothing for it. */
27
+ function isProtectedScan(full, st, id) {
28
+ if (id.inodes.includes(`${st.dev}:${st.ino}`))
29
+ return true;
30
+ const name = basename(full).toLowerCase();
31
+ if (!id.paths.some((p) => name.startsWith(basename(p).toLowerCase())))
32
+ return false;
33
+ return isProtectedDiskPath(diskPath(full), id);
34
+ }
22
35
  /** ACI-5 — the excerpt WINDOWS THE MATCH instead of taking the line's head.
23
36
  *
24
37
  * `line.trim().slice(0, 160)` answers "what does this line start with",
@@ -77,6 +90,8 @@ export async function runSearch(req) {
77
90
  let text;
78
91
  try {
79
92
  const st = await fh.stat();
93
+ if (req.protectedIdentity !== undefined && isProtectedScan(full, st, req.protectedIdentity))
94
+ return;
80
95
  if (st.nlink > 1) {
81
96
  multiLink += 1;
82
97
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tools-node",
3
- "version": "0.39.1",
3
+ "version": "0.40.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.39.1",
28
+ "@vincemakes/kiso-core": "0.40.0",
29
29
  "ignore": "^7.0.9"
30
30
  },
31
31
  "devDependencies": {