@ia-ccun/code-agent-cli 0.0.52-beta.2 → 0.0.52-beta.4
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/config/agent/extensions/aicode-advisor.ts +13 -0
- package/config/agent/extensions/aicode-btw.ts +66 -14
- package/config/agent/extensions/aicode-cd.ts +834 -0
- package/config/agent/extensions/aicode-grillMe.ts +198 -0
- package/config/agent/extensions/aicode-pets.ts +362 -0
- package/config/agent/extensions/aicode-safety-guard.ts +550 -0
- package/config/agent/extensions/aicode-tips.ts +19 -10
- package/config/agent/extensions/aicode-todo-progress.ts +488 -0
- package/config/agent/extensions/custom.ts +0 -32
- package/config/agent/extensions/error-monitor.ts +1 -18
- package/config/agent/extensions/ponytail.ts +441 -0
- package/config/agent/extensions/tps.ts +87 -0
- package/config/agent/extensions/working-msg.ts +160 -66
- package/config/agent/settings.json +2 -1
- package/config/agent/skills/ponytail/SKILL.md +100 -0
- package/config/agent/skills/ponytail-audit/SKILL.md +40 -0
- package/config/agent/skills/ponytail-debt/SKILL.md +44 -0
- package/config/agent/skills/ponytail-help/SKILL.md +68 -0
- package/config/agent/skills/ponytail-review/SKILL.md +56 -0
- package/config/agent/skills/zread/README.md +115 -0
- package/config/agent/skills/zread/SKILL.md +103 -0
- package/config/agent/skills/zread/references/stdio-protocol.md +312 -0
- package/package.json +4 -2
- package/scripts/postinstall.js +0 -1
- package/config/agent/dangerous-patterns.json +0 -106
- package/config/agent/extensions/bash-check.ts +0 -227
|
@@ -0,0 +1,550 @@
|
|
|
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" ? "High-risk bash command" : "Dangerous bash command";
|
|
366
|
+
const impact = rule.level === "strong-confirm"
|
|
367
|
+
? "This can be difficult to undo. Verify the target, branch, and scope before allowing."
|
|
368
|
+
: "Verify the target and scope before allowing.";
|
|
369
|
+
const excerpt = matchingCommandExcerpt(rule, commandForMatching, highlight);
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
title,
|
|
373
|
+
message: [
|
|
374
|
+
`Reason: ${highlight(rule.label)} (${rule.category})`,
|
|
375
|
+
impact,
|
|
376
|
+
"",
|
|
377
|
+
"DANGEROUS LINE(S) AND CONTEXT",
|
|
378
|
+
`Lines marked with ${highlight("!!!")} triggered the safety rule; the matched pattern is wrapped as ${highlight(">>> pattern <<<")}.`,
|
|
379
|
+
"────────────────────────────────────────",
|
|
380
|
+
excerpt,
|
|
381
|
+
"────────────────────────────────────────",
|
|
382
|
+
"",
|
|
383
|
+
"Execute anyway?",
|
|
384
|
+
].join("\n"),
|
|
385
|
+
nonInteractiveReason: `Blocked ${rule.category} command (${rule.label}) in non-interactive mode`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function updateStatus(ctx: ExtensionContext, enabled: boolean): void {
|
|
390
|
+
if (!ctx.hasUI) return;
|
|
391
|
+
if (enabled) {
|
|
392
|
+
ctx.ui.setStatus(STATUS_KEY, "");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("success", "🔓!"));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export default function safetyGuard(pi: ExtensionAPI) {
|
|
399
|
+
let enabled = true;
|
|
400
|
+
const sessionAllow = new Map<string, AllowEntry>();
|
|
401
|
+
let permanentAllow = readAllowStore();
|
|
402
|
+
|
|
403
|
+
const isAllowed = (key: string): boolean => sessionAllow.has(key) || permanentAllow.entries.some((entry) => entry.key === key);
|
|
404
|
+
|
|
405
|
+
const rememberAllow = (entry: Omit<AllowEntry, "createdAt">, scope: AllowScope, ctx: ExtensionContext): void => {
|
|
406
|
+
const next: AllowEntry = { ...entry, createdAt: new Date().toISOString() };
|
|
407
|
+
if (scope === "session") {
|
|
408
|
+
sessionAllow.set(entry.key, next);
|
|
409
|
+
if (ctx.hasUI) ctx.ui.notify(`Allowed for this session: ${formatAllowEntry(next)}`, "info");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
addPermanentAllow(entry);
|
|
414
|
+
permanentAllow = readAllowStore();
|
|
415
|
+
if (ctx.hasUI) ctx.ui.notify(`Permanently allowed: ${formatAllowEntry(next)}`, "info");
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
pi.registerCommand("safety-guard", {
|
|
419
|
+
description: "Safety guard control: on | off | status | allow-list | allow-clear-session | allow-clear-permanent",
|
|
420
|
+
handler: async (args, ctx) => {
|
|
421
|
+
const cmd = args?.trim().toLowerCase();
|
|
422
|
+
if (!cmd || cmd === "status") {
|
|
423
|
+
permanentAllow = readAllowStore();
|
|
424
|
+
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");
|
|
425
|
+
updateStatus(ctx, enabled);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (cmd === "on") {
|
|
429
|
+
enabled = true;
|
|
430
|
+
updateStatus(ctx, enabled);
|
|
431
|
+
if (ctx.hasUI) ctx.ui.notify("Safety guard enabled\n\nTo add more safety rules, contact xujianjiang", "info");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (cmd === "off") {
|
|
435
|
+
enabled = false;
|
|
436
|
+
updateStatus(ctx, enabled);
|
|
437
|
+
if (ctx.hasUI) ctx.ui.notify("Safety guard disabled", "warning");
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (cmd === "allow-list") {
|
|
441
|
+
permanentAllow = readAllowStore();
|
|
442
|
+
const lines = [
|
|
443
|
+
"Session allows:",
|
|
444
|
+
...(sessionAllow.size ? [...sessionAllow.values()].map(formatAllowEntry) : ["(none)"]),
|
|
445
|
+
"",
|
|
446
|
+
`Permanent allows (${ALLOW_STORE_PATH}):`,
|
|
447
|
+
...(permanentAllow.entries.length ? permanentAllow.entries.map(formatAllowEntry) : ["(none)"]),
|
|
448
|
+
];
|
|
449
|
+
if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (cmd === "allow-clear-session") {
|
|
453
|
+
sessionAllow.clear();
|
|
454
|
+
if (ctx.hasUI) ctx.ui.notify("Cleared safety guard session allow-list", "info");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (cmd === "allow-clear-permanent") {
|
|
458
|
+
writeAllowStore({ version: 1, entries: [] });
|
|
459
|
+
permanentAllow = readAllowStore();
|
|
460
|
+
if (ctx.hasUI) ctx.ui.notify("Cleared safety guard permanent allow-list", "info");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (ctx.hasUI) ctx.ui.notify("Usage: /safety-guard on|off|status|allow-list|allow-clear-session|allow-clear-permanent", "warning");
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
468
|
+
updateStatus(ctx, enabled);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
472
|
+
if (!enabled) return;
|
|
473
|
+
if (isToolCallEventType("bash", event)) {
|
|
474
|
+
const command = event.input.command ?? "";
|
|
475
|
+
const commandForMatching = stripHeredocBodies(command);
|
|
476
|
+
const shellMatch = DANGEROUS_BASH_RULES.find((entry) => entry.pattern.test(commandForMatching));
|
|
477
|
+
const databaseMatch = DATABASE_RULES.find((entry) => entry.pattern.test(command));
|
|
478
|
+
const match = shellMatch ?? databaseMatch;
|
|
479
|
+
const matchedCommandText = databaseMatch && !shellMatch ? command : commandForMatching;
|
|
480
|
+
if (!match) return;
|
|
481
|
+
|
|
482
|
+
const entry = {
|
|
483
|
+
key: allowKey("bash", command, ctx.cwd),
|
|
484
|
+
kind: "bash" as const,
|
|
485
|
+
value: command,
|
|
486
|
+
cwd: normalizeCwd(ctx.cwd),
|
|
487
|
+
label: match.label,
|
|
488
|
+
category: match.category,
|
|
489
|
+
};
|
|
490
|
+
if (isAllowed(entry.key)) return;
|
|
491
|
+
|
|
492
|
+
const prompt = formatRuleMessage(match, matchedCommandText, (value) => ctx.ui.theme.fg("warning", value));
|
|
493
|
+
const decision = await confirmOrBlock(ctx, prompt.title, prompt.message, prompt.nonInteractiveReason);
|
|
494
|
+
const scope = allowedScope(decision);
|
|
495
|
+
if (scope) rememberAllow(entry, scope, ctx);
|
|
496
|
+
if (decision && "block" in decision) return decision;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (isToolCallEventType("write", event)) {
|
|
501
|
+
if (!isProtectedPath(event.input.path, ctx.cwd)) return;
|
|
502
|
+
|
|
503
|
+
const resolvedPath = path.resolve(ctx.cwd, event.input.path);
|
|
504
|
+
const entry = {
|
|
505
|
+
key: allowKey("write", resolvedPath, ctx.cwd),
|
|
506
|
+
kind: "write" as const,
|
|
507
|
+
value: resolvedPath,
|
|
508
|
+
cwd: normalizeCwd(ctx.cwd),
|
|
509
|
+
label: resolvedPath,
|
|
510
|
+
};
|
|
511
|
+
if (isAllowed(entry.key)) return;
|
|
512
|
+
|
|
513
|
+
const decision = await confirmOrBlock(
|
|
514
|
+
ctx,
|
|
515
|
+
"Protected file write",
|
|
516
|
+
`Write to protected path '${event.input.path}'?`,
|
|
517
|
+
`Blocked write to protected path '${event.input.path}' in non-interactive mode`,
|
|
518
|
+
);
|
|
519
|
+
const scope = allowedScope(decision);
|
|
520
|
+
if (scope) rememberAllow(entry, scope, ctx);
|
|
521
|
+
if (decision && "block" in decision) return decision;
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (isToolCallEventType("edit", event)) {
|
|
526
|
+
if (!isProtectedPath(event.input.path, ctx.cwd)) return;
|
|
527
|
+
|
|
528
|
+
const resolvedPath = path.resolve(ctx.cwd, event.input.path);
|
|
529
|
+
const entry = {
|
|
530
|
+
key: allowKey("edit", resolvedPath, ctx.cwd),
|
|
531
|
+
kind: "edit" as const,
|
|
532
|
+
value: resolvedPath,
|
|
533
|
+
cwd: normalizeCwd(ctx.cwd),
|
|
534
|
+
label: resolvedPath,
|
|
535
|
+
};
|
|
536
|
+
if (isAllowed(entry.key)) return;
|
|
537
|
+
|
|
538
|
+
const decision = await confirmOrBlock(
|
|
539
|
+
ctx,
|
|
540
|
+
"Protected file edit",
|
|
541
|
+
`Edit protected path '${event.input.path}'?`,
|
|
542
|
+
`Blocked edit to protected path '${event.input.path}' in non-interactive mode`,
|
|
543
|
+
);
|
|
544
|
+
const scope = allowedScope(decision);
|
|
545
|
+
if (scope) rememberAllow(entry, scope, ctx);
|
|
546
|
+
if (decision && "block" in decision) return decision;
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
}
|
|
@@ -164,7 +164,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
164
164
|
let currentTipIndex = -1;
|
|
165
165
|
let autoHideTimeoutId: NodeJS.Timeout | undefined;
|
|
166
166
|
let autoShowTimeoutId: NodeJS.Timeout | undefined;
|
|
167
|
-
let
|
|
167
|
+
let currentUI: any = undefined; // 保存当前有效的 UI context
|
|
168
168
|
let isAutoPlaying = false;
|
|
169
169
|
|
|
170
170
|
// 基于 #49bccf (73, 188, 207) 的随机颜色生成
|
|
@@ -178,8 +178,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
178
|
};
|
|
179
179
|
|
|
180
180
|
const hideTip = () => {
|
|
181
|
-
if (!
|
|
182
|
-
|
|
181
|
+
if (!currentUI) return;
|
|
182
|
+
currentUI.setWidget("aicode-tips", [], { placement: "aboveEditor" });
|
|
183
183
|
};
|
|
184
184
|
|
|
185
185
|
const clearAllTimers = () => {
|
|
@@ -202,7 +202,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
202
202
|
};
|
|
203
203
|
|
|
204
204
|
const showRandomTip = (isInternalCall = false) => {
|
|
205
|
-
if (!
|
|
205
|
+
if (!currentUI) return;
|
|
206
206
|
|
|
207
207
|
// 清除之前的定时器(但内部轮播调用时不清除,避免中断轮播)
|
|
208
208
|
if (!isInternalCall) {
|
|
@@ -218,11 +218,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
218
218
|
// 使用随机颜色
|
|
219
219
|
const color = getRandomTipColor();
|
|
220
220
|
const coloredTip = `${color}tips: ${tip}\x1b[0m`;
|
|
221
|
-
|
|
221
|
+
currentUI.setWidget("aicode-tips", [coloredTip], { placement: "aboveEditor" });
|
|
222
222
|
|
|
223
223
|
// 自动轮播:10 秒后直接显示下一条
|
|
224
224
|
if (isAutoPlaying) {
|
|
225
|
-
autoHideTimeoutId = setTimeout(() =>
|
|
225
|
+
autoHideTimeoutId = setTimeout(() => {
|
|
226
|
+
try {
|
|
227
|
+
showRandomTip(true);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// 如果出现错误(比如 stale context),清理定时器
|
|
230
|
+
console.error('aicode-tips extension error:', error);
|
|
231
|
+
stopAutoPlay();
|
|
232
|
+
currentUI = undefined;
|
|
233
|
+
}
|
|
234
|
+
}, 10000);
|
|
226
235
|
}
|
|
227
236
|
};
|
|
228
237
|
|
|
@@ -238,22 +247,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
238
247
|
|
|
239
248
|
// 会话开始时启动自动轮播
|
|
240
249
|
pi.on("session_start", (_event, ctx) => {
|
|
241
|
-
|
|
250
|
+
currentUI = ctx.ui;
|
|
242
251
|
startAutoPlay();
|
|
243
252
|
});
|
|
244
253
|
|
|
245
254
|
// 会话结束时清理
|
|
246
255
|
pi.on("session_shutdown", () => {
|
|
247
256
|
stopAutoPlay();
|
|
248
|
-
|
|
249
|
-
|
|
257
|
+
currentUI?.setWidget("aicode-tips", undefined);
|
|
258
|
+
currentUI = undefined;
|
|
250
259
|
});
|
|
251
260
|
|
|
252
261
|
// 手动切换技巧
|
|
253
262
|
pi.registerCommand("tip", {
|
|
254
263
|
description: "显示一个新的 aicode 使用技巧",
|
|
255
264
|
handler: async (_args, ctx) => {
|
|
256
|
-
|
|
265
|
+
currentUI = ctx.ui;
|
|
257
266
|
showRandomTip();
|
|
258
267
|
},
|
|
259
268
|
});
|