@gotgenes/pi-permission-system 10.1.0 → 10.3.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 +14 -0
- package/package.json +1 -1
- package/src/config-modal.ts +7 -10
- package/src/config-store.ts +243 -0
- package/src/index.ts +11 -15
- package/src/permission-manager.ts +69 -3
- package/src/permission-prompter.ts +3 -3
- package/src/permission-session.ts +13 -28
- package/src/runtime.ts +34 -203
- package/test/config-modal.test.ts +15 -10
- package/test/config-store.test.ts +452 -0
- package/test/handlers/external-directory-integration.test.ts +81 -176
- package/test/handlers/gates/bash-path.test.ts +26 -44
- package/test/handlers/gates/runner.test.ts +27 -119
- package/test/handlers/tool-call.test.ts +44 -153
- package/test/helpers/gate-fixtures.ts +66 -2
- package/test/helpers/handler-fixtures.ts +83 -2
- package/test/permission-manager-unified.test.ts +159 -1
- package/test/permission-prompter.test.ts +12 -5
- package/test/permission-session.test.ts +111 -120
- package/test/runtime.test.ts +11 -275
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ 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
|
+
## [10.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.2.0...pi-permission-system-v10.3.0) (2026-06-05)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add ConfigStore owning extension config state ([5941733](https://github.com/gotgenes/pi-packages/commit/5941733a67c0ad9aef3d3b2e5908a82e76ac8603))
|
|
14
|
+
|
|
15
|
+
## [10.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.1.0...pi-permission-system-v10.2.0) (2026-06-04)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* add PermissionManager.configureForCwd and agentDir option ([5a2d363](https://github.com/gotgenes/pi-packages/commit/5a2d3634a0b8466a5d6aa8baa170a9bf53e068fb))
|
|
21
|
+
|
|
8
22
|
## [10.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.0.0...pi-permission-system-v10.1.0) (2026-06-03)
|
|
9
23
|
|
|
10
24
|
|
package/package.json
CHANGED
package/src/config-modal.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { type SettingItem, SettingsList } from "@earendil-works/pi-tui";
|
|
7
7
|
|
|
8
|
+
import type { CommandConfigStore } from "./config-store";
|
|
8
9
|
import {
|
|
9
10
|
DEFAULT_EXTENSION_CONFIG,
|
|
10
11
|
type PermissionSystemExtensionConfig,
|
|
@@ -12,11 +13,7 @@ import {
|
|
|
12
13
|
import type { Ruleset } from "./rule";
|
|
13
14
|
|
|
14
15
|
interface PermissionSystemConfigController {
|
|
15
|
-
|
|
16
|
-
setConfig(
|
|
17
|
-
next: PermissionSystemExtensionConfig,
|
|
18
|
-
ctx: ExtensionCommandContext,
|
|
19
|
-
): void;
|
|
16
|
+
config: CommandConfigStore;
|
|
20
17
|
getConfigPath(): string;
|
|
21
18
|
/** Optional: returns the composed config-layer ruleset for origin display. */
|
|
22
19
|
getComposedRules?(): Ruleset;
|
|
@@ -175,15 +172,15 @@ async function openSettingsModal(
|
|
|
175
172
|
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- ctx.ui.custom<void> is valid; rule does not allow void in generic fn call type args
|
|
176
173
|
await ctx.ui.custom<void>(
|
|
177
174
|
(_tui, _theme, _keybindings, done) => {
|
|
178
|
-
let current = controller.
|
|
175
|
+
let current = controller.config.current();
|
|
179
176
|
const settingsList = new SettingsList(
|
|
180
177
|
buildSettingItems(current),
|
|
181
178
|
10,
|
|
182
179
|
getSettingsListTheme(),
|
|
183
180
|
(id, newValue) => {
|
|
184
181
|
current = applySetting(current, id, newValue);
|
|
185
|
-
controller.
|
|
186
|
-
current = controller.
|
|
182
|
+
controller.config.save(current, ctx);
|
|
183
|
+
current = controller.config.current();
|
|
187
184
|
syncSettingValues(settingsList, current);
|
|
188
185
|
},
|
|
189
186
|
() => done(),
|
|
@@ -208,7 +205,7 @@ function handleArgs(
|
|
|
208
205
|
if (normalized === "show") {
|
|
209
206
|
const rules = controller.getComposedRules?.();
|
|
210
207
|
ctx.ui.notify(
|
|
211
|
-
`permission-system: ${summarizeConfig(controller.
|
|
208
|
+
`permission-system: ${summarizeConfig(controller.config.current(), rules)}`,
|
|
212
209
|
"info",
|
|
213
210
|
);
|
|
214
211
|
return true;
|
|
@@ -223,7 +220,7 @@ function handleArgs(
|
|
|
223
220
|
}
|
|
224
221
|
|
|
225
222
|
if (normalized === "reset") {
|
|
226
|
-
controller.
|
|
223
|
+
controller.config.save(cloneDefaultConfig(), ctx);
|
|
227
224
|
ctx.ui.notify("Permission system settings reset to defaults.", "info");
|
|
228
225
|
return true;
|
|
229
226
|
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
renameSync,
|
|
5
|
+
unlinkSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { dirname, normalize } from "node:path";
|
|
9
|
+
import type {
|
|
10
|
+
ExtensionCommandContext,
|
|
11
|
+
ExtensionContext,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
|
|
15
|
+
import {
|
|
16
|
+
getGlobalConfigPath,
|
|
17
|
+
getLegacyExtensionConfigPath,
|
|
18
|
+
getLegacyGlobalPolicyPath,
|
|
19
|
+
getLegacyProjectPolicyPath,
|
|
20
|
+
} from "./config-paths";
|
|
21
|
+
import { buildResolvedConfigLogEntry } from "./config-reporter";
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_EXTENSION_CONFIG,
|
|
24
|
+
EXTENSION_ROOT,
|
|
25
|
+
normalizePermissionSystemConfig,
|
|
26
|
+
type PermissionSystemExtensionConfig,
|
|
27
|
+
} from "./extension-config";
|
|
28
|
+
import type { ResolvedPolicyPaths } from "./policy-loader";
|
|
29
|
+
import { syncPermissionSystemStatus } from "./status";
|
|
30
|
+
|
|
31
|
+
/** Read-only view of the current config — for consumers that only read. */
|
|
32
|
+
export interface ConfigReader {
|
|
33
|
+
current(): PermissionSystemExtensionConfig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Narrow subset of `ConfigStore` that `PermissionSession` depends on.
|
|
38
|
+
*
|
|
39
|
+
* Using an interface rather than the concrete class avoids private-member
|
|
40
|
+
* coupling between the class and test doubles.
|
|
41
|
+
*/
|
|
42
|
+
export interface SessionConfigStore extends ConfigReader {
|
|
43
|
+
refresh(ctx?: ExtensionContext): void;
|
|
44
|
+
logResolvedPaths(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Narrow subset of `ConfigStore` for the `/permission-system` command.
|
|
49
|
+
*
|
|
50
|
+
* Using an interface rather than the concrete class avoids private-member
|
|
51
|
+
* coupling between the class and test doubles.
|
|
52
|
+
*/
|
|
53
|
+
export interface CommandConfigStore extends ConfigReader {
|
|
54
|
+
save(
|
|
55
|
+
next: PermissionSystemExtensionConfig,
|
|
56
|
+
ctx: ExtensionCommandContext,
|
|
57
|
+
): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Transitional get/set seam over the runtime-owned context.
|
|
62
|
+
*
|
|
63
|
+
* Retired in Step 4 (#337) when context ownership moves to `PermissionSession`.
|
|
64
|
+
*/
|
|
65
|
+
export interface RuntimeContextRef {
|
|
66
|
+
get(): ExtensionContext | null;
|
|
67
|
+
set(ctx: ExtensionContext): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Narrow logging sink — replaced by an injected logger in Step 3 (#336). */
|
|
71
|
+
export interface ConfigStoreLogger {
|
|
72
|
+
writeDebugLog(event: string, details?: Record<string, unknown>): void;
|
|
73
|
+
writeReviewLog(event: string, details?: Record<string, unknown>): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Narrow view of the manager's resolved policy paths (for `logResolvedPaths`). */
|
|
77
|
+
export interface ResolvedPolicyPathProvider {
|
|
78
|
+
getResolvedPolicyPaths(): ResolvedPolicyPaths;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ConfigStoreDeps {
|
|
82
|
+
agentDir: string;
|
|
83
|
+
context: RuntimeContextRef;
|
|
84
|
+
policyPaths: ResolvedPolicyPathProvider;
|
|
85
|
+
logger: ConfigStoreLogger;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Owns the mutable extension config and the operations that read/write it.
|
|
90
|
+
*
|
|
91
|
+
* Replaces the three `(runtime, …)` config free functions
|
|
92
|
+
* (`refreshExtensionConfig`, `saveExtensionConfig`, `logResolvedConfigPaths`)
|
|
93
|
+
* with methods that privately own `config` and `lastConfigWarning`.
|
|
94
|
+
*
|
|
95
|
+
* Implements {@link ConfigReader} so consumers that only read the current config
|
|
96
|
+
* can depend on the narrow interface rather than the full class.
|
|
97
|
+
*/
|
|
98
|
+
export class ConfigStore implements SessionConfigStore, CommandConfigStore {
|
|
99
|
+
private config: PermissionSystemExtensionConfig;
|
|
100
|
+
private lastConfigWarning: string | null = null;
|
|
101
|
+
|
|
102
|
+
constructor(private readonly deps: ConfigStoreDeps) {
|
|
103
|
+
this.config = { ...DEFAULT_EXTENSION_CONFIG };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Return the current extension config. */
|
|
107
|
+
current(): PermissionSystemExtensionConfig {
|
|
108
|
+
return this.config;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Reload merged config from disk.
|
|
113
|
+
*
|
|
114
|
+
* If `ctx` is provided, updates the stored runtime context via the seam first.
|
|
115
|
+
* Equivalent to `refreshExtensionConfig(runtime, ctx?)`.
|
|
116
|
+
*/
|
|
117
|
+
refresh(ctx?: ExtensionContext): void {
|
|
118
|
+
if (ctx) {
|
|
119
|
+
this.deps.context.set(ctx);
|
|
120
|
+
}
|
|
121
|
+
const cwd = this.deps.context.get()?.cwd ?? null;
|
|
122
|
+
const mergeResult = loadAndMergeConfigs(
|
|
123
|
+
this.deps.agentDir,
|
|
124
|
+
cwd ?? "",
|
|
125
|
+
EXTENSION_ROOT,
|
|
126
|
+
);
|
|
127
|
+
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
|
|
128
|
+
this.config = runtimeConfig;
|
|
129
|
+
|
|
130
|
+
const currentCtx = this.deps.context.get();
|
|
131
|
+
if (currentCtx?.hasUI) {
|
|
132
|
+
syncPermissionSystemStatus(currentCtx, runtimeConfig);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const warning =
|
|
136
|
+
mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
|
|
137
|
+
|
|
138
|
+
if (warning && warning !== this.lastConfigWarning) {
|
|
139
|
+
this.lastConfigWarning = warning;
|
|
140
|
+
currentCtx?.ui.notify(warning, "warning");
|
|
141
|
+
} else if (!warning) {
|
|
142
|
+
this.lastConfigWarning = null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.deps.logger.writeDebugLog("config.loaded", {
|
|
146
|
+
warning: warning ?? null,
|
|
147
|
+
debugLog: runtimeConfig.debugLog,
|
|
148
|
+
permissionReviewLog: runtimeConfig.permissionReviewLog,
|
|
149
|
+
yoloMode: runtimeConfig.yoloMode,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Save updated runtime knobs to the global config file, then update
|
|
155
|
+
* the current config and sync UI status.
|
|
156
|
+
*
|
|
157
|
+
* Equivalent to `saveExtensionConfig(runtime, next, ctx)`.
|
|
158
|
+
*/
|
|
159
|
+
// Called via the CommandConfigStore interface from config-modal.ts — fallow cannot trace through interfaces.
|
|
160
|
+
// fallow-ignore-next-line unused-class-member
|
|
161
|
+
save(
|
|
162
|
+
next: PermissionSystemExtensionConfig,
|
|
163
|
+
ctx: ExtensionCommandContext,
|
|
164
|
+
): void {
|
|
165
|
+
const normalized = normalizePermissionSystemConfig(next);
|
|
166
|
+
const globalPath = getGlobalConfigPath(this.deps.agentDir);
|
|
167
|
+
|
|
168
|
+
const existing = loadUnifiedConfig(globalPath);
|
|
169
|
+
const merged = {
|
|
170
|
+
...existing.config,
|
|
171
|
+
debugLog: normalized.debugLog,
|
|
172
|
+
permissionReviewLog: normalized.permissionReviewLog,
|
|
173
|
+
yoloMode: normalized.yoloMode,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const tmpPath = `${globalPath}.tmp`;
|
|
177
|
+
try {
|
|
178
|
+
mkdirSync(dirname(globalPath), { recursive: true });
|
|
179
|
+
writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
|
|
180
|
+
renameSync(tmpPath, globalPath);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
try {
|
|
183
|
+
if (existsSync(tmpPath)) {
|
|
184
|
+
unlinkSync(tmpPath);
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Ignore cleanup failures.
|
|
188
|
+
}
|
|
189
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
190
|
+
ctx.ui.notify(
|
|
191
|
+
`Failed to save permission-system config at '${globalPath}': ${message}`,
|
|
192
|
+
"error",
|
|
193
|
+
);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.config = normalized;
|
|
198
|
+
syncPermissionSystemStatus(ctx, normalized);
|
|
199
|
+
this.lastConfigWarning = null;
|
|
200
|
+
|
|
201
|
+
this.deps.logger.writeDebugLog("config.saved", {
|
|
202
|
+
debugLog: normalized.debugLog,
|
|
203
|
+
permissionReviewLog: normalized.permissionReviewLog,
|
|
204
|
+
yoloMode: normalized.yoloMode,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Write the resolved config path set to the review and debug logs.
|
|
210
|
+
*
|
|
211
|
+
* Equivalent to `logResolvedConfigPaths(runtime)`.
|
|
212
|
+
*/
|
|
213
|
+
logResolvedPaths(): void {
|
|
214
|
+
const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths();
|
|
215
|
+
const cwd = this.deps.context.get()?.cwd ?? null;
|
|
216
|
+
const { agentDir } = this.deps;
|
|
217
|
+
const legacyGlobalPolicyDetected = existsSync(
|
|
218
|
+
getLegacyGlobalPolicyPath(agentDir),
|
|
219
|
+
);
|
|
220
|
+
const legacyProjectPolicyDetected = cwd
|
|
221
|
+
? existsSync(getLegacyProjectPolicyPath(cwd))
|
|
222
|
+
: false;
|
|
223
|
+
const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
|
|
224
|
+
const newGlobalPath = getGlobalConfigPath(agentDir);
|
|
225
|
+
const legacyExtensionConfigDetected =
|
|
226
|
+
normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
|
|
227
|
+
existsSync(legacyExtConfigPath);
|
|
228
|
+
const entry = buildResolvedConfigLogEntry({
|
|
229
|
+
policyPaths,
|
|
230
|
+
legacyGlobalPolicyDetected,
|
|
231
|
+
legacyProjectPolicyDetected,
|
|
232
|
+
legacyExtensionConfigDetected,
|
|
233
|
+
});
|
|
234
|
+
this.deps.logger.writeReviewLog(
|
|
235
|
+
"config.resolved",
|
|
236
|
+
entry as unknown as Record<string, unknown>,
|
|
237
|
+
);
|
|
238
|
+
this.deps.logger.writeDebugLog(
|
|
239
|
+
"config.resolved",
|
|
240
|
+
entry as unknown as Record<string, unknown>,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,15 +18,11 @@ import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeli
|
|
|
18
18
|
import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
|
|
19
19
|
import { requestPermissionDecisionFromUi } from "./permission-dialog";
|
|
20
20
|
import { registerPermissionRpcHandlers } from "./permission-event-rpc";
|
|
21
|
+
import { PermissionManager } from "./permission-manager";
|
|
21
22
|
import { PermissionPrompter } from "./permission-prompter";
|
|
22
23
|
import { PermissionSession } from "./permission-session";
|
|
23
24
|
import { LocalPermissionsService } from "./permissions-service";
|
|
24
|
-
import {
|
|
25
|
-
createExtensionRuntime,
|
|
26
|
-
logResolvedConfigPaths,
|
|
27
|
-
refreshExtensionConfig,
|
|
28
|
-
saveExtensionConfig,
|
|
29
|
-
} from "./runtime";
|
|
25
|
+
import { createExtensionRuntime } from "./runtime";
|
|
30
26
|
import { PermissionServiceLifecycle } from "./service-lifecycle";
|
|
31
27
|
import { createSessionLogger } from "./session-logger";
|
|
32
28
|
import { isSubagentExecutionContext } from "./subagent-context";
|
|
@@ -56,18 +52,20 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
56
52
|
writeReviewLog: runtime.writeReviewLog.bind(runtime),
|
|
57
53
|
requestPermissionDecisionFromUi,
|
|
58
54
|
shouldAutoApprove: () =>
|
|
59
|
-
shouldAutoApprovePermissionState("ask", runtime.
|
|
55
|
+
shouldAutoApprovePermissionState("ask", runtime.configStore.current()),
|
|
60
56
|
};
|
|
61
57
|
const forwarder = new PermissionForwarder(forwardingDeps);
|
|
62
58
|
|
|
63
59
|
const prompter = new PermissionPrompter({
|
|
64
|
-
|
|
60
|
+
config: runtime.configStore,
|
|
65
61
|
writeReviewLog: runtime.writeReviewLog.bind(runtime),
|
|
66
62
|
events: pi.events,
|
|
67
63
|
forwarder,
|
|
68
64
|
});
|
|
69
65
|
|
|
70
|
-
|
|
66
|
+
runtime.configStore.refresh();
|
|
67
|
+
|
|
68
|
+
const sessionManager = new PermissionManager({ agentDir: runtime.agentDir });
|
|
71
69
|
|
|
72
70
|
const session = new PermissionSession(
|
|
73
71
|
runtime,
|
|
@@ -77,13 +75,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
77
75
|
forwarder,
|
|
78
76
|
subagentRegistry,
|
|
79
77
|
),
|
|
78
|
+
sessionManager,
|
|
79
|
+
runtime.configStore,
|
|
80
80
|
{
|
|
81
|
-
refreshExtensionConfig: (ctx) => refreshExtensionConfig(runtime, ctx),
|
|
82
|
-
logResolvedConfigPaths: () => logResolvedConfigPaths(runtime),
|
|
83
|
-
getConfig: () => runtime.config,
|
|
84
81
|
canRequestPermissionConfirmation: (ctx) =>
|
|
85
82
|
canResolveAskPermissionRequest({
|
|
86
|
-
config: runtime.
|
|
83
|
+
config: runtime.configStore.current(),
|
|
87
84
|
hasUI: ctx.hasUI,
|
|
88
85
|
isSubagent: isSubagentExecutionContext(
|
|
89
86
|
ctx,
|
|
@@ -96,8 +93,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
96
93
|
);
|
|
97
94
|
|
|
98
95
|
registerPermissionSystemCommand(pi, {
|
|
99
|
-
|
|
100
|
-
setConfig: (next, ctx) => saveExtensionConfig(runtime, next, ctx),
|
|
96
|
+
config: runtime.configStore,
|
|
101
97
|
getConfigPath: () => getGlobalConfigPath(runtime.agentDir),
|
|
102
98
|
getComposedRules: () =>
|
|
103
99
|
runtime.permissionManager.getComposedConfigRules(
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { isPermissionState } from "./common";
|
|
3
|
+
import { getGlobalConfigPath, getProjectConfigPath } from "./config-paths";
|
|
2
4
|
import { normalizeInput } from "./input-normalizer";
|
|
3
5
|
import { normalizeFlatConfig } from "./normalize";
|
|
4
6
|
import {
|
|
@@ -48,19 +50,66 @@ type ResolvedPermissions = {
|
|
|
48
50
|
composedRules: Ruleset;
|
|
49
51
|
};
|
|
50
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Narrow interface for session-scoped permission checking.
|
|
55
|
+
* `PermissionSession` depends on this — not the full concrete class — so
|
|
56
|
+
* test mocks can satisfy it without an `as unknown as PermissionManager` cast.
|
|
57
|
+
*/
|
|
58
|
+
export interface ScopedPermissionManager {
|
|
59
|
+
configureForCwd(cwd: string | undefined | null): void;
|
|
60
|
+
checkPermission(
|
|
61
|
+
toolName: string,
|
|
62
|
+
input: unknown,
|
|
63
|
+
agentName?: string,
|
|
64
|
+
sessionRules?: Ruleset,
|
|
65
|
+
): PermissionCheckResult;
|
|
66
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
67
|
+
getConfigIssues(agentName?: string): string[];
|
|
68
|
+
getPolicyCacheStamp(agentName?: string): string;
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
52
72
|
policyLoader?: PolicyLoader;
|
|
73
|
+
/**
|
|
74
|
+
* Pi agent directory. When provided, the manager derives all loader paths
|
|
75
|
+
* from this value and supports {@link PermissionManager.configureForCwd}.
|
|
76
|
+
*/
|
|
77
|
+
agentDir?: string;
|
|
53
78
|
}
|
|
54
79
|
|
|
55
|
-
export class PermissionManager {
|
|
56
|
-
private readonly
|
|
80
|
+
export class PermissionManager implements ScopedPermissionManager {
|
|
81
|
+
private readonly agentDir: string | undefined;
|
|
82
|
+
private loader: PolicyLoader;
|
|
57
83
|
private readonly resolvedPermissionsCache = new Map<
|
|
58
84
|
string,
|
|
59
85
|
FileCacheEntry<ResolvedPermissions>
|
|
60
86
|
>();
|
|
61
87
|
|
|
62
88
|
constructor(options: PermissionManagerOptions = {}) {
|
|
63
|
-
this.
|
|
89
|
+
this.agentDir = options.agentDir;
|
|
90
|
+
this.loader =
|
|
91
|
+
options.policyLoader ??
|
|
92
|
+
new FilePolicyLoader(
|
|
93
|
+
options.agentDir !== undefined
|
|
94
|
+
? derivePolicyLoaderOptions(options.agentDir, undefined)
|
|
95
|
+
: options,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Rebuild the policy loader for a new working directory and clear the
|
|
101
|
+
* resolved-permissions cache.
|
|
102
|
+
*
|
|
103
|
+
* When `agentDir` was not provided at construction (e.g. test managers
|
|
104
|
+
* built with explicit paths), only the cache is cleared.
|
|
105
|
+
*/
|
|
106
|
+
configureForCwd(cwd: string | undefined | null): void {
|
|
107
|
+
if (this.agentDir !== undefined) {
|
|
108
|
+
this.loader = new FilePolicyLoader(
|
|
109
|
+
derivePolicyLoaderOptions(this.agentDir, cwd),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
this.resolvedPermissionsCache.clear();
|
|
64
113
|
}
|
|
65
114
|
|
|
66
115
|
getConfigIssues(agentName?: string): string[] {
|
|
@@ -219,6 +268,23 @@ export class PermissionManager {
|
|
|
219
268
|
}
|
|
220
269
|
}
|
|
221
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Derive `PolicyLoaderOptions` from an agentDir + an optional cwd.
|
|
273
|
+
* Setting agentsDir explicitly from agentDir removes the hidden
|
|
274
|
+
* `getAgentDir()` env-read that FilePolicyLoader's default would perform.
|
|
275
|
+
*/
|
|
276
|
+
function derivePolicyLoaderOptions(
|
|
277
|
+
agentDir: string,
|
|
278
|
+
cwd: string | undefined | null,
|
|
279
|
+
): PolicyLoaderOptions {
|
|
280
|
+
return {
|
|
281
|
+
globalConfigPath: getGlobalConfigPath(agentDir),
|
|
282
|
+
agentsDir: join(agentDir, "agents"),
|
|
283
|
+
projectGlobalConfigPath: cwd ? getProjectConfigPath(cwd) : undefined,
|
|
284
|
+
projectAgentsDir: cwd ? join(cwd, ".pi", "agent", "agents") : undefined,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
222
288
|
/**
|
|
223
289
|
* Map a matched rule + tool name to the correct PermissionCheckResult.source.
|
|
224
290
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ConfigReader } from "./config-store";
|
|
3
3
|
import type { ApprovalRequester } from "./forwarded-permissions/permission-forwarder";
|
|
4
4
|
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
5
5
|
import {
|
|
@@ -45,7 +45,7 @@ export interface PermissionPrompterApi {
|
|
|
45
45
|
*/
|
|
46
46
|
export interface PermissionPrompterDeps {
|
|
47
47
|
/** Read current config for yolo-mode check (called at prompt time). */
|
|
48
|
-
|
|
48
|
+
config: ConfigReader;
|
|
49
49
|
/** Write structured entries to the permission review log. */
|
|
50
50
|
writeReviewLog(event: string, details: Record<string, unknown>): void;
|
|
51
51
|
/** Event bus used for UI prompt broadcasts. */
|
|
@@ -72,7 +72,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
72
72
|
ctx: ExtensionContext,
|
|
73
73
|
details: PromptPermissionDetails,
|
|
74
74
|
): Promise<PermissionPromptDecision> {
|
|
75
|
-
if (shouldAutoApprovePermissionState("ask", this.deps.
|
|
75
|
+
if (shouldAutoApprovePermissionState("ask", this.deps.config.current())) {
|
|
76
76
|
this.writeReviewEntry("permission_request.auto_approved", details);
|
|
77
77
|
return { approved: true, state: "approved", autoApproved: true };
|
|
78
78
|
}
|
|
@@ -5,17 +5,17 @@ import {
|
|
|
5
5
|
getActiveAgentNameFromSystemPrompt,
|
|
6
6
|
} from "./active-agent";
|
|
7
7
|
import type { AgentPrepSession } from "./agent-prep-session";
|
|
8
|
+
import type { SessionConfigStore } from "./config-store";
|
|
8
9
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
9
10
|
import type { ExtensionPaths } from "./extension-paths";
|
|
10
11
|
import type { ForwardingController } from "./forwarding-manager";
|
|
11
12
|
import type { GateHandlerSession } from "./gate-handler-session";
|
|
12
13
|
import type { GatePrompter } from "./gate-prompter";
|
|
13
14
|
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
14
|
-
import type {
|
|
15
|
+
import type { ScopedPermissionManager } from "./permission-manager";
|
|
15
16
|
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
16
17
|
import type { PermissionResolver } from "./permission-resolver";
|
|
17
18
|
import type { Rule } from "./rule";
|
|
18
|
-
import { createPermissionManagerForCwd } from "./runtime";
|
|
19
19
|
import type { SessionApproval } from "./session-approval";
|
|
20
20
|
import type { SessionApprovalRecorder } from "./session-approval-recorder";
|
|
21
21
|
import type { SessionLifecycleSession } from "./session-lifecycle-session";
|
|
@@ -35,12 +35,6 @@ import type { PermissionCheckResult, PermissionState } from "./types";
|
|
|
35
35
|
* where the `ExtensionRuntime` is available.
|
|
36
36
|
*/
|
|
37
37
|
export interface PermissionSessionRuntimeDeps {
|
|
38
|
-
/** Reload merged config from disk; optionally update the stored runtime context. */
|
|
39
|
-
refreshExtensionConfig(ctx?: ExtensionContext): void;
|
|
40
|
-
/** Write the resolved config path set to the review and debug logs. */
|
|
41
|
-
logResolvedConfigPaths(): void;
|
|
42
|
-
/** Read current extension config (called at query time). */
|
|
43
|
-
getConfig(): PermissionSystemExtensionConfig;
|
|
44
38
|
/** Whether the current context can show an interactive permission prompt. */
|
|
45
39
|
canRequestPermissionConfirmation(ctx: ExtensionContext): boolean;
|
|
46
40
|
/** Prompt the user for a permission decision, log the outcome, and return it. */
|
|
@@ -62,7 +56,8 @@ export interface PermissionSessionRuntimeDeps {
|
|
|
62
56
|
* - `ExtensionPaths` — immutable path constants
|
|
63
57
|
* - `SessionLogger` — debug + review + warn
|
|
64
58
|
* - `ForwardingController` — polling lifecycle
|
|
65
|
-
* - `
|
|
59
|
+
* - `SessionConfigStore` — owns extension config; provides refresh, log, read
|
|
60
|
+
* - `PermissionSessionRuntimeDeps` — prompting + permission-confirmation bridge
|
|
66
61
|
*/
|
|
67
62
|
export class PermissionSession
|
|
68
63
|
implements
|
|
@@ -74,7 +69,6 @@ export class PermissionSession
|
|
|
74
69
|
SessionLifecycleSession
|
|
75
70
|
{
|
|
76
71
|
private context: ExtensionContext | null = null;
|
|
77
|
-
private permissionManager: PermissionManager;
|
|
78
72
|
private readonly sessionRules = new SessionRules();
|
|
79
73
|
private skillEntries: SkillPromptEntry[] = [];
|
|
80
74
|
private knownAgentName: string | null = null;
|
|
@@ -85,13 +79,10 @@ export class PermissionSession
|
|
|
85
79
|
private readonly paths: ExtensionPaths,
|
|
86
80
|
readonly logger: SessionLogger,
|
|
87
81
|
private readonly forwarding: ForwardingController,
|
|
82
|
+
private readonly permissionManager: ScopedPermissionManager,
|
|
83
|
+
private readonly configStore: SessionConfigStore,
|
|
88
84
|
private readonly runtimeDeps: PermissionSessionRuntimeDeps,
|
|
89
|
-
) {
|
|
90
|
-
this.permissionManager = createPermissionManagerForCwd(
|
|
91
|
-
paths.agentDir,
|
|
92
|
-
undefined,
|
|
93
|
-
);
|
|
94
|
-
}
|
|
85
|
+
) {}
|
|
95
86
|
|
|
96
87
|
// ── Context lifecycle ──────────────────────────────────────────────────
|
|
97
88
|
|
|
@@ -173,14 +164,11 @@ export class PermissionSession
|
|
|
173
164
|
/**
|
|
174
165
|
* Reset all mutable state for a new session.
|
|
175
166
|
*
|
|
176
|
-
*
|
|
167
|
+
* Configures the injected PermissionManager for `ctx.cwd`, clears caches,
|
|
177
168
|
* skill entries, and activates the new context.
|
|
178
169
|
*/
|
|
179
170
|
resetForNewSession(ctx: ExtensionContext): void {
|
|
180
|
-
this.permissionManager
|
|
181
|
-
this.paths.agentDir,
|
|
182
|
-
ctx.cwd,
|
|
183
|
-
);
|
|
171
|
+
this.permissionManager.configureForCwd(ctx.cwd);
|
|
184
172
|
this.skillEntries = [];
|
|
185
173
|
this.toolsCacheKey = null;
|
|
186
174
|
this.promptCacheKey = null;
|
|
@@ -204,10 +192,7 @@ export class PermissionSession
|
|
|
204
192
|
* Used on config reload (e.g. `resources_discover` with reason "reload").
|
|
205
193
|
*/
|
|
206
194
|
reload(): void {
|
|
207
|
-
this.permissionManager
|
|
208
|
-
this.paths.agentDir,
|
|
209
|
-
this.context?.cwd,
|
|
210
|
-
);
|
|
195
|
+
this.permissionManager.configureForCwd(this.context?.cwd);
|
|
211
196
|
this.skillEntries = [];
|
|
212
197
|
this.toolsCacheKey = null;
|
|
213
198
|
this.promptCacheKey = null;
|
|
@@ -272,17 +257,17 @@ export class PermissionSession
|
|
|
272
257
|
|
|
273
258
|
/** Reload merged config from disk; optionally update the stored runtime context. */
|
|
274
259
|
refreshConfig(ctx?: ExtensionContext): void {
|
|
275
|
-
this.
|
|
260
|
+
this.configStore.refresh(ctx);
|
|
276
261
|
}
|
|
277
262
|
|
|
278
263
|
/** Write the resolved config path set to the review and debug logs. */
|
|
279
264
|
logResolvedConfigPaths(): void {
|
|
280
|
-
this.
|
|
265
|
+
this.configStore.logResolvedPaths();
|
|
281
266
|
}
|
|
282
267
|
|
|
283
268
|
/** Read current extension config. */
|
|
284
269
|
get config(): PermissionSystemExtensionConfig {
|
|
285
|
-
return this.
|
|
270
|
+
return this.configStore.current();
|
|
286
271
|
}
|
|
287
272
|
|
|
288
273
|
// ── Infrastructure paths ───────────────────────────────────────────────
|