@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/exec.js CHANGED
@@ -7,6 +7,329 @@ import {
7
7
  } from "@wrongstack/core/observability";
8
8
  import { toErrorMessage as toErrorMessage2 } from "@wrongstack/core/utils/error";
9
9
 
10
+ // src/_danger-detect.ts
11
+ var argHas = (args, value) => args.includes(value);
12
+ var argMatches = (args, re) => args.some((a) => re.test(a));
13
+ var hasShortFlags = (args, letters) => {
14
+ const seen = /* @__PURE__ */ new Set();
15
+ for (const a of args) {
16
+ if (!a.startsWith("-") || a.startsWith("--")) continue;
17
+ for (const ch of a.replace(/^-+/, "")) seen.add(ch);
18
+ }
19
+ return letters.split("").every((l) => seen.has(l));
20
+ };
21
+ var RULES = [
22
+ // ----- rm / rmdir: recursive force delete (any path) -----
23
+ // Note: BLOCKED_ARG_PATTERNS already hard-denies root/home/glob paths,
24
+ // but `rm -rf ./build` is a normal dev workflow that the user might
25
+ // want to do intentionally. We downgrade it to 'destructive' so the
26
+ // confirm prompt can approve.
27
+ {
28
+ id: "rm-recursive",
29
+ level: "destructive",
30
+ test: (cmd, args) => (cmd === "rm" || cmd === "rmdir") && hasShortFlags(args, "rf"),
31
+ reason: "recursive force-delete"
32
+ },
33
+ // ----- Windows PowerShell Remove-Item: -Recurse -Force -----
34
+ {
35
+ id: "powershell-remove-item-recursive-force",
36
+ level: "destructive",
37
+ test: (cmd, args) => {
38
+ if (cmd !== "powershell" && cmd !== "pwsh") return false;
39
+ const hasRecurse = argMatches(args, /^-(?:R|Recurse|Recurse\s)/);
40
+ const hasForce = argHas(args, "-Force") || argHas(args, "-F");
41
+ if (argHas(args, "-WhatIf")) return false;
42
+ return hasRecurse && hasForce;
43
+ },
44
+ reason: "Remove-Item with -Recurse -Force"
45
+ },
46
+ // ----- find -exec / -ok / -execdir -----
47
+ {
48
+ id: "find-exec",
49
+ level: "destructive",
50
+ test: (cmd, args) => {
51
+ if (cmd !== "find") return false;
52
+ return args.some(
53
+ (a) => a === "-exec" || a === "-exec;" || a === "-ok" || a === "-ok;" || a === "-execdir" || a === "-execdir;" || a.startsWith("-exec=") || a.startsWith("-ok=") || a.startsWith("-execdir=")
54
+ );
55
+ },
56
+ reason: "find with -exec/-ok (executes arbitrary command on matches)"
57
+ },
58
+ // ----- git --exec= / --upload-pack= / --receive-pack= -----
59
+ // These run arbitrary commands via the git transport layer.
60
+ {
61
+ id: "git-exec",
62
+ level: "destructive",
63
+ test: (cmd, args) => cmd === "git" && args.some(
64
+ (a) => a.startsWith("--exec=") || a.startsWith("--upload-pack=") || a.startsWith("--receive-pack=") || a === "--exec" || a === "--upload-pack" || a === "--receive-pack"
65
+ ),
66
+ reason: "git with --exec/--upload-pack/--receive-pack (runs arbitrary code)"
67
+ },
68
+ // ----- Windows: format / diskpart / bcdedit -----
69
+ {
70
+ id: "win32-format",
71
+ level: "destructive",
72
+ test: (cmd) => cmd === "format" || cmd === "format.exe",
73
+ reason: "format (Windows disk format)"
74
+ },
75
+ {
76
+ id: "win32-diskpart",
77
+ level: "destructive",
78
+ test: (cmd) => cmd === "diskpart" || cmd === "diskpart.exe",
79
+ reason: "diskpart (Windows partition editor)"
80
+ },
81
+ {
82
+ id: "win32-bcdedit",
83
+ level: "destructive",
84
+ test: (cmd) => cmd === "bcdedit" || cmd === "bcdedit.exe",
85
+ reason: "bcdedit (Windows boot config editor)"
86
+ },
87
+ // ----- mkfs family -----
88
+ {
89
+ id: "mkfs",
90
+ level: "destructive",
91
+ test: (cmd) => /^mkfs(\.[a-z0-9]+)?$/.test(cmd) || cmd === "mkswap",
92
+ reason: "mkfs (filesystem creation \u2014 destroys existing data)"
93
+ },
94
+ // ----- dd writing to a block device -----
95
+ {
96
+ id: "dd-to-block-device",
97
+ level: "destructive",
98
+ test: (cmd, args) => {
99
+ if (cmd !== "dd") return false;
100
+ return args.some((a) => /of=\/dev\/(sd|hd|nvme|vd|mmcblk|xvd|loop|disk)/.test(a));
101
+ },
102
+ reason: "dd writing to a block device"
103
+ },
104
+ // ----- Secure-erase tools -----
105
+ {
106
+ id: "shred",
107
+ level: "destructive",
108
+ test: (cmd) => cmd === "shred" || cmd === "shred.exe",
109
+ reason: "shred (secure file delete)"
110
+ },
111
+ {
112
+ id: "wipefs",
113
+ level: "destructive",
114
+ test: (cmd) => cmd === "wipefs" || cmd === "wipefs.exe",
115
+ reason: "wipefs (signature wipe \u2014 destroys filesystem headers)"
116
+ },
117
+ {
118
+ id: "sdelete",
119
+ level: "destructive",
120
+ test: (cmd) => cmd === "sdelete" || cmd === "sdelete.exe",
121
+ reason: "sdelete (Sysinternals secure delete)"
122
+ },
123
+ // ----- VCS history rewrite (destructive) -----
124
+ // `git push --force` / `-f` rewrites remote history. `--force-with-lease`
125
+ // is the safer variant (checks remote hasn't moved) but still rewrites.
126
+ {
127
+ id: "git-push-force",
128
+ level: "destructive",
129
+ test: (cmd, args) => {
130
+ if (cmd !== "git") return false;
131
+ const pushIdx = args.indexOf("push");
132
+ if (pushIdx < 0) return false;
133
+ for (let i = pushIdx + 1; i < args.length; i++) {
134
+ const a = args[i];
135
+ if (a === "--force" || a === "-f" || a === "--force-with-lease") return true;
136
+ if (!a.startsWith("-") && !a.includes("=")) continue;
137
+ if (a.startsWith("--force")) return true;
138
+ }
139
+ return false;
140
+ },
141
+ reason: "git push with --force / -f (rewrites remote history)"
142
+ },
143
+ // ----- git reset --hard (destructive) -----
144
+ {
145
+ id: "git-reset-hard",
146
+ level: "destructive",
147
+ test: (cmd, args) => cmd === "git" && args.some((a) => a === "--hard" || a.startsWith("--hard=")),
148
+ reason: "git reset --hard (discards working tree + index)"
149
+ },
150
+ // ----- git clean -f / -fd (destructive) -----
151
+ {
152
+ id: "git-clean-force",
153
+ level: "destructive",
154
+ test: (cmd, args) => {
155
+ if (cmd !== "git") return false;
156
+ const cleanIdx = args.indexOf("clean");
157
+ if (cleanIdx < 0) return false;
158
+ return args.slice(cleanIdx + 1).some(
159
+ (a) => a === "-f" || a === "--force" || a.startsWith("-f") || a.startsWith("--force=")
160
+ );
161
+ },
162
+ reason: "git clean -f (deletes untracked files)"
163
+ },
164
+ // ----- package publish (destructive — public, irreversible) -----
165
+ {
166
+ id: "npm-publish",
167
+ level: "destructive",
168
+ test: (cmd, args) => {
169
+ if (!["npm", "pnpm", "yarn", "bun", "cargo"].includes(cmd)) return false;
170
+ return args.includes("publish") || cmd === "cargo" && args.includes("yank");
171
+ },
172
+ reason: "publishing to a public package registry (hard to reverse)"
173
+ },
174
+ // ----- k8s cluster-wide destructive ops (destructive) -----
175
+ {
176
+ id: "kubectl-delete-namespace",
177
+ level: "destructive",
178
+ test: (cmd, args) => {
179
+ if (cmd !== "kubectl") return false;
180
+ const delIdx = args.indexOf("delete");
181
+ if (delIdx < 0) return false;
182
+ const after = args.slice(delIdx + 1);
183
+ return after[0] === "namespace" || after[0] === "ns";
184
+ },
185
+ reason: "kubectl delete namespace (deletes all resources in the namespace)"
186
+ },
187
+ {
188
+ id: "kubectl-drain",
189
+ level: "destructive",
190
+ test: (cmd, args) => cmd === "kubectl" && args.includes("drain"),
191
+ reason: "kubectl drain (evicts pods, marks node unschedulable)"
192
+ },
193
+ // ----- inline code evaluation (caution — high false-positive) -----
194
+ // Common in scripts: `python -c "..."`, `node -e "..."`, `bash -c "..."`.
195
+ // We tag 'caution' rather than 'destructive' because these are used in
196
+ // many legitimate one-liners (e.g. `python -c "print(1)"`).
197
+ {
198
+ id: "inline-eval",
199
+ level: "caution",
200
+ test: (cmd, args) => {
201
+ if (![
202
+ "python",
203
+ "python3",
204
+ "python2",
205
+ "node",
206
+ "bash",
207
+ "sh",
208
+ "zsh",
209
+ "ruby",
210
+ "perl",
211
+ "lua"
212
+ ].includes(cmd)) {
213
+ return false;
214
+ }
215
+ return args.some(
216
+ (a) => a === "-c" || a === "-e" || a === "--eval" || a === "-eval" || a === "-E"
217
+ );
218
+ },
219
+ reason: "inline script evaluation (-c / -e / --eval)"
220
+ },
221
+ // ----- pipe-to-shell (destructive — download-and-run pattern) -----
222
+ // The classic `curl https://... | sh` download-and-run vector. Detected by
223
+ // looking for a known fetcher piped into a shell or expression evaluator.
224
+ // A shell command passed through `bash -c` / `pwsh -Command` arrives as one
225
+ // argv string, so scan the reconstructed argv text rather than individual
226
+ // tokens only.
227
+ {
228
+ id: "pipe-to-shell",
229
+ level: "destructive",
230
+ test: (cmd, args) => /\b(?:curl|wget|fetch|httpie|http|irm|iwr|Invoke-WebRequest|Invoke-RestMethod)\b[\s\S]{0,300}\|\s*(?:sudo\s+)?(?:sh|bash|zsh|fish|pwsh|powershell|iex|Invoke-Expression)\b/i.test(
231
+ [cmd, ...args].join(" ")
232
+ ),
233
+ reason: "network fetch piped to a shell (download-and-run pattern)"
234
+ },
235
+ // ----- privilege escalation (caution) -----
236
+ {
237
+ id: "sudo",
238
+ level: "caution",
239
+ test: (cmd) => cmd === "sudo" || cmd === "doas",
240
+ reason: "privilege escalation (sudo / doas)"
241
+ },
242
+ {
243
+ id: "runas",
244
+ level: "caution",
245
+ test: (cmd) => cmd === "runas" || cmd === "runas.exe",
246
+ reason: "Windows runas (run as different user)"
247
+ },
248
+ // ----- world-writable permissions (caution) -----
249
+ // `chmod 777` is rarely correct. `chmod -R 777` is almost always wrong.
250
+ // We only flag octal modes; symbolic modes like `chmod o+w` are
251
+ // left to the operator's discretion.
252
+ {
253
+ id: "chmod-world-writable",
254
+ level: "caution",
255
+ test: (cmd, args) => {
256
+ if (cmd !== "chmod") return false;
257
+ return args.some((a) => /^[0-7]{3,4}$/.test(a) && /7/.test(a));
258
+ },
259
+ reason: "chmod with world-writable octal mode (e.g. 777)"
260
+ },
261
+ // ----- network recon / offensive scanners (caution) -----
262
+ // These tools are purpose-built for network reconnaissance and offensive
263
+ // security. They have legitimate uses in dev (security testing, infra
264
+ // debugging) but their presence in an agent's command stream is worth a
265
+ // caution banner so the user can notice an unexpected scan.
266
+ {
267
+ id: "network-scanner",
268
+ level: "caution",
269
+ 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",
270
+ reason: "network scanner / offensive reconnaissance tool"
271
+ },
272
+ // ----- process termination (caution) -----
273
+ // kill/killall/pkill terminate processes. The exec kill guard
274
+ // (exec-kill-guard.ts) hard-blocks attempts on protected WrongStack
275
+ // processes; this rule adds a caution banner for generic process
276
+ // termination so the user sees a warning even for non-protected targets.
277
+ {
278
+ id: "process-kill",
279
+ level: "caution",
280
+ test: (cmd) => cmd === "kill" || cmd === "killall" || cmd === "pkill",
281
+ reason: "process termination command"
282
+ },
283
+ // ----- shell/interpreter launchers (caution) -----
284
+ // `env bash -c '…'`, `timeout sh`, `nohup perl -e '…'` etc. wrap a child
285
+ // binary that bypasses the allowlist name-gate entirely (the wrapper
286
+ // resolves the child from PATH, not from the allowlist). We flag caution
287
+ // when a known launcher has a shell/interpreter as its target program.
288
+ {
289
+ id: "shell-launcher",
290
+ level: "caution",
291
+ test: (cmd, args) => {
292
+ const launchers = ["env", "timeout", "nohup", "nice", "script", "expect", "tmux", "screen", "byobu", "dtach"];
293
+ if (!launchers.includes(cmd)) return false;
294
+ const shells = ["sh", "bash", "zsh", "fish", "python", "python3", "perl", "ruby", "node", "pwsh", "powershell", "cmd"];
295
+ if (cmd === "env") {
296
+ const prog = args.find((a) => !a.includes("=") && !a.startsWith("-"));
297
+ return prog !== void 0 && shells.includes(prog);
298
+ }
299
+ return args.some((a) => shells.includes(a));
300
+ },
301
+ reason: "launcher wrapping a shell/interpreter (bypasses allowlist name-gate)"
302
+ }
303
+ ];
304
+ function detectDanger(cmd, args, bypass) {
305
+ const reasons = [];
306
+ let level = "safe";
307
+ let matchedRule;
308
+ for (const rule of RULES) {
309
+ if (bypass?.has(rule.id)) continue;
310
+ if (!rule.test(cmd, args)) continue;
311
+ reasons.push(rule.reason);
312
+ matchedRule = rule.id;
313
+ if (levelRank(rule.level) > levelRank(level)) {
314
+ level = rule.level;
315
+ }
316
+ }
317
+ if (level === "safe") return { level: "safe", reasons: [] };
318
+ const result = { level, reasons };
319
+ if (matchedRule !== void 0) result.matchedRule = matchedRule;
320
+ return result;
321
+ }
322
+ function levelRank(level) {
323
+ switch (level) {
324
+ case "safe":
325
+ return 0;
326
+ case "caution":
327
+ return 1;
328
+ case "destructive":
329
+ return 2;
330
+ }
331
+ }
332
+
10
333
  // src/_env.ts
11
334
  import { buildChildEnv } from "@wrongstack/core/utils";
12
335
 
@@ -81,11 +404,12 @@ function createOutputSpool(opts) {
81
404
  return {
82
405
  write(text) {
83
406
  if (finalized || !text) return;
84
- totalBytes += Buffer.byteLength(text, "utf8");
407
+ const textBytes = Buffer.byteLength(text, "utf8");
408
+ totalBytes += textBytes;
85
409
  if (!stream && !failed) {
86
- if (headBytes + text.length <= threshold) {
410
+ if (headBytes + textBytes <= threshold) {
87
411
  head += text;
88
- headBytes += text.length;
412
+ headBytes += textBytes;
89
413
  return;
90
414
  }
91
415
  head += text;
@@ -95,7 +419,7 @@ function createOutputSpool(opts) {
95
419
  }
96
420
  if (stream) {
97
421
  if (stream.writableLength > SPOOL_WRITE_HWM_BYTES) {
98
- droppedBytes += Buffer.byteLength(text, "utf8");
422
+ droppedBytes += textBytes;
99
423
  return;
100
424
  }
101
425
  stream.write(text);
@@ -142,12 +466,13 @@ function ensureInsideRoot(absPath, ctx) {
142
466
  function safeResolve(input, ctx) {
143
467
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
144
468
  }
145
- async function assertRealInsideRoot(absPath, ctx) {
146
- if (ctx.allowOutsideProjectRoot) return;
469
+ async function resolveRealInsideRoot(absPath, ctx) {
470
+ if (ctx.allowOutsideProjectRoot) return absPath;
147
471
  const realRoots = await Promise.all(
148
472
  allowedRoots(ctx).map((r) => fsp2.realpath(r).catch(() => path2.resolve(r)))
149
473
  );
150
474
  let probe = absPath;
475
+ const pendingTail = [];
151
476
  for (; ; ) {
152
477
  let real;
153
478
  try {
@@ -155,13 +480,16 @@ async function assertRealInsideRoot(absPath, ctx) {
155
480
  } catch (err) {
156
481
  if (err.code === "ENOENT") {
157
482
  const parent = path2.dirname(probe);
158
- if (parent === probe) return;
483
+ if (parent === probe) return absPath;
484
+ pendingTail.unshift(path2.basename(probe));
159
485
  probe = parent;
160
486
  continue;
161
487
  }
162
488
  throw err;
163
489
  }
164
- if (isInsideAny(real, realRoots)) return;
490
+ if (isInsideAny(real, realRoots)) {
491
+ return pendingTail.length > 0 ? path2.join(real, ...pendingTail) : real;
492
+ }
165
493
  throw new Error(
166
494
  `Path "${absPath}" resolves through a symlink outside project root "${realRoots[0]}"`
167
495
  );
@@ -169,8 +497,7 @@ async function assertRealInsideRoot(absPath, ctx) {
169
497
  }
170
498
  async function safeResolveReal(input, ctx) {
171
499
  const abs = safeResolve(input, ctx);
172
- await assertRealInsideRoot(abs, ctx);
173
- return abs;
500
+ return await resolveRealInsideRoot(abs, ctx);
174
501
  }
175
502
  var COMMAND_OUTPUT_MAX_BYTES = 32768;
176
503
  var REPEAT_RUN_THRESHOLD = 3;
@@ -243,6 +570,62 @@ function normalizeCommandOutput(raw, opts = {}) {
243
570
  return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);
244
571
  }
245
572
 
573
+ // src/_win32-resolve.ts
574
+ import * as fs from "node:fs";
575
+ import * as path3 from "node:path";
576
+ function resolveWin32Command(cmd) {
577
+ if (process.platform !== "win32") return cmd;
578
+ if (cmd.includes("/") || cmd.includes("\\") || path3.extname(cmd.replace(/\//g, "\\"))) {
579
+ return cmd;
580
+ }
581
+ const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
582
+ const pathDirs = (process.env["PATH"] ?? "").split(path3.delimiter);
583
+ for (const dir of pathDirs) {
584
+ const base = path3.join(dir, cmd);
585
+ for (const ext of pathext) {
586
+ const full = `${base}${ext}`;
587
+ try {
588
+ fs.accessSync(full, fs.constants.X_OK);
589
+ return full;
590
+ } catch {
591
+ }
592
+ }
593
+ }
594
+ return cmd;
595
+ }
596
+ var WIN32_SHELL_META = /[&|<>"\r\n\0]/;
597
+ function assertSafeWin32ShellArgs(args) {
598
+ for (const arg of args) {
599
+ if (typeof arg === "string" && WIN32_SHELL_META.test(arg)) {
600
+ throw new Error(
601
+ 'win32 cmd shim spawn: argument contains a shell metacharacter (one of & | < > ", or a newline) that could enable command injection through the .cmd/.bat wrapper - refusing to run. Offending argument: ' + JSON.stringify(arg)
602
+ );
603
+ }
604
+ }
605
+ }
606
+ function buildWin32CmdShimInvocation(command, args = []) {
607
+ assertSafeWin32ShellArgs([command, ...args]);
608
+ const line = ["call", quoteWin32CmdArg(command), ...args.map(quoteWin32CmdArg)].join(" ");
609
+ return {
610
+ command: process.env["COMSPEC"] ?? "cmd.exe",
611
+ args: ["/d", "/c", line],
612
+ windowsVerbatimArguments: true
613
+ };
614
+ }
615
+ function quoteWin32CmdArg(arg) {
616
+ return `"${arg}"`;
617
+ }
618
+
619
+ // src/exec-kill-guard.ts
620
+ import * as os3 from "node:os";
621
+ import * as path5 from "node:path";
622
+
623
+ // src/process-registry-persistent.ts
624
+ import * as fs2 from "node:fs/promises";
625
+ import * as os2 from "node:os";
626
+ import { wstackGlobalRoot as wstackGlobalRoot3 } from "@wrongstack/core/utils";
627
+ import * as path4 from "node:path";
628
+
246
629
  // src/process-registry.ts
247
630
  import { spawn } from "node:child_process";
248
631
  import * as os from "node:os";
@@ -667,542 +1050,207 @@ var ProcessRegistryImpl = class {
667
1050
  }
668
1051
  if (this.breaker.isEnabled && this.breaker.snapshot().state === "open") {
669
1052
  this._armAutoKillReset();
670
- }
671
- }
672
- /**
673
- * Live countdown to the next auto kill/reset, or null when nothing is armed.
674
- * The TUI polls this on a 1s tick while armed so the statusline decrements.
675
- */
676
- getBreakerCountdown() {
677
- if (this.autoKillArmedAt === null || this.autoKillResetMs <= 0) return null;
678
- const elapsed = Date.now() - this.autoKillArmedAt;
679
- return { remainingMs: Math.max(0, this.autoKillResetMs - elapsed), totalMs: this.autoKillResetMs };
680
- }
681
- /**
682
- * Subscribe to countdown arm/cancel events. Returns an unsubscribe function.
683
- * Use {@link getBreakerCountdown} for the live ticking value between events.
684
- */
685
- onBreakerCountdownChange(listener) {
686
- this.breakerCountdownListeners.push(listener);
687
- return () => {
688
- this.breakerCountdownListeners = this.breakerCountdownListeners.filter((l) => l !== listener);
689
- };
690
- }
691
- _emitBreakerCountdown() {
692
- const snap = this.getBreakerCountdown();
693
- for (const l of this.breakerCountdownListeners) {
694
- try {
695
- l(snap);
696
- } catch {
697
- }
698
- }
699
- }
700
- /**
701
- * Arm the auto kill/reset countdown. Idempotent: re-arming resets the window
702
- * (a fresh trip after a failed half-open probe restarts the clock). No-op
703
- * when protection is off or no timeout is configured.
704
- */
705
- _armAutoKillReset() {
706
- if (this.autoKillResetMs <= 0 || !this.breaker.isEnabled) return;
707
- this._clearAutoKillTimer();
708
- this.autoKillArmedAt = Date.now();
709
- this.autoKillTimer = setTimeout(() => {
710
- this.autoKillTimer = null;
711
- this.autoKillArmedAt = null;
712
- this.killAll({ force: false, preserveBackground: true });
713
- this.breaker.forceReset();
714
- this._emitBreakerCountdown();
715
- }, this.autoKillResetMs);
716
- this.autoKillTimer.unref?.();
717
- this._emitBreakerCountdown();
718
- }
719
- _cancelAutoKillReset() {
720
- const wasArmed = this.autoKillArmedAt !== null;
721
- this._clearAutoKillTimer();
722
- if (wasArmed) {
723
- this.autoKillArmedAt = null;
724
- this._emitBreakerCountdown();
725
- }
726
- }
727
- _clearAutoKillTimer() {
728
- if (this.autoKillTimer !== null) {
729
- clearTimeout(this.autoKillTimer);
730
- this.autoKillTimer = null;
731
- }
732
- }
733
- /** Kill a single process by PID.
734
- *
735
- * On POSIX: sends SIGTERM to the *process group* (-pid) so that
736
- * runaway grandchild processes (`sleep 9999 & disown`) are also killed.
737
- * After `graceMs` a SIGKILL is sent if the process hasn't exited.
738
- *
739
- * On Windows: `child.kill()` maps to TerminateProcess — process groups
740
- * are not meaningfully supported. A second `force=true` call sends
741
- * SIGKILL (which maps to TerminateProcess again — the distinction is
742
- * in the exit code, not the signal).
743
- *
744
- * Returns true if the process was found and kill was attempted.
745
- */
746
- kill(pid, opts = {}) {
747
- this._pruneStale(pid);
748
- const p = this.processes.get(pid);
749
- if (!p) return false;
750
- if (p.killed) return true;
751
- if (p.protected) return false;
752
- if (opts.preserveBackground && p.background) return false;
753
- const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
754
- const isWin3 = os.platform() === "win32";
755
- if (isWin3) {
756
- const liveRealChild = p.child.exitCode === null && typeof p.child.pid === "number";
757
- const directFallback = () => {
758
- if (p.child.exitCode === null) {
759
- try {
760
- p.child.kill("SIGKILL");
761
- } catch {
762
- }
763
- }
764
- };
765
- if (liveRealChild && killWin32Tree(pid, {
766
- timeoutMs: Math.max(graceMs, WIN32_TASKKILL_TIMEOUT_MS),
767
- onSettled: directFallback
768
- })) {
769
- } else {
770
- try {
771
- p.child.kill(force ? "SIGKILL" : "SIGTERM");
772
- } catch {
773
- }
774
- }
775
- p.killed = true;
776
- return true;
777
- }
778
- try {
779
- if (force) {
780
- this._killPosix(p, "SIGKILL");
781
- } else {
782
- this._killPosix(p, "SIGTERM");
783
- const timer = setTimeout(() => {
784
- if (this.processes.has(pid) && !p.child.killed) {
785
- this._killPosix(p, "SIGKILL");
786
- }
787
- }, graceMs);
788
- timer.unref?.();
789
- }
790
- } catch {
791
- }
792
- p.killed = true;
793
- return true;
794
- }
795
- /**
796
- * Kill all tracked processes.
797
- * Returns the PIDs that were kill()ed.
798
- */
799
- killAll(opts = {}) {
800
- const pids = Array.from(this.processes.keys());
801
- const killed = [];
802
- for (const pid of pids) {
803
- const p = this.processes.get(pid);
804
- if (p && !p.protected && this.kill(pid, opts)) killed.push(pid);
805
- }
806
- return killed;
807
- }
808
- /**
809
- * Kill all processes for a specific session.
810
- * Returns the PIDs that were kill()ed.
811
- */
812
- killSession(sessionId, opts = {}) {
813
- const pids = this.bySession(sessionId).map((p) => p.pid);
814
- const killed = [];
815
- for (const pid of pids) {
816
- if (this.kill(pid, opts)) killed.push(pid);
817
- }
818
- return killed;
819
- }
820
- /**
821
- * Check whether a tracked process entry is stale — the child has exited
822
- * (exitCode !== null) AND it's been in the registry long enough that the
823
- * OS may have reused the PID for a new, unrelated process.
824
- *
825
- * P3 #24 (before-release.md): on POSIX, PIDs are reused after process
826
- * exit. If a tracked process exits but its 'close' event hasn't fired yet
827
- * (or was missed), the registry still holds the entry. A new process
828
- * gets the same PID, and PID-based lookups (get, kill, shouldBlockKill)
829
- * may incorrectly protect or target the wrong process.
830
- *
831
- * The 60s threshold is conservative — the OS typically waits much longer
832
- * before reusing a PID, but we want to clean up before that becomes a risk.
833
- */
834
- _isStaleEntry(entry) {
835
- return entry.child.exitCode !== null && Date.now() - entry.startedAt > 6e4;
836
- }
837
- /**
838
- * Remove a stale entry for a specific PID before any PID-based lookup.
839
- * This prevents PID reuse from causing the registry to act on a dead
840
- * process that has been replaced by a new one with the same PID.
841
- */
842
- _pruneStale(pid) {
843
- const entry = this.processes.get(pid);
844
- if (entry && this._isStaleEntry(entry)) {
845
- this.processes.delete(pid);
846
- }
847
- }
848
- /**
849
- * Remove every stale entry, not just one PID. `list()`/`stats()` — the
850
- * surfaces the TUI status bar and `/ps` poll — must prune too: a child
851
- * whose 'close' event never fires (e.g. Windows grandchildren holding stdio
852
- * open) would otherwise linger in the registry until someone looks up its
853
- * exact PID, and PID reuse meanwhile makes `get()`/`kill()` target the
854
- * wrong process. RAM-leak audit 2026-07-31, MEDIUM.
855
- */
856
- _pruneAllStale() {
857
- for (const [pid, entry] of this.processes) {
858
- if (this._isStaleEntry(entry)) this.processes.delete(pid);
859
- }
860
- }
861
- };
862
- var _registry;
863
- function getProcessRegistry() {
864
- if (!_registry) {
865
- _registry = new ProcessRegistryImpl();
1053
+ }
866
1054
  }
867
- return _registry;
868
- }
869
-
870
- // src/_win32-resolve.ts
871
- import * as fs from "node:fs";
872
- import * as path3 from "node:path";
873
- function resolveWin32Command(cmd) {
874
- if (process.platform !== "win32") return cmd;
875
- if (cmd.includes("/") || cmd.includes("\\") || path3.extname(cmd.replace(/\//g, "\\"))) {
876
- return cmd;
1055
+ /**
1056
+ * Live countdown to the next auto kill/reset, or null when nothing is armed.
1057
+ * The TUI polls this on a 1s tick while armed so the statusline decrements.
1058
+ */
1059
+ getBreakerCountdown() {
1060
+ if (this.autoKillArmedAt === null || this.autoKillResetMs <= 0) return null;
1061
+ const elapsed = Date.now() - this.autoKillArmedAt;
1062
+ return { remainingMs: Math.max(0, this.autoKillResetMs - elapsed), totalMs: this.autoKillResetMs };
877
1063
  }
878
- const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
879
- const pathDirs = (process.env["PATH"] ?? "").split(path3.delimiter);
880
- for (const dir of pathDirs) {
881
- const base = path3.join(dir, cmd);
882
- for (const ext of pathext) {
883
- const full = `${base}${ext}`;
1064
+ /**
1065
+ * Subscribe to countdown arm/cancel events. Returns an unsubscribe function.
1066
+ * Use {@link getBreakerCountdown} for the live ticking value between events.
1067
+ */
1068
+ onBreakerCountdownChange(listener) {
1069
+ this.breakerCountdownListeners.push(listener);
1070
+ return () => {
1071
+ this.breakerCountdownListeners = this.breakerCountdownListeners.filter((l) => l !== listener);
1072
+ };
1073
+ }
1074
+ _emitBreakerCountdown() {
1075
+ const snap = this.getBreakerCountdown();
1076
+ for (const l of this.breakerCountdownListeners) {
884
1077
  try {
885
- fs.accessSync(full, fs.constants.X_OK);
886
- return full;
1078
+ l(snap);
887
1079
  } catch {
888
1080
  }
889
1081
  }
890
1082
  }
891
- return cmd;
892
- }
893
- var WIN32_SHELL_META = /[&|<>"\r\n\0]/;
894
- function assertSafeWin32ShellArgs(args) {
895
- for (const arg of args) {
896
- if (typeof arg === "string" && WIN32_SHELL_META.test(arg)) {
897
- throw new Error(
898
- 'win32 cmd shim spawn: argument contains a shell metacharacter (one of & | < > ", or a newline) that could enable command injection through the .cmd/.bat wrapper - refusing to run. Offending argument: ' + JSON.stringify(arg)
899
- );
1083
+ /**
1084
+ * Arm the auto kill/reset countdown. Idempotent: re-arming resets the window
1085
+ * (a fresh trip after a failed half-open probe restarts the clock). No-op
1086
+ * when protection is off or no timeout is configured.
1087
+ */
1088
+ _armAutoKillReset() {
1089
+ if (this.autoKillResetMs <= 0 || !this.breaker.isEnabled) return;
1090
+ this._clearAutoKillTimer();
1091
+ this.autoKillArmedAt = Date.now();
1092
+ this.autoKillTimer = setTimeout(() => {
1093
+ this.autoKillTimer = null;
1094
+ this.autoKillArmedAt = null;
1095
+ this.killAll({ force: false, preserveBackground: true });
1096
+ this.breaker.forceReset();
1097
+ this._emitBreakerCountdown();
1098
+ }, this.autoKillResetMs);
1099
+ this.autoKillTimer.unref?.();
1100
+ this._emitBreakerCountdown();
1101
+ }
1102
+ _cancelAutoKillReset() {
1103
+ const wasArmed = this.autoKillArmedAt !== null;
1104
+ this._clearAutoKillTimer();
1105
+ if (wasArmed) {
1106
+ this.autoKillArmedAt = null;
1107
+ this._emitBreakerCountdown();
900
1108
  }
901
1109
  }
902
- }
903
- function buildWin32CmdShimInvocation(command, args = []) {
904
- assertSafeWin32ShellArgs([command, ...args]);
905
- const line = ["call", quoteWin32CmdArg(command), ...args.map(quoteWin32CmdArg)].join(" ");
906
- return {
907
- command: process.env["COMSPEC"] ?? "cmd.exe",
908
- args: ["/d", "/c", line],
909
- windowsVerbatimArguments: true
910
- };
911
- }
912
- function quoteWin32CmdArg(arg) {
913
- return `"${arg}"`;
914
- }
915
-
916
- // src/_danger-detect.ts
917
- var argHas = (args, value) => args.includes(value);
918
- var argMatches = (args, re) => args.some((a) => re.test(a));
919
- var hasShortFlags = (args, letters) => {
920
- const seen = /* @__PURE__ */ new Set();
921
- for (const a of args) {
922
- if (!a.startsWith("-") || a.startsWith("--")) continue;
923
- for (const ch of a.replace(/^-+/, "")) seen.add(ch);
1110
+ _clearAutoKillTimer() {
1111
+ if (this.autoKillTimer !== null) {
1112
+ clearTimeout(this.autoKillTimer);
1113
+ this.autoKillTimer = null;
1114
+ }
924
1115
  }
925
- return letters.split("").every((l) => seen.has(l));
926
- };
927
- var RULES = [
928
- // ----- rm / rmdir: recursive force delete (any path) -----
929
- // Note: BLOCKED_ARG_PATTERNS already hard-denies root/home/glob paths,
930
- // but `rm -rf ./build` is a normal dev workflow that the user might
931
- // want to do intentionally. We downgrade it to 'destructive' so the
932
- // confirm prompt can approve.
933
- {
934
- id: "rm-recursive",
935
- level: "destructive",
936
- test: (cmd, args) => (cmd === "rm" || cmd === "rmdir") && hasShortFlags(args, "rf"),
937
- reason: "recursive force-delete"
938
- },
939
- // ----- Windows PowerShell Remove-Item: -Recurse -Force -----
940
- {
941
- id: "powershell-remove-item-recursive-force",
942
- level: "destructive",
943
- test: (cmd, args) => {
944
- if (cmd !== "powershell" && cmd !== "pwsh") return false;
945
- const hasRecurse = argMatches(args, /^-(?:R|Recurse|Recurse\s)/);
946
- const hasForce = argHas(args, "-Force") || argHas(args, "-F");
947
- if (argHas(args, "-WhatIf")) return false;
948
- return hasRecurse && hasForce;
949
- },
950
- reason: "Remove-Item with -Recurse -Force"
951
- },
952
- // ----- find -exec / -ok / -execdir -----
953
- {
954
- id: "find-exec",
955
- level: "destructive",
956
- test: (cmd, args) => {
957
- if (cmd !== "find") return false;
958
- return args.some(
959
- (a) => a === "-exec" || a === "-exec;" || a === "-ok" || a === "-ok;" || a === "-execdir" || a === "-execdir;" || a.startsWith("-exec=") || a.startsWith("-ok=") || a.startsWith("-execdir=")
960
- );
961
- },
962
- reason: "find with -exec/-ok (executes arbitrary command on matches)"
963
- },
964
- // ----- git --exec= / --upload-pack= / --receive-pack= -----
965
- // These run arbitrary commands via the git transport layer.
966
- {
967
- id: "git-exec",
968
- level: "destructive",
969
- test: (cmd, args) => cmd === "git" && args.some(
970
- (a) => a.startsWith("--exec=") || a.startsWith("--upload-pack=") || a.startsWith("--receive-pack=") || a === "--exec" || a === "--upload-pack" || a === "--receive-pack"
971
- ),
972
- reason: "git with --exec/--upload-pack/--receive-pack (runs arbitrary code)"
973
- },
974
- // ----- Windows: format / diskpart / bcdedit -----
975
- {
976
- id: "win32-format",
977
- level: "destructive",
978
- test: (cmd) => cmd === "format" || cmd === "format.exe",
979
- reason: "format (Windows disk format)"
980
- },
981
- {
982
- id: "win32-diskpart",
983
- level: "destructive",
984
- test: (cmd) => cmd === "diskpart" || cmd === "diskpart.exe",
985
- reason: "diskpart (Windows partition editor)"
986
- },
987
- {
988
- id: "win32-bcdedit",
989
- level: "destructive",
990
- test: (cmd) => cmd === "bcdedit" || cmd === "bcdedit.exe",
991
- reason: "bcdedit (Windows boot config editor)"
992
- },
993
- // ----- mkfs family -----
994
- {
995
- id: "mkfs",
996
- level: "destructive",
997
- test: (cmd) => /^mkfs(\.[a-z0-9]+)?$/.test(cmd) || cmd === "mkswap",
998
- reason: "mkfs (filesystem creation \u2014 destroys existing data)"
999
- },
1000
- // ----- dd writing to a block device -----
1001
- {
1002
- id: "dd-to-block-device",
1003
- level: "destructive",
1004
- test: (cmd, args) => {
1005
- if (cmd !== "dd") return false;
1006
- return args.some((a) => /of=\/dev\/(sd|hd|nvme|vd|mmcblk|xvd|loop|disk)/.test(a));
1007
- },
1008
- reason: "dd writing to a block device"
1009
- },
1010
- // ----- Secure-erase tools -----
1011
- {
1012
- id: "shred",
1013
- level: "destructive",
1014
- test: (cmd) => cmd === "shred" || cmd === "shred.exe",
1015
- reason: "shred (secure file delete)"
1016
- },
1017
- {
1018
- id: "wipefs",
1019
- level: "destructive",
1020
- test: (cmd) => cmd === "wipefs" || cmd === "wipefs.exe",
1021
- reason: "wipefs (signature wipe \u2014 destroys filesystem headers)"
1022
- },
1023
- {
1024
- id: "sdelete",
1025
- level: "destructive",
1026
- test: (cmd) => cmd === "sdelete" || cmd === "sdelete.exe",
1027
- reason: "sdelete (Sysinternals secure delete)"
1028
- },
1029
- // ----- VCS history rewrite (destructive) -----
1030
- // `git push --force` / `-f` rewrites remote history. `--force-with-lease`
1031
- // is the safer variant (checks remote hasn't moved) but still rewrites.
1032
- {
1033
- id: "git-push-force",
1034
- level: "destructive",
1035
- test: (cmd, args) => {
1036
- if (cmd !== "git") return false;
1037
- const pushIdx = args.indexOf("push");
1038
- if (pushIdx < 0) return false;
1039
- for (let i = pushIdx + 1; i < args.length; i++) {
1040
- const a = args[i];
1041
- if (a === "--force" || a === "-f" || a === "--force-with-lease") return true;
1042
- if (!a.startsWith("-") && !a.includes("=")) continue;
1043
- if (a.startsWith("--force")) return true;
1116
+ /** Kill a single process by PID.
1117
+ *
1118
+ * On POSIX: sends SIGTERM to the *process group* (-pid) so that
1119
+ * runaway grandchild processes (`sleep 9999 & disown`) are also killed.
1120
+ * After `graceMs` a SIGKILL is sent if the process hasn't exited.
1121
+ *
1122
+ * On Windows: `child.kill()` maps to TerminateProcess process groups
1123
+ * are not meaningfully supported. A second `force=true` call sends
1124
+ * SIGKILL (which maps to TerminateProcess again — the distinction is
1125
+ * in the exit code, not the signal).
1126
+ *
1127
+ * Returns true if the process was found and kill was attempted.
1128
+ */
1129
+ kill(pid, opts = {}) {
1130
+ this._pruneStale(pid);
1131
+ const p = this.processes.get(pid);
1132
+ if (!p) return false;
1133
+ if (p.killed) return true;
1134
+ if (p.protected) return false;
1135
+ if (opts.preserveBackground && p.background) return false;
1136
+ const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
1137
+ const isWin3 = os.platform() === "win32";
1138
+ if (isWin3) {
1139
+ const liveRealChild = p.child.exitCode === null && typeof p.child.pid === "number";
1140
+ const directFallback = () => {
1141
+ if (p.child.exitCode === null) {
1142
+ try {
1143
+ p.child.kill("SIGKILL");
1144
+ } catch {
1145
+ }
1146
+ }
1147
+ };
1148
+ if (liveRealChild && killWin32Tree(pid, {
1149
+ timeoutMs: Math.max(graceMs, WIN32_TASKKILL_TIMEOUT_MS),
1150
+ onSettled: directFallback
1151
+ })) {
1152
+ } else {
1153
+ try {
1154
+ p.child.kill(force ? "SIGKILL" : "SIGTERM");
1155
+ } catch {
1156
+ }
1044
1157
  }
1045
- return false;
1046
- },
1047
- reason: "git push with --force / -f (rewrites remote history)"
1048
- },
1049
- // ----- git reset --hard (destructive) -----
1050
- {
1051
- id: "git-reset-hard",
1052
- level: "destructive",
1053
- test: (cmd, args) => cmd === "git" && args.some((a) => a === "--hard" || a.startsWith("--hard=")),
1054
- reason: "git reset --hard (discards working tree + index)"
1055
- },
1056
- // ----- git clean -f / -fd (destructive) -----
1057
- {
1058
- id: "git-clean-force",
1059
- level: "destructive",
1060
- test: (cmd, args) => {
1061
- if (cmd !== "git") return false;
1062
- const cleanIdx = args.indexOf("clean");
1063
- if (cleanIdx < 0) return false;
1064
- return args.slice(cleanIdx + 1).some(
1065
- (a) => a === "-f" || a === "--force" || a.startsWith("-f") || a.startsWith("--force=")
1066
- );
1067
- },
1068
- reason: "git clean -f (deletes untracked files)"
1069
- },
1070
- // ----- package publish (destructive — public, irreversible) -----
1071
- {
1072
- id: "npm-publish",
1073
- level: "destructive",
1074
- test: (cmd, args) => {
1075
- if (!["npm", "pnpm", "yarn", "bun", "cargo"].includes(cmd)) return false;
1076
- return args.includes("publish") || cmd === "cargo" && args.includes("yank");
1077
- },
1078
- reason: "publishing to a public package registry (hard to reverse)"
1079
- },
1080
- // ----- k8s cluster-wide destructive ops (destructive) -----
1081
- {
1082
- id: "kubectl-delete-namespace",
1083
- level: "destructive",
1084
- test: (cmd, args) => {
1085
- if (cmd !== "kubectl") return false;
1086
- const delIdx = args.indexOf("delete");
1087
- if (delIdx < 0) return false;
1088
- const after = args.slice(delIdx + 1);
1089
- return after[0] === "namespace" || after[0] === "ns";
1090
- },
1091
- reason: "kubectl delete namespace (deletes all resources in the namespace)"
1092
- },
1093
- {
1094
- id: "kubectl-drain",
1095
- level: "destructive",
1096
- test: (cmd, args) => cmd === "kubectl" && args.includes("drain"),
1097
- reason: "kubectl drain (evicts pods, marks node unschedulable)"
1098
- },
1099
- // ----- inline code evaluation (caution — high false-positive) -----
1100
- // Common in scripts: `python -c "..."`, `node -e "..."`, `bash -c "..."`.
1101
- // We tag 'caution' rather than 'destructive' because these are used in
1102
- // many legitimate one-liners (e.g. `python -c "print(1)"`).
1103
- {
1104
- id: "inline-eval",
1105
- level: "caution",
1106
- test: (cmd, args) => {
1107
- if (![
1108
- "python",
1109
- "python3",
1110
- "python2",
1111
- "node",
1112
- "bash",
1113
- "sh",
1114
- "zsh",
1115
- "ruby",
1116
- "perl",
1117
- "lua"
1118
- ].includes(cmd)) {
1119
- return false;
1158
+ p.killed = true;
1159
+ return true;
1160
+ }
1161
+ try {
1162
+ if (force) {
1163
+ this._killPosix(p, "SIGKILL");
1164
+ } else {
1165
+ this._killPosix(p, "SIGTERM");
1166
+ const timer = setTimeout(() => {
1167
+ if (this.processes.has(pid) && !p.child.killed) {
1168
+ this._killPosix(p, "SIGKILL");
1169
+ }
1170
+ }, graceMs);
1171
+ timer.unref?.();
1120
1172
  }
1121
- return args.some(
1122
- (a) => a === "-c" || a === "-e" || a === "--eval" || a === "-eval" || a === "-E"
1123
- );
1124
- },
1125
- reason: "inline script evaluation (-c / -e / --eval)"
1126
- },
1127
- // ----- pipe-to-shell (destructive — download-and-run pattern) -----
1128
- // The classic `curl https://... | sh` download-and-run vector. Detected by
1129
- // looking for a known fetcher piped into a shell or expression evaluator.
1130
- // A shell command passed through `bash -c` / `pwsh -Command` arrives as one
1131
- // argv string, so scan the reconstructed argv text rather than individual
1132
- // tokens only.
1133
- {
1134
- id: "pipe-to-shell",
1135
- level: "destructive",
1136
- test: (cmd, args) => /\b(?:curl|wget|fetch|httpie|http|irm|iwr|Invoke-WebRequest|Invoke-RestMethod)\b[\s\S]{0,300}\|\s*(?:sudo\s+)?(?:sh|bash|zsh|fish|pwsh|powershell|iex|Invoke-Expression)\b/i.test(
1137
- [cmd, ...args].join(" ")
1138
- ),
1139
- reason: "network fetch piped to a shell (download-and-run pattern)"
1140
- },
1141
- // ----- privilege escalation (caution) -----
1142
- {
1143
- id: "sudo",
1144
- level: "caution",
1145
- test: (cmd) => cmd === "sudo" || cmd === "doas",
1146
- reason: "privilege escalation (sudo / doas)"
1147
- },
1148
- {
1149
- id: "runas",
1150
- level: "caution",
1151
- test: (cmd) => cmd === "runas" || cmd === "runas.exe",
1152
- reason: "Windows runas (run as different user)"
1153
- },
1154
- // ----- world-writable permissions (caution) -----
1155
- // `chmod 777` is rarely correct. `chmod -R 777` is almost always wrong.
1156
- // We only flag octal modes; symbolic modes like `chmod o+w` are
1157
- // left to the operator's discretion.
1158
- {
1159
- id: "chmod-world-writable",
1160
- level: "caution",
1161
- test: (cmd, args) => {
1162
- if (cmd !== "chmod") return false;
1163
- return args.some((a) => /^[0-7]{3,4}$/.test(a) && /7/.test(a));
1164
- },
1165
- reason: "chmod with world-writable octal mode (e.g. 777)"
1173
+ } catch {
1174
+ }
1175
+ p.killed = true;
1176
+ return true;
1166
1177
  }
1167
- ];
1168
- function detectDanger(cmd, args, bypass) {
1169
- const reasons = [];
1170
- let level = "safe";
1171
- let matchedRule;
1172
- for (const rule of RULES) {
1173
- if (bypass?.has(rule.id)) continue;
1174
- if (!rule.test(cmd, args)) continue;
1175
- reasons.push(rule.reason);
1176
- matchedRule = rule.id;
1177
- if (levelRank(rule.level) > levelRank(level)) {
1178
- level = rule.level;
1178
+ /**
1179
+ * Kill all tracked processes.
1180
+ * Returns the PIDs that were kill()ed.
1181
+ */
1182
+ killAll(opts = {}) {
1183
+ const pids = Array.from(this.processes.keys());
1184
+ const killed = [];
1185
+ for (const pid of pids) {
1186
+ const p = this.processes.get(pid);
1187
+ if (p && !p.protected && this.kill(pid, opts)) killed.push(pid);
1179
1188
  }
1189
+ return killed;
1180
1190
  }
1181
- if (level === "safe") return { level: "safe", reasons: [] };
1182
- const result = { level, reasons };
1183
- if (matchedRule !== void 0) result.matchedRule = matchedRule;
1184
- return result;
1185
- }
1186
- function levelRank(level) {
1187
- switch (level) {
1188
- case "safe":
1189
- return 0;
1190
- case "caution":
1191
- return 1;
1192
- case "destructive":
1193
- return 2;
1191
+ /**
1192
+ * Kill all processes for a specific session.
1193
+ * Returns the PIDs that were kill()ed.
1194
+ */
1195
+ killSession(sessionId, opts = {}) {
1196
+ const pids = this.bySession(sessionId).map((p) => p.pid);
1197
+ const killed = [];
1198
+ for (const pid of pids) {
1199
+ if (this.kill(pid, opts)) killed.push(pid);
1200
+ }
1201
+ return killed;
1202
+ }
1203
+ /**
1204
+ * Check whether a tracked process entry is stale — the child has exited
1205
+ * (exitCode !== null) AND it's been in the registry long enough that the
1206
+ * OS may have reused the PID for a new, unrelated process.
1207
+ *
1208
+ * P3 #24 (before-release.md): on POSIX, PIDs are reused after process
1209
+ * exit. If a tracked process exits but its 'close' event hasn't fired yet
1210
+ * (or was missed), the registry still holds the entry. A new process
1211
+ * gets the same PID, and PID-based lookups (get, kill, shouldBlockKill)
1212
+ * may incorrectly protect or target the wrong process.
1213
+ *
1214
+ * The 60s threshold is conservative — the OS typically waits much longer
1215
+ * before reusing a PID, but we want to clean up before that becomes a risk.
1216
+ */
1217
+ _isStaleEntry(entry) {
1218
+ return entry.child.exitCode !== null && Date.now() - entry.startedAt > 6e4;
1219
+ }
1220
+ /**
1221
+ * Remove a stale entry for a specific PID before any PID-based lookup.
1222
+ * This prevents PID reuse from causing the registry to act on a dead
1223
+ * process that has been replaced by a new one with the same PID.
1224
+ */
1225
+ _pruneStale(pid) {
1226
+ const entry = this.processes.get(pid);
1227
+ if (entry && this._isStaleEntry(entry)) {
1228
+ this.processes.delete(pid);
1229
+ }
1230
+ }
1231
+ /**
1232
+ * Remove every stale entry, not just one PID. `list()`/`stats()` — the
1233
+ * surfaces the TUI status bar and `/ps` poll — must prune too: a child
1234
+ * whose 'close' event never fires (e.g. Windows grandchildren holding stdio
1235
+ * open) would otherwise linger in the registry until someone looks up its
1236
+ * exact PID, and PID reuse meanwhile makes `get()`/`kill()` target the
1237
+ * wrong process. RAM-leak audit 2026-07-31, MEDIUM.
1238
+ */
1239
+ _pruneAllStale() {
1240
+ for (const [pid, entry] of this.processes) {
1241
+ if (this._isStaleEntry(entry)) this.processes.delete(pid);
1242
+ }
1194
1243
  }
1244
+ };
1245
+ var _registry;
1246
+ function getProcessRegistry() {
1247
+ if (!_registry) {
1248
+ _registry = new ProcessRegistryImpl();
1249
+ }
1250
+ return _registry;
1195
1251
  }
1196
1252
 
1197
- // src/exec-kill-guard.ts
1198
- import * as os3 from "node:os";
1199
- import * as path5 from "node:path";
1200
-
1201
1253
  // src/process-registry-persistent.ts
1202
- import * as fs2 from "node:fs/promises";
1203
- import * as os2 from "node:os";
1204
- import { wstackGlobalRoot as wstackGlobalRoot3 } from "@wrongstack/core/utils";
1205
- import * as path4 from "node:path";
1206
1254
  var REGISTRY_FILE = "process-registry.json";
1207
1255
  function toErrorMessage(err) {
1208
1256
  return err instanceof Error ? err.message : String(err);
@@ -1916,8 +1964,7 @@ async function checkKillTarget(target) {
1916
1964
  return { blocked: false };
1917
1965
  }
1918
1966
 
1919
- // src/exec.ts
1920
- var isWin2 = process.platform === "win32";
1967
+ // src/exec-allowlist.ts
1921
1968
  var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
1922
1969
  // JS / TS toolchain
1923
1970
  "node",
@@ -1996,7 +2043,11 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
1996
2043
  "docker",
1997
2044
  "podman",
1998
2045
  "kubectl",
1999
- // network (read-only intent; destructive ops still blocked by BLOCKED_ARG_PATTERNS)
2046
+ // network fetch tools. Per-arg safety is NOT enforced by BLOCKED_ARG_PATTERNS
2047
+ // (no curl/wget entries) — the real boundary is the per-call `confirm`
2048
+ // permission gate (exec.ts permission:'confirm', subjectKey:'command'),
2049
+ // the `pipe-to-shell` danger rule, and the kill guard. The allowlist is a
2050
+ // guidance layer, not a security boundary on its own.
2000
2051
  "curl",
2001
2052
  "wget",
2002
2053
  // common POSIX file/text utilities
@@ -2049,9 +2100,15 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
2049
2100
  "powershell.exe",
2050
2101
  "pwsh",
2051
2102
  "pwsh.exe",
2052
- // [core] Extended default allowlist (added 4b3d18d1 + this commit). All
2053
- // non-destructive, broadly-used dev binaries. Per-arg safety is still
2054
- // enforced by BLOCKED_ARG_PATTERNS + bash-kill-guard.ts.
2103
+ // [core] Extended default allowlist (added 4b3d18d1 + this commit). Broadly
2104
+ // used dev binaries — NOT all non-destructive: this section contains
2105
+ // process/system tools (kill, chown, mount, userdel), network scanners
2106
+ // (nmap, masscan, zmap, nuclei), and launchers (env, perl, timeout, tmux).
2107
+ // The real safety boundary for these is the per-call `confirm` permission
2108
+ // gate (exec.ts permission:'confirm', subjectKey:'command' renders the full
2109
+ // invocation) plus the danger rules in _danger-detect.ts. Per-arg hard-deny
2110
+ // is still enforced by BLOCKED_ARG_PATTERNS + bash-kill-guard.ts, but only
2111
+ // for the commands that have entries there (git, find, rm).
2055
2112
  // --- Archives & compression ---
2056
2113
  "7z",
2057
2114
  "7za",
@@ -2223,10 +2280,7 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
2223
2280
  "git-lfs",
2224
2281
  "tig",
2225
2282
  "lazygit",
2226
- // --- POSIX text utilities (extended; duplicates of pwd/ls/cat/head/tail/wc/
2227
- // grep/find/echo/awk/mkdir/cp/mv/rm/touch from the base list above are
2228
- // omitted — the Set is de-duplicated at runtime but a clean literal is
2229
- // easier to maintain) ---
2283
+ // --- POSIX text utilities (extended) ---
2230
2284
  "gawk",
2231
2285
  "tr",
2232
2286
  "cut",
@@ -2259,39 +2313,16 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
2259
2313
  "locate",
2260
2314
  "which",
2261
2315
  "whereis",
2262
- "type",
2263
- "hash",
2264
- "pushd",
2265
- "popd",
2266
- "dirs",
2267
- "history",
2268
- "fc",
2269
- "jobs",
2270
- "bg",
2271
- "fg",
2272
- "wait",
2273
- "ulimit",
2274
- "umask",
2316
+ // NOTE: shell builtins with no standalone binary (type, hash, pushd,
2317
+ // popd, dirs, history, fc, jobs, bg, fg, wait, ulimit, umask, trap,
2318
+ // exit, return, source, ., alias, unalias, set, unset, export, readonly,
2319
+ // typeset, declare, local, eval, exec) are intentionally excluded —
2320
+ // spawn() would fail with ENOENT on every platform because they only
2321
+ // exist inside a shell process. Use the `bash` tool for builtins.
2275
2322
  "nice",
2276
2323
  "nohup",
2277
2324
  "timeout",
2278
2325
  "time",
2279
- "trap",
2280
- "exit",
2281
- "return",
2282
- "source",
2283
- ".",
2284
- "alias",
2285
- "unalias",
2286
- "set",
2287
- "unset",
2288
- "export",
2289
- "readonly",
2290
- "typeset",
2291
- "declare",
2292
- "local",
2293
- "eval",
2294
- "exec",
2295
2326
  // --- Process / system inspection ---
2296
2327
  "htop",
2297
2328
  "top",
@@ -2522,6 +2553,9 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
2522
2553
  "sublert",
2523
2554
  "chaos"
2524
2555
  ]);
2556
+
2557
+ // src/exec.ts
2558
+ var isWin2 = process.platform === "win32";
2525
2559
  var allowedCommands = new Set(DEFAULT_ALLOWED_COMMANDS);
2526
2560
  var normalizeCmd = (c) => c.trim();
2527
2561
  function configureExecPolicy(opts = {}) {
@@ -2582,7 +2616,17 @@ var BLOCKED_ARG_PATTERNS = {
2582
2616
  bun: [],
2583
2617
  docker: [],
2584
2618
  // find -exec/-ok/-execdir execute arbitrary commands
2585
- find: [/^-exec$/, /^-exec;$/, /^-ok$/, /^-ok;$/, /^-execdir$/, /^-execdir;$/, /^-exec=/, /^-ok=/, /^-execdir=/],
2619
+ find: [
2620
+ /^-exec$/,
2621
+ /^-exec;$/,
2622
+ /^-ok$/,
2623
+ /^-ok;$/,
2624
+ /^-execdir$/,
2625
+ /^-execdir;$/,
2626
+ /^-exec=/,
2627
+ /^-ok=/,
2628
+ /^-execdir=/
2629
+ ],
2586
2630
  // rm -rf / is catastrophic — block absolute paths, home, dot-dirs,
2587
2631
  // and glob patterns that could expand to dangerous targets.
2588
2632
  // `rm -rf ./src/*` expands to project files; `rm -rf ../../` escapes upward;
@@ -2653,6 +2697,14 @@ var execTool = {
2653
2697
  useInstead: ["bash"]
2654
2698
  },
2655
2699
  permission: "confirm",
2700
+ // WS-046: without this, every exec call collapsed onto the bare tool name.
2701
+ // Three consequences followed: "always allow" stored a rule that could never
2702
+ // match, pressing "no" once blocked ALL exec for the session, and the
2703
+ // permission cache keyed one decision for every command. `subjectKey:
2704
+ // 'command'` renders the FULL invocation (`command` + `args`) — using the
2705
+ // program alone would make `exec git status` and `exec git push --force` the
2706
+ // same subject, so trusting one would silently authorize the other.
2707
+ subjectKey: "command",
2656
2708
  mutating: true,
2657
2709
  riskTier: "standard",
2658
2710
  timeoutMs: DEFAULT_TIMEOUT_MS,
@@ -2869,7 +2921,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId, danger) {
2869
2921
  const pid = child.pid;
2870
2922
  if (typeof pid === "number") {
2871
2923
  const fullCommand = `${cmd} ${args.join(" ")}`;
2872
- registry.register({ pid, name: "exec", command: redactCommand(fullCommand), startedAt: Date.now(), sessionId, child });
2924
+ registry.register({
2925
+ pid,
2926
+ name: "exec",
2927
+ command: redactCommand(fullCommand),
2928
+ startedAt: Date.now(),
2929
+ sessionId,
2930
+ child
2931
+ });
2873
2932
  }
2874
2933
  const timer = setTimeout(() => {
2875
2934
  killed = true;