@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/hono.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createRequire } from 'module';
2
2
  import { z } from 'zod';
3
- import { randomBytes, createHash, randomUUID, createCipheriv, createDecipheriv, createHmac } from 'crypto';
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 sqlUpper = stripSqlNoise(sql).trim().toUpperCase();
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(sqlUpper, keyword)) {
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 (!sqlUpper.startsWith("SELECT") && !sqlUpper.startsWith("WITH")) {
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(sqlUpper, keyword)) {
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(sqlUpper, keyword)) {
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 = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE"];
350
- READWRITE_BLOCKED = ["DELETE", "DROP", "TRUNCATE"];
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 = /'(?:[^']|'')*'|\$([A-Za-z_]\w*)?\$.*?\$\1?\$|--[^\n]*|\/\*[\s\S]*?\*\//g;
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 TrustedSentinel = class {
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 null;
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: row.expires_at ? new Date(String(row.expires_at)) : null,
686
- resolvedAt: row.resolved_at ? new Date(String(row.resolved_at)) : null,
687
- createdAt: row.created_at ? new Date(String(row.created_at)) : null
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
- async function createPending(engine, opts) {
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',$6,$7)`,
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
- opts.principals === void 0 ? null : JSON.stringify(opts.principals),
717
- expiresAt,
718
- createdAt
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
- principals: opts.principals ?? null,
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
- constructor(url, headers = null) {
1613
+ allowPrivate;
1614
+ constructor(url, headers = null, opts = {}) {
1166
1615
  this.url = url;
1167
1616
  this.headers = headers ?? {};
1168
- this.transport = _MCPClient.createTransport(url, this.headers);
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://")) return new WsTransport(url, headers);
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
- await this.transport.connect();
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
- redirect: "follow",
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.text();
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
- const requestInit = {
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 resp = client ? await client.request(requestInit) : await defaultRequest(requestInit);
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 data;
2366
+ let text = "";
1860
2367
  try {
1861
- data = contentType.includes("application/json") ? await resp.json() : await resp.text();
2368
+ text = await resp.text();
1862
2369
  } catch {
1863
- data = await resp.text();
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
- return {
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 rowToToolConfig(row, engine) {
2087
- let config = {};
2088
- if (row.config_encrypted) {
2089
- config = decryptDict(String(row.config_encrypted), getSecretKey(engine.config));
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
- canonicals.push(...canonicalFromConfig(rowToToolConfig(row, engine)));
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
- const configEncrypted = Object.keys(config).length ? encryptDict(config, getSecretKey(engine.config)) : null;
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(value ? encryptDict(value, getSecretKey(engine.config)) : null);
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
- return rowToToolConfig(updated.rows[0], engine);
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(_engine, tc) {
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
- const resp = await fetch(url, {
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
- redirect: "follow",
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 WHERE tool_name = $1 AND status = 'approved'`,
2289
- [toolName]
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, { client: engine._toolHttpClient ?? null });
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") return dispatchMcp(ct, config, args);
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), getSecretKey(engine.config));
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
- approvalId = await findAndClaimApproved(engine, ct.callName, publicArgs, opts.sourceId ?? null);
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 record = await createPending(engine, {
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: opts.principals ?? null,
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
- redirect: opts.redirect ?? "follow",
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 ([200, 302, 303, 400].includes(resp.status)) {
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
- return {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
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&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>`;
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&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>`;
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
- authorizeAcl(tc.acl, caller);
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 tools = result.rows.filter((row) => aclVisible(row.acl != null ? [...row.acl] : null, caller)).map(toolRowPublic);
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
- if ("acl" in fields) authorizeAcl(fields.acl, caller);
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
- return engine.testTool ? engine.testTool(tc) : testTool(engine, tc);
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
- async executeToolRoute(body, principals) {
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
- const metadata = await discoverOauthMetadata(body.url, { hooks: engine.hooks });
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
- const metadata = await discoverOauthMetadata(body.server_url, { hooks: engine.hooks });
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", {
@@ -3118,7 +3886,11 @@ function createHonoRouter(engine, opts) {
3118
3886
  app.post(
3119
3887
  "/tools/execute",
3120
3888
  wrap(
3121
- async (c) => tools.executeToolRoute(await c.req.json(), await opts.principals(c))
3889
+ async (c) => tools.executeToolRoute(
3890
+ await c.req.json(),
3891
+ await opts.principals(c),
3892
+ opts.approvalScope ? await opts.approvalScope(c) : null
3893
+ )
3122
3894
  )
3123
3895
  );
3124
3896
  app.get(