@zhushanwen/pi-permission 0.0.1

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.
Files changed (51) hide show
  1. package/README.md +455 -0
  2. package/index.ts +2 -0
  3. package/package.json +52 -0
  4. package/src/__tests__/approval.test.ts +456 -0
  5. package/src/__tests__/commands.test.ts +487 -0
  6. package/src/__tests__/config.test.ts +229 -0
  7. package/src/__tests__/e2e-modes.test.ts +253 -0
  8. package/src/__tests__/extension.test.ts +170 -0
  9. package/src/__tests__/index-integration.test.ts +282 -0
  10. package/src/__tests__/model-picker.test.ts +371 -0
  11. package/src/__tests__/pipeline.test.ts +976 -0
  12. package/src/__tests__/production.test.ts +102 -0
  13. package/src/__tests__/rule-editor-component.test.ts +475 -0
  14. package/src/__tests__/rule-editor.test.ts +252 -0
  15. package/src/__tests__/rule-templates.test.ts +264 -0
  16. package/src/__tests__/statusline.test.ts +221 -0
  17. package/src/__tests__/types.test.ts +59 -0
  18. package/src/approval.ts +352 -0
  19. package/src/ast/__tests__/analyzer.test.ts +262 -0
  20. package/src/ast/__tests__/loader.test.ts +136 -0
  21. package/src/ast/analyzer.ts +326 -0
  22. package/src/ast/index.ts +7 -0
  23. package/src/ast/loader.ts +113 -0
  24. package/src/classifier/__tests__/classifier.test.ts +285 -0
  25. package/src/classifier/__tests__/json-parser.test.ts +118 -0
  26. package/src/classifier/__tests__/model-resolver.test.ts +173 -0
  27. package/src/classifier/__tests__/prompt.test.ts +59 -0
  28. package/src/classifier/classifier.ts +242 -0
  29. package/src/classifier/index.ts +28 -0
  30. package/src/classifier/json-parser.ts +127 -0
  31. package/src/classifier/model-resolver.ts +295 -0
  32. package/src/classifier/prompt.ts +52 -0
  33. package/src/commands.ts +234 -0
  34. package/src/config.ts +216 -0
  35. package/src/index.ts +291 -0
  36. package/src/model-picker.ts +398 -0
  37. package/src/pipeline.ts +541 -0
  38. package/src/production.ts +102 -0
  39. package/src/rule-editor-component.ts +883 -0
  40. package/src/rule-editor.ts +262 -0
  41. package/src/rule-templates.ts +332 -0
  42. package/src/rules/__tests__/builtins.test.ts +614 -0
  43. package/src/rules/__tests__/index.test.ts +26 -0
  44. package/src/rules/__tests__/matcher.test.ts +378 -0
  45. package/src/rules/__tests__/wildcard.test.ts +84 -0
  46. package/src/rules/builtins.ts +680 -0
  47. package/src/rules/index.ts +27 -0
  48. package/src/rules/matcher.ts +160 -0
  49. package/src/rules/wildcard.ts +43 -0
  50. package/src/statusline.ts +226 -0
  51. package/src/types.ts +249 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * analyzeBashStructure — bash 命令结构分析(层 1 AST 安全门)。
3
+ *
4
+ * 忠实移植自 Codex `codex-rs/shell-command/src/bash.rs` 的
5
+ * `try_parse_word_only_commands_sequence` + `parse_plain_command_from_node`。
6
+ *
7
+ * 判定规则(与 Codex 一致):
8
+ * - ALLOWED_KINDS(11 个 named node):program/list/pipeline/command/command_name/
9
+ * word/string/string_content/raw_string/number/concatenation
10
+ * - ALLOWED_PUNCT_TOKENS(6 个 anonymous token):'&&' '||' ';' '|' '"' "'"
11
+ * - root.hasError → 解析失败
12
+ * - 任何非白名单 named node → 危险(command_substitution/file_redirect/subshell 等)
13
+ * - 任何非白名单 anonymous token(且非空白)→ 危险(括号/反引号/重定向符等)
14
+ * - command 节点的 named children 必须全部能被 parsePlainCommand 提取(5 分支),
15
+ * 任一分支返回 null → 该 command 跳过(但 dangerousStructures 已收集,clean 已置 false)
16
+ *
17
+ * 降级策略(fail-closed):wasm 加载失败 / parse 异常 / root.hasError / 超长 →
18
+ * 返回 { clean:false, commands:[], dangerousStructures:[...], parseError:true },永不 throw。
19
+ */
20
+
21
+ import type { Node, Tree } from "web-tree-sitter";
22
+
23
+ import type { BashAnalysis } from "../types.js";
24
+ import { getBashParser } from "./loader.js";
25
+
26
+ /** 超过此长度的输入直接拒(防止 wasm 解析巨型 payload DoS)。 */
27
+ const MAX_COMMAND_LENGTH = 65536;
28
+
29
+ /**
30
+ * Codex ALLOWED_KINDS(11 个 named node)。
31
+ * 遇到不在此集合的 named node 即判定为危险结构。
32
+ */
33
+ const ALLOWED_KINDS = new Set([
34
+ "program",
35
+ "list",
36
+ "pipeline",
37
+ "command",
38
+ "command_name",
39
+ "word",
40
+ "string",
41
+ "string_content",
42
+ "raw_string",
43
+ "number",
44
+ "concatenation",
45
+ ]);
46
+
47
+ /**
48
+ * Codex ALLOWED_PUNCT_TOKENS(6 个 anonymous operator/quote token)。
49
+ * 遇到不在此集合且含 &;| 的 operator token,或任何非空白 anonymous token,即判定为危险。
50
+ */
51
+ const ALLOWED_PUNCT_TOKENS = new Set(["&&", "||", ";", "|", '"', "'"]);
52
+
53
+ /** 检查 anonymous token 是否含 & ; | 中任一字符(与 Rust `kind.chars().any(...)` 等价)。 */
54
+ function hasControlOperatorChar(kind: string): boolean {
55
+ return kind.includes("&") || kind.includes(";") || kind.includes("|");
56
+ }
57
+
58
+ /**
59
+ * 解析双引号字符串(对应 Rust parse_double_quoted_string)。
60
+ *
61
+ * 规则:string 的 named children 必须全部是 string_content;
62
+ * 若含 ${VAR}/$VAR/$(...) 会变成 simple_expansion/command_substitution(非 string_content),
63
+ * 返回 null。通过后,返回去掉首尾双引号的文本。
64
+ */
65
+ function parseDoubleQuotedString(node: Node): string | null {
66
+ if (node.type !== "string") {
67
+ return null;
68
+ }
69
+ for (const child of node.namedChildren) {
70
+ if (child.type !== "string_content") {
71
+ return null;
72
+ }
73
+ }
74
+ const raw = node.text;
75
+ // 去掉首尾的 `"`(raw 形如 `"hello world"`)
76
+ if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
77
+ return raw.slice(1, -1);
78
+ }
79
+ // 异常 grammar(理论上不会到这)—— 原样返回
80
+ return raw;
81
+ }
82
+
83
+ /**
84
+ * 解析单引号字符串(对应 Rust parse_raw_string)。
85
+ * 去掉首尾的 `'`。
86
+ */
87
+ function parseRawString(node: Node): string | null {
88
+ if (node.type !== "raw_string") {
89
+ return null;
90
+ }
91
+ const raw = node.text;
92
+ if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2) {
93
+ return raw.slice(1, -1);
94
+ }
95
+ return raw;
96
+ }
97
+
98
+ /**
99
+ * 从一个 command 节点提取 argv(对应 Rust parse_plain_command_from_node)。
100
+ *
101
+ * 5 分支处理 named children:
102
+ * 1. command_name → 其第一个 named child 必须是 word,取文本
103
+ * 2. word | number → 直接取文本
104
+ * 3. string → parseDoubleQuotedString(含 expansion 则 null)
105
+ * 4. raw_string → parseRawString
106
+ * 5. concatenation(如 -g"*.py")→ 递归拼接各 part(word/number/string/raw_string)
107
+ *
108
+ * 任一分支失败 → 返回 null(该 command 被跳过,但 clean 已在 DFS 阶段被置 false)。
109
+ */
110
+ function parsePlainCommand(cmd: Node): string[] | null {
111
+ if (cmd.type !== "command") {
112
+ return null;
113
+ }
114
+
115
+ const words: string[] = [];
116
+ for (const child of cmd.namedChildren) {
117
+ switch (child.type) {
118
+ case "command_name": {
119
+ const wordNode = child.namedChild(0);
120
+ if (!wordNode || wordNode.type !== "word") {
121
+ return null;
122
+ }
123
+ words.push(wordNode.text);
124
+ break;
125
+ }
126
+ case "word":
127
+ case "number": {
128
+ words.push(child.text);
129
+ break;
130
+ }
131
+ case "string": {
132
+ const parsed = parseDoubleQuotedString(child);
133
+ if (parsed === null) {
134
+ return null;
135
+ }
136
+ words.push(parsed);
137
+ break;
138
+ }
139
+ case "raw_string": {
140
+ const parsed = parseRawString(child);
141
+ if (parsed === null) {
142
+ return null;
143
+ }
144
+ words.push(parsed);
145
+ break;
146
+ }
147
+ case "concatenation": {
148
+ // 拼接如 -g"*.py" 或 '/usr'"/"'local'/bin
149
+ let concatenated = "";
150
+ for (const part of child.namedChildren) {
151
+ switch (part.type) {
152
+ case "word":
153
+ case "number":
154
+ concatenated += part.text;
155
+ break;
156
+ case "string": {
157
+ const parsed = parseDoubleQuotedString(part);
158
+ if (parsed === null) return null;
159
+ concatenated += parsed;
160
+ break;
161
+ }
162
+ case "raw_string": {
163
+ const parsed = parseRawString(part);
164
+ if (parsed === null) return null;
165
+ concatenated += parsed;
166
+ break;
167
+ }
168
+ default:
169
+ return null;
170
+ }
171
+ }
172
+ if (concatenated.length === 0) {
173
+ return null;
174
+ }
175
+ words.push(concatenated);
176
+ break;
177
+ }
178
+ default:
179
+ // 未知 named child(不应发生 —— DFS 阶段已拒,但保险起见 fail-closed)
180
+ return null;
181
+ }
182
+ }
183
+ return words;
184
+ }
185
+
186
+ /**
187
+ * 分析 bash 命令字符串的结构(层 1 AST 安全门)。
188
+ *
189
+ * 主函数编排:空/超长/初始化快速路径 → parse → collectStructures(DFS)→
190
+ * parsePlainCommand 提取 argv → 组装 BashAnalysis。DFS + argv 提取已拆为独立
191
+ * helper(collectStructures / extractCommands),主函数 ≤80 行。
192
+ *
193
+ * @param command 原始命令字符串
194
+ * @returns BashAnalysis —— 永不 throw,失败时 fail-closed 返回 parseError:true
195
+ */
196
+ export async function analyzeBashStructure(command: string): Promise<BashAnalysis> {
197
+ // 空字符串 / 纯空白 → 干净且无命令
198
+ if (command.trim().length === 0) {
199
+ return { clean: true, commands: [], dangerousStructures: [], parseError: false };
200
+ }
201
+
202
+ // 超长 → fail-closed
203
+ if (command.length > MAX_COMMAND_LENGTH) {
204
+ return failClosed("INPUT_TOO_LONG");
205
+ }
206
+
207
+ const parser = await getBashParser();
208
+ if (parser === null) {
209
+ return failClosed("INIT_FAILED");
210
+ }
211
+
212
+ let tree: Tree | null = null;
213
+ try {
214
+ tree = parser.parse(command);
215
+ if (!tree) return failClosed("PARSE_NULL");
216
+
217
+ const root = tree.rootNode;
218
+ if (root.hasError) return failClosed("ERROR");
219
+
220
+ // DFS 遍历收集危险结构 + command 节点(拆为 helper,主函数保持精简)
221
+ const { dangerousStructures, commandNodes } = collectStructures(root);
222
+ const commands = extractCommands(commandNodes);
223
+
224
+ return {
225
+ clean: dangerousStructures.length === 0,
226
+ commands,
227
+ dangerousStructures,
228
+ parseError: false,
229
+ };
230
+ } catch (err) {
231
+ // 任何意外异常 → fail-closed
232
+ const msg = err instanceof Error ? err.message : String(err);
233
+ console.warn(`[pi-permission/ast] analyzeBashStructure exception: ${msg}`);
234
+ return failClosed(msg || "EXCEPTION");
235
+ } finally {
236
+ // G1 critical: Tree 是 wasm 堆对象,不受 V8 GC 管,必须显式 delete 释放。
237
+ tree?.delete();
238
+ }
239
+ }
240
+
241
+ /** 构造 fail-closed BashAnalysis(clean=false, parseError=true, 单条 reason)。 */
242
+ function failClosed(reason: string): BashAnalysis {
243
+ return {
244
+ clean: false,
245
+ commands: [],
246
+ dangerousStructures: [reason],
247
+ parseError: true,
248
+ };
249
+ }
250
+
251
+ /** DFS 遍历收集结果:危险结构节点类型 + command 节点。 */
252
+ interface StructureCollection {
253
+ dangerousStructures: string[];
254
+ commandNodes: Node[];
255
+ }
256
+
257
+ /**
258
+ * DFS 遍历 AST(stack-based,与 Rust 一致用 node.children() 不是 namedChildren)。
259
+ *
260
+ * 收集:
261
+ * - 非白名单 named node → dangerousStructures(command_substitution/subshell 等)
262
+ * - 非白名单 anonymous token(含 &;| 或非空白)→ dangerousStructures
263
+ * - command 节点 → commandNodes(后续 parsePlainCommand 提取 argv)
264
+ *
265
+ * @param root AST 根节点
266
+ * @returns 危险结构 + command 节点(commandNodes 已按 start_byte 排序恢复源码顺序)
267
+ */
268
+ function collectStructures(root: Node): StructureCollection {
269
+ const stack: Node[] = [root];
270
+ const dangerousStructures: string[] = [];
271
+ const commandNodes: Node[] = [];
272
+
273
+ while (stack.length > 0) {
274
+ const node = stack.pop()!;
275
+ const kind = node.type;
276
+
277
+ if (node.isNamed) {
278
+ if (!ALLOWED_KINDS.has(kind)) {
279
+ // 非白名单 named node → 危险结构
280
+ dangerousStructures.push(kind);
281
+ }
282
+ if (kind === "command") {
283
+ commandNodes.push(node);
284
+ }
285
+ } else {
286
+ // anonymous token —— 与 Rust 两条判定对应:
287
+ // 1. 含 &;| 但不在白名单 → 危险
288
+ // 2. 不在白名单且非空白 → 危险(括号/反引号/重定向符等)
289
+ const isAllowedOrWhitespace =
290
+ ALLOWED_PUNCT_TOKENS.has(kind) || kind.trim().length === 0;
291
+ if (hasControlOperatorChar(kind) && !ALLOWED_PUNCT_TOKENS.has(kind)) {
292
+ dangerousStructures.push(kind);
293
+ } else if (!isAllowedOrWhitespace) {
294
+ dangerousStructures.push(kind);
295
+ }
296
+ }
297
+
298
+ // node.children 在 web-tree-sitter 是 getter(返回 Array<SyntaxNode>)
299
+ for (const child of node.children) {
300
+ stack.push(child);
301
+ }
302
+ }
303
+
304
+ // stack 是 LIFO,按 start_byte 排序恢复源码顺序(与 Rust 一致)
305
+ commandNodes.sort((a, b) => a.startIndex - b.startIndex);
306
+ return { dangerousStructures, commandNodes };
307
+ }
308
+
309
+ /**
310
+ * 从 command 节点列表提取 argv(用 parsePlainCommand)。
311
+ *
312
+ * parsePlainCommand 返回 null 的 command 跳过(clean 已由 dangerousStructures 决定)。
313
+ *
314
+ * @param commandNodes 已按源码顺序排序的 command 节点
315
+ * @returns argv 数组(每个元素是一条 command 的 argv)
316
+ */
317
+ function extractCommands(commandNodes: Node[]): string[][] {
318
+ const commands: string[][] = [];
319
+ for (const node of commandNodes) {
320
+ const words = parsePlainCommand(node);
321
+ if (words !== null) {
322
+ commands.push(words);
323
+ }
324
+ }
325
+ return commands;
326
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * ast 模块 barrel —— 只 re-export 公开 API。
3
+ *
4
+ * loader 内部细节(getBashParser / resolveWasmPaths)不泄露给 ast 之外的消费者,
5
+ * 但 loader.test.ts 直接从 "./loader" import 测试(保持公开 API 最小)。
6
+ */
7
+ export { analyzeBashStructure } from "./analyzer.js";
@@ -0,0 +1,113 @@
1
+ /**
2
+ * web-tree-sitter WASM loader + bash Parser singleton.
3
+ *
4
+ * 职责:
5
+ * - 定位 tree-sitter-bash.wasm + web-tree-sitter 运行时 wasm 的物理路径
6
+ * - 初始化 Parser 运行时(locateFile 指向 wasm 目录)
7
+ * - 加载 bash grammar,构造并缓存 Parser 单例(promise memoization,天然并发互斥)
8
+ *
9
+ * 失败策略(fail-closed):任一步抛错 → console.warn + 返回 null,永不 throw。
10
+ * analyzer 拿到 null 时降级为 { clean:false, parseError:true }。
11
+ *
12
+ * 注意(实测 web-tree-sitter 0.26.11 的事实,与 pi-lens 用的 0.24.5 有差异):
13
+ * - web-tree-sitter 0.26 是 **named export**(`export class Parser / Language`),不是 default。
14
+ * - `Parser.init({ locateFile })` 是 static method(返回 Promise<void>)。
15
+ * - `Language.load(path)` 是 static method(返回 Promise<Language>)。
16
+ * - `new Parser()` 构造实例;`parser.setLanguage(lang)` 实例方法。
17
+ * - 运行时 wasm 在 0.26 叫 `web-tree-sitter.wasm`(与 .js 同名),
18
+ * 而非 0.24.5 时代的 `tree-sitter.wasm`。emscripten 的 locateFile 回调会
19
+ * 传入实际请求的文件名,所以我们只需定位 .js 所在目录,不硬编码 wasm 文件名。
20
+ */
21
+
22
+ import { createRequire } from "node:module";
23
+ import { existsSync } from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+
26
+ import { Language, Parser } from "web-tree-sitter";
27
+
28
+ const nodeRequire = createRequire(import.meta.url);
29
+
30
+ /** 解析出的 wasm 物理路径(bashWasmPath 是文件,runtimeWasmDir 是目录)。 */
31
+ export interface WasmPaths {
32
+ bashWasmPath: string;
33
+ runtimeWasmDir: string;
34
+ }
35
+
36
+ /**
37
+ * 定位两个 wasm:
38
+ * - bashWasmPath:tree-sitter-bash 的 grammar wasm(解析 bash 用)—— 包根 `tree-sitter-bash.wasm`
39
+ * - runtimeWasmDir:web-tree-sitter 运行时 wasm 所在目录(emscripten 运行时用)
40
+ *
41
+ * tree-sitter-bash 0.25 不在 exports 暴露 .wasm 子路径(package.json files 用 `*.wasm` glob
42
+ * 打包,但 exports 只暴露 `.` 主入口给原生 binding)。所以用 package.json 目录定位。
43
+ * web-tree-sitter 0.26 的运行时 wasm 与 .js 同目录(0.26 起叫 `web-tree-sitter.wasm`,
44
+ * 不再是 0.24 时代的 `tree-sitter.wasm`)。emscripten 的 locateFile 会传入实际文件名,
45
+ * 所以这里只定位 .js 所在目录,不硬编码 wasm 文件名(跨版本健壮)。
46
+ *
47
+ * 任一不存在则 throw(fail-loud,由 getBashParser 捕获后 fail-closed)。
48
+ */
49
+ export function resolveWasmPaths(): WasmPaths {
50
+ // 1. tree-sitter-bash.wasm —— grammar wasm 在包根目录
51
+ const bashPkgDir = dirname(nodeRequire.resolve("tree-sitter-bash/package.json"));
52
+ const bashWasmPath = join(bashPkgDir, "tree-sitter-bash.wasm");
53
+ if (!existsSync(bashWasmPath)) {
54
+ throw new Error(
55
+ `tree-sitter-bash.wasm not found at ${bashWasmPath} (tree-sitter-bash package may be incomplete)`,
56
+ );
57
+ }
58
+
59
+ // 2. 运行时 wasm 目录 —— web-tree-sitter 的 .js / .wasm 同目录
60
+ // 用 resolve("web-tree-sitter") 定位 .js 入口,取其 dirname。
61
+ const runtimeEntry = nodeRequire.resolve("web-tree-sitter");
62
+ const runtimeWasmDir = dirname(runtimeEntry);
63
+ if (!existsSync(runtimeWasmDir)) {
64
+ throw new Error(
65
+ `web-tree-sitter runtime dir not found at ${runtimeWasmDir} (web-tree-sitter package may be incomplete)`,
66
+ );
67
+ }
68
+
69
+ return { bashWasmPath, runtimeWasmDir };
70
+ }
71
+
72
+ /**
73
+ * 模块级 promise 缓存。首次调用触发完整初始化链路;
74
+ * 再次调用直接返回同一个 promise(天然并发互斥,无需锁)。
75
+ * 失败时缓存 null(下一次调用会重试 —— wasm 加载失败可能是瞬时问题)。
76
+ */
77
+ let parserPromise: Promise<Parser | null> | null = null;
78
+
79
+ /**
80
+ * 获取(并在首次调用时初始化)bash Parser 单例。
81
+ *
82
+ * @returns 可用的 Parser 实例;初始化失败返回 null(fail-closed)。
83
+ * 永不 throw。
84
+ */
85
+ export function getBashParser(): Promise<Parser | null> {
86
+ if (parserPromise) {
87
+ return parserPromise;
88
+ }
89
+
90
+ parserPromise = (async () => {
91
+ try {
92
+ const { bashWasmPath, runtimeWasmDir } = resolveWasmPaths();
93
+
94
+ // locateFile 让 emscripten 从 web-tree-sitter 目录加载 tree-sitter.wasm 运行时。
95
+ await Parser.init({
96
+ locateFile: (name: string) => join(runtimeWasmDir, name),
97
+ });
98
+
99
+ const bash = await Language.load(bashWasmPath);
100
+ const parser = new Parser();
101
+ parser.setLanguage(bash);
102
+ return parser;
103
+ } catch (err) {
104
+ // fail-closed:清空缓存让下次调用重试,warn 后返回 null。
105
+ parserPromise = null;
106
+ const msg = err instanceof Error ? err.message : String(err);
107
+ console.warn(`[pi-permission/ast] getBashParser init failed: ${msg}`);
108
+ return null;
109
+ }
110
+ })();
111
+
112
+ return parserPromise;
113
+ }