@gotgenes/pi-permission-system 20.10.0 → 22.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,49 @@ 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
+ ## [22.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v21.0.0...pi-permission-system-v22.0.0) (2026-07-24)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * **pi-permission-system:** In an untrusted project, project-scoped permission configuration (project config.json and project-agent frontmatter) and project-scoped runtime config (yoloMode, permissionReviewLog, etc.) are no longer loaded until the user grants project trust. Only global policy applies. Grant project trust, or set defaultProjectTrust, to restore the prior behavior.
14
+
15
+ ### Features
16
+
17
+ * **pi-permission-system:** support skipping project scope in loadAndMergeConfigs ([e5a2e57](https://github.com/gotgenes/pi-packages/commit/e5a2e57b39c7bae44e7ac126b24094c2d5dce155))
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **pi-permission-system:** gate project-scoped config on project trust ([f264e71](https://github.com/gotgenes/pi-packages/commit/f264e711b90c7947d805bec654bc78199a76fada))
23
+
24
+
25
+ ### Documentation
26
+
27
+ * **pi-permission-system:** document project-trust gating for project config ([e955a29](https://github.com/gotgenes/pi-packages/commit/e955a299156215be57144000d5157481615d8060))
28
+
29
+ ## [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)
30
+
31
+
32
+ ### ⚠ BREAKING CHANGES
33
+
34
+ * **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.
35
+
36
+ ### Features
37
+
38
+ * **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))
39
+ * **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))
40
+
41
+
42
+ ### Bug Fixes
43
+
44
+ * **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))
45
+
46
+
47
+ ### Documentation
48
+
49
+ * **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))
50
+
8
51
  ## [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
52
 
10
53
 
package/README.md CHANGED
@@ -107,6 +107,7 @@ Config lives in one JSON file per scope:
107
107
  | Project | `<cwd>/.pi/extensions/pi-permission-system/config.json` |
108
108
 
109
109
  Project overrides global; per-agent YAML frontmatter overrides both.
110
+ Project config (policy and runtime knobs) is loaded only once the project is trusted — in an untrusted directory only global config applies, so an untrusted repository cannot loosen your global policy (see [Upgrading](#2200--project-config-requires-project-trust)).
110
111
 
111
112
  Within a surface map like `bash` or `mcp`, **last matching rule wins** — put broad catch-alls first and specific overrides after.
112
113
 
@@ -120,6 +121,13 @@ For the full reference — all surfaces, runtime knobs, per-agent overrides, mer
120
121
 
121
122
  ## Upgrading
122
123
 
124
+ ### 22.0.0 — project config requires project trust
125
+
126
+ Project-scoped configuration (the project `config.json` and project-agent frontmatter — both permission policy and runtime knobs such as `yoloMode`) is now loaded only when Pi reports the project as trusted.
127
+ In an untrusted directory, only global config applies; a skip is surfaced with a warning and a `project_trust.skipped` review-log entry.
128
+ Grant project trust (or set `defaultProjectTrust`) to load a project's config.
129
+ See [docs/migration/0644-project-trust-gating.md](docs/migration/0644-project-trust-gating.md).
130
+
123
131
  ### 16.0.0 — the bash gate now fails closed
124
132
 
125
133
  The permission gate fails closed: an internal gate error blocks the tool (with a `gate_error` review-log entry) instead of running it ungated, and a non-empty bash command that cannot be parsed resolves to `ask` (sentinel `<unparseable-bash-command>`) rather than falling through to a permissive top-level `*`.
@@ -129,17 +137,18 @@ If you relied on the old permissive behavior for bash, set an explicit permissiv
129
137
 
130
138
  ## Documentation
131
139
 
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 |
140
+ | Document | Contents |
141
+ | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
142
+ | [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes |
143
+ | [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table |
144
+ | [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, prompt and decision broadcasts |
145
+ | [docs/subagent-integration.md](docs/subagent-integration.md) | Permission forwarding, coexistence with subagent extensions |
146
+ | [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors |
147
+ | [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide |
148
+ | [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
149
+ | [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout |
150
+ | [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 |
151
+ | [docs/migration/0644-project-trust-gating.md](docs/migration/0644-project-trust-gating.md) | Project-trust gating (breaking) — project config loads only after project trust |
143
152
 
144
153
  ## Development
145
154
 
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.
@@ -11,6 +11,12 @@ One unified config file per scope:
11
11
 
12
12
  Project config overrides global config; per-agent frontmatter overrides both.
13
13
 
14
+ **Project config requires project trust.**
15
+ Project and project-agent scopes (both permission policy and runtime config such as `yoloMode`) are loaded only when Pi reports the project as trusted (`ctx.isProjectTrusted()`).
16
+ In an untrusted directory, only global (and global-agent) config applies, so an untrusted repository cannot loosen your global policy; the extension surfaces a loud warning plus a `project_trust.skipped` review-log entry when it skips a project scope.
17
+ Grant project trust (or configure `defaultProjectTrust`) to load the project's config; a trust grant reloads project policy on the next `resources_discover` reload.
18
+ See [migration/0644-project-trust-gating.md](migration/0644-project-trust-gating.md).
19
+
14
20
  > **Coming from OpenCode?**
15
21
  > This extension's permission model was inspired by OpenCode's.
16
22
  > See [OpenCode Compatibility](opencode-compatibility.md) for shared concepts, divergences, and a porting guide.
@@ -32,6 +38,14 @@ Project config overrides global config; per-agent frontmatter overrides both.
32
38
  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
39
  Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`) use simple replacement.
34
40
 
41
+ **Invalid higher-precedence scope fails closed.**
42
+ 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.
43
+ 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.
44
+ 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.
45
+ A validation warning plus a distinct fail-closed notice are emitted, and a fix + reload restores the intended policy.
46
+ An invalid **global** scope does not trigger the clamp — it is the lowest precedence, so nothing more permissive is inherited when it fails.
47
+ 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.
48
+
35
49
  ## Full Example
36
50
 
37
51
  ```jsonc
@@ -0,0 +1,32 @@
1
+ # Migration guide: project-trust gating
2
+
3
+ Starting with the release that closes #644, the permission-system loads project-scoped configuration only after Pi reports the project as **trusted** (`ctx.isProjectTrusted()`).
4
+ This is a **breaking change** in how config is loaded in an untrusted directory.
5
+
6
+ ## What changed
7
+
8
+ The extension used to load project-scoped config from the current working directory unconditionally — it never consulted Pi's project-trust decision.
9
+ Because project scope has higher precedence than global, an untrusted repository could ship a `.pi/extensions/pi-permission-system/config.json` that **loosened** an operator's global policy before the user granted trust — for example flipping a global `bash: deny` to `bash: allow`, or setting `yoloMode: true`.
10
+
11
+ Now, when the project is **not** trusted:
12
+
13
+ - Project and project-agent **permission policy** scopes are not loaded — only global (and global-agent) policy participates in resolution.
14
+ - Project **runtime config** (`yoloMode`, `permissionReviewLog`, `piInfrastructureReadPaths`, `shellTools`, `authorizerChain`, …) is not merged.
15
+ - Each skip is surfaced loudly: a UI warning and a `project_trust.skipped` entry in the permission review log.
16
+
17
+ This aligns the extension with Pi's own trust model, which already withholds project-local skills, prompts, and agents from untrusted directories.
18
+
19
+ ## Timing and recovery
20
+
21
+ Pi resolves the trust decision (including any `defaultProjectTrust` setting) before `session_start`, so the guard sees the effective decision from the first tool call.
22
+ If you grant trust after the session starts, Pi fires `resources_discover` with `reason: "reload"`, and the extension re-reads trust and loads the project **policy** at that point.
23
+ Project **runtime** config (e.g. `yoloMode`) is re-read on the next session start.
24
+
25
+ ## What you need to do
26
+
27
+ If you only use global config, nothing changes.
28
+
29
+ If you rely on a project's `.pi/extensions/pi-permission-system/config.json`, **grant the project trust** when Pi prompts (or configure `defaultProjectTrust` to always trust).
30
+ Until then, the project's permission rules and runtime knobs are ignored and only your global policy applies.
31
+
32
+ If a project's rules stop taking effect after upgrading (surfaces you allowed at the project scope start prompting or denying per global policy), check whether the project is trusted — the review log will contain a `project_trust.skipped` entry naming the untrusted `cwd`.
@@ -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.10.0",
3
+ "version": "22.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -286,12 +286,20 @@ export interface MergedConfigResult {
286
286
  * Legacy files are detected and warned about. Their content is parsed with the
287
287
  * flat-format parser — legacy-format keys (defaultPolicy, tools, bash, etc.)
288
288
  * are not translated and contribute no permission rules.
289
+ *
290
+ * When `options.includeProjectScope` is `false`, the project-scope steps (4 and
291
+ * 5) are skipped entirely — neither the legacy project policy nor the new
292
+ * project config is read or merged. This gates project-local config on project
293
+ * trust: an untrusted repository cannot loosen the operator's global policy
294
+ * (#644). It defaults to `true`, preserving the trusted / caller-agnostic path.
289
295
  */
290
296
  export function loadAndMergeConfigs(
291
297
  agentDir: string,
292
298
  cwd: string,
293
299
  extensionRoot: string,
300
+ options: { includeProjectScope?: boolean } = {},
294
301
  ): MergedConfigResult {
302
+ const includeProjectScope = options.includeProjectScope !== false;
295
303
  const allIssues: string[] = [];
296
304
 
297
305
  const newGlobalPath = getGlobalConfigPath(agentDir);
@@ -339,8 +347,8 @@ export function loadAndMergeConfigs(
339
347
  const globalConfig = globalResult.config;
340
348
  merged = mergeUnifiedConfigs(merged, globalConfig);
341
349
 
342
- // 4. Legacy project policy
343
- if (existsSync(legacyProjectPolicyPath)) {
350
+ // 4. Legacy project policy — skipped when the project scope is withheld.
351
+ if (includeProjectScope && existsSync(legacyProjectPolicyPath)) {
344
352
  const legacy = loadUnifiedConfig(legacyProjectPolicyPath);
345
353
  allIssues.push(
346
354
  `Legacy project policy found at '${legacyProjectPolicyPath}'. ` +
@@ -351,8 +359,11 @@ export function loadAndMergeConfigs(
351
359
  merged = mergeUnifiedConfigs(merged, legacy.config);
352
360
  }
353
361
 
354
- // 5. New project config
355
- const projectResult = loadUnifiedConfig(newProjectPath);
362
+ // 5. New project config — skipped when the project scope is withheld, so an
363
+ // untrusted project contributes nothing and `project` reports empty.
364
+ const projectResult = includeProjectScope
365
+ ? loadUnifiedConfig(newProjectPath)
366
+ : { config: {}, issues: [] };
356
367
  allIssues.push(...projectResult.issues);
357
368
  const projectConfig = projectResult.config;
358
369
  merged = mergeUnifiedConfigs(merged, projectConfig);
@@ -41,7 +41,7 @@ export interface ConfigReader {
41
41
  * coupling between the class and test doubles.
42
42
  */
43
43
  export interface SessionConfigStore extends ConfigReader {
44
- refresh(ctx?: ExtensionContext): void;
44
+ refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void;
45
45
  logResolvedPaths(cwd?: string): void;
46
46
  }
47
47
 
@@ -96,14 +96,17 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
96
96
  * Reload merged config from disk.
97
97
  *
98
98
  * If `ctx` is provided, uses it to derive the cwd and sync UI status.
99
- * Equivalent to `refreshExtensionConfig(runtime, ctx?)`.
99
+ * When `projectTrusted` is `false`, the project scope is withheld so an
100
+ * untrusted repository's runtime config (`yoloMode`, `permissionReviewLog`,
101
+ * …) cannot loosen the operator's global config (#644).
100
102
  */
101
- refresh(ctx?: ExtensionContext): void {
103
+ refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void {
102
104
  const cwd = ctx?.cwd ?? null;
103
105
  const mergeResult = loadAndMergeConfigs(
104
106
  this.deps.agentDir,
105
107
  cwd ?? "",
106
108
  EXTENSION_ROOT,
109
+ { includeProjectScope: projectTrusted },
107
110
  );
108
111
  const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
109
112
  this.config = runtimeConfig;
@@ -127,6 +130,7 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
127
130
  debugLog: runtimeConfig.debugLog,
128
131
  permissionReviewLog: runtimeConfig.permissionReviewLog,
129
132
  yoloMode: runtimeConfig.yoloMode,
133
+ projectTrusted,
130
134
  });
131
135
  }
132
136
 
@@ -62,7 +62,11 @@ export class AgentPrepHandler {
62
62
  // to whole-string matching.
63
63
  this.warmParser();
64
64
  this.session.activate(ctx);
65
- this.session.refreshConfig(ctx);
65
+ // Gate the mid-session runtime-config refresh on project trust too, so an
66
+ // untrusted project cannot slip its runtime config (e.g. `yoloMode`) in
67
+ // right before agent start after session_start withheld it (#644). The
68
+ // session_start handler already warned; do not re-warn on every start.
69
+ this.session.refreshConfig(ctx, ctx.isProjectTrusted());
66
70
 
67
71
  const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
68
72
  const activeTools = this.toolRegistry.getActive();
@@ -17,6 +17,15 @@ interface ResourcesDiscoverPayload {
17
17
  reason: string;
18
18
  }
19
19
 
20
+ /**
21
+ * Shown when project config is skipped because the project is untrusted, so the
22
+ * reduced-scope state is never silent (#644). Exported for assertion in tests.
23
+ */
24
+ export const UNTRUSTED_PROJECT_MESSAGE =
25
+ "pi-permission-system: project is not trusted — skipping project-scoped " +
26
+ "permission configuration. Only global policy applies. Grant project trust " +
27
+ "to load this project's permission rules.";
28
+
20
29
  /**
21
30
  * Handles session lifecycle events: start, reload, and shutdown.
22
31
  *
@@ -42,9 +51,13 @@ export class SessionLifecycleHandler {
42
51
  event: SessionStartPayload,
43
52
  ctx: ExtensionContext,
44
53
  ): Promise<void> {
45
- this.session.refreshConfig(ctx);
46
- this.session.resetForNewSession(ctx);
54
+ const projectTrusted = ctx.isProjectTrusted();
55
+ this.session.refreshConfig(ctx, projectTrusted);
56
+ this.session.resetForNewSession(ctx, projectTrusted);
47
57
  this.session.logResolvedConfigPaths();
58
+ if (!projectTrusted) {
59
+ this.warnProjectUntrusted(ctx, "session_start");
60
+ }
48
61
 
49
62
  const agentName = this.session.resolveAgentName(ctx);
50
63
  const policyIssues = this.resolver.getConfigIssues(agentName ?? undefined);
@@ -68,12 +81,19 @@ export class SessionLifecycleHandler {
68
81
  return Promise.resolve();
69
82
  }
70
83
 
71
- handleResourcesDiscover(event: ResourcesDiscoverPayload): Promise<void> {
84
+ handleResourcesDiscover(
85
+ event: ResourcesDiscoverPayload,
86
+ ctx: ExtensionContext,
87
+ ): Promise<void> {
72
88
  if (event.reason !== "reload") {
73
89
  return Promise.resolve();
74
90
  }
75
91
 
76
- this.session.reload();
92
+ const projectTrusted = ctx.isProjectTrusted();
93
+ this.session.reload(projectTrusted);
94
+ if (!projectTrusted) {
95
+ this.warnProjectUntrusted(ctx, "resources_discover");
96
+ }
77
97
  this.logger.debug("lifecycle.reload", {
78
98
  triggeredBy: "resources_discover",
79
99
  reason: event.reason,
@@ -82,6 +102,18 @@ export class SessionLifecycleHandler {
82
102
  return Promise.resolve();
83
103
  }
84
104
 
105
+ /**
106
+ * Record the project-trust skip in the review log and surface a loud warning
107
+ * to the user, so the reduced (global-only) scope is never silent (#644).
108
+ */
109
+ private warnProjectUntrusted(
110
+ ctx: ExtensionContext,
111
+ phase: "session_start" | "resources_discover",
112
+ ): void {
113
+ this.logger.review("project_trust.skipped", { cwd: ctx.cwd, phase });
114
+ this.logger.warn(UNTRUSTED_PROJECT_MESSAGE);
115
+ }
116
+
85
117
  handleSessionShutdown(): Promise<void> {
86
118
  const ctx = this.session.getRuntimeContext();
87
119
  if (ctx) {
package/src/index.ts CHANGED
@@ -171,7 +171,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
171
171
  // refresh() must run after `session` is assigned: a debug-write IO failure
172
172
  // triggers the logger's notify sink — `session.notify(m)` — which no-ops
173
173
  // on the null context but requires `session` to be bound.
174
- configStore.refresh();
174
+ // No ctx/trust decision exists at factory init, so withhold the project
175
+ // scope (fail closed); session_start reloads with the real trust decision.
176
+ configStore.refresh(undefined, false);
175
177
 
176
178
  const configPath = getGlobalConfigPath(agentDir);
177
179
  registerPermissionSystemCommand(pi, {
@@ -258,8 +260,8 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
258
260
  pi.on("session_start", (event, ctx) =>
259
261
  lifecycle.handleSessionStart(event, ctx),
260
262
  );
261
- pi.on("resources_discover", (event) =>
262
- lifecycle.handleResourcesDiscover(event),
263
+ pi.on("resources_discover", (event, ctx) =>
264
+ lifecycle.handleResourcesDiscover(event, ctx),
263
265
  );
264
266
  pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
265
267
  pi.on("before_agent_start", (event, ctx) => agentPrep.handle(event, ctx));
@@ -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
  }
@@ -99,11 +99,15 @@ export class PermissionSession implements ToolCallGateInputs {
99
99
  /**
100
100
  * Reset all mutable state for a new session.
101
101
  *
102
- * Configures the injected PermissionManager for `ctx.cwd`, clears skill
102
+ * Configures the injected PermissionManager for `ctx.cwd` (or global-only
103
+ * when `projectTrusted` is `false`, withholding the project cwd so an
104
+ * untrusted project's policy scopes are not loaded, #644), clears skill
103
105
  * entries, and activates the new context.
104
106
  */
105
- resetForNewSession(ctx: ExtensionContext): void {
106
- this.permissionManager.configureForCwd(ctx.cwd);
107
+ resetForNewSession(ctx: ExtensionContext, projectTrusted: boolean): void {
108
+ this.permissionManager.configureForCwd(
109
+ projectTrusted ? ctx.cwd : undefined,
110
+ );
107
111
  this.skillEntries = [];
108
112
  this.activate(ctx);
109
113
  }
@@ -121,9 +125,15 @@ export class PermissionSession implements ToolCallGateInputs {
121
125
  /**
122
126
  * Reload permission manager and clear skill entries for the current context.
123
127
  * Used on config reload (e.g. `resources_discover` with reason "reload").
128
+ *
129
+ * When `projectTrusted` is `false` the project cwd is withheld, so a reload
130
+ * in an untrusted project reloads only global policy; a trust grant on a
131
+ * later reload re-includes the project scope (#644).
124
132
  */
125
- reload(): void {
126
- this.permissionManager.configureForCwd(this.context?.cwd);
133
+ reload(projectTrusted: boolean): void {
134
+ this.permissionManager.configureForCwd(
135
+ projectTrusted ? this.context?.cwd : undefined,
136
+ );
127
137
  this.skillEntries = [];
128
138
  }
129
139
 
@@ -168,9 +178,16 @@ export class PermissionSession implements ToolCallGateInputs {
168
178
 
169
179
  // ── Config ─────────────────────────────────────────────────────────────
170
180
 
171
- /** Reload merged config from disk; optionally update the stored runtime context. */
172
- refreshConfig(ctx?: ExtensionContext): void {
173
- this.configStore.refresh(ctx);
181
+ /**
182
+ * Reload merged config from disk; optionally update the stored runtime
183
+ * context. When `projectTrusted` is `false`, the project scope is withheld
184
+ * so an untrusted project's runtime config is not merged (#644).
185
+ */
186
+ refreshConfig(
187
+ ctx: ExtensionContext | undefined,
188
+ projectTrusted: boolean,
189
+ ): void {
190
+ this.configStore.refresh(ctx, projectTrusted);
174
191
  }
175
192
 
176
193
  /** Write the resolved config path set to the review and debug logs. */
@@ -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/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
  /**