@frontmcp/adapters 1.1.2 → 1.2.0
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/esm/index.mjs +396 -385
- package/esm/openapi/index.mjs +396 -385
- package/esm/package.json +18 -6
- package/esm/skills/index.mjs +2117 -0
- package/index.js +407 -389
- package/openapi/index.d.ts +11 -1
- package/openapi/index.d.ts.map +1 -1
- package/openapi/index.js +407 -389
- package/openapi/openapi.adapter.d.ts +2 -2
- package/openapi/openapi.adapter.d.ts.map +1 -1
- package/openapi/openapi.tool.d.ts.map +1 -1
- package/openapi/openapi.types.d.ts +3 -3
- package/openapi/openapi.types.d.ts.map +1 -1
- package/openapi/openapi.utils.d.ts.map +1 -1
- package/package.json +18 -6
- package/skills/audit/audit-chain.d.ts +81 -0
- package/skills/audit/audit-chain.d.ts.map +1 -0
- package/skills/audit/audit-config.d.ts +87 -0
- package/skills/audit/audit-config.d.ts.map +1 -0
- package/skills/audit/audit-record.types.d.ts +114 -0
- package/skills/audit/audit-record.types.d.ts.map +1 -0
- package/skills/audit/audit-signer.d.ts +72 -0
- package/skills/audit/audit-signer.d.ts.map +1 -0
- package/skills/audit/audit-store.d.ts +84 -0
- package/skills/audit/audit-store.d.ts.map +1 -0
- package/skills/audit/audit-writer.d.ts +175 -0
- package/skills/audit/audit-writer.d.ts.map +1 -0
- package/skills/audit/index.d.ts +7 -0
- package/skills/audit/index.d.ts.map +1 -0
- package/skills/bundle/bundle-diff.d.ts +30 -0
- package/skills/bundle/bundle-diff.d.ts.map +1 -0
- package/skills/bundle/bundle.schema.d.ts +101 -0
- package/skills/bundle/bundle.schema.d.ts.map +1 -0
- package/skills/bundle/bundle.store.d.ts +143 -0
- package/skills/bundle/bundle.store.d.ts.map +1 -0
- package/skills/bundle/bundle.types.d.ts +143 -0
- package/skills/bundle/bundle.types.d.ts.map +1 -0
- package/skills/bundle/overlay-parser.d.ts +42 -0
- package/skills/bundle/overlay-parser.d.ts.map +1 -0
- package/skills/dependency/skill-dag.d.ts +39 -0
- package/skills/dependency/skill-dag.d.ts.map +1 -0
- package/skills/index.d.ts +17 -0
- package/skills/index.d.ts.map +1 -0
- package/skills/index.js +2192 -0
- package/skills/security/bundle-signature.d.ts +44 -0
- package/skills/security/bundle-signature.d.ts.map +1 -0
- package/skills/security/jwt-verifier.d.ts +38 -0
- package/skills/security/jwt-verifier.d.ts.map +1 -0
- package/skills/security/webhook-replay-guard.d.ts +52 -0
- package/skills/security/webhook-replay-guard.d.ts.map +1 -0
- package/skills/source-options.d.ts +55 -0
- package/skills/source-options.d.ts.map +1 -0
- package/skills/sources/filesystem-skills.source.d.ts +127 -0
- package/skills/sources/filesystem-skills.source.d.ts.map +1 -0
- package/skills/sources/index.d.ts +10 -0
- package/skills/sources/index.d.ts.map +1 -0
- package/skills/sources/npm.source.d.ts +30 -0
- package/skills/sources/npm.source.d.ts.map +1 -0
- package/skills/sources/saas-pull.source.d.ts +47 -0
- package/skills/sources/saas-pull.source.d.ts.map +1 -0
- package/skills/sources/skill-bundle-source.interface.d.ts +36 -0
- package/skills/sources/skill-bundle-source.interface.d.ts.map +1 -0
- package/skills/sources/static.source.d.ts +30 -0
- package/skills/sources/static.source.d.ts.map +1 -0
|
@@ -0,0 +1,2117 @@
|
|
|
1
|
+
// libs/adapters/src/skills/bundle/bundle.types.ts
|
|
2
|
+
function bundleSkillToActions(skill, ops) {
|
|
3
|
+
return skill.operationIds.map((opId) => {
|
|
4
|
+
const op = ops[opId];
|
|
5
|
+
if (!op) {
|
|
6
|
+
throw new Error(`bundleSkillToActions: skill "${skill.id}" references unknown operationId "${opId}"`);
|
|
7
|
+
}
|
|
8
|
+
const action = {
|
|
9
|
+
actionId: op.operationId,
|
|
10
|
+
summary: op.summary ?? `${op.httpMethod} ${op.pathTemplate}`,
|
|
11
|
+
...op.description !== void 0 && { description: op.description },
|
|
12
|
+
inputJsonSchema: op.inputSchema,
|
|
13
|
+
outputJsonSchema: op.outputSchema,
|
|
14
|
+
...op.requiredAuthorities !== void 0 && { requiredAuthorities: op.requiredAuthorities }
|
|
15
|
+
};
|
|
16
|
+
return action;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// libs/adapters/src/skills/bundle/bundle.schema.ts
|
|
21
|
+
import { z } from "@frontmcp/lazy-zod";
|
|
22
|
+
var httpMethodSchema = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]);
|
|
23
|
+
var pathTemplateRegex = /^\/[^\s?#]*$/;
|
|
24
|
+
var FORBIDDEN_PATH_FRAGMENTS = ["..", "\\", "`", "$(", "${"];
|
|
25
|
+
var pathTemplateSchema = z.string().min(1).max(2048).refine((v) => pathTemplateRegex.test(v), {
|
|
26
|
+
message: "pathTemplate must start with `/` and contain no whitespace, `?`, or `#`"
|
|
27
|
+
}).refine((v) => !FORBIDDEN_PATH_FRAGMENTS.some((bad) => v.includes(bad)), {
|
|
28
|
+
message: "pathTemplate contains a forbidden character or fragment (`..`, backtick, `$(`, etc.)"
|
|
29
|
+
});
|
|
30
|
+
var jsonSchemaShape = z.record(z.string(), z.unknown());
|
|
31
|
+
var authoritiesPolicySchema = z.record(z.string(), z.unknown());
|
|
32
|
+
var authBindingSchema = z.discriminatedUnion("kind", [
|
|
33
|
+
z.object({ kind: z.literal("none") }).strict(),
|
|
34
|
+
z.object({
|
|
35
|
+
kind: z.literal("bearer"),
|
|
36
|
+
vaultRef: z.string().min(1).max(256),
|
|
37
|
+
passthroughCallerToken: z.boolean().optional()
|
|
38
|
+
}).strict(),
|
|
39
|
+
z.object({
|
|
40
|
+
kind: z.literal("apiKey"),
|
|
41
|
+
in: z.enum(["header", "query"]),
|
|
42
|
+
name: z.string().min(1).max(128).regex(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/, "apiKey name must match RFC 7230 token grammar"),
|
|
43
|
+
vaultRef: z.string().min(1).max(256)
|
|
44
|
+
}).strict(),
|
|
45
|
+
z.object({
|
|
46
|
+
kind: z.literal("oauth2"),
|
|
47
|
+
flow: z.literal("client_credentials"),
|
|
48
|
+
vaultRef: z.string().min(1).max(256)
|
|
49
|
+
}).strict()
|
|
50
|
+
]);
|
|
51
|
+
var serviceDescriptorSchema = z.object({
|
|
52
|
+
id: z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/, "service id must be alphanumeric / `-` / `_`"),
|
|
53
|
+
baseUrl: z.string().url().max(2048),
|
|
54
|
+
description: z.string().max(4096).optional()
|
|
55
|
+
}).strict();
|
|
56
|
+
var parameterMapperSchema = z.object({
|
|
57
|
+
inputKey: z.string().min(1).max(256),
|
|
58
|
+
type: z.enum(["path", "query", "header", "cookie", "body"]),
|
|
59
|
+
key: z.string().min(1).max(256),
|
|
60
|
+
required: z.boolean().optional(),
|
|
61
|
+
style: z.string().max(64).optional(),
|
|
62
|
+
explode: z.boolean().optional(),
|
|
63
|
+
serialization: z.record(z.string(), z.unknown()).optional(),
|
|
64
|
+
security: z.record(z.string(), z.unknown()).optional()
|
|
65
|
+
}).passthrough();
|
|
66
|
+
var operationDescriptorSchema = z.object({
|
|
67
|
+
operationId: z.string().min(1).max(256).regex(/^[a-zA-Z0-9_.\-:]+$/, "operationId must be alphanumeric / `-` / `_` / `.` / `:`"),
|
|
68
|
+
serviceId: z.string().min(1).max(128),
|
|
69
|
+
httpMethod: httpMethodSchema,
|
|
70
|
+
pathTemplate: pathTemplateSchema,
|
|
71
|
+
inputSchema: jsonSchemaShape,
|
|
72
|
+
outputSchema: jsonSchemaShape,
|
|
73
|
+
mapper: z.array(parameterMapperSchema).max(256),
|
|
74
|
+
authBindingRef: z.string().min(1).max(128),
|
|
75
|
+
requiredAuthorities: authoritiesPolicySchema.optional(),
|
|
76
|
+
maxResponseBytes: z.number().int().positive().max(64 * 1024 * 1024).optional(),
|
|
77
|
+
timeoutMs: z.number().int().positive().max(6e5).optional(),
|
|
78
|
+
summary: z.string().max(512).optional(),
|
|
79
|
+
description: z.string().max(8192).optional()
|
|
80
|
+
}).strict();
|
|
81
|
+
var bundledSkillSchema = z.object({
|
|
82
|
+
id: z.string().min(1).max(128).regex(/^[a-zA-Z0-9_.-]+$/),
|
|
83
|
+
name: z.string().min(1).max(256),
|
|
84
|
+
description: z.string().max(2048),
|
|
85
|
+
instructions: z.string().max(256 * 1024),
|
|
86
|
+
tags: z.array(z.string().max(64)).max(64).optional(),
|
|
87
|
+
operationIds: z.array(z.string().min(1).max(256)).min(0).max(2e3),
|
|
88
|
+
requiredAuthorities: authoritiesPolicySchema.optional(),
|
|
89
|
+
/**
|
|
90
|
+
* Other skill ids this skill depends on. Hosts that consume the bundle
|
|
91
|
+
* register skills in dependency order; cycles and missing deps are caught
|
|
92
|
+
* at apply time. Field name aligned with the agentskills Skill Package
|
|
93
|
+
* Manifest proposal (https://github.com/agentskills/agentskills/discussions/210)
|
|
94
|
+
* so bundles round-trip across runtimes without a schema bump.
|
|
95
|
+
*/
|
|
96
|
+
requires: z.array(z.string().min(1).max(128)).max(64).optional()
|
|
97
|
+
}).strict();
|
|
98
|
+
var integritySchema = z.object({
|
|
99
|
+
alg: z.enum(["RS256", "EdDSA"]),
|
|
100
|
+
keyId: z.string().min(1).max(256),
|
|
101
|
+
signature: z.string().min(1).max(8192),
|
|
102
|
+
digest: z.string().min(1).max(256)
|
|
103
|
+
}).strict();
|
|
104
|
+
var resolvedBundleSchema = z.object({
|
|
105
|
+
schemaVersion: z.literal(1),
|
|
106
|
+
bundleId: z.string().min(1).max(256).regex(/^[a-zA-Z0-9_.\-:]+$/),
|
|
107
|
+
version: z.string().min(1).max(256),
|
|
108
|
+
generatedAt: z.string().min(1).refine((v) => !Number.isNaN(Date.parse(v)), { message: "generatedAt must be ISO 8601 parsable" }),
|
|
109
|
+
sourceDigest: z.string().min(1).max(256).regex(/^[a-fA-F0-9]+$/, "sourceDigest must be a hex sha256"),
|
|
110
|
+
services: z.array(serviceDescriptorSchema).min(1).max(64),
|
|
111
|
+
authBindings: z.record(z.string().min(1).max(128), authBindingSchema),
|
|
112
|
+
skills: z.array(bundledSkillSchema).max(1e3),
|
|
113
|
+
operations: z.record(z.string().min(1).max(256), operationDescriptorSchema),
|
|
114
|
+
integrity: integritySchema.optional()
|
|
115
|
+
}).strict();
|
|
116
|
+
function crossValidate(bundle) {
|
|
117
|
+
const errors = [];
|
|
118
|
+
const serviceIds = new Set(bundle.services.map((s) => s.id));
|
|
119
|
+
const authRefs = new Set(Object.keys(bundle.authBindings));
|
|
120
|
+
const opIds = new Set(Object.keys(bundle.operations));
|
|
121
|
+
for (const [opId, op] of Object.entries(bundle.operations)) {
|
|
122
|
+
if (op.operationId !== opId) {
|
|
123
|
+
errors.push(`operation key "${opId}" does not match operationId "${op.operationId}"`);
|
|
124
|
+
}
|
|
125
|
+
if (!serviceIds.has(op.serviceId)) {
|
|
126
|
+
errors.push(`operation "${opId}" references unknown service "${op.serviceId}"`);
|
|
127
|
+
}
|
|
128
|
+
if (!authRefs.has(op.authBindingRef)) {
|
|
129
|
+
errors.push(`operation "${opId}" references unknown authBinding "${op.authBindingRef}"`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const seenSkillIds = /* @__PURE__ */ new Set();
|
|
133
|
+
for (const skill of bundle.skills) {
|
|
134
|
+
if (seenSkillIds.has(skill.id)) {
|
|
135
|
+
errors.push(`duplicate skill id "${skill.id}"`);
|
|
136
|
+
}
|
|
137
|
+
seenSkillIds.add(skill.id);
|
|
138
|
+
for (const opId of skill.operationIds) {
|
|
139
|
+
if (!opIds.has(opId)) {
|
|
140
|
+
errors.push(`skill "${skill.id}" references unknown operationId "${opId}"`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// libs/adapters/src/skills/bundle/overlay-parser.ts
|
|
148
|
+
import * as yaml from "js-yaml";
|
|
149
|
+
var OVERLAY_BUNDLE_KEY = "x-frontmcp-bundle";
|
|
150
|
+
var OverlayParseError = class extends Error {
|
|
151
|
+
constructor(message, errors) {
|
|
152
|
+
super(message);
|
|
153
|
+
this.errors = errors;
|
|
154
|
+
this.name = "OverlayParseError";
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
function parseRaw(input) {
|
|
158
|
+
switch (input.kind) {
|
|
159
|
+
case "yaml":
|
|
160
|
+
try {
|
|
161
|
+
return yaml.load(input.content);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
throw new OverlayParseError(`Overlay YAML parse failed: ${e.message}`);
|
|
164
|
+
}
|
|
165
|
+
case "json":
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(input.content);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
throw new OverlayParseError(`Overlay JSON parse failed: ${e.message}`);
|
|
170
|
+
}
|
|
171
|
+
case "object":
|
|
172
|
+
return input.content;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function extractBundle(overlay) {
|
|
176
|
+
if (overlay === null || typeof overlay !== "object") {
|
|
177
|
+
throw new OverlayParseError("Overlay root must be an object");
|
|
178
|
+
}
|
|
179
|
+
const root = overlay;
|
|
180
|
+
if (OVERLAY_BUNDLE_KEY in root) {
|
|
181
|
+
return root[OVERLAY_BUNDLE_KEY];
|
|
182
|
+
}
|
|
183
|
+
const info = root["info"];
|
|
184
|
+
if (info && typeof info === "object" && OVERLAY_BUNDLE_KEY in info) {
|
|
185
|
+
return info[OVERLAY_BUNDLE_KEY];
|
|
186
|
+
}
|
|
187
|
+
if ("schemaVersion" in root && "bundleId" in root) {
|
|
188
|
+
return root;
|
|
189
|
+
}
|
|
190
|
+
throw new OverlayParseError(
|
|
191
|
+
"Overlay does not contain `info.x-frontmcp-bundle` or a bare bundle object with schemaVersion + bundleId"
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
function parseOverlay(input) {
|
|
195
|
+
const raw = parseRaw(input);
|
|
196
|
+
const bundleCandidate = extractBundle(raw);
|
|
197
|
+
const result = resolvedBundleSchema.safeParse(bundleCandidate);
|
|
198
|
+
if (!result.success) {
|
|
199
|
+
const errors = result.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`);
|
|
200
|
+
throw new OverlayParseError(`Bundle schema validation failed (${errors.length} issue(s))`, errors);
|
|
201
|
+
}
|
|
202
|
+
const cross = crossValidate(result.data);
|
|
203
|
+
if (!cross.ok) {
|
|
204
|
+
throw new OverlayParseError(
|
|
205
|
+
`Bundle cross-reference validation failed (${cross.errors.length} issue(s))`,
|
|
206
|
+
cross.errors
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return result.data;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// libs/adapters/src/skills/bundle/bundle-diff.ts
|
|
213
|
+
var opEqual = (a, b) => a.operationId === b.operationId && a.serviceId === b.serviceId && a.httpMethod === b.httpMethod && a.pathTemplate === b.pathTemplate && a.authBindingRef === b.authBindingRef && JSON.stringify(a.mapper ?? null) === JSON.stringify(b.mapper ?? null) && JSON.stringify(a.inputSchema) === JSON.stringify(b.inputSchema) && JSON.stringify(a.outputSchema) === JSON.stringify(b.outputSchema) && JSON.stringify(a.requiredAuthorities ?? null) === JSON.stringify(b.requiredAuthorities ?? null) && a.maxResponseBytes === b.maxResponseBytes && a.timeoutMs === b.timeoutMs;
|
|
214
|
+
var skillEqual = (a, b) => a.id === b.id && a.name === b.name && a.description === b.description && a.instructions === b.instructions && JSON.stringify(a.tags ?? []) === JSON.stringify(b.tags ?? []) && JSON.stringify(a.operationIds) === JSON.stringify(b.operationIds) && JSON.stringify(a.requiredAuthorities ?? null) === JSON.stringify(b.requiredAuthorities ?? null);
|
|
215
|
+
function diffBundles(oldBundle, newBundle) {
|
|
216
|
+
const oldSkills = new Map((oldBundle?.skills ?? []).map((s) => [s.id, s]));
|
|
217
|
+
const newSkills = new Map(newBundle.skills.map((s) => [s.id, s]));
|
|
218
|
+
const addedSkillIds = [];
|
|
219
|
+
const removedSkillIds = [];
|
|
220
|
+
const changedSkillIds = [];
|
|
221
|
+
for (const [id, skill] of newSkills) {
|
|
222
|
+
const prev = oldSkills.get(id);
|
|
223
|
+
if (!prev) addedSkillIds.push(id);
|
|
224
|
+
else if (!skillEqual(prev, skill)) changedSkillIds.push(id);
|
|
225
|
+
}
|
|
226
|
+
for (const id of oldSkills.keys()) {
|
|
227
|
+
if (!newSkills.has(id)) removedSkillIds.push(id);
|
|
228
|
+
}
|
|
229
|
+
const oldOps = oldBundle?.operations ?? {};
|
|
230
|
+
const newOps = newBundle.operations;
|
|
231
|
+
const addedOperationIds = [];
|
|
232
|
+
const removedOperationIds = [];
|
|
233
|
+
const changedOperationIds = [];
|
|
234
|
+
for (const [id, op] of Object.entries(newOps)) {
|
|
235
|
+
const prev = oldOps[id];
|
|
236
|
+
if (!prev) addedOperationIds.push(id);
|
|
237
|
+
else if (!opEqual(prev, op)) changedOperationIds.push(id);
|
|
238
|
+
}
|
|
239
|
+
for (const id of Object.keys(oldOps)) {
|
|
240
|
+
if (!(id in newOps)) removedOperationIds.push(id);
|
|
241
|
+
}
|
|
242
|
+
const isNoOp = !!oldBundle && oldBundle.version === newBundle.version && addedSkillIds.length === 0 && removedSkillIds.length === 0 && changedSkillIds.length === 0 && addedOperationIds.length === 0 && removedOperationIds.length === 0 && changedOperationIds.length === 0;
|
|
243
|
+
return {
|
|
244
|
+
addedSkillIds,
|
|
245
|
+
removedSkillIds,
|
|
246
|
+
changedSkillIds,
|
|
247
|
+
addedOperationIds,
|
|
248
|
+
removedOperationIds,
|
|
249
|
+
changedOperationIds,
|
|
250
|
+
isNoOp
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function formatDiffSummary(diff) {
|
|
254
|
+
if (diff.isNoOp) return "no-op";
|
|
255
|
+
return [
|
|
256
|
+
`+${diff.addedSkillIds.length}/-${diff.removedSkillIds.length}/~${diff.changedSkillIds.length} skills`,
|
|
257
|
+
`+${diff.addedOperationIds.length}/-${diff.removedOperationIds.length}/~${diff.changedOperationIds.length} ops`
|
|
258
|
+
].join(", ");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// libs/adapters/src/skills/bundle/bundle.store.ts
|
|
262
|
+
var KNOWN_BUNDLE_SOURCES = ["static", "npm", "saas-pull", "webhook", "filesystem", "unknown"];
|
|
263
|
+
function normalizeBundleSource(value) {
|
|
264
|
+
if (typeof value !== "string") return "unknown";
|
|
265
|
+
return KNOWN_BUNDLE_SOURCES.includes(value) ? value : "unknown";
|
|
266
|
+
}
|
|
267
|
+
function classifySwapError(err) {
|
|
268
|
+
if (err instanceof BundlePinnedError) return "pinned";
|
|
269
|
+
if (err instanceof Error) {
|
|
270
|
+
const name = err.name;
|
|
271
|
+
const msg = err.message.toLowerCase();
|
|
272
|
+
if (name === "BundlePinnedError") return "pinned";
|
|
273
|
+
if (msg.includes("bundle is required") || msg.includes("malformed")) return "invalid_bundle";
|
|
274
|
+
if (msg.includes("listener")) return "listener_error";
|
|
275
|
+
}
|
|
276
|
+
return "unknown";
|
|
277
|
+
}
|
|
278
|
+
var BundleStore = class {
|
|
279
|
+
active;
|
|
280
|
+
listeners = /* @__PURE__ */ new Set();
|
|
281
|
+
history = [];
|
|
282
|
+
pinnedVersion;
|
|
283
|
+
historySize;
|
|
284
|
+
telemetry;
|
|
285
|
+
pullsCounter;
|
|
286
|
+
constructor(options = {}) {
|
|
287
|
+
const requested = options.historySize ?? 3;
|
|
288
|
+
if (!Number.isInteger(requested) || requested < 1) {
|
|
289
|
+
throw new Error("BundleStore: historySize must be a positive integer");
|
|
290
|
+
}
|
|
291
|
+
this.historySize = requested;
|
|
292
|
+
this.telemetry = options.telemetry;
|
|
293
|
+
this.pullsCounter = this.telemetry?.createCounter(
|
|
294
|
+
"frontmcp_skills_bundle_pulls_total",
|
|
295
|
+
"Number of bundle pulls (swaps) attempted, partitioned by status / source."
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
current() {
|
|
299
|
+
return this.active;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Swap to a new bundle. Returns the structural diff (no-op = same version
|
|
303
|
+
* with no field changes; the swap itself still fires listeners with isNoOp=true
|
|
304
|
+
* so observers can record the heartbeat).
|
|
305
|
+
*
|
|
306
|
+
* Throws when the store is pinned. The caller should check `isPinned()` first
|
|
307
|
+
* if it wants to skip silently rather than throw.
|
|
308
|
+
*
|
|
309
|
+
* @param next - new bundle to activate
|
|
310
|
+
* @param source - optional source label (e.g. `npm`, `saas-pull`, `webhook`)
|
|
311
|
+
* used as a counter / span attribute. When omitted, `unknown`
|
|
312
|
+
* is reported.
|
|
313
|
+
*/
|
|
314
|
+
swap(next, source) {
|
|
315
|
+
if (!next) {
|
|
316
|
+
throw new Error("BundleStore.swap: bundle is required");
|
|
317
|
+
}
|
|
318
|
+
const sourceLabel = normalizeBundleSource(source);
|
|
319
|
+
if (this.telemetry) {
|
|
320
|
+
const span = this.telemetry.startSpan("skill.bundle.swap", {
|
|
321
|
+
source: sourceLabel,
|
|
322
|
+
bundle_id: next.bundleId,
|
|
323
|
+
version: next.version,
|
|
324
|
+
skill_count: next.skills.length,
|
|
325
|
+
...this.active?.version ? { from_version: this.active.version } : {}
|
|
326
|
+
});
|
|
327
|
+
try {
|
|
328
|
+
if (this.pinnedVersion !== void 0) {
|
|
329
|
+
throw new BundlePinnedError(this.pinnedVersion);
|
|
330
|
+
}
|
|
331
|
+
const diff = this.commit(next, "normal");
|
|
332
|
+
span.setAttributes({ status: "ok", is_no_op: diff.isNoOp });
|
|
333
|
+
span.end();
|
|
334
|
+
this.pullsCounter?.inc(1, { status: "ok", source: sourceLabel });
|
|
335
|
+
return diff;
|
|
336
|
+
} catch (err) {
|
|
337
|
+
const reason = classifySwapError(err);
|
|
338
|
+
if (span.endWithError) {
|
|
339
|
+
span.endWithError(err instanceof Error ? err : String(err));
|
|
340
|
+
} else {
|
|
341
|
+
span.recordError?.(err instanceof Error ? err : new Error(String(err)));
|
|
342
|
+
span.end();
|
|
343
|
+
}
|
|
344
|
+
this.pullsCounter?.inc(1, { status: "error", source: sourceLabel, reason });
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (this.pinnedVersion !== void 0) {
|
|
349
|
+
throw new BundlePinnedError(this.pinnedVersion);
|
|
350
|
+
}
|
|
351
|
+
return this.commit(next, "normal");
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Pin the active bundle to a specific historical version. Pinned stores
|
|
355
|
+
* reject `swap()` so source-driven updates accumulate elsewhere without
|
|
356
|
+
* activating. Returns the diff of the swap that activated the pinned
|
|
357
|
+
* version, or undefined if the pinned version was already active.
|
|
358
|
+
*
|
|
359
|
+
* Throws when `version` is unknown to the store's history.
|
|
360
|
+
*/
|
|
361
|
+
pin(version) {
|
|
362
|
+
if (this.active && this.active.version === version) {
|
|
363
|
+
this.pinnedVersion = version;
|
|
364
|
+
return void 0;
|
|
365
|
+
}
|
|
366
|
+
const entry = this.history.find((h) => h.bundle.version === version);
|
|
367
|
+
if (!entry) {
|
|
368
|
+
throw new Error(`BundleStore.pin: version "${version}" not in history`);
|
|
369
|
+
}
|
|
370
|
+
const diff = this.commit(entry.bundle, "pin");
|
|
371
|
+
this.pinnedVersion = version;
|
|
372
|
+
return diff;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Re-activate the most recent bundle in history (the bundle that was
|
|
376
|
+
* active before the current one). Useful after a bad swap is detected.
|
|
377
|
+
* Returns the diff. Throws when no prior bundle is available.
|
|
378
|
+
*
|
|
379
|
+
* Rollback does NOT pin — sources can resume auto-swapping after rollback.
|
|
380
|
+
*/
|
|
381
|
+
rollback() {
|
|
382
|
+
const prior = this.history[this.history.length - 1];
|
|
383
|
+
if (!prior) {
|
|
384
|
+
throw new Error("BundleStore.rollback: no prior bundle in history");
|
|
385
|
+
}
|
|
386
|
+
this.pinnedVersion = void 0;
|
|
387
|
+
return this.commit(prior.bundle, "rollback");
|
|
388
|
+
}
|
|
389
|
+
/** Lift the pin so `swap()` accepts new bundles again. */
|
|
390
|
+
unpin() {
|
|
391
|
+
this.pinnedVersion = void 0;
|
|
392
|
+
}
|
|
393
|
+
/** True when the store is currently pinned. */
|
|
394
|
+
isPinned() {
|
|
395
|
+
return this.pinnedVersion !== void 0;
|
|
396
|
+
}
|
|
397
|
+
/** Pinned version string, or undefined when unpinned. */
|
|
398
|
+
pinned() {
|
|
399
|
+
return this.pinnedVersion;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Snapshot of the history ring (oldest first; does not include the active
|
|
403
|
+
* bundle). Returned array is a shallow copy — safe to inspect.
|
|
404
|
+
*/
|
|
405
|
+
historySnapshot() {
|
|
406
|
+
return this.history.map((h) => ({
|
|
407
|
+
version: h.bundle.version,
|
|
408
|
+
bundleId: h.bundle.bundleId,
|
|
409
|
+
validatedAt: h.validatedAt
|
|
410
|
+
}));
|
|
411
|
+
}
|
|
412
|
+
/** Subscribe to swap events. Returns an unsubscribe function. */
|
|
413
|
+
subscribe(fn) {
|
|
414
|
+
this.listeners.add(fn);
|
|
415
|
+
return () => {
|
|
416
|
+
this.listeners.delete(fn);
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/** Reset to empty state. Used by tests. Does NOT fire listeners. */
|
|
420
|
+
reset() {
|
|
421
|
+
this.active = void 0;
|
|
422
|
+
this.history = [];
|
|
423
|
+
this.pinnedVersion = void 0;
|
|
424
|
+
}
|
|
425
|
+
/** Commit a new active bundle (shared by swap / pin / rollback). */
|
|
426
|
+
commit(next, reason) {
|
|
427
|
+
const previous = this.active;
|
|
428
|
+
const diff = diffBundles(previous, next);
|
|
429
|
+
if (previous) {
|
|
430
|
+
const lastInHistory = this.history[this.history.length - 1];
|
|
431
|
+
if (!lastInHistory || lastInHistory.bundle.version !== previous.version) {
|
|
432
|
+
this.history.push({ bundle: previous, validatedAt: Date.now() });
|
|
433
|
+
if (this.history.length > this.historySize) {
|
|
434
|
+
this.history.splice(0, this.history.length - this.historySize);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
this.active = next;
|
|
439
|
+
for (const fn of this.listeners) {
|
|
440
|
+
try {
|
|
441
|
+
fn({ previous, current: next, diff, reason });
|
|
442
|
+
} catch {
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return diff;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
var BundlePinnedError = class extends Error {
|
|
449
|
+
constructor(pinnedVersion) {
|
|
450
|
+
super(`BundleStore is pinned to version "${pinnedVersion}"; new swaps are rejected until unpin().`);
|
|
451
|
+
this.pinnedVersion = pinnedVersion;
|
|
452
|
+
this.name = "BundlePinnedError";
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// libs/adapters/src/skills/dependency/skill-dag.ts
|
|
457
|
+
var SkillDependencyCycleError = class extends Error {
|
|
458
|
+
constructor(cycle) {
|
|
459
|
+
super(`skill dependency cycle: ${cycle.join(" -> ")}`);
|
|
460
|
+
this.cycle = cycle;
|
|
461
|
+
this.name = "SkillDependencyCycleError";
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
var SkillDependencyMissingError = class extends Error {
|
|
465
|
+
constructor(skillId, missingId) {
|
|
466
|
+
super(`skill "${skillId}" requires unknown skill "${missingId}"`);
|
|
467
|
+
this.skillId = skillId;
|
|
468
|
+
this.missingId = missingId;
|
|
469
|
+
this.name = "SkillDependencyMissingError";
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
var SkillDependencyInvariantError = class extends Error {
|
|
473
|
+
constructor(message) {
|
|
474
|
+
super(message);
|
|
475
|
+
this.name = "SkillDependencyInvariantError";
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
function resolveSkillLoadOrder(skills) {
|
|
479
|
+
const byId = /* @__PURE__ */ new Map();
|
|
480
|
+
for (const skill of skills) {
|
|
481
|
+
if (byId.has(skill.id)) {
|
|
482
|
+
byId.set(skill.id, skill);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
byId.set(skill.id, skill);
|
|
486
|
+
}
|
|
487
|
+
const inDegree = /* @__PURE__ */ new Map();
|
|
488
|
+
const requiredBy = /* @__PURE__ */ new Map();
|
|
489
|
+
for (const id of byId.keys()) {
|
|
490
|
+
inDegree.set(id, 0);
|
|
491
|
+
requiredBy.set(id, []);
|
|
492
|
+
}
|
|
493
|
+
for (const skill of byId.values()) {
|
|
494
|
+
const deps = skill.requires ?? [];
|
|
495
|
+
for (const depId of deps) {
|
|
496
|
+
if (!byId.has(depId)) {
|
|
497
|
+
throw new SkillDependencyMissingError(skill.id, depId);
|
|
498
|
+
}
|
|
499
|
+
inDegree.set(skill.id, (inDegree.get(skill.id) ?? 0) + 1);
|
|
500
|
+
const reverse = requiredBy.get(depId) ?? [];
|
|
501
|
+
reverse.push(skill.id);
|
|
502
|
+
requiredBy.set(depId, reverse);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const ready = [...byId.keys()].filter((id) => inDegree.get(id) === 0).sort();
|
|
506
|
+
const order = [];
|
|
507
|
+
while (ready.length > 0) {
|
|
508
|
+
const next = ready.shift();
|
|
509
|
+
if (next === void 0) {
|
|
510
|
+
throw new SkillDependencyInvariantError(
|
|
511
|
+
"resolveSkillLoadOrder: invariant broken \u2014 ready queue drained mid-iteration"
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const nextSkill = byId.get(next);
|
|
515
|
+
if (!nextSkill) {
|
|
516
|
+
throw new SkillDependencyInvariantError(
|
|
517
|
+
`resolveSkillLoadOrder: invariant broken \u2014 id "${next}" missing from byId map`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
order.push(nextSkill);
|
|
521
|
+
for (const dependent of requiredBy.get(next) ?? []) {
|
|
522
|
+
const remaining = (inDegree.get(dependent) ?? 0) - 1;
|
|
523
|
+
inDegree.set(dependent, remaining);
|
|
524
|
+
if (remaining === 0) {
|
|
525
|
+
let i = 0;
|
|
526
|
+
while (i < ready.length && ready[i] < dependent) i++;
|
|
527
|
+
ready.splice(i, 0, dependent);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (order.length === byId.size) {
|
|
532
|
+
return order;
|
|
533
|
+
}
|
|
534
|
+
const remainingIds = [...byId.keys()].filter((id) => (inDegree.get(id) ?? 0) > 0);
|
|
535
|
+
const cycle = findCycle(remainingIds, byId);
|
|
536
|
+
throw new SkillDependencyCycleError(cycle);
|
|
537
|
+
}
|
|
538
|
+
function findCycle(remaining, byId) {
|
|
539
|
+
const adj = /* @__PURE__ */ new Map();
|
|
540
|
+
for (const id of remaining) {
|
|
541
|
+
adj.set(
|
|
542
|
+
id,
|
|
543
|
+
(byId.get(id)?.requires ?? []).filter((d) => remaining.includes(d))
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
const WHITE = 0;
|
|
547
|
+
const GRAY = 1;
|
|
548
|
+
const BLACK = 2;
|
|
549
|
+
const color = /* @__PURE__ */ new Map();
|
|
550
|
+
for (const id of remaining) color.set(id, WHITE);
|
|
551
|
+
const stack = [];
|
|
552
|
+
function dfs(node) {
|
|
553
|
+
color.set(node, GRAY);
|
|
554
|
+
stack.push(node);
|
|
555
|
+
for (const next of adj.get(node) ?? []) {
|
|
556
|
+
const c = color.get(next);
|
|
557
|
+
if (c === GRAY) {
|
|
558
|
+
const startIdx = stack.indexOf(next);
|
|
559
|
+
return [...stack.slice(startIdx), next];
|
|
560
|
+
}
|
|
561
|
+
if (c === WHITE) {
|
|
562
|
+
const found = dfs(next);
|
|
563
|
+
if (found) return found;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
color.set(node, BLACK);
|
|
567
|
+
stack.pop();
|
|
568
|
+
return void 0;
|
|
569
|
+
}
|
|
570
|
+
for (const id of remaining) {
|
|
571
|
+
if (color.get(id) === WHITE) {
|
|
572
|
+
const cycle = dfs(id);
|
|
573
|
+
if (cycle) return cycle;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return remaining;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// libs/adapters/src/skills/source-options.ts
|
|
580
|
+
import { z as z2 } from "@frontmcp/lazy-zod";
|
|
581
|
+
var httpsUrl = z2.string().url().refine(
|
|
582
|
+
(v) => {
|
|
583
|
+
try {
|
|
584
|
+
return new URL(v).protocol === "https:";
|
|
585
|
+
} catch {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
{ message: "must use https://" }
|
|
590
|
+
);
|
|
591
|
+
var staticSourceSchema = z2.object({
|
|
592
|
+
type: z2.literal("static"),
|
|
593
|
+
/** Path to the bundle directory (containing spec.yaml + overlay.yaml) or a single overlay file. */
|
|
594
|
+
path: z2.string().min(1),
|
|
595
|
+
/** Watch the path for changes (fs.watch). Default false. */
|
|
596
|
+
watch: z2.boolean().default(false)
|
|
597
|
+
});
|
|
598
|
+
var npmSourceSchema = z2.object({
|
|
599
|
+
type: z2.literal("npm"),
|
|
600
|
+
/** npm package specifier, e.g. `@acme/frontmcp-billing-bundle`. */
|
|
601
|
+
packageName: z2.string().min(1),
|
|
602
|
+
/** Optional named export to read; default is the package's default export. */
|
|
603
|
+
exportName: z2.string().optional(),
|
|
604
|
+
/**
|
|
605
|
+
* Verify GitHub artifact attestation / npm provenance for the package before loading.
|
|
606
|
+
* Defaults to true. Set false (with warning) only for local development packages.
|
|
607
|
+
*/
|
|
608
|
+
verifyProvenance: z2.boolean().default(true)
|
|
609
|
+
});
|
|
610
|
+
var saasSourceSchema = z2.object({
|
|
611
|
+
type: z2.literal("saas"),
|
|
612
|
+
/** SaaS pull endpoint, e.g. `https://cloud.frontmcp.dev/v1/bundles/<bundleId>`. */
|
|
613
|
+
endpoint: httpsUrl,
|
|
614
|
+
/** Pinned JWT issued by the SaaS for the customer's FrontMCP server. */
|
|
615
|
+
authToken: z2.string().min(1),
|
|
616
|
+
/**
|
|
617
|
+
* RFC 8707 resource indicator the JWT must encode. The plugin verifies
|
|
618
|
+
* `resource` AND `aud` claims match this on every push/pull.
|
|
619
|
+
*/
|
|
620
|
+
expectedAudience: z2.string().min(1),
|
|
621
|
+
/** Polling interval in ms (boot pull is always immediate). Default 300000 (5 min). */
|
|
622
|
+
pollIntervalMs: z2.number().int().positive().default(3e5),
|
|
623
|
+
/**
|
|
624
|
+
* Mount a webhook handler at `POST /__skilled_openapi/push` so the SaaS can push
|
|
625
|
+
* updates synchronously. Default false in v1.2 (interval polling only).
|
|
626
|
+
*/
|
|
627
|
+
enableWebhook: z2.boolean().default(false),
|
|
628
|
+
/** JWKS URL for verifying SaaS-issued tokens. */
|
|
629
|
+
jwksUrl: httpsUrl,
|
|
630
|
+
/** Expected issuer (`iss`) claim. */
|
|
631
|
+
expectedIssuer: z2.string().min(1)
|
|
632
|
+
});
|
|
633
|
+
var bundleSourceSchema = z2.discriminatedUnion("type", [staticSourceSchema, npmSourceSchema, saasSourceSchema]);
|
|
634
|
+
var signatureKeySchema = z2.object({
|
|
635
|
+
/** Stable key id (matches `kid` claim in the bundle JWT). */
|
|
636
|
+
keyId: z2.string().min(1),
|
|
637
|
+
/** Algorithm for the signing key. */
|
|
638
|
+
alg: z2.enum(["RS256", "EdDSA"]),
|
|
639
|
+
/** PEM-encoded public key (RSA SPKI or Ed25519). */
|
|
640
|
+
publicKeyPem: z2.string().min(1)
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// libs/adapters/src/skills/sources/npm.source.ts
|
|
644
|
+
var NpmSource = class {
|
|
645
|
+
constructor(options, logger) {
|
|
646
|
+
this.options = options;
|
|
647
|
+
this.logger = logger;
|
|
648
|
+
this.id = `npm:${this.options.packageName}${this.options.exportName ? `#${this.options.exportName}` : ""}`;
|
|
649
|
+
}
|
|
650
|
+
id;
|
|
651
|
+
listeners = /* @__PURE__ */ new Set();
|
|
652
|
+
cached;
|
|
653
|
+
async start() {
|
|
654
|
+
const bundle = await this.loadBundleFromPackage();
|
|
655
|
+
this.cached = bundle;
|
|
656
|
+
this.notify(bundle);
|
|
657
|
+
}
|
|
658
|
+
onChange(listener) {
|
|
659
|
+
this.listeners.add(listener);
|
|
660
|
+
if (this.cached) {
|
|
661
|
+
try {
|
|
662
|
+
listener(this.cached);
|
|
663
|
+
} catch (e) {
|
|
664
|
+
this.logger.warn(`[npm-source] listener threw on replay: ${e.message}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return () => {
|
|
668
|
+
this.listeners.delete(listener);
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
async stop() {
|
|
672
|
+
this.listeners.clear();
|
|
673
|
+
}
|
|
674
|
+
async loadBundleFromPackage() {
|
|
675
|
+
if (this.options.verifyProvenance) {
|
|
676
|
+
this.logger.warn(
|
|
677
|
+
`[npm-source] verifyProvenance=true requested but provenance verification is not yet implemented in OSS (planned v1.2.x). Loading "${this.options.packageName}" without provenance check.`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
const mod = await this.dynamicImport(this.options.packageName);
|
|
681
|
+
const exportName = this.options.exportName ?? "default";
|
|
682
|
+
const modRecord = mod;
|
|
683
|
+
const exported = modRecord[exportName] ?? (exportName === "default" ? mod : void 0);
|
|
684
|
+
if (exported === void 0 || exported === null) {
|
|
685
|
+
throw new Error(`npm source "${this.options.packageName}": export "${exportName}" not found on the package`);
|
|
686
|
+
}
|
|
687
|
+
return parseOverlay({ kind: "object", content: exported });
|
|
688
|
+
}
|
|
689
|
+
// Indirected so tests can stub.
|
|
690
|
+
dynamicImport(specifier) {
|
|
691
|
+
return import(specifier);
|
|
692
|
+
}
|
|
693
|
+
notify(bundle) {
|
|
694
|
+
for (const fn of this.listeners) {
|
|
695
|
+
try {
|
|
696
|
+
fn(bundle);
|
|
697
|
+
} catch (e) {
|
|
698
|
+
this.logger.warn(`[npm-source] listener threw: ${e.message}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
// libs/adapters/src/skills/sources/saas-pull.source.ts
|
|
705
|
+
import { promises as fs } from "node:fs";
|
|
706
|
+
import * as path from "node:path";
|
|
707
|
+
var DEFAULT_CACHE_DIR = ".frontmcp/skilled-openapi";
|
|
708
|
+
var DEFAULT_PULL_TIMEOUT_MS = 3e4;
|
|
709
|
+
var SaasPullSource = class {
|
|
710
|
+
constructor(options, cacheDir, logger) {
|
|
711
|
+
this.options = options;
|
|
712
|
+
this.cacheDir = cacheDir;
|
|
713
|
+
this.logger = logger;
|
|
714
|
+
this.id = `saas:${this.options.endpoint}`;
|
|
715
|
+
}
|
|
716
|
+
id;
|
|
717
|
+
listeners = /* @__PURE__ */ new Set();
|
|
718
|
+
pollHandle;
|
|
719
|
+
inFlight = false;
|
|
720
|
+
stopped = false;
|
|
721
|
+
async start() {
|
|
722
|
+
let bundle;
|
|
723
|
+
try {
|
|
724
|
+
bundle = await this.fetchOnce();
|
|
725
|
+
await this.persistCache(bundle);
|
|
726
|
+
} catch (e) {
|
|
727
|
+
this.logger.warn(
|
|
728
|
+
`[saas-source] initial pull failed (${e.message}); attempting cached bundle fallback`
|
|
729
|
+
);
|
|
730
|
+
bundle = await this.loadCache();
|
|
731
|
+
if (!bundle) {
|
|
732
|
+
throw new Error(
|
|
733
|
+
`[saas-source] initial pull failed and no cached bundle is available at ${this.cachePath()}: ${e.message}`
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
this.logger.warn(`[saas-source] using cached bundle "${bundle.bundleId}@${bundle.version}"`);
|
|
737
|
+
}
|
|
738
|
+
this.notify(bundle);
|
|
739
|
+
this.schedulePoll();
|
|
740
|
+
}
|
|
741
|
+
onChange(listener) {
|
|
742
|
+
this.listeners.add(listener);
|
|
743
|
+
return () => {
|
|
744
|
+
this.listeners.delete(listener);
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
async stop() {
|
|
748
|
+
this.stopped = true;
|
|
749
|
+
if (this.pollHandle) {
|
|
750
|
+
clearTimeout(this.pollHandle);
|
|
751
|
+
this.pollHandle = void 0;
|
|
752
|
+
}
|
|
753
|
+
this.listeners.clear();
|
|
754
|
+
}
|
|
755
|
+
schedulePoll() {
|
|
756
|
+
if (this.stopped) return;
|
|
757
|
+
this.pollHandle = setTimeout(() => {
|
|
758
|
+
void this.pollOnce();
|
|
759
|
+
}, this.options.pollIntervalMs);
|
|
760
|
+
this.pollHandle.unref?.();
|
|
761
|
+
}
|
|
762
|
+
async pollOnce() {
|
|
763
|
+
if (this.stopped) return;
|
|
764
|
+
if (this.inFlight) {
|
|
765
|
+
this.schedulePoll();
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
this.inFlight = true;
|
|
769
|
+
try {
|
|
770
|
+
const bundle = await this.fetchOnce();
|
|
771
|
+
await this.persistCache(bundle);
|
|
772
|
+
this.notify(bundle);
|
|
773
|
+
} catch (e) {
|
|
774
|
+
this.logger.warn(`[saas-source] poll failed: ${e.message}`);
|
|
775
|
+
} finally {
|
|
776
|
+
this.inFlight = false;
|
|
777
|
+
this.schedulePoll();
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
// Indirected so tests can stub the network call.
|
|
781
|
+
async httpGet(url, headers) {
|
|
782
|
+
const controller = new AbortController();
|
|
783
|
+
const timer = setTimeout(() => controller.abort(), DEFAULT_PULL_TIMEOUT_MS);
|
|
784
|
+
try {
|
|
785
|
+
const res = await fetch(url, { method: "GET", headers, signal: controller.signal });
|
|
786
|
+
const body = await res.text();
|
|
787
|
+
return { status: res.status, body };
|
|
788
|
+
} finally {
|
|
789
|
+
clearTimeout(timer);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
async fetchOnce() {
|
|
793
|
+
const { status, body } = await this.httpGet(this.options.endpoint, {
|
|
794
|
+
Authorization: `Bearer ${this.options.authToken}`,
|
|
795
|
+
Accept: "application/json, application/yaml",
|
|
796
|
+
"User-Agent": "frontmcp-skilled-openapi"
|
|
797
|
+
});
|
|
798
|
+
if (status < 200 || status >= 300) {
|
|
799
|
+
throw new Error(`pull responded ${status}`);
|
|
800
|
+
}
|
|
801
|
+
const trimmed = body.trimStart();
|
|
802
|
+
return parseOverlay(
|
|
803
|
+
trimmed.startsWith("{") || trimmed.startsWith("[") ? { kind: "json", content: body } : { kind: "yaml", content: body }
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
cachePath() {
|
|
807
|
+
const dir = this.cacheDir ?? DEFAULT_CACHE_DIR;
|
|
808
|
+
return path.resolve(dir, `${this.options.expectedAudience.replace(/[^a-zA-Z0-9_.-]/g, "_")}.json`);
|
|
809
|
+
}
|
|
810
|
+
async persistCache(bundle) {
|
|
811
|
+
try {
|
|
812
|
+
const filePath = this.cachePath();
|
|
813
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
814
|
+
await fs.writeFile(filePath, JSON.stringify(bundle), "utf8");
|
|
815
|
+
} catch (e) {
|
|
816
|
+
this.logger.warn(`[saas-source] cache persist failed: ${e.message}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
async loadCache() {
|
|
820
|
+
try {
|
|
821
|
+
const filePath = this.cachePath();
|
|
822
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
823
|
+
return parseOverlay({ kind: "json", content: raw });
|
|
824
|
+
} catch {
|
|
825
|
+
return void 0;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
notify(bundle) {
|
|
829
|
+
for (const fn of this.listeners) {
|
|
830
|
+
try {
|
|
831
|
+
fn(bundle);
|
|
832
|
+
} catch (e) {
|
|
833
|
+
this.logger.warn(`[saas-source] listener threw: ${e.message}`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
// libs/adapters/src/skills/sources/static.source.ts
|
|
840
|
+
import { watch as fsWatch } from "node:fs";
|
|
841
|
+
import * as path2 from "node:path";
|
|
842
|
+
import { readFile } from "@frontmcp/utils";
|
|
843
|
+
var StaticSource = class {
|
|
844
|
+
constructor(options, logger) {
|
|
845
|
+
this.options = options;
|
|
846
|
+
this.logger = logger;
|
|
847
|
+
this.id = `static:${this.options.path}`;
|
|
848
|
+
}
|
|
849
|
+
id;
|
|
850
|
+
listeners = /* @__PURE__ */ new Set();
|
|
851
|
+
watcher;
|
|
852
|
+
debounceTimer;
|
|
853
|
+
stopped = false;
|
|
854
|
+
async start() {
|
|
855
|
+
const bundle = await this.fetchOnce();
|
|
856
|
+
this.notify(bundle);
|
|
857
|
+
if (this.options.watch) {
|
|
858
|
+
this.beginWatch();
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
onChange(listener) {
|
|
862
|
+
this.listeners.add(listener);
|
|
863
|
+
return () => {
|
|
864
|
+
this.listeners.delete(listener);
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
async stop() {
|
|
868
|
+
this.stopped = true;
|
|
869
|
+
if (this.watcher) {
|
|
870
|
+
this.watcher.close();
|
|
871
|
+
this.watcher = void 0;
|
|
872
|
+
}
|
|
873
|
+
if (this.debounceTimer) {
|
|
874
|
+
clearTimeout(this.debounceTimer);
|
|
875
|
+
this.debounceTimer = void 0;
|
|
876
|
+
}
|
|
877
|
+
this.listeners.clear();
|
|
878
|
+
}
|
|
879
|
+
async fetchOnce() {
|
|
880
|
+
const absPath = path2.resolve(this.options.path);
|
|
881
|
+
const raw = await readFile(absPath, "utf8");
|
|
882
|
+
const ext = path2.extname(absPath).toLowerCase();
|
|
883
|
+
let input;
|
|
884
|
+
if (ext === ".json") {
|
|
885
|
+
input = { kind: "json", content: raw };
|
|
886
|
+
} else if (ext === ".yaml" || ext === ".yml") {
|
|
887
|
+
input = { kind: "yaml", content: raw };
|
|
888
|
+
} else {
|
|
889
|
+
const trimmed = raw.trimStart();
|
|
890
|
+
input = trimmed.startsWith("{") || trimmed.startsWith("[") ? { kind: "json", content: raw } : { kind: "yaml", content: raw };
|
|
891
|
+
}
|
|
892
|
+
return parseOverlay(input);
|
|
893
|
+
}
|
|
894
|
+
notify(bundle) {
|
|
895
|
+
for (const fn of this.listeners) {
|
|
896
|
+
try {
|
|
897
|
+
fn(bundle);
|
|
898
|
+
} catch (e) {
|
|
899
|
+
this.logger.warn(`[static-source] listener threw: ${e.message}`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
beginWatch() {
|
|
904
|
+
const absPath = path2.resolve(this.options.path);
|
|
905
|
+
try {
|
|
906
|
+
this.watcher = fsWatch(absPath, { persistent: false }, () => this.scheduleRefresh());
|
|
907
|
+
} catch (e) {
|
|
908
|
+
this.logger.warn(`[static-source] fs.watch failed (${e.message}); polling at 2s instead`);
|
|
909
|
+
const tick = () => {
|
|
910
|
+
if (this.stopped) return;
|
|
911
|
+
this.scheduleRefresh();
|
|
912
|
+
setTimeout(tick, 2e3).unref?.();
|
|
913
|
+
};
|
|
914
|
+
setTimeout(tick, 2e3).unref?.();
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
scheduleRefresh() {
|
|
918
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
919
|
+
this.debounceTimer = setTimeout(async () => {
|
|
920
|
+
if (this.stopped) return;
|
|
921
|
+
try {
|
|
922
|
+
const bundle = await this.fetchOnce();
|
|
923
|
+
this.notify(bundle);
|
|
924
|
+
} catch (e) {
|
|
925
|
+
this.logger.warn(`[static-source] refresh failed: ${e.message}`);
|
|
926
|
+
}
|
|
927
|
+
}, 250);
|
|
928
|
+
this.debounceTimer.unref?.();
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
// libs/adapters/src/skills/sources/index.ts
|
|
933
|
+
function createBundleSource(source, cacheDir, logger) {
|
|
934
|
+
switch (source.type) {
|
|
935
|
+
case "static":
|
|
936
|
+
return new StaticSource(source, logger);
|
|
937
|
+
case "npm":
|
|
938
|
+
return new NpmSource(source, logger);
|
|
939
|
+
case "saas":
|
|
940
|
+
return new SaasPullSource(source, cacheDir, logger);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// libs/adapters/src/skills/sources/filesystem-skills.source.ts
|
|
945
|
+
import { watch as fsWatch2 } from "node:fs";
|
|
946
|
+
import { sep as pathSep } from "node:path";
|
|
947
|
+
import { basename, joinPath, pathResolve, readdir, readFile as readFile2, realpath, stat } from "@frontmcp/utils";
|
|
948
|
+
var DEFAULT_OPTIONS = {
|
|
949
|
+
skillsDir: "./skills",
|
|
950
|
+
watch: true,
|
|
951
|
+
debounceMs: 200,
|
|
952
|
+
pollIntervalMs: 2e3
|
|
953
|
+
};
|
|
954
|
+
var FilesystemSkillsSource = class {
|
|
955
|
+
constructor(options, logger) {
|
|
956
|
+
this.logger = logger;
|
|
957
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
958
|
+
this.id = `filesystem-skills:${this.options.skillsDir}`;
|
|
959
|
+
}
|
|
960
|
+
id;
|
|
961
|
+
options;
|
|
962
|
+
listeners = /* @__PURE__ */ new Set();
|
|
963
|
+
known = /* @__PURE__ */ new Map();
|
|
964
|
+
// absPath → last emitted content
|
|
965
|
+
watcher;
|
|
966
|
+
pollTimer;
|
|
967
|
+
debounceTimers = /* @__PURE__ */ new Map();
|
|
968
|
+
stopped = false;
|
|
969
|
+
rootResolved = "";
|
|
970
|
+
onChange(listener) {
|
|
971
|
+
this.listeners.add(listener);
|
|
972
|
+
return () => {
|
|
973
|
+
this.listeners.delete(listener);
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
async start() {
|
|
977
|
+
const absRoot = pathResolve(this.options.skillsDir);
|
|
978
|
+
try {
|
|
979
|
+
this.rootResolved = await realpath(absRoot);
|
|
980
|
+
} catch {
|
|
981
|
+
this.rootResolved = absRoot;
|
|
982
|
+
}
|
|
983
|
+
await this.initialScan();
|
|
984
|
+
if (this.options.watch) {
|
|
985
|
+
this.beginWatch();
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
async stop() {
|
|
989
|
+
this.stopped = true;
|
|
990
|
+
if (this.watcher) {
|
|
991
|
+
try {
|
|
992
|
+
this.watcher.close();
|
|
993
|
+
} catch {
|
|
994
|
+
}
|
|
995
|
+
this.watcher = void 0;
|
|
996
|
+
}
|
|
997
|
+
if (this.pollTimer) {
|
|
998
|
+
clearTimeout(this.pollTimer);
|
|
999
|
+
this.pollTimer = void 0;
|
|
1000
|
+
}
|
|
1001
|
+
for (const t of this.debounceTimers.values()) clearTimeout(t);
|
|
1002
|
+
this.debounceTimers.clear();
|
|
1003
|
+
this.listeners.clear();
|
|
1004
|
+
this.known.clear();
|
|
1005
|
+
}
|
|
1006
|
+
async initialScan() {
|
|
1007
|
+
const entries = await this.listSkillSubdirs();
|
|
1008
|
+
for (const dir of entries) {
|
|
1009
|
+
await this.refreshSkill(dir);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
async listSkillSubdirs() {
|
|
1013
|
+
let entries;
|
|
1014
|
+
try {
|
|
1015
|
+
entries = await readdir(this.rootResolved);
|
|
1016
|
+
} catch (e) {
|
|
1017
|
+
this.logger.warn(`[fs-skills] cannot read ${this.rootResolved}: ${e.message}`);
|
|
1018
|
+
return [];
|
|
1019
|
+
}
|
|
1020
|
+
const out = [];
|
|
1021
|
+
for (const name of entries) {
|
|
1022
|
+
if (name.startsWith(".")) continue;
|
|
1023
|
+
const abs = joinPath(this.rootResolved, name);
|
|
1024
|
+
let resolved;
|
|
1025
|
+
try {
|
|
1026
|
+
resolved = await realpath(abs);
|
|
1027
|
+
} catch {
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
if (!resolved.startsWith(this.rootResolved + pathSep) && resolved !== this.rootResolved) {
|
|
1031
|
+
this.logger.warn(`[fs-skills] skipping ${name}: realpath escapes skillsDir`);
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
try {
|
|
1035
|
+
const st = await stat(resolved);
|
|
1036
|
+
if (st.isDirectory()) out.push(resolved);
|
|
1037
|
+
} catch {
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return out.sort();
|
|
1041
|
+
}
|
|
1042
|
+
/** Reload a single skill subdir and emit upsert (or delete if vanished). */
|
|
1043
|
+
async refreshSkill(absDir) {
|
|
1044
|
+
if (this.stopped) return;
|
|
1045
|
+
let exists = false;
|
|
1046
|
+
try {
|
|
1047
|
+
const st = await stat(absDir);
|
|
1048
|
+
exists = st.isDirectory();
|
|
1049
|
+
} catch {
|
|
1050
|
+
exists = false;
|
|
1051
|
+
}
|
|
1052
|
+
if (!exists) {
|
|
1053
|
+
const prev2 = this.known.get(absDir);
|
|
1054
|
+
if (prev2) {
|
|
1055
|
+
this.known.delete(absDir);
|
|
1056
|
+
this.emit({ op: "delete", skillId: prev2.id, absPath: absDir });
|
|
1057
|
+
}
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
let content;
|
|
1061
|
+
try {
|
|
1062
|
+
content = await loadFilesystemSkill(absDir);
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
this.logger.warn(`[fs-skills] failed to load ${absDir}: ${e.message}`);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
const prev = this.known.get(absDir);
|
|
1068
|
+
if (prev && skillContentEqual(prev, content)) {
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
this.known.set(absDir, content);
|
|
1072
|
+
this.emit({ op: "upsert", skill: content, absPath: absDir });
|
|
1073
|
+
}
|
|
1074
|
+
emit(event) {
|
|
1075
|
+
for (const fn of this.listeners) {
|
|
1076
|
+
try {
|
|
1077
|
+
fn(event);
|
|
1078
|
+
} catch (e) {
|
|
1079
|
+
this.logger.warn(`[fs-skills] listener threw: ${e.message}`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
beginWatch() {
|
|
1084
|
+
try {
|
|
1085
|
+
this.watcher = fsWatch2(this.rootResolved, { recursive: true, persistent: false }, (_eventType, filename) => {
|
|
1086
|
+
if (this.stopped) return;
|
|
1087
|
+
if (!filename) {
|
|
1088
|
+
this.scheduleRescan();
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
const first = filename.split(pathSep)[0];
|
|
1092
|
+
if (!first || first.startsWith(".")) return;
|
|
1093
|
+
const absDir = joinPath(this.rootResolved, first);
|
|
1094
|
+
this.scheduleRefresh(absDir);
|
|
1095
|
+
});
|
|
1096
|
+
this.watcher.on("error", (e) => {
|
|
1097
|
+
this.logger.warn(`[fs-skills] fs.watch error: ${e.message}; falling back to polling`);
|
|
1098
|
+
this.watcher?.close();
|
|
1099
|
+
this.watcher = void 0;
|
|
1100
|
+
this.beginPolling();
|
|
1101
|
+
});
|
|
1102
|
+
} catch (e) {
|
|
1103
|
+
this.logger.warn(
|
|
1104
|
+
`[fs-skills] fs.watch failed (${e.message}); polling at ${this.options.pollIntervalMs}ms instead`
|
|
1105
|
+
);
|
|
1106
|
+
this.beginPolling();
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
beginPolling() {
|
|
1110
|
+
const tick = async () => {
|
|
1111
|
+
if (this.stopped) return;
|
|
1112
|
+
try {
|
|
1113
|
+
await this.pollOnce();
|
|
1114
|
+
} catch (e) {
|
|
1115
|
+
this.logger.warn(`[fs-skills] poll tick error: ${e.message}`);
|
|
1116
|
+
}
|
|
1117
|
+
if (this.stopped) return;
|
|
1118
|
+
this.pollTimer = setTimeout(tick, this.options.pollIntervalMs);
|
|
1119
|
+
this.pollTimer.unref?.();
|
|
1120
|
+
};
|
|
1121
|
+
this.pollTimer = setTimeout(tick, this.options.pollIntervalMs);
|
|
1122
|
+
this.pollTimer.unref?.();
|
|
1123
|
+
}
|
|
1124
|
+
/** Compare current dir listing to known set; emit upserts/deletes. */
|
|
1125
|
+
async pollOnce() {
|
|
1126
|
+
const dirs = new Set(await this.listSkillSubdirs());
|
|
1127
|
+
for (const known of [...this.known.keys()]) {
|
|
1128
|
+
if (!dirs.has(known)) {
|
|
1129
|
+
await this.refreshSkill(known);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
for (const abs of dirs) {
|
|
1133
|
+
await this.refreshSkill(abs);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
/** Coarse rescan when filename is unknown (some Linux/macOS fs.watch events). */
|
|
1137
|
+
scheduleRescan() {
|
|
1138
|
+
const key = "<rescan>";
|
|
1139
|
+
const existing = this.debounceTimers.get(key);
|
|
1140
|
+
if (existing) clearTimeout(existing);
|
|
1141
|
+
const t = setTimeout(() => {
|
|
1142
|
+
this.debounceTimers.delete(key);
|
|
1143
|
+
void this.pollOnce().catch((e) => {
|
|
1144
|
+
this.logger.warn(`[fs-skills] rescan failed: ${e.message}`);
|
|
1145
|
+
});
|
|
1146
|
+
}, this.options.debounceMs);
|
|
1147
|
+
t.unref?.();
|
|
1148
|
+
this.debounceTimers.set(key, t);
|
|
1149
|
+
}
|
|
1150
|
+
scheduleRefresh(absDir) {
|
|
1151
|
+
const existing = this.debounceTimers.get(absDir);
|
|
1152
|
+
if (existing) clearTimeout(existing);
|
|
1153
|
+
const t = setTimeout(() => {
|
|
1154
|
+
this.debounceTimers.delete(absDir);
|
|
1155
|
+
void this.refreshSkill(absDir).catch((e) => {
|
|
1156
|
+
this.logger.warn(`[fs-skills] refresh failed for ${absDir}: ${e.message}`);
|
|
1157
|
+
});
|
|
1158
|
+
}, this.options.debounceMs);
|
|
1159
|
+
t.unref?.();
|
|
1160
|
+
this.debounceTimers.set(absDir, t);
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
async function loadFilesystemSkill(absDir) {
|
|
1164
|
+
const skillMdPath = joinPath(absDir, "SKILL.md");
|
|
1165
|
+
const raw = await readFile2(skillMdPath, "utf8");
|
|
1166
|
+
const { frontmatter, body } = parseSkillFrontmatter(raw);
|
|
1167
|
+
const dirName = basename(absDir);
|
|
1168
|
+
const name = frontmatter["name"] ?? dirName;
|
|
1169
|
+
const description = frontmatter["description"] ?? "";
|
|
1170
|
+
if (!description) {
|
|
1171
|
+
throw new Error(`SKILL.md missing 'description' frontmatter (in ${absDir})`);
|
|
1172
|
+
}
|
|
1173
|
+
const instructions = body.trim();
|
|
1174
|
+
if (!instructions) {
|
|
1175
|
+
throw new Error(`SKILL.md missing body content (in ${absDir})`);
|
|
1176
|
+
}
|
|
1177
|
+
const tools = parseToolList(frontmatter["tools"]);
|
|
1178
|
+
const resources = {};
|
|
1179
|
+
for (const sub of ["scripts", "references", "examples", "assets"]) {
|
|
1180
|
+
const subAbs = joinPath(absDir, sub);
|
|
1181
|
+
try {
|
|
1182
|
+
const st = await stat(subAbs);
|
|
1183
|
+
if (st.isDirectory()) resources[sub] = subAbs;
|
|
1184
|
+
} catch {
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
const out = {
|
|
1188
|
+
id: name,
|
|
1189
|
+
name,
|
|
1190
|
+
description,
|
|
1191
|
+
instructions,
|
|
1192
|
+
tools,
|
|
1193
|
+
...Object.keys(resources).length > 0 && { resources }
|
|
1194
|
+
};
|
|
1195
|
+
if (typeof frontmatter["license"] === "string") out.license = frontmatter["license"];
|
|
1196
|
+
if (typeof frontmatter["compatibility"] === "string") out.compatibility = frontmatter["compatibility"];
|
|
1197
|
+
return out;
|
|
1198
|
+
}
|
|
1199
|
+
function parseSkillFrontmatter(raw) {
|
|
1200
|
+
if (!raw.startsWith("---")) {
|
|
1201
|
+
return { frontmatter: {}, body: raw };
|
|
1202
|
+
}
|
|
1203
|
+
const closeIdx = raw.indexOf("\n---", 3);
|
|
1204
|
+
if (closeIdx === -1) {
|
|
1205
|
+
return { frontmatter: {}, body: raw };
|
|
1206
|
+
}
|
|
1207
|
+
const yamlBlock = raw.slice(3, closeIdx).trim();
|
|
1208
|
+
let bodyStart = closeIdx + 4;
|
|
1209
|
+
if (raw[bodyStart] === "\n") bodyStart += 1;
|
|
1210
|
+
const body = raw.slice(bodyStart);
|
|
1211
|
+
const fm = {};
|
|
1212
|
+
let currentListKey;
|
|
1213
|
+
for (const line of yamlBlock.split("\n")) {
|
|
1214
|
+
if (!line.trim()) continue;
|
|
1215
|
+
if (line.startsWith("- ") && currentListKey) {
|
|
1216
|
+
const arr = fm[currentListKey] ?? [];
|
|
1217
|
+
arr.push(stripQuotes(line.slice(2).trim()));
|
|
1218
|
+
fm[currentListKey] = arr;
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
const colon = line.indexOf(":");
|
|
1222
|
+
if (colon === -1) continue;
|
|
1223
|
+
const key = line.slice(0, colon).trim();
|
|
1224
|
+
const rest = line.slice(colon + 1).trim();
|
|
1225
|
+
if (!rest) {
|
|
1226
|
+
currentListKey = key;
|
|
1227
|
+
fm[key] = [];
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
currentListKey = void 0;
|
|
1231
|
+
fm[key] = stripQuotes(rest);
|
|
1232
|
+
}
|
|
1233
|
+
return { frontmatter: fm, body };
|
|
1234
|
+
}
|
|
1235
|
+
function stripQuotes(s) {
|
|
1236
|
+
if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
|
|
1237
|
+
return s.slice(1, -1);
|
|
1238
|
+
}
|
|
1239
|
+
return s;
|
|
1240
|
+
}
|
|
1241
|
+
function parseToolList(raw) {
|
|
1242
|
+
if (Array.isArray(raw)) {
|
|
1243
|
+
return raw.map((t) => typeof t === "string" ? { name: t } : null).filter((t) => t !== null);
|
|
1244
|
+
}
|
|
1245
|
+
if (typeof raw === "string") {
|
|
1246
|
+
return raw.split(",").map((t) => t.trim()).filter(Boolean).map((name) => ({ name }));
|
|
1247
|
+
}
|
|
1248
|
+
return [];
|
|
1249
|
+
}
|
|
1250
|
+
function skillContentEqual(a, b) {
|
|
1251
|
+
return a.id === b.id && a.name === b.name && a.description === b.description && a.instructions === b.instructions && a.license === b.license && a.compatibility === b.compatibility && JSON.stringify(a.tools) === JSON.stringify(b.tools) && JSON.stringify(a.resources ?? {}) === JSON.stringify(b.resources ?? {});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// libs/adapters/src/skills/security/bundle-signature.ts
|
|
1255
|
+
import { base64urlDecode, pemToPublicJwk, rsaVerifySync, sha256Hex } from "@frontmcp/utils";
|
|
1256
|
+
function classifyReason(reason) {
|
|
1257
|
+
if (reason.includes("missing integrity")) return "missing_integrity";
|
|
1258
|
+
if (reason.includes("digest mismatch")) return "digest_mismatch";
|
|
1259
|
+
if (reason.includes("unknown signing keyId")) return "unknown_key_id";
|
|
1260
|
+
if (reason.includes("alg=")) return "alg_mismatch";
|
|
1261
|
+
if (reason.includes("parse public key")) return "malformed_public_key";
|
|
1262
|
+
if (reason.includes("not valid base64url")) return "malformed_signature";
|
|
1263
|
+
if (reason.includes("signature verify threw")) return "verify_threw";
|
|
1264
|
+
if (reason.includes("signature verification failed")) return "verify_failed";
|
|
1265
|
+
return "other";
|
|
1266
|
+
}
|
|
1267
|
+
function canonicalize(value) {
|
|
1268
|
+
if (value === null || typeof value !== "object") {
|
|
1269
|
+
return JSON.stringify(value);
|
|
1270
|
+
}
|
|
1271
|
+
if (Array.isArray(value)) {
|
|
1272
|
+
return `[${value.map((v) => canonicalize(v)).join(",")}]`;
|
|
1273
|
+
}
|
|
1274
|
+
const obj = value;
|
|
1275
|
+
const keys = Object.keys(obj).sort();
|
|
1276
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`).join(",")}}`;
|
|
1277
|
+
}
|
|
1278
|
+
function bundleDigest(bundle) {
|
|
1279
|
+
const { integrity: _drop, ...rest } = bundle;
|
|
1280
|
+
void _drop;
|
|
1281
|
+
return sha256Hex(canonicalize(rest));
|
|
1282
|
+
}
|
|
1283
|
+
function decodeSignatureBytes(signatureBase64Url) {
|
|
1284
|
+
try {
|
|
1285
|
+
return base64urlDecode(signatureBase64Url);
|
|
1286
|
+
} catch {
|
|
1287
|
+
return void 0;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
function verifyBundleSignature(bundle, trustedKeys, telemetry) {
|
|
1291
|
+
const failuresCounter = telemetry?.createCounter(
|
|
1292
|
+
"frontmcp_skills_signature_failures_total",
|
|
1293
|
+
"Number of bundle signature verification failures, partitioned by reason."
|
|
1294
|
+
);
|
|
1295
|
+
const verificationsCounter = telemetry?.createCounter(
|
|
1296
|
+
"frontmcp_skills_signature_verifications_total",
|
|
1297
|
+
"Number of bundle signature verification attempts, partitioned by status (ok|error)."
|
|
1298
|
+
);
|
|
1299
|
+
const fail = (reason) => {
|
|
1300
|
+
failuresCounter?.inc(1, { reason: classifyReason(reason) });
|
|
1301
|
+
verificationsCounter?.inc(1, { status: "error" });
|
|
1302
|
+
return { ok: false, reason };
|
|
1303
|
+
};
|
|
1304
|
+
if (!bundle.integrity) {
|
|
1305
|
+
return fail("bundle missing integrity envelope");
|
|
1306
|
+
}
|
|
1307
|
+
const { alg, keyId, signature, digest } = bundle.integrity;
|
|
1308
|
+
const expectedDigest = bundleDigest(bundle);
|
|
1309
|
+
if (digest.toLowerCase() !== expectedDigest.toLowerCase()) {
|
|
1310
|
+
return fail(`digest mismatch (envelope=${digest.slice(0, 12)}.. computed=${expectedDigest.slice(0, 12)}..)`);
|
|
1311
|
+
}
|
|
1312
|
+
const trusted = trustedKeys.find((k) => k.keyId === keyId);
|
|
1313
|
+
if (!trusted) {
|
|
1314
|
+
return fail(`unknown signing keyId "${keyId}" \u2014 not in trustedKeys allowlist`);
|
|
1315
|
+
}
|
|
1316
|
+
if (trusted.alg !== alg) {
|
|
1317
|
+
return fail(`key "${keyId}" alg=${trusted.alg} but envelope alg=${alg}`);
|
|
1318
|
+
}
|
|
1319
|
+
let publicJwk;
|
|
1320
|
+
try {
|
|
1321
|
+
publicJwk = pemToPublicJwk(trusted.publicKeyPem);
|
|
1322
|
+
} catch (e) {
|
|
1323
|
+
return fail(`failed to parse public key for "${keyId}": ${e.message}`);
|
|
1324
|
+
}
|
|
1325
|
+
const sigBytes = decodeSignatureBytes(signature);
|
|
1326
|
+
if (!sigBytes) {
|
|
1327
|
+
return fail("signature is not valid base64url");
|
|
1328
|
+
}
|
|
1329
|
+
const { integrity: _drop, ...rest } = bundle;
|
|
1330
|
+
void _drop;
|
|
1331
|
+
const canonicalBytes = new TextEncoder().encode(canonicalize(rest));
|
|
1332
|
+
const verified = rsaVerifySync(alg, canonicalBytes, publicJwk, sigBytes);
|
|
1333
|
+
if (!verified) {
|
|
1334
|
+
return fail("signature verification failed");
|
|
1335
|
+
}
|
|
1336
|
+
verificationsCounter?.inc(1, { status: "ok" });
|
|
1337
|
+
return { ok: true, keyId, alg };
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// libs/adapters/src/skills/security/jwt-verifier.ts
|
|
1341
|
+
import { JwksService } from "@frontmcp/auth";
|
|
1342
|
+
var DEFAULT_REQUIRED_ROLES = ["frontmcp:cloud:push"];
|
|
1343
|
+
var BundlePushJwtVerifier = class {
|
|
1344
|
+
constructor(options, logger, jwks) {
|
|
1345
|
+
this.options = options;
|
|
1346
|
+
this.logger = logger;
|
|
1347
|
+
this.jwks = jwks ?? new JwksService({});
|
|
1348
|
+
this.providerRef = {
|
|
1349
|
+
id: "skilled-openapi-cloud-push",
|
|
1350
|
+
issuerUrl: options.expectedIssuer,
|
|
1351
|
+
jwksUri: options.jwksUri
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
providerRef;
|
|
1355
|
+
jwks;
|
|
1356
|
+
/**
|
|
1357
|
+
* Verify a SaaS-issued bearer token. Returns ok=true only if signature,
|
|
1358
|
+
* issuer, audience, RFC 8707 resource indicator, and role claims all check.
|
|
1359
|
+
*/
|
|
1360
|
+
async verify(token) {
|
|
1361
|
+
if (!token || typeof token !== "string") {
|
|
1362
|
+
return { ok: false, reason: "token missing" };
|
|
1363
|
+
}
|
|
1364
|
+
let res;
|
|
1365
|
+
try {
|
|
1366
|
+
res = await this.jwks.verifyTransparentToken(token, [this.providerRef]);
|
|
1367
|
+
} catch (e) {
|
|
1368
|
+
this.logger.debug(`[skilled-openapi] jwt verify threw: ${e.message}`);
|
|
1369
|
+
return { ok: false, reason: `verify failed: ${e.message}` };
|
|
1370
|
+
}
|
|
1371
|
+
if (!res.ok || !res.payload) {
|
|
1372
|
+
return { ok: false, reason: res.error ?? "signature verify failed" };
|
|
1373
|
+
}
|
|
1374
|
+
const payload = res.payload;
|
|
1375
|
+
const aud = payload["aud"];
|
|
1376
|
+
const audValues = Array.isArray(aud) ? aud : aud !== void 0 ? [aud] : [];
|
|
1377
|
+
if (!audValues.includes(this.options.expectedAudience)) {
|
|
1378
|
+
return { ok: false, reason: `aud mismatch (expected "${this.options.expectedAudience}")` };
|
|
1379
|
+
}
|
|
1380
|
+
const resource = payload["resource"];
|
|
1381
|
+
const resValues = Array.isArray(resource) ? resource : resource !== void 0 ? [resource] : [];
|
|
1382
|
+
if (!resValues.includes(this.options.expectedResource)) {
|
|
1383
|
+
return {
|
|
1384
|
+
ok: false,
|
|
1385
|
+
reason: `RFC 8707 resource indicator mismatch (expected "${this.options.expectedResource}")`
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
const requiredRoles = this.options.requiredRoles ?? DEFAULT_REQUIRED_ROLES;
|
|
1389
|
+
if (requiredRoles.length > 0 && !this.matchesAny(payload["roles"], requiredRoles)) {
|
|
1390
|
+
return { ok: false, reason: `missing required role (need one of ${requiredRoles.join(", ")})` };
|
|
1391
|
+
}
|
|
1392
|
+
if (this.options.requiredScopes && this.options.requiredScopes.length > 0) {
|
|
1393
|
+
if (!this.matchesAny(payload["scope"] ?? payload["scp"], this.options.requiredScopes)) {
|
|
1394
|
+
return {
|
|
1395
|
+
ok: false,
|
|
1396
|
+
reason: `missing required scope (need one of ${this.options.requiredScopes.join(", ")})`
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
return { ok: true, payload };
|
|
1401
|
+
}
|
|
1402
|
+
matchesAny(claim, required) {
|
|
1403
|
+
const have = Array.isArray(claim) ? claim.filter((v) => typeof v === "string") : typeof claim === "string" ? claim.split(/\s+/).filter(Boolean) : [];
|
|
1404
|
+
return required.some((r) => have.includes(r));
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
// libs/adapters/src/skills/security/webhook-replay-guard.ts
|
|
1409
|
+
import { sha256Hex as sha256Hex2 } from "@frontmcp/utils";
|
|
1410
|
+
function classifyReplayReason(reason) {
|
|
1411
|
+
if (reason.includes("not finite")) return "invalid_timestamp";
|
|
1412
|
+
if (reason.includes("too short")) return "invalid_nonce";
|
|
1413
|
+
if (reason.includes("outside")) return "outside_window";
|
|
1414
|
+
if (reason.includes("replay")) return "nonce_replay";
|
|
1415
|
+
return "other";
|
|
1416
|
+
}
|
|
1417
|
+
var DEFAULT_WINDOW_MS = 5 * 60 * 1e3;
|
|
1418
|
+
var DEFAULT_CAPACITY = 5e3;
|
|
1419
|
+
var WebhookReplayGuard = class {
|
|
1420
|
+
windowMs;
|
|
1421
|
+
capacity;
|
|
1422
|
+
seen = /* @__PURE__ */ new Map();
|
|
1423
|
+
now = Date.now;
|
|
1424
|
+
rejectsCounter;
|
|
1425
|
+
checksCounter;
|
|
1426
|
+
constructor(options = {}) {
|
|
1427
|
+
const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
|
|
1428
|
+
const capacity = options.capacity ?? DEFAULT_CAPACITY;
|
|
1429
|
+
if (!Number.isFinite(windowMs) || windowMs <= 0) {
|
|
1430
|
+
throw new RangeError("WebhookReplayGuard: windowMs must be a finite number > 0");
|
|
1431
|
+
}
|
|
1432
|
+
if (!Number.isInteger(capacity) || capacity <= 0) {
|
|
1433
|
+
throw new RangeError("WebhookReplayGuard: capacity must be a positive integer");
|
|
1434
|
+
}
|
|
1435
|
+
this.windowMs = windowMs;
|
|
1436
|
+
this.capacity = capacity;
|
|
1437
|
+
this.rejectsCounter = options.telemetry?.createCounter(
|
|
1438
|
+
"frontmcp_skills_replay_rejects_total",
|
|
1439
|
+
"Number of webhook replay-guard rejections, partitioned by reason."
|
|
1440
|
+
);
|
|
1441
|
+
this.checksCounter = options.telemetry?.createCounter(
|
|
1442
|
+
"frontmcp_skills_replay_checks_total",
|
|
1443
|
+
"Number of webhook replay-guard checks, partitioned by status (ok|error)."
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
/** For tests: override the time source. */
|
|
1447
|
+
setNowProvider(fn) {
|
|
1448
|
+
this.now = fn;
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Check that a (timestamp, nonce) pair is fresh and unseen. Returns ok=false
|
|
1452
|
+
* if the timestamp is outside the freshness window or the nonce has already
|
|
1453
|
+
* been observed within that window.
|
|
1454
|
+
*/
|
|
1455
|
+
check(args) {
|
|
1456
|
+
const { timestampMs, nonce } = args;
|
|
1457
|
+
const reject = (reason) => {
|
|
1458
|
+
this.rejectsCounter?.inc(1, { reason: classifyReplayReason(reason) });
|
|
1459
|
+
this.checksCounter?.inc(1, { status: "error" });
|
|
1460
|
+
return { ok: false, reason };
|
|
1461
|
+
};
|
|
1462
|
+
if (!Number.isFinite(timestampMs)) {
|
|
1463
|
+
return reject("timestamp not finite");
|
|
1464
|
+
}
|
|
1465
|
+
if (typeof nonce !== "string" || nonce.length < 8) {
|
|
1466
|
+
return reject("nonce too short");
|
|
1467
|
+
}
|
|
1468
|
+
const now = this.now();
|
|
1469
|
+
if (Math.abs(now - timestampMs) > this.windowMs) {
|
|
1470
|
+
return reject(`timestamp outside ${this.windowMs}ms window`);
|
|
1471
|
+
}
|
|
1472
|
+
const key = sha256Hex2(nonce);
|
|
1473
|
+
const expiresAt = this.seen.get(key);
|
|
1474
|
+
if (expiresAt !== void 0 && expiresAt >= now) {
|
|
1475
|
+
return reject("nonce replay detected within freshness window");
|
|
1476
|
+
}
|
|
1477
|
+
if (this.seen.size >= this.capacity) {
|
|
1478
|
+
this.prune();
|
|
1479
|
+
}
|
|
1480
|
+
if (this.seen.size >= this.capacity) {
|
|
1481
|
+
let victimKey;
|
|
1482
|
+
let victimExpiry = Number.POSITIVE_INFINITY;
|
|
1483
|
+
for (const [candidateKey, candidateExpiry] of this.seen) {
|
|
1484
|
+
if (candidateExpiry < victimExpiry) {
|
|
1485
|
+
victimKey = candidateKey;
|
|
1486
|
+
victimExpiry = candidateExpiry;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
if (victimKey !== void 0) this.seen.delete(victimKey);
|
|
1490
|
+
}
|
|
1491
|
+
this.seen.set(key, timestampMs + this.windowMs);
|
|
1492
|
+
this.checksCounter?.inc(1, { status: "ok" });
|
|
1493
|
+
return { ok: true };
|
|
1494
|
+
}
|
|
1495
|
+
/** Test/maintenance helper: drop expired entries proactively. */
|
|
1496
|
+
prune() {
|
|
1497
|
+
const now = this.now();
|
|
1498
|
+
let removed = 0;
|
|
1499
|
+
for (const [k, expiresAt] of this.seen) {
|
|
1500
|
+
if (expiresAt < now) {
|
|
1501
|
+
this.seen.delete(k);
|
|
1502
|
+
removed++;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
return removed;
|
|
1506
|
+
}
|
|
1507
|
+
/** Inspect: number of nonces currently tracked. */
|
|
1508
|
+
get size() {
|
|
1509
|
+
return this.seen.size;
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1513
|
+
// libs/adapters/src/skills/audit/audit-record.types.ts
|
|
1514
|
+
var SKILL_AUDIT_GENESIS_PREV_HASH = "0".repeat(64);
|
|
1515
|
+
var SKILL_AUDIT_KEYS = {
|
|
1516
|
+
/** Atomic chain head counter — incremented per append. */
|
|
1517
|
+
sequence: "audit:skills:sequence",
|
|
1518
|
+
/** Per-record key. Use `SKILL_AUDIT_KEYS.record(seq)` to build. */
|
|
1519
|
+
record: (sequence) => `audit:skills:records:${sequence}`
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
// libs/adapters/src/skills/audit/audit-chain.ts
|
|
1523
|
+
import { sha256Hex as sha256Hex3 } from "@frontmcp/utils";
|
|
1524
|
+
function canonicalizeRecordForSigning(record) {
|
|
1525
|
+
const { signature: _drop, ...rest } = record;
|
|
1526
|
+
void _drop;
|
|
1527
|
+
return canonicalize(rest);
|
|
1528
|
+
}
|
|
1529
|
+
function nextPrevHash(prev) {
|
|
1530
|
+
if (!prev) return SKILL_AUDIT_GENESIS_PREV_HASH;
|
|
1531
|
+
return sha256Hex3(canonicalizeRecordForSigning(prev));
|
|
1532
|
+
}
|
|
1533
|
+
function linkRecord(prev, partial) {
|
|
1534
|
+
return {
|
|
1535
|
+
...partial,
|
|
1536
|
+
prevHash: nextPrevHash(prev)
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
function verifyChain(records, trustedKeys, verifier) {
|
|
1540
|
+
if (records.length === 0) {
|
|
1541
|
+
return { ok: true, verified: 0 };
|
|
1542
|
+
}
|
|
1543
|
+
let prev;
|
|
1544
|
+
for (let i = 0; i < records.length; i++) {
|
|
1545
|
+
const rec = records[i];
|
|
1546
|
+
if (!rec) {
|
|
1547
|
+
return { ok: false, breakAt: i, reason: "record at index is null/undefined" };
|
|
1548
|
+
}
|
|
1549
|
+
if (prev && rec.sequence !== prev.sequence + 1) {
|
|
1550
|
+
return {
|
|
1551
|
+
ok: false,
|
|
1552
|
+
breakAt: rec.sequence,
|
|
1553
|
+
reason: `sequence gap: expected ${prev.sequence + 1}, got ${rec.sequence}`
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
const expectedPrev = nextPrevHash(prev);
|
|
1557
|
+
if (rec.prevHash !== expectedPrev) {
|
|
1558
|
+
return {
|
|
1559
|
+
ok: false,
|
|
1560
|
+
breakAt: rec.sequence,
|
|
1561
|
+
reason: `prevHash mismatch at sequence ${rec.sequence}: expected ${expectedPrev.slice(0, 12)}.. got ${rec.prevHash.slice(0, 12)}..`
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
const canonicalBytes = new TextEncoder().encode(canonicalizeRecordForSigning(rec));
|
|
1565
|
+
const ok = verifier({
|
|
1566
|
+
alg: rec.signatureAlg,
|
|
1567
|
+
keyId: rec.signatureKeyId,
|
|
1568
|
+
data: canonicalBytes,
|
|
1569
|
+
signatureBase64Url: rec.signature,
|
|
1570
|
+
trustedKeys: [...trustedKeys]
|
|
1571
|
+
});
|
|
1572
|
+
if (!ok) {
|
|
1573
|
+
return {
|
|
1574
|
+
ok: false,
|
|
1575
|
+
breakAt: rec.sequence,
|
|
1576
|
+
reason: `signature verification failed at sequence ${rec.sequence} (keyId="${rec.signatureKeyId}", alg=${rec.signatureAlg})`
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
prev = rec;
|
|
1580
|
+
}
|
|
1581
|
+
return { ok: true, verified: records.length };
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// libs/adapters/src/skills/audit/audit-signer.ts
|
|
1585
|
+
import {
|
|
1586
|
+
base64urlDecode as base64urlDecode2,
|
|
1587
|
+
base64urlEncode,
|
|
1588
|
+
hmacSha256,
|
|
1589
|
+
pemToPublicJwk as pemToPublicJwk2,
|
|
1590
|
+
rsaSignBase64Url,
|
|
1591
|
+
rsaVerifySync as rsaVerifySync2,
|
|
1592
|
+
timingSafeEqual
|
|
1593
|
+
} from "@frontmcp/utils";
|
|
1594
|
+
var Hs256AuditSigner = class {
|
|
1595
|
+
key;
|
|
1596
|
+
keyId;
|
|
1597
|
+
/**
|
|
1598
|
+
* @param secret - HMAC secret. Strings are UTF-8 encoded; pass a
|
|
1599
|
+
* Uint8Array directly when you've derived the key (e.g.
|
|
1600
|
+
* from HKDF) and want to avoid the extra encode step.
|
|
1601
|
+
* @param keyId - Stable identifier (e.g. `'audit-dev-key'`) — surfaces in
|
|
1602
|
+
* the signed record's `signatureKeyId` field.
|
|
1603
|
+
*/
|
|
1604
|
+
constructor(secret, keyId) {
|
|
1605
|
+
if (!keyId) {
|
|
1606
|
+
throw new Error("Hs256AuditSigner: keyId must be a non-empty string");
|
|
1607
|
+
}
|
|
1608
|
+
if (typeof secret === "string") {
|
|
1609
|
+
if (secret.length === 0) throw new Error("Hs256AuditSigner: secret must be non-empty");
|
|
1610
|
+
this.key = new TextEncoder().encode(secret);
|
|
1611
|
+
} else {
|
|
1612
|
+
if (secret.length === 0) throw new Error("Hs256AuditSigner: secret must be non-empty");
|
|
1613
|
+
this.key = new Uint8Array(secret);
|
|
1614
|
+
}
|
|
1615
|
+
this.keyId = keyId;
|
|
1616
|
+
}
|
|
1617
|
+
sign(record) {
|
|
1618
|
+
const canonicalBytes = new TextEncoder().encode(
|
|
1619
|
+
canonicalizeRecordForSigning({
|
|
1620
|
+
...record,
|
|
1621
|
+
// Stub the signature fields to a deterministic shape so canonicalize
|
|
1622
|
+
// sees the exact same byte layout the verifier will reconstruct.
|
|
1623
|
+
signature: "",
|
|
1624
|
+
signatureKeyId: this.keyId,
|
|
1625
|
+
signatureAlg: "HS256"
|
|
1626
|
+
})
|
|
1627
|
+
);
|
|
1628
|
+
const mac = hmacSha256(this.key, canonicalBytes);
|
|
1629
|
+
return {
|
|
1630
|
+
signature: base64urlEncode(mac),
|
|
1631
|
+
keyId: this.keyId,
|
|
1632
|
+
alg: "HS256"
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
getKeyId() {
|
|
1636
|
+
return this.keyId;
|
|
1637
|
+
}
|
|
1638
|
+
getAlg() {
|
|
1639
|
+
return "HS256";
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
var Rs256AuditSigner = class {
|
|
1643
|
+
privateJwk;
|
|
1644
|
+
keyId;
|
|
1645
|
+
constructor(privateJwk, keyId) {
|
|
1646
|
+
if (!keyId) {
|
|
1647
|
+
throw new Error("Rs256AuditSigner: keyId must be a non-empty string");
|
|
1648
|
+
}
|
|
1649
|
+
if (!privateJwk || typeof privateJwk !== "object") {
|
|
1650
|
+
throw new Error("Rs256AuditSigner: privateJwk must be a JWK object");
|
|
1651
|
+
}
|
|
1652
|
+
if (privateJwk.kty !== "RSA") {
|
|
1653
|
+
throw new Error(`Rs256AuditSigner: privateJwk.kty must be "RSA", got "${String(privateJwk.kty)}"`);
|
|
1654
|
+
}
|
|
1655
|
+
if (!privateJwk.n || !privateJwk.e || !privateJwk.d) {
|
|
1656
|
+
throw new Error("Rs256AuditSigner: privateJwk must include `n`, `e`, and `d` (RSA private key)");
|
|
1657
|
+
}
|
|
1658
|
+
this.privateJwk = { ...privateJwk };
|
|
1659
|
+
this.keyId = keyId;
|
|
1660
|
+
}
|
|
1661
|
+
sign(record) {
|
|
1662
|
+
const canonicalBytes = new TextEncoder().encode(
|
|
1663
|
+
canonicalizeRecordForSigning({
|
|
1664
|
+
...record,
|
|
1665
|
+
signature: "",
|
|
1666
|
+
signatureKeyId: this.keyId,
|
|
1667
|
+
signatureAlg: "RS256"
|
|
1668
|
+
})
|
|
1669
|
+
);
|
|
1670
|
+
const signature = rsaSignBase64Url("RS256", canonicalBytes, this.privateJwk);
|
|
1671
|
+
return {
|
|
1672
|
+
signature,
|
|
1673
|
+
keyId: this.keyId,
|
|
1674
|
+
alg: "RS256"
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
getKeyId() {
|
|
1678
|
+
return this.keyId;
|
|
1679
|
+
}
|
|
1680
|
+
getAlg() {
|
|
1681
|
+
return "RS256";
|
|
1682
|
+
}
|
|
1683
|
+
};
|
|
1684
|
+
var defaultAuditSignatureVerifier = ({
|
|
1685
|
+
alg,
|
|
1686
|
+
keyId,
|
|
1687
|
+
data,
|
|
1688
|
+
signatureBase64Url,
|
|
1689
|
+
trustedKeys
|
|
1690
|
+
}) => {
|
|
1691
|
+
const trusted = trustedKeys.find((k) => k.keyId === keyId && k.alg === alg);
|
|
1692
|
+
if (!trusted) return false;
|
|
1693
|
+
if (alg === "HS256") {
|
|
1694
|
+
if (!trusted.secret) return false;
|
|
1695
|
+
const expected = hmacSha256(trusted.secret, data);
|
|
1696
|
+
let actual;
|
|
1697
|
+
try {
|
|
1698
|
+
actual = base64urlDecode2(signatureBase64Url);
|
|
1699
|
+
} catch {
|
|
1700
|
+
return false;
|
|
1701
|
+
}
|
|
1702
|
+
if (actual.length !== expected.length) return false;
|
|
1703
|
+
return timingSafeEqual(actual, expected);
|
|
1704
|
+
}
|
|
1705
|
+
if (alg === "RS256") {
|
|
1706
|
+
if (!trusted.publicKeyPem && !trusted.publicJwk) return false;
|
|
1707
|
+
let publicJwk;
|
|
1708
|
+
try {
|
|
1709
|
+
publicJwk = trusted.publicJwk ?? pemToPublicJwk2(trusted.publicKeyPem);
|
|
1710
|
+
} catch {
|
|
1711
|
+
return false;
|
|
1712
|
+
}
|
|
1713
|
+
let sigBytes;
|
|
1714
|
+
try {
|
|
1715
|
+
sigBytes = base64urlDecode2(signatureBase64Url);
|
|
1716
|
+
} catch {
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
return rsaVerifySync2("RS256", data, publicJwk, sigBytes);
|
|
1720
|
+
}
|
|
1721
|
+
const _exhaustive = alg;
|
|
1722
|
+
void _exhaustive;
|
|
1723
|
+
return false;
|
|
1724
|
+
};
|
|
1725
|
+
|
|
1726
|
+
// libs/adapters/src/skills/audit/audit-store.ts
|
|
1727
|
+
var MemoryAuditStore = class {
|
|
1728
|
+
records = [];
|
|
1729
|
+
seq = 0;
|
|
1730
|
+
async nextSequence() {
|
|
1731
|
+
this.seq += 1;
|
|
1732
|
+
return this.seq;
|
|
1733
|
+
}
|
|
1734
|
+
async appendAtSequence(record) {
|
|
1735
|
+
if (this.records.some((r) => r.sequence === record.sequence)) {
|
|
1736
|
+
throw new Error(`MemoryAuditStore: record at sequence ${record.sequence} already exists`);
|
|
1737
|
+
}
|
|
1738
|
+
this.records.push(record);
|
|
1739
|
+
}
|
|
1740
|
+
async tail() {
|
|
1741
|
+
if (this.records.length === 0) return void 0;
|
|
1742
|
+
return this.records[this.records.length - 1];
|
|
1743
|
+
}
|
|
1744
|
+
async read(opts) {
|
|
1745
|
+
const from = opts?.from ?? 1;
|
|
1746
|
+
const limit = opts?.limit;
|
|
1747
|
+
const filtered = this.records.filter((r) => r.sequence >= from).sort((a, b) => a.sequence - b.sequence);
|
|
1748
|
+
return limit !== void 0 ? filtered.slice(0, limit) : filtered;
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
var StorageAdapterAuditStore = class {
|
|
1752
|
+
constructor(adapter, options = {}) {
|
|
1753
|
+
this.adapter = adapter;
|
|
1754
|
+
this.options = options;
|
|
1755
|
+
}
|
|
1756
|
+
get sequenceKey() {
|
|
1757
|
+
return this.options.sequenceKey ?? SKILL_AUDIT_KEYS.sequence;
|
|
1758
|
+
}
|
|
1759
|
+
recordKey(sequence) {
|
|
1760
|
+
const prefix = this.options.recordKeyPrefix;
|
|
1761
|
+
return prefix ? `${prefix}${sequence}` : SKILL_AUDIT_KEYS.record(sequence);
|
|
1762
|
+
}
|
|
1763
|
+
async nextSequence() {
|
|
1764
|
+
return this.adapter.incr(this.sequenceKey);
|
|
1765
|
+
}
|
|
1766
|
+
async appendAtSequence(record) {
|
|
1767
|
+
const key = this.recordKey(record.sequence);
|
|
1768
|
+
const existing = await this.adapter.get(key);
|
|
1769
|
+
if (existing !== null) {
|
|
1770
|
+
throw new Error(`StorageAdapterAuditStore: record at sequence ${record.sequence} already exists`);
|
|
1771
|
+
}
|
|
1772
|
+
const payload = JSON.stringify(record);
|
|
1773
|
+
await this.adapter.set(key, payload, { ifNotExists: true });
|
|
1774
|
+
const stored = await this.adapter.get(key);
|
|
1775
|
+
if (stored !== payload) {
|
|
1776
|
+
throw new Error(`StorageAdapterAuditStore: record at sequence ${record.sequence} was clobbered or not stored`);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
async tail() {
|
|
1780
|
+
const seqRaw = await this.adapter.get(this.sequenceKey);
|
|
1781
|
+
if (!seqRaw) return void 0;
|
|
1782
|
+
const seq = Number.parseInt(seqRaw, 10);
|
|
1783
|
+
if (!Number.isFinite(seq) || seq <= 0) return void 0;
|
|
1784
|
+
const maxLookback = this.options.tailLookback ?? 1024;
|
|
1785
|
+
for (let s = seq; s > 0 && s > seq - maxLookback; s--) {
|
|
1786
|
+
const raw = await this.adapter.get(this.recordKey(s));
|
|
1787
|
+
if (!raw) continue;
|
|
1788
|
+
try {
|
|
1789
|
+
return JSON.parse(raw);
|
|
1790
|
+
} catch {
|
|
1791
|
+
throw new Error(`StorageAdapterAuditStore.tail: corrupt record at sequence ${s} cannot be parsed as JSON`);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
return void 0;
|
|
1795
|
+
}
|
|
1796
|
+
async read(opts) {
|
|
1797
|
+
const seqRaw = await this.adapter.get(this.sequenceKey);
|
|
1798
|
+
if (!seqRaw) return [];
|
|
1799
|
+
const head = Number.parseInt(seqRaw, 10);
|
|
1800
|
+
if (!Number.isFinite(head) || head <= 0) return [];
|
|
1801
|
+
const from = Math.max(1, opts?.from ?? 1);
|
|
1802
|
+
const limit = opts?.limit;
|
|
1803
|
+
const out = [];
|
|
1804
|
+
for (let s = from; s <= head; s++) {
|
|
1805
|
+
const raw = await this.adapter.get(this.recordKey(s));
|
|
1806
|
+
if (!raw) continue;
|
|
1807
|
+
try {
|
|
1808
|
+
const parsed = JSON.parse(raw);
|
|
1809
|
+
out.push(parsed);
|
|
1810
|
+
if (limit !== void 0 && out.length >= limit) break;
|
|
1811
|
+
} catch {
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
return out;
|
|
1815
|
+
}
|
|
1816
|
+
};
|
|
1817
|
+
|
|
1818
|
+
// libs/adapters/src/skills/audit/audit-writer.ts
|
|
1819
|
+
import { bytesToHex, hmacSha256 as hmacSha2562, randomUUID, sha256Hex as sha256Hex4 } from "@frontmcp/utils";
|
|
1820
|
+
var SKILL_AUDIT_ERROR_MESSAGE_MAX = 500;
|
|
1821
|
+
var SKILL_AUDIT_QUEUE_MAX = 1e3;
|
|
1822
|
+
var SkillAuditWriter = class {
|
|
1823
|
+
constructor(store, signer, logger, metrics, options = {}) {
|
|
1824
|
+
this.store = store;
|
|
1825
|
+
this.signer = signer;
|
|
1826
|
+
this.logger = logger;
|
|
1827
|
+
this.metrics = metrics;
|
|
1828
|
+
this.subjectMode = options.subjectMode ?? "hash";
|
|
1829
|
+
this.subjectHashSecret = options.subjectHashSecret ?? this.deriveDefaultSubjectSecret();
|
|
1830
|
+
this.maxQueueDepth = options.maxQueueDepth ?? SKILL_AUDIT_QUEUE_MAX;
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Single-flight queue: every append() chains onto this promise so the
|
|
1834
|
+
* read-tail-then-append-with-prevHash sequence stays atomic per process.
|
|
1835
|
+
* This is the mate to the storage adapter's `incr` for cross-pod safety.
|
|
1836
|
+
*/
|
|
1837
|
+
chainHeadLock = Promise.resolve();
|
|
1838
|
+
/** Current in-flight queue depth (records awaiting the chain head lock). */
|
|
1839
|
+
queueDepth = 0;
|
|
1840
|
+
subjectMode;
|
|
1841
|
+
subjectHashSecret;
|
|
1842
|
+
maxQueueDepth;
|
|
1843
|
+
/**
|
|
1844
|
+
* Phase 2 in the tool: authority check just passed. We log the inputHash
|
|
1845
|
+
* here so even invocations that fail at the network layer still leave a
|
|
1846
|
+
* forensic breadcrumb tying the input to the policy decision.
|
|
1847
|
+
*/
|
|
1848
|
+
writeAuthorityPass(ctx) {
|
|
1849
|
+
return this.write({
|
|
1850
|
+
phase: "authority-check-pass",
|
|
1851
|
+
ctx
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* Phase 2 in the tool: authority check denied. Writing this record is
|
|
1856
|
+
* security-relevant — without it, denied invocations would not appear in
|
|
1857
|
+
* the audit log at all, and the most security-relevant events (someone
|
|
1858
|
+
* trying to invoke an action they're not authorized for) would silently
|
|
1859
|
+
* disappear. The tool MUST call this before returning the denial envelope.
|
|
1860
|
+
*/
|
|
1861
|
+
writeAuthorityFail(ctx, extras) {
|
|
1862
|
+
return this.write({
|
|
1863
|
+
phase: "authority-check-fail",
|
|
1864
|
+
ctx,
|
|
1865
|
+
// Re-use the errorMessage field for the denial reason so the on-disk
|
|
1866
|
+
// shape stays identical to other failure phases.
|
|
1867
|
+
error: extras.reason
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
/** Phase 4 in the tool, success path. */
|
|
1871
|
+
writeHttpCallSuccess(ctx, extras) {
|
|
1872
|
+
return this.write({
|
|
1873
|
+
phase: "http-call-success",
|
|
1874
|
+
ctx,
|
|
1875
|
+
status: extras.status,
|
|
1876
|
+
output: extras.output
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
/** Phase 4 in the tool, failure path. */
|
|
1880
|
+
writeHttpCallFailure(ctx, extras) {
|
|
1881
|
+
return this.write({
|
|
1882
|
+
phase: "http-call-failure",
|
|
1883
|
+
ctx,
|
|
1884
|
+
status: extras.status,
|
|
1885
|
+
error: extras.error
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
/**
|
|
1889
|
+
* Wait for all pending writes to complete. Useful for tests and graceful
|
|
1890
|
+
* shutdown — the chain head lock resolves once every queued write has
|
|
1891
|
+
* finished (success or swallowed-failure). Never throws.
|
|
1892
|
+
*/
|
|
1893
|
+
async flush() {
|
|
1894
|
+
while (this.queueDepth > 0) {
|
|
1895
|
+
await this.chainHeadLock;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
// ─── Internal ──────────────────────────────────────────────────────────
|
|
1899
|
+
write(args) {
|
|
1900
|
+
if (this.queueDepth >= this.maxQueueDepth) {
|
|
1901
|
+
this.logger.warn(
|
|
1902
|
+
`[skill-audit] queue overflow (${this.queueDepth}/${this.maxQueueDepth}); dropping ${args.phase} record. Audit backend likely unhealthy.`
|
|
1903
|
+
);
|
|
1904
|
+
this.metrics?.incrementDropped?.("queue-overflow");
|
|
1905
|
+
return Promise.resolve();
|
|
1906
|
+
}
|
|
1907
|
+
this.queueDepth += 1;
|
|
1908
|
+
const next = this.chainHeadLock.then(() => this.doWrite(args));
|
|
1909
|
+
this.chainHeadLock = next.catch(() => void 0).finally(() => {
|
|
1910
|
+
this.queueDepth -= 1;
|
|
1911
|
+
});
|
|
1912
|
+
return next.catch(() => void 0);
|
|
1913
|
+
}
|
|
1914
|
+
async doWrite(args) {
|
|
1915
|
+
const { phase, ctx, status, output, error } = args;
|
|
1916
|
+
let prev;
|
|
1917
|
+
try {
|
|
1918
|
+
prev = await this.store.tail();
|
|
1919
|
+
} catch (e) {
|
|
1920
|
+
this.logger.warn(`[skill-audit] failed to load chain tail: ${e.message}`);
|
|
1921
|
+
this.metrics?.incrementWriteFailure("append");
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
let inputHash;
|
|
1925
|
+
try {
|
|
1926
|
+
inputHash = sha256Hex4(canonicalize(ctx.input ?? null));
|
|
1927
|
+
} catch (e) {
|
|
1928
|
+
this.logger.warn(`[skill-audit] failed to canonicalize input: ${e.message}`);
|
|
1929
|
+
this.metrics?.incrementWriteFailure("unexpected");
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
let sequence;
|
|
1933
|
+
try {
|
|
1934
|
+
sequence = await this.store.nextSequence();
|
|
1935
|
+
} catch (e) {
|
|
1936
|
+
this.logger.warn(`[skill-audit] failed to allocate sequence: ${e.message}`);
|
|
1937
|
+
this.metrics?.incrementWriteFailure("append");
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
const partial = {
|
|
1941
|
+
id: randomUUID(),
|
|
1942
|
+
sequence,
|
|
1943
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1944
|
+
subject: this.applySubjectMode(ctx.subject || "anonymous"),
|
|
1945
|
+
skillId: ctx.skillId,
|
|
1946
|
+
actionId: ctx.actionId,
|
|
1947
|
+
bundleId: ctx.bundleId,
|
|
1948
|
+
bundleVersion: ctx.bundleVersion,
|
|
1949
|
+
phase,
|
|
1950
|
+
inputHash,
|
|
1951
|
+
...status !== void 0 ? { status } : {},
|
|
1952
|
+
...phase === "http-call-success" ? { outputHash: this.safeHash(output) } : {},
|
|
1953
|
+
...phase === "http-call-failure" || phase === "authority-check-fail" ? { errorMessage: this.formatError(error) } : {}
|
|
1954
|
+
};
|
|
1955
|
+
const linked = linkRecord(prev, partial);
|
|
1956
|
+
let signed;
|
|
1957
|
+
try {
|
|
1958
|
+
const sig = this.signer.sign(linked);
|
|
1959
|
+
signed = {
|
|
1960
|
+
...linked,
|
|
1961
|
+
signature: sig.signature,
|
|
1962
|
+
signatureKeyId: sig.keyId,
|
|
1963
|
+
signatureAlg: sig.alg
|
|
1964
|
+
};
|
|
1965
|
+
} catch (e) {
|
|
1966
|
+
this.logger.warn(`[skill-audit] failed to sign record at seq=${sequence}: ${e.message}`);
|
|
1967
|
+
this.metrics?.incrementWriteFailure("sign");
|
|
1968
|
+
await this.tryDecrementSequence(sequence);
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
try {
|
|
1972
|
+
await this.store.appendAtSequence(signed);
|
|
1973
|
+
this.logger.debug?.(`[skill-audit] appended seq=${sequence} phase=${phase}`);
|
|
1974
|
+
} catch (e) {
|
|
1975
|
+
this.logger.warn(`[skill-audit] failed to append record at seq=${sequence}: ${e.message}`);
|
|
1976
|
+
this.metrics?.incrementWriteFailure("append");
|
|
1977
|
+
await this.tryDecrementSequence(sequence);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* Best-effort sequence rollback. Storage adapters that support `decr` will
|
|
1982
|
+
* close the gap; ones that don't are no-ops. We never throw out of this
|
|
1983
|
+
* helper — a failed rollback is strictly worse than a permanent gap.
|
|
1984
|
+
*
|
|
1985
|
+
* Detection is duck-typed (we look at `.adapter.decr` and `.options.sequenceKey`)
|
|
1986
|
+
* because the SkillAuditStore interface intentionally doesn't expose
|
|
1987
|
+
* sequence-mutation methods — those are storage-specific implementation
|
|
1988
|
+
* detail, and forcing every implementation to expose them would push the
|
|
1989
|
+
* complexity onto every adapter even when the underlying store can't
|
|
1990
|
+
* support transactional rollback.
|
|
1991
|
+
*/
|
|
1992
|
+
async tryDecrementSequence(allocated) {
|
|
1993
|
+
const storeAny = this.store;
|
|
1994
|
+
if (!storeAny.adapter || typeof storeAny.adapter.decr !== "function") return;
|
|
1995
|
+
const sequenceKey = storeAny.options?.sequenceKey ?? "audit:skills:sequence";
|
|
1996
|
+
try {
|
|
1997
|
+
await storeAny.adapter.decr(sequenceKey);
|
|
1998
|
+
this.logger.debug?.(`[skill-audit] rolled back unused sequence ${allocated}`);
|
|
1999
|
+
} catch {
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
/** Apply the configured subject-mode redaction. */
|
|
2003
|
+
applySubjectMode(subject) {
|
|
2004
|
+
if (this.subjectMode === "plain") return subject;
|
|
2005
|
+
if (this.subjectMode === "omit") return "redacted";
|
|
2006
|
+
const mac = hmacSha2562(this.subjectHashSecret, new TextEncoder().encode(subject));
|
|
2007
|
+
const hex = bytesToHex(mac);
|
|
2008
|
+
return `hashed:${hex.slice(0, 32)}`;
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* Default subject-hash secret derivation when the host doesn't supply one.
|
|
2012
|
+
* Returns a deterministic 32-byte key seeded from the signer's keyId so
|
|
2013
|
+
* two SkillAuditWriter instances configured against the same signer
|
|
2014
|
+
* produce stable subject hashes — useful for joining records across
|
|
2015
|
+
* writer reconfigurations within the same logical deployment. NOT a
|
|
2016
|
+
* cryptographic key in its own right — the host SHOULD override via
|
|
2017
|
+
* constructor options for production deployments.
|
|
2018
|
+
*/
|
|
2019
|
+
deriveDefaultSubjectSecret() {
|
|
2020
|
+
const key = new Uint8Array(32);
|
|
2021
|
+
const seed = new TextEncoder().encode(`frontmcp:audit:subject:${this.signer.getKeyId()}`);
|
|
2022
|
+
for (let i = 0; i < key.length; i++) {
|
|
2023
|
+
key[i] = seed[i % seed.length] ?? 0;
|
|
2024
|
+
}
|
|
2025
|
+
return key;
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Hash arbitrary output. Falls back to a sentinel if canonicalization
|
|
2029
|
+
* fails (circular references, non-serializable values) — the verifier
|
|
2030
|
+
* will treat it as a hash like any other.
|
|
2031
|
+
*/
|
|
2032
|
+
safeHash(value) {
|
|
2033
|
+
try {
|
|
2034
|
+
return sha256Hex4(canonicalize(value ?? null));
|
|
2035
|
+
} catch {
|
|
2036
|
+
return sha256Hex4("audit:non-canonicalizable-output");
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
/** Truncate + sanitize an error to fit in the record. */
|
|
2040
|
+
formatError(error) {
|
|
2041
|
+
let raw;
|
|
2042
|
+
if (error instanceof Error) {
|
|
2043
|
+
raw = error.message;
|
|
2044
|
+
} else if (typeof error === "string") {
|
|
2045
|
+
raw = error;
|
|
2046
|
+
} else if (error === void 0 || error === null) {
|
|
2047
|
+
raw = "";
|
|
2048
|
+
} else {
|
|
2049
|
+
try {
|
|
2050
|
+
raw = JSON.stringify(error);
|
|
2051
|
+
} catch {
|
|
2052
|
+
raw = String(error);
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
if (raw.length > SKILL_AUDIT_ERROR_MESSAGE_MAX) {
|
|
2056
|
+
return raw.slice(0, SKILL_AUDIT_ERROR_MESSAGE_MAX) + "...";
|
|
2057
|
+
}
|
|
2058
|
+
return raw;
|
|
2059
|
+
}
|
|
2060
|
+
};
|
|
2061
|
+
|
|
2062
|
+
// libs/adapters/src/skills/audit/audit-config.ts
|
|
2063
|
+
var SkillAuditWriterToken = /* @__PURE__ */ Symbol.for(
|
|
2064
|
+
"frontmcp:SKILL_AUDIT_WRITER"
|
|
2065
|
+
);
|
|
2066
|
+
|
|
2067
|
+
// libs/adapters/src/skills/index.ts
|
|
2068
|
+
import { hasSkillAuditFactory, setSkillAuditFactory } from "@frontmcp/sdk";
|
|
2069
|
+
export {
|
|
2070
|
+
BundlePinnedError,
|
|
2071
|
+
BundlePushJwtVerifier,
|
|
2072
|
+
BundleStore,
|
|
2073
|
+
FilesystemSkillsSource,
|
|
2074
|
+
Hs256AuditSigner,
|
|
2075
|
+
MemoryAuditStore,
|
|
2076
|
+
NpmSource,
|
|
2077
|
+
OverlayParseError,
|
|
2078
|
+
Rs256AuditSigner,
|
|
2079
|
+
SKILL_AUDIT_ERROR_MESSAGE_MAX,
|
|
2080
|
+
SKILL_AUDIT_GENESIS_PREV_HASH,
|
|
2081
|
+
SKILL_AUDIT_KEYS,
|
|
2082
|
+
SKILL_AUDIT_QUEUE_MAX,
|
|
2083
|
+
SaasPullSource,
|
|
2084
|
+
SkillAuditWriter,
|
|
2085
|
+
SkillAuditWriterToken,
|
|
2086
|
+
SkillDependencyCycleError,
|
|
2087
|
+
SkillDependencyInvariantError,
|
|
2088
|
+
SkillDependencyMissingError,
|
|
2089
|
+
StaticSource,
|
|
2090
|
+
StorageAdapterAuditStore,
|
|
2091
|
+
WebhookReplayGuard,
|
|
2092
|
+
bundleDigest,
|
|
2093
|
+
bundleSkillToActions,
|
|
2094
|
+
bundleSourceSchema,
|
|
2095
|
+
canonicalize,
|
|
2096
|
+
canonicalizeRecordForSigning,
|
|
2097
|
+
createBundleSource,
|
|
2098
|
+
crossValidate,
|
|
2099
|
+
defaultAuditSignatureVerifier,
|
|
2100
|
+
diffBundles,
|
|
2101
|
+
formatDiffSummary,
|
|
2102
|
+
hasSkillAuditFactory,
|
|
2103
|
+
linkRecord,
|
|
2104
|
+
loadFilesystemSkill,
|
|
2105
|
+
nextPrevHash,
|
|
2106
|
+
npmSourceSchema,
|
|
2107
|
+
parseOverlay,
|
|
2108
|
+
parseSkillFrontmatter,
|
|
2109
|
+
resolveSkillLoadOrder,
|
|
2110
|
+
resolvedBundleSchema,
|
|
2111
|
+
saasSourceSchema,
|
|
2112
|
+
setSkillAuditFactory,
|
|
2113
|
+
signatureKeySchema,
|
|
2114
|
+
staticSourceSchema,
|
|
2115
|
+
verifyBundleSignature,
|
|
2116
|
+
verifyChain
|
|
2117
|
+
};
|