@gotgenes/pi-permission-system 21.0.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,27 @@ 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
+
8
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)
9
30
 
10
31
 
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 `*`.
@@ -140,6 +148,7 @@ If you relied on the old permissive behavior for bash, set an explicit permissiv
140
148
  | [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
141
149
  | [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout |
142
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
 
@@ -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.
@@ -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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "21.0.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));
@@ -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. */