@gotgenes/pi-permission-system 3.7.0 → 3.9.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/src/runtime.ts ADDED
@@ -0,0 +1,484 @@
1
+ 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
+ type ExtensionContext,
12
+ getAgentDir,
13
+ } from "@mariozechner/pi-coding-agent";
14
+ import {
15
+ getActiveAgentName,
16
+ getActiveAgentNameFromSystemPrompt,
17
+ } from "./active-agent";
18
+ import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
19
+ import {
20
+ DEBUG_LOG_FILENAME,
21
+ getGlobalConfigPath,
22
+ getGlobalLogsDir,
23
+ getLegacyExtensionConfigPath,
24
+ getLegacyGlobalPolicyPath,
25
+ getLegacyProjectPolicyPath,
26
+ getProjectConfigPath,
27
+ REVIEW_LOG_FILENAME,
28
+ } from "./config-paths";
29
+ import { buildResolvedConfigLogEntry } from "./config-reporter";
30
+ import {
31
+ DEFAULT_EXTENSION_CONFIG,
32
+ EXTENSION_ROOT,
33
+ ensurePermissionSystemLogsDirectory,
34
+ normalizePermissionSystemConfig,
35
+ type PermissionSystemExtensionConfig,
36
+ } from "./extension-config";
37
+ import {
38
+ confirmPermission,
39
+ type PermissionForwardingDeps,
40
+ processForwardedPermissionRequests,
41
+ } from "./forwarded-permissions/polling";
42
+ import type { PromptPermissionDetails } from "./handlers/types";
43
+ import { createPermissionSystemLogger } from "./logging";
44
+ import type { PermissionPromptDecision } from "./permission-dialog";
45
+ import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
46
+ import { PermissionManager } from "./permission-manager";
47
+ import { SessionApprovalCache } from "./session-approval-cache";
48
+ import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
49
+ import { syncPermissionSystemStatus } from "./status";
50
+ import { isSubagentExecutionContext } from "./subagent-context";
51
+ import { shouldAutoApprovePermissionState } from "./yolo-mode";
52
+
53
+ /**
54
+ * Runtime context object created once inside `piPermissionSystemExtension()`.
55
+ *
56
+ * Holds all path constants (derived from `getAgentDir()` at construction time),
57
+ * mutable extension state, and the log-writing methods — eliminating the
58
+ * module-scope cached constants and setter-injection pattern that previously
59
+ * lived in `src/index.ts`.
60
+ *
61
+ * Tests construct this via `createExtensionRuntime({ agentDir: tmpDir })`
62
+ * without timing issues around `PI_CODING_AGENT_DIR`.
63
+ */
64
+ export interface ExtensionRuntime {
65
+ // ── Immutable paths (derived from agentDir at construction) ───────────
66
+ readonly agentDir: string;
67
+ readonly sessionsDir: string;
68
+ readonly subagentSessionsDir: string;
69
+ readonly forwardingDir: string;
70
+ readonly globalLogsDir: string;
71
+
72
+ // ── Mutable state ──────────────────────────────────────────────────────
73
+ config: PermissionSystemExtensionConfig;
74
+ runtimeContext: ExtensionContext | null;
75
+ permissionManager: PermissionManager;
76
+ activeSkillEntries: SkillPromptEntry[];
77
+ lastKnownActiveAgentName: string | null;
78
+ lastActiveToolsCacheKey: string | null;
79
+ lastPromptStateCacheKey: string | null;
80
+ lastConfigWarning: string | null;
81
+ readonly sessionApprovalCache: SessionApprovalCache;
82
+
83
+ // ── Forwarding polling state ───────────────────────────────────────────
84
+ permissionForwardingContext: ExtensionContext | null;
85
+ permissionForwardingTimer: NodeJS.Timeout | null;
86
+ isProcessingForwardedRequests: boolean;
87
+
88
+ // ── Logging (backed by logger created at construction) ─────────────────
89
+ writeDebugLog(event: string, details?: Record<string, unknown>): void;
90
+ writeReviewLog(event: string, details?: Record<string, unknown>): void;
91
+ }
92
+
93
+ // ── Pure helpers ───────────────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Derive Pi project-level config and agents paths from a working directory.
97
+ * Returns null when cwd is absent (headless / global-only config).
98
+ */
99
+ export function derivePiProjectPaths(cwd: string | undefined | null): {
100
+ projectGlobalConfigPath: string;
101
+ projectAgentsDir: string;
102
+ } | null {
103
+ if (!cwd) {
104
+ return null;
105
+ }
106
+ return {
107
+ projectGlobalConfigPath: getProjectConfigPath(cwd),
108
+ projectAgentsDir: join(cwd, ".pi", "agent", "agents"),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Create a new PermissionManager scoped to a working directory's config hierarchy.
114
+ * Pass `cwd` as null/undefined to use global config only.
115
+ */
116
+ export function createPermissionManagerForCwd(
117
+ agentDir: string,
118
+ cwd: string | undefined | null,
119
+ ): PermissionManager {
120
+ const projectPaths = derivePiProjectPaths(cwd);
121
+ return new PermissionManager({
122
+ globalConfigPath: getGlobalConfigPath(agentDir),
123
+ projectGlobalConfigPath: projectPaths?.projectGlobalConfigPath,
124
+ projectAgentsDir: projectPaths?.projectAgentsDir,
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Reload merged config from disk into the runtime.
130
+ * If `ctx` is provided, updates `runtime.runtimeContext` first.
131
+ */
132
+ export function refreshExtensionConfig(
133
+ runtime: ExtensionRuntime,
134
+ ctx?: ExtensionContext,
135
+ ): void {
136
+ if (ctx) {
137
+ runtime.runtimeContext = ctx;
138
+ }
139
+ const cwd = runtime.runtimeContext?.cwd ?? null;
140
+ const mergeResult = loadAndMergeConfigs(
141
+ runtime.agentDir,
142
+ cwd ?? "",
143
+ EXTENSION_ROOT,
144
+ );
145
+ const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
146
+ runtime.config = runtimeConfig;
147
+
148
+ if (runtime.runtimeContext?.hasUI) {
149
+ syncPermissionSystemStatus(runtime.runtimeContext, runtimeConfig);
150
+ }
151
+
152
+ const warning =
153
+ mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
154
+
155
+ if (warning && warning !== runtime.lastConfigWarning) {
156
+ runtime.lastConfigWarning = warning;
157
+ runtime.runtimeContext?.ui.notify(warning, "warning");
158
+ } else if (!warning) {
159
+ runtime.lastConfigWarning = null;
160
+ }
161
+
162
+ runtime.writeDebugLog("config.loaded", {
163
+ warning: warning ?? null,
164
+ debugLog: runtimeConfig.debugLog,
165
+ permissionReviewLog: runtimeConfig.permissionReviewLog,
166
+ yoloMode: runtimeConfig.yoloMode,
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Save updated runtime knobs (debugLog, permissionReviewLog, yoloMode) to the
172
+ * global config file, then update runtime.config and sync UI status.
173
+ */
174
+ export function saveExtensionConfig(
175
+ runtime: ExtensionRuntime,
176
+ next: PermissionSystemExtensionConfig,
177
+ ctx: ExtensionCommandContext,
178
+ ): void {
179
+ const normalized = normalizePermissionSystemConfig(next);
180
+ const globalPath = getGlobalConfigPath(runtime.agentDir);
181
+
182
+ const existing = loadUnifiedConfig(globalPath);
183
+ const merged = {
184
+ ...existing.config,
185
+ debugLog: normalized.debugLog,
186
+ permissionReviewLog: normalized.permissionReviewLog,
187
+ yoloMode: normalized.yoloMode,
188
+ };
189
+
190
+ const tmpPath = `${globalPath}.tmp`;
191
+ try {
192
+ mkdirSync(dirname(globalPath), { recursive: true });
193
+ writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
194
+ renameSync(tmpPath, globalPath);
195
+ } catch (error) {
196
+ try {
197
+ if (existsSync(tmpPath)) {
198
+ unlinkSync(tmpPath);
199
+ }
200
+ } catch {
201
+ // Ignore cleanup failures.
202
+ }
203
+ const message = error instanceof Error ? error.message : String(error);
204
+ ctx.ui.notify(
205
+ `Failed to save permission-system config at '${globalPath}': ${message}`,
206
+ "error",
207
+ );
208
+ return;
209
+ }
210
+
211
+ runtime.config = normalized;
212
+ syncPermissionSystemStatus(ctx, normalized);
213
+ runtime.lastConfigWarning = null;
214
+
215
+ runtime.writeDebugLog("config.saved", {
216
+ debugLog: normalized.debugLog,
217
+ permissionReviewLog: normalized.permissionReviewLog,
218
+ yoloMode: normalized.yoloMode,
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Resolve the active agent name from the Pi session, system prompt, or last
224
+ * known name. Updates `runtime.lastKnownActiveAgentName` as a side effect.
225
+ */
226
+ export function resolveAgentName(
227
+ runtime: ExtensionRuntime,
228
+ ctx: ExtensionContext,
229
+ systemPrompt?: string,
230
+ ): string | null {
231
+ const fromSession = getActiveAgentName(ctx);
232
+ if (fromSession) {
233
+ runtime.lastKnownActiveAgentName = fromSession;
234
+ return fromSession;
235
+ }
236
+ const fromSystemPrompt = getActiveAgentNameFromSystemPrompt(systemPrompt);
237
+ if (fromSystemPrompt) {
238
+ runtime.lastKnownActiveAgentName = fromSystemPrompt;
239
+ return fromSystemPrompt;
240
+ }
241
+ return runtime.lastKnownActiveAgentName;
242
+ }
243
+
244
+ /**
245
+ * Write the resolved config path set (global, project, legacy) to the review
246
+ * and debug logs.
247
+ */
248
+ export function logResolvedConfigPaths(runtime: ExtensionRuntime): void {
249
+ const policyPaths = runtime.permissionManager.getResolvedPolicyPaths();
250
+ const cwd = runtime.runtimeContext?.cwd ?? null;
251
+ const { agentDir } = runtime;
252
+ const legacyGlobalPolicyDetected = existsSync(
253
+ getLegacyGlobalPolicyPath(agentDir),
254
+ );
255
+ const legacyProjectPolicyDetected = cwd
256
+ ? existsSync(getLegacyProjectPolicyPath(cwd))
257
+ : false;
258
+ const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
259
+ const newGlobalPath = getGlobalConfigPath(agentDir);
260
+ const legacyExtensionConfigDetected =
261
+ normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
262
+ existsSync(legacyExtConfigPath);
263
+ const entry = buildResolvedConfigLogEntry({
264
+ policyPaths,
265
+ legacyGlobalPolicyDetected,
266
+ legacyProjectPolicyDetected,
267
+ legacyExtensionConfigDetected,
268
+ });
269
+ runtime.writeReviewLog(
270
+ "config.resolved",
271
+ entry as unknown as Record<string, unknown>,
272
+ );
273
+ runtime.writeDebugLog(
274
+ "config.resolved",
275
+ entry as unknown as Record<string, unknown>,
276
+ );
277
+ }
278
+
279
+ // ── Permission helpers ─────────────────────────────────────────────────────
280
+
281
+ /** Internal: write a structured permission decision entry to the review log. */
282
+ function reviewPermissionDecision(
283
+ writeReviewLog: (event: string, details: Record<string, unknown>) => void,
284
+ event: string,
285
+ details: PromptPermissionDetails & {
286
+ resolution?: string;
287
+ denialReason?: string;
288
+ },
289
+ ): void {
290
+ writeReviewLog(event, {
291
+ requestId: details.requestId,
292
+ source: details.source,
293
+ agentName: details.agentName,
294
+ message: details.message,
295
+ toolCallId: details.toolCallId ?? null,
296
+ toolName: details.toolName ?? null,
297
+ skillName: details.skillName ?? null,
298
+ path: details.path ?? null,
299
+ command: details.command ?? null,
300
+ target: details.target ?? null,
301
+ toolInputPreview: details.toolInputPreview ?? null,
302
+ resolution: details.resolution ?? null,
303
+ denialReason: details.denialReason ?? null,
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Prompt the user for a permission decision using the forwarding flow,
309
+ * log the waiting / approved / denied outcome, and return the decision.
310
+ * In yolo mode, auto-approves without prompting.
311
+ */
312
+ export async function promptPermission(
313
+ runtime: ExtensionRuntime,
314
+ forwardingDeps: PermissionForwardingDeps,
315
+ ctx: ExtensionContext,
316
+ details: PromptPermissionDetails,
317
+ ): Promise<PermissionPromptDecision> {
318
+ if (shouldAutoApprovePermissionState("ask", runtime.config)) {
319
+ reviewPermissionDecision(
320
+ runtime.writeReviewLog,
321
+ "permission_request.auto_approved",
322
+ details,
323
+ );
324
+ return { approved: true, state: "approved" };
325
+ }
326
+ reviewPermissionDecision(
327
+ runtime.writeReviewLog,
328
+ "permission_request.waiting",
329
+ details,
330
+ );
331
+ const decision = await confirmPermission(
332
+ ctx,
333
+ details.message,
334
+ forwardingDeps,
335
+ );
336
+ reviewPermissionDecision(
337
+ runtime.writeReviewLog,
338
+ decision.approved
339
+ ? "permission_request.approved"
340
+ : "permission_request.denied",
341
+ {
342
+ ...details,
343
+ resolution: decision.state,
344
+ denialReason: decision.denialReason,
345
+ },
346
+ );
347
+ return decision;
348
+ }
349
+
350
+ // ── Forwarding polling lifecycle ───────────────────────────────────────────
351
+
352
+ /** Stop the forwarded-permission polling interval and clear related state. */
353
+ export function stopForwardedPermissionPolling(
354
+ runtime: ExtensionRuntime,
355
+ ): void {
356
+ if (runtime.permissionForwardingTimer) {
357
+ clearInterval(runtime.permissionForwardingTimer);
358
+ runtime.permissionForwardingTimer = null;
359
+ }
360
+ runtime.permissionForwardingContext = null;
361
+ runtime.isProcessingForwardedRequests = false;
362
+ }
363
+
364
+ /**
365
+ * Start the forwarded-permission polling interval.
366
+ * No-ops (and stops any existing poll) when the context has no UI or is a
367
+ * subagent execution context.
368
+ */
369
+ export function startForwardedPermissionPolling(
370
+ runtime: ExtensionRuntime,
371
+ forwardingDeps: PermissionForwardingDeps,
372
+ ctx: ExtensionContext,
373
+ ): void {
374
+ if (
375
+ !ctx.hasUI ||
376
+ isSubagentExecutionContext(ctx, runtime.subagentSessionsDir)
377
+ ) {
378
+ stopForwardedPermissionPolling(runtime);
379
+ return;
380
+ }
381
+ runtime.permissionForwardingContext = ctx;
382
+ if (runtime.permissionForwardingTimer) {
383
+ return;
384
+ }
385
+ runtime.permissionForwardingTimer = setInterval(() => {
386
+ if (
387
+ !runtime.permissionForwardingContext ||
388
+ runtime.isProcessingForwardedRequests
389
+ ) {
390
+ return;
391
+ }
392
+ runtime.isProcessingForwardedRequests = true;
393
+ void processForwardedPermissionRequests(
394
+ runtime.permissionForwardingContext,
395
+ forwardingDeps,
396
+ ).finally(() => {
397
+ runtime.isProcessingForwardedRequests = false;
398
+ });
399
+ }, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
400
+ }
401
+
402
+ // ── Factory ────────────────────────────────────────────────────────────────
403
+
404
+ /**
405
+ * Create a fully-initialized `ExtensionRuntime`.
406
+ *
407
+ * Calls `getAgentDir()` at invocation time (never at module scope), so tests
408
+ * may set `PI_CODING_AGENT_DIR` before calling the factory.
409
+ */
410
+ export function createExtensionRuntime(options?: {
411
+ agentDir?: string;
412
+ }): ExtensionRuntime {
413
+ const agentDir = options?.agentDir ?? getAgentDir();
414
+ const sessionsDir = join(agentDir, "sessions");
415
+ const subagentSessionsDir = join(agentDir, "subagent-sessions");
416
+ const forwardingDir = join(sessionsDir, "permission-forwarding");
417
+ const globalLogsDir = getGlobalLogsDir(agentDir);
418
+
419
+ // Build a plain-object runtime first so the logger's `getConfig` closure
420
+ // can reference `runtime.config` directly (always reads current value).
421
+ const runtime: ExtensionRuntime = {
422
+ agentDir,
423
+ sessionsDir,
424
+ subagentSessionsDir,
425
+ forwardingDir,
426
+ globalLogsDir,
427
+ config: { ...DEFAULT_EXTENSION_CONFIG },
428
+ runtimeContext: null,
429
+ permissionManager: createPermissionManagerForCwd(agentDir, undefined),
430
+ activeSkillEntries: [],
431
+ lastKnownActiveAgentName: null,
432
+ lastActiveToolsCacheKey: null,
433
+ lastPromptStateCacheKey: null,
434
+ lastConfigWarning: null,
435
+ sessionApprovalCache: new SessionApprovalCache(),
436
+ permissionForwardingContext: null,
437
+ permissionForwardingTimer: null,
438
+ isProcessingForwardedRequests: false,
439
+ // Logging methods are replaced below after the logger is constructed.
440
+ writeDebugLog: () => {},
441
+ writeReviewLog: () => {},
442
+ };
443
+
444
+ const reportedLoggingWarnings = new Set<string>();
445
+ const logger = createPermissionSystemLogger({
446
+ // Reads runtime.config at call time — always current.
447
+ getConfig: () => runtime.config,
448
+ debugLogPath: join(globalLogsDir, DEBUG_LOG_FILENAME),
449
+ reviewLogPath: join(globalLogsDir, REVIEW_LOG_FILENAME),
450
+ ensureLogsDirectory: () =>
451
+ ensurePermissionSystemLogsDirectory(globalLogsDir),
452
+ });
453
+
454
+ const reportLoggingWarning = (message: string): void => {
455
+ if (reportedLoggingWarnings.has(message)) {
456
+ return;
457
+ }
458
+ reportedLoggingWarnings.add(message);
459
+ // Reads runtime.runtimeContext at call time — always current.
460
+ runtime.runtimeContext?.ui.notify(message, "warning");
461
+ };
462
+
463
+ runtime.writeDebugLog = (
464
+ event: string,
465
+ details: Record<string, unknown> = {},
466
+ ): void => {
467
+ const warning = logger.debug(event, details);
468
+ if (warning) {
469
+ reportLoggingWarning(warning);
470
+ }
471
+ };
472
+
473
+ runtime.writeReviewLog = (
474
+ event: string,
475
+ details: Record<string, unknown> = {},
476
+ ): void => {
477
+ const warning = logger.review(event, details);
478
+ if (warning) {
479
+ reportLoggingWarning(warning);
480
+ }
481
+ };
482
+
483
+ return runtime;
484
+ }
package/src/types.ts CHANGED
@@ -9,16 +9,8 @@ export type BuiltInToolName =
9
9
  | "find"
10
10
  | "ls";
11
11
 
12
- export type ToolPermissions = Record<string, PermissionState>;
13
-
14
- export type BashPermissions = Record<string, PermissionState>;
15
-
16
- export type SkillPermissions = Record<string, PermissionState>;
17
-
18
12
  export type SpecialPermissionName = "external_directory";
19
13
 
20
- export type SpecialPermissions = Record<string, PermissionState>;
21
-
22
14
  export interface PermissionDefaultPolicy {
23
15
  tools: PermissionState;
24
16
  bash: PermissionState;
@@ -27,17 +19,20 @@ export interface PermissionDefaultPolicy {
27
19
  special: PermissionState;
28
20
  }
29
21
 
30
- export interface AgentPermissions {
22
+ /**
23
+ * Per-scope permission config shape after loading and validation.
24
+ * All fields optional — each scope may define a subset of the policy.
25
+ *
26
+ * This replaces the former AgentPermissions / GlobalPermissionConfig
27
+ * interfaces (removed in #56).
28
+ */
29
+ export interface ScopeConfig {
31
30
  defaultPolicy?: Partial<PermissionDefaultPolicy>;
32
- tools?: ToolPermissions;
33
- bash?: BashPermissions;
34
- mcp?: ToolPermissions;
35
- skills?: SkillPermissions;
36
- special?: SpecialPermissions;
37
- }
38
-
39
- export interface GlobalPermissionConfig extends AgentPermissions {
40
- defaultPolicy: PermissionDefaultPolicy;
31
+ tools?: Record<string, PermissionState>;
32
+ bash?: Record<string, PermissionState>;
33
+ mcp?: Record<string, PermissionState>;
34
+ skills?: Record<string, PermissionState>;
35
+ special?: Record<string, PermissionState>;
41
36
  }
42
37
 
43
38
  export interface PermissionCheckResult {
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ DEFAULT_POLICY,
4
+ getSurfaceDefault,
5
+ mergeDefaults,
6
+ } from "../src/defaults";
7
+ import type { PermissionDefaultPolicy } from "../src/types";
8
+
9
+ const SPECIAL_KEYS = new Set(["external_directory"]);
10
+
11
+ describe("getSurfaceDefault", () => {
12
+ const defaults: PermissionDefaultPolicy = {
13
+ tools: "allow",
14
+ bash: "deny",
15
+ mcp: "ask",
16
+ skills: "allow",
17
+ special: "deny",
18
+ };
19
+
20
+ test("returns defaults.bash for surface 'bash'", () => {
21
+ expect(getSurfaceDefault("bash", defaults, SPECIAL_KEYS)).toBe("deny");
22
+ });
23
+
24
+ test("returns defaults.mcp for surface 'mcp'", () => {
25
+ expect(getSurfaceDefault("mcp", defaults, SPECIAL_KEYS)).toBe("ask");
26
+ });
27
+
28
+ test("returns defaults.skills for surface 'skill'", () => {
29
+ expect(getSurfaceDefault("skill", defaults, SPECIAL_KEYS)).toBe("allow");
30
+ });
31
+
32
+ test("returns defaults.special for special-key surfaces", () => {
33
+ expect(
34
+ getSurfaceDefault("external_directory", defaults, SPECIAL_KEYS),
35
+ ).toBe("deny");
36
+ });
37
+
38
+ test("returns defaults.tools for tool-name surfaces", () => {
39
+ expect(getSurfaceDefault("read", defaults, SPECIAL_KEYS)).toBe("allow");
40
+ expect(getSurfaceDefault("write", defaults, SPECIAL_KEYS)).toBe("allow");
41
+ expect(getSurfaceDefault("edit", defaults, SPECIAL_KEYS)).toBe("allow");
42
+ });
43
+
44
+ test("returns defaults.tools for unknown surfaces (least privilege via tools default)", () => {
45
+ expect(
46
+ getSurfaceDefault("unknown_extension_tool", defaults, SPECIAL_KEYS),
47
+ ).toBe("allow");
48
+ });
49
+
50
+ test("uses DEFAULT_POLICY when no overrides exist", () => {
51
+ expect(getSurfaceDefault("bash", DEFAULT_POLICY, SPECIAL_KEYS)).toBe("ask");
52
+ expect(getSurfaceDefault("read", DEFAULT_POLICY, SPECIAL_KEYS)).toBe("ask");
53
+ expect(
54
+ getSurfaceDefault("external_directory", DEFAULT_POLICY, SPECIAL_KEYS),
55
+ ).toBe("ask");
56
+ });
57
+ });
58
+
59
+ describe("mergeDefaults", () => {
60
+ test("returns DEFAULT_POLICY when called with no partials", () => {
61
+ expect(mergeDefaults()).toEqual(DEFAULT_POLICY);
62
+ });
63
+
64
+ test("overrides specific fields from a single partial", () => {
65
+ const result = mergeDefaults({ tools: "allow", bash: "deny" });
66
+ expect(result).toEqual({
67
+ tools: "allow",
68
+ bash: "deny",
69
+ mcp: "ask",
70
+ skills: "ask",
71
+ special: "ask",
72
+ });
73
+ });
74
+
75
+ test("later partials override earlier ones", () => {
76
+ const global: Partial<PermissionDefaultPolicy> = { tools: "allow" };
77
+ const project: Partial<PermissionDefaultPolicy> = { tools: "deny" };
78
+ const result = mergeDefaults(global, project);
79
+ expect(result.tools).toBe("deny");
80
+ });
81
+
82
+ test("merges across multiple partials", () => {
83
+ const global: Partial<PermissionDefaultPolicy> = {
84
+ tools: "allow",
85
+ bash: "allow",
86
+ };
87
+ const project: Partial<PermissionDefaultPolicy> = { bash: "deny" };
88
+ const agent: Partial<PermissionDefaultPolicy> = { mcp: "allow" };
89
+ const result = mergeDefaults(global, project, agent);
90
+ expect(result).toEqual({
91
+ tools: "allow",
92
+ bash: "deny",
93
+ mcp: "allow",
94
+ skills: "ask",
95
+ special: "ask",
96
+ });
97
+ });
98
+
99
+ test("undefined fields in later partials do not override earlier values", () => {
100
+ const global: Partial<PermissionDefaultPolicy> = { tools: "allow" };
101
+ const project: Partial<PermissionDefaultPolicy> = { bash: "deny" };
102
+ const result = mergeDefaults(global, project);
103
+ expect(result.tools).toBe("allow");
104
+ });
105
+ });