@gotgenes/pi-permission-system 5.17.0 → 5.18.1

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,33 @@ 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
+ ## [5.18.1](https://github.com/gotgenes/pi-permission-system/compare/v5.18.0...v5.18.1) (2026-05-15)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * plan Pi GitHub Tools extension ([#153](https://github.com/gotgenes/pi-permission-system/issues/153)) ([6f8566f](https://github.com/gotgenes/pi-permission-system/commit/6f8566feba22981e3f726aae8544bab53dce8a8a))
14
+ * **retro:** add retro notes for issue [#145](https://github.com/gotgenes/pi-permission-system/issues/145) ([70ff363](https://github.com/gotgenes/pi-permission-system/commit/70ff36369a902f82d78e277ba9fa7948bb62d82c))
15
+ * update /ship-issue to use pi-github-tools ([#153](https://github.com/gotgenes/pi-permission-system/issues/153)) ([7a4de21](https://github.com/gotgenes/pi-permission-system/commit/7a4de21ee16f7a1fff3ef8cf6e2b3a0516183ab5))
16
+ * update docs and pattern-suggest for path surface ([6defcdb](https://github.com/gotgenes/pi-permission-system/commit/6defcdb5430296c82da6eefc1980ab109dedc202))
17
+
18
+ ## [5.18.0](https://github.com/gotgenes/pi-permission-system/compare/v5.17.0...v5.18.0) (2026-05-14)
19
+
20
+
21
+ ### Features
22
+
23
+ * add package.json exports field for cross-extension import ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([1091de5](https://github.com/gotgenes/pi-permission-system/commit/1091de5eb673050c3b83448ee69cffb876c407d9))
24
+ * add Symbol.for()-backed service accessor module ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([6a7ddab](https://github.com/gotgenes/pi-permission-system/commit/6a7ddab6e3e58ca0f93807255e9e48716d96ca24))
25
+ * publish permissions service on startup, clear on shutdown ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([97bea7b](https://github.com/gotgenes/pi-permission-system/commit/97bea7bec043de4f6abb823846cef5c04a069517))
26
+
27
+
28
+ ### Documentation
29
+
30
+ * deprecate permissions:rpc:check types in favor of service accessor ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([a64b1b9](https://github.com/gotgenes/pi-permission-system/commit/a64b1b91f141e1a98b576433280ad09d95ec3011))
31
+ * document service accessor and deprecate RPC check ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([931a14e](https://github.com/gotgenes/pi-permission-system/commit/931a14efec1ef9bc53075f8974bfd1eeff7e0749))
32
+ * plan Symbol.for()-backed service accessor ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([d9448bc](https://github.com/gotgenes/pi-permission-system/commit/d9448bc8c9ee71714599a18f03cf516a5ffca2cb))
33
+ * **retro:** add retro notes for issue [#148](https://github.com/gotgenes/pi-permission-system/issues/148) ([84e0262](https://github.com/gotgenes/pi-permission-system/commit/84e026264e292357c18c0333b1d1bd561f70149b))
34
+
8
35
  ## [5.17.0](https://github.com/gotgenes/pi-permission-system/compare/v5.16.0...v5.17.0) (2026-05-14)
9
36
 
10
37
 
package/README.md CHANGED
@@ -17,6 +17,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
17
17
  - **Enforces allow / ask / deny** at tool-call time with UI confirmation dialogs
18
18
  - **Controls bash commands** with wildcard pattern matching (`git *: ask`, `rm -rf *: deny`)
19
19
  - **Gates MCP and skill access** at server, tool, and skill-name granularity
20
+ - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once
20
21
  - **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
21
22
  - **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
22
23
 
@@ -91,7 +92,7 @@ For the full reference — all surfaces, runtime knobs, per-agent overrides, mer
91
92
  |---|---|
92
93
  |[docs/configuration.md](docs/configuration.md)|Full policy reference, runtime knobs, per-agent overrides, recipes|
93
94
  |[docs/session-approvals.md](docs/session-approvals.md)|Session-scoped rules, pattern suggestions, bash arity table|
94
- |[docs/event-api.md](docs/event-api.md)|Event bus integration, decision broadcasts, RPC check/prompt|
95
+ |[docs/cross-extension-api.md](docs/cross-extension-api.md)|Cross-extension service accessor, event bus integration, decision broadcasts|
95
96
  |[docs/subagent-integration.md](docs/subagent-integration.md)|Permission forwarding, coexistence with subagent extensions|
96
97
  |[docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md)|Convention guide for subagent extension authors|
97
98
  |[docs/opencode-compatibility.md](docs/opencode-compatibility.md)|OpenCode compatibility — shared concepts, divergences, porting guide|
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.17.0",
3
+ "version": "5.18.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
+ "exports": {
7
+ ".": "./src/service.ts"
8
+ },
6
9
  "files": [
7
10
  "src",
8
11
  "tests",
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  PermissionGateHandler,
9
9
  SessionLifecycleHandler,
10
10
  } from "./handlers";
11
+ import { buildInputForSurface } from "./input-normalizer";
11
12
  import { requestPermissionDecisionFromUi } from "./permission-dialog";
12
13
  import { registerPermissionRpcHandlers } from "./permission-event-rpc";
13
14
  import { emitReadyEvent } from "./permission-events";
@@ -19,6 +20,11 @@ import {
19
20
  refreshExtensionConfig,
20
21
  saveExtensionConfig,
21
22
  } from "./runtime";
23
+ import type { PermissionsService } from "./service";
24
+ import {
25
+ publishPermissionsService,
26
+ unpublishPermissionsService,
27
+ } from "./service";
22
28
  import { createSessionLogger } from "./session-logger";
23
29
  import { isSubagentExecutionContext } from "./subagent-context";
24
30
  import {
@@ -91,6 +97,20 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
91
97
  writeReviewLog: runtime.writeReviewLog.bind(runtime),
92
98
  });
93
99
 
100
+ const permissionsService: PermissionsService = {
101
+ checkPermission(surface, value, agentName) {
102
+ const input = buildInputForSurface(surface, value);
103
+ const sessionRules = runtime.sessionRules.getRuleset();
104
+ return runtime.permissionManager.checkPermission(
105
+ surface,
106
+ input,
107
+ agentName,
108
+ sessionRules,
109
+ );
110
+ },
111
+ };
112
+ publishPermissionsService(permissionsService);
113
+
94
114
  emitReadyEvent(pi.events);
95
115
 
96
116
  const toolRegistry = {
@@ -101,6 +121,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
101
121
  const lifecycle = new SessionLifecycleHandler(session, () => {
102
122
  rpcHandles.unsubCheck();
103
123
  rpcHandles.unsubPrompt();
124
+ unpublishPermissionsService();
104
125
  });
105
126
  const agentPrep = new AgentPrepHandler(session, toolRegistry);
106
127
  const gates = new PermissionGateHandler(session, pi.events, toolRegistry);
@@ -2,6 +2,34 @@ import { toRecord } from "./common";
2
2
  import { createMcpPermissionTargets } from "./mcp-targets";
3
3
  import { getPathBearingToolPath, PATH_BEARING_TOOLS } from "./path-utils";
4
4
 
5
+ /**
6
+ * Construct a surface-appropriate input object from a raw value string.
7
+ *
8
+ * This is the inverse of `normalizeInput()` — it builds the minimal input
9
+ * object that `PermissionManager.checkPermission()` expects for a given
10
+ * surface, from a single string value.
11
+ *
12
+ * Used by the event-bus RPC handler and the `Symbol.for()` service accessor
13
+ * so external callers can query policy with `(surface, value)` instead of
14
+ * constructing a full tool-call input payload.
15
+ *
16
+ * Note: MCP inputs are complex (server name + tool name derivation). Callers
17
+ * providing an MCP surface receive a best-effort policy evaluation using the
18
+ * value as a pre-qualified target string. Pass the fully-qualified target
19
+ * (e.g. "exa:search" or "exa") directly.
20
+ */
21
+ export function buildInputForSurface(
22
+ surface: string,
23
+ value: string | undefined,
24
+ ): unknown {
25
+ const v = value ?? "";
26
+ if (surface === "bash") return { command: v };
27
+ if (surface === "skill") return { name: v };
28
+ if (surface === "external_directory") return { path: v };
29
+ // MCP and tool surfaces: normalizeInput handles them from the surface alone.
30
+ return {};
31
+ }
32
+
5
33
  /**
6
34
  * Surface-normalized representation of a tool invocation used by
7
35
  * `checkPermission()` to feed a single `evaluateFirst()` call.
@@ -69,6 +69,8 @@ function buildLabel(pattern: string, surface: string): string {
69
69
  return `Yes, allow skill "${pattern}" for this session`;
70
70
  case "external_directory":
71
71
  return `Yes, allow access to external directory "${pattern}" for this session`;
72
+ case "path":
73
+ return `Yes, allow path "${pattern}" for this session`;
72
74
  default:
73
75
  // Path-bearing tools with a specific path pattern show the pattern.
74
76
  if (PATH_BEARING_TOOLS.has(surface) && pattern !== "*") {
@@ -104,6 +106,9 @@ export function suggestSessionPattern(
104
106
  case "external_directory":
105
107
  pattern = deriveApprovalPattern(value);
106
108
  break;
109
+ case "path":
110
+ pattern = deriveApprovalPattern(value);
111
+ break;
107
112
  default:
108
113
  // Path-bearing tools: derive a directory-scoped pattern from the path.
109
114
  if (PATH_BEARING_TOOLS.has(surface) && value !== "*") {
@@ -6,6 +6,7 @@
6
6
  * permission prompts without importing this package.
7
7
  */
8
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { buildInputForSurface } from "./input-normalizer";
9
10
  import type {
10
11
  PermissionPromptDecision,
11
12
  RequestPermissionOptions,
@@ -79,26 +80,6 @@ function errorReply(error: string): PermissionsRpcReply {
79
80
  };
80
81
  }
81
82
 
82
- /**
83
- * Construct a surface-appropriate input object from a raw value string.
84
- *
85
- * Note: MCP inputs are complex (server name + tool name derivation). Callers
86
- * providing an MCP surface receive a best-effort policy evaluation using the
87
- * value as a pre-qualified target string. Pass the fully-qualified target
88
- * (e.g. "exa:search" or "exa") directly.
89
- */
90
- function buildInputForSurface(
91
- surface: string,
92
- value: string | undefined,
93
- ): unknown {
94
- const v = value ?? "";
95
- if (surface === "bash") return { command: v };
96
- if (surface === "skill") return { name: v };
97
- if (surface === "external_directory") return { path: v };
98
- // MCP and tool surfaces: normalizeInput handles them from the surface alone.
99
- return {};
100
- }
101
-
102
83
  // ── RPC handler: permissions:rpc:check ────────────────────────────────────
103
84
 
104
85
  function handleCheckRpc(
@@ -30,7 +30,19 @@ export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
30
30
  /** Emitted after every permission gate resolution. */
31
31
  export const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
32
32
 
33
- /** RPC request channel — query the permission policy (no prompting). */
33
+ /**
34
+ * RPC request channel — query the permission policy (no prompting).
35
+ *
36
+ * @deprecated Use the `Symbol.for()`-backed service accessor instead:
37
+ * ```typescript
38
+ * const { getPermissionsService } = await import("@gotgenes/pi-permission-system");
39
+ * const service = getPermissionsService();
40
+ * if (service) {
41
+ * const result = service.checkPermission("bash", "git push");
42
+ * }
43
+ * ```
44
+ * The event-bus RPC remains available as a zero-dependency fallback.
45
+ */
34
46
  export const PERMISSIONS_RPC_CHECK_CHANNEL = "permissions:rpc:check";
35
47
 
36
48
  /** RPC request channel — forward a permission prompt to the parent UI. */
@@ -88,7 +100,12 @@ export interface PermissionDecisionEvent {
88
100
 
89
101
  // ── permissions:rpc:check ──────────────────────────────────────────────────
90
102
 
91
- /** Request payload for `permissions:rpc:check`. */
103
+ /**
104
+ * Request payload for `permissions:rpc:check`.
105
+ *
106
+ * @deprecated Prefer `getPermissionsService().checkPermission()` from the
107
+ * service accessor module. See `PERMISSIONS_RPC_CHECK_CHANNEL` for details.
108
+ */
92
109
  export interface PermissionsCheckRequest {
93
110
  requestId: string;
94
111
  /** Permission surface to evaluate. */
@@ -99,7 +116,12 @@ export interface PermissionsCheckRequest {
99
116
  agentName?: string;
100
117
  }
101
118
 
102
- /** Data field in a successful `permissions:rpc:check` reply. */
119
+ /**
120
+ * Data field in a successful `permissions:rpc:check` reply.
121
+ *
122
+ * @deprecated Prefer `getPermissionsService().checkPermission()` from the
123
+ * service accessor module. See `PERMISSIONS_RPC_CHECK_CHANNEL` for details.
124
+ */
103
125
  export interface PermissionsCheckReplyData {
104
126
  result: "allow" | "deny" | "ask";
105
127
  matchedPattern: string | null;
package/src/service.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Cross-extension service accessor backed by `Symbol.for()` on `globalThis`.
3
+ *
4
+ * `Symbol.for()` is process-global by spec, so it survives jiti's per-extension
5
+ * module isolation (`moduleCache: false`). A consumer doing
6
+ * `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but
7
+ * `getPermissionsService()` reads from the same `globalThis` slot the provider
8
+ * wrote to — enabling direct, synchronous, type-safe function calls.
9
+ *
10
+ * Best practice: call `getPermissionsService()` per use rather than caching the
11
+ * reference — this ensures resilience across `/reload` and load-order edge cases.
12
+ */
13
+
14
+ import type { PermissionCheckResult, PermissionState } from "./types";
15
+
16
+ export type { PermissionCheckResult, PermissionState };
17
+
18
+ /** Process-global key for the service slot. */
19
+ const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
20
+
21
+ /**
22
+ * Public interface exposed to other extensions via `getPermissionsService()`.
23
+ *
24
+ * Mirrors the simplified RPC signature — surface + optional value + optional
25
+ * agent name — and delegates to `PermissionManager.checkPermission()` with
26
+ * current session rules internally.
27
+ */
28
+ export interface PermissionsService {
29
+ /**
30
+ * Query the permission policy for a surface and value.
31
+ *
32
+ * @param surface - Permission surface: "bash", "read", "mcp", "skill",
33
+ * "external_directory", etc.
34
+ * @param value - The value to evaluate: command string, tool name, skill
35
+ * name, or path. Omit or pass `undefined` for a
36
+ * surface-level query.
37
+ * @param agentName - Optional agent name for per-agent policy resolution.
38
+ * @returns Full check result including state, matched pattern, and origin.
39
+ */
40
+ checkPermission(
41
+ surface: string,
42
+ value?: string,
43
+ agentName?: string,
44
+ ): PermissionCheckResult;
45
+ }
46
+
47
+ /**
48
+ * Store a `PermissionsService` on `globalThis` so other extensions can
49
+ * retrieve it via `getPermissionsService()`.
50
+ *
51
+ * Overwrites any previously published service — safe for `/reload`.
52
+ */
53
+ export function publishPermissionsService(service: PermissionsService): void {
54
+ (globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
55
+ }
56
+
57
+ /**
58
+ * Retrieve the published `PermissionsService`, or `undefined` if the
59
+ * permission-system extension has not loaded (or has been unloaded).
60
+ */
61
+ export function getPermissionsService(): PermissionsService | undefined {
62
+ return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
63
+ | PermissionsService
64
+ | undefined;
65
+ }
66
+
67
+ /**
68
+ * Remove the service from `globalThis`.
69
+ *
70
+ * Called during `session_shutdown` to avoid stale references after the
71
+ * extension is torn down.
72
+ */
73
+ export function unpublishPermissionsService(): void {
74
+ delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
75
+ }
@@ -121,6 +121,21 @@ describe("suggestSessionPattern", () => {
121
121
  });
122
122
  });
123
123
 
124
+ describe("path surface", () => {
125
+ it("returns directory-scoped pattern for a file path", () => {
126
+ const result = suggestSessionPattern("path", "src/.env");
127
+ expect(result).toMatchObject({
128
+ surface: "path",
129
+ pattern: "src/*",
130
+ });
131
+ });
132
+
133
+ it("label includes path pattern", () => {
134
+ const result = suggestSessionPattern("path", "src/.env");
135
+ expect(result.label).toBe('Yes, allow path "src/*" for this session');
136
+ });
137
+ });
138
+
124
139
  describe("path-bearing tool surfaces", () => {
125
140
  it("returns directory-scoped pattern for read with a file path", () => {
126
141
  const result = suggestSessionPattern("read", "/outside/project/file.ts");
@@ -0,0 +1,144 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { buildInputForSurface } from "../src/input-normalizer";
3
+ import type { PermissionsService } from "../src/service";
4
+ import {
5
+ getPermissionsService,
6
+ publishPermissionsService,
7
+ unpublishPermissionsService,
8
+ } from "../src/service";
9
+ import type { PermissionCheckResult } from "../src/types";
10
+
11
+ // ── helpers ────────────────────────────────────────────────────────────────
12
+
13
+ function makeService(
14
+ overrides: Partial<PermissionsService> = {},
15
+ ): PermissionsService {
16
+ return {
17
+ checkPermission: vi.fn(),
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ // ── globalThis accessor ────────────────────────────────────────────────────
23
+
24
+ describe("globalThis accessor", () => {
25
+ afterEach(() => {
26
+ unpublishPermissionsService();
27
+ });
28
+
29
+ it("returns undefined when nothing has been published", () => {
30
+ expect(getPermissionsService()).toBeUndefined();
31
+ });
32
+
33
+ it("returns the published service", () => {
34
+ const service = makeService();
35
+ publishPermissionsService(service);
36
+ expect(getPermissionsService()).toBe(service);
37
+ });
38
+
39
+ it("overwrites a previously published service", () => {
40
+ const first = makeService();
41
+ const second = makeService();
42
+ publishPermissionsService(first);
43
+ publishPermissionsService(second);
44
+ expect(getPermissionsService()).toBe(second);
45
+ });
46
+
47
+ it("returns undefined after unpublish", () => {
48
+ const service = makeService();
49
+ publishPermissionsService(service);
50
+ unpublishPermissionsService();
51
+ expect(getPermissionsService()).toBeUndefined();
52
+ });
53
+
54
+ it("unpublish is safe to call when nothing was published", () => {
55
+ expect(() => unpublishPermissionsService()).not.toThrow();
56
+ expect(getPermissionsService()).toBeUndefined();
57
+ });
58
+ });
59
+
60
+ // ── service adapter delegation ─────────────────────────────────────────────
61
+
62
+ describe("service adapter delegation", () => {
63
+ afterEach(() => {
64
+ unpublishPermissionsService();
65
+ });
66
+
67
+ const fakeResult: PermissionCheckResult = {
68
+ toolName: "bash",
69
+ state: "allow",
70
+ matchedPattern: "git *",
71
+ source: "bash",
72
+ origin: "global",
73
+ };
74
+
75
+ it("checkPermission delegates surface and value through buildInputForSurface", () => {
76
+ const checkPermission = vi.fn().mockReturnValue(fakeResult);
77
+ const sessionRules = [
78
+ {
79
+ surface: "bash",
80
+ pattern: "*",
81
+ action: "allow" as const,
82
+ layer: "session" as const,
83
+ origin: "session" as const,
84
+ },
85
+ ];
86
+
87
+ // Build the adapter the same way index.ts will
88
+ const service: PermissionsService = {
89
+ checkPermission(surface, value, agentName) {
90
+ const input = buildInputForSurface(surface, value);
91
+ return checkPermission(surface, input, agentName, sessionRules);
92
+ },
93
+ };
94
+
95
+ publishPermissionsService(service);
96
+ const retrieved = getPermissionsService()!;
97
+ const result = retrieved.checkPermission("bash", "git push");
98
+
99
+ expect(result).toBe(fakeResult);
100
+ expect(checkPermission).toHaveBeenCalledWith(
101
+ "bash",
102
+ { command: "git push" },
103
+ undefined,
104
+ sessionRules,
105
+ );
106
+ });
107
+
108
+ it("checkPermission passes agentName through", () => {
109
+ const checkPermission = vi.fn().mockReturnValue(fakeResult);
110
+
111
+ const service: PermissionsService = {
112
+ checkPermission(surface, value, agentName) {
113
+ const input = buildInputForSurface(surface, value);
114
+ return checkPermission(surface, input, agentName, []);
115
+ },
116
+ };
117
+
118
+ publishPermissionsService(service);
119
+ getPermissionsService()!.checkPermission("skill", "my-skill", "Explore");
120
+
121
+ expect(checkPermission).toHaveBeenCalledWith(
122
+ "skill",
123
+ { name: "my-skill" },
124
+ "Explore",
125
+ [],
126
+ );
127
+ });
128
+
129
+ it("checkPermission uses empty object for unknown surfaces", () => {
130
+ const checkPermission = vi.fn().mockReturnValue(fakeResult);
131
+
132
+ const service: PermissionsService = {
133
+ checkPermission(surface, value, agentName) {
134
+ const input = buildInputForSurface(surface, value);
135
+ return checkPermission(surface, input, agentName, []);
136
+ },
137
+ };
138
+
139
+ publishPermissionsService(service);
140
+ getPermissionsService()!.checkPermission("read", "/tmp/file");
141
+
142
+ expect(checkPermission).toHaveBeenCalledWith("read", {}, undefined, []);
143
+ });
144
+ });