@gotgenes/pi-permission-system 13.1.0 → 13.1.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [13.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.1.1...pi-permission-system-v13.1.2) (2026-06-16)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * **pi-permission-system:** clarify external_directory surface in README ([#413](https://github.com/gotgenes/pi-packages/issues/413)) ([c09929b](https://github.com/gotgenes/pi-packages/commit/c09929be209050c1f85e9e01dbb231f99e940f82))
14
+ * **pi-permission-system:** document external_directory allow-list for outside-CWD caches ([#413](https://github.com/gotgenes/pi-packages/issues/413)) ([86b1d87](https://github.com/gotgenes/pi-packages/commit/86b1d87ff92e63c26d3f81ddb41aad7aab085074))
15
+ * **pi-permission-system:** show external_directory allow-list in example config and schema ([#413](https://github.com/gotgenes/pi-packages/issues/413)) ([8178a7e](https://github.com/gotgenes/pi-packages/commit/8178a7ebc3237e188b8e492d5c3a8bfca9b197aa))
16
+
17
+ ## [13.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.1.0...pi-permission-system-v13.1.1) (2026-06-13)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * preserve forwarded-permission responses dir while requests pending ([#398](https://github.com/gotgenes/pi-packages/issues/398)) ([9914e70](https://github.com/gotgenes/pi-packages/commit/9914e7093c5addee80bd39f2ff99211991b4a238))
23
+ * recreate forwarded-permission responses dir before write ([#398](https://github.com/gotgenes/pi-packages/issues/398)) ([67d34ef](https://github.com/gotgenes/pi-packages/commit/67d34efb33dbda28f363a004d0945c2a4aacea29))
24
+
8
25
  ## [13.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.0.0...pi-permission-system-v13.1.0) (2026-06-13)
9
26
 
10
27
 
package/README.md CHANGED
@@ -72,7 +72,25 @@ For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patt
72
72
  This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
73
73
  When Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`.
74
74
 
75
+ The `external_directory` surface is the CWD-boundary gate: it decides whether reaching **outside** the working tree is allowed, and accepts a pattern map so you can allow specific outside-CWD directories without opening up all external access.
76
+ This is the right surface for silencing repeated prompts on a local cache like `~/.cargo/registry` — allow it here, not on `path`:
77
+
78
+ ```jsonc
79
+ {
80
+ "permission": {
81
+ "external_directory": {
82
+ "*": "ask",
83
+ "~/.cargo/registry/*": "allow"
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ The trailing `*` is greedy and crosses subdirectory boundaries, so it allows every file beneath the directory; a bare `~/.cargo/registry` matches only the directory entry itself.
90
+
75
91
  Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `external_directory` (CWD boundary) → per-tool patterns → `bash` command patterns.
92
+ Because `ask` is more restrictive than `allow`, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories on `external_directory`.
93
+ See [docs/configuration.md](docs/configuration.md) for the full recipe.
76
94
 
77
95
  ## Configuration
78
96
 
@@ -32,7 +32,8 @@
32
32
  "skill": { "*": "ask" },
33
33
  "external_directory": {
34
34
  "*": "ask",
35
- "~/development/*": "allow"
35
+ "~/development/*": "allow",
36
+ "~/.cargo/registry/*": "allow"
36
37
  }
37
38
  }
38
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "13.1.0",
3
+ "version": "13.1.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "permission": {
55
55
  "description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
56
- "markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, `path`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `\"read\": { \"*\": \"allow\", \"*.env\": \"deny\" }` allows reads but denies `.env` files.\n\nWhen Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. Bash path tokens use the effective directory after literal `cd` commands for this matching; non-literal `cd \"$DIR\"` style commands remain conservative.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor). A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all path-aware tools at once.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
56
+ "markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, `path`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `\"read\": { \"*\": \"allow\", \"*.env\": \"deny\" }` allows reads but denies `.env` files.\n\nWhen Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. Bash path tokens use the effective directory after literal `cd` commands for this matching; non-literal `cd \"$DIR\"` style commands remain conservative.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor). A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all path-aware tools at once.\n\nThe `external_directory` surface gates access **outside** the working directory. Give it a pattern map to allow specific outside-CWD directories without opening all external access — e.g. `\"external_directory\": { \"*\": \"ask\", \"~/.cargo/registry/*\": \"allow\" }` to silence repeated prompts on a local cache. The trailing `*` is greedy and crosses subdirectory boundaries; a bare `~/.cargo/registry` matches only the directory entry itself. Because layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories here, not on `path`.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
57
57
  "type": "object",
58
58
  "propertyNames": {
59
59
  "description": "A surface name or the universal fallback key '*'.",
@@ -92,7 +92,7 @@
92
92
  },
93
93
  "mcp": { "*": "ask", "mcp_status": "allow", "exa:*": "allow" },
94
94
  "skill": { "*": "ask", "librarian": "allow" },
95
- "external_directory": "ask"
95
+ "external_directory": { "*": "ask", "~/.cargo/registry/*": "allow" }
96
96
  }
97
97
  ]
98
98
  }
@@ -175,13 +175,20 @@ export function getExistingPermissionForwardingLocation(
175
175
  return existsSync(location.requestsDir) ? location : null;
176
176
  }
177
177
 
178
+ /**
179
+ * Attempt to remove a directory if it is empty.
180
+ *
181
+ * Returns `true` when the directory is absent after the call (successfully
182
+ * removed, or never existed). Returns `false` when the directory still exists
183
+ * (non-empty, or a filesystem error prevented removal).
184
+ */
178
185
  export function tryRemoveDirectoryIfEmpty(
179
186
  logger: DebugReviewLogger | null,
180
187
  path: string,
181
188
  description: string,
182
- ): void {
189
+ ): boolean {
183
190
  if (!existsSync(path)) {
184
- return;
191
+ return true;
185
192
  }
186
193
 
187
194
  let entries: string[];
@@ -193,18 +200,22 @@ export function tryRemoveDirectoryIfEmpty(
193
200
  `Failed to inspect ${description} directory '${path}'`,
194
201
  error,
195
202
  );
196
- return;
203
+ return false;
197
204
  }
198
205
 
199
206
  if (entries.length > 0) {
200
- return;
207
+ return false;
201
208
  }
202
209
 
203
210
  try {
204
211
  rmdirSync(path);
212
+ return true;
205
213
  } catch (error) {
206
- if (isErrnoCode(error, "ENOENT") || isErrnoCode(error, "ENOTEMPTY")) {
207
- return;
214
+ if (isErrnoCode(error, "ENOENT")) {
215
+ return true;
216
+ }
217
+ if (isErrnoCode(error, "ENOTEMPTY")) {
218
+ return false;
208
219
  }
209
220
 
210
221
  logPermissionForwardingWarning(
@@ -212,6 +223,7 @@ export function tryRemoveDirectoryIfEmpty(
212
223
  `Failed to remove empty ${description} directory '${path}'`,
213
224
  error,
214
225
  );
226
+ return false;
215
227
  }
216
228
  }
217
229
 
@@ -219,16 +231,20 @@ export function cleanupPermissionForwardingLocationIfEmpty(
219
231
  logger: DebugReviewLogger | null,
220
232
  location: PermissionForwardingLocation,
221
233
  ): void {
222
- tryRemoveDirectoryIfEmpty(
234
+ // Only remove responses/ when requests/ is already gone — removing responses/
235
+ // while a request is still pending causes the ENOENT write loop (issue #398).
236
+ const requestsGone = tryRemoveDirectoryIfEmpty(
223
237
  logger,
224
238
  location.requestsDir,
225
239
  `${location.label} permission forwarding requests`,
226
240
  );
227
- tryRemoveDirectoryIfEmpty(
228
- logger,
229
- location.responsesDir,
230
- `${location.label} permission forwarding responses`,
231
- );
241
+ if (requestsGone) {
242
+ tryRemoveDirectoryIfEmpty(
243
+ logger,
244
+ location.responsesDir,
245
+ `${location.label} permission forwarding responses`,
246
+ );
247
+ }
232
248
  tryRemoveDirectoryIfEmpty(
233
249
  logger,
234
250
  location.sessionRootDir,
@@ -35,6 +35,7 @@ import { shouldAutoApprovePermissionState } from "#src/yolo-mode";
35
35
 
36
36
  import {
37
37
  cleanupPermissionForwardingLocationIfEmpty,
38
+ ensureDirectoryExists,
38
39
  ensurePermissionForwardingLocation,
39
40
  getExistingPermissionForwardingLocation,
40
41
  listRequestFiles,
@@ -255,6 +256,20 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
255
256
  return;
256
257
  }
257
258
 
259
+ // Defensively recreate responses/ before writing any response — a
260
+ // concurrent cleanup pass may have removed it between the requestsDir
261
+ // existence check above and the write inside processSingleForwardedRequest
262
+ // (the ENOENT write loop reported in issue #398).
263
+ if (
264
+ !ensureDirectoryExists(
265
+ this.logger,
266
+ location.responsesDir,
267
+ "permission forwarding responses",
268
+ )
269
+ ) {
270
+ return;
271
+ }
272
+
258
273
  for (const fileName of requestFiles) {
259
274
  const requestPath = join(location.requestsDir, fileName);
260
275
  const request = readForwardedPermissionRequest(this.logger, requestPath);
@@ -1,11 +1,24 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
12
 
3
13
  import {
14
+ cleanupPermissionForwardingLocationIfEmpty,
4
15
  formatUnknownErrorMessage,
5
16
  isErrnoCode,
6
17
  logPermissionForwardingError,
7
18
  logPermissionForwardingWarning,
19
+ tryRemoveDirectoryIfEmpty,
8
20
  } from "#src/forwarded-permissions/io";
21
+ import { createPermissionForwardingLocation } from "#src/permission-forwarding";
9
22
  import type { DebugReviewLogger } from "#src/session-logger";
10
23
 
11
24
  // ── helpers ────────────────────────────────────────────────────────────────
@@ -130,3 +143,109 @@ describe("logPermissionForwardingError", () => {
130
143
  expect(() => logPermissionForwardingError(null, "ignored")).not.toThrow();
131
144
  });
132
145
  });
146
+
147
+ // ── tryRemoveDirectoryIfEmpty ──────────────────────────────────────────────
148
+
149
+ describe("tryRemoveDirectoryIfEmpty", () => {
150
+ let root: string;
151
+
152
+ afterEach(() => {
153
+ rmSync(root, { recursive: true, force: true });
154
+ });
155
+
156
+ it("returns true when the directory does not exist", () => {
157
+ root = mkdtempSync(join(tmpdir(), "io-test-"));
158
+ const absent = join(root, "nonexistent");
159
+ expect(tryRemoveDirectoryIfEmpty(null, absent, "test")).toBe(true);
160
+ });
161
+
162
+ it("returns true and removes an empty directory", () => {
163
+ root = mkdtempSync(join(tmpdir(), "io-test-"));
164
+ const dir = join(root, "empty");
165
+ mkdirSync(dir);
166
+ expect(tryRemoveDirectoryIfEmpty(null, dir, "test")).toBe(true);
167
+ expect(existsSync(dir)).toBe(false);
168
+ });
169
+
170
+ it("returns false and leaves a non-empty directory in place", () => {
171
+ root = mkdtempSync(join(tmpdir(), "io-test-"));
172
+ const dir = join(root, "nonempty");
173
+ mkdirSync(dir);
174
+ writeFileSync(join(dir, "file.json"), "{}", "utf-8");
175
+ expect(tryRemoveDirectoryIfEmpty(null, dir, "test")).toBe(false);
176
+ expect(existsSync(dir)).toBe(true);
177
+ });
178
+ });
179
+
180
+ // ── cleanupPermissionForwardingLocationIfEmpty ─────────────────────────────
181
+
182
+ describe("cleanupPermissionForwardingLocationIfEmpty", () => {
183
+ let root: string;
184
+
185
+ afterEach(() => {
186
+ rmSync(root, { recursive: true, force: true });
187
+ });
188
+
189
+ it("preserves responses/ when requests/ is non-empty (the concurrent-request race)", () => {
190
+ root = mkdtempSync(join(tmpdir(), "io-cleanup-"));
191
+ const forwardingDir = join(root, "forwarding");
192
+ const location = createPermissionForwardingLocation(
193
+ forwardingDir,
194
+ "parent-session",
195
+ );
196
+ // Simulate: requests/ has a pending file, responses/ is momentarily empty
197
+ mkdirSync(location.requestsDir, { recursive: true });
198
+ mkdirSync(location.responsesDir, { recursive: true });
199
+ writeFileSync(join(location.requestsDir, "req-b.json"), "{}", "utf-8");
200
+ // responses/ is empty (sibling subagent A already cleaned up its response)
201
+
202
+ cleanupPermissionForwardingLocationIfEmpty(null, location);
203
+
204
+ // requests/ is non-empty → should NOT be removed
205
+ expect(existsSync(location.requestsDir)).toBe(true);
206
+ // responses/ must survive — removing it causes the ENOENT write loop
207
+ expect(existsSync(location.responsesDir)).toBe(true);
208
+ // sessionRoot must also survive while subdirs are present
209
+ expect(existsSync(location.sessionRootDir)).toBe(true);
210
+ });
211
+
212
+ it("removes both subdirs and sessionRoot when both are empty (normal serial cleanup)", () => {
213
+ root = mkdtempSync(join(tmpdir(), "io-cleanup-"));
214
+ const forwardingDir = join(root, "forwarding");
215
+ const location = createPermissionForwardingLocation(
216
+ forwardingDir,
217
+ "parent-session",
218
+ );
219
+ mkdirSync(location.requestsDir, { recursive: true });
220
+ mkdirSync(location.responsesDir, { recursive: true });
221
+ // Both empty — normal end-of-lifecycle state
222
+
223
+ cleanupPermissionForwardingLocationIfEmpty(null, location);
224
+
225
+ expect(existsSync(location.requestsDir)).toBe(false);
226
+ expect(existsSync(location.responsesDir)).toBe(false);
227
+ expect(existsSync(location.sessionRootDir)).toBe(false);
228
+ });
229
+
230
+ it("leaves responses/ in place when it is non-empty even if requests/ is empty", () => {
231
+ root = mkdtempSync(join(tmpdir(), "io-cleanup-"));
232
+ const forwardingDir = join(root, "forwarding");
233
+ const location = createPermissionForwardingLocation(
234
+ forwardingDir,
235
+ "parent-session",
236
+ );
237
+ mkdirSync(location.requestsDir, { recursive: true });
238
+ mkdirSync(location.responsesDir, { recursive: true });
239
+ writeFileSync(join(location.responsesDir, "resp.json"), "{}", "utf-8");
240
+ // requests/ is empty, responses/ has a stale response
241
+
242
+ cleanupPermissionForwardingLocationIfEmpty(null, location);
243
+
244
+ // requests/ is empty so it gets removed
245
+ expect(existsSync(location.requestsDir)).toBe(false);
246
+ // responses/ is non-empty → survives
247
+ expect(existsSync(location.responsesDir)).toBe(true);
248
+ // sessionRoot survives because responses/ is still present
249
+ expect(existsSync(location.sessionRootDir)).toBe(true);
250
+ });
251
+ });
@@ -307,4 +307,63 @@ describe("processInbox", () => {
307
307
  rmSync(root, { recursive: true, force: true });
308
308
  }
309
309
  });
310
+
311
+ test("recreates a missing responses/ directory and still writes the response", async () => {
312
+ const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
313
+ try {
314
+ const forwardingDir = join(root, "forwarding");
315
+ const location = createPermissionForwardingLocation(
316
+ forwardingDir,
317
+ "parent-session",
318
+ );
319
+ // Simulate the race: requests/ exists with a pending file, but
320
+ // responses/ was removed by a concurrent cleanup pass.
321
+ mkdirSync(location.requestsDir, { recursive: true });
322
+ // Deliberately do NOT create location.responsesDir.
323
+ writeFileSync(
324
+ join(location.requestsDir, "req-race.json"),
325
+ JSON.stringify({
326
+ id: "req-race",
327
+ createdAt: Date.now(),
328
+ requesterSessionId: "child-session",
329
+ targetSessionId: "parent-session",
330
+ requesterAgentName: "Explore",
331
+ message: "Allow read?",
332
+ }),
333
+ "utf-8",
334
+ );
335
+
336
+ const logger = { review: vi.fn(), debug: vi.fn() };
337
+ const requestPermissionDecisionFromUi = vi
338
+ .fn()
339
+ .mockResolvedValue({ approved: true, state: "approved" as const });
340
+
341
+ const forwarder = new PermissionForwarder(
342
+ makeDeps({
343
+ forwardingDir,
344
+ logger,
345
+ requestPermissionDecisionFromUi,
346
+ }),
347
+ );
348
+
349
+ await forwarder.processInbox(
350
+ makeCtx({
351
+ hasUI: true,
352
+ sessionManager: {
353
+ getSessionId: vi.fn(() => "parent-session"),
354
+ },
355
+ }),
356
+ );
357
+
358
+ // processInbox must have recreated responses/ and written a response
359
+ // file — no permission_forwarding.error should have been logged.
360
+ expect(logger.review).not.toHaveBeenCalledWith(
361
+ "permission_forwarding.error",
362
+ expect.anything(),
363
+ );
364
+ expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
365
+ } finally {
366
+ rmSync(root, { recursive: true, force: true });
367
+ }
368
+ });
310
369
  });