@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 +8 -0
- package/package.json +1 -1
- package/src/config/config-store.ts +1 -1
- package/src/config/policy-issue-reporter.ts +55 -0
- package/src/config/policy-loader.ts +7 -22
- package/src/handlers/before-agent-start.ts +9 -0
- package/src/handlers/lifecycle.ts +6 -7
- package/src/index.ts +6 -1
- package/src/policy/permission-manager.ts +15 -6
- package/src/policy/permission-resolver.ts +4 -3
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
|
@@ -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.
|
|
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
|
|
85
|
-
*
|
|
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
|
|
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
|
-
* - `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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 = [
|
|
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
|
-
|
|
143
|
-
return this.permissionManager.
|
|
143
|
+
getPolicyIssues(agentName?: string): string[] {
|
|
144
|
+
return this.permissionManager.getPolicyIssues(agentName);
|
|
144
145
|
}
|
|
145
146
|
}
|