@wrongstack/tools 0.296.4 → 0.298.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.
Files changed (258) hide show
  1. package/dist/_spawn-stream.d.ts +2 -0
  2. package/dist/_util.d.ts +29 -1
  3. package/dist/audit.js +128 -65
  4. package/dist/bash.js +20 -4
  5. package/dist/batch-tool-use.js +70 -7
  6. package/dist/browser/index.js +31 -1
  7. package/dist/builtin.js +883 -351
  8. package/dist/codebase-index/index.js +95 -16
  9. package/dist/codebase-index/project-server-protocol.d.ts +26 -0
  10. package/dist/codebase-index/project-server.js +76 -16
  11. package/dist/codebase-index/worker.js +36 -13
  12. package/dist/codebase-index/writer-search-helpers.d.ts +16 -0
  13. package/dist/codebase-index/writer.d.ts +13 -1
  14. package/dist/design.d.ts +1 -1
  15. package/dist/design.js +4 -3
  16. package/dist/diff.js +11 -1
  17. package/dist/e2e.js +9 -6
  18. package/dist/edit.js +9 -6
  19. package/dist/exec.js +562 -516
  20. package/dist/format.js +131 -65
  21. package/dist/git.js +22 -2
  22. package/dist/glob.js +11 -5
  23. package/dist/grep.js +160 -104
  24. package/dist/index.js +834 -290
  25. package/dist/install.js +135 -68
  26. package/dist/json.js +64 -18
  27. package/dist/kanban-board-inputs.d.ts +21 -4
  28. package/dist/kanban.js +20 -5
  29. package/dist/languages/index.js +143 -71
  30. package/dist/lint.js +128 -65
  31. package/dist/next-steps.d.ts +23 -0
  32. package/dist/next-steps.js +4 -0
  33. package/dist/outdated.js +101 -26
  34. package/dist/pack.js +883 -351
  35. package/dist/patch.js +11 -1
  36. package/dist/plan.js +32 -10
  37. package/dist/process-registry.d.ts +9 -0
  38. package/dist/process-registry.js +15 -0
  39. package/dist/ps-slash.js +15 -0
  40. package/dist/read.js +104 -23
  41. package/dist/replace.js +11 -3
  42. package/dist/scaffold.js +9 -6
  43. package/dist/session-kanban.js +21 -5
  44. package/dist/task.js +3 -1
  45. package/dist/test.js +128 -65
  46. package/dist/todo.js +3 -1
  47. package/dist/tool-tier.js +883 -351
  48. package/dist/tool-use.d.ts +1 -1
  49. package/dist/tool-use.js +28 -4
  50. package/dist/tree.d.ts +1 -0
  51. package/dist/tree.js +44 -8
  52. package/dist/typecheck.js +128 -65
  53. package/dist/write.js +9 -6
  54. package/package.json +5 -4
  55. package/dist/_concurrency.d.ts.map +0 -1
  56. package/dist/_danger-detect.d.ts.map +0 -1
  57. package/dist/_edit-match.d.ts.map +0 -1
  58. package/dist/_env.d.ts.map +0 -1
  59. package/dist/_fetch-guard.d.ts.map +0 -1
  60. package/dist/_output-spool.d.ts.map +0 -1
  61. package/dist/_redact-command.d.ts.map +0 -1
  62. package/dist/_regex.d.ts.map +0 -1
  63. package/dist/_session-shell.d.ts.map +0 -1
  64. package/dist/_shell-pick.d.ts.map +0 -1
  65. package/dist/_spawn-stream.d.ts.map +0 -1
  66. package/dist/_syntax-check.d.ts.map +0 -1
  67. package/dist/_util.d.ts.map +0 -1
  68. package/dist/_win32-resolve.d.ts.map +0 -1
  69. package/dist/audit.d.ts.map +0 -1
  70. package/dist/audit.js.map +0 -7
  71. package/dist/auto-proceed-loop-guard.d.ts.map +0 -1
  72. package/dist/auto-proceed-loop-guard.js.map +0 -7
  73. package/dist/bash-kill-guard.d.ts.map +0 -1
  74. package/dist/bash.d.ts.map +0 -1
  75. package/dist/bash.js.map +0 -7
  76. package/dist/batch-tool-use.d.ts.map +0 -1
  77. package/dist/batch-tool-use.js.map +0 -7
  78. package/dist/browser/artifacts.d.ts.map +0 -1
  79. package/dist/browser/index.d.ts.map +0 -1
  80. package/dist/browser/index.js.map +0 -7
  81. package/dist/browser/manager.d.ts.map +0 -1
  82. package/dist/browser/network-guard-proxy.d.ts.map +0 -1
  83. package/dist/browser/security.d.ts.map +0 -1
  84. package/dist/browser/tools.d.ts.map +0 -1
  85. package/dist/browser/types.d.ts.map +0 -1
  86. package/dist/builtin.d.ts.map +0 -1
  87. package/dist/builtin.js.map +0 -7
  88. package/dist/circuit-breaker.d.ts.map +0 -1
  89. package/dist/circuit-breaker.js.map +0 -7
  90. package/dist/codebase-index/background-indexer.d.ts.map +0 -1
  91. package/dist/codebase-index/bm25.d.ts.map +0 -1
  92. package/dist/codebase-index/circuit-breaker.d.ts.map +0 -1
  93. package/dist/codebase-index/codebase-index-tool.d.ts.map +0 -1
  94. package/dist/codebase-index/codebase-search-tool.d.ts.map +0 -1
  95. package/dist/codebase-index/codebase-stats-tool.d.ts.map +0 -1
  96. package/dist/codebase-index/dead-code-scan.d.ts.map +0 -1
  97. package/dist/codebase-index/generic-parser.d.ts.map +0 -1
  98. package/dist/codebase-index/gitignore.d.ts.map +0 -1
  99. package/dist/codebase-index/go-parser.d.ts.map +0 -1
  100. package/dist/codebase-index/index-service.d.ts.map +0 -1
  101. package/dist/codebase-index/index.d.ts.map +0 -1
  102. package/dist/codebase-index/index.js.map +0 -7
  103. package/dist/codebase-index/indexer.d.ts.map +0 -1
  104. package/dist/codebase-index/json-parser.d.ts.map +0 -1
  105. package/dist/codebase-index/languages.d.ts.map +0 -1
  106. package/dist/codebase-index/lsp-kind.d.ts.map +0 -1
  107. package/dist/codebase-index/parser-dispatch.d.ts.map +0 -1
  108. package/dist/codebase-index/project-server-cache.d.ts.map +0 -1
  109. package/dist/codebase-index/project-server-client.d.ts.map +0 -1
  110. package/dist/codebase-index/project-server-endpoint.d.ts.map +0 -1
  111. package/dist/codebase-index/project-server-protocol.d.ts.map +0 -1
  112. package/dist/codebase-index/project-server.d.ts.map +0 -1
  113. package/dist/codebase-index/project-server.js.map +0 -7
  114. package/dist/codebase-index/py-parser.d.ts.map +0 -1
  115. package/dist/codebase-index/refs-extractor.d.ts.map +0 -1
  116. package/dist/codebase-index/rs-parser.d.ts.map +0 -1
  117. package/dist/codebase-index/schema.d.ts.map +0 -1
  118. package/dist/codebase-index/spawn-gate.d.ts.map +0 -1
  119. package/dist/codebase-index/sqlite-runtime.d.ts.map +0 -1
  120. package/dist/codebase-index/ts-parser.d.ts.map +0 -1
  121. package/dist/codebase-index/worker-protocol.d.ts.map +0 -1
  122. package/dist/codebase-index/worker.d.ts.map +0 -1
  123. package/dist/codebase-index/worker.js.map +0 -7
  124. package/dist/codebase-index/writer-admin.d.ts.map +0 -1
  125. package/dist/codebase-index/writer-bulk-insert.d.ts.map +0 -1
  126. package/dist/codebase-index/writer-graph-helpers.d.ts.map +0 -1
  127. package/dist/codebase-index/writer-graph-reader.d.ts.map +0 -1
  128. package/dist/codebase-index/writer-helpers.d.ts.map +0 -1
  129. package/dist/codebase-index/writer-pragmas.d.ts.map +0 -1
  130. package/dist/codebase-index/writer-ref-mapper.d.ts.map +0 -1
  131. package/dist/codebase-index/writer-schema.d.ts.map +0 -1
  132. package/dist/codebase-index/writer-search-helpers.d.ts.map +0 -1
  133. package/dist/codebase-index/writer-store-pool.d.ts.map +0 -1
  134. package/dist/codebase-index/writer.d.ts.map +0 -1
  135. package/dist/codebase-index/yaml-parser.d.ts.map +0 -1
  136. package/dist/design.d.ts.map +0 -1
  137. package/dist/design.js.map +0 -7
  138. package/dist/diff.d.ts.map +0 -1
  139. package/dist/diff.js.map +0 -7
  140. package/dist/document.d.ts.map +0 -1
  141. package/dist/document.js.map +0 -7
  142. package/dist/e2e.d.ts.map +0 -1
  143. package/dist/e2e.js.map +0 -7
  144. package/dist/edit.d.ts.map +0 -1
  145. package/dist/edit.js.map +0 -7
  146. package/dist/exec-kill-guard.d.ts.map +0 -1
  147. package/dist/exec.d.ts.map +0 -1
  148. package/dist/exec.js.map +0 -7
  149. package/dist/fetch.d.ts.map +0 -1
  150. package/dist/fetch.js.map +0 -7
  151. package/dist/format.d.ts.map +0 -1
  152. package/dist/format.js.map +0 -7
  153. package/dist/git.d.ts.map +0 -1
  154. package/dist/git.js.map +0 -7
  155. package/dist/glob.d.ts.map +0 -1
  156. package/dist/glob.js.map +0 -7
  157. package/dist/grep.d.ts.map +0 -1
  158. package/dist/grep.js.map +0 -7
  159. package/dist/index.d.ts.map +0 -1
  160. package/dist/index.js.map +0 -7
  161. package/dist/install.d.ts.map +0 -1
  162. package/dist/install.js.map +0 -7
  163. package/dist/json.d.ts.map +0 -1
  164. package/dist/json.js.map +0 -7
  165. package/dist/kanban-board-inputs.d.ts.map +0 -1
  166. package/dist/kanban-decomposition-actions.d.ts.map +0 -1
  167. package/dist/kanban-detail-actions.d.ts.map +0 -1
  168. package/dist/kanban-evidence-bridge.d.ts.map +0 -1
  169. package/dist/kanban-presence.d.ts.map +0 -1
  170. package/dist/kanban-split-task-handler.d.ts.map +0 -1
  171. package/dist/kanban-task-inputs.d.ts.map +0 -1
  172. package/dist/kanban-tool-results.d.ts.map +0 -1
  173. package/dist/kanban-tool-schema.d.ts.map +0 -1
  174. package/dist/kanban-tool-types.d.ts.map +0 -1
  175. package/dist/kanban.d.ts.map +0 -1
  176. package/dist/kanban.js.map +0 -7
  177. package/dist/languages/detect.d.ts.map +0 -1
  178. package/dist/languages/diagnostics.d.ts.map +0 -1
  179. package/dist/languages/execute-tool.d.ts.map +0 -1
  180. package/dist/languages/execute.d.ts.map +0 -1
  181. package/dist/languages/index.d.ts.map +0 -1
  182. package/dist/languages/index.js.map +0 -7
  183. package/dist/languages/legacy-bridge.d.ts.map +0 -1
  184. package/dist/languages/package-tool.d.ts.map +0 -1
  185. package/dist/languages/plan.d.ts.map +0 -1
  186. package/dist/languages/profile-helpers.d.ts.map +0 -1
  187. package/dist/languages/profiles/additional.d.ts.map +0 -1
  188. package/dist/languages/profiles/primary.d.ts.map +0 -1
  189. package/dist/languages/registry.d.ts.map +0 -1
  190. package/dist/languages/tool.d.ts.map +0 -1
  191. package/dist/languages/types.d.ts.map +0 -1
  192. package/dist/lint.d.ts.map +0 -1
  193. package/dist/lint.js.map +0 -7
  194. package/dist/logs.d.ts.map +0 -1
  195. package/dist/logs.js.map +0 -7
  196. package/dist/memory.d.ts.map +0 -1
  197. package/dist/memory.js.map +0 -7
  198. package/dist/mode.d.ts.map +0 -1
  199. package/dist/mode.js.map +0 -7
  200. package/dist/next-steps.d.ts.map +0 -1
  201. package/dist/next-steps.js.map +0 -7
  202. package/dist/outdated.d.ts.map +0 -1
  203. package/dist/outdated.js.map +0 -7
  204. package/dist/pack.d.ts.map +0 -1
  205. package/dist/pack.js.map +0 -7
  206. package/dist/patch.d.ts.map +0 -1
  207. package/dist/patch.js.map +0 -7
  208. package/dist/plan.d.ts.map +0 -1
  209. package/dist/plan.js.map +0 -7
  210. package/dist/process-guardian.d.ts.map +0 -1
  211. package/dist/process-registry-persistent.d.ts.map +0 -1
  212. package/dist/process-registry.d.ts.map +0 -1
  213. package/dist/process-registry.js.map +0 -7
  214. package/dist/ps-slash.d.ts.map +0 -1
  215. package/dist/ps-slash.js.map +0 -7
  216. package/dist/read.d.ts.map +0 -1
  217. package/dist/read.js.map +0 -7
  218. package/dist/replace.d.ts.map +0 -1
  219. package/dist/replace.js.map +0 -7
  220. package/dist/scaffold.d.ts.map +0 -1
  221. package/dist/scaffold.js.map +0 -7
  222. package/dist/search.d.ts.map +0 -1
  223. package/dist/search.js.map +0 -7
  224. package/dist/session-kanban.d.ts.map +0 -1
  225. package/dist/session-kanban.js.map +0 -7
  226. package/dist/set-working-dir.d.ts.map +0 -1
  227. package/dist/skill.d.ts.map +0 -1
  228. package/dist/skill.js.map +0 -7
  229. package/dist/spawn-background.d.ts.map +0 -1
  230. package/dist/task.d.ts.map +0 -1
  231. package/dist/task.js.map +0 -7
  232. package/dist/test.d.ts.map +0 -1
  233. package/dist/test.js.map +0 -7
  234. package/dist/todo.d.ts.map +0 -1
  235. package/dist/todo.js.map +0 -7
  236. package/dist/tool-diff.d.ts.map +0 -1
  237. package/dist/tool-diff.js.map +0 -7
  238. package/dist/tool-help.d.ts.map +0 -1
  239. package/dist/tool-help.js.map +0 -7
  240. package/dist/tool-icon-map.d.ts.map +0 -1
  241. package/dist/tool-icons.d.ts.map +0 -1
  242. package/dist/tool-icons.js.map +0 -7
  243. package/dist/tool-search.d.ts.map +0 -1
  244. package/dist/tool-search.js.map +0 -7
  245. package/dist/tool-summary.d.ts.map +0 -1
  246. package/dist/tool-summary.js.map +0 -7
  247. package/dist/tool-tier.d.ts.map +0 -1
  248. package/dist/tool-tier.js.map +0 -7
  249. package/dist/tool-use.d.ts.map +0 -1
  250. package/dist/tool-use.js.map +0 -7
  251. package/dist/tree.d.ts.map +0 -1
  252. package/dist/tree.js.map +0 -7
  253. package/dist/typecheck.d.ts.map +0 -1
  254. package/dist/typecheck.js.map +0 -7
  255. package/dist/win32.d.ts.map +0 -1
  256. package/dist/win32.js.map +0 -7
  257. package/dist/write.d.ts.map +0 -1
  258. package/dist/write.js.map +0 -7
package/dist/pack.js CHANGED
@@ -81,11 +81,12 @@ function createOutputSpool(opts) {
81
81
  return {
82
82
  write(text) {
83
83
  if (finalized || !text) return;
84
- totalBytes += Buffer.byteLength(text, "utf8");
84
+ const textBytes = Buffer.byteLength(text, "utf8");
85
+ totalBytes += textBytes;
85
86
  if (!stream && !failed) {
86
- if (headBytes + text.length <= threshold) {
87
+ if (headBytes + textBytes <= threshold) {
87
88
  head += text;
88
- headBytes += text.length;
89
+ headBytes += textBytes;
89
90
  return;
90
91
  }
91
92
  head += text;
@@ -95,7 +96,7 @@ function createOutputSpool(opts) {
95
96
  }
96
97
  if (stream) {
97
98
  if (stream.writableLength > SPOOL_WRITE_HWM_BYTES) {
98
- droppedBytes += Buffer.byteLength(text, "utf8");
99
+ droppedBytes += textBytes;
99
100
  return;
100
101
  }
101
102
  stream.write(text);
@@ -126,6 +127,73 @@ var init_output_spool = __esm({
126
127
  }
127
128
  });
128
129
 
130
+ // src/_win32-resolve.ts
131
+ import * as fs from "node:fs";
132
+ import * as path2 from "node:path";
133
+ function resolveWin32Command(cmd) {
134
+ if (process.platform !== "win32") return cmd;
135
+ if (cmd.includes("/") || cmd.includes("\\") || path2.extname(cmd.replace(/\//g, "\\"))) {
136
+ return cmd;
137
+ }
138
+ const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
139
+ const pathDirs = (process.env["PATH"] ?? "").split(path2.delimiter);
140
+ for (const dir of pathDirs) {
141
+ const base = path2.join(dir, cmd);
142
+ for (const ext of pathext) {
143
+ const full = `${base}${ext}`;
144
+ try {
145
+ fs.accessSync(full, fs.constants.X_OK);
146
+ return full;
147
+ } catch {
148
+ }
149
+ }
150
+ }
151
+ return cmd;
152
+ }
153
+ function resolvePowerShell(cmd) {
154
+ if (process.platform !== "win32") return cmd;
155
+ const lower = cmd.toLowerCase();
156
+ if (lower !== "pwsh" && lower !== "powershell" && lower !== "pwsh.exe" && lower !== "powershell.exe") {
157
+ return resolveWin32Command(cmd);
158
+ }
159
+ const primary = lower.startsWith("pwsh") ? "pwsh.exe" : "powershell.exe";
160
+ const fallback = lower.startsWith("pwsh") ? "powershell.exe" : "pwsh.exe";
161
+ const resolved = resolveWin32Command(primary);
162
+ if (resolved !== primary) {
163
+ const fb = resolveWin32Command(fallback);
164
+ return fb === fallback ? cmd : fb;
165
+ }
166
+ return resolved;
167
+ }
168
+ function assertSafeWin32ShellArgs(args) {
169
+ for (const arg of args) {
170
+ if (typeof arg === "string" && WIN32_SHELL_META.test(arg)) {
171
+ throw new Error(
172
+ 'win32 cmd shim spawn: argument contains a shell metacharacter (one of & | < > ", or a newline) that could enable command injection through the .cmd/.bat wrapper - refusing to run. Offending argument: ' + JSON.stringify(arg)
173
+ );
174
+ }
175
+ }
176
+ }
177
+ function buildWin32CmdShimInvocation(command, args = []) {
178
+ assertSafeWin32ShellArgs([command, ...args]);
179
+ const line = ["call", quoteWin32CmdArg(command), ...args.map(quoteWin32CmdArg)].join(" ");
180
+ return {
181
+ command: process.env["COMSPEC"] ?? "cmd.exe",
182
+ args: ["/d", "/c", line],
183
+ windowsVerbatimArguments: true
184
+ };
185
+ }
186
+ function quoteWin32CmdArg(arg) {
187
+ return `"${arg}"`;
188
+ }
189
+ var WIN32_SHELL_META;
190
+ var init_win32_resolve = __esm({
191
+ "src/_win32-resolve.ts"() {
192
+ "use strict";
193
+ WIN32_SHELL_META = /[&|<>"\r\n\0]/;
194
+ }
195
+ });
196
+
129
197
  // src/circuit-breaker.ts
130
198
  var DEFAULT_MAX_CONSECUTIVE_FAILURES, DEFAULT_SLOW_CALL_THRESHOLD_MS, DEFAULT_MAX_SLOW_CALLS, DEFAULT_WINDOW_MS, DEFAULT_MAX_CALLS_PER_WINDOW, DEFAULT_COOLDOWN_MS, CircuitBreaker;
131
199
  var init_circuit_breaker = __esm({
@@ -479,6 +547,7 @@ var init_process_registry = __esm({
479
547
  }
480
548
  /** Get all tracked processes. */
481
549
  list() {
550
+ this._pruneAllStale();
482
551
  return Array.from(this.processes.values());
483
552
  }
484
553
  /** Get processes filtered by name (e.g. 'bash', 'exec'). */
@@ -509,6 +578,7 @@ var init_process_registry = __esm({
509
578
  * Combined stats for observability — used by /ps and the TUI status bar.
510
579
  */
511
580
  stats() {
581
+ this._pruneAllStale();
512
582
  return {
513
583
  activeCount: this.activeCount,
514
584
  backgroundCount: this.activeBackgroundCount,
@@ -748,74 +818,20 @@ var init_process_registry = __esm({
748
818
  this.processes.delete(pid);
749
819
  }
750
820
  }
751
- };
752
- }
753
- });
754
-
755
- // src/_win32-resolve.ts
756
- import * as fs from "node:fs";
757
- import * as path2 from "node:path";
758
- function resolveWin32Command(cmd) {
759
- if (process.platform !== "win32") return cmd;
760
- if (cmd.includes("/") || cmd.includes("\\") || path2.extname(cmd.replace(/\//g, "\\"))) {
761
- return cmd;
762
- }
763
- const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
764
- const pathDirs = (process.env["PATH"] ?? "").split(path2.delimiter);
765
- for (const dir of pathDirs) {
766
- const base = path2.join(dir, cmd);
767
- for (const ext of pathext) {
768
- const full = `${base}${ext}`;
769
- try {
770
- fs.accessSync(full, fs.constants.X_OK);
771
- return full;
772
- } catch {
821
+ /**
822
+ * Remove every stale entry, not just one PID. `list()`/`stats()` — the
823
+ * surfaces the TUI status bar and `/ps` poll — must prune too: a child
824
+ * whose 'close' event never fires (e.g. Windows grandchildren holding stdio
825
+ * open) would otherwise linger in the registry until someone looks up its
826
+ * exact PID, and PID reuse meanwhile makes `get()`/`kill()` target the
827
+ * wrong process. RAM-leak audit 2026-07-31, MEDIUM.
828
+ */
829
+ _pruneAllStale() {
830
+ for (const [pid, entry] of this.processes) {
831
+ if (this._isStaleEntry(entry)) this.processes.delete(pid);
832
+ }
773
833
  }
774
- }
775
- }
776
- return cmd;
777
- }
778
- function resolvePowerShell(cmd) {
779
- if (process.platform !== "win32") return cmd;
780
- const lower = cmd.toLowerCase();
781
- if (lower !== "pwsh" && lower !== "powershell" && lower !== "pwsh.exe" && lower !== "powershell.exe") {
782
- return resolveWin32Command(cmd);
783
- }
784
- const primary = lower.startsWith("pwsh") ? "pwsh.exe" : "powershell.exe";
785
- const fallback = lower.startsWith("pwsh") ? "powershell.exe" : "pwsh.exe";
786
- const resolved = resolveWin32Command(primary);
787
- if (resolved !== primary) {
788
- const fb = resolveWin32Command(fallback);
789
- return fb === fallback ? cmd : fb;
790
- }
791
- return resolved;
792
- }
793
- function assertSafeWin32ShellArgs(args) {
794
- for (const arg of args) {
795
- if (typeof arg === "string" && WIN32_SHELL_META.test(arg)) {
796
- throw new Error(
797
- 'win32 cmd shim spawn: argument contains a shell metacharacter (one of & | < > ", or a newline) that could enable command injection through the .cmd/.bat wrapper - refusing to run. Offending argument: ' + JSON.stringify(arg)
798
- );
799
- }
800
- }
801
- }
802
- function buildWin32CmdShimInvocation(command, args = []) {
803
- assertSafeWin32ShellArgs([command, ...args]);
804
- const line = ["call", quoteWin32CmdArg(command), ...args.map(quoteWin32CmdArg)].join(" ");
805
- return {
806
- command: process.env["COMSPEC"] ?? "cmd.exe",
807
- args: ["/d", "/c", line],
808
- windowsVerbatimArguments: true
809
- };
810
- }
811
- function quoteWin32CmdArg(arg) {
812
- return `"${arg}"`;
813
- }
814
- var WIN32_SHELL_META;
815
- var init_win32_resolve = __esm({
816
- "src/_win32-resolve.ts"() {
817
- "use strict";
818
- WIN32_SHELL_META = /[&|<>"\r\n\0]/;
834
+ };
819
835
  }
820
836
  });
821
837
 
@@ -831,8 +847,11 @@ async function* spawnStream(opts) {
831
847
  const max = opts.maxBytes ?? 2e5;
832
848
  const flushAt = opts.flushBytes ?? 4 * 1024;
833
849
  const maxQueue = opts.maxQueueSize ?? 500;
850
+ const maxQueueBytes = opts.maxQueueBytes ?? 1024 * 1024;
834
851
  let stdout = "";
835
852
  let stderr = "";
853
+ let stdoutRetainedBytes = 0;
854
+ let stderrRetainedBytes = 0;
836
855
  let pending2 = "";
837
856
  let error;
838
857
  const spool = createOutputSpool({ tool: opts.cmd, thresholdBytes: max });
@@ -874,6 +893,7 @@ async function* spawnStream(opts) {
874
893
  });
875
894
  }
876
895
  const queue = [];
896
+ let queuedBytes = 0;
877
897
  let waiter;
878
898
  let paused = false;
879
899
  const wake = () => {
@@ -884,7 +904,7 @@ async function* spawnStream(opts) {
884
904
  }
885
905
  };
886
906
  const resume = () => {
887
- if (paused && queue.length < maxQueue) {
907
+ if (paused && queue.length <= Math.floor(maxQueue / 2) && queuedBytes <= Math.floor(maxQueueBytes / 2)) {
888
908
  paused = false;
889
909
  child.stdout?.resume();
890
910
  child.stderr?.resume();
@@ -894,11 +914,16 @@ async function* spawnStream(opts) {
894
914
  const s = c.toString();
895
915
  stdoutBytes += c.byteLength;
896
916
  emitProcessOutput({ pid, stream: "stdout", chunk: c });
897
- if (stdout.length < max) stdout += s;
917
+ if (stdoutRetainedBytes < max) {
918
+ const retained = utf8Prefix(s, max - stdoutRetainedBytes);
919
+ stdout += retained;
920
+ stdoutRetainedBytes += Buffer.byteLength(retained, "utf8");
921
+ }
898
922
  spool.write(s);
899
- queue.push({ kind: "out", data: s });
923
+ queue.push({ kind: "out", data: s, bytes: c.byteLength });
924
+ queuedBytes += c.byteLength;
900
925
  wake();
901
- if (!paused && queue.length >= maxQueue) {
926
+ if (!paused && (queue.length >= maxQueue || queuedBytes >= maxQueueBytes)) {
902
927
  paused = true;
903
928
  child.stdout?.pause();
904
929
  child.stderr?.pause();
@@ -908,11 +933,16 @@ async function* spawnStream(opts) {
908
933
  const s = c.toString();
909
934
  stderrBytes += c.byteLength;
910
935
  emitProcessOutput({ pid, stream: "stderr", chunk: c });
911
- if (stderr.length < max) stderr += s;
936
+ if (stderrRetainedBytes < max) {
937
+ const retained = utf8Prefix(s, max - stderrRetainedBytes);
938
+ stderr += retained;
939
+ stderrRetainedBytes += Buffer.byteLength(retained, "utf8");
940
+ }
912
941
  spool.write(s);
913
- queue.push({ kind: "err", data: s });
942
+ queue.push({ kind: "err", data: s, bytes: c.byteLength });
943
+ queuedBytes += c.byteLength;
914
944
  wake();
915
- if (!paused && queue.length >= maxQueue) {
945
+ if (!paused && (queue.length >= maxQueue || queuedBytes >= maxQueueBytes)) {
916
946
  paused = true;
917
947
  child.stdout?.pause();
918
948
  child.stderr?.pause();
@@ -922,7 +952,9 @@ async function* spawnStream(opts) {
922
952
  child.stderr?.on("data", onErr);
923
953
  child.on("error", (e) => {
924
954
  error = e.message;
925
- queue.push({ kind: "error", data: e.message });
955
+ const bytes = Buffer.byteLength(e.message, "utf8");
956
+ queue.push({ kind: "error", data: e.message, bytes });
957
+ queuedBytes += bytes;
926
958
  wake();
927
959
  });
928
960
  const completeTelemetry = (code, signal, timedOut = false) => {
@@ -943,7 +975,13 @@ async function* spawnStream(opts) {
943
975
  if (typeof pid === "number") registry.unregister(pid);
944
976
  const exitCode2 = code ?? (signal ? 1 : 0);
945
977
  completeTelemetry(exitCode2, signal ?? void 0);
946
- queue.push({ kind: "close", data: "", code: exitCode2, ...signal ? { signal } : {} });
978
+ queue.push({
979
+ kind: "close",
980
+ data: "",
981
+ bytes: 0,
982
+ code: exitCode2,
983
+ ...signal ? { signal } : {}
984
+ });
947
985
  wake();
948
986
  });
949
987
  const onAbort = () => {
@@ -955,7 +993,7 @@ async function* spawnStream(opts) {
955
993
  } catch {
956
994
  }
957
995
  }
958
- queue.push({ kind: "close", data: "", code: 124 });
996
+ queue.push({ kind: "close", data: "", bytes: 0, code: 124 });
959
997
  completeTelemetry(124, "SIGKILL", true);
960
998
  wake();
961
999
  };
@@ -973,6 +1011,7 @@ async function* spawnStream(opts) {
973
1011
  });
974
1012
  }
975
1013
  const chunk = queue.shift();
1014
+ queuedBytes -= chunk.bytes;
976
1015
  resume();
977
1016
  if (chunk.kind === "close") {
978
1017
  if (!spawnFailed) exitCode = chunk.code ?? 0;
@@ -999,7 +1038,7 @@ async function* spawnStream(opts) {
999
1038
  stdout: spooled ? stdout + spoolNote(spooled) : stdout,
1000
1039
  stderr,
1001
1040
  exitCode,
1002
- truncated: stdout.length >= max || stderr.length >= max,
1041
+ truncated: stdoutBytes > stdoutRetainedBytes || stderrBytes > stderrRetainedBytes,
1003
1042
  error,
1004
1043
  spoolPath: spooled?.path,
1005
1044
  spoolBytes: spooled?.bytes
@@ -1023,13 +1062,27 @@ async function* spawnStream(opts) {
1023
1062
  }
1024
1063
  }
1025
1064
  }
1065
+ function utf8Prefix(text, maxBytes) {
1066
+ if (maxBytes <= 0) return "";
1067
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
1068
+ let low = 0;
1069
+ let high = text.length;
1070
+ while (low < high) {
1071
+ const mid = Math.ceil((low + high) / 2);
1072
+ if (Buffer.byteLength(text.slice(0, mid), "utf8") <= maxBytes) low = mid;
1073
+ else high = mid - 1;
1074
+ }
1075
+ let end = low;
1076
+ if (end > 0 && /[\uD800-\uDBFF]/.test(text[end - 1])) end--;
1077
+ return text.slice(0, end);
1078
+ }
1026
1079
  var isWin;
1027
1080
  var init_spawn_stream = __esm({
1028
1081
  "src/_spawn-stream.ts"() {
1029
1082
  "use strict";
1030
1083
  init_output_spool();
1031
- init_process_registry();
1032
1084
  init_win32_resolve();
1085
+ init_process_registry();
1033
1086
  isWin = process.platform === "win32";
1034
1087
  }
1035
1088
  });
@@ -1043,14 +1096,14 @@ function sha256hex(content) {
1043
1096
  return createHash("sha256").update(content, "utf8").digest("hex");
1044
1097
  }
1045
1098
  async function detectPackageManager(cwd) {
1046
- const { stat: stat17 } = await import("node:fs/promises");
1099
+ const { stat: stat18 } = await import("node:fs/promises");
1047
1100
  try {
1048
- await stat17(`${cwd}/pnpm-lock.yaml`);
1101
+ await stat18(`${cwd}/pnpm-lock.yaml`);
1049
1102
  return "pnpm";
1050
1103
  } catch {
1051
1104
  }
1052
1105
  try {
1053
- await stat17(`${cwd}/yarn.lock`);
1106
+ await stat18(`${cwd}/yarn.lock`);
1054
1107
  return "yarn";
1055
1108
  } catch {
1056
1109
  }
@@ -1078,11 +1131,15 @@ function safeResolve(input, ctx) {
1078
1131
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
1079
1132
  }
1080
1133
  async function assertRealInsideRoot(absPath, ctx) {
1081
- if (ctx.allowOutsideProjectRoot) return;
1134
+ await resolveRealInsideRoot(absPath, ctx);
1135
+ }
1136
+ async function resolveRealInsideRoot(absPath, ctx) {
1137
+ if (ctx.allowOutsideProjectRoot) return absPath;
1082
1138
  const realRoots = await Promise.all(
1083
1139
  allowedRoots(ctx).map((r) => fsp2.realpath(r).catch(() => path3.resolve(r)))
1084
1140
  );
1085
1141
  let probe2 = absPath;
1142
+ const pendingTail = [];
1086
1143
  for (; ; ) {
1087
1144
  let real;
1088
1145
  try {
@@ -1090,13 +1147,16 @@ async function assertRealInsideRoot(absPath, ctx) {
1090
1147
  } catch (err) {
1091
1148
  if (err.code === "ENOENT") {
1092
1149
  const parent = path3.dirname(probe2);
1093
- if (parent === probe2) return;
1150
+ if (parent === probe2) return absPath;
1151
+ pendingTail.unshift(path3.basename(probe2));
1094
1152
  probe2 = parent;
1095
1153
  continue;
1096
1154
  }
1097
1155
  throw err;
1098
1156
  }
1099
- if (isInsideAny(real, realRoots)) return;
1157
+ if (isInsideAny(real, realRoots)) {
1158
+ return pendingTail.length > 0 ? path3.join(real, ...pendingTail) : real;
1159
+ }
1100
1160
  throw new Error(
1101
1161
  `Path "${absPath}" resolves through a symlink outside project root "${realRoots[0]}"`
1102
1162
  );
@@ -1104,8 +1164,7 @@ async function assertRealInsideRoot(absPath, ctx) {
1104
1164
  }
1105
1165
  async function safeResolveReal(input, ctx) {
1106
1166
  const abs = safeResolve(input, ctx);
1107
- await assertRealInsideRoot(abs, ctx);
1108
- return abs;
1167
+ return await resolveRealInsideRoot(abs, ctx);
1109
1168
  }
1110
1169
  function truncateMiddle(s, max) {
1111
1170
  if (Buffer.byteLength(s, "utf8") <= max) return s;
@@ -2669,8 +2728,8 @@ async function scanDirectory(directory, depth, profiles, limits, state, extraIgn
2669
2728
  collectFileEvidence(directory, fullPath, entry.name, profiles, state);
2670
2729
  }
2671
2730
  }
2672
- function collectFileEvidence(directory, fullPath, basename12, profiles, state) {
2673
- const lower = basename12.toLowerCase();
2731
+ function collectFileEvidence(directory, fullPath, basename13, profiles, state) {
2732
+ const lower = basename13.toLowerCase();
2674
2733
  const extension = path4.extname(lower);
2675
2734
  for (const profile of profiles) {
2676
2735
  const detector = profile.detectors.find(
@@ -2681,7 +2740,7 @@ function collectFileEvidence(directory, fullPath, basename12, profiles, state) {
2681
2740
  candidate.evidence.push({
2682
2741
  kind: detector.kind,
2683
2742
  path: fullPath,
2684
- value: basename12,
2743
+ value: basename13,
2685
2744
  weight: detector.weight
2686
2745
  });
2687
2746
  if (detector.kind === "manifest" || detector.kind === "config") {
@@ -2809,8 +2868,8 @@ function normalizeLimits(input) {
2809
2868
  async function canonicalDirectory(input) {
2810
2869
  const resolved = path4.resolve(input);
2811
2870
  const real = await fs2.realpath(resolved);
2812
- const stat17 = await fs2.stat(real);
2813
- if (!stat17.isDirectory()) throw new Error(`Project root is not a directory: ${input}`);
2871
+ const stat18 = await fs2.stat(real);
2872
+ if (!stat18.isDirectory()) throw new Error(`Project root is not a directory: ${input}`);
2814
2873
  return real;
2815
2874
  }
2816
2875
  async function canonicalInside(input, root, label) {
@@ -3366,6 +3425,7 @@ import * as fs3 from "node:fs/promises";
3366
3425
  import * as path6 from "node:path";
3367
3426
  async function planLanguageOperation(options) {
3368
3427
  validateOperationOptions(options.operation, options.operationOptions?.packages);
3428
+ validateFilterOption(options.operationOptions?.filter);
3369
3429
  const detection = await detectLanguageWorkspaces(options);
3370
3430
  const candidates = detection.workspaces.filter(
3371
3431
  (workspace) => workspace.capabilities.includes(options.operation)
@@ -3538,6 +3598,15 @@ function workspaceDepth(workspaceRoot, projectRoot) {
3538
3598
  const relative12 = path6.relative(path6.resolve(projectRoot), path6.resolve(workspaceRoot));
3539
3599
  return relative12 === "" ? 0 : relative12.split(path6.sep).length;
3540
3600
  }
3601
+ function validateFilterOption(filter) {
3602
+ if (filter === void 0) return;
3603
+ if (filter.startsWith("-") || filter.includes(" --")) {
3604
+ throw new Error(`Invalid test filter (parsed as a runner option): "${filter}"`);
3605
+ }
3606
+ if (filter.length > MAX_ARGUMENT_LENGTH || /[\r\n\0]/.test(filter)) {
3607
+ throw new Error("Invalid test filter (oversized or contains a control character)");
3608
+ }
3609
+ }
3541
3610
  function validateOperationOptions(operation, packages) {
3542
3611
  if (!operation.startsWith("package-") || !packages) return;
3543
3612
  for (const value of packages) {
@@ -3579,7 +3648,7 @@ var init_plan = __esm({
3579
3648
  MAX_ARGUMENT_LENGTH = 4096;
3580
3649
  PACKAGE_NAME_RE = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+(?:@[a-z0-9*+._~^<>=|-]+)?$/i;
3581
3650
  COMPOSER_PACKAGE_RE = /^[a-z0-9_.-]+\/[a-z0-9_.-]+(?::[a-z0-9*+._~^<>=|-]+)?$/i;
3582
- GO_MODULE_RE = /^(?:[a-z0-9.-]+\.)+[a-z0-9.-]+\/[a-z0-9._~+/@-]+$/i;
3651
+ GO_MODULE_RE = /^(?:[a-z0-9-]+\.)+[a-z0-9-]+\/[a-z0-9._~+/@-]+$/i;
3583
3652
  PYTHON_PACKAGE_RE = /^[a-z0-9._-]+(?:\[[a-z0-9._,-]+\])?==[a-z0-9*+._!~-]+$/i;
3584
3653
  }
3585
3654
  });
@@ -3699,8 +3768,8 @@ async function executeInternal(options, startedAt) {
3699
3768
  const target = options.plan.evidence.find((item) => item.kind === "target")?.path;
3700
3769
  if (!target) return unavailableResult(options, "Internal syntax plan has no target evidence.");
3701
3770
  const safeTarget = await assertContainedFile(target, options.projectRoot);
3702
- const stat17 = await fs4.stat(safeTarget);
3703
- if (stat17.size > MAX_INTERNAL_SOURCE_BYTES) {
3771
+ const stat18 = await fs4.stat(safeTarget);
3772
+ if (stat18.size > MAX_INTERNAL_SOURCE_BYTES) {
3704
3773
  return unavailableResult(
3705
3774
  options,
3706
3775
  `Internal syntax target exceeds ${MAX_INTERNAL_SOURCE_BYTES} bytes.`
@@ -3987,8 +4056,8 @@ async function snapshotPaths(paths) {
3987
4056
  const existing = [];
3988
4057
  for (const candidate of paths) {
3989
4058
  try {
3990
- const stat17 = await fs4.stat(candidate);
3991
- if (!stat17.isFile()) continue;
4059
+ const stat18 = await fs4.stat(candidate);
4060
+ if (!stat18.isFile()) continue;
3992
4061
  existing.push(candidate);
3993
4062
  } catch {
3994
4063
  }
@@ -4016,8 +4085,8 @@ async function snapshotSizes(paths) {
4016
4085
  const sizes = /* @__PURE__ */ new Map();
4017
4086
  for (const candidate of paths) {
4018
4087
  try {
4019
- const stat17 = await fs4.stat(candidate);
4020
- if (stat17.isFile()) sizes.set(candidate, stat17.size);
4088
+ const stat18 = await fs4.stat(candidate);
4089
+ if (stat18.isFile()) sizes.set(candidate, stat18.size);
4021
4090
  } catch {
4022
4091
  }
4023
4092
  }
@@ -4177,6 +4246,9 @@ var init_execute_tool = __esm({
4177
4246
  useInstead: ["language_info", "install", "audit", "outdated"]
4178
4247
  },
4179
4248
  permission: "confirm",
4249
+ // WS-046: gives permission decisions something to key on.
4250
+ // The action performed (build/test/lint); the cwd is where, not what.
4251
+ subjectKey: "action",
4180
4252
  mutating: true,
4181
4253
  riskTier: "standard",
4182
4254
  capabilities: ["shell.restricted", "fs.write"],
@@ -4409,6 +4481,9 @@ var init_package_tool = __esm({
4409
4481
  useInstead: ["language_info", "language", "install", "audit", "outdated"]
4410
4482
  },
4411
4483
  permission: "confirm",
4484
+ // WS-046: gives permission decisions something to key on.
4485
+ // The package operation performed — install/remove are not interchangeable.
4486
+ subjectKey: "operation",
4412
4487
  mutating: true,
4413
4488
  riskTier: "destructive",
4414
4489
  capabilities: ["shell.restricted", "fs.write", "net.outbound", "package.install"],
@@ -6460,9 +6535,9 @@ function parseSymbols6(opts) {
6460
6535
  function regexParse2(opts) {
6461
6536
  const { file, content, lang } = opts;
6462
6537
  const symbols = [];
6463
- const basename12 = path22.basename(file).toLowerCase();
6464
- const isPackageJson = basename12 === "package.json";
6465
- const isTsconfig = basename12 === "tsconfig.json" || basename12 === "tsconfig.build.json";
6538
+ const basename13 = path22.basename(file).toLowerCase();
6539
+ const isPackageJson = basename13 === "package.json";
6540
+ const isTsconfig = basename13 === "tsconfig.json" || basename13 === "tsconfig.build.json";
6466
6541
  const isJsonSchema = content.includes("$schema") || content.includes("$id") || content.includes("$ref");
6467
6542
  const isOpenApi = content.includes("openapi") || content.includes("swagger");
6468
6543
  const lines = content.split("\n");
@@ -8358,30 +8433,59 @@ ${hint}` : ""),
8358
8433
  import {
8359
8434
  GOVERNED_TOOL_EXECUTOR_META_KEY
8360
8435
  } from "@wrongstack/core/types";
8436
+ var BATCHABLE_TOOL_NAMES = [
8437
+ "read",
8438
+ "glob",
8439
+ "grep",
8440
+ "tree",
8441
+ "codebase-stats",
8442
+ "codebase-search",
8443
+ "language_info",
8444
+ "tool_search",
8445
+ "memory_for_file",
8446
+ "memory_for_path",
8447
+ "memory_search"
8448
+ ];
8449
+ var BATCHABLE_TOOLS = new Set(BATCHABLE_TOOL_NAMES);
8450
+ var MAX_BATCH_CALLS = 8;
8451
+ var MAX_PARALLEL_BATCH_CALLS = 3;
8361
8452
  var batchToolUseTool = {
8362
8453
  name: "batch_tool_use",
8363
8454
  category: "Meta",
8364
- description: "Execute a batch of tool calls either sequentially or in parallel. Returns structured results for every call.",
8365
- usageHint: "ADVANCED / POWER USER TOOL:\n\n- Useful when you have a clear list of independent operations to perform.\n- `parallel: true` (default) runs them concurrently for speed.\n- `stop_on_error: true` makes it fail fast on the first error.\nUse with care \u2014 batching many mutating operations can be risky. Prefer explicit sequential steps for important work.",
8455
+ description: "Execute a small batch of independent, read-only discovery calls. Process, mutation, browser-action, MCP, delegation, and nested meta tools are not eligible.",
8456
+ usageHint: `BOUNDED READ-ONLY BATCHING:
8457
+
8458
+ - Use only for a small set of independent reads or searches that are all needed now.
8459
+ - Do not wrap normal sequential reasoning or every tool call in a batch.
8460
+ - \`parallel: true\` (default) runs at most three eligible calls concurrently.
8461
+ - \`stop_on_error: true\` makes it fail fast on the first error.
8462
+ - Eligible tools: ${BATCHABLE_TOOL_NAMES.join(", ")}.`,
8366
8463
  permission: "confirm",
8464
+ // Scheduling barrier: keep the outer meta-call out of ToolExecutor's
8465
+ // non-mutating fan-out even though every eligible inner tool is read-only.
8367
8466
  mutating: true,
8368
8467
  timeoutMs: 12e4,
8369
- capabilities: ["tool.mutate.any"],
8468
+ capabilities: ["tool.meta"],
8370
8469
  icon: "meta",
8371
8470
  inputSchema: {
8372
8471
  type: "object",
8373
8472
  properties: {
8374
8473
  calls: {
8375
8474
  type: "array",
8475
+ maxItems: MAX_BATCH_CALLS,
8376
8476
  items: {
8377
8477
  type: "object",
8378
8478
  properties: {
8379
- tool: { type: "string" },
8479
+ tool: {
8480
+ type: "string",
8481
+ enum: [...BATCHABLE_TOOL_NAMES],
8482
+ description: "An explicitly batch-safe, read-only discovery tool."
8483
+ },
8380
8484
  input: { type: "object" }
8381
8485
  },
8382
8486
  required: ["tool"]
8383
8487
  },
8384
- description: "Array of tool calls to execute"
8488
+ description: `Up to ${MAX_BATCH_CALLS} independent, batch-safe read calls.`
8385
8489
  },
8386
8490
  stop_on_error: {
8387
8491
  type: "boolean",
@@ -8404,6 +8508,9 @@ var batchToolUseTool = {
8404
8508
  stop_on_error: false
8405
8509
  };
8406
8510
  }
8511
+ if (input.calls.length > MAX_BATCH_CALLS) {
8512
+ throw new Error(`batch_tool_use: calls must contain at most ${MAX_BATCH_CALLS} items`);
8513
+ }
8407
8514
  const governedExecute = ctx.meta[GOVERNED_TOOL_EXECUTOR_META_KEY];
8408
8515
  if (typeof governedExecute !== "function") {
8409
8516
  return {
@@ -8423,8 +8530,11 @@ var batchToolUseTool = {
8423
8530
  let succeeded = 0;
8424
8531
  let failed = 0;
8425
8532
  if (input.parallel !== false) {
8426
- const promises = input.calls.map(async (call) => executeSingle(call, ctx, governedExecute));
8427
- const allResults = await Promise.all(promises);
8533
+ const allResults = await mapWithConcurrency(
8534
+ input.calls,
8535
+ MAX_PARALLEL_BATCH_CALLS,
8536
+ (call) => executeSingle(call, ctx, governedExecute)
8537
+ );
8428
8538
  results.push(...allResults);
8429
8539
  succeeded = allResults.filter((r) => r.success).length;
8430
8540
  failed = allResults.filter((r) => !r.success).length;
@@ -8449,8 +8559,28 @@ var batchToolUseTool = {
8449
8559
  };
8450
8560
  }
8451
8561
  };
8562
+ async function mapWithConcurrency(items, limit, run) {
8563
+ const results = new Array(items.length);
8564
+ let nextIndex = 0;
8565
+ const worker2 = async () => {
8566
+ while (nextIndex < items.length) {
8567
+ const index = nextIndex++;
8568
+ results[index] = await run(items[index]);
8569
+ }
8570
+ };
8571
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker2()));
8572
+ return results;
8573
+ }
8452
8574
  async function executeSingle(call, ctx, governedExecute) {
8453
8575
  const start = Date.now();
8576
+ if (call.tool === batchToolUseTool.name) {
8577
+ return {
8578
+ tool: call.tool,
8579
+ success: false,
8580
+ error: "recursive batch_tool_use execution is not allowed",
8581
+ executionMs: Date.now() - start
8582
+ };
8583
+ }
8454
8584
  const tool = ctx.tools.find((candidate) => candidate.name === call.tool);
8455
8585
  if (!tool) {
8456
8586
  return {
@@ -8460,6 +8590,14 @@ async function executeSingle(call, ctx, governedExecute) {
8460
8590
  executionMs: Date.now() - start
8461
8591
  };
8462
8592
  }
8593
+ if (!BATCHABLE_TOOLS.has(tool.name) || tool.mutating) {
8594
+ return {
8595
+ tool: call.tool,
8596
+ success: false,
8597
+ error: `tool "${call.tool}" is not eligible for batch execution; call it directly. Allowed: ${BATCHABLE_TOOL_NAMES.join(", ")}`,
8598
+ executionMs: Date.now() - start
8599
+ };
8600
+ }
8463
8601
  try {
8464
8602
  const result = await governedExecute(call.tool, call.input);
8465
8603
  return {
@@ -8496,8 +8634,14 @@ import * as path10 from "node:path";
8496
8634
  import { atomicWrite, ulid } from "@wrongstack/core/utils";
8497
8635
  var ARTIFACT_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
8498
8636
  var sweptRoots = /* @__PURE__ */ new Set();
8637
+ var MAX_SWEPT_ROOTS = 128;
8499
8638
  function sweepOldArtifacts(root) {
8500
8639
  if (sweptRoots.has(root)) return;
8640
+ while (sweptRoots.size >= MAX_SWEPT_ROOTS) {
8641
+ const oldest = sweptRoots.values().next().value;
8642
+ if (oldest === void 0) break;
8643
+ sweptRoots.delete(oldest);
8644
+ }
8501
8645
  sweptRoots.add(root);
8502
8646
  void (async () => {
8503
8647
  const cutoff = Date.now() - ARTIFACT_RETENTION_MS;
@@ -8520,8 +8664,8 @@ function sweepOldArtifacts(root) {
8520
8664
  for (const name of names) {
8521
8665
  const target = path10.join(dir, name);
8522
8666
  try {
8523
- const stat17 = await fs7.stat(target);
8524
- if (stat17.isFile() && stat17.mtimeMs < cutoff) {
8667
+ const stat18 = await fs7.stat(target);
8668
+ if (stat18.isFile() && stat18.mtimeMs < cutoff) {
8525
8669
  await fs7.rm(target, { force: true });
8526
8670
  removed++;
8527
8671
  }
@@ -8549,14 +8693,14 @@ var BrowserArtifactStore = class {
8549
8693
  }
8550
8694
  async record(id, sessionId, kind, target, mimeType) {
8551
8695
  await fs7.chmod(target, 384).catch(() => void 0);
8552
- const [stat17, sha256] = await Promise.all([fs7.stat(target), hashFile(target)]);
8696
+ const [stat18, sha256] = await Promise.all([fs7.stat(target), hashFile(target)]);
8553
8697
  const artifact = {
8554
8698
  id,
8555
8699
  kind,
8556
8700
  sensitivity: "sensitive",
8557
8701
  path: target,
8558
8702
  mimeType,
8559
- sizeBytes: stat17.size,
8703
+ sizeBytes: stat18.size,
8560
8704
  sha256,
8561
8705
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
8562
8706
  };
@@ -8926,7 +9070,7 @@ var BrowserSessionManager = class {
8926
9070
  constructor(options, launcher = defaultLauncher) {
8927
9071
  this.launcher = launcher;
8928
9072
  this.artifacts = new BrowserArtifactStore(options.artifactRoot);
8929
- this.allowPrivateHosts = options.allowPrivateHosts ?? true;
9073
+ this.allowPrivateHosts = options.allowPrivateHosts ?? false;
8930
9074
  this.allowedPrivateOrigins = options.allowedPrivateOrigins ?? [];
8931
9075
  this.networkProxy = new BrowserNetworkGuardProxy({
8932
9076
  allowPrivateHosts: this.allowPrivateHosts,
@@ -9149,8 +9293,8 @@ var BrowserSessionManager = class {
9149
9293
  if (realRelative.startsWith("..") || path11.isAbsolute(realRelative)) {
9150
9294
  throw new Error("browser: upload files must not escape the project root through a symlink");
9151
9295
  }
9152
- const stat17 = await fs8.stat(realFile);
9153
- if (!stat17.isFile()) throw new Error(`browser: upload target is not a file: ${file}`);
9296
+ const stat18 = await fs8.stat(realFile);
9297
+ if (!stat18.isFile()) throw new Error(`browser: upload target is not a file: ${file}`);
9154
9298
  resolved.push(realFile);
9155
9299
  }
9156
9300
  await this.runPageOperation(
@@ -9499,6 +9643,9 @@ var browserScreenshotTool = {
9499
9643
  description: "Capture a page or element PNG and return sensitive artifact metadata with integrity hash.",
9500
9644
  usageHint: "browser_screenshot({ sessionId, fullPage?, selector? })",
9501
9645
  permission: "confirm",
9646
+ // WS-046: the element/expression acted on, not the sessionId — a trust
9647
+ // rule must distinguish clicking one control from another.
9648
+ subjectKey: "selector",
9502
9649
  mutating: true,
9503
9650
  riskTier: "safe",
9504
9651
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9528,6 +9675,9 @@ function selectorTool(name, description, run) {
9528
9675
  description,
9529
9676
  usageHint: `${name}({ sessionId, selector })`,
9530
9677
  permission: "confirm",
9678
+ // WS-046: the element/expression acted on, not the sessionId — a trust
9679
+ // rule must distinguish clicking one control from another.
9680
+ subjectKey: "selector",
9531
9681
  mutating: true,
9532
9682
  riskTier: "standard",
9533
9683
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9557,6 +9707,9 @@ var browserTypeTool = {
9557
9707
  description: "Fill an input in an owned browser session. Use secretEnv instead of text for credentials so values stay out of tool arguments and session audit.",
9558
9708
  usageHint: "browser_type({ sessionId, selector, text? | secretEnv? })",
9559
9709
  permission: "confirm",
9710
+ // WS-046: the element/expression acted on, not the sessionId — a trust
9711
+ // rule must distinguish clicking one control from another.
9712
+ subjectKey: "selector",
9560
9713
  mutating: true,
9561
9714
  riskTier: "standard",
9562
9715
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9591,6 +9744,9 @@ var browserSelectTool = {
9591
9744
  description: "Select an option in an owned browser session.",
9592
9745
  usageHint: "browser_select({ sessionId, selector, value })",
9593
9746
  permission: "confirm",
9747
+ // WS-046: the element/expression acted on, not the sessionId — a trust
9748
+ // rule must distinguish clicking one control from another.
9749
+ subjectKey: "selector",
9594
9750
  mutating: true,
9595
9751
  riskTier: "standard",
9596
9752
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9619,6 +9775,9 @@ var browserPressTool = {
9619
9775
  description: "Press a keyboard key in an owned browser session.",
9620
9776
  usageHint: "browser_press({ sessionId, key })",
9621
9777
  permission: "confirm",
9778
+ // WS-046: the element/expression acted on, not the sessionId — a trust
9779
+ // rule must distinguish clicking one control from another.
9780
+ subjectKey: "key",
9622
9781
  mutating: true,
9623
9782
  riskTier: "standard",
9624
9783
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9637,6 +9796,9 @@ var browserDragTool = {
9637
9796
  description: "Drag one element to another in an owned browser session.",
9638
9797
  usageHint: "browser_drag({ sessionId, from, to })",
9639
9798
  permission: "confirm",
9799
+ // WS-046: the element/expression acted on, not the sessionId — a trust
9800
+ // rule must distinguish clicking one control from another.
9801
+ subjectKey: "from",
9640
9802
  mutating: true,
9641
9803
  riskTier: "standard",
9642
9804
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9686,6 +9848,9 @@ var browserEvaluateTool = {
9686
9848
  description: "Evaluate bounded JavaScript in the page. Requires confirmation because page code is arbitrary.",
9687
9849
  usageHint: "browser_evaluate({ sessionId, expression })",
9688
9850
  permission: "confirm",
9851
+ // WS-046: the element/expression acted on, not the sessionId — a trust
9852
+ // rule must distinguish clicking one control from another.
9853
+ subjectKey: "expression",
9689
9854
  mutating: true,
9690
9855
  riskTier: "destructive",
9691
9856
  capabilities: [ToolCapabilities.NET_OUTBOUND, ToolCapabilities.TOOL_MUTATE_ANY],
@@ -9708,6 +9873,9 @@ var browserUploadTool = {
9708
9873
  description: "Upload project-local files through a file input in an owned browser session.",
9709
9874
  usageHint: "browser_upload({ sessionId, selector, files })",
9710
9875
  permission: "confirm",
9876
+ // WS-046: the element/expression acted on, not the sessionId — a trust
9877
+ // rule must distinguish clicking one control from another.
9878
+ subjectKey: "selector",
9711
9879
  mutating: true,
9712
9880
  riskTier: "destructive",
9713
9881
  capabilities: [ToolCapabilities.NET_OUTBOUND, ToolCapabilities.FS_READ],
@@ -10643,6 +10811,7 @@ var REFS_INDEX_SQL = [
10643
10811
  var SYMBOLS_FTS_SQL = "CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(text, tokenize = 'unicode61')";
10644
10812
 
10645
10813
  // src/codebase-index/writer-search-helpers.ts
10814
+ var SEARCH_CANDIDATE_SCAN_CAP = 5e3;
10646
10815
  function normalizeSearchLimit(limit) {
10647
10816
  return typeof limit === "number" && Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : void 0;
10648
10817
  }
@@ -10672,8 +10841,8 @@ function buildWriterSearchWhere(query, filter) {
10672
10841
  }
10673
10842
  if (query.trim()) {
10674
10843
  const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
10675
- conditions.push(`(${tokens.map(() => "text LIKE ?").join(" OR ")})`);
10676
- for (const token of tokens) values.push(`%${token}%`);
10844
+ conditions.push(`(${tokens.map(() => "text LIKE ? ESCAPE '\\'").join(" OR ")})`);
10845
+ for (const token of tokens) values.push(`%${escapeLike(token)}%`);
10677
10846
  }
10678
10847
  return { where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", values };
10679
10848
  }
@@ -10782,6 +10951,7 @@ var StorePool = class {
10782
10951
 
10783
10952
  // src/codebase-index/writer.ts
10784
10953
  var DB_FILE2 = "index.db";
10954
+ var MAX_STATEMENT_CACHE = 128;
10785
10955
  var IndexStore = class _IndexStore {
10786
10956
  db;
10787
10957
  /** Absolute path to this project's index directory. */
@@ -10819,12 +10989,31 @@ var IndexStore = class _IndexStore {
10819
10989
  // Dirty on open so the first getOrBuildBm25() rebuilds against current rows;
10820
10990
  // an empty or pre-existing corpus makes a stale IDF table meaningless.
10821
10991
  bm25Dirty = true;
10822
- /** Prepare-once helper: compile `sql` on first use, reuse thereafter. */
10992
+ /**
10993
+ * Prepare-once helper: compile `sql` on first use, reuse thereafter.
10994
+ *
10995
+ * Bounded LRU rather than an open Map. The cache is keyed by SQL TEXT, and
10996
+ * the fallback search builder emits one `text LIKE ?` clause per query token
10997
+ * — so the SQL varies with the token count and a stream of differently-sized
10998
+ * queries grew the cache without limit. Sage's store already bounds its
10999
+ * equivalent at 128 (WS-096).
11000
+ *
11001
+ * Re-inserting on a hit keeps the hot fixed-SQL statements (upsertFile,
11002
+ * insertRefs, …) at the young end, so a burst of one-off search SQL evicts
11003
+ * itself rather than the reindex hot path.
11004
+ */
10823
11005
  stmt(sql) {
10824
- let s = this.stmtCache.get(sql);
10825
- if (s === void 0) {
10826
- s = this.db.prepare(sql);
10827
- this.stmtCache.set(sql, s);
11006
+ const cached = this.stmtCache.get(sql);
11007
+ if (cached !== void 0) {
11008
+ this.stmtCache.delete(sql);
11009
+ this.stmtCache.set(sql, cached);
11010
+ return cached;
11011
+ }
11012
+ const s = this.db.prepare(sql);
11013
+ this.stmtCache.set(sql, s);
11014
+ if (this.stmtCache.size > MAX_STATEMENT_CACHE) {
11015
+ const oldest = this.stmtCache.keys().next();
11016
+ if (!oldest.done) this.stmtCache.delete(oldest.value);
10828
11017
  }
10829
11018
  return s;
10830
11019
  }
@@ -11097,7 +11286,7 @@ var IndexStore = class _IndexStore {
11097
11286
  const { where, values } = built;
11098
11287
  const limit = normalizeSearchLimit(opts?.limit);
11099
11288
  const limitSql = limit !== void 0 ? " LIMIT ?" : "";
11100
- const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment, text FROM symbols ${where}${limitSql}`;
11289
+ const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment FROM symbols ${where}${limitSql}`;
11101
11290
  const binds = limit !== void 0 ? [...values, limit] : values;
11102
11291
  const rows = this.stmt(sql).all(
11103
11292
  ...binds
@@ -11226,11 +11415,13 @@ var IndexStore = class _IndexStore {
11226
11415
  /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
11227
11416
  searchRankedFallback(query, filter, limit) {
11228
11417
  if (!query.trim()) {
11229
- const total = this.countSearch(query, filter);
11230
- if (total === 0) return { results: [], total: 0 };
11231
- return { results: this.search(query, filter, { limit }), total };
11418
+ const total2 = this.countSearch(query, filter);
11419
+ if (total2 === 0) return { results: [], total: 0 };
11420
+ return { results: this.search(query, filter, { limit }), total: total2 };
11232
11421
  }
11233
- const candidates = this.search(query, filter);
11422
+ const total = this.countSearch(query, filter);
11423
+ if (total === 0) return { results: [], total: 0 };
11424
+ const candidates = this.search(query, filter, { limit: SEARCH_CANDIDATE_SCAN_CAP });
11234
11425
  if (candidates.length === 0) return { results: [], total: 0 };
11235
11426
  const candidateById = new Map(candidates.map((c) => [c.id, c]));
11236
11427
  const bm25 = this.getOrBuildBm25();
@@ -11256,7 +11447,7 @@ var IndexStore = class _IndexStore {
11256
11447
  const c = expectDefined2(candidateById.get(id));
11257
11448
  return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
11258
11449
  });
11259
- return { results, total: candidates.length };
11450
+ return { results, total };
11260
11451
  }
11261
11452
  getAllIndexable() {
11262
11453
  return getAllIndexableWithStatement((sql) => this.stmt(sql));
@@ -11675,12 +11866,12 @@ var buildIdCache;
11675
11866
  function projectIndexServerBuildId(entrypoint) {
11676
11867
  const file = entrypoint instanceof URL || entrypoint.startsWith("file:") ? fileURLToPath(entrypoint) : path16.resolve(entrypoint);
11677
11868
  try {
11678
- const stat17 = fs11.statSync(file);
11679
- if (buildIdCache?.file === file && buildIdCache.mtimeMs === stat17.mtimeMs && buildIdCache.size === stat17.size) {
11869
+ const stat18 = fs11.statSync(file);
11870
+ if (buildIdCache?.file === file && buildIdCache.mtimeMs === stat18.mtimeMs && buildIdCache.size === stat18.size) {
11680
11871
  return buildIdCache.buildId;
11681
11872
  }
11682
11873
  const buildId = createHash4("sha256").update(fs11.readFileSync(file)).digest("hex").slice(0, 24);
11683
- buildIdCache = { file, mtimeMs: stat17.mtimeMs, size: stat17.size, buildId };
11874
+ buildIdCache = { file, mtimeMs: stat18.mtimeMs, size: stat18.size, buildId };
11684
11875
  return buildId;
11685
11876
  } catch {
11686
11877
  return `unreadable:${path16.basename(file)}`;
@@ -11850,6 +12041,8 @@ var ProjectServerConnection = class {
11850
12041
  connectResolve = null;
11851
12042
  connectReject = null;
11852
12043
  nextId = 1;
12044
+ /** WS-027: read from the owner-only metadata file — see currentAuthToken. */
12045
+ authToken;
11853
12046
  pending = /* @__PURE__ */ new Map();
11854
12047
  transition(status, options = {}) {
11855
12048
  const previous = connectionStates.get(this.endpoint);
@@ -11870,6 +12063,10 @@ var ProjectServerConnection = class {
11870
12063
  isConnected() {
11871
12064
  return this.socket !== null && !this.socket.destroyed && this.info !== null;
11872
12065
  }
12066
+ /** Safe LRU candidate: no request, connect, or health probe is in flight. */
12067
+ isEvictable() {
12068
+ return this.pending.size === 0 && this.connecting === null && this.healthCheck === null;
12069
+ }
11873
12070
  async checkHealth(spawnIfMissing = false, timeoutMs = SERVER_HEALTH_TIMEOUT_MS) {
11874
12071
  await this.ensureConnected(spawnIfMissing);
11875
12072
  if (this.healthCheck) return this.healthCheck;
@@ -11982,6 +12179,31 @@ var ProjectServerConnection = class {
11982
12179
  this.transition("offline");
11983
12180
  maybeStopHeartbeatLoop();
11984
12181
  }
12182
+ /**
12183
+ * WS-027: the per-process token, read from the daemon's owner-only metadata
12184
+ * file — never from the `hello` frame, which the daemon sends to every
12185
+ * socket that connects (the mistake WS-028 found in the SAGE daemon).
12186
+ *
12187
+ * Read lazily and re-read while unknown: the daemon starts listening before
12188
+ * it writes metadata (endpoint ownership has to be won first), and a
12189
+ * respawned daemon mints a new token, so a cached wrong one would be sticky.
12190
+ */
12191
+ currentAuthToken() {
12192
+ if (this.authToken === void 0) {
12193
+ try {
12194
+ const raw = fs12.readFileSync(
12195
+ projectIndexServerMetadataPath(this.projectRoot, this.indexDir),
12196
+ "utf8"
12197
+ );
12198
+ const parsed = JSON.parse(raw);
12199
+ if (typeof parsed.authToken === "string" && parsed.authToken.length > 0) {
12200
+ this.authToken = parsed.authToken;
12201
+ }
12202
+ } catch {
12203
+ }
12204
+ }
12205
+ return this.authToken;
12206
+ }
11985
12207
  request(message, options) {
11986
12208
  const socket = this.socket;
11987
12209
  if (!socket || socket.destroyed) {
@@ -12025,7 +12247,7 @@ var ProjectServerConnection = class {
12025
12247
  return;
12026
12248
  }
12027
12249
  }
12028
- this.write({ ...message, id });
12250
+ this.write({ ...message, id, authToken: this.currentAuthToken() });
12029
12251
  });
12030
12252
  }
12031
12253
  async ensureConnected(spawnIfMissing) {
@@ -12072,6 +12294,7 @@ var ProjectServerConnection = class {
12072
12294
  connectOnce() {
12073
12295
  this.socket?.destroy();
12074
12296
  this.socket = null;
12297
+ this.authToken = void 0;
12075
12298
  this.info = null;
12076
12299
  this.activity = null;
12077
12300
  this.health = null;
@@ -12268,7 +12491,26 @@ var ProjectServerConnection = class {
12268
12491
  }
12269
12492
  };
12270
12493
  var connections = /* @__PURE__ */ new Map();
12494
+ var MAX_CACHED_CONNECTIONS = 8;
12271
12495
  var heartbeatTimer;
12496
+ function forgetConnection(endpoint, connection) {
12497
+ if (connections.get(endpoint) === connection) connections.delete(endpoint);
12498
+ connection.close();
12499
+ connectionStates.delete(endpoint);
12500
+ if (latestConnectionState.endpoint !== endpoint) return;
12501
+ latestConnectionState = [...connectionStates.values()].at(-1) ?? {
12502
+ status: isProjectIndexServerAvailable() ? "offline" : "unavailable",
12503
+ connected: false
12504
+ };
12505
+ }
12506
+ function trimConnectionCache(protectedConnection) {
12507
+ if (connections.size <= MAX_CACHED_CONNECTIONS) return;
12508
+ for (const [endpoint, connection] of connections) {
12509
+ if (connections.size <= MAX_CACHED_CONNECTIONS) break;
12510
+ if (connection === protectedConnection || !connection.isEvictable()) continue;
12511
+ forgetConnection(endpoint, connection);
12512
+ }
12513
+ }
12272
12514
  function ensureHeartbeatLoop() {
12273
12515
  if (heartbeatTimer) return;
12274
12516
  heartbeatTimer = setInterval(() => {
@@ -12291,7 +12533,11 @@ function connectionFor(projectRoot, indexDir) {
12291
12533
  if (!connection) {
12292
12534
  connection = new ProjectServerConnection(projectRoot, indexDir, endpoint);
12293
12535
  connections.set(endpoint, connection);
12536
+ } else {
12537
+ connections.delete(endpoint);
12538
+ connections.set(endpoint, connection);
12294
12539
  }
12540
+ trimConnectionCache(connection);
12295
12541
  return connection;
12296
12542
  }
12297
12543
  function callProjectIndexServer(op, args, options) {
@@ -12658,9 +12904,9 @@ async function runIndexerWithStore(store, opts) {
12658
12904
  const statReadParse = await Promise.allSettled(
12659
12905
  batchFiles.map(
12660
12906
  async (file) => {
12661
- let stat17;
12907
+ let stat18;
12662
12908
  try {
12663
- stat17 = await fs17.stat(file, statOpts);
12909
+ stat18 = await fs17.stat(file, statOpts);
12664
12910
  } catch (e) {
12665
12911
  if (isAbortError(e)) throw e;
12666
12912
  return {
@@ -12672,21 +12918,21 @@ async function runIndexerWithStore(store, opts) {
12672
12918
  missing: isMissingPathError(e)
12673
12919
  };
12674
12920
  }
12675
- if (!stat17.isFile()) return { file, stat: stat17, lang: "", parsed: null };
12921
+ if (!stat18.isFile()) return { file, stat: stat18, lang: "", parsed: null };
12676
12922
  const lang = detectLang(file);
12677
- if (!lang) return { file, stat: stat17, lang: "", parsed: null };
12678
- if (stat17.size > MAX_INDEX_FILE_BYTES) {
12923
+ if (!lang) return { file, stat: stat18, lang: "", parsed: null };
12924
+ if (stat18.size > MAX_INDEX_FILE_BYTES) {
12679
12925
  return {
12680
12926
  file,
12681
- stat: stat17,
12927
+ stat: stat18,
12682
12928
  lang,
12683
12929
  parsed: null,
12684
- error: `file too large (${stat17.size} bytes; max ${MAX_INDEX_FILE_BYTES})`
12930
+ error: `file too large (${stat18.size} bytes; max ${MAX_INDEX_FILE_BYTES})`
12685
12931
  };
12686
12932
  }
12687
12933
  const meta = existingMeta.get(file);
12688
- if (!force && meta && meta.mtimeMs === Math.floor(stat17.mtimeMs)) {
12689
- return { file, stat: stat17, lang, parsed: null, skippedMeta: meta };
12934
+ if (!force && meta && meta.mtimeMs === Math.floor(stat18.mtimeMs)) {
12935
+ return { file, stat: stat18, lang, parsed: null, skippedMeta: meta };
12690
12936
  }
12691
12937
  let content;
12692
12938
  try {
@@ -12695,7 +12941,7 @@ async function runIndexerWithStore(store, opts) {
12695
12941
  if (isAbortError(e)) throw e;
12696
12942
  return {
12697
12943
  file,
12698
- stat: stat17,
12944
+ stat: stat18,
12699
12945
  lang,
12700
12946
  parsed: null,
12701
12947
  error: `read error: ${e instanceof Error ? e.message : String(e)}`
@@ -12707,13 +12953,13 @@ async function runIndexerWithStore(store, opts) {
12707
12953
  } catch (e) {
12708
12954
  return {
12709
12955
  file,
12710
- stat: stat17,
12956
+ stat: stat18,
12711
12957
  lang,
12712
12958
  parsed: null,
12713
12959
  error: `parse error: ${e instanceof Error ? e.message : String(e)}`
12714
12960
  };
12715
12961
  }
12716
- return { file, stat: stat17, lang, parsed, content };
12962
+ return { file, stat: stat18, lang, parsed, content };
12717
12963
  }
12718
12964
  )
12719
12965
  );
@@ -12734,7 +12980,7 @@ async function runIndexerWithStore(store, opts) {
12734
12980
  errors.push(`${file}: ${result.error}`);
12735
12981
  continue;
12736
12982
  }
12737
- const { stat: stat17, lang, parsed } = result;
12983
+ const { stat: stat18, lang, parsed } = result;
12738
12984
  if (result.skippedMeta) {
12739
12985
  langStats[lang] = (langStats[lang] ?? 0) + result.skippedMeta.symbolCount;
12740
12986
  symbolsIndexed += result.skippedMeta.symbolCount;
@@ -12746,7 +12992,7 @@ async function runIndexerWithStore(store, opts) {
12746
12992
  store.upsertFile({
12747
12993
  file,
12748
12994
  lang,
12749
- mtimeMs: Math.floor(stat17.mtimeMs),
12995
+ mtimeMs: Math.floor(stat18.mtimeMs),
12750
12996
  symbolCount: 0,
12751
12997
  lastIndexed: Date.now()
12752
12998
  });
@@ -12758,7 +13004,7 @@ async function runIndexerWithStore(store, opts) {
12758
13004
  store.replaceEmptyFile({
12759
13005
  file,
12760
13006
  lang,
12761
- mtimeMs: Math.floor(stat17.mtimeMs),
13007
+ mtimeMs: Math.floor(stat18.mtimeMs),
12762
13008
  symbolCount: 0,
12763
13009
  lastIndexed: Date.now()
12764
13010
  });
@@ -12770,7 +13016,7 @@ async function runIndexerWithStore(store, opts) {
12770
13016
  lang,
12771
13017
  symbols: parsed.symbols,
12772
13018
  refs: parsed.refs ?? [],
12773
- mtimeMs: Math.floor(stat17.mtimeMs),
13019
+ mtimeMs: Math.floor(stat18.mtimeMs),
12774
13020
  symbolCount: parsed.symbols.length
12775
13021
  });
12776
13022
  deleteForFiles.push(file);
@@ -13823,8 +14069,9 @@ function runDeadCodeScan(projectRoot, opts = {}) {
13823
14069
  const content = fs19.readFileSync(epFile, "utf8");
13824
14070
  const strippedContent = content.replace(/\/\*[\s\S]*?\*\//g, (m) => " ".repeat(m.length)).replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length));
13825
14071
  const reExportRe = /export\s+(?:(?:type\s+)?\{[\s\S]*?\}\s+from|\*\s+as\s+\w+\s+from|\*\s+from)\s+['"]([^'"]+)['"]/g;
13826
- let match;
13827
- while ((match = reExportRe.exec(strippedContent)) !== null) {
14072
+ for (; ; ) {
14073
+ const match = reExportRe.exec(strippedContent);
14074
+ if (match === null) break;
13828
14075
  const moduleSpec = match[1];
13829
14076
  const resolvedFiles = resolveModulePath(epFile, moduleSpec, indexedFiles);
13830
14077
  for (const rf of resolvedFiles) {
@@ -14030,6 +14277,9 @@ var designTool = {
14030
14277
  description: 'Browse, load, customize, and enforce curated frontend/mobile UI design kits. Use BEFORE writing UI code to commit to one coherent, modern, responsive, dark/light, accessible design. Actions: "list" (menu), "use" (load+pin a kit for a stack), "foundations" (baseline), "set" (override kit colors/tokens), "materialize" (write the tokens to a real theme file \u2014 CSS @theme/OKLCH or native), "verify" (scan UI files for off-palette colors).',
14031
14278
  usageHint: 'Flow: `design {action:"use", kit:"minimal-clarity", stack:"web"}` \u2192 optionally `design {action:"set", set:{primary:"oklch(62% 0.2 25)"}}` \u2192 `design {action:"materialize"}` to write tokens to disk \u2192 implement against them \u2192 `design {action:"verify"}`.',
14032
14279
  permission: "confirm",
14280
+ // WS-046: gives permission decisions something to key on.
14281
+ // The action performed; `out` is optional so it cannot be the subject.
14282
+ subjectKey: "action",
14033
14283
  mutating: true,
14034
14284
  capabilities: ["fs.write"],
14035
14285
  timeoutMs: 15e3,
@@ -14210,9 +14460,7 @@ Run \`design {action:"materialize"}\` to write the tuned tokens to your theme fi
14210
14460
  const abs = path25.join(absParent, path25.basename(absResolved));
14211
14461
  const rel = path25.relative(root, abs);
14212
14462
  if (rel.startsWith("..") || path25.isAbsolute(rel)) {
14213
- throw new Error(
14214
- `design: materialize path "${result.path}" would escape the project root`
14215
- );
14463
+ throw new Error(`design: materialize path "${result.path}" would escape the project root`);
14216
14464
  }
14217
14465
  let exists = false;
14218
14466
  try {
@@ -14287,6 +14535,7 @@ import { statSync as statSync3 } from "node:fs";
14287
14535
  import * as fs21 from "node:fs/promises";
14288
14536
  import * as path26 from "node:path";
14289
14537
  import { buildChildEnv as buildChildEnv3 } from "@wrongstack/core/utils";
14538
+ var MAX_FILE_DUMP_BYTES = 5 * 1024 * 1024;
14290
14539
  var diffTool = {
14291
14540
  name: "diff",
14292
14541
  category: "Filesystem",
@@ -14294,6 +14543,7 @@ var diffTool = {
14294
14543
  usageHint: 'USE FOR CODE REVIEW AND CHANGE INSPECTION:\n\n- `files` + no `a`/`b` \u2192 show file content with line numbers (NOT a unified diff; no +/- prefixes).\n- `a` and/or `b` \u2192 git-style commit/branch diff (unified format, real +/- prefixes).\n- `staged: true` \u2192 only show staged changes.\n- `mode` can be "unified", "stat", or "side-by-side" (only affects the git-diff path).\n\nNOTE: For a true file-vs-file unified diff, supply `a` and `b` so the tool delegates to `git diff`. The `files`-only path is a line-numbered dump, not a diff.\n\nThis tool has important safety guards against flag injection (see previous security findings).',
14295
14544
  permission: "auto",
14296
14545
  mutating: false,
14546
+ maxOutputBytes: 262144,
14297
14547
  capabilities: ["fs.read"],
14298
14548
  icon: "diff",
14299
14549
  timeoutMs: 1e4,
@@ -14369,8 +14619,8 @@ function findGitDir(cwd) {
14369
14619
  let dir = cwd;
14370
14620
  for (let i = 0; i < 20; i++) {
14371
14621
  try {
14372
- const stat17 = statSync3(path26.join(dir, ".git"));
14373
- if (stat17.isDirectory()) return dir;
14622
+ const stat18 = statSync3(path26.join(dir, ".git"));
14623
+ if (stat18.isDirectory()) return dir;
14374
14624
  } catch {
14375
14625
  }
14376
14626
  const parent = path26.dirname(dir);
@@ -14412,10 +14662,18 @@ async function fileDiff(input, ctx, _signal) {
14412
14662
  };
14413
14663
  }
14414
14664
  const results = [];
14665
+ let truncated = false;
14415
14666
  for (const file of files) {
14416
14667
  const absPath = safeResolve(file, ctx);
14417
- const stat17 = await fs21.stat(absPath).catch(() => null);
14418
- if (!stat17?.isFile()) continue;
14668
+ const stat18 = await fs21.stat(absPath).catch(() => null);
14669
+ if (!stat18?.isFile()) continue;
14670
+ if (stat18.size > MAX_FILE_DUMP_BYTES) {
14671
+ truncated = true;
14672
+ results.push(
14673
+ `--- ${file} (skipped: ${stat18.size} bytes exceeds the ${MAX_FILE_DUMP_BYTES} limit; use the read tool with offset/limit) ---`
14674
+ );
14675
+ continue;
14676
+ }
14419
14677
  const content = await fs21.readFile(absPath, "utf8");
14420
14678
  const lines = content.split(/\r?\n/);
14421
14679
  results.push(formatWithLineNumbers(file, lines));
@@ -14423,7 +14681,7 @@ async function fileDiff(input, ctx, _signal) {
14423
14681
  return {
14424
14682
  diff: results.join("\n\n"),
14425
14683
  files,
14426
- truncated: false,
14684
+ truncated,
14427
14685
  mode: input.mode ?? "unified"
14428
14686
  };
14429
14687
  }
@@ -14516,8 +14774,8 @@ async function resolveFiles(filesInput, cwd) {
14516
14774
  for (const f of files) {
14517
14775
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
14518
14776
  try {
14519
- const stat17 = await fs22.stat(absPath);
14520
- if (stat17.isFile()) resolved.push(absPath);
14777
+ const stat18 = await fs22.stat(absPath);
14778
+ if (stat18.isFile()) resolved.push(absPath);
14521
14779
  } catch {
14522
14780
  }
14523
14781
  }
@@ -15370,7 +15628,7 @@ var editTool = {
15370
15628
  });
15371
15629
  }
15372
15630
  const absPath = await safeResolveReal(input.path, ctx);
15373
- const stat17 = await fs23.stat(absPath).catch((err) => {
15631
+ const stat18 = await fs23.stat(absPath).catch((err) => {
15374
15632
  if (err.code === "ENOENT") {
15375
15633
  throw new ToolValidationError({
15376
15634
  message: `edit: file "${input.path}" does not exist. Use \`write\` instead.`,
@@ -15380,7 +15638,7 @@ var editTool = {
15380
15638
  }
15381
15639
  throw err;
15382
15640
  });
15383
- if (!stat17.isFile()) {
15641
+ if (!stat18.isFile()) {
15384
15642
  throw new ToolValidationError({
15385
15643
  message: `edit: "${input.path}" is not a regular file`,
15386
15644
  field: "path"
@@ -15410,7 +15668,7 @@ var editTool = {
15410
15668
  });
15411
15669
  }
15412
15670
  }
15413
- if (autoRead && updated.mtimeMs > stat17.mtimeMs + mtimeTolerance) {
15671
+ if (autoRead && updated.mtimeMs > stat18.mtimeMs + mtimeTolerance) {
15414
15672
  throw new ToolValidationError({
15415
15673
  message: `edit: file "${input.path}" changed while being auto-read. Retry the edit.`,
15416
15674
  field: "path",
@@ -15533,10 +15791,6 @@ import {
15533
15791
  emitProcessStarted as emitProcessStarted3
15534
15792
  } from "@wrongstack/core/observability";
15535
15793
  import { toErrorMessage as toErrorMessage3 } from "@wrongstack/core/utils/error";
15536
- init_output_spool();
15537
- init_util();
15538
- init_process_registry();
15539
- init_win32_resolve();
15540
15794
 
15541
15795
  // src/_danger-detect.ts
15542
15796
  var argHas = (args, value) => args.includes(value);
@@ -15819,6 +16073,11 @@ function levelRank(level) {
15819
16073
  }
15820
16074
  }
15821
16075
 
16076
+ // src/exec.ts
16077
+ init_output_spool();
16078
+ init_util();
16079
+ init_win32_resolve();
16080
+
15822
16081
  // src/exec-kill-guard.ts
15823
16082
  import * as os8 from "node:os";
15824
16083
  import * as path29 from "node:path";
@@ -16055,6 +16314,7 @@ async function checkKillTarget(target) {
16055
16314
  }
16056
16315
 
16057
16316
  // src/exec.ts
16317
+ init_process_registry();
16058
16318
  var isWin4 = process.platform === "win32";
16059
16319
  var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16060
16320
  // JS / TS toolchain
@@ -16691,7 +16951,17 @@ var BLOCKED_ARG_PATTERNS = {
16691
16951
  bun: [],
16692
16952
  docker: [],
16693
16953
  // find -exec/-ok/-execdir execute arbitrary commands
16694
- find: [/^-exec$/, /^-exec;$/, /^-ok$/, /^-ok;$/, /^-execdir$/, /^-execdir;$/, /^-exec=/, /^-ok=/, /^-execdir=/],
16954
+ find: [
16955
+ /^-exec$/,
16956
+ /^-exec;$/,
16957
+ /^-ok$/,
16958
+ /^-ok;$/,
16959
+ /^-execdir$/,
16960
+ /^-execdir;$/,
16961
+ /^-exec=/,
16962
+ /^-ok=/,
16963
+ /^-execdir=/
16964
+ ],
16695
16965
  // rm -rf / is catastrophic — block absolute paths, home, dot-dirs,
16696
16966
  // and glob patterns that could expand to dangerous targets.
16697
16967
  // `rm -rf ./src/*` expands to project files; `rm -rf ../../` escapes upward;
@@ -16762,6 +17032,14 @@ var execTool = {
16762
17032
  useInstead: ["bash"]
16763
17033
  },
16764
17034
  permission: "confirm",
17035
+ // WS-046: without this, every exec call collapsed onto the bare tool name.
17036
+ // Three consequences followed: "always allow" stored a rule that could never
17037
+ // match, pressing "no" once blocked ALL exec for the session, and the
17038
+ // permission cache keyed one decision for every command. `subjectKey:
17039
+ // 'command'` renders the FULL invocation (`command` + `args`) — using the
17040
+ // program alone would make `exec git status` and `exec git push --force` the
17041
+ // same subject, so trusting one would silently authorize the other.
17042
+ subjectKey: "command",
16765
17043
  mutating: true,
16766
17044
  riskTier: "standard",
16767
17045
  timeoutMs: DEFAULT_TIMEOUT_MS3,
@@ -16978,7 +17256,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId, danger) {
16978
17256
  const pid = child.pid;
16979
17257
  if (typeof pid === "number") {
16980
17258
  const fullCommand = `${cmd} ${args.join(" ")}`;
16981
- registry.register({ pid, name: "exec", command: redactCommand(fullCommand), startedAt: Date.now(), sessionId, child });
17259
+ registry.register({
17260
+ pid,
17261
+ name: "exec",
17262
+ command: redactCommand(fullCommand),
17263
+ startedAt: Date.now(),
17264
+ sessionId,
17265
+ child
17266
+ });
16982
17267
  }
16983
17268
  const timer = setTimeout(() => {
16984
17269
  killed = true;
@@ -17418,6 +17703,9 @@ var formatTool = {
17418
17703
  description: "Format source files according to project style (Biome). Can also run in check-only mode.",
17419
17704
  usageHint: "RUN REGULARLY:\n\n- Use on changed files before committing.\n- `check: true` verifies formatting without making changes (useful in CI-like flows).\nThis project has very consistent formatting expectations. Always ensure your changes are formatted.",
17420
17705
  permission: "confirm",
17706
+ // WS-046: gives permission decisions something to key on.
17707
+ // The files being rewritten are the subject; the fixer is how, not what.
17708
+ subjectKey: "files",
17421
17709
  mutating: true,
17422
17710
  capabilities: ["fs.write", "shell.restricted"],
17423
17711
  icon: "code",
@@ -17522,13 +17810,13 @@ var formatTool = {
17522
17810
  }
17523
17811
  };
17524
17812
  async function detectFixer(cwd) {
17525
- const { stat: stat17 } = await import("node:fs/promises");
17813
+ const { stat: stat18 } = await import("node:fs/promises");
17526
17814
  try {
17527
- await stat17(`${cwd}/biome.json`);
17815
+ await stat18(`${cwd}/biome.json`);
17528
17816
  return "biome";
17529
17817
  } catch {
17530
17818
  try {
17531
- await stat17(`${cwd}/.prettierrc`);
17819
+ await stat18(`${cwd}/.prettierrc`);
17532
17820
  return "prettier";
17533
17821
  } catch {
17534
17822
  return "biome";
@@ -17555,6 +17843,10 @@ var gitTool = {
17555
17843
  // Conservative: any of these may mutate. The non-mutating commands
17556
17844
  // (status/log/diff/branch/fetch) are still gated on `permission: 'confirm'`
17557
17845
  // and `MUTATING_SUBCOMMANDS` is consulted at runtime for per-call checks.
17846
+ // WS-046: gives permission decisions something to key on.
17847
+ // The git subcommand (status/commit/push) is what a trust rule needs to
17848
+ // distinguish; `git status` and `git push --force` must not share a subject.
17849
+ subjectKey: "command",
17558
17850
  mutating: true,
17559
17851
  capabilities: ["fs.write", "shell.restricted"],
17560
17852
  timeoutMs: TIMEOUT_MS2,
@@ -17623,6 +17915,8 @@ var gitTool = {
17623
17915
  truncated: false
17624
17916
  };
17625
17917
  }
17918
+ const branchGuard = validateBranchInput(input);
17919
+ if (branchGuard) return branchGuard;
17626
17920
  if (input.command === "worktree") {
17627
17921
  const guard = validateWorktreeInput(input, ctx.projectRoot);
17628
17922
  if (guard) return guard;
@@ -17671,6 +17965,18 @@ var gitTool = {
17671
17965
  return result;
17672
17966
  }
17673
17967
  };
17968
+ function validateBranchInput(input) {
17969
+ const branch = input.branch;
17970
+ if (branch === void 0) return null;
17971
+ if (!branch.startsWith("-") && !branch.includes(" --")) return null;
17972
+ return {
17973
+ command: input.command,
17974
+ stdout: "",
17975
+ stderr: `unsafe branch name (parsed as a git option): ${branch}`,
17976
+ exitCode: 1,
17977
+ truncated: false
17978
+ };
17979
+ }
17674
17980
  function validateWorktreeInput(input, projectRoot) {
17675
17981
  const reject = (stderr) => ({
17676
17982
  command: "worktree",
@@ -17679,7 +17985,6 @@ function validateWorktreeInput(input, projectRoot) {
17679
17985
  exitCode: 1,
17680
17986
  truncated: false
17681
17987
  });
17682
- if (input.branch?.startsWith("-")) return reject(`unsafe branch name: ${input.branch}`);
17683
17988
  if (input.worktreePath?.startsWith("-")) {
17684
17989
  return reject(`unsafe worktree path: ${input.worktreePath}`);
17685
17990
  }
@@ -17697,8 +18002,8 @@ function findGitDir2(cwd, projectRoot) {
17697
18002
  let dir = cwd;
17698
18003
  for (let i = 0; i < 20; i++) {
17699
18004
  try {
17700
- const stat17 = statSync4(`${dir}/.git`);
17701
- if (stat17.isDirectory() || stat17.isFile()) return dir;
18005
+ const stat18 = statSync4(`${dir}/.git`);
18006
+ if (stat18.isDirectory() || stat18.isFile()) return dir;
17702
18007
  } catch {
17703
18008
  }
17704
18009
  if (dir === root) break;
@@ -17733,7 +18038,10 @@ function buildArgs(input) {
17733
18038
  ...files.length ? ["--", ...files] : []
17734
18039
  ];
17735
18040
  case "branch":
17736
- return input.branch ? ["branch", ...input.branch.startsWith("-") || input.branch.includes(" --") ? [] : [input.branch]] : ["branch"];
18041
+ return input.branch ? [
18042
+ "branch",
18043
+ ...input.branch.startsWith("-") || input.branch.includes(" --") ? [] : [input.branch]
18044
+ ] : ["branch"];
17737
18045
  case "checkout":
17738
18046
  return [
17739
18047
  "checkout",
@@ -17826,7 +18134,7 @@ import * as path30 from "node:path";
17826
18134
  import { compileGlob as compileGlob2, DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS2 } from "@wrongstack/core/utils";
17827
18135
 
17828
18136
  // src/_concurrency.ts
17829
- async function mapWithConcurrency(items, limit, fn) {
18137
+ async function mapWithConcurrency2(items, limit, fn) {
17830
18138
  if (items.length === 0) return [];
17831
18139
  const effectiveLimit = Math.max(1, Math.min(limit | 0, items.length));
17832
18140
  const results = new Array(items.length);
@@ -17961,10 +18269,10 @@ var globTool = {
17961
18269
  }
17962
18270
  if (truncated) return;
17963
18271
  }
17964
- await mapWithConcurrency(matchedFiles, WALK_CONCURRENCY, pushResult);
18272
+ await mapWithConcurrency2(matchedFiles, WALK_CONCURRENCY, pushResult);
17965
18273
  if (truncated) return;
17966
18274
  const remainingSubdirs = truncated ? [] : subdirs;
17967
- await mapWithConcurrency(remainingSubdirs, WALK_CONCURRENCY, ({ full, rel }) => walk(full, rel));
18275
+ await mapWithConcurrency2(remainingSubdirs, WALK_CONCURRENCY, ({ full, rel }) => walk(full, rel));
17968
18276
  };
17969
18277
  await walk(base, "");
17970
18278
  results.sort((a, b) => b.mtime - a.mtime);
@@ -17973,12 +18281,16 @@ var globTool = {
17973
18281
  };
17974
18282
 
17975
18283
  // src/grep.ts
17976
- import { expectDefined as expectDefined7 } from "@wrongstack/core/utils";
17977
18284
  import { spawn as spawn11 } from "node:child_process";
17978
18285
  import * as fs25 from "node:fs/promises";
17979
18286
  import * as path31 from "node:path";
17980
18287
  import { ToolValidationError as ToolValidationError4 } from "@wrongstack/core/types";
17981
- import { buildChildEnv as buildChildEnv5, compileGlob as compileGlob3, DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS3 } from "@wrongstack/core/utils";
18288
+ import {
18289
+ buildChildEnv as buildChildEnv5,
18290
+ compileGlob as compileGlob3,
18291
+ DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS3,
18292
+ expectDefined as expectDefined7
18293
+ } from "@wrongstack/core/utils";
17982
18294
 
17983
18295
  // src/_regex.ts
17984
18296
  var MAX_PATTERN_LEN = 256;
@@ -18031,6 +18343,8 @@ var DEFAULT_IGNORE3 = DEFAULT_WALK_IGNORE_DIRS3;
18031
18343
  var NATIVE_SCAN_CONCURRENCY = 32;
18032
18344
  var NATIVE_READ_CHUNK_BYTES = 64 * 1024;
18033
18345
  var NATIVE_MAX_FILE_BYTES = 1e6;
18346
+ var RG_MAX_QUEUE_CHUNKS = 128;
18347
+ var RG_MAX_QUEUE_CHARS = 8 * 1024 * 1024;
18034
18348
  var grepTool = {
18035
18349
  name: "grep",
18036
18350
  category: "Search",
@@ -18158,9 +18472,19 @@ async function* runRgStream(input, base, mode, limit, signal) {
18158
18472
  const FLUSH_AT = 16;
18159
18473
  const MAX_BUF_BYTES = 1e6;
18160
18474
  let bufOverflow = false;
18161
- const child = spawn11("rg", args, { signal, env: buildChildEnv5(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
18475
+ const child = spawn11("rg", args, {
18476
+ signal,
18477
+ env: buildChildEnv5(),
18478
+ // rg diagnostics are not part of the tool result. Ignoring stderr avoids
18479
+ // an unread pipe filling up and pinning the child when rg reports many
18480
+ // filesystem errors.
18481
+ stdio: ["ignore", "pipe", "ignore"],
18482
+ windowsHide: true
18483
+ });
18162
18484
  const queue = [];
18485
+ let queuedChars = 0;
18163
18486
  let waiter;
18487
+ let paused = false;
18164
18488
  const wake = () => {
18165
18489
  if (waiter) {
18166
18490
  const w = waiter;
@@ -18168,93 +18492,133 @@ async function* runRgStream(input, base, mode, limit, signal) {
18168
18492
  w();
18169
18493
  }
18170
18494
  };
18171
- child.stdout?.on("data", (c) => {
18172
- queue.push({ kind: "out", data: c.toString() });
18495
+ const pauseIfFlooded = () => {
18496
+ if (paused || queue.length < RG_MAX_QUEUE_CHUNKS && queuedChars < RG_MAX_QUEUE_CHARS)
18497
+ return;
18498
+ paused = true;
18499
+ child.stdout?.pause();
18500
+ };
18501
+ const resumeIfDrained = () => {
18502
+ if (!paused || queue.length >= RG_MAX_QUEUE_CHUNKS || queuedChars >= RG_MAX_QUEUE_CHARS)
18503
+ return;
18504
+ paused = false;
18505
+ child.stdout?.resume();
18506
+ };
18507
+ const onStdoutData = (c) => {
18508
+ const data = c.toString();
18509
+ queue.push({ kind: "out", data });
18510
+ queuedChars += data.length;
18173
18511
  wake();
18174
- });
18175
- child.on("error", (e) => {
18512
+ pauseIfFlooded();
18513
+ };
18514
+ const onError = (e) => {
18176
18515
  queue.push({ kind: "error", data: e.message });
18177
18516
  wake();
18178
- });
18179
- child.on("close", () => {
18517
+ };
18518
+ const onClose = () => {
18180
18519
  queue.push({ kind: "close", data: "" });
18181
18520
  wake();
18182
- });
18521
+ };
18522
+ child.stdout?.on("data", onStdoutData);
18523
+ child.on("error", onError);
18524
+ child.on("close", onClose);
18183
18525
  let pendingBatch = [];
18184
18526
  let errored = false;
18185
- for (; ; ) {
18186
- while (queue.length === 0) {
18187
- await new Promise((r) => {
18188
- waiter = r;
18189
- });
18190
- }
18191
- const c = expectDefined7(queue.shift());
18192
- if (c.kind === "error") {
18193
- errored = true;
18194
- continue;
18195
- }
18196
- if (c.kind === "close") break;
18197
- buf += c.data;
18198
- if (buf.length > MAX_BUF_BYTES && !bufOverflow) {
18199
- bufOverflow = true;
18200
- buf = buf.slice(-MAX_BUF_BYTES);
18201
- try {
18202
- child.kill("SIGTERM");
18203
- } catch {
18527
+ let closed = false;
18528
+ try {
18529
+ for (; ; ) {
18530
+ while (queue.length === 0) {
18531
+ await new Promise((r) => {
18532
+ waiter = r;
18533
+ });
18204
18534
  }
18205
- }
18206
- const idx = buf.lastIndexOf("\n");
18207
- if (idx === -1) continue;
18208
- const ready = buf.slice(0, idx);
18209
- buf = buf.slice(idx + 1);
18210
- for (const line of ready.split("\n")) {
18211
- if (!line) continue;
18212
- totalLines++;
18213
- if (mode === "count") totalCount += parseRgCountLine(line);
18214
- if (matches.length < limit) {
18215
- matches.push(line);
18216
- pendingBatch.push(line);
18217
- batchSinceFlush++;
18535
+ const c = expectDefined7(queue.shift());
18536
+ queuedChars = Math.max(0, queuedChars - c.data.length);
18537
+ resumeIfDrained();
18538
+ if (c.kind === "error") {
18539
+ errored = true;
18540
+ continue;
18541
+ }
18542
+ if (c.kind === "close") {
18543
+ closed = true;
18544
+ break;
18545
+ }
18546
+ buf += c.data;
18547
+ if (buf.length > MAX_BUF_BYTES && !bufOverflow) {
18548
+ bufOverflow = true;
18549
+ buf = buf.slice(-MAX_BUF_BYTES);
18550
+ try {
18551
+ child.kill("SIGTERM");
18552
+ } catch {
18553
+ }
18554
+ }
18555
+ const idx = buf.lastIndexOf("\n");
18556
+ if (idx === -1) continue;
18557
+ const ready = buf.slice(0, idx);
18558
+ buf = buf.slice(idx + 1);
18559
+ for (const line of ready.split("\n")) {
18560
+ if (!line) continue;
18561
+ totalLines++;
18562
+ if (mode === "count") totalCount += parseRgCountLine(line);
18563
+ if (matches.length < limit) {
18564
+ matches.push(line);
18565
+ pendingBatch.push(line);
18566
+ batchSinceFlush++;
18567
+ }
18568
+ }
18569
+ if (batchSinceFlush >= FLUSH_AT) {
18570
+ yield {
18571
+ type: "partial_output",
18572
+ text: pendingBatch.join("\n"),
18573
+ data: { matches_so_far: matches.length }
18574
+ };
18575
+ pendingBatch = [];
18576
+ batchSinceFlush = 0;
18577
+ }
18578
+ }
18579
+ if (buf.trim()) {
18580
+ for (const line of buf.split("\n")) {
18581
+ if (!line) continue;
18582
+ totalLines++;
18583
+ if (mode === "count") totalCount += parseRgCountLine(line);
18584
+ if (matches.length < limit) {
18585
+ matches.push(line);
18586
+ pendingBatch.push(line);
18587
+ }
18218
18588
  }
18219
18589
  }
18220
- if (batchSinceFlush >= FLUSH_AT) {
18590
+ if (pendingBatch.length > 0) {
18221
18591
  yield {
18222
18592
  type: "partial_output",
18223
18593
  text: pendingBatch.join("\n"),
18224
18594
  data: { matches_so_far: matches.length }
18225
18595
  };
18226
- pendingBatch = [];
18227
- batchSinceFlush = 0;
18228
18596
  }
18229
- }
18230
- if (buf.trim()) {
18231
- for (const line of buf.split("\n")) {
18232
- if (!line) continue;
18233
- totalLines++;
18234
- if (mode === "count") totalCount += parseRgCountLine(line);
18235
- if (matches.length < limit) {
18236
- matches.push(line);
18237
- pendingBatch.push(line);
18238
- }
18239
- }
18240
- }
18241
- if (pendingBatch.length > 0) {
18597
+ if (errored) throw new Error("rg: spawn error");
18242
18598
  yield {
18243
- type: "partial_output",
18244
- text: pendingBatch.join("\n"),
18245
- data: { matches_so_far: matches.length }
18599
+ type: "final",
18600
+ output: {
18601
+ matches,
18602
+ count: mode === "count" ? totalCount : totalLines,
18603
+ truncated: totalLines > limit || bufOverflow,
18604
+ used: "rg"
18605
+ }
18246
18606
  };
18247
- }
18248
- if (errored) throw new Error("rg: spawn error");
18249
- yield {
18250
- type: "final",
18251
- output: {
18252
- matches,
18253
- count: mode === "count" ? totalCount : totalLines,
18254
- truncated: totalLines > limit || bufOverflow,
18255
- used: "rg"
18607
+ } finally {
18608
+ child.stdout?.off("data", onStdoutData);
18609
+ child.off("error", onError);
18610
+ child.off("close", onClose);
18611
+ child.stdout?.destroy();
18612
+ queue.length = 0;
18613
+ queuedChars = 0;
18614
+ waiter = void 0;
18615
+ if (!closed && child.exitCode === null) {
18616
+ try {
18617
+ child.kill("SIGTERM");
18618
+ } catch {
18619
+ }
18256
18620
  }
18257
- };
18621
+ }
18258
18622
  }
18259
18623
  function parseRgCountLine(line) {
18260
18624
  const idx = line.lastIndexOf(":");
@@ -18284,8 +18648,8 @@ async function runNative(input, base, mode, limit, signal) {
18284
18648
  if (globRe && !globRe.test(name) && !globRe.test(full)) return;
18285
18649
  if (globRe) globRe.lastIndex = 0;
18286
18650
  try {
18287
- const stat17 = await fs25.stat(full);
18288
- if (!stat17.isFile() || stat17.size > maxBytes || stopped || signal.aborted) return;
18651
+ const stat18 = await fs25.stat(full);
18652
+ if (!stat18.isFile() || stat18.size > maxBytes || stopped || signal.aborted) return;
18289
18653
  const file = await fs25.open(full, "r");
18290
18654
  try {
18291
18655
  let bytesReadTotal = 0;
@@ -18385,8 +18749,8 @@ async function runNative(input, base, mode, limit, signal) {
18385
18749
  files.push({ full, name: e.name });
18386
18750
  }
18387
18751
  }
18388
- await mapWithConcurrency(files, NATIVE_SCAN_CONCURRENCY, ({ full, name }) => scanFile(full, name));
18389
- await mapWithConcurrency(
18752
+ await mapWithConcurrency2(files, NATIVE_SCAN_CONCURRENCY, ({ full, name }) => scanFile(full, name));
18753
+ await mapWithConcurrency2(
18390
18754
  subdirs,
18391
18755
  Math.min(16, NATIVE_SCAN_CONCURRENCY),
18392
18756
  ({ full, rel }) => walk(full, rel)
@@ -18405,7 +18769,7 @@ async function runNative(input, base, mode, limit, signal) {
18405
18769
  init_spawn_stream();
18406
18770
  init_util();
18407
18771
  init_legacy_bridge();
18408
- import { join as join24 } from "node:path";
18772
+ import { join as join25 } from "node:path";
18409
18773
  import {
18410
18774
  detectEcosystem as detectPackageEcosystem,
18411
18775
  recordPackageAction
@@ -18416,6 +18780,10 @@ var installTool = {
18416
18780
  description: "Install, update or manage packages using the detected package manager (pnpm/npm/yarn). Strongly preferred over raw shell commands for dependency management because it is structured and safer.",
18417
18781
  usageHint: "ALWAYS USE THIS INSTEAD OF BASH FOR PACKAGE WORK:\n\n- Empty `packages` \u2192 normal `install` (respects lockfile).\n- Provide names \u2192 adds/updates specific packages.\n- `dry_run: true` for safe preview.\n- Set `save` appropriately.\nThis tool has proper capability declaration and is heavily recommended in the security posture of the project.",
18418
18782
  permission: "confirm",
18783
+ // WS-046: gives permission decisions something to key on.
18784
+ // What gets installed is the subject — installing a package runs its
18785
+ // lifecycle scripts, so `express` and `evil-pkg` must not be interchangeable.
18786
+ subjectKey: "packages",
18419
18787
  mutating: true,
18420
18788
  riskTier: "standard",
18421
18789
  icon: "package",
@@ -18585,10 +18953,10 @@ function resolveManifestPath(cwd, pkgManager) {
18585
18953
  case "pnpm":
18586
18954
  case "yarn":
18587
18955
  case "npm":
18588
- return join24(cwd, "package.json");
18956
+ return join25(cwd, "package.json");
18589
18957
  /* v8 ignore next 2 -- pkgManager is always pnpm/yarn/npm; the default is defensive. */
18590
18958
  default:
18591
- return join24(cwd, "package.json");
18959
+ return join25(cwd, "package.json");
18592
18960
  }
18593
18961
  }
18594
18962
 
@@ -18596,6 +18964,24 @@ function resolveManifestPath(cwd, pkgManager) {
18596
18964
  init_util();
18597
18965
  import * as fs26 from "node:fs/promises";
18598
18966
  import { deepMerge as deepMergeCore } from "@wrongstack/core/utils";
18967
+ var MAX_JSON_FILE_BYTES = 16 * 1024 * 1024;
18968
+ var MAX_JSON_FILE_BYTES_HUMAN = "16 MiB";
18969
+ var JsonFileTooLargeError = class extends Error {
18970
+ constructor(filePath, size) {
18971
+ super(
18972
+ `json: "${filePath}" is ${size} bytes \u2014 exceeds the ${MAX_JSON_FILE_BYTES_HUMAN} file-size limit. Extract the relevant portion into a smaller file first, or use a shell tool to query it directly.`
18973
+ );
18974
+ this.name = "JsonFileTooLargeError";
18975
+ }
18976
+ };
18977
+ async function readJsonFileBounded(filePath, ctx) {
18978
+ const resolved = await safeResolveReal(filePath, ctx);
18979
+ const stat18 = await fs26.stat(resolved);
18980
+ if (stat18.size > MAX_JSON_FILE_BYTES) {
18981
+ throw new JsonFileTooLargeError(filePath, stat18.size);
18982
+ }
18983
+ return fs26.readFile(resolved, "utf8");
18984
+ }
18599
18985
  var jsonTool = {
18600
18986
  name: "json",
18601
18987
  category: "Data",
@@ -18603,6 +18989,7 @@ var jsonTool = {
18603
18989
  usageHint: 'VERY USEFUL FOR DATA INSPECTION:\n\n- `action: "parse"` (default): read/pretty-print/convert JSON, JSON5, or YAML from `file` or `data`.\n- `action: "query"`: JMESPath-like query (`a.b[0].c`, `items[*].name`, filters, functions).\n- `action: "validate"`: validate data against a JSON Schema (`schema` param).\n- `action: "transform"`: chain multiple JMESPath transforms (`transforms` param).\n- `action: "merge"`: deep merge `base` and `patch` objects (`conflictResolution` param).\nPrefer this over raw `read` + manual parsing when dealing with configuration or data files.',
18604
18990
  permission: "auto",
18605
18991
  mutating: false,
18992
+ maxOutputBytes: 262144,
18606
18993
  timeoutMs: 5e3,
18607
18994
  capabilities: ["fs.read"],
18608
18995
  icon: "json",
@@ -18669,9 +19056,15 @@ async function executeParse(input, ctx) {
18669
19056
  let raw;
18670
19057
  if (input.file) {
18671
19058
  try {
18672
- raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
18673
- } catch {
18674
- return { data: null, formatted: "", type: "unknown", action: "parse", error: "Could not read file" };
19059
+ raw = await readJsonFileBounded(input.file, ctx);
19060
+ } catch (error) {
19061
+ return {
19062
+ data: null,
19063
+ formatted: "",
19064
+ type: "unknown",
19065
+ action: "parse",
19066
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read file"
19067
+ };
18675
19068
  }
18676
19069
  } else if (input.data) {
18677
19070
  raw = input.data;
@@ -18727,10 +19120,16 @@ async function executeQuery(input, ctx) {
18727
19120
  let parsed;
18728
19121
  if (input.file) {
18729
19122
  try {
18730
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19123
+ const raw = await readJsonFileBounded(input.file, ctx);
18731
19124
  parsed = JSON.parse(raw);
18732
- } catch {
18733
- return { data: null, formatted: "", type: "unknown", action: "query", error: "Could not read/parse file" };
19125
+ } catch (error) {
19126
+ return {
19127
+ data: null,
19128
+ formatted: "",
19129
+ type: "unknown",
19130
+ action: "query",
19131
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19132
+ };
18734
19133
  }
18735
19134
  } else if (input.data) {
18736
19135
  try {
@@ -18769,10 +19168,16 @@ async function executeValidate(input, ctx) {
18769
19168
  let parsed;
18770
19169
  if (input.file) {
18771
19170
  try {
18772
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19171
+ const raw = await readJsonFileBounded(input.file, ctx);
18773
19172
  parsed = JSON.parse(raw);
18774
- } catch {
18775
- return { data: null, formatted: "", type: "unknown", action: "validate", error: "Could not read/parse file" };
19173
+ } catch (error) {
19174
+ return {
19175
+ data: null,
19176
+ formatted: "",
19177
+ type: "unknown",
19178
+ action: "validate",
19179
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19180
+ };
18776
19181
  }
18777
19182
  } else if (input.data) {
18778
19183
  try {
@@ -18811,10 +19216,16 @@ async function executeTransform(input, ctx) {
18811
19216
  let parsed;
18812
19217
  if (input.file) {
18813
19218
  try {
18814
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19219
+ const raw = await readJsonFileBounded(input.file, ctx);
18815
19220
  parsed = JSON.parse(raw);
18816
- } catch {
18817
- return { data: null, formatted: "", type: "unknown", action: "transform", error: "Could not read/parse file" };
19221
+ } catch (error) {
19222
+ return {
19223
+ data: null,
19224
+ formatted: "",
19225
+ type: "unknown",
19226
+ action: "transform",
19227
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19228
+ };
18818
19229
  }
18819
19230
  } else if (input.data) {
18820
19231
  try {
@@ -19418,6 +19829,10 @@ async function handleKanbanDetailAction(projectRoot, input) {
19418
19829
  }
19419
19830
 
19420
19831
  // src/kanban-board-inputs.ts
19832
+ function agentSettableGate(enforcement) {
19833
+ if (enforcement === void 0 || enforcement === "off") return {};
19834
+ return { completionGate: { enforcement } };
19835
+ }
19421
19836
  function boardCreateInput(input, title) {
19422
19837
  return {
19423
19838
  title,
@@ -19430,7 +19845,7 @@ function boardCreateInput(input, title) {
19430
19845
  decomposition: input.atomicityDecomposition ?? "propose"
19431
19846
  }
19432
19847
  } : {},
19433
- ...input.gateEnforcement !== void 0 ? { completionGate: { enforcement: input.gateEnforcement } } : {}
19848
+ ...agentSettableGate(input.gateEnforcement)
19434
19849
  };
19435
19850
  }
19436
19851
  function boardUpdatePatch(input) {
@@ -19444,7 +19859,7 @@ function boardUpdatePatch(input) {
19444
19859
  decomposition: input.atomicityDecomposition ?? "propose"
19445
19860
  }
19446
19861
  } : {},
19447
- ...input.gateEnforcement !== void 0 ? { completionGate: { enforcement: input.gateEnforcement } } : {}
19862
+ ...agentSettableGate(input.gateEnforcement)
19448
19863
  };
19449
19864
  }
19450
19865
  function duplicateBoardOptions(input) {
@@ -19459,6 +19874,7 @@ function duplicateBoardOptions(input) {
19459
19874
 
19460
19875
  // src/kanban-task-inputs.ts
19461
19876
  import { randomUUID } from "node:crypto";
19877
+ import { clampSubagentCapabilities } from "@wrongstack/core/security";
19462
19878
  function taskInput(input) {
19463
19879
  const assignment = hasAssignmentInput(input) ? assignmentForTaskCreate(input) : void 0;
19464
19880
  return {
@@ -19556,6 +19972,10 @@ function taskPatch(input) {
19556
19972
  ...input.actualHours !== void 0 ? { actualHours: input.actualHours } : {}
19557
19973
  };
19558
19974
  }
19975
+ function clampRequestedCapabilities(requested) {
19976
+ if (requested === void 0) return void 0;
19977
+ return clampSubagentCapabilities(requested).granted;
19978
+ }
19559
19979
  function assignmentInput(input) {
19560
19980
  return {
19561
19981
  agentId: input.agentId,
@@ -19566,7 +19986,7 @@ function assignmentInput(input) {
19566
19986
  fallbackProfile: input.fallbackProfile,
19567
19987
  fallbackModels: input.fallbackModels,
19568
19988
  tools: input.tools,
19569
- allowedCapabilities: input.allowedCapabilities,
19989
+ allowedCapabilities: clampRequestedCapabilities(input.allowedCapabilities),
19570
19990
  assignee: input.assignee,
19571
19991
  leaseId: input.leaseId,
19572
19992
  claimedAt: input.claimedAt,
@@ -19593,7 +20013,7 @@ function assignmentForTaskCreate(input) {
19593
20013
  ...input.fallbackProfile !== void 0 ? { fallbackProfile: input.fallbackProfile } : {},
19594
20014
  ...input.fallbackModels !== void 0 ? { fallbackModels: input.fallbackModels } : {},
19595
20015
  ...input.tools !== void 0 ? { tools: input.tools } : {},
19596
- ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: input.allowedCapabilities } : {},
20016
+ ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: clampRequestedCapabilities(input.allowedCapabilities) } : {},
19597
20017
  ...input.leaseId !== void 0 ? { leaseId: input.leaseId } : {},
19598
20018
  ...input.claimedAt !== void 0 ? { claimedAt: input.claimedAt } : {},
19599
20019
  ...input.heartbeatAt !== void 0 ? { heartbeatAt: input.heartbeatAt } : {},
@@ -19804,7 +20224,13 @@ var KANBAN_INPUT_SCHEMA = {
19804
20224
  moveTasksToColumnId: { type: "string" },
19805
20225
  atomicityMode: { type: "string", enum: ["off", "assess", "enforce"] },
19806
20226
  atomicityDecomposition: { type: "string", enum: ["auto", "propose"] },
19807
- gateEnforcement: { type: "string", enum: ["strict", "soft", "off"] },
20227
+ gateEnforcement: {
20228
+ type: "string",
20229
+ // WS-023: `'off'` is deliberately absent. The agent whose work this gate
20230
+ // checks must not be able to switch it off; it may only tighten. Turning
20231
+ // a gate off stays a human decision, made through board config.
20232
+ enum: ["strict", "soft"]
20233
+ },
19808
20234
  subtasks: {
19809
20235
  type: "array",
19810
20236
  minItems: 2,
@@ -19911,7 +20337,9 @@ async function ensureSessionKanbanBoard(projectRoot, sessionId) {
19911
20337
  description: "Live session work: todos, tasks, and plan items.",
19912
20338
  tags: sessionBoardTags(sessionId),
19913
20339
  columns: SESSION_KANBAN_COLUMNS,
19914
- generatedBy: `session-kanban:${sessionId}`
20340
+ generatedBy: `session-kanban:${sessionId}`,
20341
+ kind: "session_mirror",
20342
+ retention: { mode: "archive_after_ttl", ttlMs: 7 * 24 * 60 * 60 * 1e3 }
19915
20343
  });
19916
20344
  }
19917
20345
  if (!sameColumns(board.columns) || !board.tags?.includes(SESSION_BOARD_TAG)) {
@@ -20945,11 +21373,11 @@ var lintTool = {
20945
21373
  }
20946
21374
  };
20947
21375
  async function detectLinter(cwd) {
20948
- const { stat: stat17 } = await import("node:fs/promises");
21376
+ const { stat: stat18 } = await import("node:fs/promises");
20949
21377
  const checks = ["biome.json", ".eslintrc.json", "tslint.json", ".eslintrc.js", "tsconfig.json"];
20950
21378
  for (const f of checks) {
20951
21379
  try {
20952
- await stat17(`${cwd}/${f}`);
21380
+ await stat18(`${cwd}/${f}`);
20953
21381
  if (f.includes("biome")) return "biome";
20954
21382
  if (f.includes("eslint")) return "eslint";
20955
21383
  if (f.includes("tslint")) return "tslint";
@@ -21193,6 +21621,9 @@ var outdatedTool = {
21193
21621
  // tool.confirm_needed flow on every invocation. M-1 originally
21194
21622
  // fixed four sibling tools (mcp_control, shellcheck, shellcheck (scan mode),
21195
21623
  // search) but missed this one; applying the same contract here.
21624
+ // WS-046: gives permission decisions something to key on.
21625
+ // The working directory scanned.
21626
+ subjectKey: "cwd",
21196
21627
  mutating: true,
21197
21628
  // Capability is outbound network — the tool only hits the package
21198
21629
  // registry over HTTP, never touches the filesystem or runs shell.
@@ -21374,6 +21805,10 @@ var patchTool = {
21374
21805
  useInstead: ["edit"]
21375
21806
  },
21376
21807
  permission: "confirm",
21808
+ // WS-046: gives permission decisions something to key on.
21809
+ // The target directory. The patch body itself is unstable (every diff
21810
+ // differs), so it could never re-match its own stored rule.
21811
+ subjectKey: "directory",
21377
21812
  mutating: true,
21378
21813
  capabilities: ["fs.write"],
21379
21814
  icon: "edit",
@@ -21440,8 +21875,8 @@ var patchTool = {
21440
21875
  const before = beforeContents.get(target) ?? null;
21441
21876
  const after = await readTextForTracking(target);
21442
21877
  if (after === null || after === before) continue;
21443
- const stat17 = await fs27.stat(target).catch(() => null);
21444
- if (stat17) ctx.recordRead?.(target, stat17.mtimeMs, "write", sha256hex(after));
21878
+ const stat18 = await fs27.stat(target).catch(() => null);
21879
+ if (stat18) ctx.recordRead?.(target, stat18.mtimeMs, "write", sha256hex(after));
21445
21880
  ctx.session?.recordFileChange?.({
21446
21881
  path: target,
21447
21882
  action: before === null ? "created" : "modified",
@@ -21466,8 +21901,8 @@ var patchTool = {
21466
21901
  var MAX_TRACKING_BYTES = 5 * 1024 * 1024;
21467
21902
  async function readTextForTracking(absPath) {
21468
21903
  try {
21469
- const stat17 = await fs27.stat(absPath);
21470
- if (!stat17.isFile() || stat17.size > MAX_TRACKING_BYTES) return null;
21904
+ const stat18 = await fs27.stat(absPath);
21905
+ if (!stat18.isFile() || stat18.size > MAX_TRACKING_BYTES) return null;
21471
21906
  const buf = await fs27.readFile(absPath);
21472
21907
  if (buf.includes(0)) return null;
21473
21908
  return buf.toString("utf8");
@@ -21497,7 +21932,13 @@ function runPatch(args, cwd, signal) {
21497
21932
  let stdout = "";
21498
21933
  let stderr = "";
21499
21934
  const env = { ...buildChildEnv8(), LANG: "C", LC_ALL: "C" };
21500
- const child = spawn14("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
21935
+ const child = spawn14("patch", args, {
21936
+ cwd,
21937
+ signal,
21938
+ env,
21939
+ stdio: ["pipe", "pipe", "pipe"],
21940
+ windowsHide: true
21941
+ });
21501
21942
  child.stdout?.on("data", (c) => {
21502
21943
  stdout += c.toString();
21503
21944
  });
@@ -21518,6 +21959,7 @@ function extractPatchedFiles(output) {
21518
21959
  }
21519
21960
 
21520
21961
  // src/plan.ts
21962
+ import { randomUUID as randomUUID2 } from "node:crypto";
21521
21963
  import {
21522
21964
  addPlanItem,
21523
21965
  clearPlan,
@@ -21525,20 +21967,20 @@ import {
21525
21967
  formatPlan,
21526
21968
  getPlanTemplate,
21527
21969
  mutatePlan as mutatePlan2,
21970
+ mutateTasks as mutateTasks2,
21528
21971
  removePlanItem,
21529
21972
  setPlanItemStatus
21530
21973
  } from "@wrongstack/core/storage";
21531
- import {
21532
- mutateTasks as mutateTasks2
21533
- } from "@wrongstack/core/storage";
21534
21974
  import { formatTaskList } from "@wrongstack/core/utils";
21535
- import { randomUUID as randomUUID2 } from "node:crypto";
21536
21975
  var planTool = {
21537
21976
  name: "plan",
21538
21977
  category: "Session",
21539
21978
  description: 'Manage a session-persistent strategic plan. The plan is written to disk and survives conversation resumptions within the same session, but is isolated to this session \u2014 other sessions have their own separate plans. Unlike todos (which are per-turn and lost on restart), a plan tracks high-level progress across multiple turns. Use this to outline big-picture work, then promote concrete items into the todo list when ready to execute. By default plans are isolated to this session; use `scope: "project"` to store the plan in a shared project-level file visible to all sessions.',
21540
21979
  usageHint: 'RECOMMENDED FOR COMPLEX, MULTI-PHASE WORK:\n\n- Start by creating a high-level plan with `action: "add"` or using templates (`template_use`).\n- Use `promote` to turn a plan item into actionable todos.\n- Use `taskify` to convert a plan item into a structured task (with type/priority/deps).\n- Keep plans at the "why and what" level, and todos at the "how and next step" level.\n- Common templates: "new-feature", "bug-fix", "refactor", "release", "security-audit".\n\nThis tool is excellent for maintaining long-term direction across many turns within a session. Plans survive resume but are not shared across separate sessions.\nUse `scope: "project"` to use a shared project-level plan file.',
21541
21980
  permission: "confirm",
21981
+ // WS-046: gives permission decisions something to key on.
21982
+ // The action performed; plan has no single file or path subject.
21983
+ subjectKey: "action",
21542
21984
  mutating: true,
21543
21985
  capabilities: ["fs.write"],
21544
21986
  icon: "plan",
@@ -21595,7 +22037,10 @@ var planTool = {
21595
22037
  let planPath;
21596
22038
  if (input.scope === "project") {
21597
22039
  if (typeof sessionPlanPath === "string") {
21598
- const lastSep = Math.max(sessionPlanPath.lastIndexOf("/"), sessionPlanPath.lastIndexOf("\\"));
22040
+ const lastSep = Math.max(
22041
+ sessionPlanPath.lastIndexOf("/"),
22042
+ sessionPlanPath.lastIndexOf("\\")
22043
+ );
21599
22044
  planPath = lastSep >= 0 ? sessionPlanPath.slice(0, lastSep + 1) + "backlog.plan.json" : "backlog.plan.json";
21600
22045
  }
21601
22046
  } else {
@@ -21632,7 +22077,11 @@ var planTool = {
21632
22077
  case "start":
21633
22078
  case "done": {
21634
22079
  if (!input.target) {
21635
- early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
22080
+ early = mkResult(
22081
+ p,
22082
+ false,
22083
+ `${input.action} requires \`target\` (id|index|substring).`
22084
+ );
21636
22085
  return p;
21637
22086
  }
21638
22087
  const next = setPlanItemStatus(
@@ -21660,7 +22109,11 @@ var planTool = {
21660
22109
  }
21661
22110
  case "promote": {
21662
22111
  if (!input.target) {
21663
- early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
22112
+ early = mkResult(
22113
+ p,
22114
+ false,
22115
+ `${input.action} requires \`target\` (id|index|substring).`
22116
+ );
21664
22117
  return p;
21665
22118
  }
21666
22119
  const derived = deriveTodosFromPlanItem(p, input.target, input.subtasks);
@@ -21703,7 +22156,11 @@ var planTool = {
21703
22156
  return clearPlan(p);
21704
22157
  case "taskify": {
21705
22158
  if (!input.target) {
21706
- early = mkResult(p, false, "taskify requires `target` (plan item id|index|substring).");
22159
+ early = mkResult(
22160
+ p,
22161
+ false,
22162
+ "taskify requires `target` (plan item id|index|substring)."
22163
+ );
21707
22164
  return p;
21708
22165
  }
21709
22166
  let itemIdx = -1;
@@ -21776,7 +22233,11 @@ var planTool = {
21776
22233
  ${formatTaskList(taskFile.tasks)}`
21777
22234
  );
21778
22235
  } catch (err) {
21779
- return mkResult(plan, false, `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`);
22236
+ return mkResult(
22237
+ plan,
22238
+ false,
22239
+ `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`
22240
+ );
21780
22241
  }
21781
22242
  }
21782
22243
  return mkResult(plan, true, `Plan ${input.action} ok.`);
@@ -21853,9 +22314,9 @@ var readTool = {
21853
22314
  }
21854
22315
  const absPath = await safeResolveReal(input.path, ctx);
21855
22316
  const shouldIncludeSymbols = input.includeSymbols === true || input.includeSymbols !== false && ctx.meta[ADVANCED_MODE_META_KEY] === true;
21856
- let stat17;
22317
+ let stat18;
21857
22318
  try {
21858
- stat17 = await fs28.stat(absPath);
22319
+ stat18 = await fs28.stat(absPath);
21859
22320
  } catch (err) {
21860
22321
  const code = err.code;
21861
22322
  if (code === "ENOENT") {
@@ -21874,7 +22335,7 @@ var readTool = {
21874
22335
  cause: err
21875
22336
  });
21876
22337
  }
21877
- if (!stat17.isFile()) {
22338
+ if (!stat18.isFile()) {
21878
22339
  throw new FsError({
21879
22340
  message: `read: "${input.path}" is not a regular file`,
21880
22341
  code: "FS_READ_FAILED",
@@ -21882,23 +22343,23 @@ var readTool = {
21882
22343
  context: { reason: "not-a-regular-file" }
21883
22344
  });
21884
22345
  }
21885
- if (stat17.size > MAX_BYTES2) {
22346
+ if (stat18.size > MAX_BYTES2) {
21886
22347
  throw new FsError({
21887
- message: `read: file too large (${stat17.size} bytes, limit ${MAX_BYTES2})`,
22348
+ message: `read: file too large (${stat18.size} bytes, limit ${MAX_BYTES2})`,
21888
22349
  code: "FS_READ_FAILED",
21889
22350
  path: absPath,
21890
- context: { size: stat17.size, limit: MAX_BYTES2, reason: "too-large" }
22351
+ context: { size: stat18.size, limit: MAX_BYTES2, reason: "too-large" }
21891
22352
  });
21892
22353
  }
21893
22354
  const offset = Math.max(1, input.offset ?? 1);
21894
22355
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
21895
22356
  const prior = getReadRangeRecord(ctx, absPath);
21896
22357
  const requestedEnd = prior ? Math.min(offset + limit - 1, prior.totalLines) : offset + limit - 1;
21897
- if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat17.mtimeMs, offset, requestedEnd)) {
21898
- ctx.recordRead(absPath, stat17.mtimeMs);
22358
+ if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat18.mtimeMs, offset, requestedEnd)) {
22359
+ ctx.recordRead(absPath, stat18.mtimeMs);
21899
22360
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
21900
22361
  return {
21901
- text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat17.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
22362
+ text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat18.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
21902
22363
  total_lines: prior.totalLines,
21903
22364
  encoding: "utf8",
21904
22365
  truncated: requestedEnd < prior.totalLines,
@@ -21916,11 +22377,11 @@ var readTool = {
21916
22377
  const allLines = text.split(/\r\n|\r|\n/);
21917
22378
  const total = allLines.length;
21918
22379
  if (input.mode === "summary") {
21919
- ctx.recordRead(absPath, stat17.mtimeMs, "user", contentHash);
21920
- rememberReadRange(ctx, absPath, stat17.mtimeMs, total, 1, Math.min(total, 200));
22380
+ ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22381
+ rememberReadRange(ctx, absPath, stat18.mtimeMs, total, 1, Math.min(total, 200));
21921
22382
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
21922
22383
  return {
21923
- text: summarizeFile(input.path, stat17.size, allLines),
22384
+ text: summarizeFile(input.path, stat18.size, allLines),
21924
22385
  total_lines: total,
21925
22386
  encoding: "utf8",
21926
22387
  truncated: total > 200,
@@ -21932,8 +22393,8 @@ var readTool = {
21932
22393
  };
21933
22394
  }
21934
22395
  if (limit === 0) {
21935
- ctx.recordRead(absPath, stat17.mtimeMs, "user", contentHash);
21936
- rememberReadRange(ctx, absPath, stat17.mtimeMs, total, 1, 0);
22396
+ ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22397
+ rememberReadRange(ctx, absPath, stat18.mtimeMs, total, 1, 0);
21937
22398
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
21938
22399
  return {
21939
22400
  text: "",
@@ -21945,8 +22406,8 @@ var readTool = {
21945
22406
  };
21946
22407
  }
21947
22408
  if (offset > total) {
21948
- ctx.recordRead(absPath, stat17.mtimeMs, "user", contentHash);
21949
- rememberReadRange(ctx, absPath, stat17.mtimeMs, total, total + 1, total + 1);
22409
+ ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22410
+ rememberReadRange(ctx, absPath, stat18.mtimeMs, total, total + 1, total + 1);
21950
22411
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
21951
22412
  return {
21952
22413
  text: `[offset ${offset} is past end of file "${input.path}" \u2014 file has ${total} line(s). Do not retry this offset.]`,
@@ -21961,8 +22422,8 @@ var readTool = {
21961
22422
  const truncated = offset - 1 + slice.length < total;
21962
22423
  const width = String(offset + slice.length - 1).length;
21963
22424
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
21964
- ctx.recordRead(absPath, stat17.mtimeMs, "user", contentHash);
21965
- rememberReadRange(ctx, absPath, stat17.mtimeMs, total, offset, offset + slice.length - 1);
22425
+ ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22426
+ rememberReadRange(ctx, absPath, stat18.mtimeMs, total, offset, offset + slice.length - 1);
21966
22427
  const symResult = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
21967
22428
  return {
21968
22429
  text: numbered,
@@ -22068,20 +22529,20 @@ ${interesting.join("\n")}` : "symbols/imports: (none detected)"
22068
22529
  }
22069
22530
 
22070
22531
  // src/replace.ts
22071
- import { expectDefined as expectDefined8 } from "@wrongstack/core/utils";
22072
22532
  import { spawn as spawn15 } from "node:child_process";
22073
22533
  import * as fs29 from "node:fs/promises";
22074
22534
  import * as path33 from "node:path";
22535
+ import { ToolValidationError as ToolValidationError6 } from "@wrongstack/core/types";
22075
22536
  import {
22076
22537
  atomicWrite as atomicWrite3,
22077
22538
  buildChildEnv as buildChildEnv9,
22078
22539
  compileGlob as compileGlob4,
22079
22540
  detectNewlineStyle as detectNewlineStyle2,
22541
+ expectDefined as expectDefined8,
22080
22542
  normalizeToLf as normalizeToLf2,
22081
22543
  toStyle as toStyle2,
22082
22544
  unifiedDiff as unifiedDiff2
22083
22545
  } from "@wrongstack/core/utils";
22084
- import { ToolValidationError as ToolValidationError6 } from "@wrongstack/core/types";
22085
22546
  init_util();
22086
22547
  var DEFAULT_IGNORE4 = ["node_modules", ".git", "dist", "build", ".next", "coverage"];
22087
22548
  var replaceTool = {
@@ -22090,6 +22551,10 @@ var replaceTool = {
22090
22551
  description: "Perform a search-and-replace across multiple files using a regex pattern. This is a powerful bulk transformation tool. Dry-run is ON by default \u2014 set `dry_run: false` to apply changes.",
22091
22552
  usageHint: "DANGEROUS IF USED CARELESSLY \u2014 review the diff output carefully.\n\nRecommended workflow:\n1. Run without `dry_run: false` first to see exactly what would change (dry-run is the default).\n2. Review the diff output, then re-run with `dry_run: false` to apply.\n3. Use a specific enough `pattern` (and `glob` / `files`) to avoid accidental broad changes.\n4. `replace_all` controls whether only the first match per file or all matches are replaced.\nThis tool is excellent for large-scale refactors (renaming, import updates, etc.) but must be used with caution.",
22092
22553
  permission: "confirm",
22554
+ // WS-046: gives permission decisions something to key on.
22555
+ // The file scope being rewritten, not the pattern: a trust rule should say
22556
+ // where a bulk replace may run.
22557
+ subjectKey: "files",
22093
22558
  mutating: true,
22094
22559
  capabilities: ["fs.write"],
22095
22560
  icon: "edit",
@@ -22162,8 +22627,8 @@ var replaceTool = {
22162
22627
  }
22163
22628
  const rel = path33.relative(realRoot, realPath);
22164
22629
  if (rel.startsWith("..") || path33.isAbsolute(rel)) continue;
22165
- const stat17 = await fs29.stat(realPath).catch(() => null);
22166
- if (!stat17?.isFile()) continue;
22630
+ const stat18 = await fs29.stat(realPath).catch(() => null);
22631
+ if (!stat18?.isFile()) continue;
22167
22632
  let content;
22168
22633
  try {
22169
22634
  const buf = await fs29.readFile(realPath);
@@ -22188,7 +22653,7 @@ var replaceTool = {
22188
22653
  totalReplacements += count;
22189
22654
  if (!dryRun) {
22190
22655
  const newContent = toStyle2(newContentLf, style);
22191
- await atomicWrite3(realPath, newContent, { mode: stat17.mode & 511 });
22656
+ await atomicWrite3(realPath, newContent, { mode: stat18.mode & 511 });
22192
22657
  const written = await fs29.stat(realPath).catch(() => null);
22193
22658
  if (written) {
22194
22659
  ctx.recordRead?.(realPath, written.mtimeMs, "write", sha256hex(newContent));
@@ -22228,8 +22693,8 @@ async function resolveFiles2(filesInput, ctx, extraGlob) {
22228
22693
  const resolved = [];
22229
22694
  for (const p of parts) {
22230
22695
  const absPath = safeResolve(p, ctx);
22231
- const stat17 = await fs29.stat(absPath).catch(() => null);
22232
- if (stat17?.isFile()) {
22696
+ const stat18 = await fs29.stat(absPath).catch(() => null);
22697
+ if (stat18?.isFile()) {
22233
22698
  resolved.push(absPath);
22234
22699
  }
22235
22700
  }
@@ -22249,7 +22714,11 @@ async function globFiles(pattern, base, extraGlob) {
22249
22714
  function checkRg() {
22250
22715
  return new Promise((resolve16) => {
22251
22716
  try {
22252
- const p = spawn15("rg", ["--version"], { env: buildChildEnv9(), stdio: "ignore", windowsHide: true });
22717
+ const p = spawn15("rg", ["--version"], {
22718
+ env: buildChildEnv9(),
22719
+ stdio: "ignore",
22720
+ windowsHide: true
22721
+ });
22253
22722
  p.on("error", () => resolve16(false));
22254
22723
  p.on("close", (code) => resolve16(code === 0));
22255
22724
  } catch {
@@ -22300,8 +22769,8 @@ async function globNative(pattern, base, extraGlob) {
22300
22769
  if (DEFAULT_IGNORE4.includes(e.name)) continue;
22301
22770
  const full = path33.join(dir, e.name);
22302
22771
  try {
22303
- const stat17 = await fs29.lstat(full);
22304
- if (stat17.isSymbolicLink()) continue;
22772
+ const stat18 = await fs29.lstat(full);
22773
+ if (stat18.isSymbolicLink()) continue;
22305
22774
  } catch {
22306
22775
  continue;
22307
22776
  }
@@ -22425,6 +22894,7 @@ var scaffoldTool = {
22425
22894
  usageHint: "PREFERRED FOR SCAFFOLDING:\n\n- Use built-in templates when they match your needs (e.g. react-component, npm-package).\n- Supports `dry_run` so you can preview exactly what will be created.\n- Has the powerful `fs.write.outside-project` capability \u2014 review paths carefully.\nMuch cleaner and safer than manually writing multiple files.",
22426
22895
  permission: "confirm",
22427
22896
  mutating: true,
22897
+ subjectKey: "name",
22428
22898
  capabilities: ["fs.write.outside-project", "fs.write"],
22429
22899
  icon: "scaffold",
22430
22900
  timeoutMs: 3e4,
@@ -22499,15 +22969,17 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
22499
22969
  output: dryRun ? `Would create ${filesCreated} files: ${files.join(", ")}` : `Created ${filesCreated} files: ${files.join(", ")}`
22500
22970
  };
22501
22971
  }
22972
+ function escapeRegexLiteral(value) {
22973
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22974
+ }
22502
22975
  function substituteVars(content, name, vars) {
22976
+ const kebab = name.toLowerCase().replace(/\s+/g, "-");
22977
+ const pascal = name.replace(/(?:^|[-_\s]+)([a-z])/g, (_, c) => c.toUpperCase());
22503
22978
  let result = content;
22504
- result = result.replace(/\{\{name\}\}/g, name.toLowerCase().replace(/\s+/g, "-"));
22505
- result = result.replace(
22506
- /\{\{Name\}\}/g,
22507
- name.replace(/(?:^|[-_\s]+)([a-z])/g, (_, c) => c.toUpperCase())
22508
- );
22979
+ result = result.replace(/\{\{name\}\}/g, () => kebab);
22980
+ result = result.replace(/\{\{Name\}\}/g, () => pascal);
22509
22981
  for (const [k, v] of Object.entries(vars)) {
22510
- result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), v);
22982
+ result = result.replace(new RegExp(`\\{\\{${escapeRegexLiteral(k)}\\}\\}`, "g"), () => v);
22511
22983
  }
22512
22984
  return result;
22513
22985
  }
@@ -23443,11 +23915,11 @@ var testTool = {
23443
23915
  }
23444
23916
  };
23445
23917
  async function detectRunner(cwd) {
23446
- const { stat: stat17 } = await import("node:fs/promises");
23918
+ const { stat: stat18 } = await import("node:fs/promises");
23447
23919
  const candidates = ["vitest.config.ts", "jest.config.js", ".mocharc.json"];
23448
23920
  for (const f of candidates) {
23449
23921
  try {
23450
- await stat17(path35.join(cwd, f));
23922
+ await stat18(path35.join(cwd, f));
23451
23923
  if (f.includes("vitest")) return "vitest";
23452
23924
  if (f.includes("jest")) return "jest";
23453
23925
  if (f.includes("mocha")) return "mocha";
@@ -23844,12 +24316,19 @@ var toolSearchTool = {
23844
24316
  };
23845
24317
 
23846
24318
  // src/tool-use.ts
24319
+ import {
24320
+ GOVERNED_TOOL_EXECUTOR_META_KEY as GOVERNED_TOOL_EXECUTOR_META_KEY2
24321
+ } from "@wrongstack/core/types";
23847
24322
  var toolUseTool = {
23848
24323
  name: "tool_use",
23849
24324
  category: "Meta",
23850
24325
  description: "Directly execute any registered tool by its exact name, bypassing normal discovery. This is a powerful meta-tool intended for cases where the agent has a clear plan and knows precisely which tool to invoke.",
23851
24326
  usageHint: "ADVANCED META TOOL \u2014 USE WITH CARE:\n\n- Only use when you are certain of the exact tool name and its expected input shape.\n- Prefer using the normal tool calling mechanism when possible.\n- Very useful in batch-tool-use or when orchestrating complex workflows programmatically.\n- The call still goes through full permission checks and capability validation.",
23852
24327
  permission: "confirm",
24328
+ // WS-046: gives permission decisions something to key on.
24329
+ // The tool being invoked through this indirection — the whole point of a
24330
+ // permission decision here is WHICH tool is being reached.
24331
+ subjectKey: "tool",
23853
24332
  mutating: true,
23854
24333
  timeoutMs: 6e4,
23855
24334
  capabilities: ["tool.mutate.any"],
@@ -23868,7 +24347,7 @@ var toolUseTool = {
23868
24347
  },
23869
24348
  required: ["tool"]
23870
24349
  },
23871
- async execute(input, ctx, opts) {
24350
+ async execute(input, ctx) {
23872
24351
  const start = Date.now();
23873
24352
  if (!input?.tool) {
23874
24353
  return {
@@ -23887,6 +24366,14 @@ var toolUseTool = {
23887
24366
  executionMs: Date.now() - start
23888
24367
  };
23889
24368
  }
24369
+ if (tool.name === toolUseTool.name) {
24370
+ return {
24371
+ tool: input.tool,
24372
+ success: false,
24373
+ error: "tool_use: recursive meta-tool execution is not allowed",
24374
+ executionMs: Date.now() - start
24375
+ };
24376
+ }
23890
24377
  if (tool.permission === "deny") {
23891
24378
  return {
23892
24379
  tool: input.tool,
@@ -23895,12 +24382,21 @@ var toolUseTool = {
23895
24382
  executionMs: Date.now() - start
23896
24383
  };
23897
24384
  }
24385
+ const governedExecute = ctx.meta[GOVERNED_TOOL_EXECUTOR_META_KEY2];
24386
+ if (typeof governedExecute !== "function") {
24387
+ return {
24388
+ tool: input.tool,
24389
+ success: false,
24390
+ error: "tool_use: governed nested execution is unavailable; call the tool directly",
24391
+ executionMs: Date.now() - start
24392
+ };
24393
+ }
23898
24394
  try {
23899
- const result = await tool.execute(input.input, ctx, opts);
24395
+ const result = await governedExecute(input.tool, input.input ?? {});
23900
24396
  return {
23901
24397
  tool: input.tool,
23902
- success: true,
23903
- result,
24398
+ success: result.success,
24399
+ ...result.success ? { result: result.result } : { error: result.error ?? "nested tool failed" },
23904
24400
  executionMs: Date.now() - start
23905
24401
  };
23906
24402
  } catch (e) {
@@ -23916,10 +24412,12 @@ var toolUseTool = {
23916
24412
 
23917
24413
  // src/tree.ts
23918
24414
  init_util();
23919
- import { DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS4, expectDefined as expectDefined10 } from "@wrongstack/core/utils";
23920
24415
  import * as fs32 from "node:fs/promises";
23921
24416
  import * as path36 from "node:path";
24417
+ import { DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS4, expectDefined as expectDefined10 } from "@wrongstack/core/utils";
23922
24418
  var DEFAULT_IGNORE5 = [...DEFAULT_WALK_IGNORE_DIRS4, ".wrongstack", ".ssh", ".gnupg", ".aws"];
24419
+ var DEFAULT_MAX_ENTRIES2 = 5e3;
24420
+ var MAX_TREE_OUTPUT_BYTES = 256 * 1024;
23923
24421
  var treeTool = {
23924
24422
  name: "tree",
23925
24423
  category: "Filesystem",
@@ -23963,6 +24461,12 @@ var treeTool = {
23963
24461
  show_hidden: {
23964
24462
  type: "boolean",
23965
24463
  description: "Show hidden files starting with . (default: false)"
24464
+ },
24465
+ max_entries: {
24466
+ type: "integer",
24467
+ description: `Maximum entries retained in the rendered tree (default ${DEFAULT_MAX_ENTRIES2}).`,
24468
+ minimum: 1,
24469
+ maximum: 5e4
23966
24470
  }
23967
24471
  }
23968
24472
  },
@@ -23976,7 +24480,7 @@ var treeTool = {
23976
24480
  if (!final) throw new Error("tree: stream ended without final event");
23977
24481
  return final;
23978
24482
  },
23979
- async *executeStream(input, ctx) {
24483
+ async *executeStream(input, ctx, opts) {
23980
24484
  const basePath = input.path ? safeResolve(input.path, ctx) : ctx.cwd;
23981
24485
  const maxDepth = input.depth ?? 3;
23982
24486
  const showFiles = input.show_files ?? true;
@@ -23984,8 +24488,17 @@ var treeTool = {
23984
24488
  const showHidden = input.show_hidden ?? false;
23985
24489
  const exclude = /* @__PURE__ */ new Set([...DEFAULT_IGNORE5, ...input.exclude ?? []]);
23986
24490
  const filterGlob = input.glob;
24491
+ const maxEntries = input.max_entries ?? DEFAULT_MAX_ENTRIES2;
24492
+ const signal = opts?.signal ?? ctx.signal ?? new AbortController().signal;
23987
24493
  const lines = [basePath];
23988
24494
  const totals = { totalFiles: { value: 0 }, totalDirs: { value: 0 } };
24495
+ const retention = {
24496
+ entries: 0,
24497
+ outputBytes: Buffer.byteLength(basePath, "utf8"),
24498
+ maxEntries,
24499
+ maxOutputBytes: MAX_TREE_OUTPUT_BYTES,
24500
+ truncated: false
24501
+ };
23989
24502
  const queue = [];
23990
24503
  const FLUSH_EVERY = 200;
23991
24504
  let lastEmittedTotal = 0;
@@ -24012,12 +24525,19 @@ var treeTool = {
24012
24525
  isLast: true,
24013
24526
  totalFiles: totals.totalFiles,
24014
24527
  totalDirs: totals.totalDirs,
24015
- onProgress: tickProgress
24528
+ onProgress: tickProgress,
24529
+ retention,
24530
+ signal
24016
24531
  });
24017
24532
  let walkDone = false;
24018
- walkPromise.finally(() => {
24019
- walkDone = true;
24020
- });
24533
+ void walkPromise.then(
24534
+ () => {
24535
+ walkDone = true;
24536
+ },
24537
+ () => {
24538
+ walkDone = true;
24539
+ }
24540
+ );
24021
24541
  while (!walkDone || queue.length > 0) {
24022
24542
  if (queue.length > 0) {
24023
24543
  yield expectDefined10(queue.shift());
@@ -24040,13 +24560,15 @@ var treeTool = {
24040
24560
  tree: lines.join("\n"),
24041
24561
  total_files: totals.totalFiles.value,
24042
24562
  total_dirs: totals.totalDirs.value,
24043
- truncated: false,
24563
+ truncated: retention.truncated,
24044
24564
  path: basePath
24045
24565
  }
24046
24566
  };
24047
24567
  }
24048
24568
  };
24049
24569
  async function walkDir(dir, depth, opts) {
24570
+ opts.signal.throwIfAborted();
24571
+ if (opts.retention.truncated) return;
24050
24572
  const entries = await fs32.readdir(dir, { withFileTypes: true }).catch(() => []);
24051
24573
  const filtered = entries.filter((e) => {
24052
24574
  if (!opts.showHidden && e.name.startsWith(".")) return false;
@@ -24066,6 +24588,8 @@ async function walkDir(dir, depth, opts) {
24066
24588
  return a.name.localeCompare(b.name);
24067
24589
  });
24068
24590
  for (let i = 0; i < items.length; i++) {
24591
+ opts.signal.throwIfAborted();
24592
+ if (opts.retention.truncated) return;
24069
24593
  const entry = items[i];
24070
24594
  if (!entry) continue;
24071
24595
  const isLast = i === items.length - 1;
@@ -24074,7 +24598,15 @@ async function walkDir(dir, depth, opts) {
24074
24598
  const displayName = entry.name + (entry.isDirectory() ? "/" : "");
24075
24599
  if (!opts.showDirs && entry.isDirectory()) continue;
24076
24600
  if (!opts.showFiles && entry.isFile()) continue;
24077
- opts.lines.push(opts.prefix + branch + displayName);
24601
+ const line = opts.prefix + branch + displayName;
24602
+ const lineBytes = Buffer.byteLength(line, "utf8") + 1;
24603
+ if (opts.retention.entries >= opts.retention.maxEntries || opts.retention.outputBytes + lineBytes > opts.retention.maxOutputBytes) {
24604
+ opts.retention.truncated = true;
24605
+ return;
24606
+ }
24607
+ opts.lines.push(line);
24608
+ opts.retention.entries++;
24609
+ opts.retention.outputBytes += lineBytes;
24078
24610
  if (entry.isDirectory() && (opts.maxDepth === 0 || depth < opts.maxDepth)) {
24079
24611
  const childPrefix = opts.prefix + connector;
24080
24612
  await walkDir(path36.join(dir, entry.name), depth + 1, {
@@ -24189,11 +24721,11 @@ var typecheckTool = {
24189
24721
  }
24190
24722
  };
24191
24723
  async function findTsConfig(cwd) {
24192
- const { stat: stat17 } = await import("node:fs/promises");
24724
+ const { stat: stat18 } = await import("node:fs/promises");
24193
24725
  const candidates = ["tsconfig.json", "tsconfig.base.json"];
24194
24726
  for (const f of candidates) {
24195
24727
  try {
24196
- const s = await stat17(path37.join(cwd, f));
24728
+ const s = await stat18(path37.join(cwd, f));
24197
24729
  if (s.isFile()) return path37.join(cwd, f);
24198
24730
  } catch {
24199
24731
  }
@@ -24268,12 +24800,12 @@ async function prepareWrite(input, ctx) {
24268
24800
  let existed = false;
24269
24801
  let prev = "";
24270
24802
  try {
24271
- const stat17 = await fs33.stat(absPath);
24272
- existed = stat17.isFile();
24803
+ const stat18 = await fs33.stat(absPath);
24804
+ existed = stat18.isFile();
24273
24805
  if (existed) {
24274
24806
  if (!ctx.hasRead(absPath)) {
24275
24807
  prev = await fs33.readFile(absPath, "utf8");
24276
- ctx.recordRead(absPath, stat17.mtimeMs, "write", sha256hex(prev));
24808
+ ctx.recordRead(absPath, stat18.mtimeMs, "write", sha256hex(prev));
24277
24809
  } else {
24278
24810
  prev = await fs33.readFile(absPath, "utf8");
24279
24811
  }
@@ -24290,8 +24822,8 @@ async function finishWrite(input, ctx, prepared, signal) {
24290
24822
  await atomicWrite5(prepared.absPath, input.content);
24291
24823
  const diff = prepared.existed ? unifiedDiff3(prepared.prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
24292
24824
  + (new file, ${input.content.split("\n").length} lines)`;
24293
- const stat17 = await fs33.stat(prepared.absPath);
24294
- ctx.recordRead(prepared.absPath, stat17.mtimeMs, "write", sha256hex(input.content));
24825
+ const stat18 = await fs33.stat(prepared.absPath);
24826
+ ctx.recordRead(prepared.absPath, stat18.mtimeMs, "write", sha256hex(input.content));
24295
24827
  ctx.session.recordFileChange({
24296
24828
  path: prepared.absPath,
24297
24829
  action: prepared.existed ? "modified" : "created",