pi-command-guardian 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ 本文档遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 格式,版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.2.0] - 2026-07-26
8
+
9
+ ### Changed
10
+
11
+ - 保护范围缩减为模型发起的显式破坏性 Bash 命令。
12
+ - 工作区和系统临时目录严格子项的文件删除自动允许。
13
+ - 工作区根、外部目标和显式删库在执行前阻止,并要求人工审阅操作和目标后自行决定是否手动删除。
14
+ - 动态内容、标准输入、空缺目标或命令结构无法可靠确定的删除在执行前阻止,并要求先确定所有实际目标,再简化为语法完整、直接包含静态目标的 Bash 删除命令。
15
+ - Git 命令保持 Pi 原有行为。
16
+ - 文件系统根、用户目录根、系统关键目录、数据库数据目录、磁盘格式化、分区擦除和原始设备写入永久阻止模型调用。
17
+ - Shell 解析继续支持组合命令、命令替换、静态 `bash -c`、`find -delete`、`find -exec` 和写入重定向。
18
+ - `sudo`、`env` 和执行模式的 `command` 包装后继续判断实际删除命令,`find` 删除回调使用相同规则。
19
+ - 未引用的波浪号和花括号展开按无法静态确定的删除目标阻止,引用后的相同字符保持普通文件名语义。
20
+
21
+ ### Removed
22
+
23
+ - 删除 `git clean` 和 `git reset --hard` 特殊阻止规则。
24
+ - 删除当前模型 Guardian 请求和 Pi AI 依赖。
25
+ - 删除管理、用户和项目三层配置。
26
+ - 删除会话拒绝关联、用户授权证据和已处理状态。
27
+ - 删除通用安全命令白名单、全部模型工具保护和敏感文件读取保护。
28
+ - 删除本机 JSONL 审查记录、分段和容量管理。
29
+ - 删除 `minimatch` 与 TypeBox 依赖。
30
+
31
+ ### Security
32
+
33
+ - 受控目录根本身不属于自动允许范围,`rm -rf .` 保持阻止。
34
+ - 组合命令采用 `forbidden > block > allow` 的最严格判断。
35
+ - 静态 Shell 嵌套最多分析八层,超过上限时阻止并要求简化命令。
36
+ - 普通未知命令和间接脚本副作用保持 Pi 原有行为,保护边界限定为明确可识别的破坏操作。
37
+
38
+ ## [0.1.0] - 2026-07-24
39
+
40
+ ### Added
41
+
42
+ - 增加 Pi package 与 Extension 入口。
43
+ - 增加 Tree-sitter Bash 结构解析和初始通用 Guardian 实现。
44
+
45
+ [Unreleased]: https://github.com/sunnyx11/pi-command-guardian/compare/v0.2.0...HEAD
46
+ [0.2.0]: https://github.com/sunnyx11/pi-command-guardian/compare/v0.1.0...v0.2.0
47
+ [0.1.0]: https://github.com/sunnyx11/pi-command-guardian/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sunnyx11
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # Pi Command Guardian
2
+
3
+ Pi Command Guardian 是一个最小 Pi Extension,用于阻止模型 Bash 中常见的显式删库、系统删除和磁盘破坏操作,同时允许清理项目构建产物和系统临时文件。
4
+
5
+ ## 保护范围
6
+
7
+ Extension 只处理模型发起的 `bash tool_call`:
8
+
9
+ - 自动允许工作区和系统临时目录严格子项的文件删除。
10
+ - `sudo`、`env` 和执行模式的 `command` 包装后继续判断实际删除命令。
11
+ - 阻止工作区根和工作区外目标的删除,并要求人工审阅。
12
+ - 变量、命令替换、未引用的波浪号或花括号展开等无法静态确定的目标,要求简化为语法完整、直接包含静态目标的 Bash 删除命令。
13
+ - 阻止显式 `DROP DATABASE`、`DROP TABLE` 和 `TRUNCATE`。
14
+ - 永久阻止文件系统根、用户目录根、系统关键目录、数据库数据目录和磁盘设备破坏。
15
+
16
+ 明确外部目标等普通阻止发生后,应先审阅被阻止的操作和目标,再自行决定是否手动删除。无法静态确定目标或 Shell 嵌套超过八层时,应先确定所有实际删除目标,再将命令简化为语法完整、直接包含静态目标的 Bash 删除命令后重新提交。Extension 不推荐绕过保护的具体执行方式。永久阻止只限制模型工具调用,同样不会接管用户 Bash。
17
+
18
+ ## 允许示例
19
+
20
+ ```bash
21
+ rm -rf build
22
+ rm -rf ./dist/*
23
+ find ./coverage -type f -delete
24
+ rm -f /tmp/pi-output.log
25
+ ```
26
+
27
+ 所有删除目标均位于工作区或系统临时目录内部,因此保持自动执行。
28
+
29
+ ## 阻止示例
30
+
31
+ ```bash
32
+ rm -rf .
33
+ rm -rf ../other-project
34
+ rm -rf "$TARGET"
35
+ rm -rf ~/
36
+ rm -rf {build,../shared}
37
+ sudo rm -rf /
38
+ psql -c 'DROP DATABASE production'
39
+ ```
40
+
41
+ 外部目标和工作区根会被阻止并要求人工审阅。动态内容、`xargs` 输入、空缺目标、无法可靠解析或 Shell 嵌套超过八层等不确定删除会被阻止,并要求先确定所有实际删除目标,再简化为语法完整、直接包含静态目标的 Bash 删除命令。显式删库使用人工审阅提示,Git 命令保持 Pi 原有行为。
42
+
43
+ ## 永久阻止示例
44
+
45
+ ```bash
46
+ rm -rf /
47
+ rm -rf "$HOME"
48
+ rm -rf /var/lib/postgresql
49
+ mkfs.ext4 /dev/sda1
50
+ dd if=/dev/zero of=/dev/sda
51
+ parted /dev/sda mklabel gpt
52
+ ```
53
+
54
+ ## 明确限制
55
+
56
+ 以下间接副作用不做分析:
57
+
58
+ ```bash
59
+ python cleanup.py
60
+ npm run migrate
61
+ ./deploy.sh
62
+ ```
63
+
64
+ Extension 也不处理 `read`、`write`、`edit`、自定义工具、`!command` 或 `!!command`。备份、数据库最小权限、磁盘快照和容器隔离仍由运行环境负责。
65
+
66
+ ## 实现结构
67
+
68
+ ```text
69
+ src/
70
+ ├── index.ts # Pi tool_call 接入
71
+ ├── command-parser.ts # Tree-sitter Bash 解析
72
+ └── deletion-guard.ts # 显式破坏操作判断
73
+ ```
74
+
75
+ 运行时没有模型请求、配置文件、会话授权状态或审查记录。生产依赖只有 `@vscode/tree-sitter-wasm`。
76
+
77
+ ## 环境要求
78
+
79
+ - Node.js `>=22.19.0`
80
+ - Pi `0.81.1` 或兼容版本
81
+
82
+ ## 安装
83
+
84
+ 仓库安装:
85
+
86
+ ```bash
87
+ npm install
88
+ npm run build
89
+ pi install .
90
+ ```
91
+
92
+ 临时加载:
93
+
94
+ ```bash
95
+ pi -e .
96
+ ```
97
+
98
+ npm 发布后安装:
99
+
100
+ ```bash
101
+ pi install npm:pi-command-guardian@0.1.0
102
+ ```
103
+
104
+ ## 验证
105
+
106
+ ```bash
107
+ npm test
108
+ npm run typecheck
109
+ npm run spec:check
110
+ npm run pack:check
111
+ ```
112
+
113
+ ## 卸载
114
+
115
+ ```bash
116
+ pi remove npm:pi-command-guardian
117
+ ```
118
+
119
+ 旧版本创建的配置和审查记录不会自动删除。
120
+
121
+ ## 许可证
122
+
123
+ 当前 package 标记为 `UNLICENSED`。
@@ -0,0 +1,43 @@
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 {};
@@ -0,0 +1,109 @@
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);
@@ -0,0 +1,16 @@
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>;
@@ -0,0 +1,414 @@
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
+ }
@@ -0,0 +1,12 @@
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;
package/dist/index.js ADDED
@@ -0,0 +1,56 @@
1
+ import { tmpdir } from "node:os";
2
+ import { parseShellCommand } from "./command-parser.js";
3
+ import { assessDeletionCommand } from "./deletion-guard.js";
4
+ 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;
5
+ const MANUAL_REVIEW_GUIDANCE = "请审阅操作和目标后,自行决定是否手动删除。";
6
+ const STATIC_TARGET_GUIDANCE = "请先确定所有实际删除目标,再将命令简化为语法完整、直接包含静态目标的 Bash 删除命令后重新提交。";
7
+ function invalidInputReason(code) {
8
+ return `Command Guardian 阻止了本次操作,操作尚未执行:${code}`;
9
+ }
10
+ /** 创建只审查显式破坏性 Bash 命令的 Pi Extension。 */
11
+ export function createCommandGuardianExtension(dependencies = {}) {
12
+ const parseCommand = dependencies.parseCommand ?? parseShellCommand;
13
+ const tempDirectory = dependencies.tempDirectory ?? tmpdir();
14
+ return (pi) => {
15
+ pi.on("tool_call", async (event, ctx) => {
16
+ if (event.toolName !== "bash")
17
+ return undefined;
18
+ const input = event.input;
19
+ if (typeof input.command !== "string" || input.command.trim().length === 0) {
20
+ return { block: true, reason: invalidInputReason("PCG_INVALID_BASH_INPUT") };
21
+ }
22
+ try {
23
+ const parsed = await parseCommand(input.command);
24
+ if (parsed.status !== "parsed") {
25
+ return EXPLICIT_DELETION_PATTERN.test(input.command)
26
+ ? {
27
+ block: true,
28
+ reason: `Command Guardian 阻止了本次删除操作,操作尚未执行:命令结构无法可靠确定。${STATIC_TARGET_GUIDANCE}`,
29
+ }
30
+ : undefined;
31
+ }
32
+ const result = await assessDeletionCommand(parsed, ctx.cwd, tempDirectory);
33
+ if (result.decision === "allow")
34
+ return undefined;
35
+ if (result.decision === "forbidden") {
36
+ return {
37
+ block: true,
38
+ reason: `Command Guardian 永久阻止了本次操作,操作尚未执行:${result.reason}。该判断无法由模型重试覆盖。`,
39
+ };
40
+ }
41
+ const guidance = result.guidance === "static-target"
42
+ ? STATIC_TARGET_GUIDANCE
43
+ : MANUAL_REVIEW_GUIDANCE;
44
+ return {
45
+ block: true,
46
+ reason: `Command Guardian 阻止了本次删除操作,操作尚未执行:${result.reason}。${guidance}`,
47
+ };
48
+ }
49
+ catch {
50
+ return { block: true, reason: invalidInputReason("PCG_INTERNAL_ERROR") };
51
+ }
52
+ });
53
+ };
54
+ }
55
+ /** Pi package 加载入口。 */
56
+ export default createCommandGuardianExtension();
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "pi-command-guardian",
3
+ "version": "0.2.0",
4
+ "description": "Deterministic explicit deletion guard for Pi",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "security",
11
+ "command-guardian"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/sunnyx11/pi-command-guardian.git"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/sunnyx11/pi-command-guardian/issues"
19
+ },
20
+ "homepage": "https://github.com/sunnyx11/pi-command-guardian#readme",
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "CHANGELOG.md",
25
+ "LICENSE"
26
+ ],
27
+ "engines": {
28
+ "node": ">=22.19.0"
29
+ },
30
+ "pi": {
31
+ "extensions": [
32
+ "./dist/index.js"
33
+ ]
34
+ },
35
+ "scripts": {
36
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
37
+ "build": "npm run clean && tsc -p tsconfig.json",
38
+ "typecheck": "tsc -p tsconfig.json --noEmit",
39
+ "test": "npm run build && node --test",
40
+ "pack:check": "npm pack --dry-run",
41
+ "spec:check": "node .agents/skills/spec-coding/scripts/check-spec-docs.mjs --root . --specs-dir docs --domain command-guarding --all-changes && node .agents/skills/spec-coding/scripts/check-change-summary.mjs --root . --specs-dir docs && node .agents/skills/spec-coding/scripts/check-changes-index.mjs --root . --specs-dir docs && node .agents/skills/spec-coding/scripts/check-requirements-format.mjs --root . --specs-dir docs --domain command-guarding --all-changes && node .agents/skills/spec-coding/scripts/build-specs-index.mjs --root . --specs-dir docs --dry-run",
42
+ "prepublishOnly": "npm test && npm run typecheck && npm run spec:check && npm run pack:check"
43
+ },
44
+ "dependencies": {
45
+ "@vscode/tree-sitter-wasm": "0.3.1"
46
+ },
47
+ "peerDependencies": {
48
+ "@earendil-works/pi-coding-agent": "*"
49
+ },
50
+ "devDependencies": {
51
+ "@earendil-works/pi-coding-agent": "0.81.1",
52
+ "@types/node": "22.19.0",
53
+ "typescript": "5.9.3"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public",
57
+ "registry": "https://registry.npmjs.org"
58
+ }
59
+ }