@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,47 @@
1
+ import { join } from "node:path";
2
+
3
+ const EXTENSION_ID = "pi-permission-system";
4
+
5
+ export const DEBUG_LOG_FILENAME = `${EXTENSION_ID}-debug.jsonl`;
6
+ export const REVIEW_LOG_FILENAME = `${EXTENSION_ID}-permission-review.jsonl`;
7
+
8
+ export function getGlobalConfigDir(agentDir: string): string {
9
+ return join(agentDir, "extensions", EXTENSION_ID);
10
+ }
11
+
12
+ export function getGlobalConfigPath(agentDir: string): string {
13
+ return join(getGlobalConfigDir(agentDir), "config.json");
14
+ }
15
+
16
+ export function getGlobalLogsDir(agentDir: string): string {
17
+ return join(getGlobalConfigDir(agentDir), "logs");
18
+ }
19
+
20
+ export function getProjectConfigPath(cwd: string): string {
21
+ return join(cwd, ".pi", "extensions", EXTENSION_ID, "config.json");
22
+ }
23
+
24
+ /**
25
+ * Directory holding project-scoped custom agent definition files.
26
+ *
27
+ * `<cwd>/.pi/agents` is a Pi platform convention, also encoded by
28
+ * `@gotgenes/pi-subagents`' `loadCustomAgents` (`config/custom-agents.ts`).
29
+ * The two packages encode it independently — pi-permission-system has no
30
+ * dependency on pi-subagents (ADR-0002) — so this is this package's
31
+ * authoritative copy.
32
+ */
33
+ export function getProjectAgentsDir(cwd: string): string {
34
+ return join(cwd, ".pi", "agents");
35
+ }
36
+
37
+ export function getLegacyGlobalPolicyPath(agentDir: string): string {
38
+ return join(agentDir, "pi-permissions.jsonc");
39
+ }
40
+
41
+ export function getLegacyProjectPolicyPath(cwd: string): string {
42
+ return join(cwd, ".pi", "agent", "pi-permissions.jsonc");
43
+ }
44
+
45
+ export function getLegacyExtensionConfigPath(extensionRoot: string): string {
46
+ return join(extensionRoot, "config.json");
47
+ }
@@ -0,0 +1,34 @@
1
+ import type { ResolvedPolicyPaths } from "./permission-manager";
2
+
3
+ export interface ResolvedConfigLogEntry {
4
+ globalConfigPath: string;
5
+ globalConfigExists: boolean;
6
+ projectConfigPath: string | null;
7
+ projectConfigExists: boolean;
8
+ agentsDir: string;
9
+ agentsDirExists: boolean;
10
+ projectAgentsDir: string | null;
11
+ projectAgentsDirExists: boolean;
12
+ legacyGlobalPolicyDetected: boolean;
13
+ legacyProjectPolicyDetected: boolean;
14
+ legacyExtensionConfigDetected: boolean;
15
+ }
16
+
17
+ export interface BuildResolvedConfigLogEntryOptions {
18
+ policyPaths: ResolvedPolicyPaths;
19
+ legacyGlobalPolicyDetected?: boolean;
20
+ legacyProjectPolicyDetected?: boolean;
21
+ legacyExtensionConfigDetected?: boolean;
22
+ }
23
+
24
+ export function buildResolvedConfigLogEntry(
25
+ options: BuildResolvedConfigLogEntryOptions,
26
+ ): ResolvedConfigLogEntry {
27
+ return {
28
+ ...options.policyPaths,
29
+ legacyGlobalPolicyDetected: options.legacyGlobalPolicyDetected ?? false,
30
+ legacyProjectPolicyDetected: options.legacyProjectPolicyDetected ?? false,
31
+ legacyExtensionConfigDetected:
32
+ options.legacyExtensionConfigDetected ?? false,
33
+ };
34
+ }
@@ -0,0 +1,222 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ renameSync,
5
+ unlinkSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { dirname, normalize } from "node:path";
9
+ import type {
10
+ ExtensionCommandContext,
11
+ ExtensionContext,
12
+ } from "@earendil-works/pi-coding-agent";
13
+
14
+ import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
15
+ import {
16
+ getGlobalConfigPath,
17
+ getLegacyExtensionConfigPath,
18
+ getLegacyGlobalPolicyPath,
19
+ getLegacyProjectPolicyPath,
20
+ } from "./config-paths";
21
+ import { buildResolvedConfigLogEntry } from "./config-reporter";
22
+ import {
23
+ DEFAULT_EXTENSION_CONFIG,
24
+ EXTENSION_ROOT,
25
+ normalizePermissionSystemConfig,
26
+ type PermissionSystemExtensionConfig,
27
+ } from "./extension-config";
28
+ import type { ResolvedPolicyPaths } from "./policy-loader";
29
+ import type { DebugReviewLogger } from "./session-logger";
30
+ import { syncPermissionSystemStatus } from "./status";
31
+
32
+ /** Read-only view of the current config — for consumers that only read. */
33
+ export interface ConfigReader {
34
+ current(): PermissionSystemExtensionConfig;
35
+ }
36
+
37
+ /**
38
+ * Narrow subset of `ConfigStore` that `PermissionSession` depends on.
39
+ *
40
+ * Using an interface rather than the concrete class avoids private-member
41
+ * coupling between the class and test doubles.
42
+ */
43
+ export interface SessionConfigStore extends ConfigReader {
44
+ refresh(ctx?: ExtensionContext): void;
45
+ logResolvedPaths(cwd?: string): void;
46
+ }
47
+
48
+ /**
49
+ * Narrow subset of `ConfigStore` for the `/permission-system` command.
50
+ *
51
+ * Using an interface rather than the concrete class avoids private-member
52
+ * coupling between the class and test doubles.
53
+ */
54
+ export interface CommandConfigStore extends ConfigReader {
55
+ save(
56
+ next: PermissionSystemExtensionConfig,
57
+ ctx: ExtensionCommandContext,
58
+ ): void;
59
+ }
60
+
61
+ /** Narrow view of the manager's resolved policy paths (for `logResolvedPaths`). */
62
+ export interface ResolvedPolicyPathProvider {
63
+ getResolvedPolicyPaths(): ResolvedPolicyPaths;
64
+ }
65
+
66
+ export interface ConfigStoreDeps {
67
+ agentDir: string;
68
+ policyPaths: ResolvedPolicyPathProvider;
69
+ logger: DebugReviewLogger;
70
+ }
71
+
72
+ /**
73
+ * Owns the mutable extension config and the operations that read/write it.
74
+ *
75
+ * Replaces the three `(runtime, …)` config free functions
76
+ * (`refreshExtensionConfig`, `saveExtensionConfig`, `logResolvedConfigPaths`)
77
+ * with methods that privately own `config` and `lastConfigWarning`.
78
+ *
79
+ * Implements {@link ConfigReader} so consumers that only read the current config
80
+ * can depend on the narrow interface rather than the full class.
81
+ */
82
+ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
83
+ private config: PermissionSystemExtensionConfig;
84
+ private lastConfigWarning: string | null = null;
85
+
86
+ constructor(private readonly deps: ConfigStoreDeps) {
87
+ this.config = { ...DEFAULT_EXTENSION_CONFIG };
88
+ }
89
+
90
+ /** Return the current extension config. */
91
+ current(): PermissionSystemExtensionConfig {
92
+ return this.config;
93
+ }
94
+
95
+ /**
96
+ * Reload merged config from disk.
97
+ *
98
+ * If `ctx` is provided, uses it to derive the cwd and sync UI status.
99
+ * Equivalent to `refreshExtensionConfig(runtime, ctx?)`.
100
+ */
101
+ refresh(ctx?: ExtensionContext): void {
102
+ const cwd = ctx?.cwd ?? null;
103
+ const mergeResult = loadAndMergeConfigs(
104
+ this.deps.agentDir,
105
+ cwd ?? "",
106
+ EXTENSION_ROOT,
107
+ );
108
+ const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
109
+ this.config = runtimeConfig;
110
+
111
+ if (ctx?.hasUI) {
112
+ syncPermissionSystemStatus(ctx, runtimeConfig);
113
+ }
114
+
115
+ const warning =
116
+ mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
117
+
118
+ if (warning && warning !== this.lastConfigWarning) {
119
+ this.lastConfigWarning = warning;
120
+ ctx?.ui.notify(warning, "warning");
121
+ } else if (!warning) {
122
+ this.lastConfigWarning = null;
123
+ }
124
+
125
+ this.deps.logger.debug("config.loaded", {
126
+ warning: warning ?? null,
127
+ debugLog: runtimeConfig.debugLog,
128
+ permissionReviewLog: runtimeConfig.permissionReviewLog,
129
+ yoloMode: runtimeConfig.yoloMode,
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Save updated runtime knobs to the global config file, then update
135
+ * the current config and sync UI status.
136
+ *
137
+ * Equivalent to `saveExtensionConfig(runtime, next, ctx)`.
138
+ */
139
+ // Called via the CommandConfigStore interface from config-modal.ts — fallow cannot trace through interfaces.
140
+ // fallow-ignore-next-line unused-class-member
141
+ save(
142
+ next: PermissionSystemExtensionConfig,
143
+ ctx: ExtensionCommandContext,
144
+ ): void {
145
+ const normalized = normalizePermissionSystemConfig(next);
146
+ const globalPath = getGlobalConfigPath(this.deps.agentDir);
147
+
148
+ const existing = loadUnifiedConfig(globalPath);
149
+ const merged = {
150
+ ...existing.config,
151
+ debugLog: normalized.debugLog,
152
+ permissionReviewLog: normalized.permissionReviewLog,
153
+ yoloMode: normalized.yoloMode,
154
+ };
155
+
156
+ const tmpPath = `${globalPath}.tmp`;
157
+ try {
158
+ mkdirSync(dirname(globalPath), { recursive: true });
159
+ writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
160
+ renameSync(tmpPath, globalPath);
161
+ } catch (error) {
162
+ try {
163
+ if (existsSync(tmpPath)) {
164
+ unlinkSync(tmpPath);
165
+ }
166
+ } catch {
167
+ // Ignore cleanup failures.
168
+ }
169
+ const message = error instanceof Error ? error.message : String(error);
170
+ ctx.ui.notify(
171
+ `Failed to save permission-system config at '${globalPath}': ${message}`,
172
+ "error",
173
+ );
174
+ return;
175
+ }
176
+
177
+ this.config = normalized;
178
+ syncPermissionSystemStatus(ctx, normalized);
179
+ this.lastConfigWarning = null;
180
+
181
+ this.deps.logger.debug("config.saved", {
182
+ debugLog: normalized.debugLog,
183
+ permissionReviewLog: normalized.permissionReviewLog,
184
+ yoloMode: normalized.yoloMode,
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Write the resolved config path set to the review and debug logs.
190
+ *
191
+ * Equivalent to `logResolvedConfigPaths(runtime)`.
192
+ */
193
+ logResolvedPaths(cwd?: string): void {
194
+ const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths();
195
+ const { agentDir } = this.deps;
196
+ const legacyGlobalPolicyDetected = existsSync(
197
+ getLegacyGlobalPolicyPath(agentDir),
198
+ );
199
+ const legacyProjectPolicyDetected = cwd
200
+ ? existsSync(getLegacyProjectPolicyPath(cwd))
201
+ : false;
202
+ const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
203
+ const newGlobalPath = getGlobalConfigPath(agentDir);
204
+ const legacyExtensionConfigDetected =
205
+ normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
206
+ existsSync(legacyExtConfigPath);
207
+ const entry = buildResolvedConfigLogEntry({
208
+ policyPaths,
209
+ legacyGlobalPolicyDetected,
210
+ legacyProjectPolicyDetected,
211
+ legacyExtensionConfigDetected,
212
+ });
213
+ this.deps.logger.review(
214
+ "config.resolved",
215
+ entry as unknown as Record<string, unknown>,
216
+ );
217
+ this.deps.logger.debug(
218
+ "config.resolved",
219
+ entry as unknown as Record<string, unknown>,
220
+ );
221
+ }
222
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Records the per-call terminal decision so an evaluated-and-allowed call is
3
+ * distinguishable from a never-evaluated one. The fail-closed boundary owns the
4
+ * recorder and calls exactly one of `recordDecision` / `recordError` per call.
5
+ */
6
+ export interface DecisionRecorder {
7
+ /** Record a terminal allow/block decision (also bumps the tool-call count). */
8
+ recordDecision(action: "allow" | "block"): void;
9
+ /** Record a gate error that blocked fail-closed (also bumps the count). */
10
+ recordError(): void;
11
+ }
12
+
13
+ /** Narrow logging surface the summary needs: a debug line and a warning. */
14
+ export interface AuditLogger {
15
+ debug(event: string, details?: Record<string, unknown>): void;
16
+ warn(message: string): void;
17
+ }
18
+
19
+ /** Narrow surface the session-shutdown handler depends on. */
20
+ export interface DecisionSummaryWriter {
21
+ writeSummary(logger: AuditLogger): void;
22
+ }
23
+
24
+ /**
25
+ * In-process, per-session decision counters.
26
+ *
27
+ * The boundary produces exactly one terminal decision per tool call, so
28
+ * `toolCalls` must always equal `allowed + blocked + errors`. `writeSummary`
29
+ * emits the counters on `session_shutdown` and flags any mismatch as a cheap
30
+ * structural self-check — a mismatch means a code path re-opened a silent
31
+ * (never-recorded) exit.
32
+ */
33
+ export class DecisionAudit implements DecisionRecorder {
34
+ private toolCalls = 0;
35
+ private allowed = 0;
36
+ private blocked = 0;
37
+ private errors = 0;
38
+
39
+ recordDecision(action: "allow" | "block"): void {
40
+ this.toolCalls++;
41
+ if (action === "allow") {
42
+ this.allowed++;
43
+ } else {
44
+ this.blocked++;
45
+ }
46
+ }
47
+
48
+ recordError(): void {
49
+ this.toolCalls++;
50
+ this.errors++;
51
+ }
52
+
53
+ /**
54
+ * Emit one `permission.session_summary` debug line with the counters. When
55
+ * `toolCalls !== allowed + blocked + errors`, also emit a warning — the
56
+ * invariant violation means a tool call resolved without a recorded terminal
57
+ * decision (a re-opened silent path).
58
+ */
59
+ writeSummary(logger: AuditLogger): void {
60
+ const counts = {
61
+ toolCalls: this.toolCalls,
62
+ allowed: this.allowed,
63
+ blocked: this.blocked,
64
+ errors: this.errors,
65
+ };
66
+ logger.debug("permission.session_summary", counts);
67
+ if (this.toolCalls !== this.allowed + this.blocked + this.errors) {
68
+ logger.warn(
69
+ `[pi-permission-system] decision audit invariant violated: ${this.toolCalls} tool calls != ` +
70
+ `${this.allowed} allowed + ${this.blocked} blocked + ${this.errors} errors. ` +
71
+ "A tool call resolved without a recorded terminal decision.",
72
+ );
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,41 @@
1
+ import {
2
+ emitDecisionEvent,
3
+ type PermissionDecisionEvent,
4
+ type PermissionEventBus,
5
+ } from "./permission-events";
6
+ import type { SessionLogger } from "./session-logger";
7
+
8
+ /**
9
+ * Reports a permission gate's outcome to the review log and the decision
10
+ * channel. Groups the two side effects that always travel together:
11
+ * writing a structured review-log entry and broadcasting a decision event.
12
+ */
13
+ export interface DecisionReporter {
14
+ writeReviewLog(event: string, details: Record<string, unknown>): void;
15
+ emitDecision(event: PermissionDecisionEvent): void;
16
+ }
17
+
18
+ /**
19
+ * Owns the `SessionLogger` and the event bus so neither the handler nor
20
+ * the runner has to reach through the session to its logger or close over
21
+ * the event bus directly.
22
+ *
23
+ * Built once in `PermissionGateHandler`'s constructor; shared between
24
+ * `handleToolCall` (gate runner + bypass branch) and `handleInput`.
25
+ *
26
+ * Answers "who owns the event bus" — the reporter does, not the session.
27
+ */
28
+ export class GateDecisionReporter implements DecisionReporter {
29
+ constructor(
30
+ private readonly logger: SessionLogger,
31
+ private readonly events: PermissionEventBus,
32
+ ) {}
33
+
34
+ writeReviewLog(event: string, details: Record<string, unknown>): void {
35
+ this.logger.review(event, details);
36
+ }
37
+
38
+ emitDecision(event: PermissionDecisionEvent): void {
39
+ emitDecisionEvent(this.events, event);
40
+ }
41
+ }
@@ -0,0 +1,232 @@
1
+ import { EXTENSION_ID } from "./extension-config";
2
+ import type { BashCommandContext, PermissionCheckResult } from "./types";
3
+
4
+ // ── Extension attribution tag ──────────────────────────────────────────────
5
+
6
+ export const EXTENSION_TAG = `[${EXTENSION_ID}]`;
7
+
8
+ // ── Denial context discriminated union ─────────────────────────────────────
9
+
10
+ export type DenialContext =
11
+ | {
12
+ kind: "tool";
13
+ check: PermissionCheckResult;
14
+ agentName?: string;
15
+ input?: unknown;
16
+ }
17
+ | {
18
+ kind: "path";
19
+ toolName: string;
20
+ pathValue: string;
21
+ agentName?: string;
22
+ }
23
+ | {
24
+ kind: "external_directory";
25
+ toolName: string;
26
+ pathValue: string;
27
+ cwd: string;
28
+ agentName?: string;
29
+ }
30
+ | {
31
+ kind: "bash_external_directory";
32
+ command: string;
33
+ externalPaths: string[];
34
+ cwd: string;
35
+ agentName?: string;
36
+ }
37
+ | {
38
+ kind: "bash_path";
39
+ command: string;
40
+ pathValue: string;
41
+ agentName?: string;
42
+ }
43
+ | {
44
+ kind: "skill_read";
45
+ skillName: string;
46
+ readPath: string;
47
+ agentName?: string;
48
+ }
49
+ | {
50
+ kind: "skill_input";
51
+ skillName: string;
52
+ agentName?: string;
53
+ };
54
+
55
+ // ── Public formatter API ───────────────────────────────────────────────────
56
+
57
+ /** Format the block reason when permission policy denies an operation. */
58
+ export function formatDenyReason(ctx: DenialContext): string {
59
+ return `${EXTENSION_TAG} ${buildDenyBody(ctx)}`;
60
+ }
61
+
62
+ /** Format the block reason when no interactive UI is available to prompt. */
63
+ export function formatUnavailableReason(ctx: DenialContext): string {
64
+ return `${EXTENSION_TAG} ${buildUnavailableBody(ctx)}`;
65
+ }
66
+
67
+ /** Format the block reason when the user denies at an interactive prompt. */
68
+ export function formatUserDeniedReason(
69
+ ctx: DenialContext,
70
+ denialReason?: string,
71
+ ): string {
72
+ return `${EXTENSION_TAG} ${buildUserDeniedBody(ctx, denialReason)}`;
73
+ }
74
+
75
+ // ── Private body builders ──────────────────────────────────────────────────
76
+
77
+ function subject(agentName?: string): string {
78
+ return agentName ? `Agent '${agentName}'` : "Current agent";
79
+ }
80
+
81
+ function reasonSuffix(denialReason?: string): string {
82
+ return denialReason ? ` Reason: ${denialReason}.` : "";
83
+ }
84
+
85
+ function buildDenyBody(ctx: DenialContext): string {
86
+ switch (ctx.kind) {
87
+ case "tool":
88
+ return buildToolDenyBody(ctx);
89
+ case "path":
90
+ return `${subject(ctx.agentName)} is not permitted to access path '${ctx.pathValue}' via tool '${ctx.toolName}'.`;
91
+ case "external_directory":
92
+ return `${subject(ctx.agentName)} is not permitted to run tool '${ctx.toolName}' for path '${ctx.pathValue}' outside working directory '${ctx.cwd}'.`;
93
+ case "bash_external_directory":
94
+ return `${subject(ctx.agentName)} is not permitted to run bash command '${ctx.command}' which references path(s) outside working directory '${ctx.cwd}': ${ctx.externalPaths.join(", ")}.`;
95
+ case "bash_path":
96
+ return `${subject(ctx.agentName)} is not permitted to access path '${ctx.pathValue}' via tool 'bash'.`;
97
+ case "skill_read":
98
+ return `${subject(ctx.agentName)} is not permitted to access skill '${ctx.skillName}' via '${ctx.readPath}'.`;
99
+ case "skill_input":
100
+ return `${subject(ctx.agentName)} is not permitted to access skill '${ctx.skillName}'.`;
101
+ }
102
+ }
103
+
104
+ function buildToolDenyBody(
105
+ ctx: Extract<DenialContext, { kind: "tool" }>,
106
+ ): string {
107
+ const parts: string[] = [];
108
+ const { check, agentName } = ctx;
109
+
110
+ if (agentName) {
111
+ parts.push(`Agent '${agentName}'`);
112
+ }
113
+
114
+ if (isMcpCheck(check)) {
115
+ parts.push(`is not permitted to run MCP target '${check.target}'`);
116
+ } else {
117
+ parts.push(`is not permitted to run '${check.toolName}'`);
118
+ }
119
+
120
+ if (check.command) {
121
+ parts.push(`command '${check.command}'`);
122
+ }
123
+
124
+ const qualifier = matchQualifier(check.matchedPattern, check.commandContext);
125
+ if (qualifier) {
126
+ parts.push(qualifier);
127
+ }
128
+
129
+ // reasonSuffix appends ` Reason: <reason>.` after the sentence-ending period.
130
+ return `${parts.join(" ")}.${reasonSuffix(check.reason)}`;
131
+ }
132
+
133
+ /**
134
+ * Human-readable label for a nested bash execution context, or `undefined` for
135
+ * a current-shell (top-level) command.
136
+ */
137
+ export function describeBashCommandContext(
138
+ context?: BashCommandContext,
139
+ ): string | undefined {
140
+ switch (context) {
141
+ case "command_substitution":
142
+ return "command substitution";
143
+ case "process_substitution":
144
+ return "process substitution";
145
+ case "subshell":
146
+ return "subshell";
147
+ default:
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Build the parenthetical qualifier for a bash decision, folding the matched
154
+ * rule and (for a nested command) its execution context into one clause, e.g.
155
+ * `(matched 'rm *', inside command substitution)`. Returns `""` when neither
156
+ * applies.
157
+ */
158
+ export function matchQualifier(
159
+ matchedPattern?: string,
160
+ context?: BashCommandContext,
161
+ ): string {
162
+ const parts: string[] = [];
163
+ if (matchedPattern) {
164
+ parts.push(`matched '${matchedPattern}'`);
165
+ }
166
+ const label = describeBashCommandContext(context);
167
+ if (label) {
168
+ parts.push(`inside ${label}`);
169
+ }
170
+ return parts.length > 0 ? `(${parts.join(", ")})` : "";
171
+ }
172
+
173
+ function buildUnavailableBody(ctx: DenialContext): string {
174
+ switch (ctx.kind) {
175
+ case "tool": {
176
+ const { check } = ctx;
177
+ if (check.toolName === "bash" && check.command) {
178
+ return `Running bash command '${check.command}' requires approval, but no interactive UI is available.`;
179
+ }
180
+ if (isMcpCheck(check)) {
181
+ return "Using tool 'mcp' requires approval, but no interactive UI is available.";
182
+ }
183
+ return `Using tool '${check.toolName}' requires approval, but no interactive UI is available.`;
184
+ }
185
+ case "path":
186
+ return `Accessing '${ctx.pathValue}' requires approval, but no interactive UI is available.`;
187
+ case "external_directory":
188
+ return `Accessing '${ctx.pathValue}' outside the working directory requires approval, but no interactive UI is available.`;
189
+ case "bash_external_directory":
190
+ return `Bash command '${ctx.command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`;
191
+ case "bash_path":
192
+ return `Bash command '${ctx.command}' accesses path '${ctx.pathValue}' which requires approval, but no interactive UI is available.`;
193
+ case "skill_read":
194
+ return `Accessing skill '${ctx.skillName}' requires approval, but no interactive UI is available.`;
195
+ case "skill_input":
196
+ return `Accessing skill '${ctx.skillName}' requires approval, but no interactive UI is available.`;
197
+ }
198
+ }
199
+
200
+ function buildUserDeniedBody(
201
+ ctx: DenialContext,
202
+ denialReason?: string,
203
+ ): string {
204
+ switch (ctx.kind) {
205
+ case "tool": {
206
+ const { check } = ctx;
207
+ if (isMcpCheck(check)) {
208
+ return `User denied MCP target '${check.target}'.${reasonSuffix(denialReason)}`;
209
+ }
210
+ if (check.toolName === "bash" && check.command) {
211
+ return `User denied bash command '${check.command}'.${reasonSuffix(denialReason)}`;
212
+ }
213
+ return `User denied tool '${check.toolName}'.${reasonSuffix(denialReason)}`;
214
+ }
215
+ case "path":
216
+ return `User denied access to path '${ctx.pathValue}'.${reasonSuffix(denialReason)}`;
217
+ case "external_directory":
218
+ return `User denied external directory access for tool '${ctx.toolName}' path '${ctx.pathValue}'.${reasonSuffix(denialReason)}`;
219
+ case "bash_external_directory":
220
+ return `User denied external directory access for bash command '${ctx.command}'.${reasonSuffix(denialReason)}`;
221
+ case "bash_path":
222
+ return `User denied path access for bash command '${ctx.command}' (path '${ctx.pathValue}').${reasonSuffix(denialReason)}`;
223
+ case "skill_read":
224
+ return `User denied access to skill '${ctx.skillName}'.${reasonSuffix(denialReason)}`;
225
+ case "skill_input":
226
+ return `User denied access to skill '${ctx.skillName}'.${reasonSuffix(denialReason)}`;
227
+ }
228
+ }
229
+
230
+ function isMcpCheck(check: PermissionCheckResult): boolean {
231
+ return (check.source === "mcp" || check.toolName === "mcp") && !!check.target;
232
+ }
@@ -0,0 +1,28 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Expand `~` and `$HOME` prefixes in a pattern to the OS home directory.
6
+ *
7
+ * Supported forms:
8
+ * - `~` → `homedir()`
9
+ * - `~/path` → `homedir()/path`
10
+ * - `~\path` → `homedir()\path` (Windows)
11
+ * - `$HOME` → `homedir()`
12
+ * - `$HOME/path` → `homedir()/path`
13
+ * - `$HOME\path` → `homedir()\path` (Windows)
14
+ *
15
+ * All other patterns are returned unchanged.
16
+ */
17
+ export function expandHomePath(pattern: string): string {
18
+ if (pattern === "~" || pattern === "$HOME") {
19
+ return homedir();
20
+ }
21
+ if (pattern.startsWith("~/") || pattern.startsWith("~\\")) {
22
+ return join(homedir(), pattern.slice(2));
23
+ }
24
+ if (pattern.startsWith("$HOME/") || pattern.startsWith("$HOME\\")) {
25
+ return join(homedir(), pattern.slice(6));
26
+ }
27
+ return pattern;
28
+ }