@gotgenes/pi-permission-system 18.2.0 → 19.0.1
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 +25 -0
- package/README.md +1 -0
- package/config/config.example.json +1 -1
- package/docs/configuration.md +8 -2
- package/docs/migration/strict-config-validation.md +38 -0
- package/docs/opencode-compatibility.md +1 -1
- package/package.json +4 -2
- package/schemas/permissions.schema.json +57 -45
- package/src/{forwarded-permissions/permission-forwarder.ts → authority/approval-escalator.ts} +35 -301
- package/src/authority/forwarded-request-server.ts +285 -0
- package/src/authority/forwarder-context.ts +32 -0
- package/src/{subagent-context.ts → authority/subagent-context.ts} +2 -2
- package/src/authority/subagent-detection.ts +65 -0
- package/src/config-loader.ts +48 -79
- package/src/config-schema.ts +204 -0
- package/src/forwarding-manager.ts +4 -16
- package/src/index.ts +31 -20
- package/src/permission-prompter.ts +1 -1
- package/src/policy-loader.ts +8 -4
- package/src/prompting-gateway.ts +5 -19
- package/src/service-lifecycle.ts +3 -5
- package/src/session-logger.ts +1 -1
- package/src/types.ts +16 -26
- package/src/value-guards.ts +0 -17
- /package/src/{forwarded-permissions/io.ts → authority/forwarding-io.ts} +0 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
type ForwarderContext,
|
|
4
|
+
getSessionId,
|
|
5
|
+
} from "#src/authority/forwarder-context";
|
|
6
|
+
import type { ConfigReader } from "#src/config-store";
|
|
7
|
+
import { isYoloModeEnabled } from "#src/extension-config";
|
|
8
|
+
import type {
|
|
9
|
+
PermissionDecisionUi,
|
|
10
|
+
PermissionPromptDecision,
|
|
11
|
+
RequestPermissionOptions,
|
|
12
|
+
} from "#src/permission-dialog";
|
|
13
|
+
import {
|
|
14
|
+
emitUiPromptEvent,
|
|
15
|
+
type PermissionEventBus,
|
|
16
|
+
} from "#src/permission-events";
|
|
17
|
+
import {
|
|
18
|
+
type ForwardedPermissionRequest,
|
|
19
|
+
type ForwardedPermissionResponse,
|
|
20
|
+
isForwardedPermissionRequestForSession,
|
|
21
|
+
type PermissionForwardingLocation,
|
|
22
|
+
} from "#src/permission-forwarding";
|
|
23
|
+
import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
|
|
24
|
+
import type { DebugReviewLogger } from "#src/session-logger";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
cleanupPermissionForwardingLocationIfEmpty,
|
|
28
|
+
ensureDirectoryExists,
|
|
29
|
+
getExistingPermissionForwardingLocation,
|
|
30
|
+
listRequestFiles,
|
|
31
|
+
logPermissionForwardingError,
|
|
32
|
+
logPermissionForwardingWarning,
|
|
33
|
+
readForwardedPermissionRequest,
|
|
34
|
+
safeDeleteFile,
|
|
35
|
+
writeJsonFileAtomic,
|
|
36
|
+
} from "./forwarding-io";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Narrow seam describing what `ForwardingManager` needs from the server: a
|
|
40
|
+
* single method that drains this session's forwarded-permission inbox.
|
|
41
|
+
*
|
|
42
|
+
* Depending on the interface (not the concrete `ForwardedRequestServer`)
|
|
43
|
+
* keeps the manager's unit tests free of casts — they inject a plain
|
|
44
|
+
* `{ processInbox: vi.fn() }` mock.
|
|
45
|
+
*/
|
|
46
|
+
export interface InboxProcessor {
|
|
47
|
+
processInbox(ctx: ForwarderContext): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Constructor config for `ForwardedRequestServer`. */
|
|
51
|
+
export interface ForwardedRequestServerDeps {
|
|
52
|
+
forwardingDir: string;
|
|
53
|
+
logger: DebugReviewLogger;
|
|
54
|
+
/** Event bus used for UI prompt broadcasts. */
|
|
55
|
+
events?: PermissionEventBus;
|
|
56
|
+
requestPermissionDecisionFromUi: (
|
|
57
|
+
ui: PermissionDecisionUi,
|
|
58
|
+
title: string,
|
|
59
|
+
message: string,
|
|
60
|
+
options?: RequestPermissionOptions,
|
|
61
|
+
) => Promise<PermissionPromptDecision>;
|
|
62
|
+
/** Read current config for the retained forwarded-inbox yolo auto-approve check. */
|
|
63
|
+
config: ConfigReader;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Module-private helpers ────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function formatForwardedPermissionPrompt(
|
|
69
|
+
request: ForwardedPermissionRequest,
|
|
70
|
+
): string {
|
|
71
|
+
const agentName = request.requesterAgentName || "unknown";
|
|
72
|
+
const sessionId = request.requesterSessionId || "unknown";
|
|
73
|
+
return [
|
|
74
|
+
`Subagent '${agentName}' requested permission.`,
|
|
75
|
+
`Session ID: ${sessionId}`,
|
|
76
|
+
"",
|
|
77
|
+
request.message,
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── ForwardedRequestServer ────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Owner of the serving-down role of the forwarded-permission behavior:
|
|
85
|
+
* draining this session's forwarded-permission inbox and answering each
|
|
86
|
+
* request (auto-approve under yolo, or prompt via the injected UI).
|
|
87
|
+
*/
|
|
88
|
+
export class ForwardedRequestServer implements InboxProcessor {
|
|
89
|
+
private readonly forwardingDir: string;
|
|
90
|
+
private readonly events: PermissionEventBus | undefined;
|
|
91
|
+
private readonly logger: DebugReviewLogger;
|
|
92
|
+
private readonly requestPermissionDecisionFromUi: (
|
|
93
|
+
ui: PermissionDecisionUi,
|
|
94
|
+
title: string,
|
|
95
|
+
message: string,
|
|
96
|
+
options?: RequestPermissionOptions,
|
|
97
|
+
) => Promise<PermissionPromptDecision>;
|
|
98
|
+
private readonly config: ConfigReader;
|
|
99
|
+
|
|
100
|
+
constructor(deps: ForwardedRequestServerDeps) {
|
|
101
|
+
this.forwardingDir = deps.forwardingDir;
|
|
102
|
+
this.events = deps.events;
|
|
103
|
+
this.logger = deps.logger;
|
|
104
|
+
this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
|
|
105
|
+
this.config = deps.config;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Drain and respond to this session's forwarded-permission inbox. */
|
|
109
|
+
async processInbox(ctx: ForwarderContext): Promise<void> {
|
|
110
|
+
if (!ctx.hasUI) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const currentSessionId = getSessionId(ctx);
|
|
115
|
+
const location = getExistingPermissionForwardingLocation(
|
|
116
|
+
this.forwardingDir,
|
|
117
|
+
currentSessionId,
|
|
118
|
+
);
|
|
119
|
+
if (!location) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const requestFiles = listRequestFiles(this.logger, location.requestsDir);
|
|
124
|
+
if (requestFiles.length === 0) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Defensively recreate responses/ before writing any response — a
|
|
129
|
+
// concurrent cleanup pass may have removed it between the requestsDir
|
|
130
|
+
// existence check above and the write inside processSingleForwardedRequest
|
|
131
|
+
// (the ENOENT write loop reported in issue #398).
|
|
132
|
+
if (
|
|
133
|
+
!ensureDirectoryExists(
|
|
134
|
+
this.logger,
|
|
135
|
+
location.responsesDir,
|
|
136
|
+
"permission forwarding responses",
|
|
137
|
+
)
|
|
138
|
+
) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const fileName of requestFiles) {
|
|
143
|
+
const requestPath = join(location.requestsDir, fileName);
|
|
144
|
+
const request = readForwardedPermissionRequest(this.logger, requestPath);
|
|
145
|
+
if (!request) {
|
|
146
|
+
safeDeleteFile(
|
|
147
|
+
this.logger,
|
|
148
|
+
requestPath,
|
|
149
|
+
`${location.label} forwarded permission request`,
|
|
150
|
+
);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await this.processSingleForwardedRequest(
|
|
155
|
+
ctx,
|
|
156
|
+
request,
|
|
157
|
+
location,
|
|
158
|
+
requestPath,
|
|
159
|
+
currentSessionId,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Private methods ────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
private async processSingleForwardedRequest(
|
|
169
|
+
ctx: ForwarderContext,
|
|
170
|
+
request: ForwardedPermissionRequest,
|
|
171
|
+
location: PermissionForwardingLocation,
|
|
172
|
+
requestPath: string,
|
|
173
|
+
currentSessionId: string,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
|
|
176
|
+
logPermissionForwardingWarning(
|
|
177
|
+
this.logger,
|
|
178
|
+
`Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
|
|
179
|
+
);
|
|
180
|
+
safeDeleteFile(
|
|
181
|
+
this.logger,
|
|
182
|
+
requestPath,
|
|
183
|
+
`${location.label} forwarded permission request`,
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const forwardedPermissionLogDetails = {
|
|
189
|
+
requestId: request.id,
|
|
190
|
+
source: location.label,
|
|
191
|
+
requesterAgentName: request.requesterAgentName,
|
|
192
|
+
requesterSessionId: request.requesterSessionId,
|
|
193
|
+
targetSessionId: request.targetSessionId,
|
|
194
|
+
requestPath,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
let decision: PermissionPromptDecision = {
|
|
198
|
+
approved: false,
|
|
199
|
+
state: "denied",
|
|
200
|
+
};
|
|
201
|
+
// Last yolo check outside the composed ruleset: dissolves when
|
|
202
|
+
// processInbox is refactored onto evaluate() + Authorizer selection in
|
|
203
|
+
// the Phase 9 spine work.
|
|
204
|
+
if (isYoloModeEnabled(this.config.current())) {
|
|
205
|
+
this.logger.review(
|
|
206
|
+
"forwarded_permission.auto_approved",
|
|
207
|
+
forwardedPermissionLogDetails,
|
|
208
|
+
);
|
|
209
|
+
decision = { approved: true, state: "approved" };
|
|
210
|
+
} else {
|
|
211
|
+
this.logger.review(
|
|
212
|
+
"forwarded_permission.prompted",
|
|
213
|
+
forwardedPermissionLogDetails,
|
|
214
|
+
);
|
|
215
|
+
try {
|
|
216
|
+
const forwardedMessage = formatForwardedPermissionPrompt(request);
|
|
217
|
+
if (this.events) {
|
|
218
|
+
emitUiPromptEvent(
|
|
219
|
+
this.events,
|
|
220
|
+
buildForwardedUiPrompt({
|
|
221
|
+
requestId: request.id,
|
|
222
|
+
message: forwardedMessage,
|
|
223
|
+
requesterAgentName: request.requesterAgentName || null,
|
|
224
|
+
requesterSessionId: request.requesterSessionId || null,
|
|
225
|
+
source: request.source ?? null,
|
|
226
|
+
surface: request.surface ?? null,
|
|
227
|
+
value: request.value ?? null,
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
decision = await this.requestPermissionDecisionFromUi(
|
|
232
|
+
ctx.ui,
|
|
233
|
+
"Permission Required (Subagent)",
|
|
234
|
+
forwardedMessage,
|
|
235
|
+
);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
logPermissionForwardingError(
|
|
238
|
+
this.logger,
|
|
239
|
+
"Failed to show forwarded permission confirmation dialog",
|
|
240
|
+
error,
|
|
241
|
+
);
|
|
242
|
+
decision = { approved: false, state: "denied" };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
247
|
+
this.logger.review(
|
|
248
|
+
decision.approved
|
|
249
|
+
? "forwarded_permission.approved"
|
|
250
|
+
: "forwarded_permission.denied",
|
|
251
|
+
{
|
|
252
|
+
requestId: request.id,
|
|
253
|
+
source: location.label,
|
|
254
|
+
requesterAgentName: request.requesterAgentName,
|
|
255
|
+
requesterSessionId: request.requesterSessionId,
|
|
256
|
+
targetSessionId: request.targetSessionId,
|
|
257
|
+
responsePath,
|
|
258
|
+
resolution: decision.state,
|
|
259
|
+
denialReason: decision.denialReason ?? null,
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
try {
|
|
263
|
+
writeJsonFileAtomic(this.logger, responsePath, {
|
|
264
|
+
approved: decision.approved,
|
|
265
|
+
state: decision.state,
|
|
266
|
+
denialReason: decision.denialReason,
|
|
267
|
+
responderSessionId: currentSessionId,
|
|
268
|
+
respondedAt: Date.now(),
|
|
269
|
+
} satisfies ForwardedPermissionResponse);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
logPermissionForwardingError(
|
|
272
|
+
this.logger,
|
|
273
|
+
`Failed to write ${location.label} forwarded permission response '${responsePath}'`,
|
|
274
|
+
error,
|
|
275
|
+
);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
safeDeleteFile(
|
|
280
|
+
this.logger,
|
|
281
|
+
requestPath,
|
|
282
|
+
`${location.label} forwarded permission request`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SessionEntryView } from "#src/active-agent";
|
|
2
|
+
import type { PermissionDecisionUi } from "#src/permission-dialog";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Narrow context the forwarding subsystem reads: the UI gate (`hasUI`), the
|
|
6
|
+
* dialog UI surface, and the three session-manager readers `getSessionId`
|
|
7
|
+
* and the `active-agent` helpers use.
|
|
8
|
+
*
|
|
9
|
+
* A full `ExtensionContext` satisfies this structurally, so production
|
|
10
|
+
* callers pass `ctx` unchanged.
|
|
11
|
+
*/
|
|
12
|
+
export interface ForwarderContext {
|
|
13
|
+
hasUI: boolean;
|
|
14
|
+
ui: PermissionDecisionUi;
|
|
15
|
+
sessionManager: {
|
|
16
|
+
getSessionId(): string;
|
|
17
|
+
getSessionDir(): string;
|
|
18
|
+
getEntries(): readonly SessionEntryView[];
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Reads the current session id off `ctx`, falling back to `"unknown"`. */
|
|
23
|
+
export function getSessionId(ctx: ForwarderContext): string {
|
|
24
|
+
try {
|
|
25
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
26
|
+
if (typeof sessionId === "string" && sessionId.trim()) {
|
|
27
|
+
return sessionId.trim();
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
|
|
31
|
+
return "unknown";
|
|
32
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
2
2
|
|
|
3
|
-
import { SUBAGENT_ENV_HINT_KEYS } from "
|
|
4
|
-
import type { SubagentSessionRegistry } from "
|
|
3
|
+
import { SUBAGENT_ENV_HINT_KEYS } from "#src/permission-forwarding";
|
|
4
|
+
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Narrow context for subagent detection — the only session-manager readers
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isRegisteredSubagentChild,
|
|
3
|
+
isSubagentExecutionContext,
|
|
4
|
+
type SubagentDetectionContext,
|
|
5
|
+
} from "#src/authority/subagent-context";
|
|
6
|
+
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Narrow seam for the ask-path consumers: "is the current session a subagent?"
|
|
10
|
+
*
|
|
11
|
+
* `PromptingGateway`, `ForwardingManager`, and `ApprovalEscalator` depend on
|
|
12
|
+
* this single-method view so their unit tests inject a one-field fake without
|
|
13
|
+
* casts. It is the Authorizer-selection predicate the Phase 9 spine consumes.
|
|
14
|
+
*/
|
|
15
|
+
export interface SubagentDetector {
|
|
16
|
+
isSubagent(ctx: SubagentDetectionContext): boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Narrow seam for the service-publication guard (#302): "is the current
|
|
21
|
+
* session a registered in-process child?"
|
|
22
|
+
*
|
|
23
|
+
* `PermissionServiceLifecycle` depends on this single-method view so a
|
|
24
|
+
* registered child never publishes over its parent's process-global slot.
|
|
25
|
+
*/
|
|
26
|
+
export interface RegisteredChildDetector {
|
|
27
|
+
isRegisteredChild(ctx: SubagentDetectionContext): boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Composition-root inputs for {@link SubagentDetection}. */
|
|
31
|
+
export interface SubagentDetectionDeps {
|
|
32
|
+
subagentSessionsDir: string;
|
|
33
|
+
platform: NodeJS.Platform;
|
|
34
|
+
registry?: SubagentSessionRegistry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Single owner of subagent detection.
|
|
39
|
+
*
|
|
40
|
+
* Constructed once in the composition root with the detection inputs
|
|
41
|
+
* (`subagentSessionsDir`, `platform`, `registry`) and shared across every
|
|
42
|
+
* consumer, replacing the dep triple those consumers previously threaded
|
|
43
|
+
* individually. Delegates to the pure detection functions in
|
|
44
|
+
* {@link ./subagent-context}, holding only the deps.
|
|
45
|
+
*/
|
|
46
|
+
export class SubagentDetection
|
|
47
|
+
implements SubagentDetector, RegisteredChildDetector
|
|
48
|
+
{
|
|
49
|
+
constructor(private readonly deps: SubagentDetectionDeps) {}
|
|
50
|
+
|
|
51
|
+
isSubagent(ctx: SubagentDetectionContext): boolean {
|
|
52
|
+
return isSubagentExecutionContext(
|
|
53
|
+
ctx,
|
|
54
|
+
this.deps.subagentSessionsDir,
|
|
55
|
+
this.deps.platform,
|
|
56
|
+
this.deps.registry,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isRegisteredChild(ctx: SubagentDetectionContext): boolean {
|
|
61
|
+
return this.deps.registry
|
|
62
|
+
? isRegisteredSubagentChild(ctx, this.deps.registry)
|
|
63
|
+
: false;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/config-loader.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { normalize } from "node:path";
|
|
3
|
+
import type { ZodError } from "zod";
|
|
3
4
|
import {
|
|
4
5
|
getGlobalConfigPath,
|
|
5
6
|
getLegacyExtensionConfigPath,
|
|
@@ -7,32 +8,19 @@ import {
|
|
|
7
8
|
getLegacyProjectPolicyPath,
|
|
8
9
|
getProjectConfigPath,
|
|
9
10
|
} from "./config-paths";
|
|
11
|
+
import {
|
|
12
|
+
type UnifiedPermissionConfig,
|
|
13
|
+
unifiedConfigSchema,
|
|
14
|
+
} from "./config-schema";
|
|
10
15
|
import { mergeFlatPermissions } from "./permission-merge";
|
|
11
16
|
import type { FlatPermissionConfig, PatternValue } from "./types";
|
|
12
|
-
import {
|
|
13
|
-
isDenyWithReason,
|
|
14
|
-
isPermissionState,
|
|
15
|
-
normalizeOptionalPositiveInt,
|
|
16
|
-
normalizeOptionalStringArray,
|
|
17
|
-
toRecord,
|
|
18
|
-
} from "./value-guards";
|
|
17
|
+
import { isDenyWithReason, isPermissionState } from "./value-guards";
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
export
|
|
25
|
-
// Runtime knobs
|
|
26
|
-
debugLog?: boolean;
|
|
27
|
-
permissionReviewLog?: boolean;
|
|
28
|
-
yoloMode?: boolean;
|
|
29
|
-
toolInputPreviewMaxLength?: number;
|
|
30
|
-
toolTextSummaryMaxLength?: number;
|
|
31
|
-
piInfrastructureReadPaths?: string[];
|
|
32
|
-
|
|
33
|
-
// Flat permission policy
|
|
34
|
-
permission?: FlatPermissionConfig;
|
|
35
|
-
}
|
|
19
|
+
// The unified config shape is derived from the zod schema (config-schema.ts,
|
|
20
|
+
// the single source of truth) and re-exported so existing importers keep their
|
|
21
|
+
// import path. All fields are optional so partial configs merge before
|
|
22
|
+
// defaults are applied downstream.
|
|
23
|
+
export type { UnifiedPermissionConfig };
|
|
36
24
|
|
|
37
25
|
export interface UnifiedConfigLoadResult {
|
|
38
26
|
config: UnifiedPermissionConfig;
|
|
@@ -118,20 +106,13 @@ function consumeString(input: string, start: number): ScanSegment {
|
|
|
118
106
|
return { output, nextIndex: i };
|
|
119
107
|
}
|
|
120
108
|
|
|
121
|
-
function normalizeOptionalBoolean(value: unknown): boolean | undefined {
|
|
122
|
-
if (typeof value === "boolean") {
|
|
123
|
-
return value;
|
|
124
|
-
}
|
|
125
|
-
return undefined;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
109
|
/**
|
|
129
110
|
* Normalize a raw `permission` value from parsed JSON into a FlatPermissionConfig.
|
|
130
111
|
* Accepts PermissionState strings and DenyWithReason objects inside pattern
|
|
131
112
|
* maps. Drops non-object top-level values, invalid PermissionState strings, and
|
|
132
113
|
* invalid action values inside object maps.
|
|
133
114
|
*/
|
|
134
|
-
function normalizeFlatPermissionValue(
|
|
115
|
+
export function normalizeFlatPermissionValue(
|
|
135
116
|
value: unknown,
|
|
136
117
|
): FlatPermissionConfig | undefined {
|
|
137
118
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -172,52 +153,39 @@ function normalizeFlatPermissionValue(
|
|
|
172
153
|
}
|
|
173
154
|
|
|
174
155
|
/**
|
|
175
|
-
*
|
|
156
|
+
* Validate raw parsed JSON against the config schema (the single source of
|
|
157
|
+
* truth in `config-schema.ts`).
|
|
158
|
+
*
|
|
159
|
+
* On success the typed config is returned. On failure the whole scope config is
|
|
160
|
+
* rejected — fail-closed: an empty config contributes no rules, so missing
|
|
161
|
+
* surfaces fall through to the universal `ask` default rather than `allow` —
|
|
162
|
+
* and every schema violation is reported as a clear, actionable issue.
|
|
176
163
|
*/
|
|
177
|
-
export function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const debugLog = normalizeOptionalBoolean(record.debugLog);
|
|
187
|
-
if (debugLog !== undefined) config.debugLog = debugLog;
|
|
188
|
-
|
|
189
|
-
const permissionReviewLog = normalizeOptionalBoolean(
|
|
190
|
-
record.permissionReviewLog,
|
|
191
|
-
);
|
|
192
|
-
if (permissionReviewLog !== undefined)
|
|
193
|
-
config.permissionReviewLog = permissionReviewLog;
|
|
194
|
-
|
|
195
|
-
const yoloMode = normalizeOptionalBoolean(record.yoloMode);
|
|
196
|
-
if (yoloMode !== undefined) config.yoloMode = yoloMode;
|
|
197
|
-
|
|
198
|
-
const toolInputPreviewMaxLength = normalizeOptionalPositiveInt(
|
|
199
|
-
record.toolInputPreviewMaxLength,
|
|
200
|
-
);
|
|
201
|
-
if (toolInputPreviewMaxLength !== undefined)
|
|
202
|
-
config.toolInputPreviewMaxLength = toolInputPreviewMaxLength;
|
|
203
|
-
|
|
204
|
-
const toolTextSummaryMaxLength = normalizeOptionalPositiveInt(
|
|
205
|
-
record.toolTextSummaryMaxLength,
|
|
206
|
-
);
|
|
207
|
-
if (toolTextSummaryMaxLength !== undefined)
|
|
208
|
-
config.toolTextSummaryMaxLength = toolTextSummaryMaxLength;
|
|
209
|
-
|
|
210
|
-
const piInfrastructureReadPaths = normalizeOptionalStringArray(
|
|
211
|
-
record.piInfrastructureReadPaths,
|
|
212
|
-
);
|
|
213
|
-
if (piInfrastructureReadPaths !== undefined)
|
|
214
|
-
config.piInfrastructureReadPaths = piInfrastructureReadPaths;
|
|
215
|
-
|
|
216
|
-
// Flat permission policy
|
|
217
|
-
const permission = normalizeFlatPermissionValue(record.permission);
|
|
218
|
-
if (permission !== undefined) config.permission = permission;
|
|
164
|
+
export function validateUnifiedConfig(
|
|
165
|
+
parsed: unknown,
|
|
166
|
+
): UnifiedConfigLoadResult {
|
|
167
|
+
const result = unifiedConfigSchema.safeParse(parsed);
|
|
168
|
+
if (result.success) {
|
|
169
|
+
return { config: result.data, issues: [] };
|
|
170
|
+
}
|
|
171
|
+
return { config: {}, issues: formatConfigIssues(result.error) };
|
|
172
|
+
}
|
|
219
173
|
|
|
220
|
-
|
|
174
|
+
/** Render each schema violation as a clear, path-qualified message. */
|
|
175
|
+
function formatConfigIssues(error: ZodError): string[] {
|
|
176
|
+
const messages: string[] = [];
|
|
177
|
+
for (const issue of error.issues) {
|
|
178
|
+
if (issue.code === "unrecognized_keys") {
|
|
179
|
+
for (const key of issue.keys) {
|
|
180
|
+
messages.push(`Unrecognized config key '${key}'.`);
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const location =
|
|
185
|
+
issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
|
|
186
|
+
messages.push(`Invalid config value at '${location}': ${issue.message}`);
|
|
187
|
+
}
|
|
188
|
+
return messages;
|
|
221
189
|
}
|
|
222
190
|
|
|
223
191
|
/**
|
|
@@ -320,7 +288,8 @@ export function loadAndMergeConfigs(
|
|
|
320
288
|
`Move it to '${newGlobalPath}':\n` +
|
|
321
289
|
` mv '${legacyGlobalPolicyPath}' '${newGlobalPath}'`,
|
|
322
290
|
);
|
|
323
|
-
|
|
291
|
+
// Legacy files are migrated away; the move-it guidance above is the
|
|
292
|
+
// actionable signal, so strict-validation issues for them are suppressed.
|
|
324
293
|
merged = mergeUnifiedConfigs(merged, legacy.config);
|
|
325
294
|
}
|
|
326
295
|
|
|
@@ -337,7 +306,7 @@ export function loadAndMergeConfigs(
|
|
|
337
306
|
`Move runtime settings to '${newGlobalPath}':\n` +
|
|
338
307
|
` mv '${legacyExtConfigPath}' '${newGlobalPath}'`,
|
|
339
308
|
);
|
|
340
|
-
|
|
309
|
+
// See above: legacy-file validation issues are suppressed.
|
|
341
310
|
merged = mergeUnifiedConfigs(merged, legacy.config);
|
|
342
311
|
}
|
|
343
312
|
|
|
@@ -355,7 +324,7 @@ export function loadAndMergeConfigs(
|
|
|
355
324
|
`Move it to '${newProjectPath}':\n` +
|
|
356
325
|
` mv '${legacyProjectPolicyPath}' '${newProjectPath}'`,
|
|
357
326
|
);
|
|
358
|
-
|
|
327
|
+
// See above: legacy-file validation issues are suppressed.
|
|
359
328
|
merged = mergeUnifiedConfigs(merged, legacy.config);
|
|
360
329
|
}
|
|
361
330
|
|
|
@@ -421,7 +390,7 @@ export function loadUnifiedConfig(path: string): UnifiedConfigLoadResult {
|
|
|
421
390
|
try {
|
|
422
391
|
const raw = readFileSync(path, "utf-8");
|
|
423
392
|
const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
|
|
424
|
-
return
|
|
393
|
+
return validateUnifiedConfig(parsed);
|
|
425
394
|
} catch (error) {
|
|
426
395
|
const message = error instanceof Error ? error.message : String(error);
|
|
427
396
|
return {
|