@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 +13 -0
- package/package.json +1 -1
- package/src/index.ts +172 -231
- package/src/permission-gate.ts +74 -0
- package/tests/permission-gate.test.ts +201 -0
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
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
701
|
-
})
|
|
702
|
-
|
|
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
|
-
|
|
713
|
-
|
|
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
|
-
|
|
760
|
-
|
|
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
|
-
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
838
|
-
|
|
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
|
-
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
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
|
-
|
|
920
|
-
|
|
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
|
-
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
+
});
|