@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,157 @@
1
+ import { stripBashCommentLines } from "./bash-arity";
2
+ import { getNonEmptyString, toRecord } from "./common";
3
+ import { createMcpPermissionTargets } from "./mcp-targets";
4
+ import { getPathPolicyValues, PATH_BEARING_TOOLS } from "./path-utils";
5
+
6
+ /**
7
+ * Construct a surface-appropriate input object from a raw value string.
8
+ *
9
+ * This is the inverse of `normalizeInput()` — it builds the minimal input
10
+ * object that `PermissionManager.checkPermission()` expects for a given
11
+ * surface, from a single string value.
12
+ *
13
+ * Used by the event-bus RPC handler and the `Symbol.for()` service accessor
14
+ * so external callers can query policy with `(surface, value)` instead of
15
+ * constructing a full tool-call input payload.
16
+ *
17
+ * Note: MCP inputs are complex (server name + tool name derivation). Callers
18
+ * providing an MCP surface receive a best-effort policy evaluation using the
19
+ * value as a pre-qualified target string. Pass the fully-qualified target
20
+ * (e.g. "exa:search" or "exa") directly.
21
+ */
22
+ export function buildInputForSurface(
23
+ surface: string,
24
+ value: string | undefined,
25
+ ): unknown {
26
+ const v = value ?? "";
27
+ if (surface === "bash") return { command: v };
28
+ if (surface === "skill") return { name: v };
29
+ if (surface === "external_directory") return { path: v };
30
+ // MCP and tool surfaces: normalizeInput handles them from the surface alone.
31
+ return {};
32
+ }
33
+
34
+ /**
35
+ * Surface-normalized representation of a tool invocation used by
36
+ * `checkPermission()` to feed a single `evaluateFirst()` call.
37
+ */
38
+ export interface NormalizedInput {
39
+ /** The permission surface for `evaluate()` (e.g. "bash", "mcp", "skill"). */
40
+ surface: string;
41
+ /**
42
+ * Candidate lookup values in priority order (most-specific first).
43
+ * Most surfaces produce a single-element array; MCP produces a
44
+ * multi-candidate list derived from the invocation input.
45
+ */
46
+ values: string[];
47
+ /**
48
+ * Surface-specific fields forwarded verbatim into `PermissionCheckResult`
49
+ * (e.g. `{ command }` for bash, `{ target }` for mcp).
50
+ */
51
+ resultExtras: Record<string, unknown>;
52
+ }
53
+
54
+ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
55
+
56
+ /**
57
+ * Map a raw tool invocation to the surface/values/extras triple needed by
58
+ * `checkPermission()`.
59
+ *
60
+ * @param toolName - Normalized (trimmed) tool name from the tool-call event.
61
+ * @param input - Raw input payload from the tool-call event.
62
+ * @param configuredMcpServerNames - Ordered list of MCP server names from the
63
+ * global MCP config, used to derive server-qualified MCP targets.
64
+ */
65
+ export function normalizeInput(
66
+ toolName: string,
67
+ input: unknown,
68
+ configuredMcpServerNames: readonly string[],
69
+ cwd?: string,
70
+ ): NormalizedInput {
71
+ // --- Special surfaces (path, external_directory) ---
72
+ if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
73
+ return {
74
+ surface: toolName,
75
+ values: normalizePathSurfaceValues(input, cwd),
76
+ resultExtras: {},
77
+ };
78
+ }
79
+
80
+ // --- Skill ---
81
+ if (toolName === "skill") {
82
+ const record = toRecord(input);
83
+ const skillName = record.name;
84
+ const lookupValue = typeof skillName === "string" ? skillName : "*";
85
+ return {
86
+ surface: "skill",
87
+ values: [lookupValue],
88
+ resultExtras: {},
89
+ };
90
+ }
91
+
92
+ // --- Bash ---
93
+ if (toolName === "bash") {
94
+ const record = toRecord(input);
95
+ const command = typeof record.command === "string" ? record.command : "";
96
+ // Strip leading shell comment lines so pattern matching operates on the
97
+ // actual command, not a `# description` prefix agents often prepend.
98
+ // Fall back to the raw command when stripping leaves nothing, so an
99
+ // all-comment command still evaluates against its literal text.
100
+ const matchValue = stripBashCommentLines(command) || command;
101
+ return {
102
+ surface: "bash",
103
+ values: [matchValue],
104
+ resultExtras: { command },
105
+ };
106
+ }
107
+
108
+ // --- MCP ---
109
+ if (toolName === "mcp") {
110
+ const mcpTargets = [
111
+ ...createMcpPermissionTargets(input, configuredMcpServerNames),
112
+ "mcp",
113
+ ];
114
+ const fallbackTarget = mcpTargets[0] ?? "mcp";
115
+ return {
116
+ surface: "mcp",
117
+ values: mcpTargets,
118
+ resultExtras: { target: fallbackTarget },
119
+ };
120
+ }
121
+
122
+ // --- Path-bearing tools (read, write, edit, grep, find, ls) ---
123
+ if (PATH_BEARING_TOOLS.has(toolName)) {
124
+ return {
125
+ surface: toolName,
126
+ values: normalizePathSurfaceValues(input, cwd),
127
+ resultExtras: {},
128
+ };
129
+ }
130
+
131
+ // --- Extension tools (non-path-bearing) ---
132
+ return {
133
+ surface: toolName,
134
+ values: ["*"],
135
+ resultExtras: {},
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Extract and normalize the path lookup values shared by every path surface
141
+ * (`path`, `external_directory`, and the path-bearing tools).
142
+ *
143
+ * Missing, empty, or whitespace-only paths collapse to the surface catch-all
144
+ * `"*"`. When CWD is known, a relative path also produces a normalized
145
+ * absolute policy value and a project-relative alias while keeping its legacy
146
+ * relative value, so values match home- and cwd-anchored patterns
147
+ * symmetrically with how the patterns themselves are expanded (#350).
148
+ *
149
+ * Only `input.path` is read — policy values are never sourced from any other
150
+ * (potentially attacker-controlled) field on the raw tool input.
151
+ */
152
+ function normalizePathSurfaceValues(input: unknown, cwd?: string): string[] {
153
+ const path = getNonEmptyString(toRecord(input).path);
154
+ if (path === null) return ["*"];
155
+ const values = getPathPolicyValues(path, cwd ? { cwd } : {});
156
+ return values.length > 0 ? values : ["*"];
157
+ }
package/src/logging.ts ADDED
@@ -0,0 +1,113 @@
1
+ import { appendFileSync } from "node:fs";
2
+
3
+ import {
4
+ EXTENSION_ID,
5
+ type PermissionSystemExtensionConfig,
6
+ } from "./extension-config";
7
+
8
+ export function safeJsonStringify(value: unknown): string | undefined {
9
+ const seen = new WeakSet<object>();
10
+ return JSON.stringify(value, (_key, currentValue) => {
11
+ if (currentValue instanceof Error) {
12
+ return {
13
+ name: currentValue.name,
14
+ message: currentValue.message,
15
+ stack: currentValue.stack,
16
+ };
17
+ }
18
+
19
+ if (typeof currentValue === "bigint") {
20
+ return currentValue.toString();
21
+ }
22
+
23
+ if (typeof currentValue === "object" && currentValue !== null) {
24
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- JSON.stringify replacer receives any; currentValue is narrowed to object here
25
+ if (seen.has(currentValue)) {
26
+ return "[Circular]";
27
+ }
28
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- same as above
29
+ seen.add(currentValue);
30
+ }
31
+
32
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- JSON.stringify replacer must return any
33
+ return currentValue;
34
+ });
35
+ }
36
+
37
+ export interface PermissionSystemLogger {
38
+ debug: (
39
+ event: string,
40
+ details?: Record<string, unknown>,
41
+ ) => string | undefined;
42
+ review: (
43
+ event: string,
44
+ details?: Record<string, unknown>,
45
+ ) => string | undefined;
46
+ }
47
+
48
+ interface PermissionSystemLoggerOptions {
49
+ getConfig: () => PermissionSystemExtensionConfig;
50
+ debugLogPath: string;
51
+ reviewLogPath: string;
52
+ ensureLogsDirectory: () => string | undefined;
53
+ }
54
+
55
+ export function createPermissionSystemLogger(
56
+ options: PermissionSystemLoggerOptions,
57
+ ): PermissionSystemLogger {
58
+ const { debugLogPath, reviewLogPath, ensureLogsDirectory } = options;
59
+
60
+ const writeLine = (
61
+ stream: "debug" | "review",
62
+ path: string,
63
+ event: string,
64
+ details: Record<string, unknown>,
65
+ ): string | undefined => {
66
+ const directoryError = ensureLogsDirectory();
67
+ if (directoryError) {
68
+ return directoryError;
69
+ }
70
+
71
+ try {
72
+ const line = safeJsonStringify({
73
+ timestamp: new Date().toISOString(),
74
+ extension: EXTENSION_ID,
75
+ stream,
76
+ event,
77
+ ...details,
78
+ });
79
+ if (!line) {
80
+ return `Failed to write permission-system ${stream} log '${path}': event could not be serialized.`;
81
+ }
82
+ appendFileSync(path, `${line}\n`, "utf-8");
83
+ return undefined;
84
+ } catch (error) {
85
+ const message = error instanceof Error ? error.message : String(error);
86
+ return `Failed to write permission-system ${stream} log '${path}': ${message}`;
87
+ }
88
+ };
89
+
90
+ const debug = (
91
+ event: string,
92
+ details: Record<string, unknown> = {},
93
+ ): string | undefined => {
94
+ if (!options.getConfig().debugLog) {
95
+ return undefined;
96
+ }
97
+
98
+ return writeLine("debug", debugLogPath, event, details);
99
+ };
100
+
101
+ const review = (
102
+ event: string,
103
+ details: Record<string, unknown> = {},
104
+ ): string | undefined => {
105
+ if (!options.getConfig().permissionReviewLog) {
106
+ return undefined;
107
+ }
108
+
109
+ return writeLine("review", reviewLogPath, event, details);
110
+ };
111
+
112
+ return { debug, review };
113
+ }
@@ -0,0 +1,170 @@
1
+ import { getNonEmptyString, toRecord } from "./common";
2
+
3
+ /**
4
+ * An ordered accumulator that owns the uniqueness invariant.
5
+ *
6
+ * `add` ignores null/empty values and silently skips duplicates (first-insertion
7
+ * wins). `toArray` returns the ordered result as an independent copy.
8
+ */
9
+ export class McpTargetList {
10
+ private readonly targets: string[] = [];
11
+
12
+ add(value: string | null): void {
13
+ if (!value) {
14
+ return;
15
+ }
16
+ if (!this.targets.includes(value)) {
17
+ this.targets.push(value);
18
+ }
19
+ }
20
+
21
+ toArray(): string[] {
22
+ return [...this.targets];
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Parse a qualified MCP tool name of the form `server:tool`.
28
+ *
29
+ * Returns `{ server, tool }` when the string contains exactly one colon with
30
+ * non-empty text on both sides; otherwise returns `null`.
31
+ */
32
+ export function parseQualifiedMcpToolName(
33
+ value: string,
34
+ ): { server: string; tool: string } | null {
35
+ const trimmed = value.trim();
36
+ if (!trimmed) {
37
+ return null;
38
+ }
39
+
40
+ const colonIndex = trimmed.indexOf(":");
41
+ if (colonIndex <= 0 || colonIndex >= trimmed.length - 1) {
42
+ return null;
43
+ }
44
+
45
+ const server = trimmed.slice(0, colonIndex).trim();
46
+ const tool = trimmed.slice(colonIndex + 1).trim();
47
+ if (!server || !tool) {
48
+ return null;
49
+ }
50
+
51
+ return { server, tool };
52
+ }
53
+
54
+ function addDerivedMcpServerTargets(
55
+ toolName: string,
56
+ configuredServerNames: readonly string[],
57
+ targets: McpTargetList,
58
+ ): void {
59
+ const trimmedToolName = toolName.trim();
60
+ if (!trimmedToolName) {
61
+ return;
62
+ }
63
+
64
+ for (const serverName of configuredServerNames) {
65
+ const trimmedServerName = serverName.trim();
66
+ if (!trimmedServerName) {
67
+ continue;
68
+ }
69
+
70
+ if (!trimmedToolName.endsWith(`_${trimmedServerName}`)) {
71
+ continue;
72
+ }
73
+
74
+ if (trimmedToolName.startsWith(`${trimmedServerName}_`)) {
75
+ continue;
76
+ }
77
+
78
+ targets.add(`${trimmedServerName}_${trimmedToolName}`);
79
+ targets.add(`${trimmedServerName}:${trimmedToolName}`);
80
+ targets.add(trimmedServerName);
81
+ }
82
+ }
83
+
84
+ function pushMcpToolPermissionTargets(
85
+ rawReference: string,
86
+ serverHint: string | null,
87
+ configuredServerNames: readonly string[],
88
+ targets: McpTargetList,
89
+ ): void {
90
+ const qualified = parseQualifiedMcpToolName(rawReference);
91
+ const resolvedServer = serverHint ?? qualified?.server ?? null;
92
+ const resolvedTool = qualified?.tool ?? rawReference;
93
+
94
+ if (resolvedServer) {
95
+ targets.add(`${resolvedServer}_${resolvedTool}`);
96
+ targets.add(`${resolvedServer}:${resolvedTool}`);
97
+ targets.add(resolvedServer);
98
+ } else {
99
+ addDerivedMcpServerTargets(resolvedTool, configuredServerNames, targets);
100
+ }
101
+
102
+ targets.add(resolvedTool);
103
+ targets.add(rawReference);
104
+ }
105
+
106
+ /**
107
+ * Derive the ordered list of MCP permission-lookup candidates from a raw MCP
108
+ * tool invocation input.
109
+ *
110
+ * Candidates are ordered from most-specific to least-specific so that
111
+ * `evaluateFirst()` stops at the first non-default match.
112
+ */
113
+ export function createMcpPermissionTargets(
114
+ input: unknown,
115
+ configuredServerNames: readonly string[] = [],
116
+ ): string[] {
117
+ const record = toRecord(input);
118
+ const tool = getNonEmptyString(record.tool);
119
+ const server = getNonEmptyString(record.server);
120
+ const connect = getNonEmptyString(record.connect);
121
+ const describe = getNonEmptyString(record.describe);
122
+ const search = getNonEmptyString(record.search);
123
+
124
+ const targets = new McpTargetList();
125
+
126
+ if (tool) {
127
+ pushMcpToolPermissionTargets(tool, server, configuredServerNames, targets);
128
+ targets.add("mcp_call");
129
+ return targets.toArray();
130
+ }
131
+
132
+ if (connect) {
133
+ targets.add(`mcp_connect_${connect}`);
134
+ targets.add(connect);
135
+ targets.add("mcp_connect");
136
+ return targets.toArray();
137
+ }
138
+
139
+ if (describe) {
140
+ pushMcpToolPermissionTargets(
141
+ describe,
142
+ server,
143
+ configuredServerNames,
144
+ targets,
145
+ );
146
+ targets.add("mcp_describe");
147
+ return targets.toArray();
148
+ }
149
+
150
+ if (search) {
151
+ if (server) {
152
+ targets.add(`mcp_server_${server}`);
153
+ targets.add(server);
154
+ }
155
+
156
+ targets.add(search);
157
+ targets.add("mcp_search");
158
+ return targets.toArray();
159
+ }
160
+
161
+ if (server) {
162
+ targets.add(`mcp_server_${server}`);
163
+ targets.add(server);
164
+ targets.add("mcp_list");
165
+ return targets.toArray();
166
+ }
167
+
168
+ targets.add("mcp_status");
169
+ return targets.toArray();
170
+ }
@@ -0,0 +1,76 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Walk up the directory tree from the given file URL until a directory
8
+ * literally named `node_modules` is found.
9
+ *
10
+ * Returns the `node_modules` path, or `null` if the URL cannot be parsed or
11
+ * no `node_modules` ancestor exists.
12
+ */
13
+ function walkUpToNodeModules(fromUrl: string): string | null {
14
+ try {
15
+ const thisFile = fileURLToPath(fromUrl);
16
+ let dir = dirname(thisFile);
17
+ while (dir !== dirname(dir)) {
18
+ if (basename(dir) === "node_modules") {
19
+ return dir;
20
+ }
21
+ dir = dirname(dir);
22
+ }
23
+ return null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Run `npm root -g` synchronously and return the trimmed output, or `null` on
31
+ * any failure (non-zero exit, ENOENT, timeout, non-existent path).
32
+ *
33
+ * Only called when the walk-up-from-self strategy fails (i.e. the extension is
34
+ * running from a local development checkout, not a global install).
35
+ */
36
+ function discoverGlobalNodeModulesViaSubprocess(): string | null {
37
+ try {
38
+ const result = spawnSync("npm", ["root", "-g"], {
39
+ encoding: "utf-8",
40
+ timeout: 5000,
41
+ stdio: ["ignore", "pipe", "ignore"],
42
+ });
43
+ const root = result.stdout.trim();
44
+ if (result.status === 0 && root && existsSync(root)) {
45
+ return root;
46
+ }
47
+ return null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Discover the global node_modules root.
55
+ *
56
+ * Strategy 1 (zero-cost, covers all global installs): walk up from
57
+ * `fromUrl` (defaults to this module's own `import.meta.url`) looking for a
58
+ * directory named `node_modules`. This works whenever the extension is
59
+ * installed inside a `node_modules` tree.
60
+ *
61
+ * Strategy 2 (subprocess fallback, dev checkout only): when Strategy 1 fails
62
+ * because the extension is running from a local development checkout with no
63
+ * `node_modules` ancestor, run `npm root -g` to discover the global root.
64
+ * Pi installs skills and extensions via `npm` by default, so `npm root -g`
65
+ * returns the correct root regardless of the user's own project package
66
+ * manager.
67
+ *
68
+ * Returns `null` when both strategies fail — callers must degrade gracefully.
69
+ */
70
+ export function discoverGlobalNodeModulesRoot(
71
+ fromUrl = import.meta.url,
72
+ ): string | null {
73
+ const fromSelf = walkUpToNodeModules(fromUrl);
74
+ if (fromSelf) return fromSelf;
75
+ return discoverGlobalNodeModulesViaSubprocess();
76
+ }
@@ -0,0 +1,43 @@
1
+ import { isDenyWithReason, isPermissionState } from "./common";
2
+ import type { Rule, Ruleset } from "./rule";
3
+ import type { FlatPermissionConfig } from "./types";
4
+
5
+ /**
6
+ * Convert a flat permission config into a Ruleset.
7
+ *
8
+ * Each key is a surface name. A string value is shorthand for
9
+ * `{ "*": action }`. An object value maps patterns to actions.
10
+ * A pattern value may be a PermissionState string or a `DenyWithReason`
11
+ * object (`{ action: "deny", reason?: string }`).
12
+ * Invalid action values are silently skipped.
13
+ *
14
+ * The universal fallback key `"*"` is included if present — callers
15
+ * that use `"*"` only for `synthesizeDefaults()` should strip it before
16
+ * calling this function.
17
+ */
18
+ export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
19
+ const rules: Rule[] = [];
20
+ for (const [surface, value] of Object.entries(permission)) {
21
+ if (typeof value === "string") {
22
+ if (isPermissionState(value)) {
23
+ rules.push({ surface, pattern: "*", action: value, origin: "builtin" });
24
+ }
25
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check; value type does not include null but runtime JSON may
26
+ } else if (typeof value === "object" && value !== null) {
27
+ for (const [pattern, action] of Object.entries(value)) {
28
+ if (isDenyWithReason(action)) {
29
+ rules.push({
30
+ surface,
31
+ pattern,
32
+ action: "deny",
33
+ reason: action.reason,
34
+ origin: "builtin",
35
+ });
36
+ } else if (isPermissionState(action)) {
37
+ rules.push({ surface, pattern, action, origin: "builtin" });
38
+ }
39
+ }
40
+ }
41
+ }
42
+ return rules;
43
+ }