@wrongstack/tools 0.297.0 → 0.298.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) 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 +841 -351
  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.js +560 -529
  20. package/dist/format.js +116 -65
  21. package/dist/git.js +22 -2
  22. package/dist/glob.js +11 -5
  23. package/dist/grep.js +160 -104
  24. package/dist/index.js +792 -290
  25. package/dist/install.js +120 -68
  26. package/dist/json.js +64 -18
  27. package/dist/kanban-board-inputs.d.ts +21 -4
  28. package/dist/kanban.js +20 -5
  29. package/dist/languages/index.js +128 -71
  30. package/dist/lint.js +113 -65
  31. package/dist/outdated.js +86 -26
  32. package/dist/pack.js +841 -351
  33. package/dist/patch.js +11 -1
  34. package/dist/plan.js +32 -10
  35. package/dist/read.js +77 -23
  36. package/dist/replace.js +11 -3
  37. package/dist/scaffold.js +9 -6
  38. package/dist/session-kanban.js +21 -5
  39. package/dist/task.js +3 -1
  40. package/dist/test.js +113 -65
  41. package/dist/todo.js +3 -1
  42. package/dist/tool-tier.js +841 -351
  43. package/dist/tool-use.d.ts +1 -1
  44. package/dist/tool-use.js +28 -4
  45. package/dist/tree.d.ts +1 -0
  46. package/dist/tree.js +44 -8
  47. package/dist/typecheck.js +113 -65
  48. package/dist/write.js +9 -6
  49. package/package.json +5 -4
  50. package/dist/_concurrency.d.ts.map +0 -1
  51. package/dist/_danger-detect.d.ts.map +0 -1
  52. package/dist/_edit-match.d.ts.map +0 -1
  53. package/dist/_env.d.ts.map +0 -1
  54. package/dist/_fetch-guard.d.ts.map +0 -1
  55. package/dist/_output-spool.d.ts.map +0 -1
  56. package/dist/_redact-command.d.ts.map +0 -1
  57. package/dist/_regex.d.ts.map +0 -1
  58. package/dist/_session-shell.d.ts.map +0 -1
  59. package/dist/_shell-pick.d.ts.map +0 -1
  60. package/dist/_spawn-stream.d.ts.map +0 -1
  61. package/dist/_syntax-check.d.ts.map +0 -1
  62. package/dist/_util.d.ts.map +0 -1
  63. package/dist/_win32-resolve.d.ts.map +0 -1
  64. package/dist/audit.d.ts.map +0 -1
  65. package/dist/audit.js.map +0 -7
  66. package/dist/auto-proceed-loop-guard.d.ts.map +0 -1
  67. package/dist/auto-proceed-loop-guard.js.map +0 -7
  68. package/dist/bash-kill-guard.d.ts.map +0 -1
  69. package/dist/bash.d.ts.map +0 -1
  70. package/dist/bash.js.map +0 -7
  71. package/dist/batch-tool-use.d.ts.map +0 -1
  72. package/dist/batch-tool-use.js.map +0 -7
  73. package/dist/browser/artifacts.d.ts.map +0 -1
  74. package/dist/browser/index.d.ts.map +0 -1
  75. package/dist/browser/index.js.map +0 -7
  76. package/dist/browser/manager.d.ts.map +0 -1
  77. package/dist/browser/network-guard-proxy.d.ts.map +0 -1
  78. package/dist/browser/security.d.ts.map +0 -1
  79. package/dist/browser/tools.d.ts.map +0 -1
  80. package/dist/browser/types.d.ts.map +0 -1
  81. package/dist/builtin.d.ts.map +0 -1
  82. package/dist/builtin.js.map +0 -7
  83. package/dist/circuit-breaker.d.ts.map +0 -1
  84. package/dist/circuit-breaker.js.map +0 -7
  85. package/dist/codebase-index/background-indexer.d.ts.map +0 -1
  86. package/dist/codebase-index/bm25.d.ts.map +0 -1
  87. package/dist/codebase-index/circuit-breaker.d.ts.map +0 -1
  88. package/dist/codebase-index/codebase-index-tool.d.ts.map +0 -1
  89. package/dist/codebase-index/codebase-search-tool.d.ts.map +0 -1
  90. package/dist/codebase-index/codebase-stats-tool.d.ts.map +0 -1
  91. package/dist/codebase-index/dead-code-scan.d.ts.map +0 -1
  92. package/dist/codebase-index/generic-parser.d.ts.map +0 -1
  93. package/dist/codebase-index/gitignore.d.ts.map +0 -1
  94. package/dist/codebase-index/go-parser.d.ts.map +0 -1
  95. package/dist/codebase-index/index-service.d.ts.map +0 -1
  96. package/dist/codebase-index/index.d.ts.map +0 -1
  97. package/dist/codebase-index/index.js.map +0 -7
  98. package/dist/codebase-index/indexer.d.ts.map +0 -1
  99. package/dist/codebase-index/json-parser.d.ts.map +0 -1
  100. package/dist/codebase-index/languages.d.ts.map +0 -1
  101. package/dist/codebase-index/lsp-kind.d.ts.map +0 -1
  102. package/dist/codebase-index/parser-dispatch.d.ts.map +0 -1
  103. package/dist/codebase-index/project-server-cache.d.ts.map +0 -1
  104. package/dist/codebase-index/project-server-client.d.ts.map +0 -1
  105. package/dist/codebase-index/project-server-endpoint.d.ts.map +0 -1
  106. package/dist/codebase-index/project-server-protocol.d.ts.map +0 -1
  107. package/dist/codebase-index/project-server.d.ts.map +0 -1
  108. package/dist/codebase-index/project-server.js.map +0 -7
  109. package/dist/codebase-index/py-parser.d.ts.map +0 -1
  110. package/dist/codebase-index/refs-extractor.d.ts.map +0 -1
  111. package/dist/codebase-index/rs-parser.d.ts.map +0 -1
  112. package/dist/codebase-index/schema.d.ts.map +0 -1
  113. package/dist/codebase-index/spawn-gate.d.ts.map +0 -1
  114. package/dist/codebase-index/sqlite-runtime.d.ts.map +0 -1
  115. package/dist/codebase-index/ts-parser.d.ts.map +0 -1
  116. package/dist/codebase-index/worker-protocol.d.ts.map +0 -1
  117. package/dist/codebase-index/worker.d.ts.map +0 -1
  118. package/dist/codebase-index/worker.js.map +0 -7
  119. package/dist/codebase-index/writer-admin.d.ts.map +0 -1
  120. package/dist/codebase-index/writer-bulk-insert.d.ts.map +0 -1
  121. package/dist/codebase-index/writer-graph-helpers.d.ts.map +0 -1
  122. package/dist/codebase-index/writer-graph-reader.d.ts.map +0 -1
  123. package/dist/codebase-index/writer-helpers.d.ts.map +0 -1
  124. package/dist/codebase-index/writer-pragmas.d.ts.map +0 -1
  125. package/dist/codebase-index/writer-ref-mapper.d.ts.map +0 -1
  126. package/dist/codebase-index/writer-schema.d.ts.map +0 -1
  127. package/dist/codebase-index/writer-search-helpers.d.ts.map +0 -1
  128. package/dist/codebase-index/writer-store-pool.d.ts.map +0 -1
  129. package/dist/codebase-index/writer.d.ts.map +0 -1
  130. package/dist/codebase-index/yaml-parser.d.ts.map +0 -1
  131. package/dist/design.d.ts.map +0 -1
  132. package/dist/design.js.map +0 -7
  133. package/dist/diff.d.ts.map +0 -1
  134. package/dist/diff.js.map +0 -7
  135. package/dist/document.d.ts.map +0 -1
  136. package/dist/document.js.map +0 -7
  137. package/dist/e2e.d.ts.map +0 -1
  138. package/dist/e2e.js.map +0 -7
  139. package/dist/edit.d.ts.map +0 -1
  140. package/dist/edit.js.map +0 -7
  141. package/dist/exec-kill-guard.d.ts.map +0 -1
  142. package/dist/exec.d.ts.map +0 -1
  143. package/dist/exec.js.map +0 -7
  144. package/dist/fetch.d.ts.map +0 -1
  145. package/dist/fetch.js.map +0 -7
  146. package/dist/format.d.ts.map +0 -1
  147. package/dist/format.js.map +0 -7
  148. package/dist/git.d.ts.map +0 -1
  149. package/dist/git.js.map +0 -7
  150. package/dist/glob.d.ts.map +0 -1
  151. package/dist/glob.js.map +0 -7
  152. package/dist/grep.d.ts.map +0 -1
  153. package/dist/grep.js.map +0 -7
  154. package/dist/index.d.ts.map +0 -1
  155. package/dist/index.js.map +0 -7
  156. package/dist/install.d.ts.map +0 -1
  157. package/dist/install.js.map +0 -7
  158. package/dist/json.d.ts.map +0 -1
  159. package/dist/json.js.map +0 -7
  160. package/dist/kanban-board-inputs.d.ts.map +0 -1
  161. package/dist/kanban-decomposition-actions.d.ts.map +0 -1
  162. package/dist/kanban-detail-actions.d.ts.map +0 -1
  163. package/dist/kanban-evidence-bridge.d.ts.map +0 -1
  164. package/dist/kanban-presence.d.ts.map +0 -1
  165. package/dist/kanban-split-task-handler.d.ts.map +0 -1
  166. package/dist/kanban-task-inputs.d.ts.map +0 -1
  167. package/dist/kanban-tool-results.d.ts.map +0 -1
  168. package/dist/kanban-tool-schema.d.ts.map +0 -1
  169. package/dist/kanban-tool-types.d.ts.map +0 -1
  170. package/dist/kanban.d.ts.map +0 -1
  171. package/dist/kanban.js.map +0 -7
  172. package/dist/languages/detect.d.ts.map +0 -1
  173. package/dist/languages/diagnostics.d.ts.map +0 -1
  174. package/dist/languages/execute-tool.d.ts.map +0 -1
  175. package/dist/languages/execute.d.ts.map +0 -1
  176. package/dist/languages/index.d.ts.map +0 -1
  177. package/dist/languages/index.js.map +0 -7
  178. package/dist/languages/legacy-bridge.d.ts.map +0 -1
  179. package/dist/languages/package-tool.d.ts.map +0 -1
  180. package/dist/languages/plan.d.ts.map +0 -1
  181. package/dist/languages/profile-helpers.d.ts.map +0 -1
  182. package/dist/languages/profiles/additional.d.ts.map +0 -1
  183. package/dist/languages/profiles/primary.d.ts.map +0 -1
  184. package/dist/languages/registry.d.ts.map +0 -1
  185. package/dist/languages/tool.d.ts.map +0 -1
  186. package/dist/languages/types.d.ts.map +0 -1
  187. package/dist/lint.d.ts.map +0 -1
  188. package/dist/lint.js.map +0 -7
  189. package/dist/logs.d.ts.map +0 -1
  190. package/dist/logs.js.map +0 -7
  191. package/dist/memory.d.ts.map +0 -1
  192. package/dist/memory.js.map +0 -7
  193. package/dist/mode.d.ts.map +0 -1
  194. package/dist/mode.js.map +0 -7
  195. package/dist/next-steps.d.ts.map +0 -1
  196. package/dist/next-steps.js.map +0 -7
  197. package/dist/outdated.d.ts.map +0 -1
  198. package/dist/outdated.js.map +0 -7
  199. package/dist/pack.d.ts.map +0 -1
  200. package/dist/pack.js.map +0 -7
  201. package/dist/patch.d.ts.map +0 -1
  202. package/dist/patch.js.map +0 -7
  203. package/dist/plan.d.ts.map +0 -1
  204. package/dist/plan.js.map +0 -7
  205. package/dist/process-guardian.d.ts.map +0 -1
  206. package/dist/process-registry-persistent.d.ts.map +0 -1
  207. package/dist/process-registry.d.ts.map +0 -1
  208. package/dist/process-registry.js.map +0 -7
  209. package/dist/ps-slash.d.ts.map +0 -1
  210. package/dist/ps-slash.js.map +0 -7
  211. package/dist/read.d.ts.map +0 -1
  212. package/dist/read.js.map +0 -7
  213. package/dist/replace.d.ts.map +0 -1
  214. package/dist/replace.js.map +0 -7
  215. package/dist/scaffold.d.ts.map +0 -1
  216. package/dist/scaffold.js.map +0 -7
  217. package/dist/search.d.ts.map +0 -1
  218. package/dist/search.js.map +0 -7
  219. package/dist/session-kanban.d.ts.map +0 -1
  220. package/dist/session-kanban.js.map +0 -7
  221. package/dist/set-working-dir.d.ts.map +0 -1
  222. package/dist/skill.d.ts.map +0 -1
  223. package/dist/skill.js.map +0 -7
  224. package/dist/spawn-background.d.ts.map +0 -1
  225. package/dist/task.d.ts.map +0 -1
  226. package/dist/task.js.map +0 -7
  227. package/dist/test.d.ts.map +0 -1
  228. package/dist/test.js.map +0 -7
  229. package/dist/todo.d.ts.map +0 -1
  230. package/dist/todo.js.map +0 -7
  231. package/dist/tool-diff.d.ts.map +0 -1
  232. package/dist/tool-diff.js.map +0 -7
  233. package/dist/tool-help.d.ts.map +0 -1
  234. package/dist/tool-help.js.map +0 -7
  235. package/dist/tool-icon-map.d.ts.map +0 -1
  236. package/dist/tool-icons.d.ts.map +0 -1
  237. package/dist/tool-icons.js.map +0 -7
  238. package/dist/tool-search.d.ts.map +0 -1
  239. package/dist/tool-search.js.map +0 -7
  240. package/dist/tool-summary.d.ts.map +0 -1
  241. package/dist/tool-summary.js.map +0 -7
  242. package/dist/tool-tier.d.ts.map +0 -1
  243. package/dist/tool-tier.js.map +0 -7
  244. package/dist/tool-use.d.ts.map +0 -1
  245. package/dist/tool-use.js.map +0 -7
  246. package/dist/tree.d.ts.map +0 -1
  247. package/dist/tree.js.map +0 -7
  248. package/dist/typecheck.d.ts.map +0 -1
  249. package/dist/typecheck.js.map +0 -7
  250. package/dist/win32.d.ts.map +0 -1
  251. package/dist/win32.js.map +0 -7
  252. package/dist/write.d.ts.map +0 -1
  253. package/dist/write.js.map +0 -7
package/dist/exec.js CHANGED
@@ -7,6 +7,287 @@ 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
+ ];
262
+ function detectDanger(cmd, args, bypass) {
263
+ const reasons = [];
264
+ let level = "safe";
265
+ let matchedRule;
266
+ for (const rule of RULES) {
267
+ if (bypass?.has(rule.id)) continue;
268
+ if (!rule.test(cmd, args)) continue;
269
+ reasons.push(rule.reason);
270
+ matchedRule = rule.id;
271
+ if (levelRank(rule.level) > levelRank(level)) {
272
+ level = rule.level;
273
+ }
274
+ }
275
+ if (level === "safe") return { level: "safe", reasons: [] };
276
+ const result = { level, reasons };
277
+ if (matchedRule !== void 0) result.matchedRule = matchedRule;
278
+ return result;
279
+ }
280
+ function levelRank(level) {
281
+ switch (level) {
282
+ case "safe":
283
+ return 0;
284
+ case "caution":
285
+ return 1;
286
+ case "destructive":
287
+ return 2;
288
+ }
289
+ }
290
+
10
291
  // src/_env.ts
11
292
  import { buildChildEnv } from "@wrongstack/core/utils";
12
293
 
@@ -81,11 +362,12 @@ function createOutputSpool(opts) {
81
362
  return {
82
363
  write(text) {
83
364
  if (finalized || !text) return;
84
- totalBytes += Buffer.byteLength(text, "utf8");
365
+ const textBytes = Buffer.byteLength(text, "utf8");
366
+ totalBytes += textBytes;
85
367
  if (!stream && !failed) {
86
- if (headBytes + text.length <= threshold) {
368
+ if (headBytes + textBytes <= threshold) {
87
369
  head += text;
88
- headBytes += text.length;
370
+ headBytes += textBytes;
89
371
  return;
90
372
  }
91
373
  head += text;
@@ -95,7 +377,7 @@ function createOutputSpool(opts) {
95
377
  }
96
378
  if (stream) {
97
379
  if (stream.writableLength > SPOOL_WRITE_HWM_BYTES) {
98
- droppedBytes += Buffer.byteLength(text, "utf8");
380
+ droppedBytes += textBytes;
99
381
  return;
100
382
  }
101
383
  stream.write(text);
@@ -142,12 +424,13 @@ function ensureInsideRoot(absPath, ctx) {
142
424
  function safeResolve(input, ctx) {
143
425
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
144
426
  }
145
- async function assertRealInsideRoot(absPath, ctx) {
146
- if (ctx.allowOutsideProjectRoot) return;
427
+ async function resolveRealInsideRoot(absPath, ctx) {
428
+ if (ctx.allowOutsideProjectRoot) return absPath;
147
429
  const realRoots = await Promise.all(
148
430
  allowedRoots(ctx).map((r) => fsp2.realpath(r).catch(() => path2.resolve(r)))
149
431
  );
150
432
  let probe = absPath;
433
+ const pendingTail = [];
151
434
  for (; ; ) {
152
435
  let real;
153
436
  try {
@@ -155,13 +438,16 @@ async function assertRealInsideRoot(absPath, ctx) {
155
438
  } catch (err) {
156
439
  if (err.code === "ENOENT") {
157
440
  const parent = path2.dirname(probe);
158
- if (parent === probe) return;
441
+ if (parent === probe) return absPath;
442
+ pendingTail.unshift(path2.basename(probe));
159
443
  probe = parent;
160
444
  continue;
161
445
  }
162
446
  throw err;
163
447
  }
164
- if (isInsideAny(real, realRoots)) return;
448
+ if (isInsideAny(real, realRoots)) {
449
+ return pendingTail.length > 0 ? path2.join(real, ...pendingTail) : real;
450
+ }
165
451
  throw new Error(
166
452
  `Path "${absPath}" resolves through a symlink outside project root "${realRoots[0]}"`
167
453
  );
@@ -169,8 +455,7 @@ async function assertRealInsideRoot(absPath, ctx) {
169
455
  }
170
456
  async function safeResolveReal(input, ctx) {
171
457
  const abs = safeResolve(input, ctx);
172
- await assertRealInsideRoot(abs, ctx);
173
- return abs;
458
+ return await resolveRealInsideRoot(abs, ctx);
174
459
  }
175
460
  var COMMAND_OUTPUT_MAX_BYTES = 32768;
176
461
  var REPEAT_RUN_THRESHOLD = 3;
@@ -243,6 +528,62 @@ function normalizeCommandOutput(raw, opts = {}) {
243
528
  return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);
244
529
  }
245
530
 
531
+ // src/_win32-resolve.ts
532
+ import * as fs from "node:fs";
533
+ import * as path3 from "node:path";
534
+ function resolveWin32Command(cmd) {
535
+ if (process.platform !== "win32") return cmd;
536
+ if (cmd.includes("/") || cmd.includes("\\") || path3.extname(cmd.replace(/\//g, "\\"))) {
537
+ return cmd;
538
+ }
539
+ const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
540
+ const pathDirs = (process.env["PATH"] ?? "").split(path3.delimiter);
541
+ for (const dir of pathDirs) {
542
+ const base = path3.join(dir, cmd);
543
+ for (const ext of pathext) {
544
+ const full = `${base}${ext}`;
545
+ try {
546
+ fs.accessSync(full, fs.constants.X_OK);
547
+ return full;
548
+ } catch {
549
+ }
550
+ }
551
+ }
552
+ return cmd;
553
+ }
554
+ var WIN32_SHELL_META = /[&|<>"\r\n\0]/;
555
+ function assertSafeWin32ShellArgs(args) {
556
+ for (const arg of args) {
557
+ if (typeof arg === "string" && WIN32_SHELL_META.test(arg)) {
558
+ throw new Error(
559
+ '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)
560
+ );
561
+ }
562
+ }
563
+ }
564
+ function buildWin32CmdShimInvocation(command, args = []) {
565
+ assertSafeWin32ShellArgs([command, ...args]);
566
+ const line = ["call", quoteWin32CmdArg(command), ...args.map(quoteWin32CmdArg)].join(" ");
567
+ return {
568
+ command: process.env["COMSPEC"] ?? "cmd.exe",
569
+ args: ["/d", "/c", line],
570
+ windowsVerbatimArguments: true
571
+ };
572
+ }
573
+ function quoteWin32CmdArg(arg) {
574
+ return `"${arg}"`;
575
+ }
576
+
577
+ // src/exec-kill-guard.ts
578
+ import * as os3 from "node:os";
579
+ import * as path5 from "node:path";
580
+
581
+ // src/process-registry-persistent.ts
582
+ import * as fs2 from "node:fs/promises";
583
+ import * as os2 from "node:os";
584
+ import { wstackGlobalRoot as wstackGlobalRoot3 } from "@wrongstack/core/utils";
585
+ import * as path4 from "node:path";
586
+
246
587
  // src/process-registry.ts
247
588
  import { spawn } from "node:child_process";
248
589
  import * as os from "node:os";
@@ -665,544 +1006,209 @@ var ProcessRegistryImpl = class {
665
1006
  this._cancelAutoKillReset();
666
1007
  return;
667
1008
  }
668
- if (this.breaker.isEnabled && this.breaker.snapshot().state === "open") {
669
- 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);
1009
+ if (this.breaker.isEnabled && this.breaker.snapshot().state === "open") {
1010
+ this._armAutoKillReset();
859
1011
  }
860
1012
  }
861
- };
862
- var _registry;
863
- function getProcessRegistry() {
864
- if (!_registry) {
865
- _registry = new ProcessRegistryImpl();
1013
+ /**
1014
+ * Live countdown to the next auto kill/reset, or null when nothing is armed.
1015
+ * The TUI polls this on a 1s tick while armed so the statusline decrements.
1016
+ */
1017
+ getBreakerCountdown() {
1018
+ if (this.autoKillArmedAt === null || this.autoKillResetMs <= 0) return null;
1019
+ const elapsed = Date.now() - this.autoKillArmedAt;
1020
+ return { remainingMs: Math.max(0, this.autoKillResetMs - elapsed), totalMs: this.autoKillResetMs };
866
1021
  }
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;
1022
+ /**
1023
+ * Subscribe to countdown arm/cancel events. Returns an unsubscribe function.
1024
+ * Use {@link getBreakerCountdown} for the live ticking value between events.
1025
+ */
1026
+ onBreakerCountdownChange(listener) {
1027
+ this.breakerCountdownListeners.push(listener);
1028
+ return () => {
1029
+ this.breakerCountdownListeners = this.breakerCountdownListeners.filter((l) => l !== listener);
1030
+ };
877
1031
  }
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}`;
1032
+ _emitBreakerCountdown() {
1033
+ const snap = this.getBreakerCountdown();
1034
+ for (const l of this.breakerCountdownListeners) {
884
1035
  try {
885
- fs.accessSync(full, fs.constants.X_OK);
886
- return full;
1036
+ l(snap);
887
1037
  } catch {
888
1038
  }
889
1039
  }
890
1040
  }
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
- );
1041
+ /**
1042
+ * Arm the auto kill/reset countdown. Idempotent: re-arming resets the window
1043
+ * (a fresh trip after a failed half-open probe restarts the clock). No-op
1044
+ * when protection is off or no timeout is configured.
1045
+ */
1046
+ _armAutoKillReset() {
1047
+ if (this.autoKillResetMs <= 0 || !this.breaker.isEnabled) return;
1048
+ this._clearAutoKillTimer();
1049
+ this.autoKillArmedAt = Date.now();
1050
+ this.autoKillTimer = setTimeout(() => {
1051
+ this.autoKillTimer = null;
1052
+ this.autoKillArmedAt = null;
1053
+ this.killAll({ force: false, preserveBackground: true });
1054
+ this.breaker.forceReset();
1055
+ this._emitBreakerCountdown();
1056
+ }, this.autoKillResetMs);
1057
+ this.autoKillTimer.unref?.();
1058
+ this._emitBreakerCountdown();
1059
+ }
1060
+ _cancelAutoKillReset() {
1061
+ const wasArmed = this.autoKillArmedAt !== null;
1062
+ this._clearAutoKillTimer();
1063
+ if (wasArmed) {
1064
+ this.autoKillArmedAt = null;
1065
+ this._emitBreakerCountdown();
900
1066
  }
901
1067
  }
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);
1068
+ _clearAutoKillTimer() {
1069
+ if (this.autoKillTimer !== null) {
1070
+ clearTimeout(this.autoKillTimer);
1071
+ this.autoKillTimer = null;
1072
+ }
924
1073
  }
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;
1074
+ /** Kill a single process by PID.
1075
+ *
1076
+ * On POSIX: sends SIGTERM to the *process group* (-pid) so that
1077
+ * runaway grandchild processes (`sleep 9999 & disown`) are also killed.
1078
+ * After `graceMs` a SIGKILL is sent if the process hasn't exited.
1079
+ *
1080
+ * On Windows: `child.kill()` maps to TerminateProcess process groups
1081
+ * are not meaningfully supported. A second `force=true` call sends
1082
+ * SIGKILL (which maps to TerminateProcess again — the distinction is
1083
+ * in the exit code, not the signal).
1084
+ *
1085
+ * Returns true if the process was found and kill was attempted.
1086
+ */
1087
+ kill(pid, opts = {}) {
1088
+ this._pruneStale(pid);
1089
+ const p = this.processes.get(pid);
1090
+ if (!p) return false;
1091
+ if (p.killed) return true;
1092
+ if (p.protected) return false;
1093
+ if (opts.preserveBackground && p.background) return false;
1094
+ const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
1095
+ const isWin3 = os.platform() === "win32";
1096
+ if (isWin3) {
1097
+ const liveRealChild = p.child.exitCode === null && typeof p.child.pid === "number";
1098
+ const directFallback = () => {
1099
+ if (p.child.exitCode === null) {
1100
+ try {
1101
+ p.child.kill("SIGKILL");
1102
+ } catch {
1103
+ }
1104
+ }
1105
+ };
1106
+ if (liveRealChild && killWin32Tree(pid, {
1107
+ timeoutMs: Math.max(graceMs, WIN32_TASKKILL_TIMEOUT_MS),
1108
+ onSettled: directFallback
1109
+ })) {
1110
+ } else {
1111
+ try {
1112
+ p.child.kill(force ? "SIGKILL" : "SIGTERM");
1113
+ } catch {
1114
+ }
1044
1115
  }
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;
1116
+ p.killed = true;
1117
+ return true;
1118
+ }
1119
+ try {
1120
+ if (force) {
1121
+ this._killPosix(p, "SIGKILL");
1122
+ } else {
1123
+ this._killPosix(p, "SIGTERM");
1124
+ const timer = setTimeout(() => {
1125
+ if (this.processes.has(pid) && !p.child.killed) {
1126
+ this._killPosix(p, "SIGKILL");
1127
+ }
1128
+ }, graceMs);
1129
+ timer.unref?.();
1120
1130
  }
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)"
1131
+ } catch {
1132
+ }
1133
+ p.killed = true;
1134
+ return true;
1166
1135
  }
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;
1136
+ /**
1137
+ * Kill all tracked processes.
1138
+ * Returns the PIDs that were kill()ed.
1139
+ */
1140
+ killAll(opts = {}) {
1141
+ const pids = Array.from(this.processes.keys());
1142
+ const killed = [];
1143
+ for (const pid of pids) {
1144
+ const p = this.processes.get(pid);
1145
+ if (p && !p.protected && this.kill(pid, opts)) killed.push(pid);
1179
1146
  }
1147
+ return killed;
1180
1148
  }
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;
1149
+ /**
1150
+ * Kill all processes for a specific session.
1151
+ * Returns the PIDs that were kill()ed.
1152
+ */
1153
+ killSession(sessionId, opts = {}) {
1154
+ const pids = this.bySession(sessionId).map((p) => p.pid);
1155
+ const killed = [];
1156
+ for (const pid of pids) {
1157
+ if (this.kill(pid, opts)) killed.push(pid);
1158
+ }
1159
+ return killed;
1160
+ }
1161
+ /**
1162
+ * Check whether a tracked process entry is stale — the child has exited
1163
+ * (exitCode !== null) AND it's been in the registry long enough that the
1164
+ * OS may have reused the PID for a new, unrelated process.
1165
+ *
1166
+ * P3 #24 (before-release.md): on POSIX, PIDs are reused after process
1167
+ * exit. If a tracked process exits but its 'close' event hasn't fired yet
1168
+ * (or was missed), the registry still holds the entry. A new process
1169
+ * gets the same PID, and PID-based lookups (get, kill, shouldBlockKill)
1170
+ * may incorrectly protect or target the wrong process.
1171
+ *
1172
+ * The 60s threshold is conservative — the OS typically waits much longer
1173
+ * before reusing a PID, but we want to clean up before that becomes a risk.
1174
+ */
1175
+ _isStaleEntry(entry) {
1176
+ return entry.child.exitCode !== null && Date.now() - entry.startedAt > 6e4;
1177
+ }
1178
+ /**
1179
+ * Remove a stale entry for a specific PID before any PID-based lookup.
1180
+ * This prevents PID reuse from causing the registry to act on a dead
1181
+ * process that has been replaced by a new one with the same PID.
1182
+ */
1183
+ _pruneStale(pid) {
1184
+ const entry = this.processes.get(pid);
1185
+ if (entry && this._isStaleEntry(entry)) {
1186
+ this.processes.delete(pid);
1187
+ }
1194
1188
  }
1189
+ /**
1190
+ * Remove every stale entry, not just one PID. `list()`/`stats()` — the
1191
+ * surfaces the TUI status bar and `/ps` poll — must prune too: a child
1192
+ * whose 'close' event never fires (e.g. Windows grandchildren holding stdio
1193
+ * open) would otherwise linger in the registry until someone looks up its
1194
+ * exact PID, and PID reuse meanwhile makes `get()`/`kill()` target the
1195
+ * wrong process. RAM-leak audit 2026-07-31, MEDIUM.
1196
+ */
1197
+ _pruneAllStale() {
1198
+ for (const [pid, entry] of this.processes) {
1199
+ if (this._isStaleEntry(entry)) this.processes.delete(pid);
1200
+ }
1201
+ }
1202
+ };
1203
+ var _registry;
1204
+ function getProcessRegistry() {
1205
+ if (!_registry) {
1206
+ _registry = new ProcessRegistryImpl();
1207
+ }
1208
+ return _registry;
1195
1209
  }
1196
1210
 
1197
- // src/exec-kill-guard.ts
1198
- import * as os3 from "node:os";
1199
- import * as path5 from "node:path";
1200
-
1201
1211
  // 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
1212
  var REGISTRY_FILE = "process-registry.json";
1207
1213
  function toErrorMessage(err) {
1208
1214
  return err instanceof Error ? err.message : String(err);
@@ -2582,7 +2588,17 @@ var BLOCKED_ARG_PATTERNS = {
2582
2588
  bun: [],
2583
2589
  docker: [],
2584
2590
  // find -exec/-ok/-execdir execute arbitrary commands
2585
- find: [/^-exec$/, /^-exec;$/, /^-ok$/, /^-ok;$/, /^-execdir$/, /^-execdir;$/, /^-exec=/, /^-ok=/, /^-execdir=/],
2591
+ find: [
2592
+ /^-exec$/,
2593
+ /^-exec;$/,
2594
+ /^-ok$/,
2595
+ /^-ok;$/,
2596
+ /^-execdir$/,
2597
+ /^-execdir;$/,
2598
+ /^-exec=/,
2599
+ /^-ok=/,
2600
+ /^-execdir=/
2601
+ ],
2586
2602
  // rm -rf / is catastrophic — block absolute paths, home, dot-dirs,
2587
2603
  // and glob patterns that could expand to dangerous targets.
2588
2604
  // `rm -rf ./src/*` expands to project files; `rm -rf ../../` escapes upward;
@@ -2653,6 +2669,14 @@ var execTool = {
2653
2669
  useInstead: ["bash"]
2654
2670
  },
2655
2671
  permission: "confirm",
2672
+ // WS-046: without this, every exec call collapsed onto the bare tool name.
2673
+ // Three consequences followed: "always allow" stored a rule that could never
2674
+ // match, pressing "no" once blocked ALL exec for the session, and the
2675
+ // permission cache keyed one decision for every command. `subjectKey:
2676
+ // 'command'` renders the FULL invocation (`command` + `args`) — using the
2677
+ // program alone would make `exec git status` and `exec git push --force` the
2678
+ // same subject, so trusting one would silently authorize the other.
2679
+ subjectKey: "command",
2656
2680
  mutating: true,
2657
2681
  riskTier: "standard",
2658
2682
  timeoutMs: DEFAULT_TIMEOUT_MS,
@@ -2869,7 +2893,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId, danger) {
2869
2893
  const pid = child.pid;
2870
2894
  if (typeof pid === "number") {
2871
2895
  const fullCommand = `${cmd} ${args.join(" ")}`;
2872
- registry.register({ pid, name: "exec", command: redactCommand(fullCommand), startedAt: Date.now(), sessionId, child });
2896
+ registry.register({
2897
+ pid,
2898
+ name: "exec",
2899
+ command: redactCommand(fullCommand),
2900
+ startedAt: Date.now(),
2901
+ sessionId,
2902
+ child
2903
+ });
2873
2904
  }
2874
2905
  const timer = setTimeout(() => {
2875
2906
  killed = true;