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