@trim21/personal-pi-extensions 0.0.403 → 0.0.406

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/README.md CHANGED
@@ -94,7 +94,7 @@ bash 工具(opencode 风格 `bash`、Claude Code 风格 `Bash`)注册了 `da
94
94
  }
95
95
  ```
96
96
 
97
- `dangerouslyDisableSandbox: true` 的审批流程:先按 `approvalRules` 匹配(含嵌套 `$(...)` 内的命令,规则后写优先),命中 allow/deny 直接放行/拒绝,未命中才弹确认框。
97
+ `dangerouslyDisableSandbox: true` 的审批流程:先按 `approvalRules` 匹配(含嵌套 `$(...)` 内的命令,规则后写优先),命中 allow/deny 直接放行/拒绝,未命中才弹确认框。含文件输出重定向(`>` / `>>` / `&>` 等)的命令即使命令规则全匹配也不会自动放行,避免 `echo *` 把 `echo '' > file` 带过;管道(`echo | tail`)和 fd 复制(`2>&1`)不受影响。
98
98
 
99
99
  ### 使用
100
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.403",
3
+ "version": "0.0.406",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -3,7 +3,8 @@
3
3
  * 用 tree-sitter 解析命令(含嵌套 `$(...)`),按 BashArity 生成命令模式
4
4
  * (`git checkout main` → `git checkout *`),再用通配匹配对 allow/deny
5
5
  * 规则求值。接入 bwrap 的 `dangerouslyDisableSandbox` 审批:命中规则自动
6
- * 放行/拒绝,未命中才弹审批对话框。
6
+ * 放行/拒绝,未命中才弹审批对话框。文件输出重定向(`>` / `>>` / `&>`
7
+ * 等)不会因命令规则自动放行,避免 `echo *` 把 `echo '' > file` 带过。
7
8
  *
8
9
  * 参考实现:
9
10
  * - opencode packages/opencode/src/permission/arity.ts(BashArity 表)
@@ -27,6 +28,8 @@ export interface ParsedBash {
27
28
  commands: BashCommand[];
28
29
  /** 语法错误时的提示(解析失败不抛错,退化为无法匹配)。 */
29
30
  error?: string;
31
+ /** 含写入文件的重定向(`>` / `>>` / `&>` / `<>` 等);fd 复制与纯输入除外。 */
32
+ hasFileOutputRedirect: boolean;
30
33
  }
31
34
 
32
35
  export type ApprovalAction = "allow" | "deny";
@@ -108,6 +111,27 @@ function extractParts(node: Node): { name: string; args: string[] } {
108
111
  return { name: name.join(" "), args };
109
112
  }
110
113
 
114
+ /** 会打开/截断/追加文件的重定向算子;`>&` / `<&` 是 fd 复制,不算。 */
115
+ const FILE_OUTPUT_REDIRECT_OPS = new Set([">", ">>", ">|", "&>", "&>>"]);
116
+
117
+ function fileRedirectWritesToFile(node: Node): boolean {
118
+ for (let i = 0; i < node.childCount; i++) {
119
+ const child = node.child(i);
120
+ if (!child) continue;
121
+ if (FILE_OUTPUT_REDIRECT_OPS.has(child.type)) return true;
122
+ // `<>` 被解析成 `<` + 含 `>` 的 ERROR
123
+ if (child.type === "ERROR" && child.text.includes(">")) return true;
124
+ }
125
+ return false;
126
+ }
127
+
128
+ function treeHasFileOutputRedirect(root: Node): boolean {
129
+ for (const node of root.descendantsOfType("file_redirect")) {
130
+ if (fileRedirectWritesToFile(node)) return true;
131
+ }
132
+ return false;
133
+ }
134
+
111
135
  function collectCommand(node: Node, all: Node[]): BashCommand | undefined {
112
136
  const { name, args } = extractParts(node);
113
137
  if (!name) return undefined;
@@ -139,10 +163,11 @@ export async function parseBashCommands(command: string): Promise<ParsedBash> {
139
163
  const parsed = collectCommand(node, all);
140
164
  if (parsed) commands.push(parsed);
141
165
  }
142
- return { commands };
166
+ return { commands, hasFileOutputRedirect: treeHasFileOutputRedirect(tree.rootNode) };
143
167
  } catch (error) {
144
168
  return {
145
169
  commands: [],
170
+ hasFileOutputRedirect: false,
146
171
  error: error instanceof Error ? error.message : String(error),
147
172
  };
148
173
  }
@@ -197,20 +222,25 @@ export function matchRule(input: string, pattern: string): boolean {
197
222
  * 命令(含所有嵌套命令)的权限模式列表(命令替换里的命令也展开)。
198
223
  * 供规则求值与"allow forever"写规则复用。
199
224
  */
200
- export async function commandPatternsFor(command: string): Promise<string[]> {
201
- const parsed = await parseBashCommands(command);
225
+ function patternsFromCommands(commands: BashCommand[]): string[] {
202
226
  const flat: string[] = [];
203
227
  const visit = (cmd: BashCommand) => {
204
228
  flat.push(commandPattern(cmd));
205
229
  for (const nested of cmd.nested) visit(nested);
206
230
  };
207
- for (const cmd of parsed.commands) visit(cmd);
231
+ for (const cmd of commands) visit(cmd);
208
232
  return flat;
209
233
  }
210
234
 
235
+ export async function commandPatternsFor(command: string): Promise<string[]> {
236
+ const parsed = await parseBashCommands(command);
237
+ return patternsFromCommands(parsed.commands);
238
+ }
239
+
211
240
  /**
212
241
  * 对命令(含所有嵌套命令)求值:
213
242
  * - deny 优先:任一命令命中 deny 规则即整体拒绝
243
+ * - 文件输出重定向不自动放行:即使命令规则全匹配,也返回 undefined 交人审
214
244
  * - allow 需全量:所有命令都命中 allow 规则才整体放行,否则返回
215
245
  * undefined(有命令未命中规则,交给人审),避免未允许的命令被同链放行带过。
216
246
  * 规则内后写优先(findLast,对齐 opencode PermissionV2)。
@@ -219,7 +249,8 @@ export async function evaluateBashApproval(
219
249
  command: string,
220
250
  rules: readonly ApprovalRule[],
221
251
  ): Promise<ApprovalAction | undefined> {
222
- const patterns = await commandPatternsFor(command);
252
+ const parsed = await parseBashCommands(command);
253
+ const patterns = patternsFromCommands(parsed.commands);
223
254
  if (patterns.length === 0) return;
224
255
  let allowed = 0;
225
256
  for (const pattern of patterns) {
@@ -227,5 +258,6 @@ export async function evaluateBashApproval(
227
258
  if (rule?.action === "deny") return "deny";
228
259
  if (rule?.action === "allow") allowed++;
229
260
  }
261
+ if (parsed.hasFileOutputRedirect) return;
230
262
  return allowed === patterns.length ? "allow" : undefined;
231
263
  }
@@ -6,6 +6,7 @@
6
6
  * - 配置来源:全局 `~/.pi/agent/lsp.json` + 本地 `<cwd>/.pi/lsp.json`
7
7
  * (本地覆盖全局):`servers` 按 id 合并(同名 id 整体覆盖、新增 id,全局
8
8
  * 其余服务器保留),`enabled`/`disabled` 白名单与各超时参数继续生效;
9
+ * 没有内置默认服务器,所有服务器均须在配置里定义;
9
10
  * 配置在每个工具的调用 cwd 下惰性读取;enabled/disabled 引用不存在的
10
11
  * 服务器 id 是配置错误:全局配置在扩展加载(createLspService)时抛错,
11
12
  * 本地配置在 session 开始预加载时通知,工具调用时校验抛错兜底;
@@ -26,13 +27,7 @@ import { Value } from "typebox/value";
26
27
  import { type LspServerAdapter } from "./adapter.js";
27
28
  import { create, type CreateInput, type Diagnostic, type Info as LspClient } from "./client.js";
28
29
  import { report } from "./diagnostic.js";
29
- import {
30
- createAdapters,
31
- defaultServers,
32
- mergeServerConfigs,
33
- mergeServerRecords,
34
- serverConfigSchema,
35
- } from "./server-config.js";
30
+ import { createAdapters, mergeServerRecords, serverConfigSchema } from "./server-config.js";
36
31
 
37
32
  /** 超时值:number(毫秒,>=1)或字符串("500"、"5s"、"1m"),Parse 后由 toMs 统一换算。 */
38
33
  const timeoutValue = Type.Union([Type.Number({ minimum: 1 }), Type.String()]);
@@ -41,7 +36,7 @@ const timeoutValue = Type.Union([Type.Number({ minimum: 1 }), Type.String()]);
41
36
  const lspConfigSchema = Type.Object({
42
37
  /** 配置文件版本(当前 1);未知版本会被 typebox 严格校验拒绝并回退空配置。 */
43
38
  version: Type.Optional(Type.Number()),
44
- /** 配置驱动的语言服务器定义(id → 配置);按 id 与内置默认服务器合并(覆盖/enabled:false 禁用)。 */
39
+ /** 配置驱动的语言服务器定义(id → 配置);无内置默认,全部在此定义。 */
45
40
  servers: Type.Optional(Type.Record(Type.String(), serverConfigSchema)),
46
41
  /** 只启用列出的服务器 id(缺省 = 全部启用)。 */
47
42
  enabled: Type.Optional(Type.Array(Type.String())),
@@ -124,9 +119,7 @@ function readConfigFileSync(filePath: string): LspConfig {
124
119
  */
125
120
  function validateConfig(config: LspConfig, adapters?: LspServerAdapter[]): void {
126
121
  const available = new Set(
127
- adapters
128
- ? adapters.map((adapter) => adapter.id)
129
- : Object.keys(mergeServerConfigs(defaultServers, config.servers)),
122
+ adapters ? adapters.map((adapter) => adapter.id) : Object.keys(config.servers ?? {}),
130
123
  );
131
124
  const unknown = [...(config.enabled ?? []), ...(config.disabled ?? [])].filter(
132
125
  (id) => !available.has(id),
@@ -48,69 +48,6 @@ export const serverConfigSchema = Type.Object({
48
48
 
49
49
  export type ServerConfig = Static<typeof serverConfigSchema>;
50
50
 
51
- /** 内置默认服务器(id → 配置,可被用户配置按 id 覆盖,全量字段均可配置化表达)。 */
52
- export const defaultServers: Record<string, ServerConfig> = {
53
- typescript: {
54
- include: ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}"],
55
- rootMarkers: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"],
56
- bin: "typescript-language-server",
57
- args: ["--stdio"],
58
- cwd: "{root}",
59
- languageIdByExtension: {
60
- ".ts": "typescript",
61
- ".tsx": "typescriptreact",
62
- ".js": "javascript",
63
- ".jsx": "javascriptreact",
64
- ".mjs": "javascript",
65
- ".cjs": "javascript",
66
- ".mts": "typescript",
67
- ".cts": "typescript",
68
- },
69
- },
70
- pyright: {
71
- include: ["**/*.py", "**/*.pyi"],
72
- rootMarkers: [
73
- "pyproject.toml",
74
- "setup.py",
75
- "setup.cfg",
76
- "requirements.txt",
77
- "Pipfile",
78
- "pyrightconfig.json",
79
- ],
80
- bin: "pyright-langserver",
81
- args: ["--stdio"],
82
- cwd: "{root}",
83
- languageIdByExtension: { ".py": "python", ".pyi": "python" },
84
- },
85
- ruff: {
86
- include: ["**/*.py", "**/*.pyi"],
87
- rootMarkers: ["pyproject.toml", "ruff.toml", ".ruff.toml"],
88
- bin: "ruff",
89
- args: ["server"],
90
- cwd: "{root}",
91
- languageIdByExtension: { ".py": "python", ".pyi": "python" },
92
- },
93
- clangd: {
94
- include: ["**/*.{c,h,cpp,hpp,cc,cxx,c++,hh,hxx,h++}"],
95
- rootMarkers: ["compile_commands.json", "compile_flags.txt", ".clangd"],
96
- bin: "clangd",
97
- args: ["--background-index", "--clang-tidy"],
98
- cwd: "{root}",
99
- languageIdByExtension: {
100
- ".c": "c",
101
- ".h": "c",
102
- ".cpp": "cpp",
103
- ".hpp": "cpp",
104
- ".cc": "cpp",
105
- ".cxx": "cpp",
106
- ".c++": "cpp",
107
- ".hh": "cpp",
108
- ".hxx": "cpp",
109
- ".h++": "cpp",
110
- },
111
- },
112
- };
113
-
114
51
  /** 按 id 合并 servers record:同名 id 整体覆盖(不做逐字段 merge),其余保留;返回 undefined 表示没有任何 servers 定义。 */
115
52
  export function mergeServerRecords(
116
53
  ...records: (Readonly<Record<string, ServerConfig>> | undefined)[]
@@ -123,21 +60,11 @@ export function mergeServerRecords(
123
60
  return Object.keys(merged).length > 0 ? merged : undefined;
124
61
  }
125
62
 
126
- /** 用户 servers(id → 配置)与默认配置合并:同名 id 整体覆盖、新增 id、enabled:false 移除。 */
127
- export function mergeServerConfigs(
128
- defaults: Readonly<Record<string, ServerConfig>>,
129
- user: Readonly<Record<string, ServerConfig>> | undefined,
130
- ): Record<string, ServerConfig> {
131
- if (!user) return { ...defaults };
132
- const merged = { ...defaults };
133
- for (const [id, server] of Object.entries(user)) {
134
- if (server.enabled === false) {
135
- delete merged[id];
136
- continue;
137
- }
138
- merged[id] = server;
139
- }
140
- return merged;
63
+ /** 组装启用的服务器列表:全部来自用户配置 servers(id → 配置),无内置默认。 */
64
+ export function createAdapters(
65
+ userServers?: Readonly<Record<string, ServerConfig>>,
66
+ ): LspServerAdapter[] {
67
+ return Object.entries(userServers ?? {}).map(([id, config]) => new ConfigAdapter(id, config));
141
68
  }
142
69
 
143
70
  /** {root} / {cwd} 模板替换(bin / cwd 字段均支持)。 */
@@ -220,12 +147,3 @@ export class ConfigAdapter implements LspServerAdapter {
220
147
  };
221
148
  }
222
149
  }
223
-
224
- /** 组装启用的服务器列表:默认配置 + 用户 servers(id → 配置)合并。 */
225
- export function createAdapters(
226
- userServers?: Readonly<Record<string, ServerConfig>>,
227
- ): LspServerAdapter[] {
228
- return Object.entries(mergeServerConfigs(defaultServers, userServers)).map(
229
- ([id, config]) => new ConfigAdapter(id, config),
230
- );
231
- }