@gotgenes/pi-permission-system 3.1.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,35 @@ 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
+
21
+ ## [3.2.0](https://github.com/gotgenes/pi-permission-system/compare/v3.1.0...v3.2.0) (2026-05-03)
22
+
23
+
24
+ ### Features
25
+
26
+ * add SAFE_SYSTEM_PATHS allowlist and isSafeSystemPath helper ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([331b53f](https://github.com/gotgenes/pi-permission-system/commit/331b53f1a6425c7ee641127cbc82b5aada1e7018))
27
+ * filter safe system paths from bash external path extraction ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([a0a907f](https://github.com/gotgenes/pi-permission-system/commit/a0a907f020cbf08722ea47be11d3f67fc95ef448))
28
+ * skip safe system paths in isPathOutsideWorkingDirectory ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([360594c](https://github.com/gotgenes/pi-permission-system/commit/360594c8ddfd6f7f45abe04352a514de292df357))
29
+
30
+
31
+ ### Documentation
32
+
33
+ * clarify /dev/null redirect risks in plan [#44](https://github.com/gotgenes/pi-permission-system/issues/44) ([00c61e7](https://github.com/gotgenes/pi-permission-system/commit/00c61e75eb5bf3cd9d5fc3297024ef9642655b86))
34
+ * note safe system path allowlist in external-directory section ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([eaec9ae](https://github.com/gotgenes/pi-permission-system/commit/eaec9ae4ad88155bf2630bba9607920cbbdc8583))
35
+ * plan auto-allow /dev/null in external directory checks ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([90b94f4](https://github.com/gotgenes/pi-permission-system/commit/90b94f4e0ae01b3a9f9dc90c5426720742a652e2))
36
+
8
37
  ## [3.1.0](https://github.com/gotgenes/pi-permission-system/compare/v3.0.5...v3.1.0) (2026-05-03)
9
38
 
10
39
 
package/README.md CHANGED
@@ -367,7 +367,7 @@ Reserved permission checks:
367
367
 
368
368
  `external_directory` is evaluated before the normal tool permission check. For example, `tools.read: "allow"` can permit ordinary reads while `special.external_directory: "ask"` still requires confirmation before reading `../outside.txt` or an absolute path outside `ctx.cwd`. Optional-path search tools (`find`, `grep`, `ls`) skip this check when no `path` is provided because they default to the active working directory.
369
369
 
370
- Bash commands are also covered: the extension extracts path-like tokens from the command string and applies the same gate when any resolve outside `ctx.cwd`. Quoted strings are stripped first to reduce false positives (e.g., paths inside `git commit -m "..."` messages). This is a best-effort heuristic — variable expansion, subshells, and escaped quotes are not parsed.
370
+ Bash commands are also covered: the extension extracts path-like tokens from the command string and applies the same gate when any resolve outside `ctx.cwd`. Quoted strings are stripped first to reduce false positives (e.g., paths inside `git commit -m "..."` messages). This is a best-effort heuristic — variable expansion, subshells, and escaped quotes are not parsed. OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded from this check — they cannot hold or leak data and commonly appear in stderr-redirect idioms such as `command 2>/dev/null`.
371
371
 
372
372
  ---
373
373
 
package/package.json CHANGED
@@ -1,14 +1,9 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
- "main": "./index.ts",
7
- "exports": {
8
- ".": "./index.ts"
9
- },
10
6
  "files": [
11
- "index.ts",
12
7
  "src",
13
8
  "tests",
14
9
  "config/config.example.json",
@@ -49,7 +44,7 @@
49
44
  },
50
45
  "pi": {
51
46
  "extensions": [
52
- "./index.ts"
47
+ "./src/index.ts"
53
48
  ]
54
49
  },
55
50
  "peerDependencies": {
@@ -71,7 +66,7 @@
71
66
  "lint:fix": "biome check --write .",
72
67
  "lint:md": "markdownlint-cli2 '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
73
68
  "lint:md:fix": "markdownlint-cli2 --fix '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
74
- "lint:imports": "! grep -rn --include='*.ts' 'from \"\\.[./][^\"]*\\.js\"' src/ tests/ index.ts",
69
+ "lint:imports": "! grep -rn --include='*.ts' 'from \"\\.[./][^\"]*\\.js\"' src/ tests/",
75
70
  "lint:all": "pnpm run lint && pnpm run lint:md && pnpm run lint:imports",
76
71
  "format": "biome format --write .",
77
72
  "test": "vitest run",
@@ -3,6 +3,25 @@ import { join, normalize, resolve, sep } from "node:path";
3
3
 
4
4
  import { getNonEmptyString, toRecord } from "./common";
5
5
 
6
+ /**
7
+ * Paths that are universally safe and should never trigger external-directory checks.
8
+ * These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
9
+ */
10
+ export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
11
+ "/dev/null",
12
+ "/dev/stdin",
13
+ "/dev/stdout",
14
+ "/dev/stderr",
15
+ ]);
16
+
17
+ /**
18
+ * Returns true if the given normalized path is a safe OS device file
19
+ * that should never trigger external-directory checks.
20
+ */
21
+ export function isSafeSystemPath(normalizedPath: string): boolean {
22
+ return SAFE_SYSTEM_PATHS.has(normalizedPath);
23
+ }
24
+
6
25
  export const PATH_BEARING_TOOLS = new Set([
7
26
  "read",
8
27
  "write",
@@ -72,11 +91,13 @@ export function isPathOutsideWorkingDirectory(
72
91
  ): boolean {
73
92
  const normalizedCwd = normalizePathForComparison(cwd, cwd);
74
93
  const normalizedPath = normalizePathForComparison(pathValue, cwd);
75
- return Boolean(
76
- normalizedCwd &&
77
- normalizedPath &&
78
- !isPathWithinDirectory(normalizedPath, normalizedCwd),
79
- );
94
+ if (!normalizedCwd || !normalizedPath) {
95
+ return false;
96
+ }
97
+ if (isSafeSystemPath(normalizedPath)) {
98
+ return false;
99
+ }
100
+ return !isPathWithinDirectory(normalizedPath, normalizedCwd);
80
101
  }
81
102
 
82
103
  export function formatExternalDirectoryHardStopHint(): string {
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
+ }
@@ -223,6 +223,62 @@ describe("extractExternalPathsFromBashCommand", () => {
223
223
  });
224
224
  });
225
225
 
226
+ describe("safe system paths are filtered", () => {
227
+ test("does not flag /dev/null in stderr redirect", () => {
228
+ const result = extractExternalPathsFromBashCommand(
229
+ "command 2>/dev/null",
230
+ cwd,
231
+ );
232
+ expect(result).toHaveLength(0);
233
+ });
234
+
235
+ test("does not flag /dev/null as a redirect target", () => {
236
+ const result = extractExternalPathsFromBashCommand(
237
+ "echo hello > /dev/null",
238
+ cwd,
239
+ );
240
+ expect(result).toHaveLength(0);
241
+ });
242
+
243
+ test("does not flag /dev/stdin", () => {
244
+ const result = extractExternalPathsFromBashCommand("cat /dev/stdin", cwd);
245
+ expect(result).toHaveLength(0);
246
+ });
247
+
248
+ test("does not flag /dev/stdout", () => {
249
+ const result = extractExternalPathsFromBashCommand(
250
+ "cat /dev/stdout",
251
+ cwd,
252
+ );
253
+ expect(result).toHaveLength(0);
254
+ });
255
+
256
+ test("does not flag /dev/stderr", () => {
257
+ const result = extractExternalPathsFromBashCommand(
258
+ "cat /dev/stderr",
259
+ cwd,
260
+ );
261
+ expect(result).toHaveLength(0);
262
+ });
263
+
264
+ test("still flags a real external path alongside /dev/null", () => {
265
+ const result = extractExternalPathsFromBashCommand(
266
+ "cat /etc/hosts 2>/dev/null",
267
+ cwd,
268
+ );
269
+ expect(result).toContain("/etc/hosts");
270
+ expect(result).not.toContain("/dev/null");
271
+ });
272
+
273
+ test("does not flag /dev/null/subdir (not a safe path)", () => {
274
+ const result = extractExternalPathsFromBashCommand(
275
+ "cat /dev/null/subdir",
276
+ cwd,
277
+ );
278
+ expect(result).toContain("/dev/null/subdir");
279
+ });
280
+ });
281
+
226
282
  describe("deduplication", () => {
227
283
  test("returns deduplicated paths", () => {
228
284
  const result = extractExternalPathsFromBashCommand(
@@ -18,8 +18,10 @@ import {
18
18
  getPathBearingToolPath,
19
19
  isPathOutsideWorkingDirectory,
20
20
  isPathWithinDirectory,
21
+ isSafeSystemPath,
21
22
  normalizePathForComparison,
22
23
  PATH_BEARING_TOOLS,
24
+ SAFE_SYSTEM_PATHS,
23
25
  } from "../src/external-directory";
24
26
 
25
27
  afterEach(() => {
@@ -39,6 +41,49 @@ describe("PATH_BEARING_TOOLS", () => {
39
41
  });
40
42
  });
41
43
 
44
+ describe("SAFE_SYSTEM_PATHS", () => {
45
+ test("contains /dev/null, /dev/stdin, /dev/stdout, /dev/stderr", () => {
46
+ expect(SAFE_SYSTEM_PATHS.has("/dev/null")).toBe(true);
47
+ expect(SAFE_SYSTEM_PATHS.has("/dev/stdin")).toBe(true);
48
+ expect(SAFE_SYSTEM_PATHS.has("/dev/stdout")).toBe(true);
49
+ expect(SAFE_SYSTEM_PATHS.has("/dev/stderr")).toBe(true);
50
+ });
51
+ });
52
+
53
+ describe("isSafeSystemPath", () => {
54
+ test("returns true for /dev/null", () => {
55
+ expect(isSafeSystemPath("/dev/null")).toBe(true);
56
+ });
57
+
58
+ test("returns true for /dev/stdin", () => {
59
+ expect(isSafeSystemPath("/dev/stdin")).toBe(true);
60
+ });
61
+
62
+ test("returns true for /dev/stdout", () => {
63
+ expect(isSafeSystemPath("/dev/stdout")).toBe(true);
64
+ });
65
+
66
+ test("returns true for /dev/stderr", () => {
67
+ expect(isSafeSystemPath("/dev/stderr")).toBe(true);
68
+ });
69
+
70
+ test("returns false for an arbitrary absolute path", () => {
71
+ expect(isSafeSystemPath("/etc/passwd")).toBe(false);
72
+ });
73
+
74
+ test("returns false for a path prefixed with a safe system path", () => {
75
+ expect(isSafeSystemPath("/dev/null/subdir")).toBe(false);
76
+ });
77
+
78
+ test("returns false for an empty string", () => {
79
+ expect(isSafeSystemPath("")).toBe(false);
80
+ });
81
+
82
+ test("returns false for a relative path", () => {
83
+ expect(isSafeSystemPath("dev/null")).toBe(false);
84
+ });
85
+ });
86
+
42
87
  describe("normalizePathForComparison", () => {
43
88
  const cwd = "/projects/my-app";
44
89
 
@@ -163,6 +208,26 @@ describe("isPathOutsideWorkingDirectory", () => {
163
208
  test("returns false for empty path (normalizes to empty string)", () => {
164
209
  expect(isPathOutsideWorkingDirectory("", cwd)).toBe(false);
165
210
  });
211
+
212
+ test("returns false for /dev/null regardless of cwd", () => {
213
+ expect(isPathOutsideWorkingDirectory("/dev/null", cwd)).toBe(false);
214
+ });
215
+
216
+ test("returns false for /dev/stdin regardless of cwd", () => {
217
+ expect(isPathOutsideWorkingDirectory("/dev/stdin", cwd)).toBe(false);
218
+ });
219
+
220
+ test("returns false for /dev/stdout regardless of cwd", () => {
221
+ expect(isPathOutsideWorkingDirectory("/dev/stdout", cwd)).toBe(false);
222
+ });
223
+
224
+ test("returns false for /dev/stderr regardless of cwd", () => {
225
+ expect(isPathOutsideWorkingDirectory("/dev/stderr", cwd)).toBe(false);
226
+ });
227
+
228
+ test("returns true for /dev/null/subdir (not a safe path)", () => {
229
+ expect(isPathOutsideWorkingDirectory("/dev/null/subdir", cwd)).toBe(true);
230
+ });
166
231
  });
167
232
 
168
233
  describe("formatExternalDirectoryHardStopHint", () => {
@@ -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
+ });
package/index.ts DELETED
@@ -1,3 +0,0 @@
1
- import permissionSystemExtension from "./src/index";
2
-
3
- export default permissionSystemExtension;