@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,292 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isPathWithinDirectory,
|
|
5
|
+
normalizePathForComparison,
|
|
6
|
+
} from "./path-utils";
|
|
7
|
+
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Narrow interface for the permission checker used by skill prompt resolution.
|
|
11
|
+
* Both `PermissionManager` and `PermissionResolver` satisfy this structurally.
|
|
12
|
+
*/
|
|
13
|
+
export interface SkillPermissionChecker {
|
|
14
|
+
checkPermission(
|
|
15
|
+
surface: string,
|
|
16
|
+
input: unknown,
|
|
17
|
+
agentName?: string,
|
|
18
|
+
): PermissionCheckResult;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const AVAILABLE_SKILLS_OPEN_TAG = "<available_skills>";
|
|
22
|
+
const AVAILABLE_SKILLS_CLOSE_TAG = "</available_skills>";
|
|
23
|
+
const SKILL_BLOCK_PATTERN = "<skill>([\\s\\S]*?)<\\/skill>";
|
|
24
|
+
const SKILL_NAME_REGEX = /<name>([\s\S]*?)<\/name>/;
|
|
25
|
+
const SKILL_DESCRIPTION_REGEX = /<description>([\s\S]*?)<\/description>/;
|
|
26
|
+
const SKILL_LOCATION_REGEX = /<location>([\s\S]*?)<\/location>/;
|
|
27
|
+
|
|
28
|
+
type ParsedSkillPromptEntry = {
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
location: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type SkillPromptEntry = {
|
|
35
|
+
name: string;
|
|
36
|
+
description: string;
|
|
37
|
+
location: string;
|
|
38
|
+
state: PermissionState;
|
|
39
|
+
normalizedLocation: string;
|
|
40
|
+
normalizedBaseDir: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type SkillPromptSection = {
|
|
44
|
+
start: number;
|
|
45
|
+
end: number;
|
|
46
|
+
entries: ParsedSkillPromptEntry[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function decodeXml(value: string): string {
|
|
50
|
+
return value
|
|
51
|
+
.replace(/</g, "<")
|
|
52
|
+
.replace(/>/g, ">")
|
|
53
|
+
.replace(/"/g, '"')
|
|
54
|
+
.replace(/'/g, "'")
|
|
55
|
+
.replace(/&/g, "&");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function encodeXml(value: string): string {
|
|
59
|
+
return value
|
|
60
|
+
.replace(/&/g, "&")
|
|
61
|
+
.replace(/</g, "<")
|
|
62
|
+
.replace(/>/g, ">")
|
|
63
|
+
.replace(/"/g, """)
|
|
64
|
+
.replace(/'/g, "'");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
|
|
68
|
+
const entries: ParsedSkillPromptEntry[] = [];
|
|
69
|
+
const skillBlockRegex = new RegExp(SKILL_BLOCK_PATTERN, "g");
|
|
70
|
+
|
|
71
|
+
for (const match of sectionBody.matchAll(skillBlockRegex)) {
|
|
72
|
+
const block = match[1];
|
|
73
|
+
const nameMatch = SKILL_NAME_REGEX.exec(block);
|
|
74
|
+
const descriptionMatch = SKILL_DESCRIPTION_REGEX.exec(block);
|
|
75
|
+
const locationMatch = SKILL_LOCATION_REGEX.exec(block);
|
|
76
|
+
|
|
77
|
+
if (!nameMatch || !descriptionMatch || !locationMatch) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const name = decodeXml(nameMatch[1].trim());
|
|
82
|
+
const description = decodeXml(descriptionMatch[1].trim());
|
|
83
|
+
const location = decodeXml(locationMatch[1].trim());
|
|
84
|
+
|
|
85
|
+
if (!name || !location) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
entries.push({ name, description, location });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return entries;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseAllSkillPromptSections(
|
|
96
|
+
prompt: string,
|
|
97
|
+
): SkillPromptSection[] {
|
|
98
|
+
const sections: SkillPromptSection[] = [];
|
|
99
|
+
let searchStart = 0;
|
|
100
|
+
|
|
101
|
+
while (searchStart < prompt.length) {
|
|
102
|
+
const start = prompt.indexOf(AVAILABLE_SKILLS_OPEN_TAG, searchStart);
|
|
103
|
+
if (start === -1) {
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const closeStart = prompt.indexOf(
|
|
108
|
+
AVAILABLE_SKILLS_CLOSE_TAG,
|
|
109
|
+
start + AVAILABLE_SKILLS_OPEN_TAG.length,
|
|
110
|
+
);
|
|
111
|
+
if (closeStart === -1) {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const end = closeStart + AVAILABLE_SKILLS_CLOSE_TAG.length;
|
|
116
|
+
const sectionBody = prompt.slice(
|
|
117
|
+
start + AVAILABLE_SKILLS_OPEN_TAG.length,
|
|
118
|
+
closeStart,
|
|
119
|
+
);
|
|
120
|
+
sections.push({
|
|
121
|
+
start,
|
|
122
|
+
end,
|
|
123
|
+
entries: parseSkillEntries(sectionBody),
|
|
124
|
+
});
|
|
125
|
+
searchStart = end;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return sections;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolvePermissionState(
|
|
132
|
+
skillName: string,
|
|
133
|
+
permissionManager: SkillPermissionChecker,
|
|
134
|
+
agentName: string | null,
|
|
135
|
+
cache: Map<string, PermissionState>,
|
|
136
|
+
): PermissionState {
|
|
137
|
+
const cachedState = cache.get(skillName);
|
|
138
|
+
if (cachedState) {
|
|
139
|
+
return cachedState;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const state = permissionManager.checkPermission(
|
|
143
|
+
"skill",
|
|
144
|
+
{ name: skillName },
|
|
145
|
+
agentName ?? undefined,
|
|
146
|
+
).state;
|
|
147
|
+
cache.set(skillName, state);
|
|
148
|
+
return state;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createResolvedSkillEntry(
|
|
152
|
+
entry: ParsedSkillPromptEntry,
|
|
153
|
+
state: PermissionState,
|
|
154
|
+
cwd: string,
|
|
155
|
+
): SkillPromptEntry {
|
|
156
|
+
return {
|
|
157
|
+
name: entry.name,
|
|
158
|
+
description: entry.description,
|
|
159
|
+
location: entry.location,
|
|
160
|
+
state,
|
|
161
|
+
normalizedLocation: normalizePathForComparison(entry.location, cwd),
|
|
162
|
+
normalizedBaseDir: normalizePathForComparison(dirname(entry.location), cwd),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderAvailableSkillsSection(
|
|
167
|
+
entries: readonly SkillPromptEntry[],
|
|
168
|
+
): string {
|
|
169
|
+
return [
|
|
170
|
+
AVAILABLE_SKILLS_OPEN_TAG,
|
|
171
|
+
...entries.flatMap((entry) => [
|
|
172
|
+
" <skill>",
|
|
173
|
+
` <name>${encodeXml(entry.name)}</name>`,
|
|
174
|
+
` <description>${encodeXml(entry.description)}</description>`,
|
|
175
|
+
` <location>${encodeXml(entry.location)}</location>`,
|
|
176
|
+
" </skill>",
|
|
177
|
+
]),
|
|
178
|
+
AVAILABLE_SKILLS_CLOSE_TAG,
|
|
179
|
+
].join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function removePromptRange(prompt: string, start: number, end: number): string {
|
|
183
|
+
const beforeSection = prompt.slice(0, start).replace(/\n+$/, "");
|
|
184
|
+
const afterSection = prompt.slice(end);
|
|
185
|
+
return `${beforeSection}${afterSection}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function resolveSkillPromptEntries(
|
|
189
|
+
prompt: string,
|
|
190
|
+
permissionManager: SkillPermissionChecker,
|
|
191
|
+
agentName: string | null,
|
|
192
|
+
cwd: string,
|
|
193
|
+
): { prompt: string; entries: SkillPromptEntry[] } {
|
|
194
|
+
const sections = parseAllSkillPromptSections(prompt);
|
|
195
|
+
if (sections.length === 0) {
|
|
196
|
+
return { prompt, entries: [] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const permissionCache = new Map<string, PermissionState>();
|
|
200
|
+
const visibleEntries: SkillPromptEntry[] = [];
|
|
201
|
+
const replacements: Array<{ start: number; end: number; content: string }> =
|
|
202
|
+
[];
|
|
203
|
+
|
|
204
|
+
for (const section of sections) {
|
|
205
|
+
const resolvedEntries = section.entries.map((entry) => {
|
|
206
|
+
const state = resolvePermissionState(
|
|
207
|
+
entry.name,
|
|
208
|
+
permissionManager,
|
|
209
|
+
agentName,
|
|
210
|
+
permissionCache,
|
|
211
|
+
);
|
|
212
|
+
return createResolvedSkillEntry(entry, state, cwd);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const visibleSectionEntries = resolvedEntries.filter(
|
|
216
|
+
(entry) => entry.state !== "deny",
|
|
217
|
+
);
|
|
218
|
+
visibleEntries.push(...visibleSectionEntries);
|
|
219
|
+
|
|
220
|
+
if (visibleSectionEntries.length === resolvedEntries.length) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
replacements.push({
|
|
225
|
+
start: section.start,
|
|
226
|
+
end: section.end,
|
|
227
|
+
content:
|
|
228
|
+
visibleSectionEntries.length > 0
|
|
229
|
+
? renderAvailableSkillsSection(visibleSectionEntries)
|
|
230
|
+
: "",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (replacements.length === 0) {
|
|
235
|
+
return { prompt, entries: visibleEntries };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let sanitizedPrompt = prompt;
|
|
239
|
+
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
240
|
+
const replacement = replacements[i];
|
|
241
|
+
sanitizedPrompt =
|
|
242
|
+
replacement.content.length > 0
|
|
243
|
+
? `${sanitizedPrompt.slice(0, replacement.start)}${replacement.content}${sanitizedPrompt.slice(replacement.end)}`
|
|
244
|
+
: removePromptRange(
|
|
245
|
+
sanitizedPrompt,
|
|
246
|
+
replacement.start,
|
|
247
|
+
replacement.end,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
prompt: sanitizedPrompt,
|
|
253
|
+
entries: visibleEntries,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function findSkillPathMatch(
|
|
258
|
+
normalizedPath: string,
|
|
259
|
+
entries: readonly SkillPromptEntry[],
|
|
260
|
+
): SkillPromptEntry | null {
|
|
261
|
+
if (!normalizedPath || entries.length === 0) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const entry of entries) {
|
|
266
|
+
if (
|
|
267
|
+
entry.normalizedLocation &&
|
|
268
|
+
normalizedPath === entry.normalizedLocation
|
|
269
|
+
) {
|
|
270
|
+
return entry;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let bestMatch: SkillPromptEntry | null = null;
|
|
275
|
+
for (const entry of entries) {
|
|
276
|
+
if (
|
|
277
|
+
!entry.normalizedBaseDir ||
|
|
278
|
+
!isPathWithinDirectory(normalizedPath, entry.normalizedBaseDir)
|
|
279
|
+
) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (
|
|
284
|
+
!bestMatch ||
|
|
285
|
+
entry.normalizedBaseDir.length > bestMatch.normalizedBaseDir.length
|
|
286
|
+
) {
|
|
287
|
+
bestMatch = entry;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return bestMatch;
|
|
292
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionCommandContext,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
EXTENSION_ID,
|
|
8
|
+
type PermissionSystemExtensionConfig,
|
|
9
|
+
} from "./extension-config";
|
|
10
|
+
import { isYoloModeEnabled } from "./yolo-mode";
|
|
11
|
+
|
|
12
|
+
export const PERMISSION_SYSTEM_STATUS_KEY = EXTENSION_ID;
|
|
13
|
+
export const PERMISSION_SYSTEM_YOLO_STATUS_VALUE = "yolo";
|
|
14
|
+
|
|
15
|
+
type PermissionStatusContext =
|
|
16
|
+
| Pick<ExtensionContext, "hasUI" | "ui">
|
|
17
|
+
| Pick<ExtensionCommandContext, "ui">;
|
|
18
|
+
|
|
19
|
+
export function getPermissionSystemStatus(
|
|
20
|
+
config: PermissionSystemExtensionConfig,
|
|
21
|
+
): string | undefined {
|
|
22
|
+
return isYoloModeEnabled(config)
|
|
23
|
+
? PERMISSION_SYSTEM_YOLO_STATUS_VALUE
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function syncPermissionSystemStatus(
|
|
28
|
+
ctx: PermissionStatusContext,
|
|
29
|
+
config: PermissionSystemExtensionConfig,
|
|
30
|
+
): void {
|
|
31
|
+
ctx.ui.setStatus(
|
|
32
|
+
PERMISSION_SYSTEM_STATUS_KEY,
|
|
33
|
+
getPermissionSystemStatus(config),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { normalize } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { SUBAGENT_ENV_HINT_KEYS } from "./permission-forwarding";
|
|
4
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Narrow context for subagent detection — the only session-manager readers
|
|
8
|
+
* {@link isSubagentExecutionContext} and {@link isRegisteredSubagentChild}
|
|
9
|
+
* consume. A full `ExtensionContext` satisfies this structurally.
|
|
10
|
+
*/
|
|
11
|
+
export interface SubagentDetectionContext {
|
|
12
|
+
sessionManager: {
|
|
13
|
+
getSessionId(): string;
|
|
14
|
+
getSessionDir(): string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeFilesystemPath(pathValue: string): string {
|
|
19
|
+
const normalizedPath = normalize(pathValue);
|
|
20
|
+
return process.platform === "win32"
|
|
21
|
+
? normalizedPath.toLowerCase()
|
|
22
|
+
: normalizedPath;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isPathWithinDirectoryForSubagent(
|
|
26
|
+
pathValue: string,
|
|
27
|
+
directory: string,
|
|
28
|
+
): boolean {
|
|
29
|
+
if (!pathValue || !directory) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (pathValue === directory) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sep = process.platform === "win32" ? "\\" : "/";
|
|
38
|
+
const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
|
|
39
|
+
return pathValue.startsWith(prefix);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Return `true` when `ctx` belongs to an in-process subagent child registered
|
|
44
|
+
* in `registry` by its session id.
|
|
45
|
+
*
|
|
46
|
+
* This is the only signal that identifies an **in-process** child (one sharing
|
|
47
|
+
* the parent's `globalThis`); env-hint and filesystem heuristics identify
|
|
48
|
+
* **process-based** subagents instead. The composition root uses this to decide
|
|
49
|
+
* whether the instance owns the process-global service slot — a registered
|
|
50
|
+
* child must not publish over its parent.
|
|
51
|
+
*/
|
|
52
|
+
export function isRegisteredSubagentChild(
|
|
53
|
+
ctx: SubagentDetectionContext,
|
|
54
|
+
registry: SubagentSessionRegistry,
|
|
55
|
+
): boolean {
|
|
56
|
+
try {
|
|
57
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
58
|
+
if (!sessionId) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return registry.has(sessionId);
|
|
62
|
+
} catch {
|
|
63
|
+
// getSessionId() unavailable — treat as not-a-registered-child.
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isSubagentExecutionContext(
|
|
69
|
+
ctx: SubagentDetectionContext,
|
|
70
|
+
subagentSessionsDir: string,
|
|
71
|
+
registry?: SubagentSessionRegistry,
|
|
72
|
+
): boolean {
|
|
73
|
+
// 1. Explicit registry — in-process subagent extensions register by child
|
|
74
|
+
// session id before bindExtensions(); checked first so it takes priority
|
|
75
|
+
// over heuristics. Each concurrent sibling has a unique session id, so
|
|
76
|
+
// one sibling's disposed event cannot affect another's registration.
|
|
77
|
+
if (registry && isRegisteredSubagentChild(ctx, registry)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
82
|
+
|
|
83
|
+
// 2. Env vars — process-based subagent extensions (nicobailon/pi-subagents,
|
|
84
|
+
// HazAT/pi-interactive-subagents, pi-agent-router, etc.).
|
|
85
|
+
for (const key of SUBAGENT_ENV_HINT_KEYS) {
|
|
86
|
+
const value = process.env[key];
|
|
87
|
+
if (typeof value === "string" && value.trim()) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Filesystem path — fallback heuristic for extensions that store sessions
|
|
93
|
+
// under a known subagent root directory.
|
|
94
|
+
if (!sessionDir) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const normalizedSessionDir = normalizeFilesystemPath(sessionDir);
|
|
99
|
+
const normalizedSubagentRoot = normalizeFilesystemPath(subagentSessionsDir);
|
|
100
|
+
return isPathWithinDirectoryForSubagent(
|
|
101
|
+
normalizedSessionDir,
|
|
102
|
+
normalizedSubagentRoot,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-lifecycle-events.ts — Subscribe to @gotgenes/pi-subagents' child
|
|
3
|
+
* lifecycle events and keep the SubagentSessionRegistry in sync.
|
|
4
|
+
*
|
|
5
|
+
* @gotgenes/pi-subagents publishes its child-execution lifecycle on the Pi
|
|
6
|
+
* event bus (ADR 0002): it no longer calls this package's service directly.
|
|
7
|
+
* We register the child on `session-created` and unregister it on `disposed`.
|
|
8
|
+
*
|
|
9
|
+
* The channel names and payload shapes are declared independently here (the two
|
|
10
|
+
* packages must not depend on each other under jiti) and MUST match the
|
|
11
|
+
* publisher in `@gotgenes/pi-subagents` (`src/lifecycle/child-lifecycle.ts`).
|
|
12
|
+
*
|
|
13
|
+
* The `session-created` handler MUST stay synchronous: the core emits it on the
|
|
14
|
+
* same synchronous call stack immediately before `bindExtensions()`, and the
|
|
15
|
+
* event bus dispatches listeners synchronously, so a synchronous handler lands
|
|
16
|
+
* the registry entry before binding proceeds. Introducing an `await` before
|
|
17
|
+
* `registry.register(...)` would break the pre-bind ordering.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
21
|
+
|
|
22
|
+
/** Emitted by the core after session creation, before `bindExtensions()`. */
|
|
23
|
+
export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
|
|
24
|
+
|
|
25
|
+
/** Emitted by the core in the run's `finally` (success and error). */
|
|
26
|
+
export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
|
|
27
|
+
|
|
28
|
+
/** Minimal event-bus surface this module needs (subscribe only). */
|
|
29
|
+
interface LifecycleEventBus {
|
|
30
|
+
on(channel: string, handler: (data: unknown) => void): () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Fields read from the `session-created` payload (ISP). */
|
|
34
|
+
interface ChildSessionCreatedEvent {
|
|
35
|
+
/** Child session id — the registry key. Must match the publisher. */
|
|
36
|
+
sessionId: string;
|
|
37
|
+
parentSessionId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fields read from the `disposed` payload (ISP). */
|
|
41
|
+
interface ChildDisposedEvent {
|
|
42
|
+
/** Child session id — the registry key. Must match the publisher. */
|
|
43
|
+
sessionId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to the subagent child lifecycle.
|
|
48
|
+
*
|
|
49
|
+
* @returns an unsubscribe that detaches both handlers (call during
|
|
50
|
+
* `session_shutdown`).
|
|
51
|
+
*/
|
|
52
|
+
export function subscribeSubagentLifecycle(
|
|
53
|
+
events: LifecycleEventBus,
|
|
54
|
+
registry: SubagentSessionRegistry,
|
|
55
|
+
): () => void {
|
|
56
|
+
const unsubCreated = events.on(SUBAGENT_CHILD_SESSION_CREATED, (data) => {
|
|
57
|
+
const event = data as ChildSessionCreatedEvent;
|
|
58
|
+
registry.register(event.sessionId, {
|
|
59
|
+
parentSessionId: event.parentSessionId,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const unsubDisposed = events.on(SUBAGENT_CHILD_DISPOSED, (data) => {
|
|
64
|
+
const event = data as ChildDisposedEvent;
|
|
65
|
+
registry.unregister(event.sessionId);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
unsubCreated();
|
|
70
|
+
unsubDisposed();
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-registry.ts — In-process subagent session registry.
|
|
3
|
+
*
|
|
4
|
+
* In-process subagent extensions (e.g. `@gotgenes/pi-subagents`) register
|
|
5
|
+
* each child session here before calling `bindExtensions()` so that
|
|
6
|
+
* `isSubagentExecutionContext()` and permission-forwarding target resolution
|
|
7
|
+
* can detect them without relying on environment variables or filesystem
|
|
8
|
+
* heuristics.
|
|
9
|
+
*
|
|
10
|
+
* The registry is keyed by the child's **session id**, which is unique per
|
|
11
|
+
* child and available to both producer (via `sessionManager.getSessionId()`
|
|
12
|
+
* after `newSession()` in `create-subagent-session.ts`) and consumer (via
|
|
13
|
+
* `ctx.sessionManager.getSessionId()`). Two concurrent siblings of the same
|
|
14
|
+
* parent therefore occupy distinct keys, so one sibling's `disposed` event
|
|
15
|
+
* cannot evict the entry the others depend on.
|
|
16
|
+
*
|
|
17
|
+
* The single registry instance is stored on `globalThis` (via `Symbol.for()`)
|
|
18
|
+
* so that the parent's permission-system instance (which registers children
|
|
19
|
+
* on the parent's event bus) and each child's separate jiti instance (which
|
|
20
|
+
* reads the registry to detect itself and resolve its forwarding target) share
|
|
21
|
+
* one store across per-session event buses. See `getSubagentSessionRegistry()`.
|
|
22
|
+
*
|
|
23
|
+
* When a future code path needs the child's agent name, read it from
|
|
24
|
+
* `tcc.agentName` (resolved from the `<active_agent>` system-prompt tag) —
|
|
25
|
+
* not from this registry.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Process-global key for the shared registry slot. */
|
|
29
|
+
const SUBAGENT_SESSION_REGISTRY_KEY = Symbol.for(
|
|
30
|
+
"@gotgenes/pi-permission-system:subagent-registry",
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Return the process-global SubagentSessionRegistry, creating it on first call.
|
|
35
|
+
*
|
|
36
|
+
* Backed by `globalThis` + `Symbol.for()` so the parent's permission-system
|
|
37
|
+
* instance (which registers children on the parent event bus) and each child's
|
|
38
|
+
* separate jiti instance (which reads the registry to detect itself and resolve
|
|
39
|
+
* its forwarding target) share one store across per-session event buses.
|
|
40
|
+
*
|
|
41
|
+
* Intentionally has no shutdown/unpublish hook — a child's `session_shutdown`
|
|
42
|
+
* must not be able to wipe the parent's registrations. Entries are added and
|
|
43
|
+
* removed exclusively by the parent's `subagents:child:session-created` /
|
|
44
|
+
* `subagents:child:disposed` subscription.
|
|
45
|
+
*/
|
|
46
|
+
export function getSubagentSessionRegistry(): SubagentSessionRegistry {
|
|
47
|
+
const store = globalThis as Record<symbol, unknown>;
|
|
48
|
+
const existing = store[SUBAGENT_SESSION_REGISTRY_KEY] as
|
|
49
|
+
| SubagentSessionRegistry
|
|
50
|
+
| undefined;
|
|
51
|
+
if (existing) {
|
|
52
|
+
return existing;
|
|
53
|
+
}
|
|
54
|
+
const registry = new SubagentSessionRegistry();
|
|
55
|
+
store[SUBAGENT_SESSION_REGISTRY_KEY] = registry;
|
|
56
|
+
return registry;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Signal stored per registered in-process subagent session. */
|
|
60
|
+
export interface SubagentSessionInfo {
|
|
61
|
+
/** Parent session ID for permission forwarding. Omit when unknown. */
|
|
62
|
+
parentSessionId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Registry of active in-process subagent sessions.
|
|
67
|
+
*
|
|
68
|
+
* A process-global singleton — obtain it via `getSubagentSessionRegistry()`,
|
|
69
|
+
* never `new` (see that accessor for why). Written exclusively by
|
|
70
|
+
* `subscribeSubagentLifecycle` via the `subagents:child:session-created` /
|
|
71
|
+
* `subagents:child:disposed` event subscription (ADR 0002 — the core
|
|
72
|
+
* publishes, consumers observe).
|
|
73
|
+
*
|
|
74
|
+
* Keyed by child session id. Each concurrent child of the same parent receives
|
|
75
|
+
* a unique session id from `sessionManager.newSession()`, so siblings occupy
|
|
76
|
+
* distinct keys and one sibling's `disposed` cannot evict another's entry.
|
|
77
|
+
*/
|
|
78
|
+
export class SubagentSessionRegistry {
|
|
79
|
+
private readonly sessions = new Map<string, SubagentSessionInfo>();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Register an in-process subagent session.
|
|
83
|
+
*
|
|
84
|
+
* If a previous entry exists for `sessionId`, it is overwritten
|
|
85
|
+
* (last-write-wins; single-writer expected per key).
|
|
86
|
+
*/
|
|
87
|
+
register(sessionId: string, info: SubagentSessionInfo): void {
|
|
88
|
+
this.sessions.set(sessionId, info);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Remove a previously registered session. No-op if the key is absent. */
|
|
92
|
+
unregister(sessionId: string): void {
|
|
93
|
+
this.sessions.delete(sessionId);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Return the registered info for `sessionId`, or `undefined` if absent. */
|
|
97
|
+
get(sessionId: string): SubagentSessionInfo | undefined {
|
|
98
|
+
return this.sessions.get(sessionId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Return `true` when `sessionId` has a registered entry. */
|
|
102
|
+
has(sessionId: string): boolean {
|
|
103
|
+
return this.sessions.has(sessionId);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
2
|
+
import type { PermissionState } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Synthesize a single universal catch-all rule from the universal default.
|
|
6
|
+
*
|
|
7
|
+
* Produces one rule:
|
|
8
|
+
* `{ surface: "*", pattern: "*", action: universalDefault, layer: "default" }`
|
|
9
|
+
*
|
|
10
|
+
* Per-surface catch-alls (`bash["*"]`, `mcp["*"]`, etc.) are expressed as
|
|
11
|
+
* regular config rules from `normalizeFlatConfig()` and sit at higher indices
|
|
12
|
+
* in the composed array, so they override this default via last-match-wins.
|
|
13
|
+
*/
|
|
14
|
+
export function synthesizeDefaults(
|
|
15
|
+
universalDefault: PermissionState,
|
|
16
|
+
origin: RuleOrigin = "builtin",
|
|
17
|
+
): Ruleset {
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
surface: "*",
|
|
21
|
+
pattern: "*",
|
|
22
|
+
action: universalDefault,
|
|
23
|
+
layer: "default",
|
|
24
|
+
origin,
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* MCP metadata operation targets that are auto-allowed when any explicit MCP
|
|
31
|
+
* allow rule exists in the config layer.
|
|
32
|
+
*/
|
|
33
|
+
const MCP_BASELINE_TARGETS: readonly string[] = [
|
|
34
|
+
"mcp_status",
|
|
35
|
+
"mcp_list",
|
|
36
|
+
"mcp_search",
|
|
37
|
+
"mcp_describe",
|
|
38
|
+
"mcp_connect",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Conditionally synthesize MCP baseline auto-allow rules.
|
|
43
|
+
*
|
|
44
|
+
* Emits allow rules for the 5 MCP metadata targets only when `configRules`
|
|
45
|
+
* contains at least one `surface: "mcp", action: "allow"` rule. This replicates
|
|
46
|
+
* the `hasAnyMcpAllowRule` heuristic as actual rules.
|
|
47
|
+
*
|
|
48
|
+
* When `permission["mcp"]` is `"allow"` (or `mcp["*"]` is `"allow"`), the
|
|
49
|
+
* synthesized config catch-all already covers all MCP targets — no separate
|
|
50
|
+
* baseline rules are needed (and this function is not called in that case).
|
|
51
|
+
*
|
|
52
|
+
* Baseline rules are placed BEFORE config rules in the composed array so
|
|
53
|
+
* that explicit config deny rules can still override them.
|
|
54
|
+
*
|
|
55
|
+
* All rules carry `layer: "baseline"`.
|
|
56
|
+
*/
|
|
57
|
+
export function synthesizeBaseline(configRules: Ruleset): Ruleset {
|
|
58
|
+
const hasAnyMcpAllow = configRules.some(
|
|
59
|
+
(r) => r.surface === "mcp" && r.action === "allow",
|
|
60
|
+
);
|
|
61
|
+
if (!hasAnyMcpAllow) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return MCP_BASELINE_TARGETS.map(
|
|
65
|
+
(target): Rule => ({
|
|
66
|
+
surface: "mcp",
|
|
67
|
+
pattern: target,
|
|
68
|
+
action: "allow",
|
|
69
|
+
layer: "baseline",
|
|
70
|
+
origin: "baseline",
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Concatenate all rule layers into a single flat ruleset.
|
|
77
|
+
*
|
|
78
|
+
* Priority order (lowest → highest, i.e. earlier index → later index):
|
|
79
|
+
* defaults → baseline → config
|
|
80
|
+
*
|
|
81
|
+
* Session rules are NOT included here — they are appended at call-time inside
|
|
82
|
+
* `checkPermission()` so that the cached composed ruleset remains session-agnostic.
|
|
83
|
+
*
|
|
84
|
+
* `evaluate()` scans from the end, so later layers override earlier ones.
|
|
85
|
+
*/
|
|
86
|
+
export function composeRuleset(
|
|
87
|
+
defaults: Ruleset,
|
|
88
|
+
baseline: Ruleset,
|
|
89
|
+
config: Ruleset,
|
|
90
|
+
): Ruleset {
|
|
91
|
+
return [...defaults, ...baseline, ...config];
|
|
92
|
+
}
|