@gotgenes/pi-permission-system 3.2.0 → 3.3.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 CHANGED
@@ -5,6 +5,19 @@ 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
+ ## [3.3.0](https://github.com/gotgenes/pi-permission-system/compare/v3.2.0...v3.3.0) (2026-05-03)
9
+
10
+
11
+ ### Features
12
+
13
+ * add permission-gate module ([507a1b6](https://github.com/gotgenes/pi-permission-system/commit/507a1b6155513562958bb277cd7c38ed8d44c215)), closes [#41](https://github.com/gotgenes/pi-permission-system/issues/41)
14
+
15
+
16
+ ### Documentation
17
+
18
+ * plan extract reusable permission-gate function ([#41](https://github.com/gotgenes/pi-permission-system/issues/41)) ([2458bf2](https://github.com/gotgenes/pi-permission-system/commit/2458bf28f6c698db78c7a65cfdb6afa488e5b6ee))
19
+ * **retro:** add retro notes for issue [#44](https://github.com/gotgenes/pi-permission-system/issues/44) ([963bb1b](https://github.com/gotgenes/pi-permission-system/commit/963bb1ba78d7e305a80732a55e385266e5222b82))
20
+
8
21
  ## [3.2.0](https://github.com/gotgenes/pi-permission-system/compare/v3.1.0...v3.2.0) (2026-05-03)
9
22
 
10
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
package/src/index.ts CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  requestPermissionDecisionFromUi,
69
69
  } from "./permission-dialog";
70
70
  import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
71
+ import { applyPermissionGate } from "./permission-gate";
71
72
  import { PermissionManager } from "./permission-manager";
72
73
  import {
73
74
  formatAskPrompt,
@@ -673,45 +674,44 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
673
674
  agentName ?? undefined,
674
675
  );
675
676
 
676
- if (check.state === "deny") {
677
- if (ctx.hasUI) {
678
- const message = agentName
679
- ? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
680
- : `Skill '${skillName}' is not permitted by the current skill policy.`;
681
- ctx.ui.notify(message, "warning");
682
- }
683
- writeReviewLog("permission_request.blocked", {
684
- source: "skill_input",
685
- skillName,
686
- agentName,
687
- resolution: "policy_denied",
688
- });
689
- return { action: "handled" };
677
+ if (check.state === "deny" && ctx.hasUI) {
678
+ const notifyMessage = agentName
679
+ ? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
680
+ : `Skill '${skillName}' is not permitted by the current skill policy.`;
681
+ ctx.ui.notify(notifyMessage, "warning");
690
682
  }
691
683
 
692
- if (check.state === "ask") {
693
- const message = formatSkillAskPrompt(skillName, agentName ?? undefined);
694
- if (!canRequestPermissionConfirmation(ctx)) {
695
- writeReviewLog("permission_request.blocked", {
684
+ const skillInputMessage = formatSkillAskPrompt(
685
+ skillName,
686
+ agentName ?? undefined,
687
+ );
688
+ const skillInputGate = await applyPermissionGate({
689
+ state: check.state,
690
+ canConfirm: canRequestPermissionConfirmation(ctx),
691
+ promptForApproval: () =>
692
+ promptPermission(ctx, {
693
+ requestId: createPermissionRequestId("skill-input"),
696
694
  source: "skill_input",
697
- skillName,
698
695
  agentName,
699
- message,
700
- resolution: "confirmation_unavailable",
701
- });
702
- return { action: "handled" };
703
- }
704
-
705
- const decision = await promptPermission(ctx, {
706
- requestId: createPermissionRequestId("skill-input"),
696
+ message: skillInputMessage,
697
+ skillName,
698
+ }),
699
+ writeLog: writeReviewLog,
700
+ logContext: {
707
701
  source: "skill_input",
708
- agentName,
709
- message,
710
702
  skillName,
711
- });
712
- if (!decision.approved) {
713
- return { action: "handled" };
714
- }
703
+ agentName,
704
+ message: skillInputMessage,
705
+ },
706
+ messages: {
707
+ denyReason: skillInputMessage,
708
+ unavailableReason:
709
+ "Skill requires approval, but no interactive UI is available.",
710
+ userDeniedReason: () => "User denied skill.",
711
+ },
712
+ });
713
+ if (skillInputGate.action === "block") {
714
+ return { action: "handled" };
715
715
  }
716
716
 
717
717
  return { action: "continue" };
@@ -756,64 +756,50 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
756
756
  );
757
757
 
758
758
  if (matchedSkill) {
759
- if (matchedSkill.state === "deny") {
760
- writeReviewLog("permission_request.blocked", {
759
+ const skillReadMessage = formatSkillPathAskPrompt(
760
+ matchedSkill,
761
+ event.input.path,
762
+ agentName ?? undefined,
763
+ );
764
+ const skillReadGate = await applyPermissionGate({
765
+ state: matchedSkill.state,
766
+ canConfirm: canRequestPermissionConfirmation(ctx),
767
+ promptForApproval: () =>
768
+ promptPermission(ctx, {
769
+ requestId: event.toolCallId,
770
+ source: "skill_read",
771
+ agentName,
772
+ message: skillReadMessage,
773
+ toolCallId: event.toolCallId,
774
+ toolName: toolName,
775
+ skillName: matchedSkill.name,
776
+ path: event.input.path,
777
+ }),
778
+ writeLog: writeReviewLog,
779
+ logContext: {
761
780
  source: "skill_read",
762
781
  skillName: matchedSkill.name,
763
782
  agentName,
764
783
  path: event.input.path,
765
- resolution: "policy_denied",
766
- });
767
- return {
768
- block: true,
769
- reason: formatSkillPathDenyReason(
784
+ message: skillReadMessage,
785
+ },
786
+ messages: {
787
+ denyReason: formatSkillPathDenyReason(
770
788
  matchedSkill,
771
789
  event.input.path,
772
790
  agentName ?? undefined,
773
791
  ),
774
- };
775
- }
776
-
777
- if (matchedSkill.state === "ask") {
778
- const message = formatSkillPathAskPrompt(
779
- matchedSkill,
780
- event.input.path,
781
- agentName ?? undefined,
782
- );
783
- if (!canRequestPermissionConfirmation(ctx)) {
784
- writeReviewLog("permission_request.blocked", {
785
- source: "skill_read",
786
- skillName: matchedSkill.name,
787
- agentName,
788
- path: event.input.path,
789
- message,
790
- resolution: "confirmation_unavailable",
791
- });
792
- return {
793
- block: true,
794
- reason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
795
- };
796
- }
797
-
798
- const decision = await promptPermission(ctx, {
799
- requestId: event.toolCallId,
800
- source: "skill_read",
801
- agentName,
802
- message,
803
- toolCallId: event.toolCallId,
804
- toolName: toolName,
805
- skillName: matchedSkill.name,
806
- path: event.input.path,
807
- });
808
- if (!decision.approved) {
809
- const denialReason = decision.denialReason
810
- ? ` Reason: ${decision.denialReason}.`
811
- : "";
812
- return {
813
- block: true,
814
- reason: `User denied access to skill '${matchedSkill.name}'.${denialReason}`,
815
- };
816
- }
792
+ unavailableReason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
793
+ userDeniedReason: (decision) => {
794
+ const denialReason = decision.denialReason
795
+ ? ` Reason: ${decision.denialReason}.`
796
+ : "";
797
+ return `User denied access to skill '${matchedSkill.name}'.${denialReason}`;
798
+ },
799
+ },
800
+ });
801
+ if (skillReadGate.action === "block") {
802
+ return { block: true, reason: skillReadGate.reason };
817
803
  }
818
804
  }
819
805
  }
@@ -834,69 +820,52 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
834
820
  agentName ?? undefined,
835
821
  );
836
822
 
837
- if (extCheck.state === "deny") {
838
- writeReviewLog("permission_request.blocked", {
823
+ const extDirMessage = formatExternalDirectoryAskPrompt(
824
+ toolName,
825
+ externalDirectoryPath,
826
+ ctx.cwd,
827
+ agentName ?? undefined,
828
+ );
829
+ const extDirGate = await applyPermissionGate({
830
+ state: extCheck.state,
831
+ canConfirm: canRequestPermissionConfirmation(ctx),
832
+ promptForApproval: () =>
833
+ promptPermission(ctx, {
834
+ requestId: event.toolCallId,
835
+ source: "tool_call",
836
+ agentName,
837
+ message: extDirMessage,
838
+ toolCallId: event.toolCallId,
839
+ toolName,
840
+ path: externalDirectoryPath,
841
+ }),
842
+ writeLog: writeReviewLog,
843
+ logContext: {
839
844
  source: "tool_call",
840
845
  toolCallId: event.toolCallId,
841
846
  toolName,
842
847
  agentName,
843
848
  path: externalDirectoryPath,
844
- resolution: "policy_denied",
845
- });
846
- return {
847
- block: true,
848
- reason: formatExternalDirectoryDenyReason(
849
+ message: extDirMessage,
850
+ },
851
+ messages: {
852
+ denyReason: formatExternalDirectoryDenyReason(
849
853
  toolName,
850
854
  externalDirectoryPath,
851
855
  ctx.cwd,
852
856
  agentName ?? undefined,
853
857
  ),
854
- };
855
- }
856
-
857
- if (extCheck.state === "ask") {
858
- const message = formatExternalDirectoryAskPrompt(
859
- toolName,
860
- externalDirectoryPath,
861
- ctx.cwd,
862
- agentName ?? undefined,
863
- );
864
- if (!canRequestPermissionConfirmation(ctx)) {
865
- writeReviewLog("permission_request.blocked", {
866
- source: "tool_call",
867
- toolCallId: event.toolCallId,
868
- toolName,
869
- agentName,
870
- path: externalDirectoryPath,
871
- message,
872
- resolution: "confirmation_unavailable",
873
- });
874
- return {
875
- block: true,
876
- reason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
877
- };
878
- }
879
-
880
- const extDecision = await promptPermission(ctx, {
881
- requestId: event.toolCallId,
882
- source: "tool_call",
883
- agentName,
884
- message,
885
- toolCallId: event.toolCallId,
886
- toolName,
887
- path: externalDirectoryPath,
888
- });
889
-
890
- if (!extDecision.approved) {
891
- return {
892
- block: true,
893
- reason: formatExternalDirectoryUserDeniedReason(
858
+ unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
859
+ userDeniedReason: (decision) =>
860
+ formatExternalDirectoryUserDeniedReason(
894
861
  toolName,
895
862
  externalDirectoryPath,
896
- extDecision.denialReason,
863
+ decision.denialReason,
897
864
  ),
898
- };
899
- }
865
+ },
866
+ });
867
+ if (extDirGate.action === "block") {
868
+ return { block: true, reason: extDirGate.reason };
900
869
  }
901
870
  // state === "allow" → fall through to normal permission check
902
871
  }
@@ -916,69 +885,53 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
916
885
  agentName ?? undefined,
917
886
  );
918
887
 
919
- if (extCheck.state === "deny") {
920
- writeReviewLog("permission_request.blocked", {
888
+ const bashExtMessage = formatBashExternalDirectoryAskPrompt(
889
+ command,
890
+ externalPaths,
891
+ ctx.cwd,
892
+ agentName ?? undefined,
893
+ );
894
+ const bashExtGate = await applyPermissionGate({
895
+ state: extCheck.state,
896
+ canConfirm: canRequestPermissionConfirmation(ctx),
897
+ promptForApproval: () =>
898
+ promptPermission(ctx, {
899
+ requestId: event.toolCallId,
900
+ source: "tool_call",
901
+ agentName,
902
+ message: bashExtMessage,
903
+ toolCallId: event.toolCallId,
904
+ toolName,
905
+ command,
906
+ }),
907
+ writeLog: writeReviewLog,
908
+ logContext: {
921
909
  source: "tool_call",
922
910
  toolCallId: event.toolCallId,
923
911
  toolName,
924
912
  agentName,
925
913
  command,
926
914
  externalPaths,
927
- resolution: "policy_denied",
928
- });
929
- return {
930
- block: true,
931
- reason: formatBashExternalDirectoryDenyReason(
915
+ message: bashExtMessage,
916
+ },
917
+ messages: {
918
+ denyReason: formatBashExternalDirectoryDenyReason(
932
919
  command,
933
920
  externalPaths,
934
921
  ctx.cwd,
935
922
  agentName ?? undefined,
936
923
  ),
937
- };
938
- }
939
-
940
- if (extCheck.state === "ask") {
941
- const message = formatBashExternalDirectoryAskPrompt(
942
- command,
943
- externalPaths,
944
- ctx.cwd,
945
- agentName ?? undefined,
946
- );
947
- if (!canRequestPermissionConfirmation(ctx)) {
948
- writeReviewLog("permission_request.blocked", {
949
- source: "tool_call",
950
- toolCallId: event.toolCallId,
951
- toolName,
952
- agentName,
953
- command,
954
- externalPaths,
955
- message,
956
- resolution: "confirmation_unavailable",
957
- });
958
- return {
959
- block: true,
960
- reason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
961
- };
962
- }
963
-
964
- const extDecision = await promptPermission(ctx, {
965
- requestId: event.toolCallId,
966
- source: "tool_call",
967
- agentName,
968
- message,
969
- toolCallId: event.toolCallId,
970
- toolName,
971
- command,
972
- });
973
- if (!extDecision.approved) {
974
- const reasonSuffix = extDecision.denialReason
975
- ? ` Reason: ${extDecision.denialReason}.`
976
- : "";
977
- return {
978
- block: true,
979
- reason: `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`,
980
- };
981
- }
924
+ unavailableReason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
925
+ userDeniedReason: (decision) => {
926
+ const reasonSuffix = decision.denialReason
927
+ ? ` Reason: ${decision.denialReason}.`
928
+ : "";
929
+ return `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
930
+ },
931
+ },
932
+ });
933
+ if (bashExtGate.action === "block") {
934
+ return { block: true, reason: bashExtGate.reason };
982
935
  }
983
936
  // state === "allow" → fall through to normal bash permission check
984
937
  }
@@ -996,61 +949,49 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
996
949
  PATH_BEARING_TOOLS,
997
950
  );
998
951
 
999
- if (check.state === "deny") {
1000
- writeReviewLog("permission_request.blocked", {
1001
- source: "tool_call",
1002
- toolCallId: event.toolCallId,
1003
- toolName,
1004
- agentName,
1005
- ...permissionLogContext,
1006
- resolution: "policy_denied",
1007
- });
1008
- return {
1009
- block: true,
1010
- reason: formatDenyReason(check, agentName ?? undefined),
1011
- };
1012
- }
952
+ const toolUnavailableReason =
953
+ toolName === "bash" && isToolCallEventType("bash", event)
954
+ ? `Running bash command '${event.input.command}' requires approval, but no interactive UI is available.`
955
+ : toolName === "mcp"
956
+ ? "Using tool 'mcp' requires approval, but no interactive UI is available."
957
+ : `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
1013
958
 
1014
- if (check.state === "ask") {
1015
- const unavailableReason =
1016
- toolName === "bash" && isToolCallEventType("bash", event)
1017
- ? `Running bash command '${event.input.command}' requires approval, but no interactive UI is available.`
1018
- : toolName === "mcp"
1019
- ? "Using tool 'mcp' requires approval, but no interactive UI is available."
1020
- : `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
1021
-
1022
- const message = formatAskPrompt(check, agentName ?? undefined, input);
1023
- if (!canRequestPermissionConfirmation(ctx)) {
1024
- writeReviewLog("permission_request.blocked", {
959
+ const toolAskMessage = formatAskPrompt(
960
+ check,
961
+ agentName ?? undefined,
962
+ input,
963
+ );
964
+ const toolGate = await applyPermissionGate({
965
+ state: check.state,
966
+ canConfirm: canRequestPermissionConfirmation(ctx),
967
+ promptForApproval: () =>
968
+ promptPermission(ctx, {
969
+ requestId: event.toolCallId,
1025
970
  source: "tool_call",
971
+ agentName,
972
+ message: toolAskMessage,
1026
973
  toolCallId: event.toolCallId,
1027
974
  toolName,
1028
- agentName,
1029
- message,
1030
975
  ...permissionLogContext,
1031
- resolution: "confirmation_unavailable",
1032
- });
1033
- return {
1034
- block: true,
1035
- reason: unavailableReason,
1036
- };
1037
- }
1038
-
1039
- const decision = await promptPermission(ctx, {
1040
- requestId: event.toolCallId,
976
+ }),
977
+ writeLog: writeReviewLog,
978
+ logContext: {
1041
979
  source: "tool_call",
1042
- agentName,
1043
- message,
1044
980
  toolCallId: event.toolCallId,
1045
981
  toolName,
982
+ agentName,
983
+ message: toolAskMessage,
1046
984
  ...permissionLogContext,
1047
- });
1048
- if (!decision.approved) {
1049
- return {
1050
- block: true,
1051
- reason: formatUserDeniedReason(check, decision.denialReason),
1052
- };
1053
- }
985
+ },
986
+ messages: {
987
+ denyReason: formatDenyReason(check, agentName ?? undefined),
988
+ unavailableReason: toolUnavailableReason,
989
+ userDeniedReason: (decision) =>
990
+ formatUserDeniedReason(check, decision.denialReason),
991
+ },
992
+ });
993
+ if (toolGate.action === "block") {
994
+ return { block: true, reason: toolGate.reason };
1054
995
  }
1055
996
 
1056
997
  return {};
@@ -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,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
+ });