@vincemakes/kiso-tools-node 0.24.5 → 0.26.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
@@ -91,14 +91,36 @@ export interface WorkspaceToolsOptions {
91
91
  * STRIPPED — a shell command must not inherit the agent's API keys (a
92
92
  * nested kiso would hit the REAL provider and blow up faux e2e runs;
93
93
  * the keys are an exposure surface for any command).
94
+ *
95
+ * ADR-0031 Amendment 1: a RECORD is the third shape — the STRIPPED
96
+ * environment plus these explicit entries, the entries winning (the
97
+ * MCP surface's decision 2, applied here). An embedding host injects
98
+ * what its tools need without a temp file and without opening the
99
+ * whole environment; an entry may deliberately re-add a stripped
100
+ * name — explicit beats the heuristic, as it does for MCP servers.
94
101
  */
95
- readonly shellEnv?: "inherit";
102
+ readonly shellEnv?: "inherit" | Readonly<Record<string, string>>;
96
103
  /**
97
104
  * DC-54 — the bounds that keep a tool call finite. Every field is
98
105
  * optional and defaults to the constant beside it; a host embedding
99
106
  * kiso over an unusually large or unusually small tree can move them,
100
107
  * and the gate can set them low enough to observe the stop.
101
108
  */
109
+ /**
110
+ * DC-49 — roots a WALK may not descend into, as realpaths.
111
+ *
112
+ * The CLI passes `$KISO_HOME`. With the workspace at `~` the walks
113
+ * otherwise report the user's own session logs as though they were
114
+ * their work — and `~/.kiso` escapes the dot-name skip only by
115
+ * accident, so a KISO_HOME anywhere else is walked in full.
116
+ *
117
+ * DISCOVERY, NOT ACCESS. This removes a directory from what a walk
118
+ * FINDS when it descends from above. A path the user or the model
119
+ * NAMES is still served, at the root or inside it: the exclusion is
120
+ * not a permission, and anything the model can name it can still
121
+ * read.
122
+ */
123
+ readonly excludeRoots?: readonly string[];
102
124
  readonly limits?: {
103
125
  /** search_text: skip a file larger than this (default 1 MiB). */
104
126
  readonly searchMaxFileBytes?: number;
package/dist/index.js CHANGED
@@ -562,7 +562,35 @@ export function searchTextTool(opts) {
562
562
  // the call budget: a silent skip is a result the model cannot
563
563
  // tell is incomplete.
564
564
  let skippedFiles = 0;
565
+ let excludedDirs = 0;
565
566
  let filesSeen = 0;
567
+ // DC-49 — realpath on BOTH sides, so a symlinked HOME still
568
+ // matches (the reviewer's constraint (d): `/tmp` is a symlink to
569
+ // `/private/tmp` on darwin, and a raw string compare there is a
570
+ // gate that passes on one machine and not another).
571
+ //
572
+ // A root that CONTAINS the search root is not an exclusion: the
573
+ // caller pointed at it, and refusing would make an explicit path
574
+ // unservable. That is the reviewer's constraint (c), expressed
575
+ // where it belongs — in the predicate, not in four call sites.
576
+ const realOrSelf = (p) => {
577
+ try {
578
+ return realpathSync(p);
579
+ }
580
+ catch {
581
+ return p;
582
+ }
583
+ };
584
+ const searchRootReal = realOrSelf(root);
585
+ const excluded = (opts.excludeRoots ?? [])
586
+ .map(realOrSelf)
587
+ .filter((ex) => !(searchRootReal === ex || searchRootReal.startsWith(`${ex}/`)));
588
+ const isExcluded = (dir) => {
589
+ if (excluded.length === 0)
590
+ return false;
591
+ const r = realOrSelf(dir);
592
+ return excluded.some((ex) => r === ex || r.startsWith(`${ex}/`));
593
+ };
566
594
  // An explicit flag, NOT `stoppedAt > 0`: a wall-clock budget can
567
595
  // expire before the first file is scanned, and a zero-valued
568
596
  // sentinel would then read as "never stopped" — the walk would
@@ -742,8 +770,18 @@ export function searchTextTool(opts) {
742
770
  if (entry.name.startsWith(".") || entry.name === "node_modules")
743
771
  continue;
744
772
  const full = join(dir, entry.name);
745
- if (entry.isDirectory())
773
+ if (entry.isDirectory()) {
774
+ // DC-49 — a walk does not DESCEND into an excluded root.
775
+ // Reaching it from above is what is refused; being
776
+ // pointed at it is not (see `excluded` below, which is
777
+ // seeded from the SEARCH ROOT and therefore empty when
778
+ // the root is itself excluded).
779
+ if (isExcluded(full)) {
780
+ excludedDirs += 1;
781
+ continue;
782
+ }
746
783
  await walk(full, depth + 1);
784
+ }
747
785
  else if (entry.isFile())
748
786
  await scanFile(full);
749
787
  }
@@ -766,6 +804,12 @@ export function searchTextTool(opts) {
766
804
  content += `\n… ${unreadableDirs} unreadable ${unreadableDirs === 1 ? "directory" : "directories"} skipped`;
767
805
  // DC-54 — one merged sentence, and only when it happened. A note
768
806
  // that always fires says nothing.
807
+ // DC-49 — what a WALK did not enter, in the same discipline the
808
+ // file skips already follow: a scoped result that reads as total
809
+ // is one the model cannot tell is scoped.
810
+ if (excludedDirs > 0) {
811
+ content += `\n… ${excludedDirs} ${excludedDirs === 1 ? "directory" : "directories"} excluded`;
812
+ }
769
813
  if (skippedFiles > 0 || stopped) {
770
814
  const parts = [];
771
815
  if (skippedFiles > 0)
@@ -803,6 +847,7 @@ export function writeFileTool(opts) {
803
847
  promptSnippet: "write_file — create or replace a whole file",
804
848
  promptGuidelines: ["write/edit: cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"],
805
849
  execute: async ({ path, content, expectedRevision: citedRevision }) => {
850
+ const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
806
851
  // WR-1-F2: normalize every plausible copy of the token FIRST —
807
852
  // tolerance in the reader, strictness in the comparison.
808
853
  const expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
@@ -838,6 +883,19 @@ export function writeFileTool(opts) {
838
883
  if (!exists) {
839
884
  return precondition(`write_file: ${path} no longer exists — pass expectedRevision:"absent" to create it`);
840
885
  }
886
+ // DC-54 owed (R14) — the ceiling, before the read.
887
+ //
888
+ // `read_file` got this bound in 0.24.5; these two did not,
889
+ // and they read the WHOLE file to compute a revision BEFORE
890
+ // the comparison that would reject the call — so the freeze
891
+ // happened on the way to the refusal rather than instead of
892
+ // it. Same ceiling, same precondition shape, same reason: a
893
+ // revision over a prefix could never match, so there is
894
+ // nothing to compute and nothing to compare.
895
+ const wSize = statSync(full).size;
896
+ if (wSize > maxReadBytes) {
897
+ return precondition(`write_file: ${path} is ${mib(wSize)} — too large to read (ceiling ${mib(maxReadBytes)}); use shell with sed/head to take a range`);
898
+ }
841
899
  const current = contentRevision(readFileSync(full));
842
900
  if (current !== expectedRevision) {
843
901
  return precondition(`write_file: ${path} changed since ${expectedRevision} — read it again and cite its [rev:…] line, then re-apply the change`);
@@ -937,6 +995,7 @@ export function editFileTool(opts) {
937
995
  promptSnippet: "edit_file — replace an exact old_string block (never rewrite whole files)",
938
996
  promptGuidelines: ["write/edit: cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"],
939
997
  execute: async ({ path, search, replace, edits, expectedRevision: citedRevision }) => {
998
+ const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
940
999
  // WR-1-F2: normalize the citation (see write_file).
941
1000
  const expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
942
1001
  // WR-1E2 — the form XOR (the draft-07 subset has no oneOf; the
@@ -983,6 +1042,19 @@ export function editFileTool(opts) {
983
1042
  // what was edited (the external validation→replacement window
984
1043
  // remains and is the narrowed claim). Staleness reports BEFORE
985
1044
  // the pattern search: the truer cause first.
1045
+ // DC-54 owed (R14) — the ceiling, before the read.
1046
+ //
1047
+ // `read_file` got this bound in 0.24.5; these two did not,
1048
+ // and they read the WHOLE file to compute a revision BEFORE
1049
+ // the comparison that would reject the call — so the freeze
1050
+ // happened on the way to the refusal rather than instead of
1051
+ // it. Same ceiling, same precondition shape, same reason: a
1052
+ // revision over a prefix could never match, so there is
1053
+ // nothing to compute and nothing to compare.
1054
+ const eSize = statSync(full).size;
1055
+ if (eSize > maxReadBytes) {
1056
+ return precondition(`edit_file: ${path} is ${mib(eSize)} — too large to read (ceiling ${mib(maxReadBytes)}); use shell with sed/head to take a range`);
1057
+ }
986
1058
  const bytes = readFileSync(full);
987
1059
  const current = contentRevision(bytes);
988
1060
  if (current !== expectedRevision) {
@@ -1105,13 +1177,16 @@ export function shellTool(opts) {
1105
1177
  // not just the outer shell (Area 4). cwd is the workspace.
1106
1178
  // bootstrap #3 (finding #7): the shell child NEVER inherits kiso's own
1107
1179
  // provider credentials by default — only the explicit
1108
- // shellEnv: "inherit" opt-in keeps them.
1180
+ // shellEnv: "inherit" opt-in keeps them. A record (ADR-0031
1181
+ // Amendment 1) rides on the STRIPPED base and wins per key.
1109
1182
  const child = spawn(command, {
1110
1183
  shell: true,
1111
1184
  detached: true,
1112
1185
  cwd: opts.workspaceRoot,
1113
1186
  stdio: ["ignore", "pipe", "pipe"],
1114
- env: opts.shellEnv === "inherit" ? process.env : strippedShellEnv(process.env),
1187
+ env: opts.shellEnv === "inherit"
1188
+ ? process.env
1189
+ : { ...strippedShellEnv(process.env), ...(opts.shellEnv ?? {}) },
1115
1190
  });
1116
1191
  let stdout = "";
1117
1192
  let stderr = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tools-node",
3
- "version": "0.24.5",
3
+ "version": "0.26.0",
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.5"
24
+ "@vincemakes/kiso-core": "0.26.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",