@gotgenes/pi-permission-system 7.3.1 → 7.3.3

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,27 @@ 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
+ ## [7.3.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.3.2...pi-permission-system-v7.3.3) (2026-05-28)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * respect config-level allow/deny in bash external-directory gate ([#249](https://github.com/gotgenes/pi-packages/issues/249)) ([1437ff3](https://github.com/gotgenes/pi-packages/commit/1437ff3e3c0bdde93927ba9fdf9e3cf5b52e7c0c))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * plan fix for bash external-directory config-level allow bypass ([#249](https://github.com/gotgenes/pi-packages/issues/249)) ([9e09f35](https://github.com/gotgenes/pi-packages/commit/9e09f35e6cfad09b53a1b55b54fcd44af4ed6a7b))
19
+ * **retro:** add planning stage notes for issue [#249](https://github.com/gotgenes/pi-packages/issues/249) ([fe13214](https://github.com/gotgenes/pi-packages/commit/fe132144869db93bfc83c4e940abb7d3ce813d46))
20
+ * **retro:** add TDD stage notes for issue [#249](https://github.com/gotgenes/pi-packages/issues/249) ([b5d22f6](https://github.com/gotgenes/pi-packages/commit/b5d22f6d67f7d2c28ed406c99bc0458df9024713))
21
+
22
+ ## [7.3.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.3.1...pi-permission-system-v7.3.2) (2026-05-27)
23
+
24
+
25
+ ### Documentation
26
+
27
+ * replace \n with <br/> in Mermaid node labels ([3312a45](https://github.com/gotgenes/pi-packages/commit/3312a4559100cf9ae923f67819653b5a99fceb12))
28
+
8
29
  ## [7.3.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.3.0...pi-permission-system-v7.3.1) (2026-05-26)
9
30
 
10
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "7.3.1",
3
+ "version": "7.3.3",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -41,7 +41,6 @@ export function shouldExposeTool(
41
41
  */
42
42
  export class AgentPrepHandler {
43
43
  constructor(
44
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via destructuring (const { session } = this)
45
44
  private readonly session: PermissionSession,
46
45
  private readonly toolRegistry: ToolRegistry,
47
46
  ) {}
@@ -51,11 +50,10 @@ export class AgentPrepHandler {
51
50
  event: BeforeAgentStartPayload,
52
51
  ctx: ExtensionContext,
53
52
  ): Promise<BeforeAgentStartEventResult> {
54
- const { session } = this;
55
- session.activate(ctx);
56
- session.refreshConfig(ctx);
53
+ this.session.activate(ctx);
54
+ this.session.refreshConfig(ctx);
57
55
 
58
- const agentName = session.resolveAgentName(ctx, event.systemPrompt);
56
+ const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
59
57
  const allTools = this.toolRegistry.getAll();
60
58
  const allowedTools: string[] = [];
61
59
 
@@ -66,7 +64,7 @@ export class AgentPrepHandler {
66
64
  }
67
65
  if (
68
66
  shouldExposeTool(toolName, agentName, (t, a) =>
69
- session.getToolPermission(t, a),
67
+ this.session.getToolPermission(t, a),
70
68
  )
71
69
  ) {
72
70
  allowedTools.push(toolName);
@@ -74,24 +72,24 @@ export class AgentPrepHandler {
74
72
  }
75
73
 
76
74
  const activeToolsCacheKey = createActiveToolsCacheKey(allowedTools);
77
- if (session.shouldUpdateActiveTools(activeToolsCacheKey)) {
75
+ if (this.session.shouldUpdateActiveTools(activeToolsCacheKey)) {
78
76
  this.toolRegistry.setActive(allowedTools);
79
- session.commitActiveToolsCacheKey(activeToolsCacheKey);
77
+ this.session.commitActiveToolsCacheKey(activeToolsCacheKey);
80
78
  }
81
79
 
82
80
  const promptStateCacheKey = createBeforeAgentStartPromptStateKey({
83
81
  agentName,
84
82
  cwd: ctx.cwd,
85
- permissionStamp: session.getPolicyCacheStamp(agentName ?? undefined),
83
+ permissionStamp: this.session.getPolicyCacheStamp(agentName ?? undefined),
86
84
  systemPrompt: event.systemPrompt,
87
85
  allowedToolNames: allowedTools,
88
86
  });
89
87
 
90
- if (!session.shouldUpdatePromptState(promptStateCacheKey)) {
88
+ if (!this.session.shouldUpdatePromptState(promptStateCacheKey)) {
91
89
  return {};
92
90
  }
93
91
 
94
- session.commitPromptStateCacheKey(promptStateCacheKey);
92
+ this.session.commitPromptStateCacheKey(promptStateCacheKey);
95
93
 
96
94
  const toolPromptResult = sanitizeAvailableToolsSection(
97
95
  event.systemPrompt,
@@ -99,11 +97,11 @@ export class AgentPrepHandler {
99
97
  );
100
98
  const skillPromptResult = resolveSkillPromptEntries(
101
99
  toolPromptResult.prompt,
102
- session,
100
+ this.session,
103
101
  agentName,
104
102
  ctx.cwd,
105
103
  );
106
- session.setActiveSkillEntries(skillPromptResult.entries);
104
+ this.session.setActiveSkillEntries(skillPromptResult.entries);
107
105
 
108
106
  if (skillPromptResult.prompt !== event.systemPrompt) {
109
107
  return { systemPrompt: skillPromptResult.prompt };
@@ -21,7 +21,7 @@ type CheckPermissionFn = (
21
21
  * Extracts paths from a bash command and checks whether any reference
22
22
  * directories outside the working directory. Returns `null` when the gate
23
23
  * does not apply (tool is not bash, no CWD, or no external paths found).
24
- * Returns a `GateBypass` when all paths are session-covered.
24
+ * Returns a `GateBypass` when all paths are allowed (by config or session rule).
25
25
  * Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
26
26
  */
27
27
  export async function describeBashExternalDirectoryGate(
@@ -41,15 +41,26 @@ export async function describeBashExternalDirectoryGate(
41
41
  if (externalPaths.length === 0) return null;
42
42
 
43
43
  const bashSessionRules = getSessionRuleset();
44
- const uncoveredPaths = externalPaths.filter(
45
- (p) =>
46
- checkPermission(
47
- "external_directory",
48
- { path: p },
49
- tcc.agentName ?? undefined,
50
- bashSessionRules,
51
- ).source !== "session",
52
- );
44
+
45
+ // Collect paths whose resolved state is not already "allow".
46
+ // Checking state (not source) ensures config-level allow rules (source: "special")
47
+ // suppress the prompt just as session-level allow rules (source: "session") do.
48
+ const uncoveredEntries: Array<{
49
+ path: string;
50
+ check: PermissionCheckResult;
51
+ }> = [];
52
+ for (const p of externalPaths) {
53
+ const check = checkPermission(
54
+ "external_directory",
55
+ { path: p },
56
+ tcc.agentName ?? undefined,
57
+ bashSessionRules,
58
+ );
59
+ if (check.state !== "allow") {
60
+ uncoveredEntries.push({ path: p, check });
61
+ }
62
+ }
63
+ const uncoveredPaths = uncoveredEntries.map(({ path }) => path);
53
64
 
54
65
  if (uncoveredPaths.length === 0) {
55
66
  return {
@@ -69,12 +80,12 @@ export async function describeBashExternalDirectoryGate(
69
80
  };
70
81
  }
71
82
 
72
- // Get the config-level policy (no path no session check).
73
- const extCheck = checkPermission(
74
- "external_directory",
75
- {},
76
- tcc.agentName ?? undefined,
77
- );
83
+ // Use the most restrictive check among uncovered paths as the pre-check result.
84
+ // This ensures a config-level "deny" rule is not downgraded to "ask" by the
85
+ // generic "*" catch-all that the old path-less checkPermission call returned.
86
+ const worstCheck =
87
+ uncoveredEntries.find(({ check }) => check.state === "deny")?.check ??
88
+ uncoveredEntries[0].check;
78
89
 
79
90
  const bashExtMessage = formatBashExternalDirectoryAskPrompt(
80
91
  command,
@@ -120,6 +131,6 @@ export async function describeBashExternalDirectoryGate(
120
131
  surface: "external_directory",
121
132
  value: command,
122
133
  },
123
- preCheck: extCheck,
134
+ preCheck: worstCheck,
124
135
  };
125
136
  }
@@ -22,7 +22,6 @@ interface ResourcesDiscoverPayload {
22
22
  */
23
23
  export class SessionLifecycleHandler {
24
24
  constructor(
25
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via destructuring (const { session } = this)
26
25
  private readonly session: PermissionSession,
27
26
  private readonly cleanupRpc: () => void,
28
27
  ) {}
@@ -31,19 +30,18 @@ export class SessionLifecycleHandler {
31
30
  event: SessionStartPayload,
32
31
  ctx: ExtensionContext,
33
32
  ): Promise<void> {
34
- const { session } = this;
35
- session.refreshConfig(ctx);
36
- session.resetForNewSession(ctx);
37
- session.logResolvedConfigPaths();
33
+ this.session.refreshConfig(ctx);
34
+ this.session.resetForNewSession(ctx);
35
+ this.session.logResolvedConfigPaths();
38
36
 
39
- const agentName = session.resolveAgentName(ctx);
40
- const policyIssues = session.getConfigIssues(agentName ?? undefined);
37
+ const agentName = this.session.resolveAgentName(ctx);
38
+ const policyIssues = this.session.getConfigIssues(agentName ?? undefined);
41
39
  for (const issue of policyIssues) {
42
- session.logger.warn(issue);
40
+ this.session.logger.warn(issue);
43
41
  }
44
42
 
45
43
  if (event.reason === "reload") {
46
- session.logger.debug("lifecycle.reload", {
44
+ this.session.logger.debug("lifecycle.reload", {
47
45
  triggeredBy: "session_start",
48
46
  reason: event.reason,
49
47
  cwd: ctx.cwd,
@@ -57,23 +55,21 @@ export class SessionLifecycleHandler {
57
55
  return Promise.resolve();
58
56
  }
59
57
 
60
- const { session } = this;
61
- session.reload();
62
- session.logger.debug("lifecycle.reload", {
58
+ this.session.reload();
59
+ this.session.logger.debug("lifecycle.reload", {
63
60
  triggeredBy: "resources_discover",
64
61
  reason: event.reason,
65
- cwd: session.getRuntimeContext()?.cwd ?? null,
62
+ cwd: this.session.getRuntimeContext()?.cwd ?? null,
66
63
  });
67
64
  return Promise.resolve();
68
65
  }
69
66
 
70
67
  handleSessionShutdown(): Promise<void> {
71
- const { session } = this;
72
- const ctx = session.getRuntimeContext();
68
+ const ctx = this.session.getRuntimeContext();
73
69
  if (ctx) {
74
70
  ctx.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
75
71
  }
76
- session.shutdown();
72
+ this.session.shutdown();
77
73
  this.cleanupRpc();
78
74
  return Promise.resolve();
79
75
  }
@@ -47,7 +47,6 @@ interface InputPayload {
47
47
  */
48
48
  export class PermissionGateHandler {
49
49
  constructor(
50
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via destructuring (const { session } = this)
51
50
  private readonly session: PermissionSession,
52
51
  private readonly events: PermissionEventBus,
53
52
  private readonly toolRegistry: ToolRegistry,
@@ -57,10 +56,9 @@ export class PermissionGateHandler {
57
56
  event: unknown,
58
57
  ctx: ExtensionContext,
59
58
  ): Promise<{ block?: true; reason?: string }> {
60
- const { session } = this;
61
- session.activate(ctx);
59
+ this.session.activate(ctx);
62
60
 
63
- const agentName = session.resolveAgentName(ctx);
61
+ const agentName = this.session.resolveAgentName(ctx);
64
62
  const toolName = getToolNameFromValue(event);
65
63
 
66
64
  if (!toolName) {
@@ -100,22 +98,22 @@ export class PermissionGateHandler {
100
98
  };
101
99
 
102
100
  // ── Shared gate adapter closures ─────────────────────────────────────
103
- const canConfirm = () => session.canPrompt(ctx);
101
+ const canConfirm = () => this.session.canPrompt(ctx);
104
102
  const promptPermission = (details: PromptPermissionDetails) =>
105
- session.prompt(ctx, details);
103
+ this.session.prompt(ctx, details);
106
104
  const emitDecision: GateRunnerDeps["emitDecision"] = (e) =>
107
105
  emitDecisionEvent(this.events, e);
108
106
  // eslint-disable-next-line @typescript-eslint/unbound-method -- logger.review is a plain function closure; no this-binding issue
109
- const writeReviewLog = session.logger.review;
107
+ const writeReviewLog = this.session.logger.review;
110
108
  const checkPermission: GateRunnerDeps["checkPermission"] = (
111
109
  surface,
112
110
  input,
113
111
  agent,
114
112
  sessionRules,
115
- ) => session.checkPermission(surface, input, agent, sessionRules);
116
- const getSessionRuleset = () => session.getSessionRuleset();
113
+ ) => this.session.checkPermission(surface, input, agent, sessionRules);
114
+ const getSessionRuleset = () => this.session.getSessionRuleset();
117
115
  const approveSessionRule = (surface: string, pattern: string) =>
118
- session.approveSessionRule(surface, pattern);
116
+ this.session.approveSessionRule(surface, pattern);
119
117
 
120
118
  // ── Shared runner deps (built once, reused for all gates) ────────────
121
119
  const runnerDeps: GateRunnerDeps = {
@@ -130,7 +128,7 @@ export class PermissionGateHandler {
130
128
 
131
129
  // ── Skill-read gate (descriptor + runner) ───────────────────────────────
132
130
  const skillDescriptor = describeSkillReadGate(tcc, () =>
133
- session.getActiveSkillEntries(),
131
+ this.session.getActiveSkillEntries(),
134
132
  );
135
133
  if (skillDescriptor) {
136
134
  const skillResult = await runGateCheck(
@@ -166,8 +164,8 @@ export class PermissionGateHandler {
166
164
 
167
165
  // ── External-directory gate (descriptor + runner) ────────────────────────
168
166
  const infraDirs = [
169
- ...session.getInfrastructureDirs(),
170
- ...session.getInfrastructureReadPaths(),
167
+ ...this.session.getInfrastructureDirs(),
168
+ ...this.session.getInfrastructureReadPaths(),
171
169
  ];
172
170
  const extDirDesc = describeExternalDirectoryGate(tcc, infraDirs);
173
171
  if (extDirDesc) {
@@ -265,16 +263,15 @@ export class PermissionGateHandler {
265
263
  event: InputPayload,
266
264
  ctx: ExtensionContext,
267
265
  ): Promise<InputEventResult> {
268
- const { session } = this;
269
- session.activate(ctx);
266
+ this.session.activate(ctx);
270
267
 
271
268
  const skillName = extractSkillNameFromInput(event.text);
272
269
  if (!skillName) {
273
270
  return { action: "continue" };
274
271
  }
275
272
 
276
- const agentName = session.resolveAgentName(ctx);
277
- const check = session.checkPermission(
273
+ const agentName = this.session.resolveAgentName(ctx);
274
+ const check = this.session.checkPermission(
278
275
  "skill",
279
276
  { name: skillName },
280
277
  agentName ?? undefined,
@@ -291,14 +288,14 @@ export class PermissionGateHandler {
291
288
  skillName,
292
289
  agentName ?? undefined,
293
290
  );
294
- const skillInputCanConfirm = session.canPrompt(ctx);
291
+ const skillInputCanConfirm = this.session.canPrompt(ctx);
295
292
  let skillInputAutoApproved = false;
296
293
  const skillInputGate = await applyPermissionGate({
297
294
  state: check.state,
298
295
  canConfirm: skillInputCanConfirm,
299
296
  promptForApproval: async () => {
300
- const decision = await session.prompt(ctx, {
301
- requestId: session.createPermissionRequestId("skill-input"),
297
+ const decision = await this.session.prompt(ctx, {
298
+ requestId: this.session.createPermissionRequestId("skill-input"),
302
299
  source: "skill_input",
303
300
  agentName,
304
301
  message: skillInputMessage,
@@ -308,7 +305,7 @@ export class PermissionGateHandler {
308
305
  return decision;
309
306
  },
310
307
  // eslint-disable-next-line @typescript-eslint/unbound-method -- logger.review is a plain function closure; no this-binding issue
311
- writeLog: session.logger.review,
308
+ writeLog: this.session.logger.review,
312
309
  logContext: {
313
310
  source: "skill_input",
314
311
  skillName,
@@ -98,16 +98,39 @@ describe("describeBashExternalDirectoryGate", () => {
98
98
  expect(patterns.length).toBeGreaterThan(0);
99
99
  });
100
100
 
101
- it("uses config-level checkPermission for the policy state", async () => {
101
+ it("returns GateBypass when all external paths are config-level allowed", async () => {
102
+ // Config-level allow (source: "special") should suppress the prompt,
103
+ // not just session-level allow. This was the bug: source !== "session"
104
+ // kept config-allowed paths in the uncovered set.
102
105
  const checkPermission = vi
103
106
  .fn()
104
107
  .mockImplementation(
105
108
  (_surface: string, input: Record<string, unknown>) => {
106
- // Path-specific check returns session for coverage filtering
107
109
  if (input.path)
108
110
  return makeCheckResult("allow", { source: "special" });
109
- // Config-level check (no path) returns deny
110
- return makeCheckResult("deny");
111
+ return makeCheckResult("ask");
112
+ },
113
+ );
114
+ const result = await describeBashExternalDirectoryGate(
115
+ makeTcc(),
116
+ checkPermission,
117
+ vi.fn().mockReturnValue([]),
118
+ );
119
+ expect(result).not.toBeNull();
120
+ expect(isGateBypass(result)).toBe(true);
121
+ });
122
+
123
+ it("uses worst-check state from uncovered paths for preCheck (config deny > catch-all ask)", async () => {
124
+ // The path-less extCheck used to always return the "*" catch-all (ask),
125
+ // silently downgrading a config-level deny to ask. After the fix, the
126
+ // descriptor's preCheck is derived from the actual path check result.
127
+ const checkPermission = vi
128
+ .fn()
129
+ .mockImplementation(
130
+ (_surface: string, input: Record<string, unknown>) => {
131
+ if (input.path) return makeCheckResult("deny", { source: "special" });
132
+ // Path-less catch-all returns ask — should NOT be used as preCheck.
133
+ return makeCheckResult("ask");
111
134
  },
112
135
  );
113
136
  const result = await describeBashExternalDirectoryGate(
@@ -116,8 +139,6 @@ describe("describeBashExternalDirectoryGate", () => {
116
139
  vi.fn().mockReturnValue([]),
117
140
  );
118
141
  expect(isGateDescriptor(result)).toBe(true);
119
- // The descriptor should carry the deny state from the config-level check
120
- // (it will be checked as preCheck by the runner)
121
142
  const desc = result as GateDescriptor;
122
143
  expect(desc.preCheck?.state).toBe("deny");
123
144
  });
@@ -172,6 +193,53 @@ describe("describeBashExternalDirectoryGate", () => {
172
193
  });
173
194
  });
174
195
 
196
+ it("config-allowed path is excluded; remaining ask path produces a descriptor", async () => {
197
+ // One path config-allowed, one config-ask → descriptor with only the ask path.
198
+ const checkPermission = vi
199
+ .fn()
200
+ .mockImplementation(
201
+ (_surface: string, input: Record<string, unknown>) => {
202
+ if (input.path === "/outside/a.ts")
203
+ return makeCheckResult("allow", { source: "special" });
204
+ return makeCheckResult("ask");
205
+ },
206
+ );
207
+ const result = await describeBashExternalDirectoryGate(
208
+ makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
209
+ checkPermission,
210
+ vi.fn().mockReturnValue([]),
211
+ );
212
+ expect(isGateDescriptor(result)).toBe(true);
213
+ const desc = result as GateDescriptor;
214
+ const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
215
+ expect(patterns.length).toBe(1);
216
+ expect(desc.preCheck?.state).toBe("ask");
217
+ });
218
+
219
+ it("config-denied path makes worstCheck deny even when another path is ask", async () => {
220
+ // One path config-denied, one config-ask → descriptor with preCheck.state === "deny".
221
+ const checkPermission = vi
222
+ .fn()
223
+ .mockImplementation(
224
+ (_surface: string, input: Record<string, unknown>) => {
225
+ if (input.path === "/outside/a.ts")
226
+ return makeCheckResult("deny", { source: "special" });
227
+ return makeCheckResult("ask");
228
+ },
229
+ );
230
+ const result = await describeBashExternalDirectoryGate(
231
+ makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
232
+ checkPermission,
233
+ vi.fn().mockReturnValue([]),
234
+ );
235
+ expect(isGateDescriptor(result)).toBe(true);
236
+ const desc = result as GateDescriptor;
237
+ expect(desc.preCheck?.state).toBe("deny");
238
+ // Both paths are uncovered (neither is allow), so both patterns are included.
239
+ const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
240
+ expect(patterns.length).toBe(2);
241
+ });
242
+
175
243
  it("only includes uncovered paths when some are session-covered", async () => {
176
244
  const checkPermission = vi
177
245
  .fn()