@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/src/runtime.ts
CHANGED
|
@@ -1,35 +1,12 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import {
|
|
2
|
-
existsSync,
|
|
3
|
-
mkdirSync,
|
|
4
|
-
renameSync,
|
|
5
|
-
unlinkSync,
|
|
6
|
-
writeFileSync,
|
|
7
|
-
} from "node:fs";
|
|
8
|
-
import { dirname, join, normalize } from "node:path";
|
|
9
|
-
import {
|
|
10
|
-
type ExtensionCommandContext,
|
|
11
3
|
type ExtensionContext,
|
|
12
4
|
getAgentDir,
|
|
13
5
|
} from "@earendil-works/pi-coding-agent";
|
|
14
6
|
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
getGlobalConfigPath,
|
|
19
|
-
getLegacyExtensionConfigPath,
|
|
20
|
-
getLegacyGlobalPolicyPath,
|
|
21
|
-
getLegacyProjectPolicyPath,
|
|
22
|
-
getProjectConfigPath,
|
|
23
|
-
REVIEW_LOG_FILENAME,
|
|
24
|
-
} from "./config-paths";
|
|
25
|
-
import { buildResolvedConfigLogEntry } from "./config-reporter";
|
|
26
|
-
import {
|
|
27
|
-
DEFAULT_EXTENSION_CONFIG,
|
|
28
|
-
EXTENSION_ROOT,
|
|
29
|
-
ensurePermissionSystemLogsDirectory,
|
|
30
|
-
normalizePermissionSystemConfig,
|
|
31
|
-
type PermissionSystemExtensionConfig,
|
|
32
|
-
} from "./extension-config";
|
|
7
|
+
import { DEBUG_LOG_FILENAME, REVIEW_LOG_FILENAME } from "./config-paths";
|
|
8
|
+
import { ConfigStore, type RuntimeContextRef } from "./config-store";
|
|
9
|
+
import { ensurePermissionSystemLogsDirectory } from "./extension-config";
|
|
33
10
|
import { computeExtensionPaths, type ExtensionPaths } from "./extension-paths";
|
|
34
11
|
|
|
35
12
|
export type { ExtensionPaths } from "./extension-paths";
|
|
@@ -38,7 +15,6 @@ import { createPermissionSystemLogger } from "./logging";
|
|
|
38
15
|
import { PermissionManager } from "./permission-manager";
|
|
39
16
|
import { SessionRules } from "./session-rules";
|
|
40
17
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
41
|
-
import { syncPermissionSystemStatus } from "./status";
|
|
42
18
|
|
|
43
19
|
/**
|
|
44
20
|
* Mutable session state — the subset of ExtensionRuntime that holds
|
|
@@ -68,179 +44,14 @@ interface SessionState {
|
|
|
68
44
|
* without timing issues around `PI_CODING_AGENT_DIR`.
|
|
69
45
|
*/
|
|
70
46
|
export interface ExtensionRuntime extends ExtensionPaths, SessionState {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
lastConfigWarning: string | null;
|
|
47
|
+
/** The store that owns extension config. */
|
|
48
|
+
configStore: ConfigStore;
|
|
74
49
|
|
|
75
50
|
// ── Logging (backed by logger created at construction) ─────────────────
|
|
76
51
|
writeDebugLog(event: string, details?: Record<string, unknown>): void;
|
|
77
52
|
writeReviewLog(event: string, details?: Record<string, unknown>): void;
|
|
78
53
|
}
|
|
79
54
|
|
|
80
|
-
// ── Pure helpers ───────────────────────────────────────────────────────────
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Derive Pi project-level config and agents paths from a working directory.
|
|
84
|
-
* Returns null when cwd is absent (headless / global-only config).
|
|
85
|
-
*/
|
|
86
|
-
export function derivePiProjectPaths(cwd: string | undefined | null): {
|
|
87
|
-
projectGlobalConfigPath: string;
|
|
88
|
-
projectAgentsDir: string;
|
|
89
|
-
} | null {
|
|
90
|
-
if (!cwd) {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
return {
|
|
94
|
-
projectGlobalConfigPath: getProjectConfigPath(cwd),
|
|
95
|
-
projectAgentsDir: join(cwd, ".pi", "agent", "agents"),
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Create a new PermissionManager scoped to a working directory's config hierarchy.
|
|
101
|
-
* Pass `cwd` as null/undefined to use global config only.
|
|
102
|
-
*/
|
|
103
|
-
export function createPermissionManagerForCwd(
|
|
104
|
-
agentDir: string,
|
|
105
|
-
cwd: string | undefined | null,
|
|
106
|
-
): PermissionManager {
|
|
107
|
-
const projectPaths = derivePiProjectPaths(cwd);
|
|
108
|
-
return new PermissionManager({
|
|
109
|
-
globalConfigPath: getGlobalConfigPath(agentDir),
|
|
110
|
-
projectGlobalConfigPath: projectPaths?.projectGlobalConfigPath,
|
|
111
|
-
projectAgentsDir: projectPaths?.projectAgentsDir,
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Reload merged config from disk into the runtime.
|
|
117
|
-
* If `ctx` is provided, updates `runtime.runtimeContext` first.
|
|
118
|
-
*/
|
|
119
|
-
export function refreshExtensionConfig(
|
|
120
|
-
runtime: ExtensionRuntime,
|
|
121
|
-
ctx?: ExtensionContext,
|
|
122
|
-
): void {
|
|
123
|
-
if (ctx) {
|
|
124
|
-
runtime.runtimeContext = ctx;
|
|
125
|
-
}
|
|
126
|
-
const cwd = runtime.runtimeContext?.cwd ?? null;
|
|
127
|
-
const mergeResult = loadAndMergeConfigs(
|
|
128
|
-
runtime.agentDir,
|
|
129
|
-
cwd ?? "",
|
|
130
|
-
EXTENSION_ROOT,
|
|
131
|
-
);
|
|
132
|
-
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
|
|
133
|
-
runtime.config = runtimeConfig;
|
|
134
|
-
|
|
135
|
-
if (runtime.runtimeContext?.hasUI) {
|
|
136
|
-
syncPermissionSystemStatus(runtime.runtimeContext, runtimeConfig);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const warning =
|
|
140
|
-
mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
|
|
141
|
-
|
|
142
|
-
if (warning && warning !== runtime.lastConfigWarning) {
|
|
143
|
-
runtime.lastConfigWarning = warning;
|
|
144
|
-
runtime.runtimeContext?.ui.notify(warning, "warning");
|
|
145
|
-
} else if (!warning) {
|
|
146
|
-
runtime.lastConfigWarning = null;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
runtime.writeDebugLog("config.loaded", {
|
|
150
|
-
warning: warning ?? null,
|
|
151
|
-
debugLog: runtimeConfig.debugLog,
|
|
152
|
-
permissionReviewLog: runtimeConfig.permissionReviewLog,
|
|
153
|
-
yoloMode: runtimeConfig.yoloMode,
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Save updated runtime knobs (debugLog, permissionReviewLog, yoloMode) to the
|
|
159
|
-
* global config file, then update runtime.config and sync UI status.
|
|
160
|
-
*/
|
|
161
|
-
export function saveExtensionConfig(
|
|
162
|
-
runtime: ExtensionRuntime,
|
|
163
|
-
next: PermissionSystemExtensionConfig,
|
|
164
|
-
ctx: ExtensionCommandContext,
|
|
165
|
-
): void {
|
|
166
|
-
const normalized = normalizePermissionSystemConfig(next);
|
|
167
|
-
const globalPath = getGlobalConfigPath(runtime.agentDir);
|
|
168
|
-
|
|
169
|
-
const existing = loadUnifiedConfig(globalPath);
|
|
170
|
-
const merged = {
|
|
171
|
-
...existing.config,
|
|
172
|
-
debugLog: normalized.debugLog,
|
|
173
|
-
permissionReviewLog: normalized.permissionReviewLog,
|
|
174
|
-
yoloMode: normalized.yoloMode,
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
const tmpPath = `${globalPath}.tmp`;
|
|
178
|
-
try {
|
|
179
|
-
mkdirSync(dirname(globalPath), { recursive: true });
|
|
180
|
-
writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
|
|
181
|
-
renameSync(tmpPath, globalPath);
|
|
182
|
-
} catch (error) {
|
|
183
|
-
try {
|
|
184
|
-
if (existsSync(tmpPath)) {
|
|
185
|
-
unlinkSync(tmpPath);
|
|
186
|
-
}
|
|
187
|
-
} catch {
|
|
188
|
-
// Ignore cleanup failures.
|
|
189
|
-
}
|
|
190
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
191
|
-
ctx.ui.notify(
|
|
192
|
-
`Failed to save permission-system config at '${globalPath}': ${message}`,
|
|
193
|
-
"error",
|
|
194
|
-
);
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
runtime.config = normalized;
|
|
199
|
-
syncPermissionSystemStatus(ctx, normalized);
|
|
200
|
-
runtime.lastConfigWarning = null;
|
|
201
|
-
|
|
202
|
-
runtime.writeDebugLog("config.saved", {
|
|
203
|
-
debugLog: normalized.debugLog,
|
|
204
|
-
permissionReviewLog: normalized.permissionReviewLog,
|
|
205
|
-
yoloMode: normalized.yoloMode,
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Write the resolved config path set (global, project, legacy) to the review
|
|
211
|
-
* and debug logs.
|
|
212
|
-
*/
|
|
213
|
-
export function logResolvedConfigPaths(runtime: ExtensionRuntime): void {
|
|
214
|
-
const policyPaths = runtime.permissionManager.getResolvedPolicyPaths();
|
|
215
|
-
const cwd = runtime.runtimeContext?.cwd ?? null;
|
|
216
|
-
const { agentDir } = runtime;
|
|
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
|
-
runtime.writeReviewLog(
|
|
235
|
-
"config.resolved",
|
|
236
|
-
entry as unknown as Record<string, unknown>,
|
|
237
|
-
);
|
|
238
|
-
runtime.writeDebugLog(
|
|
239
|
-
"config.resolved",
|
|
240
|
-
entry as unknown as Record<string, unknown>,
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
55
|
// ── Factory ────────────────────────────────────────────────────────────────
|
|
245
56
|
|
|
246
57
|
/**
|
|
@@ -255,28 +66,49 @@ export function createExtensionRuntime(options?: {
|
|
|
255
66
|
const agentDir = options?.agentDir ?? getAgentDir();
|
|
256
67
|
const paths = computeExtensionPaths(agentDir);
|
|
257
68
|
|
|
258
|
-
|
|
259
|
-
|
|
69
|
+
const permissionManager = new PermissionManager({ agentDir });
|
|
70
|
+
|
|
260
71
|
const runtime: ExtensionRuntime = {
|
|
261
72
|
...paths,
|
|
262
|
-
config: { ...DEFAULT_EXTENSION_CONFIG },
|
|
263
73
|
runtimeContext: null,
|
|
264
|
-
|
|
74
|
+
configStore: null as unknown as ConfigStore,
|
|
75
|
+
permissionManager,
|
|
265
76
|
activeSkillEntries: [],
|
|
266
77
|
lastKnownActiveAgentName: null,
|
|
267
78
|
lastActiveToolsCacheKey: null,
|
|
268
79
|
lastPromptStateCacheKey: null,
|
|
269
|
-
lastConfigWarning: null,
|
|
270
80
|
sessionRules: new SessionRules(),
|
|
271
81
|
// Logging methods are replaced below after the logger is constructed.
|
|
272
82
|
writeDebugLog: () => {},
|
|
273
83
|
writeReviewLog: () => {},
|
|
274
84
|
};
|
|
275
85
|
|
|
86
|
+
// Transitional RuntimeContextRef: reads/writes the still-runtime-owned
|
|
87
|
+
// `runtimeContext` field until Step 4 (#337) unifies context onto
|
|
88
|
+
// PermissionSession.
|
|
89
|
+
const contextRef: RuntimeContextRef = {
|
|
90
|
+
get: () => runtime.runtimeContext,
|
|
91
|
+
set: (ctx) => {
|
|
92
|
+
runtime.runtimeContext = ctx;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const configStore = new ConfigStore({
|
|
97
|
+
agentDir,
|
|
98
|
+
context: contextRef,
|
|
99
|
+
policyPaths: permissionManager,
|
|
100
|
+
logger: {
|
|
101
|
+
// Deferred-binding: `runtime.writeDebugLog` is replaced below after
|
|
102
|
+
// the logger is constructed — same deferred pattern as before Step 2.
|
|
103
|
+
writeDebugLog: (e, d) => runtime.writeDebugLog(e, d),
|
|
104
|
+
writeReviewLog: (e, d) => runtime.writeReviewLog(e, d),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
runtime.configStore = configStore;
|
|
108
|
+
|
|
276
109
|
const reportedLoggingWarnings = new Set<string>();
|
|
277
110
|
const logger = createPermissionSystemLogger({
|
|
278
|
-
|
|
279
|
-
getConfig: () => runtime.config,
|
|
111
|
+
getConfig: () => configStore.current(),
|
|
280
112
|
debugLogPath: join(paths.globalLogsDir, DEBUG_LOG_FILENAME),
|
|
281
113
|
reviewLogPath: join(paths.globalLogsDir, REVIEW_LOG_FILENAME),
|
|
282
114
|
ensureLogsDirectory: () =>
|
|
@@ -288,7 +120,6 @@ export function createExtensionRuntime(options?: {
|
|
|
288
120
|
return;
|
|
289
121
|
}
|
|
290
122
|
reportedLoggingWarnings.add(message);
|
|
291
|
-
// Reads runtime.runtimeContext at call time — always current.
|
|
292
123
|
runtime.runtimeContext?.ui.notify(message, "warning");
|
|
293
124
|
};
|
|
294
125
|
|
|
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { expect, test, vi } from "vitest";
|
|
5
5
|
import { registerPermissionSystemCommand } from "#src/config-modal";
|
|
6
|
+
import type { CommandConfigStore } from "#src/config-store";
|
|
6
7
|
import {
|
|
7
8
|
DEFAULT_EXTENSION_CONFIG,
|
|
8
9
|
normalizePermissionSystemConfig,
|
|
@@ -79,11 +80,14 @@ test("permission-system command completions expose top-level config actions", ()
|
|
|
79
80
|
let config: PermissionSystemExtensionConfig = { ...DEFAULT_EXTENSION_CONFIG };
|
|
80
81
|
|
|
81
82
|
try {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
const configStore: CommandConfigStore = {
|
|
84
|
+
current: () => config,
|
|
85
|
+
save: (next) => {
|
|
85
86
|
config = next;
|
|
86
87
|
},
|
|
88
|
+
};
|
|
89
|
+
const controller = {
|
|
90
|
+
config: configStore,
|
|
87
91
|
getConfigPath: () => configPath,
|
|
88
92
|
};
|
|
89
93
|
|
|
@@ -136,9 +140,9 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
136
140
|
"utf-8",
|
|
137
141
|
);
|
|
138
142
|
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
const configStore: CommandConfigStore = {
|
|
144
|
+
current: () => config,
|
|
145
|
+
save: (next) => {
|
|
142
146
|
const currentConfig = normalizePermissionSystemConfig(
|
|
143
147
|
JSON.parse(readFileSync(configPath, "utf-8")) as unknown,
|
|
144
148
|
);
|
|
@@ -153,6 +157,9 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
153
157
|
);
|
|
154
158
|
expect(config).not.toEqual(currentConfig);
|
|
155
159
|
},
|
|
160
|
+
};
|
|
161
|
+
const controller = {
|
|
162
|
+
config: configStore,
|
|
156
163
|
getConfigPath: () => configPath,
|
|
157
164
|
};
|
|
158
165
|
|
|
@@ -249,8 +256,7 @@ test("show output includes rule origins when getComposedRules is provided", asyn
|
|
|
249
256
|
];
|
|
250
257
|
|
|
251
258
|
const controller = {
|
|
252
|
-
|
|
253
|
-
setConfig: () => {},
|
|
259
|
+
config: { current: () => config, save: () => {} } as CommandConfigStore,
|
|
254
260
|
getConfigPath: () => "/fake/config.json",
|
|
255
261
|
getComposedRules: () => composedRules,
|
|
256
262
|
};
|
|
@@ -282,8 +288,7 @@ test("show output omits rule summary when getComposedRules is not provided", asy
|
|
|
282
288
|
const config = { ...DEFAULT_EXTENSION_CONFIG, yoloMode: true };
|
|
283
289
|
|
|
284
290
|
const controller = {
|
|
285
|
-
|
|
286
|
-
setConfig: () => {},
|
|
291
|
+
config: { current: () => config, save: () => {} } as CommandConfigStore,
|
|
287
292
|
getConfigPath: () => "/fake/config.json",
|
|
288
293
|
// no getComposedRules
|
|
289
294
|
};
|