@gotgenes/pi-permission-system 39.0.3 → 39.0.4

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,14 @@ 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
+ ## [39.0.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.3...pi-permission-system-v39.0.4) (2026-10-05)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** show a broken config file's error once, not twice, at session start ([67ddf4f](https://github.com/gotgenes/pi-packages/commit/67ddf4f637bd6f4e15246093dc3d1c459552c7f2)), closes [#953](https://github.com/gotgenes/pi-packages/issues/953)
14
+ * **pi-permission-system:** report a policy file that fails closed mid-session ([100a0d3](https://github.com/gotgenes/pi-packages/commit/100a0d3e1b49c92454163b270df52df7cc78ca1f)), closes [#953](https://github.com/gotgenes/pi-packages/issues/953)
15
+
8
16
  ## [39.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.2...pi-permission-system-v39.0.3) (2026-10-03)
9
17
 
10
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "39.0.3",
3
+ "version": "39.0.4",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -101,7 +101,7 @@ export class ConfigStore
101
101
  *
102
102
  * This store answers; `ConfigIssueReporter` decides whether the operator has
103
103
  * heard it yet (#933). Not to be confused with
104
- * `PermissionResolver.getConfigIssues(agentName?)`, which answers for the
104
+ * `PermissionResolver.getPolicyIssues(agentName?)`, which answers for the
105
105
  * *policy* files rather than the extension config.
106
106
  */
107
107
  getConfigIssues(): readonly string[] {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * policy-issue-reporter.ts — Tell the operator what composing their policy
3
+ * revealed, once per notice, for as long as it holds.
4
+ *
5
+ * The policy side answers only what it alone knows: the fail-closed notice for
6
+ * a rejected non-global scope (its `allow` rules are clamped to `ask`) and the
7
+ * port notice for relocated MCP tool keys. A config file's own schema errors
8
+ * are `ConfigIssueReporter`'s; both reporters reading them showed the operator
9
+ * each one twice (#953).
10
+ *
11
+ * Policy is re-read from file mtimes whenever it is consulted, so a file broken
12
+ * mid-session is clamped mid-session. Reporting only at `session_start` left
13
+ * that clamp unexplained; this is driven there and on every
14
+ * `before_agent_start`, so the operator hears about it on the next turn.
15
+ *
16
+ * The notices are agent-scoped, so the agent name is an argument rather than
17
+ * something this reads for itself: `AgentPrepHandler` resolves it from the
18
+ * `<active_agent>` prompt tag — the only name a pi-subagents child has — after
19
+ * turn prep runs, and hands it here.
20
+ *
21
+ * The latch is `ConfigIssueReporter`'s: per notice, delivered as one joined
22
+ * message per report, and *replaced* each report rather than added to — so a
23
+ * file fixed and broken again, or an agent switched away from and back into
24
+ * with a broken scope, is announced again.
25
+ */
26
+
27
+ import type { ConfigIssueWarner } from "./config-issue-reporter";
28
+
29
+ /** The policy seam this reads (ISP): the notices composing an agent's policy produced. */
30
+ export interface PolicyIssueSource {
31
+ getPolicyIssues(agentName?: string): readonly string[];
32
+ }
33
+
34
+ /** The seam the session-start and agent-prep handlers drive. */
35
+ export interface PolicyIssueReporting {
36
+ report(agentName: string | undefined): void;
37
+ }
38
+
39
+ export class PolicyIssueReporter implements PolicyIssueReporting {
40
+ private reported: ReadonlySet<string> = new Set();
41
+
42
+ constructor(
43
+ private readonly source: PolicyIssueSource,
44
+ private readonly log: ConfigIssueWarner,
45
+ ) {}
46
+
47
+ report(agentName: string | undefined): void {
48
+ const current = this.source.getPolicyIssues(agentName);
49
+ const unreported = current.filter((issue) => !this.reported.has(issue));
50
+ if (unreported.length > 0) {
51
+ this.log.warn(unreported.join("\n"));
52
+ }
53
+ this.reported = new Set(current);
54
+ }
55
+ }
@@ -81,8 +81,8 @@ export interface ResolvedPolicyPaths {
81
81
 
82
82
  /**
83
83
  * Abstraction over file I/O for loading permission policy from disk.
84
- * Implementations handle caching, path resolution, and config-issue
85
- * accumulation. `PermissionManager` depends on this interface so that
84
+ * Implementations handle caching, path resolution, and marking a rejected
85
+ * scope `invalid`. `PermissionManager` depends on this interface so that
86
86
  * merge + evaluation logic can be tested with an in-memory stub.
87
87
  */
88
88
  export interface PolicyLoader {
@@ -93,8 +93,6 @@ export interface PolicyLoader {
93
93
  getConfiguredMcpServerNames(): readonly string[];
94
94
  /** Combined mtime stamp for cache invalidation. */
95
95
  getCacheStamp(agentName?: string): string;
96
- /** Accumulated config-parse issues across all loads. */
97
- getConfigIssues(): string[];
98
96
  /** Resolved paths for the /permission-system show command. */
99
97
  getResolvedPolicyPaths(): ResolvedPolicyPaths;
100
98
  }
@@ -172,7 +170,6 @@ export class FilePolicyLoader implements PolicyLoader {
172
170
  private configuredMcpServerNamesCache: FileCacheEntry<
173
171
  readonly string[]
174
172
  > | null = null;
175
- private accumulatedConfigIssues: string[] = [];
176
173
 
177
174
  constructor(options: PolicyLoaderOptions = {}) {
178
175
  this.globalConfigPath =
@@ -194,21 +191,11 @@ export class FilePolicyLoader implements PolicyLoader {
194
191
  : null;
195
192
  }
196
193
 
197
- // ── Config issue accumulation ────────────────────────────────────────
198
-
199
- private accumulateConfigIssues(issues: string[]): void {
200
- for (const issue of issues) {
201
- if (!this.accumulatedConfigIssues.includes(issue)) {
202
- this.accumulatedConfigIssues.push(issue);
203
- }
204
- }
205
- }
206
-
207
- getConfigIssues(): string[] {
208
- return [...this.accumulatedConfigIssues];
209
- }
210
-
211
194
  // ── Scope loaders ────────────────────────────────────────────────────
195
+ //
196
+ // A config file's schema errors are not kept here: `ConfigStore` loads the
197
+ // same files through the same `loadUnifiedConfig` and reports them, so this
198
+ // loader reads them only to decide whether a scope fails closed (#953).
212
199
 
213
200
  loadGlobalConfig(): ScopeConfig {
214
201
  const stamp = getFileStamp(this.globalConfigPath);
@@ -216,8 +203,7 @@ export class FilePolicyLoader implements PolicyLoader {
216
203
  return this.globalConfigCache.value;
217
204
  }
218
205
 
219
- const { config, issues } = loadUnifiedConfig(this.globalConfigPath);
220
- this.accumulateConfigIssues(issues);
206
+ const { config } = loadUnifiedConfig(this.globalConfigPath);
221
207
 
222
208
  const value: ScopeConfig = {
223
209
  permission: config.permission,
@@ -238,7 +224,6 @@ export class FilePolicyLoader implements PolicyLoader {
238
224
  }
239
225
 
240
226
  const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
241
- this.accumulateConfigIssues(issues);
242
227
 
243
228
  // A present-but-rejected file yields issues (parse error or schema
244
229
  // rejection); an absent file yields none. Fail closed on the former.
@@ -4,6 +4,7 @@ import type {
4
4
  NormalizedBuildSystemPromptOptions,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import type { SubagentDetector } from "#src/authority/subagent-detection";
7
+ import type { PolicyIssueReporting } from "#src/config/policy-issue-reporter";
7
8
  import {
8
9
  visibleSkillPromptEntries,
9
10
  withoutDeniedSkills,
@@ -78,6 +79,10 @@ export function shouldExposeTool(
78
79
  * - `logger` — records each change to the effective tool surface
79
80
  * - `detector` — tells a subagent child from a root, which decides whether a
80
81
  * custom prompt gets this node's tool surface
82
+ * - `policyIssues` — reports what composing policy revealed for the agent the
83
+ * prompt names; driven here rather than in turn prep because the
84
+ * `<active_agent>` tag, the only name a pi-subagents child has, is read here
85
+ * (#953)
81
86
  *
82
87
  * The active set is recomputed from the session's pre-filter tool surface
83
88
  * every turn, so relaxing a rule restores the tool it had withheld (#873).
@@ -90,6 +95,7 @@ export class AgentPrepHandler {
90
95
  private readonly toolRegistry: ToolRegistry,
91
96
  private readonly logger: DebugLogger,
92
97
  private readonly detector: SubagentDetector,
98
+ private readonly policyIssues: PolicyIssueReporting,
93
99
  ) {}
94
100
 
95
101
  // eslint-disable-next-line @typescript-eslint/require-await
@@ -105,6 +111,9 @@ export class AgentPrepHandler {
105
111
  ? event.systemPrompt
106
112
  : event.systemPrompt.join("\n");
107
113
  const agentName = this.session.resolveAgentName(ctx, systemPrompt);
114
+ // Policy is re-read by mtime, so this turn may compose a clamp the file
115
+ // just caused; say so for the agent this turn runs as.
116
+ this.policyIssues.report(agentName ?? undefined);
108
117
  const registered = readRegisteredTools(this.toolRegistry.getAll());
109
118
  const surface = this.session.resolveExposedTools(
110
119
  this.observeToolSurface(registered),
@@ -1,10 +1,10 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AskDialogRelease } from "#src/authority/ask-dialog-queue";
3
3
  import type { ConfigIssueReporting } from "#src/config/config-issue-reporter";
4
+ import type { PolicyIssueReporting } from "#src/config/policy-issue-reporter";
4
5
  import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/config/status";
5
6
  import type { DecisionSummaryWriter } from "#src/logging/decision-audit";
6
7
  import type { SessionLogger } from "#src/logging/session-logger";
7
- import type { PermissionResolver } from "#src/policy/permission-resolver";
8
8
  import type { ServiceLifecycle } from "#src/service/service-lifecycle";
9
9
  import type { PermissionSession } from "#src/session/permission-session";
10
10
 
@@ -41,7 +41,9 @@ export const UNTRUSTED_PROJECT_MESSAGE =
41
41
  *
42
42
  * Constructor deps:
43
43
  * - `session` — encapsulates all mutable session state and lifecycle operations
44
- * - `resolver` — owns permission-query surface: `getConfigIssues`
44
+ * - `policyIssues` — reports what composing policy revealed (a fail-closed clamp,
45
+ * a port notice) for the agent the session names, latched per notice; driven
46
+ * here and on every turn, so a clamp caused mid-session is explained (#953)
45
47
  * - `serviceLifecycle` — owns the process-global service publication;
46
48
  * `activate` publishes (skipped for registered subagent children) and emits
47
49
  * the ready event; `teardown` unsubscribes all session listeners and unpublishes
@@ -56,7 +58,7 @@ export const UNTRUSTED_PROJECT_MESSAGE =
56
58
  export class SessionLifecycleHandler {
57
59
  constructor(
58
60
  private readonly session: PermissionSession,
59
- private readonly resolver: PermissionResolver,
61
+ private readonly policyIssues: PolicyIssueReporting,
60
62
  private readonly serviceLifecycle: ServiceLifecycle,
61
63
  private readonly logger: SessionLogger,
62
64
  private readonly audit: DecisionSummaryWriter,
@@ -84,10 +86,7 @@ export class SessionLifecycleHandler {
84
86
  }
85
87
 
86
88
  const agentName = this.session.resolveAgentName(ctx);
87
- const policyIssues = this.resolver.getConfigIssues(agentName ?? undefined);
88
- for (const issue of policyIssues) {
89
- this.logger.warn(issue);
90
- }
89
+ this.policyIssues.report(agentName ?? undefined);
91
90
 
92
91
  if (event.reason === "reload") {
93
92
  this.logger.debug("lifecycle.reload", {
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import { ConfigStore } from "#src/config/config-store";
41
41
  import { resolveDialogKeys } from "#src/config/dialog-keys";
42
42
  import { isYoloModeEnabled } from "#src/config/extension-config";
43
43
  import { computeExtensionPaths } from "#src/config/extension-paths";
44
+ import { PolicyIssueReporter } from "#src/config/policy-issue-reporter";
44
45
  import { GateRunner } from "#src/handlers/gates/runner";
45
46
  import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
46
47
  import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
@@ -322,9 +323,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
322
323
  // was recorded as delivered and never shown (#933). Driven at session_start
323
324
  // and on every turn; the latch keeps an unchanged issue quiet.
324
325
  const configIssueReporter = new ConfigIssueReporter(configStore, logger);
326
+ // What composing policy revealed (a fail-closed clamp, a port notice) is
327
+ // agent-scoped, so each driver hands over the agent name it resolved (#953).
328
+ const policyIssueReporter = new PolicyIssueReporter(resolver, logger);
325
329
  const lifecycle = new SessionLifecycleHandler(
326
330
  session,
327
- resolver,
331
+ policyIssueReporter,
328
332
  serviceLifecycle,
329
333
  logger,
330
334
  audit,
@@ -346,6 +350,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
346
350
  toolRegistry,
347
351
  logger,
348
352
  subagentDetection,
353
+ policyIssueReporter,
349
354
  );
350
355
 
351
356
  const gateRunner = new GateRunner(
@@ -62,12 +62,12 @@ type ResolvedPermissions = {
62
62
  /**
63
63
  * Non-global scopes whose config file failed to load or validate. When
64
64
  * non-empty the composed ruleset has been floored allow→ask (#646); the
65
- * names also drive the fail-closed notice in {@link getConfigIssues}.
65
+ * names also drive the fail-closed notice in {@link getPolicyIssues}.
66
66
  */
67
67
  failClosedScopes: RuleOrigin[];
68
68
  /**
69
69
  * Top-level permission keys naming Pi MCP tools, relocated onto the `mcp`
70
- * surface; they drive the port notice in {@link getConfigIssues}.
70
+ * surface; they drive the port notice in {@link getPolicyIssues}.
71
71
  */
72
72
  legacyMcpToolKeys: string[];
73
73
  };
@@ -92,7 +92,7 @@ export interface ScopedPermissionManager {
92
92
  ): PermissionCheckResult;
93
93
  getToolPermission(toolName: string, agentName?: string): PermissionState;
94
94
  isToolFullyDenied(toolName: string, agentName?: string): boolean;
95
- getConfigIssues(agentName?: string): string[];
95
+ getPolicyIssues(agentName?: string): string[];
96
96
  }
97
97
 
98
98
  export interface PermissionManagerOptions extends PolicyLoaderOptions {
@@ -158,11 +158,20 @@ export class PermissionManager implements ScopedPermissionManager {
158
158
  this.resolvedPermissionsCache.clear();
159
159
  }
160
160
 
161
- getConfigIssues(agentName?: string): string[] {
162
- // Trigger a load/resolve to ensure issues are collected.
161
+ /**
162
+ * What composing `agentName`'s policy revealed: the fail-closed notice for
163
+ * a rejected non-global scope and the port notice for relocated MCP tool
164
+ * keys. Recomputed on every resolve, so a notice disappears once its cause is
165
+ * fixed.
166
+ *
167
+ * A config file's own schema errors are not here: `ConfigStore` loads the
168
+ * same files through the same `loadUnifiedConfig` and owns reporting them,
169
+ * so listing them here too showed the operator each one twice (#953).
170
+ */
171
+ getPolicyIssues(agentName?: string): string[] {
163
172
  const { failClosedScopes, legacyMcpToolKeys } =
164
173
  this.resolvePermissions(agentName);
165
- const issues = [...this.loader.getConfigIssues()];
174
+ const issues: string[] = [];
166
175
  if (failClosedScopes.length > 0) {
167
176
  issues.push(
168
177
  `Invalid ${failClosedScopes.join(", ")} configuration detected — ` +
@@ -4,6 +4,7 @@ import type {
4
4
  ResolvedAccessIntent,
5
5
  } from "#src/access-intent/access-intent";
6
6
  import { surfaceFamilyMembers } from "#src/access-intent/path-surfaces";
7
+ import type { PolicyIssueSource } from "#src/config/policy-issue-reporter";
7
8
  import type { SkillPermissionChecker } from "#src/exposure/skill-prompt-sanitizer";
8
9
  import type { SessionRules } from "#src/session/session-rules";
9
10
  import type { PermissionCheckResult, PermissionState } from "#src/types";
@@ -64,7 +65,7 @@ function toResolvedIntent(
64
65
  * - `sessionRules` — narrowed to `getRuleset` (ISP: the resolver only reads, never records)
65
66
  */
66
67
  export class PermissionResolver
67
- implements ScopedPermissionResolver, SkillPermissionChecker
68
+ implements ScopedPermissionResolver, SkillPermissionChecker, PolicyIssueSource
68
69
  {
69
70
  constructor(
70
71
  private readonly permissionManager: ScopedPermissionManager,
@@ -139,7 +140,7 @@ export class PermissionResolver
139
140
  return this.permissionManager.isToolFullyDenied(toolName, agentName);
140
141
  }
141
142
 
142
- getConfigIssues(agentName?: string): string[] {
143
- return this.permissionManager.getConfigIssues(agentName);
143
+ getPolicyIssues(agentName?: string): string[] {
144
+ return this.permissionManager.getPolicyIssues(agentName);
144
145
  }
145
146
  }