@wrongstack/tools 0.297.0 → 0.298.1

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 (254) hide show
  1. package/dist/_spawn-stream.d.ts +2 -0
  2. package/dist/_util.d.ts +29 -1
  3. package/dist/audit.js +113 -65
  4. package/dist/bash.js +5 -4
  5. package/dist/batch-tool-use.js +70 -7
  6. package/dist/browser/index.js +31 -1
  7. package/dist/builtin.js +908 -390
  8. package/dist/codebase-index/index.js +68 -16
  9. package/dist/codebase-index/project-server-protocol.d.ts +26 -0
  10. package/dist/codebase-index/project-server.js +60 -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-allowlist.d.ts +15 -0
  20. package/dist/exec.js +627 -568
  21. package/dist/format.js +116 -65
  22. package/dist/git.js +22 -2
  23. package/dist/glob.js +11 -5
  24. package/dist/grep.js +160 -104
  25. package/dist/index.js +859 -329
  26. package/dist/install.js +120 -68
  27. package/dist/json.js +64 -18
  28. package/dist/kanban-board-inputs.d.ts +21 -4
  29. package/dist/kanban.js +20 -5
  30. package/dist/languages/index.js +128 -71
  31. package/dist/lint.js +113 -65
  32. package/dist/outdated.js +86 -26
  33. package/dist/pack.js +908 -390
  34. package/dist/patch.js +11 -1
  35. package/dist/plan.js +32 -10
  36. package/dist/read.js +77 -23
  37. package/dist/replace.js +11 -3
  38. package/dist/scaffold.js +9 -6
  39. package/dist/session-kanban.js +21 -5
  40. package/dist/task.js +3 -1
  41. package/dist/test.js +113 -65
  42. package/dist/todo.js +3 -1
  43. package/dist/tool-tier.js +908 -390
  44. package/dist/tool-use.d.ts +1 -1
  45. package/dist/tool-use.js +28 -4
  46. package/dist/tree.d.ts +1 -0
  47. package/dist/tree.js +44 -8
  48. package/dist/typecheck.js +113 -65
  49. package/dist/write.js +9 -6
  50. package/package.json +7 -6
  51. package/dist/_concurrency.d.ts.map +0 -1
  52. package/dist/_danger-detect.d.ts.map +0 -1
  53. package/dist/_edit-match.d.ts.map +0 -1
  54. package/dist/_env.d.ts.map +0 -1
  55. package/dist/_fetch-guard.d.ts.map +0 -1
  56. package/dist/_output-spool.d.ts.map +0 -1
  57. package/dist/_redact-command.d.ts.map +0 -1
  58. package/dist/_regex.d.ts.map +0 -1
  59. package/dist/_session-shell.d.ts.map +0 -1
  60. package/dist/_shell-pick.d.ts.map +0 -1
  61. package/dist/_spawn-stream.d.ts.map +0 -1
  62. package/dist/_syntax-check.d.ts.map +0 -1
  63. package/dist/_util.d.ts.map +0 -1
  64. package/dist/_win32-resolve.d.ts.map +0 -1
  65. package/dist/audit.d.ts.map +0 -1
  66. package/dist/audit.js.map +0 -7
  67. package/dist/auto-proceed-loop-guard.d.ts.map +0 -1
  68. package/dist/auto-proceed-loop-guard.js.map +0 -7
  69. package/dist/bash-kill-guard.d.ts.map +0 -1
  70. package/dist/bash.d.ts.map +0 -1
  71. package/dist/bash.js.map +0 -7
  72. package/dist/batch-tool-use.d.ts.map +0 -1
  73. package/dist/batch-tool-use.js.map +0 -7
  74. package/dist/browser/artifacts.d.ts.map +0 -1
  75. package/dist/browser/index.d.ts.map +0 -1
  76. package/dist/browser/index.js.map +0 -7
  77. package/dist/browser/manager.d.ts.map +0 -1
  78. package/dist/browser/network-guard-proxy.d.ts.map +0 -1
  79. package/dist/browser/security.d.ts.map +0 -1
  80. package/dist/browser/tools.d.ts.map +0 -1
  81. package/dist/browser/types.d.ts.map +0 -1
  82. package/dist/builtin.d.ts.map +0 -1
  83. package/dist/builtin.js.map +0 -7
  84. package/dist/circuit-breaker.d.ts.map +0 -1
  85. package/dist/circuit-breaker.js.map +0 -7
  86. package/dist/codebase-index/background-indexer.d.ts.map +0 -1
  87. package/dist/codebase-index/bm25.d.ts.map +0 -1
  88. package/dist/codebase-index/circuit-breaker.d.ts.map +0 -1
  89. package/dist/codebase-index/codebase-index-tool.d.ts.map +0 -1
  90. package/dist/codebase-index/codebase-search-tool.d.ts.map +0 -1
  91. package/dist/codebase-index/codebase-stats-tool.d.ts.map +0 -1
  92. package/dist/codebase-index/dead-code-scan.d.ts.map +0 -1
  93. package/dist/codebase-index/generic-parser.d.ts.map +0 -1
  94. package/dist/codebase-index/gitignore.d.ts.map +0 -1
  95. package/dist/codebase-index/go-parser.d.ts.map +0 -1
  96. package/dist/codebase-index/index-service.d.ts.map +0 -1
  97. package/dist/codebase-index/index.d.ts.map +0 -1
  98. package/dist/codebase-index/index.js.map +0 -7
  99. package/dist/codebase-index/indexer.d.ts.map +0 -1
  100. package/dist/codebase-index/json-parser.d.ts.map +0 -1
  101. package/dist/codebase-index/languages.d.ts.map +0 -1
  102. package/dist/codebase-index/lsp-kind.d.ts.map +0 -1
  103. package/dist/codebase-index/parser-dispatch.d.ts.map +0 -1
  104. package/dist/codebase-index/project-server-cache.d.ts.map +0 -1
  105. package/dist/codebase-index/project-server-client.d.ts.map +0 -1
  106. package/dist/codebase-index/project-server-endpoint.d.ts.map +0 -1
  107. package/dist/codebase-index/project-server-protocol.d.ts.map +0 -1
  108. package/dist/codebase-index/project-server.d.ts.map +0 -1
  109. package/dist/codebase-index/project-server.js.map +0 -7
  110. package/dist/codebase-index/py-parser.d.ts.map +0 -1
  111. package/dist/codebase-index/refs-extractor.d.ts.map +0 -1
  112. package/dist/codebase-index/rs-parser.d.ts.map +0 -1
  113. package/dist/codebase-index/schema.d.ts.map +0 -1
  114. package/dist/codebase-index/spawn-gate.d.ts.map +0 -1
  115. package/dist/codebase-index/sqlite-runtime.d.ts.map +0 -1
  116. package/dist/codebase-index/ts-parser.d.ts.map +0 -1
  117. package/dist/codebase-index/worker-protocol.d.ts.map +0 -1
  118. package/dist/codebase-index/worker.d.ts.map +0 -1
  119. package/dist/codebase-index/worker.js.map +0 -7
  120. package/dist/codebase-index/writer-admin.d.ts.map +0 -1
  121. package/dist/codebase-index/writer-bulk-insert.d.ts.map +0 -1
  122. package/dist/codebase-index/writer-graph-helpers.d.ts.map +0 -1
  123. package/dist/codebase-index/writer-graph-reader.d.ts.map +0 -1
  124. package/dist/codebase-index/writer-helpers.d.ts.map +0 -1
  125. package/dist/codebase-index/writer-pragmas.d.ts.map +0 -1
  126. package/dist/codebase-index/writer-ref-mapper.d.ts.map +0 -1
  127. package/dist/codebase-index/writer-schema.d.ts.map +0 -1
  128. package/dist/codebase-index/writer-search-helpers.d.ts.map +0 -1
  129. package/dist/codebase-index/writer-store-pool.d.ts.map +0 -1
  130. package/dist/codebase-index/writer.d.ts.map +0 -1
  131. package/dist/codebase-index/yaml-parser.d.ts.map +0 -1
  132. package/dist/design.d.ts.map +0 -1
  133. package/dist/design.js.map +0 -7
  134. package/dist/diff.d.ts.map +0 -1
  135. package/dist/diff.js.map +0 -7
  136. package/dist/document.d.ts.map +0 -1
  137. package/dist/document.js.map +0 -7
  138. package/dist/e2e.d.ts.map +0 -1
  139. package/dist/e2e.js.map +0 -7
  140. package/dist/edit.d.ts.map +0 -1
  141. package/dist/edit.js.map +0 -7
  142. package/dist/exec-kill-guard.d.ts.map +0 -1
  143. package/dist/exec.d.ts.map +0 -1
  144. package/dist/exec.js.map +0 -7
  145. package/dist/fetch.d.ts.map +0 -1
  146. package/dist/fetch.js.map +0 -7
  147. package/dist/format.d.ts.map +0 -1
  148. package/dist/format.js.map +0 -7
  149. package/dist/git.d.ts.map +0 -1
  150. package/dist/git.js.map +0 -7
  151. package/dist/glob.d.ts.map +0 -1
  152. package/dist/glob.js.map +0 -7
  153. package/dist/grep.d.ts.map +0 -1
  154. package/dist/grep.js.map +0 -7
  155. package/dist/index.d.ts.map +0 -1
  156. package/dist/index.js.map +0 -7
  157. package/dist/install.d.ts.map +0 -1
  158. package/dist/install.js.map +0 -7
  159. package/dist/json.d.ts.map +0 -1
  160. package/dist/json.js.map +0 -7
  161. package/dist/kanban-board-inputs.d.ts.map +0 -1
  162. package/dist/kanban-decomposition-actions.d.ts.map +0 -1
  163. package/dist/kanban-detail-actions.d.ts.map +0 -1
  164. package/dist/kanban-evidence-bridge.d.ts.map +0 -1
  165. package/dist/kanban-presence.d.ts.map +0 -1
  166. package/dist/kanban-split-task-handler.d.ts.map +0 -1
  167. package/dist/kanban-task-inputs.d.ts.map +0 -1
  168. package/dist/kanban-tool-results.d.ts.map +0 -1
  169. package/dist/kanban-tool-schema.d.ts.map +0 -1
  170. package/dist/kanban-tool-types.d.ts.map +0 -1
  171. package/dist/kanban.d.ts.map +0 -1
  172. package/dist/kanban.js.map +0 -7
  173. package/dist/languages/detect.d.ts.map +0 -1
  174. package/dist/languages/diagnostics.d.ts.map +0 -1
  175. package/dist/languages/execute-tool.d.ts.map +0 -1
  176. package/dist/languages/execute.d.ts.map +0 -1
  177. package/dist/languages/index.d.ts.map +0 -1
  178. package/dist/languages/index.js.map +0 -7
  179. package/dist/languages/legacy-bridge.d.ts.map +0 -1
  180. package/dist/languages/package-tool.d.ts.map +0 -1
  181. package/dist/languages/plan.d.ts.map +0 -1
  182. package/dist/languages/profile-helpers.d.ts.map +0 -1
  183. package/dist/languages/profiles/additional.d.ts.map +0 -1
  184. package/dist/languages/profiles/primary.d.ts.map +0 -1
  185. package/dist/languages/registry.d.ts.map +0 -1
  186. package/dist/languages/tool.d.ts.map +0 -1
  187. package/dist/languages/types.d.ts.map +0 -1
  188. package/dist/lint.d.ts.map +0 -1
  189. package/dist/lint.js.map +0 -7
  190. package/dist/logs.d.ts.map +0 -1
  191. package/dist/logs.js.map +0 -7
  192. package/dist/memory.d.ts.map +0 -1
  193. package/dist/memory.js.map +0 -7
  194. package/dist/mode.d.ts.map +0 -1
  195. package/dist/mode.js.map +0 -7
  196. package/dist/next-steps.d.ts.map +0 -1
  197. package/dist/next-steps.js.map +0 -7
  198. package/dist/outdated.d.ts.map +0 -1
  199. package/dist/outdated.js.map +0 -7
  200. package/dist/pack.d.ts.map +0 -1
  201. package/dist/pack.js.map +0 -7
  202. package/dist/patch.d.ts.map +0 -1
  203. package/dist/patch.js.map +0 -7
  204. package/dist/plan.d.ts.map +0 -1
  205. package/dist/plan.js.map +0 -7
  206. package/dist/process-guardian.d.ts.map +0 -1
  207. package/dist/process-registry-persistent.d.ts.map +0 -1
  208. package/dist/process-registry.d.ts.map +0 -1
  209. package/dist/process-registry.js.map +0 -7
  210. package/dist/ps-slash.d.ts.map +0 -1
  211. package/dist/ps-slash.js.map +0 -7
  212. package/dist/read.d.ts.map +0 -1
  213. package/dist/read.js.map +0 -7
  214. package/dist/replace.d.ts.map +0 -1
  215. package/dist/replace.js.map +0 -7
  216. package/dist/scaffold.d.ts.map +0 -1
  217. package/dist/scaffold.js.map +0 -7
  218. package/dist/search.d.ts.map +0 -1
  219. package/dist/search.js.map +0 -7
  220. package/dist/session-kanban.d.ts.map +0 -1
  221. package/dist/session-kanban.js.map +0 -7
  222. package/dist/set-working-dir.d.ts.map +0 -1
  223. package/dist/skill.d.ts.map +0 -1
  224. package/dist/skill.js.map +0 -7
  225. package/dist/spawn-background.d.ts.map +0 -1
  226. package/dist/task.d.ts.map +0 -1
  227. package/dist/task.js.map +0 -7
  228. package/dist/test.d.ts.map +0 -1
  229. package/dist/test.js.map +0 -7
  230. package/dist/todo.d.ts.map +0 -1
  231. package/dist/todo.js.map +0 -7
  232. package/dist/tool-diff.d.ts.map +0 -1
  233. package/dist/tool-diff.js.map +0 -7
  234. package/dist/tool-help.d.ts.map +0 -1
  235. package/dist/tool-help.js.map +0 -7
  236. package/dist/tool-icon-map.d.ts.map +0 -1
  237. package/dist/tool-icons.d.ts.map +0 -1
  238. package/dist/tool-icons.js.map +0 -7
  239. package/dist/tool-search.d.ts.map +0 -1
  240. package/dist/tool-search.js.map +0 -7
  241. package/dist/tool-summary.d.ts.map +0 -1
  242. package/dist/tool-summary.js.map +0 -7
  243. package/dist/tool-tier.d.ts.map +0 -1
  244. package/dist/tool-tier.js.map +0 -7
  245. package/dist/tool-use.d.ts.map +0 -1
  246. package/dist/tool-use.js.map +0 -7
  247. package/dist/tree.d.ts.map +0 -1
  248. package/dist/tree.js.map +0 -7
  249. package/dist/typecheck.d.ts.map +0 -1
  250. package/dist/typecheck.js.map +0 -7
  251. package/dist/win32.d.ts.map +0 -1
  252. package/dist/win32.js.map +0 -7
  253. package/dist/write.d.ts.map +0 -1
  254. package/dist/write.js.map +0 -7
package/dist/tool-tier.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({
@@ -767,73 +835,6 @@ var init_process_registry = __esm({
767
835
  }
768
836
  });
769
837
 
770
- // src/_win32-resolve.ts
771
- import * as fs from "node:fs";
772
- import * as path2 from "node:path";
773
- function resolveWin32Command(cmd) {
774
- if (process.platform !== "win32") return cmd;
775
- if (cmd.includes("/") || cmd.includes("\\") || path2.extname(cmd.replace(/\//g, "\\"))) {
776
- return cmd;
777
- }
778
- const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
779
- const pathDirs = (process.env["PATH"] ?? "").split(path2.delimiter);
780
- for (const dir of pathDirs) {
781
- const base = path2.join(dir, cmd);
782
- for (const ext of pathext) {
783
- const full = `${base}${ext}`;
784
- try {
785
- fs.accessSync(full, fs.constants.X_OK);
786
- return full;
787
- } catch {
788
- }
789
- }
790
- }
791
- return cmd;
792
- }
793
- function resolvePowerShell(cmd) {
794
- if (process.platform !== "win32") return cmd;
795
- const lower = cmd.toLowerCase();
796
- if (lower !== "pwsh" && lower !== "powershell" && lower !== "pwsh.exe" && lower !== "powershell.exe") {
797
- return resolveWin32Command(cmd);
798
- }
799
- const primary = lower.startsWith("pwsh") ? "pwsh.exe" : "powershell.exe";
800
- const fallback = lower.startsWith("pwsh") ? "powershell.exe" : "pwsh.exe";
801
- const resolved = resolveWin32Command(primary);
802
- if (resolved !== primary) {
803
- const fb = resolveWin32Command(fallback);
804
- return fb === fallback ? cmd : fb;
805
- }
806
- return resolved;
807
- }
808
- function assertSafeWin32ShellArgs(args) {
809
- for (const arg of args) {
810
- if (typeof arg === "string" && WIN32_SHELL_META.test(arg)) {
811
- throw new Error(
812
- '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)
813
- );
814
- }
815
- }
816
- }
817
- function buildWin32CmdShimInvocation(command, args = []) {
818
- assertSafeWin32ShellArgs([command, ...args]);
819
- const line = ["call", quoteWin32CmdArg(command), ...args.map(quoteWin32CmdArg)].join(" ");
820
- return {
821
- command: process.env["COMSPEC"] ?? "cmd.exe",
822
- args: ["/d", "/c", line],
823
- windowsVerbatimArguments: true
824
- };
825
- }
826
- function quoteWin32CmdArg(arg) {
827
- return `"${arg}"`;
828
- }
829
- var WIN32_SHELL_META;
830
- var init_win32_resolve = __esm({
831
- "src/_win32-resolve.ts"() {
832
- "use strict";
833
- WIN32_SHELL_META = /[&|<>"\r\n\0]/;
834
- }
835
- });
836
-
837
838
  // src/_spawn-stream.ts
838
839
  import { spawn as spawn2 } from "node:child_process";
839
840
  import {
@@ -846,8 +847,11 @@ async function* spawnStream(opts) {
846
847
  const max = opts.maxBytes ?? 2e5;
847
848
  const flushAt = opts.flushBytes ?? 4 * 1024;
848
849
  const maxQueue = opts.maxQueueSize ?? 500;
850
+ const maxQueueBytes = opts.maxQueueBytes ?? 1024 * 1024;
849
851
  let stdout = "";
850
852
  let stderr = "";
853
+ let stdoutRetainedBytes = 0;
854
+ let stderrRetainedBytes = 0;
851
855
  let pending2 = "";
852
856
  let error;
853
857
  const spool = createOutputSpool({ tool: opts.cmd, thresholdBytes: max });
@@ -889,6 +893,7 @@ async function* spawnStream(opts) {
889
893
  });
890
894
  }
891
895
  const queue = [];
896
+ let queuedBytes = 0;
892
897
  let waiter;
893
898
  let paused = false;
894
899
  const wake = () => {
@@ -899,7 +904,7 @@ async function* spawnStream(opts) {
899
904
  }
900
905
  };
901
906
  const resume = () => {
902
- if (paused && queue.length < maxQueue) {
907
+ if (paused && queue.length <= Math.floor(maxQueue / 2) && queuedBytes <= Math.floor(maxQueueBytes / 2)) {
903
908
  paused = false;
904
909
  child.stdout?.resume();
905
910
  child.stderr?.resume();
@@ -909,11 +914,16 @@ async function* spawnStream(opts) {
909
914
  const s = c.toString();
910
915
  stdoutBytes += c.byteLength;
911
916
  emitProcessOutput({ pid, stream: "stdout", chunk: c });
912
- 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
+ }
913
922
  spool.write(s);
914
- queue.push({ kind: "out", data: s });
923
+ queue.push({ kind: "out", data: s, bytes: c.byteLength });
924
+ queuedBytes += c.byteLength;
915
925
  wake();
916
- if (!paused && queue.length >= maxQueue) {
926
+ if (!paused && (queue.length >= maxQueue || queuedBytes >= maxQueueBytes)) {
917
927
  paused = true;
918
928
  child.stdout?.pause();
919
929
  child.stderr?.pause();
@@ -923,11 +933,16 @@ async function* spawnStream(opts) {
923
933
  const s = c.toString();
924
934
  stderrBytes += c.byteLength;
925
935
  emitProcessOutput({ pid, stream: "stderr", chunk: c });
926
- 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
+ }
927
941
  spool.write(s);
928
- queue.push({ kind: "err", data: s });
942
+ queue.push({ kind: "err", data: s, bytes: c.byteLength });
943
+ queuedBytes += c.byteLength;
929
944
  wake();
930
- if (!paused && queue.length >= maxQueue) {
945
+ if (!paused && (queue.length >= maxQueue || queuedBytes >= maxQueueBytes)) {
931
946
  paused = true;
932
947
  child.stdout?.pause();
933
948
  child.stderr?.pause();
@@ -937,7 +952,9 @@ async function* spawnStream(opts) {
937
952
  child.stderr?.on("data", onErr);
938
953
  child.on("error", (e) => {
939
954
  error = e.message;
940
- 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;
941
958
  wake();
942
959
  });
943
960
  const completeTelemetry = (code, signal, timedOut = false) => {
@@ -958,7 +975,13 @@ async function* spawnStream(opts) {
958
975
  if (typeof pid === "number") registry.unregister(pid);
959
976
  const exitCode2 = code ?? (signal ? 1 : 0);
960
977
  completeTelemetry(exitCode2, signal ?? void 0);
961
- 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
+ });
962
985
  wake();
963
986
  });
964
987
  const onAbort = () => {
@@ -970,7 +993,7 @@ async function* spawnStream(opts) {
970
993
  } catch {
971
994
  }
972
995
  }
973
- queue.push({ kind: "close", data: "", code: 124 });
996
+ queue.push({ kind: "close", data: "", bytes: 0, code: 124 });
974
997
  completeTelemetry(124, "SIGKILL", true);
975
998
  wake();
976
999
  };
@@ -988,6 +1011,7 @@ async function* spawnStream(opts) {
988
1011
  });
989
1012
  }
990
1013
  const chunk = queue.shift();
1014
+ queuedBytes -= chunk.bytes;
991
1015
  resume();
992
1016
  if (chunk.kind === "close") {
993
1017
  if (!spawnFailed) exitCode = chunk.code ?? 0;
@@ -1014,7 +1038,7 @@ async function* spawnStream(opts) {
1014
1038
  stdout: spooled ? stdout + spoolNote(spooled) : stdout,
1015
1039
  stderr,
1016
1040
  exitCode,
1017
- truncated: stdout.length >= max || stderr.length >= max,
1041
+ truncated: stdoutBytes > stdoutRetainedBytes || stderrBytes > stderrRetainedBytes,
1018
1042
  error,
1019
1043
  spoolPath: spooled?.path,
1020
1044
  spoolBytes: spooled?.bytes
@@ -1038,13 +1062,27 @@ async function* spawnStream(opts) {
1038
1062
  }
1039
1063
  }
1040
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
+ }
1041
1079
  var isWin;
1042
1080
  var init_spawn_stream = __esm({
1043
1081
  "src/_spawn-stream.ts"() {
1044
1082
  "use strict";
1045
1083
  init_output_spool();
1046
- init_process_registry();
1047
1084
  init_win32_resolve();
1085
+ init_process_registry();
1048
1086
  isWin = process.platform === "win32";
1049
1087
  }
1050
1088
  });
@@ -1058,14 +1096,14 @@ function sha256hex(content) {
1058
1096
  return createHash("sha256").update(content, "utf8").digest("hex");
1059
1097
  }
1060
1098
  async function detectPackageManager(cwd) {
1061
- const { stat: stat17 } = await import("node:fs/promises");
1099
+ const { stat: stat18 } = await import("node:fs/promises");
1062
1100
  try {
1063
- await stat17(`${cwd}/pnpm-lock.yaml`);
1101
+ await stat18(`${cwd}/pnpm-lock.yaml`);
1064
1102
  return "pnpm";
1065
1103
  } catch {
1066
1104
  }
1067
1105
  try {
1068
- await stat17(`${cwd}/yarn.lock`);
1106
+ await stat18(`${cwd}/yarn.lock`);
1069
1107
  return "yarn";
1070
1108
  } catch {
1071
1109
  }
@@ -1093,11 +1131,15 @@ function safeResolve(input, ctx) {
1093
1131
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
1094
1132
  }
1095
1133
  async function assertRealInsideRoot(absPath, ctx) {
1096
- if (ctx.allowOutsideProjectRoot) return;
1134
+ await resolveRealInsideRoot(absPath, ctx);
1135
+ }
1136
+ async function resolveRealInsideRoot(absPath, ctx) {
1137
+ if (ctx.allowOutsideProjectRoot) return absPath;
1097
1138
  const realRoots = await Promise.all(
1098
1139
  allowedRoots(ctx).map((r) => fsp2.realpath(r).catch(() => path3.resolve(r)))
1099
1140
  );
1100
1141
  let probe2 = absPath;
1142
+ const pendingTail = [];
1101
1143
  for (; ; ) {
1102
1144
  let real;
1103
1145
  try {
@@ -1105,13 +1147,16 @@ async function assertRealInsideRoot(absPath, ctx) {
1105
1147
  } catch (err) {
1106
1148
  if (err.code === "ENOENT") {
1107
1149
  const parent = path3.dirname(probe2);
1108
- if (parent === probe2) return;
1150
+ if (parent === probe2) return absPath;
1151
+ pendingTail.unshift(path3.basename(probe2));
1109
1152
  probe2 = parent;
1110
1153
  continue;
1111
1154
  }
1112
1155
  throw err;
1113
1156
  }
1114
- if (isInsideAny(real, realRoots)) return;
1157
+ if (isInsideAny(real, realRoots)) {
1158
+ return pendingTail.length > 0 ? path3.join(real, ...pendingTail) : real;
1159
+ }
1115
1160
  throw new Error(
1116
1161
  `Path "${absPath}" resolves through a symlink outside project root "${realRoots[0]}"`
1117
1162
  );
@@ -1119,8 +1164,7 @@ async function assertRealInsideRoot(absPath, ctx) {
1119
1164
  }
1120
1165
  async function safeResolveReal(input, ctx) {
1121
1166
  const abs = safeResolve(input, ctx);
1122
- await assertRealInsideRoot(abs, ctx);
1123
- return abs;
1167
+ return await resolveRealInsideRoot(abs, ctx);
1124
1168
  }
1125
1169
  function truncateMiddle(s, max) {
1126
1170
  if (Buffer.byteLength(s, "utf8") <= max) return s;
@@ -2684,8 +2728,8 @@ async function scanDirectory(directory, depth, profiles, limits, state, extraIgn
2684
2728
  collectFileEvidence(directory, fullPath, entry.name, profiles, state);
2685
2729
  }
2686
2730
  }
2687
- function collectFileEvidence(directory, fullPath, basename12, profiles, state) {
2688
- const lower = basename12.toLowerCase();
2731
+ function collectFileEvidence(directory, fullPath, basename13, profiles, state) {
2732
+ const lower = basename13.toLowerCase();
2689
2733
  const extension = path4.extname(lower);
2690
2734
  for (const profile of profiles) {
2691
2735
  const detector = profile.detectors.find(
@@ -2696,7 +2740,7 @@ function collectFileEvidence(directory, fullPath, basename12, profiles, state) {
2696
2740
  candidate.evidence.push({
2697
2741
  kind: detector.kind,
2698
2742
  path: fullPath,
2699
- value: basename12,
2743
+ value: basename13,
2700
2744
  weight: detector.weight
2701
2745
  });
2702
2746
  if (detector.kind === "manifest" || detector.kind === "config") {
@@ -2824,8 +2868,8 @@ function normalizeLimits(input) {
2824
2868
  async function canonicalDirectory(input) {
2825
2869
  const resolved = path4.resolve(input);
2826
2870
  const real = await fs2.realpath(resolved);
2827
- const stat17 = await fs2.stat(real);
2828
- 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}`);
2829
2873
  return real;
2830
2874
  }
2831
2875
  async function canonicalInside(input, root, label) {
@@ -3381,6 +3425,7 @@ import * as fs3 from "node:fs/promises";
3381
3425
  import * as path6 from "node:path";
3382
3426
  async function planLanguageOperation(options) {
3383
3427
  validateOperationOptions(options.operation, options.operationOptions?.packages);
3428
+ validateFilterOption(options.operationOptions?.filter);
3384
3429
  const detection = await detectLanguageWorkspaces(options);
3385
3430
  const candidates = detection.workspaces.filter(
3386
3431
  (workspace) => workspace.capabilities.includes(options.operation)
@@ -3553,6 +3598,15 @@ function workspaceDepth(workspaceRoot, projectRoot) {
3553
3598
  const relative12 = path6.relative(path6.resolve(projectRoot), path6.resolve(workspaceRoot));
3554
3599
  return relative12 === "" ? 0 : relative12.split(path6.sep).length;
3555
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
+ }
3556
3610
  function validateOperationOptions(operation, packages) {
3557
3611
  if (!operation.startsWith("package-") || !packages) return;
3558
3612
  for (const value of packages) {
@@ -3594,7 +3648,7 @@ var init_plan = __esm({
3594
3648
  MAX_ARGUMENT_LENGTH = 4096;
3595
3649
  PACKAGE_NAME_RE = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+(?:@[a-z0-9*+._~^<>=|-]+)?$/i;
3596
3650
  COMPOSER_PACKAGE_RE = /^[a-z0-9_.-]+\/[a-z0-9_.-]+(?::[a-z0-9*+._~^<>=|-]+)?$/i;
3597
- 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;
3598
3652
  PYTHON_PACKAGE_RE = /^[a-z0-9._-]+(?:\[[a-z0-9._,-]+\])?==[a-z0-9*+._!~-]+$/i;
3599
3653
  }
3600
3654
  });
@@ -3714,8 +3768,8 @@ async function executeInternal(options, startedAt) {
3714
3768
  const target = options.plan.evidence.find((item) => item.kind === "target")?.path;
3715
3769
  if (!target) return unavailableResult(options, "Internal syntax plan has no target evidence.");
3716
3770
  const safeTarget = await assertContainedFile(target, options.projectRoot);
3717
- const stat17 = await fs4.stat(safeTarget);
3718
- if (stat17.size > MAX_INTERNAL_SOURCE_BYTES) {
3771
+ const stat18 = await fs4.stat(safeTarget);
3772
+ if (stat18.size > MAX_INTERNAL_SOURCE_BYTES) {
3719
3773
  return unavailableResult(
3720
3774
  options,
3721
3775
  `Internal syntax target exceeds ${MAX_INTERNAL_SOURCE_BYTES} bytes.`
@@ -4002,8 +4056,8 @@ async function snapshotPaths(paths) {
4002
4056
  const existing = [];
4003
4057
  for (const candidate of paths) {
4004
4058
  try {
4005
- const stat17 = await fs4.stat(candidate);
4006
- if (!stat17.isFile()) continue;
4059
+ const stat18 = await fs4.stat(candidate);
4060
+ if (!stat18.isFile()) continue;
4007
4061
  existing.push(candidate);
4008
4062
  } catch {
4009
4063
  }
@@ -4031,8 +4085,8 @@ async function snapshotSizes(paths) {
4031
4085
  const sizes = /* @__PURE__ */ new Map();
4032
4086
  for (const candidate of paths) {
4033
4087
  try {
4034
- const stat17 = await fs4.stat(candidate);
4035
- if (stat17.isFile()) sizes.set(candidate, stat17.size);
4088
+ const stat18 = await fs4.stat(candidate);
4089
+ if (stat18.isFile()) sizes.set(candidate, stat18.size);
4036
4090
  } catch {
4037
4091
  }
4038
4092
  }
@@ -4192,6 +4246,9 @@ var init_execute_tool = __esm({
4192
4246
  useInstead: ["language_info", "install", "audit", "outdated"]
4193
4247
  },
4194
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",
4195
4252
  mutating: true,
4196
4253
  riskTier: "standard",
4197
4254
  capabilities: ["shell.restricted", "fs.write"],
@@ -4424,6 +4481,9 @@ var init_package_tool = __esm({
4424
4481
  useInstead: ["language_info", "language", "install", "audit", "outdated"]
4425
4482
  },
4426
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",
4427
4487
  mutating: true,
4428
4488
  riskTier: "destructive",
4429
4489
  capabilities: ["shell.restricted", "fs.write", "net.outbound", "package.install"],
@@ -6475,9 +6535,9 @@ function parseSymbols6(opts) {
6475
6535
  function regexParse2(opts) {
6476
6536
  const { file, content, lang } = opts;
6477
6537
  const symbols = [];
6478
- const basename12 = path22.basename(file).toLowerCase();
6479
- const isPackageJson = basename12 === "package.json";
6480
- 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";
6481
6541
  const isJsonSchema = content.includes("$schema") || content.includes("$id") || content.includes("$ref");
6482
6542
  const isOpenApi = content.includes("openapi") || content.includes("swagger");
6483
6543
  const lines = content.split("\n");
@@ -8373,30 +8433,59 @@ ${hint}` : ""),
8373
8433
  import {
8374
8434
  GOVERNED_TOOL_EXECUTOR_META_KEY
8375
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;
8376
8452
  var batchToolUseTool = {
8377
8453
  name: "batch_tool_use",
8378
8454
  category: "Meta",
8379
- description: "Execute a batch of tool calls either sequentially or in parallel. Returns structured results for every call.",
8380
- 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(", ")}.`,
8381
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.
8382
8466
  mutating: true,
8383
8467
  timeoutMs: 12e4,
8384
- capabilities: ["tool.mutate.any"],
8468
+ capabilities: ["tool.meta"],
8385
8469
  icon: "meta",
8386
8470
  inputSchema: {
8387
8471
  type: "object",
8388
8472
  properties: {
8389
8473
  calls: {
8390
8474
  type: "array",
8475
+ maxItems: MAX_BATCH_CALLS,
8391
8476
  items: {
8392
8477
  type: "object",
8393
8478
  properties: {
8394
- tool: { type: "string" },
8479
+ tool: {
8480
+ type: "string",
8481
+ enum: [...BATCHABLE_TOOL_NAMES],
8482
+ description: "An explicitly batch-safe, read-only discovery tool."
8483
+ },
8395
8484
  input: { type: "object" }
8396
8485
  },
8397
8486
  required: ["tool"]
8398
8487
  },
8399
- description: "Array of tool calls to execute"
8488
+ description: `Up to ${MAX_BATCH_CALLS} independent, batch-safe read calls.`
8400
8489
  },
8401
8490
  stop_on_error: {
8402
8491
  type: "boolean",
@@ -8419,6 +8508,9 @@ var batchToolUseTool = {
8419
8508
  stop_on_error: false
8420
8509
  };
8421
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
+ }
8422
8514
  const governedExecute = ctx.meta[GOVERNED_TOOL_EXECUTOR_META_KEY];
8423
8515
  if (typeof governedExecute !== "function") {
8424
8516
  return {
@@ -8438,8 +8530,11 @@ var batchToolUseTool = {
8438
8530
  let succeeded = 0;
8439
8531
  let failed = 0;
8440
8532
  if (input.parallel !== false) {
8441
- const promises = input.calls.map(async (call) => executeSingle(call, ctx, governedExecute));
8442
- 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
+ );
8443
8538
  results.push(...allResults);
8444
8539
  succeeded = allResults.filter((r) => r.success).length;
8445
8540
  failed = allResults.filter((r) => !r.success).length;
@@ -8464,8 +8559,28 @@ var batchToolUseTool = {
8464
8559
  };
8465
8560
  }
8466
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
+ }
8467
8574
  async function executeSingle(call, ctx, governedExecute) {
8468
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
+ }
8469
8584
  const tool = ctx.tools.find((candidate) => candidate.name === call.tool);
8470
8585
  if (!tool) {
8471
8586
  return {
@@ -8475,6 +8590,14 @@ async function executeSingle(call, ctx, governedExecute) {
8475
8590
  executionMs: Date.now() - start
8476
8591
  };
8477
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
+ }
8478
8601
  try {
8479
8602
  const result = await governedExecute(call.tool, call.input);
8480
8603
  return {
@@ -8511,8 +8634,14 @@ import * as path10 from "node:path";
8511
8634
  import { atomicWrite, ulid } from "@wrongstack/core/utils";
8512
8635
  var ARTIFACT_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
8513
8636
  var sweptRoots = /* @__PURE__ */ new Set();
8637
+ var MAX_SWEPT_ROOTS = 128;
8514
8638
  function sweepOldArtifacts(root) {
8515
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
+ }
8516
8645
  sweptRoots.add(root);
8517
8646
  void (async () => {
8518
8647
  const cutoff = Date.now() - ARTIFACT_RETENTION_MS;
@@ -8535,8 +8664,8 @@ function sweepOldArtifacts(root) {
8535
8664
  for (const name of names) {
8536
8665
  const target = path10.join(dir, name);
8537
8666
  try {
8538
- const stat17 = await fs7.stat(target);
8539
- if (stat17.isFile() && stat17.mtimeMs < cutoff) {
8667
+ const stat18 = await fs7.stat(target);
8668
+ if (stat18.isFile() && stat18.mtimeMs < cutoff) {
8540
8669
  await fs7.rm(target, { force: true });
8541
8670
  removed++;
8542
8671
  }
@@ -8564,14 +8693,14 @@ var BrowserArtifactStore = class {
8564
8693
  }
8565
8694
  async record(id, sessionId, kind, target, mimeType) {
8566
8695
  await fs7.chmod(target, 384).catch(() => void 0);
8567
- const [stat17, sha256] = await Promise.all([fs7.stat(target), hashFile(target)]);
8696
+ const [stat18, sha256] = await Promise.all([fs7.stat(target), hashFile(target)]);
8568
8697
  const artifact = {
8569
8698
  id,
8570
8699
  kind,
8571
8700
  sensitivity: "sensitive",
8572
8701
  path: target,
8573
8702
  mimeType,
8574
- sizeBytes: stat17.size,
8703
+ sizeBytes: stat18.size,
8575
8704
  sha256,
8576
8705
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
8577
8706
  };
@@ -8941,7 +9070,7 @@ var BrowserSessionManager = class {
8941
9070
  constructor(options, launcher = defaultLauncher) {
8942
9071
  this.launcher = launcher;
8943
9072
  this.artifacts = new BrowserArtifactStore(options.artifactRoot);
8944
- this.allowPrivateHosts = options.allowPrivateHosts ?? true;
9073
+ this.allowPrivateHosts = options.allowPrivateHosts ?? false;
8945
9074
  this.allowedPrivateOrigins = options.allowedPrivateOrigins ?? [];
8946
9075
  this.networkProxy = new BrowserNetworkGuardProxy({
8947
9076
  allowPrivateHosts: this.allowPrivateHosts,
@@ -9164,8 +9293,8 @@ var BrowserSessionManager = class {
9164
9293
  if (realRelative.startsWith("..") || path11.isAbsolute(realRelative)) {
9165
9294
  throw new Error("browser: upload files must not escape the project root through a symlink");
9166
9295
  }
9167
- const stat17 = await fs8.stat(realFile);
9168
- 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}`);
9169
9298
  resolved.push(realFile);
9170
9299
  }
9171
9300
  await this.runPageOperation(
@@ -9514,6 +9643,9 @@ var browserScreenshotTool = {
9514
9643
  description: "Capture a page or element PNG and return sensitive artifact metadata with integrity hash.",
9515
9644
  usageHint: "browser_screenshot({ sessionId, fullPage?, selector? })",
9516
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",
9517
9649
  mutating: true,
9518
9650
  riskTier: "safe",
9519
9651
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9543,6 +9675,9 @@ function selectorTool(name, description, run) {
9543
9675
  description,
9544
9676
  usageHint: `${name}({ sessionId, selector })`,
9545
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",
9546
9681
  mutating: true,
9547
9682
  riskTier: "standard",
9548
9683
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9572,6 +9707,9 @@ var browserTypeTool = {
9572
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.",
9573
9708
  usageHint: "browser_type({ sessionId, selector, text? | secretEnv? })",
9574
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",
9575
9713
  mutating: true,
9576
9714
  riskTier: "standard",
9577
9715
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9606,6 +9744,9 @@ var browserSelectTool = {
9606
9744
  description: "Select an option in an owned browser session.",
9607
9745
  usageHint: "browser_select({ sessionId, selector, value })",
9608
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",
9609
9750
  mutating: true,
9610
9751
  riskTier: "standard",
9611
9752
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9634,6 +9775,9 @@ var browserPressTool = {
9634
9775
  description: "Press a keyboard key in an owned browser session.",
9635
9776
  usageHint: "browser_press({ sessionId, key })",
9636
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",
9637
9781
  mutating: true,
9638
9782
  riskTier: "standard",
9639
9783
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9652,6 +9796,9 @@ var browserDragTool = {
9652
9796
  description: "Drag one element to another in an owned browser session.",
9653
9797
  usageHint: "browser_drag({ sessionId, from, to })",
9654
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",
9655
9802
  mutating: true,
9656
9803
  riskTier: "standard",
9657
9804
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9701,6 +9848,9 @@ var browserEvaluateTool = {
9701
9848
  description: "Evaluate bounded JavaScript in the page. Requires confirmation because page code is arbitrary.",
9702
9849
  usageHint: "browser_evaluate({ sessionId, expression })",
9703
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",
9704
9854
  mutating: true,
9705
9855
  riskTier: "destructive",
9706
9856
  capabilities: [ToolCapabilities.NET_OUTBOUND, ToolCapabilities.TOOL_MUTATE_ANY],
@@ -9723,6 +9873,9 @@ var browserUploadTool = {
9723
9873
  description: "Upload project-local files through a file input in an owned browser session.",
9724
9874
  usageHint: "browser_upload({ sessionId, selector, files })",
9725
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",
9726
9879
  mutating: true,
9727
9880
  riskTier: "destructive",
9728
9881
  capabilities: [ToolCapabilities.NET_OUTBOUND, ToolCapabilities.FS_READ],
@@ -10658,6 +10811,7 @@ var REFS_INDEX_SQL = [
10658
10811
  var SYMBOLS_FTS_SQL = "CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(text, tokenize = 'unicode61')";
10659
10812
 
10660
10813
  // src/codebase-index/writer-search-helpers.ts
10814
+ var SEARCH_CANDIDATE_SCAN_CAP = 5e3;
10661
10815
  function normalizeSearchLimit(limit) {
10662
10816
  return typeof limit === "number" && Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : void 0;
10663
10817
  }
@@ -10687,8 +10841,8 @@ function buildWriterSearchWhere(query, filter) {
10687
10841
  }
10688
10842
  if (query.trim()) {
10689
10843
  const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
10690
- conditions.push(`(${tokens.map(() => "text LIKE ?").join(" OR ")})`);
10691
- 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)}%`);
10692
10846
  }
10693
10847
  return { where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", values };
10694
10848
  }
@@ -10797,6 +10951,7 @@ var StorePool = class {
10797
10951
 
10798
10952
  // src/codebase-index/writer.ts
10799
10953
  var DB_FILE2 = "index.db";
10954
+ var MAX_STATEMENT_CACHE = 128;
10800
10955
  var IndexStore = class _IndexStore {
10801
10956
  db;
10802
10957
  /** Absolute path to this project's index directory. */
@@ -10834,12 +10989,31 @@ var IndexStore = class _IndexStore {
10834
10989
  // Dirty on open so the first getOrBuildBm25() rebuilds against current rows;
10835
10990
  // an empty or pre-existing corpus makes a stale IDF table meaningless.
10836
10991
  bm25Dirty = true;
10837
- /** 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
+ */
10838
11005
  stmt(sql) {
10839
- let s = this.stmtCache.get(sql);
10840
- if (s === void 0) {
10841
- s = this.db.prepare(sql);
10842
- 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);
10843
11017
  }
10844
11018
  return s;
10845
11019
  }
@@ -11112,7 +11286,7 @@ var IndexStore = class _IndexStore {
11112
11286
  const { where, values } = built;
11113
11287
  const limit = normalizeSearchLimit(opts?.limit);
11114
11288
  const limitSql = limit !== void 0 ? " LIMIT ?" : "";
11115
- 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}`;
11116
11290
  const binds = limit !== void 0 ? [...values, limit] : values;
11117
11291
  const rows = this.stmt(sql).all(
11118
11292
  ...binds
@@ -11241,11 +11415,13 @@ var IndexStore = class _IndexStore {
11241
11415
  /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
11242
11416
  searchRankedFallback(query, filter, limit) {
11243
11417
  if (!query.trim()) {
11244
- const total = this.countSearch(query, filter);
11245
- if (total === 0) return { results: [], total: 0 };
11246
- 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 };
11247
11421
  }
11248
- 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 });
11249
11425
  if (candidates.length === 0) return { results: [], total: 0 };
11250
11426
  const candidateById = new Map(candidates.map((c) => [c.id, c]));
11251
11427
  const bm25 = this.getOrBuildBm25();
@@ -11271,7 +11447,7 @@ var IndexStore = class _IndexStore {
11271
11447
  const c = expectDefined2(candidateById.get(id));
11272
11448
  return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
11273
11449
  });
11274
- return { results, total: candidates.length };
11450
+ return { results, total };
11275
11451
  }
11276
11452
  getAllIndexable() {
11277
11453
  return getAllIndexableWithStatement((sql) => this.stmt(sql));
@@ -11690,12 +11866,12 @@ var buildIdCache;
11690
11866
  function projectIndexServerBuildId(entrypoint) {
11691
11867
  const file = entrypoint instanceof URL || entrypoint.startsWith("file:") ? fileURLToPath(entrypoint) : path16.resolve(entrypoint);
11692
11868
  try {
11693
- const stat17 = fs11.statSync(file);
11694
- 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) {
11695
11871
  return buildIdCache.buildId;
11696
11872
  }
11697
11873
  const buildId = createHash4("sha256").update(fs11.readFileSync(file)).digest("hex").slice(0, 24);
11698
- buildIdCache = { file, mtimeMs: stat17.mtimeMs, size: stat17.size, buildId };
11874
+ buildIdCache = { file, mtimeMs: stat18.mtimeMs, size: stat18.size, buildId };
11699
11875
  return buildId;
11700
11876
  } catch {
11701
11877
  return `unreadable:${path16.basename(file)}`;
@@ -11865,6 +12041,8 @@ var ProjectServerConnection = class {
11865
12041
  connectResolve = null;
11866
12042
  connectReject = null;
11867
12043
  nextId = 1;
12044
+ /** WS-027: read from the owner-only metadata file — see currentAuthToken. */
12045
+ authToken;
11868
12046
  pending = /* @__PURE__ */ new Map();
11869
12047
  transition(status, options = {}) {
11870
12048
  const previous = connectionStates.get(this.endpoint);
@@ -12001,6 +12179,31 @@ var ProjectServerConnection = class {
12001
12179
  this.transition("offline");
12002
12180
  maybeStopHeartbeatLoop();
12003
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
+ }
12004
12207
  request(message, options) {
12005
12208
  const socket = this.socket;
12006
12209
  if (!socket || socket.destroyed) {
@@ -12044,7 +12247,7 @@ var ProjectServerConnection = class {
12044
12247
  return;
12045
12248
  }
12046
12249
  }
12047
- this.write({ ...message, id });
12250
+ this.write({ ...message, id, authToken: this.currentAuthToken() });
12048
12251
  });
12049
12252
  }
12050
12253
  async ensureConnected(spawnIfMissing) {
@@ -12091,6 +12294,7 @@ var ProjectServerConnection = class {
12091
12294
  connectOnce() {
12092
12295
  this.socket?.destroy();
12093
12296
  this.socket = null;
12297
+ this.authToken = void 0;
12094
12298
  this.info = null;
12095
12299
  this.activity = null;
12096
12300
  this.health = null;
@@ -12700,9 +12904,9 @@ async function runIndexerWithStore(store, opts) {
12700
12904
  const statReadParse = await Promise.allSettled(
12701
12905
  batchFiles.map(
12702
12906
  async (file) => {
12703
- let stat17;
12907
+ let stat18;
12704
12908
  try {
12705
- stat17 = await fs17.stat(file, statOpts);
12909
+ stat18 = await fs17.stat(file, statOpts);
12706
12910
  } catch (e) {
12707
12911
  if (isAbortError(e)) throw e;
12708
12912
  return {
@@ -12714,21 +12918,21 @@ async function runIndexerWithStore(store, opts) {
12714
12918
  missing: isMissingPathError(e)
12715
12919
  };
12716
12920
  }
12717
- if (!stat17.isFile()) return { file, stat: stat17, lang: "", parsed: null };
12921
+ if (!stat18.isFile()) return { file, stat: stat18, lang: "", parsed: null };
12718
12922
  const lang = detectLang(file);
12719
- if (!lang) return { file, stat: stat17, lang: "", parsed: null };
12720
- 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) {
12721
12925
  return {
12722
12926
  file,
12723
- stat: stat17,
12927
+ stat: stat18,
12724
12928
  lang,
12725
12929
  parsed: null,
12726
- 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})`
12727
12931
  };
12728
12932
  }
12729
12933
  const meta = existingMeta.get(file);
12730
- if (!force && meta && meta.mtimeMs === Math.floor(stat17.mtimeMs)) {
12731
- 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 };
12732
12936
  }
12733
12937
  let content;
12734
12938
  try {
@@ -12737,7 +12941,7 @@ async function runIndexerWithStore(store, opts) {
12737
12941
  if (isAbortError(e)) throw e;
12738
12942
  return {
12739
12943
  file,
12740
- stat: stat17,
12944
+ stat: stat18,
12741
12945
  lang,
12742
12946
  parsed: null,
12743
12947
  error: `read error: ${e instanceof Error ? e.message : String(e)}`
@@ -12749,13 +12953,13 @@ async function runIndexerWithStore(store, opts) {
12749
12953
  } catch (e) {
12750
12954
  return {
12751
12955
  file,
12752
- stat: stat17,
12956
+ stat: stat18,
12753
12957
  lang,
12754
12958
  parsed: null,
12755
12959
  error: `parse error: ${e instanceof Error ? e.message : String(e)}`
12756
12960
  };
12757
12961
  }
12758
- return { file, stat: stat17, lang, parsed, content };
12962
+ return { file, stat: stat18, lang, parsed, content };
12759
12963
  }
12760
12964
  )
12761
12965
  );
@@ -12776,7 +12980,7 @@ async function runIndexerWithStore(store, opts) {
12776
12980
  errors.push(`${file}: ${result.error}`);
12777
12981
  continue;
12778
12982
  }
12779
- const { stat: stat17, lang, parsed } = result;
12983
+ const { stat: stat18, lang, parsed } = result;
12780
12984
  if (result.skippedMeta) {
12781
12985
  langStats[lang] = (langStats[lang] ?? 0) + result.skippedMeta.symbolCount;
12782
12986
  symbolsIndexed += result.skippedMeta.symbolCount;
@@ -12788,7 +12992,7 @@ async function runIndexerWithStore(store, opts) {
12788
12992
  store.upsertFile({
12789
12993
  file,
12790
12994
  lang,
12791
- mtimeMs: Math.floor(stat17.mtimeMs),
12995
+ mtimeMs: Math.floor(stat18.mtimeMs),
12792
12996
  symbolCount: 0,
12793
12997
  lastIndexed: Date.now()
12794
12998
  });
@@ -12800,7 +13004,7 @@ async function runIndexerWithStore(store, opts) {
12800
13004
  store.replaceEmptyFile({
12801
13005
  file,
12802
13006
  lang,
12803
- mtimeMs: Math.floor(stat17.mtimeMs),
13007
+ mtimeMs: Math.floor(stat18.mtimeMs),
12804
13008
  symbolCount: 0,
12805
13009
  lastIndexed: Date.now()
12806
13010
  });
@@ -12812,7 +13016,7 @@ async function runIndexerWithStore(store, opts) {
12812
13016
  lang,
12813
13017
  symbols: parsed.symbols,
12814
13018
  refs: parsed.refs ?? [],
12815
- mtimeMs: Math.floor(stat17.mtimeMs),
13019
+ mtimeMs: Math.floor(stat18.mtimeMs),
12816
13020
  symbolCount: parsed.symbols.length
12817
13021
  });
12818
13022
  deleteForFiles.push(file);
@@ -13865,8 +14069,9 @@ function runDeadCodeScan(projectRoot, opts = {}) {
13865
14069
  const content = fs19.readFileSync(epFile, "utf8");
13866
14070
  const strippedContent = content.replace(/\/\*[\s\S]*?\*\//g, (m) => " ".repeat(m.length)).replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length));
13867
14071
  const reExportRe = /export\s+(?:(?:type\s+)?\{[\s\S]*?\}\s+from|\*\s+as\s+\w+\s+from|\*\s+from)\s+['"]([^'"]+)['"]/g;
13868
- let match;
13869
- while ((match = reExportRe.exec(strippedContent)) !== null) {
14072
+ for (; ; ) {
14073
+ const match = reExportRe.exec(strippedContent);
14074
+ if (match === null) break;
13870
14075
  const moduleSpec = match[1];
13871
14076
  const resolvedFiles = resolveModulePath(epFile, moduleSpec, indexedFiles);
13872
14077
  for (const rf of resolvedFiles) {
@@ -14072,6 +14277,9 @@ var designTool = {
14072
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).',
14073
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"}`.',
14074
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",
14075
14283
  mutating: true,
14076
14284
  capabilities: ["fs.write"],
14077
14285
  timeoutMs: 15e3,
@@ -14252,9 +14460,7 @@ Run \`design {action:"materialize"}\` to write the tuned tokens to your theme fi
14252
14460
  const abs = path25.join(absParent, path25.basename(absResolved));
14253
14461
  const rel = path25.relative(root, abs);
14254
14462
  if (rel.startsWith("..") || path25.isAbsolute(rel)) {
14255
- throw new Error(
14256
- `design: materialize path "${result.path}" would escape the project root`
14257
- );
14463
+ throw new Error(`design: materialize path "${result.path}" would escape the project root`);
14258
14464
  }
14259
14465
  let exists = false;
14260
14466
  try {
@@ -14329,6 +14535,7 @@ import { statSync as statSync3 } from "node:fs";
14329
14535
  import * as fs21 from "node:fs/promises";
14330
14536
  import * as path26 from "node:path";
14331
14537
  import { buildChildEnv as buildChildEnv3 } from "@wrongstack/core/utils";
14538
+ var MAX_FILE_DUMP_BYTES = 5 * 1024 * 1024;
14332
14539
  var diffTool = {
14333
14540
  name: "diff",
14334
14541
  category: "Filesystem",
@@ -14336,6 +14543,7 @@ var diffTool = {
14336
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).',
14337
14544
  permission: "auto",
14338
14545
  mutating: false,
14546
+ maxOutputBytes: 262144,
14339
14547
  capabilities: ["fs.read"],
14340
14548
  icon: "diff",
14341
14549
  timeoutMs: 1e4,
@@ -14411,8 +14619,8 @@ function findGitDir(cwd) {
14411
14619
  let dir = cwd;
14412
14620
  for (let i = 0; i < 20; i++) {
14413
14621
  try {
14414
- const stat17 = statSync3(path26.join(dir, ".git"));
14415
- if (stat17.isDirectory()) return dir;
14622
+ const stat18 = statSync3(path26.join(dir, ".git"));
14623
+ if (stat18.isDirectory()) return dir;
14416
14624
  } catch {
14417
14625
  }
14418
14626
  const parent = path26.dirname(dir);
@@ -14454,10 +14662,18 @@ async function fileDiff(input, ctx, _signal) {
14454
14662
  };
14455
14663
  }
14456
14664
  const results = [];
14665
+ let truncated = false;
14457
14666
  for (const file of files) {
14458
14667
  const absPath = safeResolve(file, ctx);
14459
- const stat17 = await fs21.stat(absPath).catch(() => null);
14460
- 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
+ }
14461
14677
  const content = await fs21.readFile(absPath, "utf8");
14462
14678
  const lines = content.split(/\r?\n/);
14463
14679
  results.push(formatWithLineNumbers(file, lines));
@@ -14465,7 +14681,7 @@ async function fileDiff(input, ctx, _signal) {
14465
14681
  return {
14466
14682
  diff: results.join("\n\n"),
14467
14683
  files,
14468
- truncated: false,
14684
+ truncated,
14469
14685
  mode: input.mode ?? "unified"
14470
14686
  };
14471
14687
  }
@@ -14558,8 +14774,8 @@ async function resolveFiles(filesInput, cwd) {
14558
14774
  for (const f of files) {
14559
14775
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
14560
14776
  try {
14561
- const stat17 = await fs22.stat(absPath);
14562
- if (stat17.isFile()) resolved.push(absPath);
14777
+ const stat18 = await fs22.stat(absPath);
14778
+ if (stat18.isFile()) resolved.push(absPath);
14563
14779
  } catch {
14564
14780
  }
14565
14781
  }
@@ -15412,7 +15628,7 @@ var editTool = {
15412
15628
  });
15413
15629
  }
15414
15630
  const absPath = await safeResolveReal(input.path, ctx);
15415
- const stat17 = await fs23.stat(absPath).catch((err) => {
15631
+ const stat18 = await fs23.stat(absPath).catch((err) => {
15416
15632
  if (err.code === "ENOENT") {
15417
15633
  throw new ToolValidationError({
15418
15634
  message: `edit: file "${input.path}" does not exist. Use \`write\` instead.`,
@@ -15422,7 +15638,7 @@ var editTool = {
15422
15638
  }
15423
15639
  throw err;
15424
15640
  });
15425
- if (!stat17.isFile()) {
15641
+ if (!stat18.isFile()) {
15426
15642
  throw new ToolValidationError({
15427
15643
  message: `edit: "${input.path}" is not a regular file`,
15428
15644
  field: "path"
@@ -15452,7 +15668,7 @@ var editTool = {
15452
15668
  });
15453
15669
  }
15454
15670
  }
15455
- if (autoRead && updated.mtimeMs > stat17.mtimeMs + mtimeTolerance) {
15671
+ if (autoRead && updated.mtimeMs > stat18.mtimeMs + mtimeTolerance) {
15456
15672
  throw new ToolValidationError({
15457
15673
  message: `edit: file "${input.path}" changed while being auto-read. Retry the edit.`,
15458
15674
  field: "path",
@@ -15575,10 +15791,6 @@ import {
15575
15791
  emitProcessStarted as emitProcessStarted3
15576
15792
  } from "@wrongstack/core/observability";
15577
15793
  import { toErrorMessage as toErrorMessage3 } from "@wrongstack/core/utils/error";
15578
- init_output_spool();
15579
- init_util();
15580
- init_process_registry();
15581
- init_win32_resolve();
15582
15794
 
15583
15795
  // src/_danger-detect.ts
15584
15796
  var argHas = (args, value) => args.includes(value);
@@ -15830,6 +16042,48 @@ var RULES = [
15830
16042
  return args.some((a) => /^[0-7]{3,4}$/.test(a) && /7/.test(a));
15831
16043
  },
15832
16044
  reason: "chmod with world-writable octal mode (e.g. 777)"
16045
+ },
16046
+ // ----- network recon / offensive scanners (caution) -----
16047
+ // These tools are purpose-built for network reconnaissance and offensive
16048
+ // security. They have legitimate uses in dev (security testing, infra
16049
+ // debugging) but their presence in an agent's command stream is worth a
16050
+ // caution banner so the user can notice an unexpected scan.
16051
+ {
16052
+ id: "network-scanner",
16053
+ level: "caution",
16054
+ test: (cmd) => cmd === "nmap" || cmd === "masscan" || cmd === "zmap" || cmd === "nuclei" || cmd === "hping3" || cmd === "naabu" || cmd === "katana" || cmd === "amass" || cmd === "subfinder" || cmd === "httpx" || cmd === "rustscan" || cmd === "zgrab",
16055
+ reason: "network scanner / offensive reconnaissance tool"
16056
+ },
16057
+ // ----- process termination (caution) -----
16058
+ // kill/killall/pkill terminate processes. The exec kill guard
16059
+ // (exec-kill-guard.ts) hard-blocks attempts on protected WrongStack
16060
+ // processes; this rule adds a caution banner for generic process
16061
+ // termination so the user sees a warning even for non-protected targets.
16062
+ {
16063
+ id: "process-kill",
16064
+ level: "caution",
16065
+ test: (cmd) => cmd === "kill" || cmd === "killall" || cmd === "pkill",
16066
+ reason: "process termination command"
16067
+ },
16068
+ // ----- shell/interpreter launchers (caution) -----
16069
+ // `env bash -c '…'`, `timeout sh`, `nohup perl -e '…'` etc. wrap a child
16070
+ // binary that bypasses the allowlist name-gate entirely (the wrapper
16071
+ // resolves the child from PATH, not from the allowlist). We flag caution
16072
+ // when a known launcher has a shell/interpreter as its target program.
16073
+ {
16074
+ id: "shell-launcher",
16075
+ level: "caution",
16076
+ test: (cmd, args) => {
16077
+ const launchers = ["env", "timeout", "nohup", "nice", "script", "expect", "tmux", "screen", "byobu", "dtach"];
16078
+ if (!launchers.includes(cmd)) return false;
16079
+ const shells = ["sh", "bash", "zsh", "fish", "python", "python3", "perl", "ruby", "node", "pwsh", "powershell", "cmd"];
16080
+ if (cmd === "env") {
16081
+ const prog = args.find((a) => !a.includes("=") && !a.startsWith("-"));
16082
+ return prog !== void 0 && shells.includes(prog);
16083
+ }
16084
+ return args.some((a) => shells.includes(a));
16085
+ },
16086
+ reason: "launcher wrapping a shell/interpreter (bypasses allowlist name-gate)"
15833
16087
  }
15834
16088
  ];
15835
16089
  function detectDanger(cmd, args, bypass) {
@@ -15861,6 +16115,11 @@ function levelRank(level) {
15861
16115
  }
15862
16116
  }
15863
16117
 
16118
+ // src/exec.ts
16119
+ init_output_spool();
16120
+ init_util();
16121
+ init_win32_resolve();
16122
+
15864
16123
  // src/exec-kill-guard.ts
15865
16124
  import * as os8 from "node:os";
15866
16125
  import * as path29 from "node:path";
@@ -16096,8 +16355,7 @@ async function checkKillTarget(target) {
16096
16355
  return { blocked: false };
16097
16356
  }
16098
16357
 
16099
- // src/exec.ts
16100
- var isWin4 = process.platform === "win32";
16358
+ // src/exec-allowlist.ts
16101
16359
  var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16102
16360
  // JS / TS toolchain
16103
16361
  "node",
@@ -16176,7 +16434,11 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16176
16434
  "docker",
16177
16435
  "podman",
16178
16436
  "kubectl",
16179
- // network (read-only intent; destructive ops still blocked by BLOCKED_ARG_PATTERNS)
16437
+ // network fetch tools. Per-arg safety is NOT enforced by BLOCKED_ARG_PATTERNS
16438
+ // (no curl/wget entries) — the real boundary is the per-call `confirm`
16439
+ // permission gate (exec.ts permission:'confirm', subjectKey:'command'),
16440
+ // the `pipe-to-shell` danger rule, and the kill guard. The allowlist is a
16441
+ // guidance layer, not a security boundary on its own.
16180
16442
  "curl",
16181
16443
  "wget",
16182
16444
  // common POSIX file/text utilities
@@ -16229,9 +16491,15 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16229
16491
  "powershell.exe",
16230
16492
  "pwsh",
16231
16493
  "pwsh.exe",
16232
- // [core] Extended default allowlist (added 4b3d18d1 + this commit). All
16233
- // non-destructive, broadly-used dev binaries. Per-arg safety is still
16234
- // enforced by BLOCKED_ARG_PATTERNS + bash-kill-guard.ts.
16494
+ // [core] Extended default allowlist (added 4b3d18d1 + this commit). Broadly
16495
+ // used dev binaries — NOT all non-destructive: this section contains
16496
+ // process/system tools (kill, chown, mount, userdel), network scanners
16497
+ // (nmap, masscan, zmap, nuclei), and launchers (env, perl, timeout, tmux).
16498
+ // The real safety boundary for these is the per-call `confirm` permission
16499
+ // gate (exec.ts permission:'confirm', subjectKey:'command' renders the full
16500
+ // invocation) plus the danger rules in _danger-detect.ts. Per-arg hard-deny
16501
+ // is still enforced by BLOCKED_ARG_PATTERNS + bash-kill-guard.ts, but only
16502
+ // for the commands that have entries there (git, find, rm).
16235
16503
  // --- Archives & compression ---
16236
16504
  "7z",
16237
16505
  "7za",
@@ -16403,10 +16671,7 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16403
16671
  "git-lfs",
16404
16672
  "tig",
16405
16673
  "lazygit",
16406
- // --- POSIX text utilities (extended; duplicates of pwd/ls/cat/head/tail/wc/
16407
- // grep/find/echo/awk/mkdir/cp/mv/rm/touch from the base list above are
16408
- // omitted — the Set is de-duplicated at runtime but a clean literal is
16409
- // easier to maintain) ---
16674
+ // --- POSIX text utilities (extended) ---
16410
16675
  "gawk",
16411
16676
  "tr",
16412
16677
  "cut",
@@ -16439,39 +16704,16 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16439
16704
  "locate",
16440
16705
  "which",
16441
16706
  "whereis",
16442
- "type",
16443
- "hash",
16444
- "pushd",
16445
- "popd",
16446
- "dirs",
16447
- "history",
16448
- "fc",
16449
- "jobs",
16450
- "bg",
16451
- "fg",
16452
- "wait",
16453
- "ulimit",
16454
- "umask",
16707
+ // NOTE: shell builtins with no standalone binary (type, hash, pushd,
16708
+ // popd, dirs, history, fc, jobs, bg, fg, wait, ulimit, umask, trap,
16709
+ // exit, return, source, ., alias, unalias, set, unset, export, readonly,
16710
+ // typeset, declare, local, eval, exec) are intentionally excluded —
16711
+ // spawn() would fail with ENOENT on every platform because they only
16712
+ // exist inside a shell process. Use the `bash` tool for builtins.
16455
16713
  "nice",
16456
16714
  "nohup",
16457
16715
  "timeout",
16458
16716
  "time",
16459
- "trap",
16460
- "exit",
16461
- "return",
16462
- "source",
16463
- ".",
16464
- "alias",
16465
- "unalias",
16466
- "set",
16467
- "unset",
16468
- "export",
16469
- "readonly",
16470
- "typeset",
16471
- "declare",
16472
- "local",
16473
- "eval",
16474
- "exec",
16475
16717
  // --- Process / system inspection ---
16476
16718
  "htop",
16477
16719
  "top",
@@ -16702,6 +16944,10 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16702
16944
  "sublert",
16703
16945
  "chaos"
16704
16946
  ]);
16947
+
16948
+ // src/exec.ts
16949
+ init_process_registry();
16950
+ var isWin4 = process.platform === "win32";
16705
16951
  var allowedCommands = new Set(DEFAULT_ALLOWED_COMMANDS);
16706
16952
  var normalizeCmd = (c) => c.trim();
16707
16953
  var dangerBypass = /* @__PURE__ */ new Set();
@@ -16733,7 +16979,17 @@ var BLOCKED_ARG_PATTERNS = {
16733
16979
  bun: [],
16734
16980
  docker: [],
16735
16981
  // find -exec/-ok/-execdir execute arbitrary commands
16736
- find: [/^-exec$/, /^-exec;$/, /^-ok$/, /^-ok;$/, /^-execdir$/, /^-execdir;$/, /^-exec=/, /^-ok=/, /^-execdir=/],
16982
+ find: [
16983
+ /^-exec$/,
16984
+ /^-exec;$/,
16985
+ /^-ok$/,
16986
+ /^-ok;$/,
16987
+ /^-execdir$/,
16988
+ /^-execdir;$/,
16989
+ /^-exec=/,
16990
+ /^-ok=/,
16991
+ /^-execdir=/
16992
+ ],
16737
16993
  // rm -rf / is catastrophic — block absolute paths, home, dot-dirs,
16738
16994
  // and glob patterns that could expand to dangerous targets.
16739
16995
  // `rm -rf ./src/*` expands to project files; `rm -rf ../../` escapes upward;
@@ -16804,6 +17060,14 @@ var execTool = {
16804
17060
  useInstead: ["bash"]
16805
17061
  },
16806
17062
  permission: "confirm",
17063
+ // WS-046: without this, every exec call collapsed onto the bare tool name.
17064
+ // Three consequences followed: "always allow" stored a rule that could never
17065
+ // match, pressing "no" once blocked ALL exec for the session, and the
17066
+ // permission cache keyed one decision for every command. `subjectKey:
17067
+ // 'command'` renders the FULL invocation (`command` + `args`) — using the
17068
+ // program alone would make `exec git status` and `exec git push --force` the
17069
+ // same subject, so trusting one would silently authorize the other.
17070
+ subjectKey: "command",
16807
17071
  mutating: true,
16808
17072
  riskTier: "standard",
16809
17073
  timeoutMs: DEFAULT_TIMEOUT_MS3,
@@ -17020,7 +17284,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId, danger) {
17020
17284
  const pid = child.pid;
17021
17285
  if (typeof pid === "number") {
17022
17286
  const fullCommand = `${cmd} ${args.join(" ")}`;
17023
- registry.register({ pid, name: "exec", command: redactCommand(fullCommand), startedAt: Date.now(), sessionId, child });
17287
+ registry.register({
17288
+ pid,
17289
+ name: "exec",
17290
+ command: redactCommand(fullCommand),
17291
+ startedAt: Date.now(),
17292
+ sessionId,
17293
+ child
17294
+ });
17024
17295
  }
17025
17296
  const timer = setTimeout(() => {
17026
17297
  killed = true;
@@ -17460,6 +17731,9 @@ var formatTool = {
17460
17731
  description: "Format source files according to project style (Biome). Can also run in check-only mode.",
17461
17732
  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.",
17462
17733
  permission: "confirm",
17734
+ // WS-046: gives permission decisions something to key on.
17735
+ // The files being rewritten are the subject; the fixer is how, not what.
17736
+ subjectKey: "files",
17463
17737
  mutating: true,
17464
17738
  capabilities: ["fs.write", "shell.restricted"],
17465
17739
  icon: "code",
@@ -17564,13 +17838,13 @@ var formatTool = {
17564
17838
  }
17565
17839
  };
17566
17840
  async function detectFixer(cwd) {
17567
- const { stat: stat17 } = await import("node:fs/promises");
17841
+ const { stat: stat18 } = await import("node:fs/promises");
17568
17842
  try {
17569
- await stat17(`${cwd}/biome.json`);
17843
+ await stat18(`${cwd}/biome.json`);
17570
17844
  return "biome";
17571
17845
  } catch {
17572
17846
  try {
17573
- await stat17(`${cwd}/.prettierrc`);
17847
+ await stat18(`${cwd}/.prettierrc`);
17574
17848
  return "prettier";
17575
17849
  } catch {
17576
17850
  return "biome";
@@ -17597,6 +17871,10 @@ var gitTool = {
17597
17871
  // Conservative: any of these may mutate. The non-mutating commands
17598
17872
  // (status/log/diff/branch/fetch) are still gated on `permission: 'confirm'`
17599
17873
  // and `MUTATING_SUBCOMMANDS` is consulted at runtime for per-call checks.
17874
+ // WS-046: gives permission decisions something to key on.
17875
+ // The git subcommand (status/commit/push) is what a trust rule needs to
17876
+ // distinguish; `git status` and `git push --force` must not share a subject.
17877
+ subjectKey: "command",
17600
17878
  mutating: true,
17601
17879
  capabilities: ["fs.write", "shell.restricted"],
17602
17880
  timeoutMs: TIMEOUT_MS2,
@@ -17665,6 +17943,8 @@ var gitTool = {
17665
17943
  truncated: false
17666
17944
  };
17667
17945
  }
17946
+ const branchGuard = validateBranchInput(input);
17947
+ if (branchGuard) return branchGuard;
17668
17948
  if (input.command === "worktree") {
17669
17949
  const guard = validateWorktreeInput(input, ctx.projectRoot);
17670
17950
  if (guard) return guard;
@@ -17713,6 +17993,18 @@ var gitTool = {
17713
17993
  return result;
17714
17994
  }
17715
17995
  };
17996
+ function validateBranchInput(input) {
17997
+ const branch = input.branch;
17998
+ if (branch === void 0) return null;
17999
+ if (!branch.startsWith("-") && !branch.includes(" --")) return null;
18000
+ return {
18001
+ command: input.command,
18002
+ stdout: "",
18003
+ stderr: `unsafe branch name (parsed as a git option): ${branch}`,
18004
+ exitCode: 1,
18005
+ truncated: false
18006
+ };
18007
+ }
17716
18008
  function validateWorktreeInput(input, projectRoot) {
17717
18009
  const reject = (stderr) => ({
17718
18010
  command: "worktree",
@@ -17721,7 +18013,6 @@ function validateWorktreeInput(input, projectRoot) {
17721
18013
  exitCode: 1,
17722
18014
  truncated: false
17723
18015
  });
17724
- if (input.branch?.startsWith("-")) return reject(`unsafe branch name: ${input.branch}`);
17725
18016
  if (input.worktreePath?.startsWith("-")) {
17726
18017
  return reject(`unsafe worktree path: ${input.worktreePath}`);
17727
18018
  }
@@ -17739,8 +18030,8 @@ function findGitDir2(cwd, projectRoot) {
17739
18030
  let dir = cwd;
17740
18031
  for (let i = 0; i < 20; i++) {
17741
18032
  try {
17742
- const stat17 = statSync4(`${dir}/.git`);
17743
- if (stat17.isDirectory() || stat17.isFile()) return dir;
18033
+ const stat18 = statSync4(`${dir}/.git`);
18034
+ if (stat18.isDirectory() || stat18.isFile()) return dir;
17744
18035
  } catch {
17745
18036
  }
17746
18037
  if (dir === root) break;
@@ -17775,7 +18066,10 @@ function buildArgs(input) {
17775
18066
  ...files.length ? ["--", ...files] : []
17776
18067
  ];
17777
18068
  case "branch":
17778
- return input.branch ? ["branch", ...input.branch.startsWith("-") || input.branch.includes(" --") ? [] : [input.branch]] : ["branch"];
18069
+ return input.branch ? [
18070
+ "branch",
18071
+ ...input.branch.startsWith("-") || input.branch.includes(" --") ? [] : [input.branch]
18072
+ ] : ["branch"];
17779
18073
  case "checkout":
17780
18074
  return [
17781
18075
  "checkout",
@@ -17868,7 +18162,7 @@ import * as path30 from "node:path";
17868
18162
  import { compileGlob as compileGlob2, DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS2 } from "@wrongstack/core/utils";
17869
18163
 
17870
18164
  // src/_concurrency.ts
17871
- async function mapWithConcurrency(items, limit, fn) {
18165
+ async function mapWithConcurrency2(items, limit, fn) {
17872
18166
  if (items.length === 0) return [];
17873
18167
  const effectiveLimit = Math.max(1, Math.min(limit | 0, items.length));
17874
18168
  const results = new Array(items.length);
@@ -18003,10 +18297,10 @@ var globTool = {
18003
18297
  }
18004
18298
  if (truncated) return;
18005
18299
  }
18006
- await mapWithConcurrency(matchedFiles, WALK_CONCURRENCY, pushResult);
18300
+ await mapWithConcurrency2(matchedFiles, WALK_CONCURRENCY, pushResult);
18007
18301
  if (truncated) return;
18008
18302
  const remainingSubdirs = truncated ? [] : subdirs;
18009
- await mapWithConcurrency(remainingSubdirs, WALK_CONCURRENCY, ({ full, rel }) => walk(full, rel));
18303
+ await mapWithConcurrency2(remainingSubdirs, WALK_CONCURRENCY, ({ full, rel }) => walk(full, rel));
18010
18304
  };
18011
18305
  await walk(base, "");
18012
18306
  results.sort((a, b) => b.mtime - a.mtime);
@@ -18015,12 +18309,16 @@ var globTool = {
18015
18309
  };
18016
18310
 
18017
18311
  // src/grep.ts
18018
- import { expectDefined as expectDefined7 } from "@wrongstack/core/utils";
18019
18312
  import { spawn as spawn11 } from "node:child_process";
18020
18313
  import * as fs25 from "node:fs/promises";
18021
18314
  import * as path31 from "node:path";
18022
18315
  import { ToolValidationError as ToolValidationError4 } from "@wrongstack/core/types";
18023
- import { buildChildEnv as buildChildEnv5, compileGlob as compileGlob3, DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS3 } from "@wrongstack/core/utils";
18316
+ import {
18317
+ buildChildEnv as buildChildEnv5,
18318
+ compileGlob as compileGlob3,
18319
+ DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS3,
18320
+ expectDefined as expectDefined7
18321
+ } from "@wrongstack/core/utils";
18024
18322
 
18025
18323
  // src/_regex.ts
18026
18324
  var MAX_PATTERN_LEN = 256;
@@ -18073,6 +18371,8 @@ var DEFAULT_IGNORE3 = DEFAULT_WALK_IGNORE_DIRS3;
18073
18371
  var NATIVE_SCAN_CONCURRENCY = 32;
18074
18372
  var NATIVE_READ_CHUNK_BYTES = 64 * 1024;
18075
18373
  var NATIVE_MAX_FILE_BYTES = 1e6;
18374
+ var RG_MAX_QUEUE_CHUNKS = 128;
18375
+ var RG_MAX_QUEUE_CHARS = 8 * 1024 * 1024;
18076
18376
  var grepTool = {
18077
18377
  name: "grep",
18078
18378
  category: "Search",
@@ -18200,9 +18500,19 @@ async function* runRgStream(input, base, mode, limit, signal) {
18200
18500
  const FLUSH_AT = 16;
18201
18501
  const MAX_BUF_BYTES = 1e6;
18202
18502
  let bufOverflow = false;
18203
- const child = spawn11("rg", args, { signal, env: buildChildEnv5(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
18503
+ const child = spawn11("rg", args, {
18504
+ signal,
18505
+ env: buildChildEnv5(),
18506
+ // rg diagnostics are not part of the tool result. Ignoring stderr avoids
18507
+ // an unread pipe filling up and pinning the child when rg reports many
18508
+ // filesystem errors.
18509
+ stdio: ["ignore", "pipe", "ignore"],
18510
+ windowsHide: true
18511
+ });
18204
18512
  const queue = [];
18513
+ let queuedChars = 0;
18205
18514
  let waiter;
18515
+ let paused = false;
18206
18516
  const wake = () => {
18207
18517
  if (waiter) {
18208
18518
  const w = waiter;
@@ -18210,93 +18520,133 @@ async function* runRgStream(input, base, mode, limit, signal) {
18210
18520
  w();
18211
18521
  }
18212
18522
  };
18213
- child.stdout?.on("data", (c) => {
18214
- queue.push({ kind: "out", data: c.toString() });
18523
+ const pauseIfFlooded = () => {
18524
+ if (paused || queue.length < RG_MAX_QUEUE_CHUNKS && queuedChars < RG_MAX_QUEUE_CHARS)
18525
+ return;
18526
+ paused = true;
18527
+ child.stdout?.pause();
18528
+ };
18529
+ const resumeIfDrained = () => {
18530
+ if (!paused || queue.length >= RG_MAX_QUEUE_CHUNKS || queuedChars >= RG_MAX_QUEUE_CHARS)
18531
+ return;
18532
+ paused = false;
18533
+ child.stdout?.resume();
18534
+ };
18535
+ const onStdoutData = (c) => {
18536
+ const data = c.toString();
18537
+ queue.push({ kind: "out", data });
18538
+ queuedChars += data.length;
18215
18539
  wake();
18216
- });
18217
- child.on("error", (e) => {
18540
+ pauseIfFlooded();
18541
+ };
18542
+ const onError = (e) => {
18218
18543
  queue.push({ kind: "error", data: e.message });
18219
18544
  wake();
18220
- });
18221
- child.on("close", () => {
18545
+ };
18546
+ const onClose = () => {
18222
18547
  queue.push({ kind: "close", data: "" });
18223
18548
  wake();
18224
- });
18549
+ };
18550
+ child.stdout?.on("data", onStdoutData);
18551
+ child.on("error", onError);
18552
+ child.on("close", onClose);
18225
18553
  let pendingBatch = [];
18226
18554
  let errored = false;
18227
- for (; ; ) {
18228
- while (queue.length === 0) {
18229
- await new Promise((r) => {
18230
- waiter = r;
18231
- });
18232
- }
18233
- const c = expectDefined7(queue.shift());
18234
- if (c.kind === "error") {
18235
- errored = true;
18236
- continue;
18237
- }
18238
- if (c.kind === "close") break;
18239
- buf += c.data;
18240
- if (buf.length > MAX_BUF_BYTES && !bufOverflow) {
18241
- bufOverflow = true;
18242
- buf = buf.slice(-MAX_BUF_BYTES);
18243
- try {
18244
- child.kill("SIGTERM");
18245
- } catch {
18555
+ let closed = false;
18556
+ try {
18557
+ for (; ; ) {
18558
+ while (queue.length === 0) {
18559
+ await new Promise((r) => {
18560
+ waiter = r;
18561
+ });
18246
18562
  }
18247
- }
18248
- const idx = buf.lastIndexOf("\n");
18249
- if (idx === -1) continue;
18250
- const ready = buf.slice(0, idx);
18251
- buf = buf.slice(idx + 1);
18252
- for (const line of ready.split("\n")) {
18253
- if (!line) continue;
18254
- totalLines++;
18255
- if (mode === "count") totalCount += parseRgCountLine(line);
18256
- if (matches.length < limit) {
18257
- matches.push(line);
18258
- pendingBatch.push(line);
18259
- batchSinceFlush++;
18563
+ const c = expectDefined7(queue.shift());
18564
+ queuedChars = Math.max(0, queuedChars - c.data.length);
18565
+ resumeIfDrained();
18566
+ if (c.kind === "error") {
18567
+ errored = true;
18568
+ continue;
18569
+ }
18570
+ if (c.kind === "close") {
18571
+ closed = true;
18572
+ break;
18573
+ }
18574
+ buf += c.data;
18575
+ if (buf.length > MAX_BUF_BYTES && !bufOverflow) {
18576
+ bufOverflow = true;
18577
+ buf = buf.slice(-MAX_BUF_BYTES);
18578
+ try {
18579
+ child.kill("SIGTERM");
18580
+ } catch {
18581
+ }
18582
+ }
18583
+ const idx = buf.lastIndexOf("\n");
18584
+ if (idx === -1) continue;
18585
+ const ready = buf.slice(0, idx);
18586
+ buf = buf.slice(idx + 1);
18587
+ for (const line of ready.split("\n")) {
18588
+ if (!line) continue;
18589
+ totalLines++;
18590
+ if (mode === "count") totalCount += parseRgCountLine(line);
18591
+ if (matches.length < limit) {
18592
+ matches.push(line);
18593
+ pendingBatch.push(line);
18594
+ batchSinceFlush++;
18595
+ }
18596
+ }
18597
+ if (batchSinceFlush >= FLUSH_AT) {
18598
+ yield {
18599
+ type: "partial_output",
18600
+ text: pendingBatch.join("\n"),
18601
+ data: { matches_so_far: matches.length }
18602
+ };
18603
+ pendingBatch = [];
18604
+ batchSinceFlush = 0;
18605
+ }
18606
+ }
18607
+ if (buf.trim()) {
18608
+ for (const line of buf.split("\n")) {
18609
+ if (!line) continue;
18610
+ totalLines++;
18611
+ if (mode === "count") totalCount += parseRgCountLine(line);
18612
+ if (matches.length < limit) {
18613
+ matches.push(line);
18614
+ pendingBatch.push(line);
18615
+ }
18260
18616
  }
18261
18617
  }
18262
- if (batchSinceFlush >= FLUSH_AT) {
18618
+ if (pendingBatch.length > 0) {
18263
18619
  yield {
18264
18620
  type: "partial_output",
18265
18621
  text: pendingBatch.join("\n"),
18266
18622
  data: { matches_so_far: matches.length }
18267
18623
  };
18268
- pendingBatch = [];
18269
- batchSinceFlush = 0;
18270
- }
18271
- }
18272
- if (buf.trim()) {
18273
- for (const line of buf.split("\n")) {
18274
- if (!line) continue;
18275
- totalLines++;
18276
- if (mode === "count") totalCount += parseRgCountLine(line);
18277
- if (matches.length < limit) {
18278
- matches.push(line);
18279
- pendingBatch.push(line);
18280
- }
18281
18624
  }
18282
- }
18283
- if (pendingBatch.length > 0) {
18625
+ if (errored) throw new Error("rg: spawn error");
18284
18626
  yield {
18285
- type: "partial_output",
18286
- text: pendingBatch.join("\n"),
18287
- data: { matches_so_far: matches.length }
18627
+ type: "final",
18628
+ output: {
18629
+ matches,
18630
+ count: mode === "count" ? totalCount : totalLines,
18631
+ truncated: totalLines > limit || bufOverflow,
18632
+ used: "rg"
18633
+ }
18288
18634
  };
18289
- }
18290
- if (errored) throw new Error("rg: spawn error");
18291
- yield {
18292
- type: "final",
18293
- output: {
18294
- matches,
18295
- count: mode === "count" ? totalCount : totalLines,
18296
- truncated: totalLines > limit || bufOverflow,
18297
- used: "rg"
18635
+ } finally {
18636
+ child.stdout?.off("data", onStdoutData);
18637
+ child.off("error", onError);
18638
+ child.off("close", onClose);
18639
+ child.stdout?.destroy();
18640
+ queue.length = 0;
18641
+ queuedChars = 0;
18642
+ waiter = void 0;
18643
+ if (!closed && child.exitCode === null) {
18644
+ try {
18645
+ child.kill("SIGTERM");
18646
+ } catch {
18647
+ }
18298
18648
  }
18299
- };
18649
+ }
18300
18650
  }
18301
18651
  function parseRgCountLine(line) {
18302
18652
  const idx = line.lastIndexOf(":");
@@ -18326,8 +18676,8 @@ async function runNative(input, base, mode, limit, signal) {
18326
18676
  if (globRe && !globRe.test(name) && !globRe.test(full)) return;
18327
18677
  if (globRe) globRe.lastIndex = 0;
18328
18678
  try {
18329
- const stat17 = await fs25.stat(full);
18330
- if (!stat17.isFile() || stat17.size > maxBytes || stopped || signal.aborted) return;
18679
+ const stat18 = await fs25.stat(full);
18680
+ if (!stat18.isFile() || stat18.size > maxBytes || stopped || signal.aborted) return;
18331
18681
  const file = await fs25.open(full, "r");
18332
18682
  try {
18333
18683
  let bytesReadTotal = 0;
@@ -18427,8 +18777,8 @@ async function runNative(input, base, mode, limit, signal) {
18427
18777
  files.push({ full, name: e.name });
18428
18778
  }
18429
18779
  }
18430
- await mapWithConcurrency(files, NATIVE_SCAN_CONCURRENCY, ({ full, name }) => scanFile(full, name));
18431
- await mapWithConcurrency(
18780
+ await mapWithConcurrency2(files, NATIVE_SCAN_CONCURRENCY, ({ full, name }) => scanFile(full, name));
18781
+ await mapWithConcurrency2(
18432
18782
  subdirs,
18433
18783
  Math.min(16, NATIVE_SCAN_CONCURRENCY),
18434
18784
  ({ full, rel }) => walk(full, rel)
@@ -18447,7 +18797,7 @@ async function runNative(input, base, mode, limit, signal) {
18447
18797
  init_spawn_stream();
18448
18798
  init_util();
18449
18799
  init_legacy_bridge();
18450
- import { join as join24 } from "node:path";
18800
+ import { join as join25 } from "node:path";
18451
18801
  import {
18452
18802
  detectEcosystem as detectPackageEcosystem,
18453
18803
  recordPackageAction
@@ -18458,6 +18808,10 @@ var installTool = {
18458
18808
  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.",
18459
18809
  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.",
18460
18810
  permission: "confirm",
18811
+ // WS-046: gives permission decisions something to key on.
18812
+ // What gets installed is the subject — installing a package runs its
18813
+ // lifecycle scripts, so `express` and `evil-pkg` must not be interchangeable.
18814
+ subjectKey: "packages",
18461
18815
  mutating: true,
18462
18816
  riskTier: "standard",
18463
18817
  icon: "package",
@@ -18627,10 +18981,10 @@ function resolveManifestPath(cwd, pkgManager) {
18627
18981
  case "pnpm":
18628
18982
  case "yarn":
18629
18983
  case "npm":
18630
- return join24(cwd, "package.json");
18984
+ return join25(cwd, "package.json");
18631
18985
  /* v8 ignore next 2 -- pkgManager is always pnpm/yarn/npm; the default is defensive. */
18632
18986
  default:
18633
- return join24(cwd, "package.json");
18987
+ return join25(cwd, "package.json");
18634
18988
  }
18635
18989
  }
18636
18990
 
@@ -18638,6 +18992,24 @@ function resolveManifestPath(cwd, pkgManager) {
18638
18992
  init_util();
18639
18993
  import * as fs26 from "node:fs/promises";
18640
18994
  import { deepMerge as deepMergeCore } from "@wrongstack/core/utils";
18995
+ var MAX_JSON_FILE_BYTES = 16 * 1024 * 1024;
18996
+ var MAX_JSON_FILE_BYTES_HUMAN = "16 MiB";
18997
+ var JsonFileTooLargeError = class extends Error {
18998
+ constructor(filePath, size) {
18999
+ super(
19000
+ `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.`
19001
+ );
19002
+ this.name = "JsonFileTooLargeError";
19003
+ }
19004
+ };
19005
+ async function readJsonFileBounded(filePath, ctx) {
19006
+ const resolved = await safeResolveReal(filePath, ctx);
19007
+ const stat18 = await fs26.stat(resolved);
19008
+ if (stat18.size > MAX_JSON_FILE_BYTES) {
19009
+ throw new JsonFileTooLargeError(filePath, stat18.size);
19010
+ }
19011
+ return fs26.readFile(resolved, "utf8");
19012
+ }
18641
19013
  var jsonTool = {
18642
19014
  name: "json",
18643
19015
  category: "Data",
@@ -18645,6 +19017,7 @@ var jsonTool = {
18645
19017
  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.',
18646
19018
  permission: "auto",
18647
19019
  mutating: false,
19020
+ maxOutputBytes: 262144,
18648
19021
  timeoutMs: 5e3,
18649
19022
  capabilities: ["fs.read"],
18650
19023
  icon: "json",
@@ -18711,9 +19084,15 @@ async function executeParse(input, ctx) {
18711
19084
  let raw;
18712
19085
  if (input.file) {
18713
19086
  try {
18714
- raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
18715
- } catch {
18716
- return { data: null, formatted: "", type: "unknown", action: "parse", error: "Could not read file" };
19087
+ raw = await readJsonFileBounded(input.file, ctx);
19088
+ } catch (error) {
19089
+ return {
19090
+ data: null,
19091
+ formatted: "",
19092
+ type: "unknown",
19093
+ action: "parse",
19094
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read file"
19095
+ };
18717
19096
  }
18718
19097
  } else if (input.data) {
18719
19098
  raw = input.data;
@@ -18769,10 +19148,16 @@ async function executeQuery(input, ctx) {
18769
19148
  let parsed;
18770
19149
  if (input.file) {
18771
19150
  try {
18772
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19151
+ const raw = await readJsonFileBounded(input.file, ctx);
18773
19152
  parsed = JSON.parse(raw);
18774
- } catch {
18775
- return { data: null, formatted: "", type: "unknown", action: "query", error: "Could not read/parse file" };
19153
+ } catch (error) {
19154
+ return {
19155
+ data: null,
19156
+ formatted: "",
19157
+ type: "unknown",
19158
+ action: "query",
19159
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19160
+ };
18776
19161
  }
18777
19162
  } else if (input.data) {
18778
19163
  try {
@@ -18811,10 +19196,16 @@ async function executeValidate(input, ctx) {
18811
19196
  let parsed;
18812
19197
  if (input.file) {
18813
19198
  try {
18814
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19199
+ const raw = await readJsonFileBounded(input.file, ctx);
18815
19200
  parsed = JSON.parse(raw);
18816
- } catch {
18817
- return { data: null, formatted: "", type: "unknown", action: "validate", error: "Could not read/parse file" };
19201
+ } catch (error) {
19202
+ return {
19203
+ data: null,
19204
+ formatted: "",
19205
+ type: "unknown",
19206
+ action: "validate",
19207
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19208
+ };
18818
19209
  }
18819
19210
  } else if (input.data) {
18820
19211
  try {
@@ -18853,10 +19244,16 @@ async function executeTransform(input, ctx) {
18853
19244
  let parsed;
18854
19245
  if (input.file) {
18855
19246
  try {
18856
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19247
+ const raw = await readJsonFileBounded(input.file, ctx);
18857
19248
  parsed = JSON.parse(raw);
18858
- } catch {
18859
- return { data: null, formatted: "", type: "unknown", action: "transform", error: "Could not read/parse file" };
19249
+ } catch (error) {
19250
+ return {
19251
+ data: null,
19252
+ formatted: "",
19253
+ type: "unknown",
19254
+ action: "transform",
19255
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19256
+ };
18860
19257
  }
18861
19258
  } else if (input.data) {
18862
19259
  try {
@@ -19460,6 +19857,10 @@ async function handleKanbanDetailAction(projectRoot, input) {
19460
19857
  }
19461
19858
 
19462
19859
  // src/kanban-board-inputs.ts
19860
+ function agentSettableGate(enforcement) {
19861
+ if (enforcement === void 0 || enforcement === "off") return {};
19862
+ return { completionGate: { enforcement } };
19863
+ }
19463
19864
  function boardCreateInput(input, title) {
19464
19865
  return {
19465
19866
  title,
@@ -19472,7 +19873,7 @@ function boardCreateInput(input, title) {
19472
19873
  decomposition: input.atomicityDecomposition ?? "propose"
19473
19874
  }
19474
19875
  } : {},
19475
- ...input.gateEnforcement !== void 0 ? { completionGate: { enforcement: input.gateEnforcement } } : {}
19876
+ ...agentSettableGate(input.gateEnforcement)
19476
19877
  };
19477
19878
  }
19478
19879
  function boardUpdatePatch(input) {
@@ -19486,7 +19887,7 @@ function boardUpdatePatch(input) {
19486
19887
  decomposition: input.atomicityDecomposition ?? "propose"
19487
19888
  }
19488
19889
  } : {},
19489
- ...input.gateEnforcement !== void 0 ? { completionGate: { enforcement: input.gateEnforcement } } : {}
19890
+ ...agentSettableGate(input.gateEnforcement)
19490
19891
  };
19491
19892
  }
19492
19893
  function duplicateBoardOptions(input) {
@@ -19501,6 +19902,7 @@ function duplicateBoardOptions(input) {
19501
19902
 
19502
19903
  // src/kanban-task-inputs.ts
19503
19904
  import { randomUUID } from "node:crypto";
19905
+ import { clampSubagentCapabilities } from "@wrongstack/core/security";
19504
19906
  function taskInput(input) {
19505
19907
  const assignment = hasAssignmentInput(input) ? assignmentForTaskCreate(input) : void 0;
19506
19908
  return {
@@ -19598,6 +20000,10 @@ function taskPatch(input) {
19598
20000
  ...input.actualHours !== void 0 ? { actualHours: input.actualHours } : {}
19599
20001
  };
19600
20002
  }
20003
+ function clampRequestedCapabilities(requested) {
20004
+ if (requested === void 0) return void 0;
20005
+ return clampSubagentCapabilities(requested).granted;
20006
+ }
19601
20007
  function assignmentInput(input) {
19602
20008
  return {
19603
20009
  agentId: input.agentId,
@@ -19608,7 +20014,7 @@ function assignmentInput(input) {
19608
20014
  fallbackProfile: input.fallbackProfile,
19609
20015
  fallbackModels: input.fallbackModels,
19610
20016
  tools: input.tools,
19611
- allowedCapabilities: input.allowedCapabilities,
20017
+ allowedCapabilities: clampRequestedCapabilities(input.allowedCapabilities),
19612
20018
  assignee: input.assignee,
19613
20019
  leaseId: input.leaseId,
19614
20020
  claimedAt: input.claimedAt,
@@ -19635,7 +20041,7 @@ function assignmentForTaskCreate(input) {
19635
20041
  ...input.fallbackProfile !== void 0 ? { fallbackProfile: input.fallbackProfile } : {},
19636
20042
  ...input.fallbackModels !== void 0 ? { fallbackModels: input.fallbackModels } : {},
19637
20043
  ...input.tools !== void 0 ? { tools: input.tools } : {},
19638
- ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: input.allowedCapabilities } : {},
20044
+ ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: clampRequestedCapabilities(input.allowedCapabilities) } : {},
19639
20045
  ...input.leaseId !== void 0 ? { leaseId: input.leaseId } : {},
19640
20046
  ...input.claimedAt !== void 0 ? { claimedAt: input.claimedAt } : {},
19641
20047
  ...input.heartbeatAt !== void 0 ? { heartbeatAt: input.heartbeatAt } : {},
@@ -19846,7 +20252,13 @@ var KANBAN_INPUT_SCHEMA = {
19846
20252
  moveTasksToColumnId: { type: "string" },
19847
20253
  atomicityMode: { type: "string", enum: ["off", "assess", "enforce"] },
19848
20254
  atomicityDecomposition: { type: "string", enum: ["auto", "propose"] },
19849
- gateEnforcement: { type: "string", enum: ["strict", "soft", "off"] },
20255
+ gateEnforcement: {
20256
+ type: "string",
20257
+ // WS-023: `'off'` is deliberately absent. The agent whose work this gate
20258
+ // checks must not be able to switch it off; it may only tighten. Turning
20259
+ // a gate off stays a human decision, made through board config.
20260
+ enum: ["strict", "soft"]
20261
+ },
19850
20262
  subtasks: {
19851
20263
  type: "array",
19852
20264
  minItems: 2,
@@ -19953,7 +20365,9 @@ async function ensureSessionKanbanBoard(projectRoot, sessionId) {
19953
20365
  description: "Live session work: todos, tasks, and plan items.",
19954
20366
  tags: sessionBoardTags(sessionId),
19955
20367
  columns: SESSION_KANBAN_COLUMNS,
19956
- generatedBy: `session-kanban:${sessionId}`
20368
+ generatedBy: `session-kanban:${sessionId}`,
20369
+ kind: "session_mirror",
20370
+ retention: { mode: "archive_after_ttl", ttlMs: 7 * 24 * 60 * 60 * 1e3 }
19957
20371
  });
19958
20372
  }
19959
20373
  if (!sameColumns(board.columns) || !board.tags?.includes(SESSION_BOARD_TAG)) {
@@ -20987,11 +21401,11 @@ var lintTool = {
20987
21401
  }
20988
21402
  };
20989
21403
  async function detectLinter(cwd) {
20990
- const { stat: stat17 } = await import("node:fs/promises");
21404
+ const { stat: stat18 } = await import("node:fs/promises");
20991
21405
  const checks = ["biome.json", ".eslintrc.json", "tslint.json", ".eslintrc.js", "tsconfig.json"];
20992
21406
  for (const f of checks) {
20993
21407
  try {
20994
- await stat17(`${cwd}/${f}`);
21408
+ await stat18(`${cwd}/${f}`);
20995
21409
  if (f.includes("biome")) return "biome";
20996
21410
  if (f.includes("eslint")) return "eslint";
20997
21411
  if (f.includes("tslint")) return "tslint";
@@ -21235,6 +21649,9 @@ var outdatedTool = {
21235
21649
  // tool.confirm_needed flow on every invocation. M-1 originally
21236
21650
  // fixed four sibling tools (mcp_control, shellcheck, shellcheck (scan mode),
21237
21651
  // search) but missed this one; applying the same contract here.
21652
+ // WS-046: gives permission decisions something to key on.
21653
+ // The working directory scanned.
21654
+ subjectKey: "cwd",
21238
21655
  mutating: true,
21239
21656
  // Capability is outbound network — the tool only hits the package
21240
21657
  // registry over HTTP, never touches the filesystem or runs shell.
@@ -21416,6 +21833,10 @@ var patchTool = {
21416
21833
  useInstead: ["edit"]
21417
21834
  },
21418
21835
  permission: "confirm",
21836
+ // WS-046: gives permission decisions something to key on.
21837
+ // The target directory. The patch body itself is unstable (every diff
21838
+ // differs), so it could never re-match its own stored rule.
21839
+ subjectKey: "directory",
21419
21840
  mutating: true,
21420
21841
  capabilities: ["fs.write"],
21421
21842
  icon: "edit",
@@ -21482,8 +21903,8 @@ var patchTool = {
21482
21903
  const before = beforeContents.get(target) ?? null;
21483
21904
  const after = await readTextForTracking(target);
21484
21905
  if (after === null || after === before) continue;
21485
- const stat17 = await fs27.stat(target).catch(() => null);
21486
- if (stat17) ctx.recordRead?.(target, stat17.mtimeMs, "write", sha256hex(after));
21906
+ const stat18 = await fs27.stat(target).catch(() => null);
21907
+ if (stat18) ctx.recordRead?.(target, stat18.mtimeMs, "write", sha256hex(after));
21487
21908
  ctx.session?.recordFileChange?.({
21488
21909
  path: target,
21489
21910
  action: before === null ? "created" : "modified",
@@ -21508,8 +21929,8 @@ var patchTool = {
21508
21929
  var MAX_TRACKING_BYTES = 5 * 1024 * 1024;
21509
21930
  async function readTextForTracking(absPath) {
21510
21931
  try {
21511
- const stat17 = await fs27.stat(absPath);
21512
- if (!stat17.isFile() || stat17.size > MAX_TRACKING_BYTES) return null;
21932
+ const stat18 = await fs27.stat(absPath);
21933
+ if (!stat18.isFile() || stat18.size > MAX_TRACKING_BYTES) return null;
21513
21934
  const buf = await fs27.readFile(absPath);
21514
21935
  if (buf.includes(0)) return null;
21515
21936
  return buf.toString("utf8");
@@ -21539,7 +21960,13 @@ function runPatch(args, cwd, signal) {
21539
21960
  let stdout = "";
21540
21961
  let stderr = "";
21541
21962
  const env = { ...buildChildEnv8(), LANG: "C", LC_ALL: "C" };
21542
- const child = spawn14("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
21963
+ const child = spawn14("patch", args, {
21964
+ cwd,
21965
+ signal,
21966
+ env,
21967
+ stdio: ["pipe", "pipe", "pipe"],
21968
+ windowsHide: true
21969
+ });
21543
21970
  child.stdout?.on("data", (c) => {
21544
21971
  stdout += c.toString();
21545
21972
  });
@@ -21560,6 +21987,7 @@ function extractPatchedFiles(output) {
21560
21987
  }
21561
21988
 
21562
21989
  // src/plan.ts
21990
+ import { randomUUID as randomUUID2 } from "node:crypto";
21563
21991
  import {
21564
21992
  addPlanItem,
21565
21993
  clearPlan,
@@ -21567,20 +21995,20 @@ import {
21567
21995
  formatPlan,
21568
21996
  getPlanTemplate,
21569
21997
  mutatePlan as mutatePlan2,
21998
+ mutateTasks as mutateTasks2,
21570
21999
  removePlanItem,
21571
22000
  setPlanItemStatus
21572
22001
  } from "@wrongstack/core/storage";
21573
- import {
21574
- mutateTasks as mutateTasks2
21575
- } from "@wrongstack/core/storage";
21576
22002
  import { formatTaskList } from "@wrongstack/core/utils";
21577
- import { randomUUID as randomUUID2 } from "node:crypto";
21578
22003
  var planTool = {
21579
22004
  name: "plan",
21580
22005
  category: "Session",
21581
22006
  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.',
21582
22007
  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.',
21583
22008
  permission: "confirm",
22009
+ // WS-046: gives permission decisions something to key on.
22010
+ // The action performed; plan has no single file or path subject.
22011
+ subjectKey: "action",
21584
22012
  mutating: true,
21585
22013
  capabilities: ["fs.write"],
21586
22014
  icon: "plan",
@@ -21637,7 +22065,10 @@ var planTool = {
21637
22065
  let planPath;
21638
22066
  if (input.scope === "project") {
21639
22067
  if (typeof sessionPlanPath === "string") {
21640
- const lastSep = Math.max(sessionPlanPath.lastIndexOf("/"), sessionPlanPath.lastIndexOf("\\"));
22068
+ const lastSep = Math.max(
22069
+ sessionPlanPath.lastIndexOf("/"),
22070
+ sessionPlanPath.lastIndexOf("\\")
22071
+ );
21641
22072
  planPath = lastSep >= 0 ? sessionPlanPath.slice(0, lastSep + 1) + "backlog.plan.json" : "backlog.plan.json";
21642
22073
  }
21643
22074
  } else {
@@ -21674,7 +22105,11 @@ var planTool = {
21674
22105
  case "start":
21675
22106
  case "done": {
21676
22107
  if (!input.target) {
21677
- early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
22108
+ early = mkResult(
22109
+ p,
22110
+ false,
22111
+ `${input.action} requires \`target\` (id|index|substring).`
22112
+ );
21678
22113
  return p;
21679
22114
  }
21680
22115
  const next = setPlanItemStatus(
@@ -21702,7 +22137,11 @@ var planTool = {
21702
22137
  }
21703
22138
  case "promote": {
21704
22139
  if (!input.target) {
21705
- early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
22140
+ early = mkResult(
22141
+ p,
22142
+ false,
22143
+ `${input.action} requires \`target\` (id|index|substring).`
22144
+ );
21706
22145
  return p;
21707
22146
  }
21708
22147
  const derived = deriveTodosFromPlanItem(p, input.target, input.subtasks);
@@ -21745,7 +22184,11 @@ var planTool = {
21745
22184
  return clearPlan(p);
21746
22185
  case "taskify": {
21747
22186
  if (!input.target) {
21748
- early = mkResult(p, false, "taskify requires `target` (plan item id|index|substring).");
22187
+ early = mkResult(
22188
+ p,
22189
+ false,
22190
+ "taskify requires `target` (plan item id|index|substring)."
22191
+ );
21749
22192
  return p;
21750
22193
  }
21751
22194
  let itemIdx = -1;
@@ -21818,7 +22261,11 @@ var planTool = {
21818
22261
  ${formatTaskList(taskFile.tasks)}`
21819
22262
  );
21820
22263
  } catch (err) {
21821
- return mkResult(plan, false, `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`);
22264
+ return mkResult(
22265
+ plan,
22266
+ false,
22267
+ `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`
22268
+ );
21822
22269
  }
21823
22270
  }
21824
22271
  return mkResult(plan, true, `Plan ${input.action} ok.`);
@@ -21895,9 +22342,9 @@ var readTool = {
21895
22342
  }
21896
22343
  const absPath = await safeResolveReal(input.path, ctx);
21897
22344
  const shouldIncludeSymbols = input.includeSymbols === true || input.includeSymbols !== false && ctx.meta[ADVANCED_MODE_META_KEY] === true;
21898
- let stat17;
22345
+ let stat18;
21899
22346
  try {
21900
- stat17 = await fs28.stat(absPath);
22347
+ stat18 = await fs28.stat(absPath);
21901
22348
  } catch (err) {
21902
22349
  const code = err.code;
21903
22350
  if (code === "ENOENT") {
@@ -21916,7 +22363,7 @@ var readTool = {
21916
22363
  cause: err
21917
22364
  });
21918
22365
  }
21919
- if (!stat17.isFile()) {
22366
+ if (!stat18.isFile()) {
21920
22367
  throw new FsError({
21921
22368
  message: `read: "${input.path}" is not a regular file`,
21922
22369
  code: "FS_READ_FAILED",
@@ -21924,23 +22371,23 @@ var readTool = {
21924
22371
  context: { reason: "not-a-regular-file" }
21925
22372
  });
21926
22373
  }
21927
- if (stat17.size > MAX_BYTES2) {
22374
+ if (stat18.size > MAX_BYTES2) {
21928
22375
  throw new FsError({
21929
- message: `read: file too large (${stat17.size} bytes, limit ${MAX_BYTES2})`,
22376
+ message: `read: file too large (${stat18.size} bytes, limit ${MAX_BYTES2})`,
21930
22377
  code: "FS_READ_FAILED",
21931
22378
  path: absPath,
21932
- context: { size: stat17.size, limit: MAX_BYTES2, reason: "too-large" }
22379
+ context: { size: stat18.size, limit: MAX_BYTES2, reason: "too-large" }
21933
22380
  });
21934
22381
  }
21935
22382
  const offset = Math.max(1, input.offset ?? 1);
21936
22383
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
21937
22384
  const prior = getReadRangeRecord(ctx, absPath);
21938
22385
  const requestedEnd = prior ? Math.min(offset + limit - 1, prior.totalLines) : offset + limit - 1;
21939
- if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat17.mtimeMs, offset, requestedEnd)) {
21940
- ctx.recordRead(absPath, stat17.mtimeMs);
22386
+ if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat18.mtimeMs, offset, requestedEnd)) {
22387
+ ctx.recordRead(absPath, stat18.mtimeMs);
21941
22388
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
21942
22389
  return {
21943
- 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.]`,
22390
+ 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.]`,
21944
22391
  total_lines: prior.totalLines,
21945
22392
  encoding: "utf8",
21946
22393
  truncated: requestedEnd < prior.totalLines,
@@ -21958,11 +22405,11 @@ var readTool = {
21958
22405
  const allLines = text.split(/\r\n|\r|\n/);
21959
22406
  const total = allLines.length;
21960
22407
  if (input.mode === "summary") {
21961
- ctx.recordRead(absPath, stat17.mtimeMs, "user", contentHash);
21962
- rememberReadRange(ctx, absPath, stat17.mtimeMs, total, 1, Math.min(total, 200));
22408
+ ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22409
+ rememberReadRange(ctx, absPath, stat18.mtimeMs, total, 1, Math.min(total, 200));
21963
22410
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
21964
22411
  return {
21965
- text: summarizeFile(input.path, stat17.size, allLines),
22412
+ text: summarizeFile(input.path, stat18.size, allLines),
21966
22413
  total_lines: total,
21967
22414
  encoding: "utf8",
21968
22415
  truncated: total > 200,
@@ -21974,8 +22421,8 @@ var readTool = {
21974
22421
  };
21975
22422
  }
21976
22423
  if (limit === 0) {
21977
- ctx.recordRead(absPath, stat17.mtimeMs, "user", contentHash);
21978
- rememberReadRange(ctx, absPath, stat17.mtimeMs, total, 1, 0);
22424
+ ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22425
+ rememberReadRange(ctx, absPath, stat18.mtimeMs, total, 1, 0);
21979
22426
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
21980
22427
  return {
21981
22428
  text: "",
@@ -21987,8 +22434,8 @@ var readTool = {
21987
22434
  };
21988
22435
  }
21989
22436
  if (offset > total) {
21990
- ctx.recordRead(absPath, stat17.mtimeMs, "user", contentHash);
21991
- rememberReadRange(ctx, absPath, stat17.mtimeMs, total, total + 1, total + 1);
22437
+ ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22438
+ rememberReadRange(ctx, absPath, stat18.mtimeMs, total, total + 1, total + 1);
21992
22439
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
21993
22440
  return {
21994
22441
  text: `[offset ${offset} is past end of file "${input.path}" \u2014 file has ${total} line(s). Do not retry this offset.]`,
@@ -22003,8 +22450,8 @@ var readTool = {
22003
22450
  const truncated = offset - 1 + slice.length < total;
22004
22451
  const width = String(offset + slice.length - 1).length;
22005
22452
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
22006
- ctx.recordRead(absPath, stat17.mtimeMs, "user", contentHash);
22007
- rememberReadRange(ctx, absPath, stat17.mtimeMs, total, offset, offset + slice.length - 1);
22453
+ ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22454
+ rememberReadRange(ctx, absPath, stat18.mtimeMs, total, offset, offset + slice.length - 1);
22008
22455
  const symResult = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22009
22456
  return {
22010
22457
  text: numbered,
@@ -22110,20 +22557,20 @@ ${interesting.join("\n")}` : "symbols/imports: (none detected)"
22110
22557
  }
22111
22558
 
22112
22559
  // src/replace.ts
22113
- import { expectDefined as expectDefined8 } from "@wrongstack/core/utils";
22114
22560
  import { spawn as spawn15 } from "node:child_process";
22115
22561
  import * as fs29 from "node:fs/promises";
22116
22562
  import * as path33 from "node:path";
22563
+ import { ToolValidationError as ToolValidationError6 } from "@wrongstack/core/types";
22117
22564
  import {
22118
22565
  atomicWrite as atomicWrite3,
22119
22566
  buildChildEnv as buildChildEnv9,
22120
22567
  compileGlob as compileGlob4,
22121
22568
  detectNewlineStyle as detectNewlineStyle2,
22569
+ expectDefined as expectDefined8,
22122
22570
  normalizeToLf as normalizeToLf2,
22123
22571
  toStyle as toStyle2,
22124
22572
  unifiedDiff as unifiedDiff2
22125
22573
  } from "@wrongstack/core/utils";
22126
- import { ToolValidationError as ToolValidationError6 } from "@wrongstack/core/types";
22127
22574
  init_util();
22128
22575
  var DEFAULT_IGNORE4 = ["node_modules", ".git", "dist", "build", ".next", "coverage"];
22129
22576
  var replaceTool = {
@@ -22132,6 +22579,10 @@ var replaceTool = {
22132
22579
  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.",
22133
22580
  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.",
22134
22581
  permission: "confirm",
22582
+ // WS-046: gives permission decisions something to key on.
22583
+ // The file scope being rewritten, not the pattern: a trust rule should say
22584
+ // where a bulk replace may run.
22585
+ subjectKey: "files",
22135
22586
  mutating: true,
22136
22587
  capabilities: ["fs.write"],
22137
22588
  icon: "edit",
@@ -22204,8 +22655,8 @@ var replaceTool = {
22204
22655
  }
22205
22656
  const rel = path33.relative(realRoot, realPath);
22206
22657
  if (rel.startsWith("..") || path33.isAbsolute(rel)) continue;
22207
- const stat17 = await fs29.stat(realPath).catch(() => null);
22208
- if (!stat17?.isFile()) continue;
22658
+ const stat18 = await fs29.stat(realPath).catch(() => null);
22659
+ if (!stat18?.isFile()) continue;
22209
22660
  let content;
22210
22661
  try {
22211
22662
  const buf = await fs29.readFile(realPath);
@@ -22230,7 +22681,7 @@ var replaceTool = {
22230
22681
  totalReplacements += count;
22231
22682
  if (!dryRun) {
22232
22683
  const newContent = toStyle2(newContentLf, style);
22233
- await atomicWrite3(realPath, newContent, { mode: stat17.mode & 511 });
22684
+ await atomicWrite3(realPath, newContent, { mode: stat18.mode & 511 });
22234
22685
  const written = await fs29.stat(realPath).catch(() => null);
22235
22686
  if (written) {
22236
22687
  ctx.recordRead?.(realPath, written.mtimeMs, "write", sha256hex(newContent));
@@ -22270,8 +22721,8 @@ async function resolveFiles2(filesInput, ctx, extraGlob) {
22270
22721
  const resolved = [];
22271
22722
  for (const p of parts) {
22272
22723
  const absPath = safeResolve(p, ctx);
22273
- const stat17 = await fs29.stat(absPath).catch(() => null);
22274
- if (stat17?.isFile()) {
22724
+ const stat18 = await fs29.stat(absPath).catch(() => null);
22725
+ if (stat18?.isFile()) {
22275
22726
  resolved.push(absPath);
22276
22727
  }
22277
22728
  }
@@ -22291,7 +22742,11 @@ async function globFiles(pattern, base, extraGlob) {
22291
22742
  function checkRg() {
22292
22743
  return new Promise((resolve16) => {
22293
22744
  try {
22294
- const p = spawn15("rg", ["--version"], { env: buildChildEnv9(), stdio: "ignore", windowsHide: true });
22745
+ const p = spawn15("rg", ["--version"], {
22746
+ env: buildChildEnv9(),
22747
+ stdio: "ignore",
22748
+ windowsHide: true
22749
+ });
22295
22750
  p.on("error", () => resolve16(false));
22296
22751
  p.on("close", (code) => resolve16(code === 0));
22297
22752
  } catch {
@@ -22342,8 +22797,8 @@ async function globNative(pattern, base, extraGlob) {
22342
22797
  if (DEFAULT_IGNORE4.includes(e.name)) continue;
22343
22798
  const full = path33.join(dir, e.name);
22344
22799
  try {
22345
- const stat17 = await fs29.lstat(full);
22346
- if (stat17.isSymbolicLink()) continue;
22800
+ const stat18 = await fs29.lstat(full);
22801
+ if (stat18.isSymbolicLink()) continue;
22347
22802
  } catch {
22348
22803
  continue;
22349
22804
  }
@@ -22467,6 +22922,7 @@ var scaffoldTool = {
22467
22922
  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.",
22468
22923
  permission: "confirm",
22469
22924
  mutating: true,
22925
+ subjectKey: "name",
22470
22926
  capabilities: ["fs.write.outside-project", "fs.write"],
22471
22927
  icon: "scaffold",
22472
22928
  timeoutMs: 3e4,
@@ -22541,15 +22997,17 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
22541
22997
  output: dryRun ? `Would create ${filesCreated} files: ${files.join(", ")}` : `Created ${filesCreated} files: ${files.join(", ")}`
22542
22998
  };
22543
22999
  }
23000
+ function escapeRegexLiteral(value) {
23001
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23002
+ }
22544
23003
  function substituteVars(content, name, vars) {
23004
+ const kebab = name.toLowerCase().replace(/\s+/g, "-");
23005
+ const pascal = name.replace(/(?:^|[-_\s]+)([a-z])/g, (_, c) => c.toUpperCase());
22545
23006
  let result = content;
22546
- result = result.replace(/\{\{name\}\}/g, name.toLowerCase().replace(/\s+/g, "-"));
22547
- result = result.replace(
22548
- /\{\{Name\}\}/g,
22549
- name.replace(/(?:^|[-_\s]+)([a-z])/g, (_, c) => c.toUpperCase())
22550
- );
23007
+ result = result.replace(/\{\{name\}\}/g, () => kebab);
23008
+ result = result.replace(/\{\{Name\}\}/g, () => pascal);
22551
23009
  for (const [k, v] of Object.entries(vars)) {
22552
- result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), v);
23010
+ result = result.replace(new RegExp(`\\{\\{${escapeRegexLiteral(k)}\\}\\}`, "g"), () => v);
22553
23011
  }
22554
23012
  return result;
22555
23013
  }
@@ -23485,11 +23943,11 @@ var testTool = {
23485
23943
  }
23486
23944
  };
23487
23945
  async function detectRunner(cwd) {
23488
- const { stat: stat17 } = await import("node:fs/promises");
23946
+ const { stat: stat18 } = await import("node:fs/promises");
23489
23947
  const candidates = ["vitest.config.ts", "jest.config.js", ".mocharc.json"];
23490
23948
  for (const f of candidates) {
23491
23949
  try {
23492
- await stat17(path35.join(cwd, f));
23950
+ await stat18(path35.join(cwd, f));
23493
23951
  if (f.includes("vitest")) return "vitest";
23494
23952
  if (f.includes("jest")) return "jest";
23495
23953
  if (f.includes("mocha")) return "mocha";
@@ -23886,12 +24344,19 @@ var toolSearchTool = {
23886
24344
  };
23887
24345
 
23888
24346
  // src/tool-use.ts
24347
+ import {
24348
+ GOVERNED_TOOL_EXECUTOR_META_KEY as GOVERNED_TOOL_EXECUTOR_META_KEY2
24349
+ } from "@wrongstack/core/types";
23889
24350
  var toolUseTool = {
23890
24351
  name: "tool_use",
23891
24352
  category: "Meta",
23892
24353
  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.",
23893
24354
  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.",
23894
24355
  permission: "confirm",
24356
+ // WS-046: gives permission decisions something to key on.
24357
+ // The tool being invoked through this indirection — the whole point of a
24358
+ // permission decision here is WHICH tool is being reached.
24359
+ subjectKey: "tool",
23895
24360
  mutating: true,
23896
24361
  timeoutMs: 6e4,
23897
24362
  capabilities: ["tool.mutate.any"],
@@ -23910,7 +24375,7 @@ var toolUseTool = {
23910
24375
  },
23911
24376
  required: ["tool"]
23912
24377
  },
23913
- async execute(input, ctx, opts) {
24378
+ async execute(input, ctx) {
23914
24379
  const start = Date.now();
23915
24380
  if (!input?.tool) {
23916
24381
  return {
@@ -23929,6 +24394,14 @@ var toolUseTool = {
23929
24394
  executionMs: Date.now() - start
23930
24395
  };
23931
24396
  }
24397
+ if (tool.name === toolUseTool.name) {
24398
+ return {
24399
+ tool: input.tool,
24400
+ success: false,
24401
+ error: "tool_use: recursive meta-tool execution is not allowed",
24402
+ executionMs: Date.now() - start
24403
+ };
24404
+ }
23932
24405
  if (tool.permission === "deny") {
23933
24406
  return {
23934
24407
  tool: input.tool,
@@ -23937,12 +24410,21 @@ var toolUseTool = {
23937
24410
  executionMs: Date.now() - start
23938
24411
  };
23939
24412
  }
24413
+ const governedExecute = ctx.meta[GOVERNED_TOOL_EXECUTOR_META_KEY2];
24414
+ if (typeof governedExecute !== "function") {
24415
+ return {
24416
+ tool: input.tool,
24417
+ success: false,
24418
+ error: "tool_use: governed nested execution is unavailable; call the tool directly",
24419
+ executionMs: Date.now() - start
24420
+ };
24421
+ }
23940
24422
  try {
23941
- const result = await tool.execute(input.input, ctx, opts);
24423
+ const result = await governedExecute(input.tool, input.input ?? {});
23942
24424
  return {
23943
24425
  tool: input.tool,
23944
- success: true,
23945
- result,
24426
+ success: result.success,
24427
+ ...result.success ? { result: result.result } : { error: result.error ?? "nested tool failed" },
23946
24428
  executionMs: Date.now() - start
23947
24429
  };
23948
24430
  } catch (e) {
@@ -23958,10 +24440,12 @@ var toolUseTool = {
23958
24440
 
23959
24441
  // src/tree.ts
23960
24442
  init_util();
23961
- import { DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS4, expectDefined as expectDefined10 } from "@wrongstack/core/utils";
23962
24443
  import * as fs32 from "node:fs/promises";
23963
24444
  import * as path36 from "node:path";
24445
+ import { DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS4, expectDefined as expectDefined10 } from "@wrongstack/core/utils";
23964
24446
  var DEFAULT_IGNORE5 = [...DEFAULT_WALK_IGNORE_DIRS4, ".wrongstack", ".ssh", ".gnupg", ".aws"];
24447
+ var DEFAULT_MAX_ENTRIES2 = 5e3;
24448
+ var MAX_TREE_OUTPUT_BYTES = 256 * 1024;
23965
24449
  var treeTool = {
23966
24450
  name: "tree",
23967
24451
  category: "Filesystem",
@@ -24005,6 +24489,12 @@ var treeTool = {
24005
24489
  show_hidden: {
24006
24490
  type: "boolean",
24007
24491
  description: "Show hidden files starting with . (default: false)"
24492
+ },
24493
+ max_entries: {
24494
+ type: "integer",
24495
+ description: `Maximum entries retained in the rendered tree (default ${DEFAULT_MAX_ENTRIES2}).`,
24496
+ minimum: 1,
24497
+ maximum: 5e4
24008
24498
  }
24009
24499
  }
24010
24500
  },
@@ -24018,7 +24508,7 @@ var treeTool = {
24018
24508
  if (!final) throw new Error("tree: stream ended without final event");
24019
24509
  return final;
24020
24510
  },
24021
- async *executeStream(input, ctx) {
24511
+ async *executeStream(input, ctx, opts) {
24022
24512
  const basePath = input.path ? safeResolve(input.path, ctx) : ctx.cwd;
24023
24513
  const maxDepth = input.depth ?? 3;
24024
24514
  const showFiles = input.show_files ?? true;
@@ -24026,8 +24516,17 @@ var treeTool = {
24026
24516
  const showHidden = input.show_hidden ?? false;
24027
24517
  const exclude = /* @__PURE__ */ new Set([...DEFAULT_IGNORE5, ...input.exclude ?? []]);
24028
24518
  const filterGlob = input.glob;
24519
+ const maxEntries = input.max_entries ?? DEFAULT_MAX_ENTRIES2;
24520
+ const signal = opts?.signal ?? ctx.signal ?? new AbortController().signal;
24029
24521
  const lines = [basePath];
24030
24522
  const totals = { totalFiles: { value: 0 }, totalDirs: { value: 0 } };
24523
+ const retention = {
24524
+ entries: 0,
24525
+ outputBytes: Buffer.byteLength(basePath, "utf8"),
24526
+ maxEntries,
24527
+ maxOutputBytes: MAX_TREE_OUTPUT_BYTES,
24528
+ truncated: false
24529
+ };
24031
24530
  const queue = [];
24032
24531
  const FLUSH_EVERY = 200;
24033
24532
  let lastEmittedTotal = 0;
@@ -24054,12 +24553,19 @@ var treeTool = {
24054
24553
  isLast: true,
24055
24554
  totalFiles: totals.totalFiles,
24056
24555
  totalDirs: totals.totalDirs,
24057
- onProgress: tickProgress
24556
+ onProgress: tickProgress,
24557
+ retention,
24558
+ signal
24058
24559
  });
24059
24560
  let walkDone = false;
24060
- walkPromise.finally(() => {
24061
- walkDone = true;
24062
- });
24561
+ void walkPromise.then(
24562
+ () => {
24563
+ walkDone = true;
24564
+ },
24565
+ () => {
24566
+ walkDone = true;
24567
+ }
24568
+ );
24063
24569
  while (!walkDone || queue.length > 0) {
24064
24570
  if (queue.length > 0) {
24065
24571
  yield expectDefined10(queue.shift());
@@ -24082,13 +24588,15 @@ var treeTool = {
24082
24588
  tree: lines.join("\n"),
24083
24589
  total_files: totals.totalFiles.value,
24084
24590
  total_dirs: totals.totalDirs.value,
24085
- truncated: false,
24591
+ truncated: retention.truncated,
24086
24592
  path: basePath
24087
24593
  }
24088
24594
  };
24089
24595
  }
24090
24596
  };
24091
24597
  async function walkDir(dir, depth, opts) {
24598
+ opts.signal.throwIfAborted();
24599
+ if (opts.retention.truncated) return;
24092
24600
  const entries = await fs32.readdir(dir, { withFileTypes: true }).catch(() => []);
24093
24601
  const filtered = entries.filter((e) => {
24094
24602
  if (!opts.showHidden && e.name.startsWith(".")) return false;
@@ -24108,6 +24616,8 @@ async function walkDir(dir, depth, opts) {
24108
24616
  return a.name.localeCompare(b.name);
24109
24617
  });
24110
24618
  for (let i = 0; i < items.length; i++) {
24619
+ opts.signal.throwIfAborted();
24620
+ if (opts.retention.truncated) return;
24111
24621
  const entry = items[i];
24112
24622
  if (!entry) continue;
24113
24623
  const isLast = i === items.length - 1;
@@ -24116,7 +24626,15 @@ async function walkDir(dir, depth, opts) {
24116
24626
  const displayName = entry.name + (entry.isDirectory() ? "/" : "");
24117
24627
  if (!opts.showDirs && entry.isDirectory()) continue;
24118
24628
  if (!opts.showFiles && entry.isFile()) continue;
24119
- opts.lines.push(opts.prefix + branch + displayName);
24629
+ const line = opts.prefix + branch + displayName;
24630
+ const lineBytes = Buffer.byteLength(line, "utf8") + 1;
24631
+ if (opts.retention.entries >= opts.retention.maxEntries || opts.retention.outputBytes + lineBytes > opts.retention.maxOutputBytes) {
24632
+ opts.retention.truncated = true;
24633
+ return;
24634
+ }
24635
+ opts.lines.push(line);
24636
+ opts.retention.entries++;
24637
+ opts.retention.outputBytes += lineBytes;
24120
24638
  if (entry.isDirectory() && (opts.maxDepth === 0 || depth < opts.maxDepth)) {
24121
24639
  const childPrefix = opts.prefix + connector;
24122
24640
  await walkDir(path36.join(dir, entry.name), depth + 1, {
@@ -24231,11 +24749,11 @@ var typecheckTool = {
24231
24749
  }
24232
24750
  };
24233
24751
  async function findTsConfig(cwd) {
24234
- const { stat: stat17 } = await import("node:fs/promises");
24752
+ const { stat: stat18 } = await import("node:fs/promises");
24235
24753
  const candidates = ["tsconfig.json", "tsconfig.base.json"];
24236
24754
  for (const f of candidates) {
24237
24755
  try {
24238
- const s = await stat17(path37.join(cwd, f));
24756
+ const s = await stat18(path37.join(cwd, f));
24239
24757
  if (s.isFile()) return path37.join(cwd, f);
24240
24758
  } catch {
24241
24759
  }
@@ -24310,12 +24828,12 @@ async function prepareWrite(input, ctx) {
24310
24828
  let existed = false;
24311
24829
  let prev = "";
24312
24830
  try {
24313
- const stat17 = await fs33.stat(absPath);
24314
- existed = stat17.isFile();
24831
+ const stat18 = await fs33.stat(absPath);
24832
+ existed = stat18.isFile();
24315
24833
  if (existed) {
24316
24834
  if (!ctx.hasRead(absPath)) {
24317
24835
  prev = await fs33.readFile(absPath, "utf8");
24318
- ctx.recordRead(absPath, stat17.mtimeMs, "write", sha256hex(prev));
24836
+ ctx.recordRead(absPath, stat18.mtimeMs, "write", sha256hex(prev));
24319
24837
  } else {
24320
24838
  prev = await fs33.readFile(absPath, "utf8");
24321
24839
  }
@@ -24332,8 +24850,8 @@ async function finishWrite(input, ctx, prepared, signal) {
24332
24850
  await atomicWrite5(prepared.absPath, input.content);
24333
24851
  const diff = prepared.existed ? unifiedDiff3(prepared.prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
24334
24852
  + (new file, ${input.content.split("\n").length} lines)`;
24335
- const stat17 = await fs33.stat(prepared.absPath);
24336
- ctx.recordRead(prepared.absPath, stat17.mtimeMs, "write", sha256hex(input.content));
24853
+ const stat18 = await fs33.stat(prepared.absPath);
24854
+ ctx.recordRead(prepared.absPath, stat18.mtimeMs, "write", sha256hex(input.content));
24337
24855
  ctx.session.recordFileChange({
24338
24856
  path: prepared.absPath,
24339
24857
  action: prepared.existed ? "modified" : "created",