@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 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) — reading and fixing rejected configs |
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 (`(details, query) => verdict`).
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 };
@@ -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, so its surfaces fall back to the safe universal `ask` default — never `allow`.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "20.9.1",
3
+ "version": "21.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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` at `authorize` time (ADR 0007
20
- * §3) so it queries the deterministic engine at gate parity; the terminal never
21
- * queries. With zero links the composed chain **is** the terminal instance
22
- * (identity), so behavior is byte-identical to the pre-chain spine the
23
- * empty-links case that ships until a link registers.
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;
@@ -126,6 +126,7 @@ export class AuthorizerSelection
126
126
  this.resolveConfiguredLinks(),
127
127
  this.terminal,
128
128
  this.deps.getPermissionQuery(),
129
+ this.deps.logger,
129
130
  );
130
131
  return this.deps.prompter.prompt(chain, details);
131
132
  }
@@ -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` and the injected `query` are forwarded as-is.
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
- return [...this.loader.getConfigIssues()];
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
- const value: ResolvedPermissions = { composedRules };
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
  }
@@ -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
- value = {};
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 (`(details, query) => verdict`).
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
  /**