decorated-pi 0.2.0 → 0.2.2

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.
@@ -1,371 +0,0 @@
1
- /**
2
- * Safety — 安全防护模块
3
- *
4
- * - Command Guard: 拦截危险 bash 命令与 shell 覆盖写入(枚举式)
5
- * - Protected Paths: 禁止写入敏感路径
6
- * - Write Guard: 覆盖非空文件前确认
7
- * - Secret Redact: API Key / Token 自动掩码
8
- */
9
-
10
- import type {
11
- ExtensionAPI,
12
- ExtensionContext,
13
- ToolResultEvent,
14
- } from "@earendil-works/pi-coding-agent";
15
- import * as fs from "node:fs";
16
- import { resolve } from "node:path";
17
-
18
- // ─── 危险命令枚举 ──────────────────────────────────────────────────────────
19
-
20
- const DANGEROUS_COMMANDS: [string, string[]][] = [
21
- ["rm", []],
22
- ["sudo", []],
23
- ["npm", ["publish"]],
24
- ["svn", ["commit", "revert"]],
25
- ["git", ["reset", "restore", "clean", "push", "revert"]],
26
- ];
27
-
28
- const SAFE_REDIRECT_TARGETS = new Set([
29
- "/dev/null",
30
- "/dev/stdout",
31
- "/dev/stderr",
32
- ]);
33
-
34
- const SHELL_SEGMENT_BREAKS = new Set(["|", "&&", "||", ";"]);
35
- const SHELL_REDIRECT_OPERATORS = new Set([">", ">>", "1>", "1>>", "2>", "2>>", "&>", "&>>"]);
36
-
37
- function tokenizeShell(command: string): string[] {
38
- const tokens: string[] = [];
39
- let current = "";
40
- let quote: "'" | '"' | null = null;
41
-
42
- const pushCurrent = () => {
43
- if (current.length > 0) {
44
- tokens.push(current);
45
- current = "";
46
- }
47
- };
48
-
49
- for (let i = 0; i < command.length; i++) {
50
- const ch = command[i]!;
51
-
52
- if (quote) {
53
- if (ch === quote) {
54
- quote = null;
55
- } else if (ch === "\\" && quote === '"' && i + 1 < command.length) {
56
- current += command[i + 1]!;
57
- i += 1;
58
- } else {
59
- current += ch;
60
- }
61
- continue;
62
- }
63
-
64
- if (ch === "'" || ch === '"') {
65
- quote = ch;
66
- continue;
67
- }
68
-
69
- if (/\s/.test(ch)) {
70
- pushCurrent();
71
- continue;
72
- }
73
-
74
- if (ch === ";") {
75
- pushCurrent();
76
- tokens.push(";");
77
- continue;
78
- }
79
-
80
- if (ch === "|" || ch === "&") {
81
- if (i + 1 < command.length && command[i + 1] === ch) {
82
- pushCurrent();
83
- tokens.push(ch + ch);
84
- i += 1;
85
- continue;
86
- }
87
- if (ch === "|") {
88
- pushCurrent();
89
- tokens.push("|");
90
- continue;
91
- }
92
- }
93
-
94
- if (ch === ">") {
95
- let op = ">";
96
- if (i + 1 < command.length && command[i + 1] === ">") {
97
- op = ">>";
98
- i += 1;
99
- }
100
- if (current === "&" || /^\d+$/.test(current)) {
101
- op = current + op;
102
- current = "";
103
- } else {
104
- pushCurrent();
105
- }
106
- tokens.push(op);
107
- continue;
108
- }
109
-
110
- current += ch;
111
- }
112
-
113
- pushCurrent();
114
- return tokens;
115
- }
116
-
117
- function isExistingRegularFile(target: string, cwd: string): boolean {
118
- if (!target || SAFE_REDIRECT_TARGETS.has(target)) return false;
119
- try {
120
- return fs.statSync(resolve(cwd, target)).isFile();
121
- } catch {
122
- return false;
123
- }
124
- }
125
-
126
- function collectDangerousReasons(command: string, cwd: string): string[] {
127
- const tokens = tokenizeShell(command);
128
- const reasons: string[] = [];
129
- const seen = new Set<string>();
130
-
131
- const addReason = (reason: string) => {
132
- if (seen.has(reason)) return;
133
- seen.add(reason);
134
- reasons.push(reason);
135
- };
136
-
137
- for (let i = 0; i < tokens.length; i++) {
138
- const token = tokens[i]!;
139
- if (SHELL_SEGMENT_BREAKS.has(token)) continue;
140
-
141
- for (const [cmd, subs] of DANGEROUS_COMMANDS) {
142
- const name = token.split("/").pop() ?? token;
143
- if (name !== cmd && name !== `${cmd}.exe`) continue;
144
- if (subs.length === 0) {
145
- addReason(`"${cmd}" is a dangerous command`);
146
- break;
147
- }
148
- const next = tokens[i + 1];
149
- if (next && subs.includes(next)) {
150
- addReason(`"${cmd} ${next}" is a dangerous command`);
151
- break;
152
- }
153
- }
154
-
155
- if (SHELL_REDIRECT_OPERATORS.has(token)) {
156
- const target = tokens[i + 1];
157
- if (target && isExistingRegularFile(target, cwd)) {
158
- addReason(`shell redirection would write to existing file "${target}"`);
159
- }
160
- continue;
161
- }
162
-
163
- const name = token.split("/").pop() ?? token;
164
- if (name !== "tee" && name !== "tee.exe") continue;
165
-
166
- for (let j = i + 1; j < tokens.length; j++) {
167
- const next = tokens[j]!;
168
- if (SHELL_SEGMENT_BREAKS.has(next)) break;
169
- if (next === "-a" || next === "--append") continue;
170
- if (next.startsWith("-")) continue;
171
- if (isExistingRegularFile(next, cwd)) {
172
- addReason(`"tee" would write to existing file "${next}"`);
173
- }
174
- }
175
- }
176
-
177
- return reasons;
178
- }
179
-
180
- function formatDangerousReasons(reasons: string[]): string | null {
181
- if (reasons.length === 0) return null;
182
- if (reasons.length === 1) return reasons[0]!;
183
- return `dangerous operations detected:\n- ${reasons.join("\n- ")}`;
184
- }
185
-
186
- function checkDangerous(command: string, cwd: string): string | null {
187
- return formatDangerousReasons(collectDangerousReasons(command, cwd));
188
- }
189
-
190
- // ─── Protected Paths ────────────────────────────────────────────────────────
191
-
192
- const PROTECTED_PATH_SEGMENTS = [
193
- ".env", ".git/", "node_modules/", ".ssh/",
194
- ".gnupg/", ".aws/", "secrets/", ".docker/",
195
- ];
196
- const PROTECTED_EXTENSIONS = [".pem", ".key", ".p12", ".pfx", ".keystore"];
197
- const PROTECTED_FILENAMES = [
198
- "id_rsa", "id_ed25519", "id_ecdsa",
199
- "authorized_keys", "known_hosts",
200
- ".env.local", ".env.production",
201
- ];
202
-
203
- function checkProtectedPath(filePath: string): string | null {
204
- const normalized = filePath.replace(/\\/g, "/");
205
- const filename = normalized.split("/").pop() ?? "";
206
- for (const seg of PROTECTED_PATH_SEGMENTS) {
207
- if (normalized.includes(seg)) return `path contains "${seg}"`;
208
- }
209
- for (const ext of PROTECTED_EXTENSIONS) {
210
- if (normalized.endsWith(ext)) return `file extension "${ext}"`;
211
- }
212
- for (const name of PROTECTED_FILENAMES) {
213
- if (filename === name) return `protected file "${name}"`;
214
- }
215
- return null;
216
- }
217
-
218
- // ─── Secret Redact ──────────────────────────────────────────────────────────
219
-
220
- import { createEngine } from "@secretlint/node";
221
-
222
- type SecretLintEngine = Awaited<ReturnType<typeof createEngine>>;
223
- type ToolTextContent = Extract<NonNullable<ToolResultEvent["content"]>[number], { type: "text" }>;
224
-
225
- let engine: SecretLintEngine | null = null;
226
-
227
- function maskSecret(text: string): string {
228
- if (text.length <= 8) return "********";
229
- return text.slice(0, 4) + "********" + text.slice(-4);
230
- }
231
-
232
- async function ensureEngine(): Promise<SecretLintEngine> {
233
- if (!engine) {
234
- engine = await createEngine({
235
- formatter: "json",
236
- color: false,
237
- maskSecrets: false,
238
- configFileJSON: {
239
- rules: [
240
- { id: "@secretlint/secretlint-rule-preset-recommend" },
241
- { id: "@secretlint/secretlint-rule-azure" },
242
- { id: "@secretlint/secretlint-rule-secp256k1-privatekey" },
243
- ],
244
- },
245
- });
246
- }
247
- return engine;
248
- }
249
-
250
- function extractRanges(jsonOutput: string): Array<{ start: number; end: number }> {
251
- try {
252
- const reports = JSON.parse(jsonOutput) as Array<{
253
- messages: Array<{ range: [number, number]; ruleId: string }>;
254
- }>;
255
- const ranges: Array<{ start: number; end: number }> = [];
256
- for (const report of reports) {
257
- for (const msg of report.messages) {
258
- ranges.push({ start: msg.range[0], end: msg.range[1] });
259
- }
260
- }
261
- const unique = new Map<string, { start: number; end: number }>();
262
- for (const r of ranges) unique.set(`${r.start}-${r.end}`, r);
263
- return [...unique.values()].sort((a, b) => b.start - a.start);
264
- } catch { return []; }
265
- }
266
-
267
- // ─── Setup ──────────────────────────────────────────────────────────────────
268
-
269
- export function setupSafety(pi: ExtensionAPI) {
270
- // ── Command Guard + Protected Paths + Write Guard (tool_call) ─────────
271
-
272
- pi.on("tool_call", async (event, ctx) => {
273
-
274
- // Gate 1: 危险命令
275
- if (event.toolName === "bash") {
276
- const command = (event.input as { command?: string }).command;
277
- if (command) {
278
- const danger = checkDangerous(command, ctx.cwd);
279
- if (danger) {
280
- if (!ctx.hasUI) {
281
- return { block: true, reason: `⛔ ${danger} (non-interactive)` };
282
- }
283
- const choice = await ctx.ui.select(
284
- `⚠️ ${danger}\n\nAllow execution?`,
285
- ["Block", "Allow once"],
286
- );
287
- if (!choice || choice === "Block") {
288
- return { block: true, reason: `⛔ ${danger}` };
289
- }
290
- }
291
- }
292
- }
293
-
294
- // Gate 2: 保护路径
295
- if (event.toolName === "write" || event.toolName === "edit") {
296
- const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
297
- if (filePath) {
298
- const danger = checkProtectedPath(filePath);
299
- if (danger) {
300
- if (!ctx.hasUI) {
301
- return { block: true, reason: `🔐 ${danger}` };
302
- }
303
- const choice = await ctx.ui.select(
304
- `🔐 ${danger}\n\nProceed?`,
305
- ["Block", "Allow once"],
306
- );
307
- if (!choice || choice === "Block") {
308
- return { block: true, reason: `🔐 ${danger}` };
309
- }
310
- }
311
- }
312
- }
313
-
314
- // Gate 3: 写保护(已有内容的文件禁止 write,直接返回信息给 agent)
315
- if (event.toolName === "write") {
316
- const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
317
- if (filePath) {
318
- try {
319
- const abs = resolve(ctx.cwd, filePath);
320
- if (fs.existsSync(abs) && fs.readFileSync(abs, "utf8").length > 0) {
321
- return { block: true, reason: "Overwriting a non-empty file is dangerous, use the edit tool instead!" };
322
- }
323
- } catch { /* file doesn't exist */ }
324
- }
325
- }
326
- });
327
-
328
- // ── Secret Redact (tool_result) ────────────────────────────────────────
329
-
330
- const handleToolResult = async (
331
- event: ToolResultEvent,
332
- ctx: ExtensionContext,
333
- ): Promise<{ content?: NonNullable<ToolResultEvent["content"]> } | void> => {
334
- if (!event.content || !Array.isArray(event.content)) return;
335
-
336
- const textParts: Array<{ index: number; text: string; item: ToolTextContent }> = [];
337
- for (let i = 0; i < event.content.length; i++) {
338
- const item = event.content[i];
339
- if (item.type === "text" && typeof item.text === "string" && item.text.length > 0) {
340
- textParts.push({ index: i, text: item.text, item });
341
- }
342
- }
343
- if (textParts.length === 0) return;
344
-
345
- const eng = await ensureEngine();
346
- let totalCount = 0;
347
- const newContent = [...event.content];
348
-
349
- for (const { index, text, item } of textParts) {
350
- const result = await eng.executeOnContent({ content: text, filePath: "tool-output.txt" });
351
- const ranges = extractRanges(result.output);
352
- if (ranges.length === 0) continue;
353
-
354
- totalCount += ranges.length;
355
- let redacted = text;
356
- for (const { start, end } of ranges) {
357
- const original = redacted.slice(start, end);
358
- redacted = redacted.slice(0, start) + maskSecret(original) + redacted.slice(end);
359
- }
360
- const updatedItem: ToolTextContent = { ...item, text: redacted };
361
- newContent[index] = updatedItem;
362
- }
363
-
364
- if (totalCount === 0) return;
365
- const label = totalCount === 1 ? "1 secret" : `${totalCount} secrets`;
366
- ctx.ui.notify(`🔐 Redacted ${label} in ${event.toolName} output`, "warning");
367
- return { content: newContent };
368
- };
369
-
370
- pi.on("tool_result", handleToolResult);
371
- }