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