@trim21/personal-pi-extensions 0.0.251 → 0.0.254

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.251",
3
+ "version": "0.0.254",
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,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { createWriteStream, mkdirSync, type WriteStream } from "node:fs";
3
- import { join } from "node:path";
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";
4
5
 
5
6
  import type {
6
7
  AgentToolUpdateCallback,
@@ -17,11 +18,13 @@ import {
17
18
  import { type TObject, Type } from "typebox";
18
19
 
19
20
  import { type CommandSpec, parseCommand } from "../lib/cli.js";
20
- 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";
21
23
  import {
22
24
  type BwrapMode,
23
25
  createBwrapBashOperations,
24
26
  findBwrap,
27
+ getBwrapConfigPaths,
25
28
  loadBwrapConfig,
26
29
  resolveBwrap,
27
30
  resolveBwrapPath,
@@ -42,6 +45,20 @@ export function resolveEscalation(opts: { hasUI: boolean }): EscalationDecision
42
45
  return { kind: "dialog" };
43
46
  }
44
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
+
45
62
  export interface BwrapExecutionRequest {
46
63
  toolCallId: string;
47
64
  command: string;
@@ -274,7 +291,14 @@ export class BwrapRuntime {
274
291
  );
275
292
  }
276
293
  if (request.requestFullAccess === true && runtime.bwrapEnabled) {
277
- 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
+ }
278
302
  }
279
303
  const operations =
280
304
  runtime.bwrapEnabled && request.requestFullAccess !== true
@@ -368,36 +392,57 @@ export class BwrapRuntime {
368
392
  if (policy.kind === "deny") throw new Error(policy.reason);
369
393
  const description = `Allow this command to run without sandbox?\n---\n\nReason: ${escapeHtml(reason ?? "(No reason provided by model)")}\n---\n${fenceCodeBlock(command)}`;
370
394
 
371
- // 单选 1:允许还是拦截(关闭对话框 = 中断并拒绝)
372
- const verdict = await selectWithOptionalInput(
373
- description,
374
- [{ label: "Approve once" }, { label: "Block" }],
375
- ctx.ui,
376
- { signal: ctx.signal },
377
- );
395
+ // 单选:允许一次 / 永久允许(写入规则)/ 拒绝 / 拒绝并附理由(弹输入框)
396
+ const verdict = await selectWithOptionalInput(description, FULL_ACCESS_CHOICES, ctx.ui, {
397
+ signal: ctx.signal,
398
+ });
399
+ // 关闭对话框 = 中断并拒绝,不循环重问
378
400
  if (verdict === undefined) {
379
401
  ctx.abort();
380
402
  throw new Error("User denied the command execution.");
381
403
  }
382
- if (verdict.label === "Approve once") return;
383
-
384
- // 单选 2 + input 组合:直接拦截还是附带理由;选 "Block with reason"
385
- // 自动弹输入框。关闭对话框/取消输入/空白都按无理由拒绝,不循环重问。
386
- const style = await selectWithOptionalInput(
387
- "Block this command?",
388
- [{ label: "Block" }, { label: "Block with reason", inputPrompt: "Why was this denied?" }],
389
- ctx.ui,
390
- { signal: ctx.signal },
391
- );
392
- if (style === undefined || style.label === "Block") {
393
- 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];
394
445
  }
395
- const feedback = style.input?.trim() ?? "";
396
- throw new Error(
397
- feedback
398
- ? `User denied unsandboxed execution: ${feedback}`
399
- : "User denied unsandboxed execution.",
400
- );
401
446
  }
402
447
 
403
448
  private registerCommands(pi: ExtensionAPI): void {
@@ -57,7 +57,7 @@ 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
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
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.
@@ -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
+ }