@komarspn/pi-permission-system 16.0.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.
Files changed (203) hide show
  1. package/CHANGELOG.md +2234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +158 -0
  4. package/config/config.example.json +39 -0
  5. package/package.json +82 -0
  6. package/schemas/permissions.schema.json +158 -0
  7. package/src/active-agent.ts +72 -0
  8. package/src/async-cache.ts +21 -0
  9. package/src/bash-arity.ts +210 -0
  10. package/src/builtin-tool-input-formatters.ts +82 -0
  11. package/src/canonicalize-path.ts +30 -0
  12. package/src/common.ts +121 -0
  13. package/src/config-loader.ts +432 -0
  14. package/src/config-modal.ts +259 -0
  15. package/src/config-paths.ts +47 -0
  16. package/src/config-reporter.ts +34 -0
  17. package/src/config-store.ts +222 -0
  18. package/src/decision-audit.ts +75 -0
  19. package/src/decision-reporter.ts +41 -0
  20. package/src/denial-messages.ts +232 -0
  21. package/src/expand-home.ts +28 -0
  22. package/src/extension-config.ts +79 -0
  23. package/src/extension-paths.ts +66 -0
  24. package/src/forwarded-permissions/io.ts +404 -0
  25. package/src/forwarded-permissions/permission-forwarder.ts +580 -0
  26. package/src/forwarding-manager.ts +74 -0
  27. package/src/gate-prompter.ts +12 -0
  28. package/src/handlers/before-agent-start.ts +94 -0
  29. package/src/handlers/gates/bash-command.ts +75 -0
  30. package/src/handlers/gates/bash-external-directory.ts +127 -0
  31. package/src/handlers/gates/bash-path-extractor.ts +15 -0
  32. package/src/handlers/gates/bash-path.ts +152 -0
  33. package/src/handlers/gates/bash-program.ts +1143 -0
  34. package/src/handlers/gates/bash-token-classification.ts +105 -0
  35. package/src/handlers/gates/candidate-check.ts +32 -0
  36. package/src/handlers/gates/descriptor.ts +81 -0
  37. package/src/handlers/gates/external-directory-messages.ts +20 -0
  38. package/src/handlers/gates/external-directory.ts +133 -0
  39. package/src/handlers/gates/helpers.ts +76 -0
  40. package/src/handlers/gates/path.ts +91 -0
  41. package/src/handlers/gates/runner.ts +186 -0
  42. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  43. package/src/handlers/gates/skill-input.ts +46 -0
  44. package/src/handlers/gates/skill-read.ts +87 -0
  45. package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
  46. package/src/handlers/gates/tool.ts +102 -0
  47. package/src/handlers/gates/types.ts +13 -0
  48. package/src/handlers/index.ts +3 -0
  49. package/src/handlers/lifecycle.ts +95 -0
  50. package/src/handlers/permission-gate-handler.ts +190 -0
  51. package/src/handlers/tool-call-boundary.ts +91 -0
  52. package/src/index.ts +225 -0
  53. package/src/input-normalizer.ts +157 -0
  54. package/src/logging.ts +113 -0
  55. package/src/mcp-targets.ts +170 -0
  56. package/src/node-modules-discovery.ts +76 -0
  57. package/src/normalize.ts +43 -0
  58. package/src/path-utils.ts +355 -0
  59. package/src/pattern-suggest.ts +132 -0
  60. package/src/permission-dialog.ts +138 -0
  61. package/src/permission-event-rpc.ts +223 -0
  62. package/src/permission-events.ts +266 -0
  63. package/src/permission-forwarding.ts +188 -0
  64. package/src/permission-gate.ts +94 -0
  65. package/src/permission-manager.ts +392 -0
  66. package/src/permission-merge.ts +32 -0
  67. package/src/permission-prompter.ts +142 -0
  68. package/src/permission-prompts.ts +93 -0
  69. package/src/permission-resolver.ts +109 -0
  70. package/src/permission-session.ts +189 -0
  71. package/src/permission-ui-prompt.ts +127 -0
  72. package/src/permissions-service.ts +63 -0
  73. package/src/persistent-approval-recorder.ts +139 -0
  74. package/src/policy-loader.ts +350 -0
  75. package/src/prompting-gateway.ts +104 -0
  76. package/src/rule.ts +188 -0
  77. package/src/scope-merge.ts +72 -0
  78. package/src/service-lifecycle.ts +49 -0
  79. package/src/service.ts +163 -0
  80. package/src/session-approval-recorder.ts +6 -0
  81. package/src/session-approval.ts +43 -0
  82. package/src/session-logger.ts +91 -0
  83. package/src/session-rules.ts +79 -0
  84. package/src/skill-prompt-sanitizer.ts +292 -0
  85. package/src/status.ts +35 -0
  86. package/src/subagent-context.ts +104 -0
  87. package/src/subagent-lifecycle-events.ts +72 -0
  88. package/src/subagent-registry.ts +105 -0
  89. package/src/synthesize.ts +92 -0
  90. package/src/system-prompt-sanitizer.ts +274 -0
  91. package/src/tool-access-extractor-registry.ts +68 -0
  92. package/src/tool-input-formatter-registry.ts +67 -0
  93. package/src/tool-input-preview.ts +34 -0
  94. package/src/tool-input-prompt-formatters.ts +63 -0
  95. package/src/tool-preview-formatter.ts +207 -0
  96. package/src/tool-registry.ts +148 -0
  97. package/src/types.ts +64 -0
  98. package/src/wildcard-matcher.ts +120 -0
  99. package/src/yolo-mode.ts +30 -0
  100. package/test/active-agent.test.ts +155 -0
  101. package/test/async-cache.test.ts +48 -0
  102. package/test/bash-arity.test.ts +144 -0
  103. package/test/bash-external-directory.test.ts +956 -0
  104. package/test/builtin-tool-input-formatters.test.ts +109 -0
  105. package/test/canonicalize-path.test.ts +93 -0
  106. package/test/common.test.ts +287 -0
  107. package/test/composition-root.test.ts +603 -0
  108. package/test/config-loader.test.ts +740 -0
  109. package/test/config-modal.test.ts +320 -0
  110. package/test/config-paths.test.ts +83 -0
  111. package/test/config-pipeline.test.ts +90 -0
  112. package/test/config-reporter.test.ts +147 -0
  113. package/test/config-store.test.ts +466 -0
  114. package/test/decision-audit.test.ts +72 -0
  115. package/test/decision-reporter.test.ts +112 -0
  116. package/test/denial-messages.test.ts +656 -0
  117. package/test/detect-permissive-bash-fallback.test.ts +56 -0
  118. package/test/expand-home.test.ts +93 -0
  119. package/test/extension-config.test.ts +129 -0
  120. package/test/extension-paths.test.ts +108 -0
  121. package/test/forwarded-permissions/io.test.ts +251 -0
  122. package/test/forwarding-manager.test.ts +194 -0
  123. package/test/handlers/before-agent-start.test.ts +317 -0
  124. package/test/handlers/external-directory-integration.test.ts +623 -0
  125. package/test/handlers/external-directory-session-dedup.test.ts +430 -0
  126. package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
  127. package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
  128. package/test/handlers/gates/bash-command.test.ts +191 -0
  129. package/test/handlers/gates/bash-external-directory.test.ts +269 -0
  130. package/test/handlers/gates/bash-path.test.ts +337 -0
  131. package/test/handlers/gates/bash-program.test.ts +410 -0
  132. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  133. package/test/handlers/gates/candidate-check.test.ts +52 -0
  134. package/test/handlers/gates/external-directory-messages.test.ts +61 -0
  135. package/test/handlers/gates/external-directory.test.ts +259 -0
  136. package/test/handlers/gates/helpers.test.ts +177 -0
  137. package/test/handlers/gates/path.test.ts +294 -0
  138. package/test/handlers/gates/runner.test.ts +447 -0
  139. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  140. package/test/handlers/gates/skill-input.test.ts +131 -0
  141. package/test/handlers/gates/skill-read.test.ts +158 -0
  142. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
  143. package/test/handlers/gates/tool.test.ts +223 -0
  144. package/test/handlers/input-events.test.ts +168 -0
  145. package/test/handlers/input.test.ts +199 -0
  146. package/test/handlers/lifecycle.test.ts +221 -0
  147. package/test/handlers/tool-call-boundary.test.ts +145 -0
  148. package/test/handlers/tool-call-events.test.ts +277 -0
  149. package/test/handlers/tool-call.test.ts +395 -0
  150. package/test/handlers/validate-requested-tool.test.ts +92 -0
  151. package/test/helpers/gate-fixtures.ts +323 -0
  152. package/test/helpers/handler-fixtures.ts +335 -0
  153. package/test/helpers/make-fake-pi.ts +100 -0
  154. package/test/helpers/manager-harness.ts +112 -0
  155. package/test/helpers/session-fixtures.ts +204 -0
  156. package/test/input-normalizer.test.ts +367 -0
  157. package/test/logging.test.ts +51 -0
  158. package/test/mcp-targets.test.ts +233 -0
  159. package/test/node-modules-discovery.test.ts +97 -0
  160. package/test/normalize.test.ts +247 -0
  161. package/test/path-utils.test.ts +650 -0
  162. package/test/pattern-suggest.test.ts +248 -0
  163. package/test/permission-dialog.test.ts +241 -0
  164. package/test/permission-event-rpc.test.ts +541 -0
  165. package/test/permission-events.test.ts +402 -0
  166. package/test/permission-forwarder.test.ts +369 -0
  167. package/test/permission-forwarding.test.ts +315 -0
  168. package/test/permission-gate.test.ts +305 -0
  169. package/test/permission-manager-unified.test.ts +3368 -0
  170. package/test/permission-merge.test.ts +61 -0
  171. package/test/permission-prompter.test.ts +518 -0
  172. package/test/permission-prompts.test.ts +363 -0
  173. package/test/permission-resolver.test.ts +265 -0
  174. package/test/permission-session.test.ts +363 -0
  175. package/test/permission-ui-prompt.test.ts +146 -0
  176. package/test/permissions-service.test.ts +177 -0
  177. package/test/persistent-approval-recorder.test.ts +133 -0
  178. package/test/pi-infrastructure-read.test.ts +369 -0
  179. package/test/policy-loader.test.ts +561 -0
  180. package/test/prompting-gateway.test.ts +230 -0
  181. package/test/rule.test.ts +604 -0
  182. package/test/scope-merge.test.ts +116 -0
  183. package/test/service-lifecycle.test.ts +163 -0
  184. package/test/service.test.ts +308 -0
  185. package/test/session-approval.test.ts +75 -0
  186. package/test/session-logger.test.ts +200 -0
  187. package/test/session-rules.test.ts +304 -0
  188. package/test/session-start.test.ts +112 -0
  189. package/test/skill-prompt-sanitizer.test.ts +374 -0
  190. package/test/status.test.ts +10 -0
  191. package/test/subagent-context.test.ts +326 -0
  192. package/test/subagent-lifecycle-events.test.ts +132 -0
  193. package/test/subagent-registry.test.ts +145 -0
  194. package/test/synthesize.test.ts +300 -0
  195. package/test/system-prompt-sanitizer.test.ts +382 -0
  196. package/test/tool-access-extractor-registry.test.ts +77 -0
  197. package/test/tool-input-formatter-registry.test.ts +75 -0
  198. package/test/tool-input-preview.test.ts +129 -0
  199. package/test/tool-input-prompt-formatters.test.ts +115 -0
  200. package/test/tool-preview-formatter.test.ts +458 -0
  201. package/test/tool-registry.test.ts +197 -0
  202. package/test/wildcard-matcher.test.ts +424 -0
  203. package/test/yolo-mode.test.ts +188 -0
@@ -0,0 +1,79 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import type { UnifiedPermissionConfig } from "./config-loader";
6
+
7
+ export const EXTENSION_ID = "pi-permission-system";
8
+
9
+ export interface PermissionSystemExtensionConfig {
10
+ debugLog: boolean;
11
+ permissionReviewLog: boolean;
12
+ yoloMode: boolean;
13
+ /** Additional directories to auto-allow for reads as Pi infrastructure. */
14
+ piInfrastructureReadPaths?: string[];
15
+ /** Max length of the inline-JSON input preview shown in permission prompts. Defaults to 200. */
16
+ toolInputPreviewMaxLength?: number;
17
+ /** Max length of inline pattern/path summaries (grep/find/ls) in permission prompts. Defaults to 80. */
18
+ toolTextSummaryMaxLength?: number;
19
+ }
20
+
21
+ export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
22
+ debugLog: false,
23
+ permissionReviewLog: true,
24
+ yoloMode: false,
25
+ };
26
+
27
+ function resolveExtensionRoot(moduleUrl = import.meta.url): string {
28
+ return join(dirname(fileURLToPath(moduleUrl)), "..");
29
+ }
30
+
31
+ export const EXTENSION_ROOT = resolveExtensionRoot();
32
+
33
+ const PERMISSION_POLICY_KEYS: ReadonlySet<string> = new Set([
34
+ "defaultPolicy",
35
+ "tools",
36
+ "bash",
37
+ "mcp",
38
+ "skills",
39
+ "special",
40
+ "external_directory",
41
+ ]);
42
+
43
+ export function detectMisplacedPermissionKeys(
44
+ raw: Record<string, unknown>,
45
+ ): string[] {
46
+ return Object.keys(raw).filter((key) => PERMISSION_POLICY_KEYS.has(key));
47
+ }
48
+
49
+ export function normalizePermissionSystemConfig(
50
+ raw: UnifiedPermissionConfig,
51
+ ): PermissionSystemExtensionConfig {
52
+ const result: PermissionSystemExtensionConfig = {
53
+ debugLog: raw.debugLog === true,
54
+ permissionReviewLog: raw.permissionReviewLog !== false,
55
+ yoloMode: raw.yoloMode === true,
56
+ };
57
+ if (raw.piInfrastructureReadPaths !== undefined) {
58
+ result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
59
+ }
60
+ if (raw.toolInputPreviewMaxLength !== undefined) {
61
+ result.toolInputPreviewMaxLength = raw.toolInputPreviewMaxLength;
62
+ }
63
+ if (raw.toolTextSummaryMaxLength !== undefined) {
64
+ result.toolTextSummaryMaxLength = raw.toolTextSummaryMaxLength;
65
+ }
66
+ return result;
67
+ }
68
+
69
+ export function ensurePermissionSystemLogsDirectory(
70
+ logsDir: string,
71
+ ): string | undefined {
72
+ try {
73
+ mkdirSync(logsDir, { recursive: true });
74
+ return undefined;
75
+ } catch (error) {
76
+ const message = error instanceof Error ? error.message : String(error);
77
+ return `Failed to create permission-system log directory '${logsDir}': ${message}`;
78
+ }
79
+ }
@@ -0,0 +1,66 @@
1
+ import { join } from "node:path";
2
+ import { getGlobalLogsDir } from "./config-paths";
3
+ import { discoverGlobalNodeModulesRoot } from "./node-modules-discovery";
4
+
5
+ /**
6
+ * Immutable path constants derived from `agentDir` at construction time.
7
+ *
8
+ * Computed once at startup in `computeExtensionPaths()` and embedded into
9
+ * `ExtensionRuntime`. Later refactorings (#129 PermissionSession, #130
10
+ * handler classes) consume this as a single dep instead of individual fields.
11
+ */
12
+ export interface ExtensionPaths {
13
+ readonly agentDir: string;
14
+ readonly sessionsDir: string;
15
+ readonly subagentSessionsDir: string;
16
+ readonly forwardingDir: string;
17
+ readonly globalLogsDir: string;
18
+ /**
19
+ * Static Pi infrastructure directories used for external-directory
20
+ * read auto-allow. Computed once from `agentDir`,
21
+ * `discoverGlobalNodeModulesRoot()`, and (when provided) Pi's own
22
+ * install directory (`getPackageDir()`). Config-based extras
23
+ * (`piInfrastructureReadPaths`) are read from `runtime.config` at
24
+ * call time in the handler so they pick up config reloads.
25
+ */
26
+ readonly piInfrastructureDirs: readonly string[];
27
+ }
28
+
29
+ /**
30
+ * Compute all immutable path constants from `agentDir`.
31
+ *
32
+ * Calls `discoverGlobalNodeModulesRoot()` internally so the result is
33
+ * self-contained. Call this once at extension startup, not at module scope.
34
+ *
35
+ * `piPackageDir` is Pi's own install directory (from the coding-agent
36
+ * `getPackageDir()` API, resolved at the composition root). When provided it is
37
+ * auto-allowed for read-only tools so the agent can read Pi's bundled docs and
38
+ * examples regardless of install layout. It is strictly narrower than the
39
+ * discovered global `node_modules` root already included here.
40
+ */
41
+ export function computeExtensionPaths(
42
+ agentDir: string,
43
+ piPackageDir?: string,
44
+ ): ExtensionPaths {
45
+ const sessionsDir = join(agentDir, "sessions");
46
+ const subagentSessionsDir = join(agentDir, "subagent-sessions");
47
+ const forwardingDir = join(sessionsDir, "permission-forwarding");
48
+ const globalLogsDir = getGlobalLogsDir(agentDir);
49
+
50
+ const globalNodeModulesRoot = discoverGlobalNodeModulesRoot();
51
+ const piInfrastructureDirs: string[] = [
52
+ agentDir,
53
+ join(agentDir, "git"),
54
+ ...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
55
+ ...(piPackageDir ? [piPackageDir] : []),
56
+ ];
57
+
58
+ return {
59
+ agentDir,
60
+ sessionsDir,
61
+ subagentSessionsDir,
62
+ forwardingDir,
63
+ globalLogsDir,
64
+ piInfrastructureDirs,
65
+ };
66
+ }
@@ -0,0 +1,404 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmdirSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+
12
+ import { isPermissionDecisionState } from "#src/permission-dialog";
13
+ import type { PermissionUiPromptSource } from "#src/permission-events";
14
+ import {
15
+ createPermissionForwardingLocation,
16
+ type ForwardedPermissionRequest,
17
+ type ForwardedPermissionResponse,
18
+ type PermissionForwardingLocation,
19
+ } from "#src/permission-forwarding";
20
+ import type { DebugReviewLogger } from "#src/session-logger";
21
+
22
+ /** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
23
+ const UI_PROMPT_SOURCES = [
24
+ "tool_call",
25
+ "skill_input",
26
+ "skill_read",
27
+ "rpc_prompt",
28
+ ] as const satisfies readonly PermissionUiPromptSource[];
29
+
30
+ /** Narrow an unknown value to a valid prompt source, or `undefined`. */
31
+ function asUiPromptSource(
32
+ value: unknown,
33
+ ): PermissionUiPromptSource | undefined {
34
+ return UI_PROMPT_SOURCES.find((source) => source === value);
35
+ }
36
+
37
+ /** Narrow an unknown value to a nullable display string, or `undefined`. */
38
+ function asNullableDisplayString(value: unknown): string | null | undefined {
39
+ if (value === null || typeof value === "string") {
40
+ return value;
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ export function formatUnknownErrorMessage(error: unknown): string {
46
+ if (error instanceof Error && error.message) {
47
+ return error.message;
48
+ }
49
+ return String(error);
50
+ }
51
+
52
+ export function isErrnoCode(error: unknown, code: string): boolean {
53
+ return Boolean(
54
+ error &&
55
+ typeof error === "object" &&
56
+ "code" in error &&
57
+ (error as { code?: string }).code === code,
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Log a warning to both the review and debug logs.
63
+ * Pass `null` for `logger` to silently no-op (e.g. in unit tests without IO).
64
+ */
65
+ export function logPermissionForwardingWarning(
66
+ logger: DebugReviewLogger | null,
67
+ message: string,
68
+ error?: unknown,
69
+ ): void {
70
+ const details =
71
+ typeof error === "undefined"
72
+ ? { message }
73
+ : { message, error: formatUnknownErrorMessage(error) };
74
+
75
+ logger?.review("permission_forwarding.warning", details);
76
+ logger?.debug("permission_forwarding.warning", details);
77
+ }
78
+
79
+ /**
80
+ * Log an error to both the review and debug logs.
81
+ * Pass `null` for `logger` to silently no-op (e.g. in unit tests without IO).
82
+ */
83
+ export function logPermissionForwardingError(
84
+ logger: DebugReviewLogger | null,
85
+ message: string,
86
+ error?: unknown,
87
+ ): void {
88
+ const details =
89
+ typeof error === "undefined"
90
+ ? { message }
91
+ : { message, error: formatUnknownErrorMessage(error) };
92
+
93
+ logger?.review("permission_forwarding.error", details);
94
+ logger?.debug("permission_forwarding.error", details);
95
+ }
96
+
97
+ export function ensureDirectoryExists(
98
+ logger: DebugReviewLogger | null,
99
+ path: string,
100
+ description: string,
101
+ ): boolean {
102
+ try {
103
+ mkdirSync(path, { recursive: true });
104
+ return true;
105
+ } catch (error) {
106
+ logPermissionForwardingError(
107
+ logger,
108
+ `Failed to create ${description} directory '${path}'`,
109
+ error,
110
+ );
111
+ return false;
112
+ }
113
+ }
114
+
115
+ export function getPermissionForwardingLocationForSession(
116
+ forwardingDir: string,
117
+ sessionId: string,
118
+ ): PermissionForwardingLocation {
119
+ return createPermissionForwardingLocation(forwardingDir, sessionId);
120
+ }
121
+
122
+ export function ensurePermissionForwardingLocation(
123
+ logger: DebugReviewLogger | null,
124
+ forwardingDir: string,
125
+ sessionId: string,
126
+ ): PermissionForwardingLocation | null {
127
+ let location: PermissionForwardingLocation;
128
+ try {
129
+ location = getPermissionForwardingLocationForSession(
130
+ forwardingDir,
131
+ sessionId,
132
+ );
133
+ } catch (error) {
134
+ logPermissionForwardingError(
135
+ logger,
136
+ "Failed to resolve permission forwarding location",
137
+ error,
138
+ );
139
+ return null;
140
+ }
141
+
142
+ const sessionRootReady = ensureDirectoryExists(
143
+ logger,
144
+ location.sessionRootDir,
145
+ "permission forwarding session root",
146
+ );
147
+ const requestsReady = ensureDirectoryExists(
148
+ logger,
149
+ location.requestsDir,
150
+ "permission forwarding requests",
151
+ );
152
+ const responsesReady = ensureDirectoryExists(
153
+ logger,
154
+ location.responsesDir,
155
+ "permission forwarding responses",
156
+ );
157
+
158
+ return sessionRootReady && requestsReady && responsesReady ? location : null;
159
+ }
160
+
161
+ export function getExistingPermissionForwardingLocation(
162
+ forwardingDir: string,
163
+ sessionId: string,
164
+ ): PermissionForwardingLocation | null {
165
+ let location: PermissionForwardingLocation;
166
+ try {
167
+ location = getPermissionForwardingLocationForSession(
168
+ forwardingDir,
169
+ sessionId,
170
+ );
171
+ } catch {
172
+ return null;
173
+ }
174
+
175
+ return existsSync(location.requestsDir) ? location : null;
176
+ }
177
+
178
+ /**
179
+ * Attempt to remove a directory if it is empty.
180
+ *
181
+ * Returns `true` when the directory is absent after the call (successfully
182
+ * removed, or never existed). Returns `false` when the directory still exists
183
+ * (non-empty, or a filesystem error prevented removal).
184
+ */
185
+ export function tryRemoveDirectoryIfEmpty(
186
+ logger: DebugReviewLogger | null,
187
+ path: string,
188
+ description: string,
189
+ ): boolean {
190
+ if (!existsSync(path)) {
191
+ return true;
192
+ }
193
+
194
+ let entries: string[];
195
+ try {
196
+ entries = readdirSync(path);
197
+ } catch (error) {
198
+ logPermissionForwardingWarning(
199
+ logger,
200
+ `Failed to inspect ${description} directory '${path}'`,
201
+ error,
202
+ );
203
+ return false;
204
+ }
205
+
206
+ if (entries.length > 0) {
207
+ return false;
208
+ }
209
+
210
+ try {
211
+ rmdirSync(path);
212
+ return true;
213
+ } catch (error) {
214
+ if (isErrnoCode(error, "ENOENT")) {
215
+ return true;
216
+ }
217
+ if (isErrnoCode(error, "ENOTEMPTY")) {
218
+ return false;
219
+ }
220
+
221
+ logPermissionForwardingWarning(
222
+ logger,
223
+ `Failed to remove empty ${description} directory '${path}'`,
224
+ error,
225
+ );
226
+ return false;
227
+ }
228
+ }
229
+
230
+ export function cleanupPermissionForwardingLocationIfEmpty(
231
+ logger: DebugReviewLogger | null,
232
+ location: PermissionForwardingLocation,
233
+ ): void {
234
+ // Only remove responses/ when requests/ is already gone — removing responses/
235
+ // while a request is still pending causes the ENOENT write loop (issue #398).
236
+ const requestsGone = tryRemoveDirectoryIfEmpty(
237
+ logger,
238
+ location.requestsDir,
239
+ `${location.label} permission forwarding requests`,
240
+ );
241
+ if (requestsGone) {
242
+ tryRemoveDirectoryIfEmpty(
243
+ logger,
244
+ location.responsesDir,
245
+ `${location.label} permission forwarding responses`,
246
+ );
247
+ }
248
+ tryRemoveDirectoryIfEmpty(
249
+ logger,
250
+ location.sessionRootDir,
251
+ `${location.label} permission forwarding session root`,
252
+ );
253
+ }
254
+
255
+ export function safeDeleteFile(
256
+ logger: DebugReviewLogger | null,
257
+ filePath: string,
258
+ description: string,
259
+ ): void {
260
+ try {
261
+ unlinkSync(filePath);
262
+ } catch (error) {
263
+ if (isErrnoCode(error, "ENOENT")) {
264
+ return;
265
+ }
266
+
267
+ logPermissionForwardingWarning(
268
+ logger,
269
+ `Failed to delete ${description} file '${filePath}'`,
270
+ error,
271
+ );
272
+ }
273
+ }
274
+
275
+ export function writeJsonFileAtomic(
276
+ logger: DebugReviewLogger | null,
277
+ filePath: string,
278
+ value: unknown,
279
+ ): void {
280
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
281
+
282
+ try {
283
+ writeFileSync(tempPath, JSON.stringify(value), "utf-8");
284
+ renameSync(tempPath, filePath);
285
+ } catch (error) {
286
+ safeDeleteFile(logger, tempPath, "temporary permission-forwarding");
287
+ throw error;
288
+ }
289
+ }
290
+
291
+ export function readForwardedPermissionRequest(
292
+ logger: DebugReviewLogger | null,
293
+ filePath: string,
294
+ ): ForwardedPermissionRequest | null {
295
+ try {
296
+ const raw = readFileSync(filePath, "utf-8");
297
+ const parsed = JSON.parse(raw) as Partial<ForwardedPermissionRequest>;
298
+ if (
299
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JSON.parse can return null for the string "null"
300
+ !parsed ||
301
+ typeof parsed.id !== "string" ||
302
+ typeof parsed.createdAt !== "number" ||
303
+ typeof parsed.requesterSessionId !== "string" ||
304
+ typeof parsed.targetSessionId !== "string" ||
305
+ typeof parsed.requesterAgentName !== "string" ||
306
+ typeof parsed.message !== "string"
307
+ ) {
308
+ logPermissionForwardingWarning(
309
+ logger,
310
+ `Ignoring invalid forwarded permission request format in '${filePath}'`,
311
+ );
312
+ return null;
313
+ }
314
+
315
+ return {
316
+ id: parsed.id,
317
+ createdAt: parsed.createdAt,
318
+ requesterSessionId: parsed.requesterSessionId,
319
+ targetSessionId: parsed.targetSessionId,
320
+ requesterAgentName: parsed.requesterAgentName,
321
+ message: parsed.message,
322
+ // Tolerant read: display fields are optional and may be absent (older
323
+ // child) or malformed; reconstruct only the well-formed ones.
324
+ source: asUiPromptSource(parsed.source),
325
+ surface: asNullableDisplayString(parsed.surface),
326
+ value: asNullableDisplayString(parsed.value),
327
+ };
328
+ } catch (error) {
329
+ logPermissionForwardingWarning(
330
+ logger,
331
+ `Failed to read forwarded permission request '${filePath}'`,
332
+ error,
333
+ );
334
+ return null;
335
+ }
336
+ }
337
+
338
+ export function readForwardedPermissionResponse(
339
+ logger: DebugReviewLogger | null,
340
+ filePath: string,
341
+ ): ForwardedPermissionResponse | null {
342
+ try {
343
+ const raw = readFileSync(filePath, "utf-8");
344
+ const parsed = JSON.parse(raw) as Partial<ForwardedPermissionResponse>;
345
+ if (
346
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JSON.parse can return null for the string "null"
347
+ !parsed ||
348
+ typeof parsed.approved !== "boolean" ||
349
+ !isPermissionDecisionState(parsed.state) ||
350
+ typeof parsed.responderSessionId !== "string"
351
+ ) {
352
+ logPermissionForwardingWarning(
353
+ logger,
354
+ `Ignoring invalid forwarded permission response format in '${filePath}'`,
355
+ );
356
+ return null;
357
+ }
358
+
359
+ return {
360
+ approved: parsed.approved,
361
+ state: parsed.state,
362
+ denialReason:
363
+ typeof parsed.denialReason === "string"
364
+ ? parsed.denialReason
365
+ : undefined,
366
+ responderSessionId: parsed.responderSessionId,
367
+ respondedAt:
368
+ typeof parsed.respondedAt === "number"
369
+ ? parsed.respondedAt
370
+ : Date.now(),
371
+ };
372
+ } catch (error) {
373
+ logPermissionForwardingWarning(
374
+ logger,
375
+ `Failed to read forwarded permission response '${filePath}'`,
376
+ error,
377
+ );
378
+ return null;
379
+ }
380
+ }
381
+
382
+ export function listRequestFiles(
383
+ logger: DebugReviewLogger | null,
384
+ requestsDir: string,
385
+ ): string[] {
386
+ try {
387
+ return readdirSync(requestsDir)
388
+ .filter((name) => name.endsWith(".json"))
389
+ .sort();
390
+ } catch (error) {
391
+ logPermissionForwardingWarning(
392
+ logger,
393
+ `Failed to read permission forwarding requests from '${requestsDir}'`,
394
+ error,
395
+ );
396
+ return [];
397
+ }
398
+ }
399
+
400
+ export function sleep(ms: number): Promise<void> {
401
+ return new Promise((resolve) => {
402
+ setTimeout(resolve, ms);
403
+ });
404
+ }