@trim21/personal-pi-extensions 0.0.399 → 0.0.401

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
@@ -52,7 +52,9 @@ bash 工具(opencode 风格 `bash`、Claude Code 风格 `Bash`)注册了 `da
52
52
 
53
53
  ### 保护目录
54
54
 
55
- `.git`、`.pi`、`.agent` 即使在 `workspace-write` 模式下也始终只读。
55
+ `.pi`、`.agent` 即使在 `workspace-write` 模式下也始终只读;`.git` 同样只读:工作区根是 git 仓库时保护根 `.git`,根不是 git 仓库时才递归扫描嵌套仓库(monorepo 子仓库,跳过 `node_modules`、`.venv` 等包目录)。
56
+
57
+ 可写与保护路径均使用 bwrap 的 `--*-bind-try` 变体:路径不存在时自动忽略该项,而不是让整条命令失败。
56
58
 
57
59
  ### 运行时命令
58
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.399",
3
+ "version": "0.0.401",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/bwrap/core.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type ChildProcess, spawn } from "node:child_process";
2
- import { closeSync, constants, existsSync, openSync, readFileSync } from "node:fs";
3
- import { access as fsAccess } from "node:fs/promises";
2
+ import { closeSync, constants, type Dirent, existsSync, openSync, readFileSync } from "node:fs";
3
+ import { access as fsAccess, readdir, stat } from "node:fs/promises";
4
4
  import { delimiter, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
@@ -12,7 +12,7 @@ import { Value } from "typebox/value";
12
12
  import { expandHome } from "../lib/path.js";
13
13
  import { type ApprovalRule } from "./approval-rules.js";
14
14
 
15
- const PROTECTED_DIRS = [".git", ".pi", ".agent"];
15
+ const PROTECTED_DIRS = [".pi", ".agent"];
16
16
 
17
17
  export const BWRAP_MODES = ["allow-all", "workspace-write", "allow-net", "readonly"] as const;
18
18
 
@@ -190,23 +190,96 @@ export function findBwrap(override?: string): string {
190
190
  return findDefaultBwrap();
191
191
  }
192
192
 
193
- export function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): string[] {
193
+ /** 扫描 .git 时跳过的目录:包/依赖/构建产物,嵌套 git 仓库几乎不会出现在这里。 */
194
+ const GIT_DIR_SCAN_SKIP = new Set([
195
+ "node_modules",
196
+ ".venv",
197
+ "venv",
198
+ ".venvs",
199
+ "dist",
200
+ "build",
201
+ "target",
202
+ "__pycache__",
203
+ ".next",
204
+ ".cache",
205
+ ".turbo",
206
+ ".pytest_cache",
207
+ ".mypy_cache",
208
+ ".hg",
209
+ ".svn",
210
+ ]);
211
+
212
+ /** 最多收集的 .git 数量与扫描深度,防止异常大的工作区拖慢每条命令。 */
213
+ const MAX_GIT_DIRS = 64;
214
+ const MAX_GIT_SCAN_DEPTH = 24;
215
+
216
+ class ScanLimitError extends Error {}
217
+
218
+ /**
219
+ * 递归扫描工作区,收集所有 `.git` 目录的绝对路径(monorepo / 嵌套仓库)。
220
+ * 达到数量上限立即停止整棵遍历;跳过 symlink(防循环)与无法读取的目录。
221
+ */
222
+ export async function findGitDirs(root: string): Promise<string[]> {
223
+ const found: string[] = [];
224
+ const walk = async (dir: string, depth: number): Promise<void> => {
225
+ if (depth > MAX_GIT_SCAN_DEPTH) return;
226
+ let entries: Dirent[];
227
+ try {
228
+ entries = await readdir(dir, { withFileTypes: true });
229
+ } catch {
230
+ return;
231
+ }
232
+ for (const entry of entries) {
233
+ if (found.length >= MAX_GIT_DIRS) throw new ScanLimitError();
234
+ if (entry.name === ".git" && entry.isDirectory()) {
235
+ found.push(join(dir, ".git"));
236
+ continue;
237
+ }
238
+ if (!entry.isDirectory() || GIT_DIR_SCAN_SKIP.has(entry.name)) continue;
239
+ await walk(join(dir, entry.name), depth + 1);
240
+ }
241
+ };
242
+ try {
243
+ await walk(root, 0);
244
+ } catch (error) {
245
+ if (!(error instanceof ScanLimitError)) throw error;
246
+ }
247
+ return found;
248
+ }
249
+
250
+ export async function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): Promise<string[]> {
194
251
  const args = ["--new-session", "--die-with-parent", "--unshare-user", "--unshare-pid"];
252
+ // --*-bind-try:配置的路径不存在时忽略该项而不是让整条命令失败
195
253
  for (const path of resolved.writablePaths) {
196
254
  const absolutePath = resolveBwrapPath(path, cwd);
197
- args.push("--bind", absolutePath, absolutePath);
255
+ args.push("--bind-try", absolutePath, absolutePath);
198
256
  }
199
257
  for (const path of resolved.extraWritablePaths) {
200
258
  const absolutePath = resolveBwrapPath(path, cwd);
201
- args.push("--bind", absolutePath, absolutePath);
259
+ args.push("--bind-try", absolutePath, absolutePath);
202
260
  }
203
261
  for (const path of resolved.tmpfsPaths) {
204
262
  args.push("--tmpfs", resolveBwrapPath(path, cwd));
205
263
  }
206
264
  if (!resolved.network) args.push("--unshare-net");
265
+ // --ro-bind-try:目录不存在(或已被删除)时自动忽略
207
266
  for (const name of PROTECTED_DIRS) {
208
267
  const absolutePath = join(cwd, name);
209
- if (existsSync(absolutePath)) args.push("--ro-bind", absolutePath, absolutePath);
268
+ args.push("--ro-bind-try", absolutePath, absolutePath);
269
+ }
270
+ // 工作区下所有 .git 一律只读:可写 bind 之上的覆盖绑定,防止命令篡改仓库元数据。
271
+ // 根目录本身是 git 仓库时只保护根 .git(递归扫描有成本,绝大多数情况根即唯一仓库);
272
+ // 根不是 git 仓库时才递归扫描嵌套仓库(如 monorepo 子仓库)。
273
+ const rootGit = join(cwd, ".git");
274
+ let gitDirs: string[];
275
+ try {
276
+ await stat(rootGit);
277
+ gitDirs = [rootGit];
278
+ } catch {
279
+ gitDirs = await findGitDirs(cwd);
280
+ }
281
+ for (const gitDir of gitDirs) {
282
+ args.push("--ro-bind-try", gitDir, gitDir);
210
283
  }
211
284
  args.push(...resolved.extraArgs);
212
285
  return args;
@@ -265,7 +338,7 @@ export function createBwrapBashOperations(
265
338
  "--ro-bind",
266
339
  "/",
267
340
  "/",
268
- ...buildBwrapArgs(resolved, workspace),
341
+ ...(await buildBwrapArgs(resolved, workspace)),
269
342
  "--dev",
270
343
  "/dev",
271
344
  "--proc",
@@ -6,6 +6,8 @@ The working directory persists between commands, but shell state does not. The s
6
6
 
7
7
  Commands run in a sandbox: no write access outside the workspace and no network access. Use the `dangerouslyDisableSandbox` parameter to request unsandboxed execution; the user must approve this request.
8
8
 
9
+ Long command output is automatically truncated and the full output is saved to a file. Avoid piping commands through `tail` to limit output unless you have a specific reason.
10
+
9
11
  IMPORTANT: Avoid using this tool to run `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user:
10
12
 
11
13
  - File search: Use Glob (NOT find or ls)
@@ -0,0 +1,7 @@
1
+ ### Bash tool
2
+
3
+ Executes a given bash command synchronously and returns its output.
4
+
5
+ Commands run in a sandbox: no write access outside the workspace and no network access. Use the `dangerouslyDisableSandbox` parameter to request unsandboxed execution; the user must approve this request.
6
+
7
+ Long command output is automatically truncated and the full output is saved to a file. Avoid piping commands through `tail` to limit output unless you have a specific reason.
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
1
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
5
  import { Type } from "typebox";
3
6
 
@@ -10,6 +13,9 @@ const MAX_TIMEOUT_MS = 600_000;
10
13
  /** 对齐上游 opencode 的截断提示文案(tools/BashTool/bash.ts)。 */
11
14
  const CAPTURE_TRUNCATED_NOTICE = "[output capture truncated at the in-memory safety limit]";
12
15
 
16
+ /** Bash tool guidance, kept in markdown so it reads like documentation. */
17
+ const BASH_PROMPT = readFileSync(fileURLToPath(new URL("bash.md", import.meta.url)), "utf8").trim();
18
+
13
19
  /**
14
20
  * 对齐上游 opencode(packages/core/src/tool/bash.ts):
15
21
  * 命令失败(非 0 退出码)与超时都不抛错,输出与状态文本一起返回,
@@ -32,6 +38,7 @@ export default function opencodeBash(
32
38
  "Every command runs in the foreground. Background command execution is not supported; shell jobs are waited for before the tool returns.",
33
39
  ].join("\n"),
34
40
  promptSnippet: "execute bash command",
41
+ promptGuidelines: [BASH_PROMPT],
35
42
  parameters: Type.Object(
36
43
  {
37
44
  command: Type.String({ description: "The command to execute" }),