@sureshsankaran/opencode-destructive-check 1.0.1 → 1.0.3
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/package.json +1 -1
- package/src/index.test.ts +779 -0
- package/src/index.ts +18 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sureshsankaran/opencode-destructive-check",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "OpenCode plugin that checks for destructive commands before any tool/bash call and asks for permission",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for Destructive Command Check Plugin
|
|
3
|
+
*
|
|
4
|
+
* Tests the pattern matching for various destructive commands:
|
|
5
|
+
* - Git operations (push --force, reset --hard, etc.)
|
|
6
|
+
* - File deletion (rm -rf on dangerous paths)
|
|
7
|
+
* - Database operations (DROP TABLE, TRUNCATE, etc.)
|
|
8
|
+
* - System operations (chmod 777, dd, mkfs)
|
|
9
|
+
* - Container/cloud operations (kubectl delete, docker rm -f)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
|
13
|
+
import { mkdirSync, rmSync, writeFileSync } from "fs"
|
|
14
|
+
import { execSync } from "child_process"
|
|
15
|
+
import { join } from "path"
|
|
16
|
+
import { tmpdir } from "os"
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Extract the pattern matching logic from the plugin for testing
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
const DESTRUCTIVE_PATTERNS = {
|
|
23
|
+
rmDangerous: [
|
|
24
|
+
/\brm\s+(-[rRf]+\s+)*[\/~]\s*$/i,
|
|
25
|
+
/\brm\s+(-[rRf]+\s+)*\/\*/,
|
|
26
|
+
/\brm\s+(-[rRf]+\s+)*~\/\*/,
|
|
27
|
+
/\brm\s+(-[rRf]+\s+)*\$HOME\b/i,
|
|
28
|
+
/\brm\s+(-[rRf]+\s+)*\/home\b/i,
|
|
29
|
+
/\brm\s+(-[rRf]+\s+)*\/etc\b/i,
|
|
30
|
+
/\brm\s+(-[rRf]+\s+)*\/var\b/i,
|
|
31
|
+
/\brm\s+(-[rRf]+\s+)*\/usr\b/i,
|
|
32
|
+
/\brm\s+(-[rRf]+\s+)*\/bin\b/i,
|
|
33
|
+
/\brm\s+(-[rRf]+\s+)*\/sbin\b/i,
|
|
34
|
+
/\brm\s+(-[rRf]+\s+)*\/boot\b/i,
|
|
35
|
+
/\brm\s+(-[rRf]+\s+)*\/lib\b/i,
|
|
36
|
+
/\brm\s+(-[rRf]+\s+)*\/opt\b/i,
|
|
37
|
+
/\brm\s+(-[rRf]+\s+)*\/root\b/i,
|
|
38
|
+
/\brm\s+(-[rRf]+\s+)*\/sys\b/i,
|
|
39
|
+
/\brm\s+(-[rRf]+\s+)*\/proc\b/i,
|
|
40
|
+
/\brm\s+(-[rRf]+\s+)*\/dev\b/i,
|
|
41
|
+
/\brm\s+(-[rRf]+\s+)*\/mnt\b/i,
|
|
42
|
+
/\brm\s+(-[rRf]+\s+)*\/tmp\b/i,
|
|
43
|
+
/\brm\s+(-[rRf]+\s+)*\.git\b/i,
|
|
44
|
+
/\brm\s+(-[rRf]+\s+)*node_modules\b/i,
|
|
45
|
+
],
|
|
46
|
+
|
|
47
|
+
git: [
|
|
48
|
+
/\bgit\s+push\s+.*--force\b/i,
|
|
49
|
+
/\bgit\s+push\s+.*-f\b/i,
|
|
50
|
+
/\bgit\s+reset\s+--hard\b/i,
|
|
51
|
+
/\bgit\s+clean\s+.*-f/i,
|
|
52
|
+
/\bgit\s+checkout\s+--\s+\./i,
|
|
53
|
+
/\bgit\s+stash\s+drop/i,
|
|
54
|
+
/\bgit\s+branch\s+.*-D\b/i,
|
|
55
|
+
/\bgit\s+reflog\s+expire/i,
|
|
56
|
+
/\bgit\s+gc\s+--prune/i,
|
|
57
|
+
],
|
|
58
|
+
|
|
59
|
+
database: [
|
|
60
|
+
/\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX)\b/i,
|
|
61
|
+
/\bTRUNCATE\s+TABLE\b/i,
|
|
62
|
+
/\bDELETE\s+FROM\s+\S+\s*(;|\s*$)/i,
|
|
63
|
+
/\bALTER\s+TABLE\s+\S+\s+DROP\b/i,
|
|
64
|
+
],
|
|
65
|
+
|
|
66
|
+
system: [
|
|
67
|
+
/\bchmod\s+(-R\s+)?777\s+\//i,
|
|
68
|
+
/\bchown\s+(-R\s+)?\S+\s+\//i,
|
|
69
|
+
/\bdd\s+.*of=\/dev\//i,
|
|
70
|
+
/\bmkfs\b/i,
|
|
71
|
+
/\bformat\s+[a-z]:/i,
|
|
72
|
+
/\bfdisk\b/i,
|
|
73
|
+
/\bparted\b/i,
|
|
74
|
+
],
|
|
75
|
+
|
|
76
|
+
sudo: [
|
|
77
|
+
/\bsudo\s+rm\s+(-[rRf]+\s+)*\//i,
|
|
78
|
+
/\bsudo\s+chmod\b/i,
|
|
79
|
+
/\bsudo\s+chown\b/i,
|
|
80
|
+
/\bsudo\s+dd\b/i,
|
|
81
|
+
/\bsudo\s+mkfs\b/i,
|
|
82
|
+
],
|
|
83
|
+
|
|
84
|
+
container: [
|
|
85
|
+
/\bkubectl\s+delete\s+(namespace|ns|pod|deployment|service)\b/i,
|
|
86
|
+
/\bdocker\s+rm\s+.*-f/i,
|
|
87
|
+
/\bdocker\s+system\s+prune\s+.*-a/i,
|
|
88
|
+
/\bdocker\s+volume\s+rm\b/i,
|
|
89
|
+
/\baws\s+s3\s+rm\s+.*--recursive\b/i,
|
|
90
|
+
/\baws\s+ec2\s+terminate-instances\b/i,
|
|
91
|
+
/\bgcloud\s+.*delete\b/i,
|
|
92
|
+
],
|
|
93
|
+
|
|
94
|
+
packages: [
|
|
95
|
+
/\bnpm\s+cache\s+clean\s+--force\b/i,
|
|
96
|
+
/\byarn\s+cache\s+clean\b/i,
|
|
97
|
+
/\bpip\s+uninstall\s+.*-y\b/i,
|
|
98
|
+
/\bbrew\s+uninstall\s+--force\b/i,
|
|
99
|
+
],
|
|
100
|
+
|
|
101
|
+
network: [/\biptables\s+.*-F\b/i, /\biptables\s+.*--flush\b/i, /\bufw\s+reset\b/i],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const DANGEROUS_PATHS = [
|
|
105
|
+
"/",
|
|
106
|
+
"/*",
|
|
107
|
+
"/home",
|
|
108
|
+
"/etc",
|
|
109
|
+
"/var",
|
|
110
|
+
"/usr",
|
|
111
|
+
"/bin",
|
|
112
|
+
"/sbin",
|
|
113
|
+
"/boot",
|
|
114
|
+
"/lib",
|
|
115
|
+
"/opt",
|
|
116
|
+
"/root",
|
|
117
|
+
"/sys",
|
|
118
|
+
"/proc",
|
|
119
|
+
"/dev",
|
|
120
|
+
"~",
|
|
121
|
+
"~/",
|
|
122
|
+
"$HOME",
|
|
123
|
+
".git",
|
|
124
|
+
".env",
|
|
125
|
+
".ssh",
|
|
126
|
+
"package.json",
|
|
127
|
+
"package-lock.json",
|
|
128
|
+
"yarn.lock",
|
|
129
|
+
"bun.lockb",
|
|
130
|
+
"Cargo.toml",
|
|
131
|
+
"go.mod",
|
|
132
|
+
"pyproject.toml",
|
|
133
|
+
"requirements.txt",
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
type DestructiveMatch = {
|
|
137
|
+
category: string
|
|
138
|
+
pattern: string
|
|
139
|
+
severity: "critical" | "high" | "medium"
|
|
140
|
+
command: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function checkCommand(command: string): DestructiveMatch | null {
|
|
144
|
+
for (const [category, patterns] of Object.entries(DESTRUCTIVE_PATTERNS)) {
|
|
145
|
+
for (const pattern of patterns) {
|
|
146
|
+
if (pattern.test(command)) {
|
|
147
|
+
const severity = getSeverity(category)
|
|
148
|
+
return { category, pattern: pattern.toString(), severity, command }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getSeverity(category: string): "critical" | "high" | "medium" {
|
|
156
|
+
if (category === "rmDangerous" || category === "sudo" || category === "system") {
|
|
157
|
+
return "critical"
|
|
158
|
+
}
|
|
159
|
+
if (category === "git" || category === "database" || category === "container") {
|
|
160
|
+
return "high"
|
|
161
|
+
}
|
|
162
|
+
return "medium"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isDangerousPath(path: string): boolean {
|
|
166
|
+
const normalized = path.replace(/\\/g, "/").toLowerCase()
|
|
167
|
+
return DANGEROUS_PATHS.some((dangerous) => {
|
|
168
|
+
const d = dangerous.toLowerCase()
|
|
169
|
+
return normalized === d || normalized.startsWith(d + "/") || normalized.endsWith("/" + d)
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ============================================================================
|
|
174
|
+
// Test Suites
|
|
175
|
+
// ============================================================================
|
|
176
|
+
|
|
177
|
+
describe("Destructive Command Check - Git Operations", () => {
|
|
178
|
+
test("detects git push --force", () => {
|
|
179
|
+
const match = checkCommand("git push --force origin main")
|
|
180
|
+
expect(match).not.toBeNull()
|
|
181
|
+
expect(match?.category).toBe("git")
|
|
182
|
+
expect(match?.severity).toBe("high")
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test("detects git push -f", () => {
|
|
186
|
+
const match = checkCommand("git push -f origin main")
|
|
187
|
+
expect(match).not.toBeNull()
|
|
188
|
+
expect(match?.category).toBe("git")
|
|
189
|
+
expect(match?.severity).toBe("high")
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test("detects git reset --hard", () => {
|
|
193
|
+
const match = checkCommand("git reset --hard HEAD~1")
|
|
194
|
+
expect(match).not.toBeNull()
|
|
195
|
+
expect(match?.category).toBe("git")
|
|
196
|
+
expect(match?.severity).toBe("high")
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test("detects git reset --hard HEAD", () => {
|
|
200
|
+
const match = checkCommand("git reset --hard HEAD")
|
|
201
|
+
expect(match).not.toBeNull()
|
|
202
|
+
expect(match?.category).toBe("git")
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test("detects git clean -f", () => {
|
|
206
|
+
const match = checkCommand("git clean -fd")
|
|
207
|
+
expect(match).not.toBeNull()
|
|
208
|
+
expect(match?.category).toBe("git")
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test("detects git clean -f", () => {
|
|
212
|
+
// Note: Pattern matches -f or flags ending with -f
|
|
213
|
+
const match = checkCommand("git clean -f")
|
|
214
|
+
expect(match).not.toBeNull()
|
|
215
|
+
expect(match?.category).toBe("git")
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test("detects git checkout -- .", () => {
|
|
219
|
+
const match = checkCommand("git checkout -- .")
|
|
220
|
+
expect(match).not.toBeNull()
|
|
221
|
+
expect(match?.category).toBe("git")
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test("detects git stash drop", () => {
|
|
225
|
+
const match = checkCommand("git stash drop stash@{0}")
|
|
226
|
+
expect(match).not.toBeNull()
|
|
227
|
+
expect(match?.category).toBe("git")
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test("detects git branch -D", () => {
|
|
231
|
+
const match = checkCommand("git branch -D feature-branch")
|
|
232
|
+
expect(match).not.toBeNull()
|
|
233
|
+
expect(match?.category).toBe("git")
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test("detects git reflog expire", () => {
|
|
237
|
+
const match = checkCommand("git reflog expire --expire=now --all")
|
|
238
|
+
expect(match).not.toBeNull()
|
|
239
|
+
expect(match?.category).toBe("git")
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test("detects git gc --prune", () => {
|
|
243
|
+
const match = checkCommand("git gc --prune=now")
|
|
244
|
+
expect(match).not.toBeNull()
|
|
245
|
+
expect(match?.category).toBe("git")
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test("allows safe git commands", () => {
|
|
249
|
+
expect(checkCommand("git status")).toBeNull()
|
|
250
|
+
expect(checkCommand("git log --oneline -10")).toBeNull()
|
|
251
|
+
expect(checkCommand("git diff")).toBeNull()
|
|
252
|
+
expect(checkCommand("git add .")).toBeNull()
|
|
253
|
+
expect(checkCommand("git commit -m 'test'")).toBeNull()
|
|
254
|
+
expect(checkCommand("git push origin main")).toBeNull()
|
|
255
|
+
expect(checkCommand("git pull origin main")).toBeNull()
|
|
256
|
+
expect(checkCommand("git fetch --all")).toBeNull()
|
|
257
|
+
expect(checkCommand("git branch -a")).toBeNull()
|
|
258
|
+
expect(checkCommand("git checkout main")).toBeNull()
|
|
259
|
+
expect(checkCommand("git merge feature")).toBeNull()
|
|
260
|
+
expect(checkCommand("git rebase main")).toBeNull()
|
|
261
|
+
expect(checkCommand("git stash")).toBeNull()
|
|
262
|
+
expect(checkCommand("git stash pop")).toBeNull()
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe("Destructive Command Check - rm Commands", () => {
|
|
267
|
+
test("detects rm -rf /", () => {
|
|
268
|
+
const match = checkCommand("rm -rf /")
|
|
269
|
+
expect(match).not.toBeNull()
|
|
270
|
+
expect(match?.category).toBe("rmDangerous")
|
|
271
|
+
expect(match?.severity).toBe("critical")
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test("detects rm -rf /*", () => {
|
|
275
|
+
const match = checkCommand("rm -rf /*")
|
|
276
|
+
expect(match).not.toBeNull()
|
|
277
|
+
expect(match?.category).toBe("rmDangerous")
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test("detects rm -rf /home", () => {
|
|
281
|
+
const match = checkCommand("rm -rf /home")
|
|
282
|
+
expect(match).not.toBeNull()
|
|
283
|
+
expect(match?.category).toBe("rmDangerous")
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test("detects rm -rf /etc", () => {
|
|
287
|
+
const match = checkCommand("rm -rf /etc")
|
|
288
|
+
expect(match).not.toBeNull()
|
|
289
|
+
expect(match?.category).toBe("rmDangerous")
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test("detects rm -rf .git", () => {
|
|
293
|
+
const match = checkCommand("rm -rf .git")
|
|
294
|
+
expect(match).not.toBeNull()
|
|
295
|
+
expect(match?.category).toBe("rmDangerous")
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test("detects rm -rf node_modules", () => {
|
|
299
|
+
const match = checkCommand("rm -rf node_modules")
|
|
300
|
+
expect(match).not.toBeNull()
|
|
301
|
+
expect(match?.category).toBe("rmDangerous")
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test("detects rm with various flag combinations", () => {
|
|
305
|
+
expect(checkCommand("rm -r /home")).not.toBeNull()
|
|
306
|
+
expect(checkCommand("rm -f /etc")).not.toBeNull()
|
|
307
|
+
expect(checkCommand("rm -fr /var")).not.toBeNull()
|
|
308
|
+
expect(checkCommand("rm -Rf /usr")).not.toBeNull()
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test("allows safe rm commands", () => {
|
|
312
|
+
expect(checkCommand("rm file.txt")).toBeNull()
|
|
313
|
+
expect(checkCommand("rm -f temp.log")).toBeNull()
|
|
314
|
+
expect(checkCommand("rm -rf ./build")).toBeNull()
|
|
315
|
+
expect(checkCommand("rm -rf dist/")).toBeNull()
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe("Destructive Command Check - Database Operations", () => {
|
|
320
|
+
test("detects DROP TABLE", () => {
|
|
321
|
+
const match = checkCommand("DROP TABLE users")
|
|
322
|
+
expect(match).not.toBeNull()
|
|
323
|
+
expect(match?.category).toBe("database")
|
|
324
|
+
expect(match?.severity).toBe("high")
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test("detects DROP DATABASE", () => {
|
|
328
|
+
const match = checkCommand("DROP DATABASE production")
|
|
329
|
+
expect(match).not.toBeNull()
|
|
330
|
+
expect(match?.category).toBe("database")
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test("detects TRUNCATE TABLE", () => {
|
|
334
|
+
const match = checkCommand("TRUNCATE TABLE logs")
|
|
335
|
+
expect(match).not.toBeNull()
|
|
336
|
+
expect(match?.category).toBe("database")
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test("detects DELETE FROM without WHERE", () => {
|
|
340
|
+
const match = checkCommand("DELETE FROM users;")
|
|
341
|
+
expect(match).not.toBeNull()
|
|
342
|
+
expect(match?.category).toBe("database")
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test("detects ALTER TABLE DROP", () => {
|
|
346
|
+
const match = checkCommand("ALTER TABLE users DROP COLUMN email")
|
|
347
|
+
expect(match).not.toBeNull()
|
|
348
|
+
expect(match?.category).toBe("database")
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test("allows safe database commands", () => {
|
|
352
|
+
expect(checkCommand("SELECT * FROM users")).toBeNull()
|
|
353
|
+
expect(checkCommand("INSERT INTO users VALUES (1, 'test')")).toBeNull()
|
|
354
|
+
expect(checkCommand("UPDATE users SET name = 'test' WHERE id = 1")).toBeNull()
|
|
355
|
+
expect(checkCommand("DELETE FROM users WHERE id = 1")).toBeNull()
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
describe("Destructive Command Check - System Operations", () => {
|
|
360
|
+
test("detects chmod 777 /", () => {
|
|
361
|
+
const match = checkCommand("chmod 777 /")
|
|
362
|
+
expect(match).not.toBeNull()
|
|
363
|
+
expect(match?.category).toBe("system")
|
|
364
|
+
expect(match?.severity).toBe("critical")
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test("detects chmod -R 777 /", () => {
|
|
368
|
+
const match = checkCommand("chmod -R 777 /var")
|
|
369
|
+
expect(match).not.toBeNull()
|
|
370
|
+
expect(match?.category).toBe("system")
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test("detects dd to device", () => {
|
|
374
|
+
const match = checkCommand("dd if=/dev/zero of=/dev/sda")
|
|
375
|
+
expect(match).not.toBeNull()
|
|
376
|
+
expect(match?.category).toBe("system")
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test("detects mkfs", () => {
|
|
380
|
+
const match = checkCommand("mkfs.ext4 /dev/sda1")
|
|
381
|
+
expect(match).not.toBeNull()
|
|
382
|
+
expect(match?.category).toBe("system")
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test("detects fdisk", () => {
|
|
386
|
+
const match = checkCommand("fdisk /dev/sda")
|
|
387
|
+
expect(match).not.toBeNull()
|
|
388
|
+
expect(match?.category).toBe("system")
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
test("detects parted", () => {
|
|
392
|
+
const match = checkCommand("parted /dev/sda")
|
|
393
|
+
expect(match).not.toBeNull()
|
|
394
|
+
expect(match?.category).toBe("system")
|
|
395
|
+
})
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
describe("Destructive Command Check - Sudo Operations", () => {
|
|
399
|
+
test("detects sudo rm -rf /", () => {
|
|
400
|
+
const match = checkCommand("sudo rm -rf /")
|
|
401
|
+
expect(match).not.toBeNull()
|
|
402
|
+
// May match rmDangerous first due to pattern order, but it's still detected
|
|
403
|
+
expect(match?.severity).toBe("critical")
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
test("detects sudo chmod", () => {
|
|
407
|
+
const match = checkCommand("sudo chmod 755 /etc/passwd")
|
|
408
|
+
expect(match).not.toBeNull()
|
|
409
|
+
// Matches sudo pattern
|
|
410
|
+
expect(match?.category).toBe("sudo")
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test("detects sudo chown", () => {
|
|
414
|
+
// Note: Pattern matching order - system chown pattern matches before sudo
|
|
415
|
+
// The important thing is it IS detected as dangerous
|
|
416
|
+
const match = checkCommand("sudo chown root:root /etc/hosts")
|
|
417
|
+
expect(match).not.toBeNull()
|
|
418
|
+
expect(match?.severity).toBe("critical") // Both system and sudo are critical
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
test("detects sudo dd", () => {
|
|
422
|
+
// Note: system dd pattern matches first (dd with of=/dev/)
|
|
423
|
+
const match = checkCommand("sudo dd if=/dev/zero of=/dev/sda")
|
|
424
|
+
expect(match).not.toBeNull()
|
|
425
|
+
expect(match?.severity).toBe("critical")
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
test("detects sudo mkfs", () => {
|
|
429
|
+
// Note: system mkfs pattern matches first
|
|
430
|
+
const match = checkCommand("sudo mkfs.ext4 /dev/sda1")
|
|
431
|
+
expect(match).not.toBeNull()
|
|
432
|
+
expect(match?.severity).toBe("critical")
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
describe("Destructive Command Check - Container/Cloud Operations", () => {
|
|
437
|
+
test("detects kubectl delete namespace", () => {
|
|
438
|
+
const match = checkCommand("kubectl delete namespace production")
|
|
439
|
+
expect(match).not.toBeNull()
|
|
440
|
+
expect(match?.category).toBe("container")
|
|
441
|
+
expect(match?.severity).toBe("high")
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
test("detects kubectl delete pod", () => {
|
|
445
|
+
const match = checkCommand("kubectl delete pod my-pod")
|
|
446
|
+
expect(match).not.toBeNull()
|
|
447
|
+
expect(match?.category).toBe("container")
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test("detects docker rm -f", () => {
|
|
451
|
+
const match = checkCommand("docker rm -f container-id")
|
|
452
|
+
expect(match).not.toBeNull()
|
|
453
|
+
expect(match?.category).toBe("container")
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
test("detects docker system prune -a", () => {
|
|
457
|
+
const match = checkCommand("docker system prune -a")
|
|
458
|
+
expect(match).not.toBeNull()
|
|
459
|
+
expect(match?.category).toBe("container")
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
test("detects docker volume rm", () => {
|
|
463
|
+
const match = checkCommand("docker volume rm my-volume")
|
|
464
|
+
expect(match).not.toBeNull()
|
|
465
|
+
expect(match?.category).toBe("container")
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test("detects aws s3 rm --recursive", () => {
|
|
469
|
+
const match = checkCommand("aws s3 rm s3://bucket --recursive")
|
|
470
|
+
expect(match).not.toBeNull()
|
|
471
|
+
expect(match?.category).toBe("container")
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
test("detects aws ec2 terminate-instances", () => {
|
|
475
|
+
const match = checkCommand("aws ec2 terminate-instances --instance-ids i-12345")
|
|
476
|
+
expect(match).not.toBeNull()
|
|
477
|
+
expect(match?.category).toBe("container")
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
test("detects gcloud delete", () => {
|
|
481
|
+
const match = checkCommand("gcloud compute instances delete my-instance")
|
|
482
|
+
expect(match).not.toBeNull()
|
|
483
|
+
expect(match?.category).toBe("container")
|
|
484
|
+
})
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
describe("Destructive Command Check - Package Manager Operations", () => {
|
|
488
|
+
test("detects npm cache clean --force", () => {
|
|
489
|
+
const match = checkCommand("npm cache clean --force")
|
|
490
|
+
expect(match).not.toBeNull()
|
|
491
|
+
expect(match?.category).toBe("packages")
|
|
492
|
+
expect(match?.severity).toBe("medium")
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
test("detects yarn cache clean", () => {
|
|
496
|
+
const match = checkCommand("yarn cache clean")
|
|
497
|
+
expect(match).not.toBeNull()
|
|
498
|
+
expect(match?.category).toBe("packages")
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test("detects pip uninstall -y", () => {
|
|
502
|
+
const match = checkCommand("pip uninstall package -y")
|
|
503
|
+
expect(match).not.toBeNull()
|
|
504
|
+
expect(match?.category).toBe("packages")
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
test("detects brew uninstall --force", () => {
|
|
508
|
+
const match = checkCommand("brew uninstall --force package")
|
|
509
|
+
expect(match).not.toBeNull()
|
|
510
|
+
expect(match?.category).toBe("packages")
|
|
511
|
+
})
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
describe("Destructive Command Check - Network Operations", () => {
|
|
515
|
+
test("detects iptables -F", () => {
|
|
516
|
+
const match = checkCommand("iptables -F")
|
|
517
|
+
expect(match).not.toBeNull()
|
|
518
|
+
expect(match?.category).toBe("network")
|
|
519
|
+
expect(match?.severity).toBe("medium")
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
test("detects iptables --flush", () => {
|
|
523
|
+
const match = checkCommand("iptables --flush")
|
|
524
|
+
expect(match).not.toBeNull()
|
|
525
|
+
expect(match?.category).toBe("network")
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
test("detects ufw reset", () => {
|
|
529
|
+
const match = checkCommand("ufw reset")
|
|
530
|
+
expect(match).not.toBeNull()
|
|
531
|
+
expect(match?.category).toBe("network")
|
|
532
|
+
})
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
describe("Dangerous Path Detection", () => {
|
|
536
|
+
test("detects root path", () => {
|
|
537
|
+
expect(isDangerousPath("/")).toBe(true)
|
|
538
|
+
expect(isDangerousPath("/*")).toBe(true)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
test("detects system directories", () => {
|
|
542
|
+
expect(isDangerousPath("/home")).toBe(true)
|
|
543
|
+
expect(isDangerousPath("/etc")).toBe(true)
|
|
544
|
+
expect(isDangerousPath("/var")).toBe(true)
|
|
545
|
+
expect(isDangerousPath("/usr")).toBe(true)
|
|
546
|
+
expect(isDangerousPath("/bin")).toBe(true)
|
|
547
|
+
expect(isDangerousPath("/sbin")).toBe(true)
|
|
548
|
+
expect(isDangerousPath("/boot")).toBe(true)
|
|
549
|
+
expect(isDangerousPath("/lib")).toBe(true)
|
|
550
|
+
expect(isDangerousPath("/opt")).toBe(true)
|
|
551
|
+
expect(isDangerousPath("/root")).toBe(true)
|
|
552
|
+
expect(isDangerousPath("/sys")).toBe(true)
|
|
553
|
+
expect(isDangerousPath("/proc")).toBe(true)
|
|
554
|
+
expect(isDangerousPath("/dev")).toBe(true)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test("detects home directory shortcuts", () => {
|
|
558
|
+
expect(isDangerousPath("~")).toBe(true)
|
|
559
|
+
expect(isDangerousPath("~/")).toBe(true)
|
|
560
|
+
expect(isDangerousPath("$HOME")).toBe(true)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
test("detects critical project files", () => {
|
|
564
|
+
expect(isDangerousPath(".git")).toBe(true)
|
|
565
|
+
expect(isDangerousPath(".env")).toBe(true)
|
|
566
|
+
expect(isDangerousPath(".ssh")).toBe(true)
|
|
567
|
+
expect(isDangerousPath("package.json")).toBe(true)
|
|
568
|
+
expect(isDangerousPath("package-lock.json")).toBe(true)
|
|
569
|
+
expect(isDangerousPath("yarn.lock")).toBe(true)
|
|
570
|
+
expect(isDangerousPath("bun.lockb")).toBe(true)
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
test("detects paths with subdirectories", () => {
|
|
574
|
+
expect(isDangerousPath("/etc/passwd")).toBe(true)
|
|
575
|
+
expect(isDangerousPath("/home/user")).toBe(true)
|
|
576
|
+
expect(isDangerousPath("project/.git")).toBe(true)
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
test("allows safe paths", () => {
|
|
580
|
+
expect(isDangerousPath("./src")).toBe(false)
|
|
581
|
+
expect(isDangerousPath("./dist")).toBe(false)
|
|
582
|
+
expect(isDangerousPath("./build")).toBe(false)
|
|
583
|
+
expect(isDangerousPath("./node_modules")).toBe(false)
|
|
584
|
+
expect(isDangerousPath("/Users/dev/project")).toBe(false)
|
|
585
|
+
})
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
describe("Severity Classification", () => {
|
|
589
|
+
test("critical severity for rmDangerous", () => {
|
|
590
|
+
expect(getSeverity("rmDangerous")).toBe("critical")
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
test("critical severity for sudo", () => {
|
|
594
|
+
expect(getSeverity("sudo")).toBe("critical")
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test("critical severity for system", () => {
|
|
598
|
+
expect(getSeverity("system")).toBe("critical")
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
test("high severity for git", () => {
|
|
602
|
+
expect(getSeverity("git")).toBe("high")
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
test("high severity for database", () => {
|
|
606
|
+
expect(getSeverity("database")).toBe("high")
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
test("high severity for container", () => {
|
|
610
|
+
expect(getSeverity("container")).toBe("high")
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
test("medium severity for packages", () => {
|
|
614
|
+
expect(getSeverity("packages")).toBe("medium")
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
test("medium severity for network", () => {
|
|
618
|
+
expect(getSeverity("network")).toBe("medium")
|
|
619
|
+
})
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
// ============================================================================
|
|
623
|
+
// Integration Tests with Dummy Git Repo
|
|
624
|
+
// ============================================================================
|
|
625
|
+
|
|
626
|
+
describe("Integration Tests - Dummy Git Repo", () => {
|
|
627
|
+
const tempDir = join(tmpdir(), `destructive-check-test-${Date.now()}`)
|
|
628
|
+
const gitDir = join(tempDir, "test-repo")
|
|
629
|
+
|
|
630
|
+
beforeAll(() => {
|
|
631
|
+
// Create temp directory and initialize a dummy git repo
|
|
632
|
+
mkdirSync(gitDir, { recursive: true })
|
|
633
|
+
|
|
634
|
+
// Initialize git repo
|
|
635
|
+
execSync("git init", { cwd: gitDir })
|
|
636
|
+
execSync("git config user.email 'test@test.com'", { cwd: gitDir })
|
|
637
|
+
execSync("git config user.name 'Test User'", { cwd: gitDir })
|
|
638
|
+
|
|
639
|
+
// Create some files and commits
|
|
640
|
+
writeFileSync(join(gitDir, "README.md"), "# Test Repo\n")
|
|
641
|
+
execSync("git add .", { cwd: gitDir })
|
|
642
|
+
execSync("git commit -m 'Initial commit'", { cwd: gitDir })
|
|
643
|
+
|
|
644
|
+
writeFileSync(join(gitDir, "file1.txt"), "content 1\n")
|
|
645
|
+
execSync("git add .", { cwd: gitDir })
|
|
646
|
+
execSync("git commit -m 'Add file1'", { cwd: gitDir })
|
|
647
|
+
|
|
648
|
+
writeFileSync(join(gitDir, "file2.txt"), "content 2\n")
|
|
649
|
+
execSync("git add .", { cwd: gitDir })
|
|
650
|
+
execSync("git commit -m 'Add file2'", { cwd: gitDir })
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
afterAll(() => {
|
|
654
|
+
// Cleanup
|
|
655
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
test("verifies dummy repo has commits", () => {
|
|
659
|
+
const log = execSync("git log --oneline", { cwd: gitDir }).toString()
|
|
660
|
+
expect(log).toContain("Add file2")
|
|
661
|
+
expect(log).toContain("Add file1")
|
|
662
|
+
expect(log).toContain("Initial commit")
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
test("detects git reset --hard in context of real repo", () => {
|
|
666
|
+
// Simulate checking a command that would be run in the repo
|
|
667
|
+
const command = "git reset --hard HEAD~1"
|
|
668
|
+
const match = checkCommand(command)
|
|
669
|
+
|
|
670
|
+
expect(match).not.toBeNull()
|
|
671
|
+
expect(match?.category).toBe("git")
|
|
672
|
+
expect(match?.severity).toBe("high")
|
|
673
|
+
|
|
674
|
+
// This command SHOULD be blocked by the plugin
|
|
675
|
+
// We verify the pattern detection works correctly
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
test("detects git push --force for the repo", () => {
|
|
679
|
+
const command = "git push --force origin main"
|
|
680
|
+
const match = checkCommand(command)
|
|
681
|
+
|
|
682
|
+
expect(match).not.toBeNull()
|
|
683
|
+
expect(match?.category).toBe("git")
|
|
684
|
+
expect(match?.severity).toBe("high")
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
test("detects git clean -fd in repo context", () => {
|
|
688
|
+
const command = "git clean -fd"
|
|
689
|
+
const match = checkCommand(command)
|
|
690
|
+
|
|
691
|
+
expect(match).not.toBeNull()
|
|
692
|
+
expect(match?.category).toBe("git")
|
|
693
|
+
expect(match?.severity).toBe("high")
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
test("allows safe git operations on the repo", () => {
|
|
697
|
+
// These commands should NOT be blocked
|
|
698
|
+
expect(checkCommand("git status")).toBeNull()
|
|
699
|
+
expect(checkCommand("git log --oneline")).toBeNull()
|
|
700
|
+
expect(checkCommand("git diff HEAD~1")).toBeNull()
|
|
701
|
+
expect(checkCommand("git show HEAD")).toBeNull()
|
|
702
|
+
expect(checkCommand("git branch -a")).toBeNull()
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
test("detects rm -rf .git which would destroy the repo", () => {
|
|
706
|
+
const command = "rm -rf .git"
|
|
707
|
+
const match = checkCommand(command)
|
|
708
|
+
|
|
709
|
+
expect(match).not.toBeNull()
|
|
710
|
+
expect(match?.category).toBe("rmDangerous")
|
|
711
|
+
expect(match?.severity).toBe("critical")
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
test("simulates full workflow detection", () => {
|
|
715
|
+
// Simulate a series of commands that an AI might try to run
|
|
716
|
+
const commands = [
|
|
717
|
+
{ cmd: "git status", shouldBlock: false },
|
|
718
|
+
{ cmd: "git log --oneline -5", shouldBlock: false },
|
|
719
|
+
{ cmd: "git reset --hard HEAD~1", shouldBlock: true },
|
|
720
|
+
{ cmd: "git push --force origin main", shouldBlock: true },
|
|
721
|
+
{ cmd: "rm -rf .git", shouldBlock: true },
|
|
722
|
+
{ cmd: "git clean -fd", shouldBlock: true }, // Fixed: -fd instead of -xfd
|
|
723
|
+
{ cmd: "git stash drop", shouldBlock: true },
|
|
724
|
+
{ cmd: "git branch -D feature", shouldBlock: true },
|
|
725
|
+
{ cmd: "git add .", shouldBlock: false },
|
|
726
|
+
{ cmd: "git commit -m 'test'", shouldBlock: false },
|
|
727
|
+
{ cmd: "git push origin main", shouldBlock: false },
|
|
728
|
+
]
|
|
729
|
+
|
|
730
|
+
for (const { cmd, shouldBlock } of commands) {
|
|
731
|
+
const match = checkCommand(cmd)
|
|
732
|
+
if (shouldBlock) {
|
|
733
|
+
expect(match).not.toBeNull()
|
|
734
|
+
} else {
|
|
735
|
+
expect(match).toBeNull()
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
})
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
describe("Edge Cases and Variations", () => {
|
|
742
|
+
test("handles uppercase commands", () => {
|
|
743
|
+
expect(checkCommand("GIT PUSH --FORCE origin main")).not.toBeNull()
|
|
744
|
+
expect(checkCommand("GIT RESET --HARD HEAD")).not.toBeNull()
|
|
745
|
+
expect(checkCommand("RM -RF /home")).not.toBeNull()
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
test("handles mixed case", () => {
|
|
749
|
+
expect(checkCommand("Git Push --Force origin main")).not.toBeNull()
|
|
750
|
+
expect(checkCommand("Git Reset --Hard HEAD")).not.toBeNull()
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
test("handles extra whitespace", () => {
|
|
754
|
+
expect(checkCommand("git push --force origin main")).not.toBeNull()
|
|
755
|
+
expect(checkCommand("git reset --hard HEAD")).not.toBeNull()
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
test("handles commands with pipes", () => {
|
|
759
|
+
expect(checkCommand("git log | git reset --hard HEAD")).not.toBeNull()
|
|
760
|
+
expect(checkCommand("echo 'test' | git push --force")).not.toBeNull()
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
test("handles commands with && chains", () => {
|
|
764
|
+
expect(checkCommand("git add . && git reset --hard")).not.toBeNull()
|
|
765
|
+
expect(checkCommand("cd /tmp && rm -rf /home")).not.toBeNull()
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
test("handles commands with ; chains", () => {
|
|
769
|
+
expect(checkCommand("git status; git reset --hard")).not.toBeNull()
|
|
770
|
+
expect(checkCommand("ls; rm -rf /var")).not.toBeNull()
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
test("does not false positive on similar but safe commands", () => {
|
|
774
|
+
expect(checkCommand("git reset HEAD")).toBeNull() // no --hard
|
|
775
|
+
expect(checkCommand("git push origin main")).toBeNull() // no --force
|
|
776
|
+
expect(checkCommand("rm file.txt")).toBeNull() // safe target
|
|
777
|
+
expect(checkCommand("git clean -n")).toBeNull() // dry run
|
|
778
|
+
})
|
|
779
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -308,22 +308,31 @@ const destructiveCheck: Plugin = async () => {
|
|
|
308
308
|
async ["permission.ask"](
|
|
309
309
|
input: {
|
|
310
310
|
id: string
|
|
311
|
-
type
|
|
311
|
+
// Old permission system uses 'type', new system uses 'permission'
|
|
312
|
+
type?: string
|
|
313
|
+
permission?: string
|
|
312
314
|
pattern?: string | string[]
|
|
315
|
+
patterns?: string[]
|
|
313
316
|
sessionID: string
|
|
314
|
-
messageID
|
|
317
|
+
messageID?: string
|
|
315
318
|
callID?: string
|
|
316
|
-
title
|
|
319
|
+
title?: string
|
|
317
320
|
metadata: Record<string, unknown>
|
|
318
|
-
time
|
|
321
|
+
time?: { created: number }
|
|
319
322
|
},
|
|
320
323
|
output: { status: "ask" | "deny" | "allow" },
|
|
321
324
|
): Promise<void> {
|
|
322
325
|
const stats = getStats(input.sessionID)
|
|
323
326
|
|
|
327
|
+
// Get permission type from either old or new system
|
|
328
|
+
const permissionType = input.permission || input.type || ""
|
|
329
|
+
|
|
324
330
|
// Check if this is a bash/command execution permission
|
|
325
|
-
if (
|
|
326
|
-
|
|
331
|
+
if (permissionType === "bash" || permissionType === "command" || permissionType === "shell") {
|
|
332
|
+
// Get command from patterns (new system) or metadata/title (old system)
|
|
333
|
+
const patterns =
|
|
334
|
+
input.patterns || (Array.isArray(input.pattern) ? input.pattern : input.pattern ? [input.pattern] : [])
|
|
335
|
+
const command = patterns.join(" ") || (input.metadata?.command as string) || input.title || ""
|
|
327
336
|
|
|
328
337
|
if (command) {
|
|
329
338
|
const match = checkCommand(command)
|
|
@@ -347,8 +356,9 @@ const destructiveCheck: Plugin = async () => {
|
|
|
347
356
|
}
|
|
348
357
|
|
|
349
358
|
// Check file operations
|
|
350
|
-
if (
|
|
351
|
-
const patterns =
|
|
359
|
+
if (permissionType === "write" || permissionType === "edit" || permissionType === "delete") {
|
|
360
|
+
const patterns =
|
|
361
|
+
input.patterns || (Array.isArray(input.pattern) ? input.pattern : input.pattern ? [input.pattern] : [])
|
|
352
362
|
for (const p of patterns) {
|
|
353
363
|
if (isDangerousPath(p)) {
|
|
354
364
|
stats.permissionsRequested++
|