@trim21/personal-pi-extensions 0.0.155 → 0.0.160

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
@@ -4,13 +4,12 @@
4
4
 
5
5
  ## 扩展概览
6
6
 
7
- | 扩展 | 描述 |
8
- | ------------------------------------------------- | ------------------------------------------------------------ |
9
- | [bwrap](#bwrap) | 基于 bubblewrap 的 OS 级沙箱,提供文件系统和网络隔离 |
10
- | [workspace-guard](#workspace-guard) | 限制文件写入在 workspace 内,外部写入需用户审批 |
11
- | [opencode-edit](#opencode-edit) | 替换内置 edit 工具,使用 opencode 的 schema 和匹配引擎 |
12
- | [bash-default-timeout](#bash-default-timeout) | 为 bash 工具设置默认超时(180 秒) |
13
- | [agents-md-user-message](#agents-md-user-message) | 将项目级 AGENTS.md 移至 user message,避免占用 system prompt |
7
+ | 扩展 | 描述 |
8
+ | --------------------------------------------- | ------------------------------------------------------ |
9
+ | [bwrap](#bwrap) | 基于 bubblewrap 的 OS 级沙箱,提供文件系统和网络隔离 |
10
+ | [workspace-guard](#workspace-guard) | 限制文件写入在 workspace 内,外部写入需用户审批 |
11
+ | [opencode-edit](#opencode-edit) | 替换内置 edit 工具,使用 opencode 的 schema 和匹配引擎 |
12
+ | [bash-default-timeout](#bash-default-timeout) | 为 bash 工具设置默认超时(180 秒) |
14
13
 
15
14
  ---
16
15
 
@@ -134,23 +133,6 @@ pi -e ./src/bash-default-timeout.ts
134
133
 
135
134
  ---
136
135
 
137
- ## agents-md-user-message
138
-
139
- 将项目级 `AGENTS.md` 从 system prompt 移至 user message,减少 system prompt 占用。
140
-
141
- - 项目级 `AGENTS.md` → 放入 user message(仅首次消息注入一次)
142
- - 全局 `~/.pi/agent/AGENTS.md` → 保留在 system prompt
143
-
144
- 需要配合 `--no-context-files` 参数禁用 pi 默认的上下文文件加载。
145
-
146
- ### 使用
147
-
148
- ```bash
149
- pi -e ./src/agents-md-user-message.ts --no-context-files
150
- ```
151
-
152
- ---
153
-
154
136
  ## 安装
155
137
 
156
138
  ### 通过 npm/git 包
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.155",
3
+ "version": "0.0.160",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -43,7 +43,7 @@
43
43
  "eslint-plugin-promise": "7.3.0",
44
44
  "eslint-plugin-simple-import-sort": "14.0.0",
45
45
  "eslint-plugin-tsdoc": "0.5.2",
46
- "eslint-plugin-unicorn": "71.1.0",
46
+ "eslint-plugin-unicorn": "73.0.0",
47
47
  "eslint-plugin-unused-imports": "4.4.1",
48
48
  "husky": "^9.1.7",
49
49
  "lint-staged": "^17.3.0",
@@ -104,8 +104,29 @@ work inside the sandbox, run it WITHOUT full access first. If it fails with
104
104
  and set \`request_full_access_reason\` to describe the failure.
105
105
  `;
106
106
 
107
+ const SUBAGENT_SANDBOX_PROMPT = `
108
+ ## Command Execution (subagent sandbox)
109
+
110
+ You are running inside a read-only sandbox in a subagent session.
111
+
112
+ - The bash tool is read-only: no filesystem writes, no network access, and
113
+ \`request_full_access\` is denied. Do not pass \`request_full_access\`.
114
+ - Use the write/edit tools for file changes inside your workspace.
115
+ - Writes outside the workspace are rejected. If a change outside the
116
+ workspace is needed, ask the parent session to apply it.
117
+ - If a command truly requires network or system-level access, stop and ask
118
+ the parent session to run it, where the user can approve it.
119
+ `;
120
+
107
121
  const PROTECTED_DIRS = [".git", ".pi", ".agent"];
108
122
 
123
+ /**
124
+ * pi-subagents sets PI_SUBAGENT_CHILD=1 in every spawned child session
125
+ * (foreground and async alike). Subagent sessions are headless and must not
126
+ * be able to bypass the sandbox, so bwrap forces read-only bash there.
127
+ */
128
+ const isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1";
129
+
109
130
  const bwrapPath = findDefaultBwrap();
110
131
 
111
132
  function findDefaultBwrap(): string {
@@ -134,7 +155,7 @@ function findBwrap(override?: string): string {
134
155
 
135
156
  type BwrapMode = "allow-all" | "workspace-write" | "readonly";
136
157
 
137
- interface BwrapConfig {
158
+ export interface BwrapConfig {
138
159
  mode: BwrapMode;
139
160
  bwrapPath?: string;
140
161
  writablePaths?: string[];
@@ -143,7 +164,7 @@ interface BwrapConfig {
143
164
  extraArgs?: string[];
144
165
  }
145
166
 
146
- interface ResolvedBwrap {
167
+ export interface ResolvedBwrap {
147
168
  mode: BwrapMode;
148
169
  bwrapEnabled: boolean;
149
170
  network: boolean;
@@ -176,6 +197,22 @@ function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
176
197
  }
177
198
  }
178
199
 
200
+ /**
201
+ * Subagent sessions are forced read-only regardless of config: no writable
202
+ * paths (including configured extraWritablePaths), no network, and no
203
+ * user-supplied extra args that could add writable mounts.
204
+ */
205
+ export function resolveSubagentBwrap(config: BwrapConfig): ResolvedBwrap {
206
+ return resolveBwrap({
207
+ ...config,
208
+ mode: "readonly",
209
+ writablePaths: [],
210
+ extraWritablePaths: [],
211
+ tmpfsPaths: [],
212
+ extraArgs: [],
213
+ });
214
+ }
215
+
179
216
  const DEFAULT_CONFIG: BwrapConfig = {
180
217
  mode: "workspace-write",
181
218
  writablePaths: [".", "/tmp"],
@@ -483,6 +520,36 @@ function notifyMode(
483
520
  ctx.ui.notify(labels[mode], "info");
484
521
  }
485
522
 
523
+ export type EscalationDecision = { kind: "dialog" } | { kind: "deny"; reason: string };
524
+
525
+ /**
526
+ * Escalation (`request_full_access`) policy:
527
+ * - Subagent sessions are always denied: bash is read-only there and there is
528
+ * no approval path (headless), so escalation would silently bypass the sandbox.
529
+ * - Any other headless session is denied: there is no user to approve.
530
+ * - Interactive sessions require the user approval dialog.
531
+ */
532
+ export function resolveEscalation(opts: {
533
+ hasUI: boolean;
534
+ isSubagentChild: boolean;
535
+ }): EscalationDecision {
536
+ if (opts.isSubagentChild) {
537
+ return {
538
+ kind: "deny",
539
+ reason:
540
+ "request_full_access is disabled in subagent sessions: the bash tool is read-only and cannot escalate. Ask the parent session to run this command.",
541
+ };
542
+ }
543
+ if (!opts.hasUI) {
544
+ return {
545
+ kind: "deny",
546
+ reason:
547
+ "request_full_access requires an interactive session with user approval; no UI is available in this session.",
548
+ };
549
+ }
550
+ return { kind: "dialog" };
551
+ }
552
+
486
553
  const sandboxedBashSchema = Type.Object({
487
554
  command: Type.String({ description: "Bash command to execute" }),
488
555
  timeout: Type.Optional(
@@ -517,6 +584,10 @@ export default function bwrapExtension(pi: ExtensionAPI) {
517
584
  let resolved: ResolvedBwrap | null = null;
518
585
 
519
586
  function getResolved(): ResolvedBwrap {
587
+ // Subagents ignore config and runtime switches: bash is always read-only.
588
+ if (isSubagentChild) {
589
+ return resolveSubagentBwrap(loadConfig(localCwd));
590
+ }
520
591
  if (!resolved) {
521
592
  resolved = resolveBwrap(loadConfig(localCwd));
522
593
  }
@@ -524,6 +595,7 @@ export default function bwrapExtension(pi: ExtensionAPI) {
524
595
  }
525
596
 
526
597
  function setMode(mode: BwrapMode) {
598
+ if (isSubagentChild) return; // mode switching is not allowed in subagents
527
599
  const config = loadConfig(localCwd);
528
600
  config.mode = mode;
529
601
  resolved = resolveBwrap(config);
@@ -550,6 +622,11 @@ export default function bwrapExtension(pi: ExtensionAPI) {
550
622
  const escalate = params.request_full_access === true;
551
623
 
552
624
  if (escalate) {
625
+ const policy = resolveEscalation({ hasUI: ctx?.hasUI ?? false, isSubagentChild });
626
+ if (policy.kind === "deny") {
627
+ throw new Error(policy.reason);
628
+ }
629
+
553
630
  if (ctx?.hasUI) {
554
631
  const reason = params.request_full_access_reason;
555
632
  const reasonText = reason
@@ -595,7 +672,9 @@ export default function bwrapExtension(pi: ExtensionAPI) {
595
672
  pi.on("session_start", (_event, ctx) => {
596
673
  const noBwrap = pi.getFlag("no-bwrap") === true;
597
674
 
598
- if (noBwrap) {
675
+ // Subagent sessions are always sandboxed read-only; --no-bwrap must not
676
+ // disable that (the flag is only honored in the parent session).
677
+ if (noBwrap && !isSubagentChild) {
599
678
  resolved = null;
600
679
  ctx.ui.notify("bwrap sandbox disabled via --no-bwrap", "warning");
601
680
  return;
@@ -612,7 +691,7 @@ export default function bwrapExtension(pi: ExtensionAPI) {
612
691
  }
613
692
 
614
693
  const config = loadConfig(ctx.cwd);
615
- resolved = resolveBwrap(config);
694
+ resolved = isSubagentChild ? resolveSubagentBwrap(config) : resolveBwrap(config);
616
695
 
617
696
  if (resolved.bwrapEnabled) {
618
697
  try {
@@ -644,8 +723,9 @@ export default function bwrapExtension(pi: ExtensionAPI) {
644
723
  const r = getResolved();
645
724
 
646
725
  return {
647
- systemPrompt:
648
- event.systemPrompt + "\n\n" + SANDBOX_PROMPT + `\n\nCurrent mode: **${r.mode}**\n`,
726
+ systemPrompt: isSubagentChild
727
+ ? event.systemPrompt + "\n\n" + SUBAGENT_SANDBOX_PROMPT
728
+ : event.systemPrompt + "\n\n" + SANDBOX_PROMPT + `\n\nCurrent mode: **${r.mode}**\n`,
649
729
  };
650
730
  });
651
731
 
@@ -114,7 +114,7 @@ export function runGh(
114
114
 
115
115
  proc.on("close", (code) => {
116
116
  if (timeoutId) clearTimeout(timeoutId);
117
- if (ctx.signal && onAbort) {
117
+ if (onAbort && ctx.signal) {
118
118
  ctx.signal.removeEventListener("abort", onAbort);
119
119
  }
120
120
  resolve({
@@ -133,7 +133,7 @@ export function runGh(
133
133
 
134
134
  proc.on("error", () => {
135
135
  if (timeoutId) clearTimeout(timeoutId);
136
- if (ctx.signal && onAbort) {
136
+ if (onAbort && ctx.signal) {
137
137
  ctx.signal.removeEventListener("abort", onAbort);
138
138
  }
139
139
  resolve({ stdout, stderr, code: 1, killed, combined: combined.join(""), reason: killReason });
@@ -199,7 +199,7 @@ const BlockAnchorReplacer: Replacer = function* (content, find) {
199
199
  bestMatch = candidate;
200
200
  }
201
201
  }
202
- if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
202
+ if (bestMatch && maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD) {
203
203
  const { startLine, endLine } = bestMatch;
204
204
  let matchStartIndex = 0;
205
205
  for (let k = 0; k < startLine; k++) {
@@ -27,6 +27,13 @@ import { normalizeForEdit, replace } from "./opencode-edit-engine.js";
27
27
  const WRITE_TOOLS = new Set(["write", "edit"]);
28
28
  const ALWAYS_ALLOW = ["/tmp"];
29
29
 
30
+ /**
31
+ * pi-subagents sets PI_SUBAGENT_CHILD=1 in every spawned child session.
32
+ * Subagents are headless, so there is no approval path: writes outside the
33
+ * workspace are rejected outright instead of asking for approval.
34
+ */
35
+ const isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1";
36
+
30
37
  /** Maximum lines of the diff preview shown in the approval dialog. */
31
38
  const MAX_PREVIEW_LINES = 100;
32
39
 
@@ -173,15 +180,48 @@ export async function buildDiffPreview(
173
180
  return wrapDiff(generateUnifiedPatch(basename(resolvedPath), oldContent, newContent, 2));
174
181
  }
175
182
 
183
+ export interface WriteGuardDecision {
184
+ block: boolean;
185
+ reason?: string;
186
+ }
187
+
188
+ /**
189
+ * Decide how to handle a write outside the workspace.
190
+ * Subagent sessions (and any other headless session) have no approval path,
191
+ * so the write is rejected outright.
192
+ */
193
+ export function decideOutsideWorkspaceWrite(
194
+ rawPath: string,
195
+ opts: { hasUI: boolean; isSubagentChild: boolean },
196
+ ): WriteGuardDecision {
197
+ if (opts.isSubagentChild) {
198
+ return {
199
+ block: true,
200
+ reason: `Path "${rawPath}" is outside the subagent workspace. Writes outside the workspace are rejected in subagent sessions; ask the parent session to apply this change.`,
201
+ };
202
+ }
203
+ if (!opts.hasUI) {
204
+ return {
205
+ block: true,
206
+ reason: `Path "${rawPath}" is outside workspace. No UI available for approval.`,
207
+ };
208
+ }
209
+ return { block: false };
210
+ }
211
+
176
212
  export default function workspaceGuard(pi: ExtensionAPI) {
177
213
  pi.on("before_agent_start", (event, ctx) => {
178
214
  const currentCwd = ctx.cwd;
179
215
  return {
180
- systemPrompt:
181
- event.systemPrompt +
182
- `\nWorkspace write protection is active. ` +
183
- `write and edit to paths inside the workspace "${currentCwd}" or /tmp are auto-allowed. ` +
184
- `Paths outside require user approval before execution.`,
216
+ systemPrompt: isSubagentChild
217
+ ? event.systemPrompt +
218
+ `\nSubagent write protection is active. ` +
219
+ `write and edit are allowed inside "${currentCwd}" and /tmp. ` +
220
+ `Writes outside the workspace are rejected without approval ask the parent session to make such changes.`
221
+ : event.systemPrompt +
222
+ `\nWorkspace write protection is active. ` +
223
+ `write and edit to paths inside the workspace "${currentCwd}" or /tmp are auto-allowed. ` +
224
+ `Paths outside require user approval before execution.`,
185
225
  };
186
226
  });
187
227
 
@@ -195,12 +235,11 @@ export default function workspaceGuard(pi: ExtensionAPI) {
195
235
 
196
236
  if (isPathAllowed(resolved, ctx.cwd)) return;
197
237
 
198
- if (!ctx.hasUI) {
199
- return {
200
- block: true,
201
- reason: `Path "${rawPath}" is outside workspace. No UI available for approval.`,
202
- };
203
- }
238
+ const decision = decideOutsideWorkspaceWrite(rawPath, {
239
+ hasUI: ctx.hasUI,
240
+ isSubagentChild,
241
+ });
242
+ if (decision.block) return decision;
204
243
 
205
244
  let choice: string | undefined;
206
245
  while (!choice) {
@@ -1,71 +0,0 @@
1
- /**
2
- * AGENTS.md User Message
3
- *
4
- * Moves project-level AGENTS.md from system prompt to user message.
5
- * Global ~/.pi/agent/AGENTS.md stays in system prompt.
6
- *
7
- * Requires --no-context-files to disable pi's default context file loading.
8
- *
9
- * Usage:
10
- * pi -e ./agents-md-user-message --no-context-files
11
- */
12
-
13
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
- import { getAgentDir, loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
15
-
16
- export default function agentsMdUserMessage(pi: ExtensionAPI) {
17
- let messageInjected = false;
18
-
19
- pi.on("before_agent_start", (event) => {
20
- const contextFiles = event.systemPromptOptions.contextFiles ?? [];
21
- if (contextFiles.length > 0) return; // pi already handled them
22
-
23
- // --no-context-files was used, we handle everything
24
- const files = loadProjectContextFiles({
25
- cwd: event.systemPromptOptions.cwd,
26
- agentDir: getAgentDir(),
27
- });
28
- if (files.length === 0) return;
29
-
30
- const agentDir = getAgentDir();
31
- const globalFiles = files.filter((f) => f.path.startsWith(agentDir));
32
- const projectFiles = files.filter((f) => !f.path.startsWith(agentDir));
33
-
34
- if (projectFiles.length === 0) return;
35
-
36
- let systemPrompt = event.systemPrompt;
37
- if (globalFiles.length > 0) {
38
- let block = "\n\n<project_context>\n\nProject-specific instructions and guidelines:\n\n";
39
- for (const { path, content } of globalFiles) {
40
- block += `<project_instructions path="${path}">\n${content}\n</project_instructions>\n\n`;
41
- }
42
- block += "</project_context>\n";
43
- systemPrompt += block;
44
- }
45
-
46
- if (!messageInjected) {
47
- messageInjected = true;
48
-
49
- const content = projectFiles
50
- .map(
51
- (f) => `<project_instructions path="${f.path}">\n${f.content}\n</project_instructions>`,
52
- )
53
- .join("\n\n");
54
-
55
- return {
56
- systemPrompt,
57
- message: {
58
- customType: "agents-md-user",
59
- content,
60
- display: true,
61
- },
62
- };
63
- }
64
-
65
- return { systemPrompt };
66
- });
67
-
68
- pi.on("session_shutdown", () => {
69
- messageInjected = false;
70
- });
71
- }