@frontmcp/plugin-approval 1.8.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/approval.identity.d.ts +22 -0
- package/approval.identity.d.ts.map +1 -0
- package/approval.plugin.d.ts +2 -8
- package/approval.plugin.d.ts.map +1 -1
- package/esm/index.mjs +387 -371
- package/esm/package.json +4 -4
- package/hooks/approval-check.hook.d.ts +3 -2
- package/hooks/approval-check.hook.d.ts.map +1 -1
- package/index.js +365 -354
- package/package.json +4 -4
- package/stores/approval-storage.store.d.ts +7 -3
- package/stores/approval-storage.store.d.ts.map +1 -1
- package/types/approval.types.d.ts +7 -0
- package/types/approval.types.d.ts.map +1 -1
package/index.js
CHANGED
|
@@ -78,9 +78,34 @@ module.exports = __toCommonJS(index_exports);
|
|
|
78
78
|
// plugins/plugin-approval/src/approval.plugin.ts
|
|
79
79
|
var import_sdk5 = require("@frontmcp/sdk");
|
|
80
80
|
|
|
81
|
-
// plugins/plugin-approval/src/
|
|
82
|
-
var import_sdk = require("@frontmcp/sdk");
|
|
81
|
+
// plugins/plugin-approval/src/approval.identity.ts
|
|
83
82
|
var import_utils = require("@frontmcp/utils");
|
|
83
|
+
function nonEmptyString(value) {
|
|
84
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
85
|
+
}
|
|
86
|
+
function resolveApprovalIdentity(ctx) {
|
|
87
|
+
const extra = ctx?.authInfo?.extra;
|
|
88
|
+
const userId = nonEmptyString(extra?.["userId"]) ?? nonEmptyString(extra?.["sub"]) ?? nonEmptyString(ctx?.authInfo?.clientId);
|
|
89
|
+
const sessionId = nonEmptyString(ctx?.verifiedSessionId);
|
|
90
|
+
if (sessionId) {
|
|
91
|
+
return { sessionId, userId };
|
|
92
|
+
}
|
|
93
|
+
return { sessionId: userId ? `stateless-user:${userId}` : `unidentified:${(0, import_utils.randomUUID)()}`, userId };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// plugins/plugin-approval/src/approval.symbols.ts
|
|
97
|
+
var ApprovalStoreToken = /* @__PURE__ */ Symbol.for(
|
|
98
|
+
"plugin:approval:store"
|
|
99
|
+
);
|
|
100
|
+
var ApprovalServiceToken = /* @__PURE__ */ Symbol.for(
|
|
101
|
+
"plugin:approval:service"
|
|
102
|
+
);
|
|
103
|
+
var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
|
|
104
|
+
"plugin:approval:challenge-service"
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// plugins/plugin-approval/src/hooks/approval-check.hook.ts
|
|
108
|
+
var import_sdk = require("@frontmcp/sdk");
|
|
84
109
|
|
|
85
110
|
// plugins/plugin-approval/src/approval/types.ts
|
|
86
111
|
var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
|
|
@@ -445,266 +470,122 @@ var ChallengeValidationError = class extends ApprovalError {
|
|
|
445
470
|
}
|
|
446
471
|
};
|
|
447
472
|
|
|
448
|
-
// plugins/plugin-approval/src/
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
this.options = {
|
|
460
|
-
storage: options.storage ?? { type: "auto" },
|
|
461
|
-
storageInstance: options.storageInstance,
|
|
462
|
-
namespace: options.namespace ?? "approval",
|
|
463
|
-
cleanupIntervalSeconds: options.cleanupIntervalSeconds ?? 60
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
/**
|
|
467
|
-
* Initialize the storage connection.
|
|
468
|
-
*/
|
|
469
|
-
async initialize() {
|
|
470
|
-
if (this.initialized) {
|
|
473
|
+
// plugins/plugin-approval/src/hooks/approval-check.hook.ts
|
|
474
|
+
var passedApprovalStores = /* @__PURE__ */ new WeakMap();
|
|
475
|
+
var ApprovalCheckPlugin = class extends import_sdk.DynamicPlugin {
|
|
476
|
+
async checkApproval(flowCtx) {
|
|
477
|
+
const { tool, toolContext } = flowCtx.state;
|
|
478
|
+
if (!tool || !toolContext) return;
|
|
479
|
+
const metadata = tool.metadata;
|
|
480
|
+
const approvalConfig = this.resolveApprovalConfig(
|
|
481
|
+
metadata["approval"]
|
|
482
|
+
);
|
|
483
|
+
if (!approvalConfig.required) {
|
|
471
484
|
return;
|
|
472
485
|
}
|
|
473
|
-
if (
|
|
474
|
-
|
|
475
|
-
this.ownedStorage = false;
|
|
476
|
-
} else {
|
|
477
|
-
const rootStorage = await (0, import_utils.createStorage)(this.options.storage);
|
|
478
|
-
this.storage = rootStorage.namespace(this.options.namespace);
|
|
479
|
-
this.ownedStorage = true;
|
|
480
|
-
}
|
|
481
|
-
if (this.options.cleanupIntervalSeconds > 0) {
|
|
482
|
-
this.cleanupInterval = setInterval(() => {
|
|
483
|
-
void this.clearExpiredApprovals();
|
|
484
|
-
}, this.options.cleanupIntervalSeconds * 1e3);
|
|
485
|
-
this.cleanupInterval.unref?.();
|
|
486
|
+
if (approvalConfig.skipApproval) {
|
|
487
|
+
return;
|
|
486
488
|
}
|
|
487
|
-
|
|
489
|
+
const approvalStore = this.get(ApprovalStoreToken);
|
|
490
|
+
const passedStores = passedApprovalStores.get(toolContext) ?? /* @__PURE__ */ new WeakSet();
|
|
491
|
+
if (passedStores.has(approvalStore)) return;
|
|
492
|
+
await this.enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore);
|
|
493
|
+
passedStores.add(approvalStore);
|
|
494
|
+
passedApprovalStores.set(toolContext, passedStores);
|
|
488
495
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
496
|
+
async enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore) {
|
|
497
|
+
const { sessionId, userId } = resolveApprovalIdentity(toolContext.tryGetContext?.());
|
|
498
|
+
const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
|
|
499
|
+
if (approval?.state === "denied" /* DENIED */) {
|
|
500
|
+
throw new ApprovalRequiredError({
|
|
501
|
+
toolId: tool.fullName,
|
|
502
|
+
state: "denied",
|
|
503
|
+
message: `Tool "${tool.fullName}" execution denied.`
|
|
504
|
+
});
|
|
492
505
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const parts = [toolId];
|
|
496
|
-
if (sessionId) parts.push(`session:${sessionId}`);
|
|
497
|
-
if (userId) parts.push(`user:${userId}`);
|
|
498
|
-
if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
|
|
499
|
-
return parts.join(":");
|
|
500
|
-
}
|
|
501
|
-
parseRecord(value) {
|
|
502
|
-
if (!value) return void 0;
|
|
503
|
-
try {
|
|
504
|
-
const parsed = JSON.parse(value);
|
|
505
|
-
const result = approvalRecordSchema.safeParse(parsed);
|
|
506
|
-
if (!result.success) {
|
|
507
|
-
return void 0;
|
|
508
|
-
}
|
|
509
|
-
return result.data;
|
|
510
|
-
} catch {
|
|
511
|
-
return void 0;
|
|
506
|
+
if (this.isPreApprovedContext(approvalConfig, this.getCurrentContext(flowCtx))) {
|
|
507
|
+
return;
|
|
512
508
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
}
|
|
517
|
-
async getApproval(toolId, sessionId, userId) {
|
|
518
|
-
this.ensureInitialized();
|
|
519
|
-
const sessionKey = this.buildKey(toolId, sessionId);
|
|
520
|
-
const sessionValue = await this.storage.get(sessionKey);
|
|
521
|
-
const sessionApproval = this.parseRecord(sessionValue);
|
|
522
|
-
if (sessionApproval && !this.isExpired(sessionApproval)) {
|
|
523
|
-
return sessionApproval;
|
|
509
|
+
if (approvalConfig.alwaysPrompt) {
|
|
510
|
+
await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
|
|
511
|
+
return;
|
|
524
512
|
}
|
|
525
|
-
if (
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const userApproval = this.parseRecord(userValue);
|
|
529
|
-
if (userApproval && !this.isExpired(userApproval)) {
|
|
530
|
-
return userApproval;
|
|
513
|
+
if (approval?.state === "approved" /* APPROVED */) {
|
|
514
|
+
if (!this.isExpired(approval)) {
|
|
515
|
+
return;
|
|
531
516
|
}
|
|
532
517
|
}
|
|
533
|
-
|
|
518
|
+
await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
|
|
534
519
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
const pattern = query.toolId ? `${query.toolId}:*` : "*";
|
|
539
|
-
const keys = await this.storage.keys(pattern);
|
|
540
|
-
const values = await this.storage.mget(keys);
|
|
541
|
-
for (const value of values) {
|
|
542
|
-
const approval = this.parseRecord(value);
|
|
543
|
-
if (!approval) continue;
|
|
544
|
-
if (!query.includeExpired && this.isExpired(approval)) {
|
|
545
|
-
continue;
|
|
546
|
-
}
|
|
547
|
-
if (query.toolId && approval.toolId !== query.toolId) continue;
|
|
548
|
-
if (query.toolIds && !query.toolIds.includes(approval.toolId)) continue;
|
|
549
|
-
if (query.scope && approval.scope !== query.scope) continue;
|
|
550
|
-
if (query.scopes && !query.scopes.includes(approval.scope)) continue;
|
|
551
|
-
if (query.state && approval.state !== query.state) continue;
|
|
552
|
-
if (query.states && !query.states.includes(approval.state)) continue;
|
|
553
|
-
if (query.sessionId && approval.sessionId !== query.sessionId) continue;
|
|
554
|
-
if (query.userId && approval.userId !== query.userId) continue;
|
|
555
|
-
if (query.context) {
|
|
556
|
-
if (!approval.context || approval.context.type !== query.context.type || approval.context.identifier !== query.context.identifier) {
|
|
557
|
-
continue;
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
results.push(approval);
|
|
520
|
+
resolveApprovalConfig(config) {
|
|
521
|
+
if (config === true) {
|
|
522
|
+
return { required: true, defaultScope: "session" /* SESSION */ };
|
|
561
523
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const record = {
|
|
570
|
-
toolId: options.toolId,
|
|
571
|
-
state: "approved" /* APPROVED */,
|
|
572
|
-
scope: options.scope,
|
|
573
|
-
grantedAt: now,
|
|
574
|
-
expiresAt,
|
|
575
|
-
ttlMs: options.ttlMs,
|
|
576
|
-
sessionId: options.sessionId,
|
|
577
|
-
userId: options.userId,
|
|
578
|
-
context: options.context,
|
|
579
|
-
grantedBy,
|
|
580
|
-
reason: options.reason,
|
|
581
|
-
metadata: options.metadata
|
|
524
|
+
if (config === false || config === void 0) {
|
|
525
|
+
return { required: false };
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
...config,
|
|
529
|
+
required: config.required ?? true,
|
|
530
|
+
defaultScope: config.defaultScope ?? "session" /* SESSION */
|
|
582
531
|
};
|
|
583
|
-
const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
|
|
584
|
-
const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
|
|
585
|
-
await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
|
|
586
|
-
return record;
|
|
587
532
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
const exists = await this.storage.exists(key);
|
|
592
|
-
if (exists) {
|
|
593
|
-
await this.storage.delete(key);
|
|
594
|
-
return true;
|
|
595
|
-
}
|
|
596
|
-
return false;
|
|
533
|
+
isExpired(approval) {
|
|
534
|
+
if (!approval.expiresAt) return false;
|
|
535
|
+
return Date.now() > approval.expiresAt;
|
|
597
536
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
|
|
612
|
-
return true;
|
|
613
|
-
}
|
|
614
|
-
if (userId) {
|
|
615
|
-
const userKey = this.buildKey(toolId, void 0, userId);
|
|
616
|
-
const userValue = await this.storage.get(userKey);
|
|
617
|
-
const userApproval = this.parseRecord(userValue);
|
|
618
|
-
if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
|
|
619
|
-
return true;
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
return false;
|
|
537
|
+
/**
|
|
538
|
+
* The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
|
|
539
|
+
*
|
|
540
|
+
* Deliberately ignores `toolContext.input.context`. The only consumer of this value is
|
|
541
|
+
* `isPreApprovedContext`, which skips the approval prompt outright, so reading it from the
|
|
542
|
+
* arguments of the call being gated let a caller name its own pre-approved context and walk
|
|
543
|
+
* straight past the gate. Only `authInfo.extra.approvalContext`, which the server sets
|
|
544
|
+
* while authenticating, can say what context a call belongs to.
|
|
545
|
+
*/
|
|
546
|
+
getCurrentContext(flowCtx) {
|
|
547
|
+
const ctx = flowCtx.state.toolContext?.tryGetContext?.();
|
|
548
|
+
const sessionContext = ctx?.authInfo?.extra?.["approvalContext"];
|
|
549
|
+
return this.isApprovalContext(sessionContext) ? sessionContext : void 0;
|
|
623
550
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
const escapedSessionId = escapePattern(sessionId);
|
|
627
|
-
const pattern = `*:session:${escapedSessionId}*`;
|
|
628
|
-
const keys = await this.storage.keys(pattern);
|
|
629
|
-
if (keys.length === 0) {
|
|
630
|
-
return 0;
|
|
631
|
-
}
|
|
632
|
-
return await this.storage.mdelete(keys);
|
|
551
|
+
isApprovalContext(value) {
|
|
552
|
+
return typeof value === "object" && value !== null && "type" in value && "identifier" in value && typeof value.type === "string" && typeof value.identifier === "string";
|
|
633
553
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
const keys = await this.storage.keys("*");
|
|
638
|
-
const values = await this.storage.mget(keys);
|
|
639
|
-
const keysToDelete = [];
|
|
640
|
-
for (let i = 0; i < keys.length; i++) {
|
|
641
|
-
const approval = this.parseRecord(values[i]);
|
|
642
|
-
if (approval && approval.expiresAt && approval.expiresAt <= now) {
|
|
643
|
-
keysToDelete.push(keys[i]);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
if (keysToDelete.length > 0) {
|
|
647
|
-
return await this.storage.mdelete(keysToDelete);
|
|
554
|
+
isPreApprovedContext(config, currentContext) {
|
|
555
|
+
if (!currentContext || !config.preApprovedContexts?.length) {
|
|
556
|
+
return false;
|
|
648
557
|
}
|
|
649
|
-
return
|
|
558
|
+
return config.preApprovedContexts.some(
|
|
559
|
+
(preApproved) => preApproved.type === currentContext.type && preApproved.identifier === currentContext.identifier
|
|
560
|
+
);
|
|
650
561
|
}
|
|
651
|
-
async
|
|
652
|
-
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
};
|
|
666
|
-
const keys = await this.storage.keys("*");
|
|
667
|
-
const values = await this.storage.mget(keys);
|
|
668
|
-
let total = 0;
|
|
669
|
-
for (const value of values) {
|
|
670
|
-
const approval = this.parseRecord(value);
|
|
671
|
-
if (approval) {
|
|
672
|
-
total++;
|
|
673
|
-
byScope[approval.scope]++;
|
|
674
|
-
byState[approval.state]++;
|
|
562
|
+
async handleApprovalRequired(flowCtx, config, existingApproval) {
|
|
563
|
+
const { tool } = flowCtx.state;
|
|
564
|
+
const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
|
|
565
|
+
const isExpiredApproval = existingApproval ? this.isExpired(existingApproval) : false;
|
|
566
|
+
throw new ApprovalRequiredError({
|
|
567
|
+
toolId: tool?.fullName ?? "unknown",
|
|
568
|
+
state: isExpiredApproval ? "expired" : "pending",
|
|
569
|
+
message,
|
|
570
|
+
approvalOptions: {
|
|
571
|
+
allowedScopes: config.allowedScopes,
|
|
572
|
+
defaultScope: config.defaultScope,
|
|
573
|
+
maxTtlMs: config.maxTtlMs,
|
|
574
|
+
category: config.category,
|
|
575
|
+
riskLevel: config.riskLevel
|
|
675
576
|
}
|
|
676
|
-
}
|
|
677
|
-
return {
|
|
678
|
-
totalApprovals: total,
|
|
679
|
-
byScope,
|
|
680
|
-
byState
|
|
681
|
-
};
|
|
682
|
-
}
|
|
683
|
-
async close() {
|
|
684
|
-
if (this.cleanupInterval) {
|
|
685
|
-
clearInterval(this.cleanupInterval);
|
|
686
|
-
this.cleanupInterval = void 0;
|
|
687
|
-
}
|
|
688
|
-
if (this.ownedStorage && this.storage) {
|
|
689
|
-
await this.storage.root.disconnect();
|
|
690
|
-
}
|
|
691
|
-
this.initialized = false;
|
|
577
|
+
});
|
|
692
578
|
}
|
|
693
579
|
};
|
|
694
|
-
|
|
695
|
-
(
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
580
|
+
__decorateClass([
|
|
581
|
+
import_sdk.ToolHook.Will("execute", { priority: 100 })
|
|
582
|
+
], ApprovalCheckPlugin.prototype, "checkApproval", 1);
|
|
583
|
+
ApprovalCheckPlugin = __decorateClass([
|
|
584
|
+
(0, import_sdk.Plugin)({
|
|
585
|
+
name: "approval:check",
|
|
586
|
+
description: "Checks tool approval state before execution"
|
|
699
587
|
})
|
|
700
|
-
],
|
|
701
|
-
function createApprovalMemoryStore(options = {}) {
|
|
702
|
-
const memoryStorage = (0, import_utils.createMemoryStorage)();
|
|
703
|
-
return new ApprovalStorageStore({
|
|
704
|
-
...options,
|
|
705
|
-
storageInstance: memoryStorage
|
|
706
|
-
});
|
|
707
|
-
}
|
|
588
|
+
], ApprovalCheckPlugin);
|
|
708
589
|
|
|
709
590
|
// plugins/plugin-approval/src/services/approval.service.ts
|
|
710
591
|
var import_sdk2 = require("@frontmcp/sdk");
|
|
@@ -1014,134 +895,269 @@ function createMemoryChallengeService(options = {}) {
|
|
|
1014
895
|
});
|
|
1015
896
|
}
|
|
1016
897
|
|
|
1017
|
-
// plugins/plugin-approval/src/approval.
|
|
1018
|
-
var ApprovalStoreToken = /* @__PURE__ */ Symbol.for(
|
|
1019
|
-
"plugin:approval:store"
|
|
1020
|
-
);
|
|
1021
|
-
var ApprovalServiceToken = /* @__PURE__ */ Symbol.for(
|
|
1022
|
-
"plugin:approval:service"
|
|
1023
|
-
);
|
|
1024
|
-
var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
|
|
1025
|
-
"plugin:approval:challenge-service"
|
|
1026
|
-
);
|
|
1027
|
-
|
|
1028
|
-
// plugins/plugin-approval/src/hooks/approval-check.hook.ts
|
|
898
|
+
// plugins/plugin-approval/src/stores/approval-storage.store.ts
|
|
1029
899
|
var import_sdk4 = require("@frontmcp/sdk");
|
|
1030
|
-
var
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
if (approvalConfig.alwaysPrompt) {
|
|
1054
|
-
await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
|
|
900
|
+
var import_utils3 = require("@frontmcp/utils");
|
|
901
|
+
function escapePattern(str) {
|
|
902
|
+
return str.replace(/[*?[\]\\]/g, "\\$&");
|
|
903
|
+
}
|
|
904
|
+
var ApprovalStorageStore = class {
|
|
905
|
+
storage;
|
|
906
|
+
options;
|
|
907
|
+
cleanupInterval;
|
|
908
|
+
initialized = false;
|
|
909
|
+
ownedStorage = false;
|
|
910
|
+
constructor(options = {}) {
|
|
911
|
+
this.options = {
|
|
912
|
+
storage: options.storage ?? { type: "auto" },
|
|
913
|
+
storageInstance: options.storageInstance,
|
|
914
|
+
namespace: options.namespace ?? "approval",
|
|
915
|
+
cleanupIntervalSeconds: options.cleanupIntervalSeconds ?? 60
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Initialize the storage connection.
|
|
920
|
+
*/
|
|
921
|
+
async initialize() {
|
|
922
|
+
if (this.initialized) {
|
|
1055
923
|
return;
|
|
1056
924
|
}
|
|
1057
|
-
if (
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
925
|
+
if (this.options.storageInstance) {
|
|
926
|
+
this.storage = this.options.storageInstance.namespace(this.options.namespace);
|
|
927
|
+
this.ownedStorage = false;
|
|
928
|
+
} else {
|
|
929
|
+
const rootStorage = await (0, import_utils3.createStorage)(this.options.storage);
|
|
930
|
+
this.storage = rootStorage.namespace(this.options.namespace);
|
|
931
|
+
this.ownedStorage = true;
|
|
1061
932
|
}
|
|
1062
|
-
if (
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
});
|
|
933
|
+
if (this.options.cleanupIntervalSeconds > 0) {
|
|
934
|
+
this.cleanupInterval = setInterval(() => {
|
|
935
|
+
void this.clearExpiredApprovals();
|
|
936
|
+
}, this.options.cleanupIntervalSeconds * 1e3);
|
|
937
|
+
this.cleanupInterval.unref?.();
|
|
1068
938
|
}
|
|
1069
|
-
|
|
939
|
+
this.initialized = true;
|
|
1070
940
|
}
|
|
1071
|
-
|
|
1072
|
-
if (
|
|
1073
|
-
|
|
941
|
+
ensureInitialized() {
|
|
942
|
+
if (!this.initialized) {
|
|
943
|
+
throw new Error("ApprovalStorageStore not initialized. Call initialize() first.");
|
|
1074
944
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
945
|
+
}
|
|
946
|
+
buildKey(toolId, sessionId, userId, context) {
|
|
947
|
+
const parts = [toolId];
|
|
948
|
+
if (sessionId) parts.push(`session:${sessionId}`);
|
|
949
|
+
if (userId) parts.push(`user:${userId}`);
|
|
950
|
+
if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
|
|
951
|
+
return parts.join(":");
|
|
952
|
+
}
|
|
953
|
+
parseRecord(value) {
|
|
954
|
+
if (!value) return void 0;
|
|
955
|
+
try {
|
|
956
|
+
const parsed = JSON.parse(value);
|
|
957
|
+
const result = approvalRecordSchema.safeParse(parsed);
|
|
958
|
+
if (!result.success) {
|
|
959
|
+
return void 0;
|
|
960
|
+
}
|
|
961
|
+
return result.data;
|
|
962
|
+
} catch {
|
|
963
|
+
return void 0;
|
|
1077
964
|
}
|
|
1078
|
-
return {
|
|
1079
|
-
...config,
|
|
1080
|
-
required: config.required ?? true,
|
|
1081
|
-
defaultScope: config.defaultScope ?? "session" /* SESSION */
|
|
1082
|
-
};
|
|
1083
965
|
}
|
|
1084
966
|
isExpired(approval) {
|
|
1085
|
-
|
|
1086
|
-
return Date.now() > approval.expiresAt;
|
|
967
|
+
return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
|
|
1087
968
|
}
|
|
1088
969
|
/**
|
|
1089
|
-
* The
|
|
1090
|
-
*
|
|
1091
|
-
* Deliberately ignores `toolContext.input.context`. The only consumer of this value is
|
|
1092
|
-
* `isPreApprovedContext`, which skips the approval prompt outright, so reading it from the
|
|
1093
|
-
* arguments of the call being gated let a caller name its own pre-approved context and walk
|
|
1094
|
-
* straight past the gate. Only `authInfo.extra.approvalContext`, which the server sets
|
|
1095
|
-
* while authenticating, can say what context a call belongs to.
|
|
970
|
+
* The session record, else the user record, except that a denial in either scope wins,
|
|
971
|
+
* so a session approval cannot mask a user-level denial.
|
|
1096
972
|
*/
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
const
|
|
1100
|
-
|
|
973
|
+
async getApproval(toolId, sessionId, userId) {
|
|
974
|
+
this.ensureInitialized();
|
|
975
|
+
const keys = [this.buildKey(toolId, sessionId)];
|
|
976
|
+
if (userId) {
|
|
977
|
+
keys.push(this.buildKey(toolId, void 0, userId));
|
|
978
|
+
}
|
|
979
|
+
const activeRecords = [];
|
|
980
|
+
for (const key of keys) {
|
|
981
|
+
const record = this.parseRecord(await this.storage.get(key));
|
|
982
|
+
if (record && !this.isExpired(record)) {
|
|
983
|
+
activeRecords.push(record);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return activeRecords.find((record) => record.state === "denied" /* DENIED */) ?? activeRecords[0];
|
|
1101
987
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
988
|
+
async queryApprovals(query) {
|
|
989
|
+
this.ensureInitialized();
|
|
990
|
+
const results = [];
|
|
991
|
+
const pattern = query.toolId ? `${query.toolId}:*` : "*";
|
|
992
|
+
const keys = await this.storage.keys(pattern);
|
|
993
|
+
const values = await this.storage.mget(keys);
|
|
994
|
+
for (const value of values) {
|
|
995
|
+
const approval = this.parseRecord(value);
|
|
996
|
+
if (!approval) continue;
|
|
997
|
+
if (!query.includeExpired && this.isExpired(approval)) {
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (query.toolId && approval.toolId !== query.toolId) continue;
|
|
1001
|
+
if (query.toolIds && !query.toolIds.includes(approval.toolId)) continue;
|
|
1002
|
+
if (query.scope && approval.scope !== query.scope) continue;
|
|
1003
|
+
if (query.scopes && !query.scopes.includes(approval.scope)) continue;
|
|
1004
|
+
if (query.state && approval.state !== query.state) continue;
|
|
1005
|
+
if (query.states && !query.states.includes(approval.state)) continue;
|
|
1006
|
+
if (query.sessionId && approval.sessionId !== query.sessionId) continue;
|
|
1007
|
+
if (query.userId && approval.userId !== query.userId) continue;
|
|
1008
|
+
if (query.context) {
|
|
1009
|
+
if (!approval.context || approval.context.type !== query.context.type || approval.context.identifier !== query.context.identifier) {
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
results.push(approval);
|
|
1014
|
+
}
|
|
1015
|
+
return results;
|
|
1104
1016
|
}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1017
|
+
async grantApproval(options) {
|
|
1018
|
+
this.ensureInitialized();
|
|
1019
|
+
const now = Date.now();
|
|
1020
|
+
const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
|
|
1021
|
+
const grantedBy = normalizeGrantor(options.grantedBy);
|
|
1022
|
+
const record = {
|
|
1023
|
+
toolId: options.toolId,
|
|
1024
|
+
state: "approved" /* APPROVED */,
|
|
1025
|
+
scope: options.scope,
|
|
1026
|
+
grantedAt: now,
|
|
1027
|
+
expiresAt,
|
|
1028
|
+
ttlMs: options.ttlMs,
|
|
1029
|
+
sessionId: options.sessionId,
|
|
1030
|
+
userId: options.userId,
|
|
1031
|
+
context: options.context,
|
|
1032
|
+
grantedBy,
|
|
1033
|
+
reason: options.reason,
|
|
1034
|
+
metadata: options.metadata
|
|
1035
|
+
};
|
|
1036
|
+
const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
|
|
1037
|
+
const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
|
|
1038
|
+
await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
|
|
1039
|
+
return record;
|
|
1109
1040
|
}
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1041
|
+
async revokeApproval(options) {
|
|
1042
|
+
this.ensureInitialized();
|
|
1043
|
+
const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
|
|
1044
|
+
const exists = await this.storage.exists(key);
|
|
1045
|
+
if (exists) {
|
|
1046
|
+
await this.storage.delete(key);
|
|
1047
|
+
return true;
|
|
1113
1048
|
}
|
|
1114
|
-
return
|
|
1115
|
-
(preApproved) => preApproved.type === currentContext.type && preApproved.identifier === currentContext.identifier
|
|
1116
|
-
);
|
|
1049
|
+
return false;
|
|
1117
1050
|
}
|
|
1118
|
-
async
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
state
|
|
1125
|
-
|
|
1126
|
-
approvalOptions: {
|
|
1127
|
-
allowedScopes: config.allowedScopes,
|
|
1128
|
-
defaultScope: config.defaultScope,
|
|
1129
|
-
maxTtlMs: config.maxTtlMs,
|
|
1130
|
-
category: config.category,
|
|
1131
|
-
riskLevel: config.riskLevel
|
|
1051
|
+
async isApproved(toolId, sessionId, userId, context) {
|
|
1052
|
+
this.ensureInitialized();
|
|
1053
|
+
if (context) {
|
|
1054
|
+
const contextKey = this.buildKey(toolId, sessionId, userId, context);
|
|
1055
|
+
const contextValue = await this.storage.get(contextKey);
|
|
1056
|
+
const contextApproval = this.parseRecord(contextValue);
|
|
1057
|
+
if (contextApproval && contextApproval.state === "approved" /* APPROVED */ && !this.isExpired(contextApproval)) {
|
|
1058
|
+
return true;
|
|
1132
1059
|
}
|
|
1133
|
-
}
|
|
1060
|
+
}
|
|
1061
|
+
const sessionKey = this.buildKey(toolId, sessionId);
|
|
1062
|
+
const sessionValue = await this.storage.get(sessionKey);
|
|
1063
|
+
const sessionApproval = this.parseRecord(sessionValue);
|
|
1064
|
+
if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
1067
|
+
if (userId) {
|
|
1068
|
+
const userKey = this.buildKey(toolId, void 0, userId);
|
|
1069
|
+
const userValue = await this.storage.get(userKey);
|
|
1070
|
+
const userApproval = this.parseRecord(userValue);
|
|
1071
|
+
if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
|
|
1072
|
+
return true;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
async clearSessionApprovals(sessionId) {
|
|
1078
|
+
this.ensureInitialized();
|
|
1079
|
+
const escapedSessionId = escapePattern(sessionId);
|
|
1080
|
+
const pattern = `*:session:${escapedSessionId}*`;
|
|
1081
|
+
const keys = await this.storage.keys(pattern);
|
|
1082
|
+
if (keys.length === 0) {
|
|
1083
|
+
return 0;
|
|
1084
|
+
}
|
|
1085
|
+
return await this.storage.mdelete(keys);
|
|
1086
|
+
}
|
|
1087
|
+
async clearExpiredApprovals() {
|
|
1088
|
+
this.ensureInitialized();
|
|
1089
|
+
const now = Date.now();
|
|
1090
|
+
const keys = await this.storage.keys("*");
|
|
1091
|
+
const values = await this.storage.mget(keys);
|
|
1092
|
+
const keysToDelete = [];
|
|
1093
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1094
|
+
const approval = this.parseRecord(values[i]);
|
|
1095
|
+
if (approval && approval.expiresAt && approval.expiresAt <= now) {
|
|
1096
|
+
keysToDelete.push(keys[i]);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (keysToDelete.length > 0) {
|
|
1100
|
+
return await this.storage.mdelete(keysToDelete);
|
|
1101
|
+
}
|
|
1102
|
+
return 0;
|
|
1103
|
+
}
|
|
1104
|
+
async getStats() {
|
|
1105
|
+
this.ensureInitialized();
|
|
1106
|
+
const byScope = {
|
|
1107
|
+
["session" /* SESSION */]: 0,
|
|
1108
|
+
["user" /* USER */]: 0,
|
|
1109
|
+
["time_limited" /* TIME_LIMITED */]: 0,
|
|
1110
|
+
["tool_specific" /* TOOL_SPECIFIC */]: 0,
|
|
1111
|
+
["context_specific" /* CONTEXT_SPECIFIC */]: 0
|
|
1112
|
+
};
|
|
1113
|
+
const byState = {
|
|
1114
|
+
["pending" /* PENDING */]: 0,
|
|
1115
|
+
["approved" /* APPROVED */]: 0,
|
|
1116
|
+
["denied" /* DENIED */]: 0,
|
|
1117
|
+
["expired" /* EXPIRED */]: 0
|
|
1118
|
+
};
|
|
1119
|
+
const keys = await this.storage.keys("*");
|
|
1120
|
+
const values = await this.storage.mget(keys);
|
|
1121
|
+
let total = 0;
|
|
1122
|
+
for (const value of values) {
|
|
1123
|
+
const approval = this.parseRecord(value);
|
|
1124
|
+
if (approval) {
|
|
1125
|
+
total++;
|
|
1126
|
+
byScope[approval.scope]++;
|
|
1127
|
+
byState[approval.state]++;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return {
|
|
1131
|
+
totalApprovals: total,
|
|
1132
|
+
byScope,
|
|
1133
|
+
byState
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
async close() {
|
|
1137
|
+
if (this.cleanupInterval) {
|
|
1138
|
+
clearInterval(this.cleanupInterval);
|
|
1139
|
+
this.cleanupInterval = void 0;
|
|
1140
|
+
}
|
|
1141
|
+
if (this.ownedStorage && this.storage) {
|
|
1142
|
+
await this.storage.root.disconnect();
|
|
1143
|
+
}
|
|
1144
|
+
this.initialized = false;
|
|
1134
1145
|
}
|
|
1135
1146
|
};
|
|
1136
|
-
__decorateClass([
|
|
1137
|
-
import_sdk4.
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
name: "approval:check",
|
|
1142
|
-
description: "Checks tool approval state before execution"
|
|
1147
|
+
ApprovalStorageStore = __decorateClass([
|
|
1148
|
+
(0, import_sdk4.Provider)({
|
|
1149
|
+
name: "provider:approval:store:storage",
|
|
1150
|
+
description: "Storage-backed approval store (supports Memory, Redis, Vercel KV, Upstash)",
|
|
1151
|
+
scope: import_sdk4.ProviderScope.GLOBAL
|
|
1143
1152
|
})
|
|
1144
|
-
],
|
|
1153
|
+
], ApprovalStorageStore);
|
|
1154
|
+
function createApprovalMemoryStore(options = {}) {
|
|
1155
|
+
const memoryStorage = (0, import_utils3.createMemoryStorage)();
|
|
1156
|
+
return new ApprovalStorageStore({
|
|
1157
|
+
...options,
|
|
1158
|
+
storageInstance: memoryStorage
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1145
1161
|
|
|
1146
1162
|
// plugins/plugin-approval/src/approval.plugin.ts
|
|
1147
1163
|
var ApprovalPlugin = class extends import_sdk5.DynamicPlugin {
|
|
@@ -1153,12 +1169,6 @@ var ApprovalPlugin = class extends import_sdk5.DynamicPlugin {
|
|
|
1153
1169
|
...options
|
|
1154
1170
|
};
|
|
1155
1171
|
}
|
|
1156
|
-
/**
|
|
1157
|
-
* Get plugin metadata including nested plugins.
|
|
1158
|
-
*/
|
|
1159
|
-
static getPluginMetadata(_options) {
|
|
1160
|
-
return { plugins: [ApprovalCheckPlugin] };
|
|
1161
|
-
}
|
|
1162
1172
|
};
|
|
1163
1173
|
__publicField(ApprovalPlugin, "defaultOptions", {
|
|
1164
1174
|
namespace: "approval",
|
|
@@ -1214,8 +1224,8 @@ __publicField(ApprovalPlugin, "dynamicProviders", (options) => {
|
|
|
1214
1224
|
scope: import_sdk5.ProviderScope.CONTEXT,
|
|
1215
1225
|
inject: () => [ApprovalStoreToken, import_sdk5.FRONTMCP_CONTEXT],
|
|
1216
1226
|
useFactory: (store, ctx) => {
|
|
1217
|
-
const
|
|
1218
|
-
return createApprovalService(store,
|
|
1227
|
+
const { sessionId, userId } = resolveApprovalIdentity(ctx);
|
|
1228
|
+
return createApprovalService(store, sessionId, userId);
|
|
1219
1229
|
}
|
|
1220
1230
|
});
|
|
1221
1231
|
return providers;
|
|
@@ -1224,6 +1234,7 @@ ApprovalPlugin = __decorateClass([
|
|
|
1224
1234
|
(0, import_sdk5.Plugin)({
|
|
1225
1235
|
name: "approval",
|
|
1226
1236
|
description: "Tool authorization workflow with PKCE webhook security",
|
|
1237
|
+
plugins: [ApprovalCheckPlugin],
|
|
1227
1238
|
contextExtensions: [
|
|
1228
1239
|
{
|
|
1229
1240
|
property: "approval",
|