@frontmcp/plugin-approval 1.8.2 → 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.plugin.d.ts.map +1 -1
- package/esm/index.mjs +373 -236
- 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 +371 -235
- 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,9 +22,209 @@ 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
229
|
import { randomUUID } from "@frontmcp/utils";
|
|
30
230
|
function nonEmptyString(value) {
|
|
@@ -54,23 +254,6 @@ var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
|
|
|
54
254
|
// plugins/plugin-approval/src/hooks/approval-check.hook.ts
|
|
55
255
|
import { DynamicPlugin, Plugin, ToolHook } from "@frontmcp/sdk";
|
|
56
256
|
|
|
57
|
-
// plugins/plugin-approval/src/approval/types.ts
|
|
58
|
-
var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
|
|
59
|
-
ApprovalScope2["SESSION"] = "session";
|
|
60
|
-
ApprovalScope2["USER"] = "user";
|
|
61
|
-
ApprovalScope2["TIME_LIMITED"] = "time_limited";
|
|
62
|
-
ApprovalScope2["TOOL_SPECIFIC"] = "tool_specific";
|
|
63
|
-
ApprovalScope2["CONTEXT_SPECIFIC"] = "context_specific";
|
|
64
|
-
return ApprovalScope2;
|
|
65
|
-
})(ApprovalScope || {});
|
|
66
|
-
var ApprovalState = /* @__PURE__ */ ((ApprovalState2) => {
|
|
67
|
-
ApprovalState2["PENDING"] = "pending";
|
|
68
|
-
ApprovalState2["APPROVED"] = "approved";
|
|
69
|
-
ApprovalState2["DENIED"] = "denied";
|
|
70
|
-
ApprovalState2["EXPIRED"] = "expired";
|
|
71
|
-
return ApprovalState2;
|
|
72
|
-
})(ApprovalState || {});
|
|
73
|
-
|
|
74
257
|
// plugins/plugin-approval/src/approval/schemas.ts
|
|
75
258
|
import { z } from "@frontmcp/lazy-zod";
|
|
76
259
|
var approvalScopeSchema = z.nativeEnum(ApprovalScope);
|
|
@@ -287,136 +470,6 @@ function isApiGrantor(grantor) {
|
|
|
287
470
|
return grantor.source === "api" || grantor.source === "oauth";
|
|
288
471
|
}
|
|
289
472
|
|
|
290
|
-
// plugins/plugin-approval/src/approval/errors.ts
|
|
291
|
-
var ApprovalError = class extends Error {
|
|
292
|
-
constructor(message) {
|
|
293
|
-
super(message);
|
|
294
|
-
this.name = "ApprovalError";
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
var ApprovalRequiredError = class extends ApprovalError {
|
|
298
|
-
constructor(details) {
|
|
299
|
-
super(details.message);
|
|
300
|
-
this.details = details;
|
|
301
|
-
this.name = "ApprovalRequiredError";
|
|
302
|
-
}
|
|
303
|
-
details;
|
|
304
|
-
/**
|
|
305
|
-
* Convert to a JSON-RPC compatible error structure.
|
|
306
|
-
*/
|
|
307
|
-
toJsonRpcError() {
|
|
308
|
-
return {
|
|
309
|
-
code: -32600,
|
|
310
|
-
// Invalid Request
|
|
311
|
-
message: this.details.message,
|
|
312
|
-
data: {
|
|
313
|
-
type: "approval_required",
|
|
314
|
-
toolId: this.details.toolId,
|
|
315
|
-
state: this.details.state,
|
|
316
|
-
options: this.details.approvalOptions
|
|
317
|
-
}
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
};
|
|
321
|
-
var ApprovalOperationError = class extends ApprovalError {
|
|
322
|
-
constructor(operation, reason) {
|
|
323
|
-
super(`Approval ${operation} failed: ${reason}`);
|
|
324
|
-
this.operation = operation;
|
|
325
|
-
this.reason = reason;
|
|
326
|
-
this.name = "ApprovalOperationError";
|
|
327
|
-
}
|
|
328
|
-
operation;
|
|
329
|
-
reason;
|
|
330
|
-
/**
|
|
331
|
-
* Convert to a JSON-RPC compatible error structure.
|
|
332
|
-
*/
|
|
333
|
-
toJsonRpcError() {
|
|
334
|
-
return {
|
|
335
|
-
code: -32603,
|
|
336
|
-
// Internal Error
|
|
337
|
-
message: "Approval operation failed",
|
|
338
|
-
data: {
|
|
339
|
-
type: "approval_operation_error",
|
|
340
|
-
operation: this.operation
|
|
341
|
-
}
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
};
|
|
345
|
-
var ApprovalScopeNotAllowedError = class extends ApprovalError {
|
|
346
|
-
constructor(requestedScope, allowedScopes) {
|
|
347
|
-
super(
|
|
348
|
-
`Approval scope '${requestedScope}' is not allowed for this tool. Allowed scopes: ${allowedScopes.join(", ")}`
|
|
349
|
-
);
|
|
350
|
-
this.requestedScope = requestedScope;
|
|
351
|
-
this.allowedScopes = allowedScopes;
|
|
352
|
-
this.name = "ApprovalScopeNotAllowedError";
|
|
353
|
-
}
|
|
354
|
-
requestedScope;
|
|
355
|
-
allowedScopes;
|
|
356
|
-
/**
|
|
357
|
-
* Convert to a JSON-RPC compatible error structure.
|
|
358
|
-
*/
|
|
359
|
-
toJsonRpcError() {
|
|
360
|
-
return {
|
|
361
|
-
code: -32602,
|
|
362
|
-
// Invalid Params
|
|
363
|
-
message: this.message,
|
|
364
|
-
data: {
|
|
365
|
-
type: "approval_scope_not_allowed",
|
|
366
|
-
requestedScope: this.requestedScope,
|
|
367
|
-
allowedScopes: this.allowedScopes
|
|
368
|
-
}
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
};
|
|
372
|
-
var ApprovalExpiredError = class extends ApprovalError {
|
|
373
|
-
constructor(toolId, expiredAt) {
|
|
374
|
-
super(`Approval for tool '${toolId}' expired at ${new Date(expiredAt).toISOString()}`);
|
|
375
|
-
this.toolId = toolId;
|
|
376
|
-
this.expiredAt = expiredAt;
|
|
377
|
-
this.name = "ApprovalExpiredError";
|
|
378
|
-
}
|
|
379
|
-
toolId;
|
|
380
|
-
expiredAt;
|
|
381
|
-
/**
|
|
382
|
-
* Convert to a JSON-RPC compatible error structure.
|
|
383
|
-
*/
|
|
384
|
-
toJsonRpcError() {
|
|
385
|
-
return {
|
|
386
|
-
code: -32600,
|
|
387
|
-
// Invalid Request
|
|
388
|
-
message: this.message,
|
|
389
|
-
data: {
|
|
390
|
-
type: "approval_expired",
|
|
391
|
-
toolId: this.toolId,
|
|
392
|
-
expiredAt: this.expiredAt
|
|
393
|
-
}
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
};
|
|
397
|
-
var ChallengeValidationError = class extends ApprovalError {
|
|
398
|
-
constructor(reason = "invalid", message) {
|
|
399
|
-
super(message ?? `PKCE challenge validation failed: ${reason}`);
|
|
400
|
-
this.reason = reason;
|
|
401
|
-
this.name = "ChallengeValidationError";
|
|
402
|
-
}
|
|
403
|
-
reason;
|
|
404
|
-
/**
|
|
405
|
-
* Convert to a JSON-RPC compatible error structure.
|
|
406
|
-
*/
|
|
407
|
-
toJsonRpcError() {
|
|
408
|
-
return {
|
|
409
|
-
code: -32600,
|
|
410
|
-
// Invalid Request
|
|
411
|
-
message: this.message,
|
|
412
|
-
data: {
|
|
413
|
-
type: "challenge_validation_error",
|
|
414
|
-
reason: this.reason
|
|
415
|
-
}
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
|
-
|
|
420
473
|
// plugins/plugin-approval/src/hooks/approval-check.hook.ts
|
|
421
474
|
var passedApprovalStores = /* @__PURE__ */ new WeakMap();
|
|
422
475
|
var ApprovalCheckPlugin = class extends DynamicPlugin {
|
|
@@ -424,7 +477,7 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
|
|
|
424
477
|
const { tool, toolContext } = flowCtx.state;
|
|
425
478
|
if (!tool || !toolContext) return;
|
|
426
479
|
const metadata = tool.metadata;
|
|
427
|
-
const approvalConfig =
|
|
480
|
+
const approvalConfig = resolveApprovalRequirement(
|
|
428
481
|
metadata["approval"]
|
|
429
482
|
);
|
|
430
483
|
if (!approvalConfig.required) {
|
|
@@ -442,44 +495,43 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
|
|
|
442
495
|
}
|
|
443
496
|
async enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore) {
|
|
444
497
|
const { sessionId, userId } = resolveApprovalIdentity(toolContext.tryGetContext?.());
|
|
445
|
-
const
|
|
446
|
-
|
|
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 */)) {
|
|
447
501
|
throw new ApprovalRequiredError({
|
|
448
502
|
toolId: tool.fullName,
|
|
449
503
|
state: "denied",
|
|
450
504
|
message: `Tool "${tool.fullName}" execution denied.`
|
|
451
505
|
});
|
|
452
506
|
}
|
|
453
|
-
if (this.isPreApprovedContext(approvalConfig,
|
|
507
|
+
if (this.isPreApprovedContext(approvalConfig, currentContext)) {
|
|
454
508
|
return;
|
|
455
509
|
}
|
|
510
|
+
const approved = records.filter((record) => record.state === "approved" /* APPROVED */);
|
|
456
511
|
if (approvalConfig.alwaysPrompt) {
|
|
457
|
-
await this.handleApprovalRequired(flowCtx, approvalConfig,
|
|
512
|
+
await this.handleApprovalRequired(flowCtx, approvalConfig, approved[0]);
|
|
458
513
|
return;
|
|
459
514
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
}
|
|
515
|
+
const now = Date.now();
|
|
516
|
+
if (approved.some((record) => isApprovalUsable(record, approvalConfig, now))) {
|
|
517
|
+
return;
|
|
464
518
|
}
|
|
465
|
-
await this.handleApprovalRequired(
|
|
519
|
+
await this.handleApprovalRequired(
|
|
520
|
+
flowCtx,
|
|
521
|
+
approvalConfig,
|
|
522
|
+
approved.find((record) => isApprovalExpired(record, approvalConfig, now)) ?? approved[0]
|
|
523
|
+
);
|
|
466
524
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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);
|
|
473
532
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
required: config.required ?? true,
|
|
477
|
-
defaultScope: config.defaultScope ?? "session" /* SESSION */
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
isExpired(approval) {
|
|
481
|
-
if (!approval.expiresAt) return false;
|
|
482
|
-
return Date.now() > approval.expiresAt;
|
|
533
|
+
const record = await store.getApproval(toolId, sessionId, userId, context);
|
|
534
|
+
return record ? [record] : [];
|
|
483
535
|
}
|
|
484
536
|
/**
|
|
485
537
|
* The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
|
|
@@ -509,7 +561,7 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
|
|
|
509
561
|
async handleApprovalRequired(flowCtx, config, existingApproval) {
|
|
510
562
|
const { tool } = flowCtx.state;
|
|
511
563
|
const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
|
|
512
|
-
const isExpiredApproval = existingApproval ?
|
|
564
|
+
const isExpiredApproval = existingApproval ? isApprovalExpired(existingApproval, config, Date.now()) : false;
|
|
513
565
|
throw new ApprovalRequiredError({
|
|
514
566
|
toolId: tool?.fullName ?? "unknown",
|
|
515
567
|
state: isExpiredApproval ? "expired" : "pending",
|
|
@@ -525,26 +577,34 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
|
|
|
525
577
|
}
|
|
526
578
|
};
|
|
527
579
|
__decorateClass([
|
|
528
|
-
ToolHook.Will("execute", { priority: 100 })
|
|
580
|
+
ToolHook.Will("execute", { priority: 100, appliesTo: "uncovered-apps" })
|
|
529
581
|
], ApprovalCheckPlugin.prototype, "checkApproval", 1);
|
|
530
582
|
ApprovalCheckPlugin = __decorateClass([
|
|
531
583
|
Plugin({
|
|
532
584
|
name: "approval:check",
|
|
533
|
-
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"]
|
|
534
588
|
})
|
|
535
589
|
], ApprovalCheckPlugin);
|
|
536
590
|
|
|
537
591
|
// plugins/plugin-approval/src/services/approval.service.ts
|
|
538
592
|
import { Provider, ProviderScope } from "@frontmcp/sdk";
|
|
539
593
|
var ApprovalService = class {
|
|
540
|
-
constructor(store, sessionId, userId) {
|
|
594
|
+
constructor(store, sessionId, userId, requirementOf = () => void 0) {
|
|
541
595
|
this.store = store;
|
|
542
596
|
this.sessionId = sessionId;
|
|
543
597
|
this.userId = userId;
|
|
598
|
+
this.requirementOf = requirementOf;
|
|
544
599
|
}
|
|
545
600
|
store;
|
|
546
601
|
sessionId;
|
|
547
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
|
+
}
|
|
548
608
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
549
609
|
// Query Methods
|
|
550
610
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -552,7 +612,14 @@ var ApprovalService = class {
|
|
|
552
612
|
* Check if a tool is approved for current session/user.
|
|
553
613
|
*/
|
|
554
614
|
async isApproved(toolId, context) {
|
|
555
|
-
|
|
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));
|
|
556
623
|
}
|
|
557
624
|
/**
|
|
558
625
|
* Get approval record for a tool.
|
|
@@ -583,14 +650,15 @@ var ApprovalService = class {
|
|
|
583
650
|
});
|
|
584
651
|
}
|
|
585
652
|
/**
|
|
586
|
-
* 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.
|
|
587
656
|
*/
|
|
588
657
|
async queryApprovals(query) {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
sessionId
|
|
592
|
-
|
|
593
|
-
});
|
|
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
|
+
);
|
|
594
662
|
}
|
|
595
663
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
596
664
|
// Grant Methods
|
|
@@ -599,9 +667,11 @@ var ApprovalService = class {
|
|
|
599
667
|
* Grant session-scoped approval for a tool.
|
|
600
668
|
*/
|
|
601
669
|
async grantSessionApproval(toolId, options = {}) {
|
|
670
|
+
const ttlMs = this.checkGrant(toolId, "session" /* SESSION */);
|
|
602
671
|
return this.store.grantApproval({
|
|
603
672
|
toolId,
|
|
604
673
|
scope: "session" /* SESSION */,
|
|
674
|
+
ttlMs,
|
|
605
675
|
sessionId: this.sessionId,
|
|
606
676
|
grantedBy: options.grantedBy ?? "policy",
|
|
607
677
|
reason: options.reason,
|
|
@@ -615,9 +685,11 @@ var ApprovalService = class {
|
|
|
615
685
|
if (!this.userId) {
|
|
616
686
|
throw new Error("Cannot grant user approval without userId");
|
|
617
687
|
}
|
|
688
|
+
const ttlMs = this.checkGrant(toolId, "user" /* USER */);
|
|
618
689
|
return this.store.grantApproval({
|
|
619
690
|
toolId,
|
|
620
691
|
scope: "user" /* USER */,
|
|
692
|
+
ttlMs,
|
|
621
693
|
userId: this.userId,
|
|
622
694
|
grantedBy: options.grantedBy ?? "policy",
|
|
623
695
|
reason: options.reason,
|
|
@@ -628,6 +700,7 @@ var ApprovalService = class {
|
|
|
628
700
|
* Grant time-limited approval for a tool.
|
|
629
701
|
*/
|
|
630
702
|
async grantTimeLimitedApproval(toolId, ttlMs, options = {}) {
|
|
703
|
+
this.checkGrant(toolId, "time_limited" /* TIME_LIMITED */, ttlMs);
|
|
631
704
|
return this.store.grantApproval({
|
|
632
705
|
toolId,
|
|
633
706
|
scope: "time_limited" /* TIME_LIMITED */,
|
|
@@ -643,9 +716,11 @@ var ApprovalService = class {
|
|
|
643
716
|
* Grant context-specific approval for a tool.
|
|
644
717
|
*/
|
|
645
718
|
async grantContextApproval(toolId, context, options = {}) {
|
|
719
|
+
const ttlMs = this.checkGrant(toolId, "context_specific" /* CONTEXT_SPECIFIC */);
|
|
646
720
|
return this.store.grantApproval({
|
|
647
721
|
toolId,
|
|
648
722
|
scope: "context_specific" /* CONTEXT_SPECIFIC */,
|
|
723
|
+
ttlMs,
|
|
649
724
|
context,
|
|
650
725
|
sessionId: this.sessionId,
|
|
651
726
|
userId: this.userId,
|
|
@@ -658,7 +733,8 @@ var ApprovalService = class {
|
|
|
658
733
|
// Revoke Methods
|
|
659
734
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
660
735
|
/**
|
|
661
|
-
* 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.
|
|
662
738
|
*/
|
|
663
739
|
async revokeApproval(toolId, options = {}) {
|
|
664
740
|
return this.store.revokeApproval({
|
|
@@ -683,8 +759,8 @@ ApprovalService = __decorateClass([
|
|
|
683
759
|
scope: ProviderScope.CONTEXT
|
|
684
760
|
})
|
|
685
761
|
], ApprovalService);
|
|
686
|
-
function createApprovalService(store, sessionId, userId) {
|
|
687
|
-
return new ApprovalService(store, sessionId, userId);
|
|
762
|
+
function createApprovalService(store, sessionId, userId, requirementOf) {
|
|
763
|
+
return new ApprovalService(store, sessionId, userId, requirementOf);
|
|
688
764
|
}
|
|
689
765
|
|
|
690
766
|
// plugins/plugin-approval/src/services/challenge.service.ts
|
|
@@ -856,6 +932,7 @@ import {
|
|
|
856
932
|
function escapePattern(str) {
|
|
857
933
|
return str.replace(/[*?[\]\\]/g, "\\$&");
|
|
858
934
|
}
|
|
935
|
+
var TIME_LIMITED_KEY_SEGMENT = `scope:${"time_limited" /* TIME_LIMITED */}`;
|
|
859
936
|
var ApprovalStorageStore = class {
|
|
860
937
|
storage;
|
|
861
938
|
options;
|
|
@@ -905,6 +982,22 @@ var ApprovalStorageStore = class {
|
|
|
905
982
|
if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
|
|
906
983
|
return parts.join(":");
|
|
907
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
|
+
}
|
|
908
1001
|
parseRecord(value) {
|
|
909
1002
|
if (!value) return void 0;
|
|
910
1003
|
try {
|
|
@@ -922,28 +1015,55 @@ var ApprovalStorageStore = class {
|
|
|
922
1015
|
return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
|
|
923
1016
|
}
|
|
924
1017
|
/**
|
|
925
|
-
* The
|
|
926
|
-
*
|
|
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.
|
|
927
1024
|
*/
|
|
928
|
-
|
|
929
|
-
this.
|
|
930
|
-
const keys = [this.buildKey(toolId, sessionId)];
|
|
1025
|
+
callerKeys(toolId, sessionId, userId, context) {
|
|
1026
|
+
const keys = [this.buildKey(toolId, sessionId), this.timeLimitedKey(toolId, sessionId)];
|
|
931
1027
|
if (userId) {
|
|
932
|
-
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
|
+
);
|
|
933
1033
|
}
|
|
934
|
-
|
|
935
|
-
|
|
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)) {
|
|
936
1048
|
const record = this.parseRecord(await this.storage.get(key));
|
|
937
|
-
if (record && !this.isExpired(record)) {
|
|
938
|
-
|
|
1049
|
+
if (record && record.toolId === toolId && !this.isExpired(record)) {
|
|
1050
|
+
records.push(record);
|
|
939
1051
|
}
|
|
940
1052
|
}
|
|
941
|
-
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];
|
|
942
1062
|
}
|
|
943
1063
|
async queryApprovals(query) {
|
|
944
1064
|
this.ensureInitialized();
|
|
945
1065
|
const results = [];
|
|
946
|
-
const pattern = query.toolId ? `${query.toolId}:*` : "*";
|
|
1066
|
+
const pattern = query.toolId ? `${escapePattern(query.toolId)}:*` : "*";
|
|
947
1067
|
const keys = await this.storage.keys(pattern);
|
|
948
1068
|
const values = await this.storage.mget(keys);
|
|
949
1069
|
for (const value of values) {
|
|
@@ -971,8 +1091,12 @@ var ApprovalStorageStore = class {
|
|
|
971
1091
|
}
|
|
972
1092
|
async grantApproval(options) {
|
|
973
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
|
+
}
|
|
974
1098
|
const now = Date.now();
|
|
975
|
-
const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
|
|
1099
|
+
const expiresAt = options.ttlMs !== void 0 ? now + options.ttlMs : void 0;
|
|
976
1100
|
const grantedBy = normalizeGrantor(options.grantedBy);
|
|
977
1101
|
const record = {
|
|
978
1102
|
toolId: options.toolId,
|
|
@@ -988,52 +1112,59 @@ var ApprovalStorageStore = class {
|
|
|
988
1112
|
reason: options.reason,
|
|
989
1113
|
metadata: options.metadata
|
|
990
1114
|
};
|
|
991
|
-
const key = this.
|
|
992
|
-
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;
|
|
993
1117
|
await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
|
|
994
1118
|
return record;
|
|
995
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
|
+
*/
|
|
996
1125
|
async revokeApproval(options) {
|
|
997
1126
|
this.ensureInitialized();
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
await this.storage.delete(key);
|
|
1002
|
-
return true;
|
|
1127
|
+
const { toolId, sessionId, userId, context } = options;
|
|
1128
|
+
if (!sessionId && !userId && !context) {
|
|
1129
|
+
return false;
|
|
1003
1130
|
}
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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;
|
|
1014
1144
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
const sessionValue = await this.storage.get(sessionKey);
|
|
1018
|
-
const sessionApproval = this.parseRecord(sessionValue);
|
|
1019
|
-
if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
|
|
1020
|
-
return true;
|
|
1021
|
-
}
|
|
1022
|
-
if (userId) {
|
|
1023
|
-
const userKey = this.buildKey(toolId, void 0, userId);
|
|
1024
|
-
const userValue = await this.storage.get(userKey);
|
|
1025
|
-
const userApproval = this.parseRecord(userValue);
|
|
1026
|
-
if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
|
|
1027
|
-
return true;
|
|
1145
|
+
if (sessionId && record.sessionId === sessionId || userId && record.userId === userId) {
|
|
1146
|
+
keysToDelete.push(keys[i]);
|
|
1028
1147
|
}
|
|
1029
1148
|
}
|
|
1030
|
-
|
|
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 */;
|
|
1031
1158
|
}
|
|
1032
1159
|
async clearSessionApprovals(sessionId) {
|
|
1033
1160
|
this.ensureInitialized();
|
|
1034
1161
|
const escapedSessionId = escapePattern(sessionId);
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1162
|
+
const keys = [
|
|
1163
|
+
.../* @__PURE__ */ new Set([
|
|
1164
|
+
...await this.storage.keys(`*:session:${escapedSessionId}`),
|
|
1165
|
+
...await this.storage.keys(`*:session:${escapedSessionId}:*`)
|
|
1166
|
+
])
|
|
1167
|
+
];
|
|
1037
1168
|
if (keys.length === 0) {
|
|
1038
1169
|
return 0;
|
|
1039
1170
|
}
|
|
@@ -1177,10 +1308,16 @@ __publicField(ApprovalPlugin, "dynamicProviders", (options) => {
|
|
|
1177
1308
|
name: "approval:service",
|
|
1178
1309
|
provide: ApprovalServiceToken,
|
|
1179
1310
|
scope: ProviderScope4.CONTEXT,
|
|
1180
|
-
inject: () => [ApprovalStoreToken, FRONTMCP_CONTEXT],
|
|
1181
|
-
useFactory: (store, ctx) => {
|
|
1311
|
+
inject: () => [ApprovalStoreToken, FRONTMCP_CONTEXT, ScopeEntry],
|
|
1312
|
+
useFactory: (store, ctx, scope) => {
|
|
1182
1313
|
const { sessionId, userId } = resolveApprovalIdentity(ctx);
|
|
1183
|
-
|
|
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);
|
|
1184
1321
|
}
|
|
1185
1322
|
});
|
|
1186
1323
|
return providers;
|