@gotgenes/pi-permission-system 8.0.0 → 8.2.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 +21 -0
- package/config/config.example.json +3 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +12 -0
- package/src/extension-config.ts +23 -0
- package/src/handlers/gates/bash-external-directory.ts +2 -4
- package/src/handlers/gates/bash-path.ts +2 -4
- package/src/handlers/gates/descriptor.ts +6 -6
- package/src/handlers/gates/external-directory.ts +2 -4
- package/src/handlers/gates/helpers.ts +30 -1
- package/src/handlers/gates/path.ts +2 -4
- package/src/handlers/gates/runner.ts +29 -56
- package/src/handlers/gates/tool.ts +9 -6
- package/src/handlers/permission-gate-handler.ts +110 -141
- package/src/permission-manager.ts +6 -49
- package/src/permission-prompts.ts +5 -2
- package/src/permission-session.ts +3 -2
- package/src/scope-merge.ts +72 -0
- package/src/session-approval.ts +43 -0
- package/src/session-rules.ts +13 -0
- package/src/tool-input-preview.ts +0 -116
- package/src/tool-preview-formatter.ts +188 -0
- package/test/extension-config.test.ts +93 -0
- package/test/handlers/external-directory-integration.test.ts +3 -1
- package/test/handlers/external-directory-session-dedup.test.ts +17 -12
- package/test/handlers/gates/bash-external-directory.test.ts +11 -9
- package/test/handlers/gates/external-directory.test.ts +2 -5
- package/test/handlers/gates/helpers.test.ts +81 -0
- package/test/handlers/gates/path.test.ts +2 -2
- package/test/handlers/gates/runner.test.ts +18 -23
- package/test/handlers/gates/tool.test.ts +31 -4
- package/test/handlers/input-events.test.ts +1 -1
- package/test/handlers/input.test.ts +1 -1
- package/test/handlers/tool-call-events.test.ts +3 -2
- package/test/handlers/tool-call.test.ts +3 -2
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/permission-prompts.test.ts +66 -38
- package/test/permission-session.test.ts +6 -3
- package/test/scope-merge.test.ts +116 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-rules.test.ts +49 -0
- package/test/tool-input-preview.test.ts +0 -244
- package/test/tool-preview-formatter.test.ts +385 -0
|
@@ -16,6 +16,10 @@ import {
|
|
|
16
16
|
formatUnknownToolReason,
|
|
17
17
|
} from "#src/permission-prompts";
|
|
18
18
|
import type { PermissionSession } from "#src/permission-session";
|
|
19
|
+
import {
|
|
20
|
+
resolveToolPreviewLimits,
|
|
21
|
+
ToolPreviewFormatter,
|
|
22
|
+
} from "#src/tool-preview-formatter";
|
|
19
23
|
import {
|
|
20
24
|
checkRequestedToolRegistration,
|
|
21
25
|
getToolNameFromValue,
|
|
@@ -23,7 +27,7 @@ import {
|
|
|
23
27
|
} from "#src/tool-registry";
|
|
24
28
|
import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
|
|
25
29
|
import { describeBashPathGate } from "./gates/bash-path";
|
|
26
|
-
import type { GateRunnerDeps } from "./gates/descriptor";
|
|
30
|
+
import type { GateResult, GateRunnerDeps } from "./gates/descriptor";
|
|
27
31
|
import { isGateBypass } from "./gates/descriptor";
|
|
28
32
|
import { describeExternalDirectoryGate } from "./gates/external-directory";
|
|
29
33
|
import { describePathGate } from "./gates/path";
|
|
@@ -58,30 +62,13 @@ export class PermissionGateHandler {
|
|
|
58
62
|
): Promise<{ block?: true; reason?: string }> {
|
|
59
63
|
this.session.activate(ctx);
|
|
60
64
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!toolName) {
|
|
65
|
-
return { block: true, reason: formatMissingToolNameReason() };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const registrationCheck = checkRequestedToolRegistration(
|
|
69
|
-
toolName,
|
|
70
|
-
this.toolRegistry.getAll(),
|
|
71
|
-
);
|
|
72
|
-
if (registrationCheck.status === "missing-tool-name") {
|
|
73
|
-
return { block: true, reason: formatMissingToolNameReason() };
|
|
65
|
+
const validation = validateRequestedTool(event, this.toolRegistry.getAll());
|
|
66
|
+
if (validation.status === "block") {
|
|
67
|
+
return { block: true, reason: validation.reason };
|
|
74
68
|
}
|
|
69
|
+
const toolName = validation.toolName;
|
|
75
70
|
|
|
76
|
-
|
|
77
|
-
return {
|
|
78
|
-
block: true,
|
|
79
|
-
reason: formatUnknownToolReason(
|
|
80
|
-
registrationCheck.requestedToolName,
|
|
81
|
-
registrationCheck.availableToolNames,
|
|
82
|
-
),
|
|
83
|
-
};
|
|
84
|
-
}
|
|
71
|
+
const agentName = this.session.resolveAgentName(ctx);
|
|
85
72
|
|
|
86
73
|
const input = getEventInput(event);
|
|
87
74
|
const toolCallId =
|
|
@@ -112,150 +99,93 @@ export class PermissionGateHandler {
|
|
|
112
99
|
sessionRules,
|
|
113
100
|
) => this.session.checkPermission(surface, input, agent, sessionRules);
|
|
114
101
|
const getSessionRuleset = () => this.session.getSessionRuleset();
|
|
115
|
-
const
|
|
116
|
-
|
|
102
|
+
const recordSessionApproval: GateRunnerDeps["recordSessionApproval"] = (
|
|
103
|
+
approval,
|
|
104
|
+
) => this.session.recordSessionApproval(approval);
|
|
117
105
|
|
|
118
106
|
// ── Shared runner deps (built once, reused for all gates) ────────────
|
|
119
107
|
const runnerDeps: GateRunnerDeps = {
|
|
120
108
|
checkPermission,
|
|
121
109
|
getSessionRuleset,
|
|
122
|
-
|
|
110
|
+
recordSessionApproval,
|
|
123
111
|
writeReviewLog,
|
|
124
112
|
emitDecision,
|
|
125
113
|
canConfirm,
|
|
126
114
|
promptPermission,
|
|
127
115
|
};
|
|
128
116
|
|
|
129
|
-
// ──
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
117
|
+
// ── Unified gate executor ─────────────────────────────────────────────
|
|
118
|
+
// Handles the bypass log/emit branch, calls runGateCheck for descriptors,
|
|
119
|
+
// and returns a block result or undefined (allow / no-op).
|
|
120
|
+
const runGate = async (
|
|
121
|
+
gate: GateResult,
|
|
122
|
+
): Promise<{ block: true; reason: string } | undefined> => {
|
|
123
|
+
if (!gate) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
if (isGateBypass(gate)) {
|
|
127
|
+
if (gate.log) {
|
|
128
|
+
writeReviewLog(gate.log.event, gate.log.details);
|
|
129
|
+
}
|
|
130
|
+
if (gate.decision) {
|
|
131
|
+
emitDecision(gate.decision);
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const result = await runGateCheck(
|
|
136
|
+
gate,
|
|
136
137
|
tcc.agentName,
|
|
137
138
|
tcc.toolCallId,
|
|
138
139
|
runnerDeps,
|
|
139
140
|
);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
141
|
+
return result.action === "block"
|
|
142
|
+
? { block: true, reason: result.reason }
|
|
143
|
+
: undefined;
|
|
144
|
+
};
|
|
144
145
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (isGateBypass(pathDesc)) {
|
|
149
|
-
if (pathDesc.log) {
|
|
150
|
-
writeReviewLog(pathDesc.log.event, pathDesc.log.details);
|
|
151
|
-
}
|
|
152
|
-
} else {
|
|
153
|
-
const pathResult = await runGateCheck(
|
|
154
|
-
pathDesc,
|
|
155
|
-
tcc.agentName,
|
|
156
|
-
tcc.toolCallId,
|
|
157
|
-
runnerDeps,
|
|
158
|
-
);
|
|
159
|
-
if (pathResult.action === "block") {
|
|
160
|
-
return { block: true, reason: pathResult.reason };
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
146
|
+
const formatter = new ToolPreviewFormatter(
|
|
147
|
+
resolveToolPreviewLimits(this.session.config),
|
|
148
|
+
);
|
|
164
149
|
|
|
165
|
-
// ──
|
|
150
|
+
// ── Ordered gate pipeline ─────────────────────────────────────────────
|
|
151
|
+
// infraDirs is computed once, outside the pipeline, exactly as before.
|
|
166
152
|
const infraDirs = [
|
|
167
153
|
...this.session.getInfrastructureDirs(),
|
|
168
154
|
...this.session.getInfrastructureReadPaths(),
|
|
169
155
|
];
|
|
170
|
-
const extDirDesc = describeExternalDirectoryGate(tcc, infraDirs);
|
|
171
|
-
if (extDirDesc) {
|
|
172
|
-
if (isGateBypass(extDirDesc)) {
|
|
173
|
-
if (extDirDesc.log) {
|
|
174
|
-
writeReviewLog(extDirDesc.log.event, extDirDesc.log.details);
|
|
175
|
-
}
|
|
176
|
-
if (extDirDesc.decision) {
|
|
177
|
-
emitDecision(extDirDesc.decision);
|
|
178
|
-
}
|
|
179
|
-
} else {
|
|
180
|
-
const extDirResult = await runGateCheck(
|
|
181
|
-
extDirDesc,
|
|
182
|
-
tcc.agentName,
|
|
183
|
-
tcc.toolCallId,
|
|
184
|
-
runnerDeps,
|
|
185
|
-
);
|
|
186
|
-
if (extDirResult.action === "block") {
|
|
187
|
-
return { block: true, reason: extDirResult.reason };
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
156
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
checkPermission,
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
tcc.
|
|
207
|
-
tcc.
|
|
208
|
-
|
|
157
|
+
const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
|
|
158
|
+
() =>
|
|
159
|
+
describeSkillReadGate(tcc, () => this.session.getActiveSkillEntries()),
|
|
160
|
+
() => describePathGate(tcc, checkPermission, getSessionRuleset),
|
|
161
|
+
() => describeExternalDirectoryGate(tcc, infraDirs),
|
|
162
|
+
() =>
|
|
163
|
+
describeBashExternalDirectoryGate(
|
|
164
|
+
tcc,
|
|
165
|
+
checkPermission,
|
|
166
|
+
getSessionRuleset,
|
|
167
|
+
),
|
|
168
|
+
() => describeBashPathGate(tcc, checkPermission, getSessionRuleset),
|
|
169
|
+
() => {
|
|
170
|
+
const toolCheck = checkPermission(
|
|
171
|
+
tcc.toolName,
|
|
172
|
+
tcc.input,
|
|
173
|
+
tcc.agentName ?? undefined,
|
|
174
|
+
getSessionRuleset(),
|
|
209
175
|
);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
176
|
+
const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
|
|
177
|
+
toolDescriptor.preCheck = toolCheck;
|
|
178
|
+
return toolDescriptor;
|
|
179
|
+
},
|
|
180
|
+
];
|
|
215
181
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
getSessionRuleset,
|
|
221
|
-
);
|
|
222
|
-
if (bashPathDesc) {
|
|
223
|
-
if (isGateBypass(bashPathDesc)) {
|
|
224
|
-
if (bashPathDesc.log) {
|
|
225
|
-
writeReviewLog(bashPathDesc.log.event, bashPathDesc.log.details);
|
|
226
|
-
}
|
|
227
|
-
} else {
|
|
228
|
-
const bashPathResult = await runGateCheck(
|
|
229
|
-
bashPathDesc,
|
|
230
|
-
tcc.agentName,
|
|
231
|
-
tcc.toolCallId,
|
|
232
|
-
runnerDeps,
|
|
233
|
-
);
|
|
234
|
-
if (bashPathResult.action === "block") {
|
|
235
|
-
return { block: true, reason: bashPathResult.reason };
|
|
236
|
-
}
|
|
182
|
+
for (const produce of gateProducers) {
|
|
183
|
+
const blocked = await runGate(await produce());
|
|
184
|
+
if (blocked) {
|
|
185
|
+
return blocked;
|
|
237
186
|
}
|
|
238
187
|
}
|
|
239
188
|
|
|
240
|
-
// ── Normal tool permission gate (descriptor + runner) ────────────────────
|
|
241
|
-
const toolCheck = checkPermission(
|
|
242
|
-
tcc.toolName,
|
|
243
|
-
tcc.input,
|
|
244
|
-
tcc.agentName ?? undefined,
|
|
245
|
-
getSessionRuleset(),
|
|
246
|
-
);
|
|
247
|
-
const toolDescriptor = describeToolGate(tcc, toolCheck);
|
|
248
|
-
toolDescriptor.preCheck = toolCheck;
|
|
249
|
-
const toolResult = await runGateCheck(
|
|
250
|
-
toolDescriptor,
|
|
251
|
-
tcc.agentName,
|
|
252
|
-
tcc.toolCallId,
|
|
253
|
-
runnerDeps,
|
|
254
|
-
);
|
|
255
|
-
if (toolResult.action === "block") {
|
|
256
|
-
return { block: true, reason: toolResult.reason };
|
|
257
|
-
}
|
|
258
|
-
|
|
259
189
|
return {};
|
|
260
190
|
}
|
|
261
191
|
|
|
@@ -352,7 +282,46 @@ export class PermissionGateHandler {
|
|
|
352
282
|
}
|
|
353
283
|
}
|
|
354
284
|
|
|
355
|
-
// ── Pure helpers
|
|
285
|
+
// ── Pure helpers ─────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/** Discriminated result of validating a tool-call event's name and registration. */
|
|
288
|
+
export type RequestedToolValidation =
|
|
289
|
+
| { status: "ok"; toolName: string }
|
|
290
|
+
| { status: "block"; reason: string };
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Validate the tool name from a raw event against the registered tool list.
|
|
294
|
+
*
|
|
295
|
+
* Composes `getToolNameFromValue` + `checkRequestedToolRegistration` + the
|
|
296
|
+
* two reason formatters and returns a discriminated result so `handleToolCall`
|
|
297
|
+
* reads as a straight validate → proceed path without nested early-returns.
|
|
298
|
+
*
|
|
299
|
+
* Returns the **raw** tool name (not the normalised form) so that
|
|
300
|
+
* `ToolCallContext.toolName` stays identical to the pre-extraction behaviour.
|
|
301
|
+
*/
|
|
302
|
+
export function validateRequestedTool(
|
|
303
|
+
event: unknown,
|
|
304
|
+
availableTools: readonly unknown[],
|
|
305
|
+
): RequestedToolValidation {
|
|
306
|
+
const toolName = getToolNameFromValue(event);
|
|
307
|
+
if (!toolName) {
|
|
308
|
+
return { status: "block", reason: formatMissingToolNameReason() };
|
|
309
|
+
}
|
|
310
|
+
const check = checkRequestedToolRegistration(toolName, availableTools);
|
|
311
|
+
if (check.status === "missing-tool-name") {
|
|
312
|
+
return { status: "block", reason: formatMissingToolNameReason() };
|
|
313
|
+
}
|
|
314
|
+
if (check.status === "unregistered") {
|
|
315
|
+
return {
|
|
316
|
+
status: "block",
|
|
317
|
+
reason: formatUnknownToolReason(
|
|
318
|
+
check.requestedToolName,
|
|
319
|
+
check.availableToolNames,
|
|
320
|
+
),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
return { status: "ok", toolName };
|
|
324
|
+
}
|
|
356
325
|
|
|
357
326
|
/**
|
|
358
327
|
* Extract the tool input from an event, checking both `input` and `arguments`
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { isPermissionState } from "./common";
|
|
2
2
|
import { normalizeInput } from "./input-normalizer";
|
|
3
3
|
import { normalizeFlatConfig } from "./normalize";
|
|
4
|
-
import { mergeFlatPermissions } from "./permission-merge";
|
|
5
4
|
import {
|
|
6
5
|
FilePolicyLoader,
|
|
7
6
|
type PolicyLoader,
|
|
@@ -10,6 +9,7 @@ import {
|
|
|
10
9
|
} from "./policy-loader";
|
|
11
10
|
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
12
11
|
import { evaluate, evaluateFirst } from "./rule";
|
|
12
|
+
import { mergeScopesWithOrigins } from "./scope-merge";
|
|
13
13
|
import {
|
|
14
14
|
composeRuleset,
|
|
15
15
|
synthesizeBaseline,
|
|
@@ -90,58 +90,15 @@ export class PermissionManager {
|
|
|
90
90
|
const agentConfig = this.loader.loadAgentConfig(agentName);
|
|
91
91
|
const projectAgentConfig = this.loader.loadProjectAgentConfig(agentName);
|
|
92
92
|
|
|
93
|
-
// Merge permission objects across scopes (lowest → highest precedence)
|
|
94
|
-
//
|
|
95
|
-
// (surface, pattern) entry
|
|
96
|
-
|
|
97
|
-
const origins: OriginMap = new Map();
|
|
98
|
-
let mergedPermission: FlatPermissionConfig = {};
|
|
99
|
-
|
|
100
|
-
for (const [scopeName, scope] of [
|
|
93
|
+
// Merge permission objects across scopes (lowest → highest precedence),
|
|
94
|
+
// building a parallel origin map that tracks which scope contributed each
|
|
95
|
+
// (surface, pattern) entry.
|
|
96
|
+
const { mergedPermission, origins } = mergeScopesWithOrigins([
|
|
101
97
|
["global", globalConfig],
|
|
102
98
|
["project", projectConfig],
|
|
103
99
|
["agent", agentConfig],
|
|
104
100
|
["project-agent", projectAgentConfig],
|
|
105
|
-
]
|
|
106
|
-
if (!scope.permission) continue;
|
|
107
|
-
|
|
108
|
-
for (const [surface, value] of Object.entries(scope.permission)) {
|
|
109
|
-
const baseVal = mergedPermission[surface];
|
|
110
|
-
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
|
|
111
|
-
const bothObjects =
|
|
112
|
-
typeof baseVal === "object" &&
|
|
113
|
-
baseVal !== null &&
|
|
114
|
-
typeof value === "object" &&
|
|
115
|
-
value !== null;
|
|
116
|
-
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
|
117
|
-
|
|
118
|
-
if (bothObjects) {
|
|
119
|
-
// Shallow-merge: each incoming pattern is attributed to this scope;
|
|
120
|
-
// existing patterns from lower scopes keep their earlier origin.
|
|
121
|
-
if (!origins.has(surface)) origins.set(surface, new Map());
|
|
122
|
-
for (const pattern of Object.keys(value)) {
|
|
123
|
-
origins.get(surface)?.set(pattern, scopeName);
|
|
124
|
-
}
|
|
125
|
-
} else {
|
|
126
|
-
// Full replacement: this scope takes over the entire surface entry.
|
|
127
|
-
const surfaceOrigins = new Map<string, RuleOrigin>();
|
|
128
|
-
if (typeof value === "string") {
|
|
129
|
-
surfaceOrigins.set("*", scopeName);
|
|
130
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check
|
|
131
|
-
} else if (typeof value === "object" && value !== null) {
|
|
132
|
-
for (const pattern of Object.keys(value)) {
|
|
133
|
-
surfaceOrigins.set(pattern, scopeName);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
origins.set(surface, surfaceOrigins);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
mergedPermission = mergeFlatPermissions(
|
|
141
|
-
mergedPermission,
|
|
142
|
-
scope.permission,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
101
|
+
]);
|
|
145
102
|
|
|
146
103
|
// Extract the universal fallback from permission["*"].
|
|
147
104
|
// The "*" key feeds synthesizeDefaults() only — it is NOT included as a
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
2
|
-
import {
|
|
2
|
+
import type { ToolPreviewFormatter } from "./tool-preview-formatter";
|
|
3
3
|
import type { PermissionCheckResult } from "./types";
|
|
4
4
|
|
|
5
5
|
// NOTE: formatDenyReason, formatUserDeniedReason, and
|
|
@@ -31,6 +31,7 @@ export function formatAskPrompt(
|
|
|
31
31
|
result: PermissionCheckResult,
|
|
32
32
|
agentName?: string,
|
|
33
33
|
input?: unknown,
|
|
34
|
+
formatter?: ToolPreviewFormatter,
|
|
34
35
|
): string {
|
|
35
36
|
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
36
37
|
|
|
@@ -51,7 +52,9 @@ export function formatAskPrompt(
|
|
|
51
52
|
const patternInfo = result.matchedPattern
|
|
52
53
|
? ` (matched '${result.matchedPattern}')`
|
|
53
54
|
: "";
|
|
54
|
-
const inputPreview =
|
|
55
|
+
const inputPreview = formatter
|
|
56
|
+
? formatter.formatToolInputForPrompt(result.toolName, input)
|
|
57
|
+
: "";
|
|
55
58
|
const inputSuffix = inputPreview ? ` ${inputPreview}` : "";
|
|
56
59
|
return `${subject} requested tool '${result.toolName}'${patternInfo}${inputSuffix}. Allow this call?`;
|
|
57
60
|
}
|
|
@@ -12,6 +12,7 @@ import type { PermissionManager } from "./permission-manager";
|
|
|
12
12
|
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
13
13
|
import type { Rule } from "./rule";
|
|
14
14
|
import { createPermissionManagerForCwd } from "./runtime";
|
|
15
|
+
import type { SessionApproval } from "./session-approval";
|
|
15
16
|
import type { SessionLogger } from "./session-logger";
|
|
16
17
|
import { SessionRules } from "./session-rules";
|
|
17
18
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
@@ -127,8 +128,8 @@ export class PermissionSession {
|
|
|
127
128
|
return this.sessionRules.getRuleset();
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
|
|
131
|
-
this.sessionRules.
|
|
131
|
+
recordSessionApproval(approval: SessionApproval): void {
|
|
132
|
+
this.sessionRules.record(approval);
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
// ── Session lifecycle ────────────────────────────────────────────────────
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mergeFlatPermissions } from "#src/permission-merge";
|
|
2
|
+
import type { RuleOrigin } from "#src/rule";
|
|
3
|
+
import type { FlatPermissionConfig, ScopeConfig } from "#src/types";
|
|
4
|
+
|
|
5
|
+
/** Surface → (pattern → originating scope). */
|
|
6
|
+
type OriginMap = Map<string, Map<string, RuleOrigin>>;
|
|
7
|
+
|
|
8
|
+
/** Result of merging permission objects across scopes with provenance tracking. */
|
|
9
|
+
export interface MergedScopes {
|
|
10
|
+
/** Fully merged flat permission config (lowest → highest precedence). */
|
|
11
|
+
mergedPermission: FlatPermissionConfig;
|
|
12
|
+
/** Maps each surface to a per-pattern origin (which scope contributed it). */
|
|
13
|
+
origins: OriginMap;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Merge permission objects across scopes (lowest → highest precedence) while
|
|
18
|
+
* tracking which scope contributed each (surface, pattern) entry.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors mergeFlatPermissions() semantics for origin attribution:
|
|
21
|
+
* - Both base and incoming are objects → shallow-merge: each incoming pattern
|
|
22
|
+
* is attributed to this scope; patterns the higher scope does not redefine
|
|
23
|
+
* keep their earlier origin.
|
|
24
|
+
* - Otherwise → full replacement: this scope takes over the entire surface
|
|
25
|
+
* entry, discarding all lower-scope attribution.
|
|
26
|
+
*/
|
|
27
|
+
export function mergeScopesWithOrigins(
|
|
28
|
+
scopes: readonly (readonly [RuleOrigin, ScopeConfig])[],
|
|
29
|
+
): MergedScopes {
|
|
30
|
+
const origins: OriginMap = new Map();
|
|
31
|
+
let mergedPermission: FlatPermissionConfig = {};
|
|
32
|
+
|
|
33
|
+
for (const [scopeName, scope] of scopes) {
|
|
34
|
+
if (!scope.permission) continue;
|
|
35
|
+
|
|
36
|
+
for (const [surface, value] of Object.entries(scope.permission)) {
|
|
37
|
+
const baseVal = mergedPermission[surface];
|
|
38
|
+
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
|
|
39
|
+
const bothObjects =
|
|
40
|
+
typeof baseVal === "object" &&
|
|
41
|
+
baseVal !== null &&
|
|
42
|
+
typeof value === "object" &&
|
|
43
|
+
value !== null;
|
|
44
|
+
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
|
45
|
+
|
|
46
|
+
if (bothObjects) {
|
|
47
|
+
// Shallow-merge: each incoming pattern is attributed to this scope;
|
|
48
|
+
// existing patterns from lower scopes keep their earlier origin.
|
|
49
|
+
if (!origins.has(surface)) origins.set(surface, new Map());
|
|
50
|
+
for (const pattern of Object.keys(value)) {
|
|
51
|
+
origins.get(surface)?.set(pattern, scopeName);
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
// Full replacement: this scope takes over the entire surface entry.
|
|
55
|
+
const surfaceOrigins = new Map<string, RuleOrigin>();
|
|
56
|
+
if (typeof value === "string") {
|
|
57
|
+
surfaceOrigins.set("*", scopeName);
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check
|
|
59
|
+
} else if (typeof value === "object" && value !== null) {
|
|
60
|
+
for (const pattern of Object.keys(value)) {
|
|
61
|
+
surfaceOrigins.set(pattern, scopeName);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
origins.set(surface, surfaceOrigins);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
mergedPermission = mergeFlatPermissions(mergedPermission, scope.permission);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { mergedPermission, origins };
|
|
72
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value object for a session-scoped approval: one surface, one-or-more patterns.
|
|
3
|
+
*
|
|
4
|
+
* Owned by gate descriptors and passed to the session store — the runner never
|
|
5
|
+
* needs to know whether there is one pattern or many.
|
|
6
|
+
*/
|
|
7
|
+
export class SessionApproval {
|
|
8
|
+
private constructor(
|
|
9
|
+
readonly surface: string,
|
|
10
|
+
readonly patterns: readonly string[],
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
/** Create an approval for a single pattern (the common case). */
|
|
14
|
+
static single(surface: string, pattern: string): SessionApproval {
|
|
15
|
+
return new SessionApproval(surface, [pattern]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create an approval for multiple patterns (e.g. bash external-directory
|
|
20
|
+
* gates that cover several uncovered paths in one prompt).
|
|
21
|
+
*/
|
|
22
|
+
static multiple(
|
|
23
|
+
surface: string,
|
|
24
|
+
patterns: readonly string[],
|
|
25
|
+
): SessionApproval {
|
|
26
|
+
return new SessionApproval(surface, [...patterns]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Representative pattern for the interactive prompt — the first, if any. */
|
|
30
|
+
get representativePattern(): string | undefined {
|
|
31
|
+
return this.patterns[0];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Single-pattern shape `applyPermissionGate` echoes back to the caller.
|
|
36
|
+
* Returns `undefined` when patterns is empty (degenerate case).
|
|
37
|
+
*/
|
|
38
|
+
toGateApproval(): { surface: string; pattern: string } | undefined {
|
|
39
|
+
const pattern = this.representativePattern;
|
|
40
|
+
if (pattern === undefined) return undefined;
|
|
41
|
+
return { surface: this.surface, pattern };
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/session-rules.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dirname, sep } from "node:path";
|
|
2
2
|
|
|
3
3
|
import type { Ruleset } from "./rule";
|
|
4
|
+
import type { SessionApproval } from "./session-approval";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Ephemeral in-memory store of session-scoped permission approvals.
|
|
@@ -29,6 +30,18 @@ export class SessionRules {
|
|
|
29
30
|
return [...this.rules];
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Record all patterns from a `SessionApproval` value object.
|
|
35
|
+
*
|
|
36
|
+
* The loop lives here so callers never need to know whether an approval
|
|
37
|
+
* carries one pattern or many — they just tell the store to record it.
|
|
38
|
+
*/
|
|
39
|
+
record(approval: SessionApproval): void {
|
|
40
|
+
for (const pattern of approval.patterns) {
|
|
41
|
+
this.approve(approval.surface, pattern);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
/** Remove all session approvals. */
|
|
33
46
|
clear(): void {
|
|
34
47
|
this.rules = [];
|