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