@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
|
@@ -10,11 +10,9 @@ import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
|
10
10
|
import type { ExtensionPaths } from "./extension-paths";
|
|
11
11
|
import type { ForwardingController } from "./forwarding-manager";
|
|
12
12
|
import type { GateHandlerSession } from "./gate-handler-session";
|
|
13
|
-
import type { GatePrompter } from "./gate-prompter";
|
|
14
|
-
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
15
13
|
import type { ScopedPermissionManager } from "./permission-manager";
|
|
16
|
-
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
17
14
|
import type { PermissionResolver } from "./permission-resolver";
|
|
15
|
+
import type { PromptingGatewayLifecycle } from "./prompting-gateway";
|
|
18
16
|
import type { Rule } from "./rule";
|
|
19
17
|
import type { SessionApproval } from "./session-approval";
|
|
20
18
|
import type { SessionApprovalRecorder } from "./session-approval-recorder";
|
|
@@ -28,21 +26,6 @@ import {
|
|
|
28
26
|
} from "./tool-preview-formatter";
|
|
29
27
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
30
28
|
|
|
31
|
-
/**
|
|
32
|
-
* Runtime operations that `PermissionSession` delegates to but does not own.
|
|
33
|
-
*
|
|
34
|
-
* Injected at construction time from the composition root (`index.ts`).
|
|
35
|
-
*/
|
|
36
|
-
export interface PermissionSessionRuntimeDeps {
|
|
37
|
-
/** Whether the current context can show an interactive permission prompt. */
|
|
38
|
-
canRequestPermissionConfirmation(ctx: ExtensionContext): boolean;
|
|
39
|
-
/** Prompt the user for a permission decision, log the outcome, and return it. */
|
|
40
|
-
promptPermission(
|
|
41
|
-
ctx: ExtensionContext,
|
|
42
|
-
details: PromptPermissionDetails,
|
|
43
|
-
): Promise<PermissionPromptDecision>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
29
|
/**
|
|
47
30
|
* Encapsulates all mutable session state and exposes operations instead of
|
|
48
31
|
* fields.
|
|
@@ -56,13 +39,12 @@ export interface PermissionSessionRuntimeDeps {
|
|
|
56
39
|
* - `SessionLogger` — debug + review + warn
|
|
57
40
|
* - `ForwardingController` — polling lifecycle
|
|
58
41
|
* - `SessionConfigStore` — owns extension config; provides refresh, log, read
|
|
59
|
-
* - `
|
|
42
|
+
* - `PromptingGatewayLifecycle` — prompting lifecycle forwarded via activate/deactivate
|
|
60
43
|
*/
|
|
61
44
|
export class PermissionSession
|
|
62
45
|
implements
|
|
63
46
|
PermissionResolver,
|
|
64
47
|
SessionApprovalRecorder,
|
|
65
|
-
GatePrompter,
|
|
66
48
|
GateHandlerSession,
|
|
67
49
|
AgentPrepSession,
|
|
68
50
|
SessionLifecycleSession
|
|
@@ -80,21 +62,23 @@ export class PermissionSession
|
|
|
80
62
|
private readonly permissionManager: ScopedPermissionManager,
|
|
81
63
|
private readonly sessionRules: SessionRules,
|
|
82
64
|
private readonly configStore: SessionConfigStore,
|
|
83
|
-
private readonly
|
|
65
|
+
private readonly gateway: PromptingGatewayLifecycle,
|
|
84
66
|
) {}
|
|
85
67
|
|
|
86
68
|
// ── Context lifecycle ──────────────────────────────────────────────────
|
|
87
69
|
|
|
88
|
-
/** Store the current extension context and
|
|
70
|
+
/** Store the current extension context, start forwarding, and activate the gateway. */
|
|
89
71
|
activate(ctx: ExtensionContext): void {
|
|
90
72
|
this.context = ctx;
|
|
91
73
|
this.forwarding.start(ctx);
|
|
74
|
+
this.gateway.activate(ctx);
|
|
92
75
|
}
|
|
93
76
|
|
|
94
|
-
/** Clear the context and
|
|
77
|
+
/** Clear the context, stop forwarding, and deactivate the gateway. */
|
|
95
78
|
deactivate(): void {
|
|
96
79
|
this.context = null;
|
|
97
80
|
this.forwarding.stop();
|
|
81
|
+
this.gateway.deactivate();
|
|
98
82
|
}
|
|
99
83
|
|
|
100
84
|
/** Return the current runtime context, or null if not activated. */
|
|
@@ -291,44 +275,4 @@ export class PermissionSession
|
|
|
291
275
|
getToolPreviewLimits(): ToolPreviewFormatterOptions {
|
|
292
276
|
return resolveToolPreviewLimits(this.config);
|
|
293
277
|
}
|
|
294
|
-
|
|
295
|
-
// ── Prompting ──────────────────────────────────────────────────────────
|
|
296
|
-
|
|
297
|
-
/** Whether the current context can show an interactive permission prompt. */
|
|
298
|
-
canPrompt(ctx: ExtensionContext): boolean {
|
|
299
|
-
return this.runtimeDeps.canRequestPermissionConfirmation(ctx);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** Prompt the user for a permission decision, log the outcome, and return it. */
|
|
303
|
-
prompt(
|
|
304
|
-
ctx: ExtensionContext,
|
|
305
|
-
details: PromptPermissionDetails,
|
|
306
|
-
): Promise<PermissionPromptDecision> {
|
|
307
|
-
return this.runtimeDeps.promptPermission(ctx, details);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Whether an interactive confirmation is possible using the stored context.
|
|
312
|
-
* Returns `false` when no context is active (before `activate` is called).
|
|
313
|
-
* Implements {@link GatePrompter}.
|
|
314
|
-
*/
|
|
315
|
-
canConfirm(): boolean {
|
|
316
|
-
return this.context !== null && this.canPrompt(this.context);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Prompt the user for a permission decision using the stored context.
|
|
321
|
-
* Throws if no context is active — `canConfirm()` guards this in normal use.
|
|
322
|
-
* Implements {@link GatePrompter}.
|
|
323
|
-
*/
|
|
324
|
-
promptPermission(
|
|
325
|
-
details: PromptPermissionDetails,
|
|
326
|
-
): Promise<PermissionPromptDecision> {
|
|
327
|
-
if (this.context === null) {
|
|
328
|
-
return Promise.reject(
|
|
329
|
-
new Error("promptPermission called before the session was activated"),
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
return this.prompt(this.context, details);
|
|
333
|
-
}
|
|
334
278
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { ConfigReader } from "./config-store";
|
|
4
|
+
import type { GatePrompter } from "./gate-prompter";
|
|
5
|
+
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
6
|
+
import type {
|
|
7
|
+
PermissionPrompterApi,
|
|
8
|
+
PromptPermissionDetails,
|
|
9
|
+
} from "./permission-prompter";
|
|
10
|
+
import { isSubagentExecutionContext } from "./subagent-context";
|
|
11
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
12
|
+
import { canResolveAskPermissionRequest } from "./yolo-mode";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Dependencies required by PromptingGateway.
|
|
16
|
+
*
|
|
17
|
+
* All four fields are actively consumed:
|
|
18
|
+
* - `config` + `subagentSessionsDir` + `registry` drive `canConfirm()`.
|
|
19
|
+
* - `prompter` is called by `prompt()`.
|
|
20
|
+
*/
|
|
21
|
+
export interface PromptingGatewayDeps {
|
|
22
|
+
/** Read current config for the yolo-mode branch of the can-prompt policy. */
|
|
23
|
+
config: ConfigReader;
|
|
24
|
+
/** Static path used to detect a forwarding subagent context. */
|
|
25
|
+
subagentSessionsDir: string;
|
|
26
|
+
/** Process-global registry used to detect a registered child session. */
|
|
27
|
+
registry?: SubagentSessionRegistry;
|
|
28
|
+
/** Resolves the permission decision: direct UI dialog or forwarded to parent. */
|
|
29
|
+
prompter: PermissionPrompterApi;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The lifecycle slice of the gateway that PermissionSession drives.
|
|
34
|
+
*
|
|
35
|
+
* PermissionSession calls activate/deactivate to keep the gateway's stored
|
|
36
|
+
* context in sync with its own — the same pattern used for ForwardingController.
|
|
37
|
+
*/
|
|
38
|
+
export interface PromptingGatewayLifecycle {
|
|
39
|
+
activate(ctx: ExtensionContext): void;
|
|
40
|
+
deactivate(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Context-owning implementation of the GatePrompter role.
|
|
45
|
+
*
|
|
46
|
+
* Owns the stored ExtensionContext and the "can we prompt?" policy
|
|
47
|
+
* (UI / subagent / yolo-mode), replacing the four twin methods
|
|
48
|
+
* that previously lived on PermissionSession.
|
|
49
|
+
*
|
|
50
|
+
* Lifecycle: PermissionSession drives activate/deactivate so the stored
|
|
51
|
+
* context mirrors the session context without independent call-site changes.
|
|
52
|
+
*/
|
|
53
|
+
export class PromptingGateway
|
|
54
|
+
implements GatePrompter, PromptingGatewayLifecycle
|
|
55
|
+
{
|
|
56
|
+
private context: ExtensionContext | null = null;
|
|
57
|
+
|
|
58
|
+
constructor(private readonly deps: PromptingGatewayDeps) {}
|
|
59
|
+
|
|
60
|
+
/** Store the current extension context. */
|
|
61
|
+
activate(ctx: ExtensionContext): void {
|
|
62
|
+
this.context = ctx;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Clear the stored context. */
|
|
66
|
+
deactivate(): void {
|
|
67
|
+
this.context = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether an interactive permission prompt can be shown.
|
|
72
|
+
*
|
|
73
|
+
* Returns false when no context is active. Otherwise delegates to
|
|
74
|
+
* canResolveAskPermissionRequest, which checks hasUI, subagent status,
|
|
75
|
+
* and yolo-mode — relocating the policy from the index.ts closure.
|
|
76
|
+
*/
|
|
77
|
+
canConfirm(): boolean {
|
|
78
|
+
if (this.context === null) return false;
|
|
79
|
+
return canResolveAskPermissionRequest({
|
|
80
|
+
config: this.deps.config.current(),
|
|
81
|
+
hasUI: this.context.hasUI,
|
|
82
|
+
isSubagent: isSubagentExecutionContext(
|
|
83
|
+
this.context,
|
|
84
|
+
this.deps.subagentSessionsDir,
|
|
85
|
+
this.deps.registry,
|
|
86
|
+
),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Prompt the user for a permission decision using the stored context.
|
|
92
|
+
*
|
|
93
|
+
* Rejects if no context is active — canConfirm() guards this in normal use.
|
|
94
|
+
* Implements {@link GatePrompter}.
|
|
95
|
+
*/
|
|
96
|
+
prompt(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
|
|
97
|
+
if (this.context === null) {
|
|
98
|
+
return Promise.reject(
|
|
99
|
+
new Error("prompt called before the session was activated"),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return this.deps.prompter.prompt(this.context, details);
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/session-logger.ts
CHANGED
|
@@ -6,6 +6,22 @@ import {
|
|
|
6
6
|
} from "./extension-config";
|
|
7
7
|
import { createPermissionSystemLogger } from "./logging";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Narrowest logging seam — consumers that only write review-log entries.
|
|
11
|
+
* Injected into `PermissionPrompter` and the RPC handlers.
|
|
12
|
+
*/
|
|
13
|
+
export interface ReviewLogger {
|
|
14
|
+
review(event: string, details?: Record<string, unknown>): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Logging seam for consumers that write both debug and review entries.
|
|
19
|
+
* Injected into `ConfigStore` and `PermissionForwarder`.
|
|
20
|
+
*/
|
|
21
|
+
export interface DebugReviewLogger extends ReviewLogger {
|
|
22
|
+
debug(event: string, details?: Record<string, unknown>): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
/**
|
|
10
26
|
* Unified logging + notification surface for handler deps.
|
|
11
27
|
*
|
|
@@ -13,9 +29,7 @@ import { createPermissionSystemLogger } from "./logging";
|
|
|
13
29
|
* `writeReviewLog`, `notifyWarning`) with a single typed collaborator.
|
|
14
30
|
* This is an intermediate abstraction on the path to PermissionSession (#129).
|
|
15
31
|
*/
|
|
16
|
-
export interface SessionLogger {
|
|
17
|
-
debug(event: string, details?: Record<string, unknown>): void;
|
|
18
|
-
review(event: string, details?: Record<string, unknown>): void;
|
|
32
|
+
export interface SessionLogger extends DebugReviewLogger {
|
|
19
33
|
warn(message: string): void;
|
|
20
34
|
}
|
|
21
35
|
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
normalizePermissionSystemConfig,
|
|
10
10
|
type PermissionSystemExtensionConfig,
|
|
11
11
|
} from "#src/extension-config";
|
|
12
|
-
import type { Rule } from "#src/rule";
|
|
12
|
+
import type { Rule, Ruleset } from "#src/rule";
|
|
13
13
|
|
|
14
14
|
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
15
15
|
getSettingsListTheme: () => ({}),
|
|
@@ -88,7 +88,9 @@ test("permission-system command completions expose top-level config actions", ()
|
|
|
88
88
|
};
|
|
89
89
|
const controller = {
|
|
90
90
|
config: configStore,
|
|
91
|
-
|
|
91
|
+
configPath,
|
|
92
|
+
permissionManager: { getComposedConfigRules: () => [] as Ruleset },
|
|
93
|
+
session: { lastKnownActiveAgentName: null },
|
|
92
94
|
};
|
|
93
95
|
|
|
94
96
|
let definition: {
|
|
@@ -160,7 +162,9 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
160
162
|
};
|
|
161
163
|
const controller = {
|
|
162
164
|
config: configStore,
|
|
163
|
-
|
|
165
|
+
configPath,
|
|
166
|
+
permissionManager: { getComposedConfigRules: () => [] as Ruleset },
|
|
167
|
+
session: { lastKnownActiveAgentName: null },
|
|
164
168
|
};
|
|
165
169
|
|
|
166
170
|
let registeredName = "";
|
|
@@ -257,8 +261,9 @@ test("show output includes rule origins when getComposedRules is provided", asyn
|
|
|
257
261
|
|
|
258
262
|
const controller = {
|
|
259
263
|
config: { current: () => config, save: () => {} } as CommandConfigStore,
|
|
260
|
-
|
|
261
|
-
|
|
264
|
+
configPath: "/fake/config.json",
|
|
265
|
+
permissionManager: { getComposedConfigRules: () => composedRules },
|
|
266
|
+
session: { lastKnownActiveAgentName: null },
|
|
262
267
|
};
|
|
263
268
|
|
|
264
269
|
let definition: {
|
|
@@ -289,8 +294,9 @@ test("show output omits rule summary when getComposedRules is not provided", asy
|
|
|
289
294
|
|
|
290
295
|
const controller = {
|
|
291
296
|
config: { current: () => config, save: () => {} } as CommandConfigStore,
|
|
292
|
-
|
|
293
|
-
|
|
297
|
+
configPath: "/fake/config.json",
|
|
298
|
+
permissionManager: { getComposedConfigRules: () => [] as Ruleset },
|
|
299
|
+
session: { lastKnownActiveAgentName: null },
|
|
294
300
|
};
|
|
295
301
|
|
|
296
302
|
let definition: {
|
|
@@ -90,10 +90,8 @@ function makePolicyPathProvider(
|
|
|
90
90
|
|
|
91
91
|
function makeLogger() {
|
|
92
92
|
return {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
writeReviewLog:
|
|
96
|
-
vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
|
|
93
|
+
debug: vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
|
|
94
|
+
review: vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
|
|
97
95
|
};
|
|
98
96
|
}
|
|
99
97
|
|
|
@@ -197,7 +195,7 @@ describe("ConfigStore", () => {
|
|
|
197
195
|
it("writes config.loaded debug log", () => {
|
|
198
196
|
const { store, logger } = makeStore();
|
|
199
197
|
store.refresh();
|
|
200
|
-
expect(logger.
|
|
198
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
201
199
|
"config.loaded",
|
|
202
200
|
expect.objectContaining({ debugLog: false }),
|
|
203
201
|
);
|
|
@@ -336,7 +334,7 @@ describe("ConfigStore", () => {
|
|
|
336
334
|
it("writes config.saved debug log after a successful save", () => {
|
|
337
335
|
const { store, logger } = makeStore();
|
|
338
336
|
store.save({ ...DEFAULT_EXTENSION_CONFIG }, makeCommandCtx());
|
|
339
|
-
expect(logger.
|
|
337
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
340
338
|
"config.saved",
|
|
341
339
|
expect.objectContaining({ debugLog: false }),
|
|
342
340
|
);
|
|
@@ -357,7 +355,7 @@ describe("ConfigStore", () => {
|
|
|
357
355
|
// current() is not updated on failure
|
|
358
356
|
expect(store.current()).toEqual(DEFAULT_EXTENSION_CONFIG);
|
|
359
357
|
// no debug log on failure
|
|
360
|
-
expect(logger.
|
|
358
|
+
expect(logger.debug).not.toHaveBeenCalledWith(
|
|
361
359
|
"config.saved",
|
|
362
360
|
expect.anything(),
|
|
363
361
|
);
|
|
@@ -381,11 +379,11 @@ describe("ConfigStore", () => {
|
|
|
381
379
|
it("writes config.resolved to both review and debug logs", () => {
|
|
382
380
|
const { store, logger } = makeStore();
|
|
383
381
|
store.logResolvedPaths();
|
|
384
|
-
expect(logger.
|
|
382
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
385
383
|
"config.resolved",
|
|
386
384
|
expect.any(Object),
|
|
387
385
|
);
|
|
388
|
-
expect(logger.
|
|
386
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
389
387
|
"config.resolved",
|
|
390
388
|
expect.any(Object),
|
|
391
389
|
);
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
import type { ForwardedPermissionLogger } from "#src/forwarded-permissions/io";
|
|
4
3
|
import {
|
|
5
4
|
formatUnknownErrorMessage,
|
|
6
5
|
isErrnoCode,
|
|
7
6
|
logPermissionForwardingError,
|
|
8
7
|
logPermissionForwardingWarning,
|
|
9
8
|
} from "#src/forwarded-permissions/io";
|
|
9
|
+
import type { DebugReviewLogger } from "#src/session-logger";
|
|
10
10
|
|
|
11
11
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
12
12
|
|
|
13
|
-
function makeLogger():
|
|
13
|
+
function makeLogger(): DebugReviewLogger {
|
|
14
14
|
return {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
review: vi.fn(),
|
|
16
|
+
debug: vi.fn(),
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -59,28 +59,27 @@ describe("isErrnoCode", () => {
|
|
|
59
59
|
// ── logPermissionForwardingWarning ─────────────────────────────────────────
|
|
60
60
|
|
|
61
61
|
describe("logPermissionForwardingWarning", () => {
|
|
62
|
-
it("calls logger.
|
|
62
|
+
it("calls logger.review with the warning event", () => {
|
|
63
63
|
const logger = makeLogger();
|
|
64
64
|
logPermissionForwardingWarning(logger, "something went wrong");
|
|
65
|
-
expect(logger.
|
|
65
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
66
66
|
"permission_forwarding.warning",
|
|
67
67
|
{ message: "something went wrong" },
|
|
68
68
|
);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
it("calls logger.
|
|
71
|
+
it("calls logger.debug with the warning event", () => {
|
|
72
72
|
const logger = makeLogger();
|
|
73
73
|
logPermissionForwardingWarning(logger, "something went wrong");
|
|
74
|
-
expect(logger.
|
|
75
|
-
"
|
|
76
|
-
|
|
77
|
-
);
|
|
74
|
+
expect(logger.debug).toHaveBeenCalledWith("permission_forwarding.warning", {
|
|
75
|
+
message: "something went wrong",
|
|
76
|
+
});
|
|
78
77
|
});
|
|
79
78
|
|
|
80
79
|
it("includes formatted error when an error is provided", () => {
|
|
81
80
|
const logger = makeLogger();
|
|
82
81
|
logPermissionForwardingWarning(logger, "bad thing", new Error("fs fail"));
|
|
83
|
-
expect(logger.
|
|
82
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
84
83
|
"permission_forwarding.warning",
|
|
85
84
|
{ message: "bad thing", error: "fs fail" },
|
|
86
85
|
);
|
|
@@ -102,31 +101,29 @@ describe("logPermissionForwardingWarning", () => {
|
|
|
102
101
|
// ── logPermissionForwardingError ───────────────────────────────────────────
|
|
103
102
|
|
|
104
103
|
describe("logPermissionForwardingError", () => {
|
|
105
|
-
it("calls logger.
|
|
104
|
+
it("calls logger.review with the error event", () => {
|
|
106
105
|
const logger = makeLogger();
|
|
107
106
|
logPermissionForwardingError(logger, "critical failure");
|
|
108
|
-
expect(logger.
|
|
109
|
-
"
|
|
110
|
-
|
|
111
|
-
);
|
|
107
|
+
expect(logger.review).toHaveBeenCalledWith("permission_forwarding.error", {
|
|
108
|
+
message: "critical failure",
|
|
109
|
+
});
|
|
112
110
|
});
|
|
113
111
|
|
|
114
|
-
it("calls logger.
|
|
112
|
+
it("calls logger.debug with the error event", () => {
|
|
115
113
|
const logger = makeLogger();
|
|
116
114
|
logPermissionForwardingError(logger, "critical failure");
|
|
117
|
-
expect(logger.
|
|
118
|
-
"
|
|
119
|
-
|
|
120
|
-
);
|
|
115
|
+
expect(logger.debug).toHaveBeenCalledWith("permission_forwarding.error", {
|
|
116
|
+
message: "critical failure",
|
|
117
|
+
});
|
|
121
118
|
});
|
|
122
119
|
|
|
123
120
|
it("includes formatted error when an error is provided", () => {
|
|
124
121
|
const logger = makeLogger();
|
|
125
122
|
logPermissionForwardingError(logger, "io error", new Error("ENOENT"));
|
|
126
|
-
expect(logger.
|
|
127
|
-
"
|
|
128
|
-
|
|
129
|
-
);
|
|
123
|
+
expect(logger.review).toHaveBeenCalledWith("permission_forwarding.error", {
|
|
124
|
+
message: "io error",
|
|
125
|
+
error: "ENOENT",
|
|
126
|
+
});
|
|
130
127
|
});
|
|
131
128
|
|
|
132
129
|
it("does not throw when logger is null", () => {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { describe, expect, it, vi } from "vitest";
|
|
13
13
|
|
|
14
14
|
import { EXTENSION_TAG } from "#src/denial-messages";
|
|
15
|
+
import type { GatePrompter } from "#src/gate-prompter";
|
|
15
16
|
import { formatExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
|
|
16
17
|
import type { PermissionCheckResult } from "#src/types";
|
|
17
18
|
|
|
@@ -218,13 +219,13 @@ describe("external_directory — allow external reads, gate external writes (#14
|
|
|
218
219
|
});
|
|
219
220
|
|
|
220
221
|
it("prompts for write to external path when external_directory allows but write is ask", async () => {
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
222
|
+
const { handler, prompter } = makeHandler({
|
|
223
|
+
session: { checkPermission: makeExtDirCheck("allow", "ask") },
|
|
224
|
+
prompter: {
|
|
225
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
226
|
+
prompt: vi
|
|
227
|
+
.fn<GatePrompter["prompt"]>()
|
|
228
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
228
229
|
},
|
|
229
230
|
tools: ALL_TOOLS,
|
|
230
231
|
});
|
|
@@ -234,7 +235,7 @@ describe("external_directory — allow external reads, gate external writes (#14
|
|
|
234
235
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
235
236
|
// external_directory passes; write gate prompts and user approves
|
|
236
237
|
expect(result).toEqual({});
|
|
237
|
-
expect(prompt).toHaveBeenCalledOnce();
|
|
238
|
+
expect(prompter.prompt).toHaveBeenCalledOnce();
|
|
238
239
|
});
|
|
239
240
|
|
|
240
241
|
it("blocks write to external path when external_directory allows but write is deny", async () => {
|
|
@@ -341,10 +342,11 @@ describe("external_directory policy state — deny", () => {
|
|
|
341
342
|
describe("external_directory policy state — ask", () => {
|
|
342
343
|
it("does not block when user approves", async () => {
|
|
343
344
|
const { handler } = makeHandler({
|
|
344
|
-
session: {
|
|
345
|
-
|
|
345
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
346
|
+
prompter: {
|
|
347
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
346
348
|
prompt: vi
|
|
347
|
-
.fn()
|
|
349
|
+
.fn<GatePrompter["prompt"]>()
|
|
348
350
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
349
351
|
},
|
|
350
352
|
tools: ALL_TOOLS,
|
|
@@ -356,10 +358,11 @@ describe("external_directory policy state — ask", () => {
|
|
|
356
358
|
|
|
357
359
|
it("emits user_approved decision when user approves", async () => {
|
|
358
360
|
const { handler, events } = makeHandler({
|
|
359
|
-
session: {
|
|
360
|
-
|
|
361
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
362
|
+
prompter: {
|
|
363
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
361
364
|
prompt: vi
|
|
362
|
-
.fn()
|
|
365
|
+
.fn<GatePrompter["prompt"]>()
|
|
363
366
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
364
367
|
},
|
|
365
368
|
tools: ALL_TOOLS,
|
|
@@ -379,9 +382,12 @@ describe("external_directory policy state — ask", () => {
|
|
|
379
382
|
|
|
380
383
|
it("blocks when user denies", async () => {
|
|
381
384
|
const { handler } = makeHandler({
|
|
382
|
-
session: {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
386
|
+
prompter: {
|
|
387
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
388
|
+
prompt: vi
|
|
389
|
+
.fn<GatePrompter["prompt"]>()
|
|
390
|
+
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
385
391
|
},
|
|
386
392
|
tools: ALL_TOOLS,
|
|
387
393
|
});
|
|
@@ -392,9 +398,12 @@ describe("external_directory policy state — ask", () => {
|
|
|
392
398
|
|
|
393
399
|
it("emits user_denied decision when user denies", async () => {
|
|
394
400
|
const { handler, events } = makeHandler({
|
|
395
|
-
session: {
|
|
396
|
-
|
|
397
|
-
|
|
401
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
402
|
+
prompter: {
|
|
403
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
404
|
+
prompt: vi
|
|
405
|
+
.fn<GatePrompter["prompt"]>()
|
|
406
|
+
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
398
407
|
},
|
|
399
408
|
tools: ALL_TOOLS,
|
|
400
409
|
});
|
|
@@ -413,9 +422,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
413
422
|
|
|
414
423
|
it("block reason includes denialReason when user provides one", async () => {
|
|
415
424
|
const { handler } = makeHandler({
|
|
416
|
-
session: {
|
|
417
|
-
|
|
418
|
-
|
|
425
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
426
|
+
prompter: {
|
|
427
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
428
|
+
prompt: vi.fn<GatePrompter["prompt"]>().mockResolvedValue({
|
|
419
429
|
approved: false,
|
|
420
430
|
state: "denied",
|
|
421
431
|
denialReason: "not needed",
|
|
@@ -431,9 +441,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
431
441
|
|
|
432
442
|
it("blocks with confirmation_unavailable when no UI is available", async () => {
|
|
433
443
|
const { handler } = makeHandler({
|
|
434
|
-
session: {
|
|
435
|
-
|
|
436
|
-
|
|
444
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
445
|
+
prompter: {
|
|
446
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
447
|
+
prompt: vi.fn<GatePrompter["prompt"]>(),
|
|
437
448
|
},
|
|
438
449
|
tools: ALL_TOOLS,
|
|
439
450
|
});
|
|
@@ -448,9 +459,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
448
459
|
|
|
449
460
|
it("writes review-log entry with confirmation_unavailable when no UI", async () => {
|
|
450
461
|
const { handler, session } = makeHandler({
|
|
451
|
-
session: {
|
|
452
|
-
|
|
453
|
-
|
|
462
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
463
|
+
prompter: {
|
|
464
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
465
|
+
prompt: vi.fn<GatePrompter["prompt"]>(),
|
|
454
466
|
},
|
|
455
467
|
tools: ALL_TOOLS,
|
|
456
468
|
});
|
|
@@ -469,9 +481,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
469
481
|
|
|
470
482
|
it("emits confirmation_unavailable decision when no UI", async () => {
|
|
471
483
|
const { handler, events } = makeHandler({
|
|
472
|
-
session: {
|
|
473
|
-
|
|
474
|
-
|
|
484
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
485
|
+
prompter: {
|
|
486
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
487
|
+
prompt: vi.fn<GatePrompter["prompt"]>(),
|
|
475
488
|
},
|
|
476
489
|
tools: ALL_TOOLS,
|
|
477
490
|
});
|