@sanctuary-framework/mcp-server 1.2.3 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -4457,9 +4457,35 @@ function validatePolicy(raw) {
4457
4457
  };
4458
4458
  delete merged.auto_deny;
4459
4459
  return merged;
4460
- })()
4460
+ })(),
4461
+ approval_redirect: parseApprovalRedirect(raw.approval_redirect)
4461
4462
  };
4462
4463
  }
4464
+ function parseApprovalRedirect(raw) {
4465
+ if (raw === void 0 || raw === null) {
4466
+ return { ...DEFAULT_APPROVAL_REDIRECT };
4467
+ }
4468
+ if (typeof raw !== "object") {
4469
+ return { ...DEFAULT_APPROVAL_REDIRECT };
4470
+ }
4471
+ const obj = raw;
4472
+ const enabled = typeof obj.enabled === "boolean" ? obj.enabled : DEFAULT_APPROVAL_REDIRECT.enabled;
4473
+ const modeRaw = obj.mode;
4474
+ let mode = DEFAULT_APPROVAL_REDIRECT.mode;
4475
+ if (modeRaw !== void 0) {
4476
+ if (modeRaw !== "replace" && modeRaw !== "notify") {
4477
+ throw new Error(
4478
+ `approval_redirect.mode must be "replace" or "notify" (got ${JSON.stringify(modeRaw)})`
4479
+ );
4480
+ }
4481
+ mode = modeRaw;
4482
+ }
4483
+ const result = { enabled, mode };
4484
+ if (obj.per_agent !== void 0 && typeof obj.per_agent === "object" && obj.per_agent !== null) {
4485
+ result.per_agent = obj.per_agent;
4486
+ }
4487
+ return result;
4488
+ }
4463
4489
  function generateDefaultPolicyYaml() {
4464
4490
  return `# Sanctuary Principal Policy v1
4465
4491
  # This file controls what your agent can do without asking.
@@ -4538,6 +4564,7 @@ tier3_always_allow:
4538
4564
  - handshake_status
4539
4565
  - handshake_exchange
4540
4566
  - handshake_verify_attestation
4567
+ - handshake_abort
4541
4568
  - reputation_query_weighted
4542
4569
  - federation_peers
4543
4570
  - federation_trust_evaluate
@@ -4572,25 +4599,58 @@ tier3_always_allow:
4572
4599
  approval_channel:
4573
4600
  type: stderr
4574
4601
  timeout_seconds: 300
4602
+
4603
+ # \u2500\u2500\u2500 Approval Redirect (v1.3 WP-V1.3-10 Upsilon-2) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4604
+ # Cross-harness approval-inbox redirect. When enabled, Tier 1/2 approvals
4605
+ # resolve via the unified approval inbox at /api/approval-inbox/* instead
4606
+ # of (or in addition to) the configured approval_channel above.
4607
+ #
4608
+ # mode:
4609
+ # replace: bypass the approval_channel entirely; the gate awaits a
4610
+ # decision from the inbox (default once enabled).
4611
+ # notify: fire BOTH the approval_channel and the inbox; first decision
4612
+ # wins. Right shape for harnesses that cannot fully suppress
4613
+ # their local approval prompt (e.g. Mastra-class).
4614
+ approval_redirect:
4615
+ enabled: false
4616
+ mode: replace
4575
4617
  `;
4576
4618
  }
4577
4619
  async function loadPrincipalPolicy(storagePath) {
4578
4620
  const policyPath = path.join(storagePath, "principal-policy.yaml");
4621
+ let content;
4622
+ try {
4623
+ content = await promises.readFile(policyPath, "utf-8");
4624
+ } catch (err) {
4625
+ const code = err?.code;
4626
+ if (code === "ENOENT") {
4627
+ const defaultYaml = generateDefaultPolicyYaml();
4628
+ try {
4629
+ await promises.writeFile(policyPath, defaultYaml, "utf-8");
4630
+ await promises.chmod(policyPath, 384);
4631
+ } catch (writeErr) {
4632
+ console.warn(
4633
+ `Sanctuary: could not write default principal policy to ${policyPath}: ${writeErr.message}. Continuing with in-memory default.`
4634
+ );
4635
+ }
4636
+ return Object.freeze({ ...DEFAULT_POLICY });
4637
+ }
4638
+ throw new MalformedPrincipalPolicyError(
4639
+ policyPath,
4640
+ `read failed: ${err.message}`
4641
+ );
4642
+ }
4579
4643
  try {
4580
- const content = await promises.readFile(policyPath, "utf-8");
4581
4644
  const policy = parsePolicy(content);
4582
4645
  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 });
4646
+ } catch (parseErr) {
4647
+ throw new MalformedPrincipalPolicyError(
4648
+ policyPath,
4649
+ parseErr.message
4650
+ );
4591
4651
  }
4592
4652
  }
4593
- var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_POLICY;
4653
+ var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_APPROVAL_REDIRECT, DEFAULT_POLICY, MalformedPrincipalPolicyError;
4594
4654
  var init_loader = __esm({
4595
4655
  "src/principal-policy/loader.ts"() {
4596
4656
  DEFAULT_TIER2 = {
@@ -4607,6 +4667,10 @@ var init_loader = __esm({
4607
4667
  // SEC-002: auto_deny is not configurable. Timeout always denies.
4608
4668
  // Field omitted intentionally — all channels hardcode deny on timeout.
4609
4669
  };
4670
+ DEFAULT_APPROVAL_REDIRECT = {
4671
+ enabled: false,
4672
+ mode: "replace"
4673
+ };
4610
4674
  DEFAULT_POLICY = {
4611
4675
  version: 1,
4612
4676
  tier1_always_approve: [
@@ -4680,6 +4744,7 @@ var init_loader = __esm({
4680
4744
  "handshake_status",
4681
4745
  "handshake_exchange",
4682
4746
  "handshake_verify_attestation",
4747
+ "handshake_abort",
4683
4748
  "reputation_query_weighted",
4684
4749
  "federation_peers",
4685
4750
  "federation_trust_evaluate",
@@ -4721,7 +4786,22 @@ var init_loader = __esm({
4721
4786
  "compliance_eu_ai_act_annex_iii_classify"
4722
4787
  // Read-only; rule-based Annex III classifier
4723
4788
  ],
4724
- approval_channel: DEFAULT_CHANNEL
4789
+ approval_channel: DEFAULT_CHANNEL,
4790
+ approval_redirect: DEFAULT_APPROVAL_REDIRECT
4791
+ };
4792
+ MalformedPrincipalPolicyError = class extends Error {
4793
+ constructor(policyPath, reason) {
4794
+ super(
4795
+ `Principal policy at ${policyPath} is malformed and cannot be loaded.
4796
+ Reason: ${reason}
4797
+ 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.`
4798
+ );
4799
+ this.policyPath = policyPath;
4800
+ this.reason = reason;
4801
+ this.name = "MalformedPrincipalPolicyError";
4802
+ }
4803
+ policyPath;
4804
+ reason;
4725
4805
  };
4726
4806
  }
4727
4807
  });
@@ -4942,7 +5022,7 @@ function deepSortKeys(obj) {
4942
5022
  return sorted;
4943
5023
  }
4944
5024
  function canonicalizeForSigning(body) {
4945
- return JSON.stringify(deepSortKeys(body));
5025
+ return JSON.stringify(deepSortKeys(body)).normalize("NFC");
4946
5026
  }
4947
5027
  var init_types = __esm({
4948
5028
  "src/shr/types.ts"() {
@@ -12441,7 +12521,7 @@ var init_auth_middleware = __esm({
12441
12521
  });
12442
12522
 
12443
12523
  // 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;
12524
+ 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
12525
  var init_constants3 = __esm({
12446
12526
  "src/hub/constants.ts"() {
12447
12527
  HUB_API_PREFIX = "/api/hub";
@@ -12469,6 +12549,16 @@ var init_constants3 = __esm({
12469
12549
  */
12470
12550
  CHAT_CONCIERGE_SEND: "/api/hub/chat/concierge",
12471
12551
  CHAT_CONCIERGE_HISTORY: "/api/hub/chat/concierge/history",
12552
+ /**
12553
+ * Concierge memory thread routes (WP-V1.3-9 Tau-1). Thread enumeration,
12554
+ * scrollback, and operator-initiated thread delete. Distinct from the
12555
+ * v1.2 `/history` route, which surfaces the active in-session thread
12556
+ * shape; the new routes target persisted multi-thread memory used by
12557
+ * v1.3 conversational sovereignty depth.
12558
+ */
12559
+ CHAT_CONCIERGE_THREADS_LIST: "/api/hub/chat/concierge/threads",
12560
+ CHAT_CONCIERGE_THREAD_READ: "/api/hub/chat/concierge/threads/:thread_id",
12561
+ CHAT_CONCIERGE_THREAD_DELETE: "/api/hub/chat/concierge/threads/:thread_id",
12472
12562
  /**
12473
12563
  * Click-to-inspect panel (WP-V1.2 reshape). Returns the agent's
12474
12564
  * recent activity feed, pending Tier 1 approvals routed through this
@@ -12492,6 +12582,10 @@ var init_constants3 = __esm({
12492
12582
  ];
12493
12583
  HUB_ACTIVITY_DEFAULT_LIMIT = 50;
12494
12584
  HUB_ACTIVITY_MAX_LIMIT = 500;
12585
+ HUB_CHAT_THREADS_DEFAULT_LIMIT = 50;
12586
+ HUB_CHAT_THREADS_MAX_LIMIT = 500;
12587
+ HUB_CHAT_TURNS_DEFAULT_LIMIT = 200;
12588
+ HUB_CHAT_TURNS_MAX_LIMIT = 1e3;
12495
12589
  HUB_INBOX_DEFAULT_LIMIT = 100;
12496
12590
  HUB_INBOX_MAX_LIMIT = 500;
12497
12591
  HUB_AGENTS_DEFAULT_LIMIT = 100;
@@ -12674,6 +12768,23 @@ function checkChatMessage(value) {
12674
12768
  }
12675
12769
  return trimmed;
12676
12770
  }
12771
+ function matchConciergeThreadRoute(path) {
12772
+ const prefix = `${HUB_API_PREFIX}/chat/concierge/threads/`;
12773
+ if (!path.startsWith(prefix)) return null;
12774
+ const rest = path.slice(prefix.length);
12775
+ if (rest.length === 0 || rest.includes("/")) return null;
12776
+ const decoded = decodeURIComponent(rest);
12777
+ if (decoded.length === 0) return null;
12778
+ return { threadId: decoded };
12779
+ }
12780
+ function parseSince(raw) {
12781
+ if (raw === null || raw === "") return void 0;
12782
+ const parsed = Number.parseInt(raw, 10);
12783
+ if (Number.isNaN(parsed) || parsed < 0) {
12784
+ throw new HubValidationError("since must be a non-negative integer");
12785
+ }
12786
+ return parsed;
12787
+ }
12677
12788
  function matchInboxRoute(path) {
12678
12789
  const prefix = `${HUB_API_PREFIX}/inbox/`;
12679
12790
  if (!path.startsWith(prefix)) return null;
@@ -12857,6 +12968,47 @@ async function handleHubRoute(deps, req, res) {
12857
12968
  writeJSON2(res, 200, { ok: true, data: { messages } });
12858
12969
  return true;
12859
12970
  }
12971
+ if (method === "GET" && path === HUB_ROUTES.CHAT_CONCIERGE_THREADS_LIST) {
12972
+ const limit = parseLimit(
12973
+ url.searchParams.get("limit"),
12974
+ HUB_CHAT_THREADS_DEFAULT_LIMIT,
12975
+ HUB_CHAT_THREADS_MAX_LIMIT
12976
+ );
12977
+ const threads = await deps.service.listConciergeMemoryThreads({ limit });
12978
+ writeJSON2(res, 200, { ok: true, data: { threads } });
12979
+ return true;
12980
+ }
12981
+ {
12982
+ const threadMatch = matchConciergeThreadRoute(path);
12983
+ if (threadMatch) {
12984
+ if (method === "GET") {
12985
+ const since = parseSince(url.searchParams.get("since"));
12986
+ const limit = parseLimit(
12987
+ url.searchParams.get("limit"),
12988
+ HUB_CHAT_TURNS_DEFAULT_LIMIT,
12989
+ HUB_CHAT_TURNS_MAX_LIMIT
12990
+ );
12991
+ const readOpts = { limit };
12992
+ if (since !== void 0) readOpts.sinceTurnId = since;
12993
+ const turns = await deps.service.readConciergeMemoryThread(
12994
+ threadMatch.threadId,
12995
+ readOpts
12996
+ );
12997
+ writeJSON2(res, 200, { ok: true, data: { turns } });
12998
+ return true;
12999
+ }
13000
+ if (method === "DELETE") {
13001
+ const removed = await deps.service.deleteConciergeMemoryThread(
13002
+ threadMatch.threadId
13003
+ );
13004
+ writeJSON2(res, removed ? 200 : 404, {
13005
+ ok: removed,
13006
+ data: { thread_id: threadMatch.threadId, removed }
13007
+ });
13008
+ return true;
13009
+ }
13010
+ }
13011
+ }
12860
13012
  writeJSON2(res, 404, { ok: false, error: "not_found", path });
12861
13013
  return true;
12862
13014
  } catch (err) {
@@ -16979,6 +17131,168 @@ var init_dispatch = __esm({
16979
17131
  init_intelligence_api_router();
16980
17132
  }
16981
17133
  });
17134
+
17135
+ // src/principal-policy/approval-aggregator-routes.ts
17136
+ function writeJSON4(res, status, payload) {
17137
+ res.writeHead(status, {
17138
+ "Content-Type": "application/json",
17139
+ "Cache-Control": "no-store"
17140
+ });
17141
+ res.end(JSON.stringify(payload));
17142
+ }
17143
+ function parseLimit2(raw, defaultValue, max) {
17144
+ if (raw === null || raw === "") return defaultValue;
17145
+ const parsed = Number.parseInt(raw, 10);
17146
+ if (Number.isNaN(parsed) || parsed < 0) {
17147
+ return defaultValue;
17148
+ }
17149
+ return Math.min(parsed, max);
17150
+ }
17151
+ function isStatusFilter(value) {
17152
+ return value === "pending" || value === "approved" || value === "denied" || value === "timeout" || value === "expired";
17153
+ }
17154
+ function matchEntryRoute(path) {
17155
+ const prefix = `${APPROVAL_INBOX_API_PREFIX}/`;
17156
+ if (!path.startsWith(prefix)) return null;
17157
+ const rest = path.slice(prefix.length);
17158
+ if (rest.length === 0) return null;
17159
+ const slash = rest.indexOf("/");
17160
+ if (slash === -1) {
17161
+ return { aggregatorId: decodeURIComponent(rest), action: null };
17162
+ }
17163
+ return {
17164
+ aggregatorId: decodeURIComponent(rest.slice(0, slash)),
17165
+ action: rest.slice(slash + 1)
17166
+ };
17167
+ }
17168
+ async function handleStream2(deps, res) {
17169
+ res.writeHead(200, {
17170
+ "Content-Type": "text/event-stream",
17171
+ "Cache-Control": "no-cache, no-transform",
17172
+ Connection: "keep-alive",
17173
+ "X-Accel-Buffering": "no"
17174
+ });
17175
+ const initial = await deps.aggregator.list({ status: "pending" });
17176
+ res.write(
17177
+ `event: approval_inbox_snapshot
17178
+ data: ${JSON.stringify({ entries: initial })}
17179
+
17180
+ `
17181
+ );
17182
+ const unsubscribe = deps.aggregator.onEvent((event) => {
17183
+ try {
17184
+ res.write(
17185
+ `event: approval_inbox_${event.type}
17186
+ data: ${JSON.stringify(event.entry)}
17187
+
17188
+ `
17189
+ );
17190
+ } catch {
17191
+ }
17192
+ });
17193
+ const keepAlive = setInterval(() => {
17194
+ try {
17195
+ res.write(": keepalive\n\n");
17196
+ } catch {
17197
+ }
17198
+ }, 25e3);
17199
+ const cleanup = () => {
17200
+ clearInterval(keepAlive);
17201
+ unsubscribe();
17202
+ };
17203
+ res.on("close", cleanup);
17204
+ res.on("error", cleanup);
17205
+ }
17206
+ async function handleApprovalInboxRoute(deps, req, res) {
17207
+ const host = req.headers.host || "localhost";
17208
+ const url = new URL(req.url ?? "/", `http://${host}`);
17209
+ const method = (req.method ?? "GET").toUpperCase();
17210
+ const path = url.pathname;
17211
+ if (path !== APPROVAL_INBOX_API_PREFIX && !path.startsWith(`${APPROVAL_INBOX_API_PREFIX}/`)) {
17212
+ return false;
17213
+ }
17214
+ const checkAuth = authMiddleware(deps.authConfig);
17215
+ if (!checkAuth(req, res, url)) return true;
17216
+ try {
17217
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/stream`) {
17218
+ await handleStream2(deps, res);
17219
+ return true;
17220
+ }
17221
+ if (method === "GET" && path === APPROVAL_INBOX_API_PREFIX) {
17222
+ const limit = parseLimit2(
17223
+ url.searchParams.get("limit"),
17224
+ APPROVAL_INBOX_DEFAULT_LIMIT,
17225
+ APPROVAL_INBOX_MAX_LIMIT
17226
+ );
17227
+ const statusRaw = url.searchParams.get("status");
17228
+ const status = statusRaw && isStatusFilter(statusRaw) ? statusRaw : "pending";
17229
+ const sinceTs = url.searchParams.get("since") ?? void 0;
17230
+ const entries = await deps.aggregator.list({
17231
+ status,
17232
+ limit,
17233
+ ...sinceTs !== void 0 ? { sinceTs } : {}
17234
+ });
17235
+ writeJSON4(res, 200, { ok: true, data: { entries } });
17236
+ return true;
17237
+ }
17238
+ const entryMatch = matchEntryRoute(path);
17239
+ if (entryMatch === null) {
17240
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
17241
+ return true;
17242
+ }
17243
+ if (method === "GET" && entryMatch.action === null) {
17244
+ const entries = await deps.aggregator.list({ limit: APPROVAL_INBOX_MAX_LIMIT });
17245
+ const entry = entries.find(
17246
+ (e) => e.aggregator_id === entryMatch.aggregatorId
17247
+ );
17248
+ if (!entry) {
17249
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
17250
+ return true;
17251
+ }
17252
+ const payload = await deps.aggregator.getFullPayload(
17253
+ entryMatch.aggregatorId
17254
+ );
17255
+ writeJSON4(res, 200, { ok: true, data: { entry, request_payload: payload } });
17256
+ return true;
17257
+ }
17258
+ if (method === "POST" && (entryMatch.action === "approve" || entryMatch.action === "deny")) {
17259
+ const decision = entryMatch.action === "approve" ? "approved" : "denied";
17260
+ const operatorId = deps.operatorId ?? APPROVAL_INBOX_OPERATOR_DEFAULT;
17261
+ try {
17262
+ const entry = await deps.aggregator.resolve(
17263
+ entryMatch.aggregatorId,
17264
+ decision,
17265
+ operatorId
17266
+ );
17267
+ writeJSON4(res, 200, { ok: true, data: { entry } });
17268
+ } catch (err) {
17269
+ const msg = err instanceof Error ? err.message : String(err);
17270
+ if (msg === "approval-aggregator: not_found") {
17271
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
17272
+ } else {
17273
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
17274
+ }
17275
+ }
17276
+ return true;
17277
+ }
17278
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
17279
+ return true;
17280
+ } catch (err) {
17281
+ const msg = err instanceof Error ? err.message : String(err);
17282
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
17283
+ return true;
17284
+ }
17285
+ }
17286
+ var APPROVAL_INBOX_API_PREFIX, APPROVAL_INBOX_OPERATOR_DEFAULT, APPROVAL_INBOX_DEFAULT_LIMIT, APPROVAL_INBOX_MAX_LIMIT;
17287
+ var init_approval_aggregator_routes = __esm({
17288
+ "src/principal-policy/approval-aggregator-routes.ts"() {
17289
+ init_auth_middleware();
17290
+ APPROVAL_INBOX_API_PREFIX = "/api/approval-inbox";
17291
+ APPROVAL_INBOX_OPERATOR_DEFAULT = "operator_dashboard";
17292
+ APPROVAL_INBOX_DEFAULT_LIMIT = 50;
17293
+ APPROVAL_INBOX_MAX_LIMIT = 200;
17294
+ }
17295
+ });
16982
17296
  function isDashboardViewRoute(method, path) {
16983
17297
  if (method !== "GET") return false;
16984
17298
  return path === "/" || path === "/dashboard" || path === "/v1.0" || path === "/fortress" || path === "/events";
@@ -16992,6 +17306,7 @@ var init_dashboard = __esm({
16992
17306
  init_fortress_view();
16993
17307
  init_system_prompt_generator();
16994
17308
  init_dispatch();
17309
+ init_approval_aggregator_routes();
16995
17310
  SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
16996
17311
  SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
16997
17312
  MAX_SESSIONS = 1e3;
@@ -17051,6 +17366,14 @@ var init_dashboard = __esm({
17051
17366
  * regardless. Default route flip is deferred to v1.2.
17052
17367
  */
17053
17368
  v11Bindings = null;
17369
+ /**
17370
+ * v1.3 WP-V1.3-10 Cross-Harness Approval Inbox aggregator. Mounted
17371
+ * additively at `/api/approval-inbox/*` when set. Legacy approval
17372
+ * routes at `/api/approvals/:id/(allow|deny)` continue to serve. The
17373
+ * aggregator is a passive subscriber to the gate; the routes here are
17374
+ * the operator-facing query / decision surface.
17375
+ */
17376
+ approvalAggregator = null;
17054
17377
  constructor(config) {
17055
17378
  this.config = config;
17056
17379
  this.authToken = config.auth_token;
@@ -17101,6 +17424,34 @@ var init_dashboard = __esm({
17101
17424
  setV11Bindings(bindings) {
17102
17425
  this.v11Bindings = bindings;
17103
17426
  }
17427
+ /**
17428
+ * v1.3 WP-V1.3-10 Upsilon-1: bind the cross-harness approval inbox
17429
+ * aggregator. Once set, requests to `/api/approval-inbox/*` route
17430
+ * through `handleApprovalInboxRoute`. Pass `null` to detach (used by
17431
+ * tests + during shutdown).
17432
+ */
17433
+ setApprovalAggregator(aggregator) {
17434
+ this.approvalAggregator = aggregator;
17435
+ }
17436
+ /**
17437
+ * v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
17438
+ * before the legacy approval route table. Returns true when served.
17439
+ */
17440
+ async dispatchApprovalInbox(req, res) {
17441
+ if (!this.approvalAggregator) return false;
17442
+ return handleApprovalInboxRoute(
17443
+ {
17444
+ authConfig: {
17445
+ loopbackAutoAuth: this._autoAuthLocalhost,
17446
+ ...this.authToken !== void 0 ? { authToken: this.authToken } : {}
17447
+ },
17448
+ aggregator: this.approvalAggregator,
17449
+ operatorId: this.identityManager?.getPrimaryIdentityId() ?? void 0
17450
+ },
17451
+ req,
17452
+ res
17453
+ );
17454
+ }
17104
17455
  /**
17105
17456
  * v1.1 dispatch entry point. Called from `handleRequest` before the
17106
17457
  * legacy route table. Returns true when the request was served by v1.1
@@ -17476,6 +17827,18 @@ var init_dashboard = __esm({
17476
17827
  res.end();
17477
17828
  return;
17478
17829
  }
17830
+ if (this.approvalAggregator && url.pathname.startsWith(APPROVAL_INBOX_API_PREFIX)) {
17831
+ this.dispatchApprovalInbox(req, res).then((handled) => {
17832
+ if (handled) return;
17833
+ this.handleLegacyRequest(req, res, url, method);
17834
+ }).catch(() => {
17835
+ if (!res.headersSent) {
17836
+ res.writeHead(500, { "Content-Type": "application/json" });
17837
+ res.end(JSON.stringify({ error: "Internal server error" }));
17838
+ }
17839
+ });
17840
+ return;
17841
+ }
17479
17842
  if (this.v11Bindings) {
17480
17843
  this.dispatchV11(req, res, url, method).then((handled) => {
17481
17844
  if (handled) return;
@@ -19519,14 +19882,25 @@ var init_gate = __esm({
19519
19882
  auditLog;
19520
19883
  injectionDetector;
19521
19884
  onInjectionAlert;
19885
+ onApprovalEvent;
19522
19886
  proxyTierResolver;
19523
- constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert) {
19887
+ constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert, onApprovalEvent) {
19524
19888
  this.policy = policy;
19525
19889
  this.baseline = baseline;
19526
19890
  this.channel = channel;
19527
19891
  this.auditLog = auditLog;
19528
19892
  this.injectionDetector = injectionDetector ?? new InjectionDetector();
19529
19893
  this.onInjectionAlert = onInjectionAlert;
19894
+ this.onApprovalEvent = onApprovalEvent;
19895
+ }
19896
+ /**
19897
+ * Set the approval-event callback after construction. Used by the
19898
+ * Upsilon-1 wire-up when the aggregator is constructed alongside the
19899
+ * gate. The aggregator subscribes through this setter rather than the
19900
+ * constructor so existing call sites continue to work unchanged.
19901
+ */
19902
+ setApprovalEventCallback(cb) {
19903
+ this.onApprovalEvent = cb;
19530
19904
  }
19531
19905
  /**
19532
19906
  * Set the proxy tier resolver. Called after the proxy router is initialized.
@@ -19760,21 +20134,105 @@ var init_gate = __esm({
19760
20134
  }
19761
20135
  /**
19762
20136
  * Request approval from the human principal.
20137
+ *
20138
+ * Fail-closed contract (full-sweep #49): if the channel throws (network
20139
+ * down, callback unreachable, dashboard SSE peer dropped, webhook DNS
20140
+ * failure, etc.), the gate denies the operation and audit-logs the cause.
20141
+ * Channel-internal timeouts already resolve with decision: "deny" per
20142
+ * SEC-002; this catch covers the remaining "channel raised" path so an
20143
+ * unhandled rejection cannot turn into an indeterminate state at the gate.
19763
20144
  */
19764
20145
  async requestApproval(operation, tier, reason, context) {
20146
+ const requestTimestamp = (/* @__PURE__ */ new Date()).toISOString();
19765
20147
  const request = {
19766
20148
  operation,
19767
20149
  tier,
19768
20150
  reason,
19769
20151
  context,
19770
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
20152
+ timestamp: requestTimestamp
19771
20153
  };
19772
- const response = await this.channel.requestApproval(request);
20154
+ const correlationId = `${requestTimestamp}:${operation}:${Math.random().toString(16).slice(2, 6)}`;
20155
+ if (this.onApprovalEvent) {
20156
+ try {
20157
+ this.onApprovalEvent({
20158
+ phase: "requested",
20159
+ operation,
20160
+ tier,
20161
+ reason,
20162
+ context,
20163
+ request_timestamp: requestTimestamp,
20164
+ correlation_id: correlationId
20165
+ });
20166
+ } catch {
20167
+ }
20168
+ }
20169
+ let response;
20170
+ try {
20171
+ response = await this.channel.requestApproval(request);
20172
+ } catch (err) {
20173
+ const errMessage = err instanceof Error ? err.message : String(err);
20174
+ const decidedAt = (/* @__PURE__ */ new Date()).toISOString();
20175
+ this.auditLog.append("l2", `gate_deny:${operation}`, "system", {
20176
+ tier,
20177
+ reason,
20178
+ decided_by: "channel_failure",
20179
+ channel_error: errMessage
20180
+ });
20181
+ if (this.onApprovalEvent) {
20182
+ try {
20183
+ this.onApprovalEvent({
20184
+ phase: "resolved",
20185
+ operation,
20186
+ tier,
20187
+ reason,
20188
+ context,
20189
+ request_timestamp: requestTimestamp,
20190
+ resolution: {
20191
+ decision: "deny",
20192
+ decided_at: decidedAt,
20193
+ decided_by: "channel_failure"
20194
+ },
20195
+ correlation_id: correlationId
20196
+ });
20197
+ } catch {
20198
+ }
20199
+ }
20200
+ return {
20201
+ allowed: false,
20202
+ tier,
20203
+ reason: AGENT_VISIBLE_DENY_REASONS.REQUIRES_APPROVAL,
20204
+ approval_required: true,
20205
+ approval_response: {
20206
+ decision: "deny",
20207
+ decided_at: decidedAt,
20208
+ decided_by: "channel_failure"
20209
+ }
20210
+ };
20211
+ }
19773
20212
  this.auditLog.append("l2", `gate_${response.decision}:${operation}`, "system", {
19774
20213
  tier,
19775
20214
  reason,
19776
20215
  decided_by: response.decided_by
19777
20216
  });
20217
+ if (this.onApprovalEvent) {
20218
+ try {
20219
+ this.onApprovalEvent({
20220
+ phase: "resolved",
20221
+ operation,
20222
+ tier,
20223
+ reason,
20224
+ context,
20225
+ request_timestamp: requestTimestamp,
20226
+ resolution: {
20227
+ decision: response.decision,
20228
+ decided_at: response.decided_at,
20229
+ decided_by: response.decided_by
20230
+ },
20231
+ correlation_id: correlationId
20232
+ });
20233
+ } catch {
20234
+ }
20235
+ }
19778
20236
  return {
19779
20237
  allowed: response.decision === "approve",
19780
20238
  tier,
@@ -19809,6 +20267,524 @@ var init_gate = __esm({
19809
20267
  };
19810
20268
  }
19811
20269
  });
20270
+ 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;
20271
+ var init_approval_aggregator = __esm({
20272
+ "src/principal-policy/approval-aggregator.ts"() {
20273
+ init_encryption();
20274
+ init_key_derivation();
20275
+ init_encoding();
20276
+ APPROVAL_AGGREGATOR_NAMESPACE = "_approval_aggregator";
20277
+ APPROVAL_AGGREGATOR_HKDF_INFO = "l2-approval-aggregator-v1";
20278
+ APPROVAL_AGGREGATOR_AUDIT_OPS = {
20279
+ AGGREGATED: "cross_harness_approval_aggregated",
20280
+ RESOLVED: "cross_harness_approval_resolved",
20281
+ DEDUPED: "cross_harness_approval_deduped"
20282
+ };
20283
+ DEFAULT_PENDING_TTL_MS = 5 * 60 * 1e3;
20284
+ DEFAULT_MAX_LIST_LIMIT = 200;
20285
+ DEFAULT_LIST_PAGE_SIZE = 50;
20286
+ ApprovalAggregator = class {
20287
+ storage;
20288
+ encryptionKey;
20289
+ auditLog;
20290
+ identityId;
20291
+ fortressId;
20292
+ pendingTtlMs;
20293
+ maxListLimit;
20294
+ now;
20295
+ resolveSourceContext;
20296
+ resolveHubInboxItemId;
20297
+ /** Cached entries by `aggregator_id`. */
20298
+ entries = /* @__PURE__ */ new Map();
20299
+ /** Dedup index: `${harness}|${agent}|${audit_id}` -> aggregator_id. */
20300
+ dedupIndex = /* @__PURE__ */ new Map();
20301
+ /** Correlation index: gate `correlation_id` -> aggregator_id. */
20302
+ correlationIndex = /* @__PURE__ */ new Map();
20303
+ /** Original request payloads kept in-memory for `getFullPayload()`. */
20304
+ fullPayloads = /* @__PURE__ */ new Map();
20305
+ /** Has the aggregator hydrated persisted entries on this process? */
20306
+ hydrated = false;
20307
+ /** Active SSE listeners. */
20308
+ listeners = /* @__PURE__ */ new Set();
20309
+ constructor(deps) {
20310
+ this.storage = deps.storage;
20311
+ this.encryptionKey = derivePurposeKey(
20312
+ deps.masterKey,
20313
+ APPROVAL_AGGREGATOR_HKDF_INFO
20314
+ );
20315
+ this.auditLog = deps.auditLog;
20316
+ this.identityId = deps.identityId;
20317
+ this.fortressId = deps.fortressId;
20318
+ this.pendingTtlMs = deps.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
20319
+ this.maxListLimit = deps.maxListLimit ?? DEFAULT_MAX_LIST_LIMIT;
20320
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
20321
+ this.resolveSourceContext = deps.resolveSourceContext ?? ((_event) => ({
20322
+ source_harness: this.fortressId,
20323
+ source_agent_id: this.fortressId
20324
+ }));
20325
+ this.resolveHubInboxItemId = deps.resolveHubInboxItemId ?? ((_event) => void 0);
20326
+ }
20327
+ /**
20328
+ * Subscribe an event listener. Returns an unsubscribe fn. SSE handlers
20329
+ * use this to forward aggregator emissions to the dashboard.
20330
+ */
20331
+ onEvent(listener) {
20332
+ this.listeners.add(listener);
20333
+ return () => this.listeners.delete(listener);
20334
+ }
20335
+ /**
20336
+ * Ingest a gate event. Returns the aggregator entry on first sight,
20337
+ * `null` when deduped. Resolution events update the existing record;
20338
+ * unmatched resolutions are dropped silently (caller's gate emitted a
20339
+ * resolved-without-requested pair, which the aggregator does not invent
20340
+ * a record for).
20341
+ */
20342
+ async ingest(event) {
20343
+ await this.hydrate();
20344
+ if (event.phase === "requested") {
20345
+ return this.ingestRequested(event);
20346
+ }
20347
+ if (event.phase === "resolved") {
20348
+ return this.ingestResolved(event);
20349
+ }
20350
+ return null;
20351
+ }
20352
+ /**
20353
+ * List pending or recently resolved entries. Pending entries past TTL
20354
+ * are lazily transitioned to `expired` and persisted before the list
20355
+ * snapshot is returned.
20356
+ */
20357
+ async list(opts) {
20358
+ await this.hydrate();
20359
+ await this.expireStale();
20360
+ const limit = Math.min(
20361
+ opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
20362
+ this.maxListLimit
20363
+ );
20364
+ const sinceMs = opts?.sinceTs ? Date.parse(opts.sinceTs) : Number.NEGATIVE_INFINITY;
20365
+ const matching = [];
20366
+ for (const entry of this.entries.values()) {
20367
+ if (opts?.status && entry.status !== opts.status) continue;
20368
+ if (Date.parse(entry.created_at) < sinceMs) continue;
20369
+ matching.push(entry);
20370
+ }
20371
+ matching.sort((a, b) => b.created_at.localeCompare(a.created_at));
20372
+ return matching.slice(0, limit);
20373
+ }
20374
+ /**
20375
+ * Return the original (unhashed) request payload for the entry. Returns
20376
+ * `null` when the entry is unknown or the payload was evicted (e.g. the
20377
+ * process restarted; payloads are in-memory only at v1.3 Upsilon-1).
20378
+ */
20379
+ async getFullPayload(aggregatorId) {
20380
+ await this.hydrate();
20381
+ if (!this.entries.has(aggregatorId)) return null;
20382
+ return this.fullPayloads.get(aggregatorId) ?? null;
20383
+ }
20384
+ /**
20385
+ * Resolve an entry. Used by both:
20386
+ * 1. The gate wire-up on channel-decision return.
20387
+ * 2. The HTTP `approve`/`deny` routes when an operator clicks.
20388
+ *
20389
+ * Idempotent: resolving an already-resolved entry is a no-op (the record
20390
+ * keeps its first decision and the audit log is not double-fired).
20391
+ * Unknown ids throw `Error("approval-aggregator: not_found")` so HTTP
20392
+ * routes return 404.
20393
+ */
20394
+ async resolve(aggregatorId, decision, operatorId) {
20395
+ await this.hydrate();
20396
+ const entry = this.entries.get(aggregatorId);
20397
+ if (!entry) {
20398
+ throw new Error("approval-aggregator: not_found");
20399
+ }
20400
+ if (entry.status !== "pending") {
20401
+ return entry;
20402
+ }
20403
+ entry.status = decision;
20404
+ entry.resolved_at = this.now().toISOString();
20405
+ entry.resolved_by = operatorId;
20406
+ await this.persist(entry);
20407
+ this.auditLog.append(
20408
+ "l2",
20409
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
20410
+ this.identityId,
20411
+ {
20412
+ aggregator_id: entry.aggregator_id,
20413
+ source_harness: entry.source_harness,
20414
+ source_agent_id: entry.source_agent_id,
20415
+ audit_log_entry_id: entry.audit_log_entry_id,
20416
+ policy_rule_id: entry.policy_rule_id,
20417
+ decision,
20418
+ decided_by: operatorId,
20419
+ decided_at: entry.resolved_at
20420
+ }
20421
+ );
20422
+ this.emit({ type: "resolved", entry: { ...entry } });
20423
+ return entry;
20424
+ }
20425
+ // ── Internal: ingest paths ─────────────────────────────────────────────
20426
+ async ingestRequested(event) {
20427
+ const ctx = this.resolveSourceContext(event);
20428
+ const auditId = this.auditEntryIdForEvent(event);
20429
+ const dedupKey = `${ctx.source_harness}|${ctx.source_agent_id}|${auditId}`;
20430
+ const existing = this.dedupIndex.get(dedupKey);
20431
+ if (existing) {
20432
+ const existingEntry = this.entries.get(existing);
20433
+ if (existingEntry) {
20434
+ this.correlationIndex.set(event.correlation_id, existing);
20435
+ this.auditLog.append(
20436
+ "l2",
20437
+ APPROVAL_AGGREGATOR_AUDIT_OPS.DEDUPED,
20438
+ this.identityId,
20439
+ {
20440
+ aggregator_id: existing,
20441
+ source_harness: ctx.source_harness,
20442
+ source_agent_id: ctx.source_agent_id,
20443
+ audit_log_entry_id: auditId,
20444
+ policy_rule_id: this.derivePolicyRuleId(event),
20445
+ correlation_id: event.correlation_id
20446
+ }
20447
+ );
20448
+ this.emit({ type: "deduped", entry: { ...existingEntry } });
20449
+ return null;
20450
+ }
20451
+ }
20452
+ const id = crypto.randomUUID();
20453
+ const now = this.now();
20454
+ const expires = new Date(now.getTime() + this.pendingTtlMs);
20455
+ const hubInboxId = this.resolveHubInboxItemId(event);
20456
+ const entry = {
20457
+ aggregator_id: id,
20458
+ source_harness: ctx.source_harness,
20459
+ source_agent_id: ctx.source_agent_id,
20460
+ audit_log_entry_id: auditId,
20461
+ policy_rule_id: this.derivePolicyRuleId(event),
20462
+ action_summary: this.deriveActionSummary(event),
20463
+ request_payload_hash: this.hashPayload(event.context),
20464
+ status: "pending",
20465
+ created_at: now.toISOString(),
20466
+ expires_at: expires.toISOString(),
20467
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
20468
+ };
20469
+ this.entries.set(id, entry);
20470
+ this.dedupIndex.set(dedupKey, id);
20471
+ this.correlationIndex.set(event.correlation_id, id);
20472
+ this.fullPayloads.set(id, event.context);
20473
+ await this.persist(entry);
20474
+ this.auditLog.append(
20475
+ "l2",
20476
+ APPROVAL_AGGREGATOR_AUDIT_OPS.AGGREGATED,
20477
+ this.identityId,
20478
+ {
20479
+ aggregator_id: id,
20480
+ source_harness: ctx.source_harness,
20481
+ source_agent_id: ctx.source_agent_id,
20482
+ audit_log_entry_id: auditId,
20483
+ policy_rule_id: entry.policy_rule_id,
20484
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
20485
+ }
20486
+ );
20487
+ this.emit({ type: "aggregated", entry: { ...entry } });
20488
+ return entry;
20489
+ }
20490
+ async ingestResolved(event) {
20491
+ const id = this.correlationIndex.get(event.correlation_id);
20492
+ if (!id) return null;
20493
+ const entry = this.entries.get(id);
20494
+ if (!entry) return null;
20495
+ if (entry.status !== "pending") return entry;
20496
+ if (!event.resolution) return entry;
20497
+ const failClosed = event.resolution.decision === "deny" && event.resolution.decided_by === "channel_failure";
20498
+ const status = failClosed ? "timeout" : event.resolution.decision === "approve" ? "approved" : "denied";
20499
+ entry.status = status;
20500
+ entry.resolved_at = event.resolution.decided_at;
20501
+ entry.resolved_by = event.resolution.decided_by;
20502
+ await this.persist(entry);
20503
+ this.auditLog.append(
20504
+ "l2",
20505
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
20506
+ this.identityId,
20507
+ {
20508
+ aggregator_id: id,
20509
+ source_harness: entry.source_harness,
20510
+ source_agent_id: entry.source_agent_id,
20511
+ audit_log_entry_id: entry.audit_log_entry_id,
20512
+ policy_rule_id: entry.policy_rule_id,
20513
+ decision: status,
20514
+ decided_by: entry.resolved_by,
20515
+ decided_at: entry.resolved_at,
20516
+ fail_closed: failClosed
20517
+ }
20518
+ );
20519
+ this.emit({ type: "resolved", entry: { ...entry } });
20520
+ return entry;
20521
+ }
20522
+ // ── Internal: helpers ──────────────────────────────────────────────────
20523
+ /**
20524
+ * Audit-log entry id for the dedup tuple. The audit log itself does not
20525
+ * surface a stable per-entry id (counter-prefixed keys are internal); the
20526
+ * aggregator uses the request timestamp + operation, which together pin
20527
+ * the audit entry the gate appended on the same call.
20528
+ */
20529
+ auditEntryIdForEvent(event) {
20530
+ return `${event.request_timestamp}:${event.operation}`;
20531
+ }
20532
+ derivePolicyRuleId(event) {
20533
+ return `tier${event.tier}:${event.operation}`;
20534
+ }
20535
+ deriveActionSummary(event) {
20536
+ return `${event.operation} (tier ${event.tier})`;
20537
+ }
20538
+ /**
20539
+ * Canonical SHA-256 of the request context. Sorted-keys serialization so
20540
+ * identical payloads always hash the same, even when key insertion order
20541
+ * varies. Defends against payload-replay smuggling (the aggregator can
20542
+ * tell the same payload was seen twice without storing it cleartext).
20543
+ */
20544
+ hashPayload(payload) {
20545
+ const canonical = JSON.stringify(payload, Object.keys(payload).sort());
20546
+ return crypto.createHash("sha256").update(canonical).digest("hex");
20547
+ }
20548
+ emit(event) {
20549
+ for (const listener of this.listeners) {
20550
+ try {
20551
+ listener(event);
20552
+ } catch {
20553
+ }
20554
+ }
20555
+ }
20556
+ async expireStale() {
20557
+ const nowMs = this.now().getTime();
20558
+ for (const entry of this.entries.values()) {
20559
+ if (entry.status !== "pending") continue;
20560
+ if (Date.parse(entry.expires_at) > nowMs) continue;
20561
+ entry.status = "expired";
20562
+ entry.resolved_at = this.now().toISOString();
20563
+ entry.resolved_by = "system_ttl";
20564
+ await this.persist(entry);
20565
+ this.auditLog.append(
20566
+ "l2",
20567
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
20568
+ this.identityId,
20569
+ {
20570
+ aggregator_id: entry.aggregator_id,
20571
+ source_harness: entry.source_harness,
20572
+ source_agent_id: entry.source_agent_id,
20573
+ audit_log_entry_id: entry.audit_log_entry_id,
20574
+ policy_rule_id: entry.policy_rule_id,
20575
+ decision: "expired",
20576
+ decided_by: "system_ttl",
20577
+ decided_at: entry.resolved_at
20578
+ }
20579
+ );
20580
+ this.emit({ type: "resolved", entry: { ...entry } });
20581
+ }
20582
+ }
20583
+ async persist(entry) {
20584
+ const serialized = stringToBytes(JSON.stringify(entry));
20585
+ const encrypted = encrypt(serialized, this.encryptionKey);
20586
+ await this.storage.write(
20587
+ APPROVAL_AGGREGATOR_NAMESPACE,
20588
+ entry.aggregator_id,
20589
+ stringToBytes(JSON.stringify(encrypted))
20590
+ );
20591
+ }
20592
+ async hydrate() {
20593
+ if (this.hydrated) return;
20594
+ this.hydrated = true;
20595
+ try {
20596
+ const metas = await this.storage.list(APPROVAL_AGGREGATOR_NAMESPACE);
20597
+ for (const meta of metas) {
20598
+ const raw = await this.storage.read(
20599
+ APPROVAL_AGGREGATOR_NAMESPACE,
20600
+ meta.key
20601
+ );
20602
+ if (!raw) continue;
20603
+ try {
20604
+ const encrypted = JSON.parse(bytesToString(raw));
20605
+ const decrypted = decrypt(encrypted, this.encryptionKey);
20606
+ const entry = JSON.parse(bytesToString(decrypted));
20607
+ this.entries.set(entry.aggregator_id, entry);
20608
+ const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
20609
+ this.dedupIndex.set(dedupKey, entry.aggregator_id);
20610
+ } catch {
20611
+ }
20612
+ }
20613
+ } catch {
20614
+ this.hydrated = false;
20615
+ }
20616
+ }
20617
+ };
20618
+ }
20619
+ });
20620
+
20621
+ // src/principal-policy/channels/aggregator-backed-channel.ts
20622
+ function auditEntryIdFor(request) {
20623
+ return `${request.timestamp}:${request.operation}`;
20624
+ }
20625
+ function statusToDecision(entry) {
20626
+ switch (entry.status) {
20627
+ case "approved":
20628
+ return {
20629
+ decision: "approve",
20630
+ decided_by: "human"
20631
+ };
20632
+ case "denied":
20633
+ return {
20634
+ decision: "deny",
20635
+ decided_by: "human"
20636
+ };
20637
+ case "timeout":
20638
+ case "expired":
20639
+ return {
20640
+ decision: "deny",
20641
+ decided_by: "timeout"
20642
+ };
20643
+ default:
20644
+ return null;
20645
+ }
20646
+ }
20647
+ function makeRedirectResolverFromPolicySupplier(supplier) {
20648
+ return (_request) => {
20649
+ const cfg = supplier().approval_redirect;
20650
+ if (!cfg || cfg.enabled !== true) {
20651
+ return { enabled: false, mode: "replace" };
20652
+ }
20653
+ return {
20654
+ enabled: true,
20655
+ mode: cfg.mode === "notify" ? "notify" : "replace"
20656
+ };
20657
+ };
20658
+ }
20659
+ var DEFAULT_REPLACE_MODE_TIMEOUT_MS, AggregatorBackedChannel;
20660
+ var init_aggregator_backed_channel = __esm({
20661
+ "src/principal-policy/channels/aggregator-backed-channel.ts"() {
20662
+ DEFAULT_REPLACE_MODE_TIMEOUT_MS = 5 * 60 * 1e3;
20663
+ AggregatorBackedChannel = class {
20664
+ underlying;
20665
+ aggregator;
20666
+ resolveRedirect;
20667
+ replaceModeTimeoutMs;
20668
+ now;
20669
+ constructor(opts) {
20670
+ this.underlying = opts.underlying;
20671
+ this.aggregator = opts.aggregator;
20672
+ this.resolveRedirect = opts.resolveRedirect;
20673
+ this.replaceModeTimeoutMs = opts.replaceModeTimeoutMs ?? DEFAULT_REPLACE_MODE_TIMEOUT_MS;
20674
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
20675
+ }
20676
+ /** Expose underlying for tests / wire-up reuse. */
20677
+ getUnderlying() {
20678
+ return this.underlying;
20679
+ }
20680
+ async requestApproval(request) {
20681
+ const cfg = this.resolveRedirect(request);
20682
+ if (!cfg.enabled) {
20683
+ return this.underlying.requestApproval(request);
20684
+ }
20685
+ if (cfg.mode === "replace") {
20686
+ return this.awaitAggregatorDecision(request);
20687
+ }
20688
+ return this.notifyMode(request);
20689
+ }
20690
+ /**
20691
+ * `replace` mode. Subscribe to the aggregator's event stream BEFORE
20692
+ * checking already-stored entries (avoids a race where the entry resolves
20693
+ * between list and subscribe). Match incoming events to this request by
20694
+ * audit_entry_id. Time out after `replaceModeTimeoutMs` to honor SEC-002.
20695
+ */
20696
+ async awaitAggregatorDecision(request) {
20697
+ const auditId = auditEntryIdFor(request);
20698
+ return new Promise((resolveOuter) => {
20699
+ let settled = false;
20700
+ let unsubscribe = null;
20701
+ let timeoutHandle = null;
20702
+ const settle = (response) => {
20703
+ if (settled) return;
20704
+ settled = true;
20705
+ if (timeoutHandle) clearTimeout(timeoutHandle);
20706
+ if (unsubscribe) {
20707
+ try {
20708
+ unsubscribe();
20709
+ } catch {
20710
+ }
20711
+ }
20712
+ resolveOuter(response);
20713
+ };
20714
+ const onEvent = (emit) => {
20715
+ if (emit.type !== "resolved") return;
20716
+ if (emit.entry.audit_log_entry_id !== auditId) return;
20717
+ const mapped = statusToDecision(emit.entry);
20718
+ if (!mapped) return;
20719
+ settle({
20720
+ decision: mapped.decision,
20721
+ decided_at: emit.entry.resolved_at ?? this.now().toISOString(),
20722
+ decided_by: mapped.decided_by
20723
+ });
20724
+ };
20725
+ try {
20726
+ unsubscribe = this.aggregator.onEvent(onEvent);
20727
+ } catch (err) {
20728
+ settle({
20729
+ decision: "deny",
20730
+ decided_at: this.now().toISOString(),
20731
+ decided_by: "channel_failure"
20732
+ });
20733
+ throw err instanceof Error ? err : new Error(String(err));
20734
+ }
20735
+ void this.aggregator.list({ limit: 200 }).then((entries) => {
20736
+ for (const entry of entries) {
20737
+ if (entry.audit_log_entry_id !== auditId) continue;
20738
+ const mapped = statusToDecision(entry);
20739
+ if (!mapped) return;
20740
+ settle({
20741
+ decision: mapped.decision,
20742
+ decided_at: entry.resolved_at ?? this.now().toISOString(),
20743
+ decided_by: mapped.decided_by
20744
+ });
20745
+ return;
20746
+ }
20747
+ }).catch(() => {
20748
+ });
20749
+ timeoutHandle = setTimeout(() => {
20750
+ settle({
20751
+ decision: "deny",
20752
+ decided_at: this.now().toISOString(),
20753
+ decided_by: "timeout"
20754
+ });
20755
+ }, this.replaceModeTimeoutMs);
20756
+ });
20757
+ }
20758
+ /**
20759
+ * `notify` mode. Fire the underlying channel and listen on the
20760
+ * aggregator simultaneously; whichever resolves first wins. Both
20761
+ * paths produce identical `ApprovalResponse` shapes; the gate's
20762
+ * downstream audit logging is unchanged.
20763
+ *
20764
+ * On underlying-channel failure, fall through to the aggregator wait
20765
+ * (still bounded by `replaceModeTimeoutMs`). Operator can still
20766
+ * resolve from the inbox even if the dashboard/webhook is down.
20767
+ */
20768
+ async notifyMode(request) {
20769
+ const aggregatorPromise = this.awaitAggregatorDecision(request);
20770
+ let underlyingPromise;
20771
+ try {
20772
+ underlyingPromise = this.underlying.requestApproval(request);
20773
+ } catch (err) {
20774
+ const response = await aggregatorPromise;
20775
+ return response;
20776
+ }
20777
+ return Promise.race([
20778
+ aggregatorPromise,
20779
+ underlyingPromise.catch(
20780
+ () => new Promise(() => {
20781
+ })
20782
+ )
20783
+ ]);
20784
+ }
20785
+ };
20786
+ }
20787
+ });
19812
20788
 
19813
20789
  // src/principal-policy/tools.ts
19814
20790
  function createPrincipalPolicyTools(policy, baseline, auditLog) {
@@ -20713,6 +21689,76 @@ var init_attestation = __esm({
20713
21689
  }
20714
21690
  });
20715
21691
 
21692
+ // src/handshake/audit.ts
21693
+ function auditHandshakeInitiated(auditLog, ctx) {
21694
+ auditLog.append(
21695
+ "l4",
21696
+ HANDSHAKE_LIFECYCLE_OPS.INITIATED,
21697
+ ctx.identity_id,
21698
+ detailsFromContext(ctx),
21699
+ "success"
21700
+ );
21701
+ }
21702
+ function auditHandshakeCompleted(auditLog, ctx) {
21703
+ const details = detailsFromContext(ctx);
21704
+ if (ctx.trust_tier !== void 0) {
21705
+ details.trust_tier = ctx.trust_tier;
21706
+ }
21707
+ auditLog.append(
21708
+ "l4",
21709
+ HANDSHAKE_LIFECYCLE_OPS.COMPLETED,
21710
+ ctx.identity_id,
21711
+ details,
21712
+ "success"
21713
+ );
21714
+ }
21715
+ function auditHandshakeFailed(auditLog, ctx) {
21716
+ const details = detailsFromContext(ctx);
21717
+ details.reason = ctx.reason;
21718
+ if (ctx.error !== void 0) {
21719
+ details.error = ctx.error;
21720
+ }
21721
+ auditLog.append(
21722
+ "l4",
21723
+ HANDSHAKE_LIFECYCLE_OPS.FAILED,
21724
+ ctx.identity_id,
21725
+ details,
21726
+ "failure"
21727
+ );
21728
+ }
21729
+ function auditHandshakeAborted(auditLog, ctx) {
21730
+ const details = detailsFromContext(ctx);
21731
+ details.reason = ctx.reason;
21732
+ auditLog.append(
21733
+ "l4",
21734
+ HANDSHAKE_LIFECYCLE_OPS.ABORTED,
21735
+ ctx.identity_id,
21736
+ details,
21737
+ "failure"
21738
+ );
21739
+ }
21740
+ function detailsFromContext(ctx) {
21741
+ const details = {
21742
+ session_id: ctx.session_id,
21743
+ role: ctx.role
21744
+ };
21745
+ if (ctx.counterparty_id !== void 0) {
21746
+ details.counterparty_id = ctx.counterparty_id;
21747
+ }
21748
+ return details;
21749
+ }
21750
+ var HANDSHAKE_LIFECYCLE_OPS;
21751
+ var init_audit = __esm({
21752
+ "src/handshake/audit.ts"() {
21753
+ HANDSHAKE_LIFECYCLE_OPS = {
21754
+ INITIATED: "handshake_initiated",
21755
+ COMPLETED: "handshake_completed",
21756
+ FAILED: "handshake_failed",
21757
+ ABORTED: "handshake_aborted"
21758
+ };
21759
+ }
21760
+ });
21761
+
20716
21762
  // src/handshake/tools.ts
20717
21763
  function createHandshakeTools(config, identityManager, masterKey, auditLog, options) {
20718
21764
  const autoPublishHandshakes = options?.autoPublishHandshakes ?? false;
@@ -20746,6 +21792,11 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20746
21792
  const { challenge, session } = initiateHandshake(shr);
20747
21793
  sessions.set(session.session_id, session);
20748
21794
  auditLog.append("l4", "handshake_initiate", shr.body.instance_id);
21795
+ auditHandshakeInitiated(auditLog, {
21796
+ session_id: session.session_id,
21797
+ role: "initiator",
21798
+ identity_id: shr.body.instance_id
21799
+ });
20749
21800
  return toolResult({
20750
21801
  session_id: session.session_id,
20751
21802
  challenge,
@@ -20785,10 +21836,23 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20785
21836
  );
20786
21837
  if ("error" in result) {
20787
21838
  auditLog.append("l4", "handshake_respond", shr.body.instance_id, void 0, "failure");
21839
+ auditHandshakeFailed(auditLog, {
21840
+ session_id: "unknown",
21841
+ role: "responder",
21842
+ identity_id: shr.body.instance_id,
21843
+ reason: classifyRespondFailure(result.error),
21844
+ error: result.error
21845
+ });
20788
21846
  return toolResult({ error: result.error });
20789
21847
  }
20790
21848
  sessions.set(result.session.session_id, result.session);
20791
21849
  auditLog.append("l4", "handshake_respond", shr.body.instance_id);
21850
+ auditHandshakeInitiated(auditLog, {
21851
+ session_id: result.session.session_id,
21852
+ role: "responder",
21853
+ identity_id: shr.body.instance_id,
21854
+ counterparty_id: challenge.shr.body.instance_id
21855
+ });
20792
21856
  let autoPublishResult;
20793
21857
  if (autoPublishHandshakes) {
20794
21858
  autoPublishResult = { attempted: true };
@@ -20896,9 +21960,23 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20896
21960
  const response = args.response;
20897
21961
  const session = sessions.get(sessionId);
20898
21962
  if (!session) {
21963
+ auditHandshakeFailed(auditLog, {
21964
+ session_id: sessionId,
21965
+ role: "initiator",
21966
+ identity_id: "unknown",
21967
+ reason: "session_unknown",
21968
+ error: `No handshake session found: ${sessionId}`
21969
+ });
20899
21970
  return toolResult({ error: `No handshake session found: ${sessionId}` });
20900
21971
  }
20901
21972
  if (session.state !== "initiated") {
21973
+ auditHandshakeFailed(auditLog, {
21974
+ session_id: sessionId,
21975
+ role: "initiator",
21976
+ identity_id: session.our_shr.body.instance_id,
21977
+ reason: "session_state_mismatch",
21978
+ error: `Session is in state '${session.state}', expected 'initiated'`
21979
+ });
20902
21980
  return toolResult({
20903
21981
  error: `Session is in state '${session.state}', expected 'initiated'`
20904
21982
  });
@@ -20912,6 +21990,13 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20912
21990
  if ("error" in result) {
20913
21991
  session.state = "failed";
20914
21992
  auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id, void 0, "failure");
21993
+ auditHandshakeFailed(auditLog, {
21994
+ session_id: sessionId,
21995
+ role: "initiator",
21996
+ identity_id: session.our_shr.body.instance_id,
21997
+ reason: classifyCompleteFailure(result.error),
21998
+ error: result.error
21999
+ });
20915
22000
  return toolResult({ error: result.error });
20916
22001
  }
20917
22002
  session.state = "completed";
@@ -20920,6 +22005,13 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20920
22005
  session.result = result.result;
20921
22006
  handshakeResults.set(result.result.counterparty_id, result.result);
20922
22007
  auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id);
22008
+ auditHandshakeCompleted(auditLog, {
22009
+ session_id: sessionId,
22010
+ role: "initiator",
22011
+ identity_id: session.our_shr.body.instance_id,
22012
+ counterparty_id: result.result.counterparty_id,
22013
+ trust_tier: result.result.trust_tier
22014
+ });
20923
22015
  return toolResult({
20924
22016
  completion: result.completion,
20925
22017
  result: result.result,
@@ -20967,6 +22059,24 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20967
22059
  void 0,
20968
22060
  result.verified ? "success" : "failure"
20969
22061
  );
22062
+ if (result.verified) {
22063
+ auditHandshakeCompleted(auditLog, {
22064
+ session_id: session.session_id,
22065
+ role: "responder",
22066
+ identity_id: session.our_shr.body.instance_id,
22067
+ counterparty_id: result.counterparty_id,
22068
+ trust_tier: result.trust_tier
22069
+ });
22070
+ } else {
22071
+ auditHandshakeFailed(auditLog, {
22072
+ session_id: session.session_id,
22073
+ role: "responder",
22074
+ identity_id: session.our_shr.body.instance_id,
22075
+ counterparty_id: result.counterparty_id,
22076
+ reason: classifyCompleteFailure(result.errors.join("; ")),
22077
+ error: result.errors.join("; ")
22078
+ });
22079
+ }
20970
22080
  return toolResult({ result });
20971
22081
  }
20972
22082
  return toolResult({
@@ -21074,10 +22184,74 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
21074
22184
  _content_trust: "external"
21075
22185
  });
21076
22186
  }
22187
+ },
22188
+ {
22189
+ name: "handshake_abort",
22190
+ description: "Abort an in-flight handshake session. Drops the session record and appends a session-lifecycle audit entry (handshake_aborted) so the operator can distinguish operator-cancelled, timed-out, and dropped sessions from sessions that simply fell off the protocol path.",
22191
+ inputSchema: {
22192
+ type: "object",
22193
+ properties: {
22194
+ session_id: {
22195
+ type: "string",
22196
+ description: "Session ID returned from handshake_initiate / handshake_respond."
22197
+ },
22198
+ reason: {
22199
+ type: "string",
22200
+ enum: [
22201
+ "operator_cancelled",
22202
+ "session_timeout",
22203
+ "transport_dropped",
22204
+ "shutdown",
22205
+ "other"
22206
+ ],
22207
+ description: "Why the session is being aborted. Defaults to 'operator_cancelled'."
22208
+ }
22209
+ },
22210
+ required: ["session_id"]
22211
+ },
22212
+ handler: async (args) => {
22213
+ const sessionId = args.session_id;
22214
+ const reason = args.reason ?? "operator_cancelled";
22215
+ const session = sessions.get(sessionId);
22216
+ if (!session) {
22217
+ return toolResult({ error: `No handshake session found: ${sessionId}` });
22218
+ }
22219
+ if (session.state === "completed") {
22220
+ return toolResult({
22221
+ error: `Session ${sessionId} already completed; abort is only valid for in-flight sessions`
22222
+ });
22223
+ }
22224
+ sessions.delete(sessionId);
22225
+ auditHandshakeAborted(auditLog, {
22226
+ session_id: sessionId,
22227
+ role: session.role,
22228
+ identity_id: session.our_shr.body.instance_id,
22229
+ ...session.their_shr ? { counterparty_id: session.their_shr.body.instance_id } : {},
22230
+ reason
22231
+ });
22232
+ return toolResult({
22233
+ aborted: true,
22234
+ session_id: sessionId,
22235
+ reason
22236
+ });
22237
+ }
21077
22238
  }
21078
22239
  ];
21079
22240
  return { tools, handshakeResults };
21080
22241
  }
22242
+ function classifyRespondFailure(error) {
22243
+ if (error.includes("Unsupported protocol version")) return "protocol_version_unsupported";
22244
+ if (error.includes("SHR verification failed")) return "shr_invalid";
22245
+ if (error.includes("No identity available")) return "no_signing_identity";
22246
+ return "other";
22247
+ }
22248
+ function classifyCompleteFailure(error) {
22249
+ if (error.includes("Unsupported protocol version")) return "protocol_version_unsupported";
22250
+ if (error.includes("SHR verification failed") || error.includes("SHR")) return "shr_invalid";
22251
+ if (error.includes("nonce signature is invalid")) return "nonce_signature_invalid";
22252
+ if (error.includes("No identity available")) return "no_signing_identity";
22253
+ return "other";
22254
+ }
21081
22255
  var init_tools6 = __esm({
21082
22256
  "src/handshake/tools.ts"() {
21083
22257
  init_router();
@@ -21087,6 +22261,7 @@ var init_tools6 = __esm({
21087
22261
  init_encoding();
21088
22262
  init_protocol();
21089
22263
  init_attestation();
22264
+ init_audit();
21090
22265
  init_verifier();
21091
22266
  }
21092
22267
  });
@@ -22726,6 +23901,12 @@ function typed(markerPath, lineNumber, field, expected) {
22726
23901
  }
22727
23902
  async function consumeResetHistoryMarker(options) {
22728
23903
  const markerPath = path.join(options.storagePath, RESET_HISTORY_FILENAME);
23904
+ const consumedPath = markerPath + ".consumed";
23905
+ if (await fileExists3(consumedPath)) {
23906
+ await promises.rm(markerPath, { force: true });
23907
+ await promises.rm(consumedPath, { force: true });
23908
+ return { emitted: 0, markerPath };
23909
+ }
22729
23910
  if (!await fileExists3(markerPath)) {
22730
23911
  return { emitted: 0, markerPath };
22731
23912
  }
@@ -22750,7 +23931,9 @@ async function consumeResetHistoryMarker(options) {
22750
23931
  });
22751
23932
  }
22752
23933
  await options.auditLog.flush();
23934
+ await promises.writeFile(consumedPath, "", "utf-8");
22753
23935
  await promises.rm(markerPath, { force: true });
23936
+ await promises.rm(consumedPath, { force: true });
22754
23937
  return { emitted: markers.length, markerHash, markerPath };
22755
23938
  }
22756
23939
  async function fileExists3(path) {
@@ -32033,6 +33216,36 @@ var init_hub_service = __esm({
32033
33216
  const chat = this.requireOperatorChat();
32034
33217
  return chat.getConciergeHistory();
32035
33218
  }
33219
+ // ── Concierge memory threads (WP-V1.3-9 Tau-1) ─────────────────────
33220
+ /**
33221
+ * Whether the operator-chat service has the WP-V1.3-9 memory store
33222
+ * wired. Routes use this to 503 cleanly when the foundation memory
33223
+ * surface is unavailable on a given fortress.
33224
+ */
33225
+ hasConciergeMemory() {
33226
+ return Boolean(this.deps.operatorChat?.hasConciergeMemory());
33227
+ }
33228
+ async listConciergeMemoryThreads(opts) {
33229
+ const chat = this.requireOperatorChat();
33230
+ if (!chat.hasConciergeMemory()) {
33231
+ throw new HubCapabilityError("concierge_memory_not_wired");
33232
+ }
33233
+ return chat.listConciergeMemoryThreads(opts);
33234
+ }
33235
+ async readConciergeMemoryThread(threadId, opts) {
33236
+ const chat = this.requireOperatorChat();
33237
+ if (!chat.hasConciergeMemory()) {
33238
+ throw new HubCapabilityError("concierge_memory_not_wired");
33239
+ }
33240
+ return chat.readConciergeMemoryThread(threadId, opts);
33241
+ }
33242
+ async deleteConciergeMemoryThread(threadId) {
33243
+ const chat = this.requireOperatorChat();
33244
+ if (!chat.hasConciergeMemory()) {
33245
+ throw new HubCapabilityError("concierge_memory_not_wired");
33246
+ }
33247
+ return chat.deleteConciergeMemoryThread(threadId);
33248
+ }
32036
33249
  /**
32037
33250
  * Open the click-to-inspect/approve panel for a wrapped agent. The
32038
33251
  * panel surfaces recent activity routed through this agent, pending
@@ -32171,7 +33384,27 @@ var init_operator_chat_audit_events = __esm({
32171
33384
  * affordance now opens an inspect/approve panel (recent activity +
32172
33385
  * pending approvals + policy summary) instead of a chat session.
32173
33386
  */
32174
- AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened"
33387
+ AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened",
33388
+ /**
33389
+ * Operator viewed concierge thread history (WP-V1.3-9 Tau-1). Emitted
33390
+ * when the operator hits the list-threads or read-thread route. Body
33391
+ * carries the thread_id (or `*` for the list endpoint) and a count;
33392
+ * raw turn content never crosses the audit surface.
33393
+ */
33394
+ CONCIERGE_HISTORY_READ: "operator_concierge_history_read",
33395
+ /**
33396
+ * Operator deleted a concierge thread (WP-V1.3-9 Tau-1). Emitted on
33397
+ * successful thread removal. Body carries thread_id + turn_count of
33398
+ * the deleted bundle.
33399
+ */
33400
+ CONCIERGE_THREAD_DELETED: "operator_concierge_thread_deleted",
33401
+ /**
33402
+ * Concierge memory fold-read failed (WP-V1.3-9 Tau-2). Emitted when
33403
+ * the multi-turn coherence fold cannot load the active thread's prior
33404
+ * turns; the concierge degrades to single-turn after emitting. Body
33405
+ * carries thread_id + a stable failure_reason enum.
33406
+ */
33407
+ CONCIERGE_MEMORY_READ_FAILED: "operator_concierge_memory_read_failed"
32175
33408
  };
32176
33409
  }
32177
33410
  });
@@ -32184,13 +33417,20 @@ var init_operator_chat_types = __esm({
32184
33417
  CONCIERGE_THREAD_KEY = "_fortress";
32185
33418
  }
32186
33419
  });
33420
+ function approxTokenLen(text) {
33421
+ return Math.ceil(text.length / 4);
33422
+ }
32187
33423
  function makeEventId(prefix) {
32188
33424
  return `${prefix}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
32189
33425
  }
33426
+ function formatPriorTurnLine(turn) {
33427
+ const label = turn.role === "user" ? "OPERATOR" : "CONCIERGE";
33428
+ return `${label}: ${turn.content}`;
33429
+ }
32190
33430
  function hashOf(input) {
32191
33431
  return hashToString(sha256.sha256(stringToBytes(input)));
32192
33432
  }
32193
- var DEFAULT_CONCIERGE_MAX_TOKENS, SANCTUARY_DOMAIN_REFERENCE, OperatorChatService;
33433
+ var DEFAULT_CONCIERGE_MAX_TOKENS, DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS, DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS, DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET, DEFAULT_CONCIERGE_SESSION_TTL_MS, SANCTUARY_DOMAIN_REFERENCE, OperatorChatService;
32194
33434
  var init_operator_chat_service = __esm({
32195
33435
  "src/chat/operator-chat-service.ts"() {
32196
33436
  init_hashing();
@@ -32198,6 +33438,10 @@ var init_operator_chat_service = __esm({
32198
33438
  init_operator_chat_audit_events();
32199
33439
  init_operator_chat_types();
32200
33440
  DEFAULT_CONCIERGE_MAX_TOKENS = 512;
33441
+ DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS = 10;
33442
+ DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
33443
+ DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET = 500;
33444
+ DEFAULT_CONCIERGE_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
32201
33445
  SANCTUARY_DOMAIN_REFERENCE = `Castle Architecture (four enforcement layers):
32202
33446
  1. Castle Wall: OS-boundary egress filter enforced at the kernel level. Blocks unauthorized outbound calls even from prompt-injected agents.
32203
33447
  2. Sentinels: internal observation via process introspection. Surfaces anomalies to the operator; does not enforce.
@@ -32233,6 +33477,27 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32233
33477
  contextProviders;
32234
33478
  piiFilter;
32235
33479
  conciergeMaxTokens;
33480
+ memory;
33481
+ historyWindowTurns;
33482
+ historyFreshnessMs;
33483
+ historyTokenBudget;
33484
+ sessionTtlMs;
33485
+ clock;
33486
+ /**
33487
+ * In-memory thread_id assigned to the active concierge session.
33488
+ * The first sendConcierge call after construction allocates a fresh
33489
+ * UUID; subsequent calls reuse it so multi-turn coherence (Tau-2)
33490
+ * folds the prior turns into context. Cleared by `resetConciergeMemoryThread`.
33491
+ */
33492
+ activeMemoryThreadId;
33493
+ /**
33494
+ * Wall-clock ms of the most recent sendConcierge that touched the
33495
+ * active session thread. Drives the WP-V1.3-9 Tau-2 session-TTL
33496
+ * check: a fresh sendConcierge after `sessionTtlMs` of quiet
33497
+ * allocates a new thread_id even though the prior one is still
33498
+ * readable from the memory store.
33499
+ */
33500
+ lastInteractionAt;
32236
33501
  constructor(deps) {
32237
33502
  this.store = deps.store;
32238
33503
  this.auditLog = deps.auditLog;
@@ -32243,6 +33508,12 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32243
33508
  }
32244
33509
  if (deps.conciergePiiFilter) this.piiFilter = deps.conciergePiiFilter;
32245
33510
  this.conciergeMaxTokens = deps.conciergeMaxTokens ?? DEFAULT_CONCIERGE_MAX_TOKENS;
33511
+ if (deps.conciergeMemory) this.memory = deps.conciergeMemory;
33512
+ this.historyWindowTurns = deps.conciergeHistoryWindowTurns !== void 0 && deps.conciergeHistoryWindowTurns > 0 ? deps.conciergeHistoryWindowTurns : DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS;
33513
+ this.historyFreshnessMs = deps.conciergeHistoryFreshnessMs !== void 0 && deps.conciergeHistoryFreshnessMs > 0 ? deps.conciergeHistoryFreshnessMs : DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS;
33514
+ this.historyTokenBudget = deps.conciergeHistoryTokenBudget !== void 0 && deps.conciergeHistoryTokenBudget > 0 ? deps.conciergeHistoryTokenBudget : DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET;
33515
+ this.sessionTtlMs = deps.conciergeSessionTtlMs !== void 0 && deps.conciergeSessionTtlMs > 0 ? deps.conciergeSessionTtlMs : DEFAULT_CONCIERGE_SESSION_TTL_MS;
33516
+ this.clock = deps.conciergeClock ?? (() => Date.now());
32246
33517
  }
32247
33518
  // ── Concierge ─────────────────────────────────────────────────────────
32248
33519
  /**
@@ -32261,6 +33532,10 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32261
33532
  throw new Error("concierge query must not be empty");
32262
33533
  }
32263
33534
  const filterResult = this.piiFilter ? this.piiFilter.filter(trimmed) : { filtered: trimmed, redactions: 0 };
33535
+ const nowMs = this.clock();
33536
+ if (this.activeMemoryThreadId && this.lastInteractionAt !== void 0 && nowMs - this.lastInteractionAt > this.sessionTtlMs) {
33537
+ this.activeMemoryThreadId = void 0;
33538
+ }
32264
33539
  const operatorMessage = {
32265
33540
  message_id: crypto.randomUUID(),
32266
33541
  surface: "concierge",
@@ -32273,6 +33548,30 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32273
33548
  CONCIERGE_THREAD_KEY,
32274
33549
  operatorMessage
32275
33550
  );
33551
+ let priorTurns = [];
33552
+ let memoryReadFailureReason = null;
33553
+ let activeThreadIdForRound;
33554
+ if (this.memory) {
33555
+ activeThreadIdForRound = this.ensureActiveMemoryThread();
33556
+ const result = await this.memory.readThreadStrict(activeThreadIdForRound).catch(() => ({ ok: false, reason: "io_failed" }));
33557
+ if (result.ok) {
33558
+ const cutoff = nowMs - this.historyFreshnessMs;
33559
+ const fresh = result.turns.filter((t) => {
33560
+ const ts = Date.parse(t.created_at);
33561
+ return Number.isFinite(ts) && ts >= cutoff;
33562
+ });
33563
+ const recent = fresh.length > this.historyWindowTurns ? fresh.slice(fresh.length - this.historyWindowTurns) : fresh;
33564
+ priorTurns = recent;
33565
+ } else {
33566
+ memoryReadFailureReason = result.reason;
33567
+ this.emitMemoryReadFailed(activeThreadIdForRound, result.reason);
33568
+ }
33569
+ }
33570
+ if (this.memory) {
33571
+ const threadId = this.ensureActiveMemoryThread();
33572
+ await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
33573
+ });
33574
+ }
32276
33575
  const start = Date.now();
32277
33576
  let conciergeBody;
32278
33577
  let servedBy = "disabled";
@@ -32289,7 +33588,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32289
33588
  conciergeBody = "Concierge unavailable. The chosen substrate does not support summarization. Pick a different substrate in the Policy center.";
32290
33589
  outcome = "substrate_disabled";
32291
33590
  } else {
32292
- const context = await this.assembleConciergeContext();
33591
+ const context = await this.assembleConciergeContext(priorTurns);
32293
33592
  const response = await this.substrateSelector.invokeSummarize(
32294
33593
  "concierge",
32295
33594
  {
@@ -32328,6 +33627,15 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32328
33627
  CONCIERGE_THREAD_KEY,
32329
33628
  responseMessage
32330
33629
  );
33630
+ let assistantTurnId;
33631
+ if (this.memory) {
33632
+ const threadId = this.ensureActiveMemoryThread();
33633
+ const persisted = await this.memory.appendTurn(threadId, "assistant", conciergeBody).catch(() => void 0);
33634
+ if (persisted) assistantTurnId = persisted.turn_id;
33635
+ }
33636
+ if (this.memory && activeThreadIdForRound) {
33637
+ this.lastInteractionAt = nowMs;
33638
+ }
32331
33639
  const payload = {
32332
33640
  version: "1.2",
32333
33641
  event_id: makeEventId("conc"),
@@ -32339,7 +33647,12 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32339
33647
  response_hash: outcome === "ok" ? hashOf(conciergeBody) : null,
32340
33648
  substrate: servedBy,
32341
33649
  latency_ms: latencyMs,
32342
- outcome
33650
+ outcome,
33651
+ ...activeThreadIdForRound !== void 0 ? { thread_id: activeThreadIdForRound } : {},
33652
+ ...assistantTurnId !== void 0 ? { turn_index: assistantTurnId } : {},
33653
+ ...this.memory ? {
33654
+ prior_turns_folded: memoryReadFailureReason === null ? priorTurns.length : 0
33655
+ } : {}
32343
33656
  };
32344
33657
  this.emit(OPERATOR_CHAT_OPS.CONCIERGE_CHAT, payload, outcome === "ok" ? "success" : "failure");
32345
33658
  return {
@@ -32349,6 +33662,25 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32349
33662
  outcome
32350
33663
  };
32351
33664
  }
33665
+ /**
33666
+ * Emit the WP-V1.3-9 Tau-2 graceful-degradation audit event. Pulled
33667
+ * out of `sendConcierge` so the read-fold path stays readable. Emits
33668
+ * with `result: "failure"` since the concierge fell back to
33669
+ * single-turn mode for this round-trip.
33670
+ */
33671
+ emitMemoryReadFailed(threadId, reason) {
33672
+ const payload = {
33673
+ version: "1.2",
33674
+ event_id: makeEventId("conc-memfail"),
33675
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33676
+ identity_id: this.identityId,
33677
+ kind: "operator_concierge_memory_read_failed",
33678
+ surface: "concierge",
33679
+ thread_id: threadId,
33680
+ failure_reason: reason
33681
+ };
33682
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_MEMORY_READ_FAILED, payload, "failure");
33683
+ }
32352
33684
  /**
32353
33685
  * Read the persisted concierge thread, oldest message first. Returns
32354
33686
  * an empty array when no thread exists yet.
@@ -32360,6 +33692,105 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32360
33692
  );
32361
33693
  return thread ? thread.messages : [];
32362
33694
  }
33695
+ // ── WP-V1.3-9 Tau-1 memory accessors ─────────────────────────────────
33696
+ /**
33697
+ * Whether the foundation memory store is wired. Routes use this to
33698
+ * 503 cleanly when called against an unwired service.
33699
+ */
33700
+ hasConciergeMemory() {
33701
+ return this.memory !== void 0;
33702
+ }
33703
+ /**
33704
+ * List concierge memory threads, newest-first. Emits the
33705
+ * `operator_concierge_history_read` audit event with `thread_id="*"`.
33706
+ */
33707
+ async listConciergeMemoryThreads(opts) {
33708
+ if (!this.memory) {
33709
+ throw new Error("concierge memory store not configured");
33710
+ }
33711
+ const summaries = await this.memory.listThreads(opts);
33712
+ const totalTurns = summaries.reduce((acc, s) => acc + s.turn_count, 0);
33713
+ const payload = {
33714
+ version: "1.2",
33715
+ event_id: makeEventId("conc-hist"),
33716
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33717
+ identity_id: this.identityId,
33718
+ kind: "operator_concierge_history_read",
33719
+ surface: "concierge",
33720
+ thread_id: "*",
33721
+ turn_count: totalTurns
33722
+ };
33723
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
33724
+ return summaries;
33725
+ }
33726
+ /**
33727
+ * Read a concierge memory thread, oldest turn first. Emits the
33728
+ * `operator_concierge_history_read` audit event with the named
33729
+ * thread_id and the count of turns surfaced.
33730
+ */
33731
+ async readConciergeMemoryThread(threadId, opts) {
33732
+ if (!this.memory) {
33733
+ throw new Error("concierge memory store not configured");
33734
+ }
33735
+ const turns = await this.memory.readThread(threadId, opts);
33736
+ const payload = {
33737
+ version: "1.2",
33738
+ event_id: makeEventId("conc-hist"),
33739
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33740
+ identity_id: this.identityId,
33741
+ kind: "operator_concierge_history_read",
33742
+ surface: "concierge",
33743
+ thread_id: threadId,
33744
+ turn_count: turns.length
33745
+ };
33746
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
33747
+ return turns;
33748
+ }
33749
+ /**
33750
+ * Delete a concierge memory thread. Emits
33751
+ * `operator_concierge_thread_deleted` only when a bundle was actually
33752
+ * removed; absent threads return false without an audit event.
33753
+ */
33754
+ async deleteConciergeMemoryThread(threadId) {
33755
+ if (!this.memory) {
33756
+ throw new Error("concierge memory store not configured");
33757
+ }
33758
+ const turnsBefore = await this.memory.readThread(threadId);
33759
+ if (turnsBefore.length === 0) {
33760
+ return await this.memory.deleteThread(threadId);
33761
+ }
33762
+ const removed = await this.memory.deleteThread(threadId);
33763
+ if (!removed) return false;
33764
+ if (this.activeMemoryThreadId === threadId) {
33765
+ this.activeMemoryThreadId = void 0;
33766
+ }
33767
+ const payload = {
33768
+ version: "1.2",
33769
+ event_id: makeEventId("conc-del"),
33770
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33771
+ identity_id: this.identityId,
33772
+ kind: "operator_concierge_thread_deleted",
33773
+ surface: "concierge",
33774
+ thread_id: threadId,
33775
+ turn_count: turnsBefore.length
33776
+ };
33777
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_THREAD_DELETED, payload, "success");
33778
+ return true;
33779
+ }
33780
+ /**
33781
+ * Reset the active session memory thread. Subsequent sendConcierge
33782
+ * calls allocate a fresh thread_id. Surfaced for tests + future "new
33783
+ * conversation" affordance; not currently called by the dashboard.
33784
+ */
33785
+ resetConciergeMemoryThread() {
33786
+ this.activeMemoryThreadId = void 0;
33787
+ }
33788
+ ensureActiveMemoryThread() {
33789
+ if (!this.activeMemoryThreadId) {
33790
+ this.activeMemoryThreadId = crypto.randomUUID();
33791
+ }
33792
+ return this.activeMemoryThreadId;
33793
+ }
32363
33794
  /**
32364
33795
  * Stitch fortress state into a single context blob the substrate
32365
33796
  * folds into its summarization prompt.
@@ -32372,6 +33803,11 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32372
33803
  * ## Sanctuary reference
32373
33804
  * <static domain reference block>
32374
33805
  *
33806
+ * ## Prior conversation ← WP-V1.3-9 Tau-2, when present
33807
+ * OPERATOR: ...
33808
+ * CONCIERGE: ...
33809
+ * ---
33810
+ *
32375
33811
  * ## Recent activity
32376
33812
  * <recentActivity output>
32377
33813
  *
@@ -32381,37 +33817,69 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32381
33817
  * ## Open inbox
32382
33818
  * <openInbox output>
32383
33819
  * ```
33820
+ *
33821
+ * The substrate selector ships a `context: string` shape (not a
33822
+ * messages array), so multi-turn coherence is folded as a structured
33823
+ * prior-conversation section with explicit OPERATOR / CONCIERGE
33824
+ * boundaries. Coordinator-CTO guidance: prefer messages-array shape
33825
+ * if available; the v1.2 selector does not expose one, so structured
33826
+ * serialization is the canonical path for v1.3.
32384
33827
  */
32385
- async assembleConciergeContext() {
33828
+ async assembleConciergeContext(priorTurns = []) {
32386
33829
  const ref = `## Sanctuary reference
32387
33830
  ${SANCTUARY_DOMAIN_REFERENCE}`;
33831
+ const priorSection = this.formatPriorTurnsSection(priorTurns);
32388
33832
  if (!this.contextProviders) {
32389
- return `${ref}
32390
-
32391
- ## Recent activity
32392
- (no providers wired)
32393
-
32394
- ## Wrapped agents
32395
- (no providers wired)
32396
-
32397
- ## Open inbox
32398
- (no providers wired)`;
33833
+ return [
33834
+ ref,
33835
+ ...priorSection ? [priorSection] : [],
33836
+ "## Recent activity\n(no providers wired)",
33837
+ "## Wrapped agents\n(no providers wired)",
33838
+ "## Open inbox\n(no providers wired)"
33839
+ ].join("\n\n");
32399
33840
  }
32400
33841
  const [activity, agents, inbox] = await Promise.all([
32401
33842
  this.contextProviders.recentActivity(),
32402
33843
  this.contextProviders.agentInventory(),
32403
33844
  this.contextProviders.openInbox()
32404
33845
  ]);
32405
- return `${ref}
32406
-
32407
- ## Recent activity
32408
- ${activity}
32409
-
32410
- ## Wrapped agents
32411
- ${agents}
32412
-
32413
- ## Open inbox
32414
- ${inbox}`;
33846
+ return [
33847
+ ref,
33848
+ ...priorSection ? [priorSection] : [],
33849
+ `## Recent activity
33850
+ ${activity}`,
33851
+ `## Wrapped agents
33852
+ ${agents}`,
33853
+ `## Open inbox
33854
+ ${inbox}`
33855
+ ].join("\n\n");
33856
+ }
33857
+ /**
33858
+ * Render the prior-conversation section with token-budget enforcement
33859
+ * (WP-V1.3-9 Tau-2). Drops oldest turns first when the rendered
33860
+ * section exceeds `historyTokenBudget`. Returns an empty string when
33861
+ * the input is empty or when the budget excludes every turn.
33862
+ */
33863
+ formatPriorTurnsSection(turns) {
33864
+ if (turns.length === 0) return "";
33865
+ const HEADER = "## Prior conversation";
33866
+ const lines = turns.map(formatPriorTurnLine);
33867
+ const headerTokens = approxTokenLen(`${HEADER}
33868
+ `);
33869
+ const sepTokens = approxTokenLen("\n");
33870
+ let runningTokens = headerTokens;
33871
+ let runningLines = [];
33872
+ for (let i = lines.length - 1; i >= 0; i--) {
33873
+ const line = lines[i];
33874
+ const tokens = approxTokenLen(line) + (runningLines.length > 0 ? sepTokens : 0);
33875
+ if (runningTokens + tokens > this.historyTokenBudget) break;
33876
+ runningTokens += tokens;
33877
+ runningLines.push(line);
33878
+ }
33879
+ if (runningLines.length === 0) return "";
33880
+ runningLines = runningLines.reverse();
33881
+ return `${HEADER}
33882
+ ${runningLines.join("\n")}`;
32415
33883
  }
32416
33884
  // ── audit helpers ────────────────────────────────────────────────────
32417
33885
  emit(operation, payload, result) {
@@ -32526,11 +33994,309 @@ var init_operator_chat_store = __esm({
32526
33994
  }
32527
33995
  });
32528
33996
 
33997
+ // src/chat/concierge-memory-store.ts
33998
+ function bundleKey(threadId) {
33999
+ return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
34000
+ }
34001
+ function stripKeyPrefix(key) {
34002
+ if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
34003
+ return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
34004
+ }
34005
+ function lastTurnId(bundle) {
34006
+ let max = 0;
34007
+ for (const t of bundle.turns) {
34008
+ if (t.turn_id > max) max = t.turn_id;
34009
+ }
34010
+ return max;
34011
+ }
34012
+ var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO2, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES2, ConciergeMemoryStore;
34013
+ var init_concierge_memory_store = __esm({
34014
+ "src/chat/concierge-memory-store.ts"() {
34015
+ init_encryption();
34016
+ init_key_derivation();
34017
+ init_encoding();
34018
+ CONCIERGE_MEMORY_NAMESPACE = "_chat";
34019
+ CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
34020
+ HKDF_INFO2 = "concierge-memory-store-v1";
34021
+ DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
34022
+ MAX_BUNDLE_BYTES2 = 4 * 1024 * 1024;
34023
+ ConciergeMemoryStore = class {
34024
+ storage;
34025
+ encryptionKey;
34026
+ fortressId;
34027
+ retentionDays;
34028
+ locks;
34029
+ constructor(opts) {
34030
+ this.storage = opts.storage;
34031
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
34032
+ this.fortressId = opts.fortressId;
34033
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
34034
+ this.locks = /* @__PURE__ */ new Map();
34035
+ }
34036
+ /**
34037
+ * Append a turn to the named thread, creating the bundle if no record
34038
+ * exists. Returns the persisted turn (with assigned turn_id +
34039
+ * retention_until). Per-thread serialisation guarantees turn_id
34040
+ * monotonicity even under concurrent callers.
34041
+ */
34042
+ async appendTurn(threadId, role, content) {
34043
+ return this.withLock(threadId, async () => {
34044
+ const bundle = await this.loadBundle(threadId) ?? null;
34045
+ const now = /* @__PURE__ */ new Date();
34046
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
34047
+ const retentionUntil = new Date(now.getTime() + retentionMs);
34048
+ const nextTurnId = bundle ? lastTurnId(bundle) + 1 : 1;
34049
+ const turn = {
34050
+ thread_id: threadId,
34051
+ fortress_id: this.fortressId,
34052
+ turn_id: nextTurnId,
34053
+ role,
34054
+ content,
34055
+ created_at: now.toISOString(),
34056
+ retention_until: retentionUntil.toISOString()
34057
+ };
34058
+ const next = bundle ? { ...bundle, turns: [...bundle.turns, turn] } : {
34059
+ version: 1,
34060
+ thread_id: threadId,
34061
+ fortress_id: this.fortressId,
34062
+ created_at: now.toISOString(),
34063
+ turns: [turn]
34064
+ };
34065
+ await this.saveBundle(next);
34066
+ return turn;
34067
+ });
34068
+ }
34069
+ /**
34070
+ * Read turns from a thread, oldest-first. Returns an empty array if
34071
+ * the thread does not exist or its bundle is corrupt. Does not emit
34072
+ * audit events; the caller (HTTP route handler) owns audit semantics.
34073
+ */
34074
+ async readThread(threadId, opts) {
34075
+ const bundle = await this.loadBundle(threadId);
34076
+ if (!bundle) return [];
34077
+ let turns = bundle.turns;
34078
+ if (opts?.sinceTurnId !== void 0) {
34079
+ const cutoff = opts.sinceTurnId;
34080
+ turns = turns.filter((t) => t.turn_id > cutoff);
34081
+ }
34082
+ if (opts?.limit !== void 0) {
34083
+ turns = turns.slice(0, opts.limit);
34084
+ }
34085
+ return turns;
34086
+ }
34087
+ /**
34088
+ * Read turns with explicit failure surfacing (WP-V1.3-9 Tau-2). Where
34089
+ * `readThread` collapses every failure mode to an empty array, this
34090
+ * variant returns a discriminated result so the multi-turn fold path
34091
+ * can degrade cleanly + emit `operator_concierge_memory_read_failed`
34092
+ * with a concrete cause.
34093
+ *
34094
+ * - No bundle on disk → `{ ok: true, turns: [] }` (a fresh thread).
34095
+ * - Bundle present, decode + decrypt + schema check pass → ok with turns.
34096
+ * - Bundle present, oversize → `{ ok: false, reason: "oversize_bundle" }`.
34097
+ * - Bundle present, decryption fails → `{ ok: false, reason: "decrypt_failed" }`.
34098
+ * - Bundle present, schema mismatch (version / thread_id) → `schema_mismatch`.
34099
+ * - Storage IO error → `io_failed`.
34100
+ */
34101
+ async readThreadStrict(threadId, opts) {
34102
+ const key = bundleKey(threadId);
34103
+ let raw;
34104
+ try {
34105
+ raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
34106
+ } catch {
34107
+ return { ok: false, reason: "io_failed" };
34108
+ }
34109
+ if (!raw) return { ok: true, turns: [] };
34110
+ if (raw.length > MAX_BUNDLE_BYTES2) {
34111
+ return { ok: false, reason: "oversize_bundle" };
34112
+ }
34113
+ let envelope;
34114
+ try {
34115
+ envelope = JSON.parse(bytesToString(raw));
34116
+ } catch {
34117
+ return { ok: false, reason: "schema_mismatch" };
34118
+ }
34119
+ let plaintext;
34120
+ try {
34121
+ const aad = stringToBytes(threadId);
34122
+ plaintext = decrypt(envelope, this.encryptionKey, aad);
34123
+ } catch {
34124
+ return { ok: false, reason: "decrypt_failed" };
34125
+ }
34126
+ let parsed;
34127
+ try {
34128
+ parsed = JSON.parse(bytesToString(plaintext));
34129
+ } catch {
34130
+ return { ok: false, reason: "schema_mismatch" };
34131
+ }
34132
+ if (parsed.version !== 1) return { ok: false, reason: "schema_mismatch" };
34133
+ if (parsed.thread_id !== threadId) {
34134
+ return { ok: false, reason: "schema_mismatch" };
34135
+ }
34136
+ let turns = parsed.turns;
34137
+ if (opts?.sinceTurnId !== void 0) {
34138
+ const cutoff = opts.sinceTurnId;
34139
+ turns = turns.filter((t) => t.turn_id > cutoff);
34140
+ }
34141
+ if (opts?.limit !== void 0) {
34142
+ turns = turns.slice(0, opts.limit);
34143
+ }
34144
+ return { ok: true, turns };
34145
+ }
34146
+ /**
34147
+ * Enumerate concierge threads in this fortress with summary metadata.
34148
+ * Sorted newest-first by last_turn_at.
34149
+ */
34150
+ async listThreads(opts) {
34151
+ const entries = await this.storage.list(
34152
+ CONCIERGE_MEMORY_NAMESPACE,
34153
+ CONCIERGE_MEMORY_KEY_PREFIX
34154
+ );
34155
+ const summaries = [];
34156
+ for (const meta of entries) {
34157
+ const threadId = stripKeyPrefix(meta.key);
34158
+ if (threadId === null) continue;
34159
+ const bundle = await this.loadBundle(threadId);
34160
+ if (!bundle || bundle.turns.length === 0) continue;
34161
+ const last = bundle.turns[bundle.turns.length - 1];
34162
+ summaries.push({
34163
+ thread_id: bundle.thread_id,
34164
+ created_at: bundle.created_at,
34165
+ last_turn_at: last ? last.created_at : bundle.created_at,
34166
+ turn_count: bundle.turns.length
34167
+ });
34168
+ }
34169
+ summaries.sort(
34170
+ (a, b) => a.last_turn_at < b.last_turn_at ? 1 : a.last_turn_at > b.last_turn_at ? -1 : 0
34171
+ );
34172
+ if (opts?.limit !== void 0) {
34173
+ return summaries.slice(0, opts.limit);
34174
+ }
34175
+ return summaries;
34176
+ }
34177
+ /**
34178
+ * Delete a thread's bundle. Returns true if the bundle existed and
34179
+ * was removed; false if no bundle was present. Audit emission is the
34180
+ * caller's responsibility.
34181
+ */
34182
+ async deleteThread(threadId) {
34183
+ const key = bundleKey(threadId);
34184
+ return this.withLock(threadId, async () => {
34185
+ const existed = await this.storage.exists(
34186
+ CONCIERGE_MEMORY_NAMESPACE,
34187
+ key
34188
+ );
34189
+ if (!existed) return false;
34190
+ try {
34191
+ await this.storage.delete(CONCIERGE_MEMORY_NAMESPACE, key);
34192
+ } catch {
34193
+ return false;
34194
+ }
34195
+ return true;
34196
+ });
34197
+ }
34198
+ /**
34199
+ * Drop expired turns across all threads. Threads emptied by pruning
34200
+ * are removed entirely. Returns the count of turns pruned.
34201
+ */
34202
+ async pruneExpired(now) {
34203
+ const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
34204
+ const entries = await this.storage.list(
34205
+ CONCIERGE_MEMORY_NAMESPACE,
34206
+ CONCIERGE_MEMORY_KEY_PREFIX
34207
+ );
34208
+ let pruned = 0;
34209
+ for (const meta of entries) {
34210
+ const threadId = stripKeyPrefix(meta.key);
34211
+ if (threadId === null) continue;
34212
+ pruned += await this.withLock(threadId, async () => {
34213
+ const bundle = await this.loadBundle(threadId);
34214
+ if (!bundle) return 0;
34215
+ const kept = bundle.turns.filter((t) => t.retention_until > cutoff);
34216
+ const dropped = bundle.turns.length - kept.length;
34217
+ if (dropped === 0) return 0;
34218
+ if (kept.length === 0) {
34219
+ await this.storage.delete(
34220
+ CONCIERGE_MEMORY_NAMESPACE,
34221
+ bundleKey(threadId)
34222
+ );
34223
+ } else {
34224
+ await this.saveBundle({ ...bundle, turns: kept });
34225
+ }
34226
+ return dropped;
34227
+ });
34228
+ }
34229
+ return { pruned };
34230
+ }
34231
+ // ── internals ────────────────────────────────────────────────────────
34232
+ async loadBundle(threadId) {
34233
+ const key = bundleKey(threadId);
34234
+ let raw;
34235
+ try {
34236
+ raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
34237
+ } catch {
34238
+ return null;
34239
+ }
34240
+ if (!raw) return null;
34241
+ if (raw.length > MAX_BUNDLE_BYTES2) return null;
34242
+ try {
34243
+ const envelope = JSON.parse(bytesToString(raw));
34244
+ const aad = stringToBytes(threadId);
34245
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
34246
+ const parsed = JSON.parse(
34247
+ bytesToString(plaintext)
34248
+ );
34249
+ if (parsed.version !== 1) return null;
34250
+ if (parsed.thread_id !== threadId) return null;
34251
+ return parsed;
34252
+ } catch {
34253
+ return null;
34254
+ }
34255
+ }
34256
+ async saveBundle(bundle) {
34257
+ const key = bundleKey(bundle.thread_id);
34258
+ const aad = stringToBytes(bundle.thread_id);
34259
+ const plaintext = stringToBytes(JSON.stringify(bundle));
34260
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
34261
+ await this.storage.write(
34262
+ CONCIERGE_MEMORY_NAMESPACE,
34263
+ key,
34264
+ stringToBytes(JSON.stringify(envelope))
34265
+ );
34266
+ }
34267
+ /**
34268
+ * Run `task` while holding the per-thread async lock. Lock is released
34269
+ * once the task settles (success or failure). Generic helper so
34270
+ * appendTurn / deleteThread / pruneExpired share serialisation.
34271
+ */
34272
+ async withLock(threadId, task) {
34273
+ const previous = this.locks.get(threadId) ?? Promise.resolve();
34274
+ let release;
34275
+ const next = new Promise((resolve8) => {
34276
+ release = resolve8;
34277
+ });
34278
+ const chained = previous.then(() => next);
34279
+ this.locks.set(threadId, chained);
34280
+ try {
34281
+ await previous;
34282
+ return await task();
34283
+ } finally {
34284
+ release();
34285
+ if (this.locks.get(threadId) === chained) {
34286
+ this.locks.delete(threadId);
34287
+ }
34288
+ }
34289
+ }
34290
+ };
34291
+ }
34292
+ });
34293
+
32529
34294
  // src/chat/operator-chat-index.ts
32530
34295
  var init_operator_chat_index = __esm({
32531
34296
  "src/chat/operator-chat-index.ts"() {
32532
34297
  init_operator_chat_service();
32533
34298
  init_operator_chat_store();
34299
+ init_concierge_memory_store();
32534
34300
  init_operator_chat_audit_events();
32535
34301
  init_operator_chat_types();
32536
34302
  }
@@ -32544,6 +34310,14 @@ function buildV11Bindings(inputs) {
32544
34310
  let operatorChatService;
32545
34311
  if (inputs.storage && inputs.masterKey) {
32546
34312
  const chatStore = new OperatorChatStore(inputs.storage, inputs.masterKey);
34313
+ const conciergeMemory = new ConciergeMemoryStore({
34314
+ storage: inputs.storage,
34315
+ masterKey: inputs.masterKey,
34316
+ fortressId: inputs.fortressId,
34317
+ ...inputs.conciergeMemoryRetentionDays !== void 0 ? { retentionDays: inputs.conciergeMemoryRetentionDays } : {}
34318
+ });
34319
+ void conciergeMemory.pruneExpired().catch(() => {
34320
+ });
32547
34321
  operatorChatService = new OperatorChatService({
32548
34322
  store: chatStore,
32549
34323
  auditLog: inputs.auditLog,
@@ -32554,7 +34328,8 @@ function buildV11Bindings(inputs) {
32554
34328
  identityId: inputs.identityId,
32555
34329
  registry
32556
34330
  }),
32557
- conciergePiiFilter: buildConciergePiiFilter()
34331
+ conciergePiiFilter: buildConciergePiiFilter(),
34332
+ conciergeMemory
32558
34333
  });
32559
34334
  }
32560
34335
  const hubService = new HubService({
@@ -32789,7 +34564,7 @@ var init_defaults = __esm({
32789
34564
  });
32790
34565
 
32791
34566
  // src/intelligence/policy-store.ts
32792
- var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO2, IntelligenceConfigStore;
34567
+ var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO3, IntelligenceConfigStore;
32793
34568
  var init_policy_store = __esm({
32794
34569
  "src/intelligence/policy-store.ts"() {
32795
34570
  init_encryption();
@@ -32798,13 +34573,13 @@ var init_policy_store = __esm({
32798
34573
  init_defaults();
32799
34574
  INTELLIGENCE_NAMESPACE = "_intelligence";
32800
34575
  SUBSTRATE_CONFIG_KEY = "substrate-config";
32801
- HKDF_INFO2 = "intelligence-substrate-config";
34576
+ HKDF_INFO3 = "intelligence-substrate-config";
32802
34577
  IntelligenceConfigStore = class {
32803
34578
  storage;
32804
34579
  encryptionKey;
32805
34580
  constructor(storage, masterKey) {
32806
34581
  this.storage = storage;
32807
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
34582
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
32808
34583
  }
32809
34584
  /**
32810
34585
  * Load the operator's substrate config from disk. Returns the config
@@ -34826,7 +36601,9 @@ async function verifyExitBundle(bundleDir, options = {}) {
34826
36601
  );
34827
36602
  }
34828
36603
  }
34829
- const reputationFailed = reputation?.bundle_signature_valid === false || (reputation?.invalid_attestations ?? 0) > 0;
36604
+ const reputationBundleFailed = reputation?.bundle_signature_valid === false;
36605
+ const reputationAttestationFailed = (reputation?.invalid_attestations ?? 0) > 0;
36606
+ const reputationFailed = reputationBundleFailed || reputationAttestationFailed;
34830
36607
  const identityFailed = identity ? !identity.signature_valid : false;
34831
36608
  const unverifiableCount = reputation?.unverifiable_attestations ?? 0;
34832
36609
  const unverifiableFailed = unverifiableCount > 0 && !options.acceptUnverifiableAttestations;
@@ -34835,6 +36612,16 @@ async function verifyExitBundle(bundleDir, options = {}) {
34835
36612
  `${unverifiableCount} reputation attestation(s) have unknown signer public keys; pass --accept-unverifiable-attestations to import anyway`
34836
36613
  );
34837
36614
  }
36615
+ let detailedFailureClass;
36616
+ if (identityFailed) {
36617
+ detailedFailureClass = "identity_signature_invalid";
36618
+ } else if (reputationBundleFailed) {
36619
+ detailedFailureClass = "reputation_bundle_signature_invalid";
36620
+ } else if (reputationAttestationFailed) {
36621
+ detailedFailureClass = "reputation_attestation_signature_invalid";
36622
+ } else if (unverifiableFailed) {
36623
+ detailedFailureClass = "reputation_unverifiable_attestations";
36624
+ }
34838
36625
  return {
34839
36626
  version: "1.1",
34840
36627
  passed: !reputationFailed && !identityFailed && !unverifiableFailed,
@@ -34854,7 +36641,7 @@ async function verifyExitBundle(bundleDir, options = {}) {
34854
36641
  identity,
34855
36642
  audit,
34856
36643
  reputation,
34857
- failure_class: reputationFailed || identityFailed || unverifiableFailed ? "other" : void 0
36644
+ failure_class: detailedFailureClass
34858
36645
  };
34859
36646
  }
34860
36647
  var InvalidExitBundleError, PRIVATE_MATERIAL_KEYS;
@@ -35253,7 +37040,7 @@ async function resolveSourceMasterKey(encryptedState, opts) {
35253
37040
  }
35254
37041
  return null;
35255
37042
  }
35256
- async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIdentityId) {
37043
+ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIdentityId, importedRekeyEntries) {
35257
37044
  const destinationSigner = opts.destinationSignerIdentityId ? opts.identityManager.get(opts.destinationSignerIdentityId) : opts.identityManager.getDefault();
35258
37045
  if (!destinationSigner) {
35259
37046
  return {
@@ -35315,8 +37102,9 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
35315
37102
  }
35316
37103
  }
35317
37104
  }
37105
+ let plaintext;
35318
37106
  try {
35319
- const plaintext = decrypt(
37107
+ plaintext = decrypt(
35320
37108
  item.entry.payload,
35321
37109
  deriveNamespaceKey(sourceMasterKey, item.namespace)
35322
37110
  );
@@ -35325,28 +37113,30 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
35325
37113
  skipped++;
35326
37114
  continue;
35327
37115
  }
35328
- await stateStore.write(
35329
- item.namespace,
35330
- item.key,
35331
- bytesToString(plaintext),
35332
- destinationSigner.identity_id,
35333
- destinationSigner.encrypted_private_key,
35334
- identityEncryptionKey,
35335
- {
35336
- content_type: item.entry.metadata.content_type,
35337
- ttl_seconds: item.entry.metadata.ttl_seconds,
35338
- tags: [
35339
- ...item.entry.metadata.tags ?? [],
35340
- "exit-import",
35341
- `source:${item.entry.kid}`
35342
- ]
35343
- }
35344
- );
35345
- imported++;
35346
37116
  } catch {
35347
37117
  skippedInvalidSig++;
35348
37118
  skipped++;
37119
+ continue;
35349
37120
  }
37121
+ await stateStore.write(
37122
+ item.namespace,
37123
+ item.key,
37124
+ bytesToString(plaintext),
37125
+ destinationSigner.identity_id,
37126
+ destinationSigner.encrypted_private_key,
37127
+ identityEncryptionKey,
37128
+ {
37129
+ content_type: item.entry.metadata.content_type,
37130
+ ttl_seconds: item.entry.metadata.ttl_seconds,
37131
+ tags: [
37132
+ ...item.entry.metadata.tags ?? [],
37133
+ "exit-import",
37134
+ `source:${item.entry.kid}`
37135
+ ]
37136
+ }
37137
+ );
37138
+ imported++;
37139
+ importedRekeyEntries?.push({ namespace: item.namespace, key: item.key });
35350
37140
  }
35351
37141
  return {
35352
37142
  status: "rekeyed",
@@ -35357,6 +37147,23 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
35357
37147
  conflicts
35358
37148
  };
35359
37149
  }
37150
+ async function cleanupStagedPaths(storage, staged) {
37151
+ let removed = 0;
37152
+ const failed = [];
37153
+ for (const loc of staged) {
37154
+ try {
37155
+ const ok2 = await storage.delete(loc.namespace, loc.key);
37156
+ if (ok2) {
37157
+ removed++;
37158
+ } else {
37159
+ failed.push(loc);
37160
+ }
37161
+ } catch {
37162
+ failed.push(loc);
37163
+ }
37164
+ }
37165
+ return { removed, failed };
37166
+ }
35360
37167
  async function stageArtifact(storage, namespace, key, value) {
35361
37168
  await storage.write(namespace, key, jsonBytes(value));
35362
37169
  }
@@ -35481,6 +37288,8 @@ async function importExitBundle(opts) {
35481
37288
  }
35482
37289
  const importId = importIdForManifest(manifest);
35483
37290
  const stagedArtifacts = [];
37291
+ const stagedLocations = [];
37292
+ const importedRekeyEntries = [];
35484
37293
  if (identityArtifact) {
35485
37294
  await stageArtifact(
35486
37295
  opts.storage,
@@ -35489,10 +37298,15 @@ async function importExitBundle(opts) {
35489
37298
  identityArtifact.json
35490
37299
  );
35491
37300
  stagedArtifacts.push("public_identity");
37301
+ stagedLocations.push({
37302
+ namespace: EXIT_PUBLIC_IDENTITIES_NAMESPACE,
37303
+ key: identityArtifact.json.bundle.identity_id
37304
+ });
35492
37305
  }
35493
37306
  if (policySet) {
35494
37307
  await stageArtifact(opts.storage, EXIT_POLICY_SETS_NAMESPACE, importId, policySet.json);
35495
37308
  stagedArtifacts.push("policy_set");
37309
+ stagedLocations.push({ namespace: EXIT_POLICY_SETS_NAMESPACE, key: importId });
35496
37310
  }
35497
37311
  if (auditReceipts) {
35498
37312
  await stageArtifact(
@@ -35502,10 +37316,12 @@ async function importExitBundle(opts) {
35502
37316
  auditReceipts.json
35503
37317
  );
35504
37318
  stagedArtifacts.push("audit_receipts");
37319
+ stagedLocations.push({ namespace: EXIT_AUDIT_RECEIPTS_NAMESPACE, key: importId });
35505
37320
  }
35506
37321
  if (commitments) {
35507
37322
  await stageArtifact(opts.storage, EXIT_COMMITMENTS_NAMESPACE, importId, commitments.json);
35508
37323
  stagedArtifacts.push("commitments");
37324
+ stagedLocations.push({ namespace: EXIT_COMMITMENTS_NAMESPACE, key: importId });
35509
37325
  }
35510
37326
  if (placeholderMetadata) {
35511
37327
  await stageArtifact(
@@ -35515,12 +37331,17 @@ async function importExitBundle(opts) {
35515
37331
  placeholderMetadata.json
35516
37332
  );
35517
37333
  stagedArtifacts.push("placeholder_vault_metadata");
37334
+ stagedLocations.push({
37335
+ namespace: EXIT_PLACEHOLDER_METADATA_NAMESPACE,
37336
+ key: importId
37337
+ });
35518
37338
  }
35519
37339
  await stageArtifact(opts.storage, EXIT_IMPORT_NAMESPACE, importId, {
35520
37340
  manifest: manifest.body,
35521
37341
  verified_at: verification.verified_at,
35522
37342
  activated_at: (/* @__PURE__ */ new Date()).toISOString()
35523
37343
  });
37344
+ stagedLocations.push({ namespace: EXIT_IMPORT_NAMESPACE, key: importId });
35524
37345
  const publicKeys = identityArtifact ? publicKeysFromIdentityArtifact(identityArtifact.json) : { byIdentityId: /* @__PURE__ */ new Map(), byDid: /* @__PURE__ */ new Map() };
35525
37346
  let reputationResult = {
35526
37347
  imported_attestations: 0,
@@ -35545,26 +37366,57 @@ async function importExitBundle(opts) {
35545
37366
  encryptedState?.json ?? null,
35546
37367
  opts
35547
37368
  );
35548
- const stateResult = encryptedState && encryptedState.json.entries.length > 0 ? sourceMasterKey ? await rekeyState(
35549
- encryptedState.json,
35550
- opts,
35551
- sourceMasterKey,
35552
- publicKeys.byIdentityId
35553
- ) : {
35554
- status: "staged_requires_source_key",
35555
- imported_keys: 0,
35556
- skipped_keys: encryptedState.json.entries.length,
35557
- skipped_invalid_sig: 0,
35558
- skipped_unknown_kid: 0,
35559
- conflicts: conflicts.state_conflicts.length
35560
- } : {
35561
- status: "not_requested",
35562
- imported_keys: 0,
35563
- skipped_keys: 0,
35564
- skipped_invalid_sig: 0,
35565
- skipped_unknown_kid: 0,
35566
- conflicts: 0
35567
- };
37369
+ let stateResult;
37370
+ try {
37371
+ stateResult = encryptedState && encryptedState.json.entries.length > 0 ? sourceMasterKey ? await rekeyState(
37372
+ encryptedState.json,
37373
+ opts,
37374
+ sourceMasterKey,
37375
+ publicKeys.byIdentityId,
37376
+ importedRekeyEntries
37377
+ ) : {
37378
+ status: "staged_requires_source_key",
37379
+ imported_keys: 0,
37380
+ skipped_keys: encryptedState.json.entries.length,
37381
+ skipped_invalid_sig: 0,
37382
+ skipped_unknown_kid: 0,
37383
+ conflicts: conflicts.state_conflicts.length
37384
+ } : {
37385
+ status: "not_requested",
37386
+ imported_keys: 0,
37387
+ skipped_keys: 0,
37388
+ skipped_invalid_sig: 0,
37389
+ skipped_unknown_kid: 0,
37390
+ conflicts: 0
37391
+ };
37392
+ } catch (err) {
37393
+ const toCleanup = [
37394
+ ...importedRekeyEntries,
37395
+ ...stagedLocations
37396
+ ];
37397
+ const cleanup = await cleanupStagedPaths(opts.storage, toCleanup);
37398
+ opts.auditLog.append(
37399
+ "l1",
37400
+ "exit_bundle_rekey_failed_cleanup",
37401
+ manifest.body.identity_binding.identity_id,
37402
+ {
37403
+ import_id: importId,
37404
+ manifest_version: manifest.body.manifest_version,
37405
+ rekey_entries_removed: importedRekeyEntries.length,
37406
+ staged_artifacts_removed: stagedLocations.length,
37407
+ removed_total: cleanup.removed,
37408
+ cleanup_failed_count: cleanup.failed.length,
37409
+ original_error: err instanceof Error ? err.message : String(err)
37410
+ },
37411
+ "failure"
37412
+ );
37413
+ await opts.auditLog.flush();
37414
+ const originalMessage = err instanceof Error ? err.message : String(err);
37415
+ throw new ExitBundleImportError(
37416
+ "REKEY_FAILED_AND_CLEANED",
37417
+ `Exit-bundle re-key failed: ${originalMessage}. Cleanup removed ${cleanup.removed} of ${toCleanup.length} staged paths (${importedRekeyEntries.length} re-keyed entries plus ${stagedLocations.length} staged artifacts; ${cleanup.failed.length} cleanup deletes failed).`
37418
+ );
37419
+ }
35568
37420
  opts.auditLog.append("l1", "exit_bundle_import_activate", manifest.body.identity_binding.identity_id, {
35569
37421
  import_id: importId,
35570
37422
  manifest_version: manifest.body.manifest_version,
@@ -35822,7 +37674,19 @@ async function runExitCommand(args) {
35822
37674
  }
35823
37675
  const config = await loadConfig();
35824
37676
  const ctx = await openExitContext(argv, env);
35825
- const policy = await loadPrincipalPolicy(ctx.storagePath);
37677
+ let policy;
37678
+ try {
37679
+ policy = await loadPrincipalPolicy(ctx.storagePath);
37680
+ } catch (policyErr) {
37681
+ if (policyErr instanceof MalformedPrincipalPolicyError) {
37682
+ write(err, `
37683
+ Sanctuary cannot proceed.
37684
+ ${policyErr.message}
37685
+ `);
37686
+ return 1;
37687
+ }
37688
+ throw policyErr;
37689
+ }
35826
37690
  const result = await exportExitBundle({
35827
37691
  bundleDir: outDir,
35828
37692
  storage: ctx.storage,
@@ -36522,7 +38386,19 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
36522
38386
  const profileStore = new SovereigntyProfileStore(storage, masterKey);
36523
38387
  await profileStore.load();
36524
38388
  const { tools: profileTools } = createSovereigntyProfileTools(profileStore, auditLog);
36525
- const policy = await loadPrincipalPolicy(config.storage_path);
38389
+ let policy;
38390
+ try {
38391
+ policy = await loadPrincipalPolicy(config.storage_path);
38392
+ } catch (err) {
38393
+ if (err instanceof MalformedPrincipalPolicyError) {
38394
+ console.error(`
38395
+ Sanctuary cannot start.
38396
+ ${err.message}
38397
+ `);
38398
+ process.exit(1);
38399
+ }
38400
+ throw err;
38401
+ }
36526
38402
  const baseline = new BaselineTracker(storage, masterKey);
36527
38403
  await baseline.load();
36528
38404
  let approvalChannel;
@@ -36619,7 +38495,35 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
36619
38495
  timestamp: alert.timestamp
36620
38496
  });
36621
38497
  } : void 0;
36622
- const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
38498
+ const fortressIdForAggregator = fortressIdFromStoragePath(config.storage_path);
38499
+ const aggregatorIdentityId = identityManager.getPrimaryIdentityId() ?? `fortress:${config.storage_path}`;
38500
+ const approvalAggregator = new ApprovalAggregator({
38501
+ storage,
38502
+ masterKey,
38503
+ auditLog,
38504
+ identityId: aggregatorIdentityId,
38505
+ fortressId: fortressIdForAggregator
38506
+ });
38507
+ const wrappedApprovalChannel = new AggregatorBackedChannel({
38508
+ underlying: approvalChannel,
38509
+ aggregator: approvalAggregator,
38510
+ resolveRedirect: makeRedirectResolverFromPolicySupplier(() => policy),
38511
+ replaceModeTimeoutMs: policy.approval_channel.timeout_seconds * 1e3
38512
+ });
38513
+ const gate = new ApprovalGate(
38514
+ policy,
38515
+ baseline,
38516
+ wrappedApprovalChannel,
38517
+ auditLog,
38518
+ injectionDetector,
38519
+ onInjectionAlert
38520
+ );
38521
+ gate.setApprovalEventCallback((event) => {
38522
+ void approvalAggregator.ingest(event);
38523
+ });
38524
+ if (dashboard) {
38525
+ dashboard.setApprovalAggregator(approvalAggregator);
38526
+ }
36623
38527
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
36624
38528
  const { tools: sanctuaryMetaTools } = createSanctuaryTools({
36625
38529
  config,
@@ -36810,6 +38714,8 @@ var init_src = __esm({
36810
38714
  init_dashboard();
36811
38715
  init_webhook();
36812
38716
  init_gate();
38717
+ init_approval_aggregator();
38718
+ init_aggregator_backed_channel();
36813
38719
  init_tools4();
36814
38720
  init_router();
36815
38721
  init_router();
@@ -39774,6 +41680,22 @@ var init_broker = __esm({
39774
41680
  auditLog;
39775
41681
  issuer;
39776
41682
  principalIdentityId;
41683
+ /**
41684
+ * Per-secret-name mutex. Hardening wave 6 finding #64: two concurrent
41685
+ * addSecret() / rotateSecret() / deleteSecret() calls on the same name
41686
+ * MUST serialize cleanly. The keychain backend's `find-then-add` and
41687
+ * `find-then-delete-then-add` shapes (KeychainBackend.addSecret /
41688
+ * .rotateSecret) are not atomic against another caller racing the same
41689
+ * service-name; without serialization the second caller can observe a
41690
+ * stale "exists" check and either drop the new value or leave a
41691
+ * duplicate keychain entry.
41692
+ *
41693
+ * Implementation: an in-memory promise chain per name. Subsequent
41694
+ * callers `await` the chain tail and append their own work; failures
41695
+ * propagate to the failing caller without poisoning the chain for
41696
+ * later callers.
41697
+ */
41698
+ nameLocks = /* @__PURE__ */ new Map();
39777
41699
  constructor(opts) {
39778
41700
  this.backend = opts.backend;
39779
41701
  this.auditLog = opts.auditLog;
@@ -39784,6 +41706,40 @@ var init_broker = __esm({
39784
41706
  grants: opts.grants
39785
41707
  });
39786
41708
  }
41709
+ /**
41710
+ * Serialize `op` against any other in-flight write to the same secret
41711
+ * `name`. Per-name fairness only, distinct names run in parallel.
41712
+ * The current chain tail is used as the acceptance gate; we then
41713
+ * publish a new tail that swallows the operation's outcome so a
41714
+ * thrown error does not poison the next caller's wait.
41715
+ */
41716
+ async withNameLock(name, op) {
41717
+ const previous = this.nameLocks.get(name) ?? Promise.resolve();
41718
+ let release = () => {
41719
+ };
41720
+ const next = new Promise((resolve8) => {
41721
+ release = resolve8;
41722
+ });
41723
+ this.nameLocks.set(name, next);
41724
+ try {
41725
+ await previous.catch(() => {
41726
+ });
41727
+ return await op();
41728
+ } finally {
41729
+ release();
41730
+ if (this.nameLocks.get(name) === next) {
41731
+ this.nameLocks.delete(name);
41732
+ }
41733
+ }
41734
+ }
41735
+ /**
41736
+ * Diagnostic-only: visible for tests so they can assert that distinct
41737
+ * names do not contend on a shared lock. Not part of the public broker
41738
+ * contract; do not consume from production code.
41739
+ */
41740
+ __nameLockCountForTests() {
41741
+ return this.nameLocks.size;
41742
+ }
39787
41743
  /** Ensure backend is initialized and unlocked. Audits the unlock. */
39788
41744
  async ensureUnlocked(passphrase) {
39789
41745
  await this.backend.ensureInitialized(passphrase);
@@ -39796,31 +41752,37 @@ var init_broker = __esm({
39796
41752
  );
39797
41753
  }
39798
41754
  async addSecret(name, value) {
39799
- await this.backend.addSecret(name, value);
39800
- this.auditLog.append(
39801
- "l3",
39802
- BROKER_OPS.SECRET_ADDED,
39803
- this.principalIdentityId,
39804
- { secret: name }
39805
- );
41755
+ await this.withNameLock(name, async () => {
41756
+ await this.backend.addSecret(name, value);
41757
+ this.auditLog.append(
41758
+ "l3",
41759
+ BROKER_OPS.SECRET_ADDED,
41760
+ this.principalIdentityId,
41761
+ { secret: name }
41762
+ );
41763
+ });
39806
41764
  }
39807
41765
  async rotateSecret(name, newValue) {
39808
- await this.backend.rotateSecret(name, newValue);
39809
- this.auditLog.append(
39810
- "l3",
39811
- BROKER_OPS.SECRET_ROTATED,
39812
- this.principalIdentityId,
39813
- { secret: name }
39814
- );
41766
+ await this.withNameLock(name, async () => {
41767
+ await this.backend.rotateSecret(name, newValue);
41768
+ this.auditLog.append(
41769
+ "l3",
41770
+ BROKER_OPS.SECRET_ROTATED,
41771
+ this.principalIdentityId,
41772
+ { secret: name }
41773
+ );
41774
+ });
39815
41775
  }
39816
41776
  async deleteSecret(name) {
39817
- await this.backend.deleteSecret(name);
39818
- this.auditLog.append(
39819
- "l3",
39820
- BROKER_OPS.SECRET_DELETED,
39821
- this.principalIdentityId,
39822
- { secret: name }
39823
- );
41777
+ await this.withNameLock(name, async () => {
41778
+ await this.backend.deleteSecret(name);
41779
+ this.auditLog.append(
41780
+ "l3",
41781
+ BROKER_OPS.SECRET_DELETED,
41782
+ this.principalIdentityId,
41783
+ { secret: name }
41784
+ );
41785
+ });
39824
41786
  }
39825
41787
  async listSecretNames() {
39826
41788
  return this.backend.listSecretNames();
@@ -39860,6 +41822,19 @@ var init_broker = __esm({
39860
41822
  liveTokenCount() {
39861
41823
  return this.issuer.liveTokenCount();
39862
41824
  }
41825
+ /**
41826
+ * Drop expired tokens from the in-memory issuer map. Hardening wave 6
41827
+ * finding #86: previously expiry pruning depended on opportunistic
41828
+ * `pruneExpired()` calls; now the cocoon-unlock initialization path
41829
+ * (openBroker -> after backend.ensureInitialized -> after Broker
41830
+ * construction) fires this once so each cocoon-unlock cycle drops
41831
+ * stale bindings before any operator interaction.
41832
+ *
41833
+ * Returns the number of tokens removed. Safe to call repeatedly; idempotent.
41834
+ */
41835
+ pruneExpiredTokens() {
41836
+ return this.issuer.pruneExpired();
41837
+ }
39863
41838
  /**
39864
41839
  * Audit query restricted to broker-scoped operations. Returns entries
39865
41840
  * with their timestamps, op, and result (never the secret value).
@@ -40018,6 +41993,7 @@ async function openBroker(opts = {}) {
40018
41993
  grants,
40019
41994
  principalIdentityId: opts.principalIdentityId ?? "sanctuary-broker"
40020
41995
  });
41996
+ broker.pruneExpiredTokens();
40021
41997
  return {
40022
41998
  broker,
40023
41999
  close: async () => {
@@ -40886,8 +42862,6 @@ var init_health = __esm({
40886
42862
  DEFAULT_TIMEOUT_MS4 = 500;
40887
42863
  }
40888
42864
  });
40889
-
40890
- // src/cli/agents/cli.ts
40891
42865
  function resolveCtx(args) {
40892
42866
  const env = args.env ?? process.env;
40893
42867
  const discoverOpts = {
@@ -40925,6 +42899,8 @@ async function runAgentsCommand(args) {
40925
42899
  return await cmdShow2(rest, ctx);
40926
42900
  case "status":
40927
42901
  return await cmdStatus(rest, ctx);
42902
+ case "config":
42903
+ return await cmdConfig(rest, ctx);
40928
42904
  default:
40929
42905
  ctx.err.write(`Unknown subcommand: ${sub}
40930
42906
  `);
@@ -40940,10 +42916,18 @@ async function runAgentsCommand(args) {
40940
42916
  }
40941
42917
  function printUsage4(s) {
40942
42918
  s.write(`Usage: sanctuary agents <command> [flags]
42919
+ sanctuary agent <command> [flags] (alias)
40943
42920
 
40944
42921
  list [--json] List every tenant visible on this host.
40945
- show <tenant> [--json] Show details for one tenant.
42922
+ show <tenant> [--json] Show details for one tenant (includes
42923
+ approval-redirect state).
40946
42924
  status [--json] One-line-per-tenant running/stopped summary.
42925
+ config <tenant> [opts] Write tenant principal-policy.yaml fields.
42926
+ --approval-redirect=<bool> Toggle cross-harness inbox redirect.
42927
+ --approval-redirect-mode=<replace|notify>
42928
+ Pick replace (bypass underlying channel)
42929
+ or notify (race both paths). Default
42930
+ replace when toggled on.
40947
42931
 
40948
42932
  Options:
40949
42933
  --fortress <path> Scope discovery to a specific storage path
@@ -41045,6 +43029,7 @@ async function cmdShow2(argv, ctx) {
41045
43029
  return 1;
41046
43030
  }
41047
43031
  const probe = await ctx.probe(tenant);
43032
+ const approvalRedirect = await readApprovalRedirectState(tenant);
41048
43033
  const payload = {
41049
43034
  name: tenant.name,
41050
43035
  storage_path: tenant.storage_path,
@@ -41058,7 +43043,8 @@ async function cmdShow2(argv, ctx) {
41058
43043
  running: probe.running,
41059
43044
  status: probe.status,
41060
43045
  reason: probe.reason
41061
- }
43046
+ },
43047
+ approval_redirect: approvalRedirect
41062
43048
  };
41063
43049
  if (hasJsonFlag(argv)) {
41064
43050
  ctx.out.write(JSON.stringify(payload, null, 2) + "\n");
@@ -41104,8 +43090,171 @@ async function cmdShow2(argv, ctx) {
41104
43090
  `probe: ${probe.running ? "running" : "not-running"}${probe.reason ? ` (${probe.reason})` : ""}
41105
43091
  `
41106
43092
  );
43093
+ ctx.out.write(
43094
+ `approval_redirect: ${approvalRedirect.enabled ? `on (${approvalRedirect.mode})` : "off"}
43095
+ `
43096
+ );
43097
+ return 0;
43098
+ }
43099
+ async function readApprovalRedirectState(tenant) {
43100
+ const policyPath = path.join(tenant.storage_path, "principal-policy.yaml");
43101
+ try {
43102
+ const content = await promises.readFile(policyPath, "utf-8");
43103
+ const parsed = parsePolicy(content);
43104
+ const cfg = parsed.approval_redirect;
43105
+ if (!cfg) return { enabled: false, mode: "replace" };
43106
+ return {
43107
+ enabled: !!cfg.enabled,
43108
+ mode: cfg.mode === "notify" ? "notify" : "replace"
43109
+ };
43110
+ } catch {
43111
+ return { enabled: false, mode: "replace" };
43112
+ }
43113
+ }
43114
+ function parseBoolFlag(raw) {
43115
+ if (raw === void 0) return null;
43116
+ const v = raw.toLowerCase();
43117
+ if (v === "true" || v === "yes" || v === "on" || v === "1") return true;
43118
+ if (v === "false" || v === "no" || v === "off" || v === "0") return false;
43119
+ return null;
43120
+ }
43121
+ function findFlagValue(argv, name) {
43122
+ for (let i = 0; i < argv.length; i++) {
43123
+ const a = argv[i];
43124
+ if (a === name) {
43125
+ return argv[i + 1];
43126
+ }
43127
+ const eq = `${name}=`;
43128
+ if (a.startsWith(eq)) {
43129
+ return a.slice(eq.length);
43130
+ }
43131
+ }
43132
+ return void 0;
43133
+ }
43134
+ async function cmdConfig(argv, ctx) {
43135
+ const positional = argv.find((a) => !a.startsWith("--"));
43136
+ if (!positional) {
43137
+ ctx.err.write(
43138
+ "Missing tenant. Usage: sanctuary agents config <tenant> --approval-redirect=<bool>\n"
43139
+ );
43140
+ return 2;
43141
+ }
43142
+ const tenant = await findTenant(positional, ctx.discoverOpts);
43143
+ if (!tenant) {
43144
+ ctx.err.write(`sanctuary agents: unknown tenant "${positional}"
43145
+ `);
43146
+ return 1;
43147
+ }
43148
+ const redirectFlag = parseBoolFlag(
43149
+ findFlagValue(argv, "--approval-redirect")
43150
+ );
43151
+ const modeFlag = findFlagValue(argv, "--approval-redirect-mode");
43152
+ if (redirectFlag === null && modeFlag === void 0) {
43153
+ ctx.err.write(
43154
+ "sanctuary agents config: nothing to do. Pass --approval-redirect=<bool> or --approval-redirect-mode=<replace|notify>.\n"
43155
+ );
43156
+ return 2;
43157
+ }
43158
+ if (modeFlag !== void 0 && modeFlag !== "replace" && modeFlag !== "notify") {
43159
+ ctx.err.write(
43160
+ `sanctuary agents config: --approval-redirect-mode must be "replace" or "notify" (got "${modeFlag}")
43161
+ `
43162
+ );
43163
+ return 2;
43164
+ }
43165
+ const current = await readApprovalRedirectState(tenant);
43166
+ const next = {
43167
+ enabled: redirectFlag !== null ? redirectFlag : current.enabled,
43168
+ mode: modeFlag === "notify" || modeFlag === "replace" ? modeFlag : current.mode
43169
+ };
43170
+ await writeApprovalRedirectToPolicyFile(tenant.storage_path, next);
43171
+ if (hasJsonFlag(argv)) {
43172
+ ctx.out.write(
43173
+ JSON.stringify(
43174
+ {
43175
+ tenant: tenant.name,
43176
+ approval_redirect: next
43177
+ },
43178
+ null,
43179
+ 2
43180
+ ) + "\n"
43181
+ );
43182
+ } else {
43183
+ ctx.out.write(
43184
+ `sanctuary agents config: tenant "${tenant.name}" approval_redirect=${next.enabled ? `on (${next.mode})` : "off"}
43185
+ `
43186
+ );
43187
+ ctx.out.write(
43188
+ ` Takes effect on the next gate request for the running server.
43189
+ `
43190
+ );
43191
+ }
41107
43192
  return 0;
41108
43193
  }
43194
+ async function writeApprovalRedirectToPolicyFile(storagePath, state) {
43195
+ const policyPath = path.join(storagePath, "principal-policy.yaml");
43196
+ let content;
43197
+ try {
43198
+ content = await promises.readFile(policyPath, "utf-8");
43199
+ } catch (err) {
43200
+ const code = err?.code;
43201
+ if (code !== "ENOENT") throw err;
43202
+ content = await defaultPolicyTextForBootstrap();
43203
+ }
43204
+ const block = renderApprovalRedirectBlock(state);
43205
+ const updated = upsertApprovalRedirectBlock(content, block);
43206
+ await promises.writeFile(policyPath, updated, "utf-8");
43207
+ await promises.chmod(policyPath, 384);
43208
+ }
43209
+ function renderApprovalRedirectBlock(state) {
43210
+ return [
43211
+ "# Approval Redirect (v1.3 WP-V1.3-10 Upsilon-2)",
43212
+ "approval_redirect:",
43213
+ ` enabled: ${state.enabled ? "true" : "false"}`,
43214
+ ` mode: ${state.mode}`
43215
+ ].join("\n");
43216
+ }
43217
+ function upsertApprovalRedirectBlock(content, block) {
43218
+ const lines = content.split("\n");
43219
+ const startIdx = lines.findIndex((l) => l.startsWith("approval_redirect:"));
43220
+ if (startIdx === -1) {
43221
+ const trimmed = content.endsWith("\n") ? content : content + "\n";
43222
+ return trimmed + "\n" + block + "\n";
43223
+ }
43224
+ let blockStart = startIdx;
43225
+ if (blockStart > 0 && lines[blockStart - 1] !== void 0 && lines[blockStart - 1].startsWith("# Approval Redirect")) {
43226
+ blockStart = blockStart - 1;
43227
+ }
43228
+ let blockEnd = startIdx + 1;
43229
+ while (blockEnd < lines.length) {
43230
+ const l = lines[blockEnd];
43231
+ if (l === "") {
43232
+ blockEnd++;
43233
+ continue;
43234
+ }
43235
+ if (/^[A-Za-z0-9#]/.test(l)) {
43236
+ break;
43237
+ }
43238
+ blockEnd++;
43239
+ }
43240
+ const before = lines.slice(0, blockStart);
43241
+ const after = lines.slice(blockEnd);
43242
+ const replaced = [...before, ...block.split("\n"), ...after].join("\n");
43243
+ return replaced.endsWith("\n") ? replaced : replaced + "\n";
43244
+ }
43245
+ async function defaultPolicyTextForBootstrap() {
43246
+ return [
43247
+ "version: 1",
43248
+ "tier1_always_approve:",
43249
+ " - state_export",
43250
+ " - state_import",
43251
+ " - state_delete",
43252
+ "approval_channel:",
43253
+ " type: stderr",
43254
+ " timeout_seconds: 300",
43255
+ ""
43256
+ ].join("\n");
43257
+ }
41109
43258
  async function cmdStatus(argv, ctx) {
41110
43259
  const tenants = await discoverTenants(ctx.discoverOpts);
41111
43260
  const probes = await Promise.all(tenants.map((t) => ctx.probe(t)));
@@ -41146,6 +43295,7 @@ var init_cli5 = __esm({
41146
43295
  "src/cli/agents/cli.ts"() {
41147
43296
  init_discovery();
41148
43297
  init_health();
43298
+ init_loader();
41149
43299
  }
41150
43300
  });
41151
43301
 
@@ -41173,7 +43323,8 @@ var init_agents = __esm({
41173
43323
  // src/cli/reset-passphrase.ts
41174
43324
  var reset_passphrase_exports = {};
41175
43325
  __export(reset_passphrase_exports, {
41176
- runResetPassphraseCommand: () => runResetPassphraseCommand
43326
+ runResetPassphraseCommand: () => runResetPassphraseCommand,
43327
+ zeroizeBuffers: () => zeroizeBuffers
41177
43328
  });
41178
43329
  async function runResetPassphraseCommand(args) {
41179
43330
  const out = args.out ?? process.stdout;
@@ -41201,34 +43352,42 @@ Then re-run this command.
41201
43352
  return 1;
41202
43353
  }
41203
43354
  const lines = new LineReader(stdin);
43355
+ let code = 1;
43356
+ let nukeSucceeded = false;
41204
43357
  try {
41205
43358
  const availability = await surveyAvailableModes(storagePath);
41206
43359
  const mode = parsed.mode ?? await selectMode(lines, out, err, availability);
41207
43360
  if (!mode) {
41208
43361
  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);
43362
+ code = 1;
43363
+ } else if (mode === "shares") {
43364
+ code = await runSharesPath(out, err, availability);
43365
+ } else if (mode === "guardian") {
43366
+ code = await runGuardianPath(out, err, availability);
43367
+ } else {
43368
+ code = await runNukePath({
43369
+ out,
43370
+ err,
43371
+ lines,
43372
+ storagePath,
43373
+ home,
43374
+ plat,
43375
+ exec: args.exec ?? defaultExec2
43376
+ });
43377
+ nukeSucceeded = mode === "nuke" && code === 0;
41216
43378
  }
41217
- return await runNukePath({
41218
- out,
41219
- err,
41220
- lines,
41221
- storagePath,
41222
- home,
41223
- plat,
41224
- exec: args.exec ?? defaultExec2
41225
- });
41226
43379
  } finally {
43380
+ zeroizeBuffers(args.keyMaterialToZeroize);
41227
43381
  lines.close();
41228
43382
  }
43383
+ if (parsed.exitOnCompletion && nukeSucceeded) {
43384
+ const doExit = args.exitProcess ?? ((c) => process.exit(c));
43385
+ doExit(0);
43386
+ }
43387
+ return code;
41229
43388
  }
41230
43389
  function parseArgs2(argv) {
41231
- const out = { help: false };
43390
+ const out = { exitOnCompletion: false, help: false };
41232
43391
  for (let i = 0; i < argv.length; i++) {
41233
43392
  const a = argv[i];
41234
43393
  if (a === "--help" || a === "-h") {
@@ -41245,6 +43404,8 @@ function parseArgs2(argv) {
41245
43404
  out.storage = argv[++i];
41246
43405
  } else if (a === "--fortress" && argv[i + 1]) {
41247
43406
  out.fortress = argv[++i];
43407
+ } else if (a === "--exit-on-completion") {
43408
+ out.exitOnCompletion = true;
41248
43409
  } else if (a && a.startsWith("--")) {
41249
43410
  throw new Error(`Unknown flag: ${a}`);
41250
43411
  }
@@ -41277,6 +43438,16 @@ Options:
41277
43438
  --fortress <path> Override the fortress storage path.
41278
43439
  Consistent with "sanctuary wrap --fortress".
41279
43440
  --storage <path> Alias for --fortress.
43441
+ --exit-on-completion After a successful nuke, call process.exit(0)
43442
+ immediately so the post-wipe heap is reaped
43443
+ by the OS without re-entering the shell. Use
43444
+ on extreme-threat-model deployments where an
43445
+ attacker-on-host with heap-dump access could
43446
+ recover residual passphrase or key bytes
43447
+ between the wipe and the next operator
43448
+ command. JS strings cannot be explicitly
43449
+ zeroed; this flag is the supported way to
43450
+ bound the heap-dump window.
41280
43451
  --help, -h Show this help.
41281
43452
 
41282
43453
  Without --mode, the command surveys which paths are operationally available
@@ -41546,6 +43717,16 @@ async function prompt(lines, err, question) {
41546
43717
  err.write(question);
41547
43718
  return await lines.next();
41548
43719
  }
43720
+ function zeroizeBuffers(buffers) {
43721
+ if (!buffers) return;
43722
+ for (const b of buffers) {
43723
+ if (!b) continue;
43724
+ try {
43725
+ b.fill(0);
43726
+ } catch {
43727
+ }
43728
+ }
43729
+ }
41549
43730
  async function defaultExec2(cmd, args) {
41550
43731
  return await new Promise((resolve8, reject) => {
41551
43732
  const child = child_process.spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -42226,7 +44407,19 @@ Refusing to start the dashboard while the reset-history marker is unreadable.`
42226
44407
  }
42227
44408
  throw err;
42228
44409
  }
42229
- const policy = await loadPrincipalPolicy(config.storage_path);
44410
+ let policy;
44411
+ try {
44412
+ policy = await loadPrincipalPolicy(config.storage_path);
44413
+ } catch (err) {
44414
+ if (err instanceof MalformedPrincipalPolicyError) {
44415
+ console.error(`
44416
+ Sanctuary cannot start.
44417
+ ${err.message}
44418
+ `);
44419
+ process.exit(1);
44420
+ }
44421
+ throw err;
44422
+ }
42230
44423
  const baseline = new BaselineTracker(storage, masterKey);
42231
44424
  await baseline.load();
42232
44425
  const dashboardPort = options.port ?? config.dashboard.port;
@@ -42531,7 +44724,7 @@ async function main() {
42531
44724
  const code = await runIdentityCommand2({ argv: args.slice(1) });
42532
44725
  process.exit(code);
42533
44726
  }
42534
- if (args[0] === "agents") {
44727
+ if (args[0] === "agents" || args[0] === "agent") {
42535
44728
  const { runAgentsCommand: runAgentsCommand2 } = await Promise.resolve().then(() => (init_agents(), agents_exports));
42536
44729
  const code = await runAgentsCommand2({ argv: args.slice(1) });
42537
44730
  process.exit(code);