@trim21/personal-pi-extensions 0.1.560 → 0.1.562
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 +6 -0
- package/package.json +1 -1
- package/src/bwrap/runtime.ts +88 -5
- package/src/claude-code/shell.ts +8 -3
- package/src/lib/request-policy.ts +29 -0
- package/src/lib/write-guard.ts +9 -0
- package/src/opencode/bash.ts +2 -1
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
package/src/bwrap/runtime.ts
CHANGED
|
@@ -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 { 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";
|
|
@@ -115,23 +116,35 @@ export class BashInterruptedError extends Error {
|
|
|
115
116
|
readonly kind: "timeout" | "aborted";
|
|
116
117
|
readonly partial: BashExecutionPartial;
|
|
117
118
|
readonly sandboxHint: string | undefined;
|
|
119
|
+
/**
|
|
120
|
+
* 命令实际运行时长(毫秒):从审批结束、命令真正开始执行算到终止,
|
|
121
|
+
* 不含审批弹窗等用户 UI 交互耗时。
|
|
122
|
+
*/
|
|
123
|
+
readonly elapsedMs: number;
|
|
118
124
|
|
|
119
125
|
constructor(
|
|
120
126
|
kind: "timeout" | "aborted",
|
|
121
127
|
message: string,
|
|
122
128
|
partial: BashExecutionPartial,
|
|
123
129
|
sandboxHint: string | undefined,
|
|
130
|
+
elapsedMs: number,
|
|
124
131
|
cause: unknown,
|
|
125
132
|
) {
|
|
126
133
|
super(message, { cause });
|
|
127
134
|
this.kind = kind;
|
|
128
135
|
this.partial = partial;
|
|
129
136
|
this.sandboxHint = sandboxHint;
|
|
137
|
+
this.elapsedMs = elapsedMs;
|
|
130
138
|
// 对齐标准错误分类:中断=AbortError(用户取消),超时=TimeoutError
|
|
131
139
|
this.name = kind === "aborted" ? "AbortError" : "TimeoutError";
|
|
132
140
|
}
|
|
133
141
|
}
|
|
134
142
|
|
|
143
|
+
/** 命令运行时长的展示文案(秒,一位小数),超时/中断状态文本共用。 */
|
|
144
|
+
export function formatElapsedSeconds(elapsedMs: number): string {
|
|
145
|
+
return `${(elapsedMs / 1000).toFixed(1)} seconds`;
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
function escapeHtml(text: string): string {
|
|
136
149
|
return text
|
|
137
150
|
.replaceAll("&", "&")
|
|
@@ -271,6 +284,12 @@ function describeNetwork(resolved: ResolvedBwrap): string {
|
|
|
271
284
|
return "network access is unrestricted";
|
|
272
285
|
}
|
|
273
286
|
|
|
287
|
+
/**
|
|
288
|
+
* 用户无理由拒绝非沙盒请求的文案。`/bwrap-deny-request` 生效时的拒绝必须与
|
|
289
|
+
* 用户在审批框点 Deny 完全一致——模型看到的是一次普通拒绝,而不是另一套错误语义。
|
|
290
|
+
*/
|
|
291
|
+
const UNSANDBOXED_DENIED = "User denied unsandboxed execution.";
|
|
292
|
+
|
|
274
293
|
/** 命令没经沙箱时沿用 prompt 里的说法,给出在沙盒外重跑的手段。 */
|
|
275
294
|
const SANDBOX_ESCAPE_HATCH =
|
|
276
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 +332,7 @@ export class BwrapRuntime {
|
|
|
313
332
|
this.sandboxDisabled = pi.getFlag("no-bwrap") === true && ctx.hasUI;
|
|
314
333
|
this.resolved = undefined;
|
|
315
334
|
this.bwrapUnavailable = false;
|
|
335
|
+
requestPolicy.setDenyRequests(false);
|
|
316
336
|
if (process.platform === "win32") {
|
|
317
337
|
// Windows 没有 bubblewrap:不做 bwrap 检测、不显示 bwrap 状态,
|
|
318
338
|
// 每条 bash 命令在 execute 时逐条人工审批,模型无需知道 bwrap 的存在。
|
|
@@ -334,7 +354,7 @@ export class BwrapRuntime {
|
|
|
334
354
|
return;
|
|
335
355
|
}
|
|
336
356
|
}
|
|
337
|
-
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent",
|
|
357
|
+
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime.mode)));
|
|
338
358
|
ctx.ui.notify(
|
|
339
359
|
runtime.bwrapEnabled
|
|
340
360
|
? `bwrap initialized (${runtime.mode})`
|
|
@@ -361,9 +381,14 @@ export class BwrapRuntime {
|
|
|
361
381
|
!isWindows && this.bwrapUnavailable
|
|
362
382
|
? " bwrap is unavailable (binary not found): bash commands are refused unless the user explicitly approves unsandboxed execution."
|
|
363
383
|
: "";
|
|
384
|
+
const denyRequestsText =
|
|
385
|
+
!isWindows && requestPolicy.deniesRequests()
|
|
386
|
+
? " Unsandboxed execution is currently denied by the user: `dangerouslyDisableSandbox` and writes outside the workspace are refused without approval."
|
|
387
|
+
: "";
|
|
364
388
|
return {
|
|
365
389
|
systemPrompt:
|
|
366
|
-
event.systemPrompt +
|
|
390
|
+
event.systemPrompt +
|
|
391
|
+
`\n\n## Command Execution\n${modeText}${unavailableText}${denyRequestsText}\n`,
|
|
367
392
|
};
|
|
368
393
|
});
|
|
369
394
|
|
|
@@ -406,6 +431,11 @@ export class BwrapRuntime {
|
|
|
406
431
|
// (allow-all 模式是显式 opt-out,仍直接执行)。
|
|
407
432
|
const needsApproval = request.requestFullAccess === true || (isWindows && runtime.bwrapEnabled);
|
|
408
433
|
if (needsApproval && runtime.bwrapEnabled) {
|
|
434
|
+
// /bwrap-deny-request:非沙盒请求直接拒绝——审批规则与审批框都不再参与,
|
|
435
|
+
// 拒绝文案与用户点 Deny 相同,直到用户用 /bwrap-allow-request 恢复审批。
|
|
436
|
+
if (request.requestFullAccess === true && requestPolicy.deniesRequests()) {
|
|
437
|
+
throw new Error(UNSANDBOXED_DENIED);
|
|
438
|
+
}
|
|
409
439
|
// 先按 approvalRules 自动判定:allow 直接放行,deny 直接拒绝,未命中才弹框
|
|
410
440
|
const decision = await evaluateBashApproval(request.command, runtime.approvalRules);
|
|
411
441
|
if (decision === "deny") {
|
|
@@ -425,6 +455,8 @@ export class BwrapRuntime {
|
|
|
425
455
|
}
|
|
426
456
|
// 命令失败时附带的沙箱状态(写边界 + 网络层级),由上层拼进错误文本
|
|
427
457
|
const sandboxHint = describeSandbox(runtime, local);
|
|
458
|
+
// 计时起点放在审批之后:审批弹窗的等待时长属于用户 UI 操作,不是命令运行时间
|
|
459
|
+
const startedAt = Date.now();
|
|
428
460
|
await using output = new BashOutput(request.ctx.sessionManager.getSessionId());
|
|
429
461
|
const { onUpdate } = request;
|
|
430
462
|
|
|
@@ -463,6 +495,7 @@ export class BwrapRuntime {
|
|
|
463
495
|
truncation: partial.truncation,
|
|
464
496
|
};
|
|
465
497
|
} catch (error) {
|
|
498
|
+
const elapsedMs = Date.now() - startedAt;
|
|
466
499
|
// 超时/中断:把命令终止前已捕获的输出附在错误上(文本 + 落盘路径),
|
|
467
500
|
// 展示时输出在前、状态在最后(对齐 pi 内置 bash),避免只报超时丢输出
|
|
468
501
|
// 超时识别:优先 name=TimeoutError(对齐标准错误分类),
|
|
@@ -477,6 +510,7 @@ export class BwrapRuntime {
|
|
|
477
510
|
`Command timed out after ${error.message.slice("timeout:".length)} seconds`,
|
|
478
511
|
partial,
|
|
479
512
|
sandboxHint,
|
|
513
|
+
elapsedMs,
|
|
480
514
|
error,
|
|
481
515
|
);
|
|
482
516
|
}
|
|
@@ -489,6 +523,7 @@ export class BwrapRuntime {
|
|
|
489
523
|
"Command aborted by user",
|
|
490
524
|
partial,
|
|
491
525
|
sandboxHint,
|
|
526
|
+
elapsedMs,
|
|
492
527
|
error,
|
|
493
528
|
);
|
|
494
529
|
}
|
|
@@ -555,7 +590,7 @@ export class BwrapRuntime {
|
|
|
555
590
|
if (foreverApprovedPattern.length > 0) {
|
|
556
591
|
await this.persistAllowRule(ctx, command, foreverApprovedPattern);
|
|
557
592
|
}
|
|
558
|
-
throw new Error(
|
|
593
|
+
throw new Error(UNSANDBOXED_DENIED);
|
|
559
594
|
}
|
|
560
595
|
case DENY_WITH_REASON: {
|
|
561
596
|
if (foreverApprovedPattern.length > 0) {
|
|
@@ -740,6 +775,18 @@ export class BwrapRuntime {
|
|
|
740
775
|
description: "Reload bwrap config and restart the network stack",
|
|
741
776
|
flags: Type.Object({}),
|
|
742
777
|
},
|
|
778
|
+
"bwrap-deny-request": {
|
|
779
|
+
name: "bwrap-deny-request",
|
|
780
|
+
usage: "",
|
|
781
|
+
description: "Deny unsandboxed execution requests without approval",
|
|
782
|
+
flags: Type.Object({}),
|
|
783
|
+
},
|
|
784
|
+
"bwrap-allow-request": {
|
|
785
|
+
name: "bwrap-allow-request",
|
|
786
|
+
usage: "",
|
|
787
|
+
description: "Require user approval for unsandboxed execution requests again",
|
|
788
|
+
flags: Type.Object({}),
|
|
789
|
+
},
|
|
743
790
|
} as const satisfies Record<string, CommandSpec<TObject>>;
|
|
744
791
|
|
|
745
792
|
pi.registerCommand("bwrap", {
|
|
@@ -792,13 +839,26 @@ export class BwrapRuntime {
|
|
|
792
839
|
this.reload(commandCtx);
|
|
793
840
|
}),
|
|
794
841
|
});
|
|
842
|
+
|
|
843
|
+
for (const [name, deny] of [
|
|
844
|
+
["bwrap-deny-request", true],
|
|
845
|
+
["bwrap-allow-request", false],
|
|
846
|
+
] as const) {
|
|
847
|
+
pi.registerCommand(name, {
|
|
848
|
+
description: specs[name].description,
|
|
849
|
+
handler: (args, ctx) =>
|
|
850
|
+
this.runCommand(pi, specs[name], args, ctx, (commandCtx) =>
|
|
851
|
+
this.setDenyRequests(deny, commandCtx),
|
|
852
|
+
),
|
|
853
|
+
});
|
|
854
|
+
}
|
|
795
855
|
}
|
|
796
856
|
|
|
797
857
|
private reload(ctx: ExtensionCommandContext): void {
|
|
798
858
|
this.resolved = undefined;
|
|
799
859
|
this.bwrapUnavailable = false;
|
|
800
860
|
const runtime = this.resolve(ctx);
|
|
801
|
-
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent",
|
|
861
|
+
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime.mode)));
|
|
802
862
|
ctx.ui.notify(`bwrap config reloaded (mode: ${runtime.mode})`, "info");
|
|
803
863
|
}
|
|
804
864
|
|
|
@@ -808,7 +868,7 @@ export class BwrapRuntime {
|
|
|
808
868
|
return;
|
|
809
869
|
}
|
|
810
870
|
this.setMode(ctx.cwd, mode);
|
|
811
|
-
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent",
|
|
871
|
+
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(mode)));
|
|
812
872
|
notifyMode(ctx, mode);
|
|
813
873
|
pi.sendMessage({
|
|
814
874
|
customType: "info",
|
|
@@ -817,6 +877,29 @@ export class BwrapRuntime {
|
|
|
817
877
|
});
|
|
818
878
|
}
|
|
819
879
|
|
|
880
|
+
/** 状态行文案:模式 + 非沙盒请求策略(拒绝时标注,便于解释模型的请求为何被拒)。 */
|
|
881
|
+
private statusLabel(mode: BwrapMode): string {
|
|
882
|
+
return `bwrap: ${mode}${requestPolicy.deniesRequests() ? " (requests denied)" : ""}`;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* 切换非沙盒请求策略:拒绝时模型的提权请求(bash 的 `dangerouslyDisableSandbox`、
|
|
887
|
+
* 编辑类工具的工作区外写入)直接按用户拒绝处理,不再弹审批框。
|
|
888
|
+
*/
|
|
889
|
+
private setDenyRequests(deny: boolean, ctx: ExtensionCommandContext): void {
|
|
890
|
+
requestPolicy.setDenyRequests(deny);
|
|
891
|
+
ctx.ui.notify(
|
|
892
|
+
deny
|
|
893
|
+
? "Non-sandbox requests are denied without approval; /bwrap-allow-request restores approval."
|
|
894
|
+
: "Non-sandbox requests require user approval again.",
|
|
895
|
+
"info",
|
|
896
|
+
);
|
|
897
|
+
const runtime = this.resolve(ctx);
|
|
898
|
+
if (ctx.hasUI && runtime.bwrapEnabled && !this.bwrapUnavailable) {
|
|
899
|
+
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime.mode)));
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
820
903
|
private runCommand(
|
|
821
904
|
pi: ExtensionAPI,
|
|
822
905
|
spec: CommandSpec<TObject>,
|
package/src/claude-code/shell.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
BashInterruptedError,
|
|
15
15
|
type BwrapRuntime,
|
|
16
16
|
createBwrapRuntime,
|
|
17
|
+
formatElapsedSeconds,
|
|
17
18
|
sandboxHintBlock,
|
|
18
19
|
} from "../bwrap/runtime.js";
|
|
19
20
|
import { resolveWorkdir } from "../lib/path.js";
|
|
@@ -152,9 +153,13 @@ export function registerShellTools(
|
|
|
152
153
|
error.partial.fullOutputPath,
|
|
153
154
|
);
|
|
154
155
|
if (error.kind === "aborted") {
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
// 用户取消:直接返回已捕获的输出,不抛错;时长只算命令真正运行的时间,
|
|
157
|
+
// 不含审批弹窗等 UI 交互
|
|
158
|
+
const status = `Command aborted by user after ${formatElapsedSeconds(error.elapsedMs)}`;
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: "text", text: text ? `${text}\n\n${status}` : status }],
|
|
161
|
+
details: undefined,
|
|
162
|
+
};
|
|
158
163
|
}
|
|
159
164
|
const full = text
|
|
160
165
|
? `${text}\n\nCommand timed out after ${timeout} milliseconds`
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 非沙盒请求策略:用户执行 `/bwrap-deny-request` 后,模型的提权请求
|
|
3
|
+
* (bash 的 `dangerouslyDisableSandbox`、编辑类工具的工作区外写入)不再弹审批框,
|
|
4
|
+
* 一律按用户「无理由拒绝」处理;`/bwrap-allow-request` 恢复审批。
|
|
5
|
+
*
|
|
6
|
+
* 状态由闭包持有(AGENTS.md:不用模块级可变变量维护跨调用状态)。
|
|
7
|
+
* 模块级单例让互相独立的扩展入口(bwrap runtime、claude-code / opencode 的文件工具、
|
|
8
|
+
* lsp-rename、web_fetch)共享同一份会话状态——各入口分别 createRequestPolicy()
|
|
9
|
+
* 会得到互不相通的策略,等于开关只对其中一半生效。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface RequestPolicy {
|
|
13
|
+
/** 是否拒绝一切非沙盒请求(不弹审批框,直接拒绝)。 */
|
|
14
|
+
deniesRequests(): boolean;
|
|
15
|
+
setDenyRequests(deny: boolean): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createRequestPolicy(): RequestPolicy {
|
|
19
|
+
let denyRequests = false;
|
|
20
|
+
return {
|
|
21
|
+
deniesRequests: () => denyRequests,
|
|
22
|
+
setDenyRequests(deny: boolean) {
|
|
23
|
+
denyRequests = deny;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 进程级共享的策略实例;由 `/bwrap-deny-request` / `/bwrap-allow-request` 切换,session_start 复位。 */
|
|
29
|
+
export const requestPolicy = createRequestPolicy();
|
package/src/lib/write-guard.ts
CHANGED
|
@@ -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 { requestPolicy } from "./request-policy.js";
|
|
23
26
|
|
|
24
27
|
const ALWAYS_ALLOW = ["/tmp"];
|
|
25
28
|
const MAX_PREVIEW_LINES = 100;
|
|
@@ -133,6 +136,12 @@ export async function guardWriteAccess(
|
|
|
133
136
|
const { absolutePath } = opts;
|
|
134
137
|
if (isPathAllowed(absolutePath, ctx.cwd)) return;
|
|
135
138
|
|
|
139
|
+
// 非沙盒请求策略生效时不弹审批框:工作区外写入按用户点 "Block"(无理由)处理。
|
|
140
|
+
// 放在 win32 / 无 UI 分支之前,策略优先级高于各平台的降级路径。
|
|
141
|
+
if (requestPolicy.deniesRequests()) {
|
|
142
|
+
throw new Error(`user deny ${opts.toolName}: blocked`);
|
|
143
|
+
}
|
|
144
|
+
|
|
136
145
|
if (process.platform === "win32") {
|
|
137
146
|
// Windows 上退化为「只能写工作区」:无沙箱兜底,工作区外写入一律拒绝,
|
|
138
147
|
// 不提供审批路径。
|
package/src/opencode/bash.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
BashInterruptedError,
|
|
9
9
|
type BwrapRuntime,
|
|
10
10
|
createBwrapRuntime,
|
|
11
|
+
formatElapsedSeconds,
|
|
11
12
|
sandboxHintBlock,
|
|
12
13
|
} from "../bwrap/runtime.js";
|
|
13
14
|
import { resolveWorkdir } from "../lib/path.js";
|
|
@@ -114,7 +115,7 @@ export default function opencodeBash(
|
|
|
114
115
|
const status =
|
|
115
116
|
error.kind === "timeout"
|
|
116
117
|
? `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
|
117
|
-
:
|
|
118
|
+
: `Command aborted by user after ${formatElapsedSeconds(error.elapsedMs)}`;
|
|
118
119
|
const full = text ? `${text}\n\n${status}` : status;
|
|
119
120
|
// 对齐上游 opencode:超时与中断都不抛错,输出与状态文本一起返回;
|
|
120
121
|
// 超时可能是沙箱的网络限制导致的,附加沙箱状态(用户中断与沙箱无关)
|