@sanctuary-framework/mcp-server 1.2.3 → 1.2.4

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
@@ -4589,20 +4589,52 @@ approval_channel:
4589
4589
  timeout_seconds: 300
4590
4590
  `;
4591
4591
  }
4592
+ var MalformedPrincipalPolicyError = class extends Error {
4593
+ constructor(policyPath, reason) {
4594
+ super(
4595
+ `Principal policy at ${policyPath} is malformed and cannot be loaded.
4596
+ Reason: ${reason}
4597
+ 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.`
4598
+ );
4599
+ this.policyPath = policyPath;
4600
+ this.reason = reason;
4601
+ this.name = "MalformedPrincipalPolicyError";
4602
+ }
4603
+ policyPath;
4604
+ reason;
4605
+ };
4592
4606
  async function loadPrincipalPolicy(storagePath) {
4593
4607
  const policyPath = path.join(storagePath, "principal-policy.yaml");
4608
+ let content;
4609
+ try {
4610
+ content = await promises.readFile(policyPath, "utf-8");
4611
+ } catch (err) {
4612
+ const code = err?.code;
4613
+ if (code === "ENOENT") {
4614
+ const defaultYaml = generateDefaultPolicyYaml();
4615
+ try {
4616
+ await promises.writeFile(policyPath, defaultYaml, "utf-8");
4617
+ await promises.chmod(policyPath, 384);
4618
+ } catch (writeErr) {
4619
+ console.warn(
4620
+ `Sanctuary: could not write default principal policy to ${policyPath}: ${writeErr.message}. Continuing with in-memory default.`
4621
+ );
4622
+ }
4623
+ return Object.freeze({ ...DEFAULT_POLICY });
4624
+ }
4625
+ throw new MalformedPrincipalPolicyError(
4626
+ policyPath,
4627
+ `read failed: ${err.message}`
4628
+ );
4629
+ }
4594
4630
  try {
4595
- const content = await promises.readFile(policyPath, "utf-8");
4596
4631
  const policy = parsePolicy(content);
4597
4632
  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 });
4633
+ } catch (parseErr) {
4634
+ throw new MalformedPrincipalPolicyError(
4635
+ policyPath,
4636
+ parseErr.message
4637
+ );
4606
4638
  }
4607
4639
  }
4608
4640
 
@@ -4829,7 +4861,7 @@ function deepSortKeys(obj) {
4829
4861
  return sorted;
4830
4862
  }
4831
4863
  function canonicalizeForSigning(body) {
4832
- return JSON.stringify(deepSortKeys(body));
4864
+ return JSON.stringify(deepSortKeys(body)).normalize("NFC");
4833
4865
  }
4834
4866
 
4835
4867
  // src/shr/generator.ts
@@ -11651,6 +11683,16 @@ var HUB_ROUTES = {
11651
11683
  */
11652
11684
  CHAT_CONCIERGE_SEND: "/api/hub/chat/concierge",
11653
11685
  CHAT_CONCIERGE_HISTORY: "/api/hub/chat/concierge/history",
11686
+ /**
11687
+ * Concierge memory thread routes (WP-V1.3-9 Tau-1). Thread enumeration,
11688
+ * scrollback, and operator-initiated thread delete. Distinct from the
11689
+ * v1.2 `/history` route, which surfaces the active in-session thread
11690
+ * shape; the new routes target persisted multi-thread memory used by
11691
+ * v1.3 conversational sovereignty depth.
11692
+ */
11693
+ CHAT_CONCIERGE_THREADS_LIST: "/api/hub/chat/concierge/threads",
11694
+ CHAT_CONCIERGE_THREAD_READ: "/api/hub/chat/concierge/threads/:thread_id",
11695
+ CHAT_CONCIERGE_THREAD_DELETE: "/api/hub/chat/concierge/threads/:thread_id",
11654
11696
  /**
11655
11697
  * Click-to-inspect panel (WP-V1.2 reshape). Returns the agent's
11656
11698
  * recent activity feed, pending Tier 1 approvals routed through this
@@ -11674,6 +11716,10 @@ var HUB_TIER_1_AGENT_CONTROL_ACTIONS = [
11674
11716
  ];
11675
11717
  var HUB_ACTIVITY_DEFAULT_LIMIT = 50;
11676
11718
  var HUB_ACTIVITY_MAX_LIMIT = 500;
11719
+ var HUB_CHAT_THREADS_DEFAULT_LIMIT = 50;
11720
+ var HUB_CHAT_THREADS_MAX_LIMIT = 500;
11721
+ var HUB_CHAT_TURNS_DEFAULT_LIMIT = 200;
11722
+ var HUB_CHAT_TURNS_MAX_LIMIT = 1e3;
11677
11723
  var HUB_INBOX_DEFAULT_LIMIT = 100;
11678
11724
  var HUB_INBOX_MAX_LIMIT = 500;
11679
11725
  var HUB_AGENTS_DEFAULT_LIMIT = 100;
@@ -11845,6 +11891,23 @@ function checkChatMessage(value) {
11845
11891
  }
11846
11892
  return trimmed;
11847
11893
  }
11894
+ function matchConciergeThreadRoute(path) {
11895
+ const prefix = `${HUB_API_PREFIX}/chat/concierge/threads/`;
11896
+ if (!path.startsWith(prefix)) return null;
11897
+ const rest = path.slice(prefix.length);
11898
+ if (rest.length === 0 || rest.includes("/")) return null;
11899
+ const decoded = decodeURIComponent(rest);
11900
+ if (decoded.length === 0) return null;
11901
+ return { threadId: decoded };
11902
+ }
11903
+ function parseSince(raw) {
11904
+ if (raw === null || raw === "") return void 0;
11905
+ const parsed = Number.parseInt(raw, 10);
11906
+ if (Number.isNaN(parsed) || parsed < 0) {
11907
+ throw new HubValidationError("since must be a non-negative integer");
11908
+ }
11909
+ return parsed;
11910
+ }
11848
11911
  function matchInboxRoute(path) {
11849
11912
  const prefix = `${HUB_API_PREFIX}/inbox/`;
11850
11913
  if (!path.startsWith(prefix)) return null;
@@ -12028,6 +12091,47 @@ async function handleHubRoute(deps, req, res) {
12028
12091
  writeJSON2(res, 200, { ok: true, data: { messages } });
12029
12092
  return true;
12030
12093
  }
12094
+ if (method === "GET" && path === HUB_ROUTES.CHAT_CONCIERGE_THREADS_LIST) {
12095
+ const limit = parseLimit(
12096
+ url.searchParams.get("limit"),
12097
+ HUB_CHAT_THREADS_DEFAULT_LIMIT,
12098
+ HUB_CHAT_THREADS_MAX_LIMIT
12099
+ );
12100
+ const threads = await deps.service.listConciergeMemoryThreads({ limit });
12101
+ writeJSON2(res, 200, { ok: true, data: { threads } });
12102
+ return true;
12103
+ }
12104
+ {
12105
+ const threadMatch = matchConciergeThreadRoute(path);
12106
+ if (threadMatch) {
12107
+ if (method === "GET") {
12108
+ const since = parseSince(url.searchParams.get("since"));
12109
+ const limit = parseLimit(
12110
+ url.searchParams.get("limit"),
12111
+ HUB_CHAT_TURNS_DEFAULT_LIMIT,
12112
+ HUB_CHAT_TURNS_MAX_LIMIT
12113
+ );
12114
+ const readOpts = { limit };
12115
+ if (since !== void 0) readOpts.sinceTurnId = since;
12116
+ const turns = await deps.service.readConciergeMemoryThread(
12117
+ threadMatch.threadId,
12118
+ readOpts
12119
+ );
12120
+ writeJSON2(res, 200, { ok: true, data: { turns } });
12121
+ return true;
12122
+ }
12123
+ if (method === "DELETE") {
12124
+ const removed = await deps.service.deleteConciergeMemoryThread(
12125
+ threadMatch.threadId
12126
+ );
12127
+ writeJSON2(res, removed ? 200 : 404, {
12128
+ ok: removed,
12129
+ data: { thread_id: threadMatch.threadId, removed }
12130
+ });
12131
+ return true;
12132
+ }
12133
+ }
12134
+ }
12031
12135
  writeJSON2(res, 404, { ok: false, error: "not_found", path });
12032
12136
  return true;
12033
12137
  } catch (err) {
@@ -16077,6 +16181,162 @@ async function dispatchV11Request(inputs, req, res, url, method) {
16077
16181
  return false;
16078
16182
  }
16079
16183
 
16184
+ // src/principal-policy/approval-aggregator-routes.ts
16185
+ var APPROVAL_INBOX_API_PREFIX = "/api/approval-inbox";
16186
+ var APPROVAL_INBOX_OPERATOR_DEFAULT = "operator_dashboard";
16187
+ var APPROVAL_INBOX_DEFAULT_LIMIT = 50;
16188
+ var APPROVAL_INBOX_MAX_LIMIT = 200;
16189
+ function writeJSON4(res, status, payload) {
16190
+ res.writeHead(status, {
16191
+ "Content-Type": "application/json",
16192
+ "Cache-Control": "no-store"
16193
+ });
16194
+ res.end(JSON.stringify(payload));
16195
+ }
16196
+ function parseLimit2(raw, defaultValue, max) {
16197
+ if (raw === null || raw === "") return defaultValue;
16198
+ const parsed = Number.parseInt(raw, 10);
16199
+ if (Number.isNaN(parsed) || parsed < 0) {
16200
+ return defaultValue;
16201
+ }
16202
+ return Math.min(parsed, max);
16203
+ }
16204
+ function isStatusFilter(value) {
16205
+ return value === "pending" || value === "approved" || value === "denied" || value === "timeout" || value === "expired";
16206
+ }
16207
+ function matchEntryRoute(path) {
16208
+ const prefix = `${APPROVAL_INBOX_API_PREFIX}/`;
16209
+ if (!path.startsWith(prefix)) return null;
16210
+ const rest = path.slice(prefix.length);
16211
+ if (rest.length === 0) return null;
16212
+ const slash = rest.indexOf("/");
16213
+ if (slash === -1) {
16214
+ return { aggregatorId: decodeURIComponent(rest), action: null };
16215
+ }
16216
+ return {
16217
+ aggregatorId: decodeURIComponent(rest.slice(0, slash)),
16218
+ action: rest.slice(slash + 1)
16219
+ };
16220
+ }
16221
+ async function handleStream2(deps, res) {
16222
+ res.writeHead(200, {
16223
+ "Content-Type": "text/event-stream",
16224
+ "Cache-Control": "no-cache, no-transform",
16225
+ Connection: "keep-alive",
16226
+ "X-Accel-Buffering": "no"
16227
+ });
16228
+ const initial = await deps.aggregator.list({ status: "pending" });
16229
+ res.write(
16230
+ `event: approval_inbox_snapshot
16231
+ data: ${JSON.stringify({ entries: initial })}
16232
+
16233
+ `
16234
+ );
16235
+ const unsubscribe = deps.aggregator.onEvent((event) => {
16236
+ try {
16237
+ res.write(
16238
+ `event: approval_inbox_${event.type}
16239
+ data: ${JSON.stringify(event.entry)}
16240
+
16241
+ `
16242
+ );
16243
+ } catch {
16244
+ }
16245
+ });
16246
+ const keepAlive = setInterval(() => {
16247
+ try {
16248
+ res.write(": keepalive\n\n");
16249
+ } catch {
16250
+ }
16251
+ }, 25e3);
16252
+ const cleanup = () => {
16253
+ clearInterval(keepAlive);
16254
+ unsubscribe();
16255
+ };
16256
+ res.on("close", cleanup);
16257
+ res.on("error", cleanup);
16258
+ }
16259
+ async function handleApprovalInboxRoute(deps, req, res) {
16260
+ const host = req.headers.host || "localhost";
16261
+ const url = new URL(req.url ?? "/", `http://${host}`);
16262
+ const method = (req.method ?? "GET").toUpperCase();
16263
+ const path = url.pathname;
16264
+ if (path !== APPROVAL_INBOX_API_PREFIX && !path.startsWith(`${APPROVAL_INBOX_API_PREFIX}/`)) {
16265
+ return false;
16266
+ }
16267
+ const checkAuth = authMiddleware(deps.authConfig);
16268
+ if (!checkAuth(req, res, url)) return true;
16269
+ try {
16270
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/stream`) {
16271
+ await handleStream2(deps, res);
16272
+ return true;
16273
+ }
16274
+ if (method === "GET" && path === APPROVAL_INBOX_API_PREFIX) {
16275
+ const limit = parseLimit2(
16276
+ url.searchParams.get("limit"),
16277
+ APPROVAL_INBOX_DEFAULT_LIMIT,
16278
+ APPROVAL_INBOX_MAX_LIMIT
16279
+ );
16280
+ const statusRaw = url.searchParams.get("status");
16281
+ const status = statusRaw && isStatusFilter(statusRaw) ? statusRaw : "pending";
16282
+ const sinceTs = url.searchParams.get("since") ?? void 0;
16283
+ const entries = await deps.aggregator.list({
16284
+ status,
16285
+ limit,
16286
+ ...sinceTs !== void 0 ? { sinceTs } : {}
16287
+ });
16288
+ writeJSON4(res, 200, { ok: true, data: { entries } });
16289
+ return true;
16290
+ }
16291
+ const entryMatch = matchEntryRoute(path);
16292
+ if (entryMatch === null) {
16293
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
16294
+ return true;
16295
+ }
16296
+ if (method === "GET" && entryMatch.action === null) {
16297
+ const entries = await deps.aggregator.list({ limit: APPROVAL_INBOX_MAX_LIMIT });
16298
+ const entry = entries.find(
16299
+ (e) => e.aggregator_id === entryMatch.aggregatorId
16300
+ );
16301
+ if (!entry) {
16302
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
16303
+ return true;
16304
+ }
16305
+ const payload = await deps.aggregator.getFullPayload(
16306
+ entryMatch.aggregatorId
16307
+ );
16308
+ writeJSON4(res, 200, { ok: true, data: { entry, request_payload: payload } });
16309
+ return true;
16310
+ }
16311
+ if (method === "POST" && (entryMatch.action === "approve" || entryMatch.action === "deny")) {
16312
+ const decision = entryMatch.action === "approve" ? "approved" : "denied";
16313
+ const operatorId = deps.operatorId ?? APPROVAL_INBOX_OPERATOR_DEFAULT;
16314
+ try {
16315
+ const entry = await deps.aggregator.resolve(
16316
+ entryMatch.aggregatorId,
16317
+ decision,
16318
+ operatorId
16319
+ );
16320
+ writeJSON4(res, 200, { ok: true, data: { entry } });
16321
+ } catch (err) {
16322
+ const msg = err instanceof Error ? err.message : String(err);
16323
+ if (msg === "approval-aggregator: not_found") {
16324
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
16325
+ } else {
16326
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
16327
+ }
16328
+ }
16329
+ return true;
16330
+ }
16331
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
16332
+ return true;
16333
+ } catch (err) {
16334
+ const msg = err instanceof Error ? err.message : String(err);
16335
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
16336
+ return true;
16337
+ }
16338
+ }
16339
+
16080
16340
  // src/principal-policy/dashboard.ts
16081
16341
  var SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
16082
16342
  var SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
@@ -16141,6 +16401,14 @@ var DashboardApprovalChannel = class {
16141
16401
  * regardless. Default route flip is deferred to v1.2.
16142
16402
  */
16143
16403
  v11Bindings = null;
16404
+ /**
16405
+ * v1.3 WP-V1.3-10 Cross-Harness Approval Inbox aggregator. Mounted
16406
+ * additively at `/api/approval-inbox/*` when set. Legacy approval
16407
+ * routes at `/api/approvals/:id/(allow|deny)` continue to serve. The
16408
+ * aggregator is a passive subscriber to the gate; the routes here are
16409
+ * the operator-facing query / decision surface.
16410
+ */
16411
+ approvalAggregator = null;
16144
16412
  constructor(config) {
16145
16413
  this.config = config;
16146
16414
  this.authToken = config.auth_token;
@@ -16191,6 +16459,34 @@ var DashboardApprovalChannel = class {
16191
16459
  setV11Bindings(bindings) {
16192
16460
  this.v11Bindings = bindings;
16193
16461
  }
16462
+ /**
16463
+ * v1.3 WP-V1.3-10 Upsilon-1: bind the cross-harness approval inbox
16464
+ * aggregator. Once set, requests to `/api/approval-inbox/*` route
16465
+ * through `handleApprovalInboxRoute`. Pass `null` to detach (used by
16466
+ * tests + during shutdown).
16467
+ */
16468
+ setApprovalAggregator(aggregator) {
16469
+ this.approvalAggregator = aggregator;
16470
+ }
16471
+ /**
16472
+ * v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
16473
+ * before the legacy approval route table. Returns true when served.
16474
+ */
16475
+ async dispatchApprovalInbox(req, res) {
16476
+ if (!this.approvalAggregator) return false;
16477
+ return handleApprovalInboxRoute(
16478
+ {
16479
+ authConfig: {
16480
+ loopbackAutoAuth: this._autoAuthLocalhost,
16481
+ ...this.authToken !== void 0 ? { authToken: this.authToken } : {}
16482
+ },
16483
+ aggregator: this.approvalAggregator,
16484
+ operatorId: this.identityManager?.getPrimaryIdentityId() ?? void 0
16485
+ },
16486
+ req,
16487
+ res
16488
+ );
16489
+ }
16194
16490
  /**
16195
16491
  * v1.1 dispatch entry point. Called from `handleRequest` before the
16196
16492
  * legacy route table. Returns true when the request was served by v1.1
@@ -16566,6 +16862,18 @@ var DashboardApprovalChannel = class {
16566
16862
  res.end();
16567
16863
  return;
16568
16864
  }
16865
+ if (this.approvalAggregator && url.pathname.startsWith(APPROVAL_INBOX_API_PREFIX)) {
16866
+ this.dispatchApprovalInbox(req, res).then((handled) => {
16867
+ if (handled) return;
16868
+ this.handleLegacyRequest(req, res, url, method);
16869
+ }).catch(() => {
16870
+ if (!res.headersSent) {
16871
+ res.writeHead(500, { "Content-Type": "application/json" });
16872
+ res.end(JSON.stringify({ error: "Internal server error" }));
16873
+ }
16874
+ });
16875
+ return;
16876
+ }
16569
16877
  if (this.v11Bindings) {
16570
16878
  this.dispatchV11(req, res, url, method).then((handled) => {
16571
16879
  if (handled) return;
@@ -18584,14 +18892,25 @@ var ApprovalGate = class {
18584
18892
  auditLog;
18585
18893
  injectionDetector;
18586
18894
  onInjectionAlert;
18895
+ onApprovalEvent;
18587
18896
  proxyTierResolver;
18588
- constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert) {
18897
+ constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert, onApprovalEvent) {
18589
18898
  this.policy = policy;
18590
18899
  this.baseline = baseline;
18591
18900
  this.channel = channel;
18592
18901
  this.auditLog = auditLog;
18593
18902
  this.injectionDetector = injectionDetector ?? new InjectionDetector();
18594
18903
  this.onInjectionAlert = onInjectionAlert;
18904
+ this.onApprovalEvent = onApprovalEvent;
18905
+ }
18906
+ /**
18907
+ * Set the approval-event callback after construction. Used by the
18908
+ * Upsilon-1 wire-up when the aggregator is constructed alongside the
18909
+ * gate. The aggregator subscribes through this setter rather than the
18910
+ * constructor so existing call sites continue to work unchanged.
18911
+ */
18912
+ setApprovalEventCallback(cb) {
18913
+ this.onApprovalEvent = cb;
18595
18914
  }
18596
18915
  /**
18597
18916
  * Set the proxy tier resolver. Called after the proxy router is initialized.
@@ -18825,21 +19144,105 @@ var ApprovalGate = class {
18825
19144
  }
18826
19145
  /**
18827
19146
  * Request approval from the human principal.
19147
+ *
19148
+ * Fail-closed contract (full-sweep #49): if the channel throws (network
19149
+ * down, callback unreachable, dashboard SSE peer dropped, webhook DNS
19150
+ * failure, etc.), the gate denies the operation and audit-logs the cause.
19151
+ * Channel-internal timeouts already resolve with decision: "deny" per
19152
+ * SEC-002; this catch covers the remaining "channel raised" path so an
19153
+ * unhandled rejection cannot turn into an indeterminate state at the gate.
18828
19154
  */
18829
19155
  async requestApproval(operation, tier, reason, context) {
19156
+ const requestTimestamp = (/* @__PURE__ */ new Date()).toISOString();
18830
19157
  const request = {
18831
19158
  operation,
18832
19159
  tier,
18833
19160
  reason,
18834
19161
  context,
18835
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
19162
+ timestamp: requestTimestamp
18836
19163
  };
18837
- const response = await this.channel.requestApproval(request);
19164
+ const correlationId = `${requestTimestamp}:${operation}:${Math.random().toString(16).slice(2, 6)}`;
19165
+ if (this.onApprovalEvent) {
19166
+ try {
19167
+ this.onApprovalEvent({
19168
+ phase: "requested",
19169
+ operation,
19170
+ tier,
19171
+ reason,
19172
+ context,
19173
+ request_timestamp: requestTimestamp,
19174
+ correlation_id: correlationId
19175
+ });
19176
+ } catch {
19177
+ }
19178
+ }
19179
+ let response;
19180
+ try {
19181
+ response = await this.channel.requestApproval(request);
19182
+ } catch (err) {
19183
+ const errMessage = err instanceof Error ? err.message : String(err);
19184
+ const decidedAt = (/* @__PURE__ */ new Date()).toISOString();
19185
+ this.auditLog.append("l2", `gate_deny:${operation}`, "system", {
19186
+ tier,
19187
+ reason,
19188
+ decided_by: "channel_failure",
19189
+ channel_error: errMessage
19190
+ });
19191
+ if (this.onApprovalEvent) {
19192
+ try {
19193
+ this.onApprovalEvent({
19194
+ phase: "resolved",
19195
+ operation,
19196
+ tier,
19197
+ reason,
19198
+ context,
19199
+ request_timestamp: requestTimestamp,
19200
+ resolution: {
19201
+ decision: "deny",
19202
+ decided_at: decidedAt,
19203
+ decided_by: "channel_failure"
19204
+ },
19205
+ correlation_id: correlationId
19206
+ });
19207
+ } catch {
19208
+ }
19209
+ }
19210
+ return {
19211
+ allowed: false,
19212
+ tier,
19213
+ reason: AGENT_VISIBLE_DENY_REASONS.REQUIRES_APPROVAL,
19214
+ approval_required: true,
19215
+ approval_response: {
19216
+ decision: "deny",
19217
+ decided_at: decidedAt,
19218
+ decided_by: "channel_failure"
19219
+ }
19220
+ };
19221
+ }
18838
19222
  this.auditLog.append("l2", `gate_${response.decision}:${operation}`, "system", {
18839
19223
  tier,
18840
19224
  reason,
18841
19225
  decided_by: response.decided_by
18842
19226
  });
19227
+ if (this.onApprovalEvent) {
19228
+ try {
19229
+ this.onApprovalEvent({
19230
+ phase: "resolved",
19231
+ operation,
19232
+ tier,
19233
+ reason,
19234
+ context,
19235
+ request_timestamp: requestTimestamp,
19236
+ resolution: {
19237
+ decision: response.decision,
19238
+ decided_at: response.decided_at,
19239
+ decided_by: response.decided_by
19240
+ },
19241
+ correlation_id: correlationId
19242
+ });
19243
+ } catch {
19244
+ }
19245
+ }
18843
19246
  return {
18844
19247
  allowed: response.decision === "approve",
18845
19248
  tier,
@@ -18873,6 +19276,352 @@ var ApprovalGate = class {
18873
19276
  }
18874
19277
  };
18875
19278
 
19279
+ // src/principal-policy/approval-aggregator.ts
19280
+ init_encryption();
19281
+ init_encoding();
19282
+ var APPROVAL_AGGREGATOR_NAMESPACE = "_approval_aggregator";
19283
+ var APPROVAL_AGGREGATOR_HKDF_INFO = "l2-approval-aggregator-v1";
19284
+ var APPROVAL_AGGREGATOR_AUDIT_OPS = {
19285
+ AGGREGATED: "cross_harness_approval_aggregated",
19286
+ RESOLVED: "cross_harness_approval_resolved",
19287
+ DEDUPED: "cross_harness_approval_deduped"
19288
+ };
19289
+ var DEFAULT_PENDING_TTL_MS = 5 * 60 * 1e3;
19290
+ var DEFAULT_MAX_LIST_LIMIT = 200;
19291
+ var DEFAULT_LIST_PAGE_SIZE = 50;
19292
+ var ApprovalAggregator = class {
19293
+ storage;
19294
+ encryptionKey;
19295
+ auditLog;
19296
+ identityId;
19297
+ fortressId;
19298
+ pendingTtlMs;
19299
+ maxListLimit;
19300
+ now;
19301
+ resolveSourceContext;
19302
+ resolveHubInboxItemId;
19303
+ /** Cached entries by `aggregator_id`. */
19304
+ entries = /* @__PURE__ */ new Map();
19305
+ /** Dedup index: `${harness}|${agent}|${audit_id}` -> aggregator_id. */
19306
+ dedupIndex = /* @__PURE__ */ new Map();
19307
+ /** Correlation index: gate `correlation_id` -> aggregator_id. */
19308
+ correlationIndex = /* @__PURE__ */ new Map();
19309
+ /** Original request payloads kept in-memory for `getFullPayload()`. */
19310
+ fullPayloads = /* @__PURE__ */ new Map();
19311
+ /** Has the aggregator hydrated persisted entries on this process? */
19312
+ hydrated = false;
19313
+ /** Active SSE listeners. */
19314
+ listeners = /* @__PURE__ */ new Set();
19315
+ constructor(deps) {
19316
+ this.storage = deps.storage;
19317
+ this.encryptionKey = derivePurposeKey(
19318
+ deps.masterKey,
19319
+ APPROVAL_AGGREGATOR_HKDF_INFO
19320
+ );
19321
+ this.auditLog = deps.auditLog;
19322
+ this.identityId = deps.identityId;
19323
+ this.fortressId = deps.fortressId;
19324
+ this.pendingTtlMs = deps.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
19325
+ this.maxListLimit = deps.maxListLimit ?? DEFAULT_MAX_LIST_LIMIT;
19326
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
19327
+ this.resolveSourceContext = deps.resolveSourceContext ?? ((_event) => ({
19328
+ source_harness: this.fortressId,
19329
+ source_agent_id: this.fortressId
19330
+ }));
19331
+ this.resolveHubInboxItemId = deps.resolveHubInboxItemId ?? ((_event) => void 0);
19332
+ }
19333
+ /**
19334
+ * Subscribe an event listener. Returns an unsubscribe fn. SSE handlers
19335
+ * use this to forward aggregator emissions to the dashboard.
19336
+ */
19337
+ onEvent(listener) {
19338
+ this.listeners.add(listener);
19339
+ return () => this.listeners.delete(listener);
19340
+ }
19341
+ /**
19342
+ * Ingest a gate event. Returns the aggregator entry on first sight,
19343
+ * `null` when deduped. Resolution events update the existing record;
19344
+ * unmatched resolutions are dropped silently (caller's gate emitted a
19345
+ * resolved-without-requested pair, which the aggregator does not invent
19346
+ * a record for).
19347
+ */
19348
+ async ingest(event) {
19349
+ await this.hydrate();
19350
+ if (event.phase === "requested") {
19351
+ return this.ingestRequested(event);
19352
+ }
19353
+ if (event.phase === "resolved") {
19354
+ return this.ingestResolved(event);
19355
+ }
19356
+ return null;
19357
+ }
19358
+ /**
19359
+ * List pending or recently resolved entries. Pending entries past TTL
19360
+ * are lazily transitioned to `expired` and persisted before the list
19361
+ * snapshot is returned.
19362
+ */
19363
+ async list(opts) {
19364
+ await this.hydrate();
19365
+ await this.expireStale();
19366
+ const limit = Math.min(
19367
+ opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
19368
+ this.maxListLimit
19369
+ );
19370
+ const sinceMs = opts?.sinceTs ? Date.parse(opts.sinceTs) : Number.NEGATIVE_INFINITY;
19371
+ const matching = [];
19372
+ for (const entry of this.entries.values()) {
19373
+ if (opts?.status && entry.status !== opts.status) continue;
19374
+ if (Date.parse(entry.created_at) < sinceMs) continue;
19375
+ matching.push(entry);
19376
+ }
19377
+ matching.sort((a, b) => b.created_at.localeCompare(a.created_at));
19378
+ return matching.slice(0, limit);
19379
+ }
19380
+ /**
19381
+ * Return the original (unhashed) request payload for the entry. Returns
19382
+ * `null` when the entry is unknown or the payload was evicted (e.g. the
19383
+ * process restarted; payloads are in-memory only at v1.3 Upsilon-1).
19384
+ */
19385
+ async getFullPayload(aggregatorId) {
19386
+ await this.hydrate();
19387
+ if (!this.entries.has(aggregatorId)) return null;
19388
+ return this.fullPayloads.get(aggregatorId) ?? null;
19389
+ }
19390
+ /**
19391
+ * Resolve an entry. Used by both:
19392
+ * 1. The gate wire-up on channel-decision return.
19393
+ * 2. The HTTP `approve`/`deny` routes when an operator clicks.
19394
+ *
19395
+ * Idempotent: resolving an already-resolved entry is a no-op (the record
19396
+ * keeps its first decision and the audit log is not double-fired).
19397
+ * Unknown ids throw `Error("approval-aggregator: not_found")` so HTTP
19398
+ * routes return 404.
19399
+ */
19400
+ async resolve(aggregatorId, decision, operatorId) {
19401
+ await this.hydrate();
19402
+ const entry = this.entries.get(aggregatorId);
19403
+ if (!entry) {
19404
+ throw new Error("approval-aggregator: not_found");
19405
+ }
19406
+ if (entry.status !== "pending") {
19407
+ return entry;
19408
+ }
19409
+ entry.status = decision;
19410
+ entry.resolved_at = this.now().toISOString();
19411
+ entry.resolved_by = operatorId;
19412
+ await this.persist(entry);
19413
+ this.auditLog.append(
19414
+ "l2",
19415
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
19416
+ this.identityId,
19417
+ {
19418
+ aggregator_id: entry.aggregator_id,
19419
+ source_harness: entry.source_harness,
19420
+ source_agent_id: entry.source_agent_id,
19421
+ audit_log_entry_id: entry.audit_log_entry_id,
19422
+ policy_rule_id: entry.policy_rule_id,
19423
+ decision,
19424
+ decided_by: operatorId,
19425
+ decided_at: entry.resolved_at
19426
+ }
19427
+ );
19428
+ this.emit({ type: "resolved", entry: { ...entry } });
19429
+ return entry;
19430
+ }
19431
+ // ── Internal: ingest paths ─────────────────────────────────────────────
19432
+ async ingestRequested(event) {
19433
+ const ctx = this.resolveSourceContext(event);
19434
+ const auditId = this.auditEntryIdForEvent(event);
19435
+ const dedupKey = `${ctx.source_harness}|${ctx.source_agent_id}|${auditId}`;
19436
+ const existing = this.dedupIndex.get(dedupKey);
19437
+ if (existing) {
19438
+ const existingEntry = this.entries.get(existing);
19439
+ if (existingEntry) {
19440
+ this.correlationIndex.set(event.correlation_id, existing);
19441
+ this.auditLog.append(
19442
+ "l2",
19443
+ APPROVAL_AGGREGATOR_AUDIT_OPS.DEDUPED,
19444
+ this.identityId,
19445
+ {
19446
+ aggregator_id: existing,
19447
+ source_harness: ctx.source_harness,
19448
+ source_agent_id: ctx.source_agent_id,
19449
+ audit_log_entry_id: auditId,
19450
+ policy_rule_id: this.derivePolicyRuleId(event),
19451
+ correlation_id: event.correlation_id
19452
+ }
19453
+ );
19454
+ this.emit({ type: "deduped", entry: { ...existingEntry } });
19455
+ return null;
19456
+ }
19457
+ }
19458
+ const id = crypto.randomUUID();
19459
+ const now = this.now();
19460
+ const expires = new Date(now.getTime() + this.pendingTtlMs);
19461
+ const hubInboxId = this.resolveHubInboxItemId(event);
19462
+ const entry = {
19463
+ aggregator_id: id,
19464
+ source_harness: ctx.source_harness,
19465
+ source_agent_id: ctx.source_agent_id,
19466
+ audit_log_entry_id: auditId,
19467
+ policy_rule_id: this.derivePolicyRuleId(event),
19468
+ action_summary: this.deriveActionSummary(event),
19469
+ request_payload_hash: this.hashPayload(event.context),
19470
+ status: "pending",
19471
+ created_at: now.toISOString(),
19472
+ expires_at: expires.toISOString(),
19473
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
19474
+ };
19475
+ this.entries.set(id, entry);
19476
+ this.dedupIndex.set(dedupKey, id);
19477
+ this.correlationIndex.set(event.correlation_id, id);
19478
+ this.fullPayloads.set(id, event.context);
19479
+ await this.persist(entry);
19480
+ this.auditLog.append(
19481
+ "l2",
19482
+ APPROVAL_AGGREGATOR_AUDIT_OPS.AGGREGATED,
19483
+ this.identityId,
19484
+ {
19485
+ aggregator_id: id,
19486
+ source_harness: ctx.source_harness,
19487
+ source_agent_id: ctx.source_agent_id,
19488
+ audit_log_entry_id: auditId,
19489
+ policy_rule_id: entry.policy_rule_id,
19490
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
19491
+ }
19492
+ );
19493
+ this.emit({ type: "aggregated", entry: { ...entry } });
19494
+ return entry;
19495
+ }
19496
+ async ingestResolved(event) {
19497
+ const id = this.correlationIndex.get(event.correlation_id);
19498
+ if (!id) return null;
19499
+ const entry = this.entries.get(id);
19500
+ if (!entry) return null;
19501
+ if (entry.status !== "pending") return entry;
19502
+ if (!event.resolution) return entry;
19503
+ const failClosed = event.resolution.decision === "deny" && event.resolution.decided_by === "channel_failure";
19504
+ const status = failClosed ? "timeout" : event.resolution.decision === "approve" ? "approved" : "denied";
19505
+ entry.status = status;
19506
+ entry.resolved_at = event.resolution.decided_at;
19507
+ entry.resolved_by = event.resolution.decided_by;
19508
+ await this.persist(entry);
19509
+ this.auditLog.append(
19510
+ "l2",
19511
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
19512
+ this.identityId,
19513
+ {
19514
+ aggregator_id: id,
19515
+ source_harness: entry.source_harness,
19516
+ source_agent_id: entry.source_agent_id,
19517
+ audit_log_entry_id: entry.audit_log_entry_id,
19518
+ policy_rule_id: entry.policy_rule_id,
19519
+ decision: status,
19520
+ decided_by: entry.resolved_by,
19521
+ decided_at: entry.resolved_at,
19522
+ fail_closed: failClosed
19523
+ }
19524
+ );
19525
+ this.emit({ type: "resolved", entry: { ...entry } });
19526
+ return entry;
19527
+ }
19528
+ // ── Internal: helpers ──────────────────────────────────────────────────
19529
+ /**
19530
+ * Audit-log entry id for the dedup tuple. The audit log itself does not
19531
+ * surface a stable per-entry id (counter-prefixed keys are internal); the
19532
+ * aggregator uses the request timestamp + operation, which together pin
19533
+ * the audit entry the gate appended on the same call.
19534
+ */
19535
+ auditEntryIdForEvent(event) {
19536
+ return `${event.request_timestamp}:${event.operation}`;
19537
+ }
19538
+ derivePolicyRuleId(event) {
19539
+ return `tier${event.tier}:${event.operation}`;
19540
+ }
19541
+ deriveActionSummary(event) {
19542
+ return `${event.operation} (tier ${event.tier})`;
19543
+ }
19544
+ /**
19545
+ * Canonical SHA-256 of the request context. Sorted-keys serialization so
19546
+ * identical payloads always hash the same, even when key insertion order
19547
+ * varies. Defends against payload-replay smuggling (the aggregator can
19548
+ * tell the same payload was seen twice without storing it cleartext).
19549
+ */
19550
+ hashPayload(payload) {
19551
+ const canonical = JSON.stringify(payload, Object.keys(payload).sort());
19552
+ return crypto.createHash("sha256").update(canonical).digest("hex");
19553
+ }
19554
+ emit(event) {
19555
+ for (const listener of this.listeners) {
19556
+ try {
19557
+ listener(event);
19558
+ } catch {
19559
+ }
19560
+ }
19561
+ }
19562
+ async expireStale() {
19563
+ const nowMs = this.now().getTime();
19564
+ for (const entry of this.entries.values()) {
19565
+ if (entry.status !== "pending") continue;
19566
+ if (Date.parse(entry.expires_at) > nowMs) continue;
19567
+ entry.status = "expired";
19568
+ entry.resolved_at = this.now().toISOString();
19569
+ entry.resolved_by = "system_ttl";
19570
+ await this.persist(entry);
19571
+ this.auditLog.append(
19572
+ "l2",
19573
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
19574
+ this.identityId,
19575
+ {
19576
+ aggregator_id: entry.aggregator_id,
19577
+ source_harness: entry.source_harness,
19578
+ source_agent_id: entry.source_agent_id,
19579
+ audit_log_entry_id: entry.audit_log_entry_id,
19580
+ policy_rule_id: entry.policy_rule_id,
19581
+ decision: "expired",
19582
+ decided_by: "system_ttl",
19583
+ decided_at: entry.resolved_at
19584
+ }
19585
+ );
19586
+ this.emit({ type: "resolved", entry: { ...entry } });
19587
+ }
19588
+ }
19589
+ async persist(entry) {
19590
+ const serialized = stringToBytes(JSON.stringify(entry));
19591
+ const encrypted = encrypt(serialized, this.encryptionKey);
19592
+ await this.storage.write(
19593
+ APPROVAL_AGGREGATOR_NAMESPACE,
19594
+ entry.aggregator_id,
19595
+ stringToBytes(JSON.stringify(encrypted))
19596
+ );
19597
+ }
19598
+ async hydrate() {
19599
+ if (this.hydrated) return;
19600
+ this.hydrated = true;
19601
+ try {
19602
+ const metas = await this.storage.list(APPROVAL_AGGREGATOR_NAMESPACE);
19603
+ for (const meta of metas) {
19604
+ const raw = await this.storage.read(
19605
+ APPROVAL_AGGREGATOR_NAMESPACE,
19606
+ meta.key
19607
+ );
19608
+ if (!raw) continue;
19609
+ try {
19610
+ const encrypted = JSON.parse(bytesToString(raw));
19611
+ const decrypted = decrypt(encrypted, this.encryptionKey);
19612
+ const entry = JSON.parse(bytesToString(decrypted));
19613
+ this.entries.set(entry.aggregator_id, entry);
19614
+ const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
19615
+ this.dedupIndex.set(dedupKey, entry.aggregator_id);
19616
+ } catch {
19617
+ }
19618
+ }
19619
+ } catch {
19620
+ this.hydrated = false;
19621
+ }
19622
+ }
19623
+ };
19624
+
18876
19625
  // src/principal-policy/tools.ts
18877
19626
  function createPrincipalPolicyTools(policy, baseline, auditLog) {
18878
19627
  return [
@@ -21725,6 +22474,12 @@ function typed(markerPath, lineNumber, field, expected) {
21725
22474
  }
21726
22475
  async function consumeResetHistoryMarker(options) {
21727
22476
  const markerPath = path.join(options.storagePath, RESET_HISTORY_FILENAME);
22477
+ const consumedPath = markerPath + ".consumed";
22478
+ if (await fileExists3(consumedPath)) {
22479
+ await promises.rm(markerPath, { force: true });
22480
+ await promises.rm(consumedPath, { force: true });
22481
+ return { emitted: 0, markerPath };
22482
+ }
21728
22483
  if (!await fileExists3(markerPath)) {
21729
22484
  return { emitted: 0, markerPath };
21730
22485
  }
@@ -21749,7 +22504,9 @@ async function consumeResetHistoryMarker(options) {
21749
22504
  });
21750
22505
  }
21751
22506
  await options.auditLog.flush();
22507
+ await promises.writeFile(consumedPath, "", "utf-8");
21752
22508
  await promises.rm(markerPath, { force: true });
22509
+ await promises.rm(consumedPath, { force: true });
21753
22510
  return { emitted: markers.length, markerHash, markerPath };
21754
22511
  }
21755
22512
  async function fileExists3(path) {
@@ -30695,6 +31452,36 @@ var HubService = class {
30695
31452
  const chat = this.requireOperatorChat();
30696
31453
  return chat.getConciergeHistory();
30697
31454
  }
31455
+ // ── Concierge memory threads (WP-V1.3-9 Tau-1) ─────────────────────
31456
+ /**
31457
+ * Whether the operator-chat service has the WP-V1.3-9 memory store
31458
+ * wired. Routes use this to 503 cleanly when the foundation memory
31459
+ * surface is unavailable on a given fortress.
31460
+ */
31461
+ hasConciergeMemory() {
31462
+ return Boolean(this.deps.operatorChat?.hasConciergeMemory());
31463
+ }
31464
+ async listConciergeMemoryThreads(opts) {
31465
+ const chat = this.requireOperatorChat();
31466
+ if (!chat.hasConciergeMemory()) {
31467
+ throw new HubCapabilityError("concierge_memory_not_wired");
31468
+ }
31469
+ return chat.listConciergeMemoryThreads(opts);
31470
+ }
31471
+ async readConciergeMemoryThread(threadId, opts) {
31472
+ const chat = this.requireOperatorChat();
31473
+ if (!chat.hasConciergeMemory()) {
31474
+ throw new HubCapabilityError("concierge_memory_not_wired");
31475
+ }
31476
+ return chat.readConciergeMemoryThread(threadId, opts);
31477
+ }
31478
+ async deleteConciergeMemoryThread(threadId) {
31479
+ const chat = this.requireOperatorChat();
31480
+ if (!chat.hasConciergeMemory()) {
31481
+ throw new HubCapabilityError("concierge_memory_not_wired");
31482
+ }
31483
+ return chat.deleteConciergeMemoryThread(threadId);
31484
+ }
30698
31485
  /**
30699
31486
  * Open the click-to-inspect/approve panel for a wrapped agent. The
30700
31487
  * panel surfaces recent activity routed through this agent, pending
@@ -30782,7 +31569,21 @@ init_encoding();
30782
31569
 
30783
31570
  // src/chat/operator-chat-audit-events.ts
30784
31571
  var OPERATOR_CHAT_OPS = {
30785
- CONCIERGE_CHAT: "operator_concierge_chat"};
31572
+ CONCIERGE_CHAT: "operator_concierge_chat",
31573
+ /**
31574
+ * Operator viewed concierge thread history (WP-V1.3-9 Tau-1). Emitted
31575
+ * when the operator hits the list-threads or read-thread route. Body
31576
+ * carries the thread_id (or `*` for the list endpoint) and a count;
31577
+ * raw turn content never crosses the audit surface.
31578
+ */
31579
+ CONCIERGE_HISTORY_READ: "operator_concierge_history_read",
31580
+ /**
31581
+ * Operator deleted a concierge thread (WP-V1.3-9 Tau-1). Emitted on
31582
+ * successful thread removal. Body carries thread_id + turn_count of
31583
+ * the deleted bundle.
31584
+ */
31585
+ CONCIERGE_THREAD_DELETED: "operator_concierge_thread_deleted"
31586
+ };
30786
31587
 
30787
31588
  // src/chat/operator-chat-types.ts
30788
31589
  var OPERATOR_CHAT_MAX_THREAD_LENGTH = 500;
@@ -30825,6 +31626,14 @@ var OperatorChatService = class {
30825
31626
  contextProviders;
30826
31627
  piiFilter;
30827
31628
  conciergeMaxTokens;
31629
+ memory;
31630
+ /**
31631
+ * In-memory thread_id assigned to the active concierge session.
31632
+ * The first sendConcierge call after construction allocates a fresh
31633
+ * UUID; subsequent calls reuse it so multi-turn coherence (Tau-2)
31634
+ * folds the prior turns into context. Cleared by `resetConciergeMemoryThread`.
31635
+ */
31636
+ activeMemoryThreadId;
30828
31637
  constructor(deps) {
30829
31638
  this.store = deps.store;
30830
31639
  this.auditLog = deps.auditLog;
@@ -30835,6 +31644,7 @@ var OperatorChatService = class {
30835
31644
  }
30836
31645
  if (deps.conciergePiiFilter) this.piiFilter = deps.conciergePiiFilter;
30837
31646
  this.conciergeMaxTokens = deps.conciergeMaxTokens ?? DEFAULT_CONCIERGE_MAX_TOKENS;
31647
+ if (deps.conciergeMemory) this.memory = deps.conciergeMemory;
30838
31648
  }
30839
31649
  // ── Concierge ─────────────────────────────────────────────────────────
30840
31650
  /**
@@ -30865,6 +31675,11 @@ var OperatorChatService = class {
30865
31675
  CONCIERGE_THREAD_KEY,
30866
31676
  operatorMessage
30867
31677
  );
31678
+ if (this.memory) {
31679
+ const threadId = this.ensureActiveMemoryThread();
31680
+ await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
31681
+ });
31682
+ }
30868
31683
  const start = Date.now();
30869
31684
  let conciergeBody;
30870
31685
  let servedBy = "disabled";
@@ -30920,6 +31735,11 @@ var OperatorChatService = class {
30920
31735
  CONCIERGE_THREAD_KEY,
30921
31736
  responseMessage
30922
31737
  );
31738
+ if (this.memory) {
31739
+ const threadId = this.ensureActiveMemoryThread();
31740
+ await this.memory.appendTurn(threadId, "assistant", conciergeBody).catch(() => {
31741
+ });
31742
+ }
30923
31743
  const payload = {
30924
31744
  version: "1.2",
30925
31745
  event_id: makeEventId("conc"),
@@ -30952,6 +31772,105 @@ var OperatorChatService = class {
30952
31772
  );
30953
31773
  return thread ? thread.messages : [];
30954
31774
  }
31775
+ // ── WP-V1.3-9 Tau-1 memory accessors ─────────────────────────────────
31776
+ /**
31777
+ * Whether the foundation memory store is wired. Routes use this to
31778
+ * 503 cleanly when called against an unwired service.
31779
+ */
31780
+ hasConciergeMemory() {
31781
+ return this.memory !== void 0;
31782
+ }
31783
+ /**
31784
+ * List concierge memory threads, newest-first. Emits the
31785
+ * `operator_concierge_history_read` audit event with `thread_id="*"`.
31786
+ */
31787
+ async listConciergeMemoryThreads(opts) {
31788
+ if (!this.memory) {
31789
+ throw new Error("concierge memory store not configured");
31790
+ }
31791
+ const summaries = await this.memory.listThreads(opts);
31792
+ const totalTurns = summaries.reduce((acc, s) => acc + s.turn_count, 0);
31793
+ const payload = {
31794
+ version: "1.2",
31795
+ event_id: makeEventId("conc-hist"),
31796
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
31797
+ identity_id: this.identityId,
31798
+ kind: "operator_concierge_history_read",
31799
+ surface: "concierge",
31800
+ thread_id: "*",
31801
+ turn_count: totalTurns
31802
+ };
31803
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
31804
+ return summaries;
31805
+ }
31806
+ /**
31807
+ * Read a concierge memory thread, oldest turn first. Emits the
31808
+ * `operator_concierge_history_read` audit event with the named
31809
+ * thread_id and the count of turns surfaced.
31810
+ */
31811
+ async readConciergeMemoryThread(threadId, opts) {
31812
+ if (!this.memory) {
31813
+ throw new Error("concierge memory store not configured");
31814
+ }
31815
+ const turns = await this.memory.readThread(threadId, opts);
31816
+ const payload = {
31817
+ version: "1.2",
31818
+ event_id: makeEventId("conc-hist"),
31819
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
31820
+ identity_id: this.identityId,
31821
+ kind: "operator_concierge_history_read",
31822
+ surface: "concierge",
31823
+ thread_id: threadId,
31824
+ turn_count: turns.length
31825
+ };
31826
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
31827
+ return turns;
31828
+ }
31829
+ /**
31830
+ * Delete a concierge memory thread. Emits
31831
+ * `operator_concierge_thread_deleted` only when a bundle was actually
31832
+ * removed; absent threads return false without an audit event.
31833
+ */
31834
+ async deleteConciergeMemoryThread(threadId) {
31835
+ if (!this.memory) {
31836
+ throw new Error("concierge memory store not configured");
31837
+ }
31838
+ const turnsBefore = await this.memory.readThread(threadId);
31839
+ if (turnsBefore.length === 0) {
31840
+ return await this.memory.deleteThread(threadId);
31841
+ }
31842
+ const removed = await this.memory.deleteThread(threadId);
31843
+ if (!removed) return false;
31844
+ if (this.activeMemoryThreadId === threadId) {
31845
+ this.activeMemoryThreadId = void 0;
31846
+ }
31847
+ const payload = {
31848
+ version: "1.2",
31849
+ event_id: makeEventId("conc-del"),
31850
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
31851
+ identity_id: this.identityId,
31852
+ kind: "operator_concierge_thread_deleted",
31853
+ surface: "concierge",
31854
+ thread_id: threadId,
31855
+ turn_count: turnsBefore.length
31856
+ };
31857
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_THREAD_DELETED, payload, "success");
31858
+ return true;
31859
+ }
31860
+ /**
31861
+ * Reset the active session memory thread. Subsequent sendConcierge
31862
+ * calls allocate a fresh thread_id. Surfaced for tests + future "new
31863
+ * conversation" affordance; not currently called by the dashboard.
31864
+ */
31865
+ resetConciergeMemoryThread() {
31866
+ this.activeMemoryThreadId = void 0;
31867
+ }
31868
+ ensureActiveMemoryThread() {
31869
+ if (!this.activeMemoryThreadId) {
31870
+ this.activeMemoryThreadId = crypto.randomUUID();
31871
+ }
31872
+ return this.activeMemoryThreadId;
31873
+ }
30955
31874
  /**
30956
31875
  * Stitch fortress state into a single context blob the substrate
30957
31876
  * folds into its summarization prompt.
@@ -31115,6 +32034,238 @@ var OperatorChatStore = class {
31115
32034
  }
31116
32035
  };
31117
32036
 
32037
+ // src/chat/concierge-memory-store.ts
32038
+ init_encryption();
32039
+ init_encoding();
32040
+ var CONCIERGE_MEMORY_NAMESPACE = "_chat";
32041
+ var CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
32042
+ var HKDF_INFO2 = "concierge-memory-store-v1";
32043
+ var DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
32044
+ var MAX_BUNDLE_BYTES2 = 4 * 1024 * 1024;
32045
+ var ConciergeMemoryStore = class {
32046
+ storage;
32047
+ encryptionKey;
32048
+ fortressId;
32049
+ retentionDays;
32050
+ locks;
32051
+ constructor(opts) {
32052
+ this.storage = opts.storage;
32053
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
32054
+ this.fortressId = opts.fortressId;
32055
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
32056
+ this.locks = /* @__PURE__ */ new Map();
32057
+ }
32058
+ /**
32059
+ * Append a turn to the named thread, creating the bundle if no record
32060
+ * exists. Returns the persisted turn (with assigned turn_id +
32061
+ * retention_until). Per-thread serialisation guarantees turn_id
32062
+ * monotonicity even under concurrent callers.
32063
+ */
32064
+ async appendTurn(threadId, role, content) {
32065
+ return this.withLock(threadId, async () => {
32066
+ const bundle = await this.loadBundle(threadId) ?? null;
32067
+ const now = /* @__PURE__ */ new Date();
32068
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
32069
+ const retentionUntil = new Date(now.getTime() + retentionMs);
32070
+ const nextTurnId = bundle ? lastTurnId(bundle) + 1 : 1;
32071
+ const turn = {
32072
+ thread_id: threadId,
32073
+ fortress_id: this.fortressId,
32074
+ turn_id: nextTurnId,
32075
+ role,
32076
+ content,
32077
+ created_at: now.toISOString(),
32078
+ retention_until: retentionUntil.toISOString()
32079
+ };
32080
+ const next = bundle ? { ...bundle, turns: [...bundle.turns, turn] } : {
32081
+ version: 1,
32082
+ thread_id: threadId,
32083
+ fortress_id: this.fortressId,
32084
+ created_at: now.toISOString(),
32085
+ turns: [turn]
32086
+ };
32087
+ await this.saveBundle(next);
32088
+ return turn;
32089
+ });
32090
+ }
32091
+ /**
32092
+ * Read turns from a thread, oldest-first. Returns an empty array if
32093
+ * the thread does not exist or its bundle is corrupt. Does not emit
32094
+ * audit events; the caller (HTTP route handler) owns audit semantics.
32095
+ */
32096
+ async readThread(threadId, opts) {
32097
+ const bundle = await this.loadBundle(threadId);
32098
+ if (!bundle) return [];
32099
+ let turns = bundle.turns;
32100
+ if (opts?.sinceTurnId !== void 0) {
32101
+ const cutoff = opts.sinceTurnId;
32102
+ turns = turns.filter((t) => t.turn_id > cutoff);
32103
+ }
32104
+ if (opts?.limit !== void 0) {
32105
+ turns = turns.slice(0, opts.limit);
32106
+ }
32107
+ return turns;
32108
+ }
32109
+ /**
32110
+ * Enumerate concierge threads in this fortress with summary metadata.
32111
+ * Sorted newest-first by last_turn_at.
32112
+ */
32113
+ async listThreads(opts) {
32114
+ const entries = await this.storage.list(
32115
+ CONCIERGE_MEMORY_NAMESPACE,
32116
+ CONCIERGE_MEMORY_KEY_PREFIX
32117
+ );
32118
+ const summaries = [];
32119
+ for (const meta of entries) {
32120
+ const threadId = stripKeyPrefix(meta.key);
32121
+ if (threadId === null) continue;
32122
+ const bundle = await this.loadBundle(threadId);
32123
+ if (!bundle || bundle.turns.length === 0) continue;
32124
+ const last = bundle.turns[bundle.turns.length - 1];
32125
+ summaries.push({
32126
+ thread_id: bundle.thread_id,
32127
+ created_at: bundle.created_at,
32128
+ last_turn_at: last ? last.created_at : bundle.created_at,
32129
+ turn_count: bundle.turns.length
32130
+ });
32131
+ }
32132
+ summaries.sort(
32133
+ (a, b) => a.last_turn_at < b.last_turn_at ? 1 : a.last_turn_at > b.last_turn_at ? -1 : 0
32134
+ );
32135
+ if (opts?.limit !== void 0) {
32136
+ return summaries.slice(0, opts.limit);
32137
+ }
32138
+ return summaries;
32139
+ }
32140
+ /**
32141
+ * Delete a thread's bundle. Returns true if the bundle existed and
32142
+ * was removed; false if no bundle was present. Audit emission is the
32143
+ * caller's responsibility.
32144
+ */
32145
+ async deleteThread(threadId) {
32146
+ const key = bundleKey(threadId);
32147
+ return this.withLock(threadId, async () => {
32148
+ const existed = await this.storage.exists(
32149
+ CONCIERGE_MEMORY_NAMESPACE,
32150
+ key
32151
+ );
32152
+ if (!existed) return false;
32153
+ try {
32154
+ await this.storage.delete(CONCIERGE_MEMORY_NAMESPACE, key);
32155
+ } catch {
32156
+ return false;
32157
+ }
32158
+ return true;
32159
+ });
32160
+ }
32161
+ /**
32162
+ * Drop expired turns across all threads. Threads emptied by pruning
32163
+ * are removed entirely. Returns the count of turns pruned.
32164
+ */
32165
+ async pruneExpired(now) {
32166
+ const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
32167
+ const entries = await this.storage.list(
32168
+ CONCIERGE_MEMORY_NAMESPACE,
32169
+ CONCIERGE_MEMORY_KEY_PREFIX
32170
+ );
32171
+ let pruned = 0;
32172
+ for (const meta of entries) {
32173
+ const threadId = stripKeyPrefix(meta.key);
32174
+ if (threadId === null) continue;
32175
+ pruned += await this.withLock(threadId, async () => {
32176
+ const bundle = await this.loadBundle(threadId);
32177
+ if (!bundle) return 0;
32178
+ const kept = bundle.turns.filter((t) => t.retention_until > cutoff);
32179
+ const dropped = bundle.turns.length - kept.length;
32180
+ if (dropped === 0) return 0;
32181
+ if (kept.length === 0) {
32182
+ await this.storage.delete(
32183
+ CONCIERGE_MEMORY_NAMESPACE,
32184
+ bundleKey(threadId)
32185
+ );
32186
+ } else {
32187
+ await this.saveBundle({ ...bundle, turns: kept });
32188
+ }
32189
+ return dropped;
32190
+ });
32191
+ }
32192
+ return { pruned };
32193
+ }
32194
+ // ── internals ────────────────────────────────────────────────────────
32195
+ async loadBundle(threadId) {
32196
+ const key = bundleKey(threadId);
32197
+ let raw;
32198
+ try {
32199
+ raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
32200
+ } catch {
32201
+ return null;
32202
+ }
32203
+ if (!raw) return null;
32204
+ if (raw.length > MAX_BUNDLE_BYTES2) return null;
32205
+ try {
32206
+ const envelope = JSON.parse(bytesToString(raw));
32207
+ const aad = stringToBytes(threadId);
32208
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
32209
+ const parsed = JSON.parse(
32210
+ bytesToString(plaintext)
32211
+ );
32212
+ if (parsed.version !== 1) return null;
32213
+ if (parsed.thread_id !== threadId) return null;
32214
+ return parsed;
32215
+ } catch {
32216
+ return null;
32217
+ }
32218
+ }
32219
+ async saveBundle(bundle) {
32220
+ const key = bundleKey(bundle.thread_id);
32221
+ const aad = stringToBytes(bundle.thread_id);
32222
+ const plaintext = stringToBytes(JSON.stringify(bundle));
32223
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
32224
+ await this.storage.write(
32225
+ CONCIERGE_MEMORY_NAMESPACE,
32226
+ key,
32227
+ stringToBytes(JSON.stringify(envelope))
32228
+ );
32229
+ }
32230
+ /**
32231
+ * Run `task` while holding the per-thread async lock. Lock is released
32232
+ * once the task settles (success or failure). Generic helper so
32233
+ * appendTurn / deleteThread / pruneExpired share serialisation.
32234
+ */
32235
+ async withLock(threadId, task) {
32236
+ const previous = this.locks.get(threadId) ?? Promise.resolve();
32237
+ let release;
32238
+ const next = new Promise((resolve6) => {
32239
+ release = resolve6;
32240
+ });
32241
+ const chained = previous.then(() => next);
32242
+ this.locks.set(threadId, chained);
32243
+ try {
32244
+ await previous;
32245
+ return await task();
32246
+ } finally {
32247
+ release();
32248
+ if (this.locks.get(threadId) === chained) {
32249
+ this.locks.delete(threadId);
32250
+ }
32251
+ }
32252
+ }
32253
+ };
32254
+ function bundleKey(threadId) {
32255
+ return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
32256
+ }
32257
+ function stripKeyPrefix(key) {
32258
+ if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
32259
+ return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
32260
+ }
32261
+ function lastTurnId(bundle) {
32262
+ let max = 0;
32263
+ for (const t of bundle.turns) {
32264
+ if (t.turn_id > max) max = t.turn_id;
32265
+ }
32266
+ return max;
32267
+ }
32268
+
31118
32269
  // src/dashboard/v1_1/wiring.ts
31119
32270
  var CapabilityErrorAgentController = class {
31120
32271
  fail(action) {
@@ -31153,6 +32304,14 @@ function buildV11Bindings(inputs) {
31153
32304
  let operatorChatService;
31154
32305
  if (inputs.storage && inputs.masterKey) {
31155
32306
  const chatStore = new OperatorChatStore(inputs.storage, inputs.masterKey);
32307
+ const conciergeMemory = new ConciergeMemoryStore({
32308
+ storage: inputs.storage,
32309
+ masterKey: inputs.masterKey,
32310
+ fortressId: inputs.fortressId,
32311
+ ...inputs.conciergeMemoryRetentionDays !== void 0 ? { retentionDays: inputs.conciergeMemoryRetentionDays } : {}
32312
+ });
32313
+ void conciergeMemory.pruneExpired().catch(() => {
32314
+ });
31156
32315
  operatorChatService = new OperatorChatService({
31157
32316
  store: chatStore,
31158
32317
  auditLog: inputs.auditLog,
@@ -31163,7 +32322,8 @@ function buildV11Bindings(inputs) {
31163
32322
  identityId: inputs.identityId,
31164
32323
  registry
31165
32324
  }),
31166
- conciergePiiFilter: buildConciergePiiFilter()
32325
+ conciergePiiFilter: buildConciergePiiFilter(),
32326
+ conciergeMemory
31167
32327
  });
31168
32328
  }
31169
32329
  const hubService = new HubService({
@@ -31355,13 +32515,13 @@ init_encryption();
31355
32515
  init_encoding();
31356
32516
  var INTELLIGENCE_NAMESPACE = "_intelligence";
31357
32517
  var SUBSTRATE_CONFIG_KEY = "substrate-config";
31358
- var HKDF_INFO2 = "intelligence-substrate-config";
32518
+ var HKDF_INFO3 = "intelligence-substrate-config";
31359
32519
  var IntelligenceConfigStore = class {
31360
32520
  storage;
31361
32521
  encryptionKey;
31362
32522
  constructor(storage, masterKey) {
31363
32523
  this.storage = storage;
31364
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
32524
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
31365
32525
  }
31366
32526
  /**
31367
32527
  * Load the operator's substrate config from disk. Returns the config
@@ -33500,7 +34660,9 @@ async function verifyExitBundle(bundleDir, options = {}) {
33500
34660
  );
33501
34661
  }
33502
34662
  }
33503
- const reputationFailed = reputation?.bundle_signature_valid === false || (reputation?.invalid_attestations ?? 0) > 0;
34663
+ const reputationBundleFailed = reputation?.bundle_signature_valid === false;
34664
+ const reputationAttestationFailed = (reputation?.invalid_attestations ?? 0) > 0;
34665
+ const reputationFailed = reputationBundleFailed || reputationAttestationFailed;
33504
34666
  const identityFailed = identity ? !identity.signature_valid : false;
33505
34667
  const unverifiableCount = reputation?.unverifiable_attestations ?? 0;
33506
34668
  const unverifiableFailed = unverifiableCount > 0 && !options.acceptUnverifiableAttestations;
@@ -33509,6 +34671,16 @@ async function verifyExitBundle(bundleDir, options = {}) {
33509
34671
  `${unverifiableCount} reputation attestation(s) have unknown signer public keys; pass --accept-unverifiable-attestations to import anyway`
33510
34672
  );
33511
34673
  }
34674
+ let detailedFailureClass;
34675
+ if (identityFailed) {
34676
+ detailedFailureClass = "identity_signature_invalid";
34677
+ } else if (reputationBundleFailed) {
34678
+ detailedFailureClass = "reputation_bundle_signature_invalid";
34679
+ } else if (reputationAttestationFailed) {
34680
+ detailedFailureClass = "reputation_attestation_signature_invalid";
34681
+ } else if (unverifiableFailed) {
34682
+ detailedFailureClass = "reputation_unverifiable_attestations";
34683
+ }
33512
34684
  return {
33513
34685
  version: "1.1",
33514
34686
  passed: !reputationFailed && !identityFailed && !unverifiableFailed,
@@ -33528,7 +34700,7 @@ async function verifyExitBundle(bundleDir, options = {}) {
33528
34700
  identity,
33529
34701
  audit,
33530
34702
  reputation,
33531
- failure_class: reputationFailed || identityFailed || unverifiableFailed ? "other" : void 0
34703
+ failure_class: detailedFailureClass
33532
34704
  };
33533
34705
  }
33534
34706
 
@@ -34455,7 +35627,19 @@ async function runExitCommand(args) {
34455
35627
  }
34456
35628
  const config = await loadConfig();
34457
35629
  const ctx = await openExitContext(argv, env);
34458
- const policy = await loadPrincipalPolicy(ctx.storagePath);
35630
+ let policy;
35631
+ try {
35632
+ policy = await loadPrincipalPolicy(ctx.storagePath);
35633
+ } catch (policyErr) {
35634
+ if (policyErr instanceof MalformedPrincipalPolicyError) {
35635
+ write(err, `
35636
+ Sanctuary cannot proceed.
35637
+ ${policyErr.message}
35638
+ `);
35639
+ return 1;
35640
+ }
35641
+ throw policyErr;
35642
+ }
34459
35643
  const result = await exportExitBundle({
34460
35644
  bundleDir: outDir,
34461
35645
  storage: ctx.storage,
@@ -35107,7 +36291,19 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
35107
36291
  const profileStore = new SovereigntyProfileStore(storage, masterKey);
35108
36292
  await profileStore.load();
35109
36293
  const { tools: profileTools } = createSovereigntyProfileTools(profileStore, auditLog);
35110
- const policy = await loadPrincipalPolicy(config.storage_path);
36294
+ let policy;
36295
+ try {
36296
+ policy = await loadPrincipalPolicy(config.storage_path);
36297
+ } catch (err) {
36298
+ if (err instanceof MalformedPrincipalPolicyError) {
36299
+ console.error(`
36300
+ Sanctuary cannot start.
36301
+ ${err.message}
36302
+ `);
36303
+ process.exit(1);
36304
+ }
36305
+ throw err;
36306
+ }
35111
36307
  const baseline = new BaselineTracker(storage, masterKey);
35112
36308
  await baseline.load();
35113
36309
  let approvalChannel;
@@ -35205,6 +36401,21 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
35205
36401
  });
35206
36402
  } : void 0;
35207
36403
  const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
36404
+ const fortressIdForAggregator = fortressIdFromStoragePath(config.storage_path);
36405
+ const aggregatorIdentityId = identityManager.getPrimaryIdentityId() ?? `fortress:${config.storage_path}`;
36406
+ const approvalAggregator = new ApprovalAggregator({
36407
+ storage,
36408
+ masterKey,
36409
+ auditLog,
36410
+ identityId: aggregatorIdentityId,
36411
+ fortressId: fortressIdForAggregator
36412
+ });
36413
+ gate.setApprovalEventCallback((event) => {
36414
+ void approvalAggregator.ingest(event);
36415
+ });
36416
+ if (dashboard) {
36417
+ dashboard.setApprovalAggregator(approvalAggregator);
36418
+ }
35208
36419
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
35209
36420
  const { tools: sanctuaryMetaTools } = createSanctuaryTools({
35210
36421
  config,
@@ -35399,6 +36610,7 @@ exports.HERO_COPY = HERO_COPY;
35399
36610
  exports.InMemoryModelProvenanceStore = InMemoryModelProvenanceStore;
35400
36611
  exports.InjectionDetector = InjectionDetector;
35401
36612
  exports.MODEL_PRESETS = MODEL_PRESETS;
36613
+ exports.MalformedPrincipalPolicyError = MalformedPrincipalPolicyError;
35402
36614
  exports.MemoryStorage = MemoryStorage;
35403
36615
  exports.PolicyStore = PolicyStore;
35404
36616
  exports.ProxyRouter = ProxyRouter;