@promptev/context-engine 0.0.0 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -5
- package/dist/cli.js +1896 -552
- package/dist/cli.js.map +1 -1
- package/dist/{config-Bl9U789m.d.cts → config-BODDdXJ7.d.ts} +75 -16
- package/dist/{config-Bt9bUQqU.d.ts → config-C5RZ00W6.d.cts} +75 -16
- package/dist/express.cjs +925 -151
- package/dist/express.cjs.map +1 -1
- package/dist/express.d.cts +11 -4
- package/dist/express.d.ts +11 -4
- package/dist/express.js +926 -152
- package/dist/express.js.map +1 -1
- package/dist/fastify.cjs +923 -151
- package/dist/fastify.cjs.map +1 -1
- package/dist/fastify.d.cts +8 -4
- package/dist/fastify.d.ts +8 -4
- package/dist/fastify.js +924 -152
- package/dist/fastify.js.map +1 -1
- package/dist/{governance-XIScatRO.d.ts → governance-BLPK7NMe.d.ts} +8 -2
- package/dist/{governance-BDkcv4qZ.d.cts → governance-P9pRb4Ol.d.cts} +8 -2
- package/dist/graph/index.cjs +171 -59
- package/dist/graph/index.cjs.map +1 -1
- package/dist/graph/index.d.cts +5 -3
- package/dist/graph/index.d.ts +5 -3
- package/dist/graph/index.js +171 -59
- package/dist/graph/index.js.map +1 -1
- package/dist/hono.cjs +923 -151
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +8 -4
- package/dist/hono.d.ts +8 -4
- package/dist/hono.js +924 -152
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +2413 -1052
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +125 -107
- package/dist/index.d.ts +125 -107
- package/dist/index.js +2412 -1050
- package/dist/index.js.map +1 -1
- package/dist/mcp.cjs +100 -14
- package/dist/mcp.cjs.map +1 -1
- package/dist/mcp.d.cts +5 -0
- package/dist/mcp.d.ts +5 -0
- package/dist/mcp.js +100 -14
- package/dist/mcp.js.map +1 -1
- package/dist/migrations/sql/0003_tools.sql +2 -0
- package/dist/migrations/sql/0004_acl_indexes.sql +23 -2
- package/dist/{redaction-BmDSWJ7h.d.cts → redaction-BqD_DEUQ.d.cts} +22 -1
- package/dist/{redaction-BmDSWJ7h.d.ts → redaction-BqD_DEUQ.d.ts} +22 -1
- package/dist/redaction-presidio.d.cts +1 -1
- package/dist/redaction-presidio.d.ts +1 -1
- package/dist/{router-CrxZ2y_Z.d.ts → router-CiFwC-EN.d.cts} +17 -2
- package/dist/{router-OPgSoYAB.d.cts → router-D8gBzwLd.d.ts} +17 -2
- package/dist/skills/context-engine/SKILL.md +5 -1
- package/dist/storage-CJrKgJeJ.d.ts +167 -0
- package/dist/storage-Dvpq2xAC.d.cts +167 -0
- package/package.json +61 -23
- package/src/migrations/sql/0003_tools.sql +2 -0
- package/src/migrations/sql/0004_acl_indexes.sql +23 -2
- package/src/skills/context-engine/SKILL.md +5 -1
- package/dist/embeddings-B-jZ42mk.d.cts +0 -67
- package/dist/embeddings-DaSdAZN3.d.ts +0 -67
package/dist/express.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createRequire } from 'module';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { randomBytes, createHash, randomUUID,
|
|
3
|
+
import { randomBytes, createHash, randomUUID, createDecipheriv, createCipheriv, createHmac } from 'crypto';
|
|
4
|
+
import { promises } from 'dns';
|
|
5
|
+
import { isIP } from 'net';
|
|
4
6
|
|
|
5
7
|
var __defProp = Object.defineProperty;
|
|
6
8
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
@@ -58,6 +60,9 @@ __export(db_exports, {
|
|
|
58
60
|
function stripSqlNoise(sql) {
|
|
59
61
|
return sql.replace(SQL_NOISE_RE, (m) => " ".repeat(m.length));
|
|
60
62
|
}
|
|
63
|
+
function stripSqlNoiseBackslash(sql) {
|
|
64
|
+
return sql.replace(SQL_NOISE_BACKSLASH_RE, (m) => " ".repeat(m.length));
|
|
65
|
+
}
|
|
61
66
|
function hasKeyword(sqlUpper, keyword) {
|
|
62
67
|
return new RegExp(`\\b${keyword}\\b`).test(sqlUpper);
|
|
63
68
|
}
|
|
@@ -186,24 +191,28 @@ async function executeQueryAsync(config, sql, maxRows, accessModeRaw) {
|
|
|
186
191
|
`Invalid access_mode ${JSON.stringify(accessModeRaw)}; must be one of ${ALLOWED_ACCESS_MODES}`
|
|
187
192
|
);
|
|
188
193
|
}
|
|
189
|
-
const
|
|
194
|
+
const sqlVariants = [
|
|
195
|
+
stripSqlNoise(sql).trim().toUpperCase(),
|
|
196
|
+
stripSqlNoiseBackslash(sql).trim().toUpperCase()
|
|
197
|
+
];
|
|
198
|
+
const sqlUpper = sqlVariants[0];
|
|
190
199
|
for (const keyword of ALWAYS_BLOCKED) {
|
|
191
|
-
if (hasKeyword(
|
|
200
|
+
if ([...sqlVariants, sql.toUpperCase()].some((v) => hasKeyword(v, keyword))) {
|
|
192
201
|
return { success: false, error: `${keyword} queries are not allowed` };
|
|
193
202
|
}
|
|
194
203
|
}
|
|
195
204
|
if (accessMode === "readonly") {
|
|
196
|
-
if (!
|
|
205
|
+
if (!sqlVariants.every((v) => v.startsWith("SELECT") || v.startsWith("WITH"))) {
|
|
197
206
|
return { success: false, error: "Only SELECT queries are allowed in read-only mode" };
|
|
198
207
|
}
|
|
199
208
|
for (const keyword of READONLY_BLOCKED) {
|
|
200
|
-
if (hasKeyword(
|
|
209
|
+
if (sqlVariants.some((v) => hasKeyword(v, keyword))) {
|
|
201
210
|
return { success: false, error: `${keyword} queries are not allowed in read-only mode` };
|
|
202
211
|
}
|
|
203
212
|
}
|
|
204
213
|
} else if (accessMode === "readwrite") {
|
|
205
214
|
for (const keyword of READWRITE_BLOCKED) {
|
|
206
|
-
if (hasKeyword(
|
|
215
|
+
if (sqlVariants.some((v) => hasKeyword(v, keyword))) {
|
|
207
216
|
return { success: false, error: `${keyword} queries are not allowed in read-write mode` };
|
|
208
217
|
}
|
|
209
218
|
}
|
|
@@ -337,7 +346,7 @@ async function getSchemaText(config, selectedTables) {
|
|
|
337
346
|
}
|
|
338
347
|
return rowsToText(schemaRows);
|
|
339
348
|
}
|
|
340
|
-
var require2, MAX_ROWS, CONNECT_TIMEOUT, QUERY_TIMEOUT_MS, ALWAYS_BLOCKED, READONLY_BLOCKED, READWRITE_BLOCKED, ALLOWED_ACCESS_MODES, SQL_NOISE_RE;
|
|
349
|
+
var require2, MAX_ROWS, CONNECT_TIMEOUT, QUERY_TIMEOUT_MS, ALWAYS_BLOCKED, READONLY_BLOCKED, READWRITE_BLOCKED, ALLOWED_ACCESS_MODES, SQL_NOISE_RE, SQL_NOISE_BACKSLASH_RE;
|
|
341
350
|
var init_db = __esm({
|
|
342
351
|
"src/tools/executors/db.ts"() {
|
|
343
352
|
init_errors();
|
|
@@ -346,10 +355,22 @@ var init_db = __esm({
|
|
|
346
355
|
CONNECT_TIMEOUT = 10;
|
|
347
356
|
QUERY_TIMEOUT_MS = 3e4;
|
|
348
357
|
ALWAYS_BLOCKED = ["GRANT", "REVOKE"];
|
|
349
|
-
READONLY_BLOCKED = [
|
|
350
|
-
|
|
358
|
+
READONLY_BLOCKED = [
|
|
359
|
+
"INSERT",
|
|
360
|
+
"UPDATE",
|
|
361
|
+
"DELETE",
|
|
362
|
+
"MERGE",
|
|
363
|
+
"DROP",
|
|
364
|
+
"ALTER",
|
|
365
|
+
"CREATE",
|
|
366
|
+
"TRUNCATE",
|
|
367
|
+
"DO",
|
|
368
|
+
"CALL"
|
|
369
|
+
];
|
|
370
|
+
READWRITE_BLOCKED = ["DELETE", "DROP", "TRUNCATE", "DO", "CALL"];
|
|
351
371
|
ALLOWED_ACCESS_MODES = ["readonly", "readwrite", "full"];
|
|
352
|
-
SQL_NOISE_RE =
|
|
372
|
+
SQL_NOISE_RE = /\b[eE]'(?:[^'\\]|\\[\s\S]|'')*'|'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
|
|
373
|
+
SQL_NOISE_BACKSLASH_RE = /'(?:[^'\\]|\\[\s\S]|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
|
|
353
374
|
}
|
|
354
375
|
});
|
|
355
376
|
|
|
@@ -357,16 +378,7 @@ var init_db = __esm({
|
|
|
357
378
|
init_errors();
|
|
358
379
|
|
|
359
380
|
// src/sentinels.ts
|
|
360
|
-
var
|
|
361
|
-
[Symbol.toStringTag] = "TRUSTED";
|
|
362
|
-
toString() {
|
|
363
|
-
return "TRUSTED";
|
|
364
|
-
}
|
|
365
|
-
valueOf() {
|
|
366
|
-
return true;
|
|
367
|
-
}
|
|
368
|
-
};
|
|
369
|
-
var TRUSTED = Object.freeze(new TrustedSentinel());
|
|
381
|
+
var TRUSTED = /* @__PURE__ */ Symbol.for("context_engine.TRUSTED");
|
|
370
382
|
|
|
371
383
|
// src/routing-core.ts
|
|
372
384
|
var HandlerError = class extends Error {
|
|
@@ -400,6 +412,10 @@ var documentPatchSchema = z.object({
|
|
|
400
412
|
var searchRequestSchema = z.object({
|
|
401
413
|
query: z.string(),
|
|
402
414
|
source_ids: z.array(z.string()).nullable().optional(),
|
|
415
|
+
// Narrows WITHIN a source and INTERSECTS with source_ids — it can only
|
|
416
|
+
// shrink the result set (the ACL predicate still applies in the same SQL
|
|
417
|
+
// conjunction), so exposing it needs no authorizeAcl-style grant check.
|
|
418
|
+
document_ids: z.array(z.string()).nullable().optional(),
|
|
403
419
|
top_k: z.number().int().optional().default(10),
|
|
404
420
|
mode: z.enum(["hybrid", "graph"]).optional().default("hybrid"),
|
|
405
421
|
compress_to_tokens: z.number().int().nullable().optional()
|
|
@@ -428,17 +444,23 @@ function formStr(value) {
|
|
|
428
444
|
return value === void 0 || value === null || value === "" ? null : String(value);
|
|
429
445
|
}
|
|
430
446
|
function resolveRequestPrincipals(value, opts) {
|
|
431
|
-
if (value === TRUSTED) return
|
|
447
|
+
if (value === TRUSTED) return TRUSTED;
|
|
432
448
|
if (value == null) {
|
|
433
449
|
throw new HandlerError(
|
|
434
450
|
500,
|
|
435
451
|
`${opts.surface}: the \`principals\` dependency returned null, which means TRUSTED CALLER \u2014 it disables ACL filtering and skips the check that stops a caller filing documents under groups it does not hold. This mount is misconfigured: return [] for an unauthenticated caller, or pass TRUSTED explicitly (from @promptev/context-engine) if the surface really is trusted.`
|
|
436
452
|
);
|
|
437
453
|
}
|
|
454
|
+
if (!Array.isArray(value) || value.some((p) => typeof p !== "string")) {
|
|
455
|
+
throw new HandlerError(
|
|
456
|
+
500,
|
|
457
|
+
`${opts.surface}: the \`principals\` dependency must return a list of principal strings, [] for an anonymous caller, or TRUSTED \u2014 got ${typeof value}.`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
438
460
|
return value;
|
|
439
461
|
}
|
|
440
462
|
function authorizeAcl(requested, principals) {
|
|
441
|
-
if (requested == null || principals == null) return;
|
|
463
|
+
if (requested == null || principals == null || principals === TRUSTED) return;
|
|
442
464
|
if (!Array.isArray(requested)) {
|
|
443
465
|
throw new HandlerError(422, "acl must be a list of principal strings");
|
|
444
466
|
}
|
|
@@ -587,9 +609,11 @@ async function handleSearch(engine, payload, principals) {
|
|
|
587
609
|
const parsed = searchRequestSchema.safeParse(payload);
|
|
588
610
|
if (!parsed.success) throw zodError(parsed.error);
|
|
589
611
|
const body = parsed.data;
|
|
612
|
+
for (const did of body.document_ids ?? []) requireValidUuid(did);
|
|
590
613
|
try {
|
|
591
614
|
const result = await engine.search(body.query, {
|
|
592
615
|
sourceIds: body.source_ids,
|
|
616
|
+
documentIds: body.document_ids,
|
|
593
617
|
principals: resolved,
|
|
594
618
|
topK: body.top_k,
|
|
595
619
|
mode: body.mode,
|
|
@@ -613,7 +637,7 @@ init_errors();
|
|
|
613
637
|
|
|
614
638
|
// src/tools/acl.ts
|
|
615
639
|
function aclVisible(acl, principals) {
|
|
616
|
-
if (principals === null) return true;
|
|
640
|
+
if (principals === null || principals === TRUSTED) return true;
|
|
617
641
|
if (acl == null) return true;
|
|
618
642
|
if (!acl.length) return false;
|
|
619
643
|
const held = new Set(principals ?? []);
|
|
@@ -678,17 +702,24 @@ function rowToRecord(row) {
|
|
|
678
702
|
toolName: String(row.tool_name),
|
|
679
703
|
toolArgsFrozen: row.tool_args_frozen ?? {},
|
|
680
704
|
sourceId: row.source_id ?? null,
|
|
705
|
+
approvalScope: row.approval_scope ?? null,
|
|
681
706
|
principals: row.principals,
|
|
682
707
|
status: String(row.status),
|
|
683
708
|
approver: row.approver ?? null,
|
|
684
709
|
approverMeta: row.approver_meta ?? null,
|
|
685
|
-
expiresAt:
|
|
686
|
-
resolvedAt:
|
|
687
|
-
createdAt:
|
|
710
|
+
expiresAt: toDate(row.expires_at),
|
|
711
|
+
resolvedAt: toDate(row.resolved_at),
|
|
712
|
+
createdAt: toDate(row.created_at)
|
|
688
713
|
};
|
|
689
714
|
}
|
|
715
|
+
function toDate(value) {
|
|
716
|
+
if (value == null) return null;
|
|
717
|
+
if (value instanceof Date) return value;
|
|
718
|
+
return new Date(String(value));
|
|
719
|
+
}
|
|
690
720
|
function visibilitySql(principals, paramIndex) {
|
|
691
|
-
if (principals === null) return { sql: "TRUE", params: [] };
|
|
721
|
+
if (principals === null || principals === TRUSTED) return { sql: "TRUE", params: [] };
|
|
722
|
+
principals = principals;
|
|
692
723
|
if (!principals.length) {
|
|
693
724
|
return { sql: `(principals IS NULL OR principals = 'null'::jsonb)`, params: [] };
|
|
694
725
|
}
|
|
@@ -697,41 +728,95 @@ function visibilitySql(principals, paramIndex) {
|
|
|
697
728
|
params: [principals]
|
|
698
729
|
};
|
|
699
730
|
}
|
|
700
|
-
|
|
731
|
+
function claimVisibilitySql(principals, paramIndex) {
|
|
732
|
+
if (principals === null || principals === TRUSTED) return { sql: "TRUE", params: [] };
|
|
733
|
+
const noWall = `principals IS NULL OR principals = 'null'::jsonb OR principals = '[]'::jsonb`;
|
|
734
|
+
principals = principals;
|
|
735
|
+
if (!principals.length) return { sql: `(${noWall})`, params: [] };
|
|
736
|
+
return { sql: `(${noWall} OR principals ?| $${paramIndex}::text[])`, params: [principals] };
|
|
737
|
+
}
|
|
738
|
+
function validateApprovalScope(approvalScope) {
|
|
739
|
+
if (approvalScope === null || approvalScope === void 0) return null;
|
|
740
|
+
if (typeof approvalScope !== "string") {
|
|
741
|
+
throw new TypeError(`approvalScope must be a non-empty string or null, got ${typeof approvalScope}`);
|
|
742
|
+
}
|
|
743
|
+
if (!approvalScope) throw new Error("approvalScope must be a non-empty string or null, got ''");
|
|
744
|
+
return approvalScope;
|
|
745
|
+
}
|
|
746
|
+
function pendingRow(opts, approvalScope) {
|
|
701
747
|
const policy = opts.policy ?? {};
|
|
702
748
|
const frozen = structuredClone(opts.args);
|
|
703
749
|
const createdAt = opts.now ?? /* @__PURE__ */ new Date();
|
|
704
750
|
const timeout = Number(policy.timeout_minutes ?? DEFAULT_TIMEOUT_MINUTES);
|
|
705
751
|
const expiresAt = new Date(createdAt.getTime() + timeout * 6e4);
|
|
752
|
+
const principals = opts.principals === void 0 || opts.principals === TRUSTED ? null : opts.principals;
|
|
753
|
+
return { frozen, createdAt, expiresAt, principals, approvalScope };
|
|
754
|
+
}
|
|
755
|
+
async function insertPending(engine, opts, row) {
|
|
706
756
|
const id = randomUUID();
|
|
707
757
|
await engine.pool.query(
|
|
708
758
|
`INSERT INTO context_engine_tool_approvals
|
|
709
|
-
(id, tool_name, tool_args_frozen, source_id, principals, status, expires_at, created_at)
|
|
710
|
-
VALUES ($1,$2,$3::jsonb,$4,$5::jsonb,'pending',$
|
|
759
|
+
(id, tool_name, tool_args_frozen, source_id, approval_scope, principals, status, expires_at, created_at)
|
|
760
|
+
VALUES ($1,$2,$3::jsonb,$4,$5,$6::jsonb,'pending',$7,$8)`,
|
|
711
761
|
[
|
|
712
762
|
id,
|
|
713
763
|
opts.toolName,
|
|
714
|
-
JSON.stringify(frozen),
|
|
764
|
+
JSON.stringify(row.frozen),
|
|
715
765
|
opts.sourceId ?? null,
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
766
|
+
row.approvalScope,
|
|
767
|
+
row.principals === null ? null : JSON.stringify(row.principals),
|
|
768
|
+
row.expiresAt,
|
|
769
|
+
row.createdAt
|
|
719
770
|
]
|
|
720
771
|
);
|
|
721
772
|
return {
|
|
722
773
|
id,
|
|
723
774
|
toolName: opts.toolName,
|
|
724
|
-
toolArgsFrozen: frozen,
|
|
775
|
+
toolArgsFrozen: row.frozen,
|
|
725
776
|
sourceId: opts.sourceId ?? null,
|
|
726
|
-
|
|
777
|
+
approvalScope: row.approvalScope,
|
|
778
|
+
principals: row.principals,
|
|
727
779
|
status: "pending",
|
|
728
780
|
approver: null,
|
|
729
781
|
approverMeta: null,
|
|
730
|
-
expiresAt,
|
|
782
|
+
expiresAt: row.expiresAt,
|
|
731
783
|
resolvedAt: null,
|
|
732
|
-
createdAt
|
|
784
|
+
createdAt: row.createdAt
|
|
733
785
|
};
|
|
734
786
|
}
|
|
787
|
+
async function createPending(engine, opts) {
|
|
788
|
+
const approvalScope = validateApprovalScope(opts.approvalScope);
|
|
789
|
+
return insertPending(engine, opts, pendingRow(opts, approvalScope));
|
|
790
|
+
}
|
|
791
|
+
async function findOrCreatePending(engine, opts) {
|
|
792
|
+
const approvalScope = validateApprovalScope(opts.approvalScope);
|
|
793
|
+
if (approvalScope === null)
|
|
794
|
+
throw new Error("findOrCreatePending requires an approvalScope; use createPending");
|
|
795
|
+
const row = pendingRow(opts, approvalScope);
|
|
796
|
+
const frozenJson = JSON.stringify(row.frozen);
|
|
797
|
+
const sameCall = `tool_name = $1 AND approval_scope = $2 AND tool_args_frozen = $3::jsonb`;
|
|
798
|
+
const params = [opts.toolName, approvalScope, frozenJson, row.createdAt];
|
|
799
|
+
await engine.pool.query(
|
|
800
|
+
`UPDATE context_engine_tool_approvals SET status = 'expired'
|
|
801
|
+
WHERE ${sameCall} AND status = 'pending' AND expires_at <= $4`,
|
|
802
|
+
params
|
|
803
|
+
);
|
|
804
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
805
|
+
const found = await engine.pool.query(
|
|
806
|
+
`SELECT * FROM context_engine_tool_approvals
|
|
807
|
+
WHERE ${sameCall} AND status = 'pending' AND (expires_at IS NULL OR expires_at > $4)
|
|
808
|
+
ORDER BY created_at ASC LIMIT 1`,
|
|
809
|
+
params
|
|
810
|
+
);
|
|
811
|
+
if (found.rows[0]) return rowToRecord(found.rows[0]);
|
|
812
|
+
try {
|
|
813
|
+
return await insertPending(engine, opts, row);
|
|
814
|
+
} catch (exc) {
|
|
815
|
+
if (exc?.code !== "23505") throw exc;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
throw new Error("findOrCreatePending: lost the insert race twice and found no live pending row");
|
|
819
|
+
}
|
|
735
820
|
async function resolveApproval(engine, approvalId, decision, approver, meta = null, opts = {}) {
|
|
736
821
|
if (decision !== "approved" && decision !== "rejected") {
|
|
737
822
|
throw new Error(`invalid decision: ${JSON.stringify(decision)} (expected 'approved' or 'rejected')`);
|
|
@@ -778,6 +863,10 @@ async function listApprovals(engine, opts = {}) {
|
|
|
778
863
|
params.push(opts.sourceId);
|
|
779
864
|
clauses.push(`source_id = $${params.length}`);
|
|
780
865
|
}
|
|
866
|
+
if (opts.approvalScope != null) {
|
|
867
|
+
params.push(opts.approvalScope);
|
|
868
|
+
clauses.push(`approval_scope = $${params.length}`);
|
|
869
|
+
}
|
|
781
870
|
const vis = visibilitySql(principals, params.length + 1);
|
|
782
871
|
clauses.push(vis.sql);
|
|
783
872
|
params.push(...vis.params);
|
|
@@ -805,6 +894,10 @@ var ToolConfig = class _ToolConfig {
|
|
|
805
894
|
requiresApproval;
|
|
806
895
|
approvalPolicy;
|
|
807
896
|
enabled;
|
|
897
|
+
/** Engine-opaque user metadata (documents have the same column). Stored
|
|
898
|
+
* and returned in CLEAR on the admin surface only — secrets go in
|
|
899
|
+
* `config`, which is encrypted. */
|
|
900
|
+
metaData;
|
|
808
901
|
constructor(init) {
|
|
809
902
|
this.id = init.id ?? null;
|
|
810
903
|
this.name = init.name;
|
|
@@ -816,6 +909,7 @@ var ToolConfig = class _ToolConfig {
|
|
|
816
909
|
this.requiresApproval = init.requiresApproval ?? init.requires_approval ?? false;
|
|
817
910
|
this.approvalPolicy = init.approvalPolicy ?? init.approval_policy ?? {};
|
|
818
911
|
this.enabled = init.enabled ?? true;
|
|
912
|
+
this.metaData = init.metaData ?? init.meta_data ?? {};
|
|
819
913
|
}
|
|
820
914
|
static fromUnknown(body) {
|
|
821
915
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
@@ -838,7 +932,8 @@ var ToolConfig = class _ToolConfig {
|
|
|
838
932
|
acl: b.acl ?? null,
|
|
839
933
|
requiresApproval: Boolean(b.requiresApproval ?? b.requires_approval ?? false),
|
|
840
934
|
approvalPolicy: b.approvalPolicy ?? b.approval_policy ?? {},
|
|
841
|
-
enabled: b.enabled === void 0 ? true : Boolean(b.enabled)
|
|
935
|
+
enabled: b.enabled === void 0 ? true : Boolean(b.enabled),
|
|
936
|
+
metaData: b.metaData ?? b.meta_data ?? {}
|
|
842
937
|
});
|
|
843
938
|
}
|
|
844
939
|
};
|
|
@@ -857,9 +952,26 @@ var SCHEMAS = {
|
|
|
857
952
|
additionalProperties: { type: "string" },
|
|
858
953
|
writeOnly: true
|
|
859
954
|
},
|
|
955
|
+
// The request body TEMPLATE (the http executor reads it). Whether it
|
|
956
|
+
// round-trips is the USER's call — see `body_secret` and the
|
|
957
|
+
// conditional in `redactConfig`; listing it documents the shape, the
|
|
958
|
+
// redactor decides.
|
|
959
|
+
body: {
|
|
960
|
+
description: "Request body template; top-level keys merge with LLM parameters"
|
|
961
|
+
},
|
|
962
|
+
// Salman (2026-08-18): "give checkbox to user" — the author declares
|
|
963
|
+
// whether their body carries secrets. True (or ABSENT — every pre-flag
|
|
964
|
+
// row, fail closed) masks body values like headers; an explicit false
|
|
965
|
+
// round-trips the body in clear like `url`. The flag round-trips.
|
|
966
|
+
body_secret: {
|
|
967
|
+
type: "boolean",
|
|
968
|
+
description: "Body values are write-only (mask like headers)"
|
|
969
|
+
},
|
|
860
970
|
parameters: {
|
|
861
971
|
type: "object",
|
|
862
|
-
description: "Static/user-fixed parameters sent on every call"
|
|
972
|
+
description: "Static/user-fixed parameters sent on every call",
|
|
973
|
+
// The canonical home of a static api_key — secret by position.
|
|
974
|
+
writeOnly: true
|
|
863
975
|
},
|
|
864
976
|
llmParameters: {
|
|
865
977
|
type: "object",
|
|
@@ -890,7 +1002,13 @@ var SCHEMAS = {
|
|
|
890
1002
|
default: "readonly"
|
|
891
1003
|
},
|
|
892
1004
|
max_rows: { type: "integer", default: 1e3 },
|
|
893
|
-
selected_tables: { type: "array", items: { type: "string" } }
|
|
1005
|
+
selected_tables: { type: "array", items: { type: "string" } },
|
|
1006
|
+
// Dialect connection identifiers the db executor reads — same
|
|
1007
|
+
// sensitivity class as `database`, listed so they round-trip instead
|
|
1008
|
+
// of dying to redactConfig's default-deny.
|
|
1009
|
+
service_name: { type: "string" },
|
|
1010
|
+
schema_name: { type: "string" },
|
|
1011
|
+
warehouse: { type: "string" }
|
|
894
1012
|
},
|
|
895
1013
|
required: ["engine", "database"]
|
|
896
1014
|
},
|
|
@@ -922,11 +1040,306 @@ function configSchema(kind) {
|
|
|
922
1040
|
if (!schema) throw new Error(`unknown tool kind: ${JSON.stringify(kind)}`);
|
|
923
1041
|
return schema;
|
|
924
1042
|
}
|
|
1043
|
+
var REDACTED_SENTINEL = "__redacted__";
|
|
1044
|
+
var ConfigTemplateError = class extends Error {
|
|
1045
|
+
};
|
|
1046
|
+
function isPlainObject(value) {
|
|
1047
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1048
|
+
}
|
|
1049
|
+
function maskValue(value) {
|
|
1050
|
+
if (isPlainObject(value)) {
|
|
1051
|
+
const out = {};
|
|
1052
|
+
for (const [k, v] of Object.entries(value)) out[k] = maskValue(v);
|
|
1053
|
+
return out;
|
|
1054
|
+
}
|
|
1055
|
+
if (Array.isArray(value)) return value.map(maskValue);
|
|
1056
|
+
return REDACTED_SENTINEL;
|
|
1057
|
+
}
|
|
1058
|
+
function containsSentinel(value) {
|
|
1059
|
+
if (typeof value === "string") return value === REDACTED_SENTINEL;
|
|
1060
|
+
if (Array.isArray(value)) return value.some(containsSentinel);
|
|
1061
|
+
if (isPlainObject(value)) return Object.values(value).some(containsSentinel);
|
|
1062
|
+
return false;
|
|
1063
|
+
}
|
|
1064
|
+
function redactConfig(kind, config) {
|
|
1065
|
+
const props = (SCHEMAS[kind]?.properties ?? {}) || {};
|
|
1066
|
+
const out = {};
|
|
1067
|
+
for (const [key, value] of Object.entries(config)) {
|
|
1068
|
+
if (kind === "http" && key === "body") {
|
|
1069
|
+
out[key] = config.body_secret === false ? structuredClone(value) : maskValue(value);
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
const spec = props[key];
|
|
1073
|
+
if (spec === void 0 || spec.writeOnly) {
|
|
1074
|
+
out[key] = maskValue(value);
|
|
1075
|
+
} else {
|
|
1076
|
+
out[key] = structuredClone(value);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return out;
|
|
1080
|
+
}
|
|
1081
|
+
function mergeRedacted(incoming, stored) {
|
|
1082
|
+
const resolve = (value, kept, keptPresent, path) => {
|
|
1083
|
+
if (isPlainObject(value)) {
|
|
1084
|
+
const keptDict = isPlainObject(kept) ? kept : {};
|
|
1085
|
+
const out2 = {};
|
|
1086
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1087
|
+
out2[k] = resolve(v, keptDict[k], k in keptDict, path ? `${path}.${k}` : k);
|
|
1088
|
+
}
|
|
1089
|
+
return out2;
|
|
1090
|
+
}
|
|
1091
|
+
if (Array.isArray(value)) {
|
|
1092
|
+
const keptList = Array.isArray(kept) ? kept : [];
|
|
1093
|
+
return value.map((v, i) => resolve(v, keptList[i], i < keptList.length, `${path}[${i}]`));
|
|
1094
|
+
}
|
|
1095
|
+
if (value === REDACTED_SENTINEL) {
|
|
1096
|
+
if (!keptPresent) {
|
|
1097
|
+
throw new ConfigTemplateError(
|
|
1098
|
+
`config.${path} is ${JSON.stringify(REDACTED_SENTINEL)} but there is no stored value to keep \u2014 re-enter the secret or omit the field`
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
return kept;
|
|
1102
|
+
}
|
|
1103
|
+
return value;
|
|
1104
|
+
};
|
|
1105
|
+
const keptRoot = stored ?? {};
|
|
1106
|
+
const out = {};
|
|
1107
|
+
for (const [k, v] of Object.entries(incoming)) {
|
|
1108
|
+
out[k] = resolve(v, keptRoot[k], k in keptRoot, k);
|
|
1109
|
+
}
|
|
1110
|
+
return out;
|
|
1111
|
+
}
|
|
1112
|
+
function encryptDict(data, key) {
|
|
1113
|
+
const nonce = randomBytes(12);
|
|
1114
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
1115
|
+
const plaintext = Buffer.from(JSON.stringify(data), "utf8");
|
|
1116
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
1117
|
+
const tag = cipher.getAuthTag();
|
|
1118
|
+
return Buffer.concat([nonce, ciphertext, tag]).toString("base64");
|
|
1119
|
+
}
|
|
1120
|
+
function decryptDict(token, key) {
|
|
1121
|
+
const raw = Buffer.from(token, "base64");
|
|
1122
|
+
const nonce = raw.subarray(0, 12);
|
|
1123
|
+
const tag = raw.subarray(raw.length - 16);
|
|
1124
|
+
const ciphertext = raw.subarray(12, raw.length - 16);
|
|
1125
|
+
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
|
1126
|
+
decipher.setAuthTag(tag);
|
|
1127
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1128
|
+
return JSON.parse(plaintext.toString("utf8"));
|
|
1129
|
+
}
|
|
1130
|
+
function getSecretKey(config) {
|
|
1131
|
+
const secretKey = config.secretKey;
|
|
1132
|
+
if (!secretKey) {
|
|
1133
|
+
throw new Error(
|
|
1134
|
+
"ContextEngineConfig.secretKey (env CE_SECRET_KEY) is not configured \u2014 a base64url-encoded 32-byte AES key is required to encrypt/decrypt tool configs that hold secrets."
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
return Buffer.from(secretKey, "base64url");
|
|
1138
|
+
}
|
|
1139
|
+
function hmacSha256Hex(key, value) {
|
|
1140
|
+
const k = typeof key === "string" ? Buffer.from(key) : key;
|
|
1141
|
+
return createHmac("sha256", k).update(value, "utf8").digest("hex");
|
|
1142
|
+
}
|
|
1143
|
+
var MAX_RESPONSE_BYTES = 1e6;
|
|
1144
|
+
var MAX_REDIRECTS = 5;
|
|
1145
|
+
function isRedirect(resp) {
|
|
1146
|
+
return resp.status >= 300 && resp.status < 400 || resp.type === "opaqueredirect";
|
|
1147
|
+
}
|
|
1148
|
+
var EgressDenied = class extends Error {
|
|
1149
|
+
constructor(message) {
|
|
1150
|
+
super(message);
|
|
1151
|
+
this.name = "EgressDenied";
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
var resolver = {
|
|
1155
|
+
async lookup(host) {
|
|
1156
|
+
const answers = await promises.lookup(host, { all: true, verbatim: true });
|
|
1157
|
+
return answers.map((a) => a.address);
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
var V4_PRIVATE = [
|
|
1161
|
+
["0.0.0.0", 8],
|
|
1162
|
+
// "this network" / unspecified
|
|
1163
|
+
["10.0.0.0", 8],
|
|
1164
|
+
// RFC 1918
|
|
1165
|
+
["100.64.0.0", 10],
|
|
1166
|
+
// RFC 6598 carrier-grade NAT — overlay VPNs, pod CIDRs
|
|
1167
|
+
["127.0.0.0", 8],
|
|
1168
|
+
// loopback
|
|
1169
|
+
["169.254.0.0", 16],
|
|
1170
|
+
// link-local, incl. the cloud metadata address
|
|
1171
|
+
["172.16.0.0", 12],
|
|
1172
|
+
// RFC 1918
|
|
1173
|
+
["192.168.0.0", 16],
|
|
1174
|
+
// RFC 1918
|
|
1175
|
+
["224.0.0.0", 4],
|
|
1176
|
+
// multicast
|
|
1177
|
+
["240.0.0.0", 4],
|
|
1178
|
+
// reserved
|
|
1179
|
+
["255.255.255.255", 32]
|
|
1180
|
+
// broadcast (inside 240/4; spelled out anyway)
|
|
1181
|
+
];
|
|
1182
|
+
var V6_PRIVATE = [
|
|
1183
|
+
["::", 128],
|
|
1184
|
+
// unspecified
|
|
1185
|
+
["::1", 128],
|
|
1186
|
+
// loopback
|
|
1187
|
+
["::ffff:0:0", 96],
|
|
1188
|
+
// IPv4-mapped — the wrapped v4 is checked too
|
|
1189
|
+
["64:ff9b::", 96],
|
|
1190
|
+
// NAT64 — the wrapped v4 is checked too
|
|
1191
|
+
["2002::", 16],
|
|
1192
|
+
// 6to4 — the wrapped v4 is checked too, and this is denied
|
|
1193
|
+
["fc00::", 7],
|
|
1194
|
+
// unique local
|
|
1195
|
+
["fe80::", 10],
|
|
1196
|
+
// link-local
|
|
1197
|
+
["fec0::", 10],
|
|
1198
|
+
// site-local (deprecated, still configured)
|
|
1199
|
+
["ff00::", 8],
|
|
1200
|
+
// multicast
|
|
1201
|
+
["3fff::", 20]
|
|
1202
|
+
// documentation
|
|
1203
|
+
];
|
|
1204
|
+
function parseV4(text) {
|
|
1205
|
+
const parts = text.split(".");
|
|
1206
|
+
if (parts.length !== 4) return null;
|
|
1207
|
+
const out = new Uint8Array(4);
|
|
1208
|
+
for (let i = 0; i < 4; i++) {
|
|
1209
|
+
const part = parts[i];
|
|
1210
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
1211
|
+
const value = Number(part);
|
|
1212
|
+
if (value > 255) return null;
|
|
1213
|
+
out[i] = value;
|
|
1214
|
+
}
|
|
1215
|
+
return out;
|
|
1216
|
+
}
|
|
1217
|
+
function parseV6(text) {
|
|
1218
|
+
let body = text.split("%")[0] ?? "";
|
|
1219
|
+
const lastColon = body.lastIndexOf(":");
|
|
1220
|
+
if (lastColon < 0) return null;
|
|
1221
|
+
const tail = body.slice(lastColon + 1);
|
|
1222
|
+
if (tail.includes(".")) {
|
|
1223
|
+
const v4 = parseV4(tail);
|
|
1224
|
+
if (!v4) return null;
|
|
1225
|
+
const hi = (v4[0] << 8 | v4[1]).toString(16);
|
|
1226
|
+
const lo = (v4[2] << 8 | v4[3]).toString(16);
|
|
1227
|
+
body = `${body.slice(0, lastColon + 1)}${hi}:${lo}`;
|
|
1228
|
+
}
|
|
1229
|
+
const halves = body.split("::");
|
|
1230
|
+
if (halves.length > 2) return null;
|
|
1231
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
1232
|
+
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
1233
|
+
let groups;
|
|
1234
|
+
if (halves.length === 1) {
|
|
1235
|
+
if (head.length !== 8) return null;
|
|
1236
|
+
groups = head;
|
|
1237
|
+
} else {
|
|
1238
|
+
const missing = 8 - head.length - rest.length;
|
|
1239
|
+
if (missing < 0) return null;
|
|
1240
|
+
groups = [...head, ...Array(missing).fill("0"), ...rest];
|
|
1241
|
+
}
|
|
1242
|
+
const out = new Uint8Array(16);
|
|
1243
|
+
for (let i = 0; i < 8; i++) {
|
|
1244
|
+
const group = groups[i];
|
|
1245
|
+
if (!/^[0-9a-f]{1,4}$/i.test(group)) return null;
|
|
1246
|
+
const value = Number.parseInt(group, 16);
|
|
1247
|
+
out[2 * i] = value >> 8;
|
|
1248
|
+
out[2 * i + 1] = value & 255;
|
|
1249
|
+
}
|
|
1250
|
+
return out;
|
|
1251
|
+
}
|
|
1252
|
+
function parseAddress(text) {
|
|
1253
|
+
const family = isIP(text);
|
|
1254
|
+
if (family === 4) return parseV4(text);
|
|
1255
|
+
if (family === 6) return parseV6(text);
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
function inNet(addr, net, prefix) {
|
|
1259
|
+
if (addr.length !== net.length) return false;
|
|
1260
|
+
const whole = prefix >> 3;
|
|
1261
|
+
for (let i = 0; i < whole; i++) if (addr[i] !== net[i]) return false;
|
|
1262
|
+
const bits = prefix & 7;
|
|
1263
|
+
if (bits === 0) return true;
|
|
1264
|
+
const mask = 255 << 8 - bits;
|
|
1265
|
+
return (addr[whole] & mask) === (net[whole] & mask);
|
|
1266
|
+
}
|
|
1267
|
+
function compile(table) {
|
|
1268
|
+
return table.map(([cidr, prefix]) => {
|
|
1269
|
+
const bytes = parseAddress(cidr);
|
|
1270
|
+
if (!bytes) throw new Error(`egress: unparseable network ${cidr}`);
|
|
1271
|
+
return [bytes, prefix];
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
var V4_NETS = compile(V4_PRIVATE);
|
|
1275
|
+
var V6_NETS = compile(V6_PRIVATE);
|
|
1276
|
+
var MAPPED_V4 = compile([["::ffff:0:0", 96]])[0];
|
|
1277
|
+
var NAT64 = compile([["64:ff9b::", 96]])[0];
|
|
1278
|
+
var SIXTOFOUR = compile([["2002::", 16]])[0];
|
|
1279
|
+
function embeddedV4(bytes) {
|
|
1280
|
+
if (inNet(bytes, MAPPED_V4[0], MAPPED_V4[1]) || inNet(bytes, NAT64[0], NAT64[1])) {
|
|
1281
|
+
return bytes.subarray(12, 16);
|
|
1282
|
+
}
|
|
1283
|
+
if (inNet(bytes, SIXTOFOUR[0], SIXTOFOUR[1])) return bytes.subarray(2, 6);
|
|
1284
|
+
return null;
|
|
1285
|
+
}
|
|
1286
|
+
function addressIsPrivate(address) {
|
|
1287
|
+
const bytes = parseAddress(address);
|
|
1288
|
+
if (!bytes) return true;
|
|
1289
|
+
if (bytes.length === 4) return V4_NETS.some(([net, prefix]) => inNet(bytes, net, prefix));
|
|
1290
|
+
const embedded = embeddedV4(bytes);
|
|
1291
|
+
if (embedded && V4_NETS.some(([net, prefix]) => inNet(embedded, net, prefix))) return true;
|
|
1292
|
+
return V6_NETS.some(([net, prefix]) => inNet(bytes, net, prefix));
|
|
1293
|
+
}
|
|
1294
|
+
async function isPrivateAddress(host) {
|
|
1295
|
+
let name = (host ?? "").trim().toLowerCase().replace(/^\.+|\.+$/g, "");
|
|
1296
|
+
if (!name) return true;
|
|
1297
|
+
if (name === "localhost") return true;
|
|
1298
|
+
if (name.startsWith("[") && name.endsWith("]")) name = name.slice(1, -1);
|
|
1299
|
+
if (isIP(name)) return addressIsPrivate(name);
|
|
1300
|
+
let addresses;
|
|
1301
|
+
try {
|
|
1302
|
+
addresses = await resolver.lookup(name);
|
|
1303
|
+
} catch {
|
|
1304
|
+
return true;
|
|
1305
|
+
}
|
|
1306
|
+
if (!addresses.length) return true;
|
|
1307
|
+
return addresses.some((address) => addressIsPrivate(address.split("%")[0] ?? address));
|
|
1308
|
+
}
|
|
1309
|
+
async function assertEgressAllowed(url, opts) {
|
|
1310
|
+
let parsed;
|
|
1311
|
+
try {
|
|
1312
|
+
parsed = new URL(url ?? "");
|
|
1313
|
+
} catch {
|
|
1314
|
+
throw new EgressDenied(`egress denied: ${JSON.stringify(url)} is not a valid URL`);
|
|
1315
|
+
}
|
|
1316
|
+
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
|
|
1317
|
+
if (scheme !== "http" && scheme !== "https") {
|
|
1318
|
+
throw new EgressDenied(
|
|
1319
|
+
`egress denied: unsupported URL scheme ${JSON.stringify(scheme)} \u2014 http tools may only reach http/https`
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
if (opts.allowPrivate) return;
|
|
1323
|
+
const host = parsed.hostname;
|
|
1324
|
+
if (await isPrivateAddress(host)) {
|
|
1325
|
+
throw new EgressDenied(
|
|
1326
|
+
`egress denied: ${host || "(no host)"} is a private, loopback, link-local or metadata address; set allowPrivateEgress to permit it`
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
925
1330
|
|
|
926
1331
|
// src/version.ts
|
|
927
1332
|
var __version__ = "0.0.0";
|
|
928
1333
|
|
|
929
1334
|
// src/tools/executors/mcp-client.ts
|
|
1335
|
+
async function checkEgress(url, allowPrivate) {
|
|
1336
|
+
let checked = url;
|
|
1337
|
+
const scheme = (/^([a-z0-9+.-]+):/i.exec(url ?? "")?.[1] ?? "").toLowerCase();
|
|
1338
|
+
if (scheme === "ws") checked = `http:${url.slice(scheme.length + 1)}`;
|
|
1339
|
+
else if (scheme === "wss") checked = `https:${url.slice(scheme.length + 1)}`;
|
|
1340
|
+
await assertEgressAllowed(checked, { allowPrivate });
|
|
1341
|
+
}
|
|
1342
|
+
var REDIRECT_MESSAGE = "MCP server redirected; register the final URL";
|
|
930
1343
|
var MCPError = class _MCPError extends Error {
|
|
931
1344
|
code;
|
|
932
1345
|
data;
|
|
@@ -955,10 +1368,12 @@ function parseSseBuffer(raw) {
|
|
|
955
1368
|
var HttpTransport = class {
|
|
956
1369
|
kind = "http";
|
|
957
1370
|
url;
|
|
1371
|
+
allowPrivate;
|
|
958
1372
|
baseHeaders;
|
|
959
1373
|
sessionId = null;
|
|
960
|
-
constructor(url, headers) {
|
|
1374
|
+
constructor(url, headers, allowPrivate = false) {
|
|
961
1375
|
this.url = url;
|
|
1376
|
+
this.allowPrivate = allowPrivate;
|
|
962
1377
|
this.baseHeaders = {
|
|
963
1378
|
...headers,
|
|
964
1379
|
"Content-Type": "application/json",
|
|
@@ -966,6 +1381,7 @@ var HttpTransport = class {
|
|
|
966
1381
|
};
|
|
967
1382
|
}
|
|
968
1383
|
async connect() {
|
|
1384
|
+
await checkEgress(this.url, this.allowPrivate);
|
|
969
1385
|
}
|
|
970
1386
|
async close() {
|
|
971
1387
|
this.sessionId = null;
|
|
@@ -979,10 +1395,17 @@ var HttpTransport = class {
|
|
|
979
1395
|
const resp = await fetch(this.url, {
|
|
980
1396
|
method: "POST",
|
|
981
1397
|
headers: this.buildHeaders(),
|
|
982
|
-
body: JSON.stringify(msg)
|
|
1398
|
+
body: JSON.stringify(msg),
|
|
1399
|
+
// `redirect: "manual"`: a followed redirect would carry this POST —
|
|
1400
|
+
// headers, Authorization and all — to a destination `connect()`'s
|
|
1401
|
+
// check never saw. A 3xx is reported, never chased.
|
|
1402
|
+
redirect: "manual"
|
|
983
1403
|
});
|
|
984
1404
|
const sid = resp.headers.get("Mcp-Session-Id");
|
|
985
1405
|
if (sid) this.sessionId = sid;
|
|
1406
|
+
if (isRedirect(resp)) {
|
|
1407
|
+
throw new MCPError(resp.status, REDIRECT_MESSAGE);
|
|
1408
|
+
}
|
|
986
1409
|
if (resp.status >= 400) {
|
|
987
1410
|
const body = await resp.text();
|
|
988
1411
|
console.error(`[HTTP Transport] ${resp.status} from ${this.url}: ${body.slice(0, 300)}`);
|
|
@@ -1015,11 +1438,20 @@ var SseTransport = class {
|
|
|
1015
1438
|
eventsUrl;
|
|
1016
1439
|
baseUrl;
|
|
1017
1440
|
headers;
|
|
1441
|
+
allowPrivate;
|
|
1442
|
+
/** Where the server told us to POST — it arrives in an `endpoint` event on
|
|
1443
|
+
* the stream, so it is server-chosen and hence checked. Private: the only
|
|
1444
|
+
* way in is the parser, which is the path a real server takes. */
|
|
1018
1445
|
postUrl = null;
|
|
1446
|
+
/** The last POST endpoint cleared by the egress check. The check costs a
|
|
1447
|
+
* DNS resolution and the endpoint changes at most once per connection, so
|
|
1448
|
+
* it is not repeated per message. */
|
|
1449
|
+
checkedPostUrl = null;
|
|
1019
1450
|
queue = [];
|
|
1020
1451
|
waiters = [];
|
|
1021
1452
|
abort = null;
|
|
1022
|
-
constructor(url, headers) {
|
|
1453
|
+
constructor(url, headers, allowPrivate = false) {
|
|
1454
|
+
this.allowPrivate = allowPrivate;
|
|
1023
1455
|
this.eventsUrl = url.replace(/\/$/, "");
|
|
1024
1456
|
if (url.endsWith("/events")) this.baseUrl = url.slice(0, -7);
|
|
1025
1457
|
else if (url.endsWith("/sse")) this.baseUrl = url.slice(0, -4);
|
|
@@ -1032,14 +1464,19 @@ var SseTransport = class {
|
|
|
1032
1464
|
else this.queue.push(msg);
|
|
1033
1465
|
}
|
|
1034
1466
|
async connect() {
|
|
1467
|
+
await checkEgress(this.eventsUrl, this.allowPrivate);
|
|
1035
1468
|
this.abort = new AbortController();
|
|
1036
1469
|
void this.listen();
|
|
1037
1470
|
}
|
|
1038
1471
|
async listen() {
|
|
1039
1472
|
const resp = await fetch(this.eventsUrl, {
|
|
1040
1473
|
headers: { ...this.headers, Accept: "text/event-stream" },
|
|
1041
|
-
signal: this.abort?.signal
|
|
1474
|
+
signal: this.abort?.signal,
|
|
1475
|
+
redirect: "manual"
|
|
1042
1476
|
});
|
|
1477
|
+
if (isRedirect(resp)) {
|
|
1478
|
+
throw new MCPError(resp.status, REDIRECT_MESSAGE);
|
|
1479
|
+
}
|
|
1043
1480
|
if (!resp.ok) throw new Error(`SSE ${resp.status}`);
|
|
1044
1481
|
if (!resp.body) throw new Error("SSE response has no body");
|
|
1045
1482
|
const reader = resp.body.getReader();
|
|
@@ -1091,11 +1528,19 @@ var SseTransport = class {
|
|
|
1091
1528
|
}
|
|
1092
1529
|
}
|
|
1093
1530
|
const postUrl = this.postUrl || this.baseUrl;
|
|
1531
|
+
if (postUrl !== this.checkedPostUrl) {
|
|
1532
|
+
await checkEgress(postUrl, this.allowPrivate);
|
|
1533
|
+
this.checkedPostUrl = postUrl;
|
|
1534
|
+
}
|
|
1094
1535
|
const resp = await fetch(postUrl, {
|
|
1095
1536
|
method: "POST",
|
|
1096
1537
|
headers: { ...this.headers, "Content-Type": "application/json" },
|
|
1097
|
-
body: JSON.stringify(msg)
|
|
1538
|
+
body: JSON.stringify(msg),
|
|
1539
|
+
redirect: "manual"
|
|
1098
1540
|
});
|
|
1541
|
+
if (isRedirect(resp)) {
|
|
1542
|
+
throw new MCPError(resp.status, REDIRECT_MESSAGE);
|
|
1543
|
+
}
|
|
1099
1544
|
if (!resp.ok) throw new Error(`SSE POST ${resp.status}`);
|
|
1100
1545
|
try {
|
|
1101
1546
|
const data = await resp.json();
|
|
@@ -1114,14 +1559,17 @@ var WsTransport = class {
|
|
|
1114
1559
|
kind = "websocket";
|
|
1115
1560
|
url;
|
|
1116
1561
|
headers;
|
|
1562
|
+
allowPrivate;
|
|
1117
1563
|
ws = null;
|
|
1118
1564
|
queue = [];
|
|
1119
1565
|
waiters = [];
|
|
1120
|
-
constructor(url, headers) {
|
|
1566
|
+
constructor(url, headers, allowPrivate = false) {
|
|
1121
1567
|
this.url = url;
|
|
1568
|
+
this.allowPrivate = allowPrivate;
|
|
1122
1569
|
this.headers = headers;
|
|
1123
1570
|
}
|
|
1124
1571
|
async connect() {
|
|
1572
|
+
await checkEgress(this.url, this.allowPrivate);
|
|
1125
1573
|
const WS = globalThis.WebSocket;
|
|
1126
1574
|
if (!WS) throw new Error("WebSocket is not available in this runtime");
|
|
1127
1575
|
this.ws = new WS(this.url);
|
|
@@ -1162,21 +1610,27 @@ var MCPClient = class _MCPClient {
|
|
|
1162
1610
|
tools = [];
|
|
1163
1611
|
resources = [];
|
|
1164
1612
|
headers;
|
|
1165
|
-
|
|
1613
|
+
allowPrivate;
|
|
1614
|
+
constructor(url, headers = null, opts = {}) {
|
|
1166
1615
|
this.url = url;
|
|
1167
1616
|
this.headers = headers ?? {};
|
|
1168
|
-
this.
|
|
1617
|
+
this.allowPrivate = opts.allowPrivate ?? false;
|
|
1618
|
+
this.transport = _MCPClient.createTransport(url, this.headers, this.allowPrivate);
|
|
1169
1619
|
}
|
|
1170
|
-
static createTransport(url, headers) {
|
|
1620
|
+
static createTransport(url, headers, allowPrivate = false) {
|
|
1171
1621
|
if (url.startsWith("stdio:") || url === "stdio") {
|
|
1172
1622
|
throw new Error("stdio MCP transport is not supported (cloud deployments use SSE/HTTP/WebSocket)");
|
|
1173
1623
|
}
|
|
1174
|
-
if (url.startsWith("ws://") || url.startsWith("wss://"))
|
|
1624
|
+
if (url.startsWith("ws://") || url.startsWith("wss://")) {
|
|
1625
|
+
return new WsTransport(url, headers, allowPrivate);
|
|
1626
|
+
}
|
|
1175
1627
|
if (url.startsWith("sse+http://") || url.startsWith("sse+https://") || url.endsWith("/events") || url.endsWith("/sse")) {
|
|
1176
1628
|
const clean = url.replace("sse+http://", "http://").replace("sse+https://", "https://");
|
|
1177
|
-
return new SseTransport(clean, headers);
|
|
1629
|
+
return new SseTransport(clean, headers, allowPrivate);
|
|
1630
|
+
}
|
|
1631
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
1632
|
+
return new HttpTransport(url, headers, allowPrivate);
|
|
1178
1633
|
}
|
|
1179
|
-
if (url.startsWith("http://") || url.startsWith("https://")) return new HttpTransport(url, headers);
|
|
1180
1634
|
throw new Error(`Unsupported URL: ${url} (use http(s)://, ws(s)://, or .../sse)`);
|
|
1181
1635
|
}
|
|
1182
1636
|
nextId() {
|
|
@@ -1228,7 +1682,15 @@ var MCPClient = class _MCPClient {
|
|
|
1228
1682
|
await this.transport.send({ jsonrpc: "2.0", method, params: params ?? {} });
|
|
1229
1683
|
}
|
|
1230
1684
|
async connect() {
|
|
1231
|
-
|
|
1685
|
+
try {
|
|
1686
|
+
await this.transport.connect();
|
|
1687
|
+
} catch (e) {
|
|
1688
|
+
try {
|
|
1689
|
+
await this.transport.close();
|
|
1690
|
+
} catch {
|
|
1691
|
+
}
|
|
1692
|
+
throw e;
|
|
1693
|
+
}
|
|
1232
1694
|
if (!(this.transport instanceof HttpTransport)) {
|
|
1233
1695
|
this.connected = true;
|
|
1234
1696
|
void this.recvLoop();
|
|
@@ -1311,7 +1773,7 @@ var MCPClient = class _MCPClient {
|
|
|
1311
1773
|
}
|
|
1312
1774
|
this.id = 0;
|
|
1313
1775
|
this.pending.clear();
|
|
1314
|
-
this.transport = _MCPClient.createTransport(url, headers);
|
|
1776
|
+
this.transport = _MCPClient.createTransport(url, headers, this.allowPrivate);
|
|
1315
1777
|
await this.connect();
|
|
1316
1778
|
await this.listTools();
|
|
1317
1779
|
}
|
|
@@ -1319,11 +1781,11 @@ var MCPClient = class _MCPClient {
|
|
|
1319
1781
|
var MCPRegistry = class {
|
|
1320
1782
|
clients = /* @__PURE__ */ new Map();
|
|
1321
1783
|
healthTask = null;
|
|
1322
|
-
async connect(name, url, headers = null, token = null) {
|
|
1784
|
+
async connect(name, url, headers = null, token = null, allowPrivate = false) {
|
|
1323
1785
|
this.clients.delete(name);
|
|
1324
1786
|
const hdrs = { ...headers ?? {} };
|
|
1325
1787
|
if (token) hdrs.Authorization = hdrs.Authorization ?? `Bearer ${token}`;
|
|
1326
|
-
const client = new MCPClient(url, hdrs);
|
|
1788
|
+
const client = new MCPClient(url, hdrs, { allowPrivate });
|
|
1327
1789
|
await client.connect();
|
|
1328
1790
|
await client.listTools();
|
|
1329
1791
|
this.clients.set(name, client);
|
|
@@ -1375,8 +1837,18 @@ var MCPRegistry = class {
|
|
|
1375
1837
|
};
|
|
1376
1838
|
var PromptevMCP = class {
|
|
1377
1839
|
clients = new MCPRegistry();
|
|
1840
|
+
allowPrivate;
|
|
1841
|
+
/**
|
|
1842
|
+
* `allowPrivate` is the operator knob `allowPrivateEgress`, carried down to
|
|
1843
|
+
* every transport this facade builds. It defaults to `false`, so a
|
|
1844
|
+
* construction site that forgets to pass it denies private destinations
|
|
1845
|
+
* rather than permitting them.
|
|
1846
|
+
*/
|
|
1847
|
+
constructor(opts = {}) {
|
|
1848
|
+
this.allowPrivate = opts.allowPrivate ?? false;
|
|
1849
|
+
}
|
|
1378
1850
|
async addServer(name, url, opts = {}) {
|
|
1379
|
-
await this.clients.connect(name, url, opts.headers ?? null, opts.token ?? null);
|
|
1851
|
+
await this.clients.connect(name, url, opts.headers ?? null, opts.token ?? null, this.allowPrivate);
|
|
1380
1852
|
}
|
|
1381
1853
|
async shutdown() {
|
|
1382
1854
|
await this.clients.shutdown();
|
|
@@ -1416,37 +1888,6 @@ function emitToolCall(hooks, event) {
|
|
|
1416
1888
|
log.warn("onToolCall callback raised; swallowing");
|
|
1417
1889
|
}
|
|
1418
1890
|
}
|
|
1419
|
-
function encryptDict(data, key) {
|
|
1420
|
-
const nonce = randomBytes(12);
|
|
1421
|
-
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
1422
|
-
const plaintext = Buffer.from(JSON.stringify(data), "utf8");
|
|
1423
|
-
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
1424
|
-
const tag = cipher.getAuthTag();
|
|
1425
|
-
return Buffer.concat([nonce, ciphertext, tag]).toString("base64");
|
|
1426
|
-
}
|
|
1427
|
-
function decryptDict(token, key) {
|
|
1428
|
-
const raw = Buffer.from(token, "base64");
|
|
1429
|
-
const nonce = raw.subarray(0, 12);
|
|
1430
|
-
const tag = raw.subarray(raw.length - 16);
|
|
1431
|
-
const ciphertext = raw.subarray(12, raw.length - 16);
|
|
1432
|
-
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
|
1433
|
-
decipher.setAuthTag(tag);
|
|
1434
|
-
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1435
|
-
return JSON.parse(plaintext.toString("utf8"));
|
|
1436
|
-
}
|
|
1437
|
-
function getSecretKey(config) {
|
|
1438
|
-
const secretKey = config.secretKey;
|
|
1439
|
-
if (!secretKey) {
|
|
1440
|
-
throw new Error(
|
|
1441
|
-
"ContextEngineConfig.secretKey (env CE_SECRET_KEY) is not configured \u2014 a base64url-encoded 32-byte AES key is required to encrypt/decrypt tool configs that hold secrets."
|
|
1442
|
-
);
|
|
1443
|
-
}
|
|
1444
|
-
return Buffer.from(secretKey, "base64url");
|
|
1445
|
-
}
|
|
1446
|
-
function hmacSha256Hex(key, value) {
|
|
1447
|
-
const k = typeof key === "string" ? Buffer.from(key) : key;
|
|
1448
|
-
return createHmac("sha256", k).update(value, "utf8").digest("hex");
|
|
1449
|
-
}
|
|
1450
1891
|
|
|
1451
1892
|
// src/redaction.ts
|
|
1452
1893
|
var EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
@@ -1726,6 +2167,7 @@ async function logToolCall(engine, row) {
|
|
|
1726
2167
|
init_db();
|
|
1727
2168
|
|
|
1728
2169
|
// src/tools/executors/http.ts
|
|
2170
|
+
init_errors();
|
|
1729
2171
|
var TIMEOUT_MS = 3e4;
|
|
1730
2172
|
var SENSITIVE_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
1731
2173
|
"set-cookie",
|
|
@@ -1734,6 +2176,8 @@ var SENSITIVE_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
|
1734
2176
|
"proxy-authenticate",
|
|
1735
2177
|
"www-authenticate"
|
|
1736
2178
|
]);
|
|
2179
|
+
var REDIRECT_STATUS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
2180
|
+
var CROSS_ORIGIN_HEADERS = /* @__PURE__ */ new Set(["authorization", "proxy-authorization", "cookie"]);
|
|
1737
2181
|
function percentEncode(value) {
|
|
1738
2182
|
return encodeURIComponent(value).replace(
|
|
1739
2183
|
/[!'()*]/g,
|
|
@@ -1764,10 +2208,38 @@ function withQuery(url, params) {
|
|
|
1764
2208
|
if (!q) return url;
|
|
1765
2209
|
return url.includes("?") ? `${url}&${q}` : `${url}?${q}`;
|
|
1766
2210
|
}
|
|
2211
|
+
async function readCapped(resp) {
|
|
2212
|
+
if (!resp.body) return { text: await resp.text(), truncated: false };
|
|
2213
|
+
const reader = resp.body.getReader();
|
|
2214
|
+
const chunks = [];
|
|
2215
|
+
let size = 0;
|
|
2216
|
+
let truncated = false;
|
|
2217
|
+
for (; ; ) {
|
|
2218
|
+
const { done, value } = await reader.read();
|
|
2219
|
+
if (done) break;
|
|
2220
|
+
if (value) {
|
|
2221
|
+
chunks.push(Buffer.from(value));
|
|
2222
|
+
size += value.byteLength;
|
|
2223
|
+
}
|
|
2224
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
2225
|
+
truncated = true;
|
|
2226
|
+
await reader.cancel();
|
|
2227
|
+
break;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
const bytes = Buffer.concat(chunks);
|
|
2231
|
+
return {
|
|
2232
|
+
text: (truncated ? bytes.subarray(0, MAX_RESPONSE_BYTES) : bytes).toString("utf8"),
|
|
2233
|
+
truncated
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
1767
2236
|
async function defaultRequest(init) {
|
|
1768
2237
|
let url = init.url;
|
|
1769
2238
|
if (init.params) url = withQuery(url, init.params);
|
|
1770
2239
|
const headers = { ...init.headers ?? {} };
|
|
2240
|
+
if (!Object.keys(headers).some((k) => k.toLowerCase() === "accept-encoding")) {
|
|
2241
|
+
headers["Accept-Encoding"] = "identity";
|
|
2242
|
+
}
|
|
1771
2243
|
let body;
|
|
1772
2244
|
if (init.json !== void 0) {
|
|
1773
2245
|
headers["Content-Type"] = headers["Content-Type"] ?? headers["content-type"] ?? "application/json";
|
|
@@ -1779,25 +2251,47 @@ async function defaultRequest(init) {
|
|
|
1779
2251
|
method: init.method,
|
|
1780
2252
|
headers,
|
|
1781
2253
|
body,
|
|
1782
|
-
|
|
2254
|
+
// Redirects are followed by hand in `executeHttp` — an automatic follow
|
|
2255
|
+
// would jump to a destination no egress check ever saw.
|
|
2256
|
+
redirect: "manual",
|
|
1783
2257
|
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
1784
2258
|
});
|
|
1785
2259
|
const respHeaders = {};
|
|
1786
2260
|
resp.headers.forEach((v, k) => {
|
|
1787
2261
|
respHeaders[k] = v;
|
|
1788
2262
|
});
|
|
1789
|
-
const text = await resp
|
|
2263
|
+
const { text, truncated } = await readCapped(resp);
|
|
1790
2264
|
return {
|
|
1791
2265
|
status: resp.status,
|
|
1792
2266
|
headers: respHeaders,
|
|
2267
|
+
truncated,
|
|
1793
2268
|
async json() {
|
|
1794
|
-
return JSON.parse(text);
|
|
2269
|
+
return JSON.parse(text.replace(/^/, ""));
|
|
1795
2270
|
},
|
|
1796
2271
|
async text() {
|
|
1797
2272
|
return text;
|
|
1798
2273
|
}
|
|
1799
2274
|
};
|
|
1800
2275
|
}
|
|
2276
|
+
function nextHop(init, status, location) {
|
|
2277
|
+
const current = new URL(init.url);
|
|
2278
|
+
const target = new URL(location, current);
|
|
2279
|
+
let method = init.method;
|
|
2280
|
+
if ((status === 302 || status === 303) && method !== "HEAD") method = "GET";
|
|
2281
|
+
else if (status === 301 && method === "POST") method = "GET";
|
|
2282
|
+
let headers = { ...init.headers ?? {} };
|
|
2283
|
+
if (current.origin !== target.origin) {
|
|
2284
|
+
headers = Object.fromEntries(
|
|
2285
|
+
Object.entries(headers).filter(([k]) => !CROSS_ORIGIN_HEADERS.has(k.toLowerCase()))
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
const hop = { method, url: target.toString(), headers };
|
|
2289
|
+
if (method === init.method) {
|
|
2290
|
+
if (init.json !== void 0) hop.json = init.json;
|
|
2291
|
+
if (init.content !== void 0) hop.content = init.content;
|
|
2292
|
+
}
|
|
2293
|
+
return hop;
|
|
2294
|
+
}
|
|
1801
2295
|
async function executeHttp(config, args, opts = {}) {
|
|
1802
2296
|
const method = String(config.method ?? "GET").toUpperCase();
|
|
1803
2297
|
let url = config.url;
|
|
@@ -1818,7 +2312,7 @@ async function executeHttp(config, args, opts = {}) {
|
|
|
1818
2312
|
}
|
|
1819
2313
|
}
|
|
1820
2314
|
}
|
|
1821
|
-
|
|
2315
|
+
let requestInit = {
|
|
1822
2316
|
method,
|
|
1823
2317
|
url: url ?? "",
|
|
1824
2318
|
headers
|
|
@@ -1854,23 +2348,50 @@ async function executeHttp(config, args, opts = {}) {
|
|
|
1854
2348
|
}
|
|
1855
2349
|
}
|
|
1856
2350
|
const client = opts.client;
|
|
1857
|
-
const
|
|
2351
|
+
const allowPrivate = opts.allowPrivate ?? false;
|
|
2352
|
+
let resp;
|
|
2353
|
+
let hops = 0;
|
|
2354
|
+
for (; ; ) {
|
|
2355
|
+
await assertEgressAllowed(requestInit.url, { allowPrivate });
|
|
2356
|
+
resp = client ? await client.request(requestInit) : await defaultRequest(requestInit);
|
|
2357
|
+
const location = resp.headers.location ?? resp.headers.Location;
|
|
2358
|
+
if (!REDIRECT_STATUS.has(resp.status) || !location) break;
|
|
2359
|
+
if (hops >= MAX_REDIRECTS) {
|
|
2360
|
+
throw new EngineActionError(`too many redirects (more than ${MAX_REDIRECTS}) starting at ${url}`);
|
|
2361
|
+
}
|
|
2362
|
+
hops += 1;
|
|
2363
|
+
requestInit = nextHop(requestInit, resp.status, location);
|
|
2364
|
+
}
|
|
1858
2365
|
const contentType = resp.headers["content-type"] ?? resp.headers["Content-Type"] ?? "";
|
|
1859
|
-
let
|
|
2366
|
+
let text = "";
|
|
1860
2367
|
try {
|
|
1861
|
-
|
|
2368
|
+
text = await resp.text();
|
|
1862
2369
|
} catch {
|
|
1863
|
-
|
|
2370
|
+
text = "";
|
|
2371
|
+
}
|
|
2372
|
+
const bytes = Buffer.from(text, "utf8");
|
|
2373
|
+
const truncated = resp.truncated === true || bytes.byteLength > MAX_RESPONSE_BYTES;
|
|
2374
|
+
let data;
|
|
2375
|
+
if (truncated) {
|
|
2376
|
+
data = bytes.subarray(0, MAX_RESPONSE_BYTES).toString("utf8");
|
|
2377
|
+
} else {
|
|
2378
|
+
try {
|
|
2379
|
+
data = contentType.includes("application/json") ? await resp.json() : text;
|
|
2380
|
+
} catch {
|
|
2381
|
+
data = text;
|
|
2382
|
+
}
|
|
1864
2383
|
}
|
|
1865
2384
|
const safeHeaders = {};
|
|
1866
2385
|
for (const [k, v] of Object.entries(resp.headers)) {
|
|
1867
2386
|
if (!SENSITIVE_RESPONSE_HEADERS.has(k.toLowerCase())) safeHeaders[k] = v;
|
|
1868
2387
|
}
|
|
1869
|
-
|
|
2388
|
+
const result = {
|
|
1870
2389
|
status_code: resp.status,
|
|
1871
2390
|
headers: safeHeaders,
|
|
1872
2391
|
data
|
|
1873
2392
|
};
|
|
2393
|
+
if (truncated) result.truncated = true;
|
|
2394
|
+
return result;
|
|
1874
2395
|
}
|
|
1875
2396
|
|
|
1876
2397
|
// src/tools/registry.ts
|
|
@@ -2000,8 +2521,13 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
|
|
|
2000
2521
|
"requires_approval",
|
|
2001
2522
|
"approvalPolicy",
|
|
2002
2523
|
"approval_policy",
|
|
2003
|
-
"enabled"
|
|
2524
|
+
"enabled",
|
|
2525
|
+
"metaData",
|
|
2526
|
+
"meta_data"
|
|
2004
2527
|
]);
|
|
2528
|
+
function allowPrivateEgress(engine) {
|
|
2529
|
+
return Boolean(engine?.config?.allowPrivateEgress);
|
|
2530
|
+
}
|
|
2005
2531
|
function toolVisible(ct, principals) {
|
|
2006
2532
|
return aclVisible(ct.acl, principals);
|
|
2007
2533
|
}
|
|
@@ -2083,11 +2609,19 @@ function redactToolResult(result, policy, opts) {
|
|
|
2083
2609
|
if (failed.length) note.rules_failed = failed;
|
|
2084
2610
|
return [redacted, note];
|
|
2085
2611
|
}
|
|
2086
|
-
function
|
|
2087
|
-
|
|
2088
|
-
if (
|
|
2089
|
-
|
|
2612
|
+
function cryptoKey(engine) {
|
|
2613
|
+
const key = getSecretKey(engine.config);
|
|
2614
|
+
if (key.length !== 32) {
|
|
2615
|
+
throw new Error(`CE_SECRET_KEY is malformed: decoded to ${key.length} bytes, need 32`);
|
|
2090
2616
|
}
|
|
2617
|
+
return key;
|
|
2618
|
+
}
|
|
2619
|
+
function decryptRowConfig(row, engine) {
|
|
2620
|
+
if (!row.config_encrypted) return {};
|
|
2621
|
+
return decryptDict(String(row.config_encrypted), cryptoKey(engine));
|
|
2622
|
+
}
|
|
2623
|
+
function rowToToolConfig(row, engine, config) {
|
|
2624
|
+
config ??= decryptRowConfig(row, engine);
|
|
2091
2625
|
return new ToolConfig({
|
|
2092
2626
|
id: String(row.id),
|
|
2093
2627
|
name: String(row.name),
|
|
@@ -2098,9 +2632,24 @@ function rowToToolConfig(row, engine) {
|
|
|
2098
2632
|
acl: row.acl != null ? [...row.acl] : null,
|
|
2099
2633
|
requiresApproval: Boolean(row.requires_approval),
|
|
2100
2634
|
approvalPolicy: row.approval_policy ?? {},
|
|
2101
|
-
enabled: Boolean(row.enabled)
|
|
2635
|
+
enabled: Boolean(row.enabled),
|
|
2636
|
+
metaData: row.meta_data ?? {}
|
|
2102
2637
|
});
|
|
2103
2638
|
}
|
|
2639
|
+
function rowToToolConfigLenient(row, engine) {
|
|
2640
|
+
if (!row.config_encrypted) return rowToToolConfig(row, engine, {});
|
|
2641
|
+
const key = cryptoKey(engine);
|
|
2642
|
+
let config;
|
|
2643
|
+
try {
|
|
2644
|
+
config = decryptDict(String(row.config_encrypted), key);
|
|
2645
|
+
} catch {
|
|
2646
|
+
console.warn(
|
|
2647
|
+
`tool ${String(row.id)} (${String(row.name)}): config_error \u2014 its stored config could not be decrypted (was the secret key rotated?); leaving it out of the tool set`
|
|
2648
|
+
);
|
|
2649
|
+
return null;
|
|
2650
|
+
}
|
|
2651
|
+
return rowToToolConfig(row, engine, config);
|
|
2652
|
+
}
|
|
2104
2653
|
async function loadPersistedCanonicals(engine, sourceId) {
|
|
2105
2654
|
const params = [];
|
|
2106
2655
|
let sql = `SELECT * FROM context_engine_tools WHERE enabled IS TRUE`;
|
|
@@ -2111,7 +2660,9 @@ async function loadPersistedCanonicals(engine, sourceId) {
|
|
|
2111
2660
|
const result = await engine.pool.query(sql, params);
|
|
2112
2661
|
const canonicals = [];
|
|
2113
2662
|
for (const row of result.rows) {
|
|
2114
|
-
|
|
2663
|
+
const tc = rowToToolConfigLenient(row, engine);
|
|
2664
|
+
if (tc === null) continue;
|
|
2665
|
+
canonicals.push(...canonicalFromConfig(tc));
|
|
2115
2666
|
}
|
|
2116
2667
|
return canonicals;
|
|
2117
2668
|
}
|
|
@@ -2123,13 +2674,18 @@ async function mergedTools(engine, sourceId) {
|
|
|
2123
2674
|
async function registerTool(engine, tc) {
|
|
2124
2675
|
canonicalFromConfig(tc);
|
|
2125
2676
|
const config = tc.config ?? {};
|
|
2126
|
-
|
|
2677
|
+
if (containsSentinel(config)) {
|
|
2678
|
+
throw new ConfigTemplateError(
|
|
2679
|
+
`config contains the ${JSON.stringify(REDACTED_SENTINEL)} placeholder \u2014 a redacted template cannot be registered as a new tool; re-enter the secret values`
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2682
|
+
const configEncrypted = Object.keys(config).length ? encryptDict(config, cryptoKey(engine)) : null;
|
|
2127
2683
|
const id = randomUUID();
|
|
2128
2684
|
await engine.pool.query(
|
|
2129
2685
|
`INSERT INTO context_engine_tools
|
|
2130
2686
|
(id, name, kind, description, source_id, acl, config_encrypted,
|
|
2131
|
-
requires_approval, approval_policy, enabled)
|
|
2132
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
|
|
2687
|
+
requires_approval, approval_policy, enabled, meta_data)
|
|
2688
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11::jsonb)`,
|
|
2133
2689
|
[
|
|
2134
2690
|
id,
|
|
2135
2691
|
tc.name,
|
|
@@ -2140,7 +2696,8 @@ async function registerTool(engine, tc) {
|
|
|
2140
2696
|
configEncrypted,
|
|
2141
2697
|
tc.requiresApproval,
|
|
2142
2698
|
JSON.stringify(tc.approvalPolicy ?? {}),
|
|
2143
|
-
tc.enabled
|
|
2699
|
+
tc.enabled,
|
|
2700
|
+
JSON.stringify(tc.metaData ?? {})
|
|
2144
2701
|
]
|
|
2145
2702
|
);
|
|
2146
2703
|
return id;
|
|
@@ -2157,12 +2714,41 @@ async function updateTool(engine, id, opts) {
|
|
|
2157
2714
|
let i = 1;
|
|
2158
2715
|
for (const [key, value] of Object.entries(fields)) {
|
|
2159
2716
|
if (key === "config") {
|
|
2717
|
+
let config = value;
|
|
2718
|
+
if (config && containsSentinel(config)) {
|
|
2719
|
+
if (Object.hasOwn(fields, "kind") && fields.kind !== row.kind) {
|
|
2720
|
+
throw new ConfigTemplateError(
|
|
2721
|
+
"cannot change kind and keep redacted secrets in one PATCH \u2014 re-enter the config in full"
|
|
2722
|
+
);
|
|
2723
|
+
}
|
|
2724
|
+
if (config.body_secret === false && containsSentinel(config.body)) {
|
|
2725
|
+
throw new ConfigTemplateError(
|
|
2726
|
+
`body_secret cannot be turned off while the body still contains ${JSON.stringify(REDACTED_SENTINEL)} \u2014 re-enter the body to declassify it`
|
|
2727
|
+
);
|
|
2728
|
+
}
|
|
2729
|
+
if (!row.config_encrypted) {
|
|
2730
|
+
throw new ConfigTemplateError(
|
|
2731
|
+
`config contains ${JSON.stringify(REDACTED_SENTINEL)} but this tool has no stored config to keep \u2014 re-enter the secret values`
|
|
2732
|
+
);
|
|
2733
|
+
}
|
|
2734
|
+
const key2 = cryptoKey(engine);
|
|
2735
|
+
let stored;
|
|
2736
|
+
try {
|
|
2737
|
+
stored = decryptDict(String(row.config_encrypted), key2);
|
|
2738
|
+
} catch (exc) {
|
|
2739
|
+
throw new ConfigTemplateError(
|
|
2740
|
+
`the stored config cannot be decrypted (was the secret key rotated?) \u2014 re-enter the config in full, without ${JSON.stringify(REDACTED_SENTINEL)} values`,
|
|
2741
|
+
{ cause: exc }
|
|
2742
|
+
);
|
|
2743
|
+
}
|
|
2744
|
+
config = mergeRedacted(config, stored);
|
|
2745
|
+
}
|
|
2160
2746
|
sets.push(`config_encrypted = $${i++}`);
|
|
2161
|
-
params.push(
|
|
2747
|
+
params.push(config && Object.keys(config).length ? encryptDict(config, cryptoKey(engine)) : null);
|
|
2162
2748
|
} else if (UPDATABLE_COLUMNS.has(key)) {
|
|
2163
|
-
const col = key === "sourceId" ? "source_id" : key === "requiresApproval" ? "requires_approval" : key === "approvalPolicy" ? "approval_policy" : key;
|
|
2749
|
+
const col = key === "sourceId" ? "source_id" : key === "requiresApproval" ? "requires_approval" : key === "approvalPolicy" ? "approval_policy" : key === "metaData" ? "meta_data" : key;
|
|
2164
2750
|
sets.push(`${col} = $${i++}`);
|
|
2165
|
-
params.push(col === "approval_policy" ? JSON.stringify(value ?? {}) : value);
|
|
2751
|
+
params.push(col === "approval_policy" || col === "meta_data" ? JSON.stringify(value ?? {}) : value);
|
|
2166
2752
|
}
|
|
2167
2753
|
}
|
|
2168
2754
|
sets.push(`updated_at = now()`);
|
|
@@ -2171,7 +2757,8 @@ async function updateTool(engine, id, opts) {
|
|
|
2171
2757
|
`UPDATE context_engine_tools SET ${sets.join(", ")} WHERE id = $${i} RETURNING *`,
|
|
2172
2758
|
params
|
|
2173
2759
|
);
|
|
2174
|
-
|
|
2760
|
+
const row_ = updated.rows[0];
|
|
2761
|
+
return rowToToolConfigLenient(row_, engine) ?? rowToToolConfig(row_, engine, {});
|
|
2175
2762
|
}
|
|
2176
2763
|
async function deleteTool(engine, id, opts = {}) {
|
|
2177
2764
|
const existing = await engine.pool.query(`SELECT * FROM context_engine_tools WHERE id = $1`, [id]);
|
|
@@ -2238,17 +2825,38 @@ function probeFailure(exc, kind) {
|
|
|
2238
2825
|
console.warn(`test_tool(kind=${kind}) failed:`, exc, "->", category);
|
|
2239
2826
|
return { ok: false, error: category };
|
|
2240
2827
|
}
|
|
2241
|
-
async function testTool(
|
|
2828
|
+
async function testTool(engine, tc) {
|
|
2242
2829
|
const kind = tc.kind;
|
|
2243
2830
|
const config = tc.config ?? {};
|
|
2831
|
+
if (containsSentinel(config)) {
|
|
2832
|
+
throw new ConfigTemplateError(
|
|
2833
|
+
`config contains the ${JSON.stringify(REDACTED_SENTINEL)} placeholder \u2014 a probe would send it verbatim as the credential; re-enter the secret values`
|
|
2834
|
+
);
|
|
2835
|
+
}
|
|
2244
2836
|
if (kind === "http") {
|
|
2245
2837
|
const url = config.url;
|
|
2246
2838
|
if (!url) return { ok: false, error: "http config missing 'url'" };
|
|
2247
2839
|
try {
|
|
2248
|
-
|
|
2840
|
+
await assertEgressAllowed(url, { allowPrivate: allowPrivateEgress(engine) });
|
|
2841
|
+
} catch (exc) {
|
|
2842
|
+
if (exc instanceof EgressDenied) {
|
|
2843
|
+
console.warn(`testTool(kind=http) refused: ${exc.message}`);
|
|
2844
|
+
return { ok: false, error: "egress_denied" };
|
|
2845
|
+
}
|
|
2846
|
+
throw exc;
|
|
2847
|
+
}
|
|
2848
|
+
const client = engine?._toolHttpClient ?? null;
|
|
2849
|
+
try {
|
|
2850
|
+
const resp = client ? await client.request({
|
|
2851
|
+
method: "HEAD",
|
|
2852
|
+
url,
|
|
2853
|
+
headers: config.headers ?? {}
|
|
2854
|
+
}) : await fetch(url, {
|
|
2249
2855
|
method: "HEAD",
|
|
2250
2856
|
headers: config.headers ?? {},
|
|
2251
|
-
|
|
2857
|
+
// No automatic follow: a redirect would reach a destination the
|
|
2858
|
+
// check above never saw.
|
|
2859
|
+
redirect: "manual",
|
|
2252
2860
|
signal: AbortSignal.timeout(1e4)
|
|
2253
2861
|
});
|
|
2254
2862
|
return { ok: resp.status < 500, status_code: resp.status };
|
|
@@ -2269,12 +2877,16 @@ async function testTool(_engine, tc) {
|
|
|
2269
2877
|
const url = config.url;
|
|
2270
2878
|
if (!url) return { ok: false, error: "mcp config missing 'url'" };
|
|
2271
2879
|
const token = config.oauth_token ?? config.bearer ?? config.access_token;
|
|
2272
|
-
const mcp = new PromptevMCP();
|
|
2880
|
+
const mcp = new PromptevMCP({ allowPrivate: allowPrivateEgress(engine) });
|
|
2273
2881
|
try {
|
|
2274
2882
|
await mcp.addServer(tc.name, url, { token: token ?? null });
|
|
2275
2883
|
const client = mcp.clients.get(tc.name);
|
|
2276
2884
|
return { ok: true, tools: client.tools.map((t) => t.name) };
|
|
2277
2885
|
} catch (exc) {
|
|
2886
|
+
if (exc instanceof EgressDenied) {
|
|
2887
|
+
console.warn(`testTool(kind=mcp) refused: ${exc.message}`);
|
|
2888
|
+
return { ok: false, error: "egress_denied" };
|
|
2889
|
+
}
|
|
2278
2890
|
return probeFailure(exc, "mcp");
|
|
2279
2891
|
} finally {
|
|
2280
2892
|
await mcp.shutdown();
|
|
@@ -2282,11 +2894,20 @@ async function testTool(_engine, tc) {
|
|
|
2282
2894
|
}
|
|
2283
2895
|
return { ok: false, error: `test_tool does not support kind=${JSON.stringify(kind)}` };
|
|
2284
2896
|
}
|
|
2285
|
-
async function findAndClaimApproved(engine, toolName, args, sourceId) {
|
|
2897
|
+
async function findAndClaimApproved(engine, toolName, args, sourceId, principals, approvalScope) {
|
|
2286
2898
|
const claimedAt = /* @__PURE__ */ new Date();
|
|
2899
|
+
const params = [toolName];
|
|
2900
|
+
let scopeSql = "approval_scope IS NULL";
|
|
2901
|
+
if (approvalScope !== null) {
|
|
2902
|
+
params.push(approvalScope);
|
|
2903
|
+
scopeSql = `approval_scope = $${params.length}`;
|
|
2904
|
+
}
|
|
2905
|
+
const wall = claimVisibilitySql(principals, params.length + 1);
|
|
2906
|
+
params.push(...wall.params);
|
|
2287
2907
|
const candidates = await engine.pool.query(
|
|
2288
|
-
`SELECT * FROM context_engine_tool_approvals
|
|
2289
|
-
|
|
2908
|
+
`SELECT * FROM context_engine_tool_approvals
|
|
2909
|
+
WHERE tool_name = $1 AND status = 'approved' AND ${scopeSql} AND ${wall.sql}`,
|
|
2910
|
+
params
|
|
2290
2911
|
);
|
|
2291
2912
|
for (const row of candidates.rows) {
|
|
2292
2913
|
if ((row.source_id ?? null) !== (sourceId ?? null)) continue;
|
|
@@ -2303,7 +2924,7 @@ async function findAndClaimApproved(engine, toolName, args, sourceId) {
|
|
|
2303
2924
|
}
|
|
2304
2925
|
return null;
|
|
2305
2926
|
}
|
|
2306
|
-
async function dispatchMcp(ct, config, args) {
|
|
2927
|
+
async function dispatchMcp(ct, config, args, opts = {}) {
|
|
2307
2928
|
const url = config.url;
|
|
2308
2929
|
if (!url) throw new EngineActionError(`mcp tool ${ct.callName} config missing 'url'`);
|
|
2309
2930
|
const headers = { ...config.headers ?? {} };
|
|
@@ -2312,7 +2933,7 @@ async function dispatchMcp(ct, config, args) {
|
|
|
2312
2933
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
2313
2934
|
else if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
2314
2935
|
const toolName = String(ct.raw?.tool_name ?? ct.displayName);
|
|
2315
|
-
const mcp = new PromptevMCP();
|
|
2936
|
+
const mcp = new PromptevMCP({ allowPrivate: opts.allowPrivate ?? false });
|
|
2316
2937
|
try {
|
|
2317
2938
|
await mcp.addServer(ct.callName, url, { headers: Object.keys(headers).length ? headers : null });
|
|
2318
2939
|
const client = mcp.clients.get(ct.callName);
|
|
@@ -2323,7 +2944,10 @@ async function dispatchMcp(ct, config, args) {
|
|
|
2323
2944
|
}
|
|
2324
2945
|
async function dispatch(engine, ct, config, args) {
|
|
2325
2946
|
if (ct.kind === "http") {
|
|
2326
|
-
return executeHttp(config, args, {
|
|
2947
|
+
return executeHttp(config, args, {
|
|
2948
|
+
client: engine._toolHttpClient ?? null,
|
|
2949
|
+
allowPrivate: allowPrivateEgress(engine)
|
|
2950
|
+
});
|
|
2327
2951
|
}
|
|
2328
2952
|
if (ct.kind === "db") {
|
|
2329
2953
|
const query = String(args.query ?? "");
|
|
@@ -2338,7 +2962,9 @@ async function dispatch(engine, ct, config, args) {
|
|
|
2338
2962
|
}
|
|
2339
2963
|
return result;
|
|
2340
2964
|
}
|
|
2341
|
-
if (ct.kind === "mcp")
|
|
2965
|
+
if (ct.kind === "mcp") {
|
|
2966
|
+
return dispatchMcp(ct, config, args, { allowPrivate: allowPrivateEgress(engine) });
|
|
2967
|
+
}
|
|
2342
2968
|
if (ct.kind === "function") {
|
|
2343
2969
|
const fn = ct.raw?.callable;
|
|
2344
2970
|
if (!fn) throw new EngineActionError(`function tool ${ct.callName} has no callable`);
|
|
@@ -2359,10 +2985,20 @@ async function decryptCtConfig(engine, toolId) {
|
|
|
2359
2985
|
]);
|
|
2360
2986
|
const row = result.rows[0];
|
|
2361
2987
|
if (!row?.config_encrypted) return {};
|
|
2362
|
-
return decryptDict(String(row.config_encrypted),
|
|
2988
|
+
return decryptDict(String(row.config_encrypted), cryptoKey(engine));
|
|
2989
|
+
}
|
|
2990
|
+
var warnedUnscoped = /* @__PURE__ */ new Set();
|
|
2991
|
+
function warnUnscopedApproval(callName) {
|
|
2992
|
+
if (warnedUnscoped.has(callName)) return;
|
|
2993
|
+
warnedUnscoped.add(callName);
|
|
2994
|
+
process.emitWarning(
|
|
2995
|
+
`executeTool(${JSON.stringify(callName)}) needs an approval but was called without approvalScope. The record is opened and matched UNSCOPED (tool + args + sourceId, behind the principals wall). Pass approvalScope: <opaque string> (e.g. a run id) so approvals are claimable only within that scope; a gated call without one will be refused in the next release.`,
|
|
2996
|
+
{ type: "DeprecationWarning", code: "CE_APPROVAL_SCOPE_MISSING" }
|
|
2997
|
+
);
|
|
2363
2998
|
}
|
|
2364
2999
|
async function executeTool(engine, callName, args, opts = {}) {
|
|
2365
3000
|
const runtimeArgs = args ?? {};
|
|
3001
|
+
const approvalScope = validateApprovalScope(opts.approvalScope);
|
|
2366
3002
|
const ct = findTool(await mergedTools(engine, opts.sourceId ?? null), callName);
|
|
2367
3003
|
if (!ct) throw new EngineActionError(`tool not found: ${callName}`);
|
|
2368
3004
|
if (!toolVisible(ct, opts.principals ?? null)) {
|
|
@@ -2371,22 +3007,32 @@ async function executeTool(engine, callName, args, opts = {}) {
|
|
|
2371
3007
|
const publicArgs = stripUnderscoreArgs(runtimeArgs);
|
|
2372
3008
|
let approvalId = null;
|
|
2373
3009
|
if (shouldRequireApproval(ct, publicArgs)) {
|
|
2374
|
-
|
|
3010
|
+
if (approvalScope === null) warnUnscopedApproval(callName);
|
|
3011
|
+
approvalId = await findAndClaimApproved(
|
|
3012
|
+
engine,
|
|
3013
|
+
ct.callName,
|
|
3014
|
+
publicArgs,
|
|
3015
|
+
opts.sourceId ?? null,
|
|
3016
|
+
opts.principals ?? null,
|
|
3017
|
+
approvalScope
|
|
3018
|
+
);
|
|
2375
3019
|
if (approvalId == null) {
|
|
2376
|
-
const
|
|
3020
|
+
const pendingOpts = {
|
|
2377
3021
|
toolName: ct.callName,
|
|
2378
3022
|
args: publicArgs,
|
|
2379
3023
|
sourceId: opts.sourceId ?? null,
|
|
2380
3024
|
principals: opts.principals ?? null,
|
|
2381
3025
|
policy: ct.approvalPolicy ?? {}
|
|
2382
|
-
}
|
|
3026
|
+
};
|
|
3027
|
+
const record = approvalScope === null ? await createPending(engine, pendingOpts) : await findOrCreatePending(engine, { ...pendingOpts, approvalScope });
|
|
2383
3028
|
const reason = ct.requiresApproval ? "tool requires approval" : `approval policy condition met: ${ct.approvalPolicy?.condition}`;
|
|
2384
3029
|
return {
|
|
2385
3030
|
approval_required: {
|
|
2386
3031
|
approval_id: String(record.id),
|
|
2387
3032
|
tool_name: ct.callName,
|
|
2388
3033
|
args: publicArgs,
|
|
2389
|
-
reason
|
|
3034
|
+
reason,
|
|
3035
|
+
expires_at: record.expiresAt ? record.expiresAt.toISOString() : null
|
|
2390
3036
|
}
|
|
2391
3037
|
};
|
|
2392
3038
|
}
|
|
@@ -2411,8 +3057,9 @@ async function executeTool(engine, callName, args, opts = {}) {
|
|
|
2411
3057
|
let truncated = false;
|
|
2412
3058
|
if (success) {
|
|
2413
3059
|
try {
|
|
3060
|
+
const redactPrincipals = opts.principals === TRUSTED ? null : opts.principals ?? null;
|
|
2414
3061
|
const [redacted] = redactToolResult(rawResult, engine.config.redaction, {
|
|
2415
|
-
principals:
|
|
3062
|
+
principals: redactPrincipals,
|
|
2416
3063
|
secretKey: engine.config.secretKey,
|
|
2417
3064
|
hooks: engine.hooks
|
|
2418
3065
|
});
|
|
@@ -2457,6 +3104,9 @@ var DISCOVERY_TIMEOUT_MS = 1e4;
|
|
|
2457
3104
|
var TOKEN_TIMEOUT_MS = 15e3;
|
|
2458
3105
|
var PROBE_TIMEOUT_MS = 5e3;
|
|
2459
3106
|
var REGISTER_TIMEOUT_MS = 1e4;
|
|
3107
|
+
async function checkEgress2(url, allowPrivate) {
|
|
3108
|
+
await assertEgressAllowed(url, { allowPrivate });
|
|
3109
|
+
}
|
|
2460
3110
|
function report(hooks, exc, ctx) {
|
|
2461
3111
|
if (hooks) emitError(hooks, exc, ctx);
|
|
2462
3112
|
else console.error("[context-engine] mcp_oauth error:", exc, "| ctx=", ctx);
|
|
@@ -2487,11 +3137,16 @@ async function fetchJson(url, opts = {}) {
|
|
|
2487
3137
|
method: opts.method ?? "GET",
|
|
2488
3138
|
headers: opts.headers,
|
|
2489
3139
|
body: opts.body,
|
|
2490
|
-
|
|
3140
|
+
// Never followed: a redirect reaches a destination no egress check saw,
|
|
3141
|
+
// and on the token endpoint it would forward the client secret with it.
|
|
3142
|
+
redirect: opts.redirect ?? "manual",
|
|
2491
3143
|
signal: AbortSignal.timeout(opts.timeout ?? DISCOVERY_TIMEOUT_MS)
|
|
2492
3144
|
});
|
|
2493
3145
|
return {
|
|
3146
|
+
// `type` rides along so callers can tell an opaque redirect (status 0)
|
|
3147
|
+
// from a request that simply failed — see `isRedirect`.
|
|
2494
3148
|
status: resp.status,
|
|
3149
|
+
type: resp.type,
|
|
2495
3150
|
json: () => resp.json(),
|
|
2496
3151
|
text: () => resp.text()
|
|
2497
3152
|
};
|
|
@@ -2499,6 +3154,7 @@ async function fetchJson(url, opts = {}) {
|
|
|
2499
3154
|
async function discoverOauthMetadata(serverUrl, opts = {}) {
|
|
2500
3155
|
const parsed = new URL(serverUrl);
|
|
2501
3156
|
const base = `${parsed.protocol}//${parsed.host}`;
|
|
3157
|
+
await checkEgress2(base, opts.allowPrivate ?? false);
|
|
2502
3158
|
const wellKnownUrl = `${base}/.well-known/oauth-authorization-server`;
|
|
2503
3159
|
try {
|
|
2504
3160
|
const resp = await fetchJson(wellKnownUrl, { timeout: DISCOVERY_TIMEOUT_MS });
|
|
@@ -2549,7 +3205,7 @@ async function discoverOauthMetadata(serverUrl, opts = {}) {
|
|
|
2549
3205
|
timeout: PROBE_TIMEOUT_MS,
|
|
2550
3206
|
redirect: "manual"
|
|
2551
3207
|
});
|
|
2552
|
-
if (
|
|
3208
|
+
if (resp.status === 200 || resp.status === 400 || resp.status === 302 || resp.status === 303 || resp.type === "opaqueredirect") {
|
|
2553
3209
|
return {
|
|
2554
3210
|
authorization_endpoint: `${base}${authPath}`,
|
|
2555
3211
|
token_endpoint: `${base}${tokenPath}`,
|
|
@@ -2564,6 +3220,7 @@ async function discoverOauthMetadata(serverUrl, opts = {}) {
|
|
|
2564
3220
|
return null;
|
|
2565
3221
|
}
|
|
2566
3222
|
async function registerOauthClient(registrationEndpoint, redirectUri, clientName = "Promptev Context Engine", opts = {}) {
|
|
3223
|
+
await checkEgress2(registrationEndpoint, opts.allowPrivate ?? false);
|
|
2567
3224
|
const payload = {
|
|
2568
3225
|
client_name: clientName,
|
|
2569
3226
|
redirect_uris: [redirectUri],
|
|
@@ -2634,6 +3291,7 @@ async function exchangeCodeForToken(state, code, store, opts = {}) {
|
|
|
2634
3291
|
if (!pending) throw new Error("Invalid or expired OAuth state \u2014 please try connecting again");
|
|
2635
3292
|
const tokenEndpoint = pending.token_endpoint;
|
|
2636
3293
|
if (!tokenEndpoint) throw new Error("No token_endpoint in stored OAuth state");
|
|
3294
|
+
await checkEgress2(tokenEndpoint, opts.allowPrivate ?? false);
|
|
2637
3295
|
const body = new URLSearchParams({
|
|
2638
3296
|
grant_type: "authorization_code",
|
|
2639
3297
|
code,
|
|
@@ -2697,8 +3355,8 @@ function coerceUuid(value) {
|
|
|
2697
3355
|
}
|
|
2698
3356
|
return value;
|
|
2699
3357
|
}
|
|
2700
|
-
function toolRowPublic(row) {
|
|
2701
|
-
|
|
3358
|
+
function toolRowPublic(row, opts = {}) {
|
|
3359
|
+
const out = {
|
|
2702
3360
|
id: String(row.id),
|
|
2703
3361
|
name: row.name,
|
|
2704
3362
|
kind: row.kind,
|
|
@@ -2707,8 +3365,36 @@ function toolRowPublic(row) {
|
|
|
2707
3365
|
acl: row.acl != null ? [...row.acl] : null,
|
|
2708
3366
|
requires_approval: row.requires_approval,
|
|
2709
3367
|
approval_policy: row.approval_policy ?? {},
|
|
2710
|
-
enabled: row.enabled
|
|
3368
|
+
enabled: row.enabled,
|
|
3369
|
+
// Engine-opaque user metadata — admin surface only (the agent-facing
|
|
3370
|
+
// listTools/searchTools views never carry it), returned in clear:
|
|
3371
|
+
// secrets belong in `config`.
|
|
3372
|
+
meta_data: row.meta_data ?? {}
|
|
2711
3373
|
};
|
|
3374
|
+
if (opts.template && row.config_encrypted) {
|
|
3375
|
+
try {
|
|
3376
|
+
if (!opts.templateKey) throw new Error("no secret key configured");
|
|
3377
|
+
out.config_redacted = redactConfig(
|
|
3378
|
+
String(row.kind),
|
|
3379
|
+
decryptDict(String(row.config_encrypted), opts.templateKey)
|
|
3380
|
+
);
|
|
3381
|
+
} catch {
|
|
3382
|
+
out.config_error = "undecryptable";
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
return out;
|
|
3386
|
+
}
|
|
3387
|
+
function escapeHtml(text) {
|
|
3388
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3389
|
+
}
|
|
3390
|
+
function authorizeToolAcl(requested, principals) {
|
|
3391
|
+
if (requested == null && principals !== TRUSTED) {
|
|
3392
|
+
throw new HandlerError(
|
|
3393
|
+
403,
|
|
3394
|
+
"a tool without an acl is visible to every principal; only a trusted mount may register one \u2014 pass acl with principals you hold"
|
|
3395
|
+
);
|
|
3396
|
+
}
|
|
3397
|
+
authorizeAcl(requested, principals);
|
|
2712
3398
|
}
|
|
2713
3399
|
function resolveCaller(value, surface) {
|
|
2714
3400
|
return resolveRequestPrincipals(value, { surface });
|
|
@@ -2734,10 +3420,27 @@ function popupCloseHtml(success, message, targetOrigin, tokenData = null) {
|
|
|
2734
3420
|
if (tokenData && success) payload.token = tokenData;
|
|
2735
3421
|
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
|
2736
3422
|
const title = success ? "Connected" : "Failed";
|
|
2737
|
-
return `<!DOCTYPE html><html><head><title>Context Engine MCP OAuth</title></head><body style="font-family:system-ui;text-align:center;padding:40px;"><h2>${title}</h2><p>${message}</p><p style="font-size:12px;">This window will close automatically…</p><script>try { var d = JSON.parse(atob("${encoded}")); if (window.opener) window.opener.postMessage(d, "${targetOrigin}");} catch (e) { console.error('postMessage error', e); }setTimeout(function () { window.close(); }, 1500);</script></body></html>`;
|
|
3423
|
+
return `<!DOCTYPE html><html><head><title>Context Engine MCP OAuth</title></head><body style="font-family:system-ui;text-align:center;padding:40px;"><h2>${title}</h2><p>${escapeHtml(message)}</p><p style="font-size:12px;">This window will close automatically…</p><script>try { var d = JSON.parse(atob("${encoded}")); if (window.opener) window.opener.postMessage(d, "${targetOrigin}");} catch (e) { console.error('postMessage error', e); }setTimeout(function () { window.close(); }, 1500);</script></body></html>`;
|
|
2738
3424
|
}
|
|
2739
3425
|
function createToolsHandlers(engine, opts = {}) {
|
|
2740
3426
|
const store = opts.pendingStore ?? new InMemoryPendingStore();
|
|
3427
|
+
const configTemplate = opts.configTemplate ?? false;
|
|
3428
|
+
const templateKey = () => {
|
|
3429
|
+
if (!configTemplate) return null;
|
|
3430
|
+
let key;
|
|
3431
|
+
try {
|
|
3432
|
+
key = getSecretKey(engine.config);
|
|
3433
|
+
} catch {
|
|
3434
|
+
return null;
|
|
3435
|
+
}
|
|
3436
|
+
if (key.length !== 32) {
|
|
3437
|
+
console.warn(
|
|
3438
|
+
`CE_SECRET_KEY is malformed: decoded to ${key.length} bytes, need 32; tool config templates will degrade to config_error`
|
|
3439
|
+
);
|
|
3440
|
+
return null;
|
|
3441
|
+
}
|
|
3442
|
+
return key;
|
|
3443
|
+
};
|
|
2741
3444
|
const redirectBaseUrl = (opts.redirectBaseUrl ?? "").replace(/\/$/, "");
|
|
2742
3445
|
let targetOrigin = "";
|
|
2743
3446
|
try {
|
|
@@ -2750,11 +3453,12 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2750
3453
|
async createTool(body, principals) {
|
|
2751
3454
|
const caller = resolveCaller(principals, "POST /tools");
|
|
2752
3455
|
const tc = ToolConfig.fromUnknown(body);
|
|
2753
|
-
|
|
3456
|
+
authorizeToolAcl(tc.acl, caller);
|
|
2754
3457
|
try {
|
|
2755
3458
|
const toolId = engine.registerTool ? await engine.registerTool(tc) : await registerTool(engine, tc);
|
|
2756
3459
|
return { id: toolId };
|
|
2757
3460
|
} catch (exc) {
|
|
3461
|
+
if (exc instanceof ConfigTemplateError) throw new HandlerError(400, exc.message);
|
|
2758
3462
|
if (exc instanceof Error && /canonicalFromConfig|kind=/.test(exc.message)) {
|
|
2759
3463
|
throw new HandlerError(400, String(exc));
|
|
2760
3464
|
}
|
|
@@ -2770,7 +3474,8 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2770
3474
|
sql += ` WHERE source_id = $1`;
|
|
2771
3475
|
}
|
|
2772
3476
|
const result = await engine.pool.query(sql, params);
|
|
2773
|
-
const
|
|
3477
|
+
const key = templateKey();
|
|
3478
|
+
const tools = result.rows.filter((row) => aclVisible(row.acl != null ? [...row.acl] : null, caller)).map((row) => toolRowPublic(row, { template: configTemplate, templateKey: key }));
|
|
2774
3479
|
return { tools };
|
|
2775
3480
|
},
|
|
2776
3481
|
async getTool(toolId, principals) {
|
|
@@ -2781,7 +3486,7 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2781
3486
|
if (!row || !aclVisible(row.acl != null ? [...row.acl] : null, caller)) {
|
|
2782
3487
|
throw new HandlerError(404, `tool not found: ${toolId}`);
|
|
2783
3488
|
}
|
|
2784
|
-
return toolRowPublic(row);
|
|
3489
|
+
return toolRowPublic(row, { template: configTemplate, templateKey: templateKey() });
|
|
2785
3490
|
},
|
|
2786
3491
|
async updateToolRoute(toolId, body, principals) {
|
|
2787
3492
|
const caller = resolveCaller(principals, "PATCH /tools/{tool_id}");
|
|
@@ -2796,12 +3501,31 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2796
3501
|
acl: "acl",
|
|
2797
3502
|
requires_approval: "requiresApproval",
|
|
2798
3503
|
approval_policy: "approvalPolicy",
|
|
2799
|
-
enabled: "enabled"
|
|
3504
|
+
enabled: "enabled",
|
|
3505
|
+
meta_data: "metaData"
|
|
2800
3506
|
};
|
|
2801
3507
|
for (const [k, dest] of Object.entries(map)) {
|
|
2802
3508
|
if (Object.hasOwn(body, k)) fields[dest] = body[k];
|
|
2803
3509
|
}
|
|
2804
|
-
|
|
3510
|
+
const wireName = {
|
|
3511
|
+
requiresApproval: "requires_approval",
|
|
3512
|
+
approvalPolicy: "approval_policy",
|
|
3513
|
+
metaData: "meta_data"
|
|
3514
|
+
};
|
|
3515
|
+
for (const field of [
|
|
3516
|
+
"name",
|
|
3517
|
+
"kind",
|
|
3518
|
+
"description",
|
|
3519
|
+
"requiresApproval",
|
|
3520
|
+
"approvalPolicy",
|
|
3521
|
+
"enabled",
|
|
3522
|
+
"metaData"
|
|
3523
|
+
]) {
|
|
3524
|
+
if (field in fields && fields[field] == null) {
|
|
3525
|
+
throw new HandlerError(400, `${wireName[field] ?? field} cannot be null`);
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
if ("acl" in fields) authorizeToolAcl(fields.acl, caller);
|
|
2805
3529
|
try {
|
|
2806
3530
|
const updated = engine.updateTool ? await engine.updateTool(toolId, { principals: caller, ...fields }) : await updateTool(engine, toolId, { principals: caller, ...fields });
|
|
2807
3531
|
return {
|
|
@@ -2813,9 +3537,11 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2813
3537
|
acl: updated.acl,
|
|
2814
3538
|
requires_approval: updated.requiresApproval,
|
|
2815
3539
|
approval_policy: updated.approvalPolicy,
|
|
2816
|
-
enabled: updated.enabled
|
|
3540
|
+
enabled: updated.enabled,
|
|
3541
|
+
meta_data: updated.metaData
|
|
2817
3542
|
};
|
|
2818
3543
|
} catch (exc) {
|
|
3544
|
+
if (exc instanceof ConfigTemplateError) throw new HandlerError(400, exc.message);
|
|
2819
3545
|
if (exc instanceof EngineActionError) throw new HandlerError(404, String(exc.message));
|
|
2820
3546
|
throw exc;
|
|
2821
3547
|
}
|
|
@@ -2834,7 +3560,12 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2834
3560
|
},
|
|
2835
3561
|
async testToolRoute(body) {
|
|
2836
3562
|
const tc = ToolConfig.fromUnknown(body);
|
|
2837
|
-
|
|
3563
|
+
try {
|
|
3564
|
+
return await (engine.testTool ? engine.testTool(tc) : testTool(engine, tc));
|
|
3565
|
+
} catch (exc) {
|
|
3566
|
+
if (exc instanceof ConfigTemplateError) throw new HandlerError(400, exc.message);
|
|
3567
|
+
throw exc;
|
|
3568
|
+
}
|
|
2838
3569
|
},
|
|
2839
3570
|
toolSchemaRoute(kind) {
|
|
2840
3571
|
try {
|
|
@@ -2843,14 +3574,30 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2843
3574
|
throw new HandlerError(404, `unknown tool kind: ${JSON.stringify(kind)}`);
|
|
2844
3575
|
}
|
|
2845
3576
|
},
|
|
2846
|
-
|
|
3577
|
+
/**
|
|
3578
|
+
* `approvalScope` is resolved by the adapter's `approvalScope` option —
|
|
3579
|
+
* SERVER-SIDE, never from the body. A body key of the same name is
|
|
3580
|
+
* ignored exactly like a body `principals`: a client that could name
|
|
3581
|
+
* its own scope could claim another scope's approvals. Absent, the
|
|
3582
|
+
* execution runs unscoped (the deprecated legacy path). A malformed
|
|
3583
|
+
* scope is the host's wiring bug, so it surfaces as a 500, never a 4xx
|
|
3584
|
+
* the client might "fix" by changing its body.
|
|
3585
|
+
*/
|
|
3586
|
+
async executeToolRoute(body, principals, approvalScope = null) {
|
|
2847
3587
|
const caller = resolveCaller(principals, "POST /tools/execute");
|
|
2848
3588
|
if (!body.call_name) throw new HandlerError(422, "call_name is required");
|
|
3589
|
+
let scope;
|
|
3590
|
+
try {
|
|
3591
|
+
scope = validateApprovalScope(approvalScope);
|
|
3592
|
+
} catch (exc) {
|
|
3593
|
+
throw new HandlerError(500, `approvalScope resolver: ${exc.message}`);
|
|
3594
|
+
}
|
|
2849
3595
|
try {
|
|
2850
3596
|
const exec = engine.executeTool ?? ((n, a, o) => executeTool(engine, n, a, o));
|
|
2851
3597
|
return await exec(body.call_name, body.args ?? null, {
|
|
2852
3598
|
sourceId: body.source_id,
|
|
2853
|
-
principals: caller
|
|
3599
|
+
principals: caller,
|
|
3600
|
+
approvalScope: scope
|
|
2854
3601
|
});
|
|
2855
3602
|
} catch (exc) {
|
|
2856
3603
|
if (exc instanceof EngineActionError) throw new HandlerError(404, String(exc.message));
|
|
@@ -2858,7 +3605,7 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2858
3605
|
}
|
|
2859
3606
|
},
|
|
2860
3607
|
async mcpConnectAndList(body) {
|
|
2861
|
-
const mcp = new PromptevMCP();
|
|
3608
|
+
const mcp = new PromptevMCP({ allowPrivate: allowPrivateEgress(engine) });
|
|
2862
3609
|
try {
|
|
2863
3610
|
await mcp.addServer(body.server_name, body.url, { token: body.token, headers: body.headers });
|
|
2864
3611
|
const client = mcp.clients.get(body.server_name);
|
|
@@ -2873,10 +3620,20 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2873
3620
|
requires_oauth: false
|
|
2874
3621
|
};
|
|
2875
3622
|
} catch (exc) {
|
|
3623
|
+
if (exc instanceof EgressDenied) throw new HandlerError(400, String(exc.message));
|
|
2876
3624
|
const msg = String(exc);
|
|
2877
3625
|
const statusMatch = /HTTP (401|403)/.exec(msg);
|
|
2878
3626
|
if (statusMatch || /401|403/.test(msg)) {
|
|
2879
|
-
|
|
3627
|
+
let metadata;
|
|
3628
|
+
try {
|
|
3629
|
+
metadata = await discoverOauthMetadata(body.url, {
|
|
3630
|
+
hooks: engine.hooks,
|
|
3631
|
+
allowPrivate: allowPrivateEgress(engine)
|
|
3632
|
+
});
|
|
3633
|
+
} catch (denied) {
|
|
3634
|
+
if (denied instanceof EgressDenied) throw new HandlerError(400, String(denied.message));
|
|
3635
|
+
throw denied;
|
|
3636
|
+
}
|
|
2880
3637
|
if (metadata) {
|
|
2881
3638
|
return {
|
|
2882
3639
|
server: body.server_name,
|
|
@@ -2902,7 +3659,16 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2902
3659
|
if (!redirectUri) {
|
|
2903
3660
|
throw new HandlerError(500, "redirectBaseUrl is required to start MCP OAuth");
|
|
2904
3661
|
}
|
|
2905
|
-
|
|
3662
|
+
let metadata;
|
|
3663
|
+
try {
|
|
3664
|
+
metadata = await discoverOauthMetadata(body.server_url, {
|
|
3665
|
+
hooks: engine.hooks,
|
|
3666
|
+
allowPrivate: allowPrivateEgress(engine)
|
|
3667
|
+
});
|
|
3668
|
+
} catch (exc) {
|
|
3669
|
+
if (exc instanceof EgressDenied) throw new HandlerError(400, String(exc.message));
|
|
3670
|
+
throw exc;
|
|
3671
|
+
}
|
|
2906
3672
|
if (!metadata) {
|
|
2907
3673
|
throw new HandlerError(502, "MCP server does not support OAuth \u2014 no authorization endpoint found");
|
|
2908
3674
|
}
|
|
@@ -2914,11 +3680,12 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2914
3680
|
String(metadata.registration_endpoint),
|
|
2915
3681
|
redirectUri,
|
|
2916
3682
|
"Promptev Context Engine",
|
|
2917
|
-
{ hooks: engine.hooks }
|
|
3683
|
+
{ hooks: engine.hooks, allowPrivate: allowPrivateEgress(engine) }
|
|
2918
3684
|
);
|
|
2919
3685
|
clientId = reg.client_id ?? null;
|
|
2920
3686
|
clientSecret = reg.client_secret || clientSecret;
|
|
2921
|
-
} catch {
|
|
3687
|
+
} catch (exc) {
|
|
3688
|
+
if (exc instanceof EgressDenied) throw new HandlerError(400, String(exc.message));
|
|
2922
3689
|
}
|
|
2923
3690
|
}
|
|
2924
3691
|
try {
|
|
@@ -2941,7 +3708,8 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2941
3708
|
}
|
|
2942
3709
|
try {
|
|
2943
3710
|
const tokenResult = await exchangeCodeForToken(query.state, query.code, store, {
|
|
2944
|
-
hooks: engine.hooks
|
|
3711
|
+
hooks: engine.hooks,
|
|
3712
|
+
allowPrivate: allowPrivateEgress(engine)
|
|
2945
3713
|
});
|
|
2946
3714
|
return {
|
|
2947
3715
|
html: html(true, "Connected successfully", {
|
|
@@ -3119,7 +3887,13 @@ function createExpressRouter(engine, opts) {
|
|
|
3119
3887
|
);
|
|
3120
3888
|
router.post(
|
|
3121
3889
|
"/tools/execute",
|
|
3122
|
-
wrap(
|
|
3890
|
+
wrap(
|
|
3891
|
+
async (req) => tools.executeToolRoute(
|
|
3892
|
+
req.body,
|
|
3893
|
+
await opts.principals(req),
|
|
3894
|
+
opts.approvalScope ? await opts.approvalScope(req) : null
|
|
3895
|
+
)
|
|
3896
|
+
)
|
|
3123
3897
|
);
|
|
3124
3898
|
router.get(
|
|
3125
3899
|
"/tools/:tool_id",
|