@frontmcp/plugin-approval 1.7.2 → 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 -366
- package/esm/package.json +4 -4
- package/hooks/approval-check.hook.d.ts +12 -2
- package/hooks/approval-check.hook.d.ts.map +1 -1
- package/index.js +377 -360
- 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,132 +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
|
-
|
|
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) {
|
|
1039
924
|
return;
|
|
1040
925
|
}
|
|
1041
|
-
if (
|
|
1042
|
-
|
|
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;
|
|
1043
933
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
return;
|
|
934
|
+
if (this.options.cleanupIntervalSeconds > 0) {
|
|
935
|
+
this.cleanupInterval = setInterval(() => {
|
|
936
|
+
void this.clearExpiredApprovals();
|
|
937
|
+
}, this.options.cleanupIntervalSeconds * 1e3);
|
|
938
|
+
this.cleanupInterval.unref?.();
|
|
1050
939
|
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
940
|
+
this.initialized = true;
|
|
941
|
+
}
|
|
942
|
+
ensureInitialized() {
|
|
943
|
+
if (!this.initialized) {
|
|
944
|
+
throw new Error("ApprovalStorageStore not initialized. Call initialize() first.");
|
|
1056
945
|
}
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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;
|
|
1060
961
|
}
|
|
962
|
+
return result.data;
|
|
963
|
+
} catch {
|
|
964
|
+
return void 0;
|
|
1061
965
|
}
|
|
1062
|
-
if (approval?.state === "denied" /* DENIED */) {
|
|
1063
|
-
throw new ApprovalRequiredError({
|
|
1064
|
-
toolId: tool.fullName,
|
|
1065
|
-
state: "denied",
|
|
1066
|
-
message: `Tool "${tool.fullName}" execution denied.`
|
|
1067
|
-
});
|
|
1068
|
-
}
|
|
1069
|
-
await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
|
|
1070
966
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
967
|
+
isExpired(approval) {
|
|
968
|
+
return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
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.
|
|
973
|
+
*/
|
|
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));
|
|
1074
979
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
+
}
|
|
1077
986
|
}
|
|
1078
|
-
return
|
|
1079
|
-
...config,
|
|
1080
|
-
required: config.required ?? true,
|
|
1081
|
-
defaultScope: config.defaultScope ?? "session" /* SESSION */
|
|
1082
|
-
};
|
|
987
|
+
return activeRecords.find((record) => record.state === "denied" /* DENIED */) ?? activeRecords[0];
|
|
1083
988
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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;
|
|
1087
1017
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
const
|
|
1091
|
-
const
|
|
1092
|
-
const
|
|
1093
|
-
const
|
|
1094
|
-
|
|
1095
|
-
|
|
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;
|
|
1096
1041
|
}
|
|
1097
|
-
|
|
1098
|
-
|
|
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;
|
|
1049
|
+
}
|
|
1050
|
+
return false;
|
|
1099
1051
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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;
|
|
1060
|
+
}
|
|
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;
|
|
1104
1077
|
}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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;
|
|
1108
1085
|
}
|
|
1109
|
-
return
|
|
1110
|
-
(preApproved) => preApproved.type === currentContext.type && preApproved.identifier === currentContext.identifier
|
|
1111
|
-
);
|
|
1086
|
+
return await this.storage.mdelete(keys);
|
|
1112
1087
|
}
|
|
1113
|
-
async
|
|
1114
|
-
|
|
1115
|
-
const
|
|
1116
|
-
const
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
defaultScope: config.defaultScope,
|
|
1124
|
-
maxTtlMs: config.maxTtlMs,
|
|
1125
|
-
category: config.category,
|
|
1126
|
-
riskLevel: config.riskLevel
|
|
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]);
|
|
1127
1098
|
}
|
|
1128
|
-
}
|
|
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;
|
|
1129
1146
|
}
|
|
1130
1147
|
};
|
|
1131
|
-
__decorateClass([
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
name: "approval:check",
|
|
1137
|
-
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
|
|
1138
1153
|
})
|
|
1139
|
-
],
|
|
1154
|
+
], ApprovalStorageStore);
|
|
1155
|
+
function createApprovalMemoryStore(options = {}) {
|
|
1156
|
+
const memoryStorage = (0, import_utils3.createMemoryStorage)();
|
|
1157
|
+
return new ApprovalStorageStore({
|
|
1158
|
+
...options,
|
|
1159
|
+
storageInstance: memoryStorage
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1140
1162
|
|
|
1141
1163
|
// plugins/plugin-approval/src/approval.plugin.ts
|
|
1142
|
-
var ApprovalPlugin = class extends
|
|
1164
|
+
var ApprovalPlugin = class extends import_sdk6.DynamicPlugin {
|
|
1143
1165
|
options;
|
|
1144
1166
|
constructor(options = {}) {
|
|
1145
1167
|
super();
|
|
@@ -1148,12 +1170,6 @@ var ApprovalPlugin = class extends import_sdk5.DynamicPlugin {
|
|
|
1148
1170
|
...options
|
|
1149
1171
|
};
|
|
1150
1172
|
}
|
|
1151
|
-
/**
|
|
1152
|
-
* Get plugin metadata including nested plugins.
|
|
1153
|
-
*/
|
|
1154
|
-
static getPluginMetadata(_options) {
|
|
1155
|
-
return { plugins: [ApprovalCheckPlugin] };
|
|
1156
|
-
}
|
|
1157
1173
|
};
|
|
1158
1174
|
__publicField(ApprovalPlugin, "defaultOptions", {
|
|
1159
1175
|
namespace: "approval",
|
|
@@ -1206,19 +1222,20 @@ __publicField(ApprovalPlugin, "dynamicProviders", (options) => {
|
|
|
1206
1222
|
providers.push({
|
|
1207
1223
|
name: "approval:service",
|
|
1208
1224
|
provide: ApprovalServiceToken,
|
|
1209
|
-
scope:
|
|
1210
|
-
inject: () => [ApprovalStoreToken,
|
|
1225
|
+
scope: import_sdk6.ProviderScope.CONTEXT,
|
|
1226
|
+
inject: () => [ApprovalStoreToken, import_sdk6.FRONTMCP_CONTEXT],
|
|
1211
1227
|
useFactory: (store, ctx) => {
|
|
1212
|
-
const
|
|
1213
|
-
return createApprovalService(store,
|
|
1228
|
+
const { sessionId, userId } = resolveApprovalIdentity(ctx);
|
|
1229
|
+
return createApprovalService(store, sessionId, userId);
|
|
1214
1230
|
}
|
|
1215
1231
|
});
|
|
1216
1232
|
return providers;
|
|
1217
1233
|
});
|
|
1218
1234
|
ApprovalPlugin = __decorateClass([
|
|
1219
|
-
(0,
|
|
1235
|
+
(0, import_sdk6.Plugin)({
|
|
1220
1236
|
name: "approval",
|
|
1221
1237
|
description: "Tool authorization workflow with PKCE webhook security",
|
|
1238
|
+
plugins: [ApprovalCheckPlugin],
|
|
1222
1239
|
contextExtensions: [
|
|
1223
1240
|
{
|
|
1224
1241
|
property: "approval",
|