@gotgenes/pi-permission-system 3.2.0 → 3.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 +29 -0
- package/README.md +18 -0
- package/package.json +1 -1
- package/src/index.ts +231 -223
- package/src/permission-dialog.ts +14 -1
- package/src/permission-gate.ts +74 -0
- package/src/session-approval-cache.ts +81 -0
- package/tests/permission-dialog.test.ts +166 -0
- package/tests/permission-gate.test.ts +201 -0
- package/tests/permission-system.test.ts +289 -0
- package/tests/session-approval-cache.test.ts +131 -0
package/src/permission-dialog.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type PermissionDecisionState =
|
|
2
2
|
| "approved"
|
|
3
|
+
| "approved_for_session"
|
|
3
4
|
| "denied"
|
|
4
5
|
| "denied_with_reason";
|
|
5
6
|
|
|
@@ -15,10 +16,12 @@ export interface PermissionDecisionUi {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
const APPROVE_OPTION = "Yes";
|
|
19
|
+
const APPROVE_FOR_SESSION_OPTION = "Yes, for this session";
|
|
18
20
|
const DENY_OPTION = "No";
|
|
19
21
|
const DENY_WITH_REASON_OPTION = "No, provide reason";
|
|
20
22
|
const PERMISSION_DECISION_OPTIONS = [
|
|
21
23
|
APPROVE_OPTION,
|
|
24
|
+
APPROVE_FOR_SESSION_OPTION,
|
|
22
25
|
DENY_OPTION,
|
|
23
26
|
DENY_WITH_REASON_OPTION,
|
|
24
27
|
] as const;
|
|
@@ -54,7 +57,10 @@ export function isPermissionDecisionState(
|
|
|
54
57
|
value: unknown,
|
|
55
58
|
): value is PermissionDecisionState {
|
|
56
59
|
return (
|
|
57
|
-
value === "approved" ||
|
|
60
|
+
value === "approved" ||
|
|
61
|
+
value === "approved_for_session" ||
|
|
62
|
+
value === "denied" ||
|
|
63
|
+
value === "denied_with_reason"
|
|
58
64
|
);
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -74,6 +80,13 @@ export async function requestPermissionDecisionFromUi(
|
|
|
74
80
|
};
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
if (selected === APPROVE_FOR_SESSION_OPTION) {
|
|
84
|
+
return {
|
|
85
|
+
approved: true,
|
|
86
|
+
state: "approved_for_session",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
if (selected === DENY_WITH_REASON_OPTION) {
|
|
78
91
|
const denialReason = normalizePermissionDenialReason(
|
|
79
92
|
await ui.input(
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
2
|
+
|
|
3
|
+
/** Result of applying the permission gate. */
|
|
4
|
+
export type PermissionGateResult =
|
|
5
|
+
| { action: "allow" }
|
|
6
|
+
| { action: "block"; reason: string };
|
|
7
|
+
|
|
8
|
+
/** Everything the gate needs — no direct dependency on ExtensionContext. */
|
|
9
|
+
export interface PermissionGateParams {
|
|
10
|
+
/** The resolved permission state from checkPermission(). */
|
|
11
|
+
state: "allow" | "deny" | "ask";
|
|
12
|
+
|
|
13
|
+
/** Whether the current context supports interactive prompts. */
|
|
14
|
+
canConfirm: boolean;
|
|
15
|
+
|
|
16
|
+
/** Prompt the user for approval. Only called when state === "ask" and canConfirm is true. */
|
|
17
|
+
promptForApproval: () => Promise<PermissionPromptDecision>;
|
|
18
|
+
|
|
19
|
+
/** Write a review-log entry. Called for deny and ask-but-unavailable paths. */
|
|
20
|
+
writeLog: (event: string, extra: Record<string, unknown>) => void;
|
|
21
|
+
|
|
22
|
+
/** Log context fields shared across all log calls for this gate. */
|
|
23
|
+
logContext: Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
/** Message strings/factories for each outcome. */
|
|
26
|
+
messages: {
|
|
27
|
+
denyReason: string;
|
|
28
|
+
unavailableReason: string;
|
|
29
|
+
userDeniedReason: (decision: PermissionPromptDecision) => string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Apply the deny/ask/allow permission gate.
|
|
35
|
+
*
|
|
36
|
+
* This is a pure decision function: all IO is injected via callbacks.
|
|
37
|
+
*/
|
|
38
|
+
export async function applyPermissionGate(
|
|
39
|
+
params: PermissionGateParams,
|
|
40
|
+
): Promise<PermissionGateResult> {
|
|
41
|
+
const {
|
|
42
|
+
state,
|
|
43
|
+
canConfirm,
|
|
44
|
+
promptForApproval,
|
|
45
|
+
writeLog,
|
|
46
|
+
logContext,
|
|
47
|
+
messages,
|
|
48
|
+
} = params;
|
|
49
|
+
|
|
50
|
+
if (state === "deny") {
|
|
51
|
+
writeLog("permission_request.blocked", {
|
|
52
|
+
...logContext,
|
|
53
|
+
resolution: "policy_denied",
|
|
54
|
+
});
|
|
55
|
+
return { action: "block", reason: messages.denyReason };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (state === "ask") {
|
|
59
|
+
if (!canConfirm) {
|
|
60
|
+
writeLog("permission_request.blocked", {
|
|
61
|
+
...logContext,
|
|
62
|
+
resolution: "confirmation_unavailable",
|
|
63
|
+
});
|
|
64
|
+
return { action: "block", reason: messages.unavailableReason };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const decision = await promptForApproval();
|
|
68
|
+
if (!decision.approved) {
|
|
69
|
+
return { action: "block", reason: messages.userDeniedReason(decision) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { action: "allow" };
|
|
74
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { dirname, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { isPathWithinDirectory } from "./external-directory";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ephemeral in-memory cache of session-scoped permission approvals.
|
|
7
|
+
* Keyed by permission surface (e.g. "external_directory"), values are
|
|
8
|
+
* normalized directory prefixes that have been approved for the session.
|
|
9
|
+
*
|
|
10
|
+
* Cleared on session_shutdown — never persisted to disk.
|
|
11
|
+
*/
|
|
12
|
+
export class SessionApprovalCache {
|
|
13
|
+
private approvals = new Map<string, Set<string>>();
|
|
14
|
+
|
|
15
|
+
/** Record a directory prefix as approved for the given surface. */
|
|
16
|
+
approve(surface: string, prefix: string): void {
|
|
17
|
+
let prefixes = this.approvals.get(surface);
|
|
18
|
+
if (!prefixes) {
|
|
19
|
+
prefixes = new Set();
|
|
20
|
+
this.approvals.set(surface, prefixes);
|
|
21
|
+
}
|
|
22
|
+
prefixes.add(prefix);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check whether a path falls under any approved prefix for the given surface.
|
|
27
|
+
* Uses `isPathWithinDirectory()` for correct separator-aware prefix matching.
|
|
28
|
+
*/
|
|
29
|
+
has(surface: string, path: string): boolean {
|
|
30
|
+
const prefixes = this.approvals.get(surface);
|
|
31
|
+
if (!prefixes) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
for (const prefix of prefixes) {
|
|
35
|
+
if (isPathWithinDirectory(path, prefix)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Find and return the matching approved prefix, or null if none matches. */
|
|
43
|
+
findMatchingPrefix(surface: string, path: string): string | null {
|
|
44
|
+
const prefixes = this.approvals.get(surface);
|
|
45
|
+
if (!prefixes) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
for (const prefix of prefixes) {
|
|
49
|
+
if (isPathWithinDirectory(path, prefix)) {
|
|
50
|
+
return prefix;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Remove all session approvals. */
|
|
57
|
+
clear(): void {
|
|
58
|
+
this.approvals.clear();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Derive the directory prefix to approve from a normalized path.
|
|
64
|
+
* Returns `dirname(path)` with a trailing separator so that
|
|
65
|
+
* prefix matching via `isPathWithinDirectory()` works correctly.
|
|
66
|
+
*
|
|
67
|
+
* For paths that already end with a separator (directories),
|
|
68
|
+
* the trailing separator is stripped by dirname and re-added.
|
|
69
|
+
*/
|
|
70
|
+
export function deriveApprovalPrefix(normalizedPath: string): string {
|
|
71
|
+
// If the path already ends with a separator, it's a directory — return as-is.
|
|
72
|
+
if (normalizedPath.endsWith(sep)) {
|
|
73
|
+
return normalizedPath;
|
|
74
|
+
}
|
|
75
|
+
const dir = dirname(normalizedPath);
|
|
76
|
+
if (dir === normalizedPath) {
|
|
77
|
+
// Root path — dirname('/') === '/'
|
|
78
|
+
return dir;
|
|
79
|
+
}
|
|
80
|
+
return dir.endsWith(sep) ? dir : `${dir}${sep}`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createDeniedPermissionDecision,
|
|
4
|
+
isPermissionDecisionState,
|
|
5
|
+
normalizePermissionDenialReason,
|
|
6
|
+
type PermissionDecisionUi,
|
|
7
|
+
requestPermissionDecisionFromUi,
|
|
8
|
+
} from "../src/permission-dialog";
|
|
9
|
+
|
|
10
|
+
describe("isPermissionDecisionState", () => {
|
|
11
|
+
it("accepts approved", () => {
|
|
12
|
+
expect(isPermissionDecisionState("approved")).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("accepts denied", () => {
|
|
16
|
+
expect(isPermissionDecisionState("denied")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("accepts denied_with_reason", () => {
|
|
20
|
+
expect(isPermissionDecisionState("denied_with_reason")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("accepts approved_for_session", () => {
|
|
24
|
+
expect(isPermissionDecisionState("approved_for_session")).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects unknown strings", () => {
|
|
28
|
+
expect(isPermissionDecisionState("unknown")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects non-strings", () => {
|
|
32
|
+
expect(isPermissionDecisionState(42)).toBe(false);
|
|
33
|
+
expect(isPermissionDecisionState(null)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("requestPermissionDecisionFromUi", () => {
|
|
38
|
+
it("returns approved when user selects Yes", async () => {
|
|
39
|
+
const ui: PermissionDecisionUi = {
|
|
40
|
+
select: vi.fn().mockResolvedValue("Yes"),
|
|
41
|
+
input: vi.fn(),
|
|
42
|
+
};
|
|
43
|
+
const result = await requestPermissionDecisionFromUi(
|
|
44
|
+
ui,
|
|
45
|
+
"Title",
|
|
46
|
+
"Message",
|
|
47
|
+
);
|
|
48
|
+
expect(result).toEqual({ approved: true, state: "approved" });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns approved_for_session when user selects session option", async () => {
|
|
52
|
+
const ui: PermissionDecisionUi = {
|
|
53
|
+
select: vi.fn().mockResolvedValue("Yes, for this session"),
|
|
54
|
+
input: vi.fn(),
|
|
55
|
+
};
|
|
56
|
+
const result = await requestPermissionDecisionFromUi(
|
|
57
|
+
ui,
|
|
58
|
+
"Title",
|
|
59
|
+
"Message",
|
|
60
|
+
);
|
|
61
|
+
expect(result).toEqual({ approved: true, state: "approved_for_session" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns denied when user selects No", async () => {
|
|
65
|
+
const ui: PermissionDecisionUi = {
|
|
66
|
+
select: vi.fn().mockResolvedValue("No"),
|
|
67
|
+
input: vi.fn(),
|
|
68
|
+
};
|
|
69
|
+
const result = await requestPermissionDecisionFromUi(
|
|
70
|
+
ui,
|
|
71
|
+
"Title",
|
|
72
|
+
"Message",
|
|
73
|
+
);
|
|
74
|
+
expect(result).toEqual({ approved: false, state: "denied" });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns denied_with_reason when user provides reason", async () => {
|
|
78
|
+
const ui: PermissionDecisionUi = {
|
|
79
|
+
select: vi.fn().mockResolvedValue("No, provide reason"),
|
|
80
|
+
input: vi.fn().mockResolvedValue("not now"),
|
|
81
|
+
};
|
|
82
|
+
const result = await requestPermissionDecisionFromUi(
|
|
83
|
+
ui,
|
|
84
|
+
"Title",
|
|
85
|
+
"Message",
|
|
86
|
+
);
|
|
87
|
+
expect(result).toEqual({
|
|
88
|
+
approved: false,
|
|
89
|
+
state: "denied_with_reason",
|
|
90
|
+
denialReason: "not now",
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns denied when user selects deny-with-reason but gives empty input", async () => {
|
|
95
|
+
const ui: PermissionDecisionUi = {
|
|
96
|
+
select: vi.fn().mockResolvedValue("No, provide reason"),
|
|
97
|
+
input: vi.fn().mockResolvedValue(""),
|
|
98
|
+
};
|
|
99
|
+
const result = await requestPermissionDecisionFromUi(
|
|
100
|
+
ui,
|
|
101
|
+
"Title",
|
|
102
|
+
"Message",
|
|
103
|
+
);
|
|
104
|
+
expect(result).toEqual({ approved: false, state: "denied" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns denied when user dismisses dialog (undefined)", async () => {
|
|
108
|
+
const ui: PermissionDecisionUi = {
|
|
109
|
+
select: vi.fn().mockResolvedValue(undefined),
|
|
110
|
+
input: vi.fn(),
|
|
111
|
+
};
|
|
112
|
+
const result = await requestPermissionDecisionFromUi(
|
|
113
|
+
ui,
|
|
114
|
+
"Title",
|
|
115
|
+
"Message",
|
|
116
|
+
);
|
|
117
|
+
expect(result).toEqual({ approved: false, state: "denied" });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("passes four options to ui.select", async () => {
|
|
121
|
+
const selectFn = vi.fn().mockResolvedValue("Yes");
|
|
122
|
+
const ui: PermissionDecisionUi = {
|
|
123
|
+
select: selectFn,
|
|
124
|
+
input: vi.fn(),
|
|
125
|
+
};
|
|
126
|
+
await requestPermissionDecisionFromUi(ui, "Title", "Message");
|
|
127
|
+
const options = selectFn.mock.calls[0][1] as string[];
|
|
128
|
+
expect(options).toEqual([
|
|
129
|
+
"Yes",
|
|
130
|
+
"Yes, for this session",
|
|
131
|
+
"No",
|
|
132
|
+
"No, provide reason",
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("normalizePermissionDenialReason", () => {
|
|
138
|
+
it("returns trimmed string for non-empty input", () => {
|
|
139
|
+
expect(normalizePermissionDenialReason(" reason ")).toBe("reason");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns undefined for empty string", () => {
|
|
143
|
+
expect(normalizePermissionDenialReason("")).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns undefined for non-string", () => {
|
|
147
|
+
expect(normalizePermissionDenialReason(42)).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("createDeniedPermissionDecision", () => {
|
|
152
|
+
it("returns denied_with_reason when reason provided", () => {
|
|
153
|
+
expect(createDeniedPermissionDecision("nope")).toEqual({
|
|
154
|
+
approved: false,
|
|
155
|
+
state: "denied_with_reason",
|
|
156
|
+
denialReason: "nope",
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns denied when no reason", () => {
|
|
161
|
+
expect(createDeniedPermissionDecision()).toEqual({
|
|
162
|
+
approved: false,
|
|
163
|
+
state: "denied",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { PermissionPromptDecision } from "../src/permission-dialog";
|
|
3
|
+
import {
|
|
4
|
+
applyPermissionGate,
|
|
5
|
+
type PermissionGateParams,
|
|
6
|
+
} from "../src/permission-gate";
|
|
7
|
+
|
|
8
|
+
function makeParams(
|
|
9
|
+
overrides: Partial<PermissionGateParams> = {},
|
|
10
|
+
): PermissionGateParams {
|
|
11
|
+
return {
|
|
12
|
+
state: "allow",
|
|
13
|
+
canConfirm: true,
|
|
14
|
+
promptForApproval: vi.fn<() => Promise<PermissionPromptDecision>>(),
|
|
15
|
+
writeLog: vi.fn(),
|
|
16
|
+
logContext: { source: "test" },
|
|
17
|
+
messages: {
|
|
18
|
+
denyReason: "Denied by policy.",
|
|
19
|
+
unavailableReason: "No interactive UI available.",
|
|
20
|
+
userDeniedReason: (d) =>
|
|
21
|
+
d.denialReason
|
|
22
|
+
? `User denied. Reason: ${d.denialReason}.`
|
|
23
|
+
: "User denied.",
|
|
24
|
+
},
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("applyPermissionGate", () => {
|
|
30
|
+
describe("deny branch", () => {
|
|
31
|
+
it("returns block with deny reason when state is deny", async () => {
|
|
32
|
+
const params = makeParams({ state: "deny" });
|
|
33
|
+
const result = await applyPermissionGate(params);
|
|
34
|
+
expect(result).toEqual({
|
|
35
|
+
action: "block",
|
|
36
|
+
reason: "Denied by policy.",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("calls writeLog with policy_denied resolution", async () => {
|
|
41
|
+
const params = makeParams({
|
|
42
|
+
state: "deny",
|
|
43
|
+
logContext: { source: "tool_call", toolName: "bash" },
|
|
44
|
+
});
|
|
45
|
+
await applyPermissionGate(params);
|
|
46
|
+
expect(params.writeLog).toHaveBeenCalledOnce();
|
|
47
|
+
expect(params.writeLog).toHaveBeenCalledWith(
|
|
48
|
+
"permission_request.blocked",
|
|
49
|
+
{
|
|
50
|
+
source: "tool_call",
|
|
51
|
+
toolName: "bash",
|
|
52
|
+
resolution: "policy_denied",
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("does not call promptForApproval when state is deny", async () => {
|
|
58
|
+
const params = makeParams({ state: "deny" });
|
|
59
|
+
await applyPermissionGate(params);
|
|
60
|
+
expect(params.promptForApproval).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("ask branch — unavailable", () => {
|
|
65
|
+
it("returns block with unavailable reason when canConfirm is false", async () => {
|
|
66
|
+
const params = makeParams({ state: "ask", canConfirm: false });
|
|
67
|
+
const result = await applyPermissionGate(params);
|
|
68
|
+
expect(result).toEqual({
|
|
69
|
+
action: "block",
|
|
70
|
+
reason: "No interactive UI available.",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("calls writeLog with confirmation_unavailable resolution", async () => {
|
|
75
|
+
const params = makeParams({
|
|
76
|
+
state: "ask",
|
|
77
|
+
canConfirm: false,
|
|
78
|
+
logContext: { source: "skill_read", skillName: "foo" },
|
|
79
|
+
});
|
|
80
|
+
await applyPermissionGate(params);
|
|
81
|
+
expect(params.writeLog).toHaveBeenCalledOnce();
|
|
82
|
+
expect(params.writeLog).toHaveBeenCalledWith(
|
|
83
|
+
"permission_request.blocked",
|
|
84
|
+
{
|
|
85
|
+
source: "skill_read",
|
|
86
|
+
skillName: "foo",
|
|
87
|
+
resolution: "confirmation_unavailable",
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not call promptForApproval when canConfirm is false", async () => {
|
|
93
|
+
const params = makeParams({ state: "ask", canConfirm: false });
|
|
94
|
+
await applyPermissionGate(params);
|
|
95
|
+
expect(params.promptForApproval).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("ask branch — user rejects", () => {
|
|
100
|
+
it("returns block with user-denied reason when user rejects", async () => {
|
|
101
|
+
const decision: PermissionPromptDecision = {
|
|
102
|
+
approved: false,
|
|
103
|
+
state: "denied",
|
|
104
|
+
};
|
|
105
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
106
|
+
const params = makeParams({
|
|
107
|
+
state: "ask",
|
|
108
|
+
canConfirm: true,
|
|
109
|
+
promptForApproval,
|
|
110
|
+
});
|
|
111
|
+
const result = await applyPermissionGate(params);
|
|
112
|
+
expect(result).toEqual({ action: "block", reason: "User denied." });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("passes denial reason through userDeniedReason formatter", async () => {
|
|
116
|
+
const decision: PermissionPromptDecision = {
|
|
117
|
+
approved: false,
|
|
118
|
+
state: "denied_with_reason",
|
|
119
|
+
denialReason: "not now",
|
|
120
|
+
};
|
|
121
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
122
|
+
const params = makeParams({
|
|
123
|
+
state: "ask",
|
|
124
|
+
canConfirm: true,
|
|
125
|
+
promptForApproval,
|
|
126
|
+
});
|
|
127
|
+
const result = await applyPermissionGate(params);
|
|
128
|
+
expect(result).toEqual({
|
|
129
|
+
action: "block",
|
|
130
|
+
reason: "User denied. Reason: not now.",
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("does not call writeLog when user rejects (logged by promptPermission)", async () => {
|
|
135
|
+
const decision: PermissionPromptDecision = {
|
|
136
|
+
approved: false,
|
|
137
|
+
state: "denied",
|
|
138
|
+
};
|
|
139
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
140
|
+
const params = makeParams({
|
|
141
|
+
state: "ask",
|
|
142
|
+
canConfirm: true,
|
|
143
|
+
promptForApproval,
|
|
144
|
+
});
|
|
145
|
+
await applyPermissionGate(params);
|
|
146
|
+
expect(params.writeLog).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("ask branch — user approves", () => {
|
|
151
|
+
it("returns allow when user approves", async () => {
|
|
152
|
+
const decision: PermissionPromptDecision = {
|
|
153
|
+
approved: true,
|
|
154
|
+
state: "approved",
|
|
155
|
+
};
|
|
156
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
157
|
+
const params = makeParams({
|
|
158
|
+
state: "ask",
|
|
159
|
+
canConfirm: true,
|
|
160
|
+
promptForApproval,
|
|
161
|
+
});
|
|
162
|
+
const result = await applyPermissionGate(params);
|
|
163
|
+
expect(result).toEqual({ action: "allow" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("does not call writeLog when user approves", async () => {
|
|
167
|
+
const decision: PermissionPromptDecision = {
|
|
168
|
+
approved: true,
|
|
169
|
+
state: "approved",
|
|
170
|
+
};
|
|
171
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
172
|
+
const params = makeParams({
|
|
173
|
+
state: "ask",
|
|
174
|
+
canConfirm: true,
|
|
175
|
+
promptForApproval,
|
|
176
|
+
});
|
|
177
|
+
await applyPermissionGate(params);
|
|
178
|
+
expect(params.writeLog).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("allow branch", () => {
|
|
183
|
+
it("returns allow immediately when state is allow", async () => {
|
|
184
|
+
const params = makeParams({ state: "allow" });
|
|
185
|
+
const result = await applyPermissionGate(params);
|
|
186
|
+
expect(result).toEqual({ action: "allow" });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("does not call writeLog when state is allow", async () => {
|
|
190
|
+
const params = makeParams({ state: "allow" });
|
|
191
|
+
await applyPermissionGate(params);
|
|
192
|
+
expect(params.writeLog).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("does not call promptForApproval when state is allow", async () => {
|
|
196
|
+
const params = makeParams({ state: "allow" });
|
|
197
|
+
await applyPermissionGate(params);
|
|
198
|
+
expect(params.promptForApproval).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|