@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/index.js CHANGED
@@ -148,11 +148,12 @@ function createOutputSpool(opts) {
148
148
  return {
149
149
  write(text) {
150
150
  if (finalized || !text) return;
151
- totalBytes += Buffer.byteLength(text, "utf8");
151
+ const textBytes = Buffer.byteLength(text, "utf8");
152
+ totalBytes += textBytes;
152
153
  if (!stream && !failed) {
153
- if (headBytes + text.length <= threshold) {
154
+ if (headBytes + textBytes <= threshold) {
154
155
  head += text;
155
- headBytes += text.length;
156
+ headBytes += textBytes;
156
157
  return;
157
158
  }
158
159
  head += text;
@@ -162,7 +163,7 @@ function createOutputSpool(opts) {
162
163
  }
163
164
  if (stream) {
164
165
  if (stream.writableLength > SPOOL_WRITE_HWM_BYTES) {
165
- droppedBytes += Buffer.byteLength(text, "utf8");
166
+ droppedBytes += textBytes;
166
167
  return;
167
168
  }
168
169
  stream.write(text);
@@ -849,8 +850,11 @@ async function* spawnStream(opts) {
849
850
  const max = opts.maxBytes ?? 2e5;
850
851
  const flushAt = opts.flushBytes ?? 4 * 1024;
851
852
  const maxQueue = opts.maxQueueSize ?? 500;
853
+ const maxQueueBytes = opts.maxQueueBytes ?? 1024 * 1024;
852
854
  let stdout = "";
853
855
  let stderr = "";
856
+ let stdoutRetainedBytes = 0;
857
+ let stderrRetainedBytes = 0;
854
858
  let pending2 = "";
855
859
  let error;
856
860
  const spool = createOutputSpool({ tool: opts.cmd, thresholdBytes: max });
@@ -892,6 +896,7 @@ async function* spawnStream(opts) {
892
896
  });
893
897
  }
894
898
  const queue = [];
899
+ let queuedBytes = 0;
895
900
  let waiter;
896
901
  let paused = false;
897
902
  const wake = () => {
@@ -902,7 +907,7 @@ async function* spawnStream(opts) {
902
907
  }
903
908
  };
904
909
  const resume = () => {
905
- if (paused && queue.length < maxQueue) {
910
+ if (paused && queue.length <= Math.floor(maxQueue / 2) && queuedBytes <= Math.floor(maxQueueBytes / 2)) {
906
911
  paused = false;
907
912
  child.stdout?.resume();
908
913
  child.stderr?.resume();
@@ -912,11 +917,16 @@ async function* spawnStream(opts) {
912
917
  const s = c.toString();
913
918
  stdoutBytes += c.byteLength;
914
919
  emitProcessOutput({ pid, stream: "stdout", chunk: c });
915
- if (stdout.length < max) stdout += s;
920
+ if (stdoutRetainedBytes < max) {
921
+ const retained = utf8Prefix(s, max - stdoutRetainedBytes);
922
+ stdout += retained;
923
+ stdoutRetainedBytes += Buffer.byteLength(retained, "utf8");
924
+ }
916
925
  spool.write(s);
917
- queue.push({ kind: "out", data: s });
926
+ queue.push({ kind: "out", data: s, bytes: c.byteLength });
927
+ queuedBytes += c.byteLength;
918
928
  wake();
919
- if (!paused && queue.length >= maxQueue) {
929
+ if (!paused && (queue.length >= maxQueue || queuedBytes >= maxQueueBytes)) {
920
930
  paused = true;
921
931
  child.stdout?.pause();
922
932
  child.stderr?.pause();
@@ -926,11 +936,16 @@ async function* spawnStream(opts) {
926
936
  const s = c.toString();
927
937
  stderrBytes += c.byteLength;
928
938
  emitProcessOutput({ pid, stream: "stderr", chunk: c });
929
- if (stderr.length < max) stderr += s;
939
+ if (stderrRetainedBytes < max) {
940
+ const retained = utf8Prefix(s, max - stderrRetainedBytes);
941
+ stderr += retained;
942
+ stderrRetainedBytes += Buffer.byteLength(retained, "utf8");
943
+ }
930
944
  spool.write(s);
931
- queue.push({ kind: "err", data: s });
945
+ queue.push({ kind: "err", data: s, bytes: c.byteLength });
946
+ queuedBytes += c.byteLength;
932
947
  wake();
933
- if (!paused && queue.length >= maxQueue) {
948
+ if (!paused && (queue.length >= maxQueue || queuedBytes >= maxQueueBytes)) {
934
949
  paused = true;
935
950
  child.stdout?.pause();
936
951
  child.stderr?.pause();
@@ -940,7 +955,9 @@ async function* spawnStream(opts) {
940
955
  child.stderr?.on("data", onErr);
941
956
  child.on("error", (e) => {
942
957
  error = e.message;
943
- queue.push({ kind: "error", data: e.message });
958
+ const bytes = Buffer.byteLength(e.message, "utf8");
959
+ queue.push({ kind: "error", data: e.message, bytes });
960
+ queuedBytes += bytes;
944
961
  wake();
945
962
  });
946
963
  const completeTelemetry = (code, signal, timedOut = false) => {
@@ -961,7 +978,13 @@ async function* spawnStream(opts) {
961
978
  if (typeof pid === "number") registry.unregister(pid);
962
979
  const exitCode2 = code ?? (signal ? 1 : 0);
963
980
  completeTelemetry(exitCode2, signal ?? void 0);
964
- queue.push({ kind: "close", data: "", code: exitCode2, ...signal ? { signal } : {} });
981
+ queue.push({
982
+ kind: "close",
983
+ data: "",
984
+ bytes: 0,
985
+ code: exitCode2,
986
+ ...signal ? { signal } : {}
987
+ });
965
988
  wake();
966
989
  });
967
990
  const onAbort = () => {
@@ -973,7 +996,7 @@ async function* spawnStream(opts) {
973
996
  } catch {
974
997
  }
975
998
  }
976
- queue.push({ kind: "close", data: "", code: 124 });
999
+ queue.push({ kind: "close", data: "", bytes: 0, code: 124 });
977
1000
  completeTelemetry(124, "SIGKILL", true);
978
1001
  wake();
979
1002
  };
@@ -991,6 +1014,7 @@ async function* spawnStream(opts) {
991
1014
  });
992
1015
  }
993
1016
  const chunk = queue.shift();
1017
+ queuedBytes -= chunk.bytes;
994
1018
  resume();
995
1019
  if (chunk.kind === "close") {
996
1020
  if (!spawnFailed) exitCode = chunk.code ?? 0;
@@ -1017,7 +1041,7 @@ async function* spawnStream(opts) {
1017
1041
  stdout: spooled ? stdout + spoolNote(spooled) : stdout,
1018
1042
  stderr,
1019
1043
  exitCode,
1020
- truncated: stdout.length >= max || stderr.length >= max,
1044
+ truncated: stdoutBytes > stdoutRetainedBytes || stderrBytes > stderrRetainedBytes,
1021
1045
  error,
1022
1046
  spoolPath: spooled?.path,
1023
1047
  spoolBytes: spooled?.bytes
@@ -1041,13 +1065,27 @@ async function* spawnStream(opts) {
1041
1065
  }
1042
1066
  }
1043
1067
  }
1068
+ function utf8Prefix(text, maxBytes) {
1069
+ if (maxBytes <= 0) return "";
1070
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
1071
+ let low = 0;
1072
+ let high = text.length;
1073
+ while (low < high) {
1074
+ const mid = Math.ceil((low + high) / 2);
1075
+ if (Buffer.byteLength(text.slice(0, mid), "utf8") <= maxBytes) low = mid;
1076
+ else high = mid - 1;
1077
+ }
1078
+ let end = low;
1079
+ if (end > 0 && /[\uD800-\uDBFF]/.test(text[end - 1])) end--;
1080
+ return text.slice(0, end);
1081
+ }
1044
1082
  var isWin;
1045
1083
  var init_spawn_stream = __esm({
1046
1084
  "src/_spawn-stream.ts"() {
1047
1085
  "use strict";
1048
1086
  init_output_spool();
1049
- init_process_registry();
1050
1087
  init_win32_resolve();
1088
+ init_process_registry();
1051
1089
  isWin = process.platform === "win32";
1052
1090
  }
1053
1091
  });
@@ -1061,14 +1099,14 @@ function sha256hex(content) {
1061
1099
  return createHash("sha256").update(content, "utf8").digest("hex");
1062
1100
  }
1063
1101
  async function detectPackageManager(cwd) {
1064
- const { stat: stat18 } = await import("node:fs/promises");
1102
+ const { stat: stat19 } = await import("node:fs/promises");
1065
1103
  try {
1066
- await stat18(`${cwd}/pnpm-lock.yaml`);
1104
+ await stat19(`${cwd}/pnpm-lock.yaml`);
1067
1105
  return "pnpm";
1068
1106
  } catch {
1069
1107
  }
1070
1108
  try {
1071
- await stat18(`${cwd}/yarn.lock`);
1109
+ await stat19(`${cwd}/yarn.lock`);
1072
1110
  return "yarn";
1073
1111
  } catch {
1074
1112
  }
@@ -1096,11 +1134,15 @@ function safeResolve(input, ctx) {
1096
1134
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
1097
1135
  }
1098
1136
  async function assertRealInsideRoot(absPath, ctx) {
1099
- if (ctx.allowOutsideProjectRoot) return;
1137
+ await resolveRealInsideRoot(absPath, ctx);
1138
+ }
1139
+ async function resolveRealInsideRoot(absPath, ctx) {
1140
+ if (ctx.allowOutsideProjectRoot) return absPath;
1100
1141
  const realRoots = await Promise.all(
1101
1142
  allowedRoots(ctx).map((r) => fsp2.realpath(r).catch(() => path3.resolve(r)))
1102
1143
  );
1103
1144
  let probe2 = absPath;
1145
+ const pendingTail = [];
1104
1146
  for (; ; ) {
1105
1147
  let real;
1106
1148
  try {
@@ -1108,13 +1150,16 @@ async function assertRealInsideRoot(absPath, ctx) {
1108
1150
  } catch (err) {
1109
1151
  if (err.code === "ENOENT") {
1110
1152
  const parent = path3.dirname(probe2);
1111
- if (parent === probe2) return;
1153
+ if (parent === probe2) return absPath;
1154
+ pendingTail.unshift(path3.basename(probe2));
1112
1155
  probe2 = parent;
1113
1156
  continue;
1114
1157
  }
1115
1158
  throw err;
1116
1159
  }
1117
- if (isInsideAny(real, realRoots)) return;
1160
+ if (isInsideAny(real, realRoots)) {
1161
+ return pendingTail.length > 0 ? path3.join(real, ...pendingTail) : real;
1162
+ }
1118
1163
  throw new Error(
1119
1164
  `Path "${absPath}" resolves through a symlink outside project root "${realRoots[0]}"`
1120
1165
  );
@@ -1122,8 +1167,7 @@ async function assertRealInsideRoot(absPath, ctx) {
1122
1167
  }
1123
1168
  async function safeResolveReal(input, ctx) {
1124
1169
  const abs = safeResolve(input, ctx);
1125
- await assertRealInsideRoot(abs, ctx);
1126
- return abs;
1170
+ return await resolveRealInsideRoot(abs, ctx);
1127
1171
  }
1128
1172
  function truncateMiddle(s, max) {
1129
1173
  if (Buffer.byteLength(s, "utf8") <= max) return s;
@@ -2687,8 +2731,8 @@ async function scanDirectory(directory, depth, profiles, limits, state, extraIgn
2687
2731
  collectFileEvidence(directory, fullPath, entry.name, profiles, state);
2688
2732
  }
2689
2733
  }
2690
- function collectFileEvidence(directory, fullPath, basename13, profiles, state) {
2691
- const lower = basename13.toLowerCase();
2734
+ function collectFileEvidence(directory, fullPath, basename14, profiles, state) {
2735
+ const lower = basename14.toLowerCase();
2692
2736
  const extension = path4.extname(lower);
2693
2737
  for (const profile of profiles) {
2694
2738
  const detector = profile.detectors.find(
@@ -2699,7 +2743,7 @@ function collectFileEvidence(directory, fullPath, basename13, profiles, state) {
2699
2743
  candidate.evidence.push({
2700
2744
  kind: detector.kind,
2701
2745
  path: fullPath,
2702
- value: basename13,
2746
+ value: basename14,
2703
2747
  weight: detector.weight
2704
2748
  });
2705
2749
  if (detector.kind === "manifest" || detector.kind === "config") {
@@ -2827,8 +2871,8 @@ function normalizeLimits(input) {
2827
2871
  async function canonicalDirectory(input) {
2828
2872
  const resolved = path4.resolve(input);
2829
2873
  const real = await fs2.realpath(resolved);
2830
- const stat18 = await fs2.stat(real);
2831
- if (!stat18.isDirectory()) throw new Error(`Project root is not a directory: ${input}`);
2874
+ const stat19 = await fs2.stat(real);
2875
+ if (!stat19.isDirectory()) throw new Error(`Project root is not a directory: ${input}`);
2832
2876
  return real;
2833
2877
  }
2834
2878
  async function canonicalInside(input, root, label) {
@@ -3384,6 +3428,7 @@ import * as fs3 from "node:fs/promises";
3384
3428
  import * as path6 from "node:path";
3385
3429
  async function planLanguageOperation(options) {
3386
3430
  validateOperationOptions(options.operation, options.operationOptions?.packages);
3431
+ validateFilterOption(options.operationOptions?.filter);
3387
3432
  const detection = await detectLanguageWorkspaces(options);
3388
3433
  const candidates = detection.workspaces.filter(
3389
3434
  (workspace) => workspace.capabilities.includes(options.operation)
@@ -3556,6 +3601,15 @@ function workspaceDepth(workspaceRoot, projectRoot) {
3556
3601
  const relative13 = path6.relative(path6.resolve(projectRoot), path6.resolve(workspaceRoot));
3557
3602
  return relative13 === "" ? 0 : relative13.split(path6.sep).length;
3558
3603
  }
3604
+ function validateFilterOption(filter) {
3605
+ if (filter === void 0) return;
3606
+ if (filter.startsWith("-") || filter.includes(" --")) {
3607
+ throw new Error(`Invalid test filter (parsed as a runner option): "${filter}"`);
3608
+ }
3609
+ if (filter.length > MAX_ARGUMENT_LENGTH || /[\r\n\0]/.test(filter)) {
3610
+ throw new Error("Invalid test filter (oversized or contains a control character)");
3611
+ }
3612
+ }
3559
3613
  function validateOperationOptions(operation, packages) {
3560
3614
  if (!operation.startsWith("package-") || !packages) return;
3561
3615
  for (const value of packages) {
@@ -3597,7 +3651,7 @@ var init_plan = __esm({
3597
3651
  MAX_ARGUMENT_LENGTH = 4096;
3598
3652
  PACKAGE_NAME_RE = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+(?:@[a-z0-9*+._~^<>=|-]+)?$/i;
3599
3653
  COMPOSER_PACKAGE_RE = /^[a-z0-9_.-]+\/[a-z0-9_.-]+(?::[a-z0-9*+._~^<>=|-]+)?$/i;
3600
- GO_MODULE_RE = /^(?:[a-z0-9.-]+\.)+[a-z0-9.-]+\/[a-z0-9._~+/@-]+$/i;
3654
+ GO_MODULE_RE = /^(?:[a-z0-9-]+\.)+[a-z0-9-]+\/[a-z0-9._~+/@-]+$/i;
3601
3655
  PYTHON_PACKAGE_RE = /^[a-z0-9._-]+(?:\[[a-z0-9._,-]+\])?==[a-z0-9*+._!~-]+$/i;
3602
3656
  }
3603
3657
  });
@@ -3717,8 +3771,8 @@ async function executeInternal(options, startedAt) {
3717
3771
  const target = options.plan.evidence.find((item) => item.kind === "target")?.path;
3718
3772
  if (!target) return unavailableResult(options, "Internal syntax plan has no target evidence.");
3719
3773
  const safeTarget = await assertContainedFile(target, options.projectRoot);
3720
- const stat18 = await fs4.stat(safeTarget);
3721
- if (stat18.size > MAX_INTERNAL_SOURCE_BYTES) {
3774
+ const stat19 = await fs4.stat(safeTarget);
3775
+ if (stat19.size > MAX_INTERNAL_SOURCE_BYTES) {
3722
3776
  return unavailableResult(
3723
3777
  options,
3724
3778
  `Internal syntax target exceeds ${MAX_INTERNAL_SOURCE_BYTES} bytes.`
@@ -4005,8 +4059,8 @@ async function snapshotPaths(paths) {
4005
4059
  const existing = [];
4006
4060
  for (const candidate of paths) {
4007
4061
  try {
4008
- const stat18 = await fs4.stat(candidate);
4009
- if (!stat18.isFile()) continue;
4062
+ const stat19 = await fs4.stat(candidate);
4063
+ if (!stat19.isFile()) continue;
4010
4064
  existing.push(candidate);
4011
4065
  } catch {
4012
4066
  }
@@ -4034,8 +4088,8 @@ async function snapshotSizes(paths) {
4034
4088
  const sizes = /* @__PURE__ */ new Map();
4035
4089
  for (const candidate of paths) {
4036
4090
  try {
4037
- const stat18 = await fs4.stat(candidate);
4038
- if (stat18.isFile()) sizes.set(candidate, stat18.size);
4091
+ const stat19 = await fs4.stat(candidate);
4092
+ if (stat19.isFile()) sizes.set(candidate, stat19.size);
4039
4093
  } catch {
4040
4094
  }
4041
4095
  }
@@ -4195,6 +4249,9 @@ var init_execute_tool = __esm({
4195
4249
  useInstead: ["language_info", "install", "audit", "outdated"]
4196
4250
  },
4197
4251
  permission: "confirm",
4252
+ // WS-046: gives permission decisions something to key on.
4253
+ // The action performed (build/test/lint); the cwd is where, not what.
4254
+ subjectKey: "action",
4198
4255
  mutating: true,
4199
4256
  riskTier: "standard",
4200
4257
  capabilities: ["shell.restricted", "fs.write"],
@@ -4427,6 +4484,9 @@ var init_package_tool = __esm({
4427
4484
  useInstead: ["language_info", "language", "install", "audit", "outdated"]
4428
4485
  },
4429
4486
  permission: "confirm",
4487
+ // WS-046: gives permission decisions something to key on.
4488
+ // The package operation performed — install/remove are not interchangeable.
4489
+ subjectKey: "operation",
4430
4490
  mutating: true,
4431
4491
  riskTier: "destructive",
4432
4492
  capabilities: ["shell.restricted", "fs.write", "net.outbound", "package.install"],
@@ -6481,9 +6541,9 @@ function parseSymbols6(opts) {
6481
6541
  function regexParse2(opts) {
6482
6542
  const { file, content, lang } = opts;
6483
6543
  const symbols = [];
6484
- const basename13 = path22.basename(file).toLowerCase();
6485
- const isPackageJson = basename13 === "package.json";
6486
- const isTsconfig = basename13 === "tsconfig.json" || basename13 === "tsconfig.build.json";
6544
+ const basename14 = path22.basename(file).toLowerCase();
6545
+ const isPackageJson = basename14 === "package.json";
6546
+ const isTsconfig = basename14 === "tsconfig.json" || basename14 === "tsconfig.build.json";
6487
6547
  const isJsonSchema = content.includes("$schema") || content.includes("$id") || content.includes("$ref");
6488
6548
  const isOpenApi = content.includes("openapi") || content.includes("swagger");
6489
6549
  const lines = content.split("\n");
@@ -7088,6 +7148,48 @@ var RULES = [
7088
7148
  return args.some((a) => /^[0-7]{3,4}$/.test(a) && /7/.test(a));
7089
7149
  },
7090
7150
  reason: "chmod with world-writable octal mode (e.g. 777)"
7151
+ },
7152
+ // ----- network recon / offensive scanners (caution) -----
7153
+ // These tools are purpose-built for network reconnaissance and offensive
7154
+ // security. They have legitimate uses in dev (security testing, infra
7155
+ // debugging) but their presence in an agent's command stream is worth a
7156
+ // caution banner so the user can notice an unexpected scan.
7157
+ {
7158
+ id: "network-scanner",
7159
+ level: "caution",
7160
+ 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",
7161
+ reason: "network scanner / offensive reconnaissance tool"
7162
+ },
7163
+ // ----- process termination (caution) -----
7164
+ // kill/killall/pkill terminate processes. The exec kill guard
7165
+ // (exec-kill-guard.ts) hard-blocks attempts on protected WrongStack
7166
+ // processes; this rule adds a caution banner for generic process
7167
+ // termination so the user sees a warning even for non-protected targets.
7168
+ {
7169
+ id: "process-kill",
7170
+ level: "caution",
7171
+ test: (cmd) => cmd === "kill" || cmd === "killall" || cmd === "pkill",
7172
+ reason: "process termination command"
7173
+ },
7174
+ // ----- shell/interpreter launchers (caution) -----
7175
+ // `env bash -c '…'`, `timeout sh`, `nohup perl -e '…'` etc. wrap a child
7176
+ // binary that bypasses the allowlist name-gate entirely (the wrapper
7177
+ // resolves the child from PATH, not from the allowlist). We flag caution
7178
+ // when a known launcher has a shell/interpreter as its target program.
7179
+ {
7180
+ id: "shell-launcher",
7181
+ level: "caution",
7182
+ test: (cmd, args) => {
7183
+ const launchers = ["env", "timeout", "nohup", "nice", "script", "expect", "tmux", "screen", "byobu", "dtach"];
7184
+ if (!launchers.includes(cmd)) return false;
7185
+ const shells = ["sh", "bash", "zsh", "fish", "python", "python3", "perl", "ruby", "node", "pwsh", "powershell", "cmd"];
7186
+ if (cmd === "env") {
7187
+ const prog = args.find((a) => !a.includes("=") && !a.startsWith("-"));
7188
+ return prog !== void 0 && shells.includes(prog);
7189
+ }
7190
+ return args.some((a) => shells.includes(a));
7191
+ },
7192
+ reason: "launcher wrapping a shell/interpreter (bypasses allowlist name-gate)"
7091
7193
  }
7092
7194
  ];
7093
7195
  function detectDanger(cmd, args, bypass) {
@@ -8695,30 +8797,59 @@ ${hint}` : ""),
8695
8797
  import {
8696
8798
  GOVERNED_TOOL_EXECUTOR_META_KEY
8697
8799
  } from "@wrongstack/core/types";
8800
+ var BATCHABLE_TOOL_NAMES = [
8801
+ "read",
8802
+ "glob",
8803
+ "grep",
8804
+ "tree",
8805
+ "codebase-stats",
8806
+ "codebase-search",
8807
+ "language_info",
8808
+ "tool_search",
8809
+ "memory_for_file",
8810
+ "memory_for_path",
8811
+ "memory_search"
8812
+ ];
8813
+ var BATCHABLE_TOOLS = new Set(BATCHABLE_TOOL_NAMES);
8814
+ var MAX_BATCH_CALLS = 8;
8815
+ var MAX_PARALLEL_BATCH_CALLS = 3;
8698
8816
  var batchToolUseTool = {
8699
8817
  name: "batch_tool_use",
8700
8818
  category: "Meta",
8701
- description: "Execute a batch of tool calls either sequentially or in parallel. Returns structured results for every call.",
8702
- 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.",
8819
+ description: "Execute a small batch of independent, read-only discovery calls. Process, mutation, browser-action, MCP, delegation, and nested meta tools are not eligible.",
8820
+ usageHint: `BOUNDED READ-ONLY BATCHING:
8821
+
8822
+ - Use only for a small set of independent reads or searches that are all needed now.
8823
+ - Do not wrap normal sequential reasoning or every tool call in a batch.
8824
+ - \`parallel: true\` (default) runs at most three eligible calls concurrently.
8825
+ - \`stop_on_error: true\` makes it fail fast on the first error.
8826
+ - Eligible tools: ${BATCHABLE_TOOL_NAMES.join(", ")}.`,
8703
8827
  permission: "confirm",
8828
+ // Scheduling barrier: keep the outer meta-call out of ToolExecutor's
8829
+ // non-mutating fan-out even though every eligible inner tool is read-only.
8704
8830
  mutating: true,
8705
8831
  timeoutMs: 12e4,
8706
- capabilities: ["tool.mutate.any"],
8832
+ capabilities: ["tool.meta"],
8707
8833
  icon: "meta",
8708
8834
  inputSchema: {
8709
8835
  type: "object",
8710
8836
  properties: {
8711
8837
  calls: {
8712
8838
  type: "array",
8839
+ maxItems: MAX_BATCH_CALLS,
8713
8840
  items: {
8714
8841
  type: "object",
8715
8842
  properties: {
8716
- tool: { type: "string" },
8843
+ tool: {
8844
+ type: "string",
8845
+ enum: [...BATCHABLE_TOOL_NAMES],
8846
+ description: "An explicitly batch-safe, read-only discovery tool."
8847
+ },
8717
8848
  input: { type: "object" }
8718
8849
  },
8719
8850
  required: ["tool"]
8720
8851
  },
8721
- description: "Array of tool calls to execute"
8852
+ description: `Up to ${MAX_BATCH_CALLS} independent, batch-safe read calls.`
8722
8853
  },
8723
8854
  stop_on_error: {
8724
8855
  type: "boolean",
@@ -8741,6 +8872,9 @@ var batchToolUseTool = {
8741
8872
  stop_on_error: false
8742
8873
  };
8743
8874
  }
8875
+ if (input.calls.length > MAX_BATCH_CALLS) {
8876
+ throw new Error(`batch_tool_use: calls must contain at most ${MAX_BATCH_CALLS} items`);
8877
+ }
8744
8878
  const governedExecute = ctx.meta[GOVERNED_TOOL_EXECUTOR_META_KEY];
8745
8879
  if (typeof governedExecute !== "function") {
8746
8880
  return {
@@ -8760,8 +8894,11 @@ var batchToolUseTool = {
8760
8894
  let succeeded = 0;
8761
8895
  let failed = 0;
8762
8896
  if (input.parallel !== false) {
8763
- const promises = input.calls.map(async (call) => executeSingle(call, ctx, governedExecute));
8764
- const allResults = await Promise.all(promises);
8897
+ const allResults = await mapWithConcurrency(
8898
+ input.calls,
8899
+ MAX_PARALLEL_BATCH_CALLS,
8900
+ (call) => executeSingle(call, ctx, governedExecute)
8901
+ );
8765
8902
  results.push(...allResults);
8766
8903
  succeeded = allResults.filter((r) => r.success).length;
8767
8904
  failed = allResults.filter((r) => !r.success).length;
@@ -8786,8 +8923,28 @@ var batchToolUseTool = {
8786
8923
  };
8787
8924
  }
8788
8925
  };
8926
+ async function mapWithConcurrency(items, limit, run) {
8927
+ const results = new Array(items.length);
8928
+ let nextIndex = 0;
8929
+ const worker2 = async () => {
8930
+ while (nextIndex < items.length) {
8931
+ const index = nextIndex++;
8932
+ results[index] = await run(items[index]);
8933
+ }
8934
+ };
8935
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker2()));
8936
+ return results;
8937
+ }
8789
8938
  async function executeSingle(call, ctx, governedExecute) {
8790
8939
  const start = Date.now();
8940
+ if (call.tool === batchToolUseTool.name) {
8941
+ return {
8942
+ tool: call.tool,
8943
+ success: false,
8944
+ error: "recursive batch_tool_use execution is not allowed",
8945
+ executionMs: Date.now() - start
8946
+ };
8947
+ }
8791
8948
  const tool = ctx.tools.find((candidate) => candidate.name === call.tool);
8792
8949
  if (!tool) {
8793
8950
  return {
@@ -8797,6 +8954,14 @@ async function executeSingle(call, ctx, governedExecute) {
8797
8954
  executionMs: Date.now() - start
8798
8955
  };
8799
8956
  }
8957
+ if (!BATCHABLE_TOOLS.has(tool.name) || tool.mutating) {
8958
+ return {
8959
+ tool: call.tool,
8960
+ success: false,
8961
+ error: `tool "${call.tool}" is not eligible for batch execution; call it directly. Allowed: ${BATCHABLE_TOOL_NAMES.join(", ")}`,
8962
+ executionMs: Date.now() - start
8963
+ };
8964
+ }
8800
8965
  try {
8801
8966
  const result = await governedExecute(call.tool, call.input);
8802
8967
  return {
@@ -8823,8 +8988,14 @@ import * as path10 from "node:path";
8823
8988
  import { atomicWrite, ulid } from "@wrongstack/core/utils";
8824
8989
  var ARTIFACT_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
8825
8990
  var sweptRoots = /* @__PURE__ */ new Set();
8991
+ var MAX_SWEPT_ROOTS = 128;
8826
8992
  function sweepOldArtifacts(root) {
8827
8993
  if (sweptRoots.has(root)) return;
8994
+ while (sweptRoots.size >= MAX_SWEPT_ROOTS) {
8995
+ const oldest = sweptRoots.values().next().value;
8996
+ if (oldest === void 0) break;
8997
+ sweptRoots.delete(oldest);
8998
+ }
8828
8999
  sweptRoots.add(root);
8829
9000
  void (async () => {
8830
9001
  const cutoff = Date.now() - ARTIFACT_RETENTION_MS;
@@ -8847,8 +9018,8 @@ function sweepOldArtifacts(root) {
8847
9018
  for (const name of names) {
8848
9019
  const target = path10.join(dir, name);
8849
9020
  try {
8850
- const stat18 = await fs7.stat(target);
8851
- if (stat18.isFile() && stat18.mtimeMs < cutoff) {
9021
+ const stat19 = await fs7.stat(target);
9022
+ if (stat19.isFile() && stat19.mtimeMs < cutoff) {
8852
9023
  await fs7.rm(target, { force: true });
8853
9024
  removed++;
8854
9025
  }
@@ -8876,14 +9047,14 @@ var BrowserArtifactStore = class {
8876
9047
  }
8877
9048
  async record(id, sessionId, kind, target, mimeType) {
8878
9049
  await fs7.chmod(target, 384).catch(() => void 0);
8879
- const [stat18, sha256] = await Promise.all([fs7.stat(target), hashFile(target)]);
9050
+ const [stat19, sha256] = await Promise.all([fs7.stat(target), hashFile(target)]);
8880
9051
  const artifact = {
8881
9052
  id,
8882
9053
  kind,
8883
9054
  sensitivity: "sensitive",
8884
9055
  path: target,
8885
9056
  mimeType,
8886
- sizeBytes: stat18.size,
9057
+ sizeBytes: stat19.size,
8887
9058
  sha256,
8888
9059
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
8889
9060
  };
@@ -9258,7 +9429,7 @@ var BrowserSessionManager = class {
9258
9429
  constructor(options, launcher = defaultLauncher) {
9259
9430
  this.launcher = launcher;
9260
9431
  this.artifacts = new BrowserArtifactStore(options.artifactRoot);
9261
- this.allowPrivateHosts = options.allowPrivateHosts ?? true;
9432
+ this.allowPrivateHosts = options.allowPrivateHosts ?? false;
9262
9433
  this.allowedPrivateOrigins = options.allowedPrivateOrigins ?? [];
9263
9434
  this.networkProxy = new BrowserNetworkGuardProxy({
9264
9435
  allowPrivateHosts: this.allowPrivateHosts,
@@ -9481,8 +9652,8 @@ var BrowserSessionManager = class {
9481
9652
  if (realRelative.startsWith("..") || path11.isAbsolute(realRelative)) {
9482
9653
  throw new Error("browser: upload files must not escape the project root through a symlink");
9483
9654
  }
9484
- const stat18 = await fs8.stat(realFile);
9485
- if (!stat18.isFile()) throw new Error(`browser: upload target is not a file: ${file}`);
9655
+ const stat19 = await fs8.stat(realFile);
9656
+ if (!stat19.isFile()) throw new Error(`browser: upload target is not a file: ${file}`);
9486
9657
  resolved.push(realFile);
9487
9658
  }
9488
9659
  await this.runPageOperation(
@@ -9834,6 +10005,9 @@ var browserScreenshotTool = {
9834
10005
  description: "Capture a page or element PNG and return sensitive artifact metadata with integrity hash.",
9835
10006
  usageHint: "browser_screenshot({ sessionId, fullPage?, selector? })",
9836
10007
  permission: "confirm",
10008
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10009
+ // rule must distinguish clicking one control from another.
10010
+ subjectKey: "selector",
9837
10011
  mutating: true,
9838
10012
  riskTier: "safe",
9839
10013
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9863,6 +10037,9 @@ function selectorTool(name, description, run) {
9863
10037
  description,
9864
10038
  usageHint: `${name}({ sessionId, selector })`,
9865
10039
  permission: "confirm",
10040
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10041
+ // rule must distinguish clicking one control from another.
10042
+ subjectKey: "selector",
9866
10043
  mutating: true,
9867
10044
  riskTier: "standard",
9868
10045
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9892,6 +10069,9 @@ var browserTypeTool = {
9892
10069
  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.",
9893
10070
  usageHint: "browser_type({ sessionId, selector, text? | secretEnv? })",
9894
10071
  permission: "confirm",
10072
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10073
+ // rule must distinguish clicking one control from another.
10074
+ subjectKey: "selector",
9895
10075
  mutating: true,
9896
10076
  riskTier: "standard",
9897
10077
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9926,6 +10106,9 @@ var browserSelectTool = {
9926
10106
  description: "Select an option in an owned browser session.",
9927
10107
  usageHint: "browser_select({ sessionId, selector, value })",
9928
10108
  permission: "confirm",
10109
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10110
+ // rule must distinguish clicking one control from another.
10111
+ subjectKey: "selector",
9929
10112
  mutating: true,
9930
10113
  riskTier: "standard",
9931
10114
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9954,6 +10137,9 @@ var browserPressTool = {
9954
10137
  description: "Press a keyboard key in an owned browser session.",
9955
10138
  usageHint: "browser_press({ sessionId, key })",
9956
10139
  permission: "confirm",
10140
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10141
+ // rule must distinguish clicking one control from another.
10142
+ subjectKey: "key",
9957
10143
  mutating: true,
9958
10144
  riskTier: "standard",
9959
10145
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9972,6 +10158,9 @@ var browserDragTool = {
9972
10158
  description: "Drag one element to another in an owned browser session.",
9973
10159
  usageHint: "browser_drag({ sessionId, from, to })",
9974
10160
  permission: "confirm",
10161
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10162
+ // rule must distinguish clicking one control from another.
10163
+ subjectKey: "from",
9975
10164
  mutating: true,
9976
10165
  riskTier: "standard",
9977
10166
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -10021,6 +10210,9 @@ var browserEvaluateTool = {
10021
10210
  description: "Evaluate bounded JavaScript in the page. Requires confirmation because page code is arbitrary.",
10022
10211
  usageHint: "browser_evaluate({ sessionId, expression })",
10023
10212
  permission: "confirm",
10213
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10214
+ // rule must distinguish clicking one control from another.
10215
+ subjectKey: "expression",
10024
10216
  mutating: true,
10025
10217
  riskTier: "destructive",
10026
10218
  capabilities: [ToolCapabilities.NET_OUTBOUND, ToolCapabilities.TOOL_MUTATE_ANY],
@@ -10043,6 +10235,9 @@ var browserUploadTool = {
10043
10235
  description: "Upload project-local files through a file input in an owned browser session.",
10044
10236
  usageHint: "browser_upload({ sessionId, selector, files })",
10045
10237
  permission: "confirm",
10238
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10239
+ // rule must distinguish clicking one control from another.
10240
+ subjectKey: "selector",
10046
10241
  mutating: true,
10047
10242
  riskTier: "destructive",
10048
10243
  capabilities: [ToolCapabilities.NET_OUTBOUND, ToolCapabilities.FS_READ],
@@ -10986,6 +11181,7 @@ var REFS_INDEX_SQL = [
10986
11181
  var SYMBOLS_FTS_SQL = "CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(text, tokenize = 'unicode61')";
10987
11182
 
10988
11183
  // src/codebase-index/writer-search-helpers.ts
11184
+ var SEARCH_CANDIDATE_SCAN_CAP = 5e3;
10989
11185
  function normalizeSearchLimit(limit) {
10990
11186
  return typeof limit === "number" && Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : void 0;
10991
11187
  }
@@ -11015,8 +11211,8 @@ function buildWriterSearchWhere(query, filter) {
11015
11211
  }
11016
11212
  if (query.trim()) {
11017
11213
  const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
11018
- conditions.push(`(${tokens.map(() => "text LIKE ?").join(" OR ")})`);
11019
- for (const token of tokens) values.push(`%${token}%`);
11214
+ conditions.push(`(${tokens.map(() => "text LIKE ? ESCAPE '\\'").join(" OR ")})`);
11215
+ for (const token of tokens) values.push(`%${escapeLike(token)}%`);
11020
11216
  }
11021
11217
  return { where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", values };
11022
11218
  }
@@ -11125,6 +11321,7 @@ var StorePool = class {
11125
11321
 
11126
11322
  // src/codebase-index/writer.ts
11127
11323
  var DB_FILE2 = "index.db";
11324
+ var MAX_STATEMENT_CACHE = 128;
11128
11325
  var IndexStore = class _IndexStore {
11129
11326
  db;
11130
11327
  /** Absolute path to this project's index directory. */
@@ -11162,12 +11359,31 @@ var IndexStore = class _IndexStore {
11162
11359
  // Dirty on open so the first getOrBuildBm25() rebuilds against current rows;
11163
11360
  // an empty or pre-existing corpus makes a stale IDF table meaningless.
11164
11361
  bm25Dirty = true;
11165
- /** Prepare-once helper: compile `sql` on first use, reuse thereafter. */
11362
+ /**
11363
+ * Prepare-once helper: compile `sql` on first use, reuse thereafter.
11364
+ *
11365
+ * Bounded LRU rather than an open Map. The cache is keyed by SQL TEXT, and
11366
+ * the fallback search builder emits one `text LIKE ?` clause per query token
11367
+ * — so the SQL varies with the token count and a stream of differently-sized
11368
+ * queries grew the cache without limit. Sage's store already bounds its
11369
+ * equivalent at 128 (WS-096).
11370
+ *
11371
+ * Re-inserting on a hit keeps the hot fixed-SQL statements (upsertFile,
11372
+ * insertRefs, …) at the young end, so a burst of one-off search SQL evicts
11373
+ * itself rather than the reindex hot path.
11374
+ */
11166
11375
  stmt(sql) {
11167
- let s = this.stmtCache.get(sql);
11168
- if (s === void 0) {
11169
- s = this.db.prepare(sql);
11170
- this.stmtCache.set(sql, s);
11376
+ const cached = this.stmtCache.get(sql);
11377
+ if (cached !== void 0) {
11378
+ this.stmtCache.delete(sql);
11379
+ this.stmtCache.set(sql, cached);
11380
+ return cached;
11381
+ }
11382
+ const s = this.db.prepare(sql);
11383
+ this.stmtCache.set(sql, s);
11384
+ if (this.stmtCache.size > MAX_STATEMENT_CACHE) {
11385
+ const oldest = this.stmtCache.keys().next();
11386
+ if (!oldest.done) this.stmtCache.delete(oldest.value);
11171
11387
  }
11172
11388
  return s;
11173
11389
  }
@@ -11440,7 +11656,7 @@ var IndexStore = class _IndexStore {
11440
11656
  const { where, values } = built;
11441
11657
  const limit = normalizeSearchLimit(opts?.limit);
11442
11658
  const limitSql = limit !== void 0 ? " LIMIT ?" : "";
11443
- const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment, text FROM symbols ${where}${limitSql}`;
11659
+ const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment FROM symbols ${where}${limitSql}`;
11444
11660
  const binds = limit !== void 0 ? [...values, limit] : values;
11445
11661
  const rows = this.stmt(sql).all(
11446
11662
  ...binds
@@ -11569,11 +11785,13 @@ var IndexStore = class _IndexStore {
11569
11785
  /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
11570
11786
  searchRankedFallback(query, filter, limit) {
11571
11787
  if (!query.trim()) {
11572
- const total = this.countSearch(query, filter);
11573
- if (total === 0) return { results: [], total: 0 };
11574
- return { results: this.search(query, filter, { limit }), total };
11788
+ const total2 = this.countSearch(query, filter);
11789
+ if (total2 === 0) return { results: [], total: 0 };
11790
+ return { results: this.search(query, filter, { limit }), total: total2 };
11575
11791
  }
11576
- const candidates = this.search(query, filter);
11792
+ const total = this.countSearch(query, filter);
11793
+ if (total === 0) return { results: [], total: 0 };
11794
+ const candidates = this.search(query, filter, { limit: SEARCH_CANDIDATE_SCAN_CAP });
11577
11795
  if (candidates.length === 0) return { results: [], total: 0 };
11578
11796
  const candidateById = new Map(candidates.map((c) => [c.id, c]));
11579
11797
  const bm25 = this.getOrBuildBm25();
@@ -11599,7 +11817,7 @@ var IndexStore = class _IndexStore {
11599
11817
  const c = expectDefined2(candidateById.get(id));
11600
11818
  return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
11601
11819
  });
11602
- return { results, total: candidates.length };
11820
+ return { results, total };
11603
11821
  }
11604
11822
  getAllIndexable() {
11605
11823
  return getAllIndexableWithStatement((sql) => this.stmt(sql));
@@ -12018,12 +12236,12 @@ var buildIdCache;
12018
12236
  function projectIndexServerBuildId(entrypoint) {
12019
12237
  const file = entrypoint instanceof URL || entrypoint.startsWith("file:") ? fileURLToPath(entrypoint) : path16.resolve(entrypoint);
12020
12238
  try {
12021
- const stat18 = fs11.statSync(file);
12022
- if (buildIdCache?.file === file && buildIdCache.mtimeMs === stat18.mtimeMs && buildIdCache.size === stat18.size) {
12239
+ const stat19 = fs11.statSync(file);
12240
+ if (buildIdCache?.file === file && buildIdCache.mtimeMs === stat19.mtimeMs && buildIdCache.size === stat19.size) {
12023
12241
  return buildIdCache.buildId;
12024
12242
  }
12025
12243
  const buildId = createHash4("sha256").update(fs11.readFileSync(file)).digest("hex").slice(0, 24);
12026
- buildIdCache = { file, mtimeMs: stat18.mtimeMs, size: stat18.size, buildId };
12244
+ buildIdCache = { file, mtimeMs: stat19.mtimeMs, size: stat19.size, buildId };
12027
12245
  return buildId;
12028
12246
  } catch {
12029
12247
  return `unreadable:${path16.basename(file)}`;
@@ -12193,6 +12411,8 @@ var ProjectServerConnection = class {
12193
12411
  connectResolve = null;
12194
12412
  connectReject = null;
12195
12413
  nextId = 1;
12414
+ /** WS-027: read from the owner-only metadata file — see currentAuthToken. */
12415
+ authToken;
12196
12416
  pending = /* @__PURE__ */ new Map();
12197
12417
  transition(status, options = {}) {
12198
12418
  const previous = connectionStates.get(this.endpoint);
@@ -12329,6 +12549,31 @@ var ProjectServerConnection = class {
12329
12549
  this.transition("offline");
12330
12550
  maybeStopHeartbeatLoop();
12331
12551
  }
12552
+ /**
12553
+ * WS-027: the per-process token, read from the daemon's owner-only metadata
12554
+ * file — never from the `hello` frame, which the daemon sends to every
12555
+ * socket that connects (the mistake WS-028 found in the SAGE daemon).
12556
+ *
12557
+ * Read lazily and re-read while unknown: the daemon starts listening before
12558
+ * it writes metadata (endpoint ownership has to be won first), and a
12559
+ * respawned daemon mints a new token, so a cached wrong one would be sticky.
12560
+ */
12561
+ currentAuthToken() {
12562
+ if (this.authToken === void 0) {
12563
+ try {
12564
+ const raw = fs12.readFileSync(
12565
+ projectIndexServerMetadataPath(this.projectRoot, this.indexDir),
12566
+ "utf8"
12567
+ );
12568
+ const parsed = JSON.parse(raw);
12569
+ if (typeof parsed.authToken === "string" && parsed.authToken.length > 0) {
12570
+ this.authToken = parsed.authToken;
12571
+ }
12572
+ } catch {
12573
+ }
12574
+ }
12575
+ return this.authToken;
12576
+ }
12332
12577
  request(message, options) {
12333
12578
  const socket = this.socket;
12334
12579
  if (!socket || socket.destroyed) {
@@ -12372,7 +12617,7 @@ var ProjectServerConnection = class {
12372
12617
  return;
12373
12618
  }
12374
12619
  }
12375
- this.write({ ...message, id });
12620
+ this.write({ ...message, id, authToken: this.currentAuthToken() });
12376
12621
  });
12377
12622
  }
12378
12623
  async ensureConnected(spawnIfMissing) {
@@ -12419,6 +12664,7 @@ var ProjectServerConnection = class {
12419
12664
  connectOnce() {
12420
12665
  this.socket?.destroy();
12421
12666
  this.socket = null;
12667
+ this.authToken = void 0;
12422
12668
  this.info = null;
12423
12669
  this.activity = null;
12424
12670
  this.health = null;
@@ -13062,9 +13308,9 @@ async function runIndexerWithStore(store, opts) {
13062
13308
  const statReadParse = await Promise.allSettled(
13063
13309
  batchFiles.map(
13064
13310
  async (file) => {
13065
- let stat18;
13311
+ let stat19;
13066
13312
  try {
13067
- stat18 = await fs17.stat(file, statOpts);
13313
+ stat19 = await fs17.stat(file, statOpts);
13068
13314
  } catch (e) {
13069
13315
  if (isAbortError(e)) throw e;
13070
13316
  return {
@@ -13076,21 +13322,21 @@ async function runIndexerWithStore(store, opts) {
13076
13322
  missing: isMissingPathError(e)
13077
13323
  };
13078
13324
  }
13079
- if (!stat18.isFile()) return { file, stat: stat18, lang: "", parsed: null };
13325
+ if (!stat19.isFile()) return { file, stat: stat19, lang: "", parsed: null };
13080
13326
  const lang = detectLang(file);
13081
- if (!lang) return { file, stat: stat18, lang: "", parsed: null };
13082
- if (stat18.size > MAX_INDEX_FILE_BYTES) {
13327
+ if (!lang) return { file, stat: stat19, lang: "", parsed: null };
13328
+ if (stat19.size > MAX_INDEX_FILE_BYTES) {
13083
13329
  return {
13084
13330
  file,
13085
- stat: stat18,
13331
+ stat: stat19,
13086
13332
  lang,
13087
13333
  parsed: null,
13088
- error: `file too large (${stat18.size} bytes; max ${MAX_INDEX_FILE_BYTES})`
13334
+ error: `file too large (${stat19.size} bytes; max ${MAX_INDEX_FILE_BYTES})`
13089
13335
  };
13090
13336
  }
13091
13337
  const meta = existingMeta.get(file);
13092
- if (!force && meta && meta.mtimeMs === Math.floor(stat18.mtimeMs)) {
13093
- return { file, stat: stat18, lang, parsed: null, skippedMeta: meta };
13338
+ if (!force && meta && meta.mtimeMs === Math.floor(stat19.mtimeMs)) {
13339
+ return { file, stat: stat19, lang, parsed: null, skippedMeta: meta };
13094
13340
  }
13095
13341
  let content;
13096
13342
  try {
@@ -13099,7 +13345,7 @@ async function runIndexerWithStore(store, opts) {
13099
13345
  if (isAbortError(e)) throw e;
13100
13346
  return {
13101
13347
  file,
13102
- stat: stat18,
13348
+ stat: stat19,
13103
13349
  lang,
13104
13350
  parsed: null,
13105
13351
  error: `read error: ${e instanceof Error ? e.message : String(e)}`
@@ -13111,13 +13357,13 @@ async function runIndexerWithStore(store, opts) {
13111
13357
  } catch (e) {
13112
13358
  return {
13113
13359
  file,
13114
- stat: stat18,
13360
+ stat: stat19,
13115
13361
  lang,
13116
13362
  parsed: null,
13117
13363
  error: `parse error: ${e instanceof Error ? e.message : String(e)}`
13118
13364
  };
13119
13365
  }
13120
- return { file, stat: stat18, lang, parsed, content };
13366
+ return { file, stat: stat19, lang, parsed, content };
13121
13367
  }
13122
13368
  )
13123
13369
  );
@@ -13138,7 +13384,7 @@ async function runIndexerWithStore(store, opts) {
13138
13384
  errors.push(`${file}: ${result.error}`);
13139
13385
  continue;
13140
13386
  }
13141
- const { stat: stat18, lang, parsed } = result;
13387
+ const { stat: stat19, lang, parsed } = result;
13142
13388
  if (result.skippedMeta) {
13143
13389
  langStats[lang] = (langStats[lang] ?? 0) + result.skippedMeta.symbolCount;
13144
13390
  symbolsIndexed += result.skippedMeta.symbolCount;
@@ -13150,7 +13396,7 @@ async function runIndexerWithStore(store, opts) {
13150
13396
  store.upsertFile({
13151
13397
  file,
13152
13398
  lang,
13153
- mtimeMs: Math.floor(stat18.mtimeMs),
13399
+ mtimeMs: Math.floor(stat19.mtimeMs),
13154
13400
  symbolCount: 0,
13155
13401
  lastIndexed: Date.now()
13156
13402
  });
@@ -13162,7 +13408,7 @@ async function runIndexerWithStore(store, opts) {
13162
13408
  store.replaceEmptyFile({
13163
13409
  file,
13164
13410
  lang,
13165
- mtimeMs: Math.floor(stat18.mtimeMs),
13411
+ mtimeMs: Math.floor(stat19.mtimeMs),
13166
13412
  symbolCount: 0,
13167
13413
  lastIndexed: Date.now()
13168
13414
  });
@@ -13174,7 +13420,7 @@ async function runIndexerWithStore(store, opts) {
13174
13420
  lang,
13175
13421
  symbols: parsed.symbols,
13176
13422
  refs: parsed.refs ?? [],
13177
- mtimeMs: Math.floor(stat18.mtimeMs),
13423
+ mtimeMs: Math.floor(stat19.mtimeMs),
13178
13424
  symbolCount: parsed.symbols.length
13179
13425
  });
13180
13426
  deleteForFiles.push(file);
@@ -14370,8 +14616,9 @@ function runDeadCodeScan(projectRoot, opts = {}) {
14370
14616
  const content = fs19.readFileSync(epFile, "utf8");
14371
14617
  const strippedContent = content.replace(/\/\*[\s\S]*?\*\//g, (m) => " ".repeat(m.length)).replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length));
14372
14618
  const reExportRe = /export\s+(?:(?:type\s+)?\{[\s\S]*?\}\s+from|\*\s+as\s+\w+\s+from|\*\s+from)\s+['"]([^'"]+)['"]/g;
14373
- let match;
14374
- while ((match = reExportRe.exec(strippedContent)) !== null) {
14619
+ for (; ; ) {
14620
+ const match = reExportRe.exec(strippedContent);
14621
+ if (match === null) break;
14375
14622
  const moduleSpec = match[1];
14376
14623
  const resolvedFiles = resolveModulePath(epFile, moduleSpec, indexedFiles);
14377
14624
  for (const rf of resolvedFiles) {
@@ -14577,6 +14824,9 @@ var designTool = {
14577
14824
  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).',
14578
14825
  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"}`.',
14579
14826
  permission: "confirm",
14827
+ // WS-046: gives permission decisions something to key on.
14828
+ // The action performed; `out` is optional so it cannot be the subject.
14829
+ subjectKey: "action",
14580
14830
  mutating: true,
14581
14831
  capabilities: ["fs.write"],
14582
14832
  timeoutMs: 15e3,
@@ -14757,9 +15007,7 @@ Run \`design {action:"materialize"}\` to write the tuned tokens to your theme fi
14757
15007
  const abs = path25.join(absParent, path25.basename(absResolved));
14758
15008
  const rel = path25.relative(root, abs);
14759
15009
  if (rel.startsWith("..") || path25.isAbsolute(rel)) {
14760
- throw new Error(
14761
- `design: materialize path "${result.path}" would escape the project root`
14762
- );
15010
+ throw new Error(`design: materialize path "${result.path}" would escape the project root`);
14763
15011
  }
14764
15012
  let exists = false;
14765
15013
  try {
@@ -14834,6 +15082,7 @@ import { statSync as statSync3 } from "node:fs";
14834
15082
  import * as fs21 from "node:fs/promises";
14835
15083
  import * as path26 from "node:path";
14836
15084
  import { buildChildEnv as buildChildEnv3 } from "@wrongstack/core/utils";
15085
+ var MAX_FILE_DUMP_BYTES = 5 * 1024 * 1024;
14837
15086
  var diffTool = {
14838
15087
  name: "diff",
14839
15088
  category: "Filesystem",
@@ -14841,6 +15090,7 @@ var diffTool = {
14841
15090
  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).',
14842
15091
  permission: "auto",
14843
15092
  mutating: false,
15093
+ maxOutputBytes: 262144,
14844
15094
  capabilities: ["fs.read"],
14845
15095
  icon: "diff",
14846
15096
  timeoutMs: 1e4,
@@ -14916,8 +15166,8 @@ function findGitDir(cwd) {
14916
15166
  let dir = cwd;
14917
15167
  for (let i = 0; i < 20; i++) {
14918
15168
  try {
14919
- const stat18 = statSync3(path26.join(dir, ".git"));
14920
- if (stat18.isDirectory()) return dir;
15169
+ const stat19 = statSync3(path26.join(dir, ".git"));
15170
+ if (stat19.isDirectory()) return dir;
14921
15171
  } catch {
14922
15172
  }
14923
15173
  const parent = path26.dirname(dir);
@@ -14959,10 +15209,18 @@ async function fileDiff(input, ctx, _signal) {
14959
15209
  };
14960
15210
  }
14961
15211
  const results = [];
15212
+ let truncated = false;
14962
15213
  for (const file of files) {
14963
15214
  const absPath = safeResolve(file, ctx);
14964
- const stat18 = await fs21.stat(absPath).catch(() => null);
14965
- if (!stat18?.isFile()) continue;
15215
+ const stat19 = await fs21.stat(absPath).catch(() => null);
15216
+ if (!stat19?.isFile()) continue;
15217
+ if (stat19.size > MAX_FILE_DUMP_BYTES) {
15218
+ truncated = true;
15219
+ results.push(
15220
+ `--- ${file} (skipped: ${stat19.size} bytes exceeds the ${MAX_FILE_DUMP_BYTES} limit; use the read tool with offset/limit) ---`
15221
+ );
15222
+ continue;
15223
+ }
14966
15224
  const content = await fs21.readFile(absPath, "utf8");
14967
15225
  const lines = content.split(/\r?\n/);
14968
15226
  results.push(formatWithLineNumbers(file, lines));
@@ -14970,7 +15228,7 @@ async function fileDiff(input, ctx, _signal) {
14970
15228
  return {
14971
15229
  diff: results.join("\n\n"),
14972
15230
  files,
14973
- truncated: false,
15231
+ truncated,
14974
15232
  mode: input.mode ?? "unified"
14975
15233
  };
14976
15234
  }
@@ -15063,8 +15321,8 @@ async function resolveFiles(filesInput, cwd) {
15063
15321
  for (const f of files) {
15064
15322
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
15065
15323
  try {
15066
- const stat18 = await fs22.stat(absPath);
15067
- if (stat18.isFile()) resolved.push(absPath);
15324
+ const stat19 = await fs22.stat(absPath);
15325
+ if (stat19.isFile()) resolved.push(absPath);
15068
15326
  } catch {
15069
15327
  }
15070
15328
  }
@@ -15917,7 +16175,7 @@ var editTool = {
15917
16175
  });
15918
16176
  }
15919
16177
  const absPath = await safeResolveReal(input.path, ctx);
15920
- const stat18 = await fs23.stat(absPath).catch((err) => {
16178
+ const stat19 = await fs23.stat(absPath).catch((err) => {
15921
16179
  if (err.code === "ENOENT") {
15922
16180
  throw new ToolValidationError({
15923
16181
  message: `edit: file "${input.path}" does not exist. Use \`write\` instead.`,
@@ -15927,7 +16185,7 @@ var editTool = {
15927
16185
  }
15928
16186
  throw err;
15929
16187
  });
15930
- if (!stat18.isFile()) {
16188
+ if (!stat19.isFile()) {
15931
16189
  throw new ToolValidationError({
15932
16190
  message: `edit: "${input.path}" is not a regular file`,
15933
16191
  field: "path"
@@ -15957,7 +16215,7 @@ var editTool = {
15957
16215
  });
15958
16216
  }
15959
16217
  }
15960
- if (autoRead && updated.mtimeMs > stat18.mtimeMs + mtimeTolerance) {
16218
+ if (autoRead && updated.mtimeMs > stat19.mtimeMs + mtimeTolerance) {
15961
16219
  throw new ToolValidationError({
15962
16220
  message: `edit: file "${input.path}" changed while being auto-read. Retry the edit.`,
15963
16221
  field: "path",
@@ -16082,7 +16340,6 @@ import {
16082
16340
  import { toErrorMessage as toErrorMessage3 } from "@wrongstack/core/utils/error";
16083
16341
  init_output_spool();
16084
16342
  init_util();
16085
- init_process_registry();
16086
16343
  init_win32_resolve();
16087
16344
 
16088
16345
  // src/exec-kill-guard.ts
@@ -16320,8 +16577,7 @@ async function checkKillTarget(target) {
16320
16577
  return { blocked: false };
16321
16578
  }
16322
16579
 
16323
- // src/exec.ts
16324
- var isWin4 = process.platform === "win32";
16580
+ // src/exec-allowlist.ts
16325
16581
  var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16326
16582
  // JS / TS toolchain
16327
16583
  "node",
@@ -16400,7 +16656,11 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16400
16656
  "docker",
16401
16657
  "podman",
16402
16658
  "kubectl",
16403
- // network (read-only intent; destructive ops still blocked by BLOCKED_ARG_PATTERNS)
16659
+ // network fetch tools. Per-arg safety is NOT enforced by BLOCKED_ARG_PATTERNS
16660
+ // (no curl/wget entries) — the real boundary is the per-call `confirm`
16661
+ // permission gate (exec.ts permission:'confirm', subjectKey:'command'),
16662
+ // the `pipe-to-shell` danger rule, and the kill guard. The allowlist is a
16663
+ // guidance layer, not a security boundary on its own.
16404
16664
  "curl",
16405
16665
  "wget",
16406
16666
  // common POSIX file/text utilities
@@ -16453,9 +16713,15 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16453
16713
  "powershell.exe",
16454
16714
  "pwsh",
16455
16715
  "pwsh.exe",
16456
- // [core] Extended default allowlist (added 4b3d18d1 + this commit). All
16457
- // non-destructive, broadly-used dev binaries. Per-arg safety is still
16458
- // enforced by BLOCKED_ARG_PATTERNS + bash-kill-guard.ts.
16716
+ // [core] Extended default allowlist (added 4b3d18d1 + this commit). Broadly
16717
+ // used dev binaries — NOT all non-destructive: this section contains
16718
+ // process/system tools (kill, chown, mount, userdel), network scanners
16719
+ // (nmap, masscan, zmap, nuclei), and launchers (env, perl, timeout, tmux).
16720
+ // The real safety boundary for these is the per-call `confirm` permission
16721
+ // gate (exec.ts permission:'confirm', subjectKey:'command' renders the full
16722
+ // invocation) plus the danger rules in _danger-detect.ts. Per-arg hard-deny
16723
+ // is still enforced by BLOCKED_ARG_PATTERNS + bash-kill-guard.ts, but only
16724
+ // for the commands that have entries there (git, find, rm).
16459
16725
  // --- Archives & compression ---
16460
16726
  "7z",
16461
16727
  "7za",
@@ -16627,10 +16893,7 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16627
16893
  "git-lfs",
16628
16894
  "tig",
16629
16895
  "lazygit",
16630
- // --- POSIX text utilities (extended; duplicates of pwd/ls/cat/head/tail/wc/
16631
- // grep/find/echo/awk/mkdir/cp/mv/rm/touch from the base list above are
16632
- // omitted — the Set is de-duplicated at runtime but a clean literal is
16633
- // easier to maintain) ---
16896
+ // --- POSIX text utilities (extended) ---
16634
16897
  "gawk",
16635
16898
  "tr",
16636
16899
  "cut",
@@ -16663,39 +16926,16 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16663
16926
  "locate",
16664
16927
  "which",
16665
16928
  "whereis",
16666
- "type",
16667
- "hash",
16668
- "pushd",
16669
- "popd",
16670
- "dirs",
16671
- "history",
16672
- "fc",
16673
- "jobs",
16674
- "bg",
16675
- "fg",
16676
- "wait",
16677
- "ulimit",
16678
- "umask",
16929
+ // NOTE: shell builtins with no standalone binary (type, hash, pushd,
16930
+ // popd, dirs, history, fc, jobs, bg, fg, wait, ulimit, umask, trap,
16931
+ // exit, return, source, ., alias, unalias, set, unset, export, readonly,
16932
+ // typeset, declare, local, eval, exec) are intentionally excluded —
16933
+ // spawn() would fail with ENOENT on every platform because they only
16934
+ // exist inside a shell process. Use the `bash` tool for builtins.
16679
16935
  "nice",
16680
16936
  "nohup",
16681
16937
  "timeout",
16682
16938
  "time",
16683
- "trap",
16684
- "exit",
16685
- "return",
16686
- "source",
16687
- ".",
16688
- "alias",
16689
- "unalias",
16690
- "set",
16691
- "unset",
16692
- "export",
16693
- "readonly",
16694
- "typeset",
16695
- "declare",
16696
- "local",
16697
- "eval",
16698
- "exec",
16699
16939
  // --- Process / system inspection ---
16700
16940
  "htop",
16701
16941
  "top",
@@ -16926,6 +17166,10 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16926
17166
  "sublert",
16927
17167
  "chaos"
16928
17168
  ]);
17169
+
17170
+ // src/exec.ts
17171
+ init_process_registry();
17172
+ var isWin4 = process.platform === "win32";
16929
17173
  var allowedCommands = new Set(DEFAULT_ALLOWED_COMMANDS);
16930
17174
  var normalizeCmd = (c) => c.trim();
16931
17175
  function configureExecPolicy(opts = {}) {
@@ -16986,7 +17230,17 @@ var BLOCKED_ARG_PATTERNS = {
16986
17230
  bun: [],
16987
17231
  docker: [],
16988
17232
  // find -exec/-ok/-execdir execute arbitrary commands
16989
- find: [/^-exec$/, /^-exec;$/, /^-ok$/, /^-ok;$/, /^-execdir$/, /^-execdir;$/, /^-exec=/, /^-ok=/, /^-execdir=/],
17233
+ find: [
17234
+ /^-exec$/,
17235
+ /^-exec;$/,
17236
+ /^-ok$/,
17237
+ /^-ok;$/,
17238
+ /^-execdir$/,
17239
+ /^-execdir;$/,
17240
+ /^-exec=/,
17241
+ /^-ok=/,
17242
+ /^-execdir=/
17243
+ ],
16990
17244
  // rm -rf / is catastrophic — block absolute paths, home, dot-dirs,
16991
17245
  // and glob patterns that could expand to dangerous targets.
16992
17246
  // `rm -rf ./src/*` expands to project files; `rm -rf ../../` escapes upward;
@@ -17057,6 +17311,14 @@ var execTool = {
17057
17311
  useInstead: ["bash"]
17058
17312
  },
17059
17313
  permission: "confirm",
17314
+ // WS-046: without this, every exec call collapsed onto the bare tool name.
17315
+ // Three consequences followed: "always allow" stored a rule that could never
17316
+ // match, pressing "no" once blocked ALL exec for the session, and the
17317
+ // permission cache keyed one decision for every command. `subjectKey:
17318
+ // 'command'` renders the FULL invocation (`command` + `args`) — using the
17319
+ // program alone would make `exec git status` and `exec git push --force` the
17320
+ // same subject, so trusting one would silently authorize the other.
17321
+ subjectKey: "command",
17060
17322
  mutating: true,
17061
17323
  riskTier: "standard",
17062
17324
  timeoutMs: DEFAULT_TIMEOUT_MS3,
@@ -17273,7 +17535,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId, danger) {
17273
17535
  const pid = child.pid;
17274
17536
  if (typeof pid === "number") {
17275
17537
  const fullCommand = `${cmd} ${args.join(" ")}`;
17276
- registry.register({ pid, name: "exec", command: redactCommand(fullCommand), startedAt: Date.now(), sessionId, child });
17538
+ registry.register({
17539
+ pid,
17540
+ name: "exec",
17541
+ command: redactCommand(fullCommand),
17542
+ startedAt: Date.now(),
17543
+ sessionId,
17544
+ child
17545
+ });
17277
17546
  }
17278
17547
  const timer = setTimeout(() => {
17279
17548
  killed = true;
@@ -17713,6 +17982,9 @@ var formatTool = {
17713
17982
  description: "Format source files according to project style (Biome). Can also run in check-only mode.",
17714
17983
  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.",
17715
17984
  permission: "confirm",
17985
+ // WS-046: gives permission decisions something to key on.
17986
+ // The files being rewritten are the subject; the fixer is how, not what.
17987
+ subjectKey: "files",
17716
17988
  mutating: true,
17717
17989
  capabilities: ["fs.write", "shell.restricted"],
17718
17990
  icon: "code",
@@ -17817,13 +18089,13 @@ var formatTool = {
17817
18089
  }
17818
18090
  };
17819
18091
  async function detectFixer(cwd) {
17820
- const { stat: stat18 } = await import("node:fs/promises");
18092
+ const { stat: stat19 } = await import("node:fs/promises");
17821
18093
  try {
17822
- await stat18(`${cwd}/biome.json`);
18094
+ await stat19(`${cwd}/biome.json`);
17823
18095
  return "biome";
17824
18096
  } catch {
17825
18097
  try {
17826
- await stat18(`${cwd}/.prettierrc`);
18098
+ await stat19(`${cwd}/.prettierrc`);
17827
18099
  return "prettier";
17828
18100
  } catch {
17829
18101
  return "biome";
@@ -17850,6 +18122,10 @@ var gitTool = {
17850
18122
  // Conservative: any of these may mutate. The non-mutating commands
17851
18123
  // (status/log/diff/branch/fetch) are still gated on `permission: 'confirm'`
17852
18124
  // and `MUTATING_SUBCOMMANDS` is consulted at runtime for per-call checks.
18125
+ // WS-046: gives permission decisions something to key on.
18126
+ // The git subcommand (status/commit/push) is what a trust rule needs to
18127
+ // distinguish; `git status` and `git push --force` must not share a subject.
18128
+ subjectKey: "command",
17853
18129
  mutating: true,
17854
18130
  capabilities: ["fs.write", "shell.restricted"],
17855
18131
  timeoutMs: TIMEOUT_MS2,
@@ -17918,6 +18194,8 @@ var gitTool = {
17918
18194
  truncated: false
17919
18195
  };
17920
18196
  }
18197
+ const branchGuard = validateBranchInput(input);
18198
+ if (branchGuard) return branchGuard;
17921
18199
  if (input.command === "worktree") {
17922
18200
  const guard = validateWorktreeInput(input, ctx.projectRoot);
17923
18201
  if (guard) return guard;
@@ -17966,6 +18244,18 @@ var gitTool = {
17966
18244
  return result;
17967
18245
  }
17968
18246
  };
18247
+ function validateBranchInput(input) {
18248
+ const branch = input.branch;
18249
+ if (branch === void 0) return null;
18250
+ if (!branch.startsWith("-") && !branch.includes(" --")) return null;
18251
+ return {
18252
+ command: input.command,
18253
+ stdout: "",
18254
+ stderr: `unsafe branch name (parsed as a git option): ${branch}`,
18255
+ exitCode: 1,
18256
+ truncated: false
18257
+ };
18258
+ }
17969
18259
  function validateWorktreeInput(input, projectRoot) {
17970
18260
  const reject = (stderr) => ({
17971
18261
  command: "worktree",
@@ -17974,7 +18264,6 @@ function validateWorktreeInput(input, projectRoot) {
17974
18264
  exitCode: 1,
17975
18265
  truncated: false
17976
18266
  });
17977
- if (input.branch?.startsWith("-")) return reject(`unsafe branch name: ${input.branch}`);
17978
18267
  if (input.worktreePath?.startsWith("-")) {
17979
18268
  return reject(`unsafe worktree path: ${input.worktreePath}`);
17980
18269
  }
@@ -17992,8 +18281,8 @@ function findGitDir2(cwd, projectRoot) {
17992
18281
  let dir = cwd;
17993
18282
  for (let i = 0; i < 20; i++) {
17994
18283
  try {
17995
- const stat18 = statSync4(`${dir}/.git`);
17996
- if (stat18.isDirectory() || stat18.isFile()) return dir;
18284
+ const stat19 = statSync4(`${dir}/.git`);
18285
+ if (stat19.isDirectory() || stat19.isFile()) return dir;
17997
18286
  } catch {
17998
18287
  }
17999
18288
  if (dir === root) break;
@@ -18028,7 +18317,10 @@ function buildArgs(input) {
18028
18317
  ...files.length ? ["--", ...files] : []
18029
18318
  ];
18030
18319
  case "branch":
18031
- return input.branch ? ["branch", ...input.branch.startsWith("-") || input.branch.includes(" --") ? [] : [input.branch]] : ["branch"];
18320
+ return input.branch ? [
18321
+ "branch",
18322
+ ...input.branch.startsWith("-") || input.branch.includes(" --") ? [] : [input.branch]
18323
+ ] : ["branch"];
18032
18324
  case "checkout":
18033
18325
  return [
18034
18326
  "checkout",
@@ -18121,7 +18413,7 @@ import * as path30 from "node:path";
18121
18413
  import { compileGlob as compileGlob2, DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS2 } from "@wrongstack/core/utils";
18122
18414
 
18123
18415
  // src/_concurrency.ts
18124
- async function mapWithConcurrency(items, limit, fn) {
18416
+ async function mapWithConcurrency2(items, limit, fn) {
18125
18417
  if (items.length === 0) return [];
18126
18418
  const effectiveLimit = Math.max(1, Math.min(limit | 0, items.length));
18127
18419
  const results = new Array(items.length);
@@ -18256,10 +18548,10 @@ var globTool = {
18256
18548
  }
18257
18549
  if (truncated) return;
18258
18550
  }
18259
- await mapWithConcurrency(matchedFiles, WALK_CONCURRENCY, pushResult);
18551
+ await mapWithConcurrency2(matchedFiles, WALK_CONCURRENCY, pushResult);
18260
18552
  if (truncated) return;
18261
18553
  const remainingSubdirs = truncated ? [] : subdirs;
18262
- await mapWithConcurrency(remainingSubdirs, WALK_CONCURRENCY, ({ full, rel }) => walk2(full, rel));
18554
+ await mapWithConcurrency2(remainingSubdirs, WALK_CONCURRENCY, ({ full, rel }) => walk2(full, rel));
18263
18555
  };
18264
18556
  await walk2(base, "");
18265
18557
  results.sort((a, b) => b.mtime - a.mtime);
@@ -18268,12 +18560,16 @@ var globTool = {
18268
18560
  };
18269
18561
 
18270
18562
  // src/grep.ts
18271
- import { expectDefined as expectDefined7 } from "@wrongstack/core/utils";
18272
18563
  import { spawn as spawn11 } from "node:child_process";
18273
18564
  import * as fs25 from "node:fs/promises";
18274
18565
  import * as path31 from "node:path";
18275
18566
  import { ToolValidationError as ToolValidationError4 } from "@wrongstack/core/types";
18276
- import { buildChildEnv as buildChildEnv5, compileGlob as compileGlob3, DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS3 } from "@wrongstack/core/utils";
18567
+ import {
18568
+ buildChildEnv as buildChildEnv5,
18569
+ compileGlob as compileGlob3,
18570
+ DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS3,
18571
+ expectDefined as expectDefined7
18572
+ } from "@wrongstack/core/utils";
18277
18573
 
18278
18574
  // src/_regex.ts
18279
18575
  var MAX_PATTERN_LEN = 256;
@@ -18326,6 +18622,8 @@ var DEFAULT_IGNORE3 = DEFAULT_WALK_IGNORE_DIRS3;
18326
18622
  var NATIVE_SCAN_CONCURRENCY = 32;
18327
18623
  var NATIVE_READ_CHUNK_BYTES = 64 * 1024;
18328
18624
  var NATIVE_MAX_FILE_BYTES = 1e6;
18625
+ var RG_MAX_QUEUE_CHUNKS = 128;
18626
+ var RG_MAX_QUEUE_CHARS = 8 * 1024 * 1024;
18329
18627
  var grepTool = {
18330
18628
  name: "grep",
18331
18629
  category: "Search",
@@ -18453,9 +18751,19 @@ async function* runRgStream(input, base, mode, limit, signal) {
18453
18751
  const FLUSH_AT = 16;
18454
18752
  const MAX_BUF_BYTES = 1e6;
18455
18753
  let bufOverflow = false;
18456
- const child = spawn11("rg", args, { signal, env: buildChildEnv5(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
18754
+ const child = spawn11("rg", args, {
18755
+ signal,
18756
+ env: buildChildEnv5(),
18757
+ // rg diagnostics are not part of the tool result. Ignoring stderr avoids
18758
+ // an unread pipe filling up and pinning the child when rg reports many
18759
+ // filesystem errors.
18760
+ stdio: ["ignore", "pipe", "ignore"],
18761
+ windowsHide: true
18762
+ });
18457
18763
  const queue = [];
18764
+ let queuedChars = 0;
18458
18765
  let waiter;
18766
+ let paused = false;
18459
18767
  const wake = () => {
18460
18768
  if (waiter) {
18461
18769
  const w = waiter;
@@ -18463,93 +18771,133 @@ async function* runRgStream(input, base, mode, limit, signal) {
18463
18771
  w();
18464
18772
  }
18465
18773
  };
18466
- child.stdout?.on("data", (c) => {
18467
- queue.push({ kind: "out", data: c.toString() });
18774
+ const pauseIfFlooded = () => {
18775
+ if (paused || queue.length < RG_MAX_QUEUE_CHUNKS && queuedChars < RG_MAX_QUEUE_CHARS)
18776
+ return;
18777
+ paused = true;
18778
+ child.stdout?.pause();
18779
+ };
18780
+ const resumeIfDrained = () => {
18781
+ if (!paused || queue.length >= RG_MAX_QUEUE_CHUNKS || queuedChars >= RG_MAX_QUEUE_CHARS)
18782
+ return;
18783
+ paused = false;
18784
+ child.stdout?.resume();
18785
+ };
18786
+ const onStdoutData = (c) => {
18787
+ const data = c.toString();
18788
+ queue.push({ kind: "out", data });
18789
+ queuedChars += data.length;
18468
18790
  wake();
18469
- });
18470
- child.on("error", (e) => {
18791
+ pauseIfFlooded();
18792
+ };
18793
+ const onError = (e) => {
18471
18794
  queue.push({ kind: "error", data: e.message });
18472
18795
  wake();
18473
- });
18474
- child.on("close", () => {
18796
+ };
18797
+ const onClose = () => {
18475
18798
  queue.push({ kind: "close", data: "" });
18476
18799
  wake();
18477
- });
18800
+ };
18801
+ child.stdout?.on("data", onStdoutData);
18802
+ child.on("error", onError);
18803
+ child.on("close", onClose);
18478
18804
  let pendingBatch = [];
18479
18805
  let errored = false;
18480
- for (; ; ) {
18481
- while (queue.length === 0) {
18482
- await new Promise((r) => {
18483
- waiter = r;
18484
- });
18485
- }
18486
- const c = expectDefined7(queue.shift());
18487
- if (c.kind === "error") {
18488
- errored = true;
18489
- continue;
18490
- }
18491
- if (c.kind === "close") break;
18492
- buf += c.data;
18493
- if (buf.length > MAX_BUF_BYTES && !bufOverflow) {
18494
- bufOverflow = true;
18495
- buf = buf.slice(-MAX_BUF_BYTES);
18496
- try {
18497
- child.kill("SIGTERM");
18498
- } catch {
18806
+ let closed = false;
18807
+ try {
18808
+ for (; ; ) {
18809
+ while (queue.length === 0) {
18810
+ await new Promise((r) => {
18811
+ waiter = r;
18812
+ });
18499
18813
  }
18500
- }
18501
- const idx = buf.lastIndexOf("\n");
18502
- if (idx === -1) continue;
18503
- const ready = buf.slice(0, idx);
18504
- buf = buf.slice(idx + 1);
18505
- for (const line of ready.split("\n")) {
18506
- if (!line) continue;
18507
- totalLines++;
18508
- if (mode === "count") totalCount += parseRgCountLine(line);
18509
- if (matches.length < limit) {
18510
- matches.push(line);
18511
- pendingBatch.push(line);
18512
- batchSinceFlush++;
18814
+ const c = expectDefined7(queue.shift());
18815
+ queuedChars = Math.max(0, queuedChars - c.data.length);
18816
+ resumeIfDrained();
18817
+ if (c.kind === "error") {
18818
+ errored = true;
18819
+ continue;
18820
+ }
18821
+ if (c.kind === "close") {
18822
+ closed = true;
18823
+ break;
18824
+ }
18825
+ buf += c.data;
18826
+ if (buf.length > MAX_BUF_BYTES && !bufOverflow) {
18827
+ bufOverflow = true;
18828
+ buf = buf.slice(-MAX_BUF_BYTES);
18829
+ try {
18830
+ child.kill("SIGTERM");
18831
+ } catch {
18832
+ }
18833
+ }
18834
+ const idx = buf.lastIndexOf("\n");
18835
+ if (idx === -1) continue;
18836
+ const ready = buf.slice(0, idx);
18837
+ buf = buf.slice(idx + 1);
18838
+ for (const line of ready.split("\n")) {
18839
+ if (!line) continue;
18840
+ totalLines++;
18841
+ if (mode === "count") totalCount += parseRgCountLine(line);
18842
+ if (matches.length < limit) {
18843
+ matches.push(line);
18844
+ pendingBatch.push(line);
18845
+ batchSinceFlush++;
18846
+ }
18847
+ }
18848
+ if (batchSinceFlush >= FLUSH_AT) {
18849
+ yield {
18850
+ type: "partial_output",
18851
+ text: pendingBatch.join("\n"),
18852
+ data: { matches_so_far: matches.length }
18853
+ };
18854
+ pendingBatch = [];
18855
+ batchSinceFlush = 0;
18856
+ }
18857
+ }
18858
+ if (buf.trim()) {
18859
+ for (const line of buf.split("\n")) {
18860
+ if (!line) continue;
18861
+ totalLines++;
18862
+ if (mode === "count") totalCount += parseRgCountLine(line);
18863
+ if (matches.length < limit) {
18864
+ matches.push(line);
18865
+ pendingBatch.push(line);
18866
+ }
18513
18867
  }
18514
18868
  }
18515
- if (batchSinceFlush >= FLUSH_AT) {
18869
+ if (pendingBatch.length > 0) {
18516
18870
  yield {
18517
18871
  type: "partial_output",
18518
18872
  text: pendingBatch.join("\n"),
18519
18873
  data: { matches_so_far: matches.length }
18520
18874
  };
18521
- pendingBatch = [];
18522
- batchSinceFlush = 0;
18523
- }
18524
- }
18525
- if (buf.trim()) {
18526
- for (const line of buf.split("\n")) {
18527
- if (!line) continue;
18528
- totalLines++;
18529
- if (mode === "count") totalCount += parseRgCountLine(line);
18530
- if (matches.length < limit) {
18531
- matches.push(line);
18532
- pendingBatch.push(line);
18533
- }
18534
18875
  }
18535
- }
18536
- if (pendingBatch.length > 0) {
18876
+ if (errored) throw new Error("rg: spawn error");
18537
18877
  yield {
18538
- type: "partial_output",
18539
- text: pendingBatch.join("\n"),
18540
- data: { matches_so_far: matches.length }
18878
+ type: "final",
18879
+ output: {
18880
+ matches,
18881
+ count: mode === "count" ? totalCount : totalLines,
18882
+ truncated: totalLines > limit || bufOverflow,
18883
+ used: "rg"
18884
+ }
18541
18885
  };
18542
- }
18543
- if (errored) throw new Error("rg: spawn error");
18544
- yield {
18545
- type: "final",
18546
- output: {
18547
- matches,
18548
- count: mode === "count" ? totalCount : totalLines,
18549
- truncated: totalLines > limit || bufOverflow,
18550
- used: "rg"
18886
+ } finally {
18887
+ child.stdout?.off("data", onStdoutData);
18888
+ child.off("error", onError);
18889
+ child.off("close", onClose);
18890
+ child.stdout?.destroy();
18891
+ queue.length = 0;
18892
+ queuedChars = 0;
18893
+ waiter = void 0;
18894
+ if (!closed && child.exitCode === null) {
18895
+ try {
18896
+ child.kill("SIGTERM");
18897
+ } catch {
18898
+ }
18551
18899
  }
18552
- };
18900
+ }
18553
18901
  }
18554
18902
  function parseRgCountLine(line) {
18555
18903
  const idx = line.lastIndexOf(":");
@@ -18579,8 +18927,8 @@ async function runNative(input, base, mode, limit, signal) {
18579
18927
  if (globRe && !globRe.test(name) && !globRe.test(full)) return;
18580
18928
  if (globRe) globRe.lastIndex = 0;
18581
18929
  try {
18582
- const stat18 = await fs25.stat(full);
18583
- if (!stat18.isFile() || stat18.size > maxBytes || stopped || signal.aborted) return;
18930
+ const stat19 = await fs25.stat(full);
18931
+ if (!stat19.isFile() || stat19.size > maxBytes || stopped || signal.aborted) return;
18584
18932
  const file = await fs25.open(full, "r");
18585
18933
  try {
18586
18934
  let bytesReadTotal = 0;
@@ -18680,8 +19028,8 @@ async function runNative(input, base, mode, limit, signal) {
18680
19028
  files.push({ full, name: e.name });
18681
19029
  }
18682
19030
  }
18683
- await mapWithConcurrency(files, NATIVE_SCAN_CONCURRENCY, ({ full, name }) => scanFile(full, name));
18684
- await mapWithConcurrency(
19031
+ await mapWithConcurrency2(files, NATIVE_SCAN_CONCURRENCY, ({ full, name }) => scanFile(full, name));
19032
+ await mapWithConcurrency2(
18685
19033
  subdirs,
18686
19034
  Math.min(16, NATIVE_SCAN_CONCURRENCY),
18687
19035
  ({ full, rel }) => walk2(full, rel)
@@ -18700,7 +19048,7 @@ async function runNative(input, base, mode, limit, signal) {
18700
19048
  init_spawn_stream();
18701
19049
  init_util();
18702
19050
  init_legacy_bridge();
18703
- import { join as join24 } from "node:path";
19051
+ import { join as join25 } from "node:path";
18704
19052
  import {
18705
19053
  detectEcosystem as detectPackageEcosystem,
18706
19054
  recordPackageAction
@@ -18711,6 +19059,10 @@ var installTool = {
18711
19059
  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.",
18712
19060
  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.",
18713
19061
  permission: "confirm",
19062
+ // WS-046: gives permission decisions something to key on.
19063
+ // What gets installed is the subject — installing a package runs its
19064
+ // lifecycle scripts, so `express` and `evil-pkg` must not be interchangeable.
19065
+ subjectKey: "packages",
18714
19066
  mutating: true,
18715
19067
  riskTier: "standard",
18716
19068
  icon: "package",
@@ -18880,10 +19232,10 @@ function resolveManifestPath(cwd, pkgManager) {
18880
19232
  case "pnpm":
18881
19233
  case "yarn":
18882
19234
  case "npm":
18883
- return join24(cwd, "package.json");
19235
+ return join25(cwd, "package.json");
18884
19236
  /* v8 ignore next 2 -- pkgManager is always pnpm/yarn/npm; the default is defensive. */
18885
19237
  default:
18886
- return join24(cwd, "package.json");
19238
+ return join25(cwd, "package.json");
18887
19239
  }
18888
19240
  }
18889
19241
 
@@ -18891,6 +19243,24 @@ function resolveManifestPath(cwd, pkgManager) {
18891
19243
  init_util();
18892
19244
  import * as fs26 from "node:fs/promises";
18893
19245
  import { deepMerge as deepMergeCore } from "@wrongstack/core/utils";
19246
+ var MAX_JSON_FILE_BYTES = 16 * 1024 * 1024;
19247
+ var MAX_JSON_FILE_BYTES_HUMAN = "16 MiB";
19248
+ var JsonFileTooLargeError = class extends Error {
19249
+ constructor(filePath, size) {
19250
+ super(
19251
+ `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.`
19252
+ );
19253
+ this.name = "JsonFileTooLargeError";
19254
+ }
19255
+ };
19256
+ async function readJsonFileBounded(filePath, ctx) {
19257
+ const resolved = await safeResolveReal(filePath, ctx);
19258
+ const stat19 = await fs26.stat(resolved);
19259
+ if (stat19.size > MAX_JSON_FILE_BYTES) {
19260
+ throw new JsonFileTooLargeError(filePath, stat19.size);
19261
+ }
19262
+ return fs26.readFile(resolved, "utf8");
19263
+ }
18894
19264
  var jsonTool = {
18895
19265
  name: "json",
18896
19266
  category: "Data",
@@ -18898,6 +19268,7 @@ var jsonTool = {
18898
19268
  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.',
18899
19269
  permission: "auto",
18900
19270
  mutating: false,
19271
+ maxOutputBytes: 262144,
18901
19272
  timeoutMs: 5e3,
18902
19273
  capabilities: ["fs.read"],
18903
19274
  icon: "json",
@@ -18964,9 +19335,15 @@ async function executeParse(input, ctx) {
18964
19335
  let raw;
18965
19336
  if (input.file) {
18966
19337
  try {
18967
- raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
18968
- } catch {
18969
- return { data: null, formatted: "", type: "unknown", action: "parse", error: "Could not read file" };
19338
+ raw = await readJsonFileBounded(input.file, ctx);
19339
+ } catch (error) {
19340
+ return {
19341
+ data: null,
19342
+ formatted: "",
19343
+ type: "unknown",
19344
+ action: "parse",
19345
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read file"
19346
+ };
18970
19347
  }
18971
19348
  } else if (input.data) {
18972
19349
  raw = input.data;
@@ -19022,10 +19399,16 @@ async function executeQuery(input, ctx) {
19022
19399
  let parsed;
19023
19400
  if (input.file) {
19024
19401
  try {
19025
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19402
+ const raw = await readJsonFileBounded(input.file, ctx);
19026
19403
  parsed = JSON.parse(raw);
19027
- } catch {
19028
- return { data: null, formatted: "", type: "unknown", action: "query", error: "Could not read/parse file" };
19404
+ } catch (error) {
19405
+ return {
19406
+ data: null,
19407
+ formatted: "",
19408
+ type: "unknown",
19409
+ action: "query",
19410
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19411
+ };
19029
19412
  }
19030
19413
  } else if (input.data) {
19031
19414
  try {
@@ -19064,10 +19447,16 @@ async function executeValidate(input, ctx) {
19064
19447
  let parsed;
19065
19448
  if (input.file) {
19066
19449
  try {
19067
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19450
+ const raw = await readJsonFileBounded(input.file, ctx);
19068
19451
  parsed = JSON.parse(raw);
19069
- } catch {
19070
- return { data: null, formatted: "", type: "unknown", action: "validate", error: "Could not read/parse file" };
19452
+ } catch (error) {
19453
+ return {
19454
+ data: null,
19455
+ formatted: "",
19456
+ type: "unknown",
19457
+ action: "validate",
19458
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19459
+ };
19071
19460
  }
19072
19461
  } else if (input.data) {
19073
19462
  try {
@@ -19106,10 +19495,16 @@ async function executeTransform(input, ctx) {
19106
19495
  let parsed;
19107
19496
  if (input.file) {
19108
19497
  try {
19109
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19498
+ const raw = await readJsonFileBounded(input.file, ctx);
19110
19499
  parsed = JSON.parse(raw);
19111
- } catch {
19112
- return { data: null, formatted: "", type: "unknown", action: "transform", error: "Could not read/parse file" };
19500
+ } catch (error) {
19501
+ return {
19502
+ data: null,
19503
+ formatted: "",
19504
+ type: "unknown",
19505
+ action: "transform",
19506
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19507
+ };
19113
19508
  }
19114
19509
  } else if (input.data) {
19115
19510
  try {
@@ -19713,6 +20108,10 @@ async function handleKanbanDetailAction(projectRoot, input) {
19713
20108
  }
19714
20109
 
19715
20110
  // src/kanban-board-inputs.ts
20111
+ function agentSettableGate(enforcement) {
20112
+ if (enforcement === void 0 || enforcement === "off") return {};
20113
+ return { completionGate: { enforcement } };
20114
+ }
19716
20115
  function boardCreateInput(input, title) {
19717
20116
  return {
19718
20117
  title,
@@ -19725,7 +20124,7 @@ function boardCreateInput(input, title) {
19725
20124
  decomposition: input.atomicityDecomposition ?? "propose"
19726
20125
  }
19727
20126
  } : {},
19728
- ...input.gateEnforcement !== void 0 ? { completionGate: { enforcement: input.gateEnforcement } } : {}
20127
+ ...agentSettableGate(input.gateEnforcement)
19729
20128
  };
19730
20129
  }
19731
20130
  function boardUpdatePatch(input) {
@@ -19739,7 +20138,7 @@ function boardUpdatePatch(input) {
19739
20138
  decomposition: input.atomicityDecomposition ?? "propose"
19740
20139
  }
19741
20140
  } : {},
19742
- ...input.gateEnforcement !== void 0 ? { completionGate: { enforcement: input.gateEnforcement } } : {}
20141
+ ...agentSettableGate(input.gateEnforcement)
19743
20142
  };
19744
20143
  }
19745
20144
  function duplicateBoardOptions(input) {
@@ -19754,6 +20153,7 @@ function duplicateBoardOptions(input) {
19754
20153
 
19755
20154
  // src/kanban-task-inputs.ts
19756
20155
  import { randomUUID } from "node:crypto";
20156
+ import { clampSubagentCapabilities } from "@wrongstack/core/security";
19757
20157
  function taskInput(input) {
19758
20158
  const assignment = hasAssignmentInput(input) ? assignmentForTaskCreate(input) : void 0;
19759
20159
  return {
@@ -19851,6 +20251,10 @@ function taskPatch(input) {
19851
20251
  ...input.actualHours !== void 0 ? { actualHours: input.actualHours } : {}
19852
20252
  };
19853
20253
  }
20254
+ function clampRequestedCapabilities(requested) {
20255
+ if (requested === void 0) return void 0;
20256
+ return clampSubagentCapabilities(requested).granted;
20257
+ }
19854
20258
  function assignmentInput(input) {
19855
20259
  return {
19856
20260
  agentId: input.agentId,
@@ -19861,7 +20265,7 @@ function assignmentInput(input) {
19861
20265
  fallbackProfile: input.fallbackProfile,
19862
20266
  fallbackModels: input.fallbackModels,
19863
20267
  tools: input.tools,
19864
- allowedCapabilities: input.allowedCapabilities,
20268
+ allowedCapabilities: clampRequestedCapabilities(input.allowedCapabilities),
19865
20269
  assignee: input.assignee,
19866
20270
  leaseId: input.leaseId,
19867
20271
  claimedAt: input.claimedAt,
@@ -19888,7 +20292,7 @@ function assignmentForTaskCreate(input) {
19888
20292
  ...input.fallbackProfile !== void 0 ? { fallbackProfile: input.fallbackProfile } : {},
19889
20293
  ...input.fallbackModels !== void 0 ? { fallbackModels: input.fallbackModels } : {},
19890
20294
  ...input.tools !== void 0 ? { tools: input.tools } : {},
19891
- ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: input.allowedCapabilities } : {},
20295
+ ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: clampRequestedCapabilities(input.allowedCapabilities) } : {},
19892
20296
  ...input.leaseId !== void 0 ? { leaseId: input.leaseId } : {},
19893
20297
  ...input.claimedAt !== void 0 ? { claimedAt: input.claimedAt } : {},
19894
20298
  ...input.heartbeatAt !== void 0 ? { heartbeatAt: input.heartbeatAt } : {},
@@ -20099,7 +20503,13 @@ var KANBAN_INPUT_SCHEMA = {
20099
20503
  moveTasksToColumnId: { type: "string" },
20100
20504
  atomicityMode: { type: "string", enum: ["off", "assess", "enforce"] },
20101
20505
  atomicityDecomposition: { type: "string", enum: ["auto", "propose"] },
20102
- gateEnforcement: { type: "string", enum: ["strict", "soft", "off"] },
20506
+ gateEnforcement: {
20507
+ type: "string",
20508
+ // WS-023: `'off'` is deliberately absent. The agent whose work this gate
20509
+ // checks must not be able to switch it off; it may only tighten. Turning
20510
+ // a gate off stays a human decision, made through board config.
20511
+ enum: ["strict", "soft"]
20512
+ },
20103
20513
  subtasks: {
20104
20514
  type: "array",
20105
20515
  minItems: 2,
@@ -20141,7 +20551,7 @@ function createKanbanPresenceWrapper(projectRoot, input, ctx) {
20141
20551
 
20142
20552
  // src/session-kanban.ts
20143
20553
  import { watch } from "node:fs";
20144
- import { basename as basename12, dirname as dirname15 } from "node:path";
20554
+ import { basename as basename13, dirname as dirname15 } from "node:path";
20145
20555
  import { getSharedProjectMailbox } from "@wrongstack/core/coordination";
20146
20556
  import {
20147
20557
  loadPlan,
@@ -20231,7 +20641,9 @@ async function ensureSessionKanbanBoard(projectRoot, sessionId) {
20231
20641
  description: "Live session work: todos, tasks, and plan items.",
20232
20642
  tags: sessionBoardTags(sessionId),
20233
20643
  columns: SESSION_KANBAN_COLUMNS,
20234
- generatedBy: `session-kanban:${sessionId}`
20644
+ generatedBy: `session-kanban:${sessionId}`,
20645
+ kind: "session_mirror",
20646
+ retention: { mode: "archive_after_ttl", ttlMs: 7 * 24 * 60 * 60 * 1e3 }
20235
20647
  });
20236
20648
  }
20237
20649
  if (!sameColumns(board.columns) || !board.tags?.includes(SESSION_BOARD_TAG)) {
@@ -20466,12 +20878,26 @@ function projectSessionPlanToKanban(projectRoot, items, sessionId) {
20466
20878
  function fireAndForget(context, work) {
20467
20879
  void work.catch((err) => {
20468
20880
  const message = err instanceof Error ? err.message : String(err);
20469
- console.warn(JSON.stringify({ level: "warn", event: "session-kanban", context, message, timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
20881
+ console.warn(
20882
+ JSON.stringify({
20883
+ level: "warn",
20884
+ event: "session-kanban",
20885
+ context,
20886
+ message,
20887
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
20888
+ })
20889
+ );
20470
20890
  });
20471
20891
  }
20472
20892
  function broadcastTodoUpdate(context, todos) {
20473
20893
  const sessionId = context.session?.id ?? "";
20474
20894
  if (!context.agentId || !sessionId) return;
20895
+ const statusCounts = { pending: 0, inProgress: 0, completed: 0 };
20896
+ for (const todo of todos) {
20897
+ if (todo.status === "completed") statusCounts.completed++;
20898
+ else if (todo.status === "in_progress") statusCounts.inProgress++;
20899
+ else statusCounts.pending++;
20900
+ }
20475
20901
  const projectDir = resolveWstackPaths3({ projectRoot: context.projectRoot }).projectDir;
20476
20902
  const mailbox = getSharedProjectMailbox(projectDir);
20477
20903
  void mailbox.send({
@@ -20483,11 +20909,11 @@ function broadcastTodoUpdate(context, todos) {
20483
20909
  kind: "kanban.todos.updated",
20484
20910
  sessionId,
20485
20911
  revision: context.state.revision,
20486
- todos
20912
+ todoCount: todos.length,
20913
+ statusCounts
20487
20914
  }),
20488
20915
  priority: "normal",
20489
- senderSessionId: sessionId,
20490
- ttlMs: 6 * 60 * 60 * 1e3
20916
+ senderSessionId: sessionId
20491
20917
  }).catch(() => {
20492
20918
  });
20493
20919
  }
@@ -20623,8 +21049,8 @@ function attachSessionKanbanMirror(context) {
20623
21049
  const name = filename?.toString();
20624
21050
  const currentPlanPath = context.meta["plan.path"];
20625
21051
  const currentTaskPath = context.meta["task.path"];
20626
- const planName = typeof currentPlanPath === "string" ? basename12(currentPlanPath) : "";
20627
- const taskName = typeof currentTaskPath === "string" ? basename12(currentTaskPath) : "";
21052
+ const planName = typeof currentPlanPath === "string" ? basename13(currentPlanPath) : "";
21053
+ const taskName = typeof currentTaskPath === "string" ? basename13(currentTaskPath) : "";
20628
21054
  if (name && name !== planName && name !== taskName) return;
20629
21055
  if (timer) clearTimeout(timer);
20630
21056
  timer = setTimeout(() => fireAndForget("refresh-files", refreshFiles()), 60);
@@ -21640,11 +22066,11 @@ var lintTool = {
21640
22066
  }
21641
22067
  };
21642
22068
  async function detectLinter(cwd) {
21643
- const { stat: stat18 } = await import("node:fs/promises");
22069
+ const { stat: stat19 } = await import("node:fs/promises");
21644
22070
  const checks = ["biome.json", ".eslintrc.json", "tslint.json", ".eslintrc.js", "tsconfig.json"];
21645
22071
  for (const f of checks) {
21646
22072
  try {
21647
- await stat18(`${cwd}/${f}`);
22073
+ await stat19(`${cwd}/${f}`);
21648
22074
  if (f.includes("biome")) return "biome";
21649
22075
  if (f.includes("eslint")) return "eslint";
21650
22076
  if (f.includes("tslint")) return "tslint";
@@ -21888,6 +22314,9 @@ var outdatedTool = {
21888
22314
  // tool.confirm_needed flow on every invocation. M-1 originally
21889
22315
  // fixed four sibling tools (mcp_control, shellcheck, shellcheck (scan mode),
21890
22316
  // search) but missed this one; applying the same contract here.
22317
+ // WS-046: gives permission decisions something to key on.
22318
+ // The working directory scanned.
22319
+ subjectKey: "cwd",
21891
22320
  mutating: true,
21892
22321
  // Capability is outbound network — the tool only hits the package
21893
22322
  // registry over HTTP, never touches the filesystem or runs shell.
@@ -22069,6 +22498,10 @@ var patchTool = {
22069
22498
  useInstead: ["edit"]
22070
22499
  },
22071
22500
  permission: "confirm",
22501
+ // WS-046: gives permission decisions something to key on.
22502
+ // The target directory. The patch body itself is unstable (every diff
22503
+ // differs), so it could never re-match its own stored rule.
22504
+ subjectKey: "directory",
22072
22505
  mutating: true,
22073
22506
  capabilities: ["fs.write"],
22074
22507
  icon: "edit",
@@ -22135,8 +22568,8 @@ var patchTool = {
22135
22568
  const before = beforeContents.get(target) ?? null;
22136
22569
  const after = await readTextForTracking(target);
22137
22570
  if (after === null || after === before) continue;
22138
- const stat18 = await fs27.stat(target).catch(() => null);
22139
- if (stat18) ctx.recordRead?.(target, stat18.mtimeMs, "write", sha256hex(after));
22571
+ const stat19 = await fs27.stat(target).catch(() => null);
22572
+ if (stat19) ctx.recordRead?.(target, stat19.mtimeMs, "write", sha256hex(after));
22140
22573
  ctx.session?.recordFileChange?.({
22141
22574
  path: target,
22142
22575
  action: before === null ? "created" : "modified",
@@ -22161,8 +22594,8 @@ var patchTool = {
22161
22594
  var MAX_TRACKING_BYTES = 5 * 1024 * 1024;
22162
22595
  async function readTextForTracking(absPath) {
22163
22596
  try {
22164
- const stat18 = await fs27.stat(absPath);
22165
- if (!stat18.isFile() || stat18.size > MAX_TRACKING_BYTES) return null;
22597
+ const stat19 = await fs27.stat(absPath);
22598
+ if (!stat19.isFile() || stat19.size > MAX_TRACKING_BYTES) return null;
22166
22599
  const buf = await fs27.readFile(absPath);
22167
22600
  if (buf.includes(0)) return null;
22168
22601
  return buf.toString("utf8");
@@ -22192,7 +22625,13 @@ function runPatch(args, cwd, signal) {
22192
22625
  let stdout = "";
22193
22626
  let stderr = "";
22194
22627
  const env = { ...buildChildEnv8(), LANG: "C", LC_ALL: "C" };
22195
- const child = spawn14("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
22628
+ const child = spawn14("patch", args, {
22629
+ cwd,
22630
+ signal,
22631
+ env,
22632
+ stdio: ["pipe", "pipe", "pipe"],
22633
+ windowsHide: true
22634
+ });
22196
22635
  child.stdout?.on("data", (c) => {
22197
22636
  stdout += c.toString();
22198
22637
  });
@@ -22213,6 +22652,7 @@ function extractPatchedFiles(output) {
22213
22652
  }
22214
22653
 
22215
22654
  // src/plan.ts
22655
+ import { randomUUID as randomUUID2 } from "node:crypto";
22216
22656
  import {
22217
22657
  addPlanItem,
22218
22658
  clearPlan,
@@ -22220,20 +22660,20 @@ import {
22220
22660
  formatPlan,
22221
22661
  getPlanTemplate,
22222
22662
  mutatePlan as mutatePlan2,
22663
+ mutateTasks as mutateTasks2,
22223
22664
  removePlanItem,
22224
22665
  setPlanItemStatus
22225
22666
  } from "@wrongstack/core/storage";
22226
- import {
22227
- mutateTasks as mutateTasks2
22228
- } from "@wrongstack/core/storage";
22229
22667
  import { formatTaskList } from "@wrongstack/core/utils";
22230
- import { randomUUID as randomUUID2 } from "node:crypto";
22231
22668
  var planTool = {
22232
22669
  name: "plan",
22233
22670
  category: "Session",
22234
22671
  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.',
22235
22672
  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.',
22236
22673
  permission: "confirm",
22674
+ // WS-046: gives permission decisions something to key on.
22675
+ // The action performed; plan has no single file or path subject.
22676
+ subjectKey: "action",
22237
22677
  mutating: true,
22238
22678
  capabilities: ["fs.write"],
22239
22679
  icon: "plan",
@@ -22290,7 +22730,10 @@ var planTool = {
22290
22730
  let planPath;
22291
22731
  if (input.scope === "project") {
22292
22732
  if (typeof sessionPlanPath === "string") {
22293
- const lastSep = Math.max(sessionPlanPath.lastIndexOf("/"), sessionPlanPath.lastIndexOf("\\"));
22733
+ const lastSep = Math.max(
22734
+ sessionPlanPath.lastIndexOf("/"),
22735
+ sessionPlanPath.lastIndexOf("\\")
22736
+ );
22294
22737
  planPath = lastSep >= 0 ? sessionPlanPath.slice(0, lastSep + 1) + "backlog.plan.json" : "backlog.plan.json";
22295
22738
  }
22296
22739
  } else {
@@ -22327,7 +22770,11 @@ var planTool = {
22327
22770
  case "start":
22328
22771
  case "done": {
22329
22772
  if (!input.target) {
22330
- early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
22773
+ early = mkResult(
22774
+ p,
22775
+ false,
22776
+ `${input.action} requires \`target\` (id|index|substring).`
22777
+ );
22331
22778
  return p;
22332
22779
  }
22333
22780
  const next = setPlanItemStatus(
@@ -22355,7 +22802,11 @@ var planTool = {
22355
22802
  }
22356
22803
  case "promote": {
22357
22804
  if (!input.target) {
22358
- early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
22805
+ early = mkResult(
22806
+ p,
22807
+ false,
22808
+ `${input.action} requires \`target\` (id|index|substring).`
22809
+ );
22359
22810
  return p;
22360
22811
  }
22361
22812
  const derived = deriveTodosFromPlanItem(p, input.target, input.subtasks);
@@ -22398,7 +22849,11 @@ var planTool = {
22398
22849
  return clearPlan(p);
22399
22850
  case "taskify": {
22400
22851
  if (!input.target) {
22401
- early = mkResult(p, false, "taskify requires `target` (plan item id|index|substring).");
22852
+ early = mkResult(
22853
+ p,
22854
+ false,
22855
+ "taskify requires `target` (plan item id|index|substring)."
22856
+ );
22402
22857
  return p;
22403
22858
  }
22404
22859
  let itemIdx = -1;
@@ -22471,7 +22926,11 @@ var planTool = {
22471
22926
  ${formatTaskList(taskFile.tasks)}`
22472
22927
  );
22473
22928
  } catch (err) {
22474
- return mkResult(plan, false, `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`);
22929
+ return mkResult(
22930
+ plan,
22931
+ false,
22932
+ `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`
22933
+ );
22475
22934
  }
22476
22935
  }
22477
22936
  return mkResult(plan, true, `Plan ${input.action} ok.`);
@@ -22548,9 +23007,9 @@ var readTool = {
22548
23007
  }
22549
23008
  const absPath = await safeResolveReal(input.path, ctx);
22550
23009
  const shouldIncludeSymbols = input.includeSymbols === true || input.includeSymbols !== false && ctx.meta[ADVANCED_MODE_META_KEY] === true;
22551
- let stat18;
23010
+ let stat19;
22552
23011
  try {
22553
- stat18 = await fs28.stat(absPath);
23012
+ stat19 = await fs28.stat(absPath);
22554
23013
  } catch (err) {
22555
23014
  const code = err.code;
22556
23015
  if (code === "ENOENT") {
@@ -22569,7 +23028,7 @@ var readTool = {
22569
23028
  cause: err
22570
23029
  });
22571
23030
  }
22572
- if (!stat18.isFile()) {
23031
+ if (!stat19.isFile()) {
22573
23032
  throw new FsError({
22574
23033
  message: `read: "${input.path}" is not a regular file`,
22575
23034
  code: "FS_READ_FAILED",
@@ -22577,23 +23036,23 @@ var readTool = {
22577
23036
  context: { reason: "not-a-regular-file" }
22578
23037
  });
22579
23038
  }
22580
- if (stat18.size > MAX_BYTES2) {
23039
+ if (stat19.size > MAX_BYTES2) {
22581
23040
  throw new FsError({
22582
- message: `read: file too large (${stat18.size} bytes, limit ${MAX_BYTES2})`,
23041
+ message: `read: file too large (${stat19.size} bytes, limit ${MAX_BYTES2})`,
22583
23042
  code: "FS_READ_FAILED",
22584
23043
  path: absPath,
22585
- context: { size: stat18.size, limit: MAX_BYTES2, reason: "too-large" }
23044
+ context: { size: stat19.size, limit: MAX_BYTES2, reason: "too-large" }
22586
23045
  });
22587
23046
  }
22588
23047
  const offset = Math.max(1, input.offset ?? 1);
22589
23048
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
22590
23049
  const prior = getReadRangeRecord(ctx, absPath);
22591
23050
  const requestedEnd = prior ? Math.min(offset + limit - 1, prior.totalLines) : offset + limit - 1;
22592
- if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat18.mtimeMs, offset, requestedEnd)) {
22593
- ctx.recordRead(absPath, stat18.mtimeMs);
23051
+ if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat19.mtimeMs, offset, requestedEnd)) {
23052
+ ctx.recordRead(absPath, stat19.mtimeMs);
22594
23053
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22595
23054
  return {
22596
- 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.]`,
23055
+ text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat19.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
22597
23056
  total_lines: prior.totalLines,
22598
23057
  encoding: "utf8",
22599
23058
  truncated: requestedEnd < prior.totalLines,
@@ -22611,11 +23070,11 @@ var readTool = {
22611
23070
  const allLines = text.split(/\r\n|\r|\n/);
22612
23071
  const total = allLines.length;
22613
23072
  if (input.mode === "summary") {
22614
- ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22615
- rememberReadRange(ctx, absPath, stat18.mtimeMs, total, 1, Math.min(total, 200));
23073
+ ctx.recordRead(absPath, stat19.mtimeMs, "user", contentHash);
23074
+ rememberReadRange(ctx, absPath, stat19.mtimeMs, total, 1, Math.min(total, 200));
22616
23075
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22617
23076
  return {
22618
- text: summarizeFile(input.path, stat18.size, allLines),
23077
+ text: summarizeFile(input.path, stat19.size, allLines),
22619
23078
  total_lines: total,
22620
23079
  encoding: "utf8",
22621
23080
  truncated: total > 200,
@@ -22627,8 +23086,8 @@ var readTool = {
22627
23086
  };
22628
23087
  }
22629
23088
  if (limit === 0) {
22630
- ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22631
- rememberReadRange(ctx, absPath, stat18.mtimeMs, total, 1, 0);
23089
+ ctx.recordRead(absPath, stat19.mtimeMs, "user", contentHash);
23090
+ rememberReadRange(ctx, absPath, stat19.mtimeMs, total, 1, 0);
22632
23091
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22633
23092
  return {
22634
23093
  text: "",
@@ -22640,8 +23099,8 @@ var readTool = {
22640
23099
  };
22641
23100
  }
22642
23101
  if (offset > total) {
22643
- ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22644
- rememberReadRange(ctx, absPath, stat18.mtimeMs, total, total + 1, total + 1);
23102
+ ctx.recordRead(absPath, stat19.mtimeMs, "user", contentHash);
23103
+ rememberReadRange(ctx, absPath, stat19.mtimeMs, total, total + 1, total + 1);
22645
23104
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22646
23105
  return {
22647
23106
  text: `[offset ${offset} is past end of file "${input.path}" \u2014 file has ${total} line(s). Do not retry this offset.]`,
@@ -22656,8 +23115,8 @@ var readTool = {
22656
23115
  const truncated = offset - 1 + slice.length < total;
22657
23116
  const width = String(offset + slice.length - 1).length;
22658
23117
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
22659
- ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22660
- rememberReadRange(ctx, absPath, stat18.mtimeMs, total, offset, offset + slice.length - 1);
23118
+ ctx.recordRead(absPath, stat19.mtimeMs, "user", contentHash);
23119
+ rememberReadRange(ctx, absPath, stat19.mtimeMs, total, offset, offset + slice.length - 1);
22661
23120
  const symResult = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22662
23121
  return {
22663
23122
  text: numbered,
@@ -22763,20 +23222,20 @@ ${interesting.join("\n")}` : "symbols/imports: (none detected)"
22763
23222
  }
22764
23223
 
22765
23224
  // src/replace.ts
22766
- import { expectDefined as expectDefined8 } from "@wrongstack/core/utils";
22767
23225
  import { spawn as spawn15 } from "node:child_process";
22768
23226
  import * as fs29 from "node:fs/promises";
22769
23227
  import * as path33 from "node:path";
23228
+ import { ToolValidationError as ToolValidationError6 } from "@wrongstack/core/types";
22770
23229
  import {
22771
23230
  atomicWrite as atomicWrite3,
22772
23231
  buildChildEnv as buildChildEnv9,
22773
23232
  compileGlob as compileGlob4,
22774
23233
  detectNewlineStyle as detectNewlineStyle2,
23234
+ expectDefined as expectDefined8,
22775
23235
  normalizeToLf as normalizeToLf2,
22776
23236
  toStyle as toStyle2,
22777
23237
  unifiedDiff as unifiedDiff2
22778
23238
  } from "@wrongstack/core/utils";
22779
- import { ToolValidationError as ToolValidationError6 } from "@wrongstack/core/types";
22780
23239
  init_util();
22781
23240
  var DEFAULT_IGNORE4 = ["node_modules", ".git", "dist", "build", ".next", "coverage"];
22782
23241
  var replaceTool = {
@@ -22785,6 +23244,10 @@ var replaceTool = {
22785
23244
  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.",
22786
23245
  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.",
22787
23246
  permission: "confirm",
23247
+ // WS-046: gives permission decisions something to key on.
23248
+ // The file scope being rewritten, not the pattern: a trust rule should say
23249
+ // where a bulk replace may run.
23250
+ subjectKey: "files",
22788
23251
  mutating: true,
22789
23252
  capabilities: ["fs.write"],
22790
23253
  icon: "edit",
@@ -22857,8 +23320,8 @@ var replaceTool = {
22857
23320
  }
22858
23321
  const rel = path33.relative(realRoot, realPath);
22859
23322
  if (rel.startsWith("..") || path33.isAbsolute(rel)) continue;
22860
- const stat18 = await fs29.stat(realPath).catch(() => null);
22861
- if (!stat18?.isFile()) continue;
23323
+ const stat19 = await fs29.stat(realPath).catch(() => null);
23324
+ if (!stat19?.isFile()) continue;
22862
23325
  let content;
22863
23326
  try {
22864
23327
  const buf = await fs29.readFile(realPath);
@@ -22883,7 +23346,7 @@ var replaceTool = {
22883
23346
  totalReplacements += count;
22884
23347
  if (!dryRun) {
22885
23348
  const newContent = toStyle2(newContentLf, style);
22886
- await atomicWrite3(realPath, newContent, { mode: stat18.mode & 511 });
23349
+ await atomicWrite3(realPath, newContent, { mode: stat19.mode & 511 });
22887
23350
  const written = await fs29.stat(realPath).catch(() => null);
22888
23351
  if (written) {
22889
23352
  ctx.recordRead?.(realPath, written.mtimeMs, "write", sha256hex(newContent));
@@ -22923,8 +23386,8 @@ async function resolveFiles2(filesInput, ctx, extraGlob) {
22923
23386
  const resolved = [];
22924
23387
  for (const p of parts) {
22925
23388
  const absPath = safeResolve(p, ctx);
22926
- const stat18 = await fs29.stat(absPath).catch(() => null);
22927
- if (stat18?.isFile()) {
23389
+ const stat19 = await fs29.stat(absPath).catch(() => null);
23390
+ if (stat19?.isFile()) {
22928
23391
  resolved.push(absPath);
22929
23392
  }
22930
23393
  }
@@ -22944,7 +23407,11 @@ async function globFiles(pattern, base, extraGlob) {
22944
23407
  function checkRg() {
22945
23408
  return new Promise((resolve17) => {
22946
23409
  try {
22947
- const p = spawn15("rg", ["--version"], { env: buildChildEnv9(), stdio: "ignore", windowsHide: true });
23410
+ const p = spawn15("rg", ["--version"], {
23411
+ env: buildChildEnv9(),
23412
+ stdio: "ignore",
23413
+ windowsHide: true
23414
+ });
22948
23415
  p.on("error", () => resolve17(false));
22949
23416
  p.on("close", (code) => resolve17(code === 0));
22950
23417
  } catch {
@@ -22995,8 +23462,8 @@ async function globNative(pattern, base, extraGlob) {
22995
23462
  if (DEFAULT_IGNORE4.includes(e.name)) continue;
22996
23463
  const full = path33.join(dir, e.name);
22997
23464
  try {
22998
- const stat18 = await fs29.lstat(full);
22999
- if (stat18.isSymbolicLink()) continue;
23465
+ const stat19 = await fs29.lstat(full);
23466
+ if (stat19.isSymbolicLink()) continue;
23000
23467
  } catch {
23001
23468
  continue;
23002
23469
  }
@@ -23120,6 +23587,7 @@ var scaffoldTool = {
23120
23587
  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.",
23121
23588
  permission: "confirm",
23122
23589
  mutating: true,
23590
+ subjectKey: "name",
23123
23591
  capabilities: ["fs.write.outside-project", "fs.write"],
23124
23592
  icon: "scaffold",
23125
23593
  timeoutMs: 3e4,
@@ -23194,15 +23662,17 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
23194
23662
  output: dryRun ? `Would create ${filesCreated} files: ${files.join(", ")}` : `Created ${filesCreated} files: ${files.join(", ")}`
23195
23663
  };
23196
23664
  }
23665
+ function escapeRegexLiteral(value) {
23666
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23667
+ }
23197
23668
  function substituteVars(content, name, vars) {
23669
+ const kebab = name.toLowerCase().replace(/\s+/g, "-");
23670
+ const pascal = name.replace(/(?:^|[-_\s]+)([a-z])/g, (_, c) => c.toUpperCase());
23198
23671
  let result = content;
23199
- result = result.replace(/\{\{name\}\}/g, name.toLowerCase().replace(/\s+/g, "-"));
23200
- result = result.replace(
23201
- /\{\{Name\}\}/g,
23202
- name.replace(/(?:^|[-_\s]+)([a-z])/g, (_, c) => c.toUpperCase())
23203
- );
23672
+ result = result.replace(/\{\{name\}\}/g, () => kebab);
23673
+ result = result.replace(/\{\{Name\}\}/g, () => pascal);
23204
23674
  for (const [k, v] of Object.entries(vars)) {
23205
- result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), v);
23675
+ result = result.replace(new RegExp(`\\{\\{${escapeRegexLiteral(k)}\\}\\}`, "g"), () => v);
23206
23676
  }
23207
23677
  return result;
23208
23678
  }
@@ -24138,11 +24608,11 @@ var testTool = {
24138
24608
  }
24139
24609
  };
24140
24610
  async function detectRunner(cwd) {
24141
- const { stat: stat18 } = await import("node:fs/promises");
24611
+ const { stat: stat19 } = await import("node:fs/promises");
24142
24612
  const candidates = ["vitest.config.ts", "jest.config.js", ".mocharc.json"];
24143
24613
  for (const f of candidates) {
24144
24614
  try {
24145
- await stat18(path35.join(cwd, f));
24615
+ await stat19(path35.join(cwd, f));
24146
24616
  if (f.includes("vitest")) return "vitest";
24147
24617
  if (f.includes("jest")) return "jest";
24148
24618
  if (f.includes("mocha")) return "mocha";
@@ -24539,12 +25009,19 @@ var toolSearchTool = {
24539
25009
  };
24540
25010
 
24541
25011
  // src/tool-use.ts
25012
+ import {
25013
+ GOVERNED_TOOL_EXECUTOR_META_KEY as GOVERNED_TOOL_EXECUTOR_META_KEY2
25014
+ } from "@wrongstack/core/types";
24542
25015
  var toolUseTool = {
24543
25016
  name: "tool_use",
24544
25017
  category: "Meta",
24545
25018
  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.",
24546
25019
  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.",
24547
25020
  permission: "confirm",
25021
+ // WS-046: gives permission decisions something to key on.
25022
+ // The tool being invoked through this indirection — the whole point of a
25023
+ // permission decision here is WHICH tool is being reached.
25024
+ subjectKey: "tool",
24548
25025
  mutating: true,
24549
25026
  timeoutMs: 6e4,
24550
25027
  capabilities: ["tool.mutate.any"],
@@ -24563,7 +25040,7 @@ var toolUseTool = {
24563
25040
  },
24564
25041
  required: ["tool"]
24565
25042
  },
24566
- async execute(input, ctx, opts) {
25043
+ async execute(input, ctx) {
24567
25044
  const start = Date.now();
24568
25045
  if (!input?.tool) {
24569
25046
  return {
@@ -24582,6 +25059,14 @@ var toolUseTool = {
24582
25059
  executionMs: Date.now() - start
24583
25060
  };
24584
25061
  }
25062
+ if (tool.name === toolUseTool.name) {
25063
+ return {
25064
+ tool: input.tool,
25065
+ success: false,
25066
+ error: "tool_use: recursive meta-tool execution is not allowed",
25067
+ executionMs: Date.now() - start
25068
+ };
25069
+ }
24585
25070
  if (tool.permission === "deny") {
24586
25071
  return {
24587
25072
  tool: input.tool,
@@ -24590,12 +25075,21 @@ var toolUseTool = {
24590
25075
  executionMs: Date.now() - start
24591
25076
  };
24592
25077
  }
25078
+ const governedExecute = ctx.meta[GOVERNED_TOOL_EXECUTOR_META_KEY2];
25079
+ if (typeof governedExecute !== "function") {
25080
+ return {
25081
+ tool: input.tool,
25082
+ success: false,
25083
+ error: "tool_use: governed nested execution is unavailable; call the tool directly",
25084
+ executionMs: Date.now() - start
25085
+ };
25086
+ }
24593
25087
  try {
24594
- const result = await tool.execute(input.input, ctx, opts);
25088
+ const result = await governedExecute(input.tool, input.input ?? {});
24595
25089
  return {
24596
25090
  tool: input.tool,
24597
- success: true,
24598
- result,
25091
+ success: result.success,
25092
+ ...result.success ? { result: result.result } : { error: result.error ?? "nested tool failed" },
24599
25093
  executionMs: Date.now() - start
24600
25094
  };
24601
25095
  } catch (e) {
@@ -24611,10 +25105,12 @@ var toolUseTool = {
24611
25105
 
24612
25106
  // src/tree.ts
24613
25107
  init_util();
24614
- import { DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS4, expectDefined as expectDefined10 } from "@wrongstack/core/utils";
24615
25108
  import * as fs32 from "node:fs/promises";
24616
25109
  import * as path36 from "node:path";
25110
+ import { DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS4, expectDefined as expectDefined10 } from "@wrongstack/core/utils";
24617
25111
  var DEFAULT_IGNORE5 = [...DEFAULT_WALK_IGNORE_DIRS4, ".wrongstack", ".ssh", ".gnupg", ".aws"];
25112
+ var DEFAULT_MAX_ENTRIES2 = 5e3;
25113
+ var MAX_TREE_OUTPUT_BYTES = 256 * 1024;
24618
25114
  var treeTool = {
24619
25115
  name: "tree",
24620
25116
  category: "Filesystem",
@@ -24658,6 +25154,12 @@ var treeTool = {
24658
25154
  show_hidden: {
24659
25155
  type: "boolean",
24660
25156
  description: "Show hidden files starting with . (default: false)"
25157
+ },
25158
+ max_entries: {
25159
+ type: "integer",
25160
+ description: `Maximum entries retained in the rendered tree (default ${DEFAULT_MAX_ENTRIES2}).`,
25161
+ minimum: 1,
25162
+ maximum: 5e4
24661
25163
  }
24662
25164
  }
24663
25165
  },
@@ -24671,7 +25173,7 @@ var treeTool = {
24671
25173
  if (!final) throw new Error("tree: stream ended without final event");
24672
25174
  return final;
24673
25175
  },
24674
- async *executeStream(input, ctx) {
25176
+ async *executeStream(input, ctx, opts) {
24675
25177
  const basePath = input.path ? safeResolve(input.path, ctx) : ctx.cwd;
24676
25178
  const maxDepth = input.depth ?? 3;
24677
25179
  const showFiles = input.show_files ?? true;
@@ -24679,8 +25181,17 @@ var treeTool = {
24679
25181
  const showHidden = input.show_hidden ?? false;
24680
25182
  const exclude = /* @__PURE__ */ new Set([...DEFAULT_IGNORE5, ...input.exclude ?? []]);
24681
25183
  const filterGlob = input.glob;
25184
+ const maxEntries = input.max_entries ?? DEFAULT_MAX_ENTRIES2;
25185
+ const signal = opts?.signal ?? ctx.signal ?? new AbortController().signal;
24682
25186
  const lines = [basePath];
24683
25187
  const totals = { totalFiles: { value: 0 }, totalDirs: { value: 0 } };
25188
+ const retention = {
25189
+ entries: 0,
25190
+ outputBytes: Buffer.byteLength(basePath, "utf8"),
25191
+ maxEntries,
25192
+ maxOutputBytes: MAX_TREE_OUTPUT_BYTES,
25193
+ truncated: false
25194
+ };
24684
25195
  const queue = [];
24685
25196
  const FLUSH_EVERY = 200;
24686
25197
  let lastEmittedTotal = 0;
@@ -24707,12 +25218,19 @@ var treeTool = {
24707
25218
  isLast: true,
24708
25219
  totalFiles: totals.totalFiles,
24709
25220
  totalDirs: totals.totalDirs,
24710
- onProgress: tickProgress
25221
+ onProgress: tickProgress,
25222
+ retention,
25223
+ signal
24711
25224
  });
24712
25225
  let walkDone = false;
24713
- walkPromise.finally(() => {
24714
- walkDone = true;
24715
- });
25226
+ void walkPromise.then(
25227
+ () => {
25228
+ walkDone = true;
25229
+ },
25230
+ () => {
25231
+ walkDone = true;
25232
+ }
25233
+ );
24716
25234
  while (!walkDone || queue.length > 0) {
24717
25235
  if (queue.length > 0) {
24718
25236
  yield expectDefined10(queue.shift());
@@ -24735,13 +25253,15 @@ var treeTool = {
24735
25253
  tree: lines.join("\n"),
24736
25254
  total_files: totals.totalFiles.value,
24737
25255
  total_dirs: totals.totalDirs.value,
24738
- truncated: false,
25256
+ truncated: retention.truncated,
24739
25257
  path: basePath
24740
25258
  }
24741
25259
  };
24742
25260
  }
24743
25261
  };
24744
25262
  async function walkDir(dir, depth, opts) {
25263
+ opts.signal.throwIfAborted();
25264
+ if (opts.retention.truncated) return;
24745
25265
  const entries = await fs32.readdir(dir, { withFileTypes: true }).catch(() => []);
24746
25266
  const filtered = entries.filter((e) => {
24747
25267
  if (!opts.showHidden && e.name.startsWith(".")) return false;
@@ -24761,6 +25281,8 @@ async function walkDir(dir, depth, opts) {
24761
25281
  return a.name.localeCompare(b.name);
24762
25282
  });
24763
25283
  for (let i = 0; i < items.length; i++) {
25284
+ opts.signal.throwIfAborted();
25285
+ if (opts.retention.truncated) return;
24764
25286
  const entry = items[i];
24765
25287
  if (!entry) continue;
24766
25288
  const isLast = i === items.length - 1;
@@ -24769,7 +25291,15 @@ async function walkDir(dir, depth, opts) {
24769
25291
  const displayName = entry.name + (entry.isDirectory() ? "/" : "");
24770
25292
  if (!opts.showDirs && entry.isDirectory()) continue;
24771
25293
  if (!opts.showFiles && entry.isFile()) continue;
24772
- opts.lines.push(opts.prefix + branch + displayName);
25294
+ const line = opts.prefix + branch + displayName;
25295
+ const lineBytes = Buffer.byteLength(line, "utf8") + 1;
25296
+ if (opts.retention.entries >= opts.retention.maxEntries || opts.retention.outputBytes + lineBytes > opts.retention.maxOutputBytes) {
25297
+ opts.retention.truncated = true;
25298
+ return;
25299
+ }
25300
+ opts.lines.push(line);
25301
+ opts.retention.entries++;
25302
+ opts.retention.outputBytes += lineBytes;
24773
25303
  if (entry.isDirectory() && (opts.maxDepth === 0 || depth < opts.maxDepth)) {
24774
25304
  const childPrefix = opts.prefix + connector;
24775
25305
  await walkDir(path36.join(dir, entry.name), depth + 1, {
@@ -24884,11 +25414,11 @@ var typecheckTool = {
24884
25414
  }
24885
25415
  };
24886
25416
  async function findTsConfig(cwd) {
24887
- const { stat: stat18 } = await import("node:fs/promises");
25417
+ const { stat: stat19 } = await import("node:fs/promises");
24888
25418
  const candidates = ["tsconfig.json", "tsconfig.base.json"];
24889
25419
  for (const f of candidates) {
24890
25420
  try {
24891
- const s = await stat18(path37.join(cwd, f));
25421
+ const s = await stat19(path37.join(cwd, f));
24892
25422
  if (s.isFile()) return path37.join(cwd, f);
24893
25423
  } catch {
24894
25424
  }
@@ -24963,12 +25493,12 @@ async function prepareWrite(input, ctx) {
24963
25493
  let existed = false;
24964
25494
  let prev = "";
24965
25495
  try {
24966
- const stat18 = await fs33.stat(absPath);
24967
- existed = stat18.isFile();
25496
+ const stat19 = await fs33.stat(absPath);
25497
+ existed = stat19.isFile();
24968
25498
  if (existed) {
24969
25499
  if (!ctx.hasRead(absPath)) {
24970
25500
  prev = await fs33.readFile(absPath, "utf8");
24971
- ctx.recordRead(absPath, stat18.mtimeMs, "write", sha256hex(prev));
25501
+ ctx.recordRead(absPath, stat19.mtimeMs, "write", sha256hex(prev));
24972
25502
  } else {
24973
25503
  prev = await fs33.readFile(absPath, "utf8");
24974
25504
  }
@@ -24985,8 +25515,8 @@ async function finishWrite(input, ctx, prepared, signal) {
24985
25515
  await atomicWrite5(prepared.absPath, input.content);
24986
25516
  const diff = prepared.existed ? unifiedDiff3(prepared.prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
24987
25517
  + (new file, ${input.content.split("\n").length} lines)`;
24988
- const stat18 = await fs33.stat(prepared.absPath);
24989
- ctx.recordRead(prepared.absPath, stat18.mtimeMs, "write", sha256hex(input.content));
25518
+ const stat19 = await fs33.stat(prepared.absPath);
25519
+ ctx.recordRead(prepared.absPath, stat19.mtimeMs, "write", sha256hex(input.content));
24990
25520
  ctx.session.recordFileChange({
24991
25521
  path: prepared.absPath,
24992
25522
  action: prepared.existed ? "modified" : "created",
@@ -26174,9 +26704,9 @@ async function walk(root, dir, out) {
26174
26704
  } else if (e.isFile()) {
26175
26705
  if (e.name === "SKILL.md" || e.name === "SKILL.save.md") continue;
26176
26706
  try {
26177
- const stat18 = await fs34.stat(fullPath);
26707
+ const stat19 = await fs34.stat(fullPath);
26178
26708
  const rel = path38.relative(root, fullPath).split(path38.sep).join("/");
26179
- out.push({ path: rel, bytes: stat18.size });
26709
+ out.push({ path: rel, bytes: stat19.size });
26180
26710
  } catch {
26181
26711
  }
26182
26712
  }