@sanctuary-framework/mcp-server 1.2.3 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -4261,6 +4261,10 @@ var DEFAULT_CHANNEL = {
4261
4261
  // SEC-002: auto_deny is not configurable. Timeout always denies.
4262
4262
  // Field omitted intentionally — all channels hardcode deny on timeout.
4263
4263
  };
4264
+ var DEFAULT_APPROVAL_REDIRECT = {
4265
+ enabled: false,
4266
+ mode: "replace"
4267
+ };
4264
4268
  var DEFAULT_POLICY = {
4265
4269
  version: 1,
4266
4270
  tier1_always_approve: [
@@ -4334,6 +4338,7 @@ var DEFAULT_POLICY = {
4334
4338
  "handshake_status",
4335
4339
  "handshake_exchange",
4336
4340
  "handshake_verify_attestation",
4341
+ "handshake_abort",
4337
4342
  "reputation_query_weighted",
4338
4343
  "federation_peers",
4339
4344
  "federation_trust_evaluate",
@@ -4375,7 +4380,8 @@ var DEFAULT_POLICY = {
4375
4380
  "compliance_eu_ai_act_annex_iii_classify"
4376
4381
  // Read-only; rule-based Annex III classifier
4377
4382
  ],
4378
- approval_channel: DEFAULT_CHANNEL
4383
+ approval_channel: DEFAULT_CHANNEL,
4384
+ approval_redirect: DEFAULT_APPROVAL_REDIRECT
4379
4385
  };
4380
4386
  function extractOperationName(toolName) {
4381
4387
  if (toolName.startsWith("proxy/")) {
@@ -4472,9 +4478,35 @@ function validatePolicy(raw) {
4472
4478
  };
4473
4479
  delete merged.auto_deny;
4474
4480
  return merged;
4475
- })()
4481
+ })(),
4482
+ approval_redirect: parseApprovalRedirect(raw.approval_redirect)
4476
4483
  };
4477
4484
  }
4485
+ function parseApprovalRedirect(raw) {
4486
+ if (raw === void 0 || raw === null) {
4487
+ return { ...DEFAULT_APPROVAL_REDIRECT };
4488
+ }
4489
+ if (typeof raw !== "object") {
4490
+ return { ...DEFAULT_APPROVAL_REDIRECT };
4491
+ }
4492
+ const obj = raw;
4493
+ const enabled = typeof obj.enabled === "boolean" ? obj.enabled : DEFAULT_APPROVAL_REDIRECT.enabled;
4494
+ const modeRaw = obj.mode;
4495
+ let mode = DEFAULT_APPROVAL_REDIRECT.mode;
4496
+ if (modeRaw !== void 0) {
4497
+ if (modeRaw !== "replace" && modeRaw !== "notify") {
4498
+ throw new Error(
4499
+ `approval_redirect.mode must be "replace" or "notify" (got ${JSON.stringify(modeRaw)})`
4500
+ );
4501
+ }
4502
+ mode = modeRaw;
4503
+ }
4504
+ const result = { enabled, mode };
4505
+ if (obj.per_agent !== void 0 && typeof obj.per_agent === "object" && obj.per_agent !== null) {
4506
+ result.per_agent = obj.per_agent;
4507
+ }
4508
+ return result;
4509
+ }
4478
4510
  function generateDefaultPolicyYaml() {
4479
4511
  return `# Sanctuary Principal Policy v1
4480
4512
  # This file controls what your agent can do without asking.
@@ -4553,6 +4585,7 @@ tier3_always_allow:
4553
4585
  - handshake_status
4554
4586
  - handshake_exchange
4555
4587
  - handshake_verify_attestation
4588
+ - handshake_abort
4556
4589
  - reputation_query_weighted
4557
4590
  - federation_peers
4558
4591
  - federation_trust_evaluate
@@ -4587,22 +4620,69 @@ tier3_always_allow:
4587
4620
  approval_channel:
4588
4621
  type: stderr
4589
4622
  timeout_seconds: 300
4623
+
4624
+ # \u2500\u2500\u2500 Approval Redirect (v1.3 WP-V1.3-10 Upsilon-2) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4625
+ # Cross-harness approval-inbox redirect. When enabled, Tier 1/2 approvals
4626
+ # resolve via the unified approval inbox at /api/approval-inbox/* instead
4627
+ # of (or in addition to) the configured approval_channel above.
4628
+ #
4629
+ # mode:
4630
+ # replace: bypass the approval_channel entirely; the gate awaits a
4631
+ # decision from the inbox (default once enabled).
4632
+ # notify: fire BOTH the approval_channel and the inbox; first decision
4633
+ # wins. Right shape for harnesses that cannot fully suppress
4634
+ # their local approval prompt (e.g. Mastra-class).
4635
+ approval_redirect:
4636
+ enabled: false
4637
+ mode: replace
4590
4638
  `;
4591
4639
  }
4640
+ var MalformedPrincipalPolicyError = class extends Error {
4641
+ constructor(policyPath, reason) {
4642
+ super(
4643
+ `Principal policy at ${policyPath} is malformed and cannot be loaded.
4644
+ Reason: ${reason}
4645
+ Sanctuary refuses to substitute a default policy when an existing file is present, to avoid silently overriding operator intent. Fix the file or delete it to regenerate the default.`
4646
+ );
4647
+ this.policyPath = policyPath;
4648
+ this.reason = reason;
4649
+ this.name = "MalformedPrincipalPolicyError";
4650
+ }
4651
+ policyPath;
4652
+ reason;
4653
+ };
4592
4654
  async function loadPrincipalPolicy(storagePath) {
4593
4655
  const policyPath = path.join(storagePath, "principal-policy.yaml");
4656
+ let content;
4657
+ try {
4658
+ content = await promises.readFile(policyPath, "utf-8");
4659
+ } catch (err) {
4660
+ const code = err?.code;
4661
+ if (code === "ENOENT") {
4662
+ const defaultYaml = generateDefaultPolicyYaml();
4663
+ try {
4664
+ await promises.writeFile(policyPath, defaultYaml, "utf-8");
4665
+ await promises.chmod(policyPath, 384);
4666
+ } catch (writeErr) {
4667
+ console.warn(
4668
+ `Sanctuary: could not write default principal policy to ${policyPath}: ${writeErr.message}. Continuing with in-memory default.`
4669
+ );
4670
+ }
4671
+ return Object.freeze({ ...DEFAULT_POLICY });
4672
+ }
4673
+ throw new MalformedPrincipalPolicyError(
4674
+ policyPath,
4675
+ `read failed: ${err.message}`
4676
+ );
4677
+ }
4594
4678
  try {
4595
- const content = await promises.readFile(policyPath, "utf-8");
4596
4679
  const policy = parsePolicy(content);
4597
4680
  return Object.freeze(policy);
4598
- } catch {
4599
- const defaultYaml = generateDefaultPolicyYaml();
4600
- try {
4601
- await promises.writeFile(policyPath, defaultYaml, "utf-8");
4602
- await promises.chmod(policyPath, 384);
4603
- } catch {
4604
- }
4605
- return Object.freeze({ ...DEFAULT_POLICY });
4681
+ } catch (parseErr) {
4682
+ throw new MalformedPrincipalPolicyError(
4683
+ policyPath,
4684
+ parseErr.message
4685
+ );
4606
4686
  }
4607
4687
  }
4608
4688
 
@@ -4829,7 +4909,7 @@ function deepSortKeys(obj) {
4829
4909
  return sorted;
4830
4910
  }
4831
4911
  function canonicalizeForSigning(body) {
4832
- return JSON.stringify(deepSortKeys(body));
4912
+ return JSON.stringify(deepSortKeys(body)).normalize("NFC");
4833
4913
  }
4834
4914
 
4835
4915
  // src/shr/generator.ts
@@ -11651,6 +11731,16 @@ var HUB_ROUTES = {
11651
11731
  */
11652
11732
  CHAT_CONCIERGE_SEND: "/api/hub/chat/concierge",
11653
11733
  CHAT_CONCIERGE_HISTORY: "/api/hub/chat/concierge/history",
11734
+ /**
11735
+ * Concierge memory thread routes (WP-V1.3-9 Tau-1). Thread enumeration,
11736
+ * scrollback, and operator-initiated thread delete. Distinct from the
11737
+ * v1.2 `/history` route, which surfaces the active in-session thread
11738
+ * shape; the new routes target persisted multi-thread memory used by
11739
+ * v1.3 conversational sovereignty depth.
11740
+ */
11741
+ CHAT_CONCIERGE_THREADS_LIST: "/api/hub/chat/concierge/threads",
11742
+ CHAT_CONCIERGE_THREAD_READ: "/api/hub/chat/concierge/threads/:thread_id",
11743
+ CHAT_CONCIERGE_THREAD_DELETE: "/api/hub/chat/concierge/threads/:thread_id",
11654
11744
  /**
11655
11745
  * Click-to-inspect panel (WP-V1.2 reshape). Returns the agent's
11656
11746
  * recent activity feed, pending Tier 1 approvals routed through this
@@ -11674,6 +11764,10 @@ var HUB_TIER_1_AGENT_CONTROL_ACTIONS = [
11674
11764
  ];
11675
11765
  var HUB_ACTIVITY_DEFAULT_LIMIT = 50;
11676
11766
  var HUB_ACTIVITY_MAX_LIMIT = 500;
11767
+ var HUB_CHAT_THREADS_DEFAULT_LIMIT = 50;
11768
+ var HUB_CHAT_THREADS_MAX_LIMIT = 500;
11769
+ var HUB_CHAT_TURNS_DEFAULT_LIMIT = 200;
11770
+ var HUB_CHAT_TURNS_MAX_LIMIT = 1e3;
11677
11771
  var HUB_INBOX_DEFAULT_LIMIT = 100;
11678
11772
  var HUB_INBOX_MAX_LIMIT = 500;
11679
11773
  var HUB_AGENTS_DEFAULT_LIMIT = 100;
@@ -11845,6 +11939,23 @@ function checkChatMessage(value) {
11845
11939
  }
11846
11940
  return trimmed;
11847
11941
  }
11942
+ function matchConciergeThreadRoute(path) {
11943
+ const prefix = `${HUB_API_PREFIX}/chat/concierge/threads/`;
11944
+ if (!path.startsWith(prefix)) return null;
11945
+ const rest = path.slice(prefix.length);
11946
+ if (rest.length === 0 || rest.includes("/")) return null;
11947
+ const decoded = decodeURIComponent(rest);
11948
+ if (decoded.length === 0) return null;
11949
+ return { threadId: decoded };
11950
+ }
11951
+ function parseSince(raw) {
11952
+ if (raw === null || raw === "") return void 0;
11953
+ const parsed = Number.parseInt(raw, 10);
11954
+ if (Number.isNaN(parsed) || parsed < 0) {
11955
+ throw new HubValidationError("since must be a non-negative integer");
11956
+ }
11957
+ return parsed;
11958
+ }
11848
11959
  function matchInboxRoute(path) {
11849
11960
  const prefix = `${HUB_API_PREFIX}/inbox/`;
11850
11961
  if (!path.startsWith(prefix)) return null;
@@ -12028,6 +12139,47 @@ async function handleHubRoute(deps, req, res) {
12028
12139
  writeJSON2(res, 200, { ok: true, data: { messages } });
12029
12140
  return true;
12030
12141
  }
12142
+ if (method === "GET" && path === HUB_ROUTES.CHAT_CONCIERGE_THREADS_LIST) {
12143
+ const limit = parseLimit(
12144
+ url.searchParams.get("limit"),
12145
+ HUB_CHAT_THREADS_DEFAULT_LIMIT,
12146
+ HUB_CHAT_THREADS_MAX_LIMIT
12147
+ );
12148
+ const threads = await deps.service.listConciergeMemoryThreads({ limit });
12149
+ writeJSON2(res, 200, { ok: true, data: { threads } });
12150
+ return true;
12151
+ }
12152
+ {
12153
+ const threadMatch = matchConciergeThreadRoute(path);
12154
+ if (threadMatch) {
12155
+ if (method === "GET") {
12156
+ const since = parseSince(url.searchParams.get("since"));
12157
+ const limit = parseLimit(
12158
+ url.searchParams.get("limit"),
12159
+ HUB_CHAT_TURNS_DEFAULT_LIMIT,
12160
+ HUB_CHAT_TURNS_MAX_LIMIT
12161
+ );
12162
+ const readOpts = { limit };
12163
+ if (since !== void 0) readOpts.sinceTurnId = since;
12164
+ const turns = await deps.service.readConciergeMemoryThread(
12165
+ threadMatch.threadId,
12166
+ readOpts
12167
+ );
12168
+ writeJSON2(res, 200, { ok: true, data: { turns } });
12169
+ return true;
12170
+ }
12171
+ if (method === "DELETE") {
12172
+ const removed = await deps.service.deleteConciergeMemoryThread(
12173
+ threadMatch.threadId
12174
+ );
12175
+ writeJSON2(res, removed ? 200 : 404, {
12176
+ ok: removed,
12177
+ data: { thread_id: threadMatch.threadId, removed }
12178
+ });
12179
+ return true;
12180
+ }
12181
+ }
12182
+ }
12031
12183
  writeJSON2(res, 404, { ok: false, error: "not_found", path });
12032
12184
  return true;
12033
12185
  } catch (err) {
@@ -16077,6 +16229,162 @@ async function dispatchV11Request(inputs, req, res, url, method) {
16077
16229
  return false;
16078
16230
  }
16079
16231
 
16232
+ // src/principal-policy/approval-aggregator-routes.ts
16233
+ var APPROVAL_INBOX_API_PREFIX = "/api/approval-inbox";
16234
+ var APPROVAL_INBOX_OPERATOR_DEFAULT = "operator_dashboard";
16235
+ var APPROVAL_INBOX_DEFAULT_LIMIT = 50;
16236
+ var APPROVAL_INBOX_MAX_LIMIT = 200;
16237
+ function writeJSON4(res, status, payload) {
16238
+ res.writeHead(status, {
16239
+ "Content-Type": "application/json",
16240
+ "Cache-Control": "no-store"
16241
+ });
16242
+ res.end(JSON.stringify(payload));
16243
+ }
16244
+ function parseLimit2(raw, defaultValue, max) {
16245
+ if (raw === null || raw === "") return defaultValue;
16246
+ const parsed = Number.parseInt(raw, 10);
16247
+ if (Number.isNaN(parsed) || parsed < 0) {
16248
+ return defaultValue;
16249
+ }
16250
+ return Math.min(parsed, max);
16251
+ }
16252
+ function isStatusFilter(value) {
16253
+ return value === "pending" || value === "approved" || value === "denied" || value === "timeout" || value === "expired";
16254
+ }
16255
+ function matchEntryRoute(path) {
16256
+ const prefix = `${APPROVAL_INBOX_API_PREFIX}/`;
16257
+ if (!path.startsWith(prefix)) return null;
16258
+ const rest = path.slice(prefix.length);
16259
+ if (rest.length === 0) return null;
16260
+ const slash = rest.indexOf("/");
16261
+ if (slash === -1) {
16262
+ return { aggregatorId: decodeURIComponent(rest), action: null };
16263
+ }
16264
+ return {
16265
+ aggregatorId: decodeURIComponent(rest.slice(0, slash)),
16266
+ action: rest.slice(slash + 1)
16267
+ };
16268
+ }
16269
+ async function handleStream2(deps, res) {
16270
+ res.writeHead(200, {
16271
+ "Content-Type": "text/event-stream",
16272
+ "Cache-Control": "no-cache, no-transform",
16273
+ Connection: "keep-alive",
16274
+ "X-Accel-Buffering": "no"
16275
+ });
16276
+ const initial = await deps.aggregator.list({ status: "pending" });
16277
+ res.write(
16278
+ `event: approval_inbox_snapshot
16279
+ data: ${JSON.stringify({ entries: initial })}
16280
+
16281
+ `
16282
+ );
16283
+ const unsubscribe = deps.aggregator.onEvent((event) => {
16284
+ try {
16285
+ res.write(
16286
+ `event: approval_inbox_${event.type}
16287
+ data: ${JSON.stringify(event.entry)}
16288
+
16289
+ `
16290
+ );
16291
+ } catch {
16292
+ }
16293
+ });
16294
+ const keepAlive = setInterval(() => {
16295
+ try {
16296
+ res.write(": keepalive\n\n");
16297
+ } catch {
16298
+ }
16299
+ }, 25e3);
16300
+ const cleanup = () => {
16301
+ clearInterval(keepAlive);
16302
+ unsubscribe();
16303
+ };
16304
+ res.on("close", cleanup);
16305
+ res.on("error", cleanup);
16306
+ }
16307
+ async function handleApprovalInboxRoute(deps, req, res) {
16308
+ const host = req.headers.host || "localhost";
16309
+ const url = new URL(req.url ?? "/", `http://${host}`);
16310
+ const method = (req.method ?? "GET").toUpperCase();
16311
+ const path = url.pathname;
16312
+ if (path !== APPROVAL_INBOX_API_PREFIX && !path.startsWith(`${APPROVAL_INBOX_API_PREFIX}/`)) {
16313
+ return false;
16314
+ }
16315
+ const checkAuth = authMiddleware(deps.authConfig);
16316
+ if (!checkAuth(req, res, url)) return true;
16317
+ try {
16318
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/stream`) {
16319
+ await handleStream2(deps, res);
16320
+ return true;
16321
+ }
16322
+ if (method === "GET" && path === APPROVAL_INBOX_API_PREFIX) {
16323
+ const limit = parseLimit2(
16324
+ url.searchParams.get("limit"),
16325
+ APPROVAL_INBOX_DEFAULT_LIMIT,
16326
+ APPROVAL_INBOX_MAX_LIMIT
16327
+ );
16328
+ const statusRaw = url.searchParams.get("status");
16329
+ const status = statusRaw && isStatusFilter(statusRaw) ? statusRaw : "pending";
16330
+ const sinceTs = url.searchParams.get("since") ?? void 0;
16331
+ const entries = await deps.aggregator.list({
16332
+ status,
16333
+ limit,
16334
+ ...sinceTs !== void 0 ? { sinceTs } : {}
16335
+ });
16336
+ writeJSON4(res, 200, { ok: true, data: { entries } });
16337
+ return true;
16338
+ }
16339
+ const entryMatch = matchEntryRoute(path);
16340
+ if (entryMatch === null) {
16341
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
16342
+ return true;
16343
+ }
16344
+ if (method === "GET" && entryMatch.action === null) {
16345
+ const entries = await deps.aggregator.list({ limit: APPROVAL_INBOX_MAX_LIMIT });
16346
+ const entry = entries.find(
16347
+ (e) => e.aggregator_id === entryMatch.aggregatorId
16348
+ );
16349
+ if (!entry) {
16350
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
16351
+ return true;
16352
+ }
16353
+ const payload = await deps.aggregator.getFullPayload(
16354
+ entryMatch.aggregatorId
16355
+ );
16356
+ writeJSON4(res, 200, { ok: true, data: { entry, request_payload: payload } });
16357
+ return true;
16358
+ }
16359
+ if (method === "POST" && (entryMatch.action === "approve" || entryMatch.action === "deny")) {
16360
+ const decision = entryMatch.action === "approve" ? "approved" : "denied";
16361
+ const operatorId = deps.operatorId ?? APPROVAL_INBOX_OPERATOR_DEFAULT;
16362
+ try {
16363
+ const entry = await deps.aggregator.resolve(
16364
+ entryMatch.aggregatorId,
16365
+ decision,
16366
+ operatorId
16367
+ );
16368
+ writeJSON4(res, 200, { ok: true, data: { entry } });
16369
+ } catch (err) {
16370
+ const msg = err instanceof Error ? err.message : String(err);
16371
+ if (msg === "approval-aggregator: not_found") {
16372
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
16373
+ } else {
16374
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
16375
+ }
16376
+ }
16377
+ return true;
16378
+ }
16379
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
16380
+ return true;
16381
+ } catch (err) {
16382
+ const msg = err instanceof Error ? err.message : String(err);
16383
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
16384
+ return true;
16385
+ }
16386
+ }
16387
+
16080
16388
  // src/principal-policy/dashboard.ts
16081
16389
  var SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
16082
16390
  var SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
@@ -16141,6 +16449,14 @@ var DashboardApprovalChannel = class {
16141
16449
  * regardless. Default route flip is deferred to v1.2.
16142
16450
  */
16143
16451
  v11Bindings = null;
16452
+ /**
16453
+ * v1.3 WP-V1.3-10 Cross-Harness Approval Inbox aggregator. Mounted
16454
+ * additively at `/api/approval-inbox/*` when set. Legacy approval
16455
+ * routes at `/api/approvals/:id/(allow|deny)` continue to serve. The
16456
+ * aggregator is a passive subscriber to the gate; the routes here are
16457
+ * the operator-facing query / decision surface.
16458
+ */
16459
+ approvalAggregator = null;
16144
16460
  constructor(config) {
16145
16461
  this.config = config;
16146
16462
  this.authToken = config.auth_token;
@@ -16191,6 +16507,34 @@ var DashboardApprovalChannel = class {
16191
16507
  setV11Bindings(bindings) {
16192
16508
  this.v11Bindings = bindings;
16193
16509
  }
16510
+ /**
16511
+ * v1.3 WP-V1.3-10 Upsilon-1: bind the cross-harness approval inbox
16512
+ * aggregator. Once set, requests to `/api/approval-inbox/*` route
16513
+ * through `handleApprovalInboxRoute`. Pass `null` to detach (used by
16514
+ * tests + during shutdown).
16515
+ */
16516
+ setApprovalAggregator(aggregator) {
16517
+ this.approvalAggregator = aggregator;
16518
+ }
16519
+ /**
16520
+ * v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
16521
+ * before the legacy approval route table. Returns true when served.
16522
+ */
16523
+ async dispatchApprovalInbox(req, res) {
16524
+ if (!this.approvalAggregator) return false;
16525
+ return handleApprovalInboxRoute(
16526
+ {
16527
+ authConfig: {
16528
+ loopbackAutoAuth: this._autoAuthLocalhost,
16529
+ ...this.authToken !== void 0 ? { authToken: this.authToken } : {}
16530
+ },
16531
+ aggregator: this.approvalAggregator,
16532
+ operatorId: this.identityManager?.getPrimaryIdentityId() ?? void 0
16533
+ },
16534
+ req,
16535
+ res
16536
+ );
16537
+ }
16194
16538
  /**
16195
16539
  * v1.1 dispatch entry point. Called from `handleRequest` before the
16196
16540
  * legacy route table. Returns true when the request was served by v1.1
@@ -16566,6 +16910,18 @@ var DashboardApprovalChannel = class {
16566
16910
  res.end();
16567
16911
  return;
16568
16912
  }
16913
+ if (this.approvalAggregator && url.pathname.startsWith(APPROVAL_INBOX_API_PREFIX)) {
16914
+ this.dispatchApprovalInbox(req, res).then((handled) => {
16915
+ if (handled) return;
16916
+ this.handleLegacyRequest(req, res, url, method);
16917
+ }).catch(() => {
16918
+ if (!res.headersSent) {
16919
+ res.writeHead(500, { "Content-Type": "application/json" });
16920
+ res.end(JSON.stringify({ error: "Internal server error" }));
16921
+ }
16922
+ });
16923
+ return;
16924
+ }
16569
16925
  if (this.v11Bindings) {
16570
16926
  this.dispatchV11(req, res, url, method).then((handled) => {
16571
16927
  if (handled) return;
@@ -18584,14 +18940,25 @@ var ApprovalGate = class {
18584
18940
  auditLog;
18585
18941
  injectionDetector;
18586
18942
  onInjectionAlert;
18943
+ onApprovalEvent;
18587
18944
  proxyTierResolver;
18588
- constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert) {
18945
+ constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert, onApprovalEvent) {
18589
18946
  this.policy = policy;
18590
18947
  this.baseline = baseline;
18591
18948
  this.channel = channel;
18592
18949
  this.auditLog = auditLog;
18593
18950
  this.injectionDetector = injectionDetector ?? new InjectionDetector();
18594
18951
  this.onInjectionAlert = onInjectionAlert;
18952
+ this.onApprovalEvent = onApprovalEvent;
18953
+ }
18954
+ /**
18955
+ * Set the approval-event callback after construction. Used by the
18956
+ * Upsilon-1 wire-up when the aggregator is constructed alongside the
18957
+ * gate. The aggregator subscribes through this setter rather than the
18958
+ * constructor so existing call sites continue to work unchanged.
18959
+ */
18960
+ setApprovalEventCallback(cb) {
18961
+ this.onApprovalEvent = cb;
18595
18962
  }
18596
18963
  /**
18597
18964
  * Set the proxy tier resolver. Called after the proxy router is initialized.
@@ -18825,21 +19192,105 @@ var ApprovalGate = class {
18825
19192
  }
18826
19193
  /**
18827
19194
  * Request approval from the human principal.
19195
+ *
19196
+ * Fail-closed contract (full-sweep #49): if the channel throws (network
19197
+ * down, callback unreachable, dashboard SSE peer dropped, webhook DNS
19198
+ * failure, etc.), the gate denies the operation and audit-logs the cause.
19199
+ * Channel-internal timeouts already resolve with decision: "deny" per
19200
+ * SEC-002; this catch covers the remaining "channel raised" path so an
19201
+ * unhandled rejection cannot turn into an indeterminate state at the gate.
18828
19202
  */
18829
19203
  async requestApproval(operation, tier, reason, context) {
19204
+ const requestTimestamp = (/* @__PURE__ */ new Date()).toISOString();
18830
19205
  const request = {
18831
19206
  operation,
18832
19207
  tier,
18833
19208
  reason,
18834
19209
  context,
18835
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
19210
+ timestamp: requestTimestamp
18836
19211
  };
18837
- const response = await this.channel.requestApproval(request);
19212
+ const correlationId = `${requestTimestamp}:${operation}:${Math.random().toString(16).slice(2, 6)}`;
19213
+ if (this.onApprovalEvent) {
19214
+ try {
19215
+ this.onApprovalEvent({
19216
+ phase: "requested",
19217
+ operation,
19218
+ tier,
19219
+ reason,
19220
+ context,
19221
+ request_timestamp: requestTimestamp,
19222
+ correlation_id: correlationId
19223
+ });
19224
+ } catch {
19225
+ }
19226
+ }
19227
+ let response;
19228
+ try {
19229
+ response = await this.channel.requestApproval(request);
19230
+ } catch (err) {
19231
+ const errMessage = err instanceof Error ? err.message : String(err);
19232
+ const decidedAt = (/* @__PURE__ */ new Date()).toISOString();
19233
+ this.auditLog.append("l2", `gate_deny:${operation}`, "system", {
19234
+ tier,
19235
+ reason,
19236
+ decided_by: "channel_failure",
19237
+ channel_error: errMessage
19238
+ });
19239
+ if (this.onApprovalEvent) {
19240
+ try {
19241
+ this.onApprovalEvent({
19242
+ phase: "resolved",
19243
+ operation,
19244
+ tier,
19245
+ reason,
19246
+ context,
19247
+ request_timestamp: requestTimestamp,
19248
+ resolution: {
19249
+ decision: "deny",
19250
+ decided_at: decidedAt,
19251
+ decided_by: "channel_failure"
19252
+ },
19253
+ correlation_id: correlationId
19254
+ });
19255
+ } catch {
19256
+ }
19257
+ }
19258
+ return {
19259
+ allowed: false,
19260
+ tier,
19261
+ reason: AGENT_VISIBLE_DENY_REASONS.REQUIRES_APPROVAL,
19262
+ approval_required: true,
19263
+ approval_response: {
19264
+ decision: "deny",
19265
+ decided_at: decidedAt,
19266
+ decided_by: "channel_failure"
19267
+ }
19268
+ };
19269
+ }
18838
19270
  this.auditLog.append("l2", `gate_${response.decision}:${operation}`, "system", {
18839
19271
  tier,
18840
19272
  reason,
18841
19273
  decided_by: response.decided_by
18842
19274
  });
19275
+ if (this.onApprovalEvent) {
19276
+ try {
19277
+ this.onApprovalEvent({
19278
+ phase: "resolved",
19279
+ operation,
19280
+ tier,
19281
+ reason,
19282
+ context,
19283
+ request_timestamp: requestTimestamp,
19284
+ resolution: {
19285
+ decision: response.decision,
19286
+ decided_at: response.decided_at,
19287
+ decided_by: response.decided_by
19288
+ },
19289
+ correlation_id: correlationId
19290
+ });
19291
+ } catch {
19292
+ }
19293
+ }
18843
19294
  return {
18844
19295
  allowed: response.decision === "approve",
18845
19296
  tier,
@@ -18873,50 +19324,559 @@ var ApprovalGate = class {
18873
19324
  }
18874
19325
  };
18875
19326
 
18876
- // src/principal-policy/tools.ts
18877
- function createPrincipalPolicyTools(policy, baseline, auditLog) {
18878
- return [
18879
- {
18880
- name: "principal_policy_view",
18881
- description: "View the current Principal Policy \u2014 the human-controlled rules governing what operations require approval. Read-only.",
18882
- inputSchema: {
18883
- type: "object",
18884
- properties: {
18885
- include_defaults: {
18886
- type: "boolean",
18887
- description: "Include tier3_always_allow list (can be long)",
18888
- default: false
18889
- }
18890
- }
18891
- },
18892
- handler: async (args) => {
18893
- const includeDefaults = args.include_defaults ?? false;
18894
- const view = {
18895
- version: policy.version,
18896
- tier1_always_approve: policy.tier1_always_approve,
18897
- tier2_anomaly: policy.tier2_anomaly,
18898
- approval_channel: {
18899
- type: policy.approval_channel.type,
18900
- timeout_seconds: policy.approval_channel.timeout_seconds,
18901
- auto_deny: true
18902
- // SEC-002: hardcoded, not configurable
18903
- }
18904
- };
18905
- if (includeDefaults) {
18906
- view.tier3_always_allow = policy.tier3_always_allow;
18907
- } else {
18908
- view.tier3_always_allow_count = policy.tier3_always_allow.length;
18909
- view.note = "Pass include_defaults: true to see the full tier3_always_allow list";
18910
- }
18911
- auditLog.append("l2", "principal_policy_view", "system", {
18912
- include_defaults: includeDefaults
18913
- });
18914
- return toolResult(view);
18915
- }
18916
- },
18917
- {
18918
- name: "principal_baseline_view",
18919
- description: "View the current behavioral baseline \u2014 the session profile used for anomaly detection. Shows known namespaces, counterparties, and tool call counts. Read-only.",
19327
+ // src/principal-policy/approval-aggregator.ts
19328
+ init_encryption();
19329
+ init_encoding();
19330
+ var APPROVAL_AGGREGATOR_NAMESPACE = "_approval_aggregator";
19331
+ var APPROVAL_AGGREGATOR_HKDF_INFO = "l2-approval-aggregator-v1";
19332
+ var APPROVAL_AGGREGATOR_AUDIT_OPS = {
19333
+ AGGREGATED: "cross_harness_approval_aggregated",
19334
+ RESOLVED: "cross_harness_approval_resolved",
19335
+ DEDUPED: "cross_harness_approval_deduped"
19336
+ };
19337
+ var DEFAULT_PENDING_TTL_MS = 5 * 60 * 1e3;
19338
+ var DEFAULT_MAX_LIST_LIMIT = 200;
19339
+ var DEFAULT_LIST_PAGE_SIZE = 50;
19340
+ var ApprovalAggregator = class {
19341
+ storage;
19342
+ encryptionKey;
19343
+ auditLog;
19344
+ identityId;
19345
+ fortressId;
19346
+ pendingTtlMs;
19347
+ maxListLimit;
19348
+ now;
19349
+ resolveSourceContext;
19350
+ resolveHubInboxItemId;
19351
+ /** Cached entries by `aggregator_id`. */
19352
+ entries = /* @__PURE__ */ new Map();
19353
+ /** Dedup index: `${harness}|${agent}|${audit_id}` -> aggregator_id. */
19354
+ dedupIndex = /* @__PURE__ */ new Map();
19355
+ /** Correlation index: gate `correlation_id` -> aggregator_id. */
19356
+ correlationIndex = /* @__PURE__ */ new Map();
19357
+ /** Original request payloads kept in-memory for `getFullPayload()`. */
19358
+ fullPayloads = /* @__PURE__ */ new Map();
19359
+ /** Has the aggregator hydrated persisted entries on this process? */
19360
+ hydrated = false;
19361
+ /** Active SSE listeners. */
19362
+ listeners = /* @__PURE__ */ new Set();
19363
+ constructor(deps) {
19364
+ this.storage = deps.storage;
19365
+ this.encryptionKey = derivePurposeKey(
19366
+ deps.masterKey,
19367
+ APPROVAL_AGGREGATOR_HKDF_INFO
19368
+ );
19369
+ this.auditLog = deps.auditLog;
19370
+ this.identityId = deps.identityId;
19371
+ this.fortressId = deps.fortressId;
19372
+ this.pendingTtlMs = deps.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
19373
+ this.maxListLimit = deps.maxListLimit ?? DEFAULT_MAX_LIST_LIMIT;
19374
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
19375
+ this.resolveSourceContext = deps.resolveSourceContext ?? ((_event) => ({
19376
+ source_harness: this.fortressId,
19377
+ source_agent_id: this.fortressId
19378
+ }));
19379
+ this.resolveHubInboxItemId = deps.resolveHubInboxItemId ?? ((_event) => void 0);
19380
+ }
19381
+ /**
19382
+ * Subscribe an event listener. Returns an unsubscribe fn. SSE handlers
19383
+ * use this to forward aggregator emissions to the dashboard.
19384
+ */
19385
+ onEvent(listener) {
19386
+ this.listeners.add(listener);
19387
+ return () => this.listeners.delete(listener);
19388
+ }
19389
+ /**
19390
+ * Ingest a gate event. Returns the aggregator entry on first sight,
19391
+ * `null` when deduped. Resolution events update the existing record;
19392
+ * unmatched resolutions are dropped silently (caller's gate emitted a
19393
+ * resolved-without-requested pair, which the aggregator does not invent
19394
+ * a record for).
19395
+ */
19396
+ async ingest(event) {
19397
+ await this.hydrate();
19398
+ if (event.phase === "requested") {
19399
+ return this.ingestRequested(event);
19400
+ }
19401
+ if (event.phase === "resolved") {
19402
+ return this.ingestResolved(event);
19403
+ }
19404
+ return null;
19405
+ }
19406
+ /**
19407
+ * List pending or recently resolved entries. Pending entries past TTL
19408
+ * are lazily transitioned to `expired` and persisted before the list
19409
+ * snapshot is returned.
19410
+ */
19411
+ async list(opts) {
19412
+ await this.hydrate();
19413
+ await this.expireStale();
19414
+ const limit = Math.min(
19415
+ opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
19416
+ this.maxListLimit
19417
+ );
19418
+ const sinceMs = opts?.sinceTs ? Date.parse(opts.sinceTs) : Number.NEGATIVE_INFINITY;
19419
+ const matching = [];
19420
+ for (const entry of this.entries.values()) {
19421
+ if (opts?.status && entry.status !== opts.status) continue;
19422
+ if (Date.parse(entry.created_at) < sinceMs) continue;
19423
+ matching.push(entry);
19424
+ }
19425
+ matching.sort((a, b) => b.created_at.localeCompare(a.created_at));
19426
+ return matching.slice(0, limit);
19427
+ }
19428
+ /**
19429
+ * Return the original (unhashed) request payload for the entry. Returns
19430
+ * `null` when the entry is unknown or the payload was evicted (e.g. the
19431
+ * process restarted; payloads are in-memory only at v1.3 Upsilon-1).
19432
+ */
19433
+ async getFullPayload(aggregatorId) {
19434
+ await this.hydrate();
19435
+ if (!this.entries.has(aggregatorId)) return null;
19436
+ return this.fullPayloads.get(aggregatorId) ?? null;
19437
+ }
19438
+ /**
19439
+ * Resolve an entry. Used by both:
19440
+ * 1. The gate wire-up on channel-decision return.
19441
+ * 2. The HTTP `approve`/`deny` routes when an operator clicks.
19442
+ *
19443
+ * Idempotent: resolving an already-resolved entry is a no-op (the record
19444
+ * keeps its first decision and the audit log is not double-fired).
19445
+ * Unknown ids throw `Error("approval-aggregator: not_found")` so HTTP
19446
+ * routes return 404.
19447
+ */
19448
+ async resolve(aggregatorId, decision, operatorId) {
19449
+ await this.hydrate();
19450
+ const entry = this.entries.get(aggregatorId);
19451
+ if (!entry) {
19452
+ throw new Error("approval-aggregator: not_found");
19453
+ }
19454
+ if (entry.status !== "pending") {
19455
+ return entry;
19456
+ }
19457
+ entry.status = decision;
19458
+ entry.resolved_at = this.now().toISOString();
19459
+ entry.resolved_by = operatorId;
19460
+ await this.persist(entry);
19461
+ this.auditLog.append(
19462
+ "l2",
19463
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
19464
+ this.identityId,
19465
+ {
19466
+ aggregator_id: entry.aggregator_id,
19467
+ source_harness: entry.source_harness,
19468
+ source_agent_id: entry.source_agent_id,
19469
+ audit_log_entry_id: entry.audit_log_entry_id,
19470
+ policy_rule_id: entry.policy_rule_id,
19471
+ decision,
19472
+ decided_by: operatorId,
19473
+ decided_at: entry.resolved_at
19474
+ }
19475
+ );
19476
+ this.emit({ type: "resolved", entry: { ...entry } });
19477
+ return entry;
19478
+ }
19479
+ // ── Internal: ingest paths ─────────────────────────────────────────────
19480
+ async ingestRequested(event) {
19481
+ const ctx = this.resolveSourceContext(event);
19482
+ const auditId = this.auditEntryIdForEvent(event);
19483
+ const dedupKey = `${ctx.source_harness}|${ctx.source_agent_id}|${auditId}`;
19484
+ const existing = this.dedupIndex.get(dedupKey);
19485
+ if (existing) {
19486
+ const existingEntry = this.entries.get(existing);
19487
+ if (existingEntry) {
19488
+ this.correlationIndex.set(event.correlation_id, existing);
19489
+ this.auditLog.append(
19490
+ "l2",
19491
+ APPROVAL_AGGREGATOR_AUDIT_OPS.DEDUPED,
19492
+ this.identityId,
19493
+ {
19494
+ aggregator_id: existing,
19495
+ source_harness: ctx.source_harness,
19496
+ source_agent_id: ctx.source_agent_id,
19497
+ audit_log_entry_id: auditId,
19498
+ policy_rule_id: this.derivePolicyRuleId(event),
19499
+ correlation_id: event.correlation_id
19500
+ }
19501
+ );
19502
+ this.emit({ type: "deduped", entry: { ...existingEntry } });
19503
+ return null;
19504
+ }
19505
+ }
19506
+ const id = crypto.randomUUID();
19507
+ const now = this.now();
19508
+ const expires = new Date(now.getTime() + this.pendingTtlMs);
19509
+ const hubInboxId = this.resolveHubInboxItemId(event);
19510
+ const entry = {
19511
+ aggregator_id: id,
19512
+ source_harness: ctx.source_harness,
19513
+ source_agent_id: ctx.source_agent_id,
19514
+ audit_log_entry_id: auditId,
19515
+ policy_rule_id: this.derivePolicyRuleId(event),
19516
+ action_summary: this.deriveActionSummary(event),
19517
+ request_payload_hash: this.hashPayload(event.context),
19518
+ status: "pending",
19519
+ created_at: now.toISOString(),
19520
+ expires_at: expires.toISOString(),
19521
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
19522
+ };
19523
+ this.entries.set(id, entry);
19524
+ this.dedupIndex.set(dedupKey, id);
19525
+ this.correlationIndex.set(event.correlation_id, id);
19526
+ this.fullPayloads.set(id, event.context);
19527
+ await this.persist(entry);
19528
+ this.auditLog.append(
19529
+ "l2",
19530
+ APPROVAL_AGGREGATOR_AUDIT_OPS.AGGREGATED,
19531
+ this.identityId,
19532
+ {
19533
+ aggregator_id: id,
19534
+ source_harness: ctx.source_harness,
19535
+ source_agent_id: ctx.source_agent_id,
19536
+ audit_log_entry_id: auditId,
19537
+ policy_rule_id: entry.policy_rule_id,
19538
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
19539
+ }
19540
+ );
19541
+ this.emit({ type: "aggregated", entry: { ...entry } });
19542
+ return entry;
19543
+ }
19544
+ async ingestResolved(event) {
19545
+ const id = this.correlationIndex.get(event.correlation_id);
19546
+ if (!id) return null;
19547
+ const entry = this.entries.get(id);
19548
+ if (!entry) return null;
19549
+ if (entry.status !== "pending") return entry;
19550
+ if (!event.resolution) return entry;
19551
+ const failClosed = event.resolution.decision === "deny" && event.resolution.decided_by === "channel_failure";
19552
+ const status = failClosed ? "timeout" : event.resolution.decision === "approve" ? "approved" : "denied";
19553
+ entry.status = status;
19554
+ entry.resolved_at = event.resolution.decided_at;
19555
+ entry.resolved_by = event.resolution.decided_by;
19556
+ await this.persist(entry);
19557
+ this.auditLog.append(
19558
+ "l2",
19559
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
19560
+ this.identityId,
19561
+ {
19562
+ aggregator_id: id,
19563
+ source_harness: entry.source_harness,
19564
+ source_agent_id: entry.source_agent_id,
19565
+ audit_log_entry_id: entry.audit_log_entry_id,
19566
+ policy_rule_id: entry.policy_rule_id,
19567
+ decision: status,
19568
+ decided_by: entry.resolved_by,
19569
+ decided_at: entry.resolved_at,
19570
+ fail_closed: failClosed
19571
+ }
19572
+ );
19573
+ this.emit({ type: "resolved", entry: { ...entry } });
19574
+ return entry;
19575
+ }
19576
+ // ── Internal: helpers ──────────────────────────────────────────────────
19577
+ /**
19578
+ * Audit-log entry id for the dedup tuple. The audit log itself does not
19579
+ * surface a stable per-entry id (counter-prefixed keys are internal); the
19580
+ * aggregator uses the request timestamp + operation, which together pin
19581
+ * the audit entry the gate appended on the same call.
19582
+ */
19583
+ auditEntryIdForEvent(event) {
19584
+ return `${event.request_timestamp}:${event.operation}`;
19585
+ }
19586
+ derivePolicyRuleId(event) {
19587
+ return `tier${event.tier}:${event.operation}`;
19588
+ }
19589
+ deriveActionSummary(event) {
19590
+ return `${event.operation} (tier ${event.tier})`;
19591
+ }
19592
+ /**
19593
+ * Canonical SHA-256 of the request context. Sorted-keys serialization so
19594
+ * identical payloads always hash the same, even when key insertion order
19595
+ * varies. Defends against payload-replay smuggling (the aggregator can
19596
+ * tell the same payload was seen twice without storing it cleartext).
19597
+ */
19598
+ hashPayload(payload) {
19599
+ const canonical = JSON.stringify(payload, Object.keys(payload).sort());
19600
+ return crypto.createHash("sha256").update(canonical).digest("hex");
19601
+ }
19602
+ emit(event) {
19603
+ for (const listener of this.listeners) {
19604
+ try {
19605
+ listener(event);
19606
+ } catch {
19607
+ }
19608
+ }
19609
+ }
19610
+ async expireStale() {
19611
+ const nowMs = this.now().getTime();
19612
+ for (const entry of this.entries.values()) {
19613
+ if (entry.status !== "pending") continue;
19614
+ if (Date.parse(entry.expires_at) > nowMs) continue;
19615
+ entry.status = "expired";
19616
+ entry.resolved_at = this.now().toISOString();
19617
+ entry.resolved_by = "system_ttl";
19618
+ await this.persist(entry);
19619
+ this.auditLog.append(
19620
+ "l2",
19621
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
19622
+ this.identityId,
19623
+ {
19624
+ aggregator_id: entry.aggregator_id,
19625
+ source_harness: entry.source_harness,
19626
+ source_agent_id: entry.source_agent_id,
19627
+ audit_log_entry_id: entry.audit_log_entry_id,
19628
+ policy_rule_id: entry.policy_rule_id,
19629
+ decision: "expired",
19630
+ decided_by: "system_ttl",
19631
+ decided_at: entry.resolved_at
19632
+ }
19633
+ );
19634
+ this.emit({ type: "resolved", entry: { ...entry } });
19635
+ }
19636
+ }
19637
+ async persist(entry) {
19638
+ const serialized = stringToBytes(JSON.stringify(entry));
19639
+ const encrypted = encrypt(serialized, this.encryptionKey);
19640
+ await this.storage.write(
19641
+ APPROVAL_AGGREGATOR_NAMESPACE,
19642
+ entry.aggregator_id,
19643
+ stringToBytes(JSON.stringify(encrypted))
19644
+ );
19645
+ }
19646
+ async hydrate() {
19647
+ if (this.hydrated) return;
19648
+ this.hydrated = true;
19649
+ try {
19650
+ const metas = await this.storage.list(APPROVAL_AGGREGATOR_NAMESPACE);
19651
+ for (const meta of metas) {
19652
+ const raw = await this.storage.read(
19653
+ APPROVAL_AGGREGATOR_NAMESPACE,
19654
+ meta.key
19655
+ );
19656
+ if (!raw) continue;
19657
+ try {
19658
+ const encrypted = JSON.parse(bytesToString(raw));
19659
+ const decrypted = decrypt(encrypted, this.encryptionKey);
19660
+ const entry = JSON.parse(bytesToString(decrypted));
19661
+ this.entries.set(entry.aggregator_id, entry);
19662
+ const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
19663
+ this.dedupIndex.set(dedupKey, entry.aggregator_id);
19664
+ } catch {
19665
+ }
19666
+ }
19667
+ } catch {
19668
+ this.hydrated = false;
19669
+ }
19670
+ }
19671
+ };
19672
+
19673
+ // src/principal-policy/channels/aggregator-backed-channel.ts
19674
+ var DEFAULT_REPLACE_MODE_TIMEOUT_MS = 5 * 60 * 1e3;
19675
+ function auditEntryIdFor(request) {
19676
+ return `${request.timestamp}:${request.operation}`;
19677
+ }
19678
+ function statusToDecision(entry) {
19679
+ switch (entry.status) {
19680
+ case "approved":
19681
+ return {
19682
+ decision: "approve",
19683
+ decided_by: "human"
19684
+ };
19685
+ case "denied":
19686
+ return {
19687
+ decision: "deny",
19688
+ decided_by: "human"
19689
+ };
19690
+ case "timeout":
19691
+ case "expired":
19692
+ return {
19693
+ decision: "deny",
19694
+ decided_by: "timeout"
19695
+ };
19696
+ default:
19697
+ return null;
19698
+ }
19699
+ }
19700
+ var AggregatorBackedChannel = class {
19701
+ underlying;
19702
+ aggregator;
19703
+ resolveRedirect;
19704
+ replaceModeTimeoutMs;
19705
+ now;
19706
+ constructor(opts) {
19707
+ this.underlying = opts.underlying;
19708
+ this.aggregator = opts.aggregator;
19709
+ this.resolveRedirect = opts.resolveRedirect;
19710
+ this.replaceModeTimeoutMs = opts.replaceModeTimeoutMs ?? DEFAULT_REPLACE_MODE_TIMEOUT_MS;
19711
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
19712
+ }
19713
+ /** Expose underlying for tests / wire-up reuse. */
19714
+ getUnderlying() {
19715
+ return this.underlying;
19716
+ }
19717
+ async requestApproval(request) {
19718
+ const cfg = this.resolveRedirect(request);
19719
+ if (!cfg.enabled) {
19720
+ return this.underlying.requestApproval(request);
19721
+ }
19722
+ if (cfg.mode === "replace") {
19723
+ return this.awaitAggregatorDecision(request);
19724
+ }
19725
+ return this.notifyMode(request);
19726
+ }
19727
+ /**
19728
+ * `replace` mode. Subscribe to the aggregator's event stream BEFORE
19729
+ * checking already-stored entries (avoids a race where the entry resolves
19730
+ * between list and subscribe). Match incoming events to this request by
19731
+ * audit_entry_id. Time out after `replaceModeTimeoutMs` to honor SEC-002.
19732
+ */
19733
+ async awaitAggregatorDecision(request) {
19734
+ const auditId = auditEntryIdFor(request);
19735
+ return new Promise((resolveOuter) => {
19736
+ let settled = false;
19737
+ let unsubscribe = null;
19738
+ let timeoutHandle = null;
19739
+ const settle = (response) => {
19740
+ if (settled) return;
19741
+ settled = true;
19742
+ if (timeoutHandle) clearTimeout(timeoutHandle);
19743
+ if (unsubscribe) {
19744
+ try {
19745
+ unsubscribe();
19746
+ } catch {
19747
+ }
19748
+ }
19749
+ resolveOuter(response);
19750
+ };
19751
+ const onEvent = (emit) => {
19752
+ if (emit.type !== "resolved") return;
19753
+ if (emit.entry.audit_log_entry_id !== auditId) return;
19754
+ const mapped = statusToDecision(emit.entry);
19755
+ if (!mapped) return;
19756
+ settle({
19757
+ decision: mapped.decision,
19758
+ decided_at: emit.entry.resolved_at ?? this.now().toISOString(),
19759
+ decided_by: mapped.decided_by
19760
+ });
19761
+ };
19762
+ try {
19763
+ unsubscribe = this.aggregator.onEvent(onEvent);
19764
+ } catch (err) {
19765
+ settle({
19766
+ decision: "deny",
19767
+ decided_at: this.now().toISOString(),
19768
+ decided_by: "channel_failure"
19769
+ });
19770
+ throw err instanceof Error ? err : new Error(String(err));
19771
+ }
19772
+ void this.aggregator.list({ limit: 200 }).then((entries) => {
19773
+ for (const entry of entries) {
19774
+ if (entry.audit_log_entry_id !== auditId) continue;
19775
+ const mapped = statusToDecision(entry);
19776
+ if (!mapped) return;
19777
+ settle({
19778
+ decision: mapped.decision,
19779
+ decided_at: entry.resolved_at ?? this.now().toISOString(),
19780
+ decided_by: mapped.decided_by
19781
+ });
19782
+ return;
19783
+ }
19784
+ }).catch(() => {
19785
+ });
19786
+ timeoutHandle = setTimeout(() => {
19787
+ settle({
19788
+ decision: "deny",
19789
+ decided_at: this.now().toISOString(),
19790
+ decided_by: "timeout"
19791
+ });
19792
+ }, this.replaceModeTimeoutMs);
19793
+ });
19794
+ }
19795
+ /**
19796
+ * `notify` mode. Fire the underlying channel and listen on the
19797
+ * aggregator simultaneously; whichever resolves first wins. Both
19798
+ * paths produce identical `ApprovalResponse` shapes; the gate's
19799
+ * downstream audit logging is unchanged.
19800
+ *
19801
+ * On underlying-channel failure, fall through to the aggregator wait
19802
+ * (still bounded by `replaceModeTimeoutMs`). Operator can still
19803
+ * resolve from the inbox even if the dashboard/webhook is down.
19804
+ */
19805
+ async notifyMode(request) {
19806
+ const aggregatorPromise = this.awaitAggregatorDecision(request);
19807
+ let underlyingPromise;
19808
+ try {
19809
+ underlyingPromise = this.underlying.requestApproval(request);
19810
+ } catch (err) {
19811
+ const response = await aggregatorPromise;
19812
+ return response;
19813
+ }
19814
+ return Promise.race([
19815
+ aggregatorPromise,
19816
+ underlyingPromise.catch(
19817
+ () => new Promise(() => {
19818
+ })
19819
+ )
19820
+ ]);
19821
+ }
19822
+ };
19823
+ function makeRedirectResolverFromPolicySupplier(supplier) {
19824
+ return (_request) => {
19825
+ const cfg = supplier().approval_redirect;
19826
+ if (!cfg || cfg.enabled !== true) {
19827
+ return { enabled: false, mode: "replace" };
19828
+ }
19829
+ return {
19830
+ enabled: true,
19831
+ mode: cfg.mode === "notify" ? "notify" : "replace"
19832
+ };
19833
+ };
19834
+ }
19835
+
19836
+ // src/principal-policy/tools.ts
19837
+ function createPrincipalPolicyTools(policy, baseline, auditLog) {
19838
+ return [
19839
+ {
19840
+ name: "principal_policy_view",
19841
+ description: "View the current Principal Policy \u2014 the human-controlled rules governing what operations require approval. Read-only.",
19842
+ inputSchema: {
19843
+ type: "object",
19844
+ properties: {
19845
+ include_defaults: {
19846
+ type: "boolean",
19847
+ description: "Include tier3_always_allow list (can be long)",
19848
+ default: false
19849
+ }
19850
+ }
19851
+ },
19852
+ handler: async (args) => {
19853
+ const includeDefaults = args.include_defaults ?? false;
19854
+ const view = {
19855
+ version: policy.version,
19856
+ tier1_always_approve: policy.tier1_always_approve,
19857
+ tier2_anomaly: policy.tier2_anomaly,
19858
+ approval_channel: {
19859
+ type: policy.approval_channel.type,
19860
+ timeout_seconds: policy.approval_channel.timeout_seconds,
19861
+ auto_deny: true
19862
+ // SEC-002: hardcoded, not configurable
19863
+ }
19864
+ };
19865
+ if (includeDefaults) {
19866
+ view.tier3_always_allow = policy.tier3_always_allow;
19867
+ } else {
19868
+ view.tier3_always_allow_count = policy.tier3_always_allow.length;
19869
+ view.note = "Pass include_defaults: true to see the full tier3_always_allow list";
19870
+ }
19871
+ auditLog.append("l2", "principal_policy_view", "system", {
19872
+ include_defaults: includeDefaults
19873
+ });
19874
+ return toolResult(view);
19875
+ }
19876
+ },
19877
+ {
19878
+ name: "principal_baseline_view",
19879
+ description: "View the current behavioral baseline \u2014 the session profile used for anomaly detection. Shows known namespaces, counterparties, and tool call counts. Read-only.",
18920
19880
  inputSchema: {
18921
19881
  type: "object",
18922
19882
  properties: {}
@@ -19743,6 +20703,71 @@ function verifyAttestation(attestation, now) {
19743
20703
  };
19744
20704
  }
19745
20705
 
20706
+ // src/handshake/audit.ts
20707
+ var HANDSHAKE_LIFECYCLE_OPS = {
20708
+ INITIATED: "handshake_initiated",
20709
+ COMPLETED: "handshake_completed",
20710
+ FAILED: "handshake_failed",
20711
+ ABORTED: "handshake_aborted"
20712
+ };
20713
+ function auditHandshakeInitiated(auditLog, ctx) {
20714
+ auditLog.append(
20715
+ "l4",
20716
+ HANDSHAKE_LIFECYCLE_OPS.INITIATED,
20717
+ ctx.identity_id,
20718
+ detailsFromContext(ctx),
20719
+ "success"
20720
+ );
20721
+ }
20722
+ function auditHandshakeCompleted(auditLog, ctx) {
20723
+ const details = detailsFromContext(ctx);
20724
+ if (ctx.trust_tier !== void 0) {
20725
+ details.trust_tier = ctx.trust_tier;
20726
+ }
20727
+ auditLog.append(
20728
+ "l4",
20729
+ HANDSHAKE_LIFECYCLE_OPS.COMPLETED,
20730
+ ctx.identity_id,
20731
+ details,
20732
+ "success"
20733
+ );
20734
+ }
20735
+ function auditHandshakeFailed(auditLog, ctx) {
20736
+ const details = detailsFromContext(ctx);
20737
+ details.reason = ctx.reason;
20738
+ if (ctx.error !== void 0) {
20739
+ details.error = ctx.error;
20740
+ }
20741
+ auditLog.append(
20742
+ "l4",
20743
+ HANDSHAKE_LIFECYCLE_OPS.FAILED,
20744
+ ctx.identity_id,
20745
+ details,
20746
+ "failure"
20747
+ );
20748
+ }
20749
+ function auditHandshakeAborted(auditLog, ctx) {
20750
+ const details = detailsFromContext(ctx);
20751
+ details.reason = ctx.reason;
20752
+ auditLog.append(
20753
+ "l4",
20754
+ HANDSHAKE_LIFECYCLE_OPS.ABORTED,
20755
+ ctx.identity_id,
20756
+ details,
20757
+ "failure"
20758
+ );
20759
+ }
20760
+ function detailsFromContext(ctx) {
20761
+ const details = {
20762
+ session_id: ctx.session_id,
20763
+ role: ctx.role
20764
+ };
20765
+ if (ctx.counterparty_id !== void 0) {
20766
+ details.counterparty_id = ctx.counterparty_id;
20767
+ }
20768
+ return details;
20769
+ }
20770
+
19746
20771
  // src/handshake/tools.ts
19747
20772
  function createHandshakeTools(config, identityManager, masterKey, auditLog, options) {
19748
20773
  const autoPublishHandshakes = options?.autoPublishHandshakes ?? false;
@@ -19776,6 +20801,11 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
19776
20801
  const { challenge, session } = initiateHandshake(shr);
19777
20802
  sessions.set(session.session_id, session);
19778
20803
  auditLog.append("l4", "handshake_initiate", shr.body.instance_id);
20804
+ auditHandshakeInitiated(auditLog, {
20805
+ session_id: session.session_id,
20806
+ role: "initiator",
20807
+ identity_id: shr.body.instance_id
20808
+ });
19779
20809
  return toolResult({
19780
20810
  session_id: session.session_id,
19781
20811
  challenge,
@@ -19815,10 +20845,23 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
19815
20845
  );
19816
20846
  if ("error" in result) {
19817
20847
  auditLog.append("l4", "handshake_respond", shr.body.instance_id, void 0, "failure");
20848
+ auditHandshakeFailed(auditLog, {
20849
+ session_id: "unknown",
20850
+ role: "responder",
20851
+ identity_id: shr.body.instance_id,
20852
+ reason: classifyRespondFailure(result.error),
20853
+ error: result.error
20854
+ });
19818
20855
  return toolResult({ error: result.error });
19819
20856
  }
19820
20857
  sessions.set(result.session.session_id, result.session);
19821
20858
  auditLog.append("l4", "handshake_respond", shr.body.instance_id);
20859
+ auditHandshakeInitiated(auditLog, {
20860
+ session_id: result.session.session_id,
20861
+ role: "responder",
20862
+ identity_id: shr.body.instance_id,
20863
+ counterparty_id: challenge.shr.body.instance_id
20864
+ });
19822
20865
  let autoPublishResult;
19823
20866
  if (autoPublishHandshakes) {
19824
20867
  autoPublishResult = { attempted: true };
@@ -19926,9 +20969,23 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
19926
20969
  const response = args.response;
19927
20970
  const session = sessions.get(sessionId);
19928
20971
  if (!session) {
20972
+ auditHandshakeFailed(auditLog, {
20973
+ session_id: sessionId,
20974
+ role: "initiator",
20975
+ identity_id: "unknown",
20976
+ reason: "session_unknown",
20977
+ error: `No handshake session found: ${sessionId}`
20978
+ });
19929
20979
  return toolResult({ error: `No handshake session found: ${sessionId}` });
19930
20980
  }
19931
20981
  if (session.state !== "initiated") {
20982
+ auditHandshakeFailed(auditLog, {
20983
+ session_id: sessionId,
20984
+ role: "initiator",
20985
+ identity_id: session.our_shr.body.instance_id,
20986
+ reason: "session_state_mismatch",
20987
+ error: `Session is in state '${session.state}', expected 'initiated'`
20988
+ });
19932
20989
  return toolResult({
19933
20990
  error: `Session is in state '${session.state}', expected 'initiated'`
19934
20991
  });
@@ -19942,6 +20999,13 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
19942
20999
  if ("error" in result) {
19943
21000
  session.state = "failed";
19944
21001
  auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id, void 0, "failure");
21002
+ auditHandshakeFailed(auditLog, {
21003
+ session_id: sessionId,
21004
+ role: "initiator",
21005
+ identity_id: session.our_shr.body.instance_id,
21006
+ reason: classifyCompleteFailure(result.error),
21007
+ error: result.error
21008
+ });
19945
21009
  return toolResult({ error: result.error });
19946
21010
  }
19947
21011
  session.state = "completed";
@@ -19950,6 +21014,13 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
19950
21014
  session.result = result.result;
19951
21015
  handshakeResults.set(result.result.counterparty_id, result.result);
19952
21016
  auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id);
21017
+ auditHandshakeCompleted(auditLog, {
21018
+ session_id: sessionId,
21019
+ role: "initiator",
21020
+ identity_id: session.our_shr.body.instance_id,
21021
+ counterparty_id: result.result.counterparty_id,
21022
+ trust_tier: result.result.trust_tier
21023
+ });
19953
21024
  return toolResult({
19954
21025
  completion: result.completion,
19955
21026
  result: result.result,
@@ -19997,6 +21068,24 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
19997
21068
  void 0,
19998
21069
  result.verified ? "success" : "failure"
19999
21070
  );
21071
+ if (result.verified) {
21072
+ auditHandshakeCompleted(auditLog, {
21073
+ session_id: session.session_id,
21074
+ role: "responder",
21075
+ identity_id: session.our_shr.body.instance_id,
21076
+ counterparty_id: result.counterparty_id,
21077
+ trust_tier: result.trust_tier
21078
+ });
21079
+ } else {
21080
+ auditHandshakeFailed(auditLog, {
21081
+ session_id: session.session_id,
21082
+ role: "responder",
21083
+ identity_id: session.our_shr.body.instance_id,
21084
+ counterparty_id: result.counterparty_id,
21085
+ reason: classifyCompleteFailure(result.errors.join("; ")),
21086
+ error: result.errors.join("; ")
21087
+ });
21088
+ }
20000
21089
  return toolResult({ result });
20001
21090
  }
20002
21091
  return toolResult({
@@ -20104,10 +21193,74 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20104
21193
  _content_trust: "external"
20105
21194
  });
20106
21195
  }
21196
+ },
21197
+ {
21198
+ name: "handshake_abort",
21199
+ description: "Abort an in-flight handshake session. Drops the session record and appends a session-lifecycle audit entry (handshake_aborted) so the operator can distinguish operator-cancelled, timed-out, and dropped sessions from sessions that simply fell off the protocol path.",
21200
+ inputSchema: {
21201
+ type: "object",
21202
+ properties: {
21203
+ session_id: {
21204
+ type: "string",
21205
+ description: "Session ID returned from handshake_initiate / handshake_respond."
21206
+ },
21207
+ reason: {
21208
+ type: "string",
21209
+ enum: [
21210
+ "operator_cancelled",
21211
+ "session_timeout",
21212
+ "transport_dropped",
21213
+ "shutdown",
21214
+ "other"
21215
+ ],
21216
+ description: "Why the session is being aborted. Defaults to 'operator_cancelled'."
21217
+ }
21218
+ },
21219
+ required: ["session_id"]
21220
+ },
21221
+ handler: async (args) => {
21222
+ const sessionId = args.session_id;
21223
+ const reason = args.reason ?? "operator_cancelled";
21224
+ const session = sessions.get(sessionId);
21225
+ if (!session) {
21226
+ return toolResult({ error: `No handshake session found: ${sessionId}` });
21227
+ }
21228
+ if (session.state === "completed") {
21229
+ return toolResult({
21230
+ error: `Session ${sessionId} already completed; abort is only valid for in-flight sessions`
21231
+ });
21232
+ }
21233
+ sessions.delete(sessionId);
21234
+ auditHandshakeAborted(auditLog, {
21235
+ session_id: sessionId,
21236
+ role: session.role,
21237
+ identity_id: session.our_shr.body.instance_id,
21238
+ ...session.their_shr ? { counterparty_id: session.their_shr.body.instance_id } : {},
21239
+ reason
21240
+ });
21241
+ return toolResult({
21242
+ aborted: true,
21243
+ session_id: sessionId,
21244
+ reason
21245
+ });
21246
+ }
20107
21247
  }
20108
21248
  ];
20109
21249
  return { tools, handshakeResults };
20110
21250
  }
21251
+ function classifyRespondFailure(error) {
21252
+ if (error.includes("Unsupported protocol version")) return "protocol_version_unsupported";
21253
+ if (error.includes("SHR verification failed")) return "shr_invalid";
21254
+ if (error.includes("No identity available")) return "no_signing_identity";
21255
+ return "other";
21256
+ }
21257
+ function classifyCompleteFailure(error) {
21258
+ if (error.includes("Unsupported protocol version")) return "protocol_version_unsupported";
21259
+ if (error.includes("SHR verification failed") || error.includes("SHR")) return "shr_invalid";
21260
+ if (error.includes("nonce signature is invalid")) return "nonce_signature_invalid";
21261
+ if (error.includes("No identity available")) return "no_signing_identity";
21262
+ return "other";
21263
+ }
20111
21264
 
20112
21265
  // src/federation/registry.ts
20113
21266
  var DEFAULT_CAPABILITIES = {
@@ -21725,6 +22878,12 @@ function typed(markerPath, lineNumber, field, expected) {
21725
22878
  }
21726
22879
  async function consumeResetHistoryMarker(options) {
21727
22880
  const markerPath = path.join(options.storagePath, RESET_HISTORY_FILENAME);
22881
+ const consumedPath = markerPath + ".consumed";
22882
+ if (await fileExists3(consumedPath)) {
22883
+ await promises.rm(markerPath, { force: true });
22884
+ await promises.rm(consumedPath, { force: true });
22885
+ return { emitted: 0, markerPath };
22886
+ }
21728
22887
  if (!await fileExists3(markerPath)) {
21729
22888
  return { emitted: 0, markerPath };
21730
22889
  }
@@ -21749,7 +22908,9 @@ async function consumeResetHistoryMarker(options) {
21749
22908
  });
21750
22909
  }
21751
22910
  await options.auditLog.flush();
22911
+ await promises.writeFile(consumedPath, "", "utf-8");
21752
22912
  await promises.rm(markerPath, { force: true });
22913
+ await promises.rm(consumedPath, { force: true });
21753
22914
  return { emitted: markers.length, markerHash, markerPath };
21754
22915
  }
21755
22916
  async function fileExists3(path) {
@@ -30693,7 +31854,37 @@ var HubService = class {
30693
31854
  */
30694
31855
  async getConciergeHistory() {
30695
31856
  const chat = this.requireOperatorChat();
30696
- return chat.getConciergeHistory();
31857
+ return chat.getConciergeHistory();
31858
+ }
31859
+ // ── Concierge memory threads (WP-V1.3-9 Tau-1) ─────────────────────
31860
+ /**
31861
+ * Whether the operator-chat service has the WP-V1.3-9 memory store
31862
+ * wired. Routes use this to 503 cleanly when the foundation memory
31863
+ * surface is unavailable on a given fortress.
31864
+ */
31865
+ hasConciergeMemory() {
31866
+ return Boolean(this.deps.operatorChat?.hasConciergeMemory());
31867
+ }
31868
+ async listConciergeMemoryThreads(opts) {
31869
+ const chat = this.requireOperatorChat();
31870
+ if (!chat.hasConciergeMemory()) {
31871
+ throw new HubCapabilityError("concierge_memory_not_wired");
31872
+ }
31873
+ return chat.listConciergeMemoryThreads(opts);
31874
+ }
31875
+ async readConciergeMemoryThread(threadId, opts) {
31876
+ const chat = this.requireOperatorChat();
31877
+ if (!chat.hasConciergeMemory()) {
31878
+ throw new HubCapabilityError("concierge_memory_not_wired");
31879
+ }
31880
+ return chat.readConciergeMemoryThread(threadId, opts);
31881
+ }
31882
+ async deleteConciergeMemoryThread(threadId) {
31883
+ const chat = this.requireOperatorChat();
31884
+ if (!chat.hasConciergeMemory()) {
31885
+ throw new HubCapabilityError("concierge_memory_not_wired");
31886
+ }
31887
+ return chat.deleteConciergeMemoryThread(threadId);
30697
31888
  }
30698
31889
  /**
30699
31890
  * Open the click-to-inspect/approve panel for a wrapped agent. The
@@ -30782,7 +31973,28 @@ init_encoding();
30782
31973
 
30783
31974
  // src/chat/operator-chat-audit-events.ts
30784
31975
  var OPERATOR_CHAT_OPS = {
30785
- CONCIERGE_CHAT: "operator_concierge_chat"};
31976
+ CONCIERGE_CHAT: "operator_concierge_chat",
31977
+ /**
31978
+ * Operator viewed concierge thread history (WP-V1.3-9 Tau-1). Emitted
31979
+ * when the operator hits the list-threads or read-thread route. Body
31980
+ * carries the thread_id (or `*` for the list endpoint) and a count;
31981
+ * raw turn content never crosses the audit surface.
31982
+ */
31983
+ CONCIERGE_HISTORY_READ: "operator_concierge_history_read",
31984
+ /**
31985
+ * Operator deleted a concierge thread (WP-V1.3-9 Tau-1). Emitted on
31986
+ * successful thread removal. Body carries thread_id + turn_count of
31987
+ * the deleted bundle.
31988
+ */
31989
+ CONCIERGE_THREAD_DELETED: "operator_concierge_thread_deleted",
31990
+ /**
31991
+ * Concierge memory fold-read failed (WP-V1.3-9 Tau-2). Emitted when
31992
+ * the multi-turn coherence fold cannot load the active thread's prior
31993
+ * turns; the concierge degrades to single-turn after emitting. Body
31994
+ * carries thread_id + a stable failure_reason enum.
31995
+ */
31996
+ CONCIERGE_MEMORY_READ_FAILED: "operator_concierge_memory_read_failed"
31997
+ };
30786
31998
 
30787
31999
  // src/chat/operator-chat-types.ts
30788
32000
  var OPERATOR_CHAT_MAX_THREAD_LENGTH = 500;
@@ -30790,6 +32002,13 @@ var CONCIERGE_THREAD_KEY = "_fortress";
30790
32002
 
30791
32003
  // src/chat/operator-chat-service.ts
30792
32004
  var DEFAULT_CONCIERGE_MAX_TOKENS = 512;
32005
+ var DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS = 10;
32006
+ var DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
32007
+ var DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET = 500;
32008
+ var DEFAULT_CONCIERGE_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
32009
+ function approxTokenLen(text) {
32010
+ return Math.ceil(text.length / 4);
32011
+ }
30793
32012
  var SANCTUARY_DOMAIN_REFERENCE = `Castle Architecture (four enforcement layers):
30794
32013
  1. Castle Wall: OS-boundary egress filter enforced at the kernel level. Blocks unauthorized outbound calls even from prompt-injected agents.
30795
32014
  2. Sentinels: internal observation via process introspection. Surfaces anomalies to the operator; does not enforce.
@@ -30825,6 +32044,27 @@ var OperatorChatService = class {
30825
32044
  contextProviders;
30826
32045
  piiFilter;
30827
32046
  conciergeMaxTokens;
32047
+ memory;
32048
+ historyWindowTurns;
32049
+ historyFreshnessMs;
32050
+ historyTokenBudget;
32051
+ sessionTtlMs;
32052
+ clock;
32053
+ /**
32054
+ * In-memory thread_id assigned to the active concierge session.
32055
+ * The first sendConcierge call after construction allocates a fresh
32056
+ * UUID; subsequent calls reuse it so multi-turn coherence (Tau-2)
32057
+ * folds the prior turns into context. Cleared by `resetConciergeMemoryThread`.
32058
+ */
32059
+ activeMemoryThreadId;
32060
+ /**
32061
+ * Wall-clock ms of the most recent sendConcierge that touched the
32062
+ * active session thread. Drives the WP-V1.3-9 Tau-2 session-TTL
32063
+ * check: a fresh sendConcierge after `sessionTtlMs` of quiet
32064
+ * allocates a new thread_id even though the prior one is still
32065
+ * readable from the memory store.
32066
+ */
32067
+ lastInteractionAt;
30828
32068
  constructor(deps) {
30829
32069
  this.store = deps.store;
30830
32070
  this.auditLog = deps.auditLog;
@@ -30835,6 +32075,12 @@ var OperatorChatService = class {
30835
32075
  }
30836
32076
  if (deps.conciergePiiFilter) this.piiFilter = deps.conciergePiiFilter;
30837
32077
  this.conciergeMaxTokens = deps.conciergeMaxTokens ?? DEFAULT_CONCIERGE_MAX_TOKENS;
32078
+ if (deps.conciergeMemory) this.memory = deps.conciergeMemory;
32079
+ this.historyWindowTurns = deps.conciergeHistoryWindowTurns !== void 0 && deps.conciergeHistoryWindowTurns > 0 ? deps.conciergeHistoryWindowTurns : DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS;
32080
+ this.historyFreshnessMs = deps.conciergeHistoryFreshnessMs !== void 0 && deps.conciergeHistoryFreshnessMs > 0 ? deps.conciergeHistoryFreshnessMs : DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS;
32081
+ this.historyTokenBudget = deps.conciergeHistoryTokenBudget !== void 0 && deps.conciergeHistoryTokenBudget > 0 ? deps.conciergeHistoryTokenBudget : DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET;
32082
+ this.sessionTtlMs = deps.conciergeSessionTtlMs !== void 0 && deps.conciergeSessionTtlMs > 0 ? deps.conciergeSessionTtlMs : DEFAULT_CONCIERGE_SESSION_TTL_MS;
32083
+ this.clock = deps.conciergeClock ?? (() => Date.now());
30838
32084
  }
30839
32085
  // ── Concierge ─────────────────────────────────────────────────────────
30840
32086
  /**
@@ -30853,6 +32099,10 @@ var OperatorChatService = class {
30853
32099
  throw new Error("concierge query must not be empty");
30854
32100
  }
30855
32101
  const filterResult = this.piiFilter ? this.piiFilter.filter(trimmed) : { filtered: trimmed, redactions: 0 };
32102
+ const nowMs = this.clock();
32103
+ if (this.activeMemoryThreadId && this.lastInteractionAt !== void 0 && nowMs - this.lastInteractionAt > this.sessionTtlMs) {
32104
+ this.activeMemoryThreadId = void 0;
32105
+ }
30856
32106
  const operatorMessage = {
30857
32107
  message_id: crypto.randomUUID(),
30858
32108
  surface: "concierge",
@@ -30865,6 +32115,30 @@ var OperatorChatService = class {
30865
32115
  CONCIERGE_THREAD_KEY,
30866
32116
  operatorMessage
30867
32117
  );
32118
+ let priorTurns = [];
32119
+ let memoryReadFailureReason = null;
32120
+ let activeThreadIdForRound;
32121
+ if (this.memory) {
32122
+ activeThreadIdForRound = this.ensureActiveMemoryThread();
32123
+ const result = await this.memory.readThreadStrict(activeThreadIdForRound).catch(() => ({ ok: false, reason: "io_failed" }));
32124
+ if (result.ok) {
32125
+ const cutoff = nowMs - this.historyFreshnessMs;
32126
+ const fresh = result.turns.filter((t) => {
32127
+ const ts = Date.parse(t.created_at);
32128
+ return Number.isFinite(ts) && ts >= cutoff;
32129
+ });
32130
+ const recent = fresh.length > this.historyWindowTurns ? fresh.slice(fresh.length - this.historyWindowTurns) : fresh;
32131
+ priorTurns = recent;
32132
+ } else {
32133
+ memoryReadFailureReason = result.reason;
32134
+ this.emitMemoryReadFailed(activeThreadIdForRound, result.reason);
32135
+ }
32136
+ }
32137
+ if (this.memory) {
32138
+ const threadId = this.ensureActiveMemoryThread();
32139
+ await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
32140
+ });
32141
+ }
30868
32142
  const start = Date.now();
30869
32143
  let conciergeBody;
30870
32144
  let servedBy = "disabled";
@@ -30881,7 +32155,7 @@ var OperatorChatService = class {
30881
32155
  conciergeBody = "Concierge unavailable. The chosen substrate does not support summarization. Pick a different substrate in the Policy center.";
30882
32156
  outcome = "substrate_disabled";
30883
32157
  } else {
30884
- const context = await this.assembleConciergeContext();
32158
+ const context = await this.assembleConciergeContext(priorTurns);
30885
32159
  const response = await this.substrateSelector.invokeSummarize(
30886
32160
  "concierge",
30887
32161
  {
@@ -30920,6 +32194,15 @@ var OperatorChatService = class {
30920
32194
  CONCIERGE_THREAD_KEY,
30921
32195
  responseMessage
30922
32196
  );
32197
+ let assistantTurnId;
32198
+ if (this.memory) {
32199
+ const threadId = this.ensureActiveMemoryThread();
32200
+ const persisted = await this.memory.appendTurn(threadId, "assistant", conciergeBody).catch(() => void 0);
32201
+ if (persisted) assistantTurnId = persisted.turn_id;
32202
+ }
32203
+ if (this.memory && activeThreadIdForRound) {
32204
+ this.lastInteractionAt = nowMs;
32205
+ }
30923
32206
  const payload = {
30924
32207
  version: "1.2",
30925
32208
  event_id: makeEventId("conc"),
@@ -30931,7 +32214,12 @@ var OperatorChatService = class {
30931
32214
  response_hash: outcome === "ok" ? hashOf(conciergeBody) : null,
30932
32215
  substrate: servedBy,
30933
32216
  latency_ms: latencyMs,
30934
- outcome
32217
+ outcome,
32218
+ ...activeThreadIdForRound !== void 0 ? { thread_id: activeThreadIdForRound } : {},
32219
+ ...assistantTurnId !== void 0 ? { turn_index: assistantTurnId } : {},
32220
+ ...this.memory ? {
32221
+ prior_turns_folded: memoryReadFailureReason === null ? priorTurns.length : 0
32222
+ } : {}
30935
32223
  };
30936
32224
  this.emit(OPERATOR_CHAT_OPS.CONCIERGE_CHAT, payload, outcome === "ok" ? "success" : "failure");
30937
32225
  return {
@@ -30941,6 +32229,25 @@ var OperatorChatService = class {
30941
32229
  outcome
30942
32230
  };
30943
32231
  }
32232
+ /**
32233
+ * Emit the WP-V1.3-9 Tau-2 graceful-degradation audit event. Pulled
32234
+ * out of `sendConcierge` so the read-fold path stays readable. Emits
32235
+ * with `result: "failure"` since the concierge fell back to
32236
+ * single-turn mode for this round-trip.
32237
+ */
32238
+ emitMemoryReadFailed(threadId, reason) {
32239
+ const payload = {
32240
+ version: "1.2",
32241
+ event_id: makeEventId("conc-memfail"),
32242
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
32243
+ identity_id: this.identityId,
32244
+ kind: "operator_concierge_memory_read_failed",
32245
+ surface: "concierge",
32246
+ thread_id: threadId,
32247
+ failure_reason: reason
32248
+ };
32249
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_MEMORY_READ_FAILED, payload, "failure");
32250
+ }
30944
32251
  /**
30945
32252
  * Read the persisted concierge thread, oldest message first. Returns
30946
32253
  * an empty array when no thread exists yet.
@@ -30952,6 +32259,105 @@ var OperatorChatService = class {
30952
32259
  );
30953
32260
  return thread ? thread.messages : [];
30954
32261
  }
32262
+ // ── WP-V1.3-9 Tau-1 memory accessors ─────────────────────────────────
32263
+ /**
32264
+ * Whether the foundation memory store is wired. Routes use this to
32265
+ * 503 cleanly when called against an unwired service.
32266
+ */
32267
+ hasConciergeMemory() {
32268
+ return this.memory !== void 0;
32269
+ }
32270
+ /**
32271
+ * List concierge memory threads, newest-first. Emits the
32272
+ * `operator_concierge_history_read` audit event with `thread_id="*"`.
32273
+ */
32274
+ async listConciergeMemoryThreads(opts) {
32275
+ if (!this.memory) {
32276
+ throw new Error("concierge memory store not configured");
32277
+ }
32278
+ const summaries = await this.memory.listThreads(opts);
32279
+ const totalTurns = summaries.reduce((acc, s) => acc + s.turn_count, 0);
32280
+ const payload = {
32281
+ version: "1.2",
32282
+ event_id: makeEventId("conc-hist"),
32283
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
32284
+ identity_id: this.identityId,
32285
+ kind: "operator_concierge_history_read",
32286
+ surface: "concierge",
32287
+ thread_id: "*",
32288
+ turn_count: totalTurns
32289
+ };
32290
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
32291
+ return summaries;
32292
+ }
32293
+ /**
32294
+ * Read a concierge memory thread, oldest turn first. Emits the
32295
+ * `operator_concierge_history_read` audit event with the named
32296
+ * thread_id and the count of turns surfaced.
32297
+ */
32298
+ async readConciergeMemoryThread(threadId, opts) {
32299
+ if (!this.memory) {
32300
+ throw new Error("concierge memory store not configured");
32301
+ }
32302
+ const turns = await this.memory.readThread(threadId, opts);
32303
+ const payload = {
32304
+ version: "1.2",
32305
+ event_id: makeEventId("conc-hist"),
32306
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
32307
+ identity_id: this.identityId,
32308
+ kind: "operator_concierge_history_read",
32309
+ surface: "concierge",
32310
+ thread_id: threadId,
32311
+ turn_count: turns.length
32312
+ };
32313
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
32314
+ return turns;
32315
+ }
32316
+ /**
32317
+ * Delete a concierge memory thread. Emits
32318
+ * `operator_concierge_thread_deleted` only when a bundle was actually
32319
+ * removed; absent threads return false without an audit event.
32320
+ */
32321
+ async deleteConciergeMemoryThread(threadId) {
32322
+ if (!this.memory) {
32323
+ throw new Error("concierge memory store not configured");
32324
+ }
32325
+ const turnsBefore = await this.memory.readThread(threadId);
32326
+ if (turnsBefore.length === 0) {
32327
+ return await this.memory.deleteThread(threadId);
32328
+ }
32329
+ const removed = await this.memory.deleteThread(threadId);
32330
+ if (!removed) return false;
32331
+ if (this.activeMemoryThreadId === threadId) {
32332
+ this.activeMemoryThreadId = void 0;
32333
+ }
32334
+ const payload = {
32335
+ version: "1.2",
32336
+ event_id: makeEventId("conc-del"),
32337
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
32338
+ identity_id: this.identityId,
32339
+ kind: "operator_concierge_thread_deleted",
32340
+ surface: "concierge",
32341
+ thread_id: threadId,
32342
+ turn_count: turnsBefore.length
32343
+ };
32344
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_THREAD_DELETED, payload, "success");
32345
+ return true;
32346
+ }
32347
+ /**
32348
+ * Reset the active session memory thread. Subsequent sendConcierge
32349
+ * calls allocate a fresh thread_id. Surfaced for tests + future "new
32350
+ * conversation" affordance; not currently called by the dashboard.
32351
+ */
32352
+ resetConciergeMemoryThread() {
32353
+ this.activeMemoryThreadId = void 0;
32354
+ }
32355
+ ensureActiveMemoryThread() {
32356
+ if (!this.activeMemoryThreadId) {
32357
+ this.activeMemoryThreadId = crypto.randomUUID();
32358
+ }
32359
+ return this.activeMemoryThreadId;
32360
+ }
30955
32361
  /**
30956
32362
  * Stitch fortress state into a single context blob the substrate
30957
32363
  * folds into its summarization prompt.
@@ -30964,6 +32370,11 @@ var OperatorChatService = class {
30964
32370
  * ## Sanctuary reference
30965
32371
  * <static domain reference block>
30966
32372
  *
32373
+ * ## Prior conversation ← WP-V1.3-9 Tau-2, when present
32374
+ * OPERATOR: ...
32375
+ * CONCIERGE: ...
32376
+ * ---
32377
+ *
30967
32378
  * ## Recent activity
30968
32379
  * <recentActivity output>
30969
32380
  *
@@ -30973,37 +32384,69 @@ var OperatorChatService = class {
30973
32384
  * ## Open inbox
30974
32385
  * <openInbox output>
30975
32386
  * ```
32387
+ *
32388
+ * The substrate selector ships a `context: string` shape (not a
32389
+ * messages array), so multi-turn coherence is folded as a structured
32390
+ * prior-conversation section with explicit OPERATOR / CONCIERGE
32391
+ * boundaries. Coordinator-CTO guidance: prefer messages-array shape
32392
+ * if available; the v1.2 selector does not expose one, so structured
32393
+ * serialization is the canonical path for v1.3.
30976
32394
  */
30977
- async assembleConciergeContext() {
32395
+ async assembleConciergeContext(priorTurns = []) {
30978
32396
  const ref = `## Sanctuary reference
30979
32397
  ${SANCTUARY_DOMAIN_REFERENCE}`;
32398
+ const priorSection = this.formatPriorTurnsSection(priorTurns);
30980
32399
  if (!this.contextProviders) {
30981
- return `${ref}
30982
-
30983
- ## Recent activity
30984
- (no providers wired)
30985
-
30986
- ## Wrapped agents
30987
- (no providers wired)
30988
-
30989
- ## Open inbox
30990
- (no providers wired)`;
32400
+ return [
32401
+ ref,
32402
+ ...priorSection ? [priorSection] : [],
32403
+ "## Recent activity\n(no providers wired)",
32404
+ "## Wrapped agents\n(no providers wired)",
32405
+ "## Open inbox\n(no providers wired)"
32406
+ ].join("\n\n");
30991
32407
  }
30992
32408
  const [activity, agents, inbox] = await Promise.all([
30993
32409
  this.contextProviders.recentActivity(),
30994
32410
  this.contextProviders.agentInventory(),
30995
32411
  this.contextProviders.openInbox()
30996
32412
  ]);
30997
- return `${ref}
30998
-
30999
- ## Recent activity
31000
- ${activity}
31001
-
31002
- ## Wrapped agents
31003
- ${agents}
31004
-
31005
- ## Open inbox
31006
- ${inbox}`;
32413
+ return [
32414
+ ref,
32415
+ ...priorSection ? [priorSection] : [],
32416
+ `## Recent activity
32417
+ ${activity}`,
32418
+ `## Wrapped agents
32419
+ ${agents}`,
32420
+ `## Open inbox
32421
+ ${inbox}`
32422
+ ].join("\n\n");
32423
+ }
32424
+ /**
32425
+ * Render the prior-conversation section with token-budget enforcement
32426
+ * (WP-V1.3-9 Tau-2). Drops oldest turns first when the rendered
32427
+ * section exceeds `historyTokenBudget`. Returns an empty string when
32428
+ * the input is empty or when the budget excludes every turn.
32429
+ */
32430
+ formatPriorTurnsSection(turns) {
32431
+ if (turns.length === 0) return "";
32432
+ const HEADER = "## Prior conversation";
32433
+ const lines = turns.map(formatPriorTurnLine);
32434
+ const headerTokens = approxTokenLen(`${HEADER}
32435
+ `);
32436
+ const sepTokens = approxTokenLen("\n");
32437
+ let runningTokens = headerTokens;
32438
+ let runningLines = [];
32439
+ for (let i = lines.length - 1; i >= 0; i--) {
32440
+ const line = lines[i];
32441
+ const tokens = approxTokenLen(line) + (runningLines.length > 0 ? sepTokens : 0);
32442
+ if (runningTokens + tokens > this.historyTokenBudget) break;
32443
+ runningTokens += tokens;
32444
+ runningLines.push(line);
32445
+ }
32446
+ if (runningLines.length === 0) return "";
32447
+ runningLines = runningLines.reverse();
32448
+ return `${HEADER}
32449
+ ${runningLines.join("\n")}`;
31007
32450
  }
31008
32451
  // ── audit helpers ────────────────────────────────────────────────────
31009
32452
  emit(operation, payload, result) {
@@ -31019,6 +32462,10 @@ ${inbox}`;
31019
32462
  function makeEventId(prefix) {
31020
32463
  return `${prefix}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
31021
32464
  }
32465
+ function formatPriorTurnLine(turn) {
32466
+ const label = turn.role === "user" ? "OPERATOR" : "CONCIERGE";
32467
+ return `${label}: ${turn.content}`;
32468
+ }
31022
32469
  function hashOf(input) {
31023
32470
  return hashToString(sha256.sha256(stringToBytes(input)));
31024
32471
  }
@@ -31115,6 +32562,297 @@ var OperatorChatStore = class {
31115
32562
  }
31116
32563
  };
31117
32564
 
32565
+ // src/chat/concierge-memory-store.ts
32566
+ init_encryption();
32567
+ init_encoding();
32568
+ var CONCIERGE_MEMORY_NAMESPACE = "_chat";
32569
+ var CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
32570
+ var HKDF_INFO2 = "concierge-memory-store-v1";
32571
+ var DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
32572
+ var MAX_BUNDLE_BYTES2 = 4 * 1024 * 1024;
32573
+ var ConciergeMemoryStore = class {
32574
+ storage;
32575
+ encryptionKey;
32576
+ fortressId;
32577
+ retentionDays;
32578
+ locks;
32579
+ constructor(opts) {
32580
+ this.storage = opts.storage;
32581
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
32582
+ this.fortressId = opts.fortressId;
32583
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
32584
+ this.locks = /* @__PURE__ */ new Map();
32585
+ }
32586
+ /**
32587
+ * Append a turn to the named thread, creating the bundle if no record
32588
+ * exists. Returns the persisted turn (with assigned turn_id +
32589
+ * retention_until). Per-thread serialisation guarantees turn_id
32590
+ * monotonicity even under concurrent callers.
32591
+ */
32592
+ async appendTurn(threadId, role, content) {
32593
+ return this.withLock(threadId, async () => {
32594
+ const bundle = await this.loadBundle(threadId) ?? null;
32595
+ const now = /* @__PURE__ */ new Date();
32596
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
32597
+ const retentionUntil = new Date(now.getTime() + retentionMs);
32598
+ const nextTurnId = bundle ? lastTurnId(bundle) + 1 : 1;
32599
+ const turn = {
32600
+ thread_id: threadId,
32601
+ fortress_id: this.fortressId,
32602
+ turn_id: nextTurnId,
32603
+ role,
32604
+ content,
32605
+ created_at: now.toISOString(),
32606
+ retention_until: retentionUntil.toISOString()
32607
+ };
32608
+ const next = bundle ? { ...bundle, turns: [...bundle.turns, turn] } : {
32609
+ version: 1,
32610
+ thread_id: threadId,
32611
+ fortress_id: this.fortressId,
32612
+ created_at: now.toISOString(),
32613
+ turns: [turn]
32614
+ };
32615
+ await this.saveBundle(next);
32616
+ return turn;
32617
+ });
32618
+ }
32619
+ /**
32620
+ * Read turns from a thread, oldest-first. Returns an empty array if
32621
+ * the thread does not exist or its bundle is corrupt. Does not emit
32622
+ * audit events; the caller (HTTP route handler) owns audit semantics.
32623
+ */
32624
+ async readThread(threadId, opts) {
32625
+ const bundle = await this.loadBundle(threadId);
32626
+ if (!bundle) return [];
32627
+ let turns = bundle.turns;
32628
+ if (opts?.sinceTurnId !== void 0) {
32629
+ const cutoff = opts.sinceTurnId;
32630
+ turns = turns.filter((t) => t.turn_id > cutoff);
32631
+ }
32632
+ if (opts?.limit !== void 0) {
32633
+ turns = turns.slice(0, opts.limit);
32634
+ }
32635
+ return turns;
32636
+ }
32637
+ /**
32638
+ * Read turns with explicit failure surfacing (WP-V1.3-9 Tau-2). Where
32639
+ * `readThread` collapses every failure mode to an empty array, this
32640
+ * variant returns a discriminated result so the multi-turn fold path
32641
+ * can degrade cleanly + emit `operator_concierge_memory_read_failed`
32642
+ * with a concrete cause.
32643
+ *
32644
+ * - No bundle on disk → `{ ok: true, turns: [] }` (a fresh thread).
32645
+ * - Bundle present, decode + decrypt + schema check pass → ok with turns.
32646
+ * - Bundle present, oversize → `{ ok: false, reason: "oversize_bundle" }`.
32647
+ * - Bundle present, decryption fails → `{ ok: false, reason: "decrypt_failed" }`.
32648
+ * - Bundle present, schema mismatch (version / thread_id) → `schema_mismatch`.
32649
+ * - Storage IO error → `io_failed`.
32650
+ */
32651
+ async readThreadStrict(threadId, opts) {
32652
+ const key = bundleKey(threadId);
32653
+ let raw;
32654
+ try {
32655
+ raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
32656
+ } catch {
32657
+ return { ok: false, reason: "io_failed" };
32658
+ }
32659
+ if (!raw) return { ok: true, turns: [] };
32660
+ if (raw.length > MAX_BUNDLE_BYTES2) {
32661
+ return { ok: false, reason: "oversize_bundle" };
32662
+ }
32663
+ let envelope;
32664
+ try {
32665
+ envelope = JSON.parse(bytesToString(raw));
32666
+ } catch {
32667
+ return { ok: false, reason: "schema_mismatch" };
32668
+ }
32669
+ let plaintext;
32670
+ try {
32671
+ const aad = stringToBytes(threadId);
32672
+ plaintext = decrypt(envelope, this.encryptionKey, aad);
32673
+ } catch {
32674
+ return { ok: false, reason: "decrypt_failed" };
32675
+ }
32676
+ let parsed;
32677
+ try {
32678
+ parsed = JSON.parse(bytesToString(plaintext));
32679
+ } catch {
32680
+ return { ok: false, reason: "schema_mismatch" };
32681
+ }
32682
+ if (parsed.version !== 1) return { ok: false, reason: "schema_mismatch" };
32683
+ if (parsed.thread_id !== threadId) {
32684
+ return { ok: false, reason: "schema_mismatch" };
32685
+ }
32686
+ let turns = parsed.turns;
32687
+ if (opts?.sinceTurnId !== void 0) {
32688
+ const cutoff = opts.sinceTurnId;
32689
+ turns = turns.filter((t) => t.turn_id > cutoff);
32690
+ }
32691
+ if (opts?.limit !== void 0) {
32692
+ turns = turns.slice(0, opts.limit);
32693
+ }
32694
+ return { ok: true, turns };
32695
+ }
32696
+ /**
32697
+ * Enumerate concierge threads in this fortress with summary metadata.
32698
+ * Sorted newest-first by last_turn_at.
32699
+ */
32700
+ async listThreads(opts) {
32701
+ const entries = await this.storage.list(
32702
+ CONCIERGE_MEMORY_NAMESPACE,
32703
+ CONCIERGE_MEMORY_KEY_PREFIX
32704
+ );
32705
+ const summaries = [];
32706
+ for (const meta of entries) {
32707
+ const threadId = stripKeyPrefix(meta.key);
32708
+ if (threadId === null) continue;
32709
+ const bundle = await this.loadBundle(threadId);
32710
+ if (!bundle || bundle.turns.length === 0) continue;
32711
+ const last = bundle.turns[bundle.turns.length - 1];
32712
+ summaries.push({
32713
+ thread_id: bundle.thread_id,
32714
+ created_at: bundle.created_at,
32715
+ last_turn_at: last ? last.created_at : bundle.created_at,
32716
+ turn_count: bundle.turns.length
32717
+ });
32718
+ }
32719
+ summaries.sort(
32720
+ (a, b) => a.last_turn_at < b.last_turn_at ? 1 : a.last_turn_at > b.last_turn_at ? -1 : 0
32721
+ );
32722
+ if (opts?.limit !== void 0) {
32723
+ return summaries.slice(0, opts.limit);
32724
+ }
32725
+ return summaries;
32726
+ }
32727
+ /**
32728
+ * Delete a thread's bundle. Returns true if the bundle existed and
32729
+ * was removed; false if no bundle was present. Audit emission is the
32730
+ * caller's responsibility.
32731
+ */
32732
+ async deleteThread(threadId) {
32733
+ const key = bundleKey(threadId);
32734
+ return this.withLock(threadId, async () => {
32735
+ const existed = await this.storage.exists(
32736
+ CONCIERGE_MEMORY_NAMESPACE,
32737
+ key
32738
+ );
32739
+ if (!existed) return false;
32740
+ try {
32741
+ await this.storage.delete(CONCIERGE_MEMORY_NAMESPACE, key);
32742
+ } catch {
32743
+ return false;
32744
+ }
32745
+ return true;
32746
+ });
32747
+ }
32748
+ /**
32749
+ * Drop expired turns across all threads. Threads emptied by pruning
32750
+ * are removed entirely. Returns the count of turns pruned.
32751
+ */
32752
+ async pruneExpired(now) {
32753
+ const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
32754
+ const entries = await this.storage.list(
32755
+ CONCIERGE_MEMORY_NAMESPACE,
32756
+ CONCIERGE_MEMORY_KEY_PREFIX
32757
+ );
32758
+ let pruned = 0;
32759
+ for (const meta of entries) {
32760
+ const threadId = stripKeyPrefix(meta.key);
32761
+ if (threadId === null) continue;
32762
+ pruned += await this.withLock(threadId, async () => {
32763
+ const bundle = await this.loadBundle(threadId);
32764
+ if (!bundle) return 0;
32765
+ const kept = bundle.turns.filter((t) => t.retention_until > cutoff);
32766
+ const dropped = bundle.turns.length - kept.length;
32767
+ if (dropped === 0) return 0;
32768
+ if (kept.length === 0) {
32769
+ await this.storage.delete(
32770
+ CONCIERGE_MEMORY_NAMESPACE,
32771
+ bundleKey(threadId)
32772
+ );
32773
+ } else {
32774
+ await this.saveBundle({ ...bundle, turns: kept });
32775
+ }
32776
+ return dropped;
32777
+ });
32778
+ }
32779
+ return { pruned };
32780
+ }
32781
+ // ── internals ────────────────────────────────────────────────────────
32782
+ async loadBundle(threadId) {
32783
+ const key = bundleKey(threadId);
32784
+ let raw;
32785
+ try {
32786
+ raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
32787
+ } catch {
32788
+ return null;
32789
+ }
32790
+ if (!raw) return null;
32791
+ if (raw.length > MAX_BUNDLE_BYTES2) return null;
32792
+ try {
32793
+ const envelope = JSON.parse(bytesToString(raw));
32794
+ const aad = stringToBytes(threadId);
32795
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
32796
+ const parsed = JSON.parse(
32797
+ bytesToString(plaintext)
32798
+ );
32799
+ if (parsed.version !== 1) return null;
32800
+ if (parsed.thread_id !== threadId) return null;
32801
+ return parsed;
32802
+ } catch {
32803
+ return null;
32804
+ }
32805
+ }
32806
+ async saveBundle(bundle) {
32807
+ const key = bundleKey(bundle.thread_id);
32808
+ const aad = stringToBytes(bundle.thread_id);
32809
+ const plaintext = stringToBytes(JSON.stringify(bundle));
32810
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
32811
+ await this.storage.write(
32812
+ CONCIERGE_MEMORY_NAMESPACE,
32813
+ key,
32814
+ stringToBytes(JSON.stringify(envelope))
32815
+ );
32816
+ }
32817
+ /**
32818
+ * Run `task` while holding the per-thread async lock. Lock is released
32819
+ * once the task settles (success or failure). Generic helper so
32820
+ * appendTurn / deleteThread / pruneExpired share serialisation.
32821
+ */
32822
+ async withLock(threadId, task) {
32823
+ const previous = this.locks.get(threadId) ?? Promise.resolve();
32824
+ let release;
32825
+ const next = new Promise((resolve6) => {
32826
+ release = resolve6;
32827
+ });
32828
+ const chained = previous.then(() => next);
32829
+ this.locks.set(threadId, chained);
32830
+ try {
32831
+ await previous;
32832
+ return await task();
32833
+ } finally {
32834
+ release();
32835
+ if (this.locks.get(threadId) === chained) {
32836
+ this.locks.delete(threadId);
32837
+ }
32838
+ }
32839
+ }
32840
+ };
32841
+ function bundleKey(threadId) {
32842
+ return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
32843
+ }
32844
+ function stripKeyPrefix(key) {
32845
+ if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
32846
+ return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
32847
+ }
32848
+ function lastTurnId(bundle) {
32849
+ let max = 0;
32850
+ for (const t of bundle.turns) {
32851
+ if (t.turn_id > max) max = t.turn_id;
32852
+ }
32853
+ return max;
32854
+ }
32855
+
31118
32856
  // src/dashboard/v1_1/wiring.ts
31119
32857
  var CapabilityErrorAgentController = class {
31120
32858
  fail(action) {
@@ -31153,6 +32891,14 @@ function buildV11Bindings(inputs) {
31153
32891
  let operatorChatService;
31154
32892
  if (inputs.storage && inputs.masterKey) {
31155
32893
  const chatStore = new OperatorChatStore(inputs.storage, inputs.masterKey);
32894
+ const conciergeMemory = new ConciergeMemoryStore({
32895
+ storage: inputs.storage,
32896
+ masterKey: inputs.masterKey,
32897
+ fortressId: inputs.fortressId,
32898
+ ...inputs.conciergeMemoryRetentionDays !== void 0 ? { retentionDays: inputs.conciergeMemoryRetentionDays } : {}
32899
+ });
32900
+ void conciergeMemory.pruneExpired().catch(() => {
32901
+ });
31156
32902
  operatorChatService = new OperatorChatService({
31157
32903
  store: chatStore,
31158
32904
  auditLog: inputs.auditLog,
@@ -31163,7 +32909,8 @@ function buildV11Bindings(inputs) {
31163
32909
  identityId: inputs.identityId,
31164
32910
  registry
31165
32911
  }),
31166
- conciergePiiFilter: buildConciergePiiFilter()
32912
+ conciergePiiFilter: buildConciergePiiFilter(),
32913
+ conciergeMemory
31167
32914
  });
31168
32915
  }
31169
32916
  const hubService = new HubService({
@@ -31355,13 +33102,13 @@ init_encryption();
31355
33102
  init_encoding();
31356
33103
  var INTELLIGENCE_NAMESPACE = "_intelligence";
31357
33104
  var SUBSTRATE_CONFIG_KEY = "substrate-config";
31358
- var HKDF_INFO2 = "intelligence-substrate-config";
33105
+ var HKDF_INFO3 = "intelligence-substrate-config";
31359
33106
  var IntelligenceConfigStore = class {
31360
33107
  storage;
31361
33108
  encryptionKey;
31362
33109
  constructor(storage, masterKey) {
31363
33110
  this.storage = storage;
31364
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
33111
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
31365
33112
  }
31366
33113
  /**
31367
33114
  * Load the operator's substrate config from disk. Returns the config
@@ -33500,7 +35247,9 @@ async function verifyExitBundle(bundleDir, options = {}) {
33500
35247
  );
33501
35248
  }
33502
35249
  }
33503
- const reputationFailed = reputation?.bundle_signature_valid === false || (reputation?.invalid_attestations ?? 0) > 0;
35250
+ const reputationBundleFailed = reputation?.bundle_signature_valid === false;
35251
+ const reputationAttestationFailed = (reputation?.invalid_attestations ?? 0) > 0;
35252
+ const reputationFailed = reputationBundleFailed || reputationAttestationFailed;
33504
35253
  const identityFailed = identity ? !identity.signature_valid : false;
33505
35254
  const unverifiableCount = reputation?.unverifiable_attestations ?? 0;
33506
35255
  const unverifiableFailed = unverifiableCount > 0 && !options.acceptUnverifiableAttestations;
@@ -33509,6 +35258,16 @@ async function verifyExitBundle(bundleDir, options = {}) {
33509
35258
  `${unverifiableCount} reputation attestation(s) have unknown signer public keys; pass --accept-unverifiable-attestations to import anyway`
33510
35259
  );
33511
35260
  }
35261
+ let detailedFailureClass;
35262
+ if (identityFailed) {
35263
+ detailedFailureClass = "identity_signature_invalid";
35264
+ } else if (reputationBundleFailed) {
35265
+ detailedFailureClass = "reputation_bundle_signature_invalid";
35266
+ } else if (reputationAttestationFailed) {
35267
+ detailedFailureClass = "reputation_attestation_signature_invalid";
35268
+ } else if (unverifiableFailed) {
35269
+ detailedFailureClass = "reputation_unverifiable_attestations";
35270
+ }
33512
35271
  return {
33513
35272
  version: "1.1",
33514
35273
  passed: !reputationFailed && !identityFailed && !unverifiableFailed,
@@ -33528,7 +35287,7 @@ async function verifyExitBundle(bundleDir, options = {}) {
33528
35287
  identity,
33529
35288
  audit,
33530
35289
  reputation,
33531
- failure_class: reputationFailed || identityFailed || unverifiableFailed ? "other" : void 0
35290
+ failure_class: detailedFailureClass
33532
35291
  };
33533
35292
  }
33534
35293
 
@@ -33918,7 +35677,7 @@ async function resolveSourceMasterKey(encryptedState, opts) {
33918
35677
  }
33919
35678
  return null;
33920
35679
  }
33921
- async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIdentityId) {
35680
+ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIdentityId, importedRekeyEntries) {
33922
35681
  const destinationSigner = opts.destinationSignerIdentityId ? opts.identityManager.get(opts.destinationSignerIdentityId) : opts.identityManager.getDefault();
33923
35682
  if (!destinationSigner) {
33924
35683
  return {
@@ -33980,8 +35739,9 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
33980
35739
  }
33981
35740
  }
33982
35741
  }
35742
+ let plaintext;
33983
35743
  try {
33984
- const plaintext = decrypt(
35744
+ plaintext = decrypt(
33985
35745
  item.entry.payload,
33986
35746
  deriveNamespaceKey(sourceMasterKey, item.namespace)
33987
35747
  );
@@ -33990,28 +35750,30 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
33990
35750
  skipped++;
33991
35751
  continue;
33992
35752
  }
33993
- await stateStore.write(
33994
- item.namespace,
33995
- item.key,
33996
- bytesToString(plaintext),
33997
- destinationSigner.identity_id,
33998
- destinationSigner.encrypted_private_key,
33999
- identityEncryptionKey,
34000
- {
34001
- content_type: item.entry.metadata.content_type,
34002
- ttl_seconds: item.entry.metadata.ttl_seconds,
34003
- tags: [
34004
- ...item.entry.metadata.tags ?? [],
34005
- "exit-import",
34006
- `source:${item.entry.kid}`
34007
- ]
34008
- }
34009
- );
34010
- imported++;
34011
35753
  } catch {
34012
35754
  skippedInvalidSig++;
34013
35755
  skipped++;
35756
+ continue;
34014
35757
  }
35758
+ await stateStore.write(
35759
+ item.namespace,
35760
+ item.key,
35761
+ bytesToString(plaintext),
35762
+ destinationSigner.identity_id,
35763
+ destinationSigner.encrypted_private_key,
35764
+ identityEncryptionKey,
35765
+ {
35766
+ content_type: item.entry.metadata.content_type,
35767
+ ttl_seconds: item.entry.metadata.ttl_seconds,
35768
+ tags: [
35769
+ ...item.entry.metadata.tags ?? [],
35770
+ "exit-import",
35771
+ `source:${item.entry.kid}`
35772
+ ]
35773
+ }
35774
+ );
35775
+ imported++;
35776
+ importedRekeyEntries?.push({ namespace: item.namespace, key: item.key });
34015
35777
  }
34016
35778
  return {
34017
35779
  status: "rekeyed",
@@ -34022,6 +35784,23 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
34022
35784
  conflicts
34023
35785
  };
34024
35786
  }
35787
+ async function cleanupStagedPaths(storage, staged) {
35788
+ let removed = 0;
35789
+ const failed = [];
35790
+ for (const loc of staged) {
35791
+ try {
35792
+ const ok = await storage.delete(loc.namespace, loc.key);
35793
+ if (ok) {
35794
+ removed++;
35795
+ } else {
35796
+ failed.push(loc);
35797
+ }
35798
+ } catch {
35799
+ failed.push(loc);
35800
+ }
35801
+ }
35802
+ return { removed, failed };
35803
+ }
34025
35804
  async function stageArtifact(storage, namespace, key, value) {
34026
35805
  await storage.write(namespace, key, jsonBytes(value));
34027
35806
  }
@@ -34146,6 +35925,8 @@ async function importExitBundle(opts) {
34146
35925
  }
34147
35926
  const importId = importIdForManifest(manifest);
34148
35927
  const stagedArtifacts = [];
35928
+ const stagedLocations = [];
35929
+ const importedRekeyEntries = [];
34149
35930
  if (identityArtifact) {
34150
35931
  await stageArtifact(
34151
35932
  opts.storage,
@@ -34154,10 +35935,15 @@ async function importExitBundle(opts) {
34154
35935
  identityArtifact.json
34155
35936
  );
34156
35937
  stagedArtifacts.push("public_identity");
35938
+ stagedLocations.push({
35939
+ namespace: EXIT_PUBLIC_IDENTITIES_NAMESPACE,
35940
+ key: identityArtifact.json.bundle.identity_id
35941
+ });
34157
35942
  }
34158
35943
  if (policySet) {
34159
35944
  await stageArtifact(opts.storage, EXIT_POLICY_SETS_NAMESPACE, importId, policySet.json);
34160
35945
  stagedArtifacts.push("policy_set");
35946
+ stagedLocations.push({ namespace: EXIT_POLICY_SETS_NAMESPACE, key: importId });
34161
35947
  }
34162
35948
  if (auditReceipts) {
34163
35949
  await stageArtifact(
@@ -34167,10 +35953,12 @@ async function importExitBundle(opts) {
34167
35953
  auditReceipts.json
34168
35954
  );
34169
35955
  stagedArtifacts.push("audit_receipts");
35956
+ stagedLocations.push({ namespace: EXIT_AUDIT_RECEIPTS_NAMESPACE, key: importId });
34170
35957
  }
34171
35958
  if (commitments) {
34172
35959
  await stageArtifact(opts.storage, EXIT_COMMITMENTS_NAMESPACE, importId, commitments.json);
34173
35960
  stagedArtifacts.push("commitments");
35961
+ stagedLocations.push({ namespace: EXIT_COMMITMENTS_NAMESPACE, key: importId });
34174
35962
  }
34175
35963
  if (placeholderMetadata) {
34176
35964
  await stageArtifact(
@@ -34180,12 +35968,17 @@ async function importExitBundle(opts) {
34180
35968
  placeholderMetadata.json
34181
35969
  );
34182
35970
  stagedArtifacts.push("placeholder_vault_metadata");
35971
+ stagedLocations.push({
35972
+ namespace: EXIT_PLACEHOLDER_METADATA_NAMESPACE,
35973
+ key: importId
35974
+ });
34183
35975
  }
34184
35976
  await stageArtifact(opts.storage, EXIT_IMPORT_NAMESPACE, importId, {
34185
35977
  manifest: manifest.body,
34186
35978
  verified_at: verification.verified_at,
34187
35979
  activated_at: (/* @__PURE__ */ new Date()).toISOString()
34188
35980
  });
35981
+ stagedLocations.push({ namespace: EXIT_IMPORT_NAMESPACE, key: importId });
34189
35982
  const publicKeys = identityArtifact ? publicKeysFromIdentityArtifact(identityArtifact.json) : { byIdentityId: /* @__PURE__ */ new Map(), byDid: /* @__PURE__ */ new Map() };
34190
35983
  let reputationResult = {
34191
35984
  imported_attestations: 0,
@@ -34210,26 +36003,57 @@ async function importExitBundle(opts) {
34210
36003
  encryptedState?.json ?? null,
34211
36004
  opts
34212
36005
  );
34213
- const stateResult = encryptedState && encryptedState.json.entries.length > 0 ? sourceMasterKey ? await rekeyState(
34214
- encryptedState.json,
34215
- opts,
34216
- sourceMasterKey,
34217
- publicKeys.byIdentityId
34218
- ) : {
34219
- status: "staged_requires_source_key",
34220
- imported_keys: 0,
34221
- skipped_keys: encryptedState.json.entries.length,
34222
- skipped_invalid_sig: 0,
34223
- skipped_unknown_kid: 0,
34224
- conflicts: conflicts.state_conflicts.length
34225
- } : {
34226
- status: "not_requested",
34227
- imported_keys: 0,
34228
- skipped_keys: 0,
34229
- skipped_invalid_sig: 0,
34230
- skipped_unknown_kid: 0,
34231
- conflicts: 0
34232
- };
36006
+ let stateResult;
36007
+ try {
36008
+ stateResult = encryptedState && encryptedState.json.entries.length > 0 ? sourceMasterKey ? await rekeyState(
36009
+ encryptedState.json,
36010
+ opts,
36011
+ sourceMasterKey,
36012
+ publicKeys.byIdentityId,
36013
+ importedRekeyEntries
36014
+ ) : {
36015
+ status: "staged_requires_source_key",
36016
+ imported_keys: 0,
36017
+ skipped_keys: encryptedState.json.entries.length,
36018
+ skipped_invalid_sig: 0,
36019
+ skipped_unknown_kid: 0,
36020
+ conflicts: conflicts.state_conflicts.length
36021
+ } : {
36022
+ status: "not_requested",
36023
+ imported_keys: 0,
36024
+ skipped_keys: 0,
36025
+ skipped_invalid_sig: 0,
36026
+ skipped_unknown_kid: 0,
36027
+ conflicts: 0
36028
+ };
36029
+ } catch (err) {
36030
+ const toCleanup = [
36031
+ ...importedRekeyEntries,
36032
+ ...stagedLocations
36033
+ ];
36034
+ const cleanup = await cleanupStagedPaths(opts.storage, toCleanup);
36035
+ opts.auditLog.append(
36036
+ "l1",
36037
+ "exit_bundle_rekey_failed_cleanup",
36038
+ manifest.body.identity_binding.identity_id,
36039
+ {
36040
+ import_id: importId,
36041
+ manifest_version: manifest.body.manifest_version,
36042
+ rekey_entries_removed: importedRekeyEntries.length,
36043
+ staged_artifacts_removed: stagedLocations.length,
36044
+ removed_total: cleanup.removed,
36045
+ cleanup_failed_count: cleanup.failed.length,
36046
+ original_error: err instanceof Error ? err.message : String(err)
36047
+ },
36048
+ "failure"
36049
+ );
36050
+ await opts.auditLog.flush();
36051
+ const originalMessage = err instanceof Error ? err.message : String(err);
36052
+ throw new ExitBundleImportError(
36053
+ "REKEY_FAILED_AND_CLEANED",
36054
+ `Exit-bundle re-key failed: ${originalMessage}. Cleanup removed ${cleanup.removed} of ${toCleanup.length} staged paths (${importedRekeyEntries.length} re-keyed entries plus ${stagedLocations.length} staged artifacts; ${cleanup.failed.length} cleanup deletes failed).`
36055
+ );
36056
+ }
34233
36057
  opts.auditLog.append("l1", "exit_bundle_import_activate", manifest.body.identity_binding.identity_id, {
34234
36058
  import_id: importId,
34235
36059
  manifest_version: manifest.body.manifest_version,
@@ -34455,7 +36279,19 @@ async function runExitCommand(args) {
34455
36279
  }
34456
36280
  const config = await loadConfig();
34457
36281
  const ctx = await openExitContext(argv, env);
34458
- const policy = await loadPrincipalPolicy(ctx.storagePath);
36282
+ let policy;
36283
+ try {
36284
+ policy = await loadPrincipalPolicy(ctx.storagePath);
36285
+ } catch (policyErr) {
36286
+ if (policyErr instanceof MalformedPrincipalPolicyError) {
36287
+ write(err, `
36288
+ Sanctuary cannot proceed.
36289
+ ${policyErr.message}
36290
+ `);
36291
+ return 1;
36292
+ }
36293
+ throw policyErr;
36294
+ }
34459
36295
  const result = await exportExitBundle({
34460
36296
  bundleDir: outDir,
34461
36297
  storage: ctx.storage,
@@ -35107,7 +36943,19 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
35107
36943
  const profileStore = new SovereigntyProfileStore(storage, masterKey);
35108
36944
  await profileStore.load();
35109
36945
  const { tools: profileTools } = createSovereigntyProfileTools(profileStore, auditLog);
35110
- const policy = await loadPrincipalPolicy(config.storage_path);
36946
+ let policy;
36947
+ try {
36948
+ policy = await loadPrincipalPolicy(config.storage_path);
36949
+ } catch (err) {
36950
+ if (err instanceof MalformedPrincipalPolicyError) {
36951
+ console.error(`
36952
+ Sanctuary cannot start.
36953
+ ${err.message}
36954
+ `);
36955
+ process.exit(1);
36956
+ }
36957
+ throw err;
36958
+ }
35111
36959
  const baseline = new BaselineTracker(storage, masterKey);
35112
36960
  await baseline.load();
35113
36961
  let approvalChannel;
@@ -35204,7 +37052,35 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
35204
37052
  timestamp: alert.timestamp
35205
37053
  });
35206
37054
  } : void 0;
35207
- const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
37055
+ const fortressIdForAggregator = fortressIdFromStoragePath(config.storage_path);
37056
+ const aggregatorIdentityId = identityManager.getPrimaryIdentityId() ?? `fortress:${config.storage_path}`;
37057
+ const approvalAggregator = new ApprovalAggregator({
37058
+ storage,
37059
+ masterKey,
37060
+ auditLog,
37061
+ identityId: aggregatorIdentityId,
37062
+ fortressId: fortressIdForAggregator
37063
+ });
37064
+ const wrappedApprovalChannel = new AggregatorBackedChannel({
37065
+ underlying: approvalChannel,
37066
+ aggregator: approvalAggregator,
37067
+ resolveRedirect: makeRedirectResolverFromPolicySupplier(() => policy),
37068
+ replaceModeTimeoutMs: policy.approval_channel.timeout_seconds * 1e3
37069
+ });
37070
+ const gate = new ApprovalGate(
37071
+ policy,
37072
+ baseline,
37073
+ wrappedApprovalChannel,
37074
+ auditLog,
37075
+ injectionDetector,
37076
+ onInjectionAlert
37077
+ );
37078
+ gate.setApprovalEventCallback((event) => {
37079
+ void approvalAggregator.ingest(event);
37080
+ });
37081
+ if (dashboard) {
37082
+ dashboard.setApprovalAggregator(approvalAggregator);
37083
+ }
35208
37084
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
35209
37085
  const { tools: sanctuaryMetaTools } = createSanctuaryTools({
35210
37086
  config,
@@ -35399,6 +37275,7 @@ exports.HERO_COPY = HERO_COPY;
35399
37275
  exports.InMemoryModelProvenanceStore = InMemoryModelProvenanceStore;
35400
37276
  exports.InjectionDetector = InjectionDetector;
35401
37277
  exports.MODEL_PRESETS = MODEL_PRESETS;
37278
+ exports.MalformedPrincipalPolicyError = MalformedPrincipalPolicyError;
35402
37279
  exports.MemoryStorage = MemoryStorage;
35403
37280
  exports.PolicyStore = PolicyStore;
35404
37281
  exports.ProxyRouter = ProxyRouter;