@ia-ccun/code-agent-cli 0.0.52-beta.3 → 0.0.52-beta.5

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.
@@ -0,0 +1,552 @@
1
+ /**
2
+ * @author: xujianjiang
3
+ * 安全卫士扩展 — 在 AI 执行危险命令前进行拦截、确认和阻止
4
+ *
5
+ * 功能说明:
6
+ * - 拦截危险 bash 命令(git reset/clean/push --force、rm -rf、docker prune、sudo 等)
7
+ * - 拦截对受保护路径(.env、.ssh、密钥文件等)的写入和编辑
8
+ * - 三级风险等级:prompt(提示)、strong-confirm(强确认)、block-noninteractive(非交互模式阻止)
9
+ * - 支持一次性允许、本次会话允许、永久允许
10
+ * - 受保护路径:.ssh、.env、密钥文件、.kube/config、.aws/credentials 等
11
+ * - 命令规则覆盖:Git、文件系统、Docker、包管理器、系统命令、数据库 SQL、密钥暴露
12
+ *
13
+ * 使用方法:
14
+ * /safety-guard on 启用安全卫士
15
+ * /safety-guard off 禁用安全卫士
16
+ * /safety-guard status 查看状态
17
+ * /safety-guard allow-list 查看允许列表
18
+ * /safety-guard allow-clear-session 清除会话允许
19
+ * /safety-guard allow-clear-permanent 清除永久允许
20
+ *
21
+ * 永久允许存储位置:~/.aicode-cli/agent/safety-guard-allow.json
22
+ */
23
+
24
+ import fs from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import path from "node:path";
27
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
28
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
29
+
30
+ const STATUS_KEY = "safety-guard";
31
+ const PROMPT_WIDGET_KEY = "safety-guard-prompt";
32
+
33
+ type RiskLevel = "prompt" | "strong-confirm" | "block-noninteractive";
34
+ type RuleCategory = "git" | "filesystem" | "docker" | "package" | "system" | "database" | "secrets";
35
+
36
+ type CommandRule = {
37
+ pattern: RegExp;
38
+ label: string;
39
+ category: RuleCategory;
40
+ level: RiskLevel;
41
+ };
42
+
43
+ type AllowScope = "session" | "permanent";
44
+ type AllowDecision = { block: true; reason: string } | { allow: true; scope?: AllowScope } | undefined;
45
+ type AllowEntry = {
46
+ key: string;
47
+ kind: "bash" | "write" | "edit";
48
+ value: string;
49
+ cwd: string;
50
+ label: string;
51
+ category?: RuleCategory;
52
+ createdAt: string;
53
+ };
54
+ type AllowStore = { version: 1; entries: AllowEntry[] };
55
+
56
+ const ALLOW_STORE_PATH = path.join(homedir(), ".aicode-cli", "agent", "safety-guard-allow.json");
57
+
58
+ const GIT_RULES: CommandRule[] = [
59
+ { pattern: /\bgit\s+reset\s+--hard\b/i, label: "git reset --hard", category: "git", level: "block-noninteractive" },
60
+ { pattern: /\bgit\s+reset\s+(?:--soft|--mixed|--merge|--keep)\b/i, label: "git reset history rewrite", category: "git", level: "strong-confirm" },
61
+ { pattern: /\bgit\s+reset\s+(?:HEAD[~^]|[a-f0-9]{7,40}\b)/i, label: "git reset to revision", category: "git", level: "strong-confirm" },
62
+ { pattern: /\bgit\s+clean\b[^\n;&|]*\s-[^\s;&|]*f/i, label: "git clean -f", category: "git", level: "block-noninteractive" },
63
+ { pattern: /\bgit\s+checkout\s+--\s+/i, label: "git checkout -- <path>", category: "git", level: "prompt" },
64
+ { pattern: /\bgit\s+switch\b/i, label: "git switch", category: "git", level: "prompt" },
65
+ { pattern: /\bgit\s+restore\b/i, label: "git restore", category: "git", level: "prompt" },
66
+ { pattern: /\bgit\s+branch\s+-(?:d|D)\b/i, label: "git branch delete", category: "git", level: "prompt" },
67
+ { pattern: /\bgit\s+tag\s+-d\b/i, label: "git tag delete", category: "git", level: "prompt" },
68
+ { pattern: /\bgit\s+push\b[^\n;&|]*--force(?:-with-lease)?\b/i, label: "git push --force", category: "git", level: "strong-confirm" },
69
+ { pattern: /\bgit\s+push\b[^\n;&|]*(?:--delete|:refs\/heads\/)/i, label: "git push delete branch", category: "git", level: "strong-confirm" },
70
+ { pattern: /\bgit\s+rebase\b/i, label: "git rebase", category: "git", level: "strong-confirm" },
71
+ { pattern: /\bgit\s+filter-(?:branch|repo)\b/i, label: "git history filtering", category: "git", level: "strong-confirm" },
72
+ { pattern: /\bgit\s+commit\b[^\n;&|]*--amend\b/i, label: "git commit --amend", category: "git", level: "prompt" },
73
+ { pattern: /\bgit\s+commit\b[^\n;&|]*--(?:fixup|squash)\b/i, label: "git commit fixup/squash", category: "git", level: "prompt" },
74
+ { pattern: /\bgit\s+replace\b/i, label: "git replace", category: "git", level: "strong-confirm" },
75
+ { pattern: /\bgit\s+notes\s+(?:remove|prune)\b/i, label: "git notes remove/prune", category: "git", level: "prompt" },
76
+ { pattern: /\bgit\s+update-ref\b/i, label: "git update-ref", category: "git", level: "strong-confirm" },
77
+ { pattern: /\bgit\s+reflog\s+expire\b/i, label: "git reflog expire", category: "git", level: "strong-confirm" },
78
+ { pattern: /\bgit\s+gc\b[^\n;&|]*--prune(?:=|\s+)?(?:now|all)?\b/i, label: "git gc --prune", category: "git", level: "strong-confirm" },
79
+ { pattern: /\bgit\s+prune\b/i, label: "git prune", category: "git", level: "strong-confirm" },
80
+ ];
81
+
82
+ const FILESYSTEM_RULES: CommandRule[] = [
83
+ { pattern: /(^|[^\w-])rm\s+[^\n;&|]*-[^\s;&|]*r[^\s;&|]*f?\b/i, label: "recursive rm", category: "filesystem", level: "prompt" },
84
+ { pattern: /(^|[^\w-])rm\s+[^\n;&|]*-[^\s;&|]*f[^\s;&|]*r\b/i, label: "recursive force rm", category: "filesystem", level: "prompt" },
85
+ { pattern: /(^|[^\w-])rm\s+[^\n;&|]*(?:-[^\s;&|]*(?:r|f)[^\s;&|]*(?:r|f)[^\s;&|]*\s+)?(?:\/|~|\$HOME|\.)(?:\s|$|[;&|])/i, label: "rm targeting root/home/current directory", category: "filesystem", level: "block-noninteractive" },
86
+ { pattern: /(^|[^\w-])rm\s+[^\n;&|]*\*/i, label: "rm with glob", category: "filesystem", level: "prompt" },
87
+ { pattern: /\bfind\b[^\n;&|]*\s-delete\b/i, label: "find -delete", category: "filesystem", level: "prompt" },
88
+ { pattern: /\bfind\b[^\n;&|]*\s-exec\s+rm\b/i, label: "find -exec rm", category: "filesystem", level: "prompt" },
89
+ { pattern: /\bxargs\b[^\n;&|]*\brm\b/i, label: "xargs rm", category: "filesystem", level: "prompt" },
90
+ { pattern: /\btruncate\s+-s\s+0\b/i, label: "truncate file to zero", category: "filesystem", level: "prompt" },
91
+ { pattern: /\bshred\b/i, label: "shred", category: "filesystem", level: "strong-confirm" },
92
+ { pattern: /\bdd\b[^\n;&|]*\bof=\/dev\//i, label: "dd to block device", category: "filesystem", level: "block-noninteractive" },
93
+ { pattern: /\bdd\b/i, label: "dd", category: "filesystem", level: "prompt" },
94
+ { pattern: /\bmkfs(?:\.|\b)/i, label: "mkfs", category: "filesystem", level: "block-noninteractive" },
95
+ { pattern: /\b(?:wipefs|parted|fdisk|sfdisk|sgdisk)\b/i, label: "disk partition/filesystem tool", category: "filesystem", level: "block-noninteractive" },
96
+ ];
97
+
98
+ const DOCKER_RULES: CommandRule[] = [
99
+ { pattern: /\bdocker\s+(?:rm|rmi)\b/i, label: "docker remove", category: "docker", level: "prompt" },
100
+ { pattern: /\bdocker\s+volume\s+(?:rm|prune)\b/i, label: "docker volume removal/prune", category: "docker", level: "block-noninteractive" },
101
+ { pattern: /\bdocker\s+system\s+prune\b/i, label: "docker system prune", category: "docker", level: "strong-confirm" },
102
+ { pattern: /\bdocker\s+compose\s+down\b[^\n;&|]*(?:-v|--volumes)\b/i, label: "docker compose down --volumes", category: "docker", level: "block-noninteractive" },
103
+ { pattern: /\bdocker-compose\s+down\b[^\n;&|]*(?:-v|--volumes)\b/i, label: "docker-compose down --volumes", category: "docker", level: "block-noninteractive" },
104
+ { pattern: /\bpodman\s+(?:rm|rmi)\b/i, label: "podman remove", category: "docker", level: "prompt" },
105
+ { pattern: /\bpodman\s+system\s+prune\b/i, label: "podman system prune", category: "docker", level: "strong-confirm" },
106
+ ];
107
+
108
+ const PACKAGE_MANAGER_RULES: CommandRule[] = [
109
+ { pattern: /\b(?:npm|pnpm|yarn|bun)\s+(?:uninstall|remove|rm|prune|autoclean)\b/i, label: "JS package removal", category: "package", level: "prompt" },
110
+ { pattern: /\b(?:pip|uv|cargo)\s+(?:uninstall|remove)\b/i, label: "package removal", category: "package", level: "prompt" },
111
+ { pattern: /\b(?:pacman|paru|yay)\s+-R/i, label: "Arch package removal", category: "package", level: "strong-confirm" },
112
+ { pattern: /\bapt(?:-get)?\s+(?:remove|purge|autoremove)\b/i, label: "APT package removal", category: "package", level: "strong-confirm" },
113
+ { pattern: /\bdnf\s+remove\b/i, label: "DNF package removal", category: "package", level: "strong-confirm" },
114
+ ];
115
+
116
+ const SYSTEM_RULES: CommandRule[] = [
117
+ { pattern: /\bsudo\b/i, label: "sudo", category: "system", level: "prompt" },
118
+ { pattern: /\b(?:shutdown|reboot|poweroff)\b/i, label: "shutdown/reboot", category: "system", level: "block-noninteractive" },
119
+ { pattern: /\bsystemctl\s+(?:stop|disable|mask|restart)\b/i, label: "systemctl service change", category: "system", level: "prompt" },
120
+ { pattern: /\b(?:killall|pkill)\b/i, label: "process kill", category: "system", level: "prompt" },
121
+ { pattern: /\bkill\s+-9\b/i, label: "kill -9", category: "system", level: "prompt" },
122
+ { pattern: /\b(?:umount|mount|swapon|swapoff)\b/i, label: "mount/swap change", category: "system", level: "prompt" },
123
+ { pattern: /\b(?:chmod|chown)\b[^\n;&|]*\s-R\b/i, label: "recursive chmod/chown", category: "system", level: "prompt" },
124
+ { pattern: /\bchmod\b[^\n;&|]*\s777\b/i, label: "chmod 777", category: "system", level: "prompt" },
125
+ { pattern: /\bsetfacl\b/i, label: "ACL change", category: "system", level: "prompt" },
126
+ { pattern: /:\(\)\s*\{/i, label: "fork bomb", category: "system", level: "block-noninteractive" },
127
+ ];
128
+
129
+ const DATABASE_RULES: CommandRule[] = [
130
+ { pattern: /\bDROP\s+(?:DATABASE|SCHEMA)\b/i, label: "SQL drop database/schema", category: "database", level: "block-noninteractive" },
131
+ { pattern: /\bDROP\s+TABLE\b/i, label: "SQL drop table", category: "database", level: "strong-confirm" },
132
+ { pattern: /\bDROP\s+INDEX\b/i, label: "SQL drop index", category: "database", level: "strong-confirm" },
133
+ { pattern: /\bTRUNCATE\s+(?:TABLE\s+)?[\w.`\"\[\]-]+/i, label: "SQL truncate table", category: "database", level: "strong-confirm" },
134
+ { pattern: /\bDELETE\s+FROM\b(?:(?!\bWHERE\b|;)[\s\S])*(?:;|$)/i, label: "SQL delete without WHERE", category: "database", level: "strong-confirm" },
135
+ { pattern: /\bUPDATE\s+[\w.`\"\[\]-]+\s+SET\b(?:(?!\bWHERE\b|;)[\s\S])*(?:;|$)/i, label: "SQL update without WHERE", category: "database", level: "strong-confirm" },
136
+ { pattern: /\bALTER\s+TABLE\b[\s\S]*\bDROP\s+(?:COLUMN|CONSTRAINT)\b/i, label: "SQL alter table drop", category: "database", level: "strong-confirm" },
137
+ ];
138
+
139
+ const SECRET_EXPOSURE_RULES: CommandRule[] = [
140
+ { pattern: /\b(?:cat|grep|rg|awk|sed|cp)\b[^\n;&|]*(?:\.env(?:\.[^\s;&|]+)?|id_rsa|id_ed25519|\.git-credentials|auth\.json|\.npmrc|\.pypirc|\.netrc|credentials|hosts\.yml|\.pem|\.key|\.p12|\.kdbx)/i, label: "possible secret file access", category: "secrets", level: "prompt" },
141
+ ];
142
+
143
+ const DANGEROUS_BASH_RULES: CommandRule[] = [
144
+ ...GIT_RULES,
145
+ ...FILESYSTEM_RULES,
146
+ ...DOCKER_RULES,
147
+ ...PACKAGE_MANAGER_RULES,
148
+ ...SYSTEM_RULES,
149
+ ...SECRET_EXPOSURE_RULES,
150
+ ];
151
+
152
+ function normalizeCwd(cwd: string): string {
153
+ return path.resolve(cwd || process.cwd());
154
+ }
155
+
156
+ function allowKey(kind: AllowEntry["kind"], value: string, cwd: string): string {
157
+ return `${kind}:${normalizeCwd(cwd)}:${value}`;
158
+ }
159
+
160
+ function readAllowStore(): AllowStore {
161
+ try {
162
+ const raw = fs.readFileSync(ALLOW_STORE_PATH, "utf8");
163
+ const parsed = JSON.parse(raw) as Partial<AllowStore>;
164
+ if (parsed.version !== 1 || !Array.isArray(parsed.entries)) return { version: 1, entries: [] };
165
+ return {
166
+ version: 1,
167
+ entries: parsed.entries.filter((entry): entry is AllowEntry => (
168
+ !!entry
169
+ && typeof entry.key === "string"
170
+ && (entry.kind === "bash" || entry.kind === "write" || entry.kind === "edit")
171
+ && typeof entry.value === "string"
172
+ && typeof entry.cwd === "string"
173
+ && typeof entry.label === "string"
174
+ && typeof entry.createdAt === "string"
175
+ )),
176
+ };
177
+ } catch {
178
+ return { version: 1, entries: [] };
179
+ }
180
+ }
181
+
182
+ function writeAllowStore(store: AllowStore): void {
183
+ fs.mkdirSync(path.dirname(ALLOW_STORE_PATH), { recursive: true });
184
+ fs.writeFileSync(ALLOW_STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
185
+ }
186
+
187
+ function addPermanentAllow(entry: Omit<AllowEntry, "createdAt">): void {
188
+ const store = readAllowStore();
189
+ const next: AllowEntry = { ...entry, createdAt: new Date().toISOString() };
190
+ store.entries = [...store.entries.filter((existing) => existing.key !== entry.key), next];
191
+ writeAllowStore(store);
192
+ }
193
+
194
+ function formatAllowEntry(entry: AllowEntry): string {
195
+ return `${entry.kind} ${entry.label} @ ${entry.cwd}`;
196
+ }
197
+
198
+ function allowedScope(decision: AllowDecision): AllowScope | undefined {
199
+ return decision && "allow" in decision ? decision.scope : undefined;
200
+ }
201
+
202
+ function isProtectedPath(targetPath: string, cwd: string): boolean {
203
+ const resolved = path.resolve(cwd, targetPath);
204
+ const lower = resolved.toLowerCase();
205
+
206
+ if (/(^|\/)\.ssh(\/|$)/.test(lower)) return true;
207
+ if (/(^|\/)\.git-credentials$/.test(lower)) return true;
208
+ if (/(^|\/)auth\.json$/.test(lower)) return true;
209
+ if (/(^|\/)id_(rsa|ed25519)(\.pub)?$/.test(lower)) return true;
210
+ if (/(^|\/)\.env(\..+)?$/.test(lower)) return true;
211
+ if (/(^|\/)\.envrc$/.test(lower)) return true;
212
+ if (/(^|\/)\.npmrc$/.test(lower)) return true;
213
+ if (/(^|\/)\.pypirc$/.test(lower)) return true;
214
+ if (/(^|\/)\.netrc$/.test(lower)) return true;
215
+ if (/(^|\/)\.kube\/config$/.test(lower)) return true;
216
+ if (/(^|\/)\.aws\/(credentials|config)$/.test(lower)) return true;
217
+ if (/(^|\/)\.config\/gh\/hosts\.yml$/.test(lower)) return true;
218
+ if (/(^|\/)\.config\/gcloud(\/|$)/.test(lower)) return true;
219
+ if (/\.(pem|key|p12|kdbx)$/.test(lower)) return true;
220
+
221
+ return false;
222
+ }
223
+
224
+ async function confirmOrBlock(
225
+ ctx: ExtensionContext,
226
+ title: string,
227
+ message: string,
228
+ nonInteractiveReason: string,
229
+ ): Promise<AllowDecision> {
230
+ if (!ctx.hasUI) {
231
+ return { block: true, reason: nonInteractiveReason };
232
+ }
233
+
234
+ return await new Promise<AllowDecision>((resolveDecision) => {
235
+ let settled = false;
236
+ let unsubscribe: (() => void) | undefined;
237
+
238
+ const finish = (decision: AllowDecision) => {
239
+ if (settled) return;
240
+ settled = true;
241
+ unsubscribe?.();
242
+ ctx.ui.setWidget(PROMPT_WIDGET_KEY, undefined);
243
+ resolveDecision(decision);
244
+ };
245
+
246
+ const promptTitle = [
247
+ ctx.ui.theme.fg("warning", `⚠ ${title}`),
248
+ "",
249
+ message,
250
+ ].join("\n");
251
+
252
+ void ctx.ui.select(promptTitle, [
253
+ "Block",
254
+ "Allow once",
255
+ "Allow for this session",
256
+ "Always allow in this cwd",
257
+ ]).then((choice) => {
258
+ switch (choice) {
259
+ case "Allow once":
260
+ finish({ allow: true });
261
+ break;
262
+ case "Allow for this session":
263
+ finish({ allow: true, scope: "session" });
264
+ break;
265
+ case "Always allow in this cwd":
266
+ finish({ allow: true, scope: "permanent" });
267
+ break;
268
+ default:
269
+ finish({ block: true, reason: "Blocked by safety-guard extension" });
270
+ break;
271
+ }
272
+ }).catch(() => finish({ block: true, reason: "Blocked by safety-guard extension" }));
273
+ });
274
+ }
275
+
276
+ function stripHeredocBodies(command: string): string {
277
+ const lines = command.split(/\r?\n/);
278
+ const kept: string[] = [];
279
+ let terminator: string | undefined;
280
+
281
+ for (const line of lines) {
282
+ if (terminator) {
283
+ if (line.trim() === terminator) terminator = undefined;
284
+ continue;
285
+ }
286
+
287
+ kept.push(line);
288
+ const match = line.match(/<<-?\s*(?:['"]?)([A-Za-z_][A-Za-z0-9_]*)(?:['"]?)/);
289
+ if (match) terminator = match[1];
290
+ }
291
+
292
+ return kept.join("\n");
293
+ }
294
+
295
+ function previewCommand(command: string, maxChars = 800): string {
296
+ if (command.length <= maxChars) return command;
297
+ return `${command.slice(0, maxChars)}\n… [truncated ${command.length - maxChars} chars for prompt display]`;
298
+ }
299
+
300
+ function highlightMatchedPattern(
301
+ rule: CommandRule,
302
+ line: string,
303
+ highlight: (value: string) => string,
304
+ ): string {
305
+ rule.pattern.lastIndex = 0;
306
+ const match = rule.pattern.exec(line);
307
+ rule.pattern.lastIndex = 0;
308
+ if (!match || match.index === undefined || match[0].length === 0) return line;
309
+
310
+ const start = match.index;
311
+ const end = start + match[0].length;
312
+ return `${line.slice(0, start)}${highlight(`>>> ${match[0]} <<<`)}${line.slice(end)}`;
313
+ }
314
+
315
+ function matchingCommandExcerpt(
316
+ rule: CommandRule,
317
+ commandForMatching: string,
318
+ highlight: (value: string) => string = (value) => value,
319
+ ): string {
320
+ const lines = commandForMatching.split(/\r?\n/);
321
+ const matchedIndexes = lines
322
+ .map((line, index) => ({ line, index }))
323
+ .filter(({ line }) => {
324
+ rule.pattern.lastIndex = 0;
325
+ const matched = rule.pattern.test(line);
326
+ rule.pattern.lastIndex = 0;
327
+ return matched;
328
+ })
329
+ .map(({ index }) => index);
330
+
331
+ if (matchedIndexes.length === 0) {
332
+ return previewCommand(commandForMatching.trim() || commandForMatching, 800);
333
+ }
334
+
335
+ const matchedSet = new Set(matchedIndexes);
336
+ const included = new Set<number>();
337
+ for (const index of matchedIndexes.slice(0, 3)) {
338
+ for (let offset = -1; offset <= 1; offset += 1) {
339
+ const lineIndex = index + offset;
340
+ if (lineIndex < 0 || lineIndex >= lines.length) continue;
341
+ included.add(lineIndex);
342
+ }
343
+ }
344
+
345
+ const lineNumberWidth = String(lines.length).length;
346
+ const excerptLines = [...included]
347
+ .sort((a, b) => a - b)
348
+ .map((lineIndex) => {
349
+ const isMatchedLine = matchedSet.has(lineIndex);
350
+ const marker = isMatchedLine ? highlight("!!!") : " ";
351
+ const lineNumber = String(lineIndex + 1).padStart(lineNumberWidth, " ");
352
+ const line = isMatchedLine ? highlightMatchedPattern(rule, lines[lineIndex], highlight) : lines[lineIndex];
353
+ return `${marker} ${lineNumber} | ${line}`;
354
+ });
355
+
356
+ if (matchedIndexes.length > 3) excerptLines.push(`… ${matchedIndexes.length - 3} more matching lines omitted`);
357
+ return previewCommand(excerptLines.join("\n"), 1000);
358
+ }
359
+
360
+ function formatRuleMessage(
361
+ rule: CommandRule,
362
+ commandForMatching: string,
363
+ highlight: (value: string) => string = (value) => value,
364
+ ): { title: string; message: string; nonInteractiveReason: string } {
365
+ const title = rule.level === "strong-confirm"
366
+ ? "⚠ aicode提示:高危 bash 命令"
367
+ : "⚠ aicode提示:危险 bash 命令";
368
+ const impact = rule.level === "strong-confirm"
369
+ ? "此操作难以撤销。执行前请确认目标、分支和作用域。"
370
+ : "执行前请确认目标和作用域。";
371
+ const excerpt = matchingCommandExcerpt(rule, commandForMatching, highlight);
372
+
373
+ return {
374
+ title,
375
+ message: [
376
+ `原因:${highlight(rule.label)}(${rule.category})`,
377
+ impact,
378
+ "",
379
+ "危险行和上下文",
380
+ `标记为 ${highlight("!!!")} 的行触发了安全规则;匹配的模式包裹为 ${highlight(">>> pattern >>>")}。`,
381
+ "────────────────────────────────────────",
382
+ excerpt,
383
+ "────────────────────────────────────────",
384
+ "",
385
+ "确认执行?",
386
+ ].join("\n"),
387
+ nonInteractiveReason: `Blocked ${rule.category} command (${rule.label}) in non-interactive mode`,
388
+ };
389
+ }
390
+
391
+ function updateStatus(ctx: ExtensionContext, enabled: boolean): void {
392
+ if (!ctx.hasUI) return;
393
+ if (enabled) {
394
+ ctx.ui.setStatus(STATUS_KEY, "");
395
+ return;
396
+ }
397
+ ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("success", "🔓!"));
398
+ }
399
+
400
+ export default function safetyGuard(pi: ExtensionAPI) {
401
+ let enabled = true;
402
+ const sessionAllow = new Map<string, AllowEntry>();
403
+ let permanentAllow = readAllowStore();
404
+
405
+ const isAllowed = (key: string): boolean => sessionAllow.has(key) || permanentAllow.entries.some((entry) => entry.key === key);
406
+
407
+ const rememberAllow = (entry: Omit<AllowEntry, "createdAt">, scope: AllowScope, ctx: ExtensionContext): void => {
408
+ const next: AllowEntry = { ...entry, createdAt: new Date().toISOString() };
409
+ if (scope === "session") {
410
+ sessionAllow.set(entry.key, next);
411
+ if (ctx.hasUI) ctx.ui.notify(`Allowed for this session: ${formatAllowEntry(next)}`, "info");
412
+ return;
413
+ }
414
+
415
+ addPermanentAllow(entry);
416
+ permanentAllow = readAllowStore();
417
+ if (ctx.hasUI) ctx.ui.notify(`Permanently allowed: ${formatAllowEntry(next)}`, "info");
418
+ };
419
+
420
+ pi.registerCommand("safety-guard", {
421
+ description: "Safety guard control: on | off | status | allow-list | allow-clear-session | allow-clear-permanent",
422
+ handler: async (args, ctx) => {
423
+ const cmd = args?.trim().toLowerCase();
424
+ if (!cmd || cmd === "status") {
425
+ permanentAllow = readAllowStore();
426
+ if (ctx.hasUI) ctx.ui.notify(`Safety guard: ${enabled ? "ON" : "OFF"}. Allows: ${sessionAllow.size} session, ${permanentAllow.entries.length} permanent.\n\nTo add more safety rules, contact xujianjiang.`, "info");
427
+ updateStatus(ctx, enabled);
428
+ return;
429
+ }
430
+ if (cmd === "on") {
431
+ enabled = true;
432
+ updateStatus(ctx, enabled);
433
+ if (ctx.hasUI) ctx.ui.notify("Safety guard enabled\n\nTo add more safety rules, contact xujianjiang", "info");
434
+ return;
435
+ }
436
+ if (cmd === "off") {
437
+ enabled = false;
438
+ updateStatus(ctx, enabled);
439
+ if (ctx.hasUI) ctx.ui.notify("Safety guard disabled", "warning");
440
+ return;
441
+ }
442
+ if (cmd === "allow-list") {
443
+ permanentAllow = readAllowStore();
444
+ const lines = [
445
+ "Session allows:",
446
+ ...(sessionAllow.size ? [...sessionAllow.values()].map(formatAllowEntry) : ["(none)"]),
447
+ "",
448
+ `Permanent allows (${ALLOW_STORE_PATH}):`,
449
+ ...(permanentAllow.entries.length ? permanentAllow.entries.map(formatAllowEntry) : ["(none)"]),
450
+ ];
451
+ if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
452
+ return;
453
+ }
454
+ if (cmd === "allow-clear-session") {
455
+ sessionAllow.clear();
456
+ if (ctx.hasUI) ctx.ui.notify("Cleared safety guard session allow-list", "info");
457
+ return;
458
+ }
459
+ if (cmd === "allow-clear-permanent") {
460
+ writeAllowStore({ version: 1, entries: [] });
461
+ permanentAllow = readAllowStore();
462
+ if (ctx.hasUI) ctx.ui.notify("Cleared safety guard permanent allow-list", "info");
463
+ return;
464
+ }
465
+ if (ctx.hasUI) ctx.ui.notify("Usage: /safety-guard on|off|status|allow-list|allow-clear-session|allow-clear-permanent", "warning");
466
+ },
467
+ });
468
+
469
+ pi.on("session_start", async (_event, ctx) => {
470
+ updateStatus(ctx, enabled);
471
+ });
472
+
473
+ pi.on("tool_call", async (event, ctx) => {
474
+ if (!enabled) return;
475
+ if (isToolCallEventType("bash", event)) {
476
+ const command = event.input.command ?? "";
477
+ const commandForMatching = stripHeredocBodies(command);
478
+ const shellMatch = DANGEROUS_BASH_RULES.find((entry) => entry.pattern.test(commandForMatching));
479
+ const databaseMatch = DATABASE_RULES.find((entry) => entry.pattern.test(command));
480
+ const match = shellMatch ?? databaseMatch;
481
+ const matchedCommandText = databaseMatch && !shellMatch ? command : commandForMatching;
482
+ if (!match) return;
483
+
484
+ const entry = {
485
+ key: allowKey("bash", command, ctx.cwd),
486
+ kind: "bash" as const,
487
+ value: command,
488
+ cwd: normalizeCwd(ctx.cwd),
489
+ label: match.label,
490
+ category: match.category,
491
+ };
492
+ if (isAllowed(entry.key)) return;
493
+
494
+ const prompt = formatRuleMessage(match, matchedCommandText, (value) => ctx.ui.theme.fg("warning", value));
495
+ const decision = await confirmOrBlock(ctx, prompt.title, prompt.message, prompt.nonInteractiveReason);
496
+ const scope = allowedScope(decision);
497
+ if (scope) rememberAllow(entry, scope, ctx);
498
+ if (decision && "block" in decision) return decision;
499
+ return;
500
+ }
501
+
502
+ if (isToolCallEventType("write", event)) {
503
+ if (!isProtectedPath(event.input.path, ctx.cwd)) return;
504
+
505
+ const resolvedPath = path.resolve(ctx.cwd, event.input.path);
506
+ const entry = {
507
+ key: allowKey("write", resolvedPath, ctx.cwd),
508
+ kind: "write" as const,
509
+ value: resolvedPath,
510
+ cwd: normalizeCwd(ctx.cwd),
511
+ label: resolvedPath,
512
+ };
513
+ if (isAllowed(entry.key)) return;
514
+
515
+ const decision = await confirmOrBlock(
516
+ ctx,
517
+ "Protected file write",
518
+ `Write to protected path '${event.input.path}'?`,
519
+ `Blocked write to protected path '${event.input.path}' in non-interactive mode`,
520
+ );
521
+ const scope = allowedScope(decision);
522
+ if (scope) rememberAllow(entry, scope, ctx);
523
+ if (decision && "block" in decision) return decision;
524
+ return;
525
+ }
526
+
527
+ if (isToolCallEventType("edit", event)) {
528
+ if (!isProtectedPath(event.input.path, ctx.cwd)) return;
529
+
530
+ const resolvedPath = path.resolve(ctx.cwd, event.input.path);
531
+ const entry = {
532
+ key: allowKey("edit", resolvedPath, ctx.cwd),
533
+ kind: "edit" as const,
534
+ value: resolvedPath,
535
+ cwd: normalizeCwd(ctx.cwd),
536
+ label: resolvedPath,
537
+ };
538
+ if (isAllowed(entry.key)) return;
539
+
540
+ const decision = await confirmOrBlock(
541
+ ctx,
542
+ "Protected file edit",
543
+ `Edit protected path '${event.input.path}'?`,
544
+ `Blocked edit to protected path '${event.input.path}' in non-interactive mode`,
545
+ );
546
+ const scope = allowedScope(decision);
547
+ if (scope) rememberAllow(entry, scope, ctx);
548
+ if (decision && "block" in decision) return decision;
549
+ return;
550
+ }
551
+ });
552
+ }