@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.
- package/CHANGELOG.md +33 -0
- package/README.md +7 -1
- package/docs/configuration.md +53 -24
- package/docs/migration/1001-pi-mcp-tools-on-mcp-surface.md +91 -0
- package/package.json +5 -5
- package/src/access-intent/access-intent.ts +4 -2
- package/src/access-intent/input-normalizer.ts +51 -24
- package/src/access-intent/mcp-targets.ts +71 -0
- package/src/access-intent/tool-input-path.ts +1 -0
- package/src/access-intent/tool-kind.ts +53 -8
- package/src/config/policy-loader.ts +11 -1
- package/src/exposure/skill-prompt-sanitizer.ts +41 -93
- package/src/exposure/tool-surface-prompt.ts +34 -430
- package/src/handlers/before-agent-start.ts +64 -50
- package/src/handlers/gates/helpers.ts +1 -0
- package/src/handlers/gates/tool.ts +43 -9
- package/src/policy/normalize.ts +47 -0
- package/src/policy/permission-manager.ts +89 -25
- package/src/presentation/pattern-suggest.ts +12 -5
- package/src/presentation/permission-prompts.ts +2 -1
- package/src/presentation/tool-ask-payload.ts +11 -6
- package/src/service/permissions-service.ts +5 -2
- package/src/tool-input/tool-preview-formatter.ts +8 -2
|
@@ -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` —
|
|
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 =
|
|
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 —
|
|
105
|
-
* `mcp
|
|
106
|
-
* `source` disjunct is why this cannot reduce to
|
|
107
|
-
* `deriveSource` can set `source` to `mcp` on 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
|
-
|
|
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 =
|
|
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(/&/g, "&");
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
function encodeXml(value: string): string {
|
|
56
|
-
return value
|
|
57
|
-
.replace(/&/g, "&")
|
|
58
|
-
.replace(/</g, "<")
|
|
59
|
-
.replace(/>/g, ">")
|
|
60
|
-
.replace(/"/g, """)
|
|
61
|
-
.replace(/'/g, "'");
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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(
|