@trim21/personal-pi-extensions 0.0.249 → 0.0.252

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
@@ -81,9 +81,18 @@ bash 工具(opencode 风格 `bash`、Claude Code 风格 `Bash`)注册了 `da
81
81
  "tmpfsPaths": [],
82
82
  // 额外 bwrap 参数
83
83
  "extraArgs": ["--die-with-parent"],
84
+ // 全权限执行的自动审批规则:命中规则的命令不弹确认框
85
+ // allow 直接放行,deny 直接拒绝;命令用 tree-sitter 解析,
86
+ // 按 BashArity 生成模式(git checkout main → "git checkout *")
87
+ "approvalRules": [
88
+ { "action": "allow", "pattern": "git status *" },
89
+ { "action": "deny", "pattern": "git push *" },
90
+ ],
84
91
  }
85
92
  ```
86
93
 
94
+ `dangerouslyDisableSandbox: true` 的审批流程:先按 `approvalRules` 匹配(含嵌套 `$(...)` 内的命令,规则后写优先),命中 allow/deny 直接放行/拒绝,未命中才弹确认框。
95
+
87
96
  ### 使用
88
97
 
89
98
  bwrap 已集成进 bash 工具实现(opencode 风格 `bash` 位于 `src/opencode/bash.ts`,Claude Code 风格 `Bash` 位于 `src/claude-code/shell.ts`),随对应扩展一起加载,无需单独安装。bash 工具内置默认超时 120 秒。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.249",
3
+ "version": "0.0.252",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -77,5 +77,9 @@
77
77
  "prettier --write"
78
78
  ]
79
79
  },
80
- "packageManager": "pnpm@11.21.0"
80
+ "packageManager": "pnpm@11.21.0",
81
+ "dependencies": {
82
+ "tree-sitter-bash": "^0.25.1",
83
+ "web-tree-sitter": "^0.26.12"
84
+ }
81
85
  }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * approval-rules —— bash 命令审核组件(对齐 opencode 的权限方法):
3
+ * 用 tree-sitter 解析命令(含嵌套 `$(...)`),按 BashArity 生成命令模式
4
+ * (`git checkout main` → `git checkout *`),再用通配匹配对 allow/deny
5
+ * 规则求值。接入 bwrap 的 `dangerouslyDisableSandbox` 审批:命中规则自动
6
+ * 放行/拒绝,未命中才弹审批对话框。
7
+ *
8
+ * 参考实现:
9
+ * - opencode packages/opencode/src/permission/arity.ts(BashArity 表)
10
+ * - opencode packages/core/src/util/wildcard.ts(通配匹配)
11
+ * - opencode packages/opencode/src/tool/shell.ts(tree-sitter 命令提取)
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ import type { Node } from "web-tree-sitter";
17
+
18
+ import { resolvePackageWasm } from "../lib/wasm.js";
19
+
20
+ /** 解析后的单个命令:命令名 + 参数 + 原文 + 嵌套命令(命令替换里的)。 */
21
+ export interface BashCommand {
22
+ name: string;
23
+ args: string[];
24
+ raw: string;
25
+ nested: BashCommand[];
26
+ }
27
+
28
+ export interface ParsedBash {
29
+ commands: BashCommand[];
30
+ /** 语法错误时的提示(解析失败不抛错,退化为无法匹配)。 */
31
+ error?: string;
32
+ }
33
+
34
+ export type ApprovalAction = "allow" | "deny";
35
+
36
+ export interface ApprovalRule {
37
+ action: ApprovalAction;
38
+ /** 命令模式,如 `git push *`、`curl *`、`npm install *`。 */
39
+ pattern: string;
40
+ }
41
+
42
+ // ── tree-sitter ──────────────────────────────────────────────────────────────
43
+
44
+ interface BashParser {
45
+ parse: (source: string) => unknown;
46
+ }
47
+
48
+ /**
49
+ * 延迟初始化 tree-sitter bash parser(wasm 加载开销大,只做一次)。
50
+ * Node 环境:主 wasm 由 web-tree-sitter 按自身路径自动加载,
51
+ * bash grammar 的 wasm 显式读文件传入。
52
+ */
53
+ function createParserLoader(): () => Promise<BashParser> {
54
+ let parserPromise: Promise<BashParser> | undefined;
55
+ return function loadParser(): Promise<BashParser> {
56
+ parserPromise ??= (async () => {
57
+ const { Language, Parser } = await import("web-tree-sitter");
58
+ await Parser.init();
59
+ const bashWasm = readFileSync(
60
+ fileURLToPath(resolvePackageWasm("tree-sitter-bash", "tree-sitter-bash.wasm")),
61
+ );
62
+ const language = await Language.load(bashWasm);
63
+ const parser = new Parser();
64
+ parser.setLanguage(language);
65
+ return { parse: (source: string) => parser.parse(source) };
66
+ })();
67
+ return parserPromise;
68
+ };
69
+ }
70
+ const loadParser = createParserLoader();
71
+
72
+ // ── 命令提取(对齐 opencode shell.ts 的 commands/parts)────────────────────
73
+
74
+ /** 命令参数中需要跳过的节点类型。 */
75
+ const SKIP_ARG_TYPES = new Set(["command_argument_sep", "redirection"]);
76
+
77
+ function extractParts(node: Node): { name: string; args: string[] } {
78
+ const name: string[] = [];
79
+ const args: string[] = [];
80
+ const visit = (child: Node) => {
81
+ if (child.type === "command_name" || child.type === "command_name_expr") {
82
+ name.push(child.text);
83
+ return;
84
+ }
85
+ if (child.type === "command_elements") {
86
+ for (let i = 0; i < child.childCount; i++) {
87
+ const item = child.child(i);
88
+ if (item && !SKIP_ARG_TYPES.has(item.type)) {
89
+ // 参数词与命令替换都保留原文(命令替换内部的命令由 nested 提取)
90
+ args.push(item.text);
91
+ }
92
+ }
93
+ return;
94
+ }
95
+ if (
96
+ child.type === "word" ||
97
+ child.type === "string" ||
98
+ child.type === "raw_string" ||
99
+ child.type === "concatenation"
100
+ ) {
101
+ args.push(child.text);
102
+ }
103
+ };
104
+ for (let i = 0; i < node.childCount; i++) {
105
+ const child = node.child(i);
106
+ if (child) visit(child);
107
+ }
108
+ return { name: name.join(" "), args };
109
+ }
110
+
111
+ function collectCommand(node: Node, all: Node[]): BashCommand | undefined {
112
+ const { name, args } = extractParts(node);
113
+ if (!name) return undefined;
114
+ // 嵌套命令 = 完全落在本命令范围内的其他 command 节点(含 `$(...)` 内的)。
115
+ // 用位置判断而非 descendantsOfType 递归:0.26 的 descendantsOfType 会包含
116
+ // 自身且每次返回新 wrapper,`===` 比较失效会无限递归。
117
+ const nested: BashCommand[] = [];
118
+ for (const descendant of all) {
119
+ if (descendant === node) continue;
120
+ if (descendant.startIndex >= node.startIndex && descendant.endIndex <= node.endIndex) {
121
+ const inner = collectCommand(descendant, all);
122
+ if (inner) nested.push(inner);
123
+ }
124
+ }
125
+ return { name, args, raw: node.text, nested };
126
+ }
127
+
128
+ /**
129
+ * 解析 bash 命令,返回所有命令(含嵌套 `$(...)` 与管道两端)。
130
+ * 语法错误时返回 `error` 而不抛错——审核失败应拒绝而非崩溃。
131
+ */
132
+ export async function parseBashCommands(command: string): Promise<ParsedBash> {
133
+ try {
134
+ const parser = await loadParser();
135
+ const tree = parser.parse(command) as { rootNode: Node };
136
+ const all = tree.rootNode.descendantsOfType("command");
137
+ const commands: BashCommand[] = [];
138
+ for (const node of all) {
139
+ const parsed = collectCommand(node, all);
140
+ if (parsed) commands.push(parsed);
141
+ }
142
+ return { commands };
143
+ } catch (error) {
144
+ return {
145
+ commands: [],
146
+ error: error instanceof Error ? error.message : String(error),
147
+ };
148
+ }
149
+ }
150
+
151
+ // ── BashArity:命令前缀 → token 数(参考 opencode arity.ts)─────────────────
152
+
153
+ /**
154
+ * 命令前缀 → 定义该命令的 token 数。`git checkout main` → `git` 的 arity 2,
155
+ * 权限模式取前 2 个 token + `*`(`git checkout *`),避免具体参数进规则。
156
+ * 表来自 opencode packages/opencode/src/permission/arity.ts(Apache-2.0),
157
+ * 数据存放在 arity.json(所有 key 带引号)。
158
+ */
159
+ const ARITY = JSON.parse(readFileSync(new URL("arity.json", import.meta.url), "utf8")) as Record<
160
+ string,
161
+ number
162
+ >;
163
+
164
+ /**
165
+ * 生成命令的权限模式:BashArity 前缀 + `*`。
166
+ * `git checkout main` → `git checkout *`;未收录的命令 → 命令名 + `*`。
167
+ */
168
+ export function commandPattern(command: BashCommand): string {
169
+ const tokens = [command.name, ...command.args];
170
+ for (let len = tokens.length; len > 0; len--) {
171
+ const prefix = tokens.slice(0, len).join(" ");
172
+ const arity = ARITY[prefix];
173
+ if (arity !== undefined) return [...tokens.slice(0, arity), "*"].join(" ");
174
+ }
175
+ if (tokens.length === 0) return "*";
176
+ return [tokens[0], "*"].join(" ");
177
+ }
178
+
179
+ // ── 通配匹配(参考 opencode wildcard.ts)────────────────────────────────────
180
+
181
+ /**
182
+ * `*` 匹配任意字符序列,`?` 匹配单个字符;规则模式是正则字面量。
183
+ * `git push *` 匹配 `git push main` 等。
184
+ */
185
+ export function matchRule(input: string, pattern: string): boolean {
186
+ let escaped = pattern
187
+ .replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`)
188
+ .replaceAll("*", ".*")
189
+ .replaceAll("?", ".");
190
+ if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?";
191
+ return new RegExp(`^${escaped}$`, "s").test(input);
192
+ }
193
+
194
+ // ── 规则求值 ────────────────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * 命令(含所有嵌套命令)的权限模式列表(命令替换里的命令也展开)。
198
+ * 供规则求值与"allow forever"写规则复用。
199
+ */
200
+ export async function commandPatternsFor(command: string): Promise<string[]> {
201
+ const parsed = await parseBashCommands(command);
202
+ const flat: string[] = [];
203
+ const visit = (cmd: BashCommand) => {
204
+ flat.push(commandPattern(cmd));
205
+ for (const nested of cmd.nested) visit(nested);
206
+ };
207
+ for (const cmd of parsed.commands) visit(cmd);
208
+ return flat;
209
+ }
210
+
211
+ /**
212
+ * 对命令(含所有嵌套命令)求值:任一命令命中规则即生效,规则后写优先
213
+ * (findLast,对齐 opencode PermissionV2)。返回 allow/deny;无规则命中
214
+ * 返回 undefined(交给人审)。
215
+ */
216
+ export function evaluateBashApproval(
217
+ command: string,
218
+ rules: readonly ApprovalRule[],
219
+ ): Promise<ApprovalAction | undefined> {
220
+ return commandPatternsFor(command).then((patterns) => {
221
+ if (patterns.length === 0) return;
222
+ // 规则后写优先(对齐 opencode PermissionV2 的 findLast)
223
+ for (const pattern of patterns) {
224
+ const rule = rules.findLast((r) => matchRule(pattern, r.pattern));
225
+ if (rule) return rule.action;
226
+ }
227
+ return;
228
+ });
229
+ }
@@ -0,0 +1,138 @@
1
+ {
2
+ "cat": 1,
3
+ "cd": 1,
4
+ "chmod": 1,
5
+ "chown": 1,
6
+ "cp": 1,
7
+ "echo": 1,
8
+ "env": 1,
9
+ "export": 1,
10
+ "grep": 1,
11
+ "kill": 1,
12
+ "killall": 1,
13
+ "ln": 1,
14
+ "ls": 1,
15
+ "mkdir": 1,
16
+ "mv": 1,
17
+ "ps": 1,
18
+ "pwd": 1,
19
+ "rm": 1,
20
+ "rmdir": 1,
21
+ "sleep": 1,
22
+ "source": 1,
23
+ "tail": 1,
24
+ "touch": 1,
25
+ "unset": 1,
26
+ "which": 1,
27
+ "aws": 3,
28
+ "az": 3,
29
+ "bazel": 2,
30
+ "brew": 2,
31
+ "bun": 2,
32
+ "bun run": 3,
33
+ "bun x": 3,
34
+ "cargo": 2,
35
+ "cargo add": 3,
36
+ "cargo run": 3,
37
+ "cdk": 2,
38
+ "cf": 2,
39
+ "cmake": 2,
40
+ "composer": 2,
41
+ "consul": 2,
42
+ "consul kv": 3,
43
+ "crictl": 2,
44
+ "deno": 2,
45
+ "deno task": 3,
46
+ "doctl": 3,
47
+ "docker": 2,
48
+ "docker builder": 3,
49
+ "docker compose": 3,
50
+ "docker container": 3,
51
+ "docker image": 3,
52
+ "docker network": 3,
53
+ "docker volume": 3,
54
+ "eksctl": 2,
55
+ "eksctl create": 3,
56
+ "firebase": 2,
57
+ "flyctl": 2,
58
+ "gcloud": 3,
59
+ "gh": 3,
60
+ "git": 2,
61
+ "git config": 3,
62
+ "git remote": 3,
63
+ "git stash": 3,
64
+ "go": 2,
65
+ "gradle": 2,
66
+ "helm": 2,
67
+ "heroku": 2,
68
+ "hugo": 2,
69
+ "ip": 2,
70
+ "ip addr": 3,
71
+ "ip link": 3,
72
+ "ip netns": 3,
73
+ "ip route": 3,
74
+ "kind": 2,
75
+ "kind create": 3,
76
+ "kubectl": 2,
77
+ "kubectl kustomize": 3,
78
+ "kubectl rollout": 3,
79
+ "kustomize": 2,
80
+ "make": 2,
81
+ "mc": 2,
82
+ "mc admin": 3,
83
+ "minikube": 2,
84
+ "mongosh": 2,
85
+ "mysql": 2,
86
+ "mvn": 2,
87
+ "ng": 2,
88
+ "npm": 2,
89
+ "npm exec": 3,
90
+ "npm init": 3,
91
+ "npm run": 3,
92
+ "npm view": 3,
93
+ "nvm": 2,
94
+ "nx": 2,
95
+ "openssl": 2,
96
+ "openssl req": 3,
97
+ "openssl x509": 3,
98
+ "pip": 2,
99
+ "pipenv": 2,
100
+ "pnpm": 2,
101
+ "pnpm dlx": 3,
102
+ "pnpm exec": 3,
103
+ "pnpm run": 3,
104
+ "poetry": 2,
105
+ "podman": 2,
106
+ "podman container": 3,
107
+ "podman image": 3,
108
+ "psql": 2,
109
+ "pulumi": 2,
110
+ "pulumi stack": 3,
111
+ "pyenv": 2,
112
+ "python": 2,
113
+ "rake": 2,
114
+ "rbenv": 2,
115
+ "redis-cli": 2,
116
+ "rustup": 2,
117
+ "serverless": 2,
118
+ "sfdx": 3,
119
+ "skaffold": 2,
120
+ "sls": 2,
121
+ "sst": 2,
122
+ "swift": 2,
123
+ "systemctl": 2,
124
+ "terraform": 2,
125
+ "terraform workspace": 3,
126
+ "tmux": 2,
127
+ "turbo": 2,
128
+ "ufw": 2,
129
+ "vault": 2,
130
+ "vault auth": 3,
131
+ "vault kv": 3,
132
+ "vercel": 2,
133
+ "volta": 2,
134
+ "wp": 2,
135
+ "yarn": 2,
136
+ "yarn dlx": 3,
137
+ "yarn run": 3
138
+ }
package/src/bwrap/core.ts CHANGED
@@ -12,6 +12,7 @@ import { type Static, Type } from "typebox";
12
12
  import { Value } from "typebox/value";
13
13
 
14
14
  import { expandHome } from "../lib/path.js";
15
+ import { type ApprovalRule } from "./approval-rules.js";
15
16
 
16
17
  const PROTECTED_DIRS = [".git", ".pi", ".agent"];
17
18
 
@@ -26,6 +27,17 @@ const bwrapConfigProperties = {
26
27
  extraWritablePaths: Type.Array(Type.String()),
27
28
  tmpfsPaths: Type.Array(Type.String()),
28
29
  extraArgs: Type.Array(Type.String()),
30
+ approvalRules: Type.Optional(
31
+ Type.Array(
32
+ Type.Object(
33
+ {
34
+ action: StringEnum(["allow", "deny"] as const),
35
+ pattern: Type.String({ description: '命令模式,如 "git push *"、"npm install *"' }),
36
+ },
37
+ { additionalProperties: false },
38
+ ),
39
+ ),
40
+ ),
29
41
  };
30
42
 
31
43
  export const bwrapConfigSchema = Type.Object(bwrapConfigProperties, {
@@ -48,6 +60,8 @@ export interface ResolvedBwrap {
48
60
  extraWritablePaths: string[];
49
61
  tmpfsPaths: string[];
50
62
  extraArgs: string[];
63
+ /** 全权限执行的自动审批规则(allow/deny 命令模式)。 */
64
+ approvalRules: ApprovalRule[];
51
65
  }
52
66
 
53
67
  const DEFAULT_CONFIG: BwrapConfig = {
@@ -66,6 +80,7 @@ export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
66
80
  extraWritablePaths: config.extraWritablePaths,
67
81
  tmpfsPaths: config.tmpfsPaths ?? [],
68
82
  extraArgs: config.extraArgs ?? [],
83
+ approvalRules: config.approvalRules ?? [],
69
84
  };
70
85
  switch (config.mode) {
71
86
  case "allow-all": {
@@ -102,6 +117,7 @@ function deepMerge(base: BwrapConfig, overrides: Partial<BwrapConfig>): BwrapCon
102
117
  extraWritablePaths: [...base.extraWritablePaths, ...(overrides.extraWritablePaths ?? [])],
103
118
  tmpfsPaths: overrides.tmpfsPaths ?? base.tmpfsPaths,
104
119
  extraArgs: overrides.extraArgs ?? base.extraArgs,
120
+ approvalRules: [...(base.approvalRules ?? []), ...(overrides.approvalRules ?? [])],
105
121
  };
106
122
  }
107
123
 
@@ -1,18 +1,30 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, type WriteStream } from "node:fs";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+
1
6
  import type {
2
7
  AgentToolUpdateCallback,
3
8
  ExtensionAPI,
4
9
  ExtensionCommandContext,
5
10
  ExtensionContext,
6
11
  } from "@earendil-works/pi-coding-agent";
7
- import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ createLocalBashOperations,
14
+ getAgentDir,
15
+ truncateTail,
16
+ type TruncationResult,
17
+ } from "@earendil-works/pi-coding-agent";
8
18
  import { type TObject, Type } from "typebox";
9
19
 
10
20
  import { type CommandSpec, parseCommand } from "../lib/cli.js";
11
- import { selectWithOptionalInput } from "../lib/ui.js";
21
+ import { type SelectAction, selectWithOptionalInput } from "../lib/ui.js";
22
+ import { type ApprovalRule, commandPatternsFor, evaluateBashApproval } from "./approval-rules.js";
12
23
  import {
13
24
  type BwrapMode,
14
25
  createBwrapBashOperations,
15
26
  findBwrap,
27
+ getBwrapConfigPaths,
16
28
  loadBwrapConfig,
17
29
  resolveBwrap,
18
30
  resolveBwrapPath,
@@ -33,6 +45,20 @@ export function resolveEscalation(opts: { hasUI: boolean }): EscalationDecision
33
45
  return { kind: "dialog" };
34
46
  }
35
47
 
48
+ /** 全权限审批对话框的选项 label(也作为 switch 匹配键与测试引用)。 */
49
+ export const ALLOW_ONCE = "Allow once";
50
+ export const ALLOW_FOREVER = "Allow forever";
51
+ export const DENY = "Deny";
52
+ export const DENY_WITH_REASON = "Deny with reason";
53
+
54
+ /** 审批对话框选项:允许一次 / 永久允许 / 拒绝 / 拒绝并附理由。 */
55
+ export const FULL_ACCESS_CHOICES: readonly SelectAction[] = [
56
+ { label: ALLOW_ONCE },
57
+ { label: ALLOW_FOREVER },
58
+ { label: DENY },
59
+ { label: DENY_WITH_REASON, inputPrompt: "Why was this denied?" },
60
+ ];
61
+
36
62
  export interface BwrapExecutionRequest {
37
63
  toolCallId: string;
38
64
  command: string;
@@ -45,12 +71,18 @@ export interface BwrapExecutionRequest {
45
71
  }
46
72
 
47
73
  /**
48
- * 底层执行结果:完整退出码 + 完整输出(stdout/stderr 合并,未截断)。
49
- * 退出码语义(grep exit 1 等)由上层 Bash 工具解释,这里不做成败判定。
74
+ * 底层执行结果:完整退出码 + 截断后的输出文本。
75
+ * 输出在运行时就直接写入 agent-dir/tmp/{uuid}.txt(完整内容),内存不保留全量;
76
+ * `truncation.totalLines/totalBytes` 是精确统计值(非尾部缓冲的)。
77
+ * 退出码语义由上层 Bash 工具解释,这里不做成败判定。
50
78
  */
51
79
  export interface BwrapExecutionResult {
52
80
  exitCode: number | null;
81
+ /** 截断后的输出(尾部),未截断时为完整输出;空输出为空字符串。 */
53
82
  output: string;
83
+ /** 完整输出的文件路径;无输出时不存在。 */
84
+ fullOutputPath?: string;
85
+ truncation: TruncationResult;
54
86
  }
55
87
 
56
88
  function escapeHtml(text: string): string {
@@ -71,30 +103,87 @@ function fenceCodeBlock(code: string): string {
71
103
  const BASH_UPDATE_THROTTLE_MS = 100;
72
104
  /** 进度快照只保留尾部内容,避免大输出每 100ms 全量推给 TUI。 */
73
105
  const BASH_UPDATE_TAIL_BYTES = 64 * 1024;
106
+ /** 内存尾部缓冲上限:必须大于 truncateTail 的默认上限(50KB / 2000 行)。 */
107
+ const BASH_TAIL_LIMIT_BYTES = 1024 * 1024;
108
+
109
+ function countNewlines(data: Buffer): number {
110
+ let count = 0;
111
+ for (const byte of data) {
112
+ if (byte === 0x0a) count++;
113
+ }
114
+ return count;
115
+ }
74
116
 
75
117
  /**
76
- * 合并 stdout/stderr 的流式输出累积器。
77
- * 内存中保留全部输出供最终结果使用;进度快照只取尾部。
118
+ * 合并 stdout/stderr 的流式输出累积器:输出在运行时就直接写入
119
+ * agent-dir/tmp/{uuid}.txt(完整内容),内存只保留尾部缓冲。
120
+ * 大输出不会撑爆内存;最终结果只返回截断后的文本。
78
121
  */
79
122
  class BashOutput {
80
- private chunks: Buffer[] = [];
123
+ private stream: WriteStream | undefined;
124
+ private writeError: Error | undefined;
125
+ private tail: Buffer[] = [];
126
+ private tailBytes = 0;
81
127
  private totalBytes = 0;
128
+ private totalLines = 0;
129
+ filePath: string | undefined;
82
130
 
83
131
  append(data: Buffer): void {
84
- this.chunks.push(data);
85
132
  this.totalBytes += data.length;
133
+ this.totalLines += countNewlines(data);
134
+ if (!this.stream) {
135
+ const dir = join(getAgentDir(), "tmp");
136
+ mkdirSync(dir, { recursive: true });
137
+ this.filePath = join(dir, `${randomUUID()}.txt`);
138
+ this.stream = createWriteStream(this.filePath, { flags: "w" });
139
+ this.stream.on("error", (error) => {
140
+ this.writeError = error;
141
+ });
142
+ }
143
+ this.stream.write(data);
144
+ this.tail.push(data);
145
+ this.tailBytes += data.length;
146
+ while (this.tailBytes > BASH_TAIL_LIMIT_BYTES && this.tail.length > 1) {
147
+ this.tailBytes -= this.tail[0].length;
148
+ this.tail.shift();
149
+ }
150
+ if (this.tailBytes > BASH_TAIL_LIMIT_BYTES && this.tail.length === 1) {
151
+ // 单个 chunk 超过上限:截掉头部,只保留尾部
152
+ this.tail[0] = this.tail[0].subarray(this.tailBytes - BASH_TAIL_LIMIT_BYTES);
153
+ this.tailBytes = BASH_TAIL_LIMIT_BYTES;
154
+ }
155
+ }
156
+
157
+ close(): Promise<void> {
158
+ if (!this.stream) return Promise.resolve();
159
+ const stream = this.stream;
160
+ this.stream = undefined;
161
+ return new Promise((resolve) => {
162
+ stream.end(() => {
163
+ if (this.writeError) {
164
+ // 落盘失败(如 readonly 沙箱):降级为纯内存模式,命令仍正常返回
165
+ this.filePath = undefined;
166
+ }
167
+ resolve();
168
+ });
169
+ });
170
+ }
171
+
172
+ /** 尾部文本(截断结果的候选,未截断时即完整输出)。 */
173
+ tailText(): string {
174
+ return Buffer.concat(this.tail, this.tailBytes).toString("utf8");
86
175
  }
87
176
 
88
- toString(): string {
89
- return Buffer.concat(this.chunks, this.totalBytes).toString("utf8");
177
+ get stats(): { totalBytes: number; totalLines: number } {
178
+ return { totalBytes: this.totalBytes, totalLines: this.totalLines };
90
179
  }
91
180
 
92
181
  /** 尾部快照(用于流式进度显示)。 */
93
182
  tailSnapshot(): string {
94
183
  let remaining = BASH_UPDATE_TAIL_BYTES;
95
184
  const tail: Buffer[] = [];
96
- for (let i = this.chunks.length - 1; i >= 0 && remaining > 0; i--) {
97
- const chunk = this.chunks[i];
185
+ for (let i = this.tail.length - 1; i >= 0 && remaining > 0; i--) {
186
+ const chunk = this.tail[i];
98
187
  if (chunk.length <= remaining) {
99
188
  tail.unshift(chunk);
100
189
  remaining -= chunk.length;
@@ -202,7 +291,14 @@ export class BwrapRuntime {
202
291
  );
203
292
  }
204
293
  if (request.requestFullAccess === true && runtime.bwrapEnabled) {
205
- await this.approveFullAccess(request.ctx, request.command, request.requestFullAccessReason);
294
+ // 先按 approvalRules 自动判定:allow 直接放行,deny 直接拒绝,未命中才弹框
295
+ const decision = await evaluateBashApproval(request.command, runtime.approvalRules);
296
+ if (decision === "deny") {
297
+ throw new Error(`Command denied by bwrap approval rule: ${request.command}`);
298
+ }
299
+ if (decision === undefined) {
300
+ await this.approveFullAccess(request.ctx, request.command, request.requestFullAccessReason);
301
+ }
206
302
  }
207
303
  const operations =
208
304
  runtime.bwrapEnabled && request.requestFullAccess !== true
@@ -251,7 +347,15 @@ export class BwrapRuntime {
251
347
  signal: request.signal,
252
348
  timeout: request.timeout,
253
349
  });
254
- return { exitCode, output: output.toString() };
350
+ await output.close();
351
+ const truncation = truncateTail(output.tailText());
352
+ return {
353
+ exitCode,
354
+ output: truncation.content,
355
+ ...(output.filePath && { fullOutputPath: output.filePath }),
356
+ // 用精确统计值覆盖尾部缓冲的估算(提示文本的行数/字节数要准确)
357
+ truncation: { ...truncation, ...output.stats },
358
+ };
255
359
  } catch (error) {
256
360
  // 底层统一把超时/中断转成可读文案(对齐 pi 内置 bash 工具)
257
361
  if (error instanceof Error && error.message.startsWith("timeout:")) {
@@ -267,6 +371,7 @@ export class BwrapRuntime {
267
371
  } finally {
268
372
  if (updateTimer) clearTimeout(updateTimer);
269
373
  if (onUpdate && dirty) emitUpdate();
374
+ await output.close();
270
375
  }
271
376
  }
272
377
 
@@ -287,36 +392,57 @@ export class BwrapRuntime {
287
392
  if (policy.kind === "deny") throw new Error(policy.reason);
288
393
  const description = `Allow this command to run without sandbox?\n---\n\nReason: ${escapeHtml(reason ?? "(No reason provided by model)")}\n---\n${fenceCodeBlock(command)}`;
289
394
 
290
- // 单选 1:允许还是拦截(关闭对话框 = 中断并拒绝)
291
- const verdict = await selectWithOptionalInput(
292
- description,
293
- [{ label: "Approve once" }, { label: "Block" }],
294
- ctx.ui,
295
- { signal: ctx.signal },
296
- );
395
+ // 单选:允许一次 / 永久允许(写入规则)/ 拒绝 / 拒绝并附理由(弹输入框)
396
+ const verdict = await selectWithOptionalInput(description, FULL_ACCESS_CHOICES, ctx.ui, {
397
+ signal: ctx.signal,
398
+ });
399
+ // 关闭对话框 = 中断并拒绝,不循环重问
297
400
  if (verdict === undefined) {
298
401
  ctx.abort();
299
402
  throw new Error("User denied the command execution.");
300
403
  }
301
- if (verdict.label === "Approve once") return;
302
-
303
- // 单选 2 + input 组合:直接拦截还是附带理由;选 "Block with reason"
304
- // 自动弹输入框。关闭对话框/取消输入/空白都按无理由拒绝,不循环重问。
305
- const style = await selectWithOptionalInput(
306
- "Block this command?",
307
- [{ label: "Block" }, { label: "Block with reason", inputPrompt: "Why was this denied?" }],
308
- ctx.ui,
309
- { signal: ctx.signal },
310
- );
311
- if (style === undefined || style.label === "Block") {
312
- throw new Error("User denied unsandboxed execution.");
404
+ switch (verdict.label) {
405
+ case ALLOW_ONCE: {
406
+ return;
407
+ }
408
+ case ALLOW_FOREVER: {
409
+ await this.persistAllowRule(ctx, command);
410
+ return;
411
+ }
412
+ case DENY: {
413
+ throw new Error("User denied unsandboxed execution.");
414
+ }
415
+ case DENY_WITH_REASON: {
416
+ const feedback = verdict.input?.trim() ?? "";
417
+ throw new Error(
418
+ feedback
419
+ ? `User denied unsandboxed execution: ${feedback}`
420
+ : "User denied unsandboxed execution.",
421
+ );
422
+ }
423
+ }
424
+ }
425
+
426
+ /** 把命令的权限模式写入项目 bwrap.json 的 approvalRules(allow forever)。 */
427
+ private async persistAllowRule(ctx: ExtensionContext, command: string): Promise<void> {
428
+ const patterns = await commandPatternsFor(command);
429
+ if (patterns.length === 0) return; // 解析失败:本次放行,不写规则
430
+ const newRules: ApprovalRule[] = patterns.map((pattern) => ({ action: "allow", pattern }));
431
+ const { project } = getBwrapConfigPaths(ctx.cwd);
432
+ let config: Record<string, unknown> = {};
433
+ if (existsSync(project)) {
434
+ config = JSON.parse(readFileSync(project, "utf8")) as Record<string, unknown>;
435
+ }
436
+ const existing = Array.isArray(config.approvalRules)
437
+ ? (config.approvalRules as ApprovalRule[])
438
+ : [];
439
+ config.approvalRules = [...existing, ...newRules];
440
+ await mkdir(dirname(project), { recursive: true });
441
+ await writeFile(project, `${JSON.stringify(config, null, 2)}\n`, "utf8");
442
+ // 更新缓存的规则,立即生效
443
+ if (this.resolved) {
444
+ this.resolved.approvalRules = [...this.resolved.approvalRules, ...newRules];
313
445
  }
314
- const feedback = style.input?.trim() ?? "";
315
- throw new Error(
316
- feedback
317
- ? `User denied unsandboxed execution: ${feedback}`
318
- : "User denied unsandboxed execution.",
319
- );
320
446
  }
321
447
 
322
448
  private registerCommands(pi: ExtensionAPI): void {
@@ -1,17 +1,9 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { readFileSync } from "node:fs";
3
- import { mkdir, writeFile } from "node:fs/promises";
4
- import { join } from "node:path";
2
+ import { readFile } from "node:fs/promises";
5
3
  import { fileURLToPath } from "node:url";
6
4
 
7
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
- import {
9
- type BashToolDetails,
10
- DEFAULT_MAX_BYTES,
11
- formatSize,
12
- getAgentDir,
13
- truncateTail,
14
- } from "@earendil-works/pi-coding-agent";
6
+ import { type BashToolDetails, formatSize } from "@earendil-works/pi-coding-agent";
15
7
  import { Type } from "typebox";
16
8
 
17
9
  import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
@@ -42,24 +34,17 @@ function formatBashError(exitCode: number | null, output: string): string {
42
34
  }
43
35
 
44
36
  /**
45
- * 成功路径:truncateTail 截断 + 全量落盘临时文件,
46
- * 提示文本 `[Showing lines X-Y of N. Full output: path]`。
37
+ * 成功路径:消费 runtime 的截断结果(输出已由 runtime 截断并落盘),
38
+ * 截断时追加 `[Showing lines X-Y of N. Full output: path]` 提示。
47
39
  * opencode 套件的 bash 工具复用同一逻辑。
48
40
  */
49
- export async function formatBashSuccess(
50
- output: string,
51
- ): Promise<{ content: { type: "text"; text: string }[]; details: BashToolDetails | undefined }> {
52
- const truncation = truncateTail(output);
53
- let text = truncation.content || "(no output)";
54
- let details: BashToolDetails | undefined;
55
- if (truncation.truncated) {
56
- // 完整输出落盘到 agent 数据目录的 tmp 子目录(与 pi 的 agent 状态同处,
57
- // 模型可读;系统临时目录可能被清理)
58
- const dir = join(getAgentDir(), "tmp");
59
- await mkdir(dir, { recursive: true });
60
- const fullOutputPath = join(dir, `${randomUUID()}.txt`);
61
- await writeFile(fullOutputPath, output, "utf8");
62
- details = { truncation, fullOutputPath };
41
+ export function formatBashSuccess(result: Awaited<ReturnType<BwrapRuntime["execute"]>>): {
42
+ content: { type: "text"; text: string }[];
43
+ details: BashToolDetails | undefined;
44
+ } {
45
+ const { output, truncation, fullOutputPath } = result;
46
+ let text = output || "(no output)";
47
+ if (fullOutputPath && truncation.truncated) {
63
48
  const startLine = truncation.totalLines - truncation.outputLines + 1;
64
49
  const endLine = truncation.totalLines;
65
50
  if (truncation.lastLinePartial) {
@@ -70,10 +55,13 @@ export async function formatBashSuccess(
70
55
  } else if (truncation.truncatedBy === "lines") {
71
56
  text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${fullOutputPath}]`;
72
57
  } else {
73
- text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit). Full output: ${fullOutputPath}]`;
58
+ text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). Full output: ${fullOutputPath}]`;
74
59
  }
75
60
  }
76
- return { content: [{ type: "text", text }], details };
61
+ return {
62
+ content: [{ type: "text", text }],
63
+ details: fullOutputPath && truncation.truncated ? { truncation, fullOutputPath } : undefined,
64
+ };
77
65
  }
78
66
 
79
67
  /**
@@ -151,13 +139,16 @@ export function registerShellTools(
151
139
  }
152
140
 
153
141
  // 对齐 Claude Code:非 0 退出码视为错误(不做 grep/find 等命令语义化特判,
154
- // 任何非 0 都抛错);成功路径返回纯输出
142
+ // 任何非 0 都抛错);错误文本用完整输出(从落盘文件读取,必要时头尾截断)
155
143
  if (result.exitCode !== 0 && result.exitCode !== null) {
156
- throw new Error(formatBashError(result.exitCode, result.output), {
144
+ const full = result.fullOutputPath
145
+ ? await readFile(result.fullOutputPath, "utf8")
146
+ : result.output;
147
+ throw new Error(formatBashError(result.exitCode, full), {
157
148
  cause: result,
158
149
  });
159
150
  }
160
- return formatBashSuccess(result.output);
151
+ return formatBashSuccess(result);
161
152
  },
162
153
  });
163
154
  }
@@ -57,9 +57,12 @@ The capitalized tools below follow Claude Code behavior with a few deliberate de
57
57
 
58
58
  ## Bash
59
59
 
60
- - Commands run through the bwrap sandbox (modes: `allow-all` / `workspace-write` / `allow-net` / `readonly`), switchable via `/bwrap-*` commands. `dangerouslyDisableSandbox: true` requests one-time unsandboxed execution and needs user approval (denied in headless sessions).
60
+ - Commands run through the bwrap sandbox (modes: `allow-all` / `workspace-write` / `allow-net` / `readonly`), switchable via `/bwrap-*` commands. `dangerouslyDisableSandbox: true` requests one-time unsandboxed execution. Approval flow: commands are parsed (tree-sitter, including nested `$(...)`) and matched against `approvalRules` from `bwrap.json` — an `allow` rule auto-approves, a `deny` rule rejects outright (last matching rule wins), and only unmatched commands show the approval dialog. In headless sessions unsandboxed execution is denied.
61
61
  - `timeout` is in milliseconds, default 120000, max 600000. `workdir` overrides the working directory.
62
- - **Deviation from Claude Code:** no auto-backgrounding on timeouta timed-out command is killed and the error reports the timeout.
62
+ - **Non-zero exit code is a tool failure**: the error text starts with `Exit code N` followed by the full output (head/tail-truncated at 10000 chars if larger). **Deviation from Claude Code:** no command-semantics special cases`grep` with no matches (exit 1), `diff` differences, `test` false, etc. all fail like any other non-zero exit.
63
+ - Output is streamed to a file under `agent-dir/tmp/<uuid>.txt` during execution; the tool result only contains the truncated tail (2000 lines / 50 KB). On truncation a note is appended: `[Showing lines X-Y of N. Full output: <path>]` — read that file for the complete output. In a read-only sandbox where the write fails, the result degrades to the in-memory tail only.
64
+ - **Deviation from Claude Code:** no auto-backgrounding on timeout — a timed-out command is killed and the error reports `Command timed out after N milliseconds`.
65
+ - The lowercase opencode-style `bash` tool differs: it **never throws** on non-zero exit — it returns the output plus a `Command exited with code N.` status text block; a timeout returns `Command exceeded timeout of N ms. Retry with a larger timeout...` instead of failing.
63
66
 
64
67
  ## TodoWrite
65
68
 
@@ -0,0 +1,9 @@
1
+ /**
2
+ * wasm —— 运行时定位 npm 包内的 wasm 文件。
3
+ * Node ESM 下 `import.meta.resolve` 返回 file URL(受包 exports 约束)。
4
+ */
5
+
6
+ export function resolvePackageWasm(packageName: string, subpath: string): URL {
7
+ const resolved = import.meta.resolve(`${packageName}/${subpath}`);
8
+ return new URL(resolved);
9
+ }
@@ -2,16 +2,25 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
 
4
4
  import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
5
- import { formatBashSuccess } from "../claude-code/shell.js";
6
5
  import { resolveWorkdir } from "../lib/path.js";
7
6
 
8
7
  const DEFAULT_TIMEOUT_MS = 120_000;
9
8
  const MAX_TIMEOUT_MS = 600_000;
10
9
 
11
- export default function opencodeBash(pi: ExtensionAPI): void {
10
+ /** 对齐上游 opencode 的截断提示文案(tools/BashTool/bash.ts)。 */
11
+ const CAPTURE_TRUNCATED_NOTICE = "[output capture truncated at the in-memory safety limit]";
12
+
13
+ /**
14
+ * 对齐上游 opencode(packages/core/src/tool/bash.ts):
15
+ * 命令失败(非 0 退出码)与超时都不抛错,输出与状态文本一起返回,
16
+ * 由模型根据 `Command exited with code N.` 自行判断。
17
+ */
18
+ export default function opencodeBash(
19
+ pi: ExtensionAPI,
20
+ runtime: BwrapRuntime = createBwrapRuntime(),
21
+ ): void {
12
22
  // 每个扩展实例持有自己的 runtime:不依赖模块级全局状态,状态随扩展
13
23
  // 实例生命周期(进程启动 / /reload / session 切换时工厂重建即重置)。
14
- const runtime = createBwrapRuntime();
15
24
  runtime.setup(pi);
16
25
  pi.registerTool({
17
26
  name: "bash",
@@ -68,23 +77,39 @@ export default function opencodeBash(pi: ExtensionAPI): void {
68
77
  });
69
78
  } catch (error) {
70
79
  if (!(error instanceof Error)) throw error;
71
- const timeoutMatch = /Command timed out after [\d.]+ seconds/.exec(error.message);
72
- const message = timeoutMatch
73
- ? error.message.slice(0, timeoutMatch.index) +
74
- `Command timed out after ${timeout} milliseconds` +
75
- error.message.slice(timeoutMatch.index + timeoutMatch[0].length)
76
- : error.message;
77
- throw new Error(message, { cause: error });
80
+ // 对齐上游 opencode:超时不抛错,返回提示文本(丢弃部分输出)
81
+ if (/Command timed out after [\d.]+ seconds/.test(error.message)) {
82
+ return {
83
+ content: [
84
+ {
85
+ type: "text" as const,
86
+ text: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
87
+ },
88
+ { type: "text" as const, text: "Command timed out before completion." },
89
+ ],
90
+ details: { timeout: true },
91
+ };
92
+ }
93
+ throw error;
78
94
  }
79
95
 
80
- // 任何非 0 退出码都视为失败(不做命令语义化特判)
81
- if (result.exitCode !== 0 && result.exitCode !== null) {
82
- const status = `Command exited with code ${result.exitCode}`;
83
- throw new Error(result.output ? `${result.output}\n\n${status}` : status, {
84
- cause: result,
85
- });
96
+ // 命令失败(非 0 退出码)不抛错:输出与状态文本一起返回
97
+ let text = result.output || "(no output)";
98
+ if (result.truncation.truncated) {
99
+ text += `\n\n${CAPTURE_TRUNCATED_NOTICE}`;
100
+ if (result.fullOutputPath) text += `\nFull output: ${result.fullOutputPath}`;
86
101
  }
87
- return formatBashSuccess(result.output);
102
+ return {
103
+ content: [
104
+ { type: "text" as const, text },
105
+ { type: "text" as const, text: `Command exited with code ${result.exitCode}.` },
106
+ ],
107
+ details: {
108
+ exitCode: result.exitCode,
109
+ truncated: result.truncation.truncated,
110
+ ...(result.fullOutputPath && { fullOutputPath: result.fullOutputPath }),
111
+ },
112
+ };
88
113
  },
89
114
  });
90
115
  }