@sanctuary-framework/mcp-server 1.2.4 → 1.2.6

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,6 +4592,21 @@ 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) {
@@ -4601,7 +4643,7 @@ async function loadPrincipalPolicy(storagePath) {
4601
4643
  );
4602
4644
  }
4603
4645
  }
4604
- var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_POLICY, MalformedPrincipalPolicyError;
4646
+ var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_APPROVAL_REDIRECT, DEFAULT_POLICY, MalformedPrincipalPolicyError;
4605
4647
  var init_loader = __esm({
4606
4648
  "src/principal-policy/loader.ts"() {
4607
4649
  DEFAULT_TIER2 = {
@@ -4618,6 +4660,10 @@ var init_loader = __esm({
4618
4660
  // SEC-002: auto_deny is not configurable. Timeout always denies.
4619
4661
  // Field omitted intentionally — all channels hardcode deny on timeout.
4620
4662
  };
4663
+ DEFAULT_APPROVAL_REDIRECT = {
4664
+ enabled: false,
4665
+ mode: "replace"
4666
+ };
4621
4667
  DEFAULT_POLICY = {
4622
4668
  version: 1,
4623
4669
  tier1_always_approve: [
@@ -4691,6 +4737,7 @@ var init_loader = __esm({
4691
4737
  "handshake_status",
4692
4738
  "handshake_exchange",
4693
4739
  "handshake_verify_attestation",
4740
+ "handshake_abort",
4694
4741
  "reputation_query_weighted",
4695
4742
  "federation_peers",
4696
4743
  "federation_trust_evaluate",
@@ -4732,7 +4779,8 @@ var init_loader = __esm({
4732
4779
  "compliance_eu_ai_act_annex_iii_classify"
4733
4780
  // Read-only; rule-based Annex III classifier
4734
4781
  ],
4735
- approval_channel: DEFAULT_CHANNEL
4782
+ approval_channel: DEFAULT_CHANNEL,
4783
+ approval_redirect: DEFAULT_APPROVAL_REDIRECT
4736
4784
  };
4737
4785
  MalformedPrincipalPolicyError = class extends Error {
4738
4786
  constructor(policyPath, reason) {
@@ -17163,6 +17211,27 @@ async function handleApprovalInboxRoute(deps, req, res) {
17163
17211
  await handleStream2(deps, res);
17164
17212
  return true;
17165
17213
  }
17214
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/history`) {
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 sinceTs = url.searchParams.get("since") ?? void 0;
17222
+ const operatorId = deps.operatorId ?? APPROVAL_INBOX_OPERATOR_DEFAULT;
17223
+ const filterStatus = statusRaw && isStatusFilter(statusRaw) && statusRaw !== "pending" ? statusRaw : void 0;
17224
+ const entries = await deps.aggregator.getHistory(
17225
+ {
17226
+ limit,
17227
+ ...filterStatus !== void 0 ? { status: filterStatus } : {},
17228
+ ...sinceTs !== void 0 ? { sinceTs } : {}
17229
+ },
17230
+ operatorId
17231
+ );
17232
+ writeJSON4(res, 200, { ok: true, data: { entries } });
17233
+ return true;
17234
+ }
17166
17235
  if (method === "GET" && path === APPROVAL_INBOX_API_PREFIX) {
17167
17236
  const limit = parseLimit2(
17168
17237
  url.searchParams.get("limit"),
@@ -17185,11 +17254,39 @@ async function handleApprovalInboxRoute(deps, req, res) {
17185
17254
  writeJSON4(res, 404, { ok: false, error: "not_found", path });
17186
17255
  return true;
17187
17256
  }
17188
- if (method === "GET" && entryMatch.action === null) {
17189
- const entries = await deps.aggregator.list({ limit: APPROVAL_INBOX_MAX_LIMIT });
17190
- const entry = entries.find(
17191
- (e) => e.aggregator_id === entryMatch.aggregatorId
17257
+ if (method === "GET" && entryMatch.action === "audit-trail") {
17258
+ const entry = await deps.aggregator.getEntry(entryMatch.aggregatorId);
17259
+ if (!entry) {
17260
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
17261
+ return true;
17262
+ }
17263
+ const operatorId = deps.operatorId ?? APPROVAL_INBOX_OPERATOR_DEFAULT;
17264
+ const trail = await deps.aggregator.getAuditTrail(
17265
+ entryMatch.aggregatorId,
17266
+ operatorId
17267
+ );
17268
+ writeJSON4(res, 200, { ok: true, data: { entry, audit_trail: trail } });
17269
+ return true;
17270
+ }
17271
+ if (method === "GET" && entryMatch.action === "payload") {
17272
+ const entry = await deps.aggregator.getEntry(entryMatch.aggregatorId);
17273
+ if (!entry) {
17274
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
17275
+ return true;
17276
+ }
17277
+ const operatorId = deps.operatorId ?? APPROVAL_INBOX_OPERATOR_DEFAULT;
17278
+ const payload = await deps.aggregator.getFullPayloadWithAudit(
17279
+ entryMatch.aggregatorId,
17280
+ operatorId
17192
17281
  );
17282
+ writeJSON4(res, 200, {
17283
+ ok: true,
17284
+ data: { entry, request_payload: payload }
17285
+ });
17286
+ return true;
17287
+ }
17288
+ if (method === "GET" && entryMatch.action === null) {
17289
+ const entry = await deps.aggregator.getEntry(entryMatch.aggregatorId);
17193
17290
  if (!entry) {
17194
17291
  writeJSON4(res, 404, { ok: false, error: "not_found" });
17195
17292
  return true;
@@ -20223,7 +20320,10 @@ var init_approval_aggregator = __esm({
20223
20320
  APPROVAL_AGGREGATOR_AUDIT_OPS = {
20224
20321
  AGGREGATED: "cross_harness_approval_aggregated",
20225
20322
  RESOLVED: "cross_harness_approval_resolved",
20226
- DEDUPED: "cross_harness_approval_deduped"
20323
+ DEDUPED: "cross_harness_approval_deduped",
20324
+ PAYLOAD_DECRYPTED: "cross_harness_approval_payload_decrypted",
20325
+ AUDIT_TRAIL_VIEWED: "cross_harness_approval_audit_trail_viewed",
20326
+ REPLAYED: "cross_harness_approval_replayed"
20227
20327
  };
20228
20328
  DEFAULT_PENDING_TTL_MS = 5 * 60 * 1e3;
20229
20329
  DEFAULT_MAX_LIST_LIMIT = 200;
@@ -20239,6 +20339,8 @@ var init_approval_aggregator = __esm({
20239
20339
  now;
20240
20340
  resolveSourceContext;
20241
20341
  resolveHubInboxItemId;
20342
+ payloadStore;
20343
+ resolveEnforcementChain;
20242
20344
  /** Cached entries by `aggregator_id`. */
20243
20345
  entries = /* @__PURE__ */ new Map();
20244
20346
  /** Dedup index: `${harness}|${agent}|${audit_id}` -> aggregator_id. */
@@ -20268,6 +20370,14 @@ var init_approval_aggregator = __esm({
20268
20370
  source_agent_id: this.fortressId
20269
20371
  }));
20270
20372
  this.resolveHubInboxItemId = deps.resolveHubInboxItemId ?? ((_event) => void 0);
20373
+ this.payloadStore = deps.payloadStore ?? null;
20374
+ this.resolveEnforcementChain = deps.resolveEnforcementChain ?? ((event) => [
20375
+ {
20376
+ layer: "l2",
20377
+ event: `approval_required:${event.operation}`,
20378
+ timestamp: event.request_timestamp
20379
+ }
20380
+ ]);
20271
20381
  }
20272
20382
  /**
20273
20383
  * Subscribe an event listener. Returns an unsubscribe fn. SSE handlers
@@ -20318,13 +20428,152 @@ var init_approval_aggregator = __esm({
20318
20428
  }
20319
20429
  /**
20320
20430
  * Return the original (unhashed) request payload for the entry. Returns
20321
- * `null` when the entry is unknown or the payload was evicted (e.g. the
20322
- * process restarted; payloads are in-memory only at v1.3 Upsilon-1).
20431
+ * `null` when the entry is unknown. When the in-memory payload map has
20432
+ * been evicted (e.g. after a server restart) and a `payloadStore` was
20433
+ * provided, the at-rest bundle is decrypted and the in-memory map is
20434
+ * refilled. Audit emission lives on the `*WithAudit` variant; this base
20435
+ * accessor is silent so internal callers can read without polluting the
20436
+ * audit trail.
20323
20437
  */
20324
20438
  async getFullPayload(aggregatorId) {
20325
20439
  await this.hydrate();
20326
20440
  if (!this.entries.has(aggregatorId)) return null;
20327
- return this.fullPayloads.get(aggregatorId) ?? null;
20441
+ const cached = this.fullPayloads.get(aggregatorId);
20442
+ if (cached !== void 0) return cached;
20443
+ if (this.payloadStore) {
20444
+ try {
20445
+ const restored = await this.payloadStore.loadPayload(aggregatorId);
20446
+ if (restored !== null) {
20447
+ this.fullPayloads.set(aggregatorId, restored);
20448
+ return restored;
20449
+ }
20450
+ } catch {
20451
+ }
20452
+ }
20453
+ return null;
20454
+ }
20455
+ /**
20456
+ * Return the entry record for the given id, or null when unknown.
20457
+ * Idempotent. v1.3 Upsilon-3.
20458
+ */
20459
+ async getEntry(aggregatorId) {
20460
+ await this.hydrate();
20461
+ return this.entries.get(aggregatorId) ?? null;
20462
+ }
20463
+ /**
20464
+ * Audited variant of `getFullPayload`. Emits the
20465
+ * `cross_harness_approval_payload_decrypted` audit event before
20466
+ * returning. Used by the operator-facing /payload replay route.
20467
+ * v1.3 Upsilon-3.
20468
+ */
20469
+ async getFullPayloadWithAudit(aggregatorId, operatorId) {
20470
+ const payload = await this.getFullPayload(aggregatorId);
20471
+ if (payload === null) return null;
20472
+ const entry = this.entries.get(aggregatorId);
20473
+ this.auditLog.append(
20474
+ "l2",
20475
+ APPROVAL_AGGREGATOR_AUDIT_OPS.PAYLOAD_DECRYPTED,
20476
+ operatorId,
20477
+ {
20478
+ aggregator_id: aggregatorId,
20479
+ ...entry ? {
20480
+ source_harness: entry.source_harness,
20481
+ source_agent_id: entry.source_agent_id,
20482
+ entry_status: entry.status
20483
+ } : {}
20484
+ }
20485
+ );
20486
+ return payload;
20487
+ }
20488
+ /**
20489
+ * Return the audit-log entries that led to and surround this approval.
20490
+ * Best-effort matching: aggregator-side emissions (AGGREGATED, RESOLVED,
20491
+ * DEDUPED, replay events) all carry `details.aggregator_id` and link
20492
+ * directly. Gate-side emissions (`gate_*:operation`) do not carry the
20493
+ * aggregator id at v1.3, so they are matched via timestamp window
20494
+ * (entry.created_at to entry.resolved_at + 1s, or expires_at + 1s while
20495
+ * pending) and operation suffix. Emits AUDIT_TRAIL_VIEWED on call.
20496
+ * v1.3 Upsilon-3.
20497
+ */
20498
+ async getAuditTrail(aggregatorId, operatorId) {
20499
+ await this.hydrate();
20500
+ const entry = this.entries.get(aggregatorId);
20501
+ if (!entry) {
20502
+ return [];
20503
+ }
20504
+ const sinceMs = Date.parse(entry.created_at) - 1e3;
20505
+ const sinceIso = new Date(sinceMs).toISOString();
20506
+ const queried = await this.auditLog.query({ since: sinceIso, limit: 1e3 });
20507
+ const operationPart = entry.policy_rule_id.includes(":") ? entry.policy_rule_id.slice(entry.policy_rule_id.indexOf(":") + 1) : entry.policy_rule_id;
20508
+ const lifetimeStart = sinceMs;
20509
+ const lifetimeEnd = entry.resolved_at ? Date.parse(entry.resolved_at) + 1e3 : Date.parse(entry.expires_at) + 1e3;
20510
+ const matches = [];
20511
+ for (const audit of queried.entries) {
20512
+ const detailsId = audit.details !== void 0 ? audit.details["aggregator_id"] : void 0;
20513
+ if (detailsId === aggregatorId) {
20514
+ matches.push(audit);
20515
+ continue;
20516
+ }
20517
+ const auditMs = Date.parse(audit.timestamp);
20518
+ if (auditMs < lifetimeStart || auditMs > lifetimeEnd) continue;
20519
+ if (audit.operation.endsWith(`:${operationPart}`)) {
20520
+ matches.push(audit);
20521
+ }
20522
+ }
20523
+ matches.sort(
20524
+ (a, b) => a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0
20525
+ );
20526
+ this.auditLog.append(
20527
+ "l2",
20528
+ APPROVAL_AGGREGATOR_AUDIT_OPS.AUDIT_TRAIL_VIEWED,
20529
+ operatorId,
20530
+ {
20531
+ aggregator_id: aggregatorId,
20532
+ entry_status: entry.status,
20533
+ match_count: matches.length
20534
+ }
20535
+ );
20536
+ return matches;
20537
+ }
20538
+ /**
20539
+ * List historical (resolved) approvals. Excludes pending entries by
20540
+ * design: `list()` is the pending-inbox surface and `getHistory()` is
20541
+ * the resolved-replay surface. Emits REPLAYED on each call. v1.3
20542
+ * Upsilon-3.
20543
+ */
20544
+ async getHistory(opts, operatorId) {
20545
+ await this.hydrate();
20546
+ await this.expireStale();
20547
+ const limit = Math.min(
20548
+ opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
20549
+ this.maxListLimit
20550
+ );
20551
+ const sinceMs = opts?.sinceTs ? Date.parse(opts.sinceTs) : Number.NEGATIVE_INFINITY;
20552
+ const matching = [];
20553
+ for (const entry of this.entries.values()) {
20554
+ if (entry.status === "pending") continue;
20555
+ if (opts?.status && entry.status !== opts.status) continue;
20556
+ const stamp = Date.parse(entry.resolved_at ?? entry.created_at);
20557
+ if (stamp < sinceMs) continue;
20558
+ matching.push(entry);
20559
+ }
20560
+ matching.sort((a, b) => {
20561
+ const aStamp = a.resolved_at ?? a.created_at;
20562
+ const bStamp = b.resolved_at ?? b.created_at;
20563
+ return bStamp.localeCompare(aStamp);
20564
+ });
20565
+ const sliced = matching.slice(0, limit);
20566
+ this.auditLog.append(
20567
+ "l2",
20568
+ APPROVAL_AGGREGATOR_AUDIT_OPS.REPLAYED,
20569
+ operatorId,
20570
+ {
20571
+ result_count: sliced.length,
20572
+ ...opts?.status !== void 0 ? { status_filter: opts.status } : {},
20573
+ ...opts?.sinceTs !== void 0 ? { since: opts.sinceTs } : {}
20574
+ }
20575
+ );
20576
+ return sliced;
20328
20577
  }
20329
20578
  /**
20330
20579
  * Resolve an entry. Used by both:
@@ -20398,6 +20647,7 @@ var init_approval_aggregator = __esm({
20398
20647
  const now = this.now();
20399
20648
  const expires = new Date(now.getTime() + this.pendingTtlMs);
20400
20649
  const hubInboxId = this.resolveHubInboxItemId(event);
20650
+ const enforcementChain = this.resolveEnforcementChain(event);
20401
20651
  const entry = {
20402
20652
  aggregator_id: id,
20403
20653
  source_harness: ctx.source_harness,
@@ -20409,13 +20659,20 @@ var init_approval_aggregator = __esm({
20409
20659
  status: "pending",
20410
20660
  created_at: now.toISOString(),
20411
20661
  expires_at: expires.toISOString(),
20412
- ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
20662
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {},
20663
+ ...enforcementChain.length > 0 ? { enforcement_chain: enforcementChain } : {}
20413
20664
  };
20414
20665
  this.entries.set(id, entry);
20415
20666
  this.dedupIndex.set(dedupKey, id);
20416
20667
  this.correlationIndex.set(event.correlation_id, id);
20417
20668
  this.fullPayloads.set(id, event.context);
20418
20669
  await this.persist(entry);
20670
+ if (this.payloadStore) {
20671
+ try {
20672
+ await this.payloadStore.savePayload(id, event.context);
20673
+ } catch {
20674
+ }
20675
+ }
20419
20676
  this.auditLog.append(
20420
20677
  "l2",
20421
20678
  APPROVAL_AGGREGATOR_AUDIT_OPS.AGGREGATED,
@@ -20563,6 +20820,317 @@ var init_approval_aggregator = __esm({
20563
20820
  }
20564
20821
  });
20565
20822
 
20823
+ // src/principal-policy/channels/aggregator-backed-channel.ts
20824
+ function auditEntryIdFor(request) {
20825
+ return `${request.timestamp}:${request.operation}`;
20826
+ }
20827
+ function statusToDecision(entry) {
20828
+ switch (entry.status) {
20829
+ case "approved":
20830
+ return {
20831
+ decision: "approve",
20832
+ decided_by: "human"
20833
+ };
20834
+ case "denied":
20835
+ return {
20836
+ decision: "deny",
20837
+ decided_by: "human"
20838
+ };
20839
+ case "timeout":
20840
+ case "expired":
20841
+ return {
20842
+ decision: "deny",
20843
+ decided_by: "timeout"
20844
+ };
20845
+ default:
20846
+ return null;
20847
+ }
20848
+ }
20849
+ function makeRedirectResolverFromPolicySupplier(supplier) {
20850
+ return (_request) => {
20851
+ const cfg = supplier().approval_redirect;
20852
+ if (!cfg || cfg.enabled !== true) {
20853
+ return { enabled: false, mode: "replace" };
20854
+ }
20855
+ return {
20856
+ enabled: true,
20857
+ mode: cfg.mode === "notify" ? "notify" : "replace"
20858
+ };
20859
+ };
20860
+ }
20861
+ var DEFAULT_REPLACE_MODE_TIMEOUT_MS, AggregatorBackedChannel;
20862
+ var init_aggregator_backed_channel = __esm({
20863
+ "src/principal-policy/channels/aggregator-backed-channel.ts"() {
20864
+ DEFAULT_REPLACE_MODE_TIMEOUT_MS = 5 * 60 * 1e3;
20865
+ AggregatorBackedChannel = class {
20866
+ underlying;
20867
+ aggregator;
20868
+ resolveRedirect;
20869
+ replaceModeTimeoutMs;
20870
+ now;
20871
+ constructor(opts) {
20872
+ this.underlying = opts.underlying;
20873
+ this.aggregator = opts.aggregator;
20874
+ this.resolveRedirect = opts.resolveRedirect;
20875
+ this.replaceModeTimeoutMs = opts.replaceModeTimeoutMs ?? DEFAULT_REPLACE_MODE_TIMEOUT_MS;
20876
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
20877
+ }
20878
+ /** Expose underlying for tests / wire-up reuse. */
20879
+ getUnderlying() {
20880
+ return this.underlying;
20881
+ }
20882
+ async requestApproval(request) {
20883
+ const cfg = this.resolveRedirect(request);
20884
+ if (!cfg.enabled) {
20885
+ return this.underlying.requestApproval(request);
20886
+ }
20887
+ if (cfg.mode === "replace") {
20888
+ return this.awaitAggregatorDecision(request);
20889
+ }
20890
+ return this.notifyMode(request);
20891
+ }
20892
+ /**
20893
+ * `replace` mode. Subscribe to the aggregator's event stream BEFORE
20894
+ * checking already-stored entries (avoids a race where the entry resolves
20895
+ * between list and subscribe). Match incoming events to this request by
20896
+ * audit_entry_id. Time out after `replaceModeTimeoutMs` to honor SEC-002.
20897
+ */
20898
+ async awaitAggregatorDecision(request) {
20899
+ const auditId = auditEntryIdFor(request);
20900
+ return new Promise((resolveOuter) => {
20901
+ let settled = false;
20902
+ let unsubscribe = null;
20903
+ let timeoutHandle = null;
20904
+ const settle = (response) => {
20905
+ if (settled) return;
20906
+ settled = true;
20907
+ if (timeoutHandle) clearTimeout(timeoutHandle);
20908
+ if (unsubscribe) {
20909
+ try {
20910
+ unsubscribe();
20911
+ } catch {
20912
+ }
20913
+ }
20914
+ resolveOuter(response);
20915
+ };
20916
+ const onEvent = (emit) => {
20917
+ if (emit.type !== "resolved") return;
20918
+ if (emit.entry.audit_log_entry_id !== auditId) return;
20919
+ const mapped = statusToDecision(emit.entry);
20920
+ if (!mapped) return;
20921
+ settle({
20922
+ decision: mapped.decision,
20923
+ decided_at: emit.entry.resolved_at ?? this.now().toISOString(),
20924
+ decided_by: mapped.decided_by
20925
+ });
20926
+ };
20927
+ try {
20928
+ unsubscribe = this.aggregator.onEvent(onEvent);
20929
+ } catch (err) {
20930
+ settle({
20931
+ decision: "deny",
20932
+ decided_at: this.now().toISOString(),
20933
+ decided_by: "channel_failure"
20934
+ });
20935
+ throw err instanceof Error ? err : new Error(String(err));
20936
+ }
20937
+ void this.aggregator.list({ limit: 200 }).then((entries) => {
20938
+ for (const entry of entries) {
20939
+ if (entry.audit_log_entry_id !== auditId) continue;
20940
+ const mapped = statusToDecision(entry);
20941
+ if (!mapped) return;
20942
+ settle({
20943
+ decision: mapped.decision,
20944
+ decided_at: entry.resolved_at ?? this.now().toISOString(),
20945
+ decided_by: mapped.decided_by
20946
+ });
20947
+ return;
20948
+ }
20949
+ }).catch(() => {
20950
+ });
20951
+ timeoutHandle = setTimeout(() => {
20952
+ settle({
20953
+ decision: "deny",
20954
+ decided_at: this.now().toISOString(),
20955
+ decided_by: "timeout"
20956
+ });
20957
+ }, this.replaceModeTimeoutMs);
20958
+ });
20959
+ }
20960
+ /**
20961
+ * `notify` mode. Fire the underlying channel and listen on the
20962
+ * aggregator simultaneously; whichever resolves first wins. Both
20963
+ * paths produce identical `ApprovalResponse` shapes; the gate's
20964
+ * downstream audit logging is unchanged.
20965
+ *
20966
+ * On underlying-channel failure, fall through to the aggregator wait
20967
+ * (still bounded by `replaceModeTimeoutMs`). Operator can still
20968
+ * resolve from the inbox even if the dashboard/webhook is down.
20969
+ */
20970
+ async notifyMode(request) {
20971
+ const aggregatorPromise = this.awaitAggregatorDecision(request);
20972
+ let underlyingPromise;
20973
+ try {
20974
+ underlyingPromise = this.underlying.requestApproval(request);
20975
+ } catch (err) {
20976
+ const response = await aggregatorPromise;
20977
+ return response;
20978
+ }
20979
+ return Promise.race([
20980
+ aggregatorPromise,
20981
+ underlyingPromise.catch(
20982
+ () => new Promise(() => {
20983
+ })
20984
+ )
20985
+ ]);
20986
+ }
20987
+ };
20988
+ }
20989
+ });
20990
+
20991
+ // src/principal-policy/aggregator-store.ts
20992
+ function payloadKey(aggregatorId) {
20993
+ return `${AGGREGATOR_PAYLOAD_KEY_PREFIX}${aggregatorId}`;
20994
+ }
20995
+ function stripKeyPrefix(key) {
20996
+ if (!key.startsWith(AGGREGATOR_PAYLOAD_KEY_PREFIX)) return null;
20997
+ return key.slice(AGGREGATOR_PAYLOAD_KEY_PREFIX.length);
20998
+ }
20999
+ var AGGREGATOR_PAYLOAD_NAMESPACE, AGGREGATOR_PAYLOAD_KEY_PREFIX, HKDF_INFO, DEFAULT_AGGREGATOR_PAYLOAD_RETENTION_DAYS, MAX_BUNDLE_BYTES2, AggregatorPayloadStore;
21000
+ var init_aggregator_store = __esm({
21001
+ "src/principal-policy/aggregator-store.ts"() {
21002
+ init_encryption();
21003
+ init_key_derivation();
21004
+ init_encoding();
21005
+ AGGREGATOR_PAYLOAD_NAMESPACE = "_approval_aggregator_payloads";
21006
+ AGGREGATOR_PAYLOAD_KEY_PREFIX = "payload.";
21007
+ HKDF_INFO = "l2-approval-aggregator-payload-v1";
21008
+ DEFAULT_AGGREGATOR_PAYLOAD_RETENTION_DAYS = 30;
21009
+ MAX_BUNDLE_BYTES2 = 4 * 1024 * 1024;
21010
+ AggregatorPayloadStore = class {
21011
+ storage;
21012
+ encryptionKey;
21013
+ fortressId;
21014
+ retentionDays;
21015
+ constructor(opts) {
21016
+ this.storage = opts.storage;
21017
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO);
21018
+ this.fortressId = opts.fortressId;
21019
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_AGGREGATOR_PAYLOAD_RETENTION_DAYS;
21020
+ }
21021
+ /**
21022
+ * Persist `payload` under the given aggregator_id. Idempotent; calling
21023
+ * twice with the same id rewrites the bundle (retention_until is
21024
+ * recomputed). Returns the bundle's retention_until ISO-8601 timestamp
21025
+ * so callers can log it.
21026
+ */
21027
+ async savePayload(aggregatorId, payload) {
21028
+ const now = /* @__PURE__ */ new Date();
21029
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
21030
+ const retentionUntil = new Date(now.getTime() + retentionMs);
21031
+ const bundle = {
21032
+ version: 1,
21033
+ aggregator_id: aggregatorId,
21034
+ fortress_id: this.fortressId,
21035
+ created_at: now.toISOString(),
21036
+ retention_until: retentionUntil.toISOString(),
21037
+ payload
21038
+ };
21039
+ const aad = stringToBytes(aggregatorId);
21040
+ const plaintext = stringToBytes(JSON.stringify(bundle));
21041
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
21042
+ await this.storage.write(
21043
+ AGGREGATOR_PAYLOAD_NAMESPACE,
21044
+ payloadKey(aggregatorId),
21045
+ stringToBytes(JSON.stringify(envelope))
21046
+ );
21047
+ return bundle.retention_until;
21048
+ }
21049
+ /**
21050
+ * Read the persisted payload for the aggregator_id. Returns null if no
21051
+ * bundle exists, the bundle is corrupted, or AAD binding fails.
21052
+ */
21053
+ async loadPayload(aggregatorId) {
21054
+ const key = payloadKey(aggregatorId);
21055
+ let raw;
21056
+ try {
21057
+ raw = await this.storage.read(AGGREGATOR_PAYLOAD_NAMESPACE, key);
21058
+ } catch {
21059
+ return null;
21060
+ }
21061
+ if (!raw) return null;
21062
+ if (raw.length > MAX_BUNDLE_BYTES2) return null;
21063
+ try {
21064
+ const envelope = JSON.parse(bytesToString(raw));
21065
+ const aad = stringToBytes(aggregatorId);
21066
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
21067
+ const parsed = JSON.parse(
21068
+ bytesToString(plaintext)
21069
+ );
21070
+ if (parsed.version !== 1) return null;
21071
+ if (parsed.aggregator_id !== aggregatorId) return null;
21072
+ return parsed.payload;
21073
+ } catch {
21074
+ return null;
21075
+ }
21076
+ }
21077
+ /**
21078
+ * Delete the persisted payload. Returns true when a bundle was removed,
21079
+ * false when none existed.
21080
+ */
21081
+ async deletePayload(aggregatorId) {
21082
+ const key = payloadKey(aggregatorId);
21083
+ const existed = await this.storage.exists(
21084
+ AGGREGATOR_PAYLOAD_NAMESPACE,
21085
+ key
21086
+ );
21087
+ if (!existed) return false;
21088
+ try {
21089
+ await this.storage.delete(AGGREGATOR_PAYLOAD_NAMESPACE, key);
21090
+ } catch {
21091
+ return false;
21092
+ }
21093
+ return true;
21094
+ }
21095
+ /**
21096
+ * Drop expired payload bundles. Returns the count of bundles pruned.
21097
+ * Caller wires this into the cocoon-unlock initialization path.
21098
+ */
21099
+ async pruneExpired(now) {
21100
+ const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
21101
+ const entries = await this.storage.list(
21102
+ AGGREGATOR_PAYLOAD_NAMESPACE,
21103
+ AGGREGATOR_PAYLOAD_KEY_PREFIX
21104
+ );
21105
+ let pruned = 0;
21106
+ for (const meta of entries) {
21107
+ const aggregatorId = stripKeyPrefix(meta.key);
21108
+ if (aggregatorId === null) continue;
21109
+ const raw = await this.storage.read(
21110
+ AGGREGATOR_PAYLOAD_NAMESPACE,
21111
+ meta.key
21112
+ );
21113
+ if (!raw) continue;
21114
+ try {
21115
+ const envelope = JSON.parse(bytesToString(raw));
21116
+ const aad = stringToBytes(aggregatorId);
21117
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
21118
+ const parsed = JSON.parse(
21119
+ bytesToString(plaintext)
21120
+ );
21121
+ if (parsed.retention_until <= cutoff) {
21122
+ await this.storage.delete(AGGREGATOR_PAYLOAD_NAMESPACE, meta.key);
21123
+ pruned += 1;
21124
+ }
21125
+ } catch {
21126
+ }
21127
+ }
21128
+ return { pruned };
21129
+ }
21130
+ };
21131
+ }
21132
+ });
21133
+
20566
21134
  // src/principal-policy/tools.ts
20567
21135
  function createPrincipalPolicyTools(policy, baseline, auditLog) {
20568
21136
  return [
@@ -21466,6 +22034,76 @@ var init_attestation = __esm({
21466
22034
  }
21467
22035
  });
21468
22036
 
22037
+ // src/handshake/audit.ts
22038
+ function auditHandshakeInitiated(auditLog, ctx) {
22039
+ auditLog.append(
22040
+ "l4",
22041
+ HANDSHAKE_LIFECYCLE_OPS.INITIATED,
22042
+ ctx.identity_id,
22043
+ detailsFromContext(ctx),
22044
+ "success"
22045
+ );
22046
+ }
22047
+ function auditHandshakeCompleted(auditLog, ctx) {
22048
+ const details = detailsFromContext(ctx);
22049
+ if (ctx.trust_tier !== void 0) {
22050
+ details.trust_tier = ctx.trust_tier;
22051
+ }
22052
+ auditLog.append(
22053
+ "l4",
22054
+ HANDSHAKE_LIFECYCLE_OPS.COMPLETED,
22055
+ ctx.identity_id,
22056
+ details,
22057
+ "success"
22058
+ );
22059
+ }
22060
+ function auditHandshakeFailed(auditLog, ctx) {
22061
+ const details = detailsFromContext(ctx);
22062
+ details.reason = ctx.reason;
22063
+ if (ctx.error !== void 0) {
22064
+ details.error = ctx.error;
22065
+ }
22066
+ auditLog.append(
22067
+ "l4",
22068
+ HANDSHAKE_LIFECYCLE_OPS.FAILED,
22069
+ ctx.identity_id,
22070
+ details,
22071
+ "failure"
22072
+ );
22073
+ }
22074
+ function auditHandshakeAborted(auditLog, ctx) {
22075
+ const details = detailsFromContext(ctx);
22076
+ details.reason = ctx.reason;
22077
+ auditLog.append(
22078
+ "l4",
22079
+ HANDSHAKE_LIFECYCLE_OPS.ABORTED,
22080
+ ctx.identity_id,
22081
+ details,
22082
+ "failure"
22083
+ );
22084
+ }
22085
+ function detailsFromContext(ctx) {
22086
+ const details = {
22087
+ session_id: ctx.session_id,
22088
+ role: ctx.role
22089
+ };
22090
+ if (ctx.counterparty_id !== void 0) {
22091
+ details.counterparty_id = ctx.counterparty_id;
22092
+ }
22093
+ return details;
22094
+ }
22095
+ var HANDSHAKE_LIFECYCLE_OPS;
22096
+ var init_audit = __esm({
22097
+ "src/handshake/audit.ts"() {
22098
+ HANDSHAKE_LIFECYCLE_OPS = {
22099
+ INITIATED: "handshake_initiated",
22100
+ COMPLETED: "handshake_completed",
22101
+ FAILED: "handshake_failed",
22102
+ ABORTED: "handshake_aborted"
22103
+ };
22104
+ }
22105
+ });
22106
+
21469
22107
  // src/handshake/tools.ts
21470
22108
  function createHandshakeTools(config, identityManager, masterKey, auditLog, options) {
21471
22109
  const autoPublishHandshakes = options?.autoPublishHandshakes ?? false;
@@ -21499,6 +22137,11 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
21499
22137
  const { challenge, session } = initiateHandshake(shr);
21500
22138
  sessions.set(session.session_id, session);
21501
22139
  auditLog.append("l4", "handshake_initiate", shr.body.instance_id);
22140
+ auditHandshakeInitiated(auditLog, {
22141
+ session_id: session.session_id,
22142
+ role: "initiator",
22143
+ identity_id: shr.body.instance_id
22144
+ });
21502
22145
  return toolResult({
21503
22146
  session_id: session.session_id,
21504
22147
  challenge,
@@ -21538,10 +22181,23 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
21538
22181
  );
21539
22182
  if ("error" in result) {
21540
22183
  auditLog.append("l4", "handshake_respond", shr.body.instance_id, void 0, "failure");
22184
+ auditHandshakeFailed(auditLog, {
22185
+ session_id: "unknown",
22186
+ role: "responder",
22187
+ identity_id: shr.body.instance_id,
22188
+ reason: classifyRespondFailure(result.error),
22189
+ error: result.error
22190
+ });
21541
22191
  return toolResult({ error: result.error });
21542
22192
  }
21543
22193
  sessions.set(result.session.session_id, result.session);
21544
22194
  auditLog.append("l4", "handshake_respond", shr.body.instance_id);
22195
+ auditHandshakeInitiated(auditLog, {
22196
+ session_id: result.session.session_id,
22197
+ role: "responder",
22198
+ identity_id: shr.body.instance_id,
22199
+ counterparty_id: challenge.shr.body.instance_id
22200
+ });
21545
22201
  let autoPublishResult;
21546
22202
  if (autoPublishHandshakes) {
21547
22203
  autoPublishResult = { attempted: true };
@@ -21649,9 +22305,23 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
21649
22305
  const response = args.response;
21650
22306
  const session = sessions.get(sessionId);
21651
22307
  if (!session) {
22308
+ auditHandshakeFailed(auditLog, {
22309
+ session_id: sessionId,
22310
+ role: "initiator",
22311
+ identity_id: "unknown",
22312
+ reason: "session_unknown",
22313
+ error: `No handshake session found: ${sessionId}`
22314
+ });
21652
22315
  return toolResult({ error: `No handshake session found: ${sessionId}` });
21653
22316
  }
21654
22317
  if (session.state !== "initiated") {
22318
+ auditHandshakeFailed(auditLog, {
22319
+ session_id: sessionId,
22320
+ role: "initiator",
22321
+ identity_id: session.our_shr.body.instance_id,
22322
+ reason: "session_state_mismatch",
22323
+ error: `Session is in state '${session.state}', expected 'initiated'`
22324
+ });
21655
22325
  return toolResult({
21656
22326
  error: `Session is in state '${session.state}', expected 'initiated'`
21657
22327
  });
@@ -21665,6 +22335,13 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
21665
22335
  if ("error" in result) {
21666
22336
  session.state = "failed";
21667
22337
  auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id, void 0, "failure");
22338
+ auditHandshakeFailed(auditLog, {
22339
+ session_id: sessionId,
22340
+ role: "initiator",
22341
+ identity_id: session.our_shr.body.instance_id,
22342
+ reason: classifyCompleteFailure(result.error),
22343
+ error: result.error
22344
+ });
21668
22345
  return toolResult({ error: result.error });
21669
22346
  }
21670
22347
  session.state = "completed";
@@ -21673,6 +22350,13 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
21673
22350
  session.result = result.result;
21674
22351
  handshakeResults.set(result.result.counterparty_id, result.result);
21675
22352
  auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id);
22353
+ auditHandshakeCompleted(auditLog, {
22354
+ session_id: sessionId,
22355
+ role: "initiator",
22356
+ identity_id: session.our_shr.body.instance_id,
22357
+ counterparty_id: result.result.counterparty_id,
22358
+ trust_tier: result.result.trust_tier
22359
+ });
21676
22360
  return toolResult({
21677
22361
  completion: result.completion,
21678
22362
  result: result.result,
@@ -21720,6 +22404,24 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
21720
22404
  void 0,
21721
22405
  result.verified ? "success" : "failure"
21722
22406
  );
22407
+ if (result.verified) {
22408
+ auditHandshakeCompleted(auditLog, {
22409
+ session_id: session.session_id,
22410
+ role: "responder",
22411
+ identity_id: session.our_shr.body.instance_id,
22412
+ counterparty_id: result.counterparty_id,
22413
+ trust_tier: result.trust_tier
22414
+ });
22415
+ } else {
22416
+ auditHandshakeFailed(auditLog, {
22417
+ session_id: session.session_id,
22418
+ role: "responder",
22419
+ identity_id: session.our_shr.body.instance_id,
22420
+ counterparty_id: result.counterparty_id,
22421
+ reason: classifyCompleteFailure(result.errors.join("; ")),
22422
+ error: result.errors.join("; ")
22423
+ });
22424
+ }
21723
22425
  return toolResult({ result });
21724
22426
  }
21725
22427
  return toolResult({
@@ -21827,10 +22529,74 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
21827
22529
  _content_trust: "external"
21828
22530
  });
21829
22531
  }
22532
+ },
22533
+ {
22534
+ name: "handshake_abort",
22535
+ 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.",
22536
+ inputSchema: {
22537
+ type: "object",
22538
+ properties: {
22539
+ session_id: {
22540
+ type: "string",
22541
+ description: "Session ID returned from handshake_initiate / handshake_respond."
22542
+ },
22543
+ reason: {
22544
+ type: "string",
22545
+ enum: [
22546
+ "operator_cancelled",
22547
+ "session_timeout",
22548
+ "transport_dropped",
22549
+ "shutdown",
22550
+ "other"
22551
+ ],
22552
+ description: "Why the session is being aborted. Defaults to 'operator_cancelled'."
22553
+ }
22554
+ },
22555
+ required: ["session_id"]
22556
+ },
22557
+ handler: async (args) => {
22558
+ const sessionId = args.session_id;
22559
+ const reason = args.reason ?? "operator_cancelled";
22560
+ const session = sessions.get(sessionId);
22561
+ if (!session) {
22562
+ return toolResult({ error: `No handshake session found: ${sessionId}` });
22563
+ }
22564
+ if (session.state === "completed") {
22565
+ return toolResult({
22566
+ error: `Session ${sessionId} already completed; abort is only valid for in-flight sessions`
22567
+ });
22568
+ }
22569
+ sessions.delete(sessionId);
22570
+ auditHandshakeAborted(auditLog, {
22571
+ session_id: sessionId,
22572
+ role: session.role,
22573
+ identity_id: session.our_shr.body.instance_id,
22574
+ ...session.their_shr ? { counterparty_id: session.their_shr.body.instance_id } : {},
22575
+ reason
22576
+ });
22577
+ return toolResult({
22578
+ aborted: true,
22579
+ session_id: sessionId,
22580
+ reason
22581
+ });
22582
+ }
21830
22583
  }
21831
22584
  ];
21832
22585
  return { tools, handshakeResults };
21833
22586
  }
22587
+ function classifyRespondFailure(error) {
22588
+ if (error.includes("Unsupported protocol version")) return "protocol_version_unsupported";
22589
+ if (error.includes("SHR verification failed")) return "shr_invalid";
22590
+ if (error.includes("No identity available")) return "no_signing_identity";
22591
+ return "other";
22592
+ }
22593
+ function classifyCompleteFailure(error) {
22594
+ if (error.includes("Unsupported protocol version")) return "protocol_version_unsupported";
22595
+ if (error.includes("SHR verification failed") || error.includes("SHR")) return "shr_invalid";
22596
+ if (error.includes("nonce signature is invalid")) return "nonce_signature_invalid";
22597
+ if (error.includes("No identity available")) return "no_signing_identity";
22598
+ return "other";
22599
+ }
21834
22600
  var init_tools6 = __esm({
21835
22601
  "src/handshake/tools.ts"() {
21836
22602
  init_router();
@@ -21840,6 +22606,7 @@ var init_tools6 = __esm({
21840
22606
  init_encoding();
21841
22607
  init_protocol();
21842
22608
  init_attestation();
22609
+ init_audit();
21843
22610
  init_verifier();
21844
22611
  }
21845
22612
  });
@@ -32975,7 +33742,21 @@ var init_operator_chat_audit_events = __esm({
32975
33742
  * successful thread removal. Body carries thread_id + turn_count of
32976
33743
  * the deleted bundle.
32977
33744
  */
32978
- CONCIERGE_THREAD_DELETED: "operator_concierge_thread_deleted"
33745
+ CONCIERGE_THREAD_DELETED: "operator_concierge_thread_deleted",
33746
+ /**
33747
+ * Concierge memory fold-read failed (WP-V1.3-9 Tau-2). Emitted when
33748
+ * the multi-turn coherence fold cannot load the active thread's prior
33749
+ * turns; the concierge degrades to single-turn after emitting. Body
33750
+ * carries thread_id + a stable failure_reason enum.
33751
+ */
33752
+ CONCIERGE_MEMORY_READ_FAILED: "operator_concierge_memory_read_failed",
33753
+ /**
33754
+ * Concierge dynamic-context fetcher failed (WP-V1.3-9 Tau-3). Emitted
33755
+ * when a category fetcher throws while assembling the dynamic context
33756
+ * fold. The concierge omits that category and continues; the user-
33757
+ * facing query is never broken. Body carries category + failure_reason.
33758
+ */
33759
+ CONCIERGE_CONTEXT_FETCHER_FAILED: "operator_concierge_context_fetcher_failed"
32979
33760
  };
32980
33761
  }
32981
33762
  });
@@ -32988,20 +33769,312 @@ var init_operator_chat_types = __esm({
32988
33769
  CONCIERGE_THREAD_KEY = "_fortress";
32989
33770
  }
32990
33771
  });
33772
+
33773
+ // src/chat/concierge-context-router.ts
33774
+ function phrasePattern(phrase) {
33775
+ const escaped = phrase.toLowerCase().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+");
33776
+ return { source: `\\b${escaped}\\b`, phrase };
33777
+ }
33778
+ function extractAgentNameHint(query) {
33779
+ const agentPattern = /\bagent\s+["']?([A-Za-z][\w-]{0,40})["']?/i;
33780
+ const m = query.match(agentPattern);
33781
+ if (m && m[1]) return m[1];
33782
+ const quoted = query.match(/["']([A-Za-z][\w-]{0,40})["']/);
33783
+ if (quoted && quoted[1]) return quoted[1];
33784
+ return null;
33785
+ }
33786
+ function isTrivialQuery(query) {
33787
+ const norm = query.trim().toLowerCase();
33788
+ if (norm.length === 0) return true;
33789
+ if (norm.length < 8) return true;
33790
+ return TRIVIAL_GREETINGS.has(norm);
33791
+ }
33792
+ function classifyQuery(query) {
33793
+ const normalized = query.toLowerCase();
33794
+ const matches = [];
33795
+ for (const spec of CATEGORY_KEYWORDS) {
33796
+ const matchedPhrases = [];
33797
+ for (const pattern of spec.patterns) {
33798
+ if (matchedPhrases.includes(pattern.phrase)) continue;
33799
+ const re = new RegExp(pattern.source, "i");
33800
+ if (re.test(normalized)) {
33801
+ matchedPhrases.push(pattern.phrase);
33802
+ }
33803
+ }
33804
+ if (matchedPhrases.length === 0) continue;
33805
+ const confidence = Math.min(1, 0.4 + 0.3 * matchedPhrases.length);
33806
+ matches.push({
33807
+ category: spec.category,
33808
+ confidence,
33809
+ matched_keywords: matchedPhrases,
33810
+ agent_name_hint: spec.category === "agent_state" || spec.category === "agent_activity" ? extractAgentNameHint(query) : null
33811
+ });
33812
+ }
33813
+ matches.sort((a, b) => {
33814
+ if (b.confidence !== a.confidence) return b.confidence - a.confidence;
33815
+ return CONTEXT_CATEGORIES.indexOf(a.category) - CONTEXT_CATEGORIES.indexOf(b.category);
33816
+ });
33817
+ return matches;
33818
+ }
33819
+ function approxTokenLen(text) {
33820
+ return Math.ceil(text.length / APPROX_CHARS_PER_TOKEN);
33821
+ }
33822
+ async function runFetcher(match, fetchers) {
33823
+ switch (match.category) {
33824
+ case "templates":
33825
+ return fetchers.templates();
33826
+ case "agent_state":
33827
+ return fetchers.agent_state(match.agent_name_hint);
33828
+ case "agent_activity":
33829
+ return fetchers.agent_activity(match.agent_name_hint);
33830
+ case "audit_log":
33831
+ return fetchers.audit_log();
33832
+ case "sentinel_findings":
33833
+ return fetchers.sentinel_findings();
33834
+ case "anomaly_alerts":
33835
+ return fetchers.anomaly_alerts();
33836
+ case "recent_receipts":
33837
+ return fetchers.recent_receipts();
33838
+ case "verascore_deltas":
33839
+ return fetchers.verascore_deltas();
33840
+ }
33841
+ }
33842
+ function trivialMatch(category) {
33843
+ return {
33844
+ category,
33845
+ confidence: 0.5,
33846
+ matched_keywords: ["llm-assist"],
33847
+ agent_name_hint: null
33848
+ };
33849
+ }
33850
+ async function foldContext(query, fetchers, opts) {
33851
+ const budget = opts?.maxTokens ?? DEFAULT_DYNAMIC_CONTEXT_TOKEN_BUDGET;
33852
+ let matches = classifyQuery(query);
33853
+ if (matches.length === 0 && !isTrivialQuery(query) && opts?.llmAssistClassify) {
33854
+ try {
33855
+ const picked = await opts.llmAssistClassify(query, CONTEXT_CATEGORIES);
33856
+ if (picked !== "none" && CONTEXT_CATEGORIES.includes(picked)) {
33857
+ matches = [trivialMatch(picked)];
33858
+ }
33859
+ } catch {
33860
+ }
33861
+ }
33862
+ if (matches.length === 0) {
33863
+ return { section: "", categoriesIncluded: [] };
33864
+ }
33865
+ const attempts = [];
33866
+ for (const match of matches) {
33867
+ try {
33868
+ const text = await runFetcher(match, fetchers);
33869
+ const trimmed = text.trim();
33870
+ if (trimmed.length > 0) {
33871
+ attempts.push({ category: match.category, text: trimmed });
33872
+ }
33873
+ } catch (err) {
33874
+ opts?.onFetcherFailure?.(match.category, err);
33875
+ }
33876
+ }
33877
+ if (attempts.length === 0) {
33878
+ return { section: "", categoriesIncluded: [] };
33879
+ }
33880
+ const headerTokens = approxTokenLen(`${DYNAMIC_CONTEXT_SECTION_HEADER}
33881
+ `);
33882
+ const sepTokens = approxTokenLen("\n\n");
33883
+ let runningTokens = headerTokens;
33884
+ const kept = [];
33885
+ for (const attempt of attempts) {
33886
+ const block = `### ${CATEGORY_LABELS[attempt.category]}
33887
+ ${attempt.text}`;
33888
+ const tokens = approxTokenLen(block) + (kept.length > 0 ? sepTokens : 0);
33889
+ if (kept.length === 0) {
33890
+ kept.push(attempt);
33891
+ runningTokens += tokens;
33892
+ continue;
33893
+ }
33894
+ if (runningTokens + tokens > budget) break;
33895
+ kept.push(attempt);
33896
+ runningTokens += tokens;
33897
+ }
33898
+ const blocks = kept.map(
33899
+ (k) => `### ${CATEGORY_LABELS[k.category]}
33900
+ ${k.text}`
33901
+ );
33902
+ const section = `${DYNAMIC_CONTEXT_SECTION_HEADER}
33903
+ ${blocks.join("\n\n")}`;
33904
+ return {
33905
+ section,
33906
+ categoriesIncluded: kept.map((k) => k.category)
33907
+ };
33908
+ }
33909
+ var APPROX_CHARS_PER_TOKEN, DEFAULT_DYNAMIC_CONTEXT_TOKEN_BUDGET, DYNAMIC_CONTEXT_SECTION_HEADER, CONTEXT_CATEGORIES, CATEGORY_KEYWORDS, TRIVIAL_GREETINGS, CATEGORY_LABELS;
33910
+ var init_concierge_context_router = __esm({
33911
+ "src/chat/concierge-context-router.ts"() {
33912
+ APPROX_CHARS_PER_TOKEN = 4;
33913
+ DEFAULT_DYNAMIC_CONTEXT_TOKEN_BUDGET = 2e3;
33914
+ DYNAMIC_CONTEXT_SECTION_HEADER = "## Live fortress context";
33915
+ CONTEXT_CATEGORIES = [
33916
+ "templates",
33917
+ "agent_state",
33918
+ "agent_activity",
33919
+ "audit_log",
33920
+ "sentinel_findings",
33921
+ "anomaly_alerts",
33922
+ "recent_receipts",
33923
+ "verascore_deltas"
33924
+ ];
33925
+ CATEGORY_KEYWORDS = [
33926
+ {
33927
+ category: "templates",
33928
+ patterns: [
33929
+ "templates",
33930
+ "template",
33931
+ "channel templates",
33932
+ "channel template",
33933
+ "list templates",
33934
+ "available templates",
33935
+ "what templates"
33936
+ ].map(phrasePattern)
33937
+ },
33938
+ {
33939
+ category: "agent_state",
33940
+ patterns: [
33941
+ "state",
33942
+ "status",
33943
+ "agent state",
33944
+ "agent status",
33945
+ "status of agent",
33946
+ "status of agents",
33947
+ "state of",
33948
+ "doing"
33949
+ ].map(phrasePattern)
33950
+ },
33951
+ {
33952
+ category: "agent_activity",
33953
+ patterns: [
33954
+ "activity",
33955
+ "agent activity",
33956
+ "what did",
33957
+ "recent activity"
33958
+ ].map(phrasePattern)
33959
+ },
33960
+ {
33961
+ category: "audit_log",
33962
+ patterns: [
33963
+ "audit log",
33964
+ "audit",
33965
+ "log entry",
33966
+ "log entries",
33967
+ "what happened",
33968
+ "show me events",
33969
+ "event class"
33970
+ ].map(phrasePattern)
33971
+ },
33972
+ {
33973
+ category: "sentinel_findings",
33974
+ patterns: [
33975
+ "sentinel",
33976
+ "sentinels",
33977
+ "warning",
33978
+ "warnings",
33979
+ "alert",
33980
+ "alerts",
33981
+ "whats wrong",
33982
+ "what's wrong",
33983
+ "findings"
33984
+ ].map(phrasePattern)
33985
+ },
33986
+ {
33987
+ category: "anomaly_alerts",
33988
+ patterns: [
33989
+ "anomaly",
33990
+ "anomalies",
33991
+ "spike",
33992
+ "unusual",
33993
+ "outlier"
33994
+ ].map(phrasePattern)
33995
+ },
33996
+ {
33997
+ category: "recent_receipts",
33998
+ patterns: [
33999
+ "receipt",
34000
+ "receipts",
34001
+ "concordia",
34002
+ "commitment",
34003
+ "commitments",
34004
+ "chain",
34005
+ "chains"
34006
+ ].map(phrasePattern)
34007
+ },
34008
+ {
34009
+ category: "verascore_deltas",
34010
+ patterns: [
34011
+ "verascore",
34012
+ "vera score",
34013
+ "trust score",
34014
+ "reputation"
34015
+ ].map(phrasePattern)
34016
+ }
34017
+ ];
34018
+ TRIVIAL_GREETINGS = /* @__PURE__ */ new Set([
34019
+ "hi",
34020
+ "hello",
34021
+ "hey",
34022
+ "yo",
34023
+ "ok",
34024
+ "thanks",
34025
+ "thx",
34026
+ "thank you"
34027
+ ]);
34028
+ CATEGORY_LABELS = {
34029
+ templates: "Templates",
34030
+ agent_state: "Agent state",
34031
+ agent_activity: "Agent activity",
34032
+ audit_log: "Audit log",
34033
+ sentinel_findings: "Sentinel findings",
34034
+ anomaly_alerts: "Anomaly alerts",
34035
+ recent_receipts: "Recent receipts",
34036
+ verascore_deltas: "Verascore deltas"
34037
+ };
34038
+ }
34039
+ });
34040
+ function approxTokenLen2(text) {
34041
+ return Math.ceil(text.length / 4);
34042
+ }
32991
34043
  function makeEventId(prefix) {
32992
34044
  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}`;
32993
34045
  }
34046
+ function classifyFetcherError(error) {
34047
+ const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
34048
+ if (msg.includes("timeout") || msg.includes("timed out")) return "timeout";
34049
+ if (msg.includes("schema") || msg.includes("invalid shape")) {
34050
+ return "schema_mismatch";
34051
+ }
34052
+ if (msg.includes("io") || msg.includes("read") || msg.includes("enoent") || msg.includes("eacces")) {
34053
+ return "io_failed";
34054
+ }
34055
+ return "unknown";
34056
+ }
34057
+ function formatPriorTurnLine(turn) {
34058
+ const label = turn.role === "user" ? "OPERATOR" : "CONCIERGE";
34059
+ return `${label}: ${turn.content}`;
34060
+ }
32994
34061
  function hashOf(input) {
32995
34062
  return hashToString(sha256(stringToBytes(input)));
32996
34063
  }
32997
- var DEFAULT_CONCIERGE_MAX_TOKENS, SANCTUARY_DOMAIN_REFERENCE, OperatorChatService;
34064
+ 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, DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET, SANCTUARY_DOMAIN_REFERENCE, OperatorChatService;
32998
34065
  var init_operator_chat_service = __esm({
32999
34066
  "src/chat/operator-chat-service.ts"() {
33000
34067
  init_hashing();
33001
34068
  init_encoding();
33002
34069
  init_operator_chat_audit_events();
33003
34070
  init_operator_chat_types();
34071
+ init_concierge_context_router();
33004
34072
  DEFAULT_CONCIERGE_MAX_TOKENS = 512;
34073
+ DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS = 10;
34074
+ DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
34075
+ DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET = 500;
34076
+ DEFAULT_CONCIERGE_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
34077
+ DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET = 2e3;
33005
34078
  SANCTUARY_DOMAIN_REFERENCE = `Castle Architecture (four enforcement layers):
33006
34079
  1. Castle Wall: OS-boundary egress filter enforced at the kernel level. Blocks unauthorized outbound calls even from prompt-injected agents.
33007
34080
  2. Sentinels: internal observation via process introspection. Surfaces anomalies to the operator; does not enforce.
@@ -33038,6 +34111,14 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
33038
34111
  piiFilter;
33039
34112
  conciergeMaxTokens;
33040
34113
  memory;
34114
+ historyWindowTurns;
34115
+ historyFreshnessMs;
34116
+ historyTokenBudget;
34117
+ sessionTtlMs;
34118
+ clock;
34119
+ contextFetchers;
34120
+ contextLlmAssist;
34121
+ dynamicContextBudget;
33041
34122
  /**
33042
34123
  * In-memory thread_id assigned to the active concierge session.
33043
34124
  * The first sendConcierge call after construction allocates a fresh
@@ -33045,6 +34126,14 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
33045
34126
  * folds the prior turns into context. Cleared by `resetConciergeMemoryThread`.
33046
34127
  */
33047
34128
  activeMemoryThreadId;
34129
+ /**
34130
+ * Wall-clock ms of the most recent sendConcierge that touched the
34131
+ * active session thread. Drives the WP-V1.3-9 Tau-2 session-TTL
34132
+ * check: a fresh sendConcierge after `sessionTtlMs` of quiet
34133
+ * allocates a new thread_id even though the prior one is still
34134
+ * readable from the memory store.
34135
+ */
34136
+ lastInteractionAt;
33048
34137
  constructor(deps) {
33049
34138
  this.store = deps.store;
33050
34139
  this.auditLog = deps.auditLog;
@@ -33056,6 +34145,18 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
33056
34145
  if (deps.conciergePiiFilter) this.piiFilter = deps.conciergePiiFilter;
33057
34146
  this.conciergeMaxTokens = deps.conciergeMaxTokens ?? DEFAULT_CONCIERGE_MAX_TOKENS;
33058
34147
  if (deps.conciergeMemory) this.memory = deps.conciergeMemory;
34148
+ this.historyWindowTurns = deps.conciergeHistoryWindowTurns !== void 0 && deps.conciergeHistoryWindowTurns > 0 ? deps.conciergeHistoryWindowTurns : DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS;
34149
+ this.historyFreshnessMs = deps.conciergeHistoryFreshnessMs !== void 0 && deps.conciergeHistoryFreshnessMs > 0 ? deps.conciergeHistoryFreshnessMs : DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS;
34150
+ this.historyTokenBudget = deps.conciergeHistoryTokenBudget !== void 0 && deps.conciergeHistoryTokenBudget > 0 ? deps.conciergeHistoryTokenBudget : DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET;
34151
+ this.sessionTtlMs = deps.conciergeSessionTtlMs !== void 0 && deps.conciergeSessionTtlMs > 0 ? deps.conciergeSessionTtlMs : DEFAULT_CONCIERGE_SESSION_TTL_MS;
34152
+ this.clock = deps.conciergeClock ?? (() => Date.now());
34153
+ if (deps.conciergeContextFetchers) {
34154
+ this.contextFetchers = deps.conciergeContextFetchers;
34155
+ }
34156
+ if (deps.conciergeContextLlmAssist) {
34157
+ this.contextLlmAssist = deps.conciergeContextLlmAssist;
34158
+ }
34159
+ this.dynamicContextBudget = deps.conciergeDynamicContextBudget !== void 0 && deps.conciergeDynamicContextBudget > 0 ? deps.conciergeDynamicContextBudget : DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET;
33059
34160
  }
33060
34161
  // ── Concierge ─────────────────────────────────────────────────────────
33061
34162
  /**
@@ -33074,6 +34175,10 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
33074
34175
  throw new Error("concierge query must not be empty");
33075
34176
  }
33076
34177
  const filterResult = this.piiFilter ? this.piiFilter.filter(trimmed) : { filtered: trimmed, redactions: 0 };
34178
+ const nowMs = this.clock();
34179
+ if (this.activeMemoryThreadId && this.lastInteractionAt !== void 0 && nowMs - this.lastInteractionAt > this.sessionTtlMs) {
34180
+ this.activeMemoryThreadId = void 0;
34181
+ }
33077
34182
  const operatorMessage = {
33078
34183
  message_id: randomUUID(),
33079
34184
  surface: "concierge",
@@ -33086,6 +34191,25 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
33086
34191
  CONCIERGE_THREAD_KEY,
33087
34192
  operatorMessage
33088
34193
  );
34194
+ let priorTurns = [];
34195
+ let memoryReadFailureReason = null;
34196
+ let activeThreadIdForRound;
34197
+ if (this.memory) {
34198
+ activeThreadIdForRound = this.ensureActiveMemoryThread();
34199
+ const result = await this.memory.readThreadStrict(activeThreadIdForRound).catch(() => ({ ok: false, reason: "io_failed" }));
34200
+ if (result.ok) {
34201
+ const cutoff = nowMs - this.historyFreshnessMs;
34202
+ const fresh = result.turns.filter((t) => {
34203
+ const ts = Date.parse(t.created_at);
34204
+ return Number.isFinite(ts) && ts >= cutoff;
34205
+ });
34206
+ const recent = fresh.length > this.historyWindowTurns ? fresh.slice(fresh.length - this.historyWindowTurns) : fresh;
34207
+ priorTurns = recent;
34208
+ } else {
34209
+ memoryReadFailureReason = result.reason;
34210
+ this.emitMemoryReadFailed(activeThreadIdForRound, result.reason);
34211
+ }
34212
+ }
33089
34213
  if (this.memory) {
33090
34214
  const threadId = this.ensureActiveMemoryThread();
33091
34215
  await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
@@ -33096,6 +34220,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
33096
34220
  let servedBy = "disabled";
33097
34221
  let displayLabel = "Concierge: substrate not configured";
33098
34222
  let outcome = "substrate_disabled";
34223
+ let dynamicCategoriesIncluded = [];
33099
34224
  if (!this.substrateSelector) {
33100
34225
  conciergeBody = "Concierge unavailable. The substrate selector is not configured for this fortress. Pick a substrate in the Policy center to enable concierge replies.";
33101
34226
  } else {
@@ -33107,7 +34232,14 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
33107
34232
  conciergeBody = "Concierge unavailable. The chosen substrate does not support summarization. Pick a different substrate in the Policy center.";
33108
34233
  outcome = "substrate_disabled";
33109
34234
  } else {
33110
- const context = await this.assembleConciergeContext();
34235
+ const dynamicResult = await this.runDynamicContextFold(
34236
+ filterResult.filtered
34237
+ );
34238
+ dynamicCategoriesIncluded = dynamicResult.categoriesIncluded;
34239
+ const context = await this.assembleConciergeContext(
34240
+ priorTurns,
34241
+ dynamicResult.section
34242
+ );
33111
34243
  const response = await this.substrateSelector.invokeSummarize(
33112
34244
  "concierge",
33113
34245
  {
@@ -33146,10 +34278,14 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
33146
34278
  CONCIERGE_THREAD_KEY,
33147
34279
  responseMessage
33148
34280
  );
34281
+ let assistantTurnId;
33149
34282
  if (this.memory) {
33150
34283
  const threadId = this.ensureActiveMemoryThread();
33151
- await this.memory.appendTurn(threadId, "assistant", conciergeBody).catch(() => {
33152
- });
34284
+ const persisted = await this.memory.appendTurn(threadId, "assistant", conciergeBody).catch(() => void 0);
34285
+ if (persisted) assistantTurnId = persisted.turn_id;
34286
+ }
34287
+ if (this.memory && activeThreadIdForRound) {
34288
+ this.lastInteractionAt = nowMs;
33153
34289
  }
33154
34290
  const payload = {
33155
34291
  version: "1.2",
@@ -33162,7 +34298,13 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
33162
34298
  response_hash: outcome === "ok" ? hashOf(conciergeBody) : null,
33163
34299
  substrate: servedBy,
33164
34300
  latency_ms: latencyMs,
33165
- outcome
34301
+ outcome,
34302
+ ...activeThreadIdForRound !== void 0 ? { thread_id: activeThreadIdForRound } : {},
34303
+ ...assistantTurnId !== void 0 ? { turn_index: assistantTurnId } : {},
34304
+ ...this.memory ? {
34305
+ prior_turns_folded: memoryReadFailureReason === null ? priorTurns.length : 0
34306
+ } : {},
34307
+ ...this.contextFetchers ? { dynamic_context_categories: [...dynamicCategoriesIncluded] } : {}
33166
34308
  };
33167
34309
  this.emit(OPERATOR_CHAT_OPS.CONCIERGE_CHAT, payload, outcome === "ok" ? "success" : "failure");
33168
34310
  return {
@@ -33172,6 +34314,25 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
33172
34314
  outcome
33173
34315
  };
33174
34316
  }
34317
+ /**
34318
+ * Emit the WP-V1.3-9 Tau-2 graceful-degradation audit event. Pulled
34319
+ * out of `sendConcierge` so the read-fold path stays readable. Emits
34320
+ * with `result: "failure"` since the concierge fell back to
34321
+ * single-turn mode for this round-trip.
34322
+ */
34323
+ emitMemoryReadFailed(threadId, reason) {
34324
+ const payload = {
34325
+ version: "1.2",
34326
+ event_id: makeEventId("conc-memfail"),
34327
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
34328
+ identity_id: this.identityId,
34329
+ kind: "operator_concierge_memory_read_failed",
34330
+ surface: "concierge",
34331
+ thread_id: threadId,
34332
+ failure_reason: reason
34333
+ };
34334
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_MEMORY_READ_FAILED, payload, "failure");
34335
+ }
33175
34336
  /**
33176
34337
  * Read the persisted concierge thread, oldest message first. Returns
33177
34338
  * an empty array when no thread exists yet.
@@ -33294,6 +34455,14 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
33294
34455
  * ## Sanctuary reference
33295
34456
  * <static domain reference block>
33296
34457
  *
34458
+ * ## Live fortress context ← WP-V1.3-9 Tau-3, when present
34459
+ * ### <Category>
34460
+ * <fetcher payload>
34461
+ *
34462
+ * ## Prior conversation ← WP-V1.3-9 Tau-2, when present
34463
+ * OPERATOR: ...
34464
+ * CONCIERGE: ...
34465
+ *
33297
34466
  * ## Recent activity
33298
34467
  * <recentActivity output>
33299
34468
  *
@@ -33303,37 +34472,116 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
33303
34472
  * ## Open inbox
33304
34473
  * <openInbox output>
33305
34474
  * ```
33306
- */
33307
- async assembleConciergeContext() {
34475
+ *
34476
+ * The substrate selector ships a `context: string` shape (not a
34477
+ * messages array), so multi-turn coherence is folded as a structured
34478
+ * prior-conversation section with explicit OPERATOR / CONCIERGE
34479
+ * boundaries. Coordinator-CTO guidance: prefer messages-array shape
34480
+ * if available; the v1.2 selector does not expose one, so structured
34481
+ * serialization is the canonical path for v1.3.
34482
+ */
34483
+ async assembleConciergeContext(priorTurns = [], dynamicSection = "") {
33308
34484
  const ref = `## Sanctuary reference
33309
34485
  ${SANCTUARY_DOMAIN_REFERENCE}`;
34486
+ const priorSection = this.formatPriorTurnsSection(priorTurns);
33310
34487
  if (!this.contextProviders) {
33311
- return `${ref}
33312
-
33313
- ## Recent activity
33314
- (no providers wired)
33315
-
33316
- ## Wrapped agents
33317
- (no providers wired)
33318
-
33319
- ## Open inbox
33320
- (no providers wired)`;
34488
+ return [
34489
+ ref,
34490
+ ...dynamicSection ? [dynamicSection] : [],
34491
+ ...priorSection ? [priorSection] : [],
34492
+ "## Recent activity\n(no providers wired)",
34493
+ "## Wrapped agents\n(no providers wired)",
34494
+ "## Open inbox\n(no providers wired)"
34495
+ ].join("\n\n");
33321
34496
  }
33322
34497
  const [activity, agents, inbox] = await Promise.all([
33323
34498
  this.contextProviders.recentActivity(),
33324
34499
  this.contextProviders.agentInventory(),
33325
34500
  this.contextProviders.openInbox()
33326
34501
  ]);
33327
- return `${ref}
33328
-
33329
- ## Recent activity
33330
- ${activity}
33331
-
33332
- ## Wrapped agents
33333
- ${agents}
33334
-
33335
- ## Open inbox
33336
- ${inbox}`;
34502
+ return [
34503
+ ref,
34504
+ ...dynamicSection ? [dynamicSection] : [],
34505
+ ...priorSection ? [priorSection] : [],
34506
+ `## Recent activity
34507
+ ${activity}`,
34508
+ `## Wrapped agents
34509
+ ${agents}`,
34510
+ `## Open inbox
34511
+ ${inbox}`
34512
+ ].join("\n\n");
34513
+ }
34514
+ /**
34515
+ * Run the WP-V1.3-9 Tau-3 dynamic-context fold for a single round-
34516
+ * trip. Fail-soft on every axis: missing fetchers short-circuit to
34517
+ * an empty fold, fetcher failures emit a per-category audit event
34518
+ * and are omitted from the rendered section, an LLM-assist failure
34519
+ * proceeds with no fold. Returns the rendered section + the list of
34520
+ * categories whose data made it into the section (used for the
34521
+ * round-trip audit emission).
34522
+ */
34523
+ async runDynamicContextFold(query) {
34524
+ if (!this.contextFetchers) {
34525
+ return { section: "", categoriesIncluded: [] };
34526
+ }
34527
+ const result = await foldContext(query, this.contextFetchers, {
34528
+ maxTokens: this.dynamicContextBudget,
34529
+ ...this.contextLlmAssist ? { llmAssistClassify: this.contextLlmAssist } : {},
34530
+ onFetcherFailure: (category, error) => {
34531
+ this.emitContextFetcherFailed(category, classifyFetcherError(error));
34532
+ }
34533
+ });
34534
+ return result;
34535
+ }
34536
+ /**
34537
+ * Emit the WP-V1.3-9 Tau-3 fetcher-failure audit event. Pulled out
34538
+ * of the fold path so the dynamic-context handler stays readable.
34539
+ * Emits with `result: "failure"` since the named category dropped
34540
+ * from the rendered section for this round-trip.
34541
+ */
34542
+ emitContextFetcherFailed(category, failureReason) {
34543
+ const payload = {
34544
+ version: "1.2",
34545
+ event_id: makeEventId("conc-ctxfail"),
34546
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
34547
+ identity_id: this.identityId,
34548
+ kind: "operator_concierge_context_fetcher_failed",
34549
+ surface: "concierge",
34550
+ category,
34551
+ failure_reason: failureReason
34552
+ };
34553
+ this.emit(
34554
+ OPERATOR_CHAT_OPS.CONCIERGE_CONTEXT_FETCHER_FAILED,
34555
+ payload,
34556
+ "failure"
34557
+ );
34558
+ }
34559
+ /**
34560
+ * Render the prior-conversation section with token-budget enforcement
34561
+ * (WP-V1.3-9 Tau-2). Drops oldest turns first when the rendered
34562
+ * section exceeds `historyTokenBudget`. Returns an empty string when
34563
+ * the input is empty or when the budget excludes every turn.
34564
+ */
34565
+ formatPriorTurnsSection(turns) {
34566
+ if (turns.length === 0) return "";
34567
+ const HEADER = "## Prior conversation";
34568
+ const lines = turns.map(formatPriorTurnLine);
34569
+ const headerTokens = approxTokenLen2(`${HEADER}
34570
+ `);
34571
+ const sepTokens = approxTokenLen2("\n");
34572
+ let runningTokens = headerTokens;
34573
+ let runningLines = [];
34574
+ for (let i = lines.length - 1; i >= 0; i--) {
34575
+ const line = lines[i];
34576
+ const tokens = approxTokenLen2(line) + (runningLines.length > 0 ? sepTokens : 0);
34577
+ if (runningTokens + tokens > this.historyTokenBudget) break;
34578
+ runningTokens += tokens;
34579
+ runningLines.push(line);
34580
+ }
34581
+ if (runningLines.length === 0) return "";
34582
+ runningLines = runningLines.reverse();
34583
+ return `${HEADER}
34584
+ ${runningLines.join("\n")}`;
33337
34585
  }
33338
34586
  // ── audit helpers ────────────────────────────────────────────────────
33339
34587
  emit(operation, payload, result) {
@@ -33353,7 +34601,7 @@ ${inbox}`;
33353
34601
  function chatStorageKey(surface, threadKey) {
33354
34602
  return `${surface}.${threadKey}`;
33355
34603
  }
33356
- var OPERATOR_CHAT_NAMESPACE, HKDF_INFO, OperatorChatStore;
34604
+ var OPERATOR_CHAT_NAMESPACE, HKDF_INFO2, OperatorChatStore;
33357
34605
  var init_operator_chat_store = __esm({
33358
34606
  "src/chat/operator-chat-store.ts"() {
33359
34607
  init_encryption();
@@ -33361,13 +34609,13 @@ var init_operator_chat_store = __esm({
33361
34609
  init_encoding();
33362
34610
  init_operator_chat_types();
33363
34611
  OPERATOR_CHAT_NAMESPACE = "_chat";
33364
- HKDF_INFO = "operator-chat-store-v1";
34612
+ HKDF_INFO2 = "operator-chat-store-v1";
33365
34613
  OperatorChatStore = class {
33366
34614
  storage;
33367
34615
  encryptionKey;
33368
34616
  constructor(storage, masterKey) {
33369
34617
  this.storage = storage;
33370
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO);
34618
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
33371
34619
  }
33372
34620
  /**
33373
34621
  * Load a thread. Returns null if no record exists or if the on-disk
@@ -33452,7 +34700,7 @@ var init_operator_chat_store = __esm({
33452
34700
  function bundleKey(threadId) {
33453
34701
  return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
33454
34702
  }
33455
- function stripKeyPrefix(key) {
34703
+ function stripKeyPrefix2(key) {
33456
34704
  if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
33457
34705
  return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
33458
34706
  }
@@ -33463,7 +34711,7 @@ function lastTurnId(bundle) {
33463
34711
  }
33464
34712
  return max;
33465
34713
  }
33466
- var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO2, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES2, ConciergeMemoryStore;
34714
+ var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO3, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES3, ConciergeMemoryStore;
33467
34715
  var init_concierge_memory_store = __esm({
33468
34716
  "src/chat/concierge-memory-store.ts"() {
33469
34717
  init_encryption();
@@ -33471,9 +34719,9 @@ var init_concierge_memory_store = __esm({
33471
34719
  init_encoding();
33472
34720
  CONCIERGE_MEMORY_NAMESPACE = "_chat";
33473
34721
  CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
33474
- HKDF_INFO2 = "concierge-memory-store-v1";
34722
+ HKDF_INFO3 = "concierge-memory-store-v1";
33475
34723
  DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
33476
- MAX_BUNDLE_BYTES2 = 4 * 1024 * 1024;
34724
+ MAX_BUNDLE_BYTES3 = 4 * 1024 * 1024;
33477
34725
  ConciergeMemoryStore = class {
33478
34726
  storage;
33479
34727
  encryptionKey;
@@ -33482,7 +34730,7 @@ var init_concierge_memory_store = __esm({
33482
34730
  locks;
33483
34731
  constructor(opts) {
33484
34732
  this.storage = opts.storage;
33485
- this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
34733
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO3);
33486
34734
  this.fortressId = opts.fortressId;
33487
34735
  this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
33488
34736
  this.locks = /* @__PURE__ */ new Map();
@@ -33538,6 +34786,65 @@ var init_concierge_memory_store = __esm({
33538
34786
  }
33539
34787
  return turns;
33540
34788
  }
34789
+ /**
34790
+ * Read turns with explicit failure surfacing (WP-V1.3-9 Tau-2). Where
34791
+ * `readThread` collapses every failure mode to an empty array, this
34792
+ * variant returns a discriminated result so the multi-turn fold path
34793
+ * can degrade cleanly + emit `operator_concierge_memory_read_failed`
34794
+ * with a concrete cause.
34795
+ *
34796
+ * - No bundle on disk → `{ ok: true, turns: [] }` (a fresh thread).
34797
+ * - Bundle present, decode + decrypt + schema check pass → ok with turns.
34798
+ * - Bundle present, oversize → `{ ok: false, reason: "oversize_bundle" }`.
34799
+ * - Bundle present, decryption fails → `{ ok: false, reason: "decrypt_failed" }`.
34800
+ * - Bundle present, schema mismatch (version / thread_id) → `schema_mismatch`.
34801
+ * - Storage IO error → `io_failed`.
34802
+ */
34803
+ async readThreadStrict(threadId, opts) {
34804
+ const key = bundleKey(threadId);
34805
+ let raw;
34806
+ try {
34807
+ raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
34808
+ } catch {
34809
+ return { ok: false, reason: "io_failed" };
34810
+ }
34811
+ if (!raw) return { ok: true, turns: [] };
34812
+ if (raw.length > MAX_BUNDLE_BYTES3) {
34813
+ return { ok: false, reason: "oversize_bundle" };
34814
+ }
34815
+ let envelope;
34816
+ try {
34817
+ envelope = JSON.parse(bytesToString(raw));
34818
+ } catch {
34819
+ return { ok: false, reason: "schema_mismatch" };
34820
+ }
34821
+ let plaintext;
34822
+ try {
34823
+ const aad = stringToBytes(threadId);
34824
+ plaintext = decrypt(envelope, this.encryptionKey, aad);
34825
+ } catch {
34826
+ return { ok: false, reason: "decrypt_failed" };
34827
+ }
34828
+ let parsed;
34829
+ try {
34830
+ parsed = JSON.parse(bytesToString(plaintext));
34831
+ } catch {
34832
+ return { ok: false, reason: "schema_mismatch" };
34833
+ }
34834
+ if (parsed.version !== 1) return { ok: false, reason: "schema_mismatch" };
34835
+ if (parsed.thread_id !== threadId) {
34836
+ return { ok: false, reason: "schema_mismatch" };
34837
+ }
34838
+ let turns = parsed.turns;
34839
+ if (opts?.sinceTurnId !== void 0) {
34840
+ const cutoff = opts.sinceTurnId;
34841
+ turns = turns.filter((t) => t.turn_id > cutoff);
34842
+ }
34843
+ if (opts?.limit !== void 0) {
34844
+ turns = turns.slice(0, opts.limit);
34845
+ }
34846
+ return { ok: true, turns };
34847
+ }
33541
34848
  /**
33542
34849
  * Enumerate concierge threads in this fortress with summary metadata.
33543
34850
  * Sorted newest-first by last_turn_at.
@@ -33549,7 +34856,7 @@ var init_concierge_memory_store = __esm({
33549
34856
  );
33550
34857
  const summaries = [];
33551
34858
  for (const meta of entries) {
33552
- const threadId = stripKeyPrefix(meta.key);
34859
+ const threadId = stripKeyPrefix2(meta.key);
33553
34860
  if (threadId === null) continue;
33554
34861
  const bundle = await this.loadBundle(threadId);
33555
34862
  if (!bundle || bundle.turns.length === 0) continue;
@@ -33602,7 +34909,7 @@ var init_concierge_memory_store = __esm({
33602
34909
  );
33603
34910
  let pruned = 0;
33604
34911
  for (const meta of entries) {
33605
- const threadId = stripKeyPrefix(meta.key);
34912
+ const threadId = stripKeyPrefix2(meta.key);
33606
34913
  if (threadId === null) continue;
33607
34914
  pruned += await this.withLock(threadId, async () => {
33608
34915
  const bundle = await this.loadBundle(threadId);
@@ -33633,7 +34940,7 @@ var init_concierge_memory_store = __esm({
33633
34940
  return null;
33634
34941
  }
33635
34942
  if (!raw) return null;
33636
- if (raw.length > MAX_BUNDLE_BYTES2) return null;
34943
+ if (raw.length > MAX_BUNDLE_BYTES3) return null;
33637
34944
  try {
33638
34945
  const envelope = JSON.parse(bytesToString(raw));
33639
34946
  const aad = stringToBytes(threadId);
@@ -33724,7 +35031,18 @@ function buildV11Bindings(inputs) {
33724
35031
  registry
33725
35032
  }),
33726
35033
  conciergePiiFilter: buildConciergePiiFilter(),
33727
- conciergeMemory
35034
+ conciergeMemory,
35035
+ conciergeContextFetchers: buildConciergeContextFetchers({
35036
+ auditLog: inputs.auditLog,
35037
+ identityId: inputs.identityId,
35038
+ registry
35039
+ }),
35040
+ ...inputs.intelligenceSelector ? {
35041
+ conciergeContextLlmAssist: buildConciergeContextLlmAssist({
35042
+ selector: inputs.intelligenceSelector,
35043
+ identityId: inputs.identityId
35044
+ })
35045
+ } : {}
33728
35046
  });
33729
35047
  }
33730
35048
  const hubService = new HubService({
@@ -33785,6 +35103,107 @@ function buildConciergeContextProviders(args) {
33785
35103
  }
33786
35104
  };
33787
35105
  }
35106
+ function buildConciergeContextFetchers(args) {
35107
+ const empty = async () => "";
35108
+ return {
35109
+ templates: async () => {
35110
+ const entries = listTemplates();
35111
+ if (entries.length === 0) return "(no templates installed)";
35112
+ const lines = entries.map((e) => {
35113
+ const m = e.metadata;
35114
+ return `${m.name} (tier ${m.tier}, channel ${m.channel}, target ${m.target_archetype})`;
35115
+ });
35116
+ return lines.join("\n");
35117
+ },
35118
+ agent_state: async (agentNameHint) => {
35119
+ const records = args.registry.list({ identity_id: args.identityId });
35120
+ if (records.length === 0) return "(no wrapped agents)";
35121
+ const filtered = agentNameHint ? records.filter(
35122
+ (r) => r.agent_id.toLowerCase().includes(agentNameHint.toLowerCase()) || r.harness.toLowerCase().includes(agentNameHint.toLowerCase())
35123
+ ) : records;
35124
+ const target = filtered.length > 0 ? filtered : records;
35125
+ const lines = target.slice(0, 20).map((r) => {
35126
+ const tmpl = typeof r.channel_template_id === "string" ? r.channel_template_id : "no_template";
35127
+ return `${r.agent_id} harness=${r.harness} status=${r.status} template=${tmpl}`;
35128
+ });
35129
+ return lines.join("\n");
35130
+ },
35131
+ agent_activity: async (agentNameHint) => {
35132
+ const result = await args.auditLog.query({ limit: 50 });
35133
+ const owned = result.entries.filter(
35134
+ (e) => e.identity_id === args.identityId
35135
+ );
35136
+ const filtered = agentNameHint ? owned.filter((e) => {
35137
+ const agentId = e.details && typeof e.details["agent_id"] === "string" ? e.details["agent_id"] : "";
35138
+ return agentId.toLowerCase().includes(agentNameHint.toLowerCase());
35139
+ }) : owned;
35140
+ const tail = (filtered.length > 0 ? filtered : owned).slice(-20);
35141
+ if (tail.length === 0) return "(no activity)";
35142
+ return tail.map((e) => {
35143
+ const agentId = (e.details && typeof e.details["agent_id"] === "string" ? e.details["agent_id"] : null) ?? "_fortress";
35144
+ return `${e.timestamp} ${e.layer}.${e.operation} agent=${agentId} result=${e.result}`;
35145
+ }).join("\n");
35146
+ },
35147
+ audit_log: async () => {
35148
+ const result = await args.auditLog.query({ limit: 30 });
35149
+ const owned = result.entries.filter(
35150
+ (e) => e.identity_id === args.identityId
35151
+ );
35152
+ if (owned.length === 0) return "(no audit log entries)";
35153
+ return owned.slice(-30).map(
35154
+ (e) => `${e.timestamp} ${e.layer}.${e.operation} result=${e.result}`
35155
+ ).join("\n");
35156
+ },
35157
+ sentinel_findings: empty,
35158
+ anomaly_alerts: empty,
35159
+ recent_receipts: async () => {
35160
+ const result = await args.auditLog.query({ limit: 100 });
35161
+ const owned = result.entries.filter(
35162
+ (e) => e.identity_id === args.identityId && e.operation.startsWith("composition_")
35163
+ );
35164
+ if (owned.length === 0) return "(no recent composition events)";
35165
+ return owned.slice(-15).map((e) => `${e.timestamp} ${e.operation} result=${e.result}`).join("\n");
35166
+ },
35167
+ verascore_deltas: empty
35168
+ };
35169
+ }
35170
+ function buildConciergeContextLlmAssist(args) {
35171
+ return async (query, categories) => {
35172
+ const labelList = categories.map((c) => `- ${c}`).join("\n");
35173
+ const prompt2 = `You are a router. Classify the operator's query into one of the categories below or "none".
35174
+ Reply with exactly one token: one category name or "none".
35175
+
35176
+ Categories:
35177
+ ${labelList}
35178
+
35179
+ Query: ${query}
35180
+
35181
+ Category:`;
35182
+ try {
35183
+ const handle = await args.selector.getSubstrate("concierge");
35184
+ if (!handle.capability.summarize) return "none";
35185
+ const response = await args.selector.invokeSummarize("concierge", {
35186
+ kind: "summarize",
35187
+ context: prompt2,
35188
+ query: "Output the single category token.",
35189
+ maxTokens: 16
35190
+ });
35191
+ if (response.failureClass || response.body.kind !== "summarize") {
35192
+ return "none";
35193
+ }
35194
+ const raw = response.body.text.trim().toLowerCase();
35195
+ const head = raw.split(/\s|[.,!?:;]/)[0] ?? "";
35196
+ const normalized = head.replace(/[^a-z_]/g, "");
35197
+ const known = categories;
35198
+ if (known.includes(normalized)) {
35199
+ return normalized;
35200
+ }
35201
+ return "none";
35202
+ } catch {
35203
+ return "none";
35204
+ }
35205
+ };
35206
+ }
33788
35207
  function buildConciergePiiFilter() {
33789
35208
  return {
33790
35209
  filter(input) {
@@ -33812,6 +35231,7 @@ var init_wiring = __esm({
33812
35231
  init_agent_registry_persistence();
33813
35232
  init_operator_chat_index();
33814
35233
  init_privacy_filter();
35234
+ init_registry();
33815
35235
  CapabilityErrorAgentController = class {
33816
35236
  fail(action) {
33817
35237
  throw new HubCapabilityError(
@@ -33959,7 +35379,7 @@ var init_defaults = __esm({
33959
35379
  });
33960
35380
 
33961
35381
  // src/intelligence/policy-store.ts
33962
- var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO3, IntelligenceConfigStore;
35382
+ var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO4, IntelligenceConfigStore;
33963
35383
  var init_policy_store = __esm({
33964
35384
  "src/intelligence/policy-store.ts"() {
33965
35385
  init_encryption();
@@ -33968,13 +35388,13 @@ var init_policy_store = __esm({
33968
35388
  init_defaults();
33969
35389
  INTELLIGENCE_NAMESPACE = "_intelligence";
33970
35390
  SUBSTRATE_CONFIG_KEY = "substrate-config";
33971
- HKDF_INFO3 = "intelligence-substrate-config";
35391
+ HKDF_INFO4 = "intelligence-substrate-config";
33972
35392
  IntelligenceConfigStore = class {
33973
35393
  storage;
33974
35394
  encryptionKey;
33975
35395
  constructor(storage, masterKey) {
33976
35396
  this.storage = storage;
33977
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
35397
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO4);
33978
35398
  }
33979
35399
  /**
33980
35400
  * Load the operator's substrate config from disk. Returns the config
@@ -36435,7 +37855,7 @@ async function resolveSourceMasterKey(encryptedState, opts) {
36435
37855
  }
36436
37856
  return null;
36437
37857
  }
36438
- async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIdentityId) {
37858
+ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIdentityId, importedRekeyEntries) {
36439
37859
  const destinationSigner = opts.destinationSignerIdentityId ? opts.identityManager.get(opts.destinationSignerIdentityId) : opts.identityManager.getDefault();
36440
37860
  if (!destinationSigner) {
36441
37861
  return {
@@ -36497,8 +37917,9 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
36497
37917
  }
36498
37918
  }
36499
37919
  }
37920
+ let plaintext;
36500
37921
  try {
36501
- const plaintext = decrypt(
37922
+ plaintext = decrypt(
36502
37923
  item.entry.payload,
36503
37924
  deriveNamespaceKey(sourceMasterKey, item.namespace)
36504
37925
  );
@@ -36507,28 +37928,30 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
36507
37928
  skipped++;
36508
37929
  continue;
36509
37930
  }
36510
- await stateStore.write(
36511
- item.namespace,
36512
- item.key,
36513
- bytesToString(plaintext),
36514
- destinationSigner.identity_id,
36515
- destinationSigner.encrypted_private_key,
36516
- identityEncryptionKey,
36517
- {
36518
- content_type: item.entry.metadata.content_type,
36519
- ttl_seconds: item.entry.metadata.ttl_seconds,
36520
- tags: [
36521
- ...item.entry.metadata.tags ?? [],
36522
- "exit-import",
36523
- `source:${item.entry.kid}`
36524
- ]
36525
- }
36526
- );
36527
- imported++;
36528
37931
  } catch {
36529
37932
  skippedInvalidSig++;
36530
37933
  skipped++;
37934
+ continue;
36531
37935
  }
37936
+ await stateStore.write(
37937
+ item.namespace,
37938
+ item.key,
37939
+ bytesToString(plaintext),
37940
+ destinationSigner.identity_id,
37941
+ destinationSigner.encrypted_private_key,
37942
+ identityEncryptionKey,
37943
+ {
37944
+ content_type: item.entry.metadata.content_type,
37945
+ ttl_seconds: item.entry.metadata.ttl_seconds,
37946
+ tags: [
37947
+ ...item.entry.metadata.tags ?? [],
37948
+ "exit-import",
37949
+ `source:${item.entry.kid}`
37950
+ ]
37951
+ }
37952
+ );
37953
+ imported++;
37954
+ importedRekeyEntries?.push({ namespace: item.namespace, key: item.key });
36532
37955
  }
36533
37956
  return {
36534
37957
  status: "rekeyed",
@@ -36539,6 +37962,23 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
36539
37962
  conflicts
36540
37963
  };
36541
37964
  }
37965
+ async function cleanupStagedPaths(storage, staged) {
37966
+ let removed = 0;
37967
+ const failed = [];
37968
+ for (const loc of staged) {
37969
+ try {
37970
+ const ok2 = await storage.delete(loc.namespace, loc.key);
37971
+ if (ok2) {
37972
+ removed++;
37973
+ } else {
37974
+ failed.push(loc);
37975
+ }
37976
+ } catch {
37977
+ failed.push(loc);
37978
+ }
37979
+ }
37980
+ return { removed, failed };
37981
+ }
36542
37982
  async function stageArtifact(storage, namespace, key, value) {
36543
37983
  await storage.write(namespace, key, jsonBytes(value));
36544
37984
  }
@@ -36663,6 +38103,8 @@ async function importExitBundle(opts) {
36663
38103
  }
36664
38104
  const importId = importIdForManifest(manifest);
36665
38105
  const stagedArtifacts = [];
38106
+ const stagedLocations = [];
38107
+ const importedRekeyEntries = [];
36666
38108
  if (identityArtifact) {
36667
38109
  await stageArtifact(
36668
38110
  opts.storage,
@@ -36671,10 +38113,15 @@ async function importExitBundle(opts) {
36671
38113
  identityArtifact.json
36672
38114
  );
36673
38115
  stagedArtifacts.push("public_identity");
38116
+ stagedLocations.push({
38117
+ namespace: EXIT_PUBLIC_IDENTITIES_NAMESPACE,
38118
+ key: identityArtifact.json.bundle.identity_id
38119
+ });
36674
38120
  }
36675
38121
  if (policySet) {
36676
38122
  await stageArtifact(opts.storage, EXIT_POLICY_SETS_NAMESPACE, importId, policySet.json);
36677
38123
  stagedArtifacts.push("policy_set");
38124
+ stagedLocations.push({ namespace: EXIT_POLICY_SETS_NAMESPACE, key: importId });
36678
38125
  }
36679
38126
  if (auditReceipts) {
36680
38127
  await stageArtifact(
@@ -36684,10 +38131,12 @@ async function importExitBundle(opts) {
36684
38131
  auditReceipts.json
36685
38132
  );
36686
38133
  stagedArtifacts.push("audit_receipts");
38134
+ stagedLocations.push({ namespace: EXIT_AUDIT_RECEIPTS_NAMESPACE, key: importId });
36687
38135
  }
36688
38136
  if (commitments) {
36689
38137
  await stageArtifact(opts.storage, EXIT_COMMITMENTS_NAMESPACE, importId, commitments.json);
36690
38138
  stagedArtifacts.push("commitments");
38139
+ stagedLocations.push({ namespace: EXIT_COMMITMENTS_NAMESPACE, key: importId });
36691
38140
  }
36692
38141
  if (placeholderMetadata) {
36693
38142
  await stageArtifact(
@@ -36697,12 +38146,17 @@ async function importExitBundle(opts) {
36697
38146
  placeholderMetadata.json
36698
38147
  );
36699
38148
  stagedArtifacts.push("placeholder_vault_metadata");
38149
+ stagedLocations.push({
38150
+ namespace: EXIT_PLACEHOLDER_METADATA_NAMESPACE,
38151
+ key: importId
38152
+ });
36700
38153
  }
36701
38154
  await stageArtifact(opts.storage, EXIT_IMPORT_NAMESPACE, importId, {
36702
38155
  manifest: manifest.body,
36703
38156
  verified_at: verification.verified_at,
36704
38157
  activated_at: (/* @__PURE__ */ new Date()).toISOString()
36705
38158
  });
38159
+ stagedLocations.push({ namespace: EXIT_IMPORT_NAMESPACE, key: importId });
36706
38160
  const publicKeys = identityArtifact ? publicKeysFromIdentityArtifact(identityArtifact.json) : { byIdentityId: /* @__PURE__ */ new Map(), byDid: /* @__PURE__ */ new Map() };
36707
38161
  let reputationResult = {
36708
38162
  imported_attestations: 0,
@@ -36727,26 +38181,57 @@ async function importExitBundle(opts) {
36727
38181
  encryptedState?.json ?? null,
36728
38182
  opts
36729
38183
  );
36730
- const stateResult = encryptedState && encryptedState.json.entries.length > 0 ? sourceMasterKey ? await rekeyState(
36731
- encryptedState.json,
36732
- opts,
36733
- sourceMasterKey,
36734
- publicKeys.byIdentityId
36735
- ) : {
36736
- status: "staged_requires_source_key",
36737
- imported_keys: 0,
36738
- skipped_keys: encryptedState.json.entries.length,
36739
- skipped_invalid_sig: 0,
36740
- skipped_unknown_kid: 0,
36741
- conflicts: conflicts.state_conflicts.length
36742
- } : {
36743
- status: "not_requested",
36744
- imported_keys: 0,
36745
- skipped_keys: 0,
36746
- skipped_invalid_sig: 0,
36747
- skipped_unknown_kid: 0,
36748
- conflicts: 0
36749
- };
38184
+ let stateResult;
38185
+ try {
38186
+ stateResult = encryptedState && encryptedState.json.entries.length > 0 ? sourceMasterKey ? await rekeyState(
38187
+ encryptedState.json,
38188
+ opts,
38189
+ sourceMasterKey,
38190
+ publicKeys.byIdentityId,
38191
+ importedRekeyEntries
38192
+ ) : {
38193
+ status: "staged_requires_source_key",
38194
+ imported_keys: 0,
38195
+ skipped_keys: encryptedState.json.entries.length,
38196
+ skipped_invalid_sig: 0,
38197
+ skipped_unknown_kid: 0,
38198
+ conflicts: conflicts.state_conflicts.length
38199
+ } : {
38200
+ status: "not_requested",
38201
+ imported_keys: 0,
38202
+ skipped_keys: 0,
38203
+ skipped_invalid_sig: 0,
38204
+ skipped_unknown_kid: 0,
38205
+ conflicts: 0
38206
+ };
38207
+ } catch (err) {
38208
+ const toCleanup = [
38209
+ ...importedRekeyEntries,
38210
+ ...stagedLocations
38211
+ ];
38212
+ const cleanup = await cleanupStagedPaths(opts.storage, toCleanup);
38213
+ opts.auditLog.append(
38214
+ "l1",
38215
+ "exit_bundle_rekey_failed_cleanup",
38216
+ manifest.body.identity_binding.identity_id,
38217
+ {
38218
+ import_id: importId,
38219
+ manifest_version: manifest.body.manifest_version,
38220
+ rekey_entries_removed: importedRekeyEntries.length,
38221
+ staged_artifacts_removed: stagedLocations.length,
38222
+ removed_total: cleanup.removed,
38223
+ cleanup_failed_count: cleanup.failed.length,
38224
+ original_error: err instanceof Error ? err.message : String(err)
38225
+ },
38226
+ "failure"
38227
+ );
38228
+ await opts.auditLog.flush();
38229
+ const originalMessage = err instanceof Error ? err.message : String(err);
38230
+ throw new ExitBundleImportError(
38231
+ "REKEY_FAILED_AND_CLEANED",
38232
+ `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).`
38233
+ );
38234
+ }
36750
38235
  opts.auditLog.append("l1", "exit_bundle_import_activate", manifest.body.identity_binding.identity_id, {
36751
38236
  import_id: importId,
36752
38237
  manifest_version: manifest.body.manifest_version,
@@ -37825,16 +39310,35 @@ ${err.message}
37825
39310
  timestamp: alert.timestamp
37826
39311
  });
37827
39312
  } : void 0;
37828
- const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
37829
39313
  const fortressIdForAggregator = fortressIdFromStoragePath(config.storage_path);
37830
39314
  const aggregatorIdentityId = identityManager.getPrimaryIdentityId() ?? `fortress:${config.storage_path}`;
39315
+ const aggregatorPayloadStore = new AggregatorPayloadStore({
39316
+ storage,
39317
+ masterKey,
39318
+ fortressId: fortressIdForAggregator
39319
+ });
37831
39320
  const approvalAggregator = new ApprovalAggregator({
37832
39321
  storage,
37833
39322
  masterKey,
37834
39323
  auditLog,
37835
39324
  identityId: aggregatorIdentityId,
37836
- fortressId: fortressIdForAggregator
39325
+ fortressId: fortressIdForAggregator,
39326
+ payloadStore: aggregatorPayloadStore
39327
+ });
39328
+ const wrappedApprovalChannel = new AggregatorBackedChannel({
39329
+ underlying: approvalChannel,
39330
+ aggregator: approvalAggregator,
39331
+ resolveRedirect: makeRedirectResolverFromPolicySupplier(() => policy),
39332
+ replaceModeTimeoutMs: policy.approval_channel.timeout_seconds * 1e3
37837
39333
  });
39334
+ const gate = new ApprovalGate(
39335
+ policy,
39336
+ baseline,
39337
+ wrappedApprovalChannel,
39338
+ auditLog,
39339
+ injectionDetector,
39340
+ onInjectionAlert
39341
+ );
37838
39342
  gate.setApprovalEventCallback((event) => {
37839
39343
  void approvalAggregator.ingest(event);
37840
39344
  });
@@ -38032,6 +39536,8 @@ var init_src = __esm({
38032
39536
  init_webhook();
38033
39537
  init_gate();
38034
39538
  init_approval_aggregator();
39539
+ init_aggregator_backed_channel();
39540
+ init_aggregator_store();
38035
39541
  init_tools4();
38036
39542
  init_router();
38037
39543
  init_router();
@@ -40996,6 +42502,22 @@ var init_broker = __esm({
40996
42502
  auditLog;
40997
42503
  issuer;
40998
42504
  principalIdentityId;
42505
+ /**
42506
+ * Per-secret-name mutex. Hardening wave 6 finding #64: two concurrent
42507
+ * addSecret() / rotateSecret() / deleteSecret() calls on the same name
42508
+ * MUST serialize cleanly. The keychain backend's `find-then-add` and
42509
+ * `find-then-delete-then-add` shapes (KeychainBackend.addSecret /
42510
+ * .rotateSecret) are not atomic against another caller racing the same
42511
+ * service-name; without serialization the second caller can observe a
42512
+ * stale "exists" check and either drop the new value or leave a
42513
+ * duplicate keychain entry.
42514
+ *
42515
+ * Implementation: an in-memory promise chain per name. Subsequent
42516
+ * callers `await` the chain tail and append their own work; failures
42517
+ * propagate to the failing caller without poisoning the chain for
42518
+ * later callers.
42519
+ */
42520
+ nameLocks = /* @__PURE__ */ new Map();
40999
42521
  constructor(opts) {
41000
42522
  this.backend = opts.backend;
41001
42523
  this.auditLog = opts.auditLog;
@@ -41006,6 +42528,40 @@ var init_broker = __esm({
41006
42528
  grants: opts.grants
41007
42529
  });
41008
42530
  }
42531
+ /**
42532
+ * Serialize `op` against any other in-flight write to the same secret
42533
+ * `name`. Per-name fairness only, distinct names run in parallel.
42534
+ * The current chain tail is used as the acceptance gate; we then
42535
+ * publish a new tail that swallows the operation's outcome so a
42536
+ * thrown error does not poison the next caller's wait.
42537
+ */
42538
+ async withNameLock(name, op) {
42539
+ const previous = this.nameLocks.get(name) ?? Promise.resolve();
42540
+ let release = () => {
42541
+ };
42542
+ const next = new Promise((resolve8) => {
42543
+ release = resolve8;
42544
+ });
42545
+ this.nameLocks.set(name, next);
42546
+ try {
42547
+ await previous.catch(() => {
42548
+ });
42549
+ return await op();
42550
+ } finally {
42551
+ release();
42552
+ if (this.nameLocks.get(name) === next) {
42553
+ this.nameLocks.delete(name);
42554
+ }
42555
+ }
42556
+ }
42557
+ /**
42558
+ * Diagnostic-only: visible for tests so they can assert that distinct
42559
+ * names do not contend on a shared lock. Not part of the public broker
42560
+ * contract; do not consume from production code.
42561
+ */
42562
+ __nameLockCountForTests() {
42563
+ return this.nameLocks.size;
42564
+ }
41009
42565
  /** Ensure backend is initialized and unlocked. Audits the unlock. */
41010
42566
  async ensureUnlocked(passphrase) {
41011
42567
  await this.backend.ensureInitialized(passphrase);
@@ -41018,31 +42574,37 @@ var init_broker = __esm({
41018
42574
  );
41019
42575
  }
41020
42576
  async addSecret(name, value) {
41021
- await this.backend.addSecret(name, value);
41022
- this.auditLog.append(
41023
- "l3",
41024
- BROKER_OPS.SECRET_ADDED,
41025
- this.principalIdentityId,
41026
- { secret: name }
41027
- );
42577
+ await this.withNameLock(name, async () => {
42578
+ await this.backend.addSecret(name, value);
42579
+ this.auditLog.append(
42580
+ "l3",
42581
+ BROKER_OPS.SECRET_ADDED,
42582
+ this.principalIdentityId,
42583
+ { secret: name }
42584
+ );
42585
+ });
41028
42586
  }
41029
42587
  async rotateSecret(name, newValue) {
41030
- await this.backend.rotateSecret(name, newValue);
41031
- this.auditLog.append(
41032
- "l3",
41033
- BROKER_OPS.SECRET_ROTATED,
41034
- this.principalIdentityId,
41035
- { secret: name }
41036
- );
42588
+ await this.withNameLock(name, async () => {
42589
+ await this.backend.rotateSecret(name, newValue);
42590
+ this.auditLog.append(
42591
+ "l3",
42592
+ BROKER_OPS.SECRET_ROTATED,
42593
+ this.principalIdentityId,
42594
+ { secret: name }
42595
+ );
42596
+ });
41037
42597
  }
41038
42598
  async deleteSecret(name) {
41039
- await this.backend.deleteSecret(name);
41040
- this.auditLog.append(
41041
- "l3",
41042
- BROKER_OPS.SECRET_DELETED,
41043
- this.principalIdentityId,
41044
- { secret: name }
41045
- );
42599
+ await this.withNameLock(name, async () => {
42600
+ await this.backend.deleteSecret(name);
42601
+ this.auditLog.append(
42602
+ "l3",
42603
+ BROKER_OPS.SECRET_DELETED,
42604
+ this.principalIdentityId,
42605
+ { secret: name }
42606
+ );
42607
+ });
41046
42608
  }
41047
42609
  async listSecretNames() {
41048
42610
  return this.backend.listSecretNames();
@@ -41082,6 +42644,19 @@ var init_broker = __esm({
41082
42644
  liveTokenCount() {
41083
42645
  return this.issuer.liveTokenCount();
41084
42646
  }
42647
+ /**
42648
+ * Drop expired tokens from the in-memory issuer map. Hardening wave 6
42649
+ * finding #86: previously expiry pruning depended on opportunistic
42650
+ * `pruneExpired()` calls; now the cocoon-unlock initialization path
42651
+ * (openBroker -> after backend.ensureInitialized -> after Broker
42652
+ * construction) fires this once so each cocoon-unlock cycle drops
42653
+ * stale bindings before any operator interaction.
42654
+ *
42655
+ * Returns the number of tokens removed. Safe to call repeatedly; idempotent.
42656
+ */
42657
+ pruneExpiredTokens() {
42658
+ return this.issuer.pruneExpired();
42659
+ }
41085
42660
  /**
41086
42661
  * Audit query restricted to broker-scoped operations. Returns entries
41087
42662
  * with their timestamps, op, and result (never the secret value).
@@ -41240,6 +42815,7 @@ async function openBroker(opts = {}) {
41240
42815
  grants,
41241
42816
  principalIdentityId: opts.principalIdentityId ?? "sanctuary-broker"
41242
42817
  });
42818
+ broker.pruneExpiredTokens();
41243
42819
  return {
41244
42820
  broker,
41245
42821
  close: async () => {
@@ -42108,8 +43684,6 @@ var init_health = __esm({
42108
43684
  DEFAULT_TIMEOUT_MS4 = 500;
42109
43685
  }
42110
43686
  });
42111
-
42112
- // src/cli/agents/cli.ts
42113
43687
  function resolveCtx(args) {
42114
43688
  const env = args.env ?? process.env;
42115
43689
  const discoverOpts = {
@@ -42147,6 +43721,8 @@ async function runAgentsCommand(args) {
42147
43721
  return await cmdShow2(rest, ctx);
42148
43722
  case "status":
42149
43723
  return await cmdStatus(rest, ctx);
43724
+ case "config":
43725
+ return await cmdConfig(rest, ctx);
42150
43726
  default:
42151
43727
  ctx.err.write(`Unknown subcommand: ${sub}
42152
43728
  `);
@@ -42162,10 +43738,18 @@ async function runAgentsCommand(args) {
42162
43738
  }
42163
43739
  function printUsage4(s) {
42164
43740
  s.write(`Usage: sanctuary agents <command> [flags]
43741
+ sanctuary agent <command> [flags] (alias)
42165
43742
 
42166
43743
  list [--json] List every tenant visible on this host.
42167
- show <tenant> [--json] Show details for one tenant.
43744
+ show <tenant> [--json] Show details for one tenant (includes
43745
+ approval-redirect state).
42168
43746
  status [--json] One-line-per-tenant running/stopped summary.
43747
+ config <tenant> [opts] Write tenant principal-policy.yaml fields.
43748
+ --approval-redirect=<bool> Toggle cross-harness inbox redirect.
43749
+ --approval-redirect-mode=<replace|notify>
43750
+ Pick replace (bypass underlying channel)
43751
+ or notify (race both paths). Default
43752
+ replace when toggled on.
42169
43753
 
42170
43754
  Options:
42171
43755
  --fortress <path> Scope discovery to a specific storage path
@@ -42267,6 +43851,7 @@ async function cmdShow2(argv, ctx) {
42267
43851
  return 1;
42268
43852
  }
42269
43853
  const probe = await ctx.probe(tenant);
43854
+ const approvalRedirect = await readApprovalRedirectState(tenant);
42270
43855
  const payload = {
42271
43856
  name: tenant.name,
42272
43857
  storage_path: tenant.storage_path,
@@ -42280,7 +43865,8 @@ async function cmdShow2(argv, ctx) {
42280
43865
  running: probe.running,
42281
43866
  status: probe.status,
42282
43867
  reason: probe.reason
42283
- }
43868
+ },
43869
+ approval_redirect: approvalRedirect
42284
43870
  };
42285
43871
  if (hasJsonFlag(argv)) {
42286
43872
  ctx.out.write(JSON.stringify(payload, null, 2) + "\n");
@@ -42324,10 +43910,173 @@ async function cmdShow2(argv, ctx) {
42324
43910
  }
42325
43911
  ctx.out.write(
42326
43912
  `probe: ${probe.running ? "running" : "not-running"}${probe.reason ? ` (${probe.reason})` : ""}
43913
+ `
43914
+ );
43915
+ ctx.out.write(
43916
+ `approval_redirect: ${approvalRedirect.enabled ? `on (${approvalRedirect.mode})` : "off"}
42327
43917
  `
42328
43918
  );
42329
43919
  return 0;
42330
43920
  }
43921
+ async function readApprovalRedirectState(tenant) {
43922
+ const policyPath = join(tenant.storage_path, "principal-policy.yaml");
43923
+ try {
43924
+ const content = await readFile(policyPath, "utf-8");
43925
+ const parsed = parsePolicy(content);
43926
+ const cfg = parsed.approval_redirect;
43927
+ if (!cfg) return { enabled: false, mode: "replace" };
43928
+ return {
43929
+ enabled: !!cfg.enabled,
43930
+ mode: cfg.mode === "notify" ? "notify" : "replace"
43931
+ };
43932
+ } catch {
43933
+ return { enabled: false, mode: "replace" };
43934
+ }
43935
+ }
43936
+ function parseBoolFlag(raw) {
43937
+ if (raw === void 0) return null;
43938
+ const v = raw.toLowerCase();
43939
+ if (v === "true" || v === "yes" || v === "on" || v === "1") return true;
43940
+ if (v === "false" || v === "no" || v === "off" || v === "0") return false;
43941
+ return null;
43942
+ }
43943
+ function findFlagValue(argv, name) {
43944
+ for (let i = 0; i < argv.length; i++) {
43945
+ const a = argv[i];
43946
+ if (a === name) {
43947
+ return argv[i + 1];
43948
+ }
43949
+ const eq = `${name}=`;
43950
+ if (a.startsWith(eq)) {
43951
+ return a.slice(eq.length);
43952
+ }
43953
+ }
43954
+ return void 0;
43955
+ }
43956
+ async function cmdConfig(argv, ctx) {
43957
+ const positional = argv.find((a) => !a.startsWith("--"));
43958
+ if (!positional) {
43959
+ ctx.err.write(
43960
+ "Missing tenant. Usage: sanctuary agents config <tenant> --approval-redirect=<bool>\n"
43961
+ );
43962
+ return 2;
43963
+ }
43964
+ const tenant = await findTenant(positional, ctx.discoverOpts);
43965
+ if (!tenant) {
43966
+ ctx.err.write(`sanctuary agents: unknown tenant "${positional}"
43967
+ `);
43968
+ return 1;
43969
+ }
43970
+ const redirectFlag = parseBoolFlag(
43971
+ findFlagValue(argv, "--approval-redirect")
43972
+ );
43973
+ const modeFlag = findFlagValue(argv, "--approval-redirect-mode");
43974
+ if (redirectFlag === null && modeFlag === void 0) {
43975
+ ctx.err.write(
43976
+ "sanctuary agents config: nothing to do. Pass --approval-redirect=<bool> or --approval-redirect-mode=<replace|notify>.\n"
43977
+ );
43978
+ return 2;
43979
+ }
43980
+ if (modeFlag !== void 0 && modeFlag !== "replace" && modeFlag !== "notify") {
43981
+ ctx.err.write(
43982
+ `sanctuary agents config: --approval-redirect-mode must be "replace" or "notify" (got "${modeFlag}")
43983
+ `
43984
+ );
43985
+ return 2;
43986
+ }
43987
+ const current = await readApprovalRedirectState(tenant);
43988
+ const next = {
43989
+ enabled: redirectFlag !== null ? redirectFlag : current.enabled,
43990
+ mode: modeFlag === "notify" || modeFlag === "replace" ? modeFlag : current.mode
43991
+ };
43992
+ await writeApprovalRedirectToPolicyFile(tenant.storage_path, next);
43993
+ if (hasJsonFlag(argv)) {
43994
+ ctx.out.write(
43995
+ JSON.stringify(
43996
+ {
43997
+ tenant: tenant.name,
43998
+ approval_redirect: next
43999
+ },
44000
+ null,
44001
+ 2
44002
+ ) + "\n"
44003
+ );
44004
+ } else {
44005
+ ctx.out.write(
44006
+ `sanctuary agents config: tenant "${tenant.name}" approval_redirect=${next.enabled ? `on (${next.mode})` : "off"}
44007
+ `
44008
+ );
44009
+ ctx.out.write(
44010
+ ` Takes effect on the next gate request for the running server.
44011
+ `
44012
+ );
44013
+ }
44014
+ return 0;
44015
+ }
44016
+ async function writeApprovalRedirectToPolicyFile(storagePath, state) {
44017
+ const policyPath = join(storagePath, "principal-policy.yaml");
44018
+ let content;
44019
+ try {
44020
+ content = await readFile(policyPath, "utf-8");
44021
+ } catch (err) {
44022
+ const code = err?.code;
44023
+ if (code !== "ENOENT") throw err;
44024
+ content = await defaultPolicyTextForBootstrap();
44025
+ }
44026
+ const block = renderApprovalRedirectBlock(state);
44027
+ const updated = upsertApprovalRedirectBlock(content, block);
44028
+ await writeFile(policyPath, updated, "utf-8");
44029
+ await chmod(policyPath, 384);
44030
+ }
44031
+ function renderApprovalRedirectBlock(state) {
44032
+ return [
44033
+ "# Approval Redirect (v1.3 WP-V1.3-10 Upsilon-2)",
44034
+ "approval_redirect:",
44035
+ ` enabled: ${state.enabled ? "true" : "false"}`,
44036
+ ` mode: ${state.mode}`
44037
+ ].join("\n");
44038
+ }
44039
+ function upsertApprovalRedirectBlock(content, block) {
44040
+ const lines = content.split("\n");
44041
+ const startIdx = lines.findIndex((l) => l.startsWith("approval_redirect:"));
44042
+ if (startIdx === -1) {
44043
+ const trimmed = content.endsWith("\n") ? content : content + "\n";
44044
+ return trimmed + "\n" + block + "\n";
44045
+ }
44046
+ let blockStart = startIdx;
44047
+ if (blockStart > 0 && lines[blockStart - 1] !== void 0 && lines[blockStart - 1].startsWith("# Approval Redirect")) {
44048
+ blockStart = blockStart - 1;
44049
+ }
44050
+ let blockEnd = startIdx + 1;
44051
+ while (blockEnd < lines.length) {
44052
+ const l = lines[blockEnd];
44053
+ if (l === "") {
44054
+ blockEnd++;
44055
+ continue;
44056
+ }
44057
+ if (/^[A-Za-z0-9#]/.test(l)) {
44058
+ break;
44059
+ }
44060
+ blockEnd++;
44061
+ }
44062
+ const before = lines.slice(0, blockStart);
44063
+ const after = lines.slice(blockEnd);
44064
+ const replaced = [...before, ...block.split("\n"), ...after].join("\n");
44065
+ return replaced.endsWith("\n") ? replaced : replaced + "\n";
44066
+ }
44067
+ async function defaultPolicyTextForBootstrap() {
44068
+ return [
44069
+ "version: 1",
44070
+ "tier1_always_approve:",
44071
+ " - state_export",
44072
+ " - state_import",
44073
+ " - state_delete",
44074
+ "approval_channel:",
44075
+ " type: stderr",
44076
+ " timeout_seconds: 300",
44077
+ ""
44078
+ ].join("\n");
44079
+ }
42331
44080
  async function cmdStatus(argv, ctx) {
42332
44081
  const tenants = await discoverTenants(ctx.discoverOpts);
42333
44082
  const probes = await Promise.all(tenants.map((t) => ctx.probe(t)));
@@ -42368,6 +44117,7 @@ var init_cli5 = __esm({
42368
44117
  "src/cli/agents/cli.ts"() {
42369
44118
  init_discovery();
42370
44119
  init_health();
44120
+ init_loader();
42371
44121
  }
42372
44122
  });
42373
44123
 
@@ -43796,7 +45546,7 @@ async function main() {
43796
45546
  const code = await runIdentityCommand2({ argv: args.slice(1) });
43797
45547
  process.exit(code);
43798
45548
  }
43799
- if (args[0] === "agents") {
45549
+ if (args[0] === "agents" || args[0] === "agent") {
43800
45550
  const { runAgentsCommand: runAgentsCommand2 } = await Promise.resolve().then(() => (init_agents(), agents_exports));
43801
45551
  const code = await runAgentsCommand2({ argv: args.slice(1) });
43802
45552
  process.exit(code);