@gotgenes/pi-permission-system 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md 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.4.0](https://github.com/gotgenes/pi-permission-system/compare/v3.3.0...v3.4.0) (2026-05-03)
9
+
10
+
11
+ ### Features
12
+
13
+ * add "approve for session" option to permission dialog ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([909d5ee](https://github.com/gotgenes/pi-permission-system/commit/909d5ee540615f876852b3bdb60154487c2570fd))
14
+ * add SessionApprovalCache for ephemeral session approvals ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([4f97779](https://github.com/gotgenes/pi-permission-system/commit/4f9777980eba139c9c85a027eeddc84ff932911c))
15
+ * wire session approvals into external-directory gates ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([3ab156d](https://github.com/gotgenes/pi-permission-system/commit/3ab156dc4ad10bd37938a5096dc6c33970767b1a))
16
+
17
+
18
+ ### Documentation
19
+
20
+ * document session-scoped approval option ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([eb1eb9c](https://github.com/gotgenes/pi-permission-system/commit/eb1eb9c0052e7dd88ad7162b322160cfd6e0e62b))
21
+ * plan session-scoped approvals for permission prompts ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([29dcede](https://github.com/gotgenes/pi-permission-system/commit/29dcede17ad68fd3980bf3289069e237de2b4ef0))
22
+ * **retro:** add retro notes for issue [#41](https://github.com/gotgenes/pi-permission-system/issues/41) ([fd2755f](https://github.com/gotgenes/pi-permission-system/commit/fd2755fcf4ec68c9e65e6128e9b869da1f368abb))
23
+
24
+ ## [3.3.0](https://github.com/gotgenes/pi-permission-system/compare/v3.2.0...v3.3.0) (2026-05-03)
25
+
26
+
27
+ ### Features
28
+
29
+ * 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)
30
+
31
+
32
+ ### Documentation
33
+
34
+ * 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))
35
+ * **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))
36
+
8
37
  ## [3.2.0](https://github.com/gotgenes/pi-permission-system/compare/v3.1.0...v3.2.0) (2026-05-03)
9
38
 
10
39
 
package/README.md CHANGED
@@ -470,6 +470,23 @@ Example edit approval prompt:
470
470
  Current agent requested tool 'edit' for '.gitignore' (1 replacement: edit #1 replaces 5 lines with 2 lines). Allow this call?
471
471
  ```
472
472
 
473
+ ### Session-Scoped Approvals
474
+
475
+ When `special.external_directory` resolves to `ask`, the permission dialog offers four options:
476
+
477
+ ```text
478
+ Yes | Yes, for this session | No | No, provide reason
479
+ ```
480
+
481
+ Selecting **Yes, for this session** approves the current request and caches the directory prefix so that subsequent accesses under the same directory skip the prompt for the remainder of the session.
482
+ For example, approving access to `~/other-project/src/foo.ts` covers all paths under `~/other-project/src/` until the session ends.
483
+
484
+ Session approvals are ephemeral — they are never persisted to disk and are cleared on `session_shutdown`.
485
+ The review log records these decisions with `resolution: "session_approved"` so they remain auditable.
486
+
487
+ This is currently scoped to the `external_directory` surface only.
488
+ Other permission surfaces (tools, bash patterns, MCP, skills) always use the standard one-time approval flow.
489
+
473
490
  ### Subagent Permission Forwarding
474
491
 
475
492
  When a delegated or routed subagent runs without direct UI access, `ask` permissions can still be enforced by forwarding the confirmation request through Pi session directories. The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
@@ -514,6 +531,7 @@ This makes it easy to verify which files the extension actually loaded:
514
531
  index.ts → Root Pi entrypoint shim
515
532
  src/
516
533
  ├── index.ts → Extension bootstrap, permission checks, readable prompts, review logging, reload handling, and subagent forwarding
534
+ ├── session-approval-cache.ts → Ephemeral session-scoped approval cache for external-directory access
517
535
  ├── config-loader.ts → Unified config loader, merger, and legacy-path detection
518
536
  ├── config-paths.ts → Path derivation for global, project, and legacy config locations
519
537
  ├── config-reporter.ts → Resolved config path reporting for diagnostic logs
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.4.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,
@@ -79,6 +80,10 @@ import {
79
80
  formatUnknownToolReason,
80
81
  formatUserDeniedReason,
81
82
  } from "./permission-prompts";
83
+ import {
84
+ deriveApprovalPrefix,
85
+ SessionApprovalCache,
86
+ } from "./session-approval-cache";
82
87
  import {
83
88
  findSkillPathMatch,
84
89
  resolveSkillPromptEntries,
@@ -233,6 +238,7 @@ function createPermissionManagerForCwd(
233
238
 
234
239
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
235
240
  let permissionManager = new PermissionManager();
241
+ const sessionApprovalCache = new SessionApprovalCache();
236
242
  let activeSkillEntries: SkillPromptEntry[] = [];
237
243
  let lastKnownActiveAgentName: string | null = null;
238
244
  let lastActiveToolsCacheKey: string | null = null;
@@ -586,6 +592,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
586
592
  runtimeContext?.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
587
593
  runtimeContext = null;
588
594
  invalidateAgentStartCache();
595
+ sessionApprovalCache.clear();
589
596
  stopForwardedPermissionPolling();
590
597
  });
591
598
 
@@ -673,45 +680,44 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
673
680
  agentName ?? undefined,
674
681
  );
675
682
 
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" };
683
+ if (check.state === "deny" && ctx.hasUI) {
684
+ const notifyMessage = agentName
685
+ ? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
686
+ : `Skill '${skillName}' is not permitted by the current skill policy.`;
687
+ ctx.ui.notify(notifyMessage, "warning");
690
688
  }
691
689
 
692
- if (check.state === "ask") {
693
- const message = formatSkillAskPrompt(skillName, agentName ?? undefined);
694
- if (!canRequestPermissionConfirmation(ctx)) {
695
- writeReviewLog("permission_request.blocked", {
690
+ const skillInputMessage = formatSkillAskPrompt(
691
+ skillName,
692
+ agentName ?? undefined,
693
+ );
694
+ const skillInputGate = await applyPermissionGate({
695
+ state: check.state,
696
+ canConfirm: canRequestPermissionConfirmation(ctx),
697
+ promptForApproval: () =>
698
+ promptPermission(ctx, {
699
+ requestId: createPermissionRequestId("skill-input"),
696
700
  source: "skill_input",
697
- skillName,
698
701
  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"),
702
+ message: skillInputMessage,
703
+ skillName,
704
+ }),
705
+ writeLog: writeReviewLog,
706
+ logContext: {
707
707
  source: "skill_input",
708
- agentName,
709
- message,
710
708
  skillName,
711
- });
712
- if (!decision.approved) {
713
- return { action: "handled" };
714
- }
709
+ agentName,
710
+ message: skillInputMessage,
711
+ },
712
+ messages: {
713
+ denyReason: skillInputMessage,
714
+ unavailableReason:
715
+ "Skill requires approval, but no interactive UI is available.",
716
+ userDeniedReason: () => "User denied skill.",
717
+ },
718
+ });
719
+ if (skillInputGate.action === "block") {
720
+ return { action: "handled" };
715
721
  }
716
722
 
717
723
  return { action: "continue" };
@@ -756,64 +762,50 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
756
762
  );
757
763
 
758
764
  if (matchedSkill) {
759
- if (matchedSkill.state === "deny") {
760
- writeReviewLog("permission_request.blocked", {
765
+ const skillReadMessage = formatSkillPathAskPrompt(
766
+ matchedSkill,
767
+ event.input.path,
768
+ agentName ?? undefined,
769
+ );
770
+ const skillReadGate = await applyPermissionGate({
771
+ state: matchedSkill.state,
772
+ canConfirm: canRequestPermissionConfirmation(ctx),
773
+ promptForApproval: () =>
774
+ promptPermission(ctx, {
775
+ requestId: event.toolCallId,
776
+ source: "skill_read",
777
+ agentName,
778
+ message: skillReadMessage,
779
+ toolCallId: event.toolCallId,
780
+ toolName: toolName,
781
+ skillName: matchedSkill.name,
782
+ path: event.input.path,
783
+ }),
784
+ writeLog: writeReviewLog,
785
+ logContext: {
761
786
  source: "skill_read",
762
787
  skillName: matchedSkill.name,
763
788
  agentName,
764
789
  path: event.input.path,
765
- resolution: "policy_denied",
766
- });
767
- return {
768
- block: true,
769
- reason: formatSkillPathDenyReason(
790
+ message: skillReadMessage,
791
+ },
792
+ messages: {
793
+ denyReason: formatSkillPathDenyReason(
770
794
  matchedSkill,
771
795
  event.input.path,
772
796
  agentName ?? undefined,
773
797
  ),
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
- }
798
+ unavailableReason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
799
+ userDeniedReason: (decision) => {
800
+ const denialReason = decision.denialReason
801
+ ? ` Reason: ${decision.denialReason}.`
802
+ : "";
803
+ return `User denied access to skill '${matchedSkill.name}'.${denialReason}`;
804
+ },
805
+ },
806
+ });
807
+ if (skillReadGate.action === "block") {
808
+ return { block: true, reason: skillReadGate.reason };
817
809
  }
818
810
  }
819
811
  }
@@ -828,77 +820,91 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
828
820
  externalDirectoryPath &&
829
821
  isPathOutsideWorkingDirectory(externalDirectoryPath, ctx.cwd)
830
822
  ) {
831
- const extCheck = permissionManager.checkPermission(
823
+ const normalizedExtPath = normalizePathForComparison(
824
+ externalDirectoryPath,
825
+ ctx.cwd,
826
+ );
827
+ const sessionPrefix = sessionApprovalCache.findMatchingPrefix(
832
828
  "external_directory",
833
- {},
834
- agentName ?? undefined,
829
+ normalizedExtPath,
835
830
  );
836
831
 
837
- if (extCheck.state === "deny") {
838
- writeReviewLog("permission_request.blocked", {
832
+ if (sessionPrefix) {
833
+ writeReviewLog("permission_request.session_approved", {
839
834
  source: "tool_call",
840
835
  toolCallId: event.toolCallId,
841
836
  toolName,
842
837
  agentName,
843
838
  path: externalDirectoryPath,
844
- resolution: "policy_denied",
839
+ resolution: "session_approved",
840
+ sessionApprovalPrefix: sessionPrefix,
845
841
  });
846
- return {
847
- block: true,
848
- reason: formatExternalDirectoryDenyReason(
849
- toolName,
850
- externalDirectoryPath,
851
- ctx.cwd,
852
- agentName ?? undefined,
853
- ),
854
- };
855
- }
842
+ // Fall through to normal permission check
843
+ } else {
844
+ const extCheck = permissionManager.checkPermission(
845
+ "external_directory",
846
+ {},
847
+ agentName ?? undefined,
848
+ );
856
849
 
857
- if (extCheck.state === "ask") {
858
- const message = formatExternalDirectoryAskPrompt(
850
+ let extDirDecision: PermissionPromptDecision | null = null;
851
+ const extDirMessage = formatExternalDirectoryAskPrompt(
859
852
  toolName,
860
853
  externalDirectoryPath,
861
854
  ctx.cwd,
862
855
  agentName ?? undefined,
863
856
  );
864
- if (!canRequestPermissionConfirmation(ctx)) {
865
- writeReviewLog("permission_request.blocked", {
857
+ const extDirGate = await applyPermissionGate({
858
+ state: extCheck.state,
859
+ canConfirm: canRequestPermissionConfirmation(ctx),
860
+ promptForApproval: async () => {
861
+ const decision = await promptPermission(ctx, {
862
+ requestId: event.toolCallId,
863
+ source: "tool_call",
864
+ agentName,
865
+ message: extDirMessage,
866
+ toolCallId: event.toolCallId,
867
+ toolName,
868
+ path: externalDirectoryPath,
869
+ });
870
+ extDirDecision = decision;
871
+ return decision;
872
+ },
873
+ writeLog: writeReviewLog,
874
+ logContext: {
866
875
  source: "tool_call",
867
876
  toolCallId: event.toolCallId,
868
877
  toolName,
869
878
  agentName,
870
879
  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(
880
+ message: extDirMessage,
881
+ },
882
+ messages: {
883
+ denyReason: formatExternalDirectoryDenyReason(
894
884
  toolName,
895
885
  externalDirectoryPath,
896
- extDecision.denialReason,
886
+ ctx.cwd,
887
+ agentName ?? undefined,
897
888
  ),
898
- };
889
+ unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
890
+ userDeniedReason: (decision) =>
891
+ formatExternalDirectoryUserDeniedReason(
892
+ toolName,
893
+ externalDirectoryPath,
894
+ decision.denialReason,
895
+ ),
896
+ },
897
+ });
898
+ if (extDirGate.action === "block") {
899
+ return { block: true, reason: extDirGate.reason };
900
+ }
901
+
902
+ if (extDirDecision?.state === "approved_for_session") {
903
+ const prefix = deriveApprovalPrefix(normalizedExtPath);
904
+ sessionApprovalCache.approve("external_directory", prefix);
899
905
  }
900
906
  }
901
- // state === "allow" → fall through to normal permission check
907
+ // Fall through to normal permission check
902
908
  }
903
909
 
904
910
  // Bash external directory gate: extract paths from bash commands
@@ -910,77 +916,91 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
910
916
  ctx.cwd,
911
917
  );
912
918
  if (externalPaths.length > 0) {
913
- const extCheck = permissionManager.checkPermission(
914
- "external_directory",
915
- {},
916
- agentName ?? undefined,
919
+ // Filter out paths already covered by session approvals
920
+ const uncoveredPaths = externalPaths.filter(
921
+ (p) => !sessionApprovalCache.has("external_directory", p),
917
922
  );
918
923
 
919
- if (extCheck.state === "deny") {
920
- writeReviewLog("permission_request.blocked", {
924
+ if (uncoveredPaths.length === 0) {
925
+ // All external paths are session-approved
926
+ writeReviewLog("permission_request.session_approved", {
921
927
  source: "tool_call",
922
928
  toolCallId: event.toolCallId,
923
929
  toolName,
924
930
  agentName,
925
931
  command,
926
932
  externalPaths,
927
- resolution: "policy_denied",
933
+ resolution: "session_approved",
928
934
  });
929
- return {
930
- block: true,
931
- reason: formatBashExternalDirectoryDenyReason(
932
- command,
933
- externalPaths,
934
- ctx.cwd,
935
- agentName ?? undefined,
936
- ),
937
- };
938
- }
935
+ // Fall through to normal bash permission check
936
+ } else {
937
+ const extCheck = permissionManager.checkPermission(
938
+ "external_directory",
939
+ {},
940
+ agentName ?? undefined,
941
+ );
939
942
 
940
- if (extCheck.state === "ask") {
941
- const message = formatBashExternalDirectoryAskPrompt(
943
+ let bashExtDecision: PermissionPromptDecision | null = null;
944
+ const bashExtMessage = formatBashExternalDirectoryAskPrompt(
942
945
  command,
943
- externalPaths,
946
+ uncoveredPaths,
944
947
  ctx.cwd,
945
948
  agentName ?? undefined,
946
949
  );
947
- if (!canRequestPermissionConfirmation(ctx)) {
948
- writeReviewLog("permission_request.blocked", {
950
+ const bashExtGate = await applyPermissionGate({
951
+ state: extCheck.state,
952
+ canConfirm: canRequestPermissionConfirmation(ctx),
953
+ promptForApproval: async () => {
954
+ const decision = await promptPermission(ctx, {
955
+ requestId: event.toolCallId,
956
+ source: "tool_call",
957
+ agentName,
958
+ message: bashExtMessage,
959
+ toolCallId: event.toolCallId,
960
+ toolName,
961
+ command,
962
+ });
963
+ bashExtDecision = decision;
964
+ return decision;
965
+ },
966
+ writeLog: writeReviewLog,
967
+ logContext: {
949
968
  source: "tool_call",
950
969
  toolCallId: event.toolCallId,
951
970
  toolName,
952
971
  agentName,
953
972
  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
- };
973
+ externalPaths: uncoveredPaths,
974
+ message: bashExtMessage,
975
+ },
976
+ messages: {
977
+ denyReason: formatBashExternalDirectoryDenyReason(
978
+ command,
979
+ uncoveredPaths,
980
+ ctx.cwd,
981
+ agentName ?? undefined,
982
+ ),
983
+ unavailableReason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
984
+ userDeniedReason: (decision) => {
985
+ const reasonSuffix = decision.denialReason
986
+ ? ` Reason: ${decision.denialReason}.`
987
+ : "";
988
+ return `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
989
+ },
990
+ },
991
+ });
992
+ if (bashExtGate.action === "block") {
993
+ return { block: true, reason: bashExtGate.reason };
962
994
  }
963
995
 
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
- };
996
+ if (bashExtDecision?.state === "approved_for_session") {
997
+ for (const extPath of uncoveredPaths) {
998
+ const prefix = deriveApprovalPrefix(extPath);
999
+ sessionApprovalCache.approve("external_directory", prefix);
1000
+ }
981
1001
  }
982
1002
  }
983
- // state === "allow" → fall through to normal bash permission check
1003
+ // Fall through to normal bash permission check
984
1004
  }
985
1005
  }
986
1006
  }
@@ -996,61 +1016,49 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
996
1016
  PATH_BEARING_TOOLS,
997
1017
  );
998
1018
 
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
- }
1019
+ const toolUnavailableReason =
1020
+ toolName === "bash" && isToolCallEventType("bash", event)
1021
+ ? `Running bash command '${event.input.command}' requires approval, but no interactive UI is available.`
1022
+ : toolName === "mcp"
1023
+ ? "Using tool 'mcp' requires approval, but no interactive UI is available."
1024
+ : `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
1013
1025
 
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", {
1026
+ const toolAskMessage = formatAskPrompt(
1027
+ check,
1028
+ agentName ?? undefined,
1029
+ input,
1030
+ );
1031
+ const toolGate = await applyPermissionGate({
1032
+ state: check.state,
1033
+ canConfirm: canRequestPermissionConfirmation(ctx),
1034
+ promptForApproval: () =>
1035
+ promptPermission(ctx, {
1036
+ requestId: event.toolCallId,
1025
1037
  source: "tool_call",
1038
+ agentName,
1039
+ message: toolAskMessage,
1026
1040
  toolCallId: event.toolCallId,
1027
1041
  toolName,
1028
- agentName,
1029
- message,
1030
1042
  ...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,
1043
+ }),
1044
+ writeLog: writeReviewLog,
1045
+ logContext: {
1041
1046
  source: "tool_call",
1042
- agentName,
1043
- message,
1044
1047
  toolCallId: event.toolCallId,
1045
1048
  toolName,
1049
+ agentName,
1050
+ message: toolAskMessage,
1046
1051
  ...permissionLogContext,
1047
- });
1048
- if (!decision.approved) {
1049
- return {
1050
- block: true,
1051
- reason: formatUserDeniedReason(check, decision.denialReason),
1052
- };
1053
- }
1052
+ },
1053
+ messages: {
1054
+ denyReason: formatDenyReason(check, agentName ?? undefined),
1055
+ unavailableReason: toolUnavailableReason,
1056
+ userDeniedReason: (decision) =>
1057
+ formatUserDeniedReason(check, decision.denialReason),
1058
+ },
1059
+ });
1060
+ if (toolGate.action === "block") {
1061
+ return { block: true, reason: toolGate.reason };
1054
1062
  }
1055
1063
 
1056
1064
  return {};