pi-permission-system 0.2.0 → 0.2.2

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
@@ -4,6 +4,12 @@ import { homedir } from "node:os";
4
4
  import { dirname, join, normalize, resolve, sep } from "node:path";
5
5
 
6
6
  import { toRecord } from "./common.js";
7
+ import {
8
+ DEFAULT_EXTENSION_CONFIG,
9
+ loadPermissionSystemConfig,
10
+ type PermissionSystemExtensionConfig,
11
+ } from "./extension-config.js";
12
+ import { createPermissionSystemLogger } from "./logging.js";
7
13
  import { PermissionManager } from "./permission-manager.js";
8
14
  import { sanitizeAvailableToolsSection } from "./system-prompt-sanitizer.js";
9
15
  import { checkRequestedToolRegistration, getToolNameFromValue } from "./tool-registry.js";
@@ -21,8 +27,6 @@ const LEGACY_PERMISSION_FORWARDING_RESPONSES_DIR = join(LEGACY_PERMISSION_FORWAR
21
27
  const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
22
28
  const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
23
29
  const SUBAGENT_ENV_HINT_KEYS = ["PI_IS_SUBAGENT", "PI_SUBAGENT_SESSION_ID", "PI_AGENT_ROUTER_SUBAGENT"] as const;
24
- const ORCHESTRATOR_AGENT_NAME = "orchestrator";
25
- const DELEGATION_TOOL_NAME = "task";
26
30
 
27
31
  const AVAILABLE_SKILLS_OPEN_TAG = "<available_skills>";
28
32
  const AVAILABLE_SKILLS_CLOSE_TAG = "</available_skills>";
@@ -67,6 +71,66 @@ type PermissionForwardingLocation = {
67
71
  label: "primary" | "legacy";
68
72
  };
69
73
 
74
+ type PermissionRequestSource = "tool_call" | "skill_input" | "skill_read";
75
+ type PermissionRequestState = "waiting" | "approved" | "denied";
76
+
77
+ type PermissionRequestEvent = {
78
+ requestId: string;
79
+ source: PermissionRequestSource;
80
+ state: PermissionRequestState;
81
+ message: string;
82
+ toolCallId?: string;
83
+ toolName?: string;
84
+ skillName?: string;
85
+ path?: string;
86
+ command?: string;
87
+ target?: string;
88
+ agentName?: string | null;
89
+ };
90
+
91
+ const PERMISSION_REQUEST_EVENT_CHANNEL = "pi-permission-system:permission-request";
92
+
93
+ let extensionConfig: PermissionSystemExtensionConfig = { ...DEFAULT_EXTENSION_CONFIG };
94
+ const extensionLogger = createPermissionSystemLogger({
95
+ getConfig: () => extensionConfig,
96
+ });
97
+ const reportedLoggingWarnings = new Set<string>();
98
+ let loggingWarningReporter: ((message: string) => void) | null = null;
99
+
100
+ function setExtensionConfig(config: PermissionSystemExtensionConfig): void {
101
+ extensionConfig = {
102
+ debugLog: config.debugLog,
103
+ permissionReviewLog: config.permissionReviewLog,
104
+ };
105
+ }
106
+
107
+ function setLoggingWarningReporter(reporter: ((message: string) => void) | null): void {
108
+ loggingWarningReporter = reporter;
109
+ }
110
+
111
+ function reportLoggingWarning(message: string): void {
112
+ if (!loggingWarningReporter || reportedLoggingWarnings.has(message)) {
113
+ return;
114
+ }
115
+
116
+ reportedLoggingWarnings.add(message);
117
+ loggingWarningReporter(message);
118
+ }
119
+
120
+ function writeDebugLog(event: string, details: Record<string, unknown> = {}): void {
121
+ const warning = extensionLogger.debug(event, details);
122
+ if (warning) {
123
+ reportLoggingWarning(warning);
124
+ }
125
+ }
126
+
127
+ function writeReviewLog(event: string, details: Record<string, unknown> = {}): void {
128
+ const warning = extensionLogger.review(event, details);
129
+ if (warning) {
130
+ reportLoggingWarning(warning);
131
+ }
132
+ }
133
+
70
134
  function decodeXml(value: string): string {
71
135
  return value
72
136
  .replace(/&lt;/g, "<")
@@ -311,15 +375,6 @@ function getActiveAgentNameFromSystemPrompt(systemPrompt: string | undefined): s
311
375
  return normalizeAgentName(match[1]);
312
376
  }
313
377
 
314
- function isDelegationAllowedAgent(agentName: string | null): boolean {
315
- return Boolean(agentName && agentName.toLowerCase() === ORCHESTRATOR_AGENT_NAME);
316
- }
317
-
318
- function getDelegationBlockReason(agentName: string | null): string {
319
- const resolvedAgent = agentName ?? "none";
320
- return `Tool '${DELEGATION_TOOL_NAME}' is restricted to '${ORCHESTRATOR_AGENT_NAME}'. Active agent '${resolvedAgent}' cannot delegate.`;
321
- }
322
-
323
378
  function formatMissingToolNameReason(): string {
324
379
  return "Tool call was blocked because no tool name was provided. Use a registered tool name from pi.getAllTools().";
325
380
  }
@@ -331,7 +386,7 @@ function formatUnknownToolReason(toolName: string, availableToolNames: readonly
331
386
 
332
387
  const mcpHint = toolName === "mcp"
333
388
  ? ""
334
- : " If this was intended as an MCP server tool, call the built-in 'mcp' tool (for example: {\"tool\":\"server:tool\"}).";
389
+ : " If this was intended as an MCP server tool, call the registered 'mcp' tool when available (for example: {\"tool\":\"server:tool\"}).";
335
390
 
336
391
  return `Tool '${toolName}' is not registered in this runtime and was blocked before permission checks.${mcpHint} Registered tools: ${availableList}.`;
337
392
  }
@@ -410,6 +465,13 @@ function formatSkillPathDenyReason(skill: SkillPromptEntry, readPath: string, ag
410
465
  return `${subject} is not permitted to access skill '${skill.name}' via '${readPath}'.`;
411
466
  }
412
467
 
468
+ function getPermissionLogContext(result: PermissionCheckResult): { command?: string; target?: string } {
469
+ return {
470
+ command: result.command,
471
+ target: result.target,
472
+ };
473
+ }
474
+
413
475
  function sleep(ms: number): Promise<void> {
414
476
  return new Promise((resolve) => {
415
477
  setTimeout(resolve, ms);
@@ -467,21 +529,21 @@ function isErrnoCode(error: unknown, code: string): boolean {
467
529
  }
468
530
 
469
531
  function logPermissionForwardingWarning(message: string, error?: unknown): void {
470
- if (typeof error === "undefined") {
471
- console.warn(`[pi-permission-system] ${message}`);
472
- return;
473
- }
532
+ const details = typeof error === "undefined"
533
+ ? { message }
534
+ : { message, error: formatUnknownErrorMessage(error) };
474
535
 
475
- console.warn(`[pi-permission-system] ${message}: ${formatUnknownErrorMessage(error)}`);
536
+ writeReviewLog("permission_forwarding.warning", details);
537
+ writeDebugLog("permission_forwarding.warning", details);
476
538
  }
477
539
 
478
540
  function logPermissionForwardingError(message: string, error?: unknown): void {
479
- if (typeof error === "undefined") {
480
- console.error(`[pi-permission-system] ${message}`);
481
- return;
482
- }
541
+ const details = typeof error === "undefined"
542
+ ? { message }
543
+ : { message, error: formatUnknownErrorMessage(error) };
483
544
 
484
- console.error(`[pi-permission-system] ${message}: ${formatUnknownErrorMessage(error)}`);
545
+ writeReviewLog("permission_forwarding.error", details);
546
+ writeDebugLog("permission_forwarding.error", details);
485
547
  }
486
548
 
487
549
  function ensureDirectoryExists(path: string, description: string): boolean {
@@ -685,6 +747,14 @@ async function waitForForwardedPermissionApproval(ctx: ExtensionContext, message
685
747
  const requestPath = join(PERMISSION_FORWARDING_REQUESTS_DIR, `${requestId}.json`);
686
748
  const responsePath = join(PERMISSION_FORWARDING_RESPONSES_DIR, `${requestId}.json`);
687
749
 
750
+ writeReviewLog("forwarded_permission.request_created", {
751
+ requestId,
752
+ requesterAgentName,
753
+ requesterSessionId: request.requesterSessionId,
754
+ requestPath,
755
+ responsePath,
756
+ });
757
+
688
758
  try {
689
759
  writeJsonFileAtomic(requestPath, request);
690
760
  } catch (error) {
@@ -696,6 +766,12 @@ async function waitForForwardedPermissionApproval(ctx: ExtensionContext, message
696
766
  while (Date.now() < deadline) {
697
767
  if (existsSync(responsePath)) {
698
768
  const response = readForwardedPermissionResponse(responsePath);
769
+ writeReviewLog("forwarded_permission.response_received", {
770
+ requestId,
771
+ approved: response?.approved ?? null,
772
+ responderSessionId: response?.responderSessionId ?? null,
773
+ responsePath,
774
+ });
699
775
  safeDeleteFile(responsePath, "forwarded permission response");
700
776
  safeDeleteFile(requestPath, "forwarded permission request");
701
777
  return Boolean(response?.approved);
@@ -705,6 +781,11 @@ async function waitForForwardedPermissionApproval(ctx: ExtensionContext, message
705
781
  }
706
782
 
707
783
  logPermissionForwardingWarning(`Timed out waiting for forwarded permission response '${responsePath}'`);
784
+ writeReviewLog("forwarded_permission.response_timed_out", {
785
+ requestId,
786
+ requesterAgentName,
787
+ responsePath,
788
+ });
708
789
  safeDeleteFile(requestPath, "forwarded permission request");
709
790
  return false;
710
791
  }
@@ -738,6 +819,14 @@ async function processForwardedPermissionRequests(ctx: ExtensionContext): Promis
738
819
  continue;
739
820
  }
740
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
+ });
829
+
741
830
  let approved = false;
742
831
  try {
743
832
  approved = await ctx.ui.confirm("Permission Required (Subagent)", formatForwardedPermissionPrompt(request));
@@ -751,6 +840,13 @@ async function processForwardedPermissionRequests(ctx: ExtensionContext): Promis
751
840
  }
752
841
 
753
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
+ });
754
850
  try {
755
851
  writeJsonFileAtomic(responsePath, {
756
852
  approved,
@@ -788,6 +884,139 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
788
884
  let permissionForwardingContext: ExtensionContext | null = null;
789
885
  let permissionForwardingTimer: NodeJS.Timeout | null = null;
790
886
  let isProcessingForwardedRequests = false;
887
+ let runtimeContext: ExtensionContext | null = null;
888
+ let lastConfigWarning: string | null = null;
889
+
890
+ const notifyWarning = (message: string): void => {
891
+ if (!runtimeContext?.hasUI) {
892
+ return;
893
+ }
894
+
895
+ runtimeContext.ui.notify(message, "warning");
896
+ };
897
+
898
+ const refreshExtensionConfig = (ctx?: ExtensionContext): void => {
899
+ if (ctx) {
900
+ runtimeContext = ctx;
901
+ }
902
+
903
+ const result = loadPermissionSystemConfig();
904
+ setExtensionConfig(result.config);
905
+
906
+ if (result.warning && result.warning !== lastConfigWarning) {
907
+ lastConfigWarning = result.warning;
908
+ notifyWarning(result.warning);
909
+ } else if (!result.warning) {
910
+ lastConfigWarning = null;
911
+ }
912
+
913
+ writeDebugLog("config.loaded", {
914
+ created: result.created,
915
+ warning: result.warning ?? null,
916
+ debugLog: result.config.debugLog,
917
+ permissionReviewLog: result.config.permissionReviewLog,
918
+ });
919
+ };
920
+
921
+ setLoggingWarningReporter(notifyWarning);
922
+ refreshExtensionConfig();
923
+
924
+ const createPermissionRequestId = (prefix: string): string => {
925
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
926
+ };
927
+
928
+ const emitPermissionRequestEvent = (event: PermissionRequestEvent): void => {
929
+ try {
930
+ pi.events.emit(PERMISSION_REQUEST_EVENT_CHANNEL, event);
931
+ } catch (error) {
932
+ writeDebugLog("permission_request.event_emit_failed", {
933
+ requestId: event.requestId,
934
+ source: event.source,
935
+ state: event.state,
936
+ error: formatUnknownErrorMessage(error),
937
+ });
938
+ }
939
+ };
940
+
941
+ const reviewPermissionDecision = (
942
+ event: string,
943
+ details: {
944
+ requestId: string;
945
+ source: PermissionRequestSource;
946
+ agentName: string | null;
947
+ message: string;
948
+ toolCallId?: string;
949
+ toolName?: string;
950
+ skillName?: string;
951
+ path?: string;
952
+ command?: string;
953
+ target?: string;
954
+ resolution?: string;
955
+ },
956
+ ): void => {
957
+ writeReviewLog(event, {
958
+ requestId: details.requestId,
959
+ source: details.source,
960
+ agentName: details.agentName,
961
+ message: details.message,
962
+ toolCallId: details.toolCallId ?? null,
963
+ toolName: details.toolName ?? null,
964
+ skillName: details.skillName ?? null,
965
+ path: details.path ?? null,
966
+ command: details.command ?? null,
967
+ target: details.target ?? null,
968
+ resolution: details.resolution ?? null,
969
+ });
970
+ };
971
+
972
+ const promptPermission = async (
973
+ ctx: ExtensionContext,
974
+ details: {
975
+ requestId: string;
976
+ source: PermissionRequestSource;
977
+ agentName: string | null;
978
+ message: string;
979
+ toolCallId?: string;
980
+ toolName?: string;
981
+ skillName?: string;
982
+ path?: string;
983
+ command?: string;
984
+ target?: string;
985
+ },
986
+ ): Promise<boolean> => {
987
+ reviewPermissionDecision("permission_request.waiting", details);
988
+ emitPermissionRequestEvent({
989
+ requestId: details.requestId,
990
+ source: details.source,
991
+ state: "waiting",
992
+ message: details.message,
993
+ toolCallId: details.toolCallId,
994
+ toolName: details.toolName,
995
+ skillName: details.skillName,
996
+ path: details.path,
997
+ command: details.command,
998
+ target: details.target,
999
+ agentName: details.agentName,
1000
+ });
1001
+
1002
+ const approved = await confirmPermission(ctx, details.message);
1003
+ reviewPermissionDecision(approved ? "permission_request.approved" : "permission_request.denied", details);
1004
+ emitPermissionRequestEvent({
1005
+ requestId: details.requestId,
1006
+ source: details.source,
1007
+ state: approved ? "approved" : "denied",
1008
+ message: details.message,
1009
+ toolCallId: details.toolCallId,
1010
+ toolName: details.toolName,
1011
+ skillName: details.skillName,
1012
+ path: details.path,
1013
+ command: details.command,
1014
+ target: details.target,
1015
+ agentName: details.agentName,
1016
+ });
1017
+
1018
+ return approved;
1019
+ };
791
1020
 
792
1021
  const stopForwardedPermissionPolling = (): void => {
793
1022
  if (permissionForwardingTimer) {
@@ -840,10 +1069,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
840
1069
  };
841
1070
 
842
1071
  const shouldExposeTool = (toolName: string, agentName: string | null): boolean => {
843
- if (toolName === DELEGATION_TOOL_NAME && !isDelegationAllowedAgent(agentName)) {
844
- return false;
845
- }
846
-
847
1072
  // Use tool-level permission check for tool injection decisions
848
1073
  // This ensures that agent-specific tool deny rules (e.g., bash: deny) are respected
849
1074
  // before any command-level permissions are considered
@@ -852,6 +1077,8 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
852
1077
  };
853
1078
 
854
1079
  pi.on("session_start", async (_event, ctx) => {
1080
+ runtimeContext = ctx;
1081
+ refreshExtensionConfig(ctx);
855
1082
  permissionManager = new PermissionManager();
856
1083
  activeSkillEntries = [];
857
1084
  lastKnownActiveAgentName = getActiveAgentName(ctx);
@@ -859,16 +1086,21 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
859
1086
  });
860
1087
 
861
1088
  pi.on("session_switch", async (_event, ctx) => {
1089
+ runtimeContext = ctx;
1090
+ refreshExtensionConfig(ctx);
862
1091
  activeSkillEntries = [];
863
1092
  lastKnownActiveAgentName = getActiveAgentName(ctx);
864
1093
  startForwardedPermissionPolling(ctx);
865
1094
  });
866
1095
 
867
1096
  pi.on("session_shutdown", async () => {
1097
+ runtimeContext = null;
868
1098
  stopForwardedPermissionPolling();
869
1099
  });
870
1100
 
871
1101
  pi.on("before_agent_start", async (event, ctx) => {
1102
+ runtimeContext = ctx;
1103
+ refreshExtensionConfig(ctx);
872
1104
  startForwardedPermissionPolling(ctx);
873
1105
  const agentName = resolveAgentName(ctx, event.systemPrompt);
874
1106
  const allTools = pi.getAllTools();
@@ -899,6 +1131,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
899
1131
  });
900
1132
 
901
1133
  pi.on("input", async (event, ctx) => {
1134
+ runtimeContext = ctx;
902
1135
  startForwardedPermissionPolling(ctx);
903
1136
  const skillName = extractSkillNameFromInput(event.text);
904
1137
  if (!skillName) {
@@ -911,6 +1144,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
911
1144
  if (ctx.hasUI) {
912
1145
  ctx.ui.notify(`Skill '${skillName}' is blocked because active agent context is unavailable.`, "warning");
913
1146
  }
1147
+ writeReviewLog("permission_request.blocked", {
1148
+ source: "skill_input",
1149
+ skillName,
1150
+ agentName: null,
1151
+ resolution: "missing_agent_context",
1152
+ });
914
1153
  return { action: "handled" };
915
1154
  }
916
1155
 
@@ -921,15 +1160,35 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
921
1160
  const resolvedAgent = agentName ?? "none";
922
1161
  ctx.ui.notify(`Skill '${skillName}' is not permitted for agent '${resolvedAgent}'.`, "warning");
923
1162
  }
1163
+ writeReviewLog("permission_request.blocked", {
1164
+ source: "skill_input",
1165
+ skillName,
1166
+ agentName,
1167
+ resolution: "policy_denied",
1168
+ });
924
1169
  return { action: "handled" };
925
1170
  }
926
1171
 
927
1172
  if (check.state === "ask") {
1173
+ const message = formatSkillAskPrompt(skillName, agentName ?? undefined);
928
1174
  if (!canRequestPermissionConfirmation(ctx)) {
1175
+ writeReviewLog("permission_request.blocked", {
1176
+ source: "skill_input",
1177
+ skillName,
1178
+ agentName,
1179
+ message,
1180
+ resolution: "confirmation_unavailable",
1181
+ });
929
1182
  return { action: "handled" };
930
1183
  }
931
1184
 
932
- const approved = await confirmPermission(ctx, formatSkillAskPrompt(skillName, agentName ?? undefined));
1185
+ const approved = await promptPermission(ctx, {
1186
+ requestId: createPermissionRequestId("skill-input"),
1187
+ source: "skill_input",
1188
+ agentName,
1189
+ message,
1190
+ skillName,
1191
+ });
933
1192
  if (!approved) {
934
1193
  return { action: "handled" };
935
1194
  }
@@ -939,6 +1198,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
939
1198
  });
940
1199
 
941
1200
  pi.on("tool_call", async (event, ctx) => {
1201
+ runtimeContext = ctx;
942
1202
  startForwardedPermissionPolling(ctx);
943
1203
  const agentName = resolveAgentName(ctx);
944
1204
  const toolName = getEventToolName(event);
@@ -959,16 +1219,19 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
959
1219
  };
960
1220
  }
961
1221
 
962
- if (toolName === DELEGATION_TOOL_NAME && !isDelegationAllowedAgent(agentName)) {
963
- return { block: true, reason: getDelegationBlockReason(agentName) };
964
- }
965
-
966
1222
  if (isToolCallEventType("read", event) && activeSkillEntries.length > 0) {
967
1223
  const normalizedReadPath = normalizePathForComparison(event.input.path, ctx.cwd);
968
1224
  const matchedSkill = findSkillPathMatch(normalizedReadPath, activeSkillEntries);
969
1225
 
970
1226
  if (matchedSkill) {
971
1227
  if (matchedSkill.state === "deny") {
1228
+ writeReviewLog("permission_request.blocked", {
1229
+ source: "skill_read",
1230
+ skillName: matchedSkill.name,
1231
+ agentName,
1232
+ path: event.input.path,
1233
+ resolution: "policy_denied",
1234
+ });
972
1235
  return {
973
1236
  block: true,
974
1237
  reason: formatSkillPathDenyReason(matchedSkill, event.input.path, agentName ?? undefined),
@@ -976,17 +1239,32 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
976
1239
  }
977
1240
 
978
1241
  if (matchedSkill.state === "ask") {
1242
+ const message = formatSkillPathAskPrompt(matchedSkill, event.input.path, agentName ?? undefined);
979
1243
  if (!canRequestPermissionConfirmation(ctx)) {
1244
+ writeReviewLog("permission_request.blocked", {
1245
+ source: "skill_read",
1246
+ skillName: matchedSkill.name,
1247
+ agentName,
1248
+ path: event.input.path,
1249
+ message,
1250
+ resolution: "confirmation_unavailable",
1251
+ });
980
1252
  return {
981
1253
  block: true,
982
1254
  reason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
983
1255
  };
984
1256
  }
985
1257
 
986
- const approved = await confirmPermission(
987
- ctx,
988
- formatSkillPathAskPrompt(matchedSkill, event.input.path, agentName ?? undefined),
989
- );
1258
+ const approved = await promptPermission(ctx, {
1259
+ requestId: event.toolCallId,
1260
+ source: "skill_read",
1261
+ agentName,
1262
+ message,
1263
+ toolCallId: event.toolCallId,
1264
+ toolName: toolName,
1265
+ skillName: matchedSkill.name,
1266
+ path: event.input.path,
1267
+ });
990
1268
  if (!approved) {
991
1269
  return { block: true, reason: `User denied access to skill '${matchedSkill.name}'.` };
992
1270
  }
@@ -996,8 +1274,17 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
996
1274
 
997
1275
  const input = getEventInput(event);
998
1276
  const check = permissionManager.checkPermission(toolName, input, agentName ?? undefined);
1277
+ const permissionLogContext = getPermissionLogContext(check);
999
1278
 
1000
1279
  if (check.state === "deny") {
1280
+ writeReviewLog("permission_request.blocked", {
1281
+ source: "tool_call",
1282
+ toolCallId: event.toolCallId,
1283
+ toolName,
1284
+ agentName,
1285
+ ...permissionLogContext,
1286
+ resolution: "policy_denied",
1287
+ });
1001
1288
  return { block: true, reason: formatDenyReason(check, agentName ?? undefined) };
1002
1289
  }
1003
1290
 
@@ -1008,14 +1295,32 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1008
1295
  ? "Using tool 'mcp' requires approval, but no interactive UI is available."
1009
1296
  : `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
1010
1297
 
1298
+ const message = formatAskPrompt(check, agentName ?? undefined);
1011
1299
  if (!canRequestPermissionConfirmation(ctx)) {
1300
+ writeReviewLog("permission_request.blocked", {
1301
+ source: "tool_call",
1302
+ toolCallId: event.toolCallId,
1303
+ toolName,
1304
+ agentName,
1305
+ message,
1306
+ ...permissionLogContext,
1307
+ resolution: "confirmation_unavailable",
1308
+ });
1012
1309
  return {
1013
1310
  block: true,
1014
1311
  reason: unavailableReason,
1015
1312
  };
1016
1313
  }
1017
1314
 
1018
- const approved = await confirmPermission(ctx, formatAskPrompt(check, agentName ?? undefined));
1315
+ const approved = await promptPermission(ctx, {
1316
+ requestId: event.toolCallId,
1317
+ source: "tool_call",
1318
+ agentName,
1319
+ message,
1320
+ toolCallId: event.toolCallId,
1321
+ toolName,
1322
+ ...permissionLogContext,
1323
+ });
1019
1324
  if (!approved) {
1020
1325
  return { block: true, reason: formatUserDeniedReason(check) };
1021
1326
  }
package/src/logging.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { appendFileSync } from "node:fs";
2
+
3
+ import {
4
+ DEBUG_LOG_PATH,
5
+ EXTENSION_ID,
6
+ LOGS_DIR,
7
+ PERMISSION_REVIEW_LOG_PATH,
8
+ ensurePermissionSystemLogsDirectory,
9
+ type PermissionSystemExtensionConfig,
10
+ } from "./extension-config.js";
11
+
12
+ function safeJsonStringify(value: unknown): string {
13
+ const seen = new WeakSet<object>();
14
+ return JSON.stringify(value, (_key, currentValue) => {
15
+ if (currentValue instanceof Error) {
16
+ return {
17
+ name: currentValue.name,
18
+ message: currentValue.message,
19
+ stack: currentValue.stack,
20
+ };
21
+ }
22
+
23
+ if (typeof currentValue === "bigint") {
24
+ return currentValue.toString();
25
+ }
26
+
27
+ if (typeof currentValue === "object" && currentValue !== null) {
28
+ if (seen.has(currentValue)) {
29
+ return "[Circular]";
30
+ }
31
+ seen.add(currentValue);
32
+ }
33
+
34
+ return currentValue;
35
+ });
36
+ }
37
+
38
+ export interface PermissionSystemLogger {
39
+ debug: (event: string, details?: Record<string, unknown>) => string | undefined;
40
+ review: (event: string, details?: Record<string, unknown>) => string | undefined;
41
+ }
42
+
43
+ interface PermissionSystemLoggerOptions {
44
+ getConfig: () => PermissionSystemExtensionConfig;
45
+ debugLogPath?: string;
46
+ reviewLogPath?: string;
47
+ ensureLogsDirectory?: () => string | undefined;
48
+ }
49
+
50
+ export function createPermissionSystemLogger(options: PermissionSystemLoggerOptions): PermissionSystemLogger {
51
+ const debugLogPath = options.debugLogPath ?? DEBUG_LOG_PATH;
52
+ const reviewLogPath = options.reviewLogPath ?? PERMISSION_REVIEW_LOG_PATH;
53
+ const ensureLogsDirectory = options.ensureLogsDirectory ?? (() => ensurePermissionSystemLogsDirectory(LOGS_DIR));
54
+
55
+ const writeLine = (stream: "debug" | "review", path: string, event: string, details: Record<string, unknown>): string | undefined => {
56
+ const directoryError = ensureLogsDirectory();
57
+ if (directoryError) {
58
+ return directoryError;
59
+ }
60
+
61
+ try {
62
+ const line = safeJsonStringify({
63
+ timestamp: new Date().toISOString(),
64
+ extension: EXTENSION_ID,
65
+ stream,
66
+ event,
67
+ ...details,
68
+ });
69
+ appendFileSync(path, `${line}\n`, "utf-8");
70
+ return undefined;
71
+ } catch (error) {
72
+ const message = error instanceof Error ? error.message : String(error);
73
+ return `Failed to write permission-system ${stream} log '${path}': ${message}`;
74
+ }
75
+ };
76
+
77
+ const debug = (event: string, details: Record<string, unknown> = {}): string | undefined => {
78
+ if (!options.getConfig().debugLog) {
79
+ return undefined;
80
+ }
81
+
82
+ return writeLine("debug", debugLogPath, event, details);
83
+ };
84
+
85
+ const review = (event: string, details: Record<string, unknown> = {}): string | undefined => {
86
+ if (!options.getConfig().permissionReviewLog) {
87
+ return undefined;
88
+ }
89
+
90
+ return writeLine("review", reviewLogPath, event, details);
91
+ };
92
+
93
+ return { debug, review };
94
+ }