@trim21/personal-pi-extensions 0.0.224 → 0.0.229

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
@@ -6,17 +6,17 @@
6
6
 
7
7
  ## 扩展概览
8
8
 
9
- | 扩展 | 描述 |
10
- | --------------------------------------------- | ------------------------------------------------------ |
11
- | [bwrap](#bwrap) | 基于 bubblewrap 的 OS 级沙箱,提供文件系统和网络隔离 |
12
- | [workspace-guard](#workspace-guard) | 限制文件写入在 workspace 内,外部写入需用户审批 |
13
- | [opencode-edit](#opencode-edit) | 替换内置 edit 工具,使用 opencode 的 schema 和匹配引擎 |
14
- | [bash-default-timeout](#bash-default-timeout) | 为 bash 工具设置默认超时(180 秒) |
15
- | [vision-agent](#vision-agent) | 视觉代理:主模型不支持视觉时,spawn 子 agent 识别图片 |
16
- | [session-name](#session-name) | 首个 user prompt 自动生成会话名,模型命名 + 启发式兜底 |
17
- | [todowrite](#todowrite) | opencode 风格的任务列表工具,完整列表替换语义 |
18
- | [question](#question) | opencode 风格的提问工具,阻塞式询问用户选择 |
19
- | [talk](#talk) | session 间消息传递,SQLite 邮箱 + 双向 ask 时间戳仲裁 |
9
+ | 扩展 | 描述 |
10
+ | --------------------------------------------- | ------------------------------------------------------- |
11
+ | [bwrap](#bwrap) | 基于 bubblewrap 的 OS 级沙箱,提供文件系统和网络隔离 |
12
+ | [写保护(内置)](#写保护内置) | 写工具内置:限制文件写入在 workspace 内,外部写入需审批 |
13
+ | [opencode-edit](#opencode-edit) | 替换内置 edit 工具,使用 opencode 的 schema 和匹配引擎 |
14
+ | [bash-default-timeout](#bash-default-timeout) | 为 bash 工具设置默认超时(180 秒) |
15
+ | [vision-agent](#vision-agent) | 视觉代理:主模型不支持视觉时,spawn 子 agent 识别图片 |
16
+ | [session-name](#session-name) | 首个 user prompt 自动生成会话名,模型命名 + 启发式兜底 |
17
+ | [todowrite](#todowrite) | opencode 风格的任务列表工具,完整列表替换语义 |
18
+ | [question](#question) | opencode 风格的提问工具,阻塞式询问用户选择 |
19
+ | [talk](#talk) | session 间消息传递,SQLite 邮箱 + 双向 ask 时间戳仲裁 |
20
20
 
21
21
  ---
22
22
 
@@ -38,7 +38,7 @@
38
38
 
39
39
  ### 提权机制
40
40
 
41
- bash 工具注册了 `request_full_access` `request_full_access_reason` 参数。模型需要全权限时须说明原因(如需要网络、写入 workspace 外部路径)。
41
+ bash 工具(opencode 风格 `bash`、Claude Code 风格 `Bash`)注册了 `dangerouslyDisableSandbox` 参数。模型需要全权限时置为 true 并说明原因(如需要网络、写入 workspace 外部路径)。
42
42
 
43
43
  建议模型不确定时先尝试沙箱模式,若因沙箱限制失败,再以完整权限重试。
44
44
 
@@ -86,25 +86,20 @@ pi -e ./src/bwrap/index.ts
86
86
 
87
87
  ---
88
88
 
89
- ## workspace-guard
89
+ ## 写保护(内置)
90
90
 
91
- 阻止 `write` `edit` 工具写入 workspace 外部的路径。读取工具(`read`、`ls`、`find`、`grep`)不受限制。
91
+ 写保护直接内置在各写工具(opencode 风格 `write`/`edit`、Claude Code 风格 `Write`/`Edit`)内部,通过 `src/lib/write-guard.ts` `guardWriteAccess` 实现。读取工具(`read`、`ls`、`find`、`grep`)不受限制。
92
92
 
93
93
  - workspace 内或 `/tmp` 下的路径自动放行
94
- - 外部路径需通过确认对话框由用户审批,对话框内以 ```diff 代码块展示将要发生的变更预览(与 opencode-edit 共享匹配引擎,能定位时显示带行号的真实 patch,否则退化为参数 diff)
95
- - 无需配置
96
-
97
- ### 使用
98
-
99
- ```bash
100
- pi -e ./src/workspace-guard.ts
101
- ```
94
+ - 外部路径需通过确认对话框由用户审批,对话框内以 ```diff 代码块展示将要发生的变更预览(与 opencode-edit 共享匹配引擎,能定位时显示带行号的真实 patch,否则退化为参数 diff
95
+ - headless(无 UI)会话直接拒绝外部写入
96
+ - 无需配置,随各写工具自动生效
102
97
 
103
98
  ---
104
99
 
105
100
  ## opencode-edit
106
101
 
107
- 替换内置 `edit` 工具,使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数直接复制自 opencode,行为与原版完全一致。匹配引擎位于 `src/opencode-edit-engine.ts`,与 workspace-guard 的审批弹窗 diff 预览共享。
102
+ 替换内置 `edit` 工具,使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数直接复制自 opencode,行为与原版完全一致。匹配引擎位于 `src/opencode/edit-engine.ts`,与写保护审批弹窗(`src/lib/write-guard.ts`)的 diff 预览共享。
108
103
 
109
104
  支持的匹配策略:
110
105
 
@@ -363,7 +358,6 @@ pi -e ./src/talk/index.ts
363
358
 
364
359
  ```bash
365
360
  pi -e ./src/bwrap/index.ts
366
- pi -e ./src/workspace-guard.ts
367
361
  ```
368
362
 
369
363
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.224",
3
+ "version": "0.0.229",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -54,13 +54,10 @@
54
54
  },
55
55
  "pi": {
56
56
  "extensions": [
57
+ "src/opencode/index.ts",
58
+ "src/claude-code/index.ts",
57
59
  "src/vision-agent.ts",
58
60
  "src/session-name.ts",
59
- "src/bwrap/index.ts",
60
- "src/workspace-guard.ts",
61
- "src/claude-code/index.ts",
62
- "src/opencode/index.ts",
63
- "src/bash-default-timeout.ts",
64
61
  "src/gh-readonly.ts",
65
62
  "src/spawn-agent.ts",
66
63
  "src/talk/index.ts"
@@ -12,6 +12,7 @@ import {
12
12
  } from "@earendil-works/pi-coding-agent";
13
13
  import { Type } from "typebox";
14
14
 
15
+ import { guardWriteAccess } from "../lib/write-guard.js";
15
16
  import {
16
17
  type ClaudeCodeState,
17
18
  type FileSnapshot,
@@ -241,9 +242,18 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
241
242
  },
242
243
  { additionalProperties: false },
243
244
  ),
244
- async execute(_id, params, signal) {
245
+ async execute(_id, params, signal, _onUpdate, ctx) {
245
246
  throwIfAborted(signal);
246
247
  const filePath = requireAbsolutePath(params.file_path);
248
+ await guardWriteAccess(ctx, {
249
+ toolName: "Edit",
250
+ absolutePath: filePath,
251
+ change: {
252
+ oldText: params.old_string,
253
+ newText: params.new_string,
254
+ replaceAll: params.replace_all,
255
+ },
256
+ });
247
257
  return withFileMutationQueue(filePath, async () => {
248
258
  await requireCurrentRead(state, filePath);
249
259
  await access(filePath, constants.R_OK | constants.W_OK);
@@ -287,9 +297,14 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
287
297
  },
288
298
  { additionalProperties: false },
289
299
  ),
290
- async execute(_id, params, signal) {
300
+ async execute(_id, params, signal, _onUpdate, ctx) {
291
301
  throwIfAborted(signal);
292
302
  const filePath = requireAbsolutePath(params.file_path);
303
+ await guardWriteAccess(ctx, {
304
+ toolName: "Write",
305
+ absolutePath: filePath,
306
+ change: { oldText: "", newText: params.content },
307
+ });
293
308
  return withFileMutationQueue(filePath, async () => {
294
309
  let original: string | undefined;
295
310
  try {
@@ -1,29 +1,14 @@
1
- import { stat } from "node:fs/promises";
2
- import { isAbsolute, resolve } from "node:path";
3
-
4
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
2
  import { Type } from "typebox";
6
3
 
7
4
  import { bwrapRuntime } from "../bwrap/runtime.js";
5
+ import { resolveWorkdir } from "../lib/path.js";
8
6
 
9
7
  const DEFAULT_TIMEOUT_MS = 120_000;
10
8
  const MAX_TIMEOUT_MS = 600_000;
11
9
 
12
- async function resolveWorkdir(workdir: string, cwd: string): Promise<string> {
13
- const target = isAbsolute(workdir) ? workdir : resolve(cwd, workdir);
14
- let info;
15
- try {
16
- info = await stat(target);
17
- } catch {
18
- throw new Error(`Working directory does not exist: ${target}`);
19
- }
20
- if (!info.isDirectory()) {
21
- throw new Error(`Working directory is not a directory: ${target}`);
22
- }
23
- return target;
24
- }
25
-
26
10
  export function registerShellTools(pi: ExtensionAPI): void {
11
+ bwrapRuntime.setup(pi);
27
12
  pi.registerTool({
28
13
  name: "Bash",
29
14
  label: "Bash",
package/src/lib/path.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Shared path helpers used by multiple extensions.
3
3
  */
4
4
 
5
+ import { stat } from "node:fs/promises";
5
6
  import { homedir } from "node:os";
6
7
  import { isAbsolute, join, resolve } from "node:path";
7
8
 
@@ -26,3 +27,22 @@ export function resolveHomePath(p: string, baseDir: string): string {
26
27
  const expanded = expandHome(p);
27
28
  return isAbsolute(expanded) ? resolve(expanded) : resolve(baseDir, expanded);
28
29
  }
30
+
31
+ /**
32
+ * Resolve a command `workdir` argument: absolute paths are used as-is, relative
33
+ * paths resolve against `baseDir`. Throws if the target does not exist or is
34
+ * not a directory.
35
+ */
36
+ export async function resolveWorkdir(workdir: string, baseDir: string): Promise<string> {
37
+ const target = isAbsolute(workdir) ? workdir : resolve(baseDir, workdir);
38
+ let info;
39
+ try {
40
+ info = await stat(target);
41
+ } catch {
42
+ throw new Error(`Working directory does not exist: ${target}`);
43
+ }
44
+ if (!info.isDirectory()) {
45
+ throw new Error(`Working directory is not a directory: ${target}`);
46
+ }
47
+ return target;
48
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Workspace write guard, embedded directly in each write/edit tool.
3
+ *
4
+ * File-modifying tools gate themselves:
5
+ * - Paths inside the workspace or /tmp are auto-allowed.
6
+ * - Paths outside require user approval via a confirmation dialog showing a
7
+ * `diff` code block preview of the pending change.
8
+ * - Headless sessions (no UI) reject outside writes outright.
9
+ *
10
+ * Callers have already parsed their tool arguments, so the guard only takes the
11
+ * resolved pieces: the raw target path and the pending change (oldText/newText).
12
+ */
13
+
14
+ import { readFile } from "node:fs/promises";
15
+ import { basename, isAbsolute, relative, sep } from "node:path";
16
+
17
+ import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
18
+
19
+ import { normalizeForEdit, replace } from "../opencode/edit-engine.js";
20
+
21
+ const ALWAYS_ALLOW = ["/tmp"];
22
+ const MAX_PREVIEW_LINES = 100;
23
+
24
+ function isInside(dir: string, filePath: string): boolean {
25
+ const rel = relative(dir, filePath);
26
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
27
+ }
28
+
29
+ function isPathAllowed(absolutePath: string, cwd: string): boolean {
30
+ if (isInside(cwd, absolutePath)) return true;
31
+
32
+ for (const allowed of ALWAYS_ALLOW) {
33
+ if (isInside(allowed, absolutePath)) return true;
34
+ }
35
+
36
+ return false;
37
+ }
38
+
39
+ /** Wrap patch text in a `diff` code block, truncating very large diffs. */
40
+ function wrapDiff(patch: string): string {
41
+ const lines = patch.split("\n");
42
+ if (lines.length > MAX_PREVIEW_LINES) {
43
+ const truncated = lines.slice(0, MAX_PREVIEW_LINES).join("\n");
44
+ return `\`\`\`diff\n${truncated}\n… (preview truncated to ${MAX_PREVIEW_LINES} lines)\n\`\`\``;
45
+ }
46
+ return `\`\`\`diff\n${patch}\n\`\`\``;
47
+ }
48
+
49
+ /** The pending file change, described by the caller from already-parsed args. */
50
+ export interface PendingChange {
51
+ /** Text to replace; empty for whole-file writes. */
52
+ oldText: string;
53
+ /** Replacement text. */
54
+ newText: string;
55
+ /** Replace all occurrences of oldText (edits only). */
56
+ replaceAll?: boolean;
57
+ }
58
+
59
+ /**
60
+ * Build a `diff` code block preview of the pending change.
61
+ * Returns undefined when the diff cannot be computed.
62
+ */
63
+ export async function buildDiffPreview(
64
+ resolvedPath: string,
65
+ change: PendingChange,
66
+ ): Promise<string | undefined> {
67
+ let oldContent = "";
68
+ try {
69
+ oldContent = await readFile(resolvedPath, "utf8");
70
+ } catch {
71
+ // Unreadable or missing file: treat as empty so writes show as full additions.
72
+ }
73
+
74
+ if (change.oldText === "") {
75
+ // Whole-file write: show the full addition/replacement patch.
76
+ return wrapDiff(generateUnifiedPatch(basename(resolvedPath), oldContent, change.newText, 2));
77
+ }
78
+
79
+ // Edit: reuse the real matching engine to locate oldText, giving a
80
+ // line-numbered patch when it matches. Fall back to a parameter diff when the
81
+ // edit cannot be applied (oldText not found, ambiguous, or no file).
82
+ try {
83
+ const normalized = normalizeForEdit(oldContent);
84
+ const newContent = replace(normalized, change.oldText, change.newText, change.replaceAll);
85
+ // The full path is shown in the dialog title, so the patch header only
86
+ // carries the file name.
87
+ return wrapDiff(generateUnifiedPatch(basename(resolvedPath), normalized, newContent, 2));
88
+ } catch {
89
+ const removed = change.oldText.split("\n").map((line) => `-${line}`);
90
+ const added = change.newText.split("\n").map((line) => `+${line}`);
91
+ return wrapDiff([...removed, ...added].join("\n"));
92
+ }
93
+ }
94
+
95
+ export interface WriteGuardContext {
96
+ cwd: string;
97
+ hasUI: boolean;
98
+ abort?: () => void;
99
+ ui?: {
100
+ select: (title: string, options: string[]) => Promise<string | undefined>;
101
+ input: (title: string, placeholder?: string) => Promise<string | undefined>;
102
+ };
103
+ }
104
+
105
+ export interface WriteGuardOptions {
106
+ toolName: string;
107
+ /** The resolved absolute target path (caller has already parsed its args). */
108
+ absolutePath: string;
109
+ change: PendingChange;
110
+ }
111
+
112
+ /**
113
+ * Gate a write/edit call by its target path: auto-allows workspace and /tmp
114
+ * writes, otherwise asks for user approval (or rejects in headless sessions).
115
+ * Throws when the write is denied.
116
+ */
117
+ export async function guardWriteAccess(
118
+ ctx: WriteGuardContext | undefined,
119
+ opts: WriteGuardOptions,
120
+ ): Promise<void> {
121
+ if (!ctx) return;
122
+ const { absolutePath } = opts;
123
+ if (isPathAllowed(absolutePath, ctx.cwd)) return;
124
+
125
+ if (!ctx.hasUI || !ctx.ui) {
126
+ throw new Error(`Path "${absolutePath}" is outside workspace. No UI available for approval.`);
127
+ }
128
+
129
+ while (true) {
130
+ const diffPreview = await buildDiffPreview(absolutePath, opts.change);
131
+ const title =
132
+ `Model requests write access outside workspace:\n\n` +
133
+ ` Tool: ${opts.toolName}\n` +
134
+ ` Path: ${absolutePath}\n` +
135
+ (diffPreview ? `\n${diffPreview}\n` : "") +
136
+ `\nAllow?`;
137
+
138
+ const choice = await ctx.ui.select(title, ["Approve once", "Block", "Block with reason"]);
139
+ if (choice === undefined) {
140
+ ctx.abort?.();
141
+ throw new Error("Write outside workspace cancelled by user.");
142
+ }
143
+ if (choice === "Approve once") return;
144
+ if (choice === "Block") throw new Error("Write outside workspace denied by user.");
145
+ const feedback = await ctx.ui.input("Why was this write denied?");
146
+ if (feedback === undefined) continue;
147
+ throw new Error(
148
+ feedback
149
+ ? `Write outside workspace denied: ${feedback}`
150
+ : "Write outside workspace denied by user.",
151
+ );
152
+ }
153
+ }
@@ -0,0 +1,75 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+
4
+ import { bwrapRuntime } from "../bwrap/runtime.js";
5
+ import { resolveWorkdir } from "../lib/path.js";
6
+
7
+ const DEFAULT_TIMEOUT_MS = 120_000;
8
+ const MAX_TIMEOUT_MS = 600_000;
9
+
10
+ export default function opencodeBash(pi: ExtensionAPI): void {
11
+ bwrapRuntime.setup(pi);
12
+ pi.registerTool({
13
+ name: "bash",
14
+ label: "bash",
15
+ description: [
16
+ "Executes a given bash command synchronously and returns its output.",
17
+ "The default working directory is the current directory; use workdir to run elsewhere.",
18
+ "timeout is in milliseconds, defaults to 120000, and may not exceed 600000.",
19
+ "Every command runs in the foreground. Background command execution is not supported; shell jobs are waited for before the tool returns.",
20
+ ].join("\n"),
21
+ parameters: Type.Object(
22
+ {
23
+ command: Type.String({ description: "The command to execute" }),
24
+ workdir: Type.Optional(
25
+ Type.String({
26
+ description:
27
+ "Working directory to execute the command in. Defaults to the current directory; relative paths resolve from there.",
28
+ }),
29
+ ),
30
+ timeout: Type.Optional(
31
+ Type.Number({
32
+ description: "Optional timeout in milliseconds (max 600000)",
33
+ default: 600,
34
+ }),
35
+ ),
36
+ dangerouslyDisableSandbox: Type.Optional(
37
+ Type.Boolean({
38
+ description:
39
+ "Request one-time unsandboxed execution. The user must approve this request.",
40
+ }),
41
+ ),
42
+ },
43
+ { additionalProperties: false },
44
+ ),
45
+ async execute(id, params, signal, onUpdate, ctx) {
46
+ const timeout = params.timeout ?? DEFAULT_TIMEOUT_MS;
47
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS) {
48
+ throw new Error(`timeout must be between 1 and ${MAX_TIMEOUT_MS} milliseconds`);
49
+ }
50
+
51
+ const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
52
+
53
+ try {
54
+ return await bwrapRuntime.execute({
55
+ ctx: { ...ctx, cwd },
56
+ toolCallId: id,
57
+ command: params.command,
58
+ timeout: timeout / 1000,
59
+ requestFullAccess: params.dangerouslyDisableSandbox,
60
+ signal,
61
+ onUpdate,
62
+ });
63
+ } catch (error) {
64
+ if (!(error instanceof Error)) throw error;
65
+ const timeoutMatch = /Command timed out after [\d.]+ seconds/.exec(error.message);
66
+ const message = timeoutMatch
67
+ ? error.message.slice(0, timeoutMatch.index) +
68
+ `Command timed out after ${timeout} milliseconds` +
69
+ error.message.slice(timeoutMatch.index + timeoutMatch[0].length)
70
+ : error.message;
71
+ throw new Error(message, { cause: error });
72
+ }
73
+ },
74
+ });
75
+ }
@@ -7,8 +7,8 @@
7
7
  * and wrapped in a pi extension so the behaviour is identical to opencode.
8
8
  *
9
9
  * Shared by:
10
- * - opencode-edit.ts — the edit tool implementation
11
- * - workspace-guard.ts — the diff preview shown in the approval dialog
10
+ * - opencode-edit.ts — the edit tool implementation
11
+ * - lib/write-guard.ts — the diff preview shown in the approval dialog
12
12
  */
13
13
 
14
14
  // ── BOM & line ending helpers ─────────────────────────────────────────────────
@@ -11,7 +11,7 @@
11
11
  * formatter run.
12
12
  *
13
13
  * The matching engine (replacers + replace()) lives in opencode/edit-engine.ts
14
- * and is also used by workspace-guard for the diff preview.
14
+ * and is also used by lib/write-guard for the diff preview.
15
15
  *
16
16
  * Usage:
17
17
  * pi -e ./opencode-edit.ts
@@ -29,6 +29,7 @@ import {
29
29
  } from "@earendil-works/pi-coding-agent";
30
30
  import { Type } from "typebox";
31
31
 
32
+ import { guardWriteAccess } from "../lib/write-guard.js";
32
33
  import {
33
34
  detectLineEnding,
34
35
  normalizeToLF,
@@ -79,6 +80,12 @@ export default function opencodeEdit(pi: ExtensionAPI) {
79
80
 
80
81
  const absolutePath = isAbsolute(filePath) ? filePath : resolve(ctx.cwd, filePath);
81
82
 
83
+ await guardWriteAccess(ctx, {
84
+ toolName: "edit",
85
+ absolutePath,
86
+ change: { oldText: oldString, newText: newString, replaceAll },
87
+ });
88
+
82
89
  const throwIfAborted = (): void => {
83
90
  if (signal?.aborted) throw new Error("Operation aborted");
84
91
  };
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * opencode —— 统一注册 opencode 风格工具扩展。
3
3
  *
4
- * 聚合 read / edit / write / todo / question 五个工具,一次加载全部注册;
4
+ * 聚合 read / edit / write / todo / question / bash 六个工具,一次加载全部注册;
5
5
  * 各工具的公开 API(匹配引擎、纯函数等)也从这里重新导出,方便
6
- * 测试与其他模块(如 workspace-guard)引用。
6
+ * 测试与其他模块(如 lib/write-guard)引用。
7
7
  *
8
8
  * Usage:
9
9
  * pi -e ./opencode/index.ts
@@ -14,12 +14,14 @@
14
14
 
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
 
17
+ import opencodeBash from "./bash.js";
17
18
  import opencodeEdit from "./edit.js";
18
19
  import opencodeQuestion from "./question.js";
19
20
  import opencodeRead from "./read.js";
20
21
  import opencodeTodo from "./todo.js";
21
22
  import opencodeWrite from "./write.js";
22
23
 
24
+ export { default as opencodeBash } from "./bash.js";
23
25
  export { default as opencodeEdit } from "./edit.js";
24
26
  export {
25
27
  detectLineEnding,
@@ -40,4 +42,5 @@ export default function opencode(pi: ExtensionAPI) {
40
42
  opencodeWrite(pi);
41
43
  opencodeTodo(pi);
42
44
  opencodeQuestion(pi);
45
+ opencodeBash(pi);
43
46
  }
@@ -28,6 +28,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
28
28
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
29
29
  import { Type } from "typebox";
30
30
 
31
+ import { guardWriteAccess } from "../lib/write-guard.js";
31
32
  import { stripBom } from "./edit-engine.js";
32
33
 
33
34
  /**
@@ -69,6 +70,11 @@ export default function opencodeWrite(pi: ExtensionAPI) {
69
70
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
70
71
  const { filePath: rawPath, content } = params;
71
72
  const absolutePath = resolvePath(ctx.cwd, rawPath);
73
+ await guardWriteAccess(ctx, {
74
+ toolName: "write",
75
+ absolutePath,
76
+ change: { oldText: "", newText: content },
77
+ });
72
78
  const dir = dirname(absolutePath);
73
79
 
74
80
  const throwIfAborted = () => {
@@ -50,11 +50,11 @@ const DEFAULT_TOOLS = ["read", "grep", "find", "ls"];
50
50
  const MAX_PROGRESS_LINES = 5;
51
51
 
52
52
  /**
53
- * Extensions loaded unconditionally into every subagent: the workspace write
54
- * guard and the bwrap sandbox. Both are protection layers and must not depend
55
- * on the agent's declared toolset.
53
+ * Extensions loaded unconditionally into every subagent: the bwrap sandbox is a
54
+ * protection layer and must not depend on the agent's declared toolset.
55
+ * (Workspace write protection is embedded in the opencode write/edit tools.)
56
56
  */
57
- const UNCONDITIONAL_EXTENSIONS = ["workspace-guard.ts", "bwrap/index.ts"] as const;
57
+ const UNCONDITIONAL_EXTENSIONS = ["bwrap/index.ts"] as const;
58
58
 
59
59
  /**
60
60
  * Tool → extension override map: when a subagent's frontmatter enables a
@@ -1,9 +0,0 @@
1
- import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
2
-
3
- export default function bashDefaultTimeout(pi: ExtensionAPI) {
4
- pi.on("tool_call", (event) => {
5
- if (isToolCallEventType("bash", event) && event.input.timeout === undefined) {
6
- event.input.timeout = 180;
7
- }
8
- });
9
- }
@@ -1,56 +0,0 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { createBashTool } from "@earendil-works/pi-coding-agent";
3
- import { Type } from "typebox";
4
-
5
- import { bwrapRuntime } from "./runtime.js";
6
-
7
- export {
8
- type BwrapConfig,
9
- resolveBwrap,
10
- type ResolvedBwrap,
11
- resolveHeadlessBwrap,
12
- } from "./core.js";
13
- export { type EscalationDecision, resolveEscalation } from "./runtime.js";
14
-
15
- const sandboxedBashSchema = Type.Object(
16
- {
17
- command: Type.String({ description: "Bash command to execute" }),
18
- timeout: Type.Optional(Type.Number({ description: "Timeout in seconds" })),
19
- request_full_access: Type.Optional(
20
- Type.Boolean({
21
- description:
22
- "Set true to request unsandboxed execution. The bwrap runtime will ask the user for approval.",
23
- }),
24
- ),
25
- request_full_access_reason: Type.Optional(
26
- Type.String({ description: "Explain why unsandboxed execution is required." }),
27
- ),
28
- },
29
- { additionalProperties: false },
30
- );
31
-
32
- export default function bwrapExtension(pi: ExtensionAPI): void {
33
- bwrapRuntime.setup(pi);
34
- const localBash = createBashTool(process.cwd());
35
- pi.registerTool({
36
- name: localBash.name,
37
- label: "bash (bwrap)",
38
- description:
39
- localBash.description +
40
- "\n\nSet request_full_access to true to request unsandboxed execution.",
41
- parameters: sandboxedBashSchema,
42
- executionMode: localBash.executionMode,
43
- execute(id, params, signal, onUpdate, ctx) {
44
- return bwrapRuntime.execute({
45
- toolCallId: id,
46
- command: params.command,
47
- timeout: params.timeout,
48
- requestFullAccess: params.request_full_access,
49
- requestFullAccessReason: params.request_full_access_reason,
50
- signal,
51
- onUpdate,
52
- ctx,
53
- });
54
- },
55
- });
56
- }
@@ -1,253 +0,0 @@
1
- /**
2
- * Workspace Guard Extension
3
- *
4
- * File-modifying tools (write, edit) are gated:
5
- * - Paths inside the workspace or /tmp are auto-allowed.
6
- * - Paths outside require user approval via confirmation dialog.
7
- * The dialog shows a `diff` code block preview of the pending change.
8
- *
9
- * Read tools (read, ls, find, grep) are unrestricted.
10
- *
11
- * Usage:
12
- * pi -e workspace-guard
13
- */
14
-
15
- import { readFile } from "node:fs/promises";
16
- import { basename, isAbsolute, relative, sep } from "node:path";
17
-
18
- import {
19
- type ExtensionAPI,
20
- generateUnifiedPatch,
21
- type ToolCallEvent,
22
- } from "@earendil-works/pi-coding-agent";
23
-
24
- import { resolveHomePath } from "./lib/path.js";
25
- import { normalizeForEdit, replace } from "./opencode/edit-engine.js";
26
-
27
- const WRITE_TOOLS = new Set(["write", "edit"]);
28
- const ALWAYS_ALLOW = ["/tmp"];
29
-
30
- /** Maximum lines of the diff preview shown in the approval dialog. */
31
- const MAX_PREVIEW_LINES = 100;
32
-
33
- export function getWriteTarget(input: ToolCallEvent["input"]): string | undefined {
34
- if (typeof input !== "object" || input === null) return undefined;
35
- if ("path" in input && typeof input.path === "string") return input.path;
36
- if ("filePath" in input && typeof input.filePath === "string") return input.filePath;
37
- return undefined;
38
- }
39
-
40
- function isInside(dir: string, filePath: string): boolean {
41
- const rel = relative(dir, filePath);
42
- return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
43
- }
44
-
45
- function isPathAllowed(resolvedPath: string, cwd: string): boolean {
46
- if (isInside(cwd, resolvedPath)) return true;
47
-
48
- for (const allowed of ALWAYS_ALLOW) {
49
- const resolvedAllowed = resolveHomePath(allowed, cwd);
50
- if (isInside(resolvedAllowed, resolvedPath)) return true;
51
- }
52
-
53
- return false;
54
- }
55
-
56
- /**
57
- * Type guard for built-in edit entries `{ oldText, newText }`.
58
- */
59
- function isEditPair(value: unknown): value is { oldText: string; newText: string } {
60
- return (
61
- typeof value === "object" &&
62
- value !== null &&
63
- "oldText" in value &&
64
- typeof value.oldText === "string" &&
65
- "newText" in value &&
66
- typeof value.newText === "string"
67
- );
68
- }
69
-
70
- /**
71
- * Extract the opencode-style edit pair `{ oldString, newString, replaceAll }`.
72
- */
73
- function getOpencodeEditPair(
74
- input: ToolCallEvent["input"],
75
- ): { oldString: string; newString: string; replaceAll: boolean } | undefined {
76
- if (typeof input !== "object" || input === null) return undefined;
77
- if (!("oldString" in input) || !("newString" in input)) return undefined;
78
- if (typeof input.oldString !== "string" || typeof input.newString !== "string") return undefined;
79
- return {
80
- oldString: input.oldString,
81
- newString: input.newString,
82
- replaceAll: "replaceAll" in input && input.replaceAll === true,
83
- };
84
- }
85
-
86
- /**
87
- * Apply the pending write/edit to `content`.
88
- * Returns undefined when the change cannot be applied (missing fields or oldText not found).
89
- */
90
- function applyChange(
91
- toolName: string,
92
- input: ToolCallEvent["input"],
93
- content: string,
94
- ): string | undefined {
95
- if (typeof input !== "object" || input === null) return undefined;
96
-
97
- if (toolName === "write") {
98
- return "content" in input && typeof input.content === "string" ? input.content : undefined;
99
- }
100
-
101
- // Built-in edit: { path, edits: [{ oldText, newText }] }
102
- if ("edits" in input && Array.isArray(input.edits)) {
103
- let next = content;
104
- for (const edit of input.edits) {
105
- if (!isEditPair(edit)) return undefined;
106
- if (!next.includes(edit.oldText)) return undefined;
107
- next = next.replace(edit.oldText, () => edit.newText);
108
- }
109
- return next;
110
- }
111
-
112
- return undefined;
113
- }
114
-
115
- /** Wrap patch text in a `diff` code block, truncating very large diffs. */
116
- function wrapDiff(patch: string): string {
117
- const lines = patch.split("\n");
118
- if (lines.length > MAX_PREVIEW_LINES) {
119
- const truncated = lines.slice(0, MAX_PREVIEW_LINES).join("\n");
120
- return `\`\`\`diff\n${truncated}\n… (preview truncated to ${MAX_PREVIEW_LINES} lines)\n\`\`\``;
121
- }
122
- return `\`\`\`diff\n${patch}\n\`\`\``;
123
- }
124
-
125
- /**
126
- * Build a `diff` code block preview of the pending change.
127
- * Returns undefined when the diff cannot be computed.
128
- */
129
- export async function buildDiffPreview(
130
- toolName: string,
131
- input: ToolCallEvent["input"],
132
- resolvedPath: string,
133
- ): Promise<string | undefined> {
134
- let oldContent = "";
135
- try {
136
- oldContent = await readFile(resolvedPath, "utf8");
137
- } catch {
138
- // Unreadable or missing file: treat as empty so writes show as full additions.
139
- }
140
-
141
- // opencode-edit: reuse the real matching engine to locate oldString, giving a
142
- // line-numbered patch when it matches. Fall back to a parameter diff when the
143
- // edit cannot be applied (oldString not found, ambiguous, or no file).
144
- const pair = getOpencodeEditPair(input);
145
- if (pair) {
146
- try {
147
- const normalized = normalizeForEdit(oldContent);
148
- const newContent = replace(normalized, pair.oldString, pair.newString, pair.replaceAll);
149
- // The full path is shown in the dialog title, so the patch header only
150
- // carries the file name.
151
- return wrapDiff(generateUnifiedPatch(basename(resolvedPath), normalized, newContent, 2));
152
- } catch {
153
- const removed = pair.oldString.split("\n").map((line) => `-${line}`);
154
- const added = pair.newString.split("\n").map((line) => `+${line}`);
155
- return wrapDiff([...removed, ...added].join("\n"));
156
- }
157
- }
158
-
159
- const newContent = applyChange(toolName, input, oldContent);
160
- if (newContent === undefined) return undefined;
161
-
162
- return wrapDiff(generateUnifiedPatch(basename(resolvedPath), oldContent, newContent, 2));
163
- }
164
-
165
- export interface WriteGuardDecision {
166
- block: boolean;
167
- reason?: string;
168
- }
169
-
170
- /**
171
- * Decide how to handle a write outside the workspace.
172
- */
173
- export function decideOutsideWorkspaceWrite(
174
- rawPath: string,
175
- opts: { hasUI: boolean },
176
- ): WriteGuardDecision {
177
- if (!opts.hasUI) {
178
- return {
179
- block: true,
180
- reason: `Path "${rawPath}" is outside workspace. No UI available for approval.`,
181
- };
182
- }
183
- return { block: false };
184
- }
185
-
186
- export default function workspaceGuard(pi: ExtensionAPI) {
187
- pi.on("before_agent_start", (event, ctx) => {
188
- const currentCwd = ctx.cwd;
189
- return {
190
- systemPrompt:
191
- event.systemPrompt +
192
- `\nWorkspace write protection is active. ` +
193
- `write and edit to paths inside the workspace "${currentCwd}" or /tmp are auto-allowed. ` +
194
- (ctx.hasUI
195
- ? `Paths outside require user approval before execution.`
196
- : `Writes outside the workspace are rejected because no UI is available for approval.`),
197
- };
198
- });
199
-
200
- pi.on("tool_call", async (event, ctx) => {
201
- if (!WRITE_TOOLS.has(event.toolName)) return;
202
-
203
- const rawPath = getWriteTarget(event.input);
204
- if (!rawPath) return;
205
-
206
- const resolved = resolveHomePath(rawPath, ctx.cwd);
207
-
208
- if (isPathAllowed(resolved, ctx.cwd)) return;
209
-
210
- const decision = decideOutsideWorkspaceWrite(rawPath, {
211
- hasUI: ctx.hasUI,
212
- });
213
- if (decision.block) return decision;
214
-
215
- let choice: string | undefined;
216
- while (!choice) {
217
- const diffPreview = await buildDiffPreview(event.toolName, event.input, resolved);
218
-
219
- const title =
220
- `Model requests write access outside workspace:\n\n` +
221
- ` Tool: ${event.toolName}\n` +
222
- ` Path: ${rawPath}\n` +
223
- ` Resolved: ${resolved}\n` +
224
- (diffPreview ? `\n${diffPreview}\n` : "") +
225
- `\nAllow?`;
226
-
227
- choice = await ctx.ui.select(title, ["Approve once", "Block", "Block with reason"]);
228
-
229
- if (choice === undefined) {
230
- ctx.abort();
231
- return { block: true, reason: "Write outside workspace cancelled by user." };
232
- }
233
-
234
- if (choice === "Block with reason") {
235
- const feedback = await ctx.ui.input("Why was this write denied?");
236
- if (feedback === undefined) {
237
- choice = undefined; // cancelled input, retry select
238
- continue;
239
- }
240
- return {
241
- block: true,
242
- reason: feedback
243
- ? `Write outside workspace denied: ${feedback}`
244
- : "Write outside workspace denied by user.",
245
- };
246
- }
247
- }
248
-
249
- if (choice !== "Approve once") {
250
- return { block: true, reason: "Write outside workspace denied by user." };
251
- }
252
- });
253
- }