@trim21/personal-pi-extensions 0.1.561 → 0.1.564

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
@@ -50,6 +50,10 @@ bash 工具(opencode 风格 `bash`、Claude Code 风格 `Bash`)注册了 `da
50
50
 
51
51
  建议模型不确定时先尝试沙箱模式,若因沙箱限制失败,再以完整权限重试。
52
52
 
53
+ 另一条提权路径是编辑类工具(`Edit` / `Write` / `lsp-rename` / `web_fetch`)写工作区外路径,同样经审批框放行。
54
+
55
+ 对话中执行 `/bwrap-deny-request` 后,上述两类提权请求都不再弹审批框,直接按用户无理由拒绝处理(bash 报 `User denied unsandboxed execution.`,编辑类工具报 `user deny <tool>: blocked`);`/bwrap-allow-request` 恢复审批。该开关只影响提权路径,沙箱内可执行的命令与工作区内写入不受影响,新会话开始时复位。
56
+
53
57
  ### 保护目录
54
58
 
55
59
  `.pi`、`.agent` 即使在 `workspace-write` 模式下也始终只读;`.git` 同样只读:工作区根是 git 仓库时保护根 `.git`,根不是 git 仓库时才递归扫描嵌套仓库(monorepo 子仓库,跳过 `node_modules`、`.venv` 等包目录)。
@@ -62,6 +66,8 @@ bash 工具(opencode 风格 `bash`、Claude Code 风格 `Bash`)注册了 `da
62
66
  - `/bwrap-allow-all` — 切换到 allow-all 模式
63
67
  - `/bwrap-workspace-write` — 切换到 workspace-write 模式
64
68
  - `/bwrap-readonly` — 切换到 readonly 模式
69
+ - `/bwrap-deny-request` — 拒绝模型的非沙盒请求,不再弹审批框
70
+ - `/bwrap-allow-request` — 恢复非沙盒请求的审批
65
71
 
66
72
  ### 配置
67
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.561",
3
+ "version": "0.1.564",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -24,8 +24,8 @@
24
24
  "format": "prettier --write .",
25
25
  "test": "vitest run",
26
26
  "test:coverage": "vitest run --coverage",
27
- "sandbox": "tsx bin/sandbox.ts",
28
- "aft-search": "tsx bin/aft-search.ts",
27
+ "sandbox": "jiti bin/sandbox.ts",
28
+ "aft-search": "jiti bin/aft-search.ts",
29
29
  "build:holder": "esbuild src/bwrap/holder.ts --format=esm --target=node24 --outfile=src/bwrap/holder.js",
30
30
  "prepare": "husky"
31
31
  },
@@ -56,10 +56,10 @@
56
56
  "eslint-plugin-unicorn": "74.0.0",
57
57
  "eslint-plugin-unused-imports": "4.4.1",
58
58
  "husky": "^9.1.7",
59
+ "jiti": "^2.7.0",
59
60
  "lint-staged": "^17.3.0",
60
61
  "prettier": "^3.9.6",
61
62
  "shlex": "^3.0.0",
62
- "tsx": "^4.23.12",
63
63
  "typebox": "1.3.1",
64
64
  "typescript": "npm:@typescript/typescript6@^6.0.2",
65
65
  "typescript-eslint": "^8.67.0",
@@ -18,6 +18,7 @@ import { type TObject, Type } from "typebox";
18
18
  import { type CommandSpec, parseCommand } from "../lib/cli.js";
19
19
  import { fenceCodeBlock } from "../lib/markdown.js";
20
20
  import { formatDisplayPath } from "../lib/path.js";
21
+ import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
21
22
  import { type SelectAction, selectMultiple, selectWithOptionalInput } from "../lib/ui.js";
22
23
  import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-rules.js";
23
24
  import { commandPatternsFor } from "./approval-suggest.js";
@@ -283,6 +284,12 @@ function describeNetwork(resolved: ResolvedBwrap): string {
283
284
  return "network access is unrestricted";
284
285
  }
285
286
 
287
+ /**
288
+ * 用户无理由拒绝非沙盒请求的文案。`/bwrap-deny-request` 生效时的拒绝必须与
289
+ * 用户在审批框点 Deny 完全一致——模型看到的是一次普通拒绝,而不是另一套错误语义。
290
+ */
291
+ const UNSANDBOXED_DENIED = "User denied unsandboxed execution.";
292
+
286
293
  /** 命令没经沙箱时沿用 prompt 里的说法,给出在沙盒外重跑的手段。 */
287
294
  const SANDBOX_ESCAPE_HATCH =
288
295
  "[Sandbox] If the command needs more than that, use the `dangerouslyDisableSandbox` parameter to request unsandboxed execution; the user must approve this request.";
@@ -313,6 +320,15 @@ export class BwrapRuntime {
313
320
  private bwrapUnavailable = false;
314
321
  /** net-allowlist 首次执行时解析一次 mihomo 路径,之后随 runtime 复用,不逐命令扫描 PATH。 */
315
322
  private mihomoPath: string | undefined;
323
+ /**
324
+ * 非沙盒请求策略由创建方注入:聚合入口把它与文件工具的 write-guard 共享同一实例,
325
+ * 独立入口各持一份、经 pi.events 同步。
326
+ */
327
+ private readonly policy: RequestPolicy;
328
+
329
+ constructor(policy: RequestPolicy) {
330
+ this.policy = policy;
331
+ }
316
332
 
317
333
  setup(pi: ExtensionAPI): void {
318
334
  pi.registerFlag("no-bwrap", {
@@ -325,6 +341,7 @@ export class BwrapRuntime {
325
341
  this.sandboxDisabled = pi.getFlag("no-bwrap") === true && ctx.hasUI;
326
342
  this.resolved = undefined;
327
343
  this.bwrapUnavailable = false;
344
+ this.policy.setDenyRequests(false);
328
345
  if (process.platform === "win32") {
329
346
  // Windows 没有 bubblewrap:不做 bwrap 检测、不显示 bwrap 状态,
330
347
  // 每条 bash 命令在 execute 时逐条人工审批,模型无需知道 bwrap 的存在。
@@ -346,7 +363,7 @@ export class BwrapRuntime {
346
363
  return;
347
364
  }
348
365
  }
349
- ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${runtime.mode}`));
366
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime.mode)));
350
367
  ctx.ui.notify(
351
368
  runtime.bwrapEnabled
352
369
  ? `bwrap initialized (${runtime.mode})`
@@ -373,9 +390,14 @@ export class BwrapRuntime {
373
390
  !isWindows && this.bwrapUnavailable
374
391
  ? " bwrap is unavailable (binary not found): bash commands are refused unless the user explicitly approves unsandboxed execution."
375
392
  : "";
393
+ const denyRequestsText =
394
+ !isWindows && this.policy.deniesRequests()
395
+ ? " Unsandboxed execution is currently denied by the user: `dangerouslyDisableSandbox` and writes outside the workspace are refused without approval."
396
+ : "";
376
397
  return {
377
398
  systemPrompt:
378
- event.systemPrompt + `\n\n## Command Execution\n${modeText}${unavailableText}\n`,
399
+ event.systemPrompt +
400
+ `\n\n## Command Execution\n${modeText}${unavailableText}${denyRequestsText}\n`,
379
401
  };
380
402
  });
381
403
 
@@ -418,6 +440,11 @@ export class BwrapRuntime {
418
440
  // (allow-all 模式是显式 opt-out,仍直接执行)。
419
441
  const needsApproval = request.requestFullAccess === true || (isWindows && runtime.bwrapEnabled);
420
442
  if (needsApproval && runtime.bwrapEnabled) {
443
+ // /bwrap-deny-request:非沙盒请求直接拒绝——审批规则与审批框都不再参与,
444
+ // 拒绝文案与用户点 Deny 相同,直到用户用 /bwrap-allow-request 恢复审批。
445
+ if (request.requestFullAccess === true && this.policy.deniesRequests()) {
446
+ throw new Error(UNSANDBOXED_DENIED);
447
+ }
421
448
  // 先按 approvalRules 自动判定:allow 直接放行,deny 直接拒绝,未命中才弹框
422
449
  const decision = await evaluateBashApproval(request.command, runtime.approvalRules);
423
450
  if (decision === "deny") {
@@ -572,7 +599,7 @@ export class BwrapRuntime {
572
599
  if (foreverApprovedPattern.length > 0) {
573
600
  await this.persistAllowRule(ctx, command, foreverApprovedPattern);
574
601
  }
575
- throw new Error("User denied unsandboxed execution.");
602
+ throw new Error(UNSANDBOXED_DENIED);
576
603
  }
577
604
  case DENY_WITH_REASON: {
578
605
  if (foreverApprovedPattern.length > 0) {
@@ -757,6 +784,18 @@ export class BwrapRuntime {
757
784
  description: "Reload bwrap config and restart the network stack",
758
785
  flags: Type.Object({}),
759
786
  },
787
+ "bwrap-deny-request": {
788
+ name: "bwrap-deny-request",
789
+ usage: "",
790
+ description: "Deny unsandboxed execution requests without approval",
791
+ flags: Type.Object({}),
792
+ },
793
+ "bwrap-allow-request": {
794
+ name: "bwrap-allow-request",
795
+ usage: "",
796
+ description: "Require user approval for unsandboxed execution requests again",
797
+ flags: Type.Object({}),
798
+ },
760
799
  } as const satisfies Record<string, CommandSpec<TObject>>;
761
800
 
762
801
  pi.registerCommand("bwrap", {
@@ -809,13 +848,26 @@ export class BwrapRuntime {
809
848
  this.reload(commandCtx);
810
849
  }),
811
850
  });
851
+
852
+ for (const [name, deny] of [
853
+ ["bwrap-deny-request", true],
854
+ ["bwrap-allow-request", false],
855
+ ] as const) {
856
+ pi.registerCommand(name, {
857
+ description: specs[name].description,
858
+ handler: (args, ctx) =>
859
+ this.runCommand(pi, specs[name], args, ctx, (commandCtx) =>
860
+ this.setDenyRequests(deny, commandCtx),
861
+ ),
862
+ });
863
+ }
812
864
  }
813
865
 
814
866
  private reload(ctx: ExtensionCommandContext): void {
815
867
  this.resolved = undefined;
816
868
  this.bwrapUnavailable = false;
817
869
  const runtime = this.resolve(ctx);
818
- ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${runtime.mode}`));
870
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime.mode)));
819
871
  ctx.ui.notify(`bwrap config reloaded (mode: ${runtime.mode})`, "info");
820
872
  }
821
873
 
@@ -825,7 +877,7 @@ export class BwrapRuntime {
825
877
  return;
826
878
  }
827
879
  this.setMode(ctx.cwd, mode);
828
- ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${mode}`));
880
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(mode)));
829
881
  notifyMode(ctx, mode);
830
882
  pi.sendMessage({
831
883
  customType: "info",
@@ -834,6 +886,29 @@ export class BwrapRuntime {
834
886
  });
835
887
  }
836
888
 
889
+ /** 状态行文案:模式 + 非沙盒请求策略(拒绝时标注,便于解释模型的请求为何被拒)。 */
890
+ private statusLabel(mode: BwrapMode): string {
891
+ return `bwrap: ${mode}${this.policy.deniesRequests() ? " (requests denied)" : ""}`;
892
+ }
893
+
894
+ /**
895
+ * 切换非沙盒请求策略:拒绝时模型的提权请求(bash 的 `dangerouslyDisableSandbox`、
896
+ * 编辑类工具的工作区外写入)直接按用户拒绝处理,不再弹审批框。
897
+ */
898
+ private setDenyRequests(deny: boolean, ctx: ExtensionCommandContext): void {
899
+ this.policy.setDenyRequests(deny);
900
+ ctx.ui.notify(
901
+ deny
902
+ ? "Non-sandbox requests are denied without approval; /bwrap-allow-request restores approval."
903
+ : "Non-sandbox requests require user approval again.",
904
+ "info",
905
+ );
906
+ const runtime = this.resolve(ctx);
907
+ if (ctx.hasUI && runtime.bwrapEnabled && !this.bwrapUnavailable) {
908
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime.mode)));
909
+ }
910
+ }
911
+
837
912
  private runCommand(
838
913
  pi: ExtensionAPI,
839
914
  spec: CommandSpec<TObject>,
@@ -850,6 +925,7 @@ export class BwrapRuntime {
850
925
  }
851
926
  }
852
927
 
853
- export function createBwrapRuntime(): BwrapRuntime {
854
- return new BwrapRuntime();
928
+ /** 缺省自建一份不跨入口同步的策略;入口通常显式传入 `createRequestPolicy(pi.events)`。 */
929
+ export function createBwrapRuntime(policy: RequestPolicy = createRequestPolicy()): BwrapRuntime {
930
+ return new BwrapRuntime(policy);
855
931
  }
@@ -20,6 +20,7 @@ import { createLspManager, type LspService, type LspServiceOptions } from "../li
20
20
  import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
21
21
  import { formatSubtitlePath } from "../lib/path.js";
22
22
  import type { ToolPendant } from "../lib/pendant.ts";
23
+ import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
23
24
  import { guardWriteAccess } from "../lib/write-guard.js";
24
25
  import {
25
26
  type ClaudeCodeState,
@@ -230,6 +231,7 @@ export function registerFileTools(
230
231
  pi: ExtensionAPI,
231
232
  state: ClaudeCodeState,
232
233
  getService: () => LspService,
234
+ policy: RequestPolicy,
233
235
  ): void {
234
236
  pi.registerTool({
235
237
  name: "Read",
@@ -399,6 +401,7 @@ export function registerFileTools(
399
401
  newText: params.new_string,
400
402
  replaceAll: params.replace_all,
401
403
  },
404
+ policy,
402
405
  });
403
406
  const [message, details, diagnostics] = await withFileMutationQueue<
404
407
  [string, FileToolDetails, DiagnosticReport]
@@ -594,6 +597,7 @@ export function registerFileTools(
594
597
  toolName: "Write",
595
598
  absolutePath: filePath,
596
599
  change: { oldText: "", newText: params.content },
600
+ policy,
597
601
  });
598
602
  const [message, details, diagnostics] = await withFileMutationQueue<
599
603
  [string, FileToolDetails, DiagnosticReport]
@@ -686,14 +690,25 @@ function restoreFileReads(
686
690
  }
687
691
  }
688
692
 
693
+ export interface ClaudeCodeFileToolOptions extends LspServiceOptions {
694
+ /** 与 bash runtime 共享的非沙盒请求策略;独立入口不传,自建一份。 */
695
+ policy?: RequestPolicy;
696
+ }
697
+
689
698
  /**
690
699
  * 独立扩展入口:files.ts 可单独经 `-e claude-code/files.ts` 加载(spawn-agent
691
700
  * 子代理把 Read/Edit/Write 工具名映射到本文件),无需经 index.ts。reads
692
701
  * state 归本文件所有:扩展实例内创建,并随 session 事件从历史分支恢复,
693
702
  * 与主进程 index.ts 聚合加载时的行为一致。
694
703
  */
695
- export default function claudeCodeFileTools(pi: ExtensionAPI, options?: LspServiceOptions): void {
704
+ export default function claudeCodeFileTools(
705
+ pi: ExtensionAPI,
706
+ options?: ClaudeCodeFileToolOptions,
707
+ ): void {
696
708
  const state = createClaudeCodeState();
709
+ // 聚合入口(index.ts)注入与 bash runtime 共享的那一份;独立入口(spawn-agent
710
+ // 按工具名 `-e` 加载本文件)自建一份,靠 pi.events 跟随同一开关。
711
+ const policy = options?.policy ?? createRequestPolicy(pi.events);
697
712
 
698
713
  // LSP 专属工具(lsp-rename / inspect 族)仅在 lsp.json 存在 enabled 服务器时
699
714
  // 注册(session_start 校验后);本工具集跟踪 read-before-write 状态,rename
@@ -703,6 +718,7 @@ export default function claudeCodeFileTools(pi: ExtensionAPI, options?: LspServi
703
718
  {
704
719
  onEnabled: (pi, service) => {
705
720
  registerLspRenameTool(pi, service, {
721
+ policy,
706
722
  recordReads: async (applied) => {
707
723
  const reads: Record<string, FileSnapshot> = {};
708
724
  for (const fileEdit of applied) {
@@ -736,5 +752,5 @@ export default function claudeCodeFileTools(pi: ExtensionAPI, options?: LspServi
736
752
  });
737
753
 
738
754
  // 文件工具无条件注册;service 惰性获取,disabled 时为 no-op。
739
- registerFileTools(pi, state, () => manager.mustLazyGetService());
755
+ registerFileTools(pi, state, () => manager.mustLazyGetService(), policy);
740
756
  }
@@ -1,5 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ import { createBwrapRuntime } from "../bwrap/runtime.js";
4
+ import { createRequestPolicy } from "../lib/request-policy.js";
3
5
  import claudeCodeFileTools from "./files.js";
4
6
  import { registerSearchTools } from "./search.js";
5
7
  import { registerSessionTools } from "./session-tools.js";
@@ -9,12 +11,15 @@ import { registerShellTools } from "./shell.js";
9
11
  * Claude Code 风格工具集入口:聚合 files / search / shell / session 四组工具。
10
12
  *
11
13
  * reads state 的创建与 session 恢复归 files.ts 所有(见其 default export),
12
- * 本文件不再持有 state;spawn-agent 子代理按工具名直接 `-e` 加载各模块文件
13
- * (files.ts / grep.ts / glob.ts 均为独立扩展入口)。
14
+ * 非沙盒请求策略(Read/Edit/Write/lsp-rename 的工作区外写入与 Bash 的
15
+ * `dangerouslyDisableSandbox` 共用)由本文件创建后注入两侧;spawn-agent
16
+ * 子代理按工具名直接 `-e` 加载各模块文件(files.ts / grep.ts / glob.ts 均为
17
+ * 独立扩展入口),那些入口各自创建一份策略并经 pi.events 保持同步。
14
18
  */
15
19
  export default function claudeCodeTools(pi: ExtensionAPI): void {
16
- claudeCodeFileTools(pi);
20
+ const policy = createRequestPolicy(pi.events);
21
+ claudeCodeFileTools(pi, { policy });
17
22
  registerSearchTools(pi);
18
- registerShellTools(pi);
23
+ registerShellTools(pi, createBwrapRuntime(policy));
19
24
  registerSessionTools(pi);
20
25
  }
@@ -18,6 +18,7 @@ import {
18
18
  sandboxHintBlock,
19
19
  } from "../bwrap/runtime.js";
20
20
  import { resolveWorkdir } from "../lib/path.js";
21
+ import { createRequestPolicy } from "../lib/request-policy.js";
21
22
 
22
23
  const DEFAULT_TIMEOUT_MS = 120_000;
23
24
  const MAX_TIMEOUT_MS = 7_200_000;
@@ -85,7 +86,7 @@ export function formatBashSuccess(result: Awaited<ReturnType<BwrapRuntime["execu
85
86
  */
86
87
  export function registerShellTools(
87
88
  pi: ExtensionAPI,
88
- runtime: BwrapRuntime = createBwrapRuntime(),
89
+ runtime: BwrapRuntime = createBwrapRuntime(createRequestPolicy(pi.events)),
89
90
  ): void {
90
91
  runtime.setup(pi);
91
92
  pi.registerTool({
@@ -23,6 +23,7 @@ import { Type } from "typebox";
23
23
 
24
24
  import { resolvePathArg } from "../path.js";
25
25
  import type { ToolPendant } from "../pendant.ts";
26
+ import type { RequestPolicy } from "../request-policy.js";
26
27
  import { guardWriteAccess } from "../write-guard.js";
27
28
  import { RenameNotPossibleError } from "./client.js";
28
29
  import type { LspService } from "./lsp.js";
@@ -33,7 +34,9 @@ import {
33
34
  symbolCandidates,
34
35
  } from "./rename.js";
35
36
 
36
- export interface LspRenameHooks {
37
+ export interface LspRenameOptions {
38
+ /** 调用方所在扩展入口的非沙盒请求策略(与文件工具的 write-guard 同一份)。 */
39
+ policy: RequestPolicy;
37
40
  /**
38
41
  * rename 落盘后对每个被修改文件做已读记账;返回的 map 随 details.reads
39
42
  * 持久化,供 session 恢复时重放。不跟踪已读状态的工具集不提供该 hook。
@@ -49,7 +52,7 @@ const LSP_RENAME_PROMPT = readFileSync(
49
52
  export function registerLspRenameTool(
50
53
  pi: ExtensionAPI,
51
54
  service: LspService,
52
- hooks: LspRenameHooks = {},
55
+ options: LspRenameOptions,
53
56
  ): void {
54
57
  pi.registerTool({
55
58
  name: "lsp-rename",
@@ -100,7 +103,7 @@ export function registerLspRenameTool(
100
103
 
101
104
  const notify = (message: string, level: "info" | "warning" | "error") =>
102
105
  ctx.ui.notify(message, level);
103
- const options = { notify, signal };
106
+ const requestOptions = { notify, signal };
104
107
 
105
108
  // ── 按 symbol 名枚举行内候选,逐候选探测与消歧 ────────────────────────
106
109
  const candidates = symbolCandidates(
@@ -125,7 +128,7 @@ export function registerLspRenameTool(
125
128
  line: candidate.line,
126
129
  character: candidate.character,
127
130
  newName: params.new_name,
128
- options,
131
+ options: requestOptions,
129
132
  });
130
133
  successes.push({ result });
131
134
  } catch (error) {
@@ -187,6 +190,7 @@ export function registerLspRenameTool(
187
190
  toolName: "lsp-rename",
188
191
  absolutePath: fileEdit.path,
189
192
  change: { oldText: fileEdit.oldText, newText: fileEdit.newText },
193
+ policy: options.policy,
190
194
  });
191
195
  }
192
196
 
@@ -199,7 +203,8 @@ export function registerLspRenameTool(
199
203
  `### ${fileEdit.path} (${fileEdit.changeCount} edit(s))\n\n\`\`\`diff\n${generateDiffString(fileEdit.oldText, fileEdit.newText).diff}\n\`\`\``,
200
204
  );
201
205
  }
202
- const reads = hooks.recordReads === undefined ? undefined : await hooks.recordReads(applied);
206
+ const reads =
207
+ options.recordReads === undefined ? undefined : await options.recordReads(applied);
203
208
 
204
209
  let diagnosticText = "";
205
210
  for (const fileEdit of applied) {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 非沙盒请求策略:用户执行 `/bwrap-deny-request` 后,模型的提权请求
3
+ * (bash 的 `dangerouslyDisableSandbox`、编辑类工具的工作区外写入)不再弹审批框,
4
+ * 一律按用户「无理由拒绝」处理;`/bwrap-allow-request` 恢复审批。
5
+ *
6
+ * pi 给每个扩展入口单独建 jiti 实例(loader 里 `moduleCache: false`),模块级状态
7
+ * 不跨入口共享,所以策略实例由各入口的工厂创建,生命周期随扩展实例,聚合入口
8
+ * (claude-code / opencode 的 index.ts)创建一份注入给文件工具与 bash runtime。
9
+ *
10
+ * 需要跨入口一致时(独立入口的 web_fetch 也要跟随同一个开关)把 `pi.events` 传进来:
11
+ * 变更广播给同进程内所有订阅者,订阅由 loader 随扩展卸载自动撤销。
12
+ */
13
+
14
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
15
+ import { type Static, Type } from "typebox";
16
+ import { Value } from "typebox/value";
17
+
18
+ /** 策略广播频道;同进程内各入口的策略副本据此保持同步。 */
19
+ export const REQUEST_POLICY_CHANNEL = "bwrap:request-policy";
20
+
21
+ const requestPolicyMessageSchema = Type.Object({ denyRequests: Type.Boolean() });
22
+
23
+ export type RequestPolicyMessage = Static<typeof requestPolicyMessageSchema>;
24
+
25
+ export interface RequestPolicy {
26
+ /** 是否拒绝一切非沙盒请求(不弹审批框,直接拒绝)。 */
27
+ deniesRequests(): boolean;
28
+ setDenyRequests(deny: boolean): void;
29
+ }
30
+
31
+ /** 不传 events 时策略只在本入口内生效(测试与不需要跨入口同步的场景)。 */
32
+ export function createRequestPolicy(events?: EventBus): RequestPolicy {
33
+ let denyRequests = false;
34
+
35
+ events?.on(REQUEST_POLICY_CHANNEL, (data) => {
36
+ if (Value.Check(requestPolicyMessageSchema, data)) denyRequests = data.denyRequests;
37
+ });
38
+
39
+ return {
40
+ deniesRequests: () => denyRequests,
41
+ setDenyRequests(deny: boolean) {
42
+ denyRequests = deny;
43
+ events?.emit(REQUEST_POLICY_CHANNEL, { denyRequests: deny } satisfies RequestPolicyMessage);
44
+ },
45
+ };
46
+ }
@@ -8,6 +8,8 @@
8
8
  * - Headless sessions (no UI) reject outside writes outright.
9
9
  * - Windows (no sandbox fallback): writes are restricted to the workspace;
10
10
  * outside paths are rejected outright, with no approval path.
11
+ * - `/bwrap-deny-request` (non-sandbox request policy) refuses outside writes
12
+ * outright, with the same error as the user picking "Block".
11
13
  *
12
14
  * Callers have already parsed their tool arguments, so the guard only takes the
13
15
  * resolved pieces: the raw target path and the pending change (oldText/newText).
@@ -20,6 +22,7 @@ import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
20
22
 
21
23
  import { applyEdit, normalizeToLF } from "../opencode/edit-engine.js";
22
24
  import { fenceCodeBlock } from "./markdown.js";
25
+ import type { RequestPolicy } from "./request-policy.js";
23
26
 
24
27
  const ALWAYS_ALLOW = ["/tmp"];
25
28
  const MAX_PREVIEW_LINES = 100;
@@ -118,6 +121,8 @@ export interface WriteGuardOptions {
118
121
  * 待审批的变更内容;缺省时审批对话框不展示 diff 预览,仅按路径审批。
119
122
  */
120
123
  change?: PendingChange;
124
+ /** 调用方所在扩展入口持有的非沙盒请求策略(跨入口一致时绑同一个 pi.events)。 */
125
+ policy: RequestPolicy;
121
126
  }
122
127
 
123
128
  /**
@@ -133,6 +138,12 @@ export async function guardWriteAccess(
133
138
  const { absolutePath } = opts;
134
139
  if (isPathAllowed(absolutePath, ctx.cwd)) return;
135
140
 
141
+ // 非沙盒请求策略生效时不弹审批框:工作区外写入按用户点 "Block"(无理由)处理。
142
+ // 放在 win32 / 无 UI 分支之前,策略优先级高于各平台的降级路径。
143
+ if (opts.policy.deniesRequests()) {
144
+ throw new Error(`user deny ${opts.toolName}: blocked`);
145
+ }
146
+
136
147
  if (process.platform === "win32") {
137
148
  // Windows 上退化为「只能写工作区」:无沙箱兜底,工作区外写入一律拒绝,
138
149
  // 不提供审批路径。
@@ -12,6 +12,7 @@ import {
12
12
  sandboxHintBlock,
13
13
  } from "../bwrap/runtime.js";
14
14
  import { resolveWorkdir } from "../lib/path.js";
15
+ import { createRequestPolicy } from "../lib/request-policy.js";
15
16
 
16
17
  const DEFAULT_TIMEOUT_MS = 120_000;
17
18
  const MAX_TIMEOUT_MS = 7_200_000;
@@ -41,7 +42,7 @@ function appendTruncationNotice(
41
42
  */
42
43
  export default function opencodeBash(
43
44
  pi: ExtensionAPI,
44
- runtime: BwrapRuntime = createBwrapRuntime(),
45
+ runtime: BwrapRuntime = createBwrapRuntime(createRequestPolicy(pi.events)),
45
46
  ): void {
46
47
  // 每个扩展实例持有自己的 runtime:不依赖模块级全局状态,状态随扩展
47
48
  // 实例生命周期(进程启动 / /reload / session 切换时工厂重建即重置)。
@@ -34,6 +34,7 @@ import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
34
34
  import { createLspManager, type LspService, type LspServiceOptions } from "../lib/lsp/lsp.js";
35
35
  import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
36
36
  import { formatSubtitlePath } from "../lib/path.js";
37
+ import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
37
38
  import { guardWriteAccess } from "../lib/write-guard.js";
38
39
  import { applyEdit, normalizeToLF, stripBom } from "./edit-engine.js";
39
40
 
@@ -534,7 +535,11 @@ const editSchema = Type.Object({
534
535
  ),
535
536
  });
536
537
 
537
- function registerEditTool(pi: ExtensionAPI, getService: () => LspService): void {
538
+ function registerEditTool(
539
+ pi: ExtensionAPI,
540
+ getService: () => LspService,
541
+ policy: RequestPolicy,
542
+ ): void {
538
543
  pi.registerTool({
539
544
  name: "edit",
540
545
  label: "edit",
@@ -565,6 +570,7 @@ function registerEditTool(pi: ExtensionAPI, getService: () => LspService): void
565
570
  toolName: "edit",
566
571
  absolutePath,
567
572
  change: { oldText: oldString, newText: newString, replaceAll },
573
+ policy,
568
574
  });
569
575
 
570
576
  const [message, details, diagnostics] = await withFileMutationQueue(
@@ -686,7 +692,11 @@ export function resolveBom(
686
692
  return { bom: sourceBom || nextBom, text };
687
693
  }
688
694
 
689
- function registerWriteTool(pi: ExtensionAPI, getService: () => LspService): void {
695
+ function registerWriteTool(
696
+ pi: ExtensionAPI,
697
+ getService: () => LspService,
698
+ policy: RequestPolicy,
699
+ ): void {
690
700
  pi.registerTool({
691
701
  name: "write",
692
702
  label: "write",
@@ -707,6 +717,7 @@ function registerWriteTool(pi: ExtensionAPI, getService: () => LspService): void
707
717
  toolName: "write",
708
718
  absolutePath,
709
719
  change: { oldText: "", newText: content },
720
+ policy,
710
721
  });
711
722
  const dir = dirname(absolutePath);
712
723
 
@@ -764,25 +775,40 @@ function registerWriteTool(pi: ExtensionAPI, getService: () => LspService): void
764
775
 
765
776
  // ── 入口 ─────────────────────────────────────────────────────────────────────
766
777
 
767
- export function registerFileTools(pi: ExtensionAPI, getService: () => LspService): void {
778
+ export function registerFileTools(
779
+ pi: ExtensionAPI,
780
+ getService: () => LspService,
781
+ policy: RequestPolicy,
782
+ ): void {
768
783
  registerReadTool(pi, getService);
769
- registerEditTool(pi, getService);
770
- registerWriteTool(pi, getService);
784
+ registerEditTool(pi, getService, policy);
785
+ registerWriteTool(pi, getService, policy);
771
786
  // lsp-rename / inspect 工具由 manager 的 onEnabled 回调注册,不在这里注册。
772
787
  }
773
788
 
789
+ export interface OpencodeFileToolOptions extends LspServiceOptions {
790
+ /** 与 bash runtime 共享的非沙盒请求策略;独立入口不传,自建一份。 */
791
+ policy?: RequestPolicy;
792
+ }
793
+
774
794
  /** 独立入口:创建 LSP manager(session_start 时按配置启用)并注册文件工具。 */
775
- export default function opencodeFileTools(pi: ExtensionAPI, options?: LspServiceOptions): void {
795
+ export default function opencodeFileTools(
796
+ pi: ExtensionAPI,
797
+ options?: OpencodeFileToolOptions,
798
+ ): void {
799
+ // 聚合入口(index.ts)注入与 bash runtime 共享的那一份;独立入口自建并靠
800
+ // pi.events 跟随同一开关。
801
+ const policy = options?.policy ?? createRequestPolicy(pi.events);
776
802
  const manager = createLspManager(
777
803
  pi,
778
804
  {
779
805
  onEnabled: (pi, service) => {
780
- registerLspRenameTool(pi, service);
806
+ registerLspRenameTool(pi, service, { policy });
781
807
  registerLspInspectTools(pi, service);
782
808
  },
783
809
  },
784
810
  options,
785
811
  );
786
812
  // 文件工具无条件注册;service 惰性获取,disabled 时为 no-op。
787
- registerFileTools(pi, () => manager.mustLazyGetService());
813
+ registerFileTools(pi, () => manager.mustLazyGetService(), policy);
788
814
  }
@@ -14,6 +14,8 @@
14
14
 
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
 
17
+ import { createBwrapRuntime } from "../bwrap/runtime.js";
18
+ import { createRequestPolicy } from "../lib/request-policy.js";
17
19
  import opencodeBash from "./bash.js";
18
20
  import opencodeFileTools from "./files.js";
19
21
  import opencodeQuestion from "./question.js";
@@ -40,8 +42,11 @@ export { default as opencodeQuestion } from "./question.js";
40
42
  export { default as opencodeTodo } from "./todo.js";
41
43
 
42
44
  export default function opencode(pi: ExtensionAPI) {
43
- opencodeFileTools(pi);
45
+ // 非沙盒请求策略由本入口创建,注入文件工具与 bash runtime 共享同一份;
46
+ // 独立入口(web/fetch.ts 等)各自创建一份并经 pi.events 保持同步。
47
+ const policy = createRequestPolicy(pi.events);
48
+ opencodeFileTools(pi, { policy });
44
49
  opencodeTodo(pi);
45
50
  opencodeQuestion(pi);
46
- opencodeBash(pi);
51
+ opencodeBash(pi, createBwrapRuntime(policy));
47
52
  }
package/src/web/fetch.ts CHANGED
@@ -23,6 +23,7 @@ import { Type } from "typebox";
23
23
 
24
24
  import { resolvePathArg } from "../lib/path.js";
25
25
  import { createHttpProxy } from "../lib/proxy.js";
26
+ import { createRequestPolicy } from "../lib/request-policy.js";
26
27
  import { guardWriteAccess } from "../lib/write-guard.js";
27
28
 
28
29
  const httpProxy = createHttpProxy();
@@ -310,6 +311,9 @@ function truncateMarkdown(text: string): { text: string; truncated: boolean } {
310
311
  }
311
312
 
312
313
  export default function webFetchTool(pi: ExtensionAPI): void {
314
+ // 本工具是独立扩展入口(pi 给每个入口单独建 jiti 实例),自建一份非沙盒请求
315
+ // 策略并订阅 pi.events:/bwrap-deny-request 在 bash 入口切换时会同步过来。
316
+ const policy = createRequestPolicy(pi.events);
313
317
  pi.registerTool({
314
318
  name: "web_fetch",
315
319
  label: "Web Fetch",
@@ -339,7 +343,7 @@ export default function webFetchTool(pi: ExtensionAPI): void {
339
343
  // 落盘位置的审批与写文件工具同一套:工作区与 /tmp 自动放行,其余问用户。
340
344
  // 放在 try 外面,拒绝的原因(user deny)不该被改写成「抓取失败」。
341
345
  if (destination !== undefined) {
342
- await guardWriteAccess(ctx, { toolName: "web_fetch", absolutePath: destination });
346
+ await guardWriteAccess(ctx, { toolName: "web_fetch", absolutePath: destination, policy });
343
347
  }
344
348
 
345
349
  try {