@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,292 @@
1
+ import { dirname } from "node:path";
2
+
3
+ import {
4
+ isPathWithinDirectory,
5
+ normalizePathForComparison,
6
+ } from "./path-utils";
7
+ import type { PermissionCheckResult, PermissionState } from "./types";
8
+
9
+ /**
10
+ * Narrow interface for the permission checker used by skill prompt resolution.
11
+ * Both `PermissionManager` and `PermissionResolver` satisfy this structurally.
12
+ */
13
+ export interface SkillPermissionChecker {
14
+ checkPermission(
15
+ surface: string,
16
+ input: unknown,
17
+ agentName?: string,
18
+ ): PermissionCheckResult;
19
+ }
20
+
21
+ const AVAILABLE_SKILLS_OPEN_TAG = "<available_skills>";
22
+ const AVAILABLE_SKILLS_CLOSE_TAG = "</available_skills>";
23
+ const SKILL_BLOCK_PATTERN = "<skill>([\\s\\S]*?)<\\/skill>";
24
+ const SKILL_NAME_REGEX = /<name>([\s\S]*?)<\/name>/;
25
+ const SKILL_DESCRIPTION_REGEX = /<description>([\s\S]*?)<\/description>/;
26
+ const SKILL_LOCATION_REGEX = /<location>([\s\S]*?)<\/location>/;
27
+
28
+ type ParsedSkillPromptEntry = {
29
+ name: string;
30
+ description: string;
31
+ location: string;
32
+ };
33
+
34
+ export type SkillPromptEntry = {
35
+ name: string;
36
+ description: string;
37
+ location: string;
38
+ state: PermissionState;
39
+ normalizedLocation: string;
40
+ normalizedBaseDir: string;
41
+ };
42
+
43
+ export type SkillPromptSection = {
44
+ start: number;
45
+ end: number;
46
+ entries: ParsedSkillPromptEntry[];
47
+ };
48
+
49
+ function decodeXml(value: string): string {
50
+ return value
51
+ .replace(/&lt;/g, "<")
52
+ .replace(/&gt;/g, ">")
53
+ .replace(/&quot;/g, '"')
54
+ .replace(/&apos;/g, "'")
55
+ .replace(/&amp;/g, "&");
56
+ }
57
+
58
+ function encodeXml(value: string): string {
59
+ return value
60
+ .replace(/&/g, "&amp;")
61
+ .replace(/</g, "&lt;")
62
+ .replace(/>/g, "&gt;")
63
+ .replace(/"/g, "&quot;")
64
+ .replace(/'/g, "&apos;");
65
+ }
66
+
67
+ function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
68
+ const entries: ParsedSkillPromptEntry[] = [];
69
+ const skillBlockRegex = new RegExp(SKILL_BLOCK_PATTERN, "g");
70
+
71
+ for (const match of sectionBody.matchAll(skillBlockRegex)) {
72
+ const block = match[1];
73
+ const nameMatch = SKILL_NAME_REGEX.exec(block);
74
+ const descriptionMatch = SKILL_DESCRIPTION_REGEX.exec(block);
75
+ const locationMatch = SKILL_LOCATION_REGEX.exec(block);
76
+
77
+ if (!nameMatch || !descriptionMatch || !locationMatch) {
78
+ continue;
79
+ }
80
+
81
+ const name = decodeXml(nameMatch[1].trim());
82
+ const description = decodeXml(descriptionMatch[1].trim());
83
+ const location = decodeXml(locationMatch[1].trim());
84
+
85
+ if (!name || !location) {
86
+ continue;
87
+ }
88
+
89
+ entries.push({ name, description, location });
90
+ }
91
+
92
+ return entries;
93
+ }
94
+
95
+ export function parseAllSkillPromptSections(
96
+ prompt: string,
97
+ ): SkillPromptSection[] {
98
+ const sections: SkillPromptSection[] = [];
99
+ let searchStart = 0;
100
+
101
+ while (searchStart < prompt.length) {
102
+ const start = prompt.indexOf(AVAILABLE_SKILLS_OPEN_TAG, searchStart);
103
+ if (start === -1) {
104
+ break;
105
+ }
106
+
107
+ const closeStart = prompt.indexOf(
108
+ AVAILABLE_SKILLS_CLOSE_TAG,
109
+ start + AVAILABLE_SKILLS_OPEN_TAG.length,
110
+ );
111
+ if (closeStart === -1) {
112
+ break;
113
+ }
114
+
115
+ const end = closeStart + AVAILABLE_SKILLS_CLOSE_TAG.length;
116
+ const sectionBody = prompt.slice(
117
+ start + AVAILABLE_SKILLS_OPEN_TAG.length,
118
+ closeStart,
119
+ );
120
+ sections.push({
121
+ start,
122
+ end,
123
+ entries: parseSkillEntries(sectionBody),
124
+ });
125
+ searchStart = end;
126
+ }
127
+
128
+ return sections;
129
+ }
130
+
131
+ function resolvePermissionState(
132
+ skillName: string,
133
+ permissionManager: SkillPermissionChecker,
134
+ agentName: string | null,
135
+ cache: Map<string, PermissionState>,
136
+ ): PermissionState {
137
+ const cachedState = cache.get(skillName);
138
+ if (cachedState) {
139
+ return cachedState;
140
+ }
141
+
142
+ const state = permissionManager.checkPermission(
143
+ "skill",
144
+ { name: skillName },
145
+ agentName ?? undefined,
146
+ ).state;
147
+ cache.set(skillName, state);
148
+ return state;
149
+ }
150
+
151
+ function createResolvedSkillEntry(
152
+ entry: ParsedSkillPromptEntry,
153
+ state: PermissionState,
154
+ cwd: string,
155
+ ): SkillPromptEntry {
156
+ return {
157
+ name: entry.name,
158
+ description: entry.description,
159
+ location: entry.location,
160
+ state,
161
+ normalizedLocation: normalizePathForComparison(entry.location, cwd),
162
+ normalizedBaseDir: normalizePathForComparison(dirname(entry.location), cwd),
163
+ };
164
+ }
165
+
166
+ function renderAvailableSkillsSection(
167
+ entries: readonly SkillPromptEntry[],
168
+ ): string {
169
+ return [
170
+ AVAILABLE_SKILLS_OPEN_TAG,
171
+ ...entries.flatMap((entry) => [
172
+ " <skill>",
173
+ ` <name>${encodeXml(entry.name)}</name>`,
174
+ ` <description>${encodeXml(entry.description)}</description>`,
175
+ ` <location>${encodeXml(entry.location)}</location>`,
176
+ " </skill>",
177
+ ]),
178
+ AVAILABLE_SKILLS_CLOSE_TAG,
179
+ ].join("\n");
180
+ }
181
+
182
+ function removePromptRange(prompt: string, start: number, end: number): string {
183
+ const beforeSection = prompt.slice(0, start).replace(/\n+$/, "");
184
+ const afterSection = prompt.slice(end);
185
+ return `${beforeSection}${afterSection}`;
186
+ }
187
+
188
+ export function resolveSkillPromptEntries(
189
+ prompt: string,
190
+ permissionManager: SkillPermissionChecker,
191
+ agentName: string | null,
192
+ cwd: string,
193
+ ): { prompt: string; entries: SkillPromptEntry[] } {
194
+ const sections = parseAllSkillPromptSections(prompt);
195
+ if (sections.length === 0) {
196
+ return { prompt, entries: [] };
197
+ }
198
+
199
+ const permissionCache = new Map<string, PermissionState>();
200
+ const visibleEntries: SkillPromptEntry[] = [];
201
+ const replacements: Array<{ start: number; end: number; content: string }> =
202
+ [];
203
+
204
+ for (const section of sections) {
205
+ const resolvedEntries = section.entries.map((entry) => {
206
+ const state = resolvePermissionState(
207
+ entry.name,
208
+ permissionManager,
209
+ agentName,
210
+ permissionCache,
211
+ );
212
+ return createResolvedSkillEntry(entry, state, cwd);
213
+ });
214
+
215
+ const visibleSectionEntries = resolvedEntries.filter(
216
+ (entry) => entry.state !== "deny",
217
+ );
218
+ visibleEntries.push(...visibleSectionEntries);
219
+
220
+ if (visibleSectionEntries.length === resolvedEntries.length) {
221
+ continue;
222
+ }
223
+
224
+ replacements.push({
225
+ start: section.start,
226
+ end: section.end,
227
+ content:
228
+ visibleSectionEntries.length > 0
229
+ ? renderAvailableSkillsSection(visibleSectionEntries)
230
+ : "",
231
+ });
232
+ }
233
+
234
+ if (replacements.length === 0) {
235
+ return { prompt, entries: visibleEntries };
236
+ }
237
+
238
+ let sanitizedPrompt = prompt;
239
+ for (let i = replacements.length - 1; i >= 0; i--) {
240
+ const replacement = replacements[i];
241
+ sanitizedPrompt =
242
+ replacement.content.length > 0
243
+ ? `${sanitizedPrompt.slice(0, replacement.start)}${replacement.content}${sanitizedPrompt.slice(replacement.end)}`
244
+ : removePromptRange(
245
+ sanitizedPrompt,
246
+ replacement.start,
247
+ replacement.end,
248
+ );
249
+ }
250
+
251
+ return {
252
+ prompt: sanitizedPrompt,
253
+ entries: visibleEntries,
254
+ };
255
+ }
256
+
257
+ export function findSkillPathMatch(
258
+ normalizedPath: string,
259
+ entries: readonly SkillPromptEntry[],
260
+ ): SkillPromptEntry | null {
261
+ if (!normalizedPath || entries.length === 0) {
262
+ return null;
263
+ }
264
+
265
+ for (const entry of entries) {
266
+ if (
267
+ entry.normalizedLocation &&
268
+ normalizedPath === entry.normalizedLocation
269
+ ) {
270
+ return entry;
271
+ }
272
+ }
273
+
274
+ let bestMatch: SkillPromptEntry | null = null;
275
+ for (const entry of entries) {
276
+ if (
277
+ !entry.normalizedBaseDir ||
278
+ !isPathWithinDirectory(normalizedPath, entry.normalizedBaseDir)
279
+ ) {
280
+ continue;
281
+ }
282
+
283
+ if (
284
+ !bestMatch ||
285
+ entry.normalizedBaseDir.length > bestMatch.normalizedBaseDir.length
286
+ ) {
287
+ bestMatch = entry;
288
+ }
289
+ }
290
+
291
+ return bestMatch;
292
+ }
package/src/status.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type {
2
+ ExtensionCommandContext,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ import {
7
+ EXTENSION_ID,
8
+ type PermissionSystemExtensionConfig,
9
+ } from "./extension-config";
10
+ import { isYoloModeEnabled } from "./yolo-mode";
11
+
12
+ export const PERMISSION_SYSTEM_STATUS_KEY = EXTENSION_ID;
13
+ export const PERMISSION_SYSTEM_YOLO_STATUS_VALUE = "yolo";
14
+
15
+ type PermissionStatusContext =
16
+ | Pick<ExtensionContext, "hasUI" | "ui">
17
+ | Pick<ExtensionCommandContext, "ui">;
18
+
19
+ export function getPermissionSystemStatus(
20
+ config: PermissionSystemExtensionConfig,
21
+ ): string | undefined {
22
+ return isYoloModeEnabled(config)
23
+ ? PERMISSION_SYSTEM_YOLO_STATUS_VALUE
24
+ : undefined;
25
+ }
26
+
27
+ export function syncPermissionSystemStatus(
28
+ ctx: PermissionStatusContext,
29
+ config: PermissionSystemExtensionConfig,
30
+ ): void {
31
+ ctx.ui.setStatus(
32
+ PERMISSION_SYSTEM_STATUS_KEY,
33
+ getPermissionSystemStatus(config),
34
+ );
35
+ }
@@ -0,0 +1,104 @@
1
+ import { normalize } from "node:path";
2
+
3
+ import { SUBAGENT_ENV_HINT_KEYS } from "./permission-forwarding";
4
+ import type { SubagentSessionRegistry } from "./subagent-registry";
5
+
6
+ /**
7
+ * Narrow context for subagent detection — the only session-manager readers
8
+ * {@link isSubagentExecutionContext} and {@link isRegisteredSubagentChild}
9
+ * consume. A full `ExtensionContext` satisfies this structurally.
10
+ */
11
+ export interface SubagentDetectionContext {
12
+ sessionManager: {
13
+ getSessionId(): string;
14
+ getSessionDir(): string;
15
+ };
16
+ }
17
+
18
+ export function normalizeFilesystemPath(pathValue: string): string {
19
+ const normalizedPath = normalize(pathValue);
20
+ return process.platform === "win32"
21
+ ? normalizedPath.toLowerCase()
22
+ : normalizedPath;
23
+ }
24
+
25
+ function isPathWithinDirectoryForSubagent(
26
+ pathValue: string,
27
+ directory: string,
28
+ ): boolean {
29
+ if (!pathValue || !directory) {
30
+ return false;
31
+ }
32
+
33
+ if (pathValue === directory) {
34
+ return true;
35
+ }
36
+
37
+ const sep = process.platform === "win32" ? "\\" : "/";
38
+ const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
39
+ return pathValue.startsWith(prefix);
40
+ }
41
+
42
+ /**
43
+ * Return `true` when `ctx` belongs to an in-process subagent child registered
44
+ * in `registry` by its session id.
45
+ *
46
+ * This is the only signal that identifies an **in-process** child (one sharing
47
+ * the parent's `globalThis`); env-hint and filesystem heuristics identify
48
+ * **process-based** subagents instead. The composition root uses this to decide
49
+ * whether the instance owns the process-global service slot — a registered
50
+ * child must not publish over its parent.
51
+ */
52
+ export function isRegisteredSubagentChild(
53
+ ctx: SubagentDetectionContext,
54
+ registry: SubagentSessionRegistry,
55
+ ): boolean {
56
+ try {
57
+ const sessionId = ctx.sessionManager.getSessionId();
58
+ if (!sessionId) {
59
+ return false;
60
+ }
61
+ return registry.has(sessionId);
62
+ } catch {
63
+ // getSessionId() unavailable — treat as not-a-registered-child.
64
+ return false;
65
+ }
66
+ }
67
+
68
+ export function isSubagentExecutionContext(
69
+ ctx: SubagentDetectionContext,
70
+ subagentSessionsDir: string,
71
+ registry?: SubagentSessionRegistry,
72
+ ): boolean {
73
+ // 1. Explicit registry — in-process subagent extensions register by child
74
+ // session id before bindExtensions(); checked first so it takes priority
75
+ // over heuristics. Each concurrent sibling has a unique session id, so
76
+ // one sibling's disposed event cannot affect another's registration.
77
+ if (registry && isRegisteredSubagentChild(ctx, registry)) {
78
+ return true;
79
+ }
80
+
81
+ const sessionDir = ctx.sessionManager.getSessionDir();
82
+
83
+ // 2. Env vars — process-based subagent extensions (nicobailon/pi-subagents,
84
+ // HazAT/pi-interactive-subagents, pi-agent-router, etc.).
85
+ for (const key of SUBAGENT_ENV_HINT_KEYS) {
86
+ const value = process.env[key];
87
+ if (typeof value === "string" && value.trim()) {
88
+ return true;
89
+ }
90
+ }
91
+
92
+ // 3. Filesystem path — fallback heuristic for extensions that store sessions
93
+ // under a known subagent root directory.
94
+ if (!sessionDir) {
95
+ return false;
96
+ }
97
+
98
+ const normalizedSessionDir = normalizeFilesystemPath(sessionDir);
99
+ const normalizedSubagentRoot = normalizeFilesystemPath(subagentSessionsDir);
100
+ return isPathWithinDirectoryForSubagent(
101
+ normalizedSessionDir,
102
+ normalizedSubagentRoot,
103
+ );
104
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * subagent-lifecycle-events.ts — Subscribe to @gotgenes/pi-subagents' child
3
+ * lifecycle events and keep the SubagentSessionRegistry in sync.
4
+ *
5
+ * @gotgenes/pi-subagents publishes its child-execution lifecycle on the Pi
6
+ * event bus (ADR 0002): it no longer calls this package's service directly.
7
+ * We register the child on `session-created` and unregister it on `disposed`.
8
+ *
9
+ * The channel names and payload shapes are declared independently here (the two
10
+ * packages must not depend on each other under jiti) and MUST match the
11
+ * publisher in `@gotgenes/pi-subagents` (`src/lifecycle/child-lifecycle.ts`).
12
+ *
13
+ * The `session-created` handler MUST stay synchronous: the core emits it on the
14
+ * same synchronous call stack immediately before `bindExtensions()`, and the
15
+ * event bus dispatches listeners synchronously, so a synchronous handler lands
16
+ * the registry entry before binding proceeds. Introducing an `await` before
17
+ * `registry.register(...)` would break the pre-bind ordering.
18
+ */
19
+
20
+ import type { SubagentSessionRegistry } from "./subagent-registry";
21
+
22
+ /** Emitted by the core after session creation, before `bindExtensions()`. */
23
+ export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
24
+
25
+ /** Emitted by the core in the run's `finally` (success and error). */
26
+ export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
27
+
28
+ /** Minimal event-bus surface this module needs (subscribe only). */
29
+ interface LifecycleEventBus {
30
+ on(channel: string, handler: (data: unknown) => void): () => void;
31
+ }
32
+
33
+ /** Fields read from the `session-created` payload (ISP). */
34
+ interface ChildSessionCreatedEvent {
35
+ /** Child session id — the registry key. Must match the publisher. */
36
+ sessionId: string;
37
+ parentSessionId?: string;
38
+ }
39
+
40
+ /** Fields read from the `disposed` payload (ISP). */
41
+ interface ChildDisposedEvent {
42
+ /** Child session id — the registry key. Must match the publisher. */
43
+ sessionId: string;
44
+ }
45
+
46
+ /**
47
+ * Subscribe to the subagent child lifecycle.
48
+ *
49
+ * @returns an unsubscribe that detaches both handlers (call during
50
+ * `session_shutdown`).
51
+ */
52
+ export function subscribeSubagentLifecycle(
53
+ events: LifecycleEventBus,
54
+ registry: SubagentSessionRegistry,
55
+ ): () => void {
56
+ const unsubCreated = events.on(SUBAGENT_CHILD_SESSION_CREATED, (data) => {
57
+ const event = data as ChildSessionCreatedEvent;
58
+ registry.register(event.sessionId, {
59
+ parentSessionId: event.parentSessionId,
60
+ });
61
+ });
62
+
63
+ const unsubDisposed = events.on(SUBAGENT_CHILD_DISPOSED, (data) => {
64
+ const event = data as ChildDisposedEvent;
65
+ registry.unregister(event.sessionId);
66
+ });
67
+
68
+ return () => {
69
+ unsubCreated();
70
+ unsubDisposed();
71
+ };
72
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * subagent-registry.ts — In-process subagent session registry.
3
+ *
4
+ * In-process subagent extensions (e.g. `@gotgenes/pi-subagents`) register
5
+ * each child session here before calling `bindExtensions()` so that
6
+ * `isSubagentExecutionContext()` and permission-forwarding target resolution
7
+ * can detect them without relying on environment variables or filesystem
8
+ * heuristics.
9
+ *
10
+ * The registry is keyed by the child's **session id**, which is unique per
11
+ * child and available to both producer (via `sessionManager.getSessionId()`
12
+ * after `newSession()` in `create-subagent-session.ts`) and consumer (via
13
+ * `ctx.sessionManager.getSessionId()`). Two concurrent siblings of the same
14
+ * parent therefore occupy distinct keys, so one sibling's `disposed` event
15
+ * cannot evict the entry the others depend on.
16
+ *
17
+ * The single registry instance is stored on `globalThis` (via `Symbol.for()`)
18
+ * so that the parent's permission-system instance (which registers children
19
+ * on the parent's event bus) and each child's separate jiti instance (which
20
+ * reads the registry to detect itself and resolve its forwarding target) share
21
+ * one store across per-session event buses. See `getSubagentSessionRegistry()`.
22
+ *
23
+ * When a future code path needs the child's agent name, read it from
24
+ * `tcc.agentName` (resolved from the `<active_agent>` system-prompt tag) —
25
+ * not from this registry.
26
+ */
27
+
28
+ /** Process-global key for the shared registry slot. */
29
+ const SUBAGENT_SESSION_REGISTRY_KEY = Symbol.for(
30
+ "@gotgenes/pi-permission-system:subagent-registry",
31
+ );
32
+
33
+ /**
34
+ * Return the process-global SubagentSessionRegistry, creating it on first call.
35
+ *
36
+ * Backed by `globalThis` + `Symbol.for()` so the parent's permission-system
37
+ * instance (which registers children on the parent event bus) and each child's
38
+ * separate jiti instance (which reads the registry to detect itself and resolve
39
+ * its forwarding target) share one store across per-session event buses.
40
+ *
41
+ * Intentionally has no shutdown/unpublish hook — a child's `session_shutdown`
42
+ * must not be able to wipe the parent's registrations. Entries are added and
43
+ * removed exclusively by the parent's `subagents:child:session-created` /
44
+ * `subagents:child:disposed` subscription.
45
+ */
46
+ export function getSubagentSessionRegistry(): SubagentSessionRegistry {
47
+ const store = globalThis as Record<symbol, unknown>;
48
+ const existing = store[SUBAGENT_SESSION_REGISTRY_KEY] as
49
+ | SubagentSessionRegistry
50
+ | undefined;
51
+ if (existing) {
52
+ return existing;
53
+ }
54
+ const registry = new SubagentSessionRegistry();
55
+ store[SUBAGENT_SESSION_REGISTRY_KEY] = registry;
56
+ return registry;
57
+ }
58
+
59
+ /** Signal stored per registered in-process subagent session. */
60
+ export interface SubagentSessionInfo {
61
+ /** Parent session ID for permission forwarding. Omit when unknown. */
62
+ parentSessionId?: string;
63
+ }
64
+
65
+ /**
66
+ * Registry of active in-process subagent sessions.
67
+ *
68
+ * A process-global singleton — obtain it via `getSubagentSessionRegistry()`,
69
+ * never `new` (see that accessor for why). Written exclusively by
70
+ * `subscribeSubagentLifecycle` via the `subagents:child:session-created` /
71
+ * `subagents:child:disposed` event subscription (ADR 0002 — the core
72
+ * publishes, consumers observe).
73
+ *
74
+ * Keyed by child session id. Each concurrent child of the same parent receives
75
+ * a unique session id from `sessionManager.newSession()`, so siblings occupy
76
+ * distinct keys and one sibling's `disposed` cannot evict another's entry.
77
+ */
78
+ export class SubagentSessionRegistry {
79
+ private readonly sessions = new Map<string, SubagentSessionInfo>();
80
+
81
+ /**
82
+ * Register an in-process subagent session.
83
+ *
84
+ * If a previous entry exists for `sessionId`, it is overwritten
85
+ * (last-write-wins; single-writer expected per key).
86
+ */
87
+ register(sessionId: string, info: SubagentSessionInfo): void {
88
+ this.sessions.set(sessionId, info);
89
+ }
90
+
91
+ /** Remove a previously registered session. No-op if the key is absent. */
92
+ unregister(sessionId: string): void {
93
+ this.sessions.delete(sessionId);
94
+ }
95
+
96
+ /** Return the registered info for `sessionId`, or `undefined` if absent. */
97
+ get(sessionId: string): SubagentSessionInfo | undefined {
98
+ return this.sessions.get(sessionId);
99
+ }
100
+
101
+ /** Return `true` when `sessionId` has a registered entry. */
102
+ has(sessionId: string): boolean {
103
+ return this.sessions.has(sessionId);
104
+ }
105
+ }
@@ -0,0 +1,92 @@
1
+ import type { Rule, RuleOrigin, Ruleset } from "./rule";
2
+ import type { PermissionState } from "./types";
3
+
4
+ /**
5
+ * Synthesize a single universal catch-all rule from the universal default.
6
+ *
7
+ * Produces one rule:
8
+ * `{ surface: "*", pattern: "*", action: universalDefault, layer: "default" }`
9
+ *
10
+ * Per-surface catch-alls (`bash["*"]`, `mcp["*"]`, etc.) are expressed as
11
+ * regular config rules from `normalizeFlatConfig()` and sit at higher indices
12
+ * in the composed array, so they override this default via last-match-wins.
13
+ */
14
+ export function synthesizeDefaults(
15
+ universalDefault: PermissionState,
16
+ origin: RuleOrigin = "builtin",
17
+ ): Ruleset {
18
+ return [
19
+ {
20
+ surface: "*",
21
+ pattern: "*",
22
+ action: universalDefault,
23
+ layer: "default",
24
+ origin,
25
+ },
26
+ ];
27
+ }
28
+
29
+ /**
30
+ * MCP metadata operation targets that are auto-allowed when any explicit MCP
31
+ * allow rule exists in the config layer.
32
+ */
33
+ const MCP_BASELINE_TARGETS: readonly string[] = [
34
+ "mcp_status",
35
+ "mcp_list",
36
+ "mcp_search",
37
+ "mcp_describe",
38
+ "mcp_connect",
39
+ ];
40
+
41
+ /**
42
+ * Conditionally synthesize MCP baseline auto-allow rules.
43
+ *
44
+ * Emits allow rules for the 5 MCP metadata targets only when `configRules`
45
+ * contains at least one `surface: "mcp", action: "allow"` rule. This replicates
46
+ * the `hasAnyMcpAllowRule` heuristic as actual rules.
47
+ *
48
+ * When `permission["mcp"]` is `"allow"` (or `mcp["*"]` is `"allow"`), the
49
+ * synthesized config catch-all already covers all MCP targets — no separate
50
+ * baseline rules are needed (and this function is not called in that case).
51
+ *
52
+ * Baseline rules are placed BEFORE config rules in the composed array so
53
+ * that explicit config deny rules can still override them.
54
+ *
55
+ * All rules carry `layer: "baseline"`.
56
+ */
57
+ export function synthesizeBaseline(configRules: Ruleset): Ruleset {
58
+ const hasAnyMcpAllow = configRules.some(
59
+ (r) => r.surface === "mcp" && r.action === "allow",
60
+ );
61
+ if (!hasAnyMcpAllow) {
62
+ return [];
63
+ }
64
+ return MCP_BASELINE_TARGETS.map(
65
+ (target): Rule => ({
66
+ surface: "mcp",
67
+ pattern: target,
68
+ action: "allow",
69
+ layer: "baseline",
70
+ origin: "baseline",
71
+ }),
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Concatenate all rule layers into a single flat ruleset.
77
+ *
78
+ * Priority order (lowest → highest, i.e. earlier index → later index):
79
+ * defaults → baseline → config
80
+ *
81
+ * Session rules are NOT included here — they are appended at call-time inside
82
+ * `checkPermission()` so that the cached composed ruleset remains session-agnostic.
83
+ *
84
+ * `evaluate()` scans from the end, so later layers override earlier ones.
85
+ */
86
+ export function composeRuleset(
87
+ defaults: Ruleset,
88
+ baseline: Ruleset,
89
+ config: Ruleset,
90
+ ): Ruleset {
91
+ return [...defaults, ...baseline, ...config];
92
+ }