@gotgenes/pi-permission-system 20.9.1 → 21.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 +29 -0
- package/README.md +11 -11
- package/dist/public.d.ts +29 -6
- package/docs/configuration.md +8 -0
- package/docs/migration/strict-config-validation.md +15 -1
- package/package.json +1 -1
- package/src/authority/authorizer-chain.ts +9 -7
- package/src/authority/authorizer-selection.ts +1 -0
- package/src/authority/authorizer.ts +5 -2
- package/src/authority/delegation-envelope.ts +4 -3
- package/src/permission-manager.ts +36 -3
- package/src/policy-loader.ts +14 -1
- package/src/rule.ts +27 -2
- package/src/service.ts +20 -1
- package/src/types.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ 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
|
+
## [21.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.10.0...pi-permission-system-v21.0.0) (2026-07-24)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** A session whose project/agent/project-agent config is invalid previously inherited a lower scope's `allow` rules unchanged; those surfaces now resolve to `ask` (prompt) until the invalid config is corrected. Only sessions that already emit a config-validation warning are affected. Fix the reported config issues and reload to restore the intended policy.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** add floorAllowsToAsk allow→ask overlay ([#646](https://github.com/gotgenes/pi-packages/issues/646)) ([8dbdcd7](https://github.com/gotgenes/pi-packages/commit/8dbdcd75bd8c6f62528d0466f93cfefa039f3d4b))
|
|
18
|
+
* **pi-permission-system:** mark invalid non-global config scopes ([#646](https://github.com/gotgenes/pi-packages/issues/646)) ([1abd318](https://github.com/gotgenes/pi-packages/commit/1abd3182b7c1b6304d0ef28acfbb59c470508a43))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
* **pi-permission-system:** fail closed when a higher-precedence config scope is invalid ([#646](https://github.com/gotgenes/pi-packages/issues/646)) ([b3c11c0](https://github.com/gotgenes/pi-packages/commit/b3c11c0984b7d9849349c5d8d0f1de111d1c7a03))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
|
|
28
|
+
* **pi-permission-system:** document cross-scope fail-closed config clamp ([#646](https://github.com/gotgenes/pi-packages/issues/646)) ([7903b4c](https://github.com/gotgenes/pi-packages/commit/7903b4cc15710618a6f0ed5cc302687af18db4be))
|
|
29
|
+
|
|
30
|
+
## [20.10.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.9.1...pi-permission-system-v20.10.0) (2026-07-21)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Features
|
|
34
|
+
|
|
35
|
+
* **pi-permission-system:** thread a review-log seam into the authorizer chain ([b086474](https://github.com/gotgenes/pi-packages/commit/b086474e91c79b02632ee76bbaa3e72e39f29e40))
|
|
36
|
+
|
|
8
37
|
## [20.9.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.9.0...pi-permission-system-v20.9.1) (2026-07-20)
|
|
9
38
|
|
|
10
39
|
|
package/README.md
CHANGED
|
@@ -129,17 +129,17 @@ If you relied on the old permissive behavior for bash, set an explicit permissiv
|
|
|
129
129
|
|
|
130
130
|
## Documentation
|
|
131
131
|
|
|
132
|
-
| Document | Contents
|
|
133
|
-
| ------------------------------------------------------------------------------------------------------------------------------ |
|
|
134
|
-
| [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes
|
|
135
|
-
| [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table
|
|
136
|
-
| [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, prompt and decision broadcasts
|
|
137
|
-
| [docs/subagent-integration.md](docs/subagent-integration.md) | Permission forwarding, coexistence with subagent extensions
|
|
138
|
-
| [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors
|
|
139
|
-
| [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide
|
|
140
|
-
| [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model
|
|
141
|
-
| [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout
|
|
142
|
-
| [docs/migration/strict-config-validation.md](docs/migration/strict-config-validation.md) | Strict config validation (breaking) —
|
|
132
|
+
| Document | Contents |
|
|
133
|
+
| ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
|
134
|
+
| [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes |
|
|
135
|
+
| [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table |
|
|
136
|
+
| [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, prompt and decision broadcasts |
|
|
137
|
+
| [docs/subagent-integration.md](docs/subagent-integration.md) | Permission forwarding, coexistence with subagent extensions |
|
|
138
|
+
| [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors |
|
|
139
|
+
| [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide |
|
|
140
|
+
| [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
|
|
141
|
+
| [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout |
|
|
142
|
+
| [docs/migration/strict-config-validation.md](docs/migration/strict-config-validation.md) | Strict config validation (breaking) — rejected configs, and the cross-scope fail-closed clamp |
|
|
143
143
|
|
|
144
144
|
## Development
|
|
145
145
|
|
package/dist/public.d.ts
CHANGED
|
@@ -86,9 +86,11 @@ type PermissionState = z.infer<typeof permissionStateSchema>;
|
|
|
86
86
|
* Synthesized: "builtin" (universal default / evaluate() fallback),
|
|
87
87
|
* "baseline" (conditional MCP metadata auto-allow).
|
|
88
88
|
* Runtime: "session" (session approvals).
|
|
89
|
-
* Rewrite: "yolo" (composition-stage ask→allow rewrite under yolo mode)
|
|
89
|
+
* Rewrite: "yolo" (composition-stage ask→allow rewrite under yolo mode),
|
|
90
|
+
* "fail-closed" (composition-stage allow→ask floor when an
|
|
91
|
+
* invalid non-global config scope is detected).
|
|
90
92
|
*/
|
|
91
|
-
type RuleOrigin = "global" | "project" | "agent" | "project-agent" | "builtin" | "baseline" | "session" | "yolo";
|
|
93
|
+
type RuleOrigin = "global" | "project" | "agent" | "project-agent" | "builtin" | "baseline" | "session" | "yolo" | "fail-closed";
|
|
92
94
|
|
|
93
95
|
/**
|
|
94
96
|
* Execution context of a bash command nested inside a substitution or subshell.
|
|
@@ -217,10 +219,12 @@ type AuthorizerVerdict = {
|
|
|
217
219
|
* decide it or defer to the next link (ADR 0007). The chain injects a narrow,
|
|
218
220
|
* session-scoped {@link PermissionQuery} at `authorize` time (§3), so a link
|
|
219
221
|
* queries the deterministic engine at gate parity rather than reaching for the
|
|
220
|
-
* cross-extension service via `Symbol.for()`.
|
|
222
|
+
* cross-extension service via `Symbol.for()`. It also injects an
|
|
223
|
+
* {@link AuthorizerLog} so a link can record its decision trail to the shared
|
|
224
|
+
* permission review log (same §3 injection pattern).
|
|
221
225
|
*/
|
|
222
226
|
interface Authorizer {
|
|
223
|
-
authorize(details: PromptPermissionDetails, query: PermissionQuery): Promise<AuthorizerVerdict>;
|
|
227
|
+
authorize(details: PromptPermissionDetails, query: PermissionQuery, log: AuthorizerLog): Promise<AuthorizerVerdict>;
|
|
224
228
|
}
|
|
225
229
|
|
|
226
230
|
/**
|
|
@@ -257,6 +261,22 @@ type ToolInputFormatter = (input: Record<string, unknown>) => string | undefined
|
|
|
257
261
|
* reference — this ensures resilience across `/reload` and load-order edge cases.
|
|
258
262
|
*/
|
|
259
263
|
|
|
264
|
+
/**
|
|
265
|
+
* The narrow review-log seam handed to a chain link at `authorize` time
|
|
266
|
+
* (ADR 0007 §3, same injection pattern as {@link PermissionQuery}).
|
|
267
|
+
*
|
|
268
|
+
* A link uses it to record a positive decision trail to the permission review
|
|
269
|
+
* log — `review` for the durable, default-on audit entry (one per handled
|
|
270
|
+
* ask), `debug` for verbose or short-circuit detail gated behind the
|
|
271
|
+
* `debugLog` toggle. The session's own logger is passed straight through, so a
|
|
272
|
+
* link's entries land in the same `pi-permission-system-permission-review.jsonl`
|
|
273
|
+
* as the gate decisions, keying to a gate entry by `requestId`.
|
|
274
|
+
*/
|
|
275
|
+
interface AuthorizerLog {
|
|
276
|
+
review(event: string, details?: Record<string, unknown>): void;
|
|
277
|
+
debug(event: string, details?: Record<string, unknown>): void;
|
|
278
|
+
}
|
|
279
|
+
|
|
260
280
|
/**
|
|
261
281
|
* The narrow, read-only projection of {@link PermissionsService}: answer a
|
|
262
282
|
* policy query for a surface, and report a tool-level state. This is the
|
|
@@ -348,7 +368,10 @@ interface PermissionsService extends PermissionQuery {
|
|
|
348
368
|
* throws. The returned disposer unregisters the link.
|
|
349
369
|
*
|
|
350
370
|
* @param name - Operator-facing link name referenced from `authorizerChain`.
|
|
351
|
-
* @param authorize - The link's decision callback
|
|
371
|
+
* @param authorize - The link's decision callback
|
|
372
|
+
* (`(details, query, log) => verdict`); `log` is an
|
|
373
|
+
* {@link AuthorizerLog} for recording a decision trail to
|
|
374
|
+
* the shared permission review log.
|
|
352
375
|
*/
|
|
353
376
|
registerAuthorizer(name: string, authorize: Authorizer["authorize"]): () => void;
|
|
354
377
|
}
|
|
@@ -383,4 +406,4 @@ declare function getPermissionsService(): PermissionsService | undefined;
|
|
|
383
406
|
declare function unpublishPermissionsService(service: PermissionsService): void;
|
|
384
407
|
|
|
385
408
|
export { PERMISSIONS_DECISION_CHANNEL, PERMISSIONS_READY_CHANNEL, PERMISSIONS_UI_PROMPT_CHANNEL, getPermissionsService, publishPermissionsService, unpublishPermissionsService };
|
|
386
|
-
export type { Authorizer, AuthorizerVerdict, ForwardedPromptContext, PermissionCheckResult, PermissionDecisionEvent, PermissionQuery, PermissionState, PermissionUiPromptEvent, PermissionUiPromptSource, PermissionsReadyEvent, PermissionsService, PromptPermissionDetails, ToolInputFormatter };
|
|
409
|
+
export type { Authorizer, AuthorizerLog, AuthorizerVerdict, ForwardedPromptContext, PermissionCheckResult, PermissionDecisionEvent, PermissionQuery, PermissionState, PermissionUiPromptEvent, PermissionUiPromptSource, PermissionsReadyEvent, PermissionsService, PromptPermissionDetails, ToolInputFormatter };
|
package/docs/configuration.md
CHANGED
|
@@ -32,6 +32,14 @@ Project config overrides global config; per-agent frontmatter overrides both.
|
|
|
32
32
|
The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely.
|
|
33
33
|
Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`) use simple replacement.
|
|
34
34
|
|
|
35
|
+
**Invalid higher-precedence scope fails closed.**
|
|
36
|
+
If a non-global scope (project config, global agent frontmatter, or project agent frontmatter) is present but fails to load or validate, it no longer contributes an empty scope that silently inherits the lower scope's rules.
|
|
37
|
+
Instead the effective policy is floored so nothing resolves more permissively than `ask`: every `allow` (including one inherited from a lower scope) is clamped to `ask`, while `deny` and `ask` are unchanged.
|
|
38
|
+
So a global `bash: allow` cannot remain effective behind a project scope that was meant to deny bash but contains a typo — bash prompts until the invalid config is fixed.
|
|
39
|
+
A validation warning plus a distinct fail-closed notice are emitted, and a fix + reload restores the intended policy.
|
|
40
|
+
An invalid **global** scope does not trigger the clamp — it is the lowest precedence, so nothing more permissive is inherited when it fails.
|
|
41
|
+
This clamp is deny-preserving and, like `yoloMode`, applied at composition; when `yoloMode` is on it re-permits the floored `ask` back to `allow`, since yolo is an explicit full-permissive opt-in.
|
|
42
|
+
|
|
35
43
|
## Full Example
|
|
36
44
|
|
|
37
45
|
```jsonc
|
|
@@ -11,11 +11,25 @@ For example, a config with `"debugLog": "yes"` (a string, not a boolean) simply
|
|
|
11
11
|
The loader is now **strict and fail-closed**:
|
|
12
12
|
|
|
13
13
|
- A config file with **any** invalid field is rejected as a whole scope (global or project).
|
|
14
|
-
- The rejected scope contributes **no** permission rules
|
|
14
|
+
- The rejected scope contributes **no** permission rules.
|
|
15
15
|
- Each problem is reported as a clear, path-qualified issue in the permission review log (and the debug log when `debugLog` is on).
|
|
16
16
|
|
|
17
17
|
Nothing about the config **format** changed — a config that was already valid keeps working unchanged.
|
|
18
18
|
|
|
19
|
+
## Cross-scope hardening (fail closed on an invalid higher scope)
|
|
20
|
+
|
|
21
|
+
Rejecting a scope's rules is only half the story.
|
|
22
|
+
Because a higher-precedence scope that contributes no rules leaves the **lower** scope's rules in place, an invalid *higher* scope used to silently inherit the lower scope's policy — including a permissive `allow`.
|
|
23
|
+
For example, a global `bash: allow` remained effective even when a project config meant to deny bash but contained a typo.
|
|
24
|
+
|
|
25
|
+
The loader now fails closed across scopes as well: when a **non-global** scope (project config, global agent frontmatter, or project agent frontmatter) is present but invalid, the effective policy is floored so nothing resolves more permissively than `ask`.
|
|
26
|
+
Every `allow` — including one inherited from a lower scope — is clamped to `ask`; `deny` and `ask` are unchanged.
|
|
27
|
+
Alongside the per-problem validation issues, a distinct notice is reported: `Invalid <scope> configuration detected — failing closed: 'allow' rules are clamped to 'ask' …`.
|
|
28
|
+
|
|
29
|
+
An invalid **global** scope does not trigger the cross-scope clamp — it is the lowest precedence, so nothing more permissive is inherited when it fails.
|
|
30
|
+
The clamp is deny-preserving, and (like `yoloMode`) applied at composition; when `yoloMode` is on it re-permits the floored `ask` back to `allow`.
|
|
31
|
+
Fix the reported problems and reload to restore the intended policy.
|
|
32
|
+
|
|
19
33
|
## What you need to do
|
|
20
34
|
|
|
21
35
|
If your config was valid, nothing.
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PermissionQuery } from "#src/service";
|
|
1
|
+
import type { AuthorizerLog, PermissionQuery } from "#src/service";
|
|
2
2
|
import type {
|
|
3
3
|
Authorizer,
|
|
4
4
|
AuthorizerVerdict,
|
|
@@ -16,16 +16,18 @@ import { createDeniedPermissionDecision } from "./permission-dialog";
|
|
|
16
16
|
* (returns a full decision), so a deferring link cannot occupy the terminal
|
|
17
17
|
* slot.
|
|
18
18
|
*
|
|
19
|
-
* Each link is handed the session-scoped `query`
|
|
20
|
-
* §3) so it queries the deterministic engine at gate
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* Each link is handed the session-scoped `query` and the review-log `log` at
|
|
20
|
+
* `authorize` time (ADR 0007 §3) so it queries the deterministic engine at gate
|
|
21
|
+
* parity and records its decision trail; the terminal receives neither. With
|
|
22
|
+
* zero links the composed chain **is** the terminal instance (identity), so
|
|
23
|
+
* behavior is byte-identical to the pre-chain spine — the empty-links case that
|
|
24
|
+
* ships until a link registers.
|
|
24
25
|
*/
|
|
25
26
|
export function composeAuthorizerChain(
|
|
26
27
|
links: readonly Authorizer[],
|
|
27
28
|
terminal: TerminalAuthorizer,
|
|
28
29
|
query: PermissionQuery,
|
|
30
|
+
log: AuthorizerLog,
|
|
29
31
|
): TerminalAuthorizer {
|
|
30
32
|
if (links.length === 0) {
|
|
31
33
|
return terminal;
|
|
@@ -33,7 +35,7 @@ export function composeAuthorizerChain(
|
|
|
33
35
|
return {
|
|
34
36
|
async authorize(details) {
|
|
35
37
|
for (const link of links) {
|
|
36
|
-
const verdict = await link.authorize(details, query);
|
|
38
|
+
const verdict = await link.authorize(details, query, log);
|
|
37
39
|
const decision = decideFromVerdict(verdict);
|
|
38
40
|
if (decision) {
|
|
39
41
|
return decision;
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
} from "#src/authority/permission-prompt-component";
|
|
7
7
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
8
8
|
import type { PermissionEventBus } from "#src/permission-events";
|
|
9
|
-
import type { PermissionQuery } from "#src/service";
|
|
9
|
+
import type { AuthorizerLog, PermissionQuery } from "#src/service";
|
|
10
10
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
11
11
|
import { ParentAuthorizer } from "./approval-escalator";
|
|
12
12
|
import { DenyingAuthorizer } from "./denying-authorizer";
|
|
@@ -29,12 +29,15 @@ export type AuthorizerVerdict =
|
|
|
29
29
|
* decide it or defer to the next link (ADR 0007). The chain injects a narrow,
|
|
30
30
|
* session-scoped {@link PermissionQuery} at `authorize` time (§3), so a link
|
|
31
31
|
* queries the deterministic engine at gate parity rather than reaching for the
|
|
32
|
-
* cross-extension service via `Symbol.for()`.
|
|
32
|
+
* cross-extension service via `Symbol.for()`. It also injects an
|
|
33
|
+
* {@link AuthorizerLog} so a link can record its decision trail to the shared
|
|
34
|
+
* permission review log (same §3 injection pattern).
|
|
33
35
|
*/
|
|
34
36
|
export interface Authorizer {
|
|
35
37
|
authorize(
|
|
36
38
|
details: PromptPermissionDetails,
|
|
37
39
|
query: PermissionQuery,
|
|
40
|
+
log: AuthorizerLog,
|
|
38
41
|
): Promise<AuthorizerVerdict>;
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -26,13 +26,14 @@ export const DELEGATION_EXCLUDED_SURFACES: ReadonlySet<string> = new Set([
|
|
|
26
26
|
/**
|
|
27
27
|
* Wrap a link's `authorize` so an `allow` on an excluded surface is capped to
|
|
28
28
|
* `defer`. All other verdicts, and `allow`s on non-excluded surfaces, pass
|
|
29
|
-
* through unchanged. `details
|
|
29
|
+
* through unchanged. `details`, the injected `query`, and the review-log `log`
|
|
30
|
+
* are forwarded as-is.
|
|
30
31
|
*/
|
|
31
32
|
export function encloseInDelegationEnvelope(
|
|
32
33
|
authorize: Authorizer["authorize"],
|
|
33
34
|
): Authorizer["authorize"] {
|
|
34
|
-
return async (details, query) => {
|
|
35
|
-
const verdict = await authorize(details, query);
|
|
35
|
+
return async (details, query, log) => {
|
|
36
|
+
const verdict = await authorize(details, query, log);
|
|
36
37
|
if (verdict.kind === "allow" && isExcludedSurface(details)) {
|
|
37
38
|
return { kind: "defer" };
|
|
38
39
|
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
evaluate,
|
|
22
22
|
evaluateAnyValue,
|
|
23
23
|
evaluateFirst,
|
|
24
|
+
floorAllowsToAsk,
|
|
24
25
|
pathMatchOptions,
|
|
25
26
|
rewriteAsksToYolo,
|
|
26
27
|
} from "./rule";
|
|
@@ -61,6 +62,12 @@ type ResolvedPermissions = {
|
|
|
61
62
|
* Session rules are appended at call-time inside check().
|
|
62
63
|
*/
|
|
63
64
|
composedRules: Ruleset;
|
|
65
|
+
/**
|
|
66
|
+
* Non-global scopes whose config file failed to load or validate. When
|
|
67
|
+
* non-empty the composed ruleset has been floored allow→ask (#646); the
|
|
68
|
+
* names also drive the fail-closed notice in {@link getConfigIssues}.
|
|
69
|
+
*/
|
|
70
|
+
failClosedScopes: RuleOrigin[];
|
|
64
71
|
};
|
|
65
72
|
|
|
66
73
|
/**
|
|
@@ -159,8 +166,16 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
159
166
|
|
|
160
167
|
getConfigIssues(agentName?: string): string[] {
|
|
161
168
|
// Trigger a load/resolve to ensure issues are collected.
|
|
162
|
-
this.resolvePermissions(agentName);
|
|
163
|
-
|
|
169
|
+
const { failClosedScopes } = this.resolvePermissions(agentName);
|
|
170
|
+
const issues = [...this.loader.getConfigIssues()];
|
|
171
|
+
if (failClosedScopes.length > 0) {
|
|
172
|
+
issues.push(
|
|
173
|
+
`Invalid ${failClosedScopes.join(", ")} configuration detected — ` +
|
|
174
|
+
`failing closed: 'allow' rules are clamped to 'ask' for this session ` +
|
|
175
|
+
`until the configuration is corrected.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return issues;
|
|
164
179
|
}
|
|
165
180
|
|
|
166
181
|
getResolvedPolicyPaths(): ResolvedPolicyPaths {
|
|
@@ -222,7 +237,25 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
222
237
|
configRules,
|
|
223
238
|
);
|
|
224
239
|
|
|
225
|
-
|
|
240
|
+
// Fail closed when a non-global scope's config is invalid: floor every
|
|
241
|
+
// `allow` (including one inherited from a lower scope) to `ask` so a
|
|
242
|
+
// higher scope meant to tighten policy cannot silently fail open (#646).
|
|
243
|
+
// Global is excluded — nothing more permissive is inherited when it fails.
|
|
244
|
+
const failClosedScopes: RuleOrigin[] = [];
|
|
245
|
+
if (projectConfig.invalid === true) failClosedScopes.push("project");
|
|
246
|
+
if (agentConfig.invalid === true) failClosedScopes.push("agent");
|
|
247
|
+
if (projectAgentConfig.invalid === true)
|
|
248
|
+
failClosedScopes.push("project-agent");
|
|
249
|
+
|
|
250
|
+
const effectiveRules =
|
|
251
|
+
failClosedScopes.length > 0
|
|
252
|
+
? floorAllowsToAsk(composedRules)
|
|
253
|
+
: composedRules;
|
|
254
|
+
|
|
255
|
+
const value: ResolvedPermissions = {
|
|
256
|
+
composedRules: effectiveRules,
|
|
257
|
+
failClosedScopes,
|
|
258
|
+
};
|
|
226
259
|
this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
|
|
227
260
|
return value;
|
|
228
261
|
}
|
package/src/policy-loader.ts
CHANGED
|
@@ -232,8 +232,11 @@ export class FilePolicyLoader implements PolicyLoader {
|
|
|
232
232
|
const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
|
|
233
233
|
this.accumulateConfigIssues(issues);
|
|
234
234
|
|
|
235
|
+
// A present-but-rejected file yields issues (parse error or schema
|
|
236
|
+
// rejection); an absent file yields none. Fail closed on the former.
|
|
235
237
|
const value: ScopeConfig = {
|
|
236
238
|
permission: config.permission,
|
|
239
|
+
...(issues.length > 0 ? { invalid: true } : {}),
|
|
237
240
|
};
|
|
238
241
|
|
|
239
242
|
this.projectGlobalConfigCache = { stamp, value };
|
|
@@ -256,6 +259,14 @@ export class FilePolicyLoader implements PolicyLoader {
|
|
|
256
259
|
return cached.value;
|
|
257
260
|
}
|
|
258
261
|
|
|
262
|
+
// An absent file (stat failed) is a legitimately-empty scope, not invalid;
|
|
263
|
+
// only a present-but-unreadable file fails closed.
|
|
264
|
+
if (stamp === "missing") {
|
|
265
|
+
const value: ScopeConfig = {};
|
|
266
|
+
cache.set(agentName, { stamp, value });
|
|
267
|
+
return value;
|
|
268
|
+
}
|
|
269
|
+
|
|
259
270
|
let value: ScopeConfig;
|
|
260
271
|
try {
|
|
261
272
|
const markdown = readFileSync(filePath, "utf-8");
|
|
@@ -273,7 +284,9 @@ export class FilePolicyLoader implements PolicyLoader {
|
|
|
273
284
|
};
|
|
274
285
|
}
|
|
275
286
|
} catch {
|
|
276
|
-
|
|
287
|
+
// The file exists (stat succeeded above) but could not be read or
|
|
288
|
+
// parsed — fail closed for this scope (#646).
|
|
289
|
+
value = { invalid: true };
|
|
277
290
|
}
|
|
278
291
|
|
|
279
292
|
cache.set(agentName, { stamp, value });
|
package/src/rule.ts
CHANGED
|
@@ -11,7 +11,9 @@ import { type WildcardMatchOptions, wildcardMatch } from "./wildcard-matcher";
|
|
|
11
11
|
* Synthesized: "builtin" (universal default / evaluate() fallback),
|
|
12
12
|
* "baseline" (conditional MCP metadata auto-allow).
|
|
13
13
|
* Runtime: "session" (session approvals).
|
|
14
|
-
* Rewrite: "yolo" (composition-stage ask→allow rewrite under yolo mode)
|
|
14
|
+
* Rewrite: "yolo" (composition-stage ask→allow rewrite under yolo mode),
|
|
15
|
+
* "fail-closed" (composition-stage allow→ask floor when an
|
|
16
|
+
* invalid non-global config scope is detected).
|
|
15
17
|
*/
|
|
16
18
|
export type RuleOrigin =
|
|
17
19
|
| "global"
|
|
@@ -21,7 +23,8 @@ export type RuleOrigin =
|
|
|
21
23
|
| "builtin"
|
|
22
24
|
| "baseline"
|
|
23
25
|
| "session"
|
|
24
|
-
| "yolo"
|
|
26
|
+
| "yolo"
|
|
27
|
+
| "fail-closed";
|
|
25
28
|
|
|
26
29
|
/** A single permission rule — the atomic unit of policy. */
|
|
27
30
|
export interface Rule {
|
|
@@ -63,6 +66,28 @@ export function rewriteAsksToYolo(rules: Ruleset): Ruleset {
|
|
|
63
66
|
);
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Floor every `allow` rule to `ask`, tagged `origin: "fail-closed"`.
|
|
71
|
+
*
|
|
72
|
+
* The mirror image of {@link rewriteAsksToYolo}: the composition-stage
|
|
73
|
+
* expression of the fail-closed clamp applied when an invalid non-global config
|
|
74
|
+
* scope is detected. A permissive `allow` inherited from a lower-precedence
|
|
75
|
+
* scope becomes an `ask` prompt rather than a silent grant, while `deny` and
|
|
76
|
+
* existing `ask` rules pass through untouched — so the clamp removes only
|
|
77
|
+
* permissive inheritance and never weakens a hard deny.
|
|
78
|
+
*
|
|
79
|
+
* Pure and non-mutating — `surface`, `pattern`, and `layer` are preserved so
|
|
80
|
+
* downstream `matchedPattern` / source derivation is unaffected; only `action`
|
|
81
|
+
* and `origin` change.
|
|
82
|
+
*/
|
|
83
|
+
export function floorAllowsToAsk(rules: Ruleset): Ruleset {
|
|
84
|
+
return rules.map((rule) =>
|
|
85
|
+
rule.action === "allow"
|
|
86
|
+
? { ...rule, action: "ask", origin: "fail-closed" }
|
|
87
|
+
: rule,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
/**
|
|
67
92
|
* Pure permission evaluation.
|
|
68
93
|
*
|
package/src/service.ts
CHANGED
|
@@ -20,6 +20,22 @@ export type {
|
|
|
20
20
|
Authorizer,
|
|
21
21
|
AuthorizerVerdict,
|
|
22
22
|
} from "./authority/authorizer";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The narrow review-log seam handed to a chain link at `authorize` time
|
|
26
|
+
* (ADR 0007 §3, same injection pattern as {@link PermissionQuery}).
|
|
27
|
+
*
|
|
28
|
+
* A link uses it to record a positive decision trail to the permission review
|
|
29
|
+
* log — `review` for the durable, default-on audit entry (one per handled
|
|
30
|
+
* ask), `debug` for verbose or short-circuit detail gated behind the
|
|
31
|
+
* `debugLog` toggle. The session's own logger is passed straight through, so a
|
|
32
|
+
* link's entries land in the same `pi-permission-system-permission-review.jsonl`
|
|
33
|
+
* as the gate decisions, keying to a gate entry by `requestId`.
|
|
34
|
+
*/
|
|
35
|
+
export interface AuthorizerLog {
|
|
36
|
+
review(event: string, details?: Record<string, unknown>): void;
|
|
37
|
+
debug(event: string, details?: Record<string, unknown>): void;
|
|
38
|
+
}
|
|
23
39
|
export type { PromptPermissionDetails } from "./authority/permission-prompter";
|
|
24
40
|
export type {
|
|
25
41
|
ForwardedPromptContext,
|
|
@@ -143,7 +159,10 @@ export interface PermissionsService extends PermissionQuery {
|
|
|
143
159
|
* throws. The returned disposer unregisters the link.
|
|
144
160
|
*
|
|
145
161
|
* @param name - Operator-facing link name referenced from `authorizerChain`.
|
|
146
|
-
* @param authorize - The link's decision callback
|
|
162
|
+
* @param authorize - The link's decision callback
|
|
163
|
+
* (`(details, query, log) => verdict`); `log` is an
|
|
164
|
+
* {@link AuthorizerLog} for recording a decision trail to
|
|
165
|
+
* the shared permission review log.
|
|
147
166
|
*/
|
|
148
167
|
registerAuthorizer(
|
|
149
168
|
name: string,
|
package/src/types.ts
CHANGED
|
@@ -34,6 +34,13 @@ export type PathRuleTokenMatcher = (token: string) => boolean;
|
|
|
34
34
|
*/
|
|
35
35
|
export interface ScopeConfig {
|
|
36
36
|
permission?: FlatPermissionConfig;
|
|
37
|
+
/**
|
|
38
|
+
* True when the scope's config file was present but failed to load or
|
|
39
|
+
* validate (JSON parse error or schema rejection). Absent and valid files
|
|
40
|
+
* leave this unset. Drives the fail-closed allow→ask clamp for non-global
|
|
41
|
+
* scopes (#646).
|
|
42
|
+
*/
|
|
43
|
+
invalid?: boolean;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
/**
|