@trim21/personal-pi-extensions 0.1.584 → 0.1.585

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.584",
3
+ "version": "0.1.585",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -37,6 +37,8 @@ import { loadSandboxConfig, runInSandbox } from "./sandbox.js";
37
37
 
38
38
  /** 全权限审批对话框的选项 label(也作为 switch 匹配键与测试引用)。 */
39
39
  export const ALLOW_ONCE = "Allow once";
40
+ /** 拒绝提权、命令降级为在沙盒内执行。 */
41
+ export const RUN_IN_SANDBOX = "Run this in sandbox";
40
42
  export const DENY = "Deny";
41
43
  export const DENY_WITH_REASON = "Deny with reason";
42
44
  /** 第一层的折叠入口:进入按 pattern 勾选持久化规则的子菜单。 */
@@ -49,7 +51,7 @@ export const BACK = "Back";
49
51
  * 决定放行、拒绝并持久化勾选的规则,UI 层不直接产生副作用。
50
52
  */
51
53
  export interface FullAccessUIDecision {
52
- /** 用户选择的动作 label(ALLOW_ONCE / DENY / DENY_WITH_REASON)。 */
54
+ /** 用户选择的动作 label(ALLOW_ONCE / RUN_IN_SANDBOX / DENY / DENY_WITH_REASON)。 */
53
55
  result: string;
54
56
  /** 用户勾选、需持久化为 allow 规则的 pattern;未勾选时为空数组。 */
55
57
  foreverApprovedPattern: string[];
@@ -57,6 +59,12 @@ export interface FullAccessUIDecision {
57
59
  reason?: string;
58
60
  }
59
61
 
62
+ /**
63
+ * 全权限审批的放行结果:允许一次性全权限执行,或用户拒绝提权、
64
+ * 命令降级为在沙盒内执行(输出后附系统提醒)。
65
+ */
66
+ type FullAccessGrant = "full-access" | "sandbox";
67
+
60
68
  export interface BwrapExecutionRequest {
61
69
  toolCallId: string;
62
70
  command: string;
@@ -78,6 +86,8 @@ export interface BwrapExecutionRequest {
78
86
  */
79
87
  export interface BwrapExecutionResult {
80
88
  exitCode: number | null;
89
+ /** 用户选「Run this in sandbox」拒绝提权时的系统提醒,成功与失败都由上层附上;其余情况缺省。 */
90
+ sandboxReminder?: string;
81
91
  /** 沙箱状态说明(写边界 + 网络层级),失败时由上层工具作为独立信息块附上;未沙箱执行时为 undefined。 */
82
92
  sandboxHint: string | undefined;
83
93
  /** 截断后的输出(尾部),未截断时为完整输出;空输出为空字符串。 */
@@ -102,6 +112,8 @@ export class BashInterruptedError extends Error {
102
112
  readonly kind: "timeout" | "aborted";
103
113
  readonly partial: BashExecutionPartial;
104
114
  readonly sandboxHint: string | undefined;
115
+ /** 用户选「Run this in sandbox」拒绝提权时的系统提醒;其余情况为 undefined。 */
116
+ readonly sandboxReminder: string | undefined;
105
117
  /**
106
118
  * 命令实际运行时长(毫秒):从审批结束、命令真正开始执行算到终止,
107
119
  * 不含审批弹窗等用户 UI 交互耗时。
@@ -115,11 +127,13 @@ export class BashInterruptedError extends Error {
115
127
  sandboxHint: string | undefined,
116
128
  elapsedMs: number,
117
129
  cause: unknown,
130
+ sandboxReminder?: string,
118
131
  ) {
119
132
  super(message, { cause });
120
133
  this.kind = kind;
121
134
  this.partial = partial;
122
135
  this.sandboxHint = sandboxHint;
136
+ this.sandboxReminder = sandboxReminder;
123
137
  this.elapsedMs = elapsedMs;
124
138
  // 对齐标准错误分类:中断=AbortError(用户取消),超时=TimeoutError
125
139
  this.name = kind === "aborted" ? "AbortError" : "TimeoutError";
@@ -270,6 +284,12 @@ function describeNetwork(resolved: ResolvedBwrap): string {
270
284
  return "network access is unrestricted";
271
285
  }
272
286
 
287
+ /** 沙箱作用域一句话:写边界 + 网络层级(沙箱状态块与沙盒内执行提醒共用)。 */
288
+ function describeLimits(resolved: ResolvedBwrap): string {
289
+ const writes = resolved.mode === "readonly" ? "the filesystem is read-only" : SANDBOX_WRITE_RULES;
290
+ return `${writes}; ${describeNetwork(resolved)}`;
291
+ }
292
+
273
293
  /**
274
294
  * 用户无理由拒绝非沙盒请求的文案。`/bwrap-deny-request` 生效时与无 UI 会话里
275
295
  * 需要审批的请求,都必须与用户在审批框点 Deny 完全一致——模型看到的是一次
@@ -290,15 +310,28 @@ const SANDBOX_ESCAPE_HATCH =
290
310
  */
291
311
  export function describeSandbox(resolved: ResolvedBwrap, unsandboxed: boolean): string | undefined {
292
312
  if (unsandboxed) return undefined;
293
- const writes = resolved.mode === "readonly" ? "the filesystem is read-only" : SANDBOX_WRITE_RULES;
294
313
  return [
295
314
  "<system-reminder>",
296
- `This command ran in a sandbox: ${writes}; ${describeNetwork(resolved)}.`,
315
+ `This command ran in a sandbox: ${describeLimits(resolved)}.`,
297
316
  SANDBOX_ESCAPE_HATCH,
298
317
  "</system-reminder>",
299
318
  ].join("\n");
300
319
  }
301
320
 
321
+ /**
322
+ * 用户在审批框选「Run this in sandbox」拒绝提权后,命令输出后附带的系统提醒:
323
+ * 说明命令在沙盒内执行及其作用域,成功与失败都附上。此时 describeSandbox 的
324
+ * 状态块不再附——提权请求刚被用户拒绝,再提示 dangerouslyDisableSandbox 只会
325
+ * 诱导重复请求,作用域描述由本提醒承担。
326
+ */
327
+ export function describeSandboxChoice(resolved: ResolvedBwrap): string {
328
+ return [
329
+ "<system-reminder>",
330
+ `The user ran this command in the sandbox instead of approving unsandboxed execution: ${describeLimits(resolved)}.`,
331
+ "</system-reminder>",
332
+ ].join("\n");
333
+ }
334
+
302
335
  /** 失败结果里附加的沙箱状态块;未沙箱执行(hint 为 undefined)时没有这一块。 */
303
336
  export function sandboxHintBlock(hint: string | undefined): { type: "text"; text: string }[] {
304
337
  return hint === undefined ? [] : [{ type: "text", text: hint }];
@@ -417,6 +450,8 @@ export class BwrapRuntime {
417
450
  // 需要人工审批:非 Windows 仅 requestFullAccess;Windows 上默认所有命令
418
451
  // (allow-all 模式是显式 opt-out,仍直接执行)。
419
452
  const needsApproval = request.requestFullAccess === true || (isWindows && runtime.bwrapEnabled);
453
+ // 用户在审批框选「Run this in sandbox」拒绝提权:命令降级为沙盒内执行
454
+ let userChoseSandbox = false;
420
455
  if (needsApproval && runtime.bwrapEnabled) {
421
456
  // /bwrap-deny-request:非沙盒请求直接拒绝——审批规则与审批框都不再参与,
422
457
  // 拒绝文案与用户点 Deny 相同,直到用户用 /bwrap-allow-request 恢复审批。
@@ -429,19 +464,29 @@ export class BwrapRuntime {
429
464
  throw new Error(`Command denied by bwrap approval rule: ${request.command}`);
430
465
  }
431
466
  if (decision === undefined) {
432
- await this.approveFullAccess(request.ctx, request.command, request.description, execCwd);
467
+ userChoseSandbox =
468
+ (await this.approveFullAccess(
469
+ request.ctx,
470
+ request.command,
471
+ request.description,
472
+ execCwd,
473
+ )) === "sandbox";
433
474
  }
434
475
  }
435
- // 不经沙箱的三种情形:Windows(无 bubblewrap)、审批通过的全权限、allow-all 模式
436
- const local = isWindows || needsApproval || !runtime.bwrapEnabled;
476
+ // 不经沙箱的三种情形:Windows(无 bubblewrap)、审批通过的全权限、allow-all 模式;
477
+ // 例外是用户选「Run this in sandbox」:提权被拒,命令照常进沙箱
478
+ const local = !userChoseSandbox && (isWindows || needsApproval || !runtime.bwrapEnabled);
437
479
  // mihomoPath 是 ResolvedBwrap 的 override 语义:首次 net-allowlist 执行时解析
438
480
  // 一次存入私有字段,之后写回 resolved 直达 createNetworkStack,不逐命令扫描 PATH
439
481
  if (runtime.network && runtime.networkAllowlist.length > 0) {
440
482
  runtime.mihomoPath ??= this.mihomoPath ?? findMihomo();
441
483
  this.mihomoPath = runtime.mihomoPath;
442
484
  }
443
- // 命令失败时附带的沙箱状态(写边界 + 网络层级),由上层拼进错误文本
444
- const sandboxHint = describeSandbox(runtime, local);
485
+ // 命令失败时附带的沙箱状态(写边界 + 网络层级),由上层拼进错误文本。
486
+ // 用户选「Run this in sandbox」时不附:沙箱作用域改由 sandboxReminder 说明
487
+ const sandboxHint = userChoseSandbox ? undefined : describeSandbox(runtime, local);
488
+ // 提权被拒、命令在沙盒内执行的系统提醒:成功与失败都由上层附上
489
+ const sandboxReminder = userChoseSandbox ? describeSandboxChoice(runtime) : undefined;
445
490
  // 计时起点放在审批之后:审批弹窗的等待时长属于用户 UI 操作,不是命令运行时间
446
491
  const startedAt = Date.now();
447
492
  await using output = new BashOutput(request.ctx.sessionManager.getSessionId());
@@ -477,6 +522,7 @@ export class BwrapRuntime {
477
522
  return {
478
523
  exitCode,
479
524
  sandboxHint,
525
+ ...(sandboxReminder && { sandboxReminder }),
480
526
  output: partial.output,
481
527
  ...(partial.fullOutputPath && { fullOutputPath: partial.fullOutputPath }),
482
528
  truncation: partial.truncation,
@@ -499,6 +545,7 @@ export class BwrapRuntime {
499
545
  sandboxHint,
500
546
  elapsedMs,
501
547
  error,
548
+ sandboxReminder,
502
549
  );
503
550
  }
504
551
  // 中断识别:优先 name=AbortError(throwIfAborted/signal.reason),
@@ -512,6 +559,7 @@ export class BwrapRuntime {
512
559
  sandboxHint,
513
560
  elapsedMs,
514
561
  error,
562
+ sandboxReminder,
515
563
  );
516
564
  }
517
565
  throw error;
@@ -554,7 +602,7 @@ export class BwrapRuntime {
554
602
  command: string,
555
603
  reason: string | undefined,
556
604
  execCwd: string,
557
- ): Promise<void> {
605
+ ): Promise<FullAccessGrant> {
558
606
  // hasUI 判定推迟到审批时刻:无 UI 会话弹不了审批框,按用户点 Deny 的标准文案拒绝
559
607
  if (!ctx.hasUI) throw new Error(UNSANDBOXED_DENIED);
560
608
  const decision = await this.approveFullAccessUI(ctx, command, reason, execCwd);
@@ -565,11 +613,10 @@ export class BwrapRuntime {
565
613
  }
566
614
  const { result, foreverApprovedPattern } = decision;
567
615
  switch (result) {
568
- case ALLOW_ONCE: {
569
- if (foreverApprovedPattern.length > 0) {
570
- await this.persistAllowRule(ctx, command, foreverApprovedPattern);
571
- }
572
- return;
616
+ case RUN_IN_SANDBOX: {
617
+ // 用户拒绝提权、改为沙盒内执行:allow 规则意味着自动放行非沙盒执行,
618
+ // 与该选择矛盾,即使在子菜单勾选了 pattern 也不持久化
619
+ return "sandbox";
573
620
  }
574
621
  case DENY: {
575
622
  if (foreverApprovedPattern.length > 0) {
@@ -588,6 +635,16 @@ export class BwrapRuntime {
588
635
  : "User denied command execution.",
589
636
  );
590
637
  }
638
+ case ALLOW_ONCE: {
639
+ if (foreverApprovedPattern.length > 0) {
640
+ await this.persistAllowRule(ctx, command, foreverApprovedPattern);
641
+ }
642
+ return "full-access";
643
+ }
644
+ default: {
645
+ // UI 层只会产出上面几种 label;未识别的按历史行为放行,不持久化规则
646
+ return "full-access";
647
+ }
591
648
  }
592
649
  }
593
650
 
@@ -647,6 +704,7 @@ export class BwrapRuntime {
647
704
  // 追加折叠入口,进入子菜单逐项勾选。
648
705
  const actions: SelectAction[] = [
649
706
  { label: ALLOW_ONCE },
707
+ { label: RUN_IN_SANDBOX },
650
708
  { label: DENY },
651
709
  { label: DENY_WITH_REASON, inputPrompt: "Why was this denied?" },
652
710
  ];
@@ -661,9 +719,10 @@ export class BwrapRuntime {
661
719
  description,
662
720
  ].join("\n");
663
721
 
664
- // 两层循环:子菜单勾选后回到主决策,直到用户在 Allow once / Deny 系列中做出
665
- // 选择。勾选的规则在放行时持久化;Deny 系列同样持久化(用户确认该模式可信,
666
- // 只是本次命令不执行)。子菜单关闭 = 返回主决策;主决策关闭 = 取消(上层按拒绝处理)。
722
+ // 两层循环:子菜单勾选后回到主决策,直到用户在 Allow once / Run this in sandbox /
723
+ // Deny 系列中做出选择。勾选的规则在放行时持久化;Deny 系列同样持久化(用户确认
724
+ // 该模式可信,只是本次命令不执行);Run this in sandbox 不持久化。
725
+ // 子菜单关闭 = 返回主决策;主决策关闭 = 取消(上层按拒绝处理)。
667
726
  let selected: string[] = [];
668
727
  for (;;) {
669
728
  const pending =
@@ -75,7 +75,7 @@ export function formatBashSuccess(result: Awaited<ReturnType<BwrapRuntime["execu
75
75
  const { output, truncation, fullOutputPath } = result;
76
76
  const text = appendTruncationNotice(output || "(no output)", truncation, fullOutputPath);
77
77
  return {
78
- content: [{ type: "text", text }],
78
+ content: [{ type: "text", text }, ...sandboxHintBlock(result.sandboxReminder)],
79
79
  details: fullOutputPath && truncation.truncated ? { truncation, fullOutputPath } : undefined,
80
80
  };
81
81
  }
@@ -158,7 +158,10 @@ export function registerShellTools(
158
158
  // 不含审批弹窗等 UI 交互
159
159
  const status = `Command aborted by user after ${formatElapsedSeconds(error.elapsedMs)}`;
160
160
  return {
161
- content: [{ type: "text", text: text ? `${text}\n\n${status}` : status }],
161
+ content: [
162
+ { type: "text", text: text ? `${text}\n\n${status}` : status },
163
+ ...sandboxHintBlock(error.sandboxReminder),
164
+ ],
162
165
  details: undefined,
163
166
  };
164
167
  }
@@ -166,7 +169,11 @@ export function registerShellTools(
166
169
  ? `${text}\n\nCommand timed out after ${timeout} milliseconds`
167
170
  : `Command timed out after ${timeout} milliseconds`;
168
171
  return {
169
- content: [{ type: "text", text: full }, ...sandboxHintBlock(error.sandboxHint)],
172
+ content: [
173
+ { type: "text", text: full },
174
+ ...sandboxHintBlock(error.sandboxHint),
175
+ ...sandboxHintBlock(error.sandboxReminder),
176
+ ],
170
177
  details: undefined,
171
178
  };
172
179
  }
@@ -184,6 +191,7 @@ export function registerShellTools(
184
191
  content: [
185
192
  { type: "text", text: formatBashError(result.exitCode, full) },
186
193
  ...sandboxHintBlock(result.sandboxHint),
194
+ ...sandboxHintBlock(result.sandboxReminder),
187
195
  ],
188
196
  details: undefined,
189
197
  };
@@ -124,6 +124,7 @@ export default function opencodeBash(
124
124
  content: [
125
125
  { type: "text" as const, text: full },
126
126
  ...sandboxHintBlock(error.kind === "timeout" ? error.sandboxHint : undefined),
127
+ ...sandboxHintBlock(error.sandboxReminder),
127
128
  ],
128
129
  details: error.kind === "timeout" ? { timeout: true } : {},
129
130
  };
@@ -141,6 +142,7 @@ export default function opencodeBash(
141
142
  { type: "text" as const, text: `Command exited with code ${result.exitCode}.` },
142
143
  // 失败可能是被沙箱的写边界或网络限制挡住的,附一块沙箱状态
143
144
  ...sandboxHintBlock(failed ? result.sandboxHint : undefined),
145
+ ...sandboxHintBlock(result.sandboxReminder),
144
146
  ],
145
147
  details: {
146
148
  exitCode: result.exitCode,