@gotgenes/pi-permission-system 4.3.0 → 4.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 +18 -0
- package/package.json +1 -1
- package/src/index.ts +10 -3
- package/src/permission-prompter.ts +146 -0
- package/src/runtime.ts +0 -76
- package/tests/permission-prompter.test.ts +398 -0
- package/tests/runtime.test.ts +0 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ 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
|
+
## [4.4.0](https://github.com/gotgenes/pi-permission-system/compare/v4.3.0...v4.4.0) (2026-05-05)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* wire PermissionPrompter and remove runtime promptPermission ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([8e0980a](https://github.com/gotgenes/pi-permission-system/commit/8e0980a207f9f665ad2af3ad635d73e28d313c91))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* add permission-prompter architecture note ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([6cc1b60](https://github.com/gotgenes/pi-permission-system/commit/6cc1b6089a332f262919d20ad27d5ca7a69b0b6d))
|
|
19
|
+
* add permission-prompter to target architecture module map ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([c5cf101](https://github.com/gotgenes/pi-permission-system/commit/c5cf101af827dc108c66c5dffff94e05d4234e4c))
|
|
20
|
+
* add permission-prompter to v3 architecture module map ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([94be5b5](https://github.com/gotgenes/pi-permission-system/commit/94be5b58b9018b1918102089bb2fff8401d8bad5))
|
|
21
|
+
* plan extract PermissionPrompter class ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([50fcf34](https://github.com/gotgenes/pi-permission-system/commit/50fcf3400ed8574b036cacd2afc1b9e2161d41c6))
|
|
22
|
+
* remove interim permission-prompter from target architecture map ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([f300f08](https://github.com/gotgenes/pi-permission-system/commit/f300f086b42d9d52ff0668b63a191addebe3140e))
|
|
23
|
+
* **retro:** add retro notes for issue [#51](https://github.com/gotgenes/pi-permission-system/issues/51) ([79a564d](https://github.com/gotgenes/pi-permission-system/commit/79a564d24977da7cffa3d9d65391a75dd0d7e99c))
|
|
24
|
+
* update target architecture for completed work and new issues ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([e661345](https://github.com/gotgenes/pi-permission-system/commit/e661345c8a699e702cbaa9adb7d61c80a25010d2))
|
|
25
|
+
|
|
8
26
|
## [4.3.0](https://github.com/gotgenes/pi-permission-system/compare/v4.2.0...v4.3.0) (2026-05-04)
|
|
9
27
|
|
|
10
28
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -12,11 +12,11 @@ import {
|
|
|
12
12
|
handleToolCall,
|
|
13
13
|
} from "./handlers";
|
|
14
14
|
import { requestPermissionDecisionFromUi } from "./permission-dialog";
|
|
15
|
+
import { PermissionPrompter } from "./permission-prompter";
|
|
15
16
|
import {
|
|
16
17
|
createExtensionRuntime,
|
|
17
18
|
createPermissionManagerForCwd,
|
|
18
19
|
logResolvedConfigPaths,
|
|
19
|
-
promptPermission,
|
|
20
20
|
refreshExtensionConfig,
|
|
21
21
|
resolveAgentName,
|
|
22
22
|
saveExtensionConfig,
|
|
@@ -32,6 +32,14 @@ import {
|
|
|
32
32
|
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
33
33
|
const runtime = createExtensionRuntime();
|
|
34
34
|
|
|
35
|
+
const prompter = new PermissionPrompter({
|
|
36
|
+
getConfig: () => runtime.config,
|
|
37
|
+
writeReviewLog: runtime.writeReviewLog.bind(runtime),
|
|
38
|
+
subagentSessionsDir: runtime.subagentSessionsDir,
|
|
39
|
+
forwardingDir: runtime.forwardingDir,
|
|
40
|
+
requestPermissionDecisionFromUi,
|
|
41
|
+
});
|
|
42
|
+
|
|
35
43
|
const forwardingDeps: PermissionForwardingDeps = {
|
|
36
44
|
forwardingDir: runtime.forwardingDir,
|
|
37
45
|
subagentSessionsDir: runtime.subagentSessionsDir,
|
|
@@ -74,8 +82,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
74
82
|
runtime.subagentSessionsDir,
|
|
75
83
|
),
|
|
76
84
|
}),
|
|
77
|
-
promptPermission: (ctx, details) =>
|
|
78
|
-
promptPermission(runtime, forwardingDeps, ctx, details),
|
|
85
|
+
promptPermission: (ctx, details) => prompter.prompt(ctx, details),
|
|
79
86
|
createPermissionRequestId,
|
|
80
87
|
startForwardedPermissionPolling: (ctx) =>
|
|
81
88
|
startForwardedPermissionPolling(runtime, forwardingDeps, ctx),
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
3
|
+
import type { ForwardedPermissionLogger } from "./forwarded-permissions/io";
|
|
4
|
+
import {
|
|
5
|
+
confirmPermission,
|
|
6
|
+
type PermissionForwardingDeps,
|
|
7
|
+
} from "./forwarded-permissions/polling";
|
|
8
|
+
import type { PromptPermissionDetails } from "./handlers/types";
|
|
9
|
+
import type {
|
|
10
|
+
PermissionPromptDecision,
|
|
11
|
+
RequestPermissionOptions,
|
|
12
|
+
} from "./permission-dialog";
|
|
13
|
+
import { shouldAutoApprovePermissionState } from "./yolo-mode";
|
|
14
|
+
|
|
15
|
+
/** Mockable contract exposed to handlers via HandlerDeps. */
|
|
16
|
+
export interface PermissionPrompterApi {
|
|
17
|
+
prompt(
|
|
18
|
+
ctx: ExtensionContext,
|
|
19
|
+
details: PromptPermissionDetails,
|
|
20
|
+
): Promise<PermissionPromptDecision>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Dependencies required by PermissionPrompter.
|
|
25
|
+
*
|
|
26
|
+
* Keeps the prompter's external surface narrow: callers provide config
|
|
27
|
+
* access, review-log writing, path constants, and the UI dialog function.
|
|
28
|
+
* The prompter synthesises the PermissionForwardingDeps it needs internally.
|
|
29
|
+
*/
|
|
30
|
+
export interface PermissionPrompterDeps {
|
|
31
|
+
/** Read current config for yolo-mode check (called at prompt time). */
|
|
32
|
+
getConfig(): PermissionSystemExtensionConfig;
|
|
33
|
+
/** Write structured entries to the permission review log. */
|
|
34
|
+
writeReviewLog(event: string, details: Record<string, unknown>): void;
|
|
35
|
+
/** Directory containing subagent session state. */
|
|
36
|
+
subagentSessionsDir: string;
|
|
37
|
+
/** Directory used for file-based permission forwarding requests/responses. */
|
|
38
|
+
forwardingDir: string;
|
|
39
|
+
/** Show the interactive permission dialog in the UI. */
|
|
40
|
+
requestPermissionDecisionFromUi(
|
|
41
|
+
ui: ExtensionContext["ui"],
|
|
42
|
+
title: string,
|
|
43
|
+
message: string,
|
|
44
|
+
options?: RequestPermissionOptions,
|
|
45
|
+
): Promise<PermissionPromptDecision>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Encapsulates the full permission-prompt flow:
|
|
50
|
+
* 1. Yolo-mode auto-approval check.
|
|
51
|
+
* 2. Review-log "waiting" entry.
|
|
52
|
+
* 3. UI-present vs. subagent-forwarding branching (via confirmPermission).
|
|
53
|
+
* 4. Review-log "approved" / "denied" entry.
|
|
54
|
+
*
|
|
55
|
+
* Injecting a single PermissionPrompter instance into HandlerDeps means
|
|
56
|
+
* adding a new prompt parameter (e.g. a future sessionLabel variant) only
|
|
57
|
+
* requires changing PromptPermissionDetails and this class — not the full
|
|
58
|
+
* 4-file threading chain.
|
|
59
|
+
*/
|
|
60
|
+
export class PermissionPrompter implements PermissionPrompterApi {
|
|
61
|
+
constructor(private readonly deps: PermissionPrompterDeps) {}
|
|
62
|
+
|
|
63
|
+
async prompt(
|
|
64
|
+
ctx: ExtensionContext,
|
|
65
|
+
details: PromptPermissionDetails,
|
|
66
|
+
): Promise<PermissionPromptDecision> {
|
|
67
|
+
if (shouldAutoApprovePermissionState("ask", this.deps.getConfig())) {
|
|
68
|
+
this.writeReviewEntry("permission_request.auto_approved", details);
|
|
69
|
+
return { approved: true, state: "approved" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.writeReviewEntry("permission_request.waiting", details);
|
|
73
|
+
|
|
74
|
+
const decision = await confirmPermission(
|
|
75
|
+
ctx,
|
|
76
|
+
details.message,
|
|
77
|
+
this.buildForwardingDeps(),
|
|
78
|
+
details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
this.writeReviewEntry(
|
|
82
|
+
decision.approved
|
|
83
|
+
? "permission_request.approved"
|
|
84
|
+
: "permission_request.denied",
|
|
85
|
+
{
|
|
86
|
+
...details,
|
|
87
|
+
resolution: decision.state,
|
|
88
|
+
denialReason: decision.denialReason,
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return decision;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Private helpers ──────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
private writeReviewEntry(
|
|
98
|
+
event: string,
|
|
99
|
+
details: PromptPermissionDetails & {
|
|
100
|
+
resolution?: string;
|
|
101
|
+
denialReason?: string;
|
|
102
|
+
},
|
|
103
|
+
): void {
|
|
104
|
+
this.deps.writeReviewLog(event, {
|
|
105
|
+
requestId: details.requestId,
|
|
106
|
+
source: details.source,
|
|
107
|
+
agentName: details.agentName,
|
|
108
|
+
message: details.message,
|
|
109
|
+
toolCallId: details.toolCallId ?? null,
|
|
110
|
+
toolName: details.toolName ?? null,
|
|
111
|
+
skillName: details.skillName ?? null,
|
|
112
|
+
path: details.path ?? null,
|
|
113
|
+
command: details.command ?? null,
|
|
114
|
+
target: details.target ?? null,
|
|
115
|
+
toolInputPreview: details.toolInputPreview ?? null,
|
|
116
|
+
resolution: details.resolution ?? null,
|
|
117
|
+
denialReason: details.denialReason ?? null,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build a PermissionForwardingDeps to pass to confirmPermission.
|
|
123
|
+
*
|
|
124
|
+
* Yolo-mode is already handled at the prompter level, so shouldAutoApprove
|
|
125
|
+
* returns false here (confirmPermission does not call it; only
|
|
126
|
+
* processForwardedPermissionRequests does, and that has its own deps).
|
|
127
|
+
*
|
|
128
|
+
* The logger delegates writeReviewLog to deps and uses a no-op writeDebugLog
|
|
129
|
+
* (trace-level forwarding debug is deferred — see open question in the plan).
|
|
130
|
+
*/
|
|
131
|
+
private buildForwardingDeps(): PermissionForwardingDeps {
|
|
132
|
+
const { deps } = this;
|
|
133
|
+
const logger: ForwardedPermissionLogger = {
|
|
134
|
+
writeReviewLog: deps.writeReviewLog,
|
|
135
|
+
writeDebugLog: () => undefined,
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
forwardingDir: deps.forwardingDir,
|
|
139
|
+
subagentSessionsDir: deps.subagentSessionsDir,
|
|
140
|
+
logger,
|
|
141
|
+
writeReviewLog: deps.writeReviewLog,
|
|
142
|
+
requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
|
|
143
|
+
shouldAutoApprove: () => false,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -35,20 +35,16 @@ import {
|
|
|
35
35
|
type PermissionSystemExtensionConfig,
|
|
36
36
|
} from "./extension-config";
|
|
37
37
|
import {
|
|
38
|
-
confirmPermission,
|
|
39
38
|
type PermissionForwardingDeps,
|
|
40
39
|
processForwardedPermissionRequests,
|
|
41
40
|
} from "./forwarded-permissions/polling";
|
|
42
|
-
import type { PromptPermissionDetails } from "./handlers/types";
|
|
43
41
|
import { createPermissionSystemLogger } from "./logging";
|
|
44
|
-
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
45
42
|
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
|
46
43
|
import { PermissionManager } from "./permission-manager";
|
|
47
44
|
import { SessionRules } from "./session-rules";
|
|
48
45
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
49
46
|
import { syncPermissionSystemStatus } from "./status";
|
|
50
47
|
import { isSubagentExecutionContext } from "./subagent-context";
|
|
51
|
-
import { shouldAutoApprovePermissionState } from "./yolo-mode";
|
|
52
48
|
|
|
53
49
|
/**
|
|
54
50
|
* Runtime context object created once inside `piPermissionSystemExtension()`.
|
|
@@ -276,78 +272,6 @@ export function logResolvedConfigPaths(runtime: ExtensionRuntime): void {
|
|
|
276
272
|
);
|
|
277
273
|
}
|
|
278
274
|
|
|
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
|
-
details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
|
|
336
|
-
);
|
|
337
|
-
reviewPermissionDecision(
|
|
338
|
-
runtime.writeReviewLog,
|
|
339
|
-
decision.approved
|
|
340
|
-
? "permission_request.approved"
|
|
341
|
-
: "permission_request.denied",
|
|
342
|
-
{
|
|
343
|
-
...details,
|
|
344
|
-
resolution: decision.state,
|
|
345
|
-
denialReason: decision.denialReason,
|
|
346
|
-
},
|
|
347
|
-
);
|
|
348
|
-
return decision;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
275
|
// ── Forwarding polling lifecycle ───────────────────────────────────────────
|
|
352
276
|
|
|
353
277
|
/** Stop the forwarded-permission polling interval and clear related state. */
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ── Module mocks ────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const { mockConfirmPermission } = vi.hoisted(() => ({
|
|
6
|
+
mockConfirmPermission: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("../src/forwarded-permissions/polling", () => ({
|
|
10
|
+
confirmPermission: mockConfirmPermission,
|
|
11
|
+
processForwardedPermissionRequests: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// ── Imports (after mocks) ───────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import { DEFAULT_EXTENSION_CONFIG } from "../src/extension-config";
|
|
18
|
+
import type { PromptPermissionDetails } from "../src/handlers/types";
|
|
19
|
+
import type { PermissionPromptDecision } from "../src/permission-dialog";
|
|
20
|
+
import {
|
|
21
|
+
PermissionPrompter,
|
|
22
|
+
type PermissionPrompterDeps,
|
|
23
|
+
} from "../src/permission-prompter";
|
|
24
|
+
|
|
25
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function makeCtx(hasUI: boolean): ExtensionContext {
|
|
28
|
+
return {
|
|
29
|
+
hasUI,
|
|
30
|
+
ui: { select: vi.fn(), input: vi.fn() },
|
|
31
|
+
sessionManager: { getSessionDir: vi.fn().mockReturnValue(null) },
|
|
32
|
+
} as unknown as ExtensionContext;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeDetails(
|
|
36
|
+
overrides?: Partial<PromptPermissionDetails>,
|
|
37
|
+
): PromptPermissionDetails {
|
|
38
|
+
return {
|
|
39
|
+
requestId: "req-123",
|
|
40
|
+
source: "tool_call",
|
|
41
|
+
agentName: "test-agent",
|
|
42
|
+
message: "Allow read?",
|
|
43
|
+
toolName: "read",
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeDeps(
|
|
49
|
+
overrides?: Partial<PermissionPrompterDeps>,
|
|
50
|
+
): PermissionPrompterDeps {
|
|
51
|
+
return {
|
|
52
|
+
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: false }),
|
|
53
|
+
writeReviewLog: vi.fn(),
|
|
54
|
+
subagentSessionsDir: "/sessions/subagents",
|
|
55
|
+
forwardingDir: "/sessions/permission-forwarding",
|
|
56
|
+
requestPermissionDecisionFromUi: vi
|
|
57
|
+
.fn()
|
|
58
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
59
|
+
...overrides,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe("PermissionPrompter", () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
mockConfirmPermission.mockReset();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── Yolo-mode auto-approve ───────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe("yolo-mode auto-approve", () => {
|
|
73
|
+
it("returns approved without calling confirmPermission when yoloMode is true", async () => {
|
|
74
|
+
const deps = makeDeps({
|
|
75
|
+
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
|
|
76
|
+
});
|
|
77
|
+
const prompter = new PermissionPrompter(deps);
|
|
78
|
+
|
|
79
|
+
const decision = await prompter.prompt(makeCtx(false), makeDetails());
|
|
80
|
+
|
|
81
|
+
expect(decision).toEqual({ approved: true, state: "approved" });
|
|
82
|
+
expect(mockConfirmPermission).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("logs permission_request.auto_approved in yolo mode", async () => {
|
|
86
|
+
const writeReviewLog = vi.fn();
|
|
87
|
+
const deps = makeDeps({
|
|
88
|
+
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
|
|
89
|
+
writeReviewLog,
|
|
90
|
+
});
|
|
91
|
+
const prompter = new PermissionPrompter(deps);
|
|
92
|
+
|
|
93
|
+
await prompter.prompt(makeCtx(false), makeDetails());
|
|
94
|
+
|
|
95
|
+
expect(writeReviewLog).toHaveBeenCalledWith(
|
|
96
|
+
"permission_request.auto_approved",
|
|
97
|
+
expect.objectContaining({ requestId: "req-123" }),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not log permission_request.waiting in yolo mode", async () => {
|
|
102
|
+
const writeReviewLog = vi.fn();
|
|
103
|
+
const deps = makeDeps({
|
|
104
|
+
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
|
|
105
|
+
writeReviewLog,
|
|
106
|
+
});
|
|
107
|
+
const prompter = new PermissionPrompter(deps);
|
|
108
|
+
|
|
109
|
+
await prompter.prompt(makeCtx(false), makeDetails());
|
|
110
|
+
|
|
111
|
+
expect(writeReviewLog).not.toHaveBeenCalledWith(
|
|
112
|
+
"permission_request.waiting",
|
|
113
|
+
expect.anything(),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("does not call confirmPermission with yoloMode even when ctx has UI", async () => {
|
|
118
|
+
const deps = makeDeps({
|
|
119
|
+
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
|
|
120
|
+
});
|
|
121
|
+
const prompter = new PermissionPrompter(deps);
|
|
122
|
+
|
|
123
|
+
await prompter.prompt(makeCtx(true), makeDetails());
|
|
124
|
+
|
|
125
|
+
expect(mockConfirmPermission).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── Non-yolo path ────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
describe("non-yolo path (UI present)", () => {
|
|
132
|
+
it("logs permission_request.waiting before calling confirmPermission", async () => {
|
|
133
|
+
const writeReviewLog = vi.fn();
|
|
134
|
+
const approved: PermissionPromptDecision = {
|
|
135
|
+
approved: true,
|
|
136
|
+
state: "approved",
|
|
137
|
+
};
|
|
138
|
+
mockConfirmPermission.mockResolvedValue(approved);
|
|
139
|
+
const deps = makeDeps({ writeReviewLog });
|
|
140
|
+
const prompter = new PermissionPrompter(deps);
|
|
141
|
+
|
|
142
|
+
await prompter.prompt(makeCtx(true), makeDetails());
|
|
143
|
+
|
|
144
|
+
const calls = writeReviewLog.mock.calls.map((c) => c[0] as string);
|
|
145
|
+
expect(
|
|
146
|
+
calls.indexOf("permission_request.waiting"),
|
|
147
|
+
).toBeGreaterThanOrEqual(0);
|
|
148
|
+
expect(calls.indexOf("permission_request.waiting")).toBeLessThan(
|
|
149
|
+
calls.indexOf("permission_request.approved"),
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("logs permission_request.approved when confirmPermission returns approved", async () => {
|
|
154
|
+
const writeReviewLog = vi.fn();
|
|
155
|
+
mockConfirmPermission.mockResolvedValue({
|
|
156
|
+
approved: true,
|
|
157
|
+
state: "approved",
|
|
158
|
+
});
|
|
159
|
+
const deps = makeDeps({ writeReviewLog });
|
|
160
|
+
const prompter = new PermissionPrompter(deps);
|
|
161
|
+
|
|
162
|
+
await prompter.prompt(makeCtx(true), makeDetails());
|
|
163
|
+
|
|
164
|
+
expect(writeReviewLog).toHaveBeenCalledWith(
|
|
165
|
+
"permission_request.approved",
|
|
166
|
+
expect.objectContaining({
|
|
167
|
+
requestId: "req-123",
|
|
168
|
+
resolution: "approved",
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("logs permission_request.denied when confirmPermission returns denied", async () => {
|
|
174
|
+
const writeReviewLog = vi.fn();
|
|
175
|
+
mockConfirmPermission.mockResolvedValue({
|
|
176
|
+
approved: false,
|
|
177
|
+
state: "denied",
|
|
178
|
+
});
|
|
179
|
+
const deps = makeDeps({ writeReviewLog });
|
|
180
|
+
const prompter = new PermissionPrompter(deps);
|
|
181
|
+
|
|
182
|
+
await prompter.prompt(makeCtx(true), makeDetails());
|
|
183
|
+
|
|
184
|
+
expect(writeReviewLog).toHaveBeenCalledWith(
|
|
185
|
+
"permission_request.denied",
|
|
186
|
+
expect.objectContaining({
|
|
187
|
+
requestId: "req-123",
|
|
188
|
+
resolution: "denied",
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("logs permission_request.denied with denialReason when present", async () => {
|
|
194
|
+
const writeReviewLog = vi.fn();
|
|
195
|
+
mockConfirmPermission.mockResolvedValue({
|
|
196
|
+
approved: false,
|
|
197
|
+
state: "denied_with_reason",
|
|
198
|
+
denialReason: "too sensitive",
|
|
199
|
+
});
|
|
200
|
+
const deps = makeDeps({ writeReviewLog });
|
|
201
|
+
const prompter = new PermissionPrompter(deps);
|
|
202
|
+
|
|
203
|
+
await prompter.prompt(makeCtx(true), makeDetails());
|
|
204
|
+
|
|
205
|
+
expect(writeReviewLog).toHaveBeenCalledWith(
|
|
206
|
+
"permission_request.denied",
|
|
207
|
+
expect.objectContaining({
|
|
208
|
+
denialReason: "too sensitive",
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns the decision from confirmPermission", async () => {
|
|
214
|
+
const decision: PermissionPromptDecision = {
|
|
215
|
+
approved: false,
|
|
216
|
+
state: "denied_with_reason",
|
|
217
|
+
denialReason: "sensitive",
|
|
218
|
+
};
|
|
219
|
+
mockConfirmPermission.mockResolvedValue(decision);
|
|
220
|
+
const deps = makeDeps();
|
|
221
|
+
const prompter = new PermissionPrompter(deps);
|
|
222
|
+
|
|
223
|
+
const result = await prompter.prompt(makeCtx(true), makeDetails());
|
|
224
|
+
|
|
225
|
+
expect(result).toEqual(decision);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("passes sessionLabel option to confirmPermission when present", async () => {
|
|
229
|
+
mockConfirmPermission.mockResolvedValue({
|
|
230
|
+
approved: true,
|
|
231
|
+
state: "approved",
|
|
232
|
+
});
|
|
233
|
+
const deps = makeDeps();
|
|
234
|
+
const prompter = new PermissionPrompter(deps);
|
|
235
|
+
const details = makeDetails({ sessionLabel: "Yes, for 'read' tool" });
|
|
236
|
+
|
|
237
|
+
await prompter.prompt(makeCtx(true), details);
|
|
238
|
+
|
|
239
|
+
expect(mockConfirmPermission).toHaveBeenCalledWith(
|
|
240
|
+
expect.anything(),
|
|
241
|
+
expect.any(String),
|
|
242
|
+
expect.anything(),
|
|
243
|
+
{ sessionLabel: "Yes, for 'read' tool" },
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("passes undefined options to confirmPermission when sessionLabel is absent", async () => {
|
|
248
|
+
mockConfirmPermission.mockResolvedValue({
|
|
249
|
+
approved: true,
|
|
250
|
+
state: "approved",
|
|
251
|
+
});
|
|
252
|
+
const deps = makeDeps();
|
|
253
|
+
const prompter = new PermissionPrompter(deps);
|
|
254
|
+
|
|
255
|
+
await prompter.prompt(makeCtx(true), makeDetails());
|
|
256
|
+
|
|
257
|
+
expect(mockConfirmPermission).toHaveBeenCalledWith(
|
|
258
|
+
expect.anything(),
|
|
259
|
+
expect.any(String),
|
|
260
|
+
expect.anything(),
|
|
261
|
+
undefined,
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("passes the message from details to confirmPermission", async () => {
|
|
266
|
+
mockConfirmPermission.mockResolvedValue({
|
|
267
|
+
approved: true,
|
|
268
|
+
state: "approved",
|
|
269
|
+
});
|
|
270
|
+
const deps = makeDeps();
|
|
271
|
+
const prompter = new PermissionPrompter(deps);
|
|
272
|
+
const details = makeDetails({ message: "Allow bash: git status?" });
|
|
273
|
+
|
|
274
|
+
await prompter.prompt(makeCtx(true), details);
|
|
275
|
+
|
|
276
|
+
expect(mockConfirmPermission).toHaveBeenCalledWith(
|
|
277
|
+
expect.anything(),
|
|
278
|
+
"Allow bash: git status?",
|
|
279
|
+
expect.anything(),
|
|
280
|
+
undefined,
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ── Review log field coverage ────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
describe("review log fields", () => {
|
|
288
|
+
it("includes all standard fields in the waiting log entry", async () => {
|
|
289
|
+
const writeReviewLog = vi.fn();
|
|
290
|
+
mockConfirmPermission.mockResolvedValue({
|
|
291
|
+
approved: true,
|
|
292
|
+
state: "approved",
|
|
293
|
+
});
|
|
294
|
+
const deps = makeDeps({ writeReviewLog });
|
|
295
|
+
const prompter = new PermissionPrompter(deps);
|
|
296
|
+
const details = makeDetails({
|
|
297
|
+
toolCallId: "tc-1",
|
|
298
|
+
skillName: "librarian",
|
|
299
|
+
path: "/src/foo.ts",
|
|
300
|
+
command: "git status",
|
|
301
|
+
target: "server:tool",
|
|
302
|
+
toolInputPreview: "{ path: '...' }",
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await prompter.prompt(makeCtx(true), details);
|
|
306
|
+
|
|
307
|
+
expect(writeReviewLog).toHaveBeenCalledWith(
|
|
308
|
+
"permission_request.waiting",
|
|
309
|
+
expect.objectContaining({
|
|
310
|
+
requestId: "req-123",
|
|
311
|
+
source: "tool_call",
|
|
312
|
+
agentName: "test-agent",
|
|
313
|
+
message: "Allow read?",
|
|
314
|
+
toolCallId: "tc-1",
|
|
315
|
+
toolName: "read",
|
|
316
|
+
skillName: "librarian",
|
|
317
|
+
path: "/src/foo.ts",
|
|
318
|
+
command: "git status",
|
|
319
|
+
target: "server:tool",
|
|
320
|
+
toolInputPreview: "{ path: '...' }",
|
|
321
|
+
}),
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("uses null for optional fields not present in details", async () => {
|
|
326
|
+
const writeReviewLog = vi.fn();
|
|
327
|
+
mockConfirmPermission.mockResolvedValue({
|
|
328
|
+
approved: true,
|
|
329
|
+
state: "approved",
|
|
330
|
+
});
|
|
331
|
+
const deps = makeDeps({ writeReviewLog });
|
|
332
|
+
const prompter = new PermissionPrompter(deps);
|
|
333
|
+
|
|
334
|
+
await prompter.prompt(makeCtx(true), makeDetails());
|
|
335
|
+
|
|
336
|
+
expect(writeReviewLog).toHaveBeenCalledWith(
|
|
337
|
+
"permission_request.waiting",
|
|
338
|
+
expect.objectContaining({
|
|
339
|
+
toolCallId: null,
|
|
340
|
+
skillName: null,
|
|
341
|
+
path: null,
|
|
342
|
+
command: null,
|
|
343
|
+
target: null,
|
|
344
|
+
toolInputPreview: null,
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ── Subagent forwarding path ─────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
describe("subagent forwarding path", () => {
|
|
353
|
+
it("calls confirmPermission even when ctx has no UI", async () => {
|
|
354
|
+
const forwarded: PermissionPromptDecision = {
|
|
355
|
+
approved: true,
|
|
356
|
+
state: "approved",
|
|
357
|
+
};
|
|
358
|
+
mockConfirmPermission.mockResolvedValue(forwarded);
|
|
359
|
+
const deps = makeDeps();
|
|
360
|
+
const prompter = new PermissionPrompter(deps);
|
|
361
|
+
|
|
362
|
+
await prompter.prompt(makeCtx(false), makeDetails());
|
|
363
|
+
|
|
364
|
+
expect(mockConfirmPermission).toHaveBeenCalled();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("returns the decision from confirmPermission in the subagent path", async () => {
|
|
368
|
+
const forwarded: PermissionPromptDecision = {
|
|
369
|
+
approved: false,
|
|
370
|
+
state: "denied",
|
|
371
|
+
};
|
|
372
|
+
mockConfirmPermission.mockResolvedValue(forwarded);
|
|
373
|
+
const deps = makeDeps();
|
|
374
|
+
const prompter = new PermissionPrompter(deps);
|
|
375
|
+
|
|
376
|
+
const result = await prompter.prompt(makeCtx(false), makeDetails());
|
|
377
|
+
|
|
378
|
+
expect(result).toEqual(forwarded);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("logs the outcome when confirmPermission resolves via forwarding", async () => {
|
|
382
|
+
const writeReviewLog = vi.fn();
|
|
383
|
+
mockConfirmPermission.mockResolvedValue({
|
|
384
|
+
approved: true,
|
|
385
|
+
state: "approved",
|
|
386
|
+
});
|
|
387
|
+
const deps = makeDeps({ writeReviewLog });
|
|
388
|
+
const prompter = new PermissionPrompter(deps);
|
|
389
|
+
|
|
390
|
+
await prompter.prompt(makeCtx(false), makeDetails());
|
|
391
|
+
|
|
392
|
+
expect(writeReviewLog).toHaveBeenCalledWith(
|
|
393
|
+
"permission_request.approved",
|
|
394
|
+
expect.objectContaining({ requestId: "req-123" }),
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
});
|
package/tests/runtime.test.ts
CHANGED
|
@@ -59,9 +59,6 @@ vi.mock("../src/config-reporter", () => ({
|
|
|
59
59
|
|
|
60
60
|
vi.mock("../src/forwarded-permissions/polling", () => ({
|
|
61
61
|
processForwardedPermissionRequests: vi.fn().mockResolvedValue(undefined),
|
|
62
|
-
confirmPermission: vi
|
|
63
|
-
.fn()
|
|
64
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
65
62
|
}));
|
|
66
63
|
|
|
67
64
|
vi.mock("../src/subagent-context", () => ({
|