pi-command-guardian 0.2.0 → 0.3.0

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/src/index.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { tmpdir } from "node:os";
2
+ import type { ExtensionAPI, ToolCallEvent } from "@earendil-works/pi-coding-agent";
3
+ import { parseShellCommand } from "./command-parser.js";
4
+ import type { ShellParseResult } from "./command-parser.js";
5
+ import { assessDeletionCommand } from "./deletion-guard.js";
6
+
7
+ const EXPLICIT_DELETION_PATTERN = /\b(?:rm|rmdir|unlink|del|erase|Remove-Item|mkfs(?:\.[A-Za-z0-9_-]+)?|wipefs|diskpart|DROP\s+(?:DATABASE|TABLE)|TRUNCATE)\b|\bdd\b[^\r\n]*\bof=(?:\/dev\/|\\\\\.\\PhysicalDrive)/i;
8
+ const MANUAL_REVIEW_GUIDANCE = "请审阅操作和目标后,自行决定是否手动删除。";
9
+ const STATIC_TARGET_GUIDANCE = "请先确定所有实际删除目标,再将命令简化为语法完整、直接包含静态目标的 Bash 删除命令后重新提交。";
10
+
11
+ /** Extension 入口的可替换副作用。 */
12
+ export interface CommandGuardianDependencies {
13
+ parseCommand?: (command: string) => Promise<ShellParseResult>;
14
+ tempDirectory?: string;
15
+ }
16
+
17
+ function invalidInputReason(code: string): string {
18
+ return `Command Guardian 阻止了本次操作,操作尚未执行:${code}`;
19
+ }
20
+
21
+ /** 创建只审查显式破坏性 Bash 命令的 Pi Extension。 */
22
+ export function createCommandGuardianExtension(
23
+ dependencies: CommandGuardianDependencies = {},
24
+ ): (pi: ExtensionAPI) => void {
25
+ const parseCommand = dependencies.parseCommand ?? parseShellCommand;
26
+ const tempDirectory = dependencies.tempDirectory ?? tmpdir();
27
+
28
+ return (pi: ExtensionAPI): void => {
29
+ pi.on("tool_call", async (event: ToolCallEvent, ctx) => {
30
+ if (event.toolName !== "bash") return undefined;
31
+ const input = event.input as { command?: unknown };
32
+ if (typeof input.command !== "string" || input.command.trim().length === 0) {
33
+ return { block: true, reason: invalidInputReason("PCG_INVALID_BASH_INPUT") };
34
+ }
35
+
36
+ try {
37
+ const parsed = await parseCommand(input.command);
38
+ if (parsed.status !== "parsed") {
39
+ return EXPLICIT_DELETION_PATTERN.test(input.command)
40
+ ? {
41
+ block: true,
42
+ reason: `Command Guardian 阻止了本次删除操作,操作尚未执行:命令结构无法可靠确定。${STATIC_TARGET_GUIDANCE}`,
43
+ }
44
+ : undefined;
45
+ }
46
+
47
+ const result = await assessDeletionCommand(parsed, ctx.cwd, tempDirectory);
48
+ if (result.decision === "allow") return undefined;
49
+ if (result.decision === "forbidden") {
50
+ return {
51
+ block: true,
52
+ reason: `Command Guardian 永久阻止了本次操作,操作尚未执行:${result.reason}。该判断无法由模型重试覆盖。`,
53
+ };
54
+ }
55
+ const guidance = result.guidance === "static-target"
56
+ ? STATIC_TARGET_GUIDANCE
57
+ : MANUAL_REVIEW_GUIDANCE;
58
+ return {
59
+ block: true,
60
+ reason: `Command Guardian 阻止了本次删除操作,操作尚未执行:${result.reason}。${guidance}`,
61
+ };
62
+ } catch {
63
+ return { block: true, reason: invalidInputReason("PCG_INTERNAL_ERROR") };
64
+ }
65
+ });
66
+ };
67
+ }
68
+
69
+ /** Pi package 加载入口。 */
70
+ export default createCommandGuardianExtension();
@@ -1,43 +0,0 @@
1
- import type * as TreeSitterTypes from "@vscode/tree-sitter-wasm";
2
- /** 与 Tree-sitter 生命周期解耦的 Bash 语法节点。 */
3
- export interface ShellSyntaxNode {
4
- type: string;
5
- named: boolean;
6
- startByte: number;
7
- endByte: number;
8
- field?: string;
9
- value?: string;
10
- children: ShellSyntaxNode[];
11
- }
12
- /** Bash 语法错误或缺失节点的位置。 */
13
- export interface ShellSyntaxIssue {
14
- type: string;
15
- missing: boolean;
16
- startByte: number;
17
- endByte: number;
18
- }
19
- /** 成功解析的 Bash 命令。 */
20
- export interface ParsedShellCommand {
21
- status: "parsed";
22
- shell: "bash";
23
- source: string;
24
- root: ShellSyntaxNode;
25
- }
26
- /** 无法可靠解析的 Bash 命令。 */
27
- export interface UnparsedShellCommand {
28
- status: "unparsed";
29
- shell: "bash";
30
- source: string;
31
- reason: "empty_command" | "syntax_error" | "parser_unavailable" | "parse_failure";
32
- issues: ShellSyntaxIssue[];
33
- }
34
- /** Bash 解析结果。 */
35
- export type ShellParseResult = ParsedShellCommand | UnparsedShellCommand;
36
- type ShellParser = Pick<TreeSitterTypes.Parser, "reset" | "parse">;
37
- /** Bash Parser 加载边界,只暴露当前解析流程需要的方法。 */
38
- export type ShellParserLoader = () => Promise<ShellParser>;
39
- /** 创建具有独立初始化缓存和串行队列的 Shell 命令解析函数。 */
40
- export declare function createShellCommandParser(loadParser: ShellParserLoader): (source: string) => Promise<ShellParseResult>;
41
- /** 使用进程内共享的 Tree-sitter Bash Parser 解析命令。 */
42
- export declare const parseShellCommand: (source: string) => Promise<ShellParseResult>;
43
- export {};
@@ -1,109 +0,0 @@
1
- import { createRequire } from "node:module";
2
- const require = createRequire(import.meta.url);
3
- const TreeSitter = require("@vscode/tree-sitter-wasm");
4
- const runtimePath = require.resolve("@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm");
5
- const bashGrammarPath = require.resolve("@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm");
6
- async function loadBashParser() {
7
- await TreeSitter.Parser.init({ locateFile: () => runtimePath });
8
- const language = await TreeSitter.Language.load(bashGrammarPath);
9
- return new TreeSitter.Parser().setLanguage(language);
10
- }
11
- function unparsed(source, reason, issues = []) {
12
- return { status: "unparsed", shell: "bash", source, reason, issues };
13
- }
14
- function copySyntaxNode(node, field) {
15
- const children = [];
16
- for (let index = 0; index < node.childCount; index += 1) {
17
- const child = node.child(index);
18
- if (child === null) {
19
- continue;
20
- }
21
- const childField = node.fieldNameForChild(index) ?? undefined;
22
- children.push(copySyntaxNode(child, childField));
23
- }
24
- const copied = {
25
- type: node.type,
26
- named: node.isNamed,
27
- startByte: node.startIndex,
28
- endByte: node.endIndex,
29
- children,
30
- };
31
- if (field !== undefined) {
32
- copied.field = field;
33
- }
34
- if (children.length === 0) {
35
- copied.value = node.text;
36
- }
37
- return copied;
38
- }
39
- function collectSyntaxIssues(node, issues) {
40
- if (node.isError || node.isMissing) {
41
- issues.push({
42
- type: node.isError ? "ERROR" : node.type,
43
- missing: node.isMissing,
44
- startByte: node.startIndex,
45
- endByte: node.endIndex,
46
- });
47
- }
48
- for (const child of node.children) {
49
- if (child !== null) {
50
- collectSyntaxIssues(child, issues);
51
- }
52
- }
53
- }
54
- async function parseWith(source, getParser) {
55
- let parser;
56
- try {
57
- parser = await getParser();
58
- }
59
- catch {
60
- return unparsed(source, "parser_unavailable");
61
- }
62
- let tree;
63
- try {
64
- parser.reset();
65
- tree = parser.parse(source);
66
- }
67
- catch {
68
- return unparsed(source, "parse_failure");
69
- }
70
- if (tree === null) {
71
- return unparsed(source, "parse_failure");
72
- }
73
- try {
74
- const root = tree.rootNode;
75
- if (root.hasError) {
76
- const issues = [];
77
- collectSyntaxIssues(root, issues);
78
- return unparsed(source, "syntax_error", issues);
79
- }
80
- return {
81
- status: "parsed",
82
- shell: "bash",
83
- source,
84
- root: copySyntaxNode(root),
85
- };
86
- }
87
- finally {
88
- tree.delete();
89
- }
90
- }
91
- /** 创建具有独立初始化缓存和串行队列的 Shell 命令解析函数。 */
92
- export function createShellCommandParser(loadParser) {
93
- let parserPromise;
94
- let parseQueue = Promise.resolve();
95
- const getParser = () => {
96
- parserPromise ??= loadParser();
97
- return parserPromise;
98
- };
99
- return (source) => {
100
- if (typeof source !== "string" || source.trim().length === 0) {
101
- return Promise.resolve(unparsed(source, "empty_command"));
102
- }
103
- const result = parseQueue.then(() => parseWith(source, getParser));
104
- parseQueue = result.then(() => undefined, () => undefined);
105
- return result;
106
- };
107
- }
108
- /** 使用进程内共享的 Tree-sitter Bash Parser 解析命令。 */
109
- export const parseShellCommand = createShellCommandParser(loadBashParser);
@@ -1,16 +0,0 @@
1
- import type { ShellParseResult } from "./command-parser.js";
2
- /** 显式删除命令的本地判断。 */
3
- export type DeletionDecision = {
4
- decision: "allow";
5
- } | {
6
- decision: "block";
7
- reason: string;
8
- guidance: "static-target" | "manual-review";
9
- } | {
10
- decision: "forbidden";
11
- reason: string;
12
- };
13
- /** 判断已解析命令中的显式文件、数据库和磁盘破坏操作。 */
14
- export declare function assessDeletionCommand(parsed: Extract<ShellParseResult, {
15
- status: "parsed";
16
- }>, cwd: string, tempDirectory: string): Promise<DeletionDecision>;
@@ -1,414 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { basename, isAbsolute, parse, relative, resolve, sep } from "node:path";
3
- import { parseShellCommand } from "./command-parser.js";
4
- const ALLOW = { decision: "allow" };
5
- const BLOCK_EXTERNAL = {
6
- decision: "block",
7
- reason: "删除目标位于工作区和系统临时目录之外或是受控目录根",
8
- guidance: "manual-review",
9
- };
10
- const BLOCK_DYNAMIC = {
11
- decision: "block",
12
- reason: "删除目标包含动态内容,无法静态确定",
13
- guidance: "static-target",
14
- };
15
- const BLOCK_INDETERMINATE = {
16
- decision: "block",
17
- reason: "删除目标为空、缺失或无法静态确定",
18
- guidance: "static-target",
19
- };
20
- const BLOCK_NESTED_DEPTH = {
21
- decision: "block",
22
- reason: "嵌套 Shell 层级超过静态分析上限",
23
- guidance: "static-target",
24
- };
25
- const FORBIDDEN_SYSTEM = {
26
- decision: "forbidden",
27
- reason: "操作会删除系统关键目录、用户目录或数据库数据目录",
28
- };
29
- const FORBIDDEN_DISK = {
30
- decision: "forbidden",
31
- reason: "操作会格式化、擦除或覆盖磁盘设备",
32
- };
33
- const BLOCK_DATABASE = {
34
- decision: "block",
35
- reason: "操作包含显式数据库删除",
36
- guidance: "manual-review",
37
- };
38
- const BLOCK_XARGS = {
39
- decision: "block",
40
- reason: "删除目标来自 xargs 输入,无法静态确定",
41
- guidance: "static-target",
42
- };
43
- const BLOCK_POWERSHELL = {
44
- decision: "block",
45
- reason: "PowerShell 删除目标无法由 Bash 解析器静态确定",
46
- guidance: "static-target",
47
- };
48
- const ENV_OPTIONS_WITH_VALUES = new Set([
49
- "-a", "--argv0", "-C", "--chdir", "-S", "--split-string", "-u", "--unset",
50
- ]);
51
- const SUDO_OPTIONS_WITH_VALUES = new Set([
52
- "-a", "--auth-type", "-C", "--close-from", "-D", "--chdir", "-g", "--group",
53
- "-h", "--host", "-p", "--prompt", "-R", "--chroot", "-r", "--role", "-t", "--type",
54
- "-T", "--command-timeout", "-u", "--user",
55
- ]);
56
- function normalizeExecutable(value) {
57
- return basename(value.replaceAll("\\", "/")).replace(/\.exe$/i, "").toLowerCase();
58
- }
59
- function containsBraceExpansion(value) {
60
- return /\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(value);
61
- }
62
- function staticText(node) {
63
- if (["simple_expansion", "expansion", "command_substitution", "process_substitution"].includes(node.type)) {
64
- return { value: "", dynamic: true };
65
- }
66
- if (node.children.length === 0) {
67
- const value = node.value ?? "";
68
- if (node.type === "raw_string" && value.length >= 2) {
69
- return { value: value.slice(1, -1), dynamic: false };
70
- }
71
- if ((node.type === "\"" || node.type === "'") && (value === "\"" || value === "'")) {
72
- return { value: "", dynamic: false };
73
- }
74
- return { value, dynamic: node.type === "word" && value.startsWith("~") };
75
- }
76
- let value = "";
77
- let dynamic = false;
78
- for (const child of node.children) {
79
- const part = staticText(child);
80
- value += part.value;
81
- dynamic ||= part.dynamic;
82
- }
83
- if (node.type === "concatenation" && containsBraceExpansion(value)) {
84
- dynamic = true;
85
- }
86
- return { value, dynamic };
87
- }
88
- function commandFromArguments(args) {
89
- const [executable, ...commandArgs] = args;
90
- if (executable === undefined || executable.dynamic || executable.value.length === 0) {
91
- return undefined;
92
- }
93
- return {
94
- executable: normalizeExecutable(executable.value),
95
- args: commandArgs,
96
- };
97
- }
98
- function unwrapCommandBuiltin(command) {
99
- let index = 0;
100
- while (index < command.args.length) {
101
- const argument = command.args[index];
102
- if (argument === undefined)
103
- break;
104
- if (argument.dynamic)
105
- return undefined;
106
- if (argument.value === "--") {
107
- index += 1;
108
- break;
109
- }
110
- if (/^-[^-]*[vV]/.test(argument.value))
111
- return undefined;
112
- if (argument.value === "-p") {
113
- index += 1;
114
- continue;
115
- }
116
- if (argument.value.startsWith("-"))
117
- return undefined;
118
- break;
119
- }
120
- return commandFromArguments(command.args.slice(index));
121
- }
122
- function unwrapEnv(command) {
123
- let index = 0;
124
- while (index < command.args.length) {
125
- const argument = command.args[index];
126
- if (argument === undefined)
127
- break;
128
- if (argument.dynamic)
129
- return undefined;
130
- const value = argument.value;
131
- if (value === "--") {
132
- index += 1;
133
- break;
134
- }
135
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(value)) {
136
- index += 1;
137
- continue;
138
- }
139
- if (ENV_OPTIONS_WITH_VALUES.has(value)) {
140
- index += 2;
141
- continue;
142
- }
143
- if (/^(?:--(?:argv0|chdir|split-string|unset)=|-[aCSu].+)/.test(value) || ["-0", "--debug", "-i", "--ignore-environment", "--null"].includes(value)) {
144
- index += 1;
145
- continue;
146
- }
147
- if (value.startsWith("-"))
148
- return undefined;
149
- break;
150
- }
151
- return commandFromArguments(command.args.slice(index));
152
- }
153
- function unwrapSudo(command) {
154
- let index = 0;
155
- while (index < command.args.length) {
156
- const argument = command.args[index];
157
- if (argument === undefined)
158
- break;
159
- if (argument.dynamic)
160
- return undefined;
161
- const value = argument.value;
162
- if (value === "--") {
163
- index += 1;
164
- break;
165
- }
166
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(value)) {
167
- index += 1;
168
- continue;
169
- }
170
- if (SUDO_OPTIONS_WITH_VALUES.has(value)) {
171
- index += 2;
172
- continue;
173
- }
174
- if (/^--[^=]+=/.test(value) || /^-[aCDghpRrtTu].+/.test(value)) {
175
- index += 1;
176
- continue;
177
- }
178
- if (value.startsWith("-")) {
179
- index += 1;
180
- continue;
181
- }
182
- break;
183
- }
184
- return commandFromArguments(command.args.slice(index));
185
- }
186
- function unwrapTransparentCommand(command) {
187
- let current = command;
188
- while (true) {
189
- let unwrapped;
190
- switch (current.executable) {
191
- case "command":
192
- unwrapped = unwrapCommandBuiltin(current);
193
- break;
194
- case "env":
195
- unwrapped = unwrapEnv(current);
196
- break;
197
- case "sudo":
198
- unwrapped = unwrapSudo(current);
199
- break;
200
- default:
201
- return current;
202
- }
203
- if (unwrapped === undefined)
204
- return current;
205
- current = unwrapped;
206
- }
207
- }
208
- function extractCommands(root) {
209
- const commands = [];
210
- const visit = (node) => {
211
- if (node.type === "command") {
212
- const nameNode = node.children.find((child) => child.field === "name");
213
- if (nameNode !== undefined) {
214
- const name = staticText(nameNode);
215
- if (name.value.length > 0) {
216
- commands.push({
217
- executable: normalizeExecutable(name.value),
218
- args: node.children
219
- .filter((child) => child.field === "argument")
220
- .map(staticText),
221
- });
222
- }
223
- }
224
- }
225
- node.children.forEach(visit);
226
- };
227
- visit(root);
228
- return commands;
229
- }
230
- function strictDescendant(root, target) {
231
- const child = relative(resolve(root), resolve(target));
232
- return child.length > 0 && child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child);
233
- }
234
- function sameOrDescendant(root, target) {
235
- return resolve(root) === resolve(target) || strictDescendant(root, target);
236
- }
237
- function systemRoots() {
238
- const roots = [homedir()];
239
- if (process.platform === "win32") {
240
- for (const value of [process.env.SystemRoot, process.env.ProgramFiles, process.env.ProgramData]) {
241
- if (value !== undefined && value.length > 0)
242
- roots.push(value);
243
- }
244
- }
245
- else {
246
- roots.push("/etc", "/usr", "/var", "/boot", "/bin", "/sbin", "/lib", "/lib64");
247
- }
248
- return roots;
249
- }
250
- function isDatabaseDataTarget(rawTarget, target) {
251
- const normalized = rawTarget.replaceAll("\\", "/").toLowerCase();
252
- if (/^\/(?:var\/lib\/(?:postgresql|mysql|mongodb)|var\/opt\/mssql)(?:\/|$)/.test(normalized)) {
253
- return true;
254
- }
255
- return process.platform === "win32" && /\/(?:postgresql|mysql|mongodb|mssql)(?:\/|$)/i.test(target.replaceAll("\\", "/"));
256
- }
257
- function classifyTarget(rawTarget, cwd, tempDirectory) {
258
- if (rawTarget.dynamic)
259
- return BLOCK_DYNAMIC;
260
- if (rawTarget.value.length === 0)
261
- return BLOCK_INDETERMINATE;
262
- const target = resolve(cwd, rawTarget.value);
263
- if (target === parse(target).root)
264
- return FORBIDDEN_SYSTEM;
265
- if (isDatabaseDataTarget(rawTarget.value, target))
266
- return FORBIDDEN_SYSTEM;
267
- if (strictDescendant(cwd, target) || strictDescendant(tempDirectory, target))
268
- return ALLOW;
269
- if (systemRoots().some((root) => sameOrDescendant(root, target)))
270
- return FORBIDDEN_SYSTEM;
271
- return BLOCK_EXTERNAL;
272
- }
273
- function deletionOperands(args) {
274
- const operands = [];
275
- let optionsEnded = false;
276
- for (const argument of args) {
277
- if (!optionsEnded && argument.value === "--") {
278
- optionsEnded = true;
279
- }
280
- else if (optionsEnded || !argument.value.startsWith("-")) {
281
- operands.push(argument);
282
- }
283
- }
284
- return operands;
285
- }
286
- function combine(decisions) {
287
- return decisions.find((item) => item.decision === "forbidden")
288
- ?? decisions.find((item) => item.decision === "block" && item.guidance === "manual-review")
289
- ?? decisions.find((item) => item.decision === "block")
290
- ?? ALLOW;
291
- }
292
- function assessFileDeletion(command, cwd, tempDirectory) {
293
- if (!["rm", "rmdir", "unlink", "del", "erase", "rd"].includes(command.executable))
294
- return undefined;
295
- const targets = deletionOperands(command.args);
296
- if (targets.length === 0)
297
- return BLOCK_INDETERMINATE;
298
- return combine(targets.map((target) => classifyTarget(target, cwd, tempDirectory)));
299
- }
300
- function findDeletionRoots(command) {
301
- const deletesMatches = command.args.some((argument) => argument.value === "-delete");
302
- const callbackIndex = command.args.findIndex((argument) => ["-exec", "-execdir", "-ok", "-okdir"].includes(argument.value));
303
- const callback = callbackIndex < 0
304
- ? undefined
305
- : commandFromArguments(command.args.slice(callbackIndex + 1));
306
- const callbackExecutable = callback === undefined
307
- ? undefined
308
- : unwrapTransparentCommand(callback).executable;
309
- const deletesThroughCallback = callbackExecutable !== undefined &&
310
- ["rm", "rmdir", "unlink"].includes(callbackExecutable);
311
- if (!deletesMatches && !deletesThroughCallback)
312
- return undefined;
313
- const expressionIndex = command.args.findIndex((argument) => argument.value.startsWith("-") || ["!", "("].includes(argument.value));
314
- const roots = command.args.slice(0, expressionIndex < 0 ? command.args.length : expressionIndex);
315
- return roots.length === 0 ? [{ value: ".", dynamic: false }] : roots;
316
- }
317
- function assessFindDeletion(command, cwd, tempDirectory) {
318
- if (command.executable !== "find")
319
- return undefined;
320
- const roots = findDeletionRoots(command);
321
- return roots === undefined
322
- ? undefined
323
- : combine(roots.map((target) => classifyTarget(target, cwd, tempDirectory)));
324
- }
325
- function rawDevice(value) {
326
- return /^\/dev\/(?:sd[a-z]|hd[a-z]|vd[a-z]|xvd[a-z]|nvme\d+n\d+|mmcblk\d+|disk\d+|rdisk\d+)(?:p?\d+)?$/i.test(value) ||
327
- /^\\\\\.\\PhysicalDrive\d+$/i.test(value);
328
- }
329
- function assessRawDeviceRedirects(root) {
330
- let destructive = false;
331
- const visit = (node) => {
332
- if (node.type === "file_redirect") {
333
- const operator = node.children.find((child) => child.field === undefined)?.value ?? "";
334
- const targets = node.children
335
- .filter((child) => child.field === "destination")
336
- .map(staticText);
337
- if (/^(?:>|>>|>\||&>|&>>)$/.test(operator) && targets.some((target) => !target.dynamic && rawDevice(target.value))) {
338
- destructive = true;
339
- }
340
- }
341
- node.children.forEach(visit);
342
- };
343
- visit(root);
344
- return destructive ? FORBIDDEN_DISK : undefined;
345
- }
346
- function assessFixedDestructiveCommand(command) {
347
- const joined = command.args.map((argument) => argument.value).join(" ");
348
- if (/^mkfs(?:\.|$)/.test(command.executable))
349
- return FORBIDDEN_DISK;
350
- if (command.executable === "wipefs" && /(?:^|\s)(?:-a|--all)(?:\s|$)/.test(joined))
351
- return FORBIDDEN_DISK;
352
- if (command.executable === "dd" && command.args.some((argument) => {
353
- const output = argument.value.match(/^of=(.+)$/i)?.[1];
354
- return output !== undefined && rawDevice(output);
355
- })) {
356
- return FORBIDDEN_DISK;
357
- }
358
- if (command.executable === "parted" && /(?:^|\s)(?:mklabel|mkpart|rm|resizepart)(?:\s|$)/i.test(joined))
359
- return FORBIDDEN_DISK;
360
- if (["fdisk", "sfdisk", "cfdisk"].includes(command.executable) && /(?:^|\s)(?:--delete|-d)(?:\s|$)/i.test(joined))
361
- return FORBIDDEN_DISK;
362
- if (command.executable === "diskpart" && /(?:^|\s)clean(?:\s|$)/i.test(joined))
363
- return FORBIDDEN_DISK;
364
- if (command.executable === "format" && command.args.some((argument) => /^[A-Za-z]:$/.test(argument.value)))
365
- return FORBIDDEN_DISK;
366
- if (["psql", "mysql", "mariadb", "sqlcmd"].includes(command.executable) && /\b(?:DROP\s+(?:DATABASE|TABLE)|TRUNCATE(?:\s+TABLE)?)\b/i.test(joined)) {
367
- return BLOCK_DATABASE;
368
- }
369
- if (command.executable === "xargs" && command.args.some((argument) => ["rm", "rmdir", "unlink"].includes(normalizeExecutable(argument.value)))) {
370
- return BLOCK_XARGS;
371
- }
372
- if (["powershell", "pwsh"].includes(command.executable) && /\bRemove-Item\b/i.test(joined))
373
- return BLOCK_POWERSHELL;
374
- return undefined;
375
- }
376
- async function assessNestedShell(command, cwd, tempDirectory, depth) {
377
- if (!["bash", "sh", "dash", "zsh"].includes(command.executable))
378
- return undefined;
379
- const codeIndex = command.args.findIndex((argument) => argument.value === "-c");
380
- const code = codeIndex < 0 ? undefined : command.args[codeIndex + 1];
381
- if (code === undefined)
382
- return undefined;
383
- if (code.dynamic)
384
- return BLOCK_DYNAMIC;
385
- if (code.value.length === 0)
386
- return BLOCK_INDETERMINATE;
387
- if (depth >= 8)
388
- return BLOCK_NESTED_DEPTH;
389
- const nested = await parseShellCommand(code.value);
390
- if (nested.status !== "parsed")
391
- return BLOCK_INDETERMINATE;
392
- return assessParsed(nested, cwd, tempDirectory, depth + 1);
393
- }
394
- async function assessParsed(parsed, cwd, tempDirectory, depth) {
395
- const decisions = [];
396
- const redirectDecision = assessRawDeviceRedirects(parsed.root);
397
- if (redirectDecision !== undefined)
398
- decisions.push(redirectDecision);
399
- for (const extractedCommand of extractCommands(parsed.root)) {
400
- const command = unwrapTransparentCommand(extractedCommand);
401
- const nested = await assessNestedShell(command, cwd, tempDirectory, depth);
402
- const decision = nested
403
- ?? assessFileDeletion(command, cwd, tempDirectory)
404
- ?? assessFindDeletion(command, cwd, tempDirectory)
405
- ?? assessFixedDestructiveCommand(command);
406
- if (decision !== undefined)
407
- decisions.push(decision);
408
- }
409
- return combine(decisions);
410
- }
411
- /** 判断已解析命令中的显式文件、数据库和磁盘破坏操作。 */
412
- export function assessDeletionCommand(parsed, cwd, tempDirectory) {
413
- return assessParsed(parsed, cwd, tempDirectory, 0);
414
- }
package/dist/index.d.ts DELETED
@@ -1,12 +0,0 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { ShellParseResult } from "./command-parser.js";
3
- /** Extension 入口的可替换副作用。 */
4
- export interface CommandGuardianDependencies {
5
- parseCommand?: (command: string) => Promise<ShellParseResult>;
6
- tempDirectory?: string;
7
- }
8
- /** 创建只审查显式破坏性 Bash 命令的 Pi Extension。 */
9
- export declare function createCommandGuardianExtension(dependencies?: CommandGuardianDependencies): (pi: ExtensionAPI) => void;
10
- /** Pi package 加载入口。 */
11
- declare const _default: (pi: ExtensionAPI) => void;
12
- export default _default;