@gotgenes/pi-permission-system 36.2.1 → 38.0.0

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.
@@ -22,25 +22,57 @@ import { PATH_BEARING_TOOLS } from "./path-surfaces";
22
22
  * (`docs/decisions/0002-path-values-string-boundary.md`).
23
23
  *
24
24
  * - `bash` — its own token-based path gates; extraction product is the command.
25
- * - `mcp` — extraction product is the qualified target.
25
+ * - `mcp` — the `mcp` proxy tool (e.g. pi-mcp-adapter), whose input names the
26
+ * MCP call (`{ tool, server, args }`); extraction product is the qualified
27
+ * target.
28
+ * - `mcp-tool` — one MCP server tool registered under its own name,
29
+ * `mcp__<server>__<tool>` (Pi's built-in MCP); its input is the MCP
30
+ * arguments themselves, so it shares the extension tools' input shape while
31
+ * resolving on the `mcp` surface.
26
32
  * - `skill` — a distinct surface `normalizeInput`/`deriveSource` treat specially.
27
33
  * - `path` — a path-bearing built-in (`read`/`write`/`edit`/`grep`/`find`/`ls`);
28
34
  * extraction product is `input.path`.
29
35
  * - `extension` — every other tool, plus the `external_directory`/`path` special
30
36
  * surfaces that reach `deriveSource` as normalized names.
31
37
  */
32
- export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension";
38
+ export type ToolKind =
39
+ | "bash"
40
+ | "mcp"
41
+ | "mcp-tool"
42
+ | "skill"
43
+ | "path"
44
+ | "extension";
33
45
 
34
46
  /** Classify a tool name into its {@link ToolKind}. */
35
47
  export function classifyToolKind(toolName: string): ToolKind {
36
48
  const name = toolName.trim();
37
49
  if (name === "bash") return "bash";
38
50
  if (name === "mcp") return "mcp";
51
+ if (isPiMcpToolName(name)) return "mcp-tool";
39
52
  if (name === "skill") return "skill";
40
53
  if (PATH_BEARING_TOOLS.has(name)) return "path";
41
54
  return "extension";
42
55
  }
43
56
 
57
+ /** The prefix Pi's built-in MCP gives every server tool it registers. */
58
+ export const PI_MCP_TOOL_PREFIX = "mcp__";
59
+
60
+ /**
61
+ * True when a tool name has the shape Pi's built-in MCP registers a server
62
+ * tool under: `mcp__<server>__<tool>`, with non-empty server and tool parts.
63
+ *
64
+ * Pi builds the name with every character outside `[A-Za-z0-9_]` replaced by
65
+ * `_`, and codemode scripts call the tool by the same identifier, so the name
66
+ * itself is the stable contract (`extensions/mcp/tools.ts`,
67
+ * `createMcpToolName`).
68
+ */
69
+ export function isPiMcpToolName(toolName: string): boolean {
70
+ if (!toolName.startsWith(PI_MCP_TOOL_PREFIX)) return false;
71
+ const rest = toolName.slice(PI_MCP_TOOL_PREFIX.length);
72
+ const separator = rest.indexOf("__");
73
+ return separator > 0 && separator + 2 < rest.length;
74
+ }
75
+
44
76
  /** A shell invocation's effective command and optional working directory. */
45
77
  export interface ShellInvocation {
46
78
  /** The shell command string to decompose and gate. */
@@ -101,12 +133,25 @@ interface McpKindFields {
101
133
  }
102
134
 
103
135
  /**
104
- * True when a resolved check concerns an MCP call — either the invoked tool is
105
- * `mcp`, or the winning rule matched on the `mcp` surface (`source`). The
106
- * `source` disjunct is why this cannot reduce to `classifyToolKind(toolName)`:
107
- * `deriveSource` can set `source` to `mcp` on a result whose `toolName` is a
108
- * server-qualified string.
136
+ * True when a resolved check concerns an MCP call — the invoked tool is the
137
+ * `mcp` proxy or a Pi MCP tool, or the winning rule matched on the `mcp`
138
+ * surface (`source`). The `source` disjunct is why this cannot reduce to
139
+ * `classifyToolKind(toolName)`: `deriveSource` can set `source` to `mcp` on a
140
+ * result whose `toolName` is a server-qualified string.
109
141
  */
110
142
  export function isMcpCheck(check: McpKindFields): boolean {
111
- return check.source === "mcp" || classifyToolKind(check.toolName) === "mcp";
143
+ if (check.source === "mcp") return true;
144
+ const kind = classifyToolKind(check.toolName);
145
+ return kind === "mcp" || kind === "mcp-tool";
146
+ }
147
+
148
+ /**
149
+ * True when a check concerns an MCP call whose tool input is a request *about*
150
+ * the call (the proxy's `{ tool, server, args }`) rather than the MCP
151
+ * arguments themselves. Such input is not previewed — the target already
152
+ * names the call — while a Pi MCP tool's arguments are shown like any other
153
+ * tool's input.
154
+ */
155
+ export function isProxyMcpCheck(check: McpKindFields): boolean {
156
+ return isMcpCheck(check) && classifyToolKind(check.toolName) !== "mcp-tool";
112
157
  }
@@ -133,6 +133,12 @@ export interface PolicyLoaderOptions {
133
133
  projectGlobalConfigPath?: string;
134
134
  projectAgentsDir?: string;
135
135
  globalMcpConfigPath?: string;
136
+ /**
137
+ * The project's `.pi/mcp.json`, read beside the global file the way Pi's
138
+ * built-in MCP reads it. Set only for a trusted project: the manager derives
139
+ * it from a cwd, and an untrusted project's cwd is withheld.
140
+ */
141
+ projectMcpConfigPath?: string;
136
142
  mcpServerNames?: readonly string[];
137
143
  }
138
144
 
@@ -150,6 +156,7 @@ export class FilePolicyLoader implements PolicyLoader {
150
156
  private readonly projectGlobalConfigPath: string | null;
151
157
  private readonly projectAgentsDir: string | null;
152
158
  private readonly globalMcpConfigPath: string;
159
+ private readonly projectMcpConfigPath: string | null;
153
160
  private readonly configuredMcpServerNamesOverride: readonly string[] | null;
154
161
 
155
162
  private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
@@ -175,6 +182,7 @@ export class FilePolicyLoader implements PolicyLoader {
175
182
  this.projectAgentsDir = options.projectAgentsDir ?? null;
176
183
  this.globalMcpConfigPath =
177
184
  options.globalMcpConfigPath ?? defaultGlobalMcpConfigPath();
185
+ this.projectMcpConfigPath = options.projectMcpConfigPath ?? null;
178
186
  this.configuredMcpServerNamesOverride = options.mcpServerNames
179
187
  ? [
180
188
  ...new Set(
@@ -316,7 +324,9 @@ export class FilePolicyLoader implements PolicyLoader {
316
324
  return this.configuredMcpServerNamesOverride;
317
325
  }
318
326
 
319
- const paths = [this.globalMcpConfigPath];
327
+ const paths = this.projectMcpConfigPath
328
+ ? [this.globalMcpConfigPath, this.projectMcpConfigPath]
329
+ : [this.globalMcpConfigPath];
320
330
  const stamp = paths
321
331
  .map((path) => `${path}:${getFileStamp(path)}`)
322
332
  .join("|");
@@ -38,8 +38,6 @@ export type SkillPromptEntry = {
38
38
  };
39
39
 
40
40
  export type SkillPromptSection = {
41
- start: number;
42
- end: number;
43
41
  entries: ParsedSkillPromptEntry[];
44
42
  };
45
43
 
@@ -52,15 +50,6 @@ function decodeXml(value: string): string {
52
50
  .replace(/&amp;/g, "&");
53
51
  }
54
52
 
55
- function encodeXml(value: string): string {
56
- return value
57
- .replace(/&/g, "&amp;")
58
- .replace(/</g, "&lt;")
59
- .replace(/>/g, "&gt;")
60
- .replace(/"/g, "&quot;")
61
- .replace(/'/g, "&apos;");
62
- }
63
-
64
53
  function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
65
54
  const entries: ParsedSkillPromptEntry[] = [];
66
55
  const skillBlockRegex = new RegExp(SKILL_BLOCK_PATTERN, "g");
@@ -115,8 +104,6 @@ export function parseAllSkillPromptSections(
115
104
  closeStart,
116
105
  );
117
106
  sections.push({
118
- start,
119
- end,
120
107
  entries: parseSkillEntries(sectionBody),
121
108
  });
122
109
  searchStart = end;
@@ -160,95 +147,56 @@ function createResolvedSkillEntry(
160
147
  };
161
148
  }
162
149
 
163
- function renderAvailableSkillsSection(
164
- entries: readonly SkillPromptEntry[],
165
- ): string {
166
- return [
167
- AVAILABLE_SKILLS_OPEN_TAG,
168
- ...entries.flatMap((entry) => [
169
- " <skill>",
170
- ` <name>${encodeXml(entry.name)}</name>`,
171
- ` <description>${encodeXml(entry.description)}</description>`,
172
- ` <location>${encodeXml(entry.location)}</location>`,
173
- " </skill>",
174
- ]),
175
- AVAILABLE_SKILLS_CLOSE_TAG,
176
- ].join("\n");
177
- }
178
-
179
- function removePromptRange(prompt: string, start: number, end: number): string {
180
- const beforeSection = prompt.slice(0, start).replace(/\n+$/, "");
181
- const afterSection = prompt.slice(end);
182
- return `${beforeSection}${afterSection}`;
183
- }
184
-
185
- export function resolveSkillPromptEntries(
150
+ /**
151
+ * The skills listed in the prompt's `<available_skills>` catalogues that
152
+ * policy does not deny, in catalogue order: what skill path matching may
153
+ * treat as a skill's files. Edits no prompt text.
154
+ */
155
+ export function visibleSkillPromptEntries(
186
156
  prompt: string,
187
157
  permissionManager: SkillPermissionChecker,
188
158
  agentName: string | null,
189
159
  normalizer: PathNormalizer,
190
- ): { prompt: string; entries: SkillPromptEntry[] } {
191
- const sections = parseAllSkillPromptSections(prompt);
192
- if (sections.length === 0) {
193
- return { prompt, entries: [] };
194
- }
195
-
160
+ ): SkillPromptEntry[] {
196
161
  const permissionCache = new Map<string, PermissionState>();
197
- const visibleEntries: SkillPromptEntry[] = [];
198
- const replacements: Array<{ start: number; end: number; content: string }> =
199
- [];
162
+ return parseAllSkillPromptSections(prompt)
163
+ .flatMap((section) => section.entries)
164
+ .map((entry) =>
165
+ createResolvedSkillEntry(
166
+ entry,
167
+ resolvePermissionState(
168
+ entry.name,
169
+ permissionManager,
170
+ agentName,
171
+ permissionCache,
172
+ ),
173
+ normalizer,
174
+ ),
175
+ )
176
+ .filter((entry) => entry.state !== "deny");
177
+ }
200
178
 
201
- for (const section of sections) {
202
- const resolvedEntries = section.entries.map((entry) => {
203
- const state = resolvePermissionState(
204
- entry.name,
179
+ /**
180
+ * The skills policy does not deny, judged by name, in their original order.
181
+ *
182
+ * Judging the skill list itself, rather than a catalogue rendered from it,
183
+ * keeps the answer independent of whether that catalogue has been rendered yet.
184
+ */
185
+ export function withoutDeniedSkills<T extends { readonly name: string }>(
186
+ skills: readonly T[],
187
+ permissionManager: SkillPermissionChecker,
188
+ agentName: string | null,
189
+ ): T[] {
190
+ const permissionCache = new Map<string, PermissionState>();
191
+ return skills.filter(
192
+ (skill) =>
193
+ resolvePermissionState(
194
+ skill.name,
205
195
  permissionManager,
206
196
  agentName,
207
197
  permissionCache,
208
- );
209
- return createResolvedSkillEntry(entry, state, normalizer);
210
- });
211
-
212
- const visibleSectionEntries = resolvedEntries.filter(
213
- (entry) => entry.state !== "deny",
214
- );
215
- visibleEntries.push(...visibleSectionEntries);
216
-
217
- if (visibleSectionEntries.length === resolvedEntries.length) {
218
- continue;
219
- }
220
-
221
- replacements.push({
222
- start: section.start,
223
- end: section.end,
224
- content:
225
- visibleSectionEntries.length > 0
226
- ? renderAvailableSkillsSection(visibleSectionEntries)
227
- : "",
228
- });
229
- }
230
-
231
- if (replacements.length === 0) {
232
- return { prompt, entries: visibleEntries };
233
- }
234
-
235
- let sanitizedPrompt = prompt;
236
- for (let i = replacements.length - 1; i >= 0; i--) {
237
- const replacement = replacements[i];
238
- sanitizedPrompt =
239
- replacement.content.length > 0
240
- ? `${sanitizedPrompt.slice(0, replacement.start)}${replacement.content}${sanitizedPrompt.slice(replacement.end)}`
241
- : removePromptRange(
242
- sanitizedPrompt,
243
- replacement.start,
244
- replacement.end,
245
- );
246
- }
247
-
248
- return {
249
- prompt: sanitizedPrompt,
250
- entries: visibleEntries,
251
- };
198
+ ) !== "deny",
199
+ );
252
200
  }
253
201
 
254
202
  export function findSkillPathMatch(