@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.
- package/CHANGELOG.md +2234 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/config/config.example.json +39 -0
- package/package.json +82 -0
- package/schemas/permissions.schema.json +158 -0
- package/src/active-agent.ts +72 -0
- package/src/async-cache.ts +21 -0
- package/src/bash-arity.ts +210 -0
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/canonicalize-path.ts +30 -0
- package/src/common.ts +121 -0
- package/src/config-loader.ts +432 -0
- package/src/config-modal.ts +259 -0
- package/src/config-paths.ts +47 -0
- package/src/config-reporter.ts +34 -0
- package/src/config-store.ts +222 -0
- package/src/decision-audit.ts +75 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +232 -0
- package/src/expand-home.ts +28 -0
- package/src/extension-config.ts +79 -0
- package/src/extension-paths.ts +66 -0
- package/src/forwarded-permissions/io.ts +404 -0
- package/src/forwarded-permissions/permission-forwarder.ts +580 -0
- package/src/forwarding-manager.ts +74 -0
- package/src/gate-prompter.ts +12 -0
- package/src/handlers/before-agent-start.ts +94 -0
- package/src/handlers/gates/bash-command.ts +75 -0
- package/src/handlers/gates/bash-external-directory.ts +127 -0
- package/src/handlers/gates/bash-path-extractor.ts +15 -0
- package/src/handlers/gates/bash-path.ts +152 -0
- package/src/handlers/gates/bash-program.ts +1143 -0
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/gates/descriptor.ts +81 -0
- package/src/handlers/gates/external-directory-messages.ts +20 -0
- package/src/handlers/gates/external-directory.ts +133 -0
- package/src/handlers/gates/helpers.ts +76 -0
- package/src/handlers/gates/path.ts +91 -0
- package/src/handlers/gates/runner.ts +186 -0
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +46 -0
- package/src/handlers/gates/skill-read.ts +87 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
- package/src/handlers/gates/tool.ts +102 -0
- package/src/handlers/gates/types.ts +13 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/lifecycle.ts +95 -0
- package/src/handlers/permission-gate-handler.ts +190 -0
- package/src/handlers/tool-call-boundary.ts +91 -0
- package/src/index.ts +225 -0
- package/src/input-normalizer.ts +157 -0
- package/src/logging.ts +113 -0
- package/src/mcp-targets.ts +170 -0
- package/src/node-modules-discovery.ts +76 -0
- package/src/normalize.ts +43 -0
- package/src/path-utils.ts +355 -0
- package/src/pattern-suggest.ts +132 -0
- package/src/permission-dialog.ts +138 -0
- package/src/permission-event-rpc.ts +223 -0
- package/src/permission-events.ts +266 -0
- package/src/permission-forwarding.ts +188 -0
- package/src/permission-gate.ts +94 -0
- package/src/permission-manager.ts +392 -0
- package/src/permission-merge.ts +32 -0
- package/src/permission-prompter.ts +142 -0
- package/src/permission-prompts.ts +93 -0
- package/src/permission-resolver.ts +109 -0
- package/src/permission-session.ts +189 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +63 -0
- package/src/persistent-approval-recorder.ts +139 -0
- package/src/policy-loader.ts +350 -0
- package/src/prompting-gateway.ts +104 -0
- package/src/rule.ts +188 -0
- package/src/scope-merge.ts +72 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +163 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-approval.ts +43 -0
- package/src/session-logger.ts +91 -0
- package/src/session-rules.ts +79 -0
- package/src/skill-prompt-sanitizer.ts +292 -0
- package/src/status.ts +35 -0
- package/src/subagent-context.ts +104 -0
- package/src/subagent-lifecycle-events.ts +72 -0
- package/src/subagent-registry.ts +105 -0
- package/src/synthesize.ts +92 -0
- package/src/system-prompt-sanitizer.ts +274 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/src/tool-input-formatter-registry.ts +67 -0
- package/src/tool-input-preview.ts +34 -0
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +207 -0
- package/src/tool-registry.ts +148 -0
- package/src/types.ts +64 -0
- package/src/wildcard-matcher.ts +120 -0
- package/src/yolo-mode.ts +30 -0
- package/test/active-agent.test.ts +155 -0
- package/test/async-cache.test.ts +48 -0
- package/test/bash-arity.test.ts +144 -0
- package/test/bash-external-directory.test.ts +956 -0
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/canonicalize-path.test.ts +93 -0
- package/test/common.test.ts +287 -0
- package/test/composition-root.test.ts +603 -0
- package/test/config-loader.test.ts +740 -0
- package/test/config-modal.test.ts +320 -0
- package/test/config-paths.test.ts +83 -0
- package/test/config-pipeline.test.ts +90 -0
- package/test/config-reporter.test.ts +147 -0
- package/test/config-store.test.ts +466 -0
- package/test/decision-audit.test.ts +72 -0
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +656 -0
- package/test/detect-permissive-bash-fallback.test.ts +56 -0
- package/test/expand-home.test.ts +93 -0
- package/test/extension-config.test.ts +129 -0
- package/test/extension-paths.test.ts +108 -0
- package/test/forwarded-permissions/io.test.ts +251 -0
- package/test/forwarding-manager.test.ts +194 -0
- package/test/handlers/before-agent-start.test.ts +317 -0
- package/test/handlers/external-directory-integration.test.ts +623 -0
- package/test/handlers/external-directory-session-dedup.test.ts +430 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
- package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
- package/test/handlers/gates/bash-command.test.ts +191 -0
- package/test/handlers/gates/bash-external-directory.test.ts +269 -0
- package/test/handlers/gates/bash-path.test.ts +337 -0
- package/test/handlers/gates/bash-program.test.ts +410 -0
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/gates/external-directory-messages.test.ts +61 -0
- package/test/handlers/gates/external-directory.test.ts +259 -0
- package/test/handlers/gates/helpers.test.ts +177 -0
- package/test/handlers/gates/path.test.ts +294 -0
- package/test/handlers/gates/runner.test.ts +447 -0
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +131 -0
- package/test/handlers/gates/skill-read.test.ts +158 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
- package/test/handlers/gates/tool.test.ts +223 -0
- package/test/handlers/input-events.test.ts +168 -0
- package/test/handlers/input.test.ts +199 -0
- package/test/handlers/lifecycle.test.ts +221 -0
- package/test/handlers/tool-call-boundary.test.ts +145 -0
- package/test/handlers/tool-call-events.test.ts +277 -0
- package/test/handlers/tool-call.test.ts +395 -0
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/helpers/gate-fixtures.ts +323 -0
- package/test/helpers/handler-fixtures.ts +335 -0
- package/test/helpers/make-fake-pi.ts +100 -0
- package/test/helpers/manager-harness.ts +112 -0
- package/test/helpers/session-fixtures.ts +204 -0
- package/test/input-normalizer.test.ts +367 -0
- package/test/logging.test.ts +51 -0
- package/test/mcp-targets.test.ts +233 -0
- package/test/node-modules-discovery.test.ts +97 -0
- package/test/normalize.test.ts +247 -0
- package/test/path-utils.test.ts +650 -0
- package/test/pattern-suggest.test.ts +248 -0
- package/test/permission-dialog.test.ts +241 -0
- package/test/permission-event-rpc.test.ts +541 -0
- package/test/permission-events.test.ts +402 -0
- package/test/permission-forwarder.test.ts +369 -0
- package/test/permission-forwarding.test.ts +315 -0
- package/test/permission-gate.test.ts +305 -0
- package/test/permission-manager-unified.test.ts +3368 -0
- package/test/permission-merge.test.ts +61 -0
- package/test/permission-prompter.test.ts +518 -0
- package/test/permission-prompts.test.ts +363 -0
- package/test/permission-resolver.test.ts +265 -0
- package/test/permission-session.test.ts +363 -0
- package/test/permission-ui-prompt.test.ts +146 -0
- package/test/permissions-service.test.ts +177 -0
- package/test/persistent-approval-recorder.test.ts +133 -0
- package/test/pi-infrastructure-read.test.ts +369 -0
- package/test/policy-loader.test.ts +561 -0
- package/test/prompting-gateway.test.ts +230 -0
- package/test/rule.test.ts +604 -0
- package/test/scope-merge.test.ts +116 -0
- package/test/service-lifecycle.test.ts +163 -0
- package/test/service.test.ts +308 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-logger.test.ts +200 -0
- package/test/session-rules.test.ts +304 -0
- package/test/session-start.test.ts +112 -0
- package/test/skill-prompt-sanitizer.test.ts +374 -0
- package/test/status.test.ts +10 -0
- package/test/subagent-context.test.ts +326 -0
- package/test/subagent-lifecycle-events.test.ts +132 -0
- package/test/subagent-registry.test.ts +145 -0
- package/test/synthesize.test.ts +300 -0
- package/test/system-prompt-sanitizer.test.ts +382 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-input-preview.test.ts +129 -0
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/test/tool-preview-formatter.test.ts +458 -0
- package/test/tool-registry.test.ts +197 -0
- package/test/wildcard-matcher.test.ts +424 -0
- package/test/yolo-mode.test.ts +188 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
export interface SanitizeSystemPromptResult {
|
|
2
|
+
prompt: string;
|
|
3
|
+
removed: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
type LineSection = {
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type GuidelineRule = {
|
|
12
|
+
matches: (guideline: string) => boolean;
|
|
13
|
+
shouldKeep: (allowedTools: ReadonlySet<string>) => boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const AVAILABLE_TOOLS_SECTION_HEADER = "Available tools:";
|
|
17
|
+
const GUIDELINES_SECTION_HEADER = "Guidelines:";
|
|
18
|
+
|
|
19
|
+
const TOOL_GUIDELINE_RULES: readonly GuidelineRule[] = [
|
|
20
|
+
{
|
|
21
|
+
matches: (guideline) =>
|
|
22
|
+
guideline === "use bash for file operations like ls, rg, find",
|
|
23
|
+
shouldKeep: (allowedTools) => allowedTools.has("bash"),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
matches: (guideline) =>
|
|
27
|
+
guideline ===
|
|
28
|
+
"prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)",
|
|
29
|
+
shouldKeep: (allowedTools) =>
|
|
30
|
+
allowedTools.has("bash") &&
|
|
31
|
+
(allowedTools.has("grep") ||
|
|
32
|
+
allowedTools.has("find") ||
|
|
33
|
+
allowedTools.has("ls")),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
matches: (guideline) =>
|
|
37
|
+
guideline ===
|
|
38
|
+
"use read to examine files before editing. you must use this tool instead of cat or sed." ||
|
|
39
|
+
guideline === "use read to examine files instead of cat or sed.",
|
|
40
|
+
shouldKeep: (allowedTools) => allowedTools.has("read"),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
matches: (guideline) =>
|
|
44
|
+
guideline ===
|
|
45
|
+
"use edit for precise changes (old text must match exactly)",
|
|
46
|
+
shouldKeep: (allowedTools) => allowedTools.has("edit"),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
matches: (guideline) =>
|
|
50
|
+
guideline === "use write only for new files or complete rewrites",
|
|
51
|
+
shouldKeep: (allowedTools) => allowedTools.has("write"),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
matches: (guideline) =>
|
|
55
|
+
guideline ===
|
|
56
|
+
"when summarizing your actions, output plain text directly - do not use cat or bash to display what you did",
|
|
57
|
+
shouldKeep: (allowedTools) =>
|
|
58
|
+
allowedTools.has("edit") || allowedTools.has("write"),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
matches: (guideline) =>
|
|
62
|
+
guideline ===
|
|
63
|
+
"use task when work should be delegated to one or more specialized agents instead of handled entirely in the current session.",
|
|
64
|
+
shouldKeep: (allowedTools) => allowedTools.has("task"),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
matches: (guideline) =>
|
|
68
|
+
guideline ===
|
|
69
|
+
"use mcp for mcp discovery first: search by capability, describe one exact tool name, then call it.",
|
|
70
|
+
shouldKeep: (allowedTools) => allowedTools.has("mcp"),
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
function normalizePrompt(prompt: string): string {
|
|
75
|
+
return (prompt || "").replace(/\r\n/g, "\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function collapseExtraBlankLines(text: string): string {
|
|
79
|
+
return text.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeGuidelineText(line: string): string {
|
|
83
|
+
return line
|
|
84
|
+
.trim()
|
|
85
|
+
.replace(/^[-*]\s+/, "")
|
|
86
|
+
.replace(/\s+/g, " ")
|
|
87
|
+
.toLowerCase();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isTopLevelSectionHeader(line: string): boolean {
|
|
91
|
+
const trimmed = line.trim();
|
|
92
|
+
return (
|
|
93
|
+
trimmed.length > 0 && trimmed.endsWith(":") && !trimmed.startsWith("-")
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isSectionBodyLine(line: string): boolean {
|
|
98
|
+
const trimmed = line.trim();
|
|
99
|
+
if (trimmed.length === 0) return true; // blank line
|
|
100
|
+
if (trimmed.startsWith("- ")) return true; // bullet
|
|
101
|
+
if (line !== line.trimStart()) return true; // indented
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function findSection(
|
|
106
|
+
lines: readonly string[],
|
|
107
|
+
header: string,
|
|
108
|
+
): LineSection | null {
|
|
109
|
+
const start = lines.findIndex((line) => line.trim() === header);
|
|
110
|
+
if (start === -1) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// If a subsequent recognised section header exists, use it as the boundary.
|
|
115
|
+
// This preserves the original behaviour for the common case where sections
|
|
116
|
+
// are adjacent (e.g. "Available tools:" followed by "Guidelines:") and
|
|
117
|
+
// ensures any prose continuation between the two headers is also removed.
|
|
118
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
119
|
+
if (isTopLevelSectionHeader(lines[index])) {
|
|
120
|
+
return { start, end: index };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// No subsequent section header — stop at the first non-body line so that
|
|
125
|
+
// content after the section (e.g. custom user notes) is not silently deleted.
|
|
126
|
+
let end = start + 1;
|
|
127
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
128
|
+
if (!isSectionBodyLine(lines[index])) {
|
|
129
|
+
end = index;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
end = index + 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { start, end };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Tool name from an `Available tools:` bullet (`- read: …` -> `read`), or
|
|
140
|
+
* `null` for non-tool lines (blank lines, boilerplate prose). Matches the
|
|
141
|
+
* first token after the bullet marker, with or without a trailing colon.
|
|
142
|
+
*/
|
|
143
|
+
function extractToolBulletName(line: string): string | null {
|
|
144
|
+
const match = /^\s*-\s+([A-Za-z0-9_-]+)/.exec(line);
|
|
145
|
+
return match ? match[1] : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Narrow the `Available tools:` section to the allowed tools: keep allowed-tool
|
|
150
|
+
* bullet lines and any non-tool prose, drop denied/inactive bullet lines. When
|
|
151
|
+
* no tool bullet survives, remove the section header too. This mirrors what Pi
|
|
152
|
+
* itself renders for the active tool set, so the result is byte-stable across
|
|
153
|
+
* turns regardless of whether the input still carries the full default listing.
|
|
154
|
+
*/
|
|
155
|
+
function narrowAvailableToolsSection(
|
|
156
|
+
lines: readonly string[],
|
|
157
|
+
allowedTools: ReadonlySet<string>,
|
|
158
|
+
): { lines: string[]; removed: boolean } {
|
|
159
|
+
const section = findSection(lines, AVAILABLE_TOOLS_SECTION_HEADER);
|
|
160
|
+
if (!section) {
|
|
161
|
+
return { lines: [...lines], removed: false };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const before = lines.slice(0, section.start);
|
|
165
|
+
const header = lines[section.start];
|
|
166
|
+
const body = lines.slice(section.start + 1, section.end);
|
|
167
|
+
const after = lines.slice(section.end);
|
|
168
|
+
|
|
169
|
+
const filteredBody = body.filter((line) => {
|
|
170
|
+
const toolName = extractToolBulletName(line);
|
|
171
|
+
if (toolName === null) {
|
|
172
|
+
return true; // keep blank lines and non-tool boilerplate
|
|
173
|
+
}
|
|
174
|
+
return allowedTools.has(toolName);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const removed = filteredBody.length !== body.length;
|
|
178
|
+
if (!removed) {
|
|
179
|
+
return { lines: [...lines], removed: false };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const hasToolBullet = filteredBody.some(
|
|
183
|
+
(line) => extractToolBulletName(line) !== null,
|
|
184
|
+
);
|
|
185
|
+
if (!hasToolBullet) {
|
|
186
|
+
return { lines: [...before, ...after], removed: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
lines: [...before, header, ...filteredBody, ...after],
|
|
191
|
+
removed: true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function shouldKeepGuideline(
|
|
196
|
+
line: string,
|
|
197
|
+
allowedTools: ReadonlySet<string>,
|
|
198
|
+
): boolean {
|
|
199
|
+
const normalized = normalizeGuidelineText(line);
|
|
200
|
+
|
|
201
|
+
for (const rule of TOOL_GUIDELINE_RULES) {
|
|
202
|
+
if (rule.matches(normalized)) {
|
|
203
|
+
return rule.shouldKeep(allowedTools);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sanitizeGuidelinesSection(
|
|
211
|
+
lines: readonly string[],
|
|
212
|
+
allowedTools: ReadonlySet<string>,
|
|
213
|
+
): { lines: string[]; removed: boolean } {
|
|
214
|
+
const section = findSection(lines, GUIDELINES_SECTION_HEADER);
|
|
215
|
+
if (!section) {
|
|
216
|
+
return { lines: [...lines], removed: false };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const before = lines.slice(0, section.start + 1);
|
|
220
|
+
const after = lines.slice(section.end);
|
|
221
|
+
const body = lines.slice(section.start + 1, section.end);
|
|
222
|
+
const filteredBody = body.filter((line) => {
|
|
223
|
+
const trimmed = line.trim();
|
|
224
|
+
if (!trimmed.startsWith("- ")) {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return shouldKeepGuideline(line, allowedTools);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const removed = filteredBody.length !== body.length;
|
|
232
|
+
if (!removed) {
|
|
233
|
+
return { lines: [...lines], removed: false };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const hasBullet = filteredBody.some((line) => line.trim().startsWith("- "));
|
|
237
|
+
if (!hasBullet) {
|
|
238
|
+
return {
|
|
239
|
+
lines: [...lines.slice(0, section.start), ...after],
|
|
240
|
+
removed: true,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
lines: [...before, ...filteredBody, ...after],
|
|
246
|
+
removed: true,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function sanitizeAvailableToolsSection(
|
|
251
|
+
systemPrompt: string,
|
|
252
|
+
allowedToolNames: readonly string[],
|
|
253
|
+
): SanitizeSystemPromptResult {
|
|
254
|
+
const allowedTools = new Set(
|
|
255
|
+
allowedToolNames.map((toolName) => toolName.trim()).filter(Boolean),
|
|
256
|
+
);
|
|
257
|
+
const normalizedLines = normalizePrompt(systemPrompt).split("\n");
|
|
258
|
+
const narrowedToolsSection = narrowAvailableToolsSection(
|
|
259
|
+
normalizedLines,
|
|
260
|
+
allowedTools,
|
|
261
|
+
);
|
|
262
|
+
const sanitizedGuidelines = sanitizeGuidelinesSection(
|
|
263
|
+
narrowedToolsSection.lines,
|
|
264
|
+
allowedTools,
|
|
265
|
+
);
|
|
266
|
+
const removed = narrowedToolsSection.removed || sanitizedGuidelines.removed;
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
prompt: removed
|
|
270
|
+
? collapseExtraBlankLines(sanitizedGuidelines.lines.join("\n"))
|
|
271
|
+
: systemPrompt,
|
|
272
|
+
removed,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry for custom tool access-intent extractors.
|
|
3
|
+
*
|
|
4
|
+
* Lets sibling extensions declare the filesystem path a tool will access when
|
|
5
|
+
* the tool's input shape is not the default `input.path` convention, so the
|
|
6
|
+
* cross-cutting `path` and `external_directory` gates can see it.
|
|
7
|
+
* One extractor per tool name; duplicate registration throws.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Returns the filesystem path this tool will access, or `undefined` to decline. */
|
|
11
|
+
export type ToolAccessExtractor = (
|
|
12
|
+
input: Record<string, unknown>,
|
|
13
|
+
) => string | undefined;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read-only lookup used by the gate pipeline (ISP — exposes only the read
|
|
17
|
+
* side, not the registration surface).
|
|
18
|
+
*/
|
|
19
|
+
export interface ToolAccessExtractorLookup {
|
|
20
|
+
get(toolName: string): ToolAccessExtractor | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Registration side of the extractor registry (ISP — exposes only the write
|
|
25
|
+
* surface, mirroring the read-only {@link ToolAccessExtractorLookup}).
|
|
26
|
+
*/
|
|
27
|
+
export interface ToolAccessExtractorRegistrar {
|
|
28
|
+
register(toolName: string, extractor: ToolAccessExtractor): () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Persistent registry mapping tool names to custom access-intent extractors.
|
|
33
|
+
*
|
|
34
|
+
* Owned by the extension factory (`index.ts`) so it survives across the
|
|
35
|
+
* per-tool-call gate evaluation cycle.
|
|
36
|
+
* Exposed to sibling extensions via `PermissionsService.registerToolAccessExtractor`.
|
|
37
|
+
*/
|
|
38
|
+
export class ToolAccessExtractorRegistry
|
|
39
|
+
implements ToolAccessExtractorLookup, ToolAccessExtractorRegistrar
|
|
40
|
+
{
|
|
41
|
+
private readonly extractors = new Map<string, ToolAccessExtractor>();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register an extractor for `toolName`.
|
|
45
|
+
*
|
|
46
|
+
* Throws if an extractor is already registered for that name — keeps
|
|
47
|
+
* resolution deterministic (a pi-permission-system package priority).
|
|
48
|
+
* Returns a disposer that removes the extractor; the disposer is
|
|
49
|
+
* identity-guarded so a stale call cannot evict a later registration.
|
|
50
|
+
*/
|
|
51
|
+
register(toolName: string, extractor: ToolAccessExtractor): () => void {
|
|
52
|
+
if (this.extractors.has(toolName)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`A tool access extractor is already registered for '${toolName}'.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
this.extractors.set(toolName, extractor);
|
|
58
|
+
return () => {
|
|
59
|
+
if (this.extractors.get(toolName) === extractor) {
|
|
60
|
+
this.extractors.delete(toolName);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get(toolName: string): ToolAccessExtractor | undefined {
|
|
66
|
+
return this.extractors.get(toolName);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry for custom tool-input preview formatters.
|
|
3
|
+
*
|
|
4
|
+
* Allows extensions to register a formatter for a specific tool name so
|
|
5
|
+
* permission prompts can show a human-readable summary instead of raw JSON.
|
|
6
|
+
* One formatter per tool name; duplicate registration throws.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A custom preview formatter for one tool's input. Returns `undefined` to decline. */
|
|
10
|
+
export type ToolInputFormatter = (
|
|
11
|
+
input: Record<string, unknown>,
|
|
12
|
+
) => string | undefined;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read-only lookup used by `ToolPreviewFormatter` (ISP — exposes only the
|
|
16
|
+
* read side, not the registration surface).
|
|
17
|
+
*/
|
|
18
|
+
export interface ToolInputFormatterLookup {
|
|
19
|
+
get(toolName: string): ToolInputFormatter | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Registration side of the formatter registry (ISP — exposes only the
|
|
24
|
+
* write surface, mirroring the read-only {@link ToolInputFormatterLookup}).
|
|
25
|
+
*/
|
|
26
|
+
export interface ToolInputFormatterRegistrar {
|
|
27
|
+
register(toolName: string, formatter: ToolInputFormatter): () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Persistent registry mapping tool names to custom preview formatters.
|
|
32
|
+
*
|
|
33
|
+
* Owned by the extension factory (`index.ts`) so it survives across the
|
|
34
|
+
* per-tool-call `ToolPreviewFormatter` construction cycle.
|
|
35
|
+
* Exposed to sibling extensions via `PermissionsService.registerToolInputFormatter`.
|
|
36
|
+
*/
|
|
37
|
+
export class ToolInputFormatterRegistry
|
|
38
|
+
implements ToolInputFormatterLookup, ToolInputFormatterRegistrar
|
|
39
|
+
{
|
|
40
|
+
private readonly formatters = new Map<string, ToolInputFormatter>();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Register a formatter for `toolName`.
|
|
44
|
+
*
|
|
45
|
+
* Throws if a formatter is already registered for that name — keeps
|
|
46
|
+
* resolution deterministic (a pi-permission-system package priority).
|
|
47
|
+
* Returns a disposer that removes the formatter; the disposer is
|
|
48
|
+
* identity-guarded so a stale call cannot evict a later registration.
|
|
49
|
+
*/
|
|
50
|
+
register(toolName: string, formatter: ToolInputFormatter): () => void {
|
|
51
|
+
if (this.formatters.has(toolName)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`A tool input formatter is already registered for '${toolName}'.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
this.formatters.set(toolName, formatter);
|
|
57
|
+
return () => {
|
|
58
|
+
if (this.formatters.get(toolName) === formatter) {
|
|
59
|
+
this.formatters.delete(toolName);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get(toolName: string): ToolInputFormatter | undefined {
|
|
65
|
+
return this.formatters.get(toolName);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { safeJsonStringify } from "./logging";
|
|
2
|
+
|
|
3
|
+
export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
|
|
4
|
+
export const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
|
|
5
|
+
export const TOOL_TEXT_SUMMARY_MAX_LENGTH = 80;
|
|
6
|
+
|
|
7
|
+
export function truncateInlineText(value: string, maxLength: number): string {
|
|
8
|
+
return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function countTextLines(value: string): number {
|
|
12
|
+
if (!value) {
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return value.split(/\r\n|\r|\n/).length;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatCount(
|
|
20
|
+
value: number,
|
|
21
|
+
singular: string,
|
|
22
|
+
plural: string,
|
|
23
|
+
): string {
|
|
24
|
+
return `${value} ${value === 1 ? singular : plural}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function serializeToolInputPreview(input: unknown): string {
|
|
28
|
+
const serialized = safeJsonStringify(input);
|
|
29
|
+
if (!serialized || serialized === "{}" || serialized === "null") {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return serialized.replace(/\s+/g, " ").trim();
|
|
34
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getNonEmptyString, toRecord } from "./common";
|
|
2
|
+
import { countTextLines, formatCount } from "./tool-input-preview";
|
|
3
|
+
|
|
4
|
+
export function getPromptPath(input: Record<string, unknown>): string | null {
|
|
5
|
+
return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatEditInputForPrompt(
|
|
9
|
+
input: Record<string, unknown>,
|
|
10
|
+
): string {
|
|
11
|
+
const path = getPromptPath(input);
|
|
12
|
+
const rawEdits = Array.isArray(input.edits)
|
|
13
|
+
? input.edits
|
|
14
|
+
: typeof input.oldText === "string" && typeof input.newText === "string"
|
|
15
|
+
? [{ oldText: input.oldText, newText: input.newText }]
|
|
16
|
+
: [];
|
|
17
|
+
|
|
18
|
+
const edits = rawEdits
|
|
19
|
+
.map((edit) => toRecord(edit))
|
|
20
|
+
.filter(
|
|
21
|
+
(edit) =>
|
|
22
|
+
typeof edit.oldText === "string" && typeof edit.newText === "string",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const pathPart = path ? `for '${path}'` : "";
|
|
26
|
+
if (edits.length === 0) {
|
|
27
|
+
return pathPart ? `${pathPart} with edit input` : "with edit input";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const firstEdit = edits[0];
|
|
31
|
+
const oldText = String(firstEdit.oldText);
|
|
32
|
+
const newText = String(firstEdit.newText);
|
|
33
|
+
const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
|
|
34
|
+
const extraEdits =
|
|
35
|
+
edits.length > 1
|
|
36
|
+
? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
|
|
37
|
+
: "";
|
|
38
|
+
const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
|
|
39
|
+
return pathPart ? `${pathPart} ${summary}` : summary;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatWriteInputForPrompt(
|
|
43
|
+
input: Record<string, unknown>,
|
|
44
|
+
): string {
|
|
45
|
+
const path = getPromptPath(input);
|
|
46
|
+
const content = typeof input.content === "string" ? input.content : "";
|
|
47
|
+
const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
|
|
48
|
+
return path ? `for '${path}' ${summary}` : summary;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatReadInputForPrompt(
|
|
52
|
+
input: Record<string, unknown>,
|
|
53
|
+
): string {
|
|
54
|
+
const path = getPromptPath(input);
|
|
55
|
+
const parts = path ? [`path '${path}'`] : [];
|
|
56
|
+
if (typeof input.offset === "number") {
|
|
57
|
+
parts.push(`offset ${input.offset}`);
|
|
58
|
+
}
|
|
59
|
+
if (typeof input.limit === "number") {
|
|
60
|
+
parts.push(`limit ${input.limit}`);
|
|
61
|
+
}
|
|
62
|
+
return parts.length > 0 ? `for ${parts.join(", ")}` : "";
|
|
63
|
+
}
|