@frontmcp/plugin-approval 1.8.1 → 1.8.3
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/approval/policy.d.ts +30 -0
- package/approval/policy.d.ts.map +1 -0
- package/approval.identity.d.ts +3 -5
- package/approval.identity.d.ts.map +1 -1
- package/approval.plugin.d.ts.map +1 -1
- package/esm/index.mjs +375 -239
- package/esm/package.json +4 -4
- package/hooks/approval-check.hook.d.ts +9 -2
- package/hooks/approval-check.hook.d.ts.map +1 -1
- package/index.js +389 -254
- package/package.json +4 -4
- package/services/approval.service.d.ts +17 -6
- package/services/approval.service.d.ts.map +1 -1
- package/stores/approval-storage.store.d.ts +30 -3
- package/stores/approval-storage.store.d.ts.map +1 -1
- package/stores/approval-store.interface.d.ts +11 -3
- package/stores/approval-store.interface.d.ts.map +1 -1
package/esm/index.mjs
CHANGED
|
@@ -22,11 +22,210 @@ import {
|
|
|
22
22
|
DynamicPlugin as DynamicPlugin2,
|
|
23
23
|
FRONTMCP_CONTEXT,
|
|
24
24
|
Plugin as Plugin2,
|
|
25
|
-
ProviderScope as ProviderScope4
|
|
25
|
+
ProviderScope as ProviderScope4,
|
|
26
|
+
ScopeEntry
|
|
26
27
|
} from "@frontmcp/sdk";
|
|
27
28
|
|
|
29
|
+
// plugins/plugin-approval/src/approval/errors.ts
|
|
30
|
+
var ApprovalError = class extends Error {
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "ApprovalError";
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var ApprovalRequiredError = class extends ApprovalError {
|
|
37
|
+
constructor(details) {
|
|
38
|
+
super(details.message);
|
|
39
|
+
this.details = details;
|
|
40
|
+
this.name = "ApprovalRequiredError";
|
|
41
|
+
}
|
|
42
|
+
details;
|
|
43
|
+
/**
|
|
44
|
+
* Convert to a JSON-RPC compatible error structure.
|
|
45
|
+
*/
|
|
46
|
+
toJsonRpcError() {
|
|
47
|
+
return {
|
|
48
|
+
code: -32600,
|
|
49
|
+
// Invalid Request
|
|
50
|
+
message: this.details.message,
|
|
51
|
+
data: {
|
|
52
|
+
type: "approval_required",
|
|
53
|
+
toolId: this.details.toolId,
|
|
54
|
+
state: this.details.state,
|
|
55
|
+
options: this.details.approvalOptions
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
var ApprovalOperationError = class extends ApprovalError {
|
|
61
|
+
constructor(operation, reason) {
|
|
62
|
+
super(`Approval ${operation} failed: ${reason}`);
|
|
63
|
+
this.operation = operation;
|
|
64
|
+
this.reason = reason;
|
|
65
|
+
this.name = "ApprovalOperationError";
|
|
66
|
+
}
|
|
67
|
+
operation;
|
|
68
|
+
reason;
|
|
69
|
+
/**
|
|
70
|
+
* Convert to a JSON-RPC compatible error structure.
|
|
71
|
+
*/
|
|
72
|
+
toJsonRpcError() {
|
|
73
|
+
return {
|
|
74
|
+
code: -32603,
|
|
75
|
+
// Internal Error
|
|
76
|
+
message: "Approval operation failed",
|
|
77
|
+
data: {
|
|
78
|
+
type: "approval_operation_error",
|
|
79
|
+
operation: this.operation
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
var ApprovalScopeNotAllowedError = class extends ApprovalError {
|
|
85
|
+
constructor(requestedScope, allowedScopes) {
|
|
86
|
+
super(
|
|
87
|
+
`Approval scope '${requestedScope}' is not allowed for this tool. Allowed scopes: ${allowedScopes.join(", ")}`
|
|
88
|
+
);
|
|
89
|
+
this.requestedScope = requestedScope;
|
|
90
|
+
this.allowedScopes = allowedScopes;
|
|
91
|
+
this.name = "ApprovalScopeNotAllowedError";
|
|
92
|
+
}
|
|
93
|
+
requestedScope;
|
|
94
|
+
allowedScopes;
|
|
95
|
+
/**
|
|
96
|
+
* Convert to a JSON-RPC compatible error structure.
|
|
97
|
+
*/
|
|
98
|
+
toJsonRpcError() {
|
|
99
|
+
return {
|
|
100
|
+
code: -32602,
|
|
101
|
+
// Invalid Params
|
|
102
|
+
message: this.message,
|
|
103
|
+
data: {
|
|
104
|
+
type: "approval_scope_not_allowed",
|
|
105
|
+
requestedScope: this.requestedScope,
|
|
106
|
+
allowedScopes: this.allowedScopes
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
var ApprovalExpiredError = class extends ApprovalError {
|
|
112
|
+
constructor(toolId, expiredAt) {
|
|
113
|
+
super(`Approval for tool '${toolId}' expired at ${new Date(expiredAt).toISOString()}`);
|
|
114
|
+
this.toolId = toolId;
|
|
115
|
+
this.expiredAt = expiredAt;
|
|
116
|
+
this.name = "ApprovalExpiredError";
|
|
117
|
+
}
|
|
118
|
+
toolId;
|
|
119
|
+
expiredAt;
|
|
120
|
+
/**
|
|
121
|
+
* Convert to a JSON-RPC compatible error structure.
|
|
122
|
+
*/
|
|
123
|
+
toJsonRpcError() {
|
|
124
|
+
return {
|
|
125
|
+
code: -32600,
|
|
126
|
+
// Invalid Request
|
|
127
|
+
message: this.message,
|
|
128
|
+
data: {
|
|
129
|
+
type: "approval_expired",
|
|
130
|
+
toolId: this.toolId,
|
|
131
|
+
expiredAt: this.expiredAt
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
var ChallengeValidationError = class extends ApprovalError {
|
|
137
|
+
constructor(reason = "invalid", message) {
|
|
138
|
+
super(message ?? `PKCE challenge validation failed: ${reason}`);
|
|
139
|
+
this.reason = reason;
|
|
140
|
+
this.name = "ChallengeValidationError";
|
|
141
|
+
}
|
|
142
|
+
reason;
|
|
143
|
+
/**
|
|
144
|
+
* Convert to a JSON-RPC compatible error structure.
|
|
145
|
+
*/
|
|
146
|
+
toJsonRpcError() {
|
|
147
|
+
return {
|
|
148
|
+
code: -32600,
|
|
149
|
+
// Invalid Request
|
|
150
|
+
message: this.message,
|
|
151
|
+
data: {
|
|
152
|
+
type: "challenge_validation_error",
|
|
153
|
+
reason: this.reason
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// plugins/plugin-approval/src/approval/types.ts
|
|
160
|
+
var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
|
|
161
|
+
ApprovalScope2["SESSION"] = "session";
|
|
162
|
+
ApprovalScope2["USER"] = "user";
|
|
163
|
+
ApprovalScope2["TIME_LIMITED"] = "time_limited";
|
|
164
|
+
ApprovalScope2["TOOL_SPECIFIC"] = "tool_specific";
|
|
165
|
+
ApprovalScope2["CONTEXT_SPECIFIC"] = "context_specific";
|
|
166
|
+
return ApprovalScope2;
|
|
167
|
+
})(ApprovalScope || {});
|
|
168
|
+
var ApprovalState = /* @__PURE__ */ ((ApprovalState2) => {
|
|
169
|
+
ApprovalState2["PENDING"] = "pending";
|
|
170
|
+
ApprovalState2["APPROVED"] = "approved";
|
|
171
|
+
ApprovalState2["DENIED"] = "denied";
|
|
172
|
+
ApprovalState2["EXPIRED"] = "expired";
|
|
173
|
+
return ApprovalState2;
|
|
174
|
+
})(ApprovalState || {});
|
|
175
|
+
|
|
176
|
+
// plugins/plugin-approval/src/approval/policy.ts
|
|
177
|
+
function resolveApprovalRequirement(config) {
|
|
178
|
+
if (config === true) {
|
|
179
|
+
return { required: true, defaultScope: "session" /* SESSION */ };
|
|
180
|
+
}
|
|
181
|
+
if (config === false || config === void 0) {
|
|
182
|
+
return { required: false };
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
...config,
|
|
186
|
+
required: config.required ?? true,
|
|
187
|
+
defaultScope: config.defaultScope ?? "session" /* SESSION */
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function effectiveExpiresAt(record, requirement) {
|
|
191
|
+
const capped = requirement.maxTtlMs !== void 0 ? record.grantedAt + requirement.maxTtlMs : void 0;
|
|
192
|
+
if (record.expiresAt === void 0) return capped;
|
|
193
|
+
return capped === void 0 ? record.expiresAt : Math.min(record.expiresAt, capped);
|
|
194
|
+
}
|
|
195
|
+
function isApprovalExpired(record, requirement, now) {
|
|
196
|
+
const expiresAt = effectiveExpiresAt(record, requirement);
|
|
197
|
+
return expiresAt !== void 0 && now > expiresAt;
|
|
198
|
+
}
|
|
199
|
+
function isApprovalUsable(record, requirement, now) {
|
|
200
|
+
if (record.state !== "approved" /* APPROVED */) return false;
|
|
201
|
+
if (requirement.allowedScopes?.length && !requirement.allowedScopes.includes(record.scope)) return false;
|
|
202
|
+
return !isApprovalExpired(record, requirement, now);
|
|
203
|
+
}
|
|
204
|
+
function assertValidTtl(ttlMs) {
|
|
205
|
+
if (ttlMs === void 0) return;
|
|
206
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
207
|
+
throw new ApprovalOperationError("grant", `ttlMs must be a positive number of milliseconds, got ${ttlMs}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function checkGrantAgainstPolicy(toolId, scope, ttlMs, requirement) {
|
|
211
|
+
assertValidTtl(ttlMs);
|
|
212
|
+
if (!requirement) return ttlMs;
|
|
213
|
+
if (requirement.allowedScopes?.length && !requirement.allowedScopes.includes(scope)) {
|
|
214
|
+
throw new ApprovalScopeNotAllowedError(scope, requirement.allowedScopes);
|
|
215
|
+
}
|
|
216
|
+
const { maxTtlMs } = requirement;
|
|
217
|
+
if (maxTtlMs === void 0) return ttlMs;
|
|
218
|
+
if (ttlMs === void 0) return maxTtlMs;
|
|
219
|
+
if (ttlMs > maxTtlMs) {
|
|
220
|
+
throw new ApprovalOperationError(
|
|
221
|
+
"grant",
|
|
222
|
+
`ttlMs ${ttlMs} exceeds the maximum of ${maxTtlMs} ms that tool "${toolId}" allows`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return ttlMs;
|
|
226
|
+
}
|
|
227
|
+
|
|
28
228
|
// plugins/plugin-approval/src/approval.identity.ts
|
|
29
|
-
import { STATELESS_SESSION_ID } from "@frontmcp/sdk";
|
|
30
229
|
import { randomUUID } from "@frontmcp/utils";
|
|
31
230
|
function nonEmptyString(value) {
|
|
32
231
|
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
@@ -34,8 +233,8 @@ function nonEmptyString(value) {
|
|
|
34
233
|
function resolveApprovalIdentity(ctx) {
|
|
35
234
|
const extra = ctx?.authInfo?.extra;
|
|
36
235
|
const userId = nonEmptyString(extra?.["userId"]) ?? nonEmptyString(extra?.["sub"]) ?? nonEmptyString(ctx?.authInfo?.clientId);
|
|
37
|
-
const sessionId = nonEmptyString(ctx?.
|
|
38
|
-
if (sessionId
|
|
236
|
+
const sessionId = nonEmptyString(ctx?.verifiedSessionId);
|
|
237
|
+
if (sessionId) {
|
|
39
238
|
return { sessionId, userId };
|
|
40
239
|
}
|
|
41
240
|
return { sessionId: userId ? `stateless-user:${userId}` : `unidentified:${randomUUID()}`, userId };
|
|
@@ -55,23 +254,6 @@ var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
|
|
|
55
254
|
// plugins/plugin-approval/src/hooks/approval-check.hook.ts
|
|
56
255
|
import { DynamicPlugin, Plugin, ToolHook } from "@frontmcp/sdk";
|
|
57
256
|
|
|
58
|
-
// plugins/plugin-approval/src/approval/types.ts
|
|
59
|
-
var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
|
|
60
|
-
ApprovalScope2["SESSION"] = "session";
|
|
61
|
-
ApprovalScope2["USER"] = "user";
|
|
62
|
-
ApprovalScope2["TIME_LIMITED"] = "time_limited";
|
|
63
|
-
ApprovalScope2["TOOL_SPECIFIC"] = "tool_specific";
|
|
64
|
-
ApprovalScope2["CONTEXT_SPECIFIC"] = "context_specific";
|
|
65
|
-
return ApprovalScope2;
|
|
66
|
-
})(ApprovalScope || {});
|
|
67
|
-
var ApprovalState = /* @__PURE__ */ ((ApprovalState2) => {
|
|
68
|
-
ApprovalState2["PENDING"] = "pending";
|
|
69
|
-
ApprovalState2["APPROVED"] = "approved";
|
|
70
|
-
ApprovalState2["DENIED"] = "denied";
|
|
71
|
-
ApprovalState2["EXPIRED"] = "expired";
|
|
72
|
-
return ApprovalState2;
|
|
73
|
-
})(ApprovalState || {});
|
|
74
|
-
|
|
75
257
|
// plugins/plugin-approval/src/approval/schemas.ts
|
|
76
258
|
import { z } from "@frontmcp/lazy-zod";
|
|
77
259
|
var approvalScopeSchema = z.nativeEnum(ApprovalScope);
|
|
@@ -288,136 +470,6 @@ function isApiGrantor(grantor) {
|
|
|
288
470
|
return grantor.source === "api" || grantor.source === "oauth";
|
|
289
471
|
}
|
|
290
472
|
|
|
291
|
-
// plugins/plugin-approval/src/approval/errors.ts
|
|
292
|
-
var ApprovalError = class extends Error {
|
|
293
|
-
constructor(message) {
|
|
294
|
-
super(message);
|
|
295
|
-
this.name = "ApprovalError";
|
|
296
|
-
}
|
|
297
|
-
};
|
|
298
|
-
var ApprovalRequiredError = class extends ApprovalError {
|
|
299
|
-
constructor(details) {
|
|
300
|
-
super(details.message);
|
|
301
|
-
this.details = details;
|
|
302
|
-
this.name = "ApprovalRequiredError";
|
|
303
|
-
}
|
|
304
|
-
details;
|
|
305
|
-
/**
|
|
306
|
-
* Convert to a JSON-RPC compatible error structure.
|
|
307
|
-
*/
|
|
308
|
-
toJsonRpcError() {
|
|
309
|
-
return {
|
|
310
|
-
code: -32600,
|
|
311
|
-
// Invalid Request
|
|
312
|
-
message: this.details.message,
|
|
313
|
-
data: {
|
|
314
|
-
type: "approval_required",
|
|
315
|
-
toolId: this.details.toolId,
|
|
316
|
-
state: this.details.state,
|
|
317
|
-
options: this.details.approvalOptions
|
|
318
|
-
}
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
};
|
|
322
|
-
var ApprovalOperationError = class extends ApprovalError {
|
|
323
|
-
constructor(operation, reason) {
|
|
324
|
-
super(`Approval ${operation} failed: ${reason}`);
|
|
325
|
-
this.operation = operation;
|
|
326
|
-
this.reason = reason;
|
|
327
|
-
this.name = "ApprovalOperationError";
|
|
328
|
-
}
|
|
329
|
-
operation;
|
|
330
|
-
reason;
|
|
331
|
-
/**
|
|
332
|
-
* Convert to a JSON-RPC compatible error structure.
|
|
333
|
-
*/
|
|
334
|
-
toJsonRpcError() {
|
|
335
|
-
return {
|
|
336
|
-
code: -32603,
|
|
337
|
-
// Internal Error
|
|
338
|
-
message: "Approval operation failed",
|
|
339
|
-
data: {
|
|
340
|
-
type: "approval_operation_error",
|
|
341
|
-
operation: this.operation
|
|
342
|
-
}
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
};
|
|
346
|
-
var ApprovalScopeNotAllowedError = class extends ApprovalError {
|
|
347
|
-
constructor(requestedScope, allowedScopes) {
|
|
348
|
-
super(
|
|
349
|
-
`Approval scope '${requestedScope}' is not allowed for this tool. Allowed scopes: ${allowedScopes.join(", ")}`
|
|
350
|
-
);
|
|
351
|
-
this.requestedScope = requestedScope;
|
|
352
|
-
this.allowedScopes = allowedScopes;
|
|
353
|
-
this.name = "ApprovalScopeNotAllowedError";
|
|
354
|
-
}
|
|
355
|
-
requestedScope;
|
|
356
|
-
allowedScopes;
|
|
357
|
-
/**
|
|
358
|
-
* Convert to a JSON-RPC compatible error structure.
|
|
359
|
-
*/
|
|
360
|
-
toJsonRpcError() {
|
|
361
|
-
return {
|
|
362
|
-
code: -32602,
|
|
363
|
-
// Invalid Params
|
|
364
|
-
message: this.message,
|
|
365
|
-
data: {
|
|
366
|
-
type: "approval_scope_not_allowed",
|
|
367
|
-
requestedScope: this.requestedScope,
|
|
368
|
-
allowedScopes: this.allowedScopes
|
|
369
|
-
}
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
var ApprovalExpiredError = class extends ApprovalError {
|
|
374
|
-
constructor(toolId, expiredAt) {
|
|
375
|
-
super(`Approval for tool '${toolId}' expired at ${new Date(expiredAt).toISOString()}`);
|
|
376
|
-
this.toolId = toolId;
|
|
377
|
-
this.expiredAt = expiredAt;
|
|
378
|
-
this.name = "ApprovalExpiredError";
|
|
379
|
-
}
|
|
380
|
-
toolId;
|
|
381
|
-
expiredAt;
|
|
382
|
-
/**
|
|
383
|
-
* Convert to a JSON-RPC compatible error structure.
|
|
384
|
-
*/
|
|
385
|
-
toJsonRpcError() {
|
|
386
|
-
return {
|
|
387
|
-
code: -32600,
|
|
388
|
-
// Invalid Request
|
|
389
|
-
message: this.message,
|
|
390
|
-
data: {
|
|
391
|
-
type: "approval_expired",
|
|
392
|
-
toolId: this.toolId,
|
|
393
|
-
expiredAt: this.expiredAt
|
|
394
|
-
}
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
};
|
|
398
|
-
var ChallengeValidationError = class extends ApprovalError {
|
|
399
|
-
constructor(reason = "invalid", message) {
|
|
400
|
-
super(message ?? `PKCE challenge validation failed: ${reason}`);
|
|
401
|
-
this.reason = reason;
|
|
402
|
-
this.name = "ChallengeValidationError";
|
|
403
|
-
}
|
|
404
|
-
reason;
|
|
405
|
-
/**
|
|
406
|
-
* Convert to a JSON-RPC compatible error structure.
|
|
407
|
-
*/
|
|
408
|
-
toJsonRpcError() {
|
|
409
|
-
return {
|
|
410
|
-
code: -32600,
|
|
411
|
-
// Invalid Request
|
|
412
|
-
message: this.message,
|
|
413
|
-
data: {
|
|
414
|
-
type: "challenge_validation_error",
|
|
415
|
-
reason: this.reason
|
|
416
|
-
}
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
};
|
|
420
|
-
|
|
421
473
|
// plugins/plugin-approval/src/hooks/approval-check.hook.ts
|
|
422
474
|
var passedApprovalStores = /* @__PURE__ */ new WeakMap();
|
|
423
475
|
var ApprovalCheckPlugin = class extends DynamicPlugin {
|
|
@@ -425,7 +477,7 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
|
|
|
425
477
|
const { tool, toolContext } = flowCtx.state;
|
|
426
478
|
if (!tool || !toolContext) return;
|
|
427
479
|
const metadata = tool.metadata;
|
|
428
|
-
const approvalConfig =
|
|
480
|
+
const approvalConfig = resolveApprovalRequirement(
|
|
429
481
|
metadata["approval"]
|
|
430
482
|
);
|
|
431
483
|
if (!approvalConfig.required) {
|
|
@@ -443,44 +495,43 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
|
|
|
443
495
|
}
|
|
444
496
|
async enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore) {
|
|
445
497
|
const { sessionId, userId } = resolveApprovalIdentity(toolContext.tryGetContext?.());
|
|
446
|
-
const
|
|
447
|
-
|
|
498
|
+
const currentContext = this.getCurrentContext(flowCtx);
|
|
499
|
+
const records = await this.readApprovals(approvalStore, tool.fullName, sessionId, userId, currentContext);
|
|
500
|
+
if (records.some((record) => record.state === "denied" /* DENIED */)) {
|
|
448
501
|
throw new ApprovalRequiredError({
|
|
449
502
|
toolId: tool.fullName,
|
|
450
503
|
state: "denied",
|
|
451
504
|
message: `Tool "${tool.fullName}" execution denied.`
|
|
452
505
|
});
|
|
453
506
|
}
|
|
454
|
-
if (this.isPreApprovedContext(approvalConfig,
|
|
507
|
+
if (this.isPreApprovedContext(approvalConfig, currentContext)) {
|
|
455
508
|
return;
|
|
456
509
|
}
|
|
510
|
+
const approved = records.filter((record) => record.state === "approved" /* APPROVED */);
|
|
457
511
|
if (approvalConfig.alwaysPrompt) {
|
|
458
|
-
await this.handleApprovalRequired(flowCtx, approvalConfig,
|
|
512
|
+
await this.handleApprovalRequired(flowCtx, approvalConfig, approved[0]);
|
|
459
513
|
return;
|
|
460
514
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
515
|
+
const now = Date.now();
|
|
516
|
+
if (approved.some((record) => isApprovalUsable(record, approvalConfig, now))) {
|
|
517
|
+
return;
|
|
465
518
|
}
|
|
466
|
-
await this.handleApprovalRequired(
|
|
519
|
+
await this.handleApprovalRequired(
|
|
520
|
+
flowCtx,
|
|
521
|
+
approvalConfig,
|
|
522
|
+
approved.find((record) => isApprovalExpired(record, approvalConfig, now)) ?? approved[0]
|
|
523
|
+
);
|
|
467
524
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
525
|
+
/**
|
|
526
|
+
* The caller's approvals and denials of the tool, from the context it runs in too. Stores
|
|
527
|
+
* without `getApprovals()` give the one record `getApproval()` picks.
|
|
528
|
+
*/
|
|
529
|
+
async readApprovals(store, toolId, sessionId, userId, context) {
|
|
530
|
+
if (store.getApprovals) {
|
|
531
|
+
return store.getApprovals(toolId, sessionId, userId, context);
|
|
474
532
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
required: config.required ?? true,
|
|
478
|
-
defaultScope: config.defaultScope ?? "session" /* SESSION */
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
isExpired(approval) {
|
|
482
|
-
if (!approval.expiresAt) return false;
|
|
483
|
-
return Date.now() > approval.expiresAt;
|
|
533
|
+
const record = await store.getApproval(toolId, sessionId, userId, context);
|
|
534
|
+
return record ? [record] : [];
|
|
484
535
|
}
|
|
485
536
|
/**
|
|
486
537
|
* The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
|
|
@@ -510,7 +561,7 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
|
|
|
510
561
|
async handleApprovalRequired(flowCtx, config, existingApproval) {
|
|
511
562
|
const { tool } = flowCtx.state;
|
|
512
563
|
const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
|
|
513
|
-
const isExpiredApproval = existingApproval ?
|
|
564
|
+
const isExpiredApproval = existingApproval ? isApprovalExpired(existingApproval, config, Date.now()) : false;
|
|
514
565
|
throw new ApprovalRequiredError({
|
|
515
566
|
toolId: tool?.fullName ?? "unknown",
|
|
516
567
|
state: isExpiredApproval ? "expired" : "pending",
|
|
@@ -526,26 +577,34 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
|
|
|
526
577
|
}
|
|
527
578
|
};
|
|
528
579
|
__decorateClass([
|
|
529
|
-
ToolHook.Will("execute", { priority: 100 })
|
|
580
|
+
ToolHook.Will("execute", { priority: 100, appliesTo: "uncovered-apps" })
|
|
530
581
|
], ApprovalCheckPlugin.prototype, "checkApproval", 1);
|
|
531
582
|
ApprovalCheckPlugin = __decorateClass([
|
|
532
583
|
Plugin({
|
|
533
584
|
name: "approval:check",
|
|
534
|
-
description: "Checks tool approval state before execution"
|
|
585
|
+
description: "Checks tool approval state before execution",
|
|
586
|
+
// A server where a tool or agent declares `approval` and this gate does not reach it refuses to start.
|
|
587
|
+
enforcesMetadata: ["approval"]
|
|
535
588
|
})
|
|
536
589
|
], ApprovalCheckPlugin);
|
|
537
590
|
|
|
538
591
|
// plugins/plugin-approval/src/services/approval.service.ts
|
|
539
592
|
import { Provider, ProviderScope } from "@frontmcp/sdk";
|
|
540
593
|
var ApprovalService = class {
|
|
541
|
-
constructor(store, sessionId, userId) {
|
|
594
|
+
constructor(store, sessionId, userId, requirementOf = () => void 0) {
|
|
542
595
|
this.store = store;
|
|
543
596
|
this.sessionId = sessionId;
|
|
544
597
|
this.userId = userId;
|
|
598
|
+
this.requirementOf = requirementOf;
|
|
545
599
|
}
|
|
546
600
|
store;
|
|
547
601
|
sessionId;
|
|
548
602
|
userId;
|
|
603
|
+
requirementOf;
|
|
604
|
+
/** Checks a grant against the tool's `allowedScopes` and `maxTtlMs`; returns the ttl to store. */
|
|
605
|
+
checkGrant(toolId, scope, ttlMs) {
|
|
606
|
+
return checkGrantAgainstPolicy(toolId, scope, ttlMs, this.requirementOf(toolId));
|
|
607
|
+
}
|
|
549
608
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
550
609
|
// Query Methods
|
|
551
610
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -553,7 +612,14 @@ var ApprovalService = class {
|
|
|
553
612
|
* Check if a tool is approved for current session/user.
|
|
554
613
|
*/
|
|
555
614
|
async isApproved(toolId, context) {
|
|
556
|
-
|
|
615
|
+
const requirement = this.requirementOf(toolId);
|
|
616
|
+
if (!requirement || !this.store.getApprovals) {
|
|
617
|
+
return this.store.isApproved(toolId, this.sessionId, this.userId, context);
|
|
618
|
+
}
|
|
619
|
+
const records = await this.store.getApprovals(toolId, this.sessionId, this.userId, context);
|
|
620
|
+
if (records.some((record) => record.state === "denied" /* DENIED */)) return false;
|
|
621
|
+
const now = Date.now();
|
|
622
|
+
return records.some((record) => isApprovalUsable(record, requirement, now));
|
|
557
623
|
}
|
|
558
624
|
/**
|
|
559
625
|
* Get approval record for a tool.
|
|
@@ -584,14 +650,15 @@ var ApprovalService = class {
|
|
|
584
650
|
});
|
|
585
651
|
}
|
|
586
652
|
/**
|
|
587
|
-
* Query approvals with custom
|
|
653
|
+
* Query the current caller's approvals (those of its session and those of its user) with custom
|
|
654
|
+
* filters. A `sessionId` or `userId` in the query narrows the result further; it never reaches
|
|
655
|
+
* another caller's records. Read the store directly for administrative queries.
|
|
588
656
|
*/
|
|
589
657
|
async queryApprovals(query) {
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
sessionId
|
|
593
|
-
|
|
594
|
-
});
|
|
658
|
+
const records = await this.store.queryApprovals(query);
|
|
659
|
+
return records.filter(
|
|
660
|
+
(record) => record.sessionId === this.sessionId || this.userId !== void 0 && record.userId === this.userId
|
|
661
|
+
);
|
|
595
662
|
}
|
|
596
663
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
597
664
|
// Grant Methods
|
|
@@ -600,9 +667,11 @@ var ApprovalService = class {
|
|
|
600
667
|
* Grant session-scoped approval for a tool.
|
|
601
668
|
*/
|
|
602
669
|
async grantSessionApproval(toolId, options = {}) {
|
|
670
|
+
const ttlMs = this.checkGrant(toolId, "session" /* SESSION */);
|
|
603
671
|
return this.store.grantApproval({
|
|
604
672
|
toolId,
|
|
605
673
|
scope: "session" /* SESSION */,
|
|
674
|
+
ttlMs,
|
|
606
675
|
sessionId: this.sessionId,
|
|
607
676
|
grantedBy: options.grantedBy ?? "policy",
|
|
608
677
|
reason: options.reason,
|
|
@@ -616,9 +685,11 @@ var ApprovalService = class {
|
|
|
616
685
|
if (!this.userId) {
|
|
617
686
|
throw new Error("Cannot grant user approval without userId");
|
|
618
687
|
}
|
|
688
|
+
const ttlMs = this.checkGrant(toolId, "user" /* USER */);
|
|
619
689
|
return this.store.grantApproval({
|
|
620
690
|
toolId,
|
|
621
691
|
scope: "user" /* USER */,
|
|
692
|
+
ttlMs,
|
|
622
693
|
userId: this.userId,
|
|
623
694
|
grantedBy: options.grantedBy ?? "policy",
|
|
624
695
|
reason: options.reason,
|
|
@@ -629,6 +700,7 @@ var ApprovalService = class {
|
|
|
629
700
|
* Grant time-limited approval for a tool.
|
|
630
701
|
*/
|
|
631
702
|
async grantTimeLimitedApproval(toolId, ttlMs, options = {}) {
|
|
703
|
+
this.checkGrant(toolId, "time_limited" /* TIME_LIMITED */, ttlMs);
|
|
632
704
|
return this.store.grantApproval({
|
|
633
705
|
toolId,
|
|
634
706
|
scope: "time_limited" /* TIME_LIMITED */,
|
|
@@ -644,9 +716,11 @@ var ApprovalService = class {
|
|
|
644
716
|
* Grant context-specific approval for a tool.
|
|
645
717
|
*/
|
|
646
718
|
async grantContextApproval(toolId, context, options = {}) {
|
|
719
|
+
const ttlMs = this.checkGrant(toolId, "context_specific" /* CONTEXT_SPECIFIC */);
|
|
647
720
|
return this.store.grantApproval({
|
|
648
721
|
toolId,
|
|
649
722
|
scope: "context_specific" /* CONTEXT_SPECIFIC */,
|
|
723
|
+
ttlMs,
|
|
650
724
|
context,
|
|
651
725
|
sessionId: this.sessionId,
|
|
652
726
|
userId: this.userId,
|
|
@@ -659,7 +733,8 @@ var ApprovalService = class {
|
|
|
659
733
|
// Revoke Methods
|
|
660
734
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
661
735
|
/**
|
|
662
|
-
* Revoke
|
|
736
|
+
* Revoke the caller's approvals of a tool: its session, user, time-limited and context
|
|
737
|
+
* approvals. Returns whether anything was revoked. Recorded denials are kept.
|
|
663
738
|
*/
|
|
664
739
|
async revokeApproval(toolId, options = {}) {
|
|
665
740
|
return this.store.revokeApproval({
|
|
@@ -684,8 +759,8 @@ ApprovalService = __decorateClass([
|
|
|
684
759
|
scope: ProviderScope.CONTEXT
|
|
685
760
|
})
|
|
686
761
|
], ApprovalService);
|
|
687
|
-
function createApprovalService(store, sessionId, userId) {
|
|
688
|
-
return new ApprovalService(store, sessionId, userId);
|
|
762
|
+
function createApprovalService(store, sessionId, userId, requirementOf) {
|
|
763
|
+
return new ApprovalService(store, sessionId, userId, requirementOf);
|
|
689
764
|
}
|
|
690
765
|
|
|
691
766
|
// plugins/plugin-approval/src/services/challenge.service.ts
|
|
@@ -857,6 +932,7 @@ import {
|
|
|
857
932
|
function escapePattern(str) {
|
|
858
933
|
return str.replace(/[*?[\]\\]/g, "\\$&");
|
|
859
934
|
}
|
|
935
|
+
var TIME_LIMITED_KEY_SEGMENT = `scope:${"time_limited" /* TIME_LIMITED */}`;
|
|
860
936
|
var ApprovalStorageStore = class {
|
|
861
937
|
storage;
|
|
862
938
|
options;
|
|
@@ -906,6 +982,22 @@ var ApprovalStorageStore = class {
|
|
|
906
982
|
if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
|
|
907
983
|
return parts.join(":");
|
|
908
984
|
}
|
|
985
|
+
/**
|
|
986
|
+
* The key of a time-limited approval granted to a session alone or a user alone. Kept apart from
|
|
987
|
+
* that session's or user's own key, so a time-limited grant never replaces the session or user
|
|
988
|
+
* approval, or a denial recorded there. (With both a session and a user, `buildKey` is already distinct.)
|
|
989
|
+
*/
|
|
990
|
+
timeLimitedKey(toolId, sessionId, userId) {
|
|
991
|
+
return `${this.buildKey(toolId, sessionId, userId)}:${TIME_LIMITED_KEY_SEGMENT}`;
|
|
992
|
+
}
|
|
993
|
+
/** The key a new record is stored under. */
|
|
994
|
+
recordKey(options) {
|
|
995
|
+
const { toolId, scope, sessionId, userId, context } = options;
|
|
996
|
+
if (scope === "time_limited" /* TIME_LIMITED */ && !context && !(sessionId && userId)) {
|
|
997
|
+
return this.timeLimitedKey(toolId, sessionId, userId);
|
|
998
|
+
}
|
|
999
|
+
return this.buildKey(toolId, sessionId, userId, context);
|
|
1000
|
+
}
|
|
909
1001
|
parseRecord(value) {
|
|
910
1002
|
if (!value) return void 0;
|
|
911
1003
|
try {
|
|
@@ -923,28 +1015,55 @@ var ApprovalStorageStore = class {
|
|
|
923
1015
|
return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
|
|
924
1016
|
}
|
|
925
1017
|
/**
|
|
926
|
-
* The
|
|
927
|
-
*
|
|
1018
|
+
* The keys a caller's approvals of a tool can be stored under: its session, its user, both
|
|
1019
|
+
* (time-limited and context grants made through `ApprovalService`), the time-limited keys of its
|
|
1020
|
+
* session and its user, and, for a call in a server-established context, that context (granted
|
|
1021
|
+
* to the session and the user, or to the session before it had a user, the way a session approval
|
|
1022
|
+
* stays in force once the caller's user is known). Records written by 1.8.2 sit under the same
|
|
1023
|
+
* keys (a time-limited grant without a user under the session key), so they are still read.
|
|
928
1024
|
*/
|
|
929
|
-
|
|
930
|
-
this.
|
|
931
|
-
const keys = [this.buildKey(toolId, sessionId)];
|
|
1025
|
+
callerKeys(toolId, sessionId, userId, context) {
|
|
1026
|
+
const keys = [this.buildKey(toolId, sessionId), this.timeLimitedKey(toolId, sessionId)];
|
|
932
1027
|
if (userId) {
|
|
933
|
-
keys.push(
|
|
1028
|
+
keys.push(
|
|
1029
|
+
this.buildKey(toolId, void 0, userId),
|
|
1030
|
+
this.timeLimitedKey(toolId, void 0, userId),
|
|
1031
|
+
this.buildKey(toolId, sessionId, userId)
|
|
1032
|
+
);
|
|
934
1033
|
}
|
|
935
|
-
|
|
936
|
-
|
|
1034
|
+
if (context) {
|
|
1035
|
+
keys.push(this.buildKey(toolId, sessionId, void 0, context));
|
|
1036
|
+
if (userId) keys.push(this.buildKey(toolId, sessionId, userId, context));
|
|
1037
|
+
}
|
|
1038
|
+
return [...new Set(keys)];
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Every unexpired record that applies to this caller: session, user, time-limited and, when
|
|
1042
|
+
* `context` is given, context-specific approvals and denials.
|
|
1043
|
+
*/
|
|
1044
|
+
async getApprovals(toolId, sessionId, userId, context) {
|
|
1045
|
+
this.ensureInitialized();
|
|
1046
|
+
const records = [];
|
|
1047
|
+
for (const key of this.callerKeys(toolId, sessionId, userId, context)) {
|
|
937
1048
|
const record = this.parseRecord(await this.storage.get(key));
|
|
938
|
-
if (record && !this.isExpired(record)) {
|
|
939
|
-
|
|
1049
|
+
if (record && record.toolId === toolId && !this.isExpired(record)) {
|
|
1050
|
+
records.push(record);
|
|
940
1051
|
}
|
|
941
1052
|
}
|
|
942
|
-
return
|
|
1053
|
+
return records;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* The caller's record for a tool; a denial in any of its scopes wins, so an approval in one
|
|
1057
|
+
* scope cannot mask a denial in another.
|
|
1058
|
+
*/
|
|
1059
|
+
async getApproval(toolId, sessionId, userId, context) {
|
|
1060
|
+
const records = await this.getApprovals(toolId, sessionId, userId, context);
|
|
1061
|
+
return records.find((record) => record.state === "denied" /* DENIED */) ?? records[0];
|
|
943
1062
|
}
|
|
944
1063
|
async queryApprovals(query) {
|
|
945
1064
|
this.ensureInitialized();
|
|
946
1065
|
const results = [];
|
|
947
|
-
const pattern = query.toolId ? `${query.toolId}:*` : "*";
|
|
1066
|
+
const pattern = query.toolId ? `${escapePattern(query.toolId)}:*` : "*";
|
|
948
1067
|
const keys = await this.storage.keys(pattern);
|
|
949
1068
|
const values = await this.storage.mget(keys);
|
|
950
1069
|
for (const value of values) {
|
|
@@ -972,8 +1091,12 @@ var ApprovalStorageStore = class {
|
|
|
972
1091
|
}
|
|
973
1092
|
async grantApproval(options) {
|
|
974
1093
|
this.ensureInitialized();
|
|
1094
|
+
assertValidTtl(options.ttlMs);
|
|
1095
|
+
if (options.scope === "time_limited" /* TIME_LIMITED */ && options.ttlMs === void 0) {
|
|
1096
|
+
throw new ApprovalOperationError("grant", "a time-limited approval needs ttlMs");
|
|
1097
|
+
}
|
|
975
1098
|
const now = Date.now();
|
|
976
|
-
const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
|
|
1099
|
+
const expiresAt = options.ttlMs !== void 0 ? now + options.ttlMs : void 0;
|
|
977
1100
|
const grantedBy = normalizeGrantor(options.grantedBy);
|
|
978
1101
|
const record = {
|
|
979
1102
|
toolId: options.toolId,
|
|
@@ -989,52 +1112,59 @@ var ApprovalStorageStore = class {
|
|
|
989
1112
|
reason: options.reason,
|
|
990
1113
|
metadata: options.metadata
|
|
991
1114
|
};
|
|
992
|
-
const key = this.
|
|
993
|
-
const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
|
|
1115
|
+
const key = this.recordKey(options);
|
|
1116
|
+
const ttlSeconds = options.ttlMs !== void 0 ? Math.ceil(options.ttlMs / 1e3) : void 0;
|
|
994
1117
|
await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
|
|
995
1118
|
return record;
|
|
996
1119
|
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Deletes the approvals of a tool that belong to the given session or user: with a context, only
|
|
1122
|
+
* that context's approval; otherwise every approval of the tool stored for that session or user
|
|
1123
|
+
* (session, user, time-limited and context approvals alike). Recorded denials are kept.
|
|
1124
|
+
*/
|
|
997
1125
|
async revokeApproval(options) {
|
|
998
1126
|
this.ensureInitialized();
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
await this.storage.delete(key);
|
|
1003
|
-
return true;
|
|
1127
|
+
const { toolId, sessionId, userId, context } = options;
|
|
1128
|
+
if (!sessionId && !userId && !context) {
|
|
1129
|
+
return false;
|
|
1004
1130
|
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1131
|
+
const keys = await this.storage.keys(`${escapePattern(toolId)}:*`);
|
|
1132
|
+
const values = await this.storage.mget(keys);
|
|
1133
|
+
const keysToDelete = [];
|
|
1134
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1135
|
+
const record = this.parseRecord(values[i]);
|
|
1136
|
+
if (!record || record.toolId !== toolId || record.state === "denied" /* DENIED */) continue;
|
|
1137
|
+
if (context) {
|
|
1138
|
+
const sameSession = sessionId !== void 0 && record.sessionId === sessionId;
|
|
1139
|
+
if (record.context?.type === context.type && record.context.identifier === context.identifier && (!sessionId || sameSession) && // A context approval granted to the session before it had a user applies to that user too.
|
|
1140
|
+
(!userId || record.userId === userId || sameSession && record.userId === void 0)) {
|
|
1141
|
+
keysToDelete.push(keys[i]);
|
|
1142
|
+
}
|
|
1143
|
+
continue;
|
|
1015
1144
|
}
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
const sessionValue = await this.storage.get(sessionKey);
|
|
1019
|
-
const sessionApproval = this.parseRecord(sessionValue);
|
|
1020
|
-
if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
|
|
1021
|
-
return true;
|
|
1022
|
-
}
|
|
1023
|
-
if (userId) {
|
|
1024
|
-
const userKey = this.buildKey(toolId, void 0, userId);
|
|
1025
|
-
const userValue = await this.storage.get(userKey);
|
|
1026
|
-
const userApproval = this.parseRecord(userValue);
|
|
1027
|
-
if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
|
|
1028
|
-
return true;
|
|
1145
|
+
if (sessionId && record.sessionId === sessionId || userId && record.userId === userId) {
|
|
1146
|
+
keysToDelete.push(keys[i]);
|
|
1029
1147
|
}
|
|
1030
1148
|
}
|
|
1031
|
-
|
|
1149
|
+
if (keysToDelete.length === 0) {
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1152
|
+
await this.storage.mdelete(keysToDelete);
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
1155
|
+
async isApproved(toolId, sessionId, userId, context) {
|
|
1156
|
+
const approval = await this.getApproval(toolId, sessionId, userId, context);
|
|
1157
|
+
return approval?.state === "approved" /* APPROVED */;
|
|
1032
1158
|
}
|
|
1033
1159
|
async clearSessionApprovals(sessionId) {
|
|
1034
1160
|
this.ensureInitialized();
|
|
1035
1161
|
const escapedSessionId = escapePattern(sessionId);
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1162
|
+
const keys = [
|
|
1163
|
+
.../* @__PURE__ */ new Set([
|
|
1164
|
+
...await this.storage.keys(`*:session:${escapedSessionId}`),
|
|
1165
|
+
...await this.storage.keys(`*:session:${escapedSessionId}:*`)
|
|
1166
|
+
])
|
|
1167
|
+
];
|
|
1038
1168
|
if (keys.length === 0) {
|
|
1039
1169
|
return 0;
|
|
1040
1170
|
}
|
|
@@ -1178,10 +1308,16 @@ __publicField(ApprovalPlugin, "dynamicProviders", (options) => {
|
|
|
1178
1308
|
name: "approval:service",
|
|
1179
1309
|
provide: ApprovalServiceToken,
|
|
1180
1310
|
scope: ProviderScope4.CONTEXT,
|
|
1181
|
-
inject: () => [ApprovalStoreToken, FRONTMCP_CONTEXT],
|
|
1182
|
-
useFactory: (store, ctx) => {
|
|
1311
|
+
inject: () => [ApprovalStoreToken, FRONTMCP_CONTEXT, ScopeEntry],
|
|
1312
|
+
useFactory: (store, ctx, scope) => {
|
|
1183
1313
|
const { sessionId, userId } = resolveApprovalIdentity(ctx);
|
|
1184
|
-
|
|
1314
|
+
const requirementOf = (toolId) => {
|
|
1315
|
+
const tool = scope.tools.getTools(true).find((entry) => entry.fullName === toolId);
|
|
1316
|
+
if (!tool) return void 0;
|
|
1317
|
+
const approval = tool.metadata.approval;
|
|
1318
|
+
return resolveApprovalRequirement(approval);
|
|
1319
|
+
};
|
|
1320
|
+
return createApprovalService(store, sessionId, userId, requirementOf);
|
|
1185
1321
|
}
|
|
1186
1322
|
});
|
|
1187
1323
|
return providers;
|