@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/cli.cjs CHANGED
@@ -4576,21 +4576,39 @@ approval_channel:
4576
4576
  }
4577
4577
  async function loadPrincipalPolicy(storagePath) {
4578
4578
  const policyPath = path.join(storagePath, "principal-policy.yaml");
4579
+ let content;
4580
+ try {
4581
+ content = await promises.readFile(policyPath, "utf-8");
4582
+ } catch (err) {
4583
+ const code = err?.code;
4584
+ if (code === "ENOENT") {
4585
+ const defaultYaml = generateDefaultPolicyYaml();
4586
+ try {
4587
+ await promises.writeFile(policyPath, defaultYaml, "utf-8");
4588
+ await promises.chmod(policyPath, 384);
4589
+ } catch (writeErr) {
4590
+ console.warn(
4591
+ `Sanctuary: could not write default principal policy to ${policyPath}: ${writeErr.message}. Continuing with in-memory default.`
4592
+ );
4593
+ }
4594
+ return Object.freeze({ ...DEFAULT_POLICY });
4595
+ }
4596
+ throw new MalformedPrincipalPolicyError(
4597
+ policyPath,
4598
+ `read failed: ${err.message}`
4599
+ );
4600
+ }
4579
4601
  try {
4580
- const content = await promises.readFile(policyPath, "utf-8");
4581
4602
  const policy = parsePolicy(content);
4582
4603
  return Object.freeze(policy);
4583
- } catch {
4584
- const defaultYaml = generateDefaultPolicyYaml();
4585
- try {
4586
- await promises.writeFile(policyPath, defaultYaml, "utf-8");
4587
- await promises.chmod(policyPath, 384);
4588
- } catch {
4589
- }
4590
- return Object.freeze({ ...DEFAULT_POLICY });
4604
+ } catch (parseErr) {
4605
+ throw new MalformedPrincipalPolicyError(
4606
+ policyPath,
4607
+ parseErr.message
4608
+ );
4591
4609
  }
4592
4610
  }
4593
- var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_POLICY;
4611
+ var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_POLICY, MalformedPrincipalPolicyError;
4594
4612
  var init_loader = __esm({
4595
4613
  "src/principal-policy/loader.ts"() {
4596
4614
  DEFAULT_TIER2 = {
@@ -4723,6 +4741,20 @@ var init_loader = __esm({
4723
4741
  ],
4724
4742
  approval_channel: DEFAULT_CHANNEL
4725
4743
  };
4744
+ MalformedPrincipalPolicyError = class extends Error {
4745
+ constructor(policyPath, reason) {
4746
+ super(
4747
+ `Principal policy at ${policyPath} is malformed and cannot be loaded.
4748
+ Reason: ${reason}
4749
+ 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.`
4750
+ );
4751
+ this.policyPath = policyPath;
4752
+ this.reason = reason;
4753
+ this.name = "MalformedPrincipalPolicyError";
4754
+ }
4755
+ policyPath;
4756
+ reason;
4757
+ };
4726
4758
  }
4727
4759
  });
4728
4760
 
@@ -4942,7 +4974,7 @@ function deepSortKeys(obj) {
4942
4974
  return sorted;
4943
4975
  }
4944
4976
  function canonicalizeForSigning(body) {
4945
- return JSON.stringify(deepSortKeys(body));
4977
+ return JSON.stringify(deepSortKeys(body)).normalize("NFC");
4946
4978
  }
4947
4979
  var init_types = __esm({
4948
4980
  "src/shr/types.ts"() {
@@ -12441,7 +12473,7 @@ var init_auth_middleware = __esm({
12441
12473
  });
12442
12474
 
12443
12475
  // src/hub/constants.ts
12444
- var HUB_API_PREFIX, HUB_ROUTES, HUB_FORTRESS_AGENT_ID_SENTINEL, HUB_INBOX_ACTIONS, HUB_AGENT_CONTROL_ACTIONS, HUB_TIER_1_AGENT_CONTROL_ACTIONS, HUB_ACTIVITY_DEFAULT_LIMIT, HUB_ACTIVITY_MAX_LIMIT, HUB_INBOX_DEFAULT_LIMIT, HUB_INBOX_MAX_LIMIT, HUB_AGENTS_DEFAULT_LIMIT, HUB_AGENTS_MAX_LIMIT, HUB_MAX_REQUEST_BODY_BYTES, HUB_CHAT_MESSAGE_MAX_CHARS, HUB_INBOX_TEMPLATE_NAMESPACES, HUB_ACTIVITY_TEMPLATE_NAMESPACES;
12476
+ var HUB_API_PREFIX, HUB_ROUTES, HUB_FORTRESS_AGENT_ID_SENTINEL, HUB_INBOX_ACTIONS, HUB_AGENT_CONTROL_ACTIONS, HUB_TIER_1_AGENT_CONTROL_ACTIONS, HUB_ACTIVITY_DEFAULT_LIMIT, HUB_ACTIVITY_MAX_LIMIT, HUB_CHAT_THREADS_DEFAULT_LIMIT, HUB_CHAT_THREADS_MAX_LIMIT, HUB_CHAT_TURNS_DEFAULT_LIMIT, HUB_CHAT_TURNS_MAX_LIMIT, HUB_INBOX_DEFAULT_LIMIT, HUB_INBOX_MAX_LIMIT, HUB_AGENTS_DEFAULT_LIMIT, HUB_AGENTS_MAX_LIMIT, HUB_MAX_REQUEST_BODY_BYTES, HUB_CHAT_MESSAGE_MAX_CHARS, HUB_INBOX_TEMPLATE_NAMESPACES, HUB_ACTIVITY_TEMPLATE_NAMESPACES;
12445
12477
  var init_constants3 = __esm({
12446
12478
  "src/hub/constants.ts"() {
12447
12479
  HUB_API_PREFIX = "/api/hub";
@@ -12469,6 +12501,16 @@ var init_constants3 = __esm({
12469
12501
  */
12470
12502
  CHAT_CONCIERGE_SEND: "/api/hub/chat/concierge",
12471
12503
  CHAT_CONCIERGE_HISTORY: "/api/hub/chat/concierge/history",
12504
+ /**
12505
+ * Concierge memory thread routes (WP-V1.3-9 Tau-1). Thread enumeration,
12506
+ * scrollback, and operator-initiated thread delete. Distinct from the
12507
+ * v1.2 `/history` route, which surfaces the active in-session thread
12508
+ * shape; the new routes target persisted multi-thread memory used by
12509
+ * v1.3 conversational sovereignty depth.
12510
+ */
12511
+ CHAT_CONCIERGE_THREADS_LIST: "/api/hub/chat/concierge/threads",
12512
+ CHAT_CONCIERGE_THREAD_READ: "/api/hub/chat/concierge/threads/:thread_id",
12513
+ CHAT_CONCIERGE_THREAD_DELETE: "/api/hub/chat/concierge/threads/:thread_id",
12472
12514
  /**
12473
12515
  * Click-to-inspect panel (WP-V1.2 reshape). Returns the agent's
12474
12516
  * recent activity feed, pending Tier 1 approvals routed through this
@@ -12492,6 +12534,10 @@ var init_constants3 = __esm({
12492
12534
  ];
12493
12535
  HUB_ACTIVITY_DEFAULT_LIMIT = 50;
12494
12536
  HUB_ACTIVITY_MAX_LIMIT = 500;
12537
+ HUB_CHAT_THREADS_DEFAULT_LIMIT = 50;
12538
+ HUB_CHAT_THREADS_MAX_LIMIT = 500;
12539
+ HUB_CHAT_TURNS_DEFAULT_LIMIT = 200;
12540
+ HUB_CHAT_TURNS_MAX_LIMIT = 1e3;
12495
12541
  HUB_INBOX_DEFAULT_LIMIT = 100;
12496
12542
  HUB_INBOX_MAX_LIMIT = 500;
12497
12543
  HUB_AGENTS_DEFAULT_LIMIT = 100;
@@ -12674,6 +12720,23 @@ function checkChatMessage(value) {
12674
12720
  }
12675
12721
  return trimmed;
12676
12722
  }
12723
+ function matchConciergeThreadRoute(path) {
12724
+ const prefix = `${HUB_API_PREFIX}/chat/concierge/threads/`;
12725
+ if (!path.startsWith(prefix)) return null;
12726
+ const rest = path.slice(prefix.length);
12727
+ if (rest.length === 0 || rest.includes("/")) return null;
12728
+ const decoded = decodeURIComponent(rest);
12729
+ if (decoded.length === 0) return null;
12730
+ return { threadId: decoded };
12731
+ }
12732
+ function parseSince(raw) {
12733
+ if (raw === null || raw === "") return void 0;
12734
+ const parsed = Number.parseInt(raw, 10);
12735
+ if (Number.isNaN(parsed) || parsed < 0) {
12736
+ throw new HubValidationError("since must be a non-negative integer");
12737
+ }
12738
+ return parsed;
12739
+ }
12677
12740
  function matchInboxRoute(path) {
12678
12741
  const prefix = `${HUB_API_PREFIX}/inbox/`;
12679
12742
  if (!path.startsWith(prefix)) return null;
@@ -12857,6 +12920,47 @@ async function handleHubRoute(deps, req, res) {
12857
12920
  writeJSON2(res, 200, { ok: true, data: { messages } });
12858
12921
  return true;
12859
12922
  }
12923
+ if (method === "GET" && path === HUB_ROUTES.CHAT_CONCIERGE_THREADS_LIST) {
12924
+ const limit = parseLimit(
12925
+ url.searchParams.get("limit"),
12926
+ HUB_CHAT_THREADS_DEFAULT_LIMIT,
12927
+ HUB_CHAT_THREADS_MAX_LIMIT
12928
+ );
12929
+ const threads = await deps.service.listConciergeMemoryThreads({ limit });
12930
+ writeJSON2(res, 200, { ok: true, data: { threads } });
12931
+ return true;
12932
+ }
12933
+ {
12934
+ const threadMatch = matchConciergeThreadRoute(path);
12935
+ if (threadMatch) {
12936
+ if (method === "GET") {
12937
+ const since = parseSince(url.searchParams.get("since"));
12938
+ const limit = parseLimit(
12939
+ url.searchParams.get("limit"),
12940
+ HUB_CHAT_TURNS_DEFAULT_LIMIT,
12941
+ HUB_CHAT_TURNS_MAX_LIMIT
12942
+ );
12943
+ const readOpts = { limit };
12944
+ if (since !== void 0) readOpts.sinceTurnId = since;
12945
+ const turns = await deps.service.readConciergeMemoryThread(
12946
+ threadMatch.threadId,
12947
+ readOpts
12948
+ );
12949
+ writeJSON2(res, 200, { ok: true, data: { turns } });
12950
+ return true;
12951
+ }
12952
+ if (method === "DELETE") {
12953
+ const removed = await deps.service.deleteConciergeMemoryThread(
12954
+ threadMatch.threadId
12955
+ );
12956
+ writeJSON2(res, removed ? 200 : 404, {
12957
+ ok: removed,
12958
+ data: { thread_id: threadMatch.threadId, removed }
12959
+ });
12960
+ return true;
12961
+ }
12962
+ }
12963
+ }
12860
12964
  writeJSON2(res, 404, { ok: false, error: "not_found", path });
12861
12965
  return true;
12862
12966
  } catch (err) {
@@ -16979,6 +17083,168 @@ var init_dispatch = __esm({
16979
17083
  init_intelligence_api_router();
16980
17084
  }
16981
17085
  });
17086
+
17087
+ // src/principal-policy/approval-aggregator-routes.ts
17088
+ function writeJSON4(res, status, payload) {
17089
+ res.writeHead(status, {
17090
+ "Content-Type": "application/json",
17091
+ "Cache-Control": "no-store"
17092
+ });
17093
+ res.end(JSON.stringify(payload));
17094
+ }
17095
+ function parseLimit2(raw, defaultValue, max) {
17096
+ if (raw === null || raw === "") return defaultValue;
17097
+ const parsed = Number.parseInt(raw, 10);
17098
+ if (Number.isNaN(parsed) || parsed < 0) {
17099
+ return defaultValue;
17100
+ }
17101
+ return Math.min(parsed, max);
17102
+ }
17103
+ function isStatusFilter(value) {
17104
+ return value === "pending" || value === "approved" || value === "denied" || value === "timeout" || value === "expired";
17105
+ }
17106
+ function matchEntryRoute(path) {
17107
+ const prefix = `${APPROVAL_INBOX_API_PREFIX}/`;
17108
+ if (!path.startsWith(prefix)) return null;
17109
+ const rest = path.slice(prefix.length);
17110
+ if (rest.length === 0) return null;
17111
+ const slash = rest.indexOf("/");
17112
+ if (slash === -1) {
17113
+ return { aggregatorId: decodeURIComponent(rest), action: null };
17114
+ }
17115
+ return {
17116
+ aggregatorId: decodeURIComponent(rest.slice(0, slash)),
17117
+ action: rest.slice(slash + 1)
17118
+ };
17119
+ }
17120
+ async function handleStream2(deps, res) {
17121
+ res.writeHead(200, {
17122
+ "Content-Type": "text/event-stream",
17123
+ "Cache-Control": "no-cache, no-transform",
17124
+ Connection: "keep-alive",
17125
+ "X-Accel-Buffering": "no"
17126
+ });
17127
+ const initial = await deps.aggregator.list({ status: "pending" });
17128
+ res.write(
17129
+ `event: approval_inbox_snapshot
17130
+ data: ${JSON.stringify({ entries: initial })}
17131
+
17132
+ `
17133
+ );
17134
+ const unsubscribe = deps.aggregator.onEvent((event) => {
17135
+ try {
17136
+ res.write(
17137
+ `event: approval_inbox_${event.type}
17138
+ data: ${JSON.stringify(event.entry)}
17139
+
17140
+ `
17141
+ );
17142
+ } catch {
17143
+ }
17144
+ });
17145
+ const keepAlive = setInterval(() => {
17146
+ try {
17147
+ res.write(": keepalive\n\n");
17148
+ } catch {
17149
+ }
17150
+ }, 25e3);
17151
+ const cleanup = () => {
17152
+ clearInterval(keepAlive);
17153
+ unsubscribe();
17154
+ };
17155
+ res.on("close", cleanup);
17156
+ res.on("error", cleanup);
17157
+ }
17158
+ async function handleApprovalInboxRoute(deps, req, res) {
17159
+ const host = req.headers.host || "localhost";
17160
+ const url = new URL(req.url ?? "/", `http://${host}`);
17161
+ const method = (req.method ?? "GET").toUpperCase();
17162
+ const path = url.pathname;
17163
+ if (path !== APPROVAL_INBOX_API_PREFIX && !path.startsWith(`${APPROVAL_INBOX_API_PREFIX}/`)) {
17164
+ return false;
17165
+ }
17166
+ const checkAuth = authMiddleware(deps.authConfig);
17167
+ if (!checkAuth(req, res, url)) return true;
17168
+ try {
17169
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/stream`) {
17170
+ await handleStream2(deps, res);
17171
+ return true;
17172
+ }
17173
+ if (method === "GET" && path === APPROVAL_INBOX_API_PREFIX) {
17174
+ const limit = parseLimit2(
17175
+ url.searchParams.get("limit"),
17176
+ APPROVAL_INBOX_DEFAULT_LIMIT,
17177
+ APPROVAL_INBOX_MAX_LIMIT
17178
+ );
17179
+ const statusRaw = url.searchParams.get("status");
17180
+ const status = statusRaw && isStatusFilter(statusRaw) ? statusRaw : "pending";
17181
+ const sinceTs = url.searchParams.get("since") ?? void 0;
17182
+ const entries = await deps.aggregator.list({
17183
+ status,
17184
+ limit,
17185
+ ...sinceTs !== void 0 ? { sinceTs } : {}
17186
+ });
17187
+ writeJSON4(res, 200, { ok: true, data: { entries } });
17188
+ return true;
17189
+ }
17190
+ const entryMatch = matchEntryRoute(path);
17191
+ if (entryMatch === null) {
17192
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
17193
+ return true;
17194
+ }
17195
+ if (method === "GET" && entryMatch.action === null) {
17196
+ const entries = await deps.aggregator.list({ limit: APPROVAL_INBOX_MAX_LIMIT });
17197
+ const entry = entries.find(
17198
+ (e) => e.aggregator_id === entryMatch.aggregatorId
17199
+ );
17200
+ if (!entry) {
17201
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
17202
+ return true;
17203
+ }
17204
+ const payload = await deps.aggregator.getFullPayload(
17205
+ entryMatch.aggregatorId
17206
+ );
17207
+ writeJSON4(res, 200, { ok: true, data: { entry, request_payload: payload } });
17208
+ return true;
17209
+ }
17210
+ if (method === "POST" && (entryMatch.action === "approve" || entryMatch.action === "deny")) {
17211
+ const decision = entryMatch.action === "approve" ? "approved" : "denied";
17212
+ const operatorId = deps.operatorId ?? APPROVAL_INBOX_OPERATOR_DEFAULT;
17213
+ try {
17214
+ const entry = await deps.aggregator.resolve(
17215
+ entryMatch.aggregatorId,
17216
+ decision,
17217
+ operatorId
17218
+ );
17219
+ writeJSON4(res, 200, { ok: true, data: { entry } });
17220
+ } catch (err) {
17221
+ const msg = err instanceof Error ? err.message : String(err);
17222
+ if (msg === "approval-aggregator: not_found") {
17223
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
17224
+ } else {
17225
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
17226
+ }
17227
+ }
17228
+ return true;
17229
+ }
17230
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
17231
+ return true;
17232
+ } catch (err) {
17233
+ const msg = err instanceof Error ? err.message : String(err);
17234
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
17235
+ return true;
17236
+ }
17237
+ }
17238
+ var APPROVAL_INBOX_API_PREFIX, APPROVAL_INBOX_OPERATOR_DEFAULT, APPROVAL_INBOX_DEFAULT_LIMIT, APPROVAL_INBOX_MAX_LIMIT;
17239
+ var init_approval_aggregator_routes = __esm({
17240
+ "src/principal-policy/approval-aggregator-routes.ts"() {
17241
+ init_auth_middleware();
17242
+ APPROVAL_INBOX_API_PREFIX = "/api/approval-inbox";
17243
+ APPROVAL_INBOX_OPERATOR_DEFAULT = "operator_dashboard";
17244
+ APPROVAL_INBOX_DEFAULT_LIMIT = 50;
17245
+ APPROVAL_INBOX_MAX_LIMIT = 200;
17246
+ }
17247
+ });
16982
17248
  function isDashboardViewRoute(method, path) {
16983
17249
  if (method !== "GET") return false;
16984
17250
  return path === "/" || path === "/dashboard" || path === "/v1.0" || path === "/fortress" || path === "/events";
@@ -16992,6 +17258,7 @@ var init_dashboard = __esm({
16992
17258
  init_fortress_view();
16993
17259
  init_system_prompt_generator();
16994
17260
  init_dispatch();
17261
+ init_approval_aggregator_routes();
16995
17262
  SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
16996
17263
  SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
16997
17264
  MAX_SESSIONS = 1e3;
@@ -17051,6 +17318,14 @@ var init_dashboard = __esm({
17051
17318
  * regardless. Default route flip is deferred to v1.2.
17052
17319
  */
17053
17320
  v11Bindings = null;
17321
+ /**
17322
+ * v1.3 WP-V1.3-10 Cross-Harness Approval Inbox aggregator. Mounted
17323
+ * additively at `/api/approval-inbox/*` when set. Legacy approval
17324
+ * routes at `/api/approvals/:id/(allow|deny)` continue to serve. The
17325
+ * aggregator is a passive subscriber to the gate; the routes here are
17326
+ * the operator-facing query / decision surface.
17327
+ */
17328
+ approvalAggregator = null;
17054
17329
  constructor(config) {
17055
17330
  this.config = config;
17056
17331
  this.authToken = config.auth_token;
@@ -17101,6 +17376,34 @@ var init_dashboard = __esm({
17101
17376
  setV11Bindings(bindings) {
17102
17377
  this.v11Bindings = bindings;
17103
17378
  }
17379
+ /**
17380
+ * v1.3 WP-V1.3-10 Upsilon-1: bind the cross-harness approval inbox
17381
+ * aggregator. Once set, requests to `/api/approval-inbox/*` route
17382
+ * through `handleApprovalInboxRoute`. Pass `null` to detach (used by
17383
+ * tests + during shutdown).
17384
+ */
17385
+ setApprovalAggregator(aggregator) {
17386
+ this.approvalAggregator = aggregator;
17387
+ }
17388
+ /**
17389
+ * v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
17390
+ * before the legacy approval route table. Returns true when served.
17391
+ */
17392
+ async dispatchApprovalInbox(req, res) {
17393
+ if (!this.approvalAggregator) return false;
17394
+ return handleApprovalInboxRoute(
17395
+ {
17396
+ authConfig: {
17397
+ loopbackAutoAuth: this._autoAuthLocalhost,
17398
+ ...this.authToken !== void 0 ? { authToken: this.authToken } : {}
17399
+ },
17400
+ aggregator: this.approvalAggregator,
17401
+ operatorId: this.identityManager?.getPrimaryIdentityId() ?? void 0
17402
+ },
17403
+ req,
17404
+ res
17405
+ );
17406
+ }
17104
17407
  /**
17105
17408
  * v1.1 dispatch entry point. Called from `handleRequest` before the
17106
17409
  * legacy route table. Returns true when the request was served by v1.1
@@ -17476,6 +17779,18 @@ var init_dashboard = __esm({
17476
17779
  res.end();
17477
17780
  return;
17478
17781
  }
17782
+ if (this.approvalAggregator && url.pathname.startsWith(APPROVAL_INBOX_API_PREFIX)) {
17783
+ this.dispatchApprovalInbox(req, res).then((handled) => {
17784
+ if (handled) return;
17785
+ this.handleLegacyRequest(req, res, url, method);
17786
+ }).catch(() => {
17787
+ if (!res.headersSent) {
17788
+ res.writeHead(500, { "Content-Type": "application/json" });
17789
+ res.end(JSON.stringify({ error: "Internal server error" }));
17790
+ }
17791
+ });
17792
+ return;
17793
+ }
17479
17794
  if (this.v11Bindings) {
17480
17795
  this.dispatchV11(req, res, url, method).then((handled) => {
17481
17796
  if (handled) return;
@@ -19519,14 +19834,25 @@ var init_gate = __esm({
19519
19834
  auditLog;
19520
19835
  injectionDetector;
19521
19836
  onInjectionAlert;
19837
+ onApprovalEvent;
19522
19838
  proxyTierResolver;
19523
- constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert) {
19839
+ constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert, onApprovalEvent) {
19524
19840
  this.policy = policy;
19525
19841
  this.baseline = baseline;
19526
19842
  this.channel = channel;
19527
19843
  this.auditLog = auditLog;
19528
19844
  this.injectionDetector = injectionDetector ?? new InjectionDetector();
19529
19845
  this.onInjectionAlert = onInjectionAlert;
19846
+ this.onApprovalEvent = onApprovalEvent;
19847
+ }
19848
+ /**
19849
+ * Set the approval-event callback after construction. Used by the
19850
+ * Upsilon-1 wire-up when the aggregator is constructed alongside the
19851
+ * gate. The aggregator subscribes through this setter rather than the
19852
+ * constructor so existing call sites continue to work unchanged.
19853
+ */
19854
+ setApprovalEventCallback(cb) {
19855
+ this.onApprovalEvent = cb;
19530
19856
  }
19531
19857
  /**
19532
19858
  * Set the proxy tier resolver. Called after the proxy router is initialized.
@@ -19760,21 +20086,105 @@ var init_gate = __esm({
19760
20086
  }
19761
20087
  /**
19762
20088
  * Request approval from the human principal.
20089
+ *
20090
+ * Fail-closed contract (full-sweep #49): if the channel throws (network
20091
+ * down, callback unreachable, dashboard SSE peer dropped, webhook DNS
20092
+ * failure, etc.), the gate denies the operation and audit-logs the cause.
20093
+ * Channel-internal timeouts already resolve with decision: "deny" per
20094
+ * SEC-002; this catch covers the remaining "channel raised" path so an
20095
+ * unhandled rejection cannot turn into an indeterminate state at the gate.
19763
20096
  */
19764
20097
  async requestApproval(operation, tier, reason, context) {
20098
+ const requestTimestamp = (/* @__PURE__ */ new Date()).toISOString();
19765
20099
  const request = {
19766
20100
  operation,
19767
20101
  tier,
19768
20102
  reason,
19769
20103
  context,
19770
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
20104
+ timestamp: requestTimestamp
19771
20105
  };
19772
- const response = await this.channel.requestApproval(request);
20106
+ const correlationId = `${requestTimestamp}:${operation}:${Math.random().toString(16).slice(2, 6)}`;
20107
+ if (this.onApprovalEvent) {
20108
+ try {
20109
+ this.onApprovalEvent({
20110
+ phase: "requested",
20111
+ operation,
20112
+ tier,
20113
+ reason,
20114
+ context,
20115
+ request_timestamp: requestTimestamp,
20116
+ correlation_id: correlationId
20117
+ });
20118
+ } catch {
20119
+ }
20120
+ }
20121
+ let response;
20122
+ try {
20123
+ response = await this.channel.requestApproval(request);
20124
+ } catch (err) {
20125
+ const errMessage = err instanceof Error ? err.message : String(err);
20126
+ const decidedAt = (/* @__PURE__ */ new Date()).toISOString();
20127
+ this.auditLog.append("l2", `gate_deny:${operation}`, "system", {
20128
+ tier,
20129
+ reason,
20130
+ decided_by: "channel_failure",
20131
+ channel_error: errMessage
20132
+ });
20133
+ if (this.onApprovalEvent) {
20134
+ try {
20135
+ this.onApprovalEvent({
20136
+ phase: "resolved",
20137
+ operation,
20138
+ tier,
20139
+ reason,
20140
+ context,
20141
+ request_timestamp: requestTimestamp,
20142
+ resolution: {
20143
+ decision: "deny",
20144
+ decided_at: decidedAt,
20145
+ decided_by: "channel_failure"
20146
+ },
20147
+ correlation_id: correlationId
20148
+ });
20149
+ } catch {
20150
+ }
20151
+ }
20152
+ return {
20153
+ allowed: false,
20154
+ tier,
20155
+ reason: AGENT_VISIBLE_DENY_REASONS.REQUIRES_APPROVAL,
20156
+ approval_required: true,
20157
+ approval_response: {
20158
+ decision: "deny",
20159
+ decided_at: decidedAt,
20160
+ decided_by: "channel_failure"
20161
+ }
20162
+ };
20163
+ }
19773
20164
  this.auditLog.append("l2", `gate_${response.decision}:${operation}`, "system", {
19774
20165
  tier,
19775
20166
  reason,
19776
20167
  decided_by: response.decided_by
19777
20168
  });
20169
+ if (this.onApprovalEvent) {
20170
+ try {
20171
+ this.onApprovalEvent({
20172
+ phase: "resolved",
20173
+ operation,
20174
+ tier,
20175
+ reason,
20176
+ context,
20177
+ request_timestamp: requestTimestamp,
20178
+ resolution: {
20179
+ decision: response.decision,
20180
+ decided_at: response.decided_at,
20181
+ decided_by: response.decided_by
20182
+ },
20183
+ correlation_id: correlationId
20184
+ });
20185
+ } catch {
20186
+ }
20187
+ }
19778
20188
  return {
19779
20189
  allowed: response.decision === "approve",
19780
20190
  tier,
@@ -19809,6 +20219,356 @@ var init_gate = __esm({
19809
20219
  };
19810
20220
  }
19811
20221
  });
20222
+ var APPROVAL_AGGREGATOR_NAMESPACE, APPROVAL_AGGREGATOR_HKDF_INFO, APPROVAL_AGGREGATOR_AUDIT_OPS, DEFAULT_PENDING_TTL_MS, DEFAULT_MAX_LIST_LIMIT, DEFAULT_LIST_PAGE_SIZE, ApprovalAggregator;
20223
+ var init_approval_aggregator = __esm({
20224
+ "src/principal-policy/approval-aggregator.ts"() {
20225
+ init_encryption();
20226
+ init_key_derivation();
20227
+ init_encoding();
20228
+ APPROVAL_AGGREGATOR_NAMESPACE = "_approval_aggregator";
20229
+ APPROVAL_AGGREGATOR_HKDF_INFO = "l2-approval-aggregator-v1";
20230
+ APPROVAL_AGGREGATOR_AUDIT_OPS = {
20231
+ AGGREGATED: "cross_harness_approval_aggregated",
20232
+ RESOLVED: "cross_harness_approval_resolved",
20233
+ DEDUPED: "cross_harness_approval_deduped"
20234
+ };
20235
+ DEFAULT_PENDING_TTL_MS = 5 * 60 * 1e3;
20236
+ DEFAULT_MAX_LIST_LIMIT = 200;
20237
+ DEFAULT_LIST_PAGE_SIZE = 50;
20238
+ ApprovalAggregator = class {
20239
+ storage;
20240
+ encryptionKey;
20241
+ auditLog;
20242
+ identityId;
20243
+ fortressId;
20244
+ pendingTtlMs;
20245
+ maxListLimit;
20246
+ now;
20247
+ resolveSourceContext;
20248
+ resolveHubInboxItemId;
20249
+ /** Cached entries by `aggregator_id`. */
20250
+ entries = /* @__PURE__ */ new Map();
20251
+ /** Dedup index: `${harness}|${agent}|${audit_id}` -> aggregator_id. */
20252
+ dedupIndex = /* @__PURE__ */ new Map();
20253
+ /** Correlation index: gate `correlation_id` -> aggregator_id. */
20254
+ correlationIndex = /* @__PURE__ */ new Map();
20255
+ /** Original request payloads kept in-memory for `getFullPayload()`. */
20256
+ fullPayloads = /* @__PURE__ */ new Map();
20257
+ /** Has the aggregator hydrated persisted entries on this process? */
20258
+ hydrated = false;
20259
+ /** Active SSE listeners. */
20260
+ listeners = /* @__PURE__ */ new Set();
20261
+ constructor(deps) {
20262
+ this.storage = deps.storage;
20263
+ this.encryptionKey = derivePurposeKey(
20264
+ deps.masterKey,
20265
+ APPROVAL_AGGREGATOR_HKDF_INFO
20266
+ );
20267
+ this.auditLog = deps.auditLog;
20268
+ this.identityId = deps.identityId;
20269
+ this.fortressId = deps.fortressId;
20270
+ this.pendingTtlMs = deps.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
20271
+ this.maxListLimit = deps.maxListLimit ?? DEFAULT_MAX_LIST_LIMIT;
20272
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
20273
+ this.resolveSourceContext = deps.resolveSourceContext ?? ((_event) => ({
20274
+ source_harness: this.fortressId,
20275
+ source_agent_id: this.fortressId
20276
+ }));
20277
+ this.resolveHubInboxItemId = deps.resolveHubInboxItemId ?? ((_event) => void 0);
20278
+ }
20279
+ /**
20280
+ * Subscribe an event listener. Returns an unsubscribe fn. SSE handlers
20281
+ * use this to forward aggregator emissions to the dashboard.
20282
+ */
20283
+ onEvent(listener) {
20284
+ this.listeners.add(listener);
20285
+ return () => this.listeners.delete(listener);
20286
+ }
20287
+ /**
20288
+ * Ingest a gate event. Returns the aggregator entry on first sight,
20289
+ * `null` when deduped. Resolution events update the existing record;
20290
+ * unmatched resolutions are dropped silently (caller's gate emitted a
20291
+ * resolved-without-requested pair, which the aggregator does not invent
20292
+ * a record for).
20293
+ */
20294
+ async ingest(event) {
20295
+ await this.hydrate();
20296
+ if (event.phase === "requested") {
20297
+ return this.ingestRequested(event);
20298
+ }
20299
+ if (event.phase === "resolved") {
20300
+ return this.ingestResolved(event);
20301
+ }
20302
+ return null;
20303
+ }
20304
+ /**
20305
+ * List pending or recently resolved entries. Pending entries past TTL
20306
+ * are lazily transitioned to `expired` and persisted before the list
20307
+ * snapshot is returned.
20308
+ */
20309
+ async list(opts) {
20310
+ await this.hydrate();
20311
+ await this.expireStale();
20312
+ const limit = Math.min(
20313
+ opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
20314
+ this.maxListLimit
20315
+ );
20316
+ const sinceMs = opts?.sinceTs ? Date.parse(opts.sinceTs) : Number.NEGATIVE_INFINITY;
20317
+ const matching = [];
20318
+ for (const entry of this.entries.values()) {
20319
+ if (opts?.status && entry.status !== opts.status) continue;
20320
+ if (Date.parse(entry.created_at) < sinceMs) continue;
20321
+ matching.push(entry);
20322
+ }
20323
+ matching.sort((a, b) => b.created_at.localeCompare(a.created_at));
20324
+ return matching.slice(0, limit);
20325
+ }
20326
+ /**
20327
+ * Return the original (unhashed) request payload for the entry. Returns
20328
+ * `null` when the entry is unknown or the payload was evicted (e.g. the
20329
+ * process restarted; payloads are in-memory only at v1.3 Upsilon-1).
20330
+ */
20331
+ async getFullPayload(aggregatorId) {
20332
+ await this.hydrate();
20333
+ if (!this.entries.has(aggregatorId)) return null;
20334
+ return this.fullPayloads.get(aggregatorId) ?? null;
20335
+ }
20336
+ /**
20337
+ * Resolve an entry. Used by both:
20338
+ * 1. The gate wire-up on channel-decision return.
20339
+ * 2. The HTTP `approve`/`deny` routes when an operator clicks.
20340
+ *
20341
+ * Idempotent: resolving an already-resolved entry is a no-op (the record
20342
+ * keeps its first decision and the audit log is not double-fired).
20343
+ * Unknown ids throw `Error("approval-aggregator: not_found")` so HTTP
20344
+ * routes return 404.
20345
+ */
20346
+ async resolve(aggregatorId, decision, operatorId) {
20347
+ await this.hydrate();
20348
+ const entry = this.entries.get(aggregatorId);
20349
+ if (!entry) {
20350
+ throw new Error("approval-aggregator: not_found");
20351
+ }
20352
+ if (entry.status !== "pending") {
20353
+ return entry;
20354
+ }
20355
+ entry.status = decision;
20356
+ entry.resolved_at = this.now().toISOString();
20357
+ entry.resolved_by = operatorId;
20358
+ await this.persist(entry);
20359
+ this.auditLog.append(
20360
+ "l2",
20361
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
20362
+ this.identityId,
20363
+ {
20364
+ aggregator_id: entry.aggregator_id,
20365
+ source_harness: entry.source_harness,
20366
+ source_agent_id: entry.source_agent_id,
20367
+ audit_log_entry_id: entry.audit_log_entry_id,
20368
+ policy_rule_id: entry.policy_rule_id,
20369
+ decision,
20370
+ decided_by: operatorId,
20371
+ decided_at: entry.resolved_at
20372
+ }
20373
+ );
20374
+ this.emit({ type: "resolved", entry: { ...entry } });
20375
+ return entry;
20376
+ }
20377
+ // ── Internal: ingest paths ─────────────────────────────────────────────
20378
+ async ingestRequested(event) {
20379
+ const ctx = this.resolveSourceContext(event);
20380
+ const auditId = this.auditEntryIdForEvent(event);
20381
+ const dedupKey = `${ctx.source_harness}|${ctx.source_agent_id}|${auditId}`;
20382
+ const existing = this.dedupIndex.get(dedupKey);
20383
+ if (existing) {
20384
+ const existingEntry = this.entries.get(existing);
20385
+ if (existingEntry) {
20386
+ this.correlationIndex.set(event.correlation_id, existing);
20387
+ this.auditLog.append(
20388
+ "l2",
20389
+ APPROVAL_AGGREGATOR_AUDIT_OPS.DEDUPED,
20390
+ this.identityId,
20391
+ {
20392
+ aggregator_id: existing,
20393
+ source_harness: ctx.source_harness,
20394
+ source_agent_id: ctx.source_agent_id,
20395
+ audit_log_entry_id: auditId,
20396
+ policy_rule_id: this.derivePolicyRuleId(event),
20397
+ correlation_id: event.correlation_id
20398
+ }
20399
+ );
20400
+ this.emit({ type: "deduped", entry: { ...existingEntry } });
20401
+ return null;
20402
+ }
20403
+ }
20404
+ const id = crypto.randomUUID();
20405
+ const now = this.now();
20406
+ const expires = new Date(now.getTime() + this.pendingTtlMs);
20407
+ const hubInboxId = this.resolveHubInboxItemId(event);
20408
+ const entry = {
20409
+ aggregator_id: id,
20410
+ source_harness: ctx.source_harness,
20411
+ source_agent_id: ctx.source_agent_id,
20412
+ audit_log_entry_id: auditId,
20413
+ policy_rule_id: this.derivePolicyRuleId(event),
20414
+ action_summary: this.deriveActionSummary(event),
20415
+ request_payload_hash: this.hashPayload(event.context),
20416
+ status: "pending",
20417
+ created_at: now.toISOString(),
20418
+ expires_at: expires.toISOString(),
20419
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
20420
+ };
20421
+ this.entries.set(id, entry);
20422
+ this.dedupIndex.set(dedupKey, id);
20423
+ this.correlationIndex.set(event.correlation_id, id);
20424
+ this.fullPayloads.set(id, event.context);
20425
+ await this.persist(entry);
20426
+ this.auditLog.append(
20427
+ "l2",
20428
+ APPROVAL_AGGREGATOR_AUDIT_OPS.AGGREGATED,
20429
+ this.identityId,
20430
+ {
20431
+ aggregator_id: id,
20432
+ source_harness: ctx.source_harness,
20433
+ source_agent_id: ctx.source_agent_id,
20434
+ audit_log_entry_id: auditId,
20435
+ policy_rule_id: entry.policy_rule_id,
20436
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
20437
+ }
20438
+ );
20439
+ this.emit({ type: "aggregated", entry: { ...entry } });
20440
+ return entry;
20441
+ }
20442
+ async ingestResolved(event) {
20443
+ const id = this.correlationIndex.get(event.correlation_id);
20444
+ if (!id) return null;
20445
+ const entry = this.entries.get(id);
20446
+ if (!entry) return null;
20447
+ if (entry.status !== "pending") return entry;
20448
+ if (!event.resolution) return entry;
20449
+ const failClosed = event.resolution.decision === "deny" && event.resolution.decided_by === "channel_failure";
20450
+ const status = failClosed ? "timeout" : event.resolution.decision === "approve" ? "approved" : "denied";
20451
+ entry.status = status;
20452
+ entry.resolved_at = event.resolution.decided_at;
20453
+ entry.resolved_by = event.resolution.decided_by;
20454
+ await this.persist(entry);
20455
+ this.auditLog.append(
20456
+ "l2",
20457
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
20458
+ this.identityId,
20459
+ {
20460
+ aggregator_id: id,
20461
+ source_harness: entry.source_harness,
20462
+ source_agent_id: entry.source_agent_id,
20463
+ audit_log_entry_id: entry.audit_log_entry_id,
20464
+ policy_rule_id: entry.policy_rule_id,
20465
+ decision: status,
20466
+ decided_by: entry.resolved_by,
20467
+ decided_at: entry.resolved_at,
20468
+ fail_closed: failClosed
20469
+ }
20470
+ );
20471
+ this.emit({ type: "resolved", entry: { ...entry } });
20472
+ return entry;
20473
+ }
20474
+ // ── Internal: helpers ──────────────────────────────────────────────────
20475
+ /**
20476
+ * Audit-log entry id for the dedup tuple. The audit log itself does not
20477
+ * surface a stable per-entry id (counter-prefixed keys are internal); the
20478
+ * aggregator uses the request timestamp + operation, which together pin
20479
+ * the audit entry the gate appended on the same call.
20480
+ */
20481
+ auditEntryIdForEvent(event) {
20482
+ return `${event.request_timestamp}:${event.operation}`;
20483
+ }
20484
+ derivePolicyRuleId(event) {
20485
+ return `tier${event.tier}:${event.operation}`;
20486
+ }
20487
+ deriveActionSummary(event) {
20488
+ return `${event.operation} (tier ${event.tier})`;
20489
+ }
20490
+ /**
20491
+ * Canonical SHA-256 of the request context. Sorted-keys serialization so
20492
+ * identical payloads always hash the same, even when key insertion order
20493
+ * varies. Defends against payload-replay smuggling (the aggregator can
20494
+ * tell the same payload was seen twice without storing it cleartext).
20495
+ */
20496
+ hashPayload(payload) {
20497
+ const canonical = JSON.stringify(payload, Object.keys(payload).sort());
20498
+ return crypto.createHash("sha256").update(canonical).digest("hex");
20499
+ }
20500
+ emit(event) {
20501
+ for (const listener of this.listeners) {
20502
+ try {
20503
+ listener(event);
20504
+ } catch {
20505
+ }
20506
+ }
20507
+ }
20508
+ async expireStale() {
20509
+ const nowMs = this.now().getTime();
20510
+ for (const entry of this.entries.values()) {
20511
+ if (entry.status !== "pending") continue;
20512
+ if (Date.parse(entry.expires_at) > nowMs) continue;
20513
+ entry.status = "expired";
20514
+ entry.resolved_at = this.now().toISOString();
20515
+ entry.resolved_by = "system_ttl";
20516
+ await this.persist(entry);
20517
+ this.auditLog.append(
20518
+ "l2",
20519
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
20520
+ this.identityId,
20521
+ {
20522
+ aggregator_id: entry.aggregator_id,
20523
+ source_harness: entry.source_harness,
20524
+ source_agent_id: entry.source_agent_id,
20525
+ audit_log_entry_id: entry.audit_log_entry_id,
20526
+ policy_rule_id: entry.policy_rule_id,
20527
+ decision: "expired",
20528
+ decided_by: "system_ttl",
20529
+ decided_at: entry.resolved_at
20530
+ }
20531
+ );
20532
+ this.emit({ type: "resolved", entry: { ...entry } });
20533
+ }
20534
+ }
20535
+ async persist(entry) {
20536
+ const serialized = stringToBytes(JSON.stringify(entry));
20537
+ const encrypted = encrypt(serialized, this.encryptionKey);
20538
+ await this.storage.write(
20539
+ APPROVAL_AGGREGATOR_NAMESPACE,
20540
+ entry.aggregator_id,
20541
+ stringToBytes(JSON.stringify(encrypted))
20542
+ );
20543
+ }
20544
+ async hydrate() {
20545
+ if (this.hydrated) return;
20546
+ this.hydrated = true;
20547
+ try {
20548
+ const metas = await this.storage.list(APPROVAL_AGGREGATOR_NAMESPACE);
20549
+ for (const meta of metas) {
20550
+ const raw = await this.storage.read(
20551
+ APPROVAL_AGGREGATOR_NAMESPACE,
20552
+ meta.key
20553
+ );
20554
+ if (!raw) continue;
20555
+ try {
20556
+ const encrypted = JSON.parse(bytesToString(raw));
20557
+ const decrypted = decrypt(encrypted, this.encryptionKey);
20558
+ const entry = JSON.parse(bytesToString(decrypted));
20559
+ this.entries.set(entry.aggregator_id, entry);
20560
+ const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
20561
+ this.dedupIndex.set(dedupKey, entry.aggregator_id);
20562
+ } catch {
20563
+ }
20564
+ }
20565
+ } catch {
20566
+ this.hydrated = false;
20567
+ }
20568
+ }
20569
+ };
20570
+ }
20571
+ });
19812
20572
 
19813
20573
  // src/principal-policy/tools.ts
19814
20574
  function createPrincipalPolicyTools(policy, baseline, auditLog) {
@@ -22726,6 +23486,12 @@ function typed(markerPath, lineNumber, field, expected) {
22726
23486
  }
22727
23487
  async function consumeResetHistoryMarker(options) {
22728
23488
  const markerPath = path.join(options.storagePath, RESET_HISTORY_FILENAME);
23489
+ const consumedPath = markerPath + ".consumed";
23490
+ if (await fileExists3(consumedPath)) {
23491
+ await promises.rm(markerPath, { force: true });
23492
+ await promises.rm(consumedPath, { force: true });
23493
+ return { emitted: 0, markerPath };
23494
+ }
22729
23495
  if (!await fileExists3(markerPath)) {
22730
23496
  return { emitted: 0, markerPath };
22731
23497
  }
@@ -22750,7 +23516,9 @@ async function consumeResetHistoryMarker(options) {
22750
23516
  });
22751
23517
  }
22752
23518
  await options.auditLog.flush();
23519
+ await promises.writeFile(consumedPath, "", "utf-8");
22753
23520
  await promises.rm(markerPath, { force: true });
23521
+ await promises.rm(consumedPath, { force: true });
22754
23522
  return { emitted: markers.length, markerHash, markerPath };
22755
23523
  }
22756
23524
  async function fileExists3(path) {
@@ -32033,6 +32801,36 @@ var init_hub_service = __esm({
32033
32801
  const chat = this.requireOperatorChat();
32034
32802
  return chat.getConciergeHistory();
32035
32803
  }
32804
+ // ── Concierge memory threads (WP-V1.3-9 Tau-1) ─────────────────────
32805
+ /**
32806
+ * Whether the operator-chat service has the WP-V1.3-9 memory store
32807
+ * wired. Routes use this to 503 cleanly when the foundation memory
32808
+ * surface is unavailable on a given fortress.
32809
+ */
32810
+ hasConciergeMemory() {
32811
+ return Boolean(this.deps.operatorChat?.hasConciergeMemory());
32812
+ }
32813
+ async listConciergeMemoryThreads(opts) {
32814
+ const chat = this.requireOperatorChat();
32815
+ if (!chat.hasConciergeMemory()) {
32816
+ throw new HubCapabilityError("concierge_memory_not_wired");
32817
+ }
32818
+ return chat.listConciergeMemoryThreads(opts);
32819
+ }
32820
+ async readConciergeMemoryThread(threadId, opts) {
32821
+ const chat = this.requireOperatorChat();
32822
+ if (!chat.hasConciergeMemory()) {
32823
+ throw new HubCapabilityError("concierge_memory_not_wired");
32824
+ }
32825
+ return chat.readConciergeMemoryThread(threadId, opts);
32826
+ }
32827
+ async deleteConciergeMemoryThread(threadId) {
32828
+ const chat = this.requireOperatorChat();
32829
+ if (!chat.hasConciergeMemory()) {
32830
+ throw new HubCapabilityError("concierge_memory_not_wired");
32831
+ }
32832
+ return chat.deleteConciergeMemoryThread(threadId);
32833
+ }
32036
32834
  /**
32037
32835
  * Open the click-to-inspect/approve panel for a wrapped agent. The
32038
32836
  * panel surfaces recent activity routed through this agent, pending
@@ -32171,7 +32969,20 @@ var init_operator_chat_audit_events = __esm({
32171
32969
  * affordance now opens an inspect/approve panel (recent activity +
32172
32970
  * pending approvals + policy summary) instead of a chat session.
32173
32971
  */
32174
- AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened"
32972
+ AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened",
32973
+ /**
32974
+ * Operator viewed concierge thread history (WP-V1.3-9 Tau-1). Emitted
32975
+ * when the operator hits the list-threads or read-thread route. Body
32976
+ * carries the thread_id (or `*` for the list endpoint) and a count;
32977
+ * raw turn content never crosses the audit surface.
32978
+ */
32979
+ CONCIERGE_HISTORY_READ: "operator_concierge_history_read",
32980
+ /**
32981
+ * Operator deleted a concierge thread (WP-V1.3-9 Tau-1). Emitted on
32982
+ * successful thread removal. Body carries thread_id + turn_count of
32983
+ * the deleted bundle.
32984
+ */
32985
+ CONCIERGE_THREAD_DELETED: "operator_concierge_thread_deleted"
32175
32986
  };
32176
32987
  }
32177
32988
  });
@@ -32233,6 +33044,14 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32233
33044
  contextProviders;
32234
33045
  piiFilter;
32235
33046
  conciergeMaxTokens;
33047
+ memory;
33048
+ /**
33049
+ * In-memory thread_id assigned to the active concierge session.
33050
+ * The first sendConcierge call after construction allocates a fresh
33051
+ * UUID; subsequent calls reuse it so multi-turn coherence (Tau-2)
33052
+ * folds the prior turns into context. Cleared by `resetConciergeMemoryThread`.
33053
+ */
33054
+ activeMemoryThreadId;
32236
33055
  constructor(deps) {
32237
33056
  this.store = deps.store;
32238
33057
  this.auditLog = deps.auditLog;
@@ -32243,6 +33062,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32243
33062
  }
32244
33063
  if (deps.conciergePiiFilter) this.piiFilter = deps.conciergePiiFilter;
32245
33064
  this.conciergeMaxTokens = deps.conciergeMaxTokens ?? DEFAULT_CONCIERGE_MAX_TOKENS;
33065
+ if (deps.conciergeMemory) this.memory = deps.conciergeMemory;
32246
33066
  }
32247
33067
  // ── Concierge ─────────────────────────────────────────────────────────
32248
33068
  /**
@@ -32273,6 +33093,11 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32273
33093
  CONCIERGE_THREAD_KEY,
32274
33094
  operatorMessage
32275
33095
  );
33096
+ if (this.memory) {
33097
+ const threadId = this.ensureActiveMemoryThread();
33098
+ await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
33099
+ });
33100
+ }
32276
33101
  const start = Date.now();
32277
33102
  let conciergeBody;
32278
33103
  let servedBy = "disabled";
@@ -32328,6 +33153,11 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32328
33153
  CONCIERGE_THREAD_KEY,
32329
33154
  responseMessage
32330
33155
  );
33156
+ if (this.memory) {
33157
+ const threadId = this.ensureActiveMemoryThread();
33158
+ await this.memory.appendTurn(threadId, "assistant", conciergeBody).catch(() => {
33159
+ });
33160
+ }
32331
33161
  const payload = {
32332
33162
  version: "1.2",
32333
33163
  event_id: makeEventId("conc"),
@@ -32360,6 +33190,105 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32360
33190
  );
32361
33191
  return thread ? thread.messages : [];
32362
33192
  }
33193
+ // ── WP-V1.3-9 Tau-1 memory accessors ─────────────────────────────────
33194
+ /**
33195
+ * Whether the foundation memory store is wired. Routes use this to
33196
+ * 503 cleanly when called against an unwired service.
33197
+ */
33198
+ hasConciergeMemory() {
33199
+ return this.memory !== void 0;
33200
+ }
33201
+ /**
33202
+ * List concierge memory threads, newest-first. Emits the
33203
+ * `operator_concierge_history_read` audit event with `thread_id="*"`.
33204
+ */
33205
+ async listConciergeMemoryThreads(opts) {
33206
+ if (!this.memory) {
33207
+ throw new Error("concierge memory store not configured");
33208
+ }
33209
+ const summaries = await this.memory.listThreads(opts);
33210
+ const totalTurns = summaries.reduce((acc, s) => acc + s.turn_count, 0);
33211
+ const payload = {
33212
+ version: "1.2",
33213
+ event_id: makeEventId("conc-hist"),
33214
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33215
+ identity_id: this.identityId,
33216
+ kind: "operator_concierge_history_read",
33217
+ surface: "concierge",
33218
+ thread_id: "*",
33219
+ turn_count: totalTurns
33220
+ };
33221
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
33222
+ return summaries;
33223
+ }
33224
+ /**
33225
+ * Read a concierge memory thread, oldest turn first. Emits the
33226
+ * `operator_concierge_history_read` audit event with the named
33227
+ * thread_id and the count of turns surfaced.
33228
+ */
33229
+ async readConciergeMemoryThread(threadId, opts) {
33230
+ if (!this.memory) {
33231
+ throw new Error("concierge memory store not configured");
33232
+ }
33233
+ const turns = await this.memory.readThread(threadId, opts);
33234
+ const payload = {
33235
+ version: "1.2",
33236
+ event_id: makeEventId("conc-hist"),
33237
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33238
+ identity_id: this.identityId,
33239
+ kind: "operator_concierge_history_read",
33240
+ surface: "concierge",
33241
+ thread_id: threadId,
33242
+ turn_count: turns.length
33243
+ };
33244
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
33245
+ return turns;
33246
+ }
33247
+ /**
33248
+ * Delete a concierge memory thread. Emits
33249
+ * `operator_concierge_thread_deleted` only when a bundle was actually
33250
+ * removed; absent threads return false without an audit event.
33251
+ */
33252
+ async deleteConciergeMemoryThread(threadId) {
33253
+ if (!this.memory) {
33254
+ throw new Error("concierge memory store not configured");
33255
+ }
33256
+ const turnsBefore = await this.memory.readThread(threadId);
33257
+ if (turnsBefore.length === 0) {
33258
+ return await this.memory.deleteThread(threadId);
33259
+ }
33260
+ const removed = await this.memory.deleteThread(threadId);
33261
+ if (!removed) return false;
33262
+ if (this.activeMemoryThreadId === threadId) {
33263
+ this.activeMemoryThreadId = void 0;
33264
+ }
33265
+ const payload = {
33266
+ version: "1.2",
33267
+ event_id: makeEventId("conc-del"),
33268
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33269
+ identity_id: this.identityId,
33270
+ kind: "operator_concierge_thread_deleted",
33271
+ surface: "concierge",
33272
+ thread_id: threadId,
33273
+ turn_count: turnsBefore.length
33274
+ };
33275
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_THREAD_DELETED, payload, "success");
33276
+ return true;
33277
+ }
33278
+ /**
33279
+ * Reset the active session memory thread. Subsequent sendConcierge
33280
+ * calls allocate a fresh thread_id. Surfaced for tests + future "new
33281
+ * conversation" affordance; not currently called by the dashboard.
33282
+ */
33283
+ resetConciergeMemoryThread() {
33284
+ this.activeMemoryThreadId = void 0;
33285
+ }
33286
+ ensureActiveMemoryThread() {
33287
+ if (!this.activeMemoryThreadId) {
33288
+ this.activeMemoryThreadId = crypto.randomUUID();
33289
+ }
33290
+ return this.activeMemoryThreadId;
33291
+ }
32363
33292
  /**
32364
33293
  * Stitch fortress state into a single context blob the substrate
32365
33294
  * folds into its summarization prompt.
@@ -32526,11 +33455,250 @@ var init_operator_chat_store = __esm({
32526
33455
  }
32527
33456
  });
32528
33457
 
33458
+ // src/chat/concierge-memory-store.ts
33459
+ function bundleKey(threadId) {
33460
+ return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
33461
+ }
33462
+ function stripKeyPrefix(key) {
33463
+ if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
33464
+ return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
33465
+ }
33466
+ function lastTurnId(bundle) {
33467
+ let max = 0;
33468
+ for (const t of bundle.turns) {
33469
+ if (t.turn_id > max) max = t.turn_id;
33470
+ }
33471
+ return max;
33472
+ }
33473
+ var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO2, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES2, ConciergeMemoryStore;
33474
+ var init_concierge_memory_store = __esm({
33475
+ "src/chat/concierge-memory-store.ts"() {
33476
+ init_encryption();
33477
+ init_key_derivation();
33478
+ init_encoding();
33479
+ CONCIERGE_MEMORY_NAMESPACE = "_chat";
33480
+ CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
33481
+ HKDF_INFO2 = "concierge-memory-store-v1";
33482
+ DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
33483
+ MAX_BUNDLE_BYTES2 = 4 * 1024 * 1024;
33484
+ ConciergeMemoryStore = class {
33485
+ storage;
33486
+ encryptionKey;
33487
+ fortressId;
33488
+ retentionDays;
33489
+ locks;
33490
+ constructor(opts) {
33491
+ this.storage = opts.storage;
33492
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
33493
+ this.fortressId = opts.fortressId;
33494
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
33495
+ this.locks = /* @__PURE__ */ new Map();
33496
+ }
33497
+ /**
33498
+ * Append a turn to the named thread, creating the bundle if no record
33499
+ * exists. Returns the persisted turn (with assigned turn_id +
33500
+ * retention_until). Per-thread serialisation guarantees turn_id
33501
+ * monotonicity even under concurrent callers.
33502
+ */
33503
+ async appendTurn(threadId, role, content) {
33504
+ return this.withLock(threadId, async () => {
33505
+ const bundle = await this.loadBundle(threadId) ?? null;
33506
+ const now = /* @__PURE__ */ new Date();
33507
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
33508
+ const retentionUntil = new Date(now.getTime() + retentionMs);
33509
+ const nextTurnId = bundle ? lastTurnId(bundle) + 1 : 1;
33510
+ const turn = {
33511
+ thread_id: threadId,
33512
+ fortress_id: this.fortressId,
33513
+ turn_id: nextTurnId,
33514
+ role,
33515
+ content,
33516
+ created_at: now.toISOString(),
33517
+ retention_until: retentionUntil.toISOString()
33518
+ };
33519
+ const next = bundle ? { ...bundle, turns: [...bundle.turns, turn] } : {
33520
+ version: 1,
33521
+ thread_id: threadId,
33522
+ fortress_id: this.fortressId,
33523
+ created_at: now.toISOString(),
33524
+ turns: [turn]
33525
+ };
33526
+ await this.saveBundle(next);
33527
+ return turn;
33528
+ });
33529
+ }
33530
+ /**
33531
+ * Read turns from a thread, oldest-first. Returns an empty array if
33532
+ * the thread does not exist or its bundle is corrupt. Does not emit
33533
+ * audit events; the caller (HTTP route handler) owns audit semantics.
33534
+ */
33535
+ async readThread(threadId, opts) {
33536
+ const bundle = await this.loadBundle(threadId);
33537
+ if (!bundle) return [];
33538
+ let turns = bundle.turns;
33539
+ if (opts?.sinceTurnId !== void 0) {
33540
+ const cutoff = opts.sinceTurnId;
33541
+ turns = turns.filter((t) => t.turn_id > cutoff);
33542
+ }
33543
+ if (opts?.limit !== void 0) {
33544
+ turns = turns.slice(0, opts.limit);
33545
+ }
33546
+ return turns;
33547
+ }
33548
+ /**
33549
+ * Enumerate concierge threads in this fortress with summary metadata.
33550
+ * Sorted newest-first by last_turn_at.
33551
+ */
33552
+ async listThreads(opts) {
33553
+ const entries = await this.storage.list(
33554
+ CONCIERGE_MEMORY_NAMESPACE,
33555
+ CONCIERGE_MEMORY_KEY_PREFIX
33556
+ );
33557
+ const summaries = [];
33558
+ for (const meta of entries) {
33559
+ const threadId = stripKeyPrefix(meta.key);
33560
+ if (threadId === null) continue;
33561
+ const bundle = await this.loadBundle(threadId);
33562
+ if (!bundle || bundle.turns.length === 0) continue;
33563
+ const last = bundle.turns[bundle.turns.length - 1];
33564
+ summaries.push({
33565
+ thread_id: bundle.thread_id,
33566
+ created_at: bundle.created_at,
33567
+ last_turn_at: last ? last.created_at : bundle.created_at,
33568
+ turn_count: bundle.turns.length
33569
+ });
33570
+ }
33571
+ summaries.sort(
33572
+ (a, b) => a.last_turn_at < b.last_turn_at ? 1 : a.last_turn_at > b.last_turn_at ? -1 : 0
33573
+ );
33574
+ if (opts?.limit !== void 0) {
33575
+ return summaries.slice(0, opts.limit);
33576
+ }
33577
+ return summaries;
33578
+ }
33579
+ /**
33580
+ * Delete a thread's bundle. Returns true if the bundle existed and
33581
+ * was removed; false if no bundle was present. Audit emission is the
33582
+ * caller's responsibility.
33583
+ */
33584
+ async deleteThread(threadId) {
33585
+ const key = bundleKey(threadId);
33586
+ return this.withLock(threadId, async () => {
33587
+ const existed = await this.storage.exists(
33588
+ CONCIERGE_MEMORY_NAMESPACE,
33589
+ key
33590
+ );
33591
+ if (!existed) return false;
33592
+ try {
33593
+ await this.storage.delete(CONCIERGE_MEMORY_NAMESPACE, key);
33594
+ } catch {
33595
+ return false;
33596
+ }
33597
+ return true;
33598
+ });
33599
+ }
33600
+ /**
33601
+ * Drop expired turns across all threads. Threads emptied by pruning
33602
+ * are removed entirely. Returns the count of turns pruned.
33603
+ */
33604
+ async pruneExpired(now) {
33605
+ const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
33606
+ const entries = await this.storage.list(
33607
+ CONCIERGE_MEMORY_NAMESPACE,
33608
+ CONCIERGE_MEMORY_KEY_PREFIX
33609
+ );
33610
+ let pruned = 0;
33611
+ for (const meta of entries) {
33612
+ const threadId = stripKeyPrefix(meta.key);
33613
+ if (threadId === null) continue;
33614
+ pruned += await this.withLock(threadId, async () => {
33615
+ const bundle = await this.loadBundle(threadId);
33616
+ if (!bundle) return 0;
33617
+ const kept = bundle.turns.filter((t) => t.retention_until > cutoff);
33618
+ const dropped = bundle.turns.length - kept.length;
33619
+ if (dropped === 0) return 0;
33620
+ if (kept.length === 0) {
33621
+ await this.storage.delete(
33622
+ CONCIERGE_MEMORY_NAMESPACE,
33623
+ bundleKey(threadId)
33624
+ );
33625
+ } else {
33626
+ await this.saveBundle({ ...bundle, turns: kept });
33627
+ }
33628
+ return dropped;
33629
+ });
33630
+ }
33631
+ return { pruned };
33632
+ }
33633
+ // ── internals ────────────────────────────────────────────────────────
33634
+ async loadBundle(threadId) {
33635
+ const key = bundleKey(threadId);
33636
+ let raw;
33637
+ try {
33638
+ raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
33639
+ } catch {
33640
+ return null;
33641
+ }
33642
+ if (!raw) return null;
33643
+ if (raw.length > MAX_BUNDLE_BYTES2) return null;
33644
+ try {
33645
+ const envelope = JSON.parse(bytesToString(raw));
33646
+ const aad = stringToBytes(threadId);
33647
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
33648
+ const parsed = JSON.parse(
33649
+ bytesToString(plaintext)
33650
+ );
33651
+ if (parsed.version !== 1) return null;
33652
+ if (parsed.thread_id !== threadId) return null;
33653
+ return parsed;
33654
+ } catch {
33655
+ return null;
33656
+ }
33657
+ }
33658
+ async saveBundle(bundle) {
33659
+ const key = bundleKey(bundle.thread_id);
33660
+ const aad = stringToBytes(bundle.thread_id);
33661
+ const plaintext = stringToBytes(JSON.stringify(bundle));
33662
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
33663
+ await this.storage.write(
33664
+ CONCIERGE_MEMORY_NAMESPACE,
33665
+ key,
33666
+ stringToBytes(JSON.stringify(envelope))
33667
+ );
33668
+ }
33669
+ /**
33670
+ * Run `task` while holding the per-thread async lock. Lock is released
33671
+ * once the task settles (success or failure). Generic helper so
33672
+ * appendTurn / deleteThread / pruneExpired share serialisation.
33673
+ */
33674
+ async withLock(threadId, task) {
33675
+ const previous = this.locks.get(threadId) ?? Promise.resolve();
33676
+ let release;
33677
+ const next = new Promise((resolve8) => {
33678
+ release = resolve8;
33679
+ });
33680
+ const chained = previous.then(() => next);
33681
+ this.locks.set(threadId, chained);
33682
+ try {
33683
+ await previous;
33684
+ return await task();
33685
+ } finally {
33686
+ release();
33687
+ if (this.locks.get(threadId) === chained) {
33688
+ this.locks.delete(threadId);
33689
+ }
33690
+ }
33691
+ }
33692
+ };
33693
+ }
33694
+ });
33695
+
32529
33696
  // src/chat/operator-chat-index.ts
32530
33697
  var init_operator_chat_index = __esm({
32531
33698
  "src/chat/operator-chat-index.ts"() {
32532
33699
  init_operator_chat_service();
32533
33700
  init_operator_chat_store();
33701
+ init_concierge_memory_store();
32534
33702
  init_operator_chat_audit_events();
32535
33703
  init_operator_chat_types();
32536
33704
  }
@@ -32544,6 +33712,14 @@ function buildV11Bindings(inputs) {
32544
33712
  let operatorChatService;
32545
33713
  if (inputs.storage && inputs.masterKey) {
32546
33714
  const chatStore = new OperatorChatStore(inputs.storage, inputs.masterKey);
33715
+ const conciergeMemory = new ConciergeMemoryStore({
33716
+ storage: inputs.storage,
33717
+ masterKey: inputs.masterKey,
33718
+ fortressId: inputs.fortressId,
33719
+ ...inputs.conciergeMemoryRetentionDays !== void 0 ? { retentionDays: inputs.conciergeMemoryRetentionDays } : {}
33720
+ });
33721
+ void conciergeMemory.pruneExpired().catch(() => {
33722
+ });
32547
33723
  operatorChatService = new OperatorChatService({
32548
33724
  store: chatStore,
32549
33725
  auditLog: inputs.auditLog,
@@ -32554,7 +33730,8 @@ function buildV11Bindings(inputs) {
32554
33730
  identityId: inputs.identityId,
32555
33731
  registry
32556
33732
  }),
32557
- conciergePiiFilter: buildConciergePiiFilter()
33733
+ conciergePiiFilter: buildConciergePiiFilter(),
33734
+ conciergeMemory
32558
33735
  });
32559
33736
  }
32560
33737
  const hubService = new HubService({
@@ -32789,7 +33966,7 @@ var init_defaults = __esm({
32789
33966
  });
32790
33967
 
32791
33968
  // src/intelligence/policy-store.ts
32792
- var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO2, IntelligenceConfigStore;
33969
+ var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO3, IntelligenceConfigStore;
32793
33970
  var init_policy_store = __esm({
32794
33971
  "src/intelligence/policy-store.ts"() {
32795
33972
  init_encryption();
@@ -32798,13 +33975,13 @@ var init_policy_store = __esm({
32798
33975
  init_defaults();
32799
33976
  INTELLIGENCE_NAMESPACE = "_intelligence";
32800
33977
  SUBSTRATE_CONFIG_KEY = "substrate-config";
32801
- HKDF_INFO2 = "intelligence-substrate-config";
33978
+ HKDF_INFO3 = "intelligence-substrate-config";
32802
33979
  IntelligenceConfigStore = class {
32803
33980
  storage;
32804
33981
  encryptionKey;
32805
33982
  constructor(storage, masterKey) {
32806
33983
  this.storage = storage;
32807
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
33984
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
32808
33985
  }
32809
33986
  /**
32810
33987
  * Load the operator's substrate config from disk. Returns the config
@@ -34826,7 +36003,9 @@ async function verifyExitBundle(bundleDir, options = {}) {
34826
36003
  );
34827
36004
  }
34828
36005
  }
34829
- const reputationFailed = reputation?.bundle_signature_valid === false || (reputation?.invalid_attestations ?? 0) > 0;
36006
+ const reputationBundleFailed = reputation?.bundle_signature_valid === false;
36007
+ const reputationAttestationFailed = (reputation?.invalid_attestations ?? 0) > 0;
36008
+ const reputationFailed = reputationBundleFailed || reputationAttestationFailed;
34830
36009
  const identityFailed = identity ? !identity.signature_valid : false;
34831
36010
  const unverifiableCount = reputation?.unverifiable_attestations ?? 0;
34832
36011
  const unverifiableFailed = unverifiableCount > 0 && !options.acceptUnverifiableAttestations;
@@ -34835,6 +36014,16 @@ async function verifyExitBundle(bundleDir, options = {}) {
34835
36014
  `${unverifiableCount} reputation attestation(s) have unknown signer public keys; pass --accept-unverifiable-attestations to import anyway`
34836
36015
  );
34837
36016
  }
36017
+ let detailedFailureClass;
36018
+ if (identityFailed) {
36019
+ detailedFailureClass = "identity_signature_invalid";
36020
+ } else if (reputationBundleFailed) {
36021
+ detailedFailureClass = "reputation_bundle_signature_invalid";
36022
+ } else if (reputationAttestationFailed) {
36023
+ detailedFailureClass = "reputation_attestation_signature_invalid";
36024
+ } else if (unverifiableFailed) {
36025
+ detailedFailureClass = "reputation_unverifiable_attestations";
36026
+ }
34838
36027
  return {
34839
36028
  version: "1.1",
34840
36029
  passed: !reputationFailed && !identityFailed && !unverifiableFailed,
@@ -34854,7 +36043,7 @@ async function verifyExitBundle(bundleDir, options = {}) {
34854
36043
  identity,
34855
36044
  audit,
34856
36045
  reputation,
34857
- failure_class: reputationFailed || identityFailed || unverifiableFailed ? "other" : void 0
36046
+ failure_class: detailedFailureClass
34858
36047
  };
34859
36048
  }
34860
36049
  var InvalidExitBundleError, PRIVATE_MATERIAL_KEYS;
@@ -35822,7 +37011,19 @@ async function runExitCommand(args) {
35822
37011
  }
35823
37012
  const config = await loadConfig();
35824
37013
  const ctx = await openExitContext(argv, env);
35825
- const policy = await loadPrincipalPolicy(ctx.storagePath);
37014
+ let policy;
37015
+ try {
37016
+ policy = await loadPrincipalPolicy(ctx.storagePath);
37017
+ } catch (policyErr) {
37018
+ if (policyErr instanceof MalformedPrincipalPolicyError) {
37019
+ write(err, `
37020
+ Sanctuary cannot proceed.
37021
+ ${policyErr.message}
37022
+ `);
37023
+ return 1;
37024
+ }
37025
+ throw policyErr;
37026
+ }
35826
37027
  const result = await exportExitBundle({
35827
37028
  bundleDir: outDir,
35828
37029
  storage: ctx.storage,
@@ -36522,7 +37723,19 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
36522
37723
  const profileStore = new SovereigntyProfileStore(storage, masterKey);
36523
37724
  await profileStore.load();
36524
37725
  const { tools: profileTools } = createSovereigntyProfileTools(profileStore, auditLog);
36525
- const policy = await loadPrincipalPolicy(config.storage_path);
37726
+ let policy;
37727
+ try {
37728
+ policy = await loadPrincipalPolicy(config.storage_path);
37729
+ } catch (err) {
37730
+ if (err instanceof MalformedPrincipalPolicyError) {
37731
+ console.error(`
37732
+ Sanctuary cannot start.
37733
+ ${err.message}
37734
+ `);
37735
+ process.exit(1);
37736
+ }
37737
+ throw err;
37738
+ }
36526
37739
  const baseline = new BaselineTracker(storage, masterKey);
36527
37740
  await baseline.load();
36528
37741
  let approvalChannel;
@@ -36620,6 +37833,21 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
36620
37833
  });
36621
37834
  } : void 0;
36622
37835
  const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
37836
+ const fortressIdForAggregator = fortressIdFromStoragePath(config.storage_path);
37837
+ const aggregatorIdentityId = identityManager.getPrimaryIdentityId() ?? `fortress:${config.storage_path}`;
37838
+ const approvalAggregator = new ApprovalAggregator({
37839
+ storage,
37840
+ masterKey,
37841
+ auditLog,
37842
+ identityId: aggregatorIdentityId,
37843
+ fortressId: fortressIdForAggregator
37844
+ });
37845
+ gate.setApprovalEventCallback((event) => {
37846
+ void approvalAggregator.ingest(event);
37847
+ });
37848
+ if (dashboard) {
37849
+ dashboard.setApprovalAggregator(approvalAggregator);
37850
+ }
36623
37851
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
36624
37852
  const { tools: sanctuaryMetaTools } = createSanctuaryTools({
36625
37853
  config,
@@ -36810,6 +38038,7 @@ var init_src = __esm({
36810
38038
  init_dashboard();
36811
38039
  init_webhook();
36812
38040
  init_gate();
38041
+ init_approval_aggregator();
36813
38042
  init_tools4();
36814
38043
  init_router();
36815
38044
  init_router();
@@ -41173,7 +42402,8 @@ var init_agents = __esm({
41173
42402
  // src/cli/reset-passphrase.ts
41174
42403
  var reset_passphrase_exports = {};
41175
42404
  __export(reset_passphrase_exports, {
41176
- runResetPassphraseCommand: () => runResetPassphraseCommand
42405
+ runResetPassphraseCommand: () => runResetPassphraseCommand,
42406
+ zeroizeBuffers: () => zeroizeBuffers
41177
42407
  });
41178
42408
  async function runResetPassphraseCommand(args) {
41179
42409
  const out = args.out ?? process.stdout;
@@ -41201,34 +42431,42 @@ Then re-run this command.
41201
42431
  return 1;
41202
42432
  }
41203
42433
  const lines = new LineReader(stdin);
42434
+ let code = 1;
42435
+ let nukeSucceeded = false;
41204
42436
  try {
41205
42437
  const availability = await surveyAvailableModes(storagePath);
41206
42438
  const mode = parsed.mode ?? await selectMode(lines, out, err, availability);
41207
42439
  if (!mode) {
41208
42440
  err.write("Aborted: no recovery mode selected.\n");
41209
- return 1;
41210
- }
41211
- if (mode === "shares") {
41212
- return await runSharesPath(out, err, availability);
41213
- }
41214
- if (mode === "guardian") {
41215
- return await runGuardianPath(out, err, availability);
42441
+ code = 1;
42442
+ } else if (mode === "shares") {
42443
+ code = await runSharesPath(out, err, availability);
42444
+ } else if (mode === "guardian") {
42445
+ code = await runGuardianPath(out, err, availability);
42446
+ } else {
42447
+ code = await runNukePath({
42448
+ out,
42449
+ err,
42450
+ lines,
42451
+ storagePath,
42452
+ home,
42453
+ plat,
42454
+ exec: args.exec ?? defaultExec2
42455
+ });
42456
+ nukeSucceeded = mode === "nuke" && code === 0;
41216
42457
  }
41217
- return await runNukePath({
41218
- out,
41219
- err,
41220
- lines,
41221
- storagePath,
41222
- home,
41223
- plat,
41224
- exec: args.exec ?? defaultExec2
41225
- });
41226
42458
  } finally {
42459
+ zeroizeBuffers(args.keyMaterialToZeroize);
41227
42460
  lines.close();
41228
42461
  }
42462
+ if (parsed.exitOnCompletion && nukeSucceeded) {
42463
+ const doExit = args.exitProcess ?? ((c) => process.exit(c));
42464
+ doExit(0);
42465
+ }
42466
+ return code;
41229
42467
  }
41230
42468
  function parseArgs2(argv) {
41231
- const out = { help: false };
42469
+ const out = { exitOnCompletion: false, help: false };
41232
42470
  for (let i = 0; i < argv.length; i++) {
41233
42471
  const a = argv[i];
41234
42472
  if (a === "--help" || a === "-h") {
@@ -41245,6 +42483,8 @@ function parseArgs2(argv) {
41245
42483
  out.storage = argv[++i];
41246
42484
  } else if (a === "--fortress" && argv[i + 1]) {
41247
42485
  out.fortress = argv[++i];
42486
+ } else if (a === "--exit-on-completion") {
42487
+ out.exitOnCompletion = true;
41248
42488
  } else if (a && a.startsWith("--")) {
41249
42489
  throw new Error(`Unknown flag: ${a}`);
41250
42490
  }
@@ -41277,6 +42517,16 @@ Options:
41277
42517
  --fortress <path> Override the fortress storage path.
41278
42518
  Consistent with "sanctuary wrap --fortress".
41279
42519
  --storage <path> Alias for --fortress.
42520
+ --exit-on-completion After a successful nuke, call process.exit(0)
42521
+ immediately so the post-wipe heap is reaped
42522
+ by the OS without re-entering the shell. Use
42523
+ on extreme-threat-model deployments where an
42524
+ attacker-on-host with heap-dump access could
42525
+ recover residual passphrase or key bytes
42526
+ between the wipe and the next operator
42527
+ command. JS strings cannot be explicitly
42528
+ zeroed; this flag is the supported way to
42529
+ bound the heap-dump window.
41280
42530
  --help, -h Show this help.
41281
42531
 
41282
42532
  Without --mode, the command surveys which paths are operationally available
@@ -41546,6 +42796,16 @@ async function prompt(lines, err, question) {
41546
42796
  err.write(question);
41547
42797
  return await lines.next();
41548
42798
  }
42799
+ function zeroizeBuffers(buffers) {
42800
+ if (!buffers) return;
42801
+ for (const b of buffers) {
42802
+ if (!b) continue;
42803
+ try {
42804
+ b.fill(0);
42805
+ } catch {
42806
+ }
42807
+ }
42808
+ }
41549
42809
  async function defaultExec2(cmd, args) {
41550
42810
  return await new Promise((resolve8, reject) => {
41551
42811
  const child = child_process.spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -42226,7 +43486,19 @@ Refusing to start the dashboard while the reset-history marker is unreadable.`
42226
43486
  }
42227
43487
  throw err;
42228
43488
  }
42229
- const policy = await loadPrincipalPolicy(config.storage_path);
43489
+ let policy;
43490
+ try {
43491
+ policy = await loadPrincipalPolicy(config.storage_path);
43492
+ } catch (err) {
43493
+ if (err instanceof MalformedPrincipalPolicyError) {
43494
+ console.error(`
43495
+ Sanctuary cannot start.
43496
+ ${err.message}
43497
+ `);
43498
+ process.exit(1);
43499
+ }
43500
+ throw err;
43501
+ }
42230
43502
  const baseline = new BaselineTracker(storage, masterKey);
42231
43503
  await baseline.load();
42232
43504
  const dashboardPort = options.port ?? config.dashboard.port;