@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 +43 -0
- package/README.md +20 -11
- package/dist/public.d.ts +4 -2
- package/docs/configuration.md +14 -0
- package/docs/migration/0644-project-trust-gating.md +32 -0
- package/docs/migration/strict-config-validation.md +15 -1
- package/package.json +1 -1
- package/src/config-loader.ts +15 -4
- package/src/config-store.ts +7 -3
- package/src/handlers/before-agent-start.ts +5 -1
- package/src/handlers/lifecycle.ts +36 -4
- package/src/index.ts +5 -3
- package/src/permission-manager.ts +36 -3
- package/src/permission-session.ts +25 -8
- 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,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) —
|
|
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.
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
package/src/config-loader.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/config-store.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
this.session.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
-
/**
|
|
172
|
-
|
|
173
|
-
|
|
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. */
|
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
|
/**
|