@gotgenes/pi-permission-system 4.9.0 → 5.0.0

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,30 @@ 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.0.0](https://github.com/gotgenes/pi-permission-system/compare/v4.9.0...v5.0.0) (2026-05-05)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * Rule.origin and PermissionCheckResult.origin are now required fields. Code that constructs Rule or PermissionCheckResult literals must include an origin value.
14
+
15
+ ### Features
16
+
17
+ * add RuleOrigin type and origin field to Rule ([b4452d1](https://github.com/gotgenes/pi-permission-system/commit/b4452d1cc9e87a8315edcd6f5f2b1425310bd0b6))
18
+ * display rule origins in /permission-system show output ([af34c8e](https://github.com/gotgenes/pi-permission-system/commit/af34c8e808c7fa67bbe68635f776ec0fd8717bfa))
19
+ * include rule origin in permission review log entries ([b19fdf6](https://github.com/gotgenes/pi-permission-system/commit/b19fdf69b48248430410643ee20bee58535b99d9))
20
+ * make Rule.origin and PermissionCheckResult.origin required ([937a9f5](https://github.com/gotgenes/pi-permission-system/commit/937a9f5c4a9442611606fa3b27962555ed8c25a9))
21
+ * propagate origin to synthesized default rule ([04f9130](https://github.com/gotgenes/pi-permission-system/commit/04f91304ec5ba975ac512989c90757528a30ef7b))
22
+ * track and propagate rule origin through checkPermission ([327bc60](https://github.com/gotgenes/pi-permission-system/commit/327bc60e7f79aafd19995337f62244fd8b0c191f))
23
+
24
+
25
+ ### Documentation
26
+
27
+ * plan rule origin provenance tracking ([#88](https://github.com/gotgenes/pi-permission-system/issues/88)) ([d8f8840](https://github.com/gotgenes/pi-permission-system/commit/d8f884028f03682a896dd0d6e8e5a335d8e669f5))
28
+ * **retro:** add retro notes for issue [#48](https://github.com/gotgenes/pi-permission-system/issues/48) ([2187a53](https://github.com/gotgenes/pi-permission-system/commit/2187a53d2af30d6a68f664b1ce4af0dc30b39061))
29
+ * update target architecture for required Rule.origin ([edf0620](https://github.com/gotgenes/pi-permission-system/commit/edf06209ce148b70131a5abf361070571db51e7b))
30
+ * update target architecture for rule origin provenance ([c82435b](https://github.com/gotgenes/pi-permission-system/commit/c82435bb75dd7b22331986c6a23bfe5cf1849ca7))
31
+
8
32
  ## [4.9.0](https://github.com/gotgenes/pi-permission-system/compare/v4.8.0...v4.9.0) (2026-05-05)
9
33
 
10
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "4.9.0",
3
+ "version": "5.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -9,6 +9,7 @@ import {
9
9
  DEFAULT_EXTENSION_CONFIG,
10
10
  type PermissionSystemExtensionConfig,
11
11
  } from "./extension-config";
12
+ import type { Ruleset } from "./rule";
12
13
 
13
14
  interface PermissionSystemConfigController {
14
15
  getConfig(): PermissionSystemExtensionConfig;
@@ -17,6 +18,8 @@ interface PermissionSystemConfigController {
17
18
  ctx: ExtensionCommandContext,
18
19
  ): void;
19
20
  getConfigPath(): string;
21
+ /** Optional: returns the composed config-layer ruleset for origin display. */
22
+ getComposedRules?(): Ruleset;
20
23
  }
21
24
 
22
25
  const ON_OFF = ["on", "off"];
@@ -57,12 +60,30 @@ function toOnOff(value: boolean): string {
57
60
  return value ? "on" : "off";
58
61
  }
59
62
 
60
- function summarizeConfig(config: PermissionSystemExtensionConfig): string {
61
- return [
63
+ function formatRulesSummary(rules: Ruleset): string {
64
+ const configRules = rules.filter((r) => r.layer === "config" && r.origin);
65
+ if (configRules.length === 0) return "";
66
+ const formatted = configRules
67
+ .map((r) => {
68
+ const key =
69
+ r.pattern === "*" ? r.surface : `${r.surface}["${r.pattern}"]`;
70
+ return `${key}=${r.action} (${r.origin})`;
71
+ })
72
+ .join(", ");
73
+ return `\n rules: ${formatted}`;
74
+ }
75
+
76
+ function summarizeConfig(
77
+ config: PermissionSystemExtensionConfig,
78
+ rules?: Ruleset,
79
+ ): string {
80
+ const knobs = [
62
81
  `yoloMode=${toOnOff(config.yoloMode)}`,
63
82
  `permissionReviewLog=${toOnOff(config.permissionReviewLog)}`,
64
83
  `debugLog=${toOnOff(config.debugLog)}`,
65
84
  ].join(", ");
85
+ const rulesSuffix = rules ? formatRulesSummary(rules) : "";
86
+ return `${knobs}${rulesSuffix}`;
66
87
  }
67
88
 
68
89
  function buildSettingItems(
@@ -183,8 +204,9 @@ function handleArgs(
183
204
  }
184
205
 
185
206
  if (normalized === "show") {
207
+ const rules = controller.getComposedRules?.();
186
208
  ctx.ui.notify(
187
- `permission-system: ${summarizeConfig(controller.getConfig())}`,
209
+ `permission-system: ${summarizeConfig(controller.getConfig(), rules)}`,
188
210
  "info",
189
211
  );
190
212
  return true;
package/src/index.ts CHANGED
@@ -58,6 +58,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
58
58
  getConfig: () => runtime.config,
59
59
  setConfig: (next, ctx) => saveExtensionConfig(runtime, next, ctx),
60
60
  getConfigPath: () => getGlobalConfigPath(runtime.agentDir),
61
+ getComposedRules: () =>
62
+ runtime.permissionManager.getComposedConfigRules(
63
+ runtime.lastKnownActiveAgentName ?? undefined,
64
+ ),
61
65
  });
62
66
 
63
67
  const createPermissionRequestId = (prefix: string): string =>
package/src/normalize.ts CHANGED
@@ -18,12 +18,12 @@ export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
18
18
  for (const [surface, value] of Object.entries(permission)) {
19
19
  if (typeof value === "string") {
20
20
  if (isPermissionState(value)) {
21
- rules.push({ surface, pattern: "*", action: value });
21
+ rules.push({ surface, pattern: "*", action: value, origin: "builtin" });
22
22
  }
23
23
  } else if (typeof value === "object" && value !== null) {
24
24
  for (const [pattern, action] of Object.entries(value)) {
25
25
  if (isPermissionState(action)) {
26
- rules.push({ surface, pattern, action });
26
+ rules.push({ surface, pattern, action, origin: "builtin" });
27
27
  }
28
28
  }
29
29
  }
@@ -16,7 +16,7 @@ import {
16
16
  import { getGlobalConfigPath } from "./config-paths";
17
17
  import { normalizeInput } from "./input-normalizer";
18
18
  import { normalizeFlatConfig } from "./normalize";
19
- import type { Rule, Ruleset } from "./rule";
19
+ import type { Rule, RuleOrigin, Ruleset } from "./rule";
20
20
  import { evaluate, evaluateFirst } from "./rule";
21
21
  import {
22
22
  composeRuleset,
@@ -354,19 +354,55 @@ export class PermissionManager {
354
354
  const projectAgentConfig = this.loadProjectScopeConfig(agentName);
355
355
 
356
356
  // Merge permission objects across scopes (lowest → highest precedence).
357
+ // Build a parallel origin map that tracks which scope contributed each
358
+ // (surface, pattern) entry, mirroring mergeFlatPermissions() semantics.
359
+ type OriginMap = Map<string, Map<string, RuleOrigin>>;
360
+ const origins: OriginMap = new Map();
357
361
  let mergedPermission: FlatPermissionConfig = {};
358
- for (const scope of [
359
- globalConfig,
360
- projectConfig,
361
- agentConfig,
362
- projectAgentConfig,
363
- ]) {
364
- if (scope.permission) {
365
- mergedPermission = mergeFlatPermissions(
366
- mergedPermission,
367
- scope.permission,
368
- );
362
+
363
+ for (const [scopeName, scope] of [
364
+ ["global", globalConfig],
365
+ ["project", projectConfig],
366
+ ["agent", agentConfig],
367
+ ["project-agent", projectAgentConfig],
368
+ ] as const) {
369
+ if (!scope.permission) continue;
370
+
371
+ for (const [surface, value] of Object.entries(scope.permission)) {
372
+ const baseVal = mergedPermission[surface];
373
+ const bothObjects =
374
+ typeof baseVal === "object" &&
375
+ baseVal !== null &&
376
+ typeof value === "object" &&
377
+ value !== null;
378
+
379
+ if (bothObjects) {
380
+ // Shallow-merge: each incoming pattern is attributed to this scope;
381
+ // existing patterns from lower scopes keep their earlier origin.
382
+ if (!origins.has(surface)) origins.set(surface, new Map());
383
+ for (const pattern of Object.keys(value as Record<string, unknown>)) {
384
+ origins.get(surface)!.set(pattern, scopeName);
385
+ }
386
+ } else {
387
+ // Full replacement: this scope takes over the entire surface entry.
388
+ const surfaceOrigins = new Map<string, RuleOrigin>();
389
+ if (typeof value === "string") {
390
+ surfaceOrigins.set("*", scopeName);
391
+ } else if (typeof value === "object" && value !== null) {
392
+ for (const pattern of Object.keys(
393
+ value as Record<string, unknown>,
394
+ )) {
395
+ surfaceOrigins.set(pattern, scopeName);
396
+ }
397
+ }
398
+ origins.set(surface, surfaceOrigins);
399
+ }
369
400
  }
401
+
402
+ mergedPermission = mergeFlatPermissions(
403
+ mergedPermission,
404
+ scope.permission,
405
+ );
370
406
  }
371
407
 
372
408
  // Extract the universal fallback from permission["*"].
@@ -375,19 +411,28 @@ export class PermissionManager {
375
411
  const universalFallback = isPermissionState(mergedPermission["*"])
376
412
  ? (mergedPermission["*"] as PermissionState)
377
413
  : DEFAULT_UNIVERSAL_FALLBACK;
414
+ // Track which scope contributed the universal fallback.
415
+ const universalFallbackOrigin: RuleOrigin =
416
+ origins.get("*")?.get("*") ?? "builtin";
378
417
 
379
418
  // Build config rules from everything except the universal "*" key.
380
419
  const permissionWithoutUniversal: FlatPermissionConfig = Object.fromEntries(
381
420
  Object.entries(mergedPermission).filter(([k]) => k !== "*"),
382
421
  );
383
422
 
384
- // Normalize to config rules, tagged with "config" layer.
423
+ // Normalize to config rules, tagged with "config" layer and their origin.
385
424
  const configRules: Ruleset = normalizeFlatConfig(
386
425
  permissionWithoutUniversal,
387
- ).map((r): Rule => ({ ...r, layer: "config" }));
426
+ ).map(
427
+ (r): Rule => ({
428
+ ...r,
429
+ layer: "config",
430
+ origin: origins.get(r.surface)?.get(r.pattern) ?? "builtin",
431
+ }),
432
+ );
388
433
 
389
434
  const composedRules = composeRuleset(
390
- synthesizeDefaults(universalFallback),
435
+ synthesizeDefaults(universalFallback, universalFallbackOrigin),
391
436
  synthesizeBaseline(configRules),
392
437
  configRules,
393
438
  );
@@ -415,6 +460,17 @@ export class PermissionManager {
415
460
  return value;
416
461
  }
417
462
 
463
+ /**
464
+ * Return the composed config-layer rules for the given agent scope.
465
+ * Used by the `/permission-system show` command to display effective rules
466
+ * with their origin annotations.
467
+ * Session rules are not included — they are runtime-only.
468
+ */
469
+ getComposedConfigRules(agentName?: string): Ruleset {
470
+ const { composedRules } = this.resolvePermissions(agentName);
471
+ return composedRules.filter((r) => r.layer === "config");
472
+ }
473
+
418
474
  /**
419
475
  * Get the tool-level permission state for a tool, without considering
420
476
  * command-level rules. Used for tool injection decisions.
@@ -480,6 +536,7 @@ export class PermissionManager {
480
536
  ? rule.pattern
481
537
  : undefined,
482
538
  source: deriveSource(rule, normalizedToolName),
539
+ origin: rule.origin,
483
540
  ...extras,
484
541
  };
485
542
  }
@@ -493,7 +550,6 @@ export class PermissionManager {
493
550
  *
494
551
  * - session → "session" (always, all surfaces)
495
552
  * - mcp + default → "default"
496
- * - mcp + override → "tool"
497
553
  * - mcp + other → "mcp"
498
554
  * - special → "special" (always)
499
555
  * - skill → "skill" (always)
@@ -509,7 +565,6 @@ function deriveSource(
509
565
 
510
566
  if (toolName === "mcp") {
511
567
  if (rule.layer === "default") return "default";
512
- if (rule.layer === "override") return "tool";
513
568
  return "mcp";
514
569
  }
515
570
 
package/src/rule.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  import type { PermissionState } from "./types";
2
2
  import { wildcardMatch } from "./wildcard-matcher";
3
3
 
4
+ /**
5
+ * Provenance of a rule — which source contributed it.
6
+ *
7
+ * Config scopes: "global", "project", "agent", "project-agent".
8
+ * Synthesized: "builtin" (universal default / evaluate() fallback),
9
+ * "baseline" (conditional MCP metadata auto-allow).
10
+ * Runtime: "session" (session approvals).
11
+ */
12
+ export type RuleOrigin =
13
+ | "global"
14
+ | "project"
15
+ | "agent"
16
+ | "project-agent"
17
+ | "builtin"
18
+ | "baseline"
19
+ | "session";
20
+
4
21
  /** A single permission rule — the atomic unit of policy. */
5
22
  export interface Rule {
6
23
  /** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
@@ -13,7 +30,9 @@ export interface Rule {
13
30
  * Origin layer — used to derive PermissionCheckResult.source after evaluation.
14
31
  * Not used by evaluate(); purely informational metadata.
15
32
  */
16
- layer?: "default" | "override" | "baseline" | "config" | "session";
33
+ layer?: "default" | "baseline" | "config" | "session";
34
+ /** Which source contributed this rule. */
35
+ origin: RuleOrigin;
17
36
  }
18
37
 
19
38
  /** An ordered list of rules. Later rules take priority (last-match-wins). */
@@ -39,7 +58,12 @@ export function evaluate(
39
58
  wildcardMatch(r.surface, surface) && wildcardMatch(r.pattern, pattern),
40
59
  );
41
60
  if (rule !== undefined) return rule;
42
- return { surface, pattern, action: defaultAction ?? "ask" };
61
+ return {
62
+ surface,
63
+ pattern,
64
+ action: defaultAction ?? "ask",
65
+ origin: "builtin",
66
+ };
43
67
  }
44
68
 
45
69
  /**
@@ -15,7 +15,13 @@ export class SessionRules {
15
15
 
16
16
  /** Record a wildcard pattern as approved for the given surface. */
17
17
  approve(surface: string, pattern: string): void {
18
- this.rules.push({ surface, pattern, action: "allow", layer: "session" });
18
+ this.rules.push({
19
+ surface,
20
+ pattern,
21
+ action: "allow",
22
+ layer: "session",
23
+ origin: "session",
24
+ });
19
25
  }
20
26
 
21
27
  /** Return a defensive copy of the current session ruleset. */
package/src/synthesize.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Rule, Ruleset } from "./rule";
1
+ import type { Rule, RuleOrigin, Ruleset } from "./rule";
2
2
  import type { PermissionState } from "./types";
3
3
 
4
4
  /**
@@ -11,13 +11,17 @@ import type { PermissionState } from "./types";
11
11
  * regular config rules from `normalizeFlatConfig()` and sit at higher indices
12
12
  * in the composed array, so they override this default via last-match-wins.
13
13
  */
14
- export function synthesizeDefaults(universalDefault: PermissionState): Ruleset {
14
+ export function synthesizeDefaults(
15
+ universalDefault: PermissionState,
16
+ origin: RuleOrigin = "builtin",
17
+ ): Ruleset {
15
18
  return [
16
19
  {
17
20
  surface: "*",
18
21
  pattern: "*",
19
22
  action: universalDefault,
20
23
  layer: "default",
24
+ origin,
21
25
  },
22
26
  ];
23
27
  }
@@ -63,6 +67,7 @@ export function synthesizeBaseline(configRules: Ruleset): Ruleset {
63
67
  pattern: target,
64
68
  action: "allow",
65
69
  layer: "baseline",
70
+ origin: "baseline",
66
71
  }),
67
72
  );
68
73
  }
@@ -193,7 +193,12 @@ export function getPermissionLogContext(
193
193
  result: PermissionCheckResult,
194
194
  input: unknown,
195
195
  pathBearingTools: ReadonlySet<string>,
196
- ): { command?: string; target?: string; toolInputPreview?: string } {
196
+ ): {
197
+ command?: string;
198
+ target?: string;
199
+ toolInputPreview?: string;
200
+ origin?: string;
201
+ } {
197
202
  return {
198
203
  command: result.command,
199
204
  target: result.target,
@@ -202,5 +207,6 @@ export function getPermissionLogContext(
202
207
  input,
203
208
  pathBearingTools,
204
209
  ),
210
+ origin: result.origin,
205
211
  };
206
212
  }
package/src/types.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export type PermissionState = "allow" | "deny" | "ask";
2
2
 
3
+ import type { RuleOrigin } from "./rule";
4
+
5
+ export type { RuleOrigin };
6
+
3
7
  /**
4
8
  * The on-disk permission shape inside the `"permission"` key.
5
9
  * Each key is a surface name; values are either a PermissionState string
@@ -36,4 +40,6 @@ export interface PermissionCheckResult {
36
40
  command?: string;
37
41
  target?: string;
38
42
  source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
43
+ /** Which source contributed the winning rule. */
44
+ origin: RuleOrigin;
39
45
  }
@@ -10,6 +10,7 @@ import {
10
10
  type PermissionSystemExtensionConfig,
11
11
  savePermissionSystemConfig,
12
12
  } from "../src/extension-config";
13
+ import type { Rule } from "../src/rule";
13
14
 
14
15
  vi.mock("@mariozechner/pi-coding-agent", () => ({
15
16
  getSettingsListTheme: () => ({}),
@@ -234,3 +235,85 @@ test("permission-system command handlers manage config summary, persistence, and
234
235
  rmSync(baseDir, { recursive: true, force: true });
235
236
  }
236
237
  });
238
+
239
+ test("show output includes rule origins when getComposedRules is provided", async () => {
240
+ const config = { ...DEFAULT_EXTENSION_CONFIG };
241
+ const composedRules: Rule[] = [
242
+ {
243
+ surface: "read",
244
+ pattern: "*",
245
+ action: "allow",
246
+ layer: "config",
247
+ origin: "global",
248
+ },
249
+ {
250
+ surface: "bash",
251
+ pattern: "rm *",
252
+ action: "deny",
253
+ layer: "config",
254
+ origin: "project",
255
+ },
256
+ ];
257
+
258
+ const controller = {
259
+ getConfig: () => config,
260
+ setConfig: () => {},
261
+ getConfigPath: () => "/fake/config.json",
262
+ getComposedRules: () => composedRules,
263
+ };
264
+
265
+ let definition: {
266
+ handler: (args: string, ctx: CommandContextStub) => Promise<void>;
267
+ } | null = null;
268
+
269
+ registerPermissionSystemCommand(
270
+ {
271
+ registerCommand(_name: string, nextDef: typeof definition) {
272
+ definition = nextDef;
273
+ },
274
+ } as never,
275
+ controller as never,
276
+ );
277
+
278
+ const ctx = createCommandContext(true);
279
+ await definition!.handler("show", ctx.ctx);
280
+ const msg = lastNotification(ctx.notifications).message;
281
+
282
+ assert.ok(msg.includes("global"), `expected 'global' in: ${msg}`);
283
+ assert.ok(msg.includes("project"), `expected 'project' in: ${msg}`);
284
+ assert.ok(msg.includes("read"), `expected 'read' in: ${msg}`);
285
+ assert.ok(msg.includes("bash"), `expected 'bash' in: ${msg}`);
286
+ });
287
+
288
+ test("show output omits rule summary when getComposedRules is not provided", async () => {
289
+ const config = { ...DEFAULT_EXTENSION_CONFIG, yoloMode: true };
290
+
291
+ const controller = {
292
+ getConfig: () => config,
293
+ setConfig: () => {},
294
+ getConfigPath: () => "/fake/config.json",
295
+ // no getComposedRules
296
+ };
297
+
298
+ let definition: {
299
+ handler: (args: string, ctx: CommandContextStub) => Promise<void>;
300
+ } | null = null;
301
+
302
+ registerPermissionSystemCommand(
303
+ {
304
+ registerCommand(_name: string, nextDef: typeof definition) {
305
+ definition = nextDef;
306
+ },
307
+ } as never,
308
+ controller as never,
309
+ );
310
+
311
+ const ctx = createCommandContext(true);
312
+ await definition!.handler("show", ctx.ctx);
313
+ const msg = lastNotification(ctx.notifications).message;
314
+
315
+ // Config knobs still present.
316
+ assert.ok(msg.includes("yoloMode=on"), `expected yoloMode=on in: ${msg}`);
317
+ // No rule annotation lines.
318
+ assert.ok(!msg.includes("(global)"), `unexpected '(global)' in: ${msg}`);
319
+ });
@@ -52,7 +52,7 @@ function makeToolCallEvent(
52
52
  function makePermissionResult(
53
53
  state: "allow" | "deny" | "ask",
54
54
  ): PermissionCheckResult {
55
- return { state, toolName: "read", source: "tool" };
55
+ return { state, toolName: "read", source: "tool", origin: "builtin" };
56
56
  }
57
57
 
58
58
  function makeRuntime(
@@ -761,6 +761,7 @@ describe("handleToolCall — session recording on approved_for_session", () => {
761
761
  state: "ask",
762
762
  toolName: "read",
763
763
  source: "tool",
764
+ origin: "builtin",
764
765
  }),
765
766
  } as unknown as ExtensionRuntime["permissionManager"],
766
767
  sessionRules,