@gotgenes/pi-permission-system 20.10.0 → 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 +22 -0
- package/README.md +11 -11
- package/dist/public.d.ts +4 -2
- package/docs/configuration.md +8 -0
- package/docs/migration/strict-config-validation.md +15 -1
- package/package.json +1 -1
- package/src/permission-manager.ts +36 -3
- package/src/policy-loader.ts +14 -1
- package/src/rule.ts +27 -2
- package/src/types.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ 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
|
+
|
|
8
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)
|
|
9
31
|
|
|
10
32
|
|
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.
|
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
|
@@ -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/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
|
/**
|