@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
package/src/rule.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { PATH_SURFACES } from "./path-utils";
2
+ import type { PermissionState } from "./types";
3
+ import { wildcardMatch } from "./wildcard-matcher";
4
+
5
+ /**
6
+ * Provenance of a rule — which source contributed it.
7
+ *
8
+ * Config scopes: "global", "project", "agent", "project-agent".
9
+ * Synthesized: "builtin" (universal default / evaluate() fallback),
10
+ * "baseline" (conditional MCP metadata auto-allow).
11
+ * Runtime: "session" (session approvals).
12
+ */
13
+ export type RuleOrigin =
14
+ | "global"
15
+ | "project"
16
+ | "agent"
17
+ | "project-agent"
18
+ | "builtin"
19
+ | "baseline"
20
+ | "session";
21
+
22
+ /** A single permission rule — the atomic unit of policy. */
23
+ export interface Rule {
24
+ /** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
25
+ surface: string;
26
+ /** The match pattern: a command glob, tool name, skill name, or "*". */
27
+ pattern: string;
28
+ /** The permission decision. */
29
+ action: PermissionState;
30
+ /** Custom denial reason for deny rules (optional). */
31
+ reason?: string;
32
+ /**
33
+ * Origin layer — used to derive PermissionCheckResult.source after evaluation.
34
+ * Not used by evaluate(); purely informational metadata.
35
+ */
36
+ layer?: "default" | "baseline" | "config" | "session";
37
+ /** Which source contributed this rule. */
38
+ origin: RuleOrigin;
39
+ }
40
+
41
+ /** An ordered list of rules. Later rules take priority (last-match-wins). */
42
+ export type Ruleset = Rule[];
43
+
44
+ /**
45
+ * Pure permission evaluation.
46
+ *
47
+ * Returns the last rule in `rules` whose surface and pattern both
48
+ * wildcard-match the supplied values (last-match-wins).
49
+ *
50
+ * When no rule matches, returns a synthetic rule with `defaultAction`
51
+ * (defaults to "ask" — least privilege).
52
+ */
53
+ export function evaluate(
54
+ surface: string,
55
+ pattern: string,
56
+ rules: Ruleset,
57
+ defaultAction?: PermissionState,
58
+ platform: NodeJS.Platform = process.platform,
59
+ ): Rule {
60
+ const rule = rules.findLast((r) =>
61
+ ruleMatches(r, surface, pattern, platform),
62
+ );
63
+ if (rule !== undefined) return rule;
64
+ return {
65
+ surface,
66
+ pattern,
67
+ action: defaultAction ?? "ask",
68
+ origin: "builtin",
69
+ };
70
+ }
71
+
72
+ /**
73
+ * On Windows, path-surface values are canonicalized + lowercased; fold the
74
+ * pattern→value match (case and separators) so mixed-case / forward-slash
75
+ * overrides still match. The surface→surface match stays exact.
76
+ */
77
+ function pathMatchOptions(
78
+ surface: string,
79
+ platform: NodeJS.Platform,
80
+ ): { caseInsensitive: true; windowsSeparators: true } | undefined {
81
+ return platform === "win32" && PATH_SURFACES.has(surface)
82
+ ? { caseInsensitive: true, windowsSeparators: true }
83
+ : undefined;
84
+ }
85
+
86
+ function ruleMatches(
87
+ rule: Rule,
88
+ surface: string,
89
+ value: string,
90
+ platform: NodeJS.Platform,
91
+ ): boolean {
92
+ const matchOptions = pathMatchOptions(surface, platform);
93
+ return (
94
+ wildcardMatch(rule.surface, surface) &&
95
+ wildcardMatch(rule.pattern, value, matchOptions)
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Evaluate a surface against an ordered list of candidate values, stopping at
101
+ * the first candidate that matches a non-default rule (last-match-wins within
102
+ * each candidate, first-non-default-wins across candidates).
103
+ *
104
+ * Used by MCP (multi-candidate target list) and, uniformly, by all other
105
+ * surfaces (single-element candidate list).
106
+ *
107
+ * Returns the matched rule and the candidate value that produced it.
108
+ * When every candidate matches only the synthesized default, falls back to
109
+ * evaluating the first candidate so the caller always receives a concrete
110
+ * result.
111
+ */
112
+ /**
113
+ * Evaluate a surface against multiple values, returning the most restrictive
114
+ * non-allow result (deny > ask > allow).
115
+ *
116
+ * Used by the cross-cutting `path` surface to aggregate permission decisions
117
+ * across multiple file paths extracted from a single tool call or bash command.
118
+ *
119
+ * Returns `null` when all values evaluate to `allow` (no restriction).
120
+ * Returns the first `deny` immediately (short-circuit).
121
+ * Returns the first `ask` if no `deny` is found.
122
+ */
123
+ export function evaluateMostRestrictive(
124
+ surface: string,
125
+ values: string[],
126
+ rules: Ruleset,
127
+ ): { rule: Rule; value: string } | null {
128
+ let worst: { rule: Rule; value: string } | null = null;
129
+ for (const value of values) {
130
+ const rule = evaluate(surface, value, rules);
131
+ if (rule.action === "deny") return { rule, value };
132
+ if (rule.action === "ask" && worst?.rule.action !== "ask") {
133
+ worst = { rule, value };
134
+ }
135
+ }
136
+ return worst;
137
+ }
138
+
139
+ export function evaluateFirst(
140
+ surface: string,
141
+ values: string[],
142
+ rules: Ruleset,
143
+ ): { rule: Rule; value: string } {
144
+ for (const value of values) {
145
+ const rule = evaluate(surface, value, rules);
146
+ if (rule.layer !== "default") {
147
+ return { rule, value };
148
+ }
149
+ }
150
+ // All candidates matched only the synthesized default — use the first.
151
+ const fallbackValue = values[0] ?? "*";
152
+ return {
153
+ rule: evaluate(surface, fallbackValue, rules),
154
+ value: fallbackValue,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Evaluate equivalent lookup values as aliases of the same path.
160
+ *
161
+ * Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the
162
+ * last rule that matches any alias wins. This lets absolute allowlists and
163
+ * legacy relative rules coexist without a catch-all match on the first alias
164
+ * masking a later, more specific rule on another alias.
165
+ */
166
+ export function evaluateAnyValue(
167
+ surface: string,
168
+ values: string[],
169
+ rules: Ruleset,
170
+ platform: NodeJS.Platform = process.platform,
171
+ ): { rule: Rule; value: string } {
172
+ const fallbackValue = values[0] ?? "*";
173
+ const rule = rules.findLast((r) =>
174
+ values.some((value) => ruleMatches(r, surface, value, platform)),
175
+ );
176
+ if (rule !== undefined) {
177
+ return {
178
+ rule,
179
+ value:
180
+ values.find((value) => ruleMatches(rule, surface, value, platform)) ??
181
+ fallbackValue,
182
+ };
183
+ }
184
+ return {
185
+ rule: evaluate(surface, fallbackValue, rules),
186
+ value: fallbackValue,
187
+ };
188
+ }
@@ -0,0 +1,72 @@
1
+ import { mergeFlatPermissions } from "#src/permission-merge";
2
+ import type { RuleOrigin } from "#src/rule";
3
+ import type { FlatPermissionConfig, ScopeConfig } from "#src/types";
4
+
5
+ /** Surface → (pattern → originating scope). */
6
+ type OriginMap = Map<string, Map<string, RuleOrigin>>;
7
+
8
+ /** Result of merging permission objects across scopes with provenance tracking. */
9
+ export interface MergedScopes {
10
+ /** Fully merged flat permission config (lowest → highest precedence). */
11
+ mergedPermission: FlatPermissionConfig;
12
+ /** Maps each surface to a per-pattern origin (which scope contributed it). */
13
+ origins: OriginMap;
14
+ }
15
+
16
+ /**
17
+ * Merge permission objects across scopes (lowest → highest precedence) while
18
+ * tracking which scope contributed each (surface, pattern) entry.
19
+ *
20
+ * Mirrors mergeFlatPermissions() semantics for origin attribution:
21
+ * - Both base and incoming are objects → shallow-merge: each incoming pattern
22
+ * is attributed to this scope; patterns the higher scope does not redefine
23
+ * keep their earlier origin.
24
+ * - Otherwise → full replacement: this scope takes over the entire surface
25
+ * entry, discarding all lower-scope attribution.
26
+ */
27
+ export function mergeScopesWithOrigins(
28
+ scopes: readonly (readonly [RuleOrigin, ScopeConfig])[],
29
+ ): MergedScopes {
30
+ const origins: OriginMap = new Map();
31
+ let mergedPermission: FlatPermissionConfig = {};
32
+
33
+ for (const [scopeName, scope] of scopes) {
34
+ if (!scope.permission) continue;
35
+
36
+ for (const [surface, value] of Object.entries(scope.permission)) {
37
+ const baseVal = mergedPermission[surface];
38
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
39
+ const bothObjects =
40
+ typeof baseVal === "object" &&
41
+ baseVal !== null &&
42
+ typeof value === "object" &&
43
+ value !== null;
44
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
45
+
46
+ if (bothObjects) {
47
+ // Shallow-merge: each incoming pattern is attributed to this scope;
48
+ // existing patterns from lower scopes keep their earlier origin.
49
+ if (!origins.has(surface)) origins.set(surface, new Map());
50
+ for (const pattern of Object.keys(value)) {
51
+ origins.get(surface)?.set(pattern, scopeName);
52
+ }
53
+ } else {
54
+ // Full replacement: this scope takes over the entire surface entry.
55
+ const surfaceOrigins = new Map<string, RuleOrigin>();
56
+ if (typeof value === "string") {
57
+ surfaceOrigins.set("*", scopeName);
58
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check
59
+ } else if (typeof value === "object" && value !== null) {
60
+ for (const pattern of Object.keys(value)) {
61
+ surfaceOrigins.set(pattern, scopeName);
62
+ }
63
+ }
64
+ origins.set(surface, surfaceOrigins);
65
+ }
66
+ }
67
+
68
+ mergedPermission = mergeFlatPermissions(mergedPermission, scope.permission);
69
+ }
70
+
71
+ return { mergedPermission, origins };
72
+ }
@@ -0,0 +1,49 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
4
+ import {
5
+ type PermissionsService,
6
+ publishPermissionsService,
7
+ unpublishPermissionsService,
8
+ } from "./service";
9
+ import { isRegisteredSubagentChild } from "./subagent-context";
10
+ import type { SubagentSessionRegistry } from "./subagent-registry";
11
+
12
+ /** The session-scoped service lifecycle that the lifecycle handler drives. */
13
+ export interface ServiceLifecycle {
14
+ activate(ctx: ExtensionContext): void;
15
+ teardown(): void;
16
+ }
17
+
18
+ /**
19
+ * Owns the process-global service publication lifecycle for one extension
20
+ * instance.
21
+ *
22
+ * - `activate` publishes the service (skipped for registered subagent children
23
+ * so they never clobber the parent's slot — see #302), then emits the ready
24
+ * event.
25
+ * - `teardown` runs all session-scoped subscription cleanups in order, then
26
+ * unpublishes the service.
27
+ */
28
+ export class PermissionServiceLifecycle implements ServiceLifecycle {
29
+ constructor(
30
+ private readonly service: PermissionsService,
31
+ private readonly registry: SubagentSessionRegistry,
32
+ private readonly events: PermissionEventBus,
33
+ private readonly subscriptions: readonly (() => void)[],
34
+ ) {}
35
+
36
+ activate(ctx: ExtensionContext): void {
37
+ if (!isRegisteredSubagentChild(ctx, this.registry)) {
38
+ publishPermissionsService(this.service);
39
+ }
40
+ emitReadyEvent(this.events);
41
+ }
42
+
43
+ teardown(): void {
44
+ for (const unsubscribe of this.subscriptions) {
45
+ unsubscribe();
46
+ }
47
+ unpublishPermissionsService(this.service);
48
+ }
49
+ }
package/src/service.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Cross-extension service accessor backed by `Symbol.for()` on `globalThis`.
3
+ *
4
+ * `Symbol.for()` is process-global by spec, so it survives jiti's per-extension
5
+ * module isolation (`moduleCache: false`). A consumer doing
6
+ * `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but
7
+ * `getPermissionsService()` reads from the same `globalThis` slot the provider
8
+ * wrote to — enabling direct, synchronous, type-safe function calls.
9
+ *
10
+ * Best practice: call `getPermissionsService()` per use rather than caching the
11
+ * reference — this ensures resilience across `/reload` and load-order edge cases.
12
+ */
13
+
14
+ import type { ToolAccessExtractor } from "./tool-access-extractor-registry";
15
+ import type { ToolInputFormatter } from "./tool-input-formatter-registry";
16
+ import type { PermissionCheckResult, PermissionState } from "./types";
17
+
18
+ export type {
19
+ ForwardedPromptContext,
20
+ PermissionDecisionEvent,
21
+ PermissionsPromptReplyData,
22
+ PermissionsPromptRequest,
23
+ PermissionsReadyEvent,
24
+ PermissionsRpcReply,
25
+ PermissionUiPromptEvent,
26
+ PermissionUiPromptSource,
27
+ } from "./permission-events";
28
+ export {
29
+ PERMISSIONS_DECISION_CHANNEL,
30
+ PERMISSIONS_PROTOCOL_VERSION,
31
+ PERMISSIONS_READY_CHANNEL,
32
+ PERMISSIONS_RPC_PROMPT_CHANNEL,
33
+ PERMISSIONS_UI_PROMPT_CHANNEL,
34
+ } from "./permission-events";
35
+ export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
36
+
37
+ /** Process-global key for the service slot. */
38
+ const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
39
+
40
+ /**
41
+ * Public interface exposed to other extensions via `getPermissionsService()`.
42
+ *
43
+ * Mirrors the simplified RPC signature — surface + optional value + optional
44
+ * agent name — and delegates to `PermissionManager.checkPermission()` with
45
+ * current session rules internally.
46
+ */
47
+ export interface PermissionsService {
48
+ /**
49
+ * Query the permission policy for a surface and value.
50
+ *
51
+ * @param surface - Permission surface: "bash", "read", "mcp", "skill",
52
+ * "external_directory", etc.
53
+ * @param value - The value to evaluate: command string, tool name, skill
54
+ * name, or path. Omit or pass `undefined` for a
55
+ * surface-level query.
56
+ * @param agentName - Optional agent name for per-agent policy resolution.
57
+ * @returns Full check result including state, matched pattern, and origin.
58
+ */
59
+ checkPermission(
60
+ surface: string,
61
+ value?: string,
62
+ agentName?: string,
63
+ ): PermissionCheckResult;
64
+
65
+ /**
66
+ * Register a custom preview formatter for a specific tool name.
67
+ *
68
+ * The formatter is consulted first inside `ToolPreviewFormatter.formatToolInputForPrompt`;
69
+ * returning `undefined` falls through to the built-in switch (and ultimately
70
+ * the JSON default).
71
+ *
72
+ * Only one formatter may be registered per tool name — a second call for the
73
+ * same name throws. The returned disposer unregisters the formatter.
74
+ *
75
+ * @param toolName - Exact tool name to register for (e.g. `"mcp"`, `"my-server:run"`).
76
+ * @param formatter - Receives the raw `input` record; return a string to use
77
+ * as the prompt preview, or `undefined` to decline.
78
+ */
79
+ registerToolInputFormatter(
80
+ toolName: string,
81
+ formatter: ToolInputFormatter,
82
+ ): () => void;
83
+
84
+ /**
85
+ * Register a custom access-intent extractor for a specific tool name.
86
+ *
87
+ * The extractor declares the filesystem path a tool will access so the
88
+ * cross-cutting `path` and `external_directory` gates can see it. Use it for
89
+ * tools whose path lives under a non-standard key — built-in file tools and
90
+ * any tool exposing `input.path` (plus MCP via `input.arguments.path`) are
91
+ * already covered by convention without registration.
92
+ *
93
+ * The extractor receives the raw `input` record and returns the path string,
94
+ * or `undefined` to decline. Only one extractor may be registered per tool
95
+ * name — a second call for the same name throws. The returned disposer
96
+ * unregisters the extractor.
97
+ *
98
+ * @param toolName - Exact tool name to register for (e.g. `"ffgrep"`).
99
+ * @param extractor - Receives the raw `input` record; return the path string,
100
+ * or `undefined` to decline.
101
+ */
102
+ registerToolAccessExtractor(
103
+ toolName: string,
104
+ extractor: ToolAccessExtractor,
105
+ ): () => void;
106
+
107
+ /**
108
+ * Query the tool-level permission state for pre-filtering tools before
109
+ * creating a child session.
110
+ *
111
+ * Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
112
+ * Does not consider command-level rules (e.g. per-bash-command patterns) —
113
+ * use `checkPermission` for runtime invocation gates.
114
+ *
115
+ * @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
116
+ * @param agentName - Optional agent name for per-agent policy resolution.
117
+ */
118
+ getToolPermission(toolName: string, agentName?: string): PermissionState;
119
+ }
120
+
121
+ /**
122
+ * Store a `PermissionsService` on `globalThis` so other extensions can
123
+ * retrieve it via `getPermissionsService()`.
124
+ *
125
+ * Called at `session_start` by the top-level (parent) instance only — an
126
+ * in-process subagent child skips publishing so it cannot clobber the parent's
127
+ * service. Overwrites any previously published service, which keeps `/reload`
128
+ * working: a reloaded parent re-publishes its fresh service.
129
+ */
130
+ export function publishPermissionsService(service: PermissionsService): void {
131
+ (globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
132
+ }
133
+
134
+ /**
135
+ * Retrieve the published `PermissionsService`, or `undefined` if the
136
+ * permission-system extension has not loaded (or has been unloaded).
137
+ */
138
+ export function getPermissionsService(): PermissionsService | undefined {
139
+ return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
140
+ | PermissionsService
141
+ | undefined;
142
+ }
143
+
144
+ /**
145
+ * Remove `service` from `globalThis`, but only when the current slot still
146
+ * holds it (identity compare-and-delete).
147
+ *
148
+ * Called during `session_shutdown` to avoid stale references after the
149
+ * extension is torn down. Scoping the delete to the publishing instance keeps
150
+ * two cases correct:
151
+ *
152
+ * - An in-process subagent child never published the parent's service, so its
153
+ * shutdown is a no-op and the parent's slot survives.
154
+ * - A superseded `/reload` generation no longer owns the slot, so its late
155
+ * shutdown cannot wipe the new generation's freshly published service.
156
+ */
157
+ export function unpublishPermissionsService(service: PermissionsService): void {
158
+ if (getPermissionsService() !== service) {
159
+ return;
160
+ }
161
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
162
+ delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
163
+ }
@@ -0,0 +1,6 @@
1
+ import type { SessionApproval } from "./session-approval";
2
+
3
+ /** Records a granted session-scoped approval into the session ruleset. */
4
+ export interface SessionApprovalRecorder {
5
+ recordSessionApproval(approval: SessionApproval): void;
6
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Value object for a session-scoped approval: one surface, one-or-more patterns.
3
+ *
4
+ * Owned by gate descriptors and passed to the session store — the runner never
5
+ * needs to know whether there is one pattern or many.
6
+ */
7
+ export class SessionApproval {
8
+ private constructor(
9
+ readonly surface: string,
10
+ readonly patterns: readonly string[],
11
+ ) {}
12
+
13
+ /** Create an approval for a single pattern (the common case). */
14
+ static single(surface: string, pattern: string): SessionApproval {
15
+ return new SessionApproval(surface, [pattern]);
16
+ }
17
+
18
+ /**
19
+ * Create an approval for multiple patterns (e.g. bash external-directory
20
+ * gates that cover several uncovered paths in one prompt).
21
+ */
22
+ static multiple(
23
+ surface: string,
24
+ patterns: readonly string[],
25
+ ): SessionApproval {
26
+ return new SessionApproval(surface, [...patterns]);
27
+ }
28
+
29
+ /** Representative pattern for the interactive prompt — the first, if any. */
30
+ get representativePattern(): string | undefined {
31
+ return this.patterns[0];
32
+ }
33
+
34
+ /**
35
+ * Single-pattern shape `applyPermissionGate` echoes back to the caller.
36
+ * Returns `undefined` when patterns is empty (degenerate case).
37
+ */
38
+ toGateApproval(): { surface: string; pattern: string } | undefined {
39
+ const pattern = this.representativePattern;
40
+ if (pattern === undefined) return undefined;
41
+ return { surface: this.surface, pattern };
42
+ }
43
+ }
@@ -0,0 +1,91 @@
1
+ import { join } from "node:path";
2
+ import { DEBUG_LOG_FILENAME, REVIEW_LOG_FILENAME } from "./config-paths";
3
+ import {
4
+ ensurePermissionSystemLogsDirectory,
5
+ type PermissionSystemExtensionConfig,
6
+ } from "./extension-config";
7
+ import {
8
+ createPermissionSystemLogger,
9
+ type PermissionSystemLogger,
10
+ } from "./logging";
11
+
12
+ /**
13
+ * Narrowest logging seam — consumers that only write review-log entries.
14
+ * Injected into `PermissionPrompter` and the RPC handlers.
15
+ */
16
+ export interface ReviewLogger {
17
+ review(event: string, details?: Record<string, unknown>): void;
18
+ }
19
+
20
+ /**
21
+ * Logging seam for consumers that write both debug and review entries.
22
+ * Injected into `ConfigStore` and `PermissionForwarder`.
23
+ */
24
+ export interface DebugReviewLogger extends ReviewLogger {
25
+ debug(event: string, details?: Record<string, unknown>): void;
26
+ }
27
+
28
+ /**
29
+ * Unified logging + notification surface for handler deps.
30
+ *
31
+ * Replaces three separate logging fields (`writeDebugLog`,
32
+ * `writeReviewLog`, `notifyWarning`) with a single typed collaborator.
33
+ * This is an intermediate abstraction on the path to PermissionSession (#129).
34
+ */
35
+ export interface SessionLogger extends DebugReviewLogger {
36
+ warn(message: string): void;
37
+ }
38
+
39
+ /** Narrow dependencies for constructing a {@link SessionLogger}. */
40
+ export interface SessionLoggerDeps {
41
+ /** Root logs directory; the debug + review log file paths derive from it. */
42
+ globalLogsDir: string;
43
+ /** Reads current config for the debug/review write toggles (call-time). */
44
+ getConfig: () => PermissionSystemExtensionConfig;
45
+ /** Surfaces a warning message to the user; called at warn/IO-failure time. */
46
+ notify: (message: string) => void;
47
+ }
48
+
49
+ /**
50
+ * Concrete `SessionLogger` implementation.
51
+ *
52
+ * Composes the JSONL log writer, privately owns the IO-failure warning
53
+ * dedup Set, and routes both IO-failure warnings and explicit warn() calls
54
+ * through the injected notify sink. No ExtensionRuntime reference required.
55
+ */
56
+ export class PermissionSessionLogger implements SessionLogger {
57
+ private readonly writer: PermissionSystemLogger;
58
+ private readonly reported = new Set<string>();
59
+ private readonly notify: (message: string) => void;
60
+
61
+ constructor(deps: SessionLoggerDeps) {
62
+ this.writer = createPermissionSystemLogger({
63
+ getConfig: deps.getConfig,
64
+ debugLogPath: join(deps.globalLogsDir, DEBUG_LOG_FILENAME),
65
+ reviewLogPath: join(deps.globalLogsDir, REVIEW_LOG_FILENAME),
66
+ ensureLogsDirectory: () =>
67
+ ensurePermissionSystemLogsDirectory(deps.globalLogsDir),
68
+ });
69
+ this.notify = deps.notify;
70
+ }
71
+
72
+ debug(event: string, details?: Record<string, unknown>): void {
73
+ const warning = this.writer.debug(event, details);
74
+ if (warning) this.reportOnce(warning);
75
+ }
76
+
77
+ review(event: string, details?: Record<string, unknown>): void {
78
+ const warning = this.writer.review(event, details);
79
+ if (warning) this.reportOnce(warning);
80
+ }
81
+
82
+ warn(message: string): void {
83
+ this.notify(message);
84
+ }
85
+
86
+ private reportOnce(warning: string): void {
87
+ if (this.reported.has(warning)) return;
88
+ this.reported.add(warning);
89
+ this.notify(warning);
90
+ }
91
+ }
@@ -0,0 +1,79 @@
1
+ import { dirname, sep } from "node:path";
2
+
3
+ import type { Ruleset } from "./rule";
4
+ import type { SessionApproval } from "./session-approval";
5
+ import type { SessionApprovalRecorder } from "./session-approval-recorder";
6
+
7
+ /**
8
+ * Ephemeral in-memory store of session-scoped permission approvals.
9
+ *
10
+ * Each approval is stored as a `Rule` with `action: "allow"`, making the
11
+ * ruleset directly usable with `evaluate()` — no custom matching engine needed.
12
+ *
13
+ * Cleared on session_shutdown — never persisted to disk.
14
+ */
15
+ export class SessionRules implements SessionApprovalRecorder {
16
+ private rules: Ruleset = [];
17
+
18
+ /** Record a wildcard pattern as approved for the given surface. */
19
+ approve(surface: string, pattern: string): void {
20
+ this.rules.push({
21
+ surface,
22
+ pattern,
23
+ action: "allow",
24
+ layer: "session",
25
+ origin: "session",
26
+ });
27
+ }
28
+
29
+ /** Return a defensive copy of the current session ruleset. */
30
+ getRuleset(): Ruleset {
31
+ return [...this.rules];
32
+ }
33
+
34
+ /**
35
+ * Record all patterns from a `SessionApproval` value object.
36
+ *
37
+ * The loop lives here so callers never need to know whether an approval
38
+ * carries one pattern or many — they just tell the store to record it.
39
+ */
40
+ recordSessionApproval(approval: SessionApproval): void {
41
+ for (const pattern of approval.patterns) {
42
+ this.approve(approval.surface, pattern);
43
+ }
44
+ }
45
+
46
+ /** Remove all session approvals. */
47
+ clear(): void {
48
+ this.rules = [];
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Derive the wildcard glob pattern to approve from a normalized path.
54
+ *
55
+ * Returns `<parent-dir>/*` so that `evaluate()` / `wildcardMatch()` matches
56
+ * all paths under the approved directory — identical semantics to the former
57
+ * `SessionApprovalCache` prefix matching, using the unified wildcard engine.
58
+ *
59
+ * For paths that already end with a separator (directories), the separator
60
+ * is treated as the directory boundary and `*` is appended directly.
61
+ *
62
+ * The path is expected to be the canonical (cwd-resolved, absolute) form used
63
+ * for policy matching, so the derived pattern matches the same policy values a
64
+ * later tool call produces. Callers that hold a working directory resolve the
65
+ * path to that form first; the function itself stays free of cwd state.
66
+ */
67
+ export function deriveApprovalPattern(normalizedPath: string): string {
68
+ // If the path already ends with a separator, it's a directory — glob its contents.
69
+ if (normalizedPath.endsWith(sep)) {
70
+ return `${normalizedPath}*`;
71
+ }
72
+ const dir = dirname(normalizedPath);
73
+ if (dir === normalizedPath) {
74
+ // Root path — dirname('/') === '/'
75
+ return `${dir}*`;
76
+ }
77
+ const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
78
+ return `${prefix}*`;
79
+ }