@gotgenes/pi-permission-system 10.3.1 → 10.4.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 +12 -0
- package/package.json +1 -1
- package/src/config-modal.ts +10 -8
- package/src/config-store.ts +6 -11
- package/src/forwarded-permissions/io.ts +16 -22
- package/src/forwarded-permissions/permission-forwarder.ts +16 -19
- package/src/gate-prompter.ts +1 -3
- package/src/handlers/gates/runner.ts +1 -1
- package/src/index.ts +22 -40
- package/src/permission-event-rpc.ts +19 -15
- package/src/permission-prompter.ts +4 -3
- package/src/permission-session.ts +7 -63
- package/src/prompting-gateway.ts +104 -0
- package/src/session-logger.ts +17 -3
- package/test/config-modal.test.ts +13 -7
- package/test/config-store.test.ts +7 -9
- package/test/forwarded-permissions/io.test.ts +23 -26
- package/test/handlers/external-directory-integration.test.ts +45 -32
- package/test/handlers/external-directory-session-dedup.test.ts +36 -46
- package/test/handlers/gates/runner.test.ts +10 -16
- package/test/handlers/input-events.test.ts +19 -4
- package/test/handlers/input.test.ts +29 -13
- package/test/handlers/tool-call-events.test.ts +23 -5
- package/test/helpers/gate-fixtures.ts +6 -6
- package/test/helpers/handler-fixtures.ts +24 -39
- package/test/permission-event-rpc.test.ts +30 -28
- package/test/permission-forwarder.test.ts +6 -5
- package/test/permission-prompter.test.ts +28 -28
- package/test/permission-session.test.ts +27 -112
- package/test/prompting-gateway.test.ts +230 -0
|
@@ -22,10 +22,8 @@ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
|
22
22
|
import type { ExtensionPaths } from "#src/extension-paths";
|
|
23
23
|
import type { ForwardingController } from "#src/forwarding-manager";
|
|
24
24
|
import type { ScopedPermissionManager } from "#src/permission-manager";
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
type PermissionSessionRuntimeDeps,
|
|
28
|
-
} from "#src/permission-session";
|
|
25
|
+
import { PermissionSession } from "#src/permission-session";
|
|
26
|
+
import type { PromptingGatewayLifecycle } from "#src/prompting-gateway";
|
|
29
27
|
import type { Ruleset } from "#src/rule";
|
|
30
28
|
import { SessionApproval } from "#src/session-approval";
|
|
31
29
|
import type { SessionLogger } from "#src/session-logger";
|
|
@@ -83,12 +81,10 @@ function makeConfigStore(
|
|
|
83
81
|
};
|
|
84
82
|
}
|
|
85
83
|
|
|
86
|
-
function
|
|
84
|
+
function makeGateway(): PromptingGatewayLifecycle {
|
|
87
85
|
return {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
.fn()
|
|
91
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
86
|
+
activate: vi.fn<PromptingGatewayLifecycle["activate"]>(),
|
|
87
|
+
deactivate: vi.fn<PromptingGatewayLifecycle["deactivate"]>(),
|
|
92
88
|
};
|
|
93
89
|
}
|
|
94
90
|
|
|
@@ -132,7 +128,7 @@ function createSession(overrides?: {
|
|
|
132
128
|
permissionManager?: ScopedPermissionManager;
|
|
133
129
|
sessionRules?: SessionRules;
|
|
134
130
|
configStore?: SessionConfigStore;
|
|
135
|
-
|
|
131
|
+
gateway?: PromptingGatewayLifecycle;
|
|
136
132
|
}): {
|
|
137
133
|
session: PermissionSession;
|
|
138
134
|
paths: ExtensionPaths;
|
|
@@ -140,7 +136,7 @@ function createSession(overrides?: {
|
|
|
140
136
|
forwarding: ForwardingController;
|
|
141
137
|
sessionRules: SessionRules;
|
|
142
138
|
configStore: SessionConfigStore;
|
|
143
|
-
|
|
139
|
+
gateway: PromptingGatewayLifecycle;
|
|
144
140
|
} {
|
|
145
141
|
const paths = makePaths(overrides?.paths);
|
|
146
142
|
const logger = overrides?.logger ?? makeLogger();
|
|
@@ -149,7 +145,7 @@ function createSession(overrides?: {
|
|
|
149
145
|
overrides?.permissionManager ?? makePermissionManager();
|
|
150
146
|
const sessionRules = overrides?.sessionRules ?? new SessionRules();
|
|
151
147
|
const configStore = overrides?.configStore ?? makeConfigStore();
|
|
152
|
-
const
|
|
148
|
+
const gateway = overrides?.gateway ?? makeGateway();
|
|
153
149
|
const session = new PermissionSession(
|
|
154
150
|
paths,
|
|
155
151
|
logger,
|
|
@@ -157,7 +153,7 @@ function createSession(overrides?: {
|
|
|
157
153
|
permissionManager,
|
|
158
154
|
sessionRules,
|
|
159
155
|
configStore,
|
|
160
|
-
|
|
156
|
+
gateway,
|
|
161
157
|
);
|
|
162
158
|
return {
|
|
163
159
|
session,
|
|
@@ -166,7 +162,7 @@ function createSession(overrides?: {
|
|
|
166
162
|
forwarding,
|
|
167
163
|
sessionRules,
|
|
168
164
|
configStore,
|
|
169
|
-
|
|
165
|
+
gateway,
|
|
170
166
|
};
|
|
171
167
|
}
|
|
172
168
|
|
|
@@ -329,6 +325,23 @@ describe("PermissionSession", () => {
|
|
|
329
325
|
|
|
330
326
|
expect(forwarding.stop).toHaveBeenCalled();
|
|
331
327
|
});
|
|
328
|
+
|
|
329
|
+
it("forwards activate to the gateway", () => {
|
|
330
|
+
const { session, gateway } = createSession();
|
|
331
|
+
const ctx = makeCtx();
|
|
332
|
+
|
|
333
|
+
session.activate(ctx);
|
|
334
|
+
|
|
335
|
+
expect(gateway.activate).toHaveBeenCalledWith(ctx);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("forwards deactivate to the gateway", () => {
|
|
339
|
+
const { session, gateway } = createSession();
|
|
340
|
+
session.activate(makeCtx());
|
|
341
|
+
session.deactivate();
|
|
342
|
+
|
|
343
|
+
expect(gateway.deactivate).toHaveBeenCalled();
|
|
344
|
+
});
|
|
332
345
|
});
|
|
333
346
|
|
|
334
347
|
describe("resetForNewSession", () => {
|
|
@@ -627,102 +640,4 @@ describe("PermissionSession", () => {
|
|
|
627
640
|
expect(session.getRuntimeContext()).toBeNull();
|
|
628
641
|
});
|
|
629
642
|
});
|
|
630
|
-
|
|
631
|
-
describe("canConfirm", () => {
|
|
632
|
-
it("returns true when context is active and canPrompt returns true", () => {
|
|
633
|
-
const { session } = createSession();
|
|
634
|
-
session.activate(makeCtx());
|
|
635
|
-
expect(session.canConfirm()).toBe(true);
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
it("returns false when no context is active", () => {
|
|
639
|
-
const { session } = createSession();
|
|
640
|
-
expect(session.canConfirm()).toBe(false);
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
it("returns false when canPrompt returns false", () => {
|
|
644
|
-
const runtimeDeps = makeRuntimeDeps();
|
|
645
|
-
(
|
|
646
|
-
runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
|
|
647
|
-
).mockReturnValue(false);
|
|
648
|
-
const { session } = createSession({ runtimeDeps });
|
|
649
|
-
session.activate(makeCtx());
|
|
650
|
-
expect(session.canConfirm()).toBe(false);
|
|
651
|
-
});
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
describe("promptPermission", () => {
|
|
655
|
-
it("delegates to prompt with stored context", async () => {
|
|
656
|
-
const { session, runtimeDeps } = createSession();
|
|
657
|
-
const ctx = makeCtx();
|
|
658
|
-
session.activate(ctx);
|
|
659
|
-
const details = {
|
|
660
|
-
requestId: "req-1",
|
|
661
|
-
source: "tool_call" as const,
|
|
662
|
-
agentName: null,
|
|
663
|
-
message: "Allow?",
|
|
664
|
-
};
|
|
665
|
-
|
|
666
|
-
const result = await session.promptPermission(details);
|
|
667
|
-
|
|
668
|
-
expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
|
|
669
|
-
expect(result).toEqual({ approved: true, state: "approved" });
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
it("throws when no context is active", async () => {
|
|
673
|
-
const { session } = createSession();
|
|
674
|
-
const details = {
|
|
675
|
-
requestId: "req-1",
|
|
676
|
-
source: "tool_call" as const,
|
|
677
|
-
agentName: null,
|
|
678
|
-
message: "Allow?",
|
|
679
|
-
};
|
|
680
|
-
|
|
681
|
-
await expect(session.promptPermission(details)).rejects.toThrow(
|
|
682
|
-
"promptPermission called before the session was activated",
|
|
683
|
-
);
|
|
684
|
-
});
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
describe("canPrompt", () => {
|
|
688
|
-
it("delegates to runtimeDeps.canRequestPermissionConfirmation", () => {
|
|
689
|
-
const { session, runtimeDeps } = createSession();
|
|
690
|
-
const ctx = makeCtx();
|
|
691
|
-
|
|
692
|
-
const result = session.canPrompt(ctx);
|
|
693
|
-
|
|
694
|
-
expect(runtimeDeps.canRequestPermissionConfirmation).toHaveBeenCalledWith(
|
|
695
|
-
ctx,
|
|
696
|
-
);
|
|
697
|
-
expect(result).toBe(true);
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
it("returns false when runtimeDeps says no", () => {
|
|
701
|
-
const runtimeDeps = makeRuntimeDeps();
|
|
702
|
-
(
|
|
703
|
-
runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
|
|
704
|
-
).mockReturnValue(false);
|
|
705
|
-
const { session } = createSession({ runtimeDeps });
|
|
706
|
-
|
|
707
|
-
expect(session.canPrompt(makeCtx())).toBe(false);
|
|
708
|
-
});
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
describe("prompt", () => {
|
|
712
|
-
it("delegates to runtimeDeps.promptPermission", async () => {
|
|
713
|
-
const { session, runtimeDeps } = createSession();
|
|
714
|
-
const ctx = makeCtx();
|
|
715
|
-
const details = {
|
|
716
|
-
requestId: "req-1",
|
|
717
|
-
source: "tool_call" as const,
|
|
718
|
-
agentName: null,
|
|
719
|
-
message: "Allow?",
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
const result = await session.prompt(ctx, details);
|
|
723
|
-
|
|
724
|
-
expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
|
|
725
|
-
expect(result).toEqual({ approved: true, state: "approved" });
|
|
726
|
-
});
|
|
727
|
-
});
|
|
728
643
|
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for PromptingGateway.
|
|
3
|
+
*
|
|
4
|
+
* The gateway owns the stored ExtensionContext and is the sole implementation
|
|
5
|
+
* of the GatePrompter role. These tests exercise canConfirm() across all
|
|
6
|
+
* policy permutations and verify the prompt/reject contract for promptPermission().
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
import type { ConfigReader } from "#src/config-store";
|
|
12
|
+
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
13
|
+
import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
14
|
+
import type {
|
|
15
|
+
PermissionPrompterApi,
|
|
16
|
+
PromptPermissionDetails,
|
|
17
|
+
} from "#src/permission-prompter";
|
|
18
|
+
import {
|
|
19
|
+
PromptingGateway,
|
|
20
|
+
type PromptingGatewayDeps,
|
|
21
|
+
} from "#src/prompting-gateway";
|
|
22
|
+
|
|
23
|
+
// ── Test helpers ──────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
26
|
+
return {
|
|
27
|
+
cwd: "/test/project",
|
|
28
|
+
hasUI: true,
|
|
29
|
+
ui: {
|
|
30
|
+
setStatus: vi.fn(),
|
|
31
|
+
notify: vi.fn(),
|
|
32
|
+
select: vi.fn(),
|
|
33
|
+
input: vi.fn(),
|
|
34
|
+
},
|
|
35
|
+
sessionManager: {
|
|
36
|
+
getEntries: vi.fn().mockReturnValue([]),
|
|
37
|
+
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
|
|
38
|
+
getSessionId: vi.fn().mockReturnValue(null),
|
|
39
|
+
addEntry: vi.fn(),
|
|
40
|
+
},
|
|
41
|
+
...overrides,
|
|
42
|
+
} as unknown as ExtensionContext;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeConfigReader(
|
|
46
|
+
overrides: Partial<typeof DEFAULT_EXTENSION_CONFIG> = {},
|
|
47
|
+
): ConfigReader {
|
|
48
|
+
return {
|
|
49
|
+
current: vi
|
|
50
|
+
.fn<() => typeof DEFAULT_EXTENSION_CONFIG>()
|
|
51
|
+
.mockReturnValue({ ...DEFAULT_EXTENSION_CONFIG, ...overrides }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makePrompterApi(): PermissionPrompterApi & {
|
|
56
|
+
prompt: ReturnType<typeof vi.fn>;
|
|
57
|
+
} {
|
|
58
|
+
return {
|
|
59
|
+
prompt: vi
|
|
60
|
+
.fn<PermissionPrompterApi["prompt"]>()
|
|
61
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeDetails(): PromptPermissionDetails {
|
|
66
|
+
return {
|
|
67
|
+
requestId: "req-1",
|
|
68
|
+
source: "tool_call",
|
|
69
|
+
agentName: null,
|
|
70
|
+
message: "Allow this?",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeDeps(
|
|
75
|
+
overrides: Partial<PromptingGatewayDeps> = {},
|
|
76
|
+
): PromptingGatewayDeps {
|
|
77
|
+
return {
|
|
78
|
+
config: overrides.config ?? makeConfigReader(),
|
|
79
|
+
subagentSessionsDir:
|
|
80
|
+
overrides.subagentSessionsDir ?? "/test/agent/subagent-sessions",
|
|
81
|
+
registry: overrides.registry,
|
|
82
|
+
prompter: overrides.prompter ?? makePrompterApi(),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe("PromptingGateway", () => {
|
|
89
|
+
describe("canConfirm", () => {
|
|
90
|
+
it("returns false before activate", () => {
|
|
91
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
92
|
+
expect(gateway.canConfirm()).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns true after activate when context has UI", () => {
|
|
96
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
97
|
+
gateway.activate(makeCtx({ hasUI: true }));
|
|
98
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns false when context has no UI, is not a subagent, and yolo mode is off", () => {
|
|
102
|
+
const gateway = new PromptingGateway(
|
|
103
|
+
makeDeps({ config: makeConfigReader({ yoloMode: false }) }),
|
|
104
|
+
);
|
|
105
|
+
gateway.activate(makeCtx({ hasUI: false }));
|
|
106
|
+
expect(gateway.canConfirm()).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns true when yolo mode is enabled (no UI, not subagent)", () => {
|
|
110
|
+
const gateway = new PromptingGateway(
|
|
111
|
+
makeDeps({ config: makeConfigReader({ yoloMode: true }) }),
|
|
112
|
+
);
|
|
113
|
+
gateway.activate(makeCtx({ hasUI: false }));
|
|
114
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns true when running as a subagent (env hint)", () => {
|
|
118
|
+
vi.stubEnv("PI_IS_SUBAGENT", "1");
|
|
119
|
+
const gateway = new PromptingGateway(
|
|
120
|
+
makeDeps({ config: makeConfigReader({ yoloMode: false }) }),
|
|
121
|
+
);
|
|
122
|
+
gateway.activate(makeCtx({ hasUI: false }));
|
|
123
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
124
|
+
vi.unstubAllEnvs();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns false after deactivate", () => {
|
|
128
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
129
|
+
gateway.activate(makeCtx({ hasUI: true }));
|
|
130
|
+
gateway.deactivate();
|
|
131
|
+
expect(gateway.canConfirm()).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns true after re-activate following deactivate", () => {
|
|
135
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
136
|
+
gateway.activate(makeCtx({ hasUI: true }));
|
|
137
|
+
gateway.deactivate();
|
|
138
|
+
gateway.activate(makeCtx({ hasUI: true }));
|
|
139
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("prompt", () => {
|
|
144
|
+
it("rejects before activate", async () => {
|
|
145
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
146
|
+
await expect(gateway.prompt(makeDetails())).rejects.toThrow(
|
|
147
|
+
"prompt called before the session was activated",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("delegates to deps.prompter.prompt with the stored context", async () => {
|
|
152
|
+
const prompter = makePrompterApi();
|
|
153
|
+
const gateway = new PromptingGateway(makeDeps({ prompter }));
|
|
154
|
+
const ctx = makeCtx();
|
|
155
|
+
gateway.activate(ctx);
|
|
156
|
+
const details = makeDetails();
|
|
157
|
+
|
|
158
|
+
const result = await gateway.prompt(details);
|
|
159
|
+
|
|
160
|
+
expect(prompter.prompt).toHaveBeenCalledWith(ctx, details);
|
|
161
|
+
expect(result).toEqual({ approved: true, state: "approved" });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("uses the most recently activated context", async () => {
|
|
165
|
+
const prompter = makePrompterApi();
|
|
166
|
+
const gateway = new PromptingGateway(makeDeps({ prompter }));
|
|
167
|
+
const firstCtx = makeCtx({ cwd: "/first" });
|
|
168
|
+
const secondCtx = makeCtx({ cwd: "/second" });
|
|
169
|
+
|
|
170
|
+
gateway.activate(firstCtx);
|
|
171
|
+
gateway.activate(secondCtx);
|
|
172
|
+
|
|
173
|
+
await gateway.prompt(makeDetails());
|
|
174
|
+
|
|
175
|
+
expect(prompter.prompt).toHaveBeenCalledWith(
|
|
176
|
+
secondCtx,
|
|
177
|
+
expect.anything(),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("rejects after deactivate", async () => {
|
|
182
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
183
|
+
gateway.activate(makeCtx());
|
|
184
|
+
gateway.deactivate();
|
|
185
|
+
await expect(gateway.prompt(makeDetails())).rejects.toThrow(
|
|
186
|
+
"prompt called before the session was activated",
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("returns the prompter decision", async () => {
|
|
191
|
+
const decision: PermissionPromptDecision = {
|
|
192
|
+
approved: false,
|
|
193
|
+
state: "denied",
|
|
194
|
+
denialReason: "user declined",
|
|
195
|
+
};
|
|
196
|
+
const prompter = makePrompterApi();
|
|
197
|
+
prompter.prompt.mockResolvedValue(decision);
|
|
198
|
+
const gateway = new PromptingGateway(makeDeps({ prompter }));
|
|
199
|
+
gateway.activate(makeCtx());
|
|
200
|
+
|
|
201
|
+
const result = await gateway.prompt(makeDetails());
|
|
202
|
+
|
|
203
|
+
expect(result).toEqual(decision);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("lifecycle", () => {
|
|
208
|
+
afterEach(() => {
|
|
209
|
+
vi.unstubAllEnvs();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("activate then deactivate clears the stored context", () => {
|
|
213
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
214
|
+
gateway.activate(makeCtx());
|
|
215
|
+
gateway.deactivate();
|
|
216
|
+
expect(gateway.canConfirm()).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("multiple activate calls update the stored context", () => {
|
|
220
|
+
const prompter = makePrompterApi();
|
|
221
|
+
const gateway = new PromptingGateway(makeDeps({ prompter }));
|
|
222
|
+
const ctx2 = makeCtx({ cwd: "/new" });
|
|
223
|
+
gateway.activate(makeCtx({ cwd: "/old" }));
|
|
224
|
+
gateway.activate(ctx2);
|
|
225
|
+
|
|
226
|
+
// canConfirm still works (context set)
|
|
227
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|