@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.js CHANGED
@@ -4450,9 +4450,35 @@ function validatePolicy(raw) {
4450
4450
  };
4451
4451
  delete merged.auto_deny;
4452
4452
  return merged;
4453
- })()
4453
+ })(),
4454
+ approval_redirect: parseApprovalRedirect(raw.approval_redirect)
4454
4455
  };
4455
4456
  }
4457
+ function parseApprovalRedirect(raw) {
4458
+ if (raw === void 0 || raw === null) {
4459
+ return { ...DEFAULT_APPROVAL_REDIRECT };
4460
+ }
4461
+ if (typeof raw !== "object") {
4462
+ return { ...DEFAULT_APPROVAL_REDIRECT };
4463
+ }
4464
+ const obj = raw;
4465
+ const enabled = typeof obj.enabled === "boolean" ? obj.enabled : DEFAULT_APPROVAL_REDIRECT.enabled;
4466
+ const modeRaw = obj.mode;
4467
+ let mode = DEFAULT_APPROVAL_REDIRECT.mode;
4468
+ if (modeRaw !== void 0) {
4469
+ if (modeRaw !== "replace" && modeRaw !== "notify") {
4470
+ throw new Error(
4471
+ `approval_redirect.mode must be "replace" or "notify" (got ${JSON.stringify(modeRaw)})`
4472
+ );
4473
+ }
4474
+ mode = modeRaw;
4475
+ }
4476
+ const result = { enabled, mode };
4477
+ if (obj.per_agent !== void 0 && typeof obj.per_agent === "object" && obj.per_agent !== null) {
4478
+ result.per_agent = obj.per_agent;
4479
+ }
4480
+ return result;
4481
+ }
4456
4482
  function generateDefaultPolicyYaml() {
4457
4483
  return `# Sanctuary Principal Policy v1
4458
4484
  # This file controls what your agent can do without asking.
@@ -4531,6 +4557,7 @@ tier3_always_allow:
4531
4557
  - handshake_status
4532
4558
  - handshake_exchange
4533
4559
  - handshake_verify_attestation
4560
+ - handshake_abort
4534
4561
  - reputation_query_weighted
4535
4562
  - federation_peers
4536
4563
  - federation_trust_evaluate
@@ -4565,25 +4592,58 @@ tier3_always_allow:
4565
4592
  approval_channel:
4566
4593
  type: stderr
4567
4594
  timeout_seconds: 300
4595
+
4596
+ # \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
4597
+ # Cross-harness approval-inbox redirect. When enabled, Tier 1/2 approvals
4598
+ # resolve via the unified approval inbox at /api/approval-inbox/* instead
4599
+ # of (or in addition to) the configured approval_channel above.
4600
+ #
4601
+ # mode:
4602
+ # replace: bypass the approval_channel entirely; the gate awaits a
4603
+ # decision from the inbox (default once enabled).
4604
+ # notify: fire BOTH the approval_channel and the inbox; first decision
4605
+ # wins. Right shape for harnesses that cannot fully suppress
4606
+ # their local approval prompt (e.g. Mastra-class).
4607
+ approval_redirect:
4608
+ enabled: false
4609
+ mode: replace
4568
4610
  `;
4569
4611
  }
4570
4612
  async function loadPrincipalPolicy(storagePath) {
4571
4613
  const policyPath = join(storagePath, "principal-policy.yaml");
4614
+ let content;
4615
+ try {
4616
+ content = await readFile(policyPath, "utf-8");
4617
+ } catch (err) {
4618
+ const code = err?.code;
4619
+ if (code === "ENOENT") {
4620
+ const defaultYaml = generateDefaultPolicyYaml();
4621
+ try {
4622
+ await writeFile(policyPath, defaultYaml, "utf-8");
4623
+ await chmod(policyPath, 384);
4624
+ } catch (writeErr) {
4625
+ console.warn(
4626
+ `Sanctuary: could not write default principal policy to ${policyPath}: ${writeErr.message}. Continuing with in-memory default.`
4627
+ );
4628
+ }
4629
+ return Object.freeze({ ...DEFAULT_POLICY });
4630
+ }
4631
+ throw new MalformedPrincipalPolicyError(
4632
+ policyPath,
4633
+ `read failed: ${err.message}`
4634
+ );
4635
+ }
4572
4636
  try {
4573
- const content = await readFile(policyPath, "utf-8");
4574
4637
  const policy = parsePolicy(content);
4575
4638
  return Object.freeze(policy);
4576
- } catch {
4577
- const defaultYaml = generateDefaultPolicyYaml();
4578
- try {
4579
- await writeFile(policyPath, defaultYaml, "utf-8");
4580
- await chmod(policyPath, 384);
4581
- } catch {
4582
- }
4583
- return Object.freeze({ ...DEFAULT_POLICY });
4639
+ } catch (parseErr) {
4640
+ throw new MalformedPrincipalPolicyError(
4641
+ policyPath,
4642
+ parseErr.message
4643
+ );
4584
4644
  }
4585
4645
  }
4586
- var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_POLICY;
4646
+ var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_APPROVAL_REDIRECT, DEFAULT_POLICY, MalformedPrincipalPolicyError;
4587
4647
  var init_loader = __esm({
4588
4648
  "src/principal-policy/loader.ts"() {
4589
4649
  DEFAULT_TIER2 = {
@@ -4600,6 +4660,10 @@ var init_loader = __esm({
4600
4660
  // SEC-002: auto_deny is not configurable. Timeout always denies.
4601
4661
  // Field omitted intentionally — all channels hardcode deny on timeout.
4602
4662
  };
4663
+ DEFAULT_APPROVAL_REDIRECT = {
4664
+ enabled: false,
4665
+ mode: "replace"
4666
+ };
4603
4667
  DEFAULT_POLICY = {
4604
4668
  version: 1,
4605
4669
  tier1_always_approve: [
@@ -4673,6 +4737,7 @@ var init_loader = __esm({
4673
4737
  "handshake_status",
4674
4738
  "handshake_exchange",
4675
4739
  "handshake_verify_attestation",
4740
+ "handshake_abort",
4676
4741
  "reputation_query_weighted",
4677
4742
  "federation_peers",
4678
4743
  "federation_trust_evaluate",
@@ -4714,7 +4779,22 @@ var init_loader = __esm({
4714
4779
  "compliance_eu_ai_act_annex_iii_classify"
4715
4780
  // Read-only; rule-based Annex III classifier
4716
4781
  ],
4717
- approval_channel: DEFAULT_CHANNEL
4782
+ approval_channel: DEFAULT_CHANNEL,
4783
+ approval_redirect: DEFAULT_APPROVAL_REDIRECT
4784
+ };
4785
+ MalformedPrincipalPolicyError = class extends Error {
4786
+ constructor(policyPath, reason) {
4787
+ super(
4788
+ `Principal policy at ${policyPath} is malformed and cannot be loaded.
4789
+ Reason: ${reason}
4790
+ 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.`
4791
+ );
4792
+ this.policyPath = policyPath;
4793
+ this.reason = reason;
4794
+ this.name = "MalformedPrincipalPolicyError";
4795
+ }
4796
+ policyPath;
4797
+ reason;
4718
4798
  };
4719
4799
  }
4720
4800
  });
@@ -4935,7 +5015,7 @@ function deepSortKeys(obj) {
4935
5015
  return sorted;
4936
5016
  }
4937
5017
  function canonicalizeForSigning(body) {
4938
- return JSON.stringify(deepSortKeys(body));
5018
+ return JSON.stringify(deepSortKeys(body)).normalize("NFC");
4939
5019
  }
4940
5020
  var init_types = __esm({
4941
5021
  "src/shr/types.ts"() {
@@ -12434,7 +12514,7 @@ var init_auth_middleware = __esm({
12434
12514
  });
12435
12515
 
12436
12516
  // src/hub/constants.ts
12437
- var HUB_API_PREFIX, HUB_ROUTES, HUB_FORTRESS_AGENT_ID_SENTINEL, HUB_INBOX_ACTIONS, HUB_AGENT_CONTROL_ACTIONS, HUB_TIER_1_AGENT_CONTROL_ACTIONS, HUB_ACTIVITY_DEFAULT_LIMIT, HUB_ACTIVITY_MAX_LIMIT, HUB_INBOX_DEFAULT_LIMIT, HUB_INBOX_MAX_LIMIT, HUB_AGENTS_DEFAULT_LIMIT, HUB_AGENTS_MAX_LIMIT, HUB_MAX_REQUEST_BODY_BYTES, HUB_CHAT_MESSAGE_MAX_CHARS, HUB_INBOX_TEMPLATE_NAMESPACES, HUB_ACTIVITY_TEMPLATE_NAMESPACES;
12517
+ var HUB_API_PREFIX, HUB_ROUTES, HUB_FORTRESS_AGENT_ID_SENTINEL, HUB_INBOX_ACTIONS, HUB_AGENT_CONTROL_ACTIONS, HUB_TIER_1_AGENT_CONTROL_ACTIONS, HUB_ACTIVITY_DEFAULT_LIMIT, HUB_ACTIVITY_MAX_LIMIT, HUB_CHAT_THREADS_DEFAULT_LIMIT, HUB_CHAT_THREADS_MAX_LIMIT, HUB_CHAT_TURNS_DEFAULT_LIMIT, HUB_CHAT_TURNS_MAX_LIMIT, HUB_INBOX_DEFAULT_LIMIT, HUB_INBOX_MAX_LIMIT, HUB_AGENTS_DEFAULT_LIMIT, HUB_AGENTS_MAX_LIMIT, HUB_MAX_REQUEST_BODY_BYTES, HUB_CHAT_MESSAGE_MAX_CHARS, HUB_INBOX_TEMPLATE_NAMESPACES, HUB_ACTIVITY_TEMPLATE_NAMESPACES;
12438
12518
  var init_constants3 = __esm({
12439
12519
  "src/hub/constants.ts"() {
12440
12520
  HUB_API_PREFIX = "/api/hub";
@@ -12462,6 +12542,16 @@ var init_constants3 = __esm({
12462
12542
  */
12463
12543
  CHAT_CONCIERGE_SEND: "/api/hub/chat/concierge",
12464
12544
  CHAT_CONCIERGE_HISTORY: "/api/hub/chat/concierge/history",
12545
+ /**
12546
+ * Concierge memory thread routes (WP-V1.3-9 Tau-1). Thread enumeration,
12547
+ * scrollback, and operator-initiated thread delete. Distinct from the
12548
+ * v1.2 `/history` route, which surfaces the active in-session thread
12549
+ * shape; the new routes target persisted multi-thread memory used by
12550
+ * v1.3 conversational sovereignty depth.
12551
+ */
12552
+ CHAT_CONCIERGE_THREADS_LIST: "/api/hub/chat/concierge/threads",
12553
+ CHAT_CONCIERGE_THREAD_READ: "/api/hub/chat/concierge/threads/:thread_id",
12554
+ CHAT_CONCIERGE_THREAD_DELETE: "/api/hub/chat/concierge/threads/:thread_id",
12465
12555
  /**
12466
12556
  * Click-to-inspect panel (WP-V1.2 reshape). Returns the agent's
12467
12557
  * recent activity feed, pending Tier 1 approvals routed through this
@@ -12485,6 +12575,10 @@ var init_constants3 = __esm({
12485
12575
  ];
12486
12576
  HUB_ACTIVITY_DEFAULT_LIMIT = 50;
12487
12577
  HUB_ACTIVITY_MAX_LIMIT = 500;
12578
+ HUB_CHAT_THREADS_DEFAULT_LIMIT = 50;
12579
+ HUB_CHAT_THREADS_MAX_LIMIT = 500;
12580
+ HUB_CHAT_TURNS_DEFAULT_LIMIT = 200;
12581
+ HUB_CHAT_TURNS_MAX_LIMIT = 1e3;
12488
12582
  HUB_INBOX_DEFAULT_LIMIT = 100;
12489
12583
  HUB_INBOX_MAX_LIMIT = 500;
12490
12584
  HUB_AGENTS_DEFAULT_LIMIT = 100;
@@ -12667,6 +12761,23 @@ function checkChatMessage(value) {
12667
12761
  }
12668
12762
  return trimmed;
12669
12763
  }
12764
+ function matchConciergeThreadRoute(path) {
12765
+ const prefix = `${HUB_API_PREFIX}/chat/concierge/threads/`;
12766
+ if (!path.startsWith(prefix)) return null;
12767
+ const rest = path.slice(prefix.length);
12768
+ if (rest.length === 0 || rest.includes("/")) return null;
12769
+ const decoded = decodeURIComponent(rest);
12770
+ if (decoded.length === 0) return null;
12771
+ return { threadId: decoded };
12772
+ }
12773
+ function parseSince(raw) {
12774
+ if (raw === null || raw === "") return void 0;
12775
+ const parsed = Number.parseInt(raw, 10);
12776
+ if (Number.isNaN(parsed) || parsed < 0) {
12777
+ throw new HubValidationError("since must be a non-negative integer");
12778
+ }
12779
+ return parsed;
12780
+ }
12670
12781
  function matchInboxRoute(path) {
12671
12782
  const prefix = `${HUB_API_PREFIX}/inbox/`;
12672
12783
  if (!path.startsWith(prefix)) return null;
@@ -12850,6 +12961,47 @@ async function handleHubRoute(deps, req, res) {
12850
12961
  writeJSON2(res, 200, { ok: true, data: { messages } });
12851
12962
  return true;
12852
12963
  }
12964
+ if (method === "GET" && path === HUB_ROUTES.CHAT_CONCIERGE_THREADS_LIST) {
12965
+ const limit = parseLimit(
12966
+ url.searchParams.get("limit"),
12967
+ HUB_CHAT_THREADS_DEFAULT_LIMIT,
12968
+ HUB_CHAT_THREADS_MAX_LIMIT
12969
+ );
12970
+ const threads = await deps.service.listConciergeMemoryThreads({ limit });
12971
+ writeJSON2(res, 200, { ok: true, data: { threads } });
12972
+ return true;
12973
+ }
12974
+ {
12975
+ const threadMatch = matchConciergeThreadRoute(path);
12976
+ if (threadMatch) {
12977
+ if (method === "GET") {
12978
+ const since = parseSince(url.searchParams.get("since"));
12979
+ const limit = parseLimit(
12980
+ url.searchParams.get("limit"),
12981
+ HUB_CHAT_TURNS_DEFAULT_LIMIT,
12982
+ HUB_CHAT_TURNS_MAX_LIMIT
12983
+ );
12984
+ const readOpts = { limit };
12985
+ if (since !== void 0) readOpts.sinceTurnId = since;
12986
+ const turns = await deps.service.readConciergeMemoryThread(
12987
+ threadMatch.threadId,
12988
+ readOpts
12989
+ );
12990
+ writeJSON2(res, 200, { ok: true, data: { turns } });
12991
+ return true;
12992
+ }
12993
+ if (method === "DELETE") {
12994
+ const removed = await deps.service.deleteConciergeMemoryThread(
12995
+ threadMatch.threadId
12996
+ );
12997
+ writeJSON2(res, removed ? 200 : 404, {
12998
+ ok: removed,
12999
+ data: { thread_id: threadMatch.threadId, removed }
13000
+ });
13001
+ return true;
13002
+ }
13003
+ }
13004
+ }
12853
13005
  writeJSON2(res, 404, { ok: false, error: "not_found", path });
12854
13006
  return true;
12855
13007
  } catch (err) {
@@ -16972,6 +17124,168 @@ var init_dispatch = __esm({
16972
17124
  init_intelligence_api_router();
16973
17125
  }
16974
17126
  });
17127
+
17128
+ // src/principal-policy/approval-aggregator-routes.ts
17129
+ function writeJSON4(res, status, payload) {
17130
+ res.writeHead(status, {
17131
+ "Content-Type": "application/json",
17132
+ "Cache-Control": "no-store"
17133
+ });
17134
+ res.end(JSON.stringify(payload));
17135
+ }
17136
+ function parseLimit2(raw, defaultValue, max) {
17137
+ if (raw === null || raw === "") return defaultValue;
17138
+ const parsed = Number.parseInt(raw, 10);
17139
+ if (Number.isNaN(parsed) || parsed < 0) {
17140
+ return defaultValue;
17141
+ }
17142
+ return Math.min(parsed, max);
17143
+ }
17144
+ function isStatusFilter(value) {
17145
+ return value === "pending" || value === "approved" || value === "denied" || value === "timeout" || value === "expired";
17146
+ }
17147
+ function matchEntryRoute(path) {
17148
+ const prefix = `${APPROVAL_INBOX_API_PREFIX}/`;
17149
+ if (!path.startsWith(prefix)) return null;
17150
+ const rest = path.slice(prefix.length);
17151
+ if (rest.length === 0) return null;
17152
+ const slash = rest.indexOf("/");
17153
+ if (slash === -1) {
17154
+ return { aggregatorId: decodeURIComponent(rest), action: null };
17155
+ }
17156
+ return {
17157
+ aggregatorId: decodeURIComponent(rest.slice(0, slash)),
17158
+ action: rest.slice(slash + 1)
17159
+ };
17160
+ }
17161
+ async function handleStream2(deps, res) {
17162
+ res.writeHead(200, {
17163
+ "Content-Type": "text/event-stream",
17164
+ "Cache-Control": "no-cache, no-transform",
17165
+ Connection: "keep-alive",
17166
+ "X-Accel-Buffering": "no"
17167
+ });
17168
+ const initial = await deps.aggregator.list({ status: "pending" });
17169
+ res.write(
17170
+ `event: approval_inbox_snapshot
17171
+ data: ${JSON.stringify({ entries: initial })}
17172
+
17173
+ `
17174
+ );
17175
+ const unsubscribe = deps.aggregator.onEvent((event) => {
17176
+ try {
17177
+ res.write(
17178
+ `event: approval_inbox_${event.type}
17179
+ data: ${JSON.stringify(event.entry)}
17180
+
17181
+ `
17182
+ );
17183
+ } catch {
17184
+ }
17185
+ });
17186
+ const keepAlive = setInterval(() => {
17187
+ try {
17188
+ res.write(": keepalive\n\n");
17189
+ } catch {
17190
+ }
17191
+ }, 25e3);
17192
+ const cleanup = () => {
17193
+ clearInterval(keepAlive);
17194
+ unsubscribe();
17195
+ };
17196
+ res.on("close", cleanup);
17197
+ res.on("error", cleanup);
17198
+ }
17199
+ async function handleApprovalInboxRoute(deps, req, res) {
17200
+ const host = req.headers.host || "localhost";
17201
+ const url = new URL(req.url ?? "/", `http://${host}`);
17202
+ const method = (req.method ?? "GET").toUpperCase();
17203
+ const path = url.pathname;
17204
+ if (path !== APPROVAL_INBOX_API_PREFIX && !path.startsWith(`${APPROVAL_INBOX_API_PREFIX}/`)) {
17205
+ return false;
17206
+ }
17207
+ const checkAuth = authMiddleware(deps.authConfig);
17208
+ if (!checkAuth(req, res, url)) return true;
17209
+ try {
17210
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/stream`) {
17211
+ await handleStream2(deps, res);
17212
+ return true;
17213
+ }
17214
+ if (method === "GET" && path === APPROVAL_INBOX_API_PREFIX) {
17215
+ const limit = parseLimit2(
17216
+ url.searchParams.get("limit"),
17217
+ APPROVAL_INBOX_DEFAULT_LIMIT,
17218
+ APPROVAL_INBOX_MAX_LIMIT
17219
+ );
17220
+ const statusRaw = url.searchParams.get("status");
17221
+ const status = statusRaw && isStatusFilter(statusRaw) ? statusRaw : "pending";
17222
+ const sinceTs = url.searchParams.get("since") ?? void 0;
17223
+ const entries = await deps.aggregator.list({
17224
+ status,
17225
+ limit,
17226
+ ...sinceTs !== void 0 ? { sinceTs } : {}
17227
+ });
17228
+ writeJSON4(res, 200, { ok: true, data: { entries } });
17229
+ return true;
17230
+ }
17231
+ const entryMatch = matchEntryRoute(path);
17232
+ if (entryMatch === null) {
17233
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
17234
+ return true;
17235
+ }
17236
+ if (method === "GET" && entryMatch.action === null) {
17237
+ const entries = await deps.aggregator.list({ limit: APPROVAL_INBOX_MAX_LIMIT });
17238
+ const entry = entries.find(
17239
+ (e) => e.aggregator_id === entryMatch.aggregatorId
17240
+ );
17241
+ if (!entry) {
17242
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
17243
+ return true;
17244
+ }
17245
+ const payload = await deps.aggregator.getFullPayload(
17246
+ entryMatch.aggregatorId
17247
+ );
17248
+ writeJSON4(res, 200, { ok: true, data: { entry, request_payload: payload } });
17249
+ return true;
17250
+ }
17251
+ if (method === "POST" && (entryMatch.action === "approve" || entryMatch.action === "deny")) {
17252
+ const decision = entryMatch.action === "approve" ? "approved" : "denied";
17253
+ const operatorId = deps.operatorId ?? APPROVAL_INBOX_OPERATOR_DEFAULT;
17254
+ try {
17255
+ const entry = await deps.aggregator.resolve(
17256
+ entryMatch.aggregatorId,
17257
+ decision,
17258
+ operatorId
17259
+ );
17260
+ writeJSON4(res, 200, { ok: true, data: { entry } });
17261
+ } catch (err) {
17262
+ const msg = err instanceof Error ? err.message : String(err);
17263
+ if (msg === "approval-aggregator: not_found") {
17264
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
17265
+ } else {
17266
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
17267
+ }
17268
+ }
17269
+ return true;
17270
+ }
17271
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
17272
+ return true;
17273
+ } catch (err) {
17274
+ const msg = err instanceof Error ? err.message : String(err);
17275
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
17276
+ return true;
17277
+ }
17278
+ }
17279
+ var APPROVAL_INBOX_API_PREFIX, APPROVAL_INBOX_OPERATOR_DEFAULT, APPROVAL_INBOX_DEFAULT_LIMIT, APPROVAL_INBOX_MAX_LIMIT;
17280
+ var init_approval_aggregator_routes = __esm({
17281
+ "src/principal-policy/approval-aggregator-routes.ts"() {
17282
+ init_auth_middleware();
17283
+ APPROVAL_INBOX_API_PREFIX = "/api/approval-inbox";
17284
+ APPROVAL_INBOX_OPERATOR_DEFAULT = "operator_dashboard";
17285
+ APPROVAL_INBOX_DEFAULT_LIMIT = 50;
17286
+ APPROVAL_INBOX_MAX_LIMIT = 200;
17287
+ }
17288
+ });
16975
17289
  function isDashboardViewRoute(method, path) {
16976
17290
  if (method !== "GET") return false;
16977
17291
  return path === "/" || path === "/dashboard" || path === "/v1.0" || path === "/fortress" || path === "/events";
@@ -16985,6 +17299,7 @@ var init_dashboard = __esm({
16985
17299
  init_fortress_view();
16986
17300
  init_system_prompt_generator();
16987
17301
  init_dispatch();
17302
+ init_approval_aggregator_routes();
16988
17303
  SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
16989
17304
  SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
16990
17305
  MAX_SESSIONS = 1e3;
@@ -17044,6 +17359,14 @@ var init_dashboard = __esm({
17044
17359
  * regardless. Default route flip is deferred to v1.2.
17045
17360
  */
17046
17361
  v11Bindings = null;
17362
+ /**
17363
+ * v1.3 WP-V1.3-10 Cross-Harness Approval Inbox aggregator. Mounted
17364
+ * additively at `/api/approval-inbox/*` when set. Legacy approval
17365
+ * routes at `/api/approvals/:id/(allow|deny)` continue to serve. The
17366
+ * aggregator is a passive subscriber to the gate; the routes here are
17367
+ * the operator-facing query / decision surface.
17368
+ */
17369
+ approvalAggregator = null;
17047
17370
  constructor(config) {
17048
17371
  this.config = config;
17049
17372
  this.authToken = config.auth_token;
@@ -17094,6 +17417,34 @@ var init_dashboard = __esm({
17094
17417
  setV11Bindings(bindings) {
17095
17418
  this.v11Bindings = bindings;
17096
17419
  }
17420
+ /**
17421
+ * v1.3 WP-V1.3-10 Upsilon-1: bind the cross-harness approval inbox
17422
+ * aggregator. Once set, requests to `/api/approval-inbox/*` route
17423
+ * through `handleApprovalInboxRoute`. Pass `null` to detach (used by
17424
+ * tests + during shutdown).
17425
+ */
17426
+ setApprovalAggregator(aggregator) {
17427
+ this.approvalAggregator = aggregator;
17428
+ }
17429
+ /**
17430
+ * v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
17431
+ * before the legacy approval route table. Returns true when served.
17432
+ */
17433
+ async dispatchApprovalInbox(req, res) {
17434
+ if (!this.approvalAggregator) return false;
17435
+ return handleApprovalInboxRoute(
17436
+ {
17437
+ authConfig: {
17438
+ loopbackAutoAuth: this._autoAuthLocalhost,
17439
+ ...this.authToken !== void 0 ? { authToken: this.authToken } : {}
17440
+ },
17441
+ aggregator: this.approvalAggregator,
17442
+ operatorId: this.identityManager?.getPrimaryIdentityId() ?? void 0
17443
+ },
17444
+ req,
17445
+ res
17446
+ );
17447
+ }
17097
17448
  /**
17098
17449
  * v1.1 dispatch entry point. Called from `handleRequest` before the
17099
17450
  * legacy route table. Returns true when the request was served by v1.1
@@ -17469,6 +17820,18 @@ var init_dashboard = __esm({
17469
17820
  res.end();
17470
17821
  return;
17471
17822
  }
17823
+ if (this.approvalAggregator && url.pathname.startsWith(APPROVAL_INBOX_API_PREFIX)) {
17824
+ this.dispatchApprovalInbox(req, res).then((handled) => {
17825
+ if (handled) return;
17826
+ this.handleLegacyRequest(req, res, url, method);
17827
+ }).catch(() => {
17828
+ if (!res.headersSent) {
17829
+ res.writeHead(500, { "Content-Type": "application/json" });
17830
+ res.end(JSON.stringify({ error: "Internal server error" }));
17831
+ }
17832
+ });
17833
+ return;
17834
+ }
17472
17835
  if (this.v11Bindings) {
17473
17836
  this.dispatchV11(req, res, url, method).then((handled) => {
17474
17837
  if (handled) return;
@@ -19512,14 +19875,25 @@ var init_gate = __esm({
19512
19875
  auditLog;
19513
19876
  injectionDetector;
19514
19877
  onInjectionAlert;
19878
+ onApprovalEvent;
19515
19879
  proxyTierResolver;
19516
- constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert) {
19880
+ constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert, onApprovalEvent) {
19517
19881
  this.policy = policy;
19518
19882
  this.baseline = baseline;
19519
19883
  this.channel = channel;
19520
19884
  this.auditLog = auditLog;
19521
19885
  this.injectionDetector = injectionDetector ?? new InjectionDetector();
19522
19886
  this.onInjectionAlert = onInjectionAlert;
19887
+ this.onApprovalEvent = onApprovalEvent;
19888
+ }
19889
+ /**
19890
+ * Set the approval-event callback after construction. Used by the
19891
+ * Upsilon-1 wire-up when the aggregator is constructed alongside the
19892
+ * gate. The aggregator subscribes through this setter rather than the
19893
+ * constructor so existing call sites continue to work unchanged.
19894
+ */
19895
+ setApprovalEventCallback(cb) {
19896
+ this.onApprovalEvent = cb;
19523
19897
  }
19524
19898
  /**
19525
19899
  * Set the proxy tier resolver. Called after the proxy router is initialized.
@@ -19753,21 +20127,105 @@ var init_gate = __esm({
19753
20127
  }
19754
20128
  /**
19755
20129
  * Request approval from the human principal.
20130
+ *
20131
+ * Fail-closed contract (full-sweep #49): if the channel throws (network
20132
+ * down, callback unreachable, dashboard SSE peer dropped, webhook DNS
20133
+ * failure, etc.), the gate denies the operation and audit-logs the cause.
20134
+ * Channel-internal timeouts already resolve with decision: "deny" per
20135
+ * SEC-002; this catch covers the remaining "channel raised" path so an
20136
+ * unhandled rejection cannot turn into an indeterminate state at the gate.
19756
20137
  */
19757
20138
  async requestApproval(operation, tier, reason, context) {
20139
+ const requestTimestamp = (/* @__PURE__ */ new Date()).toISOString();
19758
20140
  const request = {
19759
20141
  operation,
19760
20142
  tier,
19761
20143
  reason,
19762
20144
  context,
19763
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
20145
+ timestamp: requestTimestamp
19764
20146
  };
19765
- const response = await this.channel.requestApproval(request);
20147
+ const correlationId = `${requestTimestamp}:${operation}:${Math.random().toString(16).slice(2, 6)}`;
20148
+ if (this.onApprovalEvent) {
20149
+ try {
20150
+ this.onApprovalEvent({
20151
+ phase: "requested",
20152
+ operation,
20153
+ tier,
20154
+ reason,
20155
+ context,
20156
+ request_timestamp: requestTimestamp,
20157
+ correlation_id: correlationId
20158
+ });
20159
+ } catch {
20160
+ }
20161
+ }
20162
+ let response;
20163
+ try {
20164
+ response = await this.channel.requestApproval(request);
20165
+ } catch (err) {
20166
+ const errMessage = err instanceof Error ? err.message : String(err);
20167
+ const decidedAt = (/* @__PURE__ */ new Date()).toISOString();
20168
+ this.auditLog.append("l2", `gate_deny:${operation}`, "system", {
20169
+ tier,
20170
+ reason,
20171
+ decided_by: "channel_failure",
20172
+ channel_error: errMessage
20173
+ });
20174
+ if (this.onApprovalEvent) {
20175
+ try {
20176
+ this.onApprovalEvent({
20177
+ phase: "resolved",
20178
+ operation,
20179
+ tier,
20180
+ reason,
20181
+ context,
20182
+ request_timestamp: requestTimestamp,
20183
+ resolution: {
20184
+ decision: "deny",
20185
+ decided_at: decidedAt,
20186
+ decided_by: "channel_failure"
20187
+ },
20188
+ correlation_id: correlationId
20189
+ });
20190
+ } catch {
20191
+ }
20192
+ }
20193
+ return {
20194
+ allowed: false,
20195
+ tier,
20196
+ reason: AGENT_VISIBLE_DENY_REASONS.REQUIRES_APPROVAL,
20197
+ approval_required: true,
20198
+ approval_response: {
20199
+ decision: "deny",
20200
+ decided_at: decidedAt,
20201
+ decided_by: "channel_failure"
20202
+ }
20203
+ };
20204
+ }
19766
20205
  this.auditLog.append("l2", `gate_${response.decision}:${operation}`, "system", {
19767
20206
  tier,
19768
20207
  reason,
19769
20208
  decided_by: response.decided_by
19770
20209
  });
20210
+ if (this.onApprovalEvent) {
20211
+ try {
20212
+ this.onApprovalEvent({
20213
+ phase: "resolved",
20214
+ operation,
20215
+ tier,
20216
+ reason,
20217
+ context,
20218
+ request_timestamp: requestTimestamp,
20219
+ resolution: {
20220
+ decision: response.decision,
20221
+ decided_at: response.decided_at,
20222
+ decided_by: response.decided_by
20223
+ },
20224
+ correlation_id: correlationId
20225
+ });
20226
+ } catch {
20227
+ }
20228
+ }
19771
20229
  return {
19772
20230
  allowed: response.decision === "approve",
19773
20231
  tier,
@@ -19802,6 +20260,524 @@ var init_gate = __esm({
19802
20260
  };
19803
20261
  }
19804
20262
  });
20263
+ 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;
20264
+ var init_approval_aggregator = __esm({
20265
+ "src/principal-policy/approval-aggregator.ts"() {
20266
+ init_encryption();
20267
+ init_key_derivation();
20268
+ init_encoding();
20269
+ APPROVAL_AGGREGATOR_NAMESPACE = "_approval_aggregator";
20270
+ APPROVAL_AGGREGATOR_HKDF_INFO = "l2-approval-aggregator-v1";
20271
+ APPROVAL_AGGREGATOR_AUDIT_OPS = {
20272
+ AGGREGATED: "cross_harness_approval_aggregated",
20273
+ RESOLVED: "cross_harness_approval_resolved",
20274
+ DEDUPED: "cross_harness_approval_deduped"
20275
+ };
20276
+ DEFAULT_PENDING_TTL_MS = 5 * 60 * 1e3;
20277
+ DEFAULT_MAX_LIST_LIMIT = 200;
20278
+ DEFAULT_LIST_PAGE_SIZE = 50;
20279
+ ApprovalAggregator = class {
20280
+ storage;
20281
+ encryptionKey;
20282
+ auditLog;
20283
+ identityId;
20284
+ fortressId;
20285
+ pendingTtlMs;
20286
+ maxListLimit;
20287
+ now;
20288
+ resolveSourceContext;
20289
+ resolveHubInboxItemId;
20290
+ /** Cached entries by `aggregator_id`. */
20291
+ entries = /* @__PURE__ */ new Map();
20292
+ /** Dedup index: `${harness}|${agent}|${audit_id}` -> aggregator_id. */
20293
+ dedupIndex = /* @__PURE__ */ new Map();
20294
+ /** Correlation index: gate `correlation_id` -> aggregator_id. */
20295
+ correlationIndex = /* @__PURE__ */ new Map();
20296
+ /** Original request payloads kept in-memory for `getFullPayload()`. */
20297
+ fullPayloads = /* @__PURE__ */ new Map();
20298
+ /** Has the aggregator hydrated persisted entries on this process? */
20299
+ hydrated = false;
20300
+ /** Active SSE listeners. */
20301
+ listeners = /* @__PURE__ */ new Set();
20302
+ constructor(deps) {
20303
+ this.storage = deps.storage;
20304
+ this.encryptionKey = derivePurposeKey(
20305
+ deps.masterKey,
20306
+ APPROVAL_AGGREGATOR_HKDF_INFO
20307
+ );
20308
+ this.auditLog = deps.auditLog;
20309
+ this.identityId = deps.identityId;
20310
+ this.fortressId = deps.fortressId;
20311
+ this.pendingTtlMs = deps.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
20312
+ this.maxListLimit = deps.maxListLimit ?? DEFAULT_MAX_LIST_LIMIT;
20313
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
20314
+ this.resolveSourceContext = deps.resolveSourceContext ?? ((_event) => ({
20315
+ source_harness: this.fortressId,
20316
+ source_agent_id: this.fortressId
20317
+ }));
20318
+ this.resolveHubInboxItemId = deps.resolveHubInboxItemId ?? ((_event) => void 0);
20319
+ }
20320
+ /**
20321
+ * Subscribe an event listener. Returns an unsubscribe fn. SSE handlers
20322
+ * use this to forward aggregator emissions to the dashboard.
20323
+ */
20324
+ onEvent(listener) {
20325
+ this.listeners.add(listener);
20326
+ return () => this.listeners.delete(listener);
20327
+ }
20328
+ /**
20329
+ * Ingest a gate event. Returns the aggregator entry on first sight,
20330
+ * `null` when deduped. Resolution events update the existing record;
20331
+ * unmatched resolutions are dropped silently (caller's gate emitted a
20332
+ * resolved-without-requested pair, which the aggregator does not invent
20333
+ * a record for).
20334
+ */
20335
+ async ingest(event) {
20336
+ await this.hydrate();
20337
+ if (event.phase === "requested") {
20338
+ return this.ingestRequested(event);
20339
+ }
20340
+ if (event.phase === "resolved") {
20341
+ return this.ingestResolved(event);
20342
+ }
20343
+ return null;
20344
+ }
20345
+ /**
20346
+ * List pending or recently resolved entries. Pending entries past TTL
20347
+ * are lazily transitioned to `expired` and persisted before the list
20348
+ * snapshot is returned.
20349
+ */
20350
+ async list(opts) {
20351
+ await this.hydrate();
20352
+ await this.expireStale();
20353
+ const limit = Math.min(
20354
+ opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
20355
+ this.maxListLimit
20356
+ );
20357
+ const sinceMs = opts?.sinceTs ? Date.parse(opts.sinceTs) : Number.NEGATIVE_INFINITY;
20358
+ const matching = [];
20359
+ for (const entry of this.entries.values()) {
20360
+ if (opts?.status && entry.status !== opts.status) continue;
20361
+ if (Date.parse(entry.created_at) < sinceMs) continue;
20362
+ matching.push(entry);
20363
+ }
20364
+ matching.sort((a, b) => b.created_at.localeCompare(a.created_at));
20365
+ return matching.slice(0, limit);
20366
+ }
20367
+ /**
20368
+ * Return the original (unhashed) request payload for the entry. Returns
20369
+ * `null` when the entry is unknown or the payload was evicted (e.g. the
20370
+ * process restarted; payloads are in-memory only at v1.3 Upsilon-1).
20371
+ */
20372
+ async getFullPayload(aggregatorId) {
20373
+ await this.hydrate();
20374
+ if (!this.entries.has(aggregatorId)) return null;
20375
+ return this.fullPayloads.get(aggregatorId) ?? null;
20376
+ }
20377
+ /**
20378
+ * Resolve an entry. Used by both:
20379
+ * 1. The gate wire-up on channel-decision return.
20380
+ * 2. The HTTP `approve`/`deny` routes when an operator clicks.
20381
+ *
20382
+ * Idempotent: resolving an already-resolved entry is a no-op (the record
20383
+ * keeps its first decision and the audit log is not double-fired).
20384
+ * Unknown ids throw `Error("approval-aggregator: not_found")` so HTTP
20385
+ * routes return 404.
20386
+ */
20387
+ async resolve(aggregatorId, decision, operatorId) {
20388
+ await this.hydrate();
20389
+ const entry = this.entries.get(aggregatorId);
20390
+ if (!entry) {
20391
+ throw new Error("approval-aggregator: not_found");
20392
+ }
20393
+ if (entry.status !== "pending") {
20394
+ return entry;
20395
+ }
20396
+ entry.status = decision;
20397
+ entry.resolved_at = this.now().toISOString();
20398
+ entry.resolved_by = operatorId;
20399
+ await this.persist(entry);
20400
+ this.auditLog.append(
20401
+ "l2",
20402
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
20403
+ this.identityId,
20404
+ {
20405
+ aggregator_id: entry.aggregator_id,
20406
+ source_harness: entry.source_harness,
20407
+ source_agent_id: entry.source_agent_id,
20408
+ audit_log_entry_id: entry.audit_log_entry_id,
20409
+ policy_rule_id: entry.policy_rule_id,
20410
+ decision,
20411
+ decided_by: operatorId,
20412
+ decided_at: entry.resolved_at
20413
+ }
20414
+ );
20415
+ this.emit({ type: "resolved", entry: { ...entry } });
20416
+ return entry;
20417
+ }
20418
+ // ── Internal: ingest paths ─────────────────────────────────────────────
20419
+ async ingestRequested(event) {
20420
+ const ctx = this.resolveSourceContext(event);
20421
+ const auditId = this.auditEntryIdForEvent(event);
20422
+ const dedupKey = `${ctx.source_harness}|${ctx.source_agent_id}|${auditId}`;
20423
+ const existing = this.dedupIndex.get(dedupKey);
20424
+ if (existing) {
20425
+ const existingEntry = this.entries.get(existing);
20426
+ if (existingEntry) {
20427
+ this.correlationIndex.set(event.correlation_id, existing);
20428
+ this.auditLog.append(
20429
+ "l2",
20430
+ APPROVAL_AGGREGATOR_AUDIT_OPS.DEDUPED,
20431
+ this.identityId,
20432
+ {
20433
+ aggregator_id: existing,
20434
+ source_harness: ctx.source_harness,
20435
+ source_agent_id: ctx.source_agent_id,
20436
+ audit_log_entry_id: auditId,
20437
+ policy_rule_id: this.derivePolicyRuleId(event),
20438
+ correlation_id: event.correlation_id
20439
+ }
20440
+ );
20441
+ this.emit({ type: "deduped", entry: { ...existingEntry } });
20442
+ return null;
20443
+ }
20444
+ }
20445
+ const id = randomUUID();
20446
+ const now = this.now();
20447
+ const expires = new Date(now.getTime() + this.pendingTtlMs);
20448
+ const hubInboxId = this.resolveHubInboxItemId(event);
20449
+ const entry = {
20450
+ aggregator_id: id,
20451
+ source_harness: ctx.source_harness,
20452
+ source_agent_id: ctx.source_agent_id,
20453
+ audit_log_entry_id: auditId,
20454
+ policy_rule_id: this.derivePolicyRuleId(event),
20455
+ action_summary: this.deriveActionSummary(event),
20456
+ request_payload_hash: this.hashPayload(event.context),
20457
+ status: "pending",
20458
+ created_at: now.toISOString(),
20459
+ expires_at: expires.toISOString(),
20460
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
20461
+ };
20462
+ this.entries.set(id, entry);
20463
+ this.dedupIndex.set(dedupKey, id);
20464
+ this.correlationIndex.set(event.correlation_id, id);
20465
+ this.fullPayloads.set(id, event.context);
20466
+ await this.persist(entry);
20467
+ this.auditLog.append(
20468
+ "l2",
20469
+ APPROVAL_AGGREGATOR_AUDIT_OPS.AGGREGATED,
20470
+ this.identityId,
20471
+ {
20472
+ aggregator_id: id,
20473
+ source_harness: ctx.source_harness,
20474
+ source_agent_id: ctx.source_agent_id,
20475
+ audit_log_entry_id: auditId,
20476
+ policy_rule_id: entry.policy_rule_id,
20477
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
20478
+ }
20479
+ );
20480
+ this.emit({ type: "aggregated", entry: { ...entry } });
20481
+ return entry;
20482
+ }
20483
+ async ingestResolved(event) {
20484
+ const id = this.correlationIndex.get(event.correlation_id);
20485
+ if (!id) return null;
20486
+ const entry = this.entries.get(id);
20487
+ if (!entry) return null;
20488
+ if (entry.status !== "pending") return entry;
20489
+ if (!event.resolution) return entry;
20490
+ const failClosed = event.resolution.decision === "deny" && event.resolution.decided_by === "channel_failure";
20491
+ const status = failClosed ? "timeout" : event.resolution.decision === "approve" ? "approved" : "denied";
20492
+ entry.status = status;
20493
+ entry.resolved_at = event.resolution.decided_at;
20494
+ entry.resolved_by = event.resolution.decided_by;
20495
+ await this.persist(entry);
20496
+ this.auditLog.append(
20497
+ "l2",
20498
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
20499
+ this.identityId,
20500
+ {
20501
+ aggregator_id: id,
20502
+ source_harness: entry.source_harness,
20503
+ source_agent_id: entry.source_agent_id,
20504
+ audit_log_entry_id: entry.audit_log_entry_id,
20505
+ policy_rule_id: entry.policy_rule_id,
20506
+ decision: status,
20507
+ decided_by: entry.resolved_by,
20508
+ decided_at: entry.resolved_at,
20509
+ fail_closed: failClosed
20510
+ }
20511
+ );
20512
+ this.emit({ type: "resolved", entry: { ...entry } });
20513
+ return entry;
20514
+ }
20515
+ // ── Internal: helpers ──────────────────────────────────────────────────
20516
+ /**
20517
+ * Audit-log entry id for the dedup tuple. The audit log itself does not
20518
+ * surface a stable per-entry id (counter-prefixed keys are internal); the
20519
+ * aggregator uses the request timestamp + operation, which together pin
20520
+ * the audit entry the gate appended on the same call.
20521
+ */
20522
+ auditEntryIdForEvent(event) {
20523
+ return `${event.request_timestamp}:${event.operation}`;
20524
+ }
20525
+ derivePolicyRuleId(event) {
20526
+ return `tier${event.tier}:${event.operation}`;
20527
+ }
20528
+ deriveActionSummary(event) {
20529
+ return `${event.operation} (tier ${event.tier})`;
20530
+ }
20531
+ /**
20532
+ * Canonical SHA-256 of the request context. Sorted-keys serialization so
20533
+ * identical payloads always hash the same, even when key insertion order
20534
+ * varies. Defends against payload-replay smuggling (the aggregator can
20535
+ * tell the same payload was seen twice without storing it cleartext).
20536
+ */
20537
+ hashPayload(payload) {
20538
+ const canonical = JSON.stringify(payload, Object.keys(payload).sort());
20539
+ return createHash("sha256").update(canonical).digest("hex");
20540
+ }
20541
+ emit(event) {
20542
+ for (const listener of this.listeners) {
20543
+ try {
20544
+ listener(event);
20545
+ } catch {
20546
+ }
20547
+ }
20548
+ }
20549
+ async expireStale() {
20550
+ const nowMs = this.now().getTime();
20551
+ for (const entry of this.entries.values()) {
20552
+ if (entry.status !== "pending") continue;
20553
+ if (Date.parse(entry.expires_at) > nowMs) continue;
20554
+ entry.status = "expired";
20555
+ entry.resolved_at = this.now().toISOString();
20556
+ entry.resolved_by = "system_ttl";
20557
+ await this.persist(entry);
20558
+ this.auditLog.append(
20559
+ "l2",
20560
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
20561
+ this.identityId,
20562
+ {
20563
+ aggregator_id: entry.aggregator_id,
20564
+ source_harness: entry.source_harness,
20565
+ source_agent_id: entry.source_agent_id,
20566
+ audit_log_entry_id: entry.audit_log_entry_id,
20567
+ policy_rule_id: entry.policy_rule_id,
20568
+ decision: "expired",
20569
+ decided_by: "system_ttl",
20570
+ decided_at: entry.resolved_at
20571
+ }
20572
+ );
20573
+ this.emit({ type: "resolved", entry: { ...entry } });
20574
+ }
20575
+ }
20576
+ async persist(entry) {
20577
+ const serialized = stringToBytes(JSON.stringify(entry));
20578
+ const encrypted = encrypt(serialized, this.encryptionKey);
20579
+ await this.storage.write(
20580
+ APPROVAL_AGGREGATOR_NAMESPACE,
20581
+ entry.aggregator_id,
20582
+ stringToBytes(JSON.stringify(encrypted))
20583
+ );
20584
+ }
20585
+ async hydrate() {
20586
+ if (this.hydrated) return;
20587
+ this.hydrated = true;
20588
+ try {
20589
+ const metas = await this.storage.list(APPROVAL_AGGREGATOR_NAMESPACE);
20590
+ for (const meta of metas) {
20591
+ const raw = await this.storage.read(
20592
+ APPROVAL_AGGREGATOR_NAMESPACE,
20593
+ meta.key
20594
+ );
20595
+ if (!raw) continue;
20596
+ try {
20597
+ const encrypted = JSON.parse(bytesToString(raw));
20598
+ const decrypted = decrypt(encrypted, this.encryptionKey);
20599
+ const entry = JSON.parse(bytesToString(decrypted));
20600
+ this.entries.set(entry.aggregator_id, entry);
20601
+ const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
20602
+ this.dedupIndex.set(dedupKey, entry.aggregator_id);
20603
+ } catch {
20604
+ }
20605
+ }
20606
+ } catch {
20607
+ this.hydrated = false;
20608
+ }
20609
+ }
20610
+ };
20611
+ }
20612
+ });
20613
+
20614
+ // src/principal-policy/channels/aggregator-backed-channel.ts
20615
+ function auditEntryIdFor(request) {
20616
+ return `${request.timestamp}:${request.operation}`;
20617
+ }
20618
+ function statusToDecision(entry) {
20619
+ switch (entry.status) {
20620
+ case "approved":
20621
+ return {
20622
+ decision: "approve",
20623
+ decided_by: "human"
20624
+ };
20625
+ case "denied":
20626
+ return {
20627
+ decision: "deny",
20628
+ decided_by: "human"
20629
+ };
20630
+ case "timeout":
20631
+ case "expired":
20632
+ return {
20633
+ decision: "deny",
20634
+ decided_by: "timeout"
20635
+ };
20636
+ default:
20637
+ return null;
20638
+ }
20639
+ }
20640
+ function makeRedirectResolverFromPolicySupplier(supplier) {
20641
+ return (_request) => {
20642
+ const cfg = supplier().approval_redirect;
20643
+ if (!cfg || cfg.enabled !== true) {
20644
+ return { enabled: false, mode: "replace" };
20645
+ }
20646
+ return {
20647
+ enabled: true,
20648
+ mode: cfg.mode === "notify" ? "notify" : "replace"
20649
+ };
20650
+ };
20651
+ }
20652
+ var DEFAULT_REPLACE_MODE_TIMEOUT_MS, AggregatorBackedChannel;
20653
+ var init_aggregator_backed_channel = __esm({
20654
+ "src/principal-policy/channels/aggregator-backed-channel.ts"() {
20655
+ DEFAULT_REPLACE_MODE_TIMEOUT_MS = 5 * 60 * 1e3;
20656
+ AggregatorBackedChannel = class {
20657
+ underlying;
20658
+ aggregator;
20659
+ resolveRedirect;
20660
+ replaceModeTimeoutMs;
20661
+ now;
20662
+ constructor(opts) {
20663
+ this.underlying = opts.underlying;
20664
+ this.aggregator = opts.aggregator;
20665
+ this.resolveRedirect = opts.resolveRedirect;
20666
+ this.replaceModeTimeoutMs = opts.replaceModeTimeoutMs ?? DEFAULT_REPLACE_MODE_TIMEOUT_MS;
20667
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
20668
+ }
20669
+ /** Expose underlying for tests / wire-up reuse. */
20670
+ getUnderlying() {
20671
+ return this.underlying;
20672
+ }
20673
+ async requestApproval(request) {
20674
+ const cfg = this.resolveRedirect(request);
20675
+ if (!cfg.enabled) {
20676
+ return this.underlying.requestApproval(request);
20677
+ }
20678
+ if (cfg.mode === "replace") {
20679
+ return this.awaitAggregatorDecision(request);
20680
+ }
20681
+ return this.notifyMode(request);
20682
+ }
20683
+ /**
20684
+ * `replace` mode. Subscribe to the aggregator's event stream BEFORE
20685
+ * checking already-stored entries (avoids a race where the entry resolves
20686
+ * between list and subscribe). Match incoming events to this request by
20687
+ * audit_entry_id. Time out after `replaceModeTimeoutMs` to honor SEC-002.
20688
+ */
20689
+ async awaitAggregatorDecision(request) {
20690
+ const auditId = auditEntryIdFor(request);
20691
+ return new Promise((resolveOuter) => {
20692
+ let settled = false;
20693
+ let unsubscribe = null;
20694
+ let timeoutHandle = null;
20695
+ const settle = (response) => {
20696
+ if (settled) return;
20697
+ settled = true;
20698
+ if (timeoutHandle) clearTimeout(timeoutHandle);
20699
+ if (unsubscribe) {
20700
+ try {
20701
+ unsubscribe();
20702
+ } catch {
20703
+ }
20704
+ }
20705
+ resolveOuter(response);
20706
+ };
20707
+ const onEvent = (emit) => {
20708
+ if (emit.type !== "resolved") return;
20709
+ if (emit.entry.audit_log_entry_id !== auditId) return;
20710
+ const mapped = statusToDecision(emit.entry);
20711
+ if (!mapped) return;
20712
+ settle({
20713
+ decision: mapped.decision,
20714
+ decided_at: emit.entry.resolved_at ?? this.now().toISOString(),
20715
+ decided_by: mapped.decided_by
20716
+ });
20717
+ };
20718
+ try {
20719
+ unsubscribe = this.aggregator.onEvent(onEvent);
20720
+ } catch (err) {
20721
+ settle({
20722
+ decision: "deny",
20723
+ decided_at: this.now().toISOString(),
20724
+ decided_by: "channel_failure"
20725
+ });
20726
+ throw err instanceof Error ? err : new Error(String(err));
20727
+ }
20728
+ void this.aggregator.list({ limit: 200 }).then((entries) => {
20729
+ for (const entry of entries) {
20730
+ if (entry.audit_log_entry_id !== auditId) continue;
20731
+ const mapped = statusToDecision(entry);
20732
+ if (!mapped) return;
20733
+ settle({
20734
+ decision: mapped.decision,
20735
+ decided_at: entry.resolved_at ?? this.now().toISOString(),
20736
+ decided_by: mapped.decided_by
20737
+ });
20738
+ return;
20739
+ }
20740
+ }).catch(() => {
20741
+ });
20742
+ timeoutHandle = setTimeout(() => {
20743
+ settle({
20744
+ decision: "deny",
20745
+ decided_at: this.now().toISOString(),
20746
+ decided_by: "timeout"
20747
+ });
20748
+ }, this.replaceModeTimeoutMs);
20749
+ });
20750
+ }
20751
+ /**
20752
+ * `notify` mode. Fire the underlying channel and listen on the
20753
+ * aggregator simultaneously; whichever resolves first wins. Both
20754
+ * paths produce identical `ApprovalResponse` shapes; the gate's
20755
+ * downstream audit logging is unchanged.
20756
+ *
20757
+ * On underlying-channel failure, fall through to the aggregator wait
20758
+ * (still bounded by `replaceModeTimeoutMs`). Operator can still
20759
+ * resolve from the inbox even if the dashboard/webhook is down.
20760
+ */
20761
+ async notifyMode(request) {
20762
+ const aggregatorPromise = this.awaitAggregatorDecision(request);
20763
+ let underlyingPromise;
20764
+ try {
20765
+ underlyingPromise = this.underlying.requestApproval(request);
20766
+ } catch (err) {
20767
+ const response = await aggregatorPromise;
20768
+ return response;
20769
+ }
20770
+ return Promise.race([
20771
+ aggregatorPromise,
20772
+ underlyingPromise.catch(
20773
+ () => new Promise(() => {
20774
+ })
20775
+ )
20776
+ ]);
20777
+ }
20778
+ };
20779
+ }
20780
+ });
19805
20781
 
19806
20782
  // src/principal-policy/tools.ts
19807
20783
  function createPrincipalPolicyTools(policy, baseline, auditLog) {
@@ -20706,6 +21682,76 @@ var init_attestation = __esm({
20706
21682
  }
20707
21683
  });
20708
21684
 
21685
+ // src/handshake/audit.ts
21686
+ function auditHandshakeInitiated(auditLog, ctx) {
21687
+ auditLog.append(
21688
+ "l4",
21689
+ HANDSHAKE_LIFECYCLE_OPS.INITIATED,
21690
+ ctx.identity_id,
21691
+ detailsFromContext(ctx),
21692
+ "success"
21693
+ );
21694
+ }
21695
+ function auditHandshakeCompleted(auditLog, ctx) {
21696
+ const details = detailsFromContext(ctx);
21697
+ if (ctx.trust_tier !== void 0) {
21698
+ details.trust_tier = ctx.trust_tier;
21699
+ }
21700
+ auditLog.append(
21701
+ "l4",
21702
+ HANDSHAKE_LIFECYCLE_OPS.COMPLETED,
21703
+ ctx.identity_id,
21704
+ details,
21705
+ "success"
21706
+ );
21707
+ }
21708
+ function auditHandshakeFailed(auditLog, ctx) {
21709
+ const details = detailsFromContext(ctx);
21710
+ details.reason = ctx.reason;
21711
+ if (ctx.error !== void 0) {
21712
+ details.error = ctx.error;
21713
+ }
21714
+ auditLog.append(
21715
+ "l4",
21716
+ HANDSHAKE_LIFECYCLE_OPS.FAILED,
21717
+ ctx.identity_id,
21718
+ details,
21719
+ "failure"
21720
+ );
21721
+ }
21722
+ function auditHandshakeAborted(auditLog, ctx) {
21723
+ const details = detailsFromContext(ctx);
21724
+ details.reason = ctx.reason;
21725
+ auditLog.append(
21726
+ "l4",
21727
+ HANDSHAKE_LIFECYCLE_OPS.ABORTED,
21728
+ ctx.identity_id,
21729
+ details,
21730
+ "failure"
21731
+ );
21732
+ }
21733
+ function detailsFromContext(ctx) {
21734
+ const details = {
21735
+ session_id: ctx.session_id,
21736
+ role: ctx.role
21737
+ };
21738
+ if (ctx.counterparty_id !== void 0) {
21739
+ details.counterparty_id = ctx.counterparty_id;
21740
+ }
21741
+ return details;
21742
+ }
21743
+ var HANDSHAKE_LIFECYCLE_OPS;
21744
+ var init_audit = __esm({
21745
+ "src/handshake/audit.ts"() {
21746
+ HANDSHAKE_LIFECYCLE_OPS = {
21747
+ INITIATED: "handshake_initiated",
21748
+ COMPLETED: "handshake_completed",
21749
+ FAILED: "handshake_failed",
21750
+ ABORTED: "handshake_aborted"
21751
+ };
21752
+ }
21753
+ });
21754
+
20709
21755
  // src/handshake/tools.ts
20710
21756
  function createHandshakeTools(config, identityManager, masterKey, auditLog, options) {
20711
21757
  const autoPublishHandshakes = options?.autoPublishHandshakes ?? false;
@@ -20739,6 +21785,11 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20739
21785
  const { challenge, session } = initiateHandshake(shr);
20740
21786
  sessions.set(session.session_id, session);
20741
21787
  auditLog.append("l4", "handshake_initiate", shr.body.instance_id);
21788
+ auditHandshakeInitiated(auditLog, {
21789
+ session_id: session.session_id,
21790
+ role: "initiator",
21791
+ identity_id: shr.body.instance_id
21792
+ });
20742
21793
  return toolResult({
20743
21794
  session_id: session.session_id,
20744
21795
  challenge,
@@ -20778,10 +21829,23 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20778
21829
  );
20779
21830
  if ("error" in result) {
20780
21831
  auditLog.append("l4", "handshake_respond", shr.body.instance_id, void 0, "failure");
21832
+ auditHandshakeFailed(auditLog, {
21833
+ session_id: "unknown",
21834
+ role: "responder",
21835
+ identity_id: shr.body.instance_id,
21836
+ reason: classifyRespondFailure(result.error),
21837
+ error: result.error
21838
+ });
20781
21839
  return toolResult({ error: result.error });
20782
21840
  }
20783
21841
  sessions.set(result.session.session_id, result.session);
20784
21842
  auditLog.append("l4", "handshake_respond", shr.body.instance_id);
21843
+ auditHandshakeInitiated(auditLog, {
21844
+ session_id: result.session.session_id,
21845
+ role: "responder",
21846
+ identity_id: shr.body.instance_id,
21847
+ counterparty_id: challenge.shr.body.instance_id
21848
+ });
20785
21849
  let autoPublishResult;
20786
21850
  if (autoPublishHandshakes) {
20787
21851
  autoPublishResult = { attempted: true };
@@ -20889,9 +21953,23 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20889
21953
  const response = args.response;
20890
21954
  const session = sessions.get(sessionId);
20891
21955
  if (!session) {
21956
+ auditHandshakeFailed(auditLog, {
21957
+ session_id: sessionId,
21958
+ role: "initiator",
21959
+ identity_id: "unknown",
21960
+ reason: "session_unknown",
21961
+ error: `No handshake session found: ${sessionId}`
21962
+ });
20892
21963
  return toolResult({ error: `No handshake session found: ${sessionId}` });
20893
21964
  }
20894
21965
  if (session.state !== "initiated") {
21966
+ auditHandshakeFailed(auditLog, {
21967
+ session_id: sessionId,
21968
+ role: "initiator",
21969
+ identity_id: session.our_shr.body.instance_id,
21970
+ reason: "session_state_mismatch",
21971
+ error: `Session is in state '${session.state}', expected 'initiated'`
21972
+ });
20895
21973
  return toolResult({
20896
21974
  error: `Session is in state '${session.state}', expected 'initiated'`
20897
21975
  });
@@ -20905,6 +21983,13 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20905
21983
  if ("error" in result) {
20906
21984
  session.state = "failed";
20907
21985
  auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id, void 0, "failure");
21986
+ auditHandshakeFailed(auditLog, {
21987
+ session_id: sessionId,
21988
+ role: "initiator",
21989
+ identity_id: session.our_shr.body.instance_id,
21990
+ reason: classifyCompleteFailure(result.error),
21991
+ error: result.error
21992
+ });
20908
21993
  return toolResult({ error: result.error });
20909
21994
  }
20910
21995
  session.state = "completed";
@@ -20913,6 +21998,13 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20913
21998
  session.result = result.result;
20914
21999
  handshakeResults.set(result.result.counterparty_id, result.result);
20915
22000
  auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id);
22001
+ auditHandshakeCompleted(auditLog, {
22002
+ session_id: sessionId,
22003
+ role: "initiator",
22004
+ identity_id: session.our_shr.body.instance_id,
22005
+ counterparty_id: result.result.counterparty_id,
22006
+ trust_tier: result.result.trust_tier
22007
+ });
20916
22008
  return toolResult({
20917
22009
  completion: result.completion,
20918
22010
  result: result.result,
@@ -20960,6 +22052,24 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
20960
22052
  void 0,
20961
22053
  result.verified ? "success" : "failure"
20962
22054
  );
22055
+ if (result.verified) {
22056
+ auditHandshakeCompleted(auditLog, {
22057
+ session_id: session.session_id,
22058
+ role: "responder",
22059
+ identity_id: session.our_shr.body.instance_id,
22060
+ counterparty_id: result.counterparty_id,
22061
+ trust_tier: result.trust_tier
22062
+ });
22063
+ } else {
22064
+ auditHandshakeFailed(auditLog, {
22065
+ session_id: session.session_id,
22066
+ role: "responder",
22067
+ identity_id: session.our_shr.body.instance_id,
22068
+ counterparty_id: result.counterparty_id,
22069
+ reason: classifyCompleteFailure(result.errors.join("; ")),
22070
+ error: result.errors.join("; ")
22071
+ });
22072
+ }
20963
22073
  return toolResult({ result });
20964
22074
  }
20965
22075
  return toolResult({
@@ -21067,10 +22177,74 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
21067
22177
  _content_trust: "external"
21068
22178
  });
21069
22179
  }
22180
+ },
22181
+ {
22182
+ name: "handshake_abort",
22183
+ 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.",
22184
+ inputSchema: {
22185
+ type: "object",
22186
+ properties: {
22187
+ session_id: {
22188
+ type: "string",
22189
+ description: "Session ID returned from handshake_initiate / handshake_respond."
22190
+ },
22191
+ reason: {
22192
+ type: "string",
22193
+ enum: [
22194
+ "operator_cancelled",
22195
+ "session_timeout",
22196
+ "transport_dropped",
22197
+ "shutdown",
22198
+ "other"
22199
+ ],
22200
+ description: "Why the session is being aborted. Defaults to 'operator_cancelled'."
22201
+ }
22202
+ },
22203
+ required: ["session_id"]
22204
+ },
22205
+ handler: async (args) => {
22206
+ const sessionId = args.session_id;
22207
+ const reason = args.reason ?? "operator_cancelled";
22208
+ const session = sessions.get(sessionId);
22209
+ if (!session) {
22210
+ return toolResult({ error: `No handshake session found: ${sessionId}` });
22211
+ }
22212
+ if (session.state === "completed") {
22213
+ return toolResult({
22214
+ error: `Session ${sessionId} already completed; abort is only valid for in-flight sessions`
22215
+ });
22216
+ }
22217
+ sessions.delete(sessionId);
22218
+ auditHandshakeAborted(auditLog, {
22219
+ session_id: sessionId,
22220
+ role: session.role,
22221
+ identity_id: session.our_shr.body.instance_id,
22222
+ ...session.their_shr ? { counterparty_id: session.their_shr.body.instance_id } : {},
22223
+ reason
22224
+ });
22225
+ return toolResult({
22226
+ aborted: true,
22227
+ session_id: sessionId,
22228
+ reason
22229
+ });
22230
+ }
21070
22231
  }
21071
22232
  ];
21072
22233
  return { tools, handshakeResults };
21073
22234
  }
22235
+ function classifyRespondFailure(error) {
22236
+ if (error.includes("Unsupported protocol version")) return "protocol_version_unsupported";
22237
+ if (error.includes("SHR verification failed")) return "shr_invalid";
22238
+ if (error.includes("No identity available")) return "no_signing_identity";
22239
+ return "other";
22240
+ }
22241
+ function classifyCompleteFailure(error) {
22242
+ if (error.includes("Unsupported protocol version")) return "protocol_version_unsupported";
22243
+ if (error.includes("SHR verification failed") || error.includes("SHR")) return "shr_invalid";
22244
+ if (error.includes("nonce signature is invalid")) return "nonce_signature_invalid";
22245
+ if (error.includes("No identity available")) return "no_signing_identity";
22246
+ return "other";
22247
+ }
21074
22248
  var init_tools6 = __esm({
21075
22249
  "src/handshake/tools.ts"() {
21076
22250
  init_router();
@@ -21080,6 +22254,7 @@ var init_tools6 = __esm({
21080
22254
  init_encoding();
21081
22255
  init_protocol();
21082
22256
  init_attestation();
22257
+ init_audit();
21083
22258
  init_verifier();
21084
22259
  }
21085
22260
  });
@@ -22719,6 +23894,12 @@ function typed(markerPath, lineNumber, field, expected) {
22719
23894
  }
22720
23895
  async function consumeResetHistoryMarker(options) {
22721
23896
  const markerPath = join(options.storagePath, RESET_HISTORY_FILENAME);
23897
+ const consumedPath = markerPath + ".consumed";
23898
+ if (await fileExists3(consumedPath)) {
23899
+ await rm(markerPath, { force: true });
23900
+ await rm(consumedPath, { force: true });
23901
+ return { emitted: 0, markerPath };
23902
+ }
22722
23903
  if (!await fileExists3(markerPath)) {
22723
23904
  return { emitted: 0, markerPath };
22724
23905
  }
@@ -22743,7 +23924,9 @@ async function consumeResetHistoryMarker(options) {
22743
23924
  });
22744
23925
  }
22745
23926
  await options.auditLog.flush();
23927
+ await writeFile(consumedPath, "", "utf-8");
22746
23928
  await rm(markerPath, { force: true });
23929
+ await rm(consumedPath, { force: true });
22747
23930
  return { emitted: markers.length, markerHash, markerPath };
22748
23931
  }
22749
23932
  async function fileExists3(path) {
@@ -32026,6 +33209,36 @@ var init_hub_service = __esm({
32026
33209
  const chat = this.requireOperatorChat();
32027
33210
  return chat.getConciergeHistory();
32028
33211
  }
33212
+ // ── Concierge memory threads (WP-V1.3-9 Tau-1) ─────────────────────
33213
+ /**
33214
+ * Whether the operator-chat service has the WP-V1.3-9 memory store
33215
+ * wired. Routes use this to 503 cleanly when the foundation memory
33216
+ * surface is unavailable on a given fortress.
33217
+ */
33218
+ hasConciergeMemory() {
33219
+ return Boolean(this.deps.operatorChat?.hasConciergeMemory());
33220
+ }
33221
+ async listConciergeMemoryThreads(opts) {
33222
+ const chat = this.requireOperatorChat();
33223
+ if (!chat.hasConciergeMemory()) {
33224
+ throw new HubCapabilityError("concierge_memory_not_wired");
33225
+ }
33226
+ return chat.listConciergeMemoryThreads(opts);
33227
+ }
33228
+ async readConciergeMemoryThread(threadId, opts) {
33229
+ const chat = this.requireOperatorChat();
33230
+ if (!chat.hasConciergeMemory()) {
33231
+ throw new HubCapabilityError("concierge_memory_not_wired");
33232
+ }
33233
+ return chat.readConciergeMemoryThread(threadId, opts);
33234
+ }
33235
+ async deleteConciergeMemoryThread(threadId) {
33236
+ const chat = this.requireOperatorChat();
33237
+ if (!chat.hasConciergeMemory()) {
33238
+ throw new HubCapabilityError("concierge_memory_not_wired");
33239
+ }
33240
+ return chat.deleteConciergeMemoryThread(threadId);
33241
+ }
32029
33242
  /**
32030
33243
  * Open the click-to-inspect/approve panel for a wrapped agent. The
32031
33244
  * panel surfaces recent activity routed through this agent, pending
@@ -32164,7 +33377,27 @@ var init_operator_chat_audit_events = __esm({
32164
33377
  * affordance now opens an inspect/approve panel (recent activity +
32165
33378
  * pending approvals + policy summary) instead of a chat session.
32166
33379
  */
32167
- AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened"
33380
+ AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened",
33381
+ /**
33382
+ * Operator viewed concierge thread history (WP-V1.3-9 Tau-1). Emitted
33383
+ * when the operator hits the list-threads or read-thread route. Body
33384
+ * carries the thread_id (or `*` for the list endpoint) and a count;
33385
+ * raw turn content never crosses the audit surface.
33386
+ */
33387
+ CONCIERGE_HISTORY_READ: "operator_concierge_history_read",
33388
+ /**
33389
+ * Operator deleted a concierge thread (WP-V1.3-9 Tau-1). Emitted on
33390
+ * successful thread removal. Body carries thread_id + turn_count of
33391
+ * the deleted bundle.
33392
+ */
33393
+ CONCIERGE_THREAD_DELETED: "operator_concierge_thread_deleted",
33394
+ /**
33395
+ * Concierge memory fold-read failed (WP-V1.3-9 Tau-2). Emitted when
33396
+ * the multi-turn coherence fold cannot load the active thread's prior
33397
+ * turns; the concierge degrades to single-turn after emitting. Body
33398
+ * carries thread_id + a stable failure_reason enum.
33399
+ */
33400
+ CONCIERGE_MEMORY_READ_FAILED: "operator_concierge_memory_read_failed"
32168
33401
  };
32169
33402
  }
32170
33403
  });
@@ -32177,13 +33410,20 @@ var init_operator_chat_types = __esm({
32177
33410
  CONCIERGE_THREAD_KEY = "_fortress";
32178
33411
  }
32179
33412
  });
33413
+ function approxTokenLen(text) {
33414
+ return Math.ceil(text.length / 4);
33415
+ }
32180
33416
  function makeEventId(prefix) {
32181
33417
  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}`;
32182
33418
  }
33419
+ function formatPriorTurnLine(turn) {
33420
+ const label = turn.role === "user" ? "OPERATOR" : "CONCIERGE";
33421
+ return `${label}: ${turn.content}`;
33422
+ }
32183
33423
  function hashOf(input) {
32184
33424
  return hashToString(sha256(stringToBytes(input)));
32185
33425
  }
32186
- var DEFAULT_CONCIERGE_MAX_TOKENS, SANCTUARY_DOMAIN_REFERENCE, OperatorChatService;
33426
+ 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;
32187
33427
  var init_operator_chat_service = __esm({
32188
33428
  "src/chat/operator-chat-service.ts"() {
32189
33429
  init_hashing();
@@ -32191,6 +33431,10 @@ var init_operator_chat_service = __esm({
32191
33431
  init_operator_chat_audit_events();
32192
33432
  init_operator_chat_types();
32193
33433
  DEFAULT_CONCIERGE_MAX_TOKENS = 512;
33434
+ DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS = 10;
33435
+ DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
33436
+ DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET = 500;
33437
+ DEFAULT_CONCIERGE_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
32194
33438
  SANCTUARY_DOMAIN_REFERENCE = `Castle Architecture (four enforcement layers):
32195
33439
  1. Castle Wall: OS-boundary egress filter enforced at the kernel level. Blocks unauthorized outbound calls even from prompt-injected agents.
32196
33440
  2. Sentinels: internal observation via process introspection. Surfaces anomalies to the operator; does not enforce.
@@ -32226,6 +33470,27 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32226
33470
  contextProviders;
32227
33471
  piiFilter;
32228
33472
  conciergeMaxTokens;
33473
+ memory;
33474
+ historyWindowTurns;
33475
+ historyFreshnessMs;
33476
+ historyTokenBudget;
33477
+ sessionTtlMs;
33478
+ clock;
33479
+ /**
33480
+ * In-memory thread_id assigned to the active concierge session.
33481
+ * The first sendConcierge call after construction allocates a fresh
33482
+ * UUID; subsequent calls reuse it so multi-turn coherence (Tau-2)
33483
+ * folds the prior turns into context. Cleared by `resetConciergeMemoryThread`.
33484
+ */
33485
+ activeMemoryThreadId;
33486
+ /**
33487
+ * Wall-clock ms of the most recent sendConcierge that touched the
33488
+ * active session thread. Drives the WP-V1.3-9 Tau-2 session-TTL
33489
+ * check: a fresh sendConcierge after `sessionTtlMs` of quiet
33490
+ * allocates a new thread_id even though the prior one is still
33491
+ * readable from the memory store.
33492
+ */
33493
+ lastInteractionAt;
32229
33494
  constructor(deps) {
32230
33495
  this.store = deps.store;
32231
33496
  this.auditLog = deps.auditLog;
@@ -32236,6 +33501,12 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32236
33501
  }
32237
33502
  if (deps.conciergePiiFilter) this.piiFilter = deps.conciergePiiFilter;
32238
33503
  this.conciergeMaxTokens = deps.conciergeMaxTokens ?? DEFAULT_CONCIERGE_MAX_TOKENS;
33504
+ if (deps.conciergeMemory) this.memory = deps.conciergeMemory;
33505
+ this.historyWindowTurns = deps.conciergeHistoryWindowTurns !== void 0 && deps.conciergeHistoryWindowTurns > 0 ? deps.conciergeHistoryWindowTurns : DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS;
33506
+ this.historyFreshnessMs = deps.conciergeHistoryFreshnessMs !== void 0 && deps.conciergeHistoryFreshnessMs > 0 ? deps.conciergeHistoryFreshnessMs : DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS;
33507
+ this.historyTokenBudget = deps.conciergeHistoryTokenBudget !== void 0 && deps.conciergeHistoryTokenBudget > 0 ? deps.conciergeHistoryTokenBudget : DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET;
33508
+ this.sessionTtlMs = deps.conciergeSessionTtlMs !== void 0 && deps.conciergeSessionTtlMs > 0 ? deps.conciergeSessionTtlMs : DEFAULT_CONCIERGE_SESSION_TTL_MS;
33509
+ this.clock = deps.conciergeClock ?? (() => Date.now());
32239
33510
  }
32240
33511
  // ── Concierge ─────────────────────────────────────────────────────────
32241
33512
  /**
@@ -32254,6 +33525,10 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32254
33525
  throw new Error("concierge query must not be empty");
32255
33526
  }
32256
33527
  const filterResult = this.piiFilter ? this.piiFilter.filter(trimmed) : { filtered: trimmed, redactions: 0 };
33528
+ const nowMs = this.clock();
33529
+ if (this.activeMemoryThreadId && this.lastInteractionAt !== void 0 && nowMs - this.lastInteractionAt > this.sessionTtlMs) {
33530
+ this.activeMemoryThreadId = void 0;
33531
+ }
32257
33532
  const operatorMessage = {
32258
33533
  message_id: randomUUID(),
32259
33534
  surface: "concierge",
@@ -32266,6 +33541,30 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32266
33541
  CONCIERGE_THREAD_KEY,
32267
33542
  operatorMessage
32268
33543
  );
33544
+ let priorTurns = [];
33545
+ let memoryReadFailureReason = null;
33546
+ let activeThreadIdForRound;
33547
+ if (this.memory) {
33548
+ activeThreadIdForRound = this.ensureActiveMemoryThread();
33549
+ const result = await this.memory.readThreadStrict(activeThreadIdForRound).catch(() => ({ ok: false, reason: "io_failed" }));
33550
+ if (result.ok) {
33551
+ const cutoff = nowMs - this.historyFreshnessMs;
33552
+ const fresh = result.turns.filter((t) => {
33553
+ const ts = Date.parse(t.created_at);
33554
+ return Number.isFinite(ts) && ts >= cutoff;
33555
+ });
33556
+ const recent = fresh.length > this.historyWindowTurns ? fresh.slice(fresh.length - this.historyWindowTurns) : fresh;
33557
+ priorTurns = recent;
33558
+ } else {
33559
+ memoryReadFailureReason = result.reason;
33560
+ this.emitMemoryReadFailed(activeThreadIdForRound, result.reason);
33561
+ }
33562
+ }
33563
+ if (this.memory) {
33564
+ const threadId = this.ensureActiveMemoryThread();
33565
+ await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
33566
+ });
33567
+ }
32269
33568
  const start = Date.now();
32270
33569
  let conciergeBody;
32271
33570
  let servedBy = "disabled";
@@ -32282,7 +33581,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32282
33581
  conciergeBody = "Concierge unavailable. The chosen substrate does not support summarization. Pick a different substrate in the Policy center.";
32283
33582
  outcome = "substrate_disabled";
32284
33583
  } else {
32285
- const context = await this.assembleConciergeContext();
33584
+ const context = await this.assembleConciergeContext(priorTurns);
32286
33585
  const response = await this.substrateSelector.invokeSummarize(
32287
33586
  "concierge",
32288
33587
  {
@@ -32321,6 +33620,15 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32321
33620
  CONCIERGE_THREAD_KEY,
32322
33621
  responseMessage
32323
33622
  );
33623
+ let assistantTurnId;
33624
+ if (this.memory) {
33625
+ const threadId = this.ensureActiveMemoryThread();
33626
+ const persisted = await this.memory.appendTurn(threadId, "assistant", conciergeBody).catch(() => void 0);
33627
+ if (persisted) assistantTurnId = persisted.turn_id;
33628
+ }
33629
+ if (this.memory && activeThreadIdForRound) {
33630
+ this.lastInteractionAt = nowMs;
33631
+ }
32324
33632
  const payload = {
32325
33633
  version: "1.2",
32326
33634
  event_id: makeEventId("conc"),
@@ -32332,7 +33640,12 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32332
33640
  response_hash: outcome === "ok" ? hashOf(conciergeBody) : null,
32333
33641
  substrate: servedBy,
32334
33642
  latency_ms: latencyMs,
32335
- outcome
33643
+ outcome,
33644
+ ...activeThreadIdForRound !== void 0 ? { thread_id: activeThreadIdForRound } : {},
33645
+ ...assistantTurnId !== void 0 ? { turn_index: assistantTurnId } : {},
33646
+ ...this.memory ? {
33647
+ prior_turns_folded: memoryReadFailureReason === null ? priorTurns.length : 0
33648
+ } : {}
32336
33649
  };
32337
33650
  this.emit(OPERATOR_CHAT_OPS.CONCIERGE_CHAT, payload, outcome === "ok" ? "success" : "failure");
32338
33651
  return {
@@ -32342,6 +33655,25 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32342
33655
  outcome
32343
33656
  };
32344
33657
  }
33658
+ /**
33659
+ * Emit the WP-V1.3-9 Tau-2 graceful-degradation audit event. Pulled
33660
+ * out of `sendConcierge` so the read-fold path stays readable. Emits
33661
+ * with `result: "failure"` since the concierge fell back to
33662
+ * single-turn mode for this round-trip.
33663
+ */
33664
+ emitMemoryReadFailed(threadId, reason) {
33665
+ const payload = {
33666
+ version: "1.2",
33667
+ event_id: makeEventId("conc-memfail"),
33668
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33669
+ identity_id: this.identityId,
33670
+ kind: "operator_concierge_memory_read_failed",
33671
+ surface: "concierge",
33672
+ thread_id: threadId,
33673
+ failure_reason: reason
33674
+ };
33675
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_MEMORY_READ_FAILED, payload, "failure");
33676
+ }
32345
33677
  /**
32346
33678
  * Read the persisted concierge thread, oldest message first. Returns
32347
33679
  * an empty array when no thread exists yet.
@@ -32353,6 +33685,105 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32353
33685
  );
32354
33686
  return thread ? thread.messages : [];
32355
33687
  }
33688
+ // ── WP-V1.3-9 Tau-1 memory accessors ─────────────────────────────────
33689
+ /**
33690
+ * Whether the foundation memory store is wired. Routes use this to
33691
+ * 503 cleanly when called against an unwired service.
33692
+ */
33693
+ hasConciergeMemory() {
33694
+ return this.memory !== void 0;
33695
+ }
33696
+ /**
33697
+ * List concierge memory threads, newest-first. Emits the
33698
+ * `operator_concierge_history_read` audit event with `thread_id="*"`.
33699
+ */
33700
+ async listConciergeMemoryThreads(opts) {
33701
+ if (!this.memory) {
33702
+ throw new Error("concierge memory store not configured");
33703
+ }
33704
+ const summaries = await this.memory.listThreads(opts);
33705
+ const totalTurns = summaries.reduce((acc, s) => acc + s.turn_count, 0);
33706
+ const payload = {
33707
+ version: "1.2",
33708
+ event_id: makeEventId("conc-hist"),
33709
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33710
+ identity_id: this.identityId,
33711
+ kind: "operator_concierge_history_read",
33712
+ surface: "concierge",
33713
+ thread_id: "*",
33714
+ turn_count: totalTurns
33715
+ };
33716
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
33717
+ return summaries;
33718
+ }
33719
+ /**
33720
+ * Read a concierge memory thread, oldest turn first. Emits the
33721
+ * `operator_concierge_history_read` audit event with the named
33722
+ * thread_id and the count of turns surfaced.
33723
+ */
33724
+ async readConciergeMemoryThread(threadId, opts) {
33725
+ if (!this.memory) {
33726
+ throw new Error("concierge memory store not configured");
33727
+ }
33728
+ const turns = await this.memory.readThread(threadId, opts);
33729
+ const payload = {
33730
+ version: "1.2",
33731
+ event_id: makeEventId("conc-hist"),
33732
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33733
+ identity_id: this.identityId,
33734
+ kind: "operator_concierge_history_read",
33735
+ surface: "concierge",
33736
+ thread_id: threadId,
33737
+ turn_count: turns.length
33738
+ };
33739
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
33740
+ return turns;
33741
+ }
33742
+ /**
33743
+ * Delete a concierge memory thread. Emits
33744
+ * `operator_concierge_thread_deleted` only when a bundle was actually
33745
+ * removed; absent threads return false without an audit event.
33746
+ */
33747
+ async deleteConciergeMemoryThread(threadId) {
33748
+ if (!this.memory) {
33749
+ throw new Error("concierge memory store not configured");
33750
+ }
33751
+ const turnsBefore = await this.memory.readThread(threadId);
33752
+ if (turnsBefore.length === 0) {
33753
+ return await this.memory.deleteThread(threadId);
33754
+ }
33755
+ const removed = await this.memory.deleteThread(threadId);
33756
+ if (!removed) return false;
33757
+ if (this.activeMemoryThreadId === threadId) {
33758
+ this.activeMemoryThreadId = void 0;
33759
+ }
33760
+ const payload = {
33761
+ version: "1.2",
33762
+ event_id: makeEventId("conc-del"),
33763
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
33764
+ identity_id: this.identityId,
33765
+ kind: "operator_concierge_thread_deleted",
33766
+ surface: "concierge",
33767
+ thread_id: threadId,
33768
+ turn_count: turnsBefore.length
33769
+ };
33770
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_THREAD_DELETED, payload, "success");
33771
+ return true;
33772
+ }
33773
+ /**
33774
+ * Reset the active session memory thread. Subsequent sendConcierge
33775
+ * calls allocate a fresh thread_id. Surfaced for tests + future "new
33776
+ * conversation" affordance; not currently called by the dashboard.
33777
+ */
33778
+ resetConciergeMemoryThread() {
33779
+ this.activeMemoryThreadId = void 0;
33780
+ }
33781
+ ensureActiveMemoryThread() {
33782
+ if (!this.activeMemoryThreadId) {
33783
+ this.activeMemoryThreadId = randomUUID();
33784
+ }
33785
+ return this.activeMemoryThreadId;
33786
+ }
32356
33787
  /**
32357
33788
  * Stitch fortress state into a single context blob the substrate
32358
33789
  * folds into its summarization prompt.
@@ -32365,6 +33796,11 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32365
33796
  * ## Sanctuary reference
32366
33797
  * <static domain reference block>
32367
33798
  *
33799
+ * ## Prior conversation ← WP-V1.3-9 Tau-2, when present
33800
+ * OPERATOR: ...
33801
+ * CONCIERGE: ...
33802
+ * ---
33803
+ *
32368
33804
  * ## Recent activity
32369
33805
  * <recentActivity output>
32370
33806
  *
@@ -32374,37 +33810,69 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
32374
33810
  * ## Open inbox
32375
33811
  * <openInbox output>
32376
33812
  * ```
33813
+ *
33814
+ * The substrate selector ships a `context: string` shape (not a
33815
+ * messages array), so multi-turn coherence is folded as a structured
33816
+ * prior-conversation section with explicit OPERATOR / CONCIERGE
33817
+ * boundaries. Coordinator-CTO guidance: prefer messages-array shape
33818
+ * if available; the v1.2 selector does not expose one, so structured
33819
+ * serialization is the canonical path for v1.3.
32377
33820
  */
32378
- async assembleConciergeContext() {
33821
+ async assembleConciergeContext(priorTurns = []) {
32379
33822
  const ref = `## Sanctuary reference
32380
33823
  ${SANCTUARY_DOMAIN_REFERENCE}`;
33824
+ const priorSection = this.formatPriorTurnsSection(priorTurns);
32381
33825
  if (!this.contextProviders) {
32382
- return `${ref}
32383
-
32384
- ## Recent activity
32385
- (no providers wired)
32386
-
32387
- ## Wrapped agents
32388
- (no providers wired)
32389
-
32390
- ## Open inbox
32391
- (no providers wired)`;
33826
+ return [
33827
+ ref,
33828
+ ...priorSection ? [priorSection] : [],
33829
+ "## Recent activity\n(no providers wired)",
33830
+ "## Wrapped agents\n(no providers wired)",
33831
+ "## Open inbox\n(no providers wired)"
33832
+ ].join("\n\n");
32392
33833
  }
32393
33834
  const [activity, agents, inbox] = await Promise.all([
32394
33835
  this.contextProviders.recentActivity(),
32395
33836
  this.contextProviders.agentInventory(),
32396
33837
  this.contextProviders.openInbox()
32397
33838
  ]);
32398
- return `${ref}
32399
-
32400
- ## Recent activity
32401
- ${activity}
32402
-
32403
- ## Wrapped agents
32404
- ${agents}
32405
-
32406
- ## Open inbox
32407
- ${inbox}`;
33839
+ return [
33840
+ ref,
33841
+ ...priorSection ? [priorSection] : [],
33842
+ `## Recent activity
33843
+ ${activity}`,
33844
+ `## Wrapped agents
33845
+ ${agents}`,
33846
+ `## Open inbox
33847
+ ${inbox}`
33848
+ ].join("\n\n");
33849
+ }
33850
+ /**
33851
+ * Render the prior-conversation section with token-budget enforcement
33852
+ * (WP-V1.3-9 Tau-2). Drops oldest turns first when the rendered
33853
+ * section exceeds `historyTokenBudget`. Returns an empty string when
33854
+ * the input is empty or when the budget excludes every turn.
33855
+ */
33856
+ formatPriorTurnsSection(turns) {
33857
+ if (turns.length === 0) return "";
33858
+ const HEADER = "## Prior conversation";
33859
+ const lines = turns.map(formatPriorTurnLine);
33860
+ const headerTokens = approxTokenLen(`${HEADER}
33861
+ `);
33862
+ const sepTokens = approxTokenLen("\n");
33863
+ let runningTokens = headerTokens;
33864
+ let runningLines = [];
33865
+ for (let i = lines.length - 1; i >= 0; i--) {
33866
+ const line = lines[i];
33867
+ const tokens = approxTokenLen(line) + (runningLines.length > 0 ? sepTokens : 0);
33868
+ if (runningTokens + tokens > this.historyTokenBudget) break;
33869
+ runningTokens += tokens;
33870
+ runningLines.push(line);
33871
+ }
33872
+ if (runningLines.length === 0) return "";
33873
+ runningLines = runningLines.reverse();
33874
+ return `${HEADER}
33875
+ ${runningLines.join("\n")}`;
32408
33876
  }
32409
33877
  // ── audit helpers ────────────────────────────────────────────────────
32410
33878
  emit(operation, payload, result) {
@@ -32519,11 +33987,309 @@ var init_operator_chat_store = __esm({
32519
33987
  }
32520
33988
  });
32521
33989
 
33990
+ // src/chat/concierge-memory-store.ts
33991
+ function bundleKey(threadId) {
33992
+ return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
33993
+ }
33994
+ function stripKeyPrefix(key) {
33995
+ if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
33996
+ return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
33997
+ }
33998
+ function lastTurnId(bundle) {
33999
+ let max = 0;
34000
+ for (const t of bundle.turns) {
34001
+ if (t.turn_id > max) max = t.turn_id;
34002
+ }
34003
+ return max;
34004
+ }
34005
+ var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO2, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES2, ConciergeMemoryStore;
34006
+ var init_concierge_memory_store = __esm({
34007
+ "src/chat/concierge-memory-store.ts"() {
34008
+ init_encryption();
34009
+ init_key_derivation();
34010
+ init_encoding();
34011
+ CONCIERGE_MEMORY_NAMESPACE = "_chat";
34012
+ CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
34013
+ HKDF_INFO2 = "concierge-memory-store-v1";
34014
+ DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
34015
+ MAX_BUNDLE_BYTES2 = 4 * 1024 * 1024;
34016
+ ConciergeMemoryStore = class {
34017
+ storage;
34018
+ encryptionKey;
34019
+ fortressId;
34020
+ retentionDays;
34021
+ locks;
34022
+ constructor(opts) {
34023
+ this.storage = opts.storage;
34024
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
34025
+ this.fortressId = opts.fortressId;
34026
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
34027
+ this.locks = /* @__PURE__ */ new Map();
34028
+ }
34029
+ /**
34030
+ * Append a turn to the named thread, creating the bundle if no record
34031
+ * exists. Returns the persisted turn (with assigned turn_id +
34032
+ * retention_until). Per-thread serialisation guarantees turn_id
34033
+ * monotonicity even under concurrent callers.
34034
+ */
34035
+ async appendTurn(threadId, role, content) {
34036
+ return this.withLock(threadId, async () => {
34037
+ const bundle = await this.loadBundle(threadId) ?? null;
34038
+ const now = /* @__PURE__ */ new Date();
34039
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
34040
+ const retentionUntil = new Date(now.getTime() + retentionMs);
34041
+ const nextTurnId = bundle ? lastTurnId(bundle) + 1 : 1;
34042
+ const turn = {
34043
+ thread_id: threadId,
34044
+ fortress_id: this.fortressId,
34045
+ turn_id: nextTurnId,
34046
+ role,
34047
+ content,
34048
+ created_at: now.toISOString(),
34049
+ retention_until: retentionUntil.toISOString()
34050
+ };
34051
+ const next = bundle ? { ...bundle, turns: [...bundle.turns, turn] } : {
34052
+ version: 1,
34053
+ thread_id: threadId,
34054
+ fortress_id: this.fortressId,
34055
+ created_at: now.toISOString(),
34056
+ turns: [turn]
34057
+ };
34058
+ await this.saveBundle(next);
34059
+ return turn;
34060
+ });
34061
+ }
34062
+ /**
34063
+ * Read turns from a thread, oldest-first. Returns an empty array if
34064
+ * the thread does not exist or its bundle is corrupt. Does not emit
34065
+ * audit events; the caller (HTTP route handler) owns audit semantics.
34066
+ */
34067
+ async readThread(threadId, opts) {
34068
+ const bundle = await this.loadBundle(threadId);
34069
+ if (!bundle) return [];
34070
+ let turns = bundle.turns;
34071
+ if (opts?.sinceTurnId !== void 0) {
34072
+ const cutoff = opts.sinceTurnId;
34073
+ turns = turns.filter((t) => t.turn_id > cutoff);
34074
+ }
34075
+ if (opts?.limit !== void 0) {
34076
+ turns = turns.slice(0, opts.limit);
34077
+ }
34078
+ return turns;
34079
+ }
34080
+ /**
34081
+ * Read turns with explicit failure surfacing (WP-V1.3-9 Tau-2). Where
34082
+ * `readThread` collapses every failure mode to an empty array, this
34083
+ * variant returns a discriminated result so the multi-turn fold path
34084
+ * can degrade cleanly + emit `operator_concierge_memory_read_failed`
34085
+ * with a concrete cause.
34086
+ *
34087
+ * - No bundle on disk → `{ ok: true, turns: [] }` (a fresh thread).
34088
+ * - Bundle present, decode + decrypt + schema check pass → ok with turns.
34089
+ * - Bundle present, oversize → `{ ok: false, reason: "oversize_bundle" }`.
34090
+ * - Bundle present, decryption fails → `{ ok: false, reason: "decrypt_failed" }`.
34091
+ * - Bundle present, schema mismatch (version / thread_id) → `schema_mismatch`.
34092
+ * - Storage IO error → `io_failed`.
34093
+ */
34094
+ async readThreadStrict(threadId, opts) {
34095
+ const key = bundleKey(threadId);
34096
+ let raw;
34097
+ try {
34098
+ raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
34099
+ } catch {
34100
+ return { ok: false, reason: "io_failed" };
34101
+ }
34102
+ if (!raw) return { ok: true, turns: [] };
34103
+ if (raw.length > MAX_BUNDLE_BYTES2) {
34104
+ return { ok: false, reason: "oversize_bundle" };
34105
+ }
34106
+ let envelope;
34107
+ try {
34108
+ envelope = JSON.parse(bytesToString(raw));
34109
+ } catch {
34110
+ return { ok: false, reason: "schema_mismatch" };
34111
+ }
34112
+ let plaintext;
34113
+ try {
34114
+ const aad = stringToBytes(threadId);
34115
+ plaintext = decrypt(envelope, this.encryptionKey, aad);
34116
+ } catch {
34117
+ return { ok: false, reason: "decrypt_failed" };
34118
+ }
34119
+ let parsed;
34120
+ try {
34121
+ parsed = JSON.parse(bytesToString(plaintext));
34122
+ } catch {
34123
+ return { ok: false, reason: "schema_mismatch" };
34124
+ }
34125
+ if (parsed.version !== 1) return { ok: false, reason: "schema_mismatch" };
34126
+ if (parsed.thread_id !== threadId) {
34127
+ return { ok: false, reason: "schema_mismatch" };
34128
+ }
34129
+ let turns = parsed.turns;
34130
+ if (opts?.sinceTurnId !== void 0) {
34131
+ const cutoff = opts.sinceTurnId;
34132
+ turns = turns.filter((t) => t.turn_id > cutoff);
34133
+ }
34134
+ if (opts?.limit !== void 0) {
34135
+ turns = turns.slice(0, opts.limit);
34136
+ }
34137
+ return { ok: true, turns };
34138
+ }
34139
+ /**
34140
+ * Enumerate concierge threads in this fortress with summary metadata.
34141
+ * Sorted newest-first by last_turn_at.
34142
+ */
34143
+ async listThreads(opts) {
34144
+ const entries = await this.storage.list(
34145
+ CONCIERGE_MEMORY_NAMESPACE,
34146
+ CONCIERGE_MEMORY_KEY_PREFIX
34147
+ );
34148
+ const summaries = [];
34149
+ for (const meta of entries) {
34150
+ const threadId = stripKeyPrefix(meta.key);
34151
+ if (threadId === null) continue;
34152
+ const bundle = await this.loadBundle(threadId);
34153
+ if (!bundle || bundle.turns.length === 0) continue;
34154
+ const last = bundle.turns[bundle.turns.length - 1];
34155
+ summaries.push({
34156
+ thread_id: bundle.thread_id,
34157
+ created_at: bundle.created_at,
34158
+ last_turn_at: last ? last.created_at : bundle.created_at,
34159
+ turn_count: bundle.turns.length
34160
+ });
34161
+ }
34162
+ summaries.sort(
34163
+ (a, b) => a.last_turn_at < b.last_turn_at ? 1 : a.last_turn_at > b.last_turn_at ? -1 : 0
34164
+ );
34165
+ if (opts?.limit !== void 0) {
34166
+ return summaries.slice(0, opts.limit);
34167
+ }
34168
+ return summaries;
34169
+ }
34170
+ /**
34171
+ * Delete a thread's bundle. Returns true if the bundle existed and
34172
+ * was removed; false if no bundle was present. Audit emission is the
34173
+ * caller's responsibility.
34174
+ */
34175
+ async deleteThread(threadId) {
34176
+ const key = bundleKey(threadId);
34177
+ return this.withLock(threadId, async () => {
34178
+ const existed = await this.storage.exists(
34179
+ CONCIERGE_MEMORY_NAMESPACE,
34180
+ key
34181
+ );
34182
+ if (!existed) return false;
34183
+ try {
34184
+ await this.storage.delete(CONCIERGE_MEMORY_NAMESPACE, key);
34185
+ } catch {
34186
+ return false;
34187
+ }
34188
+ return true;
34189
+ });
34190
+ }
34191
+ /**
34192
+ * Drop expired turns across all threads. Threads emptied by pruning
34193
+ * are removed entirely. Returns the count of turns pruned.
34194
+ */
34195
+ async pruneExpired(now) {
34196
+ const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
34197
+ const entries = await this.storage.list(
34198
+ CONCIERGE_MEMORY_NAMESPACE,
34199
+ CONCIERGE_MEMORY_KEY_PREFIX
34200
+ );
34201
+ let pruned = 0;
34202
+ for (const meta of entries) {
34203
+ const threadId = stripKeyPrefix(meta.key);
34204
+ if (threadId === null) continue;
34205
+ pruned += await this.withLock(threadId, async () => {
34206
+ const bundle = await this.loadBundle(threadId);
34207
+ if (!bundle) return 0;
34208
+ const kept = bundle.turns.filter((t) => t.retention_until > cutoff);
34209
+ const dropped = bundle.turns.length - kept.length;
34210
+ if (dropped === 0) return 0;
34211
+ if (kept.length === 0) {
34212
+ await this.storage.delete(
34213
+ CONCIERGE_MEMORY_NAMESPACE,
34214
+ bundleKey(threadId)
34215
+ );
34216
+ } else {
34217
+ await this.saveBundle({ ...bundle, turns: kept });
34218
+ }
34219
+ return dropped;
34220
+ });
34221
+ }
34222
+ return { pruned };
34223
+ }
34224
+ // ── internals ────────────────────────────────────────────────────────
34225
+ async loadBundle(threadId) {
34226
+ const key = bundleKey(threadId);
34227
+ let raw;
34228
+ try {
34229
+ raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
34230
+ } catch {
34231
+ return null;
34232
+ }
34233
+ if (!raw) return null;
34234
+ if (raw.length > MAX_BUNDLE_BYTES2) return null;
34235
+ try {
34236
+ const envelope = JSON.parse(bytesToString(raw));
34237
+ const aad = stringToBytes(threadId);
34238
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
34239
+ const parsed = JSON.parse(
34240
+ bytesToString(plaintext)
34241
+ );
34242
+ if (parsed.version !== 1) return null;
34243
+ if (parsed.thread_id !== threadId) return null;
34244
+ return parsed;
34245
+ } catch {
34246
+ return null;
34247
+ }
34248
+ }
34249
+ async saveBundle(bundle) {
34250
+ const key = bundleKey(bundle.thread_id);
34251
+ const aad = stringToBytes(bundle.thread_id);
34252
+ const plaintext = stringToBytes(JSON.stringify(bundle));
34253
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
34254
+ await this.storage.write(
34255
+ CONCIERGE_MEMORY_NAMESPACE,
34256
+ key,
34257
+ stringToBytes(JSON.stringify(envelope))
34258
+ );
34259
+ }
34260
+ /**
34261
+ * Run `task` while holding the per-thread async lock. Lock is released
34262
+ * once the task settles (success or failure). Generic helper so
34263
+ * appendTurn / deleteThread / pruneExpired share serialisation.
34264
+ */
34265
+ async withLock(threadId, task) {
34266
+ const previous = this.locks.get(threadId) ?? Promise.resolve();
34267
+ let release;
34268
+ const next = new Promise((resolve8) => {
34269
+ release = resolve8;
34270
+ });
34271
+ const chained = previous.then(() => next);
34272
+ this.locks.set(threadId, chained);
34273
+ try {
34274
+ await previous;
34275
+ return await task();
34276
+ } finally {
34277
+ release();
34278
+ if (this.locks.get(threadId) === chained) {
34279
+ this.locks.delete(threadId);
34280
+ }
34281
+ }
34282
+ }
34283
+ };
34284
+ }
34285
+ });
34286
+
32522
34287
  // src/chat/operator-chat-index.ts
32523
34288
  var init_operator_chat_index = __esm({
32524
34289
  "src/chat/operator-chat-index.ts"() {
32525
34290
  init_operator_chat_service();
32526
34291
  init_operator_chat_store();
34292
+ init_concierge_memory_store();
32527
34293
  init_operator_chat_audit_events();
32528
34294
  init_operator_chat_types();
32529
34295
  }
@@ -32537,6 +34303,14 @@ function buildV11Bindings(inputs) {
32537
34303
  let operatorChatService;
32538
34304
  if (inputs.storage && inputs.masterKey) {
32539
34305
  const chatStore = new OperatorChatStore(inputs.storage, inputs.masterKey);
34306
+ const conciergeMemory = new ConciergeMemoryStore({
34307
+ storage: inputs.storage,
34308
+ masterKey: inputs.masterKey,
34309
+ fortressId: inputs.fortressId,
34310
+ ...inputs.conciergeMemoryRetentionDays !== void 0 ? { retentionDays: inputs.conciergeMemoryRetentionDays } : {}
34311
+ });
34312
+ void conciergeMemory.pruneExpired().catch(() => {
34313
+ });
32540
34314
  operatorChatService = new OperatorChatService({
32541
34315
  store: chatStore,
32542
34316
  auditLog: inputs.auditLog,
@@ -32547,7 +34321,8 @@ function buildV11Bindings(inputs) {
32547
34321
  identityId: inputs.identityId,
32548
34322
  registry
32549
34323
  }),
32550
- conciergePiiFilter: buildConciergePiiFilter()
34324
+ conciergePiiFilter: buildConciergePiiFilter(),
34325
+ conciergeMemory
32551
34326
  });
32552
34327
  }
32553
34328
  const hubService = new HubService({
@@ -32782,7 +34557,7 @@ var init_defaults = __esm({
32782
34557
  });
32783
34558
 
32784
34559
  // src/intelligence/policy-store.ts
32785
- var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO2, IntelligenceConfigStore;
34560
+ var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO3, IntelligenceConfigStore;
32786
34561
  var init_policy_store = __esm({
32787
34562
  "src/intelligence/policy-store.ts"() {
32788
34563
  init_encryption();
@@ -32791,13 +34566,13 @@ var init_policy_store = __esm({
32791
34566
  init_defaults();
32792
34567
  INTELLIGENCE_NAMESPACE = "_intelligence";
32793
34568
  SUBSTRATE_CONFIG_KEY = "substrate-config";
32794
- HKDF_INFO2 = "intelligence-substrate-config";
34569
+ HKDF_INFO3 = "intelligence-substrate-config";
32795
34570
  IntelligenceConfigStore = class {
32796
34571
  storage;
32797
34572
  encryptionKey;
32798
34573
  constructor(storage, masterKey) {
32799
34574
  this.storage = storage;
32800
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
34575
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
32801
34576
  }
32802
34577
  /**
32803
34578
  * Load the operator's substrate config from disk. Returns the config
@@ -34819,7 +36594,9 @@ async function verifyExitBundle(bundleDir, options = {}) {
34819
36594
  );
34820
36595
  }
34821
36596
  }
34822
- const reputationFailed = reputation?.bundle_signature_valid === false || (reputation?.invalid_attestations ?? 0) > 0;
36597
+ const reputationBundleFailed = reputation?.bundle_signature_valid === false;
36598
+ const reputationAttestationFailed = (reputation?.invalid_attestations ?? 0) > 0;
36599
+ const reputationFailed = reputationBundleFailed || reputationAttestationFailed;
34823
36600
  const identityFailed = identity ? !identity.signature_valid : false;
34824
36601
  const unverifiableCount = reputation?.unverifiable_attestations ?? 0;
34825
36602
  const unverifiableFailed = unverifiableCount > 0 && !options.acceptUnverifiableAttestations;
@@ -34828,6 +36605,16 @@ async function verifyExitBundle(bundleDir, options = {}) {
34828
36605
  `${unverifiableCount} reputation attestation(s) have unknown signer public keys; pass --accept-unverifiable-attestations to import anyway`
34829
36606
  );
34830
36607
  }
36608
+ let detailedFailureClass;
36609
+ if (identityFailed) {
36610
+ detailedFailureClass = "identity_signature_invalid";
36611
+ } else if (reputationBundleFailed) {
36612
+ detailedFailureClass = "reputation_bundle_signature_invalid";
36613
+ } else if (reputationAttestationFailed) {
36614
+ detailedFailureClass = "reputation_attestation_signature_invalid";
36615
+ } else if (unverifiableFailed) {
36616
+ detailedFailureClass = "reputation_unverifiable_attestations";
36617
+ }
34831
36618
  return {
34832
36619
  version: "1.1",
34833
36620
  passed: !reputationFailed && !identityFailed && !unverifiableFailed,
@@ -34847,7 +36634,7 @@ async function verifyExitBundle(bundleDir, options = {}) {
34847
36634
  identity,
34848
36635
  audit,
34849
36636
  reputation,
34850
- failure_class: reputationFailed || identityFailed || unverifiableFailed ? "other" : void 0
36637
+ failure_class: detailedFailureClass
34851
36638
  };
34852
36639
  }
34853
36640
  var InvalidExitBundleError, PRIVATE_MATERIAL_KEYS;
@@ -35246,7 +37033,7 @@ async function resolveSourceMasterKey(encryptedState, opts) {
35246
37033
  }
35247
37034
  return null;
35248
37035
  }
35249
- async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIdentityId) {
37036
+ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIdentityId, importedRekeyEntries) {
35250
37037
  const destinationSigner = opts.destinationSignerIdentityId ? opts.identityManager.get(opts.destinationSignerIdentityId) : opts.identityManager.getDefault();
35251
37038
  if (!destinationSigner) {
35252
37039
  return {
@@ -35308,8 +37095,9 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
35308
37095
  }
35309
37096
  }
35310
37097
  }
37098
+ let plaintext;
35311
37099
  try {
35312
- const plaintext = decrypt(
37100
+ plaintext = decrypt(
35313
37101
  item.entry.payload,
35314
37102
  deriveNamespaceKey(sourceMasterKey, item.namespace)
35315
37103
  );
@@ -35318,28 +37106,30 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
35318
37106
  skipped++;
35319
37107
  continue;
35320
37108
  }
35321
- await stateStore.write(
35322
- item.namespace,
35323
- item.key,
35324
- bytesToString(plaintext),
35325
- destinationSigner.identity_id,
35326
- destinationSigner.encrypted_private_key,
35327
- identityEncryptionKey,
35328
- {
35329
- content_type: item.entry.metadata.content_type,
35330
- ttl_seconds: item.entry.metadata.ttl_seconds,
35331
- tags: [
35332
- ...item.entry.metadata.tags ?? [],
35333
- "exit-import",
35334
- `source:${item.entry.kid}`
35335
- ]
35336
- }
35337
- );
35338
- imported++;
35339
37109
  } catch {
35340
37110
  skippedInvalidSig++;
35341
37111
  skipped++;
37112
+ continue;
35342
37113
  }
37114
+ await stateStore.write(
37115
+ item.namespace,
37116
+ item.key,
37117
+ bytesToString(plaintext),
37118
+ destinationSigner.identity_id,
37119
+ destinationSigner.encrypted_private_key,
37120
+ identityEncryptionKey,
37121
+ {
37122
+ content_type: item.entry.metadata.content_type,
37123
+ ttl_seconds: item.entry.metadata.ttl_seconds,
37124
+ tags: [
37125
+ ...item.entry.metadata.tags ?? [],
37126
+ "exit-import",
37127
+ `source:${item.entry.kid}`
37128
+ ]
37129
+ }
37130
+ );
37131
+ imported++;
37132
+ importedRekeyEntries?.push({ namespace: item.namespace, key: item.key });
35343
37133
  }
35344
37134
  return {
35345
37135
  status: "rekeyed",
@@ -35350,6 +37140,23 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
35350
37140
  conflicts
35351
37141
  };
35352
37142
  }
37143
+ async function cleanupStagedPaths(storage, staged) {
37144
+ let removed = 0;
37145
+ const failed = [];
37146
+ for (const loc of staged) {
37147
+ try {
37148
+ const ok2 = await storage.delete(loc.namespace, loc.key);
37149
+ if (ok2) {
37150
+ removed++;
37151
+ } else {
37152
+ failed.push(loc);
37153
+ }
37154
+ } catch {
37155
+ failed.push(loc);
37156
+ }
37157
+ }
37158
+ return { removed, failed };
37159
+ }
35353
37160
  async function stageArtifact(storage, namespace, key, value) {
35354
37161
  await storage.write(namespace, key, jsonBytes(value));
35355
37162
  }
@@ -35474,6 +37281,8 @@ async function importExitBundle(opts) {
35474
37281
  }
35475
37282
  const importId = importIdForManifest(manifest);
35476
37283
  const stagedArtifacts = [];
37284
+ const stagedLocations = [];
37285
+ const importedRekeyEntries = [];
35477
37286
  if (identityArtifact) {
35478
37287
  await stageArtifact(
35479
37288
  opts.storage,
@@ -35482,10 +37291,15 @@ async function importExitBundle(opts) {
35482
37291
  identityArtifact.json
35483
37292
  );
35484
37293
  stagedArtifacts.push("public_identity");
37294
+ stagedLocations.push({
37295
+ namespace: EXIT_PUBLIC_IDENTITIES_NAMESPACE,
37296
+ key: identityArtifact.json.bundle.identity_id
37297
+ });
35485
37298
  }
35486
37299
  if (policySet) {
35487
37300
  await stageArtifact(opts.storage, EXIT_POLICY_SETS_NAMESPACE, importId, policySet.json);
35488
37301
  stagedArtifacts.push("policy_set");
37302
+ stagedLocations.push({ namespace: EXIT_POLICY_SETS_NAMESPACE, key: importId });
35489
37303
  }
35490
37304
  if (auditReceipts) {
35491
37305
  await stageArtifact(
@@ -35495,10 +37309,12 @@ async function importExitBundle(opts) {
35495
37309
  auditReceipts.json
35496
37310
  );
35497
37311
  stagedArtifacts.push("audit_receipts");
37312
+ stagedLocations.push({ namespace: EXIT_AUDIT_RECEIPTS_NAMESPACE, key: importId });
35498
37313
  }
35499
37314
  if (commitments) {
35500
37315
  await stageArtifact(opts.storage, EXIT_COMMITMENTS_NAMESPACE, importId, commitments.json);
35501
37316
  stagedArtifacts.push("commitments");
37317
+ stagedLocations.push({ namespace: EXIT_COMMITMENTS_NAMESPACE, key: importId });
35502
37318
  }
35503
37319
  if (placeholderMetadata) {
35504
37320
  await stageArtifact(
@@ -35508,12 +37324,17 @@ async function importExitBundle(opts) {
35508
37324
  placeholderMetadata.json
35509
37325
  );
35510
37326
  stagedArtifacts.push("placeholder_vault_metadata");
37327
+ stagedLocations.push({
37328
+ namespace: EXIT_PLACEHOLDER_METADATA_NAMESPACE,
37329
+ key: importId
37330
+ });
35511
37331
  }
35512
37332
  await stageArtifact(opts.storage, EXIT_IMPORT_NAMESPACE, importId, {
35513
37333
  manifest: manifest.body,
35514
37334
  verified_at: verification.verified_at,
35515
37335
  activated_at: (/* @__PURE__ */ new Date()).toISOString()
35516
37336
  });
37337
+ stagedLocations.push({ namespace: EXIT_IMPORT_NAMESPACE, key: importId });
35517
37338
  const publicKeys = identityArtifact ? publicKeysFromIdentityArtifact(identityArtifact.json) : { byIdentityId: /* @__PURE__ */ new Map(), byDid: /* @__PURE__ */ new Map() };
35518
37339
  let reputationResult = {
35519
37340
  imported_attestations: 0,
@@ -35538,26 +37359,57 @@ async function importExitBundle(opts) {
35538
37359
  encryptedState?.json ?? null,
35539
37360
  opts
35540
37361
  );
35541
- const stateResult = encryptedState && encryptedState.json.entries.length > 0 ? sourceMasterKey ? await rekeyState(
35542
- encryptedState.json,
35543
- opts,
35544
- sourceMasterKey,
35545
- publicKeys.byIdentityId
35546
- ) : {
35547
- status: "staged_requires_source_key",
35548
- imported_keys: 0,
35549
- skipped_keys: encryptedState.json.entries.length,
35550
- skipped_invalid_sig: 0,
35551
- skipped_unknown_kid: 0,
35552
- conflicts: conflicts.state_conflicts.length
35553
- } : {
35554
- status: "not_requested",
35555
- imported_keys: 0,
35556
- skipped_keys: 0,
35557
- skipped_invalid_sig: 0,
35558
- skipped_unknown_kid: 0,
35559
- conflicts: 0
35560
- };
37362
+ let stateResult;
37363
+ try {
37364
+ stateResult = encryptedState && encryptedState.json.entries.length > 0 ? sourceMasterKey ? await rekeyState(
37365
+ encryptedState.json,
37366
+ opts,
37367
+ sourceMasterKey,
37368
+ publicKeys.byIdentityId,
37369
+ importedRekeyEntries
37370
+ ) : {
37371
+ status: "staged_requires_source_key",
37372
+ imported_keys: 0,
37373
+ skipped_keys: encryptedState.json.entries.length,
37374
+ skipped_invalid_sig: 0,
37375
+ skipped_unknown_kid: 0,
37376
+ conflicts: conflicts.state_conflicts.length
37377
+ } : {
37378
+ status: "not_requested",
37379
+ imported_keys: 0,
37380
+ skipped_keys: 0,
37381
+ skipped_invalid_sig: 0,
37382
+ skipped_unknown_kid: 0,
37383
+ conflicts: 0
37384
+ };
37385
+ } catch (err) {
37386
+ const toCleanup = [
37387
+ ...importedRekeyEntries,
37388
+ ...stagedLocations
37389
+ ];
37390
+ const cleanup = await cleanupStagedPaths(opts.storage, toCleanup);
37391
+ opts.auditLog.append(
37392
+ "l1",
37393
+ "exit_bundle_rekey_failed_cleanup",
37394
+ manifest.body.identity_binding.identity_id,
37395
+ {
37396
+ import_id: importId,
37397
+ manifest_version: manifest.body.manifest_version,
37398
+ rekey_entries_removed: importedRekeyEntries.length,
37399
+ staged_artifacts_removed: stagedLocations.length,
37400
+ removed_total: cleanup.removed,
37401
+ cleanup_failed_count: cleanup.failed.length,
37402
+ original_error: err instanceof Error ? err.message : String(err)
37403
+ },
37404
+ "failure"
37405
+ );
37406
+ await opts.auditLog.flush();
37407
+ const originalMessage = err instanceof Error ? err.message : String(err);
37408
+ throw new ExitBundleImportError(
37409
+ "REKEY_FAILED_AND_CLEANED",
37410
+ `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).`
37411
+ );
37412
+ }
35561
37413
  opts.auditLog.append("l1", "exit_bundle_import_activate", manifest.body.identity_binding.identity_id, {
35562
37414
  import_id: importId,
35563
37415
  manifest_version: manifest.body.manifest_version,
@@ -35815,7 +37667,19 @@ async function runExitCommand(args) {
35815
37667
  }
35816
37668
  const config = await loadConfig();
35817
37669
  const ctx = await openExitContext(argv, env);
35818
- const policy = await loadPrincipalPolicy(ctx.storagePath);
37670
+ let policy;
37671
+ try {
37672
+ policy = await loadPrincipalPolicy(ctx.storagePath);
37673
+ } catch (policyErr) {
37674
+ if (policyErr instanceof MalformedPrincipalPolicyError) {
37675
+ write(err, `
37676
+ Sanctuary cannot proceed.
37677
+ ${policyErr.message}
37678
+ `);
37679
+ return 1;
37680
+ }
37681
+ throw policyErr;
37682
+ }
35819
37683
  const result = await exportExitBundle({
35820
37684
  bundleDir: outDir,
35821
37685
  storage: ctx.storage,
@@ -36515,7 +38379,19 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
36515
38379
  const profileStore = new SovereigntyProfileStore(storage, masterKey);
36516
38380
  await profileStore.load();
36517
38381
  const { tools: profileTools } = createSovereigntyProfileTools(profileStore, auditLog);
36518
- const policy = await loadPrincipalPolicy(config.storage_path);
38382
+ let policy;
38383
+ try {
38384
+ policy = await loadPrincipalPolicy(config.storage_path);
38385
+ } catch (err) {
38386
+ if (err instanceof MalformedPrincipalPolicyError) {
38387
+ console.error(`
38388
+ Sanctuary cannot start.
38389
+ ${err.message}
38390
+ `);
38391
+ process.exit(1);
38392
+ }
38393
+ throw err;
38394
+ }
36519
38395
  const baseline = new BaselineTracker(storage, masterKey);
36520
38396
  await baseline.load();
36521
38397
  let approvalChannel;
@@ -36612,7 +38488,35 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
36612
38488
  timestamp: alert.timestamp
36613
38489
  });
36614
38490
  } : void 0;
36615
- const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
38491
+ const fortressIdForAggregator = fortressIdFromStoragePath(config.storage_path);
38492
+ const aggregatorIdentityId = identityManager.getPrimaryIdentityId() ?? `fortress:${config.storage_path}`;
38493
+ const approvalAggregator = new ApprovalAggregator({
38494
+ storage,
38495
+ masterKey,
38496
+ auditLog,
38497
+ identityId: aggregatorIdentityId,
38498
+ fortressId: fortressIdForAggregator
38499
+ });
38500
+ const wrappedApprovalChannel = new AggregatorBackedChannel({
38501
+ underlying: approvalChannel,
38502
+ aggregator: approvalAggregator,
38503
+ resolveRedirect: makeRedirectResolverFromPolicySupplier(() => policy),
38504
+ replaceModeTimeoutMs: policy.approval_channel.timeout_seconds * 1e3
38505
+ });
38506
+ const gate = new ApprovalGate(
38507
+ policy,
38508
+ baseline,
38509
+ wrappedApprovalChannel,
38510
+ auditLog,
38511
+ injectionDetector,
38512
+ onInjectionAlert
38513
+ );
38514
+ gate.setApprovalEventCallback((event) => {
38515
+ void approvalAggregator.ingest(event);
38516
+ });
38517
+ if (dashboard) {
38518
+ dashboard.setApprovalAggregator(approvalAggregator);
38519
+ }
36616
38520
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
36617
38521
  const { tools: sanctuaryMetaTools } = createSanctuaryTools({
36618
38522
  config,
@@ -36803,6 +38707,8 @@ var init_src = __esm({
36803
38707
  init_dashboard();
36804
38708
  init_webhook();
36805
38709
  init_gate();
38710
+ init_approval_aggregator();
38711
+ init_aggregator_backed_channel();
36806
38712
  init_tools4();
36807
38713
  init_router();
36808
38714
  init_router();
@@ -39767,6 +41673,22 @@ var init_broker = __esm({
39767
41673
  auditLog;
39768
41674
  issuer;
39769
41675
  principalIdentityId;
41676
+ /**
41677
+ * Per-secret-name mutex. Hardening wave 6 finding #64: two concurrent
41678
+ * addSecret() / rotateSecret() / deleteSecret() calls on the same name
41679
+ * MUST serialize cleanly. The keychain backend's `find-then-add` and
41680
+ * `find-then-delete-then-add` shapes (KeychainBackend.addSecret /
41681
+ * .rotateSecret) are not atomic against another caller racing the same
41682
+ * service-name; without serialization the second caller can observe a
41683
+ * stale "exists" check and either drop the new value or leave a
41684
+ * duplicate keychain entry.
41685
+ *
41686
+ * Implementation: an in-memory promise chain per name. Subsequent
41687
+ * callers `await` the chain tail and append their own work; failures
41688
+ * propagate to the failing caller without poisoning the chain for
41689
+ * later callers.
41690
+ */
41691
+ nameLocks = /* @__PURE__ */ new Map();
39770
41692
  constructor(opts) {
39771
41693
  this.backend = opts.backend;
39772
41694
  this.auditLog = opts.auditLog;
@@ -39777,6 +41699,40 @@ var init_broker = __esm({
39777
41699
  grants: opts.grants
39778
41700
  });
39779
41701
  }
41702
+ /**
41703
+ * Serialize `op` against any other in-flight write to the same secret
41704
+ * `name`. Per-name fairness only, distinct names run in parallel.
41705
+ * The current chain tail is used as the acceptance gate; we then
41706
+ * publish a new tail that swallows the operation's outcome so a
41707
+ * thrown error does not poison the next caller's wait.
41708
+ */
41709
+ async withNameLock(name, op) {
41710
+ const previous = this.nameLocks.get(name) ?? Promise.resolve();
41711
+ let release = () => {
41712
+ };
41713
+ const next = new Promise((resolve8) => {
41714
+ release = resolve8;
41715
+ });
41716
+ this.nameLocks.set(name, next);
41717
+ try {
41718
+ await previous.catch(() => {
41719
+ });
41720
+ return await op();
41721
+ } finally {
41722
+ release();
41723
+ if (this.nameLocks.get(name) === next) {
41724
+ this.nameLocks.delete(name);
41725
+ }
41726
+ }
41727
+ }
41728
+ /**
41729
+ * Diagnostic-only: visible for tests so they can assert that distinct
41730
+ * names do not contend on a shared lock. Not part of the public broker
41731
+ * contract; do not consume from production code.
41732
+ */
41733
+ __nameLockCountForTests() {
41734
+ return this.nameLocks.size;
41735
+ }
39780
41736
  /** Ensure backend is initialized and unlocked. Audits the unlock. */
39781
41737
  async ensureUnlocked(passphrase) {
39782
41738
  await this.backend.ensureInitialized(passphrase);
@@ -39789,31 +41745,37 @@ var init_broker = __esm({
39789
41745
  );
39790
41746
  }
39791
41747
  async addSecret(name, value) {
39792
- await this.backend.addSecret(name, value);
39793
- this.auditLog.append(
39794
- "l3",
39795
- BROKER_OPS.SECRET_ADDED,
39796
- this.principalIdentityId,
39797
- { secret: name }
39798
- );
41748
+ await this.withNameLock(name, async () => {
41749
+ await this.backend.addSecret(name, value);
41750
+ this.auditLog.append(
41751
+ "l3",
41752
+ BROKER_OPS.SECRET_ADDED,
41753
+ this.principalIdentityId,
41754
+ { secret: name }
41755
+ );
41756
+ });
39799
41757
  }
39800
41758
  async rotateSecret(name, newValue) {
39801
- await this.backend.rotateSecret(name, newValue);
39802
- this.auditLog.append(
39803
- "l3",
39804
- BROKER_OPS.SECRET_ROTATED,
39805
- this.principalIdentityId,
39806
- { secret: name }
39807
- );
41759
+ await this.withNameLock(name, async () => {
41760
+ await this.backend.rotateSecret(name, newValue);
41761
+ this.auditLog.append(
41762
+ "l3",
41763
+ BROKER_OPS.SECRET_ROTATED,
41764
+ this.principalIdentityId,
41765
+ { secret: name }
41766
+ );
41767
+ });
39808
41768
  }
39809
41769
  async deleteSecret(name) {
39810
- await this.backend.deleteSecret(name);
39811
- this.auditLog.append(
39812
- "l3",
39813
- BROKER_OPS.SECRET_DELETED,
39814
- this.principalIdentityId,
39815
- { secret: name }
39816
- );
41770
+ await this.withNameLock(name, async () => {
41771
+ await this.backend.deleteSecret(name);
41772
+ this.auditLog.append(
41773
+ "l3",
41774
+ BROKER_OPS.SECRET_DELETED,
41775
+ this.principalIdentityId,
41776
+ { secret: name }
41777
+ );
41778
+ });
39817
41779
  }
39818
41780
  async listSecretNames() {
39819
41781
  return this.backend.listSecretNames();
@@ -39853,6 +41815,19 @@ var init_broker = __esm({
39853
41815
  liveTokenCount() {
39854
41816
  return this.issuer.liveTokenCount();
39855
41817
  }
41818
+ /**
41819
+ * Drop expired tokens from the in-memory issuer map. Hardening wave 6
41820
+ * finding #86: previously expiry pruning depended on opportunistic
41821
+ * `pruneExpired()` calls; now the cocoon-unlock initialization path
41822
+ * (openBroker -> after backend.ensureInitialized -> after Broker
41823
+ * construction) fires this once so each cocoon-unlock cycle drops
41824
+ * stale bindings before any operator interaction.
41825
+ *
41826
+ * Returns the number of tokens removed. Safe to call repeatedly; idempotent.
41827
+ */
41828
+ pruneExpiredTokens() {
41829
+ return this.issuer.pruneExpired();
41830
+ }
39856
41831
  /**
39857
41832
  * Audit query restricted to broker-scoped operations. Returns entries
39858
41833
  * with their timestamps, op, and result (never the secret value).
@@ -40011,6 +41986,7 @@ async function openBroker(opts = {}) {
40011
41986
  grants,
40012
41987
  principalIdentityId: opts.principalIdentityId ?? "sanctuary-broker"
40013
41988
  });
41989
+ broker.pruneExpiredTokens();
40014
41990
  return {
40015
41991
  broker,
40016
41992
  close: async () => {
@@ -40879,8 +42855,6 @@ var init_health = __esm({
40879
42855
  DEFAULT_TIMEOUT_MS4 = 500;
40880
42856
  }
40881
42857
  });
40882
-
40883
- // src/cli/agents/cli.ts
40884
42858
  function resolveCtx(args) {
40885
42859
  const env = args.env ?? process.env;
40886
42860
  const discoverOpts = {
@@ -40918,6 +42892,8 @@ async function runAgentsCommand(args) {
40918
42892
  return await cmdShow2(rest, ctx);
40919
42893
  case "status":
40920
42894
  return await cmdStatus(rest, ctx);
42895
+ case "config":
42896
+ return await cmdConfig(rest, ctx);
40921
42897
  default:
40922
42898
  ctx.err.write(`Unknown subcommand: ${sub}
40923
42899
  `);
@@ -40933,10 +42909,18 @@ async function runAgentsCommand(args) {
40933
42909
  }
40934
42910
  function printUsage4(s) {
40935
42911
  s.write(`Usage: sanctuary agents <command> [flags]
42912
+ sanctuary agent <command> [flags] (alias)
40936
42913
 
40937
42914
  list [--json] List every tenant visible on this host.
40938
- show <tenant> [--json] Show details for one tenant.
42915
+ show <tenant> [--json] Show details for one tenant (includes
42916
+ approval-redirect state).
40939
42917
  status [--json] One-line-per-tenant running/stopped summary.
42918
+ config <tenant> [opts] Write tenant principal-policy.yaml fields.
42919
+ --approval-redirect=<bool> Toggle cross-harness inbox redirect.
42920
+ --approval-redirect-mode=<replace|notify>
42921
+ Pick replace (bypass underlying channel)
42922
+ or notify (race both paths). Default
42923
+ replace when toggled on.
40940
42924
 
40941
42925
  Options:
40942
42926
  --fortress <path> Scope discovery to a specific storage path
@@ -41038,6 +43022,7 @@ async function cmdShow2(argv, ctx) {
41038
43022
  return 1;
41039
43023
  }
41040
43024
  const probe = await ctx.probe(tenant);
43025
+ const approvalRedirect = await readApprovalRedirectState(tenant);
41041
43026
  const payload = {
41042
43027
  name: tenant.name,
41043
43028
  storage_path: tenant.storage_path,
@@ -41051,7 +43036,8 @@ async function cmdShow2(argv, ctx) {
41051
43036
  running: probe.running,
41052
43037
  status: probe.status,
41053
43038
  reason: probe.reason
41054
- }
43039
+ },
43040
+ approval_redirect: approvalRedirect
41055
43041
  };
41056
43042
  if (hasJsonFlag(argv)) {
41057
43043
  ctx.out.write(JSON.stringify(payload, null, 2) + "\n");
@@ -41097,8 +43083,171 @@ async function cmdShow2(argv, ctx) {
41097
43083
  `probe: ${probe.running ? "running" : "not-running"}${probe.reason ? ` (${probe.reason})` : ""}
41098
43084
  `
41099
43085
  );
43086
+ ctx.out.write(
43087
+ `approval_redirect: ${approvalRedirect.enabled ? `on (${approvalRedirect.mode})` : "off"}
43088
+ `
43089
+ );
43090
+ return 0;
43091
+ }
43092
+ async function readApprovalRedirectState(tenant) {
43093
+ const policyPath = join(tenant.storage_path, "principal-policy.yaml");
43094
+ try {
43095
+ const content = await readFile(policyPath, "utf-8");
43096
+ const parsed = parsePolicy(content);
43097
+ const cfg = parsed.approval_redirect;
43098
+ if (!cfg) return { enabled: false, mode: "replace" };
43099
+ return {
43100
+ enabled: !!cfg.enabled,
43101
+ mode: cfg.mode === "notify" ? "notify" : "replace"
43102
+ };
43103
+ } catch {
43104
+ return { enabled: false, mode: "replace" };
43105
+ }
43106
+ }
43107
+ function parseBoolFlag(raw) {
43108
+ if (raw === void 0) return null;
43109
+ const v = raw.toLowerCase();
43110
+ if (v === "true" || v === "yes" || v === "on" || v === "1") return true;
43111
+ if (v === "false" || v === "no" || v === "off" || v === "0") return false;
43112
+ return null;
43113
+ }
43114
+ function findFlagValue(argv, name) {
43115
+ for (let i = 0; i < argv.length; i++) {
43116
+ const a = argv[i];
43117
+ if (a === name) {
43118
+ return argv[i + 1];
43119
+ }
43120
+ const eq = `${name}=`;
43121
+ if (a.startsWith(eq)) {
43122
+ return a.slice(eq.length);
43123
+ }
43124
+ }
43125
+ return void 0;
43126
+ }
43127
+ async function cmdConfig(argv, ctx) {
43128
+ const positional = argv.find((a) => !a.startsWith("--"));
43129
+ if (!positional) {
43130
+ ctx.err.write(
43131
+ "Missing tenant. Usage: sanctuary agents config <tenant> --approval-redirect=<bool>\n"
43132
+ );
43133
+ return 2;
43134
+ }
43135
+ const tenant = await findTenant(positional, ctx.discoverOpts);
43136
+ if (!tenant) {
43137
+ ctx.err.write(`sanctuary agents: unknown tenant "${positional}"
43138
+ `);
43139
+ return 1;
43140
+ }
43141
+ const redirectFlag = parseBoolFlag(
43142
+ findFlagValue(argv, "--approval-redirect")
43143
+ );
43144
+ const modeFlag = findFlagValue(argv, "--approval-redirect-mode");
43145
+ if (redirectFlag === null && modeFlag === void 0) {
43146
+ ctx.err.write(
43147
+ "sanctuary agents config: nothing to do. Pass --approval-redirect=<bool> or --approval-redirect-mode=<replace|notify>.\n"
43148
+ );
43149
+ return 2;
43150
+ }
43151
+ if (modeFlag !== void 0 && modeFlag !== "replace" && modeFlag !== "notify") {
43152
+ ctx.err.write(
43153
+ `sanctuary agents config: --approval-redirect-mode must be "replace" or "notify" (got "${modeFlag}")
43154
+ `
43155
+ );
43156
+ return 2;
43157
+ }
43158
+ const current = await readApprovalRedirectState(tenant);
43159
+ const next = {
43160
+ enabled: redirectFlag !== null ? redirectFlag : current.enabled,
43161
+ mode: modeFlag === "notify" || modeFlag === "replace" ? modeFlag : current.mode
43162
+ };
43163
+ await writeApprovalRedirectToPolicyFile(tenant.storage_path, next);
43164
+ if (hasJsonFlag(argv)) {
43165
+ ctx.out.write(
43166
+ JSON.stringify(
43167
+ {
43168
+ tenant: tenant.name,
43169
+ approval_redirect: next
43170
+ },
43171
+ null,
43172
+ 2
43173
+ ) + "\n"
43174
+ );
43175
+ } else {
43176
+ ctx.out.write(
43177
+ `sanctuary agents config: tenant "${tenant.name}" approval_redirect=${next.enabled ? `on (${next.mode})` : "off"}
43178
+ `
43179
+ );
43180
+ ctx.out.write(
43181
+ ` Takes effect on the next gate request for the running server.
43182
+ `
43183
+ );
43184
+ }
41100
43185
  return 0;
41101
43186
  }
43187
+ async function writeApprovalRedirectToPolicyFile(storagePath, state) {
43188
+ const policyPath = join(storagePath, "principal-policy.yaml");
43189
+ let content;
43190
+ try {
43191
+ content = await readFile(policyPath, "utf-8");
43192
+ } catch (err) {
43193
+ const code = err?.code;
43194
+ if (code !== "ENOENT") throw err;
43195
+ content = await defaultPolicyTextForBootstrap();
43196
+ }
43197
+ const block = renderApprovalRedirectBlock(state);
43198
+ const updated = upsertApprovalRedirectBlock(content, block);
43199
+ await writeFile(policyPath, updated, "utf-8");
43200
+ await chmod(policyPath, 384);
43201
+ }
43202
+ function renderApprovalRedirectBlock(state) {
43203
+ return [
43204
+ "# Approval Redirect (v1.3 WP-V1.3-10 Upsilon-2)",
43205
+ "approval_redirect:",
43206
+ ` enabled: ${state.enabled ? "true" : "false"}`,
43207
+ ` mode: ${state.mode}`
43208
+ ].join("\n");
43209
+ }
43210
+ function upsertApprovalRedirectBlock(content, block) {
43211
+ const lines = content.split("\n");
43212
+ const startIdx = lines.findIndex((l) => l.startsWith("approval_redirect:"));
43213
+ if (startIdx === -1) {
43214
+ const trimmed = content.endsWith("\n") ? content : content + "\n";
43215
+ return trimmed + "\n" + block + "\n";
43216
+ }
43217
+ let blockStart = startIdx;
43218
+ if (blockStart > 0 && lines[blockStart - 1] !== void 0 && lines[blockStart - 1].startsWith("# Approval Redirect")) {
43219
+ blockStart = blockStart - 1;
43220
+ }
43221
+ let blockEnd = startIdx + 1;
43222
+ while (blockEnd < lines.length) {
43223
+ const l = lines[blockEnd];
43224
+ if (l === "") {
43225
+ blockEnd++;
43226
+ continue;
43227
+ }
43228
+ if (/^[A-Za-z0-9#]/.test(l)) {
43229
+ break;
43230
+ }
43231
+ blockEnd++;
43232
+ }
43233
+ const before = lines.slice(0, blockStart);
43234
+ const after = lines.slice(blockEnd);
43235
+ const replaced = [...before, ...block.split("\n"), ...after].join("\n");
43236
+ return replaced.endsWith("\n") ? replaced : replaced + "\n";
43237
+ }
43238
+ async function defaultPolicyTextForBootstrap() {
43239
+ return [
43240
+ "version: 1",
43241
+ "tier1_always_approve:",
43242
+ " - state_export",
43243
+ " - state_import",
43244
+ " - state_delete",
43245
+ "approval_channel:",
43246
+ " type: stderr",
43247
+ " timeout_seconds: 300",
43248
+ ""
43249
+ ].join("\n");
43250
+ }
41102
43251
  async function cmdStatus(argv, ctx) {
41103
43252
  const tenants = await discoverTenants(ctx.discoverOpts);
41104
43253
  const probes = await Promise.all(tenants.map((t) => ctx.probe(t)));
@@ -41139,6 +43288,7 @@ var init_cli5 = __esm({
41139
43288
  "src/cli/agents/cli.ts"() {
41140
43289
  init_discovery();
41141
43290
  init_health();
43291
+ init_loader();
41142
43292
  }
41143
43293
  });
41144
43294
 
@@ -41166,7 +43316,8 @@ var init_agents = __esm({
41166
43316
  // src/cli/reset-passphrase.ts
41167
43317
  var reset_passphrase_exports = {};
41168
43318
  __export(reset_passphrase_exports, {
41169
- runResetPassphraseCommand: () => runResetPassphraseCommand
43319
+ runResetPassphraseCommand: () => runResetPassphraseCommand,
43320
+ zeroizeBuffers: () => zeroizeBuffers
41170
43321
  });
41171
43322
  async function runResetPassphraseCommand(args) {
41172
43323
  const out = args.out ?? process.stdout;
@@ -41194,34 +43345,42 @@ Then re-run this command.
41194
43345
  return 1;
41195
43346
  }
41196
43347
  const lines = new LineReader(stdin);
43348
+ let code = 1;
43349
+ let nukeSucceeded = false;
41197
43350
  try {
41198
43351
  const availability = await surveyAvailableModes(storagePath);
41199
43352
  const mode = parsed.mode ?? await selectMode(lines, out, err, availability);
41200
43353
  if (!mode) {
41201
43354
  err.write("Aborted: no recovery mode selected.\n");
41202
- return 1;
41203
- }
41204
- if (mode === "shares") {
41205
- return await runSharesPath(out, err, availability);
41206
- }
41207
- if (mode === "guardian") {
41208
- return await runGuardianPath(out, err, availability);
43355
+ code = 1;
43356
+ } else if (mode === "shares") {
43357
+ code = await runSharesPath(out, err, availability);
43358
+ } else if (mode === "guardian") {
43359
+ code = await runGuardianPath(out, err, availability);
43360
+ } else {
43361
+ code = await runNukePath({
43362
+ out,
43363
+ err,
43364
+ lines,
43365
+ storagePath,
43366
+ home,
43367
+ plat,
43368
+ exec: args.exec ?? defaultExec2
43369
+ });
43370
+ nukeSucceeded = mode === "nuke" && code === 0;
41209
43371
  }
41210
- return await runNukePath({
41211
- out,
41212
- err,
41213
- lines,
41214
- storagePath,
41215
- home,
41216
- plat,
41217
- exec: args.exec ?? defaultExec2
41218
- });
41219
43372
  } finally {
43373
+ zeroizeBuffers(args.keyMaterialToZeroize);
41220
43374
  lines.close();
41221
43375
  }
43376
+ if (parsed.exitOnCompletion && nukeSucceeded) {
43377
+ const doExit = args.exitProcess ?? ((c) => process.exit(c));
43378
+ doExit(0);
43379
+ }
43380
+ return code;
41222
43381
  }
41223
43382
  function parseArgs2(argv) {
41224
- const out = { help: false };
43383
+ const out = { exitOnCompletion: false, help: false };
41225
43384
  for (let i = 0; i < argv.length; i++) {
41226
43385
  const a = argv[i];
41227
43386
  if (a === "--help" || a === "-h") {
@@ -41238,6 +43397,8 @@ function parseArgs2(argv) {
41238
43397
  out.storage = argv[++i];
41239
43398
  } else if (a === "--fortress" && argv[i + 1]) {
41240
43399
  out.fortress = argv[++i];
43400
+ } else if (a === "--exit-on-completion") {
43401
+ out.exitOnCompletion = true;
41241
43402
  } else if (a && a.startsWith("--")) {
41242
43403
  throw new Error(`Unknown flag: ${a}`);
41243
43404
  }
@@ -41270,6 +43431,16 @@ Options:
41270
43431
  --fortress <path> Override the fortress storage path.
41271
43432
  Consistent with "sanctuary wrap --fortress".
41272
43433
  --storage <path> Alias for --fortress.
43434
+ --exit-on-completion After a successful nuke, call process.exit(0)
43435
+ immediately so the post-wipe heap is reaped
43436
+ by the OS without re-entering the shell. Use
43437
+ on extreme-threat-model deployments where an
43438
+ attacker-on-host with heap-dump access could
43439
+ recover residual passphrase or key bytes
43440
+ between the wipe and the next operator
43441
+ command. JS strings cannot be explicitly
43442
+ zeroed; this flag is the supported way to
43443
+ bound the heap-dump window.
41273
43444
  --help, -h Show this help.
41274
43445
 
41275
43446
  Without --mode, the command surveys which paths are operationally available
@@ -41539,6 +43710,16 @@ async function prompt(lines, err, question) {
41539
43710
  err.write(question);
41540
43711
  return await lines.next();
41541
43712
  }
43713
+ function zeroizeBuffers(buffers) {
43714
+ if (!buffers) return;
43715
+ for (const b of buffers) {
43716
+ if (!b) continue;
43717
+ try {
43718
+ b.fill(0);
43719
+ } catch {
43720
+ }
43721
+ }
43722
+ }
41542
43723
  async function defaultExec2(cmd, args) {
41543
43724
  return await new Promise((resolve8, reject) => {
41544
43725
  const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -42219,7 +44400,19 @@ Refusing to start the dashboard while the reset-history marker is unreadable.`
42219
44400
  }
42220
44401
  throw err;
42221
44402
  }
42222
- const policy = await loadPrincipalPolicy(config.storage_path);
44403
+ let policy;
44404
+ try {
44405
+ policy = await loadPrincipalPolicy(config.storage_path);
44406
+ } catch (err) {
44407
+ if (err instanceof MalformedPrincipalPolicyError) {
44408
+ console.error(`
44409
+ Sanctuary cannot start.
44410
+ ${err.message}
44411
+ `);
44412
+ process.exit(1);
44413
+ }
44414
+ throw err;
44415
+ }
42223
44416
  const baseline = new BaselineTracker(storage, masterKey);
42224
44417
  await baseline.load();
42225
44418
  const dashboardPort = options.port ?? config.dashboard.port;
@@ -42524,7 +44717,7 @@ async function main() {
42524
44717
  const code = await runIdentityCommand2({ argv: args.slice(1) });
42525
44718
  process.exit(code);
42526
44719
  }
42527
- if (args[0] === "agents") {
44720
+ if (args[0] === "agents" || args[0] === "agent") {
42528
44721
  const { runAgentsCommand: runAgentsCommand2 } = await Promise.resolve().then(() => (init_agents(), agents_exports));
42529
44722
  const code = await runAgentsCommand2({ argv: args.slice(1) });
42530
44723
  process.exit(code);