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