pi-permission-system 0.2.2 → 0.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/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { isToolCallEventType, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import { isToolCallEventType, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, normalize, resolve, sep } from "node:path";
@@ -6,27 +6,35 @@ import { dirname, join, normalize, resolve, sep } from "node:path";
6
6
  import { toRecord } from "./common.js";
7
7
  import {
8
8
  DEFAULT_EXTENSION_CONFIG,
9
+ getPermissionSystemConfigPath,
9
10
  loadPermissionSystemConfig,
11
+ normalizePermissionSystemConfig,
12
+ savePermissionSystemConfig,
10
13
  type PermissionSystemExtensionConfig,
11
14
  } from "./extension-config.js";
12
15
  import { createPermissionSystemLogger } from "./logging.js";
16
+ import { registerPermissionSystemCommand } from "./config-modal.js";
17
+ import {
18
+ createPermissionForwardingLocation,
19
+ isForwardedPermissionRequestForSession,
20
+ PERMISSION_FORWARDING_POLL_INTERVAL_MS,
21
+ PERMISSION_FORWARDING_TIMEOUT_MS,
22
+ resolvePermissionForwardingTargetSessionId,
23
+ SUBAGENT_ENV_HINT_KEYS,
24
+ type ForwardedPermissionRequest,
25
+ type ForwardedPermissionResponse,
26
+ type PermissionForwardingLocation,
27
+ } from "./permission-forwarding.js";
13
28
  import { PermissionManager } from "./permission-manager.js";
14
29
  import { sanitizeAvailableToolsSection } from "./system-prompt-sanitizer.js";
15
30
  import { checkRequestedToolRegistration, getToolNameFromValue } from "./tool-registry.js";
16
31
  import type { PermissionCheckResult, PermissionState } from "./types.js";
32
+ import { canResolveAskPermissionRequest, shouldAutoApprovePermissionState } from "./yolo-mode.js";
17
33
 
18
34
  const PI_AGENT_DIR = join(homedir(), ".pi", "agent");
19
35
  const SESSIONS_DIR = join(PI_AGENT_DIR, "sessions");
20
36
  const SUBAGENT_SESSIONS_DIR = join(PI_AGENT_DIR, "subagent-sessions");
21
37
  const PERMISSION_FORWARDING_DIR = join(SESSIONS_DIR, "permission-forwarding");
22
- const PERMISSION_FORWARDING_REQUESTS_DIR = join(PERMISSION_FORWARDING_DIR, "requests");
23
- const PERMISSION_FORWARDING_RESPONSES_DIR = join(PERMISSION_FORWARDING_DIR, "responses");
24
- const LEGACY_PERMISSION_FORWARDING_DIR = join(PI_AGENT_DIR, "permission-forwarding");
25
- const LEGACY_PERMISSION_FORWARDING_REQUESTS_DIR = join(LEGACY_PERMISSION_FORWARDING_DIR, "requests");
26
- const LEGACY_PERMISSION_FORWARDING_RESPONSES_DIR = join(LEGACY_PERMISSION_FORWARDING_DIR, "responses");
27
- const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
28
- const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
29
- const SUBAGENT_ENV_HINT_KEYS = ["PI_IS_SUBAGENT", "PI_SUBAGENT_SESSION_ID", "PI_AGENT_ROUTER_SUBAGENT"] as const;
30
38
 
31
39
  const AVAILABLE_SKILLS_OPEN_TAG = "<available_skills>";
32
40
  const AVAILABLE_SKILLS_CLOSE_TAG = "</available_skills>";
@@ -51,26 +59,6 @@ type SkillPromptSection = {
51
59
  entries: Array<{ name: string; description: string; location: string }>;
52
60
  };
53
61
 
54
- type ForwardedPermissionRequest = {
55
- id: string;
56
- createdAt: number;
57
- requesterSessionId: string;
58
- requesterAgentName: string;
59
- message: string;
60
- };
61
-
62
- type ForwardedPermissionResponse = {
63
- approved: boolean;
64
- responderSessionId: string;
65
- respondedAt: number;
66
- };
67
-
68
- type PermissionForwardingLocation = {
69
- requestsDir: string;
70
- responsesDir: string;
71
- label: "primary" | "legacy";
72
- };
73
-
74
62
  type PermissionRequestSource = "tool_call" | "skill_input" | "skill_read";
75
63
  type PermissionRequestState = "waiting" | "approved" | "denied";
76
64
 
@@ -98,10 +86,7 @@ const reportedLoggingWarnings = new Set<string>();
98
86
  let loggingWarningReporter: ((message: string) => void) | null = null;
99
87
 
100
88
  function setExtensionConfig(config: PermissionSystemExtensionConfig): void {
101
- extensionConfig = {
102
- debugLog: config.debugLog,
103
- permissionReviewLog: config.permissionReviewLog,
104
- };
89
+ extensionConfig = normalizePermissionSystemConfig(config);
105
90
  }
106
91
 
107
92
  function setLoggingWarningReporter(reporter: ((message: string) => void) | null): void {
@@ -514,7 +499,11 @@ function isSubagentExecutionContext(ctx: ExtensionContext): boolean {
514
499
  }
515
500
 
516
501
  function canRequestPermissionConfirmation(ctx: ExtensionContext): boolean {
517
- return ctx.hasUI || isSubagentExecutionContext(ctx);
502
+ return canResolveAskPermissionRequest({
503
+ config: extensionConfig,
504
+ hasUI: ctx.hasUI,
505
+ isSubagent: isSubagentExecutionContext(ctx),
506
+ });
518
507
  }
519
508
 
520
509
  function formatUnknownErrorMessage(error: unknown): string {
@@ -556,54 +545,35 @@ function ensureDirectoryExists(path: string, description: string): boolean {
556
545
  }
557
546
  }
558
547
 
559
- function ensurePermissionForwardingDirectories(): boolean {
560
- const requestsReady = ensureDirectoryExists(PERMISSION_FORWARDING_REQUESTS_DIR, "permission forwarding requests");
561
- const responsesReady = ensureDirectoryExists(PERMISSION_FORWARDING_RESPONSES_DIR, "permission forwarding responses");
562
- return requestsReady && responsesReady;
548
+ function getPermissionForwardingLocationForSession(sessionId: string): PermissionForwardingLocation {
549
+ return createPermissionForwardingLocation(PERMISSION_FORWARDING_DIR, sessionId);
563
550
  }
564
551
 
565
- function ensureLegacyPermissionForwardingResponsesDirectory(): boolean {
566
- if (existsSync(LEGACY_PERMISSION_FORWARDING_RESPONSES_DIR)) {
567
- return true;
568
- }
569
-
570
- if (!existsSync(LEGACY_PERMISSION_FORWARDING_DIR)) {
571
- logPermissionForwardingWarning(`Legacy permission-forwarding root '${LEGACY_PERMISSION_FORWARDING_DIR}' does not exist`);
572
- return false;
573
- }
574
-
552
+ function ensurePermissionForwardingLocation(sessionId: string): PermissionForwardingLocation | null {
553
+ let location: PermissionForwardingLocation;
575
554
  try {
576
- mkdirSync(LEGACY_PERMISSION_FORWARDING_RESPONSES_DIR, { recursive: true });
577
- return true;
555
+ location = getPermissionForwardingLocationForSession(sessionId);
578
556
  } catch (error) {
579
- logPermissionForwardingError(
580
- `Failed to create legacy permission forwarding responses directory '${LEGACY_PERMISSION_FORWARDING_RESPONSES_DIR}'`,
581
- error,
582
- );
583
- return false;
557
+ logPermissionForwardingError("Failed to resolve permission forwarding location", error);
558
+ return null;
584
559
  }
585
- }
586
560
 
587
- function getPermissionForwardingLocationsForProcessing(): PermissionForwardingLocation[] {
588
- const locations: PermissionForwardingLocation[] = [];
561
+ const sessionRootReady = ensureDirectoryExists(location.sessionRootDir, "permission forwarding session root");
562
+ const requestsReady = ensureDirectoryExists(location.requestsDir, "permission forwarding requests");
563
+ const responsesReady = ensureDirectoryExists(location.responsesDir, "permission forwarding responses");
589
564
 
590
- if (ensurePermissionForwardingDirectories()) {
591
- locations.push({
592
- requestsDir: PERMISSION_FORWARDING_REQUESTS_DIR,
593
- responsesDir: PERMISSION_FORWARDING_RESPONSES_DIR,
594
- label: "primary",
595
- });
596
- }
565
+ return sessionRootReady && requestsReady && responsesReady ? location : null;
566
+ }
597
567
 
598
- if (existsSync(LEGACY_PERMISSION_FORWARDING_REQUESTS_DIR)) {
599
- locations.push({
600
- requestsDir: LEGACY_PERMISSION_FORWARDING_REQUESTS_DIR,
601
- responsesDir: LEGACY_PERMISSION_FORWARDING_RESPONSES_DIR,
602
- label: "legacy",
603
- });
568
+ function getExistingPermissionForwardingLocation(sessionId: string): PermissionForwardingLocation | null {
569
+ let location: PermissionForwardingLocation;
570
+ try {
571
+ location = getPermissionForwardingLocationForSession(sessionId);
572
+ } catch {
573
+ return null;
604
574
  }
605
575
 
606
- return locations;
576
+ return existsSync(location.requestsDir) ? location : null;
607
577
  }
608
578
 
609
579
  function tryRemoveDirectoryIfEmpty(path: string, description: string): void {
@@ -634,14 +604,10 @@ function tryRemoveDirectoryIfEmpty(path: string, description: string): void {
634
604
  }
635
605
  }
636
606
 
637
- function cleanupLegacyPermissionForwardingDirectoryIfEmpty(): void {
638
- if (!existsSync(LEGACY_PERMISSION_FORWARDING_DIR)) {
639
- return;
640
- }
641
-
642
- tryRemoveDirectoryIfEmpty(LEGACY_PERMISSION_FORWARDING_REQUESTS_DIR, "legacy permission forwarding requests");
643
- tryRemoveDirectoryIfEmpty(LEGACY_PERMISSION_FORWARDING_RESPONSES_DIR, "legacy permission forwarding responses");
644
- tryRemoveDirectoryIfEmpty(LEGACY_PERMISSION_FORWARDING_DIR, "legacy permission forwarding root");
607
+ function cleanupPermissionForwardingLocationIfEmpty(location: PermissionForwardingLocation): void {
608
+ tryRemoveDirectoryIfEmpty(location.requestsDir, `${location.label} permission forwarding requests`);
609
+ tryRemoveDirectoryIfEmpty(location.responsesDir, `${location.label} permission forwarding responses`);
610
+ tryRemoveDirectoryIfEmpty(location.sessionRootDir, `${location.label} permission forwarding session root`);
645
611
  }
646
612
 
647
613
  function safeDeleteFile(filePath: string, description: string): void {
@@ -677,6 +643,7 @@ function readForwardedPermissionRequest(filePath: string): ForwardedPermissionRe
677
643
  || typeof parsed.id !== "string"
678
644
  || typeof parsed.createdAt !== "number"
679
645
  || typeof parsed.requesterSessionId !== "string"
646
+ || typeof parsed.targetSessionId !== "string"
680
647
  || typeof parsed.requesterAgentName !== "string"
681
648
  || typeof parsed.message !== "string"
682
649
  ) {
@@ -688,6 +655,7 @@ function readForwardedPermissionRequest(filePath: string): ForwardedPermissionRe
688
655
  id: parsed.id,
689
656
  createdAt: parsed.createdAt,
690
657
  requesterSessionId: parsed.requesterSessionId,
658
+ targetSessionId: parsed.targetSessionId,
691
659
  requesterAgentName: parsed.requesterAgentName,
692
660
  message: parsed.message,
693
661
  };
@@ -729,8 +697,26 @@ function formatForwardedPermissionPrompt(request: ForwardedPermissionRequest): s
729
697
  }
730
698
 
731
699
  async function waitForForwardedPermissionApproval(ctx: ExtensionContext, message: string): Promise<boolean> {
732
- if (!ensurePermissionForwardingDirectories()) {
733
- logPermissionForwardingError("Permission forwarding is unavailable because primary directories could not be prepared");
700
+ const requesterSessionId = getSessionId(ctx);
701
+ const targetSessionId = resolvePermissionForwardingTargetSessionId({
702
+ hasUI: ctx.hasUI,
703
+ isSubagent: isSubagentExecutionContext(ctx),
704
+ currentSessionId: requesterSessionId,
705
+ env: process.env,
706
+ });
707
+
708
+ if (!targetSessionId) {
709
+ logPermissionForwardingError(
710
+ "Permission forwarding target session could not be resolved from subagent runtime metadata (expected PI_AGENT_ROUTER_PARENT_SESSION_ID)",
711
+ );
712
+ return false;
713
+ }
714
+
715
+ const location = ensurePermissionForwardingLocation(targetSessionId);
716
+ if (!location) {
717
+ logPermissionForwardingError(
718
+ `Permission forwarding is unavailable because session-scoped directories could not be prepared for '${targetSessionId}'`,
719
+ );
734
720
  return false;
735
721
  }
736
722
 
@@ -739,18 +725,20 @@ async function waitForForwardedPermissionApproval(ctx: ExtensionContext, message
739
725
  const request: ForwardedPermissionRequest = {
740
726
  id: requestId,
741
727
  createdAt: Date.now(),
742
- requesterSessionId: getSessionId(ctx),
728
+ requesterSessionId,
729
+ targetSessionId,
743
730
  requesterAgentName,
744
731
  message,
745
732
  };
746
733
 
747
- const requestPath = join(PERMISSION_FORWARDING_REQUESTS_DIR, `${requestId}.json`);
748
- const responsePath = join(PERMISSION_FORWARDING_RESPONSES_DIR, `${requestId}.json`);
734
+ const requestPath = join(location.requestsDir, `${requestId}.json`);
735
+ const responsePath = join(location.responsesDir, `${requestId}.json`);
749
736
 
750
737
  writeReviewLog("forwarded_permission.request_created", {
751
738
  requestId,
752
739
  requesterAgentName,
753
740
  requesterSessionId: request.requesterSessionId,
741
+ targetSessionId,
754
742
  requestPath,
755
743
  responsePath,
756
744
  });
@@ -770,10 +758,12 @@ async function waitForForwardedPermissionApproval(ctx: ExtensionContext, message
770
758
  requestId,
771
759
  approved: response?.approved ?? null,
772
760
  responderSessionId: response?.responderSessionId ?? null,
761
+ targetSessionId,
773
762
  responsePath,
774
763
  });
775
764
  safeDeleteFile(responsePath, "forwarded permission response");
776
765
  safeDeleteFile(requestPath, "forwarded permission request");
766
+ cleanupPermissionForwardingLocationIfEmpty(location);
777
767
  return Boolean(response?.approved);
778
768
  }
779
769
 
@@ -784,9 +774,11 @@ async function waitForForwardedPermissionApproval(ctx: ExtensionContext, message
784
774
  writeReviewLog("forwarded_permission.response_timed_out", {
785
775
  requestId,
786
776
  requesterAgentName,
777
+ targetSessionId,
787
778
  responsePath,
788
779
  });
789
780
  safeDeleteFile(requestPath, "forwarded permission request");
781
+ cleanupPermissionForwardingLocationIfEmpty(location);
790
782
  return false;
791
783
  }
792
784
 
@@ -795,74 +787,85 @@ async function processForwardedPermissionRequests(ctx: ExtensionContext): Promis
795
787
  return;
796
788
  }
797
789
 
798
- const forwardingLocations = getPermissionForwardingLocationsForProcessing();
799
- if (forwardingLocations.length === 0) {
790
+ const currentSessionId = getSessionId(ctx);
791
+ const location = getExistingPermissionForwardingLocation(currentSessionId);
792
+ if (!location) {
800
793
  return;
801
794
  }
802
795
 
803
- for (const location of forwardingLocations) {
804
- let requestFiles: string[] = [];
805
- try {
806
- requestFiles = readdirSync(location.requestsDir)
807
- .filter((name) => name.endsWith(".json"))
808
- .sort();
809
- } catch (error) {
810
- logPermissionForwardingWarning(`Failed to read ${location.label} permission forwarding requests from '${location.requestsDir}'`, error);
796
+ let requestFiles: string[] = [];
797
+ try {
798
+ requestFiles = readdirSync(location.requestsDir)
799
+ .filter((name) => name.endsWith(".json"))
800
+ .sort();
801
+ } catch (error) {
802
+ logPermissionForwardingWarning(`Failed to read ${location.label} permission forwarding requests from '${location.requestsDir}'`, error);
803
+ return;
804
+ }
805
+
806
+ for (const fileName of requestFiles) {
807
+ const requestPath = join(location.requestsDir, fileName);
808
+ const request = readForwardedPermissionRequest(requestPath);
809
+ if (!request) {
810
+ safeDeleteFile(requestPath, `${location.label} forwarded permission request`);
811
811
  continue;
812
812
  }
813
813
 
814
- for (const fileName of requestFiles) {
815
- const requestPath = join(location.requestsDir, fileName);
816
- const request = readForwardedPermissionRequest(requestPath);
817
- if (!request) {
818
- safeDeleteFile(requestPath, `${location.label} forwarded permission request`);
819
- continue;
820
- }
814
+ if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
815
+ logPermissionForwardingWarning(
816
+ `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
817
+ );
818
+ safeDeleteFile(requestPath, `${location.label} forwarded permission request`);
819
+ continue;
820
+ }
821
821
 
822
- writeReviewLog("forwarded_permission.prompted", {
823
- requestId: request.id,
824
- source: location.label,
825
- requesterAgentName: request.requesterAgentName,
826
- requesterSessionId: request.requesterSessionId,
827
- requestPath,
828
- });
822
+ const forwardedPermissionLogDetails = {
823
+ requestId: request.id,
824
+ source: location.label,
825
+ requesterAgentName: request.requesterAgentName,
826
+ requesterSessionId: request.requesterSessionId,
827
+ targetSessionId: request.targetSessionId,
828
+ requestPath,
829
+ };
829
830
 
830
- let approved = false;
831
+ let approved = false;
832
+ if (shouldAutoApprovePermissionState("ask", extensionConfig)) {
833
+ writeReviewLog("forwarded_permission.auto_approved", forwardedPermissionLogDetails);
834
+ approved = true;
835
+ } else {
836
+ writeReviewLog("forwarded_permission.prompted", forwardedPermissionLogDetails);
831
837
  try {
832
838
  approved = await ctx.ui.confirm("Permission Required (Subagent)", formatForwardedPermissionPrompt(request));
833
839
  } catch (error) {
834
840
  logPermissionForwardingError("Failed to show forwarded permission confirmation dialog", error);
835
841
  approved = false;
836
842
  }
843
+ }
837
844
 
838
- if (location.label === "legacy" && !ensureLegacyPermissionForwardingResponsesDirectory()) {
839
- continue;
840
- }
841
-
842
- const responsePath = join(location.responsesDir, `${request.id}.json`);
843
- writeReviewLog(approved ? "forwarded_permission.approved" : "forwarded_permission.denied", {
844
- requestId: request.id,
845
- source: location.label,
846
- requesterAgentName: request.requesterAgentName,
847
- requesterSessionId: request.requesterSessionId,
848
- responsePath,
849
- });
850
- try {
851
- writeJsonFileAtomic(responsePath, {
852
- approved,
853
- responderSessionId: getSessionId(ctx),
854
- respondedAt: Date.now(),
855
- } satisfies ForwardedPermissionResponse);
856
- } catch (error) {
857
- logPermissionForwardingError(`Failed to write ${location.label} forwarded permission response '${responsePath}'`, error);
858
- continue;
859
- }
860
-
861
- safeDeleteFile(requestPath, `${location.label} forwarded permission request`);
845
+ const responsePath = join(location.responsesDir, `${request.id}.json`);
846
+ writeReviewLog(approved ? "forwarded_permission.approved" : "forwarded_permission.denied", {
847
+ requestId: request.id,
848
+ source: location.label,
849
+ requesterAgentName: request.requesterAgentName,
850
+ requesterSessionId: request.requesterSessionId,
851
+ targetSessionId: request.targetSessionId,
852
+ responsePath,
853
+ });
854
+ try {
855
+ writeJsonFileAtomic(responsePath, {
856
+ approved,
857
+ responderSessionId: currentSessionId,
858
+ respondedAt: Date.now(),
859
+ } satisfies ForwardedPermissionResponse);
860
+ } catch (error) {
861
+ logPermissionForwardingError(`Failed to write ${location.label} forwarded permission response '${responsePath}'`, error);
862
+ continue;
862
863
  }
864
+
865
+ safeDeleteFile(requestPath, `${location.label} forwarded permission request`);
863
866
  }
864
867
 
865
- cleanupLegacyPermissionForwardingDirectoryIfEmpty();
868
+ cleanupPermissionForwardingLocationIfEmpty(location);
866
869
  }
867
870
 
868
871
  async function confirmPermission(ctx: ExtensionContext, message: string): Promise<boolean> {
@@ -915,12 +918,39 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
915
918
  warning: result.warning ?? null,
916
919
  debugLog: result.config.debugLog,
917
920
  permissionReviewLog: result.config.permissionReviewLog,
921
+ yoloMode: result.config.yoloMode,
922
+ });
923
+ };
924
+
925
+ const saveExtensionConfig = (next: PermissionSystemExtensionConfig, ctx: ExtensionCommandContext): void => {
926
+ const normalized = normalizePermissionSystemConfig(next);
927
+ const saved = savePermissionSystemConfig(normalized);
928
+ if (!saved.success) {
929
+ if (saved.error) {
930
+ ctx.ui.notify(saved.error, "error");
931
+ }
932
+ return;
933
+ }
934
+
935
+ setExtensionConfig(normalized);
936
+ lastConfigWarning = null;
937
+
938
+ writeDebugLog("config.saved", {
939
+ debugLog: normalized.debugLog,
940
+ permissionReviewLog: normalized.permissionReviewLog,
941
+ yoloMode: normalized.yoloMode,
918
942
  });
919
943
  };
920
944
 
921
945
  setLoggingWarningReporter(notifyWarning);
922
946
  refreshExtensionConfig();
923
947
 
948
+ registerPermissionSystemCommand(pi, {
949
+ getConfig: () => extensionConfig,
950
+ setConfig: saveExtensionConfig,
951
+ getConfigPath: getPermissionSystemConfigPath,
952
+ });
953
+
924
954
  const createPermissionRequestId = (prefix: string): string => {
925
955
  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
926
956
  };
@@ -984,6 +1014,24 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
984
1014
  target?: string;
985
1015
  },
986
1016
  ): Promise<boolean> => {
1017
+ if (shouldAutoApprovePermissionState("ask", extensionConfig)) {
1018
+ reviewPermissionDecision("permission_request.auto_approved", details);
1019
+ emitPermissionRequestEvent({
1020
+ requestId: details.requestId,
1021
+ source: details.source,
1022
+ state: "approved",
1023
+ message: details.message,
1024
+ toolCallId: details.toolCallId,
1025
+ toolName: details.toolName,
1026
+ skillName: details.skillName,
1027
+ path: details.path,
1028
+ command: details.command,
1029
+ target: details.target,
1030
+ agentName: details.agentName,
1031
+ });
1032
+ return true;
1033
+ }
1034
+
987
1035
  reviewPermissionDecision("permission_request.waiting", details);
988
1036
  emitPermissionRequestEvent({
989
1037
  requestId: details.requestId,
@@ -0,0 +1,83 @@
1
+ export type PermissionDecisionState = "approved" | "denied" | "denied_with_reason";
2
+
3
+ export type PermissionPromptDecision = {
4
+ approved: boolean;
5
+ state: PermissionDecisionState;
6
+ denialReason?: string;
7
+ };
8
+
9
+ export interface PermissionDecisionUi {
10
+ select(title: string, options: string[]): Promise<string | undefined>;
11
+ input(title: string, placeholder?: string): Promise<string | undefined>;
12
+ }
13
+
14
+ const APPROVE_OPTION = "Yes";
15
+ const DENY_OPTION = "No";
16
+ const DENY_WITH_REASON_OPTION = "No, provide reason";
17
+ const PERMISSION_DECISION_OPTIONS = [
18
+ APPROVE_OPTION,
19
+ DENY_OPTION,
20
+ DENY_WITH_REASON_OPTION,
21
+ ] as const;
22
+
23
+ export function normalizePermissionDenialReason(value: unknown): string | undefined {
24
+ if (typeof value !== "string") {
25
+ return undefined;
26
+ }
27
+
28
+ const trimmed = value.trim();
29
+ return trimmed.length > 0 ? trimmed : undefined;
30
+ }
31
+
32
+ export function createDeniedPermissionDecision(
33
+ denialReason?: string,
34
+ ): PermissionPromptDecision {
35
+ const normalizedReason = normalizePermissionDenialReason(denialReason);
36
+ return normalizedReason
37
+ ? {
38
+ approved: false,
39
+ state: "denied_with_reason",
40
+ denialReason: normalizedReason,
41
+ }
42
+ : {
43
+ approved: false,
44
+ state: "denied",
45
+ };
46
+ }
47
+
48
+ export function isPermissionDecisionState(
49
+ value: unknown,
50
+ ): value is PermissionDecisionState {
51
+ return value === "approved" || value === "denied" || value === "denied_with_reason";
52
+ }
53
+
54
+ export async function requestPermissionDecisionFromUi(
55
+ ui: PermissionDecisionUi,
56
+ title: string,
57
+ message: string,
58
+ ): Promise<PermissionPromptDecision> {
59
+ const selected = await ui.select(
60
+ `${title}\n${message}`,
61
+ [...PERMISSION_DECISION_OPTIONS],
62
+ );
63
+
64
+ if (selected === APPROVE_OPTION) {
65
+ return {
66
+ approved: true,
67
+ state: "approved",
68
+ };
69
+ }
70
+
71
+ if (selected === DENY_WITH_REASON_OPTION) {
72
+ const denialReason = normalizePermissionDenialReason(
73
+ await ui.input(
74
+ `${title}\nShare why this request was denied (optional).`,
75
+ "Reason shown back to the agent",
76
+ ),
77
+ );
78
+
79
+ return createDeniedPermissionDecision(denialReason);
80
+ }
81
+
82
+ return createDeniedPermissionDecision();
83
+ }
@@ -0,0 +1,102 @@
1
+ import { join } from "node:path";
2
+
3
+ export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
4
+ export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
5
+ export const SUBAGENT_ENV_HINT_KEYS = ["PI_IS_SUBAGENT", "PI_SUBAGENT_SESSION_ID", "PI_AGENT_ROUTER_SUBAGENT"] as const;
6
+ export const SUBAGENT_PARENT_SESSION_ENV_KEY = "PI_AGENT_ROUTER_PARENT_SESSION_ID";
7
+
8
+ const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
9
+ const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
10
+ const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
11
+
12
+ export type ForwardedPermissionRequest = {
13
+ id: string;
14
+ createdAt: number;
15
+ requesterSessionId: string;
16
+ targetSessionId: string;
17
+ requesterAgentName: string;
18
+ message: string;
19
+ };
20
+
21
+ export type ForwardedPermissionResponse = {
22
+ approved: boolean;
23
+ responderSessionId: string;
24
+ respondedAt: number;
25
+ };
26
+
27
+ export type PermissionForwardingLocation = {
28
+ sessionId: string;
29
+ sessionRootDir: string;
30
+ requestsDir: string;
31
+ responsesDir: string;
32
+ label: "primary";
33
+ };
34
+
35
+ export function normalizePermissionForwardingSessionId(value: unknown): string | null {
36
+ if (typeof value !== "string") {
37
+ return null;
38
+ }
39
+
40
+ const trimmed = value.trim();
41
+ if (!trimmed || trimmed.toLowerCase() === "unknown") {
42
+ return null;
43
+ }
44
+
45
+ return trimmed;
46
+ }
47
+
48
+ function encodeSessionIdForPath(sessionId: string): string {
49
+ return encodeURIComponent(sessionId);
50
+ }
51
+
52
+ export function createPermissionForwardingLocation(
53
+ forwardingRootDir: string,
54
+ sessionId: string,
55
+ ): PermissionForwardingLocation {
56
+ const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
57
+ if (!normalizedSessionId) {
58
+ throw new Error("Permission forwarding session id must be a non-empty string.");
59
+ }
60
+
61
+ const sessionRootDir = join(
62
+ forwardingRootDir,
63
+ SESSION_FORWARDING_ROOT_DIRECTORY_NAME,
64
+ encodeSessionIdForPath(normalizedSessionId),
65
+ );
66
+
67
+ return {
68
+ sessionId: normalizedSessionId,
69
+ sessionRootDir,
70
+ requestsDir: join(sessionRootDir, SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME),
71
+ responsesDir: join(sessionRootDir, SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME),
72
+ label: "primary",
73
+ };
74
+ }
75
+
76
+ export function resolvePermissionForwardingTargetSessionId(options: {
77
+ hasUI: boolean;
78
+ isSubagent: boolean;
79
+ currentSessionId?: string | null;
80
+ env?: NodeJS.ProcessEnv;
81
+ }): string | null {
82
+ if (options.hasUI) {
83
+ return normalizePermissionForwardingSessionId(options.currentSessionId);
84
+ }
85
+
86
+ if (!options.isSubagent) {
87
+ return null;
88
+ }
89
+
90
+ return normalizePermissionForwardingSessionId(
91
+ options.env?.[SUBAGENT_PARENT_SESSION_ENV_KEY],
92
+ );
93
+ }
94
+
95
+ export function isForwardedPermissionRequestForSession(
96
+ request: Pick<ForwardedPermissionRequest, "targetSessionId">,
97
+ sessionId: string | null | undefined,
98
+ ): boolean {
99
+ const normalizedRequestSessionId = normalizePermissionForwardingSessionId(request.targetSessionId);
100
+ const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
101
+ return normalizedRequestSessionId !== null && normalizedRequestSessionId === normalizedSessionId;
102
+ }