@wrongstack/tools 0.296.4 → 0.298.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_spawn-stream.d.ts +2 -0
- package/dist/_util.d.ts +29 -1
- package/dist/audit.js +128 -65
- package/dist/bash.js +20 -4
- package/dist/batch-tool-use.js +70 -7
- package/dist/browser/index.js +31 -1
- package/dist/builtin.js +883 -351
- package/dist/codebase-index/index.js +95 -16
- package/dist/codebase-index/project-server-protocol.d.ts +26 -0
- package/dist/codebase-index/project-server.js +76 -16
- package/dist/codebase-index/worker.js +36 -13
- package/dist/codebase-index/writer-search-helpers.d.ts +16 -0
- package/dist/codebase-index/writer.d.ts +13 -1
- package/dist/design.d.ts +1 -1
- package/dist/design.js +4 -3
- package/dist/diff.js +11 -1
- package/dist/e2e.js +9 -6
- package/dist/edit.js +9 -6
- package/dist/exec.js +562 -516
- package/dist/format.js +131 -65
- package/dist/git.js +22 -2
- package/dist/glob.js +11 -5
- package/dist/grep.js +160 -104
- package/dist/index.js +834 -290
- package/dist/install.js +135 -68
- package/dist/json.js +64 -18
- package/dist/kanban-board-inputs.d.ts +21 -4
- package/dist/kanban.js +20 -5
- package/dist/languages/index.js +143 -71
- package/dist/lint.js +128 -65
- package/dist/next-steps.d.ts +23 -0
- package/dist/next-steps.js +4 -0
- package/dist/outdated.js +101 -26
- package/dist/pack.js +883 -351
- package/dist/patch.js +11 -1
- package/dist/plan.js +32 -10
- package/dist/process-registry.d.ts +9 -0
- package/dist/process-registry.js +15 -0
- package/dist/ps-slash.js +15 -0
- package/dist/read.js +104 -23
- package/dist/replace.js +11 -3
- package/dist/scaffold.js +9 -6
- package/dist/session-kanban.js +21 -5
- package/dist/task.js +3 -1
- package/dist/test.js +128 -65
- package/dist/todo.js +3 -1
- package/dist/tool-tier.js +883 -351
- package/dist/tool-use.d.ts +1 -1
- package/dist/tool-use.js +28 -4
- package/dist/tree.d.ts +1 -0
- package/dist/tree.js +44 -8
- package/dist/typecheck.js +128 -65
- package/dist/write.js +9 -6
- package/package.json +5 -4
- package/dist/_concurrency.d.ts.map +0 -1
- package/dist/_danger-detect.d.ts.map +0 -1
- package/dist/_edit-match.d.ts.map +0 -1
- package/dist/_env.d.ts.map +0 -1
- package/dist/_fetch-guard.d.ts.map +0 -1
- package/dist/_output-spool.d.ts.map +0 -1
- package/dist/_redact-command.d.ts.map +0 -1
- package/dist/_regex.d.ts.map +0 -1
- package/dist/_session-shell.d.ts.map +0 -1
- package/dist/_shell-pick.d.ts.map +0 -1
- package/dist/_spawn-stream.d.ts.map +0 -1
- package/dist/_syntax-check.d.ts.map +0 -1
- package/dist/_util.d.ts.map +0 -1
- package/dist/_win32-resolve.d.ts.map +0 -1
- package/dist/audit.d.ts.map +0 -1
- package/dist/audit.js.map +0 -7
- package/dist/auto-proceed-loop-guard.d.ts.map +0 -1
- package/dist/auto-proceed-loop-guard.js.map +0 -7
- package/dist/bash-kill-guard.d.ts.map +0 -1
- package/dist/bash.d.ts.map +0 -1
- package/dist/bash.js.map +0 -7
- package/dist/batch-tool-use.d.ts.map +0 -1
- package/dist/batch-tool-use.js.map +0 -7
- package/dist/browser/artifacts.d.ts.map +0 -1
- package/dist/browser/index.d.ts.map +0 -1
- package/dist/browser/index.js.map +0 -7
- package/dist/browser/manager.d.ts.map +0 -1
- package/dist/browser/network-guard-proxy.d.ts.map +0 -1
- package/dist/browser/security.d.ts.map +0 -1
- package/dist/browser/tools.d.ts.map +0 -1
- package/dist/browser/types.d.ts.map +0 -1
- package/dist/builtin.d.ts.map +0 -1
- package/dist/builtin.js.map +0 -7
- package/dist/circuit-breaker.d.ts.map +0 -1
- package/dist/circuit-breaker.js.map +0 -7
- package/dist/codebase-index/background-indexer.d.ts.map +0 -1
- package/dist/codebase-index/bm25.d.ts.map +0 -1
- package/dist/codebase-index/circuit-breaker.d.ts.map +0 -1
- package/dist/codebase-index/codebase-index-tool.d.ts.map +0 -1
- package/dist/codebase-index/codebase-search-tool.d.ts.map +0 -1
- package/dist/codebase-index/codebase-stats-tool.d.ts.map +0 -1
- package/dist/codebase-index/dead-code-scan.d.ts.map +0 -1
- package/dist/codebase-index/generic-parser.d.ts.map +0 -1
- package/dist/codebase-index/gitignore.d.ts.map +0 -1
- package/dist/codebase-index/go-parser.d.ts.map +0 -1
- package/dist/codebase-index/index-service.d.ts.map +0 -1
- package/dist/codebase-index/index.d.ts.map +0 -1
- package/dist/codebase-index/index.js.map +0 -7
- package/dist/codebase-index/indexer.d.ts.map +0 -1
- package/dist/codebase-index/json-parser.d.ts.map +0 -1
- package/dist/codebase-index/languages.d.ts.map +0 -1
- package/dist/codebase-index/lsp-kind.d.ts.map +0 -1
- package/dist/codebase-index/parser-dispatch.d.ts.map +0 -1
- package/dist/codebase-index/project-server-cache.d.ts.map +0 -1
- package/dist/codebase-index/project-server-client.d.ts.map +0 -1
- package/dist/codebase-index/project-server-endpoint.d.ts.map +0 -1
- package/dist/codebase-index/project-server-protocol.d.ts.map +0 -1
- package/dist/codebase-index/project-server.d.ts.map +0 -1
- package/dist/codebase-index/project-server.js.map +0 -7
- package/dist/codebase-index/py-parser.d.ts.map +0 -1
- package/dist/codebase-index/refs-extractor.d.ts.map +0 -1
- package/dist/codebase-index/rs-parser.d.ts.map +0 -1
- package/dist/codebase-index/schema.d.ts.map +0 -1
- package/dist/codebase-index/spawn-gate.d.ts.map +0 -1
- package/dist/codebase-index/sqlite-runtime.d.ts.map +0 -1
- package/dist/codebase-index/ts-parser.d.ts.map +0 -1
- package/dist/codebase-index/worker-protocol.d.ts.map +0 -1
- package/dist/codebase-index/worker.d.ts.map +0 -1
- package/dist/codebase-index/worker.js.map +0 -7
- package/dist/codebase-index/writer-admin.d.ts.map +0 -1
- package/dist/codebase-index/writer-bulk-insert.d.ts.map +0 -1
- package/dist/codebase-index/writer-graph-helpers.d.ts.map +0 -1
- package/dist/codebase-index/writer-graph-reader.d.ts.map +0 -1
- package/dist/codebase-index/writer-helpers.d.ts.map +0 -1
- package/dist/codebase-index/writer-pragmas.d.ts.map +0 -1
- package/dist/codebase-index/writer-ref-mapper.d.ts.map +0 -1
- package/dist/codebase-index/writer-schema.d.ts.map +0 -1
- package/dist/codebase-index/writer-search-helpers.d.ts.map +0 -1
- package/dist/codebase-index/writer-store-pool.d.ts.map +0 -1
- package/dist/codebase-index/writer.d.ts.map +0 -1
- package/dist/codebase-index/yaml-parser.d.ts.map +0 -1
- package/dist/design.d.ts.map +0 -1
- package/dist/design.js.map +0 -7
- package/dist/diff.d.ts.map +0 -1
- package/dist/diff.js.map +0 -7
- package/dist/document.d.ts.map +0 -1
- package/dist/document.js.map +0 -7
- package/dist/e2e.d.ts.map +0 -1
- package/dist/e2e.js.map +0 -7
- package/dist/edit.d.ts.map +0 -1
- package/dist/edit.js.map +0 -7
- package/dist/exec-kill-guard.d.ts.map +0 -1
- package/dist/exec.d.ts.map +0 -1
- package/dist/exec.js.map +0 -7
- package/dist/fetch.d.ts.map +0 -1
- package/dist/fetch.js.map +0 -7
- package/dist/format.d.ts.map +0 -1
- package/dist/format.js.map +0 -7
- package/dist/git.d.ts.map +0 -1
- package/dist/git.js.map +0 -7
- package/dist/glob.d.ts.map +0 -1
- package/dist/glob.js.map +0 -7
- package/dist/grep.d.ts.map +0 -1
- package/dist/grep.js.map +0 -7
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -7
- package/dist/install.d.ts.map +0 -1
- package/dist/install.js.map +0 -7
- package/dist/json.d.ts.map +0 -1
- package/dist/json.js.map +0 -7
- package/dist/kanban-board-inputs.d.ts.map +0 -1
- package/dist/kanban-decomposition-actions.d.ts.map +0 -1
- package/dist/kanban-detail-actions.d.ts.map +0 -1
- package/dist/kanban-evidence-bridge.d.ts.map +0 -1
- package/dist/kanban-presence.d.ts.map +0 -1
- package/dist/kanban-split-task-handler.d.ts.map +0 -1
- package/dist/kanban-task-inputs.d.ts.map +0 -1
- package/dist/kanban-tool-results.d.ts.map +0 -1
- package/dist/kanban-tool-schema.d.ts.map +0 -1
- package/dist/kanban-tool-types.d.ts.map +0 -1
- package/dist/kanban.d.ts.map +0 -1
- package/dist/kanban.js.map +0 -7
- package/dist/languages/detect.d.ts.map +0 -1
- package/dist/languages/diagnostics.d.ts.map +0 -1
- package/dist/languages/execute-tool.d.ts.map +0 -1
- package/dist/languages/execute.d.ts.map +0 -1
- package/dist/languages/index.d.ts.map +0 -1
- package/dist/languages/index.js.map +0 -7
- package/dist/languages/legacy-bridge.d.ts.map +0 -1
- package/dist/languages/package-tool.d.ts.map +0 -1
- package/dist/languages/plan.d.ts.map +0 -1
- package/dist/languages/profile-helpers.d.ts.map +0 -1
- package/dist/languages/profiles/additional.d.ts.map +0 -1
- package/dist/languages/profiles/primary.d.ts.map +0 -1
- package/dist/languages/registry.d.ts.map +0 -1
- package/dist/languages/tool.d.ts.map +0 -1
- package/dist/languages/types.d.ts.map +0 -1
- package/dist/lint.d.ts.map +0 -1
- package/dist/lint.js.map +0 -7
- package/dist/logs.d.ts.map +0 -1
- package/dist/logs.js.map +0 -7
- package/dist/memory.d.ts.map +0 -1
- package/dist/memory.js.map +0 -7
- package/dist/mode.d.ts.map +0 -1
- package/dist/mode.js.map +0 -7
- package/dist/next-steps.d.ts.map +0 -1
- package/dist/next-steps.js.map +0 -7
- package/dist/outdated.d.ts.map +0 -1
- package/dist/outdated.js.map +0 -7
- package/dist/pack.d.ts.map +0 -1
- package/dist/pack.js.map +0 -7
- package/dist/patch.d.ts.map +0 -1
- package/dist/patch.js.map +0 -7
- package/dist/plan.d.ts.map +0 -1
- package/dist/plan.js.map +0 -7
- package/dist/process-guardian.d.ts.map +0 -1
- package/dist/process-registry-persistent.d.ts.map +0 -1
- package/dist/process-registry.d.ts.map +0 -1
- package/dist/process-registry.js.map +0 -7
- package/dist/ps-slash.d.ts.map +0 -1
- package/dist/ps-slash.js.map +0 -7
- package/dist/read.d.ts.map +0 -1
- package/dist/read.js.map +0 -7
- package/dist/replace.d.ts.map +0 -1
- package/dist/replace.js.map +0 -7
- package/dist/scaffold.d.ts.map +0 -1
- package/dist/scaffold.js.map +0 -7
- package/dist/search.d.ts.map +0 -1
- package/dist/search.js.map +0 -7
- package/dist/session-kanban.d.ts.map +0 -1
- package/dist/session-kanban.js.map +0 -7
- package/dist/set-working-dir.d.ts.map +0 -1
- package/dist/skill.d.ts.map +0 -1
- package/dist/skill.js.map +0 -7
- package/dist/spawn-background.d.ts.map +0 -1
- package/dist/task.d.ts.map +0 -1
- package/dist/task.js.map +0 -7
- package/dist/test.d.ts.map +0 -1
- package/dist/test.js.map +0 -7
- package/dist/todo.d.ts.map +0 -1
- package/dist/todo.js.map +0 -7
- package/dist/tool-diff.d.ts.map +0 -1
- package/dist/tool-diff.js.map +0 -7
- package/dist/tool-help.d.ts.map +0 -1
- package/dist/tool-help.js.map +0 -7
- package/dist/tool-icon-map.d.ts.map +0 -1
- package/dist/tool-icons.d.ts.map +0 -1
- package/dist/tool-icons.js.map +0 -7
- package/dist/tool-search.d.ts.map +0 -1
- package/dist/tool-search.js.map +0 -7
- package/dist/tool-summary.d.ts.map +0 -1
- package/dist/tool-summary.js.map +0 -7
- package/dist/tool-tier.d.ts.map +0 -1
- package/dist/tool-tier.js.map +0 -7
- package/dist/tool-use.d.ts.map +0 -1
- package/dist/tool-use.js.map +0 -7
- package/dist/tree.d.ts.map +0 -1
- package/dist/tree.js.map +0 -7
- package/dist/typecheck.d.ts.map +0 -1
- package/dist/typecheck.js.map +0 -7
- package/dist/win32.d.ts.map +0 -1
- package/dist/win32.js.map +0 -7
- package/dist/write.d.ts.map +0 -1
- 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
|
-
|
|
365
|
+
const textBytes = Buffer.byteLength(text, "utf8");
|
|
366
|
+
totalBytes += textBytes;
|
|
85
367
|
if (!stream && !failed) {
|
|
86
|
-
if (headBytes +
|
|
368
|
+
if (headBytes + textBytes <= threshold) {
|
|
87
369
|
head += text;
|
|
88
|
-
headBytes +=
|
|
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 +=
|
|
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
|
|
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))
|
|
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
|
|
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";
|
|
@@ -574,6 +915,7 @@ var ProcessRegistryImpl = class {
|
|
|
574
915
|
}
|
|
575
916
|
/** Get all tracked processes. */
|
|
576
917
|
list() {
|
|
918
|
+
this._pruneAllStale();
|
|
577
919
|
return Array.from(this.processes.values());
|
|
578
920
|
}
|
|
579
921
|
/** Get processes filtered by name (e.g. 'bash', 'exec'). */
|
|
@@ -604,6 +946,7 @@ var ProcessRegistryImpl = class {
|
|
|
604
946
|
* Combined stats for observability — used by /ps and the TUI status bar.
|
|
605
947
|
*/
|
|
606
948
|
stats() {
|
|
949
|
+
this._pruneAllStale();
|
|
607
950
|
return {
|
|
608
951
|
activeCount: this.activeCount,
|
|
609
952
|
backgroundCount: this.activeBackgroundCount,
|
|
@@ -662,532 +1005,210 @@ var ProcessRegistryImpl = class {
|
|
|
662
1005
|
if (this.autoKillResetMs <= 0) {
|
|
663
1006
|
this._cancelAutoKillReset();
|
|
664
1007
|
return;
|
|
665
|
-
}
|
|
666
|
-
if (this.breaker.isEnabled && this.breaker.snapshot().state === "open") {
|
|
667
|
-
this._armAutoKillReset();
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
/**
|
|
671
|
-
* Live countdown to the next auto kill/reset, or null when nothing is armed.
|
|
672
|
-
* The TUI polls this on a 1s tick while armed so the statusline decrements.
|
|
673
|
-
*/
|
|
674
|
-
getBreakerCountdown() {
|
|
675
|
-
if (this.autoKillArmedAt === null || this.autoKillResetMs <= 0) return null;
|
|
676
|
-
const elapsed = Date.now() - this.autoKillArmedAt;
|
|
677
|
-
return { remainingMs: Math.max(0, this.autoKillResetMs - elapsed), totalMs: this.autoKillResetMs };
|
|
678
|
-
}
|
|
679
|
-
/**
|
|
680
|
-
* Subscribe to countdown arm/cancel events. Returns an unsubscribe function.
|
|
681
|
-
* Use {@link getBreakerCountdown} for the live ticking value between events.
|
|
682
|
-
*/
|
|
683
|
-
onBreakerCountdownChange(listener) {
|
|
684
|
-
this.breakerCountdownListeners.push(listener);
|
|
685
|
-
return () => {
|
|
686
|
-
this.breakerCountdownListeners = this.breakerCountdownListeners.filter((l) => l !== listener);
|
|
687
|
-
};
|
|
688
|
-
}
|
|
689
|
-
_emitBreakerCountdown() {
|
|
690
|
-
const snap = this.getBreakerCountdown();
|
|
691
|
-
for (const l of this.breakerCountdownListeners) {
|
|
692
|
-
try {
|
|
693
|
-
l(snap);
|
|
694
|
-
} catch {
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
/**
|
|
699
|
-
* Arm the auto kill/reset countdown. Idempotent: re-arming resets the window
|
|
700
|
-
* (a fresh trip after a failed half-open probe restarts the clock). No-op
|
|
701
|
-
* when protection is off or no timeout is configured.
|
|
702
|
-
*/
|
|
703
|
-
_armAutoKillReset() {
|
|
704
|
-
if (this.autoKillResetMs <= 0 || !this.breaker.isEnabled) return;
|
|
705
|
-
this._clearAutoKillTimer();
|
|
706
|
-
this.autoKillArmedAt = Date.now();
|
|
707
|
-
this.autoKillTimer = setTimeout(() => {
|
|
708
|
-
this.autoKillTimer = null;
|
|
709
|
-
this.autoKillArmedAt = null;
|
|
710
|
-
this.killAll({ force: false, preserveBackground: true });
|
|
711
|
-
this.breaker.forceReset();
|
|
712
|
-
this._emitBreakerCountdown();
|
|
713
|
-
}, this.autoKillResetMs);
|
|
714
|
-
this.autoKillTimer.unref?.();
|
|
715
|
-
this._emitBreakerCountdown();
|
|
716
|
-
}
|
|
717
|
-
_cancelAutoKillReset() {
|
|
718
|
-
const wasArmed = this.autoKillArmedAt !== null;
|
|
719
|
-
this._clearAutoKillTimer();
|
|
720
|
-
if (wasArmed) {
|
|
721
|
-
this.autoKillArmedAt = null;
|
|
722
|
-
this._emitBreakerCountdown();
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
_clearAutoKillTimer() {
|
|
726
|
-
if (this.autoKillTimer !== null) {
|
|
727
|
-
clearTimeout(this.autoKillTimer);
|
|
728
|
-
this.autoKillTimer = null;
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
/** Kill a single process by PID.
|
|
732
|
-
*
|
|
733
|
-
* On POSIX: sends SIGTERM to the *process group* (-pid) so that
|
|
734
|
-
* runaway grandchild processes (`sleep 9999 & disown`) are also killed.
|
|
735
|
-
* After `graceMs` a SIGKILL is sent if the process hasn't exited.
|
|
736
|
-
*
|
|
737
|
-
* On Windows: `child.kill()` maps to TerminateProcess — process groups
|
|
738
|
-
* are not meaningfully supported. A second `force=true` call sends
|
|
739
|
-
* SIGKILL (which maps to TerminateProcess again — the distinction is
|
|
740
|
-
* in the exit code, not the signal).
|
|
741
|
-
*
|
|
742
|
-
* Returns true if the process was found and kill was attempted.
|
|
743
|
-
*/
|
|
744
|
-
kill(pid, opts = {}) {
|
|
745
|
-
this._pruneStale(pid);
|
|
746
|
-
const p = this.processes.get(pid);
|
|
747
|
-
if (!p) return false;
|
|
748
|
-
if (p.killed) return true;
|
|
749
|
-
if (p.protected) return false;
|
|
750
|
-
if (opts.preserveBackground && p.background) return false;
|
|
751
|
-
const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
|
|
752
|
-
const isWin3 = os.platform() === "win32";
|
|
753
|
-
if (isWin3) {
|
|
754
|
-
const liveRealChild = p.child.exitCode === null && typeof p.child.pid === "number";
|
|
755
|
-
const directFallback = () => {
|
|
756
|
-
if (p.child.exitCode === null) {
|
|
757
|
-
try {
|
|
758
|
-
p.child.kill("SIGKILL");
|
|
759
|
-
} catch {
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
};
|
|
763
|
-
if (liveRealChild && killWin32Tree(pid, {
|
|
764
|
-
timeoutMs: Math.max(graceMs, WIN32_TASKKILL_TIMEOUT_MS),
|
|
765
|
-
onSettled: directFallback
|
|
766
|
-
})) {
|
|
767
|
-
} else {
|
|
768
|
-
try {
|
|
769
|
-
p.child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
770
|
-
} catch {
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
p.killed = true;
|
|
774
|
-
return true;
|
|
775
|
-
}
|
|
776
|
-
try {
|
|
777
|
-
if (force) {
|
|
778
|
-
this._killPosix(p, "SIGKILL");
|
|
779
|
-
} else {
|
|
780
|
-
this._killPosix(p, "SIGTERM");
|
|
781
|
-
const timer = setTimeout(() => {
|
|
782
|
-
if (this.processes.has(pid) && !p.child.killed) {
|
|
783
|
-
this._killPosix(p, "SIGKILL");
|
|
784
|
-
}
|
|
785
|
-
}, graceMs);
|
|
786
|
-
timer.unref?.();
|
|
787
|
-
}
|
|
788
|
-
} catch {
|
|
789
|
-
}
|
|
790
|
-
p.killed = true;
|
|
791
|
-
return true;
|
|
792
|
-
}
|
|
793
|
-
/**
|
|
794
|
-
* Kill all tracked processes.
|
|
795
|
-
* Returns the PIDs that were kill()ed.
|
|
796
|
-
*/
|
|
797
|
-
killAll(opts = {}) {
|
|
798
|
-
const pids = Array.from(this.processes.keys());
|
|
799
|
-
const killed = [];
|
|
800
|
-
for (const pid of pids) {
|
|
801
|
-
const p = this.processes.get(pid);
|
|
802
|
-
if (p && !p.protected && this.kill(pid, opts)) killed.push(pid);
|
|
803
|
-
}
|
|
804
|
-
return killed;
|
|
805
|
-
}
|
|
806
|
-
/**
|
|
807
|
-
* Kill all processes for a specific session.
|
|
808
|
-
* Returns the PIDs that were kill()ed.
|
|
809
|
-
*/
|
|
810
|
-
killSession(sessionId, opts = {}) {
|
|
811
|
-
const pids = this.bySession(sessionId).map((p) => p.pid);
|
|
812
|
-
const killed = [];
|
|
813
|
-
for (const pid of pids) {
|
|
814
|
-
if (this.kill(pid, opts)) killed.push(pid);
|
|
815
|
-
}
|
|
816
|
-
return killed;
|
|
817
|
-
}
|
|
818
|
-
/**
|
|
819
|
-
* Check whether a tracked process entry is stale — the child has exited
|
|
820
|
-
* (exitCode !== null) AND it's been in the registry long enough that the
|
|
821
|
-
* OS may have reused the PID for a new, unrelated process.
|
|
822
|
-
*
|
|
823
|
-
* P3 #24 (before-release.md): on POSIX, PIDs are reused after process
|
|
824
|
-
* exit. If a tracked process exits but its 'close' event hasn't fired yet
|
|
825
|
-
* (or was missed), the registry still holds the entry. A new process
|
|
826
|
-
* gets the same PID, and PID-based lookups (get, kill, shouldBlockKill)
|
|
827
|
-
* may incorrectly protect or target the wrong process.
|
|
828
|
-
*
|
|
829
|
-
* The 60s threshold is conservative — the OS typically waits much longer
|
|
830
|
-
* before reusing a PID, but we want to clean up before that becomes a risk.
|
|
831
|
-
*/
|
|
832
|
-
_isStaleEntry(entry) {
|
|
833
|
-
return entry.child.exitCode !== null && Date.now() - entry.startedAt > 6e4;
|
|
1008
|
+
}
|
|
1009
|
+
if (this.breaker.isEnabled && this.breaker.snapshot().state === "open") {
|
|
1010
|
+
this._armAutoKillReset();
|
|
1011
|
+
}
|
|
834
1012
|
}
|
|
835
1013
|
/**
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
* process that has been replaced by a new one with the same PID.
|
|
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.
|
|
839
1016
|
*/
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
};
|
|
847
|
-
var _registry;
|
|
848
|
-
function getProcessRegistry() {
|
|
849
|
-
if (!_registry) {
|
|
850
|
-
_registry = new ProcessRegistryImpl();
|
|
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 };
|
|
851
1021
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
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
|
+
};
|
|
862
1031
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
const base = path3.join(dir, cmd);
|
|
867
|
-
for (const ext of pathext) {
|
|
868
|
-
const full = `${base}${ext}`;
|
|
1032
|
+
_emitBreakerCountdown() {
|
|
1033
|
+
const snap = this.getBreakerCountdown();
|
|
1034
|
+
for (const l of this.breakerCountdownListeners) {
|
|
869
1035
|
try {
|
|
870
|
-
|
|
871
|
-
return full;
|
|
1036
|
+
l(snap);
|
|
872
1037
|
} catch {
|
|
873
1038
|
}
|
|
874
1039
|
}
|
|
875
1040
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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();
|
|
885
1066
|
}
|
|
886
1067
|
}
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
command: process.env["COMSPEC"] ?? "cmd.exe",
|
|
893
|
-
args: ["/d", "/c", line],
|
|
894
|
-
windowsVerbatimArguments: true
|
|
895
|
-
};
|
|
896
|
-
}
|
|
897
|
-
function quoteWin32CmdArg(arg) {
|
|
898
|
-
return `"${arg}"`;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// src/_danger-detect.ts
|
|
902
|
-
var argHas = (args, value) => args.includes(value);
|
|
903
|
-
var argMatches = (args, re) => args.some((a) => re.test(a));
|
|
904
|
-
var hasShortFlags = (args, letters) => {
|
|
905
|
-
const seen = /* @__PURE__ */ new Set();
|
|
906
|
-
for (const a of args) {
|
|
907
|
-
if (!a.startsWith("-") || a.startsWith("--")) continue;
|
|
908
|
-
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
|
+
}
|
|
909
1073
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
if (
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
)
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
{
|
|
952
|
-
id: "git-exec",
|
|
953
|
-
level: "destructive",
|
|
954
|
-
test: (cmd, args) => cmd === "git" && args.some(
|
|
955
|
-
(a) => a.startsWith("--exec=") || a.startsWith("--upload-pack=") || a.startsWith("--receive-pack=") || a === "--exec" || a === "--upload-pack" || a === "--receive-pack"
|
|
956
|
-
),
|
|
957
|
-
reason: "git with --exec/--upload-pack/--receive-pack (runs arbitrary code)"
|
|
958
|
-
},
|
|
959
|
-
// ----- Windows: format / diskpart / bcdedit -----
|
|
960
|
-
{
|
|
961
|
-
id: "win32-format",
|
|
962
|
-
level: "destructive",
|
|
963
|
-
test: (cmd) => cmd === "format" || cmd === "format.exe",
|
|
964
|
-
reason: "format (Windows disk format)"
|
|
965
|
-
},
|
|
966
|
-
{
|
|
967
|
-
id: "win32-diskpart",
|
|
968
|
-
level: "destructive",
|
|
969
|
-
test: (cmd) => cmd === "diskpart" || cmd === "diskpart.exe",
|
|
970
|
-
reason: "diskpart (Windows partition editor)"
|
|
971
|
-
},
|
|
972
|
-
{
|
|
973
|
-
id: "win32-bcdedit",
|
|
974
|
-
level: "destructive",
|
|
975
|
-
test: (cmd) => cmd === "bcdedit" || cmd === "bcdedit.exe",
|
|
976
|
-
reason: "bcdedit (Windows boot config editor)"
|
|
977
|
-
},
|
|
978
|
-
// ----- mkfs family -----
|
|
979
|
-
{
|
|
980
|
-
id: "mkfs",
|
|
981
|
-
level: "destructive",
|
|
982
|
-
test: (cmd) => /^mkfs(\.[a-z0-9]+)?$/.test(cmd) || cmd === "mkswap",
|
|
983
|
-
reason: "mkfs (filesystem creation \u2014 destroys existing data)"
|
|
984
|
-
},
|
|
985
|
-
// ----- dd writing to a block device -----
|
|
986
|
-
{
|
|
987
|
-
id: "dd-to-block-device",
|
|
988
|
-
level: "destructive",
|
|
989
|
-
test: (cmd, args) => {
|
|
990
|
-
if (cmd !== "dd") return false;
|
|
991
|
-
return args.some((a) => /of=\/dev\/(sd|hd|nvme|vd|mmcblk|xvd|loop|disk)/.test(a));
|
|
992
|
-
},
|
|
993
|
-
reason: "dd writing to a block device"
|
|
994
|
-
},
|
|
995
|
-
// ----- Secure-erase tools -----
|
|
996
|
-
{
|
|
997
|
-
id: "shred",
|
|
998
|
-
level: "destructive",
|
|
999
|
-
test: (cmd) => cmd === "shred" || cmd === "shred.exe",
|
|
1000
|
-
reason: "shred (secure file delete)"
|
|
1001
|
-
},
|
|
1002
|
-
{
|
|
1003
|
-
id: "wipefs",
|
|
1004
|
-
level: "destructive",
|
|
1005
|
-
test: (cmd) => cmd === "wipefs" || cmd === "wipefs.exe",
|
|
1006
|
-
reason: "wipefs (signature wipe \u2014 destroys filesystem headers)"
|
|
1007
|
-
},
|
|
1008
|
-
{
|
|
1009
|
-
id: "sdelete",
|
|
1010
|
-
level: "destructive",
|
|
1011
|
-
test: (cmd) => cmd === "sdelete" || cmd === "sdelete.exe",
|
|
1012
|
-
reason: "sdelete (Sysinternals secure delete)"
|
|
1013
|
-
},
|
|
1014
|
-
// ----- VCS history rewrite (destructive) -----
|
|
1015
|
-
// `git push --force` / `-f` rewrites remote history. `--force-with-lease`
|
|
1016
|
-
// is the safer variant (checks remote hasn't moved) but still rewrites.
|
|
1017
|
-
{
|
|
1018
|
-
id: "git-push-force",
|
|
1019
|
-
level: "destructive",
|
|
1020
|
-
test: (cmd, args) => {
|
|
1021
|
-
if (cmd !== "git") return false;
|
|
1022
|
-
const pushIdx = args.indexOf("push");
|
|
1023
|
-
if (pushIdx < 0) return false;
|
|
1024
|
-
for (let i = pushIdx + 1; i < args.length; i++) {
|
|
1025
|
-
const a = args[i];
|
|
1026
|
-
if (a === "--force" || a === "-f" || a === "--force-with-lease") return true;
|
|
1027
|
-
if (!a.startsWith("-") && !a.includes("=")) continue;
|
|
1028
|
-
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
|
+
}
|
|
1029
1115
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
level: "destructive",
|
|
1045
|
-
test: (cmd, args) => {
|
|
1046
|
-
if (cmd !== "git") return false;
|
|
1047
|
-
const cleanIdx = args.indexOf("clean");
|
|
1048
|
-
if (cleanIdx < 0) return false;
|
|
1049
|
-
return args.slice(cleanIdx + 1).some(
|
|
1050
|
-
(a) => a === "-f" || a === "--force" || a.startsWith("-f") || a.startsWith("--force=")
|
|
1051
|
-
);
|
|
1052
|
-
},
|
|
1053
|
-
reason: "git clean -f (deletes untracked files)"
|
|
1054
|
-
},
|
|
1055
|
-
// ----- package publish (destructive — public, irreversible) -----
|
|
1056
|
-
{
|
|
1057
|
-
id: "npm-publish",
|
|
1058
|
-
level: "destructive",
|
|
1059
|
-
test: (cmd, args) => {
|
|
1060
|
-
if (!["npm", "pnpm", "yarn", "bun", "cargo"].includes(cmd)) return false;
|
|
1061
|
-
return args.includes("publish") || cmd === "cargo" && args.includes("yank");
|
|
1062
|
-
},
|
|
1063
|
-
reason: "publishing to a public package registry (hard to reverse)"
|
|
1064
|
-
},
|
|
1065
|
-
// ----- k8s cluster-wide destructive ops (destructive) -----
|
|
1066
|
-
{
|
|
1067
|
-
id: "kubectl-delete-namespace",
|
|
1068
|
-
level: "destructive",
|
|
1069
|
-
test: (cmd, args) => {
|
|
1070
|
-
if (cmd !== "kubectl") return false;
|
|
1071
|
-
const delIdx = args.indexOf("delete");
|
|
1072
|
-
if (delIdx < 0) return false;
|
|
1073
|
-
const after = args.slice(delIdx + 1);
|
|
1074
|
-
return after[0] === "namespace" || after[0] === "ns";
|
|
1075
|
-
},
|
|
1076
|
-
reason: "kubectl delete namespace (deletes all resources in the namespace)"
|
|
1077
|
-
},
|
|
1078
|
-
{
|
|
1079
|
-
id: "kubectl-drain",
|
|
1080
|
-
level: "destructive",
|
|
1081
|
-
test: (cmd, args) => cmd === "kubectl" && args.includes("drain"),
|
|
1082
|
-
reason: "kubectl drain (evicts pods, marks node unschedulable)"
|
|
1083
|
-
},
|
|
1084
|
-
// ----- inline code evaluation (caution — high false-positive) -----
|
|
1085
|
-
// Common in scripts: `python -c "..."`, `node -e "..."`, `bash -c "..."`.
|
|
1086
|
-
// We tag 'caution' rather than 'destructive' because these are used in
|
|
1087
|
-
// many legitimate one-liners (e.g. `python -c "print(1)"`).
|
|
1088
|
-
{
|
|
1089
|
-
id: "inline-eval",
|
|
1090
|
-
level: "caution",
|
|
1091
|
-
test: (cmd, args) => {
|
|
1092
|
-
if (![
|
|
1093
|
-
"python",
|
|
1094
|
-
"python3",
|
|
1095
|
-
"python2",
|
|
1096
|
-
"node",
|
|
1097
|
-
"bash",
|
|
1098
|
-
"sh",
|
|
1099
|
-
"zsh",
|
|
1100
|
-
"ruby",
|
|
1101
|
-
"perl",
|
|
1102
|
-
"lua"
|
|
1103
|
-
].includes(cmd)) {
|
|
1104
|
-
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?.();
|
|
1105
1130
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
reason: "inline script evaluation (-c / -e / --eval)"
|
|
1111
|
-
},
|
|
1112
|
-
// ----- pipe-to-shell (destructive — download-and-run pattern) -----
|
|
1113
|
-
// The classic `curl https://... | sh` download-and-run vector. Detected by
|
|
1114
|
-
// looking for a known fetcher piped into a shell or expression evaluator.
|
|
1115
|
-
// A shell command passed through `bash -c` / `pwsh -Command` arrives as one
|
|
1116
|
-
// argv string, so scan the reconstructed argv text rather than individual
|
|
1117
|
-
// tokens only.
|
|
1118
|
-
{
|
|
1119
|
-
id: "pipe-to-shell",
|
|
1120
|
-
level: "destructive",
|
|
1121
|
-
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(
|
|
1122
|
-
[cmd, ...args].join(" ")
|
|
1123
|
-
),
|
|
1124
|
-
reason: "network fetch piped to a shell (download-and-run pattern)"
|
|
1125
|
-
},
|
|
1126
|
-
// ----- privilege escalation (caution) -----
|
|
1127
|
-
{
|
|
1128
|
-
id: "sudo",
|
|
1129
|
-
level: "caution",
|
|
1130
|
-
test: (cmd) => cmd === "sudo" || cmd === "doas",
|
|
1131
|
-
reason: "privilege escalation (sudo / doas)"
|
|
1132
|
-
},
|
|
1133
|
-
{
|
|
1134
|
-
id: "runas",
|
|
1135
|
-
level: "caution",
|
|
1136
|
-
test: (cmd) => cmd === "runas" || cmd === "runas.exe",
|
|
1137
|
-
reason: "Windows runas (run as different user)"
|
|
1138
|
-
},
|
|
1139
|
-
// ----- world-writable permissions (caution) -----
|
|
1140
|
-
// `chmod 777` is rarely correct. `chmod -R 777` is almost always wrong.
|
|
1141
|
-
// We only flag octal modes; symbolic modes like `chmod o+w` are
|
|
1142
|
-
// left to the operator's discretion.
|
|
1143
|
-
{
|
|
1144
|
-
id: "chmod-world-writable",
|
|
1145
|
-
level: "caution",
|
|
1146
|
-
test: (cmd, args) => {
|
|
1147
|
-
if (cmd !== "chmod") return false;
|
|
1148
|
-
return args.some((a) => /^[0-7]{3,4}$/.test(a) && /7/.test(a));
|
|
1149
|
-
},
|
|
1150
|
-
reason: "chmod with world-writable octal mode (e.g. 777)"
|
|
1131
|
+
} catch {
|
|
1132
|
+
}
|
|
1133
|
+
p.killed = true;
|
|
1134
|
+
return true;
|
|
1151
1135
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
if (levelRank(rule.level) > levelRank(level)) {
|
|
1163
|
-
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);
|
|
1164
1146
|
}
|
|
1147
|
+
return killed;
|
|
1165
1148
|
}
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
+
}
|
|
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();
|
|
1179
1207
|
}
|
|
1208
|
+
return _registry;
|
|
1180
1209
|
}
|
|
1181
1210
|
|
|
1182
|
-
// src/exec-kill-guard.ts
|
|
1183
|
-
import * as os3 from "node:os";
|
|
1184
|
-
import * as path5 from "node:path";
|
|
1185
|
-
|
|
1186
1211
|
// src/process-registry-persistent.ts
|
|
1187
|
-
import * as fs2 from "node:fs/promises";
|
|
1188
|
-
import * as os2 from "node:os";
|
|
1189
|
-
import { wstackGlobalRoot as wstackGlobalRoot3 } from "@wrongstack/core/utils";
|
|
1190
|
-
import * as path4 from "node:path";
|
|
1191
1212
|
var REGISTRY_FILE = "process-registry.json";
|
|
1192
1213
|
function toErrorMessage(err) {
|
|
1193
1214
|
return err instanceof Error ? err.message : String(err);
|
|
@@ -2567,7 +2588,17 @@ var BLOCKED_ARG_PATTERNS = {
|
|
|
2567
2588
|
bun: [],
|
|
2568
2589
|
docker: [],
|
|
2569
2590
|
// find -exec/-ok/-execdir execute arbitrary commands
|
|
2570
|
-
find: [
|
|
2591
|
+
find: [
|
|
2592
|
+
/^-exec$/,
|
|
2593
|
+
/^-exec;$/,
|
|
2594
|
+
/^-ok$/,
|
|
2595
|
+
/^-ok;$/,
|
|
2596
|
+
/^-execdir$/,
|
|
2597
|
+
/^-execdir;$/,
|
|
2598
|
+
/^-exec=/,
|
|
2599
|
+
/^-ok=/,
|
|
2600
|
+
/^-execdir=/
|
|
2601
|
+
],
|
|
2571
2602
|
// rm -rf / is catastrophic — block absolute paths, home, dot-dirs,
|
|
2572
2603
|
// and glob patterns that could expand to dangerous targets.
|
|
2573
2604
|
// `rm -rf ./src/*` expands to project files; `rm -rf ../../` escapes upward;
|
|
@@ -2638,6 +2669,14 @@ var execTool = {
|
|
|
2638
2669
|
useInstead: ["bash"]
|
|
2639
2670
|
},
|
|
2640
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",
|
|
2641
2680
|
mutating: true,
|
|
2642
2681
|
riskTier: "standard",
|
|
2643
2682
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
@@ -2854,7 +2893,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId, danger) {
|
|
|
2854
2893
|
const pid = child.pid;
|
|
2855
2894
|
if (typeof pid === "number") {
|
|
2856
2895
|
const fullCommand = `${cmd} ${args.join(" ")}`;
|
|
2857
|
-
registry.register({
|
|
2896
|
+
registry.register({
|
|
2897
|
+
pid,
|
|
2898
|
+
name: "exec",
|
|
2899
|
+
command: redactCommand(fullCommand),
|
|
2900
|
+
startedAt: Date.now(),
|
|
2901
|
+
sessionId,
|
|
2902
|
+
child
|
|
2903
|
+
});
|
|
2858
2904
|
}
|
|
2859
2905
|
const timer = setTimeout(() => {
|
|
2860
2906
|
killed = true;
|