@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.js CHANGED
@@ -4569,21 +4569,39 @@ approval_channel:
4569
4569
  }
4570
4570
  async function loadPrincipalPolicy(storagePath) {
4571
4571
  const policyPath = join(storagePath, "principal-policy.yaml");
4572
+ let content;
4573
+ try {
4574
+ content = await readFile(policyPath, "utf-8");
4575
+ } catch (err) {
4576
+ const code = err?.code;
4577
+ if (code === "ENOENT") {
4578
+ const defaultYaml = generateDefaultPolicyYaml();
4579
+ try {
4580
+ await writeFile(policyPath, defaultYaml, "utf-8");
4581
+ await chmod(policyPath, 384);
4582
+ } catch (writeErr) {
4583
+ console.warn(
4584
+ `Sanctuary: could not write default principal policy to ${policyPath}: ${writeErr.message}. Continuing with in-memory default.`
4585
+ );
4586
+ }
4587
+ return Object.freeze({ ...DEFAULT_POLICY });
4588
+ }
4589
+ throw new MalformedPrincipalPolicyError(
4590
+ policyPath,
4591
+ `read failed: ${err.message}`
4592
+ );
4593
+ }
4572
4594
  try {
4573
- const content = await readFile(policyPath, "utf-8");
4574
4595
  const policy = parsePolicy(content);
4575
4596
  return Object.freeze(policy);
4576
- } catch {
4577
- const defaultYaml = generateDefaultPolicyYaml();
4578
- try {
4579
- await writeFile(policyPath, defaultYaml, "utf-8");
4580
- await chmod(policyPath, 384);
4581
- } catch {
4582
- }
4583
- return Object.freeze({ ...DEFAULT_POLICY });
4597
+ } catch (parseErr) {
4598
+ throw new MalformedPrincipalPolicyError(
4599
+ policyPath,
4600
+ parseErr.message
4601
+ );
4584
4602
  }
4585
4603
  }
4586
- var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_POLICY;
4604
+ var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_POLICY, MalformedPrincipalPolicyError;
4587
4605
  var init_loader = __esm({
4588
4606
  "src/principal-policy/loader.ts"() {
4589
4607
  DEFAULT_TIER2 = {
@@ -4716,6 +4734,20 @@ var init_loader = __esm({
4716
4734
  ],
4717
4735
  approval_channel: DEFAULT_CHANNEL
4718
4736
  };
4737
+ MalformedPrincipalPolicyError = class extends Error {
4738
+ constructor(policyPath, reason) {
4739
+ super(
4740
+ `Principal policy at ${policyPath} is malformed and cannot be loaded.
4741
+ Reason: ${reason}
4742
+ 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.`
4743
+ );
4744
+ this.policyPath = policyPath;
4745
+ this.reason = reason;
4746
+ this.name = "MalformedPrincipalPolicyError";
4747
+ }
4748
+ policyPath;
4749
+ reason;
4750
+ };
4719
4751
  }
4720
4752
  });
4721
4753
 
@@ -4935,7 +4967,7 @@ function deepSortKeys(obj) {
4935
4967
  return sorted;
4936
4968
  }
4937
4969
  function canonicalizeForSigning(body) {
4938
- return JSON.stringify(deepSortKeys(body));
4970
+ return JSON.stringify(deepSortKeys(body)).normalize("NFC");
4939
4971
  }
4940
4972
  var init_types = __esm({
4941
4973
  "src/shr/types.ts"() {
@@ -12434,7 +12466,7 @@ var init_auth_middleware = __esm({
12434
12466
  });
12435
12467
 
12436
12468
  // src/hub/constants.ts
12437
- 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;
12469
+ 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;
12438
12470
  var init_constants3 = __esm({
12439
12471
  "src/hub/constants.ts"() {
12440
12472
  HUB_API_PREFIX = "/api/hub";
@@ -12462,6 +12494,16 @@ var init_constants3 = __esm({
12462
12494
  */
12463
12495
  CHAT_CONCIERGE_SEND: "/api/hub/chat/concierge",
12464
12496
  CHAT_CONCIERGE_HISTORY: "/api/hub/chat/concierge/history",
12497
+ /**
12498
+ * Concierge memory thread routes (WP-V1.3-9 Tau-1). Thread enumeration,
12499
+ * scrollback, and operator-initiated thread delete. Distinct from the
12500
+ * v1.2 `/history` route, which surfaces the active in-session thread
12501
+ * shape; the new routes target persisted multi-thread memory used by
12502
+ * v1.3 conversational sovereignty depth.
12503
+ */
12504
+ CHAT_CONCIERGE_THREADS_LIST: "/api/hub/chat/concierge/threads",
12505
+ CHAT_CONCIERGE_THREAD_READ: "/api/hub/chat/concierge/threads/:thread_id",
12506
+ CHAT_CONCIERGE_THREAD_DELETE: "/api/hub/chat/concierge/threads/:thread_id",
12465
12507
  /**
12466
12508
  * Click-to-inspect panel (WP-V1.2 reshape). Returns the agent's
12467
12509
  * recent activity feed, pending Tier 1 approvals routed through this
@@ -12485,6 +12527,10 @@ var init_constants3 = __esm({
12485
12527
  ];
12486
12528
  HUB_ACTIVITY_DEFAULT_LIMIT = 50;
12487
12529
  HUB_ACTIVITY_MAX_LIMIT = 500;
12530
+ HUB_CHAT_THREADS_DEFAULT_LIMIT = 50;
12531
+ HUB_CHAT_THREADS_MAX_LIMIT = 500;
12532
+ HUB_CHAT_TURNS_DEFAULT_LIMIT = 200;
12533
+ HUB_CHAT_TURNS_MAX_LIMIT = 1e3;
12488
12534
  HUB_INBOX_DEFAULT_LIMIT = 100;
12489
12535
  HUB_INBOX_MAX_LIMIT = 500;
12490
12536
  HUB_AGENTS_DEFAULT_LIMIT = 100;
@@ -12667,6 +12713,23 @@ function checkChatMessage(value) {
12667
12713
  }
12668
12714
  return trimmed;
12669
12715
  }
12716
+ function matchConciergeThreadRoute(path) {
12717
+ const prefix = `${HUB_API_PREFIX}/chat/concierge/threads/`;
12718
+ if (!path.startsWith(prefix)) return null;
12719
+ const rest = path.slice(prefix.length);
12720
+ if (rest.length === 0 || rest.includes("/")) return null;
12721
+ const decoded = decodeURIComponent(rest);
12722
+ if (decoded.length === 0) return null;
12723
+ return { threadId: decoded };
12724
+ }
12725
+ function parseSince(raw) {
12726
+ if (raw === null || raw === "") return void 0;
12727
+ const parsed = Number.parseInt(raw, 10);
12728
+ if (Number.isNaN(parsed) || parsed < 0) {
12729
+ throw new HubValidationError("since must be a non-negative integer");
12730
+ }
12731
+ return parsed;
12732
+ }
12670
12733
  function matchInboxRoute(path) {
12671
12734
  const prefix = `${HUB_API_PREFIX}/inbox/`;
12672
12735
  if (!path.startsWith(prefix)) return null;
@@ -12850,6 +12913,47 @@ async function handleHubRoute(deps, req, res) {
12850
12913
  writeJSON2(res, 200, { ok: true, data: { messages } });
12851
12914
  return true;
12852
12915
  }
12916
+ if (method === "GET" && path === HUB_ROUTES.CHAT_CONCIERGE_THREADS_LIST) {
12917
+ const limit = parseLimit(
12918
+ url.searchParams.get("limit"),
12919
+ HUB_CHAT_THREADS_DEFAULT_LIMIT,
12920
+ HUB_CHAT_THREADS_MAX_LIMIT
12921
+ );
12922
+ const threads = await deps.service.listConciergeMemoryThreads({ limit });
12923
+ writeJSON2(res, 200, { ok: true, data: { threads } });
12924
+ return true;
12925
+ }
12926
+ {
12927
+ const threadMatch = matchConciergeThreadRoute(path);
12928
+ if (threadMatch) {
12929
+ if (method === "GET") {
12930
+ const since = parseSince(url.searchParams.get("since"));
12931
+ const limit = parseLimit(
12932
+ url.searchParams.get("limit"),
12933
+ HUB_CHAT_TURNS_DEFAULT_LIMIT,
12934
+ HUB_CHAT_TURNS_MAX_LIMIT
12935
+ );
12936
+ const readOpts = { limit };
12937
+ if (since !== void 0) readOpts.sinceTurnId = since;
12938
+ const turns = await deps.service.readConciergeMemoryThread(
12939
+ threadMatch.threadId,
12940
+ readOpts
12941
+ );
12942
+ writeJSON2(res, 200, { ok: true, data: { turns } });
12943
+ return true;
12944
+ }
12945
+ if (method === "DELETE") {
12946
+ const removed = await deps.service.deleteConciergeMemoryThread(
12947
+ threadMatch.threadId
12948
+ );
12949
+ writeJSON2(res, removed ? 200 : 404, {
12950
+ ok: removed,
12951
+ data: { thread_id: threadMatch.threadId, removed }
12952
+ });
12953
+ return true;
12954
+ }
12955
+ }
12956
+ }
12853
12957
  writeJSON2(res, 404, { ok: false, error: "not_found", path });
12854
12958
  return true;
12855
12959
  } catch (err) {
@@ -16972,6 +17076,168 @@ var init_dispatch = __esm({
16972
17076
  init_intelligence_api_router();
16973
17077
  }
16974
17078
  });
17079
+
17080
+ // src/principal-policy/approval-aggregator-routes.ts
17081
+ function writeJSON4(res, status, payload) {
17082
+ res.writeHead(status, {
17083
+ "Content-Type": "application/json",
17084
+ "Cache-Control": "no-store"
17085
+ });
17086
+ res.end(JSON.stringify(payload));
17087
+ }
17088
+ function parseLimit2(raw, defaultValue, max) {
17089
+ if (raw === null || raw === "") return defaultValue;
17090
+ const parsed = Number.parseInt(raw, 10);
17091
+ if (Number.isNaN(parsed) || parsed < 0) {
17092
+ return defaultValue;
17093
+ }
17094
+ return Math.min(parsed, max);
17095
+ }
17096
+ function isStatusFilter(value) {
17097
+ return value === "pending" || value === "approved" || value === "denied" || value === "timeout" || value === "expired";
17098
+ }
17099
+ function matchEntryRoute(path) {
17100
+ const prefix = `${APPROVAL_INBOX_API_PREFIX}/`;
17101
+ if (!path.startsWith(prefix)) return null;
17102
+ const rest = path.slice(prefix.length);
17103
+ if (rest.length === 0) return null;
17104
+ const slash = rest.indexOf("/");
17105
+ if (slash === -1) {
17106
+ return { aggregatorId: decodeURIComponent(rest), action: null };
17107
+ }
17108
+ return {
17109
+ aggregatorId: decodeURIComponent(rest.slice(0, slash)),
17110
+ action: rest.slice(slash + 1)
17111
+ };
17112
+ }
17113
+ async function handleStream2(deps, res) {
17114
+ res.writeHead(200, {
17115
+ "Content-Type": "text/event-stream",
17116
+ "Cache-Control": "no-cache, no-transform",
17117
+ Connection: "keep-alive",
17118
+ "X-Accel-Buffering": "no"
17119
+ });
17120
+ const initial = await deps.aggregator.list({ status: "pending" });
17121
+ res.write(
17122
+ `event: approval_inbox_snapshot
17123
+ data: ${JSON.stringify({ entries: initial })}
17124
+
17125
+ `
17126
+ );
17127
+ const unsubscribe = deps.aggregator.onEvent((event) => {
17128
+ try {
17129
+ res.write(
17130
+ `event: approval_inbox_${event.type}
17131
+ data: ${JSON.stringify(event.entry)}
17132
+
17133
+ `
17134
+ );
17135
+ } catch {
17136
+ }
17137
+ });
17138
+ const keepAlive = setInterval(() => {
17139
+ try {
17140
+ res.write(": keepalive\n\n");
17141
+ } catch {
17142
+ }
17143
+ }, 25e3);
17144
+ const cleanup = () => {
17145
+ clearInterval(keepAlive);
17146
+ unsubscribe();
17147
+ };
17148
+ res.on("close", cleanup);
17149
+ res.on("error", cleanup);
17150
+ }
17151
+ async function handleApprovalInboxRoute(deps, req, res) {
17152
+ const host = req.headers.host || "localhost";
17153
+ const url = new URL(req.url ?? "/", `http://${host}`);
17154
+ const method = (req.method ?? "GET").toUpperCase();
17155
+ const path = url.pathname;
17156
+ if (path !== APPROVAL_INBOX_API_PREFIX && !path.startsWith(`${APPROVAL_INBOX_API_PREFIX}/`)) {
17157
+ return false;
17158
+ }
17159
+ const checkAuth = authMiddleware(deps.authConfig);
17160
+ if (!checkAuth(req, res, url)) return true;
17161
+ try {
17162
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/stream`) {
17163
+ await handleStream2(deps, res);
17164
+ return true;
17165
+ }
17166
+ if (method === "GET" && path === APPROVAL_INBOX_API_PREFIX) {
17167
+ const limit = parseLimit2(
17168
+ url.searchParams.get("limit"),
17169
+ APPROVAL_INBOX_DEFAULT_LIMIT,
17170
+ APPROVAL_INBOX_MAX_LIMIT
17171
+ );
17172
+ const statusRaw = url.searchParams.get("status");
17173
+ const status = statusRaw && isStatusFilter(statusRaw) ? statusRaw : "pending";
17174
+ const sinceTs = url.searchParams.get("since") ?? void 0;
17175
+ const entries = await deps.aggregator.list({
17176
+ status,
17177
+ limit,
17178
+ ...sinceTs !== void 0 ? { sinceTs } : {}
17179
+ });
17180
+ writeJSON4(res, 200, { ok: true, data: { entries } });
17181
+ return true;
17182
+ }
17183
+ const entryMatch = matchEntryRoute(path);
17184
+ if (entryMatch === null) {
17185
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
17186
+ return true;
17187
+ }
17188
+ if (method === "GET" && entryMatch.action === null) {
17189
+ const entries = await deps.aggregator.list({ limit: APPROVAL_INBOX_MAX_LIMIT });
17190
+ const entry = entries.find(
17191
+ (e) => e.aggregator_id === entryMatch.aggregatorId
17192
+ );
17193
+ if (!entry) {
17194
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
17195
+ return true;
17196
+ }
17197
+ const payload = await deps.aggregator.getFullPayload(
17198
+ entryMatch.aggregatorId
17199
+ );
17200
+ writeJSON4(res, 200, { ok: true, data: { entry, request_payload: payload } });
17201
+ return true;
17202
+ }
17203
+ if (method === "POST" && (entryMatch.action === "approve" || entryMatch.action === "deny")) {
17204
+ const decision = entryMatch.action === "approve" ? "approved" : "denied";
17205
+ const operatorId = deps.operatorId ?? APPROVAL_INBOX_OPERATOR_DEFAULT;
17206
+ try {
17207
+ const entry = await deps.aggregator.resolve(
17208
+ entryMatch.aggregatorId,
17209
+ decision,
17210
+ operatorId
17211
+ );
17212
+ writeJSON4(res, 200, { ok: true, data: { entry } });
17213
+ } catch (err) {
17214
+ const msg = err instanceof Error ? err.message : String(err);
17215
+ if (msg === "approval-aggregator: not_found") {
17216
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
17217
+ } else {
17218
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
17219
+ }
17220
+ }
17221
+ return true;
17222
+ }
17223
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
17224
+ return true;
17225
+ } catch (err) {
17226
+ const msg = err instanceof Error ? err.message : String(err);
17227
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
17228
+ return true;
17229
+ }
17230
+ }
17231
+ var APPROVAL_INBOX_API_PREFIX, APPROVAL_INBOX_OPERATOR_DEFAULT, APPROVAL_INBOX_DEFAULT_LIMIT, APPROVAL_INBOX_MAX_LIMIT;
17232
+ var init_approval_aggregator_routes = __esm({
17233
+ "src/principal-policy/approval-aggregator-routes.ts"() {
17234
+ init_auth_middleware();
17235
+ APPROVAL_INBOX_API_PREFIX = "/api/approval-inbox";
17236
+ APPROVAL_INBOX_OPERATOR_DEFAULT = "operator_dashboard";
17237
+ APPROVAL_INBOX_DEFAULT_LIMIT = 50;
17238
+ APPROVAL_INBOX_MAX_LIMIT = 200;
17239
+ }
17240
+ });
16975
17241
  function isDashboardViewRoute(method, path) {
16976
17242
  if (method !== "GET") return false;
16977
17243
  return path === "/" || path === "/dashboard" || path === "/v1.0" || path === "/fortress" || path === "/events";
@@ -16985,6 +17251,7 @@ var init_dashboard = __esm({
16985
17251
  init_fortress_view();
16986
17252
  init_system_prompt_generator();
16987
17253
  init_dispatch();
17254
+ init_approval_aggregator_routes();
16988
17255
  SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
16989
17256
  SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
16990
17257
  MAX_SESSIONS = 1e3;
@@ -17044,6 +17311,14 @@ var init_dashboard = __esm({
17044
17311
  * regardless. Default route flip is deferred to v1.2.
17045
17312
  */
17046
17313
  v11Bindings = null;
17314
+ /**
17315
+ * v1.3 WP-V1.3-10 Cross-Harness Approval Inbox aggregator. Mounted
17316
+ * additively at `/api/approval-inbox/*` when set. Legacy approval
17317
+ * routes at `/api/approvals/:id/(allow|deny)` continue to serve. The
17318
+ * aggregator is a passive subscriber to the gate; the routes here are
17319
+ * the operator-facing query / decision surface.
17320
+ */
17321
+ approvalAggregator = null;
17047
17322
  constructor(config) {
17048
17323
  this.config = config;
17049
17324
  this.authToken = config.auth_token;
@@ -17094,6 +17369,34 @@ var init_dashboard = __esm({
17094
17369
  setV11Bindings(bindings) {
17095
17370
  this.v11Bindings = bindings;
17096
17371
  }
17372
+ /**
17373
+ * v1.3 WP-V1.3-10 Upsilon-1: bind the cross-harness approval inbox
17374
+ * aggregator. Once set, requests to `/api/approval-inbox/*` route
17375
+ * through `handleApprovalInboxRoute`. Pass `null` to detach (used by
17376
+ * tests + during shutdown).
17377
+ */
17378
+ setApprovalAggregator(aggregator) {
17379
+ this.approvalAggregator = aggregator;
17380
+ }
17381
+ /**
17382
+ * v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
17383
+ * before the legacy approval route table. Returns true when served.
17384
+ */
17385
+ async dispatchApprovalInbox(req, res) {
17386
+ if (!this.approvalAggregator) return false;
17387
+ return handleApprovalInboxRoute(
17388
+ {
17389
+ authConfig: {
17390
+ loopbackAutoAuth: this._autoAuthLocalhost,
17391
+ ...this.authToken !== void 0 ? { authToken: this.authToken } : {}
17392
+ },
17393
+ aggregator: this.approvalAggregator,
17394
+ operatorId: this.identityManager?.getPrimaryIdentityId() ?? void 0
17395
+ },
17396
+ req,
17397
+ res
17398
+ );
17399
+ }
17097
17400
  /**
17098
17401
  * v1.1 dispatch entry point. Called from `handleRequest` before the
17099
17402
  * legacy route table. Returns true when the request was served by v1.1
@@ -17469,6 +17772,18 @@ var init_dashboard = __esm({
17469
17772
  res.end();
17470
17773
  return;
17471
17774
  }
17775
+ if (this.approvalAggregator && url.pathname.startsWith(APPROVAL_INBOX_API_PREFIX)) {
17776
+ this.dispatchApprovalInbox(req, res).then((handled) => {
17777
+ if (handled) return;
17778
+ this.handleLegacyRequest(req, res, url, method);
17779
+ }).catch(() => {
17780
+ if (!res.headersSent) {
17781
+ res.writeHead(500, { "Content-Type": "application/json" });
17782
+ res.end(JSON.stringify({ error: "Internal server error" }));
17783
+ }
17784
+ });
17785
+ return;
17786
+ }
17472
17787
  if (this.v11Bindings) {
17473
17788
  this.dispatchV11(req, res, url, method).then((handled) => {
17474
17789
  if (handled) return;
@@ -19512,14 +19827,25 @@ var init_gate = __esm({
19512
19827
  auditLog;
19513
19828
  injectionDetector;
19514
19829
  onInjectionAlert;
19830
+ onApprovalEvent;
19515
19831
  proxyTierResolver;
19516
- constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert) {
19832
+ constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert, onApprovalEvent) {
19517
19833
  this.policy = policy;
19518
19834
  this.baseline = baseline;
19519
19835
  this.channel = channel;
19520
19836
  this.auditLog = auditLog;
19521
19837
  this.injectionDetector = injectionDetector ?? new InjectionDetector();
19522
19838
  this.onInjectionAlert = onInjectionAlert;
19839
+ this.onApprovalEvent = onApprovalEvent;
19840
+ }
19841
+ /**
19842
+ * Set the approval-event callback after construction. Used by the
19843
+ * Upsilon-1 wire-up when the aggregator is constructed alongside the
19844
+ * gate. The aggregator subscribes through this setter rather than the
19845
+ * constructor so existing call sites continue to work unchanged.
19846
+ */
19847
+ setApprovalEventCallback(cb) {
19848
+ this.onApprovalEvent = cb;
19523
19849
  }
19524
19850
  /**
19525
19851
  * Set the proxy tier resolver. Called after the proxy router is initialized.
@@ -19753,21 +20079,105 @@ var init_gate = __esm({
19753
20079
  }
19754
20080
  /**
19755
20081
  * Request approval from the human principal.
20082
+ *
20083
+ * Fail-closed contract (full-sweep #49): if the channel throws (network
20084
+ * down, callback unreachable, dashboard SSE peer dropped, webhook DNS
20085
+ * failure, etc.), the gate denies the operation and audit-logs the cause.
20086
+ * Channel-internal timeouts already resolve with decision: "deny" per
20087
+ * SEC-002; this catch covers the remaining "channel raised" path so an
20088
+ * unhandled rejection cannot turn into an indeterminate state at the gate.
19756
20089
  */
19757
20090
  async requestApproval(operation, tier, reason, context) {
20091
+ const requestTimestamp = (/* @__PURE__ */ new Date()).toISOString();
19758
20092
  const request = {
19759
20093
  operation,
19760
20094
  tier,
19761
20095
  reason,
19762
20096
  context,
19763
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
20097
+ timestamp: requestTimestamp
19764
20098
  };
19765
- const response = await this.channel.requestApproval(request);
20099
+ const correlationId = `${requestTimestamp}:${operation}:${Math.random().toString(16).slice(2, 6)}`;
20100
+ if (this.onApprovalEvent) {
20101
+ try {
20102
+ this.onApprovalEvent({
20103
+ phase: "requested",
20104
+ operation,
20105
+ tier,
20106
+ reason,
20107
+ context,
20108
+ request_timestamp: requestTimestamp,
20109
+ correlation_id: correlationId
20110
+ });
20111
+ } catch {
20112
+ }
20113
+ }
20114
+ let response;
20115
+ try {
20116
+ response = await this.channel.requestApproval(request);
20117
+ } catch (err) {
20118
+ const errMessage = err instanceof Error ? err.message : String(err);
20119
+ const decidedAt = (/* @__PURE__ */ new Date()).toISOString();
20120
+ this.auditLog.append("l2", `gate_deny:${operation}`, "system", {
20121
+ tier,
20122
+ reason,
20123
+ decided_by: "channel_failure",
20124
+ channel_error: errMessage
20125
+ });
20126
+ if (this.onApprovalEvent) {
20127
+ try {
20128
+ this.onApprovalEvent({
20129
+ phase: "resolved",
20130
+ operation,
20131
+ tier,
20132
+ reason,
20133
+ context,
20134
+ request_timestamp: requestTimestamp,
20135
+ resolution: {
20136
+ decision: "deny",
20137
+ decided_at: decidedAt,
20138
+ decided_by: "channel_failure"
20139
+ },
20140
+ correlation_id: correlationId
20141
+ });
20142
+ } catch {
20143
+ }
20144
+ }
20145
+ return {
20146
+ allowed: false,
20147
+ tier,
20148
+ reason: AGENT_VISIBLE_DENY_REASONS.REQUIRES_APPROVAL,
20149
+ approval_required: true,
20150
+ approval_response: {
20151
+ decision: "deny",
20152
+ decided_at: decidedAt,
20153
+ decided_by: "channel_failure"
20154
+ }
20155
+ };
20156
+ }
19766
20157
  this.auditLog.append("l2", `gate_${response.decision}:${operation}`, "system", {
19767
20158
  tier,
19768
20159
  reason,
19769
20160
  decided_by: response.decided_by
19770
20161
  });
20162
+ if (this.onApprovalEvent) {
20163
+ try {
20164
+ this.onApprovalEvent({
20165
+ phase: "resolved",
20166
+ operation,
20167
+ tier,
20168
+ reason,
20169
+ context,
20170
+ request_timestamp: requestTimestamp,
20171
+ resolution: {
20172
+ decision: response.decision,
20173
+ decided_at: response.decided_at,
20174
+ decided_by: response.decided_by
20175
+ },
20176
+ correlation_id: correlationId
20177
+ });
20178
+ } catch {
20179
+ }
20180
+ }
19771
20181
  return {
19772
20182
  allowed: response.decision === "approve",
19773
20183
  tier,
@@ -19802,6 +20212,356 @@ var init_gate = __esm({
19802
20212
  };
19803
20213
  }
19804
20214
  });
20215
+ 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;
20216
+ var init_approval_aggregator = __esm({
20217
+ "src/principal-policy/approval-aggregator.ts"() {
20218
+ init_encryption();
20219
+ init_key_derivation();
20220
+ init_encoding();
20221
+ APPROVAL_AGGREGATOR_NAMESPACE = "_approval_aggregator";
20222
+ APPROVAL_AGGREGATOR_HKDF_INFO = "l2-approval-aggregator-v1";
20223
+ APPROVAL_AGGREGATOR_AUDIT_OPS = {
20224
+ AGGREGATED: "cross_harness_approval_aggregated",
20225
+ RESOLVED: "cross_harness_approval_resolved",
20226
+ DEDUPED: "cross_harness_approval_deduped"
20227
+ };
20228
+ DEFAULT_PENDING_TTL_MS = 5 * 60 * 1e3;
20229
+ DEFAULT_MAX_LIST_LIMIT = 200;
20230
+ DEFAULT_LIST_PAGE_SIZE = 50;
20231
+ ApprovalAggregator = class {
20232
+ storage;
20233
+ encryptionKey;
20234
+ auditLog;
20235
+ identityId;
20236
+ fortressId;
20237
+ pendingTtlMs;
20238
+ maxListLimit;
20239
+ now;
20240
+ resolveSourceContext;
20241
+ resolveHubInboxItemId;
20242
+ /** Cached entries by `aggregator_id`. */
20243
+ entries = /* @__PURE__ */ new Map();
20244
+ /** Dedup index: `${harness}|${agent}|${audit_id}` -> aggregator_id. */
20245
+ dedupIndex = /* @__PURE__ */ new Map();
20246
+ /** Correlation index: gate `correlation_id` -> aggregator_id. */
20247
+ correlationIndex = /* @__PURE__ */ new Map();
20248
+ /** Original request payloads kept in-memory for `getFullPayload()`. */
20249
+ fullPayloads = /* @__PURE__ */ new Map();
20250
+ /** Has the aggregator hydrated persisted entries on this process? */
20251
+ hydrated = false;
20252
+ /** Active SSE listeners. */
20253
+ listeners = /* @__PURE__ */ new Set();
20254
+ constructor(deps) {
20255
+ this.storage = deps.storage;
20256
+ this.encryptionKey = derivePurposeKey(
20257
+ deps.masterKey,
20258
+ APPROVAL_AGGREGATOR_HKDF_INFO
20259
+ );
20260
+ this.auditLog = deps.auditLog;
20261
+ this.identityId = deps.identityId;
20262
+ this.fortressId = deps.fortressId;
20263
+ this.pendingTtlMs = deps.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
20264
+ this.maxListLimit = deps.maxListLimit ?? DEFAULT_MAX_LIST_LIMIT;
20265
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
20266
+ this.resolveSourceContext = deps.resolveSourceContext ?? ((_event) => ({
20267
+ source_harness: this.fortressId,
20268
+ source_agent_id: this.fortressId
20269
+ }));
20270
+ this.resolveHubInboxItemId = deps.resolveHubInboxItemId ?? ((_event) => void 0);
20271
+ }
20272
+ /**
20273
+ * Subscribe an event listener. Returns an unsubscribe fn. SSE handlers
20274
+ * use this to forward aggregator emissions to the dashboard.
20275
+ */
20276
+ onEvent(listener) {
20277
+ this.listeners.add(listener);
20278
+ return () => this.listeners.delete(listener);
20279
+ }
20280
+ /**
20281
+ * Ingest a gate event. Returns the aggregator entry on first sight,
20282
+ * `null` when deduped. Resolution events update the existing record;
20283
+ * unmatched resolutions are dropped silently (caller's gate emitted a
20284
+ * resolved-without-requested pair, which the aggregator does not invent
20285
+ * a record for).
20286
+ */
20287
+ async ingest(event) {
20288
+ await this.hydrate();
20289
+ if (event.phase === "requested") {
20290
+ return this.ingestRequested(event);
20291
+ }
20292
+ if (event.phase === "resolved") {
20293
+ return this.ingestResolved(event);
20294
+ }
20295
+ return null;
20296
+ }
20297
+ /**
20298
+ * List pending or recently resolved entries. Pending entries past TTL
20299
+ * are lazily transitioned to `expired` and persisted before the list
20300
+ * snapshot is returned.
20301
+ */
20302
+ async list(opts) {
20303
+ await this.hydrate();
20304
+ await this.expireStale();
20305
+ const limit = Math.min(
20306
+ opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
20307
+ this.maxListLimit
20308
+ );
20309
+ const sinceMs = opts?.sinceTs ? Date.parse(opts.sinceTs) : Number.NEGATIVE_INFINITY;
20310
+ const matching = [];
20311
+ for (const entry of this.entries.values()) {
20312
+ if (opts?.status && entry.status !== opts.status) continue;
20313
+ if (Date.parse(entry.created_at) < sinceMs) continue;
20314
+ matching.push(entry);
20315
+ }
20316
+ matching.sort((a, b) => b.created_at.localeCompare(a.created_at));
20317
+ return matching.slice(0, limit);
20318
+ }
20319
+ /**
20320
+ * Return the original (unhashed) request payload for the entry. Returns
20321
+ * `null` when the entry is unknown or the payload was evicted (e.g. the
20322
+ * process restarted; payloads are in-memory only at v1.3 Upsilon-1).
20323
+ */
20324
+ async getFullPayload(aggregatorId) {
20325
+ await this.hydrate();
20326
+ if (!this.entries.has(aggregatorId)) return null;
20327
+ return this.fullPayloads.get(aggregatorId) ?? null;
20328
+ }
20329
+ /**
20330
+ * Resolve an entry. Used by both:
20331
+ * 1. The gate wire-up on channel-decision return.
20332
+ * 2. The HTTP `approve`/`deny` routes when an operator clicks.
20333
+ *
20334
+ * Idempotent: resolving an already-resolved entry is a no-op (the record
20335
+ * keeps its first decision and the audit log is not double-fired).
20336
+ * Unknown ids throw `Error("approval-aggregator: not_found")` so HTTP
20337
+ * routes return 404.
20338
+ */
20339
+ async resolve(aggregatorId, decision, operatorId) {
20340
+ await this.hydrate();
20341
+ const entry = this.entries.get(aggregatorId);
20342
+ if (!entry) {
20343
+ throw new Error("approval-aggregator: not_found");
20344
+ }
20345
+ if (entry.status !== "pending") {
20346
+ return entry;
20347
+ }
20348
+ entry.status = decision;
20349
+ entry.resolved_at = this.now().toISOString();
20350
+ entry.resolved_by = operatorId;
20351
+ await this.persist(entry);
20352
+ this.auditLog.append(
20353
+ "l2",
20354
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
20355
+ this.identityId,
20356
+ {
20357
+ aggregator_id: entry.aggregator_id,
20358
+ source_harness: entry.source_harness,
20359
+ source_agent_id: entry.source_agent_id,
20360
+ audit_log_entry_id: entry.audit_log_entry_id,
20361
+ policy_rule_id: entry.policy_rule_id,
20362
+ decision,
20363
+ decided_by: operatorId,
20364
+ decided_at: entry.resolved_at
20365
+ }
20366
+ );
20367
+ this.emit({ type: "resolved", entry: { ...entry } });
20368
+ return entry;
20369
+ }
20370
+ // ── Internal: ingest paths ─────────────────────────────────────────────
20371
+ async ingestRequested(event) {
20372
+ const ctx = this.resolveSourceContext(event);
20373
+ const auditId = this.auditEntryIdForEvent(event);
20374
+ const dedupKey = `${ctx.source_harness}|${ctx.source_agent_id}|${auditId}`;
20375
+ const existing = this.dedupIndex.get(dedupKey);
20376
+ if (existing) {
20377
+ const existingEntry = this.entries.get(existing);
20378
+ if (existingEntry) {
20379
+ this.correlationIndex.set(event.correlation_id, existing);
20380
+ this.auditLog.append(
20381
+ "l2",
20382
+ APPROVAL_AGGREGATOR_AUDIT_OPS.DEDUPED,
20383
+ this.identityId,
20384
+ {
20385
+ aggregator_id: existing,
20386
+ source_harness: ctx.source_harness,
20387
+ source_agent_id: ctx.source_agent_id,
20388
+ audit_log_entry_id: auditId,
20389
+ policy_rule_id: this.derivePolicyRuleId(event),
20390
+ correlation_id: event.correlation_id
20391
+ }
20392
+ );
20393
+ this.emit({ type: "deduped", entry: { ...existingEntry } });
20394
+ return null;
20395
+ }
20396
+ }
20397
+ const id = randomUUID();
20398
+ const now = this.now();
20399
+ const expires = new Date(now.getTime() + this.pendingTtlMs);
20400
+ const hubInboxId = this.resolveHubInboxItemId(event);
20401
+ const entry = {
20402
+ aggregator_id: id,
20403
+ source_harness: ctx.source_harness,
20404
+ source_agent_id: ctx.source_agent_id,
20405
+ audit_log_entry_id: auditId,
20406
+ policy_rule_id: this.derivePolicyRuleId(event),
20407
+ action_summary: this.deriveActionSummary(event),
20408
+ request_payload_hash: this.hashPayload(event.context),
20409
+ status: "pending",
20410
+ created_at: now.toISOString(),
20411
+ expires_at: expires.toISOString(),
20412
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
20413
+ };
20414
+ this.entries.set(id, entry);
20415
+ this.dedupIndex.set(dedupKey, id);
20416
+ this.correlationIndex.set(event.correlation_id, id);
20417
+ this.fullPayloads.set(id, event.context);
20418
+ await this.persist(entry);
20419
+ this.auditLog.append(
20420
+ "l2",
20421
+ APPROVAL_AGGREGATOR_AUDIT_OPS.AGGREGATED,
20422
+ this.identityId,
20423
+ {
20424
+ aggregator_id: id,
20425
+ source_harness: ctx.source_harness,
20426
+ source_agent_id: ctx.source_agent_id,
20427
+ audit_log_entry_id: auditId,
20428
+ policy_rule_id: entry.policy_rule_id,
20429
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
20430
+ }
20431
+ );
20432
+ this.emit({ type: "aggregated", entry: { ...entry } });
20433
+ return entry;
20434
+ }
20435
+ async ingestResolved(event) {
20436
+ const id = this.correlationIndex.get(event.correlation_id);
20437
+ if (!id) return null;
20438
+ const entry = this.entries.get(id);
20439
+ if (!entry) return null;
20440
+ if (entry.status !== "pending") return entry;
20441
+ if (!event.resolution) return entry;
20442
+ const failClosed = event.resolution.decision === "deny" && event.resolution.decided_by === "channel_failure";
20443
+ const status = failClosed ? "timeout" : event.resolution.decision === "approve" ? "approved" : "denied";
20444
+ entry.status = status;
20445
+ entry.resolved_at = event.resolution.decided_at;
20446
+ entry.resolved_by = event.resolution.decided_by;
20447
+ await this.persist(entry);
20448
+ this.auditLog.append(
20449
+ "l2",
20450
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
20451
+ this.identityId,
20452
+ {
20453
+ aggregator_id: id,
20454
+ source_harness: entry.source_harness,
20455
+ source_agent_id: entry.source_agent_id,
20456
+ audit_log_entry_id: entry.audit_log_entry_id,
20457
+ policy_rule_id: entry.policy_rule_id,
20458
+ decision: status,
20459
+ decided_by: entry.resolved_by,
20460
+ decided_at: entry.resolved_at,
20461
+ fail_closed: failClosed
20462
+ }
20463
+ );
20464
+ this.emit({ type: "resolved", entry: { ...entry } });
20465
+ return entry;
20466
+ }
20467
+ // ── Internal: helpers ──────────────────────────────────────────────────
20468
+ /**
20469
+ * Audit-log entry id for the dedup tuple. The audit log itself does not
20470
+ * surface a stable per-entry id (counter-prefixed keys are internal); the
20471
+ * aggregator uses the request timestamp + operation, which together pin
20472
+ * the audit entry the gate appended on the same call.
20473
+ */
20474
+ auditEntryIdForEvent(event) {
20475
+ return `${event.request_timestamp}:${event.operation}`;
20476
+ }
20477
+ derivePolicyRuleId(event) {
20478
+ return `tier${event.tier}:${event.operation}`;
20479
+ }
20480
+ deriveActionSummary(event) {
20481
+ return `${event.operation} (tier ${event.tier})`;
20482
+ }
20483
+ /**
20484
+ * Canonical SHA-256 of the request context. Sorted-keys serialization so
20485
+ * identical payloads always hash the same, even when key insertion order
20486
+ * varies. Defends against payload-replay smuggling (the aggregator can
20487
+ * tell the same payload was seen twice without storing it cleartext).
20488
+ */
20489
+ hashPayload(payload) {
20490
+ const canonical = JSON.stringify(payload, Object.keys(payload).sort());
20491
+ return createHash("sha256").update(canonical).digest("hex");
20492
+ }
20493
+ emit(event) {
20494
+ for (const listener of this.listeners) {
20495
+ try {
20496
+ listener(event);
20497
+ } catch {
20498
+ }
20499
+ }
20500
+ }
20501
+ async expireStale() {
20502
+ const nowMs = this.now().getTime();
20503
+ for (const entry of this.entries.values()) {
20504
+ if (entry.status !== "pending") continue;
20505
+ if (Date.parse(entry.expires_at) > nowMs) continue;
20506
+ entry.status = "expired";
20507
+ entry.resolved_at = this.now().toISOString();
20508
+ entry.resolved_by = "system_ttl";
20509
+ await this.persist(entry);
20510
+ this.auditLog.append(
20511
+ "l2",
20512
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
20513
+ this.identityId,
20514
+ {
20515
+ aggregator_id: entry.aggregator_id,
20516
+ source_harness: entry.source_harness,
20517
+ source_agent_id: entry.source_agent_id,
20518
+ audit_log_entry_id: entry.audit_log_entry_id,
20519
+ policy_rule_id: entry.policy_rule_id,
20520
+ decision: "expired",
20521
+ decided_by: "system_ttl",
20522
+ decided_at: entry.resolved_at
20523
+ }
20524
+ );
20525
+ this.emit({ type: "resolved", entry: { ...entry } });
20526
+ }
20527
+ }
20528
+ async persist(entry) {
20529
+ const serialized = stringToBytes(JSON.stringify(entry));
20530
+ const encrypted = encrypt(serialized, this.encryptionKey);
20531
+ await this.storage.write(
20532
+ APPROVAL_AGGREGATOR_NAMESPACE,
20533
+ entry.aggregator_id,
20534
+ stringToBytes(JSON.stringify(encrypted))
20535
+ );
20536
+ }
20537
+ async hydrate() {
20538
+ if (this.hydrated) return;
20539
+ this.hydrated = true;
20540
+ try {
20541
+ const metas = await this.storage.list(APPROVAL_AGGREGATOR_NAMESPACE);
20542
+ for (const meta of metas) {
20543
+ const raw = await this.storage.read(
20544
+ APPROVAL_AGGREGATOR_NAMESPACE,
20545
+ meta.key
20546
+ );
20547
+ if (!raw) continue;
20548
+ try {
20549
+ const encrypted = JSON.parse(bytesToString(raw));
20550
+ const decrypted = decrypt(encrypted, this.encryptionKey);
20551
+ const entry = JSON.parse(bytesToString(decrypted));
20552
+ this.entries.set(entry.aggregator_id, entry);
20553
+ const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
20554
+ this.dedupIndex.set(dedupKey, entry.aggregator_id);
20555
+ } catch {
20556
+ }
20557
+ }
20558
+ } catch {
20559
+ this.hydrated = false;
20560
+ }
20561
+ }
20562
+ };
20563
+ }
20564
+ });
19805
20565
 
19806
20566
  // src/principal-policy/tools.ts
19807
20567
  function createPrincipalPolicyTools(policy, baseline, auditLog) {
@@ -22719,6 +23479,12 @@ function typed(markerPath, lineNumber, field, expected) {
22719
23479
  }
22720
23480
  async function consumeResetHistoryMarker(options) {
22721
23481
  const markerPath = join(options.storagePath, RESET_HISTORY_FILENAME);
23482
+ const consumedPath = markerPath + ".consumed";
23483
+ if (await fileExists3(consumedPath)) {
23484
+ await rm(markerPath, { force: true });
23485
+ await rm(consumedPath, { force: true });
23486
+ return { emitted: 0, markerPath };
23487
+ }
22722
23488
  if (!await fileExists3(markerPath)) {
22723
23489
  return { emitted: 0, markerPath };
22724
23490
  }
@@ -22743,7 +23509,9 @@ async function consumeResetHistoryMarker(options) {
22743
23509
  });
22744
23510
  }
22745
23511
  await options.auditLog.flush();
23512
+ await writeFile(consumedPath, "", "utf-8");
22746
23513
  await rm(markerPath, { force: true });
23514
+ await rm(consumedPath, { force: true });
22747
23515
  return { emitted: markers.length, markerHash, markerPath };
22748
23516
  }
22749
23517
  async function fileExists3(path) {
@@ -32026,6 +32794,36 @@ var init_hub_service = __esm({
32026
32794
  const chat = this.requireOperatorChat();
32027
32795
  return chat.getConciergeHistory();
32028
32796
  }
32797
+ // ── Concierge memory threads (WP-V1.3-9 Tau-1) ─────────────────────
32798
+ /**
32799
+ * Whether the operator-chat service has the WP-V1.3-9 memory store
32800
+ * wired. Routes use this to 503 cleanly when the foundation memory
32801
+ * surface is unavailable on a given fortress.
32802
+ */
32803
+ hasConciergeMemory() {
32804
+ return Boolean(this.deps.operatorChat?.hasConciergeMemory());
32805
+ }
32806
+ async listConciergeMemoryThreads(opts) {
32807
+ const chat = this.requireOperatorChat();
32808
+ if (!chat.hasConciergeMemory()) {
32809
+ throw new HubCapabilityError("concierge_memory_not_wired");
32810
+ }
32811
+ return chat.listConciergeMemoryThreads(opts);
32812
+ }
32813
+ async readConciergeMemoryThread(threadId, opts) {
32814
+ const chat = this.requireOperatorChat();
32815
+ if (!chat.hasConciergeMemory()) {
32816
+ throw new HubCapabilityError("concierge_memory_not_wired");
32817
+ }
32818
+ return chat.readConciergeMemoryThread(threadId, opts);
32819
+ }
32820
+ async deleteConciergeMemoryThread(threadId) {
32821
+ const chat = this.requireOperatorChat();
32822
+ if (!chat.hasConciergeMemory()) {
32823
+ throw new HubCapabilityError("concierge_memory_not_wired");
32824
+ }
32825
+ return chat.deleteConciergeMemoryThread(threadId);
32826
+ }
32029
32827
  /**
32030
32828
  * Open the click-to-inspect/approve panel for a wrapped agent. The
32031
32829
  * panel surfaces recent activity routed through this agent, pending
@@ -32164,7 +32962,20 @@ var init_operator_chat_audit_events = __esm({
32164
32962
  * affordance now opens an inspect/approve panel (recent activity +
32165
32963
  * pending approvals + policy summary) instead of a chat session.
32166
32964
  */
32167
- AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened"
32965
+ AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened",
32966
+ /**
32967
+ * Operator viewed concierge thread history (WP-V1.3-9 Tau-1). Emitted
32968
+ * when the operator hits the list-threads or read-thread route. Body
32969
+ * carries the thread_id (or `*` for the list endpoint) and a count;
32970
+ * raw turn content never crosses the audit surface.
32971
+ */
32972
+ CONCIERGE_HISTORY_READ: "operator_concierge_history_read",
32973
+ /**
32974
+ * Operator deleted a concierge thread (WP-V1.3-9 Tau-1). Emitted on
32975
+ * successful thread removal. Body carries thread_id + turn_count of
32976
+ * the deleted bundle.
32977
+ */
32978
+ CONCIERGE_THREAD_DELETED: "operator_concierge_thread_deleted"
32168
32979
  };
32169
32980
  }
32170
32981
  });
@@ -32226,6 +33037,14 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32226
33037
  contextProviders;
32227
33038
  piiFilter;
32228
33039
  conciergeMaxTokens;
33040
+ memory;
33041
+ /**
33042
+ * In-memory thread_id assigned to the active concierge session.
33043
+ * The first sendConcierge call after construction allocates a fresh
33044
+ * UUID; subsequent calls reuse it so multi-turn coherence (Tau-2)
33045
+ * folds the prior turns into context. Cleared by `resetConciergeMemoryThread`.
33046
+ */
33047
+ activeMemoryThreadId;
32229
33048
  constructor(deps) {
32230
33049
  this.store = deps.store;
32231
33050
  this.auditLog = deps.auditLog;
@@ -32236,6 +33055,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32236
33055
  }
32237
33056
  if (deps.conciergePiiFilter) this.piiFilter = deps.conciergePiiFilter;
32238
33057
  this.conciergeMaxTokens = deps.conciergeMaxTokens ?? DEFAULT_CONCIERGE_MAX_TOKENS;
33058
+ if (deps.conciergeMemory) this.memory = deps.conciergeMemory;
32239
33059
  }
32240
33060
  // ── Concierge ─────────────────────────────────────────────────────────
32241
33061
  /**
@@ -32266,6 +33086,11 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32266
33086
  CONCIERGE_THREAD_KEY,
32267
33087
  operatorMessage
32268
33088
  );
33089
+ if (this.memory) {
33090
+ const threadId = this.ensureActiveMemoryThread();
33091
+ await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
33092
+ });
33093
+ }
32269
33094
  const start = Date.now();
32270
33095
  let conciergeBody;
32271
33096
  let servedBy = "disabled";
@@ -32321,6 +33146,11 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32321
33146
  CONCIERGE_THREAD_KEY,
32322
33147
  responseMessage
32323
33148
  );
33149
+ if (this.memory) {
33150
+ const threadId = this.ensureActiveMemoryThread();
33151
+ await this.memory.appendTurn(threadId, "assistant", conciergeBody).catch(() => {
33152
+ });
33153
+ }
32324
33154
  const payload = {
32325
33155
  version: "1.2",
32326
33156
  event_id: makeEventId("conc"),
@@ -32353,6 +33183,105 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32353
33183
  );
32354
33184
  return thread ? thread.messages : [];
32355
33185
  }
33186
+ // ── WP-V1.3-9 Tau-1 memory accessors ─────────────────────────────────
33187
+ /**
33188
+ * Whether the foundation memory store is wired. Routes use this to
33189
+ * 503 cleanly when called against an unwired service.
33190
+ */
33191
+ hasConciergeMemory() {
33192
+ return this.memory !== void 0;
33193
+ }
33194
+ /**
33195
+ * List concierge memory threads, newest-first. Emits the
33196
+ * `operator_concierge_history_read` audit event with `thread_id="*"`.
33197
+ */
33198
+ async listConciergeMemoryThreads(opts) {
33199
+ if (!this.memory) {
33200
+ throw new Error("concierge memory store not configured");
33201
+ }
33202
+ const summaries = await this.memory.listThreads(opts);
33203
+ const totalTurns = summaries.reduce((acc, s) => acc + s.turn_count, 0);
33204
+ const payload = {
33205
+ version: "1.2",
33206
+ event_id: makeEventId("conc-hist"),
33207
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33208
+ identity_id: this.identityId,
33209
+ kind: "operator_concierge_history_read",
33210
+ surface: "concierge",
33211
+ thread_id: "*",
33212
+ turn_count: totalTurns
33213
+ };
33214
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
33215
+ return summaries;
33216
+ }
33217
+ /**
33218
+ * Read a concierge memory thread, oldest turn first. Emits the
33219
+ * `operator_concierge_history_read` audit event with the named
33220
+ * thread_id and the count of turns surfaced.
33221
+ */
33222
+ async readConciergeMemoryThread(threadId, opts) {
33223
+ if (!this.memory) {
33224
+ throw new Error("concierge memory store not configured");
33225
+ }
33226
+ const turns = await this.memory.readThread(threadId, opts);
33227
+ const payload = {
33228
+ version: "1.2",
33229
+ event_id: makeEventId("conc-hist"),
33230
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33231
+ identity_id: this.identityId,
33232
+ kind: "operator_concierge_history_read",
33233
+ surface: "concierge",
33234
+ thread_id: threadId,
33235
+ turn_count: turns.length
33236
+ };
33237
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
33238
+ return turns;
33239
+ }
33240
+ /**
33241
+ * Delete a concierge memory thread. Emits
33242
+ * `operator_concierge_thread_deleted` only when a bundle was actually
33243
+ * removed; absent threads return false without an audit event.
33244
+ */
33245
+ async deleteConciergeMemoryThread(threadId) {
33246
+ if (!this.memory) {
33247
+ throw new Error("concierge memory store not configured");
33248
+ }
33249
+ const turnsBefore = await this.memory.readThread(threadId);
33250
+ if (turnsBefore.length === 0) {
33251
+ return await this.memory.deleteThread(threadId);
33252
+ }
33253
+ const removed = await this.memory.deleteThread(threadId);
33254
+ if (!removed) return false;
33255
+ if (this.activeMemoryThreadId === threadId) {
33256
+ this.activeMemoryThreadId = void 0;
33257
+ }
33258
+ const payload = {
33259
+ version: "1.2",
33260
+ event_id: makeEventId("conc-del"),
33261
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33262
+ identity_id: this.identityId,
33263
+ kind: "operator_concierge_thread_deleted",
33264
+ surface: "concierge",
33265
+ thread_id: threadId,
33266
+ turn_count: turnsBefore.length
33267
+ };
33268
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_THREAD_DELETED, payload, "success");
33269
+ return true;
33270
+ }
33271
+ /**
33272
+ * Reset the active session memory thread. Subsequent sendConcierge
33273
+ * calls allocate a fresh thread_id. Surfaced for tests + future "new
33274
+ * conversation" affordance; not currently called by the dashboard.
33275
+ */
33276
+ resetConciergeMemoryThread() {
33277
+ this.activeMemoryThreadId = void 0;
33278
+ }
33279
+ ensureActiveMemoryThread() {
33280
+ if (!this.activeMemoryThreadId) {
33281
+ this.activeMemoryThreadId = randomUUID();
33282
+ }
33283
+ return this.activeMemoryThreadId;
33284
+ }
32356
33285
  /**
32357
33286
  * Stitch fortress state into a single context blob the substrate
32358
33287
  * folds into its summarization prompt.
@@ -32519,11 +33448,250 @@ var init_operator_chat_store = __esm({
32519
33448
  }
32520
33449
  });
32521
33450
 
33451
+ // src/chat/concierge-memory-store.ts
33452
+ function bundleKey(threadId) {
33453
+ return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
33454
+ }
33455
+ function stripKeyPrefix(key) {
33456
+ if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
33457
+ return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
33458
+ }
33459
+ function lastTurnId(bundle) {
33460
+ let max = 0;
33461
+ for (const t of bundle.turns) {
33462
+ if (t.turn_id > max) max = t.turn_id;
33463
+ }
33464
+ return max;
33465
+ }
33466
+ var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO2, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES2, ConciergeMemoryStore;
33467
+ var init_concierge_memory_store = __esm({
33468
+ "src/chat/concierge-memory-store.ts"() {
33469
+ init_encryption();
33470
+ init_key_derivation();
33471
+ init_encoding();
33472
+ CONCIERGE_MEMORY_NAMESPACE = "_chat";
33473
+ CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
33474
+ HKDF_INFO2 = "concierge-memory-store-v1";
33475
+ DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
33476
+ MAX_BUNDLE_BYTES2 = 4 * 1024 * 1024;
33477
+ ConciergeMemoryStore = class {
33478
+ storage;
33479
+ encryptionKey;
33480
+ fortressId;
33481
+ retentionDays;
33482
+ locks;
33483
+ constructor(opts) {
33484
+ this.storage = opts.storage;
33485
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
33486
+ this.fortressId = opts.fortressId;
33487
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
33488
+ this.locks = /* @__PURE__ */ new Map();
33489
+ }
33490
+ /**
33491
+ * Append a turn to the named thread, creating the bundle if no record
33492
+ * exists. Returns the persisted turn (with assigned turn_id +
33493
+ * retention_until). Per-thread serialisation guarantees turn_id
33494
+ * monotonicity even under concurrent callers.
33495
+ */
33496
+ async appendTurn(threadId, role, content) {
33497
+ return this.withLock(threadId, async () => {
33498
+ const bundle = await this.loadBundle(threadId) ?? null;
33499
+ const now = /* @__PURE__ */ new Date();
33500
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
33501
+ const retentionUntil = new Date(now.getTime() + retentionMs);
33502
+ const nextTurnId = bundle ? lastTurnId(bundle) + 1 : 1;
33503
+ const turn = {
33504
+ thread_id: threadId,
33505
+ fortress_id: this.fortressId,
33506
+ turn_id: nextTurnId,
33507
+ role,
33508
+ content,
33509
+ created_at: now.toISOString(),
33510
+ retention_until: retentionUntil.toISOString()
33511
+ };
33512
+ const next = bundle ? { ...bundle, turns: [...bundle.turns, turn] } : {
33513
+ version: 1,
33514
+ thread_id: threadId,
33515
+ fortress_id: this.fortressId,
33516
+ created_at: now.toISOString(),
33517
+ turns: [turn]
33518
+ };
33519
+ await this.saveBundle(next);
33520
+ return turn;
33521
+ });
33522
+ }
33523
+ /**
33524
+ * Read turns from a thread, oldest-first. Returns an empty array if
33525
+ * the thread does not exist or its bundle is corrupt. Does not emit
33526
+ * audit events; the caller (HTTP route handler) owns audit semantics.
33527
+ */
33528
+ async readThread(threadId, opts) {
33529
+ const bundle = await this.loadBundle(threadId);
33530
+ if (!bundle) return [];
33531
+ let turns = bundle.turns;
33532
+ if (opts?.sinceTurnId !== void 0) {
33533
+ const cutoff = opts.sinceTurnId;
33534
+ turns = turns.filter((t) => t.turn_id > cutoff);
33535
+ }
33536
+ if (opts?.limit !== void 0) {
33537
+ turns = turns.slice(0, opts.limit);
33538
+ }
33539
+ return turns;
33540
+ }
33541
+ /**
33542
+ * Enumerate concierge threads in this fortress with summary metadata.
33543
+ * Sorted newest-first by last_turn_at.
33544
+ */
33545
+ async listThreads(opts) {
33546
+ const entries = await this.storage.list(
33547
+ CONCIERGE_MEMORY_NAMESPACE,
33548
+ CONCIERGE_MEMORY_KEY_PREFIX
33549
+ );
33550
+ const summaries = [];
33551
+ for (const meta of entries) {
33552
+ const threadId = stripKeyPrefix(meta.key);
33553
+ if (threadId === null) continue;
33554
+ const bundle = await this.loadBundle(threadId);
33555
+ if (!bundle || bundle.turns.length === 0) continue;
33556
+ const last = bundle.turns[bundle.turns.length - 1];
33557
+ summaries.push({
33558
+ thread_id: bundle.thread_id,
33559
+ created_at: bundle.created_at,
33560
+ last_turn_at: last ? last.created_at : bundle.created_at,
33561
+ turn_count: bundle.turns.length
33562
+ });
33563
+ }
33564
+ summaries.sort(
33565
+ (a, b) => a.last_turn_at < b.last_turn_at ? 1 : a.last_turn_at > b.last_turn_at ? -1 : 0
33566
+ );
33567
+ if (opts?.limit !== void 0) {
33568
+ return summaries.slice(0, opts.limit);
33569
+ }
33570
+ return summaries;
33571
+ }
33572
+ /**
33573
+ * Delete a thread's bundle. Returns true if the bundle existed and
33574
+ * was removed; false if no bundle was present. Audit emission is the
33575
+ * caller's responsibility.
33576
+ */
33577
+ async deleteThread(threadId) {
33578
+ const key = bundleKey(threadId);
33579
+ return this.withLock(threadId, async () => {
33580
+ const existed = await this.storage.exists(
33581
+ CONCIERGE_MEMORY_NAMESPACE,
33582
+ key
33583
+ );
33584
+ if (!existed) return false;
33585
+ try {
33586
+ await this.storage.delete(CONCIERGE_MEMORY_NAMESPACE, key);
33587
+ } catch {
33588
+ return false;
33589
+ }
33590
+ return true;
33591
+ });
33592
+ }
33593
+ /**
33594
+ * Drop expired turns across all threads. Threads emptied by pruning
33595
+ * are removed entirely. Returns the count of turns pruned.
33596
+ */
33597
+ async pruneExpired(now) {
33598
+ const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
33599
+ const entries = await this.storage.list(
33600
+ CONCIERGE_MEMORY_NAMESPACE,
33601
+ CONCIERGE_MEMORY_KEY_PREFIX
33602
+ );
33603
+ let pruned = 0;
33604
+ for (const meta of entries) {
33605
+ const threadId = stripKeyPrefix(meta.key);
33606
+ if (threadId === null) continue;
33607
+ pruned += await this.withLock(threadId, async () => {
33608
+ const bundle = await this.loadBundle(threadId);
33609
+ if (!bundle) return 0;
33610
+ const kept = bundle.turns.filter((t) => t.retention_until > cutoff);
33611
+ const dropped = bundle.turns.length - kept.length;
33612
+ if (dropped === 0) return 0;
33613
+ if (kept.length === 0) {
33614
+ await this.storage.delete(
33615
+ CONCIERGE_MEMORY_NAMESPACE,
33616
+ bundleKey(threadId)
33617
+ );
33618
+ } else {
33619
+ await this.saveBundle({ ...bundle, turns: kept });
33620
+ }
33621
+ return dropped;
33622
+ });
33623
+ }
33624
+ return { pruned };
33625
+ }
33626
+ // ── internals ────────────────────────────────────────────────────────
33627
+ async loadBundle(threadId) {
33628
+ const key = bundleKey(threadId);
33629
+ let raw;
33630
+ try {
33631
+ raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
33632
+ } catch {
33633
+ return null;
33634
+ }
33635
+ if (!raw) return null;
33636
+ if (raw.length > MAX_BUNDLE_BYTES2) return null;
33637
+ try {
33638
+ const envelope = JSON.parse(bytesToString(raw));
33639
+ const aad = stringToBytes(threadId);
33640
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
33641
+ const parsed = JSON.parse(
33642
+ bytesToString(plaintext)
33643
+ );
33644
+ if (parsed.version !== 1) return null;
33645
+ if (parsed.thread_id !== threadId) return null;
33646
+ return parsed;
33647
+ } catch {
33648
+ return null;
33649
+ }
33650
+ }
33651
+ async saveBundle(bundle) {
33652
+ const key = bundleKey(bundle.thread_id);
33653
+ const aad = stringToBytes(bundle.thread_id);
33654
+ const plaintext = stringToBytes(JSON.stringify(bundle));
33655
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
33656
+ await this.storage.write(
33657
+ CONCIERGE_MEMORY_NAMESPACE,
33658
+ key,
33659
+ stringToBytes(JSON.stringify(envelope))
33660
+ );
33661
+ }
33662
+ /**
33663
+ * Run `task` while holding the per-thread async lock. Lock is released
33664
+ * once the task settles (success or failure). Generic helper so
33665
+ * appendTurn / deleteThread / pruneExpired share serialisation.
33666
+ */
33667
+ async withLock(threadId, task) {
33668
+ const previous = this.locks.get(threadId) ?? Promise.resolve();
33669
+ let release;
33670
+ const next = new Promise((resolve8) => {
33671
+ release = resolve8;
33672
+ });
33673
+ const chained = previous.then(() => next);
33674
+ this.locks.set(threadId, chained);
33675
+ try {
33676
+ await previous;
33677
+ return await task();
33678
+ } finally {
33679
+ release();
33680
+ if (this.locks.get(threadId) === chained) {
33681
+ this.locks.delete(threadId);
33682
+ }
33683
+ }
33684
+ }
33685
+ };
33686
+ }
33687
+ });
33688
+
32522
33689
  // src/chat/operator-chat-index.ts
32523
33690
  var init_operator_chat_index = __esm({
32524
33691
  "src/chat/operator-chat-index.ts"() {
32525
33692
  init_operator_chat_service();
32526
33693
  init_operator_chat_store();
33694
+ init_concierge_memory_store();
32527
33695
  init_operator_chat_audit_events();
32528
33696
  init_operator_chat_types();
32529
33697
  }
@@ -32537,6 +33705,14 @@ function buildV11Bindings(inputs) {
32537
33705
  let operatorChatService;
32538
33706
  if (inputs.storage && inputs.masterKey) {
32539
33707
  const chatStore = new OperatorChatStore(inputs.storage, inputs.masterKey);
33708
+ const conciergeMemory = new ConciergeMemoryStore({
33709
+ storage: inputs.storage,
33710
+ masterKey: inputs.masterKey,
33711
+ fortressId: inputs.fortressId,
33712
+ ...inputs.conciergeMemoryRetentionDays !== void 0 ? { retentionDays: inputs.conciergeMemoryRetentionDays } : {}
33713
+ });
33714
+ void conciergeMemory.pruneExpired().catch(() => {
33715
+ });
32540
33716
  operatorChatService = new OperatorChatService({
32541
33717
  store: chatStore,
32542
33718
  auditLog: inputs.auditLog,
@@ -32547,7 +33723,8 @@ function buildV11Bindings(inputs) {
32547
33723
  identityId: inputs.identityId,
32548
33724
  registry
32549
33725
  }),
32550
- conciergePiiFilter: buildConciergePiiFilter()
33726
+ conciergePiiFilter: buildConciergePiiFilter(),
33727
+ conciergeMemory
32551
33728
  });
32552
33729
  }
32553
33730
  const hubService = new HubService({
@@ -32782,7 +33959,7 @@ var init_defaults = __esm({
32782
33959
  });
32783
33960
 
32784
33961
  // src/intelligence/policy-store.ts
32785
- var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO2, IntelligenceConfigStore;
33962
+ var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO3, IntelligenceConfigStore;
32786
33963
  var init_policy_store = __esm({
32787
33964
  "src/intelligence/policy-store.ts"() {
32788
33965
  init_encryption();
@@ -32791,13 +33968,13 @@ var init_policy_store = __esm({
32791
33968
  init_defaults();
32792
33969
  INTELLIGENCE_NAMESPACE = "_intelligence";
32793
33970
  SUBSTRATE_CONFIG_KEY = "substrate-config";
32794
- HKDF_INFO2 = "intelligence-substrate-config";
33971
+ HKDF_INFO3 = "intelligence-substrate-config";
32795
33972
  IntelligenceConfigStore = class {
32796
33973
  storage;
32797
33974
  encryptionKey;
32798
33975
  constructor(storage, masterKey) {
32799
33976
  this.storage = storage;
32800
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
33977
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
32801
33978
  }
32802
33979
  /**
32803
33980
  * Load the operator's substrate config from disk. Returns the config
@@ -34819,7 +35996,9 @@ async function verifyExitBundle(bundleDir, options = {}) {
34819
35996
  );
34820
35997
  }
34821
35998
  }
34822
- const reputationFailed = reputation?.bundle_signature_valid === false || (reputation?.invalid_attestations ?? 0) > 0;
35999
+ const reputationBundleFailed = reputation?.bundle_signature_valid === false;
36000
+ const reputationAttestationFailed = (reputation?.invalid_attestations ?? 0) > 0;
36001
+ const reputationFailed = reputationBundleFailed || reputationAttestationFailed;
34823
36002
  const identityFailed = identity ? !identity.signature_valid : false;
34824
36003
  const unverifiableCount = reputation?.unverifiable_attestations ?? 0;
34825
36004
  const unverifiableFailed = unverifiableCount > 0 && !options.acceptUnverifiableAttestations;
@@ -34828,6 +36007,16 @@ async function verifyExitBundle(bundleDir, options = {}) {
34828
36007
  `${unverifiableCount} reputation attestation(s) have unknown signer public keys; pass --accept-unverifiable-attestations to import anyway`
34829
36008
  );
34830
36009
  }
36010
+ let detailedFailureClass;
36011
+ if (identityFailed) {
36012
+ detailedFailureClass = "identity_signature_invalid";
36013
+ } else if (reputationBundleFailed) {
36014
+ detailedFailureClass = "reputation_bundle_signature_invalid";
36015
+ } else if (reputationAttestationFailed) {
36016
+ detailedFailureClass = "reputation_attestation_signature_invalid";
36017
+ } else if (unverifiableFailed) {
36018
+ detailedFailureClass = "reputation_unverifiable_attestations";
36019
+ }
34831
36020
  return {
34832
36021
  version: "1.1",
34833
36022
  passed: !reputationFailed && !identityFailed && !unverifiableFailed,
@@ -34847,7 +36036,7 @@ async function verifyExitBundle(bundleDir, options = {}) {
34847
36036
  identity,
34848
36037
  audit,
34849
36038
  reputation,
34850
- failure_class: reputationFailed || identityFailed || unverifiableFailed ? "other" : void 0
36039
+ failure_class: detailedFailureClass
34851
36040
  };
34852
36041
  }
34853
36042
  var InvalidExitBundleError, PRIVATE_MATERIAL_KEYS;
@@ -35815,7 +37004,19 @@ async function runExitCommand(args) {
35815
37004
  }
35816
37005
  const config = await loadConfig();
35817
37006
  const ctx = await openExitContext(argv, env);
35818
- const policy = await loadPrincipalPolicy(ctx.storagePath);
37007
+ let policy;
37008
+ try {
37009
+ policy = await loadPrincipalPolicy(ctx.storagePath);
37010
+ } catch (policyErr) {
37011
+ if (policyErr instanceof MalformedPrincipalPolicyError) {
37012
+ write(err, `
37013
+ Sanctuary cannot proceed.
37014
+ ${policyErr.message}
37015
+ `);
37016
+ return 1;
37017
+ }
37018
+ throw policyErr;
37019
+ }
35819
37020
  const result = await exportExitBundle({
35820
37021
  bundleDir: outDir,
35821
37022
  storage: ctx.storage,
@@ -36515,7 +37716,19 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
36515
37716
  const profileStore = new SovereigntyProfileStore(storage, masterKey);
36516
37717
  await profileStore.load();
36517
37718
  const { tools: profileTools } = createSovereigntyProfileTools(profileStore, auditLog);
36518
- const policy = await loadPrincipalPolicy(config.storage_path);
37719
+ let policy;
37720
+ try {
37721
+ policy = await loadPrincipalPolicy(config.storage_path);
37722
+ } catch (err) {
37723
+ if (err instanceof MalformedPrincipalPolicyError) {
37724
+ console.error(`
37725
+ Sanctuary cannot start.
37726
+ ${err.message}
37727
+ `);
37728
+ process.exit(1);
37729
+ }
37730
+ throw err;
37731
+ }
36519
37732
  const baseline = new BaselineTracker(storage, masterKey);
36520
37733
  await baseline.load();
36521
37734
  let approvalChannel;
@@ -36613,6 +37826,21 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
36613
37826
  });
36614
37827
  } : void 0;
36615
37828
  const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
37829
+ const fortressIdForAggregator = fortressIdFromStoragePath(config.storage_path);
37830
+ const aggregatorIdentityId = identityManager.getPrimaryIdentityId() ?? `fortress:${config.storage_path}`;
37831
+ const approvalAggregator = new ApprovalAggregator({
37832
+ storage,
37833
+ masterKey,
37834
+ auditLog,
37835
+ identityId: aggregatorIdentityId,
37836
+ fortressId: fortressIdForAggregator
37837
+ });
37838
+ gate.setApprovalEventCallback((event) => {
37839
+ void approvalAggregator.ingest(event);
37840
+ });
37841
+ if (dashboard) {
37842
+ dashboard.setApprovalAggregator(approvalAggregator);
37843
+ }
36616
37844
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
36617
37845
  const { tools: sanctuaryMetaTools } = createSanctuaryTools({
36618
37846
  config,
@@ -36803,6 +38031,7 @@ var init_src = __esm({
36803
38031
  init_dashboard();
36804
38032
  init_webhook();
36805
38033
  init_gate();
38034
+ init_approval_aggregator();
36806
38035
  init_tools4();
36807
38036
  init_router();
36808
38037
  init_router();
@@ -41166,7 +42395,8 @@ var init_agents = __esm({
41166
42395
  // src/cli/reset-passphrase.ts
41167
42396
  var reset_passphrase_exports = {};
41168
42397
  __export(reset_passphrase_exports, {
41169
- runResetPassphraseCommand: () => runResetPassphraseCommand
42398
+ runResetPassphraseCommand: () => runResetPassphraseCommand,
42399
+ zeroizeBuffers: () => zeroizeBuffers
41170
42400
  });
41171
42401
  async function runResetPassphraseCommand(args) {
41172
42402
  const out = args.out ?? process.stdout;
@@ -41194,34 +42424,42 @@ Then re-run this command.
41194
42424
  return 1;
41195
42425
  }
41196
42426
  const lines = new LineReader(stdin);
42427
+ let code = 1;
42428
+ let nukeSucceeded = false;
41197
42429
  try {
41198
42430
  const availability = await surveyAvailableModes(storagePath);
41199
42431
  const mode = parsed.mode ?? await selectMode(lines, out, err, availability);
41200
42432
  if (!mode) {
41201
42433
  err.write("Aborted: no recovery mode selected.\n");
41202
- return 1;
41203
- }
41204
- if (mode === "shares") {
41205
- return await runSharesPath(out, err, availability);
41206
- }
41207
- if (mode === "guardian") {
41208
- return await runGuardianPath(out, err, availability);
42434
+ code = 1;
42435
+ } else if (mode === "shares") {
42436
+ code = await runSharesPath(out, err, availability);
42437
+ } else if (mode === "guardian") {
42438
+ code = await runGuardianPath(out, err, availability);
42439
+ } else {
42440
+ code = await runNukePath({
42441
+ out,
42442
+ err,
42443
+ lines,
42444
+ storagePath,
42445
+ home,
42446
+ plat,
42447
+ exec: args.exec ?? defaultExec2
42448
+ });
42449
+ nukeSucceeded = mode === "nuke" && code === 0;
41209
42450
  }
41210
- return await runNukePath({
41211
- out,
41212
- err,
41213
- lines,
41214
- storagePath,
41215
- home,
41216
- plat,
41217
- exec: args.exec ?? defaultExec2
41218
- });
41219
42451
  } finally {
42452
+ zeroizeBuffers(args.keyMaterialToZeroize);
41220
42453
  lines.close();
41221
42454
  }
42455
+ if (parsed.exitOnCompletion && nukeSucceeded) {
42456
+ const doExit = args.exitProcess ?? ((c) => process.exit(c));
42457
+ doExit(0);
42458
+ }
42459
+ return code;
41222
42460
  }
41223
42461
  function parseArgs2(argv) {
41224
- const out = { help: false };
42462
+ const out = { exitOnCompletion: false, help: false };
41225
42463
  for (let i = 0; i < argv.length; i++) {
41226
42464
  const a = argv[i];
41227
42465
  if (a === "--help" || a === "-h") {
@@ -41238,6 +42476,8 @@ function parseArgs2(argv) {
41238
42476
  out.storage = argv[++i];
41239
42477
  } else if (a === "--fortress" && argv[i + 1]) {
41240
42478
  out.fortress = argv[++i];
42479
+ } else if (a === "--exit-on-completion") {
42480
+ out.exitOnCompletion = true;
41241
42481
  } else if (a && a.startsWith("--")) {
41242
42482
  throw new Error(`Unknown flag: ${a}`);
41243
42483
  }
@@ -41270,6 +42510,16 @@ Options:
41270
42510
  --fortress <path> Override the fortress storage path.
41271
42511
  Consistent with "sanctuary wrap --fortress".
41272
42512
  --storage <path> Alias for --fortress.
42513
+ --exit-on-completion After a successful nuke, call process.exit(0)
42514
+ immediately so the post-wipe heap is reaped
42515
+ by the OS without re-entering the shell. Use
42516
+ on extreme-threat-model deployments where an
42517
+ attacker-on-host with heap-dump access could
42518
+ recover residual passphrase or key bytes
42519
+ between the wipe and the next operator
42520
+ command. JS strings cannot be explicitly
42521
+ zeroed; this flag is the supported way to
42522
+ bound the heap-dump window.
41273
42523
  --help, -h Show this help.
41274
42524
 
41275
42525
  Without --mode, the command surveys which paths are operationally available
@@ -41539,6 +42789,16 @@ async function prompt(lines, err, question) {
41539
42789
  err.write(question);
41540
42790
  return await lines.next();
41541
42791
  }
42792
+ function zeroizeBuffers(buffers) {
42793
+ if (!buffers) return;
42794
+ for (const b of buffers) {
42795
+ if (!b) continue;
42796
+ try {
42797
+ b.fill(0);
42798
+ } catch {
42799
+ }
42800
+ }
42801
+ }
41542
42802
  async function defaultExec2(cmd, args) {
41543
42803
  return await new Promise((resolve8, reject) => {
41544
42804
  const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -42219,7 +43479,19 @@ Refusing to start the dashboard while the reset-history marker is unreadable.`
42219
43479
  }
42220
43480
  throw err;
42221
43481
  }
42222
- const policy = await loadPrincipalPolicy(config.storage_path);
43482
+ let policy;
43483
+ try {
43484
+ policy = await loadPrincipalPolicy(config.storage_path);
43485
+ } catch (err) {
43486
+ if (err instanceof MalformedPrincipalPolicyError) {
43487
+ console.error(`
43488
+ Sanctuary cannot start.
43489
+ ${err.message}
43490
+ `);
43491
+ process.exit(1);
43492
+ }
43493
+ throw err;
43494
+ }
42223
43495
  const baseline = new BaselineTracker(storage, masterKey);
42224
43496
  await baseline.load();
42225
43497
  const dashboardPort = options.port ?? config.dashboard.port;